mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
fix(daily): show the Today lead whole instead of cutting it mid-word
A lead past the 160-character budget was sliced at a character count, so a long briefing sentence reached the hero as "…sleep landed r…". Model text now leads only when it fits whole: the reaction line keeps the complete sentences that fit, the briefing leads with its first sentence when it fits, and otherwise the headline or the deterministic sentence takes the slot. Only when no source fits is the first sentence shortened, at a word boundary. The budget is unchanged, so the hero keeps its height.
This commit is contained in:
1 parent
a9bddca998
commit
20085d5759
2 files changed
+157
-26
No files matched your search
@@ -617,3 +617,89 @@ describe("locales", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lead length — whole sentences, never a word cut in half", () => {
|
||||
// The shape a live briefing produced: one sentence well past the lead's
|
||||
// budget, which used to be sliced mid-word ("…sleep landed r…").
|
||||
const LONG =
|
||||
"Today's picture is a calm one: your most recent blood pressure sits comfortably in the optimal band, resting heart rate is low, and last night's sleep landed right on your usual.";
|
||||
|
||||
/** Every word of the lead is a whole word of the source text. */
|
||||
function wordsAreWhole(lead: string, source: string) {
|
||||
const sourceWords = new Set(source.split(/\s+/));
|
||||
const words = lead.replace(/…$/, "").trim().split(/\s+/);
|
||||
for (const word of words) {
|
||||
expect(
|
||||
sourceWords.has(word) ||
|
||||
[...sourceWords].some((w) => w.replace(/[,;:]$/, "") === word),
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
it("leads with the first sentence when it fits", () => {
|
||||
const o = buildTodayOverview(
|
||||
input({
|
||||
briefing: briefing(
|
||||
"Your blood pressure sat in the optimal band all week. Sleep was steady too.",
|
||||
),
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(o.lead?.text).toBe(
|
||||
"Your blood pressure sat in the optimal band all week.",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not cut an over-long first sentence; the headline leads instead", () => {
|
||||
const o = buildTodayOverview(
|
||||
input({
|
||||
briefing: briefing(
|
||||
LONG,
|
||||
"Your latest blood pressure is sitting in the optimal band.",
|
||||
),
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(o.lead).toEqual({
|
||||
text: "Your latest blood pressure is sitting in the optimal band.",
|
||||
source: "briefing",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls to the deterministic sentence before it would cut model text", () => {
|
||||
const o = buildTodayOverview(
|
||||
input({ briefing: briefing(LONG), vitals: [vital()] }),
|
||||
t,
|
||||
);
|
||||
expect(o.lead?.source).toBe("signal");
|
||||
expect(o.lead?.text).not.toContain("…");
|
||||
});
|
||||
|
||||
it("shortens at a word boundary only when nothing else is left", () => {
|
||||
const o = buildTodayOverview(input({ briefing: briefing(LONG) }), t);
|
||||
const text = o.lead?.text ?? "";
|
||||
expect(text.length).toBeLessThanOrEqual(160);
|
||||
expect(text.endsWith("…")).toBe(true);
|
||||
wordsAreWhole(text, LONG);
|
||||
expect(text).not.toMatch(/[,;:]…$/);
|
||||
});
|
||||
|
||||
it("keeps whole reaction sentences that fit and drops the one that does not", () => {
|
||||
const o = buildTodayOverview(
|
||||
input({
|
||||
reactionLine: `A solid night, deeper than your recent stretch. ${LONG}`,
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(o.lead).toEqual({
|
||||
text: "A solid night, deeper than your recent stretch.",
|
||||
source: "reaction",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps briefingLead word-safe for the push line", () => {
|
||||
const lead = firstSubstantiveSentence(LONG, null) ?? "";
|
||||
expect(lead.length).toBeLessThanOrEqual(160);
|
||||
wordsAreWhole(lead, LONG);
|
||||
});
|
||||
});
|
||||
@@ -249,45 +249,74 @@ function repeatsScore(sentence: string, score: number | null): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function clampLead(sentence: string): string {
|
||||
return sentence.length > MAX_LEAD_LENGTH
|
||||
? `${sentence.slice(0, MAX_LEAD_LENGTH - 1).trimEnd()}…`
|
||||
: sentence;
|
||||
/** Whether a sentence (or a run of them) fits the lead's length budget. */
|
||||
function fitsLead(text: string): boolean {
|
||||
return text.length <= MAX_LEAD_LENGTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorten text that does not fit, at a word boundary, as the last resort.
|
||||
*
|
||||
* Slicing at a character count cut words in half ("…sleep landed r…"). The
|
||||
* cut now falls on the last space inside the budget, and trailing clause
|
||||
* punctuation goes with it, so what is left ends on a whole word.
|
||||
*/
|
||||
function shortenAtWord(text: string): string {
|
||||
if (fitsLead(text)) return text;
|
||||
const room = text.slice(0, MAX_LEAD_LENGTH);
|
||||
const space = room.lastIndexOf(" ");
|
||||
const head = (space > 0 ? room.slice(0, space) : room)
|
||||
.trimEnd()
|
||||
.replace(/[\s,;:–—-]+$/u, "");
|
||||
return `${head}…`;
|
||||
}
|
||||
|
||||
/** The sentences of a paragraph that say something, in order. */
|
||||
function substantiveSentences(
|
||||
paragraph: string | null | undefined,
|
||||
score: number | null,
|
||||
): string[] {
|
||||
if (!paragraph) return [];
|
||||
return sentences(paragraph).filter(
|
||||
(s) => !isGreetingOnly(s) && !repeatsScore(s, score),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The first sentence of a paragraph that says something: not a greeting, and
|
||||
* not a restatement of the score inside the ring. Null when no sentence
|
||||
* qualifies, so the caller falls through to the next source rather than
|
||||
* showing filler.
|
||||
* showing filler. A sentence past the length budget is shortened at a word
|
||||
* boundary; it feeds `briefingLead` and the push line, which have no other
|
||||
* source to fall to.
|
||||
*/
|
||||
export function firstSubstantiveSentence(
|
||||
paragraph: string | null | undefined,
|
||||
score: number | null,
|
||||
): string | null {
|
||||
if (!paragraph) return null;
|
||||
for (const sentence of sentences(paragraph)) {
|
||||
if (isGreetingOnly(sentence)) continue;
|
||||
if (repeatsScore(sentence, score)) continue;
|
||||
return clampLead(sentence);
|
||||
}
|
||||
return null;
|
||||
const first = substantiveSentences(paragraph, score)[0];
|
||||
return first ? shortenAtWord(first) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The model's reaction line, minus any greeting or score sentence. Unlike the
|
||||
* briefing it may keep more than one sentence: it is written to be read whole.
|
||||
* It keeps whole sentences from the start while they fit the budget, and is
|
||||
* null when even the first one does not, so the lead falls to a source that
|
||||
* can be shown complete.
|
||||
*/
|
||||
function cleanReactionLine(
|
||||
line: string | null,
|
||||
score: number | null,
|
||||
): string | null {
|
||||
if (!line) return null;
|
||||
const kept = sentences(line).filter(
|
||||
(s) => !isGreetingOnly(s) && !repeatsScore(s, score),
|
||||
);
|
||||
const text = kept.join(" ").replace(/\s+/g, " ").trim();
|
||||
return text.length > 0 ? clampLead(text) : null;
|
||||
let text = "";
|
||||
for (const sentence of substantiveSentences(line, score)) {
|
||||
const next = text ? `${text} ${sentence}` : sentence;
|
||||
if (!fitsLead(next.replace(/\s+/g, " "))) break;
|
||||
text = next.replace(/\s+/g, " ");
|
||||
}
|
||||
return text.length > 0 ? text : null;
|
||||
}
|
||||
|
||||
/** Vitals that may speak about today: module on, reading current. */
|
||||
@@ -562,6 +591,11 @@ function cycleFact(input: TodayOverviewInput, t: Translate): TodayFact | null {
|
||||
* their capabilities are available; the deterministic sentence is what makes
|
||||
* the card whole without them.
|
||||
*
|
||||
* A lead is shown whole or not at all: model text that does not fit
|
||||
* {@link MAX_LEAD_LENGTH} gives way to the next source, and only when no
|
||||
* source fits is the first model sentence shortened, at a word boundary. The
|
||||
* budget is also what keeps the hero's height steady from day to day.
|
||||
*
|
||||
* Facts follow a fixed priority (Rest Mode, medications, an appointment today
|
||||
* or tomorrow, last night, vitals, cycle) and are capped at
|
||||
* {@link MAX_TODAY_FACTS}. The list is never padded: a quiet account gets as
|
||||
@@ -573,26 +607,37 @@ export function buildTodayOverview(
|
||||
): TodayOverview {
|
||||
const sleep = moduleOn(input.modules, "sleep") ? input.sleep : null;
|
||||
|
||||
const briefingLead = firstSubstantiveSentence(
|
||||
input.briefing?.paragraph,
|
||||
input.scoreValue,
|
||||
);
|
||||
const briefingSentence =
|
||||
substantiveSentences(input.briefing?.paragraph, input.scoreValue)[0] ??
|
||||
null;
|
||||
const briefingLead = briefingSentence
|
||||
? shortenAtWord(briefingSentence)
|
||||
: null;
|
||||
const reaction = cleanReactionLine(input.reactionLine, input.scoreValue);
|
||||
const headline = input.briefing?.signalsOfDay?.[0]?.headline?.trim() || null;
|
||||
const rawHeadline =
|
||||
input.briefing?.signalsOfDay?.[0]?.headline?.trim() || null;
|
||||
const headline =
|
||||
rawHeadline && !repeatsScore(rawHeadline, input.scoreValue)
|
||||
? rawHeadline
|
||||
: null;
|
||||
|
||||
let lead: TodayLead | null = null;
|
||||
let consumed: TodayFactKind | null = null;
|
||||
if (reaction) {
|
||||
lead = { text: reaction, source: "reaction" };
|
||||
} else if (briefingLead) {
|
||||
lead = { text: briefingLead, source: "briefing" };
|
||||
} else if (headline && !repeatsScore(headline, input.scoreValue)) {
|
||||
lead = { text: clampLead(headline), source: "briefing" };
|
||||
} else if (briefingSentence && fitsLead(briefingSentence)) {
|
||||
lead = { text: briefingSentence, source: "briefing" };
|
||||
} else if (headline && fitsLead(headline)) {
|
||||
lead = { text: headline, source: "briefing" };
|
||||
} else {
|
||||
const signal = signalLead(input, sleep, t);
|
||||
if (signal) {
|
||||
lead = { text: signal.text, source: "signal" };
|
||||
consumed = signal.consumes;
|
||||
} else if (briefingLead) {
|
||||
lead = { text: briefingLead, source: "briefing" };
|
||||
} else if (headline) {
|
||||
lead = { text: shortenAtWord(headline), source: "briefing" };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user