Files
bibdle/tests/stats-snapshot.test.ts
George Powell a562ee3cc9 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.
2026-09-28 20:21:47 -04:00

211 lines
7.7 KiB
TypeScript

import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { testDb } from '$lib/server/db/test';
import {
analyticsEvents,
dailyCompletions,
dailyVerses,
statsSnapshot
} from '$lib/server/db/schema';
import {
buildSnapshot,
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';
const settings = { ...SETTINGS_DEFAULTS };
function c(anonymousId: string, date: string): CompletionRow {
return { anonymousId, date, guessCount: 3, guesses: null, completedAt: new Date(date) };
}
describe('computePlayerState', () => {
test('builds streak and rolling play counts as of the date', () => {
const completions: CompletionRow[] = [
c('A', '2026-09-08'),
c('A', '2026-09-09'),
c('A', '2026-09-10'),
c('A', '2026-08-01'),
c('B', '2026-09-10')
];
const state = computePlayerState(completions, '2026-09-10', 180);
const a = state.find((p) => p.id === 'A')!;
expect(a.firstDate).toBe('2026-08-01');
expect(a.lastDate).toBe('2026-09-10');
expect(a.totalPlays).toBe(4);
expect(a.plays7).toBe(3);
expect(a.streak).toBe(3);
});
test('drops players outside the active window', () => {
const state = computePlayerState([c('old', '2026-01-01')], '2026-09-10', 30);
expect(state).toHaveLength(0);
});
});
describe('computePayload point-in-time cutoff', () => {
const data: LoadedData = {
completions: [
c('A', '2026-09-10'),
c('A', '2026-09-11'), // future relative to asOfDate
c('B', '2026-09-11') // future
],
verses: [{ date: '2026-09-10', bookId: 'GEN' }],
startedByDate: new Map([['2026-09-10', 2]]),
shareEvents: 0,
firstEventDate: '2026-09-10',
signupDates: []
};
test('activity only reflects data up to the as-of date', () => {
const payload = computePayload('2026-09-10', settings, data);
expect(payload.activity.dau).toBe(1); // only A, not B (future)
});
test('completion rate uses verse_served events', () => {
const payload = computePayload('2026-09-10', settings, data);
expect(payload.meta.hasStartedData).toBe(true);
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);
expect(payload.verseMix.coverage).toHaveLength(66);
expect(payload.habit.calendar.length).toBe(120);
});
});
describe('snapshot persistence', () => {
beforeEach(async () => {
await testDb.delete(statsSnapshot);
await testDb.delete(analyticsEvents);
await testDb.delete(dailyCompletions);
await testDb.delete(dailyVerses);
});
afterEach(async () => {
await testDb.delete(statsSnapshot);
await testDb.delete(analyticsEvents);
await testDb.delete(dailyCompletions);
await testDb.delete(dailyVerses);
});
async function seed() {
await testDb.insert(dailyCompletions).values([
{ id: crypto.randomUUID(), anonymousId: 'A', date: '2026-01-10', guessCount: 2, completedAt: new Date('2026-01-10T12:00:00Z') },
{ id: crypto.randomUUID(), anonymousId: 'A', date: '2026-01-11', guessCount: 3, completedAt: new Date('2026-01-11T12:00:00Z') },
{ id: crypto.randomUUID(), anonymousId: 'B', date: '2026-01-11', guessCount: 1, completedAt: new Date('2026-01-11T13:00:00Z') }
]);
await testDb.insert(dailyVerses).values([
{ id: crypto.randomUUID(), date: '2026-01-10', bookId: 'GEN', verseText: 'x', reference: 'Genesis 1:1' },
{ id: crypto.randomUUID(), date: '2026-01-11', bookId: 'EXO', verseText: 'y', reference: 'Exodus 1:1' }
]);
}
test('building the same date twice is idempotent', async () => {
await seed();
const first = await buildSnapshot('2026-01-10', { kind: 'lazy' }, testDb);
await buildSnapshot('2026-01-10', { kind: 'lazy' }, testDb);
const rows = await testDb.select().from(statsSnapshot);
expect(rows).toHaveLength(1);
expect(first.snapshot.payload.asOfDate).toBe('2026-01-10');
expect(first.snapshot.payload.activity.dau).toBe(1);
});
test('ensureSnapshot builds when missing and reads when present', async () => {
await seed();
const a = await ensureSnapshot('2026-01-10', {}, testDb);
const b = await ensureSnapshot('2026-01-10', {}, testDb);
expect(a.generatedAt.getTime()).toBe(b.generatedAt.getTime());
expect(a.buildKind).toBe('lazy');
});
test('rebuilds snapshots written with an outdated payload shape', async () => {
await seed();
await testDb.insert(statsSnapshot).values({
date: '2026-01-10',
generatedAt: new Date(),
buildKind: 'lazy',
configVersion: 'outdated',
payload: '{}',
playerState: '[]',
config: '{}'
});
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);
});
});