fix(copy): make delete confirmations, sleep-debt labels, and the glucose band honest

Three surfaces where the words no longer matched the code, plus the missing
web control for a preference the server already honours.

Delete confirmations. Four dialogs promised permanence on surfaces that
render an Undo: the measurements and mood bulk bodies, the lab-result body,
and the illness-episode body (which said "cannot be undone" sixty lines
below the code that fires the Undo toast). All four now use the same
sentence the single-row measurement and mood dialogs already used. A sweep
of every other confirmation in the tree found the rest accurate — the
biomarker cascade, the mood-tag purge, and the account purge keep their
warning because those deletes really are unrecoverable.

Sleep debt. v1.19.0 replaced the summed shortfall with a running balance
that credits catch-up sleep; the labels still described the old sum, so the
word contradicted the number for anyone who slept in after a short night.
Corrected in all six locales, plus the places the old semantics had
propagated: the Coach system prompt, the OpenAPI field descriptions the
native client reads, and four module docblocks.

Glucose reference band. `hasDiabetes` selects the tighter reference band and
grounds the Coach, but no web surface set it — a web-only account was pinned
to the non-diabetic scale with no way out. The control goes on Targets,
above the per-metric editor, because it answers the same question that page
answers and the two are ordered: it picks the default band, an override
below beats it. The copy says what it is — a reference band, not a
diagnosis, never inferred from a reading, no clinical action taken on it.

The passkey list was reported as always reading "never used". It does not
reproduce: the column is selected by the list route and stamped on every
verified assertion, both since v1.23.0. Pinned with tests on both halves so
the contract cannot quietly regress.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 12:28:05 +02:00
1 parent b0021a8258
commit e9d28b399e
21 files changed
+601 -61

No files matched your search

