From ac51cfdc18192cc6fa022d62312084b885d1f1bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 1 Jun 2026 22:45:17 +0200 Subject: [PATCH] feat(mood): add a ranked narrative takeaway feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface plain-language takeaways above the mood charts: the weekday dip and peak, the overall trend direction, the weekend-vs-weekday effect, the strongest tag-to-mood lift and drop, the in-target share, and the current logging streak. Each takeaway is threshold-gated — every minimum sample count and minimum effect size is a named constant, and a takeaway only renders once its bar is cleared, so the feed never falls back to filler. The list is ranked by effect size and capped. The takeaways are pure helpers computed from the existing mood aggregates, so the same feed reaches both the insights endpoint and the mood snapshot the model reasons over — what the page shows matches what the model is sent. Also promote the previously snapshot-only in-target percentage to a visible headline tile. --- messages/de.json | 16 +- messages/en.json | 16 +- messages/es.json | 16 +- messages/fr.json | 16 +- messages/it.json | 16 +- messages/pl.json | 16 +- .../__tests__/mood-narrative-feed.test.tsx | 67 ++++ .../insights/mood/mood-in-target-tile.tsx | 40 ++ .../insights/mood/mood-insights-sections.tsx | 21 +- .../insights/mood/mood-narrative-feed.tsx | 90 +++++ .../__tests__/mood-aggregates.test.ts | 27 +- .../__tests__/mood-narratives.test.ts | 305 +++++++++++++++ src/lib/insights/mood-aggregates.ts | 89 ++--- src/lib/insights/mood-narratives.ts | 369 ++++++++++++++++++ src/lib/insights/mood-status.ts | 18 + 15 files changed, 1028 insertions(+), 94 deletions(-) create mode 100644 src/components/insights/mood/__tests__/mood-narrative-feed.test.tsx create mode 100644 src/components/insights/mood/mood-in-target-tile.tsx create mode 100644 src/components/insights/mood/mood-narrative-feed.tsx create mode 100644 src/lib/insights/__tests__/mood-narratives.test.ts create mode 100644 src/lib/insights/mood-narratives.ts 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 ||