mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
61 files changed
+1420
-71
No files matched your search
@@ -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
@@ -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
@@ -654,7 +654,8 @@
|
||||
"factorStress": "Stress",
|
||||
"factorConflict": "Konflikt",
|
||||
"factorFamily": "Familie",
|
||||
"factorSadness": "Traurigkeit"
|
||||
"factorSadness": "Traurigkeit",
|
||||
"praise": "Lob"
|
||||
},
|
||||
"filterBySource": "Nach Quelle filtern",
|
||||
"sourceManual": "Manuell",
|
||||
|
||||
+2
-1
@@ -654,7 +654,8 @@
|
||||
"factorStress": "stress",
|
||||
"factorConflict": "conflict",
|
||||
"factorFamily": "family",
|
||||
"factorSadness": "sadness"
|
||||
"factorSadness": "sadness",
|
||||
"praise": "praise"
|
||||
},
|
||||
"filterBySource": "Filter by source",
|
||||
"sourceManual": "Manual",
|
||||
|
||||
+2
-1
@@ -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
@@ -654,7 +654,8 @@
|
||||
"factorStress": "stress",
|
||||
"factorConflict": "conflit",
|
||||
"factorFamily": "famille",
|
||||
"factorSadness": "tristesse"
|
||||
"factorSadness": "tristesse",
|
||||
"praise": "compliment"
|
||||
},
|
||||
"filterBySource": "Filtrer par source",
|
||||
"sourceManual": "Manuel",
|
||||
|
||||
+2
-1
@@ -654,7 +654,8 @@
|
||||
"factorStress": "stress",
|
||||
"factorConflict": "conflitto",
|
||||
"factorFamily": "famiglia",
|
||||
"factorSadness": "tristezza"
|
||||
"factorSadness": "tristezza",
|
||||
"praise": "elogio"
|
||||
},
|
||||
"filterBySource": "Filtra per fonte",
|
||||
"sourceManual": "Manuale",
|
||||
|
||||
+2
-1
@@ -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
@@ -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 |
@@ -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
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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`);
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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([
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -65,6 +65,7 @@ const openApiBase: Pick<
|
||||
{ name: "Mood" },
|
||||
{ name: "Analytics" },
|
||||
{ name: "Insights" },
|
||||
{ name: "Consent" },
|
||||
{ name: "Dashboard" },
|
||||
{ name: "Notifications" },
|
||||
{ name: "Devices" },
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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"]> = {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user