mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
20085d5759
commit
524975d5f3
13 files changed
+265
-27
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<string, DailyDigest> = {
|
||||
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: [
|
||||
{
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -37,6 +37,7 @@ function makeDigest(over: Partial<DailyDigest> = {}): 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: [],
|
||||
|
||||
@@ -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<NonNullable<DailyBriefing["signalsOfDay"]>[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();
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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<DailyBriefingSignal["sourceMetric"], RegExp>
|
||||
> = {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user