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,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;
|
||||
}
|
||||
Reference in new issue
Block a user