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

@@ -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',
},
]);
</script>
@@ -132,6 +145,68 @@
</div>
</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">
<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">
@@ -194,5 +269,82 @@
{/if}
</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>