diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 62082e6fa..a58549d6b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -72562,6 +72562,28 @@ components: are available, otherwise a deterministic sentence about the day's strongest signal (a vital outside its personal range, an unusual night, vitals in range, last night). Null when there is nothing to say. When the deterministic sentence is built from a fact, that fact is left out of `today`. Additive since v1.40." + signalLine: + anyOf: + - type: object + properties: + headline: + anyOf: + - type: string + - type: "null" + description: The top signal's headline, or null when the lead already talks about its metric. + delta: + anyOf: + - type: string + - type: "null" + description: The top signal's delta, when it has one. + required: + - headline + - delta + additionalProperties: false + - type: "null" + description: "The muted line under an AI lead, decided on the server so every client shows the same one: the top signal + minus what the lead already says. Null under a deterministic lead, without a top signal, or when the lead + already says everything the signal would. Additive since v1.40.3." today: maxItems: 5 type: array @@ -72652,6 +72674,7 @@ components: - topSignal - briefingLead - lead + - signalLine - today - restMode - line diff --git a/e2e/today-overview.spec.ts b/e2e/today-overview.spec.ts index 9ac063b2d..459201129 100644 --- a/e2e/today-overview.spec.ts +++ b/e2e/today-overview.spec.ts @@ -51,6 +51,7 @@ const BASE: DailyDigest = { steadyWeeks: 4, }, topSignal: null, + signalLine: null, briefingLead: null, lead: null, today: [], @@ -115,6 +116,12 @@ const STATES: Record = { text: "Your latest blood pressure is sitting in the optimal band.", source: "briefing", }, + // The lead already talks about blood pressure, so the server keeps only + // the delta under it. + signalLine: { + headline: null, + delta: "↓ ~10 mmHg systolic vs the start of the window", + }, today: FACTS, worthALook: [ { diff --git a/e2e/v1341-ui-evidence.spec.ts b/e2e/v1341-ui-evidence.spec.ts index 78d6f3ad5..8d128c71e 100644 --- a/e2e/v1341-ui-evidence.spec.ts +++ b/e2e/v1341-ui-evidence.spec.ts @@ -30,6 +30,8 @@ const NARRATIVE_DIGEST: DailyDigest = { composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], }, topSignal: LONG_HEADLINE_BRIEFING.signalsOfDay?.[0] ?? null, + // The lead talks about blood pressure, so the server keeps only the delta. + signalLine: { headline: null, delta: "−12 mmHg vs. 30-Tage-Mittel" }, briefingLead: LONG_HEADLINE_BRIEFING.paragraph, lead: { text: LONG_HEADLINE_BRIEFING.paragraph, source: "briefing" }, today: [], diff --git a/src/app/api/daily/digest/__tests__/route.test.ts b/src/app/api/daily/digest/__tests__/route.test.ts index 9ae0648ba..10af3f903 100644 --- a/src/app/api/daily/digest/__tests__/route.test.ts +++ b/src/app/api/daily/digest/__tests__/route.test.ts @@ -70,6 +70,7 @@ const DIGEST: DailyDigest = { composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], }, topSignal: null, + signalLine: null, briefingLead: "Blood pressure is holding steady.", lead: { text: "Blood pressure is holding steady.", source: "briefing" }, today: [], diff --git a/src/components/__tests__/delegated-write-affordances.test.tsx b/src/components/__tests__/delegated-write-affordances.test.tsx index 8413cb0e3..21ea97e13 100644 --- a/src/components/__tests__/delegated-write-affordances.test.tsx +++ b/src/components/__tests__/delegated-write-affordances.test.tsx @@ -354,6 +354,7 @@ const DIGEST: DailyDigest = { sleepPending: false, score: null, topSignal: null, + signalLine: null, briefingLead: "A steady week so far.", lead: { text: "A steady week so far.", source: "briefing" }, today: [], diff --git a/src/components/daily/__tests__/today-hero-just-in.test.tsx b/src/components/daily/__tests__/today-hero-just-in.test.tsx index fed5f99e9..8c3b977c0 100644 --- a/src/components/daily/__tests__/today-hero-just-in.test.tsx +++ b/src/components/daily/__tests__/today-hero-just-in.test.tsx @@ -41,6 +41,7 @@ function digest(over: Partial = {}): DailyDigest { sleepPending: false, score: { value: 82, band: "green", delta: 3 }, topSignal: null, + signalLine: null, briefingLead: "Your week is trending steady.", lead: { text: "Your week is trending steady.", source: "briefing" }, today: [], diff --git a/src/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx index 6e6810c40..c0939590f 100644 --- a/src/components/daily/__tests__/today-hero.test.tsx +++ b/src/components/daily/__tests__/today-hero.test.tsx @@ -71,6 +71,12 @@ function digest(over: Partial = {}): DailyDigest { nudge: "Take it again after a calm five minutes.", delta: "+6 mmHg vs your 30-day average", }, + // Resolved on the server: the lead says nothing about blood pressure, so + // the whole signal rides under it. + signalLine: { + headline: "Blood pressure a touch high this morning", + delta: "+6 mmHg vs your 30-day average", + }, briefingLead: "Your week is trending steady.", lead: { text: "Your week is trending steady.", source: "briefing" }, today: [], @@ -209,6 +215,7 @@ describe("", () => { lead: null, briefingLead: null, topSignal: null, + signalLine: null, reactionLine: "Score 82 today.", })} />, @@ -233,6 +240,7 @@ describe("", () => { nudge: "", delta: null, }, + signalLine: null, })} />, ); @@ -270,6 +278,7 @@ describe("", () => { digest={digest({ score: null, topSignal: null, + signalLine: null, briefingLead: null, lead: null, line: "Nothing needs your attention today — everything's tracking normally.", @@ -289,6 +298,7 @@ describe("", () => { digest={digest({ score: null, topSignal: null, + signalLine: null, briefingLead: null, lead: null, line: "Nothing needs your attention today — everything's tracking normally.", @@ -340,6 +350,10 @@ describe("", () => { text: "Blood pressure a touch high this morning.", source: "briefing", }, + signalLine: { + headline: null, + delta: "+6 mmHg vs your 30-day average", + }, })} />, ); @@ -355,6 +369,7 @@ describe("", () => { ", () => { , @@ -443,6 +459,7 @@ describe("", () => { digest={digest({ score: null, topSignal: null, + signalLine: null, briefingLead: null, lead: null, worthALook: [], @@ -596,6 +613,7 @@ describe(" Today overview", () => { digest={digest({ score: null, topSignal: null, + signalLine: null, briefingLead: null, lead: null, worthALook: [], @@ -615,6 +633,7 @@ describe(" Today overview", () => { text: "All 4 of your latest vitals sit inside their usual range.", source: "signal", }, + signalLine: null, })} />, ); diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index 1eb83b7c3..53b3f9f59 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -74,20 +74,6 @@ function formatDelta( return t("daily.today.deltaVsBaseline", { delta: signed }); } -/** Lowercase letters and digits only, for a wording-insensitive compare. */ -function normalise(text: string): string { - return text - .toLowerCase() - .replace(/[^\p{L}\p{N}]+/gu, " ") - .trim(); -} - -/** Whether `lead` already says what `headline` says. */ -function restates(lead: string, headline: string): boolean { - const h = normalise(headline); - return h.length > 0 && normalise(lead).includes(h); -} - export function TodayHero({ digest, renderFilteredAllClear = false, @@ -148,18 +134,12 @@ export function TodayHero({ digest.lead === undefined ? (digest.reactionLine ?? digest.briefingLead ?? null) : (digest.lead?.text ?? null); - const topSignal = digest.topSignal; - // The briefing's top signal rides under an AI lead as its supporting line, - // and only with what the lead does not already say: a headline the lead - // restates is dropped and its delta, when there is one, stands alone. - const signalLine = - topSignal && lead && digest.lead?.source !== "signal" - ? restates(lead, topSignal.headline) - ? topSignal.delta - ? { headline: null, delta: topSignal.delta } - : null - : { headline: topSignal.headline, delta: topSignal.delta } - : null; + // The line under the lead is decided on the server (`digest.signalLine`): + // the briefing's top signal minus whatever the lead already says, so the + // headline goes when the lead talks about its metric and the delta stands + // alone. A digest the service worker cached before the field existed simply + // shows no line. + const signalLine = lead ? (digest.signalLine ?? null) : null; // A score-only digest with nothing else to say has no narrative content for // the leading column. Keeping the full md ring in that two-column shell left // a blank column beside a 168 px dial and pushed the all-clear read below diff --git a/src/lib/daily/__tests__/daily-briefing-push.test.ts b/src/lib/daily/__tests__/daily-briefing-push.test.ts index c1464ebf5..f1191cc27 100644 --- a/src/lib/daily/__tests__/daily-briefing-push.test.ts +++ b/src/lib/daily/__tests__/daily-briefing-push.test.ts @@ -37,6 +37,7 @@ function makeDigest(over: Partial = {}): DailyDigest { sleepPending: false, score: { value: 82, band: "good", delta: 1 }, topSignal: null, + signalLine: null, briefingLead: "Sleep looked solid last night.", lead: { text: "Sleep looked solid last night.", source: "briefing" }, today: [], diff --git a/src/lib/daily/__tests__/today-overview.test.ts b/src/lib/daily/__tests__/today-overview.test.ts index ef641c404..39e277b5d 100644 --- a/src/lib/daily/__tests__/today-overview.test.ts +++ b/src/lib/daily/__tests__/today-overview.test.ts @@ -703,3 +703,93 @@ describe("lead length — whole sentences, never a word cut in half", () => { wordsAreWhole(lead, LONG); }); }); + +describe("signal line under the lead — nothing said twice", () => { + const BP_HEADLINE = + "Your latest blood pressure is sitting in the optimal band."; + const BP_DELTA = "↓ ~10 mmHg systolic vs the start of the window"; + + function withSignal( + paragraph: string, + signal: Partial[number]> = {}, + ): DailyBriefing { + return { + paragraph, + signalsOfDay: [ + { + sourceMetric: "bp", + tone: "good", + headline: BP_HEADLINE, + nudge: "Keep the routine.", + delta: BP_DELTA, + ...signal, + }, + ], + keyFindings: [], + }; + } + + it("keeps only the delta when the lead already talks about the metric", () => { + const o = buildTodayOverview( + input({ + briefing: withSignal( + "Your most recent blood pressure sits comfortably in the optimal band, and resting heart rate is low.", + ), + }), + t, + ); + expect(o.lead?.source).toBe("briefing"); + expect(o.signalLine).toEqual({ headline: null, delta: BP_DELTA }); + }); + + it("drops the line when the lead covers the metric and there is no delta", () => { + const o = buildTodayOverview( + input({ + briefing: withSignal("Blood pressure held steady all week.", { + delta: null, + }), + }), + t, + ); + expect(o.signalLine).toBeNull(); + }); + + it("recognises the metric in the reader's language", () => { + const de = getServerTranslator("de").t; + const o = buildTodayOverview( + input({ + locale: "de", + briefing: withSignal( + "Dein Blutdruck lag die ganze Woche im optimalen Bereich.", + { headline: "Dein Blutdruck liegt im optimalen Bereich." }, + ), + }), + de, + ); + expect(o.signalLine).toEqual({ headline: null, delta: BP_DELTA }); + }); + + it("keeps headline and delta when the lead is about something else", () => { + const o = buildTodayOverview( + input({ briefing: withSignal("Last night's sleep ran long and deep.") }), + t, + ); + expect(o.signalLine).toEqual({ headline: BP_HEADLINE, delta: BP_DELTA }); + }); + + it("carries no line when the headline itself is the lead", () => { + const o = buildTodayOverview( + input({ briefing: withSignal("Your health score is 94.") }), + t, + ); + expect(o.lead?.text).toBe(BP_HEADLINE); + expect(o.signalLine).toEqual({ headline: null, delta: BP_DELTA }); + }); + + it("carries no line under a deterministic lead or without a briefing", () => { + expect( + buildTodayOverview(input({ vitals: [vital()] }), t).signalLine, + ).toBeNull(); + expect(buildTodayOverview(input(), t).signalLine).toBeNull(); + }); +}); diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index 7a9c6cdb8..178889c87 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -62,6 +62,7 @@ import { type TodayCycle, type TodayFact, type TodayLead, + type TodaySignalLine, type TodayRestMode, type TodaySleep, type TodayVital, @@ -441,6 +442,12 @@ export interface DailyDigest { * day's strongest signal. Null when there is nothing to say. */ lead: TodayLead | null; + /** + * The muted line under an AI lead: the top signal minus what the lead + * already says (the headline goes when the lead covers its metric, and the + * line is null when nothing is left). Null under a deterministic lead. + */ + signalLine: TodaySignalLine | null; /** * The Today overview: up to five statements about the day (Rest Mode, * medications, an appointment today or tomorrow, last night, vitals, @@ -1284,6 +1291,7 @@ export function buildDailyDigest( topSignal, briefingLead: overview.briefingLead, lead: overview.lead, + signalLine: overview.signalLine, today: overview.today, restMode: moduleEnabled(input.modules, "illness") ? (input.restMode ?? null) diff --git a/src/lib/daily/today-overview.ts b/src/lib/daily/today-overview.ts index f7956546f..da73f14e2 100644 --- a/src/lib/daily/today-overview.ts +++ b/src/lib/daily/today-overview.ts @@ -18,7 +18,7 @@ * fact leaves the list; an appointment already on the rail is not repeated * under Today. */ -import type { DailyBriefing } from "@/lib/ai/schema"; +import type { DailyBriefing, DailyBriefingSignal } from "@/lib/ai/schema"; import type { CyclePhase } from "@/lib/cycle/types"; import type { MedsTodayBlock } from "@/lib/dashboard/meds-today"; import type { Locale } from "@/lib/i18n/config"; @@ -154,8 +154,20 @@ export interface TodayOverviewInput { cycle: TodayCycle | null; } +/** + * The muted line under the lead: the briefing's top signal, minus whatever + * the lead already says. `headline` is null when the lead covers the signal's + * metric; the line is absent altogether when nothing is left. + */ +export interface TodaySignalLine { + headline: string | null; + delta: string | null; +} + export interface TodayOverview { lead: TodayLead | null; + /** The supporting line under an AI lead, already de-duplicated. */ + signalLine: TodaySignalLine | null; today: TodayFact[]; /** The first substantive briefing sentence, for `briefingLead`. */ briefingLead: string | null; @@ -319,6 +331,78 @@ function cleanReactionLine( return text.length > 0 ? text : null; } +/** Lowercase letters and digits only, for a wording-insensitive compare. */ +function normalise(text: string): string { + return text + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, " ") + .trim(); +} + +/** Whether `text` already says what `phrase` says, wording aside. */ +function restates(text: string, phrase: string): boolean { + const p = normalise(phrase); + return p.length > 0 && normalise(text).includes(p); +} + +/** + * How each briefing metric is named in prose, in every shipped language. The + * lead is model text in the reader's language, so "does the lead already talk + * about this metric" is a question about words. Stems, not whole words, so + * inflected forms ("Blutdrucks", "tętna") still match. A metric missing here + * simply falls back to the headline comparison. + */ +const METRIC_TERMS: Partial< + Record +> = { + bp: /blood\s*pressure|blutdruck|presi[oó]n\s+arterial|tensi[oó]n\s+arterial|pression\s+art[ée]rielle|tension\s+art[ée]rielle|pressione\s+(arteriosa|sanguigna)|ci[sś]nieni|혈압/iu, + weight: /weight|gewicht|peso|poids|wag[ai]|체중|몸무게/iu, + glp1_plateau: /weight|gewicht|peso|poids|wag[ai]|체중|몸무게/iu, + pulse: /pulse|puls|pouls|polso|t[eę]tn|맥박/iu, + resting_hr: + /resting\s+(heart\s*rate|pulse)|ruhepuls|ruhe-?herzfrequenz|en\s+reposo|au\s+repos|a\s+riposo|spoczynkow|안정\s*시?\s*심박/iu, + hrv: /\bhrv\b|heart[-\s]*rate\s+variability|herzfrequenzvariabilit|variabilidad|variabilit[ée]|zmienno[sś][cć]|심박\s*변이/iu, + sleep: + /sleep|slept|schlaf|geschlafen|sue[nñ]o|dormi|sommeil|sonno|sen\b|snu|spa[lł]|수면|잠/iu, + steps: /steps|schritte|pasos|passi|krok|걸음/iu, + mood: /mood|stimmung|[aá]nimo|humeur|umore|nastr[oó]j|기분/iu, + compliance: + /medication|dose|medikament|einnahme|medicaci[oó]n|m[ée]dicament|farmac|lek[iów]|복약|약물/iu, + body_temp: /temperature|temperatur|temperatura|temp[ée]rature|체온/iu, + vo2_max: /vo2|vo₂/iu, +}; + +/** Whether the lead already talks about the metric a signal is drawn from. */ +function leadCoversMetric( + lead: string, + metric: DailyBriefingSignal["sourceMetric"], +): boolean { + return METRIC_TERMS[metric]?.test(lead) ?? false; +} + +/** + * The line under the lead, decided here so every client shows the same one. + * + * Only an AI lead carries it (a deterministic lead is already the strongest + * signal). When the lead already talks about the top signal's metric, or + * says its headline outright, the headline goes and the delta stands alone; + * with no delta left there is no line. + */ +function buildSignalLine( + lead: TodayLead | null, + signal: DailyBriefingSignal | null, +): TodaySignalLine | null { + if (!lead || lead.source === "signal" || !signal) return null; + const headline = signal.headline?.trim() || null; + const delta = signal.delta?.trim() || null; + const covered = + (headline !== null && restates(lead.text, headline)) || + leadCoversMetric(lead.text, signal.sourceMetric); + const keptDelta = delta && !restates(lead.text, delta) ? delta : null; + if (!covered && headline) return { headline, delta: keptDelta }; + return keptDelta ? { headline: null, delta: keptDelta } : null; +} + /** Vitals that may speak about today: module on, reading current. */ function currentVitals(input: TodayOverviewInput): TodayVital[] { return input.vitals.filter( @@ -654,6 +738,10 @@ export function buildTodayOverview( return { lead, + signalLine: buildSignalLine( + lead, + input.briefing?.signalsOfDay?.[0] ?? null, + ), today: facts.slice(0, MAX_TODAY_FACTS), briefingLead, }; diff --git a/src/lib/openapi/routes/daily.ts b/src/lib/openapi/routes/daily.ts index 86e057173..3917d885e 100644 --- a/src/lib/openapi/routes/daily.ts +++ b/src/lib/openapi/routes/daily.ts @@ -177,6 +177,23 @@ export const dailyDigestResponse = z .describe( "The Today hero's lead line, resolved: the reaction line or a briefing sentence while their capabilities are available, otherwise a deterministic sentence about the day's strongest signal (a vital outside its personal range, an unusual night, vitals in range, last night). Null when there is nothing to say. When the deterministic sentence is built from a fact, that fact is left out of `today`. Additive since v1.40.", ), + signalLine: z + .object({ + headline: z + .string() + .nullable() + .describe( + "The top signal's headline, or null when the lead already talks about its metric.", + ), + delta: z + .string() + .nullable() + .describe("The top signal's delta, when it has one."), + }) + .nullable() + .describe( + "The muted line under an AI lead, decided on the server so every client shows the same one: the top signal minus what the lead already says. Null under a deterministic lead, without a top signal, or when the lead already says everything the signal would. Additive since v1.40.3.", + ), today: z .array(todayFactSchema) .max(MAX_TODAY_FACTS)