Merge pull request #678 from MBombeck/release/v1.35.4

release/v1.35.4
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-02 00:57:18 +02:00
commit ceacf4f809
21 files changed
+796 -55

No files matched your search

+30
View File
@@ -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
+1 -1
View File
@@ -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.
+8 -3
View File
@@ -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 ({
+3 -2
View File
@@ -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: {
+2 -1
View File
@@ -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,
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
@@ -64,16 +64,34 @@ describe("T1 — the Bearer resolution set is frozen", () => {
// surface, which gates on BOTH `medication:ingest` and the per-medication
// `medication:<id>: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);
});
+314
View File
@@ -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<string, string> = {
// 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<string, string> = {
// 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([]);
});
});
@@ -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
@@ -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,
@@ -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> = {}): DailyDigest {
band: "green",
delta: 3,
deltaReason: null,
scoreVersion: 2,
scoreVersion: SCORE_VERSION,
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
},
topSignal: {
@@ -114,7 +115,7 @@ describe("<TodayHero>", () => {
band: "green",
delta: 20,
deltaReason: "algorithm_changed",
scoreVersion: 2,
scoreVersion: SCORE_VERSION,
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
},
})}
@@ -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", () => {
+5 -4
View File
@@ -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,
@@ -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", () => {
@@ -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<typeof vi.fn> };
};
/**
* 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<number | undefined> = [];
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]);
});
});
+21 -4
View File
@@ -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<AggregatedFeatures["preventiveCare"] | undefined> {
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<AggregatedFeatures["preventiveCare"]>["overdue"] =
[];
const due: NonNullable<AggregatedFeatures["preventiveCare"]>["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;
+7 -1
View File
@@ -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
+89
View File
@@ -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", () => {
+12 -2
View File
@@ -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,
+46 -25
View File
@@ -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",