mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge release/v1.18.6 into main
v1.18.6 — offline reads, grounded guidance, and one consistent app.
This commit is contained in:
221 files changed
+15277
-5369
No files matched your search
@@ -0,0 +1,14 @@
|
||||
# Trivy vulnerability ignore list.
|
||||
# One CVE id per line. Only suppress a finding that has NO upstream fix we
|
||||
# can apply; never suppress one with a fixed version available — bump instead.
|
||||
# Each entry names what it covers and the condition to re-check / remove it.
|
||||
|
||||
# CVE-2026-33671 (picomatch ReDoS, HIGH) — flagged only in the npm CLI's
|
||||
# OWN bundled copy that ships inside the Node base image
|
||||
# (usr/local/lib/node_modules/npm/node_modules/picomatch, v4.0.3), used at
|
||||
# image-build time by npm, not by our runtime. Our application dependency
|
||||
# tree pins picomatch >= 4.0.4 via a pnpm override, so the running app is
|
||||
# not affected and the path is not reachable at runtime. Re-check when the
|
||||
# Node base image ships an npm build whose bundled picomatch is >= 4.0.4,
|
||||
# then drop this entry.
|
||||
CVE-2026-33671
|
||||
@@ -2,6 +2,49 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.18.6] — 2026-06-18 — offline reads, grounded guidance, and one consistent app
|
||||
|
||||
A large release that makes the app genuinely useful offline, gives every metric cited reference context, grounds the AI Coach and Daily Briefing in that same context, adds responsible safety alerts for clinically urgent readings, and brings the newer modules (preventive care, illness, labs) and the whole settings area in line with the established patterns. A guided tour now explains what each module does and how they connect. No breaking changes; all schema changes are additive.
|
||||
|
||||
### Added
|
||||
|
||||
- **A module-by-module guided tour.** After onboarding, a short tour walks each module — what it does, one capability you might miss, and how it feeds the others — so the whole app makes sense on day one. Replayable any time from Settings, or per module from its page.
|
||||
- **Cited reference ranges everywhere.** Metrics now carry plain-language explainers and the general reference range they sit in (blood pressure, resting heart rate, glucose, blood oxygen, temperature, and more), framed as general guidance and sourced from recognised guidelines — never a diagnosis. Blood pressure follows the ESH range. The blood-pressure detail now also shows pulse pressure and mean arterial pressure, derived from your readings.
|
||||
- **A Coach that knows the ranges.** The AI Coach and Daily Briefing are now grounded in those cited ranges, so a comment about a value reflects where it sits in the general range — still general guidance, never a diagnosis, with no third-party attribution.
|
||||
- **Proactive Coach check-ins you can actually see.** A proactive nudge now appears as a message in your Coach conversation (not only as a push), with a discreet unread dot on the Coach button that clears when you open it — so it works even without notifications set up.
|
||||
- **Responsible safety alerts.** A confirmed, re-measured reading in a clinically urgent range (very high or low blood pressure, low or high glucose) now raises an alert that says to re-check or contact your doctor — never a diagnosis, and only for the modules you use.
|
||||
- **A diabetes setting.** An explicit opt-in switches glucose targets to the tighter clinical goal range; it is never inferred from your readings.
|
||||
- **Per-module settings for preventive care, illness and labs.** Each gets its own settings page to reorder items and switch between card and list view; labs additionally lets you manage biomarkers and choose the sort order.
|
||||
- **Mark a reminder done.** A measurement reminder can be completed server-side (for the apps), instead of only being dismissed locally.
|
||||
- **Real offline reads.** Opened without a connection, the installed app shows your last synced dashboard and recent data with a "showing your last synced data" note, rather than blank placeholders. Changes you make offline still save when you reconnect.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Preventive-care cards match the medication cards.** A clear "measure now" action, the interval as a chip, and progress toward the next due date — with measurement-appropriate wording (no more "intake" for a blood-pressure check) and the same calm, neutral card.
|
||||
- **One consistent "add" and a settings wrench** across preventive care, illness and labs; the illness journal's resolved entries are collapsed by default.
|
||||
- **Every Settings and Admin page now has a clear heading and description**, in one shared frame, with the side menu lined up to the content — so the whole area reads as one piece. The AI section is now labelled "AI provider", and module availability has its own admin page.
|
||||
- **No more AI dead-end.** When no AI provider is connected, onboarding and the Coach/Insights/Briefing screens explain what the feature does and how to set it up, instead of a bare error.
|
||||
- **Clearer sleep, recovery and insights.** The estimated sleep-stage timeline is shown only when it reflects measured stages (with an honest note otherwise); the chronotype reads as labelled text; the sleep-quality assessment is more useful; the recovery page is de-cluttered; and insight cards link through to their detail pages, including steps.
|
||||
- **A faster dashboard.** All charts load in one batched request instead of one per chart, with target ranges computed on the server.
|
||||
- **Fewer disclaimers, one clear acknowledgment.** The repeated "not medical advice" banners are gone in favour of a single acknowledgment at onboarding (reachable any time in Settings).
|
||||
- **Mobile polish.** Small tap targets were brought up to a comfortable size, and a global reduced-motion safeguard quiets animation when your device asks for it.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Reduced motion no longer hides content.** Charts and list rows that animate in now stay visible when the device requests reduced motion, instead of disappearing.
|
||||
- **Read-after-write.** A reading or medication you just added now reliably appears in its list.
|
||||
- **A timezone bug** that anchored the local day at UTC midnight for one intake path.
|
||||
- **Contrast.** The preventive-care action button now meets AA contrast in dark mode.
|
||||
|
||||
### Security
|
||||
|
||||
- **The offline cache is hardened.** It stores only a safe, read-only allowlist (never anything tied to sign-in or sensitive AI conversations), is bound to the signed-in account, and is wiped when the session ends — not only on explicit sign-out.
|
||||
- The container scan ignores a known `picomatch` advisory (CVE-2026-33671) that exists only in the npm CLI bundled inside the base image, not in the app's own dependencies (pinned to the fixed version).
|
||||
|
||||
### Operator note
|
||||
|
||||
- Additive migrations only (`0173`–`0177`: tour progress, diabetes opt-in, disclaimer acknowledgment, Coach last-seen, a Coach-message index). The optional `APNS_CRITICAL_ENTITLEMENT` env var (introduced in 1.18.4) gates native critical alerts; urgent alerts ship as time-sensitive without it. The service-worker cache version bumps with the release, so clients refresh their offline cache automatically on update.
|
||||
|
||||
## [1.18.5] — 2026-06-18 — filter by value, a tidier advanced panel, and a titration timeline for injectables
|
||||
|
||||
A small polish release. You can now filter your readings by value, the advanced medication settings collapse into sections instead of one long page, and injectable medications show their dose-escalation plan as a timeline. No breaking changes; no migration.
|
||||
|
||||
@@ -4,7 +4,7 @@ Working notes for anyone (human or otherwise) editing this repository. Skim befo
|
||||
|
||||
## What HealthLog is
|
||||
|
||||
Self-hosted personal-health-tracking PWA: weight, blood pressure, pulse, body composition, glucose, sleep, mood, medication compliance. Withings + Apple Health sync, multi-provider AI Insights (BYOK or local), doctor-report PDF, native iOS client in public beta. Single `docker compose up`, Postgres-backed, AES-256-GCM at rest. PolyForm Noncommercial 1.0.0 (AGPL-3.0 through v1.15.18). Current line: v1.12.x.
|
||||
Self-hosted personal-health-tracking PWA: weight, blood pressure, pulse, body composition, glucose, sleep, mood, medication compliance. Withings + Apple Health sync, multi-provider AI Insights (BYOK or local), doctor-report PDF, native iOS client in public beta. Single `docker compose up`, Postgres-backed, AES-256-GCM at rest. PolyForm Noncommercial 1.0.0 (AGPL-3.0 through v1.15.18). Current line: v1.18.x.
|
||||
|
||||
## Voice and privacy
|
||||
|
||||
@@ -37,8 +37,8 @@ Prefer patch bumps. A release dominated by bug fixes plus additive features with
|
||||
| Runtime | Node 22 (Alpine) + pnpm 10.31 | Node version fixed only by the Dockerfile base image; no `.nvmrc` or `engines`. |
|
||||
| Framework | Next.js 16.2.6 — App Router, `output: "standalone"` | SWC strips `console.*` in prod except `error/warn`. |
|
||||
| Language | TypeScript 6, `strict: true`, `moduleResolution: "bundler"` | Path alias `@/*` → `./src/*`. `scripts/` excluded from typecheck. |
|
||||
| UI | React 19.2.5 (exact), Tailwind 4, shadcn/ui (new-york style, zinc base), Radix, Lucide | Recharts 3 for charts — stays; replacement requires explicit approval. |
|
||||
| Persistence | PostgreSQL 16 + Prisma 7.8 — 61 models | Generated client at `src/generated/prisma`. Production image ships a separate `/opt/prisma-cli` install + `/opt/pg-boss` runtime install (see `Dockerfile:74-83`). |
|
||||
| UI | React 19 (exact pin), Tailwind 4, shadcn/ui (new-york style, zinc base), Radix, Lucide | Recharts 3 for charts — stays; replacement requires explicit approval. |
|
||||
| Persistence | PostgreSQL 16 + Prisma 7.8 — 80 models | Generated client at `src/generated/prisma`. Production image ships a separate `/opt/prisma-cli` install + `/opt/pg-boss` runtime install (see `Dockerfile:93-100`). |
|
||||
| Queue | pg-boss 12.18 | Cron + retry semantics live in Postgres tables; workers under `src/lib/jobs/`. |
|
||||
| Auth | `@simplewebauthn/server` 13, `@node-rs/argon2` 2 | Passkey + Argon2id password, server-side sessions in Postgres. |
|
||||
| Notifications | `@parse/node-apn` 8 (APNs), `web-push` 3 (VAPID), raw fetch for Telegram + ntfy | Per-channel `recordPushAttempt` row, hard-reject classification. |
|
||||
@@ -71,8 +71,9 @@ handler body Zod parse (`safeParse` + `returnAllZodIssue
|
||||
│ business logic, `annotate({ action, meta })`,
|
||||
│ `auditLog()` for sensitive ops.
|
||||
▼
|
||||
src/lib/api-response.ts `apiSuccess(data, meta?)` / `apiError(msg, status,
|
||||
│ meta?)` — every response is `{ data, error, meta? }`.
|
||||
src/lib/api-response.ts `apiSuccess(data, status?)` → `{ data, error: null }`;
|
||||
│ `apiError(msg, status, meta?)` threads `meta` on the
|
||||
│ error path only — `{ data: null, error, meta? }`.
|
||||
▼
|
||||
src/proxy.ts security headers attach, request-id echoes.
|
||||
▼
|
||||
@@ -87,9 +88,9 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file:
|
||||
- **Every API route wraps in `apiHandler`.** Verified — zero exceptions in the current tree. Every body-accepting route runs Zod `safeParse` and returns 422 via `returnAllZodIssues` (multi-issue envelope sanitised against echoed input through `sanitiseZodIssues`).
|
||||
- **`userId` is always narrowed from session or Bearer.** No route accepts `userId` as a body field — it comes from `requireAuth()` and feeds the Prisma `where`. The batch endpoints (`/api/measurements/batch`, `/api/mood-entries/bulk`, `/api/medications/intake/bulk`, `/api/workouts/batch`) don't even declare a `userId` field in their Zod schemas.
|
||||
- **No mass assignment.** Every `prisma.X.{create,update}({ data: ... })` builds its `data` object field-by-field from `parsed.data`, never by spreading the parsed object whole. `tokens.create` hardcodes the narrow scope so the user-facing endpoint can never mint a wildcard token.
|
||||
- **`requireAdmin()` is cookie-only.** Bearer tokens — even with `["*"]` scope — cannot reach admin endpoints. This is a structural boundary at `src/lib/api-handler.ts:414-431`, not a runtime check that future code can soften.
|
||||
- **Raw SQL is parameter-bound or whitelist-spliced.** Every `$queryRaw` uses tagged-template parameters. Every `$queryRawUnsafe` / `$executeRawUnsafe` either passes positional `$N` placeholders with an argument array OR splices a value already asserted against a closed enum + regex (see `src/lib/rollups/measurement-rollups.ts:330-343`). Inline comments document the whitelist at the splice point.
|
||||
- **TanStack Query keys live in the centralised factory.** `src/lib/query-keys.ts` is the only legal source of `queryKey` / `mutationKey` arrays. The in-repo ESLint rule `healthlog/queryKey-factory` (set to `error`) flags any bare array. Same-key + different `queryFn` shape silently poisons the cache; the factory prevents it. Every read unwraps `(await res.json()).data` from the envelope.
|
||||
- **`requireAdmin()` is cookie-only.** Bearer tokens — even with `["*"]` scope — cannot reach admin endpoints. This is a structural boundary in `requireAdmin()` (`src/lib/api-handler.ts`), not a runtime check that future code can soften.
|
||||
- **Raw SQL is parameter-bound or whitelist-spliced.** Every `$queryRaw` uses tagged-template parameters. Every `$queryRawUnsafe` / `$executeRawUnsafe` either passes positional `$N` placeholders with an argument array OR splices a value already asserted against a closed enum + regex (see the whitelist-splice block in `src/lib/rollups/measurement-rollups.ts`, around the `$queryRawUnsafe` calls). Inline comments document the whitelist at the splice point.
|
||||
- **TanStack Query keys live in the centralised factory.** `src/lib/query-keys/` (16 modules, `index.ts` barrel) is the only legal source of `queryKey` / `mutationKey` arrays. The in-repo ESLint rule `healthlog/queryKey-factory` (set to `error`) flags any bare array. Same-key + different `queryFn` shape silently poisons the cache; the factory prevents it. Every read unwraps `(await res.json()).data` from the envelope.
|
||||
- **`annotate()` on every interesting code path.** Wide-event observability. Action names follow `<surface>.<noun>.<verb>` (`coach.budget.exceeded`, `measurement.batch.ingest`). Don't free-text in `meta` — pin the shape so dashboards stay stable.
|
||||
- **i18n keys exist for every `t()` call.** `i18n-call-site-coverage.test.ts` walks every `.ts(x)` under `src/`, extracts every `t("ns.key")` literal, and asserts each key resolves in `messages/en.json`. `i18n-locale-integrity.test.ts` propagates the EN guarantee across `de / en / es / fr / it / pl`. If a guard fails, fix the bundle, don't suppress the test.
|
||||
- **OpenAPI registry stays in sync.** Zod schemas carry `.meta()` annotations under `src/lib/openapi/`; `pnpm openapi:generate` emits `docs/api/openapi.yaml`. CI fails on drift. Re-run the generator after touching a request / response schema and commit the YAML alongside the Zod change.
|
||||
@@ -112,7 +113,7 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file:
|
||||
| Concern | File | What it owns |
|
||||
|---|---|---|
|
||||
| HTTP edge | `src/proxy.ts` | Public-path allowlist, demo-mode block, onboarding redirect, request-id + CSP nonce, every security header, worker-only refusal. |
|
||||
| API kit | `src/lib/api-handler.ts` (490 LOC) | `apiHandler` wrapper, `requireAuth` / `requireAdmin`, idempotency plumbing, GlitchTip forwarder. |
|
||||
| API kit | `src/lib/api-handler.ts` | `apiHandler` wrapper, `requireAuth` / `requireAdmin`, idempotency plumbing, GlitchTip forwarder. |
|
||||
| Response envelope | `src/lib/api-response.ts` | `apiSuccess` / `apiError`, `safeJson` (now with opt-in `maxBytes`), trusted-proxy IP resolver, `returnAllZodIssues` + `sanitiseZodIssues`. |
|
||||
| Crypto at rest | `src/lib/crypto.ts` | AES-256-GCM with versioned key ids, `extractKeyId`, fail-closed loader, rotation primitives. Rotation CLI: `scripts/rotate-encryption-key.ts`. |
|
||||
| Session | `src/lib/auth/session.ts` + `src/lib/auth/secure-cookie.ts` | Postgres-backed sessions, sliding 30-day expiry, `shouldEmitSecureCookie()` the one source of truth for the `Secure` flag (every cookie setter routes through it). |
|
||||
@@ -120,11 +121,11 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file:
|
||||
| Bearer tokens | `src/lib/auth/hmac.ts` + `src/lib/auth/issue-token.ts` | HMAC-SHA256 hashing under `API_TOKEN_HMAC_KEY`; no plaintext path; `lastUsedAt` updated fire-and-forget. |
|
||||
| Rollup tier | `src/lib/rollups/` | DAY / WEEK / MONTH / YEAR pre-aggregations. Read-swap pattern: try the rollup, fall back to live SQL only on coverage miss. Boot-time `rollup-full-backfill` queue handles new accounts. |
|
||||
| Compliance | `src/lib/analytics/compliance.ts` | Cadence-aware medication compliance — daysOfWeek + intervalWeeks honoured across the eight call sites that surface a rate (Coach prompt, BP-status gate, dashboard pillar, …). |
|
||||
| AI providers | `src/lib/ai/provider-chain.ts` + `src/lib/ai/{openai,anthropic,local,codex,mock}-client.ts` | Five providers, hand-rolled fetch over the documented wire (no vendor SDKs). Mock is not registered in `resolveProvider()` — production cannot reach it. |
|
||||
| AI providers | `src/lib/ai/provider.ts` (`resolveProvider`) + `src/lib/ai/provider-chain.ts` (`PROVIDER_CHAIN_TYPES`) + `src/lib/ai/{openai,anthropic,local,codex,mock}-client.ts` | Five providers, hand-rolled fetch over the documented wire (no vendor SDKs). Mock is excluded structurally by the `PROVIDER_CHAIN_TYPES` allowlist — production cannot reach it. |
|
||||
| Notification dispatcher | `src/lib/notifications/dispatcher.ts` + `src/lib/notifications/senders/` | APNs → Telegram → ntfy → Web Push cascade, hard-reject classification, `push_attempts` ledger with 90-day retention. |
|
||||
| Coach | `src/app/api/insights/chat/route.ts` + `src/lib/ai/coach/` | SSE stream, budget gate + per-user rate gate, refusal detector, snapshot builder, message persistence with `encryptedContent` Bytes column. |
|
||||
| OpenAPI | `src/lib/openapi/registry.ts` + `src/lib/openapi/routes.ts` | Source of truth for `docs/api/openapi.yaml`. |
|
||||
| Schema | `prisma/schema.prisma` (61 models) | `cuid()` PKs, `snake_case` columns via `@map`, encrypted columns mostly under `*Encrypted` (search the file). |
|
||||
| OpenAPI | `src/lib/openapi/registry.ts` + `src/lib/openapi/routes/` (23 modules, `index.ts` barrel) | Source of truth for `docs/api/openapi.yaml`. |
|
||||
| Schema | `prisma/schema.prisma` (80 models) | `cuid()` PKs, `snake_case` columns via `@map`, encrypted columns mostly under `*Encrypted` (search the file). |
|
||||
| Compose | `docker-compose.yml` | `app` + `db`. Env-var whitelist under `environment:` — vars not listed never reach the container. `pull_policy: always` is load-bearing. |
|
||||
|
||||
`.planning/codebase/arch.md` carries the full annotated walk for every section.
|
||||
@@ -134,7 +135,7 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file:
|
||||
The v1.5.2 audit produced `.planning/security-audit-v1.5.2.md` and a stack of per-domain findings under `.planning/security-audit-findings/`. The patterns worth preserving on future churn:
|
||||
|
||||
- **Encryption: fail closed everywhere.** Missing keys, malformed JSON, unknown key ids — every path throws rather than silently writing plaintext. Operators rotating keys must keep the legacy entry in `ENCRYPTION_KEYS` until the rotation script reports zero remaining legacy rows. The script covers every `*Encrypted` column + `CoachMessage.encryptedContent` (Bytes) + `IntegrationStatus.lastError`.
|
||||
- **SSRF: input-time `isPublicUrl` is the floor.** The helper in `src/lib/validations/notifications.ts:64` catches every IPv4 / IPv6 alt-notation class (octal / hex / decimal IPv4, IPv4-mapped IPv6, IPv6 ULA, CGNAT, link-local, loopback, metadata). Use it for every outbound URL that comes from a user / admin input. DNS rebinding and the redirect-follow gap remain as architectural follow-ups (issues #217 + #218). Until those land, every NEW outbound `fetch()` that touches a user-controlled host must pin `redirect: "manual"` and an `AbortSignal.timeout`.
|
||||
- **SSRF: input-time `isPublicUrl` is the floor.** The helper `isPublicUrl()` in `src/lib/validations/notifications.ts` catches every IPv4 / IPv6 alt-notation class (octal / hex / decimal IPv4, IPv4-mapped IPv6, IPv6 ULA, CGNAT, link-local, loopback, metadata). Use it for every outbound URL that comes from a user / admin input. DNS rebinding and the redirect-follow gap remain as architectural follow-ups (issues #217 + #218). Until those land, every NEW outbound `fetch()` that touches a user-controlled host must pin `redirect: "manual"` and an `AbortSignal.timeout`.
|
||||
- **Outbound egress goes through `safeFetch`.** `src/lib/safe-fetch.ts` is the one documented egress entry (`redirect: "manual"` + `AbortSignal.timeout(15_000)` defaults; opt into the connect-time DNS-rebinding pin with `requirePublicHost: true` for any operator- or user-supplied host). The in-repo ESLint rule `healthlog/safe-fetch-required` (set to `error`) bans raw `fetch(` under `src/lib/` + `src/app/` outside the wrapper internals and test files; same-origin relative-path (`/api/…`) client fetches are exempt by construction.
|
||||
- **Rate limits live in Postgres.** `src/lib/rate-limit.ts` uses a single atomic SQL upsert returning `{count, reset_at}`. Multi-instance correctness is structural. Bucket-key convention: `<surface>:${identifier}` where identifier is `userId` for authenticated routes and the trusted-proxy IP for anonymous. Anonymous auth surfaces use `checkAuthSurfaceRateLimit(...)` so a trust-violation collapses every anonymous caller into a single tight bucket rather than a free-for-all under the unknown-IP bucket.
|
||||
- **Webhooks: shared-secret + `timingSafeEqual` + per-source rate limit BEFORE secret verification.** Withings, Telegram, moodLog, the Coolify deploy webhook, and the CSP report endpoint all follow this exact pattern. The Withings path-segment secret is scrubbed from `http.path` / `http.route` by `redactSecrets()` via the `PATH_SECRET_PATHS` registry in `src/lib/logging/redact.ts:40`.
|
||||
|
||||
+779
-1
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.18.5
|
||||
version: 1.18.6
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -1443,6 +1443,55 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/measurements/series-batch:
|
||||
get:
|
||||
tags:
|
||||
- Measurements
|
||||
summary: Batched daily series (dashboard fetch coalescing)
|
||||
description: Returns the rollup-backed daily series for every requested MeasurementType in ONE response, so the
|
||||
dashboard chart row does a single round-trip instead of one self-fetch per chart. Each per-type series is
|
||||
byte-identical with `GET /api/measurements?aggregate=daily&source=rollup` for the same window. SLEEP_DURATION is
|
||||
not served here (it rides the per-night reconstruction at `/api/measurements/series?kind=sleep`). Auth via
|
||||
cookie or Bearer.
|
||||
parameters:
|
||||
- in: query
|
||||
name: types
|
||||
schema:
|
||||
description: Comma-separated list of MeasurementType values (1..16). Each is read as a daily series through the rollup
|
||||
tier.
|
||||
type: string
|
||||
minLength: 1
|
||||
required: true
|
||||
description: Comma-separated list of MeasurementType values (1..16). Each is read as a daily series through the rollup
|
||||
tier.
|
||||
- in: query
|
||||
name: from
|
||||
schema:
|
||||
description: Window start (ISO-8601 instant).
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
required: true
|
||||
description: Window start (ISO-8601 instant).
|
||||
- in: query
|
||||
name: to
|
||||
schema:
|
||||
description: Window end (ISO-8601 instant).
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
required: true
|
||||
description: Window end (ISO-8601 instant).
|
||||
responses:
|
||||
"200":
|
||||
description: Per-type daily series.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GetMeasurementsSeriesBatchResponse"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/measurements/series:
|
||||
get:
|
||||
tags:
|
||||
@@ -1788,6 +1837,8 @@ paths:
|
||||
minLength: 1
|
||||
maxLength: 32
|
||||
- type: "null"
|
||||
symptomsPresent:
|
||||
type: boolean
|
||||
required:
|
||||
- type
|
||||
- value
|
||||
@@ -3121,6 +3172,48 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/auth/me/diabetes:
|
||||
get:
|
||||
tags:
|
||||
- Auth
|
||||
summary: Read per-user diabetes opt-in flag
|
||||
description: Returns the user's per-account diabetes opt-in flag. Default `false`. When `true`, the glucose target
|
||||
resolver applies the tighter ADA glycemic GOAL bands (fasting 80–130, postprandial < 180) instead of the general
|
||||
non-diabetic bands. Powers the Settings "I have diabetes / clinician glucose targets" toggle. Never inferred
|
||||
from a reading; asserts no diagnosis.
|
||||
responses:
|
||||
"200":
|
||||
description: Resolved flag.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GetDiabetesResponse"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
patch:
|
||||
tags:
|
||||
- Auth
|
||||
summary: Toggle per-user diabetes opt-in flag
|
||||
description: Sets the per-account diabetes opt-in flag. Idempotent — the DB write fires even when the value matches so
|
||||
the audit-log row mirrors the API call. Always returns the resolved next-state. `userId` is never accepted from
|
||||
the body. Rate-limit 60/min per user.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DiabetesFlag"
|
||||
responses:
|
||||
"200":
|
||||
description: Resolved next-state echoed back for optimistic-update consumers.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PatchDiabetesResponse"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/auth/me/modules:
|
||||
get:
|
||||
tags:
|
||||
@@ -4292,6 +4385,47 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/insights/coach/nudge-status:
|
||||
get:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Whether an unopened Coach message is waiting
|
||||
description: v1.18.6 (CCH-03) — server-authoritative unread signal for the Coach FAB. `unread` is true when the caller's
|
||||
newest Coach ASSISTANT message (a proactive nudge or any reply) is newer than `User.coachLastSeenAt`; a user who
|
||||
has never opened the Coach reads an existing nudge as unread exactly once. `nudgedAt` carries that newest
|
||||
assistant-message timestamp (null when none exists) so the client can key a local seen-mirror on a stable value.
|
||||
Coach-gated (`requireAssistantSurface("coach")`); a disabled surface 403s. Auth via cookie or Bearer; the owner
|
||||
is narrowed from the session.
|
||||
responses:
|
||||
"200":
|
||||
description: The current unread state.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoachNudgeStatus"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/insights/coach/seen:
|
||||
post:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Mark the Coach as opened (clear the unread dot)
|
||||
description: v1.18.6 (CCH-03) — opening the Coach (drawer or full page) stamps `User.coachLastSeenAt = now()`, so `GET
|
||||
/api/insights/coach/nudge-status` then reports no assistant message newer than the stamp and the FAB drops the
|
||||
unread dot. Server-authoritative, so the cleared state follows the caller across web + iOS rather than just the
|
||||
opening device. No request body — the timestamp is server-minted, so a client can never backdate the stamp to
|
||||
suppress a future nudge. Coach-gated; a disabled surface 403s. Auth via cookie or Bearer.
|
||||
responses:
|
||||
"200":
|
||||
description: The Coach was marked opened; the stamp is echoed back.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoachSeenResponse"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/coach/about-me:
|
||||
get:
|
||||
tags:
|
||||
@@ -5773,6 +5907,33 @@ paths:
|
||||
"404": *a7
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/measurement-reminders/{id}/complete:
|
||||
post:
|
||||
tags:
|
||||
- MeasurementReminders
|
||||
summary: Complete a Vorsorge reminder (v1.18.6)
|
||||
description: "Explicit user-action completion (iOS #23): the app marks a reminder done server-side instead of only
|
||||
dismissing it locally. Routes through the same satisfaction primitive as the cron auto-resolve — stamps
|
||||
lastSatisfiedAt = now, re-anchors nextDueAt, fires no notification. Idempotent: completing an already-completed
|
||||
/ auto-satisfied reminder returns 200 with completed=false. Owner-scoped."
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
responses:
|
||||
"200":
|
||||
description: Completion applied (completed=true) or already-satisfied no-op (completed=false); reminder carries the
|
||||
canonical post-completion DTO.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MeasurementReminderCompleteEnvelope"
|
||||
"401": *a1
|
||||
"404": *a7
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/labs:
|
||||
get:
|
||||
tags:
|
||||
@@ -6700,6 +6861,54 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/onboarding/disclaimer:
|
||||
post:
|
||||
tags:
|
||||
- Onboarding
|
||||
summary: Acknowledge the one-time medical disclaimer
|
||||
description: "Stamps the user's medical-disclaimer acknowledgment. Idempotent: a repeat acknowledgment of the same
|
||||
version refreshes the timestamp. The body version is a freshness signal so a stale shell cannot record copy it
|
||||
never rendered; the server persists its own canonical version."
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DisclaimerAckRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Disclaimer acknowledged.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DisclaimerAckEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/onboarding/tour:
|
||||
post:
|
||||
tags:
|
||||
- Onboarding
|
||||
summary: Update module-tour completion + resume point
|
||||
description: Persists the module-tour state. The client posts a fire-and-forget `progress` checkpoint on each step so a
|
||||
reload resumes at the right module, and a terminal `completed:true` with `outcome` when the tour ends.
|
||||
`completed:false` is a replay reset that also clears the resume point.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TourUpdateRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Tour state updated.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TourUpdateEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
components:
|
||||
schemas:
|
||||
PasskeyLoginVerifyRequest:
|
||||
@@ -7595,6 +7804,16 @@ components:
|
||||
required:
|
||||
- disableCoach
|
||||
description: Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates.
|
||||
DiabetesFlag:
|
||||
type: object
|
||||
properties:
|
||||
hasDiabetes:
|
||||
type: boolean
|
||||
required:
|
||||
- hasDiabetes
|
||||
description: Per-account diabetes opt-in (v1.18.6). `true` selects the ADA glycemic GOAL bands (fasting 80–130,
|
||||
postprandial < 180) for glucose targets instead of the general non-diabetic bands. A user-declared preference
|
||||
only — never inferred from a value, never a diagnosis.
|
||||
ModulePrefsPatchRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -8030,6 +8249,67 @@ components:
|
||||
type: string
|
||||
required:
|
||||
- ids
|
||||
DisclaimerAckRequest:
|
||||
type: object
|
||||
properties:
|
||||
version:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
description: The disclaimer copy version the client rendered. A freshness signal only — the server pins and persists its
|
||||
own canonical version.
|
||||
required:
|
||||
- version
|
||||
description: Acknowledge the one-time medical disclaimer shown at onboarding.
|
||||
TourUpdateRequest:
|
||||
type: object
|
||||
properties:
|
||||
completed:
|
||||
description: Flip the coarse completion flag. `false` is a replay reset and clears the stored progress point.
|
||||
type: boolean
|
||||
outcome:
|
||||
description: Informational — distinguishes reaching the end from a skip.
|
||||
type: string
|
||||
enum:
|
||||
- completed
|
||||
- skipped
|
||||
progress:
|
||||
description: Mid-tour resume checkpoint. May arrive alone or with `completed`.
|
||||
$ref: "#/components/schemas/TourProgress"
|
||||
description: Update the module-tour state. Provide `completed` and/or `progress`.
|
||||
TourProgress:
|
||||
type: object
|
||||
properties:
|
||||
lastStopId:
|
||||
anyOf:
|
||||
- type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
- type: "null"
|
||||
completedStopIds:
|
||||
default: []
|
||||
maxItems: 64
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- in_progress
|
||||
- completed
|
||||
- skipped
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$
|
||||
required:
|
||||
- lastStopId
|
||||
- status
|
||||
- updatedAt
|
||||
description: Resumable module-tour progress point. `lastStopId` seeds the resume index; `status` is the running/terminal
|
||||
state.
|
||||
MedicationTreatmentClass:
|
||||
type: string
|
||||
enum:
|
||||
@@ -10175,6 +10455,277 @@ components:
|
||||
- updatedAt
|
||||
additionalProperties: false
|
||||
description: A soft-deleted medication intake, keyed on server `id`. Apply before upserts within the domain page.
|
||||
GetMeasurementsSeriesBatchResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/MeasurementsSeriesBatchResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
MeasurementsSeriesBatchResponse:
|
||||
type: object
|
||||
properties:
|
||||
series:
|
||||
type: object
|
||||
propertyNames:
|
||||
type: string
|
||||
enum:
|
||||
- WEIGHT
|
||||
- BLOOD_PRESSURE_SYS
|
||||
- BLOOD_PRESSURE_DIA
|
||||
- PULSE
|
||||
- BODY_FAT
|
||||
- SLEEP_DURATION
|
||||
- ACTIVITY_STEPS
|
||||
- BLOOD_GLUCOSE
|
||||
- TOTAL_BODY_WATER
|
||||
- BONE_MASS
|
||||
- OXYGEN_SATURATION
|
||||
- HEART_RATE_VARIABILITY
|
||||
- RESTING_HEART_RATE
|
||||
- ACTIVE_ENERGY_BURNED
|
||||
- FLIGHTS_CLIMBED
|
||||
- WALKING_RUNNING_DISTANCE
|
||||
- VO2_MAX
|
||||
- BODY_TEMPERATURE
|
||||
- FAT_FREE_MASS
|
||||
- FAT_MASS
|
||||
- MUSCLE_MASS
|
||||
- SKIN_TEMPERATURE
|
||||
- PULSE_WAVE_VELOCITY
|
||||
- VASCULAR_AGE
|
||||
- VISCERAL_FAT
|
||||
- AUDIO_EXPOSURE_ENV
|
||||
- AUDIO_EXPOSURE_HEADPHONE
|
||||
- TIME_IN_DAYLIGHT
|
||||
- WALKING_STEADINESS
|
||||
- AUDIO_EXPOSURE_EVENT
|
||||
- RESPIRATORY_RATE
|
||||
- BODY_MASS_INDEX
|
||||
- LEAN_BODY_MASS
|
||||
- WALKING_HEART_RATE_AVERAGE
|
||||
- WALKING_ASYMMETRY
|
||||
- WALKING_DOUBLE_SUPPORT
|
||||
- WALKING_STEP_LENGTH
|
||||
- WALKING_SPEED
|
||||
- CARDIO_RECOVERY
|
||||
- WRIST_TEMPERATURE
|
||||
- FALL_COUNT
|
||||
- SIX_MINUTE_WALK_DISTANCE
|
||||
- STAIR_ASCENT_SPEED
|
||||
- STAIR_DESCENT_SPEED
|
||||
- BREATHING_DISTURBANCES
|
||||
- IRREGULAR_RHYTHM_NOTIFICATION
|
||||
- HIGH_HEART_RATE_EVENT
|
||||
- LOW_HEART_RATE_EVENT
|
||||
- WALKING_STEADINESS_EVENT
|
||||
- BREATHING_DISTURBANCE_EVENT
|
||||
- RECOVERY_SCORE
|
||||
- STRESS_SCORE
|
||||
- STRAIN_SCORE
|
||||
- HRV_RMSSD
|
||||
- DAY_STRAIN
|
||||
- WORKOUT_STRAIN
|
||||
- SLEEP_PERFORMANCE
|
||||
- SLEEP_EFFICIENCY
|
||||
- SLEEP_CONSISTENCY
|
||||
- SLEEP_NEED
|
||||
- ENERGY_EXPENDITURE_KJ
|
||||
- AVERAGE_HEART_RATE
|
||||
- MAX_HEART_RATE
|
||||
- SLEEP_DISTURBANCE_COUNT
|
||||
- ANS_CHARGE
|
||||
- CARDIO_LOAD
|
||||
- SLEEP_SCORE
|
||||
- BODY_TEMPERATURE_DEVIATION
|
||||
additionalProperties:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- WEIGHT
|
||||
- BLOOD_PRESSURE_SYS
|
||||
- BLOOD_PRESSURE_DIA
|
||||
- PULSE
|
||||
- BODY_FAT
|
||||
- SLEEP_DURATION
|
||||
- ACTIVITY_STEPS
|
||||
- BLOOD_GLUCOSE
|
||||
- TOTAL_BODY_WATER
|
||||
- BONE_MASS
|
||||
- OXYGEN_SATURATION
|
||||
- HEART_RATE_VARIABILITY
|
||||
- RESTING_HEART_RATE
|
||||
- ACTIVE_ENERGY_BURNED
|
||||
- FLIGHTS_CLIMBED
|
||||
- WALKING_RUNNING_DISTANCE
|
||||
- VO2_MAX
|
||||
- BODY_TEMPERATURE
|
||||
- FAT_FREE_MASS
|
||||
- FAT_MASS
|
||||
- MUSCLE_MASS
|
||||
- SKIN_TEMPERATURE
|
||||
- PULSE_WAVE_VELOCITY
|
||||
- VASCULAR_AGE
|
||||
- VISCERAL_FAT
|
||||
- AUDIO_EXPOSURE_ENV
|
||||
- AUDIO_EXPOSURE_HEADPHONE
|
||||
- TIME_IN_DAYLIGHT
|
||||
- WALKING_STEADINESS
|
||||
- AUDIO_EXPOSURE_EVENT
|
||||
- RESPIRATORY_RATE
|
||||
- BODY_MASS_INDEX
|
||||
- LEAN_BODY_MASS
|
||||
- WALKING_HEART_RATE_AVERAGE
|
||||
- WALKING_ASYMMETRY
|
||||
- WALKING_DOUBLE_SUPPORT
|
||||
- WALKING_STEP_LENGTH
|
||||
- WALKING_SPEED
|
||||
- CARDIO_RECOVERY
|
||||
- WRIST_TEMPERATURE
|
||||
- FALL_COUNT
|
||||
- SIX_MINUTE_WALK_DISTANCE
|
||||
- STAIR_ASCENT_SPEED
|
||||
- STAIR_DESCENT_SPEED
|
||||
- BREATHING_DISTURBANCES
|
||||
- IRREGULAR_RHYTHM_NOTIFICATION
|
||||
- HIGH_HEART_RATE_EVENT
|
||||
- LOW_HEART_RATE_EVENT
|
||||
- WALKING_STEADINESS_EVENT
|
||||
- BREATHING_DISTURBANCE_EVENT
|
||||
- RECOVERY_SCORE
|
||||
- STRESS_SCORE
|
||||
- STRAIN_SCORE
|
||||
- HRV_RMSSD
|
||||
- DAY_STRAIN
|
||||
- WORKOUT_STRAIN
|
||||
- SLEEP_PERFORMANCE
|
||||
- SLEEP_EFFICIENCY
|
||||
- SLEEP_CONSISTENCY
|
||||
- SLEEP_NEED
|
||||
- ENERGY_EXPENDITURE_KJ
|
||||
- AVERAGE_HEART_RATE
|
||||
- MAX_HEART_RATE
|
||||
- SLEEP_DISTURBANCE_COUNT
|
||||
- ANS_CHARGE
|
||||
- CARDIO_LOAD
|
||||
- SLEEP_SCORE
|
||||
- BODY_TEMPERATURE_DEVIATION
|
||||
value:
|
||||
type: number
|
||||
description: Daily mean (spot metrics) or SUM (cumulative).
|
||||
measuredAt:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
description: Day-bucket start (ISO-8601).
|
||||
count:
|
||||
description: Underlying raw-row count for the bucket.
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
minValue:
|
||||
description: Per-day minimum (spot metrics only; absent for cumulative).
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: "null"
|
||||
maxValue:
|
||||
description: Per-day maximum (spot metrics only; absent for cumulative).
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: "null"
|
||||
required:
|
||||
- type
|
||||
- value
|
||||
- measuredAt
|
||||
additionalProperties: false
|
||||
required:
|
||||
- WEIGHT
|
||||
- BLOOD_PRESSURE_SYS
|
||||
- BLOOD_PRESSURE_DIA
|
||||
- PULSE
|
||||
- BODY_FAT
|
||||
- SLEEP_DURATION
|
||||
- ACTIVITY_STEPS
|
||||
- BLOOD_GLUCOSE
|
||||
- TOTAL_BODY_WATER
|
||||
- BONE_MASS
|
||||
- OXYGEN_SATURATION
|
||||
- HEART_RATE_VARIABILITY
|
||||
- RESTING_HEART_RATE
|
||||
- ACTIVE_ENERGY_BURNED
|
||||
- FLIGHTS_CLIMBED
|
||||
- WALKING_RUNNING_DISTANCE
|
||||
- VO2_MAX
|
||||
- BODY_TEMPERATURE
|
||||
- FAT_FREE_MASS
|
||||
- FAT_MASS
|
||||
- MUSCLE_MASS
|
||||
- SKIN_TEMPERATURE
|
||||
- PULSE_WAVE_VELOCITY
|
||||
- VASCULAR_AGE
|
||||
- VISCERAL_FAT
|
||||
- AUDIO_EXPOSURE_ENV
|
||||
- AUDIO_EXPOSURE_HEADPHONE
|
||||
- TIME_IN_DAYLIGHT
|
||||
- WALKING_STEADINESS
|
||||
- AUDIO_EXPOSURE_EVENT
|
||||
- RESPIRATORY_RATE
|
||||
- BODY_MASS_INDEX
|
||||
- LEAN_BODY_MASS
|
||||
- WALKING_HEART_RATE_AVERAGE
|
||||
- WALKING_ASYMMETRY
|
||||
- WALKING_DOUBLE_SUPPORT
|
||||
- WALKING_STEP_LENGTH
|
||||
- WALKING_SPEED
|
||||
- CARDIO_RECOVERY
|
||||
- WRIST_TEMPERATURE
|
||||
- FALL_COUNT
|
||||
- SIX_MINUTE_WALK_DISTANCE
|
||||
- STAIR_ASCENT_SPEED
|
||||
- STAIR_DESCENT_SPEED
|
||||
- BREATHING_DISTURBANCES
|
||||
- IRREGULAR_RHYTHM_NOTIFICATION
|
||||
- HIGH_HEART_RATE_EVENT
|
||||
- LOW_HEART_RATE_EVENT
|
||||
- WALKING_STEADINESS_EVENT
|
||||
- BREATHING_DISTURBANCE_EVENT
|
||||
- RECOVERY_SCORE
|
||||
- STRESS_SCORE
|
||||
- STRAIN_SCORE
|
||||
- HRV_RMSSD
|
||||
- DAY_STRAIN
|
||||
- WORKOUT_STRAIN
|
||||
- SLEEP_PERFORMANCE
|
||||
- SLEEP_EFFICIENCY
|
||||
- SLEEP_CONSISTENCY
|
||||
- SLEEP_NEED
|
||||
- ENERGY_EXPENDITURE_KJ
|
||||
- AVERAGE_HEART_RATE
|
||||
- MAX_HEART_RATE
|
||||
- SLEEP_DISTURBANCE_COUNT
|
||||
- ANS_CHARGE
|
||||
- CARDIO_LOAD
|
||||
- SLEEP_SCORE
|
||||
- BODY_TEMPERATURE_DEVIATION
|
||||
description: Per-type daily series, keyed by MeasurementType.
|
||||
required:
|
||||
- series
|
||||
additionalProperties: false
|
||||
description: Batched daily series — one rollup-backed series per requested type.
|
||||
GetMeasurementsSeriesResponse:
|
||||
type: object
|
||||
properties:
|
||||
@@ -13218,6 +13769,40 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
GetDiabetesResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/DiabetesFlagOutput"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
PatchDiabetesResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/DiabetesFlagOutput"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
GetModulesResponse:
|
||||
type: object
|
||||
properties:
|
||||
@@ -15269,6 +15854,64 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CoachNudgeStatus:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
nudgedAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
description: Timestamp of the newest Coach assistant message; null when none exists.
|
||||
unread:
|
||||
type: boolean
|
||||
description: True when that message is newer than the last time the caller opened the Coach.
|
||||
required:
|
||||
- nudgedAt
|
||||
- unread
|
||||
additionalProperties: false
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CoachSeenResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
seenAt:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
description: The server-minted open timestamp.
|
||||
required:
|
||||
- seenAt
|
||||
additionalProperties: false
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
GetCoachAboutMeResponse:
|
||||
type: object
|
||||
properties:
|
||||
@@ -18017,6 +18660,37 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
MeasurementReminderCompleteEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/MeasurementReminderCompletion"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
MeasurementReminderCompletion:
|
||||
type: object
|
||||
properties:
|
||||
completed:
|
||||
type: boolean
|
||||
reminder:
|
||||
$ref: "#/components/schemas/MeasurementReminderDTO"
|
||||
required:
|
||||
- completed
|
||||
- reminder
|
||||
additionalProperties: false
|
||||
description: Result of an explicit reminder completion. completed=true when this call advanced lastSatisfiedAt;
|
||||
completed=false when an earlier satisfy or a matching reading had already fulfilled the current cycle
|
||||
(idempotent no-op). reminder is the canonical post-completion DTO.
|
||||
ListLabResultsEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
@@ -19043,6 +19717,64 @@ components:
|
||||
description: "Cross-episode retrospective summary over the trailing window: episode + resolved counts, the typical
|
||||
(median) recovery gap (null below the min-sample floor — withholds a thin claim), a recurrence-by-month tally,
|
||||
and a per-type breakdown. Retrospective only."
|
||||
DisclaimerAckEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/DisclaimerAckResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
DisclaimerAckResponse:
|
||||
type: object
|
||||
properties:
|
||||
acknowledgedVersion:
|
||||
type: string
|
||||
description: The canonical disclaimer version the server stamped.
|
||||
required:
|
||||
- acknowledgedVersion
|
||||
additionalProperties: false
|
||||
description: The persisted disclaimer acknowledgment version.
|
||||
TourUpdateEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/TourUpdateResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
TourUpdateResponse:
|
||||
type: object
|
||||
properties:
|
||||
onboardingTourCompleted:
|
||||
type: boolean
|
||||
progress:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/TourProgressOutput"
|
||||
- type: "null"
|
||||
required:
|
||||
- onboardingTourCompleted
|
||||
- progress
|
||||
additionalProperties: false
|
||||
description: The persisted completion flag and resume point after the write.
|
||||
MedicationScheduleOutput:
|
||||
type: object
|
||||
properties:
|
||||
@@ -19183,6 +19915,17 @@ components:
|
||||
- disableCoach
|
||||
additionalProperties: false
|
||||
description: Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates.
|
||||
DiabetesFlagOutput:
|
||||
type: object
|
||||
properties:
|
||||
hasDiabetes:
|
||||
type: boolean
|
||||
required:
|
||||
- hasDiabetes
|
||||
additionalProperties: false
|
||||
description: Per-account diabetes opt-in (v1.18.6). `true` selects the ADA glycemic GOAL bands (fasting 80–130,
|
||||
postprandial < 180) for glucose targets instead of the general non-diabetic bands. A user-declared preference
|
||||
only — never inferred from a value, never a diagnosis.
|
||||
InsightsLayoutBodyOutput:
|
||||
type: object
|
||||
properties:
|
||||
@@ -19314,6 +20057,41 @@ components:
|
||||
compatibility and are normalised to their English equivalents before persisting; GET responses always carry the
|
||||
canonical English ids. Section ids are English-only. The legacy tile ids are deprecated and will be removed in a
|
||||
future major version."
|
||||
TourProgressOutput:
|
||||
type: object
|
||||
properties:
|
||||
lastStopId:
|
||||
anyOf:
|
||||
- type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
- type: "null"
|
||||
completedStopIds:
|
||||
default: []
|
||||
maxItems: 64
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- in_progress
|
||||
- completed
|
||||
- skipped
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$
|
||||
required:
|
||||
- lastStopId
|
||||
- completedStopIds
|
||||
- status
|
||||
- updatedAt
|
||||
additionalProperties: false
|
||||
description: Resumable module-tour progress point. `lastStopId` seeds the resume index; `status` is the running/terminal
|
||||
state.
|
||||
Medication:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -98,10 +98,53 @@ test.describe("authenticated dashboard render", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
// v1.18.6 — the dashboard now batches every visible chart's daily
|
||||
// series into ONE `/api/measurements/series-batch` round-trip and
|
||||
// hands each chart its slice via `preloadedSeries`. The route is a
|
||||
// sub-path of `/api/measurements`, which the `**/api/measurements*`
|
||||
// glob below does NOT match (Playwright's `*` does not cross `/`), so
|
||||
// without an explicit stub the request fell through to the real
|
||||
// backend. The seed user has no measurements, so the route returned
|
||||
// every requested type as an EMPTY array — and an empty-but-present
|
||||
// slice satisfies the chart's preloaded-coverage check, so each chart
|
||||
// rendered the batched empty slice instead of self-fetching. Mock the
|
||||
// endpoint with the same populated set so the charts paint.
|
||||
await page.route(/\/api\/measurements\/series-batch(\?|$)/, (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const types = (url.searchParams.get("types") ?? "WEIGHT").split(",");
|
||||
const series: Record<
|
||||
string,
|
||||
Array<{ type: string; value: number; measuredAt: string; count: number }>
|
||||
> = {};
|
||||
for (const type of types) {
|
||||
const baseValue =
|
||||
type === "BLOOD_PRESSURE_SYS"
|
||||
? 124
|
||||
: type === "BLOOD_PRESSURE_DIA"
|
||||
? 80
|
||||
: type === "PULSE"
|
||||
? 70
|
||||
: 78.5;
|
||||
series[type] = Array.from({ length: 10 }, (_, i) => ({
|
||||
type,
|
||||
value: baseValue + (i % 3) - 1,
|
||||
measuredAt: new Date(Date.now() - i * 86_400_000).toISOString(),
|
||||
count: 1,
|
||||
}));
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: { series }, error: null }),
|
||||
});
|
||||
});
|
||||
|
||||
// The chart's per-type fetch reads `json.data.measurements` and
|
||||
// expects a `meta.total`. Return a small dataset so the chart has
|
||||
// points to plot — otherwise Recharts mounts a placeholder instead
|
||||
// of the recharts-wrapper SVG and the assertion below times out.
|
||||
// (Self-fetch fallback for any chart not covered by the batch above,
|
||||
// e.g. a range-tab change that drops batched coverage.)
|
||||
await page.route("**/api/measurements*", (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const type = url.searchParams.get("type") ?? "WEIGHT";
|
||||
|
||||
@@ -8,7 +8,79 @@ import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import type {
|
||||
DashboardSnapshot,
|
||||
DashboardLayoutCatalogueEntry,
|
||||
DashboardTargetBands,
|
||||
} from "@/lib/dashboard/snapshot";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import {
|
||||
buildTrafficLightBands,
|
||||
buildTrafficRange,
|
||||
buildWeightBandsFromHeight,
|
||||
buildWeightRangeFromHeight,
|
||||
getBodyFatTargetRange,
|
||||
} from "@/lib/analytics/value-bands";
|
||||
import {
|
||||
getAgeFromDateOfBirth,
|
||||
getPersonalizedPulseTarget,
|
||||
} from "@/lib/analytics/pulse-targets";
|
||||
|
||||
// v1.18.6 — resolve the mock's band block from the same pure helpers the
|
||||
// server builder uses, so the e2e fixture matches the real DTO shape.
|
||||
function mockTargetBands(): DashboardTargetBands {
|
||||
const dob = new Date("1990-01-01T00:00:00.000Z");
|
||||
const bpTargets = getBpTargets(dob);
|
||||
const pulseTarget = getPersonalizedPulseTarget(
|
||||
getAgeFromDateOfBirth(dob),
|
||||
"MALE",
|
||||
);
|
||||
const bodyFatRange = getBodyFatTargetRange("MALE");
|
||||
return {
|
||||
bpTargets,
|
||||
bpSysRange: bpTargets
|
||||
? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh)
|
||||
: null,
|
||||
bpDiaRange: bpTargets
|
||||
? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh)
|
||||
: null,
|
||||
pulseDisplayRange: {
|
||||
greenMin: pulseTarget.greenMin,
|
||||
greenMax: pulseTarget.greenMax,
|
||||
orangeMin: pulseTarget.orangeMin,
|
||||
orangeMax: pulseTarget.orangeMax,
|
||||
},
|
||||
pulseBands: [
|
||||
{ min: 30, max: pulseTarget.orangeMin, color: "#ff5555", opacity: 0.16 },
|
||||
{
|
||||
min: pulseTarget.orangeMin,
|
||||
max: pulseTarget.greenMin,
|
||||
color: "#ffb86c",
|
||||
opacity: 0.18,
|
||||
},
|
||||
{
|
||||
min: pulseTarget.greenMin,
|
||||
max: pulseTarget.greenMax,
|
||||
color: "#50fa7b",
|
||||
opacity: 0.2,
|
||||
},
|
||||
{
|
||||
min: pulseTarget.greenMax,
|
||||
max: pulseTarget.orangeMax,
|
||||
color: "#ffb86c",
|
||||
opacity: 0.18,
|
||||
},
|
||||
{ min: pulseTarget.orangeMax, max: 220, color: "#ff5555", opacity: 0.16 },
|
||||
].filter((b) => b.max > b.min),
|
||||
bodyFatRange,
|
||||
bodyFatBands: buildTrafficLightBands(bodyFatRange.min, bodyFatRange.max, {
|
||||
lowerBound: 2,
|
||||
upperBound: 55,
|
||||
}),
|
||||
weightRange: buildWeightRangeFromHeight(180),
|
||||
weightBands: buildWeightBandsFromHeight(180, {
|
||||
lowerBound: 30,
|
||||
upperBound: 250,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.7.2 — shared mock for `GET /api/dashboard/snapshot`.
|
||||
@@ -138,6 +210,7 @@ export function buildMockSnapshot(
|
||||
layout: DEFAULT_DASHBOARD_LAYOUT,
|
||||
layoutCatalogue: buildLayoutCatalogue(),
|
||||
metricStates: {},
|
||||
targetBands: mockTargetBands(),
|
||||
tiles: {
|
||||
summaries,
|
||||
lastSeenByType,
|
||||
|
||||
@@ -5,17 +5,17 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
/**
|
||||
* Coach launch surfaces on `/insights/{slug}` sub-pages.
|
||||
*
|
||||
* Contracts under test (v1.16.8):
|
||||
* Contracts under test (v1.18.6, CCH-04):
|
||||
*
|
||||
* 1. The floating Coach FAB (`data-slot="coach-fab"`) is a permanent
|
||||
* launcher on every authenticated page. An unread Coach-initiated
|
||||
* 1. The floating Coach FAB (`data-slot="coach-fab"`) is the single
|
||||
* Coach launcher on every authenticated page. The per-metric inline
|
||||
* launch icon the sub-page header used to mount was retired in
|
||||
* CCH-04 — the FAB is now the only entry. An unread Coach-initiated
|
||||
* nudge (`GET /api/insights/coach/nudge-status`) paints an unread
|
||||
* dot (`data-slot="coach-fab-unread"`) on its corner; with nothing
|
||||
* unread the FAB renders without the dot. The inline launch the
|
||||
* sub-page shell mounts in the header
|
||||
* (`data-slot="coach-launch-icon"`) stays the contextual entry.
|
||||
* unread the FAB renders without the dot.
|
||||
*
|
||||
* 2. Clicking the inline launch opens the Coach drawer — as a
|
||||
* 2. Clicking the FAB opens the Coach drawer in place — as a
|
||||
* bottom-sheet (`data-variant="bottom-sheet"`) on `<sm`, as a
|
||||
* right-side sheet (`data-variant="side-sheet"`) on `>=sm`.
|
||||
*
|
||||
@@ -27,16 +27,16 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
* tray; it hands off to the full-page route `/coach`.
|
||||
*
|
||||
* The three sub-pages covered (blutdruck, gewicht, schlaf) span the
|
||||
* empty-state branch (no data → CTA + Coach launch) and the populated
|
||||
* branch (with data → full chart + Coach launch in the header).
|
||||
* empty-state branch (no data → CTA) and the populated branch (with
|
||||
* data → full chart). The FAB launcher is present on both.
|
||||
*/
|
||||
test.describe("Coach launch surfaces on insights sub-pages", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Populated analytics so each sub-page paints the data branch
|
||||
// (`hasMetricData` true) — the inline Coach launch is mounted in
|
||||
// the sub-page header by the shell.
|
||||
// (`hasMetricData` true). The Coach FAB is the launcher on every
|
||||
// authenticated page regardless of the data branch.
|
||||
// Regex form matches the slim slice the dashboard split fires
|
||||
// alongside the thick request.
|
||||
await page.route(/\/api\/analytics(\?|$)/, (route) =>
|
||||
@@ -126,21 +126,23 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
|
||||
});
|
||||
|
||||
for (const slug of ["blutdruck", "gewicht", "schlaf"] as const) {
|
||||
test(`/insights/${slug} mounts the inline Coach launch and the dot-free FAB on Pixel 5`, async ({
|
||||
test(`/insights/${slug} mounts the dot-free Coach FAB on Pixel 5`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-mobile",
|
||||
"mobile inline-launch branch",
|
||||
"mobile FAB-launcher branch",
|
||||
);
|
||||
|
||||
await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" });
|
||||
|
||||
// The header launch icon stays the contextual entry point.
|
||||
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
|
||||
await expect(icon).toBeVisible({ timeout: 10_000 });
|
||||
// CCH-04 — the per-metric inline launch icon is retired; no header
|
||||
// launcher renders on the sub-page.
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-launch-icon"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
// The FAB is a permanent launcher; with no unread nudge it
|
||||
// The FAB is the permanent launcher; with no unread nudge it
|
||||
// renders without the unread dot.
|
||||
const fab = page.locator('[data-slot="coach-fab"]');
|
||||
await expect(fab).toBeVisible({ timeout: 10_000 });
|
||||
@@ -149,18 +151,19 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test(`/insights/${slug} mounts the inline Coach launch and the dot-free FAB on Desktop Chrome`, async ({
|
||||
test(`/insights/${slug} mounts the dot-free Coach FAB on Desktop Chrome`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-desktop",
|
||||
"desktop inline-launch branch",
|
||||
"desktop FAB-launcher branch",
|
||||
);
|
||||
|
||||
await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" });
|
||||
|
||||
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
|
||||
await expect(icon).toBeVisible({ timeout: 10_000 });
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-launch-icon"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
const fab = page.locator('[data-slot="coach-fab"]');
|
||||
await expect(fab).toBeVisible({ timeout: 10_000 });
|
||||
@@ -170,7 +173,7 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
|
||||
});
|
||||
}
|
||||
|
||||
test("clicking the inline Coach launch opens the drawer as a bottom-sheet on Pixel 5", async ({
|
||||
test("clicking the Coach FAB opens the drawer as a bottom-sheet on Pixel 5", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
@@ -178,26 +181,26 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
|
||||
"drawer bottom-sheet branch is mobile-only",
|
||||
);
|
||||
|
||||
// Hydration gate — the status query is fired by the sub-page
|
||||
// boundary itself, so its response proves React has hydrated the
|
||||
// page and attached the launch icon's click handler. Clicking the
|
||||
// SSR-painted icon any earlier is silently lost (CI flake).
|
||||
const pageHydrated = page.waitForResponse(
|
||||
/\/api\/insights\/blood-pressure-status/,
|
||||
// Hydration gate — the nudge-status query is fired by the FAB
|
||||
// component itself, so its response proves the FAB's click handler
|
||||
// is attached. Clicking the SSR-painted FAB any earlier is silently
|
||||
// lost (CI flake).
|
||||
const fabHydrated = page.waitForResponse(
|
||||
/\/api\/insights\/coach\/nudge-status/,
|
||||
);
|
||||
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
|
||||
await pageHydrated;
|
||||
await fabHydrated;
|
||||
|
||||
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
|
||||
await expect(icon).toBeVisible({ timeout: 10_000 });
|
||||
await icon.click();
|
||||
const fab = page.locator('[data-slot="coach-fab"]');
|
||||
await expect(fab).toBeVisible({ timeout: 10_000 });
|
||||
await fab.click();
|
||||
|
||||
const drawer = page.locator('[data-slot="coach-drawer"]');
|
||||
await expect(drawer).toBeVisible({ timeout: 10_000 });
|
||||
await expect(drawer).toHaveAttribute("data-variant", "bottom-sheet");
|
||||
});
|
||||
|
||||
test("clicking the inline Coach launch opens the drawer as a side-sheet on Desktop Chrome", async ({
|
||||
test("clicking the Coach FAB opens the drawer as a side-sheet on Desktop Chrome", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
@@ -206,15 +209,15 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
|
||||
);
|
||||
|
||||
// Hydration gate — see the bottom-sheet branch above.
|
||||
const pageHydrated = page.waitForResponse(
|
||||
/\/api\/insights\/blood-pressure-status/,
|
||||
const fabHydrated = page.waitForResponse(
|
||||
/\/api\/insights\/coach\/nudge-status/,
|
||||
);
|
||||
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
|
||||
await pageHydrated;
|
||||
await fabHydrated;
|
||||
|
||||
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
|
||||
await expect(icon).toBeVisible({ timeout: 10_000 });
|
||||
await icon.click();
|
||||
const fab = page.locator('[data-slot="coach-fab"]');
|
||||
await expect(fab).toBeVisible({ timeout: 10_000 });
|
||||
await fab.click();
|
||||
|
||||
const drawer = page.locator('[data-slot="coach-drawer"]');
|
||||
await expect(drawer).toBeVisible({ timeout: 10_000 });
|
||||
@@ -289,15 +292,15 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
|
||||
);
|
||||
|
||||
// Hydration gate — see the bottom-sheet test above.
|
||||
const pageHydrated = page.waitForResponse(
|
||||
/\/api\/insights\/blood-pressure-status/,
|
||||
const fabHydrated = page.waitForResponse(
|
||||
/\/api\/insights\/coach\/nudge-status/,
|
||||
);
|
||||
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
|
||||
await pageHydrated;
|
||||
await fabHydrated;
|
||||
|
||||
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
|
||||
await expect(icon).toBeVisible({ timeout: 10_000 });
|
||||
await icon.click();
|
||||
const fab = page.locator('[data-slot="coach-fab"]');
|
||||
await expect(fab).toBeVisible({ timeout: 10_000 });
|
||||
await fab.click();
|
||||
|
||||
const drawer = page.locator('[data-slot="coach-drawer"]');
|
||||
await expect(drawer).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
+237
-281
File diff suppressed because it is too large.
Load diff
+243
-287
File diff suppressed because it is too large.
Load diff
+230
-274
File diff suppressed because it is too large.
Load diff
+230
-274
File diff suppressed because it is too large.
Load diff
+230
-274
File diff suppressed because it is too large.
Load diff
+230
-274
File diff suppressed because it is too large.
Load diff
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.18.5",
|
||||
"version": "1.18.6",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
@@ -73,11 +73,11 @@
|
||||
"mmdb-lib": "^3.0.2",
|
||||
"next": "16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^9.0.1",
|
||||
"p-limit": "^7.3.0",
|
||||
"pg-boss": "^12.18.2",
|
||||
"prisma": "^7.8.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"nodemailer": "^9.0.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
@@ -87,7 +87,7 @@
|
||||
"sax": "^1.6.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"undici": "^8.4.1",
|
||||
"undici": "^8.5.0",
|
||||
"web-push": "^3.6.7",
|
||||
"yaml": "^2.8.4",
|
||||
"zod": "^4.4.3",
|
||||
|
||||
@@ -33,6 +33,17 @@ export default defineConfig({
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
// v1.18.6 — block the service worker in the test context. The
|
||||
// production build registers `/sw.js`, whose v1.18.6 data branch
|
||||
// serves allowlisted `/api/*` GET reads network-first. A worker-
|
||||
// originated `fetch` is NOT subject to Playwright's `page.route`
|
||||
// interception, so the SW would re-fetch the real (empty) backend
|
||||
// and serve that — bypassing every per-spec `route.fulfill` mock and
|
||||
// breaking read-after-write assertions (a just-created row never
|
||||
// surfaced). Blocking the worker keeps the route mocks authoritative
|
||||
// without weakening the shipped SW behaviour. Real users keep the
|
||||
// offline data cache; only the test harness opts out.
|
||||
serviceWorkers: "block",
|
||||
// HealthLog ships dark mode as the default (Dracula theme) — `globals.css`
|
||||
// sets `color-scheme: dark` on the root and the `<ThemeProvider>` defaults
|
||||
// to "system". Playwright's stock context is `colorScheme: "light"`, which
|
||||
|
||||
Generated
+10
-10
@@ -82,8 +82,8 @@ importers:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
nodemailer:
|
||||
specifier: ^9.0.0
|
||||
version: 9.0.0
|
||||
specifier: ^9.0.1
|
||||
version: 9.0.1
|
||||
p-limit:
|
||||
specifier: ^7.3.0
|
||||
version: 7.3.0
|
||||
@@ -124,8 +124,8 @@ importers:
|
||||
specifier: ^3.6.0
|
||||
version: 3.6.0
|
||||
undici:
|
||||
specifier: ^8.4.1
|
||||
version: 8.4.1
|
||||
specifier: ^8.5.0
|
||||
version: 8.5.0
|
||||
web-push:
|
||||
specifier: ^3.6.7
|
||||
version: 3.6.7
|
||||
@@ -5196,8 +5196,8 @@ packages:
|
||||
resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
nodemailer@9.0.0:
|
||||
resolution: {integrity: sha512-tbPTid7d/p9jAA8CRZ3iomvrMaST0o6NYuY7v6JQZHpPRZ61mLFSPKYd7342NtOFuej9/+L48SOIxwfu2uDvtw==}
|
||||
nodemailer@9.0.1:
|
||||
resolution: {integrity: sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
non-error@0.1.0:
|
||||
@@ -6345,8 +6345,8 @@ packages:
|
||||
resolution: {integrity: sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
undici@8.4.1:
|
||||
resolution: {integrity: sha512-RNHlB4fxZK0IrkhBsxhlbx7s8kFWwr7rzzOqj5nvZugw3ig3RsB7KW3zVlV0eu8POl+rx5d1hmL7rRg0z1owow==}
|
||||
undici@8.5.0:
|
||||
resolution: {integrity: sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==}
|
||||
engines: {node: '>=22.19.0'}
|
||||
|
||||
unicorn-magic@0.3.0:
|
||||
@@ -11834,7 +11834,7 @@ snapshots:
|
||||
|
||||
node-releases@2.0.46: {}
|
||||
|
||||
nodemailer@9.0.0: {}
|
||||
nodemailer@9.0.1: {}
|
||||
|
||||
non-error@0.1.0: {}
|
||||
|
||||
@@ -13233,7 +13233,7 @@ snapshots:
|
||||
|
||||
undici@7.26.0: {}
|
||||
|
||||
undici@8.4.1: {}
|
||||
undici@8.5.0: {}
|
||||
|
||||
unicorn-magic@0.3.0: {}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- v1.18.6 — resumable module-tour progress.
|
||||
--
|
||||
-- A single nullable JSONB column on `users` carrying the fine-grained
|
||||
-- resume point for the module-by-module guided tour. Follows the
|
||||
-- established per-user JSON-column convention (`dashboard_widgets_json`,
|
||||
-- `insights_layout_json`, `coach_prefs_json`).
|
||||
--
|
||||
-- Shape (validated by `src/lib/onboarding/tour-progress.ts`):
|
||||
-- { "lastStopId": string | null,
|
||||
-- "completedStopIds": string[],
|
||||
-- "status": "in_progress" | "completed" | "skipped",
|
||||
-- "updatedAt": string (ISO 8601) }
|
||||
--
|
||||
-- NULL = the user has not started the module tour. The coarse
|
||||
-- `onboarding_tour_completed` boolean stays the launcher's auto-launch
|
||||
-- gate; this column is purely the resume point and is cleared back to
|
||||
-- NULL on a Settings replay so the tour genuinely restarts.
|
||||
--
|
||||
-- Additive + nullable: every existing account keeps NULL and the
|
||||
-- application resolver treats NULL as "not started".
|
||||
|
||||
ALTER TABLE "users" ADD COLUMN "onboarding_tour_progress_json" JSONB;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- v1.18.6 — diabetes opt-in for clinician glucose targets.
|
||||
--
|
||||
-- One additive, defaulted column on `users` so a user can declare "I have
|
||||
-- diabetes / clinician glucose targets". When `true`, the glucose target
|
||||
-- resolver applies the tighter ADA glycemic GOAL bands (fasting 80–130,
|
||||
-- postprandial < 180) instead of the general non-diabetic normal bands.
|
||||
--
|
||||
-- This is NEVER auto-inferred from a reading and is NEVER a diagnosis — it is
|
||||
-- a user-declared preference that only selects which target band the user is
|
||||
-- judged against. The default `false` backfills every existing row onto the
|
||||
-- general bands (the current behaviour), so the migration is a no-op for
|
||||
-- anyone who never opts in.
|
||||
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN "has_diabetes" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment.
|
||||
--
|
||||
-- Two nullable columns on `users` recording that the user acknowledged, once
|
||||
-- at the start of onboarding, that HealthLog is a private tracking tool — not
|
||||
-- a clinical assessment or diagnosis — and that they should talk to a doctor
|
||||
-- if worried. The `*_version` pins exactly which copy was read (mirrors the
|
||||
-- `research_mode_acknowledged_*` pair), so a future material wording change
|
||||
-- can re-prompt without a schema change.
|
||||
--
|
||||
-- Both nullable with no default: NULL = never acknowledged. The prompt only
|
||||
-- gates fresh onboarding, so existing accounts (backfilled to NULL) are never
|
||||
-- re-walled. This replaces the per-page / per-chart disclaimer banners that
|
||||
-- were removed app-wide in the same release; the acknowledged legal text
|
||||
-- stays reachable on the public privacy page.
|
||||
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN "disclaimer_acknowledged_at" TIMESTAMP(3),
|
||||
ADD COLUMN "disclaimer_acknowledged_version" TEXT;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- v1.18.6 (CCH-03) — Coach "last seen" stamp for the FAB unread dot.
|
||||
--
|
||||
-- A single nullable timestamp on `users`. The proactive Coach nudge now
|
||||
-- lands as an ASSISTANT message in the conversation rail (CCH-02); the
|
||||
-- Coach FAB shows a discreet unread dot when the newest Coach message is
|
||||
-- newer than this stamp, and clears it once the user opens the Coach
|
||||
-- (the open writes the current time here via POST
|
||||
-- /api/insights/coach/seen).
|
||||
--
|
||||
-- NULL backfills every existing row to "never opened" — harmless: the
|
||||
-- dot only paints when an actual newer Coach message exists, so a user
|
||||
-- with no proactive nudge yet sees nothing. No data migration needed.
|
||||
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN "coach_last_seen_at" TIMESTAMP(3);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- v1.18.6 (L1) — covering index for the Coach assistant-message scans.
|
||||
--
|
||||
-- Two hot reads walk a user's assistant messages newest-first:
|
||||
-- * the global Coach FAB poll (`GET /api/insights/coach/nudge-status`) —
|
||||
-- the newest assistant message across the caller's conversations, used
|
||||
-- to decide whether the unread dot paints; and
|
||||
-- * the proactive-nudge cron's frequency gate (CCH-02 / M1) — the newest
|
||||
-- `provider_type = 'nudge'` assistant message inside the rolling window.
|
||||
--
|
||||
-- The pre-existing `(conversation_id, created_at)` index does not cover the
|
||||
-- `role` / `provider_type` filter, so Postgres gathered every assistant row
|
||||
-- in the user's threads and sorted. `coach_messages` grows unbounded, and the
|
||||
-- FAB poll fires on every authenticated page with refetch-on-focus.
|
||||
--
|
||||
-- This index orders by `created_at DESC` after `(conversation_id, role)` so
|
||||
-- the planner can do an index-backed per-conversation merge for the
|
||||
-- newest-first reads. Additive; no data touched.
|
||||
--
|
||||
-- Reversibility (down):
|
||||
-- DROP INDEX IF EXISTS "coach_messages_conversation_id_role_created_at_idx";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "coach_messages_conversation_id_role_created_at_idx"
|
||||
ON "coach_messages"("conversation_id", "role", "created_at" DESC);
|
||||
@@ -193,6 +193,13 @@ model User {
|
||||
moodTagLayoutJson Json? @map("mood_tag_layout_json")
|
||||
// Blood-glucose display unit preference. Canonical storage is mg/dL.
|
||||
glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL"
|
||||
// v1.18.6 — explicit "I have diabetes / clinician glucose targets" opt-in.
|
||||
// When `true`, the glucose target resolver applies the tighter ADA glycemic
|
||||
// GOAL bands (fasting 80–130, postprandial < 180) instead of the general
|
||||
// non-diabetic normal bands. NEVER auto-inferred from values, NEVER a
|
||||
// diagnosis — purely a user-declared preference that selects which target
|
||||
// band the user is judged against. Default `false`.
|
||||
hasDiabetes Boolean @default(false) @map("has_diabetes")
|
||||
// v1.7.0 unit pref — global metric/imperial display preference.
|
||||
// Canonical storage stays SI; this only selects the display-time
|
||||
// transform branch (km/h vs mph, km vs mi). Null = default "metric".
|
||||
@@ -256,6 +263,33 @@ model User {
|
||||
// Settings → Account.
|
||||
onboardingTourCompleted Boolean @default(false) @map("onboarding_tour_completed")
|
||||
|
||||
// v1.18.6 — resumable module-tour progress. The coarse boolean above
|
||||
// stays as the "did the user ever finish/dismiss" flag the launcher
|
||||
// gates on; this column carries the fine-grained resume point so a
|
||||
// mid-tour reload reopens at the right module. Follows the per-user
|
||||
// JSON-column convention (`dashboardWidgetsJson` / `insightsLayoutJson`
|
||||
// / `coachPrefsJson`). Shape (validated by
|
||||
// `src/lib/onboarding/tour-progress.ts`):
|
||||
// { lastStopId: string | null,
|
||||
// completedStopIds: string[],
|
||||
// status: "in_progress" | "completed" | "skipped",
|
||||
// updatedAt: string (ISO) }
|
||||
// Null = the user has not started the module tour (fresh account or a
|
||||
// legacy account that only carries the boolean flag). Cleared back to
|
||||
// null on a Settings replay so the tour genuinely restarts.
|
||||
onboardingTourProgressJson Json? @map("onboarding_tour_progress_json")
|
||||
|
||||
// v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. Shown
|
||||
// once at the start of onboarding: HealthLog is a private tracking tool,
|
||||
// not a clinical assessment or diagnosis; if worried, talk to a doctor.
|
||||
// Mirrors the `researchModeAcknowledged*` pair — the version pins exactly
|
||||
// which copy the user read, so a future material wording change can ask
|
||||
// again. Null = never acknowledged (legacy accounts read as never-asked;
|
||||
// the prompt only gates fresh onboarding, so they are not re-walled). The
|
||||
// acknowledged legal text stays reachable on the public privacy page.
|
||||
disclaimerAcknowledgedAt DateTime? @map("disclaimer_acknowledged_at")
|
||||
disclaimerAcknowledgedVersion String? @map("disclaimer_acknowledged_version")
|
||||
|
||||
// HealthKit (iOS) integration config — list of per-metric direction toggles.
|
||||
// Shape: { entries: [{ id, kind, direction, enabled }], lastSyncedAt }
|
||||
healthKitConfigJson Json? @map("healthkit_config_json")
|
||||
@@ -430,6 +464,18 @@ model User {
|
||||
// `GET /api/auth/me` as a `modules: { <key>: boolean }` map.
|
||||
modulePreferencesJson Json? @map("module_preferences_json")
|
||||
|
||||
// v1.18.6 (CCH-03) — "Coach last seen" stamp powering the unread dot
|
||||
// on the Coach FAB. The proactive nudge now lands as an ASSISTANT
|
||||
// message in the conversation rail (CCH-02); the FAB shows a discreet
|
||||
// unread dot when the newest Coach message is newer than this stamp,
|
||||
// and clears it when the user opens the Coach (drawer or page, which
|
||||
// writes the current time here via `POST /api/insights/coach/seen`).
|
||||
// Null = never opened the Coach, so any existing message reads as
|
||||
// unread once. Server-authoritative so the signal is consistent across
|
||||
// web + iOS; the FAB keeps a local mirror only as an instant-paint
|
||||
// optimisation.
|
||||
coachLastSeenAt DateTime? @map("coach_last_seen_at")
|
||||
|
||||
// v1.5.5 — self-hosted avatar storage.
|
||||
//
|
||||
// Replaces the Gravatar third-party leak (the old `getGravatarUrl`
|
||||
@@ -3586,6 +3632,12 @@ model CoachMessage {
|
||||
feedback RecommendationFeedback[]
|
||||
|
||||
@@index([conversationId, createdAt])
|
||||
// v1.18.6 (L1) — the FAB nudge-status poll and the cron's persisted-nudge
|
||||
// frequency gate both scan a user's assistant messages newest-first
|
||||
// (optionally filtered to provider_type = 'nudge'). This index lets the
|
||||
// planner do an index-backed per-conversation merge instead of gathering +
|
||||
// sorting every assistant row in the user's threads on each global poll.
|
||||
@@index([conversationId, role, createdAt(sort: Desc)])
|
||||
@@map("coach_messages")
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"display_override": ["standalone"],
|
||||
"display_override": ["standalone", "minimal-ui"],
|
||||
"categories": ["health", "medical", "lifestyle"],
|
||||
"theme_color": "#282a36",
|
||||
"background_color": "#282a36",
|
||||
"icons": [
|
||||
|
||||
+127
-4
@@ -36,11 +36,72 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.18.4";
|
||||
/* @sw-version-fallback */ "v1.18.6";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
// reads. Network-first: online callers always get the fresh server response
|
||||
// (and the cache is refreshed behind it), so a read-after-write never serves a
|
||||
// stale pre-mutation list. Offline (the network fetch throws) the LAST cached
|
||||
// dashboard/series is served instead of empty skeletons forever.
|
||||
//
|
||||
// v1.18.6.x — reverted from stale-while-revalidate, which served the cached
|
||||
// pre-mutation copy first and only revalidated in the background, so a list
|
||||
// read straight after a create/update showed the old (or empty) data until the
|
||||
// next paint. Network-first restores read-after-write freshness while keeping
|
||||
// the offline fallback.
|
||||
const DATA_CACHE = `healthlog-data-${CACHE_VERSION}`;
|
||||
const MAX_STATIC_ENTRIES = 150;
|
||||
const MAX_PAGE_ENTRIES = 30;
|
||||
const MAX_DATA_ENTRIES = 60;
|
||||
|
||||
// Safe-GET read allowlist. ONLY idempotent reads that render the core views
|
||||
// and carry NO secret-shaped body. Auth, mutations and token endpoints are
|
||||
// never listed — and `isCacheableApiResponse` is a second, body-level guard
|
||||
// (mirrors the idempotency cache's `hlk_`/`hlr_`/`sk-` refusal) so a future
|
||||
// endpoint that slipped onto the list can still never persist a secret.
|
||||
// Matched by exact path or `<path>/` / `<path>?` prefix.
|
||||
// v1.18.6 — AI/clinical narrative surfaces (`/api/insights`, `/api/analytics`)
|
||||
// are deliberately NOT cached: `/api/insights` prefix-matched the Coach chat
|
||||
// endpoint into the disk cache, and neither belongs on a self-hosted health
|
||||
// PWA's durable storage. Only the dashboard snapshot, the widget layout, the
|
||||
// measurement reads (incl. the batched daily series), the medication reads,
|
||||
// and the version probe are eligible.
|
||||
const API_READ_ALLOWLIST = [
|
||||
"/api/dashboard/snapshot",
|
||||
"/api/dashboard/widgets",
|
||||
"/api/measurements",
|
||||
"/api/medications",
|
||||
"/api/version",
|
||||
];
|
||||
|
||||
// Defence in depth: even an allowlisted path is refused if its pathname looks
|
||||
// like an auth/token/secret surface.
|
||||
const API_DENY_RE = /\/(auth|tokens?|sessions?|login|password|webauthn)(\/|$|\?)/i;
|
||||
|
||||
function isAllowlistedApiRead(pathname) {
|
||||
if (API_DENY_RE.test(pathname)) return false;
|
||||
return API_READ_ALLOWLIST.some(
|
||||
(p) =>
|
||||
pathname === p ||
|
||||
pathname.startsWith(p + "/") ||
|
||||
pathname.startsWith(p + "?"),
|
||||
);
|
||||
}
|
||||
|
||||
// Body-level secret refusal: never persist a body carrying a token/secret-
|
||||
// shaped pattern even if the path was allowlisted by mistake.
|
||||
const SECRET_BODY_RE = /(hlk_|hlr_|sk-)[A-Za-z0-9_-]/;
|
||||
|
||||
function isCacheableApiResponse(response, bodyText) {
|
||||
if (!response || !response.ok) return false;
|
||||
const cacheControl = response.headers.get("Cache-Control") || "";
|
||||
if (/no-store/i.test(cacheControl)) return false;
|
||||
if (typeof bodyText === "string" && SECRET_BODY_RE.test(bodyText)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// App shell files to precache on install
|
||||
const PRECACHE_URLS = ["/", "/logo-192.png", "/logo-512.png", "/favicon.svg"];
|
||||
@@ -64,7 +125,10 @@ self.addEventListener("activate", (event) => {
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter((k) => k !== STATIC_CACHE && k !== PAGE_CACHE)
|
||||
.filter(
|
||||
(k) =>
|
||||
k !== STATIC_CACHE && k !== PAGE_CACHE && k !== DATA_CACHE,
|
||||
)
|
||||
.map((k) => caches.delete(k)),
|
||||
),
|
||||
),
|
||||
@@ -87,8 +151,16 @@ self.addEventListener("fetch", (event) => {
|
||||
// Only handle same-origin GET requests
|
||||
if (request.method !== "GET" || url.origin !== self.location.origin) return;
|
||||
|
||||
// API calls — network only (never serve stale data)
|
||||
if (url.pathname.startsWith("/api/")) return;
|
||||
// API calls. Allowlisted safe GET reads use network-first so the installed
|
||||
// PWA always renders fresh server data online (and falls back to the last
|
||||
// cached copy only when the network fails); everything else (auth,
|
||||
// mutations, anything not on the list) stays network-only.
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
if (isAllowlistedApiRead(url.pathname)) {
|
||||
event.respondWith(networkFirstApi(request));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Next.js static assets — cache-first (immutable hashed filenames)
|
||||
if (url.pathname.startsWith("/_next/static/")) {
|
||||
@@ -126,6 +198,57 @@ async function cacheFirst(request, cacheName) {
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Network-first for allowlisted safe GET `/api/*` reads.
|
||||
*
|
||||
* Always tries the network first so an online caller gets the fresh server
|
||||
* response — critical for read-after-write: a list fetched straight after a
|
||||
* create/update must reflect the mutation, not a stale pre-mutation copy. On a
|
||||
* cacheable response the data cache is refreshed behind it. Only when the
|
||||
* network fails (offline) is the last cached copy served; with neither, a JSON
|
||||
* 503 lets the client's TanStack Query cache / IndexedDB persister supply data.
|
||||
*
|
||||
* Caching is gated twice: `isAllowlistedApiRead` already filtered the path,
|
||||
* and `isCacheableApiResponse` inspects the actual body so a `no-store` or
|
||||
* secret-shaped (`hlk_`/`hlr_`/`sk-`) response is never persisted — the same
|
||||
* refusal discipline the idempotency cache uses.
|
||||
*/
|
||||
async function networkFirstApi(request) {
|
||||
const cache = await caches.open(DATA_CACHE);
|
||||
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
// Read the body once to body-screen it, then reconstruct a fresh Response
|
||||
// so both the cache.put and the return value have an unconsumed body.
|
||||
const bodyText = await response
|
||||
.clone()
|
||||
.text()
|
||||
.catch(() => undefined);
|
||||
if (isCacheableApiResponse(response, bodyText)) {
|
||||
const toStore = new Response(bodyText, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
await cache.put(request, toStore);
|
||||
trimCache(DATA_CACHE, MAX_DATA_ENTRIES);
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
// Network failed (offline) — serve the last cached copy if we have one.
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ data: null, error: "offline", meta: { offline: true } }),
|
||||
{
|
||||
status: 503,
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Privacy gate for the navigation page cache. Most app routes are
|
||||
* client-fetch-only shells with no server-rendered PII (health JSON loads
|
||||
|
||||
@@ -68,7 +68,7 @@ const REQUIRED_KEY_SETS: Array<{
|
||||
}> = [
|
||||
{
|
||||
label: "admin.carrier (B3 — login carrier chip)",
|
||||
required: ["admin.carrier", "admin.carrierUnknown"],
|
||||
required: ["admin.carrier"],
|
||||
},
|
||||
{
|
||||
label: "insights.emptyState.* (B4 — per-metric empty states)",
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* v1.18.6 — reverse i18n coverage guard.
|
||||
*
|
||||
* The forward `i18n-call-site-coverage.test.ts` asserts every `t("ns.key")`
|
||||
* call site resolves to a leaf in `messages/en.json`. This test asserts the
|
||||
* REVERSE: every leaf in `messages/en.json` is reachable from at least one
|
||||
* call site, so orphan keys can't accumulate in the bundle unnoticed.
|
||||
*
|
||||
* "Reachable" recognises the key-construction patterns the codebase actually
|
||||
* uses:
|
||||
* - a literal `t("a.b.c")` (hyphens allowed — admin section slugs use them);
|
||||
* - a `t("a.b")` that returns a subtree, covering every descendant leaf;
|
||||
* - a dynamic key built inside a `t(`…`)` template, e.g. the per-metric
|
||||
* `${i18nPrefix}.title` insight pages, `cycle.calendar.flow${LEVEL}`, or
|
||||
* the `medications.site${Suffix}` label keys — captured by the static
|
||||
* leading-literal prefix and/or trailing-literal suffix of the template;
|
||||
* - a key string assembled outside the call (`return `medications.site${s}``
|
||||
* in `describeInjectionSite`) and passed to `t()`, recognised when the
|
||||
* template's leading literal is rooted at a real top-level namespace;
|
||||
* - an explicit allowlist for keys resolved through a DB column.
|
||||
*
|
||||
* Dynamic detection is scoped to `t(`…`)` and to namespace-rooted key
|
||||
* templates only — NOT to every backtick string — so a cache key or
|
||||
* wide-event action name that happens to start with a namespace word doesn't
|
||||
* mask a genuinely dead key.
|
||||
*
|
||||
* If this fails after adding a key, either wire a call site, or — for a key
|
||||
* resolved through a runtime value the static scan can't see — add its prefix
|
||||
* to `DYNAMIC_ALLOWLIST_PREFIXES` with a one-line note on why.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ROOT = join(__dirname, "../..");
|
||||
const SRC = join(ROOT, "src");
|
||||
const EN_BUNDLE_PATH = join(ROOT, "messages/en.json");
|
||||
|
||||
const SKIP_DIRS = new Set(["__tests__", "node_modules", ".next", "generated"]);
|
||||
|
||||
/**
|
||||
* Keys resolved through a runtime value no static scan can see. Each entry is
|
||||
* a leaf or a namespace prefix that is always live.
|
||||
* - `mood.tag` / `mood.tagCategory`: the catalogue is DB-driven; the label key
|
||||
* is built from a `MoodTag.messageKey` / `MoodTagCategory.messageKey` column,
|
||||
* so the leaf never appears as a string literal in the tree.
|
||||
*/
|
||||
const DYNAMIC_ALLOWLIST_PREFIXES = ["mood.tag", "mood.tagCategory"] as const;
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (SKIP_DIRS.has(name)) continue;
|
||||
const p = join(dir, name);
|
||||
const stat = statSync(p);
|
||||
if (stat.isDirectory()) {
|
||||
walk(p, out);
|
||||
} else if (name.endsWith(".ts") || name.endsWith(".tsx")) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function leafPaths(obj: unknown, prefix: string, out: string[]): void {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||||
const path = prefix ? `${prefix}.${k}` : k;
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) {
|
||||
leafPaths(v, path, out);
|
||||
} else {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function nodePaths(obj: unknown, prefix: string, out: Set<string>): void {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||||
const path = prefix ? `${prefix}.${k}` : k;
|
||||
out.add(path);
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) {
|
||||
nodePaths(v, path, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface Reference {
|
||||
/** Literal `t("…")` keys. */
|
||||
literalKeys: Set<string>;
|
||||
/** Dotted bases (`base.${…}` / a full string-literal node path): cover the subtree. */
|
||||
dottedBases: Set<string>;
|
||||
/** Dot-less concat prefixes (`medications.site${…}`): cover any leaf that string-starts with them. */
|
||||
concatPrefixes: Set<string>;
|
||||
/** Trailing static suffixes of a template key (`${…}.title`). */
|
||||
dynamicSuffixes: Set<string>;
|
||||
}
|
||||
|
||||
function collectReferences(
|
||||
allNodes: Set<string>,
|
||||
topNamespaces: Set<string>,
|
||||
): Reference {
|
||||
const allText = walk(SRC)
|
||||
.map((f) => readFileSync(f, "utf8"))
|
||||
.join("\n");
|
||||
|
||||
const literalKeys = new Set<string>();
|
||||
const literalKeyRe =
|
||||
/(?<![a-zA-Z0-9_])t\(\s*["']([a-zA-Z][a-zA-Z0-9_.-]+)["']/g;
|
||||
for (const m of allText.matchAll(literalKeyRe)) literalKeys.add(m[1]);
|
||||
|
||||
const dottedBases = new Set<string>();
|
||||
const concatPrefixes = new Set<string>();
|
||||
const dynamicSuffixes = new Set<string>();
|
||||
|
||||
const ingestTemplate = (content: string) => {
|
||||
const open = content.indexOf("${");
|
||||
if (open > 0) {
|
||||
const lit = content.slice(0, open);
|
||||
if (lit.includes(".")) {
|
||||
if (lit.endsWith(".")) dottedBases.add(lit.slice(0, -1));
|
||||
else concatPrefixes.add(lit);
|
||||
}
|
||||
}
|
||||
const close = content.lastIndexOf("}");
|
||||
if (close !== -1 && close < content.length - 1) {
|
||||
const tail = content.slice(close + 1).replace(/^\./, "");
|
||||
if (tail) dynamicSuffixes.add(tail);
|
||||
}
|
||||
};
|
||||
|
||||
// Templates passed directly to t(`…`).
|
||||
const tTemplateRe = /(?<![a-zA-Z0-9_])t\(\s*`([^`]*)`/g;
|
||||
for (const m of allText.matchAll(tTemplateRe)) ingestTemplate(m[1]);
|
||||
|
||||
// Key strings assembled outside the call and handed to t() (the
|
||||
// `describeInjectionSite` pattern). Recognised only when the template's
|
||||
// leading literal is rooted at a real top-level i18n namespace, so a cache
|
||||
// key / action name that interpolates can't mask a dead key.
|
||||
const assignTemplateRe = /`([a-zA-Z][a-zA-Z0-9_.-]*)\$\{/g;
|
||||
for (const m of allText.matchAll(assignTemplateRe)) {
|
||||
const lit = m[1];
|
||||
if (!lit.includes(".")) continue;
|
||||
if (!topNamespaces.has(lit.split(".")[0])) continue;
|
||||
if (lit.endsWith(".")) dottedBases.add(lit.slice(0, -1));
|
||||
else concatPrefixes.add(lit);
|
||||
}
|
||||
|
||||
// String-concatenation prefixes: `"notifications.event" + eventType…`
|
||||
// builds keys like `notifications.eventMoodReminder`. Recognised only when
|
||||
// the literal is rooted at a real top-level namespace.
|
||||
const plusConcatRe = /["']([a-zA-Z][a-zA-Z0-9_.]*)["']\s*\+/g;
|
||||
for (const m of allText.matchAll(plusConcatRe)) {
|
||||
const lit = m[1];
|
||||
if (!lit.includes(".")) continue;
|
||||
if (!topNamespaces.has(lit.split(".")[0])) continue;
|
||||
if (lit.endsWith(".")) dottedBases.add(lit.slice(0, -1));
|
||||
else concatPrefixes.add(lit);
|
||||
}
|
||||
|
||||
// Full string-literal node paths (covers map values returned to t()).
|
||||
const litPathRe = /["']([a-zA-Z][a-zA-Z0-9_-]*(?:\.[a-zA-Z0-9_-]+)+)["']/g;
|
||||
for (const m of allText.matchAll(litPathRe)) {
|
||||
if (allNodes.has(m[1])) dottedBases.add(m[1]);
|
||||
}
|
||||
|
||||
return { literalKeys, dottedBases, concatPrefixes, dynamicSuffixes };
|
||||
}
|
||||
|
||||
function isReferenced(leaf: string, ref: Reference): boolean {
|
||||
if (ref.literalKeys.has(leaf)) return true;
|
||||
for (const ck of ref.literalKeys) {
|
||||
if (leaf.startsWith(`${ck}.`)) return true;
|
||||
}
|
||||
for (const base of ref.dottedBases) {
|
||||
if (leaf === base || leaf.startsWith(`${base}.`)) return true;
|
||||
}
|
||||
for (const pre of ref.concatPrefixes) {
|
||||
if (leaf.startsWith(pre)) return true;
|
||||
}
|
||||
for (const suffix of ref.dynamicSuffixes) {
|
||||
if (leaf !== suffix && !leaf.endsWith(`.${suffix}`)) continue;
|
||||
const base = leaf.slice(0, leaf.length - suffix.length).replace(/\.$/, "");
|
||||
for (const b of ref.dottedBases) {
|
||||
if (base === b || base.startsWith(`${b}.`) || b.startsWith(`${base}.`)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (const ck of ref.literalKeys) {
|
||||
if (base === ck || base.startsWith(`${ck}.`) || ck.startsWith(`${base}.`)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (const pre of ref.concatPrefixes) {
|
||||
if (base.startsWith(pre)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAllowlisted(leaf: string): boolean {
|
||||
return DYNAMIC_ALLOWLIST_PREFIXES.some(
|
||||
(p) => leaf === p || leaf.startsWith(`${p}.`),
|
||||
);
|
||||
}
|
||||
|
||||
describe("i18n reverse coverage", () => {
|
||||
it("every key in messages/en.json has a call site or is allowlisted", () => {
|
||||
const en = JSON.parse(readFileSync(EN_BUNDLE_PATH, "utf8"));
|
||||
const leaves: string[] = [];
|
||||
leafPaths(en, "", leaves);
|
||||
expect(leaves.length).toBeGreaterThan(1000);
|
||||
|
||||
const allNodes = new Set<string>();
|
||||
nodePaths(en, "", allNodes);
|
||||
const topNamespaces = new Set<string>(Object.keys(en));
|
||||
const ref = collectReferences(allNodes, topNamespaces);
|
||||
// Sanity: the static scan must see the bulk of the bundle.
|
||||
expect(ref.literalKeys.size).toBeGreaterThan(1000);
|
||||
|
||||
const orphans = leaves.filter(
|
||||
(leaf) => !isAllowlisted(leaf) && !isReferenced(leaf, ref),
|
||||
);
|
||||
|
||||
if (orphans.length > 0) {
|
||||
const report = orphans.map((k) => ` ❌ ${k}`).join("\n");
|
||||
throw new Error(
|
||||
`Found ${orphans.length} key(s) in messages/en.json with no call site:\n${report}\n\n` +
|
||||
"Wire a call site, or add a DB-driven prefix to DYNAMIC_ALLOWLIST_PREFIXES.",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -85,7 +85,8 @@ describe("public/manifest.json PWA identity hygiene (F-7, 2026-05-16)", () => {
|
||||
const manifest = JSON.parse(raw) as Record<string, unknown>;
|
||||
expect(manifest.scope).toBe("/");
|
||||
expect(manifest.id).toBe("/?source=pwa");
|
||||
expect(manifest.display_override).toEqual(["standalone"]);
|
||||
expect(manifest.display_override).toEqual(["standalone", "minimal-ui"]);
|
||||
expect(manifest.categories).toEqual(["health", "medical", "lifestyle"]);
|
||||
});
|
||||
|
||||
it("keeps the existing display/start_url contract", () => {
|
||||
|
||||
@@ -34,6 +34,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { useAchievementsQuery } from "@/lib/queries/use-achievements-query";
|
||||
import {
|
||||
@@ -317,13 +318,21 @@ export default function AchievementsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("achievements.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("achievements.subtitle")}
|
||||
</p>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1
|
||||
data-tour-id="achievements-hero"
|
||||
className="text-2xl font-bold tracking-tight"
|
||||
>
|
||||
{t("achievements.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("achievements.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ModuleTourTrigger stopId="achievements" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
|
||||
@@ -94,18 +94,32 @@ export function AdminSectionRenderer({
|
||||
// v1.18.1 — the former ai-quality, assistant, and coach-feedback
|
||||
// sections fold into one Coach area: surface toggles + operator
|
||||
// config first, then the two feedback-quality tables.
|
||||
// v1.18.6 (W9) — server-wide module availability moved out to its own
|
||||
// `module-availability` section: it gates EVERY module, not just the
|
||||
// coach, so it no longer belongs stacked under Coach.
|
||||
return (
|
||||
<SectionFrame
|
||||
title={t("admin.section.coach.title")}
|
||||
subtitle={t("admin.section.coach.subtitle")}
|
||||
>
|
||||
<AssistantSection />
|
||||
<ModuleAvailabilitySection />
|
||||
<AiServerKeySection />
|
||||
<CoachFeedbackSection />
|
||||
<AiQualitySection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "module-availability":
|
||||
// v1.18.6 (W9) — operator-side server-wide module on/off, its own
|
||||
// admin section (was stacked under Coach). Reconciled name: the
|
||||
// user-facing "Module" settings pick from what is available here.
|
||||
return (
|
||||
<SectionFrame
|
||||
title={t("admin.section.module-availability.title")}
|
||||
subtitle={t("admin.section.module-availability.subtitle")}
|
||||
>
|
||||
<ModuleAvailabilitySection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "feedback":
|
||||
return (
|
||||
<SectionFrame
|
||||
@@ -191,12 +205,18 @@ export function AdminSectionRenderer({
|
||||
</SectionFrame>
|
||||
);
|
||||
case "about":
|
||||
// v1.4.36 W4e — About section reused as-is from the settings
|
||||
// surface. The component owns its own heading + cards layout
|
||||
// so no SectionFrame wrapper.
|
||||
// v1.18.1 E5 — hide the onboarding "Replay tour" card on the admin
|
||||
// surface; it's a user-facing affordance with no admin-side use.
|
||||
return <AboutSection hideTourReplay />;
|
||||
// v1.4.36 W4e — About section reused as-is from the settings surface.
|
||||
// v1.18.6 (W9) — route it through `SectionFrame` like every other admin
|
||||
// section so it gets the same visible heading + subtitle (the one admin
|
||||
// page that was bypassing the frame and showing no heading).
|
||||
return (
|
||||
<SectionFrame
|
||||
title={t("admin.section.about.title")}
|
||||
subtitle={t("admin.section.about.subtitle")}
|
||||
>
|
||||
<AboutSection />
|
||||
</SectionFrame>
|
||||
);
|
||||
default:
|
||||
slug satisfies never;
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* v1.18.6 — per-user diabetes opt-in endpoint.
|
||||
*
|
||||
* GET /api/auth/me/diabetes — current flag.
|
||||
* PATCH /api/auth/me/diabetes — body `{ hasDiabetes: boolean }`.
|
||||
*
|
||||
* The flag is an explicit, user-declared preference ("I have diabetes /
|
||||
* clinician glucose targets"). When `true`, the glucose target resolver
|
||||
* applies the tighter ADA glycemic GOAL bands (fasting 80–130, postprandial
|
||||
* < 180) instead of the general non-diabetic normal bands. It is NEVER
|
||||
* inferred from a reading and asserts NO diagnosis — it only selects which
|
||||
* target band a glucose reading is judged against.
|
||||
*
|
||||
* Mirrors the `disable-coach` / `unit-preference` per-user-scalar pattern:
|
||||
* 60/min rate limit, Zod safeParse → 422 via `returnAllZodIssues`, audit-log
|
||||
* row, field-by-field write (no mass assignment). Idempotent — the endpoint
|
||||
* always returns the resolved next state so the client can hard-set the
|
||||
* optimistic update without an extra round-trip.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
|
||||
const patchBodySchema = z.object({
|
||||
hasDiabetes: z.boolean(),
|
||||
});
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const PATCH_RATE_LIMIT = 60;
|
||||
const PATCH_WINDOW_MS = 60_000;
|
||||
|
||||
type DiabetesResponse = {
|
||||
hasDiabetes: boolean;
|
||||
};
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "auth.me.diabetes.get" } });
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { hasDiabetes: true },
|
||||
});
|
||||
const payload: DiabetesResponse = {
|
||||
hasDiabetes: row?.hasDiabetes ?? false,
|
||||
};
|
||||
return apiSuccess(payload);
|
||||
});
|
||||
|
||||
export const PATCH = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`diabetes:patch:${user.id}`,
|
||||
PATCH_RATE_LIMIT,
|
||||
PATCH_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
// The body is a single boolean — bound the parse so a malformed or
|
||||
// oversized payload is rejected before it is materialised.
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = patchBodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
annotate({ action: { name: "auth.me.diabetes.patch.invalid_shape" } });
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const next = parsed.data.hasDiabetes;
|
||||
|
||||
const previous = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { hasDiabetes: true },
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { hasDiabetes: next },
|
||||
});
|
||||
|
||||
await auditLog("user.diabetes.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: {
|
||||
previous: previous?.hasDiabetes ?? false,
|
||||
next,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.me.diabetes.patch" },
|
||||
meta: { hasDiabetes: next },
|
||||
});
|
||||
|
||||
const payload: DiabetesResponse = { hasDiabetes: next };
|
||||
return apiSuccess(payload);
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
resolveModuleMap,
|
||||
getOperatorModuleAvailability,
|
||||
} from "@/lib/modules/gate";
|
||||
import { parseTourProgress } from "@/lib/onboarding/tour-progress";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -70,6 +71,15 @@ export const GET = apiHandler(async () => {
|
||||
timezone: user.timezone,
|
||||
onboardingCompletedAt: user.onboardingCompletedAt,
|
||||
onboardingTourCompleted: user.onboardingTourCompleted,
|
||||
// v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. Null
|
||||
// when never acknowledged; the onboarding welcome gate reads this to
|
||||
// decide whether to require the acknowledgment before "Get started".
|
||||
disclaimerAcknowledgedAt: user.disclaimerAcknowledgedAt,
|
||||
// v1.18.6 — resumable module-tour progress. Null when the user has
|
||||
// not started the tour; otherwise the resume point the launcher
|
||||
// seeds its index from. Fail-soft parse: a corrupt blob degrades to
|
||||
// null ("start from the top") rather than 500-ing the /me read.
|
||||
onboardingTourProgress: parseTourProgress(user.onboardingTourProgressJson),
|
||||
// v1.5.5 — self-hosted avatar. Replaces the Gravatar leak; the
|
||||
// URL is relative so PWA + native clients render identically
|
||||
// and the `?v={updatedAtMs}` suffix busts the browser cache on
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* contributes no gap rather than a wrong one. Born-gated + owner-scoped.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import pLimit from "p-limit";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
@@ -29,6 +30,9 @@ import { resolveUserTimezone } from "@/lib/measurements/consolidation-base";
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
/** Cap the per-episode gap computations so a heavy history can't fan out. */
|
||||
const MAX_GAP_COMPUTATIONS = 24;
|
||||
/** Run the per-episode correlations under a bounded concurrency budget so
|
||||
* the up-to-24 candidates don't serialise into ~500 round-trips. */
|
||||
const EPISODE_GAP_CONCURRENCY = 4;
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -69,19 +73,24 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const gapCandidates = rows
|
||||
.filter((r) => r.resolvedAt !== null && r.lifecycle !== "CHRONIC_ONGOING")
|
||||
.slice(0, MAX_GAP_COMPUTATIONS);
|
||||
const gapById = new Map<string, number | null>();
|
||||
for (const r of gapCandidates) {
|
||||
const derived = await computeEpisodeCorrelation(
|
||||
user.id,
|
||||
{ id: r.id, onsetAt: r.onsetAt, resolvedAt: r.resolvedAt, lifecycle: r.lifecycle },
|
||||
tz,
|
||||
now,
|
||||
);
|
||||
gapById.set(
|
||||
r.id,
|
||||
derived.status === "ok" ? derived.value.recoveryGapDays : null,
|
||||
);
|
||||
}
|
||||
const limit = pLimit(EPISODE_GAP_CONCURRENCY);
|
||||
const gapEntries = await Promise.all(
|
||||
gapCandidates.map((r) =>
|
||||
limit(async (): Promise<[string, number | null]> => {
|
||||
const derived = await computeEpisodeCorrelation(
|
||||
user.id,
|
||||
{ id: r.id, onsetAt: r.onsetAt, resolvedAt: r.resolvedAt, lifecycle: r.lifecycle },
|
||||
tz,
|
||||
now,
|
||||
);
|
||||
return [
|
||||
r.id,
|
||||
derived.status === "ok" ? derived.value.recoveryGapDays : null,
|
||||
];
|
||||
}),
|
||||
),
|
||||
);
|
||||
const gapById = new Map<string, number | null>(gapEntries);
|
||||
|
||||
const episodes: RetrospectiveEpisode[] = rows.map((r) => ({
|
||||
id: r.id,
|
||||
|
||||
@@ -393,9 +393,18 @@ The user's message answers this clarifying question from their self-context ques
|
||||
React briefly and personally to the answer; do not repeat the question and do not ask it again.
|
||||
`
|
||||
: "";
|
||||
// v1.18.6 (W7) — citation-aware reference-range grounding for the metrics
|
||||
// present in this snapshot. Deterministic + brand-free; framed as general
|
||||
// guidance, never a diagnosis. Appended after the SNAPSHOT so it is fully
|
||||
// inspectable and the model reads the published population bands + the
|
||||
// user's placement. Omitted entirely when no present metric is covered by
|
||||
// the reference backbone (the builder returns null).
|
||||
const groundingBlock = snapshot.referenceGrounding
|
||||
? `\n${snapshot.referenceGrounding}\n`
|
||||
: "";
|
||||
const userPrompt = `SNAPSHOT
|
||||
${snapshot.snapshotJson || "(no metric data in this user's log yet)"}
|
||||
${guidedBlock}
|
||||
${groundingBlock}${guidedBlock}
|
||||
CONVERSATION
|
||||
${transcript}
|
||||
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
/**
|
||||
* GET /api/insights/coach/nudge-status — has the proactive Coach nudge
|
||||
* something the caller has not engaged with yet?
|
||||
* GET /api/insights/coach/nudge-status — is there an unread Coach
|
||||
* message the caller has not opened yet?
|
||||
*
|
||||
* v1.16.1 — feeds the floating Coach bubble: the bubble renders only
|
||||
* while an unseen Coach-initiated nudge exists, instead of being a
|
||||
* permanent FAB. "Unseen" is derived, not stored: the latest successful
|
||||
* `COACH_NUDGE` dispatch (the `push_attempts` ledger the nudge cron
|
||||
* already anchors its frequency cap on) counts as read once the user
|
||||
* has sent a Coach message after it. The client additionally remembers
|
||||
* a local "seen" stamp so opening the chat without sending also clears
|
||||
* the bubble on that device.
|
||||
* v1.18.6 (CCH-03) — the proactive Coach nudge now lands as a real
|
||||
* ASSISTANT message in the conversation rail (CCH-02), not as a
|
||||
* notification-only dispatch. The unread signal moved with it: instead
|
||||
* of anchoring on the `push_attempts` ledger (which is empty when no
|
||||
* push channel is configured, so the nudge was invisible), the status
|
||||
* compares the newest Coach assistant message against the
|
||||
* server-authoritative `User.coachLastSeenAt` stamp.
|
||||
*
|
||||
* No new table, no migration: both timestamps come from existing rows,
|
||||
* scoped to the caller.
|
||||
* `unread` is true when an assistant message exists that is newer than
|
||||
* the last time the user opened the Coach (drawer or page, which writes
|
||||
* `coachLastSeenAt` via `POST /api/insights/coach/seen`). A user who has
|
||||
* never opened the Coach reads any existing nudge as unread exactly
|
||||
* once. Server-authoritative so the signal is consistent across web +
|
||||
* iOS; the FAB keeps a local mirror only as an instant-paint
|
||||
* optimisation.
|
||||
*
|
||||
* `nudgedAt` carries the newest assistant-message timestamp so the FAB's
|
||||
* local seen-stamp keys on a stable value (kept for the existing client
|
||||
* contract).
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
@@ -23,23 +31,26 @@ export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
await requireAssistantSurface("coach");
|
||||
|
||||
const [lastNudge, lastUserMessage] = await Promise.all([
|
||||
prisma.pushAttempt.findFirst({
|
||||
where: { userId: user.id, eventType: "COACH_NUDGE", result: "ok" },
|
||||
// The newest assistant message across the caller's conversations — the
|
||||
// proactive nudge writes one, and so does every normal Coach reply. We
|
||||
// intentionally key on assistant (not user) messages: the unread dot
|
||||
// means "the Coach said something", and the open-stamp clears it.
|
||||
const [lastAssistant, seen] = await Promise.all([
|
||||
prisma.coachMessage.findFirst({
|
||||
where: { role: "assistant", conversation: { userId: user.id } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
prisma.coachMessage.findFirst({
|
||||
where: { role: "user", conversation: { userId: user.id } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { coachLastSeenAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const nudgedAt = lastNudge?.createdAt ?? null;
|
||||
const nudgedAt = lastAssistant?.createdAt ?? null;
|
||||
const lastSeenAt = seen?.coachLastSeenAt ?? null;
|
||||
const unread =
|
||||
nudgedAt !== null &&
|
||||
(lastUserMessage === null || lastUserMessage.createdAt < nudgedAt);
|
||||
nudgedAt !== null && (lastSeenAt === null || lastSeenAt < nudgedAt);
|
||||
|
||||
return apiSuccess({
|
||||
nudgedAt: nudgedAt ? nudgedAt.toISOString() : null,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* POST /api/insights/coach/seen — mark every Coach message as read.
|
||||
*
|
||||
* v1.18.6 (CCH-03) — opening the Coach (drawer or full page) clears the
|
||||
* unread dot on the FAB. The open stamps `User.coachLastSeenAt = now`;
|
||||
* the FAB's `GET /api/insights/coach/nudge-status` then reads no
|
||||
* assistant message newer than the stamp and drops the dot. Server-
|
||||
* authoritative so the cleared state follows the user across web + iOS,
|
||||
* not just the device that opened the Coach.
|
||||
*
|
||||
* No request body — the timestamp is server-minted from `now()` so a
|
||||
* client can never backdate the stamp to suppress a future nudge.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
await requireAssistantSurface("coach");
|
||||
|
||||
const seenAt = new Date();
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { coachLastSeenAt: seenAt },
|
||||
});
|
||||
|
||||
return apiSuccess({ seenAt: seenAt.toISOString() });
|
||||
});
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* v1.18.6 — explicit-completion route (iOS #23 follow-up).
|
||||
*
|
||||
* Covers: marks done via the shared primitive, idempotent no-op surfaces
|
||||
* completed=false, owner-scoped 404 on a cross-user / tombstoned id, and the
|
||||
* structural no-double-notify guarantee (the route delegates to
|
||||
* `satisfyReminder` and never reaches the notification dispatcher).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
const satisfyReminderMock = vi.fn();
|
||||
vi.mock("@/lib/measurement-reminders/satisfy", () => ({
|
||||
satisfyReminder: (...args: unknown[]) => satisfyReminderMock(...args),
|
||||
}));
|
||||
|
||||
const findFirstMock = vi.fn();
|
||||
const findUserMock = vi.fn();
|
||||
const findUniqueOrThrowMock = vi.fn();
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurementReminder: {
|
||||
findFirst: (...a: unknown[]) => findFirstMock(...a),
|
||||
findUniqueOrThrow: (...a: unknown[]) => findUniqueOrThrowMock(...a),
|
||||
},
|
||||
user: { findUnique: (...a: unknown[]) => findUserMock(...a) },
|
||||
},
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
|
||||
const ROW = {
|
||||
id: "r1",
|
||||
userId: "u1",
|
||||
label: "Blutdruck messen",
|
||||
measurementType: "BLOOD_PRESSURE_SYS",
|
||||
intervalDays: 7,
|
||||
rrule: null,
|
||||
anchorDate: null,
|
||||
endsOn: null,
|
||||
origin: "VORSORGE",
|
||||
notifyHour: 9,
|
||||
location: null,
|
||||
nextDueAt: new Date("2026-06-25T07:00:00Z"),
|
||||
lastSatisfiedAt: null,
|
||||
enabled: true,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-18T00:00:00Z"),
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
function makeRequest(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/measurement-reminders/r1/complete", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
const params = { params: Promise.resolve({ id: "r1" }) };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
findUserMock.mockResolvedValue({ timezone: "Europe/Berlin" });
|
||||
});
|
||||
|
||||
describe("POST /api/measurement-reminders/[id]/complete", () => {
|
||||
it("marks the reminder done via the shared primitive and returns completed=true", async () => {
|
||||
findFirstMock.mockResolvedValue(ROW);
|
||||
satisfyReminderMock.mockResolvedValue({
|
||||
satisfied: true,
|
||||
nextDueAt: new Date("2026-06-25T07:00:00Z"),
|
||||
});
|
||||
findUniqueOrThrowMock.mockResolvedValue({
|
||||
...ROW,
|
||||
lastSatisfiedAt: new Date("2026-06-18T08:00:00Z"),
|
||||
});
|
||||
|
||||
const res = await POST(makeRequest(), params);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBeNull();
|
||||
expect(body.data.completed).toBe(true);
|
||||
expect(body.data.reminder.id).toBe("r1");
|
||||
expect(body.data.reminder.lastSatisfiedAt).toBe("2026-06-18T08:00:00.000Z");
|
||||
// The route delegates to the ONE shared satisfaction primitive.
|
||||
expect(satisfyReminderMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("is idempotent: an already-satisfied reminder is a 200 no-op with completed=false", async () => {
|
||||
findFirstMock.mockResolvedValue({
|
||||
...ROW,
|
||||
lastSatisfiedAt: new Date("2026-06-18T08:00:00Z"),
|
||||
});
|
||||
// Forward-only guard inside the primitive returns satisfied=false.
|
||||
satisfyReminderMock.mockResolvedValue({ satisfied: false, nextDueAt: null });
|
||||
findUniqueOrThrowMock.mockResolvedValue({
|
||||
...ROW,
|
||||
lastSatisfiedAt: new Date("2026-06-18T08:00:00Z"),
|
||||
});
|
||||
|
||||
const res = await POST(makeRequest(), params);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.completed).toBe(false);
|
||||
expect(body.data.reminder.id).toBe("r1");
|
||||
});
|
||||
|
||||
it("is owner-scoped: a cross-user reminder 404s and never satisfies", async () => {
|
||||
findFirstMock.mockResolvedValue({ ...ROW, userId: "someone-else" });
|
||||
|
||||
const res = await POST(makeRequest(), params);
|
||||
expect(res.status).toBe(404);
|
||||
expect(satisfyReminderMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("404s a tombstoned / missing reminder", async () => {
|
||||
findFirstMock.mockResolvedValue(null);
|
||||
|
||||
const res = await POST(makeRequest(), params);
|
||||
expect(res.status).toBe(404);
|
||||
expect(satisfyReminderMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* v1.18.6 — explicit "complete" for a Vorsorge reminder (iOS #23 follow-up).
|
||||
*
|
||||
* The user-action equivalent of the cron auto-satisfy: the iOS app (and web)
|
||||
* call this when the user actively marks a measurement reminder done, rather
|
||||
* than only dismissing the notification locally. It routes through the ONE
|
||||
* shared `satisfyReminder` primitive — the same code the cron auto-resolve
|
||||
* and the eventful ingest worker use — so it:
|
||||
*
|
||||
* - stamps `lastSatisfiedAt = now` and re-anchors the server-authoritative
|
||||
* `nextDueAt` past now (one engine, no duplicated reschedule logic);
|
||||
* - fires NO notification of its own (the primitive only writes the row;
|
||||
* the dispatcher is never invoked on this path);
|
||||
* - is idempotent via the primitive's forward-only guard — completing an
|
||||
* already-completed / auto-satisfied reminder is a no-op that still
|
||||
* returns 200, with `completed: false` so the client can tell the second
|
||||
* tap apart from the first.
|
||||
*
|
||||
* Distinct from `satisfy` only in that the response surfaces the `completed`
|
||||
* flag the explicit-action flow benefits from; the underlying effect is the
|
||||
* shared satisfaction primitive either way.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
|
||||
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
const DEFAULT_TIMEZONE = "Europe/Berlin";
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await prisma.measurementReminder.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Measurement reminder not found", 404);
|
||||
}
|
||||
|
||||
const userRow = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { timezone: true },
|
||||
});
|
||||
const timezone = userRow?.timezone || DEFAULT_TIMEZONE;
|
||||
|
||||
const now = new Date();
|
||||
// Forward-only: a completion strictly after any prior satisfy advances
|
||||
// the row; a completion at/behind the last satisfy is a no-op. Either
|
||||
// way no notification is emitted — the primitive only stamps + reschedules.
|
||||
const result = await satisfyReminder(prisma, existing, timezone, now);
|
||||
|
||||
const updated = await prisma.measurementReminder.findUniqueOrThrow({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
await auditLog("measurementReminder.complete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { reminderId: id, completed: result.satisfied },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "measurement-reminders.complete" },
|
||||
meta: { reminderId: id, completed: result.satisfied },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
completed: result.satisfied,
|
||||
reminder: toMeasurementReminderDto(updated),
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -30,15 +30,13 @@ import { withIdempotency } from "@/lib/idempotency";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import { runSafetyFloorCheck } from "@/lib/illness/safety-floor-check";
|
||||
import {
|
||||
recomputeBucketsForMeasurement,
|
||||
collapseToTypeDayKeys,
|
||||
recomputeUserRollups,
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
import {
|
||||
collapseRollupRowsBySource,
|
||||
loadUserSourcePriority,
|
||||
} from "@/lib/rollups/measurement-read";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { readDailySeries } from "@/lib/measurements/daily-series-read";
|
||||
import {
|
||||
reconstructSleepSessions,
|
||||
pickMainNightAndNaps,
|
||||
@@ -350,186 +348,23 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// populator hasn't caught up still sees a correct chart on its
|
||||
// first render.
|
||||
if (source === "rollup" && aggregate === "daily" && type && from && to) {
|
||||
// v1.18.6 — the rollup-daily read (per-source collapse + coverage
|
||||
// probe + inline fold + live-SQL fallback) moved to the shared
|
||||
// `readDailySeries` reader so the batched series endpoint
|
||||
// (`/api/measurements/series-batch`) reads through the SAME path
|
||||
// instead of duplicating this SQL. Behaviour is unchanged: the rows
|
||||
// returned here are byte-identical with the pre-extraction branch.
|
||||
const cap = Math.min(limit, BUCKET_CAP.daily);
|
||||
// v1.11.1 — rollup rows are per source; collapse overlapping sources to the
|
||||
// ladder-canonical reading per day before the chart consumes them, so a
|
||||
// dual-source vital paints one line, not two stacked points per day.
|
||||
const priorityJson = await loadUserSourcePriority(user.id);
|
||||
const rollupRows = collapseRollupRowsBySource(
|
||||
await prisma.measurementRollup.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: type as MeasurementType,
|
||||
granularity: "DAY",
|
||||
bucketStart: { gte: from, lte: to },
|
||||
},
|
||||
orderBy: { bucketStart: "asc" },
|
||||
// Fetch beyond the bucket cap pre-collapse — N sources per day can
|
||||
// exceed `cap` rows before the per-day collapse reduces them.
|
||||
select: {
|
||||
type: true,
|
||||
source: true,
|
||||
bucketStart: true,
|
||||
mean: true,
|
||||
count: true,
|
||||
// v1.4.39 W-SUM — the writer now populates `sum_value` on
|
||||
// every fold. The cumulative metric path consumes the column
|
||||
// directly instead of reconstructing `mean * count`; the
|
||||
// legacy fallback below covers the boot-backfill convergence
|
||||
// window when an existing bucket pre-dates v1.4.39.
|
||||
sumValue: true,
|
||||
minValue: true,
|
||||
maxValue: true,
|
||||
},
|
||||
}),
|
||||
type as MeasurementType,
|
||||
const measurements = await readDailySeries({
|
||||
userId: user.id,
|
||||
type: type as MeasurementType,
|
||||
from,
|
||||
to,
|
||||
limit,
|
||||
priorityJson,
|
||||
).slice(0, cap);
|
||||
// v1.4.39.2 — coverage-mismatch fallback. v1.4.39.1 wired the
|
||||
// rollup write hook into the previously-bypassed Withings sync,
|
||||
// /api/import, and admin-restore paths, and widened the boot-time
|
||||
// backfill discovery to per-(user, type, day). Together those
|
||||
// changes ensure new writes and post-deploy restarts converge the
|
||||
// rollup tier for every measurement type the user has logged. They
|
||||
// do NOT, however, guarantee convergence inside a single request
|
||||
// when the boot backfill has not yet finished folding the user's
|
||||
// historic rows: a chart query lands first, the rollup table still
|
||||
// carries 2 rows for a 30-day BP_SYS window, and the route's pre-
|
||||
// v1.4.39.2 "rollup wins on length > 0" short-circuit returns the
|
||||
// sparse rollup. The chart trips its `< 3 daily points` empty-state
|
||||
// even though `measurements` holds the full 30 days.
|
||||
//
|
||||
// This branch closes that gap. When the rollup row count is
|
||||
// suspiciously low for the requested window (≥ 7 days asked and
|
||||
// < 3 rollup rows present), we probe `measurements` for the actual
|
||||
// distinct-day count over the same window. If `measurements`
|
||||
// carries strictly more days than the rollup, the rollup is
|
||||
// under-converged: we fold the (user, type, DAY, [from, to])
|
||||
// partition inline, re-read the rollup, and return the refreshed
|
||||
// rows. The inline fold is bounded by the window + type so it is
|
||||
// small (~30 buckets × one type for the dashboard chart's 30-day
|
||||
// window) and finishes well inside the request budget.
|
||||
//
|
||||
// The probe runs only on suspicious rollups so the covered-tenant
|
||||
// happy path stays a single indexed read. The maintainer's tenant on a 30-day
|
||||
// BP_SYS window pre-fix returned 2 rollup rows and hit `total: 2`
|
||||
// in the live trace; post-fix the probe finds ~30 distinct days in
|
||||
// `measurements`, folds them, and the chart paints all 30.
|
||||
const windowMs = to.getTime() - from.getTime();
|
||||
const windowDays = Math.ceil(windowMs / 86_400_000);
|
||||
const SUSPICIOUS_ROW_FLOOR = 3;
|
||||
const SUSPICIOUS_WINDOW_DAYS = 7;
|
||||
let effectiveRows = rollupRows;
|
||||
let coverageFallbackFired = false;
|
||||
if (
|
||||
rollupRows.length > 0 &&
|
||||
rollupRows.length < SUSPICIOUS_ROW_FLOOR &&
|
||||
windowDays >= SUSPICIOUS_WINDOW_DAYS
|
||||
) {
|
||||
// Cheap probe — one indexed range scan over
|
||||
// `(user_id, type, measured_at)`. Returns the count of distinct
|
||||
// calendar days the live table holds for the window.
|
||||
const liveDayCountRows = await prisma.$queryRaw<Array<{ days: bigint }>>`
|
||||
SELECT COUNT(DISTINCT date_trunc('day', m."measured_at"))::bigint AS days
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${user.id}
|
||||
AND m."type" = ${type}::measurement_type
|
||||
AND m."measured_at" >= ${from}
|
||||
AND m."measured_at" <= ${to}
|
||||
AND m."deleted_at" IS NULL
|
||||
`;
|
||||
const liveDays = Number(liveDayCountRows[0]?.days ?? BigInt(0));
|
||||
if (liveDays > rollupRows.length) {
|
||||
coverageFallbackFired = true;
|
||||
try {
|
||||
// Inline fold of the (user, type, DAY, window) partition.
|
||||
// Bounded by the window so it stays small even on a
|
||||
// power-user account; the upsert is idempotent against the
|
||||
// composite primary key.
|
||||
await recomputeUserRollups(user.id, {
|
||||
types: [type as MeasurementType],
|
||||
granularities: ["DAY"],
|
||||
from,
|
||||
to,
|
||||
});
|
||||
// Re-read so this request returns the converged rows. The
|
||||
// next chart paint hits the same warm rollup without paying
|
||||
// the fallback cost. v1.11.1 — collapse per-source rows to the
|
||||
// canonical reading per day, same as the initial read.
|
||||
effectiveRows = collapseRollupRowsBySource(
|
||||
await prisma.measurementRollup.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: type as MeasurementType,
|
||||
granularity: "DAY",
|
||||
bucketStart: { gte: from, lte: to },
|
||||
},
|
||||
orderBy: { bucketStart: "asc" },
|
||||
select: {
|
||||
type: true,
|
||||
source: true,
|
||||
bucketStart: true,
|
||||
mean: true,
|
||||
count: true,
|
||||
sumValue: true,
|
||||
minValue: true,
|
||||
maxValue: true,
|
||||
},
|
||||
}),
|
||||
type as MeasurementType,
|
||||
priorityJson,
|
||||
).slice(0, cap);
|
||||
} catch (err) {
|
||||
// Best-effort — fall back to the legacy live aggregate path
|
||||
// below if the fold itself failed so the chart paints
|
||||
// something sensible regardless. `annotate` carries the
|
||||
// surface so ops can spot a silent populator regression.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
annotate({
|
||||
meta: {
|
||||
rollup_coverage_fallback_failed: true,
|
||||
rollup_coverage_fallback_error: message,
|
||||
type,
|
||||
},
|
||||
});
|
||||
// Drop into the live `date_trunc` branch below.
|
||||
effectiveRows = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (effectiveRows.length > 0) {
|
||||
// Cumulative metrics (steps, active energy, distance, flights,
|
||||
// daylight) store `mean` per bucket but the chart needs the
|
||||
// daily SUM. v1.4.39 W-SUM — read `sumValue` directly; fall back
|
||||
// to `mean * count` only when the legacy NULL surfaces (pre-
|
||||
// v1.4.39 rows still waiting for the boot-time backfill to
|
||||
// converge). `mean * count` is algebraically equivalent to
|
||||
// SUM(value) for a single-source day because AVG = SUM / COUNT.
|
||||
const useSum =
|
||||
type != null && CUMULATIVE_HK_TYPES.has(type as MeasurementType);
|
||||
const measurements = effectiveRows.map((r) => ({
|
||||
type: r.type,
|
||||
value: useSum ? (r.sumValue ?? r.mean * r.count) : r.mean,
|
||||
measuredAt: r.bucketStart.toISOString(),
|
||||
count: r.count,
|
||||
// v1.8.5 — per-day min / max so the chart can shade an
|
||||
// Apple-Health-style range band around the daily mean line.
|
||||
// Omitted for cumulative metrics (steps, distance, …) where the
|
||||
// daily value is a SUM, not a central tendency, so an intra-day
|
||||
// spread band carries no meaning.
|
||||
minValue: useSum ? undefined : r.minValue,
|
||||
maxValue: useSum ? undefined : r.maxValue,
|
||||
}));
|
||||
annotate({
|
||||
action: { name: "measurement.list" },
|
||||
meta: {
|
||||
total: measurements.length,
|
||||
type,
|
||||
aggregate: "daily",
|
||||
source: "rollup",
|
||||
coverage_fallback: coverageFallbackFired,
|
||||
},
|
||||
});
|
||||
});
|
||||
if (measurements.length > 0) {
|
||||
return apiSuccess({
|
||||
measurements,
|
||||
meta: {
|
||||
@@ -540,10 +375,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
}
|
||||
// Fall through to the live aggregate path when the rollup is
|
||||
// empty for the requested window — covers brand-new accounts +
|
||||
// the race between a recent write hook and the read, and the
|
||||
// coverage-fallback inline-fold-failed branch above.
|
||||
// `readDailySeries` already falls back to the live `date_trunc`
|
||||
// aggregate on a rollup-coverage miss, so an empty result here means
|
||||
// the window genuinely holds no rows. Fall through to the shared
|
||||
// live aggregate branch below to keep the response shape identical
|
||||
// for the truly-empty case.
|
||||
}
|
||||
|
||||
if (aggregate && aggregate !== "raw" && from && to) {
|
||||
@@ -941,6 +777,25 @@ async function postMeasurement(request: NextRequest) {
|
||||
// reminders against the just-landed readings now. Fire-and-forget.
|
||||
void enqueueReminderSatisfy(user.id).catch(() => {});
|
||||
|
||||
// v1.18.6 — absolute clinical safety-floor check on the just-written
|
||||
// readings (confirm-gated, module-gated, never diagnoses). The combined
|
||||
// BP form lands both arms here, so the BP floor sees a whole reading.
|
||||
// The transient per-entry `symptomsPresent` flag (ORed across the batch)
|
||||
// lifts a confirmed breach to the symptom-coupled emergency copy.
|
||||
// Fire-and-forget — a notification failure never fails the write.
|
||||
void runSafetyFloorCheck({
|
||||
userId: user.id,
|
||||
written: results.map((r) => ({
|
||||
type: r.type,
|
||||
value: r.value,
|
||||
measuredAt: r.measuredAt,
|
||||
glucoseContext: r.glucoseContext,
|
||||
})),
|
||||
symptomsPresent: parsed.data.measurements.some(
|
||||
(m) => m.symptomsPresent === true,
|
||||
),
|
||||
}).catch(() => {});
|
||||
|
||||
// v1.5.0 — refresh the persistent rollup table for every distinct
|
||||
// (type, day) the batch touched so the next analytics / coach read
|
||||
// hits the cache rather than falling through to live aggregation.
|
||||
@@ -1001,8 +856,16 @@ async function postMeasurement(request: NextRequest) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { type, value, measuredAt, notes, source, glucoseContext, deviceType } =
|
||||
parsed.data;
|
||||
const {
|
||||
type,
|
||||
value,
|
||||
measuredAt,
|
||||
notes,
|
||||
source,
|
||||
glucoseContext,
|
||||
deviceType,
|
||||
symptomsPresent,
|
||||
} = parsed.data;
|
||||
|
||||
// Handle unique constraint violation
|
||||
let measurement;
|
||||
@@ -1058,6 +921,25 @@ async function postMeasurement(request: NextRequest) {
|
||||
// against the just-landed reading now. Fire-and-forget.
|
||||
void enqueueReminderSatisfy(user.id).catch(() => {});
|
||||
|
||||
// v1.18.6 — absolute clinical safety-floor check (confirm-gated,
|
||||
// module-gated, never diagnoses). A single-entry POST carries only one arm
|
||||
// of a BP reading, so the BP floor only fires through the combined-form /
|
||||
// batch path; a lone glucose reading is evaluated here. The transient
|
||||
// `symptomsPresent` flag lifts a confirmed breach to the emergency copy.
|
||||
// Fire-and-forget — a notification failure never fails the write.
|
||||
void runSafetyFloorCheck({
|
||||
userId: user.id,
|
||||
written: [
|
||||
{
|
||||
type: measurement.type,
|
||||
value: measurement.value,
|
||||
measuredAt: measurement.measuredAt,
|
||||
glucoseContext: measurement.glucoseContext,
|
||||
},
|
||||
],
|
||||
symptomsPresent,
|
||||
}).catch(() => {});
|
||||
|
||||
// v1.5.0 — refresh the persistent rollup row for the affected
|
||||
// (type, day) tuple. Runs inline so the next read of the
|
||||
// comprehensive / analytics surface is correct without waiting on
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* v1.18.6 — `GET /api/measurements/series-batch` unit + parity tests.
|
||||
*
|
||||
* The endpoint reads each requested type through the SHARED
|
||||
* `readDailySeries` reader — the exact same reader the single-type
|
||||
* `GET /api/measurements?aggregate=daily&source=rollup` route delegates
|
||||
* to (see `src/app/api/measurements/route.ts`). The parity guarantee is
|
||||
* therefore structural: both routes call `readDailySeries`, so the rows
|
||||
* are identical by construction. These tests pin that the batch route
|
||||
* (a) validates its query, (b) calls the reader once per requested type
|
||||
* with the right window, and (c) returns each type's reader output
|
||||
* verbatim under `series[type]`.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/logging/context", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
return { ...actual, annotate: vi.fn() };
|
||||
});
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-read", () => ({
|
||||
loadUserSourcePriority: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("@/lib/measurements/daily-series-read", () => ({
|
||||
readDailySeries: vi.fn(),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { readDailySeries } from "@/lib/measurements/daily-series-read";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "u", role: "USER" as const },
|
||||
};
|
||||
|
||||
const FROM = "2026-05-01T00:00:00.000Z";
|
||||
const TO = "2026-05-31T23:59:59.000Z";
|
||||
|
||||
function req(query: string): NextRequest {
|
||||
return new NextRequest(
|
||||
`http://localhost/api/measurements/series-batch?${query}`,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/measurements/series-batch", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await GET(req(`types=WEIGHT&from=${FROM}&to=${TO}`));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 422 when types is missing", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await GET(req(`from=${FROM}&to=${TO}`));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 for an unknown type", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await GET(req(`types=GARBAGE&from=${FROM}&to=${TO}`));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("reads each type through readDailySeries and keys the result by type", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const weightRows = [
|
||||
{ type: "WEIGHT", value: 80, measuredAt: FROM, count: 1 },
|
||||
];
|
||||
const bpRows = [
|
||||
{ type: "BLOOD_PRESSURE_SYS", value: 126, measuredAt: FROM, count: 1 },
|
||||
];
|
||||
vi.mocked(readDailySeries).mockImplementation(async ({ type }) => {
|
||||
if (type === "WEIGHT") return weightRows as never;
|
||||
if (type === "BLOOD_PRESSURE_SYS") return bpRows as never;
|
||||
return [] as never;
|
||||
});
|
||||
|
||||
const res = await GET(
|
||||
req(`types=WEIGHT,BLOOD_PRESSURE_SYS&from=${FROM}&to=${TO}`),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.series.WEIGHT).toEqual(weightRows);
|
||||
expect(body.data.series.BLOOD_PRESSURE_SYS).toEqual(bpRows);
|
||||
|
||||
// One reader call per unique type, with the parsed window.
|
||||
expect(vi.mocked(readDailySeries)).toHaveBeenCalledTimes(2);
|
||||
const call = vi.mocked(readDailySeries).mock.calls[0][0];
|
||||
expect(call.userId).toBe("user-1");
|
||||
expect(call.from.toISOString()).toBe(FROM);
|
||||
expect(call.to.toISOString()).toBe(TO);
|
||||
});
|
||||
|
||||
it("de-dupes a repeated type to one reader call", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(readDailySeries).mockResolvedValue([] as never);
|
||||
const res = await GET(req(`types=WEIGHT,WEIGHT&from=${FROM}&to=${TO}`));
|
||||
expect(res.status).toBe(200);
|
||||
expect(vi.mocked(readDailySeries)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("isolates a single type's failure to an empty slice, others still return", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const weightRows = [
|
||||
{ type: "WEIGHT", value: 80, measuredAt: FROM, count: 1 },
|
||||
];
|
||||
vi.mocked(readDailySeries).mockImplementation(async ({ type }) => {
|
||||
if (type === "WEIGHT") return weightRows as never;
|
||||
throw new Error("transient db reject");
|
||||
});
|
||||
|
||||
const res = await GET(
|
||||
req(`types=WEIGHT,BLOOD_PRESSURE_SYS&from=${FROM}&to=${TO}`),
|
||||
);
|
||||
// One type rejecting must NOT 500 the whole batch.
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.series.WEIGHT).toEqual(weightRows);
|
||||
expect(body.data.series.BLOOD_PRESSURE_SYS).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* v1.18.6 — GET /api/measurements/series-batch
|
||||
*
|
||||
* Batched, rollup-backed daily-series endpoint. Returns ALL requested
|
||||
* measurement types in ONE response so the dashboard's chart row does a
|
||||
* single round-trip instead of one self-fetch per chart (audit finding
|
||||
* #2 — the per-chart fan-out).
|
||||
*
|
||||
* Query: `types` (comma-list of `MeasurementType`), `from`, `to`. Always
|
||||
* reads `aggregate=daily` through the rollup tier (`source=rollup`) via
|
||||
* the shared `readDailySeries` reader — the SAME path
|
||||
* `GET /api/measurements?aggregate=daily&source=rollup` uses, so the
|
||||
* per-type series are byte-identical between the two endpoints. Internal
|
||||
* per-type reads are capped with `p-limit` to protect the shared Prisma
|
||||
* pool.
|
||||
*
|
||||
* Sleep is deliberately NOT included: SLEEP_DURATION rides a per-night
|
||||
* stage reconstruction (`/api/measurements/series?kind=sleep`), not the
|
||||
* rollup tier, so the dashboard sleep chart stays on its existing
|
||||
* `/series` call. Passing `SLEEP_DURATION` here yields an empty series
|
||||
* for that key (the rollup carries no useful per-night collapse) — the
|
||||
* client keeps sleep on the dedicated route.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import pLimit from "p-limit";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues } from "@/lib/api-response";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import {
|
||||
readDailySeries,
|
||||
type DailySeriesRow,
|
||||
} from "@/lib/measurements/daily-series-read";
|
||||
import {
|
||||
seriesBatchQuerySchema,
|
||||
SERIES_BATCH_MAX_TYPES,
|
||||
} from "@/lib/validations/series-batch";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* Concurrency cap for the per-type rollup reads. Mirrors the W-POOL
|
||||
* `p-limit(4)` discipline applied across the analytics fan-out so a
|
||||
* wide dashboard never packs the shared Prisma pool.
|
||||
*/
|
||||
const SERIES_BATCH_CONCURRENCY = 4;
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const params = Object.fromEntries(request.nextUrl.searchParams);
|
||||
const parsed = seriesBatchQuerySchema.safeParse(params);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { types, from, to } = parsed.data;
|
||||
// De-dupe while preserving order so a caller repeating a type doesn't
|
||||
// double-read it.
|
||||
const uniqueTypes = Array.from(new Set(types)) as MeasurementType[];
|
||||
|
||||
// Load the source-priority ladder ONCE and thread it across every type
|
||||
// so the batched reader doesn't re-query it per type.
|
||||
const priorityJson = await loadUserSourcePriority(user.id);
|
||||
|
||||
// Per-type isolation: a single type's transient DB reject degrades to an
|
||||
// empty slice for THAT type only, never a 500 that blanks every chart in
|
||||
// the row. The failing chart self-fetches through its existing fallback.
|
||||
const limit = pLimit(SERIES_BATCH_CONCURRENCY);
|
||||
let degradedTypes = 0;
|
||||
const entries = await Promise.all(
|
||||
uniqueTypes.map((type) =>
|
||||
limit(async (): Promise<readonly [MeasurementType, DailySeriesRow[]]> => {
|
||||
try {
|
||||
const rows = await readDailySeries({
|
||||
userId: user.id,
|
||||
type,
|
||||
from,
|
||||
to,
|
||||
priorityJson,
|
||||
});
|
||||
return [type, rows] as const;
|
||||
} catch {
|
||||
degradedTypes += 1;
|
||||
return [type, []] as const;
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const series: Record<string, DailySeriesRow[]> = {};
|
||||
for (const [type, rows] of entries) {
|
||||
series[type] = rows;
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "measurement.series.batch" },
|
||||
meta: {
|
||||
type_count: uniqueTypes.length,
|
||||
total: entries.reduce((sum, [, rows]) => sum + rows.length, 0),
|
||||
degraded_types: degradedTypes,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ series });
|
||||
});
|
||||
|
||||
// Re-export the cap so the OpenAPI table + tests share the constant.
|
||||
export { SERIES_BATCH_MAX_TYPES };
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
expectedSlotCountForDay,
|
||||
lastNonSkippedTakenAt,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||||
import { getUserTodayBounds, startOfLocalDayInTz } from "@/lib/tz/local-day";
|
||||
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
|
||||
import {
|
||||
applyCanonicalSlotWrite,
|
||||
@@ -84,19 +84,6 @@ const updateSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
function startOfDayInTz(date: Date, tz: string): Date {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(date);
|
||||
const y = parts.find((p) => p.type === "year")?.value ?? "1970";
|
||||
const m = parts.find((p) => p.type === "month")?.value ?? "01";
|
||||
const d = parts.find((p) => p.type === "day")?.value ?? "01";
|
||||
return new Date(`${y}-${m}-${d}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
@@ -138,7 +125,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
|
||||
if (scope === "today") {
|
||||
const todayStart = startOfDayInTz(new Date(), userTz);
|
||||
const todayStart = startOfLocalDayInTz(new Date(), userTz);
|
||||
const todayEnd = new Date(todayStart.getTime() + 86_400_000);
|
||||
|
||||
// v1.4.39 W-SERVER-FIX — project pending intake rows for every
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { DISCLAIMER_VERSION } from "@/lib/onboarding/disclaimer";
|
||||
|
||||
/**
|
||||
* v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment.
|
||||
*
|
||||
* POST { version: string }
|
||||
* → stamps `users.disclaimer_acknowledged_at = now()` +
|
||||
* `disclaimer_acknowledged_version = <version>`. Idempotent: a repeat
|
||||
* acknowledgment of the same version simply refreshes the timestamp.
|
||||
*
|
||||
* Replaces the per-page / per-chart disclaimer banners removed app-wide in
|
||||
* the same release. The acknowledged legal text stays reachable on the public
|
||||
* privacy page. Never auto-set; the onboarding welcome step is the one writer.
|
||||
*/
|
||||
|
||||
const bodySchema = z.object({
|
||||
// Echoed back from the client so a stale shell cannot silently record an
|
||||
// acknowledgment of copy it never rendered. Bounded; the server pins the
|
||||
// canonical version it persists regardless.
|
||||
version: z.string().min(1).max(64),
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = z.safeParse(bodySchema, rawBody);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid input", 422);
|
||||
}
|
||||
|
||||
// The server pins the canonical version it stamps — the body version is
|
||||
// only a freshness signal, never the source of truth.
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
disclaimerAcknowledgedAt: new Date(),
|
||||
disclaimerAcknowledgedVersion: DISCLAIMER_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "onboarding.disclaimer.acknowledge" },
|
||||
meta: {
|
||||
version: DISCLAIMER_VERSION,
|
||||
clientVersion: parsed.data.version,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ acknowledgedVersion: DISCLAIMER_VERSION });
|
||||
});
|
||||
@@ -5,30 +5,45 @@ import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { tourProgressSchema } from "@/lib/onboarding/tour-progress";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* v1.4.15 Phase B5 — onboarding tour completion endpoint.
|
||||
* v1.18.6 — extended with the resumable module-tour progress point.
|
||||
*
|
||||
* Two operations on a single resource:
|
||||
* Operations on a single resource:
|
||||
*
|
||||
* POST { completed: true, outcome: "completed" | "skipped" }
|
||||
* → flips `users.onboarding_tour_completed` to true. Auto-fires
|
||||
* when the user reaches the end of the spotlight tour OR
|
||||
* explicitly dismisses it.
|
||||
* when the user reaches the end of the module tour OR explicitly
|
||||
* dismisses it.
|
||||
*
|
||||
* POST { completed: false }
|
||||
* → resets the flag so the user can replay the tour from
|
||||
* Settings → Account → "Restart onboarding tour".
|
||||
* Settings → Advanced → "Modul-Tour neu starten". Also CLEARS
|
||||
* `onboarding_tour_progress_json` back to null so the replay
|
||||
* genuinely starts from the first module.
|
||||
*
|
||||
* The `outcome` field is informational — it lands in the structured
|
||||
* Wide Event annotation so analytics can tell completion from
|
||||
* dismiss without writing two columns. Same DB write either way.
|
||||
* POST { progress: { lastStopId, completedStopIds, status, updatedAt } }
|
||||
* → fire-and-forget mid-tour checkpoint so a reload resumes at the
|
||||
* right module. May arrive WITH `completed` (the terminal
|
||||
* checkpoint sends both) or alone (every `nextStep`).
|
||||
*
|
||||
* `outcome` is informational — it lands in the structured Wide Event
|
||||
* annotation so analytics can tell completion from dismiss without a
|
||||
* second column. Same DB write either way.
|
||||
*/
|
||||
|
||||
const tourBodySchema = z.object({
|
||||
completed: z.boolean(),
|
||||
outcome: z.enum(["completed", "skipped"]).optional(),
|
||||
});
|
||||
const tourBodySchema = z
|
||||
.object({
|
||||
completed: z.boolean().optional(),
|
||||
outcome: z.enum(["completed", "skipped"]).optional(),
|
||||
progress: tourProgressSchema.optional(),
|
||||
})
|
||||
.refine((b) => b.completed !== undefined || b.progress !== undefined, {
|
||||
message: "Provide `completed` and/or `progress`.",
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -43,18 +58,36 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("Invalid input", 422);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { onboardingTourCompleted: parsed.data.completed },
|
||||
});
|
||||
// Build the update field-by-field (no mass assignment). `completed`
|
||||
// drives the coarse boolean; a replay (`completed:false`) wipes the
|
||||
// resume point; a progress checkpoint writes the JSON column.
|
||||
const data: Prisma.UserUpdateInput = {};
|
||||
if (parsed.data.completed !== undefined) {
|
||||
data.onboardingTourCompleted = parsed.data.completed;
|
||||
if (parsed.data.completed === false) {
|
||||
// Replay — start the resume point over.
|
||||
data.onboardingTourProgressJson = Prisma.JsonNull;
|
||||
}
|
||||
}
|
||||
if (parsed.data.progress !== undefined) {
|
||||
data.onboardingTourProgressJson = parsed.data.progress;
|
||||
}
|
||||
|
||||
await prisma.user.update({ where: { id: user.id }, data });
|
||||
|
||||
annotate({
|
||||
action: { name: "onboarding.tour.update" },
|
||||
meta: {
|
||||
completed: parsed.data.completed,
|
||||
completed: parsed.data.completed ?? null,
|
||||
outcome: parsed.data.outcome ?? null,
|
||||
progressStatus: parsed.data.progress?.status ?? null,
|
||||
lastStopId: parsed.data.progress?.lastStopId ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ onboardingTourCompleted: parsed.data.completed });
|
||||
return apiSuccess({
|
||||
onboardingTourCompleted:
|
||||
parsed.data.completed ?? user.onboardingTourCompleted,
|
||||
progress: parsed.data.progress ?? null,
|
||||
});
|
||||
});
|
||||
+47
-21
@@ -6,6 +6,7 @@ import { Minimize2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CoachConversation } from "@/components/insights/coach-panel/coach-conversation";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
@@ -61,15 +62,36 @@ export default function CoachPage() {
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-page"
|
||||
// v1.18.1 (W-COACH-UI C1/C3) — the card was capped at
|
||||
// `100dvh-13rem`, leaving a band of wasted space below it on a
|
||||
// standalone route that no longer carries the Insights tab strip.
|
||||
// Claim the page height down to a comfortable bottom gutter so the
|
||||
// thread + rail fill the viewport, and lift the card with a soft
|
||||
// shadow so it reads as one contained surface rather than a thin
|
||||
// outline.
|
||||
className="bg-card flex h-[calc(100dvh-8rem)] min-h-[32rem] flex-col overflow-hidden rounded-2xl border shadow-sm"
|
||||
data-tour-id="coach-hero"
|
||||
// v1.18.6 (CCH-01) — Marc: the Coach read "cut off" vs other pages
|
||||
// and wanted more width / a focused Claude-like surface. The page
|
||||
// sits inside `<AuthShell>`'s shared `mx-auto max-w-screen-xl px-4
|
||||
// pt-6 pb-20 md:px-6` content frame, which boxed the chat into the
|
||||
// same narrow column every dashboard card uses and left a tall dead
|
||||
// band below the card (the `pb-20` FAB gutter, pointless here since
|
||||
// the FAB hides on `/coach`). Break out of that frame: negative
|
||||
// margins cancel the container's horizontal + top/bottom padding so
|
||||
// the chat claims the full content width and the viewport height,
|
||||
// then a light inner padding keeps it off the window edge. The card
|
||||
// itself drops its rounded border on small screens (edge-to-edge,
|
||||
// app-like) and keeps the contained card look from `sm+`.
|
||||
//
|
||||
// v1.18.1 (W-COACH-UI C1/C3) — the card formerly capped at
|
||||
// `100dvh-13rem`, leaving wasted space below it; we now fill the
|
||||
// reclaimed height.
|
||||
className={
|
||||
// Cancel the AuthShell container's padding (`-mt-6` top,
|
||||
// `-mb-20` bottom, `-mx-4 md:-mx-6` sides) so the surface is
|
||||
// full-bleed, then re-pad lightly. The height is the full
|
||||
// viewport minus the top bar (`4rem`) and the small gutters.
|
||||
"-mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-4rem)] min-h-[32rem] flex-col md:-mx-6 " +
|
||||
"px-2 pt-2 pb-3 sm:px-4 sm:pt-4 sm:pb-4"
|
||||
}
|
||||
>
|
||||
<div
|
||||
data-slot="coach-page-card"
|
||||
className="bg-card flex min-h-0 flex-1 flex-col overflow-hidden rounded-none border-y shadow-sm sm:rounded-2xl sm:border"
|
||||
>
|
||||
<CoachConversation
|
||||
surface="page"
|
||||
autoFocusComposer
|
||||
@@ -77,20 +99,24 @@ export default function CoachPage() {
|
||||
<h1 className="min-w-0 truncate text-sm font-semibold">{title}</h1>
|
||||
)}
|
||||
leadingHeaderActions={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleMinimize}
|
||||
data-slot="coach-page-minimize"
|
||||
aria-label={t("insights.coach.minimizeAriaLabel")}
|
||||
title={t("insights.coach.minimizeAriaLabel")}
|
||||
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
|
||||
>
|
||||
<Minimize2 className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<>
|
||||
<ModuleTourTrigger stopId="coach" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleMinimize}
|
||||
data-slot="coach-page-minimize"
|
||||
aria-label={t("insights.coach.minimizeAriaLabel")}
|
||||
title={t("insights.coach.minimizeAriaLabel")}
|
||||
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
|
||||
>
|
||||
<Minimize2 className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -96,6 +96,7 @@
|
||||
* - info → dracula-cyan (neutral informational note)
|
||||
*/
|
||||
--color-success: var(--success);
|
||||
--color-success-foreground: var(--success-foreground);
|
||||
--color-warning: var(--warning);
|
||||
--color-info: var(--info);
|
||||
|
||||
@@ -155,6 +156,17 @@
|
||||
--warning: var(--dracula-orange);
|
||||
--info: var(--dracula-cyan);
|
||||
|
||||
/*
|
||||
* Foreground for glyphs/labels painted on a solid `bg-success` fill. The
|
||||
* raw Dracula green (`#50fa7b`) is a bright tone, so white-on-green reads
|
||||
* ~1.37:1 in dark mode — well under AA. The dark theme below pairs it with
|
||||
* the near-black Dracula background (`#282a36`, ~10.4:1); the light theme
|
||||
* keeps white on the darker light-mode green (`#14720a`, ~6.1:1). Mirrors
|
||||
* the `--primary-foreground` pattern. Default here keeps the dark-theme
|
||||
* value so an un-themed root never falls back to an unreadable pairing.
|
||||
*/
|
||||
--success-foreground: #282a36;
|
||||
|
||||
--radius: 0.625rem;
|
||||
}
|
||||
|
||||
@@ -212,6 +224,9 @@
|
||||
--status-pill-near: #ffb86c;
|
||||
--status-pill-out: #ff8a8a;
|
||||
|
||||
/* Dark glyph on the bright Dracula-green fill — ~10.4:1 on `#50fa7b`. */
|
||||
--success-foreground: #282a36;
|
||||
|
||||
/*
|
||||
* Dose accent in dark mode — the exact Tailwind `text-purple-400`
|
||||
* (`oklch(0.714 0.203 305.504)`) the cards used before, so the dark card
|
||||
@@ -297,6 +312,8 @@
|
||||
--success: #14720a;
|
||||
--warning: #a34d14;
|
||||
--info: #036a96;
|
||||
/* White glyph on the darker light-mode green — ~6.1:1 on `#14720a`. */
|
||||
--success-foreground: #ffffff;
|
||||
--border: #cfcfde;
|
||||
--input: #cfcfde;
|
||||
--ring: #644ac9;
|
||||
@@ -889,3 +906,32 @@
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Global reduced-motion backstop ──────────────────────────────────
|
||||
* Defensive catch-all so any animation/transition we forget to gate
|
||||
* per-component still collapses to near-instant when the user has asked
|
||||
* for reduced motion. The per-component blocks above stay — they carry
|
||||
* tailored static fallbacks (static dots, full-height bars). This is the
|
||||
* floor, not a replacement. Near-zero (not 0) keeps JS animation-end /
|
||||
* transition-end listeners firing so state machines don't stall.
|
||||
*
|
||||
* `animation-fill-mode: forwards` + `animation-delay: 0ms` are load-bearing:
|
||||
* several entrance animations rest at `opacity: 0` and only become visible
|
||||
* through their reveal keyframe. Without holding the end state (and without
|
||||
* zeroing the stagger delay) the near-zero animation completes and the
|
||||
* element snaps back to its hidden base style — leaving charts and list
|
||||
* rows permanently invisible under reduced motion. Forwards pins the final
|
||||
* (revealed) frame; zero delay reveals immediately. Per-component blocks
|
||||
* above that set `animation: none` are unaffected (no animation to fill). */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
animation-fill-mode: forwards !important;
|
||||
animation-delay: 0ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
@@ -170,6 +170,32 @@ export default function InsightsBlutdruckPage() {
|
||||
average). */}
|
||||
<MetricTargetSummary slug="blood-pressure" />
|
||||
|
||||
{/* v1.18.6 — free derived BP context: pulse pressure (SBP − DBP) and
|
||||
mean arterial pressure ((SBP + 2·DBP)/3), computed from the most
|
||||
recent reading. Pure derivation of stored BP — no schema, no extra
|
||||
fetch. Calm, cited, non-alarming. */}
|
||||
{(() => {
|
||||
const sbp = sysSummary?.latest ?? null;
|
||||
const dbp = diaSummary?.latest ?? null;
|
||||
if (sbp == null || dbp == null || sbp <= dbp) return null;
|
||||
const pp = Math.round(sbp - dbp);
|
||||
const map = Math.round((sbp + 2 * dbp) / 3);
|
||||
return (
|
||||
<p
|
||||
data-slot="bp-derived-caption"
|
||||
className="text-muted-foreground text-sm leading-relaxed"
|
||||
>
|
||||
{t("insights.subPage.bpDerived.pulsePressure", {
|
||||
value: pp,
|
||||
})}{" "}
|
||||
{t("insights.subPage.bpDerived.meanArterialPressure", {
|
||||
value: map,
|
||||
})}{" "}
|
||||
{t("insights.subPage.bpDerived.caveat")}
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* v1.12.4 — Einschätzung is the last content block; the stat strip
|
||||
(rendered by the shell) closes the spine below it. */}
|
||||
<SlugInsightStatusCard
|
||||
|
||||
+28
-42
@@ -1,20 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { FlaskConical, Loader2, MoreVertical, Plus } from "lucide-react";
|
||||
import { Loader2, Plus, Wrench } from "lucide-react";
|
||||
|
||||
import { BiomarkerManager } from "@/components/labs/biomarker-manager";
|
||||
import { LabForm } from "@/components/labs/lab-form";
|
||||
import { LabList } from "@/components/labs/lab-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -22,6 +16,7 @@ import { useMounted } from "@/hooks/use-mounted";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
|
||||
export default function LabsPage() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
@@ -30,7 +25,6 @@ export default function LabsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [manageOpen, setManageOpen] = useState(false);
|
||||
// Sticky-footer slot element for the add-result sheet (filled by the
|
||||
// ResponsiveSheet `footer` ref so the form portals its action row there).
|
||||
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
@@ -41,7 +35,7 @@ export default function LabsPage() {
|
||||
);
|
||||
const pull = usePullToRefresh({
|
||||
onRefresh: refreshVisible,
|
||||
disabled: dialogOpen || manageOpen,
|
||||
disabled: dialogOpen,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -63,7 +57,10 @@ export default function LabsPage() {
|
||||
<PullToRefreshIndicator {...pull} />
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
<h1
|
||||
data-tour-id="labs-hero"
|
||||
className="text-2xl font-bold tracking-tight"
|
||||
>
|
||||
{t("labs.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-xs sm:text-sm">
|
||||
@@ -71,34 +68,32 @@ export default function LabsPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ModuleTourTrigger stopId="labs" />
|
||||
{/* v1.18.6 (MOD-01) — the wrench is the module's customize entry
|
||||
point, left of the primary Add and linking to the Labs settings
|
||||
page (view, sort order, biomarker CRUD + reorder). Mirrors the
|
||||
medication page header's wrench glyph + slot + 44px tap floor. */}
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/labs"
|
||||
aria-label={t("labs.customize")}
|
||||
title={t("labs.customize")}
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setDialogOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("labs.addResult")}
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
{/* Secondary actions fold into one overflow kebab beside the primary
|
||||
Add, mirroring the medication-card header (one cluster, not a
|
||||
two-button row). */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem onClick={() => setManageOpen(true)}>
|
||||
<FlaskConical className="mr-2 h-4 w-4" />
|
||||
{t("labs.biomarker.manage")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -121,15 +116,6 @@ export default function LabsPage() {
|
||||
/>
|
||||
</ResponsiveSheet>
|
||||
|
||||
<ResponsiveSheet
|
||||
open={manageOpen}
|
||||
onOpenChange={setManageOpen}
|
||||
title={t("labs.biomarker.manageTitle")}
|
||||
description={t("labs.biomarker.manageSheetDescription")}
|
||||
>
|
||||
<BiomarkerManager />
|
||||
</ResponsiveSheet>
|
||||
|
||||
<LabList onAddFirst={() => setDialogOpen(true)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Plus, Loader2 } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
|
||||
export default function MeasurementsPage() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
@@ -110,7 +111,10 @@ export default function MeasurementsPage() {
|
||||
<PullToRefreshIndicator {...pull} />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
<h1
|
||||
data-tour-id="measurements-hero"
|
||||
className="text-2xl font-bold tracking-tight"
|
||||
>
|
||||
{t("measurements.title")}
|
||||
</h1>
|
||||
{/* v1.4.34 IW-G — subtitle now stays visible on mobile so
|
||||
@@ -120,13 +124,16 @@ export default function MeasurementsPage() {
|
||||
{t("measurements.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("measurements.addMeasurement")}
|
||||
</Button>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ModuleTourTrigger stopId="measurements" />
|
||||
<Button
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("measurements.addMeasurement")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ResponsiveSheet
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog";
|
||||
import { MedicationCard } from "@/components/medications/medication-card";
|
||||
import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card";
|
||||
@@ -328,7 +329,10 @@ export default function MedicationsPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
<h1
|
||||
data-tour-id="medications-hero"
|
||||
className="text-2xl font-bold tracking-tight"
|
||||
>
|
||||
{t("medications.title")}
|
||||
</h1>
|
||||
{/* v1.4.34 IW-G — subtitle stays visible on mobile so the
|
||||
@@ -338,6 +342,7 @@ export default function MedicationsPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ModuleTourTrigger stopId="medications" />
|
||||
{/* v1.16.11 (#316) — "Alle fälligen einnehmen". Contextual, not a
|
||||
permanent fixture: it renders only while at least TWO
|
||||
medications are currently due (a single due dose is the
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Plus, Loader2, Wrench } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
|
||||
export default function MoodPage() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
@@ -65,7 +66,10 @@ export default function MoodPage() {
|
||||
<PullToRefreshIndicator {...pull} />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
<h1
|
||||
data-tour-id="mood-hero"
|
||||
className="text-2xl font-bold tracking-tight"
|
||||
>
|
||||
{t("mood.title")}
|
||||
</h1>
|
||||
{/* v1.4.34 IW-G — subtitle stays visible on mobile so the
|
||||
@@ -75,6 +79,7 @@ export default function MoodPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ModuleTourTrigger stopId="mood" />
|
||||
{/* v1.17 — the wrench is the one customize entry point: it
|
||||
links to /settings/mood, which owns the tag groups, custom
|
||||
tags, visibility, and picker order. Same glyph, slot (left
|
||||
|
||||
+136
-188
@@ -52,7 +52,6 @@ import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton";
|
||||
import { TrendHint } from "@/components/charts/trend-hint";
|
||||
import { summaryToTrend7Delta } from "@/lib/analytics/trend-delta";
|
||||
import { GettingStartedChecklist } from "@/components/onboarding/getting-started-checklist";
|
||||
import { TourLauncher } from "@/components/onboarding/tour-launcher";
|
||||
import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card";
|
||||
import { RecentWorkoutsTile } from "@/components/dashboard/recent-workouts-tile";
|
||||
import { VorsorgeDashboardCard } from "@/components/measurement-reminders/vorsorge-dashboard-card";
|
||||
@@ -106,129 +105,23 @@ import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { mergeSlimAndThickAnalytics } from "@/lib/analytics/merge-slim-thick";
|
||||
import { isWindowSufficient } from "@/lib/analytics/window-confidence";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import {
|
||||
buildTrafficLightBands,
|
||||
buildTrafficRange,
|
||||
buildWeightBandsFromHeight,
|
||||
buildWeightRangeFromHeight,
|
||||
getBodyFatTargetRange,
|
||||
} from "@/lib/analytics/value-bands";
|
||||
import {
|
||||
getAgeFromDateOfBirth,
|
||||
getPersonalizedPulseTarget,
|
||||
} from "@/lib/analytics/pulse-targets";
|
||||
import { buildDashboardBands } from "@/lib/dashboard/bands";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
/**
|
||||
* v1.7.0 — first-paint gate for the dashboard tile strip.
|
||||
*
|
||||
* `primaryLoading` must come from whichever query actually drives the
|
||||
* tiles: the snapshot cell by default, the slim analytics cell when
|
||||
* `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false`. A disabled TanStack query reports
|
||||
* `isLoading: false` (idle fetch status), so keying off the wrong
|
||||
* source flashes the empty state for the whole fetch. Pure + exported
|
||||
* so the gate has direct unit coverage without mounting the page.
|
||||
*/
|
||||
export function resolveDashboardFirstPaintGate(input: {
|
||||
trendCardCount: number;
|
||||
chartCount: number;
|
||||
configuredTileCount: number;
|
||||
primaryLoading: boolean;
|
||||
}): { showTileStripSkeleton: boolean; showEmptyState: boolean } {
|
||||
const showTileStripSkeleton =
|
||||
input.trendCardCount === 0 &&
|
||||
input.primaryLoading &&
|
||||
input.configuredTileCount > 0;
|
||||
const showEmptyState =
|
||||
input.trendCardCount === 0 &&
|
||||
input.chartCount === 0 &&
|
||||
!showTileStripSkeleton &&
|
||||
!input.primaryLoading;
|
||||
return { showTileStripSkeleton, showEmptyState };
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.8 — widget ids that actually paint a tile in the strip on the
|
||||
* web dashboard, mirrored from the per-id render blocks below.
|
||||
* `medications` / `recentWorkouts` / `achievements` carry NO strip tile
|
||||
* (chart-row cards only), and the iOS-pin-only ids have no web render
|
||||
* path at all — counting any of them over-reserved the loading
|
||||
* silhouette and made the strip reshuffle when the data landed.
|
||||
*/
|
||||
const TILE_CAPABLE_WIDGET_IDS = new Set<string>([
|
||||
"weight",
|
||||
"bp",
|
||||
"pulse",
|
||||
"bodyFat",
|
||||
"mood",
|
||||
"sleep",
|
||||
"steps",
|
||||
"glucose",
|
||||
"bpInTarget",
|
||||
"vo2Max",
|
||||
]);
|
||||
|
||||
/**
|
||||
* v1.16.8 — silhouette count for the tile-strip skeleton: one card per
|
||||
* tile-capable, tile-visible widget. `bp` paints TWO tiles (sys + dia,
|
||||
* see the render block below) so it counts double; `glucose` can fan
|
||||
* out to one tile per logged context, but the contexts are unknown
|
||||
* until the snapshot lands, so it reserves one card (the sane floor).
|
||||
* Pure + exported for direct unit coverage.
|
||||
*/
|
||||
export function resolveConfiguredTileCount(layout: DashboardLayout): number {
|
||||
let count = 0;
|
||||
for (const widget of layout.widgets) {
|
||||
if (!TILE_CAPABLE_WIDGET_IDS.has(widget.id)) continue;
|
||||
if (!(widget.tileVisible ?? widget.visible)) continue;
|
||||
count += widget.id === "bp" ? 2 : 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.8 — widget ids with a chart-row surface on the web dashboard,
|
||||
* mirrored from the `charts[]` entries below. The achievements +
|
||||
* recent-workouts cards stay out: they self-skeleton, carry no
|
||||
* chart-shaped footprint, and gate on `layoutResolved`.
|
||||
*/
|
||||
const CHART_CAPABLE_WIDGET_IDS = new Set<string>([
|
||||
"weight",
|
||||
"bp",
|
||||
"pulse",
|
||||
"bodyFat",
|
||||
"mood",
|
||||
"sleep",
|
||||
"steps",
|
||||
"medications",
|
||||
// v1.18.2 — Vorsorge preventive-care summary card (chart-row only).
|
||||
"vorsorge",
|
||||
]);
|
||||
|
||||
/**
|
||||
* v1.16.8 — expected chart-row card count while the snapshot is still
|
||||
* in flight. Every chart gate below needs `count > 0` from snapshot
|
||||
* data, so the cold page used to render NO chart row at all and then
|
||||
* grow ~1000 px when the snapshot landed. The best available signal
|
||||
* before data arrives is the layout config (the user's saved layout
|
||||
* when cached, the default otherwise): one card per chart-visible
|
||||
* widget, plus the BMI card that rides the weight gate when the
|
||||
* profile carries a height. Pure + exported for direct unit coverage.
|
||||
*/
|
||||
export function resolveChartRowPlaceholderCount(
|
||||
layout: DashboardLayout,
|
||||
opts?: { hasHeightCm?: boolean },
|
||||
): number {
|
||||
let count = 0;
|
||||
for (const widget of layout.widgets) {
|
||||
if (!CHART_CAPABLE_WIDGET_IDS.has(widget.id)) continue;
|
||||
if (!widget.visible) continue;
|
||||
count += 1;
|
||||
if (widget.id === "weight" && opts?.hasHeightCm) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
// v1.18.6 — the pure first-paint / skeleton-reservation gates moved to
|
||||
// `@/components/dashboard/dashboard-gates`. Re-exported here so the
|
||||
// existing `../page` unit-test imports stay valid while `page.tsx` thins
|
||||
// to an orchestrator.
|
||||
export {
|
||||
resolveDashboardFirstPaintGate,
|
||||
resolveConfiguredTileCount,
|
||||
resolveChartRowPlaceholderCount,
|
||||
} from "@/components/dashboard/dashboard-gates";
|
||||
import {
|
||||
resolveDashboardFirstPaintGate,
|
||||
resolveConfiguredTileCount,
|
||||
resolveChartRowPlaceholderCount,
|
||||
} from "@/components/dashboard/dashboard-gates";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
@@ -621,23 +514,42 @@ export default function DashboardPage() {
|
||||
RANDOM: "targets.glucoseRandom",
|
||||
BEDTIME: "targets.glucoseBedtime",
|
||||
};
|
||||
const bpTargets =
|
||||
user?.dateOfBirth != null ? getBpTargets(new Date(user.dateOfBirth)) : null;
|
||||
const pulseAge = getAgeFromDateOfBirth(user?.dateOfBirth ?? null);
|
||||
const pulseTarget = getPersonalizedPulseTarget(
|
||||
pulseAge,
|
||||
(user?.gender as "MALE" | "FEMALE" | null | undefined) ?? null,
|
||||
);
|
||||
const bodyFatRange = getBodyFatTargetRange(user?.gender);
|
||||
const weightRange = user?.heightCm
|
||||
? buildWeightRangeFromHeight(user.heightCm)
|
||||
: null;
|
||||
const weightBands = user?.heightCm
|
||||
? buildWeightBandsFromHeight(user.heightCm, {
|
||||
lowerBound: 30,
|
||||
upperBound: 250,
|
||||
})
|
||||
// v1.18.6 — band / target math is computed SERVER-side in the snapshot
|
||||
// DTO (`targetBands`) so the client stops recomputing it from the
|
||||
// profile (audit finding #3). When snapshot mode is on, read the
|
||||
// resolved numbers straight from `snapshotQuery.data.targetBands`; the
|
||||
// `!snapshotEnabled` branch keeps the legacy client-compute path so the
|
||||
// non-snapshot fallback still works. The two produce byte-identical
|
||||
// numbers (the server calls the SAME helpers — see
|
||||
// `buildTargetBands` + its parity test).
|
||||
const serverBands = snapshotEnabled
|
||||
? snapshotQuery.data?.targetBands
|
||||
: undefined;
|
||||
// Resolve the bands through the SAME `buildDashboardBands` helper the
|
||||
// server snapshot uses, so the snapshot path and the client fallback
|
||||
// are byte-identical by construction (no second inline copy of the
|
||||
// band math to drift). Always compute the client copy from the profile
|
||||
// — it is pure + cheap — so a still-loading snapshot frame keeps real
|
||||
// bands rather than blank charts; `serverBands ?? clientBands` then
|
||||
// prefers the authoritative server numbers the moment they arrive.
|
||||
// Inputs only change with profile facts, so memoise the band math — it
|
||||
// ran on every dashboard render (and got discarded by `serverBands ??`
|
||||
// in the snapshot steady state) before this. Keyed on the `user` object
|
||||
// identity so the React Compiler can preserve the memo (a narrower
|
||||
// property list trips `preserve-manual-memoization`).
|
||||
const clientBands = useMemo(
|
||||
() =>
|
||||
buildDashboardBands({
|
||||
dateOfBirth: user?.dateOfBirth ? new Date(user.dateOfBirth) : null,
|
||||
gender: (user?.gender as "MALE" | "FEMALE" | null | undefined) ?? null,
|
||||
heightCm: user?.heightCm ?? null,
|
||||
}),
|
||||
[user],
|
||||
);
|
||||
const bands = serverBands ?? clientBands;
|
||||
const bpTargets = bands.bpTargets;
|
||||
const weightRange = bands.weightRange;
|
||||
const weightBands = bands.weightBands ?? undefined;
|
||||
const bpTargetZones = bpTargets
|
||||
? [
|
||||
{
|
||||
@@ -660,48 +572,83 @@ export default function DashboardPage() {
|
||||
},
|
||||
]
|
||||
: undefined;
|
||||
const bpSysRange = bpTargets
|
||||
? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh)
|
||||
: null;
|
||||
const bpDiaRange = bpTargets
|
||||
? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh)
|
||||
: null;
|
||||
const pulseDisplayRange = {
|
||||
greenMin: pulseTarget.greenMin,
|
||||
greenMax: pulseTarget.greenMax,
|
||||
orangeMin: pulseTarget.orangeMin,
|
||||
orangeMax: pulseTarget.orangeMax,
|
||||
};
|
||||
const pulseBands = [
|
||||
{ min: 30, max: pulseTarget.orangeMin, color: "#ff5555", opacity: 0.16 },
|
||||
{
|
||||
min: pulseTarget.orangeMin,
|
||||
max: pulseTarget.greenMin,
|
||||
color: "#ffb86c",
|
||||
opacity: 0.18,
|
||||
const bpSysRange = bands.bpSysRange;
|
||||
const bpDiaRange = bands.bpDiaRange;
|
||||
const pulseDisplayRange = bands.pulseDisplayRange;
|
||||
const pulseBands = bands.pulseBands;
|
||||
const bodyFatBands = bands.bodyFatBands;
|
||||
|
||||
// v1.18.6 — ONE batched fetch for every visible non-sleep chart series
|
||||
// (audit finding #2). Pre-fix, each `<HealthChart>` fired its own
|
||||
// `/api/measurements?...&source=rollup` round-trip, so a 6-8 chart
|
||||
// dashboard issued 6-8 parallel requests on load. The batched endpoint
|
||||
// returns all of them in one response; each chart reads its slice via
|
||||
// `preloadedSeries` instead of self-fetching. Sleep stays on its
|
||||
// dedicated per-night `/series` adapter (not rollup-backed), so it is
|
||||
// never included here and the sleep chart keeps its own fetch.
|
||||
//
|
||||
// The window matches the charts' default 30-point view (the dashboard
|
||||
// mounts them with no range-tab interaction); a chart whose window
|
||||
// widens past this slice (range tab / comparison) drops batched
|
||||
// coverage and self-fetches for the new window — see `usePreloaded`.
|
||||
const batchTypeSet = new Set<string>();
|
||||
if (showWeightChart) batchTypeSet.add("WEIGHT");
|
||||
if (showBpCharts) {
|
||||
batchTypeSet.add("BLOOD_PRESSURE_SYS");
|
||||
batchTypeSet.add("BLOOD_PRESSURE_DIA");
|
||||
}
|
||||
if (showPulseChart)
|
||||
batchTypeSet.add(hasRestingHr ? "RESTING_HEART_RATE" : "PULSE");
|
||||
if (showBodyFatChart) batchTypeSet.add("BODY_FAT");
|
||||
if (showStepsChart) batchTypeSet.add("ACTIVITY_STEPS");
|
||||
const batchChartTypes = Array.from(batchTypeSet);
|
||||
|
||||
// Stable day-bucketed window so the cache key is stable across the day
|
||||
// (mirrors the chart's own ISO-window stability contract). Computed
|
||||
// once per mount via lazy state so a re-render never re-keys the batch.
|
||||
const [batchWindow] = useState(() => {
|
||||
const to = new Date();
|
||||
to.setHours(23, 59, 59, 999);
|
||||
const from = new Date(to.getTime() - 31 * 86_400_000);
|
||||
return { from: from.toISOString(), to: to.toISOString() };
|
||||
});
|
||||
|
||||
const { data: batchedSeries } = useQuery({
|
||||
queryKey: queryKeys.chartSeriesBatch(
|
||||
batchChartTypes.join(","),
|
||||
batchWindow.from,
|
||||
batchWindow.to,
|
||||
),
|
||||
enabled:
|
||||
isAuthenticated && mounted && batchChartTypes.length > 0,
|
||||
staleTime: 60_000,
|
||||
gcTime: 5 * 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
queryFn: async () => {
|
||||
const sp = new URLSearchParams();
|
||||
sp.set("types", batchChartTypes.join(","));
|
||||
sp.set("from", batchWindow.from);
|
||||
sp.set("to", batchWindow.to);
|
||||
const res = await apiGet<{
|
||||
series: Record<
|
||||
string,
|
||||
Array<{
|
||||
value: number;
|
||||
measuredAt: string;
|
||||
count?: number;
|
||||
minValue?: number | null;
|
||||
maxValue?: number | null;
|
||||
}>
|
||||
>;
|
||||
}>(`/api/measurements/series-batch?${sp}`);
|
||||
return res.series;
|
||||
},
|
||||
{
|
||||
min: pulseTarget.greenMin,
|
||||
max: pulseTarget.greenMax,
|
||||
color: "#50fa7b",
|
||||
opacity: 0.2,
|
||||
},
|
||||
{
|
||||
min: pulseTarget.greenMax,
|
||||
max: pulseTarget.orangeMax,
|
||||
color: "#ffb86c",
|
||||
opacity: 0.18,
|
||||
},
|
||||
{ min: pulseTarget.orangeMax, max: 220, color: "#ff5555", opacity: 0.16 },
|
||||
].filter((band) => band.max > band.min);
|
||||
const bodyFatBands = buildTrafficLightBands(
|
||||
bodyFatRange.min,
|
||||
bodyFatRange.max,
|
||||
{
|
||||
lowerBound: 2,
|
||||
upperBound: 55,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Per-chart slices in the chart's `MeasurementApiRow` shape. The batched
|
||||
// rows already carry `measuredAt` / `value` / `count` / min / max — the
|
||||
// exact fields the chart's bucketing loop reads.
|
||||
const preloadedSeries = batchedSeries;
|
||||
// v1.7.0 — the primary data source differs by flag. In snapshot
|
||||
// mode `analyticsSlimQuery` is `enabled: false`, and a disabled
|
||||
// TanStack query reports `fetchStatus: "idle"` → `isLoading` is
|
||||
@@ -752,15 +699,10 @@ export default function DashboardPage() {
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* v1.4.15 Phase B5 — spotlight tour for first-time users.
|
||||
* Self-gates on `user.onboardingTourCompleted` (DB flag) plus
|
||||
* a session-storage dismiss guard. We pass `ready=true` only
|
||||
* after analytics has resolved — the tour anchors to the tile
|
||||
* strip and we don't want the cutout snapping to a 0×0
|
||||
* placeholder before tiles render. The launcher mounts a no-op
|
||||
* `null` when the user has already seen the tour, so this
|
||||
* line is free for established users. */}
|
||||
<TourLauncher ready={data !== undefined} />
|
||||
{/* v1.18.6 — the spotlight tour launcher moved to the app-shell
|
||||
* (`AuthShell`) so its overlay survives the cross-page
|
||||
* `router.push`es the module tour makes. The dashboard no longer
|
||||
* mounts it. */}
|
||||
|
||||
{/* Quick Entry Sheets — bottom-sheet on `<md`, centred Dialog on `md+`. */}
|
||||
<QuickEntrySheets
|
||||
@@ -1222,6 +1164,7 @@ export default function DashboardPage() {
|
||||
<HealthChartDynamic
|
||||
key="weight-chart"
|
||||
onDataReady={() => markChartReady("weight-chart")}
|
||||
preloadedSeries={preloadedSeries}
|
||||
chartKey="weight"
|
||||
types={["WEIGHT"]}
|
||||
title={t("dashboard.weight")}
|
||||
@@ -1242,6 +1185,7 @@ export default function DashboardPage() {
|
||||
<HealthChartDynamic
|
||||
key="bmi-chart"
|
||||
onDataReady={() => markChartReady("bmi-chart")}
|
||||
preloadedSeries={preloadedSeries}
|
||||
chartKey="bmi"
|
||||
types={["WEIGHT"]}
|
||||
title={t("targets.bmi")}
|
||||
@@ -1271,6 +1215,7 @@ export default function DashboardPage() {
|
||||
<HealthChartDynamic
|
||||
key="bp-chart"
|
||||
onDataReady={() => markChartReady("bp-chart")}
|
||||
preloadedSeries={preloadedSeries}
|
||||
chartKey="bp"
|
||||
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
|
||||
title={t("dashboard.bloodPressure")}
|
||||
@@ -1294,6 +1239,7 @@ export default function DashboardPage() {
|
||||
<HealthChartDynamic
|
||||
key="pulse-chart"
|
||||
onDataReady={() => markChartReady("pulse-chart")}
|
||||
preloadedSeries={preloadedSeries}
|
||||
chartKey="pulse"
|
||||
// v1.15.12 A2 — chart the RESTING series against the
|
||||
// resting band when available; otherwise chart raw heart
|
||||
@@ -1320,6 +1266,7 @@ export default function DashboardPage() {
|
||||
<HealthChartDynamic
|
||||
key="bodyFat-chart"
|
||||
onDataReady={() => markChartReady("bodyFat-chart")}
|
||||
preloadedSeries={preloadedSeries}
|
||||
chartKey="bodyFat"
|
||||
types={["BODY_FAT"]}
|
||||
title={t("dashboard.bodyFat")}
|
||||
@@ -1380,6 +1327,7 @@ export default function DashboardPage() {
|
||||
<HealthChartDynamic
|
||||
key="steps-chart"
|
||||
onDataReady={() => markChartReady("steps-chart")}
|
||||
preloadedSeries={preloadedSeries}
|
||||
chartKey="steps"
|
||||
types={["ACTIVITY_STEPS"]}
|
||||
title={t("dashboard.steps") ?? "Steps"}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import type { JSX } from "react";
|
||||
import type { JSX, ReactNode } from "react";
|
||||
|
||||
import { AccountSection } from "@/components/settings/account-section";
|
||||
import { AboutSection } from "@/components/settings/about-section";
|
||||
@@ -29,6 +29,9 @@ import {
|
||||
type SettingsSectionSlug,
|
||||
} from "@/components/settings/section-slugs";
|
||||
import { SettingsShell } from "@/components/settings/settings-shell";
|
||||
import { SettingsSectionFrame } from "@/components/settings/settings-section-frame";
|
||||
import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
|
||||
/**
|
||||
* Dynamic settings section route. Each of the `SETTINGS_SECTION_SLUGS`
|
||||
@@ -71,6 +74,50 @@ const SECTION_COMPONENTS: Record<
|
||||
advanced: AdvancedSection,
|
||||
};
|
||||
|
||||
// v1.18.6 (W9) — the two Layout-hub child editors render a "← back to hub"
|
||||
// link above their heading. The shared `<SettingsSectionFrame>` exposes a
|
||||
// `topSlot` for exactly this; the link lives here so the section bodies stay
|
||||
// pure card content.
|
||||
const HUB_CHILD_SLUGS: ReadonlySet<SettingsSectionSlug> = new Set([
|
||||
"dashboard",
|
||||
"insights",
|
||||
]);
|
||||
|
||||
/** Optional content rendered above the canonical heading, per slug. */
|
||||
function frameTopSlot(slug: SettingsSectionSlug): ReactNode {
|
||||
if (HUB_CHILD_SLUGS.has(slug)) {
|
||||
return (
|
||||
<SettingsHubBackLink
|
||||
href="/settings/layout"
|
||||
labelKey="settings.sections.layout.backToHub"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Optional inline content rendered at the top-right of the heading row. */
|
||||
function frameHeadingAccessory(slug: SettingsSectionSlug): ReactNode {
|
||||
// v1.18.6 (W9) — the module guided-tour re-entry triggers used to live in
|
||||
// a `justify-end` header inside the section body; they move to the frame's
|
||||
// heading row so the heading itself is the spotlight anchor.
|
||||
if (slug === "gesundheitsakte") {
|
||||
return (
|
||||
<span data-tour-id="export-hero">
|
||||
<ModuleTourTrigger stopId="export" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (slug === "integrations") {
|
||||
return (
|
||||
<span data-tour-id="integrations-hero">
|
||||
<ModuleTourTrigger stopId="integrations" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
// Next.js 16 made route `params` an async Promise. We `await` it before use.
|
||||
params: Promise<{ section: string }>;
|
||||
@@ -91,8 +138,17 @@ export default async function SettingsSectionPage({ params }: PageProps) {
|
||||
// guards the slug, but a future slug added to `SETTINGS_SECTION_SLUGS`
|
||||
// without a wired component would otherwise crash silently — placeholder
|
||||
// surfaces the gap visually instead.
|
||||
// v1.18.6 (W9) — every section now renders inside the shared
|
||||
// `<SettingsSectionFrame>`, which emits the one visible heading + subtitle
|
||||
// each page must show (the section bodies no longer carry their own).
|
||||
const body = SectionComponent ? (
|
||||
<SectionComponent />
|
||||
<SettingsSectionFrame
|
||||
slug={section}
|
||||
topSlot={frameTopSlot(section)}
|
||||
headingAccessory={frameHeadingAccessory(section)}
|
||||
>
|
||||
<SectionComponent />
|
||||
</SettingsSectionFrame>
|
||||
) : (
|
||||
<SectionPlaceholder slug={section} />
|
||||
);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ModuleSettingsFrame } from "@/components/module-list/module-settings-frame";
|
||||
import { IllnessSettings } from "@/components/illness/illness-settings";
|
||||
|
||||
/**
|
||||
* v1.18.6 (W8 / MOD-03) — the Illness module's own settings page. Dedicated
|
||||
* static route (wins over `/settings/[section]`); reached from the wrench
|
||||
* beside the Illness page's "hinzufügen" button.
|
||||
*/
|
||||
export default function IllnessSettingsPage() {
|
||||
const { isLoading } = useAuth();
|
||||
const mounted = useMounted();
|
||||
const { t } = useTranslations();
|
||||
|
||||
if (!mounted || isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[40vh] items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ModuleSettingsFrame
|
||||
title={t("illness.settings.title")}
|
||||
description={t("illness.settings.description")}
|
||||
backHref="/illness"
|
||||
backLabelKey="illness.settings.back"
|
||||
>
|
||||
<IllnessSettings />
|
||||
</ModuleSettingsFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ModuleSettingsFrame } from "@/components/module-list/module-settings-frame";
|
||||
import { LabsSettings } from "@/components/labs/labs-settings";
|
||||
|
||||
/**
|
||||
* v1.18.6 (W8 / MOD-03 + MOD-04) — the Labs module's own settings page.
|
||||
* Dedicated static route (wins over `/settings/[section]`); reached from the
|
||||
* wrench beside the Labs page's "hinzufügen" button.
|
||||
*/
|
||||
export default function LabsSettingsPage() {
|
||||
const { isLoading } = useAuth();
|
||||
const mounted = useMounted();
|
||||
const { t } = useTranslations();
|
||||
|
||||
if (!mounted || isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[40vh] items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ModuleSettingsFrame
|
||||
title={t("labs.settings.title")}
|
||||
description={t("labs.settings.description")}
|
||||
backHref="/labs"
|
||||
backLabelKey="labs.settings.back"
|
||||
>
|
||||
<LabsSettings />
|
||||
</ModuleSettingsFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ModuleSettingsFrame } from "@/components/module-list/module-settings-frame";
|
||||
import { VorsorgeSettings } from "@/components/measurement-reminders/vorsorge-settings";
|
||||
|
||||
/**
|
||||
* v1.18.6 (W8 / MOD-03) — the Vorsorge module's own settings page.
|
||||
*
|
||||
* A dedicated static route that wins over the dynamic `/settings/[section]`
|
||||
* router (static segments take precedence in Next.js), so it adds a
|
||||
* per-module settings surface without touching the parallel-phase-owned
|
||||
* `section-slugs` registry or `settings-shell`. Reached from the wrench
|
||||
* beside the Vorsorge page's "hinzufügen" button.
|
||||
*/
|
||||
export default function VorsorgeSettingsPage() {
|
||||
const { isLoading } = useAuth();
|
||||
const mounted = useMounted();
|
||||
const { t } = useTranslations();
|
||||
|
||||
if (!mounted || isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[40vh] items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ModuleSettingsFrame
|
||||
title={t("measurementReminders.settings.title")}
|
||||
description={t("measurementReminders.settings.description")}
|
||||
backHref="/vorsorge"
|
||||
backLabelKey="measurementReminders.settings.back"
|
||||
>
|
||||
<VorsorgeSettings />
|
||||
</ModuleSettingsFrame>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
Bell,
|
||||
Blocks,
|
||||
Database,
|
||||
FileText,
|
||||
Info,
|
||||
@@ -39,6 +40,7 @@ import { cn } from "@/lib/utils";
|
||||
import { scrollBehaviorForUser } from "@/lib/motion";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SectionNavHeadingSpacer } from "@/components/settings/section-nav-heading-spacer";
|
||||
import { isAdminSectionSlug, type AdminSectionSlug } from "./section-slugs";
|
||||
|
||||
interface AdminSection {
|
||||
@@ -82,6 +84,13 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [
|
||||
titleKey: "admin.section.coach.title",
|
||||
icon: Sparkles,
|
||||
},
|
||||
// v1.18.6 (W9) — server-wide module availability split out of Coach into
|
||||
// its own entry, sitting next to it.
|
||||
{
|
||||
slug: "module-availability",
|
||||
titleKey: "admin.section.module-availability.title",
|
||||
icon: Blocks,
|
||||
},
|
||||
{
|
||||
slug: "feedback",
|
||||
titleKey: "admin.section.feedback.title",
|
||||
@@ -276,6 +285,9 @@ export function AdminShell({ active, children }: AdminShellProps) {
|
||||
className="hidden md:block"
|
||||
>
|
||||
<div className="sticky top-20">
|
||||
{/* v1.18.6 (W9) — align the first nav item with the top of the
|
||||
first card, matching the settings shell. */}
|
||||
<SectionNavHeadingSpacer />
|
||||
<ul className="space-y-1">
|
||||
<li>
|
||||
<Link
|
||||
|
||||
@@ -16,11 +16,15 @@ export const ADMIN_SECTION_SLUGS = [
|
||||
"integrations",
|
||||
// v1.18.1 — the former `ai-quality`, `assistant`, and `coach-feedback`
|
||||
// sections collapse into one "Coach" area. It holds the assistant
|
||||
// surface toggles, the server-key + module-availability operator
|
||||
// config, and both feedback-quality tables (Coach + Insights). Three
|
||||
// navigation entries for one coherent coach-quality concern read as
|
||||
// clutter; one card opens the full picture.
|
||||
// surface toggles, the server-key operator config, and both
|
||||
// feedback-quality tables (Coach + Insights). Three navigation entries
|
||||
// for one coherent coach-quality concern read as clutter; one card opens
|
||||
// the full picture.
|
||||
"coach",
|
||||
// v1.18.6 (W9) — server-wide module availability is its own section now
|
||||
// (was stacked under Coach). It gates every module, not just the coach,
|
||||
// so it reads better next to Coach than buried inside it.
|
||||
"module-availability",
|
||||
"feedback",
|
||||
"reminders",
|
||||
"users",
|
||||
|
||||
@@ -198,7 +198,6 @@ describe("dashboard.*Short keys exist across all six locales", () => {
|
||||
"sleepShort",
|
||||
"stepsShort",
|
||||
"vo2MaxShort",
|
||||
"bpInTargetShort",
|
||||
];
|
||||
|
||||
// Resolved at test time so the assertion runs against the current
|
||||
|
||||
@@ -233,6 +233,23 @@ interface HealthChartProps {
|
||||
* idempotent; non-dashboard mounts simply omit it.
|
||||
*/
|
||||
onDataReady?: () => void;
|
||||
/**
|
||||
* v1.18.6 — pre-fetched daily series, keyed by `MeasurementType`. When
|
||||
* the dashboard batches every visible chart's series into ONE
|
||||
* `/api/measurements/series-batch` round-trip (audit finding #2), it
|
||||
* passes each chart its slice here. When supplied, the chart's queryFn
|
||||
* consumes these rows for the covered types instead of issuing its own
|
||||
* per-type `/api/measurements` fetch. Omitted on non-dashboard mounts
|
||||
* (insights sub-pages, mini cards) which keep self-fetching.
|
||||
* SLEEP_DURATION is never pre-fetched (it rides the per-night `/series`
|
||||
* adapter), so a sleep chart self-fetches that branch regardless.
|
||||
*
|
||||
* The rows must already be scoped to the chart's active fetch window;
|
||||
* the prop is keyed into the query cache so a range-tab change (wider
|
||||
* window) re-fetches cleanly rather than blending a batched slice with
|
||||
* a self-fetched one.
|
||||
*/
|
||||
preloadedSeries?: Record<string, MeasurementApiRow[]>;
|
||||
}
|
||||
|
||||
interface ChartDataPoint {
|
||||
@@ -251,8 +268,13 @@ interface MeasurementApiRow {
|
||||
// path (non-cumulative metrics only). Drive the Apple-Health-style
|
||||
// range band shaded around the mean line. Absent on the raw-row path
|
||||
// (windows ≤ 7 days) and on cumulative metrics.
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
// v1.18.6 — `null` accepted alongside `undefined` so the batched
|
||||
// series endpoint's row shape (which carries `number | null`) feeds
|
||||
// `preloadedSeries` directly. The bucketing loop already guards each
|
||||
// with a `typeof … === "number"` check, so a null is ignored exactly
|
||||
// like an absent value.
|
||||
minValue?: number | null;
|
||||
maxValue?: number | null;
|
||||
// v1.4.43 W2-CHART-GATE — the rollup / daily-aggregate paths return
|
||||
// the underlying raw-row count per bucket. Used downstream to gate
|
||||
// the "more days needed" empty-state copy on the actual measurement
|
||||
@@ -537,6 +559,7 @@ export function HealthChart({
|
||||
onVisibleStats,
|
||||
titleIcon,
|
||||
onDataReady,
|
||||
preloadedSeries,
|
||||
}: HealthChartProps) {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { t, locale } = useTranslations();
|
||||
@@ -620,6 +643,20 @@ export function HealthChart({
|
||||
};
|
||||
}, [rangePoints, effectiveCompareBaseline]);
|
||||
|
||||
// v1.18.6 — honour the dashboard's batched slice ONLY for the
|
||||
// daily-aggregate window (`windowDays > 7`, the rollup path) and ONLY
|
||||
// when EVERY non-sleep type the chart renders is present in the slice.
|
||||
// SLEEP_DURATION is never batched (per-night `/series`), so a chart
|
||||
// that includes it always self-fetches. A range-tab change to a ≤7-day
|
||||
// window (raw rows) drops batched coverage and self-fetches.
|
||||
const usePreloaded = useMemo(() => {
|
||||
if (!preloadedSeries) return false;
|
||||
if (fetchWindow.windowDays <= 7) return false;
|
||||
return types.every(
|
||||
(type) => type === "SLEEP_DURATION" || preloadedSeries[type] != null,
|
||||
);
|
||||
}, [preloadedSeries, fetchWindow.windowDays, types]);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
// v1.4.40 W-RSC — route the chart-data key through `queryKeys.chartData`
|
||||
// so the `["chart-data"]` prefix lands in `measurementDependentKeys`
|
||||
@@ -649,6 +686,14 @@ export function HealthChart({
|
||||
// v1.7.0 — re-key when the display scale changes so a km/h chart
|
||||
// never reads a m/s-scaled sibling out of the cache.
|
||||
valueScale,
|
||||
// v1.18.6 — discriminate a batched-slice render from a self-fetch
|
||||
// ONLY on the dashboard mounts that pass `preloadedSeries`. When the
|
||||
// prop is absent (insights sub-pages, mini cards) the empty string
|
||||
// keeps the factory tuple byte-identical with the pre-v1.18.6
|
||||
// 8-element key, so those callers' cache layout is unchanged. On a
|
||||
// dashboard mount the discriminator flips "preloaded" → "fetch" when
|
||||
// a range-tab change drops batched coverage, re-fetching cleanly.
|
||||
preloadedSeries ? (usePreloaded ? "preloaded" : "fetch") : "",
|
||||
),
|
||||
// v1.4.28 FB-D2 — cache the bounded window for a minute so tab
|
||||
// navigation between insights sub-pages does not re-fire every
|
||||
@@ -760,16 +805,30 @@ export function HealthChart({
|
||||
typeParams.set("source", "rollup");
|
||||
}
|
||||
|
||||
// v1.16.8 — a failed per-type fetch rejects the whole query
|
||||
// instead of silently dropping the series: the swallowed-catch
|
||||
// variant cached the partial/empty result as fresh success, so
|
||||
// an outage painted the empty state (or a half chart) with no
|
||||
// retry path. Rejecting lets TanStack's retry semantics and the
|
||||
// in-card error state below do their jobs.
|
||||
const data = await apiGet<{ measurements?: MeasurementApiRow[] }>(
|
||||
`/api/measurements?${typeParams}`,
|
||||
);
|
||||
const page = data?.measurements ?? [];
|
||||
// v1.18.6 — when the dashboard handed this chart a batched slice
|
||||
// for the type, fold those rows instead of a per-type fetch. The
|
||||
// rows came from `/api/measurements/series-batch`, which reads
|
||||
// through the SAME rollup path the per-type daily fetch hits, so
|
||||
// the bucketed result is byte-identical. `usePreloaded` already
|
||||
// gated the window (`> 7`) + per-type coverage above; default to
|
||||
// an empty slice (paints the no-data card) rather than fetching,
|
||||
// so a covered chart never issues a network request.
|
||||
const page =
|
||||
usePreloaded && preloadedSeries
|
||||
? (preloadedSeries[type] ?? [])
|
||||
: await (async () => {
|
||||
// v1.16.8 — a failed per-type fetch rejects the whole
|
||||
// query instead of silently dropping the series: the
|
||||
// swallowed-catch variant cached the partial/empty result
|
||||
// as fresh success, so an outage painted the empty state
|
||||
// (or a half chart) with no retry path. Rejecting lets
|
||||
// TanStack's retry semantics and the in-card error state
|
||||
// below do their jobs.
|
||||
const data = await apiGet<{
|
||||
measurements?: MeasurementApiRow[];
|
||||
}>(`/api/measurements?${typeParams}`);
|
||||
return data?.measurements ?? [];
|
||||
})();
|
||||
|
||||
for (const measurement of page) {
|
||||
// v1.7.0 — fold the display-time scale into the raw value at
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CycleRing } from "./cycle-ring";
|
||||
import { BbtChart } from "./bbt-chart";
|
||||
@@ -139,20 +140,26 @@ export function CycleView() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
<h1
|
||||
data-tour-id="cycle-hero"
|
||||
className="text-2xl font-bold tracking-tight"
|
||||
>
|
||||
{t("cycle.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-xs sm:text-sm">
|
||||
{t("cycle.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => openSheet(today)}
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("cycle.logToday")}
|
||||
</Button>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ModuleTourTrigger stopId="cycle" />
|
||||
<Button
|
||||
onClick={() => openSheet(today)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("cycle.logToday")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: ring (left) + tabs (right). Single column below lg. */}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* v1.18.6 — dashboard first-paint / skeleton-reservation gates.
|
||||
*
|
||||
* Extracted verbatim from `src/app/page.tsx` so the page component reads
|
||||
* as a thinner orchestrator and these pure helpers (already unit-tested)
|
||||
* live in one cohesive module. `page.tsx` re-exports them so the existing
|
||||
* `../page` test imports stay valid.
|
||||
*/
|
||||
import type { DashboardLayout } from "@/lib/dashboard-layout";
|
||||
|
||||
/**
|
||||
* v1.7.0 — first-paint gate for the dashboard tile strip.
|
||||
*
|
||||
* `primaryLoading` must come from whichever query actually drives the
|
||||
* tiles: the snapshot cell by default, the slim analytics cell when
|
||||
* `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false`. A disabled TanStack query reports
|
||||
* `isLoading: false` (idle fetch status), so keying off the wrong
|
||||
* source flashes the empty state for the whole fetch. Pure + exported
|
||||
* so the gate has direct unit coverage without mounting the page.
|
||||
*/
|
||||
export function resolveDashboardFirstPaintGate(input: {
|
||||
trendCardCount: number;
|
||||
chartCount: number;
|
||||
configuredTileCount: number;
|
||||
primaryLoading: boolean;
|
||||
}): { showTileStripSkeleton: boolean; showEmptyState: boolean } {
|
||||
const showTileStripSkeleton =
|
||||
input.trendCardCount === 0 &&
|
||||
input.primaryLoading &&
|
||||
input.configuredTileCount > 0;
|
||||
const showEmptyState =
|
||||
input.trendCardCount === 0 &&
|
||||
input.chartCount === 0 &&
|
||||
!showTileStripSkeleton &&
|
||||
!input.primaryLoading;
|
||||
return { showTileStripSkeleton, showEmptyState };
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.8 — widget ids that actually paint a tile in the strip on the
|
||||
* web dashboard, mirrored from the per-id render blocks below.
|
||||
* `medications` / `recentWorkouts` / `achievements` carry NO strip tile
|
||||
* (chart-row cards only), and the iOS-pin-only ids have no web render
|
||||
* path at all — counting any of them over-reserved the loading
|
||||
* silhouette and made the strip reshuffle when the data landed.
|
||||
*/
|
||||
const TILE_CAPABLE_WIDGET_IDS = new Set<string>([
|
||||
"weight",
|
||||
"bp",
|
||||
"pulse",
|
||||
"bodyFat",
|
||||
"mood",
|
||||
"sleep",
|
||||
"steps",
|
||||
"glucose",
|
||||
"bpInTarget",
|
||||
"vo2Max",
|
||||
]);
|
||||
|
||||
/**
|
||||
* v1.16.8 — silhouette count for the tile-strip skeleton: one card per
|
||||
* tile-capable, tile-visible widget. `bp` paints TWO tiles (sys + dia,
|
||||
* see the render block below) so it counts double; `glucose` can fan
|
||||
* out to one tile per logged context, but the contexts are unknown
|
||||
* until the snapshot lands, so it reserves one card (the sane floor).
|
||||
* Pure + exported for direct unit coverage.
|
||||
*/
|
||||
export function resolveConfiguredTileCount(layout: DashboardLayout): number {
|
||||
let count = 0;
|
||||
for (const widget of layout.widgets) {
|
||||
if (!TILE_CAPABLE_WIDGET_IDS.has(widget.id)) continue;
|
||||
if (!(widget.tileVisible ?? widget.visible)) continue;
|
||||
count += widget.id === "bp" ? 2 : 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.8 — widget ids with a chart-row surface on the web dashboard,
|
||||
* mirrored from the `charts[]` entries below. The achievements +
|
||||
* recent-workouts cards stay out: they self-skeleton, carry no
|
||||
* chart-shaped footprint, and gate on `layoutResolved`.
|
||||
*/
|
||||
const CHART_CAPABLE_WIDGET_IDS = new Set<string>([
|
||||
"weight",
|
||||
"bp",
|
||||
"pulse",
|
||||
"bodyFat",
|
||||
"mood",
|
||||
"sleep",
|
||||
"steps",
|
||||
"medications",
|
||||
// v1.18.2 — Vorsorge preventive-care summary card (chart-row only).
|
||||
"vorsorge",
|
||||
]);
|
||||
|
||||
/**
|
||||
* v1.16.8 — expected chart-row card count while the snapshot is still
|
||||
* in flight. Every chart gate below needs `count > 0` from snapshot
|
||||
* data, so the cold page used to render NO chart row at all and then
|
||||
* grow ~1000 px when the snapshot landed. The best available signal
|
||||
* before data arrives is the layout config (the user's saved layout
|
||||
* when cached, the default otherwise): one card per chart-visible
|
||||
* widget, plus the BMI card that rides the weight gate when the
|
||||
* profile carries a height. Pure + exported for direct unit coverage.
|
||||
*/
|
||||
export function resolveChartRowPlaceholderCount(
|
||||
layout: DashboardLayout,
|
||||
opts?: { hasHeightCm?: boolean },
|
||||
): number {
|
||||
let count = 0;
|
||||
for (const widget of layout.widgets) {
|
||||
if (!CHART_CAPABLE_WIDGET_IDS.has(widget.id)) continue;
|
||||
if (!widget.visible) continue;
|
||||
count += 1;
|
||||
if (widget.id === "weight" && opts?.hasHeightCm) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { getHourForTimeZone } from "@/components/dashboard/range-display";
|
||||
@@ -96,6 +97,8 @@ export function DashboardHeader({
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* v1.18.6 — re-enter the guided module tour from the dashboard. */}
|
||||
<ModuleTourTrigger stopId="dashboardOverview" />
|
||||
{/* Customize shortcut to the dashboard-customization settings
|
||||
(the tile/layout editor at /settings/dashboard). Sits to
|
||||
the left of the add button as a monochrome ghost icon, with
|
||||
|
||||
@@ -6,6 +6,7 @@ import { join } from "node:path";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { makeFormatters } from "@/lib/format-locale";
|
||||
import type { DashboardSnapshot } from "@/lib/dashboard/snapshot";
|
||||
import { buildTargetBands } from "@/lib/dashboard/snapshot";
|
||||
import type { MedsTodayBlock } from "@/lib/dashboard/meds-today";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { DashboardHero } from "../dashboard-hero";
|
||||
@@ -92,6 +93,11 @@ function baseSnapshot(
|
||||
layout: { version: 1, widgets: [] },
|
||||
layoutCatalogue: [],
|
||||
metricStates: {},
|
||||
targetBands: buildTargetBands({
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
}),
|
||||
tiles: {
|
||||
summaries: {},
|
||||
lastSeenByType: {},
|
||||
|
||||
@@ -32,7 +32,7 @@ export function SortableHead({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSort(column)}
|
||||
className="hover:text-foreground inline-flex items-center gap-1 transition-colors"
|
||||
className="hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1 rounded-sm transition-colors focus-visible:ring-2 focus-visible:outline-none sm:min-h-0"
|
||||
>
|
||||
{label}
|
||||
{isActive ? (
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.18.6 (W8 / MOD-03) — the Illness module's own settings page body.
|
||||
*
|
||||
* Reached from the wrench beside the page's "hinzufügen" button. Lets the
|
||||
* user reorder conditions/episodes + pick the card-vs-list page view. Order
|
||||
* + view persist client-side via `useModuleListPrefs("illness")`.
|
||||
*/
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { ModuleViewToggle } from "@/components/module-list/module-view-toggle";
|
||||
import {
|
||||
ModuleOrderEditor,
|
||||
type ReorderItem,
|
||||
} from "@/components/module-list/module-order-editor";
|
||||
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
|
||||
import { useIllnessEpisodes } from "./use-illness";
|
||||
|
||||
export function IllnessSettings() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { data: episodes, isLoading } = useIllnessEpisodes(true);
|
||||
const { prefs, setView, setOrder } = useModuleListPrefs("illness");
|
||||
|
||||
const ordered = applyOrder(episodes ?? [], prefs.order, (e) => e.id);
|
||||
const reorderItems: ReorderItem[] = ordered.map((e) => ({
|
||||
id: e.id,
|
||||
name: e.label,
|
||||
secondary: `${t(`illness.type.${e.type}`)} · ${
|
||||
e.resolvedAt
|
||||
? t("illness.status.recovered")
|
||||
: e.lifecycle === "CHRONIC_ONGOING"
|
||||
? t("illness.status.ongoing")
|
||||
: t("illness.status.active")
|
||||
} · ${fmt.dateShort(new Date(e.onsetAt))}`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t("moduleList.viewHeading")}
|
||||
</h2>
|
||||
<ModuleViewToggle view={prefs.view} onChange={setView} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("moduleList.viewDescription")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t("moduleList.reorder.heading")}
|
||||
</h2>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ModuleOrderEditor items={reorderItems} onChange={setOrder} />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,9 +15,9 @@
|
||||
* per-day timeline and correlation card live. Retrospective-only copy — a
|
||||
* journal, not a medical device, and it does not diagnose.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Plus, Stethoscope } from "lucide-react";
|
||||
import { ChevronDown, Plus, Stethoscope, Wrench } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -32,6 +32,8 @@ import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
|
||||
import { LogDaySheet } from "./log-day-sheet";
|
||||
import { NewEpisodeSheet } from "./new-episode-sheet";
|
||||
@@ -57,6 +59,7 @@ interface EpisodeCardProps {
|
||||
onEdit: (episode: IllnessEpisodeDTO) => void;
|
||||
onResolve: (id: string) => void;
|
||||
resolving: boolean;
|
||||
view: "cards" | "list";
|
||||
}
|
||||
|
||||
function EpisodeCard({
|
||||
@@ -66,6 +69,7 @@ function EpisodeCard({
|
||||
onEdit,
|
||||
onResolve,
|
||||
resolving,
|
||||
view,
|
||||
}: EpisodeCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -73,6 +77,58 @@ function EpisodeCard({
|
||||
const isChronic = episode.lifecycle === "CHRONIC_ONGOING";
|
||||
const flareCount = flares?.length ?? 0;
|
||||
|
||||
// v1.18.6 (MOD-03) — compact list row: a single divided line per episode
|
||||
// (label + status chips + onset; tap navigates to the detail surface).
|
||||
if (view === "list") {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<Link
|
||||
href={`/illness/${episode.id}`}
|
||||
className="block truncate text-sm font-medium hover:underline focus-visible:underline"
|
||||
>
|
||||
{episode.label}
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||
<Badge variant="secondary">
|
||||
{t(`illness.type.${episode.type}`)}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{active
|
||||
? isChronic
|
||||
? t("illness.status.ongoing")
|
||||
: t("illness.status.active")
|
||||
: t("illness.status.recovered")}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">
|
||||
{fmt.dateShort(new Date(episode.onsetAt))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-9"
|
||||
onClick={() => onLogDay(episode.id)}
|
||||
>
|
||||
{t("illness.logDay")}
|
||||
</Button>
|
||||
<EpisodeMenu
|
||||
episode={episode}
|
||||
onEdit={() => onEdit(episode)}
|
||||
onResolve={
|
||||
active && !isChronic ? () => onResolve(episode.id) : undefined
|
||||
}
|
||||
resolving={resolving}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full gap-3 md:gap-3">
|
||||
<CardHeader className="pb-0">
|
||||
@@ -169,6 +225,8 @@ function EpisodeGroup({
|
||||
onEdit,
|
||||
onResolve,
|
||||
resolving,
|
||||
view,
|
||||
collapsible = false,
|
||||
}: {
|
||||
title: string;
|
||||
parents: IllnessEpisodeDTO[];
|
||||
@@ -177,26 +235,74 @@ function EpisodeGroup({
|
||||
onEdit: (episode: IllnessEpisodeDTO) => void;
|
||||
onResolve: (id: string) => void;
|
||||
resolving: boolean;
|
||||
view: "cards" | "list";
|
||||
/**
|
||||
* v1.18.6 (MOD-05) — the resolved group collapses by default behind a
|
||||
* disclosure trigger so a long recovered history never pushes the active
|
||||
* conditions below the fold.
|
||||
*/
|
||||
collapsible?: boolean;
|
||||
}) {
|
||||
// Hooks must run unconditionally; bail on the empty render afterwards.
|
||||
const [open, setOpen] = useState(false);
|
||||
const gridId = useId();
|
||||
if (parents.length === 0) return null;
|
||||
|
||||
const grid = (
|
||||
<div
|
||||
id={gridId}
|
||||
className={cn(
|
||||
view === "list" ? "space-y-2" : "grid gap-4 sm:grid-cols-2",
|
||||
)}
|
||||
>
|
||||
{parents.map((episode) => (
|
||||
<EpisodeCard
|
||||
key={episode.id}
|
||||
episode={episode}
|
||||
flares={childrenByParent.get(episode.id)}
|
||||
resolving={resolving}
|
||||
onLogDay={onLogDay}
|
||||
onEdit={onEdit}
|
||||
onResolve={onResolve}
|
||||
view={view}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (collapsible) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-controls={gridId}
|
||||
className="text-muted-foreground hover:text-foreground -mx-1 flex w-fit items-center gap-1.5 rounded-md px-1 py-0.5 text-xs font-medium uppercase tracking-wide transition-colors"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0 transition-transform motion-reduce:transition-none",
|
||||
open ? "rotate-0" : "-rotate-90",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{title}
|
||||
<span className="text-muted-foreground/70 normal-case">
|
||||
({parents.length})
|
||||
</span>
|
||||
</button>
|
||||
{open ? grid : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{parents.map((episode) => (
|
||||
<EpisodeCard
|
||||
key={episode.id}
|
||||
episode={episode}
|
||||
flares={childrenByParent.get(episode.id)}
|
||||
resolving={resolving}
|
||||
onLogDay={onLogDay}
|
||||
onEdit={onEdit}
|
||||
onResolve={onResolve}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{grid}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -205,6 +311,7 @@ export function IllnessView() {
|
||||
const { t } = useTranslations();
|
||||
const { data: episodes, isLoading } = useIllnessEpisodes(true);
|
||||
const resolve = useResolveEpisode();
|
||||
const { prefs } = useModuleListPrefs("illness");
|
||||
|
||||
const today = todayLocal();
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
@@ -247,8 +354,14 @@ export function IllnessView() {
|
||||
}
|
||||
}
|
||||
|
||||
return { activeParents, resolvedParents, childrenByParent };
|
||||
}, [episodes]);
|
||||
return {
|
||||
// v1.18.6 (MOD-03) — honour the user's persisted manual order within
|
||||
// each status group (ids absent from the order sort after the block).
|
||||
activeParents: applyOrder(activeParents, prefs.order, (e) => e.id),
|
||||
resolvedParents: applyOrder(resolvedParents, prefs.order, (e) => e.id),
|
||||
childrenByParent,
|
||||
};
|
||||
}, [episodes, prefs.order]);
|
||||
|
||||
const hasEpisodes = (episodes?.length ?? 0) > 0;
|
||||
|
||||
@@ -256,23 +369,49 @@ export function IllnessView() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
<h1
|
||||
data-tour-id="illness-hero"
|
||||
className="text-2xl font-bold tracking-tight"
|
||||
>
|
||||
{t("illness.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-xs sm:text-sm">
|
||||
{t("illness.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setNewOpen(true)}
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("illness.newEpisode")}
|
||||
</Button>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ModuleTourTrigger stopId="illness" />
|
||||
{/* v1.18.6 (MOD-01) — wrench left of the primary Add, linking to the
|
||||
Illness settings page (view + reorder). */}
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/illness"
|
||||
aria-label={t("illness.customize")}
|
||||
title={t("illness.customize")}
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
{/* v1.18.6 (MOD-02) — the add button reads "hinzufügen" like every
|
||||
other module, not the bespoke "neue Episode". */}
|
||||
<Button
|
||||
onClick={() => setNewOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-xs">{t("illness.disclaimer")}</p>
|
||||
{/* v1.18.6 (DISC-01) — the per-page medical disclaimer line is removed;
|
||||
the one-time acknowledgment now lives at onboarding and the legal
|
||||
text stays reachable on the public privacy page. */}
|
||||
|
||||
{isLoading ? (
|
||||
<div className={cn("grid gap-4 sm:grid-cols-2")}>
|
||||
@@ -290,6 +429,7 @@ export function IllnessView() {
|
||||
parents={grouped.activeParents}
|
||||
childrenByParent={grouped.childrenByParent}
|
||||
resolving={resolve.isPending}
|
||||
view={prefs.view}
|
||||
onLogDay={(id) => setLogEpisodeId(id)}
|
||||
onEdit={(e) => setEditEpisode(e)}
|
||||
onResolve={(id) => resolve.mutate(id)}
|
||||
@@ -299,6 +439,8 @@ export function IllnessView() {
|
||||
parents={grouped.resolvedParents}
|
||||
childrenByParent={grouped.childrenByParent}
|
||||
resolving={resolve.isPending}
|
||||
view={prefs.view}
|
||||
collapsible
|
||||
onLogDay={(id) => setLogEpisodeId(id)}
|
||||
onEdit={(e) => setEditEpisode(e)}
|
||||
onResolve={(id) => resolve.mutate(id)}
|
||||
|
||||
@@ -206,7 +206,7 @@ describe("<RecoverySection> data-gating", () => {
|
||||
// Routed through the shared <EmptyState> (dashed bordered card), not the
|
||||
// old hand-rolled div — the empty title comes from the unified primitive.
|
||||
expect(html).toContain("border-dashed");
|
||||
expect(html).not.toContain('data-slot="recovery-group-strain"');
|
||||
expect(html).not.toContain('data-slot="recovery-block-DAY_STRAIN"');
|
||||
});
|
||||
|
||||
it("renders a canonical chart block for the present signal", () => {
|
||||
@@ -218,13 +218,18 @@ describe("<RecoverySection> data-gating", () => {
|
||||
}),
|
||||
);
|
||||
const html = render(<RecoverySection />);
|
||||
expect(html).toContain('data-slot="recovery-group-strain"');
|
||||
// v1.18.6 — the per-group sub-headings are gone; present blocks render as
|
||||
// one flat stack. The data gate now reads at the block level.
|
||||
expect(html).toContain('data-slot="recovery-block-DAY_STRAIN"');
|
||||
// B3 — the redundant "Recovery score" cross-link block is gone; this
|
||||
// surface is reached from the overview already.
|
||||
expect(html).not.toContain('data-slot="recovery-score-link"');
|
||||
// The recharge group (ANS charge only) has no data → hidden.
|
||||
expect(html).not.toContain('data-slot="recovery-group-recharge"');
|
||||
// The recharge group's only signal (ANS charge) has no data → its block
|
||||
// is dropped.
|
||||
expect(html).not.toContain('data-slot="recovery-block-ANS_CHARGE"');
|
||||
// The removed sub-headings must not reappear.
|
||||
expect(html).not.toContain('data-slot="recovery-group-strain"');
|
||||
expect(html).not.toContain("Strain & load");
|
||||
// Empty note must not show when at least one signal is present.
|
||||
expect(html).not.toContain('data-slot="recovery-empty"');
|
||||
});
|
||||
|
||||
@@ -192,7 +192,11 @@ describe("<HealthScoreCard>", () => {
|
||||
expect(moodSliceMatch?.[1]).toBe("—");
|
||||
});
|
||||
|
||||
it("renders the disclaimer", () => {
|
||||
it("v1.4.27 B1 — no longer mounts an inline Ask-the-Coach button (hero strip carries the CTA)", () => {
|
||||
// The hero strip's existing "Ask the coach" action covers this
|
||||
// surface; the card-internal duplicate button retired. v1.18.6 (L13)
|
||||
// dropped the now-dead `onAskCoach` prop too — the card renders no
|
||||
// Coach affordance at all.
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
@@ -201,34 +205,7 @@ describe("<HealthScoreCard>", () => {
|
||||
delta={null}
|
||||
/>,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="health-score-card-disclaimer"/);
|
||||
expect(html).toContain("Indicative");
|
||||
});
|
||||
|
||||
it("v1.4.27 B1 — no longer mounts an inline Ask-the-Coach button (hero strip carries the CTA)", () => {
|
||||
// The hero strip's existing "Ask the coach" action covers this
|
||||
// surface; the card-internal duplicate button retired. The
|
||||
// onAskCoach prop stays on the type signature so callers don't
|
||||
// break, but the card no longer renders it.
|
||||
const omitted = ssr(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
);
|
||||
const supplied = ssr(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
onAskCoach={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(omitted).not.toContain('data-slot="health-score-card-ask-coach"');
|
||||
expect(supplied).not.toContain('data-slot="health-score-card-ask-coach"');
|
||||
expect(html).not.toContain('data-slot="health-score-card-ask-coach"');
|
||||
});
|
||||
|
||||
|
||||
@@ -239,13 +216,11 @@ describe("<HealthScoreCard>", () => {
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={5}
|
||||
onAskCoach={() => {}}
|
||||
/>,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("Gesundheitsscore");
|
||||
expect(html).toContain("im Vergleich zur Vorwoche");
|
||||
expect(html).toContain("Orientierungshilfe");
|
||||
// v1.4.27 B1 — the inline Coach button retired; hero strip carries
|
||||
// the action now.
|
||||
expect(html).not.toContain("Coach fragen");
|
||||
@@ -308,27 +283,6 @@ describe("<HealthScoreCard>", () => {
|
||||
expect(cardOpen?.[0]).toContain("flex-col");
|
||||
});
|
||||
|
||||
it("pins the disclaimer to the bottom of the stretched card via mt-auto", () => {
|
||||
// Without `mt-auto` the disclaimer rides directly under the
|
||||
// provenance accordion and leaves the recovered space below the
|
||||
// disclaimer instead of between the body and the footer. The
|
||||
// FB-H2 contract is "score number on top, disclaimer on bottom,
|
||||
// recovered space in the middle".
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
);
|
||||
const disclaimerOpen = html.match(
|
||||
/<p[^>]*data-slot="health-score-card-disclaimer"[^>]*>/,
|
||||
);
|
||||
expect(disclaimerOpen).not.toBeNull();
|
||||
expect(disclaimerOpen?.[0]).toContain("mt-auto");
|
||||
});
|
||||
|
||||
// v1.4.37 W4a item 1 — the inner column switched from `flex
|
||||
// flex-col gap-3` to a 7-row grid so the slack collects on row 6
|
||||
// (the provenance accordion) instead of clumping under the
|
||||
|
||||
@@ -80,7 +80,11 @@ describe("<InsightStatusCard>", () => {
|
||||
text="ignored when hasProvider is false"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Assessment unavailable.");
|
||||
// v1.18.6 — the no-provider tile is a guided-setup explainer + a
|
||||
// Settings link, not a bare "unavailable" line.
|
||||
expect(html).toContain("Connect an AI provider");
|
||||
expect(html).toContain("Open AI settings");
|
||||
expect(html).toContain("/settings/ai");
|
||||
expect(html).not.toContain("ignored when hasProvider is false");
|
||||
});
|
||||
|
||||
|
||||
@@ -127,6 +127,30 @@ describe("<SleepHypnogram> — timeline bar visibility", () => {
|
||||
expect(render(session)).toContain('role="img"');
|
||||
});
|
||||
|
||||
it("suppresses the timeline bar for a SYNTHESISED (reconstructed) night", () => {
|
||||
// WHOOP / Polar report per-stage TOTALS and the server lays the segments
|
||||
// out in a fixed physiological order — the timeline would invent a phase
|
||||
// progression that was never measured. So even with distinct, well-timed
|
||||
// spans, a `reconstructed` night drops the bar and keeps the breakdown.
|
||||
const session: SleepHypnogramSession = {
|
||||
...baseSession,
|
||||
source: "WHOOP",
|
||||
reconstructed: true,
|
||||
};
|
||||
const html = render(session);
|
||||
expect(html).not.toContain('role="img"');
|
||||
expect(html).toContain('data-slot="sleep-hypnogram-breakdown"');
|
||||
expect(html).toContain('data-slot="sleep-hypnogram-estimate-note"');
|
||||
});
|
||||
|
||||
it("keeps the timeline bar for a measured (non-reconstructed) night", () => {
|
||||
// An Apple Health night carries real per-stage onsets, so the stepped bar
|
||||
// is genuine and stays. The estimate note never appears.
|
||||
const html = render({ ...baseSession, reconstructed: false });
|
||||
expect(html).toContain('role="img"');
|
||||
expect(html).not.toContain('data-slot="sleep-hypnogram-estimate-note"');
|
||||
});
|
||||
|
||||
it("collapses to the breakdown footer for a degenerate single-instant session", () => {
|
||||
// Every segment stamped on ONE instant — no real timeline, so only the
|
||||
// breakdown footer, no bar.
|
||||
|
||||
@@ -284,4 +284,22 @@ describe("<TrendsRow>", () => {
|
||||
expect(html).toContain("Moderate confidence");
|
||||
expect(html).toContain("Low confidence");
|
||||
});
|
||||
|
||||
// ── v1.18.6 — detail-page cross-links (the Insights→steps gap) ──────
|
||||
it("links each metric card to its detail page", () => {
|
||||
const html = render(<TrendsRow />);
|
||||
// The default BP / weight / mood triple each link to their sub-page.
|
||||
expect(html).toContain('data-slot="trends-row-detail-link"');
|
||||
expect(html).toContain('href="/insights/blood-pressure"');
|
||||
expect(html).toContain('href="/insights/weight"');
|
||||
expect(html).toContain('href="/insights/mood"');
|
||||
});
|
||||
|
||||
it("offers the steps detail link when the briefing flags steps", () => {
|
||||
// Steps has a routed sub-page but the overview never linked to it; a
|
||||
// briefing finding on steps now charts it AND drills into /insights/steps.
|
||||
const html = render(<TrendsRow briefing={briefing(["steps"])} />);
|
||||
expect(html).toMatch(/data-metric="steps"/);
|
||||
expect(html).toContain('href="/insights/steps"');
|
||||
});
|
||||
});
|
||||
@@ -662,13 +662,20 @@ describe("errorCodeToI18nKey", () => {
|
||||
for (const code of [
|
||||
"coach.provider.unavailable",
|
||||
"coach.provider.empty",
|
||||
"coach.provider.none",
|
||||
"coach.stream",
|
||||
]) {
|
||||
expect(errorCodeToI18nKey(code)).toBe("insights.coach.errorProvider");
|
||||
}
|
||||
});
|
||||
|
||||
// v1.18.6 — a "no provider configured anywhere" turn is a setup gap,
|
||||
// not a transient failure, so it carries its own guided-setup copy.
|
||||
it("maps the no-provider case to its dedicated guided-setup copy", () => {
|
||||
expect(errorCodeToI18nKey("coach.provider.none")).toBe(
|
||||
"insights.coach.errorNoProvider",
|
||||
);
|
||||
});
|
||||
|
||||
// v1.4.43 QoL (M6) — `coach.network` no longer collapses to the
|
||||
// generic provider copy; the user needs the actionable offline hint.
|
||||
it("maps coach.network to the dedicated offline key", () => {
|
||||
|
||||
@@ -78,7 +78,18 @@ export function CoachDrawerBody({
|
||||
<h2 className="border-border/70 text-muted-foreground flex h-14 shrink-0 items-center border-b px-4 text-xs font-medium tracking-wide uppercase">
|
||||
{t("insights.coach.historyTitle")}
|
||||
</h2>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">{historyRail}</div>
|
||||
{/* v1.18.6 (CCH-01) — the wrapping `overflow-y-auto` div here
|
||||
was the broken left scrollbar: `<HistoryRail>` is itself a
|
||||
`flex h-full flex-col` with its OWN `overflow-y-auto` list,
|
||||
so this nested it inside a second scroll container. The
|
||||
outer container scrolled the whole rail (search field
|
||||
included) against an `h-full` child whose height resolved to
|
||||
its content, leaving the inner list's `flex-1 min-h-0`
|
||||
without a bounded track — the scrollbar rendered against an
|
||||
indeterminate height and read as half-broken. Drop the outer
|
||||
scroll: the rail keeps a fixed search header and its single
|
||||
internal list-scroll is the only scroll region. */}
|
||||
<div className="flex min-h-0 flex-1 flex-col">{historyRail}</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -168,9 +169,14 @@ export function errorCodeToI18nKey(code: string): string {
|
||||
// (auth-class failure). The next action is "reconnect", not "try
|
||||
// again later", so it carries its own copy pointing at Settings.
|
||||
return "insights.coach.errorCredentialExpired";
|
||||
case "coach.provider.none":
|
||||
// v1.18.6 — no AI provider is configured anywhere, so "try again
|
||||
// in a moment" is the wrong instruction (it will 422 forever). Send
|
||||
// the user to guided setup with a one-line explainer + a Settings
|
||||
// link rendered alongside the bubble copy.
|
||||
return "insights.coach.errorNoProvider";
|
||||
case "coach.provider.unavailable":
|
||||
case "coach.provider.empty":
|
||||
case "coach.provider.none":
|
||||
case "coach.stream":
|
||||
return "insights.coach.errorProvider";
|
||||
default:
|
||||
@@ -357,6 +363,13 @@ export function MessageThread({
|
||||
data-slot="coach-message-thread"
|
||||
className={cn(
|
||||
"flex h-full flex-col gap-4 overflow-y-auto px-4 py-4 sm:px-6",
|
||||
// v1.18.6 (CCH-01) — on the now full-width page surface a thread
|
||||
// stretched edge-to-edge sprawled the prose to an unreadable
|
||||
// measure. Centre the content on a comfortable max width (Claude-
|
||||
// like) via an `mx-auto` inner gutter; the scrollbar still rides
|
||||
// the surface edge. The drawer surface is already narrow, so the
|
||||
// cap only bites on the wide page surface.
|
||||
"[&>*]:mx-auto [&>*]:w-full [&>*]:max-w-3xl",
|
||||
"scroll-smooth",
|
||||
)}
|
||||
>
|
||||
@@ -613,6 +626,19 @@ function ChatBubble({
|
||||
{safeError && content && (
|
||||
<p className="text-warning/90 text-xs">{safeError}</p>
|
||||
)}
|
||||
{/* v1.18.6 — a "no provider configured anywhere" turn is a
|
||||
setup gap, not a transient failure: surface a direct link to
|
||||
Settings → AI so the Coach guides the user into BYOK / local
|
||||
setup instead of inviting an endless retry. */}
|
||||
{errorCode === "coach.provider.none" && (
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
data-slot="coach-no-provider-cta"
|
||||
className="text-primary text-xs font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("insights.coach.errorNoProviderAction")}
|
||||
</Link>
|
||||
)}
|
||||
{/* v1.12.0 — collapse the whole provenance block ("what was
|
||||
included") behind one disclosure, collapsed by default. The
|
||||
source chips used to render fully expanded above the
|
||||
|
||||
@@ -63,7 +63,7 @@ export function GlucoseAdvancedDisclosure({
|
||||
aria-expanded={open}
|
||||
aria-controls={regionId}
|
||||
data-slot="glucose-advanced-toggle"
|
||||
className="text-muted-foreground hover:text-foreground flex min-h-11 w-full items-center justify-between gap-2 text-sm font-medium transition-colors"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 flex min-h-11 w-full items-center justify-between gap-2 rounded-sm text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<span>{t("insights.bloodGlucose.clinical.advanced.toggle")}</span>
|
||||
<ChevronDown
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Activity, Droplet, TrendingUp } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Activity, Droplet, FlaskConical, TrendingUp } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -113,6 +114,9 @@ export function GlucoseClinicalPanel() {
|
||||
}
|
||||
caveat={t("insights.bloodGlucose.clinical.spotCaveat")}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<LabsCrossLink />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -225,11 +229,34 @@ export function GlucoseClinicalPanel() {
|
||||
byContext={byContext}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<LabsCrossLink />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.6 — signpost the measurement-vs-lab mental model. This panel
|
||||
* estimates A1c / GMI from spot or CGM readings over time; a lab-ordered
|
||||
* HbA1c or fasting-glucose result is a dated panel value that lives under
|
||||
* Labs. The cross-link keeps the two surfaces referencing each other so a
|
||||
* user knows which door to use rather than treating them as rivals.
|
||||
*/
|
||||
function LabsCrossLink() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<Link
|
||||
href="/labs"
|
||||
data-slot="glucose-labs-link"
|
||||
className="text-muted-foreground hover:text-foreground border-border/60 flex items-start gap-1.5 border-t pt-3 text-xs leading-relaxed underline-offset-4 hover:underline"
|
||||
>
|
||||
<FlaskConical className="mt-0.5 size-3 shrink-0" aria-hidden="true" />
|
||||
<span>{t("insights.bloodGlucose.clinical.labsLink")}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({
|
||||
icon,
|
||||
label,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { ArrowDown, ArrowUp, ChevronDown, Minus } from "lucide-react";
|
||||
import { ArrowDown, ArrowUp, ChevronDown, Minus, Moon } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HealthScoreDeltaExplainer } from "./health-score-delta-explainer";
|
||||
@@ -26,8 +26,8 @@ import { HealthScoreDeltaExplainer } from "./health-score-delta-explainer";
|
||||
* at least one component blends entries from more than one source.
|
||||
*
|
||||
* Pure presentational — the parent owns the analytics query + the
|
||||
* drawer state. The same `onAskCoach` handler is shared with the hero
|
||||
* strip's main "Ask the coach" button (different prefill string).
|
||||
* drawer state. The card mounts no inline Coach affordance; the hero
|
||||
* strip carries the single "Ask the coach" entry point.
|
||||
*/
|
||||
|
||||
export type HealthScoreBand = "green" | "yellow" | "red";
|
||||
@@ -70,7 +70,6 @@ export interface HealthScoreCardProps {
|
||||
compliance: HealthScoreCardComponent;
|
||||
};
|
||||
delta: number | null;
|
||||
onAskCoach?: (prefill: string) => void;
|
||||
/**
|
||||
* v1.4.25 W8e — opens the provenance accordion on first render.
|
||||
* The SSR tests use it to exercise the expanded markup without a
|
||||
@@ -87,6 +86,15 @@ export interface HealthScoreCardProps {
|
||||
* four-row layout for the common (mood-enabled) case.
|
||||
*/
|
||||
moodEnabled?: boolean;
|
||||
/**
|
||||
* v1.18.6 — Rest Mode annotation. When an illness episode is active the
|
||||
* server suppresses (never penalises) the score; the card itself then
|
||||
* carries an explicit "paused during illness — not being judged today"
|
||||
* line so a frozen/held number reads as intentional rather than as a
|
||||
* silent drop. Value-free: the card only needs to know that it is
|
||||
* active, not the episode details. Default off (the common case).
|
||||
*/
|
||||
restModeActive?: boolean;
|
||||
}
|
||||
|
||||
type ComponentKey = keyof HealthScoreCardProps["components"];
|
||||
@@ -166,14 +174,9 @@ export function HealthScoreCard({
|
||||
band,
|
||||
components,
|
||||
delta,
|
||||
// v1.4.27 B1 — onAskCoach stays on the prop signature so the hero
|
||||
// strip's existing wire-up doesn't break, but the card no longer
|
||||
// mounts an inline button (the hero strip already carries the same
|
||||
// action). Destructure-and-ignore is intentional.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
onAskCoach: _onAskCoach,
|
||||
initiallyExpanded = false,
|
||||
moodEnabled = true,
|
||||
restModeActive = false,
|
||||
}: HealthScoreCardProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
// The asOf timestamps render under the source pill as a tooltip
|
||||
@@ -404,6 +407,21 @@ export function HealthScoreCard({
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* v1.18.6 — Rest Mode legibility. While an illness episode is
|
||||
active the server suppresses (never penalises) the score, so
|
||||
the card states plainly that the number is paused and not being
|
||||
judged today. Without this the held score reads as a silent
|
||||
decline. */}
|
||||
{restModeActive && (
|
||||
<p
|
||||
data-slot="health-score-card-rest-mode"
|
||||
className="text-muted-foreground inline-flex items-start gap-1.5 text-[11px] leading-relaxed"
|
||||
>
|
||||
<Moon className="mt-0.5 size-3 shrink-0" aria-hidden="true" />
|
||||
<span>{t("insights.healthScore.restModePaused")}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul
|
||||
data-slot="health-score-card-components"
|
||||
// v1.8.5 W4a — the always-visible "Zusammensetzung" breakdown is
|
||||
@@ -602,25 +620,9 @@ export function HealthScoreCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* v1.4.27 B1 — disclaimer bumped from text-[10px] to
|
||||
text-[11px] (BL-P4-9 L2: the 10 px size was borderline
|
||||
against the 12 px mobile floor). The hero strip already
|
||||
carries an "Ask the coach" action so the inline button
|
||||
here retires; the parent still receives onAskCoach via
|
||||
other surfaces but the card no longer mounts a duplicate
|
||||
CTA.
|
||||
v1.4.28 R3c-Insights — `mt-auto` pins the disclaimer to
|
||||
the bottom of the stretched card so the score number and
|
||||
sub-bars stay anchored at the top while the recovered
|
||||
vertical space (when the parent column is taller) sits
|
||||
quietly between the provenance accordion and the
|
||||
disclaimer footer. */}
|
||||
<p
|
||||
data-slot="health-score-card-disclaimer"
|
||||
className="text-muted-foreground mt-auto text-[11px] leading-snug"
|
||||
>
|
||||
{t("insights.healthScore.disclaimer")}
|
||||
</p>
|
||||
{/* v1.18.6 (DISC-01) — the "indicative, not a clinical assessment"
|
||||
card disclaimer is removed; the one-time onboarding acknowledgment
|
||||
now covers the not-a-diagnosis framing app-wide. */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -90,6 +90,12 @@ interface HeroStripProps {
|
||||
band: HealthScoreCardProps["band"];
|
||||
components: HealthScoreCardProps["components"];
|
||||
delta: HealthScoreCardProps["delta"];
|
||||
/**
|
||||
* v1.18.6 — value-free Rest Mode annotation off the analytics
|
||||
* payload. When present + active the score card states it is paused
|
||||
* during illness (suppressed, not penalised).
|
||||
*/
|
||||
restMode?: { active: boolean } | null;
|
||||
} | null;
|
||||
/**
|
||||
* v1.16.8 — true while the analytics payload that carries
|
||||
@@ -346,16 +352,7 @@ export function HeroStrip({
|
||||
components={healthScore.components}
|
||||
delta={healthScore.delta}
|
||||
moodEnabled={moodEnabled}
|
||||
onAskCoach={
|
||||
// v1.4.37 W5 — short-circuit the prop drilling even
|
||||
// though `HealthScoreCard` retired its inline button in
|
||||
// v1.4.27. A future re-addition can't accidentally
|
||||
// surface a Coach affordance from this card while the
|
||||
// operator has the flag off.
|
||||
coachEnabled && onAskCoach
|
||||
? (prefill: string) => onAskCoach(prefill)
|
||||
: undefined
|
||||
}
|
||||
restModeActive={healthScore.restMode?.active ?? false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -144,14 +145,21 @@ export function InsightStatusCard({
|
||||
|
||||
if (!hasProvider) {
|
||||
return (
|
||||
<Card className="gap-1.5 py-4 opacity-60 md:py-5">
|
||||
<Card className="gap-1.5 py-4 opacity-80 md:py-5">
|
||||
<CardHeader className="pb-1">
|
||||
<TileHeader icon={nodeIcon(icon)} title={title} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="flex flex-col gap-1.5">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.noProviderConfigured")}
|
||||
</p>
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
data-slot="insight-status-no-provider-cta"
|
||||
className="text-primary text-sm font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("insights.noProviderAction")}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
@@ -52,7 +51,6 @@ interface ComprehensivePayload {
|
||||
}
|
||||
|
||||
export function InsightsLayoutShell({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
// v1.4.33 F18 — gate the advisor POST on the operator's assistant
|
||||
// feature flag. Pre-fix, every /insights mount fired POST
|
||||
@@ -160,24 +158,11 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
|
||||
modules={user?.modules}
|
||||
/>
|
||||
{children}
|
||||
{/* v1.12.6 — SINGLE SOURCE of the generic Insights disclaimer.
|
||||
The "describes your own data, not a clinical assessment / diagnosis"
|
||||
sentence used to repeat across the overview, the metric tiles, the
|
||||
mood page, and the medications page. It now lives here once, as a
|
||||
page-level footer under every `/insights/*` route. Metric-SPECIFIC
|
||||
caveats (health score, correlations, mood relations, derived cards,
|
||||
rhythm events) stay on their own cards — those convey real,
|
||||
non-generic information. Other waves remove the generic line from the
|
||||
overview / mood / medications pages; this footer is where it lives.
|
||||
v1.18.0 — Coach moved to the standalone `/coach` route (its own
|
||||
disclaimer), so this footer now renders under every `/insights/*`
|
||||
route unconditionally. */}
|
||||
<p
|
||||
data-slot="insights-disclaimer-footer"
|
||||
className="text-muted-foreground border-border/60 border-t pt-4 text-center text-xs leading-relaxed"
|
||||
>
|
||||
{t("insights.disclaimer.footer")}
|
||||
</p>
|
||||
{/* v1.18.6 (DISC-01) — the page-level generic medical disclaimer is
|
||||
removed. The "HealthLog is a private tracking tool, not a clinical
|
||||
assessment or diagnosis" acknowledgment now lives once at the start
|
||||
of onboarding, and the legal text stays reachable on the public
|
||||
privacy page. */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import {
|
||||
@@ -656,6 +657,7 @@ function InsightsTabStripImpl({
|
||||
return (
|
||||
<nav
|
||||
data-slot="insights-tab-strip"
|
||||
data-tour-id="insights-hero"
|
||||
aria-label={t("insights.navAriaLabel")}
|
||||
// v1.4.28 FB-D3 — `touch-action: pan-y` lets vertical swipes that
|
||||
// begin on the sticky strip scroll the page through it. The
|
||||
@@ -858,6 +860,10 @@ function InsightsTabStripImpl({
|
||||
"from-background/95 pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l to-transparent sm:hidden",
|
||||
)}
|
||||
/>
|
||||
{/* v1.18.6 — per-module tour re-entry, travelling with the
|
||||
top-right control cluster. Anchors to this strip's
|
||||
`insights-hero` and opens the Insights tour card on the spot. */}
|
||||
<ModuleTourTrigger stopId="insights" />
|
||||
{/* v1.15.18 — customise cog. Sits immediately LEFT of the
|
||||
regenerate button and links to the Insights settings section
|
||||
(overview arrange + pill sort), matching the Dashboard cog
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -13,18 +13,26 @@ import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useDisableCoach } from "@/hooks/use-disable-coach";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
|
||||
/**
|
||||
* v1.16.8 — the Coach FAB is a permanent launcher again, now on EVERY
|
||||
* authenticated page (mounted once in `<AuthShell>`, no longer scoped
|
||||
* to `/insights/**`). The v1.16.1 nudge-only bubble folded into it: the
|
||||
* FAB always renders, and an unseen proactive `COACH_NUDGE` (surfaced
|
||||
* FAB always renders, and an unread proactive Coach message (surfaced
|
||||
* by `/api/insights/coach/nudge-status`) paints a small unread dot on
|
||||
* its corner instead of toggling the whole button. The dot clears once
|
||||
* the nudge counts as read — the user sent a Coach message after the
|
||||
* nudge (server-derived), or opened the Coach on this device (local
|
||||
* seen stamp keyed by the nudge timestamp).
|
||||
* its corner instead of toggling the whole button.
|
||||
*
|
||||
* v1.18.6 (CCH-02/CCH-03) — the proactive nudge now lands as a real
|
||||
* ASSISTANT message in the conversation rail, not a notification-only
|
||||
* dispatch, so the unread signal moved onto the server-authoritative
|
||||
* `User.coachLastSeenAt` stamp: `status.unread` is true while a Coach
|
||||
* message is newer than the last time the user opened the Coach. Opening
|
||||
* the Coach (this FAB, or the `/coach` page) fires
|
||||
* `POST /api/insights/coach/seen`, which stamps the server and clears
|
||||
* the dot everywhere (web + iOS). A local "seen" stamp is kept only as
|
||||
* an instant-paint optimisation so the dot disappears the moment the
|
||||
* user taps, before the mutation round-trips.
|
||||
*
|
||||
* The FAB hides inside the Coach view itself (`/coach`) — a
|
||||
* launcher pointing at the page the user is already on is noise.
|
||||
@@ -44,9 +52,12 @@ export interface CoachNudgeStatus {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure unread derivation — server signal AND-ed with the local
|
||||
* seen-stamp. Exported so the unit test pins the contract without a
|
||||
* QueryClient round-trip.
|
||||
* Pure unread derivation — the server signal is authoritative
|
||||
* (`status.unread` already compares the newest Coach message against the
|
||||
* persisted `coachLastSeenAt`). The local seen-stamp only suppresses the
|
||||
* dot for an instant after the user taps, before the mark-seen mutation
|
||||
* + status refetch land. Exported so the unit test pins the contract
|
||||
* without a QueryClient round-trip.
|
||||
*/
|
||||
export function isNudgeUnread(
|
||||
status: CoachNudgeStatus | undefined,
|
||||
@@ -77,6 +88,7 @@ export function LayoutCoachFab() {
|
||||
|
||||
const coachAvailable = !!launch && flags.coach && !disableCoach;
|
||||
const onCoachPage = pathname?.startsWith("/coach") ?? false;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: status } = useQuery({
|
||||
queryKey: queryKeys.coachNudgeStatus(),
|
||||
@@ -87,6 +99,22 @@ export function LayoutCoachFab() {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// v1.18.6 (CCH-03) — stamp `coachLastSeenAt` server-side when the user
|
||||
// opens the Coach so the dot clears across every device. Fire-and-
|
||||
// forget from the UI's perspective (the local seen-stamp already
|
||||
// suppressed the dot); on success the status query refetches so the
|
||||
// server-authoritative `unread` re-resolves to false.
|
||||
const markSeen = useMutation({
|
||||
mutationKey: queryKeys.coachMarkSeen(),
|
||||
mutationFn: async () =>
|
||||
apiPost<{ seenAt: string }>("/api/insights/coach/seen", {}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.coachNudgeStatus(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const nudgedAt = status?.nudgedAt ?? null;
|
||||
const unread = isNudgeUnread(status, seenStamp);
|
||||
|
||||
@@ -107,16 +135,30 @@ export function LayoutCoachFab() {
|
||||
prevUnreadRef.current = unread;
|
||||
}, [unread, t]);
|
||||
|
||||
// Visiting the Coach page itself counts as reading the nudge on this
|
||||
// device — persist the stamp so the dot stays gone after leaving.
|
||||
// Visiting the Coach page itself counts as reading the nudge — persist
|
||||
// the local instant-paint stamp AND stamp the server so the dot stays
|
||||
// gone after leaving, on every device. Fired once per page entry: the
|
||||
// ref guards against the effect re-running on unrelated re-renders.
|
||||
const seenStampedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!onCoachPage || nudgedAt === null) return;
|
||||
try {
|
||||
window.localStorage.setItem(NUDGE_SEEN_STORAGE_KEY, nudgedAt);
|
||||
} catch {
|
||||
// Storage unavailable (private mode) — the server-side read
|
||||
// signal still clears the dot once the user sends a message.
|
||||
if (!onCoachPage) {
|
||||
seenStampedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (seenStampedRef.current) return;
|
||||
seenStampedRef.current = true;
|
||||
if (nudgedAt !== null) {
|
||||
try {
|
||||
window.localStorage.setItem(NUDGE_SEEN_STORAGE_KEY, nudgedAt);
|
||||
} catch {
|
||||
// Storage unavailable (private mode) — the server stamp below
|
||||
// still clears the dot once it round-trips.
|
||||
}
|
||||
}
|
||||
markSeen.mutate();
|
||||
// `markSeen` is a stable mutation object; excluding it keeps the
|
||||
// effect from re-firing on its internal state changes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [onCoachPage, nudgedAt]);
|
||||
|
||||
if (!coachAvailable) return null;
|
||||
@@ -136,6 +178,10 @@ export function LayoutCoachFab() {
|
||||
// Best effort — see above.
|
||||
}
|
||||
}
|
||||
// v1.18.6 (CCH-03) — stamp the server so the dot clears on every
|
||||
// device, not just this one. The local stamp above already hid the
|
||||
// dot for an instant paint; the mutation makes it durable.
|
||||
if (unread) markSeen.mutate();
|
||||
// v1.16.11 — open the side drawer in place (the launch context the
|
||||
// layout mount consumes) instead of navigating to the chat page;
|
||||
// the conversation arrives without losing the page underneath.
|
||||
@@ -194,10 +240,15 @@ export function LayoutCoachFab() {
|
||||
>
|
||||
<Sparkles className="size-6" aria-hidden="true" />
|
||||
{unread ? (
|
||||
// v1.18.6 (CCH-03) — discreet "the Coach said something" dot.
|
||||
// Deliberately NOT an alarming red (the medication-card rule:
|
||||
// status via calm signal, never an alarm tint): a small cyan
|
||||
// dot ringed against the FAB background reads as informative,
|
||||
// not urgent.
|
||||
<span
|
||||
data-slot="coach-fab-unread"
|
||||
aria-hidden="true"
|
||||
className="border-background bg-dracula-red absolute top-0.5 right-0.5 size-3 rounded-full border-2"
|
||||
className="border-background bg-dracula-cyan absolute top-0.5 right-0.5 size-3 rounded-full border-2"
|
||||
/>
|
||||
) : null}
|
||||
</Button>
|
||||
|
||||
@@ -84,12 +84,9 @@ export function MetricCorrelationCard({ slug }: MetricCorrelationCardProps) {
|
||||
{t("insights.correlationRow.title")}
|
||||
</h2>
|
||||
<CorrelationCard result={result} />
|
||||
<p
|
||||
data-slot="metric-correlation-disclaimer"
|
||||
className="text-muted-foreground text-xs italic"
|
||||
>
|
||||
{t("insights.correlationRow.disclaimer")}
|
||||
</p>
|
||||
{/* v1.18.6 (DISC-01) — the "observational, not causal / talk to your
|
||||
doctor" disclaimer is removed; the one-time onboarding acknowledgment
|
||||
now covers the not-medical-advice framing app-wide. */}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -77,12 +77,12 @@ export function RecoveryMetricBlock({
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* v1.18.1 — block title is an `<h3>` (kept at `text-base`): it
|
||||
nests under the group `SectionHeading` `<h2>`, which itself
|
||||
sits under the page `<h1>` from `SubPageShell`. Demoting it
|
||||
keeps the recovery page's heading outline strictly nested
|
||||
(h1 → h2 → h3) instead of stacking two sibling `<h2>`s. */}
|
||||
<h3 className="text-base font-semibold">{title}</h3>
|
||||
{/* v1.18.6 — block title is an `<h2>` (at `text-base`): the per-group
|
||||
sub-headings were removed, so each block now sits directly under
|
||||
the page `<h1>` from `SubPageShell`. Promoting it from `<h3>` keeps
|
||||
the recovery page's heading outline strictly nested (h1 → h2)
|
||||
with no skipped level. */}
|
||||
<h2 className="text-base font-semibold">{title}</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{explainer}
|
||||
|
||||
@@ -14,7 +14,6 @@ import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { RestModeBanner } from "@/components/insights/rest-mode-banner";
|
||||
import type { RestModeAnnotation } from "@/lib/analytics/health-score";
|
||||
@@ -36,9 +35,6 @@ interface BlockSpec {
|
||||
|
||||
interface BlockGroup {
|
||||
key: string;
|
||||
headingIcon: typeof Activity;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
blocks: BlockSpec[];
|
||||
}
|
||||
|
||||
@@ -84,9 +80,6 @@ export function RecoverySection() {
|
||||
const groups: BlockGroup[] = [
|
||||
{
|
||||
key: "recharge",
|
||||
headingIcon: Battery,
|
||||
title: t("insights.recovery.recharge.title"),
|
||||
subtitle: t("insights.recovery.recharge.subtitle"),
|
||||
blocks: [
|
||||
{
|
||||
type: "ANS_CHARGE",
|
||||
@@ -103,9 +96,6 @@ export function RecoverySection() {
|
||||
},
|
||||
{
|
||||
key: "strain",
|
||||
headingIcon: Gauge,
|
||||
title: t("insights.recovery.strain.title"),
|
||||
subtitle: t("insights.recovery.strain.subtitle"),
|
||||
blocks: [
|
||||
{
|
||||
type: "DAY_STRAIN",
|
||||
@@ -143,9 +133,6 @@ export function RecoverySection() {
|
||||
},
|
||||
{
|
||||
key: "cardio",
|
||||
headingIcon: HeartPulse,
|
||||
title: t("insights.recovery.cardio.title"),
|
||||
subtitle: t("insights.recovery.cardio.subtitle"),
|
||||
blocks: [
|
||||
{
|
||||
type: "AVERAGE_HEART_RATE",
|
||||
@@ -190,10 +177,6 @@ export function RecoverySection() {
|
||||
if (isLoading || summaries == null) {
|
||||
return (
|
||||
<div className="space-y-3" data-slot="recovery-loading">
|
||||
<SectionHeading
|
||||
icon={Battery}
|
||||
title={t("insights.recovery.recharge.title")}
|
||||
/>
|
||||
<ChartSkeleton />
|
||||
</div>
|
||||
);
|
||||
@@ -210,51 +193,36 @@ export function RecoverySection() {
|
||||
);
|
||||
}
|
||||
|
||||
// v1.18.6 — the per-group sub-headings ("Recharge" / "Strain & load" /
|
||||
// "Daily heart & energy") are gone: read next to the page's "Erholung"
|
||||
// heading they were redundant chrome. The groups still drive ORDER + the
|
||||
// data gate (a group with no present block contributes nothing); their
|
||||
// present blocks now render as one flat, evenly-spaced stack, each block
|
||||
// carrying its own title + explainer so nothing loses context.
|
||||
const presentBlocks = groups.flatMap((group) =>
|
||||
group.blocks.filter((block) => (summaries[block.type]?.count ?? 0) > 0),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8" data-slot="recovery-section">
|
||||
{/* v1.18.1 — calm Rest Mode cue at the head of the recovery surface.
|
||||
Self-gating: renders nothing unless an episode is active. */}
|
||||
<RestModeBanner annotation={restMode} />
|
||||
{groups.map((group) => {
|
||||
const present = group.blocks.filter(
|
||||
(block) => (summaries[block.type]?.count ?? 0) > 0,
|
||||
);
|
||||
if (present.length === 0) return null;
|
||||
return (
|
||||
<section
|
||||
key={group.key}
|
||||
data-slot={`recovery-group-${group.key}`}
|
||||
className="space-y-4"
|
||||
>
|
||||
<SectionHeading
|
||||
icon={group.headingIcon}
|
||||
title={group.title}
|
||||
action={
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{group.subtitle}
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
<div className="space-y-8">
|
||||
{present.map((block) => (
|
||||
<RecoveryMetricBlock
|
||||
key={block.type}
|
||||
type={block.type}
|
||||
chartKey={block.chartKey}
|
||||
statusMetric={block.statusMetric}
|
||||
title={block.title}
|
||||
explainer={block.explainer}
|
||||
icon={block.icon}
|
||||
color={block.color}
|
||||
unit={block.unit}
|
||||
summary={summaries[block.type] ?? null}
|
||||
fractionDigits={block.fractionDigits}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
{presentBlocks.map((block) => (
|
||||
<RecoveryMetricBlock
|
||||
key={block.type}
|
||||
type={block.type}
|
||||
chartKey={block.chartKey}
|
||||
statusMetric={block.statusMetric}
|
||||
title={block.title}
|
||||
explainer={block.explainer}
|
||||
icon={block.icon}
|
||||
color={block.color}
|
||||
unit={block.unit}
|
||||
summary={summaries[block.type] ?? null}
|
||||
fractionDigits={block.fractionDigits}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,6 +58,17 @@ export interface SleepHypnogramSession {
|
||||
awakenings: number;
|
||||
stages: Record<string, number>;
|
||||
segments: SleepHypnogramSegment[];
|
||||
/**
|
||||
* `true` when the winning source has no measured per-stage onset
|
||||
* timestamps and the server synthesised a contiguous timeline in a fixed
|
||||
* physiological order (WHOOP / Polar). The "timeline" of such a night is
|
||||
* not a real hypnogram — it would read as one long Core block followed by
|
||||
* one Deep block, never the phase-to-phase progression a measured night
|
||||
* shows — so we never draw the misleading stepped bar for it and fall back
|
||||
* to the per-stage breakdown alone. A real-series source (Apple Health,
|
||||
* Withings, Fitbit) is `false` and keeps the timeline.
|
||||
*/
|
||||
reconstructed?: boolean;
|
||||
}
|
||||
|
||||
export interface SleepHypnogramProps {
|
||||
@@ -216,10 +227,16 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
|
||||
// breakdown footer. (Gating on the END instant instead hid the bar for an
|
||||
// ordinary multi-stage night whose segments happened to share a right
|
||||
// edge — the regression this restores.)
|
||||
// A SYNTHESISED night (WHOOP / Polar) has no measured stage onsets — the
|
||||
// server laid its segments out in a fixed physiological order, so drawing
|
||||
// the stepped bar would invent a phase progression that was never recorded
|
||||
// (the "Core block then Deep block, no real breakdown" the bar reads as).
|
||||
// For those we keep the per-stage breakdown + totals, which ARE real, and
|
||||
// drop the misleading timeline entirely. Real-series sources keep the bar.
|
||||
const hasTrack =
|
||||
session.reconstructed !== true &&
|
||||
spans.length > 0 &&
|
||||
(spans.length === 1 ||
|
||||
new Set(spans.map((s) => s.x1)).size > 1);
|
||||
(spans.length === 1 || new Set(spans.map((s) => s.x1)).size > 1);
|
||||
const hasBreakdown = breakdown.length > 0;
|
||||
|
||||
return (
|
||||
@@ -251,6 +268,18 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
{/* When the source reports stage TOTALS but no measured onsets
|
||||
(WHOOP / Polar) the stepped timeline is suppressed — drawing one
|
||||
would invent a phase order that was never recorded. Surface a calm
|
||||
note so the breakdown below doesn't read as a missing chart. */}
|
||||
{session.reconstructed === true && hasBreakdown ? (
|
||||
<span
|
||||
data-slot="sleep-hypnogram-estimate-note"
|
||||
className="text-muted-foreground/70 text-xs"
|
||||
>
|
||||
{t("insights.sleep.hypnogram.estimateNote")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { buildSleepQualityAssessment } from "../sleep-quality-assessment";
|
||||
|
||||
/**
|
||||
* v1.18.6 — the deterministic sleep-quality grading that backs the grounded
|
||||
* "Einschätzung" when no AI narrative exists. Pins the band thresholds and the
|
||||
* worst-grade-wins overall, and the "skip ungradable metrics" contract.
|
||||
*/
|
||||
describe("buildSleepQualityAssessment", () => {
|
||||
it("grades efficiency on the clinical 90 / 85 / 75 floors", () => {
|
||||
expect(
|
||||
buildSleepQualityAssessment([{ type: "SLEEP_EFFICIENCY", value: 92 }])
|
||||
?.lead.grade,
|
||||
).toBe("excellent");
|
||||
expect(
|
||||
buildSleepQualityAssessment([{ type: "SLEEP_EFFICIENCY", value: 86 }])
|
||||
?.lead.grade,
|
||||
).toBe("good");
|
||||
expect(
|
||||
buildSleepQualityAssessment([{ type: "SLEEP_EFFICIENCY", value: 78 }])
|
||||
?.lead.grade,
|
||||
).toBe("fair");
|
||||
expect(
|
||||
buildSleepQualityAssessment([{ type: "SLEEP_EFFICIENCY", value: 60 }])
|
||||
?.lead.grade,
|
||||
).toBe("low");
|
||||
});
|
||||
|
||||
it("grades 0–100 scores on the 90 / 85 / 70 split", () => {
|
||||
expect(
|
||||
buildSleepQualityAssessment([{ type: "SLEEP_SCORE", value: 95 }])?.lead
|
||||
.grade,
|
||||
).toBe("excellent");
|
||||
expect(
|
||||
buildSleepQualityAssessment([{ type: "SLEEP_SCORE", value: 72 }])?.lead
|
||||
.grade,
|
||||
).toBe("fair");
|
||||
});
|
||||
|
||||
it("opens on the headline score and keeps worst-grade-wins overall", () => {
|
||||
const a = buildSleepQualityAssessment([
|
||||
{ type: "SLEEP_CONSISTENCY", value: 60 },
|
||||
{ type: "SLEEP_SCORE", value: 95 },
|
||||
{ type: "SLEEP_EFFICIENCY", value: 92 },
|
||||
]);
|
||||
expect(a?.lead.type).toBe("SLEEP_SCORE");
|
||||
// The low consistency pulls the overall down even though the score is high.
|
||||
expect(a?.overall).toBe("low");
|
||||
expect(a?.rest.length).toBe(2);
|
||||
});
|
||||
|
||||
it("skips metrics it has no recognised band for, and returns null on none", () => {
|
||||
// SLEEP_NEED (minutes) + disturbance count are not gradable here.
|
||||
expect(
|
||||
buildSleepQualityAssessment([
|
||||
{ type: "SLEEP_NEED", value: 480 },
|
||||
{ type: "SLEEP_DISTURBANCE_COUNT", value: 3 },
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores non-finite values", () => {
|
||||
expect(
|
||||
buildSleepQualityAssessment([{ type: "SLEEP_SCORE", value: Number.NaN }]),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { SleepQualityGroundedNote } from "../sleep-quality-grounded-note";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
|
||||
/**
|
||||
* v1.18.6 — the grounded sleep-quality note that fills the assessment slot
|
||||
* when no AI narrative is available. Pins that it builds substantive copy from
|
||||
* the user's own averages and that it self-suppresses when the AI is present.
|
||||
*/
|
||||
function summary(avg30: number): DataSummary {
|
||||
return {
|
||||
count: 30,
|
||||
latest: avg30,
|
||||
avg7: avg30,
|
||||
avg30,
|
||||
} as unknown as DataSummary;
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<SleepQualityGroundedNote>", () => {
|
||||
const summaries = {
|
||||
SLEEP_SCORE: summary(82),
|
||||
SLEEP_EFFICIENCY: summary(88),
|
||||
};
|
||||
|
||||
it("renders a substantive, grounded assessment when the AI is absent", () => {
|
||||
const html = render(
|
||||
<SleepQualityGroundedNote
|
||||
summaries={summaries}
|
||||
showWhenAiAbsent={true}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="sleep-quality-grounded-note"');
|
||||
// The headline score (82 → fair on the 90/85/70 split) leads the read.
|
||||
expect(html).toContain("overall sleep score");
|
||||
expect(html).toContain("fair");
|
||||
expect(html).toContain("82");
|
||||
});
|
||||
|
||||
it("renders nothing when the AI assessment is present", () => {
|
||||
const html = render(
|
||||
<SleepQualityGroundedNote
|
||||
summaries={summaries}
|
||||
showWhenAiAbsent={false}
|
||||
/>,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing when no quality metric is gradable", () => {
|
||||
const html = render(
|
||||
<SleepQualityGroundedNote
|
||||
summaries={{ SLEEP_NEED: summary(480) }}
|
||||
showWhenAiAbsent={true}
|
||||
/>,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -110,6 +110,14 @@ describe("<ChronotypeCard>", () => {
|
||||
expect(html).toContain("04:30");
|
||||
});
|
||||
|
||||
it("renders the band as a labelled top-right corner readout, not a badge", () => {
|
||||
const html = render(<ChronotypeCard chronotype={READY_CHRONO} />);
|
||||
// The labelled corner carries both the "Chronotype" label and the value.
|
||||
expect(html).toContain('data-slot="chronotype-corner"');
|
||||
expect(html).toContain("Chronotype");
|
||||
expect(html).toContain("Intermediate type");
|
||||
});
|
||||
|
||||
it("inherits the standard Card rhythm (no compact override)", () => {
|
||||
const learning = render(<ChronotypeCard chronotype={LEARNING_CHRONO} />);
|
||||
const ready = render(<ChronotypeCard chronotype={READY_CHRONO} />);
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Clock, ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { LearningGate } from "@/components/ui/learning-gate";
|
||||
import type { ChronotypeDto } from "./use-sleep-rhythm";
|
||||
|
||||
@@ -67,31 +66,44 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="text-info h-4 w-4" />
|
||||
<CardTitle className="text-base font-semibold">
|
||||
{t("insights.sleep.chronotype.title")}
|
||||
</CardTitle>
|
||||
{/* The resolved chronotype rides the top-right corner as a labelled
|
||||
readout — a small "Chronotyp" label over the band value — rather
|
||||
than a badge buried in the body. The title stays left; the value
|
||||
is the thing the eye should land on first. */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="text-info h-4 w-4" />
|
||||
<CardTitle className="text-base font-semibold">
|
||||
{t("insights.sleep.chronotype.title")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-col items-end text-right"
|
||||
data-slot="chronotype-corner"
|
||||
>
|
||||
<span className="text-muted-foreground text-[0.625rem] font-medium tracking-wide uppercase">
|
||||
{t("insights.sleep.chronotype.cornerLabel")}
|
||||
</span>
|
||||
<span className="text-foreground text-sm font-semibold">
|
||||
{bandLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Badge variant="secondary" className="text-sm">
|
||||
{bandLabel}
|
||||
</Badge>
|
||||
{msfClock && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.sleep.chronotype.midSleepCaption", {
|
||||
clock: msfClock,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{msfClock && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.sleep.chronotype.midSleepCaption", {
|
||||
clock: msfClock,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
aria-expanded={showAdvanced}
|
||||
aria-controls="chronotype-advanced"
|
||||
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs font-medium transition-colors"
|
||||
>
|
||||
{showAdvanced ? (
|
||||
@@ -103,7 +115,7 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) {
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<div id="chronotype-advanced" className="space-y-2 border-t pt-3">
|
||||
{chronotype.socialJetlagMinutes != null &&
|
||||
(() => {
|
||||
const { hours, minutes } = hoursMinutes(
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* v1.18.6 — deterministic, data-grounded sleep-quality assessment.
|
||||
*
|
||||
* The AI "Einschätzung" under the sleep-quality block reads "no assessment
|
||||
* yet / unavailable" until a provider has run (and never at all on an account
|
||||
* with no AI provider). That left the most universally useful read — the one
|
||||
* the user's OWN numbers already support — blank. This pure helper turns the
|
||||
* sleep-quality summaries that the section already has in hand into a short,
|
||||
* grounded sentence: it names the night-over-night averages and grades them
|
||||
* against recognised reference ranges, with NO provider call and NO recompute
|
||||
* of a stored value (it only reads the server-authoritative averages).
|
||||
*
|
||||
* Grading thresholds are the conventional consumer-sleep-platform bands:
|
||||
* - Sleep efficiency: ≥ 85 % is the textbook "good sleeper" floor (AASM);
|
||||
* ≥ 90 % is excellent, < 75 % is poor.
|
||||
* - Performance / consistency / headline score: 0–100, graded on the
|
||||
* standard 85 / 70 split most rings use (good / fair / low).
|
||||
* The copy is intentionally cautious — it describes, it never diagnoses.
|
||||
*
|
||||
* Kept free of React + i18n so the grading is unit-testable in isolation; the
|
||||
* component maps the returned shape onto localised strings.
|
||||
*/
|
||||
|
||||
export type QualityGrade = "excellent" | "good" | "fair" | "low";
|
||||
|
||||
export interface SleepQualityMetricRead {
|
||||
/** Registry/measurement type, e.g. `SLEEP_EFFICIENCY`. */
|
||||
type: string;
|
||||
/** The averaged value the section already holds (avg30 ?? avg7 ?? latest). */
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface SleepQualityFinding {
|
||||
type: string;
|
||||
value: number;
|
||||
grade: QualityGrade;
|
||||
}
|
||||
|
||||
export interface SleepQualityAssessment {
|
||||
/** The lead metric the assessment opens on (best-known quality signal). */
|
||||
lead: SleepQualityFinding;
|
||||
/** The remaining graded metrics, most-informative first. */
|
||||
rest: SleepQualityFinding[];
|
||||
/** Worst grade across the present metrics — drives the closing nudge. */
|
||||
overall: QualityGrade;
|
||||
}
|
||||
|
||||
/** Percent-scaled 0–100 metrics graded on the 90 / 85 / 70 ring split. */
|
||||
const PERCENT_METRICS = new Set<string>([
|
||||
"SLEEP_SCORE",
|
||||
"SLEEP_PERFORMANCE",
|
||||
"SLEEP_CONSISTENCY",
|
||||
]);
|
||||
|
||||
/** Efficiency uses the clinical 90 / 85 / 75 floors rather than the ring split. */
|
||||
function gradeEfficiency(pct: number): QualityGrade {
|
||||
if (pct >= 90) return "excellent";
|
||||
if (pct >= 85) return "good";
|
||||
if (pct >= 75) return "fair";
|
||||
return "low";
|
||||
}
|
||||
|
||||
function gradeScore(value: number): QualityGrade {
|
||||
if (value >= 90) return "excellent";
|
||||
if (value >= 85) return "good";
|
||||
if (value >= 70) return "fair";
|
||||
return "low";
|
||||
}
|
||||
|
||||
/**
|
||||
* Grade one quality metric. Returns null for a metric the helper has no
|
||||
* recognised band for (e.g. raw disturbance counts or sleep-need minutes),
|
||||
* so the assessment never grades a value against a scale it does not fit.
|
||||
*/
|
||||
function gradeMetric(type: string, value: number): QualityGrade | null {
|
||||
if (type === "SLEEP_EFFICIENCY") return gradeEfficiency(value);
|
||||
if (PERCENT_METRICS.has(type)) return gradeScore(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Worst grade wins — a single low signal pulls the overall read down. */
|
||||
const GRADE_RANK: Record<QualityGrade, number> = {
|
||||
excellent: 3,
|
||||
good: 2,
|
||||
fair: 1,
|
||||
low: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Lead-metric preference: the headline score reads first when present, then
|
||||
* efficiency, then performance, then consistency. Keeps the opening sentence
|
||||
* anchored on the most holistic signal the night carries.
|
||||
*/
|
||||
const LEAD_ORDER = [
|
||||
"SLEEP_SCORE",
|
||||
"SLEEP_EFFICIENCY",
|
||||
"SLEEP_PERFORMANCE",
|
||||
"SLEEP_CONSISTENCY",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Build a deterministic assessment from the present quality metrics. Returns
|
||||
* null when none of the supplied metrics is gradable, so the caller falls back
|
||||
* to its existing copy rather than printing an empty read.
|
||||
*/
|
||||
export function buildSleepQualityAssessment(
|
||||
reads: readonly SleepQualityMetricRead[],
|
||||
): SleepQualityAssessment | null {
|
||||
const findings: SleepQualityFinding[] = [];
|
||||
for (const r of reads) {
|
||||
if (!Number.isFinite(r.value)) continue;
|
||||
const grade = gradeMetric(r.type, r.value);
|
||||
if (grade == null) continue;
|
||||
findings.push({ type: r.type, value: r.value, grade });
|
||||
}
|
||||
if (findings.length === 0) return null;
|
||||
|
||||
const leadIndex = (type: string): number => {
|
||||
const i = LEAD_ORDER.indexOf(type as (typeof LEAD_ORDER)[number]);
|
||||
return i === -1 ? Number.MAX_SAFE_INTEGER : i;
|
||||
};
|
||||
const sorted = [...findings].sort(
|
||||
(a, b) => leadIndex(a.type) - leadIndex(b.type),
|
||||
);
|
||||
|
||||
const overall = sorted.reduce<QualityGrade>(
|
||||
(worst, f) => (GRADE_RANK[f.grade] < GRADE_RANK[worst] ? f.grade : worst),
|
||||
"excellent",
|
||||
);
|
||||
|
||||
return {
|
||||
lead: sorted[0],
|
||||
rest: sorted.slice(1),
|
||||
overall,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { Moon } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import {
|
||||
buildSleepQualityAssessment,
|
||||
type QualityGrade,
|
||||
type SleepQualityFinding,
|
||||
} from "./sleep-quality-assessment";
|
||||
|
||||
/**
|
||||
* v1.18.6 — the grounded sleep-quality "Einschätzung".
|
||||
*
|
||||
* The AI assessment under the sleep-quality block reads "no assessment yet"
|
||||
* until a provider has run, and stays blank forever on an account with no AI
|
||||
* provider. This card fills that gap with a substantive read built ENTIRELY
|
||||
* from the user's own quality averages (efficiency / performance / consistency
|
||||
* / headline score) graded against recognised reference bands — no provider,
|
||||
* no recompute of a stored value. It describes; it does not diagnose.
|
||||
*
|
||||
* Mounted only when the AI assessment is NOT present (`showWhenAiAbsent`), so
|
||||
* the richer AI narrative still wins when it exists and the user never sees
|
||||
* two assessments stacked.
|
||||
*/
|
||||
|
||||
const QUALITY_TYPES = [
|
||||
"SLEEP_SCORE",
|
||||
"SLEEP_EFFICIENCY",
|
||||
"SLEEP_PERFORMANCE",
|
||||
"SLEEP_CONSISTENCY",
|
||||
] as const;
|
||||
|
||||
/** Map a finding to its localised "{metric} is {grade} at {value}" clause. */
|
||||
function clauseFor(
|
||||
finding: SleepQualityFinding,
|
||||
t: (key: string, vars?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
const metricLabel = t(`insights.sleepQuality.assessment.metric.${finding.type}`);
|
||||
const gradeLabel = t(`insights.sleepQuality.assessment.grade.${finding.grade}`);
|
||||
// Efficiency / performance / consistency are percentages; the headline score
|
||||
// is a 0–100 index. Both read naturally as a rounded integer.
|
||||
const value = Math.round(finding.value);
|
||||
return t("insights.sleepQuality.assessment.clause", {
|
||||
metric: metricLabel,
|
||||
grade: gradeLabel,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
export interface SleepQualityGroundedNoteProps {
|
||||
/** The analytics summaries the section already holds (no extra fetch). */
|
||||
summaries: Record<string, DataSummary | undefined> | undefined;
|
||||
/**
|
||||
* Whether the AI assessment is absent. The grounded note only renders when
|
||||
* the AI narrative is not there, so the two never stack.
|
||||
*/
|
||||
showWhenAiAbsent: boolean;
|
||||
}
|
||||
|
||||
export function SleepQualityGroundedNote({
|
||||
summaries,
|
||||
showWhenAiAbsent,
|
||||
}: SleepQualityGroundedNoteProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
if (!showWhenAiAbsent || !summaries) return null;
|
||||
|
||||
const reads = QUALITY_TYPES.map((type) => {
|
||||
const s = summaries[type];
|
||||
const value = s?.avg30 ?? s?.avg7 ?? s?.latest ?? null;
|
||||
return value == null
|
||||
? null
|
||||
: ({ type, value } as { type: string; value: number });
|
||||
}).filter((r): r is { type: string; value: number } => r !== null);
|
||||
|
||||
const assessment = buildSleepQualityAssessment(reads);
|
||||
if (!assessment) return null;
|
||||
|
||||
const clauses = [assessment.lead, ...assessment.rest].map((f) =>
|
||||
clauseFor(f, t),
|
||||
);
|
||||
const closing = t(
|
||||
`insights.sleepQuality.assessment.closing.${assessment.overall as QualityGrade}`,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
data-slot="sleep-quality-grounded-note"
|
||||
className="gap-1.5 py-4 md:py-5"
|
||||
>
|
||||
<CardHeader className="pb-1">
|
||||
<TileHeader
|
||||
icon={QualityNoteIcon}
|
||||
title={t("insights.assessmentTitle")}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{clauses.join(" ")} {closing}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function QualityNoteIcon({ className }: { className?: string }) {
|
||||
return <Moon className={className} aria-hidden="true" />;
|
||||
}
|
||||
@@ -3,11 +3,14 @@
|
||||
import { Activity, Gauge, Moon, Repeat, Target, Waves } from "lucide-react";
|
||||
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useInsightMetricStatus } from "@/hooks/use-insight-status";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { DeviceScoreTile } from "@/components/insights/device-score-tile";
|
||||
import { DeviceScoreGridSkeleton } from "@/components/insights/device-score-tile-skeleton";
|
||||
import { MetricStatusCard } from "@/components/insights/metric-status-card";
|
||||
import { SleepQualityGroundedNote } from "@/components/insights/sleep/sleep-quality-grounded-note";
|
||||
|
||||
/**
|
||||
* v1.17.1 — "Sleep quality" block on `/insights/sleep`.
|
||||
@@ -101,13 +104,6 @@ export function SleepQualitySection({ enabled }: { enabled: boolean }) {
|
||||
);
|
||||
if (present.length === 0) return null;
|
||||
|
||||
// v1.18.1 — the quality block is a distinct chart group, so it carries its
|
||||
// OWN assessment (matching the canonical "multiple charts ⇒ multiple texts"
|
||||
// rule the recovery page meets). Gated on the nightly headline sleep score
|
||||
// having data so the generic assessment route only fires when there is a
|
||||
// quality series to read.
|
||||
const hasSleepScore = (summaries.SLEEP_SCORE?.count ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<section data-slot="sleep-quality-section" className="space-y-3">
|
||||
<SectionHeading
|
||||
@@ -132,11 +128,58 @@ export function SleepQualitySection({ enabled }: { enabled: boolean }) {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<MetricStatusCard
|
||||
metric="SLEEP_SCORE"
|
||||
icon={<Moon className="h-5 w-5" />}
|
||||
enabled={hasSleepScore}
|
||||
/>
|
||||
<SleepQualityAssessmentBlock summaries={summaries} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The quality block's "Einschätzung". The richer AI narrative wins when it
|
||||
* exists; until then (or forever, on an account with no AI provider) a
|
||||
* deterministic note grounded in the user's own quality averages fills the
|
||||
* slot, so the assessment is never the bare "no analysis yet" line.
|
||||
*
|
||||
* Both reads are gated on the headline sleep score having data — the same gate
|
||||
* the AI route used — so the assessment only surfaces with a quality series to
|
||||
* describe. The grounded note reads the SLEEP_SCORE status query (already
|
||||
* warmed by `<MetricStatusCard>`) to know whether the AI text is absent, so it
|
||||
* adds no second round-trip and the two never stack.
|
||||
*/
|
||||
function SleepQualityAssessmentBlock({
|
||||
summaries,
|
||||
}: {
|
||||
summaries: Record<string, DataSummary | undefined>;
|
||||
}) {
|
||||
const hasSleepScore = (summaries.SLEEP_SCORE?.count ?? 0) > 0;
|
||||
const { data: status, isLoading: statusLoading } = useInsightMetricStatus(
|
||||
"SLEEP_SCORE",
|
||||
hasSleepScore,
|
||||
);
|
||||
|
||||
// The AI narrative is "present" when there is text OR the worker is actively
|
||||
// assembling one (preparing / revalidating). In those cases the AI card owns
|
||||
// the slot. The grounded note only steps in once the status query has SETTLED
|
||||
// with no narrative — so it never flashes ahead of a warm AI assessment.
|
||||
const aiAbsent =
|
||||
!statusLoading &&
|
||||
!status?.text &&
|
||||
status?.preparing !== true &&
|
||||
status?.revalidating !== true;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MetricStatusCard
|
||||
metric="SLEEP_SCORE"
|
||||
icon={<Moon className="h-5 w-5" />}
|
||||
// Suppress the AI card's static "no analysis yet" / "no provider"
|
||||
// empty states — the grounded note covers that ground with real
|
||||
// content. The AI card still renders its text / preparing states.
|
||||
enabled={hasSleepScore && !aiAbsent}
|
||||
/>
|
||||
<SleepQualityGroundedNote
|
||||
summaries={summaries}
|
||||
showWhenAiAbsent={hasSleepScore && aiAbsent}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,11 +3,16 @@
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ListOrdered } from "lucide-react";
|
||||
import { Info, ListOrdered } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { TargetAdjustButton } from "@/components/insights/target-adjust-button";
|
||||
import { TargetAdjustProvider } from "@/lib/insights/target-adjust-context";
|
||||
import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
|
||||
@@ -102,13 +107,12 @@ export interface SubPageShellProps {
|
||||
/**
|
||||
* v1.8.6 — opt-in Coach launch in the page header.
|
||||
*
|
||||
* When true, the shell mounts an icon-only `<CoachLaunchButton>` at
|
||||
* the top-right of the header, vertically aligned with the heading, so
|
||||
* "Coach fragen" reads as a header action rather than a foot-of-page
|
||||
* button. Every routed category page passes it; the mother page and
|
||||
* empty-state branches omit it. The button self-gates on the Coach
|
||||
* feature flag + per-user opt-out, so a disabled-Coach tenant shows
|
||||
* nothing.
|
||||
* v1.18.6 (CCH-04) — RETIRED as a rendered control. With the Coach FAB
|
||||
* anchored bottom-right on every authenticated page, the per-metric
|
||||
* Coach icon was a redundant second entry point, so the shell no longer
|
||||
* paints anything for it. The prop stays on the signature (every routed
|
||||
* category page still passes it) to avoid a churny rename across ~14
|
||||
* call sites; it is intentionally inert.
|
||||
*/
|
||||
coachLaunch?: boolean;
|
||||
/**
|
||||
@@ -133,7 +137,10 @@ export function SubPageShell({
|
||||
focusOnMount = false,
|
||||
statStrip,
|
||||
diversityNudge,
|
||||
coachLaunch = false,
|
||||
// v1.18.6 (CCH-04) — `coachLaunch` is intentionally not destructured:
|
||||
// the prop is still accepted on the type (call sites pass it) but the
|
||||
// shell no longer renders a Coach control. The FAB is the single Coach
|
||||
// entry point app-wide.
|
||||
showAllValuesType,
|
||||
children,
|
||||
}: SubPageShellProps) {
|
||||
@@ -264,7 +271,12 @@ export function SubPageShell({
|
||||
</Button>
|
||||
) : null}
|
||||
<TargetAdjustButton />
|
||||
{coachLaunch ? <CoachLaunchButton variant="icon" /> : null}
|
||||
{/* v1.18.6 (CCH-04) — the per-metric Coach launch icon is
|
||||
gone. With the Coach FAB anchored bottom-right on every
|
||||
authenticated page, a second Coach affordance in each
|
||||
metric header was redundant. The `coachLaunch` prop is
|
||||
still accepted (every metric page passes it) but no longer
|
||||
paints a control — the FAB is the single Coach entry. */}
|
||||
{/* v1.16.8 — the v1.16.4 customise cog is gone: the sticky
|
||||
tab strip above already carries the same control on every
|
||||
insights surface, so the header copy was a duplicate. */}
|
||||
@@ -291,6 +303,33 @@ export function SubPageShell({
|
||||
className="text-muted-foreground text-sm leading-relaxed"
|
||||
>
|
||||
{t(`insights.subPage.explainer.${explainerMetric}Body`)}
|
||||
{/* v1.18.6 — a discreet general-guidance tooltip travels with
|
||||
every metric definition. The reference bands in the body
|
||||
above are population anchors, not medical advice; this
|
||||
caption pins that framing without crowding the prose. */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="metric-reference-guidance"
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground ml-1.5 inline-flex",
|
||||
"align-middle focus-visible:ring-ring/50 rounded-full",
|
||||
"focus-visible:ring-2 focus-visible:outline-none",
|
||||
)}
|
||||
aria-label={t(
|
||||
"insights.subPage.explainer.referenceGuidance",
|
||||
)}
|
||||
>
|
||||
<Info className="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-pretty">
|
||||
{t("insights.subPage.explainer.referenceGuidance")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</p>
|
||||
) : null}
|
||||
{description && !explainerMetric ? (
|
||||
|
||||
@@ -85,14 +85,14 @@ export function SuggestedPrompts({
|
||||
type="button"
|
||||
onClick={() => onPick(prompt)}
|
||||
data-slot="insights-suggested-prompts-chip"
|
||||
// v1.8.5 W4a — denser chips. The tap target stays comfortable
|
||||
// (`min-h-9` ≈ 36 px, the WCAG/iOS floor) but the horizontal
|
||||
// padding + type size drop a notch so the two prompts pack into
|
||||
// fewer rows and the left column stops towering over the
|
||||
// Health-Score card.
|
||||
// v1.8.5 W4a — denser chips. The horizontal padding + type size
|
||||
// drop a notch so the two prompts pack into fewer rows and the
|
||||
// left column stops towering over the Health-Score card. The tap
|
||||
// target clears the 44 px iOS/WCAG floor on phones (`min-h-11`)
|
||||
// and relaxes to the denser 36 px from `sm` up.
|
||||
className={cn(
|
||||
"border-dracula-purple/18 hover:border-dracula-purple/40 hover:text-foreground",
|
||||
"text-muted-foreground inline-flex min-h-9 items-center gap-1.5",
|
||||
"text-muted-foreground inline-flex min-h-11 items-center gap-1.5 sm:min-h-9",
|
||||
"rounded-full border bg-transparent px-3 py-1.5 text-xs",
|
||||
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
"focus-visible:ring-dracula-purple/50",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { TrendingUp } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, TrendingUp } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
@@ -301,6 +302,22 @@ export function TrendsRow({
|
||||
text={t(config.captionKey)}
|
||||
/>
|
||||
)}
|
||||
{/* v1.18.6 — drill-in link to the metric's detail page. The
|
||||
overview charted these metrics but never offered a way INTO
|
||||
their detail pages — notably the steps page, which exists and
|
||||
is used but was unreachable from Insights. The link sits below
|
||||
the caption so the chart keeps its own interactions. */}
|
||||
{config.detailHref ? (
|
||||
<Link
|
||||
href={config.detailHref}
|
||||
data-slot="trends-row-detail-link"
|
||||
data-metric={config.metric}
|
||||
className="text-muted-foreground hover:text-foreground mt-auto inline-flex items-center gap-1 self-start text-xs font-medium transition-colors"
|
||||
>
|
||||
{t("insights.trendsRow.viewDetail", { metric: title })}
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { formatReferenceRange } from "@/lib/labs/reference-range";
|
||||
import { formatLabValue } from "@/lib/labs/format-value";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
|
||||
|
||||
import { LabTrendSparkline } from "./lab-trend-sparkline";
|
||||
import { ReferenceRangeBadge } from "./reference-range-badge";
|
||||
@@ -79,6 +80,7 @@ function groupReadings(results: LabResultDto[]): MarkerGroup[] {
|
||||
|
||||
export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
const { t } = useTranslations();
|
||||
const { prefs } = useModuleListPrefs("labs");
|
||||
|
||||
const listKey = queryKeys.labResultsList({
|
||||
biomarkerId: undefined,
|
||||
@@ -96,10 +98,18 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
apiGet<LabResultListResponse>("/api/labs?limit=500&sortDir=desc"),
|
||||
});
|
||||
|
||||
const groups = useMemo(
|
||||
() => groupReadings(data?.results ?? []),
|
||||
[data?.results],
|
||||
);
|
||||
const groups = useMemo(() => {
|
||||
const base = groupReadings(data?.results ?? []);
|
||||
// v1.18.6 (MOD-04) — honour the user's Labs sort choice. `groupReadings`
|
||||
// already returns most-recent-first; `recentAsc` reverses, and `manual`
|
||||
// applies the persisted biomarker order (legacy un-linked groups, which
|
||||
// carry no biomarkerId, sort after the ordered block).
|
||||
if (prefs.sortDir === "recentAsc") return [...base].reverse();
|
||||
if (prefs.sortDir === "manual") {
|
||||
return applyOrder(base, prefs.order, (g) => g.biomarkerId ?? g.key);
|
||||
}
|
||||
return base;
|
||||
}, [data?.results, prefs.sortDir, prefs.order]);
|
||||
|
||||
// The list caps at 500 rows server-side (the `limit` ceiling). Surface a
|
||||
// calm "showing latest N of M" hint when the cap truncates so the count is
|
||||
@@ -149,6 +159,65 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
// v1.18.6 (MOD-03) — compact list view: one bordered card holding tight
|
||||
// divided rows instead of a card per biomarker. The card view stays the
|
||||
// default; this is the denser alternative the settings toggle selects.
|
||||
if (prefs.view === "list") {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{truncated ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.showingLatestOf", { shown, total })}
|
||||
</p>
|
||||
) : null}
|
||||
<Card>
|
||||
<CardContent className="divide-border divide-y p-0">
|
||||
{groups.map((group) => {
|
||||
const row = (
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
|
||||
<span className="truncate font-medium">
|
||||
{group.analyte}
|
||||
</span>
|
||||
<ReferenceRangeBadge status={group.latest.rangeStatus} />
|
||||
</div>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-xs">
|
||||
<span className="text-foreground font-semibold tabular-nums">
|
||||
{formatLabValue(group.latest.value)}{" "}
|
||||
{group.latest.unit}
|
||||
</span>
|
||||
<span>{formatDate(group.latest.takenAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LabTrendSparkline
|
||||
values={group.readings.map((r) => r.value)}
|
||||
/>
|
||||
{group.biomarkerId ? (
|
||||
<ChevronRight className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return group.biomarkerId ? (
|
||||
<Link
|
||||
key={group.key}
|
||||
href={`/labs/${group.biomarkerId}`}
|
||||
className="hover:bg-muted/40 block transition-colors"
|
||||
>
|
||||
{row}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={group.key}>{row}</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{truncated ? (
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.18.6 (W8 / MOD-03 + MOD-04) — the Labs module's own settings page body.
|
||||
*
|
||||
* Reached from the wrench beside the page's "hinzufügen" button. Lets the
|
||||
* user:
|
||||
* - pick the card-vs-list page view (MOD-03),
|
||||
* - choose the sort order (by most-recent reading, descending / ascending,
|
||||
* or manual) — MOD-04,
|
||||
* - reorder biomarkers when sort = manual (MOD-03/04),
|
||||
* - and create / edit / delete biomarkers via the existing
|
||||
* `BiomarkerManager` (MOD-04).
|
||||
* View / sort / order persist client-side via `useModuleListPrefs("labs")`.
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ModuleViewToggle } from "@/components/module-list/module-view-toggle";
|
||||
import {
|
||||
ModuleOrderEditor,
|
||||
type ReorderItem,
|
||||
} from "@/components/module-list/module-order-editor";
|
||||
import {
|
||||
applyOrder,
|
||||
useModuleListPrefs,
|
||||
type ModuleSortDir,
|
||||
} from "@/lib/module-list-prefs";
|
||||
|
||||
import { BiomarkerManager } from "./biomarker-manager";
|
||||
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
|
||||
|
||||
export function LabsSettings() {
|
||||
const { t } = useTranslations();
|
||||
const { prefs, setView, setOrder, setSortDir } = useModuleListPrefs("labs");
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.biomarkers(),
|
||||
queryFn: () => apiGet<BiomarkerListResponse>("/api/biomarkers"),
|
||||
});
|
||||
const markers: BiomarkerDto[] = data?.biomarkers ?? [];
|
||||
|
||||
const ordered = applyOrder(markers, prefs.order, (m) => m.id);
|
||||
const reorderItems: ReorderItem[] = ordered.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
secondary: m.panel ? `${m.unit} · ${m.panel}` : m.unit,
|
||||
}));
|
||||
|
||||
const manual = prefs.sortDir === "manual";
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t("moduleList.viewHeading")}
|
||||
</h2>
|
||||
<ModuleViewToggle view={prefs.view} onChange={setView} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("moduleList.viewDescription")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold">{t("labs.sort.heading")}</h2>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="labs-sort">{t("labs.sort.label")}</Label>
|
||||
<NativeSelect
|
||||
id="labs-sort"
|
||||
value={prefs.sortDir}
|
||||
onChange={(e) => setSortDir(e.target.value as ModuleSortDir)}
|
||||
>
|
||||
<option value="recentDesc">{t("labs.sort.recentDesc")}</option>
|
||||
<option value="recentAsc">{t("labs.sort.recentAsc")}</option>
|
||||
<option value="manual">{t("labs.sort.manual")}</option>
|
||||
</NativeSelect>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.sort.hint")}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{manual ? (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t("moduleList.reorder.heading")}
|
||||
</h2>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ModuleOrderEditor items={reorderItems} onChange={setOrder} />
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t("labs.biomarker.manageTitle")}
|
||||
</h2>
|
||||
<BiomarkerManager />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { join } from "node:path";
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { OfflineBanner } from "../offline-banner";
|
||||
@@ -27,8 +28,14 @@ function loadMessages(locale: string): { offlineBanner: { message: string } } {
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
// v1.18.6 — the banner now reads the cached dashboard snapshot to decide
|
||||
// whether to append the "showing last synced data" hint, so it needs a
|
||||
// QueryClient in context.
|
||||
const queryClient = new QueryClient();
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 221 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user