mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-10-22 05:54:10 -04:00
first pass analytics redo
This commit is contained in:
1 parent
b656fd04c0
commit
45ffa39f2a
69 files changed
+6400
-1138
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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 & 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 & 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 & 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>
|
||||
@@ -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 & 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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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'))
|
||||
};
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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}` : ''}`);
|
||||
}
|
||||
};
|
||||
@@ -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' });
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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
@@ -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 & 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>
|
||||
Reference in new issue
Block a user