mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +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>
|
||||
);
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<CorrelationKey, MoodMetricCorrelation>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string>;
|
||||
/** 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);
|
||||
}
|
||||
@@ -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 ||
|
||||
|
||||
Reference in new issue
Block a user