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 ( + + +
    + +
    +
    +

    + {t("insights.mood.inTargetValue", { pct: String(rounded) })} +

    +

    + {t("insights.mood.inTargetCaption")} +

    +
    +
    +
    + ); +} diff --git a/src/components/insights/mood/mood-insights-sections.tsx b/src/components/insights/mood/mood-insights-sections.tsx index db1b9bc6e..e09f42dbb 100644 --- a/src/components/insights/mood/mood-insights-sections.tsx +++ b/src/components/insights/mood/mood-insights-sections.tsx @@ -29,9 +29,10 @@ import { type MoodStructuredTagRow, } from "./mood-structured-tag-breakdown"; import { - MoodNotesTimeline, - type MoodNoteEntry, -} from "./mood-notes-timeline"; + MoodNarrativeFeed, + type MoodNarrativeItem, +} from "./mood-narrative-feed"; +import { MoodInTargetTile } from "./mood-in-target-tile"; /** * v1.8.5 — additional Mood Insights sections. @@ -46,6 +47,7 @@ import { interface MoodInsightsResponse { summary: { totalEntries: number; + inTargetPct: number | null; }; heatmap: { windowDays: number; @@ -55,7 +57,7 @@ interface MoodInsightsResponse { weekday: MoodWeekdayRow[]; tags: MoodTagRow[]; structuredTags: MoodStructuredTagRow[]; - notesTimeline: MoodNoteEntry[]; + narratives: MoodNarrativeItem[]; correlations: { sleep: MoodMetricCorrelationData; steps: MoodMetricCorrelationData; @@ -110,10 +112,13 @@ export function MoodInsightsSections() { const hasTags = data.tags.length > 0; const hasStructuredTags = data.structuredTags.length > 0; - const hasNotes = data.notesTimeline.length > 0; 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 ||