mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge remote-tracking branch 'origin/feat/dashboard-today-overview' into release/v1.40.2
This commit is contained in:
commit
98ac9c3d80
28 files changed
+3837
-170
No files matched your search
+95
-2
@@ -72427,6 +72427,15 @@ components:
|
||||
so this is what distinguishes a narrow score from a broad one. Optional so older cached digests
|
||||
without the field stay valid.
|
||||
$ref: "#/components/schemas/HealthScoreBasis"
|
||||
steadyWeeks:
|
||||
description: Whole weeks the score has held where it is (same band, within two points, computed the same way), read off
|
||||
the stored daily scores. Null when it has not held for two weeks, when the stored record does not
|
||||
reach today, or when the score moved. Shown in place of a delta, never beside one.
|
||||
anyOf:
|
||||
- type: integer
|
||||
exclusiveMinimum: 0
|
||||
maximum: 9007199254740991
|
||||
- type: "null"
|
||||
required:
|
||||
- value
|
||||
- band
|
||||
@@ -72469,8 +72478,56 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: First sentence of the cached briefing paragraph. Null while the `briefing` capability is unavailable;
|
||||
`line` then falls to its deterministic floor.
|
||||
description: "The first sentence of the cached briefing paragraph that says something: a greeting or a sentence that
|
||||
only repeats the score is skipped. Null while the `briefing` capability is unavailable or when no sentence
|
||||
qualifies; `line` then falls to its deterministic floor."
|
||||
lead:
|
||||
anyOf:
|
||||
- type: object
|
||||
properties:
|
||||
text:
|
||||
type: string
|
||||
source:
|
||||
type: string
|
||||
enum:
|
||||
- reaction
|
||||
- briefing
|
||||
- signal
|
||||
description: "`reaction` and `briefing` are cached model text; `signal` is the deterministic sentence about the day's
|
||||
strongest signal."
|
||||
required:
|
||||
- text
|
||||
- source
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
description: "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."
|
||||
today:
|
||||
maxItems: 5
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DailyTodayFact"
|
||||
description: "The Today overview: up to 5 statements about the day in priority order (Rest Mode, medications taken
|
||||
today, an appointment today or tomorrow that is not already on the rail, last night's sleep, vitals against
|
||||
their personal range, cycle day), never padded. A fact about a switched-off module is absent. A narrow
|
||||
surface shows the first 4. Additive since v1.40."
|
||||
restMode:
|
||||
anyOf:
|
||||
- type: object
|
||||
properties:
|
||||
day:
|
||||
type: integer
|
||||
exclusiveMinimum: 0
|
||||
maximum: 9007199254740991
|
||||
description: 1-based day of the episode on the reader's calendar.
|
||||
required:
|
||||
- day
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
description: "Rest Mode: present while an illness episode is active and the illness module is on, else null. Additive
|
||||
since v1.40."
|
||||
line:
|
||||
type: string
|
||||
description: "Push / lock-screen line: cached-AI lead with a deterministic floor."
|
||||
@@ -72535,6 +72592,9 @@ components:
|
||||
- score
|
||||
- topSignal
|
||||
- briefingLead
|
||||
- lead
|
||||
- today
|
||||
- restMode
|
||||
- line
|
||||
- worthALook
|
||||
- justIn
|
||||
@@ -72543,6 +72603,39 @@ components:
|
||||
additionalProperties: false
|
||||
description: The one data spine of the daily-value system — composed from already-cached data, never a fresh AI call.
|
||||
Consumed by the Today surface, the daily push, and a future iOS widget.
|
||||
DailyTodayFact:
|
||||
type: object
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- rest_mode
|
||||
- medications
|
||||
- appointment
|
||||
- sleep
|
||||
- vitals
|
||||
- cycle
|
||||
description: Closed fact kind.
|
||||
label:
|
||||
type: string
|
||||
description: Localised label (resolved server-side).
|
||||
value:
|
||||
type: string
|
||||
description: Localised value (resolved server-side).
|
||||
href:
|
||||
type: string
|
||||
description: The page the fact belongs to.
|
||||
moduleKey:
|
||||
description: The module that admitted the fact; absent for core data.
|
||||
type: string
|
||||
required:
|
||||
- kind
|
||||
- label
|
||||
- value
|
||||
- href
|
||||
additionalProperties: false
|
||||
description: One statement about the day on the Today overview. A fact never asks the reader to do anything; the rail is
|
||||
the only place that does.
|
||||
DailyPriorityItem:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -115,6 +115,9 @@ test.describe("authenticated dashboard render", () => {
|
||||
score: { value: 82, band: "green", delta: 2 },
|
||||
topSignal: null,
|
||||
briefingLead: "Your week is trending steady.",
|
||||
lead: { text: "Your week is trending steady.", source: "briefing" },
|
||||
today: [],
|
||||
restMode: null,
|
||||
line: "Your week is trending steady.",
|
||||
worthALook: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import {
|
||||
mockDashboardSnapshot,
|
||||
POPULATED_SUMMARIES,
|
||||
} from "./utils/mock-dashboard-snapshot";
|
||||
import type { DailyDigest } from "@/lib/daily/digest";
|
||||
import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures";
|
||||
|
||||
/**
|
||||
* The Today overview on the dashboard hero, at a desktop and a phone width.
|
||||
*
|
||||
* What it proves is geometry, because that is what the unit suites cannot
|
||||
* see: the facts sit beside the ring on a wide screen and above it on a
|
||||
* phone, a phone shows four facts and a wide screen five, nothing pushes the
|
||||
* page sideways, the hero keeps the width of the cards around it, and the
|
||||
* ring keeps its size. Each state is also captured as an element screenshot
|
||||
* under `test-results/today-overview/` for a visual check.
|
||||
*
|
||||
* The digests are what the server would publish for each state. They carry
|
||||
* the pre-overview fields too (`briefingLead`, `line`), with the values the
|
||||
* old server sent, so the same fixtures render the previous hero on an older
|
||||
* build for a before/after comparison.
|
||||
*
|
||||
* Assertions anchor on `data-slot` / `data-kind`, never on visible text.
|
||||
*/
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
const SHOTS = join(process.cwd(), "test-results", "today-overview");
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ name: "desktop", width: 1440, height: 900 },
|
||||
{ name: "mobile", width: 390, height: 844 },
|
||||
] as const;
|
||||
|
||||
const BASE: DailyDigest = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
ai: DIGEST_AI_AVAILABLE,
|
||||
phase: "final",
|
||||
sleepPending: false,
|
||||
score: {
|
||||
value: 94,
|
||||
band: "green",
|
||||
delta: null,
|
||||
deltaReason: "first_eligibility_window",
|
||||
steadyWeeks: 4,
|
||||
},
|
||||
topSignal: null,
|
||||
briefingLead: null,
|
||||
lead: null,
|
||||
today: [],
|
||||
restMode: null,
|
||||
line: "Your health score today is 94.",
|
||||
worthALook: [],
|
||||
justIn: null,
|
||||
reactionLine: null,
|
||||
};
|
||||
|
||||
const FACTS: DailyDigest["today"] = [
|
||||
{
|
||||
kind: "medications",
|
||||
label: "Medications",
|
||||
value: "1 of 3 taken",
|
||||
href: "/medications",
|
||||
moduleKey: "medications",
|
||||
},
|
||||
{
|
||||
kind: "appointment",
|
||||
label: "Appointment",
|
||||
value: "Tomorrow 09:30, Routine visit",
|
||||
href: "/checkups",
|
||||
},
|
||||
{
|
||||
kind: "sleep",
|
||||
label: "Last night",
|
||||
value: "7h 20m, close to your usual",
|
||||
href: "/insights/sleep",
|
||||
moduleKey: "sleep",
|
||||
},
|
||||
{
|
||||
kind: "vitals",
|
||||
label: "Vitals",
|
||||
value: "6 checked, all in your range",
|
||||
href: "/insights",
|
||||
},
|
||||
{
|
||||
kind: "cycle",
|
||||
label: "Cycle",
|
||||
value: "Follicular, day 12",
|
||||
href: "/cycle",
|
||||
moduleKey: "cycle",
|
||||
},
|
||||
];
|
||||
|
||||
const STATES: Record<string, DailyDigest> = {
|
||||
// AI on. The old server led with the briefing's first sentence, which was
|
||||
// the greeting.
|
||||
"ai-on": {
|
||||
...BASE,
|
||||
briefingLead: "Good morning.",
|
||||
line: "Good morning.",
|
||||
topSignal: {
|
||||
sourceMetric: "bp",
|
||||
tone: "good",
|
||||
headline: "Blood pressure is sitting in the optimal band",
|
||||
nudge: "",
|
||||
delta: "↓ ~10 mmHg systolic vs the start of the window",
|
||||
},
|
||||
lead: {
|
||||
text: "Your latest blood pressure is sitting in the optimal band.",
|
||||
source: "briefing",
|
||||
},
|
||||
today: FACTS,
|
||||
worthALook: [
|
||||
{
|
||||
kind: "dose_window",
|
||||
title: "Medication due",
|
||||
body: "Ramipril is due soon.",
|
||||
status: "info",
|
||||
actions: [
|
||||
{
|
||||
labelKey: "daily.action.logDose",
|
||||
intent: "dose.log",
|
||||
href: "/medications",
|
||||
},
|
||||
],
|
||||
moduleKey: "medications",
|
||||
},
|
||||
],
|
||||
},
|
||||
// AI off. The old server had only the score sentence, which the hero then
|
||||
// removed again, leaving the ring and "Nothing needs your attention".
|
||||
"ai-off": {
|
||||
...BASE,
|
||||
ai: {
|
||||
...DIGEST_AI_AVAILABLE,
|
||||
briefing: {
|
||||
available: false,
|
||||
reason: "no_provider",
|
||||
onDeviceAllowed: false,
|
||||
},
|
||||
},
|
||||
lead: {
|
||||
text: "Resting heart rate is at 61 bpm, above your usual range of 50 to 58 bpm.",
|
||||
source: "signal",
|
||||
},
|
||||
today: [FACTS[0], FACTS[2], FACTS[4]],
|
||||
},
|
||||
// A quiet day: one calm sentence and no facts beyond it.
|
||||
quiet: {
|
||||
...BASE,
|
||||
lead: {
|
||||
text: "All 6 of your latest vitals sit inside their usual range.",
|
||||
source: "signal",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async function openDashboard(page: Page, digest: DailyDigest) {
|
||||
await mockDashboardSnapshot(page, { summaries: POPULATED_SUMMARIES });
|
||||
await page.route(/\/api\/daily\/digest(\?|$)/, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: digest, error: null }),
|
||||
}),
|
||||
);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const hero = page.locator('[data-slot="today-hero"]');
|
||||
await expect(hero).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator('[data-slot="dashboard-tile-strip"]')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
return hero;
|
||||
}
|
||||
|
||||
test.describe("Today overview geometry", () => {
|
||||
test.beforeEach(({}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-desktop",
|
||||
"viewport-driven spec; desktop project only",
|
||||
);
|
||||
});
|
||||
|
||||
for (const viewport of VIEWPORTS) {
|
||||
for (const [state, digest] of Object.entries(STATES)) {
|
||||
test(`${state} at ${viewport.width}px`, async ({ page }) => {
|
||||
await page.setViewportSize(viewport);
|
||||
const hero = await openDashboard(page, digest);
|
||||
|
||||
mkdirSync(SHOTS, { recursive: true });
|
||||
await hero.screenshot({
|
||||
path: join(SHOTS, `${viewport.name}-${state}.png`),
|
||||
});
|
||||
|
||||
// Nothing pushes the page sideways.
|
||||
const overflow = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth - window.innerWidth,
|
||||
);
|
||||
expect(overflow).toBeLessThanOrEqual(0);
|
||||
|
||||
// The hero spans the same column as the tile strip below it.
|
||||
const heroBox = (await hero.boundingBox())!;
|
||||
const stripBox = (await page
|
||||
.locator('[data-slot="dashboard-tile-strip"]')
|
||||
.boundingBox())!;
|
||||
expect(Math.abs(heroBox.x - stripBox.x)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(heroBox.width - stripBox.width)).toBeLessThanOrEqual(1);
|
||||
|
||||
// The ring keeps its size: the same 168 px dial as before.
|
||||
const ring = hero.locator('[data-slot="score-ring"]').first();
|
||||
const ringBox = (await ring.boundingBox())!;
|
||||
expect(Math.round(ringBox.width)).toBe(168);
|
||||
expect(Math.round(ringBox.height)).toBe(168);
|
||||
|
||||
const lead = hero.locator('[data-slot="today-hero-lead"]');
|
||||
await expect(lead).toBeVisible();
|
||||
await expect(lead).toHaveAttribute("data-source", digest.lead!.source);
|
||||
const leadBox = (await lead.boundingBox())!;
|
||||
|
||||
const facts = hero.locator('[data-slot="today-hero-fact"]');
|
||||
const expected = digest.today.length;
|
||||
await expect(facts).toHaveCount(expected);
|
||||
const visible = await facts.evaluateAll(
|
||||
(items) =>
|
||||
items.filter((el) => (el as HTMLElement).offsetParent !== null)
|
||||
.length,
|
||||
);
|
||||
expect(visible).toBe(
|
||||
viewport.width < 768 ? Math.min(expected, 4) : expected,
|
||||
);
|
||||
|
||||
// The all-clear sentence never sits under a lead or facts.
|
||||
await expect(
|
||||
hero.locator('[data-slot="today-hero-all-clear"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
if (expected === 0) {
|
||||
await expect(
|
||||
hero.locator('[data-slot="today-hero-today"]'),
|
||||
).toHaveCount(0);
|
||||
// A quiet day keeps the full composition: lead beside the ring.
|
||||
await expect(hero).toHaveAttribute("data-layout", "narrative");
|
||||
return;
|
||||
}
|
||||
|
||||
const block = hero.locator('[data-slot="today-hero-today"]');
|
||||
const blockBox = (await block.boundingBox())!;
|
||||
// The facts follow the lead.
|
||||
expect(blockBox.y).toBeGreaterThanOrEqual(
|
||||
leadBox.y + leadBox.height - 1,
|
||||
);
|
||||
if (viewport.width >= 768) {
|
||||
// Beside the ring on a wide screen, never under it.
|
||||
expect(blockBox.x + blockBox.width).toBeLessThanOrEqual(
|
||||
ringBox.x + 1,
|
||||
);
|
||||
expect(blockBox.y).toBeLessThan(ringBox.y + ringBox.height);
|
||||
} else {
|
||||
// Above the ring on a phone, so the day reads before the dial.
|
||||
expect(blockBox.y + blockBox.height).toBeLessThanOrEqual(
|
||||
ringBox.y + 1,
|
||||
);
|
||||
}
|
||||
|
||||
// No fact line overflows its own box.
|
||||
const clipped = await facts.evaluateAll(
|
||||
(items) =>
|
||||
items.filter((el) => {
|
||||
const link = el.querySelector("a");
|
||||
return link ? link.scrollWidth > link.clientWidth + 1 : false;
|
||||
}).length,
|
||||
);
|
||||
expect(clipped).toBe(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("the hero width does not move when the overview arrives", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
let calls = 0;
|
||||
await mockDashboardSnapshot(page, { summaries: POPULATED_SUMMARIES });
|
||||
await page.route(/\/api\/daily\/digest(\?|$)/, async (route) => {
|
||||
calls += 1;
|
||||
// First paint: a quiet digest. Every later read carries the overview.
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: calls > 1 ? STATES["ai-on"] : STATES.quiet,
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const hero = page.locator('[data-slot="today-hero"]');
|
||||
await expect(hero).toBeVisible({ timeout: 15_000 });
|
||||
const before = (await hero.boundingBox())!;
|
||||
|
||||
// The tab becomes visible again: the signal TanStack Query's focus
|
||||
// manager refetches on, as `today-just-in.spec.ts` uses.
|
||||
await page.evaluate(() =>
|
||||
window.dispatchEvent(new Event("visibilitychange")),
|
||||
);
|
||||
await expect(hero.locator('[data-slot="today-hero-today"]')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
const after = (await hero.boundingBox())!;
|
||||
expect(Math.abs(after.x - before.x)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(after.width - before.width)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,9 @@ const NARRATIVE_DIGEST: DailyDigest = {
|
||||
},
|
||||
topSignal: LONG_HEADLINE_BRIEFING.signalsOfDay?.[0] ?? null,
|
||||
briefingLead: LONG_HEADLINE_BRIEFING.paragraph,
|
||||
lead: { text: LONG_HEADLINE_BRIEFING.paragraph, source: "briefing" },
|
||||
today: [],
|
||||
restMode: null,
|
||||
line: LONG_HEADLINE_BRIEFING.paragraph,
|
||||
worthALook: [],
|
||||
justIn: null,
|
||||
@@ -104,6 +107,7 @@ test.describe("v1.34.1 deterministic UI evidence", () => {
|
||||
...NARRATIVE_DIGEST,
|
||||
topSignal: null,
|
||||
briefingLead: null,
|
||||
lead: null,
|
||||
line: "Dein Gesundheitsscore liegt heute bei 84.",
|
||||
reactionLine: null,
|
||||
};
|
||||
|
||||
+83
-1
@@ -11002,7 +11002,27 @@
|
||||
"sleepPending": "Der Schlaf von letzter Nacht fehlt noch — der heutige Wert ist vorläufig.",
|
||||
"worthALook": "Einen Blick wert",
|
||||
"allClear": "Heute braucht nichts deine Aufmerksamkeit.",
|
||||
"ringLink": "{metric}-Details öffnen"
|
||||
"ringLink": "{metric}-Details öffnen",
|
||||
"heading": "Heute",
|
||||
"steadyWeeksOne": "Seit {count} Woche stabil",
|
||||
"steadyWeeksFew": "Seit {count} Wochen stabil",
|
||||
"steadyWeeksOther": "Seit {count} Wochen stabil",
|
||||
"valueWithUnit": "{value} {unit}",
|
||||
"rangeWithUnit": "{low} bis {high} {unit}",
|
||||
"vital": {
|
||||
"RESTING_HEART_RATE": "Ruhepuls",
|
||||
"HEART_RATE_VARIABILITY": "Herzfrequenzvariabilität",
|
||||
"RESPIRATORY_RATE": "Atemfrequenz",
|
||||
"OXYGEN_SATURATION": "Sauerstoffsättigung",
|
||||
"BODY_TEMPERATURE": "Körpertemperatur",
|
||||
"SKIN_TEMPERATURE": "Hauttemperatur",
|
||||
"BLOOD_PRESSURE_SYS": "Systolischer Blutdruck",
|
||||
"BLOOD_PRESSURE_DIA": "Diastolischer Blutdruck",
|
||||
"BLOOD_GLUCOSE": "Blutzucker",
|
||||
"PULSE": "Puls",
|
||||
"WEIGHT": "Gewicht",
|
||||
"generic": "Ein Vitalwert"
|
||||
}
|
||||
},
|
||||
"milestone": {
|
||||
"metric": {
|
||||
@@ -11038,6 +11058,68 @@
|
||||
"body": "Ein ganzer Monat mit {metric} in deinem gewohnten Bereich. Wunderbar gleichmäßig."
|
||||
}
|
||||
}
|
||||
},
|
||||
"lead": {
|
||||
"vitalOutside": {
|
||||
"above": "{metric} liegt bei {value}, über deinem üblichen Bereich von {range}.",
|
||||
"below": "{metric} liegt bei {value}, unter deinem üblichen Bereich von {range}."
|
||||
},
|
||||
"vitalsOutside": {
|
||||
"aboveOne": "{metric} liegt bei {value}, über deinem üblichen Bereich von {range}, und {count} weiterer Vitalwert liegt ebenfalls außerhalb.",
|
||||
"aboveFew": "{metric} liegt bei {value}, über deinem üblichen Bereich von {range}, und {count} weitere Vitalwerte liegen ebenfalls außerhalb.",
|
||||
"aboveOther": "{metric} liegt bei {value}, über deinem üblichen Bereich von {range}, und {count} weitere Vitalwerte liegen ebenfalls außerhalb.",
|
||||
"belowOne": "{metric} liegt bei {value}, unter deinem üblichen Bereich von {range}, und {count} weiterer Vitalwert liegt ebenfalls außerhalb.",
|
||||
"belowFew": "{metric} liegt bei {value}, unter deinem üblichen Bereich von {range}, und {count} weitere Vitalwerte liegen ebenfalls außerhalb.",
|
||||
"belowOther": "{metric} liegt bei {value}, unter deinem üblichen Bereich von {range}, und {count} weitere Vitalwerte liegen ebenfalls außerhalb."
|
||||
},
|
||||
"sleep": {
|
||||
"more": "Letzte Nacht hast du {duration} geschlafen, {diff} mehr als sonst.",
|
||||
"less": "Letzte Nacht hast du {duration} geschlafen, {diff} weniger als sonst.",
|
||||
"usual": "Letzte Nacht hast du {duration} geschlafen, etwa so viel wie sonst.",
|
||||
"plain": "Letzte Nacht hast du {duration} geschlafen."
|
||||
},
|
||||
"vitalsInRangeOne": "Dein letzter Vitalwert liegt in seinem üblichen Bereich.",
|
||||
"vitalsInRangeFew": "Alle {count} letzten Vitalwerte liegen in ihrem üblichen Bereich.",
|
||||
"vitalsInRangeOther": "Alle {count} letzten Vitalwerte liegen in ihrem üblichen Bereich.",
|
||||
"vitalInRange": "{metric} liegt bei {value}, innerhalb deines üblichen Bereichs von {range}."
|
||||
},
|
||||
"todayFact": {
|
||||
"restMode": {
|
||||
"label": "Ruhemodus",
|
||||
"value": "Tag {day}"
|
||||
},
|
||||
"medications": {
|
||||
"label": "Medikamente",
|
||||
"value": "{taken} von {scheduled} genommen"
|
||||
},
|
||||
"appointment": {
|
||||
"label": "Termin",
|
||||
"today": "Heute {time}, {what}",
|
||||
"tomorrow": "Morgen {time}, {what}"
|
||||
},
|
||||
"sleep": {
|
||||
"label": "Letzte Nacht",
|
||||
"plain": "{duration}",
|
||||
"usual": "{duration}, etwa wie sonst",
|
||||
"more": "{duration}, {diff} mehr als sonst",
|
||||
"less": "{duration}, {diff} weniger als sonst"
|
||||
},
|
||||
"vitals": {
|
||||
"label": "Vitalwerte",
|
||||
"allInOne": "{count} geprüft, im Bereich",
|
||||
"allInFew": "{count} geprüft, alle im Bereich",
|
||||
"allInOther": "{count} geprüft, alle im Bereich",
|
||||
"oneOut": {
|
||||
"above": "{metric} über deinem Bereich",
|
||||
"below": "{metric} unter deinem Bereich"
|
||||
},
|
||||
"someOut": "{count} von {total} außerhalb deines Bereichs"
|
||||
},
|
||||
"cycle": {
|
||||
"label": "Zyklus",
|
||||
"value": "{phase}, Tag {day}",
|
||||
"dayOnly": "Tag {day}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reportSelection": {
|
||||
|
||||
+83
-1
@@ -11002,7 +11002,27 @@
|
||||
"sleepPending": "Last night's sleep isn't in yet — today's read is provisional.",
|
||||
"worthALook": "Worth a look",
|
||||
"allClear": "Nothing needs your attention today.",
|
||||
"ringLink": "Open {metric} details"
|
||||
"ringLink": "Open {metric} details",
|
||||
"heading": "Today",
|
||||
"steadyWeeksOne": "Steady for {count} week",
|
||||
"steadyWeeksFew": "Steady for {count} weeks",
|
||||
"steadyWeeksOther": "Steady for {count} weeks",
|
||||
"valueWithUnit": "{value} {unit}",
|
||||
"rangeWithUnit": "{low} to {high} {unit}",
|
||||
"vital": {
|
||||
"RESTING_HEART_RATE": "Resting heart rate",
|
||||
"HEART_RATE_VARIABILITY": "Heart-rate variability",
|
||||
"RESPIRATORY_RATE": "Respiratory rate",
|
||||
"OXYGEN_SATURATION": "Oxygen saturation",
|
||||
"BODY_TEMPERATURE": "Body temperature",
|
||||
"SKIN_TEMPERATURE": "Skin temperature",
|
||||
"BLOOD_PRESSURE_SYS": "Systolic blood pressure",
|
||||
"BLOOD_PRESSURE_DIA": "Diastolic blood pressure",
|
||||
"BLOOD_GLUCOSE": "Blood glucose",
|
||||
"PULSE": "Pulse",
|
||||
"WEIGHT": "Weight",
|
||||
"generic": "A vital"
|
||||
}
|
||||
},
|
||||
"milestone": {
|
||||
"metric": {
|
||||
@@ -11038,6 +11058,68 @@
|
||||
"body": "A full month with your {metric} in your usual range. Beautifully steady."
|
||||
}
|
||||
}
|
||||
},
|
||||
"lead": {
|
||||
"vitalOutside": {
|
||||
"above": "{metric} is at {value}, above your usual range of {range}.",
|
||||
"below": "{metric} is at {value}, below your usual range of {range}."
|
||||
},
|
||||
"vitalsOutside": {
|
||||
"aboveOne": "{metric} is at {value}, above your usual range of {range}, and {count} more vital is outside its range too.",
|
||||
"aboveFew": "{metric} is at {value}, above your usual range of {range}, and {count} more vitals are outside their range too.",
|
||||
"aboveOther": "{metric} is at {value}, above your usual range of {range}, and {count} more vitals are outside their range too.",
|
||||
"belowOne": "{metric} is at {value}, below your usual range of {range}, and {count} more vital is outside its range too.",
|
||||
"belowFew": "{metric} is at {value}, below your usual range of {range}, and {count} more vitals are outside their range too.",
|
||||
"belowOther": "{metric} is at {value}, below your usual range of {range}, and {count} more vitals are outside their range too."
|
||||
},
|
||||
"sleep": {
|
||||
"more": "Last night you slept {duration}, {diff} more than usual.",
|
||||
"less": "Last night you slept {duration}, {diff} less than usual.",
|
||||
"usual": "Last night you slept {duration}, close to your usual.",
|
||||
"plain": "Last night you slept {duration}."
|
||||
},
|
||||
"vitalsInRangeOne": "Your latest vital sits inside its usual range.",
|
||||
"vitalsInRangeFew": "All {count} of your latest vitals sit inside their usual range.",
|
||||
"vitalsInRangeOther": "All {count} of your latest vitals sit inside their usual range.",
|
||||
"vitalInRange": "{metric} is at {value}, inside your usual range of {range}."
|
||||
},
|
||||
"todayFact": {
|
||||
"restMode": {
|
||||
"label": "Rest mode",
|
||||
"value": "Day {day}"
|
||||
},
|
||||
"medications": {
|
||||
"label": "Medications",
|
||||
"value": "{taken} of {scheduled} taken"
|
||||
},
|
||||
"appointment": {
|
||||
"label": "Appointment",
|
||||
"today": "Today {time}, {what}",
|
||||
"tomorrow": "Tomorrow {time}, {what}"
|
||||
},
|
||||
"sleep": {
|
||||
"label": "Last night",
|
||||
"plain": "{duration}",
|
||||
"usual": "{duration}, close to your usual",
|
||||
"more": "{duration}, {diff} more than usual",
|
||||
"less": "{duration}, {diff} less than usual"
|
||||
},
|
||||
"vitals": {
|
||||
"label": "Vitals",
|
||||
"allInOne": "{count} checked, in your range",
|
||||
"allInFew": "{count} checked, all in your range",
|
||||
"allInOther": "{count} checked, all in your range",
|
||||
"oneOut": {
|
||||
"above": "{metric} above your range",
|
||||
"below": "{metric} below your range"
|
||||
},
|
||||
"someOut": "{count} of {total} outside your range"
|
||||
},
|
||||
"cycle": {
|
||||
"label": "Cycle",
|
||||
"value": "{phase}, day {day}",
|
||||
"dayOnly": "Day {day}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reportSelection": {
|
||||
|
||||
+83
-1
@@ -11002,7 +11002,27 @@
|
||||
"sleepPending": "El sueño de anoche aún no ha llegado; la lectura de hoy es provisional.",
|
||||
"worthALook": "Merece un vistazo",
|
||||
"allClear": "Hoy nada necesita tu atención.",
|
||||
"ringLink": "Abrir detalles de {metric}"
|
||||
"ringLink": "Abrir detalles de {metric}",
|
||||
"heading": "Hoy",
|
||||
"steadyWeeksOne": "Estable desde hace {count} semana",
|
||||
"steadyWeeksFew": "Estable desde hace {count} semanas",
|
||||
"steadyWeeksOther": "Estable desde hace {count} semanas",
|
||||
"valueWithUnit": "{value} {unit}",
|
||||
"rangeWithUnit": "{low} a {high} {unit}",
|
||||
"vital": {
|
||||
"RESTING_HEART_RATE": "Frecuencia cardíaca en reposo",
|
||||
"HEART_RATE_VARIABILITY": "Variabilidad de la frecuencia cardíaca",
|
||||
"RESPIRATORY_RATE": "Frecuencia respiratoria",
|
||||
"OXYGEN_SATURATION": "Saturación de oxígeno",
|
||||
"BODY_TEMPERATURE": "Temperatura corporal",
|
||||
"SKIN_TEMPERATURE": "Temperatura de la piel",
|
||||
"BLOOD_PRESSURE_SYS": "Presión arterial sistólica",
|
||||
"BLOOD_PRESSURE_DIA": "Presión arterial diastólica",
|
||||
"BLOOD_GLUCOSE": "Glucosa en sangre",
|
||||
"PULSE": "Pulso",
|
||||
"WEIGHT": "Peso",
|
||||
"generic": "Un signo vital"
|
||||
}
|
||||
},
|
||||
"milestone": {
|
||||
"metric": {
|
||||
@@ -11038,6 +11058,68 @@
|
||||
"body": "Un mes entero con tu {metric} dentro de tu rango habitual. Maravillosamente estable."
|
||||
}
|
||||
}
|
||||
},
|
||||
"lead": {
|
||||
"vitalOutside": {
|
||||
"above": "{metric} está en {value}, por encima de tu rango habitual de {range}.",
|
||||
"below": "{metric} está en {value}, por debajo de tu rango habitual de {range}."
|
||||
},
|
||||
"vitalsOutside": {
|
||||
"aboveOne": "{metric} está en {value}, por encima de tu rango habitual de {range}, y {count} signo vital más también está fuera de su rango.",
|
||||
"aboveFew": "{metric} está en {value}, por encima de tu rango habitual de {range}, y {count} signos vitales más también están fuera de su rango.",
|
||||
"aboveOther": "{metric} está en {value}, por encima de tu rango habitual de {range}, y {count} signos vitales más también están fuera de su rango.",
|
||||
"belowOne": "{metric} está en {value}, por debajo de tu rango habitual de {range}, y {count} signo vital más también está fuera de su rango.",
|
||||
"belowFew": "{metric} está en {value}, por debajo de tu rango habitual de {range}, y {count} signos vitales más también están fuera de su rango.",
|
||||
"belowOther": "{metric} está en {value}, por debajo de tu rango habitual de {range}, y {count} signos vitales más también están fuera de su rango."
|
||||
},
|
||||
"sleep": {
|
||||
"more": "Anoche dormiste {duration}, {diff} más de lo habitual.",
|
||||
"less": "Anoche dormiste {duration}, {diff} menos de lo habitual.",
|
||||
"usual": "Anoche dormiste {duration}, cerca de lo habitual.",
|
||||
"plain": "Anoche dormiste {duration}."
|
||||
},
|
||||
"vitalsInRangeOne": "Tu último signo vital está dentro de su rango habitual.",
|
||||
"vitalsInRangeFew": "Tus {count} últimos signos vitales están dentro de su rango habitual.",
|
||||
"vitalsInRangeOther": "Tus {count} últimos signos vitales están dentro de su rango habitual.",
|
||||
"vitalInRange": "{metric} está en {value}, dentro de tu rango habitual de {range}."
|
||||
},
|
||||
"todayFact": {
|
||||
"restMode": {
|
||||
"label": "Modo descanso",
|
||||
"value": "Día {day}"
|
||||
},
|
||||
"medications": {
|
||||
"label": "Medicamentos",
|
||||
"value": "{taken} de {scheduled} tomados"
|
||||
},
|
||||
"appointment": {
|
||||
"label": "Cita",
|
||||
"today": "Hoy {time}, {what}",
|
||||
"tomorrow": "Mañana {time}, {what}"
|
||||
},
|
||||
"sleep": {
|
||||
"label": "Anoche",
|
||||
"plain": "{duration}",
|
||||
"usual": "{duration}, cerca de lo habitual",
|
||||
"more": "{duration}, {diff} más de lo habitual",
|
||||
"less": "{duration}, {diff} menos de lo habitual"
|
||||
},
|
||||
"vitals": {
|
||||
"label": "Signos vitales",
|
||||
"allInOne": "{count} revisado, en tu rango",
|
||||
"allInFew": "{count} revisados, todos en tu rango",
|
||||
"allInOther": "{count} revisados, todos en tu rango",
|
||||
"oneOut": {
|
||||
"above": "{metric} por encima de tu rango",
|
||||
"below": "{metric} por debajo de tu rango"
|
||||
},
|
||||
"someOut": "{count} de {total} fuera de tu rango"
|
||||
},
|
||||
"cycle": {
|
||||
"label": "Ciclo",
|
||||
"value": "{phase}, día {day}",
|
||||
"dayOnly": "Día {day}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reportSelection": {
|
||||
|
||||
+83
-1
@@ -11002,7 +11002,27 @@
|
||||
"sleepPending": "Le sommeil de la nuit dernière n'est pas encore arrivé — la lecture du jour est provisoire.",
|
||||
"worthALook": "À regarder",
|
||||
"allClear": "Rien ne requiert ton attention aujourd'hui.",
|
||||
"ringLink": "Ouvrir les détails de {metric}"
|
||||
"ringLink": "Ouvrir les détails de {metric}",
|
||||
"heading": "Aujourd'hui",
|
||||
"steadyWeeksOne": "Stable depuis {count} semaine",
|
||||
"steadyWeeksFew": "Stable depuis {count} semaines",
|
||||
"steadyWeeksOther": "Stable depuis {count} semaines",
|
||||
"valueWithUnit": "{value} {unit}",
|
||||
"rangeWithUnit": "{low} à {high} {unit}",
|
||||
"vital": {
|
||||
"RESTING_HEART_RATE": "Fréquence cardiaque au repos",
|
||||
"HEART_RATE_VARIABILITY": "Variabilité de la fréquence cardiaque",
|
||||
"RESPIRATORY_RATE": "Fréquence respiratoire",
|
||||
"OXYGEN_SATURATION": "Saturation en oxygène",
|
||||
"BODY_TEMPERATURE": "Température corporelle",
|
||||
"SKIN_TEMPERATURE": "Température cutanée",
|
||||
"BLOOD_PRESSURE_SYS": "Tension systolique",
|
||||
"BLOOD_PRESSURE_DIA": "Tension diastolique",
|
||||
"BLOOD_GLUCOSE": "Glycémie",
|
||||
"PULSE": "Pouls",
|
||||
"WEIGHT": "Poids",
|
||||
"generic": "Un signe vital"
|
||||
}
|
||||
},
|
||||
"milestone": {
|
||||
"metric": {
|
||||
@@ -11038,6 +11058,68 @@
|
||||
"body": "Un mois entier avec votre {metric} dans votre plage habituelle. Merveilleusement stable."
|
||||
}
|
||||
}
|
||||
},
|
||||
"lead": {
|
||||
"vitalOutside": {
|
||||
"above": "{metric} est à {value}, au-dessus de ta plage habituelle de {range}.",
|
||||
"below": "{metric} est à {value}, en dessous de ta plage habituelle de {range}."
|
||||
},
|
||||
"vitalsOutside": {
|
||||
"aboveOne": "{metric} est à {value}, au-dessus de ta plage habituelle de {range}, et {count} autre signe vital sort aussi de sa plage.",
|
||||
"aboveFew": "{metric} est à {value}, au-dessus de ta plage habituelle de {range}, et {count} autres signes vitaux sortent aussi de leur plage.",
|
||||
"aboveOther": "{metric} est à {value}, au-dessus de ta plage habituelle de {range}, et {count} autres signes vitaux sortent aussi de leur plage.",
|
||||
"belowOne": "{metric} est à {value}, en dessous de ta plage habituelle de {range}, et {count} autre signe vital sort aussi de sa plage.",
|
||||
"belowFew": "{metric} est à {value}, en dessous de ta plage habituelle de {range}, et {count} autres signes vitaux sortent aussi de leur plage.",
|
||||
"belowOther": "{metric} est à {value}, en dessous de ta plage habituelle de {range}, et {count} autres signes vitaux sortent aussi de leur plage."
|
||||
},
|
||||
"sleep": {
|
||||
"more": "La nuit dernière, tu as dormi {duration}, {diff} de plus que d'habitude.",
|
||||
"less": "La nuit dernière, tu as dormi {duration}, {diff} de moins que d'habitude.",
|
||||
"usual": "La nuit dernière, tu as dormi {duration}, proche de ton habitude.",
|
||||
"plain": "La nuit dernière, tu as dormi {duration}."
|
||||
},
|
||||
"vitalsInRangeOne": "Ton dernier signe vital est dans sa plage habituelle.",
|
||||
"vitalsInRangeFew": "Tes {count} derniers signes vitaux sont tous dans leur plage habituelle.",
|
||||
"vitalsInRangeOther": "Tes {count} derniers signes vitaux sont tous dans leur plage habituelle.",
|
||||
"vitalInRange": "{metric} est à {value}, dans ta plage habituelle de {range}."
|
||||
},
|
||||
"todayFact": {
|
||||
"restMode": {
|
||||
"label": "Mode repos",
|
||||
"value": "Jour {day}"
|
||||
},
|
||||
"medications": {
|
||||
"label": "Médicaments",
|
||||
"value": "{taken} sur {scheduled} pris"
|
||||
},
|
||||
"appointment": {
|
||||
"label": "Rendez-vous",
|
||||
"today": "Aujourd'hui {time}, {what}",
|
||||
"tomorrow": "Demain {time}, {what}"
|
||||
},
|
||||
"sleep": {
|
||||
"label": "La nuit dernière",
|
||||
"plain": "{duration}",
|
||||
"usual": "{duration}, proche de ton habitude",
|
||||
"more": "{duration}, {diff} de plus que d'habitude",
|
||||
"less": "{duration}, {diff} de moins que d'habitude"
|
||||
},
|
||||
"vitals": {
|
||||
"label": "Signes vitaux",
|
||||
"allInOne": "{count} vérifié, dans ta plage",
|
||||
"allInFew": "{count} vérifiés, tous dans ta plage",
|
||||
"allInOther": "{count} vérifiés, tous dans ta plage",
|
||||
"oneOut": {
|
||||
"above": "{metric} au-dessus de ta plage",
|
||||
"below": "{metric} en dessous de ta plage"
|
||||
},
|
||||
"someOut": "{count} sur {total} hors de ta plage"
|
||||
},
|
||||
"cycle": {
|
||||
"label": "Cycle",
|
||||
"value": "{phase}, jour {day}",
|
||||
"dayOnly": "Jour {day}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reportSelection": {
|
||||
|
||||
+83
-1
@@ -11002,7 +11002,27 @@
|
||||
"sleepPending": "Il sonno di stanotte non è ancora arrivato: la lettura di oggi è provvisoria.",
|
||||
"worthALook": "Da tenere d'occhio",
|
||||
"allClear": "Oggi nulla richiede la tua attenzione.",
|
||||
"ringLink": "Apri i dettagli di {metric}"
|
||||
"ringLink": "Apri i dettagli di {metric}",
|
||||
"heading": "Oggi",
|
||||
"steadyWeeksOne": "Stabile da {count} settimana",
|
||||
"steadyWeeksFew": "Stabile da {count} settimane",
|
||||
"steadyWeeksOther": "Stabile da {count} settimane",
|
||||
"valueWithUnit": "{value} {unit}",
|
||||
"rangeWithUnit": "da {low} a {high} {unit}",
|
||||
"vital": {
|
||||
"RESTING_HEART_RATE": "Frequenza cardiaca a riposo",
|
||||
"HEART_RATE_VARIABILITY": "Variabilità della frequenza cardiaca",
|
||||
"RESPIRATORY_RATE": "Frequenza respiratoria",
|
||||
"OXYGEN_SATURATION": "Saturazione di ossigeno",
|
||||
"BODY_TEMPERATURE": "Temperatura corporea",
|
||||
"SKIN_TEMPERATURE": "Temperatura cutanea",
|
||||
"BLOOD_PRESSURE_SYS": "Pressione sistolica",
|
||||
"BLOOD_PRESSURE_DIA": "Pressione diastolica",
|
||||
"BLOOD_GLUCOSE": "Glicemia",
|
||||
"PULSE": "Polso",
|
||||
"WEIGHT": "Peso",
|
||||
"generic": "Un parametro vitale"
|
||||
}
|
||||
},
|
||||
"milestone": {
|
||||
"metric": {
|
||||
@@ -11038,6 +11058,68 @@
|
||||
"body": "Un mese intero con la tua {metric} nel tuo intervallo abituale. Meravigliosamente costante."
|
||||
}
|
||||
}
|
||||
},
|
||||
"lead": {
|
||||
"vitalOutside": {
|
||||
"above": "{metric} è a {value}, sopra il tuo intervallo abituale di {range}.",
|
||||
"below": "{metric} è a {value}, sotto il tuo intervallo abituale di {range}."
|
||||
},
|
||||
"vitalsOutside": {
|
||||
"aboveOne": "{metric} è a {value}, sopra il tuo intervallo abituale di {range}, e anche {count} altro parametro vitale è fuori dal suo intervallo.",
|
||||
"aboveFew": "{metric} è a {value}, sopra il tuo intervallo abituale di {range}, e anche altri {count} parametri vitali sono fuori dal loro intervallo.",
|
||||
"aboveOther": "{metric} è a {value}, sopra il tuo intervallo abituale di {range}, e anche altri {count} parametri vitali sono fuori dal loro intervallo.",
|
||||
"belowOne": "{metric} è a {value}, sotto il tuo intervallo abituale di {range}, e anche {count} altro parametro vitale è fuori dal suo intervallo.",
|
||||
"belowFew": "{metric} è a {value}, sotto il tuo intervallo abituale di {range}, e anche altri {count} parametri vitali sono fuori dal loro intervallo.",
|
||||
"belowOther": "{metric} è a {value}, sotto il tuo intervallo abituale di {range}, e anche altri {count} parametri vitali sono fuori dal loro intervallo."
|
||||
},
|
||||
"sleep": {
|
||||
"more": "Stanotte hai dormito {duration}, {diff} in più del solito.",
|
||||
"less": "Stanotte hai dormito {duration}, {diff} in meno del solito.",
|
||||
"usual": "Stanotte hai dormito {duration}, vicino al tuo solito.",
|
||||
"plain": "Stanotte hai dormito {duration}."
|
||||
},
|
||||
"vitalsInRangeOne": "Il tuo ultimo parametro vitale è nel suo intervallo abituale.",
|
||||
"vitalsInRangeFew": "Tutti i tuoi {count} ultimi parametri vitali sono nel loro intervallo abituale.",
|
||||
"vitalsInRangeOther": "Tutti i tuoi {count} ultimi parametri vitali sono nel loro intervallo abituale.",
|
||||
"vitalInRange": "{metric} è a {value}, nel tuo intervallo abituale di {range}."
|
||||
},
|
||||
"todayFact": {
|
||||
"restMode": {
|
||||
"label": "Modalità riposo",
|
||||
"value": "Giorno {day}"
|
||||
},
|
||||
"medications": {
|
||||
"label": "Farmaci",
|
||||
"value": "{taken} di {scheduled} presi"
|
||||
},
|
||||
"appointment": {
|
||||
"label": "Appuntamento",
|
||||
"today": "Oggi {time}, {what}",
|
||||
"tomorrow": "Domani {time}, {what}"
|
||||
},
|
||||
"sleep": {
|
||||
"label": "Stanotte",
|
||||
"plain": "{duration}",
|
||||
"usual": "{duration}, vicino al solito",
|
||||
"more": "{duration}, {diff} in più del solito",
|
||||
"less": "{duration}, {diff} in meno del solito"
|
||||
},
|
||||
"vitals": {
|
||||
"label": "Parametri vitali",
|
||||
"allInOne": "{count} controllato, nel tuo intervallo",
|
||||
"allInFew": "{count} controllati, tutti nel tuo intervallo",
|
||||
"allInOther": "{count} controllati, tutti nel tuo intervallo",
|
||||
"oneOut": {
|
||||
"above": "{metric} sopra il tuo intervallo",
|
||||
"below": "{metric} sotto il tuo intervallo"
|
||||
},
|
||||
"someOut": "{count} su {total} fuori dal tuo intervallo"
|
||||
},
|
||||
"cycle": {
|
||||
"label": "Ciclo",
|
||||
"value": "{phase}, giorno {day}",
|
||||
"dayOnly": "Giorno {day}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reportSelection": {
|
||||
|
||||
+83
-1
@@ -11002,7 +11002,27 @@
|
||||
"sleepPending": "어젯밤 수면이 아직 들어오지 않아 오늘 결과는 잠정치예요.",
|
||||
"worthALook": "살펴볼 만한 것",
|
||||
"allClear": "오늘은 신경 쓸 일이 없어요.",
|
||||
"ringLink": "{metric} 자세히 보기"
|
||||
"ringLink": "{metric} 자세히 보기",
|
||||
"heading": "오늘",
|
||||
"steadyWeeksOne": "{count}주째 안정적",
|
||||
"steadyWeeksFew": "{count}주째 안정적",
|
||||
"steadyWeeksOther": "{count}주째 안정적",
|
||||
"valueWithUnit": "{value} {unit}",
|
||||
"rangeWithUnit": "{low}~{high} {unit}",
|
||||
"vital": {
|
||||
"RESTING_HEART_RATE": "안정 시 심박수",
|
||||
"HEART_RATE_VARIABILITY": "심박 변이도",
|
||||
"RESPIRATORY_RATE": "호흡수",
|
||||
"OXYGEN_SATURATION": "산소포화도",
|
||||
"BODY_TEMPERATURE": "체온",
|
||||
"SKIN_TEMPERATURE": "피부 온도",
|
||||
"BLOOD_PRESSURE_SYS": "수축기 혈압",
|
||||
"BLOOD_PRESSURE_DIA": "이완기 혈압",
|
||||
"BLOOD_GLUCOSE": "혈당",
|
||||
"PULSE": "맥박",
|
||||
"WEIGHT": "체중",
|
||||
"generic": "생체 지표"
|
||||
}
|
||||
},
|
||||
"milestone": {
|
||||
"metric": {
|
||||
@@ -11038,6 +11058,68 @@
|
||||
"body": "{metric} 수치가 꼬박 한 달 동안 평소 범위 안이었어요. 아주 꾸준해요."
|
||||
}
|
||||
}
|
||||
},
|
||||
"lead": {
|
||||
"vitalOutside": {
|
||||
"above": "{metric}이(가) {value}로 평소 범위({range})보다 높아요.",
|
||||
"below": "{metric}이(가) {value}로 평소 범위({range})보다 낮아요."
|
||||
},
|
||||
"vitalsOutside": {
|
||||
"aboveOne": "{metric}이(가) {value}로 평소 범위({range})보다 높고, 다른 지표 {count}개도 범위를 벗어났어요.",
|
||||
"aboveFew": "{metric}이(가) {value}로 평소 범위({range})보다 높고, 다른 지표 {count}개도 범위를 벗어났어요.",
|
||||
"aboveOther": "{metric}이(가) {value}로 평소 범위({range})보다 높고, 다른 지표 {count}개도 범위를 벗어났어요.",
|
||||
"belowOne": "{metric}이(가) {value}로 평소 범위({range})보다 낮고, 다른 지표 {count}개도 범위를 벗어났어요.",
|
||||
"belowFew": "{metric}이(가) {value}로 평소 범위({range})보다 낮고, 다른 지표 {count}개도 범위를 벗어났어요.",
|
||||
"belowOther": "{metric}이(가) {value}로 평소 범위({range})보다 낮고, 다른 지표 {count}개도 범위를 벗어났어요."
|
||||
},
|
||||
"sleep": {
|
||||
"more": "어젯밤 {duration} 잤어요. 평소보다 {diff} 더 잤어요.",
|
||||
"less": "어젯밤 {duration} 잤어요. 평소보다 {diff} 덜 잤어요.",
|
||||
"usual": "어젯밤 {duration} 잤어요. 평소와 비슷해요.",
|
||||
"plain": "어젯밤 {duration} 잤어요."
|
||||
},
|
||||
"vitalsInRangeOne": "최근 생체 지표가 평소 범위 안에 있어요.",
|
||||
"vitalsInRangeFew": "최근 생체 지표 {count}개가 모두 평소 범위 안에 있어요.",
|
||||
"vitalsInRangeOther": "최근 생체 지표 {count}개가 모두 평소 범위 안에 있어요.",
|
||||
"vitalInRange": "{metric}이(가) {value}로 평소 범위({range}) 안에 있어요."
|
||||
},
|
||||
"todayFact": {
|
||||
"restMode": {
|
||||
"label": "휴식 모드",
|
||||
"value": "{day}일째"
|
||||
},
|
||||
"medications": {
|
||||
"label": "복약",
|
||||
"value": "{scheduled}회 중 {taken}회 복용"
|
||||
},
|
||||
"appointment": {
|
||||
"label": "예약",
|
||||
"today": "오늘 {time}, {what}",
|
||||
"tomorrow": "내일 {time}, {what}"
|
||||
},
|
||||
"sleep": {
|
||||
"label": "어젯밤",
|
||||
"plain": "{duration}",
|
||||
"usual": "{duration}, 평소와 비슷",
|
||||
"more": "{duration}, 평소보다 {diff} 더",
|
||||
"less": "{duration}, 평소보다 {diff} 덜"
|
||||
},
|
||||
"vitals": {
|
||||
"label": "생체 지표",
|
||||
"allInOne": "{count}개 확인, 범위 안",
|
||||
"allInFew": "{count}개 확인, 모두 범위 안",
|
||||
"allInOther": "{count}개 확인, 모두 범위 안",
|
||||
"oneOut": {
|
||||
"above": "{metric} 범위보다 높음",
|
||||
"below": "{metric} 범위보다 낮음"
|
||||
},
|
||||
"someOut": "{total}개 중 {count}개 범위 밖"
|
||||
},
|
||||
"cycle": {
|
||||
"label": "주기",
|
||||
"value": "{phase}, {day}일째",
|
||||
"dayOnly": "{day}일째"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reportSelection": {
|
||||
|
||||
+83
-1
@@ -11002,7 +11002,27 @@
|
||||
"sleepPending": "Sen z ostatniej nocy jeszcze nie dotarł — dzisiejszy odczyt jest wstępny.",
|
||||
"worthALook": "Warto sprawdzić",
|
||||
"allClear": "Dziś nic nie wymaga Twojej uwagi.",
|
||||
"ringLink": "Otwórz szczegóły: {metric}"
|
||||
"ringLink": "Otwórz szczegóły: {metric}",
|
||||
"heading": "Dziś",
|
||||
"steadyWeeksOne": "Stabilnie od {count} tygodnia",
|
||||
"steadyWeeksFew": "Stabilnie od {count} tygodni",
|
||||
"steadyWeeksOther": "Stabilnie od {count} tygodni",
|
||||
"valueWithUnit": "{value} {unit}",
|
||||
"rangeWithUnit": "{low} do {high} {unit}",
|
||||
"vital": {
|
||||
"RESTING_HEART_RATE": "Tętno spoczynkowe",
|
||||
"HEART_RATE_VARIABILITY": "Zmienność rytmu serca",
|
||||
"RESPIRATORY_RATE": "Częstość oddechów",
|
||||
"OXYGEN_SATURATION": "Saturacja",
|
||||
"BODY_TEMPERATURE": "Temperatura ciała",
|
||||
"SKIN_TEMPERATURE": "Temperatura skóry",
|
||||
"BLOOD_PRESSURE_SYS": "Ciśnienie skurczowe",
|
||||
"BLOOD_PRESSURE_DIA": "Ciśnienie rozkurczowe",
|
||||
"BLOOD_GLUCOSE": "Glukoza we krwi",
|
||||
"PULSE": "Tętno",
|
||||
"WEIGHT": "Waga",
|
||||
"generic": "Parametr życiowy"
|
||||
}
|
||||
},
|
||||
"milestone": {
|
||||
"metric": {
|
||||
@@ -11038,6 +11058,68 @@
|
||||
"body": "Cały miesiąc z {metric} w zwykłym zakresie. Wspaniale stabilnie."
|
||||
}
|
||||
}
|
||||
},
|
||||
"lead": {
|
||||
"vitalOutside": {
|
||||
"above": "{metric}: {value}, powyżej Twojego zwykłego zakresu {range}.",
|
||||
"below": "{metric}: {value}, poniżej Twojego zwykłego zakresu {range}."
|
||||
},
|
||||
"vitalsOutside": {
|
||||
"aboveOne": "{metric}: {value}, powyżej Twojego zwykłego zakresu {range}. Poza zakresem jest też {count} inny parametr.",
|
||||
"aboveFew": "{metric}: {value}, powyżej Twojego zwykłego zakresu {range}. Poza zakresem są też {count} inne parametry.",
|
||||
"aboveOther": "{metric}: {value}, powyżej Twojego zwykłego zakresu {range}. Poza zakresem jest też {count} innych parametrów.",
|
||||
"belowOne": "{metric}: {value}, poniżej Twojego zwykłego zakresu {range}. Poza zakresem jest też {count} inny parametr.",
|
||||
"belowFew": "{metric}: {value}, poniżej Twojego zwykłego zakresu {range}. Poza zakresem są też {count} inne parametry.",
|
||||
"belowOther": "{metric}: {value}, poniżej Twojego zwykłego zakresu {range}. Poza zakresem jest też {count} innych parametrów."
|
||||
},
|
||||
"sleep": {
|
||||
"more": "Ostatnia noc: {duration} snu, o {diff} dłużej niż zwykle.",
|
||||
"less": "Ostatnia noc: {duration} snu, o {diff} krócej niż zwykle.",
|
||||
"usual": "Ostatnia noc: {duration} snu, mniej więcej tyle co zwykle.",
|
||||
"plain": "Ostatnia noc: {duration} snu."
|
||||
},
|
||||
"vitalsInRangeOne": "Twój ostatni parametr życiowy mieści się w zwykłym zakresie.",
|
||||
"vitalsInRangeFew": "Wszystkie {count} ostatnie parametry życiowe mieszczą się w zwykłym zakresie.",
|
||||
"vitalsInRangeOther": "Wszystkie {count} ostatnich parametrów życiowych mieści się w zwykłym zakresie.",
|
||||
"vitalInRange": "{metric}: {value}, w Twoim zwykłym zakresie {range}."
|
||||
},
|
||||
"todayFact": {
|
||||
"restMode": {
|
||||
"label": "Tryb odpoczynku",
|
||||
"value": "Dzień {day}"
|
||||
},
|
||||
"medications": {
|
||||
"label": "Leki",
|
||||
"value": "Przyjęto {taken} z {scheduled}"
|
||||
},
|
||||
"appointment": {
|
||||
"label": "Wizyta",
|
||||
"today": "Dziś {time}, {what}",
|
||||
"tomorrow": "Jutro {time}, {what}"
|
||||
},
|
||||
"sleep": {
|
||||
"label": "Ostatnia noc",
|
||||
"plain": "{duration}",
|
||||
"usual": "{duration}, jak zwykle",
|
||||
"more": "{duration}, o {diff} dłużej niż zwykle",
|
||||
"less": "{duration}, o {diff} krócej niż zwykle"
|
||||
},
|
||||
"vitals": {
|
||||
"label": "Parametry życiowe",
|
||||
"allInOne": "Sprawdzono {count}, w zakresie",
|
||||
"allInFew": "Sprawdzono {count}, wszystkie w zakresie",
|
||||
"allInOther": "Sprawdzono {count}, wszystkie w zakresie",
|
||||
"oneOut": {
|
||||
"above": "{metric} powyżej zakresu",
|
||||
"below": "{metric} poniżej zakresu"
|
||||
},
|
||||
"someOut": "{count} z {total} poza zakresem"
|
||||
},
|
||||
"cycle": {
|
||||
"label": "Cykl",
|
||||
"value": "{phase}, dzień {day}",
|
||||
"dayOnly": "Dzień {day}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reportSelection": {
|
||||
|
||||
@@ -71,6 +71,9 @@ const DIGEST: DailyDigest = {
|
||||
},
|
||||
topSignal: null,
|
||||
briefingLead: "Blood pressure is holding steady.",
|
||||
lead: { text: "Blood pressure is holding steady.", source: "briefing" },
|
||||
today: [],
|
||||
restMode: null,
|
||||
line: "Blood pressure is holding steady.",
|
||||
worthALook: [],
|
||||
justIn: null,
|
||||
|
||||
@@ -355,6 +355,9 @@ const DIGEST: DailyDigest = {
|
||||
score: null,
|
||||
topSignal: null,
|
||||
briefingLead: "A steady week so far.",
|
||||
lead: { text: "A steady week so far.", source: "briefing" },
|
||||
today: [],
|
||||
restMode: null,
|
||||
line: "A steady week so far.",
|
||||
justIn: null,
|
||||
reactionLine: null,
|
||||
|
||||
@@ -42,6 +42,9 @@ function digest(over: Partial<DailyDigest> = {}): DailyDigest {
|
||||
score: { value: 82, band: "green", delta: 3 },
|
||||
topSignal: null,
|
||||
briefingLead: "Your week is trending steady.",
|
||||
lead: { text: "Your week is trending steady.", source: "briefing" },
|
||||
today: [],
|
||||
restMode: null,
|
||||
line: "Your week is trending steady.",
|
||||
worthALook: [],
|
||||
justIn: null,
|
||||
@@ -105,6 +108,7 @@ describe("TodayHero — an arrival is not a chip", () => {
|
||||
digest={digest({
|
||||
score: null,
|
||||
briefingLead: null,
|
||||
lead: null,
|
||||
worthALook: [],
|
||||
reactionLine: null,
|
||||
justIn: { kind: "sleep_night", at: ARRIVED_AT },
|
||||
@@ -152,6 +156,7 @@ describe("TodayHero — what earns the hero without a score", () => {
|
||||
return digest({
|
||||
score: null,
|
||||
briefingLead: null,
|
||||
lead: null,
|
||||
reactionLine: null,
|
||||
phase: "provisional",
|
||||
sleepPending: true,
|
||||
@@ -203,6 +208,8 @@ describe("TodayHero — the reaction line replaces the lead", () => {
|
||||
digest={digest({
|
||||
reactionLine: REACTION,
|
||||
briefingLead: "Your week is trending steady.",
|
||||
// The server resolved the reaction line as the lead.
|
||||
lead: { text: REACTION, source: "reaction" },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
@@ -218,6 +225,8 @@ describe("TodayHero — the reaction line replaces the lead", () => {
|
||||
digest={digest({
|
||||
reactionLine: REACTION,
|
||||
briefingLead: "Your week is trending steady.",
|
||||
// The server resolved the reaction line as the lead.
|
||||
lead: { text: REACTION, source: "reaction" },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
@@ -246,6 +255,7 @@ describe("TodayHero — the reaction line replaces the lead", () => {
|
||||
digest={digest({
|
||||
score: null,
|
||||
briefingLead: null,
|
||||
lead: { text: REACTION, source: "reaction" },
|
||||
worthALook: [],
|
||||
reactionLine: REACTION,
|
||||
justIn: { kind: "weight", at: ARRIVED_AT },
|
||||
|
||||
@@ -6,6 +6,7 @@ import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { TodayHero } from "../today-hero";
|
||||
import type { DailyDigest } from "@/lib/daily/digest";
|
||||
import type { PriorityItem } from "@/lib/daily/priority-item";
|
||||
import type { TodayFact } from "@/lib/daily/today-overview";
|
||||
import { SCORE_VERSION } from "@/lib/analytics/score/types";
|
||||
import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures";
|
||||
|
||||
@@ -71,6 +72,9 @@ function digest(over: Partial<DailyDigest> = {}): DailyDigest {
|
||||
delta: "+6 mmHg vs your 30-day average",
|
||||
},
|
||||
briefingLead: "Your week is trending steady.",
|
||||
lead: { text: "Your week is trending steady.", source: "briefing" },
|
||||
today: [],
|
||||
restMode: null,
|
||||
line: "Your week is trending steady.",
|
||||
worthALook: [doseItem, syncItem],
|
||||
justIn: null,
|
||||
@@ -146,6 +150,7 @@ describe("<TodayHero>", () => {
|
||||
sleepPending: true,
|
||||
score: null,
|
||||
briefingLead: null,
|
||||
lead: null,
|
||||
line: "Your health score today is 82.",
|
||||
worthALook: [doseItem],
|
||||
})}
|
||||
@@ -160,13 +165,15 @@ describe("<TodayHero>", () => {
|
||||
expect(html).not.toContain('data-slot="today-hero-briefing-link"');
|
||||
});
|
||||
|
||||
it("keeps the useful loaded narrative without repeating the ring's numeric score", () => {
|
||||
it("renders the server-resolved lead without repeating the ring's numeric score", () => {
|
||||
// The server already dropped the score sentence from the briefing (see
|
||||
// `today-overview.test.ts`); the hero prints what it was handed.
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({
|
||||
briefingLead:
|
||||
"Your health score is 82. Your week is trending steady.",
|
||||
line: "Your health score is 82. Your week is trending steady.",
|
||||
briefingLead: "Your week is trending steady.",
|
||||
lead: { text: "Your week is trending steady.", source: "briefing" },
|
||||
line: "Your week is trending steady.",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
@@ -178,12 +185,16 @@ describe("<TodayHero>", () => {
|
||||
expect(html).toContain('data-slot="today-hero-lead"');
|
||||
});
|
||||
|
||||
it("promotes the top signal when score de-duplication removes the only lead", () => {
|
||||
it("does not repeat the top signal under a lead made from it", () => {
|
||||
// A briefing whose only sentence repeated the score leads with the top
|
||||
// signal's headline (resolved on the server); the muted signal line under
|
||||
// it would say the same thing twice.
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({
|
||||
briefingLead: "Your health score is 82.",
|
||||
line: "Your health score is 82.",
|
||||
briefingLead: null,
|
||||
lead: { text: "Pulse is settling lately", source: "briefing" },
|
||||
line: "Pulse is settling lately",
|
||||
topSignal: {
|
||||
sourceMetric: "pulse",
|
||||
tone: "info",
|
||||
@@ -208,6 +219,7 @@ describe("<TodayHero>", () => {
|
||||
sleepPending: true,
|
||||
score: null,
|
||||
briefingLead: null,
|
||||
lead: null,
|
||||
reactionLine: null,
|
||||
line: "Your health score today is 82.",
|
||||
worthALook: [doseItem],
|
||||
@@ -228,6 +240,7 @@ describe("<TodayHero>", () => {
|
||||
score: null,
|
||||
topSignal: null,
|
||||
briefingLead: null,
|
||||
lead: null,
|
||||
line: "Nothing needs your attention today — everything's tracking normally.",
|
||||
worthALook: [],
|
||||
})}
|
||||
@@ -246,6 +259,7 @@ describe("<TodayHero>", () => {
|
||||
score: null,
|
||||
topSignal: null,
|
||||
briefingLead: null,
|
||||
lead: null,
|
||||
line: "Nothing needs your attention today — everything's tracking normally.",
|
||||
worthALook: [],
|
||||
reactionLine: null,
|
||||
@@ -273,7 +287,7 @@ describe("<TodayHero>", () => {
|
||||
expect(html).toContain('data-slot="today-hero-score"');
|
||||
});
|
||||
|
||||
it("shows the first-class all-clear line when a score is present but nothing is notable", () => {
|
||||
it("draws no empty rail and no all-clear sentence once a lead carries the day", () => {
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({
|
||||
@@ -282,16 +296,36 @@ describe("<TodayHero>", () => {
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="today-hero"');
|
||||
expect(html).toContain('data-slot="today-hero-all-clear"');
|
||||
expect(html).toContain('data-slot="today-hero-lead"');
|
||||
expect(html).not.toContain('data-slot="today-hero-all-clear"');
|
||||
expect(html).not.toContain('data-slot="today-hero-rail"');
|
||||
});
|
||||
|
||||
it("keeps only the delta when the lead already says the headline", () => {
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({
|
||||
lead: {
|
||||
text: "Blood pressure a touch high this morning.",
|
||||
source: "briefing",
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const text = visibleText(html);
|
||||
expect(html).toContain('data-slot="today-hero-signal"');
|
||||
expect(text).toContain("+6 mmHg vs your 30-day average");
|
||||
// Said once, by the lead; the signal line carries only the delta.
|
||||
expect(text.split("touch high this morning").length - 1).toBe(1);
|
||||
});
|
||||
|
||||
it("uses the compact score-only composition when all-clear has no narrative", () => {
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({
|
||||
topSignal: null,
|
||||
briefingLead: null,
|
||||
lead: null,
|
||||
reactionLine: null,
|
||||
line: "Your health score today is 82.",
|
||||
worthALook: [],
|
||||
@@ -379,6 +413,7 @@ describe("<TodayHero>", () => {
|
||||
score: null,
|
||||
topSignal: null,
|
||||
briefingLead: null,
|
||||
lead: null,
|
||||
worthALook: [],
|
||||
})}
|
||||
primaryContent="reminders"
|
||||
@@ -446,3 +481,165 @@ describe("<TodayHero> score basis", () => {
|
||||
expect(html).not.toContain('data-slot="today-hero-score-basis"');
|
||||
});
|
||||
});
|
||||
|
||||
const FACTS: TodayFact[] = [
|
||||
{
|
||||
kind: "rest_mode",
|
||||
label: "Rest mode",
|
||||
value: "Day 3",
|
||||
href: "/illness",
|
||||
moduleKey: "illness",
|
||||
},
|
||||
{
|
||||
kind: "medications",
|
||||
label: "Medications",
|
||||
value: "1 of 3 taken",
|
||||
href: "/medications",
|
||||
moduleKey: "medications",
|
||||
},
|
||||
{
|
||||
kind: "appointment",
|
||||
label: "Appointment",
|
||||
value: "Tomorrow 09:30, Dr. Example",
|
||||
href: "/checkups",
|
||||
},
|
||||
{
|
||||
kind: "sleep",
|
||||
label: "Last night",
|
||||
value: "7h 20m, close to your usual",
|
||||
href: "/insights/sleep",
|
||||
moduleKey: "sleep",
|
||||
},
|
||||
{
|
||||
kind: "cycle",
|
||||
label: "Cycle",
|
||||
value: "Follicular, day 12",
|
||||
href: "/cycle",
|
||||
moduleKey: "cycle",
|
||||
},
|
||||
];
|
||||
|
||||
describe("<TodayHero> Today overview", () => {
|
||||
it("renders each fact as a link to its page, in the order it was given", () => {
|
||||
const html = render(<TodayHero digest={digest({ today: FACTS })} />);
|
||||
expect(html).toContain('data-slot="today-hero-today"');
|
||||
const kinds = [
|
||||
...html.matchAll(/data-slot="today-hero-fact" data-kind="([a-z_]+)"/g),
|
||||
].map((m) => m[1]);
|
||||
expect(kinds).toEqual([
|
||||
"rest_mode",
|
||||
"medications",
|
||||
"appointment",
|
||||
"sleep",
|
||||
"cycle",
|
||||
]);
|
||||
for (const fact of FACTS) {
|
||||
expect(html).toContain(`href="${fact.href}"`);
|
||||
expect(visibleText(html)).toContain(fact.value);
|
||||
}
|
||||
});
|
||||
|
||||
it("hides the fifth fact below md and never the first four", () => {
|
||||
const html = render(<TodayHero digest={digest({ today: FACTS })} />);
|
||||
const items = [
|
||||
...html.matchAll(/<li[^>]*data-slot="today-hero-fact"[^>]*>/g),
|
||||
].map((m) => m[0]);
|
||||
expect(items).toHaveLength(5);
|
||||
items.slice(0, 4).forEach((li) => expect(li).not.toContain("hidden"));
|
||||
expect(items[4]).toContain("hidden md:block");
|
||||
});
|
||||
|
||||
it("drops the all-clear sentence when facts carry the day", () => {
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({ today: FACTS.slice(0, 2), worthALook: [] })}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="today-hero-all-clear"');
|
||||
expect(html).toContain('data-layout="narrative"');
|
||||
});
|
||||
|
||||
it("keeps the hero for an account whose only content is facts", () => {
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({
|
||||
score: null,
|
||||
topSignal: null,
|
||||
briefingLead: null,
|
||||
lead: null,
|
||||
worthALook: [],
|
||||
today: FACTS.slice(1, 2),
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="today-hero"');
|
||||
expect(html).toContain('data-slot="today-hero-today"');
|
||||
});
|
||||
|
||||
it("renders a deterministic lead without the AI signal line", () => {
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({
|
||||
lead: {
|
||||
text: "All 4 of your latest vitals sit inside their usual range.",
|
||||
source: "signal",
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-source="signal"');
|
||||
expect(html).not.toContain('data-slot="today-hero-signal"');
|
||||
});
|
||||
|
||||
it("shows the facts in reminders mode, without the ring", () => {
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({ today: FACTS.slice(0, 2) })}
|
||||
primaryContent="reminders"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="today-hero-today"');
|
||||
expect(html).not.toContain('data-slot="today-hero-score"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<TodayHero> steady line", () => {
|
||||
const steady = (over: Partial<NonNullable<DailyDigest["score"]>>) =>
|
||||
digest({
|
||||
score: {
|
||||
value: 94,
|
||||
band: "green",
|
||||
delta: null,
|
||||
deltaReason: "below_noise_floor",
|
||||
scoreVersion: SCORE_VERSION,
|
||||
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
|
||||
steadyWeeks: 4,
|
||||
...over,
|
||||
},
|
||||
});
|
||||
|
||||
it("says how long the score has held when no delta is shown", () => {
|
||||
const html = render(<TodayHero digest={steady({})} />);
|
||||
expect(html).toContain('data-slot="today-hero-score-steady"');
|
||||
expect(visibleText(html)).toContain("Steady for 4 weeks");
|
||||
expect(html).not.toContain('data-slot="today-hero-score-delta"');
|
||||
});
|
||||
|
||||
it("never pairs a steady line with a delta", () => {
|
||||
const html = render(
|
||||
<TodayHero digest={steady({ delta: 3, deltaReason: null })} />,
|
||||
);
|
||||
expect(html).toContain('data-slot="today-hero-score-delta"');
|
||||
expect(html).not.toContain('data-slot="today-hero-score-steady"');
|
||||
});
|
||||
|
||||
it("says nothing without a run", () => {
|
||||
const html = render(<TodayHero digest={steady({ steadyWeeks: null })} />);
|
||||
expect(html).not.toContain('data-slot="today-hero-score-steady"');
|
||||
});
|
||||
|
||||
it("uses the German plural", () => {
|
||||
const html = render(<TodayHero digest={steady({})} />, "de");
|
||||
expect(visibleText(html)).toContain("Seit 4 Wochen stabil");
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,8 @@ import { usePriorityItemDismiss } from "@/hooks/use-priority-item-dismiss";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { pluralKey } from "@/lib/i18n/plural";
|
||||
import { MAX_TODAY_FACTS_NARROW as TODAY_FACTS_NARROW } from "@/lib/daily/today-overview";
|
||||
import type { DailyDigest } from "@/lib/daily/digest";
|
||||
import type { HeroPrimaryContent } from "@/lib/dashboard-layout";
|
||||
import {
|
||||
@@ -72,26 +74,18 @@ function formatDelta(
|
||||
return t("daily.today.deltaVsBaseline", { delta: signed });
|
||||
}
|
||||
|
||||
/**
|
||||
* The ring already owns the headline number. AI briefing copy can still carry
|
||||
* an older lead sentence that repeats that exact score, so remove only the
|
||||
* sentence containing the displayed number and retain every other useful
|
||||
* sentence verbatim. If the score sentence was the whole lead, render no lead
|
||||
* rather than inventing replacement prose.
|
||||
*/
|
||||
function withoutRepeatedScore(
|
||||
text: string | null,
|
||||
score: number | null,
|
||||
): string | null {
|
||||
if (!text || score === null) return text;
|
||||
const shownScore = String(Math.round(score));
|
||||
const scorePattern = new RegExp(
|
||||
`(^|[^0-9])${shownScore.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}([^0-9]|$)`,
|
||||
);
|
||||
const sentences = text.match(/[^.!?]+[.!?]+(?:\s+|$)|[^.!?]+$/g) ?? [text];
|
||||
const retained = sentences.filter((sentence) => !scorePattern.test(sentence));
|
||||
const result = retained.join(" ").replace(/\s+/g, " ").trim();
|
||||
return result.length > 0 ? result : 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 `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({
|
||||
@@ -104,7 +98,7 @@ export function TodayHero({
|
||||
/** Server-persisted hero choice from the dashboard layout blob. */
|
||||
primaryContent?: HeroPrimaryContent;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { t, locale } = useTranslations();
|
||||
// The hero is deliberately NOT mount-gated (v1.30.9: it is the LCP element
|
||||
// and paints from the server-dehydrated digest on both the SSR and the
|
||||
// hydration render), so nothing in it may differ between those two passes.
|
||||
@@ -138,35 +132,52 @@ export function TodayHero({
|
||||
|
||||
const hasScore = digest.score !== null;
|
||||
const hasItems = digest.worthALook.length > 0;
|
||||
// Lead precedence, warmest first: the day's reaction line REPLACES the
|
||||
// briefing lead once something landed — it never appends as a second
|
||||
// paragraph, so the hero stays exactly one lead line tall and nothing below
|
||||
// it shifts. The briefing lead is the standing read; the deterministic
|
||||
// `line` is the floor a keyless self-hoster still gets.
|
||||
const rawLead =
|
||||
digest.reactionLine ??
|
||||
digest.briefingLead ??
|
||||
// The deterministic line is a useful floor only once the ring has a real
|
||||
// score. A provisional/null ring must not pair with a fabricated score
|
||||
// sentence from that fallback.
|
||||
(hasScore ? digest.line : null);
|
||||
const lead = withoutRepeatedScore(rawLead, digest.score?.value ?? null);
|
||||
// `?? []` / `?? null` below: the service worker's offline data cache can
|
||||
// still hand back a digest written before the overview fields existed, and
|
||||
// a missing list must read as "no facts", not as a crash.
|
||||
const facts = digest.today ?? [];
|
||||
const hasFacts = facts.length > 0;
|
||||
// The lead is resolved on the server (`digest.lead`): the reaction line or
|
||||
// a briefing sentence with content while AI text is available, otherwise a
|
||||
// deterministic sentence about the day's strongest signal. Greetings and a
|
||||
// sentence that only repeats the ring's number never reach it, so the hero
|
||||
// renders it verbatim and decides nothing about it.
|
||||
const lead = digest.lead?.text ?? null;
|
||||
const topSignal = digest.topSignal;
|
||||
// A score-only briefing can legitimately lose its lead when the only
|
||||
// sentence repeated the number already printed inside the ring. Keep the
|
||||
// day's signal as the visible headline in that case instead of leaving the
|
||||
// hero with a muted, orphaned subtitle below an empty bold slot.
|
||||
const signalFallbackLead =
|
||||
hasScore && !lead ? topSignal?.headline?.trim() || null : null;
|
||||
const displayLead = lead ?? signalFallbackLead;
|
||||
// A score-only all-clear digest 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 actual all-clear read below it.
|
||||
// Treat that honest fallback as its own compact composition: the all-clear
|
||||
// copy occupies the leading column and a smaller version of the SAME score
|
||||
// ring remains the one numeric face. This branch depends only on the
|
||||
// server-delivered digest, so SSR and hydration choose it identically.
|
||||
const compactAllClear = !lead && !topSignal && !hasItems;
|
||||
// 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;
|
||||
// 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
|
||||
// it, so that case keeps its compact composition: the all-clear copy leads
|
||||
// and a smaller version of the SAME score ring stays the one numeric face.
|
||||
// This branch depends only on the server-delivered digest, so SSR and
|
||||
// hydration choose it identically.
|
||||
const compactAllClear = !lead && !signalLine && !hasItems && !hasFacts;
|
||||
|
||||
// How long the number has held, read off the stored daily scores on the
|
||||
// server. Said only when no delta is: a moved score is described by its
|
||||
// delta, a still one by its duration, never both under one ring.
|
||||
const deltaShown =
|
||||
digest.score !== null &&
|
||||
digest.score.delta !== null &&
|
||||
digest.score.deltaReason === null;
|
||||
const steadyWeeks = digest.score?.steadyWeeks ?? null;
|
||||
const steadyLine =
|
||||
!deltaShown && steadyWeeks !== null
|
||||
? t(pluralKey("daily.today.steadyWeeks", steadyWeeks, locale), {
|
||||
count: steadyWeeks,
|
||||
})
|
||||
: null;
|
||||
|
||||
// v1.38 — what the ring's number rests on, when that is less than the
|
||||
// breadth the score recommends. The hero renders the number and nothing
|
||||
@@ -203,13 +214,7 @@ export function TodayHero({
|
||||
//
|
||||
// A filtered layout is the one explicit exception: its empty digest means
|
||||
// "the hidden canonical candidates are quiet here", so retain all-clear.
|
||||
if (
|
||||
!renderFilteredAllClear &&
|
||||
!hasScore &&
|
||||
!hasItems &&
|
||||
!digest.briefingLead &&
|
||||
!digest.reactionLine
|
||||
) {
|
||||
if (!renderFilteredAllClear && !hasScore && !hasItems && !hasFacts && !lead) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -252,6 +257,45 @@ export function TodayHero({
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
/* The Today overview — up to five statements about the day, resolved on the
|
||||
server in priority order. A phone shows the first four: the fifth is
|
||||
hidden below `md` rather than dropped, so the list a wide screen reads is
|
||||
the same list, one line longer. Each line opens the page it belongs to.
|
||||
The block never asks for anything; the rail below is the one place on the
|
||||
hero that may. */
|
||||
const todayBlock = hasFacts ? (
|
||||
<div className="space-y-2" data-slot="today-hero-today">
|
||||
<h2 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{t("daily.today.heading")}
|
||||
</h2>
|
||||
<ul className="flex flex-col md:grid md:grid-cols-2 md:gap-x-6 md:gap-y-3 xl:grid-cols-3">
|
||||
{facts.map((fact, i) => (
|
||||
<li
|
||||
key={fact.kind}
|
||||
data-slot="today-hero-fact"
|
||||
data-kind={fact.kind}
|
||||
className={cn(
|
||||
"min-w-0",
|
||||
i >= TODAY_FACTS_NARROW && "hidden md:block",
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href={fact.href}
|
||||
className="focus-visible:ring-ring/50 hover:bg-muted/50 -mx-1.5 flex items-baseline justify-between gap-3 rounded-md px-1.5 py-1 focus-visible:ring-2 focus-visible:outline-none md:flex-col md:items-start md:justify-start md:gap-0.5"
|
||||
>
|
||||
<span className="text-muted-foreground shrink-0 text-sm md:text-xs">
|
||||
{fact.label}
|
||||
</span>
|
||||
<span className="text-foreground min-w-0 text-right text-sm font-medium md:text-left">
|
||||
{fact.value}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const heroShellClassName = cn(
|
||||
// The tile strip's surface plus the ONE sanctioned Today atmosphere:
|
||||
// `.today-hero-wash` leans a faint `--primary` mix over the theme
|
||||
@@ -275,14 +319,16 @@ export function TodayHero({
|
||||
className={heroShellClassName}
|
||||
>
|
||||
<div className="flex flex-col gap-3 md:gap-4">
|
||||
{rail ?? (
|
||||
<p
|
||||
data-slot="today-hero-all-clear"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{t("daily.today.allClear")}
|
||||
</p>
|
||||
)}
|
||||
{todayBlock}
|
||||
{rail ??
|
||||
(hasFacts ? null : (
|
||||
<p
|
||||
data-slot="today-hero-all-clear"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{t("daily.today.allClear")}
|
||||
</p>
|
||||
))}
|
||||
{sleepPendingNote}
|
||||
</div>
|
||||
</section>
|
||||
@@ -312,33 +358,36 @@ export function TodayHero({
|
||||
: "flex-col md:flex-row md:items-start md:justify-between md:gap-6",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
{/* Hero numeric face: the read leads large in the foreground
|
||||
token, calm and legible — the day's read, not a slogan. */}
|
||||
{displayLead ? (
|
||||
<div
|
||||
data-slot="today-hero-lead"
|
||||
className="text-foreground text-lg leading-snug font-semibold tracking-tight sm:text-xl"
|
||||
>
|
||||
<ProseBlocks text={displayLead} strip linkify={false} />
|
||||
{lead || signalLine ? (
|
||||
<div className="space-y-2">
|
||||
{lead ? (
|
||||
<div
|
||||
data-slot="today-hero-lead"
|
||||
data-source={digest.lead?.source}
|
||||
className="text-foreground text-lg leading-snug font-semibold tracking-tight sm:text-xl"
|
||||
>
|
||||
<ProseBlocks text={lead} strip linkify={false} />
|
||||
</div>
|
||||
) : null}
|
||||
{/* Top signal — present-tense headline + optional delta, one
|
||||
muted step down so it supports the lead without
|
||||
competing. */}
|
||||
{signalLine ? (
|
||||
<p
|
||||
data-slot="today-hero-signal"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{signalLine.headline}
|
||||
{signalLine.headline && signalLine.delta ? " · " : null}
|
||||
{signalLine.delta}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{/* Top signal — present-tense headline + optional delta, one
|
||||
muted step down so it supports the lead without competing. */}
|
||||
{topSignal && lead ? (
|
||||
<p
|
||||
data-slot="today-hero-signal"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{topSignal.headline}
|
||||
{topSignal.delta ? (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
· {topSignal.delta}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
{todayBlock}
|
||||
{compactAllClear ? (
|
||||
<p
|
||||
data-slot="today-hero-all-clear"
|
||||
@@ -380,9 +429,7 @@ export function TodayHero({
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
{digest.score &&
|
||||
digest.score.delta !== null &&
|
||||
digest.score.deltaReason === null ? (
|
||||
{deltaShown && digest.score?.delta != null ? (
|
||||
<span
|
||||
data-slot="today-hero-score-delta"
|
||||
className="text-muted-foreground text-xs tabular-nums"
|
||||
@@ -390,6 +437,14 @@ export function TodayHero({
|
||||
{formatDelta(digest.score.delta, t)}
|
||||
</span>
|
||||
) : null}
|
||||
{steadyLine ? (
|
||||
<span
|
||||
data-slot="today-hero-score-steady"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{steadyLine}
|
||||
</span>
|
||||
) : null}
|
||||
{basisLine ? (
|
||||
<span
|
||||
data-slot="today-hero-score-basis"
|
||||
@@ -411,8 +466,11 @@ export function TodayHero({
|
||||
{/* Worth-a-look rail — S1's `PriorityCard`s, bounded 0–3. When the
|
||||
digest is all-clear, a first-class muted line stands in for the
|
||||
rail (calm inversion of an alarm), never an empty card. */}
|
||||
{/* The all-clear sentence only ever stands in for an empty card
|
||||
(the compact composition above). Once a lead or facts carry the
|
||||
day, an empty rail simply is not drawn. */}
|
||||
{rail ??
|
||||
(!compactAllClear ? (
|
||||
(!compactAllClear && !hasFacts && !lead ? (
|
||||
<p
|
||||
data-slot="today-hero-all-clear"
|
||||
className="text-muted-foreground text-sm"
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Today's place in the cycle, for the dashboard's Today overview.
|
||||
*
|
||||
* The cycle page is the one place that decides what may be said about today
|
||||
* (`resolveCycleVerdict` over the calendar grid). This read runs the same two
|
||||
* steps over the same rows, narrowed to what a one-line summary needs, so the
|
||||
* dashboard can never call a day something the cycle page does not: the same
|
||||
* still-learning gate withholds the phase, the same ceiling stops the count
|
||||
* once a cycle runs past its typical length.
|
||||
*
|
||||
* Read-only: no profile upsert, no forecast write. The caller has already
|
||||
* confirmed the cycle module is on for the record.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { buildCalendar, type CalendarDayLogRow } from "./engine-adapter";
|
||||
import { addDays, dayDiff } from "./day-math";
|
||||
import { goalAllowsFertileWindow } from "./dto";
|
||||
import { BBT_WINDOW, type CyclePhase } from "./types";
|
||||
import { resolveCycleVerdict } from "./verdict";
|
||||
import { moodDateKey } from "@/lib/mood/date-key";
|
||||
import { startOfLocalDayKey } from "@/lib/tz/local-day";
|
||||
|
||||
/** How far back the grid reaches; the calendar route's default span. */
|
||||
const PAST_DAYS = 90;
|
||||
|
||||
export interface TodayCycleRead {
|
||||
dayOfCycle: number;
|
||||
/** Null while the engine is still learning the cycle. */
|
||||
phase: CyclePhase | null;
|
||||
}
|
||||
|
||||
export async function readTodayCycle(
|
||||
userId: string,
|
||||
timezone: string,
|
||||
now: Date,
|
||||
): Promise<TodayCycleRead | null> {
|
||||
const today = moodDateKey(now, timezone);
|
||||
const from = addDays(today, -PAST_DAYS);
|
||||
const logFrom =
|
||||
dayDiff(from, addDays(today, -BBT_WINDOW)) <= 0
|
||||
? from
|
||||
: addDays(today, -BBT_WINDOW);
|
||||
|
||||
const [profile, cycles, dayLogRows, nightlyTemps] = await Promise.all([
|
||||
prisma.cycleProfile.findUnique({ where: { userId } }),
|
||||
prisma.menstrualCycle.findMany({
|
||||
where: { userId, deletedAt: null },
|
||||
orderBy: { startDate: "asc" },
|
||||
}),
|
||||
prisma.cycleDayLog.findMany({
|
||||
where: { userId, deletedAt: null, date: { gte: logFrom } },
|
||||
orderBy: { date: "asc" },
|
||||
select: {
|
||||
date: true,
|
||||
flow: true,
|
||||
basalBodyTempC: true,
|
||||
temperatureExcluded: true,
|
||||
ovulationTest: true,
|
||||
cervicalMucus: true,
|
||||
cervixPosition: true,
|
||||
cervixFirmness: true,
|
||||
cervixOpening: true,
|
||||
intermenstrualBleeding: true,
|
||||
},
|
||||
}),
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
type: "WRIST_TEMPERATURE",
|
||||
measuredAt: { gte: startOfLocalDayKey(addDays(today, -90), timezone) },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
]);
|
||||
if (!profile || cycles.length === 0) return null;
|
||||
|
||||
// The intent fields and the presence flags never reach a phase or a day
|
||||
// count; the grid carries them for the calendar's own overlay only.
|
||||
const dayLogs: CalendarDayLogRow[] = dayLogRows.map((row) => ({
|
||||
...row,
|
||||
hasSymptoms: false,
|
||||
hasNote: false,
|
||||
sexualActivity: false,
|
||||
pregnancyTest: null,
|
||||
progesteroneTest: null,
|
||||
contraceptive: null,
|
||||
}));
|
||||
const nights = nightlyTemps.map((m) => ({
|
||||
date: moodDateKey(m.measuredAt, timezone),
|
||||
valueC: m.value,
|
||||
}));
|
||||
|
||||
const { prediction, days } = buildCalendar(
|
||||
profile,
|
||||
cycles,
|
||||
dayLogs,
|
||||
nights,
|
||||
from,
|
||||
today,
|
||||
today,
|
||||
goalAllowsFertileWindow(profile.goal),
|
||||
);
|
||||
const verdict = resolveCycleVerdict({
|
||||
days,
|
||||
today,
|
||||
profile: {
|
||||
typicalCycleLength: profile.typicalCycleLength,
|
||||
typicalPeriodLength: profile.typicalPeriodLength,
|
||||
lutealPhaseLength: profile.lutealPhaseLength,
|
||||
},
|
||||
nextPeriodStart: prediction?.nextPeriodStart ?? null,
|
||||
lastPeriodStart:
|
||||
cycles.filter((c) => c.startDate <= today).at(-1)?.startDate ?? null,
|
||||
});
|
||||
|
||||
if (verdict.state !== "IN_CYCLE" || verdict.dayOfCycle === null) return null;
|
||||
return { dayOfCycle: verdict.dayOfCycle, phase: verdict.phase };
|
||||
}
|
||||
@@ -38,6 +38,9 @@ function makeDigest(over: Partial<DailyDigest> = {}): DailyDigest {
|
||||
score: { value: 82, band: "good", delta: 1 },
|
||||
topSignal: null,
|
||||
briefingLead: "Sleep looked solid last night.",
|
||||
lead: { text: "Sleep looked solid last night.", source: "briefing" },
|
||||
today: [],
|
||||
restMode: null,
|
||||
line: "Sleep looked solid last night.",
|
||||
worthALook: [],
|
||||
justIn: null,
|
||||
|
||||
@@ -110,7 +110,12 @@ describe("buildDailyDigest — composition", () => {
|
||||
it("lifts score, top signal, and briefing lead from cached inputs (no recompute)", () => {
|
||||
const d = buildDailyDigest(input(), t);
|
||||
expect(d.generatedAt).toBe(NOW.toISOString());
|
||||
expect(d.score).toEqual({ value: 82, band: "good", delta: 3 });
|
||||
expect(d.score).toEqual({
|
||||
value: 82,
|
||||
band: "good",
|
||||
delta: 3,
|
||||
steadyWeeks: null,
|
||||
});
|
||||
expect(d.topSignal?.headline).toBe("Blood pressure is holding steady");
|
||||
expect(d.briefingLead).toBe(
|
||||
"Your blood pressure is holding steady this week.",
|
||||
@@ -1457,3 +1462,109 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place
|
||||
).toEqual(["Dr. Weiss — today", "Dentist — tomorrow"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDailyDigest — Today overview", () => {
|
||||
const RHR_HIGH = {
|
||||
type: "RESTING_HEART_RATE",
|
||||
value: 61,
|
||||
low: 50,
|
||||
high: 58,
|
||||
direction: "above" as const,
|
||||
daysAgo: 0,
|
||||
valueLabel: "61 bpm",
|
||||
rangeLabel: "50 to 58 bpm",
|
||||
moduleKey: null,
|
||||
};
|
||||
|
||||
it("skips a greeting and publishes the sentence with content as the lead", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
briefing: {
|
||||
...briefing,
|
||||
paragraph:
|
||||
"Good morning. Your latest blood pressure is sitting in the optimal band.",
|
||||
},
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(d.briefingLead).toBe(
|
||||
"Your latest blood pressure is sitting in the optimal band.",
|
||||
);
|
||||
expect(d.lead).toEqual({
|
||||
text: "Your latest blood pressure is sitting in the optimal band.",
|
||||
source: "briefing",
|
||||
});
|
||||
expect(d.line).toBe(d.briefingLead);
|
||||
});
|
||||
|
||||
it("without AI, leads with the strongest signal instead of the score", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
ai: {
|
||||
...DIGEST_AI_AVAILABLE,
|
||||
briefing: aiUnavailable("user_disabled"),
|
||||
},
|
||||
vitals: [RHR_HIGH],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(d.lead).toEqual({
|
||||
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);
|
||||
});
|
||||
|
||||
it("publishes Rest Mode and its fact only while the illness module is on", () => {
|
||||
const on = buildDailyDigest(input({ restMode: { day: 2 } }), t);
|
||||
expect(on.restMode).toEqual({ day: 2 });
|
||||
expect(on.today[0]).toMatchObject({ kind: "rest_mode", value: "Day 2" });
|
||||
|
||||
const off = buildDailyDigest(
|
||||
input({ restMode: { day: 2 }, modules: { illness: false } }),
|
||||
t,
|
||||
);
|
||||
expect(off.restMode).toBeNull();
|
||||
expect(off.today.some((f) => f.kind === "rest_mode")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps an appointment on the rail and out of Today, never both", () => {
|
||||
const visits = [
|
||||
{
|
||||
id: "v1",
|
||||
kind: "ROUTINE",
|
||||
occurredAt: "2026-07-16T13:00:00.000Z",
|
||||
practitionerName: "Dr. Example",
|
||||
dayOffset: 0,
|
||||
timeLabel: "15:00",
|
||||
},
|
||||
];
|
||||
const withRail = buildDailyDigest(input({ upcomingVisits: visits }), t);
|
||||
expect(withRail.worthALook.some((i) => i.kind === "upcoming_visit")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(withRail.today.some((f) => f.kind === "appointment")).toBe(false);
|
||||
|
||||
const railOff = buildDailyDigest(
|
||||
input({
|
||||
upcomingVisits: visits,
|
||||
enabledHeroItemKinds: PRIORITY_ITEM_KINDS.filter(
|
||||
(k) => k !== "upcoming_visit",
|
||||
),
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(railOff.today).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "appointment",
|
||||
value: "Today 15:00, Dr. Example",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("carries the steady run on the score", () => {
|
||||
const d = buildDailyDigest(input({ scoreSteadyWeeks: 5 }), t);
|
||||
expect(d.score?.steadyWeeks).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -21,9 +21,22 @@ vi.mock("@/lib/db", () => ({
|
||||
personalRecord: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
arrivalReaction: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounter: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/insights/derived/coincident-deviation", () => ({
|
||||
computeCoincidentDeviation: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/illness/rest-mode", () => ({
|
||||
resolveRestMode: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cycle/today-verdict", () => ({
|
||||
readTodayCycle: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/dashboard/snapshot-read", () => ({
|
||||
readDashboardSnapshotCached: vi.fn(),
|
||||
}));
|
||||
@@ -38,6 +51,9 @@ vi.mock("@/lib/rollups/measurement-coverage", () => ({
|
||||
|
||||
vi.mock("@/lib/insights/derived/baseline", () => ({
|
||||
readDayMeanSeries: vi.fn().mockResolvedValue({ points: [], source: "none" }),
|
||||
loadBaselineProfile: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ageYears: 40, sex: "FEMALE", heightCm: 170 }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/analytics/intraday-pulse-io", () => ({
|
||||
@@ -73,6 +89,9 @@ import {
|
||||
aiCapabilityToServe,
|
||||
} from "@/lib/ai/capabilities/gate";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { computeCoincidentDeviation } from "@/lib/insights/derived/coincident-deviation";
|
||||
import { resolveRestMode } from "@/lib/illness/rest-mode";
|
||||
import { readTodayCycle } from "@/lib/cycle/today-verdict";
|
||||
import {
|
||||
AI_AVAILABLE,
|
||||
aiUnavailable,
|
||||
@@ -117,6 +136,16 @@ beforeEach(() => {
|
||||
vi.mocked(loadIntradayPulse).mockResolvedValue({ tension: null } as never);
|
||||
vi.mocked(aiCapabilityForRecord).mockResolvedValue(AI_AVAILABLE);
|
||||
vi.mocked(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE);
|
||||
vi.mocked(computeCoincidentDeviation).mockResolvedValue({
|
||||
status: "insufficient",
|
||||
} as never);
|
||||
vi.mocked(resolveRestMode).mockResolvedValue({
|
||||
active: false,
|
||||
since: null,
|
||||
episodeCount: 0,
|
||||
episodes: [],
|
||||
});
|
||||
vi.mocked(readTodayCycle).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe("loadDailyDigest — milestone day", () => {
|
||||
@@ -480,3 +509,187 @@ describe("loadDailyDigest — due check-ups and today's visits", () => {
|
||||
expect(digest.worthALook.map((i) => i.kind)).toContain("preventive_care");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadDailyDigest — Today overview inputs", () => {
|
||||
// Visits are left off the rail here so the Today line is the one place
|
||||
// they appear; with the rail kind on, the line steps aside (see
|
||||
// `today-overview.test.ts`).
|
||||
const NO_VISIT_RAIL = PRIORITY_ITEM_KINDS.filter(
|
||||
(kind) => kind !== "upcoming_visit",
|
||||
);
|
||||
|
||||
function visit(at: string, name = "GP") {
|
||||
return {
|
||||
id: `v-${at}`,
|
||||
kind: "ROUTINE",
|
||||
occurredAt: new Date(at),
|
||||
practitioner: { name },
|
||||
};
|
||||
}
|
||||
|
||||
// 21:30Z on 17 July is 23:30 in Berlin (CEST): the last half hour of the
|
||||
// local day. A visit at 23:45 local is still today; one at 00:30 local is
|
||||
// tomorrow, although both fall on the same UTC date.
|
||||
const LATE_EVENING = new Date("2026-07-17T21:30:00.000Z");
|
||||
|
||||
it("puts a 23:45 visit under today and a 00:30 visit under tomorrow, late in the evening", async () => {
|
||||
vi.mocked(prisma.encounter.findMany).mockResolvedValueOnce([
|
||||
visit("2026-07-17T21:45:00.000Z", "Dr. Late"),
|
||||
] as never);
|
||||
const today = await loadDailyDigest(USER, LATE_EVENING, {
|
||||
enabledItemKinds: NO_VISIT_RAIL,
|
||||
});
|
||||
expect(today.today).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "appointment",
|
||||
value: "daily.todayFact.appointment.today",
|
||||
}),
|
||||
]);
|
||||
|
||||
vi.mocked(prisma.encounter.findMany).mockResolvedValueOnce([
|
||||
visit("2026-07-17T22:30:00.000Z", "Dr. Early"),
|
||||
] as never);
|
||||
const tomorrow = await loadDailyDigest(USER, LATE_EVENING, {
|
||||
enabledItemKinds: NO_VISIT_RAIL,
|
||||
});
|
||||
expect(tomorrow.today).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "appointment",
|
||||
value: "daily.todayFact.appointment.tomorrow",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads the same instants on a New York clock", async () => {
|
||||
const NY_USER = { ...USER, timezone: "America/New_York" } as User;
|
||||
vi.mocked(prisma.encounter.findMany).mockResolvedValueOnce([
|
||||
visit("2026-07-17T22:30:00.000Z"),
|
||||
] as never);
|
||||
// 17:30 in New York: the 18:30 visit is still today there.
|
||||
const digest = await loadDailyDigest(NY_USER, LATE_EVENING, {
|
||||
enabledItemKinds: NO_VISIT_RAIL,
|
||||
});
|
||||
expect(digest.today[0]?.value).toBe("daily.todayFact.appointment.today");
|
||||
});
|
||||
|
||||
it("renders the visit time in the profile zone and hour-cycle preference", async () => {
|
||||
const { getServerTranslator } =
|
||||
await import("@/lib/i18n/server-translator");
|
||||
const seen: Array<Record<string, unknown> | undefined> = [];
|
||||
vi.mocked(getServerTranslator).mockReturnValueOnce({
|
||||
t: (key: string, params?: Record<string, unknown>) => {
|
||||
if (key === "daily.todayFact.appointment.today") seen.push(params);
|
||||
return key;
|
||||
},
|
||||
} as never);
|
||||
vi.mocked(prisma.encounter.findMany).mockResolvedValueOnce([
|
||||
visit("2026-07-17T21:45:00.000Z", "Dr. Late"),
|
||||
] as never);
|
||||
await loadDailyDigest(
|
||||
{ ...USER, timeFormat: "H12" } as User,
|
||||
LATE_EVENING,
|
||||
{ enabledItemKinds: NO_VISIT_RAIL },
|
||||
);
|
||||
expect(seen[0]).toEqual({ time: "11:45 PM", what: "Dr. Late" });
|
||||
});
|
||||
|
||||
it("passes Rest Mode through, counted on the reader's own calendar", async () => {
|
||||
vi.mocked(resolveRestMode).mockResolvedValueOnce({
|
||||
active: true,
|
||||
// 22:00Z on 14 July is already 15 July in Berlin: day 3 on 17 July.
|
||||
since: "2026-07-14T22:00:00.000Z",
|
||||
episodeCount: 1,
|
||||
episodes: [],
|
||||
});
|
||||
const digest = await loadDailyDigest(USER, NOW);
|
||||
expect(digest.restMode).toEqual({ day: 3 });
|
||||
expect(digest.today[0]).toMatchObject({ kind: "rest_mode" });
|
||||
});
|
||||
|
||||
it("does not read Rest Mode or the cycle while their modules are off", async () => {
|
||||
vi.mocked(resolveModuleMap).mockResolvedValueOnce({
|
||||
illness: false,
|
||||
cycle: false,
|
||||
} as never);
|
||||
const digest = await loadDailyDigest(USER, NOW);
|
||||
expect(resolveRestMode).not.toHaveBeenCalled();
|
||||
expect(readTodayCycle).not.toHaveBeenCalled();
|
||||
expect(digest.restMode).toBeNull();
|
||||
});
|
||||
|
||||
it("reads the cycle in the profile timezone and keeps a failed read quiet", async () => {
|
||||
vi.mocked(readTodayCycle).mockRejectedValueOnce(new Error("boom"));
|
||||
const digest = await loadDailyDigest(USER, NOW);
|
||||
expect(readTodayCycle).toHaveBeenCalledWith(USER.id, "Europe/Berlin", NOW);
|
||||
expect(digest.today.some((f) => f.kind === "cycle")).toBe(false);
|
||||
});
|
||||
|
||||
it("builds the deterministic lead from the vitals read when AI is off", async () => {
|
||||
vi.mocked(readDashboardSnapshotCached).mockResolvedValueOnce({
|
||||
...SNAPSHOT,
|
||||
body: {
|
||||
...SNAPSHOT.body,
|
||||
briefingAi: aiUnavailable("user_disabled"),
|
||||
},
|
||||
} as never);
|
||||
vi.mocked(aiCapabilityToServe).mockResolvedValue(
|
||||
aiUnavailable("user_disabled"),
|
||||
);
|
||||
vi.mocked(computeCoincidentDeviation).mockResolvedValueOnce({
|
||||
status: "ok",
|
||||
value: {
|
||||
fired: false,
|
||||
contributing: [],
|
||||
day: "2026-07-17",
|
||||
illnessExplained: false,
|
||||
vitals: [
|
||||
{
|
||||
type: "RESTING_HEART_RATE",
|
||||
value: 61,
|
||||
center: 54,
|
||||
low: 50,
|
||||
high: 58,
|
||||
outside: true,
|
||||
direction: "above",
|
||||
daysAgo: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never);
|
||||
const digest = await loadDailyDigest(USER, NOW);
|
||||
expect(digest.lead).toEqual({
|
||||
text: "daily.lead.vitalOutside.above",
|
||||
source: "signal",
|
||||
});
|
||||
expect(digest.briefingLead).toBeNull();
|
||||
});
|
||||
|
||||
it("publishes how long the score has held", async () => {
|
||||
vi.mocked(readDashboardSnapshotCached).mockResolvedValueOnce({
|
||||
...SNAPSHOT,
|
||||
body: {
|
||||
...SNAPSHOT.body,
|
||||
healthScore: {
|
||||
score: 94,
|
||||
band: "GREEN",
|
||||
delta: null,
|
||||
deltaReason: "below_noise_floor",
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
const rows = Array.from({ length: 22 }, (_, i) => ({
|
||||
dayKey: new Date(Date.parse("2026-07-17T12:00:00Z") - i * 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10),
|
||||
composite: 94,
|
||||
band: "GREEN",
|
||||
scoreVersion: 4,
|
||||
composition: ["BLOOD_PRESSURE"],
|
||||
}));
|
||||
vi.mocked(prisma.healthScoreRecord.findMany).mockResolvedValueOnce(
|
||||
rows as never,
|
||||
);
|
||||
const digest = await loadDailyDigest(USER, NOW);
|
||||
expect(digest.score?.steadyWeeks).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { steadyWeeks, type StoredScoreDay } from "@/lib/daily/score-steady";
|
||||
import { addDays } from "@/lib/cycle/day-math";
|
||||
|
||||
const TODAY = "2026-10-04";
|
||||
|
||||
/** `n` consecutive days ending today, all at `composite`. */
|
||||
function run(
|
||||
n: number,
|
||||
composite = 94,
|
||||
over: Partial<StoredScoreDay> = {},
|
||||
): StoredScoreDay[] {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
dayKey: addDays(TODAY, -i),
|
||||
composite,
|
||||
band: "GREEN",
|
||||
scoreVersion: 4,
|
||||
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
|
||||
...over,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("steadyWeeks", () => {
|
||||
it("counts whole weeks of a held score", () => {
|
||||
expect(steadyWeeks(run(29), TODAY, 94)).toBe(4);
|
||||
});
|
||||
|
||||
it("stays quiet below two weeks", () => {
|
||||
expect(steadyWeeks(run(13), TODAY, 94)).toBeNull();
|
||||
expect(steadyWeeks(run(15), TODAY, 94)).toBe(2);
|
||||
});
|
||||
|
||||
it("tolerates two points of wobble, not three", () => {
|
||||
const rows = run(29).map((r, i) => ({
|
||||
...r,
|
||||
composite: i % 2 === 0 ? 94 : 92,
|
||||
}));
|
||||
expect(steadyWeeks(rows, TODAY, 94)).toBe(4);
|
||||
const moved = run(29).map((r, i) =>
|
||||
i === 10 ? { ...r, composite: 90 } : r,
|
||||
);
|
||||
expect(steadyWeeks(moved, TODAY, 94)).toBeNull();
|
||||
});
|
||||
|
||||
it("ends the run at a band change, a recipe change or a new algorithm", () => {
|
||||
const band = run(29).map((r, i) => (i >= 15 ? { ...r, band: "AMBER" } : r));
|
||||
expect(steadyWeeks(band, TODAY, 94)).toBe(2);
|
||||
const recipe = run(29).map((r, i) =>
|
||||
i >= 15 ? { ...r, composition: ["BLOOD_PRESSURE"] } : r,
|
||||
);
|
||||
expect(steadyWeeks(recipe, TODAY, 94)).toBe(2);
|
||||
const version = run(29).map((r, i) =>
|
||||
i >= 15 ? { ...r, scoreVersion: 3 } : r,
|
||||
);
|
||||
expect(steadyWeeks(version, TODAY, 94)).toBe(2);
|
||||
});
|
||||
|
||||
it("does not bridge a gap longer than three days", () => {
|
||||
const rows = run(29).filter((_, i) => i < 15 || i > 19);
|
||||
expect(steadyWeeks(rows, TODAY, 94)).toBe(2);
|
||||
const short = run(29).filter((_, i) => i < 15 || i > 16);
|
||||
expect(steadyWeeks(short, TODAY, 94)).toBe(4);
|
||||
});
|
||||
|
||||
it("says nothing when the record stopped before today", () => {
|
||||
const stale = run(40).filter((r) => r.dayKey <= addDays(TODAY, -5));
|
||||
expect(steadyWeeks(stale, TODAY, 94)).toBeNull();
|
||||
});
|
||||
|
||||
it("says nothing when the ring shows a number the record does not", () => {
|
||||
expect(steadyWeeks(run(29), TODAY, 88)).toBeNull();
|
||||
});
|
||||
|
||||
it("reads rows in any order", () => {
|
||||
expect(steadyWeeks(run(29).reverse(), TODAY, 94)).toBe(4);
|
||||
expect(steadyWeeks([], TODAY, 94)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,588 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import type { DailyBriefing } from "@/lib/ai/schema";
|
||||
import type { MedsTodayBlock } from "@/lib/dashboard/meds-today";
|
||||
import type { PriorityItem } from "@/lib/daily/priority-item";
|
||||
import {
|
||||
buildTodayOverview,
|
||||
firstSubstantiveSentence,
|
||||
isGreetingOnly,
|
||||
MAX_TODAY_FACTS,
|
||||
type TodayOverviewInput,
|
||||
type TodayVital,
|
||||
} from "@/lib/daily/today-overview";
|
||||
|
||||
const t = getServerTranslator("en").t;
|
||||
|
||||
function meds(over: Partial<MedsTodayBlock> = {}): MedsTodayBlock {
|
||||
return {
|
||||
activeCount: 0,
|
||||
scheduledToday: 0,
|
||||
takenToday: 0,
|
||||
skippedToday: 0,
|
||||
nextDueAt: null,
|
||||
nextDueOverdue: false,
|
||||
nextDueMedicationName: null,
|
||||
nextDueMedicationId: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function vital(over: Partial<TodayVital> = {}): TodayVital {
|
||||
return {
|
||||
type: "RESTING_HEART_RATE",
|
||||
value: 54,
|
||||
low: 50,
|
||||
high: 58,
|
||||
direction: "in",
|
||||
daysAgo: 0,
|
||||
valueLabel: "54 bpm",
|
||||
rangeLabel: "50 to 58 bpm",
|
||||
moduleKey: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/** A quiet day with nothing at all to say. */
|
||||
function input(over: Partial<TodayOverviewInput> = {}): TodayOverviewInput {
|
||||
return {
|
||||
locale: "en",
|
||||
modules: {},
|
||||
reactionLine: null,
|
||||
briefing: null,
|
||||
scoreValue: 94,
|
||||
medsToday: meds(),
|
||||
visits: [],
|
||||
rail: [],
|
||||
restMode: null,
|
||||
sleep: null,
|
||||
vitals: [],
|
||||
cycle: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const briefing = (paragraph: string, headline?: string): DailyBriefing => ({
|
||||
paragraph,
|
||||
signalsOfDay: headline
|
||||
? [
|
||||
{
|
||||
sourceMetric: "pulse",
|
||||
tone: "info",
|
||||
headline,
|
||||
nudge: "",
|
||||
delta: null,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
keyFindings: [],
|
||||
});
|
||||
|
||||
/** Every fact the builder can produce, all at once. */
|
||||
function busyDay(over: Partial<TodayOverviewInput> = {}): TodayOverviewInput {
|
||||
return input({
|
||||
restMode: { day: 3 },
|
||||
medsToday: meds({ scheduledToday: 3, takenToday: 1 }),
|
||||
visits: [
|
||||
{ id: "v1", dayOffset: 1, timeLabel: "09:30", what: "Dr. Example" },
|
||||
],
|
||||
sleep: { minutes: 440, usualMinutes: 430 },
|
||||
vitals: [
|
||||
vital(),
|
||||
vital({ type: "HEART_RATE_VARIABILITY", valueLabel: "48 ms" }),
|
||||
],
|
||||
cycle: { dayOfCycle: 12, phase: "FOLLICULAR" },
|
||||
// An AI lead keeps every fact in the list; the deterministic lead would
|
||||
// take one of them.
|
||||
briefing: briefing("Resting heart rate held at 54 bpm overnight."),
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
describe("greeting detection", () => {
|
||||
it.each([
|
||||
"Good morning.",
|
||||
"Good evening!",
|
||||
"Hello there.",
|
||||
"Guten Morgen.",
|
||||
"Hallo!",
|
||||
"Buenos días.",
|
||||
"Bonjour !",
|
||||
"Buongiorno.",
|
||||
"Dzień dobry.",
|
||||
"좋은 아침이에요.",
|
||||
"안녕하세요.",
|
||||
"Welcome back.",
|
||||
"Nice.",
|
||||
])("treats %j as a greeting", (sentence) => {
|
||||
expect(isGreetingOnly(sentence)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Good morning, your resting heart rate is 52.",
|
||||
"High blood pressure readings eased this week.",
|
||||
"Morning blood pressure sat in the optimal band all week.",
|
||||
"Your sleep landed right around your target again.",
|
||||
])("treats %j as content", (sentence) => {
|
||||
expect(isGreetingOnly(sentence)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("firstSubstantiveSentence", () => {
|
||||
it("skips a greeting and leads with the sentence that says something", () => {
|
||||
expect(
|
||||
firstSubstantiveSentence(
|
||||
"Good morning. Your latest blood pressure is sitting in the optimal band.",
|
||||
94,
|
||||
),
|
||||
).toBe("Your latest blood pressure is sitting in the optimal band.");
|
||||
});
|
||||
|
||||
it("skips a sentence that only repeats the ring's number", () => {
|
||||
expect(
|
||||
firstSubstantiveSentence(
|
||||
"Your health score is 94. Sleep dipped slightly last night.",
|
||||
94,
|
||||
),
|
||||
).toBe("Sleep dipped slightly last night.");
|
||||
});
|
||||
|
||||
it("keeps decimals inside one sentence", () => {
|
||||
expect(
|
||||
firstSubstantiveSentence("You slept 7.5 hours, a solid night.", null),
|
||||
).toBe("You slept 7.5 hours, a solid night.");
|
||||
});
|
||||
|
||||
it("returns null when nothing qualifies", () => {
|
||||
expect(firstSubstantiveSentence("Good morning. Hi!", 94)).toBeNull();
|
||||
expect(firstSubstantiveSentence("", 94)).toBeNull();
|
||||
expect(firstSubstantiveSentence(null, 94)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("lead precedence", () => {
|
||||
it("prefers the reaction line over the briefing", () => {
|
||||
const o = buildTodayOverview(
|
||||
input({
|
||||
reactionLine: "A solid night, deeper than your recent stretch.",
|
||||
briefing: briefing("Your week is trending steady."),
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(o.lead).toEqual({
|
||||
text: "A solid night, deeper than your recent stretch.",
|
||||
source: "reaction",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops a greeting-only reaction line and falls through", () => {
|
||||
const o = buildTodayOverview(
|
||||
input({
|
||||
reactionLine: "Good morning!",
|
||||
briefing: briefing("Your week is trending steady."),
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(o.lead).toEqual({
|
||||
text: "Your week is trending steady.",
|
||||
source: "briefing",
|
||||
});
|
||||
});
|
||||
|
||||
it("never leads with 'Good morning.' (the defect this replaces)", () => {
|
||||
const o = buildTodayOverview(
|
||||
input({
|
||||
briefing: briefing(
|
||||
"Good morning. Your latest blood pressure is sitting in the optimal band.",
|
||||
),
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(o.lead?.text).toBe(
|
||||
"Your latest blood pressure is sitting in the optimal band.",
|
||||
);
|
||||
expect(o.briefingLead).toBe(o.lead?.text);
|
||||
});
|
||||
|
||||
it("leads with the top-signal headline when the paragraph has nothing", () => {
|
||||
const o = buildTodayOverview(
|
||||
input({
|
||||
briefing: briefing("Your health score is 94.", "Pulse is settling"),
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(o.lead).toEqual({ text: "Pulse is settling", source: "briefing" });
|
||||
});
|
||||
|
||||
it("builds a deterministic lead when no AI text exists", () => {
|
||||
const o = buildTodayOverview(
|
||||
input({ vitals: [vital(), vital({ type: "HEART_RATE_VARIABILITY" })] }),
|
||||
t,
|
||||
);
|
||||
expect(o.lead).toEqual({
|
||||
text: "All 2 of your latest vitals sit inside their usual range.",
|
||||
source: "signal",
|
||||
});
|
||||
});
|
||||
|
||||
it("has no lead, and no filler, when there is nothing to say", () => {
|
||||
const o = buildTodayOverview(input(), t);
|
||||
expect(o.lead).toBeNull();
|
||||
expect(o.today).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deterministic lead — strongest signal", () => {
|
||||
it("names the vital furthest outside its range, with its numbers", () => {
|
||||
const o = buildTodayOverview(
|
||||
input({
|
||||
vitals: [
|
||||
vital({ value: 60, direction: "above", valueLabel: "60 bpm" }),
|
||||
vital({
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
value: 20,
|
||||
low: 40,
|
||||
high: 60,
|
||||
direction: "below",
|
||||
valueLabel: "20 ms",
|
||||
rangeLabel: "40 to 60 ms",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
// HRV is a full band width below; resting HR a quarter above.
|
||||
expect(o.lead?.text).toBe(
|
||||
"Heart-rate variability is at 20 ms, below your usual range of 40 to 60 ms, and 1 more vital is outside its range too.",
|
||||
);
|
||||
expect(o.lead?.source).toBe("signal");
|
||||
});
|
||||
|
||||
it("says a single out-of-range vital plainly", () => {
|
||||
const o = buildTodayOverview(
|
||||
input({
|
||||
vitals: [
|
||||
vital({ value: 61, direction: "above", valueLabel: "61 bpm" }),
|
||||
],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(o.lead?.text).toBe(
|
||||
"Resting heart rate is at 61 bpm, above your usual range of 50 to 58 bpm.",
|
||||
);
|
||||
});
|
||||
|
||||
it("puts an unusual night ahead of calm vitals", () => {
|
||||
const o = buildTodayOverview(
|
||||
input({
|
||||
sleep: { minutes: 330, usualMinutes: 440 },
|
||||
vitals: [vital(), vital({ type: "PULSE" })],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(o.lead?.text).toBe(
|
||||
"Last night you slept 5h 30m, 1h 50m less than usual.",
|
||||
);
|
||||
// The night is the lead, so it is not repeated as a fact; the vitals stay.
|
||||
expect(o.today.map((f) => f.kind)).toEqual(["vitals"]);
|
||||
});
|
||||
|
||||
it("removes the fact it was built from, and only that one", () => {
|
||||
const o = buildTodayOverview(
|
||||
input({
|
||||
vitals: [vital(), vital({ type: "PULSE" })],
|
||||
sleep: { minutes: 440, usualMinutes: 430 },
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(o.lead?.source).toBe("signal");
|
||||
expect(o.today.map((f) => f.kind)).toEqual(["sleep"]);
|
||||
});
|
||||
|
||||
it("reads an ordinary night when there is nothing else", () => {
|
||||
const o = buildTodayOverview(
|
||||
input({ sleep: { minutes: 440, usualMinutes: 430 } }),
|
||||
t,
|
||||
);
|
||||
expect(o.lead?.text).toBe(
|
||||
"Last night you slept 7h 20m, close to your usual.",
|
||||
);
|
||||
expect(o.today).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores a vital that is not from today or yesterday", () => {
|
||||
const o = buildTodayOverview(
|
||||
input({
|
||||
vitals: [vital({ daysAgo: 4, direction: "above", value: 70 })],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(o.lead).toBeNull();
|
||||
expect(o.today).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AI on and off", () => {
|
||||
it("keeps the same facts with and without AI text", () => {
|
||||
const withAi = buildTodayOverview(busyDay({ restMode: null }), t);
|
||||
const withoutAi = buildTodayOverview(
|
||||
busyDay({ restMode: null, briefing: null }),
|
||||
t,
|
||||
);
|
||||
expect(withAi.lead?.source).toBe("briefing");
|
||||
expect(withoutAi.lead?.source).toBe("signal");
|
||||
// Without AI the deterministic lead takes the vitals line; every other
|
||||
// fact is identical.
|
||||
expect(withAi.today.filter((f) => f.kind !== "vitals")).toEqual(
|
||||
withoutAi.today,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Today facts", () => {
|
||||
it("orders facts by priority and caps them at five, never padding", () => {
|
||||
const o = buildTodayOverview(busyDay(), t);
|
||||
expect(o.today.map((f) => f.kind)).toEqual([
|
||||
"rest_mode",
|
||||
"medications",
|
||||
"appointment",
|
||||
"sleep",
|
||||
"vitals",
|
||||
]);
|
||||
expect(o.today).toHaveLength(MAX_TODAY_FACTS);
|
||||
});
|
||||
|
||||
it("states values without asking for anything", () => {
|
||||
const o = buildTodayOverview(busyDay(), t);
|
||||
const byKind = Object.fromEntries(o.today.map((f) => [f.kind, f]));
|
||||
expect(byKind.rest_mode.value).toBe("Day 3");
|
||||
expect(byKind.medications.value).toBe("1 of 3 taken");
|
||||
expect(byKind.appointment.value).toBe("Tomorrow 09:30, Dr. Example");
|
||||
expect(byKind.sleep.value).toBe("7h 20m, close to your usual");
|
||||
expect(byKind.vitals.value).toBe("2 checked, all in your range");
|
||||
for (const fact of o.today) {
|
||||
expect(fact.value).not.toMatch(/\b(please|log|add|record|measure)\b/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the cycle once there is room for it", () => {
|
||||
const o = buildTodayOverview(busyDay({ restMode: null }), t);
|
||||
expect(o.today.at(-1)).toMatchObject({
|
||||
kind: "cycle",
|
||||
value: "Follicular, day 12",
|
||||
href: "/cycle",
|
||||
});
|
||||
});
|
||||
|
||||
it("says only the day while the cycle phase is withheld", () => {
|
||||
const o = buildTodayOverview(
|
||||
input({ cycle: { dayOfCycle: 4, phase: null } }),
|
||||
t,
|
||||
);
|
||||
expect(o.today).toEqual([
|
||||
expect.objectContaining({ kind: "cycle", value: "Day 4" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("names the out-of-range vital in the vitals line", () => {
|
||||
const o = buildTodayOverview(
|
||||
input({
|
||||
briefing: briefing("Your week is trending steady."),
|
||||
vitals: [
|
||||
vital({ direction: "above", value: 61 }),
|
||||
vital({ type: "PULSE" }),
|
||||
],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(o.today).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "vitals",
|
||||
value: "Resting heart rate above your range",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips medications when nothing is scheduled today", () => {
|
||||
const o = buildTodayOverview(
|
||||
input({ medsToday: meds({ activeCount: 2, scheduledToday: 0 }) }),
|
||||
t,
|
||||
);
|
||||
expect(o.today).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("module gating", () => {
|
||||
const cases: Array<[string, TodayOverviewInput["modules"], string]> = [
|
||||
["illness", { illness: false }, "rest_mode"],
|
||||
["medications", { medications: false }, "medications"],
|
||||
["sleep", { sleep: false }, "sleep"],
|
||||
["cycle", { cycle: false }, "cycle"],
|
||||
];
|
||||
|
||||
it.each(cases)(
|
||||
"drops the %s fact when its module is off",
|
||||
(_name, modules, kind) => {
|
||||
const on = buildTodayOverview(busyDay({ restMode: null }), t);
|
||||
const all = buildTodayOverview(
|
||||
busyDay(kind === "rest_mode" ? {} : { restMode: null }),
|
||||
t,
|
||||
);
|
||||
const off = buildTodayOverview(
|
||||
busyDay({
|
||||
modules,
|
||||
...(kind === "rest_mode" ? {} : { restMode: null }),
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect([...on.today, ...all.today].some((f) => f.kind === kind)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(off.today.some((f) => f.kind === kind)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("never builds a sleep lead with the sleep module off", () => {
|
||||
const o = buildTodayOverview(
|
||||
input({
|
||||
modules: { sleep: false },
|
||||
sleep: { minutes: 300, usualMinutes: 440 },
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(o.lead).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves out a vital whose module is off", () => {
|
||||
const o = buildTodayOverview(
|
||||
input({
|
||||
modules: { glucose: false },
|
||||
vitals: [
|
||||
vital({
|
||||
type: "BLOOD_GLUCOSE",
|
||||
moduleKey: "glucose",
|
||||
direction: "above",
|
||||
value: 200,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(o.lead).toBeNull();
|
||||
expect(o.today).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps everything with every module on", () => {
|
||||
const o = buildTodayOverview(busyDay({ restMode: null }), t);
|
||||
expect(o.today.map((f) => f.kind)).toEqual([
|
||||
"medications",
|
||||
"appointment",
|
||||
"sleep",
|
||||
"vitals",
|
||||
"cycle",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("appointments", () => {
|
||||
const rail = (kind: PriorityItem["kind"]): PriorityItem[] => [
|
||||
{ kind, title: "x", status: "info", actions: [] },
|
||||
];
|
||||
|
||||
it("shows today before tomorrow", () => {
|
||||
const o = buildTodayOverview(
|
||||
input({
|
||||
visits: [
|
||||
{ id: "b", dayOffset: 1, timeLabel: "08:00", what: "Dentist" },
|
||||
{ id: "a", dayOffset: 0, timeLabel: "16:15", what: "GP" },
|
||||
],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(o.today[0].value).toBe("Today 16:15, GP");
|
||||
});
|
||||
|
||||
it("says nothing about the day after tomorrow", () => {
|
||||
const o = buildTodayOverview(
|
||||
input({
|
||||
visits: [{ id: "a", dayOffset: 2, timeLabel: "10:00", what: "GP" }],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(o.today).toEqual([]);
|
||||
});
|
||||
|
||||
it("counts further visits on the same day", () => {
|
||||
const o = buildTodayOverview(
|
||||
input({
|
||||
visits: [
|
||||
{ id: "a", dayOffset: 0, timeLabel: "09:00", what: "GP" },
|
||||
{ id: "b", dayOffset: 0, timeLabel: "11:00", what: "Lab" },
|
||||
],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(o.today[0].value).toBe("Today 09:00, GP +1");
|
||||
});
|
||||
|
||||
it("does not repeat a visit the rail already shows", () => {
|
||||
const o = buildTodayOverview(
|
||||
input({
|
||||
visits: [{ id: "a", dayOffset: 0, timeLabel: "09:00", what: "GP" }],
|
||||
rail: rail("upcoming_visit"),
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(o.today).toEqual([]);
|
||||
});
|
||||
|
||||
it("still shows it when the rail holds something else", () => {
|
||||
const o = buildTodayOverview(
|
||||
input({
|
||||
visits: [{ id: "a", dayOffset: 0, timeLabel: "09:00", what: "GP" }],
|
||||
rail: rail("dose_window"),
|
||||
}),
|
||||
t,
|
||||
);
|
||||
expect(o.today).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("locales", () => {
|
||||
it.each(["de", "es", "fr", "it", "pl", "ko"] as Locale[])(
|
||||
"resolves every %s string from its own bundle",
|
||||
(locale) => {
|
||||
const tl = getServerTranslator(locale).t;
|
||||
const o = buildTodayOverview(
|
||||
busyDay({ locale, restMode: null, briefing: null }),
|
||||
tl,
|
||||
);
|
||||
const en = buildTodayOverview(
|
||||
busyDay({ restMode: null, briefing: null }),
|
||||
t,
|
||||
);
|
||||
expect(o.lead?.text).toBeTruthy();
|
||||
expect(o.lead?.text).not.toBe(en.lead?.text);
|
||||
for (const fact of o.today) {
|
||||
expect(fact.label).not.toMatch(/^daily\./);
|
||||
expect(fact.value).not.toMatch(/daily\./);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("uses the Polish few-form for two to four", () => {
|
||||
const tl = getServerTranslator("pl").t;
|
||||
const o = buildTodayOverview(
|
||||
input({
|
||||
locale: "pl",
|
||||
vitals: [vital(), vital({ type: "PULSE" }), vital({ type: "WEIGHT" })],
|
||||
}),
|
||||
tl,
|
||||
);
|
||||
expect(o.lead?.text).toBe(
|
||||
"Wszystkie 3 ostatnie parametry życiowe mieszczą się w zwykłym zakresie.",
|
||||
);
|
||||
});
|
||||
});
|
||||
+116
-28
@@ -55,13 +55,20 @@ import {
|
||||
COACH_CHECKIN_ADJUST_INTENT,
|
||||
} from "@/lib/daily/coach-checkin-intents";
|
||||
import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import {
|
||||
buildTodayOverview,
|
||||
type TodayCycle,
|
||||
type TodayFact,
|
||||
type TodayLead,
|
||||
type TodayRestMode,
|
||||
type TodaySleep,
|
||||
type TodayVital,
|
||||
} from "@/lib/daily/today-overview";
|
||||
|
||||
/** At most three rail items — a glance, never a wall (§2.5, never padded). */
|
||||
export const MAX_WORTH_A_LOOK = 3;
|
||||
|
||||
/** Trim a briefing-lead sentence to a lock-screen-friendly length. */
|
||||
const MAX_LINE_LENGTH = 160;
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
/**
|
||||
@@ -123,6 +130,14 @@ export interface DailyDigestScore {
|
||||
* broad one. Omitted by older cached digests, like its siblings above.
|
||||
*/
|
||||
scoreBasis?: ScoreBasis;
|
||||
/**
|
||||
* Whole weeks the score has held where it is (same band, within two
|
||||
* points, computed the same way), read off the stored daily scores. Null
|
||||
* when it has not held for two weeks, when the stored record does not
|
||||
* reach today, or when the score moved. Optional so older cached digests
|
||||
* stay valid.
|
||||
*/
|
||||
steadyWeeks?: number | null;
|
||||
}
|
||||
|
||||
/** A broken integration, deterministically derived from `IntegrationStatus`. */
|
||||
@@ -170,6 +185,12 @@ export interface DailyDigestUpcomingVisit {
|
||||
* builder never needs the timezone.
|
||||
*/
|
||||
dayOffset: number;
|
||||
/**
|
||||
* The start time on the person's own clock, in their profile timezone,
|
||||
* rendered by the IO seam ("09:30"). Optional so a fixture that predates
|
||||
* the Today overview stays valid; the overview skips a visit without it.
|
||||
*/
|
||||
timeLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -370,6 +391,21 @@ export interface DailyDigestInput {
|
||||
* valid; the builder treats a missing value as "nothing landed today".
|
||||
*/
|
||||
arrivals?: readonly DailyDigestArrival[];
|
||||
/**
|
||||
* The reader's locale, for the overview's count-bearing strings. Optional
|
||||
* so a fixture that predates the overview stays valid (English then).
|
||||
*/
|
||||
locale?: Locale;
|
||||
/** Rest Mode, when an illness episode is active (illness module). */
|
||||
restMode?: TodayRestMode | null;
|
||||
/** Last night's sleep, when it is in the record (sleep module). */
|
||||
sleepLastNight?: TodaySleep | null;
|
||||
/** Each banded vital's standing against its personal range. */
|
||||
vitals?: readonly TodayVital[];
|
||||
/** Today's place in the cycle (cycle module). */
|
||||
cycle?: TodayCycle | null;
|
||||
/** Whole weeks the score has held; see `DailyDigestScore.steadyWeeks`. */
|
||||
scoreSteadyWeeks?: number | null;
|
||||
}
|
||||
|
||||
export interface DailyDigest {
|
||||
@@ -387,8 +423,27 @@ export interface DailyDigest {
|
||||
* fan-out on every read, exactly the warm-on-mount load the plan forbids.
|
||||
*/
|
||||
topSignal: DailyBriefingSignal | null;
|
||||
/** First sentence of the cached briefing paragraph, read-only. */
|
||||
/**
|
||||
* The first sentence of the cached briefing paragraph that says something:
|
||||
* a greeting ("Good morning.") or a sentence that only repeats the score is
|
||||
* skipped. Read-only.
|
||||
*/
|
||||
briefingLead: string | null;
|
||||
/**
|
||||
* The hero's lead line, resolved: the reaction line or a briefing sentence
|
||||
* while AI text is available, otherwise a deterministic sentence about the
|
||||
* day's strongest signal. Null when there is nothing to say.
|
||||
*/
|
||||
lead: TodayLead | null;
|
||||
/**
|
||||
* The Today overview: up to five statements about the day (Rest Mode,
|
||||
* medications, an appointment today or tomorrow, last night, vitals,
|
||||
* cycle), in priority order, never padded. A narrow screen shows the
|
||||
* first four.
|
||||
*/
|
||||
today: TodayFact[];
|
||||
/** Whether an illness episode is active (Rest Mode), and since which day. */
|
||||
restMode: TodayRestMode | null;
|
||||
/** The push / lock-screen line (cached-AI lead with a deterministic floor). */
|
||||
line: string;
|
||||
/** Bounded 0–3 rail items, never padded. */
|
||||
@@ -417,19 +472,6 @@ export interface DailyDigest {
|
||||
ai: DailyDigestAi;
|
||||
}
|
||||
|
||||
/** First sentence of a paragraph, trimmed; null when empty. */
|
||||
function firstSentence(paragraph: string | null | undefined): string | null {
|
||||
if (!paragraph) return null;
|
||||
const trimmed = paragraph.trim();
|
||||
if (!trimmed) return null;
|
||||
const match = trimmed.match(/^[\s\S]*?[.!?](?:\s|$)/);
|
||||
let sentence = (match ? match[0] : trimmed).trim();
|
||||
if (sentence.length > MAX_LINE_LENGTH) {
|
||||
sentence = `${sentence.slice(0, MAX_LINE_LENGTH - 1).trimEnd()}…`;
|
||||
}
|
||||
return sentence.length > 0 ? sentence : null;
|
||||
}
|
||||
|
||||
/** Title-case a lowercase integration token for user-facing copy. */
|
||||
function integrationLabel(token: string): string {
|
||||
if (!token) return token;
|
||||
@@ -962,19 +1004,23 @@ function buildEcgNewRecordingItem(
|
||||
}
|
||||
|
||||
/**
|
||||
* The push / lock-screen line: prefer the warmer cached briefing lead, fall
|
||||
* back to the top signal's headline, then a deterministic score floor, then
|
||||
* the honest all-clear. 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 push / lock-screen line: the warmer cached briefing lead first, then
|
||||
* the top signal's headline, then the deterministic sentence about the day's
|
||||
* strongest signal, then a score floor, then the honest all-clear. The
|
||||
* reaction line is left out on purpose: it answers something that landed
|
||||
* 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.
|
||||
*/
|
||||
function composeLine(
|
||||
briefingLead: string | null,
|
||||
overview: { briefingLead: string | null; lead: TodayLead | null },
|
||||
topSignal: DailyBriefingSignal | null,
|
||||
score: DailyDigestScore | null,
|
||||
t: Translate,
|
||||
): string {
|
||||
if (briefingLead) return briefingLead;
|
||||
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 });
|
||||
return t("daily.line.allClear");
|
||||
}
|
||||
@@ -1034,7 +1080,6 @@ export function buildDailyDigest(
|
||||
// is the builder's own backstop, so the DTO cannot carry it by accident.
|
||||
const briefing = input.ai.briefing.available ? input.briefing : null;
|
||||
const topSignal = briefing?.signalsOfDay?.[0] ?? null;
|
||||
const briefingLead = firstSentence(briefing?.paragraph);
|
||||
|
||||
// The day's most recently LANDED arrival drives both reaction fields. The
|
||||
// sample timestamp may be hours old (sleep synced after waking, an offline
|
||||
@@ -1170,15 +1215,58 @@ export function buildDailyDigest(
|
||||
(item) => !item.itemKey || !input.dismissedItemKeys.has(item.itemKey),
|
||||
);
|
||||
|
||||
const rail = capWithPinned(visible, pinned, MAX_WORTH_A_LOOK);
|
||||
|
||||
const overview = buildTodayOverview(
|
||||
{
|
||||
locale: input.locale ?? "en",
|
||||
modules: input.modules,
|
||||
reactionLine,
|
||||
briefing,
|
||||
scoreValue: input.score?.value ?? null,
|
||||
medsToday: input.medsToday,
|
||||
visits: (input.upcomingVisits ?? []).flatMap((visit) =>
|
||||
visit.timeLabel === undefined ||
|
||||
Number.isNaN(new Date(visit.occurredAt).getTime())
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: visit.id,
|
||||
dayOffset: visit.dayOffset,
|
||||
timeLabel: visit.timeLabel,
|
||||
what:
|
||||
visit.practitionerName ??
|
||||
t(encounterKindLabelKey(visit.kind as EncounterKind)),
|
||||
},
|
||||
],
|
||||
),
|
||||
rail,
|
||||
restMode: input.restMode ?? null,
|
||||
sleep: input.sleepLastNight ?? null,
|
||||
vitals: input.vitals ?? [],
|
||||
cycle: input.cycle ?? null,
|
||||
},
|
||||
t,
|
||||
);
|
||||
|
||||
const score: DailyDigestScore | null = input.score
|
||||
? { ...input.score, steadyWeeks: input.scoreSteadyWeeks ?? null }
|
||||
: null;
|
||||
|
||||
return {
|
||||
generatedAt: input.now.toISOString(),
|
||||
phase,
|
||||
sleepPending,
|
||||
score: input.score,
|
||||
score,
|
||||
topSignal,
|
||||
briefingLead,
|
||||
line: composeLine(briefingLead, topSignal, input.score, t),
|
||||
worthALook: capWithPinned(visible, pinned, MAX_WORTH_A_LOOK),
|
||||
briefingLead: overview.briefingLead,
|
||||
lead: overview.lead,
|
||||
today: overview.today,
|
||||
restMode: moduleEnabled(input.modules, "illness")
|
||||
? (input.restMode ?? null)
|
||||
: null,
|
||||
line: composeLine(overview, topSignal, input.score, t),
|
||||
worthALook: rail,
|
||||
justIn,
|
||||
reactionLine,
|
||||
ai: input.ai,
|
||||
|
||||
+273
-30
@@ -68,6 +68,35 @@ import {
|
||||
PRIORITY_ITEM_KINDS,
|
||||
type PriorityItemKind,
|
||||
} from "@/lib/daily/priority-item";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { computeCoincidentDeviation } from "@/lib/insights/derived/coincident-deviation";
|
||||
import { loadBaselineProfile } from "@/lib/insights/derived/baseline";
|
||||
import { moduleForMeasurementType } from "@/lib/modules/measurement-scope";
|
||||
import {
|
||||
applyDisplayTransform,
|
||||
getReadingTransform,
|
||||
resolveUnitPreferences,
|
||||
type UnitPreferences,
|
||||
} from "@/lib/measurements/display-transform";
|
||||
import { resolveRestMode } from "@/lib/illness/rest-mode";
|
||||
import { readTodayCycle } from "@/lib/cycle/today-verdict";
|
||||
import { addDays, dayDiff } from "@/lib/cycle/day-math";
|
||||
import { makeFormatters, resolveIntlLocale } from "@/lib/format-locale";
|
||||
import type {
|
||||
DateFormatPreference,
|
||||
TimeFormatPreference,
|
||||
} from "@/lib/format-locale";
|
||||
import {
|
||||
STEADY_READ_DAYS,
|
||||
steadyWeeks,
|
||||
type StoredScoreDay,
|
||||
} from "@/lib/daily/score-steady";
|
||||
import type {
|
||||
TodayCycle,
|
||||
TodayRestMode,
|
||||
TodaySleep,
|
||||
TodayVital,
|
||||
} from "@/lib/daily/today-overview";
|
||||
|
||||
/** Integration states that mean "your action is needed to keep data flowing". */
|
||||
const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const;
|
||||
@@ -206,6 +235,141 @@ interface DailyDigestExtras {
|
||||
* The labels are grouped at the call site, where the locale is resolved.
|
||||
*/
|
||||
sameTime: Omit<DailyDigestSameTime, "todayLabel" | "typicalLabel"> | null;
|
||||
/**
|
||||
* Each banded vital's standing against its personal range, from the
|
||||
* coincident-deviation engine. Canonical numbers only: the labels depend on
|
||||
* the reader's units and locale and are rendered per request.
|
||||
*/
|
||||
vitals: ExtrasVital[];
|
||||
/** The stored daily scores the "steady for" line is read from. */
|
||||
scoreDays: StoredScoreDay[];
|
||||
}
|
||||
|
||||
interface ExtrasVital {
|
||||
type: MeasurementType;
|
||||
value: number;
|
||||
low: number;
|
||||
high: number;
|
||||
direction: "above" | "below" | "in";
|
||||
daysAgo: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The vitals read for the Today overview: every banded vital's latest day
|
||||
* against its personal range. Fault-isolated, like every extras input; an
|
||||
* engine that cannot answer (too few banded vitals) leaves the list empty,
|
||||
* and the overview simply has no vitals line.
|
||||
*/
|
||||
async function gatherVitals(
|
||||
userId: string,
|
||||
timezone: string,
|
||||
now: Date,
|
||||
): Promise<ExtrasVital[]> {
|
||||
try {
|
||||
const profile = await loadBaselineProfile(prisma, userId);
|
||||
const result = await computeCoincidentDeviation(userId, profile, {
|
||||
now,
|
||||
tz: timezone,
|
||||
});
|
||||
if (result.status !== "ok") return [];
|
||||
return result.value.vitals.map((v) => ({
|
||||
type: v.type,
|
||||
value: v.value,
|
||||
low: v.low,
|
||||
high: v.high,
|
||||
direction: v.direction,
|
||||
daysAgo: Number.isFinite(v.daysAgo) ? v.daysAgo : Number.MAX_SAFE_INTEGER,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** The stored daily scores for the "steady for" read. Fault-isolated. */
|
||||
async function gatherScoreDays(
|
||||
userId: string,
|
||||
todayLocalDate: string,
|
||||
): Promise<StoredScoreDay[]> {
|
||||
try {
|
||||
// Calendar arithmetic on the reader's own day key, the space the stored
|
||||
// rows are keyed in.
|
||||
const since = addDays(todayLocalDate, -STEADY_READ_DAYS);
|
||||
const rows = await prisma.healthScoreRecord.findMany({
|
||||
where: { userId, dayKey: { gte: since } },
|
||||
orderBy: { dayKey: "desc" },
|
||||
take: STEADY_READ_DAYS + 1,
|
||||
select: {
|
||||
dayKey: true,
|
||||
composite: true,
|
||||
band: true,
|
||||
scoreVersion: true,
|
||||
composition: true,
|
||||
},
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
dayKey: row.dayKey,
|
||||
composite: row.composite,
|
||||
band: String(row.band),
|
||||
scoreVersion: row.scoreVersion,
|
||||
composition: row.composition,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display decimals per vital. The transform's own `decimals` covers the
|
||||
* converted units (lb, °F, mmol/L); the rest are whole numbers on every
|
||||
* device and in every chart that shows them.
|
||||
*/
|
||||
function vitalDecimals(type: string, transformDecimals: number): number {
|
||||
switch (type) {
|
||||
case "BODY_TEMPERATURE":
|
||||
case "SKIN_TEMPERATURE":
|
||||
case "WEIGHT":
|
||||
return 1;
|
||||
case "BLOOD_GLUCOSE":
|
||||
return transformDecimals;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Render one vital's value and range in the reader's units and locale. */
|
||||
function toTodayVital(
|
||||
vital: ExtrasVital,
|
||||
units: UnitPreferences,
|
||||
locale: Locale,
|
||||
t: (key: string, params?: Record<string, string | number>) => string,
|
||||
): TodayVital {
|
||||
const transform = getReadingTransform(vital.type, units);
|
||||
const decimals = vitalDecimals(vital.type, transform.decimals);
|
||||
const number = new Intl.NumberFormat(resolveIntlLocale(locale), {
|
||||
maximumFractionDigits: decimals,
|
||||
minimumFractionDigits: decimals,
|
||||
});
|
||||
const shown = (raw: number) =>
|
||||
number.format(applyDisplayTransform(raw, transform));
|
||||
const unit = transform.displayUnit;
|
||||
return {
|
||||
type: vital.type,
|
||||
value: vital.value,
|
||||
low: vital.low,
|
||||
high: vital.high,
|
||||
direction: vital.direction,
|
||||
daysAgo: vital.daysAgo,
|
||||
valueLabel: t("daily.today.valueWithUnit", {
|
||||
value: shown(vital.value),
|
||||
unit,
|
||||
}),
|
||||
rangeLabel: t("daily.today.rangeWithUnit", {
|
||||
low: shown(vital.low),
|
||||
high: shown(vital.high),
|
||||
unit,
|
||||
}),
|
||||
moduleKey: moduleForMeasurementType(vital.type),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,35 +400,40 @@ async function loadDailyDigestExtrasCached(
|
||||
// revision ran them sequentially). Each is already fault-isolated
|
||||
// internally (a milestone read-hiccup or a tension-read failure leaves
|
||||
// its own field quiet rather than breaking the other).
|
||||
const [milestone, tensionWindow, sameTime] = await Promise.all([
|
||||
gatherFreshMilestone(userId, timezone, now),
|
||||
loadIntradayPulse(userId, timezone, todayLocalDate)
|
||||
.then((r) => (r.tension ? { partOfDay: r.tension.partOfDay } : null))
|
||||
.catch(() => null),
|
||||
// The engine gates itself — it returns its `insufficient` arm while it
|
||||
// is still learning the usual day, when the account has no intraday
|
||||
// rows at all (every daily-total source), and before the day's first
|
||||
// hour has finished. Only the `ok` arm reaches the rail. Passing no
|
||||
// profile because the engine reads none; fault-isolated like its
|
||||
// siblings, since the digest is a must-not-fail path.
|
||||
computeSameTimeBaseline(userId, null, {
|
||||
type: SAME_TIME_RAIL_TYPE,
|
||||
now,
|
||||
})
|
||||
.then((d): DailyDigestExtras["sameTime"] =>
|
||||
d.status === "ok"
|
||||
? {
|
||||
type: d.value.type,
|
||||
band: d.value.band,
|
||||
asOfHour: d.value.asOfHour,
|
||||
todayValue: Math.round(d.value.todayValue),
|
||||
typicalValue: Math.round(d.value.typicalValue),
|
||||
}
|
||||
: null,
|
||||
)
|
||||
.catch(() => null),
|
||||
]);
|
||||
return { milestone, tensionWindow, sameTime };
|
||||
const [milestone, tensionWindow, sameTime, vitals, scoreDays] =
|
||||
await Promise.all([
|
||||
gatherFreshMilestone(userId, timezone, now),
|
||||
loadIntradayPulse(userId, timezone, todayLocalDate)
|
||||
.then((r) =>
|
||||
r.tension ? { partOfDay: r.tension.partOfDay } : null,
|
||||
)
|
||||
.catch(() => null),
|
||||
// The engine gates itself — it returns its `insufficient` arm while it
|
||||
// is still learning the usual day, when the account has no intraday
|
||||
// rows at all (every daily-total source), and before the day's first
|
||||
// hour has finished. Only the `ok` arm reaches the rail. Passing no
|
||||
// profile because the engine reads none; fault-isolated like its
|
||||
// siblings, since the digest is a must-not-fail path.
|
||||
computeSameTimeBaseline(userId, null, {
|
||||
type: SAME_TIME_RAIL_TYPE,
|
||||
now,
|
||||
})
|
||||
.then((d): DailyDigestExtras["sameTime"] =>
|
||||
d.status === "ok"
|
||||
? {
|
||||
type: d.value.type,
|
||||
band: d.value.band,
|
||||
asOfHour: d.value.asOfHour,
|
||||
todayValue: Math.round(d.value.todayValue),
|
||||
typicalValue: Math.round(d.value.typicalValue),
|
||||
}
|
||||
: null,
|
||||
)
|
||||
.catch(() => null),
|
||||
gatherVitals(userId, timezone, now),
|
||||
gatherScoreDays(userId, todayLocalDate),
|
||||
]);
|
||||
return { milestone, tensionWindow, sameTime, vitals, scoreDays };
|
||||
},
|
||||
annotate,
|
||||
DIGEST_EXTRAS_CACHE_TTL_MS,
|
||||
@@ -573,14 +742,74 @@ export async function loadDailyDigest(
|
||||
* notification about something the start page no longer mentioned. A visit
|
||||
* that is still PLANNED stays on the rail until its day ends.
|
||||
*/
|
||||
// The start time on the person's own clock, rendered here in their profile
|
||||
// timezone and hour-cycle preference. Done on the server so every reader of
|
||||
// the digest prints the same time for the same visit, whatever zone their
|
||||
// device is in.
|
||||
const visitTime = makeFormatters(
|
||||
locale,
|
||||
user.timezone,
|
||||
(user.timeFormat ?? "AUTO") as TimeFormatPreference,
|
||||
(user.dateFormat ?? "AUTO") as DateFormatPreference,
|
||||
).time;
|
||||
const upcomingVisits: DailyDigestUpcomingVisit[] = visitRows.map((row) => ({
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
occurredAt: row.occurredAt.toISOString(),
|
||||
practitionerName: row.practitioner?.name ?? null,
|
||||
dayOffset: calendarDaysUntil(row.occurredAt, now, user.timezone),
|
||||
timeLabel: visitTime(row.occurredAt),
|
||||
}));
|
||||
|
||||
// Rest Mode and today's cycle day. Both are read per request rather than
|
||||
// through the extras cell: an episode or a period logged a minute ago is not
|
||||
// a measurement write, so nothing would evict a cached answer. Each read is
|
||||
// skipped outright when its module is off, and fault-isolated, because the
|
||||
// digest is a must-not-fail path.
|
||||
const [restModeContext, cycleRead] = await Promise.all([
|
||||
modules.illness !== false
|
||||
? resolveRestMode(user.id, now)
|
||||
: Promise.resolve(null),
|
||||
modules.cycle !== false
|
||||
? readTodayCycle(user.id, user.timezone, now).catch(() => null)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
const restMode: TodayRestMode | null =
|
||||
restModeContext?.active && restModeContext.since
|
||||
? {
|
||||
day:
|
||||
Math.max(
|
||||
0,
|
||||
dayDiff(
|
||||
todayLocalDate,
|
||||
userDayKey(new Date(restModeContext.since), user.timezone),
|
||||
),
|
||||
) + 1,
|
||||
}
|
||||
: null;
|
||||
const cycle: TodayCycle | null = cycleRead
|
||||
? { dayOfCycle: cycleRead.dayOfCycle, phase: cycleRead.phase }
|
||||
: null;
|
||||
|
||||
// Last night, when it is in: the snapshot's sleep summary carries per-night
|
||||
// time asleep in minutes, `latest` being the newest night and `avg30` the
|
||||
// person's own trailing mean. "In" means the freshest night ended on the
|
||||
// reader's local today, which is the same test the freshness phase uses.
|
||||
const sleepSummary = snapshot.tiles.summaries?.SLEEP_DURATION;
|
||||
const sleepLastNight: TodaySleep | null =
|
||||
sleepLastSeenDaysAgo === 0 &&
|
||||
sleepSummary?.latest !== null &&
|
||||
sleepSummary?.latest !== undefined &&
|
||||
sleepSummary.latest > 0
|
||||
? {
|
||||
minutes: sleepSummary.latest,
|
||||
usualMinutes:
|
||||
sleepSummary.avg30 !== null && sleepSummary.avg30 > 0
|
||||
? sleepSummary.avg30
|
||||
: null,
|
||||
}
|
||||
: null;
|
||||
|
||||
// S10 — the freshest ECG recording (device verdict + recordedAt only). The
|
||||
// builder decides "new" and gates on the `insights` module. An ECG row only
|
||||
// ever carries an AFib-screening verdict; the shared enum's other members
|
||||
@@ -616,7 +845,7 @@ export async function loadDailyDigest(
|
||||
// a 120 s poll of every open tab doesn't re-run the ~8.5k-row streak +
|
||||
// per-sample tension reads on every request — see its docblock for the
|
||||
// cache/invalidation contract.
|
||||
const { milestone, tensionWindow, sameTime } =
|
||||
const { milestone, tensionWindow, sameTime, vitals, scoreDays } =
|
||||
await loadDailyDigestExtrasCached(
|
||||
user.id,
|
||||
user.timezone,
|
||||
@@ -648,6 +877,10 @@ export async function loadDailyDigest(
|
||||
|
||||
const resolvedLocale: Locale = locale;
|
||||
const { t } = getServerTranslator(resolvedLocale);
|
||||
const units = resolveUnitPreferences({
|
||||
unitPreference: user.unitPreference,
|
||||
glucoseUnit: user.glucoseUnit,
|
||||
});
|
||||
|
||||
// A Coach-suggested measurement cadence stores its label as a bundle key;
|
||||
// the checkups page and the dashboard card translate it, and so does this
|
||||
@@ -711,6 +944,16 @@ export async function loadDailyDigest(
|
||||
arrivals: arrivalRows
|
||||
.map((row) => toDigestArrival(row, reactionLinesAi.available))
|
||||
.filter((a): a is DailyDigestArrival => a !== null),
|
||||
locale: resolvedLocale,
|
||||
restMode,
|
||||
sleepLastNight,
|
||||
vitals: (vitals ?? []).map((v) =>
|
||||
toTodayVital(v, units, resolvedLocale, t),
|
||||
),
|
||||
cycle,
|
||||
scoreSteadyWeeks: score
|
||||
? steadyWeeks(scoreDays ?? [], todayLocalDate, score.value)
|
||||
: null,
|
||||
},
|
||||
t,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* "Steady for N weeks": how long the health score has held where it is.
|
||||
*
|
||||
* The score moves slowly on purpose (long windows against reference bands),
|
||||
* so on most days the honest thing to say about it is not a delta but how long
|
||||
* it has stayed put. That is a real statement only when it comes with a
|
||||
* duration read off the record, which is what this does: it walks the stored
|
||||
* daily scores (`HealthScoreRecord`) back from the newest and counts how long
|
||||
* the number held.
|
||||
*
|
||||
* A day belongs to the run while it was computed the same way (same algorithm
|
||||
* version, same composition), sat in the same band, and stayed within
|
||||
* {@link STEADY_TOLERANCE} points of the newest value. A gap in the record
|
||||
* longer than {@link STEADY_MAX_GAP_DAYS} ends the run: an absence is not
|
||||
* evidence of steadiness.
|
||||
*/
|
||||
import { dayDiff } from "@/lib/cycle/day-math";
|
||||
|
||||
/** Points either side of the newest value that still count as "the same". */
|
||||
export const STEADY_TOLERANCE = 2;
|
||||
|
||||
/** Longest stretch without a stored score the run may bridge. */
|
||||
export const STEADY_MAX_GAP_DAYS = 3;
|
||||
|
||||
/** Shortest run worth saying out loud. One week is not yet a pattern. */
|
||||
export const STEADY_MIN_WEEKS = 2;
|
||||
|
||||
/** How far back the read looks; also the longest run it can report. */
|
||||
export const STEADY_READ_DAYS = 120;
|
||||
|
||||
export interface StoredScoreDay {
|
||||
dayKey: string;
|
||||
composite: number;
|
||||
band: string;
|
||||
scoreVersion: number;
|
||||
composition: readonly string[];
|
||||
}
|
||||
|
||||
function sameComposition(a: readonly string[], b: readonly string[]): boolean {
|
||||
return a.length === b.length && a.every((id, i) => id === b[i]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole weeks the score has held, or null when it has not held for at least
|
||||
* {@link STEADY_MIN_WEEKS}. `rows` may come in any order; `todayKey` is the
|
||||
* reader's local day, so a record that stopped updating days ago does not
|
||||
* claim a run that reaches today. The newest stored day must also agree with
|
||||
* the number on screen: the ring is computed live, the record nightly, and a
|
||||
* run that ended overnight is not "steady" next to a number that has moved.
|
||||
*/
|
||||
export function steadyWeeks(
|
||||
rows: readonly StoredScoreDay[],
|
||||
todayKey: string,
|
||||
/** The number the ring shows; a run that does not end there is not told. */
|
||||
shownValue: number,
|
||||
): number | null {
|
||||
if (rows.length === 0) return null;
|
||||
const sorted = [...rows].sort((a, b) =>
|
||||
a.dayKey < b.dayKey ? 1 : a.dayKey > b.dayKey ? -1 : 0,
|
||||
);
|
||||
const newest = sorted[0];
|
||||
if (dayDiff(todayKey, newest.dayKey) > STEADY_MAX_GAP_DAYS) return null;
|
||||
if (Math.abs(newest.composite - shownValue) > STEADY_TOLERANCE) return null;
|
||||
|
||||
let oldest = newest;
|
||||
for (const row of sorted.slice(1)) {
|
||||
if (dayDiff(oldest.dayKey, row.dayKey) > STEADY_MAX_GAP_DAYS) break;
|
||||
if (row.scoreVersion !== newest.scoreVersion) break;
|
||||
if (!sameComposition(row.composition, newest.composition)) break;
|
||||
if (row.band !== newest.band) break;
|
||||
if (Math.abs(row.composite - newest.composite) > STEADY_TOLERANCE) break;
|
||||
oldest = row;
|
||||
}
|
||||
|
||||
const weeks = Math.floor(dayDiff(todayKey, oldest.dayKey) / 7);
|
||||
return weeks >= STEADY_MIN_WEEKS ? weeks : null;
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
/**
|
||||
* The Today overview: the hero's lead line and its short list of facts about
|
||||
* the day, composed from data the digest already gathered.
|
||||
*
|
||||
* Pure and deterministic, like the rest of the digest spine. It never calls a
|
||||
* model; AI text reaches it only as the already-cached reaction line and
|
||||
* briefing, and only while their capabilities are available (the caller has
|
||||
* already nulled them otherwise). Everything a person without any AI provider
|
||||
* sees here comes from their own readings.
|
||||
*
|
||||
* Three rules hold for every line this file writes:
|
||||
*
|
||||
* - A fact is a statement. No line asks the person to do anything; the
|
||||
* worth-a-look rail is the one place on the hero allowed to.
|
||||
* - A fact about a switched-off module does not exist. Gating happens here,
|
||||
* on the server, never in a client.
|
||||
* - Nothing is said twice. When the lead line is built from a fact, that
|
||||
* fact leaves the list; an appointment already on the rail is not repeated
|
||||
* under Today.
|
||||
*/
|
||||
import type { DailyBriefing } 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";
|
||||
import { pluralKey } from "@/lib/i18n/plural";
|
||||
import { formatDurationMinutes } from "@/lib/i18n/duration";
|
||||
import { isCurrentForTodayClaim } from "@/lib/insights/measurement-freshness";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import type { PriorityItem } from "@/lib/daily/priority-item";
|
||||
|
||||
type Translate = (
|
||||
key: string,
|
||||
params?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
/** The most facts the overview ever carries (a wide screen shows them all). */
|
||||
export const MAX_TODAY_FACTS = 5;
|
||||
|
||||
/**
|
||||
* The most facts a narrow screen shows. The list is already in priority
|
||||
* order, so a phone simply shows its head; the fifth line is never a
|
||||
* different fact on a phone, only an absent one.
|
||||
*/
|
||||
export const MAX_TODAY_FACTS_NARROW = 4;
|
||||
|
||||
export const TODAY_FACT_KINDS = [
|
||||
"rest_mode",
|
||||
"medications",
|
||||
"appointment",
|
||||
"sleep",
|
||||
"vitals",
|
||||
"cycle",
|
||||
] as const;
|
||||
|
||||
export type TodayFactKind = (typeof TODAY_FACT_KINDS)[number];
|
||||
|
||||
/** One line under "Today": a label, a value, and where it leads. */
|
||||
export interface TodayFact {
|
||||
kind: TodayFactKind;
|
||||
/** Localised label, resolved on the server. */
|
||||
label: string;
|
||||
/** Localised value, resolved on the server. */
|
||||
value: string;
|
||||
/** The page the fact belongs to. */
|
||||
href: string;
|
||||
/** The module that admitted the fact; absent for core data. */
|
||||
moduleKey?: ModuleKey;
|
||||
}
|
||||
|
||||
export const TODAY_LEAD_SOURCES = ["reaction", "briefing", "signal"] as const;
|
||||
|
||||
export type TodayLeadSource = (typeof TODAY_LEAD_SOURCES)[number];
|
||||
|
||||
/**
|
||||
* The hero's lead line, resolved. `reaction` and `briefing` are cached model
|
||||
* text; `signal` is the deterministic sentence built from the day's strongest
|
||||
* signal, which is what every account without AI reads.
|
||||
*/
|
||||
export interface TodayLead {
|
||||
text: string;
|
||||
source: TodayLeadSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* One vital's standing against its personal band, as the coincident-deviation
|
||||
* engine computed it, with the two labels the copy needs already rendered in
|
||||
* the reader's units.
|
||||
*/
|
||||
export interface TodayVital {
|
||||
type: string;
|
||||
/** Canonical value; only compared, never printed. */
|
||||
value: number;
|
||||
low: number;
|
||||
high: number;
|
||||
direction: "above" | "below" | "in";
|
||||
/** Whole days between the reading and the reader's local today. */
|
||||
daysAgo: number;
|
||||
/** The reading in the reader's units, unit included ("61 bpm"). */
|
||||
valueLabel: string;
|
||||
/** The band in the reader's units ("52 to 57 bpm"). */
|
||||
rangeLabel: string;
|
||||
/** The module the vital belongs to, or null for core vitals. */
|
||||
moduleKey: ModuleKey | null;
|
||||
}
|
||||
|
||||
/** Last night's sleep, when it is in the record. */
|
||||
export interface TodaySleep {
|
||||
/** Time asleep last night, in minutes. */
|
||||
minutes: number;
|
||||
/** The person's own trailing average, in minutes, when there is one. */
|
||||
usualMinutes: number | null;
|
||||
}
|
||||
|
||||
/** The current illness episode, when Rest Mode is on. */
|
||||
export interface TodayRestMode {
|
||||
/** 1-based day of the episode on the reader's own calendar. */
|
||||
day: number;
|
||||
}
|
||||
|
||||
/** Today's place in the cycle, when the cycle module knows it. */
|
||||
export interface TodayCycle {
|
||||
dayOfCycle: number;
|
||||
/** Withheld (null) while the engine is still learning the cycle. */
|
||||
phase: CyclePhase | null;
|
||||
}
|
||||
|
||||
/** A booked visit, with its start time rendered for the reader. */
|
||||
export interface TodayVisit {
|
||||
id: string;
|
||||
/** 0 today, 1 tomorrow, 2 the day after (profile timezone). */
|
||||
dayOffset: number;
|
||||
/** Start time in the reader's zone and clock ("09:30"). */
|
||||
timeLabel: string;
|
||||
/** Practitioner or visit kind, already resolved. */
|
||||
what: string;
|
||||
}
|
||||
|
||||
export interface TodayOverviewInput {
|
||||
locale: Locale;
|
||||
modules: Partial<Record<ModuleKey, boolean>>;
|
||||
/** Reaction line for today's newest arrival, already capability-gated. */
|
||||
reactionLine: string | null;
|
||||
/** The cached briefing, already capability-gated. */
|
||||
briefing: DailyBriefing | null;
|
||||
/** The number inside the ring, so a lead never repeats it. */
|
||||
scoreValue: number | null;
|
||||
medsToday: MedsTodayBlock;
|
||||
visits: readonly TodayVisit[];
|
||||
/** The rail as it will be published, for the appointment de-duplication. */
|
||||
rail: readonly PriorityItem[];
|
||||
restMode: TodayRestMode | null;
|
||||
sleep: TodaySleep | null;
|
||||
vitals: readonly TodayVital[];
|
||||
cycle: TodayCycle | null;
|
||||
}
|
||||
|
||||
export interface TodayOverview {
|
||||
lead: TodayLead | null;
|
||||
today: TodayFact[];
|
||||
/** The first substantive briefing sentence, for `briefingLead`. */
|
||||
briefingLead: string | null;
|
||||
}
|
||||
|
||||
/** Trim a lead sentence to a lock-screen-friendly length. */
|
||||
const MAX_LEAD_LENGTH = 160;
|
||||
|
||||
/**
|
||||
* How far last night may sit from the person's usual before the night itself
|
||||
* becomes the day's strongest signal. An hour is a difference anyone notices
|
||||
* the next day; less than that is ordinary variation and stays a fact.
|
||||
*/
|
||||
export const SLEEP_NOTABLE_DIFF_MINUTES = 60;
|
||||
|
||||
/** Within this many minutes of the usual, the night reads as "close to". */
|
||||
export const SLEEP_CLOSE_DIFF_MINUTES = 20;
|
||||
|
||||
function moduleOn(
|
||||
modules: Partial<Record<ModuleKey, boolean>>,
|
||||
key: ModuleKey,
|
||||
): boolean {
|
||||
return modules[key] !== false;
|
||||
}
|
||||
|
||||
/** Split prose into sentences, keeping each one's own punctuation. */
|
||||
function sentences(text: string): string[] {
|
||||
return (
|
||||
text
|
||||
.trim()
|
||||
.match(/(?:[^.!?]|[.!?](?=\S))+(?:[.!?]+|$)/g)
|
||||
?.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opening words of a salutation, in every shipped language. Matched only at
|
||||
* the very start of a sentence, and only on a sentence with no number in it:
|
||||
* "Good morning, your resting heart rate is 52." carries content and stays.
|
||||
*/
|
||||
const GREETING_START =
|
||||
/^(good\s+(morning|afternoon|evening|night|day)|hello|hi|hey|welcome|guten\s+(morgen|tag|abend)|hallo|moin|servus|willkommen|buen[oa]s?\s+(d[ií]as|tardes|noches)|hola|bienvenid[oa]|bonjour|bonsoir|salut|bienvenue|buongiorno|buon\s+pomeriggio|buonasera|ciao|benvenut[oa]|dzie[nń]\s+dobry|dobry\s+wiecz[oó]r|cze[sś][cć]|witaj|witamy|좋은\s*(아침|저녁|오후)|안녕하세요|안녕)(?![\p{L}])/iu;
|
||||
|
||||
/** Short sentence without a number, below which nothing is said. */
|
||||
const MIN_CONTENT_WORDS = 4;
|
||||
|
||||
/**
|
||||
* Whether a sentence is a greeting rather than a statement about the day.
|
||||
*
|
||||
* The briefing is model text and often opens with "Good morning." — which,
|
||||
* taken as the first sentence, became the whole lead and pushed the sentence
|
||||
* that had something to say out of view. A sentence carrying a number always
|
||||
* counts as content. Otherwise it is a greeting when it opens with one, or
|
||||
* when it is too short to say anything (an exclamation, a sign-off).
|
||||
*/
|
||||
export function isGreetingOnly(sentence: string): boolean {
|
||||
const s = sentence.trim();
|
||||
if (s.length === 0) return true;
|
||||
if (/\d/.test(s)) return false;
|
||||
if (GREETING_START.test(s)) return true;
|
||||
const words = s.split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w));
|
||||
return words.length < MIN_CONTENT_WORDS;
|
||||
}
|
||||
|
||||
/** Whether a sentence repeats the number the ring already shows. */
|
||||
function repeatsScore(sentence: string, score: number | null): boolean {
|
||||
if (score === null) return false;
|
||||
const shown = String(Math.round(score));
|
||||
return new RegExp(`(^|[^0-9])${shown}([^0-9]|$)`).test(sentence);
|
||||
}
|
||||
|
||||
function clampLead(sentence: string): string {
|
||||
return sentence.length > MAX_LEAD_LENGTH
|
||||
? `${sentence.slice(0, MAX_LEAD_LENGTH - 1).trimEnd()}…`
|
||||
: sentence;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/** Vitals that may speak about today: module on, reading current. */
|
||||
function currentVitals(input: TodayOverviewInput): TodayVital[] {
|
||||
return input.vitals.filter(
|
||||
(v) =>
|
||||
isCurrentForTodayClaim(v.daysAgo) &&
|
||||
(v.moduleKey === null || moduleOn(input.modules, v.moduleKey)),
|
||||
);
|
||||
}
|
||||
|
||||
/** How far outside its band a vital sits, in band widths. */
|
||||
function outsideBy(v: TodayVital): number {
|
||||
const width = Math.max(v.high - v.low, Number.EPSILON);
|
||||
if (v.direction === "above") return (v.value - v.high) / width;
|
||||
if (v.direction === "below") return (v.low - v.value) / width;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function vitalLabel(t: Translate, type: string): string {
|
||||
const key = `daily.today.vital.${type}`;
|
||||
const label = t(key);
|
||||
return label === key ? t("daily.today.vital.generic") : label;
|
||||
}
|
||||
|
||||
function sleepDiff(sleep: TodaySleep): number | null {
|
||||
if (sleep.usualMinutes === null) return null;
|
||||
return Math.round(sleep.minutes - sleep.usualMinutes);
|
||||
}
|
||||
|
||||
function duration(minutes: number, t: Translate): string {
|
||||
return formatDurationMinutes(Math.round(minutes), t);
|
||||
}
|
||||
|
||||
interface SignalLead {
|
||||
text: string;
|
||||
consumes: TodayFactKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* The deterministic lead: one sentence about the day's strongest signal,
|
||||
* built from numbers the server already holds. In order:
|
||||
*
|
||||
* 1. a vital outside its personal range (the furthest out leads);
|
||||
* 2. a night at least an hour off the person's usual;
|
||||
* 3. every recent vital inside its range;
|
||||
* 4. an ordinary night.
|
||||
*
|
||||
* Null when none of them holds; the overview then carries the facts alone,
|
||||
* and never a filler sentence.
|
||||
*/
|
||||
function signalLead(
|
||||
input: TodayOverviewInput,
|
||||
sleep: TodaySleep | null,
|
||||
t: Translate,
|
||||
): SignalLead | null {
|
||||
const vitals = currentVitals(input);
|
||||
const outside = vitals
|
||||
.filter((v) => v.direction !== "in")
|
||||
.sort((a, b) => outsideBy(b) - outsideBy(a));
|
||||
|
||||
if (outside.length > 0) {
|
||||
const v = outside[0];
|
||||
const params = {
|
||||
metric: vitalLabel(t, v.type),
|
||||
value: v.valueLabel,
|
||||
range: v.rangeLabel,
|
||||
};
|
||||
const text =
|
||||
outside.length === 1
|
||||
? t(`daily.lead.vitalOutside.${v.direction}`, params)
|
||||
: t(
|
||||
pluralKey(
|
||||
`daily.lead.vitalsOutside.${v.direction}`,
|
||||
outside.length - 1,
|
||||
input.locale,
|
||||
),
|
||||
{ ...params, count: outside.length - 1 },
|
||||
);
|
||||
return { text, consumes: "vitals" };
|
||||
}
|
||||
|
||||
const diff = sleep ? sleepDiff(sleep) : null;
|
||||
if (sleep && diff !== null && Math.abs(diff) >= SLEEP_NOTABLE_DIFF_MINUTES) {
|
||||
return {
|
||||
text: t(`daily.lead.sleep.${diff > 0 ? "more" : "less"}`, {
|
||||
duration: duration(sleep.minutes, t),
|
||||
diff: duration(Math.abs(diff), t),
|
||||
}),
|
||||
consumes: "sleep",
|
||||
};
|
||||
}
|
||||
|
||||
if (vitals.length >= 2) {
|
||||
return {
|
||||
text: t(
|
||||
pluralKey("daily.lead.vitalsInRange", vitals.length, input.locale),
|
||||
{ count: vitals.length },
|
||||
),
|
||||
consumes: "vitals",
|
||||
};
|
||||
}
|
||||
if (vitals.length === 1) {
|
||||
const v = vitals[0];
|
||||
return {
|
||||
text: t("daily.lead.vitalInRange", {
|
||||
metric: vitalLabel(t, v.type),
|
||||
value: v.valueLabel,
|
||||
range: v.rangeLabel,
|
||||
}),
|
||||
consumes: "vitals",
|
||||
};
|
||||
}
|
||||
|
||||
if (sleep) {
|
||||
return {
|
||||
text:
|
||||
diff !== null
|
||||
? t("daily.lead.sleep.usual", {
|
||||
duration: duration(sleep.minutes, t),
|
||||
})
|
||||
: t("daily.lead.sleep.plain", {
|
||||
duration: duration(sleep.minutes, t),
|
||||
}),
|
||||
consumes: "sleep",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function restModeFact(
|
||||
input: TodayOverviewInput,
|
||||
t: Translate,
|
||||
): TodayFact | null {
|
||||
if (!input.restMode || !moduleOn(input.modules, "illness")) return null;
|
||||
return {
|
||||
kind: "rest_mode",
|
||||
label: t("daily.todayFact.restMode.label"),
|
||||
value: t("daily.todayFact.restMode.value", { day: input.restMode.day }),
|
||||
href: "/illness",
|
||||
moduleKey: "illness",
|
||||
};
|
||||
}
|
||||
|
||||
function medicationsFact(
|
||||
input: TodayOverviewInput,
|
||||
t: Translate,
|
||||
): TodayFact | null {
|
||||
if (!moduleOn(input.modules, "medications")) return null;
|
||||
const { scheduledToday, takenToday } = input.medsToday;
|
||||
if (scheduledToday <= 0) return null;
|
||||
return {
|
||||
kind: "medications",
|
||||
label: t("daily.todayFact.medications.label"),
|
||||
value: t("daily.todayFact.medications.value", {
|
||||
taken: Math.min(takenToday, scheduledToday),
|
||||
scheduled: scheduledToday,
|
||||
}),
|
||||
href: "/medications",
|
||||
moduleKey: "medications",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The next appointment today or tomorrow, and nothing further out.
|
||||
*
|
||||
* Skipped when the rail already carries an appointment item: the rail item
|
||||
* stays (it is the published contract every client reads, and it keeps its
|
||||
* place under the cap for a visit today), and one card does not name the same
|
||||
* visit twice.
|
||||
*/
|
||||
function appointmentFact(
|
||||
input: TodayOverviewInput,
|
||||
t: Translate,
|
||||
): TodayFact | null {
|
||||
if (input.rail.some((item) => item.kind === "upcoming_visit")) return null;
|
||||
const visit = [...input.visits]
|
||||
.filter((v) => v.dayOffset >= 0 && v.dayOffset <= 1)
|
||||
.sort((a, b) => a.dayOffset - b.dayOffset)[0];
|
||||
if (!visit) return null;
|
||||
const sameDay = input.visits.filter((v) => v.dayOffset === visit.dayOffset);
|
||||
const others = sameDay.length - 1;
|
||||
const what = others > 0 ? `${visit.what} +${others}` : visit.what;
|
||||
return {
|
||||
kind: "appointment",
|
||||
label: t("daily.todayFact.appointment.label"),
|
||||
value: t(
|
||||
visit.dayOffset <= 0
|
||||
? "daily.todayFact.appointment.today"
|
||||
: "daily.todayFact.appointment.tomorrow",
|
||||
{ time: visit.timeLabel, what },
|
||||
),
|
||||
href: "/checkups",
|
||||
};
|
||||
}
|
||||
|
||||
function sleepFact(sleep: TodaySleep | null, t: Translate): TodayFact | null {
|
||||
if (!sleep) return null;
|
||||
const diff = sleepDiff(sleep);
|
||||
const params = { duration: duration(sleep.minutes, t) };
|
||||
const value =
|
||||
diff === null
|
||||
? t("daily.todayFact.sleep.plain", params)
|
||||
: Math.abs(diff) <= SLEEP_CLOSE_DIFF_MINUTES
|
||||
? t("daily.todayFact.sleep.usual", params)
|
||||
: t(`daily.todayFact.sleep.${diff > 0 ? "more" : "less"}`, {
|
||||
...params,
|
||||
diff: duration(Math.abs(diff), t),
|
||||
});
|
||||
return {
|
||||
kind: "sleep",
|
||||
label: t("daily.todayFact.sleep.label"),
|
||||
value,
|
||||
href: "/insights/sleep",
|
||||
moduleKey: "sleep",
|
||||
};
|
||||
}
|
||||
|
||||
function vitalsFact(input: TodayOverviewInput, t: Translate): TodayFact | null {
|
||||
const vitals = currentVitals(input);
|
||||
if (vitals.length === 0) return null;
|
||||
const outside = vitals
|
||||
.filter((v) => v.direction !== "in")
|
||||
.sort((a, b) => outsideBy(b) - outsideBy(a));
|
||||
let value: string;
|
||||
if (outside.length === 0) {
|
||||
value = t(
|
||||
pluralKey("daily.todayFact.vitals.allIn", vitals.length, input.locale),
|
||||
{ count: vitals.length },
|
||||
);
|
||||
} else if (outside.length === 1) {
|
||||
value = t(`daily.todayFact.vitals.oneOut.${outside[0].direction}`, {
|
||||
metric: vitalLabel(t, outside[0].type),
|
||||
});
|
||||
} else {
|
||||
value = t("daily.todayFact.vitals.someOut", {
|
||||
count: outside.length,
|
||||
total: vitals.length,
|
||||
});
|
||||
}
|
||||
return {
|
||||
kind: "vitals",
|
||||
label: t("daily.todayFact.vitals.label"),
|
||||
value,
|
||||
href: "/insights",
|
||||
};
|
||||
}
|
||||
|
||||
function cycleFact(input: TodayOverviewInput, t: Translate): TodayFact | null {
|
||||
if (!input.cycle || !moduleOn(input.modules, "cycle")) return null;
|
||||
return {
|
||||
kind: "cycle",
|
||||
label: t("daily.todayFact.cycle.label"),
|
||||
value: input.cycle.phase
|
||||
? t("daily.todayFact.cycle.value", {
|
||||
day: input.cycle.dayOfCycle,
|
||||
phase: t(`cycle.phase.${input.cycle.phase}`),
|
||||
})
|
||||
: t("daily.todayFact.cycle.dayOnly", { day: input.cycle.dayOfCycle }),
|
||||
href: "/cycle",
|
||||
moduleKey: "cycle",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the lead line and the Today facts.
|
||||
*
|
||||
* Lead precedence, warmest first: the reaction line to something that landed
|
||||
* today, then the first briefing sentence with content, then the briefing's
|
||||
* top-signal headline, then the deterministic sentence from the strongest
|
||||
* signal. The first two are model text and only reach this function while
|
||||
* their capabilities are available; the deterministic sentence is what makes
|
||||
* the card whole without them.
|
||||
*
|
||||
* 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
|
||||
* many lines as it has facts, and none at all is a valid answer.
|
||||
*/
|
||||
export function buildTodayOverview(
|
||||
input: TodayOverviewInput,
|
||||
t: Translate,
|
||||
): TodayOverview {
|
||||
const sleep = moduleOn(input.modules, "sleep") ? input.sleep : null;
|
||||
|
||||
const briefingLead = firstSubstantiveSentence(
|
||||
input.briefing?.paragraph,
|
||||
input.scoreValue,
|
||||
);
|
||||
const reaction = cleanReactionLine(input.reactionLine, input.scoreValue);
|
||||
const headline = input.briefing?.signalsOfDay?.[0]?.headline?.trim() || 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 {
|
||||
const signal = signalLead(input, sleep, t);
|
||||
if (signal) {
|
||||
lead = { text: signal.text, source: "signal" };
|
||||
consumed = signal.consumes;
|
||||
}
|
||||
}
|
||||
|
||||
const facts = [
|
||||
restModeFact(input, t),
|
||||
medicationsFact(input, t),
|
||||
appointmentFact(input, t),
|
||||
sleepFact(sleep, t),
|
||||
vitalsFact(input, t),
|
||||
cycleFact(input, t),
|
||||
].filter(
|
||||
(fact): fact is TodayFact => fact !== null && fact.kind !== consumed,
|
||||
);
|
||||
|
||||
return {
|
||||
lead,
|
||||
today: facts.slice(0, MAX_TODAY_FACTS),
|
||||
briefingLead,
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,12 @@ import type { ZodOpenApiObject } from "zod-openapi";
|
||||
|
||||
import { ARRIVAL_KINDS } from "@/lib/arrivals/types";
|
||||
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
|
||||
import {
|
||||
MAX_TODAY_FACTS,
|
||||
MAX_TODAY_FACTS_NARROW,
|
||||
TODAY_FACT_KINDS,
|
||||
TODAY_LEAD_SOURCES,
|
||||
} from "@/lib/daily/today-overview";
|
||||
import { dismissPriorityItemSchema } from "@/lib/validations/daily";
|
||||
import { dataEnvelope, recordRefusal, stdResponses } from "./shared";
|
||||
import { healthScoreBasis } from "./insights/schemas";
|
||||
@@ -57,7 +63,24 @@ const priorityItemSchema = z
|
||||
"One 'worth a look' rail item. The single model every daily-value consumer renders through PriorityCard.",
|
||||
});
|
||||
|
||||
const dailyDigestResponse = z
|
||||
const todayFactSchema = z
|
||||
.object({
|
||||
kind: z.enum([...TODAY_FACT_KINDS]).describe("Closed fact kind."),
|
||||
label: z.string().describe("Localised label (resolved server-side)."),
|
||||
value: z.string().describe("Localised value (resolved server-side)."),
|
||||
href: z.string().describe("The page the fact belongs to."),
|
||||
moduleKey: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("The module that admitted the fact; absent for core data."),
|
||||
})
|
||||
.meta({
|
||||
id: "DailyTodayFact",
|
||||
description:
|
||||
"One statement about the day on the Today overview. A fact never asks the reader to do anything; the rail is the only place that does.",
|
||||
});
|
||||
|
||||
export const dailyDigestResponse = z
|
||||
.object({
|
||||
generatedAt: z.string().describe("ISO-8601 instant the digest was read."),
|
||||
phase: z
|
||||
@@ -103,6 +126,15 @@ const dailyDigestResponse = z
|
||||
.describe(
|
||||
"What the value rests on. Since v1.38 a score is produced from one or two areas of health as well as three, so this is what distinguishes a narrow score from a broad one. Optional so older cached digests without the field stay valid.",
|
||||
),
|
||||
steadyWeeks: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Whole weeks the score has held where it is (same band, within two points, computed the same way), read off the stored daily scores. Null when it has not held for two weeks, when the stored record does not reach today, or when the score moved. Shown in place of a delta, never beside one.",
|
||||
),
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
@@ -124,7 +156,38 @@ const dailyDigestResponse = z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"First sentence of the cached briefing paragraph. Null while the `briefing` capability is unavailable; `line` then falls to its deterministic floor.",
|
||||
"The first sentence of the cached briefing paragraph that says something: a greeting or a sentence that only repeats the score is skipped. Null while the `briefing` capability is unavailable or when no sentence qualifies; `line` then falls to its deterministic floor.",
|
||||
),
|
||||
lead: z
|
||||
.object({
|
||||
text: z.string(),
|
||||
source: z
|
||||
.enum([...TODAY_LEAD_SOURCES])
|
||||
.describe(
|
||||
"`reaction` and `briefing` are cached model text; `signal` is the deterministic sentence about the day's strongest signal.",
|
||||
),
|
||||
})
|
||||
.nullable()
|
||||
.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.",
|
||||
),
|
||||
today: z
|
||||
.array(todayFactSchema)
|
||||
.max(MAX_TODAY_FACTS)
|
||||
.describe(
|
||||
`The Today overview: up to ${MAX_TODAY_FACTS} statements about the day in priority order (Rest Mode, medications taken today, an appointment today or tomorrow that is not already on the rail, last night's sleep, vitals against their personal range, cycle day), never padded. A fact about a switched-off module is absent. A narrow surface shows the first ${MAX_TODAY_FACTS_NARROW}. Additive since v1.40.`,
|
||||
),
|
||||
restMode: z
|
||||
.object({
|
||||
day: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.describe("1-based day of the episode on the reader's calendar."),
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
"Rest Mode: present while an illness episode is active and the illness module is on, else null. Additive since v1.40.",
|
||||
),
|
||||
line: z
|
||||
.string()
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* The Today overview on the daily digest, against real Postgres.
|
||||
*
|
||||
* The unit suites pin the selection rules on hand-built inputs. This one runs
|
||||
* the whole read path (the dashboard snapshot, the coincident-deviation
|
||||
* engine, the visit and episode tables, the cycle grid, the stored daily
|
||||
* scores) on seeded rows, and checks that what comes out is a valid
|
||||
* `DailyDigest` under the published OpenAPI schema. An account without any
|
||||
* AI provider is the subject on purpose: the overview has to be whole
|
||||
* without one.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { loadDailyDigest } from "@/lib/daily/load-digest";
|
||||
import { dailyDigestResponse } from "@/lib/openapi/routes/daily";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
// 08:00 in Berlin on a Sunday morning.
|
||||
const NOW = new Date("2026-10-04T06:00:00.000Z");
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
__resetAllCachesForTests();
|
||||
});
|
||||
|
||||
async function seedUser(
|
||||
id: string,
|
||||
extra: Partial<Prisma.UserCreateInput> = {},
|
||||
) {
|
||||
return getPrismaClient().user.create({
|
||||
data: {
|
||||
id,
|
||||
username: id,
|
||||
email: `${id}@example.test`,
|
||||
timezone: TZ,
|
||||
gender: "FEMALE",
|
||||
dateOfBirth: new Date("1985-05-05T00:00:00.000Z"),
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** A month of steady resting heart rate and HRV, then an elevated morning. */
|
||||
async function seedVitals(userId: string) {
|
||||
const rows: Prisma.MeasurementCreateManyInput[] = [];
|
||||
for (let d = 30; d >= 1; d--) {
|
||||
const at = new Date(NOW.getTime() - d * DAY);
|
||||
rows.push(
|
||||
{
|
||||
userId,
|
||||
type: "RESTING_HEART_RATE",
|
||||
value: 52 + (d % 3),
|
||||
unit: "bpm",
|
||||
measuredAt: at,
|
||||
source: "MANUAL",
|
||||
},
|
||||
{
|
||||
userId,
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
value: 48 + (d % 4),
|
||||
unit: "ms",
|
||||
measuredAt: at,
|
||||
source: "MANUAL",
|
||||
},
|
||||
);
|
||||
}
|
||||
rows.push(
|
||||
{
|
||||
userId,
|
||||
type: "RESTING_HEART_RATE",
|
||||
value: 66,
|
||||
unit: "bpm",
|
||||
measuredAt: new Date(NOW.getTime() - 60 * 60 * 1000),
|
||||
source: "MANUAL",
|
||||
},
|
||||
{
|
||||
userId,
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
value: 49,
|
||||
unit: "ms",
|
||||
measuredAt: new Date(NOW.getTime() - 60 * 60 * 1000),
|
||||
source: "MANUAL",
|
||||
},
|
||||
);
|
||||
await getPrismaClient().measurement.createMany({ data: rows });
|
||||
}
|
||||
|
||||
async function seedDay(userId: string, cycleTrackingEnabled?: boolean) {
|
||||
const prisma = getPrismaClient();
|
||||
await seedVitals(userId);
|
||||
// Unwell since Friday evening, Berlin time: day 3 on Sunday.
|
||||
await prisma.illnessEpisode.create({
|
||||
data: {
|
||||
userId,
|
||||
label: "Cold",
|
||||
type: "INFECTION",
|
||||
onsetAt: new Date("2026-10-02T18:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
// Tomorrow 09:30 in Berlin.
|
||||
await prisma.encounter.create({
|
||||
data: {
|
||||
userId,
|
||||
occurredAt: new Date("2026-10-05T07:30:00.000Z"),
|
||||
status: "PLANNED",
|
||||
kind: "ROUTINE",
|
||||
},
|
||||
});
|
||||
// One logged period starting eleven days ago: day 12 today, phase still
|
||||
// withheld while the engine learns.
|
||||
await prisma.cycleProfile.create({ data: { userId, cycleTrackingEnabled } });
|
||||
await prisma.menstrualCycle.create({
|
||||
data: {
|
||||
userId,
|
||||
startDate: "2026-09-23",
|
||||
periodEndDate: "2026-09-27",
|
||||
tz: TZ,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("the Today overview on a real record", () => {
|
||||
it("publishes a valid digest with a deterministic lead and module-gated facts", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await seedUser("today-owner");
|
||||
await seedDay(user.id);
|
||||
const full = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: user.id },
|
||||
});
|
||||
|
||||
const digest = await loadDailyDigest(full, NOW, { locale: "en" });
|
||||
|
||||
// The published contract holds, new fields included.
|
||||
const parsed = dailyDigestResponse.safeParse(
|
||||
JSON.parse(JSON.stringify(digest)),
|
||||
);
|
||||
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
|
||||
|
||||
// No AI provider: no briefing text, and the lead is built from the
|
||||
// morning's elevated resting heart rate.
|
||||
expect(digest.briefingLead).toBeNull();
|
||||
expect(digest.lead?.source).toBe("signal");
|
||||
expect(digest.lead?.text).toMatch(
|
||||
/^Resting heart rate is at 66 bpm, above your usual range of \d+ to \d+ bpm\.$/,
|
||||
);
|
||||
|
||||
// Rest Mode passes through, counted on the Berlin calendar.
|
||||
expect(digest.restMode).toEqual({ day: 3 });
|
||||
|
||||
// Tomorrow's visit is on the rail, so it is not repeated under Today;
|
||||
// the vitals line became the lead, so it is not repeated either.
|
||||
expect(digest.worthALook.some((i) => i.kind === "upcoming_visit")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(digest.today.map((f) => f.kind)).toEqual(["rest_mode", "cycle"]);
|
||||
expect(digest.today[0].value).toBe("Day 3");
|
||||
expect(digest.today[1].value).toBe("Day 12");
|
||||
});
|
||||
|
||||
it("names the visit under Today when the rail does not carry it", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await seedUser("today-norail");
|
||||
await seedDay(user.id);
|
||||
const full = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: user.id },
|
||||
});
|
||||
|
||||
const digest = await loadDailyDigest(full, NOW, {
|
||||
locale: "en",
|
||||
enabledItemKinds: PRIORITY_ITEM_KINDS.filter(
|
||||
(kind) => kind !== "upcoming_visit",
|
||||
),
|
||||
});
|
||||
const appointment = digest.today.find((f) => f.kind === "appointment");
|
||||
expect(appointment?.value).toBe("Tomorrow 09:30 AM, Routine visit");
|
||||
});
|
||||
|
||||
it("says nothing about switched-off modules", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await seedUser("today-modules-off", {
|
||||
modulePreferencesJson: { illness: false },
|
||||
});
|
||||
// The cycle module follows the cycle profile's own switch, not the
|
||||
// module preference blob.
|
||||
await seedDay(user.id, false);
|
||||
const full = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: user.id },
|
||||
});
|
||||
|
||||
const digest = await loadDailyDigest(full, NOW, { locale: "en" });
|
||||
expect(digest.restMode).toBeNull();
|
||||
expect(digest.today.some((f) => f.kind === "rest_mode")).toBe(false);
|
||||
expect(digest.today.some((f) => f.kind === "cycle")).toBe(false);
|
||||
expect(
|
||||
dailyDigestResponse.safeParse(JSON.parse(JSON.stringify(digest))).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("stays valid and empty for an account with nothing recorded", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await seedUser("today-empty");
|
||||
const full = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: user.id },
|
||||
});
|
||||
|
||||
const digest = await loadDailyDigest(full, NOW, { locale: "en" });
|
||||
expect(digest.lead).toBeNull();
|
||||
expect(digest.today).toEqual([]);
|
||||
expect(digest.restMode).toBeNull();
|
||||
expect(
|
||||
dailyDigestResponse.safeParse(JSON.parse(JSON.stringify(digest))).success,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user