From f9aded56f8d1500ed83bb08290f9de3ab632d291 Mon Sep 17 00:00:00 2001 From: George Powell Date: Wed, 30 Sep 2026 17:00:48 -0400 Subject: [PATCH] feat(stats): add activity buckets, WAU projection, and shared community stats - Add a weekly vs monthly players chart to Pulse, counted over whole completed weeks (Mon-Sun) and calendar months instead of rolling windows. - Add a Projection section below Pulse that estimates future weekly players three ways (flow, regression, seasonal) with a shaded likely range. - Pull the public community stats into one shared component used by both the public page and the admin console, and show yesterday's verse on the public page. - Show "Resurrected" instead of "Churned" in the segment mix chart, since churned only ever grows and flattens the rest. - Make full-width charts fill their container and line the hover cursor up correctly; show 3 months of new players per day. - Hide the verse mix lab for now. --- .../components/charts/MiniLineChart.svelte | 161 ++++++- .../components/stats/CommunityStats.svelte | 141 ++++++ src/lib/components/stats/Glossary.svelte | 2 + src/lib/components/stats/GrowthLoop.svelte | 5 +- src/lib/components/stats/Projection.svelte | 147 ++++++ src/lib/components/stats/Pulse.svelte | 97 +++- src/lib/components/stats/RetentionLab.svelte | 16 +- src/lib/server/stats/dates.ts | 26 + src/lib/server/stats/metrics.ts | 448 +++++++++++++++++- src/lib/server/stats/snapshot.ts | 11 +- src/lib/types/stats.ts | 74 +++ src/routes/admin/stats/+page.svelte | 29 +- src/routes/global/+page.server.ts | 25 +- src/routes/global/+page.svelte | 136 +----- tests/stats-metrics.test.ts | 191 +++++++- todo.md | 16 +- 16 files changed, 1363 insertions(+), 162 deletions(-) create mode 100644 src/lib/components/stats/CommunityStats.svelte create mode 100644 src/lib/components/stats/Projection.svelte diff --git a/src/lib/components/charts/MiniLineChart.svelte b/src/lib/components/charts/MiniLineChart.svelte index ad46c12..618823a 100644 --- a/src/lib/components/charts/MiniLineChart.svelte +++ b/src/lib/components/charts/MiniLineChart.svelte @@ -4,6 +4,14 @@ color: string; points: { date: string; value: number | null }[]; step?: boolean; + /** Draw the line dashed (e.g. a projection). */ + dash?: boolean; + } + /** Optional shaded uncertainty band, aligned index-for-index with the series. */ + interface Band { + lower: (number | null)[]; + upper: (number | null)[]; + color: string; } interface Props { series: Series[]; @@ -12,6 +20,13 @@ yMin?: number; yMax?: number; showLegend?: boolean; + /** + * Axis labels for calendar buckets, indexed like the points. Falls back + * to each point's date when omitted. + */ + xLabels?: string[]; + /** Shaded range drawn behind the lines (p10–p90 style). */ + band?: Band; } let { series, @@ -19,16 +34,42 @@ ySuffix = '', yMin, yMax, - showLegend = true + showLegend = true, + xLabels, + band }: Props = $props(); - const W = 600; + /** + * The viewBox tracks the element's pixel width so the plot genuinely fills + * a full-width container. A fixed viewBox would be letterboxed and centred + * by the svg's "meet" scaling, leaving dead space at the sides and breaking + * the pointer -> index mapping. Falls back to 600 before first measurement. + */ + const FALLBACK_W = 600; + const MIN_W = 280; + let wrapperEl: HTMLDivElement | undefined = $state(); + let measuredWidth = $state(0); + const W = $derived(Math.max(MIN_W, measuredWidth || FALLBACK_W)); + + $effect(() => { + const el = wrapperEl; + if (!el) return; + const observer = new ResizeObserver((entries) => { + measuredWidth = Math.round(entries[0]?.contentRect.width ?? 0); + }); + observer.observe(el); + return () => observer.disconnect(); + }); + const pad = { l: 36, r: 10, t: 10, b: 22 }; - const innerW = W - pad.l - pad.r; + const innerW = $derived(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) + [ + ...series.flatMap((s) => s.points.map((p) => p.value)), + ...(band ? [...band.lower, ...band.upper] : []) + ].filter((v): v is number => v !== null && v !== undefined) ); const domain = $derived.by(() => { const lo = yMin ?? (allValues.length ? Math.min(...allValues) : 0); @@ -64,25 +105,81 @@ 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 }; + return { name: s.name, color: s.color, segments, step: s.step, dash: s.dash }; }) ); - const firstLabel = $derived(series[0]?.points[0]?.date?.slice(5) ?? ''); - const lastLabel = $derived( - series[0]?.points[series[0].points.length - 1]?.date?.slice(5) ?? '' - ); + /** + * Filled band path: for each contiguous run where both bounds exist, trace the + * upper edge left-to-right and the lower edge right-to-left, then close. + */ + const bandPath = $derived.by(() => { + if (!band) return ''; + const n = Math.max(band.lower.length, band.upper.length); + const parts: string[] = []; + let run: number[] = []; + const flush = () => { + if (run.length < 2) { + run = []; + return; + } + const top = run.map((i) => `${xAt(i).toFixed(1)},${yAt(band.upper[i] as number).toFixed(1)}`); + const bottom = [...run] + .reverse() + .map((i) => `${xAt(i).toFixed(1)},${yAt(band.lower[i] as number).toFixed(1)}`); + const edge = [top[0], ...top.slice(1).map((p) => `L${p}`), ...bottom.map((p) => `L${p}`)]; + parts.push(`M${edge.join(' ')} Z`); + run = []; + }; + for (let i = 0; i < n; i++) { + const lo = band.lower[i]; + const hi = band.upper[i]; + if (lo === null || lo === undefined || hi === null || hi === undefined) flush(); + else run.push(i); + } + flush(); + return parts.join(' '); + }); + + function labelAt(i: number): string { + return xLabels?.[i] ?? series[0]?.points[i]?.date ?? ''; + } + + /** Axis ticks fall back to MM-DD so a 90-day chart's labels stay compact. */ + function axisLabelAt(i: number): string { + const label = xLabels?.[i]; + if (label !== undefined) return label; + return (series[0]?.points[i]?.date ?? '').slice(5); + } + + const firstLabel = $derived.by(() => (pointCount > 0 ? axisLabelAt(0) : '')); + const lastLabel = $derived.by(() => (pointCount > 0 ? axisLabelAt(pointCount - 1) : '')); + + // A middle tick only once the plot is wide enough that the three labels + // cannot collide (roughly 70px per label at this font size). + const midIndex = $derived(Math.floor((pointCount - 1) / 2)); + const showMidLabel = $derived(innerW > 240 && pointCount > 4); + const midLabel = $derived(showMidLabel ? axisLabelAt(midIndex) : ''); // --- Hover / tooltip state ------------------------------------------------- - let wrapperEl: HTMLDivElement | undefined = $state(); + let svgEl: SVGSVGElement | undefined = $state(); let hoverIndex = $state(null); + /** Letterbox transform of the viewBox inside the element, refreshed on move. */ + let box = $state({ left: 0, scale: 1, width: 1 }); 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 (!svgEl || pointCount <= 0) return; + const rect = svgEl.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + + // The svg scales its viewBox to fit the element ("meet"), so on a + // container wider than the viewBox aspect ratio the drawing is + // letterboxed and centred. Map the pointer through that same transform, + // otherwise the right half of a full-width chart reports the wrong index. + const scale = Math.min(rect.width / W, rect.height / height); + const offsetX = (rect.width - W * scale) / 2; + box = { left: offsetX, scale, width: rect.width }; + const svgX = (event.clientX - rect.left - offsetX) / scale; if (pointCount <= 1) { hoverIndex = 0; return; @@ -109,18 +206,18 @@ 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; + return labelAt(hoverIndex) || 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)) - ); + /** Tooltip position in wrapper-relative %, following the letterboxed plot. */ + const tooltipLeft = $derived.by(() => { + if (hoverIndex === null || box.width === 0) return 50; + const px = box.left + xAt(hoverIndex) * box.scale; + const pctLeft = (px / box.width) * 100; + return Math.min(88, Math.max(12, pctLeft)); + }); function formatValue(value: number | null): string { if (value === null) return '—'; @@ -138,6 +235,7 @@ onpointercancel={clearHover} > + {#if band && bandPath} + + {/if} + {#each paths as s (s.name)} {#each s.segments as d} {firstLabel} + {#if showMidLabel && midLabel} + + {midLabel} + + {/if} + import Container from '$lib/components/Container.svelte'; + import CalendarHeatmap from '$lib/components/charts/CalendarHeatmap.svelte'; + import { formatDateLabel, numOrDash } from '$lib/utils/stats-format'; + import type { CommunityStatsData } from '$lib/types/stats'; + + interface Props { + data: CommunityStatsData; + /** + * `auto` follows the site light/dark theme (public page); `dark` forces + * the dark palette for the always-dark admin console. + */ + tone?: 'auto' | 'dark'; + } + let { data, tone = 'auto' }: Props = $props(); + + const calendar = $derived(data.calendar); + const active = $derived(calendar.filter((d) => d.count > 0)); + + const total = $derived(calendar.reduce((sum, d) => sum + d.count, 0)); + const avgPerDay = $derived(calendar.length ? total / calendar.length : 0); + + const bestDay = $derived( + active.reduce<{ date: string; count: number } | null>( + (best, d) => (best === null || d.count > best.count ? d : best), + null + ) + ); + const worstDay = $derived( + active.reduce<{ date: string; count: number } | null>( + (worst, d) => (worst === null || d.count < worst.count ? d : worst), + null + ) + ); + const medianDay = $derived.by(() => { + if (active.length === 0) return null; + const sorted = [...active].sort((a, b) => a.count - b.count); + return sorted[Math.floor((sorted.length - 1) / 2)]; + }); + + /** Compact enough to sit inside a day square. */ + function compactCount(n: number): string { + if (n >= 10000) return `${Math.round(n / 1000)}k`; + if (n >= 1000) return `${(n / 1000).toFixed(1)}k`; + return String(n); + } + + const annotations = $derived.by(() => { + const out: Record = {}; + for (const day of [bestDay, medianDay, worstDay]) { + if (day) out[day.date] = compactCount(day.count); + } + return out; + }); + + const palette = $derived( + tone === 'dark' + ? { + heading: 'text-gray-100', + sub: 'text-gray-400', + label: 'text-gray-400', + value: 'text-gray-100', + muted: 'text-gray-500' + } + : { + heading: 'text-gray-900 dark:text-gray-100', + sub: 'text-gray-600 dark:text-gray-400', + label: 'text-gray-600 dark:text-gray-400', + value: 'text-gray-900 dark:text-gray-100', + muted: 'text-gray-500 dark:text-gray-400' + } + ); + + +
+
+

