diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d2b4000fa..649ff97ca 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -72588,7 +72588,8 @@ components: since v1.40." line: type: string - description: "Push / lock-screen line: cached-AI lead with a deterministic floor." + description: "Push / lock-screen line: cached-AI lead with a deterministic floor. Carries no number (no reading, dose or + score), since it is shown on a locked phone; the in-app `lead` keeps its figures." worthALook: type: array items: diff --git a/messages/de.json b/messages/de.json index 3091393ce..11191b8b7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10923,8 +10923,8 @@ }, "daily": { "line": { - "score": "Dein Gesundheitsscore heute liegt bei {score}.", - "allClear": "Heute braucht nichts deine Aufmerksamkeit." + "allClear": "Heute braucht nichts deine Aufmerksamkeit.", + "scoreReady": "Dein Gesundheitsscore für heute ist da." }, "push": { "title": "Dein Morgenüberblick", diff --git a/messages/en.json b/messages/en.json index 1339fe560..e896dddb1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10923,8 +10923,8 @@ }, "daily": { "line": { - "score": "Your health score today is {score}.", - "allClear": "Nothing needs your attention today." + "allClear": "Nothing needs your attention today.", + "scoreReady": "Your health score for today is ready." }, "push": { "title": "Your morning briefing", diff --git a/messages/es.json b/messages/es.json index 6f58f002e..48292e051 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10923,8 +10923,8 @@ }, "daily": { "line": { - "score": "Tu puntuación de salud hoy es {score}.", - "allClear": "Hoy nada necesita tu atención." + "allClear": "Hoy nada necesita tu atención.", + "scoreReady": "Tu puntuación de salud de hoy está lista." }, "push": { "title": "Tu resumen matinal", diff --git a/messages/fr.json b/messages/fr.json index 4d9b4945b..65ebe47b9 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10923,8 +10923,8 @@ }, "daily": { "line": { - "score": "Ton score de santé aujourd'hui est de {score}.", - "allClear": "Rien ne requiert ton attention aujourd'hui." + "allClear": "Rien ne requiert ton attention aujourd'hui.", + "scoreReady": "Ton score de santé du jour est prêt." }, "push": { "title": "Votre résumé du matin", diff --git a/messages/it.json b/messages/it.json index 4551242b1..9b28b906f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10923,8 +10923,8 @@ }, "daily": { "line": { - "score": "Il tuo punteggio di salute oggi è {score}.", - "allClear": "Oggi nulla richiede la tua attenzione." + "allClear": "Oggi nulla richiede la tua attenzione.", + "scoreReady": "Il tuo punteggio di salute di oggi è pronto." }, "push": { "title": "Il tuo riepilogo mattutino", diff --git a/messages/ko.json b/messages/ko.json index dd9af50b9..b61d68e7b 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10923,8 +10923,8 @@ }, "daily": { "line": { - "score": "오늘 건강 점수는 {score}점이에요.", - "allClear": "오늘은 신경 쓸 일이 없어요." + "allClear": "오늘은 신경 쓸 일이 없어요.", + "scoreReady": "오늘의 건강 점수가 준비됐어요." }, "push": { "title": "아침 브리핑", diff --git a/messages/pl.json b/messages/pl.json index b304b7b5c..9dce0db36 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10923,8 +10923,8 @@ }, "daily": { "line": { - "score": "Twój dzisiejszy wynik zdrowia to {score}.", - "allClear": "Dziś nic nie wymaga Twojej uwagi." + "allClear": "Dziś nic nie wymaga Twojej uwagi.", + "scoreReady": "Twój dzisiejszy wynik zdrowia jest gotowy." }, "push": { "title": "Twoje poranne podsumowanie", diff --git a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts index d031de698..d2af8a35f 100644 --- a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts +++ b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts @@ -501,13 +501,12 @@ describe("a settings change is never narrated as a health event", () => { expect(digest.score).not.toBeNull(); expect(digest.score!.delta).toBeNull(); expect(digest.score!.deltaReason).toBe("config_changed"); - // The push body is `digest.line` verbatim. It states today's number - // and nothing about its movement, which is the only honest sentence - // available across a recipe change. - expect(digest.line).toBe( - `Your health score today is ${snapshot.healthScore!.score}.`, - ); - expect(digest.line).not.toMatch(/[-−]\s?\d/); + // The push body is `digest.line` verbatim. It says the score is ready + // and nothing about its value or movement: no number reaches a locked + // phone, and no subtraction crosses a recipe change. + expect(snapshot.healthScore).not.toBeNull(); + expect(digest.line).toBe("Your health score for today is ready."); + expect(digest.line).not.toMatch(/\d/); // The rail is the other place a drop could reach a person. Nothing // in it may be narrating the score either. expect(digest.worthALook.map((item) => item.kind)).not.toContain( diff --git a/src/lib/daily/__tests__/daily-briefing-push.test.ts b/src/lib/daily/__tests__/daily-briefing-push.test.ts index de9fa0537..c1464ebf5 100644 --- a/src/lib/daily/__tests__/daily-briefing-push.test.ts +++ b/src/lib/daily/__tests__/daily-briefing-push.test.ts @@ -105,10 +105,10 @@ describe("buildDailyBriefingPush", () => { const digest = makeDigest({ briefingLead: null, topSignal: null, - line: "Your health score today is 82.", + line: "Your health score for today is ready.", }); const { body } = buildDailyBriefingPush(digest, t); - expect(body).toBe("Your health score today is 82."); + expect(body).toBe("Your health score for today is ready."); }); it("provisional: appends the honest sleep-pending wording", () => { diff --git a/src/lib/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts index 89ac1f62b..5bbcadf50 100644 --- a/src/lib/daily/__tests__/digest.test.ts +++ b/src/lib/daily/__tests__/digest.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { buildDailyBriefingPush } from "@/lib/daily/daily-briefing-push"; import type { DailyBriefing } from "@/lib/ai/schema"; import type { MedsTodayBlock } from "@/lib/dashboard/meds-today"; import { @@ -136,11 +137,54 @@ describe("buildDailyDigest — composition", () => { expect(d.line).toBe("Blood pressure is holding steady"); }); - it("falls back to a deterministic score floor when no briefing exists", () => { + it("falls back to a score floor without the number when no briefing exists", () => { const d = buildDailyDigest(input({ briefing: null }), t); expect(d.topSignal).toBeNull(); expect(d.briefingLead).toBeNull(); - expect(d.line).toBe("Your health score today is 82."); + expect(d.line).toBe("Your health score for today is ready."); + }); + + it("keeps concrete values off the lock screen and in the app", () => { + // The briefing's first sentence names a reading. In the app it leads the + // hero with its number; the push line, shown on a locked phone, skips + // it for the next sentence source that carries none. + const d = buildDailyDigest( + input({ + briefing: { + ...briefing, + paragraph: + "Your blood pressure averaged 128/82 this week. Sleep dipped slightly last night.", + signalsOfDay: [ + { + ...briefing.signalsOfDay![0], + headline: "Resting pulse at 52 bpm", + }, + ], + }, + }), + t, + ); + expect(d.lead?.text).toBe("Your blood pressure averaged 128/82 this week."); + expect(d.line).not.toMatch(/\d/); + expect(d.line).toBe("Your health score for today is ready."); + // The push is built from that line, final or provisional. + expect(buildDailyBriefingPush(d, t).body).not.toMatch(/\d/); + expect( + buildDailyBriefingPush({ ...d, sleepPending: true }, t).body, + ).not.toMatch(/\d/); + }); + + it("uses a numberless headline for the push when the lead has a number", () => { + const d = buildDailyDigest( + input({ + briefing: { + ...briefing, + paragraph: "Your resting heart rate is 52 this morning.", + }, + }), + t, + ); + expect(d.line).toBe("Blood pressure is holding steady"); }); it("degrades to the honest all-clear line with neither briefing nor score", () => { @@ -1329,7 +1373,7 @@ describe("buildDailyDigest — AI text follows its capability", () => { expect(d.briefingLead).toBeNull(); expect(d.topSignal).toBeNull(); // The push line falls to the deterministic floor, never to model text. - expect(d.line).toBe("Your health score today is 82."); + expect(d.line).toBe("Your health score for today is ready."); expect(d.ai.briefing.reason).toBe(reason); }, ); @@ -1513,8 +1557,9 @@ describe("buildDailyDigest — Today overview", () => { text: "Resting heart rate is at 61 bpm, above your usual range of 50 to 58 bpm.", source: "signal", }); - // The push line takes the same sentence, not "Your health score today is 82." - expect(d.line).toBe(d.lead?.text); + // The lead names the reading in the app. The push line is shown on a + // locked phone and carries no value, so it does not take that sentence. + expect(d.line).toBe("Your health score for today is ready."); }); it("publishes Rest Mode and its fact only while the illness module is on", () => { diff --git a/src/lib/daily/daily-briefing-push.ts b/src/lib/daily/daily-briefing-push.ts index a64266730..003854f8d 100644 --- a/src/lib/daily/daily-briefing-push.ts +++ b/src/lib/daily/daily-briefing-push.ts @@ -16,10 +16,11 @@ * `ok` ledger row; the frequency-cap check suppresses the second — one push per * user per local day, no "you haven't opened the app" second push, ever. * - * Privacy + calm posture (load-bearing): the body is `digest.line`, which is - * non-clinical by construction (a briefing lead sentence, the top signal's - * headline, a 0–100 score statement, or the honest all-clear — never a raw BP / - * glucose figure). It is dispatched NON-URGENT, so it never escalates to a + * Privacy + calm posture (load-bearing): the body is `digest.line`, which + * carries no number by construction (a briefing lead sentence or the top + * signal's headline without a figure in it, "your score is ready", or the + * honest all-clear — never a reading, a dose or the score itself, since the + * push lands on a locked phone). It is dispatched NON-URGENT, so it never escalates to a * Focus-bypassing / time-sensitive delivery — a concerning reading always stays * on `MEASUREMENT_ANOMALY`, never here. It fires ONLY inside a local morning * window, which is the quiet-hours guarantee for this nudge. diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index 59c46ffe8..7a9c6cdb8 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -1009,6 +1009,9 @@ function buildEcgNewRecordingItem( }; } +/** Whether a sentence carries a figure: a reading, a dose, a score. */ +const HAS_NUMBER = /\p{Nd}/u; + /** * The push / lock-screen line: the warmer cached briefing lead first, then * the top signal's headline, then the deterministic sentence about the day's @@ -1017,6 +1020,11 @@ function buildEcgNewRecordingItem( * during the day, and the morning push is sent before anything has. NEVER a * fresh AI call — every branch reads cache or a deterministic string, so a * keyless self-hoster still gets a first-class line. + * + * The line is shown on a locked phone, so it carries no number: a candidate + * with a figure in it is passed over for the next, and the score floor says + * the score is ready rather than what it is. The hero's lead keeps its + * numbers; it is read inside the app. */ function composeLine( overview: { briefingLead: string | null; lead: TodayLead | null }, @@ -1024,10 +1032,15 @@ function composeLine( score: DailyDigestScore | null, t: Translate, ): string { - if (overview.briefingLead) return overview.briefingLead; - if (topSignal?.headline) return topSignal.headline.trim(); - if (overview.lead?.source === "signal") return overview.lead.text; - if (score) return t("daily.line.score", { score: score.value }); + const candidates = [ + overview.briefingLead, + topSignal?.headline?.trim() || null, + overview.lead?.source === "signal" ? overview.lead.text : null, + ]; + for (const candidate of candidates) { + if (candidate && !HAS_NUMBER.test(candidate)) return candidate; + } + if (score) return t("daily.line.scoreReady"); return t("daily.line.allClear"); } diff --git a/src/lib/openapi/routes/daily.ts b/src/lib/openapi/routes/daily.ts index df379a80f..86e057173 100644 --- a/src/lib/openapi/routes/daily.ts +++ b/src/lib/openapi/routes/daily.ts @@ -198,7 +198,7 @@ export const dailyDigestResponse = z line: z .string() .describe( - "Push / lock-screen line: cached-AI lead with a deterministic floor.", + "Push / lock-screen line: cached-AI lead with a deterministic floor. Carries no number (no reading, dose or score), since it is shown on a locked phone; the in-app `lead` keeps its figures.", ), worthALook: z .array(priorityItemSchema)