+ {data?.attribution ?? "Weather data by Open-Meteo.com"}
+
+
+ );
+}
diff --git a/src/components/settings/modules-section.tsx b/src/components/settings/modules-section.tsx
index f6c5a5f43..3dc727b67 100644
--- a/src/components/settings/modules-section.tsx
+++ b/src/components/settings/modules-section.tsx
@@ -30,6 +30,7 @@ import {
Blocks,
CalendarHeart,
Droplet,
+ CloudSun,
Dumbbell,
FileText,
MessageCircleHeart,
@@ -72,6 +73,8 @@ const MODULE_ICONS: Record = {
// v1.18.1 (D3) — medications graduated from CORE to a toggleable module.
medications: Pill,
doctorReport: FileText,
+ // v1.25.0 — the environmental-context module (opt-in weather/daylight feed).
+ environment: CloudSun,
// v1.22.0 — the remote MCP endpoint (opt-in connectivity module).
mcp: Plug,
};
diff --git a/src/components/settings/section-placeholder.tsx b/src/components/settings/section-placeholder.tsx
index eefcf76ce..8b6b05c6b 100644
--- a/src/components/settings/section-placeholder.tsx
+++ b/src/components/settings/section-placeholder.tsx
@@ -16,6 +16,7 @@ import {
Blocks,
Bot,
CalendarCheck,
+ CloudSun,
Download,
ClipboardList,
FileHeart,
@@ -60,6 +61,7 @@ const SLUG_ICON: Record = {
mood: Smile,
labs: FlaskConical,
illness: Thermometer,
+ environment: CloudSun,
anamnesis: ClipboardList,
vorsorge: CalendarCheck,
thresholds: SlidersHorizontal,
diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts
index 84872348c..8483253ef 100644
--- a/src/components/settings/section-slugs.ts
+++ b/src/components/settings/section-slugs.ts
@@ -69,6 +69,10 @@ export const SETTINGS_SECTION_SLUGS = [
"mood",
"labs",
"illness",
+ // v1.25 (W-ENV) — "Umwelt" (environmental context): home location, travel
+ // overrides, and the weather/daylight backfill. Module-gated on the opt-in
+ // `environment` module (the nav entry only shows when it is on).
+ "environment",
// v1.25 (W-RECORDS) — "Anamnese" (medical history): the structured-record
// home for allergies + family history. Always shown (not module-gated, like
// `vorsorge`); the records are foundational health-profile data.
diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx
index 85c7838ba..37bc06cfc 100644
--- a/src/components/settings/settings-shell.tsx
+++ b/src/components/settings/settings-shell.tsx
@@ -23,6 +23,7 @@ import {
Bot,
CalendarCheck,
ClipboardList,
+ CloudSun,
Download,
FileHeart,
FlaskConical,
@@ -192,6 +193,15 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
icon: Thermometer,
moduleGate: "illness",
},
+ // v1.25 (W-ENV) — Environmental context: home location, travel overrides, and
+ // the weather/daylight backfill. Module-gated on the opt-in `environment`
+ // module, so the entry only appears once the user turns it on.
+ {
+ slug: "environment",
+ titleKey: "settings.sections.environment.title",
+ icon: CloudSun,
+ moduleGate: "environment",
+ },
// v1.25 (W-RECORDS) — Anamnese (medical history): the structured-record home
// for allergies + family history. NOT module-gated (like Vorsorge) — these
// are foundational health-profile records, always available. Sits between
diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts
index d1939cf09..06cf4bf43 100644
--- a/src/lib/dashboard/__tests__/snapshot.test.ts
+++ b/src/lib/dashboard/__tests__/snapshot.test.ts
@@ -83,6 +83,7 @@ function moduleMap(
insights: true,
medications: true,
doctorReport: true,
+ environment: true,
mcp: true,
};
return { ...base, ...overrides };
diff --git a/src/lib/jobs/environment-fetch.ts b/src/lib/jobs/environment-fetch.ts
new file mode 100644
index 000000000..2f5948bbb
--- /dev/null
+++ b/src/lib/jobs/environment-fetch.ts
@@ -0,0 +1,185 @@
+/**
+ * v1.25 (W-ENV) — nightly environmental-context fetch.
+ *
+ * For every account with the environmental-context module enabled AND a home
+ * location set, fetch the daily weather/daylight for the recent uncovered days
+ * and upsert into `EnvironmentContext`. A daily discovery tick (empty payload)
+ * fans out one per-user job; the per-user job resolves each day's location
+ * (travel override → home) and fetches a lookback window so the archive feed's
+ * settling lag (a few days) is absorbed and any day missed across worker
+ * reboots is re-attempted. The same handler also serves the on-demand backfill
+ * (an explicit `{ userId, startDate, endDate }` payload from the settings
+ * surface).
+ *
+ * Opt-in + privacy: the discovery filter requires both the module flag and a
+ * home location, so a disabled account never triggers an outbound fetch. Egress
+ * runs through `safeFetch` inside the Open-Meteo client. Recurring → pg-boss
+ * (never a CLI script; the prod image strips `tsx`).
+ */
+import { prisma } from "@/lib/db";
+import type { PgBoss } from "pg-boss";
+import { getGlobalBoss } from "@/lib/jobs/boss-instance";
+import { isModuleEnabled } from "@/lib/modules/gate";
+import {
+ ENVIRONMENT_LOOKBACK_DAYS,
+ ENVIRONMENT_MAX_BACKFILL_DAYS,
+ fetchAndStoreEnvironment,
+ utcDayKey,
+} from "@/lib/environment/service";
+import { recordError } from "@/lib/jobs/worker-status";
+import { workerLog } from "./reminder/shared";
+
+export const ENVIRONMENT_FETCH_QUEUE = "environment-fetch";
+
+/** Daily 02:10 Europe/Berlin — early, before the 02:30+ maintenance window so
+ * the night's weather rows exist before any briefing/insight pre-gen reads. */
+export const ENVIRONMENT_FETCH_CRON = "10 2 * * *";
+
+export const ENVIRONMENT_FETCH_CONCURRENCY = 1;
+
+const MS_PER_DAY = 24 * 60 * 60 * 1000;
+
+/**
+ * Per-user fetch payload. An empty payload (no `userId`) is the daily discovery
+ * tick. `startDate`/`endDate` (YYYY-MM-DD) drive the on-demand backfill; omitted
+ * ⇒ the default lookback window ending today.
+ */
+export interface EnvironmentFetchPayload {
+ userId?: string;
+ startDate?: string;
+ endDate?: string;
+}
+
+/** Resolve the date range for a per-user run (explicit range, else lookback). */
+function resolveRange(payload: EnvironmentFetchPayload): {
+ startDate: string;
+ endDate: string;
+} {
+ const today = new Date();
+ const endDate = payload.endDate ?? utcDayKey(today);
+ const startDate =
+ payload.startDate ??
+ utcDayKey(
+ new Date(today.getTime() - ENVIRONMENT_LOOKBACK_DAYS * MS_PER_DAY),
+ );
+ return { startDate, endDate };
+}
+
+/**
+ * Request-side enqueue for a single account: a default-lookback refresh (after
+ * the user sets/changes their home, so weather appears without waiting for the
+ * nightly tick) or an explicit-range backfill. No-ops cleanly when no global
+ * boss is bound (e.g. a web-only deployment) — the nightly cron still covers it.
+ * The lookback variant is singleton-coalesced per user; an explicit backfill
+ * range is NOT coalesced so it always runs.
+ */
+export async function enqueueEnvironmentFetch(
+ payload: EnvironmentFetchPayload & { userId: string },
+): Promise {
+ const boss = getGlobalBoss();
+ if (!boss) return false;
+ const options =
+ payload.startDate || payload.endDate
+ ? undefined
+ : { singletonKey: `environment-fetch:${payload.userId}` };
+ await boss.send(ENVIRONMENT_FETCH_QUEUE, payload, options);
+ return true;
+}
+
+/**
+ * Discovery fan-out: enqueue one per-user fetch job for every account with the
+ * module on and a home location set. Idempotent (singletonKey-coalesced per
+ * user). Returns counts for the worker log.
+ */
+export async function enqueueEnvironmentFetchDiscovery(
+ boss: PgBoss,
+): Promise<{ enqueued: number; skipped: number }> {
+ // Candidate set: a home location is required for any non-override day, so an
+ // account without one cannot produce a row. The per-candidate module check
+ // then enforces the opt-in flag + operator availability.
+ const candidates = await prisma.user.findMany({
+ where: { homeLat: { not: null }, homeLon: { not: null } },
+ select: { id: true },
+ });
+
+ let enqueued = 0;
+ let skipped = 0;
+ for (const { id } of candidates) {
+ if (!(await isModuleEnabled(id, "environment"))) {
+ skipped += 1;
+ continue;
+ }
+ await boss.send(
+ ENVIRONMENT_FETCH_QUEUE,
+ { userId: id } satisfies EnvironmentFetchPayload,
+ { singletonKey: `environment-fetch:${id}` },
+ );
+ enqueued += 1;
+ }
+ return { enqueued, skipped };
+}
+
+/**
+ * The queue handler. Empty payload ⇒ discovery fan-out; a `userId` payload ⇒
+ * fetch + upsert for that user across the resolved range. Re-checks the module
+ * gate on the per-user path so a backfill enqueued just before the user
+ * disabled the module is a no-op.
+ */
+export async function handleEnvironmentFetch(
+ boss: PgBoss,
+ payload: EnvironmentFetchPayload,
+): Promise {
+ if (!payload.userId) {
+ const { enqueued, skipped } = await enqueueEnvironmentFetchDiscovery(boss);
+ workerLog(
+ "info",
+ `[environment-fetch] discovery enqueued=${enqueued} skipped=${skipped}`,
+ );
+ return;
+ }
+
+ const userId = payload.userId;
+ if (!(await isModuleEnabled(userId, "environment"))) {
+ workerLog(
+ "info",
+ `[environment-fetch] user=${userId} module disabled — skipping`,
+ );
+ return;
+ }
+
+ const { startDate, endDate } = resolveRange(payload);
+ // Clamp the span so a crafted backfill can never fan out an unbounded range.
+ const span = enumerateSpanDays(startDate, endDate);
+ if (span > ENVIRONMENT_MAX_BACKFILL_DAYS) {
+ workerLog(
+ "error",
+ `[environment-fetch] user=${userId} range ${startDate}..${endDate} (${span}d) exceeds cap — skipping`,
+ );
+ return;
+ }
+
+ try {
+ const result = await fetchAndStoreEnvironment({
+ userId,
+ startDate,
+ endDate,
+ });
+ workerLog(
+ "info",
+ `[environment-fetch] user=${userId} ${startDate}..${endDate} stored=${result.stored} skipped=${result.skipped} fetches=${result.fetches}`,
+ );
+ } catch (err) {
+ recordError();
+ workerLog("error", `[environment-fetch] user=${userId} failed`, err);
+ throw err;
+ }
+}
+
+function enumerateSpanDays(start: string, end: string): number {
+ const [sy, sm, sd] = start.split("-").map(Number);
+ const [ey, em, ed] = end.split("-").map(Number);
+ const days = Math.round(
+ (Date.UTC(ey, em - 1, ed) - Date.UTC(sy, sm - 1, sd)) / MS_PER_DAY,
+ );
+ return days + 1;
+}
diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts
index 69ce90674..a6d355f6a 100644
--- a/src/lib/jobs/reminder/register-maintenance.ts
+++ b/src/lib/jobs/reminder/register-maintenance.ts
@@ -75,6 +75,13 @@ import {
handleEncryptionKeyRotate,
type EncryptionKeyRotatePayload,
} from "@/lib/jobs/encryption-key-rotate";
+import {
+ ENVIRONMENT_FETCH_QUEUE,
+ ENVIRONMENT_FETCH_CRON,
+ ENVIRONMENT_FETCH_CONCURRENCY,
+ handleEnvironmentFetch,
+ type EnvironmentFetchPayload,
+} from "@/lib/jobs/environment-fetch";
import { recordError } from "@/lib/jobs/worker-status";
import { workerLog, BOOT_BACKFILL_STAGGER_SECONDS } from "./shared";
import { createAndSchedule, type ScheduleEntry } from "./registrar-shared";
@@ -216,6 +223,12 @@ const MCP_TOKEN_CLEANUP_CRON = "0 4 * * *";
const MED_NOTES_ENCRYPTION_BACKFILL_CRON = "5 4 * * *";
const allQueues = [
+ // v1.25 (W-ENV) — nightly environmental-context fetch. A daily discovery tick
+ // fans out one per-user job per opted-in account with a home location; the
+ // queue also serves the on-demand backfill from the Environment settings
+ // surface. Without this entry pg-boss never provisions it and both the cron
+ // and the backfill button silently no-op.
+ ENVIRONMENT_FETCH_QUEUE,
DATA_BACKUP_QUEUE,
RATE_LIMIT_CLEANUP_QUEUE,
IDEMPOTENCY_CLEANUP_QUEUE,
@@ -277,6 +290,9 @@ const allQueues = [
];
const schedules: ScheduleEntry[] = [
+ // v1.25 (W-ENV) — daily 02:10 Europe/Berlin discovery tick (empty payload)
+ // that fans out one per-user environment fetch per opted-in account.
+ [ENVIRONMENT_FETCH_QUEUE, ENVIRONMENT_FETCH_CRON],
[DATA_BACKUP_QUEUE, DATA_BACKUP_CRON],
[RATE_LIMIT_CLEANUP_QUEUE, RATE_LIMIT_CLEANUP_CRON],
[IDEMPOTENCY_CLEANUP_QUEUE, IDEMPOTENCY_CLEANUP_CRON],
@@ -601,6 +617,20 @@ export async function registerMaintenanceQueues(
},
);
+ // v1.25 (W-ENV) — nightly environment fetch. The daily discovery tick (empty
+ // payload) fans out one per-user job per opted-in account; the queue also
+ // serves the on-demand backfill payloads from the settings surface. Serial
+ // concurrency so the staggered outbound fetches never crowd the request pool.
+ await boss.work(
+ ENVIRONMENT_FETCH_QUEUE,
+ { localConcurrency: ENVIRONMENT_FETCH_CONCURRENCY },
+ async (jobs) => {
+ for (const job of jobs) {
+ await handleEnvironmentFetch(boss, job.data);
+ }
+ },
+ );
+
return allQueues;
}
diff --git a/src/lib/modules/__tests__/gate.test.ts b/src/lib/modules/__tests__/gate.test.ts
index 5b76f000c..01b3de88b 100644
--- a/src/lib/modules/__tests__/gate.test.ts
+++ b/src/lib/modules/__tests__/gate.test.ts
@@ -352,10 +352,15 @@ describe("resolveModuleEnabled — mcp module (opt-in, default-OFF)", () => {
});
describe("registry — opt-in marker", () => {
- it("marks only the mcp module opt-in (every other module is default-on)", () => {
+ it("marks the mcp + environment modules opt-in (every other module is default-on)", () => {
expect(isOptInModule("mcp")).toBe(true);
+ // v1.25 (W-ENV) — the environmental-context module is opt-in too: it
+ // performs an outbound weather fetch tied to a coarse location, so it ships
+ // dark like the remote MCP endpoint.
+ expect(isOptInModule("environment")).toBe(true);
+ const optIn = new Set(["mcp", "environment"]);
for (const key of MODULE_KEYS) {
- if (key === "mcp") continue;
+ if (optIn.has(key)) continue;
expect(isOptInModule(key)).toBe(false);
}
});
diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts
index 0ae94722a..e46a067a8 100644
--- a/src/lib/modules/registry.ts
+++ b/src/lib/modules/registry.ts
@@ -115,6 +115,12 @@ export const MODULE_KEYS = [
// re-enabling finds the rows intact.
"medications",
"doctorReport",
+ // v1.25.0 (W-ENV) — environmental-context module. Like `mcp`, it is OPT-IN
+ // (off by default): it performs an outbound weather fetch tied to where the
+ // user physically is (a coarse home / travel location), so opt-in is the
+ // right privacy default. With it off no job runs, no row is written, and no
+ // egress happens. The `optIn` marker inverts the per-user default.
+ "environment",
// v1.22.0 — the remote Model Context Protocol endpoint (`/mcp`). Unlike
// every other module this is OPT-IN (off by default): it exposes a new
// external-assistant attack surface, so it ships dark and the operator /
@@ -256,6 +262,17 @@ export const MODULE_REGISTRY: Readonly> =
descriptionKey: "modules.doctorReport.description",
category: "export",
},
+ environment: {
+ key: "environment",
+ labelKey: "modules.environment.label",
+ descriptionKey: "modules.environment.description",
+ category: "device",
+ // Off by default: performs an outbound weather fetch tied to a coarse
+ // location, so it ships dark and the user turns it on deliberately. The
+ // home location + travel overrides + backfill live on the dedicated
+ // Environment settings surface (rendered when the module is on).
+ optIn: true,
+ },
mcp: {
key: "mcp",
labelKey: "modules.mcp.label",
diff --git a/src/lib/query-keys/environment.ts b/src/lib/query-keys/environment.ts
new file mode 100644
index 000000000..25a85c336
--- /dev/null
+++ b/src/lib/query-keys/environment.ts
@@ -0,0 +1,16 @@
+/**
+ * Query keys — environmental-context module (v1.25, W-ENV). The overview
+ * (home + travel overrides + stored-day summary) and the geocoding search.
+ * Part of the centralized factory; aggregated in `./index.ts`.
+ */
+export const environmentKeys = {
+ /**
+ * `environment()` is the root prefix every environment write (set home, add /
+ * remove a travel override, trigger a backfill) invalidates through, so the
+ * settings surface repaints in lockstep.
+ */
+ environment: () => ["environment"] as const,
+ /** Geocoding search results for a query string (cached per query). */
+ environmentGeocode: (query: string) =>
+ ["environment", "geocode", query] as const,
+};
diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts
index 8b40d9320..0213c3d57 100644
--- a/src/lib/query-keys/index.ts
+++ b/src/lib/query-keys/index.ts
@@ -21,6 +21,7 @@ import { coachKeys } from "./coach";
import { familyHistoryKeys } from "./family-history";
import { cycleKeys } from "./cycle";
import { dashboardKeys } from "./dashboard";
+import { environmentKeys } from "./environment";
import { illnessKeys } from "./illness";
import { insightsKeys } from "./insights";
import { integrationKeys } from "./integrations";
@@ -52,6 +53,7 @@ export const queryKeys = {
...mentalHealthKeys,
...allergyKeys,
...familyHistoryKeys,
+ ...environmentKeys,
};
/**
diff --git a/src/lib/validations/environment.ts b/src/lib/validations/environment.ts
new file mode 100644
index 000000000..ee2c65ee3
--- /dev/null
+++ b/src/lib/validations/environment.ts
@@ -0,0 +1,70 @@
+/**
+ * v1.25 (W-ENV) — environmental-context request validation.
+ *
+ * The home location, the travel overrides, and the backfill span. Coordinates
+ * are bounded to valid lat/lon; the labels are length-capped plaintext (a city
+ * name, never a quasi-identifier). Dates are strict YYYY-MM-DD. The shared
+ * shapes are reused by the OpenAPI registry so the wire contract stays
+ * single-source.
+ */
+import { z } from "zod/v4";
+
+/** Strict YYYY-MM-DD calendar day. */
+export const dayStringSchema = z
+ .string()
+ .regex(/^\d{4}-\d{2}-\d{2}$/, "Expected a YYYY-MM-DD date");
+
+const latSchema = z.number().gte(-90).lte(90);
+const lonSchema = z.number().gte(-180).lte(180);
+const labelSchema = z.string().trim().min(1).max(200);
+
+/** A coarse home location set in settings (city-level granularity). */
+export const homeLocationSchema = z
+ .object({
+ lat: latSchema,
+ lon: lonSchema,
+ label: labelSchema,
+ /** IANA timezone (anchors the stored day-key). */
+ timezone: z.string().trim().min(1).max(64),
+ })
+ .strict();
+
+export type HomeLocationInput = z.infer;
+
+/** A manual travel override covering an inclusive date range. */
+export const travelLocationSchema = z
+ .object({
+ startDate: dayStringSchema,
+ endDate: dayStringSchema,
+ lat: latSchema,
+ lon: lonSchema,
+ label: labelSchema,
+ })
+ .strict()
+ .refine((v) => v.startDate <= v.endDate, {
+ message: "startDate must be on or before endDate",
+ path: ["endDate"],
+ });
+
+export type TravelLocationInput = z.infer;
+
+/** A backfill request: fetch + store the environment rows over a date range. */
+export const environmentBackfillSchema = z
+ .object({
+ startDate: dayStringSchema,
+ endDate: dayStringSchema,
+ })
+ .strict()
+ .refine((v) => v.startDate <= v.endDate, {
+ message: "startDate must be on or before endDate",
+ path: ["endDate"],
+ });
+
+export type EnvironmentBackfillInput = z.infer<
+ typeof environmentBackfillSchema
+>;
+
+/** Geocoding search query (`?q=`). */
+export const geocodeQuerySchema = z
+ .object({ q: z.string().trim().min(1).max(120) })
+ .strict();
diff --git a/tests/integration/doctor-report-sections.test.ts b/tests/integration/doctor-report-sections.test.ts
index 574237115..4af378c4b 100644
--- a/tests/integration/doctor-report-sections.test.ts
+++ b/tests/integration/doctor-report-sections.test.ts
@@ -317,6 +317,7 @@ describe("doctor-report — module enable/disable gating", () => {
insights: true,
medications: true,
doctorReport: true,
+ environment: true,
mcp: true,
} as const;
From 6aad4a465e57a52ffe688b12945fe9d8cdfecfeb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 28 Jun 2026 17:35:45 +0200
Subject: [PATCH 65/94] feat(environment): add home effective-from and reserve
a DEVICE location source
Add `User.homeSince` (nullable effective-from instant) and a `DEVICE` value to
the location-source enum (migration 0219). `homeSince` records when the home
location became effective so day resolution can attribute a past day to the
home only on/after that instant, rather than to the current home for every past
day. Existing accounts with a home get `homeSince` stamped to the migration
instant; their deep past is filled by explicit location periods instead.
`DEVICE` is reserved for a future client-supplied coarse per-day location: the
enum value and the wire contract accept it now so the resolver can rank it
later without rework. Nothing writes it server-side yet.
---
.../0219_v125_env_home_since/migration.sql | 36 +++++++++++++++++++
prisma/schema.prisma | 26 ++++++++++----
2 files changed, 56 insertions(+), 6 deletions(-)
create mode 100644 prisma/migrations/0219_v125_env_home_since/migration.sql
diff --git a/prisma/migrations/0219_v125_env_home_since/migration.sql b/prisma/migrations/0219_v125_env_home_since/migration.sql
new file mode 100644
index 000000000..16c2453d2
--- /dev/null
+++ b/prisma/migrations/0219_v125_env_home_since/migration.sql
@@ -0,0 +1,36 @@
+-- v1.25 (W-ENV) — conservative past-location handling for the environmental
+-- context module.
+--
+-- Adds `users.home_since` (effective-from instant for the home location) and a
+-- `DEVICE` value to the location-source enum. Day resolution attributes a past
+-- day to the home location ONLY when the day is on/after `home_since`; earlier
+-- days are skipped rather than fabricated from the current home. The deep past
+-- is filled by explicit dated location periods instead.
+--
+-- `DEVICE` is the reserved forward path for a future client-supplied coarse
+-- per-day location (iOS v2). The value exists so the resolver and the wire
+-- contract already accept it; nothing writes it server-side yet.
+--
+-- Backfill of existing accounts: any row that already has a home location gets
+-- `home_since` stamped to the migration instant. This is conservative — the
+-- exact day a pre-upgrade account moved into its home is unknown, so history
+-- before the upgrade is left to explicit location periods rather than guessed.
+--
+-- Additive; no existing weather row touched. Idempotent guards so reruns are
+-- safe on prod.
+--
+-- Reversibility (down):
+-- ALTER TABLE "users" DROP COLUMN IF EXISTS "home_since";
+-- -- (the `DEVICE` enum value cannot be dropped in place; it is inert until a
+-- -- future client writes it, so a roll-back leaves it harmless.)
+
+-- AlterTable: effective-from instant for the home location (nullable).
+ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "home_since" TIMESTAMP(3);
+
+-- Backfill: stamp existing home rows so they resolve from the upgrade forward.
+UPDATE "users"
+ SET "home_since" = CURRENT_TIMESTAMP
+ WHERE "home_lat" IS NOT NULL AND "home_since" IS NULL;
+
+-- AlterEnum: reserve the DEVICE source for the iOS v2 forward path.
+ALTER TYPE "environment_location_source" ADD VALUE IF NOT EXISTS 'DEVICE';
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 073e17c88..3f82e6ac2 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -63,6 +63,12 @@ model User {
homeLon Float? @map("home_lon")
homeLabel String? @map("home_label")
homeTimezone String? @map("home_timezone")
+ // v1.25 — effective-from instant for the home location. Stamped the moment a
+ // home is set/updated. Day resolution attributes a past day to home ONLY when
+ // the day is on/after this instant; earlier days are skipped (never fabricated
+ // from the current home). To fill the deep past the user adds an explicit
+ // dated location period instead. Backfill defaults its start to this instant.
+ homeSince DateTime? @map("home_since")
// Codex OAuth (ChatGPT subscription)
codexAccessTokenEncrypted String? @map("codex_access_token_encrypted")
@@ -811,11 +817,16 @@ model User {
// ─── v1.25 (W-ENV) Environmental-context module ──────────────────────────────
/// How the location for an environment observation was resolved. HOME = the
-/// user's configured home location; TRAVEL = a manual date-range override.
-/// (iOS coarse device location is a v2 follow-up and not represented here.)
+/// user's configured home location; TRAVEL = an explicit dated location period
+/// (a past trip OR a corrected stretch of history) that overrides home for the
+/// days it covers; DEVICE = a future client-supplied coarse per-day location.
+/// DEVICE is the priority forward path for the iOS v2 client: the value exists
+/// so the resolver and the wire contract already accept it, but nothing writes
+/// it server-side yet.
enum EnvironmentLocationSource {
HOME
TRAVEL
+ DEVICE
}
/// A per-day environmental observation (daily weather / daylight) for the
@@ -876,10 +887,13 @@ model EnvironmentContext {
@@map("environment_contexts")
}
-/// A manual travel override: for any day in `[startDate, endDate]` the weather
-/// fetch resolves against this location instead of the home location. Declared,
-/// not inferred — a stated trip dominates the home fallback so a winter week in
-/// a sunny locale is not mis-attributed to the home city. Coarse location only.
+/// An explicit dated LOCATION PERIOD: for any day in `[startDate, endDate]` the
+/// weather fetch resolves against this location instead of the home location.
+/// Declared, not inferred — it dominates the home fallback so a stretch spent
+/// elsewhere is not mis-attributed to the home city. Two uses share this one
+/// model: a discrete trip away from home, AND the mechanism to correct history
+/// (e.g. a whole-history home city for the years before the current home became
+/// effective). Coarse location only. (Stored under `source = TRAVEL`.)
model EnvironmentTravelLocation {
id String @id @default(cuid())
userId String @map("user_id")
From c2541354061dad2a7d932f2a779124b73a8650a5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 28 Jun 2026 17:36:14 +0200
Subject: [PATCH 66/94] feat(environment): resolve past days conservatively,
never guessing from home
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Rework the per-day location resolution so it can no longer fabricate weather for
days before the home was set. Precedence, first hit wins:
1. an explicit dated location period (a trip, or a corrected stretch of
history) covering the day;
2. DEVICE — a reserved slot for a future client-supplied coarse per-day
location (ranks between the period and home; not produced server-side yet);
3. the home location, but only for days on/after its effective-from date;
4. otherwise the day is skipped.
The home route stamps `homeSince` whenever the home is set/updated and returns
it. The overview exposes it. Backfill bounds are now optional and default to the
conservative `[homeSince .. today]` span instead of a fixed reach onto the
current home; days before `homeSince` still resolve to a skip in the worker, so
even a wider explicit start cannot back-fill weather for the pre-home past. To
reach further back a user adds an explicit location period for that range.
Add unit coverage for the precedence (including the effective-from boundary and
the skip-not-guess rule) and the default backfill range.
---
src/app/api/environment/backfill/route.ts | 44 +++++--
src/app/api/environment/home/route.ts | 6 +
src/app/api/environment/route.ts | 4 +
src/lib/environment/__tests__/service.test.ts | 107 ++++++++++++++++++
src/lib/environment/service.ts | 53 +++++++--
src/lib/jobs/environment-fetch.ts | 11 +-
src/lib/validations/environment.ts | 13 ++-
7 files changed, 209 insertions(+), 29 deletions(-)
create mode 100644 src/lib/environment/__tests__/service.test.ts
diff --git a/src/app/api/environment/backfill/route.ts b/src/app/api/environment/backfill/route.ts
index 61e6b117b..1a02e93aa 100644
--- a/src/app/api/environment/backfill/route.ts
+++ b/src/app/api/environment/backfill/route.ts
@@ -21,7 +21,11 @@ import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { environmentBackfillSchema } from "@/lib/validations/environment";
-import { ENVIRONMENT_MAX_BACKFILL_DAYS } from "@/lib/environment/service";
+import {
+ ENVIRONMENT_MAX_BACKFILL_DAYS,
+ defaultBackfillRange,
+ utcDayKey,
+} from "@/lib/environment/service";
import { enqueueEnvironmentFetch } from "@/lib/jobs/environment-fetch";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
@@ -49,7 +53,33 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
- const { startDate, endDate } = parsed.data;
+ const profile = await prisma.user.findUnique({
+ where: { id: user.id },
+ select: { homeLat: true, homeSince: true },
+ });
+ if (profile?.homeLat == null) {
+ return apiError("Set a home location before backfilling.", 409, {
+ errorCode: "environment.no_home",
+ });
+ }
+
+ // Resolve the effective span. An omitted bound defaults to the conservative
+ // [homeSince .. today] range — never a fixed reach into the past onto the
+ // current home. Days before homeSince still resolve to SKIP in the worker, so
+ // even an explicit wider start cannot fabricate weather for the pre-home past.
+ const today = utcDayKey(new Date());
+ const fallback = defaultBackfillRange(profile.homeSince) ?? {
+ startDate: today,
+ endDate: today,
+ };
+ const startDate = parsed.data.startDate ?? fallback.startDate;
+ const endDate = parsed.data.endDate ?? fallback.endDate;
+ if (startDate > endDate) {
+ return apiError("startDate must be on or before endDate", 422, {
+ errorCode: "environment.invalid",
+ });
+ }
+
const [sy, sm, sd] = startDate.split("-").map(Number);
const [ey, em, ed] = endDate.split("-").map(Number);
const spanDays =
@@ -64,16 +94,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
}
- const profile = await prisma.user.findUnique({
- where: { id: user.id },
- select: { homeLat: true },
- });
- if (profile?.homeLat == null) {
- return apiError("Set a home location before backfilling.", 409, {
- errorCode: "environment.no_home",
- });
- }
-
const enqueued = await enqueueEnvironmentFetch({
userId: user.id,
startDate,
diff --git a/src/app/api/environment/home/route.ts b/src/app/api/environment/home/route.ts
index 46bd6443e..ded1c12e5 100644
--- a/src/app/api/environment/home/route.ts
+++ b/src/app/api/environment/home/route.ts
@@ -41,11 +41,16 @@ export const PUT = apiHandler(async (request: NextRequest) => {
}
const { lat, lon, label, timezone } = parsed.data;
+ // Stamp the effective-from instant: from now on this home resolves days; days
+ // before it stay un-attributed (filled via explicit location periods). Every
+ // set/update re-stamps — the home is "effective from the moment it is set".
+ const homeSince = new Date();
const home = {
homeLat: roundCoarse(lat),
homeLon: roundCoarse(lon),
homeLabel: label,
homeTimezone: timezone,
+ homeSince,
};
await prisma.user.update({ where: { id: user.id }, data: home });
@@ -62,6 +67,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
lon: home.homeLon,
label: home.homeLabel,
timezone: home.homeTimezone,
+ since: homeSince.toISOString(),
},
});
});
diff --git a/src/app/api/environment/route.ts b/src/app/api/environment/route.ts
index 3d393384c..2d6e1ceb2 100644
--- a/src/app/api/environment/route.ts
+++ b/src/app/api/environment/route.ts
@@ -29,6 +29,7 @@ export const GET = apiHandler(async () => {
homeLon: true,
homeLabel: true,
homeTimezone: true,
+ homeSince: true,
timezone: true,
},
}),
@@ -59,6 +60,9 @@ export const GET = apiHandler(async () => {
lon: profile.homeLon,
label: profile.homeLabel,
timezone: profile.homeTimezone ?? profile.timezone,
+ // Effective-from instant: the settings surface uses it to label the
+ // home and to prefill the backfill start (conservative default range).
+ since: profile.homeSince?.toISOString() ?? null,
}
: null;
diff --git a/src/lib/environment/__tests__/service.test.ts b/src/lib/environment/__tests__/service.test.ts
new file mode 100644
index 000000000..f9fb1c86d
--- /dev/null
+++ b/src/lib/environment/__tests__/service.test.ts
@@ -0,0 +1,107 @@
+/**
+ * v1.25 (W-ENV) — conservative location resolution.
+ *
+ * Pins the precedence (explicit period → [DEVICE, reserved] → home-on/after-
+ * effective-from → SKIP) and the conservative default backfill range. The whole
+ * point is that a past day is NEVER attributed to the current home unless the
+ * home was already effective on that day; otherwise it is skipped and left to an
+ * explicit location period. `@/lib/db` + the Open-Meteo client are stubbed so
+ * the pure functions import without touching Postgres or the network.
+ */
+import { describe, it, expect, vi } from "vitest";
+
+vi.mock("@/lib/db", () => ({ prisma: {} }));
+vi.mock("@/lib/environment/open-meteo", () => ({
+ fetchDailyEnvironment: vi.fn(),
+}));
+
+import {
+ resolveLocationForDay,
+ defaultBackfillRange,
+ utcDayKey,
+} from "../service";
+
+const HOME = {
+ lat: 52.5,
+ lon: 13.4,
+ label: "Home City",
+ timezone: "Europe/Berlin",
+ since: "2026-03-01",
+};
+
+const PERIOD = {
+ startDate: "2026-01-10",
+ endDate: "2026-01-20",
+ lat: 28.1,
+ lon: -15.4,
+ label: "Past City",
+};
+
+describe("resolveLocationForDay (conservative)", () => {
+ it("uses an explicit location period covering the day", () => {
+ const r = resolveLocationForDay("2026-01-15", HOME, [PERIOD]);
+ expect(r).toEqual({
+ lat: PERIOD.lat,
+ lon: PERIOD.lon,
+ label: PERIOD.label,
+ source: "TRAVEL",
+ });
+ });
+
+ it("honours an explicit period even before the home was effective", () => {
+ // The period (Jan) predates homeSince (Mar) — it must still win, since it is
+ // the mechanism for correcting history.
+ const r = resolveLocationForDay("2026-01-12", HOME, [PERIOD]);
+ expect(r?.source).toBe("TRAVEL");
+ });
+
+ it("uses home for a day on/after homeSince when no period covers it", () => {
+ const r = resolveLocationForDay("2026-03-02", HOME, [PERIOD]);
+ expect(r).toEqual({
+ lat: HOME.lat,
+ lon: HOME.lon,
+ label: HOME.label,
+ source: "HOME",
+ });
+ });
+
+ it("uses home on the exact homeSince day (inclusive boundary)", () => {
+ expect(resolveLocationForDay("2026-03-01", HOME, [])?.source).toBe("HOME");
+ });
+
+ it("SKIPS a day before homeSince rather than fabricating from current home", () => {
+ // Feb is before homeSince (Mar) and uncovered by any period → no row.
+ expect(resolveLocationForDay("2026-02-15", HOME, [])).toBeNull();
+ });
+
+ it("skips when no home and no covering period", () => {
+ expect(resolveLocationForDay("2026-05-01", null, [])).toBeNull();
+ });
+
+ it("skips when home has no effective-from date", () => {
+ const homeNoSince = { ...HOME, since: null };
+ expect(resolveLocationForDay("2026-05-01", homeNoSince, [])).toBeNull();
+ });
+});
+
+describe("defaultBackfillRange (conservative)", () => {
+ it("spans [homeSince .. today]", () => {
+ const since = new Date("2026-03-01T10:00:00Z");
+ const today = new Date("2026-06-28T00:00:00Z");
+ expect(defaultBackfillRange(since, today)).toEqual({
+ startDate: "2026-03-01",
+ endDate: "2026-06-28",
+ });
+ });
+
+ it("returns null without a home effective-from", () => {
+ expect(defaultBackfillRange(null)).toBeNull();
+ });
+
+ it("derives the start day-key from the homeSince instant", () => {
+ const since = new Date("2026-03-01T23:30:00Z");
+ expect(
+ defaultBackfillRange(since, new Date("2026-03-05T00:00:00Z")),
+ ).toEqual({ startDate: utcDayKey(since), endDate: "2026-03-05" });
+ });
+});
diff --git a/src/lib/environment/service.ts b/src/lib/environment/service.ts
index 59962bf73..2208e547d 100644
--- a/src/lib/environment/service.ts
+++ b/src/lib/environment/service.ts
@@ -6,11 +6,17 @@
* call {@link fetchAndStoreEnvironment} so the precedence, the day-keying, and
* the upsert live in exactly one place.
*
- * Location precedence per day (first hit wins):
- * 1. a manual TRAVEL override whose [startDate, endDate] covers the day;
- * 2. the user's HOME location.
- * No home + no covering override ⇒ the day is skipped (the module degrades
- * silently, matching the rollup "coverage miss → absent" posture).
+ * Location precedence per day (first hit wins) — CONSERVATIVE:
+ * 1. an explicit dated LOCATION PERIOD (override) whose [startDate, endDate]
+ * covers the day (a trip, OR a corrected stretch of history);
+ * 2. DEVICE — reserved slot for a future client-supplied coarse per-day
+ * location (iOS v2); not produced server-side yet (see resolver comment);
+ * 3. the user's HOME location, but ONLY for days on/after `homeSince` (the
+ * instant the home became effective).
+ * A day before `homeSince`, or with no home and no covering period, is SKIPPED.
+ * The resolver never attributes a past day to the *current* home — that would
+ * fabricate weather for anyone who has moved/travelled. The deep past is filled
+ * by adding explicit dated location periods.
*
* Coarse location only — the resolver never sees finer than the rounded city
* coordinates stored on the user / override.
@@ -42,6 +48,8 @@ interface HomeLocation {
lon: number;
label: string;
timezone: string;
+ /** YYYY-MM-DD the home became effective; null ⇒ home resolves no day. */
+ since: string | null;
}
interface TravelOverride {
@@ -72,25 +80,51 @@ export function enumerateDays(start: string, end: string): string[] {
}
/**
- * Resolve the location for one day: a covering travel override wins, else home,
- * else null. Pure — exported for unit tests.
+ * Resolve the location for one day (CONSERVATIVE, first hit wins). Pure —
+ * exported for unit tests.
*/
export function resolveLocationForDay(
day: string,
home: HomeLocation | null,
travels: readonly TravelOverride[],
): ResolvedLocation | null {
+ // 1. An explicit dated location period covering the day wins. This is also
+ // the mechanism a user/operator uses to correct history: an explicit
+ // period for a past range is honoured even before the home was effective.
for (const t of travels) {
if (day >= t.startDate && day <= t.endDate) {
return { lat: t.lat, lon: t.lon, label: t.label, source: "TRAVEL" };
}
}
- if (home) {
+ // 2. DEVICE — reserved priority forward path (iOS v2). A future client supplies
+ // a coarse per-day device location that would rank HERE, between the explicit
+ // period and the home fallback. Nothing produces it server-side yet, but the
+ // resolver signature and the EnvironmentLocationSource enum already accept a
+ // "DEVICE" source, so wiring it in later needs no rework of this precedence.
+ //
+ // 3. Home — ONLY on/after the day it became effective (`homeSince`). Never
+ // fabricate past weather from the *current* home for days before it was set;
+ // those days are skipped and left to an explicit period above.
+ if (home && home.since != null && day >= home.since) {
return { lat: home.lat, lon: home.lon, label: home.label, source: "HOME" };
}
return null;
}
+/**
+ * The conservative default backfill span for an account: from the day its home
+ * became effective (`homeSince`) through `today`. Days before `homeSince` are
+ * deliberately excluded — they belong to explicit location periods, not to the
+ * current home. Returns null when no home has been set.
+ */
+export function defaultBackfillRange(
+ homeSince: Date | null,
+ today: Date = new Date(),
+): { startDate: string; endDate: string } | null {
+ if (!homeSince) return null;
+ return { startDate: utcDayKey(homeSince), endDate: utcDayKey(today) };
+}
+
/** A location-keyed group of days (one upstream fetch per group). */
function groupKey(loc: ResolvedLocation): string {
return `${loc.lat},${loc.lon},${loc.source},${loc.label}`;
@@ -125,6 +159,7 @@ export async function fetchAndStoreEnvironment(args: {
homeLon: true,
homeLabel: true,
homeTimezone: true,
+ homeSince: true,
timezone: true,
},
});
@@ -137,6 +172,8 @@ export async function fetchAndStoreEnvironment(args: {
lon: user.homeLon,
label: user.homeLabel ?? "Home",
timezone: user.homeTimezone ?? user.timezone,
+ // Effective-from day-key; null ⇒ home resolves no day (conservative).
+ since: user.homeSince ? utcDayKey(user.homeSince) : null,
}
: null;
diff --git a/src/lib/jobs/environment-fetch.ts b/src/lib/jobs/environment-fetch.ts
index 2f5948bbb..97e865ecb 100644
--- a/src/lib/jobs/environment-fetch.ts
+++ b/src/lib/jobs/environment-fetch.ts
@@ -5,11 +5,12 @@
* location set, fetch the daily weather/daylight for the recent uncovered days
* and upsert into `EnvironmentContext`. A daily discovery tick (empty payload)
* fans out one per-user job; the per-user job resolves each day's location
- * (travel override → home) and fetches a lookback window so the archive feed's
- * settling lag (a few days) is absorbed and any day missed across worker
- * reboots is re-attempted. The same handler also serves the on-demand backfill
- * (an explicit `{ userId, startDate, endDate }` payload from the settings
- * surface).
+ * conservatively (explicit location period → home only on/after its effective
+ * date → else SKIP — never the current home for a pre-home past day) and fetches
+ * a lookback window so the archive feed's settling lag (a few days) is absorbed
+ * and any day missed across worker reboots is re-attempted. The same handler
+ * also serves the on-demand backfill (an explicit `{ userId, startDate, endDate }`
+ * payload from the settings surface).
*
* Opt-in + privacy: the discovery filter requires both the module flag and a
* home location, so a disabled account never triggers an outbound fetch. Egress
diff --git a/src/lib/validations/environment.ts b/src/lib/validations/environment.ts
index ee2c65ee3..20a8db9ff 100644
--- a/src/lib/validations/environment.ts
+++ b/src/lib/validations/environment.ts
@@ -48,14 +48,19 @@ export const travelLocationSchema = z
export type TravelLocationInput = z.infer;
-/** A backfill request: fetch + store the environment rows over a date range. */
+/**
+ * A backfill request: fetch + store the environment rows over a date range.
+ * Both bounds are optional — an omitted span defaults to the conservative
+ * `[homeSince .. today]` range on the server (never a fixed reach into the past
+ * onto the current home).
+ */
export const environmentBackfillSchema = z
.object({
- startDate: dayStringSchema,
- endDate: dayStringSchema,
+ startDate: dayStringSchema.optional(),
+ endDate: dayStringSchema.optional(),
})
.strict()
- .refine((v) => v.startDate <= v.endDate, {
+ .refine((v) => !v.startDate || !v.endDate || v.startDate <= v.endDate, {
message: "startDate must be on or before endDate",
path: ["endDate"],
});
From bacd591dee928af928f5a501d5aa1c0850377340 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 28 Jun 2026 17:36:21 +0200
Subject: [PATCH 67/94] feat(environment): frame overrides as location periods
and surface effective-from
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Settings → Environment now reads the override list as "location periods": a place
plus a date range that covers a trip OR corrects a stretch of earlier history,
overriding the home for the days it spans. Show the home's effective-from date
and prefill the backfill start with it, so the default span matches what the
server will actually store. Strings updated across all six locales.
---
messages/de.json | 19 ++++-----
messages/en.json | 19 ++++-----
messages/es.json | 19 ++++-----
messages/fr.json | 19 ++++-----
messages/it.json | 19 ++++-----
messages/pl.json | 19 ++++-----
.../settings/environment-section.tsx | 39 +++++++++++++------
7 files changed, 88 insertions(+), 65 deletions(-)
diff --git a/messages/de.json b/messages/de.json
index d971d07b8..c20c079b3 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4292,8 +4292,8 @@
"select": "Auswählen",
"saveError": "Änderung konnte nicht gespeichert werden.",
"homeSaved": "Heimatort gespeichert.",
- "travelAdded": "Reise-Ausnahme hinzugefügt.",
- "travelRemoved": "Reise-Ausnahme entfernt.",
+ "travelAdded": "Ortszeitraum hinzugefügt.",
+ "travelRemoved": "Ortszeitraum entfernt.",
"backfillQueued": "Nacherfassung eingeplant.",
"backfillError": "Nacherfassung konnte nicht gestartet werden.",
"startDate": "Startdatum",
@@ -4301,20 +4301,21 @@
"storedDays": "{count} Tage Wetter gespeichert.",
"home": {
"title": "Heimatort",
- "description": "Ein grober Ort auf Stadtebene – niemals ein genauer Standortverlauf. Wetter und Tageslicht werden für diesen Ort an allen Tagen ohne Reise-Ausnahme abgerufen.",
+ "description": "Ein grober Standort auf Stadtebene – niemals eine genaue Spur. Wetter und Tageslicht werden für diesen Ort an Tagen ohne Ortszeitraum abgerufen, ab dem Tag der Festlegung.",
"current": "Aktuell: {label}",
- "none": "Noch kein Heimatort festgelegt."
+ "none": "Noch kein Heimatort festgelegt.",
+ "since": "Wetter wird ab {date} erfasst. Frühere Tage bleiben leer, bis du einen Ortszeitraum hinzufügst."
},
"travel": {
- "title": "Reise-Ausnahmen",
- "description": "Für einen Zeitraum, den du woanders verbracht hast, den Ort festlegen, damit das Wetter zu deinem tatsächlichen Aufenthalt passt.",
- "none": "Keine Reise-Ausnahmen.",
+ "title": "Ortszeiträume",
+ "description": "Füge einen Ort und einen Zeitraum hinzu – eine Reise oder einen früheren Abschnitt deiner Verlaufsdaten. Der Zeitraum überschreibt für seine Tage deinen Wohnort, damit das Wetter zu deinem tatsächlichen Aufenthalt passt. So korrigierst du die Vergangenheit.",
+ "none": "Noch keine Ortszeiträume.",
"add": "{label} hinzufügen",
- "remove": "Reise-Ausnahme entfernen"
+ "remove": "Ortszeitraum entfernen"
},
"backfill": {
"title": "Vergangene Tage nacherfassen",
- "description": "Wetter für einen vergangenen Zeitraum abrufen und speichern, damit ältere Tage in die Korrelationen einfließen.",
+ "description": "Rufe Wetter für einen vergangenen Zeitraum ab und speichere es, damit ältere Tage in die Korrelationen einfließen. Der Start steht standardmäßig auf dem Gültigkeitsdatum deines Wohnorts; um weiter zurückzureichen, füge zuerst einen Ortszeitraum für diesen Bereich hinzu.",
"run": "Nacherfassen"
}
}
diff --git a/messages/en.json b/messages/en.json
index 5c88e09b7..f894cbae5 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4292,8 +4292,8 @@
"select": "Select",
"saveError": "Could not save your change.",
"homeSaved": "Home location saved.",
- "travelAdded": "Travel override added.",
- "travelRemoved": "Travel override removed.",
+ "travelAdded": "Location period added.",
+ "travelRemoved": "Location period removed.",
"backfillQueued": "Backfill queued.",
"backfillError": "Could not start the backfill.",
"startDate": "Start date",
@@ -4301,20 +4301,21 @@
"storedDays": "{count} days of weather stored.",
"home": {
"title": "Home location",
- "description": "A coarse, city-level location — never a precise track. Weather and daylight are fetched for this place on days no travel override applies.",
+ "description": "A coarse, city-level location — never a precise track. Weather and daylight are fetched for this place on days no location period applies, from the date you set it onward.",
"current": "Current: {label}",
- "none": "No home location set yet."
+ "none": "No home location set yet.",
+ "since": "Weather tracked from {date} onward. Earlier days stay empty until you add a location period."
},
"travel": {
- "title": "Travel overrides",
- "description": "For a date range you spent elsewhere, set the location so the weather matches where you actually were.",
- "none": "No travel overrides.",
+ "title": "Location periods",
+ "description": "Add a place and a date range — a trip, or a stretch of earlier history. The period overrides your home for the days it covers, so weather matches where you actually were. This is how you correct the past.",
+ "none": "No location periods yet.",
"add": "Add {label}",
- "remove": "Remove travel override"
+ "remove": "Remove location period"
},
"backfill": {
"title": "Backfill past days",
- "description": "Fetch and store weather for a past date range so older days can join the correlations.",
+ "description": "Fetch and store weather for a past date range so older days join the correlations. The start defaults to your home's effective date; to reach further back, add a location period for that range first.",
"run": "Backfill"
}
}
diff --git a/messages/es.json b/messages/es.json
index 82610e32b..3a1e009ab 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4292,8 +4292,8 @@
"select": "Seleccionar",
"saveError": "No se pudo guardar el cambio.",
"homeSaved": "Ubicación principal guardada.",
- "travelAdded": "Excepción de viaje añadida.",
- "travelRemoved": "Excepción de viaje eliminada.",
+ "travelAdded": "Periodo de ubicación añadido.",
+ "travelRemoved": "Periodo de ubicación eliminado.",
"backfillQueued": "Relleno programado.",
"backfillError": "No se pudo iniciar el relleno.",
"startDate": "Fecha de inicio",
@@ -4301,20 +4301,21 @@
"storedDays": "{count} días de tiempo almacenados.",
"home": {
"title": "Ubicación principal",
- "description": "Una ubicación aproximada a nivel de ciudad, nunca un rastro preciso. El tiempo y las horas de luz se obtienen para este lugar los días sin excepción de viaje.",
+ "description": "Una ubicación aproximada a nivel de ciudad, nunca un rastro preciso. El tiempo y la luz diurna se obtienen para este lugar en los días sin un periodo de ubicación, desde la fecha en que lo configuras.",
"current": "Actual: {label}",
- "none": "Aún no hay ubicación principal."
+ "none": "Aún no hay ubicación principal.",
+ "since": "Tiempo registrado desde {date}. Los días anteriores quedan vacíos hasta que añadas un periodo de ubicación."
},
"travel": {
- "title": "Excepciones de viaje",
- "description": "Para un rango de fechas que pasaste en otro sitio, define la ubicación para que el tiempo coincida con dónde estuviste realmente.",
- "none": "Sin excepciones de viaje.",
+ "title": "Periodos de ubicación",
+ "description": "Añade un lugar y un rango de fechas: un viaje o un tramo de tu historial anterior. El periodo anula tu hogar en los días que cubre, para que el tiempo coincida con donde estuviste realmente. Así corriges el pasado.",
+ "none": "Aún no hay periodos de ubicación.",
"add": "Añadir {label}",
- "remove": "Eliminar excepción de viaje"
+ "remove": "Eliminar periodo de ubicación"
},
"backfill": {
"title": "Rellenar días pasados",
- "description": "Obtén y guarda el tiempo de un rango de fechas pasado para que los días anteriores entren en las correlaciones.",
+ "description": "Obtén y guarda el tiempo de un rango de fechas pasado para que los días antiguos entren en las correlaciones. El inicio toma por defecto la fecha de vigencia de tu hogar; para llegar más atrás, añade primero un periodo de ubicación para ese rango.",
"run": "Rellenar"
}
}
diff --git a/messages/fr.json b/messages/fr.json
index a7a2052d7..c190687f2 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4292,8 +4292,8 @@
"select": "Sélectionner",
"saveError": "Impossible d'enregistrer la modification.",
"homeSaved": "Lieu de référence enregistré.",
- "travelAdded": "Exception de voyage ajoutée.",
- "travelRemoved": "Exception de voyage supprimée.",
+ "travelAdded": "Période de localisation ajoutée.",
+ "travelRemoved": "Période de localisation supprimée.",
"backfillQueued": "Récupération planifiée.",
"backfillError": "Impossible de lancer la récupération.",
"startDate": "Date de début",
@@ -4301,20 +4301,21 @@
"storedDays": "{count} jours de météo enregistrés.",
"home": {
"title": "Lieu de référence",
- "description": "Un lieu approximatif à l'échelle de la ville, jamais un suivi précis. La météo et la durée du jour sont récupérées pour ce lieu les jours sans exception de voyage.",
+ "description": "Une localisation approximative à l'échelle de la ville, jamais un tracé précis. La météo et la lumière du jour sont récupérées pour ce lieu les jours sans période de localisation, à partir de la date où vous le définissez.",
"current": "Actuel : {label}",
- "none": "Aucun lieu de référence défini."
+ "none": "Aucun lieu de référence défini.",
+ "since": "Météo enregistrée à partir du {date}. Les jours antérieurs restent vides jusqu'à ce que vous ajoutiez une période de localisation."
},
"travel": {
- "title": "Exceptions de voyage",
- "description": "Pour une période passée ailleurs, définissez le lieu afin que la météo corresponde à l'endroit où vous étiez réellement.",
- "none": "Aucune exception de voyage.",
+ "title": "Périodes de localisation",
+ "description": "Ajoutez un lieu et une plage de dates : un voyage ou une portion de votre historique passé. La période remplace votre domicile pour les jours qu'elle couvre, afin que la météo corresponde à l'endroit où vous étiez réellement. C'est ainsi que vous corrigez le passé.",
+ "none": "Aucune période de localisation pour l'instant.",
"add": "Ajouter {label}",
- "remove": "Supprimer l'exception de voyage"
+ "remove": "Supprimer la période de localisation"
},
"backfill": {
"title": "Récupérer les jours passés",
- "description": "Récupérez et enregistrez la météo d'une période passée pour que les jours plus anciens entrent dans les corrélations.",
+ "description": "Récupérez et enregistrez la météo d'une plage de dates passée pour que les jours anciens rejoignent les corrélations. Le début reprend par défaut la date d'effet de votre domicile ; pour remonter plus loin, ajoutez d'abord une période de localisation pour cette plage.",
"run": "Récupérer"
}
}
diff --git a/messages/it.json b/messages/it.json
index 94885be49..dbed40ed6 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4292,8 +4292,8 @@
"select": "Seleziona",
"saveError": "Impossibile salvare la modifica.",
"homeSaved": "Posizione principale salvata.",
- "travelAdded": "Eccezione di viaggio aggiunta.",
- "travelRemoved": "Eccezione di viaggio rimossa.",
+ "travelAdded": "Periodo di posizione aggiunto.",
+ "travelRemoved": "Periodo di posizione rimosso.",
"backfillQueued": "Recupero pianificato.",
"backfillError": "Impossibile avviare il recupero.",
"startDate": "Data di inizio",
@@ -4301,20 +4301,21 @@
"storedDays": "{count} giorni di meteo salvati.",
"home": {
"title": "Posizione principale",
- "description": "Una posizione approssimativa a livello di città, mai un tracciamento preciso. Il meteo e le ore di luce vengono recuperati per questo luogo nei giorni senza eccezioni di viaggio.",
+ "description": "Una posizione approssimativa a livello di città, mai un tracciato preciso. Meteo e luce diurna vengono recuperati per questo luogo nei giorni senza un periodo di posizione, a partire dalla data in cui lo imposti.",
"current": "Attuale: {label}",
- "none": "Nessuna posizione principale impostata."
+ "none": "Nessuna posizione principale impostata.",
+ "since": "Meteo registrato dal {date}. I giorni precedenti restano vuoti finché non aggiungi un periodo di posizione."
},
"travel": {
- "title": "Eccezioni di viaggio",
- "description": "Per un intervallo di date trascorso altrove, imposta la posizione in modo che il meteo corrisponda a dove eri davvero.",
- "none": "Nessuna eccezione di viaggio.",
+ "title": "Periodi di posizione",
+ "description": "Aggiungi un luogo e un intervallo di date: un viaggio o un tratto della tua cronologia passata. Il periodo prevale sulla tua residenza nei giorni che copre, così il meteo corrisponde a dove ti trovavi davvero. È così che correggi il passato.",
+ "none": "Nessun periodo di posizione.",
"add": "Aggiungi {label}",
- "remove": "Rimuovi eccezione di viaggio"
+ "remove": "Rimuovi periodo di posizione"
},
"backfill": {
"title": "Recupera i giorni passati",
- "description": "Recupera e salva il meteo di un intervallo di date passato così i giorni più vecchi entrano nelle correlazioni.",
+ "description": "Recupera e salva il meteo di un intervallo di date passato così i giorni più vecchi entrano nelle correlazioni. L'inizio è impostato in modo predefinito sulla data di validità della tua residenza; per andare più indietro, aggiungi prima un periodo di posizione per quell'intervallo.",
"run": "Recupera"
}
}
diff --git a/messages/pl.json b/messages/pl.json
index 70a7fea64..b8e0e201e 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4292,8 +4292,8 @@
"select": "Wybierz",
"saveError": "Nie udało się zapisać zmiany.",
"homeSaved": "Zapisano lokalizację domową.",
- "travelAdded": "Dodano wyjątek podróży.",
- "travelRemoved": "Usunięto wyjątek podróży.",
+ "travelAdded": "Dodano okres lokalizacji.",
+ "travelRemoved": "Usunięto okres lokalizacji.",
"backfillQueued": "Zaplanowano uzupełnianie.",
"backfillError": "Nie udało się rozpocząć uzupełniania.",
"startDate": "Data początkowa",
@@ -4301,20 +4301,21 @@
"storedDays": "Zapisano dni pogody: {count}.",
"home": {
"title": "Lokalizacja domowa",
- "description": "Przybliżona lokalizacja na poziomie miasta — nigdy dokładny ślad. Pogoda i długość dnia są pobierane dla tego miejsca w dni bez wyjątku podróży.",
+ "description": "Przybliżona lokalizacja na poziomie miasta – nigdy dokładny ślad. Pogoda i światło dzienne są pobierane dla tego miejsca w dni bez okresu lokalizacji, od dnia jego ustawienia.",
"current": "Obecnie: {label}",
- "none": "Nie ustawiono jeszcze lokalizacji domowej."
+ "none": "Nie ustawiono jeszcze lokalizacji domowej.",
+ "since": "Pogoda zapisywana od {date}. Wcześniejsze dni pozostają puste, dopóki nie dodasz okresu lokalizacji."
},
"travel": {
- "title": "Wyjątki podróży",
- "description": "Dla zakresu dat spędzonego gdzie indziej ustaw lokalizację, aby pogoda odpowiadała temu, gdzie naprawdę byłeś.",
- "none": "Brak wyjątków podróży.",
+ "title": "Okresy lokalizacji",
+ "description": "Dodaj miejsce i zakres dat: podróż lub fragment wcześniejszej historii. Okres zastępuje Twój dom w dniach, które obejmuje, aby pogoda odpowiadała miejscu, w którym naprawdę byłeś. W ten sposób korygujesz przeszłość.",
+ "none": "Brak okresów lokalizacji.",
"add": "Dodaj {label}",
- "remove": "Usuń wyjątek podróży"
+ "remove": "Usuń okres lokalizacji"
},
"backfill": {
"title": "Uzupełnij minione dni",
- "description": "Pobierz i zapisz pogodę dla minionego zakresu dat, aby starsze dni weszły do korelacji.",
+ "description": "Pobierz i zapisz pogodę dla minionego zakresu dat, aby starsze dni weszły do korelacji. Początek domyślnie przyjmuje datę obowiązywania Twojego domu; aby sięgnąć dalej wstecz, najpierw dodaj okres lokalizacji dla tego zakresu.",
"run": "Uzupełnij"
}
}
diff --git a/src/components/settings/environment-section.tsx b/src/components/settings/environment-section.tsx
index 68cc87b37..5f66bc2fb 100644
--- a/src/components/settings/environment-section.tsx
+++ b/src/components/settings/environment-section.tsx
@@ -52,6 +52,9 @@ interface EnvironmentOverview {
lon: number;
label: string | null;
timezone: string;
+ /** ISO instant the home became effective (drives the conservative backfill
+ * default + the "tracked since" label). */
+ since: string | null;
} | null;
travel: TravelDTO[];
context: {
@@ -162,9 +165,9 @@ export function EnvironmentSection() {
const backfill = useMutation({
mutationKey: queryKeys.environment(),
- mutationFn: () =>
+ mutationFn: (startDate: string) =>
apiPost("/api/environment/backfill", {
- startDate: backfillStart,
+ startDate,
endDate: backfillEnd,
}),
onSuccess: () => {
@@ -192,6 +195,11 @@ export function EnvironmentSection() {
const data = overview.data;
const home = data?.home ?? null;
+ // Conservative default: backfill starts at the home's effective-from date. The
+ // user can still type an earlier start, but pre-home days resolve to SKIP on
+ // the server — to fill the deep past they add an explicit location period.
+ const effectiveBackfillStart =
+ backfillStart || home?.since?.slice(0, 10) || "";
return (
@@ -207,11 +215,20 @@ export function EnvironmentSection() {
@@ -413,11 +430,11 @@ export function EnvironmentSection() {
type="button"
disabled={
!home ||
- backfillStart.length === 0 ||
- backfillStart > backfillEnd ||
+ effectiveBackfillStart.length === 0 ||
+ effectiveBackfillStart > backfillEnd ||
backfill.isPending
}
- onClick={() => backfill.mutate()}
+ onClick={() => backfill.mutate(effectiveBackfillStart)}
>
{t("settings.sections.environment.backfill.run")}
From 7cd67c8dc84fb44818ff7802a243d93098b48864 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 28 Jun 2026 17:37:40 +0200
Subject: [PATCH 68/94] feat(mcp): expose v1.25 clinical signals as grounded
reads
Bring the new physical clinical signals onto the MCP read surface through
the existing rollup-backed reads. Grip strength, pain (0-10 NRS), waist
circumference, and waist-to-height ratio now resolve in get_metric_baseline,
compare_metric, and detect_changepoints, are discoverable through search
(present-only), and hydrate through fetch (metric:).
These signals sit off the Coach snapshot by design, so the MCP layer owns
an explicit exposure allowlist and consumes the signal registry only for
grounding (backing measurement type, unit, population band). The PHQ-9 /
GAD-7 mental-health screeners and the environmental signals are absent from
the allowlist, so they never resolve through any read tool. The longevity
lab panel is already reachable through get_labs and the lab resource
template; no change needed there.
---
src/lib/mcp/__tests__/rich-reads.test.ts | 43 +++++++++
src/lib/mcp/__tests__/search-fetch.test.ts | 2 +
src/lib/mcp/__tests__/tools.test.ts | 74 ++++++++++++++
src/lib/mcp/rich-reads.ts | 106 +++++++++++++++++++++
src/lib/mcp/tools.ts | 94 +++++++++++++++++-
5 files changed, 318 insertions(+), 1 deletion(-)
diff --git a/src/lib/mcp/__tests__/rich-reads.test.ts b/src/lib/mcp/__tests__/rich-reads.test.ts
index ab61d1ff2..0c8cfc34e 100644
--- a/src/lib/mcp/__tests__/rich-reads.test.ts
+++ b/src/lib/mcp/__tests__/rich-reads.test.ts
@@ -80,6 +80,49 @@ describe("resolveRichMetric", () => {
expect(resolveRichMetric("bananas")).toBeNull();
expect(resolveRichMetric("")).toBeNull();
});
+
+ // ── v1.25 clinical signals (registry-grounded, MCP allowlist) ──────────
+ it("resolves the v1.25 clinical signals from the registry with units + bands", () => {
+ const grip = resolveRichMetric("grip strength");
+ expect(grip?.measurementType).toBe("GRIP_STRENGTH");
+ expect(grip?.unit).toBe("kg");
+ expect(grip?.band).toEqual({ low: 16, high: 60 });
+
+ expect(resolveRichMetric("pain")?.measurementType).toBe("PAIN_NRS");
+ expect(resolveRichMetric("waist")?.measurementType).toBe(
+ "WAIST_CIRCUMFERENCE",
+ );
+
+ const whtr = resolveRichMetric("waist-to-height ratio");
+ expect(whtr?.measurementType).toBe("WAIST_TO_HEIGHT");
+ expect(whtr?.band).toEqual({ low: 0, high: 0.5 });
+
+ // The exact registry key resolves too.
+ expect(resolveRichMetric("GRIP_STRENGTH")?.measurementType).toBe(
+ "GRIP_STRENGTH",
+ );
+ });
+
+ it("NEVER resolves the mental-health screeners or environmental signals (safety)", () => {
+ // PHQ-9 / GAD-7 item content + totals are excluded from AI/MCP by
+ // construction; the environmental signals are off MCP in v1. None of them
+ // are on the MCP clinical allowlist, so resolution must return null.
+ for (const off of [
+ "PHQ9_SCORE",
+ "phq9 score",
+ "phq-9",
+ "GAD7_SCORE",
+ "gad7 score",
+ "gad-7",
+ "ENV_TEMP_MEAN",
+ "ENV_SUNSHINE",
+ "ENV_PRESSURE_MEAN",
+ "daily temperature",
+ "barometric pressure",
+ ]) {
+ expect(resolveRichMetric(off)).toBeNull();
+ }
+ });
});
// ── 1. get_correlation ───────────────────────────────────────────────
diff --git a/src/lib/mcp/__tests__/search-fetch.test.ts b/src/lib/mcp/__tests__/search-fetch.test.ts
index a576b1ff2..551f0b88f 100644
--- a/src/lib/mcp/__tests__/search-fetch.test.ts
+++ b/src/lib/mcp/__tests__/search-fetch.test.ts
@@ -19,6 +19,8 @@ vi.mock("@/lib/db", () => ({
prisma: {
medication: { findMany: vi.fn(), findFirst: vi.fn() },
labResult: { findMany: vi.fn() },
+ // v1.25 — `search` probes the clinical-signal measurement types.
+ measurement: { groupBy: vi.fn(async () => []) },
},
}));
diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts
index 8007fb7c4..8291d1f1e 100644
--- a/src/lib/mcp/__tests__/tools.test.ts
+++ b/src/lib/mcp/__tests__/tools.test.ts
@@ -19,6 +19,8 @@ vi.mock("@/lib/db", () => ({
findFirst: vi.fn(async () => null),
},
labResult: { findMany: vi.fn(async () => []) },
+ // v1.25 — `search` probes the clinical-signal measurement types.
+ measurement: { groupBy: vi.fn(async () => []) },
// v1.24 — operational reads (schedule / integration status / preventive care).
user: { findUnique: vi.fn(async () => ({ timezone: "UTC" })) },
medicationIntakeEvent: {
@@ -56,9 +58,18 @@ vi.mock("../rich-reads", () => ({
detectChangepoints: vi.fn(async () => ({ present: true })),
getLabHistory: vi.fn(async () => ({ present: true })),
LAB_HISTORY_MAX_LIMIT: 50,
+ // v1.25 — the clinical-signal allowlist `search` / `fetch` consume.
+ MCP_CLINICAL_SIGNALS: [
+ {
+ key: "GRIP_STRENGTH",
+ measurementType: "GRIP_STRENGTH",
+ label: "Grip strength",
+ },
+ ],
}));
import { MCP_TOOLS, MCP_TOOL_NAMES } from "../tools";
+import { getMetricBaseline } from "../rich-reads";
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory";
import { prisma } from "@/lib/db";
@@ -567,3 +578,66 @@ describe("search — cursor pagination", () => {
expect(page2.nextCursor).toBeUndefined();
});
});
+
+describe("v1.25 clinical signals on the MCP surface", () => {
+ beforeEach(() => {
+ vi.mocked(buildCoachDataInventory).mockResolvedValue({
+ entries: [],
+ restMode: false,
+ cycleEnabled: false,
+ window: "last30days",
+ probeScope: { sources: [] },
+ } as never);
+ vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
+ vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
+ });
+
+ it("search surfaces a present clinical signal as metric:", async () => {
+ vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
+ { type: "GRIP_STRENGTH" },
+ ] as never);
+ const result = (await tool("search").run(CTX, { query: "grip" })) as {
+ results: Array<{ id: string; title: string; url: string }>;
+ };
+ const hit = result.results.find((r) => r.id === "metric:GRIP_STRENGTH");
+ expect(hit).toBeDefined();
+ expect(hit?.title).toBe("Grip strength");
+ expect(hit?.url).toContain("/insights?metric=GRIP_STRENGTH");
+ });
+
+ it("search omits a clinical signal with no recorded data", async () => {
+ vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never);
+ const result = (await tool("search").run(CTX, { query: "grip" })) as {
+ results: Array<{ id: string }>;
+ };
+ expect(result.results.some((r) => r.id.startsWith("metric:"))).toBe(false);
+ });
+
+ it("fetch hydrates a clinical signal via the baseline read (not the Coach path)", async () => {
+ vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never);
+ vi.mocked(getMetricBaseline).mockResolvedValue({
+ present: true,
+ metric: "Grip strength",
+ unit: "kg",
+ latest: 34,
+ placement: "within",
+ baseline: { low: 30, high: 38, sampleDays: 21 },
+ referenceBand: { low: 16, high: 60 },
+ } as never);
+
+ const result = (await tool("fetch").run(CTX, {
+ id: "metric:GRIP_STRENGTH",
+ })) as Record;
+
+ // Resolved through the rollup-backed baseline read, never the Coach executor.
+ expect(getMetricBaseline).toHaveBeenCalledWith("user-1", {
+ metric: "GRIP_STRENGTH",
+ });
+ expect(executeCoachTool).not.toHaveBeenCalled();
+ expect(result.title).toBe("Grip strength");
+ // Plain-text prose, grounded with the value + band; never a JSON blob.
+ expect(result.text as string).not.toContain("{");
+ expect(result.text as string).toContain("34");
+ expect(result.text as string).toContain("16–60");
+ });
+});
diff --git a/src/lib/mcp/rich-reads.ts b/src/lib/mcp/rich-reads.ts
index ddb60504f..4f06697a7 100644
--- a/src/lib/mcp/rich-reads.ts
+++ b/src/lib/mcp/rich-reads.ts
@@ -43,6 +43,7 @@ import {
getMetricStatusMeta,
METRIC_STATUS_IDS,
} from "@/lib/insights/metric-status-registry";
+import { getSignal } from "@/lib/signals/registry";
import { classifyReferenceRange } from "@/lib/labs/reference-range";
import { resolveLabFields } from "@/lib/labs/serialise";
import { sanitizeValueText } from "@/lib/ai/coach/labs-snapshot";
@@ -177,6 +178,104 @@ function fromRegistry(id: string): RichMetric | null {
};
}
+// ── v1.25 clinical signals (registry-grounded, MCP-owned exposure) ────
+//
+// The v1.25 clinical-signals wave added a set of physical / clinical
+// measurements to the signal registry. They sit OFF the Coach snapshot by
+// design (`coachSnapshot:false ⇒ surfaces.mcp:false`), so the Coach-driven
+// reads (`get_metric_series`, the data inventory) never surface them. The MCP
+// layer exposes them here through the rollup-backed rich reads — `compare_metric`,
+// `get_metric_baseline`, `detect_changepoints` — and through `search` / `fetch`.
+//
+// Exposure is an EXPLICIT allowlist owned by this module, not the registry's own
+// `surfaces.mcp` flag: the registry keeps these off the Coach surface, while the
+// MCP read surface opts them in one signal at a time. The registry remains the
+// single source of truth for grounding (the backing `MeasurementType`, unit, and
+// population band). Deliberately ABSENT and therefore unreachable over MCP: the
+// PHQ-9 / GAD-7 mental-health screeners and every environmental (`ENV_*`) signal
+// — they never reach AI / MCP by construction.
+export const MCP_CLINICAL_SIGNAL_KEYS = [
+ "GRIP_STRENGTH",
+ "PAIN_NRS",
+ "WAIST_CIRCUMFERENCE",
+ "WAIST_TO_HEIGHT",
+] as const;
+
+/** Build a `RichMetric` from a registry signal key (measurement-kind only). */
+function richMetricFromSignal(key: string): RichMetric | null {
+ const sig = getSignal(key);
+ if (!sig || sig.kind !== "measurement") return null;
+ return {
+ measurementType: sig.source.measurementType,
+ label: sig.displayName,
+ unit: sig.unit,
+ band: sig.normalRange
+ ? { low: sig.normalRange.low, high: sig.normalRange.high }
+ : null,
+ };
+}
+
+/** Resolved clinical signals, keyed by their registry key (built once). */
+const CLINICAL_SIGNAL_BY_KEY = new Map();
+for (const key of MCP_CLINICAL_SIGNAL_KEYS) {
+ const metric = richMetricFromSignal(key);
+ if (metric) CLINICAL_SIGNAL_BY_KEY.set(key, metric);
+}
+
+/**
+ * The clinical signals the MCP surface exposes, for the `search` discovery
+ * probe: the registry key (the `metric:` id `fetch` resolves), the backing
+ * `MeasurementType` (the presence probe), and the display label.
+ */
+export const MCP_CLINICAL_SIGNALS: ReadonlyArray<{
+ key: string;
+ measurementType: MeasurementType;
+ label: string;
+}> = [...CLINICAL_SIGNAL_BY_KEY.entries()].map(([key, m]) => ({
+ key,
+ measurementType: m.measurementType,
+ label: m.label,
+}));
+
+/** Friendly aliases (NL phrasings) → clinical signal key. */
+const CLINICAL_ALIASES: Record = {
+ grip: "GRIP_STRENGTH",
+ grip_strength: "GRIP_STRENGTH",
+ hand_grip: "GRIP_STRENGTH",
+ hand_grip_strength: "GRIP_STRENGTH",
+ pain: "PAIN_NRS",
+ pain_nrs: "PAIN_NRS",
+ pain_score: "PAIN_NRS",
+ waist: "WAIST_CIRCUMFERENCE",
+ waist_circumference: "WAIST_CIRCUMFERENCE",
+ whtr: "WAIST_TO_HEIGHT",
+ waist_to_height: "WAIST_TO_HEIGHT",
+ waist_to_height_ratio: "WAIST_TO_HEIGHT",
+ waist_height_ratio: "WAIST_TO_HEIGHT",
+};
+
+/**
+ * Resolve a free-text name to a clinical signal the MCP layer exposes, or
+ * `null`. Closed by construction to the `MCP_CLINICAL_SIGNAL_KEYS` allowlist, so
+ * a screener key (PHQ9_SCORE / GAD7_SCORE) or an `ENV_*` key can never resolve
+ * here even though they exist in the registry.
+ */
+function resolveClinicalSignal(key: string): RichMetric | null {
+ const aliased = CLINICAL_ALIASES[key];
+ if (aliased) return CLINICAL_SIGNAL_BY_KEY.get(aliased) ?? null;
+ const upper = key.toUpperCase();
+ if (CLINICAL_SIGNAL_BY_KEY.has(upper)) {
+ return CLINICAL_SIGNAL_BY_KEY.get(upper) ?? null;
+ }
+ // Display-name match over the allowlist (e.g. "waist-to-height ratio").
+ for (const [, metric] of CLINICAL_SIGNAL_BY_KEY) {
+ if (metric.label.toLowerCase().replace(/[\s-]+/g, "_") === key) {
+ return metric;
+ }
+ }
+ return null;
+}
+
/**
* Resolve a free-text metric name to a single scalar series. Forgiving for an
* NL assistant (alias, exact id, display-name match) but closed — an
@@ -192,6 +291,13 @@ export function resolveRichMetric(input: string): RichMetric | null {
// 1. explicit supplement (weight / pulse / bmi).
if (SUPPLEMENT[key]) return SUPPLEMENT[key];
+ // 1b. v1.25 clinical signals the MCP layer opts in (grip strength, pain NRS,
+ // waist circumference, waist-to-height). Registry-grounded; the allowlist is
+ // closed, so a mental-health screener or an environmental signal never
+ // resolves here.
+ const clinical = resolveClinicalSignal(key);
+ if (clinical) return clinical;
+
// 2. friendly alias → registry id.
const aliased = ALIASES[key];
if (aliased) return fromRegistry(aliased);
diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts
index 8dc4a4e78..6c6c16dac 100644
--- a/src/lib/mcp/tools.ts
+++ b/src/lib/mcp/tools.ts
@@ -35,6 +35,7 @@ import {
detectChangepoints,
getLabHistory,
LAB_HISTORY_MAX_LIMIT,
+ MCP_CLINICAL_SIGNALS,
type DateRange,
} from "@/lib/mcp/rich-reads";
import { encodeOffsetCursor, decodeOffsetCursor } from "@/lib/mcp/pagination";
@@ -188,6 +189,55 @@ function plainLabText(rid: string, result: unknown): string {
);
}
+/**
+ * Render a `fetch` result for a v1.25 clinical signal (grip strength, pain NRS,
+ * waist circumference, waist-to-height) as a plain-text citation sentence. These
+ * sit off the Coach snapshot, so they hydrate through the rollup-backed baseline
+ * read (`get_metric_baseline`) rather than the Coach `get_metric_series` path:
+ * the latest reading, where it sits against the user's own usual range, and the
+ * population reference band. Quotes only values the result carried; never
+ * fabricates a figure or asserts a clinical verdict.
+ */
+function plainClinicalText(
+ label: string,
+ result: {
+ present?: boolean;
+ reason?: string;
+ unit?: string;
+ latest?: number;
+ placement?: "within" | "above" | "below";
+ baseline?: { low: number; high: number };
+ referenceBand?: { low: number; high: number } | null;
+ },
+): string {
+ const unit = result.unit ? ` ${result.unit}` : "";
+ const band = result.referenceBand
+ ? ` Reference band ${result.referenceBand.low}–${result.referenceBand.high}${unit}.`
+ : "";
+ if (!result.present) {
+ if (result.reason === "insufficient_history") {
+ return `${label}: still learning your usual range — too little history yet.${band}`;
+ }
+ return `No recent ${label.toLowerCase()} data is on file.`;
+ }
+ const parts: string[] = [];
+ if (typeof result.latest === "number") {
+ parts.push(`latest ${round1(result.latest)}${unit}`);
+ }
+ if (result.placement && result.baseline) {
+ parts.push(
+ `${result.placement} your usual ${round1(result.baseline.low)}–${round1(
+ result.baseline.high,
+ )}${unit}`,
+ );
+ }
+ const head =
+ parts.length > 0
+ ? `${label}: ${parts.join(", ")}.`
+ : `${label}: a reading is on file.`;
+ return `${head}${band}`.trim();
+}
+
/**
* The `search` + `fetch` pair — the de-facto two-tool retrieval convention and,
* critically, the ONLY tools ChatGPT can call in its default (non-Developer)
@@ -288,6 +338,30 @@ function searchAndFetchTools(): McpToolDefinition[] {
});
}
+ // v1.25 clinical signals (grip strength, pain NRS, waist / WHtR) — off
+ // the Coach data inventory by design, so they are surfaced here directly.
+ // One grouped presence probe over their backing measurement types;
+ // present-only, in a stable (allowlist) order. `fetch metric:`
+ // hydrates each via the rollup-backed baseline read.
+ const clinicalPresent = await prisma.measurement.groupBy({
+ by: ["type"],
+ where: {
+ userId: ctx.userId,
+ type: { in: MCP_CLINICAL_SIGNALS.map((s) => s.measurementType) },
+ },
+ });
+ const presentTypes = new Set(clinicalPresent.map((r) => r.type));
+ for (const sig of MCP_CLINICAL_SIGNALS) {
+ if (!presentTypes.has(sig.measurementType)) continue;
+ const hay = `${sig.label} ${sig.key}`.toLowerCase();
+ if (query && !hay.includes(query)) continue;
+ results.push({
+ id: `metric:${sig.key}`,
+ title: sig.label,
+ url: `${origin}/insights?metric=${encodeURIComponent(sig.key)}`,
+ });
+ }
+
// Cursor pagination over the assembled result set (was a silent
// slice(0,50)). The set is rebuilt deterministically each call (stable
// ordering: metrics → medications → labs), so an opaque offset cursor
@@ -334,6 +408,24 @@ function searchAndFetchTools(): McpToolDefinition[] {
});
if (kind === "metric" && rid) {
+ // v1.25 clinical signals sit off the Coach snapshot, so hydrate them
+ // through the rollup-backed baseline read rather than the Coach
+ // `get_metric_series` path (which would report no data for them).
+ const clinical = MCP_CLINICAL_SIGNALS.find(
+ (s) => s.key.toLowerCase() === rid.toLowerCase(),
+ );
+ if (clinical) {
+ const baseline = await getMetricBaseline(ctx.userId, {
+ metric: clinical.key,
+ });
+ return {
+ id,
+ title: clinical.label,
+ text: plainClinicalText(clinical.label, baseline),
+ url: `${origin}/insights?metric=${encodeURIComponent(clinical.key)}`,
+ metadata: { type: "metric", metric: clinical.key },
+ };
+ }
const result = await executeCoachTool({
userId: ctx.userId,
name: "get_metric_series",
@@ -823,7 +915,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
name: "get_metric_baseline",
title: "Get a metric's personal baseline",
description:
- "Fetch where the user's latest reading for ONE metric sits against their own usual range (median ± robust deviation), plus the strongest lagged driver of that metric. Re-exports the same baseline engine the metric page renders. Returns the personal band, today's value + placement (within/above/below), and the population reference band. Returns { present: false } with reason 'insufficient_history' below the 7-day learning floor — never a fabricated range.",
+ "Fetch where the user's latest reading for ONE metric sits against their own usual range (median ± robust deviation), plus the strongest lagged driver of that metric. Re-exports the same baseline engine the metric page renders. Also resolves the clinical-signal metrics (grip strength, pain 0–10 NRS, waist circumference, waist-to-height ratio). Returns the personal band, today's value + placement (within/above/below), and the population reference band. Returns { present: false } with reason 'insufficient_history' below the 7-day learning floor — never a fabricated range.",
inputShape: {
metric: z.string().min(1).max(60),
},
From b82a9ff07cf255e2a4500d8e05033b04dda4990c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 28 Jun 2026 17:37:40 +0200
Subject: [PATCH 69/94] docs(mcp): document clinical-signal reads and the
off-surface set
---
docs/api/mcp-capabilities.md | 27 +++++++++++++++++++++++++++
1 file changed, 27 insertions(+)
diff --git a/docs/api/mcp-capabilities.md b/docs/api/mcp-capabilities.md
index 8c0b6dd8e..66874523d 100644
--- a/docs/api/mcp-capabilities.md
+++ b/docs/api/mcp-capabilities.md
@@ -95,6 +95,33 @@ Every read tool is annotated read-only / non-destructive / idempotent /
closed-world — the read-only guarantee is structural (only read tools are
registered for a read session), and the annotation merely advertises it.
+### Clinical signals
+
+The v1.25 clinical-signals set — **grip strength**, **pain (0–10 NRS)**,
+**waist circumference**, and **waist-to-height ratio** — is reachable
+through the rollup-backed reads `get_metric_baseline`, `compare_metric`,
+and `detect_changepoints` (pass the signal name, e.g. `grip strength` or
+`waist-to-height ratio`), and through `search` → `fetch` (`search`
+surfaces a signal only when the user has recorded it; `fetch metric:`
+hydrates it). Each carries its unit and population reference band from the
+single signal registry; absence is the usual `{ present: false }`. They
+sit off the Coach data inventory by design, so `list_metrics` /
+`get_metric_series` do not enumerate them — use the reads above.
+
+The **longevity lab panel** (ApoB, Lp(a), hs-CRP, HbA1c, fasting glucose,
+fasting insulin, eGFR, GGT, ferritin, omega-3 index) is reached like any
+other biomarker: `get_labs` (latest per analyte or, with `history:true`, a
+paginated trajectory), the `healthlog://lab/{analyte}` resource template,
+and `search` → `fetch` (`lab:`). Reference bounds come from the
+curated biomarker catalogue.
+
+**Off the MCP surface by construction.** The PHQ-9 / GAD-7 mental-health
+screeners (item content and totals) are excluded from AI / MCP, and the
+environmental-context signals (`ENV_*`: temperature, sunshine, daylight,
+precipitation, barometric pressure) are off MCP in v1. None of them
+resolve through any read tool — they are not on the MCP exposure
+allowlist.
+
## Resources
Read-only context the assistant can attach alongside tool results. Like
From 3bd52a322b0637f6d9a775598ae2a92ba7cdedc9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 28 Jun 2026 17:43:31 +0200
Subject: [PATCH 70/94] feat(self-description): add the non-diagnostic /
intended-purpose standard
Introduce one canonical "what this is / is not, not a medical device"
statement plus a layered, calm disclaimer set, and apply it on the
surfaces that earn a context note.
- New shared `MedicalDisclaimer` component renders the copy as plain
React text (no markdown renderer, per the XSS posture). Variants:
intendedPurpose, coach, insights, labs, medications, emergency,
dataPosture.
- New `selfDescription` i18n namespace across all six locales: the
intended-purpose line, the per-surface notes, the emergency note, and
an explicit data-posture statement (self-hosted, data stays on your
instance, encrypted at rest, BYOK / local model, no third-party
analytics).
- Apply once per surface, layered not plastered: a calm standing line on
the Coach new-chat hero, one framing line in the Insights layout shell
(covers the briefing and trends), a labs note (ranges vary, only a
clinician interprets), a neutral medications note, and the data-posture
line where the provider key is configured. Mental-wellbeing keeps its
existing crisis copy; records keep their existing note.
- Extend the Coach system prompt (EN + DE) with a self-description ground
rule so the Coach answers "what are you / where does my data go"
honestly and stays non-diagnostic.
App-authored copy was already observational; the few diagnose/treat
tokens are negations, idioms, or framed as the user's own self-reported
statement, so no softening was required.
---
messages/de.json | 9 +++
messages/en.json | 9 +++
messages/es.json | 9 +++
messages/fr.json | 9 +++
messages/it.json | 9 +++
messages/pl.json | 9 +++
src/app/labs/page.tsx | 6 ++
src/app/medications/page.tsx | 7 ++
src/components/common/medical-disclaimer.tsx | 75 +++++++++++++++++++
.../insights/coach-panel/coach-hero.tsx | 10 +++
.../insights/insights-layout-shell.tsx | 14 ++--
.../settings/ai/ai-insights-card.tsx | 6 ++
src/lib/ai/coach/system-prompt.ts | 28 +++++++
13 files changed, 195 insertions(+), 5 deletions(-)
create mode 100644 src/components/common/medical-disclaimer.tsx
diff --git a/messages/de.json b/messages/de.json
index d971d07b8..674c2f869 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -7792,5 +7792,14 @@
"saved": "Angabe aktualisiert.",
"discarded": "Dokument verworfen."
}
+ },
+ "selfDescription": {
+ "intendedPurpose": "HealthLog ist ein persönliches Werkzeug zur Gesundheitsdokumentation und zum Wohlbefinden. Es hilft dir, deine eigenen Gesundheitsdaten zu erfassen, zu visualisieren und zu exportieren. Es diagnostiziert, behandelt, heilt oder verhindert keine Krankheit und ist kein Medizinprodukt.",
+ "coach": "Der Coach arbeitet mit deinen eigenen erfassten Daten. Er ist kein Arzt und gibt keine medizinische Beratung — für Diagnose oder Behandlung wende dich an medizinisches Fachpersonal.",
+ "insights": "Das sind beschreibende Muster aus deinen eigenen erfassten Daten, keine klinische Beurteilung und keine Diagnose.",
+ "labs": "Das sind deine eigenen Aufzeichnungen, keine medizinische Diagnose. Referenzbereiche unterscheiden sich je nach Labor und Person — nur deine Ärztin oder dein Arzt kann sie für dich einordnen.",
+ "medications": "Das erfasst die von dir dokumentierten Medikamente und deine eigene Einnahmetreue. Es beurteilt weder Dosierung noch Wechselwirkungen und ist keine medizinische Beratung — kläre das mit Apotheke oder Arztpraxis.",
+ "emergency": "Falls das ein Notfall sein könnte, wende dich jetzt an deine örtliche Notrufnummer oder an eine ärztliche Stelle. HealthLog kann im Notfall nicht helfen.",
+ "dataPosture": "HealthLog wird selbst gehostet: Deine Gesundheitsdaten bleiben auf deiner eigenen Instanz und sind im Ruhezustand verschlüsselt. KI-Funktionen nutzen einen von dir mitgebrachten Anbieter-Schlüssel oder ein lokales Modell — nichts verlässt deine Instanz über den von dir konfigurierten Anbieter hinaus, und es gibt keine Drittanbieter-Analyse deiner Gesundheitsdaten."
}
}
diff --git a/messages/en.json b/messages/en.json
index 5c88e09b7..bcdee2c5e 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -7792,5 +7792,14 @@
"saved": "Fact updated.",
"discarded": "Document discarded."
}
+ },
+ "selfDescription": {
+ "intendedPurpose": "HealthLog is a personal health record-keeping and wellness tool. It helps you log, visualise, and export your own health data. It does not diagnose, treat, cure, or prevent any disease, and it is not a medical device.",
+ "coach": "The Coach works from your own logged data. It isn't a doctor and doesn't give medical advice — for diagnosis or treatment, talk to a healthcare professional.",
+ "insights": "These are descriptive patterns computed from your own logged data, not a clinical assessment or diagnosis.",
+ "labs": "These are your own records, not a medical diagnosis. Reference ranges vary by lab and person — only your clinician can interpret them for you.",
+ "medications": "This tracks the medications you log and your own adherence. It doesn't assess dosing or interactions and isn't medical advice — confirm those with a pharmacist or doctor.",
+ "emergency": "If this might be an emergency, contact your local emergency number or a clinician now. HealthLog can't help in an emergency.",
+ "dataPosture": "HealthLog is self-hosted: your health data stays on your own instance, encrypted at rest. AI features use a provider key you bring or a local model, so nothing leaves your instance beyond the provider you configure, and there is no third-party analytics on your health data."
}
}
diff --git a/messages/es.json b/messages/es.json
index 82610e32b..663e4b38c 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -7792,5 +7792,14 @@
"saved": "Dato actualizado.",
"discarded": "Documento descartado."
}
+ },
+ "selfDescription": {
+ "intendedPurpose": "HealthLog es una herramienta personal de registro de salud y bienestar. Te ayuda a registrar, visualizar y exportar tus propios datos de salud. No diagnostica, trata, cura ni previene ninguna enfermedad, y no es un producto sanitario.",
+ "coach": "El Coach trabaja con tus propios datos registrados. No es un médico y no da consejo médico: para un diagnóstico o tratamiento, consulta a un profesional sanitario.",
+ "insights": "Son patrones descriptivos calculados a partir de tus propios datos registrados, no una evaluación clínica ni un diagnóstico.",
+ "labs": "Son tus propios registros, no un diagnóstico médico. Los rangos de referencia varían según el laboratorio y la persona: solo tu médico puede interpretarlos por ti.",
+ "medications": "Esto registra los medicamentos que anotas y tu propia adherencia. No evalúa dosis ni interacciones y no es consejo médico: confírmalo con tu farmacéutico o médico.",
+ "emergency": "Si esto pudiera ser una emergencia, llama ahora a tu número de emergencias local o a un profesional sanitario. HealthLog no puede ayudar en una emergencia.",
+ "dataPosture": "HealthLog es autoalojado: tus datos de salud permanecen en tu propia instancia, cifrados en reposo. Las funciones de IA usan una clave de proveedor que tú aportas o un modelo local, así que nada sale de tu instancia más allá del proveedor que configures, y no hay analítica de terceros sobre tus datos de salud."
}
}
diff --git a/messages/fr.json b/messages/fr.json
index a7a2052d7..dd098d408 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -7792,5 +7792,14 @@
"saved": "Donnée mise à jour.",
"discarded": "Document écarté."
}
+ },
+ "selfDescription": {
+ "intendedPurpose": "HealthLog est un outil personnel de suivi de santé et de bien-être. Il vous aide à enregistrer, visualiser et exporter vos propres données de santé. Il ne diagnostique, ne traite, ne guérit ni ne prévient aucune maladie, et n'est pas un dispositif médical.",
+ "coach": "Le Coach s'appuie sur vos propres données enregistrées. Ce n'est pas un médecin et il ne donne pas de conseil médical : pour un diagnostic ou un traitement, consultez un professionnel de santé.",
+ "insights": "Ce sont des tendances descriptives calculées à partir de vos propres données enregistrées, pas une évaluation clinique ni un diagnostic.",
+ "labs": "Ce sont vos propres relevés, pas un diagnostic médical. Les valeurs de référence varient selon le laboratoire et la personne : seul votre médecin peut les interpréter pour vous.",
+ "medications": "Ceci suit les médicaments que vous enregistrez et votre propre observance. Cela n'évalue ni les doses ni les interactions et n'est pas un conseil médical : confirmez-les avec votre pharmacien ou votre médecin.",
+ "emergency": "Si cela pourrait être une urgence, contactez maintenant votre numéro d'urgence local ou un professionnel de santé. HealthLog ne peut pas aider en cas d'urgence.",
+ "dataPosture": "HealthLog est auto-hébergé : vos données de santé restent sur votre propre instance, chiffrées au repos. Les fonctions d'IA utilisent une clé de fournisseur que vous apportez ou un modèle local, donc rien ne quitte votre instance au-delà du fournisseur que vous configurez, et il n'y a aucune analyse tierce de vos données de santé."
}
}
diff --git a/messages/it.json b/messages/it.json
index 94885be49..f85dcb938 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -7792,5 +7792,14 @@
"saved": "Dato aggiornato.",
"discarded": "Documento scartato."
}
+ },
+ "selfDescription": {
+ "intendedPurpose": "HealthLog è uno strumento personale per la registrazione della salute e il benessere. Ti aiuta a registrare, visualizzare ed esportare i tuoi dati di salute. Non diagnostica, cura, guarisce né previene alcuna malattia e non è un dispositivo medico.",
+ "coach": "Il Coach lavora sui tuoi dati registrati. Non è un medico e non fornisce consigli medici: per una diagnosi o una terapia rivolgiti a un professionista sanitario.",
+ "insights": "Sono andamenti descrittivi calcolati dai tuoi dati registrati, non una valutazione clinica né una diagnosi.",
+ "labs": "Sono i tuoi referti, non una diagnosi medica. Gli intervalli di riferimento variano da laboratorio a persona: solo il tuo medico può interpretarli per te.",
+ "medications": "Questo registra i farmaci che annoti e la tua aderenza. Non valuta dosaggi o interazioni e non è un consiglio medico: confermali con il farmacista o il medico.",
+ "emergency": "Se potrebbe trattarsi di un'emergenza, contatta subito il numero di emergenza locale o un medico. HealthLog non può aiutare in caso di emergenza.",
+ "dataPosture": "HealthLog è self-hosted: i tuoi dati di salute restano sulla tua istanza, cifrati a riposo. Le funzioni di IA usano una chiave del provider che fornisci tu o un modello locale, quindi nulla lascia la tua istanza oltre il provider che configuri, e non c'è alcuna analisi di terze parti sui tuoi dati di salute."
}
}
diff --git a/messages/pl.json b/messages/pl.json
index 70a7fea64..c56dbf0df 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -7792,5 +7792,14 @@
"saved": "Dane zaktualizowane.",
"discarded": "Dokument odrzucony."
}
+ },
+ "selfDescription": {
+ "intendedPurpose": "HealthLog to osobiste narzędzie do rejestrowania zdrowia i dbania o dobre samopoczucie. Pomaga zapisywać, wizualizować i eksportować własne dane zdrowotne. Nie diagnozuje, nie leczy ani nie zapobiega żadnej chorobie i nie jest wyrobem medycznym.",
+ "coach": "Coach korzysta z Twoich własnych zapisanych danych. Nie jest lekarzem i nie udziela porad medycznych — po diagnozę lub leczenie zwróć się do pracownika ochrony zdrowia.",
+ "insights": "To opisowe wzorce wyliczone z Twoich własnych zapisanych danych, a nie ocena kliniczna ani diagnoza.",
+ "labs": "To Twoje własne zapisy, a nie diagnoza medyczna. Zakresy referencyjne różnią się w zależności od laboratorium i osoby — tylko Twój lekarz może je dla Ciebie zinterpretować.",
+ "medications": "To rejestruje leki, które zapisujesz, oraz Twoje stosowanie się do zaleceń. Nie ocenia dawkowania ani interakcji i nie jest poradą medyczną — potwierdź to u farmaceuty lub lekarza.",
+ "emergency": "Jeśli to może być nagły przypadek, zadzwoń teraz pod lokalny numer alarmowy lub skontaktuj się z lekarzem. HealthLog nie pomoże w nagłym przypadku.",
+ "dataPosture": "HealthLog jest hostowany samodzielnie: Twoje dane zdrowotne pozostają na Twojej instancji, zaszyfrowane w spoczynku. Funkcje AI korzystają z dostarczonego przez Ciebie klucza dostawcy lub modelu lokalnego, więc nic nie opuszcza Twojej instancji poza skonfigurowanym dostawcą i nie ma analityki firm trzecich na Twoich danych zdrowotnych."
}
}
diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx
index b10436be5..359f8d1b2 100644
--- a/src/app/labs/page.tsx
+++ b/src/app/labs/page.tsx
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2, Pencil, Plus, ScanLine, Wrench } from "lucide-react";
+import { MedicalDisclaimer } from "@/components/common/medical-disclaimer";
import { LabForm } from "@/components/labs/lab-form";
import { LabList } from "@/components/labs/lab-list";
import { OcrReviewDialog } from "@/components/labs/ocr-review-dialog";
@@ -173,6 +174,11 @@ export default function LabsPage() {
) : null}
setDialogOpen(true)} />
+
+ {/* v1.25 — calm, context-specific framing for lab values: your own
+ records, ranges vary by lab and person, only a clinician interprets
+ them. One footer line, not a per-row banner. */}
+
);
}
diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx
index a65d0182e..6b48c6dbc 100644
--- a/src/app/medications/page.tsx
+++ b/src/app/medications/page.tsx
@@ -7,6 +7,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
+import { MedicalDisclaimer } from "@/components/common/medical-disclaimer";
import { MedicationWizardDialog } from "@/components/medications/wizard/medication-wizard-dialog";
import { MedicationCard } from "@/components/medications/medication-card";
import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card";
@@ -524,6 +525,12 @@ export default function MedicationsPage() {
)}
+ {/* v1.25 — calm framing for the medications surface: this tracks what
+ you log and your own adherence, it doesn't assess dosing or
+ interactions and isn't medical advice. One footer line, neutral —
+ the med cards themselves stay un-tinted. */}
+
+
{/* v1.5.5 — the per-card kebab triggers used to open inline
IntakeImportDialog / ApiEndpointDialog mounts here, but both
state slots were only ever set from inside the dialogs
diff --git a/src/components/common/medical-disclaimer.tsx b/src/components/common/medical-disclaimer.tsx
new file mode 100644
index 000000000..ba27663cf
--- /dev/null
+++ b/src/components/common/medical-disclaimer.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import { useTranslations } from "@/lib/i18n/context";
+
+/**
+ * v1.25 — the one place the app states what it is and is not.
+ *
+ * HealthLog's self-description standard is layered, not a wall of warnings:
+ * a single intended-purpose statement plus calm, context-specific notes that
+ * sit once on the surface they belong to. Every string is plain React text —
+ * no markdown renderer, in line with the project's XSS posture (Coach,
+ * briefing and insights all render as text children).
+ *
+ * The copy lives under the `selfDescription` i18n namespace so every locale
+ * carries the same set; pick the note that fits the surface:
+ *
+ * - `intendedPurpose` — the canonical "what this is / is not, not a medical
+ * device" line. Reuse verbatim; do not paraphrase per surface.
+ * - `coach` — the Coach standing line (works from your data, not a doctor).
+ * - `insights` — descriptive-patterns framing for insights + the briefing.
+ * - `labs` — your own records, ranges vary, only a clinician interprets.
+ * - `medications` — tracks what you log, not dosing/interaction advice.
+ * - `emergency` — the red-flag interstitial note (contact emergency services).
+ * - `dataPosture` — self-hosted, data stays on your instance, BYOK / local AI.
+ *
+ * The acknowledged onboarding disclaimer (`onboarding.disclaimer.*`) and the
+ * legal medical-device boundary on the public privacy page remain the standing
+ * floor; these notes are the gentle, in-context layer above it.
+ */
+export type DisclaimerVariant =
+ | "intendedPurpose"
+ | "coach"
+ | "insights"
+ | "labs"
+ | "medications"
+ | "emergency"
+ | "dataPosture";
+
+const VARIANT_KEY: Record = {
+ intendedPurpose: "selfDescription.intendedPurpose",
+ coach: "selfDescription.coach",
+ insights: "selfDescription.insights",
+ labs: "selfDescription.labs",
+ medications: "selfDescription.medications",
+ emergency: "selfDescription.emergency",
+ dataPosture: "selfDescription.dataPosture",
+};
+
+export interface MedicalDisclaimerProps {
+ variant: DisclaimerVariant;
+ className?: string;
+}
+
+/**
+ * A calm, muted, plain-text disclaimer line. Defaults to a small,
+ * unobtrusive footnote treatment; pass `className` to tune spacing for the
+ * surface. Keep it to one instance per surface — the standard is layered,
+ * not repeated.
+ */
+export function MedicalDisclaimer({
+ variant,
+ className,
+}: MedicalDisclaimerProps) {
+ const { t } = useTranslations();
+ return (
+
+ {t(VARIANT_KEY[variant])}
+
+ );
+}
diff --git a/src/components/insights/coach-panel/coach-hero.tsx b/src/components/insights/coach-panel/coach-hero.tsx
index cd67c0d54..5cc06c4c9 100644
--- a/src/components/insights/coach-panel/coach-hero.tsx
+++ b/src/components/insights/coach-panel/coach-hero.tsx
@@ -2,6 +2,7 @@
import { Sparkles } from "lucide-react";
+import { MedicalDisclaimer } from "@/components/common/medical-disclaimer";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
@@ -121,6 +122,15 @@ export function CoachHero({ composer, scopeHint }: CoachHeroProps) {
{scopeHint}
) : null}
+
+ {/* v1.25 — the Coach standing self-description: works from your own
+ data, not a doctor, not medical advice. Sits once on the calm
+ new-chat surface so it frames the conversation without nagging
+ every reply. */}
+
);
diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx
index f8a76fc3b..e7ee0c0ce 100644
--- a/src/components/insights/insights-layout-shell.tsx
+++ b/src/components/insights/insights-layout-shell.tsx
@@ -8,6 +8,7 @@ import { useMounted } from "@/hooks/use-mounted";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useWorkouts } from "@/hooks/use-workouts";
import { InsightsTabStrip } from "@/components/insights/insights-tab-strip";
+import { MedicalDisclaimer } from "@/components/common/medical-disclaimer";
import {
nextAdvisorPollInterval,
useInsightsAdvisorQuery,
@@ -159,11 +160,14 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
modules={user?.modules}
/>
{children}
- {/* v1.18.6 (DISC-01) — the page-level generic medical disclaimer is
- removed. The "HealthLog is a private tracking tool, not a clinical
- assessment or diagnosis" acknowledgment now lives once at the start
- of onboarding, and the legal text stays reachable on the public
- privacy page. */}
+ {/* v1.18.6 (DISC-01) — the page-level generic medical disclaimer was
+ removed; the standing acknowledgment lives once at the start of
+ onboarding and the legal text on the public privacy page.
+ v1.25 — one calm, context-specific framing line for the whole
+ Insights surface (the briefing, trends and correlations included):
+ these are descriptive patterns from the user's own data, not a
+ clinical assessment. Layered, not a wall of warnings. */}
+
);
}
diff --git a/src/components/settings/ai/ai-insights-card.tsx b/src/components/settings/ai/ai-insights-card.tsx
index 2768c96e6..e22a1d6b2 100644
--- a/src/components/settings/ai/ai-insights-card.tsx
+++ b/src/components/settings/ai/ai-insights-card.tsx
@@ -19,6 +19,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/badge";
+import { MedicalDisclaimer } from "@/components/common/medical-disclaimer";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -201,6 +202,11 @@ export function AiInsightsCard({
})
}
/>
+
+ {/* v1.25 — the data-posture statement lives where the provider key is
+ configured: self-hosted, data stays on this instance, BYOK / local
+ model, no third-party analytics. One calm line, not a banner. */}
+
);
diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts
index 27ec7d92d..0d2c31c2a 100644
--- a/src/lib/ai/coach/system-prompt.ts
+++ b/src/lib/ai/coach/system-prompt.ts
@@ -285,6 +285,19 @@ GROUND RULES
persuasion for its own sake. If a nudge would only serve to push, drop it
and reflect instead. You inform and support a decision that stays theirs;
you never manipulate it.
+21. Describe HealthLog honestly when asked what you are, what this app is,
+ or where the user's data goes. HealthLog is a self-hosted personal
+ health record-keeping and wellness tool — it helps the user log,
+ visualise and export their OWN data. It is NOT a medical device and does
+ NOT diagnose, treat, cure or prevent any disease; you are an
+ informational assistant, not a doctor, and what you offer is not medical
+ advice. On data: the user's health data stays on their own instance,
+ encrypted at rest; AI runs on a provider key they bring or a local
+ model, so nothing leaves their instance beyond the provider they
+ configured, and there is no third-party analytics on their health data.
+ State this plainly and calmly when relevant; never oversell it as
+ confidential like a clinician's, and keep deferring diagnosis /
+ treatment / dose questions to a healthcare professional.
DAY-LEVEL READINGS — USE THE TIMELINE
@@ -844,6 +857,21 @@ GRUNDREGELN
selbst willen. Würde ein Anstoß nur dem Drängen dienen, lass ihn weg und
spiegele stattdessen. Du informierst und unterstützt eine Entscheidung, die
seine bleibt; du manipulierst sie nie.
+21. Beschreibe HealthLog ehrlich, wenn der Nutzer fragt, was du bist, was
+ diese App ist oder wohin seine Daten gehen. HealthLog ist ein selbst
+ gehostetes, persönliches Werkzeug zur Gesundheitsdokumentation und zum
+ Wohlbefinden — es hilft dem Nutzer, seine EIGENEN Daten zu erfassen, zu
+ visualisieren und zu exportieren. Es ist KEIN Medizinprodukt und
+ diagnostiziert, behandelt, heilt oder verhindert KEINE Krankheit; du bist
+ ein informativer Begleiter, kein Arzt, und was du anbietest, ist keine
+ medizinische Beratung. Zu den Daten: Die Gesundheitsdaten des Nutzers
+ bleiben auf seiner eigenen Instanz, im Ruhezustand verschlüsselt; die KI
+ läuft über einen vom Nutzer mitgebrachten Anbieter-Schlüssel oder ein
+ lokales Modell, sodass nichts seine Instanz über den konfigurierten
+ Anbieter hinaus verlässt, und es gibt keine Drittanbieter-Analyse seiner
+ Gesundheitsdaten. Sag das klar und ruhig, wenn es passt; stell es nie als
+ vertraulich wie bei einer Ärztin dar und verweise Diagnose-, Behandlungs-
+ und Dosisfragen weiterhin an medizinisches Fachpersonal.
TAGES-LEVEL-MESSWERTE — NUTZE DIE TIMELINE
From 0224abc4196f94344cd12269a1f34c686559da31 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 28 Jun 2026 17:43:43 +0200
Subject: [PATCH 71/94] docs: bring README and docs current for v1.25
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reflect the v1.25 surface without touching the changelog or version
numbers.
- README highlights now cover the new clinical signals (respiratory
rate, the opt-in PHQ-9 / GAD-7 mental-wellbeing self-checks with calm
crisis handling, grip strength, pain score, waist / body measurements,
longevity labs), the signal registry that wires each one across charts,
correlations, the coach, FHIR, and the API at once, structured records
(allergies, family history; immunizations noted out of scope), the
opt-in environmental-context module, inbound document extraction
(transcribe, never interpret), and the self-description posture (a
record-keeping and wellness tool, not a medical device).
- ai-providers.md gains a "what the Coach is — and isn't" section: the
non-diagnostic standard and the calm proactive morning check-in
behaviour.
- mcp-capabilities.md notes that the new measurement signals reach the
assistant through the existing metric and labs tools via the registry,
that the mental-wellbeing instruments / records / documents are
deliberately not exposed over MCP, and ties the grounding contract to
the project-wide self-description standard.
- getting-started.md lists the environmental module's Open-Meteo egress
as an opt-in outbound dependency.
---
README.md | 9 +++++++--
docs/api/mcp-capabilities.md | 18 +++++++++++++++++-
docs/integrations/ai-providers.md | 24 ++++++++++++++++++++++++
docs/self-hosting/getting-started.md | 4 ++++
4 files changed, 52 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index f0f168074..1a6994b5f 100644
--- a/README.md
+++ b/README.md
@@ -28,14 +28,19 @@
HealthLog is a self-hosted health tracking app (PWA + native iOS client) that runs from a single `docker compose up` on a NAS, homelab, or small VPS. It brings weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications onto one timeline — synced from the devices you already own — and keeps everything encrypted at rest on hardware you control. No vendor cloud, no subscription, no telemetry.
+HealthLog is a personal health record-keeping and wellness tool. It is not a medical device and does not diagnose, treat, cure, or prevent any disease — it helps you log, visualise, and export your own data, and leaves clinical judgement to a clinician.
+
Try the [live demo](https://demo.healthlog.dev) to see a working install — sign in with `demo` / `demo123demo123`.
## Highlights
-- **Every vital on one timeline.** Weight, blood pressure, pulse, glucose, body composition, sleep, SpO₂, mood, and cycle tracking — charted with trends, moving averages, and clinical reference ranges (ESH 2023, ADA 2024), overridable with the targets your clinician set.
+- **Every vital on one timeline.** Weight, blood pressure, pulse, glucose, body composition, sleep, SpO₂, respiratory rate, mood, and cycle tracking — charted with trends, moving averages, and clinical reference ranges (ESH 2023, ADA 2024), overridable with the targets your clinician set.
+- **Clinical signals, opt-in and additive.** Validated mental-wellbeing self-checks (PHQ-9, GAD-7) that sit beside mood tracking with calm crisis-resource handling, grip strength, a 0–10 pain score, waist and body measurements, and a longevity lab panel — each added through one signal registry, so a new signal wires into charts, correlations, the coach, the FHIR map, and the API at once.
+- **Records and the documents you're handed.** Structured allergies and family history (encrypted), lab values you key in or scan, and inbound documents — discharge letters and reports — read to extract the facts they state (values, codes, dates) for you to review and confirm. HealthLog transcribes what a document says; it never interprets or diagnoses from it. (Immunization records are deliberately out of scope.)
+- **Context that explains the numbers.** An opt-in environmental module folds weather, daylight, and temperature in as influence signals (winter darkness against mood, heat against blood pressure) using a home location you set, so a pattern has somewhere to point.
- **Your devices, one server.** Withings, WHOOP, Oura and Polar sync over OAuth2, Fitbit connects experimentally over the Fitbit Web API, an Apple Health `export.zip` folds your full history in, and the native iOS app streams HealthKit live. A per-metric source priority decides which reading is canonical when wearables overlap.
- **Medication tracking that tells the truth.** Flexible schedules (weekly injections, weekday-only, intervals, PRN, cyclic), a configurable intake window per dose, and a traceable dose history where every slot reads taken, late, skipped, or missed — the same ledger the compliance rate is computed from, so the percentage can never disagree with the timeline.
-- **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements, with proactive check-ins. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so nothing leaves your network.
+- **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements, with calm proactive check-ins it suppresses when there's nothing genuine to say. The coach describes your data, never diagnoses. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so nothing leaves your network.
- **Clinician-ready output.** A doctor-report PDF generated client-side, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit.
- **Private by construction.** AES-256-GCM encryption at rest with zero-downtime key rotation, passkey login, server-side sessions, strict CSP — and no third-party tracking anywhere.
- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history, English and German end to end.
diff --git a/docs/api/mcp-capabilities.md b/docs/api/mcp-capabilities.md
index 8c0b6dd8e..644cc8fa9 100644
--- a/docs/api/mcp-capabilities.md
+++ b/docs/api/mcp-capabilities.md
@@ -28,7 +28,10 @@ them up front):
4. **Data and context only.** The server ships facts; it never returns a
diagnosis, clinical verdict, risk score, or treatment change. The
assistant narrates the facts and leaves clinical judgement to the
- user's clinician.
+ user's clinician. This mirrors HealthLog's project-wide self-description
+ standard: the app is a personal health record-keeping and wellness tool,
+ not a medical device, and nothing it surfaces — here or in the UI —
+ diagnoses or treats.
Discover before fetching: call `list_metrics` (or read the
`healthlog://measurements/inventory` resource) first to see what exists,
@@ -95,6 +98,19 @@ Every read tool is annotated read-only / non-destructive / idempotent /
closed-world — the read-only guarantee is structural (only read tools are
registered for a read session), and the annotation merely advertises it.
+The v1.25 clinical-signal additions reach the assistant through these same
+tools rather than new tool names: respiratory rate, grip strength, the 0–10
+pain score, and waist / body measurements appear via `list_metrics` →
+`get_metric_series` / `get_metrics`, and the longevity lab markers via
+`get_labs`. A new signal is declared once in the in-app signal registry and
+shows up across the metric tools, correlations, and the FHIR map together,
+so the MCP surface never advertises a signal the record can't return. The
+opt-in mental-wellbeing instruments (PHQ-9, GAD-7), the structured records
+(allergies, family history), and the inbound-document store are deliberately
+**not** exposed over MCP: the instrument answers are kept out of the
+assistant by design, and the document pipeline extracts facts for the user
+to confirm in-app rather than streaming them to a model.
+
## Resources
Read-only context the assistant can attach alongside tool results. Like
diff --git a/docs/integrations/ai-providers.md b/docs/integrations/ai-providers.md
index 1b1600b7e..2cdb85c7b 100644
--- a/docs/integrations/ai-providers.md
+++ b/docs/integrations/ai-providers.md
@@ -198,6 +198,30 @@ The Coach drawer surfaces a "Local" badge when generation resolves
to the local provider, so users who pinned a local endpoint can see
at a glance that their context stayed on the host.
+## What the Coach is — and isn't
+
+The Coach is an informational assistant that works from your own
+logged data. It is not a doctor, what it says is not medical advice,
+and it does not diagnose, prescribe, or change treatment — those
+boundaries are written into its system prompt as safety contracts, not
+left to chance. It describes patterns observationally ("your resting
+heart rate has been rising over 14 days"), never as a verdict about a
+named condition, and it defers diagnosis, dose, and drug-interaction
+questions to a healthcare professional. This is HealthLog's
+project-wide self-description standard: a personal health
+record-keeping and wellness tool, not a medical device. The same
+non-diagnostic framing governs the daily briefing, the insights, and
+every surface that turns your data into words.
+
+The Coach can also reach out proactively — a calm, once-a-day morning
+check-in when there is something genuine to surface (a recovery trend
+easing off, blood pressure running a touch high). The nudge is warm
+and localized, references the focus by category rather than quoting
+your own words back, and stays silent when there is nothing real to
+say or when you have engaged the Coach recently. It is an invitation,
+never a streak-guilt prompt, and it inherits the same non-diagnostic
+boundaries as the chat.
+
## Caching contract
Two caches sit in front of every provider call so generation budgets
diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md
index 03135bd6f..6e59c8a7f 100644
--- a/docs/self-hosting/getting-started.md
+++ b/docs/self-hosting/getting-started.md
@@ -16,6 +16,10 @@ already installed, longer if a TLS-fronted public hostname is in scope
hold a 1.5 GB upload in `/tmp` while it parses.
- **Outbound HTTPS** to `ghcr.io` (image pulls) plus whichever
integration endpoints you plan to enable (Withings, OpenAI, etc.).
+ The opt-in environmental-context module additionally reaches
+ `api.open-meteo.com` for weather and daylight; it stays off until you
+ enable it and set a home location, and every outbound call goes
+ through the SSRF-guarded egress wrapper.
- **Optional but recommended:** a reverse proxy that terminates TLS
and forwards to `http://localhost:3000` (Caddy, Traefik, Nginx
Proxy Manager, Coolify, or bare Nginx — see `reverse-proxy.md`).
From e3342dc19ef76acc29d84fa6f9f783780817385a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 28 Jun 2026 18:12:51 +0200
Subject: [PATCH 72/94] fix(documents): encrypt staged extracted-fact PHI at
rest
The inbound-document store encrypts the raw upload
(`inbound_documents.content_encrypted`) because it is sensitive clinical
data, but the per-fact staging columns persisted the same class of data in
the clear: `extracted_facts.data_json` held the transcribed clinical values
(diagnosis text, lab values, medication names, stated codes) and
`provenance_json` held the verbatim source span (up to 2000 chars copied
straight out of the report). The at-rest guarantee was defeated for staged
facts, which persist through PENDING and are retained after the decision.
Convert both columns to the AES-256-GCM `*_encrypted` BYTEA convention shared
with the note columns, going through the same Bytes codec. The upload and
fact-edit paths encrypt on write; the confirm path decrypts at commit time to
write into the normal structured stores, so the review-then-confirm flow is
unchanged. Reads are fail-closed (a bad key id throws).
Register both columns in the encrypted-columns registry and the key-rotation
script so a dropped legacy key cannot strand them (the guard test enforces
both). Migration 0220 swaps the plaintext JSONB columns for the encrypted
BYTEA columns; pre-confirm staged rows are ephemeral and cannot be
re-encrypted in SQL, so any left by a pre-release build are dropped rather
than retained as plaintext (their parent document keeps its encrypted
original).
---
.../migration.sql | 42 +++++++++++++++++++
prisma/schema.prisma | 12 ++++--
scripts/rotate-encryption-key.ts | 16 +++++++
.../inbound/[id]/facts/[factId]/route.ts | 6 +--
src/app/api/documents/inbound/route.ts | 8 ++--
src/lib/crypto/encrypted-columns.ts | 7 ++++
src/lib/documents/commit.ts | 19 ++++-----
src/lib/documents/store.ts | 36 +++++++++++++++-
8 files changed, 123 insertions(+), 23 deletions(-)
create mode 100644 prisma/migrations/0220_v125_extracted_facts_encrypt/migration.sql
diff --git a/prisma/migrations/0220_v125_extracted_facts_encrypt/migration.sql b/prisma/migrations/0220_v125_extracted_facts_encrypt/migration.sql
new file mode 100644
index 000000000..3c5a70d1e
--- /dev/null
+++ b/prisma/migrations/0220_v125_extracted_facts_encrypt/migration.sql
@@ -0,0 +1,42 @@
+-- v1.25 (W-DOCS-IN, security) — encrypt the staged extracted-fact payloads.
+--
+-- The inbound-document store already encrypts the raw upload
+-- (`inbound_documents.content_encrypted`) precisely because it is sensitive
+-- PHI. The per-fact staging columns `extracted_facts.data_json` /
+-- `provenance_json`, however, persisted the SAME class of data in the clear:
+-- the FHIR-staged clinical values (diagnosis text, lab values, medication
+-- names, stated codes) and the verbatim source span (up to 2000 chars copied
+-- straight out of the discharge letter / lab report). That defeated the
+-- at-rest guarantee for the feature. This migration converts both columns to
+-- the AES-256-GCM `*_encrypted` BYTEA convention shared with the note columns;
+-- the application encrypts on write and decrypts at confirm time.
+--
+-- Transactional + fail-closed: encryption is application-layer, so the plaintext
+-- JSONB cannot be re-encrypted in SQL. Staged facts are EPHEMERAL pre-confirm
+-- (nothing committed until the user approves them) and this feature has not
+-- shipped, so any rows staged by a pre-release build carry plaintext PHI we
+-- will not retain — they are dropped here rather than left unencrypted, and
+-- their parent document keeps its encrypted original and can be re-extracted.
+-- The whole migration runs in one transaction, so a failure rolls back wholly.
+--
+-- Reversibility (down) — re-add the plaintext columns (data is not recoverable):
+-- ALTER TABLE "extracted_facts" ADD COLUMN "data_json" JSONB;
+-- ALTER TABLE "extracted_facts" ADD COLUMN "provenance_json" JSONB;
+-- ALTER TABLE "extracted_facts" DROP COLUMN "data_encrypted";
+-- ALTER TABLE "extracted_facts" DROP COLUMN "provenance_encrypted";
+
+-- Add the encrypted replacements (nullable during the swap).
+ALTER TABLE "extracted_facts" ADD COLUMN IF NOT EXISTS "data_encrypted" BYTEA;
+ALTER TABLE "extracted_facts" ADD COLUMN IF NOT EXISTS "provenance_encrypted" BYTEA;
+
+-- Drop any pre-release staged rows: their plaintext payload cannot be
+-- re-encrypted in SQL, and a pre-confirm fact carries no committed data.
+DELETE FROM "extracted_facts" WHERE "data_encrypted" IS NULL;
+
+-- Enforce the at-rest guarantee: every staged fact now carries ciphertext.
+ALTER TABLE "extracted_facts" ALTER COLUMN "data_encrypted" SET NOT NULL;
+ALTER TABLE "extracted_facts" ALTER COLUMN "provenance_encrypted" SET NOT NULL;
+
+-- Drop the plaintext columns so no verbatim clinical span survives.
+ALTER TABLE "extracted_facts" DROP COLUMN IF EXISTS "data_json";
+ALTER TABLE "extracted_facts" DROP COLUMN IF EXISTS "provenance_json";
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 3f82e6ac2..67e260d4d 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -5621,11 +5621,17 @@ model ExtractedFact {
/// The FHIR-staged fields, STATED status only, shaped per `factType`
/// (Condition / Observation / MedicationStatement). Codes (SNOMED / ICD-10
/// / LOINC / RxNorm / ATC) are present only when the document writes them.
- dataJson Json @map("data_json")
+ /// Stored encrypted: the JSON payload (diagnosis text, lab values,
+ /// medication names, stated codes) is clinical PHI transcribed verbatim from
+ /// the source document, so it rides the AES-256-GCM `*_encrypted` BYTEA
+ /// convention shared with the note columns rather than as plaintext JSONB.
+ dataEncrypted Bytes @map("data_encrypted")
/// Per-field provenance: the verbatim source span the value came from, an
/// optional page index, and per-field confidence. Makes "extract what's
- /// written" auditable against the original.
- provenanceJson Json @map("provenance_json")
+ /// written" auditable against the original. The source span is a verbatim
+ /// excerpt of the clinical document (PHI), so it is stored encrypted with the
+ /// same codec as `dataEncrypted`.
+ provenanceEncrypted Bytes @map("provenance_encrypted")
/// The id of the structured-store row created when the fact was approved
/// (LabResult / IllnessEpisode / Medication), or NULL while PENDING /
/// REJECTED.
diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts
index 2308f6484..648fbb4f2 100644
--- a/scripts/rotate-encryption-key.ts
+++ b/scripts/rotate-encryption-key.ts
@@ -486,6 +486,22 @@ async function main() {
prisma.inboundDocument,
),
);
+ // The staged extracted-fact payloads: the FHIR-staged clinical values and the
+ // verbatim source-span provenance. Both NOT NULL, so every staged row rotates.
+ results.push(
+ await rotateBytesColumn(
+ "ExtractedFact",
+ "dataEncrypted",
+ prisma.extractedFact,
+ ),
+ );
+ results.push(
+ await rotateBytesColumn(
+ "ExtractedFact",
+ "provenanceEncrypted",
+ prisma.extractedFact,
+ ),
+ );
console.log("\n=== Rotation summary ===");
let totalRotated = 0;
diff --git a/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts b/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts
index 533c88318..042b1387e 100644
--- a/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts
+++ b/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts
@@ -17,8 +17,8 @@ import {
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
-import { prisma, toJson } from "@/lib/db";
-import { serialiseFact } from "@/lib/documents/store";
+import { prisma } from "@/lib/db";
+import { encryptFactData, serialiseFact } from "@/lib/documents/store";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import {
@@ -113,7 +113,7 @@ export const PATCH = apiHandler(
const updated = await prisma.extractedFact.update({
where: { id: fact.id },
data: {
- dataJson: toJson(editToFactData(parsed.data)),
+ dataEncrypted: encryptFactData(editToFactData(parsed.data)),
// The values are now user-asserted — the fail-closed gate is cleared.
needsReview: false,
},
diff --git a/src/app/api/documents/inbound/route.ts b/src/app/api/documents/inbound/route.ts
index b301b06be..4b8fe1fc6 100644
--- a/src/app/api/documents/inbound/route.ts
+++ b/src/app/api/documents/inbound/route.ts
@@ -40,7 +40,7 @@ import {
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import { auditLog } from "@/lib/auth/audit";
-import { prisma, toJson } from "@/lib/db";
+import { prisma } from "@/lib/db";
import {
InboundExtractError,
runInboundExtraction,
@@ -48,6 +48,8 @@ import {
} from "@/lib/documents/extract";
import {
encryptDocumentToBytes,
+ encryptFactData,
+ encryptFactProvenance,
serialiseDocument,
} from "@/lib/documents/store";
import {
@@ -108,8 +110,8 @@ async function persistExtraction(args: {
status: "PENDING" as const,
confidence: f.confidence,
needsReview: f.needsReview,
- dataJson: toJson(f.data),
- provenanceJson: toJson(f.provenance),
+ dataEncrypted: encryptFactData(f.data),
+ provenanceEncrypted: encryptFactProvenance(f.provenance),
})),
},
},
diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts
index 78ec9895a..db33126bb 100644
--- a/src/lib/crypto/encrypted-columns.ts
+++ b/src/lib/crypto/encrypted-columns.ts
@@ -179,6 +179,13 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
// AES-256-GCM string → UTF-8 bytes. The most sensitive blob in the wave
// (a full clinical document); never logged, never in wide-event meta.
{ model: "InboundDocument", field: "contentEncrypted", kind: "bytes" },
+ // The staged extracted-fact payloads: the FHIR-staged clinical values
+ // (diagnosis text / lab values / medication names / stated codes) and the
+ // verbatim source-span provenance. Both are PHI transcribed from the source
+ // document, so the staging rows carry the same at-rest guarantee as the
+ // document itself rather than persisting as plaintext JSONB.
+ { model: "ExtractedFact", field: "dataEncrypted", kind: "bytes" },
+ { model: "ExtractedFact", field: "provenanceEncrypted", kind: "bytes" },
] as const;
/** Stable `Model.field` key for a registry entry. */
diff --git a/src/lib/documents/commit.ts b/src/lib/documents/commit.ts
index 9c0fd952a..5431bed47 100644
--- a/src/lib/documents/commit.ts
+++ b/src/lib/documents/commit.ts
@@ -18,6 +18,7 @@
*/
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { prisma } from "@/lib/db";
+import { decryptFactData } from "@/lib/documents/store";
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
import type { ExtractedFact } from "@/generated/prisma/client";
import type {
@@ -152,20 +153,14 @@ export async function commitApprovedFact(
userId: string,
fact: ExtractedFact,
): Promise {
+ // Decrypt the staged payload at confirm time to write it into the normal
+ // structured store. Fail-closed: a bad key id throws and aborts the commit.
+ const data = decryptFactData(fact.dataEncrypted);
if (fact.factType === "OBSERVATION") {
- return commitObservation(
- userId,
- fact.dataJson as unknown as ObservationFactData,
- );
+ return commitObservation(userId, data as ObservationFactData);
}
if (fact.factType === "CONDITION") {
- return commitCondition(
- userId,
- fact.dataJson as unknown as ConditionFactData,
- );
+ return commitCondition(userId, data as ConditionFactData);
}
- return commitMedication(
- userId,
- fact.dataJson as unknown as MedicationStatementFactData,
- );
+ return commitMedication(userId, data as MedicationStatementFactData);
}
diff --git a/src/lib/documents/store.ts b/src/lib/documents/store.ts
index c05964c41..5701b7ad9 100644
--- a/src/lib/documents/store.ts
+++ b/src/lib/documents/store.ts
@@ -8,6 +8,7 @@
*/
import { Buffer } from "node:buffer";
+import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { decrypt, encrypt } from "@/lib/crypto";
import type {
ExtractedFact,
@@ -40,6 +41,37 @@ export function decryptDocumentFromBytes(buf: Uint8Array): Buffer {
return Buffer.from(base64, "base64");
}
+/**
+ * Encrypt a staged fact's FHIR-staged payload into the `Bytes` column the
+ * schema stores. The structured clinical values (diagnosis text, lab values,
+ * medication names, stated codes) are PHI, so they ride the shared AES-256-GCM
+ * note codec (JSON → `encrypt()` string → UTF-8 bytes) rather than plaintext
+ * JSONB.
+ */
+export function encryptFactData(data: FactData): Uint8Array {
+ return encryptToBytes(JSON.stringify(data));
+}
+
+/** Decrypt a staged fact's payload back to its DTO shape. Throws on bad key. */
+export function decryptFactData(buf: Uint8Array): FactData {
+ return JSON.parse(decryptFromBytes(buf)) as FactData;
+}
+
+/**
+ * Encrypt a staged fact's provenance. The verbatim source span is a clinical
+ * document excerpt (PHI); encrypted with the same codec as the fact data.
+ */
+export function encryptFactProvenance(
+ provenance: FactProvenance,
+): Uint8Array {
+ return encryptToBytes(JSON.stringify(provenance));
+}
+
+/** Decrypt a staged fact's provenance back to its DTO shape. */
+export function decryptFactProvenance(buf: Uint8Array): FactProvenance {
+ return JSON.parse(decryptFromBytes(buf)) as FactProvenance;
+}
+
/** Map a persisted document row (+ counts) to the list/detail DTO. */
export function serialiseDocument(
doc: InboundDocument,
@@ -72,8 +104,8 @@ export function serialiseFact(fact: ExtractedFact): ExtractedFactDto {
status: fact.status as ExtractedFactStatus,
confidence: fact.confidence,
needsReview: fact.needsReview,
- data: fact.dataJson as unknown as FactData,
- provenance: fact.provenanceJson as unknown as FactProvenance,
+ data: decryptFactData(fact.dataEncrypted),
+ provenance: decryptFactProvenance(fact.provenanceEncrypted),
committedRecordId: fact.committedRecordId,
committedRecordType: fact.committedRecordType,
};
From 15e740bc2b16af7da85c7bded75e481c7a7da074 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 28 Jun 2026 18:25:16 +0200
Subject: [PATCH 73/94] i18n(mental-health): ship validated PHQ-9/GAD-7
translations and localize crisis copy
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The PHQ-9/GAD-7 item wordings rendered in English for es/fr/it/pl users
(de was already validated). Replace them with the officially validated,
public-domain Pfizer/PRIME-MD localized screener wordings (distributed via
the MAPI Research Trust) rather than ad-hoc translations — re-wording a
validated instrument invalidates the score. Localize the crisis-resource
copy too: translate the descriptive parentheticals while keeping the org
proper-nouns (TelefonSeelsorge, Nummer gegen Kummer, krisenchat,
Find a Helpline) and the US brand lines (988, Crisis Text Line) verbatim.
Add the new mental-wellbeing module label/description and the nav label
across all six locales.
---
messages/de.json | 7 +++-
messages/en.json | 7 +++-
messages/es.json | 49 +++++++++++++++-------------
messages/fr.json | 49 +++++++++++++++-------------
messages/it.json | 49 +++++++++++++++-------------
messages/pl.json | 49 +++++++++++++++-------------
src/lib/mental-health/instruments.ts | 5 ++-
7 files changed, 124 insertions(+), 91 deletions(-)
diff --git a/messages/de.json b/messages/de.json
index dc0e00086..b6ec0e31c 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -240,7 +240,8 @@
"labs": "Laborwerte",
"vorsorge": "Vorsorge",
"illness": "Krankheit",
- "documents": "Dokumente"
+ "documents": "Dokumente",
+ "mentalWellbeing": "Seelisches Wohlbefinden"
},
"auth": {
"login": "Anmelden",
@@ -7267,6 +7268,10 @@
"inboundDocuments": {
"label": "Eingehende Dokumente",
"description": "Lade einen Arztbericht oder Entlassbrief hoch; prüfe die erkannten Angaben, bevor sie gespeichert werden. Standardmäßig aus."
+ },
+ "mentalHealth": {
+ "label": "Seelisches Wohlbefinden",
+ "description": "Freiwillige PHQ-9- und GAD-7-Selbsttests ergänzend zum Stimmungstagebuch. Standardmäßig aus; Antworten sind verschlüsselt und bleiben aus dem KI-Coach heraus."
}
},
"illness": {
diff --git a/messages/en.json b/messages/en.json
index e6e776db1..d767ff1bf 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -240,7 +240,8 @@
"labs": "Labs",
"vorsorge": "Checkups",
"illness": "Illness",
- "documents": "Documents"
+ "documents": "Documents",
+ "mentalWellbeing": "Mental wellbeing"
},
"auth": {
"login": "Sign in",
@@ -7267,6 +7268,10 @@
"inboundDocuments": {
"label": "Inbound documents",
"description": "Upload a doctor report or discharge letter; review the extracted facts before they're saved. Off by default."
+ },
+ "mentalHealth": {
+ "label": "Mental wellbeing",
+ "description": "Opt-in PHQ-9 and GAD-7 screeners beside mood tracking. Off by default; answers are encrypted and kept out of the AI Coach."
}
},
"illness": {
diff --git a/messages/es.json b/messages/es.json
index b9687c4c0..95a5c328d 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -240,7 +240,8 @@
"labs": "Análisis",
"vorsorge": "Revisiones",
"illness": "Enfermedad",
- "documents": "Documentos"
+ "documents": "Documentos",
+ "mentalWellbeing": "Bienestar mental"
},
"auth": {
"login": "Iniciar sesión",
@@ -7267,6 +7268,10 @@
"inboundDocuments": {
"label": "Documentos entrantes",
"description": "Sube un informe médico o un alta; revisa los datos extraídos antes de guardarlos. Desactivado de forma predeterminada."
+ },
+ "mentalHealth": {
+ "label": "Bienestar mental",
+ "description": "Cuestionarios PHQ-9 y GAD-7 opcionales, junto al registro de ánimo. Desactivado por defecto; las respuestas se cifran y se mantienen fuera del Coach de IA."
}
},
"illness": {
@@ -7567,41 +7572,41 @@
},
"items": {
"phq9": {
- "1": "Little interest or pleasure in doing things",
- "2": "Feeling down, depressed, or hopeless",
- "3": "Trouble falling or staying asleep, or sleeping too much",
- "4": "Feeling tired or having little energy",
- "5": "Poor appetite or overeating",
- "6": "Feeling bad about yourself — or that you are a failure or have let yourself or your family down",
- "7": "Trouble concentrating on things, such as reading the newspaper or watching television",
- "8": "Moving or speaking so slowly that other people could have noticed. Or the opposite — being so fidgety or restless that you have been moving around a lot more than usual",
- "9": "Thoughts that you would be better off dead, or of hurting yourself in some way"
+ "1": "Tener poco interés o placer en hacer las cosas",
+ "2": "Sentirse desanimado/a, deprimido/a o sin esperanza",
+ "3": "Tener problemas para dormir o mantenerse dormido/a, o dormir demasiado",
+ "4": "Sentirse cansado/a o tener poca energía",
+ "5": "Tener poco apetito o comer en exceso",
+ "6": "Sentirse mal con usted mismo/a, o sentir que es un fracaso o que se ha fallado a sí mismo/a o a su familia",
+ "7": "Tener dificultad para concentrarse en cosas tales como leer el periódico o ver la televisión",
+ "8": "¿Moverse o hablar tan despacio que otras personas podrían haberlo notado? O lo contrario: estar tan inquieto/a o agitado/a que se ha estado moviendo mucho más de lo habitual",
+ "9": "Pensar que estaría mejor muerto/a o en hacerse daño de alguna manera"
},
"gad7": {
- "1": "Feeling nervous, anxious, or on edge",
- "2": "Not being able to stop or control worrying",
- "3": "Worrying too much about different things",
- "4": "Trouble relaxing",
- "5": "Being so restless that it is hard to sit still",
- "6": "Becoming easily annoyed or irritable",
- "7": "Feeling afraid as if something awful might happen"
+ "1": "Sentirse nervioso/a, ansioso/a o muy alterado/a",
+ "2": "No poder dejar de preocuparse o no poder controlar la preocupación",
+ "3": "Preocuparse demasiado por diferentes cosas",
+ "4": "Tener dificultad para relajarse",
+ "5": "Estar tan inquieto/a que es difícil quedarse sentado/a tranquilamente",
+ "6": "Molestarse o irritarse fácilmente",
+ "7": "Sentir miedo como si algo terrible fuera a suceder"
}
},
"crisisResource": {
"telefonSeelsorge": {
- "name": "TelefonSeelsorge (24/7, free, confidential)"
+ "name": "TelefonSeelsorge (24/7, gratuito y confidencial)"
},
"nummerGegenKummer": {
- "name": "Nummer gegen Kummer (for young people)"
+ "name": "Nummer gegen Kummer (para jóvenes)"
},
"krisenchat": {
- "name": "krisenchat (24/7 chat, young people)"
+ "name": "krisenchat (chat 24/7, para jóvenes)"
},
"findahelpline": {
- "name": "Find a helpline (worldwide directory)"
+ "name": "Find a Helpline (directorio mundial)"
},
"euEmotionalSupport": {
- "name": "Emotional-support helpline"
+ "name": "Línea de apoyo emocional"
},
"lifeline988": {
"name": "988 Suicide & Crisis Lifeline"
diff --git a/messages/fr.json b/messages/fr.json
index 7df786db9..b08cedb2d 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -240,7 +240,8 @@
"labs": "Analyses",
"vorsorge": "Examens",
"illness": "Maladie",
- "documents": "Documents"
+ "documents": "Documents",
+ "mentalWellbeing": "Bien-être mental"
},
"auth": {
"login": "Se connecter",
@@ -7267,6 +7268,10 @@
"inboundDocuments": {
"label": "Documents entrants",
"description": "Importez un compte rendu médical ou une lettre de sortie ; vérifiez les données extraites avant de les enregistrer. Désactivé par défaut."
+ },
+ "mentalHealth": {
+ "label": "Bien-être mental",
+ "description": "Questionnaires PHQ-9 et GAD-7 facultatifs, en complément du suivi de l'humeur. Désactivé par défaut ; les réponses sont chiffrées et tenues à l'écart du Coach IA."
}
},
"illness": {
@@ -7567,41 +7572,41 @@
},
"items": {
"phq9": {
- "1": "Little interest or pleasure in doing things",
- "2": "Feeling down, depressed, or hopeless",
- "3": "Trouble falling or staying asleep, or sleeping too much",
- "4": "Feeling tired or having little energy",
- "5": "Poor appetite or overeating",
- "6": "Feeling bad about yourself — or that you are a failure or have let yourself or your family down",
- "7": "Trouble concentrating on things, such as reading the newspaper or watching television",
- "8": "Moving or speaking so slowly that other people could have noticed. Or the opposite — being so fidgety or restless that you have been moving around a lot more than usual",
- "9": "Thoughts that you would be better off dead, or of hurting yourself in some way"
+ "1": "Peu d'intérêt ou de plaisir à faire les choses",
+ "2": "Se sentir triste, déprimé(e) ou désespéré(e)",
+ "3": "Difficultés à s'endormir ou à rester endormi(e), ou dormir trop",
+ "4": "Se sentir fatigué(e) ou avoir peu d'énergie",
+ "5": "Avoir peu d'appétit ou manger trop",
+ "6": "Avoir une mauvaise opinion de soi-même — ou avoir le sentiment d'être nul(le) ou d'avoir déçu sa famille ou soi-même",
+ "7": "Avoir du mal à se concentrer, par exemple pour lire le journal ou regarder la télévision",
+ "8": "Bouger ou parler si lentement que les autres ont pu le remarquer. Ou au contraire, être si agité(e) que vous avez bougé beaucoup plus que d'habitude",
+ "9": "Penser qu'il vaudrait mieux mourir ou envisager de vous faire du mal d'une manière ou d'une autre"
},
"gad7": {
- "1": "Feeling nervous, anxious, or on edge",
- "2": "Not being able to stop or control worrying",
- "3": "Worrying too much about different things",
- "4": "Trouble relaxing",
- "5": "Being so restless that it is hard to sit still",
- "6": "Becoming easily annoyed or irritable",
- "7": "Feeling afraid as if something awful might happen"
+ "1": "Se sentir nerveux/nerveuse, anxieux/anxieuse ou tendu(e)",
+ "2": "Être incapable d'arrêter de vous inquiéter ou de contrôler vos inquiétudes",
+ "3": "Vous inquiéter trop à propos de choses diverses",
+ "4": "Avoir du mal à vous détendre",
+ "5": "Être si agité(e) qu'il est difficile de rester tranquille",
+ "6": "Devenir facilement contrarié(e) ou irritable",
+ "7": "Avoir peur que quelque chose d'épouvantable puisse arriver"
}
},
"crisisResource": {
"telefonSeelsorge": {
- "name": "TelefonSeelsorge (24/7, free, confidential)"
+ "name": "TelefonSeelsorge (24h/24, 7j/7, gratuit et confidentiel)"
},
"nummerGegenKummer": {
- "name": "Nummer gegen Kummer (for young people)"
+ "name": "Nummer gegen Kummer (pour les jeunes)"
},
"krisenchat": {
- "name": "krisenchat (24/7 chat, young people)"
+ "name": "krisenchat (chat 24h/24, 7j/7, pour les jeunes)"
},
"findahelpline": {
- "name": "Find a helpline (worldwide directory)"
+ "name": "Find a Helpline (annuaire mondial)"
},
"euEmotionalSupport": {
- "name": "Emotional-support helpline"
+ "name": "Ligne d'écoute et de soutien émotionnel"
},
"lifeline988": {
"name": "988 Suicide & Crisis Lifeline"
diff --git a/messages/it.json b/messages/it.json
index e4f800775..24b7ab6cc 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -240,7 +240,8 @@
"labs": "Esami",
"vorsorge": "Controlli",
"illness": "Malattia",
- "documents": "Documenti"
+ "documents": "Documenti",
+ "mentalWellbeing": "Benessere mentale"
},
"auth": {
"login": "Accedi",
@@ -7267,6 +7268,10 @@
"inboundDocuments": {
"label": "Documenti in entrata",
"description": "Carica un referto medico o una lettera di dimissione; rivedi i dati estratti prima di salvarli. Disattivato per impostazione predefinita."
+ },
+ "mentalHealth": {
+ "label": "Benessere mentale",
+ "description": "Questionari PHQ-9 e GAD-7 facoltativi, accanto al diario dell'umore. Disattivato per impostazione predefinita; le risposte sono cifrate e tenute fuori dal Coach IA."
}
},
"illness": {
@@ -7567,41 +7572,41 @@
},
"items": {
"phq9": {
- "1": "Little interest or pleasure in doing things",
- "2": "Feeling down, depressed, or hopeless",
- "3": "Trouble falling or staying asleep, or sleeping too much",
- "4": "Feeling tired or having little energy",
- "5": "Poor appetite or overeating",
- "6": "Feeling bad about yourself — or that you are a failure or have let yourself or your family down",
- "7": "Trouble concentrating on things, such as reading the newspaper or watching television",
- "8": "Moving or speaking so slowly that other people could have noticed. Or the opposite — being so fidgety or restless that you have been moving around a lot more than usual",
- "9": "Thoughts that you would be better off dead, or of hurting yourself in some way"
+ "1": "Scarso interesse o piacere nel fare le cose",
+ "2": "Sentirsi giù di morale, depresso/a o disperato/a",
+ "3": "Difficoltà ad addormentarsi o a mantenere il sonno, oppure dormire troppo",
+ "4": "Sentirsi stanco/a o avere poca energia",
+ "5": "Scarso appetito o mangiare troppo",
+ "6": "Avere una cattiva opinione di sé, o sentirsi un/una fallito/a o aver deluso se stesso/a o i propri familiari",
+ "7": "Difficoltà a concentrarsi, ad esempio nel leggere il giornale o nel guardare la televisione",
+ "8": "Muoversi o parlare così lentamente che altre persone potrebbero averlo notato? Oppure, al contrario, essere così agitato/a o irrequieto/a da muoversi molto più del solito",
+ "9": "Pensare che sarebbe meglio morire o di farsi del male in qualche modo"
},
"gad7": {
- "1": "Feeling nervous, anxious, or on edge",
- "2": "Not being able to stop or control worrying",
- "3": "Worrying too much about different things",
- "4": "Trouble relaxing",
- "5": "Being so restless that it is hard to sit still",
- "6": "Becoming easily annoyed or irritable",
- "7": "Feeling afraid as if something awful might happen"
+ "1": "Sentirsi nervoso/a, ansioso/a o teso/a",
+ "2": "Non riuscire a smettere di preoccuparsi o a controllare le preoccupazioni",
+ "3": "Preoccuparsi troppo per cose diverse",
+ "4": "Avere difficoltà a rilassarsi",
+ "5": "Essere così irrequieto/a da far fatica a stare seduto/a fermo/a",
+ "6": "Infastidirsi o irritarsi facilmente",
+ "7": "Avere paura come se potesse accadere qualcosa di terribile"
}
},
"crisisResource": {
"telefonSeelsorge": {
- "name": "TelefonSeelsorge (24/7, free, confidential)"
+ "name": "TelefonSeelsorge (24/7, gratuito e riservato)"
},
"nummerGegenKummer": {
- "name": "Nummer gegen Kummer (for young people)"
+ "name": "Nummer gegen Kummer (per i giovani)"
},
"krisenchat": {
- "name": "krisenchat (24/7 chat, young people)"
+ "name": "krisenchat (chat 24/7, per i giovani)"
},
"findahelpline": {
- "name": "Find a helpline (worldwide directory)"
+ "name": "Find a Helpline (elenco mondiale)"
},
"euEmotionalSupport": {
- "name": "Emotional-support helpline"
+ "name": "Linea di supporto emotivo"
},
"lifeline988": {
"name": "988 Suicide & Crisis Lifeline"
diff --git a/messages/pl.json b/messages/pl.json
index 52381c2ba..b16da9a79 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -240,7 +240,8 @@
"labs": "Wyniki badań",
"vorsorge": "Badania",
"illness": "Choroba",
- "documents": "Dokumenty"
+ "documents": "Dokumenty",
+ "mentalWellbeing": "Dobrostan psychiczny"
},
"auth": {
"login": "Zaloguj się",
@@ -7267,6 +7268,10 @@
"inboundDocuments": {
"label": "Dokumenty przychodzące",
"description": "Prześlij raport lekarski lub wypis; sprawdź wyodrębnione dane przed zapisaniem. Domyślnie wyłączone."
+ },
+ "mentalHealth": {
+ "label": "Dobrostan psychiczny",
+ "description": "Dobrowolne testy PHQ-9 i GAD-7 obok dziennika nastroju. Domyślnie wyłączone; odpowiedzi są szyfrowane i pozostają poza Coachem AI."
}
},
"illness": {
@@ -7567,41 +7572,41 @@
},
"items": {
"phq9": {
- "1": "Little interest or pleasure in doing things",
- "2": "Feeling down, depressed, or hopeless",
- "3": "Trouble falling or staying asleep, or sleeping too much",
- "4": "Feeling tired or having little energy",
- "5": "Poor appetite or overeating",
- "6": "Feeling bad about yourself — or that you are a failure or have let yourself or your family down",
- "7": "Trouble concentrating on things, such as reading the newspaper or watching television",
- "8": "Moving or speaking so slowly that other people could have noticed. Or the opposite — being so fidgety or restless that you have been moving around a lot more than usual",
- "9": "Thoughts that you would be better off dead, or of hurting yourself in some way"
+ "1": "Niewielkie zainteresowanie lub odczuwanie przyjemności z wykonywania czynności",
+ "2": "Uczucie przygnębienia, depresji lub beznadziejności",
+ "3": "Kłopoty z zaśnięciem lub utrzymaniem snu albo zbyt długi sen",
+ "4": "Uczucie zmęczenia lub brak energii",
+ "5": "Brak apetytu lub przejadanie się",
+ "6": "Złe myślenie o sobie — poczucie, że jest się nieudacznikiem lub że zawiodło się siebie albo rodzinę",
+ "7": "Trudności ze skupieniem się, na przykład podczas czytania gazety lub oglądania telewizji",
+ "8": "Poruszanie się lub mówienie tak wolno, że inni mogli to zauważyć? Albo przeciwnie — niepokój lub pobudzenie powodujące, że poruszasz się znacznie więcej niż zwykle",
+ "9": "Myśli, że lepiej byłoby umrzeć albo chęć zrobienia sobie w jakiś sposób krzywdy"
},
"gad7": {
- "1": "Feeling nervous, anxious, or on edge",
- "2": "Not being able to stop or control worrying",
- "3": "Worrying too much about different things",
- "4": "Trouble relaxing",
- "5": "Being so restless that it is hard to sit still",
- "6": "Becoming easily annoyed or irritable",
- "7": "Feeling afraid as if something awful might happen"
+ "1": "Odczuwanie zdenerwowania, lęku lub napięcia",
+ "2": "Niemożność przerwania lub kontrolowania zamartwiania się",
+ "3": "Zbytnie zamartwianie się różnymi sprawami",
+ "4": "Trudności z odprężeniem się",
+ "5": "Niepokój tak duży, że trudno usiedzieć w miejscu",
+ "6": "Łatwe wpadanie w rozdrażnienie lub irytację",
+ "7": "Odczuwanie lęku, jakby miało się zdarzyć coś strasznego"
}
},
"crisisResource": {
"telefonSeelsorge": {
- "name": "TelefonSeelsorge (24/7, free, confidential)"
+ "name": "TelefonSeelsorge (całodobowo, bezpłatnie i poufnie)"
},
"nummerGegenKummer": {
- "name": "Nummer gegen Kummer (for young people)"
+ "name": "Nummer gegen Kummer (dla młodych ludzi)"
},
"krisenchat": {
- "name": "krisenchat (24/7 chat, young people)"
+ "name": "krisenchat (czat całodobowy, dla młodych ludzi)"
},
"findahelpline": {
- "name": "Find a helpline (worldwide directory)"
+ "name": "Find a Helpline (światowy katalog)"
},
"euEmotionalSupport": {
- "name": "Emotional-support helpline"
+ "name": "Telefon wsparcia emocjonalnego"
},
"lifeline988": {
"name": "988 Suicide & Crisis Lifeline"
diff --git a/src/lib/mental-health/instruments.ts b/src/lib/mental-health/instruments.ts
index aa43364c3..d54b7ac44 100644
--- a/src/lib/mental-health/instruments.ts
+++ b/src/lib/mental-health/instruments.ts
@@ -6,7 +6,10 @@
* for PHQ-9; Spitzer 2006 for GAD-7). This module owns the canonical NUMERIC
* contract; the human wording lives in `messages/*.json` under
* `mentalHealth.items.*` (officially-validated translations per locale, English
- * fallback otherwise — re-wording invalidates the score).
+ * fallback otherwise — re-wording invalidates the score). The de/es/fr/it/pl
+ * item wordings are the public-domain Pfizer/PRIME-MD validated localized
+ * versions distributed via the MAPI Research Trust (de: Löwe/Gräfe et al.); they
+ * are NOT machine-translated. See `.planning/research/v125/clinical-instruments.md`.
*
* PHQ-9 item 9 (index 8) is the passive self-harm ideation screen and is the
* safety-critical field (see `crisis-resources.ts` + the API handler): any
From f62ba50ff07ec40d77ba1c237dd2a7f88f1c41d7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 28 Jun 2026 18:25:27 +0200
Subject: [PATCH 74/94] feat(mental-health): gate the PHQ-9/GAD-7 check-in
behind an opt-in module
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The mental-wellbeing screener surface shipped built but unreachable: no nav
entry, no module key, ungated. Add a `mentalHealth` module key (opt-in,
default OFF — a depression/anxiety self-assessment is at least as sensitive
as mood) to the registry, the module-hub icon map, and the exhaustive module
maps. Give the check-in a nav home at /insights/mental-wellbeing, born-gated
on the module so it appears only once the account opts in, and redirect the
page home on a direct hit when the module is off.
Item answers stay encrypted and off the AI Coach / MCP regardless of the
toggle; this only controls whether the surface is visible and reachable.
---
src/app/insights/mental-wellbeing/page.tsx | 37 ++++++++++++++++++-
src/components/layout/nav-model.ts | 14 +++++++
src/components/settings/modules-section.tsx | 3 ++
src/lib/dashboard/__tests__/snapshot.test.ts | 1 +
src/lib/modules/__tests__/gate.test.ts | 19 +++++++---
src/lib/modules/registry.ts | 20 ++++++++++
.../doctor-report-sections.test.ts | 1 +
7 files changed, 88 insertions(+), 7 deletions(-)
diff --git a/src/app/insights/mental-wellbeing/page.tsx b/src/app/insights/mental-wellbeing/page.tsx
index fb6f1039d..778255ddf 100644
--- a/src/app/insights/mental-wellbeing/page.tsx
+++ b/src/app/insights/mental-wellbeing/page.tsx
@@ -1,16 +1,51 @@
"use client";
+import { useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { Loader2 } from "lucide-react";
+
+import { useAuth } from "@/hooks/use-auth";
import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing";
/**
- * v1.25 — `/insights/mental-wellbeing`.
+ * v1.25.0 — `/insights/mental-wellbeing`.
*
* Opt-in PHQ-9 / GAD-7 screener surface, beside mood tracking. The screen owns
* its own questionnaire + result + crisis-resource flow; it deliberately does
* NOT mount the shared Coach-launch assessment card (mental-health item content
* is kept out of the AI Coach by construction).
+ *
+ * Born-gated on the resolved `modules.mentalHealth` flag from `GET /api/auth/me`
+ * (opt-in / default-off — the per-user opt-in AND the operator server-wide
+ * kill-switch). An unauthenticated visitor is bounced to login; an authenticated
+ * account without the module opted in is bounced home (the nav entry is already
+ * hidden for them, so this only catches a direct URL hit). Both assessment
+ * routes also enforce the gate server-side, so this is a UX redirect, not the
+ * security boundary.
*/
export default function MentalWellbeingPage() {
+ const { user, isLoading, isAuthenticated } = useAuth();
+ const router = useRouter();
+
+ const enabled = user?.modules?.mentalHealth === true;
+
+ useEffect(() => {
+ if (isLoading) return;
+ if (!isAuthenticated) {
+ router.push("/auth/login");
+ } else if (!enabled) {
+ router.push("/");
+ }
+ }, [isLoading, isAuthenticated, enabled, router]);
+
+ if (isLoading || !isAuthenticated || !enabled) {
+ return (
+
+
+
+ );
+ }
+
return (
diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts
index 01d81ad3a..49e04b45b 100644
--- a/src/components/layout/nav-model.ts
+++ b/src/components/layout/nav-model.ts
@@ -1,6 +1,7 @@
import {
Activity,
Bell,
+ Brain,
Droplets,
FileScan,
FlaskConical,
@@ -82,6 +83,19 @@ export const NAV_DESTINATIONS: ReadonlyArray = [
tourId: "nav-mood",
requiresModule: "mood",
},
+ // v1.25.0 — opt-in mental-health screeners (PHQ-9 / GAD-7), beside mood.
+ // Born-gated: `requiresModule: "mentalHealth"` reads the opt-in (default-off)
+ // key from the resolved module map, so the entry is absent until the account
+ // turns the module on from the Modules hub. The destination is the dedicated
+ // `/insights/mental-wellbeing` check-in surface (its own nav home — the
+ // active-detection helper defers `/insights` to it on that path).
+ {
+ href: "/insights/mental-wellbeing",
+ tKey: "nav.mentalWellbeing",
+ icon: Brain,
+ tourId: "nav-mental-wellbeing",
+ requiresModule: "mentalHealth",
+ },
{
href: "/cycle",
tKey: "nav.cycle",
diff --git a/src/components/settings/modules-section.tsx b/src/components/settings/modules-section.tsx
index 3dfede292..e67da7e3f 100644
--- a/src/components/settings/modules-section.tsx
+++ b/src/components/settings/modules-section.tsx
@@ -28,6 +28,7 @@ import {
Activity,
Award,
Blocks,
+ Brain,
CalendarHeart,
Droplet,
CloudSun,
@@ -80,6 +81,8 @@ const MODULE_ICONS: Record = {
mcp: Plug,
// v1.25.0 (W-DOCS-IN) — inbound clinical documents (opt-in).
inboundDocuments: FileScan,
+ // v1.25.0 — opt-in mental-health screeners (PHQ-9 / GAD-7).
+ mentalHealth: Brain,
};
export function ModulesSection() {
diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts
index 0e4b2baed..0cf95f422 100644
--- a/src/lib/dashboard/__tests__/snapshot.test.ts
+++ b/src/lib/dashboard/__tests__/snapshot.test.ts
@@ -86,6 +86,7 @@ function moduleMap(
environment: true,
mcp: true,
inboundDocuments: true,
+ mentalHealth: true,
};
return { ...base, ...overrides };
}
diff --git a/src/lib/modules/__tests__/gate.test.ts b/src/lib/modules/__tests__/gate.test.ts
index 944386804..730bf8b87 100644
--- a/src/lib/modules/__tests__/gate.test.ts
+++ b/src/lib/modules/__tests__/gate.test.ts
@@ -352,12 +352,19 @@ describe("resolveModuleEnabled — mcp module (opt-in, default-OFF)", () => {
});
describe("registry — opt-in marker", () => {
- // The opt-in modules ship dark because they open an egress/attack surface:
- // `mcp` (remote external-assistant endpoint), `environment` (outbound weather
- // fetch tied to a coarse location), and `inboundDocuments` (sends an uploaded
- // clinical document to the OCR/vision provider). Every other module is the
- // default-on disabled-allowlist.
- const OPT_IN_KEYS = new Set(["mcp", "environment", "inboundDocuments"]);
+ // The opt-in modules ship dark because they open an egress/attack surface or
+ // are highly sensitive: `mcp` (remote external-assistant endpoint),
+ // `environment` (outbound weather fetch tied to a coarse location),
+ // `inboundDocuments` (sends an uploaded clinical document to the OCR/vision
+ // provider), and `mentalHealth` (a depression / anxiety self-assessment — at
+ // least as sensitive as mood, surfaced only on explicit opt-in). Every other
+ // module is the default-on disabled-allowlist.
+ const OPT_IN_KEYS = new Set([
+ "mcp",
+ "environment",
+ "inboundDocuments",
+ "mentalHealth",
+ ]);
it("marks only the egress-surface modules opt-in (every other is default-on)", () => {
for (const key of MODULE_KEYS) {
diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts
index 663b32043..0fae157e5 100644
--- a/src/lib/modules/registry.ts
+++ b/src/lib/modules/registry.ts
@@ -136,6 +136,14 @@ export const MODULE_KEYS = [
// `optIn` marker on its registry entry inverts the per-user default;
// everything else reuses the existing module machinery unchanged.
"inboundDocuments",
+ // v1.25.0 — opt-in mental-health screeners (PHQ-9 / GAD-7), beside mood
+ // tracking. OPT-IN (off by default): a depression / anxiety self-assessment
+ // is at least as sensitive as mood, so it ships dark and the user turns it on
+ // deliberately. The `optIn` marker inverts the per-user default; with it off
+ // the `/insights/mental-wellbeing` surface is hidden from nav and the
+ // assessment routes refuse server-side. Item answers are always encrypted and
+ // always excluded from the AI Coach / MCP regardless of this toggle.
+ "mentalHealth",
] as const;
export type ModuleKey = (typeof MODULE_KEYS)[number];
@@ -298,6 +306,18 @@ export const MODULE_REGISTRY: Readonly> =
// configured OCR/vision provider, so the user turns it on deliberately.
optIn: true,
},
+ mentalHealth: {
+ key: "mentalHealth",
+ labelKey: "modules.mentalHealth.label",
+ descriptionKey: "modules.mentalHealth.description",
+ category: "tracking",
+ // Off by default: a depression / anxiety screener is highly sensitive, so
+ // the surface ships dark and the user opts in deliberately. Turning it on
+ // reveals the `/insights/mental-wellbeing` check-in and lets the screener
+ // total ride the doctor-report export; item answers stay encrypted and
+ // off the AI Coach / MCP regardless.
+ optIn: true,
+ },
});
const MODULE_KEY_SET: ReadonlySet = new Set(MODULE_KEYS);
diff --git a/tests/integration/doctor-report-sections.test.ts b/tests/integration/doctor-report-sections.test.ts
index 0f2732235..c3999f6ae 100644
--- a/tests/integration/doctor-report-sections.test.ts
+++ b/tests/integration/doctor-report-sections.test.ts
@@ -320,6 +320,7 @@ describe("doctor-report — module enable/disable gating", () => {
environment: true,
mcp: true,
inboundDocuments: true,
+ mentalHealth: true,
} as const;
it("includes every module's data when all modules are enabled", async () => {
From be4bc3bd10701c8eb6120f551886a2427e94e656 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 28 Jun 2026 18:25:43 +0200
Subject: [PATCH 75/94] fix(mental-health): repair the dead-on-arrival
assessment POST
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The handler passed the `safeJson` `{ data, error }` wrapper straight into
`safeParse`, so the schema never matched and every POST returned 422 — the
screener was dead on arrival. Destructure the envelope and read an unbounded
body no more: cap the request at 8 KB before validation, matching the sibling
clinical routes.
Add the missing write protections: gate both verbs on the opt-in module
server-side (a valid Bearer token included), wrap the create in idempotency so
a double-tap/retry cannot mint duplicate assessment + score rows, and add a
tight per-user write rate-limit.
---
.../api/mental-health/assessments/route.ts | 54 +++++++++++++++++--
1 file changed, 51 insertions(+), 3 deletions(-)
diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts
index ea78e2f08..d98915794 100644
--- a/src/app/api/mental-health/assessments/route.ts
+++ b/src/app/api/mental-health/assessments/route.ts
@@ -19,6 +19,7 @@ import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
+ apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
@@ -26,6 +27,9 @@ import {
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
+import { withIdempotency } from "@/lib/idempotency";
+import { requireModuleEnabled } from "@/lib/modules/gate";
+import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
createAssessmentSchema,
@@ -73,6 +77,11 @@ function shapeRow(row: {
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
+ // Opt-in module (default OFF): the screener history is unreachable until the
+ // account turns the mental-health module on. Enforced server-side even for a
+ // valid Bearer token (the nav entry / page redirect are UX-only).
+ const gate = await requireModuleEnabled(user.id, "mentalHealth");
+ if (!gate.enabled) return gate.response;
const params = Object.fromEntries(request.nextUrl.searchParams);
const parsed = listAssessmentsSchema.safeParse(params);
if (!parsed.success) {
@@ -112,9 +121,48 @@ export const GET = apiHandler(async (request: NextRequest) => {
return apiSuccess({ assessments: rows.map(shapeRow) });
});
-export const POST = apiHandler(async (request: NextRequest) => {
+// `withIdempotency` lets a double-tap / retry re-send the same
+// `Idempotency-Key` without minting a duplicate assessment row AND a duplicate
+// `*_SCORE` Measurement row that would skew the trend (the allergy / labs
+// create precedent).
+export const POST = apiHandler(withIdempotency<[NextRequest]>(postAssessment));
+
+async function postAssessment(request: NextRequest): Promise {
const { user } = await requireAuth();
- const body = await safeJson(request);
+
+ // Opt-in module (default OFF) — same gate as GET.
+ const gate = await requireModuleEnabled(user.id, "mentalHealth");
+ if (!gate.enabled) return gate.response;
+
+ // Write rate-limit: a screener is a deliberate, infrequent action, so the
+ // bucket is tight — it only caps a double-tap / scripted loop minting
+ // duplicate trend points (the recall window is 2 weeks; 30/hour is generous).
+ const rl = await checkRateLimit(
+ `mental-health-create:${user.id}`,
+ 30,
+ 60 * 60 * 1000,
+ );
+ if (!rl.allowed) {
+ const res = apiError(
+ "Too many check-ins. Please wait before submitting another.",
+ 429,
+ { errorCode: "mentalHealth.rateLimited" },
+ );
+ for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
+ res.headers.set(k, v);
+ }
+ return res;
+ }
+
+ // Bounded body read BEFORE validation (the item answers are small; cap so an
+ // unbounded body can never reach `request.json()`). Destructure the
+ // `{ data, error }` envelope — passing the wrapper to `safeParse` would never
+ // match the schema, which is what left this route dead-on-arrival.
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 8 * 1024,
+ });
+ if (jsonError) return jsonError;
+
const parsed = createAssessmentSchema.safeParse(body);
if (!parsed.success) {
annotate({
@@ -210,4 +258,4 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
201,
);
-});
+}
From 5f0ca355f96b0da33ebe2ab242cdac9cdf75aca5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 28 Jun 2026 18:25:43 +0200
Subject: [PATCH 76/94] fix(mental-health): a11y on the check-in result and
option controls
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
On submit the form (and its submit button) unmounts, dropping focus to the
body — move focus to the result heading so keyboard/AT users land on the new
content. Announce the item-9 crisis-resource card via role="alert" so a
screen-reader user hears the resources. Bump the PHQ/GAD scale and
functional-difficulty option buttons to the 44px touch-target floor.
---
.../mental-health/mental-wellbeing.tsx | 31 +++++++++++++++++--
1 file changed, 28 insertions(+), 3 deletions(-)
diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx
index 21974aac3..1cd85d9c1 100644
--- a/src/components/mental-health/mental-wellbeing.tsx
+++ b/src/components/mental-health/mental-wellbeing.tsx
@@ -13,7 +13,7 @@
* positive PHQ-9 item-9 the server returns a calm, locale-aware crisis-resource
* set which is rendered immediately below the result.
*/
-import { useMemo, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
@@ -63,6 +63,16 @@ export function MentalWellbeing() {
const [functional, setFunctional] = useState(null);
const [result, setResult] = useState(null);
+ // a11y: on submit the form (incl. the submit button) unmounts and `phase`
+ // flips to "result". Move focus to the result heading so keyboard / screen-
+ // reader users are not dropped to `` and land on the new content.
+ const resultHeadingRef = useRef(null);
+ useEffect(() => {
+ if (phase === "result") {
+ resultHeadingRef.current?.focus();
+ }
+ }, [phase]);
+
const lower = (id: InstrumentId) => (id === "PHQ9" ? "phq9" : "gad7");
const { data: history } = useQuery({
@@ -191,6 +201,9 @@ export function MentalWellbeing() {
key={v}
type="button"
size="sm"
+ // 44px touch-target floor (WCAG 2.5.5) — these are the
+ // questionnaire option controls a full PHQ-9 taps 36 times.
+ className="min-h-11"
variant={items[i] === v ? "default" : "outline"}
aria-pressed={items[i] === v}
onClick={() =>
@@ -221,6 +234,8 @@ export function MentalWellbeing() {
key={v}
type="button"
size="sm"
+ // 44px touch-target floor (WCAG 2.5.5), matching the scale.
+ className="min-h-11"
variant={functional === v ? "default" : "outline"}
aria-pressed={functional === v}
onClick={() => setFunctional(v)}
@@ -251,7 +266,13 @@ export function MentalWellbeing() {
- {t("mentalHealth.result.title")}
+
+ {t("mentalHealth.result.title")}
+
@@ -281,7 +302,11 @@ export function MentalWellbeing() {
{result.crisis && (
-
+
{t("mentalHealth.crisis.title")}
From 12579466c08eec483bf94e952acdbcbe1146ec49 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 28 Jun 2026 18:25:43 +0200
Subject: [PATCH 77/94] fix(mental-health): gate screener-score export on the
opt-in module
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The PHQ-9/GAD-7 totals flowed into the doctor-report / FHIR export
unconditionally while the AI/MCP surfaces were correctly fenced off. Map the
two `*_SCORE` measurement types to the mental-health module so the totals
export only once the account opts the module in (default OFF — privacy by
default), consistent with how mood/sleep/glucose gate. Item-level answers are
never a measurement, so they can never leak through this path.
---
src/lib/doctor-report-data.ts | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts
index d2783626d..112084178 100644
--- a/src/lib/doctor-report-data.ts
+++ b/src/lib/doctor-report-data.ts
@@ -1515,6 +1515,16 @@ const MEASUREMENT_TYPE_MODULE: Record = {
RECOVERY_SCORE: "recovery",
STRESS_SCORE: "recovery",
BLOOD_GLUCOSE: "glucose",
+ // v1.25.0 — the PHQ-9 / GAD-7 screener TOTALS ride the doctor-report / FHIR
+ // export only when the opt-in mental-health module is on (default OFF →
+ // excluded by default, privacy-by-default). Item-level answers are never a
+ // Measurement, so they can never leak through this path regardless. The total
+ // is the intended clinical artefact (total + band per clinical-instruments.md
+ // §4); gating it on the module keeps the export consistent with how mood /
+ // sleep / glucose are gated and avoids exporting a screener the account never
+ // opted into.
+ PHQ9_SCORE: "mentalHealth",
+ GAD7_SCORE: "mentalHealth",
};
function filterMeasurementKeys(
From d9c419aab294d45b8331ebde178cc0d11bd61e9d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 28 Jun 2026 18:25:43 +0200
Subject: [PATCH 78/94] test(mental-health): cover PHQ-9/GAD-7 scoring and the
assessment POST
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Pin the highest-consequence numbers in the release: every PHQ-9/GAD-7 severity
band edge, the >=10 action threshold, and the item-9 ANY-non-zero safety flag
(true on item9 1/2/3, false on 0 or for GAD-7). Add a POST happy-path test
that would have caught the dead-on-arrival bug — a valid body now parses,
persists server-authoritative scoring, writes the derived score measurement,
and surfaces the crisis set on a positive item 9.
---
.../assessments/__tests__/route.test.ts | 211 ++++++++++++++++++
.../__tests__/instruments.test.ts | 94 ++++++++
2 files changed, 305 insertions(+)
create mode 100644 src/app/api/mental-health/assessments/__tests__/route.test.ts
create mode 100644 src/lib/mental-health/__tests__/instruments.test.ts
diff --git a/src/app/api/mental-health/assessments/__tests__/route.test.ts b/src/app/api/mental-health/assessments/__tests__/route.test.ts
new file mode 100644
index 000000000..40bdc9bea
--- /dev/null
+++ b/src/app/api/mental-health/assessments/__tests__/route.test.ts
@@ -0,0 +1,211 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { NextRequest } from "next/server";
+
+/**
+ * v1.25.0 — `POST /api/mental-health/assessments` happy path.
+ *
+ * Regression guard for the dead-on-arrival bug: the handler used to pass the
+ * `safeJson` `{ data, error }` WRAPPER straight into `safeParse`, so the schema
+ * never matched and every POST returned 422. This pins that a valid body now
+ * (a) parses, (b) persists with a server-authoritative total / band / item-9
+ * flag, (c) writes the derived `*_SCORE` Measurement, and (d) surfaces the
+ * locale-aware crisis set on a positive item 9.
+ */
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
+ mentalHealthAssessment: { create: vi.fn() },
+ measurement: { create: vi.fn().mockResolvedValue({}) },
+ },
+}));
+
+vi.mock("@/lib/modules/gate", async (importOriginal) => ({
+ ...(await importOriginal()),
+ requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
+ resolveModuleMap: vi.fn().mockResolvedValue({}),
+}));
+
+vi.mock("@/lib/rate-limit", () => ({
+ checkRateLimit: vi
+ .fn()
+ .mockResolvedValue({ allowed: true, remaining: 29, resetAt: Date.now() }),
+ rateLimitHeaders: vi.fn().mockReturnValue({}),
+}));
+
+vi.mock("@/lib/ai/coach/bytes-codec", () => ({
+ encryptToBytes: vi.fn(() => Buffer.from("ciphertext")),
+}));
+
+vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
+vi.mock("@/lib/auth/audit", () => ({
+ auditLog: vi.fn().mockResolvedValue(undefined),
+}));
+vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+vi.mock("next/headers", () => ({
+ headers: vi.fn(async () => ({ get: () => null })),
+ cookies: vi.fn(async () => ({
+ get: () => undefined,
+ set: () => {},
+ delete: () => {},
+ })),
+}));
+
+import { POST } from "../route";
+import { getSession } from "@/lib/auth/session";
+import { requireModuleEnabled } from "@/lib/modules/gate";
+import { checkRateLimit } from "@/lib/rate-limit";
+import { prisma } from "@/lib/db";
+
+const SESSION_OK = {
+ session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
+ user: {
+ id: "user-1",
+ username: "testuser",
+ role: "USER" as const,
+ locale: "en",
+ },
+};
+
+const callPost = POST as unknown as (req: NextRequest) => Promise;
+
+function makeReq(body: unknown): NextRequest {
+ return new NextRequest(
+ new URL("http://localhost/api/mental-health/assessments"),
+ {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ );
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
+ vi.mocked(checkRateLimit).mockResolvedValue({
+ allowed: true,
+ remaining: 29,
+ resetAt: Date.now(),
+ });
+});
+
+describe("POST /api/mental-health/assessments", () => {
+ it("parses a valid PHQ-9 body and returns 201 with the server-computed total/band/flag", async () => {
+ // Item 9 = 2 → safety-flagged; total = 8*1 + 2 = 10 → moderate band.
+ vi.mocked(prisma.mentalHealthAssessment.create).mockResolvedValue({
+ id: "mha_1",
+ instrument: "PHQ9",
+ locale: "en",
+ version: "standard",
+ totalScore: 10,
+ severityBand: "moderate",
+ item9Flagged: true,
+ crisisShownAt: new Date("2026-06-28T00:00:00.000Z"),
+ takenAt: new Date("2026-06-28T00:00:00.000Z"),
+ createdAt: new Date("2026-06-28T00:00:00.000Z"),
+ } as never);
+
+ const res = await callPost(
+ makeReq({
+ instrument: "PHQ9",
+ items: [1, 1, 1, 1, 1, 1, 1, 1, 2],
+ locale: "en",
+ }),
+ );
+
+ expect(res.status).toBe(201);
+ const body = (await res.json()) as {
+ data: {
+ assessment: {
+ totalScore: number;
+ severityBand: string;
+ item9Flagged: boolean;
+ };
+ actionThreshold: number;
+ crisis: { emergencyNumber: string } | null;
+ };
+ };
+ expect(body.data.assessment.totalScore).toBe(10);
+ expect(body.data.assessment.severityBand).toBe("moderate");
+ expect(body.data.assessment.item9Flagged).toBe(true);
+ expect(body.data.actionThreshold).toBe(10);
+ // Positive item 9 → crisis resources surfaced immediately.
+ expect(body.data.crisis).not.toBeNull();
+ expect(body.data.crisis?.emergencyNumber).toBeTruthy();
+ });
+
+ it("persists server-authoritative scoring (userId from session, total recomputed) + the derived measurement", async () => {
+ vi.mocked(prisma.mentalHealthAssessment.create).mockResolvedValue({
+ id: "mha_2",
+ instrument: "GAD7",
+ locale: "en",
+ version: "standard",
+ totalScore: 6,
+ severityBand: "mild",
+ item9Flagged: false,
+ crisisShownAt: null,
+ takenAt: new Date("2026-06-28T00:00:00.000Z"),
+ createdAt: new Date("2026-06-28T00:00:00.000Z"),
+ } as never);
+
+ // GAD-7, total 1+2+0+1+1+1+0 = 6 → mild, no safety item.
+ const res = await callPost(
+ makeReq({
+ instrument: "GAD7",
+ items: [1, 2, 0, 1, 1, 1, 0],
+ }),
+ );
+ expect(res.status).toBe(201);
+
+ const createArg = vi.mocked(prisma.mentalHealthAssessment.create).mock
+ .calls[0][0] as {
+ data: {
+ userId: string;
+ instrument: string;
+ totalScore: number;
+ severityBand: string;
+ item9Flagged: boolean;
+ };
+ };
+ expect(createArg.data.userId).toBe("user-1");
+ expect(createArg.data.instrument).toBe("GAD7");
+ expect(createArg.data.totalScore).toBe(6);
+ expect(createArg.data.severityBand).toBe("mild");
+ expect(createArg.data.item9Flagged).toBe(false);
+
+ // Derived projection: one PHQ9_SCORE/GAD7_SCORE Measurement per assessment.
+ const measArg = vi.mocked(prisma.measurement.create).mock.calls[0][0] as {
+ data: { type: string; value: number; unit: string; externalId: string };
+ };
+ expect(measArg.data.type).toBe("GAD7_SCORE");
+ expect(measArg.data.value).toBe(6);
+ expect(measArg.data.unit).toBe("score");
+ expect(measArg.data.externalId).toBe("assessment:mha_2");
+ });
+
+ it("rejects a wrong item count with 422 and never writes", async () => {
+ const res = await callPost(
+ makeReq({ instrument: "PHQ9", items: [1, 2, 3] }), // 3 ≠ 9
+ );
+ expect(res.status).toBe(422);
+ expect(prisma.mentalHealthAssessment.create).not.toHaveBeenCalled();
+ });
+
+ it("returns the module 403 when the mental-health module is off", async () => {
+ const { apiError } = await import("@/lib/api-response");
+ vi.mocked(requireModuleEnabled).mockResolvedValue({
+ enabled: false,
+ response: apiError("disabled", 403, { errorCode: "module.disabled" }),
+ });
+ const res = await callPost(
+ makeReq({ instrument: "PHQ9", items: [0, 0, 0, 0, 0, 0, 0, 0, 0] }),
+ );
+ expect(res.status).toBe(403);
+ expect(prisma.mentalHealthAssessment.create).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/lib/mental-health/__tests__/instruments.test.ts b/src/lib/mental-health/__tests__/instruments.test.ts
new file mode 100644
index 000000000..a97e92853
--- /dev/null
+++ b/src/lib/mental-health/__tests__/instruments.test.ts
@@ -0,0 +1,94 @@
+import { describe, it, expect } from "vitest";
+
+import {
+ INSTRUMENTS,
+ scoreTotal,
+ severityBand,
+ isSafetyFlagged,
+} from "../instruments";
+
+/**
+ * v1.25.0 — pin the highest-consequence numbers in the release: the PHQ-9 /
+ * GAD-7 totals, the validated severity-band edges (5 / 10 / 15 / 20 and
+ * 5 / 10 / 15), the ≥10 action threshold, and the item-9 ANY-non-zero safety
+ * flag. A future off-by-one on any band edge or the safety predicate must fail
+ * here. Ground truth: Kroenke 2001 (PHQ-9) / Spitzer 2006 (GAD-7), mirrored in
+ * `.planning/research/v125/clinical-instruments.md`.
+ */
+describe("mental-health instruments", () => {
+ describe("scoreTotal", () => {
+ it("sums the item answers", () => {
+ expect(scoreTotal([0, 1, 2, 3, 0, 1, 2, 3, 1])).toBe(13);
+ expect(scoreTotal([0, 0, 0, 0, 0, 0, 0])).toBe(0);
+ });
+
+ it("returns the max when every item is 3", () => {
+ expect(scoreTotal(Array(9).fill(3))).toBe(27); // PHQ-9 max
+ expect(scoreTotal(Array(7).fill(3))).toBe(21); // GAD-7 max
+ });
+ });
+
+ describe("severityBand — PHQ-9 edges (0–4 / 5–9 / 10–14 / 15–19 / 20–27)", () => {
+ const cases: Array<[number, string]> = [
+ [0, "minimal"],
+ [4, "minimal"],
+ [5, "mild"],
+ [9, "mild"],
+ [10, "moderate"],
+ [14, "moderate"],
+ [15, "modSevere"],
+ [19, "modSevere"],
+ [20, "severe"],
+ [27, "severe"],
+ ];
+ it.each(cases)("PHQ9 total %i → %s", (total, band) => {
+ expect(severityBand("PHQ9", total)).toBe(band);
+ });
+ });
+
+ describe("severityBand — GAD-7 edges (0–4 / 5–9 / 10–14 / 15–21)", () => {
+ const cases: Array<[number, string]> = [
+ [0, "minimal"],
+ [4, "minimal"],
+ [5, "mild"],
+ [9, "mild"],
+ [10, "moderate"],
+ [14, "moderate"],
+ [15, "severe"],
+ [21, "severe"],
+ ];
+ it.each(cases)("GAD7 total %i → %s", (total, band) => {
+ expect(severityBand("GAD7", total)).toBe(band);
+ });
+ });
+
+ describe("action threshold is ≥10 for both instruments", () => {
+ it("PHQ-9", () => {
+ expect(INSTRUMENTS.PHQ9.actionThreshold).toBe(10);
+ });
+ it("GAD-7", () => {
+ expect(INSTRUMENTS.GAD7.actionThreshold).toBe(10);
+ });
+ });
+
+ describe("isSafetyFlagged — PHQ-9 item 9 (index 8), ANY non-zero", () => {
+ it("flags when item 9 = 1, 2, or 3 regardless of total", () => {
+ for (const answer of [1, 2, 3]) {
+ const items = [0, 0, 0, 0, 0, 0, 0, 0, answer];
+ expect(isSafetyFlagged("PHQ9", items)).toBe(true);
+ }
+ });
+
+ it("does not flag when item 9 = 0 even with a high total", () => {
+ // Every other item maxed (24), item 9 = 0 → severe band, no flag.
+ const items = [3, 3, 3, 3, 3, 3, 3, 3, 0];
+ expect(severityBand("PHQ9", scoreTotal(items))).toBe("severe");
+ expect(isSafetyFlagged("PHQ9", items)).toBe(false);
+ });
+
+ it("never flags GAD-7 (no safety item)", () => {
+ expect(isSafetyFlagged("GAD7", [3, 3, 3, 3, 3, 3, 3])).toBe(false);
+ expect(INSTRUMENTS.GAD7.safetyItemIndex).toBeNull();
+ });
+ });
+});
From dd6b0867a4265d43e29c3bce07bafb14b0908645 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 28 Jun 2026 18:29:26 +0200
Subject: [PATCH 79/94] i18n: translate illness journal strings and add v1.25
UI strings
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The illness journal copy (load error, delete confirmation, edit/menu
labels, parent links, and the vital-sign labels) shipped in English in
every locale; translate it into de/es/fr/it/pl. Also add the new strings
the v1.25 surfaces introduce — the biomarker 'load more readings' action,
the environment city-search zero-result message, and an accessible label
for the document upload file input — across all six locales.
---
messages/de.json | 43 +++++++++++++++++++++++--------------------
messages/en.json | 9 ++++++---
messages/es.json | 43 +++++++++++++++++++++++--------------------
messages/fr.json | 43 +++++++++++++++++++++++--------------------
messages/it.json | 43 +++++++++++++++++++++++--------------------
messages/pl.json | 43 +++++++++++++++++++++++--------------------
6 files changed, 121 insertions(+), 103 deletions(-)
diff --git a/messages/de.json b/messages/de.json
index dc0e00086..621accc32 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4317,7 +4317,8 @@
"title": "Vergangene Tage nacherfassen",
"description": "Rufe Wetter für einen vergangenen Zeitraum ab und speichere es, damit ältere Tage in die Korrelationen einfließen. Der Start steht standardmäßig auf dem Gültigkeitsdatum deines Wohnorts; um weiter zurückzureichen, füge zuerst einen Ortszeitraum für diesen Bereich hinzu.",
"run": "Nacherfassen"
- }
+ },
+ "noResults": "Keine passenden Orte gefunden."
}
},
"profile": "Profil",
@@ -7201,7 +7202,8 @@
"fileType": "Lade ein JPEG, PNG, WebP oder PDF hoch.",
"extractFailed": "Der Befund konnte nicht gelesen werden. Versuche ein schärferes Foto.",
"pdfNeedsAnthropic": "PDF-Scannen benötigt einen Claude-Bildanbieter; lade stattdessen ein Foto hoch."
- }
+ },
+ "loadMoreReadings": "Weitere Werte laden"
},
"modules": {
"cycle": {
@@ -7319,8 +7321,8 @@
"note": "Notiz",
"notePlaceholder": "Was erwähnenswert ist (verschlüsselt).",
"saveError": "Die Episode konnte nicht gespeichert werden. Bitte erneut versuchen.",
- "parent": "Part of",
- "parentNone": "Not linked"
+ "parent": "Teil von",
+ "parentNone": "Nicht verknüpft"
},
"sheet": {
"title": "{date} erfassen",
@@ -7401,32 +7403,32 @@
"fever": "Temperatur: {value} °C",
"empty": "Noch keine Tage erfasst."
},
- "loadError": "Could not load this episode.",
- "backToList": "Back to journal",
+ "loadError": "Diese Episode konnte nicht geladen werden.",
+ "backToList": "Zurück zum Journal",
"detail": {
"note": "Note"
},
"edit": {
- "title": "Edit episode",
- "description": "Update this episode's details."
+ "title": "Episode bearbeiten",
+ "description": "Aktualisiere die Details dieser Episode."
},
"menu": {
- "label": "Episode actions"
+ "label": "Episodenaktionen"
},
"deleteConfirm": {
- "title": "Delete this episode?",
- "body": "“{label}” and all its day-logs will be removed. This cannot be undone."
+ "title": "Diese Episode löschen?",
+ "body": "„{label}“ und alle zugehörigen Tageseinträge werden entfernt. Dies kann nicht rückgängig gemacht werden."
},
"vital": {
- "RESTING_HEART_RATE": "Resting heart rate",
- "HEART_RATE_VARIABILITY": "Heart-rate variability",
+ "RESTING_HEART_RATE": "Ruheherzfrequenz",
+ "HEART_RATE_VARIABILITY": "Herzfrequenzvariabilität",
"RECOVERY_SCORE": "Recovery",
- "OXYGEN_SATURATION": "Oxygen saturation",
- "BODY_TEMPERATURE": "Body temperature",
- "SKIN_TEMPERATURE": "Skin temperature",
- "RESPIRATORY_RATE": "Respiratory rate",
- "BLOOD_PRESSURE_SYS": "Systolic blood pressure",
- "BLOOD_PRESSURE_DIA": "Diastolic blood pressure",
+ "OXYGEN_SATURATION": "Sauerstoffsättigung",
+ "BODY_TEMPERATURE": "Körpertemperatur",
+ "SKIN_TEMPERATURE": "Hauttemperatur",
+ "RESPIRATORY_RATE": "Atemfrequenz",
+ "BLOOD_PRESSURE_SYS": "Systolischer Blutdruck",
+ "BLOOD_PRESSURE_DIA": "Diastolischer Blutdruck",
"PULSE": "Pulse",
"WEIGHT": "Weight"
},
@@ -7741,7 +7743,8 @@
"label": "Dokument hochladen",
"kindLabel": "Dokumenttyp",
"button": "Hochladen",
- "uploading": "Wird gelesen …"
+ "uploading": "Wird gelesen …",
+ "fileLabel": "Dokumentdatei"
},
"kind": {
"DOCTOR_REPORT": "Arztbericht",
diff --git a/messages/en.json b/messages/en.json
index e6e776db1..557f2e415 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4317,7 +4317,8 @@
"title": "Backfill past days",
"description": "Fetch and store weather for a past date range so older days join the correlations. The start defaults to your home's effective date; to reach further back, add a location period for that range first.",
"run": "Backfill"
- }
+ },
+ "noResults": "No matching places found."
}
},
"profile": "Profile",
@@ -7201,7 +7202,8 @@
"fileType": "Upload a JPEG, PNG, WebP, or PDF.",
"extractFailed": "Couldn't read the report. Try a clearer photo.",
"pdfNeedsAnthropic": "PDF scanning needs a Claude vision provider; upload a photo instead."
- }
+ },
+ "loadMoreReadings": "Load more readings"
},
"modules": {
"cycle": {
@@ -7741,7 +7743,8 @@
"label": "Upload a document",
"kindLabel": "Document type",
"button": "Upload",
- "uploading": "Reading…"
+ "uploading": "Reading…",
+ "fileLabel": "Document file"
},
"kind": {
"DOCTOR_REPORT": "Doctor report",
diff --git a/messages/es.json b/messages/es.json
index b9687c4c0..d80b581bf 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4317,7 +4317,8 @@
"title": "Rellenar días pasados",
"description": "Obtén y guarda el tiempo de un rango de fechas pasado para que los días antiguos entren en las correlaciones. El inicio toma por defecto la fecha de vigencia de tu hogar; para llegar más atrás, añade primero un periodo de ubicación para ese rango.",
"run": "Rellenar"
- }
+ },
+ "noResults": "No se encontraron lugares coincidentes."
}
},
"profile": "Perfil",
@@ -7201,7 +7202,8 @@
"fileType": "Sube un JPEG, PNG, WebP o PDF.",
"extractFailed": "No se pudo leer el informe. Prueba con una foto más nítida.",
"pdfNeedsAnthropic": "El escaneo de PDF necesita un proveedor de visión de Claude; sube una foto en su lugar."
- }
+ },
+ "loadMoreReadings": "Cargar más mediciones"
},
"modules": {
"cycle": {
@@ -7319,8 +7321,8 @@
"note": "Nota",
"notePlaceholder": "Algo que valga la pena recordar (cifrado).",
"saveError": "No se pudo guardar el episodio. Inténtalo de nuevo.",
- "parent": "Part of",
- "parentNone": "Not linked"
+ "parent": "Parte de",
+ "parentNone": "Sin vincular"
},
"sheet": {
"title": "Registrar {date}",
@@ -7401,32 +7403,32 @@
"fever": "Temperatura: {value} °C",
"empty": "Todavía no hay días registrados."
},
- "loadError": "Could not load this episode.",
- "backToList": "Back to journal",
+ "loadError": "No se pudo cargar este episodio.",
+ "backToList": "Volver al diario",
"detail": {
"note": "Note"
},
"edit": {
- "title": "Edit episode",
- "description": "Update this episode's details."
+ "title": "Editar episodio",
+ "description": "Actualiza los detalles de este episodio."
},
"menu": {
- "label": "Episode actions"
+ "label": "Acciones del episodio"
},
"deleteConfirm": {
- "title": "Delete this episode?",
- "body": "“{label}” and all its day-logs will be removed. This cannot be undone."
+ "title": "¿Eliminar este episodio?",
+ "body": "«{label}» y todos sus registros diarios se eliminarán. Esta acción no se puede deshacer."
},
"vital": {
- "RESTING_HEART_RATE": "Resting heart rate",
- "HEART_RATE_VARIABILITY": "Heart-rate variability",
+ "RESTING_HEART_RATE": "Frecuencia cardíaca en reposo",
+ "HEART_RATE_VARIABILITY": "Variabilidad de la frecuencia cardíaca",
"RECOVERY_SCORE": "Recovery",
- "OXYGEN_SATURATION": "Oxygen saturation",
- "BODY_TEMPERATURE": "Body temperature",
- "SKIN_TEMPERATURE": "Skin temperature",
- "RESPIRATORY_RATE": "Respiratory rate",
- "BLOOD_PRESSURE_SYS": "Systolic blood pressure",
- "BLOOD_PRESSURE_DIA": "Diastolic blood pressure",
+ "OXYGEN_SATURATION": "Saturación de oxígeno",
+ "BODY_TEMPERATURE": "Temperatura corporal",
+ "SKIN_TEMPERATURE": "Temperatura de la piel",
+ "RESPIRATORY_RATE": "Frecuencia respiratoria",
+ "BLOOD_PRESSURE_SYS": "Presión arterial sistólica",
+ "BLOOD_PRESSURE_DIA": "Presión arterial diastólica",
"PULSE": "Pulse",
"WEIGHT": "Weight"
},
@@ -7741,7 +7743,8 @@
"label": "Subir un documento",
"kindLabel": "Tipo de documento",
"button": "Subir",
- "uploading": "Leyendo…"
+ "uploading": "Leyendo…",
+ "fileLabel": "Archivo del documento"
},
"kind": {
"DOCTOR_REPORT": "Informe médico",
diff --git a/messages/fr.json b/messages/fr.json
index 7df786db9..70c1ec681 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4317,7 +4317,8 @@
"title": "Récupérer les jours passés",
"description": "Récupérez et enregistrez la météo d'une plage de dates passée pour que les jours anciens rejoignent les corrélations. Le début reprend par défaut la date d'effet de votre domicile ; pour remonter plus loin, ajoutez d'abord une période de localisation pour cette plage.",
"run": "Récupérer"
- }
+ },
+ "noResults": "Aucun lieu correspondant trouvé."
}
},
"profile": "Profil",
@@ -7201,7 +7202,8 @@
"fileType": "Importez un JPEG, PNG, WebP ou PDF.",
"extractFailed": "Impossible de lire le compte rendu. Essayez une photo plus nette.",
"pdfNeedsAnthropic": "Le scan PDF nécessite un fournisseur de vision Claude ; importez plutôt une photo."
- }
+ },
+ "loadMoreReadings": "Charger plus de mesures"
},
"modules": {
"cycle": {
@@ -7319,8 +7321,8 @@
"note": "Note",
"notePlaceholder": "Ce qui mérite d'être noté (chiffré).",
"saveError": "Impossible d'enregistrer l'épisode. Veuillez réessayer.",
- "parent": "Part of",
- "parentNone": "Not linked"
+ "parent": "Fait partie de",
+ "parentNone": "Non lié"
},
"sheet": {
"title": "Enregistrer {date}",
@@ -7401,32 +7403,32 @@
"fever": "Température : {value} °C",
"empty": "Aucun jour enregistré pour l’instant."
},
- "loadError": "Could not load this episode.",
- "backToList": "Back to journal",
+ "loadError": "Impossible de charger cet épisode.",
+ "backToList": "Retour au journal",
"detail": {
"note": "Note"
},
"edit": {
- "title": "Edit episode",
- "description": "Update this episode's details."
+ "title": "Modifier l'épisode",
+ "description": "Mettez à jour les détails de cet épisode."
},
"menu": {
- "label": "Episode actions"
+ "label": "Actions de l'épisode"
},
"deleteConfirm": {
- "title": "Delete this episode?",
- "body": "“{label}” and all its day-logs will be removed. This cannot be undone."
+ "title": "Supprimer cet épisode ?",
+ "body": "« {label} » et tous ses relevés quotidiens seront supprimés. Cette action est irréversible."
},
"vital": {
- "RESTING_HEART_RATE": "Resting heart rate",
- "HEART_RATE_VARIABILITY": "Heart-rate variability",
+ "RESTING_HEART_RATE": "Fréquence cardiaque au repos",
+ "HEART_RATE_VARIABILITY": "Variabilité de la fréquence cardiaque",
"RECOVERY_SCORE": "Recovery",
- "OXYGEN_SATURATION": "Oxygen saturation",
- "BODY_TEMPERATURE": "Body temperature",
- "SKIN_TEMPERATURE": "Skin temperature",
- "RESPIRATORY_RATE": "Respiratory rate",
- "BLOOD_PRESSURE_SYS": "Systolic blood pressure",
- "BLOOD_PRESSURE_DIA": "Diastolic blood pressure",
+ "OXYGEN_SATURATION": "Saturation en oxygène",
+ "BODY_TEMPERATURE": "Température corporelle",
+ "SKIN_TEMPERATURE": "Température cutanée",
+ "RESPIRATORY_RATE": "Fréquence respiratoire",
+ "BLOOD_PRESSURE_SYS": "Pression artérielle systolique",
+ "BLOOD_PRESSURE_DIA": "Pression artérielle diastolique",
"PULSE": "Pulse",
"WEIGHT": "Weight"
},
@@ -7741,7 +7743,8 @@
"label": "Importer un document",
"kindLabel": "Type de document",
"button": "Importer",
- "uploading": "Lecture…"
+ "uploading": "Lecture…",
+ "fileLabel": "Fichier du document"
},
"kind": {
"DOCTOR_REPORT": "Compte rendu médical",
diff --git a/messages/it.json b/messages/it.json
index e4f800775..cc173e40b 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4317,7 +4317,8 @@
"title": "Recupera i giorni passati",
"description": "Recupera e salva il meteo di un intervallo di date passato così i giorni più vecchi entrano nelle correlazioni. L'inizio è impostato in modo predefinito sulla data di validità della tua residenza; per andare più indietro, aggiungi prima un periodo di posizione per quell'intervallo.",
"run": "Recupera"
- }
+ },
+ "noResults": "Nessun luogo corrispondente trovato."
}
},
"profile": "Profilo",
@@ -7201,7 +7202,8 @@
"fileType": "Carica un JPEG, PNG, WebP o PDF.",
"extractFailed": "Impossibile leggere il referto. Prova con una foto più nitida.",
"pdfNeedsAnthropic": "La scansione PDF richiede un provider di visione Claude; carica invece una foto."
- }
+ },
+ "loadMoreReadings": "Carica altre misurazioni"
},
"modules": {
"cycle": {
@@ -7319,8 +7321,8 @@
"note": "Nota",
"notePlaceholder": "Qualcosa da ricordare (cifrato).",
"saveError": "Impossibile salvare l'episodio. Riprova.",
- "parent": "Part of",
- "parentNone": "Not linked"
+ "parent": "Parte di",
+ "parentNone": "Non collegato"
},
"sheet": {
"title": "Registra {date}",
@@ -7401,32 +7403,32 @@
"fever": "Temperatura: {value} °C",
"empty": "Nessun giorno registrato finora."
},
- "loadError": "Could not load this episode.",
- "backToList": "Back to journal",
+ "loadError": "Impossibile caricare questo episodio.",
+ "backToList": "Torna al diario",
"detail": {
"note": "Note"
},
"edit": {
- "title": "Edit episode",
- "description": "Update this episode's details."
+ "title": "Modifica episodio",
+ "description": "Aggiorna i dettagli di questo episodio."
},
"menu": {
- "label": "Episode actions"
+ "label": "Azioni dell'episodio"
},
"deleteConfirm": {
- "title": "Delete this episode?",
- "body": "“{label}” and all its day-logs will be removed. This cannot be undone."
+ "title": "Eliminare questo episodio?",
+ "body": "«{label}» e tutti i relativi registri giornalieri verranno rimossi. L'operazione non può essere annullata."
},
"vital": {
- "RESTING_HEART_RATE": "Resting heart rate",
- "HEART_RATE_VARIABILITY": "Heart-rate variability",
+ "RESTING_HEART_RATE": "Frequenza cardiaca a riposo",
+ "HEART_RATE_VARIABILITY": "Variabilità della frequenza cardiaca",
"RECOVERY_SCORE": "Recovery",
- "OXYGEN_SATURATION": "Oxygen saturation",
- "BODY_TEMPERATURE": "Body temperature",
- "SKIN_TEMPERATURE": "Skin temperature",
- "RESPIRATORY_RATE": "Respiratory rate",
- "BLOOD_PRESSURE_SYS": "Systolic blood pressure",
- "BLOOD_PRESSURE_DIA": "Diastolic blood pressure",
+ "OXYGEN_SATURATION": "Saturazione di ossigeno",
+ "BODY_TEMPERATURE": "Temperatura corporea",
+ "SKIN_TEMPERATURE": "Temperatura cutanea",
+ "RESPIRATORY_RATE": "Frequenza respiratoria",
+ "BLOOD_PRESSURE_SYS": "Pressione arteriosa sistolica",
+ "BLOOD_PRESSURE_DIA": "Pressione arteriosa diastolica",
"PULSE": "Pulse",
"WEIGHT": "Weight"
},
@@ -7741,7 +7743,8 @@
"label": "Carica un documento",
"kindLabel": "Tipo di documento",
"button": "Carica",
- "uploading": "Lettura…"
+ "uploading": "Lettura…",
+ "fileLabel": "File del documento"
},
"kind": {
"DOCTOR_REPORT": "Referto medico",
diff --git a/messages/pl.json b/messages/pl.json
index 52381c2ba..4477b6251 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4317,7 +4317,8 @@
"title": "Uzupełnij minione dni",
"description": "Pobierz i zapisz pogodę dla minionego zakresu dat, aby starsze dni weszły do korelacji. Początek domyślnie przyjmuje datę obowiązywania Twojego domu; aby sięgnąć dalej wstecz, najpierw dodaj okres lokalizacji dla tego zakresu.",
"run": "Uzupełnij"
- }
+ },
+ "noResults": "Nie znaleziono pasujących miejsc."
}
},
"profile": "Profil",
@@ -7201,7 +7202,8 @@
"fileType": "Prześlij plik JPEG, PNG, WebP lub PDF.",
"extractFailed": "Nie udało się odczytać wyniku. Spróbuj wyraźniejszego zdjęcia.",
"pdfNeedsAnthropic": "Skanowanie PDF wymaga dostawcy wizji Claude; prześlij zamiast tego zdjęcie."
- }
+ },
+ "loadMoreReadings": "Wczytaj więcej pomiarów"
},
"modules": {
"cycle": {
@@ -7319,8 +7321,8 @@
"note": "Notatka",
"notePlaceholder": "Cokolwiek wartego zapamiętania (zaszyfrowane).",
"saveError": "Nie udało się zapisać epizodu. Spróbuj ponownie.",
- "parent": "Part of",
- "parentNone": "Not linked"
+ "parent": "Część",
+ "parentNone": "Niepowiązane"
},
"sheet": {
"title": "Zapisz {date}",
@@ -7401,32 +7403,32 @@
"fever": "Temperatura: {value} °C",
"empty": "Nie zapisano jeszcze żadnych dni."
},
- "loadError": "Could not load this episode.",
- "backToList": "Back to journal",
+ "loadError": "Nie udało się wczytać tego epizodu.",
+ "backToList": "Powrót do dziennika",
"detail": {
"note": "Note"
},
"edit": {
- "title": "Edit episode",
- "description": "Update this episode's details."
+ "title": "Edytuj epizod",
+ "description": "Zaktualizuj szczegóły tego epizodu."
},
"menu": {
- "label": "Episode actions"
+ "label": "Działania epizodu"
},
"deleteConfirm": {
- "title": "Delete this episode?",
- "body": "“{label}” and all its day-logs will be removed. This cannot be undone."
+ "title": "Usunąć ten epizod?",
+ "body": "„{label}” oraz wszystkie powiązane wpisy dzienne zostaną usunięte. Tej operacji nie można cofnąć."
},
"vital": {
- "RESTING_HEART_RATE": "Resting heart rate",
- "HEART_RATE_VARIABILITY": "Heart-rate variability",
+ "RESTING_HEART_RATE": "Spoczynkowe tętno",
+ "HEART_RATE_VARIABILITY": "Zmienność rytmu serca",
"RECOVERY_SCORE": "Recovery",
- "OXYGEN_SATURATION": "Oxygen saturation",
- "BODY_TEMPERATURE": "Body temperature",
- "SKIN_TEMPERATURE": "Skin temperature",
- "RESPIRATORY_RATE": "Respiratory rate",
- "BLOOD_PRESSURE_SYS": "Systolic blood pressure",
- "BLOOD_PRESSURE_DIA": "Diastolic blood pressure",
+ "OXYGEN_SATURATION": "Saturacja tlenem",
+ "BODY_TEMPERATURE": "Temperatura ciała",
+ "SKIN_TEMPERATURE": "Temperatura skóry",
+ "RESPIRATORY_RATE": "Częstość oddechów",
+ "BLOOD_PRESSURE_SYS": "Ciśnienie skurczowe",
+ "BLOOD_PRESSURE_DIA": "Ciśnienie rozkurczowe",
"PULSE": "Pulse",
"WEIGHT": "Weight"
},
@@ -7741,7 +7743,8 @@
"label": "Prześlij dokument",
"kindLabel": "Typ dokumentu",
"button": "Prześlij",
- "uploading": "Odczytywanie…"
+ "uploading": "Odczytywanie…",
+ "fileLabel": "Plik dokumentu"
},
"kind": {
"DOCTOR_REPORT": "Raport lekarski",
From 8e238d183431138aa36dd5bfe172e761343d664f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 28 Jun 2026 18:29:33 +0200
Subject: [PATCH 80/94] a11y: lift the shared Coach icon and info-hint to the
44px touch floor
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The top-right Coach hand-off icon button and the info-hint trigger sat
below the WCAG 2.5.5 44px minimum on touch. Grow both hit boxes to 44px
on touch while keeping the visible footprint unchanged — the Coach button
is a fill-less ghost, and the info-hint offsets its padding with an equal
negative margin so inline layout is untouched.
---
src/components/insights/ask-coach-action.tsx | 6 +++++-
src/components/ui/info-hint.tsx | 6 +++++-
2 files changed, 10 insertions(+), 2 deletions(-)
diff --git a/src/components/insights/ask-coach-action.tsx b/src/components/insights/ask-coach-action.tsx
index 08aa56d33..c0e038126 100644
--- a/src/components/insights/ask-coach-action.tsx
+++ b/src/components/insights/ask-coach-action.tsx
@@ -146,7 +146,11 @@ export function AskCoachIconButton({
aria-label={accessibleLabel}
onClick={() => launch.askCoach(question, scope, autoSend)}
className={cn(
- "text-muted-foreground hover:text-foreground size-8",
+ // Keep the quiet 32px visual on pointer surfaces, but grow the
+ // tap target to the WCAG 2.5.5 44px floor on touch (the ghost has
+ // no fill, so the larger hit box is invisible). Matches the
+ // header-action convention on the biomarker detail page.
+ "text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:size-8",
className,
)}
>
diff --git a/src/components/ui/info-hint.tsx b/src/components/ui/info-hint.tsx
index 671b09cec..d3caa2de9 100644
--- a/src/components/ui/info-hint.tsx
+++ b/src/components/ui/info-hint.tsx
@@ -36,7 +36,11 @@ export function InfoHint({
data-slot="info-hint"
aria-label={t("common.moreInfo")}
className={cn(
- "text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 -m-1 inline-flex min-h-6 min-w-6 shrink-0 items-center justify-center rounded-full p-1 transition-colors focus-visible:ring-2 focus-visible:outline-none",
+ // A 44px (WCAG 2.5.5) tap target whose visible footprint stays at
+ // the 24px the icon needs: the 44px box is offset by an equal
+ // negative margin (`p-2.5` + `-m-2.5`), so surrounding inline
+ // layout is unchanged while the touchable area meets the floor.
+ "text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 -m-2.5 inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full p-2.5 transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
>
From 38c00100d0fc0bececb2e02db0ec21c8c299727a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 28 Jun 2026 18:29:39 +0200
Subject: [PATCH 81/94] fix(insights): sleep-debt explainer on the resolved
card; Skeleton primitive for the narrative loader
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The sleep-debt computed-engine InfoHint only rendered in the still-learning
state — but the resolved full-debt card is the common case and where the
figure most needs the 'this is our own estimate, not a wearable's native
number' context. Show it there too. Separately, swap the period-narrative
loading shell's hand-rolled muted divs for the shared Skeleton primitive
so its shimmer matches every other loader.
---
src/components/insights/period-narrative-card.tsx | 7 ++++---
src/components/insights/sleep/sleep-debt-card.tsx | 7 +++++++
2 files changed, 11 insertions(+), 3 deletions(-)
diff --git a/src/components/insights/period-narrative-card.tsx b/src/components/insights/period-narrative-card.tsx
index 47ffa3cde..222401b94 100644
--- a/src/components/insights/period-narrative-card.tsx
+++ b/src/components/insights/period-narrative-card.tsx
@@ -9,6 +9,7 @@ import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
import { queryKeys } from "@/lib/query-keys";
import { useAuth } from "@/hooks/use-auth";
import { cn } from "@/lib/utils";
+import { Skeleton } from "@/components/ui/skeleton";
import { SectionHeading } from "@/components/insights/section-heading";
import { AskCoachAction } from "@/components/insights/ask-coach-action";
import { ProseBlocks } from "@/components/insights/prose-blocks";
@@ -108,9 +109,9 @@ export function PeriodNarrativeCard({
aria-hidden="true"
className={SKELETON_SHELL}
>
-
-
-
+
+
+
);
diff --git a/src/components/insights/sleep/sleep-debt-card.tsx b/src/components/insights/sleep/sleep-debt-card.tsx
index db4d329cb..cb246d5d5 100644
--- a/src/components/insights/sleep/sleep-debt-card.tsx
+++ b/src/components/insights/sleep/sleep-debt-card.tsx
@@ -70,6 +70,13 @@ export function SleepDebtCard({ debt }: { debt: SleepDebtDto }) {
{t("insights.sleep.debt.title")}
+ {/* v1.25.0 — the resolved full-debt card is the common case and the
+ place the figure most needs context: explain the COMPUTED engine
+ and that it differs from a wearable's native number here too, not
+ only in the still-learning `partial` state above. */}
+ {debt.source === "COMPUTED" ? (
+
+ ) : null}
From a3b32524f7cf5f27066a48d35f313cce28f099f1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 28 Jun 2026 18:29:45 +0200
Subject: [PATCH 82/94] fix(documents): skeleton loading, calmer review badge,
larger touch targets
The inbound-document list and review panels used centered spinners; swap
them for the app's Skeleton pattern so the resolved content lands without
a layout jump. The low-confidence review badge moves from destructive red
to a calm amber, matching the surface's non-alarming posture. Approve /
reject / edit / save controls grow to the 44px touch floor, the upload
file input gains an accessible label, and each document row exposes
aria-expanded.
---
.../documents/inbound-documents-view.tsx | 51 +++++++++++++------
1 file changed, 36 insertions(+), 15 deletions(-)
diff --git a/src/components/documents/inbound-documents-view.tsx b/src/components/documents/inbound-documents-view.tsx
index b95ec9a62..83a300b1a 100644
--- a/src/components/documents/inbound-documents-view.tsx
+++ b/src/components/documents/inbound-documents-view.tsx
@@ -27,6 +27,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { Separator } from "@/components/ui/separator";
+import { Skeleton } from "@/components/ui/skeleton";
import { apiDelete, apiFetch, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
@@ -136,6 +137,7 @@ export function InboundDocumentsView() {
type="file"
accept="image/jpeg,image/png,image/webp,application/pdf"
className="max-w-xs"
+ aria-label={t("documents.upload.fileLabel")}
disabled={upload.isPending}
/>
{list.isLoading ? (
-
-
-
+ // Skeleton rows (not a centered spinner) so the resolved list lands
+ // in place without a layout jump — the app's loading convention.
+ <>
+ {[0, 1, 2].map((i) => (
+
+ ))}
+ >
) : !list.data || list.data.documents.length === 0 ? (