feat: add WAU history table, fix retention metric, add new logos and favicon

- Add 12-week Weekly Active Users table to global stats with WoW change %
- Fix 7-day and 30-day retention to measure return on exactly day N (not any day within the window)
- Remove "Avg Guesses Today" stat card
- Update retention description to clarify exact-day measurement
- Add bibdle logos (SVG, square PNG, circle PNG) and new favicon.png
- Wire favicon.png as the site favicon via app.html link tag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
George Powell
2026-03-16 00:04:45 -04:00
parent e878dea235
commit 83cfcc66c0
7 changed files with 1066 additions and 329 deletions

View File

@@ -1,350 +1,676 @@
<script lang="ts">
import Container from '$lib/components/Container.svelte';
import Container from "$lib/components/Container.svelte";
interface Stats {
todayCount: number;
totalCount: number;
uniquePlayers: number;
weeklyPlayers: number;
activeStreaks: number;
avgGuessesToday: number | null;
registeredUsers: number;
avgCompletionsPerPlayer: number | null;
}
interface Stats {
todayCount: number;
totalCount: number;
uniquePlayers: number;
weeklyPlayers: number;
activeStreaks: number;
avgGuessesToday: number | null;
registeredUsers: number;
avgCompletionsPerPlayer: number | null;
}
interface PageData {
todayEst: string;
stats: Stats;
last14Days: { date: string; count: number }[];
streakChart: { days: number; count: number }[];
growth: {
completionsVelocity: number;
completionsAcceleration: number;
userVelocity: number;
userAcceleration: number;
newUsers7d: number;
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;
};
}
interface PageData {
todayEst: string;
stats: Stats;
last14Days: { date: string; count: number }[];
streakChart: { days: number; count: number }[];
growth: {
completionsVelocity: number;
completionsAcceleration: number;
userVelocity: number;
userAcceleration: number;
newUsers7d: number;
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;
};
wauWeeks: {
weekStart: string;
weekEnd: string;
wau: number;
changePct: number | null;
}[];
avgWau: number;
}
let { data }: { data: PageData } = $props();
let { data }: { data: PageData } = $props();
const { stats, last14Days, todayEst, streakChart, growth, retention7dSeries, retention30dSeries, overallReturnRate, newPlayerReturnSeries, newPlayerReturnVelocity } = $derived(data);
const {
stats,
last14Days,
todayEst,
streakChart,
growth,
retention7dSeries,
retention30dSeries,
overallReturnRate,
newPlayerReturnSeries,
newPlayerReturnVelocity,
wauWeeks,
avgWau,
} = $derived(data);
function signed(n: number, unit = ''): string {
if (n > 0) return `+${n}${unit}`;
if (n < 0) return `${n}${unit}`;
return `0${unit}`;
}
function signed(n: number, unit = ""): string {
if (n > 0) return `+${n}${unit}`;
if (n < 0) return `${n}${unit}`;
return `0${unit}`;
}
function trendColor(n: number): string {
if (n > 0) return 'text-green-400';
if (n < 0) return 'text-red-400';
return 'text-gray-400';
}
function trendColor(n: number): string {
if (n > 0) return "text-green-400";
if (n < 0) return "text-red-400";
return "text-gray-400";
}
const maxCount = $derived(Math.max(1, ...last14Days.map((d) => d.count)));
const maxCount = $derived(Math.max(1, ...last14Days.map((d) => d.count)));
const maxStreakCount = $derived(Math.max(1, ...streakChart.map((r) => r.count)));
const maxWau = $derived(Math.max(1, ...wauWeeks.map((w) => w.wau)));
const statCards = $derived([
{ label: 'Completions Today', value: String(stats.todayCount) },
{ label: 'All-Time Completions', value: String(stats.totalCount) },
{ label: 'Unique Players', value: String(stats.uniquePlayers) },
{ label: 'Players This Week', value: String(stats.weeklyPlayers) },
{ label: 'Active Streaks', value: String(stats.activeStreaks) },
{
label: 'Avg Guesses Today',
value: stats.avgGuessesToday != null ? stats.avgGuessesToday.toFixed(2) : 'N/A',
},
{ label: 'Registered Users', value: String(stats.registeredUsers) },
{
label: 'Avg Completions/Player',
value: stats.avgCompletionsPerPlayer != null ? stats.avgCompletionsPerPlayer.toFixed(2) : 'N/A',
},
{
label: 'Overall Return Rate',
value: overallReturnRate != null ? `${overallReturnRate}%` : 'N/A',
},
]);
const maxStreakCount = $derived(
Math.max(1, ...streakChart.map((r) => r.count)),
);
const statCards = $derived([
{ label: "Completions Today", value: String(stats.todayCount) },
{ label: "All-Time Completions", value: String(stats.totalCount) },
{ label: "Unique Players", value: String(stats.uniquePlayers) },
{ label: "Players This Week", value: String(stats.weeklyPlayers) },
{ label: "Active Streaks", value: String(stats.activeStreaks) },
{ label: "Registered Users", value: String(stats.registeredUsers) },
{
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>
<svelte:head>
<title>Global Stats | Bibdle</title>
<title>Global Stats | Bibdle</title>
</svelte:head>
<div class="min-h-screen bg-linear-to-br from-gray-900 via-slate-900 to-gray-900 text-gray-100">
<div class="max-w-6xl mx-auto px-4 py-8">
<div
class="min-h-screen bg-linear-to-br from-gray-900 via-slate-900 to-gray-900 text-gray-100"
>
<div class="max-w-6xl mx-auto px-4 py-8">
<a
href="/"
class="inline-flex items-center gap-1 text-gray-400 hover:text-gray-100 text-sm mb-6 transition-colors"
>
← Back to Game
</a>
<a href="/" class="inline-flex items-center gap-1 text-gray-400 hover:text-gray-100 text-sm mb-6 transition-colors">
← Back to Game
</a>
<header class="mb-8">
<h1 class="text-3xl font-bold text-gray-100">Global Stats</h1>
<p class="text-gray-400 text-sm mt-1">
EST reference date: {todayEst}
</p>
</header>
<header class="mb-8">
<h1 class="text-3xl font-bold text-gray-100">Global Stats</h1>
<p class="text-gray-400 text-sm mt-1">EST reference date: {todayEst}</p>
</header>
<section
class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 mb-10"
>
{#each statCards as card (card.label)}
<Container class="w-full p-5 gap-2">
<span
class="text-gray-400 text-xs uppercase tracking-wide text-center"
>{card.label}</span
>
<span class="text-2xl md:text-3xl font-bold text-gray-100"
>{card.value}</span
>
</Container>
{/each}
</section>
<section class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 mb-10">
{#each statCards as card (card.label)}
<Container class="w-full p-5 gap-2">
<span class="text-gray-400 text-xs uppercase tracking-wide text-center">{card.label}</span>
<span class="text-2xl md:text-3xl font-bold text-gray-100">{card.value}</span>
</Container>
{/each}
</section>
<section class="mb-10">
<h2 class="text-lg font-semibold text-gray-100 mb-4">
Traffic &amp; Growth <span
class="text-xs font-normal text-gray-400"
>(7-day windows)</span
>
</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
<Container class="w-full p-5 gap-2">
<span
class="text-gray-400 text-xs uppercase tracking-wide text-center"
>Completions Velocity</span
>
<span
class="text-2xl md:text-3xl font-bold text-center {trendColor(
growth.completionsVelocity,
)}">{signed(growth.completionsVelocity, "/day")}</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"
>Completions Accel.</span
>
<span
class="text-2xl md:text-3xl font-bold text-center {trendColor(
growth.completionsAcceleration,
)}"
>{signed(growth.completionsAcceleration, "/day")}</span
>
<span class="text-xs text-gray-500 text-center"
>rate of change of velocity</span
>
</Container>
<Container class="w-full p-5 gap-2">
<span
class="text-gray-400 text-xs uppercase tracking-wide text-center"
>User Velocity</span
>
<span
class="text-2xl md:text-3xl font-bold text-center {trendColor(
growth.userVelocity,
)}">{signed(growth.userVelocity)}</span
>
<span class="text-xs text-gray-500 text-center"
>unique players, wk/wk</span
>
</Container>
<Container class="w-full p-5 gap-2">
<span
class="text-gray-400 text-xs uppercase tracking-wide text-center"
>User Acceleration</span
>
<span
class="text-2xl md:text-3xl font-bold text-center {trendColor(
growth.userAcceleration,
)}">{signed(growth.userAcceleration)}</span
>
<span class="text-xs text-gray-500 text-center"
>rate of change of user velocity</span
>
</Container>
<Container class="w-full p-5 gap-2">
<span
class="text-gray-400 text-xs uppercase tracking-wide text-center"
>New Players (7d)</span
>
<span
class="text-2xl md:text-3xl font-bold text-center {trendColor(
growth.newUsers7d,
)}">{String(growth.newUsers7d)}</span
>
<span class="text-xs text-gray-500 text-center"
>first-time players</span
>
</Container>
<Container class="w-full p-5 gap-2">
<span
class="text-gray-400 text-xs uppercase tracking-wide text-center"
>Churned (7d)</span
>
<span
class="text-2xl md:text-3xl font-bold text-center {trendColor(
0,
)}">{String(growth.churned7d)}</span
>
<span class="text-xs text-gray-500 text-center"
>played wk prior, not this wk</span
>
</Container>
<Container class="w-full p-5 gap-2">
<span
class="text-gray-400 text-xs uppercase tracking-wide text-center"
>Net Growth (7d)</span
>
<span
class="text-2xl md:text-3xl font-bold text-center {trendColor(
growth.netGrowth7d,
)}">{signed(growth.netGrowth7d)}</span
>
<span class="text-xs text-gray-500 text-center"
>new minus churned</span
>
</Container>
</div>
</section>
<section class="mb-10">
<h2 class="text-lg font-semibold text-gray-100 mb-4">Traffic &amp; Growth <span class="text-xs font-normal text-gray-400">(7-day windows)</span></h2>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
<Container class="w-full p-5 gap-2">
<span class="text-gray-400 text-xs uppercase tracking-wide text-center">Completions Velocity</span>
<span class="text-2xl md:text-3xl font-bold text-center {trendColor(growth.completionsVelocity)}">{signed(growth.completionsVelocity, '/day')}</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">Completions Accel.</span>
<span class="text-2xl md:text-3xl font-bold text-center {trendColor(growth.completionsAcceleration)}">{signed(growth.completionsAcceleration, '/day')}</span>
<span class="text-xs text-gray-500 text-center">rate of change of velocity</span>
</Container>
<Container class="w-full p-5 gap-2">
<span class="text-gray-400 text-xs uppercase tracking-wide text-center">User Velocity</span>
<span class="text-2xl md:text-3xl font-bold text-center {trendColor(growth.userVelocity)}">{signed(growth.userVelocity)}</span>
<span class="text-xs text-gray-500 text-center">unique players, wk/wk</span>
</Container>
<Container class="w-full p-5 gap-2">
<span class="text-gray-400 text-xs uppercase tracking-wide text-center">User Acceleration</span>
<span class="text-2xl md:text-3xl font-bold text-center {trendColor(growth.userAcceleration)}">{signed(growth.userAcceleration)}</span>
<span class="text-xs text-gray-500 text-center">rate of change of user velocity</span>
</Container>
<Container class="w-full p-5 gap-2">
<span class="text-gray-400 text-xs uppercase tracking-wide text-center">New Players (7d)</span>
<span class="text-2xl md:text-3xl font-bold text-center {trendColor(growth.newUsers7d)}">{String(growth.newUsers7d)}</span>
<span class="text-xs text-gray-500 text-center">first-time players</span>
</Container>
<Container class="w-full p-5 gap-2">
<span class="text-gray-400 text-xs uppercase tracking-wide text-center">Churned (7d)</span>
<span class="text-2xl md:text-3xl font-bold text-center {trendColor(0)}">{String(growth.churned7d)}</span>
<span class="text-xs text-gray-500 text-center">played wk prior, not this wk</span>
</Container>
<Container class="w-full p-5 gap-2">
<span class="text-gray-400 text-xs uppercase tracking-wide text-center">Net Growth (7d)</span>
<span class="text-2xl md:text-3xl font-bold text-center {trendColor(growth.netGrowth7d)}">{signed(growth.netGrowth7d)}</span>
<span class="text-xs text-gray-500 text-center">new minus churned</span>
</Container>
</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>
<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">Return 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>
{#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">Return 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-1">
Weekly Active Users
</h2>
<p class="text-gray-400 text-sm mb-4">
Unique players per 7-day window. Most recent week first. Avg
WAU: <span class="text-gray-100 font-medium">{avgWau}</span>
</p>
<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">Week</th>
<th class="text-right px-4 py-3">Active Users</th>
<th class="text-right px-4 py-3">Wk/Wk Change</th>
<th class="px-4 py-3 w-48"></th>
</tr>
</thead>
<tbody>
{#each wauWeeks as row (row.weekEnd)}
{@const barPct = Math.round(
(row.wau / maxWau) * 100,
)}
<tr
class="border-t border-white/5 hover:bg-white/5 transition-colors"
>
<td class="px-4 py-3 text-gray-300 text-xs"
>{row.weekStart} {row.weekEnd}</td
>
<td
class="px-4 py-3 text-right text-gray-100 font-medium"
>{row.wau}</td
>
<td
class="px-4 py-3 text-right text-xs font-medium {row.changePct !=
null
? row.changePct > 0
? 'text-green-400'
: row.changePct < 0
? 'text-red-400'
: 'text-gray-400'
: 'text-gray-500'}"
>
{row.changePct != null
? signed(row.changePct, "%")
: "—"}
</td>
<td class="px-4 py-3">
<div class="w-full min-w-24">
<div
class="bg-indigo-500 h-4 rounded"
style="width: {barPct}%"
></div>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
<section class="mt-8">
<h2 class="text-lg font-semibold text-gray-100 mb-4">Last 14 Days — Completions</h2>
<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">Completions</th>
<th class="px-4 py-3 w-48"></th>
</tr>
</thead>
<tbody>
{#each last14Days as row (row.date)}
{@const barPct = Math.round((row.count / maxCount) * 100)}
<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-100 font-medium">{row.count}</td>
<td class="px-4 py-3">
<div class="w-full min-w-24">
<div class="bg-amber-500 h-4 rounded" style="width: {barPct}%"></div>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
<section class="mt-8">
<h2 class="text-lg font-semibold text-gray-100 mb-4">
Last 14 Days — Completions
</h2>
<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">Completions</th>
<th class="px-4 py-3 w-48"></th>
</tr>
</thead>
<tbody>
{#each last14Days as row (row.date)}
{@const barPct = Math.round(
(row.count / maxCount) * 100,
)}
<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-100 font-medium"
>{row.count}</td
>
<td class="px-4 py-3">
<div class="w-full min-w-24">
<div
class="bg-amber-500 h-4 rounded"
style="width: {barPct}%"
></div>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
<section class="mt-8">
<h2 class="text-lg font-semibold text-gray-100 mb-4">Active Streak Distribution</h2>
{#if streakChart.length === 0}
<p class="text-gray-400 text-sm px-4 py-6">No active streaks 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">Days</th>
<th class="text-right px-4 py-3">Players</th>
<th class="px-4 py-3 w-48"></th>
</tr>
</thead>
<tbody>
{#each streakChart as row (row.days)}
{@const barPct = Math.round((row.count / maxStreakCount) * 100)}
<tr class="border-t border-white/5 hover:bg-white/5 transition-colors">
<td class="px-4 py-3 text-gray-300">{row.days}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{row.count}</td>
<td class="px-4 py-3">
<div class="w-full min-w-24">
<div class="bg-blue-500 h-4 rounded" style="width: {barPct}%"></div>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
<section class="mt-8">
<h2 class="text-lg font-semibold text-gray-100 mb-4">
Active Streak Distribution
</h2>
{#if streakChart.length === 0}
<p class="text-gray-400 text-sm px-4 py-6">
No active streaks 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">Days</th>
<th class="text-right px-4 py-3">Players</th>
<th class="px-4 py-3 w-48"></th>
</tr>
</thead>
<tbody>
{#each streakChart as row (row.days)}
{@const barPct = Math.round(
(row.count / maxStreakCount) * 100,
)}
<tr
class="border-t border-white/5 hover:bg-white/5 transition-colors"
>
<td class="px-4 py-3 text-gray-300"
>{row.days}</td
>
<td
class="px-4 py-3 text-right text-gray-100 font-medium"
>{row.count}</td
>
<td class="px-4 py-3">
<div class="w-full min-w-24">
<div
class="bg-blue-500 h-4 rounded"
style="width: {barPct}%"
></div>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/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>
<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 played again exactly 7 or 30 days later (regardless of activity in between). 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>
<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>
<!-- 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>