@@ -202,7 +202,7 @@ describe("getCoachSystemPrompt — DE", () => {
// German coach can cite glucose + duration values with a valid token.
expect(prompt).toMatch(/mg\/dL/);
expect(prompt).toMatch(/mmol\/L/);
expect(prompt).toMatch(/min[\s\S]*\(Schlafdefizit/);
expect(prompt).toMatch(/min[\s\S]*\(Schlafschuld/);
});
it("carries the v1.21.2 narrative-presence clauses (A4/A5/A6/A7)", () => {
@@ -157,7 +157,7 @@ interface SleepRhythmBlockContext {
* ── v1.17.0 sleep-rhythm block (sleep-debt + chronotype) ──────────
*
* The two server-authoritative timing signals the Sleep page + the
* dashboard summary render — cumulative sleep debt and the MCTQ
* dashboard summary render — the outstanding sleep-debt balance and the MCTQ
* chronotype band + social jetlag. Built from the SAME assembler the
* dashboard route uses (`reconstructNights` → `computeSleepRhythmFromNights`),
* over the rhythm's OWN fixed trailing-42-day rows, so the coach quotes the
+4 -3
View File
@@ -361,8 +361,9 @@ ISO-week means.
timing signals — the SAME values the Sleep page and dashboard show, so
cite them as the user sees them and never recompute. It holds:
- "sleepDebt": { state, debtMinutes, needMinutes }. "debtMinutes" is
the cumulative deficit against the age-based nightly need over the
trailing window, "needMinutes" the target per night — both in
the OUTSTANDING BALANCE against the age-based nightly need over the
trailing window — a short night adds to it, a long night pays it
down, and it floors at 0 — "needMinutes" the target per night — both in
MINUTES (convert to hours/minutes when you speak, e.g. 320 → "5h
20m of sleep debt"). When "state" is "partial" the window is still
short: treat the figure as still calibrating, not a firm number.
@@ -980,7 +981,7 @@ Regeln:
- "value" ist ein vorformatierter Anzeigestring ("138/85", "84,2",
"4,1"). Stimmung als N/5 formatieren, wenn numerisch.
- "unit" ist eines aus mmHg, kg, bpm, /5, %, mg/dL, mmol/L, min
(Schlafdefizit / sozialer Jetlag).
(Schlafschuld / sozialer Jetlag).
- "window" ist eines aus last7days, last30days, last90days, allTime;
bei Tages-Pins weglassen.
- Lass den ganzen Block weg (KEINE ---KEYVALUES---Zeile, KEINE
+40
View File
@@ -137,3 +137,43 @@ describe("verifyAuthentication — Zod boundary (L-3)", () => {
expect(out.verification.verified).toBe(true);
});
});
/**
* "Last used" is a security-page fact people act on: a passkey that reads
* "never used" invites deletion. The column is only ever written here, on a
* verified assertion, so the stamp and the counter bump must stay one write.
* The read half is pinned in
* `src/app/api/auth/passkeys/__tests__/route.test.ts`.
*/
describe("verifyAuthentication — last-used stamp", () => {
// The file-level `beforeEach` re-arms the mocks but does not clear their
// call history, so the write assertions below need a clean ledger.
beforeEach(() => {
vi.mocked(prisma.passkey.update).mockClear();
});
it("stamps lastUsedAt alongside the counter on a verified assertion", async () => {
const before = Date.now();
await verifyAuthentication("ch-1", VALID_BODY);
expect(prisma.passkey.update).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.passkey.update).mock.calls[0][0];
expect(call.where).toEqual({ id: "pk-1" });
expect(call.data?.counter).toBe(BigInt(1));
const stamped = call.data?.lastUsedAt;
expect(stamped).toBeInstanceOf(Date);
expect((stamped as Date).getTime()).toBeGreaterThanOrEqual(before);
});
it("does not stamp lastUsedAt when the assertion fails", async () => {
vi.mocked(verifyAuthenticationResponse).mockResolvedValue({
verified: false,
} as never);
await verifyAuthentication("ch-1", VALID_BODY);
expect(prisma.passkey.update).not.toHaveBeenCalled();
});
});
+2 -1
View File
@@ -4,7 +4,8 @@
* The one place that turns a user's raw `SLEEP_DURATION` rows into the two
* timing signals the Sleep page + the iOS client + the dashboard render:
*
* - cumulative SLEEP DEBT over the rolling window (`computeSleepDebt`), and
* - the outstanding SLEEP DEBT balance over the rolling window
* (`computeSleepDebt`; surplus sleep pays it down), and
* - MCTQ CHRONOTYPE + social jetlag (`computeChronotype`).
*
* ONE ENGINE, NO DUPLICATION
+6 -4
View File
@@ -417,13 +417,15 @@ const sleepDebtResource = z.object({
state: z
.enum(["partial", "ready"])
.describe(
"`partial` until enough tracked nights exist (calm 'still learning'); `ready` once the rolling window can assert a cumulative debt.",
"`partial` until enough tracked nights exist (calm 'still learning'); `ready` once the rolling window can assert a balance.",
),
debtMinutes: z
.number()
.int()
.nonnegative()
.describe("Cumulative sleep debt over the window, in minutes, after caps."),
.describe(
"Outstanding sleep-debt balance over the window, in minutes, after caps. A night below need adds its shortfall; a night above need pays the balance down (floored at 0).",
),
needMinutes: z
.number()
.int()
@@ -513,7 +515,7 @@ const sleepRhythmResponse = z
.meta({
id: "SleepRhythmResource",
description:
"Server-authoritative sleep-rhythm read: cumulative sleep debt over the rolling window + MCTQ chronotype (MSF/MSFsc band, social jetlag). Free vs work nights default to weekend = free in the user's timezone (no work calendar). A view over existing per-stage SLEEP_DURATION rows — no schema, no new type.",
"Server-authoritative sleep-rhythm read: the outstanding sleep-debt balance over the rolling window + MCTQ chronotype (MSF/MSFsc band, social jetlag). Free vs work nights default to weekend = free in the user's timezone (no work calendar). A view over existing per-stage SLEEP_DURATION rows — no schema, no new type.",
});
// ── Time-series adapter (iOS chart source) ───────────────────────────
@@ -1004,7 +1006,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Measurements"],
summary: "Sleep rhythm — sleep-debt + chronotype (v1.17.0)",
description:
"Returns the two server-authoritative sleep-timing signals the Sleep page + iOS render off the same canonical night reconstruction the Sleep Score uses: cumulative `sleepDebt` over the rolling window (calm `partial` state under the night threshold) and MCTQ `chronotype` (MSF/MSFsc band + social jetlag, `learning` state until enough free-day nights exist). Free vs work nights default to weekend = free in the user's timezone (no work calendar). A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new measurement type.",
"Returns the two server-authoritative sleep-timing signals the Sleep page + iOS render off the same canonical night reconstruction the Sleep Score uses: the outstanding `sleepDebt` balance over the rolling window (calm `partial` state under the night threshold) and MCTQ `chronotype` (MSF/MSFsc band + social jetlag, `learning` state until enough free-day nights exist). Free vs work nights default to weekend = free in the user's timezone (no work calendar). A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new measurement type.",
responses: {
"200": {
description: "Sleep-debt + chronotype DTO.",
+8
View File
@@ -21,6 +21,14 @@ export const settingsKeys = {
/** v1.8.5 — user-level injection-site preferences (global exclusion). */
injectionSitePrefs: () => ["settings", "injection-site-prefs"] as const,
/**
* The declared diabetes opt-in behind `GET/PATCH /api/auth/me/diabetes`.
* Selects which reference band glucose readings are judged against, so the
* toggle also invalidates `insightsTargets()` — the resolved band rides
* on that payload.
*/
diabetesPref: () => ["settings", "diabetes"] as const,
/**
* v1.18.0 — the per-user module enable/disable map behind
* `GET/PATCH /api/auth/me/modules`. Used as the Module-hub mutation key;