fix(daily): decide the line under the Today lead on the server

The muted line under the lead repeated what the lead had just said: a lead
about blood pressure sat above "Your latest blood pressure is sitting in
the optimal band · ↓ ~10 mmHg…". The web client only dropped the headline
when the lead quoted it word for word, and other clients had no rule at
all. The digest now carries `signalLine`, built next to the lead: when the
lead already talks about the top signal's metric (matched in every shipped
language) or says its headline, only the delta remains, and with no delta
there is no line. The hero renders the field verbatim.
This commit is contained in:
Marc-André Bombeck committed 2026-10-05 12:19:41 +02:00
1 parent 20085d5759
commit 524975d5f3
13 files changed
+265 -27

No files matched your search

@@ -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: [],
@@ -41,6 +41,7 @@ function digest(over: Partial<DailyDigest> = {}): 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: [],
@@ -71,6 +71,12 @@ function digest(over: Partial<DailyDigest> = {}): 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("<TodayHero>", () => {
lead: null,
briefingLead: null,
topSignal: null,
signalLine: null,
reactionLine: "Score 82 today.",
})}
/>,
@@ -233,6 +240,7 @@ describe("<TodayHero>", () => {
nudge: "",
delta: null,
},
signalLine: null,
})}
/>,
);
@@ -270,6 +278,7 @@ describe("<TodayHero>", () => {
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("<TodayHero>", () => {
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("<TodayHero>", () => {
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("<TodayHero>", () => {
<TodayHero
digest={digest({
topSignal: null,
signalLine: null,
briefingLead: null,
lead: null,
reactionLine: null,
@@ -378,6 +393,7 @@ describe("<TodayHero>", () => {
<TodayHero
digest={digest({
topSignal: null,
signalLine: null,
worthALook: [],
})}
/>,
@@ -443,6 +459,7 @@ describe("<TodayHero>", () => {
digest={digest({
score: null,
topSignal: null,
signalLine: null,
briefingLead: null,
lead: null,
worthALook: [],
@@ -596,6 +613,7 @@ describe("<TodayHero> Today overview", () => {
digest={digest({
score: null,
topSignal: null,
signalLine: null,
briefingLead: null,
lead: null,
worthALook: [],
@@ -615,6 +633,7 @@ describe("<TodayHero> Today overview", () => {
text: "All 4 of your latest vitals sit inside their usual range.",
source: "signal",
},
signalLine: null,
})}
/>,
);
+6 -26
View File
@@ -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