mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
fix(dashboard): keep numbers out of the morning push line
The digest line is the body of the morning push and lands on a locked phone. It took the briefing's first sentence, the top signal's headline or the deterministic signal sentence verbatim, so a resting heart rate or a blood pressure average could sit on the lock screen, and its score floor stated the score. A candidate with a figure in it is now passed over for the next, and the floor says the score is ready instead of what it is. The hero's lead in the app keeps its numbers. Tests that pinned the numeric line are turned around, and the push builder is checked on a digest whose lead carries a reading.
This commit is contained in:
1 parent
bd8e05e484
commit
bfcf71b7eb
14 files changed
+97
-38
No files matched your search
@@ -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:
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
+2
-2
@@ -10923,8 +10923,8 @@
|
||||
},
|
||||
"daily": {
|
||||
"line": {
|
||||
"score": "오늘 건강 점수는 {score}점이에요.",
|
||||
"allClear": "오늘은 신경 쓸 일이 없어요."
|
||||
"allClear": "오늘은 신경 쓸 일이 없어요.",
|
||||
"scoreReady": "오늘의 건강 점수가 준비됐어요."
|
||||
},
|
||||
"push": {
|
||||
"title": "아침 브리핑",
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
+17
-4
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user