From a562ee3cc9a7caa4562738a47823df2258122c85 Mon Sep 17 00:00:00 2001 From: George Powell Date: Mon, 28 Sep 2026 20:21:47 -0400 Subject: [PATCH] fix(stats): finalize snapshots on the verse-day window A verse date is a player's local calendar day, spanning D-1 10:00Z (UTC+14) through D+1 12:00Z (UTC-12). The old UTC end-of-day cutoff both dropped late-night finishes in negative timezones and could persist a partial day. - Scope snapshot loads by the verse date column; use completedBefore only to pin an in-progress point-in-time build. - Gate persistence on isDayClosed (D+1 12:00Z); open days are computed live and never written. - Build the cron snapshot for latestClosedDate instead of EST yesterday, and read the same date for the community page. - Add all-time best single day to snapshot totals. - Bump PAYLOAD_SCHEMA_VERSION to 4 and extend snapshot/window tests. --- README.md | 2 +- src/lib/server/stats/dates.ts | 21 ++++ src/lib/server/stats/snapshot.ts | 122 +++++++++++++++------ src/lib/types/stats.ts | 2 + src/routes/api/cron/build-stats/+server.ts | 13 +-- src/routes/global/+page.server.ts | 13 +-- statistics-plan.md | 4 +- tests/stats-digest.test.ts | 2 +- tests/stats-signals.test.ts | 2 +- tests/stats-snapshot.test.ts | 69 ++++++++++++ 10 files changed, 198 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 1931c38..5727f4f 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ Authenticated users who have solved today's puzzle can submit a verse for schedu | --- | --- | | `POST /api/daily-verse` | Fetch (and cache) the verse for a given `YYYY-MM-DD` date. Also records a deduped `verse_served` event for the supplied `anonymousId` (started→finished funnel). | | `POST /api/events` | Fire-and-forget analytics sink. Allowlisted names: `verse_served`, `share`, `share_copy`, `first_visit`, `donate_click`, `discord_click`; `userId` resolved from the session. Always returns 204. | -| `POST /api/cron/build-stats` | Cron-only (bearer `CRON_SECRET`). Builds yesterday's snapshot (`America/New_York`) and posts the digest to `DISCORD_ADMIN_WEBHOOK`. | +| `POST /api/cron/build-stats` | Cron-only (bearer `CRON_SECRET`). Builds the most recent snapshot whose global data window has closed (`D+1 12:00Z`) and posts the digest to `DISCORD_ADMIN_WEBHOOK`. | | `POST /api/admin/rebuild-stats` | Admin-only. Rebuilds one snapshot: `{ date: 'YYYY-MM-DD' }`. | | `POST /api/submit-completion` | Submit a game result (`anonymousId`, `date`, `guessCount`, `guesses`); returns solve rank, guess rank, total solves, average guesses, ties, and percentile. Unique on `(anonymousId, date)`. | | `GET /api/streak?anonymousId=X&localDate=Y` | Current streak: walks backwards from the client's local date through completed dates, counting consecutive days. Single-day streaks are reported as 0 (minimum displayed streak is 2). | diff --git a/src/lib/server/stats/dates.ts b/src/lib/server/stats/dates.ts index d7b24e3..e0e0dcf 100644 --- a/src/lib/server/stats/dates.ts +++ b/src/lib/server/stats/dates.ts @@ -32,6 +32,27 @@ export function todayUtc(): string { return new Date().toISOString().slice(0, 10); } +// A verse `date` is the player's *local* calendar date, not a fixed UTC window. +// Globally the same verse date is played from D-1 10:00Z (UTC+14) through +// D+1 12:00Z (UTC-12), so a day's data is only final after D+1 12:00Z. +const HOUR_MS = 60 * 60 * 1000; +const VERSE_WINDOW_MS = 36 * HOUR_MS; // D+1 12:00Z, relative to D 00:00Z + +/** Instant after which no further plays for verse `dateStr` are expected. */ +export function endOfVerseWindow(dateStr: string): Date { + return new Date(new Date(dateStr + 'T00:00:00Z').getTime() + VERSE_WINDOW_MS); +} + +/** True once the global data window for `dateStr` has closed. */ +export function isDayClosed(dateStr: string, now: Date = new Date()): boolean { + return now.getTime() >= endOfVerseWindow(dateStr).getTime(); +} + +/** Most recent verse date whose global data window has closed. */ +export function latestClosedDate(now: Date = new Date()): string { + return new Date(now.getTime() - VERSE_WINDOW_MS).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()); } diff --git a/src/lib/server/stats/snapshot.ts b/src/lib/server/stats/snapshot.ts index ca62022..a75195e 100644 --- a/src/lib/server/stats/snapshot.ts +++ b/src/lib/server/stats/snapshot.ts @@ -45,7 +45,7 @@ import { timeToSecondPlayHistogram, weekdayHourHeatmap } from './metrics'; -import { addDays, dateRange, dayDiff, startOfWeek, todayUtc } from './dates'; +import { addDays, dateRange, dayDiff, endOfVerseWindow, isDayClosed, startOfWeek, todayUtc } from './dates'; export type BuildKind = 'scheduled' | 'lazy' | 'reconstructed'; @@ -53,16 +53,12 @@ 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; +export const PAYLOAD_SCHEMA_VERSION = 4; 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; } @@ -76,12 +72,28 @@ export interface LoadedData { signupDates: string[]; } -/** Load all rows needed to build a snapshot as of `cutoff` (inclusive). */ +export interface LoadStatsOptions { + /** + * Point-in-time cutoff for time-stamped rows. When omitted, the full data + * window is loaded and rows are grouped purely by their verse `date`. + */ + completedBefore?: Date; +} + +/** + * Load all rows needed to build a snapshot as of `cutoff` (inclusive). + * + * Rows are scoped by the verse `date` column, not by a UTC 24-hour window: + * a verse date is a player's *local* day, so a single date legitimately spans + * D-1 10:00Z through D+1 12:00Z. `completedBefore` is only for point-in-time + * builds of a day whose window is still open. + */ export async function loadStatsData( cutoff: string, - database: StatsDb = defaultDb + database: StatsDb = defaultDb, + opts: LoadStatsOptions = {} ): Promise { - const cutoffDate = endOfDay(cutoff); + const { completedBefore } = opts; const completionRows = await database .select({ @@ -92,13 +104,23 @@ export async function loadStatsData( completedAt: dailyCompletions.completedAt }) .from(dailyCompletions) - .where(and(lte(dailyCompletions.date, cutoff), lte(dailyCompletions.completedAt, cutoffDate))); + .where( + completedBefore + ? and( + lte(dailyCompletions.date, cutoff), + lte(dailyCompletions.completedAt, completedBefore) + ) + : lte(dailyCompletions.date, cutoff) + ); const verseRows = await database .select({ date: dailyVerses.date, bookId: dailyVerses.bookId }) .from(dailyVerses) .where(lte(dailyVerses.date, cutoff)); + // Events (and signups) are time-stamped rather than date-scoped. For a final + // build the window end is exact; an open-day build pins to `completedBefore`. + const rowBefore = completedBefore ?? endOfVerseWindow(cutoff); const eventRows = await database .select({ name: analyticsEvents.name, @@ -106,18 +128,19 @@ export async function loadStatsData( createdAt: analyticsEvents.createdAt }) .from(analyticsEvents) - .where(lte(analyticsEvents.createdAt, cutoffDate)); + .where(lte(analyticsEvents.createdAt, rowBefore)); const startedByDate = new Map(); 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 (eventDate > cutoff) continue; 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) { + if (e.name === 'share' || e.name === 'share_copy') { shareEvents++; } } @@ -125,7 +148,7 @@ export async function loadStatsData( const userRows = await database .select({ id: user.id, createdAt: user.createdAt }) .from(user) - .where(lte(user.createdAt, cutoffDate)); + .where(lte(user.createdAt, rowBefore)); return { completions: completionRows as CompletionRow[], @@ -521,11 +544,25 @@ export function computePayload( const verseMix = buildVerseMix(pointCompletions, data.verses, asOfDate); const habit = buildHabit(pointCompletions, players, asOfDate); const yesterday = pointCompletions.filter((c) => c.date === asOfDate); + + // All-time best single day (earliest date wins ties). + const completionsByDate = new Map(); + for (const c of pointCompletions) { + completionsByDate.set(c.date, (completionsByDate.get(c.date) ?? 0) + 1); + } + let bestDay: { date: string; count: number } | null = null; + for (const [date, count] of completionsByDate) { + if (!bestDay || count > bestDay.count || (count === bestDay.count && date < bestDay.date)) { + bestDay = { date, count }; + } + } + const totals = { completions: pointCompletions.length, players: firstPlayDates(pointCompletions).size, yesterdayCompletions: yesterday.length, - yesterdayAvgGuesses: averageGuesses(yesterday) + yesterdayAvgGuesses: averageGuesses(yesterday), + bestDay }; const payload: SnapshotPayload = { @@ -589,8 +626,12 @@ export interface BuildResult { } /** - * Build and persist a snapshot. `replace=false` never overwrites an existing - * (immutable) snapshot; the caller re-reads instead. + * Build a snapshot. `replace=false` never overwrites an existing (immutable) + * snapshot; the caller re-reads instead. + * + * A snapshot is only persisted once its global data window has closed: an + * in-progress day is computed as a point-in-time view and never written, so + * partial early-morning data can't become the permanent record. */ export async function buildSnapshot( asOfDate: string, @@ -600,7 +641,10 @@ export async function buildSnapshot( const settings = await getSettings(database); const version = hashSettings(settings); const cutoff = opts.matureThrough ?? asOfDate; - const data = await loadStatsData(cutoff, database); + const closed = isDayClosed(asOfDate); + const data = await loadStatsData(cutoff, database, { + completedBefore: closed ? undefined : new Date() + }); const payload = computePayload(asOfDate, settings, data, { matureThrough: opts.matureThrough }); @@ -620,7 +664,7 @@ export async function buildSnapshot( config: JSON.stringify(settings) }; - if (opts.persist !== false) { + if (opts.persist !== false && closed) { if (opts.replace) { await database .insert(statsSnapshot) @@ -647,28 +691,40 @@ export async function buildSnapshot( } /** - * Return the snapshot for `date`, building it lazily if missing. Race-safe via - * `INSERT OR IGNORE` followed by a re-read. + * Return the snapshot for `date`, building it lazily if missing. + * + * Closed days are persisted and reused (race-safe via `INSERT OR IGNORE`), + * while an in-progress day is always computed on demand and never written. */ export async function ensureSnapshot( date: string, opts: { matureThrough?: string } = {}, database: StatsDb = defaultDb ): Promise { - const existing = await getSnapshot(date, database); - if (existing && existing.payload.meta?.schemaVersion === PAYLOAD_SCHEMA_VERSION) { - return existing; + if (isDayClosed(date)) { + 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 the final view. + 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; } - // 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; + + // Open day: compute a live point-in-time view without persisting it. + return ( + await buildSnapshot( + date, + { kind: 'lazy', matureThrough: opts.matureThrough, persist: false }, + database + ) + ).snapshot; } /** True when a stored snapshot was built with different thresholds or shape. */ diff --git a/src/lib/types/stats.ts b/src/lib/types/stats.ts index 782a9c2..773dc3c 100644 --- a/src/lib/types/stats.ts +++ b/src/lib/types/stats.ts @@ -222,6 +222,8 @@ export interface SnapshotPayload { players: number; yesterdayCompletions: number; yesterdayAvgGuesses: number | null; + /** Highest single-day finish count across all time (earliest date wins ties). */ + bestDay: { date: string; count: number } | null; }; signals: Signal[]; /** Metadata about data availability, so the UI can show honest em-dashes. */ diff --git a/src/routes/api/cron/build-stats/+server.ts b/src/routes/api/cron/build-stats/+server.ts index 4dceef4..9ac6dca 100644 --- a/src/routes/api/cron/build-stats/+server.ts +++ b/src/routes/api/cron/build-stats/+server.ts @@ -1,13 +1,13 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { addDays } from '$lib/server/stats/dates'; +import { latestClosedDate } 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. + * (same pattern as `/api/send-daily-verse`). Builds the most recent date whose + * global data window has closed, then sends the digest. */ export const POST: RequestHandler = async ({ request }) => { const cronSecret = Bun.env.CRON_SECRET; @@ -16,10 +16,9 @@ export const POST: RequestHandler = async ({ request }) => { return json({ error: 'Unauthorized' }, { status: 401 }); } - const todayEst = new Date().toLocaleDateString('en-CA', { timeZone: 'America/New_York' }); - const yesterday = addDays(todayEst, -1); + const date = latestClosedDate(); - const { snapshot } = await buildSnapshot(yesterday, { + const { snapshot } = await buildSnapshot(date, { kind: 'scheduled', replace: true }); @@ -27,5 +26,5 @@ export const POST: RequestHandler = async ({ request }) => { // Digest failure must never fail the build. await sendDigest(snapshot.payload); - return json({ ok: true, date: yesterday, buildKind: 'scheduled' }); + return json({ ok: true, date, buildKind: 'scheduled' }); }; \ No newline at end of file diff --git a/src/routes/global/+page.server.ts b/src/routes/global/+page.server.ts index 88015d4..39c16f0 100644 --- a/src/routes/global/+page.server.ts +++ b/src/routes/global/+page.server.ts @@ -1,15 +1,13 @@ import type { PageServerLoad } from './$types'; import { ensureSnapshot } from '$lib/server/stats/snapshot'; -import { addDays } from '$lib/server/stats/dates'; +import { latestClosedDate } from '$lib/server/stats/dates'; /** - * Slim public community widget. Reads the latest daily snapshot and exposes - * only safe aggregates (no retention, churn, signup or revenue data). + * Slim public community widget. Reads the latest completed daily snapshot and + * exposes only safe aggregates (no retention, churn, signup or revenue data). */ export const load: PageServerLoad = async () => { - const todayEst = new Date().toLocaleDateString('en-CA', { timeZone: 'America/New_York' }); - const yesterday = addDays(todayEst, -1); - const snapshot = await ensureSnapshot(yesterday); + const snapshot = await ensureSnapshot(latestClosedDate()); const payload = snapshot.payload; return { @@ -20,6 +18,7 @@ export const load: PageServerLoad = async () => { monthlyPlayers: payload.activity.mau, yesterdayCompletions: payload.totals.yesterdayCompletions, yesterdayAvgGuesses: payload.totals.yesterdayAvgGuesses, - calendar: payload.habit.calendar.slice(-30) + bestDayAllTime: payload.totals.bestDay, + calendar: payload.habit.calendar.slice(-90) }; }; \ No newline at end of file diff --git a/statistics-plan.md b/statistics-plan.md index 64c9314..3cf53bb 100644 --- a/statistics-plan.md +++ b/statistics-plan.md @@ -220,10 +220,10 @@ Indexes: `(name, date)`, `(anonymous_id, date)`. **Unique** on `(name, anonymous **`user.created_at`** — new timestamp column (see §7). ### 6.2 Snapshot build -- **Trigger:** daily cron hits a protected `POST /api/cron/build-stats` (bearer `CRON_SECRET`, same pattern as `/api/send-daily-verse`). It builds yesterday's snapshot and sends the digest. +- **Trigger:** daily cron hits a protected `POST /api/cron/build-stats` (bearer `CRON_SECRET`, same pattern as `/api/send-daily-verse`). It builds the most recent date whose global data window has closed and sends the digest. - **Lazy fallback:** `/admin/stats` load builds a missing snapshot on demand (guarded against races, marked `lazy`). - **Backfill:** an admin action/script can reconstruct a date range from raw data (marked `reconstructed`). -- **Computation cutoff:** only data with `date <= asOfDate` (and `completedAt` before end of that day) — guarantees point-in-time. +- **Computation cutoff:** data is grouped by the verse `date` (a player's local day), not a UTC 24-hour window. A date is final after `D+1 12:00Z` (UTC-12 has finished the day); until then a build is a non-persisted point-in-time view. Snapshots are only persisted once the window has closed. - **Player state (for render-time config):** per player active in the trailing ~180 days, a compact tuple `[anonId, firstDate, lastDate, totalPlays, plays7, plays30, currentStreak]`. Thresholds applied at render time, so changing them re-segments **all history instantly** while the state stays frozen as of that day. - **Retention/pruning:** keep all snapshots (small); optionally prune `player_state` older than a configurable horizon later. diff --git a/tests/stats-digest.test.ts b/tests/stats-digest.test.ts index 26faf76..330f96e 100644 --- a/tests/stats-digest.test.ts +++ b/tests/stats-digest.test.ts @@ -40,7 +40,7 @@ const payload: SnapshotPayload = { }, habit: { streakSurvival: [], streakLengths: [], calendar: [], weekdayHour: [] }, growth: { lifecycleFunnel: [], signups: [] }, - totals: { completions: 31775, players: 6589, yesterdayCompletions: 102, yesterdayAvgGuesses: 4.1 }, + totals: { completions: 31775, players: 6589, yesterdayCompletions: 102, yesterdayAvgGuesses: 4.1, bestDay: null }, signals: [ { id: 'd1_return', diff --git a/tests/stats-signals.test.ts b/tests/stats-signals.test.ts index 718eff5..7cc6cdb 100644 --- a/tests/stats-signals.test.ts +++ b/tests/stats-signals.test.ts @@ -41,7 +41,7 @@ function makePayload(overrides: { segmentMix: [] }, growth: { lifecycleFunnel: [], signups: [] }, - totals: { completions: 0, players: 0, yesterdayCompletions: 0, yesterdayAvgGuesses: null }, + totals: { completions: 0, players: 0, yesterdayCompletions: 0, yesterdayAvgGuesses: null, bestDay: null }, verseMix: { coverage: [], testamentMix: [], diff --git a/tests/stats-snapshot.test.ts b/tests/stats-snapshot.test.ts index 8ba628f..30759b6 100644 --- a/tests/stats-snapshot.test.ts +++ b/tests/stats-snapshot.test.ts @@ -11,9 +11,11 @@ import { computePayload, computePlayerState, ensureSnapshot, + loadStatsData, PAYLOAD_SCHEMA_VERSION, type LoadedData } from '$lib/server/stats/snapshot'; +import { endOfVerseWindow, isDayClosed, latestClosedDate } from '$lib/server/stats/dates'; import { SETTINGS_DEFAULTS, type CompletionRow } from '$lib/types/stats'; import crypto from 'node:crypto'; @@ -72,6 +74,20 @@ describe('computePayload point-in-time cutoff', () => { expect(payload.kpis.completionRate).not.toBeNull(); }); + test('all-time best day picks the highest count and ignores future days', () => { + const payload = computePayload('2026-09-10', settings, { + ...data, + completions: [ + c('A', '2026-09-01'), + c('A', '2026-09-02'), + c('B', '2026-09-02'), + c('C', '2026-09-02'), + c('D', '2026-09-11') // future relative to asOfDate + ] + }); + expect(payload.totals.bestDay).toEqual({ date: '2026-09-02', count: 3 }); + }); + test('payload has all lab sections', () => { const payload = computePayload('2026-09-10', settings, data); expect(payload.retention.cohorts.length).toBeGreaterThan(0); @@ -138,4 +154,57 @@ describe('snapshot persistence', () => { const snap = await ensureSnapshot('2026-01-10', {}, testDb); expect(snap.payload.meta?.schemaVersion).toBe(PAYLOAD_SCHEMA_VERSION); }); + + test('groups completions by verse date, not a UTC 24h window', async () => { + // A player in a UTC-negative timezone finishing "Jan 10" late at night + // lands on Jan 11 04:00Z. The old UTC end-of-day cutoff dropped them. + await testDb.insert(dailyCompletions).values({ + id: crypto.randomUUID(), + anonymousId: 'late', + date: '2026-01-10', + guessCount: 4, + completedAt: new Date('2026-01-11T04:00:00Z') + }); + const { snapshot } = await buildSnapshot('2026-01-10', { kind: 'lazy' }, testDb); + expect(snapshot.payload.activity.dau).toBe(1); + }); + + test('isDayClosed gates persistence for in-progress days', async () => { + await seed(); + const open = new Date().toISOString().slice(0, 10); + await buildSnapshot(open, { kind: 'lazy' }, testDb); + await buildSnapshot(open, { kind: 'lazy', replace: true }, testDb); + const live = await ensureSnapshot(open, {}, testDb); + expect(live.date).toBe(open); + // None of the open-day builds may leave a persisted row behind. + expect(await testDb.select().from(statsSnapshot)).toHaveLength(0); + }); +}); + +describe('verse day window', () => { + test('closes at D+1 12:00Z (UTC-12 has finished the local day)', () => { + expect(endOfVerseWindow('2026-01-10').toISOString()).toBe('2026-01-11T12:00:00.000Z'); + expect(isDayClosed('2026-01-10', new Date('2026-01-11T11:59:59Z'))).toBe(false); + expect(isDayClosed('2026-01-10', new Date('2026-01-11T12:00:00Z'))).toBe(true); + }); + + test('latestClosedDate reflects the window lag', () => { + expect(latestClosedDate(new Date('2026-01-11T11:00:00Z'))).toBe('2026-01-09'); + expect(latestClosedDate(new Date('2026-01-11T13:00:00Z'))).toBe('2026-01-10'); + }); + + test('completedBefore pins a point-in-time build', async () => { + await testDb.delete(analyticsEvents); + await testDb.delete(dailyCompletions); + await testDb.insert(dailyCompletions).values([ + { id: crypto.randomUUID(), anonymousId: 'early', date: '2026-01-10', guessCount: 1, completedAt: new Date('2026-01-10T05:00:00Z') }, + { id: crypto.randomUUID(), anonymousId: 'late', date: '2026-01-10', guessCount: 2, completedAt: new Date('2026-01-11T04:00:00Z') } + ]); + const pinned = await loadStatsData('2026-01-10', testDb, { + completedBefore: new Date('2026-01-10T12:00:00Z') + }); + expect(pinned.completions.map((c) => c.anonymousId)).toEqual(['early']); + const full = await loadStatsData('2026-01-10', testDb); + expect(full.completions).toHaveLength(2); + }); });