Merge pull request #320 from MBombeck/release/v1.16.13

release/v1.16.13 — installable offline, era-correct dose edits, steadier headers
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-14 11:47:31 +02:00
commit e783d9a46a
61 files changed
+1420 -71

No files matched your search

+21
View File
@@ -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
+182 -2
View File
@@ -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:
+2 -1
View File
@@ -654,7 +654,8 @@
"factorStress": "Stress",
"factorConflict": "Konflikt",
"factorFamily": "Familie",
"factorSadness": "Traurigkeit"
"factorSadness": "Traurigkeit",
"praise": "Lob"
},
"filterBySource": "Nach Quelle filtern",
"sourceManual": "Manuell",
+2 -1
View File
@@ -654,7 +654,8 @@
"factorStress": "stress",
"factorConflict": "conflict",
"factorFamily": "family",
"factorSadness": "sadness"
"factorSadness": "sadness",
"praise": "praise"
},
"filterBySource": "Filter by source",
"sourceManual": "Manual",
+2 -1
View File
@@ -654,7 +654,8 @@
"factorStress": "estrés",
"factorConflict": "conflicto",
"factorFamily": "familia",
"factorSadness": "tristeza"
"factorSadness": "tristeza",
"praise": "elogio"
},
"filterBySource": "Filtrar por fuente",
"sourceManual": "Manual",
+2 -1
View File
@@ -654,7 +654,8 @@
"factorStress": "stress",
"factorConflict": "conflit",
"factorFamily": "famille",
"factorSadness": "tristesse"
"factorSadness": "tristesse",
"praise": "compliment"
},
"filterBySource": "Filtrer par source",
"sourceManual": "Manuel",
+2 -1
View File
@@ -654,7 +654,8 @@
"factorStress": "stress",
"factorConflict": "conflitto",
"factorFamily": "famiglia",
"factorSadness": "tristezza"
"factorSadness": "tristezza",
"praise": "elogio"
},
"filterBySource": "Filtra per fonte",
"sourceManual": "Manuale",
+2 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
@@ -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";
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

