diff --git a/CHANGELOG.md b/CHANGELOG.md index dad5e302c..69c0c542e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ ## [Unreleased] +## [1.16.13] — 2026-06-14 — installable and offline, doses that find the right day, and headers that hold their line + +### Added + +- **The app installs and works offline.** The service worker now registers for every visitor, not only after notifications are switched on, so the app installs to the home screen, its shell and assets are cached, and a lost connection shows an offline page instead of a dead tab. Authenticated pages are never cached — their data always loads live — the share-by-link page is kept out of the cache entirely, and signing out clears the page cache. The running app still heals itself across deploys, and the cached-version marker can no longer drift stale between releases. + +### Changed + +- **The default mood tags are curated.** The redundant `happy`, `excited`, water and music defaults step aside, `overtime` returns to the work picker, a new `praise` ("Lob") tag joins it, and the manual sleep-quality rating retires — a measured night already covers it. Tags already recorded against any of these keep resolving their labels; nothing on existing entries changes. +- **Page headers hold one line on a phone.** The medications header no longer drops its actions to a second row, the insights "Ask the coach" action sits to the right where it belongs, and a long label in the more-menu stays inside its tile. The mood, measurements and dashboard headers get the same treatment so none of them can wrap either. + +### Fixed + +- **A historical dose edit credits the schedule that was live then.** Editing a past intake on a medication whose schedule has since changed now attributes it to the dosing era valid at the dose's own time rather than today's schedule — the same era the history and compliance already read it under. +- **A CSV intake import moves the tracked stock.** It was the one path that recorded taken doses without decrementing the supply, so the runway read high; it now consumes exactly like every other path, once per dose, and a re-import never drains the shelf twice. +- **A workout that reaches WHOOP late is no longer lost.** Workout ingest used a one-hour overlap window while recovery and sleep used a far wider one, so a phone that synced to the WHOOP cloud more than an hour after a session left the workout permanently outside the window. It now shares the wider window; the idempotent upsert keeps the widening from duplicating anything. +- **Sleep night totals read as whole minutes** instead of a long fraction, matching the contract the clients decode. +- **A tiles-only layout save no longer wipes section customization.** Reordering the navigation tiles without sending the section layout keeps the stored sections instead of resetting them to defaults, and the same holds the other way around. +- **The web AI surfaces honour consent again.** The web client now records the consent receipt the server requires before any model call, so insights and the coach stop falling back to the empty no-provider state for people who have granted it, and the status reads "consent missing" honestly when it is genuinely absent. +- **The coach button in the public demo no longer errors** — it steps aside there instead of offering a send the demo blocks. + ## [1.16.12] — 2026-06-13 — split doses, steady cards, and a read that stays warm ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 99531be46..f1c31b748 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.16.12 + version: 1.16.13 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -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/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/package.json b/package.json index d0be78a8d..571bcf146 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.16.12", + "version": "1.16.13", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/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..258e61dcc --- /dev/null +++ b/prisma/migrations/0155_v11613_mood_tag_curation/migration.sql @@ -0,0 +1,43 @@ +-- 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"), `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 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 redundant default tags + sleep-quality factor ───── +UPDATE "mood_tags" +SET "is_active" = false +WHERE "key" IN ('happy', 'excited', 'hydrated', 'music', 'factor_sleep_quality'); + +-- ─── 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"; diff --git a/public/logo-maskable-192.png b/public/logo-maskable-192.png new file mode 100644 index 000000000..3aa46e86f Binary files /dev/null and b/public/logo-maskable-192.png differ 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" }] + } ] } diff --git a/public/sw.js b/public/sw.js index a726a174c..1890d1ae5 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.13"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; const MAX_STATIC_ENTRIES = 150; @@ -118,6 +126,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 +168,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/scripts/__tests__/generate-sw-version.test.ts b/scripts/__tests__/generate-sw-version.test.ts index f47c8e503..1df0cf177 100644 --- a/scripts/__tests__/generate-sw-version.test.ts +++ b/scripts/__tests__/generate-sw-version.test.ts @@ -43,8 +43,11 @@ describe("public/sw.js CACHE_VERSION wiring", () => { const sw = readFileSync(SW_PATH, "utf8"); // The fallback OR-expression keeps the SW functional in dev mode // where the build script has not run. Pin the shape so the fallback - // can never silently vanish. - expect(sw).toMatch(/self\.__APP_VERSION__\s*\)?\s*\|\|\s*"v\d/); + // can never silently vanish. The optional block comment is the + // `@sw-version-fallback` rewrite anchor the prebuild generator keys on. + expect(sw).toMatch( + /self\.__APP_VERSION__\s*\)?\s*\|\|\s*(?:\/\*[^]*?\*\/\s*)?"v\d/, + ); }); }); 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 66ba97460..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(); @@ -238,6 +244,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(); 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/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/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)); } 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) => ({ diff --git a/src/app/measurements/page.tsx b/src/app/measurements/page.tsx index d22e405e9..c1dfcfacd 100644 --- a/src/app/measurements/page.tsx +++ b/src/app/measurements/page.tsx @@ -109,18 +109,18 @@ export default function MeasurementsPage() {
-
+

