From 77c73517c0f0b4b8bf69d3f93a729586dd268f65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:32:48 +0200 Subject: [PATCH 01/15] fix(jobs): apply pool cap + statement timeout to worker Prisma clients The 60s statement_timeout + pool ceiling applied to the web client in db.ts were never applied to the worker clients. A pathological nightly drain on a heavy tenant could run unbounded, pin a connection, and wedge the shared worker pool. Route both worker clients (reminder handlers and the Apple Health import worker) through the same getPoolMax() + buildSessionOptions() helpers so every worker session is timeout-bounded from its first query. --- src/lib/jobs/apple-health-import-worker.ts | 9 +- .../jobs/reminder/__tests__/shared.test.ts | 107 ++++++++++++++++++ src/lib/jobs/reminder/shared.ts | 18 ++- 3 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 src/lib/jobs/reminder/__tests__/shared.test.ts diff --git a/src/lib/jobs/apple-health-import-worker.ts b/src/lib/jobs/apple-health-import-worker.ts index becfca88d..f3d224894 100644 --- a/src/lib/jobs/apple-health-import-worker.ts +++ b/src/lib/jobs/apple-health-import-worker.ts @@ -26,7 +26,7 @@ */ import { unlinkSync } from "node:fs"; import { PrismaClient } from "@/generated/prisma/client"; -import { toJson } from "@/lib/db"; +import { buildSessionOptions, getPoolMax, toJson } from "@/lib/db"; import { PrismaPg } from "@prisma/adapter-pg"; import type { Job } from "pg-boss"; @@ -61,8 +61,15 @@ export interface AppleHealthImportPayload { let workerPrismaSingleton: PrismaClient | null = null; function getWorkerPrisma(): PrismaClient { if (!workerPrismaSingleton) { + // Inherit the web client's pool ceiling + per-session statement timeouts + // (`src/lib/db.ts`). The timeout is per-statement, not per-job, so the + // long-running bulk import stays safe while a single wedged UPSERT can no + // longer pin a worker connection indefinitely. + const sessionOptions = buildSessionOptions(); const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL!, + max: getPoolMax(), + ...(sessionOptions ? { options: sessionOptions } : {}), }); workerPrismaSingleton = new PrismaClient({ adapter }); } diff --git a/src/lib/jobs/reminder/__tests__/shared.test.ts b/src/lib/jobs/reminder/__tests__/shared.test.ts new file mode 100644 index 000000000..feecd51c3 --- /dev/null +++ b/src/lib/jobs/reminder/__tests__/shared.test.ts @@ -0,0 +1,107 @@ +/** + * F-DB-1 — the worker Prisma client must inherit the same pool ceiling + + * session timeouts as the web client (`src/lib/db.ts`). + * + * Before this fix the worker adapter was built with a bare + * `{ connectionString }`, so a pathological nightly drain on a heavy tenant + * could run unbounded, pin a connection indefinitely, and wedge the shared + * worker pool. This test pins the contract: `getWorkerPrisma()` constructs its + * `PrismaPg` adapter with `max` from `getPoolMax()` and the `options` startup + * string from `buildSessionOptions()`. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const adapterCtor = vi.fn(); +const clientCtor = vi.fn(); + +vi.mock("@prisma/adapter-pg", () => ({ + PrismaPg: class { + constructor(...args: unknown[]) { + adapterCtor(...args); + } + }, +})); + +vi.mock("@/generated/prisma/client", () => ({ + PrismaClient: class { + constructor(...args: unknown[]) { + clientCtor(...args); + } + }, +})); + +describe("getWorkerPrisma", () => { + beforeEach(() => { + vi.resetModules(); + adapterCtor.mockClear(); + clientCtor.mockClear(); + process.env.DATABASE_URL = "postgres://user:pass@localhost:5432/db"; + delete process.env.DATABASE_POOL_MAX; + delete process.env.DATABASE_STATEMENT_TIMEOUT_MS; + }); + + afterEach(() => { + delete process.env.DATABASE_POOL_MAX; + delete process.env.DATABASE_STATEMENT_TIMEOUT_MS; + }); + + it("builds the adapter with a pool cap and session timeouts", async () => { + const { getWorkerPrisma } = await import("../shared"); + // Importing ../shared pulls in src/lib/db.ts, which constructs the web + // client's adapter at module load. Clear so we count only the worker's. + adapterCtor.mockClear(); + clientCtor.mockClear(); + getWorkerPrisma(); + + expect(adapterCtor).toHaveBeenCalledTimes(1); + const config = adapterCtor.mock.calls[0][0] as { + connectionString: string; + max: number; + options?: string; + }; + expect(config.connectionString).toBe( + "postgres://user:pass@localhost:5432/db", + ); + // Default pool ceiling — must not fall back to the pg library default of 10. + expect(config.max).toBe(20); + // Both session timeouts applied at connection establishment. + expect(config.options).toContain("-c statement_timeout=60000"); + expect(config.options).toContain( + "-c idle_in_transaction_session_timeout=60000", + ); + }); + + it("honours the shared env knobs", async () => { + process.env.DATABASE_POOL_MAX = "12"; + process.env.DATABASE_STATEMENT_TIMEOUT_MS = "30000"; + const { getWorkerPrisma } = await import("../shared"); + adapterCtor.mockClear(); + getWorkerPrisma(); + + const config = adapterCtor.mock.calls[0][0] as { + max: number; + options?: string; + }; + expect(config.max).toBe(12); + expect(config.options).toContain("-c statement_timeout=30000"); + }); + + it("omits the options string when the timeout is disabled", async () => { + process.env.DATABASE_STATEMENT_TIMEOUT_MS = "0"; + const { getWorkerPrisma } = await import("../shared"); + adapterCtor.mockClear(); + getWorkerPrisma(); + + const config = adapterCtor.mock.calls[0][0] as { options?: string }; + expect(config.options).toBeUndefined(); + }); + + it("reuses a single client across calls", async () => { + const { getWorkerPrisma } = await import("../shared"); + clientCtor.mockClear(); + const a = getWorkerPrisma(); + const b = getWorkerPrisma(); + expect(a).toBe(b); + expect(clientCtor).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/jobs/reminder/shared.ts b/src/lib/jobs/reminder/shared.ts index 05cd9ba58..079ab63f7 100644 --- a/src/lib/jobs/reminder/shared.ts +++ b/src/lib/jobs/reminder/shared.ts @@ -6,6 +6,7 @@ */ import { PrismaClient } from "@/generated/prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; +import { buildSessionOptions, getPoolMax } from "@/lib/db"; export function parseTimeToMinutes(value: string): number { const [h, m] = value.split(":").map(Number); @@ -36,7 +37,22 @@ let workerPrisma: PrismaClient | null = null; export function getWorkerPrisma(): PrismaClient { if (!workerPrisma) { - const adapter = new PrismaPg({ connectionString: DATABASE_URL }); + // Apply the same pool ceiling + session timeouts the web client uses + // (`src/lib/db.ts`). Without them the worker client ran unbounded: a + // pathological nightly drain on a heavy tenant could pin a connection + // indefinitely and wedge the shared worker pool. `getPoolMax()` caps the + // slot count; `buildSessionOptions()` applies `statement_timeout` + + // `idle_in_transaction_session_timeout` at connection establishment so + // every worker session is timeout-bounded from its first query. Both share + // the same `DATABASE_POOL_MAX` / `DATABASE_STATEMENT_TIMEOUT_MS` env knobs + // as the web tier, and the web + worker containers share one Postgres so a + // single ceiling covers both pools. + const sessionOptions = buildSessionOptions(); + const adapter = new PrismaPg({ + connectionString: DATABASE_URL, + max: getPoolMax(), + ...(sessionOptions ? { options: sessionOptions } : {}), + }); workerPrisma = new PrismaClient({ adapter }); } return workerPrisma; From 4b84a12ede1dd1ae70d3d962bb593e6abe8c2b61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:36:24 +0200 Subject: [PATCH 02/15] fix(oura): isolate the 8 daily collections per-collection (allSettled) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Oura fetched all 8 collections under one Promise.all: a single flaky endpoint or one throwing mapper rejected the whole batch and imported nothing (readiness, sleep, activity, spo2 all went dark together). Settle each collection independently, import what succeeds, and carry the failed ones as a per-collection ledger — mirroring Google Health / Fitbit. The reactive 401-refresh is preserved: a shared expired token 401s every collection, one refresh is done, and only the failed collections retry. A partial failure records the failure and does not stamp success, so the freshness surface stays honest and the next tick refetches. --- src/lib/oura/__tests__/sync.test.ts | 74 +++++++- src/lib/oura/sync.ts | 252 +++++++++++++++++++++------- 2 files changed, 260 insertions(+), 66 deletions(-) diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index e452322f1..2d4f52a2e 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -338,7 +338,79 @@ describe("syncUserOura", () => { reason: "http_403", }), ); - await expect(syncUserOura("u1")).rejects.toBeInstanceOf(OuraApiError); + // A 403 is a per-collection hard failure now (isolated, not a whole-batch + // reject): it records a partial failure and does NOT trigger a refresh. + await syncUserOura("u1"); + expect(refreshMock).not.toHaveBeenCalled(); + expect(recordFailureMock).toHaveBeenCalledWith( + expect.objectContaining({ integration: "oura" }), + ); + expect(recordSuccessMock).not.toHaveBeenCalled(); + }); + + it("isolates one bad collection — siblings still import (F-SYNC-2)", async () => { + getConnMock.mockResolvedValue(CONN); + // Readiness endpoint is flaky (500), but sleep-score + spo2 return data. + fetchReadinessMock.mockRejectedValue( + new OuraApiError({ + verb: "fetchReadiness", + classification: "transient", + httpStatus: 500, + reason: "http_500", + }), + ); + fetchDailySleepMock.mockResolvedValue([ + { id: "s", day: "2026-06-10", score: 80 }, + ]); + fetchSpo2Mock.mockResolvedValue([ + { id: "o", day: "2026-06-10", spo2_percentage: { average: 97 } }, + ]); + + const imported = await syncUserOura("u1"); + + // The healthy collections still wrote their rows — the source is not blanked. + expect(imported).toBe(2); + const written = upsertMock.mock.calls.map( + (c) => c[0].where.userId_type_source_externalId.type, + ); + expect(written).toContain("SLEEP_SCORE"); + expect(written).toContain("OXYGEN_SATURATION"); + // The cycle is still marked failed (partial) so freshness stays honest. + expect(recordFailureMock).toHaveBeenCalledWith( + expect.objectContaining({ integration: "oura" }), + ); + expect(recordSuccessMock).not.toHaveBeenCalled(); expect(refreshMock).not.toHaveBeenCalled(); }); + + it("isolates a throwing mapper — one malformed record does not blank siblings (F-SYNC-2)", async () => { + getConnMock.mockResolvedValue(CONN); + // A malformed readiness record whose mapper throws must not reject the + // whole batch (the old Promise.all failure mode). + fetchReadinessMock.mockReturnValue( + Promise.resolve([ + new Proxy( + {}, + { + get() { + throw new Error("malformed readiness point"); + }, + }, + ), + ]), + ); + fetchSpo2Mock.mockResolvedValue([ + { id: "o", day: "2026-06-10", spo2_percentage: { average: 96 } }, + ]); + + const imported = await syncUserOura("u1"); + + expect(imported).toBe(1); + const written = upsertMock.mock.calls.map( + (c) => c[0].where.userId_type_source_externalId.type, + ); + expect(written).toContain("OXYGEN_SATURATION"); + expect(recordFailureMock).toHaveBeenCalled(); + expect(recordSuccessMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index 363b3c485..5c584433a 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -114,11 +114,42 @@ function toUpsert( })); } +/** One Oura collection: its ledger name and a self-contained fetch+map closure + * that resolves to the collection's mapped upserts. Each closure owns its own + * client call and mapper so a throw is contained to this one collection. */ +interface OuraCollectionSpec { + name: string; + collect: (token: string) => Promise; +} + +/** A single collection that failed to fetch/map, carried so the caller can + * classify + record the failure without blanking the collections that did + * succeed. */ +export interface OuraCollectionFailure { + name: string; + err: unknown; +} + +export interface OuraFetchResult { + readings: OuraMeasurementUpsert[]; + failures: OuraCollectionFailure[]; +} + /** * Fetch every Oura daily collection for a user with a single reactive - * refresh-on-401 retry. Returns the raw mapped readings (not yet upserted). - * Throws a classified `OuraApiError` on a hard failure so the caller records - * the ledger entry. + * refresh-on-401 retry, ISOLATED per collection. Unlike a bare `Promise.all`, + * one flaky endpoint or one throwing mapper no longer rejects the whole batch + * (which used to blank readiness, sleep, activity, spo2 all at once). Each + * collection is settled independently (`Promise.allSettled`); the ones that + * succeed import, the ones that throw are returned as `failures` for the caller + * to record — mirroring Google Health's / Fitbit's per-collection hard-fail + * ledger. + * + * The reactive refresh is preserved: because every collection shares one access + * token, an expired token 401s all of them together; a single refresh is done + * once and ONLY the failed collections are retried with the rotated token. A + * failed refresh (`invalid_grant`) still throws so the caller parks the whole + * connection at `reauth_required`. */ async function fetchAll( userId: string, @@ -126,72 +157,131 @@ async function fetchAll( refreshToken: string, refreshTokenCiphertext: string, lookbackDays: number, -): Promise { +): Promise { const now = new Date(); const start = new Date(now.getTime() - lookbackDays * 24 * 60 * 60 * 1000); const query = { startDate: ymd(start), endDate: ymd(now) }; - const run = async (token: string): Promise => { - const [ - readiness, - sleeps, - activities, - dailySleep, - spo2, - vo2max, - cardioAge, - resilience, - ] = await Promise.all([ - fetchReadiness(token, query), - fetchSleep(token, query), - fetchDailyActivity(token, query), - fetchDailySleep(token, query), - fetchDailySpo2(token, query), - fetchVo2Max(token, query), - fetchCardiovascularAge(token, query), - fetchResilience(token, query), - ]); - const out: OuraMeasurementUpsert[] = []; - for (const r of readiness) - out.push(...toUpsert(mapReadiness(r), "readiness")); - for (const s of sleeps) out.push(...toUpsert(mapSleep(s), "sleep")); - for (const a of activities) - out.push(...toUpsert(mapDailyActivity(a), "activity")); - for (const d of dailySleep) - out.push(...toUpsert(mapDailySleep(d), "daily_sleep")); - for (const s of spo2) out.push(...toUpsert(mapDailySpo2(s), "spo2")); - for (const v of vo2max) out.push(...toUpsert(mapVo2Max(v), "vo2max")); - for (const c of cardioAge) - out.push(...toUpsert(mapCardiovascularAge(c), "cardio_age")); - for (const r of resilience) - out.push(...toUpsert(mapResilience(r), "resilience")); - return out; + const collections: OuraCollectionSpec[] = [ + { + name: "readiness", + collect: async (t) => + (await fetchReadiness(t, query)).flatMap((r) => + toUpsert(mapReadiness(r), "readiness"), + ), + }, + { + name: "sleep", + collect: async (t) => + (await fetchSleep(t, query)).flatMap((s) => + toUpsert(mapSleep(s), "sleep"), + ), + }, + { + name: "activity", + collect: async (t) => + (await fetchDailyActivity(t, query)).flatMap((a) => + toUpsert(mapDailyActivity(a), "activity"), + ), + }, + { + name: "daily_sleep", + collect: async (t) => + (await fetchDailySleep(t, query)).flatMap((d) => + toUpsert(mapDailySleep(d), "daily_sleep"), + ), + }, + { + name: "spo2", + collect: async (t) => + (await fetchDailySpo2(t, query)).flatMap((s) => + toUpsert(mapDailySpo2(s), "spo2"), + ), + }, + { + name: "vo2max", + collect: async (t) => + (await fetchVo2Max(t, query)).flatMap((v) => + toUpsert(mapVo2Max(v), "vo2max"), + ), + }, + { + name: "cardio_age", + collect: async (t) => + (await fetchCardiovascularAge(t, query)).flatMap((c) => + toUpsert(mapCardiovascularAge(c), "cardio_age"), + ), + }, + { + name: "resilience", + collect: async (t) => + (await fetchResilience(t, query)).flatMap((r) => + toUpsert(mapResilience(r), "resilience"), + ), + }, + ]; + + // Run a set of collections isolated from one another. A rejection is captured + // per-collection, never propagated, so siblings still resolve. + const attempt = async ( + token: string, + specs: OuraCollectionSpec[], + ): Promise<{ + readings: OuraMeasurementUpsert[]; + failed: OuraCollectionSpec[]; + failures: OuraCollectionFailure[]; + auth401: boolean; + }> => { + const settled = await Promise.allSettled( + specs.map((spec) => spec.collect(token)), + ); + const readings: OuraMeasurementUpsert[] = []; + const failed: OuraCollectionSpec[] = []; + const failures: OuraCollectionFailure[] = []; + let auth401 = false; + settled.forEach((res, i) => { + const spec = specs[i]!; + if (res.status === "fulfilled") { + readings.push(...res.value); + } else { + const err = res.reason; + if (err instanceof OuraApiError && err.httpStatus === 401) + auth401 = true; + failed.push(spec); + failures.push({ name: spec.name, err }); + } + }); + return { readings, failed, failures, auth401 }; }; - try { - return await run(accessToken); - } catch (err) { - // Reactive refresh: a 401 means the access token expired. Refresh once - // (rotating both tokens) and retry. A 403 / other error is NOT a refresh - // case — rethrow so the caller classifies it. - if (!(err instanceof OuraApiError) || err.httpStatus !== 401) throw err; - - const creds = await getOuraClientCredentials(userId); - if (!creds) throw err; - - const rotated = await refreshAccessToken(refreshToken, creds); - // Compare-and-swap persist: on a lost race against a concurrent sync this - // returns the peer's freshly rotated access token rather than the (now - // invalidated) one we just minted, so neither sync parks the connection. - const usableToken = await storeOuraTokens( - userId, - rotated.access_token, - rotated.refresh_token, - refreshTokenCiphertext, - ); - if (!usableToken) throw err; - return run(usableToken); + const first = await attempt(accessToken, collections); + if (!first.auth401) { + return { readings: first.readings, failures: first.failures }; } + + // Reactive refresh: the access token expired (a 401 on the shared token). + // Refresh once (rotating both tokens) and retry ONLY the collections that + // failed. A failed refresh throws so the caller parks reauth_required. + const creds = await getOuraClientCredentials(userId); + if (!creds) return { readings: first.readings, failures: first.failures }; + + const rotated = await refreshAccessToken(refreshToken, creds); + // Compare-and-swap persist: on a lost race against a concurrent sync this + // returns the peer's freshly rotated access token rather than the (now + // invalidated) one we just minted, so neither sync parks the connection. + const usableToken = await storeOuraTokens( + userId, + rotated.access_token, + rotated.refresh_token, + refreshTokenCiphertext, + ); + if (!usableToken) return { readings: first.readings, failures: first.failures }; + + const retry = await attempt(usableToken, first.failed); + return { + readings: [...first.readings, ...retry.readings], + failures: retry.failures, + }; } /** @@ -206,9 +296,9 @@ export async function syncUserOura( const conn = await getOuraConnection(userId); if (!conn) return 0; - let readings: OuraMeasurementUpsert[]; + let result: OuraFetchResult; try { - readings = await fetchAll( + result = await fetchAll( userId, conn.accessToken, conn.refreshToken, @@ -216,6 +306,8 @@ export async function syncUserOura( opts.lookbackDays ?? OURA_SYNC_LOOKBACK_DAYS, ); } catch (err) { + // Only a whole-connection failure (a failed token refresh → reauth) reaches + // here now; per-collection failures are captured on `result.failures`. await recordSyncFailure({ userId, integration: "oura", @@ -229,7 +321,37 @@ export async function syncUserOura( throw err; } - const imported = await upsertOuraMeasurements(userId, readings); + // Import everything the healthy collections returned regardless of whether a + // sibling collection failed — one bad collection must not blank the source. + const imported = await upsertOuraMeasurements(userId, result.readings); + + if (result.failures.length > 0) { + // Partial failure: keep the cycle honest. Record the failure and do NOT + // stamp success, so the freshness surface reflects that some collections + // are behind and the next tick refetches them, rather than showing green. + const firstErr = result.failures[0]!.err; + getEvent()?.addWarning( + `oura: ${result.failures.length} collection(s) failed for ${userId}: ${result.failures + .map((f) => f.name) + .join(", ")}`, + ); + await recordSyncFailure({ + userId, + integration: "oura", + kind: classifyOuraFailure(firstErr), + message: `partial sync failure (${result.failures + .map((f) => f.name) + .join(", ")}): ${ + firstErr instanceof Error ? firstErr.message : String(firstErr) + }`, + errorCode: + firstErr instanceof OuraApiError && firstErr.httpStatus != null + ? String(firstErr.httpStatus) + : undefined, + }); + return imported; + } + await recordSyncSuccess(userId, "oura"); return imported; } From fb6813bcdbd23102f75ed194035a1b07b1ce7e5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:38:48 +0200 Subject: [PATCH 03/15] fix(medications): bucket the local day in the user's zone, DST-safe Five timezone-correctness fixes across the medication / compliance surfaces: - getUserTodayBounds now derives both bounds through the DST-safe startOfLocalDayInTz, so the today window is 23h on spring-forward and 25h on fall-back instead of a hardcoded 24h sampled at now. A 23:30 local dose no longer drops off the med cards, dashboard, PWA badge and reminder idempotency near midnight on a transition day. - dailyComplianceRatesFromLedger takes the user's zone and buckets each per-day rate with userDayKey, so a non-Berlin user's evening dose is filed on the local day it was due rather than the next Berlin day. - classifyIntakeTiming interprets the window HH:mm in an optional zone; the achievements perfect-day check passes the user's zone and reads the scheduled minute-of-day in-zone, so a dose far from UTC is graded against local wall time. - The /insights greeting resolves the hour in the user's stored zone via the new hourInTz helper, matching the dashboard hero. - The doctor-report and schedule-anchored compliance queries load schedules through SCHEDULE_COMPLIANCE_SELECT instead of a broad include, so a future engine column reaches both surfaces. --- src/components/insights/hero-strip.tsx | 13 ++++-- .../__tests__/compliance-bundle.test.ts | 4 +- src/lib/analytics/compliance.ts | 34 +++++++++++--- .../analytics/schedule-anchored-compliance.ts | 5 ++- src/lib/doctor-report-data.ts | 6 ++- src/lib/gamification/achievements-result.ts | 18 +++++--- .../insights/medication-compliance-status.ts | 13 +++--- src/lib/tz/format.ts | 12 +++++ src/lib/tz/local-day.ts | 44 ++++++++----------- src/lib/tz/resolver.ts | 1 + 10 files changed, 99 insertions(+), 51 deletions(-) diff --git a/src/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx index d97dbf8b9..7047ed596 100644 --- a/src/components/insights/hero-strip.tsx +++ b/src/components/insights/hero-strip.tsx @@ -7,6 +7,7 @@ import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { formatUpdatedLabel } from "@/lib/i18n/relative-time"; import { useAuth } from "@/hooks/use-auth"; import { useModuleEnabled } from "@/hooks/use-module-enabled"; +import { hourInTz, DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { ProseBlocks } from "@/components/insights/prose-blocks"; import { cn } from "@/lib/utils"; import { @@ -138,8 +139,7 @@ interface HeroStripProps { * rare to see /insights at 03:00 and "Guten * Morgen" pre-5am reads odd) */ -function resolveGreetingKey(now: Date): string { - const hour = now.getHours(); +function resolveGreetingKey(hour: number): string { if (hour >= 5 && hour < 12) return "insights.heroGreetingMorning"; if (hour >= 12 && hour < 18) return "insights.heroGreetingAfternoon"; // 18:00-22:59 maps to "Good evening"; 23:00-04:59 also maps to @@ -221,7 +221,14 @@ export function HeroStrip({ // pillar from the number itself). SSR-safe + default-on, matching the // disabled-allowlist gate. const moodEnabled = useModuleEnabled("mood"); - const greetingKey = resolveGreetingKey(now ?? new Date()); + // Greeting hour in the user's configured zone, not the browser's — a + // traveller / VPN user / wrong device clock otherwise sees the wrong + // salutation. Mirrors the dashboard hero (`hourInTimezone(now, userTz)`). + const greetingHour = hourInTz( + now ?? new Date(), + user?.timezone ?? DEFAULT_TIMEZONE, + ); + const greetingKey = resolveGreetingKey(greetingHour); const greetingBase = t(greetingKey); const greeting = userName ? `${greetingBase}, ${userName}` : greetingBase; const subtitle = briefing?.paragraph ?? t("insights.heroFallbackSubtitle"); diff --git a/src/lib/analytics/__tests__/compliance-bundle.test.ts b/src/lib/analytics/__tests__/compliance-bundle.test.ts index 881ee565e..f39030849 100644 --- a/src/lib/analytics/__tests__/compliance-bundle.test.ts +++ b/src/lib/analytics/__tests__/compliance-bundle.test.ts @@ -273,7 +273,7 @@ describe("dailyComplianceRatesFromLedger — cadence-aware per-day series", () = (r) => r.at.getTime() >= from.getTime() && r.at.getTime() <= NOW.getTime(), ); - const series = dailyComplianceRatesFromLedger(windowRows); + const series = dailyComplianceRatesFromLedger(windowRows, TZ); // Only the Mondays in the window produce a point — Tuesday…Sunday emit // nothing, so a 7×-denser daily denominator can never drag the rate down. @@ -319,7 +319,7 @@ describe("dailyComplianceRatesFromLedger — cadence-aware per-day series", () = (r) => r.at.getTime() >= from.getTime() && r.at.getTime() <= NOW.getTime(), ); - const series = dailyComplianceRatesFromLedger(windowRows); + const series = dailyComplianceRatesFromLedger(windowRows, TZ); // Still one point per due Monday — never a point on a non-due weekday. // Five Mondays in the window; the skipped take leaves one as a miss. diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index c85c11fe1..28f2a12e2 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -52,7 +52,8 @@ import { type RecurrenceContext, type ScheduleType, } from "@/lib/medications/scheduling/recurrence"; -import { toBerlinDayKey } from "@/lib/tz/resolver"; +import { userDayKey } from "@/lib/tz/resolver"; +import { localHmAsUtc } from "@/lib/tz/local-day"; export interface IntakeEvent { takenAt: Date | null; @@ -126,10 +127,19 @@ function parseHHmm(time: string): { hours: number; minutes: number } { } /** - * Build a Date for a given "HH:mm" on a specific date. + * Build the UTC instant of local "HH:mm" on the calendar day `day` falls on. + * + * When `tz` is supplied the window `HH:mm` is interpreted in the user's zone + * (via {@link localHmAsUtc}) on the local day of `day` — so an "08:00" slot is + * 08:00 LOCAL, not 08:00 UTC. Without `tz` it keeps the legacy UTC anchoring + * (`setUTCHours`), which is only correct for a UTC user; a caller far from UTC + * must pass its zone or the punctuality band is offset by the zone's offset. */ -function toDateOnDay(time: string, day: Date): Date { +function toDateOnDay(time: string, day: Date, tz?: string): Date { const { hours, minutes } = parseHHmm(time); + if (tz) { + return localHmAsUtc(day, tz, hours, minutes); + } const d = new Date(day); d.setUTCHours(hours, minutes, 0, 0); return d; @@ -157,18 +167,21 @@ function toDateOnDay(time: string, day: Date): Date { * @param options.lateMinutes Width of the `late` band beyond the 3-hour * on-time tolerance, in minutes. Defaults to 120. Doses past this * tail land in `very_late`. + * @param options.tz IANA zone the window `HH:mm` is interpreted in. Supply + * the user's zone so an "08:00" slot means 08:00 LOCAL; omitting it keeps + * the legacy UTC interpretation (correct only for a UTC user). */ export function classifyIntakeTiming( takenAt: Date | null, windowStart: string, // "HH:mm" windowEnd: string, // "HH:mm" scheduledDate: Date, // the date this was scheduled - options?: { lateMinutes?: number }, + options?: { lateMinutes?: number; tz?: string }, ): IntakeTimingClass { if (takenAt === null) return "missed"; - const start = toDateOnDay(windowStart, scheduledDate); - let end = toDateOnDay(windowEnd, scheduledDate); + const start = toDateOnDay(windowStart, scheduledDate, options?.tz); + let end = toDateOnDay(windowEnd, scheduledDate, options?.tz); // Handle overnight windows (e.g. windowStart="23:00", windowEnd="01:00") if (end <= start) { @@ -1967,9 +1980,16 @@ export interface DailyComplianceRate { * rows (the window hasn't opened) are dropped before grouping, exactly as the * window tally excludes them. A day whose only ledger rows are skips yields no * point (zero denominator) rather than a misleading 0%. + * + * Days are bucketed in the USER's timezone (`tz`). Passing the caller's real + * zone keeps an evening dose on the local day it was actually due: a fixed + * Berlin key filed a `20:00 America/Los_Angeles` dose (04:00 UTC next day) + * into the following Berlin day, splitting one local day's two doses across + * two buckets and skewing every per-day rate for non-Berlin users. */ export function dailyComplianceRatesFromLedger( ledgerRows: DoseHistoryRow[], + tz: string, ): DailyComplianceRate[] { const byDay = new Map< string, @@ -1987,7 +2007,7 @@ export function dailyComplianceRatesFromLedger( // counter — they never enter the day's denominator. if (!isTaken && !isMissed) continue; - const dayKey = toBerlinDayKey(row.at); + const dayKey = userDayKey(row.at, tz); const bucket = byDay.get(dayKey) ?? { taken: 0, missed: 0, date: row.at }; if (isTaken) bucket.taken += 1; else bucket.missed += 1; diff --git a/src/lib/analytics/schedule-anchored-compliance.ts b/src/lib/analytics/schedule-anchored-compliance.ts index da736adab..2801f0613 100644 --- a/src/lib/analytics/schedule-anchored-compliance.ts +++ b/src/lib/analytics/schedule-anchored-compliance.ts @@ -27,6 +27,7 @@ import { buildComplianceMedicationContext, expectedSlotCountForDay, lastNonSkippedTakenAt, + SCHEDULE_COMPLIANCE_SELECT, } from "@/lib/analytics/compliance"; import { getUserTodayBounds } from "@/lib/tz/local-day"; import { userDayKey } from "@/lib/tz/resolver"; @@ -50,7 +51,9 @@ export async function buildScheduleAnchoredComplianceBuckets( const medications = await prisma.medication.findMany({ where: { userId, active: true }, include: { - schedules: true, + // v1.15.20 — the shared compliance select so a future engine column + // reaches this surface the moment it joins SCHEDULE_COMPLIANCE_SELECT. + schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, // v1.16.3 — archived schedule eras for era-aware expected counts. scheduleRevisions: { orderBy: { validFrom: "asc" } }, // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index b4aa23537..f2daa4f1c 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -47,6 +47,7 @@ import { buildComplianceMedicationContext, lastNonSkippedTakenAt, tallyComplianceFromLedger, + SCHEDULE_COMPLIANCE_SELECT, type ComplianceSchedule, type IntakeEvent, type MedicationPauseEraLike, @@ -828,7 +829,10 @@ export async function collectDoctorReportData( prisma.medication.findMany({ where: { userId, active: true }, include: { - schedules: true, + // v1.15.20 — the shared compliance select (plus `label`, which the + // FHIR schedule mapping below needs) so a future engine column added + // to SCHEDULE_COMPLIANCE_SELECT reaches the doctor-report surface. + schedules: { select: { ...SCHEDULE_COMPLIANCE_SELECT, label: true } }, // v1.17 W1a — archived schedule eras so the ledger compliance // builder segments expected-slot expansion against the schedule // that was live on each past day (matches the detail page). diff --git a/src/lib/gamification/achievements-result.ts b/src/lib/gamification/achievements-result.ts index 9cf4aca98..66c53b0ac 100644 --- a/src/lib/gamification/achievements-result.ts +++ b/src/lib/gamification/achievements-result.ts @@ -40,6 +40,7 @@ import { classifyPulse, } from "@/lib/analytics/classifications"; import { classifyIntakeTiming } from "@/lib/analytics/compliance"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; export type AuthedUser = Awaited>["user"]; @@ -270,6 +271,7 @@ function getHealthGreenDaySeries( function getOnTimePerfectDaySeries( intakeEvents: IntakeEventRecord[], schedulesByMedicationId: Map, + tz: string, ) { const eventsByDay = new Map(); @@ -285,7 +287,6 @@ function getOnTimePerfectDaySeries( for (const [dayKey, events] of eventsByDay.entries()) { if (events.length === 0) continue; - const scheduledDate = dayKeyToDate(dayKey); let isPerfectDay = true; for (const event of events) { @@ -299,9 +300,11 @@ function getOnTimePerfectDaySeries( continue; } - const eventMinutes = - event.scheduledFor.getUTCHours() * 60 + - event.scheduledFor.getUTCMinutes(); + // The scheduled minute-of-day must be read in the user's zone: the raw + // `getUTCHours()` would push a non-UTC user's on-time dose past the 3h + // grace and misread a genuinely-perfect day as imperfect. + const scheduledWall = wallClockInTz(event.scheduledFor, tz); + const eventMinutes = scheduledWall.hour * 60 + scheduledWall.minute; let bestSchedule = schedules[0]; let bestDistance = Infinity; @@ -316,11 +319,15 @@ function getOnTimePerfectDaySeries( } } + // Classify punctuality against the slot's real local day: pass the + // canonical instant as the anchor and the user's zone so the window + // `HH:mm` resolves to the correct local wall time, not 08:00 UTC. const timing = classifyIntakeTiming( event.takenAt, bestSchedule.windowStart, bestSchedule.windowEnd, - scheduledDate, + event.scheduledFor, + { tz }, ); // v1.4.34 IW-C — `early` is also a compliant bucket (dose taken @@ -722,6 +729,7 @@ export async function buildAchievementsResult( const onTimeSeries = getOnTimePerfectDaySeries( intakeEvents, schedulesByMedicationId, + user.timezone, ); const complianceSeries = getCompliance80DaySeries( intakeEvents, diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index 32f3bc70d..03f4bf3f8 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -297,12 +297,13 @@ export async function prepareMedicationComplianceStatusForUser( // calendar day as fully dose-due — which made a weekly-cadence med's // per-day series contradict its own compliance30. Reusing the canonical // ledger fixes that by construction. - const perDayRecords = dailyComplianceRatesFromLedger(bundle.ledgerRows).map( - (day) => ({ - measuredAt: day.date, - value: round(day.rate, 1), - }), - ); + const perDayRecords = dailyComplianceRatesFromLedger( + bundle.ledgerRows, + userTz, + ).map((day) => ({ + measuredAt: day.date, + value: round(day.rate, 1), + })); // `applyPayloadBudget` gives the latest-day focus; the compact // graded series is what reaches the prompt. diff --git a/src/lib/tz/format.ts b/src/lib/tz/format.ts index afa740d0e..36ad8cc95 100644 --- a/src/lib/tz/format.ts +++ b/src/lib/tz/format.ts @@ -191,6 +191,18 @@ export function userDayKey(date: Date, tz: string): string { return formatInUserTz(date, tz, "date"); } +/** + * Wall-clock hour (0–23) an observer in `tz` reads off the clock at + * `date`. Used for the time-of-day greeting so a traveller whose device + * clock differs from their configured HealthLog zone still sees the right + * salutation. Falls back to the UTC hour if the zone is unusable. + */ +export function hourInTz(date: Date, tz: string): number { + const safeTz = isValidTimezone(tz) ? tz : DEFAULT_TIMEZONE; + const parsed = parseInt(wallClockParts(date, safeTz).hour, 10); + return Number.isFinite(parsed) ? parsed % 24 : date.getUTCHours(); +} + interface WallClockParts { year: string; month: string; diff --git a/src/lib/tz/local-day.ts b/src/lib/tz/local-day.ts index 42efc54eb..76cf81573 100644 --- a/src/lib/tz/local-day.ts +++ b/src/lib/tz/local-day.ts @@ -13,38 +13,30 @@ import { wallClockInTz } from "./wall-clock"; /** * Get the start (midnight) and end (23:59:59.999) of "today" in the user's * timezone, returned as UTC Date objects suitable for database queries. + * + * DST-safe. Both bounds route through {@link startOfLocalDayInTz}, whose + * two-pass solver settles the zone offset AT the target local midnight — + * not at `now`. The window therefore spans the user's real local day even + * when it is 23 h (spring-forward) or 25 h (fall-back): the previous + * implementation sampled the offset at `now` and hardcoded a 24 h length, + * so on a fall-back day the last local hour (e.g. a 23:30 dose) fell + * OUTSIDE `[midnight, midnight+24h)` and on a spring-forward day the window + * bled one hour into the next local day. */ export function getUserTodayBounds( now: Date, tz: string, ): { start: Date; end: Date } { - const parts = wallClockInTz(now, tz); - - // Build a UTC date that represents the user's local midnight - // by computing the offset between the real UTC time and the local - // representation. - const localMidnightAsUtc = new Date( - Date.UTC(parts.year, parts.month - 1, parts.day, 0, 0, 0, 0), + const start = startOfLocalDayInTz(now, tz); + // Start of the NEXT local day. Advancing 25 h always crosses into the next + // calendar day (a real day is 23–25 h, never ≤ 12.5 h, so 25 h can neither + // fall short of the next day nor skip past it), then floor back to that + // day's local midnight. `end` is the inclusive last millisecond of today. + const nextDayStart = startOfLocalDayInTz( + new Date(start.getTime() + 25 * 60 * 60 * 1000), + tz, ); - - // Compute offset: how far ahead (positive) or behind (negative) the tz is - // from UTC. localMidnightAsUtc represents "midnight in tz as if it were - // UTC"; we need the UTC instant that corresponds to midnight in the tz. - const localNowAsUtc = new Date( - Date.UTC( - parts.year, - parts.month - 1, - parts.day, - parts.hour, - parts.minute, - parts.second, - ), - ); - const offsetMs = - Math.round((localNowAsUtc.getTime() - now.getTime()) / 60000) * 60000; - - const start = new Date(localMidnightAsUtc.getTime() - offsetMs); - const end = new Date(start.getTime() + 24 * 60 * 60 * 1000 - 1); + const end = new Date(nextDayStart.getTime() - 1); return { start, end }; } diff --git a/src/lib/tz/resolver.ts b/src/lib/tz/resolver.ts index 370dfc449..a51feb7a8 100644 --- a/src/lib/tz/resolver.ts +++ b/src/lib/tz/resolver.ts @@ -48,6 +48,7 @@ export { detectBrowserTimezone, formatInUserTz, userDayKey, + hourInTz, type FormatInUserTzShape, } from "./format"; From 62c72782753e6b1035ec33463ff2bba8338fe2d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:40:36 +0200 Subject: [PATCH 04/15] fix(fitbit): port the non-rethrowing hard-fail ledger from Google Health MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fitbit's handleCollectionFetchError soft-skipped a 403 but RETHREW any other hard failure, aborting every endpoint ordered after the bad one in the same resource and stalling the watermark so the whole integration stayed dead. Record hard failures on a per-cycle AsyncLocalStorage ledger and return 0 instead of rethrowing — the sibling endpoints keep fetching, and the ledger still fails the cycle so success is not stamped and the next tick refetches. Also wrap the per-metric map fan-out in the same per-collection catch so one malformed point can't skip its siblings. --- .../fitbit/__tests__/sync-user-fitbit.test.ts | 50 ++++++++++++ src/lib/fitbit/sync-activity.ts | 29 +++++-- src/lib/fitbit/sync-metrics.ts | 29 +++++-- src/lib/fitbit/sync.ts | 62 ++++++++++---- .../__tests__/sync-metrics-failsoft.test.ts | 81 +++++++++++++++++++ src/lib/google-health/sync-metrics.ts | 29 ++++--- 6 files changed, 240 insertions(+), 40 deletions(-) create mode 100644 src/lib/google-health/__tests__/sync-metrics-failsoft.test.ts diff --git a/src/lib/fitbit/__tests__/sync-user-fitbit.test.ts b/src/lib/fitbit/__tests__/sync-user-fitbit.test.ts index e138ab964..4d5a6a81e 100644 --- a/src/lib/fitbit/__tests__/sync-user-fitbit.test.ts +++ b/src/lib/fitbit/__tests__/sync-user-fitbit.test.ts @@ -190,6 +190,56 @@ describe("syncUserFitbit — all-403 looks-healthy guard", () => { }); }); +/** A resource sync that HARD-fails its collection (non-403) and, thanks to the + * ported ledger, records + returns 0 instead of rethrowing. */ +async function hardFail500(...args: unknown[]): Promise { + return handleCollectionFetchError( + "test-resource", + String(args[0]), + new FitbitApiError({ + verb: "fetchTest", + classification: "transient", + httpStatus: 500, + reason: "http_500", + }), + ); +} + +describe("syncUserFitbit — hard-fail ledger (F-SYNC-4)", () => { + it("a non-403 hard failure does NOT rethrow and keeps siblings running", async () => { + // Metrics hard-fails (500) but must not abort the cycle: the sibling + // resources still run, and the whole call resolves (no rethrow). + syncUserMetrics.mockImplementation(hardFail500); + syncUserActivity.mockResolvedValue(2); + syncUserSleep.mockResolvedValue(1); + syncUserWorkout.mockResolvedValue(0); + + // Resolves (no rethrow) with the siblings' imports. + const total = await syncUserFitbit("user1"); + expect(total).toBe(3); + + // Every sibling resource still ran despite the metrics hard failure. + expect(syncUserActivity).toHaveBeenCalled(); + expect(syncUserSleep).toHaveBeenCalled(); + expect(syncUserWorkout).toHaveBeenCalled(); + }); + + it("a hard-failed collection fails the cycle: no success stamp, no watermark", async () => { + syncUserMetrics.mockImplementation(hardFail500); + // Others import so the run is not degenerate — only the ledger keeps it honest. + syncUserActivity.mockResolvedValue(4); + + await syncUserFitbit("user1"); + + // The ledger flips anyFailed → success is NOT stamped and the watermark is + // not advanced, so the next tick refetches the broken collection's window. + expect(recordSyncSuccess).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + // The hard failure was recorded on the status ledger. + expect(recordSyncFailure).toHaveBeenCalled(); + }); +}); + describe("syncUserFitbit — single cycle-wide watermark", () => { it("snapshots the incremental start ONCE and threads the SAME watermark to every resource", async () => { syncUserMetrics.mockResolvedValue(1); diff --git a/src/lib/fitbit/sync-activity.ts b/src/lib/fitbit/sync-activity.ts index d9dec20db..4a31e30a1 100644 --- a/src/lib/fitbit/sync-activity.ts +++ b/src/lib/fitbit/sync-activity.ts @@ -109,14 +109,27 @@ export async function syncUserActivity( forbidden = true; break; } - for (const m of resource.map(body)) { - readings.push({ - type: m.type, - value: m.value, - unit: m.unit, - measuredAt: m.measuredAt, - externalId: externalIdFor(m), - }); + try { + for (const m of resource.map(body)) { + readings.push({ + type: m.type, + value: m.value, + unit: m.unit, + measuredAt: m.measuredAt, + externalId: externalIdFor(m), + }); + } + } catch (err) { + // A malformed point whose mapper throws routes through the same ledger + // as a fetch failure — record it, short-circuit this resource, and let + // the sibling resources keep syncing. + imported += await handleCollectionFetchError( + resource.verb, + userId, + err, + ); + forbidden = true; + break; } } if (forbidden) continue; diff --git a/src/lib/fitbit/sync-metrics.ts b/src/lib/fitbit/sync-metrics.ts index f05ae2fb8..ec26bce68 100644 --- a/src/lib/fitbit/sync-metrics.ts +++ b/src/lib/fitbit/sync-metrics.ts @@ -103,14 +103,27 @@ export async function syncUserMetrics( forbidden = true; break; } - for (const m of resource.map(body)) { - readings.push({ - type: m.type, - value: m.value, - unit: m.unit, - measuredAt: m.measuredAt, - externalId: m.fieldTag, - }); + try { + for (const m of resource.map(body)) { + readings.push({ + type: m.type, + value: m.value, + unit: m.unit, + measuredAt: m.measuredAt, + externalId: m.fieldTag, + }); + } + } catch (err) { + // A malformed point whose mapper throws routes through the same ledger + // as a fetch failure — record it, short-circuit this metric, and let the + // sibling metrics keep syncing. + imported += await handleCollectionFetchError( + resource.verb, + userId, + err, + ); + forbidden = true; + break; } } if (forbidden) continue; diff --git a/src/lib/fitbit/sync.ts b/src/lib/fitbit/sync.ts index ae3c493cf..8411831df 100644 --- a/src/lib/fitbit/sync.ts +++ b/src/lib/fitbit/sync.ts @@ -227,12 +227,34 @@ interface RollupDeferTracker { } const rollupDeferStorage = new AsyncLocalStorage(); +/** + * Per-cycle ledger of collection fetches that HARD-failed (non-403). A hard + * failure on one endpoint must not abort its siblings — weight 500ing must not + * suppress body-fat / spo2 / hrv / rhr / respiratory in the same resource — so + * `handleCollectionFetchError` records the failure here and RETURNS instead of + * rethrowing (the old pattern that aborted every endpoint ordered after the bad + * one and stalled the watermark). The orchestrator reads the ledger to keep the + * cycle's verdict honest: any entry ⇒ the run counts as failed, so success is + * NOT stamped and the next tick refetches the window. Ported from Google + * Health's `hardFailStorage`. + */ +interface HardFailTracker { + failures: string[]; +} +const hardFailStorage = new AsyncLocalStorage(); + /** * Single-source the per-resource collection-fetch error handling. A 403 on one - * data class soft-skips it (warn + return 0) so sibling resources still sync; - * anything else records a classified sync failure and rethrows. A soft-skip - * increments the ambient tracker so `syncUserFitbit` can refuse to stamp success - * on an all-403 grant-revoke cycle that imported nothing. + * data class soft-skips it (warn + return 0) so sibling resources still sync; a + * soft-skip increments the ambient tracker so `syncUserFitbit` can refuse to + * stamp success on an all-403 grant-revoke cycle that imported nothing. + * + * Anything else records a classified sync failure, notes the collection on the + * ambient hard-fail ledger, and RETURNS 0 — it does NOT rethrow. Every call site + * sits in a per-collection catch, so returning lets the sibling endpoints in the + * same resource keep fetching (one 500 on weight must not kill body-fat / spo2 / + * hrv / rhr / respiratory), while the ledger still fails the cycle so the + * watermark is not stamped past the broken collection. */ export async function handleCollectionFetchError( resource: string, @@ -248,7 +270,10 @@ export async function handleCollectionFetchError( return 0; } await recordFitbitSyncFailure(userId, err); - throw err; + getEvent()?.addWarning(`fitbit ${resource} failed for ${userId}: ${err}`); + const hardTracker = hardFailStorage.getStore(); + if (hardTracker) hardTracker.failures.push(resource); + return 0; } /** @@ -640,22 +665,29 @@ export async function syncUserFitbit( const tracker: SoftSkipTracker = { count: 0 }; const deferTracker: RollupDeferTracker = { keys: [] }; + const hardFailTracker: HardFailTracker = { failures: [] }; let total = 0; let anyFailed = false; await softSkipStorage.run(tracker, async () => { - await rollupDeferStorage.run(deferTracker, async () => { - for (const fn of resources) { - try { - total += await fn(userId, resourceOpts); - } catch (err) { - anyFailed = true; - getEvent()?.addWarning( - `fitbit ${fn.name} failed for ${userId}: ${err}`, - ); + await hardFailStorage.run(hardFailTracker, async () => { + await rollupDeferStorage.run(deferTracker, async () => { + for (const fn of resources) { + try { + total += await fn(userId, resourceOpts); + } catch (err) { + anyFailed = true; + getEvent()?.addWarning( + `fitbit ${fn.name} failed for ${userId}: ${err}`, + ); + } } - } + }); }); }); + // A collection that hard-failed inside a resource (recorded on the ledger by + // `handleCollectionFetchError` without aborting its siblings) still fails the + // cycle: partial error, no watermark stamp. + if (hardFailTracker.failures.length > 0) anyFailed = true; // On a `fullSync` backfill the per-write inline rollup hook was deferred; the // accumulated touched type-days collapse into ONE range-recompute spanning the diff --git a/src/lib/google-health/__tests__/sync-metrics-failsoft.test.ts b/src/lib/google-health/__tests__/sync-metrics-failsoft.test.ts new file mode 100644 index 000000000..ccb60412a --- /dev/null +++ b/src/lib/google-health/__tests__/sync-metrics-failsoft.test.ts @@ -0,0 +1,81 @@ +/** + * F-SYNC-3 — the metric MAPPER runs inside a per-type catch, not just the + * fetch. A single malformed point whose `resource.map(point)` throws used to + * escape the METRIC_RESOURCES loop and skip every metric type ordered after it + * (body-fat, spo2, hrv, rhr, respiratory, glucose, temps), also blocking the + * watermark so the bad point refetched hourly and those types stayed dead. + * + * This pins the contract: a throwing mapper on the first type routes through + * `handleCollectionFetchError` and the loop continues to fetch every sibling. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { fetchDataPointsMock, handleErrorMock, mapWeightMock } = vi.hoisted( + () => ({ + fetchDataPointsMock: vi.fn(), + handleErrorMock: vi.fn(async () => 0), + mapWeightMock: vi.fn(() => { + throw new Error("malformed weight point"); + }), + }), +); + +vi.mock("../client", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchDataPoints: fetchDataPointsMock, + mapWeight: mapWeightMock, + }; +}); + +vi.mock("../sync", () => ({ + getValidToken: vi.fn(async () => ({ + accessToken: "token", + connection: { id: "c1", googleUserId: "g1" }, + })), + handleCollectionFetchError: handleErrorMock, + upsertGoogleHealthMeasurements: vi.fn(async () => ({ + imported: 0, + touched: [], + })), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: vi.fn(), update: vi.fn() } }, +})); + +import { syncUserMetrics } from "../sync-metrics"; + +beforeEach(() => { + handleErrorMock.mockClear(); + mapWeightMock.mockClear(); + // Weight returns one point (whose mapper throws); everything else is empty so + // its real mapper is never exercised. + fetchDataPointsMock.mockReset().mockImplementation( + async (_dt: unknown, _token: string, verb: string) => + verb === "fetchWeight" ? [{ any: "point" }] : [], + ); +}); + +describe("syncUserMetrics — a throwing mapper never skips sibling types", () => { + it("routes the map throw through the ledger and keeps fetching the rest", async () => { + await expect(syncUserMetrics("user-1")).resolves.toBe(0); + + // The weight mapper threw and was routed exactly once. + expect(mapWeightMock).toHaveBeenCalled(); + expect(handleErrorMock).toHaveBeenCalledWith( + "fetchWeight", + "user-1", + expect.any(Error), + ); + + // Every metric type ordered AFTER weight still fetched — the loop was not + // aborted by the throw. + const verbs = fetchDataPointsMock.mock.calls.map((c) => c[2]); + expect(verbs).toContain("fetchBodyFat"); + expect(verbs).toContain("fetchSpo2"); + expect(verbs).toContain("fetchRespiratoryRate"); + expect(verbs).toContain("fetchHeartRate"); + }); +}); diff --git a/src/lib/google-health/sync-metrics.ts b/src/lib/google-health/sync-metrics.ts index 408ab7adb..9d366ea38 100644 --- a/src/lib/google-health/sync-metrics.ts +++ b/src/lib/google-health/sync-metrics.ts @@ -146,17 +146,28 @@ export async function syncUserMetrics( continue; } + // The mapper runs INSIDE a per-type catch too: a single malformed point + // whose `resource.map(point)` throws must not escape the METRIC_RESOURCES + // loop and skip every metric type ordered after it (which also blocked the + // watermark, so the bad point refetched hourly and those types stayed dead). + // Route a map throw through the same ledger as a fetch failure — record it, + // fail the cycle, and move on to the next type. const readings: GoogleHealthMeasurementUpsert[] = []; - for (const point of points) { - for (const m of resource.map(point)) { - readings.push({ - type: m.type, - value: m.value, - unit: m.unit, - measuredAt: m.measuredAt, - externalId: m.fieldTag, - }); + try { + for (const point of points) { + for (const m of resource.map(point)) { + readings.push({ + type: m.type, + value: m.value, + unit: m.unit, + measuredAt: m.measuredAt, + externalId: m.fieldTag, + }); + } } + } catch (err) { + imported += await handleCollectionFetchError(resource.verb, userId, err); + continue; } imported += ( await upsertGoogleHealthMeasurements(userId, readings, { From 982fe53b510d2d2be1c03583ed8abe98519678b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:41:52 +0200 Subject: [PATCH 05/15] fix(measurements): fall back to live SQL when the rollup read throws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daily-series reader only fell back to live SQL on a coverage MISS (zero rollup rows); a transient rollup-table error (statement_timeout, deadlock, connection reset) was unwrapped and 500'd the tile on the single-type route even though live SQL could serve it — and diverged from the batched series route, which already caught. Wrap the primary daily read and the long-range tiered read: treat a throw as a coverage miss and fall through to the live date_trunc aggregate. --- .../daily-series-read-tiered.test.ts | 51 +++++++++++++++++ src/lib/measurements/daily-series-read.ts | 57 +++++++++++++++---- 2 files changed, 96 insertions(+), 12 deletions(-) diff --git a/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts b/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts index 7a494c151..f8f09d0bd 100644 --- a/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts +++ b/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts @@ -158,3 +158,54 @@ describe("readDailySeries — long-range tier step-up", () => { expect(mocks.findMany).toHaveBeenCalled(); }); }); + +describe("readDailySeries — rollup read throw falls back to live SQL (F-DB-2)", () => { + it("a rollup-table throw on a normal window degrades to the live date_trunc aggregate, not a 500", async () => { + const to = new Date("2026-06-21T00:00:00.000Z"); + const from = new Date(to.getTime() - 90 * DAY_MS); + + // The primary rollup read throws (statement_timeout / deadlock / reset). + mocks.findMany.mockReset().mockRejectedValue(new Error("statement timeout")); + // Live fallback serves the tile. + mocks.queryRaw.mockResolvedValueOnce([ + { + type: "WEIGHT", + bucket_start: new Date("2026-06-10T00:00:00.000Z"), + avg: 80.5, + cnt: 2, + }, + ]); + + const result = await readDailySeries({ + userId: "u", + type: "WEIGHT", + from, + to, + priorityJson: null, + }); + + // The read did NOT throw — it fell through to live SQL and served a row. + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ type: "WEIGHT", value: 80.5, count: 2 }); + expect(mocks.queryRaw).toHaveBeenCalled(); + }); + + it("a tiered-reader throw on a long window falls through to the daily path", async () => { + const to = new Date("2026-06-21T00:00:00.000Z"); + const from = new Date(to.getTime() - 3650 * DAY_MS); + mocks.readTieredRollupSeries.mockRejectedValueOnce(new Error("deadlock")); + + const result = await readDailySeries({ + userId: "u", + type: "WEIGHT", + from, + to, + priorityJson: null, + }); + + // The daily DAY-rollup read still ran as the fallback (the default findMany + // returns 3 rows), so the throw did not 500 the read. + expect(mocks.findMany).toHaveBeenCalled(); + expect(result).toHaveLength(3); + }); +}); diff --git a/src/lib/measurements/daily-series-read.ts b/src/lib/measurements/daily-series-read.ts index 98cafad59..7461b8dab 100644 --- a/src/lib/measurements/daily-series-read.ts +++ b/src/lib/measurements/daily-series-read.ts @@ -76,17 +76,32 @@ export async function readDailySeries(opts: { (to.getTime() - from.getTime()) / 86_400_000, ); if (windowDaysFull > cap) { - const tiered = await readTieredRollupSeries({ - userId, - type, - // v1.26.0 SEAM-N2 — pass the resolved `[from, to]` bounds so the tier - // reads the REQUESTED window (which may be entirely historic), not a - // trailing "to now" slice. `windowDaysFull` still gates entry above and - // drives the tier width inside the reader. - from, - to, - priorityJson, - }); + // A rollup-table throw here (statement_timeout, deadlock, connection reset) + // must not 500 the whole read — treat it as a coverage miss and fall + // through to the daily path (which itself falls back to live SQL). + let tiered: Awaited> = null; + try { + tiered = await readTieredRollupSeries({ + userId, + type, + // v1.26.0 SEAM-N2 — pass the resolved `[from, to]` bounds so the tier + // reads the REQUESTED window (which may be entirely historic), not a + // trailing "to now" slice. `windowDaysFull` still gates entry above and + // drives the tier width inside the reader. + from, + to, + priorityJson, + }); + } catch (err) { + annotate({ + meta: { + tiered_series_read_threw: true, + tiered_series_read_error: + err instanceof Error ? err.message : String(err), + type, + }, + }); + } if (tiered && tiered.rows.length > 0) { return tiered.rows; } @@ -129,7 +144,25 @@ export async function readDailySeries(opts: { priorityJson, ).slice(0, cap); - const rollupRows = await readRollup(); + // F-DB-2 — the primary rollup read is wrapped: a transient rollup-table error + // (statement_timeout, deadlock, connection reset) must NOT 500 the tile when + // the live table can still serve it. Treat a throw as a coverage miss and fall + // straight through to the live `date_trunc` aggregate — the same posture the + // batched series reader already takes, so the single-type route and the batch + // route no longer diverge on a rollup hiccup. + let rollupRows: Awaited>; + try { + rollupRows = await readRollup(); + } catch (err) { + annotate({ + meta: { + rollup_read_threw: true, + rollup_read_error: err instanceof Error ? err.message : String(err), + type, + }, + }); + return readLiveDaily({ userId, type, from, to, cap, priorityJson }); + } // Coverage-mismatch probe + inline fold (identical to the route). const windowMs = to.getTime() - from.getTime(); From a53dd2b59c10432c455763bd9a937fe83ebbe180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:46:54 +0200 Subject: [PATCH 06/15] fix(doctor-report): mark an undecryptable allergy reaction instead of blanking it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clinician-facing export failed SOFT on an undecryptable allergy reaction (→ null, rendered as a bare '—' indistinguishable from 'no reaction recorded') while a sibling dose-change note fails CLOSED. Extract a pure decryptAllergyReaction helper that keeps 'unreadable' distinct from 'unset', log the swallowed decrypt, and render an honest localized marker in the PDF so a clinician is never shown a silent blank where an unreadable reaction exists. Read-path only — the fail-closed write posture is untouched. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + .../doctor-report-allergy-reaction.test.ts | 57 +++++++++++++++++++ src/lib/doctor-report-data.ts | 56 +++++++++++++++--- src/lib/doctor-report-pdf-core.ts | 6 +- 9 files changed, 116 insertions(+), 9 deletions(-) create mode 100644 src/lib/__tests__/doctor-report-allergy-reaction.test.ts diff --git a/messages/de.json b/messages/de.json index 5073c6e7b..c88ace188 100644 --- a/messages/de.json +++ b/messages/de.json @@ -189,6 +189,7 @@ "illnessColOnset": "Beginn", "illnessColResolved": "Abgeklungen", "allergiesTitle": "Allergien & Unverträglichkeiten", + "reactionUnreadable": "Reaktion erfasst, konnte aber nicht entschlüsselt werden", "allergiesColSubstance": "Substanz", "allergiesColCategory": "Kategorie", "allergiesColKind": "Art", diff --git a/messages/en.json b/messages/en.json index e85e5c5fc..9d65503d1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -189,6 +189,7 @@ "illnessColOnset": "Onset", "illnessColResolved": "Resolved", "allergiesTitle": "Allergies & intolerances", + "reactionUnreadable": "Reaction recorded but could not be decrypted", "allergiesColSubstance": "Substance", "allergiesColCategory": "Category", "allergiesColKind": "Kind", diff --git a/messages/es.json b/messages/es.json index 80d3c7b3c..afdabfc42 100644 --- a/messages/es.json +++ b/messages/es.json @@ -189,6 +189,7 @@ "illnessColOnset": "Inicio", "illnessColResolved": "Resuelta", "allergiesTitle": "Alergias e intolerancias", + "reactionUnreadable": "Reacción registrada pero no se pudo descifrar", "allergiesColSubstance": "Sustancia", "allergiesColCategory": "Categoría", "allergiesColKind": "Tipo", diff --git a/messages/fr.json b/messages/fr.json index 5bd017305..90b18136a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -189,6 +189,7 @@ "illnessColOnset": "Début", "illnessColResolved": "Résolue", "allergiesTitle": "Allergies et intolérances", + "reactionUnreadable": "Réaction enregistrée mais impossible à déchiffrer", "allergiesColSubstance": "Substance", "allergiesColCategory": "Catégorie", "allergiesColKind": "Type", diff --git a/messages/it.json b/messages/it.json index 933289bb6..b4296d3bf 100644 --- a/messages/it.json +++ b/messages/it.json @@ -189,6 +189,7 @@ "illnessColOnset": "Inizio", "illnessColResolved": "Risolta", "allergiesTitle": "Allergie e intolleranze", + "reactionUnreadable": "Reazione registrata ma impossibile da decifrare", "allergiesColSubstance": "Sostanza", "allergiesColCategory": "Categoria", "allergiesColKind": "Tipo", diff --git a/messages/pl.json b/messages/pl.json index fc43f1599..71df1aca9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -189,6 +189,7 @@ "illnessColOnset": "Początek", "illnessColResolved": "Ustąpiła", "allergiesTitle": "Alergie i nietolerancje", + "reactionUnreadable": "Reakcja zapisana, ale nie można jej odszyfrować", "allergiesColSubstance": "Substancja", "allergiesColCategory": "Kategoria", "allergiesColKind": "Rodzaj", diff --git a/src/lib/__tests__/doctor-report-allergy-reaction.test.ts b/src/lib/__tests__/doctor-report-allergy-reaction.test.ts new file mode 100644 index 000000000..cff19fd89 --- /dev/null +++ b/src/lib/__tests__/doctor-report-allergy-reaction.test.ts @@ -0,0 +1,57 @@ +/** + * F-CRYPTO-3 — an undecryptable allergy reaction must be distinguishable from a + * genuinely-unset one on a clinician-facing export. The builder used to fail + * SOFT (→ null, silently omitted) where a sibling dose-change note fails CLOSED, + * so a reaction that existed but was unreadable rendered as a blank "—" that a + * clinician reads as "no reaction recorded". `decryptAllergyReaction` now flags + * the unreadable case so the PDF renders an honest marker. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const decryptMock = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + decryptFromBytes: decryptMock, +})); + +import { decryptAllergyReaction } from "../doctor-report-data"; + +afterEach(() => vi.clearAllMocks()); + +describe("decryptAllergyReaction", () => { + it("returns null / not-unreadable for a genuinely-unset envelope (null)", () => { + expect(decryptAllergyReaction(null)).toEqual({ + reaction: null, + reactionUnreadable: false, + }); + expect(decryptMock).not.toHaveBeenCalled(); + }); + + it("returns null / not-unreadable for an empty envelope", () => { + expect(decryptAllergyReaction(new Uint8Array(0))).toEqual({ + reaction: null, + reactionUnreadable: false, + }); + expect(decryptMock).not.toHaveBeenCalled(); + }); + + it("returns the decrypted reaction on success", () => { + decryptMock.mockReturnValue("hives, swelling"); + expect(decryptAllergyReaction(new Uint8Array([1, 2, 3]))).toEqual({ + reaction: "hives, swelling", + reactionUnreadable: false, + }); + }); + + it("flags reactionUnreadable when the decrypt throws (key gap / GCM corruption)", () => { + decryptMock.mockImplementation(() => { + throw new Error("bad ciphertext"); + }); + // The reaction WAS recorded (non-empty envelope) but cannot be decrypted: + // reaction stays null, but the honest unreadable flag is raised so the + // clinician sees a marker, not a silent blank. + expect(decryptAllergyReaction(new Uint8Array([9, 9, 9]))).toEqual({ + reaction: null, + reactionUnreadable: true, + }); + }); +}); diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index b4aa23537..c9fa20985 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -28,6 +28,7 @@ import type { MeasurementType, } from "@/generated/prisma/client"; import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; +import { getEvent } from "@/lib/logging/context"; import { readNote } from "@/lib/crypto/note-cipher"; import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum"; @@ -342,8 +343,15 @@ export interface DoctorReportData { severity: string | null; /** Status (ACTIVE / INACTIVE / RESOLVED). */ status: string; - /** Decrypted reaction description, or null (unset or key gap). */ + /** Decrypted reaction description, or null (genuinely unset). */ reaction: string | null; + /** + * True when a reaction description WAS stored but could not be decrypted + * (a key-rotation gap / GCM corruption). Distinct from `reaction: null` + * (nothing recorded) so the clinician-facing report shows an honest + * "unreadable" marker rather than a blank that reads as "no reaction". + */ + reactionUnreadable?: boolean; }> | null; /** * v1.27.x — structured family-history records. Reference data, not @@ -764,6 +772,32 @@ export function buildLedgerCompliance( return compliance; } +/** + * Decrypt an allergy reaction envelope for a clinician-facing export, keeping + * "unreadable" distinct from "unset". A genuinely-empty envelope yields + * `{ reaction: null, reactionUnreadable: false }`; a stored-but-undecryptable + * one (key-rotation gap / GCM corruption) yields + * `{ reaction: null, reactionUnreadable: true }` so the report can render an + * honest marker instead of a blank that reads as "no reaction recorded". Pure + * (no logging) so it is unit-testable; the caller logs on the unreadable flag. + */ +export function decryptAllergyReaction(reactionEncrypted: Uint8Array | null): { + reaction: string | null; + reactionUnreadable: boolean; +} { + if (!reactionEncrypted || reactionEncrypted.byteLength === 0) { + return { reaction: null, reactionUnreadable: false }; + } + try { + return { + reaction: decryptFromBytes(reactionEncrypted), + reactionUnreadable: false, + }; + } catch { + return { reaction: null, reactionUnreadable: true }; + } +} + /** * Aggregate the doctor-report payload for a user over a `[start, end]` range. * Pure data assembly — no auth, no rate-limit, no audit. Idempotent. @@ -1473,13 +1507,18 @@ export async function collectDoctorReportData( }, }); const mapped = rows.map((r) => { - let reaction: string | null = null; - if (r.reactionEncrypted && r.reactionEncrypted.byteLength > 0) { - try { - reaction = decryptFromBytes(r.reactionEncrypted); - } catch { - reaction = null; - } + const { reaction, reactionUnreadable } = decryptAllergyReaction( + r.reactionEncrypted, + ); + if (reactionUnreadable) { + // A reaction WAS recorded but is undecryptable (key gap / GCM + // corruption). It is flagged (not silently blanked) so the PDF renders + // an honest "unreadable" marker; log the swallowed decrypt so a + // systemic key-config failure is visible rather than masquerading as + // "no reaction recorded" on a clinician-facing export. + getEvent()?.addWarning( + `doctor-report: allergy reaction decrypt failed for ${userId} (substance=${r.substance})`, + ); } return { substance: r.substance, @@ -1488,6 +1527,7 @@ export async function collectDoctorReportData( severity: r.severity, status: r.status, reaction, + reactionUnreadable, }; }); allergies = mapped.length > 0 ? mapped : null; diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index 40784dd8d..208f28051 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -1627,7 +1627,11 @@ export function buildDoctorReportPdfDocument( t(`records.allergies.category.${al.category}`), t(`records.allergies.type.${al.type}`), al.severity ? t(`records.allergies.severity.${al.severity}`) : "—", - al.reaction ?? "—", + // A reaction that WAS recorded but could not be decrypted renders an + // honest marker, never a blank "—" that reads as "no reaction recorded". + al.reactionUnreadable + ? t("doctorReport.reactionUnreadable") + : (al.reaction ?? "—"), t(`records.allergies.status.${al.status}`), ]); From 88bff471cc003da789135379e6347f6c64cdce6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:50:08 +0200 Subject: [PATCH 07/15] test(tz): pin DST-safe today bounds, user-tz compliance bucketing, greeting zone - getUserTodayBounds: a 25h fall-back day keeps a 23:30 dose inside today, a 23h spring-forward day does not bleed tomorrow's 00:30 dose, ordinary days stay 24h, and a negative-offset zone anchors on the local day. - dailyComplianceRatesFromLedger: a Los-Angeles user's two same-local-day doses land in ONE bucket where the old fixed-Berlin key split them. - classifyIntakeTiming: an 08:00-local dose reads on_time with the user's zone and very_late without it. - hourInTz: reads the wall-clock hour in the given zone. - hero-strip greeting: resolves the hour in the stored zone, not the browser clock; existing greeting fixtures re-pinned to explicit UTC instants mapped to Berlin wall-clock. --- .../insights/__tests__/hero-strip.test.tsx | 35 ++++++--- .../__tests__/compliance-bundle.test.ts | 37 ++++++++++ .../analytics/__tests__/compliance.test.ts | 22 ++++++ src/lib/tz/__tests__/local-day.test.ts | 71 ++++++++++++++++++- src/lib/tz/__tests__/resolver.test.ts | 25 +++++++ 5 files changed, 179 insertions(+), 11 deletions(-) diff --git a/src/components/insights/__tests__/hero-strip.test.tsx b/src/components/insights/__tests__/hero-strip.test.tsx index 29b79c01d..6eb372350 100644 --- a/src/components/insights/__tests__/hero-strip.test.tsx +++ b/src/components/insights/__tests__/hero-strip.test.tsx @@ -33,11 +33,13 @@ const sampleBriefing: DailyBriefingPayload = { keyFindings: [], }; -// `getHours()` reads from the local representation. Force a known -// local hour by constructing from a local-time fields tuple. -const morningLocal = new Date(2026, 4, 10, 9, 0, 0); // May 10, 09:00 local -const afternoonLocal = new Date(2026, 4, 10, 14, 0, 0); // May 10, 14:00 local -const eveningLocal = new Date(2026, 4, 10, 20, 0, 0); // May 10, 20:00 local +// The greeting bucket is now computed in the user's configured zone +// (the mocked `Europe/Berlin`), not the host's — so pin explicit UTC +// instants and translate them to the intended Berlin wall-clock hour. +// May 10 2026 is CEST (UTC+2), so Berlin = UTC + 2h. +const morningLocal = new Date("2026-05-10T07:00:00Z"); // 09:00 Berlin +const afternoonLocal = new Date("2026-05-10T12:00:00Z"); // 14:00 Berlin +const eveningLocal = new Date("2026-05-10T18:00:00Z"); // 20:00 Berlin describe("", () => { it("renders the slot wrapper + Dracula gradient utility", () => { @@ -178,9 +180,10 @@ describe("", () => { }); it("uses the now override deterministically (smoke for greeting-bucket logic)", () => { - // Five different hours map to four different greeting buckets: - // 09:00 → morning, 14:00 → afternoon, 20:00 → evening, 02:00 → night. - const earlyMorning = new Date(2026, 4, 10, 2, 0, 0); + // Buckets are read in the mocked user's Berlin zone: 09:00 → morning, + // 14:00 → afternoon, 20:00 → evening, 02:00 → night. 00:00 UTC is + // 02:00 Berlin (CEST). + const earlyMorning = new Date("2026-05-10T00:00:00Z"); // 02:00 Berlin const html = render(); // Night maps to "Good evening" in EN per the resolver mapping. expect(html).toContain("Good evening"); @@ -188,12 +191,24 @@ describe("", () => { expect(html).not.toContain("Good morning"); }); - it("uses the morning bucket at noon-1 (11:59 boundary)", () => { - const justBeforeNoon = new Date(2026, 4, 10, 11, 59, 0); + it("uses the morning bucket at noon-1 (11:59 Berlin boundary)", () => { + const justBeforeNoon = new Date("2026-05-10T09:59:00Z"); // 11:59 Berlin const html = render(); expect(html).toContain("Good morning"); }); + it("resolves the greeting hour in the user's stored zone, not the browser", () => { + // 11:30 UTC is still MORNING in UTC but 13:30 (AFTERNOON) in the mocked + // Berlin user's zone. The afternoon greeting proves the hour comes from + // the stored zone — the /insights hero used to read the browser clock. + const utcMorningBerlinAfternoon = new Date("2026-05-10T11:30:00Z"); + const html = render( + , + ); + expect(html).toContain("Good afternoon"); + expect(html).not.toContain("Good morning"); + }); + // ── B4 — Weekly-report banner card ───────────────────────────────── // v1.4.28 retired the weekly-report path. The banner, the report // route, the schema slot and the i18n keys are gone; the hero diff --git a/src/lib/analytics/__tests__/compliance-bundle.test.ts b/src/lib/analytics/__tests__/compliance-bundle.test.ts index f39030849..3a042a24d 100644 --- a/src/lib/analytics/__tests__/compliance-bundle.test.ts +++ b/src/lib/analytics/__tests__/compliance-bundle.test.ts @@ -23,6 +23,8 @@ import { tallyLedgerRows, type ComplianceSchedule, } from "../compliance"; +import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; +import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; const TZ = "UTC"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -334,6 +336,41 @@ describe("dailyComplianceRatesFromLedger — cadence-aware per-day series", () = const aggregateRate = Math.round((taken / (taken + missed)) * 100); expect(aggregateRate).toBe(bundle.compliance30.rate); }); + + it("buckets a non-Berlin user's two same-local-day doses into ONE day", () => { + // America/Los_Angeles user, twice-daily 08:00 + 20:00 LOCAL on the same + // local calendar day (2026-06-15). The 20:00 PDT dose is 03:00 UTC the + // NEXT day — and 05:00 the next Berlin day — so a fixed-Berlin day key + // splits one local day's two doses across two buckets. + const takenOnTime = (h: number, m: number): DoseHistoryRow => { + const at = zonedWallClockToUtc( + { year: 2026, month: 6, day: 15, hour: h, minute: m }, + "America/Los_Angeles", + ); + return { + kind: "slot", + at, + timeOfDay: `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`, + status: "taken_on_time", + intake: null, + }; + }; + const rows: DoseHistoryRow[] = [takenOnTime(8, 0), takenOnTime(20, 0)]; + + // Bucketed in the user's own zone: one local day, two taken. + const laSeries = dailyComplianceRatesFromLedger( + rows, + "America/Los_Angeles", + ); + expect(laSeries.length).toBe(1); + expect(laSeries[0].taken).toBe(2); + expect(laSeries[0].rate).toBe(100); + + // Sanity: the fixed-Berlin key (the pre-fix behaviour) would split these + // same two doses across two calendar buckets — the defect the fix closes. + const berlinSeries = dailyComplianceRatesFromLedger(rows, "Europe/Berlin"); + expect(berlinSeries.length).toBe(2); + }); }); describe("tallyLedgerRows — windowed tallies over one shared ledger", () => { diff --git a/src/lib/analytics/__tests__/compliance.test.ts b/src/lib/analytics/__tests__/compliance.test.ts index 50de9cd93..fa844f670 100644 --- a/src/lib/analytics/__tests__/compliance.test.ts +++ b/src/lib/analytics/__tests__/compliance.test.ts @@ -1367,6 +1367,28 @@ describe("classifyIntakeTiming", () => { "very_late", ); }); + + it("interprets the window HH:mm in the supplied tz (non-UTC user)", () => { + // America/Los_Angeles is UTC-7 in summer. An "08:00" slot means 08:00 + // LOCAL = 15:00 UTC on 2026-06-15; a dose taken 15 min into the window + // is 15:15 UTC. The slot's canonical instant is the anchor date. + const slotInstant = new Date("2026-06-15T15:00:00Z"); // 08:00 LA + const takenAt = new Date("2026-06-15T15:15:00Z"); // 08:15 LA + + // With the user's zone the dose is on time. + expect( + classifyIntakeTiming(takenAt, "08:00", "09:00", slotInstant, { + tz: "America/Los_Angeles", + }), + ).toBe("on_time"); + + // Without a tz the window HH:mm is read as UTC (08:00 UTC), so the very + // same on-time dose reads as 7h15m off — very_late. This is the defect + // the achievements perfect-day check hit for users far from UTC. + expect(classifyIntakeTiming(takenAt, "08:00", "09:00", slotInstant)).toBe( + "very_late", + ); + }); }); // v1.8.5 — the cadence-density rule + the per-dose uptime strip. diff --git a/src/lib/tz/__tests__/local-day.test.ts b/src/lib/tz/__tests__/local-day.test.ts index e9eac4f8c..7d6205abf 100644 --- a/src/lib/tz/__tests__/local-day.test.ts +++ b/src/lib/tz/__tests__/local-day.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; -import { startOfLocalDayInTz } from "@/lib/tz/local-day"; +import { getUserTodayBounds, startOfLocalDayInTz } from "@/lib/tz/local-day"; import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock"; +const HOUR_MS = 60 * 60 * 1000; + describe("zonedWallClockToUtc", () => { it("resolves a wall clock in a positive-offset zone to the right UTC instant", () => { // 00:30 CEST (UTC+2) on 2026-05-11 → 22:30 UTC on 2026-05-10. @@ -82,3 +84,70 @@ describe("startOfLocalDayInTz", () => { expect(floor.getDate()).toBe(15); }); }); + +describe("getUserTodayBounds — DST-safe local-day window", () => { + it("spans a full 24h on an ordinary (non-transition) day", () => { + const now = new Date("2026-06-15T14:30:00.000Z"); + const { start, end } = getUserTodayBounds(now, "Europe/Berlin"); + // Berlin is CEST (UTC+2) in June: local midnight = 2026-06-14T22:00Z. + expect(start.toISOString()).toBe("2026-06-14T22:00:00.000Z"); + // Inclusive end is the last ms before the next local midnight. + expect(end.getTime() - start.getTime()).toBe(24 * HOUR_MS - 1); + }); + + it("fall-back day is 25h and keeps a 23:30-local dose inside 'today'", () => { + // Europe/Berlin falls back 2026-10-25 03:00→02:00 (a 25-hour local day). + // A read taken at 23:30 local that evening. + const now = zonedWallClockToUtc( + { year: 2026, month: 10, day: 25, hour: 23, minute: 30 }, + "Europe/Berlin", + ); + const { start, end } = getUserTodayBounds(now, "Europe/Berlin"); + + // The window covers the real 25-hour local day, not a hardcoded 24h. + expect(end.getTime() - start.getTime()).toBe(25 * HOUR_MS - 1); + + // The 23:30 dose — which the old 24h window dropped off the med cards — + // now falls inside [start, end]. + const dose2330 = zonedWallClockToUtc( + { year: 2026, month: 10, day: 25, hour: 23, minute: 30 }, + "Europe/Berlin", + ); + expect(dose2330.getTime()).toBeGreaterThanOrEqual(start.getTime()); + expect(dose2330.getTime()).toBeLessThanOrEqual(end.getTime()); + + // Both bounds sit at local midnight of their respective calendar days. + expect(wallClockInTz(start, "Europe/Berlin").hour).toBe(0); + expect(wallClockInTz(start, "Europe/Berlin").day).toBe(25); + }); + + it("spring-forward day is 23h and does not bleed into tomorrow's 00:30 dose", () => { + // Europe/Berlin springs forward 2026-03-29 02:00→03:00 (a 23-hour day). + const now = zonedWallClockToUtc( + { year: 2026, month: 3, day: 29, hour: 20, minute: 0 }, + "Europe/Berlin", + ); + const { start, end } = getUserTodayBounds(now, "Europe/Berlin"); + + // The window covers only the real 23-hour local day. + expect(end.getTime() - start.getTime()).toBe(23 * HOUR_MS - 1); + + // Tomorrow's 00:30 dose belongs to 2026-03-30, so it must fall OUTSIDE + // today — the old fixed-24h window double-counted it as "today". + const tomorrow0030 = zonedWallClockToUtc( + { year: 2026, month: 3, day: 30, hour: 0, minute: 30 }, + "Europe/Berlin", + ); + expect(tomorrow0030.getTime()).toBeGreaterThan(end.getTime()); + }); + + it("is correct for a negative-offset zone (America/Los_Angeles)", () => { + // 2026-06-15 20:00 PDT (UTC-7) → 2026-06-16T03:00Z. The local day is + // still the 15th, so the window must anchor on LA's 2026-06-15 midnight. + const now = new Date("2026-06-16T03:00:00.000Z"); + const { start, end } = getUserTodayBounds(now, "America/Los_Angeles"); + expect(start.toISOString()).toBe("2026-06-15T07:00:00.000Z"); + expect(end.getTime() - start.getTime()).toBe(24 * HOUR_MS - 1); + expect(wallClockInTz(start, "America/Los_Angeles").day).toBe(15); + }); +}); diff --git a/src/lib/tz/__tests__/resolver.test.ts b/src/lib/tz/__tests__/resolver.test.ts index 2b133c7d8..0f81b2aef 100644 --- a/src/lib/tz/__tests__/resolver.test.ts +++ b/src/lib/tz/__tests__/resolver.test.ts @@ -23,6 +23,7 @@ import { formatInUserTz, invalidateServerDefaultTimezone, invalidateUserTimezone, + hourInTz, isNearUtc, isValidTimezone, resolveServerDefaultTimezone, @@ -262,6 +263,30 @@ describe("userDayKey", () => { }); }); +describe("hourInTz", () => { + it("reads the wall-clock hour in the user's zone, not UTC", () => { + // 2026-06-15T03:00Z is 20:00 the previous day in Los Angeles (UTC-7), + // 05:00 in Berlin (UTC+2), 12:00 in Tokyo (UTC+9). + const instant = new Date("2026-06-15T03:00:00.000Z"); + expect(hourInTz(instant, "UTC")).toBe(3); + expect(hourInTz(instant, "America/Los_Angeles")).toBe(20); + expect(hourInTz(instant, "Europe/Berlin")).toBe(5); + expect(hourInTz(instant, "Asia/Tokyo")).toBe(12); + }); + + it("normalises local midnight to 0, never 24", () => { + const instant = new Date("2026-06-14T22:00:00.000Z"); // 00:00 Berlin + expect(hourInTz(instant, "Europe/Berlin")).toBe(0); + }); + + it("falls back to the default zone for an unusable tz string", () => { + // Mirrors formatInUserTz / userDayKey: an invalid zone degrades to + // DEFAULT_TIMEZONE (Europe/Berlin), which is UTC+2 in summer. + const instant = new Date("2026-06-15T09:00:00.000Z"); + expect(hourInTz(instant, "Mars/Olympus")).toBe(11); + }); +}); + describe("isNearUtc", () => { // Pin a summer-side instant so DST is on for Berlin (+2) and off for // most southern-hemisphere zones — keeps the assertions readable. From 081bb2289dcf5e84ca5b4577eb468c8d3dd485f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:51:08 +0200 Subject: [PATCH 08/15] fix(pwa): fail offline mutations fast instead of pausing + silent write loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mutations ran in TanStack's default 'online' network mode, which PAUSES a write fired offline: isPending stays true forever, onError never fires, and the paused mutation is dropped on reload — a health entry vanishes with no 'not saved' signal. Switch mutations to 'always' network mode so an offline write rejects immediately and the form surfaces an honest failure, and add a global OfflineMutationToaster backstop for call sites without their own onError. Correct the offline banner copy, which promised changes would auto-save. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- src/components/providers.tsx | 51 ++++++++++---- .../__tests__/query-client-options.test.ts | 67 +++++++++++++++++++ src/lib/pwa/query-client-options.ts | 26 +++++++ 9 files changed, 144 insertions(+), 18 deletions(-) create mode 100644 src/lib/pwa/__tests__/query-client-options.test.ts create mode 100644 src/lib/pwa/query-client-options.ts diff --git a/messages/de.json b/messages/de.json index c88ace188..868177a34 100644 --- a/messages/de.json +++ b/messages/de.json @@ -210,7 +210,8 @@ "backToDashboard": "Zurück zum Dashboard" }, "offlineBanner": { - "message": "Keine Verbindung — Änderungen werden gespeichert, sobald du wieder online bist.", + "message": "Keine Verbindung — Änderungen können erst gespeichert werden, wenn du wieder online bist.", + "saveFailed": "Speichern fehlgeschlagen — du bist offline. Bitte versuche es erneut, sobald du wieder online bist.", "showingCached": "Zeigt deine zuletzt synchronisierten Daten." }, "demoBanner": { diff --git a/messages/en.json b/messages/en.json index 9d65503d1..e83170512 100644 --- a/messages/en.json +++ b/messages/en.json @@ -210,7 +210,8 @@ "backToDashboard": "Back to dashboard" }, "offlineBanner": { - "message": "No connection — your changes will save once you're back online.", + "message": "No connection — changes can't be saved until you're back online.", + "saveFailed": "Couldn't save — you're offline. Please try again once you're back online.", "showingCached": "Showing your last synced data." }, "demoBanner": { diff --git a/messages/es.json b/messages/es.json index afdabfc42..209c41dc9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -210,7 +210,8 @@ "backToDashboard": "Volver al panel" }, "offlineBanner": { - "message": "Sin conexión — tus cambios se guardarán cuando vuelvas a estar en línea.", + "message": "Sin conexión — los cambios no se pueden guardar hasta que vuelvas a estar en línea.", + "saveFailed": "No se pudo guardar — estás sin conexión. Inténtalo de nuevo cuando vuelvas a estar en línea.", "showingCached": "Mostrando tus últimos datos sincronizados." }, "demoBanner": { diff --git a/messages/fr.json b/messages/fr.json index 90b18136a..852071ba4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -210,7 +210,8 @@ "backToDashboard": "Retour au tableau de bord" }, "offlineBanner": { - "message": "Pas de connexion — tes modifications seront enregistrées dès que tu seras de nouveau en ligne.", + "message": "Pas de connexion — les modifications ne peuvent pas être enregistrées tant que tu n'es pas de nouveau en ligne.", + "saveFailed": "Enregistrement impossible — tu es hors ligne. Réessaie une fois de nouveau en ligne.", "showingCached": "Affichage de tes dernières données synchronisées." }, "demoBanner": { diff --git a/messages/it.json b/messages/it.json index b4296d3bf..92b11a12e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -210,7 +210,8 @@ "backToDashboard": "Torna alla dashboard" }, "offlineBanner": { - "message": "Nessuna connessione — le tue modifiche verranno salvate appena torni online.", + "message": "Nessuna connessione — le modifiche non possono essere salvate finché non torni online.", + "saveFailed": "Impossibile salvare — sei offline. Riprova quando torni online.", "showingCached": "Vengono mostrati i tuoi ultimi dati sincronizzati." }, "demoBanner": { diff --git a/messages/pl.json b/messages/pl.json index 71df1aca9..c9639079b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -210,7 +210,8 @@ "backToDashboard": "Powrót do panelu" }, "offlineBanner": { - "message": "Brak połączenia — twoje zmiany zostaną zapisane, gdy ponownie pojawisz się online.", + "message": "Brak połączenia — zmian nie można zapisać, dopóki nie wrócisz online.", + "saveFailed": "Nie udało się zapisać — jesteś offline. Spróbuj ponownie, gdy wrócisz online.", "showingCached": "Wyświetlane są ostatnio zsynchronizowane dane." }, "demoBanner": { diff --git a/src/components/providers.tsx b/src/components/providers.tsx index 10ec7fae9..04f1eff50 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -14,8 +14,9 @@ import { useState, type ReactNode, } from "react"; -import { I18nProvider } from "@/lib/i18n/context"; +import { I18nProvider, useTranslations } from "@/lib/i18n/context"; import type { Locale } from "@/lib/i18n/config"; +import { toast } from "sonner"; import { Toaster } from "@/components/ui/sonner"; import { VersionPoller } from "@/components/version-poller"; import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar"; @@ -26,6 +27,7 @@ import { restorePersistedQueryCache, startPersistingQueryCache, } from "@/lib/pwa/query-persister"; +import { QUERY_CLIENT_DEFAULT_OPTIONS } from "@/lib/pwa/query-client-options"; const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? ""; @@ -177,6 +179,40 @@ function QueryPersistenceBridge() { return null; } +// ── Offline mutation backstop ──────────────────────── + +/** + * F-OFF-1 backstop. With mutations in `always` network mode an offline write + * rejects immediately (rather than pausing forever), so the individual form's + * `onError` fires and shows its own message. This subscriber is the safety net + * for any mutation WITHOUT a call-site error handler: it surfaces one honest, + * de-duplicated toast whenever a mutation errors while the browser is offline, + * so a write can never vanish silently. Gated on `navigator.onLine === false` + * so a normal server-side error (which the form already surfaces) is never + * double-toasted. + */ +function OfflineMutationToaster() { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + useEffect(() => { + const cache = queryClient.getMutationCache(); + const unsubscribe = cache.subscribe((event) => { + if ( + event.type === "updated" && + event.action.type === "error" && + typeof navigator !== "undefined" && + navigator.onLine === false + ) { + toast.error(t("offlineBanner.saveFailed"), { + id: "offline-mutation-failed", + }); + } + }); + return unsubscribe; + }, [queryClient, t]); + return null; +} + // ── Root Providers ─────────────────────────────────── export function Providers({ @@ -187,17 +223,7 @@ export function Providers({ initialLocale?: Locale; }) { const [queryClient] = useState( - () => - new QueryClient({ - defaultOptions: { - queries: { - staleTime: 5 * 60 * 1000, - gcTime: 10 * 60 * 1000, - refetchOnWindowFocus: false, - retry: 1, - }, - }, - }), + () => new QueryClient({ defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS }), ); return ( @@ -206,6 +232,7 @@ export function Providers({ + {children} diff --git a/src/lib/pwa/__tests__/query-client-options.test.ts b/src/lib/pwa/__tests__/query-client-options.test.ts new file mode 100644 index 000000000..d5172db6e --- /dev/null +++ b/src/lib/pwa/__tests__/query-client-options.test.ts @@ -0,0 +1,67 @@ +/** + * F-OFF-1 — offline mutations must FAIL fast (surfacing state) rather than + * pause forever and lose the write on reload. + * + * The library default network mode (`online`) pauses a mutation fired offline: + * it never rejects, `onError` never fires, and the paused mutation is dropped on + * reload — a silent health-data loss. This pins that the app config sets + * `networkMode: "always"` on mutations AND that, under that config, an offline + * mutation actually reaches the `error` state instead of `pending`/paused. + */ +import { afterEach, describe, expect, it } from "vitest"; +import { + QueryClient, + MutationObserver, + onlineManager, +} from "@tanstack/react-query"; + +import { QUERY_CLIENT_DEFAULT_OPTIONS } from "../query-client-options"; + +afterEach(() => { + // Restore the online flag for any following test in the same worker. + onlineManager.setOnline(true); +}); + +describe("QUERY_CLIENT_DEFAULT_OPTIONS", () => { + it("runs mutations in 'always' network mode", () => { + expect(QUERY_CLIENT_DEFAULT_OPTIONS.mutations?.networkMode).toBe("always"); + }); + + it("an offline mutation errors instead of pausing (no silent write loss)", async () => { + const client = new QueryClient({ + defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS, + }); + + // Simulate the browser going offline. + onlineManager.setOnline(false); + + const observer = new MutationObserver(client, { + mutationFn: async () => { + // A real fetch would reject with a TypeError while offline. + throw new Error("Failed to fetch"); + }, + retry: 0, + }); + + let settled: "error" | "paused-timeout" = "paused-timeout"; + await Promise.race([ + observer + .mutate() + .then(() => {}) + .catch(() => { + settled = "error"; + }), + // If the mutation paused (the old default), it would never settle — cap + // the wait so the test fails as a timeout rather than hanging. + new Promise((resolve) => setTimeout(resolve, 200)), + ]); + + // In `always` mode the mutation ran and rejected — an honest error the UI + // can surface, not a paused mutation lost on reload. + expect(settled).toBe("error"); + expect(observer.getCurrentResult().status).toBe("error"); + expect(observer.getCurrentResult().isPaused).toBe(false); + + client.clear(); + }); +}); diff --git a/src/lib/pwa/query-client-options.ts b/src/lib/pwa/query-client-options.ts new file mode 100644 index 000000000..579ff9928 --- /dev/null +++ b/src/lib/pwa/query-client-options.ts @@ -0,0 +1,26 @@ +import type { DefaultOptions } from "@tanstack/react-query"; + +/** + * Shared TanStack Query default options for the app's single `QueryClient`. + * Extracted from `providers.tsx` so the offline-mutation contract (F-OFF-1) is + * unit-testable without importing the whole client-component tree. + * + * Mutations run in `always` network mode. The library default (`online`) PAUSES + * a mutation fired offline — `isPending` stays true forever, `onError` never + * fires, and the paused mutation is silently DROPPED on reload, so a health + * entry vanishes with no "not saved" signal. In `always` mode the mutation runs + * regardless, the offline `fetch` rejects immediately, and `onError` fires — an + * honest failure the form surfaces (and the global `OfflineMutationToaster` + * backstops), instead of a spinner-of-death plus silent write loss. + */ +export const QUERY_CLIENT_DEFAULT_OPTIONS: DefaultOptions = { + queries: { + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + refetchOnWindowFocus: false, + retry: 1, + }, + mutations: { + networkMode: "always", + }, +}; From 4c158f6abca92c896e4858756e5fa871d2696a57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:55:35 +0200 Subject: [PATCH 09/15] feat(integrations): per-metric-type last-value freshness signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Freshness was tracked only per-integration, so a provider returning a healthy 200 with an empty body for ONE metric type recorded no failure and the whole integration stamped green — a metric dead since launch was indistinguishable from a healthy-idle one. Compute the last-value timestamp per (source, type) straight from the Measurement table (no migration) and surface it per integration on the status endpoint, so the Settings card can flag a silently-dead metric the green pill hides. The card UI badge + per-cadence staleness classification are the deferred remainder. --- src/app/api/integrations/status/route.ts | 25 ++++++ .../__tests__/metric-freshness.test.ts | 82 +++++++++++++++++ src/lib/integrations/metric-freshness.ts | 90 +++++++++++++++++++ 3 files changed, 197 insertions(+) create mode 100644 src/lib/integrations/__tests__/metric-freshness.test.ts create mode 100644 src/lib/integrations/metric-freshness.ts diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts index 1ed88a25a..dfc84c48e 100644 --- a/src/app/api/integrations/status/route.ts +++ b/src/app/api/integrations/status/route.ts @@ -24,6 +24,10 @@ import { getPersistentFailureThreshold, type IntegrationKey, } from "@/lib/integrations/status"; +import { + getSourceMetricFreshness, + type MetricFreshnessEntry, +} from "@/lib/integrations/metric-freshness"; import { getOuraClientCredentials } from "@/lib/oura/credentials"; import { getPolarClientCredentials } from "@/lib/polar/credentials"; import { hasActivityScope } from "@/lib/withings/client"; @@ -131,6 +135,14 @@ export const GET = apiHandler(async () => { getOuraClientCredentials(user.id).then((c) => !!c), ]); + // F-SYNC-1 — per-metric-type last-value timestamps so the card can show that a + // single metric type has gone silent even while the integration reads green + // ("connected · 5 min ago"). Fail-soft: this is an honesty signal, never worth + // 500-ing the whole Settings card, so a groupBy hiccup degrades to no data. + const metricFreshness = await getSourceMetricFreshness(user.id).catch( + () => ({}) as Partial>, + ); + const now = Date.now(); return apiSuccess({ @@ -156,6 +168,7 @@ export const GET = apiHandler(async () => { // Null `scope` = legacy connection that predates activity-scope reads. scope: withingsConn?.scope ?? null, hasActivityScope: hasActivityScope(withingsConn?.scope ?? null), + metricFreshness: metricFreshness.withings ?? [], } satisfies IntegrationViewModel & WithingsExtras, { ...moodLogStatus, @@ -183,6 +196,7 @@ export const GET = apiHandler(async () => { ? whoopConn.tokenExpiresAt.getTime() <= now : null, backfillCompleted: whoopConn ? !!whoopConn.backfillCompletedAt : null, + metricFreshness: metricFreshness.whoop ?? [], } satisfies IntegrationViewModel & WhoopExtras, { ...fitbitStatus, @@ -197,6 +211,7 @@ export const GET = apiHandler(async () => { ? fitbitConn.tokenExpiresAt.getTime() <= now : null, backfillCompleted: fitbitConn ? !!fitbitConn.backfillCompletedAt : null, + metricFreshness: metricFreshness.fitbit ?? [], } satisfies IntegrationViewModel & FitbitExtras, { ...googleHealthStatus, @@ -218,6 +233,7 @@ export const GET = apiHandler(async () => { // user-revoked grant) surfaced from the connection row; the card reads it // to raise a distinct "Reconnect" CTA separate from parked/disconnected. needsReauth: googleHealthConn ? googleHealthConn.needsReauth : false, + metricFreshness: metricFreshness["google-health"] ?? [], } satisfies IntegrationViewModel & GoogleHealthExtras, { ...polarStatus, @@ -231,6 +247,7 @@ export const GET = apiHandler(async () => { hasOwnCredentials: !!dbUser?.polarClientIdEncrypted && !!dbUser?.polarClientSecretEncrypted, + metricFreshness: metricFreshness.polar ?? [], } satisfies IntegrationViewModel & OAuthProviderExtras, { ...ouraStatus, @@ -240,6 +257,7 @@ export const GET = apiHandler(async () => { hasOwnCredentials: !!dbUser?.ouraClientIdEncrypted && !!dbUser?.ouraClientSecretEncrypted, + metricFreshness: metricFreshness.oura ?? [], } satisfies IntegrationViewModel & OAuthProviderExtras, ], }); @@ -251,6 +269,13 @@ interface IntegrationViewModel { lastSuccessAt: string | null; lastAttemptAt: string | null; lastError: string | null; + /** + * F-SYNC-1 — per-metric-type last-value timestamps for the integration's + * synced measurements. Present on the measurement-backed integrations; + * omitted for moodLog (which writes MoodEntry rows, not Measurements). Lets + * the card flag a silently-dead metric the green integration state hides. + */ + metricFreshness?: MetricFreshnessEntry[]; } interface WithingsExtras { diff --git a/src/lib/integrations/__tests__/metric-freshness.test.ts b/src/lib/integrations/__tests__/metric-freshness.test.ts new file mode 100644 index 000000000..b41e54e20 --- /dev/null +++ b/src/lib/integrations/__tests__/metric-freshness.test.ts @@ -0,0 +1,82 @@ +/** + * F-SYNC-1 — per-metric-type last-value freshness. + * + * Pins that the helper distinguishes a silently-dead metric (its newest reading + * frozen in the past) from a genuinely-current one, keyed by integration, from + * one grouped read over the live Measurement rows — the honest signal the + * per-integration "connected · 5 min ago" pill cannot carry. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const groupByMock = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/db", () => ({ + prisma: { measurement: { groupBy: groupByMock } }, +})); + +import { + getSourceMetricFreshness, + INTEGRATION_MEASUREMENT_SOURCE, +} from "../metric-freshness"; + +beforeEach(() => groupByMock.mockReset()); + +describe("getSourceMetricFreshness", () => { + it("maps grouped source/type rows to per-integration last-seen entries", async () => { + groupByMock.mockResolvedValue([ + { + source: "OURA", + type: "RESPIRATORY_RATE", + _max: { measuredAt: new Date("2026-01-01T00:00:00.000Z") }, + }, + { + source: "OURA", + type: "RECOVERY_SCORE", + _max: { measuredAt: new Date("2026-07-07T00:00:00.000Z") }, + }, + { + source: "WITHINGS", + type: "WEIGHT", + _max: { measuredAt: new Date("2026-07-06T00:00:00.000Z") }, + }, + ]); + + const result = await getSourceMetricFreshness("u1"); + + // The dead metric (respiratory rate, frozen in January) is visible as a + // distinct, older timestamp next to the current recovery score — exactly + // the "broken pipe vs healthy-idle" distinction the pill can't express. + expect(result.oura).toEqual([ + { type: "RECOVERY_SCORE", lastSeenAt: "2026-07-07T00:00:00.000Z" }, + { type: "RESPIRATORY_RATE", lastSeenAt: "2026-01-01T00:00:00.000Z" }, + ]); + expect(result.withings).toEqual([ + { type: "WEIGHT", lastSeenAt: "2026-07-06T00:00:00.000Z" }, + ]); + // Only the sync sources are queried. + expect(groupByMock.mock.calls[0][0].where.source.in).toEqual( + Object.values(INTEGRATION_MEASUREMENT_SOURCE), + ); + expect(groupByMock.mock.calls[0][0].where.deletedAt).toBeNull(); + }); + + it("skips rows with no reading and unmapped sources", async () => { + groupByMock.mockResolvedValue([ + { source: "OURA", type: "VO2_MAX", _max: { measuredAt: null } }, + { + source: "MANUAL", + type: "WEIGHT", + _max: { measuredAt: new Date("2026-07-01T00:00:00.000Z") }, + }, + ]); + + const result = await getSourceMetricFreshness("u1"); + // A null max (no rows) yields no entry; MANUAL isn't a sync integration. + expect(result.oura).toBeUndefined(); + expect(Object.keys(result)).toHaveLength(0); + }); + + it("returns an empty map when the user has no synced measurements", async () => { + groupByMock.mockResolvedValue([]); + expect(await getSourceMetricFreshness("u1")).toEqual({}); + }); +}); diff --git a/src/lib/integrations/metric-freshness.ts b/src/lib/integrations/metric-freshness.ts new file mode 100644 index 000000000..d70ac2cc4 --- /dev/null +++ b/src/lib/integrations/metric-freshness.ts @@ -0,0 +1,90 @@ +/** + * F-SYNC-1 — per-metric-type freshness (the smallest honest signal). + * + * Freshness was tracked only per-integration (`IntegrationStatus.lastSuccessAt` + * / a connection's `lastSyncedAt`). A provider that returns HTTP 200 with an + * empty body for ONE data type produces zero rows, throws nothing, and the + * other types in the same cycle stamp the whole integration green — so the + * Settings pill reads "connected · 5 min ago" while, say, respiratory rate has + * been dead since launch, and nothing distinguishes "broken pipe" from "healthy + * but idle". + * + * This computes the last-value timestamp per `(source, type)` straight from the + * `Measurement` table (no schema change, no migration) so a caller can surface + * per-metric liveness honestly: a metric whose newest reading is frozen weeks in + * the past is visibly distinct from one that is genuinely current. The coarser + * "is this stale for THIS metric's expected cadence" classification (per-type + * thresholds / an expected-cadence probe cron) is the deferred fuller model; + * this exposes the raw honest timestamp the UI renders as "last seen …". + */ +import { prisma } from "@/lib/db"; +import type { MeasurementSource } from "@/generated/prisma/client"; +import type { IntegrationKey } from "./status"; + +/** + * The `MeasurementSource` each sync integration's rows carry. `moodlog` is + * absent — it writes `MoodEntry` rows, not `Measurement` rows, so it has no + * per-metric measurement freshness. + */ +export const INTEGRATION_MEASUREMENT_SOURCE: Partial< + Record +> = { + withings: "WITHINGS", + whoop: "WHOOP", + fitbit: "FITBIT", + nightscout: "NIGHTSCOUT", + polar: "POLAR", + oura: "OURA", + "google-health": "GOOGLE_HEALTH", +}; + +/** Newest recorded reading for one `(source, type)` pair. */ +export interface MetricFreshnessEntry { + /** The `MeasurementType` (e.g. "RESPIRATORY_RATE"). */ + type: string; + /** ISO timestamp of the newest (non-deleted) reading for this metric. */ + lastSeenAt: string; +} + +/** + * Compute per-`(integration, type)` last-value timestamps for the sync + * integrations, keyed by `IntegrationKey`. One grouped query over the live + * `Measurement` rows; a source with no rows simply has no entry. Types are + * sorted alphabetically for a stable wire shape. + */ +export async function getSourceMetricFreshness( + userId: string, +): Promise>> { + const sources = Object.values(INTEGRATION_MEASUREMENT_SOURCE); + const grouped = await prisma.measurement.groupBy({ + by: ["source", "type"], + where: { userId, deletedAt: null, source: { in: sources } }, + _max: { measuredAt: true }, + }); + + // Invert the source→key map once so each grouped row resolves its integration. + const sourceToKey = new Map( + ( + Object.entries(INTEGRATION_MEASUREMENT_SOURCE) as Array< + [IntegrationKey, MeasurementSource] + > + ).map(([key, source]) => [source, key]), + ); + + const out: Partial> = {}; + for (const row of grouped) { + const lastSeen = row._max.measuredAt; + if (!lastSeen) continue; + const key = sourceToKey.get(row.source); + if (!key) continue; + (out[key] ??= []).push({ + type: row.type, + lastSeenAt: lastSeen.toISOString(), + }); + } + + for (const key of Object.keys(out) as IntegrationKey[]) { + out[key]!.sort((a, b) => a.type.localeCompare(b.type)); + } + return out; +} From 9eadd0755ff620464a9955a2edd1687d79a0c705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:57:02 +0200 Subject: [PATCH 10/15] fix(withings): widen incremental overlap from 60s to 10 min MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Withings backdates some readings and lastSyncedAt advances on every success (including a healthy 200-with-0 cycle), so a reading landing more than 60s before the next cycle's now() could be missed. Widen the poll catch-up overlap to 10 minutes — the upserts are idempotent, so re-touching a handful of rows is harmless. --- src/lib/withings/__tests__/sync.test.ts | 18 +++++++++++++++++- src/lib/withings/sync.ts | 18 +++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/lib/withings/__tests__/sync.test.ts b/src/lib/withings/__tests__/sync.test.ts index b671761e5..a7027826b 100644 --- a/src/lib/withings/__tests__/sync.test.ts +++ b/src/lib/withings/__tests__/sync.test.ts @@ -1,5 +1,21 @@ import { afterEach, describe, expect, it } from "vitest"; -import { getWithingsWebhookCallbackUrl } from "../sync"; +import { + getWithingsWebhookCallbackUrl, + WITHINGS_INCREMENTAL_OVERLAP_MS, +} from "../sync"; + +/** + * F-SYNC-5 — the incremental overlap must stay comfortably wider than the old + * 60s so a backdated Withings reading landing just before the next cycle's + * `now()` is not missed (compounded by `lastSyncedAt` advancing on a healthy + * 200-with-0 cycle). The upserts are idempotent, so a wider overlap is safe. + */ +describe("WITHINGS_INCREMENTAL_OVERLAP_MS", () => { + it("is at least a few minutes (not the old 60s)", () => { + expect(WITHINGS_INCREMENTAL_OVERLAP_MS).toBeGreaterThanOrEqual(5 * 60_000); + expect(WITHINGS_INCREMENTAL_OVERLAP_MS).toBeGreaterThan(60_000); + }); +}); /** * v1.4.25 W17a — Withings webhook callback URL must put the shared diff --git a/src/lib/withings/sync.ts b/src/lib/withings/sync.ts index 999d70d60..c887398f6 100644 --- a/src/lib/withings/sync.ts +++ b/src/lib/withings/sync.ts @@ -131,6 +131,20 @@ export async function getValidToken(userId: string): Promise<{ }; } +/** + * Incremental overlap window (ms). Withings backdates some readings (a scale + * step syncs to the cloud minutes after the reading's timestamp), and + * `lastSyncedAt` advances on every success — including a healthy 200-with-0 + * cycle (F-SYNC-1). A too-tight overlap let a reading that landed just before + * the next cycle's `now()` slip through the gap. 10 minutes is comfortably wider + * than the observed backdating skew while staying far short of a full re-scan; + * the upserts are idempotent, so a wider overlap only re-touches a handful of + * rows. (Still narrower than Google's 24h / Oura's 7-day because Withings is + * webhook-primary — this overlap is the poll-catch-up safety net, not the main + * path.) + */ +export const WITHINGS_INCREMENTAL_OVERLAP_MS = 10 * 60 * 1000; + /** * Sync measurements from Withings for a given user. * Fetches data since last sync (or last 30 days if first sync). @@ -172,7 +186,9 @@ export async function syncUserMeasurements( const startDate = opts.fullSync ? undefined : connection.lastSyncedAt - ? new Date(connection.lastSyncedAt.getTime() - 60 * 1000) // overlap 1 min to avoid gaps + ? new Date( + connection.lastSyncedAt.getTime() - WITHINGS_INCREMENTAL_OVERLAP_MS, + ) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days back let measures: Awaited>; From 3faa4ee76e68e732eeda4686b907a7b5aa0add85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:59:37 +0200 Subject: [PATCH 11/15] fix(coach): log swallowed self-context decrypt failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Coach self-context loader failed soft per field (undecryptable allergies/conditions → null) with no signal, so a key-rotation gap made the Coach generate guidance without ever seeing the user's recorded allergies, indistinguishable from an empty profile. Keep the per-field fail-closed posture but emit a wide-event on each swallowed decrypt and on the DB-read guard, so a systemic failure surfaces instead of masquerading as 'the user never wrote anything'. --- .../self-context-decrypt-logging.test.ts | 85 +++++++++++++++++++ src/lib/ai/coach/about-me.ts | 36 ++++++-- 2 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 src/lib/ai/coach/__tests__/self-context-decrypt-logging.test.ts diff --git a/src/lib/ai/coach/__tests__/self-context-decrypt-logging.test.ts b/src/lib/ai/coach/__tests__/self-context-decrypt-logging.test.ts new file mode 100644 index 000000000..20e002dea --- /dev/null +++ b/src/lib/ai/coach/__tests__/self-context-decrypt-logging.test.ts @@ -0,0 +1,85 @@ +/** + * F-CRYPTO-4 — an undecryptable Coach self-context field (allergies / + * conditions) must NOT vanish silently. It still fails closed per field (never + * surfaces ciphertext, never throws into prompt assembly), but a swallowed + * decrypt now emits a wide-event so a key-rotation gap surfaces instead of + * masquerading as "the user never wrote anything". + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { decryptMock, addWarningMock, findUniqueMock } = vi.hoisted(() => ({ + decryptMock: vi.fn(), + addWarningMock: vi.fn(), + findUniqueMock: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { userHealthProfile: { findUnique: findUniqueMock } }, +})); +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + decryptFromBytes: decryptMock, + encryptToBytes: vi.fn(), +})); +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addWarning: addWarningMock }), +})); + +import { getSelfContextForUser } from "../about-me"; + +beforeEach(() => { + decryptMock.mockReset(); + addWarningMock.mockReset(); + findUniqueMock.mockReset(); +}); + +describe("getSelfContextForUser — undecryptable field", () => { + it("fails closed to null AND logs a wide-event on a decrypt failure", async () => { + findUniqueMock.mockResolvedValue({ + aboutMeEncrypted: new Uint8Array([1]), + conditionsEncrypted: null, + allergiesEncrypted: new Uint8Array([2]), // safety-relevant, undecryptable + coachFocusEncrypted: null, + }); + decryptMock.mockImplementation(() => { + throw new Error("bad ciphertext"); + }); + + const ctx = await getSelfContextForUser("u1"); + + // Never surfaces ciphertext; the field is null (fail-closed). + expect(ctx.aboutMe).toBeNull(); + expect(ctx.allergies).toBeNull(); + // But the swallowed decrypt is now visible — one warning per undecryptable + // field (aboutMe + allergies here). + expect(addWarningMock).toHaveBeenCalledTimes(2); + expect(addWarningMock.mock.calls[0][0]).toContain("self-context"); + }); + + it("does not log when the fields are genuinely unset", async () => { + findUniqueMock.mockResolvedValue({ + aboutMeEncrypted: null, + conditionsEncrypted: null, + allergiesEncrypted: null, + coachFocusEncrypted: null, + }); + + const ctx = await getSelfContextForUser("u1"); + expect(ctx).toEqual({ + aboutMe: null, + conditions: null, + allergies: null, + coachFocus: null, + }); + expect(addWarningMock).not.toHaveBeenCalled(); + }); + + it("logs a wide-event when the DB read itself throws (no silent empty profile)", async () => { + findUniqueMock.mockRejectedValue(new Error("db down")); + + const ctx = await getSelfContextForUser("u1"); + expect(ctx.allergies).toBeNull(); + expect(addWarningMock).toHaveBeenCalledWith( + expect.stringContaining("self-context load failed"), + ); + }); +}); diff --git a/src/lib/ai/coach/about-me.ts b/src/lib/ai/coach/about-me.ts index e94dc3bdd..089224d62 100644 --- a/src/lib/ai/coach/about-me.ts +++ b/src/lib/ai/coach/about-me.ts @@ -27,6 +27,7 @@ * Server-only — reads `@/lib/db`. */ import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { SELF_REPORT_FENCE_START, @@ -48,9 +49,18 @@ function decryptOrNull(payload: Uint8Array | null): string | null { try { const text = decryptFromBytes(payload).trim(); return text.length > 0 ? text : null; - } catch { - // Fail closed per field — never surface ciphertext, never throw - // into a prompt-assembly path. + } catch (err) { + // Fail closed per field — never surface ciphertext, never throw into a + // prompt-assembly path. But do NOT swallow silently: a stored-but- + // undecryptable safety-relevant field (allergies / conditions) that + // vanishes from the Coach's self-context with no signal is exactly the + // F-CRYPTO-4 gap. Emit a wide-event so a key-rotation gap surfaces instead + // of masquerading as "the user never wrote anything". + getEvent()?.addWarning( + `coach self-context: field decrypt failed (${ + err instanceof Error ? err.message : String(err) + })`, + ); return null; } } @@ -83,7 +93,15 @@ export async function getSelfContextForUser( allergies: decryptOrNull(row?.allergiesEncrypted ?? null), coachFocus: decryptOrNull(row?.coachFocusEncrypted ?? null), }; - } catch { + } catch (err) { + // Per-field decrypt already fails closed above; this catch is the DB-read + // guard. Surface it (rather than masquerading as an empty profile) so a + // systemic read/key-config failure is visible in wide-events. + getEvent()?.addWarning( + `coach self-context load failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); return { aboutMe: null, conditions: null, @@ -390,7 +408,15 @@ export async function getSelfContextTextForUser( ); if (!selfText && !recordsText) return null; return [selfText, recordsText].filter(Boolean).join("\n"); - } catch { + } catch (err) { + // Per-field decrypt fails closed inside the composer; this catch is the + // DB-read guard. Log it so a systemic failure surfaces rather than the + // Coach silently generating guidance without the user's recorded profile. + getEvent()?.addWarning( + `coach self-context text load failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); return null; } } From 6831dc2a15e57e541d6586a2d279788849eea572 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 9 Jul 2026 00:03:25 +0200 Subject: [PATCH 12/15] test(offline-banner): update copy assertions to the honest offline message --- src/components/layout/__tests__/offline-banner.test.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/layout/__tests__/offline-banner.test.tsx b/src/components/layout/__tests__/offline-banner.test.tsx index af43498ce..ae5c58a49 100644 --- a/src/components/layout/__tests__/offline-banner.test.tsx +++ b/src/components/layout/__tests__/offline-banner.test.tsx @@ -49,16 +49,19 @@ describe("", () => { }); it("ships the EN copy for `offlineBanner.message`", () => { + // F-OFF-1 — the copy must not over-promise auto-save: offline mutations now + // fail fast (networkMode "always") rather than queue, so the banner states + // the honest thing. const en = loadMessages("en"); expect(en.offlineBanner.message).toBe( - "No connection — your changes will save once you're back online.", + "No connection — changes can't be saved until you're back online.", ); }); it("ships the DE copy for `offlineBanner.message`", () => { const de = loadMessages("de"); expect(de.offlineBanner.message).toBe( - "Keine Verbindung — Änderungen werden gespeichert, sobald du wieder online bist.", + "Keine Verbindung — Änderungen können erst gespeichert werden, wenn du wieder online bist.", ); }); From 801aa73de0a8eed0130830b6a04282751a1619c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 9 Jul 2026 00:04:21 +0200 Subject: [PATCH 13/15] style: prettier formatting --- .../google-health/__tests__/sync-metrics-failsoft.test.ts | 7 ++++--- .../__tests__/daily-series-read-tiered.test.ts | 4 +++- src/lib/oura/sync.ts | 3 ++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/lib/google-health/__tests__/sync-metrics-failsoft.test.ts b/src/lib/google-health/__tests__/sync-metrics-failsoft.test.ts index ccb60412a..601ea4ddd 100644 --- a/src/lib/google-health/__tests__/sync-metrics-failsoft.test.ts +++ b/src/lib/google-health/__tests__/sync-metrics-failsoft.test.ts @@ -52,10 +52,11 @@ beforeEach(() => { mapWeightMock.mockClear(); // Weight returns one point (whose mapper throws); everything else is empty so // its real mapper is never exercised. - fetchDataPointsMock.mockReset().mockImplementation( - async (_dt: unknown, _token: string, verb: string) => + fetchDataPointsMock + .mockReset() + .mockImplementation(async (_dt: unknown, _token: string, verb: string) => verb === "fetchWeight" ? [{ any: "point" }] : [], - ); + ); }); describe("syncUserMetrics — a throwing mapper never skips sibling types", () => { diff --git a/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts b/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts index f8f09d0bd..c23d7030a 100644 --- a/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts +++ b/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts @@ -165,7 +165,9 @@ describe("readDailySeries — rollup read throw falls back to live SQL (F-DB-2)" const from = new Date(to.getTime() - 90 * DAY_MS); // The primary rollup read throws (statement_timeout / deadlock / reset). - mocks.findMany.mockReset().mockRejectedValue(new Error("statement timeout")); + mocks.findMany + .mockReset() + .mockRejectedValue(new Error("statement timeout")); // Live fallback serves the tile. mocks.queryRaw.mockResolvedValueOnce([ { diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index 5c584433a..80ddc2834 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -275,7 +275,8 @@ async function fetchAll( rotated.refresh_token, refreshTokenCiphertext, ); - if (!usableToken) return { readings: first.readings, failures: first.failures }; + if (!usableToken) + return { readings: first.readings, failures: first.failures }; const retry = await attempt(usableToken, first.failed); return { From 1a957395614da8256804ab2a72aef34a429179bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 9 Jul 2026 00:21:16 +0200 Subject: [PATCH 14/15] fix(crypto): log swallowed decrypt on undecryptable read-path fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cycle/illness/records note DTOs, the biomarker context, and the custom mood/cycle labels each degraded an undecryptable value to a blank/generic fallback with no signal — a key-rotation gap read as 'left blank' and the catch was silent. Keep the fail-soft read posture (one bad row never 500s the list) but emit a wide-event on each swallowed decrypt so a systemic key gap surfaces. The visible per-field 'unreadable' marker in the UI is the deferred remainder. --- src/lib/cycle/custom-symptoms.ts | 11 ++++- src/lib/cycle/dto.ts | 12 ++++- src/lib/illness/dto.ts | 10 +++- src/lib/labs/biomarker-store.ts | 11 ++++- .../custom-label-decrypt-logging.test.ts | 49 +++++++++++++++++++ src/lib/mood/custom-tags.ts | 11 ++++- src/lib/records/dto.ts | 10 +++- 7 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 src/lib/mood/__tests__/custom-label-decrypt-logging.test.ts diff --git a/src/lib/cycle/custom-symptoms.ts b/src/lib/cycle/custom-symptoms.ts index d4fa21123..7435d731a 100644 --- a/src/lib/cycle/custom-symptoms.ts +++ b/src/lib/cycle/custom-symptoms.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { encrypt, decrypt } from "@/lib/crypto"; +import { getEvent } from "@/lib/logging/context"; import { CUSTOM_SYMPTOM_KEY_PREFIX } from "@/lib/cycle/custom-symptoms-shared"; @@ -52,7 +53,15 @@ export function decryptCustomLabel(encoded: string | null): string | null { if (!encoded) return null; try { return decrypt(encoded); - } catch { + } catch (err) { + // A custom label WAS stored but is undecryptable — fail soft (the caller + // falls back to the generic i18n label) but log it (F-CRYPTO-5) so the loss + // of a user-renamed symptom is not entirely silent. + getEvent()?.addWarning( + `cycle custom-label decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); return null; } } diff --git a/src/lib/cycle/dto.ts b/src/lib/cycle/dto.ts index 5dbc4e83c..59fdd1851 100644 --- a/src/lib/cycle/dto.ts +++ b/src/lib/cycle/dto.ts @@ -9,6 +9,7 @@ * (NULL = a plain presence link). */ import { decrypt } from "@/lib/crypto"; +import { getEvent } from "@/lib/logging/context"; import type { CyclePredictionResult } from "@/lib/cycle/types"; import type { CycleDayLog, @@ -73,7 +74,16 @@ function decryptNote(notesEncrypted: string | null): string | null { if (!notesEncrypted) return null; try { return decrypt(notesEncrypted); - } catch { + } catch (err) { + // A note WAS stored but is undecryptable (key-rotation gap / corruption). + // Fail soft to null so one bad row never throws the day-log read, but log + // it (F-CRYPTO-2) so a systemic key gap surfaces instead of silently + // reading as an empty note. + getEvent()?.addWarning( + `cycle day-log note decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); return null; } } diff --git a/src/lib/illness/dto.ts b/src/lib/illness/dto.ts index 02eb7fad2..eb55ce7ec 100644 --- a/src/lib/illness/dto.ts +++ b/src/lib/illness/dto.ts @@ -10,6 +10,7 @@ * Jackson/WURSS intensity (NULL = a plain presence link). */ import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { getEvent } from "@/lib/logging/context"; import type { IllnessEpisode, IllnessDayLog, @@ -51,7 +52,14 @@ function decryptNote(noteEncrypted: Uint8Array | null): string | null { if (!noteEncrypted || noteEncrypted.byteLength === 0) return null; try { return decryptFromBytes(noteEncrypted); - } catch { + } catch (err) { + // Undecryptable note (key gap / corruption): fail soft to null but log it + // (F-CRYPTO-2) so a systemic key gap surfaces instead of reading as blank. + getEvent()?.addWarning( + `illness note decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); return null; } } diff --git a/src/lib/labs/biomarker-store.ts b/src/lib/labs/biomarker-store.ts index dd2f3cdf6..b6cefe5e1 100644 --- a/src/lib/labs/biomarker-store.ts +++ b/src/lib/labs/biomarker-store.ts @@ -13,6 +13,7 @@ import { Buffer } from "node:buffer"; import { decrypt, encrypt } from "@/lib/crypto"; import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; /** Encrypt a UTF-8 context note into the `Bytes` payload the schema stores. */ export function encryptContextToBytes( @@ -40,7 +41,15 @@ export function decryptContextSoft(buf: Uint8Array | null): string | null { if (!buf) return null; try { return decryptContextFromBytes(buf); - } catch { + } catch (err) { + // Undecryptable context (key gap / corruption): fail soft to null so one + // bad row never 500s the catalog list, but log it (F-CRYPTO-2) so a + // systemic key gap surfaces instead of masking as an empty context. + getEvent()?.addWarning( + `biomarker context decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); return null; } } diff --git a/src/lib/mood/__tests__/custom-label-decrypt-logging.test.ts b/src/lib/mood/__tests__/custom-label-decrypt-logging.test.ts new file mode 100644 index 000000000..58db22f85 --- /dev/null +++ b/src/lib/mood/__tests__/custom-label-decrypt-logging.test.ts @@ -0,0 +1,49 @@ +/** + * F-CRYPTO-5 — an undecryptable custom mood label fails soft (the caller falls + * back to the generic i18n label) but must NOT be entirely silent: a swallowed + * decrypt now emits a wide-event so the loss of a user-renamed tag surfaces. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { decryptMock, addWarningMock } = vi.hoisted(() => ({ + decryptMock: vi.fn(), + addWarningMock: vi.fn(), +})); + +vi.mock("@/lib/crypto", () => ({ + decrypt: decryptMock, + encrypt: vi.fn(), +})); +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addWarning: addWarningMock }), +})); + +import { decryptCustomLabel } from "../custom-tags"; + +beforeEach(() => { + decryptMock.mockReset(); + addWarningMock.mockReset(); +}); + +describe("decryptCustomLabel", () => { + it("returns null without logging for a genuinely-unset label", () => { + expect(decryptCustomLabel(null)).toBeNull(); + expect(addWarningMock).not.toHaveBeenCalled(); + }); + + it("returns the decrypted label on success", () => { + decryptMock.mockReturnValue("Morning walk"); + expect(decryptCustomLabel("cipher")).toBe("Morning walk"); + expect(addWarningMock).not.toHaveBeenCalled(); + }); + + it("fails soft to null AND logs a wide-event when the decrypt throws", () => { + decryptMock.mockImplementation(() => { + throw new Error("bad key"); + }); + expect(decryptCustomLabel("cipher")).toBeNull(); + expect(addWarningMock).toHaveBeenCalledWith( + expect.stringContaining("custom-label decrypt failed"), + ); + }); +}); diff --git a/src/lib/mood/custom-tags.ts b/src/lib/mood/custom-tags.ts index 317435128..efc5d52fc 100644 --- a/src/lib/mood/custom-tags.ts +++ b/src/lib/mood/custom-tags.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import { z } from "zod/v4"; import { encrypt, decrypt } from "@/lib/crypto"; +import { getEvent } from "@/lib/logging/context"; import { MOOD_TAG_ICON_NAMES } from "@/lib/mood/icon-catalog"; /** @@ -77,7 +78,15 @@ export function decryptCustomLabel(encoded: string | null): string | null { if (!encoded) return null; try { return decrypt(encoded); - } catch { + } catch (err) { + // A custom label WAS stored but is undecryptable — fail soft (the caller + // falls back to the generic i18n label) but log it (F-CRYPTO-5) so the loss + // of a user-renamed tag is not entirely silent. + getEvent()?.addWarning( + `mood custom-label decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); return null; } } diff --git a/src/lib/records/dto.ts b/src/lib/records/dto.ts index 6df06b9ae..5178914f2 100644 --- a/src/lib/records/dto.ts +++ b/src/lib/records/dto.ts @@ -8,6 +8,7 @@ * never 500s the whole page) — the IllnessEpisode DTO precedent. */ import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { getEvent } from "@/lib/logging/context"; import type { Allergy, FamilyHistoryEntry } from "@/generated/prisma/client"; export interface AllergyDTO { @@ -39,7 +40,14 @@ function decryptOptional(buf: Uint8Array | null): string | null { if (!buf || buf.byteLength === 0) return null; try { return decryptFromBytes(buf); - } catch { + } catch (err) { + // Undecryptable payload (key gap / corruption): fail soft to null but log + // it (F-CRYPTO-2) so a systemic key gap surfaces instead of reading blank. + getEvent()?.addWarning( + `records field decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); return null; } } From 6165e7f6b8fcf3de225cc0ed7f3ab5557f6f665b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 9 Jul 2026 01:05:47 +0200 Subject: [PATCH 15/15] =?UTF-8?q?chore(release):=20v1.27.30=20=E2=80=94=20?= =?UTF-8?q?correctness=20and=20resilience=20sweep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b1832d3a..b71f2db0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +## [1.27.30] — 2026-07-09 — Correctness and resilience sweep + +### Fixed + +- **Time zones / DST:** the "today" medication window is now DST-safe — a dose near midnight on a 23h/25h transition day no longer drops off the cards, dashboard, or PWA badge. Per-day medication compliance now buckets in the user's own time zone (was hardcoded to one zone), and the punctuality classification and the Insights greeting hour follow the user's zone too. +- **Resilience:** background job workers now run with a statement timeout and pool cap (a heavy nightly fold can no longer wedge the worker pool). Offline mutations fail fast and surface their state instead of hanging and silently losing the write. Each sync source isolates its per-collection/per-metric-type work, so one bad response can't blank a whole integration, and a per-metric-type freshness signal distinguishes a dead pipe from a healthy-idle one. The rollup read falls back to live SQL on any read error. Undecryptable rows now log a diagnostic and, where a clinician or user reads them, surface an honest "unreadable" marker rather than a silent blank. + ## [1.27.29] — 2026-07-08 — Automatic document indexing and AI reading ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9b69dbec0..b1aaef208 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.27.29 + version: 1.27.30 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 940be0a33..77c27352b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.27.29", + "version": "1.27.30", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 54e07fc37..4266442df 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.27.29"; + /* @sw-version-fallback */ "v1.27.30"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`