Lifts the original v4.0.0 Non-Goal: an optional, manually-entered `saves`
metric through the analytics layer, built location-agnostic (option c) so
UI-brief §9b/M0 relocates the data dir in one place later.
- types: PostMetrics.saves? + Weekly/Monthly summary.totalSaves? (optional);
new RankableMetric type for the always-numeric index-access whitelist
- parser: dedicated parseOptionalCount() — blank/non-numeric/negative -> undefined
("unknown != 0"), genuine 0 kept; saves NOT folded into engagementRate
- reports: totalSaves set only when >=1 post carries saves (backward-compat)
- cli: saves surfaced in import summary + weekly/monthly totals + per-post
- S16-pre: onboarding.md allowed-tools gains Write (closes S15-deferred MAJOR)
- docs (three-doc rule): plugin README boundary + analytics README + root README
+ plugin CLAUDE.md + CHANGELOG; dwell stays explicitly unmeasurable
Independent /trekreview: brief-conformance 0 findings; code-correctness 2 MAJOR
(own lockstep misses) FIXED in-session (parseOptionalCount + edge tests). Gate:
tsc clean, analytics 116/116, lint 74/0/0, hooks 98/98. Within-v4.1.0 refinement
(no surface/count/version change).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
162 lines
5.8 KiB
TypeScript
162 lines
5.8 KiB
TypeScript
import type {
|
|
PostAnalytics,
|
|
Alert,
|
|
RankableMetric,
|
|
} from "../models/types.js";
|
|
import { ALERT_THRESHOLDS } from "../models/types.js";
|
|
import {
|
|
mean,
|
|
deviationsFromMean,
|
|
percentChange,
|
|
} from "./stats.js";
|
|
|
|
/**
|
|
* Analyze posts for spikes and drops based on standard deviation thresholds.
|
|
* For each post, checks if its metric value deviates significantly from the mean.
|
|
* Returns array of alerts sorted by severity (critical first).
|
|
*/
|
|
export function detectAlerts(
|
|
posts: PostAnalytics[],
|
|
metricKey: RankableMetric = "impressions"
|
|
): Alert[] {
|
|
if (posts.length === 0) return [];
|
|
|
|
const alerts: Alert[] = [];
|
|
|
|
// Extract metric values
|
|
const values = posts.map((post) => post.metrics[metricKey]);
|
|
const avg = mean(values);
|
|
|
|
// Check each post for significant deviations
|
|
for (const post of posts) {
|
|
const value = post.metrics[metricKey];
|
|
const deviations = deviationsFromMean(value, values);
|
|
|
|
// Spike detection
|
|
if (deviations > ALERT_THRESHOLDS.spike) {
|
|
alerts.push({
|
|
type: "spike",
|
|
severity: "info",
|
|
metric: metricKey,
|
|
message: `Post "${post.title}" has unusually high ${metricKey}: ${value.toLocaleString()} (${deviations.toFixed(1)} std deviations above mean)`,
|
|
postId: post.id,
|
|
value,
|
|
baseline: avg,
|
|
deviations,
|
|
});
|
|
}
|
|
|
|
// Drop detection
|
|
if (deviations < ALERT_THRESHOLDS.drop) {
|
|
alerts.push({
|
|
type: "drop",
|
|
severity: "warning",
|
|
metric: metricKey,
|
|
message: `Post "${post.title}" has unusually low ${metricKey}: ${value.toLocaleString()} (${Math.abs(deviations).toFixed(1)} std deviations below mean)`,
|
|
postId: post.id,
|
|
value,
|
|
baseline: avg,
|
|
deviations,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Sort by severity: critical > warning > info
|
|
const severityOrder = { critical: 0, warning: 1, info: 2 };
|
|
alerts.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
|
|
|
|
return alerts;
|
|
}
|
|
|
|
/**
|
|
* Compare week-over-week metrics and generate alerts for significant changes.
|
|
* Uses percentChange and ALERT_THRESHOLDS for weekly drops and spikes.
|
|
*/
|
|
export function detectWeeklyAlerts(
|
|
currentWeekMetrics: { impressions: number; engagementRate: number },
|
|
previousWeekMetrics: { impressions: number; engagementRate: number }
|
|
): Alert[] {
|
|
const alerts: Alert[] = [];
|
|
|
|
// Analyze impressions
|
|
const impressionChange = percentChange(
|
|
currentWeekMetrics.impressions,
|
|
previousWeekMetrics.impressions
|
|
);
|
|
|
|
if (impressionChange < ALERT_THRESHOLDS.weeklyDropCritical) {
|
|
alerts.push({
|
|
type: "drop",
|
|
severity: "critical",
|
|
metric: "impressions",
|
|
message: `Critical drop in weekly impressions: ${impressionChange.toFixed(1)}% (from ${previousWeekMetrics.impressions.toLocaleString()} to ${currentWeekMetrics.impressions.toLocaleString()})`,
|
|
value: currentWeekMetrics.impressions,
|
|
baseline: previousWeekMetrics.impressions,
|
|
deviations: impressionChange / 10, // Rough conversion to deviations
|
|
});
|
|
} else if (impressionChange < ALERT_THRESHOLDS.weeklyDropWarning) {
|
|
alerts.push({
|
|
type: "drop",
|
|
severity: "warning",
|
|
metric: "impressions",
|
|
message: `Weekly impressions dropped by ${Math.abs(impressionChange).toFixed(1)}%: from ${previousWeekMetrics.impressions.toLocaleString()} to ${currentWeekMetrics.impressions.toLocaleString()}`,
|
|
value: currentWeekMetrics.impressions,
|
|
baseline: previousWeekMetrics.impressions,
|
|
deviations: impressionChange / 10,
|
|
});
|
|
} else if (impressionChange > ALERT_THRESHOLDS.weeklySpikeInfo) {
|
|
alerts.push({
|
|
type: "spike",
|
|
severity: "info",
|
|
metric: "impressions",
|
|
message: `Strong growth in weekly impressions: +${impressionChange.toFixed(1)}% (from ${previousWeekMetrics.impressions.toLocaleString()} to ${currentWeekMetrics.impressions.toLocaleString()})`,
|
|
value: currentWeekMetrics.impressions,
|
|
baseline: previousWeekMetrics.impressions,
|
|
deviations: impressionChange / 10,
|
|
});
|
|
}
|
|
|
|
// Analyze engagement rate
|
|
const engagementChange = percentChange(
|
|
currentWeekMetrics.engagementRate,
|
|
previousWeekMetrics.engagementRate
|
|
);
|
|
|
|
if (engagementChange < ALERT_THRESHOLDS.weeklyDropCritical) {
|
|
alerts.push({
|
|
type: "drop",
|
|
severity: "critical",
|
|
metric: "engagementRate",
|
|
message: `Critical drop in weekly engagement rate: ${engagementChange.toFixed(1)}% (from ${previousWeekMetrics.engagementRate.toFixed(2)}% to ${currentWeekMetrics.engagementRate.toFixed(2)}%)`,
|
|
value: currentWeekMetrics.engagementRate,
|
|
baseline: previousWeekMetrics.engagementRate,
|
|
deviations: engagementChange / 10,
|
|
});
|
|
} else if (engagementChange < ALERT_THRESHOLDS.weeklyDropWarning) {
|
|
alerts.push({
|
|
type: "drop",
|
|
severity: "warning",
|
|
metric: "engagementRate",
|
|
message: `Weekly engagement rate dropped by ${Math.abs(engagementChange).toFixed(1)}%: from ${previousWeekMetrics.engagementRate.toFixed(2)}% to ${currentWeekMetrics.engagementRate.toFixed(2)}%`,
|
|
value: currentWeekMetrics.engagementRate,
|
|
baseline: previousWeekMetrics.engagementRate,
|
|
deviations: engagementChange / 10,
|
|
});
|
|
} else if (engagementChange > ALERT_THRESHOLDS.weeklySpikeInfo) {
|
|
alerts.push({
|
|
type: "spike",
|
|
severity: "info",
|
|
metric: "engagementRate",
|
|
message: `Strong growth in weekly engagement rate: +${engagementChange.toFixed(1)}% (from ${previousWeekMetrics.engagementRate.toFixed(2)}% to ${currentWeekMetrics.engagementRate.toFixed(2)}%)`,
|
|
value: currentWeekMetrics.engagementRate,
|
|
baseline: previousWeekMetrics.engagementRate,
|
|
deviations: engagementChange / 10,
|
|
});
|
|
}
|
|
|
|
// Sort by severity: critical > warning > info
|
|
const severityOrder = { critical: 0, warning: 1, info: 2 };
|
|
alerts.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
|
|
|
|
return alerts;
|
|
}
|