Community Stats

+

As of {formatDateLabel(data.asOfDate)}

+
+ +
+ {#each [ + { label: 'All-time plays', value: numOrDash(data.completions) }, + { label: 'All-time players', value: numOrDash(data.players) }, + { label: 'Players this week', value: numOrDash(data.weeklyPlayers) }, + { label: 'Players this month', value: numOrDash(data.monthlyPlayers) }, + { label: "Latest day's plays", value: numOrDash(data.yesterdayCompletions) }, + { label: 'Avg guesses that day', value: numOrDash(data.yesterdayAvgGuesses) } + ] as card (card.label)} + +

{card.label}

+

{card.value}

+
+ {/each} +
+ + +

Daily plays (last {calendar.length} days)

+
+
+ +

+ Numbers mark the best, median, and least popular days. +

+
+ +
+
+
+
{calendar.length}-day plays
+
{numOrDash(total)}
+
+
+
Daily average
+
+ {avgPerDay.toLocaleString('en-US', { maximumFractionDigits: 1 })} +
+
+ {#if bestDay} +
+
Best day ({calendar.length}d)
+
+ {numOrDash(bestDay.count)} + on {formatDateLabel(bestDay.date)} +
+
+ {/if} + {#if data.bestDayAllTime} +
+
Best day · all-time
+
+ {numOrDash(data.bestDayAllTime.count)} + on {formatDateLabel(data.bestDayAllTime.date)} +
+
+ {/if} +
+
+
+
+
diff --git a/src/lib/components/stats/Glossary.svelte b/src/lib/components/stats/Glossary.svelte index 75d646a..f367ff5 100644 --- a/src/lib/components/stats/Glossary.svelte +++ b/src/lib/components/stats/Glossary.svelte @@ -11,6 +11,8 @@ 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: 'Weekly vs monthly players', definition: 'Every completed ISO week (Mon–Sun) or calendar month, up to the last fully closed one. Week buckets count the whole week; month buckets count the whole month.', action: 'A shrinking gap means fewer monthly players play weekly.' }, + { name: 'Resurrected', definition: `Came back after a break of 30+ days (see Settings).`, action: 'Did something bring them back?' }, { 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.' }, diff --git a/src/lib/components/stats/GrowthLoop.svelte b/src/lib/components/stats/GrowthLoop.svelte index 3b34812..add99c6 100644 --- a/src/lib/components/stats/GrowthLoop.svelte +++ b/src/lib/components/stats/GrowthLoop.svelte @@ -10,7 +10,8 @@ } let { payload }: Props = $props(); - const series = $derived(payload.dailySeries.slice(-60)); + // The full 90-day window: 3 months of daily points. + const series = $derived(payload.dailySeries); const hasStarted = $derived(payload.meta.hasStartedData); const signupTotal = $derived(payload.growth.signups.reduce((a, b) => a + b.count, 0)); @@ -20,7 +21,7 @@

New players per day

-

First-time players each day. Hover later for post annotations.

+

First-time players each day, last 3 months. Hover later for post annotations.

({ date: d.date, value: d.newPlayers })) }]} showLegend={false} diff --git a/src/lib/components/stats/Projection.svelte b/src/lib/components/stats/Projection.svelte new file mode 100644 index 0000000..bbd8540 --- /dev/null +++ b/src/lib/components/stats/Projection.svelte @@ -0,0 +1,147 @@ + + +
+
+

Projection · Weekly active users