+26
View File
@@ -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" }]
}
]
}
+36 -2
View File
@@ -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);
@@ -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/,
);
});
});
+20 -1
View File
@@ -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`);
+48 -4
View File
@@ -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<string, Response>();
@@ -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();
+48
View File
@@ -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,
});
});
+6 -1
View File
@@ -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 },
@@ -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);
});
});
@@ -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));
}
@@ -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([
+7 -3
View File
@@ -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) => ({
+3 -3
View File
@@ -109,18 +109,18 @@ export default function MeasurementsPage() {
<div className="space-y-6">
<PullToRefreshIndicator {...pull} />
<div className="flex items-center justify-between">
<div>
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">
{t("measurements.title")}
</h1>
{/* 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. */}
<p className="text-muted-foreground text-xs sm:text-sm">
<p className="text-muted-foreground truncate text-xs sm:text-sm">
{t("measurements.subtitle")}
</p>
</div>
<Button onClick={() => setDialogOpen(true)}>
<Button className="shrink-0" onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4" />
{t("measurements.addMeasurement")}
</Button>
+3 -3
View File
@@ -326,14 +326,14 @@ export default function MedicationsPage() {
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<h1 className="text-2xl font-bold tracking-tight">
{t("medications.title")}
</h1>
{/* v1.4.34 IW-G — subtitle stays visible on mobile so the
H1 isn't an unframed label. */}
<p className="text-muted-foreground text-xs sm:text-sm">
<p className="text-muted-foreground truncate text-xs sm:text-sm">
{t("medications.subtitle")}
</p>
</div>
+2 -2
View File
@@ -64,13 +64,13 @@ export default function MoodPage() {
<div className="space-y-6">
<PullToRefreshIndicator {...pull} />
<div className="flex items-center justify-between">
<div>
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">
{t("mood.title")}
</h1>
{/* v1.4.34 IW-G — subtitle stays visible on mobile so the
H1 isn't an unframed label. */}
<p className="text-muted-foreground text-xs sm:text-sm">
<p className="text-muted-foreground truncate text-xs sm:text-sm">
{t("mood.subtitle")}
</p>
</div>
@@ -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);
});
});
@@ -78,7 +78,7 @@ export function DashboardHeader({
showGreeting && "sm:items-start",
)}
>
<div>
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">
{t("dashboard.title")}
</h1>
@@ -89,13 +89,13 @@ export function DashboardHeader({
keeps the header from collapsing/growing around that swap. */
<p
data-slot="dashboard-header-greeting"
className="text-muted-foreground mt-1 min-h-5 text-sm"
className="text-muted-foreground mt-1 min-h-5 truncate text-sm"
>
{welcomeText}
</p>
) : null}
</div>
<div className="flex items-center gap-2">
<div className="flex shrink-0 items-center gap-2">
{/* 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
+1 -1
View File
@@ -250,7 +250,7 @@ export function HeroStrip({
* disappears rather than collapsing to an empty flex shell.
*/}
{coachEnabled && (
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center justify-end gap-2">
{/* 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
@@ -51,6 +51,14 @@ describe("v1.4.34 IW-B — CoachLaunchProvider hoist", () => {
expect(source).toContain("<LayoutCoachFab />");
});
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 && <LayoutCoachFab />}");
});
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.
+6 -1
View File
@@ -272,7 +272,12 @@ export function AuthShell({
<BottomNav />
</div>
<LayoutCoachMount />
<LayoutCoachFab />
{/* 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 && <LayoutCoachFab />}
</CoachLaunchProvider>
);
}
+3 -1
View File
@@ -255,7 +255,9 @@ export function BottomNav() {
)}
>
<item.icon className="h-5 w-5 shrink-0" aria-hidden="true" />
<span className="text-sm font-medium">{t(item.tKey)}</span>
<span className="min-w-0 text-sm font-medium leading-tight">
{t(item.tKey)}
</span>
</Link>
);
})}
+2
View File
@@ -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}
<Toaster position="bottom-right" richColors />
<VersionPoller />
<ServiceWorkerRegistrar />
</AppSettingsProvider>
</I18nProvider>
</ThemeProvider>
@@ -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 `<VersionPoller>` (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;
}
@@ -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,
+6 -2
View File
@@ -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 (`<ServiceWorkerRegistrar>` 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,
+13
View File
@@ -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
+80
View File
@@ -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([
@@ -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<Record<string, unknown>> = {}) {
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();
});
});
+57
View File
@@ -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<WebConsentGrantResult> {
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 };
}
+24 -8
View File
@@ -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<string>();
// 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<string>(INSIGHTS_SECTION_IDS);
+3 -1
View File
@@ -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),
+4 -1
View File
@@ -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),
+3 -1
View File
@@ -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),
@@ -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),
+3 -1
View File
@@ -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),
+3 -1
View File
@@ -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),
+3 -1
View File
@@ -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),
+20
View File
@@ -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)
+3 -1
View File
@@ -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),
@@ -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<typeof resolveSlotForWriteByBand>[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();
});
});
@@ -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,
};
+1
View File
@@ -65,6 +65,7 @@ const openApiBase: Pick<
{ name: "Mood" },
{ name: "Analytics" },
{ name: "Insights" },
{ name: "Consent" },
{ name: "Dashboard" },
{ name: "Notifications" },
{ name: "Devices" },
+126
View File
@@ -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<ZodOpenApiObject["paths"]> = {
"/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,
},
},
},
};
+2
View File
@@ -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<ZodOpenApiObject["paths"]> = {
...insightsPaths,
...moodPaths,
...settingsPaths,
...consentPaths,
};
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
+7 -2
View File
@@ -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<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...consentRequiredResponse,
},
},
},
+17
View File
@@ -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 } },
},
};
@@ -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<typeof import("../sync")>("../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();
});
});
+10
View File
@@ -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<ReturnType<typeof fetchWorkouts>>;
+4 -2
View File
@@ -17,8 +17,10 @@
*
* The incremental window starts from `lastSyncedAt - overlap`. WHOOP re-scores
* recovery/sleep hours after the night, so the overlap must comfortably cover
* the re-score lag — `WHOOP_RECOVERY_SLEEP_OVERLAP_MS` is 24 h; workout/cycle
* use the smaller `WHOOP_DEFAULT_OVERLAP_MS`.
* the re-score lag — `WHOOP_RECOVERY_SLEEP_OVERLAP_MS` is 7 days, now shared by
* workout ingest too (a phone that syncs to the WHOOP cloud well after a
* workout would otherwise fall outside a narrow window and never land);
* `WHOOP_DEFAULT_OVERLAP_MS` stays the narrow fallback for other resources.
*/
import { AsyncLocalStorage } from "node:async_hooks";
import { prisma } from "@/lib/db";
+4 -4
View File
@@ -195,7 +195,7 @@ describe("POST /api/mood-entries/bulk (real Postgres)", () => {
moodLoggedAt: "2026-05-16T08:00:00.000Z",
ratedFactors: [
{ key: "factor_work", rating: 4 },
{ key: "factor_sleep_quality", rating: 2 },
{ key: "factor_sadness", rating: 2 },
],
},
],
@@ -220,7 +220,7 @@ describe("POST /api/mood-entries/bulk (real Postgres)", () => {
rating: l.rating,
})),
).toEqual([
{ key: "factor_sleep_quality", kind: "RATED", rating: 2 },
{ key: "factor_sadness", kind: "RATED", rating: 2 },
{ key: "factor_work", kind: "RATED", rating: 4 },
]);
});
@@ -239,8 +239,8 @@ describe("POST /api/mood-entries/bulk (real Postgres)", () => {
{
mood: "GUT",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
// factor_sleep_quality scale is 1..5; rating 9 is out of range.
ratedFactors: [{ key: "factor_sleep_quality", rating: 9 }],
// factor_sadness scale is 1..5; rating 9 is out of range.
ratedFactors: [{ key: "factor_sadness", rating: 9 }],
},
],
}),
+7 -7
View File
@@ -210,7 +210,7 @@ describe("custom mood-tag groups (real Postgres)", () => {
const layoutRes = await putLayout(
jsonReq("http://localhost/api/mood/tags/layout", "PUT", {
groupOrder: [groupKey, "feelings"],
placements: { [groupKey]: ["happy"] },
placements: { [groupKey]: ["grateful"] },
}),
);
expect(layoutRes.status).toBe(200);
@@ -270,7 +270,7 @@ describe("custom mood-tag groups (real Postgres)", () => {
// 1st PUT: placements only.
await putLayout(
jsonReq("http://localhost/api/mood/tags/layout", "PUT", {
placements: { [groupKey]: ["happy"] },
placements: { [groupKey]: ["grateful"] },
}),
);
// 2nd PUT: groupOrder only — must keep the stored placements.
@@ -284,18 +284,18 @@ describe("custom mood-tag groups (real Postgres)", () => {
data: { groupOrder: string[]; placements: Record<string, string[]> };
};
expect(layout.data.groupOrder[0]).toBe(groupKey);
expect(layout.data.placements).toEqual({ [groupKey]: ["happy"] });
expect(layout.data.placements).toEqual({ [groupKey]: ["grateful"] });
// The effective tree leads with the group and carries the placed
// catalogue tag inside it (placement, not a categoryId change).
const tree = await readTree();
expect(tree[0].key).toBe(groupKey);
expect(tree[0].tags.map((t) => t.key)).toContain("happy");
const happyRow = await getPrismaClient().moodTag.findUnique({
where: { key: "happy" },
expect(tree[0].tags.map((t) => t.key)).toContain("grateful");
const gratefulRow = await getPrismaClient().moodTag.findUnique({
where: { key: "grateful" },
select: { category: { select: { key: true } } },
});
expect(happyRow?.category.key).not.toBe(groupKey);
expect(gratefulRow?.category.key).not.toBe(groupKey);
});
it("include=archived surfaces an archived custom tag for restore", async () => {