mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-10-22 05:54:10 -04:00
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.
211 lines
7.7 KiB
TypeScript
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);
|
|
});
|
|
});
|