mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
21 files changed
+796
-55
No files matched your search
@@ -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,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.
|
||||
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user