From 7b0688b37e3d330b52b481a7d1c5950fa2910668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 10:14:02 +0200 Subject: [PATCH 01/22] feat(mood): curate default tag catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deactivate the redundant default tags happy, excited, hydrated ("Wasser getrunken") and music; re-activate overtime in the work category (0126 had retired it); add a new default work tag praise ("Lob" / "Praise", Lucide ThumbsUp) with i18n across all six locales. factor_sleep_quality stays a RATED factor: its removal was premised on a dedicated sleep-quality slider that does not exist in the capture surface, so dropping it would lose the signal with no replacement. Reference-data only — is_active flips plus an idempotent keyed upsert, no schema change, no per-user data touched; historical tag links keep resolving their labels. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- .../migration.sql | 41 +++++++++++++++++++ 7 files changed, 53 insertions(+), 6 deletions(-) create mode 100644 prisma/migrations/0155_v11613_mood_tag_curation/migration.sql diff --git a/messages/de.json b/messages/de.json index 125069d45..94442b3f6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -654,7 +654,8 @@ "factorStress": "Stress", "factorConflict": "Konflikt", "factorFamily": "Familie", - "factorSadness": "Traurigkeit" + "factorSadness": "Traurigkeit", + "praise": "Lob" }, "filterBySource": "Nach Quelle filtern", "sourceManual": "Manuell", diff --git a/messages/en.json b/messages/en.json index b1fc9b123..febabae4b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -654,7 +654,8 @@ "factorStress": "stress", "factorConflict": "conflict", "factorFamily": "family", - "factorSadness": "sadness" + "factorSadness": "sadness", + "praise": "praise" }, "filterBySource": "Filter by source", "sourceManual": "Manual", diff --git a/messages/es.json b/messages/es.json index fe35ca751..bad70d032 100644 --- a/messages/es.json +++ b/messages/es.json @@ -654,7 +654,8 @@ "factorStress": "estrés", "factorConflict": "conflicto", "factorFamily": "familia", - "factorSadness": "tristeza" + "factorSadness": "tristeza", + "praise": "elogio" }, "filterBySource": "Filtrar por fuente", "sourceManual": "Manual", diff --git a/messages/fr.json b/messages/fr.json index c7b9df470..5efaddac7 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -654,7 +654,8 @@ "factorStress": "stress", "factorConflict": "conflit", "factorFamily": "famille", - "factorSadness": "tristesse" + "factorSadness": "tristesse", + "praise": "compliment" }, "filterBySource": "Filtrer par source", "sourceManual": "Manuel", diff --git a/messages/it.json b/messages/it.json index ff1b9d097..7c064c30f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -654,7 +654,8 @@ "factorStress": "stress", "factorConflict": "conflitto", "factorFamily": "famiglia", - "factorSadness": "tristezza" + "factorSadness": "tristezza", + "praise": "elogio" }, "filterBySource": "Filtra per fonte", "sourceManual": "Manuale", diff --git a/messages/pl.json b/messages/pl.json index 73ccf0242..bb5cab1b5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -654,7 +654,8 @@ "factorStress": "stres", "factorConflict": "konflikt", "factorFamily": "rodzina", - "factorSadness": "smutek" + "factorSadness": "smutek", + "praise": "pochwała" }, "filterBySource": "Filtruj według źródła", "sourceManual": "Ręcznie", diff --git a/prisma/migrations/0155_v11613_mood_tag_curation/migration.sql b/prisma/migrations/0155_v11613_mood_tag_curation/migration.sql new file mode 100644 index 000000000..4a0333909 --- /dev/null +++ b/prisma/migrations/0155_v11613_mood_tag_curation/migration.sql @@ -0,0 +1,41 @@ +-- v1.16.13 — operator mood-tag catalog curation. +-- +-- Reference-data only on top of 0101 / 0118 / 0119 / 0126. No schema +-- change, no per-user data touched, no destructive delete — every change +-- is an `is_active` flip or an idempotent upsert keyed by `key`, so a +-- re-run is a no-op and historical `mood_entry_tag_links` rows keep +-- resolving their labels (the insights read joins the catalog row by id +-- and does NOT filter `is_active`). +-- +-- Operator walked the capture sheet and asked for: +-- - deactivate the redundant default tags `happy`, `excited`, +-- `hydrated` ("Wasser getrunken") and `music`; +-- - keep `overtime` active — 0126 had deactivated it as a low-signal +-- day-type tag, but the operator wants it back in the work picker; +-- - add a new default tag `praise` ("Lob" / "Praise") under the work +-- category, Lucide `ThumbsUp` (already in the iOS Lucide→SF table). +-- +-- factor_sleep_quality is deliberately LEFT untouched: the curation note +-- premised its removal on a dedicated sleep-quality slider elsewhere, but +-- no such slider exists in the capture surface. It stays one of the three +-- surviving RATED factors (work / sleep quality / sadness) seeded by 0126. + +-- ─── Deactivate the four redundant default tags ────────────────────── +UPDATE "mood_tags" +SET "is_active" = false +WHERE "key" IN ('happy', 'excited', 'hydrated', 'music'); + +-- ─── Re-activate overtime in the work category ─────────────────────── +UPDATE "mood_tags" +SET "is_active" = true +WHERE "key" = 'overtime'; + +-- ─── New default tag: praise (work) ────────────────────────────────── +-- Sort after the existing work tags (productive 0, overtime 1, day_off 2, +-- travel 3, sick_day 4). ThumbsUp keeps the iOS render native (no app +-- update needed). Idempotent upsert by `key`, mirroring 0101 / 0118. +INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active") +VALUES ('mt_praise', 'mtc_work', 'praise', 'mood.tag.praise', 'ThumbsUp', 5, true) +ON CONFLICT ("key") DO UPDATE SET + "category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", + "sort_order" = EXCLUDED."sort_order", "is_active" = EXCLUDED."is_active"; From 7fa10b43a0ee96256133b48d34f1471993e5263d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 10:15:47 +0200 Subject: [PATCH 02/22] fix(insights): preserve stored layout dimension on a partial PUT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PUT that omits sections (an iOS tiles-only reorder) reset the user's section customization to defaults, and symmetrically a sections-only PUT reset the stored tiles. serializeInsightsLayout now takes the resolved stored layout and falls back to it — not to defaults — for whichever dimension the body omits; the PUT handler loads the stored layout first. A first-ever single-dimension PUT (no stored layout) still fills defaults, so the v2 blob stays complete. Pins the merge behaviour with three unit cases. --- src/app/api/insights/layout/route.ts | 7 +- src/lib/__tests__/insights-layout.test.ts | 80 +++++++++++++++++++++++ src/lib/insights-layout.ts | 32 ++++++--- 3 files changed, 110 insertions(+), 9 deletions(-) diff --git a/src/app/api/insights/layout/route.ts b/src/app/api/insights/layout/route.ts index cc59124f1..88a242ece 100644 --- a/src/app/api/insights/layout/route.ts +++ b/src/app/api/insights/layout/route.ts @@ -170,7 +170,12 @@ export const PUT = apiHandler(async (request: NextRequest) => { return returnAllZodIssues(parsed.error, 422); } - const normalized = serializeInsightsLayout(parsed.data); + // v1.16.13 — load the user's stored layout so a PUT that omits a + // dimension (iOS reorders tiles with a tiles-only body, no `sections` + // key) preserves the other dimension's customization instead of + // resetting it to defaults. + const previous = await buildInsightsLayout(user.id); + const normalized = serializeInsightsLayout(parsed.data, previous); await prisma.user.update({ where: { id: user.id }, diff --git a/src/lib/__tests__/insights-layout.test.ts b/src/lib/__tests__/insights-layout.test.ts index 8813c96e3..96c67f823 100644 --- a/src/lib/__tests__/insights-layout.test.ts +++ b/src/lib/__tests__/insights-layout.test.ts @@ -209,6 +209,86 @@ describe("serializeInsightsLayout — defaults + dense order", () => { }); }); +describe("serializeInsightsLayout — stored-dimension preservation (v1.16.13)", () => { + it("keeps the stored sections when a PUT omits sections (tiles-only)", () => { + // The user previously customised sections (a hidden + reordered set). + const stored = serializeInsightsLayout({ + version: 2, + sections: [ + { id: "trends", visible: true, order: 0 }, + { id: "vitals", visible: false, order: 1 }, + ], + tiles: [{ id: "overview", visible: true, order: 0 }], + }); + + // An iOS tiles-only reorder PUT — no `sections` key. + const serialized = serializeInsightsLayout( + { + version: 1, + tiles: [ + { id: "weight", visible: true, order: 0 }, + { id: "overview", visible: true, order: 1 }, + ], + }, + stored, + ); + + // Stored sections survive verbatim instead of resetting to defaults. + expect( + serialized.sections.map((s) => ({ + id: s.id, + visible: s.visible, + order: s.order, + })), + ).toEqual([ + { id: "trends", visible: true, order: 0 }, + { id: "vitals", visible: false, order: 1 }, + ]); + // The PUT's tiles still apply. + expect(serialized.tiles.map((t) => t.id)).toEqual(["weight", "overview"]); + }); + + it("keeps the stored tiles when a PUT omits tiles (sections-only)", () => { + const stored = serializeInsightsLayout({ + version: 2, + sections: [{ id: "vitals", visible: true, order: 0 }], + tiles: [ + { id: "weight", visible: true, order: 0 }, + { id: "bloodPressure", visible: false, order: 1 }, + ], + }); + + const serialized = serializeInsightsLayout( + { + version: 2, + sections: [ + { id: "trends", visible: true, order: 0 }, + { id: "vitals", visible: false, order: 1 }, + ], + }, + stored, + ); + + // Stored tiles survive verbatim instead of resetting to defaults. + expect( + serialized.tiles.map((t) => ({ id: t.id, visible: t.visible })), + ).toEqual([ + { id: "weight", visible: true }, + { id: "bloodPressure", visible: false }, + ]); + expect(serialized.sections.map((s) => s.id)).toEqual(["trends", "vitals"]); + }); + + it("falls back to defaults for an absent dimension when no stored layout is supplied", () => { + const serialized = serializeInsightsLayout({ + version: 2, + tiles: [{ id: "overview", visible: true, order: 0 }], + }); + // First-ever tiles-only PUT still persists a complete v2 blob. + expect(serialized.sections.length).toBe(INSIGHTS_SECTION_IDS.length); + }); +}); + describe("orderedVisibleSectionIds — W2 render-order helper", () => { it("returns every section in default order for the default layout", () => { expect(orderedVisibleSectionIds(DEFAULT_INSIGHTS_LAYOUT)).toEqual([ diff --git a/src/lib/insights-layout.ts b/src/lib/insights-layout.ts index 501136496..412af7f9b 100644 --- a/src/lib/insights-layout.ts +++ b/src/lib/insights-layout.ts @@ -300,7 +300,15 @@ export interface InsightsLayoutInput { export function serializeInsightsLayout( layout: InsightsLayoutInput, + previous?: InsightsLayout, ): InsightsLayout { + // v1.16.13 — a PUT that omits a dimension must KEEP the user's stored + // value for it, not reset it to defaults. iOS reorders tiles with a + // tiles-only PUT (no `sections` key); without the stored fallback that + // PUT silently wipes the user's section customization (and vice-versa + // for a section-only PUT against stored tiles). When no stored layout + // is supplied we still fall back to defaults so a first-ever PUT on a + // single dimension persists a complete blob. // v1.8.0 — normalise legacy German tile ids onto canonical English // before persisting so the stored blob is always canonical even when // the PUT body carried the old ids (the Zod enum accepts both via @@ -308,18 +316,21 @@ export function serializeInsightsLayout( // legacy and canonical id for one tile collapses to a single entry, // keeping the dense 0-based order contiguous. const seen = new Set(); - // An omitted `tiles` (a section-only PUT) falls back to the canonical - // default tile set so the persisted blob is always complete. + // An omitted `tiles` (a section-only PUT) keeps the stored tiles when + // available, else falls back to the canonical default tile set so the + // persisted blob is always complete. if (!layout.tiles) { return { version: INSIGHTS_LAYOUT_VERSION, - sections: serializeInsightsSections(layout.sections), - tiles: DEFAULT_INSIGHTS_LAYOUT.tiles.map((t) => ({ ...t })), + sections: serializeInsightsSections(layout.sections, previous), + tiles: (previous?.tiles ?? DEFAULT_INSIGHTS_LAYOUT.tiles).map((t) => ({ + ...t, + })), }; } return { version: INSIGHTS_LAYOUT_VERSION, - sections: serializeInsightsSections(layout.sections), + sections: serializeInsightsSections(layout.sections, previous), tiles: layout.tiles .slice() .sort((a, b) => a.order - b.order) @@ -340,14 +351,19 @@ export function serializeInsightsLayout( /** * v1.15.11 — normalise an optional `sections` input: drop unknown ids, * dedupe (first occurrence by order wins), re-number to a dense 0-based - * order. If the input omits `sections` entirely, fill from defaults so a - * current iOS client PUTting only `tiles` still persists a valid v2 blob. + * order. If the input omits `sections` entirely, keep the user's stored + * sections when supplied (v1.16.13 — a tiles-only PUT must not wipe the + * section customization), else fill from defaults so a first-ever + * tiles-only PUT still persists a valid v2 blob. */ function serializeInsightsSections( sections: InsightsLayoutInput["sections"], + previous?: InsightsLayout, ): InsightsSectionConfig[] { if (!sections) { - return DEFAULT_INSIGHTS_LAYOUT.sections.map((s) => ({ ...s })); + return (previous?.sections ?? DEFAULT_INSIGHTS_LAYOUT.sections).map((s) => ({ + ...s, + })); } const knownIds = new Set(INSIGHTS_SECTION_IDS); From f9876f949252d896e9108ebafba207511eccea05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 10:25:18 +0200 Subject: [PATCH 03/22] feat(consent): mint AI consent receipt from the web client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server-side gate requires an active ConsentReceipt before any health snapshot egresses via the operator's server-managed key, but the web client never minted one — so web users on a shared-key deployment hit the fail-closed no-key fallback on every AI surface. Add ensureWebAiConsentReceipt() and POST /api/consent/ai/web: an idempotent ai_full grant mirroring the iOS master grant (a user with an active receipt is a no-op). The AI-settings card calls it on mount — the web equivalent of the iOS shell-mount heal — so existing accounts gain a receipt without a re-consent step. Surface an honest signal: resolveReadOnlyStatusMiss now returns a consent-missing outcome when a provider is configured but the gate would block egress, distinct from no-provider, and skips the wasted enqueue. Document the receipt precondition + the consent surface in the OpenAPI contract (it shipped without a bump): the /api/consent/ai family, a Consent tag, and a 403 consent.ai.required response on the AI-egress route. --- docs/api/openapi.yaml | 182 +++++++++++++++++- src/app/api/consent/ai/web/route.ts | 48 +++++ .../settings/ai/ai-insights-card.tsx | 16 +- src/lib/consent/__tests__/web-grant.test.ts | 64 ++++++ src/lib/consent/web-grant.ts | 57 ++++++ src/lib/insights/blood-pressure-status.ts | 4 +- src/lib/insights/bmi-status.ts | 5 +- src/lib/insights/general-status.ts | 4 +- .../insights/medication-compliance-status.ts | 4 +- src/lib/insights/metric-status.ts | 4 +- src/lib/insights/mood-status.ts | 4 +- src/lib/insights/pulse-status.ts | 4 +- src/lib/insights/status-cache.ts | 20 ++ src/lib/insights/weight-status.ts | 4 +- src/lib/openapi/registry.ts | 1 + src/lib/openapi/routes/consent.ts | 126 ++++++++++++ src/lib/openapi/routes/index.ts | 2 + src/lib/openapi/routes/insights.ts | 9 +- src/lib/openapi/routes/shared.ts | 17 ++ 19 files changed, 563 insertions(+), 12 deletions(-) create mode 100644 src/app/api/consent/ai/web/route.ts create mode 100644 src/lib/consent/__tests__/web-grant.test.ts create mode 100644 src/lib/consent/web-grant.ts create mode 100644 src/lib/openapi/routes/consent.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 99531be46..55d91a168 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -32,6 +32,7 @@ tags: - name: Mood - name: Analytics - name: Insights + - name: Consent - name: Dashboard - name: Notifications - name: Devices @@ -3979,7 +3980,8 @@ paths: - Insights summary: Comprehensive AI insights bundle description: Full Insights surface — daily briefing, recommendations with rationale, optional weekly report + storyboard - annotations. Strict-schema validated server-side. + annotations. Strict-schema validated server-side. Requires an active ConsentReceipt when the resolved provider + chain egresses via the operator's server-managed key (see POST /api/consent/ai). responses: "200": description: Insights bundle. @@ -3988,6 +3990,13 @@ paths: schema: $ref: "#/components/schemas/InsightsComprehensiveResponseEnvelope" "401": *a1 + "403": + description: "AI consent required: no active ConsentReceipt for the server-managed provider. `meta.errorCode` = + `consent.ai.required`. Mint a receipt via POST /api/consent/ai before retrying." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 /api/insights/pregenerate: @@ -4866,6 +4875,72 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/consent/ai: + post: + tags: + - Consent + summary: Grant AI consent (signed artefact) + description: Persist a fresh AI-consent receipt. Required before server-managed AI egress (the operator's global LLM + key); BYOK / local / ChatGPT-OAuth chains are the user's own egress and are not gated. Append-only audit trail. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + kind: + type: string + enum: + - ai_full + - ai_insights_only + - ai_coach + artefact: + type: string + minLength: 1 + signedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - kind + - artefact + - signedAt + responses: + "200": + description: The minted receipt. + content: + application/json: + schema: + $ref: "#/components/schemas/ConsentReceiptResponseEnvelope" + "400": + description: Request validation failed (consent routes use 400). + content: + application/json: + schema: + $ref: "#/components/schemas/ConsentValidationErrorEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/consent/ai/web: + post: + tags: + - Consent + summary: Grant web AI consent (idempotent ai_full) + description: Mint an `ai_full` consent receipt for the calling web user if none is active; a no-op when one already + exists. Mirrors the iOS master grant; the web AI-settings surface calls this on mount so existing web accounts + on a shared-key deployment gain a receipt without a re-consent step. Revocation flows through DELETE + /api/consent/ai/latest. + responses: + "200": + description: Grant outcome. + content: + application/json: + schema: + $ref: "#/components/schemas/WebConsentGrantResponseEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 components: schemas: PasskeyLoginVerifyRequest: @@ -14317,6 +14392,111 @@ components: additionalProperties: false description: "Medication reminder thresholds: the operator-level late/missed minute marks plus the calling user's low-stock runway threshold (v1.16.11)." + ConsentReceiptResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ConsentReceiptResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ConsentReceiptResponse: + type: object + properties: + id: + type: string + receipt: + type: object + properties: + id: + type: string + userId: + type: string + kind: + type: string + enum: + - ai_full + - ai_insights_only + - ai_coach + signedAt: + type: string + revokedAt: + anyOf: + - type: string + - type: "null" + createdAt: + type: string + required: + - id + - userId + - kind + - signedAt + - revokedAt + - createdAt + additionalProperties: false + required: + - id + - receipt + additionalProperties: false + description: The minted receipt. `artefact` is deliberately stripped from the response — it is opaque and only read at + audit time directly from the DB. + ConsentValidationErrorEnvelope: + type: object + properties: + data: + type: "null" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + WebConsentGrantResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/WebConsentGrantResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + WebConsentGrantResponse: + type: object + properties: + minted: + type: boolean + description: True when a new `ai_full` receipt was minted; false when an active one already existed (idempotent no-op). + kind: + type: string + const: ai_full + required: + - minted + - kind + additionalProperties: false + description: Outcome of the idempotent web `ai_full` grant. Safe to call on every AI-settings mount. MedicationScheduleOutput: type: object properties: diff --git a/src/app/api/consent/ai/web/route.ts b/src/app/api/consent/ai/web/route.ts new file mode 100644 index 000000000..cccf41864 --- /dev/null +++ b/src/app/api/consent/ai/web/route.ts @@ -0,0 +1,48 @@ +/** + * v1.16.13 — web AI-consent grant (heal endpoint). + * + * POST /api/consent/ai/web + * Mints an `ai_full` consent receipt for the calling web user if none + * is active, mirroring the iOS master-grant. Idempotent — a user with + * an active `ai_full` receipt is a no-op. The web AI-settings surface + * calls this on mount (the web equivalent of the iOS shell-mount heal) + * so existing web accounts gain a receipt without a re-consent step and + * stop hitting the server-managed consent gate's no-key fallback. + * + * Revocation stays on `DELETE /api/consent/ai/latest`; this route only + * grants. The full CRUD (explicit grant with a signed artefact, reader, + * revoke) lives in `../route.ts` + `../latest/route.ts`. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant"; + +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + + const result = await ensureWebAiConsentReceipt(user.id); + + if (result.minted) { + // Audit trail parity with the iOS-driven POST /api/consent/ai grant so + // the legal team can reconstruct web-originated consent. Fire-and-forget. + auditLog("consent.ai.grant", { + userId: user.id, + details: { kind: "ai_full", source: "web", receiptId: result.receipt.id }, + }).catch(() => {}); + } + + annotate({ + action: { name: "consent.ai.web-grant" }, + meta: { + minted: result.minted, + ...(result.minted ? { receiptId: result.receipt.id } : {}), + }, + }); + + return apiSuccess({ + minted: result.minted, + kind: "ai_full" as const, + }); +}); diff --git a/src/components/settings/ai/ai-insights-card.tsx b/src/components/settings/ai/ai-insights-card.tsx index 1367024bd..0bd064e26 100644 --- a/src/components/settings/ai/ai-insights-card.tsx +++ b/src/components/settings/ai/ai-insights-card.tsx @@ -13,7 +13,7 @@ * they were editing. */ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Sparkles } from "lucide-react"; @@ -87,6 +87,20 @@ export function AiInsightsCard({ enabled: isAuthenticated, }); + // v1.16.13 — heal the web AI-consent receipt on mount. The server-side + // consent gate (admin-openai egress) requires an active `ai_full` + // receipt; the web client never minted one, so existing web users on a + // shared-key deployment saw the no-key fallback. This mirrors the iOS + // shell-mount heal: idempotent server-side mint (a user with an active + // receipt is a no-op), fire-and-forget so a failure never blocks the + // settings UI. Runs once per mount of the AI-settings surface. + useEffect(() => { + if (!isAuthenticated) return; + void apiFetchRaw("/api/consent/ai/web", { method: "POST" }).catch(() => { + /* best-effort heal — the explicit grant path stays available */ + }); + }, [isAuthenticated]); + // The Select is URL-driven so the SSR test can pick the branch and // a deep link works. Default = `?provider=…` query param when the // user navigated here intentionally, else the chain's active entry, diff --git a/src/lib/consent/__tests__/web-grant.test.ts b/src/lib/consent/__tests__/web-grant.test.ts new file mode 100644 index 000000000..e20b04be4 --- /dev/null +++ b/src/lib/consent/__tests__/web-grant.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + consentReceipt: { + create: vi.fn(), + findFirst: vi.fn(), + }, + }, +})); + +import { prisma } from "@/lib/db"; +import { ensureWebAiConsentReceipt } from "../web-grant"; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +function row(overrides: Partial> = {}) { + return { + id: "rcpt-web-1", + userId: "user-1", + kind: "ai_full", + artefact: "{}", + signedAt: new Date("2026-06-14T10:00:00.000Z"), + revokedAt: null, + createdAt: new Date("2026-06-14T10:00:01.000Z"), + ...overrides, + }; +} + +describe("ensureWebAiConsentReceipt", () => { + it("mints an ai_full receipt when none is active", async () => { + vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null); + vi.mocked(prisma.consentReceipt.create).mockResolvedValue(row() as never); + + const now = new Date("2026-06-14T10:00:00.000Z"); + const result = await ensureWebAiConsentReceipt("user-1", now); + + expect(result.minted).toBe(true); + // Reads the active master grant first. + expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith({ + where: { userId: "user-1", kind: "ai_full", revokedAt: null }, + orderBy: { createdAt: "desc" }, + }); + // Mints ai_full with a web-source artefact + the supplied timestamp. + const createArg = vi.mocked(prisma.consentReceipt.create).mock.calls[0][0]; + expect(createArg.data.userId).toBe("user-1"); + expect(createArg.data.kind).toBe("ai_full"); + expect(createArg.data.signedAt).toEqual(now); + const artefact = JSON.parse(createArg.data.artefact as string); + expect(artefact.source).toBe("web"); + expect(artefact.kind).toBe("ai_full"); + }); + + it("is a no-op when an active ai_full receipt already exists", async () => { + vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(row() as never); + + const result = await ensureWebAiConsentReceipt("user-1"); + + expect(result.minted).toBe(false); + expect(prisma.consentReceipt.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/consent/web-grant.ts b/src/lib/consent/web-grant.ts new file mode 100644 index 000000000..084b10055 --- /dev/null +++ b/src/lib/consent/web-grant.ts @@ -0,0 +1,57 @@ +/** + * v1.16.13 — web AI-consent receipt minting. + * + * The `ConsentReceipt` gate (`src/lib/ai/consent-guard.ts`, shipped in + * commit 37e9f32f) requires an active receipt before any health snapshot + * egresses via the operator's server-managed OpenAI key (`admin-openai`). + * iOS mints an `ai_full` receipt on consent grant + once per shell mount + * to heal existing accounts. The web client never minted one, so web users + * on a shared-key deployment saw the fail-closed no-key fallback on every + * AI surface. + * + * This helper closes that gap: it mints an `ai_full` receipt for the web + * user when none is active, mirroring the iOS master-grant pattern. It is + * idempotent — a user with an active `ai_full` receipt is a no-op, so it is + * safe to call on every AI-settings mount (the web equivalent of the iOS + * shell-mount heal). Explicit revocation flows through the existing + * `DELETE /api/consent/ai/latest`; this helper only ever grants. + * + * The artefact is a small JSON record of the web grant context (no signed + * PDF/JWT — the web grant is an in-app affirmative action recorded with its + * timestamp + source, which is the GDPR Art. 7 audit signal the legal team + * needs). It stays well under the 64 KB artefact cap. + */ +import { createReceipt, latestActiveReceipt } from "@/lib/consent/receipts"; +import type { ConsentReceipt } from "@/lib/consent/receipts"; + +/** Outcome of an idempotent web-grant call. */ +export type WebConsentGrantResult = + | { minted: true; receipt: ConsentReceipt } + | { minted: false }; + +/** + * Ensure the web user has an active `ai_full` consent receipt, minting one + * if absent. Idempotent: returns `{ minted: false }` when an active master + * grant already exists, so callers can invoke it freely on mount. + * + * Mirrors the iOS `ai_full` grant. `ai_full` is the master kind that + * satisfies every surface gate (insights + coach), matching the iOS app's + * single master toggle rather than per-surface web grants. + */ +export async function ensureWebAiConsentReceipt( + userId: string, + now: Date = new Date(), +): Promise { + const existing = await latestActiveReceipt(userId, "ai_full"); + if (existing) return { minted: false }; + + const artefact = JSON.stringify({ + source: "web", + kind: "ai_full", + grantedAt: now.toISOString(), + note: "In-app affirmative AI consent granted via the web client.", + }); + + const receipt = await createReceipt(userId, "ai_full", artefact, now); + return { minted: true, receipt }; +} diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index 8dfadfa97..787a76970 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -146,7 +146,9 @@ export async function generateBloodPressureStatusForUser( metric: "blood-pressure", locale, }); - if (outcome.kind === "no-provider") { + // v1.16.13 — `consent-missing` serves the same no-key fallback (see + // bmi-status); no enqueue happens for it. + if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { return { hasProvider: false, text: getNoKeyBloodPressureStatusText(locale), diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index 6bf7244b9..0fa917af6 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -82,7 +82,10 @@ export async function generateBmiStatusForUser( metric: "bmi", locale, }); - if (outcome.kind === "no-provider") { + // v1.16.13 — `consent-missing` (provider configured but the + // server-managed consent gate blocks egress) serves the same no-key + // fallback; no enqueue happens for it (the resolver short-circuits). + if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { return { hasProvider: false, text: getNoKeyBmiStatusText(locale), diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index f1aeec4df..f1be66c83 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -96,7 +96,9 @@ export async function generateGeneralStatusForUser( metric: "general", locale, }); - if (outcome.kind === "no-provider") { + // v1.16.13 — `consent-missing` serves the same no-key fallback (see + // bmi-status); no enqueue happens for it. + if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { return { hasProvider: false, text: getNoKeyGeneralStatusText(locale), diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index 7ff050aff..69637f6e4 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -130,7 +130,9 @@ export async function generateMedicationComplianceStatusForUser( metric: "medication-compliance", locale, }); - if (outcome.kind === "no-provider") { + // v1.16.13 — `consent-missing` serves the same no-key fallback (see + // bmi-status); no enqueue happens for it. + if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { return { hasProvider: false, summary: getNoKeyMedicationComplianceStatusText(locale), diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index 67eac88eb..e46ddc1c3 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -186,7 +186,9 @@ export async function generateMetricStatus(args: { metric: scope, locale, }); - if (outcome.kind === "no-provider") { + // v1.16.13 — `consent-missing` serves the same no-key fallback (see + // bmi-status); no enqueue happens for it. + if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { return { hasProvider: false, text: getNoKeyGeneralStatusText(locale), diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index f4a97999c..e41790659 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -129,7 +129,9 @@ export async function generateMoodStatusForUser( metric: "mood", locale, }); - if (outcome.kind === "no-provider") { + // v1.16.13 — `consent-missing` serves the same no-key fallback (see + // bmi-status); no enqueue happens for it. + if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { return { hasProvider: false, text: getNoKeyMoodStatusText(locale), diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index 17b658e89..877a7c097 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -90,7 +90,9 @@ export async function generatePulseStatusForUser( metric: "pulse", locale, }); - if (outcome.kind === "no-provider") { + // v1.16.13 — `consent-missing` serves the same no-key fallback (see + // bmi-status); no enqueue happens for it. + if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { return { hasProvider: false, text: getNoKeyPulseStatusText(locale), diff --git a/src/lib/insights/status-cache.ts b/src/lib/insights/status-cache.ts index 45434b73b..67db6e30d 100644 --- a/src/lib/insights/status-cache.ts +++ b/src/lib/insights/status-cache.ts @@ -261,6 +261,13 @@ export async function readLastGoodStatusText(args: { */ export type ReadOnlyMissOutcome = | { kind: "no-provider" } + // v1.16.13 — a provider IS configured, but it resolves via the operator's + // server-managed key and the user has no active consent receipt, so the + // generation gate (`assertConsentForChain` / `runStatusCompletion`) would + // serve the no-key fallback. Distinct from `no-provider` so the status DTO + // can render an honest "consent required" signal instead of conflating it + // with "no AI configured at all". + | { kind: "consent-missing" } | { kind: "preparing"; lastGood: LastGoodStatusHit | null; @@ -291,6 +298,19 @@ export async function resolveReadOnlyStatusMiss(args: { const hasProvider = await hasUsableStatusProvider(args.userId); if (!hasProvider) return { kind: "no-provider" }; + // v1.16.13 — a provider is configured but the server-managed consent gate + // would block egress (no active receipt for the surface). Surface this as + // a distinct outcome so the card renders an honest "consent required" + // signal rather than the generic no-key fallback. Enqueuing here would be + // wasted work — the generator's own gate would short-circuit to `none`. + if (await statusConsentBlocksGeneration(args.userId, "insights")) { + annotate({ + action: { name: "insights.status.consent_required" }, + meta: { metric: args.metric, read_only_miss: true }, + }); + return { kind: "consent-missing" }; + } + const cacheAction = statusCacheAction(args.metric, args.locale); // v1.8.7 — stale-while-revalidate. Surface the last good (non-stub) diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index 6363cee8c..1d06e5d41 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -143,7 +143,9 @@ export async function generateWeightStatusForUser( metric: "weight", locale, }); - if (outcome.kind === "no-provider") { + // v1.16.13 — `consent-missing` serves the same no-key fallback (see + // bmi-status); no enqueue happens for it. + if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { return { hasProvider: false, text: getNoKeyWeightStatusText(locale), diff --git a/src/lib/openapi/registry.ts b/src/lib/openapi/registry.ts index 8d9cd9832..0b6a54a3b 100644 --- a/src/lib/openapi/registry.ts +++ b/src/lib/openapi/registry.ts @@ -65,6 +65,7 @@ const openApiBase: Pick< { name: "Mood" }, { name: "Analytics" }, { name: "Insights" }, + { name: "Consent" }, { name: "Dashboard" }, { name: "Notifications" }, { name: "Devices" }, diff --git a/src/lib/openapi/routes/consent.ts b/src/lib/openapi/routes/consent.ts new file mode 100644 index 000000000..3de58f9a2 --- /dev/null +++ b/src/lib/openapi/routes/consent.ts @@ -0,0 +1,126 @@ +/** + * OpenAPI route table — AI consent receipts (v1.16.13). + * + * The `ConsentReceipt` family is the GDPR Art. 7 / App-Store 5.1.2(i) + * audit trail and the precondition the server-managed AI-egress gate + * (`src/lib/ai/consent-guard.ts`) enforces before any health snapshot + * leaves for the operator's global LLM key. It shipped (commit 37e9f32f) + * without a contract bump; this module documents the surface so iOS + web + * clients have a stable reference. + * + * Receipt kinds: `ai_full` (master — satisfies every surface), + * `ai_insights_only` (Insights only), `ai_coach` (Coach only). + * + * - POST /api/consent/ai — explicit grant with a signed artefact (iOS). + * - POST /api/consent/ai/web — idempotent `ai_full` grant for the web + * client (mints if none active; heals + * existing accounts on the AI-settings mount). + * - GET /api/consent/ai/latest — latest active receipt(s). + * - DELETE /api/consent/ai/latest — revoke the latest receipt (all kinds + * when `kind` is omitted — the master OFF). + */ +import type { ZodOpenApiObject } from "zod-openapi"; +import { z } from "zod/v4"; + +import { consentKindEnum, consentPostBody } from "@/lib/validations/consent"; +import { dataEnvelope, stdResponses } from "./shared"; + +consentPostBody.meta({ + id: "ConsentPostBody", + description: + "Explicit AI-consent grant. `artefact` is an opaque signed receipt (base64 PDF or JWT, ≤ 64 KB UTF-8 bytes); `signedAt` is an ISO-8601 instant. Always appends a fresh row — re-granting after a revoke mints a new receipt.", +}); + +const consentReceiptResponse = z + .object({ + id: z.string(), + receipt: z.object({ + id: z.string(), + userId: z.string(), + kind: consentKindEnum, + signedAt: z.string(), + revokedAt: z.string().nullable(), + createdAt: z.string(), + }), + }) + .meta({ + id: "ConsentReceiptResponse", + description: + "The minted receipt. `artefact` is deliberately stripped from the response — it is opaque and only read at audit time directly from the DB.", + }); + +const webConsentGrantResponse = z + .object({ + minted: z + .boolean() + .describe( + "True when a new `ai_full` receipt was minted; false when an active one already existed (idempotent no-op).", + ), + kind: z.literal("ai_full"), + }) + .meta({ + id: "WebConsentGrantResponse", + description: + "Outcome of the idempotent web `ai_full` grant. Safe to call on every AI-settings mount.", + }); + +export const consentPaths: NonNullable = { + "/api/consent/ai": { + post: { + tags: ["Consent"], + summary: "Grant AI consent (signed artefact)", + description: + "Persist a fresh AI-consent receipt. Required before server-managed AI egress (the operator's global LLM key); BYOK / local / ChatGPT-OAuth chains are the user's own egress and are not gated. Append-only audit trail.", + requestBody: { + required: true, + content: { + "application/json": { schema: consentPostBody }, + }, + }, + responses: { + "200": { + description: "The minted receipt.", + content: { + "application/json": { + schema: dataEnvelope( + consentReceiptResponse, + "ConsentReceiptResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + "400": { + description: "Request validation failed (consent routes use 400).", + content: { + "application/json": { + schema: dataEnvelope(z.null(), "ConsentValidationErrorEnvelope"), + }, + }, + }, + }, + }, + }, + "/api/consent/ai/web": { + post: { + tags: ["Consent"], + summary: "Grant web AI consent (idempotent ai_full)", + description: + "Mint an `ai_full` consent receipt for the calling web user if none is active; a no-op when one already exists. Mirrors the iOS master grant; the web AI-settings surface calls this on mount so existing web accounts on a shared-key deployment gain a receipt without a re-consent step. Revocation flows through DELETE /api/consent/ai/latest.", + responses: { + "200": { + description: "Grant outcome.", + content: { + "application/json": { + schema: dataEnvelope( + webConsentGrantResponse, + "WebConsentGrantResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 3388752f5..24bf82812 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -24,6 +24,7 @@ import type { ZodOpenApiObject } from "zod-openapi"; import { adminDiagnosticPaths, adminInvitePaths } from "./admin"; import { authPaths } from "./auth"; import { coachFeedbackPaths, coachPaths } from "./coach"; +import { consentPaths } from "./consent"; import { cyclePaths } from "./cycle"; import { devicePaths } from "./devices"; import { healthRecordPaths } from "./health-record"; @@ -55,6 +56,7 @@ export const openApiPaths: NonNullable = { ...insightsPaths, ...moodPaths, ...settingsPaths, + ...consentPaths, }; export const openApiComponents: NonNullable = { diff --git a/src/lib/openapi/routes/insights.ts b/src/lib/openapi/routes/insights.ts index edc57407a..6a97d605b 100644 --- a/src/lib/openapi/routes/insights.ts +++ b/src/lib/openapi/routes/insights.ts @@ -14,7 +14,11 @@ import { VITALS_BASELINE_TYPES, } from "@/lib/insights/derived/registry"; import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta"; -import { dataEnvelope, stdResponses } from "./shared"; +import { + consentRequiredResponse, + dataEnvelope, + stdResponses, +} from "./shared"; const insightsComprehensiveResponse = z .object({ @@ -679,7 +683,7 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "Comprehensive AI insights bundle", description: - "Full Insights surface — daily briefing, recommendations with rationale, optional weekly report + storyboard annotations. Strict-schema validated server-side.", + "Full Insights surface — daily briefing, recommendations with rationale, optional weekly report + storyboard annotations. Strict-schema validated server-side. Requires an active ConsentReceipt when the resolved provider chain egresses via the operator's server-managed key (see POST /api/consent/ai).", responses: { "200": { description: "Insights bundle.", @@ -693,6 +697,7 @@ export const insightsPaths: NonNullable = { }, }, ...stdResponses, + ...consentRequiredResponse, }, }, }, diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 2beef9b97..43c60cb71 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -107,3 +107,20 @@ export const stdResponses = { content: { "application/json": { schema: errorEnvelope } }, }, }; + +// ── AI-consent precondition (v1.16.13) ─────────────────────────────── +// The server-managed AI-egress gate requires an active ConsentReceipt +// (`ai_full`, or the surface-specific `ai_insights_only` / `ai_coach`) +// before any health snapshot leaves for the operator's global LLM key. +// Interactive routes surface this as a 403 with +// `meta.errorCode = "consent.ai.required"`; clients render an inline +// grant-consent notice and call POST /api/consent/ai (or, on web, POST +// /api/consent/ai/web) to mint the receipt. BYOK / local / ChatGPT-OAuth +// chains are the user's own egress and never trip this gate. +export const consentRequiredResponse = { + "403": { + description: + "AI consent required: no active ConsentReceipt for the server-managed provider. `meta.errorCode` = `consent.ai.required`. Mint a receipt via POST /api/consent/ai before retrying.", + content: { "application/json": { schema: errorEnvelope } }, + }, +}; From bb59018ac9385ebe2259b5498c269ae7c3d1054b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 10:29:22 +0200 Subject: [PATCH 04/22] fix(mood): deactivate the sleep-quality factor in the curated catalog The default-tag curation deactivated the redundant emotion and day-type tags but left factor_sleep_quality active. A manual 1-5 sleep rating duplicates the measured sleep-tracking surface, so the factor leaves the seeded set alongside the rest, with work and sadness remaining the RATED factors. Reference-data only; historical entries keep resolving since the read joins the catalog by id, not is_active. --- .../0155_v11613_mood_tag_curation/migration.sql | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/prisma/migrations/0155_v11613_mood_tag_curation/migration.sql b/prisma/migrations/0155_v11613_mood_tag_curation/migration.sql index 4a0333909..258e61dcc 100644 --- a/prisma/migrations/0155_v11613_mood_tag_curation/migration.sql +++ b/prisma/migrations/0155_v11613_mood_tag_curation/migration.sql @@ -9,21 +9,23 @@ -- -- Operator walked the capture sheet and asked for: -- - deactivate the redundant default tags `happy`, `excited`, --- `hydrated` ("Wasser getrunken") and `music`; +-- `hydrated` ("Wasser getrunken"), `music`, and the +-- `factor_sleep_quality` RATED factor; -- - keep `overtime` active — 0126 had deactivated it as a low-signal -- day-type tag, but the operator wants it back in the work picker; -- - add a new default tag `praise` ("Lob" / "Praise") under the work -- category, Lucide `ThumbsUp` (already in the iOS Lucide→SF table). -- --- factor_sleep_quality is deliberately LEFT untouched: the curation note --- premised its removal on a dedicated sleep-quality slider elsewhere, but --- no such slider exists in the capture surface. It stays one of the three --- surviving RATED factors (work / sleep quality / sadness) seeded by 0126. +-- factor_sleep_quality is deactivated alongside the rest: a manual 1–5 +-- sleep rating is redundant with the measured sleep-tracking surface, so +-- it leaves the seeded factor set — work / sadness stay as the RATED +-- factors from 0126. Historical entries keep resolving (read joins by id, +-- not `is_active`). --- ─── Deactivate the four redundant default tags ────────────────────── +-- ─── Deactivate the redundant default tags + sleep-quality factor ───── UPDATE "mood_tags" SET "is_active" = false -WHERE "key" IN ('happy', 'excited', 'hydrated', 'music'); +WHERE "key" IN ('happy', 'excited', 'hydrated', 'music', 'factor_sleep_quality'); -- ─── Re-activate overtime in the work category ─────────────────────── UPDATE "mood_tags" From 25a7834dec93bd3215ba758b550ffe29bf807c0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 10:33:20 +0200 Subject: [PATCH 05/22] fix(medications): attribute historical intake edits against the era live then The write/edit slot resolver loaded a medication's archived schedule revisions but dropped them before running band attribution, so editing a historical intake snapped it onto the LIVE schedule's slots instead of the era that was valid at the dose's time. The read side (dose-history, compliance) already era-resolves; the write side now matches. `loadAttributeMedication` threads `scheduleRevisions` into the attributor projection. Both `resolveSlotForWriteByBand` and `resolveForcedSlotForWrite` now pick the correct era for any past instant. --- .../resolve-slot-by-band-eras.test.ts | 161 ++++++++++++++++++ src/lib/medications/scheduling/slot-upsert.ts | 6 + 2 files changed, 167 insertions(+) create mode 100644 src/lib/medications/scheduling/__tests__/resolve-slot-by-band-eras.test.ts diff --git a/src/lib/medications/scheduling/__tests__/resolve-slot-by-band-eras.test.ts b/src/lib/medications/scheduling/__tests__/resolve-slot-by-band-eras.test.ts new file mode 100644 index 000000000..3c0756d84 --- /dev/null +++ b/src/lib/medications/scheduling/__tests__/resolve-slot-by-band-eras.test.ts @@ -0,0 +1,161 @@ +/** + * v1.16.13 — schedule-era correctness for the WRITE/EDIT slot resolution seam. + * + * `resolveSlotForWriteByBand` (and `resolveForcedSlotForWrite`) load the + * medication via `loadAttributeMedication` and run the shared band attributor. + * The loader MUST thread the archived `scheduleRevisions` into the attributor + * projection — otherwise a historical intake edit attributes against the LIVE + * schedule instead of the era that was valid at the dose's `takenAt`, silently + * misfiling old doses after a times edit. + * + * The read side (dose-history / compliance) already threads revisions; this + * pins the write side to the same era resolution. + */ +import { describe, expect, it, vi } from "vitest"; + +import { + resolveSlotForWriteByBand, + resolveForcedSlotForWrite, +} from "../slot-upsert"; + +const TZ = "Europe/Berlin"; + +const createdAt = new Date("2026-05-01T08:00:00.000Z"); +// The schedule was replaced on 31 May at 10:00 UTC (12:00 Berlin): +// old era ran 07:00 / 19:00 Berlin; the live schedule runs 09:00 / 21:00. +const replaceAt = new Date("2026-05-31T10:00:00.000Z"); + +const liveSchedule = { + id: "live-1", + windowStart: "09:00", + windowEnd: "21:00", + daysOfWeek: null, + timesOfDay: ["09:00", "21:00"], + reminderGraceMinutes: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, +}; + +const oldEraRevision = { + id: "rev-1", + validFrom: createdAt, + validUntil: replaceAt, + supersededByRevisionId: null, + payload: [ + { + timesOfDay: ["07:00", "19:00"], + windowStart: "07:00", + windowEnd: "19:00", + daysOfWeek: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + label: null, + dose: null, + reminderGraceMinutes: null, + }, + ], +}; + +/** + * Fake Prisma client whose `medication.findFirst` returns a med carrying the + * archived old-era revision exactly as `MEDICATION_SELECT` would, plus the + * live (post-replace) schedules. + */ +function makeClient(withRevisions: boolean) { + return { + medication: { + findFirst: vi.fn(async () => ({ + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt, + schedules: [liveSchedule], + scheduleRevisions: withRevisions ? [oldEraRevision] : [], + })), + }, + medicationIntakeEvent: { + findFirst: vi.fn(async () => null), + findMany: vi.fn(async () => []), + }, + }; +} + +type BandClient = Parameters[0]["client"]; + +describe("resolveSlotForWriteByBand — schedule eras", () => { + it("attributes a historical edit against the era live at the takenAt", async () => { + const client = makeClient(true) as unknown as BandClient; + // Editing a take back to 25 May 07:10 Berlin (05:10 UTC) — old era's + // 07:00 slot, NOT the live 09:00 slot. + const takenAt = new Date("2026-05-25T05:10:00.000Z"); + const out = await resolveSlotForWriteByBand({ + userId: "u1", + medicationId: "med-1", + userTz: TZ, + takenAt, + now: new Date("2026-06-05T00:00:00.000Z"), + client, + }); + expect(out.slotInstant?.toISOString()).toBe("2026-05-25T05:00:00.000Z"); + expect(out.status).toBe("on_time"); + }); + + it("without revisions the same instant does NOT snap to the old 07:00 slot", async () => { + const client = makeClient(false) as unknown as BandClient; + const takenAt = new Date("2026-05-25T05:10:00.000Z"); + const out = await resolveSlotForWriteByBand({ + userId: "u1", + medicationId: "med-1", + userTz: TZ, + takenAt, + now: new Date("2026-06-05T00:00:00.000Z"), + client, + }); + // Live schedule (09:00 / 21:00) would not place a 07:10 take on a 07:00 + // anchor — proves the revision is what fixes the attribution. + expect(out.slotInstant?.toISOString()).not.toBe( + "2026-05-25T05:00:00.000Z", + ); + }); +}); + +describe("resolveForcedSlotForWrite — schedule eras", () => { + it("validates an old-era slot anchor against the era live then", async () => { + const client = makeClient(true) as unknown as BandClient; + // Pin onto the old era's 07:00 Berlin slot on 25 May (05:00 UTC). + const slotInstant = new Date("2026-05-25T05:00:00.000Z"); + const resolved = await resolveForcedSlotForWrite({ + userId: "u1", + medicationId: "med-1", + userTz: TZ, + slotInstant, + now: new Date("2026-06-05T00:00:00.000Z"), + client, + }); + expect(resolved?.toISOString()).toBe("2026-05-25T05:00:00.000Z"); + }); + + it("rejects the old-era anchor when revisions are dropped", async () => { + const client = makeClient(false) as unknown as BandClient; + const slotInstant = new Date("2026-05-25T05:00:00.000Z"); + const resolved = await resolveForcedSlotForWrite({ + userId: "u1", + medicationId: "med-1", + userTz: TZ, + slotInstant, + now: new Date("2026-06-05T00:00:00.000Z"), + client, + }); + // The live schedule mints no 07:00 anchor, so the pin can't validate. + expect(resolved).toBeNull(); + }); +}); diff --git a/src/lib/medications/scheduling/slot-upsert.ts b/src/lib/medications/scheduling/slot-upsert.ts index f50e55ac0..c11c215b3 100644 --- a/src/lib/medications/scheduling/slot-upsert.ts +++ b/src/lib/medications/scheduling/slot-upsert.ts @@ -495,6 +495,12 @@ async function loadAttributeMedication(input: { oneShot: medication.oneShot, createdAt: medication.createdAt, schedules: medication.schedules as WorkerScheduleRow[], + // v1.16.13 — thread the archived eras into the attributor projection. + // `MEDICATION_SELECT` fetches them, but dropping them here made the + // write/edit path attribute a historical intake against the LIVE + // schedule, not the era valid at the dose's `takenAt` — the read side + // (dose-history, compliance) already era-resolves. Now both match. + scheduleRevisions: medication.scheduleRevisions, }, lastIntakeAt, }; From c21338df45d6379aeffe78041fa875bcbbd72adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 10:35:03 +0200 Subject: [PATCH 06/22] fix(medications): decrement stock on CSV intake import The CSV intake import recorded taken doses but never consumed inventory, the only intake seam that skipped stock consumption, so runway and days-left overstated for any imported history. Import now calls `consumeForIntake` per freshly created event, matching the web-take, status-toggle and bulk paths: the consumption stamp keeps it exactly-once, the existing duplicate-key skip means a re-import never re-creates the row, and the hook no-ops for as-needed / no-inventory medications. --- .../intake/import/__tests__/route.test.ts | 62 +++++++++++++++++++ .../medications/[id]/intake/import/route.ts | 16 ++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts index b8199b8ab..780a4aa49 100644 --- a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts @@ -24,6 +24,12 @@ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMedications: vi.fn(), +})); +vi.mock("@/lib/medications/inventory/consumption", () => ({ + consumeForIntake: vi.fn().mockResolvedValue(undefined), +})); vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined), dayKeyForScheduledFor: vi.fn().mockReturnValue("2026-01-01"), @@ -44,6 +50,7 @@ vi.mock("next/headers", () => ({ import { POST } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { consumeForIntake } from "@/lib/medications/inventory/consumption"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -154,3 +161,58 @@ describe("POST /api/medications/[id]/intake/import — 422 multi-issue (v1.4.43 expect(res.status).toBe(422); }); }); + +describe("POST /api/medications/[id]/intake/import — inventory consumption", () => { + beforeEach(() => { + // Fresh imports: no pre-existing dedup row, and create echoes an id. + vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValue( + null as never, + ); + let n = 0; + vi.mocked(prisma.medicationIntakeEvent.create).mockImplementation( + (async () => ({ id: `evt-${++n}`, takenAt: new Date() })) as never, + ); + }); + + it("consumes stock once per freshly imported taken dose", async () => { + const res = await POST( + postReq([ + { datum: "2026-01-01", uhrzeit: "07:00:00" }, + { datum: "2026-01-01", uhrzeit: "19:00:00" }, + { datum: "2026-01-02", uhrzeit: "07:00:00" }, + ]), + ROUTE_CTX, + ); + expect(res.status).toBe(201); + const body = (await res.json()) as { data: { imported: number } }; + expect(body.data.imported).toBe(3); + expect(consumeForIntake).toHaveBeenCalledTimes(3); + const call = vi.mocked(consumeForIntake).mock.calls[0]?.[0]; + expect(call?.medicationId).toBe("m1"); + expect(call?.userId).toBe("user-1"); + expect(call?.eventId).toBe("evt-1"); + }); + + it("does not consume for duplicate (already-imported) rows", async () => { + // First entry is a duplicate (existing dedup row), second is fresh. + vi.mocked(prisma.medicationIntakeEvent.findUnique) + .mockResolvedValueOnce({ id: "existing" } as never) + .mockResolvedValueOnce(null as never); + const res = await POST( + postReq([ + { datum: "2026-01-01", uhrzeit: "07:00:00" }, + { datum: "2026-01-01", uhrzeit: "19:00:00" }, + ]), + ROUTE_CTX, + ); + expect(res.status).toBe(201); + const body = (await res.json()) as { + data: { imported: number; skippedDuplicates: number }; + }; + expect(body.data.imported).toBe(1); + expect(body.data.skippedDuplicates).toBe(1); + // Only the fresh row consumes — the duplicate never re-creates an event, + // so re-import cannot double-decrement. + expect(consumeForIntake).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/api/medications/[id]/intake/import/route.ts b/src/app/api/medications/[id]/intake/import/route.ts index 96e618490..be0ab8373 100644 --- a/src/app/api/medications/[id]/intake/import/route.ts +++ b/src/app/api/medications/[id]/intake/import/route.ts @@ -10,6 +10,7 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; +import { consumeForIntake } from "@/lib/medications/inventory/consumption"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { recomputeMedicationComplianceForDay, @@ -121,7 +122,7 @@ export const POST = apiHandler( continue; } - await prisma.medicationIntakeEvent.create({ + const created = await prisma.medicationIntakeEvent.create({ data: { userId: user.id, medicationId: id, @@ -133,6 +134,19 @@ export const POST = apiHandler( }, }); imported++; + // v1.16.13 — CSV import was the one intake seam that recorded a taken + // dose without decrementing tracked stock, so runway / days-left + // overstated. Consume exactly like the other taken paths: the stamp on + // the freshly-created event makes it exactly-once, the duplicate skip + // above means a re-import never re-creates the row (no double-decrement), + // and `consumeForIntake` no-ops for as-needed / no-inventory medications. + await consumeForIntake({ + client: prisma, + userId: user.id, + medicationId: id, + eventId: created.id, + intakeAt: created.takenAt ?? takenAt, + }); touchedDays.add(dayKeyForScheduledFor(takenAt, user.timezone)); } From 6a4d3fd55356f6bfe7e241c6cee11a0a39a64c5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 10:36:53 +0200 Subject: [PATCH 07/22] fix(whoop): widen workout ingest overlap to match recovery and sleep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WHOOP workout sync used the default 1 h incremental overlap while recovery and sleep use the wider re-score overlap. The activity collection filters on the workout's own time range, not on when it reached the cloud, so a workout whose phone sync lands more than an hour after its window fell permanently before the cursor and was never ingested — operator-visible data loss. The workout sync now reuses the recovery/sleep overlap; the externalId upsert keeps the re-fetch idempotent. --- .../__tests__/sync-workout-overlap.test.ts | 78 +++++++++++++++++++ src/lib/whoop/sync-workout.ts | 10 +++ 2 files changed, 88 insertions(+) create mode 100644 src/lib/whoop/__tests__/sync-workout-overlap.test.ts diff --git a/src/lib/whoop/__tests__/sync-workout-overlap.test.ts b/src/lib/whoop/__tests__/sync-workout-overlap.test.ts new file mode 100644 index 000000000..3462568e7 --- /dev/null +++ b/src/lib/whoop/__tests__/sync-workout-overlap.test.ts @@ -0,0 +1,78 @@ +/** + * v1.16.13 (iOS #17) — WHOOP workout ingest used only a 1 h overlap window + * while recovery/sleep use the wide re-score overlap. A workout whose phone + * sync to the WHOOP cloud lands more than 1 h after the workout's own time + * window then falls permanently before the incremental `start` and is never + * ingested. This pins the workout sync onto the SAME overlap recovery/sleep + * use, so a late-synced workout is still re-fetched (the upsert is idempotent). + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const fetchWorkouts = vi.fn(); + +vi.mock("../client", () => ({ + fetchWorkouts: (...a: unknown[]) => fetchWorkouts(...a), + KJ_TO_KCAL: 0.239006, +})); + +const getValidToken = vi.fn(); +const markSynced = vi.fn(); +vi.mock("../sync", async () => { + const actual = await vi.importActual("../sync"); + return { + ...actual, + getValidToken: (...a: unknown[]) => getValidToken(...a), + markSynced: (...a: unknown[]) => markSynced(...a), + }; +}); + +const whoopConnectionFindUnique = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { + whoopConnection: { + findUnique: (...a: unknown[]) => whoopConnectionFindUnique(...a), + }, + workout: { upsert: vi.fn() }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addWarning: vi.fn() }), +})); + +import { syncUserWorkout } from "../sync-workout"; +import { WHOOP_RECOVERY_SLEEP_OVERLAP_MS } from "../sync"; + +const LAST_SYNCED = new Date("2026-06-10T12:00:00.000Z"); + +beforeEach(() => { + vi.clearAllMocks(); + getValidToken.mockResolvedValue({ + accessToken: "tok", + connection: { id: "c1", whoopUserId: "w1" }, + }); + whoopConnectionFindUnique.mockResolvedValue({ lastSyncedAt: LAST_SYNCED }); + markSynced.mockResolvedValue(undefined); + fetchWorkouts.mockResolvedValue([]); +}); + +describe("syncUserWorkout — late-synced workout overlap window", () => { + it("fetches from lastSyncedAt minus the recovery/sleep overlap, not 1 h", async () => { + await syncUserWorkout("user-1"); + expect(fetchWorkouts).toHaveBeenCalledTimes(1); + const arg = fetchWorkouts.mock.calls[0]![1] as { start?: Date }; + expect(arg.start?.getTime()).toBe( + LAST_SYNCED.getTime() - WHOOP_RECOVERY_SLEEP_OVERLAP_MS, + ); + // A workout that landed in the WHOOP cloud 6 h after its window is still + // inside the fetch range; under the old 1 h overlap it would be missed. + const sixHoursBefore = LAST_SYNCED.getTime() - 6 * 60 * 60 * 1000; + expect(arg.start!.getTime()).toBeLessThanOrEqual(sixHoursBefore); + }); + + it("fullSync passes no start (deep backfill anchor handles history)", async () => { + await syncUserWorkout("user-1", { fullSync: true }); + const arg = fetchWorkouts.mock.calls[0]![1] as { start?: Date }; + expect(arg.start).toBeUndefined(); + }); +}); diff --git a/src/lib/whoop/sync-workout.ts b/src/lib/whoop/sync-workout.ts index c960b2124..8e46f8fe9 100644 --- a/src/lib/whoop/sync-workout.ts +++ b/src/lib/whoop/sync-workout.ts @@ -19,6 +19,7 @@ import { incrementalStart, handleCollectionFetchError, markSynced, + WHOOP_RECOVERY_SLEEP_OVERLAP_MS, } from "./sync"; import { prisma } from "@/lib/db"; import { getEvent } from "@/lib/logging/context"; @@ -44,8 +45,17 @@ export async function syncUserWorkout( }); if (!connection) return 0; + // iOS #17 — the WHOOP `/activity/workout` collection filters on the + // workout's OWN time range, not on when it reached the cloud. A workout + // whose phone sync lands more than the overlap late then falls permanently + // before the cursor and is never ingested (operator-visible data loss). A + // 1 h overlap was far too tight for opportunistic phone sync; widen it to + // the same window recovery/sleep already use for their late re-scores. The + // `(userId, WHOOP, externalId)` upsert keeps the re-fetch idempotent and a + // handful of workouts/day keeps the page count down. const start = incrementalStart(connection.lastSyncedAt, { fullSync: opts.fullSync, + overlapMs: WHOOP_RECOVERY_SLEEP_OVERLAP_MS, }); let records: Awaited>; From b59f8118a959c9c0f81d7a72b5a4eb886d095bff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 10:40:08 +0200 Subject: [PATCH 08/22] fix(sleep): round night minute totals to whole numbers `GET /api/sleep/night` summed second-precision stage rows into asleep / awake totals and serialised them unrounded (e.g. 433.4999), which also violated the OpenAPI integer contract. Round the present minute values; `inBedMinutes` / `awakeMinutes` stay null when the night never saw that stage. --- .../api/sleep/night/__tests__/route.test.ts | 18 ++++++++++++++++++ src/app/api/sleep/night/route.ts | 10 +++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/app/api/sleep/night/__tests__/route.test.ts b/src/app/api/sleep/night/__tests__/route.test.ts index 509e7854a..aef6583ef 100644 --- a/src/app/api/sleep/night/__tests__/route.test.ts +++ b/src/app/api/sleep/night/__tests__/route.test.ts @@ -103,6 +103,24 @@ describe("GET /api/sleep/night", () => { expect(core.end).toBe("2026-06-04T01:00:00.000Z"); }); + it("rounds asleep / awake minutes to whole numbers", async () => { + // iOS #18 — `asleepMinutesOf` / awake totals sum raw second-precision + // minute values, so a night sums to e.g. 433.4999; the serializer must + // emit whole minutes. CORE+DEEP = 433.4999 → 433; AWAKE 12.6 → 13. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + stage("2026-06-04T03:00:00.000Z", "CORE", 300.25), + stage("2026-06-04T05:00:00.000Z", "DEEP", 133.2499), + stage("2026-06-04T05:10:00.000Z", "AWAKE", 12.6), + ] as never); + const res = await GET(req("date=2026-06-04")); + const body = await res.json(); + expect(body.data.main.asleepMinutes).toBe(433); + expect(body.data.main.awakeMinutes).toBe(13); + expect(Number.isInteger(body.data.main.asleepMinutes)).toBe(true); + expect(Number.isInteger(body.data.main.awakeMinutes)).toBe(true); + }); + it("defaults to the most recent scorable night when date omitted", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ diff --git a/src/app/api/sleep/night/route.ts b/src/app/api/sleep/night/route.ts index 0bcdee635..8b308436e 100644 --- a/src/app/api/sleep/night/route.ts +++ b/src/app/api/sleep/night/route.ts @@ -56,9 +56,13 @@ function serializeSession(s: SleepSession) { source: s.source, start: s.start.toISOString(), end: s.end.toISOString(), - asleepMinutes: s.asleepMinutes, - inBedMinutes: s.inBedMinutes, - awakeMinutes: s.awakeMinutes, + // iOS #18 — round to whole minutes; the underlying totals are summed from + // second-precision segments and otherwise serialise as e.g. 433.4999. + // `inBedMinutes` / `awakeMinutes` stay null when the night never saw that + // stage — rounding only the present values. + asleepMinutes: Math.round(s.asleepMinutes), + inBedMinutes: s.inBedMinutes === null ? null : Math.round(s.inBedMinutes), + awakeMinutes: s.awakeMinutes === null ? null : Math.round(s.awakeMinutes), awakenings: s.awakenings, stages: s.stages, segments: s.segments.map((seg) => ({ From b4c6c2cc88812ea2d0dcde786841a3011ecc6b26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 10:44:45 +0200 Subject: [PATCH 09/22] feat(pwa): register the service worker app-wide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now `/sw.js` registered only behind the Web-Push opt-in, so any user who never enabled notifications ran with no service worker — the offline shell cache, the precached app shell, and the network-first page cache were all inert. Add a production-gated `ServiceWorkerRegistrar` that installs the worker for everyone, deferred to the `load` event wrapped in `requestIdleCallback` so install/precache stays off the first-paint critical path. Mount it beside `VersionPoller` in the root providers. The registrar only installs. `VersionPoller` remains the sole owner of the update→reload path (poll `/api/version`, then unregister + cache-wipe + reload on a server-ahead deploy); no `updatefound`/`controllerchange` handling lives here so the two cannot double-fire. `updateViaCache: "none"` forces a network byte-check of `sw.js` on every update check so a cached worker can never mask a new deploy. --- .../service-worker-registrar.test.ts | 31 +++++++ src/components/providers.tsx | 2 + src/components/service-worker-registrar.tsx | 81 +++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 src/components/__tests__/service-worker-registrar.test.ts create mode 100644 src/components/service-worker-registrar.tsx diff --git a/src/components/__tests__/service-worker-registrar.test.ts b/src/components/__tests__/service-worker-registrar.test.ts new file mode 100644 index 000000000..d4ece3ff7 --- /dev/null +++ b/src/components/__tests__/service-worker-registrar.test.ts @@ -0,0 +1,31 @@ +/** + * Gating predicate for app-wide service-worker registration. + * + * The registrar must install `/sw.js` only in production and only where the + * browser supports the API. Dev lacks the generated `/sw-version.js` (the + * `prebuild` step writes it) and a caching SW fights HMR, so a dev register + * would be actively harmful. + */ +import { describe, expect, it } from "vitest"; + +import { shouldRegisterServiceWorker } from "../service-worker-registrar"; + +describe("shouldRegisterServiceWorker", () => { + it("registers in production with window + serviceWorker support", () => { + expect(shouldRegisterServiceWorker("production", true, true)).toBe(true); + }); + + it("does not register outside production", () => { + expect(shouldRegisterServiceWorker("development", true, true)).toBe(false); + expect(shouldRegisterServiceWorker("test", true, true)).toBe(false); + expect(shouldRegisterServiceWorker(undefined, true, true)).toBe(false); + }); + + it("does not register without a window (SSR)", () => { + expect(shouldRegisterServiceWorker("production", false, true)).toBe(false); + }); + + it("does not register when serviceWorker is unsupported", () => { + expect(shouldRegisterServiceWorker("production", true, false)).toBe(false); + }); +}); diff --git a/src/components/providers.tsx b/src/components/providers.tsx index 69340dec8..dfba33019 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -19,6 +19,7 @@ import type { Locale } from "@/lib/i18n/config"; import { Toaster } from "@/components/ui/sonner"; import { AppSettingsProvider } from "@/components/app-settings-provider"; import { VersionPoller } from "@/components/version-poller"; +import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar"; import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; import { prefetchMedicationsList } from "@/lib/queries/prefetch-medications"; @@ -184,6 +185,7 @@ export function Providers({ {children} + diff --git a/src/components/service-worker-registrar.tsx b/src/components/service-worker-registrar.tsx new file mode 100644 index 000000000..fb33f5fea --- /dev/null +++ b/src/components/service-worker-registrar.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useEffect } from "react"; + +/** + * Pure predicate for whether app-wide SW registration should proceed. + * Extracted so the production + capability gating is unit-testable without + * a DOM/effect harness. The registrar's `useEffect` calls this before it + * touches `navigator.serviceWorker`. + */ +export function shouldRegisterServiceWorker( + nodeEnv: string | undefined, + hasWindow: boolean, + hasServiceWorker: boolean, +): boolean { + if (nodeEnv !== "production") return false; + if (!hasWindow) return false; + if (!hasServiceWorker) return false; + return true; +} + +/** + * App-wide service-worker registration. + * + * Until this component shipped, `/sw.js` was registered exactly once — + * inside the Web-Push opt-in (`web-push-card.tsx`). Every user who never + * enabled notifications ran with no SW at all, so the offline shell cache, + * the precached app shell, and the network-first page cache were inert. This + * mounts the registration for everyone, gated on production. + * + * Lifecycle owner split: this component only INSTALLS the worker. The + * update→reload path stays solely with `` (polls + * `/api/version`, then unregister + cache-wipe + reload on a server-ahead + * deploy). No `updatefound` / `controllerchange` handling lives here — a + * second reload trigger would double-fire against the poller and risk a loop. + */ +export function ServiceWorkerRegistrar(): null { + useEffect(() => { + // Production only: dev lacks the generated `/sw-version.js` (written by + // the `prebuild` step), and a caching SW fights HMR. `process.env. + // NODE_ENV` is inlined into the client bundle at build time. + if ( + !shouldRegisterServiceWorker( + process.env.NODE_ENV, + typeof window !== "undefined", + typeof navigator !== "undefined" && "serviceWorker" in navigator, + ) + ) { + return; + } + + const register = () => { + const idle = + (window as unknown as { requestIdleCallback?: typeof setTimeout }) + .requestIdleCallback ?? ((cb: () => void) => setTimeout(cb, 1)); + idle(() => { + navigator.serviceWorker + // `updateViaCache: "none"` forces the browser to byte-check + // `/sw.js` (and its `importScripts('/sw-version.js')`) against the + // network on every update check rather than honouring the HTTP + // cache — without it a cached `sw.js` can mask a new deploy and the + // worker never sees the new `CACHE_VERSION`. + .register("/sw.js", { scope: "/", updateViaCache: "none" }) + .catch(() => { + /* best effort — registration failure must never break the app */ + }); + }); + }; + + // Defer to `load` (+ idle) so install/precache never contends with the + // first-paint critical path. If the window already fired `load`, run now. + if (document.readyState === "complete") { + register(); + return; + } + window.addEventListener("load", register, { once: true }); + return () => window.removeEventListener("load", register); + }, []); + + return null; +} From 2a180fec954cc3258830e4287e6f49abb8bb0493 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 10:45:39 +0200 Subject: [PATCH 10/22] fix(pwa): keep no-store and clinician-share pages out of the page cache With the service worker now registered for every user, the network-first page cache sees every authenticated navigation. The everyday app routes are client-fetch-only shells with no server-rendered PII, but the `/c/[token]` clinician-share view renders health values straight into the HTML and emits `no-store`. The page cache ignored the header and stored it, so a revoked or expired share could still render back offline. Gate `cache.put` in `networkFirst` on a new `isCacheableNavigation` check: skip any response carrying `Cache-Control: no-store` (the principled rule for any present or future server RSC that renders user data) and skip the `/c/*` path outright as belt-and-braces. Ordinary navigations still cache unchanged. --- public/sw.js | 28 +++++++++++++++++++- src/__tests__/service-worker.test.ts | 38 ++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/public/sw.js b/public/sw.js index a726a174c..c85334d78 100644 --- a/public/sw.js +++ b/public/sw.js @@ -118,6 +118,32 @@ async function cacheFirst(request, cacheName) { return response; } +/** + * Privacy gate for the navigation page cache. Most app routes are + * client-fetch-only shells with no server-rendered PII (health JSON loads + * over `/api/*`, which is network-only here), so caching their HTML is + * safe. Two carve-outs: + * + * 1. A `Cache-Control: no-store` response opts itself out — the + * principled, future-proof rule for any current/future server RSC + * that renders user data and sets the header. + * 2. `/c/*` (the clinician-share view) renders health values + wellness + * scores straight into the HTML and emits `no-store`; the explicit + * path skip is belt-and-braces so a revoked share can never linger in + * CacheStorage and render back offline. + */ +function isCacheableNavigation(request, response) { + const cacheControl = response.headers.get("Cache-Control") || ""; + if (/no-store/i.test(cacheControl)) return false; + try { + const { pathname } = new URL(request.url); + if (pathname === "/c" || pathname.startsWith("/c/")) return false; + } catch { + // Unparseable URL — fall through; the no-store check already ran. + } + return true; +} + /** * Network-first: try network (preferring the navigation-preload response * when the browser already started it), fall back to cache. @@ -134,7 +160,7 @@ async function networkFirst(event, cacheName) { const response = (event.preloadResponse ? await event.preloadResponse : null) || (await fetch(request)); - if (response.ok) { + if (response.ok && isCacheableNavigation(request, response)) { const cache = await caches.open(cacheName); cache.put(request, response.clone()); trimCache(cacheName, MAX_PAGE_ENTRIES); diff --git a/src/__tests__/service-worker.test.ts b/src/__tests__/service-worker.test.ts index 66ba97460..8601c0a41 100644 --- a/src/__tests__/service-worker.test.ts +++ b/src/__tests__/service-worker.test.ts @@ -238,6 +238,44 @@ describe("sw.js — networkFirst offline fallback", () => { }); }); +describe("sw.js — networkFirst privacy gate", () => { + it("does not cache a navigation response that carries Cache-Control: no-store", async () => { + const harness = bootServiceWorker(); + harness.context.fetch = async () => + new Response("private shell", { + headers: { "Cache-Control": "no-store" }, + }); + + const res = await dispatchNavigationFetch(harness, "/"); + expect(await res.clone().text()).toBe("private shell"); + + const pages = await harness.cacheStorage.open(CURRENT_PAGE_CACHE); + expect(await pages.match(`${ORIGIN}/`)).toBeUndefined(); + }); + + it("does not cache the /c/ clinician-share view even without no-store", async () => { + const harness = bootServiceWorker(); + harness.context.fetch = async () => new Response("share shell"); + + const res = await dispatchNavigationFetch(harness, "/c/hls_abc123"); + expect(await res.clone().text()).toBe("share shell"); + + const pages = await harness.cacheStorage.open(CURRENT_PAGE_CACHE); + expect(await pages.match(`${ORIGIN}/c/hls_abc123`)).toBeUndefined(); + }); + + it("still caches an ordinary navigation response", async () => { + const harness = bootServiceWorker(); + harness.context.fetch = async () => new Response("app shell"); + + const res = await dispatchNavigationFetch(harness, "/measurements"); + expect(await res.clone().text()).toBe("app shell"); + + const pages = await harness.cacheStorage.open(CURRENT_PAGE_CACHE); + expect(await pages.match(`${ORIGIN}/measurements`)).toBeDefined(); + }); +}); + describe("sw.js — trimCache", () => { it("reads the key list once and deletes only the oldest excess entries", async () => { const harness = bootServiceWorker(); From 0c4b6ab8bacd293616a42503cb3e0d744e38e551 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 10:46:01 +0200 Subject: [PATCH 11/22] fix(pwa): wipe the page cache on logout Now that the service worker is registered app-wide, cached navigation HTML survives a logout. The everyday routes carry no server-rendered PII, but on a shared device a clean session boundary should leave nothing in CacheStorage. On a successful logout, best-effort delete the `healthlog-pages-*` caches. The static cache (hashed chunks, icons) is left intact so the next login does not re-download the shell. The wipe never blocks the redirect to the login page. --- src/hooks/use-auth.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 6006c06a5..246a805fa 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -156,6 +156,19 @@ export function useLogout() { onSuccess: () => { queryClient.setQueryData(queryKeys.authMe(), null); queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); + // Defense-in-depth at the session boundary: wipe the service-worker + // page cache so no cached navigation HTML survives a logout on a + // shared device. Scoped to `healthlog-pages-*` only — the static + // cache (hashed chunks, icons) carries no PII and dropping it would + // force a needless re-download on the next login. Best-effort; never + // blocks the redirect. + if (typeof caches !== "undefined") { + void caches.keys().then((keys) => { + for (const key of keys) { + if (key.startsWith("healthlog-pages-")) void caches.delete(key); + } + }); + } router.push("/auth/login"); }, // v1.16.4 — a network-failed logout used to do nothing at all (the From 083410473c00d73369c1c8147f833a2cb9e6381f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 10:46:23 +0200 Subject: [PATCH 12/22] refactor(pwa): defer push subscription to the app-wide registration The Web-Push opt-in registered its own `/sw.js` before subscribing. With the app-wide registrar now installing the worker for every user, that second `register` call is a redundant source of truth at the same scope. Replace it with `navigator.serviceWorker.ready`, which resolves with the active registration once the global worker is controlling the page. --- src/components/settings/web-push-card.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/settings/web-push-card.tsx b/src/components/settings/web-push-card.tsx index 57765ed8f..f3204b4dc 100644 --- a/src/components/settings/web-push-card.tsx +++ b/src/components/settings/web-push-card.tsx @@ -81,8 +81,12 @@ export function WebPushCard() { const vapidData = await vapidRes.json(); const vapidPublicKey = vapidData.data.publicKey; - const registration = await navigator.serviceWorker.register("/sw.js"); - await navigator.serviceWorker.ready; + // Defer to the app-wide registration (`` in + // the root providers) rather than re-registering here — same scope, + // so a second `register("/sw.js")` would only return the existing + // worker. `ready` resolves with the active registration once it is + // controlling the page. + const registration = await navigator.serviceWorker.ready; const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, From 60f587c5a7e74fda1492cdca346fccf9334528b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 10:47:50 +0200 Subject: [PATCH 13/22] fix(pwa): re-anchor the sw.js fallback version on every build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `public/sw.js` carries a `|| "..."` fallback literal used for `CACHE_VERSION` when `importScripts('/sw-version.js')` is unavailable. The literal had drifted to `v1.4.43`, four-plus minors behind `package.json`; in any environment where the generated file is missing this keyed every cache to the stale version and turned the activate-step eviction into a no-op — the exact bug the generator was written to kill. Extend the prebuild generator to rewrite the fallback literal in `sw.js` to the current `package.json` version on every build, anchored on a `/* @sw-version-fallback */` marker (the build fails loudly if the marker is gone). The literal and the generated `sw-version.js` can no longer diverge. The SW test now reads the fallback straight from the source so it tracks the literal instead of hard-coding it. --- public/sw.js | 10 +++++++++- scripts/generate-sw-version.mjs | 21 ++++++++++++++++++++- src/__tests__/service-worker.test.ts | 14 ++++++++++---- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/public/sw.js b/public/sw.js index c85334d78..2e4cbf415 100644 --- a/public/sw.js +++ b/public/sw.js @@ -27,8 +27,16 @@ try { } catch { // Fall through to the literal fallback below. } +// The `|| "..."` fallback literal is the value used only when the +// `importScripts('/sw-version.js')` above is absent (dev with no `prebuild` +// step, or a 404 on the generated file). `scripts/generate-sw-version.mjs` +// rewrites it to the current `package.json` version on every prebuild — the +// marker below is the rewrite anchor — so in any shipped image the literal +// matches the active release and can never drift stale the way it did across +// 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__) || "v1.4.43"; + (typeof self !== "undefined" && self.__APP_VERSION__) || + /* @sw-version-fallback */ "v1.16.12"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; const MAX_STATIC_ENTRIES = 150; diff --git a/scripts/generate-sw-version.mjs b/scripts/generate-sw-version.mjs index 5157f051b..b206edad8 100644 --- a/scripts/generate-sw-version.mjs +++ b/scripts/generate-sw-version.mjs @@ -23,6 +23,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(__dirname, ".."); const pkgPath = join(repoRoot, "package.json"); const outPath = join(repoRoot, "public", "sw-version.js"); +const swPath = join(repoRoot, "public", "sw.js"); const pkg = JSON.parse(readFileSync(pkgPath, "utf8")); const version = `v${pkg.version}`; @@ -33,5 +34,23 @@ const body = `self.__APP_VERSION__ = ${JSON.stringify(version)};\n`; writeFileSync(outPath, banner + body, "utf8"); +// Also re-anchor the in-`sw.js` fallback literal to the current version, so +// the literal used when `/sw-version.js` is missing (dev, or a 404 on the +// generated file) can never drift stale — it bit v1.4.38.4 → v1.4.42. The +// `/* @sw-version-fallback */` marker is the rewrite anchor; the literal that +// follows it is replaced in place. If the marker is gone the SW source has +// been restructured and the build must surface that rather than silently skip +// the re-anchor. +const sw = readFileSync(swPath, "utf8"); +const fallbackPattern = /(\/\* @sw-version-fallback \*\/\s*)"v[^"]*"/; +if (!fallbackPattern.test(sw)) { + process.stderr.write( + "generate-sw-version: @sw-version-fallback marker not found in public/sw.js\n", + ); + process.exit(1); +} +const rewritten = sw.replace(fallbackPattern, `$1${JSON.stringify(version)}`); +if (rewritten !== sw) writeFileSync(swPath, rewritten, "utf8"); + // One-line log so the build output shows the version we baked in. -process.stdout.write(`sw-version.js → ${version}\n`); +process.stdout.write(`sw-version.js → ${version} (sw.js fallback re-anchored)\n`); diff --git a/src/__tests__/service-worker.test.ts b/src/__tests__/service-worker.test.ts index 8601c0a41..cde471c89 100644 --- a/src/__tests__/service-worker.test.ts +++ b/src/__tests__/service-worker.test.ts @@ -24,10 +24,16 @@ const SW_SOURCE = readFileSync( ); const ORIGIN = "https://app.example"; -// `importScripts` throws in the sandbox, so the literal fallback version -// is active and the cache names are deterministic. -const CURRENT_PAGE_CACHE = "healthlog-pages-v1.4.43"; -const CURRENT_STATIC_CACHE = "healthlog-static-v1.4.43"; +// `importScripts` throws in the sandbox, so the in-`sw.js` fallback literal +// is active and the cache names are deterministic. The fallback is +// re-anchored to `package.json` on every prebuild by +// `scripts/generate-sw-version.mjs`; read it straight from the SW source so +// this test never drifts from the literal it is exercising. +const FALLBACK_VERSION = /\/\* @sw-version-fallback \*\/\s*"(v[^"]*)"/.exec( + SW_SOURCE, +)![1]; +const CURRENT_PAGE_CACHE = `healthlog-pages-${FALLBACK_VERSION}`; +const CURRENT_STATIC_CACHE = `healthlog-static-${FALLBACK_VERSION}`; class FakeCache { map = new Map(); From 3ad0649ff6da06b4c9c0372282334bfb4d53e0b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 10:49:00 +0200 Subject: [PATCH 14/22] feat(pwa): add manifest shortcuts and a 192 maskable icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three home-screen shortcuts (Add measurement, Today, Medications) deep-linking into the app, each tagged `source=shortcut`. Add a 192×192 maskable icon alongside the existing 512 so adaptive launchers mask the right size instead of downscaling the 512 or clipping the un-masked 192 "any" icon on circular masks. No screenshots: the repo ships no real screenshot assets and fabricating them is worse than the richer-install-dialog gain. --- public/logo-maskable-192.png | Bin 0 -> 9022 bytes public/manifest.json | 26 ++++++++++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 public/logo-maskable-192.png diff --git a/public/logo-maskable-192.png b/public/logo-maskable-192.png new file mode 100644 index 0000000000000000000000000000000000000000..3aa46e86f50947a3987cd955d33ad086ac62f6b1 GIT binary patch literal 9022 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 OimarPM75Yn(EkBQzwG${ literal 0 HcmV?d00001 diff --git a/public/manifest.json b/public/manifest.json index d351c6fbb..0b6c6de85 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -22,11 +22,37 @@ "type": "image/png", "purpose": "any" }, + { + "src": "/logo-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, { "src": "/logo-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ], + "shortcuts": [ + { + "name": "Add measurement", + "short_name": "Add", + "url": "/measurements?add=WEIGHT&source=shortcut", + "icons": [{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }] + }, + { + "name": "Today", + "short_name": "Today", + "url": "/?source=shortcut", + "icons": [{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }] + }, + { + "name": "Medications", + "short_name": "Meds", + "url": "/medications?source=shortcut", + "icons": [{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }] + } ] } From 3ad1a8ab25021852b1f87cad9e0e2f4880867c58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 10:52:28 +0200 Subject: [PATCH 15/22] fix(insights): right-align the hero Coach action on mobile The overview hero's inline "Ask the Coach" button sat in a flex-wrap row with no justify-end, so it rendered left under the hero copy. Pin it to the right edge of the hero's left column. Co-Authored-By: Claude Opus 4.8 --- src/components/insights/hero-strip.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx index e412f2fc1..88fd6221c 100644 --- a/src/components/insights/hero-strip.tsx +++ b/src/components/insights/hero-strip.tsx @@ -250,7 +250,7 @@ export function HeroStrip({ * disappears rather than collapsing to an empty flex shell. */} {coachEnabled && ( -
+
{/* B2b wires this into the Coach drawer. The button is enabled whenever the parent supplies an `onAskCoach` handler; older parents that haven't adopted B2b yet From 4111124f24cfa14e85415b4c75c4ffda0281b3d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 10:52:33 +0200 Subject: [PATCH 16/22] fix(nav): keep long More-menu tile labels inside their tile The More hub grid tile label was a flex child without min-w-0, so long compound labels (e.g. "Benachrichtigungen") painted past the tile's rounded border instead of wrapping. Give the span min-w-0 and tightened leading so it wraps cleanly within the tile. Co-Authored-By: Claude Opus 4.8 --- src/components/layout/bottom-nav.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index 8f7c0a32b..1cc4b47d7 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -255,7 +255,9 @@ export function BottomNav() { )} >