first pass analytics redo

This commit is contained in:
George Powell committed 2026-09-22 12:15:56 -04:00
1 parent b656fd04c0
commit 45ffa39f2a
69 files changed
+6400 -1138

No files matched your search

+6
View File
@@ -11,6 +11,7 @@
import StreakCounter from "./StreakCounter.svelte";
import ChapterGuess from "./ChapterGuess.svelte";
import SubmitVerse from "./SubmitVerse.svelte";
import { track } from "$lib/utils/analytics";
interface StatsData {
solveRank: number;
@@ -256,11 +257,13 @@
: "Copy to Clipboard"}
onclick={() => {
if (hasWebShare) {
track("share", { snippet: includeSnippet });
shareResult(effectiveShareText);
} else {
if (!copyTracked) {
copyTracked = true;
}
track("share_copy", { snippet: includeSnippet });
clipboardCopy(effectiveShareText);
copySuccess = true;
setTimeout(() => {
@@ -289,6 +292,7 @@
if (!copyTracked) {
copyTracked = true;
}
track("share_copy", { snippet: includeSnippet });
clipboardCopy(effectiveShareText);
showSnippetOption = true;
bubbleCopied = true;
@@ -429,6 +433,7 @@
rel="noopener noreferrer"
class="discord-btn w-full"
data-umami-event="Join the BIBDLE Discord"
onclick={() => track("discord_click")}
>
<svg
class="discord-icon"
@@ -454,6 +459,7 @@
rel="noopener noreferrer"
class="progress-btn w-full"
data-umami-event="Support bibdle's development"
onclick={() => track("donate_click")}
>
<span>❤️ Support bibdle's development</span>
<svg class="progress-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+33
View File
@@ -0,0 +1,33 @@
<script lang="ts">
interface Item {
label: string;
value: number;
color?: string;
sub?: string;
}
interface Props {
items: Item[];
format?: (v: number) => string;
signed?: boolean;
}
let { items, format = (v) => String(v), signed = false }: Props = $props();
const maxAbs = $derived(Math.max(1, ...items.map((i) => Math.abs(i.value))));
</script>
<div class="flex w-full flex-col gap-1.5">
{#each items as item (item.label)}
<div class="flex items-center gap-2 text-xs">
<span class="w-20 shrink-0 text-gray-400">{item.label}</span>
<div class="relative h-4 flex-1 overflow-hidden rounded bg-white/5">
<div
class="absolute top-0 h-full rounded {item.value < 0 ? 'right-1/2 bg-rose-500/60' : 'left-0'}"
style={`width:${(Math.abs(item.value) / maxAbs) * 100}%;${item.value < 0 ? '' : `background:${item.color ?? 'rgba(56,189,248,0.5)'}`}`}
></div>
</div>
<span class="w-16 shrink-0 text-right font-medium text-gray-200">
{signed && item.value >= 0 ? '+' : ''}{format(item.value)}
</span>
</div>
{/each}
</div>
@@ -0,0 +1,58 @@
<script lang="ts">
interface Props {
data: { date: string; count: number }[];
color?: string;
}
let { data, color = '52,211,153' }: Props = $props();
const values = $derived(new Map(data.map((d) => [d.date, d.count])));
const max = $derived(Math.max(1, ...data.map((d) => d.count)));
// Build Sunday-aligned weeks ending on the last date.
const weeks = $derived.by(() => {
if (data.length === 0) return [] as { date: string; inRange: boolean }[][];
const first = new Date(data[0].date + 'T00:00:00Z');
const start = new Date(first);
start.setUTCDate(start.getUTCDate() - start.getUTCDay());
const last = new Date(data[data.length - 1].date + 'T00:00:00Z');
const end = new Date(last);
end.setUTCDate(end.getUTCDate() + (6 - end.getUTCDay()));
const range = new Set(data.map((d) => d.date));
const out: { date: string; inRange: boolean }[][] = [];
let cursor = new Date(start);
while (cursor <= end) {
const week: { date: string; inRange: boolean }[] = [];
for (let d = 0; d < 7; d++) {
const iso = cursor.toISOString().slice(0, 10);
week.push({ date: iso, inRange: range.has(iso) });
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
out.push(week);
}
return out;
});
</script>
<div class="overflow-x-auto">
<div class="flex gap-1">
<div class="flex flex-col justify-between py-0.5 text-[9px] text-gray-500">
<span>Sun</span><span>Wed</span><span>Sat</span>
</div>
<div class="flex gap-[3px]">
{#each weeks as week, wi (wi)}
<div class="flex flex-col gap-[3px]">
{#each week as cell (cell.date)}
{@const count = values.get(cell.date) ?? 0}
<div
class="h-3 w-3 rounded-[2px]"
style={cell.inRange
? `background:rgba(${color},${(0.12 + 0.88 * (count / max)).toFixed(3)})`
: 'background:transparent'}
title={cell.inRange ? `${cell.date}: ${count} finishes` : cell.date}
></div>
{/each}
</div>
{/each}
</div>
</div>
</div>
@@ -0,0 +1,47 @@
<script lang="ts">
import type { CohortRow } from '$lib/types/stats';
interface Props {
cohorts: CohortRow[];
weeks?: number;
}
let { cohorts, weeks = 8 }: Props = $props();
function shade(rate: number | null): string {
if (rate === null) return 'background:rgba(255,255,255,0.03)';
const t = Math.min(1, rate / 100);
return `background:rgba(16,185,129,${(0.08 + 0.82 * t).toFixed(3)})`;
}
</script>
<div class="overflow-x-auto">
<table class="border-separate border-spacing-0.5 text-[10px] text-gray-400">
<thead>
<tr>
<th class="pr-2 text-right font-normal">Cohort</th>
<th class="pr-2 font-normal">Size</th>
{#each Array(weeks) as _w, k (k)}
<th class="w-8 font-normal">W{k}</th>
{/each}
</tr>
</thead>
<tbody>
{#each cohorts as row (row.weekStart)}
<tr>
<td class="pr-2 text-right whitespace-nowrap text-gray-300">{row.weekStart.slice(5)}</td>
<td class="pr-2 text-right text-gray-400">{row.size}</td>
{#each Array(weeks) as _w, k (k)}
{@const rate = row.rates[k] ?? null}
<td
class="h-6 w-8 rounded-sm text-center align-middle text-gray-900"
style={shade(rate)}
title={`Week ${k}: ${rate === null ? 'not resolved' : rate + '%'}`}
>
{rate === null ? '' : Math.round(rate)}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
@@ -0,0 +1,54 @@
<script lang="ts">
interface Props {
rowLabels: string[];
colLabels: string[];
values: number[][];
max?: number;
color?: string;
format?: (v: number) => string;
cellSize?: number;
}
let {
rowLabels,
colLabels,
values,
max,
color = '16,185,129',
format = (v) => String(v),
cellSize = 22
}: Props = $props();
const effectiveMax = $derived(
max ?? Math.max(1, ...values.flat().filter((v) => Number.isFinite(v)))
);
</script>
<div class="overflow-x-auto">
<table class="border-separate border-spacing-0.5 text-[10px] text-gray-400">
<thead>
<tr>
<th></th>
{#each colLabels as label, c (c)}
<th class="px-0.5 font-normal">{label}</th>
{/each}
</tr>
</thead>
<tbody>
{#each rowLabels as row, r (r)}
<tr>
<th class="pr-1 text-right font-normal whitespace-nowrap">{row}</th>
{#each colLabels as _label, c (c)}
{@const v = values[r]?.[c] ?? 0}
<td
class="rounded-sm text-center align-middle text-[9px] text-gray-900"
style={`width:${cellSize}px;height:${cellSize}px;background:rgba(${color},${Math.min(1, v / effectiveMax).toFixed(3)})`}
title={`${row} · ${colLabels[c]}: ${format(v)}`}
>
{v > 0 ? format(v) : ''}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
@@ -0,0 +1,254 @@
<script lang="ts">
interface Series {
name: string;
color: string;
points: { date: string; value: number | null }[];
step?: boolean;
}
interface Props {
series: Series[];
height?: number;
ySuffix?: string;
yMin?: number;
yMax?: number;
showLegend?: boolean;
}
let {
series,
height = 180,
ySuffix = '',
yMin,
yMax,
showLegend = true
}: Props = $props();
const W = 600;
const pad = { l: 36, r: 10, t: 10, b: 22 };
const innerW = W - pad.l - pad.r;
const innerH = $derived(height - pad.t - pad.b);
const allValues = $derived(
series.flatMap((s) => s.points.map((p) => p.value)).filter((v): v is number => v !== null)
);
const domain = $derived.by(() => {
const lo = yMin ?? (allValues.length ? Math.min(...allValues) : 0);
const hi = yMax ?? (allValues.length ? Math.max(...allValues) : 1);
const span = hi - lo || 1;
return { lo: yMin ?? lo - span * 0.1, hi: yMax ?? hi + span * 0.1 };
});
const pointCount = $derived(
Math.max(1, ...series.map((s) => s.points.length))
);
function xAt(i: number): number {
if (pointCount <= 1) return pad.l + innerW / 2;
return pad.l + (i / (pointCount - 1)) * innerW;
}
function yAt(v: number): number {
const t = (v - domain.lo) / (domain.hi - domain.lo || 1);
return pad.t + innerH - t * innerH;
}
/** Break a series into path segments so nulls create gaps. */
const paths = $derived(
series.map((s) => {
const segments: string[] = [];
let current: string[] = [];
s.points.forEach((p, i) => {
if (p.value === null) {
if (current.length) segments.push(current.join(' '));
current = [];
return;
}
current.push(`${xAt(i).toFixed(1)},${yAt(p.value).toFixed(1)}`);
});
if (current.length) segments.push(current.join(' '));
return { name: s.name, color: s.color, segments, step: s.step };
})
);
const firstLabel = $derived(series[0]?.points[0]?.date?.slice(5) ?? '');
const lastLabel = $derived(
series[0]?.points[series[0].points.length - 1]?.date?.slice(5) ?? ''
);
// --- Hover / tooltip state -------------------------------------------------
let wrapperEl: HTMLDivElement | undefined = $state();
let hoverIndex = $state<number | null>(null);
function handlePointerMove(event: PointerEvent) {
if (!wrapperEl || pointCount <= 0) return;
const rect = wrapperEl.getBoundingClientRect();
if (rect.width === 0) return;
const scale = rect.width / W;
const svgX = (event.clientX - rect.left) / scale;
if (pointCount <= 1) {
hoverIndex = 0;
return;
}
const t = (svgX - pad.l) / innerW;
const i = Math.round(t * (pointCount - 1));
hoverIndex = Math.min(pointCount - 1, Math.max(0, i));
}
function clearHover() {
hoverIndex = null;
}
/** Rows shown in the tooltip for the currently hovered index. */
const hoverRows = $derived.by(() => {
const i = hoverIndex;
if (i === null) return [];
return series.map((s) => ({
name: s.name,
color: s.color,
value: s.points[i]?.value ?? null
}));
});
const hoveredDate = $derived.by(() => {
if (hoverIndex === null) return null;
for (const s of series) {
const point = s.points[hoverIndex];
if (point) return point.date;
}
return null;
});
const hasHoverValues = $derived(hoverRows.some((r) => r.value !== null));
const tooltipLeft = $derived(
hoverIndex === null ? 50 : Math.min(85, Math.max(15, (xAt(hoverIndex) / W) * 100))
);
function formatValue(value: number | null): string {
if (value === null) return '—';
const rounded = Number.isInteger(value) ? value : Number(value.toFixed(1));
return `${rounded.toLocaleString()}${ySuffix}`;
}
</script>
<div
class="relative w-full touch-pan-y"
bind:this={wrapperEl}
role="presentation"
onpointermove={handlePointerMove}
onpointerleave={clearHover}
onpointercancel={clearHover}
>
<svg
viewBox={`0 0 ${W} ${height}`}
class="w-full text-gray-400"
style={`height:${height}px`}
role="img"
>
<!-- gridlines -->
{#each [0, 0.5, 1] as t}
<line
x1={pad.l}
x2={W - pad.r}
y1={pad.t + innerH * t}
y2={pad.t + innerH * t}
stroke="currentColor"
stroke-opacity="0.15"
stroke-dasharray="2 3"
/>
{/each}
<text x={4} y={pad.t + 4} fill="currentColor" fill-opacity="0.8" font-size="9">
{domain.hi.toFixed(0)}{ySuffix}
</text>
<text x={4} y={pad.t + innerH} fill="currentColor" fill-opacity="0.8" font-size="9">
{domain.lo.toFixed(0)}{ySuffix}
</text>
{#each paths as s (s.name)}
{#each s.segments as d}
<polyline
points={d}
fill="none"
stroke={s.color}
stroke-width="1.75"
stroke-linejoin="round"
stroke-linecap="round"
vector-effect="non-scaling-stroke"
opacity={hoverIndex === null ? 1 : 0.45}
style="transition:opacity 120ms ease"
/>
{/each}
{/each}
{#if hoverIndex !== null}
<!-- crosshair -->
<line
x1={xAt(hoverIndex)}
x2={xAt(hoverIndex)}
y1={pad.t}
y2={pad.t + innerH}
stroke="currentColor"
stroke-opacity="0.5"
stroke-dasharray="3 3"
vector-effect="non-scaling-stroke"
/>
<!-- highlighted points -->
{#each hoverRows as row (row.name)}
{#if row.value !== null}
<circle
cx={xAt(hoverIndex)}
cy={yAt(row.value)}
r="3.5"
fill={row.color}
stroke="#0b0b12"
stroke-width="1.5"
vector-effect="non-scaling-stroke"
/>
{/if}
{/each}
{/if}
<text x={pad.l} y={height - 6} fill="currentColor" fill-opacity="0.8" font-size="9">
{firstLabel}
</text>
<text
x={W - pad.r}
y={height - 6}
text-anchor="end"
fill="currentColor"
fill-opacity="0.8"
font-size="9"
>
{lastLabel}
</text>
</svg>
{#if hoverIndex !== null && hoveredDate !== null && hasHoverValues}
<div
class="pointer-events-none absolute top-1 z-10 -translate-x-1/2 rounded-lg border border-white/15 bg-gray-900/95 px-2.5 py-1.5 text-[11px] leading-tight text-gray-100 shadow-lg backdrop-blur-sm"
style={`left:${tooltipLeft}%`}
>
<p class="mb-1 font-semibold text-gray-300">{hoveredDate}</p>
<ul class="flex flex-col gap-0.5">
{#each hoverRows as row (row.name)}
<li class="flex items-center justify-between gap-3">
<span class="inline-flex items-center gap-1.5">
<span class="h-2 w-2 shrink-0 rounded-full" style={`background:${row.color}`}></span>
{row.name}
</span>
<span class="font-semibold tabular-nums text-gray-100">{formatValue(row.value)}</span>
</li>
{/each}
</ul>
</div>
{/if}
{#if showLegend && series.length > 1}
<div class="mt-1 flex flex-wrap gap-3 text-[11px] text-gray-300">
{#each series as s (s.name)}
<span class="inline-flex items-center gap-1">
<span class="h-2 w-2 rounded-full" style={`background:${s.color}`}></span>
{s.name}
</span>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,49 @@
<script lang="ts">
interface Props {
values: number[];
width?: number;
height?: number;
class?: string;
stroke?: string;
}
let {
values,
width = 120,
height = 32,
class: className = '',
stroke = 'currentColor'
}: Props = $props();
const points = $derived.by(() => {
if (values.length === 0) return '';
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min || 1;
return values
.map((v, i) => {
const x = values.length === 1 ? width / 2 : (i / (values.length - 1)) * width;
const y = height - ((v - min) / span) * (height - 4) - 2;
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(' ');
});
</script>
<svg
viewBox={`0 0 ${width} ${height}`}
class={className}
preserveAspectRatio="none"
aria-hidden="true"
>
{#if points}
<polyline
{points}
fill="none"
{stroke}
stroke-width="1.5"
stroke-linejoin="round"
stroke-linecap="round"
vector-effect="non-scaling-stroke"
/>
{/if}
</svg>
@@ -0,0 +1,32 @@
<script lang="ts">
import type { SignalSeverity } from '$lib/types/stats';
interface Props {
severity: SignalSeverity;
children?: import('svelte').Snippet;
label?: string;
}
let { severity, children, label }: Props = $props();
const styles: Record<SignalSeverity, string> = {
good: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30',
info: 'bg-sky-500/15 text-sky-300 border-sky-500/30',
warn: 'bg-amber-500/15 text-amber-200 border-amber-500/30',
bad: 'bg-rose-500/15 text-rose-300 border-rose-500/30'
};
const dots: Record<SignalSeverity, string> = {
good: 'bg-emerald-400',
info: 'bg-sky-400',
warn: 'bg-amber-400',
bad: 'bg-rose-400'
};
</script>
<span
class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-semibold {styles[
severity
]}"
>
<span class="h-1.5 w-1.5 rounded-full {dots[severity]}"></span>
{#if children}{@render children()}{:else}{label}{/if}
</span>
@@ -0,0 +1,147 @@
<script lang="ts">
import { bibleBooks, type BibleSection, type Testament } from '$lib/types/bible';
import type { BookStat } from '$lib/types/stats';
interface Props {
coverage: BookStat[];
}
let { coverage }: Props = $props();
interface Tile extends BookStat {
name: string;
}
interface SectionGroup {
section: BibleSection;
testament: Testament;
books: Tile[];
days: number;
plays: number;
covered: number;
firstTryWeightedSum: number;
firstTryPlays: number;
}
const byId = $derived(new Map(coverage.map((c) => [c.bookId, c])));
const maxDays = $derived(Math.max(1, ...coverage.map((c) => c.days)));
const totalDays = $derived(coverage.reduce((a, b) => a + b.days, 0));
const totalPlays = $derived(coverage.reduce((a, b) => a + b.plays, 0));
/** Red → orange → yellow → green, driven by days featured relative to the busiest book. */
function coverageColor(days: number): string {
if (days <= 0) return 'hsl(220, 9%, 55%)';
const t = Math.min(1, days / maxDays);
return `hsl(${Math.round(t * 142)}, 78%, 55%)`;
}
const legendStops = [0, 0.25, 0.5, 0.75, 1];
const legendGradient = $derived(
`linear-gradient(90deg, ${legendStops
.map((t) => `hsl(${Math.round(t * 142)}, 78%, 55%) ${Math.round(t * 100)}%`)
.join(', ')})`
);
const legendTicks = $derived(legendStops.map((t) => Math.round(t * maxDays)));
/** Consecutive runs of books sharing a section (preserves canonical order). */
const groups = $derived.by(() => {
const out: SectionGroup[] = [];
for (const book of bibleBooks) {
const stat = byId.get(book.id);
if (!stat) continue;
let group = out[out.length - 1];
if (!group || group.section !== book.section || group.testament !== book.testament) {
group = {
section: book.section,
testament: book.testament,
books: [],
days: 0,
plays: 0,
covered: 0,
firstTryWeightedSum: 0,
firstTryPlays: 0
};
out.push(group);
}
group.books.push({ ...stat, name: book.name });
group.days += stat.days;
group.plays += stat.plays;
if (stat.days > 0) group.covered++;
if (stat.plays > 0) {
group.firstTryWeightedSum += stat.firstTryPct * stat.plays;
group.firstTryPlays += stat.plays;
}
}
return out;
});
</script>
<div class="flex w-full flex-col gap-5">
<!-- Numeric colour legend -->
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between text-[10px] font-semibold uppercase tracking-wider text-gray-400">
<span>Featured less</span>
<span>Days featured →</span>
<span>Featured more</span>
</div>
<div class="h-3 w-full rounded-full ring-1 ring-inset ring-white/10" style={`background:${legendGradient}`}></div>
<div class="flex justify-between text-[10px] tabular-nums text-gray-300">
{#each legendTicks as tick}
<span class="font-semibold">{tick}</span>
{/each}
</div>
<p class="flex items-center gap-1.5 text-[10px] text-gray-500">
<span class="inline-block h-2.5 w-2.5 rounded-sm ring-1 ring-inset ring-white/10" style="background:hsl(220, 9%, 55%)"></span>
never featured (0 days)
</p>
</div>
<!-- Sections -->
<div class="flex flex-col gap-6">
{#each groups as group (group.testament + group.section)}
{@const share = totalDays ? Math.round((group.days / totalDays) * 100) : 0}
{@const avgFirstTry = group.firstTryPlays
? Math.round(group.firstTryWeightedSum / group.firstTryPlays)
: null}
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b border-white/10 pb-1">
<h3 class="flex items-center gap-2 text-sm font-bold text-gray-100">
{group.section}
<span class="rounded bg-white/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-gray-300">
{group.testament === 'old' ? 'OT' : 'NT'}
</span>
</h3>
<p class="text-[11px] text-gray-400">
<span class="font-semibold text-gray-200">{group.covered}/{group.books.length}</span> books covered ·
<span class="font-semibold text-gray-200">{group.days}</span> days ({share}%) ·
<span class="font-semibold text-gray-200">{group.plays}</span> plays
{#if avgFirstTry !== null}
· avg first-try <span class="font-semibold text-gray-200">{avgFirstTry}%</span>
{/if}
</p>
</div>
<div class="h-1 w-full overflow-hidden rounded-full bg-white/5">
<div
class="h-full rounded-full bg-white/40"
style={`width:${share}%`}
></div>
</div>
<div class="grid grid-cols-4 gap-1.5 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-11">
{#each group.books as book (book.bookId)}
<div
class="flex min-h-11 flex-col items-center justify-center rounded-lg bg-white/5 px-1 py-1 text-center ring-1 ring-inset ring-white/10 transition-colors duration-100 hover:bg-white/10"
title={`${book.name}: featured ${book.days} day${book.days === 1 ? '' : 's'}, ${book.plays} play${book.plays === 1 ? '' : 's'}`}
>
<span class="text-[10px] font-bold uppercase tracking-wider text-gray-400">{book.bookId}</span>
<span class="text-base font-bold leading-tight" style={`color:${coverageColor(book.days)}`}>{book.days}</span>
</div>
{/each}
</div>
</div>
{/each}
</div>
<p class="text-[11px] text-gray-500">
66 books over the last 180 days · {totalDays} featured days · {totalPlays} plays. Colour is scaled
to the busiest book ({maxDays} day{maxDays === 1 ? '' : 's'}). Hover a tile for full details.
</p>
</div>
@@ -0,0 +1,61 @@
<script lang="ts">
import DateNavigator from './DateNavigator.svelte';
import SettingsMenu from './SettingsMenu.svelte';
import { formatDateLabel } from '$lib/utils/stats-format';
import type { StatsSettings } from '$lib/types/stats';
interface Props {
date: string;
today: string;
generatedAt: Date;
buildKind: string;
matured: boolean;
stale: boolean;
settings: StatsSettings;
}
let { date, today, generatedAt, buildKind, matured, stale, settings }: Props = $props();
const matureHref = $derived(
`/admin/stats?date=${date}${matured ? '' : '&mature=1'}`
);
const generatedLabel = $derived(
generatedAt instanceof Date ? generatedAt.toISOString().slice(0, 16).replace('T', ' ') : ''
);
</script>
<header
class="sticky top-0 z-20 -mx-4 mb-4 border-b border-white/10 bg-gray-950/85 px-4 py-3 backdrop-blur"
>
<div class="mx-auto flex max-w-6xl flex-col gap-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<a href="/" class="text-sm text-gray-400 hover:text-gray-200">← Game</a>
<h1 class="text-xl font-bold text-gray-100">Growth Console</h1>
</div>
<div class="flex flex-wrap items-center gap-2">
<DateNavigator {date} {today} mature={matured} />
<a
href={matureHref}
class="rounded-lg border px-3 py-1.5 text-xs font-semibold {matured
? 'border-amber-400/50 bg-amber-400/15 text-amber-200'
: 'border-white/10 bg-white/5 text-gray-300 hover:bg-white/10'}"
>
{matured ? 'Matured view ✓' : 'Mature this view'}
</a>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<p class="text-xs text-gray-400">
Showing <span class="font-semibold text-gray-200">{formatDateLabel(date)}</span>
· snapshot built {generatedLabel} UTC
{#if buildKind}<span class="uppercase tracking-wide"> · {buildKind}</span>{/if}
{#if matured}
<span class="font-semibold text-amber-300"> · Matured — uses data after this date.</span>
{/if}
</p>
</div>
<SettingsMenu {settings} {stale} />
</div>
</header>
@@ -0,0 +1,65 @@
<script lang="ts">
import { goto } from '$app/navigation';
interface Props {
date: string;
today: string;
mature: boolean;
}
let { date, today, mature }: Props = $props();
function shift(dateStr: string, n: number): string {
const d = new Date(dateStr + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + n);
return d.toISOString().slice(0, 10);
}
const hrefFor = (d: string) => `/admin/stats?date=${d}${mature ? '&mature=1' : ''}`;
const prev = $derived(shift(date, -1));
const next = $derived(shift(date, 1));
const nextDisabled = $derived(next > today);
function onDateChange(event: Event) {
const value = (event.currentTarget as HTMLInputElement).value;
if (value) goto(hrefFor(value));
}
</script>
<div class="flex flex-wrap items-center gap-2">
<a
href={hrefFor(prev)}
class="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-gray-200 hover:bg-white/10"
aria-label="Previous day"
>
←
</a>
<input
type="date"
value={date}
max={today}
onchange={onDateChange}
class="rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 text-sm text-gray-100 focus:border-blue-400 focus:outline-none"
/>
{#if nextDisabled}
<span
class="cursor-not-allowed rounded-lg border border-white/5 bg-white/5 px-3 py-1.5 text-sm text-gray-600"
aria-label="Next day (unavailable)"
>
→
</span>
{:else}
<a
href={hrefFor(next)}
class="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-gray-200 hover:bg-white/10"
aria-label="Next day"
>
→
</a>
{/if}
<a
href={hrefFor(today)}
class="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-semibold text-gray-300 hover:bg-white/10"
>
Latest
</a>
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import Container from '$lib/components/Container.svelte';
</script>
<section class="flex flex-col gap-3">
<h2 class="text-lg font-bold text-gray-100">7 · Feature Impact</h2>
<Container class="p-4 w-full items-start text-left gap-2">
<p class="font-semibold text-gray-100">Needs data</p>
<p class="text-xs text-gray-400">
Feature-usage events are deferred. The <code>analytics_events</code> table already supports
them; only the emit points remain. Once added, this panel will show:
</p>
<ul class="list-disc pl-5 text-xs text-gray-400">
<li>Imposter-mode entries and their return rate</li>
<li>Hint reveals per finish and whether they help retention</li>
<li>Progress-page visits as a signup/retention signal</li>
<li>Verse submissions and their effect on next-day return</li>
</ul>
</Container>
</section>
+54
View File
@@ -0,0 +1,54 @@
<script lang="ts">
import Container from '$lib/components/Container.svelte';
interface GlossaryEntry {
name: string;
definition: string;
action: string;
}
/** Mirrors statistics-plan.md §5. Single source for on-page definitions. */
const GLOSSARY: GlossaryEntry[] = [
{ name: 'Players today', definition: 'Finished the puzzle yesterday.', action: 'Investigate content or traffic.' },
{ name: 'WAU / MAU', definition: 'Played at least once in the last 7 / 30 days.', action: 'Reach vs habit.' },
{ name: 'Core players', definition: 'Finished 4+ times this week.', action: 'If falling, it is a retention problem.' },
{ name: 'Returning share', definition: 'Played before today vs. first time.', action: 'Low = acquisition-heavy and leaky.' },
{ name: 'D1 return', definition: 'First-timers who came back the next day.', action: 'The number one activation lever.' },
{ name: 'Return rate (any)', definition: 'Played again within N days.', action: 'Onboarding quality.' },
{ name: 'Overall return rate', definition: 'Ever played more than once.', action: 'Overall stickiness.' },
{ name: 'Net growth', definition: 'New + resurrected − churned.', action: 'Is the base expanding?' },
{ name: 'Completion rate', definition: 'Started → finished.', action: 'Friction or confusion.' },
{ name: 'Signup rate', definition: 'Players who made an account.', action: 'Account value.' },
{ name: 'First-try rate', definition: 'Solved with no wrong guesses.', action: 'Too high = giveaway; too low = brutal.' },
{ name: 'Share rate', definition: 'Shares ÷ finishes.', action: 'Virality.' },
{ name: 'Streak survival', definition: 'Chance a streak continues.', action: 'Streak feature return on investment.' }
];
</script>
<section class="flex flex-col gap-3">
<h2 class="text-lg font-bold text-gray-100">8 · Notes &amp; Glossary</h2>
<Container class="p-2 md:p-4 w-full overflow-x-auto">
<table class="w-full text-left text-sm text-gray-200">
<thead class="border-b border-white/10 text-xs uppercase text-gray-400">
<tr>
<th class="px-2 py-2 md:px-3">Metric</th>
<th class="px-2 py-2 md:px-3">Plain definition</th>
<th class="px-2 py-2 md:px-3">What to do if it's off</th>
</tr>
</thead>
<tbody>
{#each GLOSSARY as entry (entry.name)}
<tr class="border-b border-white/5 align-top">
<td class="px-2 py-2 font-medium md:px-3">{entry.name}</td>
<td class="px-2 py-2 text-gray-300 md:px-3">{entry.definition}</td>
<td class="px-2 py-2 text-gray-400 md:px-3">{entry.action}</td>
</tr>
{/each}
</tbody>
</table>
</Container>
<p class="text-xs text-gray-500">
Snapshots are point-in-time. A day where a metric did not exist shows
“—”.
</p>
</section>
@@ -0,0 +1,96 @@
<script lang="ts">
import Container from '$lib/components/Container.svelte';
import MiniLineChart from '$lib/components/charts/MiniLineChart.svelte';
import BarList from '$lib/components/charts/BarList.svelte';
import type { SnapshotPayload } from '$lib/types/stats';
import { signed, numOrDash } from '$lib/utils/stats-format';
interface Props {
payload: SnapshotPayload;
}
let { payload }: Props = $props();
const series = $derived(payload.dailySeries.slice(-60));
const hasStarted = $derived(payload.meta.hasStartedData);
const signupTotal = $derived(payload.growth.signups.reduce((a, b) => a + b.count, 0));
</script>
<section class="flex flex-col gap-3">
<h2 class="text-lg font-bold text-gray-100">2 · Growth Loop</h2>
<Container class="p-4 w-full items-stretch text-left gap-2">
<p class="font-semibold text-gray-100">New players per day</p>
<p class="text-xs text-gray-400">First-time players each day. Hover later for post annotations.</p>
<MiniLineChart
series={[{ name: 'New players', color: '#60a5fa', points: series.map((d) => ({ date: d.date, value: d.newPlayers })) }]}
showLegend={false}
/>
</Container>
<div class="grid gap-3 md:grid-cols-2">
<Container class="p-4 w-full items-stretch text-left gap-2">
<p class="font-semibold text-gray-100">Acquisition vs churn (7d)</p>
<p class="text-xs text-gray-400">
New + resurrected − churned = net. Week-over-week.
</p>
<BarList
signed
items={[
{ label: 'New', value: payload.activity.newPlayers, color: 'rgba(96,165,250,0.6)' },
{ label: 'Resurrected', value: payload.activity.resurrected, color: 'rgba(52,211,153,0.6)' },
{ label: 'Churned', value: -payload.activity.churned, color: 'rgba(251,113,133,0.6)' },
{ label: 'Net', value: payload.activity.netGrowth, color: 'rgba(167,139,250,0.6)' }
]}
/>
<p class="text-xs text-gray-400">Net growth: <span class="font-semibold text-gray-200">{signed(payload.activity.netGrowth)}</span></p>
</Container>
<Container class="p-4 w-full items-stretch text-left gap-2">
<p class="font-semibold text-gray-100">Lifecycle funnel</p>
<p class="text-xs text-gray-400">
Of everyone active in the tracked window, how many reached each play count.
</p>
<BarList
items={payload.growth.lifecycleFunnel.map((f) => ({
label: f.label,
value: f.players,
color: 'rgba(52,211,153,0.5)'
}))}
/>
</Container>
</div>
<div class="grid gap-3 md:grid-cols-2">
<Container class="p-4 w-full items-stretch text-left gap-2">
<p class="font-semibold text-gray-100">Started → finished</p>
{#if hasStarted}
<p class="text-xs text-gray-400">
Completion rate {payload.kpis.completionRate ?? '—'}%. Started counts distinct
players who loaded the verse.
</p>
<MiniLineChart
series={[
{ name: 'Started', color: '#f59e0b', points: series.map((d) => ({ date: d.date, value: d.started })) },
{ name: 'Finished', color: '#34d399', points: series.map((d) => ({ date: d.date, value: d.completions })) }
]}
/>
{:else}
<p class="text-sm text-gray-400">
Needs data. Once players load the game this shows start → finish.
</p>
{/if}
</Container>
<Container class="p-4 w-full items-stretch text-left gap-2">
<p class="font-semibold text-gray-100">Signups</p>
<p class="text-xs text-gray-400">
Account creations ({numOrDash(signupTotal)} in the last 90 days). Existing accounts show
“unknown” date.
</p>
<MiniLineChart
series={[{ name: 'Signups', color: '#a78bfa', points: payload.growth.signups.map((s) => ({ date: s.date, value: s.count })) }]}
showLegend={false}
/>
</Container>
</div>
</section>
+68
View File
@@ -0,0 +1,68 @@
<script lang="ts">
import Container from '$lib/components/Container.svelte';
import MiniLineChart from '$lib/components/charts/MiniLineChart.svelte';
import BarList from '$lib/components/charts/BarList.svelte';
import CalendarHeatmap from '$lib/components/charts/CalendarHeatmap.svelte';
import HeatmapGrid from '$lib/components/charts/HeatmapGrid.svelte';
import type { SnapshotPayload } from '$lib/types/stats';
interface Props {
payload: SnapshotPayload;
}
let { payload }: Props = $props();
const dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const hourLabels = Array.from({ length: 24 }, (_, h) => (h % 3 === 0 ? String(h) : ''));
</script>
<section class="flex flex-col gap-3">
<h2 class="text-lg font-bold text-gray-100">5 · Habit Lab</h2>
<div class="grid gap-3 md:grid-cols-2">
<Container class="p-4 w-full items-stretch text-left gap-2">
<p class="font-semibold text-gray-100">Streak survival</p>
<p class="text-xs text-gray-400">
If you've played N days in a row, how likely are you to play day N+1.
</p>
<MiniLineChart
series={[
{
name: 'Survival',
color: '#34d399',
points: payload.habit.streakSurvival.map((s) => ({ date: String(s.length), value: s.probability }))
}
]}
showLegend={false}
ySuffix="%"
/>
</Container>
<Container class="p-4 w-full items-stretch text-left gap-2">
<p class="font-semibold text-gray-100">Current streak lengths</p>
<p class="text-xs text-gray-400">Players whose current run has reached each length.</p>
<BarList
items={payload.habit.streakLengths.map((b) => ({ label: `${b.label} days`, value: b.value, color: 'rgba(251,191,36,0.5)' }))}
/>
</Container>
</div>
<Container class="p-4 w-full items-stretch text-left gap-2">
<p class="font-semibold text-gray-100">Daily finishes (last 120 days)</p>
<CalendarHeatmap data={payload.habit.calendar} />
</Container>
<Container class="p-4 w-full items-stretch text-left gap-2">
<p class="font-semibold text-gray-100">When people play</p>
<p class="text-xs text-gray-400">
Finishes by weekday and hour. Times are server time (UTC) and therefore approximate.
</p>
<HeatmapGrid
rowLabels={dayLabels}
colLabels={hourLabels}
values={payload.habit.weekdayHour}
color="96,165,250"
cellSize={16}
format={(v) => String(v)}
/>
</Container>
</section>
+61
View File
@@ -0,0 +1,61 @@
<script lang="ts">
import Container from '$lib/components/Container.svelte';
import Sparkline from '$lib/components/charts/Sparkline.svelte';
import type { SignalSeverity } from '$lib/types/stats';
interface Props {
label: string;
value: string;
caption?: string;
delta?: string;
deltaDirection?: 'up' | 'down' | 'flat';
sparkValues?: number[];
severity?: SignalSeverity;
}
let {
label,
value,
caption,
delta,
deltaDirection = 'flat',
sparkValues = [],
severity
}: Props = $props();
const deltaClass = $derived(
deltaDirection === 'up'
? 'text-emerald-400'
: deltaDirection === 'down'
? 'text-rose-400'
: 'text-gray-400'
);
const strokeColor = $derived(
severity === 'bad'
? '#fb7185'
: severity === 'warn'
? '#fbbf24'
: '#34d399'
);
</script>
<Container class="p-4 w-full items-start text-left">
<div class="flex items-start justify-between gap-2 w-full">
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-400">
{label}
</p>
{#if delta}
<span class="text-xs font-semibold {deltaClass}">{delta}</span>
{/if}
</div>
<p class="mt-1 text-2xl font-bold text-gray-100">{value}</p>
{#if sparkValues.length > 1}
<Sparkline
values={sparkValues}
stroke={strokeColor}
class="mt-2 w-full h-8 text-gray-500"
/>
{/if}
{#if caption}
<p class="mt-1 text-xs leading-snug text-gray-400">{caption}</p>
{/if}
</Container>
+103
View File
@@ -0,0 +1,103 @@
<script lang="ts">
import KpiCard from './KpiCard.svelte';
import StatusPill from '$lib/components/charts/StatusPill.svelte';
import type { Signal, SnapshotPayload, StatsSettings } from '$lib/types/stats';
import {
delta,
numOrDash,
pctOrDash,
signed
} from '$lib/utils/stats-format';
interface Props {
payload: SnapshotPayload;
settings: StatsSettings;
verdict: string;
topSignal: Signal | null;
}
let { payload, settings, verdict, topSignal }: Props = $props();
const series = $derived(payload.dailySeries.slice(-30));
const spark = $derived(series.map((d) => d.completions));
const newSpark = $derived(series.map((d) => d.newPlayers));
const d1Severity = $derived(
payload.kpis.d1Return === null
? 'info'
: payload.kpis.d1Return >= settings.d1_target
? 'good'
: payload.kpis.d1Return < 12
? 'bad'
: 'warn'
);
</script>
<section class="flex flex-col gap-3">
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-lg font-bold text-gray-100">1 · Pulse</h2>
{#if topSignal}
<StatusPill severity={topSignal.severity}>{topSignal.title}</StatusPill>
{/if}
</div>
<div
class="rounded-2xl border border-white/10 bg-white/5 p-3 text-sm text-gray-200"
>
{verdict}
</div>
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<KpiCard
label="Players today"
value={numOrDash(payload.activity.dau)}
delta={delta(payload.activity.dau, payload.activity.previous?.dau).text}
deltaDirection={delta(payload.activity.dau, payload.activity.previous?.dau).direction}
sparkValues={spark}
caption="Finished yesterday's verse."
/>
<KpiCard
label="Players this week"
value={numOrDash(payload.activity.wau)}
delta={delta(payload.activity.wau, payload.activity.previous?.wau).text}
deltaDirection={delta(payload.activity.wau, payload.activity.previous?.wau).direction}
caption="Distinct players in the last 7 days."
/>
<KpiCard
label="Players this month"
value={numOrDash(payload.activity.mau)}
delta={delta(payload.activity.mau, payload.activity.previous?.mau).text}
deltaDirection={delta(payload.activity.mau, payload.activity.previous?.mau).direction}
caption="Distinct players in the last 30 days."
/>
<KpiCard
label="Core players"
value={numOrDash(payload.activity.core)}
delta={delta(payload.activity.core, payload.activity.corePrevious).text}
deltaDirection={delta(payload.activity.core, payload.activity.corePrevious).direction}
caption={`Finished ${settings.core_plays_per_week}+ times this week.`}
/>
<KpiCard
label="New players"
value={numOrDash(payload.activity.newPlayers)}
sparkValues={newSpark}
caption="First-time players in the last 7 days."
/>
<KpiCard
label="Returning share"
value={pctOrDash(payload.activity.returningShare)}
caption="Of yesterday's players, how many had played before."
/>
<KpiCard
label="D1 return"
value={pctOrDash(payload.kpis.d1Return)}
severity={d1Severity}
delta={`target ${settings.d1_target}%`}
caption="First-timers who came back the next day."
/>
<KpiCard
label="Net growth (7d)"
value={signed(payload.activity.netGrowth)}
caption={`New + returning − churned (${payload.activity.newPlayers} + ${payload.activity.resurrected} − ${payload.activity.churned}).`}
/>
</div>
</section>
@@ -0,0 +1,83 @@
<script lang="ts">
import Container from '$lib/components/Container.svelte';
import CohortHeatmap from '$lib/components/charts/CohortHeatmap.svelte';
import MiniLineChart from '$lib/components/charts/MiniLineChart.svelte';
import BarList from '$lib/components/charts/BarList.svelte';
import type { SnapshotPayload, StatsSettings } from '$lib/types/stats';
import { pctOrDash, numOrDash } from '$lib/utils/stats-format';
interface Props {
payload: SnapshotPayload;
settings: StatsSettings;
}
let { payload, settings }: Props = $props();
const mix = $derived(payload.retention.segmentMix);
const colors = ['#60a5fa', '#34d399', '#a78bfa', '#f59e0b', '#fb7185'];
</script>
<section class="flex flex-col gap-3">
<h2 class="text-lg font-bold text-gray-100">3 · Retention Lab</h2>
<div class="grid gap-3 md:grid-cols-2">
<Container class="p-4 w-full items-start text-left">
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-400">
Overall return rate
</p>
<p class="text-2xl font-bold text-gray-100">
{pctOrDash(payload.kpis.overallReturnRate)}
</p>
<p class="text-xs text-gray-400">
Share of all players who ever played more than once. Target:
{settings.return7_target}%+ within 7 days.
</p>
</Container>
<Container class="p-4 w-full items-start text-left">
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-400">
Time to second play
</p>
<BarList items={payload.retention.timeToSecondPlay.map((b) => ({ label: b.label, value: b.value }))} />
</Container>
</div>
<Container class="p-4 w-full items-stretch text-left gap-2">
<p class="font-semibold text-gray-100">Cohort retention triangle</p>
<p class="text-xs text-gray-400">
Each row is a starting week; each column is weeks later. Darker = more came back.
Only resolved weeks are filled.
</p>
<CohortHeatmap cohorts={payload.retention.cohorts} weeks={8} />
</Container>
<Container class="p-4 w-full items-stretch text-left gap-2">
<p class="font-semibold text-gray-100">Retention curves</p>
<p class="text-xs text-gray-400">
D1 / D7 / D30 return over time. Latest resolvable cohorts only.
</p>
<MiniLineChart
series={[
{ name: 'D1', color: '#34d399', points: payload.retention.curves.d1 },
{ name: 'D7', color: '#60a5fa', points: payload.retention.curves.d7 },
{ name: 'D30', color: '#a78bfa', points: payload.retention.curves.d30 }
]}
ySuffix="%"
/>
</Container>
<Container class="p-4 w-full items-stretch text-left gap-2">
<p class="font-semibold text-gray-100">Segment mix over time</p>
<p class="text-xs text-gray-400">
Active players split into new, core, returning, at-risk and churned. Total tracked:
{numOrDash(payload.activity.mau)} recently active.
</p>
<MiniLineChart
series={[
{ name: 'New', color: colors[0], points: mix.map((m) => ({ date: m.date, value: m.new })) },
{ name: 'Core', color: colors[1], points: mix.map((m) => ({ date: m.date, value: m.core })) },
{ name: 'Returning', color: colors[2], points: mix.map((m) => ({ date: m.date, value: m.returning })) },
{ name: 'At risk', color: colors[3], points: mix.map((m) => ({ date: m.date, value: m.atRisk })) },
{ name: 'Churned', color: colors[4], points: mix.map((m) => ({ date: m.date, value: m.churned })) }
]}
/>
</Container>
</section>
@@ -0,0 +1,70 @@
<script lang="ts">
import Container from '$lib/components/Container.svelte';
import { SETTINGS_DEFAULTS, type SettingsKey, type StatsSettings } from '$lib/types/stats';
interface Props {
settings: StatsSettings;
stale?: boolean;
}
let { settings, stale = false }: Props = $props();
const FIELDS: { key: SettingsKey; label: string; hint: string; suffix?: string }[] = [
{ key: 'core_plays_per_week', label: 'Core player threshold', hint: 'Finishes in 7 days to count as core.' },
{ key: 'at_risk_days', label: 'At-risk after (days)', hint: 'Days inactive before a player is at risk.' },
{ key: 'churn_days', label: 'Churned after (days)', hint: 'Days inactive before a player is churned.' },
{ key: 'resurrect_gap_days', label: 'Resurrection gap (days)', hint: 'Gap that makes a return "resurrected".' },
{ key: 'active_window_days', label: 'Player-state window (days)', hint: 'How much player history is stored.' },
{ key: 'd1_target', label: 'D1 return target', hint: 'Target for next-day return.', suffix: '%' },
{ key: 'return7_target', label: '7-day return target', hint: 'Target for any return within 7 days.', suffix: '%' },
{ key: 'completion_target', label: 'Completion target', hint: 'Started → finished target.', suffix: '%' },
{ key: 'first_try_min', label: 'First-try band (min)', hint: 'Below this is too hard.', suffix: '%' },
{ key: 'first_try_max', label: 'First-try band (max)', hint: 'Above this is too easy.', suffix: '%' },
{ key: 'share_target', label: 'Share target', hint: 'Shares ÷ finishes target.', suffix: '%' },
{ key: 'anomaly_mad_threshold', label: 'Anomaly sensitivity', hint: 'Lower = more alerts.' }
];
</script>
<details class="group">
<summary
class="cursor-pointer list-none rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm font-semibold text-gray-200 hover:bg-white/10"
>
⚙️ Settings &amp; thresholds
{#if stale}
<span class="ml-2 text-xs font-normal text-amber-300"
>(snapshot definitions are outdated)</span
>
{/if}
</summary>
<Container class="mt-3 p-4 w-full items-stretch text-left gap-3">
<p class="text-xs text-gray-400">
Changing these re-reads history; snapshots themselves are unchanged.
</p>
<form method="POST" action="?/updateSettings" class="grid gap-3 sm:grid-cols-2">
{#each FIELDS as field (field.key)}
<label class="flex flex-col gap-1 text-xs text-gray-300">
<span class="font-semibold">{field.label}{field.suffix ? ` (${field.suffix})` : ''}</span>
<input
type="number"
name={field.key}
value={settings[field.key]}
step="any"
min={field.key === 'anomaly_mad_threshold' ? 0.5 : 0}
class="rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 text-sm text-gray-100 focus:border-blue-400 focus:outline-none"
/>
<span class="text-[11px] text-gray-500">{field.hint}</span>
</label>
{/each}
<div class="flex items-center gap-2 sm:col-span-2">
<button
type="submit"
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500"
>
Save settings
</button>
<span class="text-[11px] text-gray-500">
Defaults come from <code>SETTINGS_DEFAULTS</code> / <code>STATS_*</code> env vars.
</span>
</div>
</form>
</Container>
</details>
@@ -0,0 +1,41 @@
<script lang="ts">
import Container from '$lib/components/Container.svelte';
import StatusPill from '$lib/components/charts/StatusPill.svelte';
import type { Signal } from '$lib/types/stats';
interface Props {
signals: Signal[];
}
let { signals }: Props = $props();
const labels: Record<Signal['severity'], string> = {
good: 'On target',
info: 'Needs data',
warn: 'Watch',
bad: 'Act now'
};
</script>
<section class="flex flex-col gap-3">
<h2 class="text-lg font-bold text-gray-100">6 · Signals &amp; Actions</h2>
{#if signals.length === 0}
<p class="text-sm text-gray-400">No signals for this date.</p>
{:else}
<div class="grid gap-3 md:grid-cols-2">
{#each signals as signal (signal.id)}
<Container class="p-4 w-full items-start text-left gap-2">
<div class="flex items-center justify-between gap-2 w-full">
<p class="font-semibold text-gray-100">{signal.title}</p>
<StatusPill severity={signal.severity} label={labels[signal.severity]} />
</div>
<p class="text-sm text-gray-300">{signal.detail}</p>
{#if signal.action}
<p class="text-xs text-amber-200/90">
<span class="font-semibold">What to do:</span> {signal.action}
</p>
{/if}
</Container>
{/each}
</div>
{/if}
</section>
+172
View File
@@ -0,0 +1,172 @@
<script lang="ts">
import Container from '$lib/components/Container.svelte';
import MiniLineChart from '$lib/components/charts/MiniLineChart.svelte';
import BarList from '$lib/components/charts/BarList.svelte';
import BookCoverage from '$lib/components/stats/BookCoverage.svelte';
import type { SnapshotPayload } from '$lib/types/stats';
import { bibleBooks } from '$lib/types/bible';
interface Props {
payload: SnapshotPayload;
}
let { payload }: Props = $props();
const nameOf = (id: string) => bibleBooks.find((b) => b.id === id)?.name ?? id;
const coverage = $derived(payload.verseMix.coverage);
const retained = $derived(payload.verseMix.retentionByBook);
const avgD1 = $derived(
retained.length ? retained.reduce((a, b) => a + (b.d1Return ?? 0), 0) / retained.length : 0
);
const giveaways = $derived(
payload.verseMix.firstTryByBook
.filter((b) => b.firstTryPct >= 50 && b.plays >= 3)
.sort((a, b) => b.firstTryPct - a.firstTryPct)
.slice(0, 8)
);
const alarms = $derived(
retained
.filter((b) => b.plays >= 5 && (b.d1Return ?? 100) < avgD1 - 5)
.sort((a, b) => (a.d1Return ?? 0) - (b.d1Return ?? 0))
.slice(0, 8)
);
function bar(pct: number | null, color = 'rgba(52,211,153,0.6)'): string {
const w = pct === null ? 0 : Math.min(100, pct);
return `width:${w}%;background:${color}`;
}
</script>
<section class="flex flex-col gap-3">
<h2 class="text-lg font-bold text-gray-100">4 · Verse Mix Lab</h2>
<Container class="p-4 w-full items-stretch text-left gap-2">
<p class="font-semibold text-gray-100">66-book coverage (180 days)</p>
<p class="text-xs text-gray-400">
How often each book was the answer, grouped by canonical section. Red = rarely featured,
green = a staple. Aim for breadth, not repetition.
</p>
<BookCoverage {coverage} />
</Container>
<div class="grid gap-3 md:grid-cols-2">
<Container class="p-4 w-full items-stretch text-left gap-2">
<p class="font-semibold text-gray-100">Testament balance &amp; variety</p>
<p class="text-xs text-gray-400">
Weekly Old vs New Testament count, plus book-distribution entropy (higher = more variety).
</p>
<MiniLineChart
series={[
{ name: 'Old', color: '#f59e0b', points: payload.verseMix.testamentMix.map((t) => ({ date: t.date, value: t.old })) },
{ name: 'New', color: '#60a5fa', points: payload.verseMix.testamentMix.map((t) => ({ date: t.date, value: t.new })) }
]}
/>
<MiniLineChart
series={[{ name: 'Entropy (bits)', color: '#a78bfa', points: payload.verseMix.entropy }]}
showLegend={false}
height={120}
/>
</Container>
<Container class="p-4 w-full items-stretch text-left gap-2">
<p class="font-semibold text-gray-100">Freshness</p>
<p class="text-xs text-gray-400">
How long between repeats of the same book. More short gaps = repetitive mix.
</p>
<BarList
items={payload.verseMix.repeatIntervals.map((b) => ({ label: b.label, value: b.value, color: 'rgba(244,114,182,0.5)' }))}
/>
<p class="pt-2 text-xs text-gray-400">Achievability (last 30 days)</p>
<BarList
items={payload.verseMix.achievability.map((b) => ({ label: `${b.label} guess${b.label === '1' ? '' : 'es'}`, value: b.value, color: 'rgba(96,165,250,0.5)' }))}
/>
</Container>
</div>
<div class="grid gap-3 md:grid-cols-2">
<Container class="p-4 w-full items-stretch text-left gap-2">
<p class="font-semibold text-gray-100">Retention by difficulty</p>
<p class="text-xs text-gray-400">
Next-day return after solving in each guess bucket. Do hard days make people leave?
</p>
<table class="w-full text-left text-xs text-gray-300">
<thead class="text-gray-500">
<tr><th class="py-1">Guesses</th><th>Plays</th><th>D1 return</th></tr>
</thead>
<tbody>
{#each payload.verseMix.retentionByGuessBucket as row (row.bucket)}
<tr>
<td class="py-1">{row.bucket}</td>
<td>{row.plays}</td>
<td>
<div class="flex items-center gap-2">
<div class="h-2 w-24 overflow-hidden rounded bg-white/5">
<div class="h-full rounded" style={bar(row.d1)}></div>
</div>
<span>{row.d1 === null ? '—' : `${row.d1}%`}</span>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</Container>
<Container class="p-4 w-full items-stretch text-left gap-2">
<p class="font-semibold text-gray-100">Retention by book</p>
<p class="text-xs text-gray-400">
Books whose day had below-average next-day return (min 5 plays). Candidate retirements.
</p>
{#if alarms.length === 0}
<p class="text-sm text-gray-400">No clear churn outliers.</p>
{:else}
<table class="w-full text-left text-xs text-gray-300">
<thead class="text-gray-500">
<tr><th class="py-1">Book</th><th>Plays</th><th>D1</th><th>First-try</th></tr>
</thead>
<tbody>
{#each alarms as b (b.bookId)}
<tr>
<td class="py-1">{nameOf(b.bookId)}</td>
<td>{b.plays}</td>
<td>{b.d1Return === null ? '—' : `${b.d1Return}%`}</td>
<td>{b.firstTryPct}%</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</Container>
</div>
<Container class="p-4 w-full items-stretch text-left gap-2">
<p class="font-semibold text-gray-100">Giveaway detector</p>
<p class="text-xs text-gray-400">
Books solved first try more than half the time (min 3 plays). Likely self-revealing text —
down-weight in the curated mix.
</p>
{#if giveaways.length === 0}
<p class="text-sm text-gray-400">No giveaway outliers.</p>
{:else}
<table class="w-full text-left text-xs text-gray-300">
<thead class="text-gray-500">
<tr><th class="py-1">Book</th><th>Plays</th><th>First-try</th><th>Avg guesses</th></tr>
</thead>
<tbody>
{#each giveaways as b (b.bookId)}
<tr>
<td class="py-1">{nameOf(b.bookId)}</td>
<td>{b.plays}</td>
<td>{b.firstTryPct}%</td>
<td>{b.avgGuesses}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</Container>
<p class="text-xs text-gray-500">
Before/after the curated mix system will appear here once enough pre/post data exists.
</p>
</section>
+8
View File
@@ -3,6 +3,14 @@
// identity; this email is the sole exception (see spec "Admin /scheduled-verses").
export const ADMIN_EMAIL = 'geohpowell@gmail.com';
// Local-only escape hatch: set `DEV_ADMIN_ALL=true` in `.env` to treat every
// visitor as an admin so the private console can be tested on a dev machine.
// Explicitly disabled when NODE_ENV=production, so it can never apply to a
// deployed build even if the variable leaks.
export const DEV_ADMIN_ALL =
Bun.env.DEV_ADMIN_ALL === 'true' && Bun.env.NODE_ENV !== 'production';
export function isAdmin(email: string | null | undefined): boolean {
if (DEV_ADMIN_ALL) return true;
return !!email && email.toLowerCase() === ADMIN_EMAIL;
}
+2 -1
View File
@@ -102,7 +102,8 @@ export async function createUser(anonymousId: string, email: string, passwordHas
googleId: null,
firstName: firstName || null,
lastName: lastName || null,
isPrivate: false
isPrivate: false,
createdAt: new Date()
};
await db.insert(table.user).values(user);
return user;
+2 -1
View File
@@ -102,7 +102,8 @@ export async function createUser(anonymousId: string, email: string, passwordHas
googleId: null,
firstName: firstName || null,
lastName: lastName || null,
isPrivate: false
isPrivate: false,
createdAt: new Date()
};
await db.insert(table.user).values(user);
return user;
+58 -2
View File
@@ -1,4 +1,5 @@
import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
import { integer, sqliteTable, text, index, unique, uniqueIndex } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
export const user = sqliteTable('user', {
id: text('id').primaryKey(),
@@ -8,7 +9,10 @@ export const user = sqliteTable('user', {
passwordHash: text('password_hash'),
appleId: text('apple_id').unique(),
googleId: text('google_id').unique(),
isPrivate: integer('is_private', { mode: 'boolean' }).default(false)
isPrivate: integer('is_private', { mode: 'boolean' }).default(false),
// Additive: existing rows stay NULL ("unknown signup date"). New rows get a
// timestamp automatically via the client-side default function.
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date())
});
export const session = sqliteTable('session', {
@@ -65,3 +69,55 @@ export const verseSubmissions = sqliteTable('verse_submissions', {
]);
export type VerseSubmission = typeof verseSubmissions.$inferSelect;
// ── Statistics dashboard (see statistics-plan.md) ────────────────────────────
// One immutable row per "as of" day. Payload/config/playerState are JSON text.
export const statsSnapshot = sqliteTable('stats_snapshot', {
// as-of date, YYYY-MM-DD
date: text('date').primaryKey(),
// build time (server timestamp)
generatedAt: integer('generated_at', { mode: 'timestamp' }).notNull(),
// scheduled | lazy | reconstructed
buildKind: text('build_kind').notNull(),
// stable hash of the settings used at build time
configVersion: text('config_version').notNull(),
// all aggregate series / lab data
payload: text('payload').notNull(),
// compact per-player state for render-time segmentation (JSON)
playerState: text('player_state'),
// copy of the resolved settings used at build time (JSON)
config: text('config').notNull()
});
export type StatsSnapshot = typeof statsSnapshot.$inferSelect;
// Generic, extensible event log. Unlocks instrumentation B/D/E/F now; C later.
export const analyticsEvents = sqliteTable('analytics_events', {
id: text('id').primaryKey(),
name: text('name').notNull(),
anonymousId: text('anonymous_id'),
// Nullable so ON DELETE SET NULL keeps historical events when a user is deleted.
userId: text('user_id').references(() => user.id, { onDelete: 'set null' }),
date: text('date'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
metadata: text('metadata')
}, (table) => [
index('analytics_events_name_date_idx').on(table.name, table.date),
index('analytics_events_anonymous_id_date_idx').on(table.anonymousId, table.date),
// Dedupe verse_served per player per day (reloads), without collapsing
// legitimate repeat events like multiple shares in a day.
uniqueIndex('analytics_events_serve_unique')
.on(table.name, table.anonymousId, table.date)
.where(sql`${table.name} = 'verse_served'`)
]);
export type AnalyticsEvent = typeof analyticsEvents.$inferSelect;
// Simple key/value store for dashboard thresholds (env vars provide defaults).
export const dashboardSettings = sqliteTable('dashboard_settings', {
key: text('key').primaryKey(),
value: text('value').notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull()
});
export type DashboardSetting = typeof dashboardSettings.$inferSelect;
+37
View File
@@ -0,0 +1,37 @@
/** Pure UTC date arithmetic on `YYYY-MM-DD` strings. No DB, no Svelte. */
const DAY_MS = 1000 * 60 * 60 * 24;
export function addDays(dateStr: string, n: number): string {
const d = new Date(dateStr + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + n);
return d.toISOString().slice(0, 10);
}
/** Whole-day difference `b - a`. */
export function dayDiff(a: string, b: string): number {
return Math.round(
(new Date(b + 'T00:00:00Z').getTime() - new Date(a + 'T00:00:00Z').getTime()) / DAY_MS
);
}
/** The Sunday on or before `dateStr` (week cohorts are Sunday-aligned). */
export function startOfWeek(dateStr: string): string {
const day = new Date(dateStr + 'T00:00:00Z').getUTCDay(); // 0 = Sunday
return addDays(dateStr, -day);
}
/** All dates from `start` to `end` inclusive. */
export function dateRange(start: string, end: string): string[] {
const out: string[] = [];
for (let d = start; d <= end; d = addDays(d, 1)) out.push(d);
return out;
}
export function todayUtc(): string {
return new Date().toISOString().slice(0, 10);
}
export function isValidDate(dateStr: string): boolean {
return /^\d{4}-\d{2}-\d{2}$/.test(dateStr) && !Number.isNaN(new Date(dateStr + 'T00:00:00Z').getTime());
}
+4
View File
@@ -0,0 +1,4 @@
import { db } from '$lib/server/db';
/** The Drizzle database type (production db or a test db). */
export type StatsDb = typeof db;
+44
View File
@@ -0,0 +1,44 @@
import type { SnapshotPayload } from '$lib/types/stats';
import { sortSignals } from './signals';
/** Compose the ~6-line plain-language daily digest. Pure / testable. */
export function composeDigest(payload: SnapshotPayload): string {
const { activity, kpis } = payload;
const top = sortSignals(payload.signals)[0];
const lines = [
`**Bibdle stats — ${payload.asOfDate}**`,
`Players: ${activity.dau} today · ${activity.wau} this week · ${activity.mau} this month`,
`New ${activity.newPlayers} · returning ${activity.returningShare ?? '—'}% · net ${activity.netGrowth >= 0 ? '+' : ''}${activity.netGrowth}`,
`D1 return ${kpis.d1Return ?? '—'}% · 7-day ${kpis.return7 ?? '—'}%`,
top ? `Top signal: ${top.title} — ${top.detail}` : 'No signals.',
`https://bibdle.com/admin/stats?date=${payload.asOfDate}`
];
return lines.join('\n');
}
/**
* POST the digest to the admin Discord webhook. No-op (with a log) when the
* webhook is unset; never throws so a digest failure can't fail the build.
*/
export async function sendDigest(
payload: SnapshotPayload,
webhookUrl: string | undefined = Bun.env.DISCORD_ADMIN_WEBHOOK
): Promise<boolean> {
const message = composeDigest(payload);
if (!webhookUrl) {
console.log('[stats] DISCORD_ADMIN_WEBHOOK not set; digest skipped:\n' + message);
return false;
}
try {
const res = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: message })
});
if (!res.ok) console.error('[stats] digest webhook responded', res.status);
return res.ok;
} catch (err) {
console.error('[stats] digest failed', err);
return false;
}
}
+70
View File
@@ -0,0 +1,70 @@
import { db as defaultDb } from '$lib/server/db';
import { analyticsEvents } from '$lib/server/db/schema';
import { EVENT_NAMES, MAX_METADATA_BYTES, type EventName } from '$lib/types/stats';
import type { StatsDb } from './db';
export interface RecordEventInput {
name: string;
anonymousId?: string | null;
userId?: string | null;
date?: string | null;
metadata?: Record<string, unknown> | null;
}
export function isEventName(name: string): name is EventName {
return (EVENT_NAMES as readonly string[]).includes(name);
}
/**
* Safely serialize metadata, capping it at `MAX_METADATA_BYTES`. Oversized
* payloads are replaced with a marker rather than truncated into invalid JSON.
*/
export function encodeMetadata(metadata: Record<string, unknown> | null | undefined): string | null {
if (metadata == null) return null;
let json: string | undefined;
try {
json = JSON.stringify(metadata);
} catch {
return null;
}
if (json === undefined) return null;
if (Buffer.byteLength(json) > MAX_METADATA_BYTES) {
return JSON.stringify({ truncated: true });
}
return json;
}
/**
* Insert one analytics event.
*
* Always uses `ON CONFLICT DO NOTHING`: the only unique constraint is the
* partial `verse_served` index, so this dedupes reloads for `verse_served`
* while other events insert freely.
*
* Never throws — analytics must not break gameplay. Returns true on insert.
*/
export async function recordEvent(
input: RecordEventInput,
database: StatsDb = defaultDb
): Promise<boolean> {
const { name, anonymousId, userId, date, metadata } = input;
try {
const inserted = await database
.insert(analyticsEvents)
.values({
id: crypto.randomUUID(),
name,
anonymousId: anonymousId ?? null,
userId: userId ?? null,
date: date ?? null,
createdAt: new Date(),
metadata: encodeMetadata(metadata)
})
.onConflictDoNothing()
.returning({ id: analyticsEvents.id });
return inserted.length > 0;
} catch (err) {
console.error('[stats] failed to record event', name, err);
return false;
}
}
+478
View File
@@ -0,0 +1,478 @@
import type {
BookStat,
CohortRow,
CompletionRow,
HistogramBin,
VerseRow
} from '$lib/types/stats';
import { bibleBooks } from '$lib/types/bible';
import { addDays, dateRange, dayDiff, startOfWeek } from './dates';
// ── internal helpers ─────────────────────────────────────────────────────────
/** Unique play dates per player, sorted ascending. */
export function groupDatesByPlayer(completions: CompletionRow[]): Map<string, string[]> {
const map = new Map<string, Set<string>>();
for (const c of completions) {
let set = map.get(c.anonymousId);
if (!set) {
set = new Set();
map.set(c.anonymousId, set);
}
set.add(c.date);
}
const out = new Map<string, string[]>();
for (const [id, set] of map) out.set(id, [...set].sort());
return out;
}
/** First-ever play date per player. */
export function firstPlayDates(completions: CompletionRow[]): Map<string, string> {
const out = new Map<string, string>();
for (const c of completions) {
const current = out.get(c.anonymousId);
if (current === undefined || c.date < current) out.set(c.anonymousId, c.date);
}
return out;
}
function round1(n: number): number {
return Math.round(n * 10) / 10;
}
function pct(part: number, whole: number): number | null {
if (whole <= 0) return null;
return round1((part / whole) * 100);
}
// ── activity ─────────────────────────────────────────────────────────────────
/** Distinct players with a completion in `[end - windowDays + 1, end]`. */
export function activePlayers(
completions: CompletionRow[],
end: string,
windowDays: number
): number {
const start = addDays(end, -(windowDays - 1));
const set = new Set<string>();
for (const c of completions) {
if (c.date >= start && c.date <= end) set.add(c.anonymousId);
}
return set.size;
}
export interface NewVsReturning {
newPlayers: number;
returning: number;
returningShare: number | null;
}
/** Split a single day's players into first-timers and returning players. */
export function newVsReturning(completions: CompletionRow[], date: string): NewVsReturning {
const firsts = firstPlayDates(completions);
const players = new Set<string>();
for (const c of completions) if (c.date === date) players.add(c.anonymousId);
let newPlayers = 0;
let returning = 0;
for (const id of players) {
if (firsts.get(id) === date) newPlayers++;
else returning++;
}
return { newPlayers, returning, returningShare: pct(returning, players.size) };
}
export interface NetGrowth {
newPlayers: number;
resurrected: number;
churned: number;
netGrowth: number;
}
/**
* Growth accounting over a trailing window:
* `new + resurrected - churned`.
*
* - new: first-ever play inside the window
* - resurrected: played in the window after a gap longer than `resurrectGapDays`
* - churned: played in the preceding equal window but not in this one
*/
export function netGrowth(
completions: CompletionRow[],
end: string,
windowDays: number,
resurrectGapDays: number
): NetGrowth {
const windowStart = addDays(end, -(windowDays - 1));
const prevStart = addDays(windowStart, -windowDays);
const prevEnd = addDays(windowStart, -1);
const datesByPlayer = groupDatesByPlayer(completions);
let newPlayers = 0;
let resurrected = 0;
let churned = 0;
for (const dates of datesByPlayer.values()) {
const inWindow = dates.filter((d) => d >= windowStart && d <= end);
const inPrev = dates.filter((d) => d >= prevStart && d <= prevEnd);
if (inWindow.length > 0) {
const firstInWindow = inWindow[0];
const earlier = dates.filter((d) => d < firstInWindow);
if (earlier.length === 0) {
if (firstInWindow >= windowStart) newPlayers++;
} else {
const gap = dayDiff(earlier[earlier.length - 1], firstInWindow);
if (gap > resurrectGapDays) resurrected++;
}
} else if (inPrev.length > 0) {
churned++;
}
}
return { newPlayers, resurrected, churned, netGrowth: newPlayers + resurrected - churned };
}
// ── retention ────────────────────────────────────────────────────────────────
/**
* Fraction of players first seen on `cohortDate` who played again within
* `n` days. Returns null for cohorts smaller than `minSize`.
*/
export function returnWithin(
completions: CompletionRow[],
cohortDate: string,
n: number,
minSize = 1
): number | null {
const firsts = firstPlayDates(completions);
const datesByPlayer = groupDatesByPlayer(completions);
const end = addDays(cohortDate, n);
let cohort = 0;
let returned = 0;
for (const [id, first] of firsts) {
if (first !== cohortDate) continue;
cohort++;
const dates = datesByPlayer.get(id) ?? [];
if (dates.some((d) => d > cohortDate && d <= end)) returned++;
}
if (cohort < minSize) return null;
return pct(returned, cohort);
}
/** Next-day return rate for a cohort of first-timers. */
export function d1Return(completions: CompletionRow[], cohortDate: string, minSize = 1): number | null {
return returnWithin(completions, cohortDate, 1, minSize);
}
/**
* Weekly cohort triangle. Each row is a cohort (players whose first play fell
* in that week) and each column k is the share active k weeks later.
*/
export function cohortTriangle(
completions: CompletionRow[],
opts: { start: string; weeks: number; asOf?: string; minSize?: number }
): CohortRow[] {
const { start, weeks, asOf, minSize = 1 } = opts;
const firsts = firstPlayDates(completions);
const datesByPlayer = groupDatesByPlayer(completions);
const rows: CohortRow[] = [];
for (let i = 0; i < weeks; i++) {
const weekStart = addDays(start, i * 7);
const weekEnd = addDays(weekStart, 6);
const members: string[] = [];
for (const [id, first] of firsts) {
if (first >= weekStart && first <= weekEnd) members.push(id);
}
const size = members.length;
const counts: number[] = [];
const rates: (number | null)[] = [];
for (let k = 0; k < weeks - i; k++) {
const wStart = addDays(weekStart, k * 7);
const wEnd = addDays(wStart, 6);
if (asOf && wStart > asOf) {
counts.push(0);
rates.push(null);
continue;
}
let active = 0;
for (const id of members) {
const dates = datesByPlayer.get(id) ?? [];
if (dates.some((d) => d >= wStart && d <= wEnd)) active++;
}
counts.push(active);
rates.push(size < minSize ? null : pct(active, size));
}
rows.push({ weekStart, size, counts, rates });
}
return rows;
}
/** Days until a first-timer's second play, bucketed. */
export function timeToSecondPlayHistogram(completions: CompletionRow[]): HistogramBin[] {
const datesByPlayer = groupDatesByPlayer(completions);
const gaps: number[] = [];
for (const dates of datesByPlayer.values()) {
if (dates.length < 2) continue;
gaps.push(dayDiff(dates[0], dates[1]));
}
const buckets: { label: string; test: (g: number) => boolean }[] = [
{ label: '1 day', test: (g) => g === 1 },
{ label: '2 days', test: (g) => g === 2 },
{ label: '3–4 days', test: (g) => g >= 3 && g <= 4 },
{ label: '5–7 days', test: (g) => g >= 5 && g <= 7 },
{ label: '8–14 days', test: (g) => g >= 8 && g <= 14 },
{ label: '15–30 days', test: (g) => g >= 15 && g <= 30 },
{ label: '31+ days', test: (g) => g >= 31 }
];
return buckets.map((b) => ({ label: b.label, value: gaps.filter(b.test).length }));
}
// ── guesses / difficulty ─────────────────────────────────────────────────────
/** Distribution of guess counts, with everything above `max` collapsed. */
export function guessHistogram(completions: CompletionRow[], max = 8): HistogramBin[] {
const bins: HistogramBin[] = [];
for (let g = 1; g <= max; g++) bins.push({ label: String(g), value: 0 });
bins.push({ label: `${max + 1}+`, value: 0 });
for (const c of completions) {
if (c.guessCount <= max) bins[c.guessCount - 1].value++;
else bins[max].value++;
}
return bins;
}
/** Share of completions solved on the very first guess, %. */
export function firstTryRate(completions: CompletionRow[]): number | null {
if (completions.length === 0) return null;
const firstTry = completions.filter((c) => c.guessCount === 1).length;
return pct(firstTry, completions.length);
}
export function averageGuesses(completions: CompletionRow[]): number | null {
if (completions.length === 0) return null;
const total = completions.reduce((sum, c) => sum + c.guessCount, 0);
return round1(total / completions.length);
}
// ── streaks ──────────────────────────────────────────────────────────────────
export interface StreakSurvivalPoint {
length: number;
probability: number;
}
/**
* Probability that a streak of length N continues to N+1.
* Denominator counts every day a player's run reached length N.
*/
export function streakSurvival(completions: CompletionRow[]): StreakSurvivalPoint[] {
const datesByPlayer = groupDatesByPlayer(completions);
const attempts = new Map<number, number>();
const continued = new Map<number, number>();
for (const dates of datesByPlayer.values()) {
let streak = 0;
for (let i = 0; i < dates.length; i++) {
streak = i > 0 && dayDiff(dates[i - 1], dates[i]) === 1 ? streak + 1 : 1;
attempts.set(streak, (attempts.get(streak) ?? 0) + 1);
const tomorrow = addDays(dates[i], 1);
if (dates.includes(tomorrow)) {
continued.set(streak, (continued.get(streak) ?? 0) + 1);
}
}
}
const lengths = [...attempts.keys()].sort((a, b) => a - b);
const out: StreakSurvivalPoint[] = [];
for (const length of lengths) {
const denom = attempts.get(length) ?? 0;
if (denom === 0) continue;
out.push({ length, probability: round1(((continued.get(length) ?? 0) / denom) * 100) });
}
return out;
}
// ── content ──────────────────────────────────────────────────────────────────
/** Shannon entropy (bits) of a count distribution. */
export function entropy(counts: number[]): number {
const total = counts.reduce((a, b) => a + b, 0);
if (total <= 0) return 0;
let h = 0;
for (const c of counts) {
if (c <= 0) continue;
const p = c / total;
h -= p * Math.log2(p);
}
return Math.round(h * 1000) / 1000;
}
/** Next-feature interval per book, bucketed. */
export function repeatIntervals(verses: VerseRow[]): HistogramBin[] {
const byBook = new Map<string, string[]>();
for (const v of verses) {
const arr = byBook.get(v.bookId);
if (arr) arr.push(v.date);
else byBook.set(v.bookId, [v.date]);
}
const gaps: number[] = [];
for (const dates of byBook.values()) {
dates.sort();
for (let i = 1; i < dates.length; i++) gaps.push(dayDiff(dates[i - 1], dates[i]));
}
const buckets: { label: string; test: (g: number) => boolean }[] = [
{ label: '1–7d', test: (g) => g <= 7 },
{ label: '8–14d', test: (g) => g >= 8 && g <= 14 },
{ label: '15–30d', test: (g) => g >= 15 && g <= 30 },
{ label: '31–60d', test: (g) => g >= 31 && g <= 60 },
{ label: '61–90d', test: (g) => g >= 61 && g <= 90 },
{ label: '90d+', test: (g) => g > 90 }
];
return buckets.map((b) => ({ label: b.label, value: gaps.filter(b.test).length }));
}
export interface BookCoverageOptions {
start: string;
end: string;
minPlays?: number;
}
/**
* Per-book content + retention stats over `[start, end]`.
* `days` is the number of distinct days the book was the answer; `plays` is the
* number of completions on those days.
*/
export function bookCoverage(
verses: VerseRow[],
completions: CompletionRow[],
opts: BookCoverageOptions
): BookStat[] {
const { start, end, minPlays = 1 } = opts;
const verseInRange = verses.filter((v) => v.date >= start && v.date <= end);
const bookByDate = new Map<string, string>();
const daysByBook = new Map<string, Set<string>>();
for (const v of verseInRange) {
bookByDate.set(v.date, v.bookId);
let set = daysByBook.get(v.bookId);
if (!set) {
set = new Set();
daysByBook.set(v.bookId, set);
}
set.add(v.date);
}
const datesByPlayer = groupDatesByPlayer(completions);
const completionsInRange = completions.filter((c) => c.date >= start && c.date <= end);
// Aggregate per book
const stats = new Map<
string,
{ plays: number; firstTry: number; guessSum: number; d1: number; d1Denom: number }
>();
for (const c of completionsInRange) {
const bookId = bookByDate.get(c.date);
if (!bookId) continue;
let s = stats.get(bookId);
if (!s) {
s = { plays: 0, firstTry: 0, guessSum: 0, d1: 0, d1Denom: 0 };
stats.set(bookId, s);
}
s.plays++;
if (c.guessCount === 1) s.firstTry++;
s.guessSum += c.guessCount;
s.d1Denom++;
const dates = datesByPlayer.get(c.anonymousId) ?? [];
if (dates.includes(addDays(c.date, 1))) s.d1++;
}
const out: BookStat[] = [];
for (const book of bibleBooks) {
const days = daysByBook.get(book.id)?.size ?? 0;
const s = stats.get(book.id);
if (!s || s.plays < minPlays) {
out.push({
bookId: book.id,
days,
plays: s?.plays ?? 0,
firstTryPct: s ? (pct(s.firstTry, s.plays) ?? 0) : 0,
avgGuesses: s ? round1(s.guessSum / s.plays) : 0,
d1Return: null
});
continue;
}
out.push({
bookId: book.id,
days,
plays: s.plays,
firstTryPct: pct(s.firstTry, s.plays) ?? 0,
avgGuesses: round1(s.guessSum / s.plays),
d1Return: pct(s.d1, s.d1Denom)
});
}
return out;
}
/** Next-day return rate grouped by how many guesses the solve took. */
export function retentionByGuessBucket(
completions: CompletionRow[]
): { bucket: string; d1: number | null; plays: number }[] {
const datesByPlayer = groupDatesByPlayer(completions);
const buckets: { label: string; test: (g: number) => boolean }[] = [
{ label: '1', test: (g) => g === 1 },
{ label: '2', test: (g) => g === 2 },
{ label: '3', test: (g) => g === 3 },
{ label: '4–5', test: (g) => g >= 4 && g <= 5 },
{ label: '6+', test: (g) => g >= 6 }
];
return buckets.map((b) => {
const rows = completions.filter((c) => b.test(c.guessCount));
let returned = 0;
for (const c of rows) {
const dates = datesByPlayer.get(c.anonymousId) ?? [];
if (dates.includes(addDays(c.date, 1))) returned++;
}
return { bucket: b.label, d1: pct(returned, rows.length), plays: rows.length };
});
}
/** Completion counts by weekday (0=Sun) and UTC hour. */
export function weekdayHourHeatmap(completions: CompletionRow[]): number[][] {
const grid: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0));
for (const c of completions) {
const d = new Date(c.completedAt);
grid[d.getUTCDay()][d.getUTCHours()]++;
}
return grid;
}
/** Overall share of players who ever played more than once. */
export function overallReturnRate(completions: CompletionRow[]): number | null {
const datesByPlayer = groupDatesByPlayer(completions);
if (datesByPlayer.size === 0) return null;
let returned = 0;
for (const dates of datesByPlayer.values()) if (dates.length >= 2) returned++;
return pct(returned, datesByPlayer.size);
}
/** Players who completed on each date, for daily series. */
export function dailyCompletionsSeries(
completions: CompletionRow[],
start: string,
end: string
): { date: string; completions: number; newPlayers: number }[] {
const firsts = firstPlayDates(completions);
const byDate = new Map<string, { completions: number; newPlayers: number }>();
for (const date of dateRange(start, end)) byDate.set(date, { completions: 0, newPlayers: 0 });
for (const c of completions) {
const row = byDate.get(c.date);
if (!row) continue;
row.completions++;
if (firsts.get(c.anonymousId) === c.date) row.newPlayers++;
}
return [...byDate.entries()].map(([date, v]) => ({ date, ...v }));
}
export { startOfWeek, addDays, dayDiff };
+80
View File
@@ -0,0 +1,80 @@
import type {
PlayerState,
SegmentCounts,
SegmentSets,
StatsSettings
} from '$lib/types/stats';
import { dayDiff } from './dates';
/**
* Classify players into mutually-exclusive lifecycle segments using the
* thresholds supplied at render time. The stored player state is frozen as of
* the snapshot date, so changing thresholds re-segments all history instantly
* without rebuilding anything.
*/
export function deriveSegments(
players: PlayerState[],
asOfDate: string,
settings: StatsSettings
): SegmentSets {
const sets: SegmentSets = {
new: [],
core: [],
returning: [],
resurrected: [],
atRisk: [],
churned: [],
active: []
};
for (const p of players) {
const inactive = dayDiff(p.lastDate, asOfDate);
const firstAge = dayDiff(p.firstDate, asOfDate);
// Inactive segments first.
if (inactive >= settings.churn_days) {
sets.churned.push(p.id);
continue;
}
if (inactive >= settings.at_risk_days) {
sets.atRisk.push(p.id);
continue;
}
sets.active.push(p.id);
if (firstAge < 7) {
sets.new.push(p.id);
} else if (p.plays7 >= settings.core_plays_per_week) {
sets.core.push(p.id);
} else if (
p.plays7 > 0 &&
firstAge > settings.resurrect_gap_days &&
p.plays30 <= p.plays7
) {
// Active this week, no activity in the 8–30 day band, first seen long
// ago → they came back after a long gap.
sets.resurrected.push(p.id);
} else {
sets.returning.push(p.id);
}
}
return sets;
}
export function countSegments(sets: SegmentSets): SegmentCounts {
return {
new: sets.new.length,
core: sets.core.length,
returning: sets.returning.length,
resurrected: sets.resurrected.length,
atRisk: sets.atRisk.length,
churned: sets.churned.length,
active: sets.active.length,
total:
sets.active.length +
sets.atRisk.length +
sets.churned.length
};
}
+82
View File
@@ -0,0 +1,82 @@
import { db as defaultDb } from '$lib/server/db';
import { dashboardSettings } from '$lib/server/db/schema';
import {
SETTINGS_DEFAULTS,
type SettingsKey,
type StatsSettings
} from '$lib/types/stats';
import type { StatsDb } from './db';
const SETTINGS_KEYS = Object.keys(SETTINGS_DEFAULTS) as SettingsKey[];
/** `core_plays_per_week` → `STATS_CORE_PLAYS_PER_WEEK` */
export function settingsEnvVar(key: SettingsKey): string {
return `STATS_${key.toUpperCase()}`;
}
/** Defaults from `SETTINGS_DEFAULTS`, overridden by `STATS_*` env vars. */
export function envSettings(): StatsSettings {
const out = { ...SETTINGS_DEFAULTS } as StatsSettings;
for (const key of SETTINGS_KEYS) {
const raw = Bun.env[settingsEnvVar(key)];
if (raw === undefined || raw === '') continue;
const value = Number(raw);
if (Number.isFinite(value)) out[key] = value;
}
return out;
}
function coerce(key: SettingsKey, raw: string): number {
const value = Number(raw);
return Number.isFinite(value) ? value : SETTINGS_DEFAULTS[key];
}
/**
* Reads persisted overrides from `dashboard_settings` and overlays them on the
* env/default baseline. Safe to call when the table does not yet exist.
*/
export async function getSettings(database: StatsDb = defaultDb): Promise<StatsSettings> {
const settings = envSettings();
try {
const rows = await database.select().from(dashboardSettings);
for (const row of rows) {
if ((SETTINGS_KEYS as string[]).includes(row.key)) {
settings[row.key as SettingsKey] = coerce(row.key as SettingsKey, row.value);
}
}
} catch {
// Table missing / DB unavailable — fall back to defaults.
}
return settings;
}
/** Upserts a partial set of setting overrides. */
export async function updateSettings(
partial: Partial<StatsSettings>,
database: StatsDb = defaultDb
): Promise<StatsSettings> {
const now = new Date();
for (const [key, value] of Object.entries(partial)) {
if (!(SETTINGS_KEYS as string[]).includes(key)) continue;
if (value === undefined || value === null || !Number.isFinite(value)) continue;
await database
.insert(dashboardSettings)
.values({ key, value: String(value), updatedAt: now })
.onConflictDoUpdate({
target: dashboardSettings.key,
set: { value: String(value), updatedAt: now }
});
}
return getSettings(database);
}
/** Stable short hash of the resolved settings — used to detect stale snapshots. */
export function hashSettings(settings: StatsSettings): string {
const canonical = SETTINGS_KEYS.map((k) => `${k}=${settings[k]}`).join(';');
const digest = new Bun.CryptoHasher('sha256').update(canonical).digest('hex');
return digest.slice(0, 12);
}
export async function configVersion(database: StatsDb = defaultDb): Promise<string> {
return hashSettings(await getSettings(database));
}
+246
View File
@@ -0,0 +1,246 @@
import type { SeriesPoint, Signal, SignalSeverity, SnapshotPayload, StatsSettings } from '$lib/types/stats';
// ── generic anomaly detection ────────────────────────────────────────────────
export interface Anomaly {
date: string;
value: number;
median: number;
score: number;
}
/**
* Robust, weekday-aware anomaly detection. Compares the latest point with the
* median/MAD of the same weekday over the preceding `lookback` points.
* Returns null when there is not enough history or the series is flat.
*/
export function detectAnomaly(series: SeriesPoint[], threshold: number, lookback = 28): Anomaly | null {
const points = series.filter((p) => p.value !== null) as { date: string; value: number }[];
if (points.length < 5) return null;
const latest = points[points.length - 1];
const weekday = new Date(latest.date + 'T00:00:00Z').getUTCDay();
const history = points
.slice(0, -1)
.filter((p) => new Date(p.date + 'T00:00:00Z').getUTCDay() === weekday)
.slice(-lookback);
if (history.length < 4) return null;
const values = history.map((p) => p.value).sort((a, b) => a - b);
const median = medianOf(values);
const deviations = values.map((v) => Math.abs(v - median)).sort((a, b) => a - b);
const mad = medianOf(deviations);
if (mad === 0) return null;
const score = (0.6745 * (latest.value - median)) / mad;
if (Math.abs(score) < threshold) return null;
return { date: latest.date, value: latest.value, median: Math.round(median * 10) / 10, score: Math.round(score * 10) / 10 };
}
function medianOf(sorted: number[]): number {
if (sorted.length === 0) return 0;
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
}
// ── rule helpers ─────────────────────────────────────────────────────────────
type Direction = 'up' | 'down';
interface TargetRule {
id: string;
title: string;
action: string;
value: number | null;
target: number;
/** At or above target is good when direction is 'up'. */
direction?: Direction;
/** Absolute value that means "red" regardless of the target. */
red: number;
unit?: string;
}
function evaluateTarget(rule: TargetRule): Signal {
const { id, title, action, value, target, red, direction = 'up', unit = '%' } = rule;
if (value === null) {
return {
id,
severity: 'info',
title,
detail: `Not enough data yet to measure this (needs instrumented events).`,
metric: id,
value: null,
target
};
}
const meets = direction === 'up' ? value >= target : value <= target;
const isRed = direction === 'up' ? value < red : value > red;
let severity: SignalSeverity = 'good';
if (!meets) severity = isRed ? 'bad' : 'warn';
const comparison =
direction === 'up'
? value >= target
? `at or above the ${target}% target`
: `below the ${target}% target`
: value <= target
? `at or below the ${target}% target`
: `above the ${target}% target`;
return {
id,
severity,
title,
detail: `${title} is ${value}${unit}, ${comparison}.`,
action: severity === 'good' ? undefined : action,
metric: id,
value,
target
};
}
// ── main engine ──────────────────────────────────────────────────────────────
/**
* Evaluate the explicit targets plus anomaly detection. Pure: the snapshot
* payload carries every number the engine needs.
*/
export function evaluateSignals(payload: SnapshotPayload, settings: StatsSettings): Signal[] {
const { kpis, activity } = payload;
const signals: Signal[] = [];
signals.push(
evaluateTarget({
id: 'd1_return',
title: 'D1 return',
action: 'Test a "come back tomorrow" prompt on the win screen.',
value: kpis.d1Return,
target: settings.d1_target,
red: 12
})
);
signals.push(
evaluateTarget({
id: 'return7',
title: '7-day return',
action: 'Improve the first-session experience or send a day-2 reminder.',
value: kpis.return7,
target: settings.return7_target,
red: 15
})
);
signals.push(
evaluateTarget({
id: 'completion',
title: 'Completion rate',
action: 'Look for friction between the first guess and the win screen.',
value: kpis.completionRate,
target: settings.completion_target,
red: 55
})
);
// First-try rate is a band: both too high and too low are bad.
if (kpis.firstTryRate === null) {
signals.push({
id: 'first_try',
severity: 'info',
title: 'First-try rate',
detail: 'Not enough data yet to measure this.',
metric: 'first_try'
});
} else {
const v = kpis.firstTryRate;
const inBand = v >= settings.first_try_min && v <= settings.first_try_max;
const severe = v > 45 || v < 8;
signals.push({
id: 'first_try',
severity: inBand ? 'good' : severe ? 'bad' : 'warn',
title: 'Verse achievability',
detail: `First-try rate is ${v}% (target band ${settings.first_try_min}–${settings.first_try_max}%).${
v > settings.first_try_max
? ' Verses may be too easy / self-revealing.'
: v < settings.first_try_min
? ' Verses may be too hard.'
: ''
}`,
action: inBand ? undefined : 'Review the verse mix for the easiest / hardest books.',
metric: 'first_try',
value: v,
target: settings.first_try_max
});
}
signals.push(
evaluateTarget({
id: 'share_rate',
title: 'Share rate',
action: 'Make the share action more prominent or reward sharing.',
value: kpis.shareRate,
target: settings.share_target,
red: 15
})
);
// Core players: week-over-week direction.
if (activity.corePrevious === null) {
signals.push({
id: 'core_players',
severity: 'info',
title: 'Core players',
detail: `${activity.core} players finished 4+ times this week. No prior week to compare yet.`,
metric: 'core_players',
value: activity.core
});
} else {
const delta = activity.core - activity.corePrevious;
signals.push({
id: 'core_players',
severity: delta >= 0 ? 'good' : 'warn',
title: 'Core players',
detail: `${activity.core} players finished 4+ times this week (${delta >= 0 ? '+' : ''}${delta} vs last week).`,
action: delta < 0 ? 'Check whether verse difficulty or reminders changed.' : undefined,
metric: 'core_players',
value: activity.core
});
}
// Anomaly detection on the daily completions series.
const anomaly = detectAnomaly(
payload.dailySeries.map((d) => ({ date: d.date, value: d.completions })),
settings.anomaly_mad_threshold
);
if (anomaly) {
const direction = anomaly.score > 0 ? 'above' : 'below';
signals.push({
id: 'completions_anomaly',
severity: anomaly.score > 0 ? 'good' : 'warn',
title: 'Unusual day',
detail: `${anomaly.date} had ${anomaly.value} finishes, well ${direction} the usual ${anomaly.median} for that weekday.`,
action: anomaly.score < 0 ? 'Check for outages or a hard verse.' : undefined,
metric: 'completions',
value: anomaly.value,
target: anomaly.median
});
}
return signals;
}
const SEVERITY_ORDER: Record<SignalSeverity, number> = { bad: 0, warn: 1, info: 2, good: 3 };
export function sortSignals(signals: Signal[]): Signal[] {
return signals
.slice()
.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
}
/** One-line verdict for the Pulse banner. */
export function verdictLine(signals: Signal[]): string {
const top = sortSignals(signals)[0];
if (!top) return 'No signals yet.';
if (top.severity === 'good') return `All core metrics are on target. ${top.detail}`;
if (top.severity === 'info') return top.detail;
return top.detail;
}
+684
View File
@@ -0,0 +1,684 @@
import { and, eq, lte } from 'drizzle-orm';
import { db as defaultDb } from '$lib/server/db';
import {
analyticsEvents,
dailyCompletions,
dailyVerses,
statsSnapshot,
user
} from '$lib/server/db/schema';
import type { StatsDb } from './db';
import type {
Activity,
CompletionRow,
DailyPoint,
HabitData,
HistogramBin,
Kpis,
PlayerState,
SeriesPoint,
SnapshotPayload,
StatsSettings,
VerseRow
} from '$lib/types/stats';
import { bibleBooks } from '$lib/types/bible';
import { getSettings, hashSettings } from './settings';
import { deriveSegments, countSegments } from './segments';
import { evaluateSignals } from './signals';
import {
activePlayers,
averageGuesses,
bookCoverage,
cohortTriangle,
dailyCompletionsSeries,
entropy,
firstPlayDates,
firstTryRate,
groupDatesByPlayer,
guessHistogram,
netGrowth,
newVsReturning,
overallReturnRate,
repeatIntervals,
retentionByGuessBucket,
streakSurvival,
timeToSecondPlayHistogram,
weekdayHourHeatmap
} from './metrics';
import { addDays, dateRange, dayDiff, startOfWeek, todayUtc } from './dates';
export type BuildKind = 'scheduled' | 'lazy' | 'reconstructed';
/**
* Bump whenever the snapshot payload shape changes. Older snapshots are
* rebuilt lazily so the UI never reads a stale shape.
*/
export const PAYLOAD_SCHEMA_VERSION = 2;
const RETENTION_WINDOW_DAYS = 180;
const SERIES_DAYS = 90;
const RECENT_KPI_DAYS = 30;
function endOfDay(dateStr: string): Date {
return new Date(dateStr + 'T23:59:59.999Z');
}
function round1(n: number): number {
return Math.round(n * 10) / 10;
}
export interface LoadedData {
completions: CompletionRow[];
verses: VerseRow[];
startedByDate: Map<string, number>;
shareEvents: number;
firstEventDate: string | null;
signupDates: string[];
}
/** Load all rows needed to build a snapshot as of `cutoff` (inclusive). */
export async function loadStatsData(
cutoff: string,
database: StatsDb = defaultDb
): Promise<LoadedData> {
const cutoffDate = endOfDay(cutoff);
const completionRows = await database
.select({
anonymousId: dailyCompletions.anonymousId,
date: dailyCompletions.date,
guessCount: dailyCompletions.guessCount,
guesses: dailyCompletions.guesses,
completedAt: dailyCompletions.completedAt
})
.from(dailyCompletions)
.where(and(lte(dailyCompletions.date, cutoff), lte(dailyCompletions.completedAt, cutoffDate)));
const verseRows = await database
.select({ date: dailyVerses.date, bookId: dailyVerses.bookId })
.from(dailyVerses)
.where(lte(dailyVerses.date, cutoff));
const eventRows = await database
.select({
name: analyticsEvents.name,
date: analyticsEvents.date,
createdAt: analyticsEvents.createdAt
})
.from(analyticsEvents)
.where(lte(analyticsEvents.createdAt, cutoffDate));
const startedByDate = new Map<string, number>();
let shareEvents = 0;
let firstEventDate: string | null = null;
for (const e of eventRows) {
const eventDate = e.date ?? new Date(e.createdAt).toISOString().slice(0, 10);
if (!firstEventDate || eventDate < firstEventDate) firstEventDate = eventDate;
if (e.name === 'verse_served' && e.date && e.date <= cutoff) {
startedByDate.set(e.date, (startedByDate.get(e.date) ?? 0) + 1);
}
if ((e.name === 'share' || e.name === 'share_copy') && eventDate <= cutoff) {
shareEvents++;
}
}
const userRows = await database
.select({ id: user.id, createdAt: user.createdAt })
.from(user)
.where(lte(user.createdAt, cutoffDate));
return {
completions: completionRows as CompletionRow[],
verses: verseRows,
startedByDate,
shareEvents,
firstEventDate,
signupDates: userRows
.filter((u) => u.createdAt)
.map((u) => new Date(u.createdAt as Date).toISOString().slice(0, 10))
};
}
/** Compact per-player state as of `asOfDate`, for players active in the window. */
export function computePlayerState(
completions: CompletionRow[],
asOfDate: string,
activeWindowDays: number
): PlayerState[] {
const windowStart = addDays(asOfDate, -(activeWindowDays - 1));
const sevenStart = addDays(asOfDate, -6);
const thirtyStart = addDays(asOfDate, -29);
const datesByPlayer = groupDatesByPlayer(completions);
const out: PlayerState[] = [];
for (const [id, dates] of datesByPlayer) {
const inScope = dates.filter((d) => d <= asOfDate);
if (inScope.length === 0) continue;
const firstDate = inScope[0];
const lastDate = inScope[inScope.length - 1];
if (lastDate < windowStart) continue;
// Current consecutive-day streak ending at the last active day.
let streak = 1;
for (let i = inScope.length - 1; i > 0; i--) {
if (dayDiff(inScope[i - 1], inScope[i]) === 1) streak++;
else break;
}
out.push({
id,
firstDate,
lastDate,
totalPlays: inScope.length,
plays7: inScope.filter((d) => d >= sevenStart).length,
plays30: inScope.filter((d) => d >= thirtyStart).length,
streak
});
}
return out;
}
/** Pooled return rate over a run of cohort days (more stable than single days). */
function pooledReturn(
completions: CompletionRow[],
cohortEnd: string,
cohortDays: number,
n: number
): number | null {
const firsts = firstPlayDates(completions);
const datesByPlayer = groupDatesByPlayer(completions);
const start = addDays(cohortEnd, -(cohortDays - 1));
let cohort = 0;
let returned = 0;
for (const [id, first] of firsts) {
if (first < start || first > cohortEnd) continue;
cohort++;
const limit = addDays(first, n);
const dates = datesByPlayer.get(id) ?? [];
if (dates.some((d) => d > first && d <= limit)) returned++;
}
if (cohort === 0) return null;
return round1((returned / cohort) * 100);
}
function buildActivity(
pointCompletions: CompletionRow[],
asOfDate: string,
settings: StatsSettings
): Activity {
const dau = activePlayers(pointCompletions, asOfDate, 1);
const wau = activePlayers(pointCompletions, asOfDate, 7);
const mau = activePlayers(pointCompletions, asOfDate, 30);
const { returningShare } = newVsReturning(pointCompletions, asOfDate);
const growth = netGrowth(pointCompletions, asOfDate, 7, settings.resurrect_gap_days);
// Core count current + one week earlier, both with current thresholds.
const core = deriveSegments(
computePlayerState(pointCompletions, asOfDate, settings.active_window_days),
asOfDate,
settings
).core.length;
const prevDate = addDays(asOfDate, -7);
const corePrevious = deriveSegments(
computePlayerState(
pointCompletions.filter((c) => c.date <= prevDate),
prevDate,
settings.active_window_days
),
prevDate,
settings
).core.length;
const prev = addDays(asOfDate, -7);
const previous = {
dau: activePlayers(pointCompletions, prev, 1),
wau: activePlayers(pointCompletions, prev, 7),
mau: activePlayers(pointCompletions, prev, 30),
core: corePrevious
};
return {
dau,
wau,
mau,
core,
returningShare,
newPlayers: growth.newPlayers,
resurrected: growth.resurrected,
churned: growth.churned,
netGrowth: growth.netGrowth,
corePrevious,
previous
};
}
function buildKpis(
pointCompletions: CompletionRow[],
data: LoadedData,
asOfDate: string
): Kpis {
const recentStart = addDays(asOfDate, -(RECENT_KPI_DAYS - 1));
const recent = pointCompletions.filter((c) => c.date >= recentStart);
const shares = data.shareEvents;
const completionsInRecent = recent.length;
// Completion rate: only meaningful once verse_served events exist.
let started = 0;
for (const [date, count] of data.startedByDate) {
if (date >= recentStart && date <= asOfDate) started += count;
}
const completionRate = started > 0 ? round1((completionsInRecent / started) * 100) : null;
const shareRate = shares > 0 && completionsInRecent > 0
? round1((shares / completionsInRecent) * 100)
: null;
return {
// Most recent resolvable cohorts (as-of yesterday can't resolve its own D1).
d1Return: pooledReturn(pointCompletions, addDays(asOfDate, -1), 7, 1),
return7: pooledReturn(pointCompletions, addDays(asOfDate, -7), 7, 7),
overallReturnRate: overallReturnRate(pointCompletions),
completionRate,
shareRate,
firstTryRate: firstTryRate(recent),
avgGuesses: averageGuesses(recent)
};
}
function buildCurves(
pointCompletions: CompletionRow[],
allCompletions: CompletionRow[],
asOfDate: string,
n: number,
matured: boolean
): SeriesPoint[] {
const series: SeriesPoint[] = [];
// Only cohorts whose window has resolved by `asOfDate` are point-in-time
// honest; for matured builds the effective end is today.
const effectiveEnd = matured ? todayUtc() : asOfDate;
const latest = addDays(effectiveEnd, -n);
const start = addDays(latest, -29);
const firsts = firstPlayDates(pointCompletions);
const datesByPlayer = groupDatesByPlayer(allCompletions);
for (const date of dateRange(start, latest)) {
const limit = addDays(date, n);
let cohort = 0;
let returned = 0;
for (const [id, first] of firsts) {
if (first !== date) continue;
cohort++;
const dates = datesByPlayer.get(id) ?? [];
if (dates.some((d) => d > date && d <= limit)) returned++;
}
series.push({ date, value: cohort >= 3 ? round1((returned / cohort) * 100) : null });
}
return series;
}
function buildHabit(pointCompletions: CompletionRow[], players: PlayerState[], asOfDate: string): HabitData {
const calendarStart = addDays(asOfDate, -119);
const calendarCounts = new Map<string, number>();
for (const c of pointCompletions) {
if (c.date < calendarStart || c.date > asOfDate) continue;
calendarCounts.set(c.date, (calendarCounts.get(c.date) ?? 0) + 1);
}
const calendar = dateRange(calendarStart, asOfDate).map((date) => ({
date,
count: calendarCounts.get(date) ?? 0
}));
// Current-streak distribution, bucketed.
const buckets = new Map<string, number>([
['1', 0],
['2', 0],
['3–4', 0],
['5–6', 0],
['7–13', 0],
['14+', 0]
]);
for (const p of players) {
if (p.streak <= 1) continue;
const label =
p.streak === 2
? '2'
: p.streak <= 4
? '3–4'
: p.streak <= 6
? '5–6'
: p.streak <= 13
? '7–13'
: '14+';
buckets.set(label, (buckets.get(label) ?? 0) + 1);
}
const streakLengths: HistogramBin[] = [...buckets.entries()].map(([label, value]) => ({
label,
value
}));
return {
streakSurvival: streakSurvival(pointCompletions),
streakLengths,
calendar,
weekdayHour: weekdayHourHeatmap(pointCompletions)
};
}
function buildSegmentMix(
pointCompletions: CompletionRow[],
asOfDate: string,
settings: StatsSettings
): SnapshotPayload['retention']['segmentMix'] {
const start = addDays(asOfDate, -29);
const out: SnapshotPayload['retention']['segmentMix'] = [];
for (const date of dateRange(start, asOfDate)) {
const point = pointCompletions.filter((c) => c.date <= date);
const players = computePlayerState(point, date, settings.active_window_days);
const counts = countSegments(deriveSegments(players, date, settings));
out.push({
date,
new: counts.new,
core: counts.core,
returning: counts.returning,
resurrected: counts.resurrected,
atRisk: counts.atRisk,
churned: counts.churned
});
}
return out;
}
function buildGrowth(
data: LoadedData,
players: PlayerState[],
asOfDate: string
): SnapshotPayload['growth'] {
const thresholds = [1, 2, 5, 10, 25, 50];
const lifecycleFunnel = thresholds.map((t) => ({
label: `${t}+`,
players: players.filter((p) => p.totalPlays >= t).length
}));
const start = addDays(asOfDate, -89);
const counts = new Map<string, number>();
for (const d of data.signupDates) {
if (d >= start && d <= asOfDate) counts.set(d, (counts.get(d) ?? 0) + 1);
}
const signups = dateRange(start, asOfDate).map((date) => ({
date,
count: counts.get(date) ?? 0
}));
return { lifecycleFunnel, signups };
}
function buildVerseMix(
pointCompletions: CompletionRow[],
verses: VerseRow[],
asOfDate: string
): SnapshotPayload['verseMix'] {
const mixStart = addDays(asOfDate, -179);
const inRangeVerses = verses.filter((v) => v.date >= mixStart && v.date <= asOfDate);
const oldTestament = new Set(
bibleBooks.filter((b) => b.testament === 'old').map((b) => b.id)
);
const coverage = bookCoverage(inRangeVerses, pointCompletions, {
start: mixStart,
end: asOfDate
});
// Weekly testament mix + book-distribution entropy.
const weeksBack = 12;
const weekStart = startOfWeek(addDays(asOfDate, -(weeksBack - 1) * 7));
const testamentMix: { date: string; old: number; new: number }[] = [];
const entropySeries: SeriesPoint[] = [];
for (let w = 0; w < weeksBack; w++) {
const wStart = addDays(weekStart, w * 7);
const wEnd = addDays(wStart, 6);
const rows = inRangeVerses.filter((v) => v.date >= wStart && v.date <= wEnd);
if (rows.length === 0) {
testamentMix.push({ date: wStart, old: 0, new: 0 });
entropySeries.push({ date: wStart, value: null });
continue;
}
const bookCounts = new Map<string, number>();
let old = 0;
let nt = 0;
for (const v of rows) {
bookCounts.set(v.bookId, (bookCounts.get(v.bookId) ?? 0) + 1);
if (oldTestament.has(v.bookId)) old++;
else nt++;
}
testamentMix.push({ date: wStart, old, new: nt });
entropySeries.push({ date: wStart, value: entropy([...bookCounts.values()]) });
}
const firstTryByBook = coverage
.filter((b) => b.plays >= 3)
.sort((a, b) => b.plays - a.plays);
return {
coverage,
testamentMix,
entropy: entropySeries,
repeatIntervals: repeatIntervals(verses),
firstTryByBook,
retentionByBook: coverage.filter((b) => b.d1Return !== null),
retentionByGuessBucket: retentionByGuessBucket(pointCompletions),
achievability: guessHistogram(pointCompletions.filter((c) => c.date >= addDays(asOfDate, -29)))
};
}
export interface ComputeOptions {
/** Data cutoff for retention resolution (defaults to asOfDate). */
matureThrough?: string;
}
/**
* Compute a full snapshot payload. Pure with respect to I/O: everything it
* needs is passed in, which keeps the point-in-time cutoff testable.
*/
export function computePayload(
asOfDate: string,
settings: StatsSettings,
data: LoadedData,
opts: ComputeOptions = {}
): SnapshotPayload {
const pointCompletions = data.completions.filter((c) => c.date <= asOfDate);
// Retention uses everything loaded: identical for point-in-time builds, and
// extends past `asOfDate` for matured builds.
const allCompletions = data.completions;
const matured = !!opts.matureThrough && opts.matureThrough > asOfDate;
const activity = buildActivity(pointCompletions, asOfDate, settings);
const kpis = buildKpis(pointCompletions, data, asOfDate);
const seriesStart = addDays(asOfDate, -(SERIES_DAYS - 1));
const baseSeries = dailyCompletionsSeries(pointCompletions, seriesStart, asOfDate);
const dailySeries: DailyPoint[] = baseSeries.map((d) => ({
...d,
started: data.startedByDate.get(d.date) ?? 0
}));
const players = computePlayerState(pointCompletions, asOfDate, settings.active_window_days);
const cohortStart = startOfWeek(addDays(asOfDate, -11 * 7));
const cohorts = cohortTriangle(allCompletions, {
start: cohortStart,
weeks: 12,
asOf: matured ? todayUtc() : asOfDate,
minSize: 3
});
const retention: SnapshotPayload['retention'] = {
cohorts,
curves: {
d1: buildCurves(pointCompletions, allCompletions, asOfDate, 1, matured),
d7: buildCurves(pointCompletions, allCompletions, asOfDate, 7, matured),
d30: buildCurves(pointCompletions, allCompletions, asOfDate, 30, matured)
},
timeToSecondPlay: timeToSecondPlayHistogram(allCompletions),
segmentMix: buildSegmentMix(pointCompletions, asOfDate, settings)
};
const growth = buildGrowth(data, players, asOfDate);
const verseMix = buildVerseMix(pointCompletions, data.verses, asOfDate);
const habit = buildHabit(pointCompletions, players, asOfDate);
const yesterday = pointCompletions.filter((c) => c.date === asOfDate);
const totals = {
completions: pointCompletions.length,
players: firstPlayDates(pointCompletions).size,
yesterdayCompletions: yesterday.length,
yesterdayAvgGuesses: averageGuesses(yesterday)
};
const payload: SnapshotPayload = {
asOfDate,
activity,
dailySeries,
kpis,
retention,
verseMix,
habit,
growth,
totals,
signals: [],
meta: {
hasStartedData: data.startedByDate.size > 0,
hasShareData: data.shareEvents > 0,
firstEventDate: data.firstEventDate,
generatedAt: Date.now(),
schemaVersion: PAYLOAD_SCHEMA_VERSION
}
};
payload.signals = evaluateSignals(payload, settings);
return payload;
}
export interface StoredSnapshot {
date: string;
generatedAt: Date;
buildKind: BuildKind;
configVersion: string;
payload: SnapshotPayload;
config: StatsSettings;
playerState: PlayerState[];
}
function parseRow(row: typeof statsSnapshot.$inferSelect): StoredSnapshot {
return {
date: row.date,
generatedAt: row.generatedAt,
buildKind: row.buildKind as BuildKind,
configVersion: row.configVersion,
payload: JSON.parse(row.payload) as SnapshotPayload,
config: JSON.parse(row.config) as StatsSettings,
playerState: row.playerState ? (JSON.parse(row.playerState) as PlayerState[]) : []
};
}
export async function getSnapshot(
date: string,
database: StatsDb = defaultDb
): Promise<StoredSnapshot | null> {
const [row] = await database.select().from(statsSnapshot).where(eq(statsSnapshot.date, date));
return row ? parseRow(row) : null;
}
export interface BuildResult {
snapshot: StoredSnapshot;
settings: StatsSettings;
buildKind: BuildKind;
}
/**
* Build and persist a snapshot. `replace=false` never overwrites an existing
* (immutable) snapshot; the caller re-reads instead.
*/
export async function buildSnapshot(
asOfDate: string,
opts: { kind: BuildKind; replace?: boolean; matureThrough?: string; persist?: boolean },
database: StatsDb = defaultDb
): Promise<BuildResult> {
const settings = await getSettings(database);
const version = hashSettings(settings);
const cutoff = opts.matureThrough ?? asOfDate;
const data = await loadStatsData(cutoff, database);
const payload = computePayload(asOfDate, settings, data, {
matureThrough: opts.matureThrough
});
const playerState = computePlayerState(
data.completions.filter((c) => c.date <= asOfDate),
asOfDate,
settings.active_window_days
);
const row = {
date: asOfDate,
generatedAt: new Date(),
buildKind: opts.kind,
configVersion: version,
payload: JSON.stringify(payload),
playerState: JSON.stringify(playerState),
config: JSON.stringify(settings)
};
if (opts.persist !== false) {
if (opts.replace) {
await database
.insert(statsSnapshot)
.values(row)
.onConflictDoUpdate({ target: statsSnapshot.date, set: row });
} else {
await database.insert(statsSnapshot).values(row).onConflictDoNothing();
}
}
return {
snapshot: {
date: asOfDate,
generatedAt: row.generatedAt,
buildKind: opts.kind,
configVersion: version,
payload,
config: settings,
playerState
},
settings,
buildKind: opts.kind
};
}
/**
* Return the snapshot for `date`, building it lazily if missing. Race-safe via
* `INSERT OR IGNORE` followed by a re-read.
*/
export async function ensureSnapshot(
date: string,
opts: { matureThrough?: string } = {},
database: StatsDb = defaultDb
): Promise<StoredSnapshot> {
const existing = await getSnapshot(date, database);
if (existing && existing.payload.meta?.schemaVersion === PAYLOAD_SCHEMA_VERSION) {
return existing;
}
// Missing or built with an older payload shape: (re)build deterministically
// for this point in time. The point-in-time cutoff keeps it faithful.
await buildSnapshot(
date,
{ kind: 'lazy', matureThrough: opts.matureThrough, replace: !!existing },
database
);
const stored = await getSnapshot(date, database);
if (!stored) throw new Error(`Failed to build snapshot for ${date}`);
return stored;
}
/** True when a stored snapshot was built with different thresholds or shape. */
export async function isSnapshotStale(
date: string,
database: StatsDb = defaultDb
): Promise<boolean> {
const snapshot = await getSnapshot(date, database);
if (!snapshot) return false;
if (snapshot.payload.meta?.schemaVersion !== PAYLOAD_SCHEMA_VERSION) return true;
const current = hashSettings(await getSettings(database));
return snapshot.configVersion !== current;
}
+2 -14
View File
@@ -1,18 +1,6 @@
import { browser } from "$app/environment";
import { evaluateGuess, generateUUID, type Guess } from "$lib/utils/game";
// Returns a stable anonymous ID for this browser, creating one if it doesn't exist yet.
// Used to attribute stats to a player who hasn't signed in.
function getOrCreateAnonymousId(): string {
if (!browser) return "";
const key = "bibdle-anonymous-id";
let id = localStorage.getItem(key);
if (!id) {
id = generateUUID();
localStorage.setItem(key, id);
}
return id;
}
import { evaluateGuess, type Guess } from "$lib/utils/game";
import { getOrCreateAnonymousId } from "$lib/utils/analytics";
// Reactive store that keeps in-memory game state in sync with localStorage.
// Accepts getter functions (rather than plain values) so Svelte's reactivity
+252
View File
@@ -0,0 +1,252 @@
/**
* Shared types for the private statistics dashboard (see statistics-plan.md).
*
* Keep this module dependency-free (no DB / Svelte imports) so it can be used
* from server code, Svelte components, and unit tests alike.
*/
// ── Settings ─────────────────────────────────────────────────────────────────
/**
* Dashboard thresholds. Keys are snake_case to match both the
* `dashboard_settings` table and the `STATS_*` environment variables.
*/
export const SETTINGS_DEFAULTS = {
core_plays_per_week: 4,
churn_days: 30,
resurrect_gap_days: 30,
at_risk_days: 8,
active_window_days: 180,
d1_target: 20,
return7_target: 25,
completion_target: 70,
first_try_min: 15,
first_try_max: 35,
share_target: 25,
anomaly_mad_threshold: 2
} as const;
export type SettingsKey = keyof typeof SETTINGS_DEFAULTS;
export type StatsSettings = Record<SettingsKey, number>;
// ── Plain rows (pure metric inputs) ──────────────────────────────────────────
/** The subset of a `daily_completions` row the metrics need. */
export interface CompletionRow {
anonymousId: string;
date: string;
guessCount: number;
guesses?: string | null;
completedAt: Date | number;
}
export interface VerseRow {
date: string;
bookId: string;
}
export interface UserRow {
id: string;
createdAt: Date | null;
}
// ── Player state & segments ──────────────────────────────────────────────────
/** Compact per-player state frozen as of a snapshot date. */
export interface PlayerState {
id: string;
firstDate: string;
lastDate: string;
totalPlays: number;
plays7: number;
plays30: number;
streak: number;
}
export interface SegmentSets {
new: string[];
core: string[];
returning: string[];
resurrected: string[];
atRisk: string[];
churned: string[];
active: string[];
}
export interface SegmentCounts {
new: number;
core: number;
returning: number;
resurrected: number;
atRisk: number;
churned: number;
active: number;
total: number;
}
// ── Signals ──────────────────────────────────────────────────────────────────
export type SignalSeverity = 'good' | 'info' | 'warn' | 'bad';
export interface Signal {
id: string;
severity: SignalSeverity;
title: string;
detail: string;
action?: string;
metric?: string;
value?: number | null;
target?: number | null;
}
// ── Payload ──────────────────────────────────────────────────────────────────
export interface DailyPoint {
date: string;
completions: number;
newPlayers: number;
started: number;
}
export interface SeriesPoint {
date: string;
value: number | null;
}
export interface CohortRow {
weekStart: string;
size: number;
/** Index 0 = week 0 (the cohort week); index k = k weeks later. */
counts: number[];
rates: (number | null)[];
}
export interface HistogramBin {
label: string;
value: number;
}
export interface BookStat {
bookId: string;
days: number;
plays: number;
firstTryPct: number;
avgGuesses: number;
d1Return: number | null;
}
export interface Activity {
dau: number;
wau: number;
mau: number;
core: number;
returningShare: number | null;
newPlayers: number;
resurrected: number;
churned: number;
netGrowth: number;
/** Core count from the snapshot ~7 days earlier (same thresholds), if available. */
corePrevious: number | null;
/** Same-metric values for the previous day (weekday-aligned comparison). */
previous: {
dau: number;
wau: number;
mau: number;
core: number;
} | null;
}
export interface Kpis {
d1Return: number | null;
return7: number | null;
overallReturnRate: number | null;
completionRate: number | null;
shareRate: number | null;
firstTryRate: number | null;
avgGuesses: number | null;
}
export interface RetentionData {
cohorts: CohortRow[];
curves: {
d1: SeriesPoint[];
d7: SeriesPoint[];
d30: SeriesPoint[];
};
timeToSecondPlay: HistogramBin[];
segmentMix: {
date: string;
new: number;
core: number;
returning: number;
resurrected: number;
atRisk: number;
churned: number;
}[];
}
export interface VerseMixData {
coverage: BookStat[];
testamentMix: { date: string; old: number; new: number }[];
entropy: SeriesPoint[];
repeatIntervals: HistogramBin[];
firstTryByBook: BookStat[];
retentionByBook: BookStat[];
retentionByGuessBucket: { bucket: string; d1: number | null; plays: number }[];
achievability: HistogramBin[];
}
export interface HabitData {
streakSurvival: { length: number; probability: number }[];
streakLengths: HistogramBin[];
calendar: { date: string; count: number }[];
weekdayHour: number[][];
}
export interface SnapshotPayload {
asOfDate: string;
activity: Activity;
dailySeries: DailyPoint[];
kpis: Kpis;
retention: RetentionData;
verseMix: VerseMixData;
habit: HabitData;
growth: {
lifecycleFunnel: { label: string; players: number }[];
signups: { date: string; count: number }[];
};
/** Safe, all-time aggregates for the public community widget. */
totals: {
completions: number;
players: number;
yesterdayCompletions: number;
yesterdayAvgGuesses: number | null;
};
signals: Signal[];
/** Metadata about data availability, so the UI can show honest em-dashes. */
meta: {
hasStartedData: boolean;
hasShareData: boolean;
firstEventDate: string | null;
generatedAt?: number;
/** Payload shape version; older snapshots are rebuilt lazily. */
schemaVersion?: number;
};
}
// ── Analytics events ─────────────────────────────────────────────────────────
/** Appendix B — allowlisted event names. */
export const EVENT_NAMES = [
'verse_served',
'share',
'share_copy',
'first_visit',
'donate_click',
'discord_click'
] as const;
export type EventName = (typeof EVENT_NAMES)[number];
export const MAX_METADATA_BYTES = 2048;
+36
View File
@@ -0,0 +1,36 @@
/** Pure first-touch acquisition parsing (no browser/SvelteKit imports). */
export interface Acquisition {
referrer: string | null;
utm_source: string | null;
utm_medium: string | null;
utm_campaign: string | null;
}
/**
* Extract first-touch acquisition data from a query string and referrer.
* Same-origin referrers are ignored when `location` is available.
*/
export function parseAcquisition(search: string, referrer: string): Acquisition {
const params = new URLSearchParams(search);
const clean = (v: string | null): string | null => {
if (v === null) return null;
const trimmed = v.trim().slice(0, 256);
return trimmed.length > 0 ? trimmed : null;
};
let ref = referrer || null;
if (ref) {
try {
const refUrl = new URL(ref);
if (typeof location !== 'undefined' && refUrl.origin === location.origin) ref = null;
} catch {
/* keep raw referrer */
}
}
return {
referrer: clean(ref),
utm_source: clean(params.get('utm_source') ?? params.get('ref')),
utm_medium: clean(params.get('utm_medium')),
utm_campaign: clean(params.get('utm_campaign'))
};
}
+65
View File
@@ -0,0 +1,65 @@
import { browser } from '$app/environment';
import { generateUUID } from './game';
import { parseAcquisition, type Acquisition } from './acquisition';
import type { EventName } from '$lib/types/stats';
export { parseAcquisition };
export type { Acquisition };
const ANON_KEY = 'bibdle-anonymous-id';
const FIRST_VISIT_KEY = 'bibdle-first-visit-sent';
/** Stable pseudonymous ID for this browser, created on first use. */
export function getOrCreateAnonymousId(): string {
if (!browser) return '';
let id = localStorage.getItem(ANON_KEY);
if (!id) {
id = generateUUID();
localStorage.setItem(ANON_KEY, id);
}
return id;
}
/**
* Fire-and-forget event. Never throws and never blocks the UI.
* `date` should be the game date for date-scoped events (e.g. verse_served).
*/
export function track(
name: EventName,
metadata?: Record<string, unknown> | null,
date?: string | null
): void {
if (!browser) return;
try {
const body = JSON.stringify({
name,
anonymousId: getOrCreateAnonymousId(),
date: date ?? null,
metadata: metadata ?? null
});
void fetch('/api/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
keepalive: true
}).catch(() => {});
} catch {
/* ignore */
}
}
/**
* Records a single `first_visit` event per browser with referrer / UTM data.
* Call once on mount (root layout).
*/
export function captureFirstVisit(): void {
if (!browser) return;
try {
if (localStorage.getItem(FIRST_VISIT_KEY) === 'true') return;
const acquisition = parseAcquisition(location.search, document.referrer ?? '');
track('first_visit', { ...acquisition });
localStorage.setItem(FIRST_VISIT_KEY, 'true');
} catch {
/* ignore */
}
}
+43
View File
@@ -0,0 +1,43 @@
/** Small formatting helpers shared by the stats dashboard components. */
export function numOrDash(n: number | null | undefined): string {
return n === null || n === undefined ? '—' : String(n);
}
export function pctOrDash(n: number | null | undefined): string {
return n === null || n === undefined ? '—' : `${n}%`;
}
export function signed(n: number | null | undefined): string {
if (n === null || n === undefined) return '—';
return `${n >= 0 ? '+' : ''}${n}`;
}
export function formatDateLabel(dateStr: string): string {
return new Date(dateStr + 'T00:00:00Z').toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC'
});
}
export function weekdayName(dateStr: string): string {
return new Date(dateStr + 'T00:00:00Z').toLocaleDateString('en-US', {
weekday: 'short',
timeZone: 'UTC'
});
}
/** Compare a value to a previous value, returning signed delta and direction. */
export function delta(
value: number | null | undefined,
previous: number | null | undefined
): { text: string; direction: 'up' | 'down' | 'flat' } {
if (value === null || value === undefined || previous === null || previous === undefined) {
return { text: '—', direction: 'flat' };
}
const diff = value - previous;
if (diff === 0) return { text: 'no change', direction: 'flat' };
return { text: signed(diff), direction: diff > 0 ? 'up' : 'down' };
}
+4
View File
@@ -6,6 +6,7 @@
import favicon from "$lib/assets/favicon.ico";
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
import { captureFirstVisit } from "$lib/utils/analytics";
let isDev = $state(false);
@@ -14,6 +15,9 @@
window.location.host === "localhost:5173" ||
window.location.host === "test.bibdle.com";
// Instrumentation E: capture first-touch referrer / UTM once per browser.
captureFirstVisit();
// Inject analytics script
const script = document.createElement("script");
script.defer = true;
+13
View File
@@ -18,6 +18,18 @@ export const actions: Actions = {
const anonymousId = formData.get('anonymousId') as string;
const date = formData.get('date') as string;
const guessCount = parseInt(formData.get('guessCount') as string, 10);
// Optional JSON array of book IDs — mirrors the JSON API so all guest
// completions persist guess sequences (instrumentation A).
const guessesRaw = formData.get('guesses') as string | null;
let guesses: string[] | null = null;
if (guessesRaw) {
try {
const parsed = JSON.parse(guessesRaw);
if (Array.isArray(parsed)) guesses = parsed.map(String);
} catch {
guesses = null;
}
}
// Validation
if (!anonymousId || !date || isNaN(guessCount) || guessCount < 1) {
@@ -33,6 +45,7 @@ export const actions: Actions = {
anonymousId,
date,
guessCount,
guesses: guesses && guesses.length > 0 ? JSON.stringify(guesses) : null,
completedAt,
});
} catch (err: any) {
+3 -1
View File
@@ -1,15 +1,17 @@
import type { PageLoad } from './$types';
import { getOrCreateAnonymousId } from '$lib/utils/analytics';
// Disable SSR so the load function runs on the client with the correct local date
export const ssr = false;
export const load: PageLoad = async ({ fetch, data }) => {
const localDate = new Date().toLocaleDateString("en-CA");
const anonymousId = getOrCreateAnonymousId();
const res = await fetch('/api/daily-verse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date: localDate }),
body: JSON.stringify({ date: localDate, anonymousId }),
});
const result = await res.json();
+89
View File
@@ -0,0 +1,89 @@
import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { isAdmin } from '$lib/server/admin';
import { addDays, isValidDate } from '$lib/server/stats/dates';
import {
buildSnapshot,
ensureSnapshot,
isSnapshotStale
} from '$lib/server/stats/snapshot';
import { deriveSegments, countSegments } from '$lib/server/stats/segments';
import { getSettings, updateSettings, hashSettings } from '$lib/server/stats/settings';
import { sortSignals, verdictLine } from '$lib/server/stats/signals';
import { SETTINGS_DEFAULTS, type SettingsKey, type StatsSettings } from '$lib/types/stats';
function todayEst(): string {
return new Date().toLocaleDateString('en-CA', { timeZone: 'America/New_York' });
}
export const load: PageServerLoad = async ({ locals, url }) => {
if (!isAdmin(locals.user?.email)) {
throw error(404, 'Not found');
}
const today = todayEst();
const yesterday = addDays(today, -1);
const dateParam = url.searchParams.get('date');
let date = dateParam && isValidDate(dateParam) ? dateParam : yesterday;
if (date > today) date = today;
const matured = url.searchParams.get('mature') === '1';
const settings = await getSettings();
// Normal loads read (or lazily build) the immutable snapshot. The mature
// toggle is the only path allowed to compute on demand from raw data.
const snapshot = matured
? (
await buildSnapshot(date, {
kind: 'lazy',
matureThrough: today,
persist: false
})
).snapshot
: await ensureSnapshot(date);
const segments = deriveSegments(snapshot.playerState, date, settings);
const stale =
!matured && snapshot.configVersion !== hashSettings(settings);
const topSignal = sortSignals(snapshot.payload.signals)[0] ?? null;
return {
date,
today,
generatedAt: snapshot.generatedAt,
buildKind: snapshot.buildKind,
payload: snapshot.payload,
settings,
segmentCounts: countSegments(segments),
verdict: verdictLine(snapshot.payload.signals),
topSignal,
stale,
matured,
userEmail: locals.user?.email ?? null
};
};
export const actions: Actions = {
updateSettings: async ({ request, url, locals }) => {
if (!isAdmin(locals.user?.email)) {
return fail(403, { error: 'Forbidden' });
}
const form = await request.formData();
const partial: Partial<StatsSettings> = {};
for (const key of Object.keys(SETTINGS_DEFAULTS) as SettingsKey[]) {
const raw = form.get(key);
if (typeof raw === 'string' && raw !== '') {
const value = Number(raw);
if (Number.isFinite(value)) partial[key] = value;
}
}
try {
await updateSettings(partial);
} catch (err) {
return fail(500, { error: String(err) });
}
// Segments are re-derived at render time, so no rebuild is needed.
const date = url.searchParams.get('date');
throw redirect(303, `/admin/stats${date ? `?date=${date}` : ''}`);
}
};
+75
View File
@@ -0,0 +1,75 @@
<script lang="ts">
import type { PageData } from './$types';
import ConsoleChrome from '$lib/components/stats/ConsoleChrome.svelte';
import Pulse from '$lib/components/stats/Pulse.svelte';
import GrowthLoop from '$lib/components/stats/GrowthLoop.svelte';
import RetentionLab from '$lib/components/stats/RetentionLab.svelte';
import VerseMixLab from '$lib/components/stats/VerseMixLab.svelte';
import HabitLab from '$lib/components/stats/HabitLab.svelte';
import SignalsFeed from '$lib/components/stats/SignalsFeed.svelte';
import FeatureImpact from '$lib/components/stats/FeatureImpact.svelte';
import Glossary from '$lib/components/stats/Glossary.svelte';
import Container from '$lib/components/Container.svelte';
import { numOrDash } from '$lib/utils/stats-format';
let { data }: { data: PageData } = $props();
const segments = $derived(data.segmentCounts);
</script>
<svelte:head>
<title>Growth Console | Bibdle</title>
<meta name="robots" content="noindex" />
</svelte:head>
<div class="min-h-screen bg-gray-950 p-4 md:p-6">
<div class="mx-auto flex max-w-6xl flex-col gap-8 pb-16">
<ConsoleChrome
date={data.date}
today={data.today}
generatedAt={data.generatedAt}
buildKind={data.buildKind}
matured={data.matured}
stale={data.stale}
settings={data.settings}
/>
<Pulse
payload={data.payload}
settings={data.settings}
verdict={data.verdict}
topSignal={data.topSignal}
/>
<section class="flex flex-col gap-3">
<h2 class="text-lg font-bold text-gray-100">Segment mix (as of this date)</h2>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{#each [
{ label: 'New (7d)', value: segments.new },
{ label: 'Core', value: segments.core },
{ label: 'Returning', value: segments.returning },
{ label: 'Resurrected', value: segments.resurrected },
{ label: 'At risk', value: segments.atRisk },
{ label: 'Churned', value: segments.churned }
] as item (item.label)}
<Container class="p-3 w-full items-start text-left">
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-400">{item.label}</p>
<p class="text-xl font-bold text-gray-100">{numOrDash(item.value)}</p>
</Container>
{/each}
</div>
<p class="text-xs text-gray-500">
Segments are re-derived from stored per-player state, so changing thresholds in Settings
updates every date instantly. Total tracked players: {numOrDash(segments.total)}.
</p>
</section>
<GrowthLoop payload={data.payload} />
<RetentionLab payload={data.payload} settings={data.settings} />
<VerseMixLab payload={data.payload} />
<HabitLab payload={data.payload} />
<SignalsFeed signals={data.payload.signals} />
<FeatureImpact />
<Glossary />
</div>
</div>
@@ -0,0 +1,31 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { isAdmin } from '$lib/server/admin';
import { isValidDate } from '$lib/server/stats/dates';
import { buildSnapshot } from '$lib/server/stats/snapshot';
/** Admin-only manual snapshot (re)build, e.g. after a settings change. */
export const POST: RequestHandler = async ({ request, locals }) => {
if (!isAdmin(locals.user?.email)) {
return json({ error: 'Forbidden' }, { status: 403 });
}
let body: { date?: string; mature?: boolean } = {};
try {
body = await request.json();
} catch {
/* fall through to validation */
}
const date = body.date;
if (!date || !isValidDate(date)) {
return json({ error: 'A valid date (YYYY-MM-DD) is required' }, { status: 400 });
}
const { snapshot } = await buildSnapshot(date, {
kind: 'reconstructed',
replace: true
});
return json({ ok: true, date, buildKind: snapshot.buildKind, generatedAt: snapshot.generatedAt });
};
@@ -0,0 +1,31 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { addDays } from '$lib/server/stats/dates';
import { buildSnapshot } from '$lib/server/stats/snapshot';
import { sendDigest } from '$lib/server/stats/digest';
/**
* Daily snapshot build trigger. Protected by `Authorization: Bearer $CRON_SECRET`
* (same pattern as `/api/send-daily-verse`). Builds yesterday in `America/New_York`
* and then sends the digest.
*/
export const POST: RequestHandler = async ({ request }) => {
const cronSecret = Bun.env.CRON_SECRET;
const authHeader = request.headers.get('Authorization');
if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const todayEst = new Date().toLocaleDateString('en-CA', { timeZone: 'America/New_York' });
const yesterday = addDays(todayEst, -1);
const { snapshot } = await buildSnapshot(yesterday, {
kind: 'scheduled',
replace: true
});
// Digest failure must never fail the build.
await sendDigest(snapshot.payload);
return json({ ok: true, date: yesterday, buildKind: 'scheduled' });
};
+15 -2
View File
@@ -2,10 +2,11 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getVerseForDate } from '$lib/server/daily-verse';
import { getBookById } from '$lib/server/bible';
import { recordEvent } from '$lib/server/stats/events';
export const POST: RequestHandler = async ({ request }) => {
export const POST: RequestHandler = async ({ request, locals }) => {
const body = await request.json();
const { date } = body;
const { date, anonymousId } = body;
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return json({ error: 'A valid date (YYYY-MM-DD) is required' }, { status: 400 });
@@ -16,6 +17,18 @@ export const POST: RequestHandler = async ({ request }) => {
const dailyVerse = await getVerseForDate(dateStr);
const correctBook = getBookById(dailyVerse.bookId) ?? null;
// Instrumentation B: a player "started" the puzzle for this date. The unique
// partial index dedupes reloads, so this is safe to call on every load.
// Never let analytics failures affect the response.
// Prefer the signed-in user's ID so the started→finished funnel matches
// completions (which are keyed to the user ID for logged-in players).
const playerId =
locals.user?.id ??
(typeof anonymousId === 'string' && anonymousId.length > 0 ? anonymousId : null);
if (playerId) {
void recordEvent({ name: 'verse_served', anonymousId: playerId, date: dateStr });
}
return json({
dailyVerse,
correctBookId: dailyVerse.bookId,
+52
View File
@@ -0,0 +1,52 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { isEventName, recordEvent, encodeMetadata } from '$lib/server/stats/events';
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
/**
* Fire-and-forget client analytics sink.
*
* Body: `{ name, anonymousId?, date?, metadata? }`.
* `userId` is resolved server-side from the session — never trusted from the client.
* Always returns 204, even for invalid input, so gameplay is never blocked.
*/
export const POST: RequestHandler = async ({ request, locals }) => {
let body: any;
try {
body = await request.json();
} catch {
return new Response(null, { status: 204 });
}
const name = typeof body?.name === 'string' ? body.name : '';
if (!isEventName(name)) {
return new Response(null, { status: 204 });
}
const anonymousId =
typeof body?.anonymousId === 'string' && body.anonymousId.length > 0
? body.anonymousId.slice(0, 128)
: null;
const date =
typeof body?.date === 'string' && DATE_RE.test(body.date) ? body.date : null;
const metadata =
body?.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata)
? (body.metadata as Record<string, unknown>)
: null;
// Ensure metadata is always serializable/capped before it reaches the DB.
void encodeMetadata(metadata);
await recordEvent({
name,
anonymousId,
userId: locals.user?.id ?? null,
date,
metadata
});
return new Response(null, { status: 204 });
};
+19 -478
View File
@@ -1,484 +1,25 @@
import { db } from '$lib/server/db';
import { dailyCompletions, user } from '$lib/server/db/schema';
import { eq, gte, count, countDistinct, avg, asc, min } from 'drizzle-orm';
import type { PageServerLoad } from './$types';
import { ensureSnapshot } from '$lib/server/stats/snapshot';
import { addDays } from '$lib/server/stats/dates';
function estDateStr(daysAgo = 0): string {
const estNow = new Date(Date.now() - 5 * 60 * 60 * 1000); // UTC-5
estNow.setUTCDate(estNow.getUTCDate() - daysAgo);
return estNow.toISOString().slice(0, 10);
}
function prevDay(d: string): string {
const dt = new Date(d + 'T00:00:00Z');
dt.setUTCDate(dt.getUTCDate() - 1);
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);
}
/**
* Slim public community widget. Reads the latest daily snapshot and exposes
* only safe aggregates (no retention, churn, signup or revenue data).
*/
export const load: PageServerLoad = async () => {
const todayEst = estDateStr(0);
const yesterdayEst = estDateStr(1);
const sevenDaysAgo = estDateStr(7);
// Three weekly windows for first + second derivative calculations
// Week A: last 7 days (indices 0–6)
// Week B: 7–13 days ago (indices 7–13)
// Week C: 14–20 days ago (indices 14–20)
const weekAStart = estDateStr(6);
const weekBEnd = estDateStr(7);
const weekBStart = estDateStr(13);
const weekCEnd = estDateStr(14);
const weekCStart = estDateStr(20);
// ── Scalar stats ──────────────────────────────────────────────────────────
const [{ todayCount }] = await db
.select({ todayCount: count() })
.from(dailyCompletions)
.where(eq(dailyCompletions.date, todayEst));
const [{ totalCount }] = await db
.select({ totalCount: count() })
.from(dailyCompletions);
const [{ uniquePlayers }] = await db
.select({ uniquePlayers: countDistinct(dailyCompletions.anonymousId) })
.from(dailyCompletions);
const [{ weeklyPlayers }] = await db
.select({ weeklyPlayers: countDistinct(dailyCompletions.anonymousId) })
.from(dailyCompletions)
.where(gte(dailyCompletions.date, sevenDaysAgo));
const thirtyDaysAgo = estDateStr(30);
const [{ monthlyPlayers }] = await db
.select({ monthlyPlayers: countDistinct(dailyCompletions.anonymousId) })
.from(dailyCompletions)
.where(gte(dailyCompletions.date, thirtyDaysAgo));
const todayPlayers = await db
.selectDistinct({ id: dailyCompletions.anonymousId })
.from(dailyCompletions)
.where(eq(dailyCompletions.date, todayEst));
const yesterdayPlayers = await db
.selectDistinct({ id: dailyCompletions.anonymousId })
.from(dailyCompletions)
.where(eq(dailyCompletions.date, yesterdayEst));
const todaySet = new Set(todayPlayers.map((r) => r.id));
const activeStreaks = yesterdayPlayers.filter((r) => todaySet.has(r.id)).length;
const [{ avgGuessesRaw }] = await db
.select({ avgGuessesRaw: avg(dailyCompletions.guessCount) })
.from(dailyCompletions)
.where(eq(dailyCompletions.date, todayEst));
const avgGuessesToday = avgGuessesRaw != null ? parseFloat(avgGuessesRaw) : null;
const [{ registeredUsers }] = await db
.select({ registeredUsers: count() })
.from(user);
const avgCompletionsPerPlayer =
uniquePlayers > 0 ? Math.round((totalCount / uniquePlayers) * 100) / 100 : null;
// ── 21-day completions per day (covers all three weekly windows) ──────────
const rawPerDay21 = await db
.select({ date: dailyCompletions.date, dayCount: count() })
.from(dailyCompletions)
.where(gte(dailyCompletions.date, weekCStart))
.groupBy(dailyCompletions.date)
.orderBy(asc(dailyCompletions.date));
const counts21 = new Map(rawPerDay21.map((r) => [r.date, r.dayCount]));
// Build indexed array: index 0 = today, index 20 = 20 days ago
const completionsPerDay: number[] = [];
for (let i = 0; i <= 20; i++) {
completionsPerDay.push(counts21.get(estDateStr(i)) ?? 0);
}
// last14Days for the trend chart (most recent first)
const last14Days: { date: string; count: number }[] = [];
for (let i = 0; i <= 13; i++) {
last14Days.push({ date: estDateStr(i), count: completionsPerDay[i] });
}
// Weekly totals from the indexed array
const weekATotal = completionsPerDay.slice(0, 7).reduce((a, b) => a + b, 0);
const weekBTotal = completionsPerDay.slice(7, 14).reduce((a, b) => a + b, 0);
const weekCTotal = completionsPerDay.slice(14, 21).reduce((a, b) => a + b, 0);
// First derivative: avg daily completions change (week A vs week B)
const completionsVelocity = Math.round(((weekATotal - weekBTotal) / 7) * 10) / 10;
// Second derivative: is velocity itself increasing or decreasing?
const completionsAcceleration =
Math.round((((weekATotal - weekBTotal) - (weekBTotal - weekCTotal)) / 7) * 10) / 10;
// ── 90-day per-user data (reused for streaks + weekly user sets) ──────────
const ninetyDaysAgo = estDateStr(90);
const recentCompletions = await db
.select({ anonymousId: dailyCompletions.anonymousId, date: dailyCompletions.date })
.from(dailyCompletions)
.where(gte(dailyCompletions.date, ninetyDaysAgo))
.orderBy(asc(dailyCompletions.date));
// Group dates by user (ascending) and users by date
const userDatesMap = new Map<string, string[]>();
const dateUsersMap = new Map<string, Set<string>>();
for (const row of recentCompletions) {
const arr = userDatesMap.get(row.anonymousId);
if (arr) arr.push(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 ───────────────────────────────────────────────────
const streakDistribution = new Map<number, number>();
for (const dates of userDatesMap.values()) {
const desc = dates.slice().reverse();
if (desc[0] !== todayEst && desc[0] !== yesterdayEst) continue;
let streak = 1;
let cur = desc[0];
for (let i = 1; i < desc.length; i++) {
if (desc[i] === prevDay(cur)) {
streak++;
cur = desc[i];
} else {
break;
}
}
if (streak >= 2) {
streakDistribution.set(streak, (streakDistribution.get(streak) ?? 0) + 1);
}
}
const streakChart = Array.from(streakDistribution.entries())
.sort((a, b) => a[0] - b[0])
.map(([days, userCount]) => ({ days, count: userCount }));
// ── Weekly user sets (for user-based velocity + churn) ───────────────────
const weekAUsers = new Set<string>();
const weekBUsers = new Set<string>();
const weekCUsers = new Set<string>();
for (const [userId, dates] of userDatesMap) {
if (dates.some((d) => d >= weekAStart)) weekAUsers.add(userId);
if (dates.some((d) => d >= weekBStart && d <= weekBEnd)) weekBUsers.add(userId);
if (dates.some((d) => d >= weekCStart && d <= weekCEnd)) weekCUsers.add(userId);
}
// First derivative: weekly unique users change
const userVelocity = weekAUsers.size - weekBUsers.size;
// Second derivative: is user growth speeding up or slowing down?
const userAcceleration =
weekAUsers.size - weekBUsers.size - (weekBUsers.size - weekCUsers.size);
// ── New players + churn ───────────────────────────────────────────────────
// New players: anonymousIds whose first-ever completion falls in the last 7 days.
// Checking against all-time data (not just the 90-day window) ensures accuracy.
const firstDates = await db
.select({
anonymousId: dailyCompletions.anonymousId,
firstDate: min(dailyCompletions.date),
totalCompletions: count()
})
.from(dailyCompletions)
.groupBy(dailyCompletions.anonymousId);
const newUsers7d = firstDates.filter((r) => r.firstDate != null && r.firstDate >= weekAStart).length;
// Churned: played in week B but not at all in week A
const churned7d = [...weekBUsers].filter((id) => !weekAUsers.has(id)).length;
// Net growth = truly new arrivals minus departures
const netGrowth7d = newUsers7d - churned7d;
// ── Session depth funnel ──────────────────────────────────────────────────
// For each depth d, count players with >= d completions.
// returnRate at depth d = (players with >= d+1) / (players with >= d).
const depthCounts = new Map<number, number>();
for (const r of firstDates) {
const n = r.totalCompletions;
for (let d = 1; d <= n; d++) {
depthCounts.set(d, (depthCounts.get(d) ?? 0) + 1);
}
}
const sessionDepthCards = [2, 3, 4, 5, 7].map((d) => {
const atD = depthCounts.get(d) ?? 0;
const atDplus1 = depthCounts.get(d + 1) ?? 0;
return {
depth: d,
players: atD,
returnRate: atD >= 3 ? Math.round((atDplus1 / atD) * 1000) / 10 : null
};
});
// ── 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 53–59) vs prior 7 (idx 46–52)
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) {
if (dateUsersMap.get(addDays(dateD, windowDays))?.has(userId)) {
retained++;
}
}
series.push({
date: dateD,
rate: Math.round((retained / cohort.size) * 1000) / 10,
cohortSize: cohort.size
});
}
return series; // newest first (loop iterates i from smallest = most recent)
}
const retention7dSeries = retentionSeries(7, 30);
const retention30dSeries = retentionSeries(30, 30);
// ── Weekly Active Users history (12 weeks) ────────────────────────────────
const wauWeeks: { weekStart: string; weekEnd: string; wau: number; changePct: number | null }[] = [];
for (let w = 0; w < 12; w++) {
const weekEnd = estDateStr(w * 7);
const weekStart = estDateStr(w * 7 + 6);
const users = new Set<string>();
for (const row of recentCompletions) {
if (row.date >= weekStart && row.date <= weekEnd) {
users.add(row.anonymousId);
}
}
wauWeeks.push({ weekEnd, weekStart, wau: users.size, changePct: null });
}
// Change % vs prior week (index i+1 is the older week)
for (let i = 0; i < wauWeeks.length - 1; i++) {
const prev = wauWeeks[i + 1].wau;
if (prev > 0) {
wauWeeks[i].changePct = Math.round(((wauWeeks[i].wau - prev) / prev) * 1000) / 10;
}
}
const avgWau = Math.round(wauWeeks.reduce((sum, w) => sum + w.wau, 0) / wauWeeks.length);
// ── Monthly Active Users history (6 months) ───────────────────────────────
const sixMonthsAgo = estDateStr(185);
const mauCompletions = await db
.select({ anonymousId: dailyCompletions.anonymousId, date: dailyCompletions.date })
.from(dailyCompletions)
.where(gte(dailyCompletions.date, sixMonthsAgo));
// Rolling 30-day windows
const mauMonths: { monthStart: string; monthEnd: string; mau: number; changePct: number | null }[] = [];
for (let m = 0; m < 6; m++) {
const monthEnd = estDateStr(m * 30);
const monthStart = estDateStr(m * 30 + 29);
const users = new Set<string>();
for (const row of mauCompletions) {
if (row.date >= monthStart && row.date <= monthEnd) {
users.add(row.anonymousId);
}
}
mauMonths.push({ monthStart, monthEnd, mau: users.size, changePct: null });
}
for (let i = 0; i < mauMonths.length - 1; i++) {
const prev = mauMonths[i + 1].mau;
if (prev > 0) {
mauMonths[i].changePct = Math.round(((mauMonths[i].mau - prev) / prev) * 1000) / 10;
}
}
// Calendar month windows
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const [todayYear, todayMonth, todayDay] = todayEst.split('-').map(Number);
const calendarMauMonths: {
label: string;
monthStart: string;
monthEnd: string;
mau: number;
daysElapsed: number;
daysInMonth: number;
projectedMau: number | null;
changePct: number | null;
isCurrentMonth: boolean;
}[] = [];
for (let i = 0; i < 6; i++) {
let mo = todayMonth - i;
let yr = todayYear;
if (mo <= 0) { mo += 12; yr--; }
// new Date(yr, mo, 0) gives last day of month mo (1-indexed) in local time
const daysInMonth = new Date(yr, mo, 0).getDate();
const monthStart = `${yr}-${String(mo).padStart(2, '0')}-01`;
const monthEnd = `${yr}-${String(mo).padStart(2, '0')}-${String(daysInMonth).padStart(2, '0')}`;
const isCurrentMonth = i === 0;
const daysElapsed = isCurrentMonth ? todayDay : daysInMonth;
const queryEnd = isCurrentMonth ? todayEst : monthEnd;
const users = new Set<string>();
for (const row of mauCompletions) {
if (row.date >= monthStart && row.date <= queryEnd) {
users.add(row.anonymousId);
}
}
const projectedMau = isCurrentMonth && daysElapsed > 0
? Math.round(users.size * (daysInMonth / daysElapsed))
: null;
calendarMauMonths.push({
label: `${MONTH_NAMES[mo - 1]} ${yr}`,
monthStart,
monthEnd,
mau: users.size,
daysElapsed,
daysInMonth,
projectedMau,
changePct: null,
isCurrentMonth
});
}
for (let i = 0; i < calendarMauMonths.length - 1; i++) {
const curr = calendarMauMonths[i];
const prev = calendarMauMonths[i + 1];
if (prev.mau > 0) {
const compareVal = curr.projectedMau ?? curr.mau;
curr.changePct = Math.round(((compareVal - prev.mau) / prev.mau) * 1000) / 10;
}
}
const todayEst = new Date().toLocaleDateString('en-CA', { timeZone: 'America/New_York' });
const yesterday = addDays(todayEst, -1);
const snapshot = await ensureSnapshot(yesterday);
const payload = snapshot.payload;
return {
todayEst,
sessionDepthCards,
stats: {
todayCount,
totalCount,
uniquePlayers,
weeklyPlayers,
activeStreaks,
avgGuessesToday,
registeredUsers,
monthlyPlayers
},
growth: {
completionsVelocity,
completionsAcceleration,
userVelocity,
userAcceleration,
newUsers7d,
churned7d,
netGrowth7d
},
last14Days,
streakChart,
retention7dSeries,
retention30dSeries,
overallReturnRate,
newPlayerReturnSeries: newPlayerReturnSeries.slice(-30).reverse(),
newPlayerReturnVelocity: {
current7dAvg: current7dReturnAvg,
prior7dAvg: prior7dReturnAvg,
change: returnRateChange
},
wauWeeks,
avgWau,
mauMonths,
calendarMauMonths
asOfDate: payload.asOfDate,
completions: payload.totals.completions,
players: payload.totals.players,
weeklyPlayers: payload.activity.wau,
monthlyPlayers: payload.activity.mau,
yesterdayCompletions: payload.totals.yesterdayCompletions,
yesterdayAvgGuesses: payload.totals.yesterdayAvgGuesses,
calendar: payload.habit.calendar.slice(-30)
};
};
};
+47 -618
View File
@@ -1,627 +1,56 @@
<script lang="ts">
import Container from "$lib/components/Container.svelte";
import CollapsibleTable from "$lib/components/CollapsibleTable.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 }[];
}
import type { PageData } from './$types';
import Container from '$lib/components/Container.svelte';
import CalendarHeatmap from '$lib/components/charts/CalendarHeatmap.svelte';
import { formatDateLabel, numOrDash } from '$lib/utils/stats-format';
let { data }: { data: PageData } = $props();
const {
stats,
last14Days,
todayEst,
streakChart,
growth,
retention7dSeries,
retention30dSeries,
overallReturnRate,
newPlayerReturnSeries,
newPlayerReturnVelocity,
wauWeeks,
avgWau,
mauMonths,
calendarMauMonths,
sessionDepthCards,
} = $derived(data);
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 maxMau = $derived(Math.max(1, ...mauMonths.map((m) => m.mau)));
const maxCalMau = $derived(Math.max(1, ...calendarMauMonths.map((m) => m.projectedMau ?? m.mau)));
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",
},
]);
const mauModes = [
{ value: 'rolling', label: 'Rolling 30d' },
{ value: 'calendar', label: 'Calendar' },
];
</script>
<svelte:head>
<title>Global Stats | Bibdle</title>
<title>Community Stats | Bibdle</title>
<meta name="description" content="Bibdle community statistics, as of yesterday." />
</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 8–14 ago</span
>
</Container>
</div>
<CollapsibleTable
rows={newPlayerReturnSeries}
headers={[
{ label: 'Date' },
{ label: 'New Players', align: 'right' },
{ label: 'Return Rate', align: 'right' },
{ label: '7d Avg', align: 'right' },
{ label: '', width: 'w-32' },
]}
>
{#snippet row(item)}
<td class="px-4 py-3 text-gray-300">{item.date}</td>
<td class="px-4 py-3 text-right text-gray-400 text-xs">{item.cohort}</td>
<td class="px-4 py-3 text-right text-gray-400">
{item.rate != null ? `${item.rate}%` : '—'}
</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">
{item.rollingAvg != null ? `${item.rollingAvg}%` : '—'}
</td>
<td class="px-4 py-3">
<div class="w-full min-w-20">
{#if item.rollingAvg != null}
<div class="bg-sky-500 h-4 rounded" style="width: {item.rollingAvg}%"></div>
{/if}
</div>
</td>
{/snippet}
</CollapsibleTable>
</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>
<CollapsibleTable
rows={wauWeeks}
headers={[
{ label: 'Week' },
{ label: 'Active Users', align: 'right' },
{ label: 'Wk/Wk Change', align: 'right' },
{ label: '', width: 'w-48' },
]}
>
{#snippet row(item)}
{@const barPct = Math.round((item.wau / maxWau) * 100)}
<td class="px-4 py-3 text-gray-300 text-xs">{item.weekStart} – {item.weekEnd}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.wau}</td>
<td class="px-4 py-3 text-right text-xs font-medium {item.changePct != null
? item.changePct > 0 ? 'text-green-400' : item.changePct < 0 ? 'text-red-400' : 'text-gray-400'
: 'text-gray-500'}">
{item.changePct != null ? signed(item.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>
{/snippet}
</CollapsibleTable>
</section>
<section class="mt-8">
<h2 class="text-lg font-semibold text-gray-100 mb-1">Monthly Active Users</h2>
<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'}
<CollapsibleTable
rows={mauMonths}
headers={[
{ label: 'Period' },
{ label: 'Active Users', align: 'right' },
{ label: 'Mo/Mo Change', align: 'right' },
{ label: '', width: 'w-48' },
]}
modes={mauModes}
bind:mode={mauMode}
>
{#snippet row(item)}
{@const barPct = Math.round((item.mau / maxMau) * 100)}
<td class="px-4 py-3 text-gray-300 text-xs">{item.monthStart} – {item.monthEnd}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.mau}</td>
<td class="px-4 py-3 text-right text-xs font-medium {item.changePct != null
? item.changePct > 0 ? 'text-green-400' : item.changePct < 0 ? 'text-red-400' : 'text-gray-400'
: 'text-gray-500'}">
{item.changePct != null ? signed(item.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>
{/snippet}
</CollapsibleTable>
{:else}
<CollapsibleTable
rows={calendarMauMonths}
headers={[
{ label: 'Month' },
{ label: 'Active Users', align: 'right' },
{ label: 'Mo/Mo Change', align: 'right' },
{ label: '', width: 'w-48' },
]}
modes={mauModes}
bind:mode={mauMode}
>
{#snippet row(item)}
{@const displayMau = item.projectedMau ?? item.mau}
{@const barPct = Math.round((displayMau / maxCalMau) * 100)}
<td class="px-4 py-3 text-gray-300">
{item.label}
{#if item.isCurrentMonth}
<span class="text-xs text-gray-500 ml-1">(projected)</span>
{/if}
</td>
<td class="px-4 py-3 text-right font-medium {item.isCurrentMonth ? 'text-gray-400' : 'text-gray-100'}">
{#if item.isCurrentMonth}
<span class="text-gray-500 text-xs">{item.mau} → </span>{item.projectedMau ?? item.mau}
{:else}
{item.mau}
{/if}
</td>
<td class="px-4 py-3 text-right text-xs font-medium {item.changePct != null
? item.changePct > 0 ? 'text-green-400' : item.changePct < 0 ? 'text-red-400' : 'text-gray-400'
: 'text-gray-500'}">
{#if item.changePct != null}
{item.isCurrentMonth ? '~' : ''}{signed(item.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 {item.isCurrentMonth ? 'opacity-50' : ''}" style="width: {barPct}%"></div>
</div>
</td>
{/snippet}
</CollapsibleTable>
{/if}
</section>
<section class="mt-8">
<h2 class="text-lg font-semibold text-gray-100 mb-4">
Last 14 Days — Completions
</h2>
<CollapsibleTable
rows={last14Days}
headers={[
{ label: 'Date' },
{ label: 'Completions', align: 'right' },
{ label: '', width: 'w-48' },
]}
>
{#snippet row(item)}
{@const barPct = Math.round((item.count / maxCount) * 100)}
<td class="px-4 py-3 text-gray-300">{item.date}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.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>
{/snippet}
</CollapsibleTable>
</section>
<section class="mt-8">
<h2 class="text-lg font-semibold text-gray-100 mb-4">
Active Streak Distribution
</h2>
<CollapsibleTable
rows={streakChart}
headers={[
{ label: 'Days' },
{ label: 'Players', align: 'right' },
{ label: '', width: 'w-48' },
]}
>
{#snippet row(item)}
{@const barPct = Math.round((item.count / maxStreakCount) * 100)}
<td class="px-4 py-3 text-gray-300">{item.days}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.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>
{/snippet}
{#snippet empty()}
<p class="text-gray-400 text-sm px-4 py-6">No active streaks yet.</p>
{/snippet}
</CollapsibleTable>
</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>
<CollapsibleTable
rows={retention7dSeries}
headers={[
{ label: 'Cohort Date' },
{ label: 'n', align: 'right' },
{ label: 'Ret. %', align: 'right' },
{ label: '', width: 'w-32' },
]}
>
{#snippet row(item)}
<td class="px-4 py-3 text-gray-300">{item.date}</td>
<td class="px-4 py-3 text-right text-gray-400 text-xs">{item.cohortSize}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.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: {item.rate}%"></div>
</div>
</td>
{/snippet}
</CollapsibleTable>
</div>
<!-- 30-day retention -->
<div>
<h3 class="text-base font-semibold text-gray-200 mb-3">
30-Day Retention
</h3>
<CollapsibleTable
rows={retention30dSeries}
headers={[
{ label: 'Cohort Date' },
{ label: 'n', align: 'right' },
{ label: 'Ret. %', align: 'right' },
{ label: '', width: 'w-32' },
]}
>
{#snippet row(item)}
<td class="px-4 py-3 text-gray-300">{item.date}</td>
<td class="px-4 py-3 text-right text-gray-400 text-xs">{item.cohortSize}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.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: {item.rate}%"></div>
</div>
</td>
{/snippet}
</CollapsibleTable>
</div>
</div>
</section>
<div class="mx-auto max-w-3xl px-4 py-8">
<div class="mb-6 text-center">
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">Community Stats</h1>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">As of {formatDateLabel(data.asOfDate)}</p>
</div>
</div>
<div class="grid grid-cols-2 gap-3 md:grid-cols-3">
<Container class="p-4 w-full items-start text-left">
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">All-time finishes</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.completions)}</p>
</Container>
<Container class="p-4 w-full items-start text-left">
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">All-time players</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.players)}</p>
</Container>
<Container class="p-4 w-full items-start text-left">
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">Players this week</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.weeklyPlayers)}</p>
</Container>
<Container class="p-4 w-full items-start text-left">
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">Players this month</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.monthlyPlayers)}</p>
</Container>
<Container class="p-4 w-full items-start text-left">
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">Yesterday's finishes</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.yesterdayCompletions)}</p>
</Container>
<Container class="p-4 w-full items-start text-left">
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">Avg guesses yesterday</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.yesterdayAvgGuesses)}</p>
</Container>
</div>
<Container class="mt-3 p-4 w-full items-stretch text-left gap-2">
<p class="font-semibold text-gray-900 dark:text-gray-100">Daily finishes (last 30 days)</p>
<CalendarHeatmap data={data.calendar} />
</Container>
<p class="mt-4 text-center text-xs text-gray-500 dark:text-gray-500">
Updated once a day from completed games. <a class="underline hover:text-gray-300" href="/">Play today's verse →</a>
</p>
</div>