* Of the machines whose FIRST day falls in the range, how many ran an index * within `window` days of it. * * The cohort key is `machine_first_seen`, not install events: a machine that * reinstalls does not re-enter the funnel, which is what makes this a * conversion rate rather than an install
(env: Env, url: URL, range: Range)
| 607 | * says so rather than drawing a cliff and calling it a drop in conversion. |
| 608 | */ |
| 609 | async function activation(env: Env, url: URL, range: Range): Promise<ApiResult> { |
| 610 | const rawWindow = url.searchParams.get('window'); |
| 611 | const window = rawWindow === null ? DEFAULT_ACTIVATION_WINDOW : Number(rawWindow); |
| 612 | if (!Number.isInteger(window) || window < 1 || window > MAX_ACTIVATION_WINDOW) { |
| 613 | return fail(`window must be an integer between 1 and ${MAX_ACTIVATION_WINDOW}`); |
| 614 | } |
| 615 | |
| 616 | const batch = await env.DB.batch([ |
| 617 | env.DB.prepare( |
| 618 | `SELECT f.first_day AS day, |
| 619 | count(DISTINCT f.machine_id) AS installs, |
| 620 | count(DISTINCT CASE WHEN e.machine_id IS NOT NULL THEN f.machine_id END) AS activated |
| 621 | FROM machine_first_seen f |
| 622 | LEFT JOIN events e |
| 623 | ON e.machine_id = f.machine_id |
| 624 | AND e.event = 'index' |
| 625 | AND e.day >= f.first_day |
| 626 | AND e.day <= date(f.first_day, ?) |
| 627 | WHERE f.first_day BETWEEN ? AND ? |
| 628 | GROUP BY f.first_day`, |
| 629 | // A bound modifier string, built from an integer this function validated — |
| 630 | // date() takes the modifier as data, so nothing is concatenated into SQL. |
| 631 | ).bind(`+${window} days`, range.from, range.to), |
| 632 | env.DB.prepare(`SELECT min(day) AS raw_from, max(day) AS raw_to FROM events`), |
| 633 | ]); |
| 634 | |
| 635 | const rows = rowsOf<ActivationRow>(batch[0]); |
| 636 | const byDay = new Map(rows.map((r) => [r.day, r])); |
| 637 | const labels = dayList(range); |
| 638 | |
| 639 | const installs = rows.reduce((n, r) => n + (r.installs ?? 0), 0); |
| 640 | const activated = rows.reduce((n, r) => n + (r.activated ?? 0), 0); |
| 641 | |
| 642 | // Cohorts younger than the window have not finished converting yet, so their |
| 643 | // rate is a floor, not a result. Marked rather than dropped: hiding the last |
| 644 | // week of a conversion chart is its own kind of lie. |
| 645 | const boundsRow = firstOf<{ raw_from: string | null; raw_to: string | null }>(batch[1]); |
| 646 | const latestRaw = boundsRow?.raw_to ?? utcDay(Date.now()); |
| 647 | const incompleteFrom = addDays(latestRaw, -(window - 1)); |
| 648 | |
| 649 | const detail = labels.map((day) => { |
| 650 | const row = byDay.get(day); |
| 651 | const dayInstalls = row?.installs ?? 0; |
| 652 | const dayActivated = row?.activated ?? 0; |
| 653 | return { |
| 654 | day, |
| 655 | installs: dayInstalls, |
| 656 | activated: dayActivated, |
| 657 | rate: dayInstalls > 0 ? dayActivated / dayInstalls : null, |
| 658 | complete: day < incompleteFrom, |
| 659 | }; |
| 660 | }); |
| 661 | |
| 662 | return { |
| 663 | body: { |
| 664 | range, |
| 665 | window_days: window, |
| 666 | installs, |