Files
bibdle/src/routes/global/+page.svelte
2026-03-19 00:39:54 -04:00

884 lines
27 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import Container from "$lib/components/Container.svelte";
interface Stats {
todayCount: number;
totalCount: number;
uniquePlayers: number;
weeklyPlayers: number;
activeStreaks: number;
avgGuessesToday: number | null;
registeredUsers: number;
monthlyPlayers: number;
}
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;
mauMonths: { monthStart: string; monthEnd: string; mau: number; changePct: number | null }[];
calendarMauMonths: {
label: string;
monthStart: string;
monthEnd: string;
mau: number;
daysElapsed: number;
daysInMonth: number;
projectedMau: number | null;
changePct: number | null;
isCurrentMonth: boolean;
}[];
sessionDepthCards: { depth: number; players: number; returnRate: number | null }[];
}
let { data }: { data: PageData } = $props();
const {
stats,
last14Days,
todayEst,
streakChart,
growth,
retention7dSeries,
retention30dSeries,
overallReturnRate,
newPlayerReturnSeries,
newPlayerReturnVelocity,
wauWeeks,
avgWau,
mauMonths,
calendarMauMonths,
sessionDepthCards,
} = $derived(data);
// Collapsible table state
let returnExpanded = $state(false);
let wauExpanded = $state(false);
let completionsExpanded = $state(false);
let streakExpanded = $state(false);
let ret7dExpanded = $state(false);
let ret30dExpanded = $state(false);
let mauExpanded = $state(false);
let mauMode = $state<'rolling' | 'calendar'>('rolling');
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";
}
const maxCount = $derived(Math.max(1, ...last14Days.map((d) => d.count)));
const maxWau = $derived(Math.max(1, ...wauWeeks.map((w) => w.wau)));
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: "Players This Month", value: String(stats.monthlyPlayers) },
{
label: "Overall Return Rate",
value: overallReturnRate != null ? `${overallReturnRate}%` : "N/A",
},
]);
</script>
<svelte:head>
<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">
<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>
<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-1">Survival Curve</h2>
<p class="text-gray-400 text-sm mb-4">
Of players who completed N sessions, what % came back for N+1?
</p>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
{#each sessionDepthCards as card (card.depth)}
<Container class="w-full p-5 gap-2">
<span class="text-gray-400 text-xs uppercase tracking-wide text-center"
>After {card.depth} plays</span
>
<span class="text-2xl md:text-3xl font-bold text-gray-100">
{card.returnRate != null ? `${card.returnRate}%` : "N/A"}
</span>
<span class="text-xs text-gray-500 text-center"
>{card.players} players</span
>
</Container>
{/each}
</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">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 (returnExpanded ? newPlayerReturnSeries : newPlayerReturnSeries.slice(0, 3)) 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>
{#if newPlayerReturnSeries.length > 3}
<button
onclick={() => (returnExpanded = !returnExpanded)}
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
>
<span>{returnExpanded ? '▲ Show less' : '▼ Show more'}</span>
</button>
{/if}
{: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 (wauExpanded ? wauWeeks : wauWeeks.slice(0, 3)) 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>
{#if wauWeeks.length > 3}
<button
onclick={() => (wauExpanded = !wauExpanded)}
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
>
<span>{wauExpanded ? '▲ Show less' : '▼ Show more'}</span>
</button>
{/if}
</section>
<section class="mt-8">
<div class="flex items-center justify-between mb-1">
<h2 class="text-lg font-semibold text-gray-100">Monthly Active Users</h2>
<div class="flex gap-1 bg-white/5 rounded-lg p-1">
<button
onclick={() => (mauMode = 'rolling')}
class="px-3 py-1 text-xs rounded-md transition-colors {mauMode === 'rolling' ? 'bg-white/10 text-gray-100' : 'text-gray-400 hover:text-gray-200'}"
>Rolling 30d</button>
<button
onclick={() => (mauMode = 'calendar')}
class="px-3 py-1 text-xs rounded-md transition-colors {mauMode === 'calendar' ? 'bg-white/10 text-gray-100' : 'text-gray-400 hover:text-gray-200'}"
>Calendar</button>
</div>
</div>
<p class="text-gray-400 text-sm mb-4">
{mauMode === 'rolling' ? 'Unique players per 30-day window. Most recent first.' : 'Unique players per calendar month. Current month projected to end of month.'}
</p>
{#if mauMode === 'rolling'}
{@const displayedMauMonths = mauExpanded ? mauMonths : mauMonths.slice(0, 3)}
{@const maxMau = Math.max(1, ...mauMonths.map((m) => m.mau))}
<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">Period</th>
<th class="text-right px-4 py-3">Active Users</th>
<th class="text-right px-4 py-3">Mo/Mo Change</th>
<th class="px-4 py-3 w-48"></th>
</tr>
</thead>
<tbody>
{#each displayedMauMonths as row (row.monthEnd)}
{@const barPct = Math.round((row.mau / maxMau) * 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.monthStart} {row.monthEnd}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{row.mau}</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-teal-500 h-4 rounded" style="width: {barPct}%"></div>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if mauMonths.length > 3}
<button
onclick={() => (mauExpanded = !mauExpanded)}
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
>
<span>{mauExpanded ? '▲ Show less' : '▼ Show more'}</span>
</button>
{/if}
{:else}
{@const displayedCalMau = mauExpanded ? calendarMauMonths : calendarMauMonths.slice(0, 3)}
{@const maxCalMau = Math.max(1, ...calendarMauMonths.map((m) => m.projectedMau ?? m.mau))}
<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">Month</th>
<th class="text-right px-4 py-3">Active Users</th>
<th class="text-right px-4 py-3">Mo/Mo Change</th>
<th class="px-4 py-3 w-48"></th>
</tr>
</thead>
<tbody>
{#each displayedCalMau as row (row.monthStart)}
{@const displayMau = row.projectedMau ?? row.mau}
{@const barPct = Math.round((displayMau / maxCalMau) * 100)}
<tr class="border-t border-white/5 hover:bg-white/5 transition-colors">
<td class="px-4 py-3 text-gray-300">
{row.label}
{#if row.isCurrentMonth}
<span class="text-xs text-gray-500 ml-1">(projected)</span>
{/if}
</td>
<td class="px-4 py-3 text-right font-medium {row.isCurrentMonth ? 'text-gray-400' : 'text-gray-100'}">
{#if row.isCurrentMonth}
<span class="text-gray-500 text-xs">{row.mau}</span>{row.projectedMau ?? row.mau}
{:else}
{row.mau}
{/if}
</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'}">
{#if row.changePct != null}
{row.isCurrentMonth ? '~' : ''}{signed(row.changePct, '%')}
{:else}
{/if}
</td>
<td class="px-4 py-3">
<div class="w-full min-w-24">
<div class="bg-teal-500 h-4 rounded {row.isCurrentMonth ? 'opacity-50' : ''}" style="width: {barPct}%"></div>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if calendarMauMonths.length > 3}
<button
onclick={() => (mauExpanded = !mauExpanded)}
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
>
<span>{mauExpanded ? '▲ Show less' : '▼ Show more'}</span>
</button>
{/if}
{/if}
</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 (completionsExpanded ? last14Days : last14Days.slice(0, 3)) 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>
{#if last14Days.length > 3}
<button
onclick={() => (completionsExpanded = !completionsExpanded)}
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
>
<span>{completionsExpanded ? '▲ Show less' : '▼ Show more'}</span>
</button>
{/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 (streakExpanded ? streakChart : streakChart.slice(0, 3)) 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 streakChart.length > 3}
<button
onclick={() => (streakExpanded = !streakExpanded)}
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
>
<span>{streakExpanded ? '▲ Show less' : '▼ Show more'}</span>
</button>
{/if}
{/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 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 (ret7dExpanded ? retention7dSeries : retention7dSeries.slice(0, 3)) 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 retention7dSeries.length > 3}
<button
onclick={() => (ret7dExpanded = !ret7dExpanded)}
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
>
<span>{ret7dExpanded ? '▲ Show less' : '▼ Show more'}</span>
</button>
{/if}
{/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 (ret30dExpanded ? retention30dSeries : retention30dSeries.slice(0, 3)) 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 retention30dSeries.length > 3}
<button
onclick={() => (ret30dExpanded = !ret30dExpanded)}
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
>
<span>{ret30dExpanded ? '▲ Show less' : '▼ Show more'}</span>
</button>
{/if}
{/if}
</div>
</div>
</section>
</div>
</div>