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

+246
View File
@@ -0,0 +1,246 @@
import type { SeriesPoint, Signal, SignalSeverity, SnapshotPayload, StatsSettings } from '$lib/types/stats';
// ── generic anomaly detection ────────────────────────────────────────────────
export interface Anomaly {
date: string;
value: number;
median: number;
score: number;
}
/**
* Robust, weekday-aware anomaly detection. Compares the latest point with the
* median/MAD of the same weekday over the preceding `lookback` points.
* Returns null when there is not enough history or the series is flat.
*/
export function detectAnomaly(series: SeriesPoint[], threshold: number, lookback = 28): Anomaly | null {
const points = series.filter((p) => p.value !== null) as { date: string; value: number }[];
if (points.length < 5) return null;
const latest = points[points.length - 1];
const weekday = new Date(latest.date + 'T00:00:00Z').getUTCDay();
const history = points
.slice(0, -1)
.filter((p) => new Date(p.date + 'T00:00:00Z').getUTCDay() === weekday)
.slice(-lookback);
if (history.length < 4) return null;
const values = history.map((p) => p.value).sort((a, b) => a - b);
const median = medianOf(values);
const deviations = values.map((v) => Math.abs(v - median)).sort((a, b) => a - b);
const mad = medianOf(deviations);
if (mad === 0) return null;
const score = (0.6745 * (latest.value - median)) / mad;
if (Math.abs(score) < threshold) return null;
return { date: latest.date, value: latest.value, median: Math.round(median * 10) / 10, score: Math.round(score * 10) / 10 };
}
function medianOf(sorted: number[]): number {
if (sorted.length === 0) return 0;
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
}
// ── rule helpers ─────────────────────────────────────────────────────────────
type Direction = 'up' | 'down';
interface TargetRule {
id: string;
title: string;
action: string;
value: number | null;
target: number;
/** At or above target is good when direction is 'up'. */
direction?: Direction;
/** Absolute value that means "red" regardless of the target. */
red: number;
unit?: string;
}
function evaluateTarget(rule: TargetRule): Signal {
const { id, title, action, value, target, red, direction = 'up', unit = '%' } = rule;
if (value === null) {
return {
id,
severity: 'info',
title,
detail: `Not enough data yet to measure this (needs instrumented events).`,
metric: id,
value: null,
target
};
}
const meets = direction === 'up' ? value >= target : value <= target;
const isRed = direction === 'up' ? value < red : value > red;
let severity: SignalSeverity = 'good';
if (!meets) severity = isRed ? 'bad' : 'warn';
const comparison =
direction === 'up'
? value >= target
? `at or above the ${target}% target`
: `below the ${target}% target`
: value <= target
? `at or below the ${target}% target`
: `above the ${target}% target`;
return {
id,
severity,
title,
detail: `${title} is ${value}${unit}, ${comparison}.`,
action: severity === 'good' ? undefined : action,
metric: id,
value,
target
};
}
// ── main engine ──────────────────────────────────────────────────────────────
/**
* Evaluate the explicit targets plus anomaly detection. Pure: the snapshot
* payload carries every number the engine needs.
*/
export function evaluateSignals(payload: SnapshotPayload, settings: StatsSettings): Signal[] {
const { kpis, activity } = payload;
const signals: Signal[] = [];
signals.push(
evaluateTarget({
id: 'd1_return',
title: 'D1 return',
action: 'Test a "come back tomorrow" prompt on the win screen.',
value: kpis.d1Return,
target: settings.d1_target,
red: 12
})
);
signals.push(
evaluateTarget({
id: 'return7',
title: '7-day return',
action: 'Improve the first-session experience or send a day-2 reminder.',
value: kpis.return7,
target: settings.return7_target,
red: 15
})
);
signals.push(
evaluateTarget({
id: 'completion',
title: 'Completion rate',
action: 'Look for friction between the first guess and the win screen.',
value: kpis.completionRate,
target: settings.completion_target,
red: 55
})
);
// First-try rate is a band: both too high and too low are bad.
if (kpis.firstTryRate === null) {
signals.push({
id: 'first_try',
severity: 'info',
title: 'First-try rate',
detail: 'Not enough data yet to measure this.',
metric: 'first_try'
});
} else {
const v = kpis.firstTryRate;
const inBand = v >= settings.first_try_min && v <= settings.first_try_max;
const severe = v > 45 || v < 8;
signals.push({
id: 'first_try',
severity: inBand ? 'good' : severe ? 'bad' : 'warn',
title: 'Verse achievability',
detail: `First-try rate is ${v}% (target band ${settings.first_try_min}–${settings.first_try_max}%).${
v > settings.first_try_max
? ' Verses may be too easy / self-revealing.'
: v < settings.first_try_min
? ' Verses may be too hard.'
: ''
}`,
action: inBand ? undefined : 'Review the verse mix for the easiest / hardest books.',
metric: 'first_try',
value: v,
target: settings.first_try_max
});
}
signals.push(
evaluateTarget({
id: 'share_rate',
title: 'Share rate',
action: 'Make the share action more prominent or reward sharing.',
value: kpis.shareRate,
target: settings.share_target,
red: 15
})
);
// Core players: week-over-week direction.
if (activity.corePrevious === null) {
signals.push({
id: 'core_players',
severity: 'info',
title: 'Core players',
detail: `${activity.core} players finished 4+ times this week. No prior week to compare yet.`,
metric: 'core_players',
value: activity.core
});
} else {
const delta = activity.core - activity.corePrevious;
signals.push({
id: 'core_players',
severity: delta >= 0 ? 'good' : 'warn',
title: 'Core players',
detail: `${activity.core} players finished 4+ times this week (${delta >= 0 ? '+' : ''}${delta} vs last week).`,
action: delta < 0 ? 'Check whether verse difficulty or reminders changed.' : undefined,
metric: 'core_players',
value: activity.core
});
}
// Anomaly detection on the daily completions series.
const anomaly = detectAnomaly(
payload.dailySeries.map((d) => ({ date: d.date, value: d.completions })),
settings.anomaly_mad_threshold
);
if (anomaly) {
const direction = anomaly.score > 0 ? 'above' : 'below';
signals.push({
id: 'completions_anomaly',
severity: anomaly.score > 0 ? 'good' : 'warn',
title: 'Unusual day',
detail: `${anomaly.date} had ${anomaly.value} finishes, well ${direction} the usual ${anomaly.median} for that weekday.`,
action: anomaly.score < 0 ? 'Check for outages or a hard verse.' : undefined,
metric: 'completions',
value: anomaly.value,
target: anomaly.median
});
}
return signals;
}
const SEVERITY_ORDER: Record<SignalSeverity, number> = { bad: 0, warn: 1, info: 2, good: 3 };
export function sortSignals(signals: Signal[]): Signal[] {
return signals
.slice()
.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
}
/** One-line verdict for the Pulse banner. */
export function verdictLine(signals: Signal[]): string {
const top = sortSignals(signals)[0];
if (!top) return 'No signals yet.';
if (top.severity === 'good') return `All core metrics are on target. ${top.detail}`;
if (top.severity === 'info') return top.detail;
return top.detail;
}