first pass analytics redo

This commit is contained in:
George Powell committed 2026-09-22 12:15:56 -04:00
1 parent b656fd04c0
commit 45ffa39f2a
69 files changed
+6400 -1138

No files matched your search

+141
View File
@@ -0,0 +1,141 @@
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,
PAYLOAD_SCHEMA_VERSION,
type LoadedData
} from '$lib/server/stats/snapshot';
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('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);
});
});