| 368 | }; |
| 369 | |
| 370 | async function timeseries(env: Env, url: URL, range: Range): Promise<ApiResult> { |
| 371 | const metric = url.searchParams.get('metric') ?? 'installs_uninstalls'; |
| 372 | |
| 373 | // The one metric whose series are data-driven rather than fixed: one line per |
| 374 | // duration bucket, in bucket order (an ordered scale, so the order is meaning). |
| 375 | if (metric === 'duration_buckets') return durationBucketSeries(env, range); |
| 376 | |
| 377 | const spec = SERIES[metric]; |
| 378 | if (!spec) { |
| 379 | return fail(`unknown metric — one of: ${[...Object.keys(SERIES), 'duration_buckets'].join(', ')}`); |
| 380 | } |
| 381 | |
| 382 | const { results } = await env.DB.prepare(spec.sql) |
| 383 | .bind(...spec.binds(range)) |
| 384 | .all<DayValueRow>(); |
| 385 | |
| 386 | const labels = dayList(range); |
| 387 | const a = new Map<string, number>(); |
| 388 | const b = new Map<string, number>(); |
| 389 | for (const row of results) { |
| 390 | a.set(row.day, row.a ?? 0); |
| 391 | b.set(row.day, row.b ?? 0); |
| 392 | } |
| 393 | |
| 394 | const datasets = [{ label: spec.labels[0], data: densify(labels, a) }]; |
| 395 | if (spec.labels.length === 2) datasets.push({ label: spec.labels[1], data: densify(labels, b) }); |
| 396 | |
| 397 | return { |
| 398 | body: { |
| 399 | range, |
| 400 | metric, |
| 401 | title: spec.title, |
| 402 | labels, |
| 403 | datasets, |
| 404 | rows: labels.map((day, i) => ({ |
| 405 | day, |
| 406 | ...Object.fromEntries(datasets.map((d) => [d.label, d.data[i] ?? 0])), |
| 407 | })), |
| 408 | }, |
| 409 | cacheControl: CACHE_CONTROL, |
| 410 | }; |
| 411 | } |
| 412 | |
| 413 | /** "Session run length over time": one series per duration bucket, bucket-ordered. */ |
| 414 | async function durationBucketSeries(env: Env, range: Range): Promise<ApiResult> { |