mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user