mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-10-22 05:54:10 -04:00
first pass analytics redo
This commit is contained in:
1 parent
b656fd04c0
commit
45ffa39f2a
69 files changed
+6400
-1138
No files matched your search
@@ -0,0 +1,140 @@
|
||||
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 },
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user