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

@@ -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>