* For machines first seen in the range, the share still active k days later. * * Read entirely off `machine_days` + `machine_first_seen`, neither of which the * retention purge touches, so this answers for any range in history. * * The denominator is per-k, not the whole cohort: a machine first
(env: Env, range: Range)
| 702 | * elapsed contributes zero to day k by construction. |
| 703 | */ |
| 704 | async function retention(env: Env, range: Range): Promise<ApiResult> { |
| 705 | const batch = await env.DB.batch([ |
| 706 | env.DB.prepare( |
| 707 | `SELECT CAST(julianday(d.day) - julianday(f.first_day) AS INTEGER) AS k, |
| 708 | count(DISTINCT d.machine_id) AS machines |
| 709 | FROM machine_first_seen f |
| 710 | JOIN machine_days d ON d.machine_id = f.machine_id |
| 711 | WHERE f.first_day BETWEEN ? AND ? |
| 712 | AND d.day >= f.first_day |
| 713 | AND d.day <= date(f.first_day, ?) |
| 714 | GROUP BY k`, |
| 715 | ).bind(range.from, range.to, `+${RETENTION_DAYS} days`), |
| 716 | env.DB.prepare( |
| 717 | `SELECT first_day AS day, count(*) AS machines |
| 718 | FROM machine_first_seen WHERE first_day BETWEEN ? AND ? GROUP BY first_day`, |
| 719 | ).bind(range.from, range.to), |
| 720 | env.DB.prepare(`SELECT max(day) AS day FROM machine_days`), |
| 721 | ]); |
| 722 | |
| 723 | const retained = new Map( |
| 724 | rowsOf<{ k: number; machines: number }>(batch[0]).map((r) => [r.k, r.machines ?? 0]), |
| 725 | ); |
| 726 | const cohortDays = rowsOf<{ day: string; machines: number }>(batch[1]); |
| 727 | const cohortSize = cohortDays.reduce((n, r) => n + (r.machines ?? 0), 0); |
| 728 | const latestDay = firstOf<{ day: string | null }>(batch[2])?.day ?? utcDay(Date.now()); |
| 729 | |
| 730 | const rows = []; |
| 731 | for (let k = 0; k <= RETENTION_DAYS; k++) { |
| 732 | // Machines whose first day is early enough that day k has already happened. |
| 733 | const cutoff = addDays(latestDay, -k); |
| 734 | const eligible = cohortDays.reduce((n, r) => (r.day <= cutoff ? n + (r.machines ?? 0) : n), 0); |
| 735 | const back = retained.get(k) ?? 0; |
| 736 | rows.push({ |
| 737 | day: k, |
| 738 | eligible, |
| 739 | retained: back, |
| 740 | rate: eligible > 0 ? back / eligible : null, |
| 741 | }); |
| 742 | } |
| 743 | |
| 744 | return { |
| 745 | body: { |
| 746 | range, |
| 747 | cohort: cohortSize, |
| 748 | window_days: RETENTION_DAYS, |
| 749 | labels: rows.map((r) => `Day ${r.day}`), |
| 750 | datasets: [ |
| 751 | { |
| 752 | label: 'Retained', |
| 753 | data: rows.map((r) => (r.rate === null ? null : Math.round(r.rate * 1000) / 10)), |
| 754 | }, |
| 755 | ], |
| 756 | rows, |
| 757 | }, |
| 758 | cacheControl: CACHE_CONTROL, |
| 759 | }; |
| 760 | } |
| 761 |