From 75b13280ef8999b2e32558acfe9896b636edcede Mon Sep 17 00:00:00 2001 From: George Powell Date: Sun, 15 Mar 2026 02:09:55 -0400 Subject: [PATCH] feat: add return rate and retention metrics to global stats - Overall return rate: % of all-time players who played more than once - New player return rate: 7-day rolling avg of daily first-timer return rates, with velocity vs prior 7 days - 7-day and 30-day retention over time: per-cohort-day retention series Co-Authored-By: Claude Sonnet 4.6 --- src/routes/global/+page.server.ts | 130 ++++++++++++++++++++++++- src/routes/global/+page.svelte | 154 +++++++++++++++++++++++++++++- 2 files changed, 280 insertions(+), 4 deletions(-) diff --git a/src/routes/global/+page.server.ts b/src/routes/global/+page.server.ts index ad30bbc..9eaee43 100644 --- a/src/routes/global/+page.server.ts +++ b/src/routes/global/+page.server.ts @@ -15,6 +15,12 @@ function prevDay(d: string): string { return dt.toISOString().slice(0, 10); } +function addDays(d: string, n: number): string { + const dt = new Date(d + 'T00:00:00Z'); + dt.setUTCDate(dt.getUTCDate() + n); + return dt.toISOString().slice(0, 10); +} + export const load: PageServerLoad = async () => { const todayEst = estDateStr(0); const yesterdayEst = estDateStr(1); @@ -120,12 +126,17 @@ export const load: PageServerLoad = async () => { .where(gte(dailyCompletions.date, ninetyDaysAgo)) .orderBy(asc(dailyCompletions.date)); - // Group dates by user (ascending) + // Group dates by user (ascending) and users by date const userDatesMap = new Map(); + const dateUsersMap = new Map>(); for (const row of recentCompletions) { const arr = userDatesMap.get(row.anonymousId); if (arr) arr.push(row.date); else userDatesMap.set(row.anonymousId, [row.date]); + + let s = dateUsersMap.get(row.date); + if (!s) { s = new Set(); dateUsersMap.set(row.date, s); } + s.add(row.anonymousId); } // ── Streak distribution ─────────────────────────────────────────────────── @@ -177,7 +188,8 @@ export const load: PageServerLoad = async () => { const firstDates = await db .select({ anonymousId: dailyCompletions.anonymousId, - firstDate: min(dailyCompletions.date) + firstDate: min(dailyCompletions.date), + totalCompletions: count() }) .from(dailyCompletions) .groupBy(dailyCompletions.anonymousId); @@ -190,6 +202,109 @@ export const load: PageServerLoad = async () => { // Net growth = truly new arrivals minus departures const netGrowth7d = newUsers7d - churned7d; + // ── Return rate ─────────────────────────────────────────────────────────── + // "Return rate": % of all-time unique players who have ever played more than once. + const playersWithReturn = firstDates.filter((r) => r.totalCompletions >= 2).length; + const overallReturnRate = + firstDates.length > 0 + ? Math.round((playersWithReturn / firstDates.length) * 1000) / 10 + : null; + + // Daily new-player return rate: for each day D, what % of first-time players + // on D ever came back (i.e. totalCompletions >= 2)? + const dailyNewPlayerReturn = new Map(); + for (const r of firstDates) { + if (!r.firstDate) continue; + const existing = dailyNewPlayerReturn.get(r.firstDate) ?? { cohort: 0, returned: 0 }; + existing.cohort++; + if (r.totalCompletions >= 2) existing.returned++; + dailyNewPlayerReturn.set(r.firstDate, existing); + } + + // Build chronological array of daily rates (oldest first, days 60→1 ago) + // Days with fewer than 3 new players get rate=null to exclude from rolling avg + const dailyReturnRates: { date: string; cohort: number; rate: number | null }[] = []; + for (let i = 60; i >= 1; i--) { + const dateD = estDateStr(i); + const d = dailyNewPlayerReturn.get(dateD); + dailyReturnRates.push({ + date: dateD, + cohort: d?.cohort ?? 0, + rate: d && d.cohort >= 3 ? Math.round((d.returned / d.cohort) * 1000) / 10 : null + }); + } + + // 7-day trailing rolling average of the daily rates + // Index 0 = 60 days ago, index 59 = yesterday + const newPlayerReturnSeries = dailyReturnRates.map((r, idx) => { + const window = dailyReturnRates + .slice(Math.max(0, idx - 6), idx + 1) + .filter((d) => d.rate !== null); + const avg = + window.length > 0 + ? Math.round((window.reduce((sum, d) => sum + (d.rate ?? 0), 0) / window.length) * 10) / + 10 + : null; + return { date: r.date, cohort: r.cohort, rate: r.rate, rollingAvg: avg }; + }); + + // Velocity: avg of last 7 complete days (idx 53–59) vs prior 7 (idx 46–52) + const recentWindow = newPlayerReturnSeries.slice(53).filter((d) => d.rate !== null); + const priorWindow = newPlayerReturnSeries.slice(46, 53).filter((d) => d.rate !== null); + const current7dReturnAvg = + recentWindow.length > 0 + ? Math.round( + (recentWindow.reduce((a, d) => a + (d.rate ?? 0), 0) / recentWindow.length) * 10 + ) / 10 + : null; + const prior7dReturnAvg = + priorWindow.length > 0 + ? Math.round( + (priorWindow.reduce((a, d) => a + (d.rate ?? 0), 0) / priorWindow.length) * 10 + ) / 10 + : null; + const returnRateChange = + current7dReturnAvg !== null && prior7dReturnAvg !== null + ? Math.round((current7dReturnAvg - prior7dReturnAvg) * 10) / 10 + : null; + + // ── Retention over time ─────────────────────────────────────────────────── + // For each cohort day D, retention = % of that day's players who played + // again within the next N days. Only compute for days where D+N is in the past. + + function retentionSeries( + windowDays: number, + seriesLength: number + ): { date: string; rate: number; cohortSize: number }[] { + // Earliest computable cohort day: today - (windowDays + 1) + // We use index windowDays+1 through windowDays+seriesLength + const series: { date: string; rate: number; cohortSize: number }[] = []; + for (let i = windowDays + 1; i <= windowDays + seriesLength; i++) { + const dateD = estDateStr(i); + const cohort = dateUsersMap.get(dateD); + if (!cohort || cohort.size < 3) continue; // skip tiny cohorts + let retained = 0; + for (const userId of cohort) { + for (let j = 1; j <= windowDays; j++) { + if (dateUsersMap.get(addDays(dateD, j))?.has(userId)) { + retained++; + break; + } + } + } + series.push({ + date: dateD, + rate: Math.round((retained / cohort.size) * 1000) / 10, + cohortSize: cohort.size + }); + } + series.reverse(); // chronological (oldest first) + return series; + } + + const retention7dSeries = retentionSeries(7, 30); + const retention30dSeries = retentionSeries(30, 30); + return { todayEst, stats: { @@ -212,6 +327,15 @@ export const load: PageServerLoad = async () => { netGrowth7d }, last14Days, - streakChart + streakChart, + retention7dSeries, + retention30dSeries, + overallReturnRate, + newPlayerReturnSeries: newPlayerReturnSeries.slice(-30), + newPlayerReturnVelocity: { + current7dAvg: current7dReturnAvg, + prior7dAvg: prior7dReturnAvg, + change: returnRateChange + } }; }; diff --git a/src/routes/global/+page.svelte b/src/routes/global/+page.svelte index 1af6970..aacc9a6 100644 --- a/src/routes/global/+page.svelte +++ b/src/routes/global/+page.svelte @@ -26,11 +26,20 @@ churned7d: number; netGrowth7d: number; }; + retention7dSeries: { date: string; rate: number; cohortSize: number }[]; + retention30dSeries: { date: string; rate: number; cohortSize: number }[]; + overallReturnRate: number | null; + newPlayerReturnSeries: { date: string; cohort: number; rate: number | null; rollingAvg: number | null }[]; + newPlayerReturnVelocity: { + current7dAvg: number | null; + prior7dAvg: number | null; + change: number | null; + }; } let { data }: { data: PageData } = $props(); - const { stats, last14Days, todayEst, streakChart, growth } = $derived(data); + const { stats, last14Days, todayEst, streakChart, growth, retention7dSeries, retention30dSeries, overallReturnRate, newPlayerReturnSeries, newPlayerReturnVelocity } = $derived(data); function signed(n: number, unit = ''): string { if (n > 0) return `+${n}${unit}`; @@ -63,6 +72,10 @@ label: 'Avg Completions/Player', value: stats.avgCompletionsPerPlayer != null ? stats.avgCompletionsPerPlayer.toFixed(2) : 'N/A', }, + { + label: 'Overall Return Rate', + value: overallReturnRate != null ? `${overallReturnRate}%` : 'N/A', + }, ]); @@ -132,6 +145,68 @@ +
+

New Player Return Rate (7-day rolling avg)

+
+ + Return Rate (7d avg) + + {newPlayerReturnVelocity.current7dAvg != null ? `${newPlayerReturnVelocity.current7dAvg}%` : 'N/A'} + + new players who came back + + + Return Rate Change + + {newPlayerReturnVelocity.change != null ? signed(newPlayerReturnVelocity.change, 'pp') : 'N/A'} + + vs prior 7 days + + + Prior 7d Avg + + {newPlayerReturnVelocity.prior7dAvg != null ? `${newPlayerReturnVelocity.prior7dAvg}%` : 'N/A'} + + days 8–14 ago + +
+ + {#if newPlayerReturnSeries.length > 0} +
+ + + + + + + + + + + + {#each newPlayerReturnSeries as row (row.date)} + + + + + + + + {/each} + +
DateNew PlayersDay Rate7d Avg
{row.date}{row.cohort}{row.rate != null ? `${row.rate}%` : '—'}{row.rollingAvg != null ? `${row.rollingAvg}%` : '—'} +
+ {#if row.rollingAvg != null} +
+ {/if} +
+
+
+ {:else} +

Not enough data yet.

+ {/if} +
+

Last 14 Days

@@ -194,5 +269,82 @@ {/if}
+
+

Retention Over Time

+

% of each day's players who returned within the window. Cohorts with fewer than 3 players are excluded.

+ +
+ +
+

7-Day Retention

+ {#if retention7dSeries.length === 0} +

Not enough data yet.

+ {:else} +
+ + + + + + + + + + + {#each retention7dSeries as row (row.date)} + + + + + + + {/each} + +
Cohort DatenRet. %
{row.date}{row.cohortSize}{row.rate}% +
+
+
+
+
+ {/if} +
+ + +
+

30-Day Retention

+ {#if retention30dSeries.length === 0} +

Not enough data yet.

+ {:else} +
+ + + + + + + + + + + {#each retention30dSeries as row (row.date)} + + + + + + + {/each} + +
Cohort DatenRet. %
{row.date}{row.cohortSize}{row.rate}% +
+
+
+
+
+ {/if} +
+
+
+