diff --git a/CHANGELOG.md b/CHANGELOG.md index 469b3b706..4aeedec14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ ## [Unreleased] +## [1.35.4] — 2026-08-02 + +### Fixed + +- A checkup due later today is no longer described as overdue. Two places + counted the distance to a checkup in hours and rounded, which is a different + question from how many calendar days away it is. On a Friday evening, + something due Saturday morning is fourteen hours away and something due Sunday + morning is thirty-eight, and rounding turned both into tomorrow. Both now + count calendar days in the timezone on your profile. +- The assistant and the screen beside it now agree about when a checkup is due. + The list the assistant reads flipped to overdue the moment the clock time + passed, while the page still said today until midnight. One appointment could + produce two different answers depending on which one you asked. + +### Internal + +- The set of places that can work out who is calling is now frozen by a check. + It covers surfaces that read a session and surfaces that authenticate by a + credential in the address rather than a session, which no earlier check + watched at all. Adding one now fails the check until it is listed with a + reason. +- One place that turns an access token into an account was outside the previous + check's reach, because the check looked for a phrase the file happens to write + across two lines. It matched nothing and reported success. It now tolerates + the formatting and fails when it finds nothing to match, so an empty result + can no longer read as a pass. +- Test fixtures that stated the score version by hand now read it from the same + place the application does, so a future change to it cannot leave them behind. + ## [1.35.3] — 2026-08-01 ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 01bb4b8d6..cd5d39b81 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.35.3 + version: 1.35.4 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/e2e/settings-score.spec.ts b/e2e/settings-score.spec.ts index 9909ead1f..399bd9f2e 100644 --- a/e2e/settings-score.spec.ts +++ b/e2e/settings-score.spec.ts @@ -2,6 +2,8 @@ import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { settleBeforeMeasure } from "./utils/settle"; +import { SCORE_VERSION } from "@/lib/analytics/score/types"; +import { healthScoreAlgorithmItemKey } from "@/lib/daily/priority-item-key"; /** * Settings → Health Score: the surface that decides which pillars count. @@ -65,11 +67,14 @@ function scoreReport() { ], delta: null, deltaReason: "no_current_score", - scoreVersion: 2, + scoreVersion: SCORE_VERSION, weightGoal: insufficient("no_goal"), // Raised and never dismissed, which is the state every account is in: // nothing has ever rendered this notice. - algorithmNotice: { itemKey: "health_score_algorithm:2", dismissed: false }, + algorithmNotice: { + itemKey: healthScoreAlgorithmItemKey(SCORE_VERSION), + dismissed: false, + }, }; } @@ -223,7 +228,7 @@ test.describe("Settings → Health Score", () => { await notice.locator('[data-slot="score-change-notice-dismiss"]').click(); await expect(notice).toHaveCount(0); - expect(dismissed).toBe("health_score_algorithm:2"); + expect(dismissed).toBe(healthScoreAlgorithmItemKey(SCORE_VERSION)); }); test("refuses a selection too narrow to produce a score, in plain language", async ({ diff --git a/e2e/utils/mock-populated-insights.ts b/e2e/utils/mock-populated-insights.ts index 266efdc2d..f54147906 100644 --- a/e2e/utils/mock-populated-insights.ts +++ b/e2e/utils/mock-populated-insights.ts @@ -4,6 +4,7 @@ import { SECTION_MOBILITY, SECTION_VITALS, } from "@/components/insights/derived/use-dashboard-derived"; +import { SCORE_VERSION } from "@/lib/analytics/score/types"; /** * A POPULATED Insights overview fixture. @@ -170,7 +171,7 @@ const HEALTH_SCORE = { composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP", "ADIPOSITY"], configured: false, noiseFloor: 3, - scoreVersion: 2, + scoreVersion: SCORE_VERSION, }, coverage: coverage(), confidence: confidence(), @@ -187,7 +188,7 @@ const HEALTH_SCORE = { ], delta: 2, deltaReason: null, - scoreVersion: 2, + scoreVersion: SCORE_VERSION, weightGoal: { status: "ok" as const, value: { diff --git a/e2e/v1341-ui-evidence.spec.ts b/e2e/v1341-ui-evidence.spec.ts index 6eea31bf5..6448d3472 100644 --- a/e2e/v1341-ui-evidence.spec.ts +++ b/e2e/v1341-ui-evidence.spec.ts @@ -2,6 +2,7 @@ import { mkdir } from "node:fs/promises"; import { join } from "node:path"; import type { Page, TestInfo } from "@playwright/test"; import type { DailyDigest } from "@/lib/daily/digest"; +import { SCORE_VERSION } from "@/lib/analytics/score/types"; import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; @@ -23,7 +24,7 @@ const NARRATIVE_DIGEST: DailyDigest = { band: "green", delta: -2, deltaReason: null, - scoreVersion: 2, + scoreVersion: SCORE_VERSION, composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], }, topSignal: LONG_HEADLINE_BRIEFING.signalsOfDay?.[0] ?? null, diff --git a/package.json b/package.json index 96e4eeb1f..21819c8f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.35.3", + "version": "1.35.4", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 23a1b22c7..e902efa2a 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.35.3"; + /* @sw-version-fallback */ "v1.35.4"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/__tests__/bearer-scope-enforcement-guard.test.ts b/src/__tests__/bearer-scope-enforcement-guard.test.ts index 21c938fdc..b1c721cbb 100644 --- a/src/__tests__/bearer-scope-enforcement-guard.test.ts +++ b/src/__tests__/bearer-scope-enforcement-guard.test.ts @@ -64,16 +64,34 @@ describe("T1 — the Bearer resolution set is frozen", () => { // surface, which gates on BOTH `medication:ingest` and the per-medication // `medication::ingest` grant and never touches `requireAuth`. "app/api/ingest/medication/route.ts", + // `defaultUserIdResolver` — resolves the Bearer token to a user id so an + // idempotency key files under its owner. It authorises nothing: the + // handler still runs its own `requireAuth()`, and this resolver only + // decides which user's cache cell a replay lands in. Narrow in reach and + // deliberate, but it is a raw Bearer value becoming a user id, so it + // belongs on this list rather than beside it. + "lib/idempotency.ts", ].sort(); it("no file outside the allowlist resolves a Bearer token to a user", () => { // `resolveBearerToken` is the shared primitive; a `tokenHash` lookup is the // hand-rolled equivalent. Either one turns a bearer credential into an // identity, so both have to stay inside the allowlist. + // + // The whitespace classes around `.findUnique` are load-bearing. The + // original matcher demanded the literal `apiToken.findUnique(`, and + // `lib/idempotency.ts` writes the call fluent-style — `prisma.apiToken`, + // newline, `.findUnique({`. It resolved a Bearer token to a user id for + // three releases without this guard ever seeing it. A matcher that misses + // a real resolver is green because it matched nothing, not because + // nothing was there. const resolvers = filesMatching( - /resolveBearerToken|apiToken\.findUnique\([\s\S]*?where:\s*\{\s*tokenHash/, + /resolveBearerToken|apiToken\s*\.\s*findUnique\(\s*\{[\s\S]*?where:\s*\{\s*tokenHash/, ); + // Non-zero proof: an empty match set must fail rather than agree with an + // emptied allowlist. + expect(resolvers.length).toBeGreaterThan(0); expect(resolvers).toEqual(RESOLUTION_ALLOWLIST); }); diff --git a/src/__tests__/session-surface-guard.test.ts b/src/__tests__/session-surface-guard.test.ts new file mode 100644 index 000000000..067bd97d3 --- /dev/null +++ b/src/__tests__/session-surface-guard.test.ts @@ -0,0 +1,314 @@ +/** + * Structural guards on the surfaces that resolve a caller's identity. + * + * `bearer-scope-enforcement-guard.test.ts` freezes ONE mechanism: the files + * that turn an `Authorization: Bearer` value into a user. That guard is + * correct and it holds. What it is not is coverage of "who can resolve a + * caller". Two other families do the same job by other means, and a sweep + * written by copying the Bearer guard sees neither of them: + * + * A. A route or page that reads the session cookie itself, instead of + * going through `requireAuth()` inside `apiHandler`. `requireAuth` is + * where the fail-closed scope arm, the audit row, and the admin + * boundary live; a direct `getSession()` has none of them and answers + * to nothing but the file it sits in. + * B. A surface whose whole credential is a token in the URL — the + * clinician share tree serves one person's complete health record to + * an anonymous caller holding an `hls_` path segment, and never + * touches a session at all. No amount of grepping for `getSession` + * will ever show it to you. + * + * Neither family is wrong. Both are deliberate, and both are documented at + * their own call sites. The defect this file closes is that nothing said so + * when a new member joined. + * + * These are tripwires, not proofs. They cannot show an allowlist is + * correct — only that it has not changed without someone editing this file. + * + * A note on why every matcher carries its own count assertion: several legs + * below overlap (four of the five share-token files also sit under a + * `[token]` segment). If one leg's regex silently stopped matching, the + * union would be unchanged and the guard would stay green for the wrong + * reason. Each leg therefore asserts its own membership, so a matcher that + * matches nothing fails instead of passing. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join, sep } from "node:path"; +import { globSync } from "node:fs"; + +const SRC = join(process.cwd(), "src"); + +/** + * Every non-test `.ts` / `.tsx` under `src/app/`, which is the whole of the + * request-handling tree: API routes, RSC pages, layouts, route handlers. + * The generated Prisma client is excluded by construction (it is not under + * `app/`) and never read — see CLAUDE.md. + */ +function appFiles(): string[] { + return globSync("app/**/*.{ts,tsx}", { cwd: SRC }) + .map((p) => p.split(sep).join("/")) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) + .sort(); +} + +function read(rel: string): string { + return readFileSync(join(SRC, rel), "utf8"); +} + +function appFilesMatching(re: RegExp): string[] { + return appFiles().filter((rel) => re.test(read(rel))); +} + +describe("S1 — the direct session-resolution set is frozen", () => { + /** + * Files under `src/app` that resolve the caller from the session cookie + * WITHOUT going through `requireAuth()`. Every other authenticated route + * in the tree — 300-odd of them — reaches its user through `apiHandler` + * plus `requireAuth`, which is what makes this list short enough to read. + * + * An entry here is not a finding. It is a place where the protections + * `requireAuth` applies centrally have to be argued locally, so the + * reason is the point of the entry. + */ + const SESSION_RESOLUTION_ALLOWLIST: Record = { + // Completes the native ASWebAuthenticationSession handoff. Validates the + // browser session itself because it also needs `session.createdAt` for + // the freshness comparison, which `requireAuth` does not project. + "app/api/auth/native/complete/route.ts": + "reads createdAt for the handoff freshness check", + // Anonymous IdP callback. Probes for an already-valid session so a + // replayed callback redirects instead of minting a second identity. + "app/api/auth/oidc/callback/route.ts": + "duplicate-callback probe on an otherwise anonymous route", + // Public liveness probe. The session read only decides whether the body + // carries operational detail; the status code is computed without it. + "app/api/health/route.ts": "admin-only detail on a public probe", + // OAuth authorize runs under `withBackgroundEvent`, not `apiHandler`, + // because it owes RFC-format error bodies — so it resolves the + // consenting human itself. + "app/api/mcp/oauth/authorize/route.ts": + "consent decision on a route outside apiHandler", + // Provider OAuth callbacks. The browser arrives as a top-level + // navigation, so the code is bound to the session user and redirected. + "app/api/oura/callback/route.ts": + "binds the OAuth code to the session user", + "app/api/polar/callback/route.ts": + "binds the OAuth code to the session user", + "app/api/strava/callback/route.ts": + "binds the OAuth code to the session user", + "app/api/whoop/callback/route.ts": + "binds the OAuth code to the session user", + // RSC prefetch: the server component hydrates the client query cache + // with the session user's own data. Absence of a session skips the + // prefetch rather than failing the render. + "app/coach/page.tsx": "RSC prefetch of the session user's coach state", + "app/insights/page.tsx": "RSC prefetch of the session user's insights", + "app/insights/workouts/page.tsx": + "RSC prefetch of the session user's workouts", + "app/medications/page.tsx": + "RSC prefetch of the session user's medications", + "app/page.tsx": "RSC prefetch of the session user's dashboard snapshot", + // Onboarding routing. Reads the actor's own onboarding step to redirect; + // renders no record data. + "app/onboarding/[step]/page.tsx": + "redirects on the actor's onboarding step", + "app/onboarding/page.tsx": "redirects on the actor's onboarding step", + }; + + /** + * The identity-deriving helpers exported by `lib/auth/session.ts`. The + * lifecycle exports (`createSession`, `destroySession`, `destroyAllSessions`, + * `destroySessionById`, `destroyOtherSessions`) are deliberately NOT here: + * they act on a session, they do not hand the handler an identity to scope + * a query by. Adding a sixth deriving helper to `session.ts` means adding + * it to this regex. + */ + const SESSION_DERIVING_HELPERS = + /\b(getSession|getSessionUserLocale|validateSessionFromCookieValue|validateSessionWithCreatedAt)\s*\(/; + + /** + * Reaching for the cookie by name is the way around the helpers. Both the + * exported constant and the raw literal count. + */ + const RAW_SESSION_COOKIE = /\bSESSION_COOKIE_NAME\b|"healthlog_session"/; + + it("no file under src/app derives a session outside requireAuth unlisted", () => { + const resolvers = appFilesMatching(SESSION_DERIVING_HELPERS); + + // Non-zero proof. If the helper names are ever renamed out from under + // this regex, the match set collapses to empty — which must fail here + // rather than quietly agree with an emptied allowlist. + expect(resolvers.length).toBeGreaterThan(0); + expect(resolvers).toEqual(Object.keys(SESSION_RESOLUTION_ALLOWLIST).sort()); + }); + + it("no file under src/app reads the session cookie by name unlisted", () => { + const cookieReaders = appFilesMatching(RAW_SESSION_COOKIE); + + // Same non-zero proof, for this leg's own matcher. These two files also + // appear in the leg above, which is exactly why this count is asserted + // separately: an overlapping leg cannot borrow another leg's evidence. + expect(cookieReaders.length).toBeGreaterThan(0); + expect(cookieReaders).toEqual([ + "app/api/auth/native/complete/route.ts", + "app/api/auth/oidc/callback/route.ts", + ]); + + // And whatever it finds must already be an argued session surface. + for (const rel of cookieReaders) { + expect(Object.keys(SESSION_RESOLUTION_ALLOWLIST)).toContain(rel); + } + }); + + it("every allowlist entry carries a reason", () => { + for (const [rel, reason] of Object.entries(SESSION_RESOLUTION_ALLOWLIST)) { + expect(reason.trim().length, `${rel} has no reason`).toBeGreaterThan(0); + } + }); +}); + +describe("S2 — the URL-token authentication set is frozen", () => { + /** + * Files whose credential is a token in the URL — a path segment or a + * query parameter — rather than a session cookie or a Bearer header. + * + * This is the family the Bearer guard cannot see and a `getSession` sweep + * cannot see. `src/app/c/[token]/**` resolves one user's entire health + * record from an `hls_` path segment held by an anonymous caller. That is + * the product's design, it predates every guard in this file, and it is + * not a defect. What was a defect is that a sixth door could have been + * cut next to it without anything saying so. + */ + const URL_TOKEN_ALLOWLIST: Record = { + // Clinician share link: the `hls_` path segment is the whole credential. + "app/c/[token]/page.tsx": "serves the shared record view", + "app/c/[token]/d/[id]/route.ts": "serves a shared document's bytes", + "app/c/[token]/fhir/route.ts": "serves the shared record as a FHIR bundle", + "app/c/[token]/report.pdf/route.ts": "serves the shared record as a PDF", + // The passphrase gate for the same token. Anonymous by design; mints the + // token-scoped unlock cookie the view above checks. + "app/api/c/[token]/unlock/route.ts": "verifies the share-link passphrase", + // Webhook entrypoints. The provider supports no headers on a delivery, + // so the shared secret rides the path segment and is scrubbed from logs + // by `PATH_SECRET_PATHS`. These resolve a record from the payload after + // the secret compares equal. + "app/api/whoop/webhook/[token]/route.ts": + "path-segment shared secret, plus an HMAC body signature", + "app/api/withings/webhook/[token]/route.ts": + "path-segment shared secret; no body signature is available", + // The legacy form of the same Withings secret, still alive for + // subscriptions that have not rotated to the path-segment URL. + "app/api/withings/webhook/route.ts": + "legacy query-parameter form of the Withings webhook secret", + // Shape check only. Validates the `hlv_` prefix and redirects; touches + // no database, resolves nothing, and is not an enumeration oracle. + // Listed because it is a URL-token surface, with the reason being that + // it authenticates nothing. + "app/invite/[token]/page.tsx": + "validates the invite token's shape and redirects; no lookup", + }; + + /** + * Leg 1 — route shape. A dynamic segment whose name carries `token` or + * `secret` puts a credential in the URL by construction. This matcher + * reads the filesystem, not the file, so no variable name can slip past + * it; a new such route is caught before anyone reads its body. + */ + function urlCredentialSegmentFiles(): string[] { + return appFiles().filter((rel) => + rel.split("/").some((seg) => /^\[.*(token|secret).*\]$/i.test(seg)), + ); + } + + /** + * Leg 2 — share-token resolution by content, so a share surface that ever + * moves out from under a `[token]` segment is still caught. + */ + const SHARE_TOKEN_RESOLVERS = + /\b(resolveShareToken|resolveShareGateState|resolveShareReportDownload|loadShareViewData)\s*\(/; + + /** + * Leg 3 — a credential read out of the query string. + */ + const QUERY_CREDENTIAL = + /searchParams\.get\(\s*["'](secret|token|key|passphrase)["']\s*\)/; + + it("every URL-credential route segment is named", () => { + const bySegment = urlCredentialSegmentFiles(); + + expect(bySegment.length).toBeGreaterThan(0); + expect(bySegment).toEqual([ + "app/api/c/[token]/unlock/route.ts", + "app/api/whoop/webhook/[token]/route.ts", + "app/api/withings/webhook/[token]/route.ts", + "app/c/[token]/d/[id]/route.ts", + "app/c/[token]/fhir/route.ts", + "app/c/[token]/page.tsx", + "app/c/[token]/report.pdf/route.ts", + "app/invite/[token]/page.tsx", + ]); + }); + + it("every share-token resolver is named", () => { + const byResolver = appFilesMatching(SHARE_TOKEN_RESOLVERS); + + expect(byResolver.length).toBeGreaterThan(0); + expect(byResolver).toEqual([ + "app/api/c/[token]/unlock/route.ts", + "app/c/[token]/d/[id]/route.ts", + "app/c/[token]/fhir/route.ts", + "app/c/[token]/page.tsx", + "app/c/[token]/report.pdf/route.ts", + ]); + }); + + it("every query-string credential read is named", () => { + const byQuery = appFilesMatching(QUERY_CREDENTIAL); + + expect(byQuery.length).toBeGreaterThan(0); + expect(byQuery).toEqual(["app/api/withings/webhook/route.ts"]); + }); + + it("the three legs together equal the frozen allowlist", () => { + const union = [ + ...new Set([ + ...urlCredentialSegmentFiles(), + ...appFilesMatching(SHARE_TOKEN_RESOLVERS), + ...appFilesMatching(QUERY_CREDENTIAL), + ]), + ].sort(); + + expect(union.length).toBeGreaterThan(0); + expect(union).toEqual(Object.keys(URL_TOKEN_ALLOWLIST).sort()); + }); + + it("every allowlist entry carries a reason", () => { + for (const [rel, reason] of Object.entries(URL_TOKEN_ALLOWLIST)) { + expect(reason.trim().length, `${rel} has no reason`).toBeGreaterThan(0); + } + }); +}); + +describe("S3 — the two families stay distinct", () => { + /** + * A file that both resolves a session AND authenticates by a URL token is + * not forbidden, but it is a shape nobody has reviewed. If one appears, + * it should appear in a diff that says so rather than in production. + */ + it("no file authenticates by both a session and a URL token", () => { + const sessionFiles = appFilesMatching( + /\b(getSession|getSessionUserLocale|validateSessionFromCookieValue|validateSessionWithCreatedAt)\s*\(/, + ); + const tokenFiles = appFiles().filter((rel) => + rel.split("/").some((seg) => /^\[.*(token|secret).*\]$/i.test(seg)), + ); + + expect(sessionFiles.length).toBeGreaterThan(0); + expect(tokenFiles.length).toBeGreaterThan(0); + + const both = sessionFiles.filter((rel) => tokenFiles.includes(rel)); + expect(both).toEqual([]); + }); +}); diff --git a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts index 1305d3123..88bc30426 100644 --- a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts +++ b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts @@ -22,6 +22,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; import { Buffer } from "node:buffer"; +import { SCORE_VERSION } from "@/lib/analytics/score/types"; + process.env.ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @@ -269,7 +271,7 @@ function sourceClient() { timezone: "Europe/Berlin", composite: 76, band: "yellow", - scoreVersion: 2, + scoreVersion: SCORE_VERSION, composition: ["BLOOD_PRESSURE", "SLEEP", "ADIPOSITY"], pillarScores: { BLOOD_PRESSURE: 86, SLEEP: 100, ADIPOSITY: 42 }, inputFingerprint: "a".repeat(64), @@ -629,7 +631,7 @@ describe("backup round trip — export, wire schema, restore", () => { timezone: "Europe/Berlin", composite: 76, band: "yellow", - scoreVersion: 2, + scoreVersion: SCORE_VERSION, inputFingerprint: "a".repeat(64), }); // The composition and the per-pillar scores are what keep the stored day diff --git a/src/app/api/daily/digest/__tests__/route.test.ts b/src/app/api/daily/digest/__tests__/route.test.ts index 28ca64f05..4ae9ddb1a 100644 --- a/src/app/api/daily/digest/__tests__/route.test.ts +++ b/src/app/api/daily/digest/__tests__/route.test.ts @@ -3,6 +3,7 @@ import { NextRequest } from "next/server"; import { apiError } from "@/lib/api-response"; import type { DailyDigest } from "@/lib/daily/digest"; +import { SCORE_VERSION } from "@/lib/analytics/score/types"; /** * `GET /api/daily/digest` — the P3 read seam. @@ -63,7 +64,7 @@ const DIGEST: DailyDigest = { band: "good", delta: 3, deltaReason: null, - scoreVersion: 2, + scoreVersion: SCORE_VERSION, composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], }, topSignal: null, diff --git a/src/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx index 00ca9ccc4..5be773a7e 100644 --- a/src/components/daily/__tests__/today-hero.test.tsx +++ b/src/components/daily/__tests__/today-hero.test.tsx @@ -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 { SCORE_VERSION } from "@/lib/analytics/score/types"; // The hero now wires the coach check-in card's keep / let-go taps through // `useCoachCheckinAction`, so it needs a QueryClient in the tree. @@ -57,7 +58,7 @@ function digest(over: Partial = {}): DailyDigest { band: "green", delta: 3, deltaReason: null, - scoreVersion: 2, + scoreVersion: SCORE_VERSION, composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], }, topSignal: { @@ -114,7 +115,7 @@ describe("", () => { band: "green", delta: 20, deltaReason: "algorithm_changed", - scoreVersion: 2, + scoreVersion: SCORE_VERSION, composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], }, })} diff --git a/src/lib/analytics/score/__tests__/record.test.ts b/src/lib/analytics/score/__tests__/record.test.ts index 2d92f5a91..b0b05c480 100644 --- a/src/lib/analytics/score/__tests__/record.test.ts +++ b/src/lib/analytics/score/__tests__/record.test.ts @@ -144,8 +144,12 @@ describe("stored bands", () => { }); describe("input fingerprint", () => { + // The fixture only needs two DIFFERENT version markers to prove the + // fingerprint is sensitive to a version move — pinned relative to + // SCORE_VERSION so the pair stays "previous vs current" rather than + // going stale (or colliding) the next time the constant bumps. const base = { - scoreVersion: 1, + scoreVersion: SCORE_VERSION - 1, composition: ["BLOOD_PRESSURE", "SLEEP", "ADIPOSITY"] as ScorePillarId[], pillars: HEALTHY, }; @@ -157,9 +161,9 @@ describe("input fingerprint", () => { }); it("moves when the algorithm version moves", () => { - expect(healthScoreInputFingerprint({ ...base, scoreVersion: 2 })).not.toBe( - healthScoreInputFingerprint(base), - ); + expect( + healthScoreInputFingerprint({ ...base, scoreVersion: SCORE_VERSION }), + ).not.toBe(healthScoreInputFingerprint(base)); }); it("moves when the composition narrows", () => { diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 8077c2b43..fb6dacf08 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -77,7 +77,7 @@ vi.mock("@/lib/analytics/score/reader", () => ({ bandSetter: "BLOOD_PRESSURE" as const, composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"] as const, noiseFloor: 2, - scoreVersion: 2 as const, + scoreVersion: SCORE_VERSION, }, coverage, confidence: { score: 100, band: "high" as const }, @@ -94,7 +94,7 @@ vi.mock("@/lib/analytics/score/reader", () => ({ pillars: [], delta: legacy?.delta ?? null, deltaReason: null, - scoreVersion: 2 as const, + scoreVersion: SCORE_VERSION, weightGoal: { status: "insufficient" as const, coverage, @@ -154,6 +154,7 @@ import { } from "../snapshot"; import type { ModuleKey } from "@/lib/modules/gate"; import { MODULE_KEYS } from "@/lib/modules/registry"; +import { SCORE_VERSION } from "@/lib/analytics/score/types"; const emptySummary = { count: 0, @@ -370,7 +371,7 @@ describe("buildDashboardSnapshot — per-type thick-phase gate", () => { confidence: { score: 100, band: "high" }, composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], deltaReason: null, - scoreVersion: 2, + scoreVersion: SCORE_VERSION, bandSetter: "BLOOD_PRESSURE", // v1.21.2 (A5 / A6) — additive narrative fields, null absent a // disagreeing readiness set / a returned metric (the fake derived @@ -516,7 +517,7 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => { confidence: { score: 100, band: "high" }, composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], deltaReason: null, - scoreVersion: 2, + scoreVersion: SCORE_VERSION, bandSetter: "BLOOD_PRESSURE", // v1.21.2 (A5 / A6) — additive narrative fields, null here. tension: null, diff --git a/src/lib/insights/__tests__/features.test.ts b/src/lib/insights/__tests__/features.test.ts index 3f5037b66..83f17d9cf 100644 --- a/src/lib/insights/__tests__/features.test.ts +++ b/src/lib/insights/__tests__/features.test.ts @@ -37,6 +37,7 @@ vi.mock("@/lib/medication-category", () => ({ })); import { prisma } from "@/lib/db"; +import { invalidateUserTimezone } from "@/lib/tz/resolver"; import { extractFeatures, FeaturesPayloadTooLargeError, @@ -69,6 +70,13 @@ const prismaMock = prisma as unknown as { const dayMs = 24 * 60 * 60 * 1000; +/** + * Its own account id, because `resolveUserTimezone` memoises per user for a + * minute — reusing "user-1" would either read a zone a neighbouring test set + * or leave one behind for the next. + */ +const TZ_USER = "user-tz"; + function rollupRow(daysAgo: number, mean: number, count: number) { return { bucketStart: new Date(Date.now() - daysAgo * dayMs), @@ -404,6 +412,44 @@ describe("extractFeatures — v1.22 preventive-care block", () => { const f = await extractFeatures("user-1", false); expect(f.preventiveCare).toBeUndefined(); }); + + /** + * The day counting itself is pinned in `preventive-care-days.test.ts`; what + * this pins is the wire between it and the account — that the zone the block + * counts on is the one on the person's PROFILE, and that it survives the trip + * through `extractFeatures`. Both ends were right once before while the + * assembly between them handed down something else. + * + * One instant, three answers. At 05:00 UTC on the 18th it is still the 17th + * in Los Angeles, so a checkup at 20:00 UTC on the 18th is tomorrow's there — + * while UTC and the Berlin server default both call it today's. Only the + * profile zone gives 1, so nothing but the profile zone can produce it. + */ + it("counts the days on the profile's calendar, not UTC's or the server's", async () => { + const NOW = Date.parse("2026-07-18T05:00:00Z"); + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(NOW); + invalidateUserTimezone(TZ_USER); + prismaMock.user.findUnique.mockResolvedValue({ + heightCm: 180, + dateOfBirth: new Date("1980-01-01"), + gender: "MALE", + timezone: "America/Los_Angeles", + }); + prismaMock.measurementReminder.findMany.mockResolvedValue([ + { label: "Augenarzt", nextDueAt: new Date("2026-07-18T20:00:00Z") }, + ]); + + try { + const f = await extractFeatures(TZ_USER, false); + expect(f.preventiveCare?.due).toEqual([ + { label: "Augenarzt", daysUntil: 1 }, + ]); + } finally { + vi.useRealTimers(); + invalidateUserTimezone(TZ_USER); + } + }); }); describe("extractFeatures — v1.22 workouts block", () => { diff --git a/src/lib/insights/__tests__/preventive-care-days.test.ts b/src/lib/insights/__tests__/preventive-care-days.test.ts new file mode 100644 index 000000000..dcee083ed --- /dev/null +++ b/src/lib/insights/__tests__/preventive-care-days.test.ts @@ -0,0 +1,174 @@ +/** + * The preventive-care block hands the briefing model two bare numbers per + * checkup — "due in N days", "overdue by N days" — and the model writes them + * into prose the person reads next to the checkup screens. So the number has + * to answer the same question the screens answer: how many dates away is it on + * the person's own calendar, not how many hours away divided by twenty-four. + * + * Every case below is one where those two answers differ. Each instant carries + * an explicit offset so the suite says the same thing whatever zone the machine + * running it is set to. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { measurementReminder: { findMany: vi.fn() } }, +})); + +import { prisma } from "@/lib/db"; +import { readPreventiveCareBlock } from "../feature-blocks"; + +const prismaMock = prisma as unknown as { + measurementReminder: { findMany: ReturnType }; +}; + +/** + * The profile zone under test. July is deliberate: no IANA zone shifts its + * clocks mid-July, so a fixed offset stands for the whole month and building + * 00:01 or 23:59 can never land on a skipped or repeated hour. + */ +const ZONE = "Europe/Berlin"; +const ZONE_OFFSET = "+02:00"; + +// Fri 17th, Sat 18th, Sun 19th of July 2026. +const FRIDAY = 17; +const SATURDAY = 18; +const SUNDAY = 19; + +const pad = (n: number) => String(n).padStart(2, "0"); + +/** A wall-clock time in {@link ZONE}, as the instant the column stores. */ +function zoned(day: number, hour: number, minute = 0): Date { + return new Date( + `2026-07-${pad(day)}T${pad(hour)}:${pad(minute)}:00${ZONE_OFFSET}`, + ); +} + +/** The same wall clock as the epoch millis the caller passes for `now`. */ +function at(day: number, hour: number, minute = 0): number { + return zoned(day, hour, minute).getTime(); +} + +/** One reminder due at the given instant, as the only row the read returns. */ +function onlyReminder(nextDueAt: Date) { + prismaMock.measurementReminder.findMany.mockResolvedValue([ + { label: "Augenarzt", nextDueAt }, + ]); +} + +beforeEach(() => { + prismaMock.measurementReminder.findMany.mockReset(); +}); + +describe("readPreventiveCareBlock counts calendar days, not hours", () => { + it("keeps a checkup booked for this morning on today's list all day", async () => { + // Eleven hours in the past, and still the checkup for today. An hour count + // moved it to the overdue list and then rounded the count to nothing, so + // the payload said "overdue by 0 days" about something not yet missed. + onlyReminder(zoned(FRIDAY, 9)); + const block = await readPreventiveCareBlock("user-1", at(FRIDAY, 20), ZONE); + + expect(block?.overdue).toEqual([]); + expect(block?.due).toEqual([{ label: "Augenarzt", daysUntil: 0 }]); + }); + + it("calls a Sunday 01:00 checkup two days out when read late on Friday", async () => { + // Twenty-six hours apart, so an hour count rounds to one and the briefing + // says tomorrow. Two dates separate them on the calendar. + onlyReminder(zoned(SUNDAY, 1)); + const block = await readPreventiveCareBlock("user-1", at(FRIDAY, 23), ZONE); + + expect(block?.due).toEqual([{ label: "Augenarzt", daysUntil: 2 }]); + }); + + it("calls a Saturday 23:59 checkup one day out when read just after Friday midnight", async () => { + // Just under forty-eight hours apart, so an hour count rounds to two. It is + // the very next date on the calendar. + onlyReminder(zoned(SATURDAY, 23, 59)); + const block = await readPreventiveCareBlock( + "user-1", + at(FRIDAY, 0, 1), + ZONE, + ); + + expect(block?.due).toEqual([{ label: "Augenarzt", daysUntil: 1 }]); + }); + + it("counts a checkup missed yesterday as one day overdue, however recent", async () => { + // Forty-five minutes in the past. It was still yesterday's checkup, and an + // hour count called it overdue by nothing at all. + onlyReminder(zoned(FRIDAY, 23, 30)); + const block = await readPreventiveCareBlock( + "user-1", + at(SATURDAY, 0, 15), + ZONE, + ); + + expect(block?.due).toEqual([]); + expect(block?.overdue).toEqual([{ label: "Augenarzt", daysOverdue: 1 }]); + }); + + it("keeps a whole clock-change day as one day", async () => { + // The Berlin day the clocks go back is twenty-five hours long, so an hour + // count rounds a same-day evening checkup up to tomorrow. + onlyReminder(new Date("2026-10-25T22:00:00Z")); // 23:00 Berlin, after + const block = await readPreventiveCareBlock( + "user-1", + Date.parse("2026-10-25T00:00:00Z"), // 02:00 Berlin, before + ZONE, + ); + + expect(block?.due).toEqual([{ label: "Augenarzt", daysUntil: 0 }]); + }); +}); + +describe("readPreventiveCareBlock counts on the profile's calendar", () => { + // 22:30 UTC on the 17th is already 00:30 on the 18th in Berlin, and the + // checkup at 21:00 UTC on the 18th is 23:00 Berlin on that same 18th. So + // Berlin says today and UTC says tomorrow about one identical pair of + // instants. Asserting BOTH is what makes this bite: an implementation that + // ignores the zone it is given returns the same number twice. + const NOW = Date.parse("2026-07-17T22:30:00Z"); + const DUE = new Date("2026-07-18T21:00:00Z"); + + it("gives two profiles either side of midnight two different counts", async () => { + onlyReminder(DUE); + const berlin = await readPreventiveCareBlock( + "user-1", + NOW, + "Europe/Berlin", + ); + onlyReminder(DUE); + const utc = await readPreventiveCareBlock("user-1", NOW, "UTC"); + + expect(berlin?.due).toEqual([{ label: "Augenarzt", daysUntil: 0 }]); + expect(utc?.due).toEqual([{ label: "Augenarzt", daysUntil: 1 }]); + }); + + /** + * The machine's own clock, moved under the function's feet. `vitest.config` + * pins the suite to UTC, and a `TZ=` prefix on the command line does not + * override that — the only way into another host zone is to assign + * `process.env.TZ`, which is what this does. Whatever the host is set to, + * the answer must not move: that is the difference between reading the + * profile and reading the server the code happens to run on. + */ + const HOST_ZONES = ["UTC", "Pacific/Auckland", "America/Los_Angeles"]; + + it("ignores the zone the host machine itself is set to", async () => { + const original = process.env.TZ; + const answers: Array = []; + try { + for (const hostZone of HOST_ZONES) { + process.env.TZ = hostZone; + onlyReminder(DUE); + const block = await readPreventiveCareBlock("user-1", NOW, ZONE); + answers.push(block?.due[0]?.daysUntil); + } + } finally { + process.env.TZ = original; + } + + expect(answers).toEqual([0, 0, 0]); + }); +}); diff --git a/src/lib/insights/feature-blocks.ts b/src/lib/insights/feature-blocks.ts index 191aef506..b8dc18b35 100644 --- a/src/lib/insights/feature-blocks.ts +++ b/src/lib/insights/feature-blocks.ts @@ -11,6 +11,7 @@ */ import { prisma } from "@/lib/db"; import { classifyReferenceRange } from "@/lib/labs/reference-range"; +import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day"; import { resolveLabFields } from "@/lib/labs/serialise"; import { readRollupBuckets } from "@/lib/rollups/measurement-rollups"; import { deriveBucketedTypes } from "@/lib/signals/adapters/correlation"; @@ -251,10 +252,23 @@ export async function readLabsBriefingBlock( * v1.22 — preventive-care (Vorsorge) due + overdue read-side. Reads the * user's enabled, live reminders and buckets by the server-authoritative * `nextDueAt`. Returns `undefined` when nothing is due or overdue. + * + * `daysUntil` / `daysOverdue` are CALENDAR days in `timeZone`, counted by the + * same `calendarDaysUntil` the checkup screens phrase their due line with. + * They used to be an hour gap divided by 24, which is a different question: + * a checkup at 09:00 read that evening came out "overdue by 0 days", and one + * due tomorrow morning read late tonight came out due "in 0 days" — today's. + * The model narrates these numbers, so it repeated whichever the arithmetic + * had drifted to while the screen next to it said something else. + * + * `timeZone` is the user's profile zone; the horizon that bounds the read + * stays an hours-based cutoff on purpose — it is a query bound, not a + * statement to anyone about which day something falls on. */ export async function readPreventiveCareBlock( userId: string, now: number, + timeZone: string, ): Promise { const horizon = new Date(now + PREVENTIVE_DUE_HORIZON_DAYS * MS_PER_DAY); const rows = await prisma.measurementReminder.findMany({ @@ -273,15 +287,18 @@ export async function readPreventiveCareBlock( const overdue: NonNullable["overdue"] = []; const due: NonNullable["due"] = []; + const nowDate = new Date(now); for (const r of rows) { if (!r.nextDueAt) continue; const label = sanitizeLabel(r.label); if (!label) continue; - const diffMs = r.nextDueAt.getTime() - now; - if (diffMs < 0) { - overdue.push({ label, daysOverdue: Math.round(-diffMs / MS_PER_DAY) }); + const days = calendarDaysUntil(r.nextDueAt, nowDate, timeZone); + // Today's checkup is due, not overdue — the hour it was booked for may + // have passed, but the day it belongs to has not. + if (days < 0) { + overdue.push({ label, daysOverdue: -days }); } else { - due.push({ label, daysUntil: Math.round(diffMs / MS_PER_DAY) }); + due.push({ label, daysUntil: days }); } } if (overdue.length === 0 && due.length === 0) return undefined; diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 916c05b45..7157651f5 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -1354,9 +1354,15 @@ export async function extractFeatures( (typeof sinceDays === "number" && sinceDays >= INTEGRATION_BLOCK_MIN_WINDOW_DAYS); if (includeIntegrations) { + // The preventive-care block states a distance in whole days, so it needs + // to know whose calendar it is counting on. Resolved here rather than + // inside the block because the resolver reads the database and this is + // the layer that already talks to it; the resolver's own 60 s cache makes + // the second call in this function free. + const preventiveTz = await resolveUserTimezone(userId); const [labs, preventiveCare, workouts, ecg] = await Promise.all([ readLabsBriefingBlock(userId, now), - readPreventiveCareBlock(userId, now), + readPreventiveCareBlock(userId, now, preventiveTz), readWorkoutsBlock(userId, now), // S10 — ECG device-verdict descriptor (never the waveform). Weaves the // ECG silo into the narrative; the model may reference it, attributed to diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index 48e013be8..358b6ac1d 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -687,6 +687,95 @@ describe("get_preventive_care", () => { }; expect(result.present).toBe(false); }); + + /** + * Overdue is a question about dates, and the answer has to hold for the + * whole of the date. Comparing the two instants flipped the flag at the + * hour the checkup was booked for, so from ten past nine the tool told the + * model a checkup was overdue while the screen beside it still read + * "today" — one reminder, two verdicts. + * + * Read in the profile zone the mocked account carries (UTC), so the + * instants below say the same thing whatever the host machine is set to. + */ + async function overdueFlagFor( + nextDueAt: string, + now: string, + timezone = "UTC", + ) { + vi.setSystemTime(Date.parse(now)); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ timezone } as never); + vi.mocked(prisma.measurementReminder.findMany).mockResolvedValue([ + { id: "r-1" }, + ] as never); + vi.mocked(toMeasurementReminderDto).mockReturnValue({ + id: "r-1", + label: "Blood pressure check", + measurementType: "BLOOD_PRESSURE_SYS", + intervalDays: 30, + rrule: null, + anchorDate: null, + endsOn: null, + origin: "VORSORGE", + notifyHour: 9, + location: null, + nextDueAt, + lastSatisfiedAt: null, + enabled: true, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + const result = (await tool("get_preventive_care").run(CTX, {})) as { + checkups: Array<{ overdue: boolean }>; + }; + return result.checkups[0]?.overdue; + } + + it("holds the overdue flag for the whole of the day a checkup is due", async () => { + vi.useFakeTimers(); + try { + // The booked hour has passed by eleven hours, and it is still the day + // the checkup belongs to. + expect( + await overdueFlagFor( + "2026-07-17T09:00:00.000Z", + "2026-07-17T20:00:00.000Z", + ), + ).toBe(false); + // Forty-five minutes later on the clock, and a date later on the + // calendar. Now it is missed. + expect( + await overdueFlagFor( + "2026-07-17T23:30:00.000Z", + "2026-07-18T00:15:00.000Z", + ), + ).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("reads the dates off the profile's calendar, not the server's", async () => { + vi.useFakeTimers(); + try { + // The same pair of instants as the first case above, which UTC reads as + // one date and answers "not overdue". Twelve hours east it is already + // the next morning, so that same checkup was yesterday's and is missed. + // Only the profile zone can produce the second answer. + expect( + await overdueFlagFor( + "2026-07-17T09:00:00.000Z", + "2026-07-17T20:00:00.000Z", + "Pacific/Auckland", + ), + ).toBe(true); + } finally { + vi.useRealTimers(); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + timezone: "UTC", + } as never); + } + }); }); describe("search — cursor pagination", () => { diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 13abed92e..b0d9ee897 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -67,6 +67,7 @@ import { resolveSyncVerdict, } from "@/lib/integrations/sync-verdict"; import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; +import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day"; import { fenceUserText, scrubFenceMarkers } from "@/lib/ai/coach/data-fence"; import type { McpAuthContext } from "./auth"; @@ -1566,7 +1567,16 @@ export const MCP_TOOLS: McpToolDefinition[] = [ return { present: false }; } - const nowMs = Date.now(); + // Overdue is a CALENDAR-day verdict, the same one the checkups screen + // renders. Comparing the instants said a checkup booked for nine this + // morning was overdue by ten past, so the tool called something overdue + // on the very day it is due while the screen next to it read "today". + const user = await prisma.user.findUnique({ + where: { id: ctx.userId }, + select: { timezone: true }, + }); + const timezone = user?.timezone || DEFAULT_TIMEZONE; + const now = new Date(); const checkups = reminders.map((r) => { const dto = toMeasurementReminderDto(r); return { @@ -1575,7 +1585,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [ nextDueAt: dto.nextDueAt, overdue: dto.nextDueAt !== null - ? new Date(dto.nextDueAt).getTime() < nowMs + ? calendarDaysUntil(new Date(dto.nextDueAt), now, timezone) < 0 : false, location: dto.location, lastSatisfiedAt: dto.lastSatisfiedAt, diff --git a/src/lib/measurement-reminders/due-day.ts b/src/lib/measurement-reminders/due-day.ts index 855df0943..5efa96ed1 100644 --- a/src/lib/measurement-reminders/due-day.ts +++ b/src/lib/measurement-reminders/due-day.ts @@ -14,11 +14,16 @@ * `nextDueAt`) because this is the read side of the same value. Nothing in * this file touches the database or the network, so a client component can * import it directly. + * + * The words are only half of it. The AI features payload states the same + * distance as a bare number ("due in N days", "overdue by N days") for the + * briefing to narrate, and it counted hours the way the dashboard card used + * to. So the counting itself is {@link calendarDaysUntil} and both the words + * and the number come off it — the screens and the prose the model writes + * about them cannot say different things about the same reminder. */ import { DEFAULT_TIMEZONE, isValidTimezone } from "@/lib/tz/format"; -import { startOfLocalDayInTz } from "@/lib/tz/local-day"; - -const DAY_MS = 24 * 60 * 60 * 1000; +import { localDayIndex, wallClockInTz } from "@/lib/tz/wall-clock"; /** A fully-qualified i18n key plus the day count it interpolates. */ export interface RelativeDue { @@ -26,6 +31,39 @@ export interface RelativeDue { days: number; } +/** + * How many CALENDAR days forward `target` sits from `now`, both read on the + * wall clock of `timeZone`. Today is 0, tomorrow is 1, yesterday is -1. + * + * Not an hour count divided by 24. A checkup due at 09:00 is still due today + * at 20:00; one due tomorrow at 01:00 is tomorrow's whether it is read at + * 23:00 tonight or at 06:00 this morning. Dividing the gap by 24 h answers a + * different question and gets both of those wrong, and it also admits the + * 23 h / 25 h length of a clock-change day. This differences two integer day + * indices instead, so the answer is exact by construction rather than by + * rounding. + * + * `timeZone` is the person's PROFILE zone — the same clock the date printed + * beside the phrase is rendered in. It is required on purpose: it used to be + * implicit, so the day floor silently took the DEVICE clock while the date + * next to it took the profile clock, and on a laptop set elsewhere the card + * printed a date and a phrase that contradicted each other. A zone that + * cannot be read falls back exactly as `relativeCalendarDate` and `fmt.date` + * do (issue #490) — to Berlin, never to the host clock, because landing on + * the host is what let the two halves drift apart in the first place. + */ +export function calendarDaysUntil( + target: Date, + now: Date, + timeZone: string, +): number { + const zone = isValidTimezone(timeZone) ? timeZone : DEFAULT_TIMEZONE; + return ( + localDayIndex(wallClockInTz(target, zone)) - + localDayIndex(wallClockInTz(now, zone)) + ); +} + /** * v1.32.36 — returns a FULLY-QUALIFIED i18n key, not a bare tail. A call site * that interpolates the tail onto the namespace root anchors the @@ -33,14 +71,10 @@ export interface RelativeDue { * it reachable and turns the guard into a no-op for the whole namespace. The * guard now refuses that shape outright, so the key is built whole here. * - * `timeZone` is required on purpose. It used to be implicit — the day floor - * silently took the DEVICE clock while the date printed next to it took the - * profile clock, so on a laptop set to another zone the card could print a - * date and a phrase that contradicted each other. Naming the zone is how a - * caller says which of those two clocks it means, and there is no default - * because the missing answer was the whole bug: a silent caller got something - * plausible-looking rather than an error. Pass what the neighbouring date - * label renders in — `useDisplayTimezone()` on the client. + * Pass what the neighbouring date label renders in — `useDisplayTimezone()` + * on the client. The zone argument carries straight through to + * {@link calendarDaysUntil}, which is where the day counting happens and + * where the reasoning for both the requirement and the fallback lives. */ export function relativeDueKey( nextDueAt: string | null, @@ -51,20 +85,7 @@ export function relativeDueKey( const due = new Date(nextDueAt); if (Number.isNaN(due.getTime())) return { key: "measurementReminders.nextDue.none", days: 0 }; - // Resolve exactly as `relativeCalendarDate` and `fmt.date` do (issue #490): - // a usable zone stands, anything else lands on Berlin. Note where it does - // NOT land — the device. Falling back to the host clock is what let the two - // halves of this card drift apart, and a zone the profile mirror could not - // make sense of has to end up wherever the printed date ends up. - const zone = isValidTimezone(timeZone) ? timeZone : DEFAULT_TIMEZONE; - // v1.18.9 (recon) — compare CALENDAR-day deltas in the user's zone, not a - // rolling-24h delta. A reminder due at 09:00 viewed the same evening at - // 20:00 must still read "heute", and one whose due calendar-day is before - // today must read "überfällig" — a raw `(due - now) / DAY_MS` round would - // mis-bucket both. Floor each instant to its local day start first. - const dueDay = startOfLocalDayInTz(due, zone).getTime(); - const nowDay = startOfLocalDayInTz(new Date(now), zone).getTime(); - const deltaDays = Math.round((dueDay - nowDay) / DAY_MS); + const deltaDays = calendarDaysUntil(due, new Date(now), timeZone); if (deltaDays < 0) return { key: "measurementReminders.overdueByDays",