+
+ {#each methods as m (m.value)} + + {/each} +
+
+ + + {#if chart && projection} +

{activeHint}

+
+ Latest actual: {numOrDash(lastWau)} + + Retention: + {Math.round(projection.retention * 100)}%/wk + + Weekly inflow: {numOrDash(projection.inflow)} + History: {projection.sampleWeeks}w + Horizon: {projection.horizonWeeks}w +
+ + + +

+ Projected WAU in {projectedLast?.label}: + {numOrDash(projectedLast?.value ?? null)} + {#if projectedLast} + + (80% range {numOrDash(projectedLast.low)}–{numOrDash(projectedLast.high)}) + {/if} + · solid = actual, dashed = projection. +

+

+ Models are fitted on all {projection.sampleWeeks} completed weeks. Bands are model uncertainty from + resampling, not real-world coverage; treat long horizons with caution. +

+ {:else} +

Not enough completed weeks of history to project yet.

+ {/if} +
+
diff --git a/src/lib/components/stats/Pulse.svelte b/src/lib/components/stats/Pulse.svelte index 158f1b9..e54e26f 100644 --- a/src/lib/components/stats/Pulse.svelte +++ b/src/lib/components/stats/Pulse.svelte @@ -1,9 +1,17 @@
@@ -100,4 +151,48 @@ caption={`New + returning − churned (${payload.activity.newPlayers} + ${payload.activity.resurrected} − ${payload.activity.churned}).`} /> + +
+
+
+

Weekly vs monthly players

+

+ {#if latestBucket} + Latest completed {granularity === 'week' ? 'week' : 'month'} + ({latestBucket.label}) ended {formatDateLabel(latestBucket.date)}: {numOrDash(latestBucket.wau)} + weekly vs {numOrDash(latestBucket.mau)} monthly + {#if stickiness !== null} + — {stickiness}% stickiness + {/if}. + {:else} + Not enough history yet. + {/if} +

+
+
+ {#each granularityOptions as opt (opt.value)} + + {/each} +
+
+ + b.label)} yMin={0} /> + + {#if stickinessDelta && priorBucket} +

+ {comparisonLabel} {stickinessDelta.text} vs the previous + {granularity === 'week' ? 'week' : 'month'} + ({priorBucket.label}). +

+ {/if} +
diff --git a/src/lib/components/stats/RetentionLab.svelte b/src/lib/components/stats/RetentionLab.svelte index 2141b20..53dfa94 100644 --- a/src/lib/components/stats/RetentionLab.svelte +++ b/src/lib/components/stats/RetentionLab.svelte @@ -13,7 +13,10 @@ let { payload, settings }: Props = $props(); const mix = $derived(payload.retention.segmentMix); - const colors = ['#60a5fa', '#34d399', '#a78bfa', '#f59e0b', '#fb7185']; + // New / Core / Returning / Resurrected (recently returning) / At risk. + // "Churned" is deliberately not plotted: it counts every player idle for + // churn_days+, so it only ever grows and flattens the other series. + const colors = ['#60a5fa', '#34d399', '#a78bfa', '#f59e0b'];
@@ -67,7 +70,12 @@

Segment mix over time

- Active players split into new, core, returning, at-risk and churned. Total tracked: + Players re-segmented on each of the past 30 days, over the last + {settings.active_window_days} days of history. + New = first week. Core = {settings.core_plays_per_week}+ plays this week. Returning = active + again, recently. Resurrected = recently returning after a break of + {settings.resurrect_gap_days}+ days. At risk = {settings.at_risk_days}+ days idle; churned + ({settings.churn_days}+ days idle) is counted in the tiles above instead. Total tracked: {numOrDash(payload.activity.mau)} recently active.

({ 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 })) } + { name: 'Resurrected', color: colors[3], points: mix.map((m) => ({ date: m.date, value: m.resurrected })) }, + { name: 'At risk', color: colors[4], points: mix.map((m) => ({ date: m.date, value: m.atRisk })) } ]} />
diff --git a/src/lib/server/stats/dates.ts b/src/lib/server/stats/dates.ts index e0e0dcf..5990796 100644 --- a/src/lib/server/stats/dates.ts +++ b/src/lib/server/stats/dates.ts @@ -21,6 +21,32 @@ export function startOfWeek(dateStr: string): string { return addDays(dateStr, -day); } +/** The Monday on or before `dateStr` (ISO weeks run Monday–Sunday). */ +export function startOfIsoWeek(dateStr: string): string { + const day = new Date(dateStr + 'T00:00:00Z').getUTCDay(); // 0 = Sunday + return addDays(dateStr, day === 0 ? -6 : 1 - day); +} + +/** + * ISO-8601 week number and week-year for `dateStr`. + * + * ISO weeks start on Monday and week 1 is the week containing the first + * Thursday of the year, so a date in late December can belong to week 1 of the + * following week-year and early January can belong to week 52/53 of the + * previous one. + */ +export function isoWeek(dateStr: string): { week: number; year: number } { + const d = new Date(dateStr + 'T00:00:00Z'); + const day = d.getUTCDay() || 7; // Monday = 1 … Sunday = 7 + // Move to the Thursday of the same ISO week; its calendar year is the + // ISO week-year and the number of weeks since Jan 1 gives the week number. + d.setUTCDate(d.getUTCDate() + 4 - day); + const year = d.getUTCFullYear(); + const yearStart = Date.UTC(year, 0, 1); + const week = Math.ceil(((d.getTime() - yearStart) / DAY_MS + 1) / 7); + return { week, year }; +} + /** All dates from `start` to `end` inclusive. */ export function dateRange(start: string, end: string): string[] { const out: string[] = []; diff --git a/src/lib/server/stats/metrics.ts b/src/lib/server/stats/metrics.ts index f7f282e..41bcd91 100644 --- a/src/lib/server/stats/metrics.ts +++ b/src/lib/server/stats/metrics.ts @@ -1,12 +1,16 @@ import type { + ActivityBucket, + BucketGranularity, BookStat, CohortRow, CompletionRow, HistogramBin, - VerseRow + ProjectionPoint, + VerseRow, + WauProjection } from '$lib/types/stats'; import { bibleBooks } from '$lib/types/bible'; -import { addDays, dateRange, dayDiff, startOfWeek } from './dates'; +import { addDays, dateRange, dayDiff, isoWeek, startOfIsoWeek, startOfWeek } from './dates'; // ── internal helpers ───────────────────────────────────────────────────────── @@ -133,6 +137,446 @@ export function netGrowth( return { newPlayers, resurrected, churned, netGrowth: newPlayers + resurrected - churned }; } +/** Last day of the calendar month containing `dateStr`. */ +function endOfMonth(dateStr: string): string { + const d = new Date(dateStr + 'T00:00:00Z'); + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)) + .toISOString() + .slice(0, 10); +} + +/** First day of the calendar month containing `dateStr`. */ +function startOfMonth(dateStr: string): string { + return `${dateStr.slice(0, 7)}-01`; +} + +/** + * WAU vs MAU bucketed by whole calendar periods, from the first period that + * contains data up to the most recent *completed* one. + * + * Only fully completed periods are emitted, so the in-progress week/month never + * drags the series down. Weeks are ISO (Monday–Sunday); months are calendar + * months. + * + * Each bucket is anchored on its last day: + * - week buckets: `wau` is the whole calendar week, `mau` the 30 days ending it + * - month buckets: `mau` is the whole calendar month, `wau` the 7 days ending it + */ +export function activityBuckets( + completions: CompletionRow[], + asOfDate: string, + granularity: BucketGranularity +): ActivityBucket[] { + let earliest: string | null = null; + for (const c of completions) { + if (earliest === null || c.date < earliest) earliest = c.date; + } + if (earliest === null) return []; + + // Period ends, most recent first while building, then reversed. + const ends: string[] = []; + const starts: string[] = []; + + if (granularity === 'week') { + // The most recent completed ISO week is the one whose Sunday is on or + // before `asOfDate` (a week is complete once its Sunday has closed). + let end = addDays(startOfIsoWeek(asOfDate), 6); + while (end > asOfDate) end = addDays(end, -7); + const firstStart = startOfIsoWeek(earliest); + while (end >= firstStart) { + ends.push(end); + starts.push(addDays(end, -6)); + end = addDays(end, -7); + } + } else { + // The most recent completed month is the one whose last day is on or + // before `asOfDate`; otherwise it is the previous month. + let end = endOfMonth(asOfDate); + if (end > asOfDate) end = endOfMonth(addDays(startOfMonth(asOfDate), -1)); + const firstStart = startOfMonth(earliest); + while (end >= firstStart) { + ends.push(end); + starts.push(startOfMonth(end)); + end = endOfMonth(addDays(startOfMonth(end), -1)); + } + } + ends.reverse(); // oldest first, most recent last + starts.reverse(); + + // Widest (earliest) window start per bucket, used to skip rows that are too + // old. Both arrays are ascending, so a completion touches a contiguous run. + const wauStart = + granularity === 'week' ? starts : ends.map((e) => addDays(e, -6)); + const mauStart = + granularity === 'week' ? ends.map((e) => addDays(e, -29)) : starts; + + // One pass over the rows instead of one pass per bucket. `ends` is + // ascending, so binary-search the first bucket that has not already ended; + // from there a completion touches a contiguous run until the widest window + // starts after it. + const wau = ends.map(() => new Set()); + const mau = ends.map(() => new Set()); + for (const c of completions) { + let lo = 0; + let hi = ends.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (ends[mid] < c.date) lo = mid + 1; + else hi = mid; + } + for (let i = lo; i < ends.length; i++) { + if (c.date < mauStart[i]) break; // too early for this and every later bucket + if (c.date >= wauStart[i]) wau[i].add(c.anonymousId); + mau[i].add(c.anonymousId); + } + } + + const weeks = ends.map((e) => isoWeek(e)); + const months = ends.map((e) => new Date(e + 'T00:00:00Z').getUTCMonth()); + const years = ends.map((e) => new Date(e + 'T00:00:00Z').getUTCFullYear()); + const spansYears = + granularity === 'week' + ? new Set(weeks.map((w) => w.year)).size > 1 + : new Set(years).size > 1; + + return ends.map((date, i) => { + let label: string; + if (granularity === 'week') { + label = `W${weeks[i].week}${ + spansYears ? ` '${String(weeks[i].year).slice(2)}` : '' + }`; + } else { + label = + new Date(date + 'T00:00:00Z').toLocaleDateString('en-US', { + month: 'short', + timeZone: 'UTC' + }) + (spansYears || months[i] === 0 ? ` ${String(years[i]).slice(2)}` : ''); + } + return { date, label, wau: wau[i].size, mau: mau[i].size }; + }); +} + +// ── projection ─────────────────────────────────────────────────────────────── + +export interface WeeklyFlow { + /** Last day (Sunday) of the ISO week. */ + end: string; + wau: number; + /** Players active both this week and the previous one. */ + retained: number; + /** Players active this week but not the previous one (new + resurrected). */ + inflow: number; + /** First-ever players this week. */ + newPlayers: number; + /** Players returning after a gap this week (inflow − newPlayers). */ + resurrected: number; +} + +/** + * Week-over-week flow accounting for every completed ISO week: how much of the + * previous week's active base returned, and how many active players were new to + * the active set. This is the input to the WAU projection. + */ +export function weeklyFlows( + completions: CompletionRow[], + asOfDate: string +): WeeklyFlow[] { + const weeks = activityBuckets(completions, asOfDate, 'week'); + if (weeks.length === 0) return []; + const start0 = addDays(weeks[0].date, -6); + const sets = weeks.map(() => new Set()); + const firsts = firstPlayDates(completions); + const newCounts = weeks.map(() => 0); + for (const c of completions) { + const idx = Math.floor(dayDiff(start0, c.date) / 7); + if (idx >= 0 && idx < sets.length) sets[idx].add(c.anonymousId); + } + for (const first of firsts.values()) { + const idx = Math.floor(dayDiff(start0, first) / 7); + if (idx >= 0 && idx < newCounts.length) newCounts[idx]++; + } + + const out: WeeklyFlow[] = []; + for (let i = 0; i < weeks.length; i++) { + const current = sets[i]; + const previous = i > 0 ? sets[i - 1] : null; + let retained = 0; + if (previous) { + for (const id of current) if (previous.has(id)) retained++; + } + const newPlayers = newCounts[i]; + const inflow = current.size - retained; + out.push({ + end: weeks[i].date, + wau: current.size, + retained, + inflow, + newPlayers, + resurrected: Math.max(0, inflow - newPlayers) + }); + } + return out; +} + +function meanOf(xs: number[]): number { + return xs.length ? xs.reduce((sum, x) => sum + x, 0) / xs.length : 0; +} + +/** Ordinary least squares with a tiny ridge term for collinear features. */ +function fitOls(X: number[][], y: number[], ridge = 1e-6): number[] | null { + const p = X[0]?.length ?? 0; + if (p === 0 || X.length < p) return null; + const A: number[][] = Array.from({ length: p }, () => Array(p).fill(0)); + const b: number[] = Array(p).fill(0); + for (let i = 0; i < X.length; i++) { + for (let a = 0; a < p; a++) { + b[a] += X[i][a] * y[i]; + for (let c = 0; c <= a; c++) A[a][c] += X[i][a] * X[i][c]; + } + } + for (let a = 0; a < p; a++) { + for (let c = a + 1; c < p; c++) A[a][c] = A[c][a]; + A[a][a] += ridge; + } + return solveLinear(A, b); +} + +/** Gaussian elimination with partial pivoting. Mutates `A`/`b`. */ +function solveLinear(A: number[][], b: number[]): number[] | null { + const n = A.length; + for (let col = 0; col < n; col++) { + let pivot = col; + for (let r = col + 1; r < n; r++) { + if (Math.abs(A[r][col]) > Math.abs(A[pivot][col])) pivot = r; + } + if (Math.abs(A[pivot][col]) < 1e-9) return null; + [A[col], A[pivot]] = [A[pivot], A[col]]; + [b[col], b[pivot]] = [b[pivot], b[col]]; + const d = A[col][col]; + for (let c = col; c < n; c++) A[col][c] /= d; + b[col] /= d; + for (let r = 0; r < n; r++) { + if (r === col) continue; + const f = A[r][col]; + if (f === 0) continue; + for (let c = col; c < n; c++) A[r][c] -= f * A[col][c]; + b[r] -= f * b[col]; + } + } + return b; +} + +function dot(beta: number[], x: number[]): number { + let sum = 0; + for (let i = 0; i < beta.length; i++) sum += beta[i] * x[i]; + return sum; +} + +/** Weeks in a year, for the annual harmonic terms. */ +const SEASONAL_PERIOD = 365.25 / 7; + +/** One annual harmonic (a single sine/cosine pair) keeps the fit stable on <2y. */ +function seasonalFeatures(t: number): number[] { + const w = (2 * Math.PI * t) / SEASONAL_PERIOD; + return [1, t, Math.sin(w), Math.cos(w)]; +} + +/** How quickly extrapolated trend is damped (per projected week). */ +const TREND_DAMPING = 0.85; + +function percentile(sorted: number[], p: number): number { + return sorted[Math.min(sorted.length - 1, Math.round(p * (sorted.length - 1)))]; +} + +function summarize(paths: number[][], horizonWeeks: number) { + const lows: number[] = []; + const highs: number[] = []; + for (let k = 0; k < horizonWeeks; k++) { + const column = paths.map((path) => path[k]).sort((a, b) => a - b); + lows.push(percentile(column, 0.1)); + highs.push(percentile(column, 0.9)); + } + return { lows, highs }; +} + +/** + * Project weekly active users forward using three methods, all fitted over the + * full completed-week history: + * + * - flow: `WAU(t+1) = retention × WAU(t) + inflow`, with a bootstrap band + * - regression: distributed lag on lagged WAU and contemporaneous inflow + * - seasonal: harmonic (annual Fourier) regression with a damped trend + * + * The flow model is the structural guard against the classic linear overshoot: + * retention < 1 makes it converge to `inflow / (1 - retention)`. + */ +export function projectWau( + completions: CompletionRow[], + asOfDate: string, + horizonWeeks = 8 +): WauProjection | null { + const flows = weeklyFlows(completions, asOfDate); + const n = flows.length; + if (n < 3) return null; + + const last = flows[n - 1]; + const SIMS = 1000; + let seed = 0x2f6e2b1; + const rand = () => { + // xorshift32: deterministic and good enough for a UI band. + seed ^= seed << 13; + seed ^= seed >>> 17; + seed ^= seed << 5; + return (seed >>> 0) / 0xffffffff; + }; + + // Recency-weighted flow parameters over the whole history (13-week half-life). + const HALF_LIFE = 13; + let wsum = 0; + let retSum = 0; + let inSum = 0; + let newSum = 0; + let resSum = 0; + const samples: { retention: number; inflow: number }[] = []; + for (let i = 1; i < n; i++) { + const w = Math.pow(0.5, (n - 1 - i) / HALF_LIFE); + const prev = flows[i - 1]; + const ret = prev.wau > 0 ? flows[i].retained / prev.wau : 0; + wsum += w; + retSum += w * ret; + inSum += w * flows[i].inflow; + newSum += w * flows[i].newPlayers; + resSum += w * flows[i].resurrected; + samples.push({ retention: ret, inflow: flows[i].inflow }); + } + const retention = wsum > 0 ? retSum / wsum : 0; + const inflow = wsum > 0 ? inSum / wsum : 0; + const meanNew = wsum > 0 ? newSum / wsum : 0; + const meanRes = wsum > 0 ? resSum / wsum : 0; + + // Future ISO-week dates, with the year only when it actually changes. + const futureDates = Array.from({ length: horizonWeeks }, (_, k) => + addDays(last.end, 7 * (k + 1)) + ); + const lastYear = isoWeek(last.end).year; + const needsYear = futureDates.some((d) => isoWeek(d).year !== lastYear); + const labelFor = (date: string) => { + const { week, year } = isoWeek(date); + return `W${week}${needsYear ? ` '${String(year).slice(2)}` : ''}`; + }; + const toPoints = (values: number[], lows: number[], highs: number[]): ProjectionPoint[] => + values.map((v, k) => { + const value = Math.round(v); + return { + date: futureDates[k], + label: labelFor(futureDates[k]), + value, + low: Math.round(Math.min(lows[k], value)), + high: Math.round(Math.max(highs[k], value)) + }; + }); + + const wauY = flows.map((f) => f.wau); + + // ── 1. Flow model + bootstrap band ────────────────────────────────────── + const flowPoint: number[] = []; + let wau = last.wau; + for (let k = 0; k < horizonWeeks; k++) { + wau = retention * wau + inflow; + flowPoint.push(Math.max(0, wau)); + } + const flowPaths = Array.from({ length: SIMS }, () => { + let value = last.wau; + const path: number[] = []; + for (let k = 0; k < horizonWeeks; k++) { + const picked = samples[Math.floor(rand() * samples.length)]; + value = picked.retention * value + picked.inflow; + path.push(Math.max(0, value)); + } + return path; + }); + + // ── 2. Distributed-lag regression ─────────────────────────────────────── + // Uses contemporaneous new/resurrected inflow, not lagged: WAU(t) is itself + // retained(t) + new(t) + resurrected(t). With lagged inflow (an earlier + // version) the autoregressive coefficient was the only real driver, so the + // projection decayed geometrically toward zero. + const regX: number[][] = []; + const regY: number[] = []; + for (let i = 1; i < n; i++) { + regX.push([1, flows[i - 1].wau, flows[i].newPlayers, flows[i].resurrected]); + regY.push(flows[i].wau); + } + const regBeta = fitOls(regX, regY); + const regResiduals = regBeta ? regY.map((y, i) => y - dot(regBeta, regX[i])) : []; + const runRegression = (sampled: boolean): number[] => { + const path: number[] = []; + let prev = last.wau; + for (let k = 0; k < horizonWeeks; k++) { + let pred = regBeta + ? dot(regBeta, [1, prev, meanNew, meanRes]) + : retention * prev + meanNew + meanRes; + if (sampled && regResiduals.length) { + pred += regResiduals[Math.floor(rand() * regResiduals.length)]; + } + pred = Math.max(0, pred); + path.push(pred); + prev = pred; + } + return path; + }; + const regressionPoint = runRegression(false); + const regressionPaths = Array.from({ length: SIMS }, () => runRegression(true)); + + // ── 3. Seasonality-aware harmonic regression ──────────────────────────── + // A partial year cannot separate growth from seasonality, so only one annual + // harmonic is used and the linear trend is damped during projection. Without + // that, trend and annual cycle double-count the same rise and the forecast + // marches upward. + const seasonX = flows.map((_, i) => seasonalFeatures(i)); + const seasonBeta = fitOls(seasonX, wauY); + const seasonResiduals = seasonBeta ? wauY.map((y, i) => y - dot(seasonBeta, seasonX[i])) : [0]; + const seasonalBase: number[] = []; + let dampedTime = n - 1; + for (let k = 0; k < horizonWeeks; k++) { + dampedTime += Math.pow(TREND_DAMPING, k + 1); + if (seasonBeta) { + const w = (2 * Math.PI * (n + k)) / SEASONAL_PERIOD; + seasonalBase.push( + seasonBeta[0] + + seasonBeta[1] * dampedTime + + seasonBeta[2] * Math.sin(w) + + seasonBeta[3] * Math.cos(w) + ); + } else { + seasonalBase.push(meanOf(wauY)); + } + } + const seasonalPoint = seasonalBase.map((v) => Math.max(0, v)); + const seasonalPaths = Array.from({ length: SIMS }, () => { + const path: number[] = []; + for (let k = 0; k < horizonWeeks; k++) { + const resid = seasonResiduals[Math.floor(rand() * seasonResiduals.length)]; + path.push(Math.max(0, seasonalBase[k] + resid)); + } + return path; + }); + + const flowBand = summarize(flowPaths, horizonWeeks); + const regressionBand = summarize(regressionPaths, horizonWeeks); + const seasonalBand = summarize(seasonalPaths, horizonWeeks); + + return { + horizonWeeks, + retention: Math.round(retention * 1000) / 1000, + inflow: Math.round(inflow), + sampleWeeks: n, + flow: toPoints(flowPoint, flowBand.lows, flowBand.highs), + regression: toPoints(regressionPoint, regressionBand.lows, regressionBand.highs), + seasonal: toPoints(seasonalPoint, seasonalBand.lows, seasonalBand.highs) + }; +} + // ── retention ──────────────────────────────────────────────────────────────── /** diff --git a/src/lib/server/stats/snapshot.ts b/src/lib/server/stats/snapshot.ts index a75195e..dcaa6d6 100644 --- a/src/lib/server/stats/snapshot.ts +++ b/src/lib/server/stats/snapshot.ts @@ -27,6 +27,7 @@ import { deriveSegments, countSegments } from './segments'; import { evaluateSignals } from './signals'; import { activePlayers, + activityBuckets, averageGuesses, bookCoverage, cohortTriangle, @@ -39,6 +40,7 @@ import { netGrowth, newVsReturning, overallReturnRate, + projectWau, repeatIntervals, retentionByGuessBucket, streakSurvival, @@ -53,7 +55,7 @@ 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 = 4; +export const PAYLOAD_SCHEMA_VERSION = 9; const RETENTION_WINDOW_DAYS = 180; const SERIES_DAYS = 90; @@ -271,7 +273,12 @@ function buildActivity( churned: growth.churned, netGrowth: growth.netGrowth, corePrevious, - previous + previous, + stickiness: { + week: activityBuckets(pointCompletions, asOfDate, 'week'), + month: activityBuckets(pointCompletions, asOfDate, 'month') + }, + projection: projectWau(pointCompletions, asOfDate) }; } diff --git a/src/lib/types/stats.ts b/src/lib/types/stats.ts index 773dc3c..aa7b1b3 100644 --- a/src/lib/types/stats.ts +++ b/src/lib/types/stats.ts @@ -136,6 +136,60 @@ export interface BookStat { d1Return: number | null; } +/** Calendar period used to bucket the WAU/MAU comparison. */ +export type BucketGranularity = 'week' | 'month'; + +export interface ActivityBucket { + /** Last day of the calendar period, inclusive. */ + date: string; + /** Axis label: "W36 '26" for ISO weeks, or a month name for months. */ + label: string; + /** + * Distinct players in the bucket's weekly window: the whole ISO week for + * week buckets, or the 7 days ending on `date` for month buckets. + */ + wau: number; + /** + * Distinct players in the bucket's monthly window: the 30 days ending on + * `date` for week buckets, or the whole calendar month for month buckets. + */ + mau: number; +} + +/** One future week in a WAU projection. */ +export interface ProjectionPoint { + date: string; + label: string; + /** Point estimate (or p50 for the range view). */ + value: number; + /** p10 lower bound (equals `value` for the point models). */ + low: number; + /** p90 upper bound (equals `value` for the point models). */ + high: number; +} + +/** + * Forward view of weekly active users. Three methods are produced so the UI can + * toggle between them: a flow/retention model (with a bootstrap p10–p90 band), + * a distributed-lag regression, and a seasonality-aware harmonic regression. + */ +export interface WauProjection { + /** Number of future weeks projected. */ + horizonWeeks: number; + /** Recency-weighted week-over-week retention used by the flow model (0..1). */ + retention: number; + /** Recency-weighted weekly inflow: players active who were not active before. */ + inflow: number; + /** Completed weeks of history available to the models. */ + sampleWeeks: number; + /** Flow model with a bootstrap p10–p90 band. */ + flow: ProjectionPoint[]; + /** Distributed-lag regression on lagged WAU and contemporaneous new/resurrected. */ + regression: ProjectionPoint[]; + /** Harmonic (annual Fourier) regression with a damped linear trend. */ + seasonal: ProjectionPoint[]; +} + export interface Activity { dau: number; wau: number; @@ -155,6 +209,13 @@ export interface Activity { mau: number; core: number; } | null; + /** + * WAU vs MAU by completed calendar period, most recent last. Optional so + * payloads stored before this field existed still typecheck. + */ + stickiness?: Record; + /** Forward WAU projection (trend / flow / range). Optional for old payloads. */ + projection?: WauProjection | null; } export interface Kpis { @@ -204,6 +265,19 @@ export interface HabitData { weekdayHour: number[][]; } +/** Safe, all-time aggregates rendered by the public community stats widget. */ +export interface CommunityStatsData { + asOfDate: string; + completions: number; + players: number; + weeklyPlayers: number; + monthlyPlayers: number; + yesterdayCompletions: number; + yesterdayAvgGuesses: number | null; + bestDayAllTime: { date: string; count: number } | null; + calendar: { date: string; count: number }[]; +} + export interface SnapshotPayload { asOfDate: string; activity: Activity; diff --git a/src/routes/admin/stats/+page.svelte b/src/routes/admin/stats/+page.svelte index 5247473..5f4eca9 100644 --- a/src/routes/admin/stats/+page.svelte +++ b/src/routes/admin/stats/+page.svelte @@ -2,19 +2,39 @@ import type { PageData } from './$types'; import ConsoleChrome from '$lib/components/stats/ConsoleChrome.svelte'; import Pulse from '$lib/components/stats/Pulse.svelte'; + import Projection from '$lib/components/stats/Projection.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'; + // VerseMixLab hidden for now. + // 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 CommunityStats from '$lib/components/stats/CommunityStats.svelte'; import Container from '$lib/components/Container.svelte'; import { numOrDash } from '$lib/utils/stats-format'; + import type { CommunityStatsData } from '$lib/types/stats'; let { data }: { data: PageData } = $props(); const segments = $derived(data.segmentCounts); + + // The public community view lives at the top of the console too. The verse + // card is intentionally omitted here. + const community = $derived.by( + (): CommunityStatsData => ({ + asOfDate: data.payload.asOfDate, + completions: data.payload.totals.completions, + players: data.payload.totals.players, + weeklyPlayers: data.payload.activity.wau, + monthlyPlayers: data.payload.activity.mau, + yesterdayCompletions: data.payload.totals.yesterdayCompletions, + yesterdayAvgGuesses: data.payload.totals.yesterdayAvgGuesses, + bestDayAllTime: data.payload.totals.bestDay, + calendar: data.payload.habit.calendar + }) + ); @@ -24,6 +44,8 @@
+ + + +

Segment mix (as of this date)

@@ -66,7 +90,8 @@ - + + diff --git a/src/routes/global/+page.server.ts b/src/routes/global/+page.server.ts index 39c16f0..9efe72b 100644 --- a/src/routes/global/+page.server.ts +++ b/src/routes/global/+page.server.ts @@ -1,6 +1,9 @@ import type { PageServerLoad } from './$types'; import { ensureSnapshot } from '$lib/server/stats/snapshot'; import { latestClosedDate } from '$lib/server/stats/dates'; +import { db } from '$lib/server/db'; +import { dailyVerses } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; /** * Slim public community widget. Reads the latest completed daily snapshot and @@ -10,8 +13,26 @@ export const load: PageServerLoad = async () => { const snapshot = await ensureSnapshot(latestClosedDate()); const payload = snapshot.payload; + // The verse itself is public content (it was served to everyone), so showing + // it here is safe. A missing row or lookup failure just hides the card. + let verse: { reference: string; verseText: string } | null = null; + try { + const [row] = await db + .select({ + reference: dailyVerses.reference, + verseText: dailyVerses.verseText + }) + .from(dailyVerses) + .where(eq(dailyVerses.date, payload.asOfDate)) + .limit(1); + verse = row ?? null; + } catch (err) { + console.error('Failed to load community verse', err); + } + return { asOfDate: payload.asOfDate, + verse: verse ?? null, completions: payload.totals.completions, players: payload.totals.players, weeklyPlayers: payload.activity.wau, @@ -19,6 +40,6 @@ export const load: PageServerLoad = async () => { yesterdayCompletions: payload.totals.yesterdayCompletions, yesterdayAvgGuesses: payload.totals.yesterdayAvgGuesses, bestDayAllTime: payload.totals.bestDay, - calendar: payload.habit.calendar.slice(-90) + calendar: payload.habit.calendar }; -}; \ No newline at end of file +}; diff --git a/src/routes/global/+page.svelte b/src/routes/global/+page.svelte index 555e960..574096a 100644 --- a/src/routes/global/+page.svelte +++ b/src/routes/global/+page.svelte @@ -1,132 +1,34 @@ - Community Stats | Bibdle - + Community Stats | BIBDLE +
-
-

Community Stats

-

As of {formatDateLabel(data.asOfDate)}

-
+ -
- -

All-time plays

-

{numOrDash(data.completions)}

-
- -

All-time players

-

{numOrDash(data.players)}

-
- -

Players this week

-

{numOrDash(data.weeklyPlayers)}

-
- -

Players this month

-

{numOrDash(data.monthlyPlayers)}

-
- -

Latest day's plays

-

{numOrDash(data.yesterdayCompletions)}

-
- -

Avg guesses that day

-

{numOrDash(data.yesterdayAvgGuesses)}

-
-
- - -

Daily plays (last {calendar.length} days)

-
-
- -

- Numbers mark the best, median, and least popular days. -

-
- -
-
-
-
{calendar.length}-day plays
-
{numOrDash(total)}
-
-
-
Daily average
-
- {avgPerDay.toLocaleString('en-US', { maximumFractionDigits: 1 })} -
-
- {#if bestDay} -
-
Best day ({calendar.length}d)
-
- {numOrDash(bestDay.count)} - on {formatDateLabel(bestDay.date)} -
-
- {/if} - {#if data.bestDayAllTime} -
-
Best day · all-time
-
- {numOrDash(data.bestDayAllTime.count)} - on {formatDateLabel(data.bestDayAllTime.date)} -
-
- {/if} -
-
-
+ +

Latest verse

+ {#if data.verse} +
+ “{data.verse.verseText}” + + — {data.verse.reference} + +
+ {:else} +

+ Verse text unavailable for {formatDateLabel(data.asOfDate)}. +

+ {/if}

diff --git a/tests/stats-metrics.test.ts b/tests/stats-metrics.test.ts index 914342e..3a051a8 100644 --- a/tests/stats-metrics.test.ts +++ b/tests/stats-metrics.test.ts @@ -17,7 +17,10 @@ import { retentionByGuessBucket, weekdayHourHeatmap, cohortTriangle, - dailyCompletionsSeries + dailyCompletionsSeries, + activityBuckets, + weeklyFlows, + projectWau } from '$lib/server/stats/metrics'; import type { CompletionRow } from '$lib/types/stats'; @@ -191,3 +194,189 @@ describe('dailyCompletionsSeries', () => { expect(series[2]).toEqual({ date: '2026-09-03', completions: 2, newPlayers: 0 }); }); }); + +describe('activityBuckets', () => { + // 2026-09-10 is a Thursday, so its ISO week (Mon 09-07 .. Sun 09-13) is + // still in progress: the most recent completed week ends 2026-09-06 (W36). + const asOf = '2026-09-10'; + + test('weeks are ISO Monday–Sunday and stop at the last completed week', () => { + const rows: CompletionRow[] = [ + c('mon', '2026-08-31'), // first day of the week + c('sun', '2026-09-06') // last day of the week + ]; + const weeks = activityBuckets(rows, asOf, 'week'); + expect(weeks).toHaveLength(1); + expect(weeks[0].date).toBe('2026-09-06'); + expect(weeks[0].label).toBe('W36'); + expect(weeks[0].wau).toBe(2); + }); + + test('spans every completed week back to the first week with data', () => { + const rows: CompletionRow[] = [ + c('p1', '2026-08-20'), + c('p2', '2026-08-27'), + c('p3', '2026-09-03') + ]; + const weeks = activityBuckets(rows, asOf, 'week'); + expect(weeks.map((b) => b.date)).toEqual(['2026-08-23', '2026-08-30', '2026-09-06']); + expect(weeks.map((b) => b.label)).toEqual(['W34', 'W35', 'W36']); + expect(weeks.map((b) => b.wau)).toEqual([1, 1, 1]); + // The trailing 30-day window reaches further back, so it grows per bucket. + expect(weeks.map((b) => b.mau)).toEqual([1, 2, 3]); + }); + + test('excludes the in-progress week so a partial period cannot drag the series down', () => { + const rows: CompletionRow[] = [c('current', '2026-09-08'), c('done', '2026-09-04')]; + const weeks = activityBuckets(rows, asOf, 'week'); + expect(weeks).toHaveLength(1); + expect(weeks[0].date).toBe('2026-09-06'); + expect(weeks[0].wau).toBe(1); + }); + + test('monthly buckets are whole calendar months up to the last completed one', () => { + const rows: CompletionRow[] = [ + c('jun', '2026-06-01'), + c('jul', '2026-07-15'), + c('aug', '2026-08-31') + ]; + const months = activityBuckets(rows, asOf, 'month'); + expect(months.map((b) => b.date)).toEqual(['2026-06-30', '2026-07-31', '2026-08-31']); + expect(months.map((b) => b.label)).toEqual(['Jun', 'Jul', 'Aug']); + // A whole calendar month, not a trailing 30-day window: Jun 1 is included. + expect(months[0].mau).toBe(1); + }); + + test('a month ending on the as-of day counts as completed', () => { + const rows: CompletionRow[] = [c('a', '2026-09-01'), c('b', '2026-09-30')]; + const months = activityBuckets(rows, '2026-09-30', 'month'); + expect(months.map((b) => b.date)).toEqual(['2026-09-30']); + expect(months[0].mau).toBe(2); + }); + + test('adds the week-year only when the range spans week-years', () => { + const rows: CompletionRow[] = [c('a', '2025-12-01'), c('b', '2026-01-15')]; + const weeks = activityBuckets(rows, '2026-02-10', 'week'); + expect(weeks[0].label).toBe("W49 '25"); + expect(weeks.at(-1)?.label).toBe("W6 '26"); + }); + + test('adds the year to month labels when the range spans years', () => { + const rows: CompletionRow[] = [c('a', '2025-11-01'), c('b', '2026-01-15')]; + const months = activityBuckets(rows, '2026-02-10', 'month'); + expect(months.map((b) => b.label)).toEqual(['Nov 25', 'Dec 25', 'Jan 26']); + }); + + test('labels January with its year even inside a single-year range', () => { + const rows: CompletionRow[] = [c('a', '2026-01-01'), c('b', '2026-03-15')]; + const months = activityBuckets(rows, '2026-04-15', 'month'); + expect(months.map((b) => b.label)).toEqual(['Jan 26', 'Feb', 'Mar']); + }); + + test('returns nothing when every period with data is still in progress', () => { + const rows: CompletionRow[] = [c('a', '2026-09-08')]; + expect(activityBuckets(rows, asOf, 'month')).toEqual([]); + }); +}); + +describe('weeklyFlows / projectWau', () => { + // Three consecutive completed ISO weeks ending Sun 08-23, 08-30, 09-06. + // p2/p3 return, p4/p5 arrive: 2 retained and 1 inflow each week. + const rows: CompletionRow[] = [ + c('p1', '2026-08-18'), + c('p2', '2026-08-18'), + c('p3', '2026-08-18'), + c('p2', '2026-08-25'), + c('p3', '2026-08-25'), + c('p4', '2026-08-25'), + c('p3', '2026-09-01'), + c('p4', '2026-09-01'), + c('p5', '2026-09-01') + ]; + const asOf = '2026-09-10'; + + test('weeklyFlows accounts for retained vs inflow players', () => { + const flows = weeklyFlows(rows, asOf); + expect(flows.map((f) => f.end)).toEqual(['2026-08-23', '2026-08-30', '2026-09-06']); + expect(flows.map((f) => f.wau)).toEqual([3, 3, 3]); + expect(flows.map((f) => f.retained)).toEqual([0, 2, 2]); + expect(flows.map((f) => f.inflow)).toEqual([3, 1, 1]); + expect(flows.map((f) => f.newPlayers)).toEqual([3, 1, 1]); + expect(flows.map((f) => f.resurrected)).toEqual([0, 0, 0]); + }); + + test('the flow model holds a flat base steady instead of overshooting', () => { + const projection = projectWau(rows, asOf, 4); + expect(projection).not.toBeNull(); + expect(projection?.sampleWeeks).toBe(3); + expect(projection?.retention).toBeCloseTo(0.667, 3); + expect(projection?.inflow).toBe(1); + // retention 2/3 * 3 + 1 = 3 every week. + expect(projection?.flow.map((p) => p.value)).toEqual([3, 3, 3, 3]); + expect(projection?.flow).toHaveLength(4); + expect(projection?.flow[0].label).toBe('W37'); + }); + + test('every model returns a bracketed band', () => { + const projection = projectWau(rows, asOf, 4); + for (const key of ['flow', 'regression', 'seasonal'] as const) { + expect(projection?.[key]).toHaveLength(4); + for (const point of projection?.[key] ?? []) { + expect(point.low).toBeLessThanOrEqual(point.value); + expect(point.value).toBeLessThanOrEqual(point.high); + } + } + }); + + test('fits regression and seasonal models once there is enough history', () => { + const start = new Date('2026-01-05T00:00:00Z'); // Monday + const rows: CompletionRow[] = []; + for (let w = 0; w < 30; w++) { + const weekStart = new Date(start); + weekStart.setUTCDate(weekStart.getUTCDate() + w * 7); + for (let p = 0; p < 3; p++) { + const d = new Date(weekStart); + d.setUTCDate(d.getUTCDate() + ((w + p) % 7)); + rows.push(c(`core-${p}`, d.toISOString().slice(0, 10))); + } + for (let p = 0; p < 2; p++) { + const d = new Date(weekStart); + d.setUTCDate(d.getUTCDate() + ((w + p + 3) % 7)); + rows.push(c(`new-${w}-${p}`, d.toISOString().slice(0, 10))); + } + } + const after = new Date(start); + after.setUTCDate(after.getUTCDate() + 30 * 7); + const projection = projectWau(rows, after.toISOString().slice(0, 10), 4); + expect(projection?.sampleWeeks).toBe(30); + expect(projection?.regression).toHaveLength(4); + expect(projection?.seasonal).toHaveLength(4); + expect(projection?.retention).toBeGreaterThan(0); + expect(projection?.retention).toBeLessThanOrEqual(1); + }); + + test('the corrected regression does not collapse toward zero', () => { + const start = new Date('2026-01-05T00:00:00Z'); // Monday + const rows: CompletionRow[] = []; + for (let w = 0; w < 30; w++) { + const weekStart = new Date(start); + weekStart.setUTCDate(weekStart.getUTCDate() + w * 7); + for (let p = 0; p < 4; p++) { + const d = new Date(weekStart); + d.setUTCDate(d.getUTCDate() + ((w + p) % 7)); + rows.push(c(`core-${p}`, d.toISOString().slice(0, 10))); + } + } + const after = new Date(start); + after.setUTCDate(after.getUTCDate() + 30 * 7); + const projection = projectWau(rows, after.toISOString().slice(0, 10), 4); + const last = projection?.regression.at(-1)?.value ?? 0; + // A stable 4-player base should not decay away. + expect(last).toBeGreaterThanOrEqual(3); + }); + + test('needs at least three completed weeks', () => { + const few = [c('a', '2026-09-01'), c('b', '2026-09-02')]; + expect(projectWau(few, asOf)).toBeNull(); + }); +}); diff --git a/todo.md b/todo.md index 2ef2cd4..187c101 100644 --- a/todo.md +++ b/todo.md @@ -1,14 +1,18 @@ # in progress (admin/stats) -- [ ] WAU/MAU graph -- [ ] new players per day should show 3 months -- [ ] full width graphs need to have mouse tracking correctly -- [ ] re: "segment mix over time": we need a counter for recently returning players - - [ ] (add clarification for past 30 days) maybe just remove "churned" +- [x] WAU/MAU graph +- [x] show full calendar weeks (ISO, Mon-Sun) / months, stopping at the last completed period +- [x] new players per day should show 3 months +- [x] full width graphs need to have mouse tracking correctly +- [x] re: "segment mix over time": we need a counter for recently returning players + - [x] (add clarification for past 30 days) maybe just remove "churned" +- [x] embed the community stats view at the top of the console (minus the verse) +- [x] WAU projection section below Pulse (Flow / Regression / Seasonal, all-history fit, p10–p90 bands) +- [x] hide the verse mix lab completely, for now (community stats) -- [ ] show yesterday's verse in the community stats +- [x] show yesterday's verse in the community stats - [ ] show human readable numbers in the community stats - [ ] fix the "last 30 days" github style view to be full-width