mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge pull request #248 from MBombeck/release/v1.10.2
v1.10.2 — honest AI connection test, consistent insights, retention re-enabled
This commit is contained in:
55 files changed
+2240
-290
No files matched your search
+50
-8
@@ -7,30 +7,41 @@
|
||||
# compose `environment:` block at boot — `.env` should never ship.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.production.example
|
||||
.env.example
|
||||
.env.production.example
|
||||
|
||||
# Version control + CI metadata.
|
||||
.git
|
||||
.github
|
||||
.gitignore
|
||||
.gitleaks.toml
|
||||
|
||||
# Build / install output that gets regenerated inside the builder stage.
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
dist
|
||||
coverage
|
||||
test-results
|
||||
playwright-report
|
||||
playwright/.cache
|
||||
playwright.config.ts
|
||||
vitest.config.mts
|
||||
vitest.integration.config.mts
|
||||
tests
|
||||
tests/
|
||||
e2e
|
||||
e2e/
|
||||
e2e-results
|
||||
.eslintcache
|
||||
eslint-plugins
|
||||
eslint-plugins/
|
||||
knip.json
|
||||
|
||||
# Persisted session state from e2e runs.
|
||||
e2e/setup/storageState.json
|
||||
|
||||
# Planning scratch + per-release audit notes (operator-local).
|
||||
.planning
|
||||
docs/audit
|
||||
# Generated local cache files
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
public/sw-version.js
|
||||
|
||||
# Captures from local debugging sessions.
|
||||
*.har
|
||||
@@ -43,3 +54,34 @@ docs/audit
|
||||
.vscode
|
||||
.idea
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Logs / Debug
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Docker compose / local ops files
|
||||
docker-compose*.yml
|
||||
compose*.yml
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# Documentation not needed for runtime image
|
||||
README.md
|
||||
CHANGELOG.md
|
||||
CLAUDE.md
|
||||
CONTRIBUTING.md
|
||||
CONTRIBUTING-AI.md
|
||||
CODE_OF_CONDUCT.md
|
||||
SECURITY.md
|
||||
# LICENSE / LICENSE.md are deliberately NOT ignored: AGPL-3.0 conveys with
|
||||
# the software, so the licence text ships inside the image.
|
||||
AGENTS.md
|
||||
.planning
|
||||
docs
|
||||
docs/*.md
|
||||
docs/**/*.md
|
||||
@@ -1,5 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## [1.10.2] — 2026-06-03 — Honest AI connection test, consistent insights, retention re-enabled
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The AI connection test now tells you what actually went wrong.** A failed test used to surface a cryptic parse error ("unexpected token") because the server answered with a gateway-level error page the settings screen could not read; worse, the test probed a different provider than the one your nightly insights are generated with, so it could report a problem with a fallback you do not even use. The test now always returns a readable, translated reason — rejected credentials, rate-limited, a provider server error, or unreachable — and it checks the same provider your insights run on, so the result is the truth about your setup.
|
||||
- **Every insight metric page carries the same time-range controls.** The core-metric pages (weight, blood pressure, pulse, BMI, sleep) were missing the period selector that the other metric pages already had; they now match. Drill-down back-links are consistent and a "show all values" link returns you to the metric you came from.
|
||||
- **The derived-metrics dashboard no longer collapses or jumps while loading.** It shows a placeholder row at the right height, keeps its heading hidden until there is something to show, and offers an inline retry if the data fails to load instead of silently vanishing.
|
||||
- **More status colours meet contrast on the light theme** across the medication and admin surfaces.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The intra-day retention drain is back on.** The storage optimisation behind the stress reading was switched off in 1.10.0.2 after a daily-summary collision kept the app cycling post-deploy. The fold now adopts the existing daily row deterministically and recovers cleanly if a concurrent write reaches the same slot first — verified against real data — and the start-up defer that protects the connection pool stays in place.
|
||||
- **The batch-ingest and health-record export endpoints cap request-body size**, rejecting an oversized payload before parsing it.
|
||||
|
||||
### Added
|
||||
|
||||
- **A capability endpoint for API clients.** `GET /api/meta/capabilities` returns the server's live metric, tile, ingest and FHIR vocabularies alongside a contract version, so a client renders what it recognises and ignores what it does not — no more transcribing each release's new identifiers by hand.
|
||||
|
||||
## [1.10.1] — 2026-06-03 — Consistent medication cards
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -530,7 +530,7 @@ A SwiftUI iOS companion that lives on the same `/api` surfaces as the web client
|
||||
### What the phone unlocks
|
||||
|
||||
- **HealthKit two-way sync.** Today's step count, weight, blood pressure, HRV, sleep, glucose, etc. read live from Apple Health and round-trip to your server through `HKObserverQuery` + `HKAnchoredObjectQuery` + a `BGProcessingTask` for guaranteed delivery. `HKMetadataKeyExternalUUID` on every iOS-side write keeps the server and Apple Health from echoing each other.
|
||||
- **Medication reminders that actually fire.** Local notifications with action buttons (Genommen / Snooze 15 min / Übersprungen) that hit the server's mark-intake endpoint directly — without opening the app.
|
||||
- **Medication reminders that actually fire.** Local notifications with action buttons (Taken / Snooze 15 min / Skipped) that hit the server's mark-intake endpoint directly — without opening the app.
|
||||
- **On-device AI Coach.** A conversational surface powered by Apple's Foundation Models framework on iOS 26+ Apple-Intelligence-eligible iPhones. The prompt and the response never leave the device — your numbers, your health questions, your phone.
|
||||
- **Passkey-first auth.** Sign in with Face ID or Touch ID via WebAuthn; email + magic-link is the fallback. SPKI public-key pinning on every authenticated request, Keychain with `afterFirstUnlockThisDeviceOnly`, and a log sanitizer that filters tokens and hostnames out of every line.
|
||||
- **Doctor-report export.** Generate a FHIR-flavoured PDF bundle from the iPhone based on the LOINC mappings reviewed on the server's clinician surface.
|
||||
|
||||
+159
-19
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.10.1
|
||||
version: 1.10.2
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -38,7 +38,43 @@ tags:
|
||||
- name: Export
|
||||
- name: Sync
|
||||
- name: Admin
|
||||
- name: Meta
|
||||
paths:
|
||||
/api/meta/capabilities:
|
||||
get:
|
||||
tags:
|
||||
- Meta
|
||||
summary: Live server capability / id-vocabulary discovery
|
||||
description: Returns the server's REAL id vocabularies (derived-metric ids, vitals-baseline types, layout tile-ids,
|
||||
metric-status ids, the HealthKit ingest mapping, the FHIR coding constants) plus the running API contract
|
||||
version. Every list is derived server-side from the canonical registry it documents, so a client can gate its UI
|
||||
/ decoder against what the server actually ships rather than a hand-maintained copy. Auth via cookie or Bearer
|
||||
(not admin).
|
||||
responses:
|
||||
"200":
|
||||
description: Capability snapshot.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CapabilitiesEnvelope"
|
||||
"401": &a1
|
||||
description: Authentication required or invalid credentials.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": &a2
|
||||
description: Request validation failed.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"429": &a3
|
||||
description: Rate limit exceeded.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
/api/export/health-record:
|
||||
post:
|
||||
tags:
|
||||
@@ -170,24 +206,9 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
"401": &a1
|
||||
description: Authentication required or invalid credentials.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": &a2
|
||||
description: Request validation failed.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"429": &a3
|
||||
description: Rate limit exceeded.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"401": *a1
|
||||
"422": *a2
|
||||
"429": *a3
|
||||
/api/auth/login:
|
||||
post:
|
||||
tags:
|
||||
@@ -3469,6 +3490,125 @@ components:
|
||||
primitives are mutually exclusive — enforced by the Zod refine on writes, the route layer, and a DB CHECK
|
||||
constraint (`medication_schedules_rrule_xor_rolling`). v1.7.0 adds `scheduleType` (SCHEDULED / PRN / CYCLIC) and
|
||||
the cyclic on/off-week fields.
|
||||
CapabilitiesEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/CapabilitiesResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CapabilitiesResponse:
|
||||
type: object
|
||||
properties:
|
||||
apiContractVersion:
|
||||
type: string
|
||||
description: Running build version — mirrors GET /api/version `version`.
|
||||
derivedMetricIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Closed derived-metric id set (GET /api/insights/derived).
|
||||
vitalsBaselineTypes:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: MeasurementTypes the typical-range baseline engine supports.
|
||||
layoutTileIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Canonical insights layout tile-id set (English ids).
|
||||
metricStatusIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Closed metric-status / assessment id set.
|
||||
ingest:
|
||||
type: object
|
||||
properties:
|
||||
quantityTypes:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
description: HealthLog MeasurementType.
|
||||
hk:
|
||||
type: string
|
||||
description: HealthKit identifier.
|
||||
unit:
|
||||
type: string
|
||||
description: Canonical DB unit.
|
||||
required:
|
||||
- type
|
||||
- hk
|
||||
- unit
|
||||
additionalProperties: false
|
||||
description: Accepted HealthKit quantity-sample mappings.
|
||||
eventTypes:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: MeasurementTypes for device-flagged EVENT-class HealthKit samples.
|
||||
computedScores:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Server-owned nightly composite score types.
|
||||
writeAllowlist:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: MeasurementSources a client may attribute on a write (others are server-owned).
|
||||
required:
|
||||
- quantityTypes
|
||||
- eventTypes
|
||||
- computedScores
|
||||
- writeAllowlist
|
||||
additionalProperties: false
|
||||
description: Ingest vocabularies the batch / single-write paths accept.
|
||||
fhir:
|
||||
type: object
|
||||
properties:
|
||||
atcSystem:
|
||||
type: string
|
||||
description: WHO ATC CodeSystem URI.
|
||||
snomedRoute:
|
||||
type: string
|
||||
description: SNOMED CT CodeSystem URI.
|
||||
germanAtcDefaultLocales:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: App locales that default the additive BfArM ATC coding on.
|
||||
required:
|
||||
- atcSystem
|
||||
- snomedRoute
|
||||
- germanAtcDefaultLocales
|
||||
additionalProperties: false
|
||||
description: FHIR coding constants the health-record export emits.
|
||||
required:
|
||||
- apiContractVersion
|
||||
- derivedMetricIds
|
||||
- vitalsBaselineTypes
|
||||
- layoutTileIds
|
||||
- metricStatusIds
|
||||
- ingest
|
||||
- fhir
|
||||
additionalProperties: false
|
||||
description: Live id vocabularies + contract version. Every list is derived server-side from the canonical registry it
|
||||
documents, so it cannot drift from the values the routes actually accept/emit.
|
||||
ErrorEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+10
-1
@@ -2047,7 +2047,9 @@
|
||||
"weekday": "Die meisten deiner {metric}-Werte fallen auf einen Wochentag. Messungen an anderen Tagen ergeben ein vollständigeres Bild.",
|
||||
"timeOfDay": "Die meisten deiner {metric}-Werte fallen auf eine ähnliche Tageszeit. Unterschiedliche Zeiten ergeben ein vollständigeres Bild."
|
||||
},
|
||||
"valuesBack": "Zurück zu Insights"
|
||||
"valuesBack": "Zurück zu Insights",
|
||||
"valuesBackToMetric": "Zurück zu {metric}",
|
||||
"scoresBack": "Zurück zu Insights"
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Schlaf",
|
||||
@@ -2526,6 +2528,8 @@
|
||||
},
|
||||
"vitals": {
|
||||
"sectionTitle": "Deine Vitalwerte",
|
||||
"loadingLabel": "Vitalwerte werden geladen",
|
||||
"loadError": "Deine Vitalwerte konnten gerade nicht geladen werden.",
|
||||
"building": "Typischer Bereich entsteht — {count} von {target} Tagen",
|
||||
"typicalRange": "Typisch für dich: {low}–{high}",
|
||||
"fitnessYounger": "Ausdauer wie ~{years} J. jünger",
|
||||
@@ -3255,6 +3259,11 @@
|
||||
"testProvider": "Aktiven Provider testen",
|
||||
"testSuccess": "OK — {provider} ({model})",
|
||||
"testFailedShort": "Test fehlgeschlagen: {message}",
|
||||
"testUnexpectedResponse": "Verbindung zum KI-Anbieter fehlgeschlagen — unerwartete Antwort vom Server.",
|
||||
"testReasonCredentials": "Der Anbieter hat die Anmeldedaten abgelehnt — melde dich in den KI-Einstellungen erneut an.",
|
||||
"testReasonRateLimited": "Der Anbieter hat die Anfrage gedrosselt — versuche es in Kürze erneut.",
|
||||
"testReasonServerError": "Der KI-Anbieter hat einen Serverfehler zurückgegeben.",
|
||||
"testReasonUnreachable": "Der KI-Anbieter war nicht erreichbar.",
|
||||
"disableCoach": {
|
||||
"title": "Coach ausblenden",
|
||||
"description": "Versteckt den Coach-Button und das Coach-Panel auf allen Seiten.",
|
||||
|
||||
+10
-1
@@ -2047,7 +2047,9 @@
|
||||
"weekday": "Most of your {metric} readings land on one weekday. Logging on other days gives a fuller picture.",
|
||||
"timeOfDay": "Most of your {metric} readings land at a similar time of day. Varying the time gives a fuller picture."
|
||||
},
|
||||
"valuesBack": "Back to insights"
|
||||
"valuesBack": "Back to insights",
|
||||
"valuesBackToMetric": "Back to {metric}",
|
||||
"scoresBack": "Back to insights"
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sleep",
|
||||
@@ -2526,6 +2528,8 @@
|
||||
},
|
||||
"vitals": {
|
||||
"sectionTitle": "Your vitals",
|
||||
"loadingLabel": "Loading your vitals",
|
||||
"loadError": "Could not load your vitals right now.",
|
||||
"building": "Building your typical range — {count} of {target} days",
|
||||
"typicalRange": "Typical for you: {low}–{high}",
|
||||
"fitnessYounger": "Cardio fitness of someone ~{years} yr younger",
|
||||
@@ -3255,6 +3259,11 @@
|
||||
"testProvider": "Test active provider",
|
||||
"testSuccess": "OK — {provider} ({model})",
|
||||
"testFailedShort": "Test failed: {message}",
|
||||
"testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.",
|
||||
"testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.",
|
||||
"testReasonRateLimited": "Provider rate-limited the request — try again shortly.",
|
||||
"testReasonServerError": "The AI provider returned a server error.",
|
||||
"testReasonUnreachable": "Could not reach the AI provider.",
|
||||
"disableCoach": {
|
||||
"title": "Hide Coach",
|
||||
"description": "Hides the Coach button and drawer everywhere.",
|
||||
|
||||
+10
-1
@@ -2047,7 +2047,9 @@
|
||||
"weekday": "La mayoría de tus mediciones de {metric} caen en un mismo día de la semana. Registrar en otros días ofrece una imagen más completa.",
|
||||
"timeOfDay": "La mayoría de tus mediciones de {metric} caen a una hora similar del día. Variar la hora ofrece una imagen más completa."
|
||||
},
|
||||
"valuesBack": "Volver a Insights"
|
||||
"valuesBack": "Volver a Insights",
|
||||
"valuesBackToMetric": "Volver a {metric}",
|
||||
"scoresBack": "Volver a Insights"
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sueño",
|
||||
@@ -2526,6 +2528,8 @@
|
||||
},
|
||||
"vitals": {
|
||||
"sectionTitle": "Tus constantes",
|
||||
"loadingLabel": "Cargando tus constantes",
|
||||
"loadError": "No se pudieron cargar tus constantes en este momento.",
|
||||
"building": "Creando tu rango habitual — {count} de {target} días",
|
||||
"typicalRange": "Habitual para ti: {low}–{high}",
|
||||
"fitnessYounger": "Forma cardiovascular de alguien ~{years} años más joven",
|
||||
@@ -3255,6 +3259,11 @@
|
||||
"testProvider": "Test active provider",
|
||||
"testSuccess": "OK — {provider} ({model})",
|
||||
"testFailedShort": "Test failed: {message}",
|
||||
"testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.",
|
||||
"testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.",
|
||||
"testReasonRateLimited": "Provider rate-limited the request — try again shortly.",
|
||||
"testReasonServerError": "The AI provider returned a server error.",
|
||||
"testReasonUnreachable": "Could not reach the AI provider.",
|
||||
"disableCoach": {
|
||||
"title": "Ocultar el Coach",
|
||||
"description": "Oculta el botón del Coach y el panel en todas las páginas.",
|
||||
|
||||
+10
-1
@@ -2047,7 +2047,9 @@
|
||||
"weekday": "La plupart de vos mesures de {metric} tombent le même jour de la semaine. Enregistrer d'autres jours donne une image plus complète.",
|
||||
"timeOfDay": "La plupart de vos mesures de {metric} tombent à une heure similaire de la journée. Varier l'heure donne une image plus complète."
|
||||
},
|
||||
"valuesBack": "Retour aux Insights"
|
||||
"valuesBack": "Retour aux Insights",
|
||||
"valuesBackToMetric": "Retour à {metric}",
|
||||
"scoresBack": "Retour aux Insights"
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sommeil",
|
||||
@@ -2526,6 +2528,8 @@
|
||||
},
|
||||
"vitals": {
|
||||
"sectionTitle": "Vos constantes",
|
||||
"loadingLabel": "Chargement de vos constantes",
|
||||
"loadError": "Impossible de charger vos constantes pour le moment.",
|
||||
"building": "Construction de votre plage habituelle — {count} sur {target} jours",
|
||||
"typicalRange": "Habituel pour vous : {low}–{high}",
|
||||
"fitnessYounger": "Forme cardio d'une personne ~{years} ans plus jeune",
|
||||
@@ -3255,6 +3259,11 @@
|
||||
"testProvider": "Test active provider",
|
||||
"testSuccess": "OK — {provider} ({model})",
|
||||
"testFailedShort": "Test failed: {message}",
|
||||
"testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.",
|
||||
"testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.",
|
||||
"testReasonRateLimited": "Provider rate-limited the request — try again shortly.",
|
||||
"testReasonServerError": "The AI provider returned a server error.",
|
||||
"testReasonUnreachable": "Could not reach the AI provider.",
|
||||
"disableCoach": {
|
||||
"title": "Masquer le Coach",
|
||||
"description": "Masque le bouton et le panneau du Coach sur toutes les pages.",
|
||||
|
||||
+10
-1
@@ -2047,7 +2047,9 @@
|
||||
"weekday": "La maggior parte delle tue misurazioni di {metric} cade in un solo giorno della settimana. Registrare in altri giorni offre un quadro più completo.",
|
||||
"timeOfDay": "La maggior parte delle tue misurazioni di {metric} cade in un orario simile della giornata. Variare l'orario offre un quadro più completo."
|
||||
},
|
||||
"valuesBack": "Torna a Insights"
|
||||
"valuesBack": "Torna a Insights",
|
||||
"valuesBackToMetric": "Torna a {metric}",
|
||||
"scoresBack": "Torna a Insights"
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sonno",
|
||||
@@ -2526,6 +2528,8 @@
|
||||
},
|
||||
"vitals": {
|
||||
"sectionTitle": "I tuoi parametri vitali",
|
||||
"loadingLabel": "Caricamento dei tuoi parametri vitali",
|
||||
"loadError": "Al momento non è stato possibile caricare i tuoi parametri vitali.",
|
||||
"building": "Creazione del tuo intervallo abituale — {count} di {target} giorni",
|
||||
"typicalRange": "Abituale per te: {low}–{high}",
|
||||
"fitnessYounger": "Forma cardio di chi ha ~{years} anni in meno",
|
||||
@@ -3255,6 +3259,11 @@
|
||||
"testProvider": "Test active provider",
|
||||
"testSuccess": "OK — {provider} ({model})",
|
||||
"testFailedShort": "Test failed: {message}",
|
||||
"testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.",
|
||||
"testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.",
|
||||
"testReasonRateLimited": "Provider rate-limited the request — try again shortly.",
|
||||
"testReasonServerError": "The AI provider returned a server error.",
|
||||
"testReasonUnreachable": "Could not reach the AI provider.",
|
||||
"disableCoach": {
|
||||
"title": "Nascondi il Coach",
|
||||
"description": "Nasconde il pulsante e il pannello del Coach su tutte le pagine.",
|
||||
|
||||
+10
-1
@@ -2047,7 +2047,9 @@
|
||||
"weekday": "Większość Twoich pomiarów {metric} przypada na jeden dzień tygodnia. Pomiary w inne dni dają pełniejszy obraz.",
|
||||
"timeOfDay": "Większość Twoich pomiarów {metric} przypada o podobnej porze dnia. Różne pory dają pełniejszy obraz."
|
||||
},
|
||||
"valuesBack": "Powrót do Insights"
|
||||
"valuesBack": "Powrót do Insights",
|
||||
"valuesBackToMetric": "Powrót do {metric}",
|
||||
"scoresBack": "Powrót do Insights"
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sen",
|
||||
@@ -2526,6 +2528,8 @@
|
||||
},
|
||||
"vitals": {
|
||||
"sectionTitle": "Twoje parametry życiowe",
|
||||
"loadingLabel": "Wczytywanie parametrów życiowych",
|
||||
"loadError": "Nie udało się teraz wczytać Twoich parametrów życiowych.",
|
||||
"building": "Budowanie Twojego typowego zakresu — {count} z {target} dni",
|
||||
"typicalRange": "Typowe dla Ciebie: {low}–{high}",
|
||||
"fitnessYounger": "Kondycja jak u osoby ~{years} lat młodszej",
|
||||
@@ -3255,6 +3259,11 @@
|
||||
"testProvider": "Test active provider",
|
||||
"testSuccess": "OK — {provider} ({model})",
|
||||
"testFailedShort": "Test failed: {message}",
|
||||
"testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.",
|
||||
"testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.",
|
||||
"testReasonRateLimited": "Provider rate-limited the request — try again shortly.",
|
||||
"testReasonServerError": "The AI provider returned a server error.",
|
||||
"testReasonUnreachable": "Could not reach the AI provider.",
|
||||
"disableCoach": {
|
||||
"title": "Ukryj Coacha",
|
||||
"description": "Ukrywa przycisk Coacha i panel na wszystkich stronach.",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.10.1",
|
||||
"version": "1.10.2",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -65,11 +65,27 @@ function jsonRequest(body: unknown): Request {
|
||||
});
|
||||
}
|
||||
|
||||
interface TestFailureEnvelope {
|
||||
data: {
|
||||
ok: false;
|
||||
providerType: string;
|
||||
reasonCode: "credentials" | "rate_limited" | "server_error" | "unreachable";
|
||||
reason: string;
|
||||
};
|
||||
error: null;
|
||||
}
|
||||
|
||||
// V3 audit: /api/ai/test was returning provider err.message + bodyExcerpt
|
||||
// directly to the client, leaking provider URLs / partial keys / internal
|
||||
// headers. Server now logs full details via annotate() and responds with
|
||||
// a categorised, generic message.
|
||||
describe("POST /api/ai/test — provider error leak guard", () => {
|
||||
// a categorised, secret-free reason.
|
||||
//
|
||||
// This route MUST NEVER return a 5xx: a 5xx origin response is rewritten
|
||||
// by Cloudflare to its own HTML error page, so the browser's res.json()
|
||||
// crashes with `Unexpected token '<', "<!DOCTYPE "`. Every provider-call
|
||||
// failure returns HTTP 200 with `{ ok:false, reason }` the client shows
|
||||
// verbatim.
|
||||
describe("POST /api/ai/test — provider error leak guard + non-5xx contract", () => {
|
||||
function makeProviderThatThrows(
|
||||
err: Error & { httpStatus?: number; bodyExcerpt?: string },
|
||||
) {
|
||||
@@ -81,7 +97,7 @@ describe("POST /api/ai/test — provider error leak guard", () => {
|
||||
} as never);
|
||||
}
|
||||
|
||||
it("does not echo provider err.message back to the client (HIGH coverage gap)", async () => {
|
||||
it("never returns a 5xx and does not echo provider err.message back to the client", async () => {
|
||||
const err = Object.assign(
|
||||
new Error("OpenAI 401 from https://api.openai.com/v1 sk-leaked-key"),
|
||||
{
|
||||
@@ -92,49 +108,67 @@ describe("POST /api/ai/test — provider error leak guard", () => {
|
||||
makeProviderThatThrows(err);
|
||||
|
||||
const response = await POST(emptyRequest() as never);
|
||||
const body = (await response.json()) as ApiErrorEnvelope;
|
||||
|
||||
// v1.4.5: credential failures map to 422 (not 502) so Cloudflare
|
||||
// doesn't replace our JSON body with its HTML error page — that
|
||||
// rewrite was the root cause of the "Unexpected token '<'…" client
|
||||
// crash the maintainer hit when typing a bad OpenAI key.
|
||||
expect(response.status).toBe(422);
|
||||
expect(body.error ?? "").not.toMatch(/sk-/);
|
||||
expect(body.error ?? "").not.toMatch(/api\.openai\.com/);
|
||||
expect(body.error ?? "").not.toMatch(/invalid api key/i);
|
||||
expect(body.error).toBe("Provider rejected the credentials");
|
||||
// Always JSON-parseable, never a 5xx (the Cloudflare HTML-rewrite
|
||||
// root cause of "Unexpected token '<'…").
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as TestFailureEnvelope;
|
||||
expect(body.data.ok).toBe(false);
|
||||
expect(body.data.reasonCode).toBe("credentials");
|
||||
// The stable machine code must itself be secret-free.
|
||||
expect(body.data.reasonCode).not.toMatch(/sk-/);
|
||||
expect(body.data.reason).not.toMatch(/sk-/);
|
||||
expect(body.data.reason).not.toMatch(/api\.openai\.com/);
|
||||
expect(body.data.reason).not.toMatch(/invalid api key/i);
|
||||
expect(body.data.reason).toMatch(/re-authenticate/i);
|
||||
});
|
||||
|
||||
it("returns the 429-categorised message when the provider rate-limits", async () => {
|
||||
it("reclassifies a 5xx whose body signals an invalidated session as a credential failure", async () => {
|
||||
// The shape an operator hit: re-auth, gateway answers 500 with
|
||||
// "authentication token has been invalidated" instead of a 401.
|
||||
makeProviderThatThrows(
|
||||
Object.assign(new Error("upstream 500"), {
|
||||
httpStatus: 500 as const,
|
||||
bodyExcerpt:
|
||||
'{"error":{"message":"Your authentication token has been invalidated. Please try signing in again."}}',
|
||||
}),
|
||||
);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as TestFailureEnvelope;
|
||||
expect(body.data.ok).toBe(false);
|
||||
expect(body.data.reasonCode).toBe("credentials");
|
||||
expect(body.data.reason).toMatch(/re-authenticate/i);
|
||||
});
|
||||
|
||||
it("categorises a 429 rate-limit", async () => {
|
||||
makeProviderThatThrows(
|
||||
Object.assign(new Error("429 from openai"), { httpStatus: 429 as const }),
|
||||
);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
// v1.4.5: rate-limit passes through as 429 (was 502 in v1.4.4) so
|
||||
// the React Query mutation can read the JSON body instead of
|
||||
// tripping over Cloudflare's HTML 502.
|
||||
expect(response.status).toBe(429);
|
||||
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
|
||||
"Provider rate-limited the request",
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as TestFailureEnvelope;
|
||||
expect(body.data.reasonCode).toBe("rate_limited");
|
||||
expect(body.data.reason).toMatch(/rate-limited/i);
|
||||
});
|
||||
|
||||
it("returns the 5xx-categorised message when the provider has a server error", async () => {
|
||||
it("categorises a plain 5xx provider server error (no auth signal)", async () => {
|
||||
makeProviderThatThrows(
|
||||
Object.assign(new Error("503 upstream"), { httpStatus: 503 as const }),
|
||||
);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
|
||||
"Provider returned a server error",
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as TestFailureEnvelope;
|
||||
expect(body.data.reasonCode).toBe("server_error");
|
||||
expect(body.data.reason).toMatch(/server error/i);
|
||||
});
|
||||
|
||||
it("returns the unknown-error fallback otherwise", async () => {
|
||||
it("categorises a network/timeout failure", async () => {
|
||||
makeProviderThatThrows(new Error("ECONNRESET"));
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
|
||||
"Provider connection failed",
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as TestFailureEnvelope;
|
||||
expect(body.data.reasonCode).toBe("unreachable");
|
||||
expect(body.data.reason).toMatch(/could not reach/i);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// V3 audit: do not return provider error message + bodyExcerpt to the
|
||||
// client (leaks provider URL / partial keys / internal headers).
|
||||
// Log full details server-side for the operator and respond with a
|
||||
// categorised, generic message.
|
||||
// categorised, generic reason.
|
||||
const err = e as Error & { httpStatus?: number; bodyExcerpt?: string };
|
||||
annotate({
|
||||
meta: {
|
||||
@@ -91,28 +91,72 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
ai_test_provider: provider.type,
|
||||
},
|
||||
});
|
||||
// Map upstream status to a *client-side* status code that won't be
|
||||
// rewritten by Cloudflare. 5xx from origin causes Cloudflare to swap
|
||||
// the body for its own HTML error page — `await res.json()` in the
|
||||
// browser then crashes with `Unexpected token '<', "<!DOCTYPE "`,
|
||||
// which is exactly how the maintainer encountered this when typing a bad
|
||||
// OpenAI key. 4xx codes pass through untouched.
|
||||
const status = err.httpStatus ?? 0;
|
||||
let outboundStatus: number;
|
||||
let safeMessage: string;
|
||||
if (status === 401 || status === 403) {
|
||||
outboundStatus = 422;
|
||||
safeMessage = "Provider rejected the credentials";
|
||||
} else if (status === 429) {
|
||||
outboundStatus = 429;
|
||||
safeMessage = "Provider rate-limited the request";
|
||||
} else if (status >= 500) {
|
||||
outboundStatus = 502;
|
||||
safeMessage = "Provider returned a server error";
|
||||
} else {
|
||||
outboundStatus = 422;
|
||||
safeMessage = "Provider connection failed";
|
||||
}
|
||||
return apiError(safeMessage, outboundStatus);
|
||||
// This route MUST NEVER return a 5xx. A 5xx origin response is
|
||||
// rewritten by Cloudflare / the reverse proxy to its own HTML error
|
||||
// page, so the browser's `res.json()` crashes with
|
||||
// `Unexpected token '<', "<!DOCTYPE "` (Safari: "The string did not
|
||||
// match the expected pattern"). That is exactly the failure an
|
||||
// operator hit re-authenticating a provider whose token came back as
|
||||
// an invalidated-session 500. We always reply 200 with a categorised,
|
||||
// secret-free `{ ok:false, reason }` the client can show verbatim.
|
||||
const { reasonCode, reason } = classifyTestFailure(err);
|
||||
return apiSuccess({
|
||||
ok: false,
|
||||
providerType: provider.type,
|
||||
reasonCode,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Stable, machine-readable failure categories. The client maps each to a
|
||||
* localised string; the English `reason` stays as a fallback for any
|
||||
* legacy / unmapped code. Secret-free by construction.
|
||||
*/
|
||||
type TestFailureCode =
|
||||
| "credentials"
|
||||
| "rate_limited"
|
||||
| "server_error"
|
||||
| "unreachable";
|
||||
|
||||
/**
|
||||
* Map an upstream provider failure to a human-readable, secret-free
|
||||
* reason string. Distinguishes credential/auth, rate-limit, provider
|
||||
* server-error and network/timeout classes. A 5xx whose body carries an
|
||||
* auth signal (invalidated session token, expired key, "sign in again")
|
||||
* is reclassified as a credential failure — that is the shape the
|
||||
* operator's re-auth produced, where the gateway answered 500 instead of
|
||||
* the expected 401.
|
||||
*/
|
||||
function classifyTestFailure(
|
||||
err: Error & { httpStatus?: number; bodyExcerpt?: string },
|
||||
): { reasonCode: TestFailureCode; reason: string } {
|
||||
const status = err.httpStatus ?? 0;
|
||||
const haystack = `${err.message} ${err.bodyExcerpt ?? ""}`;
|
||||
const looksLikeAuth =
|
||||
/authentication|invalidated|expired|sign in again|api key/i.test(haystack);
|
||||
|
||||
if (status === 401 || status === 403 || (status >= 500 && looksLikeAuth)) {
|
||||
return {
|
||||
reasonCode: "credentials",
|
||||
reason: "Provider rejected the credentials — re-authenticate in AI settings.",
|
||||
};
|
||||
}
|
||||
if (status === 429) {
|
||||
return {
|
||||
reasonCode: "rate_limited",
|
||||
reason: "Provider rate-limited the request — try again shortly.",
|
||||
};
|
||||
}
|
||||
if (status >= 500) {
|
||||
return {
|
||||
reasonCode: "server_error",
|
||||
reason: "The AI provider returned a server error.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
reasonCode: "unreachable",
|
||||
reason: "Could not reach the AI provider.",
|
||||
};
|
||||
}
|
||||
@@ -39,7 +39,10 @@ import {
|
||||
sanitisePracticeName,
|
||||
} from "@/lib/doctor-report-data";
|
||||
import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
|
||||
import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle";
|
||||
import {
|
||||
buildFhirDocumentBundle,
|
||||
GERMAN_ATC_DEFAULT_LOCALES,
|
||||
} from "@/lib/fhir/build-bundle";
|
||||
import {
|
||||
exportSelectionSchema,
|
||||
toDoctorReportPrefs,
|
||||
@@ -58,7 +61,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("Maximum 10 exports per hour", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
// The selection payload is small and bounded — format, range,
|
||||
// section toggles, a few flags, an optional practice-name string.
|
||||
// 64 KB is far above any legitimate selection while still rejecting
|
||||
// a multi-megabyte body before it reaches `JSON.parse`. A DoS
|
||||
// ceiling, not a tight bound.
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = exportSelectionSchema.safeParse(body);
|
||||
@@ -92,7 +102,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
// BfArM ATC: an explicit selection flag wins; otherwise derive it from a
|
||||
// German-region locale. The WHO ATC coding is unaffected either way.
|
||||
const germanAtc = selection.germanAtc ?? locale === "de";
|
||||
const germanAtc =
|
||||
selection.germanAtc ??
|
||||
(GERMAN_ATC_DEFAULT_LOCALES as readonly string[]).includes(locale);
|
||||
|
||||
const [data, userTz, userRow] = await Promise.all([
|
||||
collectDoctorReportData(user.id, range, { practiceName, sections }),
|
||||
|
||||
@@ -62,6 +62,15 @@ const PR_DETECTION_SILENT_THRESHOLD = 50;
|
||||
|
||||
const MAX_BATCH_ENTRIES = 500;
|
||||
|
||||
// Body-size ceiling backstopping the per-entry cap. A worst-case entry
|
||||
// (max-length hkIdentifier + unit + externalId + two ISO dates + the
|
||||
// numeric fields) serialises to well under 1 KB, so a legitimate
|
||||
// 500-entry batch tops out a few hundred KB. 4 MB leaves an order of
|
||||
// magnitude of headroom above any real batch while still rejecting a
|
||||
// multi-megabyte payload before it reaches `JSON.parse` and the heap.
|
||||
// The cap is a DoS ceiling, not a tight bound.
|
||||
const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
// v1.4.25 W10 reconcile (security H-2): cap batch ingest at 60
|
||||
// batches per user per minute. Healthy iOS sync drains its
|
||||
// HealthKit observer queue in well under one batch per minute (the
|
||||
@@ -177,7 +186,9 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
return apiError("Too many batch submissions, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson<unknown>(request);
|
||||
const { data: rawBody, error: jsonError } = await safeJson<unknown>(request, {
|
||||
maxBytes: MAX_BODY_BYTES,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
// Distinguish "too many entries" from "validation failed" so the
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* GET /api/meta/capabilities route tests.
|
||||
*
|
||||
* This endpoint exists to retire the "doc says N, server ships M" enum-drift
|
||||
* class for the native client — so the drift-guard assertions ARE the point:
|
||||
*
|
||||
* 1. 401 when unauthenticated.
|
||||
* 2. `derivedMetricIds` is exactly the derived registry's id set (same
|
||||
* length AND members) — the endpoint can never silently diverge from
|
||||
* the registry it claims to mirror.
|
||||
* 3. `ingest.writeAllowlist` deep-equals the `WRITABLE_MEASUREMENT_SOURCES`
|
||||
* constant — a client reads the live write allowlist, not a copy.
|
||||
* 4. Every quantity-mapping row + the FHIR constants + the contract version
|
||||
* are sourced from the canonical server constants, not hand-typed here.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// The route transitively imports `@/lib/insights/derived/wellness-scores`,
|
||||
// which imports the Prisma client; mock it so the module graph loads without
|
||||
// a real DB. The route itself only reads `WELLNESS_SCORE_TYPES` (a plain
|
||||
// object), never the client.
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { DERIVED_METRIC_IDS } from "@/lib/insights/derived/registry";
|
||||
import { WRITABLE_MEASUREMENT_SOURCES } from "@/lib/validations/measurement";
|
||||
import { APPLE_HEALTH_TYPE_MAP } from "@/lib/measurements/apple-health-mapping";
|
||||
import {
|
||||
ATC_SYSTEM,
|
||||
SNOMED_SYSTEM,
|
||||
GERMAN_ATC_DEFAULT_LOCALES,
|
||||
} from "@/lib/fhir/build-bundle";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
type CapabilitiesBody = {
|
||||
data: {
|
||||
apiContractVersion: string;
|
||||
derivedMetricIds: string[];
|
||||
vitalsBaselineTypes: string[];
|
||||
layoutTileIds: string[];
|
||||
metricStatusIds: string[];
|
||||
ingest: {
|
||||
quantityTypes: { type: string; hk: string; unit: string }[];
|
||||
eventTypes: string[];
|
||||
computedScores: string[];
|
||||
writeAllowlist: string[];
|
||||
};
|
||||
fhir: {
|
||||
atcSystem: string;
|
||||
snomedRoute: string;
|
||||
germanAtcDefaultLocales: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
async function call(): Promise<Response> {
|
||||
return (GET as unknown as () => Promise<Response>)();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/meta/capabilities — auth", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await call();
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/meta/capabilities — drift guards", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
});
|
||||
|
||||
it("derivedMetricIds is exactly the derived registry's id set", async () => {
|
||||
const res = await call();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as CapabilitiesBody;
|
||||
|
||||
// Length AND membership — the endpoint cannot silently drift from the
|
||||
// registry (the whole reason it exists).
|
||||
expect(body.data.derivedMetricIds.length).toBe(DERIVED_METRIC_IDS.length);
|
||||
expect([...body.data.derivedMetricIds].sort()).toEqual(
|
||||
[...DERIVED_METRIC_IDS].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("ingest.writeAllowlist deep-equals WRITABLE_MEASUREMENT_SOURCES", async () => {
|
||||
const res = await call();
|
||||
const body = (await res.json()) as CapabilitiesBody;
|
||||
expect(body.data.ingest.writeAllowlist).toEqual([
|
||||
...WRITABLE_MEASUREMENT_SOURCES,
|
||||
]);
|
||||
});
|
||||
|
||||
it("computedScores are the three persisted wellness scores", async () => {
|
||||
const res = await call();
|
||||
const body = (await res.json()) as CapabilitiesBody;
|
||||
expect([...body.data.ingest.computedScores].sort()).toEqual(
|
||||
["RECOVERY_SCORE", "STRAIN_SCORE", "STRESS_SCORE"],
|
||||
);
|
||||
});
|
||||
|
||||
it("fhir constants mirror the FHIR builder source", async () => {
|
||||
const res = await call();
|
||||
const body = (await res.json()) as CapabilitiesBody;
|
||||
expect(body.data.fhir.atcSystem).toBe(ATC_SYSTEM);
|
||||
expect(body.data.fhir.snomedRoute).toBe(SNOMED_SYSTEM);
|
||||
expect(body.data.fhir.germanAtcDefaultLocales).toEqual([
|
||||
...GERMAN_ATC_DEFAULT_LOCALES,
|
||||
]);
|
||||
});
|
||||
|
||||
it("quantityTypes are sourced from the HealthKit ingest mapping", async () => {
|
||||
const res = await call();
|
||||
const body = (await res.json()) as CapabilitiesBody;
|
||||
|
||||
// Every emitted quantity row must trace back to a real mapping entry —
|
||||
// never an entry the ingest path doesn't actually accept.
|
||||
const byHk = new Map(
|
||||
Object.values(APPLE_HEALTH_TYPE_MAP).map((m) => [m.hkIdentifier, m]),
|
||||
);
|
||||
expect(body.data.ingest.quantityTypes.length).toBeGreaterThan(0);
|
||||
for (const q of body.data.ingest.quantityTypes) {
|
||||
const mapping = byHk.get(q.hk);
|
||||
expect(mapping).toBeDefined();
|
||||
expect(q.type).toBe(mapping!.measurementType);
|
||||
expect(q.unit).toBe(mapping!.dbUnit);
|
||||
// Quantity rows are the non-event-class mappings.
|
||||
expect(
|
||||
Boolean(
|
||||
mapping!.eventClassificationMap || mapping!.fallbackClassification,
|
||||
),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("apiContractVersion is a non-empty string", async () => {
|
||||
const res = await call();
|
||||
const body = (await res.json()) as CapabilitiesBody;
|
||||
expect(typeof body.data.apiContractVersion).toBe("string");
|
||||
expect(body.data.apiContractVersion.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* GET /api/meta/capabilities
|
||||
*
|
||||
* Live capability / discovery surface for the native client. Returns the
|
||||
* server's REAL id vocabularies — derived-metric ids, vitals-baseline
|
||||
* types, insights layout tile-ids, metric-status ids, the HealthKit ingest
|
||||
* mapping, and the FHIR coding constants — plus the running API contract
|
||||
* version. The iOS app reads this once on launch (or on a version bump) and
|
||||
* gates its UI / decoder against what the server actually ships, retiring
|
||||
* the recurring "doc says N, server ships M" enum-drift class.
|
||||
*
|
||||
* Auth: cookie session OR Bearer token (`requireAuth`). NOT admin — every
|
||||
* authenticated client may discover the contract.
|
||||
*
|
||||
* DESIGN RULE: every list is SOURCED from the existing canonical server
|
||||
* constant / Zod enum / registry — never hand-duplicated here. Adding a
|
||||
* derived metric, a tile, a writable source, or a HealthKit mapping
|
||||
* auto-updates this endpoint, so it cannot drift from the real registries.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
import packageJson from "../../../../../package.json";
|
||||
|
||||
import {
|
||||
DERIVED_METRIC_IDS,
|
||||
VITALS_BASELINE_TYPES,
|
||||
} from "@/lib/insights/derived/registry";
|
||||
import { INSIGHTS_TILE_IDS } from "@/lib/insights-layout";
|
||||
import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry";
|
||||
import { WELLNESS_SCORE_TYPES } from "@/lib/insights/derived/wellness-scores";
|
||||
import { WRITABLE_MEASUREMENT_SOURCES } from "@/lib/validations/measurement";
|
||||
import {
|
||||
APPLE_HEALTH_TYPE_MAP,
|
||||
type AppleHealthMapping,
|
||||
} from "@/lib/measurements/apple-health-mapping";
|
||||
import {
|
||||
ATC_SYSTEM,
|
||||
SNOMED_SYSTEM,
|
||||
GERMAN_ATC_DEFAULT_LOCALES,
|
||||
} from "@/lib/fhir/build-bundle";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* An EVENT-class HealthKit identifier carries a device-produced verdict, not
|
||||
* a continuous reading — it is marked by an `eventClassificationMap` or a
|
||||
* `fallbackClassification` in the ingest mapping. Everything else is a
|
||||
* quantity sample. Splitting the single mapping by this predicate keeps the
|
||||
* two ingest lists derived from one registry.
|
||||
*/
|
||||
function isEventMapping(m: AppleHealthMapping): boolean {
|
||||
return Boolean(m.eventClassificationMap || m.fallbackClassification);
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAuth();
|
||||
annotate({ action: { name: "meta.capabilities.read" } });
|
||||
|
||||
// Mirror the /api/version source so the contract version cannot drift from
|
||||
// the running build's reported version (build-arg env wins over the
|
||||
// package.json fallback for local dev).
|
||||
const apiContractVersion =
|
||||
process.env.NEXT_PUBLIC_APP_VERSION?.trim() || packageJson.version;
|
||||
|
||||
const mappings = Object.values(APPLE_HEALTH_TYPE_MAP);
|
||||
|
||||
const quantityTypes = mappings
|
||||
.filter((m) => !isEventMapping(m))
|
||||
.map((m) => ({
|
||||
type: m.measurementType,
|
||||
hk: m.hkIdentifier,
|
||||
unit: m.dbUnit,
|
||||
}));
|
||||
|
||||
// Distinct event-class MeasurementTypes (several HK event identifiers can
|
||||
// share one MeasurementType, e.g. the audio-exposure pair).
|
||||
const eventTypes = Array.from(
|
||||
new Set(mappings.filter(isEventMapping).map((m) => m.measurementType)),
|
||||
);
|
||||
|
||||
return apiSuccess({
|
||||
apiContractVersion,
|
||||
derivedMetricIds: DERIVED_METRIC_IDS,
|
||||
vitalsBaselineTypes: VITALS_BASELINE_TYPES,
|
||||
layoutTileIds: INSIGHTS_TILE_IDS,
|
||||
metricStatusIds: METRIC_STATUS_IDS,
|
||||
ingest: {
|
||||
quantityTypes,
|
||||
eventTypes,
|
||||
computedScores: Object.values(WELLNESS_SCORE_TYPES),
|
||||
writeAllowlist: WRITABLE_MEASUREMENT_SOURCES,
|
||||
},
|
||||
fhir: {
|
||||
atcSystem: ATC_SYSTEM,
|
||||
snomedRoute: SNOMED_SYSTEM,
|
||||
germanAtcDefaultLocales: GERMAN_ATC_DEFAULT_LOCALES,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -87,13 +87,13 @@ const PR_DETECTION_SILENT_THRESHOLD = 50;
|
||||
const BATCH_RATE_LIMIT_MAX = 60;
|
||||
const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
// 5 MB request-body ceiling. The route geometry is the largest tail in
|
||||
// a workout payload; with the 20 000-point cap on a single LineString
|
||||
// AND the 100-workout cap per batch, the worst-case body is bounded
|
||||
// well below this ceiling for typical lon/lat encodings. A request
|
||||
// above this ceiling almost certainly indicates a misbehaving client
|
||||
// or an attempted DoS — return 413 so the iOS client falls back to
|
||||
// one-workout-per-call.
|
||||
// 5 MB request-body ceiling (pre-existing). The route geometry is the
|
||||
// largest tail in a workout payload; a typical batch sits comfortably
|
||||
// under this, but the 20 000-point LineString cap combined with the
|
||||
// 100-workout batch cap allows a theoretical tens-of-MB body for an
|
||||
// extreme GPS-heavy backlog. Such a body returns 413 so the iOS client
|
||||
// falls back to smaller / one-workout-per-call batches — which is also
|
||||
// the correct response to a misbehaving client or an attempted DoS.
|
||||
const MAX_BODY_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
@@ -175,7 +175,16 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
return apiError("Too many batch submissions, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson<unknown>(request);
|
||||
// The Content-Length pre-flight above bounds the common case, but a
|
||||
// chunked upload carries no Content-Length. The `safeJson` body cap
|
||||
// backstops that gap: the raw text is measured before `JSON.parse`,
|
||||
// so an over-limit chunked body returns a clean 413 before the parse
|
||||
// cost. Reuses the same 5 MB ceiling — a typical iOS batch sits under
|
||||
// it; an extreme GPS-heavy backlog that exceeds it falls back to
|
||||
// smaller batches client-side.
|
||||
const { data: rawBody, error: jsonError } = await safeJson<unknown>(request, {
|
||||
maxBytes: MAX_BODY_BYTES,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
// Distinguish "too many entries" from "validation failed" so the
|
||||
|
||||
@@ -11,7 +11,9 @@ import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
@@ -101,7 +103,22 @@ export default function InsightsBlutdruckPage() {
|
||||
description={t("insights.subPage.blutdruckDescription")}
|
||||
explainerMetric="bloodPressure"
|
||||
coachLaunch
|
||||
diversityNudge={
|
||||
<MeasurementDiversityNudge
|
||||
measurementType="BLOOD_PRESSURE_SYS"
|
||||
metricLabel={t("insights.bloodPressureSectionTitle")}
|
||||
timeZone={user?.timezone ?? undefined}
|
||||
/>
|
||||
}
|
||||
showAllValuesType="BLOOD_PRESSURE_SYS"
|
||||
/* No `statStrip`: `<MetricStatStrip>` is single-series, but blood
|
||||
pressure is two series (systolic + diastolic). A single min / max /
|
||||
median / mean strip would silently drop the diastolic half, so the
|
||||
page omits it rather than mislead. */
|
||||
>
|
||||
{/* The period-over-period delta keys on the systolic series — the
|
||||
canonical BP figure the targets + status surfaces lead with. */}
|
||||
<MetricRangeControls measurementType="BLOOD_PRESSURE_SYS" enabled={!isEmpty} />
|
||||
<HealthChartDynamic
|
||||
chartKey="bp"
|
||||
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
|
||||
@@ -112,6 +113,15 @@ export default function InsightsBmiPage() {
|
||||
explainerMetric="bmi"
|
||||
coachLaunch
|
||||
>
|
||||
{/* No `statStrip` / `diversityNudge` / `showAllValuesType`: BMI is a
|
||||
derived metric (computed from the WEIGHT series + the user's height),
|
||||
so it has no first-class summary or raw-reading rows of its own. A
|
||||
stat strip would have nothing to read, and a diversity nudge / "show
|
||||
all values" entry would point at WEIGHT and duplicate the weight
|
||||
page's controls.
|
||||
BMI is derived from WEIGHT, so the period-over-period delta keys on
|
||||
the underlying weight series the chart reuses. */}
|
||||
<MetricRangeControls measurementType="WEIGHT" enabled={!isEmpty} />
|
||||
<HealthChartDynamic
|
||||
chartKey="bmi"
|
||||
types={["WEIGHT"]}
|
||||
|
||||
@@ -193,6 +193,9 @@ export default function InsightsMedikamentePage() {
|
||||
explainerMetric="medications"
|
||||
coachLaunch
|
||||
>
|
||||
{/* No `<MetricRangeControls>` here: medication compliance is
|
||||
event-driven, not a MeasurementType series, so the period-over-period
|
||||
range read has nothing to aggregate. */}
|
||||
<div
|
||||
className={
|
||||
medications.length >= 2 ? "grid gap-4 sm:grid-cols-2" : "grid gap-4"
|
||||
|
||||
@@ -100,6 +100,10 @@ export default function InsightsStimmungPage() {
|
||||
explainerMetric="mood"
|
||||
coachLaunch
|
||||
>
|
||||
{/* No `<MetricRangeControls>` here: mood is event-driven, not a
|
||||
MeasurementType series, so the period-over-period range read
|
||||
(`/api/analytics/range`, keyed on a MeasurementType enum) has
|
||||
nothing to aggregate. */}
|
||||
<MoodChart
|
||||
chartKey="mood"
|
||||
compareBaseline={compareBaseline}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
|
||||
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
|
||||
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { Vo2MaxChartRow } from "@/components/insights/vo2-max-chart-row";
|
||||
@@ -131,6 +132,7 @@ export default function InsightsPulsPage() {
|
||||
coachLaunch
|
||||
showAllValuesType="PULSE"
|
||||
>
|
||||
<MetricRangeControls measurementType="PULSE" enabled={!isEmpty} />
|
||||
<HealthChartDynamic
|
||||
chartKey="pulse"
|
||||
types={["PULSE"]}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import {
|
||||
CompositeScoreAnatomy,
|
||||
@@ -68,6 +71,20 @@ export default function CompositeScorePage() {
|
||||
<SubPageShell
|
||||
title={t(header.title)}
|
||||
description={t(header.description)}
|
||||
backLink={
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-slot="composite-score-back"
|
||||
className="-ml-2 w-fit"
|
||||
>
|
||||
<Link href="/insights">
|
||||
<ArrowLeft className="mr-1 size-4" aria-hidden="true" />
|
||||
{t("insights.subPage.scoresBack")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
coachLaunch
|
||||
>
|
||||
<CompositeScoreAnatomy metric={metric} />
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
import Link from "next/link";
|
||||
import { Moon } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
|
||||
import { MetricStatusCard } from "@/components/insights/metric-status-card";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { SleepOverview } from "@/components/insights/sleep-overview";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
@@ -30,6 +33,7 @@ import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
*/
|
||||
export default function InsightsSchlafPage() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { isEmpty } = useInsightsAnalytics("SLEEP_DURATION");
|
||||
|
||||
@@ -63,7 +67,21 @@ export default function InsightsSchlafPage() {
|
||||
description={t("insights.sleep.description")}
|
||||
explainerMetric="sleep"
|
||||
coachLaunch
|
||||
diversityNudge={
|
||||
<MeasurementDiversityNudge
|
||||
measurementType="SLEEP_DURATION"
|
||||
metricLabel={t("insights.sleep.title")}
|
||||
timeZone={user?.timezone ?? undefined}
|
||||
/>
|
||||
}
|
||||
showAllValuesType="SLEEP_DURATION"
|
||||
/* No `statStrip`: `<SleepOverview>` already leads with the average
|
||||
nightly total + per-stage breakdown, so a duration-in-minutes
|
||||
min / max / median / mean strip would duplicate it and read in an
|
||||
awkward unit. */
|
||||
>
|
||||
<MetricRangeControls measurementType="SLEEP_DURATION" enabled={!isEmpty} />
|
||||
|
||||
<SleepOverview />
|
||||
|
||||
<MetricTargetSummary slug="sleep" />
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
import { use } from "react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { notFound, useSearchParams } from "next/navigation";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
INSIGHTS_OVERVIEW,
|
||||
resolveValuesBackHref,
|
||||
} from "@/lib/insights/values-back-link";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -33,6 +37,7 @@ export default function InsightsMetricValuesPage({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { type } = use(params);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Reject any segment that is not a real MeasurementType so a typo'd
|
||||
// deep-link 404s instead of mounting a blank list.
|
||||
@@ -43,6 +48,16 @@ export default function InsightsMetricValuesPage({
|
||||
const labelKey = MEASUREMENT_TYPE_LABEL_KEYS[type];
|
||||
const metricLabel = labelKey ? t(labelKey) : type;
|
||||
|
||||
// v1.10.2 — return to the originating metric page when the link carried a
|
||||
// `from` param (set by `<SubPageShell>` on the "show all readings" link),
|
||||
// so a `weight → show all values` drill-in lands back on `weight` rather
|
||||
// than the Insights overview. `resolveValuesBackHref` sanitises the value to
|
||||
// an internal `/insights/<slug>` path so a crafted `?from=` can never become
|
||||
// an off-site or protocol-relative target; anything else falls back to the
|
||||
// overview.
|
||||
const backHref = resolveValuesBackHref(searchParams.get("from"));
|
||||
const backToOrigin = backHref !== INSIGHTS_OVERVIEW;
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("insights.subPage.valuesPageTitle", { metric: metricLabel })}
|
||||
@@ -55,9 +70,11 @@ export default function InsightsMetricValuesPage({
|
||||
data-slot="metric-values-back"
|
||||
className="-ml-2 w-fit"
|
||||
>
|
||||
<Link href="/insights">
|
||||
<Link href={backHref}>
|
||||
<ArrowLeft className="mr-1 size-4" aria-hidden="true" />
|
||||
{t("insights.subPage.valuesBack")}
|
||||
{backToOrigin
|
||||
? t("insights.subPage.valuesBackToMetric", { metric: metricLabel })
|
||||
: t("insights.subPage.valuesBack")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
|
||||
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
|
||||
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { buildWeightBandsFromHeight } from "@/lib/analytics/value-bands";
|
||||
@@ -88,6 +89,7 @@ export default function InsightsGewichtPage() {
|
||||
coachLaunch
|
||||
showAllValuesType="WEIGHT"
|
||||
>
|
||||
<MetricRangeControls measurementType="WEIGHT" enabled={!isEmpty} />
|
||||
<HealthChartDynamic
|
||||
chartKey="weight"
|
||||
types={["WEIGHT"]}
|
||||
|
||||
@@ -37,6 +37,9 @@ export default function InsightsWorkoutsPage() {
|
||||
explainerMetric="workouts"
|
||||
coachLaunch
|
||||
>
|
||||
{/* No `<MetricRangeControls>` here: workouts are session records, not a
|
||||
MeasurementType series, so the period-over-period range read has
|
||||
nothing to aggregate. */}
|
||||
{isLoading ? (
|
||||
<div data-slot="workouts-loading" className="space-y-2">
|
||||
<Skeleton className="h-14 w-full rounded-lg" />
|
||||
|
||||
@@ -84,14 +84,9 @@ export default function IntakeHistoryPage({
|
||||
<div className="space-y-6">
|
||||
{/* Back to the medication detail page — history is a drill-down
|
||||
OF the medication, not a sibling of the list. */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground -ml-2 gap-1"
|
||||
asChild
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="-ml-2 w-fit" asChild>
|
||||
<Link href={`/medications/${id}`}>
|
||||
<ArrowLeft aria-hidden="true" className="h-4 w-4" />
|
||||
<ArrowLeft aria-hidden="true" className="mr-1 size-4" />
|
||||
{t("medications.detail.history.back")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -165,14 +165,9 @@ export default function MedicationDetailPage({
|
||||
if (isError || !medication) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground -ml-2 gap-1"
|
||||
asChild
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="-ml-2 w-fit" asChild>
|
||||
<Link href="/medications">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<ArrowLeft className="mr-1 size-4" aria-hidden="true" />
|
||||
{t("medications.back")}
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -198,14 +193,9 @@ export default function MedicationDetailPage({
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-slot="medication-detail-page">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground -ml-2 gap-1"
|
||||
asChild
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="-ml-2 w-fit" asChild>
|
||||
<Link href="/medications">
|
||||
<ArrowLeft aria-hidden="true" className="h-4 w-4" />
|
||||
<ArrowLeft aria-hidden="true" className="mr-1 size-4" />
|
||||
{t("medications.back")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -236,9 +236,9 @@ export function RemindersSection() {
|
||||
{testNotification.data.results.map((r, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 text-xs">
|
||||
{r.success ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-green-400" />
|
||||
<CheckCircle2 className="text-success dark:text-green-400 h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<XCircle className="h-3.5 w-3.5 shrink-0 text-red-400" />
|
||||
<XCircle className="text-destructive dark:text-red-400 h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="font-medium">{r.channel}</span>
|
||||
{r.error && (
|
||||
@@ -281,11 +281,11 @@ export function RemindersSection() {
|
||||
{med.schedules.map((sched, j) => {
|
||||
const statusColor =
|
||||
sched.status === "open"
|
||||
? "text-green-400"
|
||||
? "text-success dark:text-green-400"
|
||||
: sched.status === "threshold"
|
||||
? "text-yellow-400"
|
||||
? "text-warning dark:text-yellow-400"
|
||||
: sched.status === "missed"
|
||||
? "text-red-400"
|
||||
? "text-destructive dark:text-red-400"
|
||||
: sched.status === "skipped"
|
||||
? "text-muted-foreground"
|
||||
: "";
|
||||
@@ -299,7 +299,7 @@ export function RemindersSection() {
|
||||
</span>
|
||||
<span className={statusColor}>{sched.label}</span>
|
||||
{sched.notificationSent && (
|
||||
<span className="flex shrink-0 items-center gap-0.5 text-green-400">
|
||||
<span className="text-success dark:text-green-400 flex shrink-0 items-center gap-0.5">
|
||||
<Bell className="h-3 w-3" />
|
||||
{t("admin.reminderCheckNotifSent")}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.10.2 — `<MetricRangeControls>` is the shared range-controls block lifted
|
||||
* out of `<HealthKitMetricPage>` so the bespoke metric sub-pages render the
|
||||
* identical time-range pills + period-over-period delta. The component owns
|
||||
* the range pref + range read + direction-sentiment lookup; the test mocks
|
||||
* those hooks so the render stays deterministic and never touches `fetch`.
|
||||
*/
|
||||
|
||||
const rangeMock = vi.fn();
|
||||
vi.mock("@/hooks/use-insights-layout-prefs", () => ({
|
||||
useInsightsRangePref: () => ({ range: "30d", setRange: () => {} }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-analytics-range", () => ({
|
||||
useAnalyticsRange: (...args: unknown[]) => rangeMock(...args),
|
||||
}));
|
||||
|
||||
import { MetricRangeControls } from "../metric-range-controls";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
rangeMock.mockReset();
|
||||
rangeMock.mockReturnValue({ data: undefined, isLoading: false });
|
||||
});
|
||||
|
||||
describe("<MetricRangeControls>", () => {
|
||||
it("renders the time-range pills and the delta slot", () => {
|
||||
const html = render(<MetricRangeControls measurementType="WEIGHT" />);
|
||||
expect(html).toContain('data-slot="metric-range-controls"');
|
||||
expect(html).toContain('data-slot="time-range-pills"');
|
||||
});
|
||||
|
||||
it("passes the measurement type and enabled flag through to the range read", () => {
|
||||
render(<MetricRangeControls measurementType="PULSE" enabled={false} />);
|
||||
expect(rangeMock).toHaveBeenCalledWith("PULSE", "30d", false);
|
||||
});
|
||||
|
||||
it("defaults the range read to enabled", () => {
|
||||
render(<MetricRangeControls measurementType="SLEEP_DURATION" />);
|
||||
expect(rangeMock).toHaveBeenCalledWith("SLEEP_DURATION", "30d", true);
|
||||
});
|
||||
});
|
||||
@@ -64,10 +64,12 @@ function insufficient(reason: string, historyDays = 0): Resp {
|
||||
*/
|
||||
function mockBatch(
|
||||
resolve: (token: DerivedBatchToken) => Resp,
|
||||
isLoading = false,
|
||||
extra: { isLoading?: boolean; isError?: boolean; refetch?: () => void } = {},
|
||||
) {
|
||||
useDerivedBatch.mockReturnValue({
|
||||
isLoading,
|
||||
isLoading: extra.isLoading ?? false,
|
||||
isError: extra.isError ?? false,
|
||||
refetch: extra.refetch ?? (() => {}),
|
||||
read: <T,>(token: DerivedBatchToken) => resolve(token) as unknown as DerivedMetricResponse<T>,
|
||||
});
|
||||
}
|
||||
@@ -90,13 +92,38 @@ describe("<VitalsDashboard>", () => {
|
||||
expect(html).toContain("Typical for you");
|
||||
});
|
||||
|
||||
it("hides an absent vital entirely (no tile)", () => {
|
||||
it("hides the whole section (heading + grid) when no vital has content", () => {
|
||||
mockBatch(() => insufficient("no_readings_in_window"));
|
||||
const html = render(<VitalsDashboard />);
|
||||
// The grid + heading still render, but no value tile.
|
||||
expect(html).toContain('data-slot="vitals-dashboard-grid"');
|
||||
// No content under the heading → the heading must not strand over blank
|
||||
// space. The whole section un-mounts.
|
||||
expect(html).not.toContain('data-slot="vitals-dashboard"');
|
||||
expect(html).not.toContain('data-slot="vitals-dashboard-grid"');
|
||||
expect(html).not.toContain("Your vitals");
|
||||
expect(html).not.toContain('data-state="ok"');
|
||||
expect(html).not.toContain('data-metric="WEIGHT"');
|
||||
});
|
||||
|
||||
it("renders a skeleton row (no tiles, no heading strand) while loading", () => {
|
||||
mockBatch(() => insufficient("no_readings_in_window"), { isLoading: true });
|
||||
const html = render(<VitalsDashboard />);
|
||||
// Heading + grid present so the section reserves its final height, with a
|
||||
// busy/live region and skeleton placeholders — but no real tiles yet.
|
||||
expect(html).toContain('data-slot="vitals-dashboard"');
|
||||
expect(html).toContain('aria-busy="true"');
|
||||
expect(html).toContain('aria-live="polite"');
|
||||
expect(html).toContain('data-slot="vitals-tile-skeleton"');
|
||||
expect(html).not.toContain('data-slot="vitals-tile"');
|
||||
});
|
||||
|
||||
it("shows an inline error + retry when the batch fails", () => {
|
||||
mockBatch(() => insufficient("no_readings_in_window"), { isError: true });
|
||||
const html = render(<VitalsDashboard />);
|
||||
// The surface must not silently vanish — a compact error + a Retry button.
|
||||
expect(html).toContain('data-slot="vitals-dashboard-error"');
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain('data-slot="vitals-dashboard-retry"');
|
||||
expect(html).toContain("Could not load your vitals");
|
||||
expect(html).toContain("Retry");
|
||||
});
|
||||
|
||||
it("shows a provisional baseline tile with the coverage meter, not a headline", () => {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Gauge, HeartPulse, Scale } from "lucide-react";
|
||||
import { Gauge, HeartPulse, RefreshCw, Scale } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { getUnitForType } from "@/lib/validations/measurement";
|
||||
@@ -409,6 +411,85 @@ function CoincidentDeviationTile({ read, isLoading }: TileProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A loading placeholder occupying the resolved tile footprint so the grid
|
||||
* reserves its final height and the real tiles drop in without a layout
|
||||
* shift. Mirrors the SparklineDeltaTile geometry (card + label row + value
|
||||
* row + framing line) with `Skeleton` blocks. Decorative — `aria-hidden`.
|
||||
*/
|
||||
function VitalsTileSkeleton() {
|
||||
return (
|
||||
<div
|
||||
data-slot="vitals-tile-skeleton"
|
||||
aria-hidden="true"
|
||||
className="bg-card border-border flex h-full w-full min-w-0 flex-col gap-3 rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-4 w-4 rounded" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the resolved batch holds at least one vital tile worth painting.
|
||||
* Mirrors the per-tile gates so the section heading is only rendered once
|
||||
* there is content under it (an empty heading stranded over blank space was
|
||||
* the CLS the load-state QoL pass closes). Kept in sync with the tile gates
|
||||
* above — if a new tile gate changes, reflect it here.
|
||||
*/
|
||||
function hasRenderableVital(read: DerivedBatchRead): boolean {
|
||||
const coincident = read<CoincidentDeviationValue>({
|
||||
metric: "COINCIDENT_DEVIATION",
|
||||
});
|
||||
if (
|
||||
coincident?.status === "ok" &&
|
||||
coincident.value?.fired &&
|
||||
coincident.value.contributing.length > 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const fitness = read<FitnessAgeValue>({ metric: "FITNESS_AGE" });
|
||||
if (fitness?.status === "ok" && fitness.value) return true;
|
||||
|
||||
const vascular = read<VascularAgeDeltaValue>({ metric: "VASCULAR_AGE_DELTA" });
|
||||
if (vascular?.status === "ok" && vascular.value) return true;
|
||||
|
||||
const bmi = read<BmiValue>({ metric: "BMI" });
|
||||
if (bmi?.status === "ok" && bmi.value) return true;
|
||||
|
||||
const hrv = read<HrvBalanceValue>({ metric: "HRV_BALANCE" });
|
||||
if (
|
||||
hrv &&
|
||||
!(hrv.status === "insufficient" && hrv.reason === "no_readings_in_window")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const type of SECTION_VITALS) {
|
||||
if (type === "HEART_RATE_VARIABILITY") continue;
|
||||
const baseline = read<VitalsBaselineValue>({
|
||||
metric: "VITALS_BASELINE",
|
||||
type,
|
||||
});
|
||||
if (
|
||||
baseline &&
|
||||
!(
|
||||
baseline.status === "insufficient" &&
|
||||
baseline.reason === "no_readings_in_window"
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full set of tokens the dashboard reads in one batch — the five
|
||||
* wellness scores + the four derived re-frames + the coincident-deviation
|
||||
@@ -445,35 +526,99 @@ export function VitalsDashboard({ enabled = true, className }: DashboardProps) {
|
||||
});
|
||||
const read = batch.read;
|
||||
const isLoading = batch.isLoading;
|
||||
const isError = batch.isError;
|
||||
const refetch = batch.refetch;
|
||||
const tileProps: TileProps = { read, isLoading };
|
||||
|
||||
// A failed batch (retry:0 + the 8 s client ceiling) must read as an error,
|
||||
// not as "no data" — otherwise the whole Vitals + Wellness surface silently
|
||||
// vanishes. Surface a compact message + a Retry that refetches the one
|
||||
// batch query both strips share.
|
||||
if (isError) {
|
||||
return (
|
||||
<div
|
||||
data-slot="vitals-dashboard-wrap"
|
||||
className={cn("space-y-6", className)}
|
||||
>
|
||||
<section
|
||||
data-slot="vitals-dashboard"
|
||||
aria-label={t("insights.derived.vitals.sectionTitle")}
|
||||
className="space-y-3"
|
||||
>
|
||||
<h2 className="text-foreground text-sm font-semibold tracking-tight">
|
||||
{t("insights.derived.vitals.sectionTitle")}
|
||||
</h2>
|
||||
<div
|
||||
data-slot="vitals-dashboard-error"
|
||||
role="alert"
|
||||
className="bg-card border-border text-muted-foreground flex flex-col items-start gap-3 rounded-xl border p-4 text-sm sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<span>{t("insights.derived.vitals.loadError")}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
data-slot="vitals-dashboard-retry"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{t("common.retry")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The heading only renders once there is content under it (or while the
|
||||
// skeleton row reserves the final height). When the batch resolves to zero
|
||||
// vitals the heading would otherwise strand over blank space, then content
|
||||
// pops in below it — the CLS this pass removes.
|
||||
const showSection = isLoading || hasRenderableVital(read);
|
||||
|
||||
return (
|
||||
<div data-slot="vitals-dashboard-wrap" className={cn("space-y-6", className)}>
|
||||
<WellnessScores read={read} isLoading={isLoading} />
|
||||
<section
|
||||
data-slot="vitals-dashboard"
|
||||
aria-label={t("insights.derived.vitals.sectionTitle")}
|
||||
className="space-y-3"
|
||||
>
|
||||
<h2 className="text-foreground text-sm font-semibold tracking-tight">
|
||||
{t("insights.derived.vitals.sectionTitle")}
|
||||
</h2>
|
||||
<div
|
||||
data-slot="vitals-dashboard-grid"
|
||||
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"
|
||||
{showSection && (
|
||||
<section
|
||||
data-slot="vitals-dashboard"
|
||||
aria-label={t("insights.derived.vitals.sectionTitle")}
|
||||
className="space-y-3"
|
||||
>
|
||||
<CoincidentDeviationTile {...tileProps} />
|
||||
<FitnessAgeTile {...tileProps} />
|
||||
<VascularAgeTile {...tileProps} />
|
||||
<HrvBalanceTile {...tileProps} />
|
||||
<BmiTile {...tileProps} />
|
||||
{vitals
|
||||
.filter((type) => type !== "HEART_RATE_VARIABILITY")
|
||||
.map((type) => (
|
||||
<BaselineTile key={type} type={type} {...tileProps} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<h2 className="text-foreground text-sm font-semibold tracking-tight">
|
||||
{t("insights.derived.vitals.sectionTitle")}
|
||||
</h2>
|
||||
<div
|
||||
data-slot="vitals-dashboard-grid"
|
||||
aria-busy={isLoading}
|
||||
aria-live="polite"
|
||||
aria-label={
|
||||
isLoading ? t("insights.derived.vitals.loadingLabel") : undefined
|
||||
}
|
||||
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
{isLoading ? (
|
||||
Array.from({ length: 6 }).map((_, i) => (
|
||||
<VitalsTileSkeleton key={`vitals-skeleton-${i}`} />
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<CoincidentDeviationTile {...tileProps} />
|
||||
<FitnessAgeTile {...tileProps} />
|
||||
<VascularAgeTile {...tileProps} />
|
||||
<HrvBalanceTile {...tileProps} />
|
||||
<BmiTile {...tileProps} />
|
||||
{vitals
|
||||
.filter((type) => type !== "HEART_RATE_VARIABILITY")
|
||||
.map((type) => (
|
||||
<BaselineTile key={type} type={type} {...tileProps} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,21 +7,11 @@ import { Sparkles } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import {
|
||||
useInsightsLayoutPrefs,
|
||||
useInsightsRangePref,
|
||||
} from "@/hooks/use-insights-layout-prefs";
|
||||
import { useAnalyticsRange } from "@/hooks/use-analytics-range";
|
||||
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { InsightMetric } from "@/lib/insights/metric-availability";
|
||||
import {
|
||||
getMetricStatusMeta,
|
||||
metricIdForMeasurementType,
|
||||
} from "@/lib/insights/metric-status-registry";
|
||||
import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry";
|
||||
import { sentimentFromMetricDirection } from "@/lib/insights/trend-sentiment";
|
||||
import type { ChartOverlayKey } from "@/lib/dashboard-layout";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { MetricStatusCard } from "@/components/insights/metric-status-card";
|
||||
@@ -29,8 +19,7 @@ import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
|
||||
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { TimeRangePills } from "@/components/insights/time-range-pills";
|
||||
import { MetricRangeDelta } from "@/components/insights/metric-range-delta";
|
||||
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
|
||||
/**
|
||||
@@ -151,30 +140,9 @@ export function HealthKitMetricPage({
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
|
||||
const { range, setRange } = useInsightsRangePref();
|
||||
|
||||
const { data: analytics, isEmpty } = useInsightsAnalytics(insightMetric);
|
||||
|
||||
// v1.9.0 — period-over-period range read. Single-metric, additive route;
|
||||
// gated off the empty-data branch so a brand-new metric never fires it.
|
||||
const { data: rangeData, isLoading: isRangeLoading } = useAnalyticsRange(
|
||||
measurementType,
|
||||
range,
|
||||
!isEmpty,
|
||||
);
|
||||
|
||||
// The delta's sentiment colour follows the metric's "good direction" from
|
||||
// the metric-status registry (higher-better → up-good, lower-better →
|
||||
// up-bad, target-band → neutral). Every HealthKit metric page is backed by
|
||||
// a registry entry; a metric absent from the registry falls back to neutral.
|
||||
const registryMeta = (() => {
|
||||
const id = metricIdForMeasurementType(measurementType as MeasurementType);
|
||||
return id ? getMetricStatusMeta(id) : null;
|
||||
})();
|
||||
const directionSentiment = registryMeta
|
||||
? sentimentFromMetricDirection(registryMeta.direction)
|
||||
: "neutral";
|
||||
|
||||
const rawSummary = analytics?.summaries?.[measurementType] ?? null;
|
||||
// The stat strip renders display-unit values. The summary holds stored
|
||||
// values, so when the page renders a scaled unit (e.g. WALKING_SPEED
|
||||
@@ -242,19 +210,10 @@ export function HealthKitMetricPage({
|
||||
>
|
||||
{/* v1.9.0 — time-range pills + period-over-period delta, between the
|
||||
stat strip and the chart. The selected range persists across metrics
|
||||
via `useInsightsRangePref`. */}
|
||||
<div
|
||||
data-slot="metric-range-controls"
|
||||
className="flex flex-wrap items-center justify-between gap-2"
|
||||
>
|
||||
<TimeRangePills value={range} onChange={setRange} />
|
||||
<MetricRangeDelta
|
||||
data={rangeData}
|
||||
range={range}
|
||||
directionSentiment={directionSentiment}
|
||||
isLoading={isRangeLoading}
|
||||
/>
|
||||
</div>
|
||||
via `useInsightsRangePref`.
|
||||
v1.10.2 — extracted into the shared `<MetricRangeControls>` so the
|
||||
bespoke metric sub-pages render the identical control. */}
|
||||
<MetricRangeControls measurementType={measurementType} enabled={!isEmpty} />
|
||||
<HealthChartDynamic
|
||||
chartKey={chartKey}
|
||||
types={[measurementType]}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useInsightsRangePref } from "@/hooks/use-insights-layout-prefs";
|
||||
import { useAnalyticsRange } from "@/hooks/use-analytics-range";
|
||||
import {
|
||||
getMetricStatusMeta,
|
||||
metricIdForMeasurementType,
|
||||
} from "@/lib/insights/metric-status-registry";
|
||||
import { sentimentFromMetricDirection } from "@/lib/insights/trend-sentiment";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { TimeRangePills } from "@/components/insights/time-range-pills";
|
||||
import { MetricRangeDelta } from "@/components/insights/metric-range-delta";
|
||||
|
||||
/**
|
||||
* v1.10.2 — shared range-controls block (time-range pills +
|
||||
* period-over-period delta) lifted out of `<HealthKitMetricPage>` so the
|
||||
* bespoke metric sub-pages (`weight`, `blood-pressure`, `pulse`, `mood`,
|
||||
* `sleep`, `bmi`, …) render the identical control.
|
||||
*
|
||||
* Before this, the 37 HealthKit pages carried the selector and the 8
|
||||
* hand-rolled pages carried nothing, so navigating from an HK metric to
|
||||
* `weight` made the control vanish. This component is the single source the
|
||||
* HK scaffold and the bespoke pages both consume, wired to the same
|
||||
* persisted range pref (`useInsightsRangePref`) so the choice sticks across
|
||||
* every metric.
|
||||
*
|
||||
* The delta's sentiment colour follows the metric's "good direction" from
|
||||
* the metric-status registry (higher-better → up-good, lower-better →
|
||||
* up-bad, target-band → neutral); a metric absent from the registry falls
|
||||
* back to neutral.
|
||||
*/
|
||||
export function MetricRangeControls({
|
||||
measurementType,
|
||||
enabled = true,
|
||||
}: {
|
||||
/** The MeasurementType the period-over-period read is keyed on. */
|
||||
measurementType: string;
|
||||
/**
|
||||
* Gate the range read. Pages pass `!isEmpty` so a brand-new metric with
|
||||
* no observations never fires the round-trip. Defaults to `true`.
|
||||
*/
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const { range, setRange } = useInsightsRangePref();
|
||||
const { data: rangeData, isLoading: isRangeLoading } = useAnalyticsRange(
|
||||
measurementType,
|
||||
range,
|
||||
enabled,
|
||||
);
|
||||
|
||||
const registryMeta = (() => {
|
||||
const id = metricIdForMeasurementType(measurementType as MeasurementType);
|
||||
return id ? getMetricStatusMeta(id) : null;
|
||||
})();
|
||||
const directionSentiment = registryMeta
|
||||
? sentimentFromMetricDirection(registryMeta.direction)
|
||||
: "neutral";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="metric-range-controls"
|
||||
className="flex flex-wrap items-center justify-between gap-2"
|
||||
>
|
||||
<TimeRangePills value={range} onChange={setRange} />
|
||||
<MetricRangeDelta
|
||||
data={rangeData}
|
||||
range={range}
|
||||
directionSentiment={directionSentiment}
|
||||
isLoading={isRangeLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ListOrdered } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -129,6 +130,11 @@ export function SubPageShell({
|
||||
children,
|
||||
}: SubPageShellProps) {
|
||||
const { t } = useTranslations();
|
||||
// v1.10.2 — the "show all readings" link carries the originating metric
|
||||
// page as a `from` param so the values sub-page can offer a back-link to
|
||||
// where the user drilled in from (e.g. `weight → show all values → back to
|
||||
// weight`) rather than always returning to the Insights overview.
|
||||
const pathname = usePathname();
|
||||
// a11y: focus the heading on mount so a tab-strip navigation actually
|
||||
// moves screen-reader focus into the sub-page body. v1.4.27 MB7 /
|
||||
// CF-35 made the focus call opt-in via `focusOnMount` — the
|
||||
@@ -254,7 +260,11 @@ export function SubPageShell({
|
||||
data-slot="metric-show-all-values"
|
||||
className="h-10 w-full sm:w-auto"
|
||||
>
|
||||
<Link href={`/insights/values/${showAllValuesType}`}>
|
||||
<Link
|
||||
href={`/insights/values/${showAllValuesType}${
|
||||
pathname ? `?from=${encodeURIComponent(pathname)}` : ""
|
||||
}`}
|
||||
>
|
||||
<ListOrdered className="mr-1.5 size-4" aria-hidden="true" />
|
||||
{t("insights.subPage.showAllValues")}
|
||||
</Link>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
@@ -163,6 +163,17 @@ function render(
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Pin the clock so the schedule-driven window pill never displaces the
|
||||
// upcoming-injection line under a CI run at a live wall-clock. The card
|
||||
// suppresses the next/last line while `now` sits inside a schedule
|
||||
// window, so pin to 07:00 Berlin (before the 08:00–20:00 fixture window)
|
||||
// on a non-scheduled weekday — the upcoming line then always renders.
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-06-02T05:00:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
|
||||
|
||||
@@ -361,7 +361,7 @@ export function IntakeHistoryListV2({
|
||||
{isTaken ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="gap-1 bg-green-500/20 text-xs text-green-400"
|
||||
className="bg-success/15 text-success dark:bg-green-500/20 dark:text-green-400 gap-1 text-xs"
|
||||
>
|
||||
<Check aria-hidden="true" className="h-3 w-3" />
|
||||
{t("medications.intakeHistoryStatusTaken")}
|
||||
|
||||
@@ -145,6 +145,29 @@ function uiToLegacyProviderEnum(p: ProviderType): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Map the connection-test failure category (a stable, secret-free code from
|
||||
// /api/ai/test) to a localised message. Falls back to the server's plain
|
||||
// English `reason` for any unmapped / legacy code so a German operator never
|
||||
// sees an untranslated sentence in the otherwise-localised Settings panel.
|
||||
function localiseTestReason(
|
||||
t: (key: string, params?: Record<string, string | number>) => string,
|
||||
reasonCode: string | undefined,
|
||||
reason: string | undefined,
|
||||
): string | undefined {
|
||||
switch (reasonCode) {
|
||||
case "credentials":
|
||||
return t("settings.ai.testReasonCredentials");
|
||||
case "rate_limited":
|
||||
return t("settings.ai.testReasonRateLimited");
|
||||
case "server_error":
|
||||
return t("settings.ai.testReasonServerError");
|
||||
case "unreachable":
|
||||
return t("settings.ai.testReasonUnreachable");
|
||||
default:
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
|
||||
export function AiSection() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
@@ -1736,7 +1759,34 @@ function RuntimeActionsRow({
|
||||
setTestMsg(null);
|
||||
try {
|
||||
const res = await fetch("/api/ai/test", { method: "POST" });
|
||||
const json = await res.json();
|
||||
// Guard against a non-JSON body. A reverse proxy / Cloudflare can
|
||||
// rewrite an origin error to its own HTML page; parsing that as JSON
|
||||
// throws `Unexpected token '<'` (Safari: "did not match the
|
||||
// expected pattern"). Show a clean message instead of leaking that.
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
setTestOk(false);
|
||||
setTestMsg(t("settings.ai.testUnexpectedResponse"));
|
||||
return;
|
||||
}
|
||||
let json: {
|
||||
data?: {
|
||||
ok?: boolean;
|
||||
providerType?: string;
|
||||
model?: string;
|
||||
reasonCode?: string;
|
||||
reason?: string;
|
||||
} | null;
|
||||
error?: string | null;
|
||||
};
|
||||
try {
|
||||
json = await res.json();
|
||||
} catch {
|
||||
setTestOk(false);
|
||||
setTestMsg(t("settings.ai.testUnexpectedResponse"));
|
||||
return;
|
||||
}
|
||||
// 4xx config errors still arrive via the error envelope.
|
||||
if (!res.ok) {
|
||||
setTestOk(false);
|
||||
setTestMsg(
|
||||
@@ -1746,11 +1796,23 @@ function RuntimeActionsRow({
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Provider-call failures now arrive as 200 + { ok:false, reasonCode }
|
||||
// so the body is never rewritten by a proxy. Map the stable code to a
|
||||
// localised string; fall back to the server's plain `reason` for any
|
||||
// unmapped / legacy code, then to a generic message.
|
||||
if (json.data && json.data.ok === false) {
|
||||
setTestOk(false);
|
||||
setTestMsg(
|
||||
localiseTestReason(t, json.data.reasonCode, json.data.reason) ??
|
||||
t("settings.ai.testFailedShort", { message: `HTTP ${res.status}` }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setTestOk(true);
|
||||
setTestMsg(
|
||||
t("settings.ai.testSuccess", {
|
||||
provider: json.data.providerType,
|
||||
model: json.data.model,
|
||||
provider: json.data?.providerType ?? "",
|
||||
model: json.data?.model ?? "",
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -1774,7 +1836,27 @@ function RuntimeActionsRow({
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ force: true }),
|
||||
});
|
||||
const json = await res.json();
|
||||
// Same defensive guard as the connection test: a proxy can rewrite
|
||||
// a 5xx (e.g. all-providers-failed) to an HTML page, which would
|
||||
// crash `res.json()` with `Unexpected token '<'`.
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
setRegenOk(false);
|
||||
setRegenMsg(
|
||||
res.status === 429
|
||||
? t("settings.regenerateRateLimit")
|
||||
: t("settings.ai.testUnexpectedResponse"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let json: { error?: string | null };
|
||||
try {
|
||||
json = await res.json();
|
||||
} catch {
|
||||
setRegenOk(false);
|
||||
setRegenMsg(t("settings.ai.testUnexpectedResponse"));
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
setRegenOk(false);
|
||||
setRegenMsg(
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { safeJson } from "../api-response";
|
||||
|
||||
// `safeJson`'s opt-in `maxBytes` cap is the DoS ceiling the batch /
|
||||
// export ingest routes lean on. The check runs against the raw text
|
||||
// before `JSON.parse`, so an over-limit body never lands in heap and
|
||||
// the caller gets a clean 413 envelope.
|
||||
|
||||
function jsonRequest(body: string): Request {
|
||||
return new Request("https://example.test/api/x", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
describe("safeJson maxBytes", () => {
|
||||
it("parses a body at or under the cap", async () => {
|
||||
const payload = JSON.stringify({ entries: [1, 2, 3] });
|
||||
const result = await safeJson(jsonRequest(payload), {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.data).toEqual({ entries: [1, 2, 3] });
|
||||
});
|
||||
|
||||
it("rejects an over-limit body with a 413 envelope", async () => {
|
||||
const payload = JSON.stringify({ blob: "x".repeat(2048) });
|
||||
const result = await safeJson(jsonRequest(payload), {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
expect(result.data).toBeUndefined();
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.error!.status).toBe(413);
|
||||
|
||||
const body = (await result.error!.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toContain("1024");
|
||||
});
|
||||
|
||||
it("returns 400 on invalid JSON within the cap", async () => {
|
||||
const result = await safeJson(jsonRequest("{not json"), {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
expect(result.data).toBeUndefined();
|
||||
expect(result.error!.status).toBe(400);
|
||||
});
|
||||
|
||||
it("still rejects a non-JSON content-type before measuring bytes", async () => {
|
||||
const request = new Request("https://example.test/api/x", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "text/plain" },
|
||||
body: "x".repeat(4096),
|
||||
});
|
||||
const result = await safeJson(request, { maxBytes: 1024 });
|
||||
expect(result.error!.status).toBe(415);
|
||||
});
|
||||
|
||||
it("parses unbounded when maxBytes is omitted", async () => {
|
||||
const payload = JSON.stringify({ ok: true });
|
||||
const result = await safeJson(jsonRequest(payload));
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.data).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
@@ -360,6 +360,36 @@ describe("runRawCompletionWithFallback — legacy route shim", () => {
|
||||
expect(result.fallbackHops[0].providerType).toBe("codex");
|
||||
});
|
||||
|
||||
it("advances past an auth-class (401) failure to the next provider and counts the fallback", async () => {
|
||||
// Generation routes consume this raw shim. An invalidated credential
|
||||
// on the primary (401/403, or a 5xx whose body signals an
|
||||
// invalidated session) must not abort the run — it advances to the
|
||||
// next enabled provider and bumps the fallback count.
|
||||
const codex = new ScriptedProvider({
|
||||
type: "codex",
|
||||
script: [{ ok: false, error: err(401, "token invalidated") }],
|
||||
});
|
||||
const openai = new ScriptedProvider({
|
||||
type: "admin-key",
|
||||
script: [{ ok: true, content: '{ "ok": true }' }],
|
||||
});
|
||||
const result = await runRawCompletionWithFallback({
|
||||
userId: "u-raw-auth-advance",
|
||||
providers: [
|
||||
{ providerType: "codex", instance: codex },
|
||||
{ providerType: "openai", instance: openai },
|
||||
],
|
||||
params: { systemPrompt: "s", userPrompt: "u" },
|
||||
});
|
||||
expect(result.result.content).toBe('{ "ok": true }');
|
||||
expect(result.workingProvider.providerType).toBe("openai");
|
||||
expect(result.fallbackHops).toHaveLength(1);
|
||||
expect(result.fallbackHops[0].providerType).toBe("codex");
|
||||
expect(result.fallbackHops[0].httpStatus).toBe(401);
|
||||
expect(codex.callCount).toBe(1);
|
||||
expect(openai.callCount).toBe(1);
|
||||
});
|
||||
|
||||
it("throws AllProvidersFailedError when every entry fails hard", async () => {
|
||||
const a = new ScriptedProvider({
|
||||
type: "codex",
|
||||
|
||||
@@ -21,7 +21,8 @@ vi.mock("@/lib/ai/codex-oauth", () => ({
|
||||
decryptCodexCreds: vi.fn(),
|
||||
}));
|
||||
|
||||
import { resolveProvider } from "../provider";
|
||||
import { resolveProvider, resolveProviderForTest } from "../provider";
|
||||
import { OpenAIClient } from "../openai-client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { decryptCodexCreds, encryptCodexCreds } from "@/lib/ai/codex-oauth";
|
||||
@@ -298,3 +299,53 @@ describe("resolveProvider", () => {
|
||||
expect(provider.type).toBe("codex");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The connection-test "test my saved config" path (empty override body)
|
||||
* MUST resolve via the SAME chain generation uses, so a green test means
|
||||
* generation will actually work. Previously it probed codex→admin
|
||||
* directly, which could test a different provider than generation ran.
|
||||
*/
|
||||
describe("resolveProviderForTest — empty override resolves via the chain", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(decrypt).mockImplementation((v: string) => `decrypted:${v}`);
|
||||
});
|
||||
|
||||
it("returns the first enabled+credentialed chain entry (the same provider generation runs)", async () => {
|
||||
// resolveProviderForTest first reads the test row (no chain), then
|
||||
// resolveProviderChain reads the chain row. The chain enables openai
|
||||
// and the user has an OpenAI key → first chain entry is OpenAIClient.
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
// 1st: resolveProviderForTest's own read
|
||||
.mockResolvedValueOnce({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: "enc-user-openai",
|
||||
} as never)
|
||||
// 2nd: resolveProviderChain's read
|
||||
.mockResolvedValueOnce({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: "enc-user-openai",
|
||||
aiProviderChain: [
|
||||
{ providerType: "openai", priority: 1, enabled: true },
|
||||
],
|
||||
} as never);
|
||||
|
||||
const provider = await resolveProviderForTest("user-123", {});
|
||||
// The user-OpenAI client is an OpenAIClient; its runtime tag defaults
|
||||
// to "admin-key" (no providerType passed). What matters is that the
|
||||
// empty-override path materialised the chain's first entry rather than
|
||||
// the legacy codex→admin probe — a NoProvider/none would mean the
|
||||
// chain was bypassed.
|
||||
expect(provider.type).not.toBe("none");
|
||||
expect(provider).toBeInstanceOf(OpenAIClient);
|
||||
});
|
||||
});
|
||||
+14
-4
@@ -424,11 +424,21 @@ export async function resolveProviderForTest(
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
// Empty selection: fall back to Codex → admin OpenAI like the regular path.
|
||||
// Empty selection ("test my saved config" — the ai-section calls
|
||||
// /api/ai/test with an empty body): resolve via the SAME path generation
|
||||
// uses — the first enabled+credentialed entry of `resolveProviderChain`.
|
||||
// The legacy codex→admin fallback used to probe a different provider than
|
||||
// generation ran (observed: test→admin-key while generation→codex), so a
|
||||
// green test gave no signal about whether overnight generation would
|
||||
// work. Falling through to the chain keeps the two paths in lock-step.
|
||||
if (!provider) {
|
||||
const codex = await resolveCodexProvider(userId);
|
||||
if (codex) return codex;
|
||||
return resolveAdminProvider();
|
||||
const chain = await resolveProviderChain(userId);
|
||||
if (chain.length > 0) return chain[0].instance;
|
||||
// Chain empty (no enabled+credentialed entry) — mirror the regular
|
||||
// single-provider resolution one more time before giving up.
|
||||
const legacy = await resolveProvider(userId);
|
||||
if (legacy.type !== "none") return legacy;
|
||||
throw new AITestConfigError(422, "No AI provider configured");
|
||||
}
|
||||
|
||||
switch (provider) {
|
||||
|
||||
+10
-7
@@ -201,13 +201,16 @@ export function apiError(
|
||||
* Returns the parsed body or a 400 error response if parsing fails.
|
||||
*
|
||||
* `opts.maxBytes` opts a route into a hard body-size cap. The check
|
||||
* runs against the raw text rather than the parsed object, so a
|
||||
* gigabyte payload never reaches `JSON.parse` and never lands in
|
||||
* heap. Single-record routes can pass a tight cap (a few KB); batch
|
||||
* routes that legitimately accept large payloads can pass a larger
|
||||
* one (a few hundred KB). When `maxBytes` is omitted the route
|
||||
* inherits the Next.js runtime default — pre-existing behaviour, no
|
||||
* regression risk for callers that haven't adopted the parameter.
|
||||
* runs against the raw text length rather than the parsed object, so an
|
||||
* over-limit payload returns 413 before reaching `JSON.parse` and never
|
||||
* builds an object graph. (`request.text()` still buffers the raw body
|
||||
* into a string, so the cap bounds the parse/object cost, not the
|
||||
* initial read — keep caps comfortably above the largest legitimate
|
||||
* payload.) Single-record routes can pass a tight cap; batch routes
|
||||
* that legitimately accept large payloads pass a larger one. When
|
||||
* `maxBytes` is omitted the route inherits the Next.js runtime default —
|
||||
* pre-existing behaviour, no regression risk for callers that haven't
|
||||
* adopted the parameter.
|
||||
*/
|
||||
export async function safeJson<T = unknown>(
|
||||
request: Request,
|
||||
|
||||
@@ -68,7 +68,7 @@ const IKNR_SYSTEM = "http://fhir.de/sid/arge-ik/iknr";
|
||||
* coding. Both are additive `coding[]` entries on the same concept; the
|
||||
* free-text `.text` (the user's medication name) stays the anchor.
|
||||
*/
|
||||
const ATC_SYSTEM = "http://www.whocc.no/atc";
|
||||
export const ATC_SYSTEM = "http://www.whocc.no/atc";
|
||||
/**
|
||||
* German national ATC URI maintained by the BfArM. Same ATC classification
|
||||
* as WHO under a national CodeSystem; emitted as an ADDITIONAL coding (never
|
||||
@@ -78,7 +78,16 @@ const ATC_SYSTEM = "http://www.whocc.no/atc";
|
||||
const ATC_BFARM_SYSTEM = "http://fhir.de/CodeSystem/bfarm/atc";
|
||||
const RXNORM_SYSTEM = "http://www.nlm.nih.gov/research/umls/rxnorm";
|
||||
/** SNOMED CT URI. Concept ids are referenced (not redistributed) in FHIR instances. */
|
||||
const SNOMED_SYSTEM = "http://snomed.info/sct";
|
||||
export const SNOMED_SYSTEM = "http://snomed.info/sct";
|
||||
|
||||
/**
|
||||
* The app locales for which a health-record export defaults `germanAtc` on
|
||||
* (the additive BfArM ATC coding). The export route derives the flag from
|
||||
* the user's locale against this set; the capabilities endpoint surfaces it
|
||||
* verbatim so a client can predict the coding without a round-trip. Keeping
|
||||
* it here — beside the BfArM URI it gates — makes the two move together.
|
||||
*/
|
||||
export const GERMAN_ATC_DEFAULT_LOCALES = ["de"] as const;
|
||||
|
||||
/** Options threaded from the export route into the builder. Additive, all defaulted. */
|
||||
export interface FhirBuildOptions {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { resolveValuesBackHref } from "../values-back-link";
|
||||
|
||||
/**
|
||||
* v1.10.2 — the `/insights/values/[type]` back-link returns to the originating
|
||||
* metric page via a sanitised `from` param. The guard keeps the target an
|
||||
* internal `/insights/<slug>` path so a crafted value can never redirect
|
||||
* off-site.
|
||||
*/
|
||||
describe("resolveValuesBackHref", () => {
|
||||
it("returns the originating metric page when from is an internal insights path", () => {
|
||||
expect(resolveValuesBackHref("/insights/weight")).toBe("/insights/weight");
|
||||
expect(resolveValuesBackHref("/insights/blood-pressure")).toBe(
|
||||
"/insights/blood-pressure",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the overview when from is missing", () => {
|
||||
expect(resolveValuesBackHref(null)).toBe("/insights");
|
||||
expect(resolveValuesBackHref(undefined)).toBe("/insights");
|
||||
expect(resolveValuesBackHref("")).toBe("/insights");
|
||||
});
|
||||
|
||||
it("rejects the overview itself and any non-metric path", () => {
|
||||
expect(resolveValuesBackHref("/insights")).toBe("/insights");
|
||||
expect(resolveValuesBackHref("/settings/account")).toBe("/insights");
|
||||
});
|
||||
|
||||
it("rejects off-site and protocol-relative targets", () => {
|
||||
expect(resolveValuesBackHref("https://evil.example/insights/weight")).toBe(
|
||||
"/insights",
|
||||
);
|
||||
expect(resolveValuesBackHref("//evil.example")).toBe("/insights");
|
||||
expect(resolveValuesBackHref("/insights//evil.example")).toBe("/insights");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* v1.10.2 — resolve the back-link target for `/insights/values/[type]`.
|
||||
*
|
||||
* The "show all readings" link (built by `<SubPageShell>`) carries the
|
||||
* originating metric page as a `from` query param so the values sub-page can
|
||||
* return the user to where they drilled in from (e.g. `weight → show all
|
||||
* values → back to weight`) rather than always to the Insights overview.
|
||||
*
|
||||
* The param is sanitised to an internal `/insights/<slug>` path — a single
|
||||
* leading slash, no protocol / host, no protocol-relative `//` — so a crafted
|
||||
* `?from=` value can never become an off-site or protocol-relative navigation
|
||||
* target. Anything that fails the check falls back to the Insights overview.
|
||||
*/
|
||||
export const INSIGHTS_OVERVIEW = "/insights";
|
||||
|
||||
export function resolveValuesBackHref(from: string | null | undefined): string {
|
||||
if (
|
||||
from &&
|
||||
from.startsWith("/insights/") &&
|
||||
!from.startsWith("/insights//")
|
||||
) {
|
||||
return from;
|
||||
}
|
||||
return INSIGHTS_OVERVIEW;
|
||||
}
|
||||
@@ -62,20 +62,21 @@ export const DENSE_INTRADAY_RETENTION_CRON = "50 3 * * *";
|
||||
export const DENSE_INTRADAY_RETENTION_BOOT_DELAY_SECONDS = 600;
|
||||
|
||||
/**
|
||||
* Kill-switch, default OFF. The drain folds out-of-window per-sample rows
|
||||
* into one daily-mean `stats:` row, but its canonical-timestamp upsert
|
||||
* collides with an already-present daily row on the
|
||||
* Kill-switch, default ON (re-enabled in v1.10.2). The v1.10.0.2 default
|
||||
* was OFF because the fold's canonical-timestamp upsert collided with an
|
||||
* already-present daily row on the
|
||||
* `(user_id, type, measured_at, source, sleep_stage)` unique constraint
|
||||
* (`P2002`) on real data — a fold-coexistence bug the mocked unit tests
|
||||
* could not surface. Until the fold is reworked + integration-tested
|
||||
* against real rows, the drain is disabled: the boot-discovery enqueues
|
||||
* nothing, the nightly walk is skipped, and the per-user handler no-ops so
|
||||
* any already-queued backlog completes harmlessly instead of erroring.
|
||||
* Re-enable with `DENSE_INTRADAY_RETENTION_ENABLED=true` once fixed. The
|
||||
* Stress proxy degrades gracefully on sparse intra-day data meanwhile.
|
||||
* could not surface. v1.10.2 reworks `writeDay` to resolve the canonical
|
||||
* local-noon row by the measured_at composite (the index the collision was
|
||||
* on) and adopt it in place, so the fold now coexists with a pre-existing
|
||||
* daily row, and an integration test pins the no-P2002 contract. The drain
|
||||
* is on by default again; the boot-discovery still defers past the startup
|
||||
* storm (`DENSE_INTRADAY_RETENTION_BOOT_DELAY_SECONDS`). An operator can
|
||||
* still disable it explicitly with `DENSE_INTRADAY_RETENTION_ENABLED=false`.
|
||||
*/
|
||||
export const DENSE_INTRADAY_RETENTION_ENABLED =
|
||||
process.env.DENSE_INTRADAY_RETENTION_ENABLED === "true";
|
||||
process.env.DENSE_INTRADAY_RETENTION_ENABLED !== "false";
|
||||
|
||||
export interface DenseIntradayRetentionPayload {
|
||||
userId: string;
|
||||
@@ -89,8 +90,8 @@ export interface DenseIntradayRetentionPayload {
|
||||
export async function runDenseIntradayRetentionForUser(
|
||||
userId: string,
|
||||
): Promise<{ daysConsolidated: number; perSampleRowsSoftDeleted: number }> {
|
||||
// Kill-switch: no-op so any already-queued backlog completes cleanly
|
||||
// (drains the queue) instead of erroring on the P2002 fold collision.
|
||||
// Kill-switch: when an operator opts out, no-op so any already-queued
|
||||
// backlog completes cleanly (drains the queue) instead of running the fold.
|
||||
if (!DENSE_INTRADAY_RETENTION_ENABLED) {
|
||||
return { daysConsolidated: 0, perSampleRowsSoftDeleted: 0 };
|
||||
}
|
||||
|
||||
@@ -2824,7 +2824,7 @@ export async function startReminderWorker() {
|
||||
if (!DENSE_INTRADAY_RETENTION_ENABLED) {
|
||||
workerLog(
|
||||
"info",
|
||||
"[dense-intraday-retention] disabled — skipping the nightly walk (P2002 fold collision; re-enable via DENSE_INTRADAY_RETENTION_ENABLED once reworked)",
|
||||
"[dense-intraday-retention] disabled via DENSE_INTRADAY_RETENTION_ENABLED — skipping the nightly walk (operator kill-switch)",
|
||||
);
|
||||
} else {
|
||||
const denseSummary = await runDenseIntradayRetention(
|
||||
|
||||
@@ -35,14 +35,26 @@ function row(
|
||||
return { id, type, value, measuredAt: new Date(iso), externalId, unit };
|
||||
}
|
||||
|
||||
function buildPrismaMock(rowsByType: Record<string, unknown[]>) {
|
||||
const upsert = vi.fn().mockResolvedValue({});
|
||||
function buildPrismaMock(
|
||||
rowsByType: Record<string, unknown[]>,
|
||||
// When set, the canonical-row lookup inside `writeDay` resolves to this
|
||||
// existing row id — simulating a daily row already sitting on the
|
||||
// canonical local-noon instant (the P2002 coexistence case).
|
||||
existingCanonicalId: string | null = null,
|
||||
) {
|
||||
const create = vi.fn().mockResolvedValue({ id: "minted-daily" });
|
||||
const update = vi.fn().mockResolvedValue({});
|
||||
const findFirst = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
existingCanonicalId ? { id: existingCanonicalId } : null,
|
||||
);
|
||||
const updateMany = vi.fn().mockResolvedValue({ count: 0 });
|
||||
const findManyMeasurement = vi.fn(
|
||||
async (args: { where: { type: string } }) =>
|
||||
rowsByType[args.where.type] ?? [],
|
||||
);
|
||||
const tx = { measurement: { upsert, updateMany } };
|
||||
const tx = { measurement: { create, update, findFirst, updateMany } };
|
||||
return {
|
||||
mock: {
|
||||
user: {
|
||||
@@ -55,7 +67,9 @@ function buildPrismaMock(rowsByType: Record<string, unknown[]>) {
|
||||
cb(tx),
|
||||
),
|
||||
} as unknown as PrismaClient,
|
||||
upsert,
|
||||
create,
|
||||
update,
|
||||
findFirst,
|
||||
updateMany,
|
||||
findManyMeasurement,
|
||||
};
|
||||
@@ -119,13 +133,13 @@ describe("runDenseIntradayRetention — retention bound", () => {
|
||||
});
|
||||
|
||||
describe("runDenseIntradayRetention — fold flow", () => {
|
||||
it("upserts the day MEAN and soft-deletes the out-of-window rows", async () => {
|
||||
it("creates the day MEAN and soft-deletes the out-of-window rows when no canonical row exists", async () => {
|
||||
// Two HRV samples on an out-of-window day.
|
||||
const hrvRows = [
|
||||
row("a", 40, "2026-05-01T08:00:00.000Z", "HEART_RATE_VARIABILITY"),
|
||||
row("b", 60, "2026-05-01T09:00:00.000Z", "HEART_RATE_VARIABILITY"),
|
||||
];
|
||||
const { mock, upsert, updateMany } = buildPrismaMock({
|
||||
const { mock, create, update, updateMany } = buildPrismaMock({
|
||||
HEART_RATE_VARIABILITY: hrvRows,
|
||||
});
|
||||
|
||||
@@ -134,13 +148,15 @@ describe("runDenseIntradayRetention — fold flow", () => {
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
const upsertArg = upsert.mock.calls[0]?.[0] as {
|
||||
create: { value: number; source: string; type: string };
|
||||
// No pre-existing canonical row → create, not update.
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
const createArg = create.mock.calls[0]?.[0] as {
|
||||
data: { value: number; source: string; type: string };
|
||||
};
|
||||
// value is the MEAN (50), not the sum (100).
|
||||
expect(upsertArg.create.value).toBeCloseTo(50, 6);
|
||||
expect(upsertArg.create.source).toBe("APPLE_HEALTH");
|
||||
expect(upsertArg.create.type).toBe("HEART_RATE_VARIABILITY");
|
||||
expect(createArg.data.value).toBeCloseTo(50, 6);
|
||||
expect(createArg.data.source).toBe("APPLE_HEALTH");
|
||||
expect(createArg.data.type).toBe("HEART_RATE_VARIABILITY");
|
||||
|
||||
// soft-delete, never hard delete.
|
||||
const updArg = updateMany.mock.calls[0]?.[0] as {
|
||||
@@ -151,8 +167,41 @@ describe("runDenseIntradayRetention — fold flow", () => {
|
||||
expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("adopts an existing canonical row in place instead of colliding (P2002 coexistence)", async () => {
|
||||
const hrvRows = [
|
||||
row("a", 40, "2026-05-01T08:00:00.000Z", "HEART_RATE_VARIABILITY"),
|
||||
row("b", 60, "2026-05-01T09:00:00.000Z", "HEART_RATE_VARIABILITY"),
|
||||
];
|
||||
// A daily row already sits on the canonical local-noon instant.
|
||||
const { mock, create, update, updateMany } = buildPrismaMock(
|
||||
{ HEART_RATE_VARIABILITY: hrvRows },
|
||||
"existing-canonical-row",
|
||||
);
|
||||
|
||||
await runDenseIntradayRetention(mock, { retentionDays: 0, log: () => {} });
|
||||
|
||||
// The fold adopts the existing row in place — no create (which would
|
||||
// collide on the measured_at unique index), an update with the stats
|
||||
// externalId + refreshed mean.
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
const updateArg = update.mock.calls[0]?.[0] as {
|
||||
where: { id: string };
|
||||
data: { value: number; externalId: string; deletedAt: null };
|
||||
};
|
||||
expect(updateArg.where.id).toBe("existing-canonical-row");
|
||||
expect(updateArg.data.value).toBeCloseTo(50, 6);
|
||||
expect(updateArg.data.externalId.startsWith("stats:")).toBe(true);
|
||||
expect(updateArg.data.deletedAt).toBeNull();
|
||||
|
||||
// The soft-delete excludes the adopted canonical row id.
|
||||
const updManyArg = updateMany.mock.calls[0]?.[0] as {
|
||||
where: { id: { not: string } };
|
||||
};
|
||||
expect(updManyArg.where.id.not).toBe("existing-canonical-row");
|
||||
});
|
||||
|
||||
it("does not write on a dry-run", async () => {
|
||||
const { mock, upsert } = buildPrismaMock({
|
||||
const { mock, create, update } = buildPrismaMock({
|
||||
HEART_RATE_VARIABILITY: [
|
||||
row("a", 40, "2026-05-01T08:00:00.000Z", "HEART_RATE_VARIABILITY"),
|
||||
],
|
||||
@@ -162,7 +211,8 @@ describe("runDenseIntradayRetention — fold flow", () => {
|
||||
dryRun: true,
|
||||
log: () => {},
|
||||
});
|
||||
expect(upsert).not.toHaveBeenCalled();
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(recomputeBucketsForMeasurement).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -77,6 +77,20 @@ export const DENSE_INTRADAY_RETENTION_DAYS = 14;
|
||||
/** The daily-stats externalId prefix marks an already-collapsed row. */
|
||||
const DAILY_STATS_PREFIX = "stats:";
|
||||
|
||||
/**
|
||||
* True for a Prisma unique-constraint violation (P2002). Mirrors the
|
||||
* `consolidate-legacy-steps.ts` precedent verbatim so the two drains
|
||||
* classify the same error the same way.
|
||||
*/
|
||||
function isUniqueConstraintViolation(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"code" in err &&
|
||||
(err as { code?: unknown }).code === "P2002"
|
||||
);
|
||||
}
|
||||
|
||||
export interface DenseIntradayRetentionSummary {
|
||||
dryRun: boolean;
|
||||
totals: {
|
||||
@@ -181,47 +195,145 @@ export async function runDenseIntradayRetention(
|
||||
sourceRowIds,
|
||||
}): Promise<DayWriteOutcome> => {
|
||||
const unit = dayRows[0]?.unit ?? "unknown";
|
||||
let removed = 0;
|
||||
await pc.$transaction(async (tx) => {
|
||||
// Mint / refresh the canonical daily-mean row first. The unique
|
||||
// index (userId, type, source, externalId) makes the upsert
|
||||
// idempotent across re-runs. Built field-by-field (no spread) per
|
||||
// the no-mass-assignment convention.
|
||||
await tx.measurement.upsert({
|
||||
where: {
|
||||
userId_type_source_externalId: {
|
||||
|
||||
// Fold the day to one canonical daily-mean row at local-noon.
|
||||
//
|
||||
// Unlike `consolidate-daily-mean` (whose types never share the
|
||||
// canonical local-noon instant with another path), the dense-tier
|
||||
// types HRV / PULSE are dense enough that a per-sample row can land
|
||||
// exactly on the canonical timestamp, and a previously-minted daily
|
||||
// row can already occupy it. TWO unique indexes can collide:
|
||||
// A) `(userId, type, source, externalId)` — the row already
|
||||
// carrying the target `stats:` externalId.
|
||||
// B) `(userId, type, measuredAt, source, sleepStage)` (NULLS NOT
|
||||
// DISTINCT) — any row sitting on the canonical local-noon
|
||||
// instant, which may carry a DIFFERENT externalId (a sibling
|
||||
// consolidation path, a manual daily entry, or a per-sample row
|
||||
// that happened at local noon).
|
||||
//
|
||||
// Determinism (VECTOR 1): resolve the canonical row by index A FIRST —
|
||||
// the row that already holds the target `stats:` externalId IS the
|
||||
// canonical daily row by construction. Only when no such row exists do
|
||||
// we fall back to the index-B row physically occupying the canonical
|
||||
// instant. A stable `orderBy: id` pins the pick. Adopting the wrong
|
||||
// sibling and stamping the target externalId onto it would otherwise
|
||||
// collide with the real `stats:` row on index A.
|
||||
//
|
||||
// Concurrency (VECTOR 2): the resolve→create is a check-then-act not
|
||||
// serialised by the unique index under READ COMMITTED. If a concurrent
|
||||
// writer wins the canonical slot between the lookup and the INSERT, the
|
||||
// `create` throws P2002 and Postgres aborts the whole transaction. We
|
||||
// therefore catch P2002 OUTSIDE the transaction (a caught error inside
|
||||
// would leave the tx in the aborted `25P02` state) and retry the fold
|
||||
// once: on the retry the deterministic lookup finds the now-existing
|
||||
// row and ADOPTS it, so the create path never fires twice. Mirrors the
|
||||
// `consolidate-legacy-steps.ts` P2002 classification, but recovers in
|
||||
// place rather than skipping, because the colliding row IS the
|
||||
// canonical daily row this fold owns. Built field-by-field (no spread)
|
||||
// per the no-mass-assignment convention.
|
||||
const foldOnce = async (): Promise<number> => {
|
||||
let removed = 0;
|
||||
await pc.$transaction(async (tx) => {
|
||||
// Index-A row: already carries the target `stats:` externalId. By
|
||||
// construction this drain only ever mints `stats:` rows at the
|
||||
// canonical instant, so in the common case this row IS the
|
||||
// canonical daily row already sitting at local-noon.
|
||||
const eidRow = await tx.measurement.findFirst({
|
||||
where: { userId, type, source: "APPLE_HEALTH", externalId },
|
||||
select: { id: true, measuredAt: true },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
// Index-B row: occupies the canonical local-noon instant
|
||||
// (`sleepStage` is NULL for these continuous types, matched via the
|
||||
// NULLS-NOT-DISTINCT index). At most one such row can exist.
|
||||
const slotRow = await tx.measurement.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
type,
|
||||
source: "APPLE_HEALTH",
|
||||
externalId,
|
||||
measuredAt: canonicalTimestamp,
|
||||
sleepStage: null,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
type,
|
||||
value: reducedValue,
|
||||
unit,
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: canonicalTimestamp,
|
||||
externalId,
|
||||
},
|
||||
update: {
|
||||
value: reducedValue,
|
||||
measuredAt: canonicalTimestamp,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
select: { id: true },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
|
||||
// Soft-delete the out-of-window per-sample rows in the same
|
||||
// transaction — tombstone, never hard-delete; they remain as an
|
||||
// audit trail and drop off the live read + this pass's re-run
|
||||
// discovery.
|
||||
const del = await tx.measurement.updateMany({
|
||||
where: { id: { in: sourceRowIds }, deletedAt: null },
|
||||
data: { deletedAt: new Date() },
|
||||
// Prefer the externalId-carrying row (index A) — adopting a sibling
|
||||
// and stamping the target externalId onto it would collide with it.
|
||||
const adoptTarget = eidRow ?? slotRow;
|
||||
|
||||
let canonicalRowId: string;
|
||||
if (adoptTarget) {
|
||||
// Pin the adopted row to local-noon, but only when the canonical
|
||||
// slot is free or already this row — a DIFFERENT row occupying
|
||||
// the slot (a per-sample row that fell on local-noon, now being
|
||||
// folded) would otherwise collide on index B. In that rare case
|
||||
// (only reachable on a tz/DST shift between mints) the row keeps
|
||||
// its existing instant; the `stats:` externalId is the identity,
|
||||
// measuredAt is secondary.
|
||||
const slotIsFreeForTarget =
|
||||
slotRow === null || slotRow.id === adoptTarget.id;
|
||||
// Adopt: refresh value, stamp the `stats:` externalId, optionally
|
||||
// re-anchor to local-noon, and un-tombstone. This coexists with a
|
||||
// pre-existing daily row instead of colliding with it.
|
||||
await tx.measurement.update({
|
||||
where: { id: adoptTarget.id },
|
||||
data: {
|
||||
value: reducedValue,
|
||||
unit,
|
||||
externalId,
|
||||
deletedAt: null,
|
||||
...(slotIsFreeForTarget
|
||||
? { measuredAt: canonicalTimestamp }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
canonicalRowId = adoptTarget.id;
|
||||
} else {
|
||||
const created = await tx.measurement.create({
|
||||
data: {
|
||||
userId,
|
||||
type,
|
||||
value: reducedValue,
|
||||
unit,
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: canonicalTimestamp,
|
||||
externalId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
canonicalRowId = created.id;
|
||||
}
|
||||
|
||||
// Soft-delete the out-of-window per-sample rows in the same
|
||||
// transaction — tombstone, never hard-delete; they remain as an
|
||||
// audit trail and drop off the live read + this pass's re-run
|
||||
// discovery. EXCLUDE the canonical row itself: a per-sample row
|
||||
// that happened to fall on the canonical local-noon instant is the
|
||||
// row we just adopted as the daily mean, so tombstoning it would
|
||||
// erase the fold.
|
||||
const del = await tx.measurement.updateMany({
|
||||
where: {
|
||||
id: { in: sourceRowIds, not: canonicalRowId },
|
||||
deletedAt: null,
|
||||
},
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
removed = del.count;
|
||||
});
|
||||
removed = del.count;
|
||||
});
|
||||
return removed;
|
||||
};
|
||||
|
||||
let removed: number;
|
||||
try {
|
||||
removed = await foldOnce();
|
||||
} catch (err) {
|
||||
if (!isUniqueConstraintViolation(err)) throw err;
|
||||
// A concurrent writer won the canonical slot mid-transaction. Retry
|
||||
// once: the deterministic lookup now resolves the winning row and
|
||||
// adopts it, so this pass cannot create a duplicate.
|
||||
removed = await foldOnce();
|
||||
}
|
||||
|
||||
// Recompute the affected (user, type, day) rollup buckets after the
|
||||
// fold commits — the rollup tier reads only `deleted_at IS NULL`, so
|
||||
|
||||
@@ -68,6 +68,7 @@ const openApiBase: Pick<
|
||||
{ name: "Export" },
|
||||
{ name: "Sync" },
|
||||
{ name: "Admin" },
|
||||
{ name: "Meta" },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -1854,6 +1854,71 @@ exportSelectionSchema.meta({
|
||||
"v1.7.0 — health-record / doctor-handover export selection. `format` picks PDF, FHIR R4 document Bundle, or a combined zip package. Grouped `sections` toggles drive which domains are read (mood is opt-in, off by default). No `userId` field — the user is always narrowed from the session/Bearer. The route is strict: unknown keys 422.",
|
||||
});
|
||||
|
||||
// v1.10.2 — live capability / discovery response. Every list is sourced
|
||||
// server-side from the canonical registry it documents, so the wire shape
|
||||
// here is the contract; the runtime values are authoritative and never
|
||||
// hand-duplicated. Used by the native client to gate its UI / decoder
|
||||
// against what the server actually ships (retires the doc-vs-server
|
||||
// enum-drift class).
|
||||
const capabilitiesResponse = z
|
||||
.object({
|
||||
apiContractVersion: z
|
||||
.string()
|
||||
.describe("Running build version — mirrors GET /api/version `version`."),
|
||||
derivedMetricIds: z
|
||||
.array(z.string())
|
||||
.describe("Closed derived-metric id set (GET /api/insights/derived)."),
|
||||
vitalsBaselineTypes: z
|
||||
.array(z.string())
|
||||
.describe("MeasurementTypes the typical-range baseline engine supports."),
|
||||
layoutTileIds: z
|
||||
.array(z.string())
|
||||
.describe("Canonical insights layout tile-id set (English ids)."),
|
||||
metricStatusIds: z
|
||||
.array(z.string())
|
||||
.describe("Closed metric-status / assessment id set."),
|
||||
ingest: z
|
||||
.object({
|
||||
quantityTypes: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.string().describe("HealthLog MeasurementType."),
|
||||
hk: z.string().describe("HealthKit identifier."),
|
||||
unit: z.string().describe("Canonical DB unit."),
|
||||
}),
|
||||
)
|
||||
.describe("Accepted HealthKit quantity-sample mappings."),
|
||||
eventTypes: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
"MeasurementTypes for device-flagged EVENT-class HealthKit samples.",
|
||||
),
|
||||
computedScores: z
|
||||
.array(z.string())
|
||||
.describe("Server-owned nightly composite score types."),
|
||||
writeAllowlist: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
"MeasurementSources a client may attribute on a write (others are server-owned).",
|
||||
),
|
||||
})
|
||||
.describe("Ingest vocabularies the batch / single-write paths accept."),
|
||||
fhir: z
|
||||
.object({
|
||||
atcSystem: z.string().describe("WHO ATC CodeSystem URI."),
|
||||
snomedRoute: z.string().describe("SNOMED CT CodeSystem URI."),
|
||||
germanAtcDefaultLocales: z
|
||||
.array(z.string())
|
||||
.describe("App locales that default the additive BfArM ATC coding on."),
|
||||
})
|
||||
.describe("FHIR coding constants the health-record export emits."),
|
||||
})
|
||||
.meta({
|
||||
id: "CapabilitiesResponse",
|
||||
description:
|
||||
"Live id vocabularies + contract version. Every list is derived server-side from the canonical registry it documents, so it cannot drift from the values the routes actually accept/emit.",
|
||||
});
|
||||
|
||||
// ── Standard 401 / 422 / 429 responses ───────────────────────────────
|
||||
|
||||
const stdResponses = {
|
||||
@@ -1874,6 +1939,28 @@ const stdResponses = {
|
||||
// ── Path table ───────────────────────────────────────────────────────
|
||||
|
||||
export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/meta/capabilities": {
|
||||
get: {
|
||||
tags: ["Meta"],
|
||||
summary: "Live server capability / id-vocabulary discovery",
|
||||
description:
|
||||
"Returns the server's REAL id vocabularies (derived-metric ids, vitals-baseline types, layout tile-ids, metric-status ids, the HealthKit ingest mapping, the FHIR coding constants) plus the running API contract version. Every list is derived server-side from the canonical registry it documents, so a client can gate its UI / decoder against what the server actually ships rather than a hand-maintained copy. Auth via cookie or Bearer (not admin).",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Capability snapshot.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
capabilitiesResponse,
|
||||
"CapabilitiesEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/export/health-record": {
|
||||
post: {
|
||||
tags: ["Export"],
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* v1.10.2 — real-Postgres integration coverage for
|
||||
* `runDenseIntradayRetention()`. The mocked unit tests could not surface
|
||||
* the v1.10.0.2 P2002 fold-coexistence bug: the fold mints a canonical
|
||||
* daily-mean row at the user's local-noon instant, but a row may ALREADY
|
||||
* sit on that `(userId, type, measuredAt, source, sleepStage)` unique
|
||||
* index from another path. An externalId-keyed upsert misses the lookup
|
||||
* and the INSERT then violates the measured_at index with P2002.
|
||||
*
|
||||
* This file seeds that exact colliding shape against a live Postgres and
|
||||
* asserts the reworked fold coexists with the pre-existing daily row,
|
||||
* stays idempotent, and preserves the dense-tier scope.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { runDenseIntradayRetention } from "@/lib/measurements/dense-intraday-retention";
|
||||
import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz";
|
||||
|
||||
const TEST_USER_ID = "user-dense-retention";
|
||||
const TZ = "Europe/Berlin";
|
||||
const DAY_KEY = "2026-05-01";
|
||||
// The canonical local-noon instant the fold writes for the seeded day.
|
||||
const CANONICAL = canonicalDailyTimestamp(DAY_KEY, TZ);
|
||||
const HRV_STATS_ID = "stats:HKQuantityTypeIdentifierHeartRateVariabilitySDNN:2026-05-01";
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: TEST_USER_ID,
|
||||
username: "dense-retention",
|
||||
email: "dense-retention@example.test",
|
||||
timezone: TZ,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("runDenseIntradayRetention (real Postgres)", () => {
|
||||
it("folds out-of-window HRV per-sample rows into one daily MEAN and soft-deletes them", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
value: 40,
|
||||
unit: "ms",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-01T08:00:00.000Z"),
|
||||
externalId: "hk-hrv-1",
|
||||
},
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
value: 60,
|
||||
unit: "ms",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-01T09:00:00.000Z"),
|
||||
externalId: "hk-hrv-2",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const summary = await runDenseIntradayRetention(prisma, {
|
||||
userId: TEST_USER_ID,
|
||||
retentionDays: 0,
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
expect(summary.totals.daysConsolidated).toBe(1);
|
||||
expect(summary.totals.perSampleRowsSoftDeleted).toBe(2);
|
||||
|
||||
const live = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: TEST_USER_ID,
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
expect(live).toHaveLength(1);
|
||||
// MEAN of (40, 60) = 50.
|
||||
expect(live[0].value).toBeCloseTo(50, 6);
|
||||
expect(live[0].externalId).toBe(HRV_STATS_ID);
|
||||
expect(live[0].measuredAt.getTime()).toBe(CANONICAL.getTime());
|
||||
});
|
||||
|
||||
it("coexists with a pre-existing daily row on the canonical instant (the P2002 case)", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
// A previously-collapsed daily `stats:` row ALREADY sits on the
|
||||
// canonical local-noon instant under a DIFFERENT HK stats id than the
|
||||
// one the fold mints (e.g. a row minted by a sibling consolidation path,
|
||||
// or a legacy stats id). The scan EXCLUDES it (NOT startsWith 'stats:'),
|
||||
// so it is never folded into the mean — but pre-v1.10.2 the fold's
|
||||
// externalId-keyed upsert missed it on lookup and the INSERT then
|
||||
// collided on (userId, type, measuredAt, source, sleepStage) with P2002.
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
value: 999, // stale value to be overwritten by the fold
|
||||
unit: "ms",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: CANONICAL,
|
||||
externalId: "stats:legacy-hrv-id:2026-05-01",
|
||||
},
|
||||
});
|
||||
// Out-of-window per-sample rows that should fold into the canonical row.
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
value: 40,
|
||||
unit: "ms",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-01T06:00:00.000Z"),
|
||||
externalId: "hk-hrv-1",
|
||||
},
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
value: 60,
|
||||
unit: "ms",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-01T18:00:00.000Z"),
|
||||
externalId: "hk-hrv-2",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Must not throw P2002.
|
||||
const summary = await runDenseIntradayRetention(prisma, {
|
||||
userId: TEST_USER_ID,
|
||||
retentionDays: 0,
|
||||
log: () => {},
|
||||
});
|
||||
expect(summary.totals.daysConsolidated).toBe(1);
|
||||
|
||||
const live = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: TEST_USER_ID,
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
// Exactly one live daily row: the pre-existing one, adopted in place.
|
||||
expect(live).toHaveLength(1);
|
||||
expect(live[0].measuredAt.getTime()).toBe(CANONICAL.getTime());
|
||||
// The fold refreshed the value to the per-sample MEAN (50), overwriting
|
||||
// the stale 999, and stamped the canonical `stats:` externalId.
|
||||
expect(live[0].value).toBeCloseTo(50, 6);
|
||||
expect(live[0].externalId).toBe(HRV_STATS_ID);
|
||||
|
||||
// The two out-of-window samples are tombstoned.
|
||||
const tombstoned = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: TEST_USER_ID,
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
deletedAt: { not: null },
|
||||
},
|
||||
});
|
||||
expect(tombstoned).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("adopts the row already carrying the target stats externalId on the canonical instant (VECTOR 1)", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
// BOTH-present collision (VECTOR 1). A prior fold already minted the
|
||||
// canonical daily row carrying the TARGET `stats:` externalId and it
|
||||
// sits on the canonical local-noon instant. A fresh batch of
|
||||
// out-of-window per-sample rows then arrives for the same day. The fold
|
||||
// must adopt the EXISTING canonical row in place — refreshing its value
|
||||
// and re-anchoring it — never INSERT a second row at local-noon (that
|
||||
// would collide on the measured_at composite) and never stamp the target
|
||||
// externalId onto a sibling (that would collide on the externalId
|
||||
// composite with this very row). The pre-fix `findFirst` had no
|
||||
// externalId discrimination and no deterministic `orderBy`, so it could
|
||||
// pick the wrong row and trip P2002 on the externalId index.
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
value: 999, // stale — the fold overwrites it with the day's mean
|
||||
unit: "ms",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: CANONICAL,
|
||||
externalId: HRV_STATS_ID, // the TARGET canonical externalId
|
||||
},
|
||||
});
|
||||
// Out-of-window per-sample rows that fold into the canonical row.
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
value: 40,
|
||||
unit: "ms",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-01T06:00:00.000Z"),
|
||||
externalId: "hk-hrv-1",
|
||||
},
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
value: 60,
|
||||
unit: "ms",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-01T18:00:00.000Z"),
|
||||
externalId: "hk-hrv-2",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Must not throw P2002.
|
||||
const summary = await runDenseIntradayRetention(prisma, {
|
||||
userId: TEST_USER_ID,
|
||||
retentionDays: 0,
|
||||
log: () => {},
|
||||
});
|
||||
expect(summary.totals.daysConsolidated).toBe(1);
|
||||
|
||||
const live = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: TEST_USER_ID,
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
// Exactly ONE live canonical row remains — the row that already carried
|
||||
// the target `stats:` externalId, adopted in place and folded.
|
||||
expect(live).toHaveLength(1);
|
||||
expect(live[0].externalId).toBe(HRV_STATS_ID);
|
||||
expect(live[0].measuredAt.getTime()).toBe(CANONICAL.getTime());
|
||||
// MEAN of the two folded per-sample values (40, 60) = 50, overwriting
|
||||
// the stale 999.
|
||||
expect(live[0].value).toBeCloseTo(50, 6);
|
||||
|
||||
// Both scanned per-sample rows are tombstoned.
|
||||
const tombstoned = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: TEST_USER_ID,
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
deletedAt: { not: null },
|
||||
},
|
||||
});
|
||||
expect(tombstoned).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not tombstone a per-sample row that happens to fall on the canonical instant", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
// One per-sample row sits exactly on the canonical local-noon instant;
|
||||
// it must become the adopted daily row, not get soft-deleted.
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
value: 50,
|
||||
unit: "ms",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: CANONICAL,
|
||||
externalId: "hk-hrv-noon",
|
||||
},
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
value: 70,
|
||||
unit: "ms",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-01T06:00:00.000Z"),
|
||||
externalId: "hk-hrv-morning",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const summary = await runDenseIntradayRetention(prisma, {
|
||||
userId: TEST_USER_ID,
|
||||
retentionDays: 0,
|
||||
log: () => {},
|
||||
});
|
||||
expect(summary.totals.daysConsolidated).toBe(1);
|
||||
|
||||
const live = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: TEST_USER_ID,
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
expect(live).toHaveLength(1);
|
||||
expect(live[0].measuredAt.getTime()).toBe(CANONICAL.getTime());
|
||||
// MEAN of (50, 70) = 60 — the adopted row carries the day's mean.
|
||||
expect(live[0].value).toBeCloseTo(60, 6);
|
||||
expect(live[0].externalId).toBe(HRV_STATS_ID);
|
||||
});
|
||||
|
||||
it("is idempotent — a second run is a no-op and the value stays correct", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "PULSE",
|
||||
value: 60,
|
||||
unit: "bpm",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-01T06:00:00.000Z"),
|
||||
externalId: "hk-pulse-1",
|
||||
},
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "PULSE",
|
||||
value: 80,
|
||||
unit: "bpm",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-01T18:00:00.000Z"),
|
||||
externalId: "hk-pulse-2",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const first = await runDenseIntradayRetention(prisma, {
|
||||
userId: TEST_USER_ID,
|
||||
retentionDays: 0,
|
||||
log: () => {},
|
||||
});
|
||||
expect(first.totals.daysConsolidated).toBe(1);
|
||||
expect(first.totals.perSampleRowsSoftDeleted).toBe(2);
|
||||
|
||||
// Second run must converge to zero work and never collide on the
|
||||
// canonical row it minted on the first pass.
|
||||
const second = await runDenseIntradayRetention(prisma, {
|
||||
userId: TEST_USER_ID,
|
||||
retentionDays: 0,
|
||||
log: () => {},
|
||||
});
|
||||
expect(second.totals.daysConsolidated).toBe(0);
|
||||
expect(second.totals.perSampleRowsSoftDeleted).toBe(0);
|
||||
|
||||
const live = await prisma.measurement.findMany({
|
||||
where: { userId: TEST_USER_ID, type: "PULSE", deletedAt: null },
|
||||
});
|
||||
expect(live).toHaveLength(1);
|
||||
// MEAN of (60, 80) = 70.
|
||||
expect(live[0].value).toBeCloseTo(70, 6);
|
||||
});
|
||||
|
||||
it("keeps in-window per-sample rows raw (retention bound)", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const recent = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); // 2 days ago
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
value: 45,
|
||||
unit: "ms",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: recent,
|
||||
externalId: "hk-hrv-recent-1",
|
||||
},
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
value: 55,
|
||||
unit: "ms",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date(recent.getTime() + 60 * 60 * 1000),
|
||||
externalId: "hk-hrv-recent-2",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const summary = await runDenseIntradayRetention(prisma, {
|
||||
userId: TEST_USER_ID,
|
||||
// Default 14-day window — both rows are inside it.
|
||||
log: () => {},
|
||||
});
|
||||
expect(summary.totals.daysConsolidated).toBe(0);
|
||||
|
||||
const live = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: TEST_USER_ID,
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
// Both raw samples survive — the intra-day shape is preserved.
|
||||
expect(live).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user