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.
This commit is contained in:
George Powell committed 2026-09-28 20:21:47 -04:00
1 parent 45ffa39f2a
commit a562ee3cc9
10 files changed
+198 -52

No files matched your search

+21
View File
@@ -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());
}
+89 -33
View File
@@ -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<LoadedData> {
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<string, number>();
let shareEvents = 0;
let firstEventDate: string | null = null;
for (const e of eventRows) {
const eventDate = e.date ?? new Date(e.createdAt).toISOString().slice(0, 10);
if (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<string, number>();
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<StoredSnapshot> {
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. */
+2
View File
@@ -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. */
+6 -7
View File
@@ -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' });
};
+6 -7
View File
@@ -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)
};
};