{t("measurements.title")}

{/* v1.4.34 IW-G — subtitle now stays visible on mobile so the H1 isn't an unframed label. `text-xs sm:text-sm` preserves the desktop hierarchy. */} -

+

{t("measurements.subtitle")}

- diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index 82eef3a4a..cc2ab5653 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -326,14 +326,14 @@ export default function MedicationsPage() { return (
-
-
+
+

{t("medications.title")}

{/* v1.4.34 IW-G — subtitle stays visible on mobile so the H1 isn't an unframed label. */} -

+

{t("medications.subtitle")}

diff --git a/src/app/mood/page.tsx b/src/app/mood/page.tsx index f18efa3ce..8d959076e 100644 --- a/src/app/mood/page.tsx +++ b/src/app/mood/page.tsx @@ -64,13 +64,13 @@ export default function MoodPage() {
-
+

{t("mood.title")}

{/* v1.4.34 IW-G — subtitle stays visible on mobile so the H1 isn't an unframed label. */} -

+

{t("mood.subtitle")}

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/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index d46f4a6d5..79aa15331 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -78,7 +78,7 @@ export function DashboardHeader({ showGreeting && "sm:items-start", )} > -
+

{t("dashboard.title")}

@@ -89,13 +89,13 @@ export function DashboardHeader({ keeps the header from collapsing/growing around that swap. */

{welcomeText}

) : null}
-
+
{/* Customize shortcut to the dashboard-customization settings (the tile/layout editor at /settings/dashboard). Sits to the left of the add button as a monochrome ghost icon, with 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 diff --git a/src/components/layout/__tests__/auth-shell-coach-hoist.test.ts b/src/components/layout/__tests__/auth-shell-coach-hoist.test.ts index 7774acae1..67893ea11 100644 --- a/src/components/layout/__tests__/auth-shell-coach-hoist.test.ts +++ b/src/components/layout/__tests__/auth-shell-coach-hoist.test.ts @@ -51,6 +51,14 @@ describe("v1.4.34 IW-B — CoachLaunchProvider hoist", () => { expect(source).toContain(""); }); + it("gates the LayoutCoachFab on `!demoMode` so demo visitors can't hit the proxy-blocked send (v1.16.13)", () => { + const source = readFileSync(AUTH_SHELL_PATH, "utf8"); + // `/api/insights/chat` is not in the proxy's DEMO_MUTATION_ALLOWLIST, + // so a tappable FAB in demo mode produces a raw 403 on send. The mount + // must be conditioned on the demo flag. + expect(source).toContain("{!demoMode && }"); + }); + it("no longer mounts any Coach surface in the routed insights layout", () => { const source = readFileSync(INSIGHTS_LAYOUT_PATH, "utf8"); // The provider import drops; the drawer mount lives on the shell. diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index 02202979a..02fb68705 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -272,7 +272,12 @@ export function AuthShell({
- + {/* v1.16.13 — the Coach FAB stays hidden in demo mode. Its send hits + `/api/insights/chat`, which is not in the proxy's + `DEMO_MUTATION_ALLOWLIST`, so a demo visitor who tapped it and sent + got a raw 403. Demo is read-only by design; the FAB has no job + here, so it's mounted only outside demo mode. */} + {!demoMode && } ); } 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() { )} >