diff --git a/src/components/insights/mood/__tests__/mood-narrative-feed.test.tsx b/src/components/insights/mood/__tests__/mood-narrative-feed.test.tsx index f6d7e60fa..f6bc4db9a 100644 --- a/src/components/insights/mood/__tests__/mood-narrative-feed.test.tsx +++ b/src/components/insights/mood/__tests__/mood-narrative-feed.test.tsx @@ -34,6 +34,22 @@ describe("", () => { expect(html).toContain('data-slot="mood-narrative-feed"'); }); + it("resolves a structured tag key into the sentence", () => { + const items: MoodNarrativeItem[] = [ + { + kind: "tag-lift", + messageKey: "insights.mood.narrative.tagLift", + vars: { tagKey: "charts.weekdaysFull.fri", delta: "0.6" }, + }, + ]; + // `charts.weekdaysFull.fri` stands in for a catalog label key — the + // renderer must resolve `tagKey` the same way it resolves `weekdayKey`. + const html = render(); + expect(html).toContain("Friday"); + expect(html).not.toContain("charts.weekdaysFull.fri"); + expect(html).toContain("0.6"); + }); + it("renders one row per takeaway in order", () => { const items: MoodNarrativeItem[] = [ { diff --git a/src/components/insights/mood/mood-narrative-feed.tsx b/src/components/insights/mood/mood-narrative-feed.tsx index fd007c356..a528cd69f 100644 --- a/src/components/insights/mood/mood-narrative-feed.tsx +++ b/src/components/insights/mood/mood-narrative-feed.tsx @@ -70,9 +70,12 @@ export function MoodNarrativeFeed({ items }: { items: MoodNarrativeItem[] }) { {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. + // interpolating so the sentence reads in the active locale. A + // structured tag→mood takeaway carries its catalog `tagKey` + // (an i18n key) the same way; flat tags supply `tag` verbatim. const vars: Record = { ...item.vars }; if (vars.weekdayKey) vars.weekday = t(vars.weekdayKey); + if (vars.tagKey) vars.tag = t(vars.tagKey); return (
  • { ); }); + it("surfaces a structured tag→mood lift via its catalog label key", () => { + const input: MoodNarrativeInput = { + ...emptyInput(), + daily: daily([ + { dayOffset: 0, value: 3 }, + { dayOffset: 1, value: 3 }, + { dayOffset: 2, value: 3 }, + ]), + structuredTags: [ + { + key: "happy", + categoryKey: "feelings", + labelKey: "insights.mood.tags.feelings.happy", + icon: null, + count: MOOD_NARRATIVE_MIN_TAG_COUNT, + avgScore: 4.5, + }, + ], + }; + const out = computeMoodNarratives(input); + const lift = out.find((n) => n.kind === "tag-lift"); + // structured tags carry an i18n key the renderer resolves, never a + // raw label string. + expect(lift?.vars.tagKey).toBe("insights.mood.tags.feelings.happy"); + expect(lift?.vars.tag).toBeUndefined(); + }); + + it("ranks flat and structured tags in one shared pool", () => { + const input: MoodNarrativeInput = { + ...emptyInput(), + 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 }], + structuredTags: [ + { + key: "celebrating", + categoryKey: "events", + labelKey: "insights.mood.tags.events.celebrating", + icon: null, + count: MOOD_NARRATIVE_MIN_TAG_COUNT, + avgScore: 5, // stronger lift than the flat "sport" tag + }, + ], + }; + const lift = computeMoodNarratives(input).find((n) => n.kind === "tag-lift"); + // the stronger structured lift wins the single tag-lift slot. + expect(lift?.vars.tagKey).toBe("insights.mood.tags.events.celebrating"); + }); + + it("ignores a structured tag below the occurrence gate", () => { + const input: MoodNarrativeInput = { + ...emptyInput(), + daily: daily([ + { dayOffset: 0, value: 3 }, + { dayOffset: 1, value: 3 }, + ]), + structuredTags: [ + { + key: "rare", + categoryKey: "events", + labelKey: "insights.mood.tags.events.rare", + icon: null, + count: MOOD_NARRATIVE_MIN_TAG_COUNT - 1, + avgScore: 5, + }, + ], + }; + const out = computeMoodNarratives(input); + expect(out.some((n) => n.kind === "tag-lift")).toBe(false); + }); + it("stays silent on a tag whose delta is below the effect threshold", () => { const input: MoodNarrativeInput = { ...emptyInput(), diff --git a/src/lib/insights/mood-narratives.ts b/src/lib/insights/mood-narratives.ts index 8593e0c6f..81078a01e 100644 --- a/src/lib/insights/mood-narratives.ts +++ b/src/lib/insights/mood-narratives.ts @@ -230,17 +230,27 @@ function weekendNarrative(input: MoodNarrativeInput): MoodNarrative | null { } /** - * 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. + * Top tag→mood deltas against the overall daily mean. Flat free-text tags + * AND structured taxonomy tags share one ranked pool: the strongest lift + * and the strongest drop across both axes survive (capped). Each candidate + * needs the min occurrence count and an effect that clears the magnitude + * threshold. + * + * The two axes carry different label shapes. A flat tag's label is the + * raw free-text string, emitted as `vars.tag` and rendered verbatim. A + * structured tag's label is an i18n message key from the catalog, emitted + * as `vars.tagKey` so the renderer resolves it in the active locale + * (mirroring the `weekdayKey` → `weekday` pattern) before interpolation. */ function tagNarratives(input: MoodNarrativeInput): MoodNarrative[] { const overall = mean(input.daily.map((b) => b.value)); if (overall == null) return []; type Candidate = { - label: string; + /** Free-text label for flat tags; null for structured (use `labelKey`). */ + label: string | null; + /** i18n key for structured tags; null for flat (use `label`). */ + labelKey: string | null; delta: number; count: number; }; @@ -250,6 +260,16 @@ function tagNarratives(input: MoodNarrativeInput): MoodNarrative[] { if (row.count < MOOD_NARRATIVE_MIN_TAG_COUNT) continue; candidates.push({ label: row.tag, + labelKey: null, + delta: round(row.avgScore - overall, 2), + count: row.count, + }); + } + for (const row of input.structuredTags) { + if (row.count < MOOD_NARRATIVE_MIN_TAG_COUNT) continue; + candidates.push({ + label: null, + labelKey: row.labelKey, delta: round(row.avgScore - overall, 2), count: row.count, }); @@ -262,13 +282,16 @@ function tagNarratives(input: MoodNarrativeInput): MoodNarrative[] { .filter((c) => c.delta <= -MOOD_NARRATIVE_MIN_EFFECT) .sort((a, b) => a.delta - b.delta); + const tagVars = (c: Candidate): Record => + c.labelKey != null ? { tagKey: c.labelKey } : { tag: c.label ?? "" }; + 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) }, + vars: { ...tagVars(topLift), delta: topLift.delta.toFixed(1) }, strength: topLift.delta, }); } @@ -277,7 +300,10 @@ function tagNarratives(input: MoodNarrativeInput): MoodNarrative[] { out.push({ kind: "tag-drop", messageKey: "insights.mood.narrative.tagDrop", - vars: { tag: topDrop.label, delta: Math.abs(topDrop.delta).toFixed(1) }, + vars: { + ...tagVars(topDrop), + delta: Math.abs(topDrop.delta).toFixed(1), + }, strength: Math.abs(topDrop.delta), }); } diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index 3dbd4d50f..d54173f17 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -13,6 +13,7 @@ import { MOOD_ORANGE_MAX, MOOD_ORANGE_MIN, computeInTargetPct, + computeStructuredTagSummary, computeTagSummary, computeWeekdayAverages, pairDailyBuckets, @@ -115,9 +116,37 @@ export async function generateMoodStatusForUser( score: true, tags: true, moodLoggedAt: true, + // v1.8.6 — pull the structured-tag links so the snapshot feeds + // `computeMoodNarratives` the same structured-tag pool the visible + // mood page does (shown == sent for the tag→mood takeaways). + tagLinks: { + select: { + moodTag: { + select: { + key: true, + labelKey: true, + icon: true, + category: { select: { key: true } }, + }, + }, + }, + }, }, }) - .then((rows) => rows.reverse()); + .then((rows) => + rows.reverse().map((row) => ({ + date: row.date, + score: row.score, + tags: row.tags, + moodLoggedAt: row.moodLoggedAt, + structuredTags: (row.tagLinks ?? []).map((link) => ({ + key: link.moodTag.key, + categoryKey: link.moodTag.category.key, + labelKey: link.moodTag.labelKey, + icon: link.moodTag.icon, + })), + })), + ); const now = new Date(); @@ -224,17 +253,18 @@ export async function generateMoodStatusForUser( // tag patterns. Shared with the `/api/mood/insights` tag breakdown // via `computeTagSummary` so the prose and the chart never drift. const tagSummary = computeTagSummary(entries, now); + const structuredTagSummary = computeStructuredTagSummary(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. + // sent). Both the flat free-text tags and the structured taxonomy tags + // feed the tag→mood pool, matching the visible feed exactly. const narratives = computeMoodNarratives({ daily: moodSeries.daily, weekday: computeWeekdayAverages(moodSeries.daily, now), tags: tagSummary, - structuredTags: [], + structuredTags: structuredTagSummary, inTargetPct: inTargetPctLast30DailyPoints, loggedDayKeys: Array.from(new Set(entries.map((entry) => entry.date))), now,