feat(mood): add a ranked narrative takeaway feed

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-01 22:51:10 +02:00
1 parent 3ecfa3a898
commit ac51cfdc18
15 files changed
+1028 -94

No files matched your search

@@ -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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("<MoodNarrativeFeed>", () => {
it("renders nothing for an empty feed (no platitude placeholders)", () => {
expect(render(<MoodNarrativeFeed items={[]} />)).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(<MoodNarrativeFeed items={items} />);
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(<MoodNarrativeFeed items={items} />);
expect((html.match(/<li/g) ?? []).length).toBe(2);
expect(html).toContain("72%");
expect(html).toContain("5 days in a row");
});
});
describe("<MoodInTargetTile>", () => {
it("renders nothing when the percentage is unavailable", () => {
expect(render(<MoodInTargetTile pct={null} />)).toBe("");
});
it("shows the rounded in-target percentage", () => {
const html = render(<MoodInTargetTile pct={71.6} />);
expect(html).toContain("72%");
expect(html).toContain("good-mood range");
});
});
@@ -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 (
<Card>
<CardContent className="flex items-center gap-4 py-4">
<div className="bg-muted text-muted-foreground flex size-10 shrink-0 items-center justify-center rounded-full">
<Target className="size-5" />
</div>
<div className="min-w-0">
<p className="text-foreground text-2xl font-semibold tabular-nums">
{t("insights.mood.inTargetValue", { pct: String(rounded) })}
</p>
<p className="text-muted-foreground text-sm">
{t("insights.mood.inTargetCaption")}
</p>
</div>
</CardContent>
</Card>
);
}
@@ -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 (
<div className="space-y-4">
<MoodInTargetTile pct={data.summary.inTargetPct} />
<MoodNarrativeFeed items={data.narratives} />
<SectionCard title={t("insights.mood.heatmapTitle")}>
<MoodHeatmap
cells={heatmapCells}
@@ -156,12 +161,6 @@ export function MoodInsightsSections() {
pulse={data.correlations.pulse}
/>
</div>
{hasNotes && (
<SectionCard title={t("insights.mood.notesTimelineTitle")}>
<MoodNotesTimeline notes={data.notesTimeline} />
</SectionCard>
)}
</div>
);
}
@@ -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<string, string>;
}
const ICONS: Record<string, ComponentType<{ className?: string }>> = {
"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 (
<ul
className="space-y-2"
data-slot="mood-narrative-feed"
aria-label={t("insights.mood.narrative.title")}
>
{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<string, string> = { ...item.vars };
if (vars.weekdayKey) vars.weekday = t(vars.weekdayKey);
return (
<li
key={`${item.kind}-${index}`}
className="bg-card border-border flex items-start gap-3 rounded-lg border p-3"
>
<Icon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<p className="text-foreground/90 text-sm leading-snug">
{t(item.messageKey, vars)}
</p>
</li>
);
})}
</ul>
);
}