mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
884 lines
27 KiB
Svelte
884 lines
27 KiB
Svelte
<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 & 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 8–14 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>
|