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 <noreply@anthropic.com>
This commit is contained in:
George Powell
2026-03-15 02:09:55 -04:00
parent 7007df2966
commit 75b13280ef
2 changed files with 280 additions and 4 deletions

View File

@@ -15,6 +15,12 @@ function prevDay(d: string): string {
return dt.toISOString().slice(0, 10); 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 () => { export const load: PageServerLoad = async () => {
const todayEst = estDateStr(0); const todayEst = estDateStr(0);
const yesterdayEst = estDateStr(1); const yesterdayEst = estDateStr(1);
@@ -120,12 +126,17 @@ export const load: PageServerLoad = async () => {
.where(gte(dailyCompletions.date, ninetyDaysAgo)) .where(gte(dailyCompletions.date, ninetyDaysAgo))
.orderBy(asc(dailyCompletions.date)); .orderBy(asc(dailyCompletions.date));
// Group dates by user (ascending) // Group dates by user (ascending) and users by date
const userDatesMap = new Map<string, string[]>(); const userDatesMap = new Map<string, string[]>();
const dateUsersMap = new Map<string, Set<string>>();
for (const row of recentCompletions) { for (const row of recentCompletions) {
const arr = userDatesMap.get(row.anonymousId); const arr = userDatesMap.get(row.anonymousId);
if (arr) arr.push(row.date); if (arr) arr.push(row.date);
else userDatesMap.set(row.anonymousId, [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 ─────────────────────────────────────────────────── // ── Streak distribution ───────────────────────────────────────────────────
@@ -177,7 +188,8 @@ export const load: PageServerLoad = async () => {
const firstDates = await db const firstDates = await db
.select({ .select({
anonymousId: dailyCompletions.anonymousId, anonymousId: dailyCompletions.anonymousId,
firstDate: min(dailyCompletions.date) firstDate: min(dailyCompletions.date),
totalCompletions: count()
}) })
.from(dailyCompletions) .from(dailyCompletions)
.groupBy(dailyCompletions.anonymousId); .groupBy(dailyCompletions.anonymousId);
@@ -190,6 +202,109 @@ export const load: PageServerLoad = async () => {
// Net growth = truly new arrivals minus departures // Net growth = truly new arrivals minus departures
const netGrowth7d = newUsers7d - churned7d; 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<string, { cohort: number; returned: number }>();
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 5359) vs prior 7 (idx 4652)
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 { return {
todayEst, todayEst,
stats: { stats: {
@@ -212,6 +327,15 @@ export const load: PageServerLoad = async () => {
netGrowth7d netGrowth7d
}, },
last14Days, last14Days,
streakChart streakChart,
retention7dSeries,
retention30dSeries,
overallReturnRate,
newPlayerReturnSeries: newPlayerReturnSeries.slice(-30),
newPlayerReturnVelocity: {
current7dAvg: current7dReturnAvg,
prior7dAvg: prior7dReturnAvg,
change: returnRateChange
}
}; };
}; };

View File

@@ -26,11 +26,20 @@
churned7d: number; churned7d: number;
netGrowth7d: 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(); 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 { function signed(n: number, unit = ''): string {
if (n > 0) return `+${n}${unit}`; if (n > 0) return `+${n}${unit}`;
@@ -63,6 +72,10 @@
label: 'Avg Completions/Player', label: 'Avg Completions/Player',
value: stats.avgCompletionsPerPlayer != null ? stats.avgCompletionsPerPlayer.toFixed(2) : 'N/A', value: stats.avgCompletionsPerPlayer != null ? stats.avgCompletionsPerPlayer.toFixed(2) : 'N/A',
}, },
{
label: 'Overall Return Rate',
value: overallReturnRate != null ? `${overallReturnRate}%` : 'N/A',
},
]); ]);
</script> </script>
@@ -132,6 +145,68 @@
</div> </div>
</section> </section>
<section class="mb-10">
<h2 class="text-lg font-semibold text-gray-100 mb-4">New Player Return Rate <span class="text-xs font-normal text-gray-400">(7-day rolling avg)</span></h2>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4 mb-6">
<Container class="w-full p-5 gap-2">
<span class="text-gray-400 text-xs uppercase tracking-wide text-center">Return Rate (7d avg)</span>
<span class="text-2xl md:text-3xl font-bold text-center text-gray-100">
{newPlayerReturnVelocity.current7dAvg != null ? `${newPlayerReturnVelocity.current7dAvg}%` : 'N/A'}
</span>
<span class="text-xs text-gray-500 text-center">new players who came back</span>
</Container>
<Container class="w-full p-5 gap-2">
<span class="text-gray-400 text-xs uppercase tracking-wide text-center">Return Rate Change</span>
<span class="text-2xl md:text-3xl font-bold text-center {newPlayerReturnVelocity.change != null ? trendColor(newPlayerReturnVelocity.change) : 'text-gray-400'}">
{newPlayerReturnVelocity.change != null ? signed(newPlayerReturnVelocity.change, 'pp') : 'N/A'}
</span>
<span class="text-xs text-gray-500 text-center">vs prior 7 days</span>
</Container>
<Container class="w-full p-5 gap-2">
<span class="text-gray-400 text-xs uppercase tracking-wide text-center">Prior 7d Avg</span>
<span class="text-2xl md:text-3xl font-bold text-center text-gray-100">
{newPlayerReturnVelocity.prior7dAvg != null ? `${newPlayerReturnVelocity.prior7dAvg}%` : 'N/A'}
</span>
<span class="text-xs text-gray-500 text-center">days 814 ago</span>
</Container>
</div>
{#if newPlayerReturnSeries.length > 0}
<div class="overflow-x-auto rounded-xl border border-white/10">
<table class="w-full text-sm">
<thead>
<tr class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide">
<th class="text-left px-4 py-3">Date</th>
<th class="text-right px-4 py-3">New Players</th>
<th class="text-right px-4 py-3">Day Rate</th>
<th class="text-right px-4 py-3">7d Avg</th>
<th class="px-4 py-3 w-32"></th>
</tr>
</thead>
<tbody>
{#each newPlayerReturnSeries as row (row.date)}
<tr class="border-t border-white/5 hover:bg-white/5 transition-colors">
<td class="px-4 py-3 text-gray-300">{row.date}</td>
<td class="px-4 py-3 text-right text-gray-400 text-xs">{row.cohort}</td>
<td class="px-4 py-3 text-right text-gray-400">{row.rate != null ? `${row.rate}%` : '—'}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{row.rollingAvg != null ? `${row.rollingAvg}%` : '—'}</td>
<td class="px-4 py-3">
<div class="w-full min-w-20">
{#if row.rollingAvg != null}
<div class="bg-sky-500 h-4 rounded" style="width: {row.rollingAvg}%"></div>
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p class="text-gray-400 text-sm px-4 py-6">Not enough data yet.</p>
{/if}
</section>
<section class="mt-8"> <section class="mt-8">
<h2 class="text-lg font-semibold text-gray-100 mb-4">Last 14 Days</h2> <h2 class="text-lg font-semibold text-gray-100 mb-4">Last 14 Days</h2>
<div class="overflow-x-auto rounded-xl border border-white/10"> <div class="overflow-x-auto rounded-xl border border-white/10">
@@ -194,5 +269,82 @@
{/if} {/if}
</section> </section>
<section class="mt-8">
<h2 class="text-lg font-semibold text-gray-100 mb-1">Retention Over Time</h2>
<p class="text-gray-400 text-sm mb-6">% of each day's players who returned within the window. Cohorts with fewer than 3 players are excluded.</p>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 7-day retention -->
<div>
<h3 class="text-base font-semibold text-gray-200 mb-3">7-Day Retention</h3>
{#if retention7dSeries.length === 0}
<p class="text-gray-400 text-sm px-4 py-6">Not enough data yet.</p>
{:else}
<div class="overflow-x-auto rounded-xl border border-white/10">
<table class="w-full text-sm">
<thead>
<tr class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide">
<th class="text-left px-4 py-3">Cohort Date</th>
<th class="text-right px-4 py-3">n</th>
<th class="text-right px-4 py-3">Ret. %</th>
<th class="px-4 py-3 w-32"></th>
</tr>
</thead>
<tbody>
{#each retention7dSeries as row (row.date)}
<tr class="border-t border-white/5 hover:bg-white/5 transition-colors">
<td class="px-4 py-3 text-gray-300">{row.date}</td>
<td class="px-4 py-3 text-right text-gray-400 text-xs">{row.cohortSize}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{row.rate}%</td>
<td class="px-4 py-3">
<div class="w-full min-w-20">
<div class="bg-emerald-500 h-4 rounded" style="width: {row.rate}%"></div>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- 30-day retention -->
<div>
<h3 class="text-base font-semibold text-gray-200 mb-3">30-Day Retention</h3>
{#if retention30dSeries.length === 0}
<p class="text-gray-400 text-sm px-4 py-6">Not enough data yet.</p>
{:else}
<div class="overflow-x-auto rounded-xl border border-white/10">
<table class="w-full text-sm">
<thead>
<tr class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide">
<th class="text-left px-4 py-3">Cohort Date</th>
<th class="text-right px-4 py-3">n</th>
<th class="text-right px-4 py-3">Ret. %</th>
<th class="px-4 py-3 w-32"></th>
</tr>
</thead>
<tbody>
{#each retention30dSeries as row (row.date)}
<tr class="border-t border-white/5 hover:bg-white/5 transition-colors">
<td class="px-4 py-3 text-gray-300">{row.date}</td>
<td class="px-4 py-3 text-right text-gray-400 text-xs">{row.cohortSize}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{row.rate}%</td>
<td class="px-4 py-3">
<div class="w-full min-w-20">
<div class="bg-violet-500 h-4 rounded" style="width: {row.rate}%"></div>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
</section>
</div> </div>
</div> </div>