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.
141 lines
4.5 KiB
TypeScript
141 lines
4.5 KiB
TypeScript
import { describe, test, expect } from 'bun:test';
|
|
import { evaluateSignals, detectAnomaly, sortSignals } from '$lib/server/stats/signals';
|
|
import { SETTINGS_DEFAULTS, type SnapshotPayload } from '$lib/types/stats';
|
|
|
|
function makePayload(overrides: {
|
|
kpis?: Partial<SnapshotPayload['kpis']>;
|
|
activity?: Partial<SnapshotPayload['activity']>;
|
|
dailySeries?: SnapshotPayload['dailySeries'];
|
|
} = {}): SnapshotPayload {
|
|
return {
|
|
asOfDate: '2026-09-14',
|
|
activity: {
|
|
dau: 100,
|
|
wau: 300,
|
|
mau: 800,
|
|
core: 50,
|
|
returningShare: 40,
|
|
newPlayers: 20,
|
|
resurrected: 5,
|
|
churned: 18,
|
|
netGrowth: 7,
|
|
corePrevious: 45,
|
|
previous: { dau: 95, wau: 290, mau: 790, core: 45 },
|
|
...overrides.activity
|
|
},
|
|
dailySeries: overrides.dailySeries ?? [],
|
|
kpis: {
|
|
d1Return: 25,
|
|
return7: 30,
|
|
overallReturnRate: 22,
|
|
completionRate: 80,
|
|
shareRate: 30,
|
|
firstTryRate: 25,
|
|
avgGuesses: 4.1,
|
|
...overrides.kpis
|
|
},
|
|
retention: {
|
|
cohorts: [],
|
|
curves: { d1: [], d7: [], d30: [] },
|
|
timeToSecondPlay: [],
|
|
segmentMix: []
|
|
},
|
|
growth: { lifecycleFunnel: [], signups: [] },
|
|
totals: { completions: 0, players: 0, yesterdayCompletions: 0, yesterdayAvgGuesses: null, bestDay: null },
|
|
verseMix: {
|
|
coverage: [],
|
|
testamentMix: [],
|
|
entropy: [],
|
|
repeatIntervals: [],
|
|
firstTryByBook: [],
|
|
retentionByBook: [],
|
|
retentionByGuessBucket: [],
|
|
achievability: []
|
|
},
|
|
habit: { streakSurvival: [], streakLengths: [], calendar: [], weekdayHour: [] },
|
|
signals: [],
|
|
meta: { hasStartedData: true, hasShareData: true, firstEventDate: null }
|
|
};
|
|
}
|
|
|
|
const settings = { ...SETTINGS_DEFAULTS };
|
|
|
|
describe('evaluateSignals', () => {
|
|
test('all-good payload yields no warn/bad signals', () => {
|
|
const signals = evaluateSignals(makePayload(), settings);
|
|
expect(signals.some((s) => s.severity === 'bad' || s.severity === 'warn')).toBe(false);
|
|
});
|
|
|
|
test('low D1 return is bad and recommends an action', () => {
|
|
const signals = evaluateSignals(makePayload({ kpis: { d1Return: 9 } }), settings);
|
|
const d1 = signals.find((s) => s.id === 'd1_return')!;
|
|
expect(d1.severity).toBe('bad');
|
|
expect(d1.action).toBeTruthy();
|
|
});
|
|
|
|
test('missed-but-not-red targets are warnings', () => {
|
|
const signals = evaluateSignals(makePayload({ kpis: { d1Return: 16 } }), settings);
|
|
expect(signals.find((s) => s.id === 'd1_return')!.severity).toBe('warn');
|
|
});
|
|
|
|
test('first-try rate outside the band is flagged', () => {
|
|
const tooEasy = evaluateSignals(makePayload({ kpis: { firstTryRate: 60 } }), settings);
|
|
expect(tooEasy.find((s) => s.id === 'first_try')!.severity).toBe('bad');
|
|
const tooHard = evaluateSignals(makePayload({ kpis: { firstTryRate: 5 } }), settings);
|
|
expect(tooHard.find((s) => s.id === 'first_try')!.severity).toBe('bad');
|
|
});
|
|
|
|
test('missing metrics surface as info, not failure', () => {
|
|
const signals = evaluateSignals(
|
|
makePayload({ kpis: { completionRate: null, shareRate: null } }),
|
|
settings
|
|
);
|
|
expect(signals.find((s) => s.id === 'completion')!.severity).toBe('info');
|
|
expect(signals.find((s) => s.id === 'share_rate')!.severity).toBe('info');
|
|
});
|
|
|
|
test('declining core players is a warning', () => {
|
|
const signals = evaluateSignals(
|
|
makePayload({ activity: { core: 30, corePrevious: 50 } }),
|
|
settings
|
|
);
|
|
expect(signals.find((s) => s.id === 'core_players')!.severity).toBe('warn');
|
|
});
|
|
|
|
test('sortSignals puts bad first', () => {
|
|
const signals = sortSignals(evaluateSignals(makePayload({ kpis: { d1Return: 5 } }), settings));
|
|
expect(signals[0].severity).toBe('bad');
|
|
});
|
|
});
|
|
|
|
describe('detectAnomaly', () => {
|
|
function series(values: number[], startWeekdayOffset = 0) {
|
|
// Build consecutive days starting 2026-08-01 so weekdays repeat every 7.
|
|
const out = values.map((value, i) => {
|
|
const d = new Date('2026-08-01T00:00:00Z');
|
|
d.setUTCDate(d.getUTCDate() + i);
|
|
return { date: d.toISOString().slice(0, 10), value };
|
|
});
|
|
void startWeekdayOffset;
|
|
return out;
|
|
}
|
|
|
|
test('flags a large weekday-aware spike', () => {
|
|
// 5 weeks of varying same-weekday values, then a spike.
|
|
const base: number[] = [];
|
|
for (let i = 0; i < 35; i++) base.push(100 + ((i * 13) % 17));
|
|
const spike = [...base, 500];
|
|
const anomaly = detectAnomaly(series(spike), 2);
|
|
expect(anomaly).not.toBeNull();
|
|
expect(anomaly!.value).toBe(500);
|
|
});
|
|
|
|
test('returns null for a flat series', () => {
|
|
expect(detectAnomaly(series(Array(35).fill(100)), 2)).toBeNull();
|
|
});
|
|
|
|
test('returns null without enough history', () => {
|
|
expect(detectAnomaly(series([1, 2, 3]), 2)).toBeNull();
|
|
});
|
|
});
|