diff --git a/messages/de.json b/messages/de.json
index 41ebbfe8f..e4cc18cde 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -2387,7 +2387,21 @@
"emptyDescription": "Erfasse Stimmung und diesen Wert an denselben Tagen, um einen Zusammenhang zu sehen."
},
"structuredTagsTitle": "Tag-Auswertung",
- "notesTimelineTitle": "Notizen"
+ "inTargetValue": "{pct} %",
+ "inTargetCaption": "der letzten Tage im guten Stimmungsbereich",
+ "narrative": {
+ "title": "Das fällt auf",
+ "weekdayDip": "Deine Stimmung sinkt am stärksten am {weekday} – {delta} unter deinem Wochenschnitt.",
+ "weekdayPeak": "{weekday} ist durchgehend dein bester Tag mit einem Schnitt von {value}.",
+ "trendUp": "Deine Stimmung steigt über die letzten {days} Tage (+{delta} gegenüber dem Vorzeitraum).",
+ "trendDown": "Deine Stimmung sinkt über die letzten {days} Tage (-{delta} gegenüber dem Vorzeitraum).",
+ "weekendUp": "Am Wochenende geht es dir besser (+{delta} gegenüber Wochentagen).",
+ "weekendDown": "Am Wochenende ist deine Stimmung niedriger (-{delta} gegenüber Wochentagen).",
+ "tagLift": "Tage mit „{tag}“ liegen im Schnitt {delta} über deiner allgemeinen Stimmung.",
+ "tagDrop": "Tage mit „{tag}“ liegen {delta} unter deinem Durchschnitt.",
+ "inTarget": "Du warst an {pct} % der letzten 30 Tage in deinem guten Stimmungsbereich.",
+ "streak": "Du hast deine Stimmung {days} Tage in Folge erfasst – weiter so."
+ }
}
},
"thresholds": {
diff --git a/messages/en.json b/messages/en.json
index b9cd8cf3f..37ad2fcff 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2387,7 +2387,21 @@
"emptyDescription": "Log mood and this metric on the same days to see a correlation."
},
"structuredTagsTitle": "Tag breakdown",
- "notesTimelineTitle": "Notes"
+ "inTargetValue": "{pct}%",
+ "inTargetCaption": "of recent days in your good-mood range",
+ "narrative": {
+ "title": "What stands out",
+ "weekdayDip": "Your mood dips most on {weekday} — {delta} below your week.",
+ "weekdayPeak": "{weekday} is consistently your best day, averaging {value}.",
+ "trendUp": "Your mood is trending up over the last {days} days (+{delta} vs the prior period).",
+ "trendDown": "Your mood is trending down over the last {days} days (-{delta} vs the prior period).",
+ "weekendUp": "You feel better on weekends (+{delta} vs weekdays).",
+ "weekendDown": "Your mood runs lower on weekends (-{delta} vs weekdays).",
+ "tagLift": "Days tagged \"{tag}\" average {delta} above your overall mood.",
+ "tagDrop": "Days tagged \"{tag}\" run {delta} below your average.",
+ "inTarget": "You've been in your good-mood range {pct}% of the last 30 days.",
+ "streak": "You've logged your mood {days} days in a row — keep it up."
+ }
}
},
"thresholds": {
diff --git a/messages/es.json b/messages/es.json
index f3f928fda..0d88c307a 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -2387,7 +2387,21 @@
"emptyDescription": "Registra el ánimo y esta métrica los mismos días para ver una correlación."
},
"structuredTagsTitle": "Desglose de etiquetas",
- "notesTimelineTitle": "Notas"
+ "inTargetValue": "{pct} %",
+ "inTargetCaption": "de los últimos días en tu rango de buen ánimo",
+ "narrative": {
+ "title": "Lo que destaca",
+ "weekdayDip": "Tu ánimo baja más el {weekday}: {delta} por debajo de tu semana.",
+ "weekdayPeak": "El {weekday} es siempre tu mejor día, con una media de {value}.",
+ "trendUp": "Tu ánimo va al alza en los últimos {days} días (+{delta} frente al periodo anterior).",
+ "trendDown": "Tu ánimo va a la baja en los últimos {days} días (-{delta} frente al periodo anterior).",
+ "weekendUp": "Te sientes mejor los fines de semana (+{delta} frente a entre semana).",
+ "weekendDown": "Tu ánimo es más bajo los fines de semana (-{delta} frente a entre semana).",
+ "tagLift": "Los días con la etiqueta «{tag}» promedian {delta} por encima de tu ánimo general.",
+ "tagDrop": "Los días con la etiqueta «{tag}» quedan {delta} por debajo de tu media.",
+ "inTarget": "Has estado en tu rango de buen ánimo el {pct} % de los últimos 30 días.",
+ "streak": "Has registrado tu ánimo {days} días seguidos: sigue así."
+ }
}
},
"thresholds": {
diff --git a/messages/fr.json b/messages/fr.json
index 43b0ed6c6..f2a73b2cb 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -2387,7 +2387,21 @@
"emptyDescription": "Enregistrez l'humeur et cette mesure les mêmes jours pour voir une corrélation."
},
"structuredTagsTitle": "Répartition des étiquettes",
- "notesTimelineTitle": "Notes"
+ "inTargetValue": "{pct} %",
+ "inTargetCaption": "des derniers jours dans votre plage de bonne humeur",
+ "narrative": {
+ "title": "Ce qui ressort",
+ "weekdayDip": "Votre humeur baisse surtout le {weekday} — {delta} sous votre semaine.",
+ "weekdayPeak": "Le {weekday} est toujours votre meilleur jour, avec une moyenne de {value}.",
+ "trendUp": "Votre humeur progresse sur les {days} derniers jours (+{delta} par rapport à la période précédente).",
+ "trendDown": "Votre humeur recule sur les {days} derniers jours (-{delta} par rapport à la période précédente).",
+ "weekendUp": "Vous vous sentez mieux le week-end (+{delta} par rapport à la semaine).",
+ "weekendDown": "Votre humeur est plus basse le week-end (-{delta} par rapport à la semaine).",
+ "tagLift": "Les jours marqués « {tag} » dépassent votre humeur générale de {delta} en moyenne.",
+ "tagDrop": "Les jours marqués « {tag} » sont {delta} sous votre moyenne.",
+ "inTarget": "Vous avez été dans votre plage de bonne humeur {pct} % des 30 derniers jours.",
+ "streak": "Vous avez noté votre humeur {days} jours d'affilée — continuez."
+ }
}
},
"thresholds": {
diff --git a/messages/it.json b/messages/it.json
index be1d03f75..55bb92650 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -2387,7 +2387,21 @@
"emptyDescription": "Registra umore e questa metrica negli stessi giorni per vedere una correlazione."
},
"structuredTagsTitle": "Analisi etichette",
- "notesTimelineTitle": "Note"
+ "inTargetValue": "{pct} %",
+ "inTargetCaption": "degli ultimi giorni nel tuo intervallo di buon umore",
+ "narrative": {
+ "title": "Cosa risalta",
+ "weekdayDip": "Il tuo umore cala di più il {weekday}: {delta} sotto la tua settimana.",
+ "weekdayPeak": "Il {weekday} è sempre il tuo giorno migliore, con una media di {value}.",
+ "trendUp": "Il tuo umore è in crescita negli ultimi {days} giorni (+{delta} rispetto al periodo precedente).",
+ "trendDown": "Il tuo umore è in calo negli ultimi {days} giorni (-{delta} rispetto al periodo precedente).",
+ "weekendUp": "Ti senti meglio nel weekend (+{delta} rispetto ai giorni feriali).",
+ "weekendDown": "Il tuo umore è più basso nel weekend (-{delta} rispetto ai giorni feriali).",
+ "tagLift": "I giorni con l'etichetta «{tag}» superano in media il tuo umore generale di {delta}.",
+ "tagDrop": "I giorni con l'etichetta «{tag}» restano {delta} sotto la tua media.",
+ "inTarget": "Sei stato nel tuo intervallo di buon umore il {pct} % degli ultimi 30 giorni.",
+ "streak": "Hai registrato il tuo umore per {days} giorni di fila — continua così."
+ }
}
},
"thresholds": {
diff --git a/messages/pl.json b/messages/pl.json
index 6e68a6b00..8f2ebfa8d 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -2387,7 +2387,21 @@
"emptyDescription": "Rejestruj nastrój i ten wskaźnik w te same dni, aby zobaczyć korelację."
},
"structuredTagsTitle": "Podział tagów",
- "notesTimelineTitle": "Notatki"
+ "inTargetValue": "{pct}%",
+ "inTargetCaption": "ostatnich dni w zakresie dobrego nastroju",
+ "narrative": {
+ "title": "Co się wyróżnia",
+ "weekdayDip": "Twój nastrój najbardziej spada w {weekday} — {delta} poniżej twojego tygodnia.",
+ "weekdayPeak": "{weekday} to zawsze twój najlepszy dzień, średnio {value}.",
+ "trendUp": "Twój nastrój rośnie w ciągu ostatnich {days} dni (+{delta} względem poprzedniego okresu).",
+ "trendDown": "Twój nastrój spada w ciągu ostatnich {days} dni (-{delta} względem poprzedniego okresu).",
+ "weekendUp": "W weekendy czujesz się lepiej (+{delta} względem dni roboczych).",
+ "weekendDown": "Twój nastrój jest niższy w weekendy (-{delta} względem dni roboczych).",
+ "tagLift": "Dni z tagiem „{tag}” są średnio o {delta} powyżej twojego ogólnego nastroju.",
+ "tagDrop": "Dni z tagiem „{tag}” są o {delta} poniżej twojej średniej.",
+ "inTarget": "Przez {pct}% ostatnich 30 dni byłeś w zakresie dobrego nastroju.",
+ "streak": "Rejestrujesz nastrój od {days} dni z rzędu — tak trzymaj."
+ }
}
},
"thresholds": {
diff --git a/src/components/insights/mood/__tests__/mood-narrative-feed.test.tsx b/src/components/insights/mood/__tests__/mood-narrative-feed.test.tsx
new file mode 100644
index 000000000..f6d7e60fa
--- /dev/null
+++ b/src/components/insights/mood/__tests__/mood-narrative-feed.test.tsx
@@ -0,0 +1,67 @@
+import { describe, it, expect } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import {
+ MoodNarrativeFeed,
+ type MoodNarrativeItem,
+} from "../mood-narrative-feed";
+import { MoodInTargetTile } from "../mood-in-target-tile";
+
+function render(node: React.ReactNode) {
+ return renderToStaticMarkup(
+ {node},
+ );
+}
+
+describe("", () => {
+ it("renders nothing for an empty feed (no platitude placeholders)", () => {
+ expect(render()).toBe("");
+ });
+
+ it("resolves the weekday key inside the sentence", () => {
+ const items: MoodNarrativeItem[] = [
+ {
+ kind: "weekday-dip",
+ messageKey: "insights.mood.narrative.weekdayDip",
+ vars: { weekdayKey: "charts.weekdaysFull.mon", delta: "0.8" },
+ },
+ ];
+ const html = render();
+ expect(html).toContain("Monday");
+ expect(html).toContain("0.8");
+ expect(html).toContain("dips most");
+ expect(html).toContain('data-slot="mood-narrative-feed"');
+ });
+
+ it("renders one row per takeaway in order", () => {
+ const items: MoodNarrativeItem[] = [
+ {
+ kind: "in-target",
+ messageKey: "insights.mood.narrative.inTarget",
+ vars: { pct: "72" },
+ },
+ {
+ kind: "streak",
+ messageKey: "insights.mood.narrative.streak",
+ vars: { days: "5" },
+ },
+ ];
+ const html = render();
+ expect((html.match(/
", () => {
+ it("renders nothing when the percentage is unavailable", () => {
+ expect(render()).toBe("");
+ });
+
+ it("shows the rounded in-target percentage", () => {
+ const html = render();
+ expect(html).toContain("72%");
+ expect(html).toContain("good-mood range");
+ });
+});
diff --git a/src/components/insights/mood/mood-in-target-tile.tsx b/src/components/insights/mood/mood-in-target-tile.tsx
new file mode 100644
index 000000000..6e3241e90
--- /dev/null
+++ b/src/components/insights/mood/mood-in-target-tile.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import { Target } from "lucide-react";
+
+import { Card, CardContent } from "@/components/ui/card";
+import { useTranslations } from "@/lib/i18n/context";
+
+/**
+ * v1.8.6 — in-target headline tile.
+ *
+ * Promotes the already-computed `inTargetPct` (previously only fed to
+ * the LLM snapshot) to a visible stat: the share of recent days that
+ * landed in the good-mood band. Renders nothing when the number is
+ * unavailable (no recent data) so the page degrades gracefully.
+ */
+export function MoodInTargetTile({ pct }: { pct: number | null }) {
+ const { t } = useTranslations();
+
+ if (pct == null) return null;
+
+ const rounded = Math.round(pct);
+
+ return (
+
+
+
-
- {hasNotes && (
-
-
-
- )}
);
}
diff --git a/src/components/insights/mood/mood-narrative-feed.tsx b/src/components/insights/mood/mood-narrative-feed.tsx
new file mode 100644
index 000000000..fd007c356
--- /dev/null
+++ b/src/components/insights/mood/mood-narrative-feed.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+import {
+ ArrowDownRight,
+ ArrowUpRight,
+ CalendarDays,
+ Flame,
+ Tag,
+ Target,
+ TrendingDown,
+ TrendingUp,
+} from "lucide-react";
+import type { ComponentType } from "react";
+
+import { useTranslations } from "@/lib/i18n/context";
+
+/**
+ * v1.8.6 — narrative takeaway feed.
+ *
+ * The "read this first" layer above the mood charts: a ranked, compact
+ * list of plain-language takeaways the server already gated on its
+ * thresholds. The component is a pure renderer — every gating decision
+ * lives in `computeMoodNarratives`, so an empty array renders nothing
+ * (no platitude placeholders). Each row resolves its i18n template with
+ * the server-supplied vars; the weekday key is itself an i18n key so it
+ * localises with the rest of the bundle. Notes render as plain React
+ * text children (no markdown library — XSS posture).
+ */
+
+export interface MoodNarrativeItem {
+ kind: string;
+ messageKey: string;
+ vars: Record;
+}
+
+const ICONS: Record> = {
+ "weekday-dip": TrendingDown,
+ "weekday-peak": CalendarDays,
+ trend: TrendingUp,
+ weekend: CalendarDays,
+ "tag-lift": ArrowUpRight,
+ "tag-drop": ArrowDownRight,
+ "in-target": Target,
+ streak: Flame,
+};
+
+function pickIcon(item: MoodNarrativeItem): ComponentType<{
+ className?: string;
+}> {
+ if (item.kind === "trend") {
+ return item.vars.direction === "down" ? TrendingDown : TrendingUp;
+ }
+ if (item.kind === "weekend") {
+ return item.vars.direction === "down" ? ArrowDownRight : ArrowUpRight;
+ }
+ return ICONS[item.kind] ?? Tag;
+}
+
+export function MoodNarrativeFeed({ items }: { items: MoodNarrativeItem[] }) {
+ const { t } = useTranslations();
+
+ if (items.length === 0) return null;
+
+ return (
+
+ {items.map((item, index) => {
+ const Icon = pickIcon(item);
+ // The weekday key is itself an i18n key — resolve it before
+ // interpolating so the sentence reads in the active locale.
+ const vars: Record = { ...item.vars };
+ if (vars.weekdayKey) vars.weekday = t(vars.weekdayKey);
+ return (
+
+
+
+ {t(item.messageKey, vars)}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/src/lib/insights/__tests__/mood-aggregates.test.ts b/src/lib/insights/__tests__/mood-aggregates.test.ts
index b8408320d..6d8deb75f 100644
--- a/src/lib/insights/__tests__/mood-aggregates.test.ts
+++ b/src/lib/insights/__tests__/mood-aggregates.test.ts
@@ -6,7 +6,6 @@ import {
computeInTargetPct,
computeMoodAggregates,
computeMoodMetricCorrelation,
- computeNotesTimeline,
computeStructuredTagSummary,
computeTagSummary,
computeWeekdayAverages,
@@ -136,24 +135,6 @@ describe("computeStructuredTagSummary", () => {
});
});
-describe("computeNotesTimeline", () => {
- it("returns noted entries newest-first, skips empty notes, and caps at the limit", () => {
- const entries: MoodAggregateEntry[] = [
- { ...entry(5, 2, ["x"]), note: "oldest", mood: "SCHLECHT" },
- { ...entry(3, 4, null), note: " ", mood: "GUT" }, // blank → skipped
- { ...entry(2, 5, null), note: "middle", mood: "SUPER_GUT" },
- { ...entry(1, 3, null), note: "newest", mood: "OKAY" },
- ];
- const timeline = computeNotesTimeline(entries, 2);
- expect(timeline.map((n) => n.note)).toEqual(["newest", "middle"]);
- expect(timeline[0]).toMatchObject({ score: 3, mood: "OKAY", note: "newest" });
- });
-
- it("returns an empty array when no entry carries a note", () => {
- expect(computeNotesTimeline([entry(1, 4, ["x"])])).toEqual([]);
- });
-});
-
describe("computeInTargetPct", () => {
it("returns the share of the newest 30 daily buckets in the green band", () => {
const daily = [
@@ -297,6 +278,14 @@ describe("computeMoodAggregates", () => {
// No sleep / steps rows supplied → empty correlation.
expect(agg.correlations.sleep.n).toBe(0);
expect(agg.correlations.sleep.result).toBeNull();
+ // v1.8.6 — the narrative feed rides on the same aggregates and is
+ // ranked strongest-first.
+ expect(Array.isArray(agg.narratives)).toBe(true);
+ for (let i = 1; i < agg.narratives.length; i++) {
+ expect(agg.narratives[i - 1].strength).toBeGreaterThanOrEqual(
+ agg.narratives[i].strength,
+ );
+ }
});
it("handles an empty entry set without throwing", () => {
diff --git a/src/lib/insights/__tests__/mood-narratives.test.ts b/src/lib/insights/__tests__/mood-narratives.test.ts
new file mode 100644
index 000000000..e41c4b0de
--- /dev/null
+++ b/src/lib/insights/__tests__/mood-narratives.test.ts
@@ -0,0 +1,305 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ MOOD_NARRATIVE_MIN_DAYS,
+ MOOD_NARRATIVE_MIN_EFFECT,
+ MOOD_NARRATIVE_MIN_WEEKDAY_SAMPLES,
+ MOOD_NARRATIVE_MIN_TAG_COUNT,
+ MOOD_NARRATIVE_TREND_WINDOW,
+ MOOD_NARRATIVE_MIN_STREAK,
+ MOOD_NARRATIVE_MAX_ITEMS,
+ computeMoodNarratives,
+ type MoodNarrativeInput,
+} from "../mood-narratives";
+
+const dayMs = 24 * 60 * 60 * 1000;
+/** 2026-06-01 is a Monday in UTC. */
+const NOW = new Date("2026-06-01T12:00:00.000Z");
+
+function daily(values: Array<{ dayOffset: number; value: number }>) {
+ return values;
+}
+
+/** A baseline input that clears nothing — every takeaway silent. */
+function emptyInput(): MoodNarrativeInput {
+ return {
+ daily: [],
+ weekday: [],
+ tags: [],
+ structuredTags: [],
+ inTargetPct: null,
+ loggedDayKeys: [],
+ now: NOW,
+ };
+}
+
+describe("computeMoodNarratives — anti-platitude thresholds", () => {
+ it("returns nothing for an empty input set", () => {
+ expect(computeMoodNarratives(emptyInput())).toEqual([]);
+ });
+
+ it("stays silent on the weekday dip when the effect is below the min", () => {
+ // Mon avg 3.4 vs overall ~3.5 → delta < MIN_EFFECT, no fire.
+ const input: MoodNarrativeInput = {
+ ...emptyInput(),
+ weekday: [
+ { weekday: 0, avgScore: 3.45, count: 5 },
+ { weekday: 1, avgScore: 3.5, count: 5 },
+ { weekday: 2, avgScore: 3.55, count: 5 },
+ ],
+ };
+ const out = computeMoodNarratives(input);
+ expect(out.find((n) => n.kind === "weekday-dip")).toBeUndefined();
+ });
+
+ it("fires the weekday dip only above the effect threshold", () => {
+ const input: MoodNarrativeInput = {
+ ...emptyInput(),
+ weekday: [
+ { weekday: 0, avgScore: 2.5, count: 5 }, // Monday, clearly low
+ { weekday: 1, avgScore: 4.5, count: 5 },
+ { weekday: 2, avgScore: 4.5, count: 5 },
+ ],
+ };
+ const out = computeMoodNarratives(input);
+ const dip = out.find((n) => n.kind === "weekday-dip");
+ expect(dip).toBeDefined();
+ expect(dip?.vars.weekdayKey).toBe("charts.weekdaysFull.mon");
+ // delta is the gap below the weekly mean, a positive magnitude.
+ expect(Number(dip?.vars.delta)).toBeGreaterThanOrEqual(
+ MOOD_NARRATIVE_MIN_EFFECT,
+ );
+ });
+
+ it("stays silent on the weekday dip when no weekday clears the sample gate", () => {
+ const input: MoodNarrativeInput = {
+ ...emptyInput(),
+ weekday: [
+ { weekday: 0, avgScore: 2.5, count: MOOD_NARRATIVE_MIN_WEEKDAY_SAMPLES - 1 },
+ { weekday: 1, avgScore: 4.5, count: MOOD_NARRATIVE_MIN_WEEKDAY_SAMPLES - 1 },
+ ],
+ };
+ expect(
+ computeMoodNarratives(input).find((n) => n.kind === "weekday-dip"),
+ ).toBeUndefined();
+ });
+
+ it("fires the trend takeaway only when the window means differ by the threshold", () => {
+ // Recent window high, prior window low → upward trend.
+ const recent = Array.from({ length: MOOD_NARRATIVE_TREND_WINDOW }, (_, i) => ({
+ dayOffset: i,
+ value: 5,
+ }));
+ const prior = Array.from({ length: MOOD_NARRATIVE_TREND_WINDOW }, (_, i) => ({
+ dayOffset: i + MOOD_NARRATIVE_TREND_WINDOW,
+ value: 2,
+ }));
+ const input: MoodNarrativeInput = {
+ ...emptyInput(),
+ daily: daily([...recent, ...prior]),
+ };
+ const trend = computeMoodNarratives(input).find((n) => n.kind === "trend");
+ expect(trend).toBeDefined();
+ expect(trend?.vars.direction).toBe("up");
+ });
+
+ it("stays silent on the trend when the change is below the effect threshold", () => {
+ const recent = Array.from({ length: MOOD_NARRATIVE_TREND_WINDOW }, (_, i) => ({
+ dayOffset: i,
+ value: 3.5,
+ }));
+ const prior = Array.from({ length: MOOD_NARRATIVE_TREND_WINDOW }, (_, i) => ({
+ dayOffset: i + MOOD_NARRATIVE_TREND_WINDOW,
+ value: 3.55,
+ }));
+ const input: MoodNarrativeInput = {
+ ...emptyInput(),
+ daily: daily([...recent, ...prior]),
+ };
+ expect(
+ computeMoodNarratives(input).find((n) => n.kind === "trend"),
+ ).toBeUndefined();
+ });
+
+ it("stays silent on the trend below the minimum window sample size", () => {
+ const recent = Array.from({ length: MOOD_NARRATIVE_TREND_WINDOW - 1 }, (_, i) => ({
+ dayOffset: i,
+ value: 5,
+ }));
+ const prior = Array.from({ length: MOOD_NARRATIVE_TREND_WINDOW - 1 }, (_, i) => ({
+ dayOffset: i + MOOD_NARRATIVE_TREND_WINDOW,
+ value: 2,
+ }));
+ const input: MoodNarrativeInput = {
+ ...emptyInput(),
+ daily: daily([...recent, ...prior]),
+ };
+ expect(
+ computeMoodNarratives(input).find((n) => n.kind === "trend"),
+ ).toBeUndefined();
+ });
+
+ it("fires a tag-lift takeaway only above the count and effect gates", () => {
+ const input: MoodNarrativeInput = {
+ ...emptyInput(),
+ // overall mean from daily is ~3
+ daily: daily([
+ { dayOffset: 0, value: 3 },
+ { dayOffset: 1, value: 3 },
+ { dayOffset: 2, value: 3 },
+ ]),
+ tags: [
+ { tag: "sport", count: MOOD_NARRATIVE_MIN_TAG_COUNT, avgScore: 4.5 },
+ { tag: "rare", count: MOOD_NARRATIVE_MIN_TAG_COUNT - 1, avgScore: 5 },
+ ],
+ };
+ const out = computeMoodNarratives(input);
+ const lift = out.find((n) => n.kind === "tag-lift");
+ expect(lift?.vars.tag).toBe("sport");
+ // the under-count tag never fires
+ expect(out.some((n) => n.vars.tag === "rare")).toBe(false);
+ });
+
+ it("fires a tag-drop takeaway for tags well below the overall mean", () => {
+ const input: MoodNarrativeInput = {
+ ...emptyInput(),
+ daily: daily([
+ { dayOffset: 0, value: 4 },
+ { dayOffset: 1, value: 4 },
+ { dayOffset: 2, value: 4 },
+ ]),
+ tags: [{ tag: "work", count: MOOD_NARRATIVE_MIN_TAG_COUNT, avgScore: 2 }],
+ };
+ const drop = computeMoodNarratives(input).find((n) => n.kind === "tag-drop");
+ expect(drop?.vars.tag).toBe("work");
+ expect(Number(drop?.vars.delta)).toBeGreaterThanOrEqual(
+ MOOD_NARRATIVE_MIN_EFFECT,
+ );
+ });
+
+ it("stays silent on a tag whose delta is below the effect threshold", () => {
+ const input: MoodNarrativeInput = {
+ ...emptyInput(),
+ daily: daily([
+ { dayOffset: 0, value: 3 },
+ { dayOffset: 1, value: 3 },
+ ]),
+ tags: [{ tag: "neutral", count: 10, avgScore: 3.1 }],
+ };
+ const out = computeMoodNarratives(input);
+ expect(out.some((n) => n.vars.tag === "neutral")).toBe(false);
+ });
+
+ it("surfaces the in-target share once data exists", () => {
+ const input: MoodNarrativeInput = { ...emptyInput(), inTargetPct: 72 };
+ const it = computeMoodNarratives(input).find((n) => n.kind === "in-target");
+ expect(it?.vars.pct).toBe("72");
+ });
+
+ it("stays silent on in-target when there is no recent data", () => {
+ expect(
+ computeMoodNarratives(emptyInput()).find((n) => n.kind === "in-target"),
+ ).toBeUndefined();
+ });
+
+ it("fires the streak takeaway only at or above the minimum run", () => {
+ const keys = (n: number) =>
+ Array.from({ length: n }, (_, i) =>
+ new Date(NOW.getTime() - i * dayMs).toISOString().slice(0, 10),
+ );
+ const below: MoodNarrativeInput = {
+ ...emptyInput(),
+ loggedDayKeys: keys(MOOD_NARRATIVE_MIN_STREAK - 1),
+ };
+ expect(
+ computeMoodNarratives(below).find((n) => n.kind === "streak"),
+ ).toBeUndefined();
+
+ const above: MoodNarrativeInput = {
+ ...emptyInput(),
+ loggedDayKeys: keys(MOOD_NARRATIVE_MIN_STREAK),
+ };
+ const streak = computeMoodNarratives(above).find((n) => n.kind === "streak");
+ expect(streak?.vars.days).toBe(String(MOOD_NARRATIVE_MIN_STREAK));
+ });
+
+ it("breaks the streak on a gap in consecutive days", () => {
+ const today = NOW.toISOString().slice(0, 10);
+ const twoDaysAgo = new Date(NOW.getTime() - 2 * dayMs)
+ .toISOString()
+ .slice(0, 10);
+ const input: MoodNarrativeInput = {
+ ...emptyInput(),
+ // today + a gap → run length 1, below the min.
+ loggedDayKeys: [today, twoDaysAgo],
+ };
+ expect(
+ computeMoodNarratives(input).find((n) => n.kind === "streak"),
+ ).toBeUndefined();
+ });
+
+ it("fires the weekend effect above the effect threshold", () => {
+ // Build a daily series where weekend days (Sat/Sun) score higher.
+ // dayOffset 0 = Mon (NOW). offsets 5,6 = Wed/Tue back; use explicit days.
+ // Sat is offset 2 (Sat May 30), Sun is offset 1 (Sun May 31).
+ const input: MoodNarrativeInput = {
+ ...emptyInput(),
+ daily: daily([
+ { dayOffset: 0, value: 2 }, // Mon
+ { dayOffset: 1, value: 5 }, // Sun
+ { dayOffset: 2, value: 5 }, // Sat
+ { dayOffset: 3, value: 2 }, // Fri
+ { dayOffset: 4, value: 2 }, // Thu
+ { dayOffset: 8, value: 5 }, // Sun prior
+ { dayOffset: 9, value: 5 }, // Sat prior
+ ]),
+ };
+ const weekend = computeMoodNarratives(input).find(
+ (n) => n.kind === "weekend",
+ );
+ expect(weekend).toBeDefined();
+ expect(weekend?.vars.direction).toBe("up");
+ });
+});
+
+describe("computeMoodNarratives — ranking and capping", () => {
+ it("ranks by descending strength and caps the list", () => {
+ // Construct many firing signals; assert ordering by strength + cap.
+ const weekday = [
+ { weekday: 0, avgScore: 1, count: 5 },
+ { weekday: 1, avgScore: 5, count: 5 },
+ { weekday: 2, avgScore: 5, count: 5 },
+ ];
+ const recent = Array.from({ length: MOOD_NARRATIVE_TREND_WINDOW }, (_, i) => ({
+ dayOffset: i,
+ value: 5,
+ }));
+ const prior = Array.from({ length: MOOD_NARRATIVE_TREND_WINDOW }, (_, i) => ({
+ dayOffset: i + MOOD_NARRATIVE_TREND_WINDOW,
+ value: 1,
+ }));
+ const input: MoodNarrativeInput = {
+ daily: [...recent, ...prior],
+ weekday,
+ tags: [
+ { tag: "great", count: 10, avgScore: 5 },
+ { tag: "bad", count: 10, avgScore: 1 },
+ ],
+ structuredTags: [],
+ inTargetPct: 80,
+ loggedDayKeys: Array.from({ length: 10 }, (_, i) =>
+ new Date(NOW.getTime() - i * dayMs).toISOString().slice(0, 10),
+ ),
+ now: NOW,
+ };
+ const out = computeMoodNarratives(input);
+ expect(out.length).toBeLessThanOrEqual(MOOD_NARRATIVE_MAX_ITEMS);
+ for (let i = 1; i < out.length; i++) {
+ expect(out[i - 1].strength).toBeGreaterThanOrEqual(out[i].strength);
+ }
+ });
+
+ it("exposes a stable minimum-days constant", () => {
+ expect(MOOD_NARRATIVE_MIN_DAYS).toBeGreaterThan(0);
+ });
+});
diff --git a/src/lib/insights/mood-aggregates.ts b/src/lib/insights/mood-aggregates.ts
index 7f17e4886..3c02d75b8 100644
--- a/src/lib/insights/mood-aggregates.ts
+++ b/src/lib/insights/mood-aggregates.ts
@@ -25,6 +25,10 @@ import {
type DailyBucket,
} from "@/lib/insights/bucket-series";
import { round, summarizeSeries } from "@/lib/insights/status-shared";
+import {
+ computeMoodNarratives,
+ type MoodNarrative,
+} from "@/lib/insights/mood-narratives";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
@@ -87,53 +91,6 @@ export interface MoodAggregateEntry {
* aggregate tests (flat tags only) keep their narrow fixtures.
*/
structuredTags?: StructuredTagRef[];
- /** v1.8.5 — free-text note (C1). */
- note?: string | null;
- /** Mood enum (for the notes timeline label / colour). */
- mood?: string;
-}
-
-// --- Notes timeline (v1.8.5 C1 — qualitative reflection feed) ---
-
-export interface NoteTimelineEntry {
- /** TZ-anchored day key. */
- date: string;
- /** ISO timestamp of the log. */
- loggedAt: string;
- score: number;
- mood: string | null;
- note: string;
- /** Flat free-text tags on the entry. */
- tags: string[];
- /** Structured-tag labels (i18n keys) on the entry. */
- structuredTagLabelKeys: string[];
-}
-
-/**
- * Recent entries that carry a free-text note, newest first. Bounded to
- * `limit` so the cached aggregate payload stays small. Entries without a
- * note are skipped — the timeline is the qualitative-reflection feed.
- */
-export function computeNotesTimeline(
- entries: MoodAggregateEntry[],
- limit = 20,
-): NoteTimelineEntry[] {
- return entries
- .filter((entry) => typeof entry.note === "string" && entry.note.trim())
- .slice()
- .reverse() // entries arrive oldest-first; show newest first
- .slice(0, limit)
- .map((entry) => ({
- date: entry.date,
- loggedAt: entry.moodLoggedAt.toISOString(),
- score: entry.score,
- mood: entry.mood ?? null,
- note: entry.note!.trim(),
- tags: Array.isArray(entry.tags) ? (entry.tags as string[]) : [],
- structuredTagLabelKeys: (entry.structuredTags ?? []).map(
- (tag) => tag.labelKey,
- ),
- }));
}
/** Cross-metric row shape (WEIGHT / BLOOD_PRESSURE_SYS / PULSE / …). */
@@ -518,8 +475,12 @@ export interface MoodAggregates {
tags: TagSummaryRow[];
/** v1.8.5 — structured-tag breakdown from the taxonomy join. */
structuredTags: StructuredTagRow[];
- /** v1.8.5 — recent notes timeline (qualitative-reflection feed). */
- notesTimeline: NoteTimelineEntry[];
+ /**
+ * v1.8.6 — ranked, threshold-gated narrative takeaways. The "read
+ * this first" layer above the charts; the same array feeds the LLM
+ * snapshot so prose and feed never drift.
+ */
+ narratives: MoodNarrative[];
correlations: Record;
}
@@ -571,6 +532,14 @@ export function computeMoodAggregates(args: {
const windowDays = selectHeatmapWindow(totalSpanDays);
+ const weekday = computeWeekdayAverages(moodDaily, now);
+ const tags = computeTagSummary(entries, now);
+ const structuredTags = computeStructuredTagSummary(entries, now);
+ const inTargetPct = computeInTargetPct(moodDaily);
+
+ // Distinct day keys the user logged on — drives the streak takeaway.
+ const loggedDayKeys = Array.from(new Set(entries.map((entry) => entry.date)));
+
return {
summary: {
points: moodSummary?.points ?? 0,
@@ -580,7 +549,7 @@ export function computeMoodAggregates(args: {
latest: latest?.value ?? null,
delta:
latest && previous ? round(latest.value - previous.value, 2) : null,
- inTargetPct: computeInTargetPct(moodDaily),
+ inTargetPct,
totalEntries: entries.length,
totalSpanDays,
newestEntryDaysAgo,
@@ -590,10 +559,18 @@ export function computeMoodAggregates(args: {
cells: computeHeatmapCells(entries, now, windowDays),
},
distribution: computeDistribution(moodDaily),
- weekday: computeWeekdayAverages(moodDaily, now),
- tags: computeTagSummary(entries, now),
- structuredTags: computeStructuredTagSummary(entries, now),
- notesTimeline: computeNotesTimeline(entries),
+ weekday,
+ tags,
+ structuredTags,
+ narratives: computeMoodNarratives({
+ daily: moodDaily,
+ weekday,
+ tags,
+ structuredTags,
+ inTargetPct,
+ loggedDayKeys,
+ now,
+ }),
correlations: Object.fromEntries(
Object.entries(CORRELATION_METRICS).map(([key, type]) => [
key,
@@ -627,8 +604,6 @@ export async function fetchMoodAggregates(
score: true,
tags: true,
moodLoggedAt: true,
- mood: true,
- note: true,
// v1.8.5 — pull the structured-tag links + their catalog rows so
// the breakdown can fold structured tags next to the flat ones.
tagLinks: {
@@ -651,8 +626,6 @@ export async function fetchMoodAggregates(
score: row.score,
tags: parseTags(row.tags),
moodLoggedAt: row.moodLoggedAt,
- mood: row.mood,
- note: row.note,
structuredTags: row.tagLinks.map((link) => ({
key: link.moodTag.key,
categoryKey: link.moodTag.category.key,
diff --git a/src/lib/insights/mood-narratives.ts b/src/lib/insights/mood-narratives.ts
new file mode 100644
index 000000000..8593e0c6f
--- /dev/null
+++ b/src/lib/insights/mood-narratives.ts
@@ -0,0 +1,369 @@
+/**
+ * v1.8.6 — ranked narrative takeaways for the mood surface.
+ *
+ * Plain-language, threshold-gated one-liners ("Your mood dips most on
+ * Mondays") that turn the already-computed mood aggregates into the
+ * "read this first" layer above the charts. Pure functions over the
+ * existing aggregate shapes so the same takeaways feed BOTH
+ * `/api/mood/insights` (the visible feed) and the LLM snapshot in
+ * `mood-status.ts` — shown == sent, no prose/chart drift.
+ *
+ * The anti-platitude rule is enforced structurally: every minimum
+ * sample / minimum effect bar is a NAMED CONSTANT, and a takeaway is
+ * only emitted when its bar is cleared. Empty data → empty feed; no
+ * "no correlation" filler.
+ */
+
+import { dayOffsetToBerlinDayKey } from "@/lib/insights/bucket-series";
+import { round } from "@/lib/insights/status-shared";
+import type {
+ StructuredTagRow,
+ TagSummaryRow,
+ WeekdayRow,
+} from "@/lib/insights/mood-aggregates";
+
+// --- Named thresholds (the anti-platitude contract) ---
+
+/** Minimum distinct daily points before any trend/weekend takeaway fires. */
+export const MOOD_NARRATIVE_MIN_DAYS = 7;
+/** Minimum score-point magnitude (1..5 scale) for an effect to count. */
+export const MOOD_NARRATIVE_MIN_EFFECT = 0.3;
+/** Minimum daily samples behind a weekday before its dip/peak is trusted. */
+export const MOOD_NARRATIVE_MIN_WEEKDAY_SAMPLES = 3;
+/** Minimum tagged occurrences before a tag→mood delta is surfaced. */
+export const MOOD_NARRATIVE_MIN_TAG_COUNT = 3;
+/** Days per slice when comparing the recent window against the prior one. */
+export const MOOD_NARRATIVE_TREND_WINDOW = 7;
+/** Minimum consecutive logged days before the streak takeaway congratulates. */
+export const MOOD_NARRATIVE_MIN_STREAK = 3;
+/** Maximum tag-delta takeaways surfaced (lift + drop combined are still capped). */
+export const MOOD_NARRATIVE_MAX_TAG_ITEMS = 2;
+/** Hard cap on the rendered feed length. */
+export const MOOD_NARRATIVE_MAX_ITEMS = 8;
+
+export type MoodNarrativeKind =
+ | "weekday-dip"
+ | "weekday-peak"
+ | "trend"
+ | "weekend"
+ | "tag-lift"
+ | "tag-drop"
+ | "in-target"
+ | "streak";
+
+/**
+ * A single takeaway. `messageKey` resolves to an i18n template; `vars`
+ * are the interpolation values (strings so they survive JSON transport
+ * to the client and the LLM snapshot unchanged). `strength` is the
+ * ranking weight — higher sorts first — and is never rendered.
+ */
+export interface MoodNarrative {
+ kind: MoodNarrativeKind;
+ messageKey: string;
+ vars: Record;
+ /** Ranking weight (effect size, normalised); higher = stronger. */
+ strength: number;
+}
+
+type DailyPoint = { dayOffset: number; value: number };
+
+export interface MoodNarrativeInput {
+ daily: DailyPoint[];
+ weekday: WeekdayRow[];
+ tags: TagSummaryRow[];
+ structuredTags: StructuredTagRow[];
+ inTargetPct: number | null;
+ /** Day keys (YYYY-MM-DD) the user logged a mood on. */
+ loggedDayKeys: string[];
+ now: Date;
+}
+
+const WEEKDAY_LABEL_KEYS = [
+ "charts.weekdaysFull.mon",
+ "charts.weekdaysFull.tue",
+ "charts.weekdaysFull.wed",
+ "charts.weekdaysFull.thu",
+ "charts.weekdaysFull.fri",
+ "charts.weekdaysFull.sat",
+ "charts.weekdaysFull.sun",
+] as const;
+
+const MS_PER_DAY = 24 * 60 * 60 * 1000;
+
+function mean(values: number[]): number | null {
+ if (values.length === 0) return null;
+ return values.reduce((sum, v) => sum + v, 0) / values.length;
+}
+
+/** Weekday (0 = Monday … 6 = Sunday) for a daily-bucket offset. */
+function weekdayOfOffset(now: Date, dayOffset: number): number {
+ const dayKey = dayOffsetToBerlinDayKey(now, dayOffset);
+ const d = new Date(dayKey + "T00:00:00Z");
+ return (d.getUTCDay() + 6) % 7;
+}
+
+// --- Individual takeaway builders ---
+
+/**
+ * Best / worst weekday vs the weekly mean. Fires the dip (worst day) and
+ * the peak (best day) independently, each gated on the per-weekday sample
+ * count and the effect magnitude against the overall weekday mean.
+ */
+function weekdayNarratives(input: MoodNarrativeInput): MoodNarrative[] {
+ const populated = input.weekday.filter(
+ (
+ row,
+ ): row is WeekdayRow & { avgScore: number } =>
+ row.avgScore != null && row.count >= MOOD_NARRATIVE_MIN_WEEKDAY_SAMPLES,
+ );
+ if (populated.length < 2) return [];
+
+ const overall = mean(populated.map((r) => r.avgScore));
+ if (overall == null) return [];
+
+ let best = populated[0];
+ let worst = populated[0];
+ for (const row of populated) {
+ if (row.avgScore > best.avgScore) best = row;
+ if (row.avgScore < worst.avgScore) worst = row;
+ }
+
+ const out: MoodNarrative[] = [];
+
+ const dipDelta = round(overall - worst.avgScore, 2);
+ if (dipDelta >= MOOD_NARRATIVE_MIN_EFFECT) {
+ out.push({
+ kind: "weekday-dip",
+ messageKey: "insights.mood.narrative.weekdayDip",
+ vars: {
+ weekdayKey: WEEKDAY_LABEL_KEYS[worst.weekday],
+ delta: dipDelta.toFixed(1),
+ },
+ strength: dipDelta,
+ });
+ }
+
+ const peakDelta = round(best.avgScore - overall, 2);
+ if (peakDelta >= MOOD_NARRATIVE_MIN_EFFECT) {
+ out.push({
+ kind: "weekday-peak",
+ messageKey: "insights.mood.narrative.weekdayPeak",
+ vars: {
+ weekdayKey: WEEKDAY_LABEL_KEYS[best.weekday],
+ value: best.avgScore.toFixed(1),
+ },
+ strength: peakDelta,
+ });
+ }
+
+ return out;
+}
+
+/**
+ * Trend direction: mean of the most recent window vs the prior window.
+ * Both windows must carry the full sample count and their means must
+ * differ by at least the effect threshold.
+ */
+function trendNarrative(input: MoodNarrativeInput): MoodNarrative | null {
+ const w = MOOD_NARRATIVE_TREND_WINDOW;
+ const recent = input.daily.filter((b) => b.dayOffset < w);
+ const prior = input.daily.filter(
+ (b) => b.dayOffset >= w && b.dayOffset < 2 * w,
+ );
+ if (recent.length < w || prior.length < w) return null;
+
+ const recentMean = mean(recent.map((b) => b.value));
+ const priorMean = mean(prior.map((b) => b.value));
+ if (recentMean == null || priorMean == null) return null;
+
+ const delta = round(recentMean - priorMean, 2);
+ if (Math.abs(delta) < MOOD_NARRATIVE_MIN_EFFECT) return null;
+
+ return {
+ kind: "trend",
+ messageKey:
+ delta > 0
+ ? "insights.mood.narrative.trendUp"
+ : "insights.mood.narrative.trendDown",
+ vars: {
+ direction: delta > 0 ? "up" : "down",
+ delta: Math.abs(delta).toFixed(1),
+ days: String(w),
+ },
+ strength: Math.abs(delta),
+ };
+}
+
+/**
+ * Weekend vs weekday effect over the daily means. Both buckets must
+ * carry data and the gap must clear the effect threshold.
+ */
+function weekendNarrative(input: MoodNarrativeInput): MoodNarrative | null {
+ if (input.daily.length < MOOD_NARRATIVE_MIN_DAYS) return null;
+
+ const weekend: number[] = [];
+ const weekday: number[] = [];
+ for (const bucket of input.daily) {
+ const wd = weekdayOfOffset(input.now, bucket.dayOffset);
+ if (wd >= 5) weekend.push(bucket.value);
+ else weekday.push(bucket.value);
+ }
+ const weekendMean = mean(weekend);
+ const weekdayMean = mean(weekday);
+ if (weekendMean == null || weekdayMean == null) return null;
+
+ const delta = round(weekendMean - weekdayMean, 2);
+ if (Math.abs(delta) < MOOD_NARRATIVE_MIN_EFFECT) return null;
+
+ return {
+ kind: "weekend",
+ messageKey:
+ delta > 0
+ ? "insights.mood.narrative.weekendUp"
+ : "insights.mood.narrative.weekendDown",
+ vars: {
+ direction: delta > 0 ? "up" : "down",
+ delta: Math.abs(delta).toFixed(1),
+ },
+ strength: Math.abs(delta),
+ };
+}
+
+/**
+ * Top tag→mood deltas against the overall daily mean. Flat tags and
+ * structured tags share one ranked pool; the strongest lift and the
+ * strongest drop survive (capped). Each needs the min occurrence count
+ * and an effect that clears the magnitude threshold.
+ */
+function tagNarratives(input: MoodNarrativeInput): MoodNarrative[] {
+ const overall = mean(input.daily.map((b) => b.value));
+ if (overall == null) return [];
+
+ type Candidate = {
+ label: string;
+ delta: number;
+ count: number;
+ };
+
+ const candidates: Candidate[] = [];
+ for (const row of input.tags) {
+ if (row.count < MOOD_NARRATIVE_MIN_TAG_COUNT) continue;
+ candidates.push({
+ label: row.tag,
+ delta: round(row.avgScore - overall, 2),
+ count: row.count,
+ });
+ }
+
+ const lifts = candidates
+ .filter((c) => c.delta >= MOOD_NARRATIVE_MIN_EFFECT)
+ .sort((a, b) => b.delta - a.delta);
+ const drops = candidates
+ .filter((c) => c.delta <= -MOOD_NARRATIVE_MIN_EFFECT)
+ .sort((a, b) => a.delta - b.delta);
+
+ const out: MoodNarrative[] = [];
+ const topLift = lifts[0];
+ if (topLift) {
+ out.push({
+ kind: "tag-lift",
+ messageKey: "insights.mood.narrative.tagLift",
+ vars: { tag: topLift.label, delta: topLift.delta.toFixed(1) },
+ strength: topLift.delta,
+ });
+ }
+ const topDrop = drops[0];
+ if (topDrop) {
+ out.push({
+ kind: "tag-drop",
+ messageKey: "insights.mood.narrative.tagDrop",
+ vars: { tag: topDrop.label, delta: Math.abs(topDrop.delta).toFixed(1) },
+ strength: Math.abs(topDrop.delta),
+ });
+ }
+
+ return out.slice(0, MOOD_NARRATIVE_MAX_TAG_ITEMS);
+}
+
+/**
+ * In-target share takeaway. Surfaces the already-computed `inTargetPct`
+ * (the share of recent days in the good-mood band) once any recent data
+ * exists. Strength is fixed-low so the more specific signals outrank it.
+ */
+function inTargetNarrative(input: MoodNarrativeInput): MoodNarrative | null {
+ if (input.inTargetPct == null) return null;
+ return {
+ kind: "in-target",
+ messageKey: "insights.mood.narrative.inTarget",
+ vars: { pct: String(Math.round(input.inTargetPct)) },
+ strength: 0.2,
+ };
+}
+
+/**
+ * Current consecutive logging streak ending today (or the most recent
+ * logged day). Fires only at or above the minimum run. Strength is
+ * fixed-low — encouragement, not analysis.
+ */
+function streakNarrative(input: MoodNarrativeInput): MoodNarrative | null {
+ if (input.loggedDayKeys.length === 0) return null;
+
+ const keys = new Set(input.loggedDayKeys);
+ const todayKey = input.now.toISOString().slice(0, 10);
+ const todayMs = Date.parse(todayKey + "T00:00:00Z");
+
+ // Anchor the streak at today if logged, else the most recent logged day.
+ let anchorMs = todayMs;
+ if (!keys.has(todayKey)) {
+ const sorted = [...keys].sort().reverse();
+ anchorMs = Date.parse(sorted[0] + "T00:00:00Z");
+ // A streak that does not include today is stale — don't congratulate.
+ if (anchorMs !== todayMs) return null;
+ }
+
+ let run = 0;
+ let cursor = anchorMs;
+ while (keys.has(new Date(cursor).toISOString().slice(0, 10))) {
+ run += 1;
+ cursor -= MS_PER_DAY;
+ }
+
+ if (run < MOOD_NARRATIVE_MIN_STREAK) return null;
+
+ return {
+ kind: "streak",
+ messageKey: "insights.mood.narrative.streak",
+ vars: { days: String(run) },
+ strength: 0.15,
+ };
+}
+
+/**
+ * Build the ranked, threshold-gated narrative feed. Pure over the
+ * supplied aggregates; emits only takeaways whose named thresholds are
+ * cleared, sorted strongest-first, capped at `MOOD_NARRATIVE_MAX_ITEMS`.
+ */
+export function computeMoodNarratives(
+ input: MoodNarrativeInput,
+): MoodNarrative[] {
+ const all: MoodNarrative[] = [
+ ...weekdayNarratives(input),
+ ...tagNarratives(input),
+ ];
+
+ const trend = trendNarrative(input);
+ if (trend) all.push(trend);
+
+ const weekend = weekendNarrative(input);
+ if (weekend) all.push(weekend);
+
+ const inTarget = inTargetNarrative(input);
+ if (inTarget) all.push(inTarget);
+
+ const streak = streakNarrative(input);
+ if (streak) all.push(streak);
+
+ return all
+ .sort((a, b) => b.strength - a.strength)
+ .slice(0, MOOD_NARRATIVE_MAX_ITEMS);
+}
diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts
index 5852677fc..3dbd4d50f 100644
--- a/src/lib/insights/mood-status.ts
+++ b/src/lib/insights/mood-status.ts
@@ -14,8 +14,10 @@ import {
MOOD_ORANGE_MIN,
computeInTargetPct,
computeTagSummary,
+ computeWeekdayAverages,
pairDailyBuckets,
} from "@/lib/insights/mood-aggregates";
+import { computeMoodNarratives } from "@/lib/insights/mood-narratives";
import {
buildGradedSeriesFromPoints,
degradeStatusSnapshotToBudget,
@@ -223,6 +225,21 @@ export async function generateMoodStatusForUser(
// via `computeTagSummary` so the prose and the chart never drift.
const tagSummary = computeTagSummary(entries, now);
+ // v1.8.6 — the same threshold-gated narrative feed the user sees on
+ // the mood page, computed from the same aggregates so the prose the
+ // model writes never contradicts the takeaways on screen (shown ==
+ // sent). Structured tags are not loaded on this lean snapshot path,
+ // so the tag deltas ride the flat-tag summary only.
+ const narratives = computeMoodNarratives({
+ daily: moodSeries.daily,
+ weekday: computeWeekdayAverages(moodSeries.daily, now),
+ tags: tagSummary,
+ structuredTags: [],
+ inTargetPct: inTargetPctLast30DailyPoints,
+ loggedDayKeys: Array.from(new Set(entries.map((entry) => entry.date))),
+ now,
+ });
+
const snapshot = {
locale,
generatedForDay: todayKey,
@@ -253,6 +270,7 @@ export async function generateMoodStatusForUser(
inTargetPctLast30DailyPoints,
},
tags: tagSummary.length > 0 ? tagSummary : null,
+ narratives: narratives.length > 0 ? narratives : null,
},
crossMetricContext:
weightSeries.daily.length >= 3 ||