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.
This commit is contained in:
George Powell committed 2026-09-30 17:00:48 -04:00
1 parent 4d5893ce14
commit f9aded56f8
16 files changed
+1363 -162

No files matched your search

@@ -0,0 +1,141 @@
<script lang="ts">
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<string, string> = {};
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'
}
);
</script>
<div>
<div class="mb-6 text-center">
<h1 class="text-3xl font-bold {palette.heading}">Community Stats</h1>
<p class="mt-1 text-sm {palette.sub}">As of {formatDateLabel(data.asOfDate)}</p>
</div>
<div class="grid grid-cols-2 gap-3 md:grid-cols-3">
{#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)}
<Container class="p-4 w-full items-start text-left">
<p class="text-[11px] font-semibold uppercase tracking-wider {palette.label}">{card.label}</p>
<p class="text-2xl font-bold {palette.value}">{card.value}</p>
</Container>
{/each}
</div>
<Container class="mt-3 p-4 w-full items-stretch text-left gap-3">
<p class="font-semibold {palette.heading}">Daily plays (last {calendar.length} days)</p>
<div class="flex flex-col gap-4 sm:flex-row sm:items-start">
<div class="flex flex-col gap-1">
<CalendarHeatmap data={calendar} cellSize={22} annotations={annotations} />
<p class="text-[10px] {palette.muted}">
Numbers mark the best, median, and least popular days.
</p>
</div>
<div class="flex min-w-0 flex-1 flex-col gap-4">
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div>
<dt class="text-[11px] uppercase tracking-wider {palette.muted}">{calendar.length}-day plays</dt>
<dd class="font-semibold tabular-nums {palette.value}">{numOrDash(total)}</dd>
</div>
<div>
<dt class="text-[11px] uppercase tracking-wider {palette.muted}">Daily average</dt>
<dd class="font-semibold tabular-nums {palette.value}">
{avgPerDay.toLocaleString('en-US', { maximumFractionDigits: 1 })}
</dd>
</div>
{#if bestDay}
<div>
<dt class="text-[11px] uppercase tracking-wider {palette.muted}">Best day ({calendar.length}d)</dt>
<dd class="font-semibold tabular-nums {palette.value}">
{numOrDash(bestDay.count)}
<span class="font-normal {palette.muted}">on {formatDateLabel(bestDay.date)}</span>
</dd>
</div>
{/if}
{#if data.bestDayAllTime}
<div>
<dt class="text-[11px] uppercase tracking-wider {palette.muted}">Best day · all-time</dt>
<dd class="font-semibold tabular-nums {palette.value}">
{numOrDash(data.bestDayAllTime.count)}
<span class="font-normal {palette.muted}">on {formatDateLabel(data.bestDayAllTime.date)}</span>
</dd>
</div>
{/if}
</dl>
</div>
</div>
</Container>
</div>
+2
View File
@@ -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.' },
+3 -2
View File
@@ -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));
</script>
@@ -20,7 +21,7 @@
<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>
<p class="text-xs text-gray-400">First-time players each day, last 3 months. 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}
+147
View File
@@ -0,0 +1,147 @@
<script lang="ts">
import Container from '$lib/components/Container.svelte';
import MiniLineChart from '$lib/components/charts/MiniLineChart.svelte';
import type { ProjectionPoint, SnapshotPayload } from '$lib/types/stats';
import { numOrDash } from '$lib/utils/stats-format';
interface Props {
payload: SnapshotPayload;
}
let { payload }: Props = $props();
type Method = 'flow' | 'regression' | 'seasonal';
let method: Method = $state('flow');
/** Only the most recent stretch of history is plotted; the models use all of it. */
const HISTORY_WEEKS = 52;
const weeks = $derived(payload.activity.stickiness?.week ?? []);
const projection = $derived(payload.activity.projection ?? null);
const methods: { value: Method; label: string; hint: string }[] = [
{
value: 'flow',
label: 'Flow',
hint: 'Retention × current base + new/returning inflow, fitted over all weeks. Converges to a saturation level; shaded band is a p10–p90 bootstrap.'
},
{
value: 'regression',
label: 'Regression',
hint: 'OLS on lagged WAU and this week’s new/resurrected inflow. Band from residual bootstrap.'
},
{
value: 'seasonal',
label: 'Seasonal',
hint: 'One annual harmonic plus a damped linear trend. Meaningful once there is close to a full year of history; band from residual bootstrap.'
}
];
const chart = $derived.by(() => {
if (!projection || weeks.length === 0) return null;
const points: ProjectionPoint[] = projection[method];
if (points.length === 0) return null;
const recent = weeks.slice(-HISTORY_WEEKS);
const n = recent.length;
const total = n + points.length;
const actual: (number | null)[] = Array(total).fill(null);
const projected: (number | null)[] = Array(total).fill(null);
const lower: (number | null)[] = Array(total).fill(null);
const upper: (number | null)[] = Array(total).fill(null);
const labels: string[] = Array(total).fill('');
const dates: string[] = Array(total).fill('');
for (let i = 0; i < n; i++) {
actual[i] = recent[i].wau;
labels[i] = recent[i].label;
dates[i] = recent[i].date;
}
// Anchor the projection to the last actual point so the lines connect.
projected[n - 1] = recent[n - 1].wau;
lower[n - 1] = recent[n - 1].wau;
upper[n - 1] = recent[n - 1].wau;
for (let k = 0; k < points.length; k++) {
const idx = n + k;
projected[idx] = points[k].value;
lower[idx] = points[k].low;
upper[idx] = points[k].high;
labels[idx] = points[k].label;
dates[idx] = points[k].date;
}
const toLine = (values: (number | null)[]) =>
values.map((value, i) => ({ date: dates[i], value }));
return {
series: [
{ name: 'Actual WAU', color: '#60a5fa', points: toLine(actual) },
{ name: 'Projected', color: '#a78bfa', dash: true, points: toLine(projected) }
],
band: { lower, upper, color: '#a78bfa' },
xLabels: labels
};
});
const activeHint = $derived(methods.find((m) => m.value === method)?.hint ?? '');
const lastWau = $derived(weeks.at(-1)?.wau ?? null);
const projectedLast = $derived(projection?.[method].at(-1) ?? null);
function toggleClass(value: Method): string {
return method === value
? 'bg-white/15 text-gray-100'
: 'text-gray-400 hover:text-gray-200';
}
</script>
<section class="flex flex-col gap-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-lg font-bold text-gray-100">Projection · Weekly active users</h2>
<div class="flex shrink-0 gap-1 rounded-lg border border-white/10 p-0.5">
{#each methods as m (m.value)}
<button
type="button"
onclick={() => (method = m.value)}
aria-pressed={method === m.value}
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors {toggleClass(m.value)}"
>
{m.label}
</button>
{/each}
</div>
</div>
<Container class="p-4 w-full items-stretch text-left gap-2">
{#if chart && projection}
<p class="text-xs text-gray-400">{activeHint}</p>
<div class="flex flex-wrap gap-x-6 gap-y-1 text-xs text-gray-400">
<span>Latest actual: <span class="font-semibold text-gray-200">{numOrDash(lastWau)}</span></span>
<span>
Retention:
<span class="font-semibold text-gray-200">{Math.round(projection.retention * 100)}%</span>/wk
</span>
<span>Weekly inflow: <span class="font-semibold text-gray-200">{numOrDash(projection.inflow)}</span></span>
<span>History: <span class="font-semibold text-gray-200">{projection.sampleWeeks}w</span></span>
<span>Horizon: <span class="font-semibold text-gray-200">{projection.horizonWeeks}w</span></span>
</div>
<MiniLineChart series={chart.series} xLabels={chart.xLabels} band={chart.band} yMin={0} />
<p class="text-xs text-gray-400">
Projected WAU in {projectedLast?.label}:
<span class="font-semibold text-gray-200">{numOrDash(projectedLast?.value ?? null)}</span>
{#if projectedLast}
<span class="text-gray-500">
(80% range {numOrDash(projectedLast.low)}–{numOrDash(projectedLast.high)})</span
>
{/if}
· solid = actual, dashed = projection.
</p>
<p class="text-[11px] text-gray-500">
Models are fitted on all {projection.sampleWeeks} completed weeks. Bands are model uncertainty from
resampling, not real-world coverage; treat long horizons with caution.
</p>
{:else}
<p class="text-sm text-gray-400">Not enough completed weeks of history to project yet.</p>
{/if}
</Container>
</section>
+96 -1
View File
@@ -1,9 +1,17 @@
<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 MiniLineChart from '$lib/components/charts/MiniLineChart.svelte';
import type {
ActivityBucket,
BucketGranularity,
Signal,
SnapshotPayload,
StatsSettings
} from '$lib/types/stats';
import {
delta,
formatDateLabel,
numOrDash,
pctOrDash,
signed
@@ -30,6 +38,49 @@
? 'bad'
: 'warn'
);
// WAU vs MAU, bucketed by calendar period rather than a rolling window.
let granularity: BucketGranularity = $state('week');
const buckets: ActivityBucket[] = $derived(payload.activity.stickiness?.[granularity] ?? []);
const latestBucket = $derived(buckets.at(-1) ?? null);
const priorBucket = $derived(buckets.at(-2) ?? null);
const stickiness = $derived(
latestBucket && latestBucket.mau > 0
? Math.round((latestBucket.wau / latestBucket.mau) * 100)
: null
);
// In week mode WAU is the headline series; in month mode it is MAU.
const comparisonMetric = $derived(granularity === 'week' ? ('wau' as const) : ('mau' as const));
const comparisonLabel = $derived(granularity === 'week' ? 'Weekly players' : 'Monthly players');
const stickinessDelta = $derived(
latestBucket && priorBucket
? delta(latestBucket[comparisonMetric], priorBucket[comparisonMetric])
: null
);
const granularityOptions: { value: BucketGranularity; label: string }[] = [
{ value: 'week', label: 'Weeks' },
{ value: 'month', label: 'Months' }
];
const bucketSeries = $derived([
{
name: 'Weekly players',
color: '#60a5fa',
points: buckets.map((b) => ({ date: b.date, value: b.wau }))
},
{
name: 'Monthly players',
color: '#a78bfa',
points: buckets.map((b) => ({ date: b.date, value: b.mau }))
}
]);
function toggleClass(value: BucketGranularity): string {
return granularity === value
? 'bg-white/15 text-gray-100'
: 'text-gray-400 hover:text-gray-200';
}
</script>
<section class="flex flex-col gap-3">
@@ -100,4 +151,48 @@
caption={`New + returning − churned (${payload.activity.newPlayers} + ${payload.activity.resurrected} − ${payload.activity.churned}).`}
/>
</div>
<div
class="flex flex-col gap-3 rounded-2xl border border-white/10 bg-white/5 p-3 text-sm text-gray-200"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<p class="font-semibold text-gray-100">Weekly vs monthly players</p>
<p class="text-xs text-gray-400">
{#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}
</p>
</div>
<div class="flex shrink-0 gap-1 rounded-lg border border-white/10 p-0.5">
{#each granularityOptions as opt (opt.value)}
<button
type="button"
onclick={() => (granularity = opt.value)}
aria-pressed={granularity === opt.value}
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors {toggleClass(opt.value)}"
>
{opt.label}
</button>
{/each}
</div>
</div>
<MiniLineChart series={bucketSeries} xLabels={buckets.map((b) => b.label)} yMin={0} />
{#if stickinessDelta && priorBucket}
<p class="text-xs text-gray-400">
{comparisonLabel} {stickinessDelta.text} vs the previous
{granularity === 'week' ? 'week' : 'month'}
({priorBucket.label}).
</p>
{/if}
</div>
</section>
+12 -4
View File
@@ -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'];
</script>
<section class="flex flex-col gap-3">
@@ -67,7 +70,12 @@
<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:
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.
</p>
<MiniLineChart
@@ -75,8 +83,8 @@
{ 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 })) }
{ 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 })) }
]}
/>
</Container>