-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathworker.js
More file actions
342 lines (270 loc) · 10.8 KB
/
worker.js
File metadata and controls
342 lines (270 loc) · 10.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
/**
* CORS headers for cross-origin requests
*/
const cors_headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type"
}
/**
* Create JSON response with CORS headers
*/
function json_response(data, status = 200) {
const body = JSON.stringify(data)
const headers = {
"Content-Type": "application/json",
...cors_headers
}
return new Response(body, { status, headers })
}
/**
* Get today's date in user's local timezone as YYYY-MM-DD
* @param {number} timezone_offset_minutes - Timezone offset in minutes (e.g., -300 for UTC-5)
*/
function get_local_today(timezone_offset_minutes) {
const now = new Date()
const utc_ms = now.getTime()
// Add offset to get local time (offset is in minutes)
const local_ms = utc_ms + (timezone_offset_minutes * 60 * 1000)
const local_date = new Date(local_ms)
const year = local_date.getUTCFullYear()
const month = String(local_date.getUTCMonth() + 1).padStart(2, "0")
const day = String(local_date.getUTCDate()).padStart(2, "0")
return `${year}-${month}-${day}`
}
/**
* Get the Monday of the current week in user's local timezone as YYYY-MM-DD
* @param {number} timezone_offset_minutes - Timezone offset in minutes
*/
function get_local_week_start(timezone_offset_minutes) {
const now = new Date()
const utc_ms = now.getTime()
// Add offset to get local time
const local_ms = utc_ms + (timezone_offset_minutes * 60 * 1000)
const local_date = new Date(local_ms)
const day_of_week = local_date.getUTCDay()
// Calculate days to subtract to get to Monday (Sunday = 0, Monday = 1, etc.)
const days_to_monday = day_of_week === 0 ? 6 : day_of_week - 1
const monday = new Date(local_ms - (days_to_monday * 24 * 60 * 60 * 1000))
const year = monday.getUTCFullYear()
const month = String(monday.getUTCMonth() + 1).padStart(2, "0")
const day = String(monday.getUTCDate()).padStart(2, "0")
return `${year}-${month}-${day}`
}
export default {
async fetch(request, env) {
// Handle CORS preflight
if (request.method === "OPTIONS") {
return new Response(null, { status: 204, headers: cors_headers })
}
try {
if (request.method !== "GET") {
return json_response({ "error": "method must be GET" }, 405)
}
const request_url = new URL(request.url)
const pathname = request_url.pathname
// Route: /leaderboard - Get leaderboard data (public)
if (pathname === "/leaderboard") {
return await handle_leaderboard(request_url, env)
}
// Route: / - Update user stats (requires key)
return await handle_sync(request_url, env)
} catch (err) {
return json_response({ "error": String(err) }, 500)
}
}
}
/**
* Handle leaderboard request - returns users sorted by specified metric
* Query params:
* - type: overall, daily, weekly, longest_session, total_awards
* - tz: timezone offset in minutes (required for daily/weekly to calculate "today")
*/
async function handle_leaderboard(request_url, env) {
const users_db = env.DB
if (!users_db) {
return json_response({ "error": "database binding not found (env.DB)" }, 500)
}
// Get leaderboard type from query param
const leaderboard_type = request_url.searchParams.get("type") || "total_steps"
// Handle different leaderboard types
if (leaderboard_type === "overall") {
const query = "SELECT name, total_steps, total_calories, total_time, best_streak FROM users ORDER BY total_steps DESC"
const result = await users_db.prepare(query).all()
const rows = result && result.results ? result.results : result
const leaderboard = (rows || []).map((row, index) => ({
rank: index + 1,
name: row.name || "Anonymous",
steps: row.total_steps || 0,
calories: row.total_calories || 0,
total_time: row.total_time || 0,
best_streak: row.best_streak || 0
}))
return json_response({ "ok": true, "type": "overall", "leaderboard": leaderboard }, 200)
}
if (leaderboard_type === "daily") {
// Get all users with their daily stats and timezone
const query = "SELECT name, total_daily_steps, total_daily_calories, daily_date, timezone_offset FROM users ORDER BY total_daily_steps DESC"
const result = await users_db.prepare(query).all()
const rows = result && result.results ? result.results : result
// Filter to users whose stored daily_date matches their own local "today"
// This shows everyone's current day stats, regardless of cross-timezone differences
const leaderboard = (rows || [])
.filter(row => {
const user_local_today = get_local_today(row.timezone_offset || 0)
return row.daily_date === user_local_today
})
.map((row, index) => ({
rank: index + 1,
name: row.name || "Anonymous",
steps: row.total_daily_steps || 0,
calories: row.total_daily_calories || 0
}))
return json_response({ "ok": true, "type": "daily", "leaderboard": leaderboard }, 200)
}
if (leaderboard_type === "weekly") {
// Get all users with their weekly stats and timezone
const query = "SELECT name, total_weekly_steps, total_weekly_calories, weekly_start_date, timezone_offset FROM users ORDER BY total_weekly_steps DESC"
const result = await users_db.prepare(query).all()
const rows = result && result.results ? result.results : result
// Filter to users whose stored weekly_start_date matches their own local week start
const leaderboard = (rows || [])
.filter(row => {
const user_local_week_start = get_local_week_start(row.timezone_offset || 0)
return row.weekly_start_date === user_local_week_start
})
.map((row, index) => ({
rank: index + 1,
name: row.name || "Anonymous",
steps: row.total_weekly_steps || 0,
calories: row.total_weekly_calories || 0
}))
return json_response({ "ok": true, "type": "weekly", "leaderboard": leaderboard }, 200)
}
if (leaderboard_type === "longest_session") {
const query = "SELECT name, longest_session FROM users ORDER BY longest_session DESC"
const result = await users_db.prepare(query).all()
const rows = result && result.results ? result.results : result
const leaderboard = (rows || []).map((row, index) => ({
rank: index + 1,
name: row.name || "Anonymous",
value: row.longest_session || 0
}))
return json_response({ "ok": true, "type": "longest_session", "leaderboard": leaderboard }, 200)
}
if (leaderboard_type === "total_awards") {
const query = "SELECT name, total_awards FROM users ORDER BY total_awards DESC"
const result = await users_db.prepare(query).all()
const rows = result && result.results ? result.results : result
const leaderboard = (rows || []).map((row, index) => ({
rank: index + 1,
name: row.name || "Anonymous",
value: row.total_awards || 0
}))
return json_response({ "ok": true, "type": "total_awards", "leaderboard": leaderboard }, 200)
}
return json_response({ "error": "invalid leaderboard type" }, 400)
}
/**
* Handle sync request - update user's stats
* Query params:
* - key: secret key (required)
* - tz: timezone offset in minutes (required, e.g., 0 for UK, -300 for EST, 780 for NZ)
* - total_steps: lifetime total steps
* - daily_steps: today's steps (maps to total_daily_steps)
* - weekly_steps: this week's steps (maps to total_weekly_steps)
* - daily_calories: today's calories (maps to total_daily_calories)
* - weekly_calories: this week's calories (maps to total_weekly_calories)
* - longest_session: longest session in minutes
* - total_awards: total awards count
*/
async function handle_sync(request_url, env) {
const api_key = request_url.searchParams.get("key")
if (!api_key) {
return json_response({ "error": "missing key parameter" }, 400)
}
const users_db = env.DB
if (!users_db) {
return json_response({ "error": "database binding not found (env.DB)" }, 500)
}
// Get timezone offset from request (in minutes)
const tz_param = request_url.searchParams.get("tz")
if (tz_param === null) {
return json_response({ "error": "missing tz (timezone offset) parameter" }, 400)
}
const timezone_offset = parseInt(tz_param, 10)
if (isNaN(timezone_offset) || timezone_offset < -720 || timezone_offset > 840) {
return json_response({ "error": "invalid tz value (must be between -720 and 840 minutes)" }, 400)
}
// Look up user by key
const select_result = await users_db.prepare("SELECT id, daily_date, weekly_start_date FROM users WHERE key = ?").bind(api_key).all()
const select_rows = select_result && select_result.results ? select_result.results : select_result
if (!select_rows || select_rows.length === 0) {
return json_response({ "error": "key not found" }, 404)
}
const user = select_rows[0]
const user_id = user.id
const stored_daily_date = user.daily_date || null
const stored_weekly_start = user.weekly_start_date || null
// Calculate user's local "today" and "week start" based on their timezone
const user_local_today = get_local_today(timezone_offset)
const user_local_week_start = get_local_week_start(timezone_offset)
// Determine if daily/weekly need reset (new day/week for the user)
const is_new_day = stored_daily_date !== user_local_today
const is_new_week = stored_weekly_start !== user_local_week_start
// Map API params to database columns
const param_to_column = {
"total_steps": "total_steps",
"total_calories": "total_calories",
"daily_steps": "total_daily_steps",
"weekly_steps": "total_weekly_steps",
"daily_calories": "total_daily_calories",
"weekly_calories": "total_weekly_calories",
"longest_session": "longest_session",
"total_awards": "total_awards",
"total_time": "total_time",
"best_streak": "best_streak"
}
// Build dynamic update based on provided params
const updates = []
const values = []
for (const [param, column] of Object.entries(param_to_column)) {
const value = request_url.searchParams.get(param)
if (value !== null) {
const num_value = Number(value)
if (!Number.isInteger(num_value) || num_value < 0 || num_value > 100000000) {
return json_response({ "error": `invalid ${param} value` }, 400)
}
updates.push(`${column} = ?`)
values.push(num_value)
}
}
if (updates.length === 0) {
return json_response({ "error": "no valid parameters provided" }, 400)
}
// Always update timezone_offset
updates.push("timezone_offset = ?")
values.push(timezone_offset)
// Update daily_date if it's a new day
if (is_new_day) {
updates.push("daily_date = ?")
values.push(user_local_today)
}
// Update weekly_start_date if it's a new week
if (is_new_week) {
updates.push("weekly_start_date = ?")
values.push(user_local_week_start)
}
// Add user_id to values for WHERE clause
values.push(user_id)
const update_query = `UPDATE users SET ${updates.join(", ")}, last_synced_at = CURRENT_TIMESTAMP WHERE id = ?`
await users_db.prepare(update_query).bind(...values).run()
return json_response({
"ok": true,
"updated_fields": updates.length,
"daily_date": user_local_today,
"weekly_start_date": user_local_week_start,
"new_day": is_new_day,
"new_week": is_new_week
}, 200)
}