mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42235076ac | ||
|
|
a6600f0215 | ||
|
|
e6862df048 | ||
|
|
99dc3b6915 | ||
|
|
81e564a724 | ||
|
|
caa37b74b5 | ||
|
|
44046eda22 | ||
|
|
40ef4b40b0 | ||
|
|
a54cb0a47f | ||
|
|
4791d6f543 | ||
|
|
4ba600d908 | ||
|
|
f2083eba36 | ||
|
|
3ffaf1906c | ||
|
|
1045639076 | ||
|
|
eb071b5dd8 | ||
|
|
75943496f7 | ||
|
|
91c4f789db | ||
|
|
6b08ffd7c5 | ||
|
|
d2656a2d90 | ||
|
|
aed541910c | ||
|
|
9084737635 | ||
|
|
91a605de2a | ||
|
|
5211771e8d | ||
|
|
a83fb6f936 | ||
|
|
4548df84bf | ||
|
|
24b1269b28 | ||
|
|
076339c274 | ||
|
|
e31c66e959 | ||
|
|
40ad2dfc96 | ||
|
|
586c4318ca | ||
|
|
ff6956decb | ||
|
|
8012aa095b | ||
|
|
df611227f9 | ||
|
|
17033f9966 | ||
|
|
20ff0c1868 | ||
|
|
1920321e44 | ||
|
|
e04383f015 | ||
|
|
d0a74e37da | ||
|
|
5c83156dcf | ||
|
|
f8c4032515 | ||
|
|
d6692e3a4b | ||
|
|
ae3d291feb | ||
|
|
93738ae968 | ||
|
|
81065f3f9d | ||
|
|
decc6681f6 | ||
|
|
e4ec0eec49 | ||
|
|
37c33d0205 | ||
|
|
13197c7ee9 | ||
|
|
fff84ea84f | ||
|
|
2032b74fc6 | ||
|
|
ec4e3adfdc | ||
|
|
7fa6498ad8 | ||
|
|
15d63c3842 | ||
|
|
5b464b26de | ||
|
|
521f5d354c | ||
|
|
7802903d93 | ||
|
|
e0387c5ceb | ||
|
|
3743a46a40 | ||
|
|
9e2c445ad7 | ||
|
|
b75771a445 | ||
|
|
64e7c57734 | ||
|
|
c2420d7f02 | ||
|
|
16fd4966d0 | ||
|
|
ada147522f | ||
|
|
e6d9cc229a | ||
|
|
74891e95f4 | ||
|
|
57e855eeb3 | ||
|
|
dfa8675a89 | ||
|
|
906acee978 | ||
|
|
e61fe42bd1 | ||
|
|
04320c415d | ||
|
|
36f73233b0 | ||
|
|
f3d92520d1 | ||
|
|
5c60558d4f | ||
|
|
271abd7b64 | ||
|
|
427b5f0b7c | ||
|
|
d4f78811d9 | ||
|
|
6af0e36c88 | ||
|
|
9a6a05a0ef | ||
|
|
0b37c15a52 | ||
|
|
5fa18a3754 |
No files matched your search
@@ -115,10 +115,12 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# hides password/passkey login entirely once set to "true" -- but only takes
|
||||
# effect when the three vars above are also fully set, so a typo can't lock
|
||||
# every user out. Enforced server-side, not just a hidden button. The native
|
||||
# iOS client authenticates via password/passkey only -- there is no OIDC
|
||||
# flow for it yet -- so OIDC_ONLY="true" locks the iOS app out of sign-in
|
||||
# entirely until a native SSO flow ships. Do not enable it on a deployment
|
||||
# with active iOS users.
|
||||
# iOS client signs in through the native OIDC SSO handoff (the app opens the
|
||||
# login URL in an in-app browser and completes at a custom-scheme callback), so
|
||||
# OIDC_ONLY="true" is supported for iOS -- but only on the app build that ships
|
||||
# that flow. An older password/passkey-only iOS build is locked out of sign-in
|
||||
# by OIDC_ONLY; confirm the app version before enabling it on a deployment with
|
||||
# active iOS users.
|
||||
# Serve the issuer over HTTPS wherever possible: sign-in material (the
|
||||
# authorization code, the token-endpoint response carrying the ID token)
|
||||
# crosses this connection. Plain HTTP is defensible only on a fully trusted
|
||||
|
||||
+147
@@ -2,6 +2,153 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.30.22] — 2026-07-19
|
||||
|
||||
Background work stops piling up on itself.
|
||||
|
||||
- **Duplicate background jobs are now actually suppressed.** Jobs were queued with a de-duplication key, but the queues were configured in a way that made that key have no effect — so the key looked right in the code and did nothing at run time. A large sync could fan out thousands of identical recompute jobs where tens were intended; a restart during a long history import could append another full import per restart; and the once-per-morning refresh had no debounce, so several paths could each trigger their own run. Each queue now carries an explicit policy chosen for what that queue does, recorded with its reason.
|
||||
- Existing installations are migrated on the next start — the setting could not be changed after a queue was created, so setting it at creation alone would have fixed nothing on any running instance. The first start after this release logs one line per migrated queue, which is how you can confirm it took effect.
|
||||
- One queue is deliberately left as it was: the explicit-range environment backfill sends without a key on purpose, and any de-duplication there would merge two different requested date ranges into one.
|
||||
|
||||
No breaking changes.
|
||||
|
||||
## [1.30.21] — 2026-07-19
|
||||
|
||||
Dose reminders land on the right hour across a clock change.
|
||||
|
||||
- **A reminder on a clock-change day now fires at the time you set.** The conversion from your local time to a real instant read the timezone offset at the moment the scheduler happened to run, not at the time the dose was actually due. On the autumn change in a timezone that observes one, a 20:00 dose could resolve to 19:00 when the scheduler ticked before the change. Two days a year, and only in timezones that change their clocks — but a dose reminder should never be an hour off. Nothing stored changed; the same schedule now produces the correct instant.
|
||||
- **A duplicate pending dose was possible on those same days.** The reminder projector, the worker and the intake write derived the instant separately and could disagree on the change day, which broke the matching that keeps them on one row.
|
||||
- **The next-dose day label could show the wrong day** when your device timezone differs from the one in your profile, because the offset was applied twice. The two copies of that label — in the card and in the table — are now one.
|
||||
- The tests could not have caught any of this: they used times with no timezone attached, so the day boundary could not be distinguished from a UTC one, and the suite ran only in one host timezone. They now state their timezone explicitly, cover both clock changes, and the suite is pinned to match the build.
|
||||
|
||||
No breaking changes.
|
||||
|
||||
## [1.30.20] — 2026-07-19
|
||||
|
||||
- **A restore now refuses when the backup file names a different account than the backup record.** The restore target was read from inside the encrypted file rather than from the record the operator selected, so a file claiming a different account would have been written into that account instead — while the audit entry recorded the one the operator picked. Both values were already available at that point; they are now compared, and a mismatch is refused and recorded with both.
|
||||
|
||||
No breaking changes.
|
||||
|
||||
## [1.30.19] — 2026-07-19
|
||||
|
||||
Switching a module off now holds everywhere.
|
||||
|
||||
- **Three surfaces ignored your module switches.** The per-topic endpoints have always honoured them, but the sync feed still sent cycle data, the FHIR export still returned the full record including the insurance number, and the dashboard summary still built glucose and sleep cards — all regardless of whether those modules were on. Each now honours the switch: the sync feed leaves the cycle rows out, the FHIR export refuses the same way the health-record export already did, and the summary omits the cards. Nothing is deleted — turning a module back on finds the history complete, and the sync feed picks up where it left off rather than skipping what changed while it was hidden.
|
||||
- **Two assistant features reached a provider without a consent record.** The follow-up questions after saving your profile, and the assistant's own nudges, both sent data to a provider without checking whether consent was on file — and could fall back to the server-wide key. Both now check first and fall back to their non-generated text when consent is absent. The follow-up questions also moved to the same reservation-based spend accounting everything else uses; a self-hoster on their own key is no longer measured against the operator's daily ceiling.
|
||||
- The check that was supposed to catch the module gaps accepted a helper being imported as proof that a gate existed. It now pins which module each surface must check and names the tests that exercise a disabled one.
|
||||
|
||||
No breaking changes to the web app. The native client may see a 403 from the FHIR endpoints and absent cards where a module is off — see the coordination note.
|
||||
|
||||
## [1.30.18] — 2026-07-19
|
||||
|
||||
Health data and credentials no longer reach the logs or the backups.
|
||||
|
||||
- **Cached responses are encrypted at rest.** A write endpoint's response is kept for 24 hours so a retried request cannot run twice. Those responses echo what was just saved — cycle notes, mood text, allergy reactions — and were stored unencrypted, in a column that lands in every backup. They are now encrypted like every other stored health field. If encryption is unavailable the response is simply not cached, rather than being written in the clear.
|
||||
- **Diagnostic log entries are scrubbed at the point they are written.** Two of the places that build a log entry did not run the redaction the others did, so an outbound request that timed out could put a notification bot's credential into the logs verbatim.
|
||||
- **A failed AI request no longer quotes what the provider sent back.** Some providers echo the request they rejected, which on this path contains the prompt and the health figures in it. The log entry now names the kind of rejection instead of quoting the response, which answers the same diagnostic question without carrying the content.
|
||||
|
||||
Operator note: no action required, and no configuration change. Log lines and backups written before this release may contain the values described above.
|
||||
|
||||
No breaking changes.
|
||||
|
||||
## [1.30.17] — 2026-07-19
|
||||
|
||||
API tokens now enforce their scope.
|
||||
|
||||
- **A token minted for medication intake is refused outside the ingest endpoint.** Previously any valid token reached every authenticated route regardless of the scope it carried, so a token handed to a third-party automation could read the full-account backup export, lab results, measurements and the assistant. A route that accepts a narrow-scope token now has to name that scope; a route that names none accepts browser sessions and the app's own full-access tokens only. Tokens issued to the web session, the native client and the MCP connector are unaffected.
|
||||
- **Revoke and re-issue any token you handed to a third party.** A token that could reach the backup export should be treated as having had that reach for its whole lifetime, not only from the moment it was noticed.
|
||||
- **The generic "create API token" button is gone from settings.** The token it minted never worked for the medication intake it advertised — the ingest endpoint also requires a per-medication grant that this button never issued — while reaching everything else. Tokens are issued per medication from the medication's own API-endpoint toggle, which grants exactly that one medication. Existing tokens stay listed and revocable.
|
||||
- **MCP connector tokens are now bound to the MCP endpoint alone.** They previously also passed on read-only REST requests. No connector flow used that.
|
||||
|
||||
No breaking changes to the web session, the native client, or the MCP connector.
|
||||
|
||||
## [1.30.16] — 2026-07-19
|
||||
|
||||
Stops an ongoing loss of resting heart-rate history.
|
||||
|
||||
- **Resting heart rate is preserved again for accounts without a device-supplied resting figure.** Older heart-rate readings are condensed on a schedule to keep the database small, and a resting value is derived and stored before the raw readings go. The check that decides whether an account already has its own resting data was matching the derived values it had written itself, so from the second run onward it concluded the data was already there and stopped deriving — while the raw readings it derives from were still being cleared. Each run lost another day's resting figure permanently. The check now looks only at readings the account actually supplied.
|
||||
- Days already affected cannot be reconstructed; from this release forward, nothing further is lost.
|
||||
|
||||
No breaking changes.
|
||||
|
||||
## [1.30.15] — 2026-07-18
|
||||
|
||||
Generated health texts now come in the language you set.
|
||||
|
||||
- **French, Spanish, Italian and Polish accounts get their assessments in their own language.** Every locale other than English previously fell back to the German instruction set, so those four either read German text or — where the pipeline narrowed them earlier — English text. Insight cards, the per-metric assessments, derived scores, the biomarker card, the period narrative and the assistant's tone settings all now follow the account's own language, with the wording rules that were already translated for each of the six languages. German and English output is unchanged, character for character.
|
||||
- **The language instruction is the last thing the model reads**, rather than being buried before the metric-specific section that follows it.
|
||||
- Under the hood: one shared resolver replaces the scattered English-or-else-German branches, and the locale now survives the whole generation path instead of being narrowed on the way in. Stored texts for the four languages regenerate on their next scheduled run; German and English keep reading exactly the rows they already had.
|
||||
|
||||
No breaking changes.
|
||||
|
||||
## [1.30.14] — 2026-07-18
|
||||
|
||||
The workouts page joins the pages that paint on first load.
|
||||
|
||||
- **The workouts list opens with the sessions already there.** It was the last high-traffic surface still fetching its rows after the page mounted, so it showed a loading skeleton first and filled in a moment later. The list is now read on the server and handed to the page populated, the same way the dashboard, insights, medications, and coach pages already work. The list logic moved into a shared read so the page and the API endpoint go through one cached, deduplicated pass instead of two.
|
||||
- **API docs: the heart-rate bucket example matches what the server accepts.** The aggregated heart-rate `externalId` was documented with an hourly example from its first version; the contract has been 10-minute buckets since v1.30.7. The description, the example timestamp, and the min/max field notes now describe the shipped contract.
|
||||
|
||||
No breaking changes.
|
||||
|
||||
## [1.30.13] — 2026-07-18
|
||||
|
||||
More pages that paint on first load.
|
||||
|
||||
- **The insights, medications, and coach pages open with their content already there**, the same way the dashboard now does — their above-the-fold data is read on the server and handed to the page populated, instead of a shell that fills in after a second round of requests. Nothing that would trigger AI work is pre-run: the daily briefing and the coach's own reads stay on-demand as before.
|
||||
|
||||
No breaking changes.
|
||||
|
||||
## [1.30.12] — 2026-07-18
|
||||
|
||||
Faster medication reads.
|
||||
|
||||
- **The dashboard's medication section stays fast as your log grows.** Its reads now use dedicated indexes instead of scanning the full intake history on every load, so a long history no longer slows down the daily view.
|
||||
|
||||
No breaking changes.
|
||||
|
||||
## [1.30.11] — 2026-07-18
|
||||
|
||||
Native single sign-on.
|
||||
|
||||
- **The iOS app can sign in through your identity provider (OIDC SSO).** Until now, single sign-on only worked in the browser — the native app couldn't complete it, which locked native users out entirely on an SSO-only instance. The server now supports a native sign-in handoff (an in-app authorization flow that returns the app's normal token, never a cookie), so the app can offer SSO once it adopts the flow. Existing password and passkey sign-in are unchanged. Multi-factor sign-in still applies, and an SSO session never satisfies a step-up prompt — those still require your own second factor.
|
||||
|
||||
No breaking changes.
|
||||
|
||||
## [1.30.10] — 2026-07-18
|
||||
|
||||
Localized insight text, and a richer workout view.
|
||||
|
||||
- **Correlation insights now read in your language.** The "this tends to go with that — a pattern worth watching, not a cause" lines under a metric were always written in English; they now render in your interface language (German, Spanish, French, Italian, Polish), with the association-not-causation framing preserved in each.
|
||||
- **A workout you open shows more of what was recorded.** The detail view now draws the session's heart-rate curve over time, its route as a clean line, the time spent in each effort zone, per-kilometre splits, and how it compares to your own average for that sport — all from data already synced, with no new connection. Anything a given workout didn't record simply isn't shown.
|
||||
|
||||
No migration. No breaking changes.
|
||||
|
||||
## [1.30.9] — 2026-07-18
|
||||
|
||||
Faster dashboard first load.
|
||||
|
||||
- **The top card and the charts paint with their data on first load** instead of flashing empty and then filling in. The daily summary and the charts' 31-day series are now read on the server and handed to the page already populated, so there's no wait for a second round of requests after the page appears. The welcome/summary card, likely the slowest thing to show before, now lands with the first paint.
|
||||
|
||||
No migration. No breaking changes.
|
||||
|
||||
## [1.30.8] — 2026-07-18
|
||||
|
||||
Hardening for the new heart-rate upload validation.
|
||||
|
||||
- **A malformed bucket timestamp is rejected, not silently misfiled.** A bucket whose time rolled over (a 24:00, an impossible day) is now refused rather than stored under a non-standard key, where it would have failed to merge with a re-post and could land on the wrong day.
|
||||
- **The per-window low/high is sanity-checked before it counts.** A window's low and high are kept only when they are in a plausible range and sit either side of the average; a glitched reading no longer skews the day's high/low band. The average is always kept.
|
||||
|
||||
No migration. No breaking changes.
|
||||
|
||||
## [1.30.7] — 2026-07-18
|
||||
|
||||
Groundwork for a tidier heart-rate history.
|
||||
|
||||
- **The server accepts a 10-minute aggregated heart-rate upload.** A watch records heart rate every few seconds, and uploading one row per raw sample fills the measurements list with the same value repeated many times a minute. The server now takes one row per 10-minute window — its average, with the low and high of the window — and the within-day pulse curve reads it directly, so the detailed 10-minute chart and the elevated-at-rest note are preserved. The de-duplication reaches the list once the mobile app sends the aggregated shape; existing per-sample history is unaffected and reconciles itself over time.
|
||||
- **The daily high/low reflects the true spread.** A day built from 10-minute windows now reports the real intra-window extremes, not the range of the averages.
|
||||
|
||||
No migration. No breaking changes.
|
||||
|
||||
## [1.30.6] — 2026-07-18
|
||||
|
||||
Accessibility pass.
|
||||
|
||||
@@ -86,9 +86,10 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file:
|
||||
|
||||
- **UI work follows the design standards.** `.planning/audits/2026-07-05-fable/UI-STANDARDS.md` is the binding design rulebook (card anatomy via TileHeader, spacing scale, text-colour rules — foreground for content, muted for meta only —, token discipline, primitive mapping). Read it before touching any surface; deviations need a written reason in the PR. Where the standards conflict with older in-file comments, the standards win.
|
||||
- **RSC by default.** Server components are the default; add `"use client"` only when a hook, state, or browser API actually needs it.
|
||||
- **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`).
|
||||
- **Every API route wraps in `apiHandler`, with three documented exceptions.** The MCP OAuth routes (`mcp/oauth/{authorize,token,register}`) use `withBackgroundEvent` instead, because OAuth requires RFC-format error bodies that the standard envelope cannot express. They carry the same protections by hand — rate limit, body cap, kill switch, mandatory PKCE-S256, redirect-URI allowlist. Every other route wraps. 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.
|
||||
- **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` builds `permissions` from a hardcoded literal, never from request input.
|
||||
- **Bearer scope is fail-closed.** `requireAuth()` with no argument admits a cookie session or a wildcard (`["*"]`) token only — a narrow-scope token is refused with 403 and an `undeclared_scope` audit row. A route that means to accept a narrow token names the scope: `requireAuth(FHIR_READ_SCOPE)`. Adding a route therefore cannot silently widen an existing token's reach, because the default arm denies. Only the login / passkey / refresh paths mint `["*"]`; every user-facing mint is narrow, and narrow now means narrow. Two surfaces resolve a Bearer token outside `requireAuth` — `/api/ingest/medication` and `/mcp` — and that set is frozen by a structural test (`src/__tests__/bearer-scope-enforcement-guard.test.ts`).
|
||||
- **`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.
|
||||
@@ -165,7 +166,7 @@ The native SwiftUI client lives in a separate repository and rides public beta v
|
||||
- The `clientManaged` opt-in on `PATCH /api/auth/me/notification-prefs` suppresses server-side `MEDICATION_REMINDER` APNs for clients that manage their own local reminders. The cron skips dose-due APNs when the flag is `true` and emits a `medication_reminder.suppressed_client_managed` wide-event annotation per skip.
|
||||
- `push_attempts` table holds the last 90 days of APNS / Web-Push / Telegram / NTFY delivery attempts (channel + eventType + result + reason + createdAt). The admin diagnostic endpoint at `/api/admin/notifications/diagnostic` surfaces masked device tokens + channel state + last 20 attempts for the calling user.
|
||||
- Any request / response schema change must regen `docs/api/openapi.yaml` and commit the result. CI fails on drift.
|
||||
- The iOS app authenticates via password/passkey only (`src/lib/auth/native-client.ts`) — there is no OIDC flow for native clients yet. `OIDC_ONLY=true` is enforced server-side on those same routes, so enabling it locks every iOS user out of sign-in until a native SSO flow ships. Don't recommend it to an operator running the iOS app.
|
||||
- The iOS app signs in via the native OIDC SSO handoff **or** password/passkey. The native SSO leg: the app opens `GET /api/auth/oidc/login?client=native&code_challenge=<S256>` in an `ASWebAuthenticationSession`, the callback returns to the fixed compiled-in scheme `healthlog://oidc-callback` with a one-time `code=hlh_…` (or an `mfa_ticket` when the account has a second factor), and the app exchanges the code at `POST /api/auth/oidc/native/token` for the standard native bundle (`src/lib/auth/oidc-native-handoff.ts`). The `healthlog://` scheme is a compile-time constant — no `redirect_uri` param, no env allowlist (the open-redirect class is removed by construction). The token pair NEVER rides a URL; only the opaque handoff code / MFA ticket do. The SSO login can never satisfy `requireFreshMfa` step-up — the native transport is Bearer and `requireFreshMfa` is cookie-only, and even the web path sets `mfaVerifiedAt` only from the app's own factor. `OIDC_ONLY=true` is now supported for iOS but requires the app build that ships this flow; an older password/passkey-only build is locked out. The MFA-verify and `/api/auth/refresh` routes that complete/rotate an OIDC login are deliberately not OIDC-only-blocked.
|
||||
|
||||
## DO-NOTs
|
||||
|
||||
|
||||
+267
-17
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.30.6
|
||||
version: 1.30.22
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -1562,6 +1562,38 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/auth/oidc/native/token:
|
||||
post:
|
||||
tags:
|
||||
- Auth
|
||||
summary: Exchange a native OIDC handoff code for the token bundle
|
||||
description: >-
|
||||
The cookie-less native leg of the OIDC SSO flow. The iOS app opens `GET
|
||||
/api/auth/oidc/login?client=native&code_challenge=<S256>` inside an `ASWebAuthenticationSession`; the callback
|
||||
returns a one-time handoff code on `healthlog://oidc-callback?code=hlh_…` (or an `mfa_ticket` when the account
|
||||
has a second factor, completed at /api/auth/mfa/verify). This endpoint exchanges the code + its PKCE
|
||||
`codeVerifier` for the SAME native bundle password login issues.
|
||||
|
||||
|
||||
Requires the native transport (no cookie, non-browser UA) — a browser is rejected. The code is single-use and
|
||||
expires in ~90 seconds; a replay of a consumed code revokes the pair the first exchange issued. A single generic
|
||||
401 covers every invalid/expired/used/PKCE-mismatch case.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/OidcNativeTokenRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Handoff accepted — native access + refresh bundle.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/OidcNativeTokenResponse"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/auth/me/mfa/totp/setup:
|
||||
post:
|
||||
tags:
|
||||
@@ -2668,14 +2700,23 @@ paths:
|
||||
- Measurements
|
||||
summary: Workout detail (v1.4.32)
|
||||
description: Single-workout envelope. Owns the optional `WorkoutRoute` GeoJSON geometry + `canonicalId` pointer that
|
||||
resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Cross-user rows
|
||||
surface as 404 (existence channel sealed).
|
||||
resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Additive
|
||||
enrichment fields (`hrSeries`, `zones`, `splits`, `sportContext`, reserved `aiInsight`) are computed
|
||||
server-side. `compact=1` (sent by the web client) drops the raw `samples.samples` HR blob and
|
||||
`route.sampleTimestamps` array from the response; without it the payload is byte-identical to the v1.4.32
|
||||
contract. Cross-user rows surface as 404 (existence channel sealed).
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
- in: query
|
||||
name: compact
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- "1"
|
||||
responses:
|
||||
"200":
|
||||
description: Workout detail.
|
||||
@@ -11006,6 +11047,21 @@ components:
|
||||
- mfaTicket
|
||||
- code
|
||||
description: Complete a second-factor login challenge and receive the same token bundle the password path issues.
|
||||
OidcNativeTokenRequest:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
pattern: ^hlh_[A-Za-z0-9_-]{43}$
|
||||
description: One-time handoff code from the healthlog://oidc-callback?code= redirect.
|
||||
codeVerifier:
|
||||
type: string
|
||||
pattern: ^[A-Za-z0-9\-._~]{43,128}$
|
||||
description: The app's PKCE code_verifier (RFC 7636).
|
||||
required:
|
||||
- code
|
||||
- codeVerifier
|
||||
description: Exchange a one-time native OIDC handoff code (+ its PKCE verifier) for the standard native token bundle.
|
||||
MfaDisableRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -11212,18 +11268,20 @@ components:
|
||||
description: HealthKit identifier (e.g. `HKQuantityTypeIdentifierBodyMass`).
|
||||
value:
|
||||
type: number
|
||||
description: Raw HealthKit reading in Apple's native unit; the server applies any canonical scaling at ingest. For an
|
||||
hourly heart-rate bucket (see `externalId`) this is the hour's AVERAGE bpm; the hour's spread rides
|
||||
description: Raw HealthKit reading in Apple's native unit; the server applies any canonical scaling at ingest. For a
|
||||
10-minute heart-rate bucket (see `externalId`) this is the bucket's AVERAGE bpm; the bucket's spread rides
|
||||
`valueMin` / `valueMax`.
|
||||
valueMin:
|
||||
description: "v1.19.2 (iOS #34 extension) — the hour's MINIMUM bpm for an hourly heart-rate bucket (see `externalId`).
|
||||
Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:<hour>` row; ignored (stored null)
|
||||
on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only contract."
|
||||
description: "v1.19.2 (iOS #34 extension) — the bucket's MINIMUM bpm for a 10-minute heart-rate bucket (see
|
||||
`externalId`). Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:<bucket-start>` row;
|
||||
ignored (stored null) on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only
|
||||
contract."
|
||||
type: number
|
||||
valueMax:
|
||||
description: "v1.19.2 (iOS #34 extension) — the hour's MAXIMUM bpm for an hourly heart-rate bucket (see `externalId`).
|
||||
Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:<hour>` row; ignored (stored null)
|
||||
on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only contract."
|
||||
description: "v1.19.2 (iOS #34 extension) — the bucket's MAXIMUM bpm for a 10-minute heart-rate bucket (see
|
||||
`externalId`). Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:<bucket-start>` row;
|
||||
ignored (stored null) on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only
|
||||
contract."
|
||||
type: number
|
||||
unit:
|
||||
type: string
|
||||
@@ -11263,12 +11321,12 @@ components:
|
||||
• `stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>` — a per-day cumulative total (Steps, Active Energy, Sleep
|
||||
Duration, Walking/Running Distance, Flights Climbed). A re-post OVERWRITES the row and returns `updated`.
|
||||
|
||||
• `stats:HKQuantityTypeIdentifierHeartRate:<bucket-start-hour>` — v1.19.0 (iOS #34) hourly heart-rate bucket
|
||||
carrying the hour's AVERAGE bpm as one PULSE row. `<bucket-start-hour>` is the ISO-8601 UTC hour boundary
|
||||
with zeroed minutes/seconds/millis and a trailing `Z` (e.g. `2026-06-21T14:00:00.000Z`). A re-post within
|
||||
the hour OVERWRITES the row and returns `updated`, so iOS uploads ~24 rows/day instead of one per raw HR
|
||||
sample. A row that targets this prefix with a malformed hour suffix is `skipped` with reason
|
||||
`malformed_hr_bucket_id`.
|
||||
• `stats:HKQuantityTypeIdentifierHeartRate:<bucket-start>` — v1.19.0 (iOS #34), refined to 10-minute
|
||||
granularity in v1.30.7, heart-rate bucket carrying the bucket's AVERAGE bpm as one PULSE row.
|
||||
`<bucket-start>` is the ISO-8601 UTC 10-minute boundary — zeroed seconds/millis, minutes ∈
|
||||
{00,10,20,30,40,50}, trailing `Z` (e.g. `2026-06-21T14:10:00.000Z`). A re-post OVERWRITES the row and
|
||||
returns `updated`, so iOS uploads ~144 rows/day instead of one per raw HR sample. A row that targets this
|
||||
prefix with a malformed bucket-start suffix is `skipped` with reason `malformed_hr_bucket_id`.
|
||||
externalSourceVersion:
|
||||
type: string
|
||||
minLength: 1
|
||||
@@ -14865,6 +14923,23 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
OidcNativeTokenResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/AccessRefreshBundle"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
TotpSetupEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
@@ -17429,6 +17504,10 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
hasRoute:
|
||||
type: boolean
|
||||
hasHrSeries:
|
||||
type: boolean
|
||||
required:
|
||||
- id
|
||||
- sportType
|
||||
@@ -17441,6 +17520,8 @@ components:
|
||||
- maxHr
|
||||
- source
|
||||
- externalId
|
||||
- hasRoute
|
||||
- hasHrSeries
|
||||
additionalProperties: false
|
||||
GetWorkoutResponse:
|
||||
type: object
|
||||
@@ -17553,6 +17634,26 @@ components:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/WorkoutHrSeries"
|
||||
- type: "null"
|
||||
hrSeries:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/WorkoutHrCurve"
|
||||
- type: "null"
|
||||
zones:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/WorkoutZones"
|
||||
- type: "null"
|
||||
splits:
|
||||
anyOf:
|
||||
- type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/WorkoutSplit"
|
||||
- type: "null"
|
||||
sportContext:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/WorkoutSportContext"
|
||||
- type: "null"
|
||||
aiInsight:
|
||||
type: "null"
|
||||
canonicalId:
|
||||
type: string
|
||||
required:
|
||||
@@ -17574,6 +17675,11 @@ components:
|
||||
- metadata
|
||||
- route
|
||||
- samples
|
||||
- hrSeries
|
||||
- zones
|
||||
- splits
|
||||
- sportContext
|
||||
- aiInsight
|
||||
- canonicalId
|
||||
additionalProperties: false
|
||||
WorkoutRouteGeometry:
|
||||
@@ -17602,6 +17708,150 @@ components:
|
||||
- sampleCount
|
||||
- samples
|
||||
additionalProperties: false
|
||||
WorkoutHrCurve:
|
||||
type: object
|
||||
properties:
|
||||
source:
|
||||
type: string
|
||||
enum:
|
||||
- workout_series
|
||||
- pulse_window
|
||||
bucketSec:
|
||||
type: integer
|
||||
exclusiveMinimum: 0
|
||||
maximum: 9007199254740991
|
||||
points:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
tSec:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
mean:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
min:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
max:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- tSec
|
||||
- mean
|
||||
- min
|
||||
- max
|
||||
additionalProperties: false
|
||||
envelope:
|
||||
type: boolean
|
||||
required:
|
||||
- source
|
||||
- bucketSec
|
||||
- points
|
||||
- envelope
|
||||
additionalProperties: false
|
||||
WorkoutZones:
|
||||
type: object
|
||||
properties:
|
||||
model:
|
||||
type: string
|
||||
enum:
|
||||
- whoop
|
||||
- tanaka
|
||||
hrMax:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
- type: "null"
|
||||
zones:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
zone:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 5
|
||||
lowBpm:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
- type: "null"
|
||||
highBpm:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
- type: "null"
|
||||
seconds:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- zone
|
||||
- lowBpm
|
||||
- highBpm
|
||||
- seconds
|
||||
additionalProperties: false
|
||||
required:
|
||||
- model
|
||||
- hrMax
|
||||
- zones
|
||||
additionalProperties: false
|
||||
WorkoutSplit:
|
||||
type: object
|
||||
properties:
|
||||
km:
|
||||
type: integer
|
||||
exclusiveMinimum: 0
|
||||
maximum: 9007199254740991
|
||||
durationSec:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
paceSecPerKm:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- km
|
||||
- durationSec
|
||||
- paceSecPerKm
|
||||
additionalProperties: false
|
||||
WorkoutSportContext:
|
||||
type: object
|
||||
properties:
|
||||
count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
avgDurationSec:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
avgDistanceM:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: "null"
|
||||
avgAvgHr:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
- type: "null"
|
||||
required:
|
||||
- count
|
||||
- avgDurationSec
|
||||
- avgDistanceM
|
||||
- avgAvgHr
|
||||
additionalProperties: false
|
||||
BatchWorkoutsResponse:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -65,10 +65,10 @@ Still in **Settings → MCP → Tokens**:
|
||||
3. **Copy the `hlk_…` value now** — it is shown exactly once and cannot
|
||||
be retrieved again. Tokens default to a 90-day lifetime.
|
||||
|
||||
The token never carries the `*` wildcard and never the
|
||||
`medication:ingest` scope the generic `/api/tokens` mints — it is the
|
||||
least-privilege MCP scope and nothing more. The same list shows
|
||||
last-used time and lets you revoke any token.
|
||||
The token never carries the `*` wildcard and never a medication-ingest
|
||||
scope — it is the least-privilege MCP scope and nothing more, and it is
|
||||
bound to the MCP surface: it is refused on every REST route. The same
|
||||
list shows last-used time and lets you revoke any token.
|
||||
|
||||
Cloud connectors that use OAuth (next section) mint the same
|
||||
`health:read` scope automatically, so for Claude.ai / ChatGPT you do not
|
||||
|
||||
@@ -109,6 +109,8 @@ const eslintConfig = defineConfig([
|
||||
"src/components/medications/dose-strength-curve.tsx",
|
||||
"src/components/medications/drug-level-chart.tsx",
|
||||
"src/components/mental-health/assessment-history-chart.tsx",
|
||||
"src/components/charts/workout-hr-chart.tsx",
|
||||
"src/components/charts/workout-elevation-chart.tsx",
|
||||
],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
|
||||
@@ -16,6 +16,24 @@
|
||||
# Per project policy (`--no-verify` is banned), prefer fixing the
|
||||
# finding over bypassing the hook.
|
||||
|
||||
# Commit-message guard: the project voice rule (CLAUDE.md, "Voice and
|
||||
# privacy") states that commits are authored by the maintainer's git
|
||||
# identity alone and carry no tooling co-author trailer or session link.
|
||||
# Editors and CLIs add those trailers by default, and a message that
|
||||
# picks one up is only removable from the object store by a history
|
||||
# rewrite — so the check belongs here, before the commit exists, not in
|
||||
# a review pass afterwards.
|
||||
commit-msg:
|
||||
commands:
|
||||
no-tooling-trailer:
|
||||
run: |
|
||||
if grep -qiE '^(Co-Authored-By:.*(claude|anthropic|copilot|gpt|gemini)|Claude-Session:|Generated with)' "{1}"; then
|
||||
echo "ERROR: the commit message carries a tooling co-author trailer or session link." >&2
|
||||
echo "Project policy: commits are authored by the maintainer's git identity alone." >&2
|
||||
echo "Remove the offending line(s) from the message and commit again." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pre-commit:
|
||||
commands:
|
||||
gitleaks:
|
||||
|
||||
+75
-2
@@ -1979,6 +1979,61 @@
|
||||
"notAvailableForCompliance": "Vergleich für Therapietreue nicht verfügbar"
|
||||
},
|
||||
"insights": {
|
||||
"correlation": {
|
||||
"direction": {
|
||||
"higher": "höher",
|
||||
"lower": "niedriger"
|
||||
},
|
||||
"weekdayDirection": {
|
||||
"above": "über",
|
||||
"below": "unter"
|
||||
},
|
||||
"channelFactor": "die Bewertung von {factor}",
|
||||
"channel": {
|
||||
"WEIGHT": "das Gewicht",
|
||||
"BLOOD_GLUCOSE": "der Blutzucker",
|
||||
"BLOOD_PRESSURE_SYS": "der systolische Blutdruck",
|
||||
"BLOOD_PRESSURE_DIA": "der diastolische Blutdruck",
|
||||
"ACTIVITY_STEPS": "die tägliche Schrittzahl",
|
||||
"SLEEP_DURATION": "die Schlafdauer",
|
||||
"HEART_RATE_VARIABILITY": "die Herzratenvariabilität",
|
||||
"RESTING_HEART_RATE": "die Ruheherzfrequenz",
|
||||
"TIME_IN_DAYLIGHT": "die Zeit im Tageslicht",
|
||||
"MOOD": "die Stimmung",
|
||||
"PULSE": "der Ruhepuls",
|
||||
"MEDICATION_COMPLIANCE": "die Medikamententreue",
|
||||
"SYMPTOM_SEVERITY": "die Symptomstärke",
|
||||
"ENV_TEMP_MEAN": "die Tagestemperatur",
|
||||
"ENV_TEMP_MIN": "die nächtliche Tiefsttemperatur",
|
||||
"ENV_SUNSHINE": "der Sonnenschein",
|
||||
"ENV_DAYLIGHT": "das Tageslicht",
|
||||
"ENV_PRECIP": "der Niederschlag",
|
||||
"ENV_PRESSURE_MEAN": "der Luftdruck",
|
||||
"ENV_PRESSURE_DELTA": "die Luftdruckschwankung am Tag"
|
||||
},
|
||||
"daily": {
|
||||
"high": "Wenn {behaviour} höher liegt, fällt {outcome} am Folgetag in deinen Daten tendenziell {direction} aus — ein Muster, das Beachtung verdient, keine Ursache.",
|
||||
"moderate": "Wenn {behaviour} höher liegt, scheint {outcome} am Folgetag nach bisheriger Datenlage eher {direction} auszufallen — ein Muster, das Beachtung verdient, keine Ursache.",
|
||||
"faint": "Wenn {behaviour} höher liegt, gibt es in deinen Daten allenfalls einen schwachen Hinweis darauf, dass {outcome} am Folgetag {direction} ausfällt — zu gering, um sich darauf zu verlassen, niemals eine Ursache."
|
||||
},
|
||||
"lab": {
|
||||
"default": "Höhere {lab}-Werte gehen in deinen Daten damit einher, dass {outcome} im selben Zeitraum {direction} ausfällt — ein Zusammenhang, den du ärztlich im Blick behalten solltest, niemals eine Ursache.",
|
||||
"faint": "Höhere {lab}-Werte deuten in deinen Daten allenfalls schwach darauf hin, dass {outcome} im selben Zeitraum {direction} ausfällt — zu gering, um sich darauf zu verlassen, ein Zusammenhang, den du ärztlich abklären lassen solltest, niemals eine Ursache."
|
||||
},
|
||||
"hypothesis": {
|
||||
"bpCompliance": {
|
||||
"support": "Höhere Medikamententreue geht mit niedrigeren systolischen Werten einher — ein Muster, das Beachtung verdient.",
|
||||
"surprising": "Medikamententreue und systolischer Wert bewegen sich in deinen Daten gemeinsam — überraschend; sprich mit deiner Ärztin oder deinem Arzt, bevor du etwas änderst.",
|
||||
"none": "Medikamententreue und systolischer Wert bewegen sich in diesem Zeitraum nicht deutlich gemeinsam."
|
||||
},
|
||||
"moodPulse": {
|
||||
"support": "Tage mit schlechterer Stimmung gehen mit höherem Ruhepuls einher — ein Muster, das Beachtung verdient.",
|
||||
"surprising": "Tage mit besserer Stimmung gehen in deinen Daten mit höherem Ruhepuls einher — überraschend.",
|
||||
"none": "Stimmung und Ruhepuls bewegen sich in diesem Zeitraum nicht deutlich gemeinsam."
|
||||
},
|
||||
"weightWeekday": "Das Gewicht am {weekday} liegt {delta} kg {direction} deinem Durchschnitt der übrigen Tage — ein Muster, das Beachtung verdient."
|
||||
}
|
||||
},
|
||||
"emptyTitle": "Noch nicht genug Daten",
|
||||
"emptyDescription": "Insights erscheinen, sobald du ein paar Messwerte erfasst hast. Beginne mit Gewicht oder Blutdruck.",
|
||||
"emptyAddMeasurement": "Messwert erfassen",
|
||||
@@ -2934,7 +2989,24 @@
|
||||
"hrChartTitle": "Herzfrequenz",
|
||||
"hrChartUnavailable": "Sekundengenaue Herzfrequenz-Werte werden für dieses Workout nicht gespeichert.",
|
||||
"notFound": "Workout nicht gefunden.",
|
||||
"loading": "Workout wird geladen…"
|
||||
"loading": "Workout wird geladen…",
|
||||
"hrFromPulse": "Aus deinen Herzfrequenzdaten rund um diese Einheit",
|
||||
"hrAxisMinutes": "Min.",
|
||||
"sportAverageLabel": "Dein aktueller Durchschnitt:",
|
||||
"zonesTitle": "Belastungszonen",
|
||||
"zonesCaption": "Wie sich deine Belastung auf die Herzfrequenzzonen verteilt hat.",
|
||||
"zonesCaptionDevice": "Zeit pro Zone laut deinem Gerät.",
|
||||
"exportGpx": "GPX exportieren",
|
||||
"elevationTitle": "Höhe",
|
||||
"splitsTitle": "Kilometerzeiten",
|
||||
"splitsKm": "km",
|
||||
"splitsTime": "Zeit",
|
||||
"splitsPace": "Tempo",
|
||||
"thatDayTitle": "An diesem Tag",
|
||||
"dayPulse": "Puls",
|
||||
"daySleep": "Schlaf",
|
||||
"dayMood": "Stimmung",
|
||||
"insightTitle": "Aktivitäts-Einblick"
|
||||
},
|
||||
"sport": {
|
||||
"walking": "Gehen",
|
||||
@@ -5105,7 +5177,8 @@
|
||||
"resumeError": "Wieder verbinden fehlgeschlagen"
|
||||
},
|
||||
"apiTokens": "API-Tokens",
|
||||
"apiTokensDescription": "Erstelle API-Tokens für die externe Medikamenten-Einnahme (z.B. Shortcuts, Automationen).",
|
||||
"apiTokensDescription": "API-Tokens für die externe Medikamenten-Einnahme prüfen und widerrufen. Tokens werden pro Medikament über dessen API-Endpoint-Schalter ausgestellt.",
|
||||
"tokenMintMovedDescription": "Tokens werden pro Medikament ausgestellt. Öffne ein Medikament und aktiviere dessen API-Endpoint, um ein Token zu erhalten, das nur für dieses Medikament gilt.",
|
||||
"tokenNamePlaceholder": "Token-Name (z.B. iPhone Shortcut)",
|
||||
"tokenCreated": "Token erstellt — kopiere ihn jetzt! Er wird nicht erneut angezeigt.",
|
||||
"tokenCopy": "Token kopieren",
|
||||
|
||||
+75
-2
@@ -1979,6 +1979,61 @@
|
||||
"notAvailableForCompliance": "Comparison N/A for adherence"
|
||||
},
|
||||
"insights": {
|
||||
"correlation": {
|
||||
"direction": {
|
||||
"higher": "higher",
|
||||
"lower": "lower"
|
||||
},
|
||||
"weekdayDirection": {
|
||||
"above": "above",
|
||||
"below": "below"
|
||||
},
|
||||
"channelFactor": "rated {factor}",
|
||||
"channel": {
|
||||
"WEIGHT": "weight",
|
||||
"BLOOD_GLUCOSE": "blood glucose",
|
||||
"BLOOD_PRESSURE_SYS": "systolic blood pressure",
|
||||
"BLOOD_PRESSURE_DIA": "diastolic blood pressure",
|
||||
"ACTIVITY_STEPS": "daily steps",
|
||||
"SLEEP_DURATION": "sleep duration",
|
||||
"HEART_RATE_VARIABILITY": "heart-rate variability",
|
||||
"RESTING_HEART_RATE": "resting heart rate",
|
||||
"TIME_IN_DAYLIGHT": "time in daylight",
|
||||
"MOOD": "mood",
|
||||
"PULSE": "resting pulse",
|
||||
"MEDICATION_COMPLIANCE": "medication compliance",
|
||||
"SYMPTOM_SEVERITY": "symptom severity",
|
||||
"ENV_TEMP_MEAN": "daily temperature",
|
||||
"ENV_TEMP_MIN": "overnight low temperature",
|
||||
"ENV_SUNSHINE": "sunshine",
|
||||
"ENV_DAYLIGHT": "daylight",
|
||||
"ENV_PRECIP": "precipitation",
|
||||
"ENV_PRESSURE_MEAN": "barometric pressure",
|
||||
"ENV_PRESSURE_DELTA": "intraday pressure swing"
|
||||
},
|
||||
"daily": {
|
||||
"high": "Higher {behaviour} tends to go with {direction} next-day {outcome} in your data — a pattern worth watching, not a cause.",
|
||||
"moderate": "Higher {behaviour} looks like it goes with {direction} next-day {outcome} in your data, on the evidence so far — a pattern worth watching, not a cause.",
|
||||
"faint": "Higher {behaviour} shows a faint hint, if anything, of {direction} next-day {outcome} in your data — too small to lean on, never a cause."
|
||||
},
|
||||
"lab": {
|
||||
"default": "Higher {lab} readings line up with {direction} {outcome} over the same periods in your data — an association worth watching with your clinician, never a cause.",
|
||||
"faint": "Higher {lab} readings show a faint hint, if anything, of {direction} {outcome} over the same periods in your data — too small to lean on, an association to raise with your clinician, never a cause."
|
||||
},
|
||||
"hypothesis": {
|
||||
"bpCompliance": {
|
||||
"support": "Higher medication compliance lines up with lower systolic readings — a pattern worth watching.",
|
||||
"surprising": "Compliance and systolic move together in your data — surprising; talk to your doctor before adjusting anything.",
|
||||
"none": "Compliance and systolic do not move together strongly in this window."
|
||||
},
|
||||
"moodPulse": {
|
||||
"support": "Lower-mood days line up with higher resting pulse — a pattern worth watching.",
|
||||
"surprising": "Higher-mood days line up with higher resting pulse in your data — surprising.",
|
||||
"none": "Mood and resting pulse do not move together strongly in this window."
|
||||
},
|
||||
"weightWeekday": "{weekday} weights run {delta} kg {direction} your other-day average — a pattern worth watching."
|
||||
}
|
||||
},
|
||||
"noAnalysisYet": "No assessment yet.",
|
||||
"assessmentPreparing": "Preparing your assessment…",
|
||||
"emptyTitle": "Not enough data yet",
|
||||
@@ -2934,7 +2989,24 @@
|
||||
"hrChartTitle": "Heart rate",
|
||||
"hrChartUnavailable": "Per-second heart-rate samples are not stored for this workout.",
|
||||
"notFound": "Workout not found.",
|
||||
"loading": "Loading workout…"
|
||||
"loading": "Loading workout…",
|
||||
"hrFromPulse": "From your heart-rate data around this session",
|
||||
"hrAxisMinutes": "min",
|
||||
"sportAverageLabel": "Your recent average:",
|
||||
"zonesTitle": "Effort zones",
|
||||
"zonesCaption": "How your effort was distributed across heart-rate zones.",
|
||||
"zonesCaptionDevice": "Time in zone as reported by your device.",
|
||||
"exportGpx": "Export GPX",
|
||||
"elevationTitle": "Elevation",
|
||||
"splitsTitle": "Splits",
|
||||
"splitsKm": "km",
|
||||
"splitsTime": "Time",
|
||||
"splitsPace": "Pace",
|
||||
"thatDayTitle": "That day",
|
||||
"dayPulse": "Pulse",
|
||||
"daySleep": "Sleep",
|
||||
"dayMood": "Mood",
|
||||
"insightTitle": "Activity insight"
|
||||
},
|
||||
"sport": {
|
||||
"walking": "Walking",
|
||||
@@ -5105,7 +5177,8 @@
|
||||
"resumeError": "Reconnect failed"
|
||||
},
|
||||
"apiTokens": "API Tokens",
|
||||
"apiTokensDescription": "Create API tokens for external medication intake (e.g., Shortcuts, automations).",
|
||||
"apiTokensDescription": "Review and revoke API tokens for external medication intake. Tokens are issued per medication from the medication's API endpoint toggle.",
|
||||
"tokenMintMovedDescription": "Tokens are issued per medication. Open a medication and enable its API endpoint to get a token scoped to that medication alone.",
|
||||
"tokenNamePlaceholder": "Token name (e.g., iPhone Shortcut)",
|
||||
"tokenCreated": "Token created — copy it now! It won't be shown again.",
|
||||
"tokenCopy": "Copy token",
|
||||
|
||||
+75
-2
@@ -1979,6 +1979,61 @@
|
||||
"notAvailableForCompliance": "Comparación no disponible para el cumplimiento"
|
||||
},
|
||||
"insights": {
|
||||
"correlation": {
|
||||
"direction": {
|
||||
"higher": "subir",
|
||||
"lower": "bajar"
|
||||
},
|
||||
"weekdayDirection": {
|
||||
"above": "por encima de",
|
||||
"below": "por debajo de"
|
||||
},
|
||||
"channelFactor": "la valoración de {factor}",
|
||||
"channel": {
|
||||
"WEIGHT": "el peso",
|
||||
"BLOOD_GLUCOSE": "la glucosa en sangre",
|
||||
"BLOOD_PRESSURE_SYS": "la presión sistólica",
|
||||
"BLOOD_PRESSURE_DIA": "la presión diastólica",
|
||||
"ACTIVITY_STEPS": "los pasos diarios",
|
||||
"SLEEP_DURATION": "la duración del sueño",
|
||||
"HEART_RATE_VARIABILITY": "la variabilidad de la frecuencia cardíaca",
|
||||
"RESTING_HEART_RATE": "la frecuencia cardíaca en reposo",
|
||||
"TIME_IN_DAYLIGHT": "el tiempo con luz natural",
|
||||
"MOOD": "el estado de ánimo",
|
||||
"PULSE": "el pulso en reposo",
|
||||
"MEDICATION_COMPLIANCE": "la adherencia a la medicación",
|
||||
"SYMPTOM_SEVERITY": "la intensidad de los síntomas",
|
||||
"ENV_TEMP_MEAN": "la temperatura diaria",
|
||||
"ENV_TEMP_MIN": "la temperatura mínima nocturna",
|
||||
"ENV_SUNSHINE": "las horas de sol",
|
||||
"ENV_DAYLIGHT": "la luz natural",
|
||||
"ENV_PRECIP": "las precipitaciones",
|
||||
"ENV_PRESSURE_MEAN": "la presión atmosférica",
|
||||
"ENV_PRESSURE_DELTA": "la variación de presión del día"
|
||||
},
|
||||
"daily": {
|
||||
"high": "Cuando {behaviour} es mayor, {outcome} del día siguiente tiende a {direction} en tus datos — un patrón que conviene observar, no una causa.",
|
||||
"moderate": "Cuando {behaviour} es mayor, {outcome} del día siguiente parece tender a {direction}, según los datos disponibles — un patrón que conviene observar, no una causa.",
|
||||
"faint": "Cuando {behaviour} es mayor, apenas se insinúa —si acaso— que {outcome} del día siguiente tienda a {direction} en tus datos — demasiado leve para fiarse, nunca una causa."
|
||||
},
|
||||
"lab": {
|
||||
"default": "Unos valores de {lab} más altos coinciden con que {outcome} tienda a {direction} en los mismos periodos de tus datos — una asociación que conviene vigilar con tu médico, nunca una causa.",
|
||||
"faint": "Unos valores de {lab} más altos apenas insinúan —si acaso— que {outcome} tienda a {direction} en los mismos periodos de tus datos — demasiado leve para fiarse, una asociación que conviene comentar con tu médico, nunca una causa."
|
||||
},
|
||||
"hypothesis": {
|
||||
"bpCompliance": {
|
||||
"support": "Una mayor adherencia a la medicación coincide con valores sistólicos más bajos — un patrón que conviene observar.",
|
||||
"surprising": "La adherencia y la sistólica se mueven juntas en tus datos — sorprendente; consulta con tu médico antes de cambiar nada.",
|
||||
"none": "La adherencia y la sistólica no se mueven juntas de forma marcada en este periodo."
|
||||
},
|
||||
"moodPulse": {
|
||||
"support": "Los días de peor ánimo coinciden con un pulso en reposo más alto — un patrón que conviene observar.",
|
||||
"surprising": "Los días de mejor ánimo coinciden con un pulso en reposo más alto en tus datos — sorprendente.",
|
||||
"none": "El ánimo y el pulso en reposo no se mueven juntos de forma marcada en este periodo."
|
||||
},
|
||||
"weightWeekday": "El peso de los {weekday} está {delta} kg {direction} tu media de los demás días — un patrón que conviene observar."
|
||||
}
|
||||
},
|
||||
"noAnalysisYet": "Aún no hay valoración.",
|
||||
"assessmentPreparing": "Preparando tu valoración…",
|
||||
"emptyTitle": "Aún no hay datos suficientes",
|
||||
@@ -2934,7 +2989,24 @@
|
||||
"hrChartTitle": "Frecuencia cardíaca",
|
||||
"hrChartUnavailable": "Las muestras de frecuencia cardíaca por segundo no se almacenan para este entrenamiento.",
|
||||
"notFound": "Entrenamiento no encontrado.",
|
||||
"loading": "Cargando entrenamiento…"
|
||||
"loading": "Cargando entrenamiento…",
|
||||
"hrFromPulse": "A partir de tus datos de frecuencia cardíaca en torno a esta sesión",
|
||||
"hrAxisMinutes": "min",
|
||||
"sportAverageLabel": "Tu promedio reciente:",
|
||||
"zonesTitle": "Zonas de esfuerzo",
|
||||
"zonesCaption": "Cómo se distribuyó tu esfuerzo entre las zonas de frecuencia cardíaca.",
|
||||
"zonesCaptionDevice": "Tiempo por zona según tu dispositivo.",
|
||||
"exportGpx": "Exportar GPX",
|
||||
"elevationTitle": "Altitud",
|
||||
"splitsTitle": "Parciales",
|
||||
"splitsKm": "km",
|
||||
"splitsTime": "Tiempo",
|
||||
"splitsPace": "Ritmo",
|
||||
"thatDayTitle": "Ese día",
|
||||
"dayPulse": "Pulso",
|
||||
"daySleep": "Sueño",
|
||||
"dayMood": "Ánimo",
|
||||
"insightTitle": "Análisis de la actividad"
|
||||
},
|
||||
"sport": {
|
||||
"walking": "Caminar",
|
||||
@@ -5105,7 +5177,8 @@
|
||||
"resumeError": "Reconexión fallida"
|
||||
},
|
||||
"apiTokens": "Tokens de API",
|
||||
"apiTokensDescription": "Crea tokens de API para registrar tomas de medicación externas (p. ej., Atajos, automatizaciones).",
|
||||
"apiTokensDescription": "Revisa y revoca tokens de API para el registro externo de tomas de medicación. Los tokens se emiten por medicamento desde su interruptor de endpoint de API.",
|
||||
"tokenMintMovedDescription": "Los tokens se emiten por medicamento. Abre un medicamento y activa su endpoint de API para obtener un token limitado únicamente a ese medicamento.",
|
||||
"tokenNamePlaceholder": "Nombre del token (p. ej., Atajo de iPhone)",
|
||||
"tokenCreated": "Token creado — ¡cópialo ahora! No se mostrará de nuevo.",
|
||||
"tokenCopy": "Copiar token",
|
||||
|
||||
+75
-2
@@ -1979,6 +1979,61 @@
|
||||
"notAvailableForCompliance": "Comparaison indisponible pour l’observance"
|
||||
},
|
||||
"insights": {
|
||||
"correlation": {
|
||||
"direction": {
|
||||
"higher": "augmenter",
|
||||
"lower": "diminuer"
|
||||
},
|
||||
"weekdayDirection": {
|
||||
"above": "au-dessus de",
|
||||
"below": "en dessous de"
|
||||
},
|
||||
"channelFactor": "l'évaluation de {factor}",
|
||||
"channel": {
|
||||
"WEIGHT": "le poids",
|
||||
"BLOOD_GLUCOSE": "la glycémie",
|
||||
"BLOOD_PRESSURE_SYS": "la pression systolique",
|
||||
"BLOOD_PRESSURE_DIA": "la pression diastolique",
|
||||
"ACTIVITY_STEPS": "le nombre de pas quotidien",
|
||||
"SLEEP_DURATION": "la durée de sommeil",
|
||||
"HEART_RATE_VARIABILITY": "la variabilité de la fréquence cardiaque",
|
||||
"RESTING_HEART_RATE": "la fréquence cardiaque au repos",
|
||||
"TIME_IN_DAYLIGHT": "le temps passé à la lumière du jour",
|
||||
"MOOD": "l'humeur",
|
||||
"PULSE": "le pouls au repos",
|
||||
"MEDICATION_COMPLIANCE": "l'observance des médicaments",
|
||||
"SYMPTOM_SEVERITY": "la sévérité des symptômes",
|
||||
"ENV_TEMP_MEAN": "la température quotidienne",
|
||||
"ENV_TEMP_MIN": "la température minimale nocturne",
|
||||
"ENV_SUNSHINE": "l'ensoleillement",
|
||||
"ENV_DAYLIGHT": "la lumière du jour",
|
||||
"ENV_PRECIP": "les précipitations",
|
||||
"ENV_PRESSURE_MEAN": "la pression atmosphérique",
|
||||
"ENV_PRESSURE_DELTA": "la variation de pression sur la journée"
|
||||
},
|
||||
"daily": {
|
||||
"high": "Quand {behaviour} monte, {outcome} du lendemain tend à {direction} dans tes données — une tendance à surveiller, pas une cause.",
|
||||
"moderate": "Quand {behaviour} monte, {outcome} du lendemain semble avoir tendance à {direction}, au vu des données actuelles — une tendance à surveiller, pas une cause.",
|
||||
"faint": "Quand {behaviour} monte, tout au plus se dessine un faible indice que {outcome} du lendemain tende à {direction} dans tes données — trop ténu pour s'y fier, jamais une cause."
|
||||
},
|
||||
"lab": {
|
||||
"default": "Des valeurs de {lab} plus élevées vont de pair avec {outcome} qui tend à {direction} sur les mêmes périodes dans tes données — une association à surveiller avec ton médecin, jamais une cause.",
|
||||
"faint": "Des valeurs de {lab} plus élevées laissent tout au plus entrevoir que {outcome} tende à {direction} sur les mêmes périodes dans tes données — trop ténu pour s'y fier, une association à évoquer avec ton médecin, jamais une cause."
|
||||
},
|
||||
"hypothesis": {
|
||||
"bpCompliance": {
|
||||
"support": "Une meilleure observance des médicaments va de pair avec des valeurs systoliques plus basses — une tendance à surveiller.",
|
||||
"surprising": "L'observance et la systolique évoluent ensemble dans tes données — surprenant ; parles-en à ton médecin avant de changer quoi que ce soit.",
|
||||
"none": "L'observance et la systolique n'évoluent pas nettement ensemble sur cette période."
|
||||
},
|
||||
"moodPulse": {
|
||||
"support": "Les jours de moral plus bas vont de pair avec un pouls au repos plus élevé — une tendance à surveiller.",
|
||||
"surprising": "Les jours de meilleur moral vont de pair avec un pouls au repos plus élevé dans tes données — surprenant.",
|
||||
"none": "L'humeur et le pouls au repos n'évoluent pas nettement ensemble sur cette période."
|
||||
},
|
||||
"weightWeekday": "Le poids du {weekday} est {delta} kg {direction} ta moyenne des autres jours — une tendance à surveiller."
|
||||
}
|
||||
},
|
||||
"noAnalysisYet": "Pas encore d’évaluation.",
|
||||
"assessmentPreparing": "Préparation de votre évaluation…",
|
||||
"emptyTitle": "Pas encore assez de données",
|
||||
@@ -2934,7 +2989,24 @@
|
||||
"hrChartTitle": "Fréquence cardiaque",
|
||||
"hrChartUnavailable": "Les échantillons de fréquence cardiaque à la seconde ne sont pas stockés pour cet entraînement.",
|
||||
"notFound": "Entraînement introuvable.",
|
||||
"loading": "Chargement de l'entraînement…"
|
||||
"loading": "Chargement de l'entraînement…",
|
||||
"hrFromPulse": "À partir de vos données de fréquence cardiaque autour de cette séance",
|
||||
"hrAxisMinutes": "min",
|
||||
"sportAverageLabel": "Votre moyenne récente :",
|
||||
"zonesTitle": "Zones d'effort",
|
||||
"zonesCaption": "Répartition de votre effort entre les zones de fréquence cardiaque.",
|
||||
"zonesCaptionDevice": "Temps par zone indiqué par votre appareil.",
|
||||
"exportGpx": "Exporter le GPX",
|
||||
"elevationTitle": "Dénivelé",
|
||||
"splitsTitle": "Temps par kilomètre",
|
||||
"splitsKm": "km",
|
||||
"splitsTime": "Temps",
|
||||
"splitsPace": "Allure",
|
||||
"thatDayTitle": "Ce jour-là",
|
||||
"dayPulse": "Pouls",
|
||||
"daySleep": "Sommeil",
|
||||
"dayMood": "Humeur",
|
||||
"insightTitle": "Analyse de l'activité"
|
||||
},
|
||||
"sport": {
|
||||
"walking": "Marche",
|
||||
@@ -5105,7 +5177,8 @@
|
||||
"resumeError": "Échec de la reconnexion"
|
||||
},
|
||||
"apiTokens": "Jetons API",
|
||||
"apiTokensDescription": "Créez des jetons API pour enregistrer des prises de médicaments externes (par exemple Raccourcis, automatisations).",
|
||||
"apiTokensDescription": "Consultez et révoquez les jetons API pour l’enregistrement externe des prises de médicaments. Les jetons sont émis par médicament depuis son interrupteur de point de terminaison API.",
|
||||
"tokenMintMovedDescription": "Les jetons sont émis par médicament. Ouvrez un médicament et activez son point de terminaison API pour obtenir un jeton limité à ce seul médicament.",
|
||||
"tokenNamePlaceholder": "Nom du jeton (par exemple Raccourci iPhone)",
|
||||
"tokenCreated": "Jeton créé — copiez-le maintenant ! Il ne sera plus affiché.",
|
||||
"tokenCopy": "Copier le jeton",
|
||||
|
||||
+75
-2
@@ -1979,6 +1979,61 @@
|
||||
"notAvailableForCompliance": "Confronto non disponibile per l’aderenza"
|
||||
},
|
||||
"insights": {
|
||||
"correlation": {
|
||||
"direction": {
|
||||
"higher": "salire",
|
||||
"lower": "scendere"
|
||||
},
|
||||
"weekdayDirection": {
|
||||
"above": "sopra",
|
||||
"below": "sotto"
|
||||
},
|
||||
"channelFactor": "la valutazione di {factor}",
|
||||
"channel": {
|
||||
"WEIGHT": "il peso",
|
||||
"BLOOD_GLUCOSE": "la glicemia",
|
||||
"BLOOD_PRESSURE_SYS": "la pressione sistolica",
|
||||
"BLOOD_PRESSURE_DIA": "la pressione diastolica",
|
||||
"ACTIVITY_STEPS": "i passi giornalieri",
|
||||
"SLEEP_DURATION": "la durata del sonno",
|
||||
"HEART_RATE_VARIABILITY": "la variabilità della frequenza cardiaca",
|
||||
"RESTING_HEART_RATE": "la frequenza cardiaca a riposo",
|
||||
"TIME_IN_DAYLIGHT": "il tempo alla luce del giorno",
|
||||
"MOOD": "l'umore",
|
||||
"PULSE": "il polso a riposo",
|
||||
"MEDICATION_COMPLIANCE": "l'aderenza ai farmaci",
|
||||
"SYMPTOM_SEVERITY": "la gravità dei sintomi",
|
||||
"ENV_TEMP_MEAN": "la temperatura giornaliera",
|
||||
"ENV_TEMP_MIN": "la temperatura minima notturna",
|
||||
"ENV_SUNSHINE": "il soleggiamento",
|
||||
"ENV_DAYLIGHT": "la luce del giorno",
|
||||
"ENV_PRECIP": "le precipitazioni",
|
||||
"ENV_PRESSURE_MEAN": "la pressione atmosferica",
|
||||
"ENV_PRESSURE_DELTA": "l'oscillazione di pressione nella giornata"
|
||||
},
|
||||
"daily": {
|
||||
"high": "Quando {behaviour} sale, {outcome} del giorno dopo tende a {direction} nei tuoi dati — un andamento da osservare, non una causa.",
|
||||
"moderate": "Quando {behaviour} sale, {outcome} del giorno dopo sembra tendere a {direction}, per quanto emerge finora — un andamento da osservare, non una causa.",
|
||||
"faint": "Quando {behaviour} sale, al più si intravede un debole indizio che {outcome} del giorno dopo tenda a {direction} nei tuoi dati — troppo lieve per farci affidamento, mai una causa."
|
||||
},
|
||||
"lab": {
|
||||
"default": "Valori di {lab} più alti si accompagnano al fatto che {outcome} tende a {direction} negli stessi periodi dei tuoi dati — un'associazione da tenere d'occhio con il tuo medico, mai una causa.",
|
||||
"faint": "Valori di {lab} più alti lasciano al più intravedere che {outcome} tenda a {direction} negli stessi periodi dei tuoi dati — troppo lieve per farci affidamento, un'associazione da discutere con il tuo medico, mai una causa."
|
||||
},
|
||||
"hypothesis": {
|
||||
"bpCompliance": {
|
||||
"support": "Una maggiore aderenza ai farmaci si accompagna a valori sistolici più bassi — un andamento da osservare.",
|
||||
"surprising": "L'aderenza e la sistolica si muovono insieme nei tuoi dati — sorprendente; parlane con il tuo medico prima di modificare qualcosa.",
|
||||
"none": "L'aderenza e la sistolica non si muovono insieme in modo marcato in questo periodo."
|
||||
},
|
||||
"moodPulse": {
|
||||
"support": "I giorni di umore più basso si accompagnano a un polso a riposo più alto — un andamento da osservare.",
|
||||
"surprising": "I giorni di umore migliore si accompagnano a un polso a riposo più alto nei tuoi dati — sorprendente.",
|
||||
"none": "L'umore e il polso a riposo non si muovono insieme in modo marcato in questo periodo."
|
||||
},
|
||||
"weightWeekday": "Il peso del {weekday} è {delta} kg {direction} la tua media degli altri giorni — un andamento da osservare."
|
||||
}
|
||||
},
|
||||
"noAnalysisYet": "Ancora nessuna valutazione.",
|
||||
"assessmentPreparing": "Preparazione della valutazione…",
|
||||
"emptyTitle": "Dati ancora insufficienti",
|
||||
@@ -2934,7 +2989,24 @@
|
||||
"hrChartTitle": "Frequenza cardiaca",
|
||||
"hrChartUnavailable": "I campioni di frequenza cardiaca al secondo non vengono salvati per questo allenamento.",
|
||||
"notFound": "Allenamento non trovato.",
|
||||
"loading": "Caricamento allenamento…"
|
||||
"loading": "Caricamento allenamento…",
|
||||
"hrFromPulse": "Dai tuoi dati di frequenza cardiaca intorno a questa sessione",
|
||||
"hrAxisMinutes": "min",
|
||||
"sportAverageLabel": "La tua media recente:",
|
||||
"zonesTitle": "Zone di sforzo",
|
||||
"zonesCaption": "Come si è distribuito il tuo sforzo tra le zone di frequenza cardiaca.",
|
||||
"zonesCaptionDevice": "Tempo per zona riportato dal tuo dispositivo.",
|
||||
"exportGpx": "Esporta GPX",
|
||||
"elevationTitle": "Dislivello",
|
||||
"splitsTitle": "Parziali",
|
||||
"splitsKm": "km",
|
||||
"splitsTime": "Tempo",
|
||||
"splitsPace": "Ritmo",
|
||||
"thatDayTitle": "Quel giorno",
|
||||
"dayPulse": "Battito",
|
||||
"daySleep": "Sonno",
|
||||
"dayMood": "Umore",
|
||||
"insightTitle": "Analisi dell'attività"
|
||||
},
|
||||
"sport": {
|
||||
"walking": "Camminata",
|
||||
@@ -5105,7 +5177,8 @@
|
||||
"resumeError": "Riconnessione fallita"
|
||||
},
|
||||
"apiTokens": "Token API",
|
||||
"apiTokensDescription": "Crea token API per registrare assunzioni di farmaci esterne (ad es. Comandi rapidi, automazioni).",
|
||||
"apiTokensDescription": "Rivedi e revoca i token API per la registrazione esterna delle assunzioni di farmaci. I token vengono emessi per singolo farmaco dal relativo interruttore dell’endpoint API.",
|
||||
"tokenMintMovedDescription": "I token vengono emessi per singolo farmaco. Apri un farmaco e attiva il suo endpoint API per ottenere un token valido solo per quel farmaco.",
|
||||
"tokenNamePlaceholder": "Nome del token (ad es. Comando rapido iPhone)",
|
||||
"tokenCreated": "Token creato — copialo ora! Non verrà mostrato di nuovo.",
|
||||
"tokenCopy": "Copia token",
|
||||
|
||||
+75
-2
@@ -1979,6 +1979,61 @@
|
||||
"notAvailableForCompliance": "Porównanie niedostępne dla przestrzegania"
|
||||
},
|
||||
"insights": {
|
||||
"correlation": {
|
||||
"direction": {
|
||||
"higher": "wzrostu",
|
||||
"lower": "spadku"
|
||||
},
|
||||
"weekdayDirection": {
|
||||
"above": "powyżej",
|
||||
"below": "poniżej"
|
||||
},
|
||||
"channelFactor": "ocena czynnika {factor}",
|
||||
"channel": {
|
||||
"WEIGHT": "waga",
|
||||
"BLOOD_GLUCOSE": "poziom cukru we krwi",
|
||||
"BLOOD_PRESSURE_SYS": "ciśnienie skurczowe",
|
||||
"BLOOD_PRESSURE_DIA": "ciśnienie rozkurczowe",
|
||||
"ACTIVITY_STEPS": "dzienna liczba kroków",
|
||||
"SLEEP_DURATION": "długość snu",
|
||||
"HEART_RATE_VARIABILITY": "zmienność rytmu serca",
|
||||
"RESTING_HEART_RATE": "spoczynkowe tętno",
|
||||
"TIME_IN_DAYLIGHT": "czas w świetle dziennym",
|
||||
"MOOD": "nastrój",
|
||||
"PULSE": "tętno spoczynkowe",
|
||||
"MEDICATION_COMPLIANCE": "przyjmowanie leków",
|
||||
"SYMPTOM_SEVERITY": "nasilenie objawów",
|
||||
"ENV_TEMP_MEAN": "temperatura dobowa",
|
||||
"ENV_TEMP_MIN": "nocna temperatura minimalna",
|
||||
"ENV_SUNSHINE": "nasłonecznienie",
|
||||
"ENV_DAYLIGHT": "światło dzienne",
|
||||
"ENV_PRECIP": "opady",
|
||||
"ENV_PRESSURE_MEAN": "ciśnienie atmosferyczne",
|
||||
"ENV_PRESSURE_DELTA": "dobowa zmiana ciśnienia"
|
||||
},
|
||||
"daily": {
|
||||
"high": "Gdy {behaviour} rośnie, {outcome} następnego dnia zwykle wykazuje tendencję do {direction} w Twoich danych — wzorzec wart obserwacji, nie przyczyna.",
|
||||
"moderate": "Gdy {behaviour} rośnie, {outcome} następnego dnia wydaje się wykazywać tendencję do {direction}, na podstawie dostępnych danych — wzorzec wart obserwacji, nie przyczyna.",
|
||||
"faint": "Gdy {behaviour} rośnie, w Twoich danych pojawia się co najwyżej słaba wskazówka, że {outcome} następnego dnia wykazuje tendencję do {direction} — zbyt słaba, by na niej polegać, nigdy przyczyna."
|
||||
},
|
||||
"lab": {
|
||||
"default": "Wyższe wartości {lab} idą w parze z tym, że {outcome} wykazuje tendencję do {direction} w tych samych okresach w Twoich danych — zależność, którą warto obserwować z lekarzem, nigdy przyczyna.",
|
||||
"faint": "Wyższe wartości {lab} co najwyżej słabo sugerują, że {outcome} wykazuje tendencję do {direction} w tych samych okresach w Twoich danych — zbyt słabo, by na tym polegać, zależność do omówienia z lekarzem, nigdy przyczyna."
|
||||
},
|
||||
"hypothesis": {
|
||||
"bpCompliance": {
|
||||
"support": "Lepsze przyjmowanie leków idzie w parze z niższymi wartościami skurczowymi — wzorzec wart obserwacji.",
|
||||
"surprising": "Przyjmowanie leków i ciśnienie skurczowe zmieniają się razem w Twoich danych — zaskakujące; skonsultuj się z lekarzem, zanim coś zmienisz.",
|
||||
"none": "Przyjmowanie leków i ciśnienie skurczowe nie zmieniają się wyraźnie razem w tym okresie."
|
||||
},
|
||||
"moodPulse": {
|
||||
"support": "Dni z gorszym nastrojem idą w parze z wyższym tętnem spoczynkowym — wzorzec wart obserwacji.",
|
||||
"surprising": "Dni z lepszym nastrojem idą w parze z wyższym tętnem spoczynkowym w Twoich danych — zaskakujące.",
|
||||
"none": "Nastrój i tętno spoczynkowe nie zmieniają się wyraźnie razem w tym okresie."
|
||||
},
|
||||
"weightWeekday": "Waga w {weekday} jest {delta} kg {direction} Twojej średniej z pozostałych dni — wzorzec wart obserwacji."
|
||||
}
|
||||
},
|
||||
"noAnalysisYet": "Brak oceny.",
|
||||
"assessmentPreparing": "Przygotowywanie oceny…",
|
||||
"emptyTitle": "Jeszcze za mało danych",
|
||||
@@ -2934,7 +2989,24 @@
|
||||
"hrChartTitle": "Tętno",
|
||||
"hrChartUnavailable": "Próbki tętna co sekundę nie są zapisywane dla tego treningu.",
|
||||
"notFound": "Nie znaleziono treningu.",
|
||||
"loading": "Wczytywanie treningu…"
|
||||
"loading": "Wczytywanie treningu…",
|
||||
"hrFromPulse": "Na podstawie Twoich danych tętna z okolic tej sesji",
|
||||
"hrAxisMinutes": "min",
|
||||
"sportAverageLabel": "Twoja ostatnia średnia:",
|
||||
"zonesTitle": "Strefy wysiłku",
|
||||
"zonesCaption": "Jak Twój wysiłek rozłożył się na strefy tętna.",
|
||||
"zonesCaptionDevice": "Czas w strefach według Twojego urządzenia.",
|
||||
"exportGpx": "Eksportuj GPX",
|
||||
"elevationTitle": "Wznios",
|
||||
"splitsTitle": "Międzyczasy",
|
||||
"splitsKm": "km",
|
||||
"splitsTime": "Czas",
|
||||
"splitsPace": "Tempo",
|
||||
"thatDayTitle": "Tego dnia",
|
||||
"dayPulse": "Tętno",
|
||||
"daySleep": "Sen",
|
||||
"dayMood": "Nastrój",
|
||||
"insightTitle": "Analiza aktywności"
|
||||
},
|
||||
"sport": {
|
||||
"walking": "Marsz",
|
||||
@@ -5105,7 +5177,8 @@
|
||||
"resumeError": "Ponowne połączenie nieudane"
|
||||
},
|
||||
"apiTokens": "Tokeny API",
|
||||
"apiTokensDescription": "Twórz tokeny API do zewnętrznego rejestrowania przyjęć leków (np. Skróty, automatyzacje).",
|
||||
"apiTokensDescription": "Przeglądaj i unieważniaj tokeny API do zewnętrznego rejestrowania przyjęć leków. Tokeny są wydawane dla pojedynczego leku z jego przełącznika punktu końcowego API.",
|
||||
"tokenMintMovedDescription": "Tokeny są wydawane dla pojedynczego leku. Otwórz lek i włącz jego punkt końcowy API, aby otrzymać token obejmujący wyłącznie ten lek.",
|
||||
"tokenNamePlaceholder": "Nazwa tokena (np. Skrót iPhone)",
|
||||
"tokenCreated": "Token utworzony — skopiuj go teraz! Nie zostanie pokazany ponownie.",
|
||||
"tokenCopy": "Kopiuj token",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.30.6",
|
||||
"version": "1.30.22",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
-- Native OIDC SSO session handoff (iOS#49).
|
||||
--
|
||||
-- One-time handoff codes bridging the browser-context OIDC callback to the
|
||||
-- cookie-less native token exchange. The web OIDC flow ends in a session
|
||||
-- cookie a Bearer-transport native client can never consume; this table lets
|
||||
-- the callback mint an opaque, PKCE-locked, single-use code that the app
|
||||
-- exchanges for the standard native token bundle at
|
||||
-- POST /api/auth/oidc/native/token.
|
||||
--
|
||||
-- The raw code (`hlh_<…>`) rides ONLY the custom-scheme redirect; only its
|
||||
-- HMAC-SHA256 hash is stored, mirroring the refresh-token / MFA-challenge
|
||||
-- hash-at-rest posture. `issued_refresh_token_hash` records the pair minted at
|
||||
-- exchange so a replay (a second presentation after consumption) can revoke
|
||||
-- exactly that family member — refresh-token reuse-detection reach-back.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "oidc_native_handoffs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"code_hash" TEXT NOT NULL,
|
||||
"code_challenge" TEXT NOT NULL,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"consumed_at" TIMESTAMP(3),
|
||||
"issued_refresh_token_hash" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"ip_address" TEXT,
|
||||
"user_agent" TEXT,
|
||||
CONSTRAINT "oidc_native_handoffs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex — the exchange loads a row by the code's hash; unique so a hash
|
||||
-- collision can never resolve to two live codes.
|
||||
CREATE UNIQUE INDEX "oidc_native_handoffs_code_hash_key"
|
||||
ON "oidc_native_handoffs" ("code_hash");
|
||||
|
||||
-- CreateIndex — per-user audit scans + the FK-cascade delete path.
|
||||
CREATE INDEX "oidc_native_handoffs_user_id_idx"
|
||||
ON "oidc_native_handoffs" ("user_id");
|
||||
|
||||
-- CreateIndex — the daily cleanup sweep filters on `expires_at`.
|
||||
CREATE INDEX "oidc_native_handoffs_expires_at_idx"
|
||||
ON "oidc_native_handoffs" ("expires_at");
|
||||
|
||||
-- AddForeignKey — deleting a user cascades their handoff rows away.
|
||||
ALTER TABLE "oidc_native_handoffs"
|
||||
ADD CONSTRAINT "oidc_native_handoffs_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- v1.30.12 perf — index the two medication-intake read shapes the dashboard
|
||||
-- "meds today" builder fires that could not index-seek before: the sibling
|
||||
-- reads filtering (user_id, scheduled_for) without medication_id, and the
|
||||
-- per-medication last-taken group-by that scanned the whole intake history.
|
||||
-- Additive; no data change.
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "medication_intake_events_user_id_scheduled_for_idx" ON "medication_intake_events"("user_id", "scheduled_for");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "medication_intake_events_user_id_medication_id_taken_at_idx" ON "medication_intake_events"("user_id", "medication_id", "taken_at");
|
||||
+42
-1
@@ -667,6 +667,9 @@ model User {
|
||||
// authorization for an MCP client, audience-bound to health:read/health:write.
|
||||
mcpOAuthConnections McpOAuthConnection[]
|
||||
refreshTokens RefreshToken[]
|
||||
// v1.30.x — one-time native OIDC SSO handoff codes. Each row bridges the
|
||||
// browser-context OIDC callback to the cookie-less native token exchange.
|
||||
oidcNativeHandoffs OidcNativeHandoff[]
|
||||
auditLogs AuditLog[]
|
||||
withingsConnection WithingsConnection?
|
||||
// v1.4.47 W6 — short-lived `(nonce → userId)` ledger backing the
|
||||
@@ -2730,6 +2733,12 @@ model MedicationIntakeEvent {
|
||||
// v1.7.0 sync — keyset support for the `/api/sync/changes` intake feed
|
||||
// (`where userId` ordered by `(updatedAt asc, id asc)`).
|
||||
@@index([userId, updatedAt, id])
|
||||
// v1.30.12 perf — the dashboard "meds today" read fires sibling queries that
|
||||
// filter `(userId, scheduledFor)` WITHOUT `medicationId` (the composite above
|
||||
// cannot range-seek those) and a per-medication `_max(takenAt)` group-by that
|
||||
// scanned the whole intake history. These two let both index-seek instead.
|
||||
@@index([userId, scheduledFor])
|
||||
@@index([userId, medicationId, takenAt])
|
||||
@@map("medication_intake_events")
|
||||
}
|
||||
|
||||
@@ -3214,6 +3223,37 @@ model RefreshToken {
|
||||
@@map("refresh_tokens")
|
||||
}
|
||||
|
||||
// v1.30.x — one-time native OIDC handoff code. Bridges the browser-context
|
||||
// OIDC callback to the cookie-less native token exchange. The raw value
|
||||
// `hlh_<…>` rides ONLY the custom-scheme redirect (healthlog://oidc-callback);
|
||||
// only its HMAC-SHA256 hash (hashToken, keyed by API_TOKEN_HMAC_KEY) is stored,
|
||||
// so a database dump reconstructs nothing. Single-use via the guarded consume
|
||||
// (`WHERE consumed_at IS NULL`); a second presentation after consumption revokes
|
||||
// the token pair the first exchange issued (refresh-token reuse-detection
|
||||
// posture). 90-second TTL enforced at read; a daily sweep prunes stale rows.
|
||||
model OidcNativeHandoff {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
codeHash String @unique @map("code_hash") // hashToken(hlh_<…>)
|
||||
/// The app's S256 PKCE challenge, bound at mint; the exchange verifies
|
||||
/// s256(code_verifier) against it (constant-time) before consuming.
|
||||
codeChallenge String @map("code_challenge")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
consumedAt DateTime? @map("consumed_at")
|
||||
/// hashToken of the refresh token issued at exchange — replay containment
|
||||
/// revokes exactly this family member (+ its paired access token).
|
||||
issuedRefreshTokenHash String? @map("issued_refresh_token_hash")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
ipAddress String? @map("ip_address")
|
||||
userAgent String? @map("user_agent")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
@@map("oidc_native_handoffs")
|
||||
}
|
||||
|
||||
// ─── Withings ──────────────────────────────────────────────
|
||||
|
||||
model WithingsConnection {
|
||||
@@ -5186,7 +5226,8 @@ model InsightNarrative {
|
||||
userId String @map("user_id")
|
||||
/// "week" | "month".
|
||||
period String
|
||||
/// Resolved UI locale of the prose ("de" | "en").
|
||||
/// Resolved UI locale of the prose — any shipped UI locale, not just de/en:
|
||||
/// the prose is generated per locale, so the row is keyed by it.
|
||||
locale String
|
||||
/// UTC YYYY-MM-DD boundary the narrative was generated for.
|
||||
dateKey String @map("date_key")
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ 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.30.6";
|
||||
/* @sw-version-fallback */ "v1.30.22";
|
||||
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/*`
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Structural guard on the AI consent boundary.
|
||||
*
|
||||
* Two surfaces shipped without a consent check — the self-context clarifying
|
||||
* questions and the AI-composed proactive nudge — for the same reason: nothing
|
||||
* connected "I am holding a live provider instance" to "I must have checked a
|
||||
* receipt first". Behavioural tests close the two we found. This closes the
|
||||
* class, so the third one cannot appear quietly.
|
||||
*
|
||||
* The invariant: a module that resolves a provider INSTANCE for a user
|
||||
* (`resolveProvider` / `resolveProviderChain` / `resolveProviderForTest`) holds
|
||||
* something it can call `generateCompletion` on. Every such module must also
|
||||
* pull in a consent helper — or sit on the allowlist below with a written
|
||||
* reason a reviewer signed off.
|
||||
*
|
||||
* WHAT THIS CANNOT PROVE — read this before trusting it:
|
||||
*
|
||||
* - It is an IMPORT-level check. It proves a consent helper is in the
|
||||
* module's scope, NOT that the gate is reached on every path through the
|
||||
* file. A module could import `hasActiveConsentForSurface`, call it on one
|
||||
* branch, and egress ungated on another; this guard stays green.
|
||||
* - It cannot prove ORDER. A gate called after the provider call, or after
|
||||
* the snapshot is built, passes here and is still wrong.
|
||||
* - It cannot prove the SURFACE is right. Passing `"insights"` where the data
|
||||
* is a Coach snapshot passes here.
|
||||
* - It cannot prove an allowlist entry is HONEST. A reviewer who waves
|
||||
* through a new entry with a plausible-sounding reason defeats it entirely.
|
||||
* The allowlist is a review checkpoint, not a proof.
|
||||
*
|
||||
* What it does buy: a NEW ungated provider call site cannot land silently. The
|
||||
* author must either wire a consent helper or edit this file, and editing this
|
||||
* file puts the decision in front of a reviewer. That is the whole claim.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, sep } from "node:path";
|
||||
import { globSync } from "node:fs";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
/** Every non-test `.ts` / `.tsx` under `src/`, minus the generated client. */
|
||||
function sourceFiles(): string[] {
|
||||
return globSync("**/*.{ts,tsx}", { cwd: SRC })
|
||||
.filter(
|
||||
(p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"),
|
||||
)
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
||||
.map((p) => p.split(sep).join("/"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function read(rel: string): string {
|
||||
return readFileSync(join(SRC, rel), "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* The helpers that hand back a live, callable provider for a user. Deliberately
|
||||
* NOT including the presence-only probes (`hasAnyConfiguredProvider`,
|
||||
* `resolveProviderAvailability`, `userRowHasProviderCredential`): those never
|
||||
* decrypt a key or construct a client, so they cannot egress anything.
|
||||
*/
|
||||
const RESOLUTION_HELPERS = [
|
||||
"resolveProvider",
|
||||
"resolveProviderChain",
|
||||
"resolveProviderForTest",
|
||||
] as const;
|
||||
|
||||
/** The two sanctioned consent APIs. Either satisfies the guard. */
|
||||
const CONSENT_HELPERS = [
|
||||
// Chain-shaped: throw-form and predicate-form.
|
||||
"assertConsentForChain",
|
||||
"chainRequiresServerManagedConsent",
|
||||
"hasActiveConsentForSurface",
|
||||
// Document-class, pick-shaped.
|
||||
"assertDocumentEgressConsent",
|
||||
"isExternalDocumentEgress",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Extract the named bindings a file imports from a given module specifier.
|
||||
* Handles the multi-line `import { a, b } from "…"` form the codebase uses.
|
||||
* Comments mentioning a helper are NOT import statements and do not count —
|
||||
* that distinction is the whole point of parsing the import rather than
|
||||
* grepping the file.
|
||||
*/
|
||||
function importedNamesFrom(source: string, moduleSpecifier: string): string[] {
|
||||
const re = new RegExp(
|
||||
`import\\s+(?:type\\s+)?\\{([^}]*)\\}\\s*from\\s*["']${moduleSpecifier.replace(
|
||||
/[/\\^$*+?.()|[\]{}]/g,
|
||||
"\\$&",
|
||||
)}["']`,
|
||||
"g",
|
||||
);
|
||||
const names: string[] = [];
|
||||
for (const match of source.matchAll(re)) {
|
||||
for (const raw of match[1].split(",")) {
|
||||
const name = raw
|
||||
.trim()
|
||||
.replace(/^type\s+/, "")
|
||||
.split(/\s+as\s+/)[0];
|
||||
if (name) names.push(name.trim());
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function resolvesAProvider(source: string): boolean {
|
||||
const imported = importedNamesFrom(source, "@/lib/ai/provider");
|
||||
return RESOLUTION_HELPERS.some((h) => imported.includes(h));
|
||||
}
|
||||
|
||||
function importsAConsentHelper(source: string): boolean {
|
||||
const imported = importedNamesFrom(source, "@/lib/ai/consent-guard");
|
||||
return CONSENT_HELPERS.some((h) => imported.includes(h));
|
||||
}
|
||||
|
||||
/**
|
||||
* Modules that resolve a provider and deliberately carry no consent helper.
|
||||
* Every entry needs a reason that survives a reviewer asking "what PHI does
|
||||
* this send?" — the answer here is "none" in every case.
|
||||
*
|
||||
* `lib/ai/provider.ts` needs no entry: it DEFINES the resolution helpers rather
|
||||
* than importing them, so the detector never flags it. That is correct — the
|
||||
* resolver constructs clients and never sends a prompt, and gating it would
|
||||
* invert the layering.
|
||||
*/
|
||||
const ALLOWLIST: Record<string, string> = {
|
||||
// --- Capability / config probes: resolve a chain, never call it. ---
|
||||
"lib/labs/ocr-capability.ts":
|
||||
"Resolves the chain to REPORT which entry could do vision/text OCR (mode, reason, pdfSupported). Makes no completion call. Every consumer that then egresses a document is gated at its own call site with assertDocumentEgressConsent.",
|
||||
"app/api/insights/provider-chain/route.ts":
|
||||
"Lists the user's configured chain and which entry is active, for the settings UI. Constructs clients but never calls generateCompletion. No health data in the request or the response.",
|
||||
"app/api/insights/comprehensive/route.ts":
|
||||
"Uses the resolver only as a boolean probe ((await resolveProvider(userId)).type !== 'none') to set hasProvider on the response. The generation path itself lives in lib/insights/comprehensive-generate.ts, which is gated.",
|
||||
|
||||
// --- Connection test: a real provider call, but with no user data. ---
|
||||
"app/api/ai/test/route.ts":
|
||||
"Sends a fixed synthetic prompt to verify credentials resolve and the endpoint answers. No health data is read, so there is no PHI egress for a receipt to authorise. Bounded by its own 5/min rate limit.",
|
||||
};
|
||||
|
||||
describe("every provider-resolving module carries a consent helper", () => {
|
||||
const resolvers = sourceFiles().filter((rel) => resolvesAProvider(read(rel)));
|
||||
|
||||
it("finds the provider-resolving modules at all (the guard is wired up)", () => {
|
||||
// A refactor that renames the resolver or moves the module would otherwise
|
||||
// silently empty the set and make every assertion below vacuous.
|
||||
expect(resolvers.length).toBeGreaterThanOrEqual(8);
|
||||
expect(resolvers).toContain("lib/ai/coach/self-context-questions.ts");
|
||||
expect(resolvers).toContain("lib/jobs/coach-nudge-ai.ts");
|
||||
});
|
||||
|
||||
it("gates every provider-resolving module that is not explicitly allowlisted", () => {
|
||||
const ungated = resolvers.filter(
|
||||
(rel) => !importsAConsentHelper(read(rel)) && !(rel in ALLOWLIST),
|
||||
);
|
||||
expect(ungated).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the allowlist free of stale entries", () => {
|
||||
// An entry that no longer resolves a provider is dead weight that hides
|
||||
// the next real one. Delete it rather than letting it rot.
|
||||
const stale = Object.keys(ALLOWLIST).filter(
|
||||
(rel) => !resolvers.includes(rel),
|
||||
);
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
|
||||
it("requires a substantive reason on every allowlist entry", () => {
|
||||
const thin = Object.entries(ALLOWLIST)
|
||||
.filter(([, reason]) => reason.trim().length < 40)
|
||||
.map(([rel]) => rel);
|
||||
expect(thin).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the consent guard itself stays the single source of the policy", () => {
|
||||
it("keeps the server-managed provider set defined in exactly one place", () => {
|
||||
// Both operator-credential tags must be classified in consent-guard.ts and
|
||||
// nowhere else — a second copy of this list is how the policy drifts.
|
||||
const guard = read("lib/ai/consent-guard.ts");
|
||||
expect(guard).toContain('"admin-openai"');
|
||||
expect(guard).toContain('"admin-codex"');
|
||||
|
||||
const copies = sourceFiles().filter(
|
||||
(rel) =>
|
||||
rel !== "lib/ai/consent-guard.ts" &&
|
||||
/SERVER_MANAGED_PROVIDER_TYPES/.test(read(rel)),
|
||||
);
|
||||
expect(copies).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Structural guards on the Bearer-scope enforcement boundary.
|
||||
*
|
||||
* The correctness argument for the fail-closed default is structural, not
|
||||
* empirical: there is exactly ONE place a raw Bearer token becomes a user, and
|
||||
* exactly ONE authorisation arm inside it. Seven behavioural tests cannot cover
|
||||
* 300-odd routes; what they can do is rest on that invariant. These guards are
|
||||
* what keep the invariant true.
|
||||
*
|
||||
* They are tripwires, not proofs. They cannot show an allowlist is correct —
|
||||
* only that it has not changed without someone editing this file. A reviewer
|
||||
* who waves through a bad addition defeats all four, and no test substitutes
|
||||
* for that review.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, sep } from "node:path";
|
||||
import { globSync } from "node:fs";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
/**
|
||||
* Every non-test `.ts` / `.tsx` under `src/`, excluding the generated Prisma
|
||||
* client (9 MB; never read it) and test files themselves.
|
||||
*/
|
||||
function sourceFiles(): string[] {
|
||||
return globSync("**/*.{ts,tsx}", { cwd: SRC })
|
||||
.filter(
|
||||
(p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"),
|
||||
)
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
||||
.map((p) => p.split(sep).join("/"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function read(rel: string): string {
|
||||
return readFileSync(join(SRC, rel), "utf8");
|
||||
}
|
||||
|
||||
function filesMatching(re: RegExp): string[] {
|
||||
return sourceFiles().filter((rel) => re.test(read(rel)));
|
||||
}
|
||||
|
||||
describe("T1 — the Bearer resolution set is frozen", () => {
|
||||
/**
|
||||
* Files allowed to turn a raw `Authorization: Bearer` value into a user.
|
||||
* This is the set that can circumvent the enforcement point, so it is the
|
||||
* only set that matters for the fail-closed guarantee.
|
||||
*
|
||||
* Two of these authenticate a request; the rest are the resolver itself and
|
||||
* the two edges that wrap it.
|
||||
*/
|
||||
const RESOLUTION_ALLOWLIST = [
|
||||
// The resolver. Owns the one authorisation arm.
|
||||
"lib/auth/bearer.ts",
|
||||
// The REST edge — maps `requireAuth`'s optional scope onto the union.
|
||||
"lib/api-handler.ts",
|
||||
// The MCP edge — the one deliberate `any-valid-token` posture.
|
||||
"lib/mcp/auth.ts",
|
||||
// The `/mcp` transport, via `resolveMcpAuthContext`.
|
||||
"app/mcp/route.ts",
|
||||
// Hand-rolled Bearer authentication: the external medication-ingest
|
||||
// surface, which gates on BOTH `medication:ingest` and the per-medication
|
||||
// `medication:<id>:ingest` grant and never touches `requireAuth`.
|
||||
"app/api/ingest/medication/route.ts",
|
||||
].sort();
|
||||
|
||||
it("no file outside the allowlist resolves a Bearer token to a user", () => {
|
||||
// `resolveBearerToken` is the shared primitive; a `tokenHash` lookup is the
|
||||
// hand-rolled equivalent. Either one turns a bearer credential into an
|
||||
// identity, so both have to stay inside the allowlist.
|
||||
const resolvers = filesMatching(
|
||||
/resolveBearerToken|apiToken\.findUnique\([\s\S]*?where:\s*\{\s*tokenHash/,
|
||||
);
|
||||
|
||||
expect(resolvers).toEqual(RESOLUTION_ALLOWLIST);
|
||||
});
|
||||
|
||||
it("the resolver exposes exactly one authorisation arm", () => {
|
||||
const src = read("lib/auth/bearer.ts");
|
||||
// One wildcard escape hatch, guarding one deny block. If a second
|
||||
// `permissions.includes("*")` short-circuit appears, the arm has forked.
|
||||
const wildcardChecks = src.match(/permissions\.includes\("\*"\)/g) ?? [];
|
||||
expect(wildcardChecks).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("T2 — `any-valid-token` is the single deliberate fail-open posture", () => {
|
||||
it("appears at exactly one call site, and that call site is the MCP edge", () => {
|
||||
// `bearer.ts` necessarily names the variant to declare the union; what
|
||||
// must stay unique is a caller PASSING it.
|
||||
const optedOut = filesMatching(/kind:\s*"any-valid-token"/).filter(
|
||||
(rel) => rel !== "lib/auth/bearer.ts",
|
||||
);
|
||||
expect(optedOut).toEqual(["lib/mcp/auth.ts"]);
|
||||
});
|
||||
|
||||
it("no route file opts out of the fail-closed default", () => {
|
||||
const routes = sourceFiles().filter((p) => p.endsWith("/route.ts"));
|
||||
const optedOut = routes.filter((rel) =>
|
||||
/kind:\s*"any-valid-token"/.test(read(rel)),
|
||||
);
|
||||
expect(optedOut).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("T3 — the mint sites are frozen", () => {
|
||||
/**
|
||||
* Every place an `ApiToken` row is created, with the scope set it may mint.
|
||||
* A new mint site, or a new scope on an existing one, has to be named here —
|
||||
* which is the point: a user-facing mint that hands out a broad scope is the
|
||||
* one failure mode the enforcement change cannot catch by itself.
|
||||
*/
|
||||
const MINT_SITES: Record<string, string> = {
|
||||
// Cookie-equivalent. The ONLY `["*"]` mints, all behind a completed login.
|
||||
"lib/auth/issue-token.ts": 'opts.permissions ?? ["*"]',
|
||||
"lib/auth/login-response.ts": '["*"]',
|
||||
"lib/auth/refresh-token.ts": '["*"]',
|
||||
"app/api/auth/passkey/login-verify/route.ts": '["*"]',
|
||||
// The working medication-ingest pair — family marker plus the per-
|
||||
// medication grant that `/api/ingest/medication` actually gates on.
|
||||
"app/api/medications/[id]/api-endpoint/route.ts":
|
||||
'["medication:ingest", scope]',
|
||||
// MCP, audience-bound to `/mcp`. `health:write` requires explicit consent.
|
||||
"app/api/mcp/tokens/route.ts": "SCOPE_HEALTH_READ / SCOPE_HEALTH_WRITE",
|
||||
"app/api/mcp/oauth/token/route.ts":
|
||||
"SCOPE_HEALTH_READ / SCOPE_HEALTH_WRITE",
|
||||
};
|
||||
|
||||
it("only the known files create ApiToken rows", () => {
|
||||
const minters = filesMatching(/apiToken\.create\(|issueApiToken\(/).filter(
|
||||
// `issue-token.ts` defines the helper; the others call it.
|
||||
(rel) => rel !== "lib/auth/issue-token.ts" || true,
|
||||
);
|
||||
expect(minters).toEqual(Object.keys(MINT_SITES).sort());
|
||||
});
|
||||
|
||||
it("no user-facing mint hands out a wildcard scope", () => {
|
||||
// The four `["*"]` mints are all reached only by a completed
|
||||
// password / passkey / refresh exchange. Every other mint is narrow.
|
||||
const userFacing = [
|
||||
"app/api/medications/[id]/api-endpoint/route.ts",
|
||||
"app/api/mcp/tokens/route.ts",
|
||||
"app/api/mcp/oauth/token/route.ts",
|
||||
];
|
||||
for (const rel of userFacing) {
|
||||
expect(read(rel)).not.toMatch(/permissions:\s*\[\s*"\*"/);
|
||||
}
|
||||
});
|
||||
|
||||
it("the retired generic token mint is gone", () => {
|
||||
// `POST /api/tokens` minted `["medication:ingest"]` — a token that could
|
||||
// not perform its advertised job (it lacked the per-medication grant) and
|
||||
// could reach every other authenticated route. List and revoke stay.
|
||||
const tokensRoute = read("app/api/tokens/route.ts");
|
||||
expect(tokensRoute).not.toMatch(/export const POST/);
|
||||
expect(tokensRoute).toMatch(/export const GET/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("T4 — declared scopes are exported constants, never string literals", () => {
|
||||
it("every requireAuth argument is an identifier", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const rel of sourceFiles()) {
|
||||
const src = read(rel);
|
||||
// Call sites only — skip the declaration in `api-handler.ts`, whose
|
||||
// parameter list is not an argument.
|
||||
for (const m of src.matchAll(
|
||||
/(?<!function\s)requireAuth\(\s*([^)\s][^)]*)\)/g,
|
||||
)) {
|
||||
const arg = m[1].trim();
|
||||
if (!/^[A-Za-z_$][\w$]*$/.test(arg)) {
|
||||
offenders.push(`${rel}: requireAuth(${arg})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("the declared scope vocabulary is closed", () => {
|
||||
// Scopes a route may name. Adding one means adding a route that accepts a
|
||||
// narrow token, which is a widening — it belongs in a reviewed diff.
|
||||
const DECLARED_SCOPES = ["FHIR_READ_SCOPE"];
|
||||
const used = new Set<string>();
|
||||
for (const rel of sourceFiles()) {
|
||||
for (const m of read(rel).matchAll(
|
||||
/(?<!function\s)requireAuth\(\s*([A-Za-z_$][\w$]*)\s*\)/g,
|
||||
)) {
|
||||
used.add(m[1]);
|
||||
}
|
||||
}
|
||||
expect([...used].sort()).toEqual(DECLARED_SCOPES);
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ import { join } from "node:path";
|
||||
*/
|
||||
|
||||
const ROOT = join(__dirname, "../../..");
|
||||
const INSIGHTS_PATH = join(ROOT, "src/app/insights/page.tsx");
|
||||
const INSIGHTS_PATH = join(ROOT, "src/app/insights/page-client.tsx");
|
||||
const ANALYTICS_ROUTE_PATH = join(ROOT, "src/app/api/analytics/route.ts");
|
||||
const ADVISOR_HOOK_PATH = join(
|
||||
ROOT,
|
||||
|
||||
@@ -29,7 +29,7 @@ import { join } from "node:path";
|
||||
|
||||
const ROOT = join(__dirname, "../../..");
|
||||
const DASHBOARD_PATH = join(ROOT, "src/app/page-client.tsx");
|
||||
const INSIGHTS_PATH = join(ROOT, "src/app/insights/page.tsx");
|
||||
const INSIGHTS_PATH = join(ROOT, "src/app/insights/page-client.tsx");
|
||||
const INSIGHTS_LAYOUT_PATH = join(ROOT, "src/app/insights/layout.tsx");
|
||||
|
||||
function load(path: string): string {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -38,13 +38,7 @@ import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
|
||||
* `coach-route-gate-inventory.test.ts`; listed here so
|
||||
* coach-bearing routes are not flagged as ungated.
|
||||
*
|
||||
* 3. BUILDER-GATED — the route delegates its whole payload to an
|
||||
* aggregator that resolves `resolveModuleMap(userId)` once and
|
||||
* excludes disabled-module sections/resources from the build
|
||||
* (`collectDoctorReportData`, `loadFhirContext`). The exclusion is
|
||||
* structural at the builder, so the route carries no per-key call.
|
||||
*
|
||||
* 4. EXEMPT — an explicit, COMMENTED allowlist of routes that serve a
|
||||
* 3. EXEMPT — an explicit, COMMENTED allowlist of routes that serve a
|
||||
* toggleable domain but are deliberately NOT gated, each with the
|
||||
* reason at the entry. The two reasons in play:
|
||||
* - DATA LAYER: raw CRUD over the domain's own rows. Writing or
|
||||
@@ -56,9 +50,30 @@ import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
|
||||
* - INFRA / UI-ONLY: settings, availability probes, and the static
|
||||
* FHIR CapabilityStatement carry no module data.
|
||||
*
|
||||
* Anything that doesn't fit one of the four buckets is an orphan and
|
||||
* Anything that doesn't fit one of the three buckets is an orphan and
|
||||
* fails the test BY NAME so the fix is one search-and-add: gate the route
|
||||
* or move it onto the EXEMPT allowlist with a documented reason.
|
||||
*
|
||||
* WHAT THIS TEST DOES NOT PROVE, AND WHO PROVES IT
|
||||
* -----------------------------------------------
|
||||
* This file is a DISCOVERY test: it proves no route escapes classification.
|
||||
* It cannot prove a gate actually refuses, because it only reads source text.
|
||||
*
|
||||
* That distinction used to be blurred. A fourth "BUILDER-GATED" bucket
|
||||
* accepted the mere MENTION of an aggregator's name (`loadFhirContext`,
|
||||
* `collectDoctorReportData`) as proof that the route was gated. It was not
|
||||
* proof of anything: `loadFhirContext` filtered per-domain modules but never
|
||||
* evaluated `doctorReport`, so all five `/api/fhir/*` data routes sat in a
|
||||
* green bucket while serving the whole record — including the decrypted
|
||||
* insurance number — to an account that had the doctor-report module off.
|
||||
* The bucket is gone. A route is now only counted as gated when it CALLS a
|
||||
* gate, and `REQUIRED_TREE_MODULE` below pins which key it must name.
|
||||
*
|
||||
* The behaviour itself — a disabled module actually producing a 403 or an
|
||||
* omission — is proven by the per-surface tests listed in
|
||||
* `BEHAVIOURAL_GATE_TESTS`. This test asserts those files still exist, so
|
||||
* deleting the behavioural coverage fails here with a pointer to what was
|
||||
* lost rather than silently leaving only a text-match behind.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -99,9 +114,10 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
|
||||
"src/app/api/medications",
|
||||
// v1.18.0 B3 — the legacy `/api/doctor-report` tree (JSON + server-PDF +
|
||||
// availability probe) was orphaned dead code (no production caller) and
|
||||
// removed. The live doctor-report / FHIR surface is `/api/export/health-record`,
|
||||
// which gates on the `doctorReport` module directly AND through the
|
||||
// `collectDoctorReportData` builder.
|
||||
// removed. The doctor-report surface is `/api/export/health-record` plus
|
||||
// the FHIR REST face below; both gate on `doctorReport` directly. The FHIR
|
||||
// routes previously relied on the removed builder-mention bucket and were
|
||||
// in practice ungated — see the header note.
|
||||
"src/app/api/fhir",
|
||||
// v1.18.0 (B2) — the AI-narrative insights tree. Every status / cards /
|
||||
// correlations / derived / narrative / pregenerate / rhythm-events route
|
||||
@@ -273,12 +289,55 @@ const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")';
|
||||
// `requireCycleEnabled`. Recognised as a delegated gate so illness routes
|
||||
// are not flagged as ungated.
|
||||
const ILLNESS_GATE_NEEDLE = "requireIllnessEnabled(";
|
||||
// Builder aggregators that resolve `resolveModuleMap` once and exclude
|
||||
// disabled-module sections/resources at the build boundary.
|
||||
const BUILDER_GATE_NEEDLES: ReadonlyArray<string> = [
|
||||
"collectDoctorReportData",
|
||||
"loadFhirContext",
|
||||
];
|
||||
|
||||
/**
|
||||
* Trees whose routes must gate on ONE specific module key. Naming the key
|
||||
* here means a future route in the tree cannot satisfy the inventory by
|
||||
* gating on some other, more permissive module — the failure mode that a
|
||||
* generic "is there any gate?" check cannot see.
|
||||
*
|
||||
* `/api/fhir` is the entry that matters: every data route there serves the
|
||||
* whole-record doctor-report aggregate, so `doctorReport` is the only
|
||||
* correct key.
|
||||
*/
|
||||
const REQUIRED_TREE_MODULE: Readonly<Record<string, ModuleKey>> = {
|
||||
"src/app/api/fhir": "doctorReport",
|
||||
};
|
||||
|
||||
/**
|
||||
* Surfaces whose module behaviour is proven by a real behavioural test
|
||||
* (module off ⇒ 403 or omission; module on ⇒ served). Listed so that
|
||||
* deleting the proof fails the inventory instead of quietly downgrading the
|
||||
* guarantee back to a text match.
|
||||
*
|
||||
* The three aggregate surfaces below are the ones that re-served per-domain
|
||||
* data ungated: two of them (`/api/sync/changes`, `/api/dashboard/summary`)
|
||||
* are cross-domain feeds that live outside `MODULE_ROUTE_TREES` entirely, so
|
||||
* without this list nothing in the inventory would notice them at all.
|
||||
*/
|
||||
const BEHAVIOURAL_GATE_TESTS: ReadonlyArray<{ path: string; proves: string }> =
|
||||
[
|
||||
{
|
||||
path: "src/app/api/fhir/__tests__/module-gate.test.ts",
|
||||
proves:
|
||||
"every /api/fhir data route 403s with module.disabled when doctorReport is off",
|
||||
},
|
||||
{
|
||||
path: "src/lib/fhir/__tests__/rest-module-backstop.test.ts",
|
||||
proves:
|
||||
"loadFhirContext itself refuses to assemble the record when doctorReport is off",
|
||||
},
|
||||
{
|
||||
path: "src/app/api/sync/changes/__tests__/module-gate.test.ts",
|
||||
proves:
|
||||
"the sync delta feed omits cycleDays/cycles (and never queries them) when cycle is off",
|
||||
},
|
||||
{
|
||||
path: "src/app/api/dashboard/summary/__tests__/module-gate.test.ts",
|
||||
proves:
|
||||
"the dashboard summary drops disabled-module metric cards while core vitals still ship",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* True when the file contains the needle on a line that is NOT a pure
|
||||
@@ -373,7 +432,7 @@ describe("module API route gate inventory", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("every toggleable-module route is gated, delegated, builder-gated, or explicitly exempt", () => {
|
||||
it("every toggleable-module route is gated, delegated, or explicitly exempt", () => {
|
||||
const routes = findAllModuleRouteFiles();
|
||||
expect(routes.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -387,15 +446,16 @@ describe("module API route gate inventory", () => {
|
||||
if (fileHasCall(text, CYCLE_GATE_NEEDLE)) continue;
|
||||
if (fileHasCall(text, COACH_GATE_NEEDLE)) continue;
|
||||
if (fileHasCall(text, ILLNESS_GATE_NEEDLE)) continue;
|
||||
if (BUILDER_GATE_NEEDLES.some((n) => fileHasCall(text, n))) continue;
|
||||
|
||||
if (exempt.has(path)) continue;
|
||||
|
||||
orphans.push({
|
||||
path,
|
||||
reason:
|
||||
'no module gate found — add requireModuleEnabled("<key>"), a delegated gate, ' +
|
||||
"a module-aware builder, or move the route onto EXEMPT_ROUTES with a documented reason",
|
||||
'no module gate found — add requireModuleEnabled("<key>") or a delegated gate, ' +
|
||||
"or move the route onto EXEMPT_ROUTES with a documented reason. " +
|
||||
"Importing a module-aware builder is NOT a gate: a builder can filter some " +
|
||||
"modules and never evaluate yours.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -432,6 +492,55 @@ describe("module API route gate inventory", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("routes in a key-pinned tree gate on that tree's module key", () => {
|
||||
// A generic "is there any gate?" check cannot tell `doctorReport` from
|
||||
// some other, more permissive key. Where a whole tree serves exactly one
|
||||
// module, name it.
|
||||
const wrong: Array<{ path: string; found: string[] }> = [];
|
||||
|
||||
for (const [tree, requiredKey] of Object.entries(REQUIRED_TREE_MODULE)) {
|
||||
for (const path of findRouteFiles(tree)) {
|
||||
if (EXEMPT_ROUTES.includes(path)) continue;
|
||||
const text = readFileSync(resolve(repoRoot, path), "utf8");
|
||||
const { keys } = extractModuleKeys(text);
|
||||
if (!keys.has(requiredKey)) {
|
||||
wrong.push({ path, found: [...keys] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
wrong,
|
||||
[
|
||||
"Routes that must gate on their tree's module key but do not:",
|
||||
...wrong.map(
|
||||
(w) =>
|
||||
` - ${w.path}: found [${w.found.join(", ") || "no literal key"}]`,
|
||||
),
|
||||
].join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("the behavioural gate tests that prove refusal still exist", () => {
|
||||
// This inventory only reads source text. The listed files are what
|
||||
// actually exercise a disabled module end to end; losing one silently
|
||||
// downgrades the guarantee back to a text match, which is precisely the
|
||||
// failure this test was rewritten to prevent.
|
||||
const missing = BEHAVIOURAL_GATE_TESTS.filter(
|
||||
(t) => !existsSync(resolve(repoRoot, t.path)),
|
||||
);
|
||||
|
||||
expect(
|
||||
missing,
|
||||
[
|
||||
"Behavioural module-gate coverage has gone missing.",
|
||||
"This inventory cannot prove a gate refuses — these tests do:",
|
||||
...missing.map((m) => ` - ${m.path}\n proves: ${m.proves}`),
|
||||
"Restore the file, or drop the entry and say what replaces it.",
|
||||
].join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("EXEMPT_ROUTES does not reference deleted route files", () => {
|
||||
const known = new Set(findAllModuleRouteFiles());
|
||||
const stale = EXEMPT_ROUTES.filter((p) => !known.has(p));
|
||||
@@ -457,8 +566,7 @@ describe("module API route gate inventory", () => {
|
||||
fileHasCall(text, MODULE_GATE_NEEDLE) ||
|
||||
fileHasCall(text, CYCLE_GATE_NEEDLE) ||
|
||||
fileHasCall(text, COACH_GATE_NEEDLE) ||
|
||||
fileHasCall(text, ILLNESS_GATE_NEEDLE) ||
|
||||
BUILDER_GATE_NEEDLES.some((n) => fileHasCall(text, n))
|
||||
fileHasCall(text, ILLNESS_GATE_NEEDLE)
|
||||
) {
|
||||
stillGated.push(path);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* A restore writes into the account the BACKUP RECORD names, not the one the
|
||||
* encrypted payload claims.
|
||||
*
|
||||
* The route took its target from `payload.userId` — a field inside the blob —
|
||||
* while logging `backup.userId` from the row. An admin selecting one user's
|
||||
* backup could therefore write into a different account, and the audit trail
|
||||
* would record the account they thought they picked. Both values are in hand at
|
||||
* that point, so the only correct behaviour is to compare them.
|
||||
*
|
||||
* This is inside the admin privilege boundary, so it is an integrity problem
|
||||
* rather than an escalation: the operator's intent was not honoured.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (fn: unknown) => fn,
|
||||
HttpError: class extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
requireAdmin: vi.fn(async () => ({ user: { id: "admin-1" } })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
dataBackup: { findUnique: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "{}") }));
|
||||
vi.mock("@/lib/crypto/note-cipher", () => ({ encryptNote: vi.fn() }));
|
||||
vi.mock("@/lib/validations/backup", () => ({
|
||||
parseBackupPayload: vi.fn(),
|
||||
summarizeBackup: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
withIdempotency: (fn: unknown) => fn,
|
||||
defaultUserIdResolver: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/mood-rollups", () => ({
|
||||
recomputeUserMoodRollups: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
recomputeUserMedicationCompliance: vi.fn(),
|
||||
MEDICATION_COMPLIANCE_BACKFILL_DAYS: 30,
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-rollups", () => ({
|
||||
recomputeUserRollups: vi.fn(),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { parseBackupPayload } from "@/lib/validations/backup";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
function request(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/admin/backups/b-1/restore", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
// The route requires a typed confirmation before it will restore anything.
|
||||
body: JSON.stringify({ confirm: "RESTORE" }),
|
||||
});
|
||||
}
|
||||
|
||||
const params = Promise.resolve({ id: "b-1" });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(prisma.dataBackup.findUnique).mockResolvedValue({
|
||||
id: "b-1",
|
||||
userId: "user-A",
|
||||
data: "cipher",
|
||||
user: { id: "user-A", username: "alice" },
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("admin backup restore — declared owner", () => {
|
||||
it("refuses when the payload names a different owner than the record", async () => {
|
||||
// The blob claims user-B; the backup row says user-A.
|
||||
vi.mocked(parseBackupPayload).mockReturnValue({
|
||||
userId: "user-B",
|
||||
} as never);
|
||||
|
||||
const res = await (
|
||||
POST as unknown as (r: NextRequest, c: unknown) => Promise<Response>
|
||||
)(request(), { params });
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
// The restore must not have gone looking for the claimed user at all.
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
// And the refusal is recorded with BOTH ids, so the attempt is reviewable.
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"admin.backups.restore.failed",
|
||||
expect.objectContaining({
|
||||
details: expect.objectContaining({
|
||||
reason: "owner_mismatch",
|
||||
ownerId: "user-A",
|
||||
declaredOwnerId: "user-B",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("proceeds past the check when the two agree", async () => {
|
||||
vi.mocked(parseBackupPayload).mockReturnValue({
|
||||
userId: "user-A",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
|
||||
|
||||
const res = await (
|
||||
POST as unknown as (r: NextRequest, c: unknown) => Promise<Response>
|
||||
)(request(), { params });
|
||||
|
||||
// Reaching the owner lookup proves the mismatch branch did not fire; this
|
||||
// case then fails on the deleted-user branch, which is the next guard.
|
||||
expect(prisma.user.findUnique).toHaveBeenCalled();
|
||||
expect(res.status).not.toBe(409);
|
||||
});
|
||||
});
|
||||
@@ -195,6 +195,29 @@ const handler = apiHandler(
|
||||
return apiError("Backup payload failed schema validation", 500);
|
||||
}
|
||||
|
||||
// The payload declares its own owner, and the backup ROW records who the
|
||||
// backup was taken for. Those must agree. Taking the owner from the
|
||||
// payload alone means the restored-into account is whatever the blob
|
||||
// claims — so an admin who selects one user's backup could write into a
|
||||
// different account without the interface ever showing it. Both values are
|
||||
// already in hand here; refuse on mismatch rather than trusting the blob.
|
||||
if (payload.userId !== backup.userId) {
|
||||
await auditLog("admin.backups.restore.failed", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
backupId: id,
|
||||
ownerId: backup.userId,
|
||||
reason: "owner_mismatch",
|
||||
declaredOwnerId: payload.userId,
|
||||
},
|
||||
});
|
||||
return apiError(
|
||||
"Backup payload declares a different owner than the backup record",
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
// The target of the restore is whoever the backup is for, NOT the
|
||||
// admin running the operation. Make sure that user still exists —
|
||||
// an upload referencing a since-deleted user would otherwise leave
|
||||
|
||||
@@ -22,6 +22,8 @@ import { resolveRestMode } from "@/lib/illness/rest-mode";
|
||||
import { buildHealthScoreBpInputs } from "@/lib/analytics/health-score-inputs";
|
||||
import { deriveBpWindow90 } from "@/lib/analytics/window-confidence";
|
||||
import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { type Locale } from "@/lib/i18n/config";
|
||||
import {
|
||||
computeGlucoseClinicalMetrics,
|
||||
GLUCOSE_PANEL_WINDOW_DAYS,
|
||||
@@ -96,12 +98,18 @@ export const GET = apiHandler(async (request?: Request) => {
|
||||
// thick body is the 30-query chain feeding the dashboard + insights
|
||||
// hero score; after a measurement sync the next mount serves the
|
||||
// prior body instantly while one background recompute refreshes it.
|
||||
// v1.30.10 — the `correlations` block carries user-facing narration that is
|
||||
// localised per reader, so the cache key MUST include the locale; otherwise a
|
||||
// language switch would serve another locale's cached interpretation for up to
|
||||
// the TTL. Every other block is locale-independent, so per-locale entries only
|
||||
// cost a little duplication (most users have one locale).
|
||||
const locale = await resolveServerLocale({ userLocale: user.locale ?? null });
|
||||
const cachedBody = await cachedSwr(
|
||||
caches.analytics as ServerCache<
|
||||
Awaited<ReturnType<typeof buildAnalyticsResponse>>
|
||||
>,
|
||||
`${user.id}|default`,
|
||||
() => buildAnalyticsResponse(user),
|
||||
`${user.id}|default|${locale}`,
|
||||
() => buildAnalyticsResponse(user, locale),
|
||||
annotate,
|
||||
);
|
||||
|
||||
@@ -173,7 +181,7 @@ function enrichLastSeenDaysAgo<
|
||||
* matches the v1.4.33 snapshot LRU's `buildCoachSnapshot` →
|
||||
* `buildCoachSnapshotImpl` shape.
|
||||
*/
|
||||
async function buildAnalyticsResponse(user: AuthedUser) {
|
||||
async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
|
||||
// v1.4.37.1 hotfix — fire-and-forget the rollup-fresh check.
|
||||
// Awaiting it on the read path can stall the Node event loop for
|
||||
// 30–60 s on a power-user account whose iOS step samples keep the
|
||||
@@ -405,6 +413,7 @@ async function buildAnalyticsResponse(user: AuthedUser) {
|
||||
userTz,
|
||||
now: new Date(),
|
||||
coverage,
|
||||
locale,
|
||||
});
|
||||
|
||||
// v1.4.20 phase B5 — Personal Health Score. Server-deterministic
|
||||
|
||||
@@ -68,6 +68,18 @@ vi.mock("@/lib/auth/oidc", () => ({
|
||||
deriveUniqueUsername: vi.fn(async (email: string) => email.split("@")[0]),
|
||||
}));
|
||||
|
||||
// The handoff lib is unit-tested separately; here we assert the callback
|
||||
// branches into it and redirects to the custom scheme (real
|
||||
// `buildNativeCallbackUrl`, mocked `mintNativeHandoff`).
|
||||
vi.mock("@/lib/auth/oidc-native-handoff", () => ({
|
||||
buildNativeCallbackUrl: (params: Record<string, string>) =>
|
||||
`healthlog://oidc-callback?${new URLSearchParams(params).toString()}`,
|
||||
mintNativeHandoff: vi.fn(async () => ({
|
||||
code: "hlh_test_code",
|
||||
handoffId: "ho-1",
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -80,6 +92,7 @@ import {
|
||||
exchangeCodeForTokens,
|
||||
verifyIdToken,
|
||||
} from "@/lib/auth/oidc";
|
||||
import { mintNativeHandoff } from "@/lib/auth/oidc-native-handoff";
|
||||
|
||||
const CONFIG = {
|
||||
issuerUrl: "https://idp.example.com",
|
||||
@@ -577,3 +590,127 @@ describe("GET /api/auth/oidc/callback", () => {
|
||||
expect(successCookie.toLowerCase()).toContain("path=/api/auth/oidc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/auth/oidc/callback — native branch", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 20,
|
||||
reset: 0,
|
||||
ip: "1.2.3.4",
|
||||
} as never);
|
||||
vi.mocked(getOidcConfig).mockReturnValue(CONFIG);
|
||||
vi.mocked(discoverOidcMetadata).mockResolvedValue(METADATA);
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0);
|
||||
vi.mocked(mintNativeHandoff).mockResolvedValue({
|
||||
code: "hlh_test_code",
|
||||
handoffId: "ho-1",
|
||||
});
|
||||
});
|
||||
|
||||
function nativeStateCookie(overrides: Record<string, unknown> = {}): string {
|
||||
return stateCookie({
|
||||
state: "STATE",
|
||||
nonce: "nonce-1",
|
||||
codeVerifier: "verifier-1",
|
||||
next: "/",
|
||||
native: true,
|
||||
appCodeChallenge: "app-challenge-123",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function nativeRequest(): NextRequest {
|
||||
return makeRequest({
|
||||
query: "?code=abc&state=STATE",
|
||||
cookie: nativeStateCookie(),
|
||||
});
|
||||
}
|
||||
|
||||
it("mints a handoff code and 302s to the custom scheme — no session", async () => {
|
||||
mockIdentity();
|
||||
mockUserLookups({
|
||||
byIdentity: userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }),
|
||||
});
|
||||
|
||||
const res = await GET(nativeRequest());
|
||||
const location = res.headers.get("location")!;
|
||||
expect(location.startsWith("healthlog://oidc-callback?code=")).toBe(true);
|
||||
expect(location).toContain("code=hlh_test_code");
|
||||
// The app's challenge (from the tamper-authenticated blob) is bound at mint.
|
||||
expect(mintNativeHandoff).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
appCodeChallenge: "app-challenge-123",
|
||||
}),
|
||||
);
|
||||
// No web session is minted on the native branch.
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hands the MFA ticket to the custom scheme (no session, no MFA cookie)", async () => {
|
||||
mockIdentity();
|
||||
mockUserLookups({
|
||||
byIdentity: userRow({
|
||||
oidcIssuer: METADATA.issuer,
|
||||
oidcSub: "sub-1",
|
||||
totpConfirmedAt: new Date(),
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await GET(nativeRequest());
|
||||
const location = res.headers.get("location")!;
|
||||
expect(location.startsWith("healthlog://oidc-callback?")).toBe(true);
|
||||
expect(location).toContain("mfa_ticket=mfa-ticket-1");
|
||||
expect(location).toContain("methods=totp%2Crecovery");
|
||||
// No handoff code on the MFA branch, and no session.
|
||||
expect(location).not.toContain("code=hlh_");
|
||||
expect(mintNativeHandoff).not.toHaveBeenCalled();
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
// The web-only MFA handoff cookie is never set on the native branch.
|
||||
const mfaCookie = res.headers
|
||||
.getSetCookie()
|
||||
.find((c) => c.startsWith("oidc_mfa_handoff="));
|
||||
expect(mfaCookie).toBeUndefined();
|
||||
});
|
||||
|
||||
it("delivers a post-identity error to the custom scheme, not a web page", async () => {
|
||||
mockIdentity();
|
||||
// An account already pinned to a different IdP identity → identity_conflict.
|
||||
mockUserLookups({
|
||||
byIdentity: null,
|
||||
byEmail: userRow({ oidcIssuer: "https://other.example", oidcSub: "x" }),
|
||||
});
|
||||
|
||||
const res = await GET(nativeRequest());
|
||||
const location = res.headers.get("location")!;
|
||||
expect(location).toBe(
|
||||
"healthlog://oidc-callback?error=oidc_identity_conflict",
|
||||
);
|
||||
});
|
||||
|
||||
it("delivers an IdP denial to the custom scheme", async () => {
|
||||
const res = await GET(
|
||||
makeRequest({
|
||||
query: "?error=access_denied&state=STATE",
|
||||
cookie: nativeStateCookie(),
|
||||
}),
|
||||
);
|
||||
expect(res.headers.get("location")).toBe(
|
||||
"healthlog://oidc-callback?error=oidc_denied",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the WEB oidc_state page on a CSRF mismatch (broken flow)", async () => {
|
||||
const res = await GET(
|
||||
makeRequest({
|
||||
query: "?code=abc&state=WRONG",
|
||||
cookie: nativeStateCookie(),
|
||||
}),
|
||||
);
|
||||
// A tampered/broken round-trip never hands the app a scheme redirect.
|
||||
expect(res.headers.get("location")).toContain(
|
||||
"/auth/login?error=oidc_state",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,10 @@ import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
|
||||
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import { resolveServerDefaultTimezone } from "@/lib/tz/resolver";
|
||||
import {
|
||||
buildNativeCallbackUrl,
|
||||
mintNativeHandoff,
|
||||
} from "@/lib/auth/oidc-native-handoff";
|
||||
|
||||
const LOGIN_ERROR_URL = "/auth/login";
|
||||
|
||||
@@ -82,17 +86,38 @@ interface StoredOidcState {
|
||||
nonce: string;
|
||||
codeVerifier: string;
|
||||
next: string;
|
||||
/**
|
||||
* v1.30.x — set only when the login GET carried `client=native`. When true
|
||||
* the callback mints a handoff code (or MFA ticket) and redirects to the
|
||||
* custom scheme instead of minting a session. Both fields live ONLY inside
|
||||
* the AES-256-GCM blob, so they are tamper-authenticated: a network attacker
|
||||
* or the IdP cannot flip a web login into a native one or swap the app
|
||||
* challenge.
|
||||
*/
|
||||
native?: boolean;
|
||||
/** The app's S256 PKCE challenge, bound at login; verified at the exchange. */
|
||||
appCodeChallenge?: string;
|
||||
}
|
||||
|
||||
function isStoredOidcState(value: unknown): value is StoredOidcState {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof (value as Record<string, unknown>).state === "string" &&
|
||||
typeof (value as Record<string, unknown>).nonce === "string" &&
|
||||
typeof (value as Record<string, unknown>).codeVerifier === "string" &&
|
||||
typeof (value as Record<string, unknown>).next === "string"
|
||||
);
|
||||
const v = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof value !== "object" ||
|
||||
value === null ||
|
||||
typeof v.state !== "string" ||
|
||||
typeof v.nonce !== "string" ||
|
||||
typeof v.codeVerifier !== "string" ||
|
||||
typeof v.next !== "string"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Optional native fields: when present they must be the right shape, and a
|
||||
// native start MUST carry the challenge the exchange verifies against.
|
||||
if (v.native !== undefined) {
|
||||
if (v.native !== true) return false;
|
||||
if (typeof v.appCodeChallenge !== "string") return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,34 +131,12 @@ function isStoredOidcState(value: unknown): value is StoredOidcState {
|
||||
export const GET = apiHandler(async (req: NextRequest) => {
|
||||
annotate({ action: { name: "auth.oidc.callback" } });
|
||||
|
||||
const config = getOidcConfig();
|
||||
if (!config) return errorRedirect("oidc_disabled");
|
||||
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
req,
|
||||
"auth:oidc:callback",
|
||||
20,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
const ip = rl.ip ?? "unknown";
|
||||
if (!rl.allowed) return errorRedirect("oidc_rate_limited");
|
||||
|
||||
const { searchParams } = req.nextUrl;
|
||||
const idpError = searchParams.get("error");
|
||||
if (idpError) {
|
||||
annotate({
|
||||
meta: {
|
||||
reason: "idp_denied",
|
||||
idpError: KNOWN_IDP_ERROR_CODES.has(idpError) ? idpError : "other",
|
||||
},
|
||||
});
|
||||
return errorRedirect("oidc_denied");
|
||||
}
|
||||
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
if (!code || !state) return errorRedirect("oidc_state");
|
||||
|
||||
// Decrypt the single-use state blob FIRST. The `native` flag lives inside the
|
||||
// AES-256-GCM payload (tamper-authenticated), so decrypting it up front lets
|
||||
// every post-decrypt error branch redirect to the custom scheme instead of
|
||||
// the web login page — the app parses `?error=<reason>` and never lands on a
|
||||
// dead web page. A missing/undecryptable blob leaves `stored` null (isNative
|
||||
// false) and those branches fall back to the web redirect by construction.
|
||||
const rawCookie = req.cookies.get(OIDC_STATE_COOKIE)?.value;
|
||||
let stored: StoredOidcState | null = null;
|
||||
if (rawCookie) {
|
||||
@@ -144,6 +147,54 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
stored = null;
|
||||
}
|
||||
}
|
||||
const isNative = stored?.native === true;
|
||||
|
||||
// Native-aware error redirect: the custom scheme for a decrypted native flow,
|
||||
// the web login page otherwise. Deletes the single-use state cookie either
|
||||
// way. An error redirect carries no code, ticket, or session, so routing it
|
||||
// to the scheme leaks nothing (spec §1).
|
||||
const failRedirect = (reason: string): NextResponse => {
|
||||
if (isNative) {
|
||||
const response = NextResponse.redirect(
|
||||
buildNativeCallbackUrl({ error: reason }),
|
||||
);
|
||||
deleteStateCookie(response);
|
||||
return response;
|
||||
}
|
||||
return errorRedirect(reason);
|
||||
};
|
||||
|
||||
const config = getOidcConfig();
|
||||
if (!config) return failRedirect("oidc_disabled");
|
||||
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
req,
|
||||
"auth:oidc:callback",
|
||||
20,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
const ip = rl.ip ?? "unknown";
|
||||
if (!rl.allowed) return failRedirect("oidc_rate_limited");
|
||||
|
||||
const { searchParams } = req.nextUrl;
|
||||
const idpError = searchParams.get("error");
|
||||
if (idpError) {
|
||||
annotate({
|
||||
meta: {
|
||||
reason: "idp_denied",
|
||||
idpError: KNOWN_IDP_ERROR_CODES.has(idpError) ? idpError : "other",
|
||||
},
|
||||
});
|
||||
return failRedirect("oidc_denied");
|
||||
}
|
||||
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
// Broken round-trip / missing-or-unreadable state blob / CSRF mismatch all
|
||||
// resolve to the web `oidc_state` fallback: the flow is fundamentally broken
|
||||
// and there is nothing safe to hand the app (spec §1 web-fallback).
|
||||
if (!code || !state) return errorRedirect("oidc_state");
|
||||
|
||||
if (!stored) return errorRedirect("oidc_state");
|
||||
|
||||
// CSRF check — timing-safe, byte-for-byte, mirroring the Withings callback.
|
||||
@@ -166,7 +217,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
codeVerifier: stored.codeVerifier,
|
||||
redirectUri,
|
||||
});
|
||||
if (!tokens.id_token) return errorRedirect("oidc_failed");
|
||||
if (!tokens.id_token) return failRedirect("oidc_failed");
|
||||
|
||||
const identity = await verifyIdToken({
|
||||
metadata,
|
||||
@@ -229,11 +280,11 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
// health record.
|
||||
if (!email) {
|
||||
annotate({ meta: { reason: "no_email" } });
|
||||
return errorRedirect("oidc_no_email");
|
||||
return failRedirect("oidc_no_email");
|
||||
}
|
||||
if (emailVerified !== true) {
|
||||
annotate({ meta: { reason: "email_unverified" } });
|
||||
return errorRedirect("oidc_email_unverified");
|
||||
return failRedirect("oidc_email_unverified");
|
||||
}
|
||||
|
||||
const byEmail = await prisma.user.findFirst({
|
||||
@@ -251,7 +302,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
ipAddress: ip,
|
||||
});
|
||||
annotate({ meta: { reason: "identity_conflict" } });
|
||||
return errorRedirect("oidc_identity_conflict");
|
||||
return failRedirect("oidc_identity_conflict");
|
||||
}
|
||||
user = await prisma.user.update({
|
||||
where: { id: byEmail.id },
|
||||
@@ -277,7 +328,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
}
|
||||
if (!registrationEnabled) {
|
||||
annotate({ meta: { reason: "registration_disabled" } });
|
||||
return errorRedirect("oidc_registration_disabled");
|
||||
return failRedirect("oidc_registration_disabled");
|
||||
}
|
||||
|
||||
const username = await deriveUniqueUsername(email, (candidate) =>
|
||||
@@ -337,11 +388,11 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
// second factor. An account with a confirmed TOTP secret or a
|
||||
// registered security key gets the same MFA challenge step password
|
||||
// login gets — no session is minted here; the single-use ticket rides
|
||||
// the handoff cookie to the login page, which renders the same
|
||||
// challenge UI and completes at `/api/auth/mfa/verify`. (The
|
||||
// trusted-device skip cannot apply on this path: its cookie is
|
||||
// SameSite=Strict, which the browser withholds on an IdP-initiated
|
||||
// redirect chain.)
|
||||
// the handoff cookie to the login page (web) or the custom scheme
|
||||
// (native), which render the same challenge UI and complete at
|
||||
// `/api/auth/mfa/verify`. (The trusted-device skip cannot apply on this
|
||||
// path: its cookie is SameSite=Strict, which the browser withholds on an
|
||||
// IdP-initiated redirect chain.)
|
||||
const hasTotp = Boolean(user.totpConfirmedAt);
|
||||
const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
|
||||
where: { userId: user.id },
|
||||
@@ -356,12 +407,31 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
await auditLog("auth.mfa.challenge", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { source: "login.oidc" },
|
||||
details: { source: isNative ? "login.oidc.native" : "login.oidc" },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.mfa.challenge" },
|
||||
meta: { mfa_required: true },
|
||||
});
|
||||
|
||||
// Native: hand the app the SAME single-use ticket via the custom scheme.
|
||||
// No `OIDC_MFA_COOKIE` (it exists only to ferry the ticket to the web
|
||||
// login page's script) and no session — the app completes at the
|
||||
// existing native-capable `/api/auth/mfa/verify{,/webauthn}` endpoints,
|
||||
// which end in `finishLogin` and return the native bundle directly. The
|
||||
// ticket alone is inert: single-use, hashed at rest, 5-minute TTL,
|
||||
// 5-attempt cap, factor-gated.
|
||||
if (isNative) {
|
||||
const response = NextResponse.redirect(
|
||||
buildNativeCallbackUrl({
|
||||
mfa_ticket: challenge.ticket,
|
||||
methods: methods.join(","),
|
||||
}),
|
||||
);
|
||||
deleteStateCookie(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
const loginUrl = oidcAppUrl(LOGIN_ERROR_URL);
|
||||
if (stored.next !== "/") {
|
||||
loginUrl.searchParams.set("next", stored.next);
|
||||
@@ -385,6 +455,33 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
|
||||
const ua = req.headers.get("user-agent");
|
||||
|
||||
// Native, no MFA: mint a one-time handoff code and hand it to the app via
|
||||
// the custom scheme. NO session and NO cookie are minted here — the app
|
||||
// exchanges the code at `POST /api/auth/oidc/native/token` for the native
|
||||
// bundle. `userId` is the resolved identity; the app's S256 challenge
|
||||
// (bound at login, tamper-authenticated in the state blob) locks the code
|
||||
// to the app instance that started the flow. The session/device are
|
||||
// recorded at the exchange via `finishLogin`, not here.
|
||||
if (isNative) {
|
||||
const { code: handoffCode } = await mintNativeHandoff({
|
||||
userId: user.id,
|
||||
// Guaranteed present by `isStoredOidcState` when `native === true`.
|
||||
appCodeChallenge: stored.appCodeChallenge as string,
|
||||
ipAddress: ip,
|
||||
userAgent: ua,
|
||||
});
|
||||
await auditLog("auth.oidc.native.handoff_minted", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
});
|
||||
annotate({ action: { name: "auth.oidc.native.handoff_minted" } });
|
||||
const response = NextResponse.redirect(
|
||||
buildNativeCallbackUrl({ code: handoffCode }),
|
||||
);
|
||||
deleteStateCookie(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
// v1.23 parity with password login — the admin-enforced-MFA hint cookie
|
||||
// sends a single-factor account into forced enrollment after sign-in.
|
||||
await syncMfaEnrollCookie(user.id, {
|
||||
@@ -414,6 +511,6 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
} catch (err) {
|
||||
getEvent()?.setError(err);
|
||||
annotate({ meta: { reason: "exchange_or_verify_failed" } });
|
||||
return errorRedirect("oidc_failed");
|
||||
return failRedirect("oidc_failed");
|
||||
}
|
||||
});
|
||||
@@ -150,4 +150,67 @@ describe("GET /api/auth/oidc/login", () => {
|
||||
const res = await GET(makeRequest());
|
||||
expect(res.headers.get("location")).toContain("error=oidc_failed");
|
||||
});
|
||||
|
||||
describe("native SSO start", () => {
|
||||
const CONFIG = {
|
||||
issuerUrl: "https://idp.example.com",
|
||||
clientId: "client-1",
|
||||
clientSecret: "secret-1",
|
||||
scopes: "openid email profile",
|
||||
buttonLabel: "SSO",
|
||||
};
|
||||
const CHALLENGE = "a".repeat(43); // valid S256 length
|
||||
|
||||
function storedFromCookie(res: Awaited<ReturnType<typeof GET>>) {
|
||||
const cookie = res.cookies.get("oidc_auth_state");
|
||||
return JSON.parse(cookie!.value.replace(/^enc\(/, "").replace(/\)$/, ""));
|
||||
}
|
||||
|
||||
it("rejects a native start with a missing challenge to the custom scheme", async () => {
|
||||
vi.mocked(getOidcConfig).mockReturnValue(CONFIG);
|
||||
const res = await GET(makeRequest("/api/auth/oidc/login?client=native"));
|
||||
expect(res.headers.get("location")).toBe(
|
||||
"healthlog://oidc-callback?error=oidc_invalid_request",
|
||||
);
|
||||
});
|
||||
|
||||
it("routes native login errors (disabled) to the custom scheme", async () => {
|
||||
vi.mocked(getOidcConfig).mockReturnValue(null);
|
||||
const res = await GET(
|
||||
makeRequest(
|
||||
`/api/auth/oidc/login?client=native&code_challenge=${CHALLENGE}`,
|
||||
),
|
||||
);
|
||||
expect(res.headers.get("location")).toBe(
|
||||
"healthlog://oidc-callback?error=oidc_disabled",
|
||||
);
|
||||
});
|
||||
|
||||
it("folds native:true + appCodeChallenge into the state blob and pins next to /", async () => {
|
||||
vi.mocked(getOidcConfig).mockReturnValue(CONFIG);
|
||||
vi.mocked(discoverOidcMetadata).mockResolvedValue({
|
||||
issuer: "https://idp.example.com",
|
||||
authorization_endpoint: "https://idp.example.com/authorize",
|
||||
token_endpoint: "https://idp.example.com/token",
|
||||
jwks_uri: "https://idp.example.com/jwks",
|
||||
});
|
||||
|
||||
const res = await GET(
|
||||
makeRequest(
|
||||
`/api/auth/oidc/login?client=native&code_challenge=${CHALLENGE}&next=/dashboard`,
|
||||
),
|
||||
);
|
||||
// Still a normal redirect to the IdP — the native fact rides the blob.
|
||||
expect(res.headers.get("location")).toBe(
|
||||
"https://idp.example.com/authorize?x=1",
|
||||
);
|
||||
const stored = storedFromCookie(res);
|
||||
expect(stored.native).toBe(true);
|
||||
expect(stored.appCodeChallenge).toBe(CHALLENGE);
|
||||
// `next` is meaningless to the app — pinned to "/".
|
||||
expect(stored.next).toBe("/");
|
||||
// The server↔IdP verifier stays independent and present.
|
||||
expect(typeof stored.codeVerifier).toBe("string");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,8 @@ import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
|
||||
import { s256Challenge, isValidChallenge } from "@/lib/mcp/oauth/pkce";
|
||||
import { buildNativeCallbackUrl } from "@/lib/auth/oidc-native-handoff";
|
||||
import {
|
||||
buildAuthorizationUrl,
|
||||
discoverOidcMetadata,
|
||||
@@ -29,9 +30,28 @@ import {
|
||||
export const GET = apiHandler(async (req: NextRequest) => {
|
||||
annotate({ action: { name: "auth.oidc.login" } });
|
||||
|
||||
// v1.30.x — native SSO. The iOS app opens this URL inside an
|
||||
// `ASWebAuthenticationSession` with `client=native` and its own PKCE
|
||||
// `code_challenge`. Both are query params here only; they are validated and
|
||||
// folded into the AES-256-GCM state blob so nothing native-specific rides the
|
||||
// IdP round-trip as a URL parameter. The `client` param is attacker-flippable
|
||||
// at this route, but routing THIS request's error to the custom scheme leaks
|
||||
// nothing — an error redirect carries no code, ticket, or session (spec §1).
|
||||
const isNative = req.nextUrl.searchParams.get("client") === "native";
|
||||
const appCodeChallenge = req.nextUrl.searchParams.get("code_challenge");
|
||||
|
||||
// Native-aware error redirect: the scheme for a native start, the web login
|
||||
// page otherwise. Machine-readable `error=<reason>` in both.
|
||||
const loginError = (reason: string): NextResponse => {
|
||||
if (isNative) {
|
||||
return NextResponse.redirect(buildNativeCallbackUrl({ error: reason }));
|
||||
}
|
||||
return NextResponse.redirect(oidcAppUrl(`/auth/login?error=${reason}`));
|
||||
};
|
||||
|
||||
const config = getOidcConfig();
|
||||
if (!config) {
|
||||
return NextResponse.redirect(oidcAppUrl("/auth/login?error=oidc_disabled"));
|
||||
return loginError("oidc_disabled");
|
||||
}
|
||||
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
@@ -41,9 +61,14 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.redirect(
|
||||
oidcAppUrl("/auth/login?error=oidc_rate_limited"),
|
||||
);
|
||||
return loginError("oidc_rate_limited");
|
||||
}
|
||||
|
||||
// A native start MUST carry a valid S256 challenge (43–128 chars). `plain` is
|
||||
// structurally unsupported — the exchange only ever verifies S256.
|
||||
if (isNative && !isValidChallenge(appCodeChallenge)) {
|
||||
annotate({ meta: { reason: "invalid_native_request" } });
|
||||
return loginError("oidc_invalid_request");
|
||||
}
|
||||
|
||||
let metadata;
|
||||
@@ -51,7 +76,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
metadata = await discoverOidcMetadata(config);
|
||||
} catch (err) {
|
||||
getEvent()?.setError(err);
|
||||
return NextResponse.redirect(oidcAppUrl("/auth/login?error=oidc_failed"));
|
||||
return loginError("oidc_failed");
|
||||
}
|
||||
|
||||
const state = randomBytes(32).toString("base64url");
|
||||
@@ -59,10 +84,11 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
const codeVerifier = randomBytes(64).toString("base64url");
|
||||
const codeChallenge = s256Challenge(codeVerifier);
|
||||
|
||||
const safeNext = sanitizeOidcNextPath(
|
||||
req.nextUrl.searchParams.get("next"),
|
||||
req.url,
|
||||
);
|
||||
// `next` is a post-login WEB path, meaningless to the app — pin it to "/" on
|
||||
// the native branch (one less input to sanitize).
|
||||
const safeNext = isNative
|
||||
? "/"
|
||||
: sanitizeOidcNextPath(req.nextUrl.searchParams.get("next"), req.url);
|
||||
|
||||
const redirectUri = getOidcRedirectUri();
|
||||
const authorizationUrl = buildAuthorizationUrl({
|
||||
@@ -75,8 +101,20 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
});
|
||||
|
||||
const response = NextResponse.redirect(authorizationUrl);
|
||||
// The server↔IdP PKCE (`codeVerifier`) stays independent of the app↔server
|
||||
// PKCE (`appCodeChallenge`). The native flag + challenge live ONLY inside the
|
||||
// AES-256-GCM blob, so the callback branches on tamper-authenticated state a
|
||||
// network attacker or the IdP cannot flip.
|
||||
const cookiePayload = encrypt(
|
||||
JSON.stringify({ state, nonce, codeVerifier, next: safeNext }),
|
||||
JSON.stringify({
|
||||
state,
|
||||
nonce,
|
||||
codeVerifier,
|
||||
next: safeNext,
|
||||
...(isNative
|
||||
? { native: true as const, appCodeChallenge: appCodeChallenge! }
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
response.cookies.set(OIDC_STATE_COOKIE, cookiePayload, {
|
||||
httpOnly: true,
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (fn: unknown) => fn,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAuthSurfaceRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/native-client", () => ({
|
||||
isCookielessNativeCaller: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/oidc-native-handoff", () => ({
|
||||
consumeNativeHandoff: vi.fn(),
|
||||
stampIssuedRefreshToken: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/login-response", () => ({
|
||||
finishLogin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { isCookielessNativeCaller } from "@/lib/auth/native-client";
|
||||
import {
|
||||
consumeNativeHandoff,
|
||||
stampIssuedRefreshToken,
|
||||
} from "@/lib/auth/oidc-native-handoff";
|
||||
import { finishLogin } from "@/lib/auth/login-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const VALID_BODY = {
|
||||
code: "hlh_" + "a".repeat(43),
|
||||
codeVerifier: "a".repeat(43),
|
||||
};
|
||||
|
||||
function makeRequest(
|
||||
body: unknown = VALID_BODY,
|
||||
headers: Record<string, string> = {},
|
||||
): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/oidc/native/token", {
|
||||
method: "POST",
|
||||
// The genuine iOS client always marks itself native; the transport gate
|
||||
// requires it. A gate-rejection test overrides `x-client-type` explicitly.
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-client-type": "native",
|
||||
...headers,
|
||||
},
|
||||
body: typeof body === "string" ? body : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function nativeBundleResponse() {
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
user: { id: "user-1", username: "alice" },
|
||||
token: "hlk_access",
|
||||
tokenExpiresAt: new Date().toISOString(),
|
||||
refreshToken: "hlr_refresh",
|
||||
refreshTokenExpiresAt: new Date().toISOString(),
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 10,
|
||||
resetAt: 0,
|
||||
ip: "1.2.3.4",
|
||||
} as never);
|
||||
vi.mocked(isCookielessNativeCaller).mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe("POST /api/auth/oidc/native/token", () => {
|
||||
it("429 when rate-limited (before any state is touched)", async () => {
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: 0,
|
||||
ip: "1.2.3.4",
|
||||
} as never);
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(429);
|
||||
expect(consumeNativeHandoff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("401 and never consumes when the transport is not cookie-less native", async () => {
|
||||
vi.mocked(isCookielessNativeCaller).mockReturnValue(false);
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(401);
|
||||
expect(consumeNativeHandoff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422 on a malformed body", async () => {
|
||||
const res = await POST(makeRequest({ code: "nope", codeVerifier: "x" }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(consumeNativeHandoff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns ONE identical generic 401 across every invalid-code class", async () => {
|
||||
const statuses = [
|
||||
{ status: "not_found" },
|
||||
{ status: "replayed", userId: "user-1" },
|
||||
{ status: "expired" },
|
||||
{ status: "pkce_mismatch" },
|
||||
{ status: "race_lost" },
|
||||
] as const;
|
||||
|
||||
const bodies: string[] = [];
|
||||
for (const s of statuses) {
|
||||
vi.mocked(consumeNativeHandoff).mockResolvedValue(s as never);
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(401);
|
||||
bodies.push(await res.text());
|
||||
}
|
||||
// Also the deleted-user-mid-flight branch.
|
||||
vi.mocked(consumeNativeHandoff).mockResolvedValue({
|
||||
status: "ok",
|
||||
userId: "user-1",
|
||||
handoffId: "ho-1",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(401);
|
||||
bodies.push(await res.text());
|
||||
|
||||
// Byte-identical envelope — a code-guesser learns nothing.
|
||||
expect(new Set(bodies).size).toBe(1);
|
||||
});
|
||||
|
||||
it("does not mint on the deleted-user branch", async () => {
|
||||
vi.mocked(consumeNativeHandoff).mockResolvedValue({
|
||||
status: "ok",
|
||||
userId: "user-1",
|
||||
handoffId: "ho-1",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
|
||||
await POST(makeRequest());
|
||||
expect(finishLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("200 on success: mints via the native source, stamps the issued hash, audits", async () => {
|
||||
vi.mocked(consumeNativeHandoff).mockResolvedValue({
|
||||
status: "ok",
|
||||
userId: "user-1",
|
||||
handoffId: "ho-1",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "user-1",
|
||||
username: "alice",
|
||||
onboardingCompletedAt: new Date(),
|
||||
} as never);
|
||||
vi.mocked(finishLogin).mockResolvedValue(nativeBundleResponse());
|
||||
|
||||
const res = await POST(
|
||||
makeRequest(VALID_BODY, { "x-client-type": "native" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const finishArg = vi.mocked(finishLogin).mock.calls[0][0];
|
||||
expect(finishArg.source).toBe("login.oidc.native");
|
||||
// `mfaVerified` is never set — the SSO login can't satisfy step-up.
|
||||
expect(finishArg.mfaVerified).toBeUndefined();
|
||||
|
||||
// The issued refresh token's hash is stamped for replay reach-back.
|
||||
expect(stampIssuedRefreshToken).toHaveBeenCalledWith("ho-1", "hlr_refresh");
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"auth.oidc.login",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: { transport: "native" },
|
||||
}),
|
||||
);
|
||||
|
||||
// The client still receives the bundle (clone left the stream intact).
|
||||
const body = await res.json();
|
||||
expect(body.data.refreshToken).toBe("hlr_refresh");
|
||||
});
|
||||
|
||||
it("is NOT blocked by OIDC_ONLY (it completes an OIDC-initiated login)", async () => {
|
||||
const prev = process.env.OIDC_ONLY;
|
||||
process.env.OIDC_ONLY = "true";
|
||||
try {
|
||||
vi.mocked(consumeNativeHandoff).mockResolvedValue({
|
||||
status: "ok",
|
||||
userId: "user-1",
|
||||
handoffId: "ho-1",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "user-1",
|
||||
username: "alice",
|
||||
onboardingCompletedAt: new Date(),
|
||||
} as never);
|
||||
vi.mocked(finishLogin).mockResolvedValue(nativeBundleResponse());
|
||||
|
||||
const res = await POST(
|
||||
makeRequest(VALID_BODY, { "x-client-type": "native" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.OIDC_ONLY;
|
||||
else process.env.OIDC_ONLY = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* POST /api/auth/oidc/native/token
|
||||
*
|
||||
* The cookie-less native leg of the OIDC SSO flow (design spec §4). The app
|
||||
* presents the one-time handoff code it received on
|
||||
* `healthlog://oidc-callback?code=` plus its PKCE `codeVerifier`; on success it
|
||||
* receives the SAME native token bundle password login issues
|
||||
* (`{ token, tokenExpiresAt, refreshToken, refreshTokenExpiresAt }`).
|
||||
*
|
||||
* Security-critical invariants:
|
||||
* - It IS the credential mint (same class as `/api/auth/mfa/verify`): no
|
||||
* session or Bearer is required to reach it.
|
||||
* - The transport gate (`isCookielessNativeCaller`) makes the endpoint
|
||||
* structurally incapable of minting a web session or handing a 60-day refresh
|
||||
* token into a browser context — a Mozilla UA or an inbound session cookie is
|
||||
* rejected before any state is touched.
|
||||
* - `userId` is derived SOLELY from the server-side handoff row — the request
|
||||
* carries no identity field.
|
||||
* - A single generic 401 covers not-found / expired / used / PKCE-mismatch /
|
||||
* deleted-user so a code-guesser learns nothing; distinct `annotate` names
|
||||
* keep the wide events diagnosable.
|
||||
* - `mfaVerifiedAt` is never set — a Bearer transport can never satisfy
|
||||
* `requireFreshMfa` step-up, so an SSO login can never satisfy step-up.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiError, safeJson } from "@/lib/api-response";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { isCookielessNativeCaller } from "@/lib/auth/native-client";
|
||||
import { isNativeClientRequest } from "@/lib/auth/issue-token";
|
||||
import { finishLogin } from "@/lib/auth/login-response";
|
||||
import {
|
||||
consumeNativeHandoff,
|
||||
stampIssuedRefreshToken,
|
||||
} from "@/lib/auth/oidc-native-handoff";
|
||||
import { oidcNativeTokenSchema } from "@/lib/validations/oidc-native";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** The single generic rejection for every invalid-code class (steps 4–9). */
|
||||
function invalidCode() {
|
||||
return apiError("Invalid or expired code", 401);
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "auth.oidc.native.token" } });
|
||||
|
||||
// (1) Rate limit — byte-for-byte the MFA-verify surface's posture, including
|
||||
// the trust-violation collapse semantics.
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"auth:oidc:native-token",
|
||||
10,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
const ip = rl.ip ?? "unknown";
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many attempts. Please try again later.", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
// (2) Transport gate — this endpoint only ever hands a native token bundle.
|
||||
// A browser (Mozilla UA or an inbound session cookie) is rejected, AND the
|
||||
// caller must present an explicit native marker (`isNativeClientRequest`, the
|
||||
// SAME predicate `finishLogin` uses to pick its bearer branch) — otherwise a
|
||||
// blank-UA caller could slip past `isCookielessNativeCaller` into the cookie
|
||||
// branch and be handed a `Set-Cookie` session instead of the bundle. Both
|
||||
// together make the cookie path structurally unreachable here.
|
||||
if (
|
||||
!isCookielessNativeCaller(request.headers) ||
|
||||
!isNativeClientRequest(request.headers)
|
||||
) {
|
||||
annotate({ action: { name: "auth.oidc.native.token.wrong_transport" } });
|
||||
return invalidCode();
|
||||
}
|
||||
|
||||
await ensureDbCompatibility();
|
||||
|
||||
// (3) Body — bounded, then the exact wire contract.
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = oidcNativeTokenSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
}
|
||||
const { code, codeVerifier } = parsed.data;
|
||||
|
||||
// (4–8) Lookup + replay/expiry/PKCE + atomic single-use consume.
|
||||
const result = await consumeNativeHandoff(code, codeVerifier);
|
||||
if (result.status !== "ok") {
|
||||
switch (result.status) {
|
||||
case "not_found":
|
||||
annotate({ action: { name: "auth.oidc.native.token.not_found" } });
|
||||
break;
|
||||
case "replayed":
|
||||
// Containment + the audit row already ran inside `consumeNativeHandoff`;
|
||||
// report whether the issued pair was actually revoked (false only in the
|
||||
// sub-ms pre-stamp window).
|
||||
annotate({
|
||||
action: { name: "auth.oidc.native.handoff_replay" },
|
||||
meta: { revoked_issued_pair: result.revokedIssuedPair },
|
||||
});
|
||||
break;
|
||||
case "expired":
|
||||
annotate({ action: { name: "auth.oidc.native.token.expired" } });
|
||||
break;
|
||||
case "pkce_mismatch":
|
||||
annotate({ action: { name: "auth.oidc.native.pkce_failed" } });
|
||||
break;
|
||||
case "race_lost":
|
||||
annotate({ action: { name: "auth.oidc.native.token.race_lost" } });
|
||||
break;
|
||||
}
|
||||
return invalidCode();
|
||||
}
|
||||
|
||||
// (9) Load the user (may have been deleted mid-flight).
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: result.userId },
|
||||
select: { id: true, username: true, onboardingCompletedAt: true },
|
||||
});
|
||||
if (!user) {
|
||||
annotate({ action: { name: "auth.oidc.native.token.user_missing" } });
|
||||
return invalidCode();
|
||||
}
|
||||
|
||||
// (10) Mint the native bundle. Step (2) guarantees the cookie-less-native
|
||||
// branch of `finishLogin`, i.e. the 24h access + 60d rotating refresh with
|
||||
// the `auth.token.autoissue.native` audit row. `mfaVerified` is deliberately
|
||||
// unset — the SSO login can never satisfy step-up.
|
||||
const ua = request.headers.get("user-agent");
|
||||
const response = await finishLogin({
|
||||
user,
|
||||
request,
|
||||
ip,
|
||||
userAgent: ua,
|
||||
source: "login.oidc.native",
|
||||
});
|
||||
|
||||
// (11) Stamp the issued refresh token's hash for replay-containment
|
||||
// reach-back, then audit the completed SSO login. Reading the bundle off a
|
||||
// clone leaves the client's response stream intact. Best-effort: the code is
|
||||
// already single-use-consumed, so a stamping hiccup only forgoes the
|
||||
// replay-revoke for this one row.
|
||||
try {
|
||||
const bundle = (await response.clone().json()) as {
|
||||
data?: { refreshToken?: unknown };
|
||||
};
|
||||
const refreshToken = bundle.data?.refreshToken;
|
||||
if (typeof refreshToken === "string") {
|
||||
await stampIssuedRefreshToken(result.handoffId, refreshToken);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — see comment above.
|
||||
}
|
||||
|
||||
await auditLog("auth.oidc.login", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { transport: "native" },
|
||||
});
|
||||
|
||||
return response;
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* `GET /api/dashboard/summary` — module gate.
|
||||
*
|
||||
* This route builds its own payload instead of going through
|
||||
* `readDashboardSnapshotCached`, so it never picked up the gate the shared
|
||||
* snapshot builder applies: it emitted glucose and sleep cards on data
|
||||
* presence alone while `/api/dashboard/snapshot` correctly withheld them for
|
||||
* the very same account.
|
||||
*
|
||||
* OMIT, not 403 — the payload spans ten metric cards across several domains,
|
||||
* and blanking an iOS dashboard because one module is off would be the wrong
|
||||
* trade. Core vitals must keep flowing.
|
||||
*
|
||||
* Behavioural: the assertions read the emitted card list, so reverting the
|
||||
* filter in the route turns them red.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
// The gate resolver runs for real; only its data sources are stubbed, so the
|
||||
// module state is driven the way production drives it.
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
cycleProfile: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/modules/operator-availability", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@/lib/modules/operator-availability")
|
||||
>();
|
||||
return { ...actual, getOperatorModuleAvailability: vi.fn() };
|
||||
});
|
||||
vi.mock("@/lib/feature-flags", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/feature-flags")>();
|
||||
return { ...actual, getAssistantFlags: vi.fn() };
|
||||
});
|
||||
// The heavy builder is stubbed: this test pins the GATE, not the aggregate
|
||||
// (which has its own coverage). It returns one card per emitted kind.
|
||||
vi.mock("@/lib/cache/server-cache", () => ({
|
||||
caches: { analytics: {} },
|
||||
cachedSwr: vi.fn(),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { cachedSwr } from "@/lib/cache/server-cache";
|
||||
import { MODULE_KEYS } from "@/lib/modules/gate";
|
||||
import { getOperatorModuleAvailability } from "@/lib/modules/operator-availability";
|
||||
import { getAssistantFlags } from "@/lib/feature-flags";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "tester",
|
||||
role: "USER" as const,
|
||||
displayName: null,
|
||||
locale: "en",
|
||||
timezone: "UTC",
|
||||
},
|
||||
};
|
||||
|
||||
/** Every card kind the builder can emit, one card each. */
|
||||
const ALL_KINDS = [
|
||||
"weight",
|
||||
"bloodPressure",
|
||||
"pulse",
|
||||
"bodyFat",
|
||||
"glucose",
|
||||
"sleep",
|
||||
"steps",
|
||||
"totalBodyWater",
|
||||
"boneMass",
|
||||
"oxygenSaturation",
|
||||
] as const;
|
||||
|
||||
function card(kind: string) {
|
||||
return {
|
||||
id: kind,
|
||||
kind,
|
||||
titleKey: `dashboard.metric.title.${kind}`,
|
||||
latestValue: 1,
|
||||
secondaryValue: null,
|
||||
unitKey: `dashboard.metric.unit.${kind}`,
|
||||
unit: null,
|
||||
sleepStages: null,
|
||||
sleepSourceDiscrepancy: null,
|
||||
trend: "flat",
|
||||
sparkline: [1],
|
||||
updatedAt: new Date().toISOString(),
|
||||
allTimeCount: 5,
|
||||
lastSeenAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Drive the real gate through the persisted disabled-allowlist. */
|
||||
function setDisabledModules(disabled: string[]): void {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
gender: null,
|
||||
disableCoach: false,
|
||||
modulePreferencesJson: Object.fromEntries(disabled.map((k) => [k, false])),
|
||||
} as never);
|
||||
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
|
||||
}
|
||||
|
||||
async function emittedKinds(): Promise<string[]> {
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { metrics: Array<{ kind: string }> };
|
||||
};
|
||||
return body.data.metrics.map((m) => m.kind);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(getOperatorModuleAvailability).mockResolvedValue(
|
||||
Object.fromEntries(MODULE_KEYS.map((k) => [k, true])) as never,
|
||||
);
|
||||
vi.mocked(getAssistantFlags).mockResolvedValue({ coach: true } as never);
|
||||
vi.mocked(cachedSwr).mockImplementation((async () => ({
|
||||
greeting: { salutation: "hi", date: new Date().toISOString() },
|
||||
streak: { currentDays: 0, longest: 0, label: "" },
|
||||
compliance: { scheduledToday: 0, takenToday: 0 },
|
||||
highlightInsight: null,
|
||||
metrics: ALL_KINDS.map(card),
|
||||
sleepRhythm: null,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
})) as never);
|
||||
});
|
||||
|
||||
describe("GET /api/dashboard/summary — module gate", () => {
|
||||
it("emits every card when no module is disabled", async () => {
|
||||
setDisabledModules([]);
|
||||
expect(await emittedKinds()).toEqual([...ALL_KINDS]);
|
||||
});
|
||||
|
||||
it("drops the glucose card when the glucose module is off", async () => {
|
||||
setDisabledModules(["glucose"]);
|
||||
const kinds = await emittedKinds();
|
||||
|
||||
expect(kinds).not.toContain("glucose");
|
||||
// Everything else survives — proves an omission, not a refusal.
|
||||
expect(kinds).toContain("weight");
|
||||
expect(kinds).toContain("sleep");
|
||||
expect(kinds).toHaveLength(ALL_KINDS.length - 1);
|
||||
});
|
||||
|
||||
it("drops the sleep card when the sleep module is off", async () => {
|
||||
setDisabledModules(["sleep"]);
|
||||
const kinds = await emittedKinds();
|
||||
|
||||
expect(kinds).not.toContain("sleep");
|
||||
expect(kinds).toContain("glucose");
|
||||
expect(kinds).toHaveLength(ALL_KINDS.length - 1);
|
||||
});
|
||||
|
||||
it("drops both when both modules are off", async () => {
|
||||
setDisabledModules(["glucose", "sleep"]);
|
||||
const kinds = await emittedKinds();
|
||||
|
||||
expect(kinds).not.toContain("glucose");
|
||||
expect(kinds).not.toContain("sleep");
|
||||
expect(kinds).toHaveLength(ALL_KINDS.length - 2);
|
||||
});
|
||||
|
||||
it("never drops a core vital, whatever is disabled", async () => {
|
||||
// Core metrics carry no SUMMARY_TYPE_MODULE entry and must always ship.
|
||||
setDisabledModules([...MODULE_KEYS]);
|
||||
const kinds = await emittedKinds();
|
||||
|
||||
for (const core of [
|
||||
"weight",
|
||||
"bloodPressure",
|
||||
"pulse",
|
||||
"bodyFat",
|
||||
"steps",
|
||||
"totalBodyWater",
|
||||
"boneMass",
|
||||
"oxygenSaturation",
|
||||
]) {
|
||||
expect(kinds).toContain(core);
|
||||
}
|
||||
});
|
||||
|
||||
it("gates the cached body, so a toggle takes effect on the next request", async () => {
|
||||
// The analytics LRU is not evicted by a module toggle. The filter must
|
||||
// therefore run on the way out, not inside the cached build.
|
||||
setDisabledModules([]);
|
||||
expect(await emittedKinds()).toContain("glucose");
|
||||
|
||||
// Same cached body, module now off.
|
||||
setDisabledModules(["glucose"]);
|
||||
expect(await emittedKinds()).not.toContain("glucose");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,19 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// Every module is on for the aggregate assertions below; the gate's own
|
||||
// behaviour (which cards a disabled module drops) is pinned in the sibling
|
||||
// `module-gate.test.ts`.
|
||||
vi.mock("@/lib/modules/gate", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/modules/gate")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveModuleMap: vi.fn(async () =>
|
||||
Object.fromEntries(actual.MODULE_KEYS.map((k) => [k, true])),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
// v1.11.4 — `findMany` reads the raw per-stage SLEEP_DURATION rows for
|
||||
|
||||
@@ -44,6 +44,12 @@
|
||||
* subsequent iOS polls inside the window hit memory. Invalidation runs
|
||||
* via the existing `invalidateUserMeasurements` + the v1.4.38-extended
|
||||
* `invalidateUserMedications` hooks.
|
||||
*
|
||||
* Module gate: the emitted `metrics` array is filtered against the account's
|
||||
* module map before it leaves the handler, so a card whose measurement type
|
||||
* belongs to a disabled module never ships. The decision comes from the same
|
||||
* map the shared snapshot builder uses (`disabledSummaryTypes`); the filter
|
||||
* runs OUTSIDE the cache because a module toggle does not evict it.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
@@ -57,6 +63,8 @@ import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
|
||||
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { gateMetricCardsByModules } from "@/lib/dashboard/widget-modules";
|
||||
import {
|
||||
summarizeSleepNights,
|
||||
reconstructSleepNights,
|
||||
@@ -364,7 +372,35 @@ export const GET = apiHandler(async () => {
|
||||
annotate,
|
||||
);
|
||||
|
||||
return apiSuccess(body);
|
||||
// ── module gate ───────────────────────────────────────────────────
|
||||
// This route builds its own payload instead of going through
|
||||
// `readDashboardSnapshotCached`, so it never picked up the module gate
|
||||
// the shared snapshot builder applies — it emitted glucose and sleep
|
||||
// tiles on data presence alone, while `/api/dashboard/snapshot` correctly
|
||||
// withheld them for the same account.
|
||||
//
|
||||
// OMIT, NOT 403 — matching the snapshot sibling and the sync feed. This is
|
||||
// an aggregate of ten metric cards across several domains; refusing the
|
||||
// whole request would blank the iOS dashboard because one module is off.
|
||||
// The card simply does not appear, which is exactly what a disabled module
|
||||
// means on every other dashboard surface.
|
||||
//
|
||||
// Applied AFTER the cache read, deliberately. The analytics LRU is keyed
|
||||
// `<userId>|dashboard-summary` with a 60 s fresh / 1 h stale window, and
|
||||
// toggling a module does not evict it — gating inside the builder would
|
||||
// leave the disabled tile served for up to an hour after the user turned
|
||||
// it off. Filtering the cached body makes the toggle effective on the very
|
||||
// next request.
|
||||
const modules = await resolveModuleMap(user.id);
|
||||
const metrics = gateMetricCardsByModules(body.metrics, modules);
|
||||
annotate({
|
||||
meta: {
|
||||
metrics_emitted: metrics.length,
|
||||
metrics_gated: body.metrics.length - metrics.length,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ ...body, metrics });
|
||||
});
|
||||
|
||||
interface SummaryBuilderContext {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
@@ -32,6 +33,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth(FHIR_READ_SCOPE);
|
||||
annotate({ action: { name: "fhir.everything.read" } });
|
||||
|
||||
// v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
|
||||
// `/api/export/health-record` (the doctor-report builder), right down to
|
||||
// the decrypted insurance number on the Patient resource. That export
|
||||
// gates on the `doctorReport` module; this surface did not, so the module
|
||||
// could be off and `/api/fhir/*` still handed out the full record.
|
||||
//
|
||||
// REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is
|
||||
// a whole-record export, not an incremental feed: there is no partial
|
||||
// answer that is still a truthful FHIR Bundle, and no background client
|
||||
// depends on it draining to stay consistent. Mirroring the sibling export's
|
||||
// 403 `module.disabled` envelope (rather than an OperationOutcome) keeps
|
||||
// the errorCode the clients already branch on for a disabled module.
|
||||
const gate = await requireModuleEnabled(user.id, "doctorReport");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return operationOutcome(429, "throttled", "Rate limit exceeded");
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { medicationAdministrationsFromReportData } from "@/lib/fhir/resources";
|
||||
@@ -24,6 +25,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth(FHIR_READ_SCOPE);
|
||||
annotate({ action: { name: "fhir.medicationadministration.search" } });
|
||||
|
||||
// v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
|
||||
// `/api/export/health-record` (the doctor-report builder), right down to
|
||||
// the decrypted insurance number on the Patient resource. That export
|
||||
// gates on the `doctorReport` module; this surface did not, so the module
|
||||
// could be off and `/api/fhir/*` still handed out the full record.
|
||||
//
|
||||
// REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is
|
||||
// a whole-record export, not an incremental feed: there is no partial
|
||||
// answer that is still a truthful FHIR Bundle, and no background client
|
||||
// depends on it draining to stay consistent. Mirroring the sibling export's
|
||||
// 403 `module.disabled` envelope (rather than an OperationOutcome) keeps
|
||||
// the errorCode the clients already branch on for a disabled module.
|
||||
const gate = await requireModuleEnabled(user.id, "doctorReport");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return operationOutcome(429, "throttled", "Rate limit exceeded");
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { medicationStatementsFromReportData } from "@/lib/fhir/resources";
|
||||
@@ -25,6 +26,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth(FHIR_READ_SCOPE);
|
||||
annotate({ action: { name: "fhir.medicationstatement.search" } });
|
||||
|
||||
// v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
|
||||
// `/api/export/health-record` (the doctor-report builder), right down to
|
||||
// the decrypted insurance number on the Patient resource. That export
|
||||
// gates on the `doctorReport` module; this surface did not, so the module
|
||||
// could be off and `/api/fhir/*` still handed out the full record.
|
||||
//
|
||||
// REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is
|
||||
// a whole-record export, not an incremental feed: there is no partial
|
||||
// answer that is still a truthful FHIR Bundle, and no background client
|
||||
// depends on it draining to stay consistent. Mirroring the sibling export's
|
||||
// 403 `module.disabled` envelope (rather than an OperationOutcome) keeps
|
||||
// the errorCode the clients already branch on for a disabled module.
|
||||
const gate = await requireModuleEnabled(user.id, "doctorReport");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return operationOutcome(429, "throttled", "Rate limit exceeded");
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { observationsFromReportData } from "@/lib/fhir/resources";
|
||||
@@ -25,6 +26,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth(FHIR_READ_SCOPE);
|
||||
annotate({ action: { name: "fhir.observation.search" } });
|
||||
|
||||
// v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
|
||||
// `/api/export/health-record` (the doctor-report builder), right down to
|
||||
// the decrypted insurance number on the Patient resource. That export
|
||||
// gates on the `doctorReport` module; this surface did not, so the module
|
||||
// could be off and `/api/fhir/*` still handed out the full record.
|
||||
//
|
||||
// REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is
|
||||
// a whole-record export, not an incremental feed: there is no partial
|
||||
// answer that is still a truthful FHIR Bundle, and no background client
|
||||
// depends on it draining to stay consistent. Mirroring the sibling export's
|
||||
// 403 `module.disabled` envelope (rather than an OperationOutcome) keeps
|
||||
// the errorCode the clients already branch on for a disabled module.
|
||||
const gate = await requireModuleEnabled(user.id, "doctorReport");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return operationOutcome(429, "throttled", "Rate limit exceeded");
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { patientResource } from "@/lib/fhir/resources";
|
||||
@@ -24,6 +25,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth(FHIR_READ_SCOPE);
|
||||
annotate({ action: { name: "fhir.patient.search" } });
|
||||
|
||||
// v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
|
||||
// `/api/export/health-record` (the doctor-report builder), right down to
|
||||
// the decrypted insurance number on the Patient resource. That export
|
||||
// gates on the `doctorReport` module; this surface did not, so the module
|
||||
// could be off and `/api/fhir/*` still handed out the full record.
|
||||
//
|
||||
// REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is
|
||||
// a whole-record export, not an incremental feed: there is no partial
|
||||
// answer that is still a truthful FHIR Bundle, and no background client
|
||||
// depends on it draining to stay consistent. Mirroring the sibling export's
|
||||
// 403 `module.disabled` envelope (rather than an OperationOutcome) keeps
|
||||
// the errorCode the clients already branch on for a disabled module.
|
||||
const gate = await requireModuleEnabled(user.id, "doctorReport");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return operationOutcome(429, "throttled", "Rate limit exceeded");
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* `/api/fhir/*` — doctorReport module gate.
|
||||
*
|
||||
* The FHIR REST face serves the same whole-record aggregate as
|
||||
* `/api/export/health-record`, including the decrypted insurance number on
|
||||
* the Patient resource. The export gates on the `doctorReport` module; the
|
||||
* FHIR routes did not, so the module could be off and `$everything` still
|
||||
* returned the full Bundle to the same token.
|
||||
*
|
||||
* REFUSE, not omit — a whole-record export has no truthful partial answer.
|
||||
* Every data route 403s with the shared `module.disabled` envelope; only the
|
||||
* static CapabilityStatement at `/api/fhir/metadata` stays open (server
|
||||
* metadata, no user data).
|
||||
*
|
||||
* Behavioural: the assertions read status codes and check that no aggregate
|
||||
* was ever loaded, so removing a gate turns them red.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
// The module gate itself is NOT mocked — the whole point is to exercise the
|
||||
// real resolver and the real 403 envelope. Only its data sources are stubbed,
|
||||
// so the module state is driven the same way production drives it: through
|
||||
// the persisted `modulePreferencesJson` allowlist.
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
cycleProfile: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/modules/operator-availability", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@/lib/modules/operator-availability")
|
||||
>();
|
||||
return { ...actual, getOperatorModuleAvailability: vi.fn() };
|
||||
});
|
||||
vi.mock("@/lib/fhir/rest", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/fhir/rest")>();
|
||||
return { ...actual, loadFhirContext: vi.fn() };
|
||||
});
|
||||
vi.mock("@/lib/fhir/resources", () => ({
|
||||
GERMAN_ATC_DEFAULT_LOCALES: ["de"],
|
||||
patientResource: vi.fn(() => ({ resourceType: "Patient", id: "p-1" })),
|
||||
coverageResource: vi.fn(() => null),
|
||||
observationsFromReportData: vi.fn(() => []),
|
||||
medicationStatementsFromReportData: vi.fn(() => []),
|
||||
medicationAdministrationsFromReportData: vi.fn(() => []),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET as patientGet } from "../Patient/route";
|
||||
import { GET as observationGet } from "../Observation/route";
|
||||
import { GET as medStatementGet } from "../MedicationStatement/route";
|
||||
import { GET as medAdminGet } from "../MedicationAdministration/route";
|
||||
import { GET as everythingGet } from "../$everything/route";
|
||||
import { GET as metadataGet } from "../metadata/route";
|
||||
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { MODULE_DISABLED_ERROR_CODE, MODULE_KEYS } from "@/lib/modules/gate";
|
||||
import { getOperatorModuleAvailability } from "@/lib/modules/operator-availability";
|
||||
import { loadFhirContext } from "@/lib/fhir/rest";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Drive the REAL gate the way production does — through the persisted
|
||||
* `modulePreferencesJson` disabled-allowlist, where only a literal `false`
|
||||
* turns a module off.
|
||||
*/
|
||||
function setDoctorReportModule(enabled: boolean): void {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
gender: null,
|
||||
disableCoach: false,
|
||||
modulePreferencesJson: enabled ? {} : { doctorReport: false },
|
||||
} as never);
|
||||
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
|
||||
}
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
/** Every data route under `/api/fhir`, by the path a caller would hit. */
|
||||
const DATA_ROUTES: ReadonlyArray<
|
||||
[string, (req: NextRequest) => Promise<Response>]
|
||||
> = [
|
||||
["Patient", patientGet],
|
||||
["Observation", observationGet],
|
||||
["MedicationStatement", medStatementGet],
|
||||
["MedicationAdministration", medAdminGet],
|
||||
["$everything", everythingGet],
|
||||
];
|
||||
|
||||
function req(path: string): NextRequest {
|
||||
return new NextRequest(`http://localhost/api/fhir/${path}`);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
count: 1,
|
||||
resetAt: Date.now(),
|
||||
} as never);
|
||||
// Operator layer available for every module; the per-user layer is what
|
||||
// each test drives.
|
||||
vi.mocked(getOperatorModuleAvailability).mockResolvedValue(
|
||||
Object.fromEntries(MODULE_KEYS.map((k) => [k, true])) as never,
|
||||
);
|
||||
vi.mocked(loadFhirContext).mockResolvedValue({
|
||||
data: {} as never,
|
||||
identity: { insuranceNumber: "A123456789" },
|
||||
germanAtc: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/fhir/* — doctorReport module gate", () => {
|
||||
describe.each(DATA_ROUTES)("GET /api/fhir/%s", (path, handler) => {
|
||||
it("403s with module.disabled when the module is off", async () => {
|
||||
setDoctorReportModule(false);
|
||||
|
||||
const res = await handler(req(path));
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: unknown;
|
||||
error: string;
|
||||
meta?: { errorCode?: string; module?: string };
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.meta?.errorCode).toBe(MODULE_DISABLED_ERROR_CODE);
|
||||
expect(body.meta?.module).toBe("doctorReport");
|
||||
|
||||
// Refused before any record was assembled — the insurance number
|
||||
// never left the store.
|
||||
expect(loadFhirContext).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(body)).not.toContain("A123456789");
|
||||
});
|
||||
|
||||
it("serves the Bundle when the module is on", async () => {
|
||||
setDoctorReportModule(true);
|
||||
|
||||
const res = await handler(req(path));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toContain(
|
||||
"application/fhir+json",
|
||||
);
|
||||
|
||||
const bundle = (await res.json()) as { resourceType: string };
|
||||
expect(bundle.resourceType).toBe("Bundle");
|
||||
expect(loadFhirContext).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the static CapabilityStatement reachable while the module is off", async () => {
|
||||
// Server metadata, no user data — the one FHIR route that stays open.
|
||||
setDoctorReportModule(false);
|
||||
const res = await metadataGet();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { resourceType: string };
|
||||
expect(body.resourceType).toBe("CapabilityStatement");
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,10 @@ vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(),
|
||||
isModuleEnabled: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/fhir/rest", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/fhir/rest")>();
|
||||
return { ...actual, loadFhirContext: vi.fn() };
|
||||
@@ -36,6 +40,7 @@ import { GET as observationGet } from "../Observation/route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { loadFhirContext, MAX_COUNT } from "@/lib/fhir/rest";
|
||||
import { requireModuleEnabled, isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { observationsFromReportData } from "@/lib/fhir/resources";
|
||||
|
||||
const SESSION_OK = {
|
||||
@@ -65,6 +70,10 @@ beforeEach(() => {
|
||||
count: 1,
|
||||
resetAt: Date.now(),
|
||||
} as never);
|
||||
// The doctorReport module is ON for the contract assertions below; the
|
||||
// OFF behaviour is pinned in `module-gate.test.ts`.
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
|
||||
vi.mocked(isModuleEnabled).mockResolvedValue(true);
|
||||
vi.mocked(loadFhirContext).mockResolvedValue({
|
||||
data: {} as never,
|
||||
identity: { insuranceNumber: null },
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
listConversations,
|
||||
} from "@/lib/ai/coach/persistence";
|
||||
import { enqueueCoachMemoryRefresh } from "@/lib/ai/coach/coach-memory-shared";
|
||||
import { instructionLocale } from "@/lib/ai/prompts/output-language";
|
||||
import { storeDeterministicFacts } from "@/lib/ai/coach/facts";
|
||||
import {
|
||||
buildDateKey,
|
||||
@@ -485,9 +486,12 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
void enqueueCoachMemoryRefresh({
|
||||
conversationId: workingConversationId,
|
||||
userId,
|
||||
// Coach memory prose is composed in de/en only (the snapshot's
|
||||
// coachLocale); collapse the wider UI locale union here.
|
||||
locale: locale === "en" ? "en" : "de",
|
||||
// Coach memory prose is composed in de/en only — it is MODEL-FACING
|
||||
// context (a rolling conversation summary + extracted durable facts),
|
||||
// not user-facing prose, so English is the correct target for every
|
||||
// locale without a reviewed body. The former `=== "en" ? "en" : "de"`
|
||||
// binary composed and keyed a French account's memory in German.
|
||||
locale: instructionLocale(locale),
|
||||
});
|
||||
}
|
||||
// v1.19.1 (C4) — token efficiency. The full SNAPSHOT (now incl. labs,
|
||||
|
||||
@@ -24,46 +24,16 @@
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { readCoachNudgeStatus } from "@/lib/ai/coach/nudge-status";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
await requireAssistantSurface("coach");
|
||||
|
||||
// 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" },
|
||||
// v1.22 (W5) — also return the owning conversation id so the FAB can
|
||||
// deep-link straight into the proactive thread (`/coach?c=<id>`)
|
||||
// instead of opening a blank new chat and relying on a most-recent
|
||||
// heuristic to find it.
|
||||
select: { createdAt: true, conversationId: true },
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { coachLastSeenAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const nudgedAt = lastAssistant?.createdAt ?? null;
|
||||
const lastSeenAt = seen?.coachLastSeenAt ?? null;
|
||||
const unread =
|
||||
nudgedAt !== null && (lastSeenAt === null || lastSeenAt < nudgedAt);
|
||||
|
||||
return apiSuccess({
|
||||
nudgedAt: nudgedAt ? nudgedAt.toISOString() : null,
|
||||
unread,
|
||||
// The conversation holding the newest assistant message. Present
|
||||
// whenever a Coach reply exists; the FAB only consumes it on the
|
||||
// unread path.
|
||||
conversationId: lastAssistant?.conversationId ?? null,
|
||||
});
|
||||
// Shared with the `/coach` RSC prefetch (`src/app/coach/page.tsx`) so both
|
||||
// readers compute the unread signal identically.
|
||||
return apiSuccess(await readCoachNudgeStatus(user.id));
|
||||
});
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -21,6 +21,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
@@ -82,6 +83,11 @@ export const GET = apiHandler(async () => {
|
||||
select: { timezone: true },
|
||||
});
|
||||
const tz = profile?.timezone ?? "Europe/Berlin";
|
||||
// Reader's locale for the narrated `interpretation` — the correlation cards
|
||||
// render this string verbatim, so it MUST be localised (cookie / User.locale /
|
||||
// Accept-Language). Without it the never-causal sentence leaked English into a
|
||||
// non-English UI.
|
||||
const locale = await resolveServerLocale({ userLocale: user.locale ?? null });
|
||||
const now = new Date();
|
||||
const since = new Date(now.getTime() - WINDOW_DAYS * MS_PER_DAY);
|
||||
|
||||
@@ -165,7 +171,7 @@ export const GET = apiHandler(async () => {
|
||||
series.push(envSeries);
|
||||
}
|
||||
|
||||
const result = discoverCorrelations(series);
|
||||
const result = discoverCorrelations(series, { locale });
|
||||
|
||||
// v1.22 — rolling early-detection pass over the trailing window, re-using
|
||||
// the already-built series (no extra DB read). Emerging pairs exclude anything
|
||||
@@ -176,11 +182,14 @@ export const GET = apiHandler(async () => {
|
||||
);
|
||||
const emerging = discoverEmergingCorrelations(series, result, {
|
||||
recentFromDayKey,
|
||||
locale,
|
||||
});
|
||||
|
||||
// v1.22 — labs ↔ outcome pass (point-vs-window over sparse draws). Degrades
|
||||
// to absent when the user has too few draws to clear the per-pair floor.
|
||||
const labCorrelations = discoverLabOutcomeCorrelations(labDraws, series);
|
||||
const labCorrelations = discoverLabOutcomeCorrelations(labDraws, series, {
|
||||
locale,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.correlations.discover" },
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from "@/lib/insights/derived";
|
||||
import { resolveDeterministicAssessment } from "@/lib/insights/derived/derived-assessment";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { instructionLocale } from "@/lib/ai/prompts/output-language";
|
||||
import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { DERIVED_MODULE } from "../route";
|
||||
|
||||
@@ -177,7 +178,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
userLocale: user.locale ?? null,
|
||||
override: request.nextUrl.searchParams.get("locale"),
|
||||
});
|
||||
const assessmentLocale = locale === "de" ? "de" : "en";
|
||||
// This grid only carries the DETERMINISTIC assessment, whose templates ship
|
||||
// de/en bodies — so the same instruction-body rule the prompts use applies:
|
||||
// German for a German reader, English for everyone else. Deliberately NOT
|
||||
// widened to the six; the AI-warm prose (which does carry the full locale)
|
||||
// is served lazily by the per-score route.
|
||||
const assessmentLocale = instructionLocale(locale);
|
||||
|
||||
// v1.16.8 — read-through the per-user derived cache with
|
||||
// stale-while-revalidate. The cold build below walks the rollup tier
|
||||
@@ -244,7 +250,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
async function buildDerivedBatch(input: {
|
||||
userId: string;
|
||||
items: BatchItem[];
|
||||
assessmentLocale: "de" | "en";
|
||||
assessmentLocale: ReturnType<typeof instructionLocale>;
|
||||
}) {
|
||||
const { userId, items, assessmentLocale } = input;
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { invalidateUserInsights } from "@/lib/cache/invalidate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { normalizeLocale } from "@/lib/insights/status-shared";
|
||||
import { hasUsableStatusProvider } from "@/lib/insights/status-provider";
|
||||
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
|
||||
import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
|
||||
@@ -202,7 +203,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
request,
|
||||
userLocale: dbUser?.locale ?? user.locale ?? null,
|
||||
});
|
||||
const locale = resolved === "de" ? "de" : "en";
|
||||
const locale = normalizeLocale(resolved);
|
||||
void enqueueForceWarm({ userId, locale });
|
||||
revalidating = true;
|
||||
}
|
||||
@@ -950,7 +951,7 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
// refresh path until the next nightly warm.
|
||||
const refillScopes = await enqueueStatusRefillForUser(
|
||||
userId,
|
||||
locale === "de" ? "de" : "en",
|
||||
normalizeLocale(locale),
|
||||
);
|
||||
annotate({
|
||||
action: { name: "insights.generate.cards_refill" },
|
||||
|
||||
@@ -17,6 +17,7 @@ import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { locales, defaultLocale, type Locale } from "@/lib/i18n/config";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { readPeriodNarrative } from "@/lib/insights/narrative/period-narrative-generate";
|
||||
@@ -37,8 +38,17 @@ const narrativeQuerySchema = z.object({
|
||||
/** A narrative read this recently is considered fresh; no warm is enqueued. */
|
||||
const NARRATIVE_FRESH_MS = 20 * 60 * 60 * 1000;
|
||||
|
||||
function narrowLocale(locale: string): "de" | "en" {
|
||||
return locale === "en" ? "en" : "de";
|
||||
/**
|
||||
* The narrative row is keyed `(user, period, locale)` across the full UI
|
||||
* locale union, so the resolved locale is carried through as-is.
|
||||
*
|
||||
* This used to be `locale === "en" ? "en" : "de"` — which sent every French,
|
||||
* Spanish, Italian and Polish reader to the German narrative row and the
|
||||
* German prompt behind it. `resolveServerLocale` already returns a validated
|
||||
* `Locale`; an unknown value falls back to the app default (`en`) there.
|
||||
*/
|
||||
function narrowLocale(locale: Locale): Locale {
|
||||
return locales.includes(locale) ? locale : defaultLocale;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
@@ -30,6 +30,7 @@ import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { normalizeLocale } from "@/lib/insights/status-shared";
|
||||
import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -59,7 +60,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
request,
|
||||
userLocale: user.locale ?? null,
|
||||
});
|
||||
const locale = resolved === "en" ? "en" : "de";
|
||||
// Carry the reader's ACTUAL locale into the warm payload. The former
|
||||
// `resolved === "en" ? "en" : "de"` sent every fr/es/it/pl account down the
|
||||
// German warm, so the whole warmed cache family was in the wrong language.
|
||||
const locale = normalizeLocale(resolved);
|
||||
|
||||
await enqueueForceWarm({ userId, locale });
|
||||
|
||||
|
||||
+49
-14
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* v1.19.0 (iOS #34) — go-forward aggregated heart-rate wire contract on
|
||||
* `POST /api/measurements/batch`.
|
||||
* v1.30.7/v1.30.8 (iOS #34) — go-forward aggregated 10-min heart-rate wire
|
||||
* contract on `POST /api/measurements/batch`.
|
||||
*
|
||||
* Asserts:
|
||||
* - a fresh hourly HR bucket inserts;
|
||||
* - a re-post of the same hour OVERWRITES (status `updated`, not a
|
||||
* - a fresh 10-min HR bucket inserts, with its per-bucket min/max spread;
|
||||
* - a re-post of the same bucket OVERWRITES (status `updated`, not a
|
||||
* duplicate row);
|
||||
* - a malformed hourly-HR bucket externalId is `skipped`;
|
||||
* - a malformed / off-grid aggregated HR bucket externalId is `skipped`;
|
||||
* - v1.30.8: an out-of-range or mis-ordered spread is dropped to null while
|
||||
* the trustworthy average survives;
|
||||
* - the per-sample uuid HR path is unaffected (immutable duplicate);
|
||||
* - the existing per-day step `stats:` overwrite path is unaffected.
|
||||
*/
|
||||
@@ -131,8 +133,8 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ count: 1 });
|
||||
});
|
||||
|
||||
describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", () => {
|
||||
it("inserts a fresh hourly HR bucket as a PULSE row", async () => {
|
||||
describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34)", () => {
|
||||
it("inserts a fresh 10-min HR bucket as a PULSE row", async () => {
|
||||
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
|
||||
const res = await POST(
|
||||
makeRequest({ entries: [hrBucketEntry(HR_BUCKET_ID, 72)] }),
|
||||
@@ -143,7 +145,7 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
|
||||
expect(data.updated).toBe(0);
|
||||
expect(data.entries[0].status).toBe("inserted");
|
||||
|
||||
// Stored as a PULSE row carrying the hourly average value.
|
||||
// Stored as a PULSE row carrying the 10-min average value.
|
||||
const createArg = vi.mocked(prisma.measurement.createMany).mock.calls[0][0];
|
||||
const rows = (createArg as { data: { type: string; value: number }[] })
|
||||
.data;
|
||||
@@ -151,7 +153,7 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
|
||||
expect(rows[0].value).toBe(72);
|
||||
});
|
||||
|
||||
it("persists per-bucket min/max on a fresh hourly HR bucket (iOS #34 ext)", async () => {
|
||||
it("persists per-bucket min/max on a fresh 10-min HR bucket (iOS #34 ext)", async () => {
|
||||
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
@@ -172,6 +174,39 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
|
||||
expect(rows[0].valueMax).toBe(96);
|
||||
});
|
||||
|
||||
it.each([
|
||||
// out-of-plausible-range spread (a sensor glitch / spurious discreteMax)
|
||||
{ valueMin: 58, valueMax: 99999 },
|
||||
{ valueMin: -9999, valueMax: 96 },
|
||||
// mis-ordered: valueMin above the average, or valueMax below it
|
||||
{ valueMin: 80, valueMax: 96 },
|
||||
{ valueMin: 58, valueMax: 70 },
|
||||
])(
|
||||
"v1.30.8 — drops an out-of-range or mis-ordered spread to null but keeps the average %o",
|
||||
async (spread) => {
|
||||
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
|
||||
const res = await POST(
|
||||
makeRequest({ entries: [hrBucketEntry(HR_BUCKET_ID, 72, spread)] }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const createArg = vi.mocked(prisma.measurement.createMany).mock
|
||||
.calls[0][0];
|
||||
const rows = (
|
||||
createArg as {
|
||||
data: {
|
||||
value: number;
|
||||
valueMin: number | null;
|
||||
valueMax: number | null;
|
||||
}[];
|
||||
}
|
||||
).data;
|
||||
// The trustworthy average survives; the invalid spread is dropped.
|
||||
expect(rows[0].value).toBe(72);
|
||||
expect(rows[0].valueMin).toBeNull();
|
||||
expect(rows[0].valueMax).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("overwrites min/max alongside the average on a re-post", async () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ type: "PULSE", source: "APPLE_HEALTH", externalId: HR_BUCKET_ID },
|
||||
@@ -214,8 +249,8 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
|
||||
expect(rows[0].valueMax).toBeNull();
|
||||
});
|
||||
|
||||
it("overwrites the same hour bucket on a re-post (updated, not duplicate)", async () => {
|
||||
// The bucket already exists from an earlier within-hour post.
|
||||
it("overwrites the same bucket on a re-post (updated, not duplicate)", async () => {
|
||||
// The bucket already exists from an earlier within-bucket post.
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{
|
||||
type: "PULSE",
|
||||
@@ -234,19 +269,19 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
|
||||
expect(data.duplicates).toBe(0);
|
||||
expect(data.entries[0].status).toBe("updated");
|
||||
|
||||
// The overwrite went through updateMany with the new hourly average.
|
||||
// The overwrite went through updateMany with the new 10-min average.
|
||||
expect(prisma.measurement.updateMany).toHaveBeenCalledTimes(1);
|
||||
const updateArg = vi.mocked(prisma.measurement.updateMany).mock.calls[0][0];
|
||||
expect((updateArg as { data: { value: number } }).data.value).toBe(78);
|
||||
expect(prisma.measurement.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips a malformed hourly HR bucket externalId", async () => {
|
||||
it("skips a malformed aggregated HR bucket externalId", async () => {
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
hrBucketEntry(
|
||||
"stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:30:00.000Z",
|
||||
"stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:35:00.000Z",
|
||||
70,
|
||||
),
|
||||
],
|
||||
@@ -50,8 +50,8 @@ import {
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import {
|
||||
mapAppleHealthEntry,
|
||||
isHourlyHeartRateStatsExternalId,
|
||||
targetsHourlyHeartRateBucket,
|
||||
isAggregatedBucketExternalId,
|
||||
targetsAggregatedBucket,
|
||||
} from "@/lib/measurements/apple-health-mapping";
|
||||
import {
|
||||
MEASURED_AT_TOLERANCE_MS,
|
||||
@@ -292,19 +292,18 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// v1.19.0 (iOS #34) — go-forward aggregated heart-rate wire
|
||||
// contract. A `stats:HKQuantityTypeIdentifierHeartRate:<hour>` row
|
||||
// is the hourly-average PULSE bucket; it rides the generic `stats:`
|
||||
// overwrite path below so a within-hour re-post (the running mean
|
||||
// shifts) replaces the value instead of duplicating. Reject a row
|
||||
// that targets the HR-bucket prefix but carries a malformed hour
|
||||
// suffix — a garbage suffix would mint an un-overwriteable row that
|
||||
// the next hour's re-post can't collapse onto. A well-formed bucket
|
||||
// (and every non-HR `stats:` / per-sample `uuid` externalId) passes
|
||||
// through unchanged.
|
||||
// v1.30.7 (iOS #34) — go-forward aggregated heart-rate wire
|
||||
// contract. A `stats:HKQuantityTypeIdentifierHeartRate:<10-min-ISO-Z>`
|
||||
// row is the 10-minute-average PULSE bucket; it rides the generic
|
||||
// `stats:` overwrite path below so a within-bucket re-post (the running
|
||||
// mean shifts) replaces the value instead of duplicating. Reject a row
|
||||
// that targets a bucket prefix but carries a malformed suffix — a
|
||||
// garbage suffix would mint an un-overwriteable row the next re-post
|
||||
// can't collapse onto. A well-formed bucket (and every non-bucket
|
||||
// `stats:` / per-sample `uuid` externalId) passes through unchanged.
|
||||
if (
|
||||
targetsHourlyHeartRateBucket(entry.externalId) &&
|
||||
!isHourlyHeartRateStatsExternalId(entry.externalId)
|
||||
targetsAggregatedBucket(entry.externalId) &&
|
||||
!isAggregatedBucketExternalId(entry.externalId)
|
||||
) {
|
||||
results[index] = {
|
||||
index,
|
||||
@@ -314,20 +313,30 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// v1.19.2 (iOS #34 extension) — the per-bucket MIN / MAX are persisted
|
||||
// ONLY on a well-formed hourly HR bucket row. A per-sample reading, a
|
||||
// v1.30.7 (iOS #34) — the per-bucket MIN / MAX are persisted ONLY on a
|
||||
// well-formed 10-min aggregated bucket row. A per-sample reading, a
|
||||
// per-day cumulative `stats:` total, or a manual entry never carries a
|
||||
// spread, so we pin them to null there even if a client mis-sends the
|
||||
// fields — `value` stays the single source of truth for those rows.
|
||||
const isHrBucket = isHourlyHeartRateStatsExternalId(entry.externalId);
|
||||
const valueMin =
|
||||
isHrBucket && typeof mapped.valueMin === "number"
|
||||
? mapped.valueMin
|
||||
: null;
|
||||
const valueMax =
|
||||
isHrBucket && typeof mapped.valueMax === "number"
|
||||
? mapped.valueMax
|
||||
: null;
|
||||
//
|
||||
// v1.30.8 — validate the spread the same way `value` is guarded (above):
|
||||
// it now drives the DAY min/max band + the intraday envelope, so an
|
||||
// out-of-range or mis-ordered spread (a spurious `discreteMin` of 0, a
|
||||
// sensor glitch) would poison the aggregate exactly like a bogus `value`
|
||||
// would. Persist the spread ONLY when both extremes are present, in range,
|
||||
// and correctly ordered around `value`; otherwise drop to null and keep the
|
||||
// trustworthy average. Partial or invalid spread → avg-only, still valid.
|
||||
const isHrBucket = isAggregatedBucketExternalId(entry.externalId);
|
||||
const spreadValid =
|
||||
isHrBucket &&
|
||||
typeof mapped.valueMin === "number" &&
|
||||
typeof mapped.valueMax === "number" &&
|
||||
validateMeasurementRange(mapped.type, mapped.valueMin) === null &&
|
||||
validateMeasurementRange(mapped.type, mapped.valueMax) === null &&
|
||||
mapped.valueMin <= mapped.value &&
|
||||
mapped.value <= mapped.valueMax;
|
||||
const valueMin = spreadValid ? mapped.valueMin : null;
|
||||
const valueMax = spreadValid ? mapped.valueMax : null;
|
||||
|
||||
prepared.push({
|
||||
index,
|
||||
|
||||
@@ -22,28 +22,15 @@
|
||||
* 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 { readSeriesBatch } from "@/lib/measurements/series-batch-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();
|
||||
@@ -55,49 +42,23 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}),
|
||||
),
|
||||
// Shared core: priority ladder once, `p-limit(4)` per-type fan-out, per-type
|
||||
// fault isolation to `[]`. The RSC dashboard prefetch calls the same
|
||||
// function so the two paths never drift.
|
||||
const { series, degradedTypes } = await readSeriesBatch(
|
||||
user.id,
|
||||
types,
|
||||
from,
|
||||
to,
|
||||
);
|
||||
|
||||
const series: Record<string, DailySeriesRow[]> = {};
|
||||
for (const [type, rows] of entries) {
|
||||
series[type] = rows;
|
||||
}
|
||||
|
||||
const seriesLists = Object.values(series);
|
||||
annotate({
|
||||
action: { name: "measurement.series.batch" },
|
||||
meta: {
|
||||
type_count: uniqueTypes.length,
|
||||
total: entries.reduce((sum, [, rows]) => sum + rows.length, 0),
|
||||
type_count: seriesLists.length,
|
||||
total: seriesLists.reduce((sum, rows) => sum + rows.length, 0),
|
||||
degraded_types: degradedTypes,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -15,262 +15,24 @@ import {
|
||||
setMedicationCategory,
|
||||
} from "@/lib/medication-category";
|
||||
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import {
|
||||
computeDisplayDue,
|
||||
OVERDUE_LOOKBACK_MS,
|
||||
toResolvedSlotMark,
|
||||
type ResolvedSlotMark,
|
||||
} from "@/lib/medications/scheduling/next-due";
|
||||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { readMedicationsListCached } from "@/lib/medications/list-read";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type MedicationsListResult = Array<Record<string, unknown>>;
|
||||
|
||||
async function buildMedicationsList(
|
||||
userId: string,
|
||||
userTz: string,
|
||||
): Promise<MedicationsListResult> {
|
||||
const { start: todayStartUtc, end: todayEndUtc } = getUserTodayBounds(
|
||||
new Date(),
|
||||
userTz,
|
||||
);
|
||||
|
||||
// v1.15.10 — the slots the user has already acted on near "now", so the
|
||||
// next-due search can skip them. A resolved slot is a taken, deliberately
|
||||
// skipped, or cron-auto-missed row. Bound the window to [today-start,
|
||||
// today-end + 2d] — the next-due lookahead only needs the slots adjacent to
|
||||
// now, and a tight window keeps the read cheap. The 60s list cache covers
|
||||
// the rest.
|
||||
const resolvedWindowEnd = new Date(
|
||||
todayEndUtc.getTime() + 2 * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
// v1.16.4 — the open-overdue search reaches back as far as the widest
|
||||
// band tail (weekly on-time + overdue), so the resolved-slot read must
|
||||
// cover the same horizon or a long-resolved past slot would resurface
|
||||
// as "overdue".
|
||||
const resolvedWindowStart = new Date(
|
||||
todayStartUtc.getTime() - OVERDUE_LOOKBACK_MS,
|
||||
);
|
||||
|
||||
const [
|
||||
medications,
|
||||
latestIntakes,
|
||||
todayEvents,
|
||||
resolvedEvents,
|
||||
eraFloors,
|
||||
usableStock,
|
||||
inventoryCounts,
|
||||
] = await Promise.all([
|
||||
prisma.medication.findMany({
|
||||
where: { userId },
|
||||
include: { schedules: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
prisma.medicationIntakeEvent.groupBy({
|
||||
by: ["medicationId"],
|
||||
// v1.7.0 sync — exclude tombstoned rows from the last-taken map.
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
skipped: false,
|
||||
takenAt: { not: null },
|
||||
},
|
||||
_max: { takenAt: true },
|
||||
}),
|
||||
prisma.medicationIntakeEvent.groupBy({
|
||||
by: ["medicationId"],
|
||||
// v1.7.0 sync — exclude tombstoned rows from the today-count map.
|
||||
// v1.16.9 — count only ACTIONED rows (taken or skipped). The
|
||||
// dashboard projector mints pending rows for every slot of the
|
||||
// day, so counting all rows made `todayEventCount` cover every
|
||||
// passed dose after any dashboard visit — and the cards'
|
||||
// overdue-pill suppression (`todayEventCount < passedDoseCount`)
|
||||
// went dark nondeterministically for genuinely overdue doses.
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
scheduledFor: { gte: todayStartUtc, lte: todayEndUtc },
|
||||
OR: [{ takenAt: { not: null } }, { skipped: true }],
|
||||
},
|
||||
_count: { id: true },
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
scheduledFor: { gte: resolvedWindowStart, lte: resolvedWindowEnd },
|
||||
OR: [
|
||||
{ takenAt: { not: null } },
|
||||
{ skipped: true },
|
||||
{ autoMissed: true },
|
||||
],
|
||||
},
|
||||
// v1.16.9 — `takenAt` rides along so the ad-hoc shape
|
||||
// (`scheduledFor === takenAt`) is detectable: such a row must not
|
||||
// ±6h-resolve a DIFFERENT slot (a 14:30 ad-hoc take hid tonight's
|
||||
// genuinely-due 20:00 dose).
|
||||
select: { medicationId: true, scheduledFor: true, takenAt: true },
|
||||
}),
|
||||
// v1.16.4 — current-era floor per medication: the newest revision's
|
||||
// `validUntil` is where the LIVE schedule rows became valid. The
|
||||
// open-overdue search mints from the live rows, so it must not reach
|
||||
// past this boundary into a previous era's cadence.
|
||||
prisma.medicationScheduleRevision.groupBy({
|
||||
by: ["medicationId"],
|
||||
// Superseded rows are audit records — a correction may have
|
||||
// shortened the era, so the boundary reads only active rows.
|
||||
where: { medication: { userId }, supersededByRevisionId: null },
|
||||
_max: { validUntil: true },
|
||||
}),
|
||||
// v1.16.10 — usable stock per medication (one batched aggregate,
|
||||
// not per-row): the sum of `unitsRemaining` over ACTIVE / IN_USE
|
||||
// containers with units left — the same usable-container filter
|
||||
// the GLP-1 details endpoint applies. Feeds the list payload's
|
||||
// `stockUnitsRemaining` / `stockDosesRemaining` for the table view.
|
||||
prisma.medicationInventoryItem.groupBy({
|
||||
by: ["medicationId"],
|
||||
where: {
|
||||
userId,
|
||||
state: { in: ["ACTIVE", "IN_USE"] },
|
||||
unitsRemaining: { gt: 0 },
|
||||
},
|
||||
_sum: { unitsRemaining: true },
|
||||
}),
|
||||
// …and the any-state item count, so a medication whose containers
|
||||
// are all used up / expired reads as stock 0 (tracking is ON, the
|
||||
// supply ran out) instead of null (tracking off).
|
||||
prisma.medicationInventoryItem.groupBy({
|
||||
by: ["medicationId"],
|
||||
where: { userId },
|
||||
_count: { id: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const resolvedSlotsByMedId = new Map<string, ResolvedSlotMark[]>();
|
||||
for (const e of resolvedEvents) {
|
||||
const mark = toResolvedSlotMark(e);
|
||||
const list = resolvedSlotsByMedId.get(e.medicationId);
|
||||
if (list) list.push(mark);
|
||||
else resolvedSlotsByMedId.set(e.medicationId, [mark]);
|
||||
}
|
||||
|
||||
const eraStartByMedId = new Map<string, Date>();
|
||||
for (const f of eraFloors) {
|
||||
if (f._max.validUntil)
|
||||
eraStartByMedId.set(f.medicationId, f._max.validUntil);
|
||||
}
|
||||
|
||||
const lastTakenAtByMedicationId = Object.fromEntries(
|
||||
latestIntakes.map((entry) => [
|
||||
entry.medicationId,
|
||||
entry._max.takenAt ? entry._max.takenAt.toISOString() : null,
|
||||
]),
|
||||
);
|
||||
// v1.7.0 SB-SCHED-3 — Date-typed last-intake map for the engine
|
||||
// (rolling cadences re-anchor on it). Same groupBy as the ISO map.
|
||||
const lastTakenAtDateByMedicationId = Object.fromEntries(
|
||||
latestIntakes.map((entry) => [entry.medicationId, entry._max.takenAt]),
|
||||
);
|
||||
const todayEventCountByMedId = Object.fromEntries(
|
||||
todayEvents.map(
|
||||
(entry: { medicationId: string; _count: { id: number } }) => [
|
||||
entry.medicationId,
|
||||
entry._count.id,
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
const usableUnitsByMedId = new Map<string, number>();
|
||||
for (const entry of usableStock) {
|
||||
usableUnitsByMedId.set(
|
||||
entry.medicationId,
|
||||
Number(entry._sum.unitsRemaining ?? 0),
|
||||
);
|
||||
}
|
||||
const trackedMedIds = new Set(inventoryCounts.map((e) => e.medicationId));
|
||||
|
||||
let categoryMap: Record<string, string> = {};
|
||||
try {
|
||||
categoryMap = await getMedicationCategories(medications.map((m) => m.id));
|
||||
} catch {
|
||||
getEvent()?.addWarning("Medication categories could not be loaded");
|
||||
}
|
||||
|
||||
// v1.7.0 SB-SCHED-3 — server-computed next due instant. Time-derived;
|
||||
// the list GET is cached 60 s on userId, so a 60 s staleness window is
|
||||
// accepted here as it already is for `todayEventCount`.
|
||||
const now = new Date();
|
||||
|
||||
return medications.map((m) => {
|
||||
// v1.16.4 — an OPEN overdue slot (anchor passed, still inside its
|
||||
// catch-up band, unresolved) surfaces FIRST with `nextDueOverdue:
|
||||
// true`; only a closed or resolved band falls through to the future
|
||||
// next-due. Keeps the card on the still-takeable dose instead of
|
||||
// jumping ahead the minute the anchor passes.
|
||||
const display = computeDisplayDue({
|
||||
medication: {
|
||||
id: m.id,
|
||||
startsOn: m.startsOn,
|
||||
endsOn: m.endsOn,
|
||||
oneShot: m.oneShot,
|
||||
createdAt: m.createdAt,
|
||||
},
|
||||
schedules: m.schedules,
|
||||
now,
|
||||
userTz,
|
||||
lastIntakeAt: lastTakenAtDateByMedicationId[m.id] ?? null,
|
||||
resolvedSlots: resolvedSlotsByMedId.get(m.id) ?? [],
|
||||
eraStart: eraStartByMedId.get(m.id) ?? null,
|
||||
});
|
||||
// v1.16.10 — dose-derived stock for the table view. NULL when the
|
||||
// medication has no inventory items at all (tracking off); 0 when
|
||||
// tracking is on but every container is used up / expired.
|
||||
const tracksInventory = trackedMedIds.has(m.id);
|
||||
const stockUnitsRemaining = tracksInventory
|
||||
? (usableUnitsByMedId.get(m.id) ?? 0)
|
||||
: null;
|
||||
const stockDosesRemaining =
|
||||
stockUnitsRemaining === null
|
||||
? null
|
||||
: Math.floor(stockUnitsRemaining / (Number(m.unitsPerDose) || 1));
|
||||
return {
|
||||
...m,
|
||||
// v1.16.12 — Decimal → number so the wire stays a JSON number, not
|
||||
// the string Prisma would otherwise serialise a Decimal to.
|
||||
unitsPerDose: Number(m.unitsPerDose),
|
||||
category: categoryMap[m.id] ?? "OTHER",
|
||||
lastTakenAt: lastTakenAtByMedicationId[m.id] ?? null,
|
||||
todayEventCount: todayEventCountByMedId[m.id] ?? 0,
|
||||
nextDueAt: display ? display.at.toISOString() : null,
|
||||
nextDueOverdue: display?.overdue ?? false,
|
||||
stockUnitsRemaining,
|
||||
stockDosesRemaining,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const userTz = user.timezone || "Europe/Berlin";
|
||||
|
||||
// Cache the list shape on userId. The 60 s fresh TTL bounds the
|
||||
// cross-midnight staleness window for `todayEventCount` and the
|
||||
// next-due fields; v1.16.8 reads through `cachedSwr`, so a visit
|
||||
// next-due fields; the read goes through `cachedSwr`, so a visit
|
||||
// minutes after the last one serves the prior list immediately while
|
||||
// one background recompute warms a fresh entry (the 10-minute stale
|
||||
// window bounds how old that served list can be). Interactive writes
|
||||
// via POST/PUT/DELETE hard-evict through
|
||||
// `invalidateUserMedications({ evict: true })` before the next read
|
||||
// lands; background sync paths mark the bucket stale instead.
|
||||
const result = await cachedSwr(
|
||||
caches.medications as ServerCache<MedicationsListResult>,
|
||||
user.id,
|
||||
() => buildMedicationsList(user.id, userTz),
|
||||
annotate,
|
||||
);
|
||||
// lands; background sync paths mark the bucket stale instead. The RSC
|
||||
// wrapper (`src/app/medications/page.tsx`) shares this exact cell.
|
||||
const result = await readMedicationsListCached(user);
|
||||
|
||||
annotate({
|
||||
action: { name: "medication.list" },
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* `GET /api/sync/changes` — cycle module gate.
|
||||
*
|
||||
* The delta feed multiplexes five domains over one cursor and used to serve
|
||||
* `cycleDays` / `cycles` ungated, so an account with cycle tracking off got
|
||||
* period days and per-day symptom rows over the very token that
|
||||
* `/api/cycle/*` refuses with a 403.
|
||||
*
|
||||
* Behavioural, not structural: the assertions read the response body and the
|
||||
* Prisma call log, so reverting the gate in the route turns them red.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
cycleDayLog: { findMany: vi.fn() },
|
||||
menstrualCycle: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decodeCursor } from "@/lib/sync/cursor";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
const NOW = new Date("2026-07-01T12:00:00.000Z");
|
||||
|
||||
/** One live cycle-day row carrying a symptom — the payload that must not leak. */
|
||||
const CYCLE_DAY_ROW = {
|
||||
id: "cd-1",
|
||||
userId: "user-1",
|
||||
externalId: "ext-cd-1",
|
||||
date: NOW,
|
||||
flow: "MEDIUM",
|
||||
notes: null,
|
||||
notesEncrypted: null,
|
||||
syncVersion: 3,
|
||||
deletedAt: null,
|
||||
updatedAt: NOW,
|
||||
createdAt: NOW,
|
||||
symptoms: [{ id: "sym-1", symptom: "CRAMPS", severity: 2 }],
|
||||
};
|
||||
|
||||
const CYCLE_ROW = {
|
||||
id: "mc-1",
|
||||
userId: "user-1",
|
||||
startDate: NOW,
|
||||
endDate: null,
|
||||
cycleLength: 28,
|
||||
periodLength: 5,
|
||||
predicted: false,
|
||||
syncVersion: 2,
|
||||
deletedAt: null,
|
||||
updatedAt: NOW,
|
||||
createdAt: NOW,
|
||||
};
|
||||
|
||||
interface FeedBody {
|
||||
data: {
|
||||
changes: {
|
||||
measurements: { upserts: unknown[]; tombstones: unknown[] };
|
||||
cycleDays: { upserts: unknown[]; tombstones: unknown[] };
|
||||
cycles: { upserts: unknown[]; tombstones: unknown[] };
|
||||
};
|
||||
cursor: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
function req(query = ""): NextRequest {
|
||||
return new NextRequest(`http://localhost/api/sync/changes${query}`);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
count: 1,
|
||||
resetAt: Date.now(),
|
||||
} as never);
|
||||
// Non-cycle domains always return rows so a 403/empty-everything result
|
||||
// cannot be mistaken for a passing "cycle omitted" assertion.
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "m-1",
|
||||
externalId: "ext-m-1",
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
measuredAt: NOW,
|
||||
source: "MANUAL",
|
||||
notes: null,
|
||||
notesEncrypted: null,
|
||||
syncVersion: 1,
|
||||
deletedAt: null,
|
||||
updatedAt: NOW,
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([
|
||||
CYCLE_DAY_ROW,
|
||||
] as never);
|
||||
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([
|
||||
CYCLE_ROW,
|
||||
] as never);
|
||||
});
|
||||
|
||||
describe("GET /api/sync/changes — cycle module gate", () => {
|
||||
it("omits cycle rows and never queries them when the module is off", async () => {
|
||||
vi.mocked(isModuleEnabled).mockResolvedValue(false);
|
||||
|
||||
const res = await GET(req());
|
||||
// OMIT, not refuse — the other four domains must keep syncing.
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as FeedBody;
|
||||
expect(body.data.changes.cycleDays.upserts).toEqual([]);
|
||||
expect(body.data.changes.cycleDays.tombstones).toEqual([]);
|
||||
expect(body.data.changes.cycles.upserts).toEqual([]);
|
||||
expect(body.data.changes.cycles.tombstones).toEqual([]);
|
||||
|
||||
// The rest of the feed is untouched — proves the empty cycle blocks are
|
||||
// the gate, not a wholesale refusal.
|
||||
expect(body.data.changes.measurements.upserts).toHaveLength(1);
|
||||
|
||||
// Not read out of Postgres at all.
|
||||
expect(prisma.cycleDayLog.findMany).not.toHaveBeenCalled();
|
||||
expect(prisma.menstrualCycle.findMany).not.toHaveBeenCalled();
|
||||
|
||||
// No symptom string anywhere in the serialised payload.
|
||||
expect(JSON.stringify(body)).not.toContain("CRAMPS");
|
||||
expect(JSON.stringify(body)).not.toContain("ext-cd-1");
|
||||
});
|
||||
|
||||
it("serves cycle rows when the module is on", async () => {
|
||||
vi.mocked(isModuleEnabled).mockResolvedValue(true);
|
||||
|
||||
const res = await GET(req());
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as FeedBody;
|
||||
expect(body.data.changes.cycleDays.upserts).toHaveLength(1);
|
||||
expect(body.data.changes.cycles.upserts).toHaveLength(1);
|
||||
expect(prisma.cycleDayLog.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.menstrualCycle.findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("gates on the cycle module key specifically", async () => {
|
||||
vi.mocked(isModuleEnabled).mockResolvedValue(true);
|
||||
await GET(req());
|
||||
expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "cycle");
|
||||
});
|
||||
|
||||
it("leaves the cycle cursor watermarks untouched while the module is off", async () => {
|
||||
// A skipped domain must not advance its watermark, so re-enabling
|
||||
// resumes from where the client left off rather than skipping the rows
|
||||
// that changed while the module was hidden.
|
||||
vi.mocked(isModuleEnabled).mockResolvedValue(false);
|
||||
const res = await GET(req());
|
||||
const body = (await res.json()) as FeedBody;
|
||||
|
||||
const cursor = decodeCursor(body.data.cursor as string);
|
||||
expect(cursor).not.toBeNull();
|
||||
expect(cursor!.cycleDays).toBeUndefined();
|
||||
expect(cursor!.cycles).toBeUndefined();
|
||||
// The domain that did return rows advanced normally.
|
||||
expect(cursor!.measurements).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,9 @@
|
||||
* been pruned, so an incremental delta could silently miss it).
|
||||
* - `syncVersion` is echoed per row so the client can keep its mirror's
|
||||
* version monotonic.
|
||||
* - The `cycleDays` / `cycles` blocks are MODULE-GATED: an account with
|
||||
* cycle tracking off gets both blocks empty (never a 403 — see the
|
||||
* gate note in the handler) and their cursor watermarks untouched.
|
||||
*
|
||||
* `apiHandler` + `requireAuth` (cookie OR Bearer; iOS uses Bearer).
|
||||
* Read-only — no idempotency, no write side-effect (unlike the legacy
|
||||
@@ -67,6 +70,7 @@ import {
|
||||
type CycleDayLogDTO,
|
||||
type MenstrualCycleDTO,
|
||||
} from "@/lib/cycle/dto";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
const DEFAULT_LIMIT = 200;
|
||||
const MAX_LIMIT = 500;
|
||||
@@ -192,6 +196,36 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const serverNow = new Date();
|
||||
|
||||
// ── module gate: cycle ────────────────────────────────────────────
|
||||
// The `cycleDays` / `cycles` domains carry period days and per-day
|
||||
// symptom rows — the same data every `/api/cycle/*` route refuses with a
|
||||
// 403 when the module is off. Without this the delta feed re-served them
|
||||
// ungated to the very same token.
|
||||
//
|
||||
// OMIT, NOT 403 — deliberately, and unlike the whole-record FHIR export.
|
||||
// This feed multiplexes five domains over one cursor: refusing the whole
|
||||
// request would wedge measurement / mood / intake catch-up for every
|
||||
// account with cycle tracking off, which (the gate delegates to
|
||||
// `isCycleEnabled`) is the default for most accounts. A partial payload
|
||||
// is the only outcome that keeps the client syncing while still honouring
|
||||
// the promise that a disabled module hands out nothing.
|
||||
//
|
||||
// The two cycle watermarks are deliberately NOT advanced while the module
|
||||
// is off (the queries are skipped, so `advanceCursor` sees an empty page
|
||||
// and leaves the prior watermark untouched). Re-enabling therefore resumes
|
||||
// from where the client actually left off instead of silently skipping the
|
||||
// rows that changed while the module was hidden.
|
||||
//
|
||||
// KNOWN GAP, out of scope here: a client that synced cycle rows BEFORE the
|
||||
// module was turned off keeps its stale local mirror. Emitting tombstones
|
||||
// for them would be wrong — a tombstone means deleted, and disabling a
|
||||
// module must leave the rows intact so re-enabling finds a complete
|
||||
// history. Purging a paired client's mirror needs an explicit
|
||||
// "module went dark" signal in the feed contract plus client support for
|
||||
// it; that is an additive wire change and an iOS-client change, not a
|
||||
// server-side gate fix, so it is left for a coordinated contract cycle.
|
||||
const cycleEnabled = await isModuleEnabled(user.id, "cycle");
|
||||
|
||||
// The retention horizon: tombstones older than this may have been
|
||||
// pruned by the cleanup job, so a cursor that predates it can no longer
|
||||
// be trusted to deliver every deletion incrementally.
|
||||
@@ -291,17 +325,23 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.cycleDayLog.findMany({
|
||||
where: { userId: user.id, ...keysetFilter(cursor.cycleDays) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
include: dayLogSymptomInclude,
|
||||
}),
|
||||
prisma.menstrualCycle.findMany({
|
||||
where: { userId: user.id, ...keysetFilter(cursor.cycles) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
}),
|
||||
// Skipped outright when the module is off — not filtered after the
|
||||
// fact — so the disabled rows are never read out of Postgres at all.
|
||||
cycleEnabled
|
||||
? prisma.cycleDayLog.findMany({
|
||||
where: { userId: user.id, ...keysetFilter(cursor.cycleDays) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
include: dayLogSymptomInclude,
|
||||
})
|
||||
: [],
|
||||
cycleEnabled
|
||||
? prisma.menstrualCycle.findMany({
|
||||
where: { userId: user.id, ...keysetFilter(cursor.cycles) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
|
||||
const nextCursor: SyncCursor = { ...cursor };
|
||||
@@ -464,6 +504,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
cycle_day_tombstones: cycleDayTombstones.length,
|
||||
cycle_upserts: cycleUpserts.length,
|
||||
cycle_tombstones: cycleTombstones.length,
|
||||
cycle_module_enabled: cycleEnabled,
|
||||
has_more: hasMore,
|
||||
cursor_present: Boolean(parsed.data.cursor),
|
||||
},
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/tokens.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
apiToken: {
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/hmac", () => ({ hashToken: (s: string) => `hash(${s})` }));
|
||||
vi.mock("@/lib/app-settings", () => ({
|
||||
isApiGloballyEnabled: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/tokens", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe("POST /api/tokens — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// Empty name (min 1) + `expiresInDays` above max (365).
|
||||
const res = await POST(postReq({ name: "", expiresInDays: 9999 }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
// Missing name + `expiresInDays` not a number + `expiresInDays` below min
|
||||
// is hard since name and expiresInDays are the only knobs, so we use a
|
||||
// name that is too long (max 100) + expiresInDays as a string and as
|
||||
// a fraction (non-int).
|
||||
const res = await POST(
|
||||
postReq({
|
||||
name: "x".repeat(200),
|
||||
expiresInDays: "string",
|
||||
// any extra unknown key Zod will silently strip; force a 3rd issue
|
||||
// by including a stray nested shape that the schema doesn't accept.
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: string; code: string }> };
|
||||
};
|
||||
// name too long + expiresInDays not a number = 2 issues at least.
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("writes a tokens.create.validation-failed audit row", async () => {
|
||||
const res = await POST(postReq({ name: "", expiresInDays: 9999 }));
|
||||
expect(res.status).toBe(422);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
|
||||
data: { userId: string; action: string };
|
||||
};
|
||||
expect(call.data.userId).toBe("user-1");
|
||||
expect(call.data.action).toBe("tokens.create.validation-failed");
|
||||
});
|
||||
|
||||
it("does not block the 422 when the audit-row write rejects", async () => {
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
const res = await POST(postReq({ name: "", expiresInDays: 9999 }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
+15
-80
@@ -1,23 +1,22 @@
|
||||
/**
|
||||
* `/api/tokens` — list and (via `[id]`) revoke a user's API tokens.
|
||||
*
|
||||
* The generic mint that used to live here (`POST`, minting
|
||||
* `["medication:ingest"]`) was removed alongside the fail-closed scope
|
||||
* default. That token could never do its advertised job — the external ingest
|
||||
* surface gates on the per-medication `medication:<id>:ingest` grant, which
|
||||
* this endpoint never issued — while the old fail-open default let it reach
|
||||
* every other authenticated route. The working credential is minted by the
|
||||
* per-medication API-endpoint toggle
|
||||
* (`/api/medications/[id]/api-endpoint`), which issues both grants.
|
||||
*
|
||||
* Listing and revoking stay so existing tokens remain visible and revocable.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const createTokenSchema = z.object({
|
||||
name: z.string().min(1, "Name required").max(100),
|
||||
expiresInDays: z.number().int().min(1).max(365).optional(),
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -43,67 +42,3 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
return apiSuccess(tokens);
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "tokens.create" } });
|
||||
|
||||
if (!(await isApiGloballyEnabled())) {
|
||||
return apiError("API is globally disabled", 403);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = createTokenSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
// v1.4.43 W6 — multi-issue 422 + audit-ledger breadcrumb so an
|
||||
// operator can grep `tokens.create.validation-failed` when a
|
||||
// device sends a malformed mint payload.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
annotate({
|
||||
action: { name: "tokens.create.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
// v1.4.49 — strip `message` from the audit-ledger row; the
|
||||
// mint payload carries a free-text `name`.
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "tokens.create.validation-failed",
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { name, expiresInDays } = parsed.data;
|
||||
|
||||
// Generate a random token
|
||||
const rawToken = `hlk_${randomBytes(32).toString("hex")}`;
|
||||
const tokenHashValue = hashToken(rawToken);
|
||||
|
||||
await prisma.apiToken.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name,
|
||||
tokenHash: tokenHashValue,
|
||||
permissions: ["medication:ingest"],
|
||||
expiresAt: expiresInDays
|
||||
? new Date(Date.now() + expiresInDays * 86400000)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Return the raw token ONCE — it can never be retrieved again
|
||||
return apiSuccess({ token: rawToken, name }, 201);
|
||||
});
|
||||
@@ -30,13 +30,31 @@ import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
|
||||
import { buildWorkoutHrSeries } from "@/lib/workouts/hr-series";
|
||||
import {
|
||||
computeZones,
|
||||
hrMaxFromAge,
|
||||
parseWhoopZoneDurations,
|
||||
} from "@/lib/workouts/zones";
|
||||
import { computeSplits } from "@/lib/workouts/splits";
|
||||
import type { RouteCoordinate } from "@/lib/workouts/route-svg";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
/** Own-history lookback for the sport-average comparison line. */
|
||||
const SPORT_CONTEXT_LOOKBACK_DAYS = 180;
|
||||
/** Slim-row cap for the sport-average read. */
|
||||
const SPORT_CONTEXT_MAX_ROWS = 500;
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
// Web always sends `compact=1` to drop the raw 30k-sample / route
|
||||
// timestamp blobs. Absent the param the response is byte-identical to
|
||||
// the v1.4.32 contract → iOS is untouched, no coordination ticket.
|
||||
const compact = request.nextUrl.searchParams.get("compact") === "1";
|
||||
|
||||
annotate({ action: { name: "workouts.detail" }, meta: { workoutId: id } });
|
||||
|
||||
@@ -98,7 +116,7 @@ export const GET = apiHandler(
|
||||
|
||||
const userRow = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { sourcePriorityJson: true },
|
||||
select: { sourcePriorityJson: true, dateOfBirth: true },
|
||||
});
|
||||
const canonicalCluster = pickCanonicalWorkoutRows(
|
||||
clusterRows,
|
||||
@@ -112,20 +130,82 @@ export const GET = apiHandler(
|
||||
canonicalCluster[0] ??
|
||||
null;
|
||||
|
||||
// ── Enrichment reads (all over existing tables — no migration) ────
|
||||
|
||||
// Heart-rate curve: stored series first, pulse-window fallback
|
||||
// second, hide third. One server path, one DTO with provenance.
|
||||
const hrSeries = await buildWorkoutHrSeries({
|
||||
userId: user.id,
|
||||
startedAt: row.startedAt,
|
||||
endedAt: row.endedAt,
|
||||
durationSec: row.durationSec,
|
||||
storedSamples: row.samples?.samples ?? null,
|
||||
});
|
||||
annotate({
|
||||
action: { name: "workouts.detail.hr_series" },
|
||||
meta: {
|
||||
source: hrSeries?.source ?? "none",
|
||||
points: hrSeries?.points.length ?? 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Effort zones: WHOOP device durations win; else %HRmax fold from
|
||||
// the series when profile age exists; else null.
|
||||
const ageYears = getAgeFromDateOfBirth(userRow?.dateOfBirth ?? null);
|
||||
const zones = computeZones({
|
||||
hrMax: hrMaxFromAge(ageYears),
|
||||
series: hrSeries?.points ?? [],
|
||||
bucketSec: hrSeries?.bucketSec ?? 0,
|
||||
whoopZoneDurations: parseWhoopZoneDurations(row.metadata),
|
||||
});
|
||||
|
||||
// Per-km splits computed server-side from the geometry + timestamps
|
||||
// so the web client keeps dropping the raw timestamp blob under
|
||||
// `compact=1` while still rendering splits (server-authoritative
|
||||
// parity — iOS gets the same resolved figures).
|
||||
const geometryCoords =
|
||||
row.route &&
|
||||
row.route.geometry &&
|
||||
typeof row.route.geometry === "object" &&
|
||||
Array.isArray(
|
||||
(row.route.geometry as { coordinates?: unknown }).coordinates,
|
||||
)
|
||||
? (row.route.geometry as { coordinates: RouteCoordinate[] }).coordinates
|
||||
: null;
|
||||
const splits =
|
||||
geometryCoords && Array.isArray(row.route?.sampleTimestamps)
|
||||
? computeSplits(geometryCoords, row.route.sampleTimestamps as string[])
|
||||
: null;
|
||||
|
||||
// Sport context: the user's own last-180-days average for this
|
||||
// sport, cross-source-collapsed so twins don't double-count.
|
||||
const sportContext = await buildSportContext(
|
||||
user.id,
|
||||
row.sportType,
|
||||
userRow?.sourcePriorityJson ?? null,
|
||||
);
|
||||
|
||||
const route = row.route
|
||||
? {
|
||||
geometry: row.route.geometry,
|
||||
sampleTimestamps: row.route.sampleTimestamps ?? null,
|
||||
// `compact=1` drops the (up to 20k-entry) timestamp array; the
|
||||
// SVG needs geometry, not the per-sample timestamps, and the
|
||||
// splits above are already derived from them server-side.
|
||||
sampleTimestamps: compact
|
||||
? null
|
||||
: (row.route.sampleTimestamps ?? null),
|
||||
}
|
||||
: null;
|
||||
|
||||
// v1.10.0 — route-independent per-workout HR series. Present for
|
||||
// both indoor (no route) and outdoor workouts that shipped a
|
||||
// `samples` array on ingest; null otherwise.
|
||||
// `samples` array on ingest; null otherwise. `compact=1` keeps the
|
||||
// denormalised count but drops the raw sample blob (the web curve
|
||||
// reads `hrSeries` instead).
|
||||
const samples = row.samples
|
||||
? {
|
||||
sampleCount: row.samples.sampleCount,
|
||||
samples: row.samples.samples,
|
||||
samples: compact ? null : row.samples.samples,
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -148,6 +228,17 @@ export const GET = apiHandler(
|
||||
metadata: row.metadata,
|
||||
route,
|
||||
samples,
|
||||
// #67 enrichment — all additive, all over existing tables.
|
||||
hrSeries,
|
||||
zones,
|
||||
splits,
|
||||
sportContext,
|
||||
// Reserved Activity-Insight seam (strategic-concept Wave C, bet 5):
|
||||
// always null today. The Phase-2 pg-boss job writes a cached
|
||||
// paragraph onto the workout row and maps it here — the detail page
|
||||
// already composes `{aiInsight ? <card/> : null}`, so the card
|
||||
// mounts with zero layout rework.
|
||||
aiInsight: null,
|
||||
// v1.4.32 — when the requested id is a non-canonical twin the
|
||||
// caller can redirect to `canonicalId` to land on the cluster
|
||||
// winner. `canonicalId === id` when the requested row already
|
||||
@@ -156,3 +247,64 @@ export const GET = apiHandler(
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* The user's own recent average for a sport — rendered as one muted
|
||||
* comparison line. Cross-source twins are collapsed through
|
||||
* `pickCanonicalWorkoutRows()` first so a run recorded by two paired
|
||||
* watches counts once. Comparisons are to the user's own history only
|
||||
* (non-diagnostic standard).
|
||||
*/
|
||||
async function buildSportContext(
|
||||
userId: string,
|
||||
sportType: string,
|
||||
sourcePriorityJson: unknown,
|
||||
): Promise<{
|
||||
count: number;
|
||||
avgDurationSec: number;
|
||||
avgDistanceM: number | null;
|
||||
avgAvgHr: number | null;
|
||||
} | null> {
|
||||
const since = new Date(
|
||||
Date.now() - SPORT_CONTEXT_LOOKBACK_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const rows = await prisma.workout.findMany({
|
||||
where: { userId, sportType, startedAt: { gte: since } },
|
||||
orderBy: [{ startedAt: "asc" }, { id: "asc" }],
|
||||
take: SPORT_CONTEXT_MAX_ROWS,
|
||||
select: {
|
||||
id: true,
|
||||
source: true,
|
||||
startedAt: true,
|
||||
sportType: true,
|
||||
durationSec: true,
|
||||
totalDistanceM: true,
|
||||
avgHeartRate: true,
|
||||
},
|
||||
});
|
||||
const canonical = pickCanonicalWorkoutRows(rows, sourcePriorityJson);
|
||||
if (canonical.length === 0) return null;
|
||||
|
||||
let durationSum = 0;
|
||||
let distanceSum = 0;
|
||||
let distanceCount = 0;
|
||||
let hrSum = 0;
|
||||
let hrCount = 0;
|
||||
for (const r of canonical) {
|
||||
durationSum += r.durationSec;
|
||||
if (r.totalDistanceM != null) {
|
||||
distanceSum += r.totalDistanceM;
|
||||
distanceCount += 1;
|
||||
}
|
||||
if (r.avgHeartRate != null) {
|
||||
hrSum += r.avgHeartRate;
|
||||
hrCount += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
count: canonical.length,
|
||||
avgDurationSec: Math.round(durationSum / canonical.length),
|
||||
avgDistanceM: distanceCount > 0 ? distanceSum / distanceCount : null,
|
||||
avgAvgHr: hrCount > 0 ? Math.round(hrSum / hrCount) : null,
|
||||
};
|
||||
}
|
||||
@@ -27,6 +27,9 @@ vi.mock("@/lib/db", () => ({
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
measurement: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -103,7 +106,14 @@ describe("GET /api/workouts/{id}", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
sourcePriorityJson: null,
|
||||
dateOfBirth: null,
|
||||
} as never);
|
||||
// #67 — the enrichment reads: the HR-curve pulse-window fallback
|
||||
// (`measurement.findMany`) and the sport-context average (a SECOND
|
||||
// `workout.findMany`). Default both to empty; each test's
|
||||
// `mockResolvedValueOnce` still wins for the cluster call.
|
||||
vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
// clearAllMocks wipes the factory default; re-seed the gate to pass.
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({
|
||||
enabled: true,
|
||||
@@ -208,6 +218,72 @@ describe("GET /api/workouts/{id}", () => {
|
||||
expect(body.data.canonicalId).toBe("w-apple");
|
||||
});
|
||||
|
||||
it("adds the #67 enrichment fields and strips raw blobs under compact=1", async () => {
|
||||
const startMs = BASE_ROW.startedAt.getTime();
|
||||
const storedSamples = Array.from({ length: 40 }, (_, i) => ({
|
||||
t: new Date(startMs + i * 45_000).toISOString(),
|
||||
hr: 140 + (i % 6),
|
||||
}));
|
||||
vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({
|
||||
...BASE_ROW,
|
||||
// WHOOP device zones win → zones render even without profile age.
|
||||
metadata: {
|
||||
zoneDurations: {
|
||||
zone_one_milli: 60_000,
|
||||
zone_two_milli: 300_000,
|
||||
zone_three_milli: 600_000,
|
||||
zone_four_milli: 200_000,
|
||||
zone_five_milli: 40_000,
|
||||
},
|
||||
},
|
||||
route: {
|
||||
id: "wr-1",
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: [
|
||||
[11, 49],
|
||||
[11.01, 49.01],
|
||||
],
|
||||
},
|
||||
sampleTimestamps: ["2026-05-15T07:00:00Z", "2026-05-15T07:01:00Z"],
|
||||
createdAt: BASE_ROW.createdAt,
|
||||
},
|
||||
samples: { sampleCount: storedSamples.length, samples: storedSamples },
|
||||
} as never);
|
||||
vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([
|
||||
{
|
||||
id: "w-1",
|
||||
source: "APPLE_HEALTH",
|
||||
startedAt: BASE_ROW.startedAt,
|
||||
sportType: "RUNNING",
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/workouts/w-1?compact=1", {
|
||||
method: "GET",
|
||||
}),
|
||||
makeParams("w-1"),
|
||||
);
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Stored series wins → provenance is the native workout series.
|
||||
expect(body.data.hrSeries.source).toBe("workout_series");
|
||||
expect(body.data.hrSeries.points.length).toBeGreaterThanOrEqual(2);
|
||||
// WHOOP device zones.
|
||||
expect(body.data.zones.model).toBe("whoop");
|
||||
// Additive keys present; reserved seam is null.
|
||||
expect(body.data).toHaveProperty("splits");
|
||||
expect(body.data).toHaveProperty("sportContext");
|
||||
expect(body.data.aiInsight).toBeNull();
|
||||
// compact=1 drops the raw blobs but keeps count + geometry.
|
||||
expect(body.data.samples.sampleCount).toBe(40);
|
||||
expect(body.data.samples.samples).toBeNull();
|
||||
expect(body.data.route.geometry).not.toBeNull();
|
||||
expect(body.data.route.sampleTimestamps).toBeNull();
|
||||
});
|
||||
|
||||
it("exposes the WorkoutRoute geometry when present", async () => {
|
||||
const routeGeometry = {
|
||||
type: "LineString",
|
||||
|
||||
@@ -57,177 +57,15 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { readWorkoutsListCached } from "@/lib/workouts/list-read";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
const DEFAULT_LIMIT = 50;
|
||||
const MAX_LIMIT = 200;
|
||||
|
||||
interface WorkoutsParams {
|
||||
readonly limit: number;
|
||||
readonly offset: number;
|
||||
readonly since: string | null;
|
||||
readonly until: string | null;
|
||||
readonly sportType: string | null;
|
||||
}
|
||||
|
||||
type WorkoutFilterParams = Pick<
|
||||
WorkoutsParams,
|
||||
"since" | "until" | "sportType"
|
||||
>;
|
||||
|
||||
interface CanonicalWorkoutRow {
|
||||
id: string;
|
||||
source: string;
|
||||
externalId: string | null;
|
||||
sportType: string | null;
|
||||
startedAt: Date | null;
|
||||
endedAt: Date | null;
|
||||
durationSec: number | null;
|
||||
totalDistanceM: number | null;
|
||||
totalEnergyKcal: number | null;
|
||||
avgHeartRate: number | null;
|
||||
maxHeartRate: number | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/** The cached, deduped, most-recent-first row set for one filter combination. */
|
||||
interface WorkoutsProjection {
|
||||
canonical: CanonicalWorkoutRow[];
|
||||
rawCount: number;
|
||||
}
|
||||
|
||||
interface WorkoutsResult {
|
||||
workouts: Array<{
|
||||
id: string;
|
||||
sportType: string | null;
|
||||
startedAt: Date | null;
|
||||
endedAt: Date | null;
|
||||
durationSec: number | null;
|
||||
distanceM: number | null;
|
||||
activeEnergyKcal: number | null;
|
||||
avgHr: number | null;
|
||||
maxHr: number | null;
|
||||
source: string;
|
||||
externalId: string | null;
|
||||
}>;
|
||||
meta: {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
droppedDuplicates: number;
|
||||
};
|
||||
}
|
||||
|
||||
async function buildWorkoutsProjection(
|
||||
userId: string,
|
||||
params: WorkoutFilterParams,
|
||||
): Promise<WorkoutsProjection> {
|
||||
const { since, until, sportType } = params;
|
||||
|
||||
const where: Record<string, unknown> = { userId };
|
||||
if (since || until) {
|
||||
const range: Record<string, Date> = {};
|
||||
if (since) {
|
||||
const d = new Date(since);
|
||||
if (!Number.isNaN(d.getTime())) range.gte = d;
|
||||
}
|
||||
if (until) {
|
||||
const d = new Date(until);
|
||||
if (!Number.isNaN(d.getTime())) range.lte = d;
|
||||
}
|
||||
if (Object.keys(range).length > 0) {
|
||||
where.startedAt = range;
|
||||
}
|
||||
}
|
||||
if (sportType) {
|
||||
where.sportType = sportType;
|
||||
}
|
||||
|
||||
// Independent reads — the sourcePriorityJson lookup doesn't depend on the
|
||||
// row read, so run them concurrently rather than paying two sequential
|
||||
// round trips.
|
||||
const [rows, userRow] = await Promise.all([
|
||||
prisma.workout.findMany({
|
||||
where,
|
||||
orderBy: [{ startedAt: "desc" }, { id: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
source: true,
|
||||
externalId: true,
|
||||
sportType: true,
|
||||
startedAt: true,
|
||||
endedAt: true,
|
||||
durationSec: true,
|
||||
totalDistanceM: true,
|
||||
totalEnergyKcal: true,
|
||||
avgHeartRate: true,
|
||||
maxHeartRate: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { sourcePriorityJson: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const canonical = pickCanonicalWorkoutRows(
|
||||
rows,
|
||||
userRow?.sourcePriorityJson ?? null,
|
||||
);
|
||||
|
||||
// `pickCanonicalWorkoutRows` re-sorts its output to `startedAt ASC` (its
|
||||
// documented contract for the rollup callers). This surface is a
|
||||
// most-recent-first list, so the DB `orderBy startedAt desc` must be
|
||||
// re-established AFTER the pick — otherwise `offset`/`limit` slice the
|
||||
// OLDEST page and a user with a long history never sees recent workouts.
|
||||
canonical.sort((a, b) => {
|
||||
const delta = (b.startedAt?.getTime() ?? 0) - (a.startedAt?.getTime() ?? 0);
|
||||
if (delta !== 0) return delta;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
|
||||
return { canonical, rawCount: rows.length };
|
||||
}
|
||||
|
||||
function sliceWorkoutsProjection(
|
||||
projection: WorkoutsProjection,
|
||||
limit: number,
|
||||
offset: number,
|
||||
): WorkoutsResult {
|
||||
const page = projection.canonical
|
||||
.slice(offset, offset + limit)
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
sportType: row.sportType,
|
||||
startedAt: row.startedAt,
|
||||
endedAt: row.endedAt,
|
||||
durationSec: row.durationSec,
|
||||
distanceM: row.totalDistanceM,
|
||||
activeEnergyKcal: row.totalEnergyKcal,
|
||||
avgHr: row.avgHeartRate,
|
||||
maxHr: row.maxHeartRate,
|
||||
source: row.source,
|
||||
externalId: row.externalId,
|
||||
}));
|
||||
|
||||
return {
|
||||
workouts: page,
|
||||
meta: {
|
||||
total: projection.canonical.length,
|
||||
limit,
|
||||
offset,
|
||||
droppedDuplicates: projection.rawCount - projection.canonical.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "workouts.list" } });
|
||||
@@ -256,18 +94,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const until = searchParams.get("until");
|
||||
const sportType = searchParams.get("sportType");
|
||||
|
||||
// Deliberately excludes `limit`/`offset` — every page of one filter
|
||||
// combination shares this cached projection (see the docblock above).
|
||||
const projectionKey = `${user.id}|${since ?? ""}|${until ?? ""}|${sportType ?? ""}`;
|
||||
|
||||
const projection = await cached(
|
||||
caches.workouts as ServerCache<WorkoutsProjection>,
|
||||
projectionKey,
|
||||
() => buildWorkoutsProjection(user.id, { since, until, sportType }),
|
||||
annotate,
|
||||
);
|
||||
|
||||
const result = sliceWorkoutsProjection(projection, limit, offset);
|
||||
const result = await readWorkoutsListCached(user.id, {
|
||||
limit,
|
||||
offset,
|
||||
since,
|
||||
until,
|
||||
sportType,
|
||||
});
|
||||
|
||||
return apiSuccess(result);
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { HydrationBoundary, hashKey } from "@tanstack/react-query";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* v1.30.x — the `/coach` server-prefetch key crux + availability gate.
|
||||
*
|
||||
* The RSC wrapper (`src/app/coach/page.tsx`) dehydrates the coach nudge status
|
||||
* under `queryKeys.coachNudgeStatus()` so the auto-open-most-recent decision is
|
||||
* available at hydrate (collapsing the nudge → auto-open waterfall). These
|
||||
* tests pin: the exact client key; the read is only run when the Coach surface
|
||||
* is reachable (operator flag ON + not user-disabled); and every unreachable /
|
||||
* error path fails soft. The streaming conversation is never prefetched here.
|
||||
*/
|
||||
|
||||
const getSession = vi.fn();
|
||||
const requireAssistantSurface = vi.fn();
|
||||
const readCoachNudgeStatus = vi.fn();
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: () => getSession() }));
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
requireAssistantSurface: (s: string) => requireAssistantSurface(s),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/nudge-status", () => ({
|
||||
readCoachNudgeStatus: (id: string) => readCoachNudgeStatus(id),
|
||||
}));
|
||||
vi.mock("../page-client", () => ({ default: () => null }));
|
||||
|
||||
import CoachPage from "../page";
|
||||
|
||||
const NUDGE = {
|
||||
nudgedAt: "2026-07-18T08:00:00.000Z",
|
||||
unread: true,
|
||||
conversationId: "c1",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env.DASHBOARD_SSR_PREFETCH;
|
||||
});
|
||||
|
||||
function dehydratedQuery(
|
||||
el: ReactElement,
|
||||
): { queryHash: string; state: { data: unknown } } | null {
|
||||
if (el.type !== HydrationBoundary) return null;
|
||||
const props = el.props as {
|
||||
state?: { queries: { queryHash: string; state: { data: unknown } }[] };
|
||||
};
|
||||
const q = props.state?.queries?.[0];
|
||||
return q ? { queryHash: q.queryHash, state: q.state } : null;
|
||||
}
|
||||
|
||||
describe("/coach RSC prefetch", () => {
|
||||
it("dehydrates the nudge status under the EXACT client key", async () => {
|
||||
getSession.mockResolvedValue({ user: { id: "u1", disableCoach: false } });
|
||||
requireAssistantSurface.mockResolvedValue({});
|
||||
readCoachNudgeStatus.mockResolvedValue(NUDGE);
|
||||
|
||||
const el = (await CoachPage()) as ReactElement;
|
||||
const q = dehydratedQuery(el);
|
||||
expect(q).not.toBeNull();
|
||||
expect(q!.queryHash).toBe(hashKey(queryKeys.coachNudgeStatus()));
|
||||
expect(q!.state.data).toEqual(NUDGE);
|
||||
});
|
||||
|
||||
it("skips the prefetch when the user opted out of the Coach", async () => {
|
||||
getSession.mockResolvedValue({ user: { id: "u1", disableCoach: true } });
|
||||
const el = (await CoachPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(requireAssistantSurface).not.toHaveBeenCalled();
|
||||
expect(readCoachNudgeStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails soft when the operator Coach flag is off (surface throws)", async () => {
|
||||
getSession.mockResolvedValue({ user: { id: "u1", disableCoach: false } });
|
||||
requireAssistantSurface.mockRejectedValue(new Error("assistant disabled"));
|
||||
|
||||
const el = (await CoachPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(readCoachNudgeStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails soft when the read throws", async () => {
|
||||
getSession.mockResolvedValue({ user: { id: "u1", disableCoach: false } });
|
||||
requireAssistantSurface.mockResolvedValue({});
|
||||
readCoachNudgeStatus.mockRejectedValue(new Error("db blip"));
|
||||
|
||||
const el = (await CoachPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
});
|
||||
|
||||
it("fails soft when there is no session", async () => {
|
||||
getSession.mockResolvedValue(null);
|
||||
const el = (await CoachPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
});
|
||||
|
||||
it("honours the DASHBOARD_SSR_PREFETCH kill-switch", async () => {
|
||||
process.env.DASHBOARD_SSR_PREFETCH = "false";
|
||||
const el = (await CoachPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(getSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { CoachConversation } from "@/components/insights/coach-panel/coach-conversation";
|
||||
import type { CoachNudgeStatus } from "@/components/insights/layout-coach-fab";
|
||||
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
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 { coachScopeSourceSchema } from "@/lib/ai/coach/types";
|
||||
|
||||
/**
|
||||
* v1.12.0 (Coach v2 #6) — full-page Coach conversation.
|
||||
*
|
||||
* Renders the exact same `<CoachConversation>` surface the drawer uses
|
||||
* (history + streaming thread + composer + provenance + settings) at
|
||||
* full width and height. There is no forked chat logic — the page is a
|
||||
* thin chrome wrapper around the shared surface.
|
||||
*
|
||||
* v1.18.0 — the route is a standalone top-level page (`/coach`), no longer
|
||||
* nested under `app/insights/layout.tsx`, so it renders in the standard
|
||||
* page chrome WITHOUT the Insights tab strip. It still sits inside the
|
||||
* authenticated `<AuthShell>` (which provides the `<CoachLaunchProvider>`
|
||||
* the bottom-right FAB drawer reopens through), so the page inherits the
|
||||
* same Coach feature gating as every other Coach surface. When the
|
||||
* operator's master flag is off or the user opted out, the page redirects
|
||||
* back to `/insights` rather than painting a dead chat shell.
|
||||
*
|
||||
* v1.18.10 (W4) — the page is now a single full-bleed conversation
|
||||
* surface: the inner panel chrome (the bordered/rounded card) is gone so
|
||||
* the chat is no longer a window-inside-a-window, and the top "back to the
|
||||
* drawer" minimize control is removed (pointless on the dedicated page —
|
||||
* the separate bottom-right FAB drawer is the compact surface). The chat
|
||||
* claims the full content width and the viewport height below the top bar.
|
||||
*
|
||||
* v1.18.11 (W11) — ChatGPT-style IA: the page drops its own header bar
|
||||
* AND the top conversation-history strip entirely. The composer is the
|
||||
* single control hub (leading `+` actions menu = new chat + open
|
||||
* conversations, settings deep-link, mic, send); conversation history is a
|
||||
* left slide-in drawer opened from that menu. The composer keeps one
|
||||
* constant, centred, max-width-capped column across the new-chat hero and
|
||||
* the active conversation. All of that lives in `<CoachConversation>`'s
|
||||
* page branch, so the page stays a thin gating + sizing wrapper.
|
||||
*
|
||||
* v1.19.1 (C1/C5) — the Coach now DEFAULTS to the new-chat hero, reversing
|
||||
* the v1.19.0 (W7) "always resume most-recent" default the maintainer
|
||||
* disliked. Resolution order on mount:
|
||||
* - `?c=<id>` → open that specific thread (explicit deep-link, unchanged).
|
||||
* The dedicated conversation-history page (`/coach/conversations`, added
|
||||
* in v1.21.4) routes back here with `?c=<id>` when a row is selected.
|
||||
* - `?c=new` or no param → the new-chat hero.
|
||||
* - EXCEPTION: when the Coach has proactively written an UNREAD message
|
||||
* (`/api/insights/coach/nudge-status` → `unread`), auto-open the
|
||||
* most-recent thread so the user lands on what the Coach said.
|
||||
* The search-param read sits in a Suspense child so the client-bailout
|
||||
* never de-opts the route.
|
||||
*
|
||||
* 2026-07-17 UX-flows audit — the route understood only `?c=` / `?doc=`, so
|
||||
* every other cross-surface hand-off (a metric card's "ask about this", the
|
||||
* Today check-in's "Adjust", a workout detail's coach button) dropped its
|
||||
* context at the URL boundary and landed on a blank composer (F1-2 / F4-1 /
|
||||
* F6-1). Two additive params close that gap, both seeding the SAME props
|
||||
* `<CoachConversation>` already exposes for the drawer's suggested-prompt
|
||||
* chips — no new plumbing inside the conversation surface itself:
|
||||
* - `?scope=<CoachScopeSource>` — narrows the snapshot the FIRST turn of a
|
||||
* fresh conversation reads (validated against the closed enum; an
|
||||
* unrecognised value is silently dropped rather than reaching the chat
|
||||
* route with a free-form string).
|
||||
* - `?ask=<text>` — seeds the composer prefill (mirrors `prefill` on the
|
||||
* in-app `askCoach()` launch call). The user still reviews/sends it —
|
||||
* this is a prefill, not an auto-send.
|
||||
* Both are ignored once `?c=` or `?doc=` pin an existing/scoped thread — scope
|
||||
* only ever applies to a fresh conversation's first turn.
|
||||
*/
|
||||
function CoachPageBody() {
|
||||
const searchParams = useSearchParams();
|
||||
// `?c=new` is an explicit "start a fresh chat" escape hatch; any other
|
||||
// value is treated as a conversation id to open. A blank/absent param
|
||||
// defaults to the new-chat hero.
|
||||
const rawC = searchParams.get("c");
|
||||
const deepLinkedId = rawC && rawC !== "new" ? rawC : null;
|
||||
// v1.28.51 (Documents R3, Design A) — `?doc=<id>` seeds a fresh chat SCOPED
|
||||
// to a stored document (the vault detail sheet's "Ask the Coach" action). An
|
||||
// explicit `?c=<id>` thread wins over it (that thread carries its own scope).
|
||||
const rawDoc = searchParams.get("doc");
|
||||
const seedDocumentId = deepLinkedId === null && rawDoc ? rawDoc : null;
|
||||
|
||||
// A fresh conversation only — an existing thread (`?c=`) or a doc-scoped
|
||||
// chat (`?doc=`) keeps its own established scope.
|
||||
const freshChat = deepLinkedId === null && seedDocumentId === null;
|
||||
const rawScope = freshChat ? searchParams.get("scope") : null;
|
||||
const scopeResult = rawScope
|
||||
? coachScopeSourceSchema.safeParse(rawScope)
|
||||
: null;
|
||||
const launchScope: CoachLaunchScope | null = scopeResult?.success
|
||||
? { metric: scopeResult.data }
|
||||
: null;
|
||||
const seedPrefill = freshChat ? searchParams.get("ask") : null;
|
||||
|
||||
// C1 exception — an unread coach-initiated message opens the most-recent
|
||||
// conversation (which holds that proactive turn). Only consulted when the
|
||||
// entry did not pin a specific thread or ask for a fresh chat.
|
||||
// A `?doc=` open is an explicit fresh doc-scoped chat — never override it by
|
||||
// resuming the most-recent thread on an unread nudge. A `?scope=`/`?ask=`
|
||||
// hand-off is likewise an explicit fresh-chat request.
|
||||
const exceptionEligible =
|
||||
deepLinkedId === null &&
|
||||
rawC !== "new" &&
|
||||
seedDocumentId === null &&
|
||||
launchScope === null &&
|
||||
!seedPrefill;
|
||||
const { data: nudge } = useQuery({
|
||||
queryKey: queryKeys.coachNudgeStatus(),
|
||||
queryFn: async (): Promise<CoachNudgeStatus> =>
|
||||
apiGet<CoachNudgeStatus>("/api/insights/coach/nudge-status"),
|
||||
enabled: exceptionEligible,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const hasUnreadCoachMessage = exceptionEligible && nudge?.unread === true;
|
||||
|
||||
return (
|
||||
<CoachConversation
|
||||
surface="page"
|
||||
autoFocusComposer
|
||||
initialConversationId={deepLinkedId}
|
||||
// v1.28.51 — seed the document scope for a `?doc=<id>` open so the first
|
||||
// turn is created + sent through the hardened fenced document endpoint.
|
||||
initialDocumentId={seedDocumentId}
|
||||
// 2026-07-17 UX-flows audit F1-2/F4-1/F6-1 — seed the scope/prefill a
|
||||
// cross-surface hand-off carried in the URL.
|
||||
launchScope={launchScope}
|
||||
prefill={seedPrefill}
|
||||
// Default is the new-chat hero; only resume most-recent for the
|
||||
// unread coach-initiated exception.
|
||||
autoOpenMostRecent={hasUnreadCoachMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CoachPageClient() {
|
||||
const router = useRouter();
|
||||
const launch = useCoachLaunch();
|
||||
const flags = useFeatureFlags();
|
||||
const disableCoach = useDisableCoach();
|
||||
|
||||
const coachUnavailable = !flags.coach || disableCoach;
|
||||
|
||||
// Coach surface gating mirrors `<CoachLaunchButton>` / `<LayoutCoachFab>`:
|
||||
// operator master flag OR per-user opt-out hides the Coach entirely.
|
||||
// Send a direct navigator back to the Insights mother page so the
|
||||
// route is never a dead-end.
|
||||
useEffect(() => {
|
||||
if (coachUnavailable) {
|
||||
router.replace("/insights");
|
||||
}
|
||||
}, [coachUnavailable, router]);
|
||||
|
||||
// Reopening the bottom-right FAB drawer is no longer wired to a page
|
||||
// control, but keep the launch context referenced so the lint rule does
|
||||
// not flag the unused hook — the drawer the page hands back to lives in
|
||||
// the shared `<AuthShell>`.
|
||||
void launch;
|
||||
|
||||
if (coachUnavailable) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-page"
|
||||
data-tour-id="coach-hero"
|
||||
// v1.18.10 (W4) — full-bleed conversation surface. Cancel the
|
||||
// AuthShell container's padding (`-mt-6` top, `-mb-20` bottom,
|
||||
// `-mx-4 md:-mx-6` sides) so the chat is edge-to-edge and claims the
|
||||
// full viewport height minus the top bar (`4rem`). No inner card:
|
||||
// `<CoachConversation>` paints directly onto the page so it reads as
|
||||
// one continuous conversation, not a window inside a window. The
|
||||
// separate bottom-right FAB/overlay drawer stays the compact surface.
|
||||
//
|
||||
// v1.18.10 (W10) — height is mobile-aware. The fixed `<BottomNav>`
|
||||
// (64px + iOS home-indicator inset) is mobile-only; the desktop
|
||||
// full-bleed layout has no bottom nav. On mobile the page must subtract
|
||||
// the TopBar (`4rem`) AND the BottomNav band (`4rem` +
|
||||
// `env(safe-area-inset-bottom)`) so the docked composer / Stop control
|
||||
// always clears the nav instead of sitting under it. On `md+` the nav
|
||||
// is hidden, so the page reclaims the full height below the TopBar.
|
||||
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]"
|
||||
>
|
||||
{/* `useSearchParams` requires a Suspense boundary so the client-search
|
||||
bailout never opts the whole route out of static optimisation. */}
|
||||
<Suspense fallback={null}>
|
||||
<CoachPageBody />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+72
-186
@@ -1,199 +1,85 @@
|
||||
"use client";
|
||||
import {
|
||||
dehydrate,
|
||||
HydrationBoundary,
|
||||
QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { CoachConversation } from "@/components/insights/coach-panel/coach-conversation";
|
||||
import type { CoachNudgeStatus } from "@/components/insights/layout-coach-fab";
|
||||
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useDisableCoach } from "@/hooks/use-disable-coach";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { readCoachNudgeStatus } from "@/lib/ai/coach/nudge-status";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { coachScopeSourceSchema } from "@/lib/ai/coach/types";
|
||||
|
||||
import CoachPageClient from "./page-client";
|
||||
|
||||
/**
|
||||
* v1.12.0 (Coach v2 #6) — full-page Coach conversation.
|
||||
* Thin RSC wrapper around the (client) full-page Coach.
|
||||
*
|
||||
* Renders the exact same `<CoachConversation>` surface the drawer uses
|
||||
* (history + streaming thread + composer + provenance + settings) at
|
||||
* full width and height. There is no forked chat logic — the page is a
|
||||
* thin chrome wrapper around the shared surface.
|
||||
* The default new-chat hero path carries the one genuine sequential waterfall
|
||||
* on this route: the client `coachNudgeStatus` cell fetches
|
||||
* `/api/insights/coach/nudge-status` after hydrate, and the conversation's
|
||||
* `autoOpenMostRecent` decision is gated on that result — so the hero "pops in
|
||||
* after a beat" while the nudge round-trip resolves. This wrapper runs the SAME
|
||||
* read the API route uses (`readCoachNudgeStatus`) during SSR and hands it to
|
||||
* TanStack via `HydrationBoundary`, so the auto-open decision is available at
|
||||
* hydrate and the waterfall step collapses.
|
||||
*
|
||||
* v1.18.0 — the route is a standalone top-level page (`/coach`), no longer
|
||||
* nested under `app/insights/layout.tsx`, so it renders in the standard
|
||||
* page chrome WITHOUT the Insights tab strip. It still sits inside the
|
||||
* authenticated `<AuthShell>` (which provides the `<CoachLaunchProvider>`
|
||||
* the bottom-right FAB drawer reopens through), so the page inherits the
|
||||
* same Coach feature gating as every other Coach surface. When the
|
||||
* operator's master flag is off or the user opted out, the page redirects
|
||||
* back to `/insights` rather than painting a dead chat shell.
|
||||
*
|
||||
* v1.18.10 (W4) — the page is now a single full-bleed conversation
|
||||
* surface: the inner panel chrome (the bordered/rounded card) is gone so
|
||||
* the chat is no longer a window-inside-a-window, and the top "back to the
|
||||
* drawer" minimize control is removed (pointless on the dedicated page —
|
||||
* the separate bottom-right FAB drawer is the compact surface). The chat
|
||||
* claims the full content width and the viewport height below the top bar.
|
||||
*
|
||||
* v1.18.11 (W11) — ChatGPT-style IA: the page drops its own header bar
|
||||
* AND the top conversation-history strip entirely. The composer is the
|
||||
* single control hub (leading `+` actions menu = new chat + open
|
||||
* conversations, settings deep-link, mic, send); conversation history is a
|
||||
* left slide-in drawer opened from that menu. The composer keeps one
|
||||
* constant, centred, max-width-capped column across the new-chat hero and
|
||||
* the active conversation. All of that lives in `<CoachConversation>`'s
|
||||
* page branch, so the page stays a thin gating + sizing wrapper.
|
||||
*
|
||||
* v1.19.1 (C1/C5) — the Coach now DEFAULTS to the new-chat hero, reversing
|
||||
* the v1.19.0 (W7) "always resume most-recent" default the maintainer
|
||||
* disliked. Resolution order on mount:
|
||||
* - `?c=<id>` → open that specific thread (explicit deep-link, unchanged).
|
||||
* The dedicated conversation-history page (`/coach/conversations`, added
|
||||
* in v1.21.4) routes back here with `?c=<id>` when a row is selected.
|
||||
* - `?c=new` or no param → the new-chat hero.
|
||||
* - EXCEPTION: when the Coach has proactively written an UNREAD message
|
||||
* (`/api/insights/coach/nudge-status` → `unread`), auto-open the
|
||||
* most-recent thread so the user lands on what the Coach said.
|
||||
* The search-param read sits in a Suspense child so the client-bailout
|
||||
* never de-opts the route.
|
||||
*
|
||||
* 2026-07-17 UX-flows audit — the route understood only `?c=` / `?doc=`, so
|
||||
* every other cross-surface hand-off (a metric card's "ask about this", the
|
||||
* Today check-in's "Adjust", a workout detail's coach button) dropped its
|
||||
* context at the URL boundary and landed on a blank composer (F1-2 / F4-1 /
|
||||
* F6-1). Two additive params close that gap, both seeding the SAME props
|
||||
* `<CoachConversation>` already exposes for the drawer's suggested-prompt
|
||||
* chips — no new plumbing inside the conversation surface itself:
|
||||
* - `?scope=<CoachScopeSource>` — narrows the snapshot the FIRST turn of a
|
||||
* fresh conversation reads (validated against the closed enum; an
|
||||
* unrecognised value is silently dropped rather than reaching the chat
|
||||
* route with a free-form string).
|
||||
* - `?ask=<text>` — seeds the composer prefill (mirrors `prefill` on the
|
||||
* in-app `askCoach()` launch call). The user still reviews/sends it —
|
||||
* this is a prefill, not an auto-send.
|
||||
* Both are ignored once `?c=` or `?doc=` pin an existing/scoped thread — scope
|
||||
* only ever applies to a fresh conversation's first turn.
|
||||
* Contract notes (the v1.30.9 dashboard template):
|
||||
* - The query key comes ONLY from the central factory —
|
||||
* `queryKeys.coachNudgeStatus()` is a deterministic zero-arg tuple, so the
|
||||
* server-seeded key equals the client's `useQuery` key by construction.
|
||||
* - The dehydrated VALUE is JSON-round-tripped; the read already returns ISO
|
||||
* strings, so the shape is byte-identical to the client's
|
||||
* ((await res.json()).data) wire.
|
||||
* - The client cell carries `staleTime: 5min` with the default
|
||||
* `refetchOnMount`, so the seeded fresh value is used at hydrate without an
|
||||
* immediate refetch. When the client query is disabled (a `?c=`/`?doc=`/
|
||||
* `?scope=` deep-link makes it `enabled: false`) the seeded value simply
|
||||
* sits unused — harmless.
|
||||
* - Availability parity: prefetch only when the Coach surface is actually
|
||||
* reachable — the operator `coach` assistant flag is ON
|
||||
* (`requireAssistantSurface` throws otherwise) AND the user has not opted
|
||||
* out (`disableCoach`). Mirrors the client's `!flags.coach || disableCoach`
|
||||
* redirect gate, so a disabled account does no wasted work.
|
||||
* - Left CLIENT-LAZY on purpose: the streaming conversation itself (the SSE
|
||||
* thread, snapshot, any provider-touching path) is NOT prefetched or warmed
|
||||
* — it depends on URL params + the nudge outcome and must never be triggered
|
||||
* server-side.
|
||||
* - Fail-soft: no session, the Coach being off, or a DB blip renders the page
|
||||
* exactly as before this wrapper existed — the client cell owns the fetch.
|
||||
*/
|
||||
function CoachPageBody() {
|
||||
const searchParams = useSearchParams();
|
||||
// `?c=new` is an explicit "start a fresh chat" escape hatch; any other
|
||||
// value is treated as a conversation id to open. A blank/absent param
|
||||
// defaults to the new-chat hero.
|
||||
const rawC = searchParams.get("c");
|
||||
const deepLinkedId = rawC && rawC !== "new" ? rawC : null;
|
||||
// v1.28.51 (Documents R3, Design A) — `?doc=<id>` seeds a fresh chat SCOPED
|
||||
// to a stored document (the vault detail sheet's "Ask the Coach" action). An
|
||||
// explicit `?c=<id>` thread wins over it (that thread carries its own scope).
|
||||
const rawDoc = searchParams.get("doc");
|
||||
const seedDocumentId = deepLinkedId === null && rawDoc ? rawDoc : null;
|
||||
export default async function CoachPage() {
|
||||
// Global SSR-prefetch kill-switch shared with the dashboard wrapper. The e2e
|
||||
// server sets `DASHBOARD_SSR_PREFETCH=false` so Playwright route mocks — which
|
||||
// only see CLIENT fetches — keep governing what every prefetched page paints.
|
||||
if (process.env.DASHBOARD_SSR_PREFETCH === "false") {
|
||||
return <CoachPageClient />;
|
||||
}
|
||||
|
||||
// A fresh conversation only — an existing thread (`?c=`) or a doc-scoped
|
||||
// chat (`?doc=`) keeps its own established scope.
|
||||
const freshChat = deepLinkedId === null && seedDocumentId === null;
|
||||
const rawScope = freshChat ? searchParams.get("scope") : null;
|
||||
const scopeResult = rawScope
|
||||
? coachScopeSourceSchema.safeParse(rawScope)
|
||||
: null;
|
||||
const launchScope: CoachLaunchScope | null = scopeResult?.success
|
||||
? { metric: scopeResult.data }
|
||||
: null;
|
||||
const seedPrefill = freshChat ? searchParams.get("ask") : null;
|
||||
|
||||
// C1 exception — an unread coach-initiated message opens the most-recent
|
||||
// conversation (which holds that proactive turn). Only consulted when the
|
||||
// entry did not pin a specific thread or ask for a fresh chat.
|
||||
// A `?doc=` open is an explicit fresh doc-scoped chat — never override it by
|
||||
// resuming the most-recent thread on an unread nudge. A `?scope=`/`?ask=`
|
||||
// hand-off is likewise an explicit fresh-chat request.
|
||||
const exceptionEligible =
|
||||
deepLinkedId === null &&
|
||||
rawC !== "new" &&
|
||||
seedDocumentId === null &&
|
||||
launchScope === null &&
|
||||
!seedPrefill;
|
||||
const { data: nudge } = useQuery({
|
||||
queryKey: queryKeys.coachNudgeStatus(),
|
||||
queryFn: async (): Promise<CoachNudgeStatus> =>
|
||||
apiGet<CoachNudgeStatus>("/api/insights/coach/nudge-status"),
|
||||
enabled: exceptionEligible,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const hasUnreadCoachMessage = exceptionEligible && nudge?.unread === true;
|
||||
|
||||
return (
|
||||
<CoachConversation
|
||||
surface="page"
|
||||
autoFocusComposer
|
||||
initialConversationId={deepLinkedId}
|
||||
// v1.28.51 — seed the document scope for a `?doc=<id>` open so the first
|
||||
// turn is created + sent through the hardened fenced document endpoint.
|
||||
initialDocumentId={seedDocumentId}
|
||||
// 2026-07-17 UX-flows audit F1-2/F4-1/F6-1 — seed the scope/prefill a
|
||||
// cross-surface hand-off carried in the URL.
|
||||
launchScope={launchScope}
|
||||
prefill={seedPrefill}
|
||||
// Default is the new-chat hero; only resume most-recent for the
|
||||
// unread coach-initiated exception.
|
||||
autoOpenMostRecent={hasUnreadCoachMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CoachPage() {
|
||||
const router = useRouter();
|
||||
const launch = useCoachLaunch();
|
||||
const flags = useFeatureFlags();
|
||||
const disableCoach = useDisableCoach();
|
||||
|
||||
const coachUnavailable = !flags.coach || disableCoach;
|
||||
|
||||
// Coach surface gating mirrors `<CoachLaunchButton>` / `<LayoutCoachFab>`:
|
||||
// operator master flag OR per-user opt-out hides the Coach entirely.
|
||||
// Send a direct navigator back to the Insights mother page so the
|
||||
// route is never a dead-end.
|
||||
useEffect(() => {
|
||||
if (coachUnavailable) {
|
||||
router.replace("/insights");
|
||||
let dehydratedState = null;
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (session && !session.user.disableCoach) {
|
||||
// Throws `AssistantDisabledError` when the operator flag is off → caught
|
||||
// below, prefetch skipped, client path stands (and redirects to /insights).
|
||||
await requireAssistantSurface("coach");
|
||||
const nudge = await readCoachNudgeStatus(session.user.id);
|
||||
const queryClient = new QueryClient();
|
||||
queryClient.setQueryData(
|
||||
queryKeys.coachNudgeStatus(),
|
||||
JSON.parse(JSON.stringify(nudge)),
|
||||
);
|
||||
dehydratedState = dehydrate(queryClient);
|
||||
}
|
||||
}, [coachUnavailable, router]);
|
||||
|
||||
// Reopening the bottom-right FAB drawer is no longer wired to a page
|
||||
// control, but keep the launch context referenced so the lint rule does
|
||||
// not flag the unused hook — the drawer the page hands back to lives in
|
||||
// the shared `<AuthShell>`.
|
||||
void launch;
|
||||
|
||||
if (coachUnavailable) return null;
|
||||
} catch {
|
||||
// Prefetch is an accelerator, never a gate — the client path stands.
|
||||
}
|
||||
|
||||
if (dehydratedState === null) {
|
||||
return <CoachPageClient />;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-page"
|
||||
data-tour-id="coach-hero"
|
||||
// v1.18.10 (W4) — full-bleed conversation surface. Cancel the
|
||||
// AuthShell container's padding (`-mt-6` top, `-mb-20` bottom,
|
||||
// `-mx-4 md:-mx-6` sides) so the chat is edge-to-edge and claims the
|
||||
// full viewport height minus the top bar (`4rem`). No inner card:
|
||||
// `<CoachConversation>` paints directly onto the page so it reads as
|
||||
// one continuous conversation, not a window inside a window. The
|
||||
// separate bottom-right FAB/overlay drawer stays the compact surface.
|
||||
//
|
||||
// v1.18.10 (W10) — height is mobile-aware. The fixed `<BottomNav>`
|
||||
// (64px + iOS home-indicator inset) is mobile-only; the desktop
|
||||
// full-bleed layout has no bottom nav. On mobile the page must subtract
|
||||
// the TopBar (`4rem`) AND the BottomNav band (`4rem` +
|
||||
// `env(safe-area-inset-bottom)`) so the docked composer / Stop control
|
||||
// always clears the nav instead of sitting under it. On `md+` the nav
|
||||
// is hidden, so the page reclaims the full height below the TopBar.
|
||||
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]"
|
||||
>
|
||||
{/* `useSearchParams` requires a Suspense boundary so the client-search
|
||||
bailout never opts the whole route out of static optimisation. */}
|
||||
<Suspense fallback={null}>
|
||||
<CoachPageBody />
|
||||
</Suspense>
|
||||
</div>
|
||||
<HydrationBoundary state={dehydratedState}>
|
||||
<CoachPageClient />
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { HydrationBoundary, hashKey } from "@tanstack/react-query";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* v1.30.x — the `/insights` server-prefetch key crux + fail-soft.
|
||||
*
|
||||
* The RSC wrapper (`src/app/insights/page.tsx`) dehydrates the dashboard
|
||||
* snapshot under the SAME locale-keyed cell the dashboard warms, so the
|
||||
* Insights hero's snapshot-derived band paints from the first HTML.
|
||||
*
|
||||
* THE CRUX: the snapshot cell is locale-keyed. The locale is resolved ONCE
|
||||
* server-side by `readDashboardSnapshotCached` and returned; the client keys on
|
||||
* the SAME resolved locale from the i18n context. These tests pin that the
|
||||
* server dehydrates under `queryKeys.dashboardSnapshot(<returned locale>)`
|
||||
* (byte-identical hash) + the widget layout, and that every error path fails
|
||||
* soft to the bare client.
|
||||
*/
|
||||
|
||||
const getSession = vi.fn();
|
||||
const readDashboardSnapshotCached = vi.fn();
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: () => getSession() }));
|
||||
vi.mock("@/lib/dashboard/snapshot-read", () => ({
|
||||
readDashboardSnapshotCached: (u: unknown) => readDashboardSnapshotCached(u),
|
||||
}));
|
||||
vi.mock("../page-client", () => ({ default: () => null }));
|
||||
|
||||
import InsightsPage from "../page";
|
||||
|
||||
const SESSION = { user: { id: "u1", timezone: "Europe/Berlin" } };
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env.DASHBOARD_SSR_PREFETCH;
|
||||
});
|
||||
|
||||
function queriesOf(
|
||||
el: ReactElement,
|
||||
): { queryHash: string; state: { data: unknown } }[] {
|
||||
if (el.type !== HydrationBoundary) return [];
|
||||
const props = el.props as {
|
||||
state?: { queries: { queryHash: string; state: { data: unknown } }[] };
|
||||
};
|
||||
return props.state?.queries ?? [];
|
||||
}
|
||||
|
||||
describe("/insights RSC prefetch", () => {
|
||||
it("dehydrates the snapshot under the locale-keyed client key", async () => {
|
||||
getSession.mockResolvedValue(SESSION);
|
||||
readDashboardSnapshotCached.mockResolvedValue({
|
||||
body: {
|
||||
healthScore: { tension: "steady" },
|
||||
layout: { version: 1, widgets: [] },
|
||||
},
|
||||
locale: "de",
|
||||
});
|
||||
|
||||
const el = (await InsightsPage()) as ReactElement;
|
||||
const queries = queriesOf(el);
|
||||
const hashes = queries.map((q) => q.queryHash);
|
||||
// The client's `useDashboardSnapshot` keys on the SAME resolved locale.
|
||||
expect(hashes).toContain(hashKey(queryKeys.dashboardSnapshot("de")));
|
||||
// And the widget layout the snapshot carries is seeded too.
|
||||
expect(hashes).toContain(hashKey(queryKeys.dashboardWidgets()));
|
||||
// A zero-arg / wrong-locale key must NOT be what was seeded.
|
||||
expect(hashes).not.toContain(hashKey(queryKeys.dashboardSnapshot()));
|
||||
expect(hashes).not.toContain(hashKey(queryKeys.dashboardSnapshot("en")));
|
||||
});
|
||||
|
||||
it("JSON-round-trips the snapshot body to the wire shape", async () => {
|
||||
getSession.mockResolvedValue(SESSION);
|
||||
const cachedAt = new Date("2026-07-18T06:00:00.000Z");
|
||||
readDashboardSnapshotCached.mockResolvedValue({
|
||||
body: { cachedAt, layout: { version: 1, widgets: [] } },
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
const el = (await InsightsPage()) as ReactElement;
|
||||
const snap = queriesOf(el).find(
|
||||
(q) => q.queryHash === hashKey(queryKeys.dashboardSnapshot("en")),
|
||||
);
|
||||
expect((snap!.state.data as { cachedAt: unknown }).cachedAt).toBe(
|
||||
cachedAt.toISOString(),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails soft to the bare client when the read throws", async () => {
|
||||
getSession.mockResolvedValue(SESSION);
|
||||
readDashboardSnapshotCached.mockRejectedValue(new Error("db blip"));
|
||||
|
||||
const el = (await InsightsPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
});
|
||||
|
||||
it("fails soft when there is no session", async () => {
|
||||
getSession.mockResolvedValue(null);
|
||||
const el = (await InsightsPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(readDashboardSnapshotCached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("honours the DASHBOARD_SSR_PREFETCH kill-switch", async () => {
|
||||
process.env.DASHBOARD_SSR_PREFETCH = "false";
|
||||
const el = (await InsightsPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(getSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,7 @@ import { join } from "node:path";
|
||||
*/
|
||||
describe("insights mother-page dynamic-skeleton loaders use the shared BlockSkeleton", () => {
|
||||
const src = readFileSync(
|
||||
join(process.cwd(), "src/app/insights/page.tsx"),
|
||||
join(process.cwd(), "src/app/insights/page-client.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment, useCallback, type ReactNode } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { SlidersHorizontal, TrendingUp } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import {
|
||||
orderedVisibleSectionIds,
|
||||
type InsightsSectionId,
|
||||
} from "@/lib/insights-layout";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HeroStrip } from "@/components/insights/hero-strip";
|
||||
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
|
||||
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
|
||||
import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
|
||||
import { useDashboardDerived } from "@/components/insights/derived/use-dashboard-derived";
|
||||
// v1.4.41 W-ORG — shared shape lives in `src/types/analytics.ts` as
|
||||
// `InsightsAnalyticsData`; aliased back to the local name to keep the
|
||||
// rest of this file readable.
|
||||
import type { InsightsAnalyticsData as AnalyticsData } from "@/types/analytics";
|
||||
|
||||
/**
|
||||
* v1.4.33 IW2 — defer the below-the-fold mother-page blocks behind
|
||||
* `next/dynamic`. `<HeroStrip>` (the only above-the-fold piece) stays an
|
||||
* eager import so the initial paint shows the greeting and health-score
|
||||
* badge without a flash; the briefing, correlation row and trends row
|
||||
* each carry their own icon-set + chart wiring (a chart card alone weighs
|
||||
* in at the lucide tree-shake limit) and used to land on every Insights
|
||||
* cold mount.
|
||||
*
|
||||
* v1.11.3 — every loader fallback now routes through the shared
|
||||
* `BlockSkeleton` instead of a bespoke `h-[Xrem] animate-pulse` div. The
|
||||
* fixed guessed heights pinned each placeholder taller (or shorter) than
|
||||
* the resolved block, so the page jumped as each chunk landed. The shared
|
||||
* skeleton carries a `min-h` floor — enough to hold the row open at cold
|
||||
* mount — and lets the block grow into its true height without fighting a
|
||||
* hard-coded pixel guess, killing the resolve-time layout shift. Reduced
|
||||
* motion is honoured by the `Skeleton` primitive (`motion-reduce:animate-none`).
|
||||
*/
|
||||
function BlockSkeleton({
|
||||
minHeight,
|
||||
decorative = false,
|
||||
}: {
|
||||
/** Tailwind `min-h-*` utility holding the row open before the chunk lands. */
|
||||
minHeight: string;
|
||||
/** Decorative placeholders (cards that may un-mount) hide from a11y. */
|
||||
decorative?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Skeleton
|
||||
{...(decorative ? { "aria-hidden": "true" } : {})}
|
||||
className={cn("w-full rounded-xl", minHeight)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const DailyBriefing = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/daily-briefing").then((mod) => ({
|
||||
default: mod.DailyBriefing,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <BlockSkeleton minHeight="min-h-96" />,
|
||||
},
|
||||
);
|
||||
const TrendsRow = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/trends-row").then((mod) => ({
|
||||
default: mod.TrendsRow,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <BlockSkeleton minHeight="min-h-80" />,
|
||||
},
|
||||
);
|
||||
// v1.12.6 — the wellness-score strip, lifted OUT of the vitals dashboard so
|
||||
// it renders as its own full-width section directly above the daily briefing.
|
||||
// Both this strip and the vitals grid read the ONE shared derived batch the
|
||||
// page owns (passed in via `batch`), so the lift adds no second request.
|
||||
const WellnessScores = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/derived").then((mod) => ({
|
||||
default: mod.WellnessScores,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <BlockSkeleton minHeight="min-h-40" decorative />,
|
||||
},
|
||||
);
|
||||
// v1.10.0 — the Vitals dashboard (Apple-Health-Highlights grid of
|
||||
// personal-typical-range + passthrough-reframe tiles). Deferred behind
|
||||
// `next/dynamic` like the other below-the-fold blocks; each tile owns its
|
||||
// own derived-metric query and un-mounts when its vital is absent.
|
||||
const VitalsDashboard = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/derived").then((mod) => ({
|
||||
default: mod.VitalsDashboard,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <BlockSkeleton minHeight="min-h-80" />,
|
||||
},
|
||||
);
|
||||
// v1.10.0 — categorical events (WX-B). The device-flagged event awareness
|
||||
// timeline. Deferred like the other below-the-fold blocks; the card
|
||||
// un-mounts itself when the user has no such events (no skeleton-then-empty
|
||||
// flash — `ssr: false` + the card's own data gate), so it carries no
|
||||
// loading placeholder of its own.
|
||||
const RhythmEventsCard = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/rhythm-events-card").then((mod) => ({
|
||||
default: mod.RhythmEventsCard,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
// v1.28.50 — ECG recording surface (waveform-backed). Deferred like the
|
||||
// other below-the-fold blocks; the section un-mounts itself when the user
|
||||
// has no recordings (`ssr: false` + its own data gate), so it carries no
|
||||
// loading placeholder of its own.
|
||||
const EcgSection = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/ecg-section").then((mod) => ({
|
||||
default: mod.EcgSection,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
// v1.25 — baseline-drift card. Un-mounts when nothing is drifting; owns its own
|
||||
// read-only `/api/insights/health-status` query.
|
||||
const HealthStatusCard = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/health-status-card").then((mod) => ({
|
||||
default: mod.HealthStatusCard,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
// v1.25 — sleep-breathing screening card. Un-mounts on no data; screening
|
||||
// signal only, never a diagnosis.
|
||||
const BreathingScreeningCard = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/breathing-screening-card").then((mod) => ({
|
||||
default: mod.BreathingScreeningCard,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
// v1.25 — "what changed since your last lab panel" card. Un-mounts with fewer
|
||||
// than two panels or no shared analyte.
|
||||
const LabsChangesCard = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/labs-changes-card").then((mod) => ({
|
||||
default: mod.LabsChangesCard,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
// v1.10.3 — "Today's signal" headline card. Promotes COINCIDENT_DEVIATION from
|
||||
// a buried below-the-fold tile to the top-of-overview daily read (the
|
||||
// always-present Apple/WHOOP/Oura pattern). Deferred behind `next/dynamic`; it
|
||||
// owns its own derived-metric query and renders four calm states. The chunk
|
||||
// loader's skeleton shares the card's `min-h-48` footprint (and the in-card
|
||||
// `CardSkeleton` matches it too) so the top of the page does not shift across
|
||||
// loading → any resolved state. Decorative → `aria-hidden`.
|
||||
const CoincidentDeviationCard = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/coincident-deviation-card").then((mod) => ({
|
||||
default: mod.CoincidentDeviationCard,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <BlockSkeleton minHeight="min-h-48" decorative />,
|
||||
},
|
||||
);
|
||||
|
||||
// v1.11.0 — period-narrative card (Pillar P1). The calm "your week/month in
|
||||
// review" summary, drawn from the read-only stale-while-revalidate narrative
|
||||
// route. Deferred behind `next/dynamic` like the other below-the-hero blocks;
|
||||
// it owns its own query and un-mounts when no narrative exists. The loader
|
||||
// skeleton shares the card's `min-h-40` footprint so the page does not shift.
|
||||
const PeriodNarrativeCard = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/period-narrative-card").then((mod) => ({
|
||||
default: mod.PeriodNarrativeCard,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <BlockSkeleton minHeight="min-h-40" decorative />,
|
||||
},
|
||||
);
|
||||
|
||||
// v1.15.2 — the gated cycle-insights summary teaser. Mounted ONLY when
|
||||
// `user.cycleTrackingEnabled` is true (the same /api/auth/me signal the
|
||||
// sidebar nav entry gates on), so the cycle reads never fire for an account
|
||||
// without the feature. Deferred behind `next/dynamic` like the other
|
||||
// below-the-hero blocks; it owns its own calendar + insights reads and renders
|
||||
// nothing while resolving / on error, so it carries no loading placeholder.
|
||||
const CycleInsightSummaryCard = dynamic(
|
||||
() =>
|
||||
import("@/components/cycle/cycle-insight-summary-card").then((mod) => ({
|
||||
default: mod.CycleInsightSummaryCard,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
// v1.15.3 — the compact cycle RING, dropped into the wellness-score strip as a
|
||||
// gated sibling tile (NOT the summary teaser — that stays further down). Mounted
|
||||
// only for a cycle-tracking account, so its calendar read never fires otherwise.
|
||||
// Deferred behind `next/dynamic`; it renders nothing while resolving / on error /
|
||||
// when there is no active cycle, so it carries no loading placeholder.
|
||||
const CycleRingTile = dynamic(
|
||||
() =>
|
||||
import("@/components/cycle/cycle-ring-tile").then((mod) => ({
|
||||
default: mod.CycleRingTile,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
/**
|
||||
* v1.4.25 W4d — Insights mother page.
|
||||
*
|
||||
* The page used to be a single 1.8k-LOC monolith that scroll-anchored
|
||||
* six per-metric sections beneath the hero. W4a/c carved those out
|
||||
* into routed sub-pages under `/insights/{slug}`; this file now holds
|
||||
* the overview-only surface:
|
||||
*
|
||||
* - The sticky tab strip lives in `src/app/insights/layout.tsx`
|
||||
* (the shared `<InsightsLayoutShell>` mounts it). The strip handles
|
||||
* navigation to every sub-page + the regenerate affordance.
|
||||
* - Hero + DailyBriefing + Trends row + advisor card stay here —
|
||||
* they're the cross-metric overview. The per-metric correlation
|
||||
* cards moved onto the metric pages they belong to (Weight owns
|
||||
* weight × weekday, Pulse owns mood × pulse, …), so the overview
|
||||
* no longer renders a duplicate correlation row.
|
||||
* - The CoachDrawer is mounted in the mother-page body only (the maintainer
|
||||
* directive). Navigating to a sub-page unmounts the drawer.
|
||||
*
|
||||
* The per-section status cards (BP/Weight/Pulse/etc.) and their
|
||||
* heavy chart wiring moved to the matching sub-pages.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The mother page only checks whether the comprehensive payload arrived
|
||||
* (the EmptyState gates on `!data`); the metric-specific shape lives on
|
||||
* the sub-pages now. Keep this slim — anything more is dead weight here.
|
||||
*/
|
||||
interface ComprehensiveData {
|
||||
totalMeasurements: number;
|
||||
}
|
||||
|
||||
export default function InsightsPageClient() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const mounted = useMounted();
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// v1.30.1 M12 — the overview fans out into ~6 independent queries
|
||||
// (comprehensive / advisor / analytics / snapshot / derived batch /
|
||||
// layout) mounted by different hooks below, so there's no single
|
||||
// `refetch()` that covers the page. Refetching every currently-mounted
|
||||
// ("active") query is the same blanket approach the audit's fallback
|
||||
// suggests for PWA-resume staleness, and it's the right shape here
|
||||
// regardless of resume vs. an explicit pull.
|
||||
const refreshInsights = useCallback(
|
||||
() => queryClient.refetchQueries({ type: "active" }),
|
||||
[queryClient],
|
||||
);
|
||||
const pull = usePullToRefresh({ onRefresh: refreshInsights });
|
||||
|
||||
// v1.4.33 IW9 — scroll-to-top on route mount centralised in the
|
||||
// shared `useScrollResetOnRoute()` hook. The mother page + the
|
||||
// `<SubPageShell>` both consume the same hook; the legacy duplicate
|
||||
// RAF that lived here pre-v1.4.33 produced a visible double-snap on
|
||||
// slow hydrates (chart skeletons inflating between the two
|
||||
// callbacks).
|
||||
useScrollResetOnRoute();
|
||||
|
||||
// v1.4.27 R3d MB4 — Coach drawer state lives in the layout-level
|
||||
// `<CoachLaunchProvider>`; the drawer is mounted next to the provider in
|
||||
// `src/app/insights/layout.tsx`. v1.18.7 removed the hero-band coach
|
||||
// entry points (the action button + suggested-prompt chips), so the
|
||||
// overview page no longer reads the launch context — the drawer + its
|
||||
// own launcher stay untouched.
|
||||
const flags = useFeatureFlags();
|
||||
|
||||
// v1.4.36 W1 — drop the page-level `isLoading` gate that used to
|
||||
// block the entire shell on `/api/insights/comprehensive`. The
|
||||
// comprehensive payload still feeds the empty-state decision but
|
||||
// every other section now mounts in parallel under its own
|
||||
// <Suspense> boundary, so the user sees the hero + tile skeletons
|
||||
// within ~500 ms instead of waiting on the slowest fan-out. The
|
||||
// empty-state branch only fires once the query has resolved AND
|
||||
// reported zero measurements; while it's in-flight the page paints
|
||||
// the regular shell and the tiles fill in as their data lands.
|
||||
const { data, isLoading, isFetched, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.insightsComprehensive(),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await apiGet<ComprehensiveData>("/api/insights/comprehensive");
|
||||
} catch {
|
||||
throw new Error(t("insights.loadError"));
|
||||
}
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
// The advisor query is also mounted by the layout shell; the page
|
||||
// consumer re-reads from the same cache key so this call is free
|
||||
// beyond the React-state subscription.
|
||||
const advisor = useInsightsAdvisorQuery(isAuthenticated);
|
||||
|
||||
// v1.4.33 IW2 — the mother page reads `healthScore` (a thick-only
|
||||
// field) for the hero score card, so it stays on the default thick
|
||||
// slice. The shared hook still centralises the cache settings so the
|
||||
// consumer dedups with the sub-page mounts that ride the slim slice
|
||||
// instead. Correlations now live on the per-metric pages, so the
|
||||
// overview no longer reads `analytics.correlations`.
|
||||
const analyticsQuery = useAnalyticsQuery();
|
||||
const analytics = analyticsQuery.data as AnalyticsData | undefined;
|
||||
|
||||
// v1.21.2 (A5 / A6) — the dashboard snapshot is the single server source
|
||||
// for the ambient narrative the hero surfaces: the Tension Verdict and
|
||||
// return-to-baseline ride its `healthScore`. The hero score number itself
|
||||
// stays on the analytics payload (unchanged). Reads the same shared
|
||||
// `queryKeys.dashboardSnapshot()` cell the dashboard warms, so the insights
|
||||
// visit pays at most one extra round-trip and usually a warm cache hit.
|
||||
// (The A4 recall/forward-look block left the briefing card — the card now
|
||||
// opens straight on the day's signals.)
|
||||
const snapshotQuery = useDashboardSnapshot(isAuthenticated);
|
||||
const snapshotHealthScore = snapshotQuery.data?.healthScore ?? null;
|
||||
const heroTension = snapshotHealthScore?.tension ?? null;
|
||||
const heroReturnToBand = snapshotHealthScore?.returnToBand ?? null;
|
||||
|
||||
// v1.12.6 — the page owns the ONE overview derived batch. The wellness
|
||||
// strip (above the briefing) and the vitals grid (below it) both read this
|
||||
// single query instance, so the wellness lift adds no second request.
|
||||
const dashboardDerived = useDashboardDerived(isAuthenticated);
|
||||
|
||||
// v1.15.11 W2 — the resolved overview layout (sections + tiles). Drives
|
||||
// both the section render order/visibility below and the Vitals grid's
|
||||
// per-tile order/visibility (passed into <VitalsDashboard>). Defaults to
|
||||
// the canonical layout while in-flight so the first paint matches the
|
||||
// default order with no flicker.
|
||||
// v1.15.18 — the overview layout (sections + tiles) still drives the section
|
||||
// render order/visibility + the Vitals grid below. CUSTOMISING it (and the
|
||||
// nav-pill order) moved out of an in-page "Anpassen" edit mode into
|
||||
// Settings → Insights, reached via the top-right cog on the tab strip.
|
||||
const { layout } = useInsightsLayoutQuery(isAuthenticated);
|
||||
|
||||
// Error branch — a transient 500 / network drop (after the query's
|
||||
// retries are exhausted) settles the comprehensive query with no data.
|
||||
// Without this gate the page would fall through to the "no data yet —
|
||||
// add a measurement" empty-state, which reads false for a user with
|
||||
// history. Surface an error + a Retry that refetches the one query,
|
||||
// mirroring the <VitalsDashboard> error pattern.
|
||||
if (isError) {
|
||||
return (
|
||||
<div data-slot="insights-overview-error">
|
||||
<QueryErrorCard
|
||||
description={t("insights.loadError")}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty-state shortcut — only paint once the comprehensive query has
|
||||
// resolved AND reported zero measurements. While it's in-flight we
|
||||
// fall through to the streamed shell so the user gets the hero +
|
||||
// skeleton tiles inside the first paint budget.
|
||||
if (!isLoading && isFetched && !data) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<TrendingUp className="size-6" />}
|
||||
title={t("insights.emptyTitle")}
|
||||
description={t("insights.emptyDescription")}
|
||||
ctaSize="lg"
|
||||
action={
|
||||
<Button size="sm" asChild>
|
||||
<Link href="/measurements">
|
||||
{t("insights.emptyAddMeasurement")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Gated on `mounted`: the auth query can resolve before this
|
||||
// boundary hydrates, and a greeting that personalises during the
|
||||
// hydration render disagrees with the name-less SSR text — React
|
||||
// #418. See `useMounted`.
|
||||
const heroGreetingName =
|
||||
mounted && user?.username?.trim() && user.username.trim().length > 0
|
||||
? user.username.split(/\s+/)[0]
|
||||
: null;
|
||||
const briefingPayload = advisor.payload?.dailyBriefing ?? null;
|
||||
const heroStripUpdatedAt = advisor.payload?.cachedAt ?? null;
|
||||
|
||||
// v1.4.36 QA C2 — no `<Suspense>` wrappers below. The mother page is
|
||||
// `"use client"`, the below-the-fold blocks load via `next/dynamic`
|
||||
// with `{ ssr: false }` (no Promise-throw on hydrate), and the
|
||||
// TanStack Query hooks each return their own loading state without
|
||||
// ever throwing a thenable. Wrapping these in `<Suspense>` would be
|
||||
// dead code — Suspense never engages and the `loading` props inside
|
||||
// each section already drive the skeleton. The perceptual win we
|
||||
// ship is "early-skeleton paint": the page-level `isLoading` gate is
|
||||
// gone, the hero + each section's own loader skeleton paints inside
|
||||
// the first paint budget while data fills in. Genuinely streamed
|
||||
// server children would require a Server-Component refactor; that's
|
||||
// a v1.5.x track.
|
||||
|
||||
// v1.12.6 — overview section order, top → bottom:
|
||||
// 1. "Guten Morgen" Hero + Coach questions (HeroStrip).
|
||||
// 2. Wellnesswerte — the wellness-score strip (lifted OUT of
|
||||
// <VitalsDashboard> so it sits ABOVE the briefing as its own
|
||||
// full-width section).
|
||||
// 3. "Heute auf einen Blick" briefing (DailyBriefing).
|
||||
// 4. Vitalwerte — the vitals grid (+ mobility) inside <VitalsDashboard>,
|
||||
// which no longer renders the wellness strip.
|
||||
// 5. Trends (TrendsRow).
|
||||
// 6. "Dein Zeitraum im Rückblick" retrospective (PeriodNarrativeCard).
|
||||
// 7. "Signale des Tages" — today's signal (CoincidentDeviationCard) plus
|
||||
// the rhythm-events alert timeline, kept out of the AI gate so a
|
||||
// health alert is never hidden.
|
||||
// The wellness strip and the vitals grid share the ONE `dashboardDerived`
|
||||
// batch the page owns — one request, two sections. The per-metric
|
||||
// correlation cards moved onto the metric pages, so the overview renders no
|
||||
// correlation row. The duplicate footer "prepare assessments" control was
|
||||
// removed in v1.12.4 — the tab-strip regenerate button is the single
|
||||
// affordance. The generic disclaimer lives once in the layout-shell footer.
|
||||
|
||||
// v1.15.11 W2 — decouple the section render order from the JSX. Each section
|
||||
// id maps to its existing node here; the customizable region below renders
|
||||
// `orderedVisibleSectionIds(layout)` against this registry. Every existing
|
||||
// feature/data gate is preserved INSIDE each entry: a section the layout
|
||||
// marks visible but whose gate is off (briefing flag, cycle-enabled) still
|
||||
// resolves to `null`, exactly as today. With the default layout the order +
|
||||
// gates reproduce the pre-v1.15.11 page byte-for-byte. HeroStrip stays
|
||||
// anchored above this region, OUTSIDE the customizable set.
|
||||
const SECTION_REGISTRY: Record<InsightsSectionId, ReactNode> = {
|
||||
"wellness-scores": (
|
||||
<WellnessScores
|
||||
read={dashboardDerived.read}
|
||||
isLoading={dashboardDerived.isLoading}
|
||||
isError={dashboardDerived.isError}
|
||||
refetch={dashboardDerived.refetch}
|
||||
// v1.15.3 — the cycle ring rides the scores strip as a gated sibling
|
||||
// tile, only for a cycle-tracking account, so its calendar read never
|
||||
// fires otherwise (the same `/api/auth/me` gate the sidebar nav uses).
|
||||
// v1.18.0 — gate on the resolved `modules.cycle` flag (per-user toggle
|
||||
// AND the operator server-wide kill-switch) so an operator-off
|
||||
// instance never renders the ring nor fires its calendar read.
|
||||
extraTile={
|
||||
user?.modules?.cycle === true ? <CycleRingTile /> : undefined
|
||||
}
|
||||
// v1.15.5 — when the cycle ring is shown it TAKES the Strain slot:
|
||||
// hide Strain so the strip stays compact instead of growing a sixth
|
||||
// tile. Strain stays visible for non-cycle accounts.
|
||||
hideStrain={user?.modules?.cycle === true}
|
||||
/>
|
||||
),
|
||||
"daily-briefing": flags.briefing ? (
|
||||
<DailyBriefing
|
||||
briefing={briefingPayload}
|
||||
updatedAt={heroStripUpdatedAt}
|
||||
loading={advisor.isLoading}
|
||||
onRegenerate={advisor.regenerate}
|
||||
regenerating={advisor.isRegenerating}
|
||||
// v1.15.20 — the read path reports a 422 (no provider configured)
|
||||
// as its own outcome; swap the futile regenerate CTA for a quiet
|
||||
// Settings → AI hint instead of an eternal "preparing" loop.
|
||||
noProvider={advisor.readOutcome === "no-provider"}
|
||||
// v1.18.9 (#4) — a (stale) cached briefing is shown but no provider
|
||||
// is connected, so it can never refresh. Pair the relative-age
|
||||
// footer with a discreet connect-provider hint. Only fires when a
|
||||
// briefing is actually rendered (the empty-state path owns the
|
||||
// `noProvider` branch above).
|
||||
noProviderStale={briefingPayload !== null && !advisor.hasProvider}
|
||||
// v1.25 — the last generation attempt failed. On a held briefing this
|
||||
// adds a discreet "couldn't refresh — retry" footer hint; on an empty
|
||||
// card it swaps the generic empty state for an honest "couldn't
|
||||
// generate" one. Suppressed when no provider is configured (that hint
|
||||
// owns the surface and a retry would be futile).
|
||||
generationFailed={advisor.generationFailed && advisor.hasProvider}
|
||||
// v1.25.3 — failure class points the empty-state hint at the right
|
||||
// lever (raise the response timeout vs re-check the provider).
|
||||
generationFailureClass={advisor.generationFailureClass}
|
||||
// v1.28.28 (#470) — the grounding gate stripped the last regenerated
|
||||
// briefing; the card explains the omission instead of "no briefing
|
||||
// yet" (which made the regenerate button read as doing nothing).
|
||||
omittedReason={advisor.briefingOmittedReason}
|
||||
/>
|
||||
) : null,
|
||||
vitals: <VitalsDashboard batch={dashboardDerived} layout={layout} />,
|
||||
trends: (
|
||||
<TrendsRow
|
||||
briefing={briefingPayload}
|
||||
annotations={advisor.payload?.trendAnnotations ?? null}
|
||||
loading={advisor.isLoading || advisor.isRegenerating}
|
||||
/>
|
||||
),
|
||||
"period-review": flags.briefing ? (
|
||||
<PeriodNarrativeCard enabled={isAuthenticated} />
|
||||
) : null,
|
||||
// v1.15.2 — gated cycle teaser. Render only for a cycle-tracking account;
|
||||
// for everyone else this is nothing (no card, no layout gap). The card
|
||||
// itself stays silent until its reads resolve.
|
||||
"cycle-summary": user?.cycleTrackingEnabled ? (
|
||||
<CycleInsightSummaryCard />
|
||||
) : null,
|
||||
signals: <CoincidentDeviationCard enabled={isAuthenticated} />,
|
||||
"rhythm-events": <RhythmEventsCard enabled={isAuthenticated} />,
|
||||
"health-status": <HealthStatusCard enabled={isAuthenticated} />,
|
||||
breathing: <BreathingScreeningCard enabled={isAuthenticated} />,
|
||||
"labs-changes": <LabsChangesCard enabled={isAuthenticated} />,
|
||||
ecg: <EcgSection enabled={isAuthenticated} />,
|
||||
};
|
||||
|
||||
const orderedSectionIds = orderedVisibleSectionIds(layout);
|
||||
const everySectionHidden = orderedSectionIds.length === 0;
|
||||
|
||||
return (
|
||||
// v1.12.7 (L3) — one consistent vertical rhythm down the overview. The
|
||||
// page used `space-y-8` (32 px) between top-level blocks while the vitals
|
||||
// wrap used `space-y-6` (24 px), so the overview read as two tiers. Unify
|
||||
// to `space-y-6` — it matches the vitals wrap and tightens the overview in
|
||||
// line with the "Insights gives away too much space" direction.
|
||||
//
|
||||
// v1.15.10 — `space-y-6` is now the SINGLE inter-section gap, top to
|
||||
// bottom. Every section renders as a real `<section>` (or `null` — never a
|
||||
// `display: contents` wrapper, which used to collapse the cycle-summary box
|
||||
// so `space-y` skipped its margin and the cycle→signals seam read as
|
||||
// zero-gap). Each section owns its OWN `SectionHeading` + `space-y-3` to its
|
||||
// card, so the rhythm is even regardless of which sections are present.
|
||||
<div className="space-y-6">
|
||||
<PullToRefreshIndicator {...pull} />
|
||||
<HeroStrip
|
||||
briefing={briefingPayload}
|
||||
updatedAt={heroStripUpdatedAt}
|
||||
userName={heroGreetingName}
|
||||
healthScore={analytics?.healthScore ?? undefined}
|
||||
// v1.16.8 — reserve the score card's column while the thick
|
||||
// analytics payload is in flight. Every overview query
|
||||
// (comprehensive, advisor, analytics, derived batch, layout)
|
||||
// already fires in parallel at mount; the "greeting first,
|
||||
// score card later" stagger came from the right column not
|
||||
// existing until the slowest payload resolved. `isPending`
|
||||
// settles false on success AND error, so a no-score account
|
||||
// collapses the column exactly once.
|
||||
healthScorePending={analyticsQuery.isPending && !analytics}
|
||||
// v1.18.9 (#4) — the hero subtitle is the cached briefing paragraph;
|
||||
// when it can never refresh (no AI provider) pair the "Generated
|
||||
// <relative>" line with a discreet connect-provider hint, matching
|
||||
// the briefing card footer. Only when a briefing is actually shown.
|
||||
noProviderStale={briefingPayload !== null && !advisor.hasProvider}
|
||||
// v1.21.2 (A5 / A6) — server-resolved Tension Verdict + return-to-
|
||||
// baseline off the dashboard snapshot. The hero localises the keys and
|
||||
// forwards them to the score card; null leaves the card quiet.
|
||||
tension={heroTension}
|
||||
returnToBand={heroReturnToBand}
|
||||
/>
|
||||
|
||||
{/* v1.15.18 — the inline "Anpassen" toggle was removed. Customising the
|
||||
overview (section show/hide + order) and sorting the nav pills now
|
||||
live ONLY in Settings → Insights, reached via the top-right cog on the
|
||||
tab strip — no duplicate in-page entry point. */}
|
||||
{everySectionHidden ? (
|
||||
/* v1.15.18 — empty-state: every section hidden. The page is never
|
||||
blank — surface a hint + a link to the Insights settings section so
|
||||
the user can bring sections back. */
|
||||
<EmptyState
|
||||
icon={<SlidersHorizontal className="size-6" />}
|
||||
title={t("insights.editMode.emptyTitle")}
|
||||
description={t("insights.editMode.emptyDescription")}
|
||||
ctaSize="lg"
|
||||
action={
|
||||
<Button size="sm" asChild>
|
||||
<Link href="/settings/layout/insights">
|
||||
{t("insights.customize")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
/* v1.15.11 W2 — the customizable region: sections render in the
|
||||
resolved layout order, skipping any the user has hidden. A
|
||||
layout-visible-but-gate-off section resolves to `null` from the
|
||||
registry, so the `space-y-6` rhythm closes the gap with no hole.
|
||||
Each registry node is wrapped in a keyed Fragment so React keeps a
|
||||
stable identity across a reorder. */
|
||||
orderedSectionIds.map((id) => (
|
||||
<Fragment key={id}>{SECTION_REGISTRY[id]}</Fragment>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+74
-599
@@ -1,611 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment, useCallback, type ReactNode } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { SlidersHorizontal, TrendingUp } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import {
|
||||
orderedVisibleSectionIds,
|
||||
type InsightsSectionId,
|
||||
} from "@/lib/insights-layout";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HeroStrip } from "@/components/insights/hero-strip";
|
||||
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
|
||||
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
|
||||
import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
|
||||
import { useDashboardDerived } from "@/components/insights/derived/use-dashboard-derived";
|
||||
// v1.4.41 W-ORG — shared shape lives in `src/types/analytics.ts` as
|
||||
// `InsightsAnalyticsData`; aliased back to the local name to keep the
|
||||
// rest of this file readable.
|
||||
import type { InsightsAnalyticsData as AnalyticsData } from "@/types/analytics";
|
||||
dehydrate,
|
||||
HydrationBoundary,
|
||||
QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import InsightsPageClient from "./page-client";
|
||||
|
||||
/**
|
||||
* v1.4.33 IW2 — defer the below-the-fold mother-page blocks behind
|
||||
* `next/dynamic`. `<HeroStrip>` (the only above-the-fold piece) stays an
|
||||
* eager import so the initial paint shows the greeting and health-score
|
||||
* badge without a flash; the briefing, correlation row and trends row
|
||||
* each carry their own icon-set + chart wiring (a chart card alone weighs
|
||||
* in at the lucide tree-shake limit) and used to land on every Insights
|
||||
* cold mount.
|
||||
* Thin RSC wrapper around the (client) Insights overview page.
|
||||
*
|
||||
* v1.11.3 — every loader fallback now routes through the shared
|
||||
* `BlockSkeleton` instead of a bespoke `h-[Xrem] animate-pulse` div. The
|
||||
* fixed guessed heights pinned each placeholder taller (or shorter) than
|
||||
* the resolved block, so the page jumped as each chunk landed. The shared
|
||||
* skeleton carries a `min-h` floor — enough to hold the row open at cold
|
||||
* mount — and lets the block grow into its true height without fighting a
|
||||
* hard-coded pixel guess, killing the resolve-time layout shift. Reduced
|
||||
* motion is honoured by the `Skeleton` primitive (`motion-reduce:animate-none`).
|
||||
* The Insights root fans out into ~6 mount queries after hydrate; the highest-
|
||||
* value, cheapest, and safest to lift onto the server is the dashboard snapshot
|
||||
* — the SAME `queryKeys.dashboardSnapshot(locale)` cell the dashboard warms
|
||||
* (the hero's Tension Verdict + return-to-baseline ride it). Reading it through
|
||||
* the shared `caches.analytics` SWR cell during SSR and handing it to TanStack
|
||||
* via `HydrationBoundary` means an Insights visit no longer re-pays the snapshot
|
||||
* round-trip on the client, and the hero's snapshot-derived band paints from the
|
||||
* first HTML instead of after a client fetch.
|
||||
*
|
||||
* Contract notes (the v1.30.9 dashboard template):
|
||||
* - THE CRUX — the snapshot cell is locale-keyed
|
||||
* (`queryKeys.dashboardSnapshot(locale)`). The locale is resolved ONCE
|
||||
* server-side by `readDashboardSnapshotCached` (via `resolveServerLocale`)
|
||||
* and the client's `useDashboardSnapshot` keys on the SAME resolved locale
|
||||
* from the i18n context — the identical mechanism the dashboard already
|
||||
* relies on, so the server-seeded key equals the client key by construction.
|
||||
* - The dehydrated VALUE is JSON-round-tripped so the hydrated shape is exactly
|
||||
* what the client `queryFn` produces from the wire (ISO date strings), never
|
||||
* a Date-carrying sibling that poisons the cell.
|
||||
* - The client snapshot cell reads `refetchOnMount: false`, so the seeded
|
||||
* fresh value lands and is used without an immediate refetch.
|
||||
* - The `["user","dashboardWidgets"]` layout the snapshot carries is seeded too
|
||||
* (mirroring the client `queryFn`'s own seed), removing the widgets stage
|
||||
* from the chart-overlay-prefs waterfall exactly as on the dashboard.
|
||||
* - Left CLIENT-LAZY on purpose: the AI daily briefing (`GET /api/insights/
|
||||
* generate`) — prefetching it must never trigger provider generation
|
||||
* (generation stays cron + explicit-button only), so it keeps its own lazy
|
||||
* read; and the heavy thick-analytics / comprehensive aggregations, whose
|
||||
* COLD SWR cell is a multi-query scan — prefetching them synchronously would
|
||||
* trade FCP for TTFB. Both stay on the client cells that own them.
|
||||
* - Fail-soft: no session or a builder hiccup renders the page exactly as
|
||||
* before this wrapper existed — the client cells own the fetch.
|
||||
*/
|
||||
function BlockSkeleton({
|
||||
minHeight,
|
||||
decorative = false,
|
||||
}: {
|
||||
/** Tailwind `min-h-*` utility holding the row open before the chunk lands. */
|
||||
minHeight: string;
|
||||
/** Decorative placeholders (cards that may un-mount) hide from a11y. */
|
||||
decorative?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Skeleton
|
||||
{...(decorative ? { "aria-hidden": "true" } : {})}
|
||||
className={cn("w-full rounded-xl", minHeight)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const DailyBriefing = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/daily-briefing").then((mod) => ({
|
||||
default: mod.DailyBriefing,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <BlockSkeleton minHeight="min-h-96" />,
|
||||
},
|
||||
);
|
||||
const TrendsRow = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/trends-row").then((mod) => ({
|
||||
default: mod.TrendsRow,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <BlockSkeleton minHeight="min-h-80" />,
|
||||
},
|
||||
);
|
||||
// v1.12.6 — the wellness-score strip, lifted OUT of the vitals dashboard so
|
||||
// it renders as its own full-width section directly above the daily briefing.
|
||||
// Both this strip and the vitals grid read the ONE shared derived batch the
|
||||
// page owns (passed in via `batch`), so the lift adds no second request.
|
||||
const WellnessScores = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/derived").then((mod) => ({
|
||||
default: mod.WellnessScores,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <BlockSkeleton minHeight="min-h-40" decorative />,
|
||||
},
|
||||
);
|
||||
// v1.10.0 — the Vitals dashboard (Apple-Health-Highlights grid of
|
||||
// personal-typical-range + passthrough-reframe tiles). Deferred behind
|
||||
// `next/dynamic` like the other below-the-fold blocks; each tile owns its
|
||||
// own derived-metric query and un-mounts when its vital is absent.
|
||||
const VitalsDashboard = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/derived").then((mod) => ({
|
||||
default: mod.VitalsDashboard,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <BlockSkeleton minHeight="min-h-80" />,
|
||||
},
|
||||
);
|
||||
// v1.10.0 — categorical events (WX-B). The device-flagged event awareness
|
||||
// timeline. Deferred like the other below-the-fold blocks; the card
|
||||
// un-mounts itself when the user has no such events (no skeleton-then-empty
|
||||
// flash — `ssr: false` + the card's own data gate), so it carries no
|
||||
// loading placeholder of its own.
|
||||
const RhythmEventsCard = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/rhythm-events-card").then((mod) => ({
|
||||
default: mod.RhythmEventsCard,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
// v1.28.50 — ECG recording surface (waveform-backed). Deferred like the
|
||||
// other below-the-fold blocks; the section un-mounts itself when the user
|
||||
// has no recordings (`ssr: false` + its own data gate), so it carries no
|
||||
// loading placeholder of its own.
|
||||
const EcgSection = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/ecg-section").then((mod) => ({
|
||||
default: mod.EcgSection,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
// v1.25 — baseline-drift card. Un-mounts when nothing is drifting; owns its own
|
||||
// read-only `/api/insights/health-status` query.
|
||||
const HealthStatusCard = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/health-status-card").then((mod) => ({
|
||||
default: mod.HealthStatusCard,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
// v1.25 — sleep-breathing screening card. Un-mounts on no data; screening
|
||||
// signal only, never a diagnosis.
|
||||
const BreathingScreeningCard = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/breathing-screening-card").then((mod) => ({
|
||||
default: mod.BreathingScreeningCard,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
// v1.25 — "what changed since your last lab panel" card. Un-mounts with fewer
|
||||
// than two panels or no shared analyte.
|
||||
const LabsChangesCard = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/labs-changes-card").then((mod) => ({
|
||||
default: mod.LabsChangesCard,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
// v1.10.3 — "Today's signal" headline card. Promotes COINCIDENT_DEVIATION from
|
||||
// a buried below-the-fold tile to the top-of-overview daily read (the
|
||||
// always-present Apple/WHOOP/Oura pattern). Deferred behind `next/dynamic`; it
|
||||
// owns its own derived-metric query and renders four calm states. The chunk
|
||||
// loader's skeleton shares the card's `min-h-48` footprint (and the in-card
|
||||
// `CardSkeleton` matches it too) so the top of the page does not shift across
|
||||
// loading → any resolved state. Decorative → `aria-hidden`.
|
||||
const CoincidentDeviationCard = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/coincident-deviation-card").then((mod) => ({
|
||||
default: mod.CoincidentDeviationCard,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <BlockSkeleton minHeight="min-h-48" decorative />,
|
||||
},
|
||||
);
|
||||
|
||||
// v1.11.0 — period-narrative card (Pillar P1). The calm "your week/month in
|
||||
// review" summary, drawn from the read-only stale-while-revalidate narrative
|
||||
// route. Deferred behind `next/dynamic` like the other below-the-hero blocks;
|
||||
// it owns its own query and un-mounts when no narrative exists. The loader
|
||||
// skeleton shares the card's `min-h-40` footprint so the page does not shift.
|
||||
const PeriodNarrativeCard = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/period-narrative-card").then((mod) => ({
|
||||
default: mod.PeriodNarrativeCard,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <BlockSkeleton minHeight="min-h-40" decorative />,
|
||||
},
|
||||
);
|
||||
|
||||
// v1.15.2 — the gated cycle-insights summary teaser. Mounted ONLY when
|
||||
// `user.cycleTrackingEnabled` is true (the same /api/auth/me signal the
|
||||
// sidebar nav entry gates on), so the cycle reads never fire for an account
|
||||
// without the feature. Deferred behind `next/dynamic` like the other
|
||||
// below-the-hero blocks; it owns its own calendar + insights reads and renders
|
||||
// nothing while resolving / on error, so it carries no loading placeholder.
|
||||
const CycleInsightSummaryCard = dynamic(
|
||||
() =>
|
||||
import("@/components/cycle/cycle-insight-summary-card").then((mod) => ({
|
||||
default: mod.CycleInsightSummaryCard,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
// v1.15.3 — the compact cycle RING, dropped into the wellness-score strip as a
|
||||
// gated sibling tile (NOT the summary teaser — that stays further down). Mounted
|
||||
// only for a cycle-tracking account, so its calendar read never fires otherwise.
|
||||
// Deferred behind `next/dynamic`; it renders nothing while resolving / on error /
|
||||
// when there is no active cycle, so it carries no loading placeholder.
|
||||
const CycleRingTile = dynamic(
|
||||
() =>
|
||||
import("@/components/cycle/cycle-ring-tile").then((mod) => ({
|
||||
default: mod.CycleRingTile,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
/**
|
||||
* v1.4.25 W4d — Insights mother page.
|
||||
*
|
||||
* The page used to be a single 1.8k-LOC monolith that scroll-anchored
|
||||
* six per-metric sections beneath the hero. W4a/c carved those out
|
||||
* into routed sub-pages under `/insights/{slug}`; this file now holds
|
||||
* the overview-only surface:
|
||||
*
|
||||
* - The sticky tab strip lives in `src/app/insights/layout.tsx`
|
||||
* (the shared `<InsightsLayoutShell>` mounts it). The strip handles
|
||||
* navigation to every sub-page + the regenerate affordance.
|
||||
* - Hero + DailyBriefing + Trends row + advisor card stay here —
|
||||
* they're the cross-metric overview. The per-metric correlation
|
||||
* cards moved onto the metric pages they belong to (Weight owns
|
||||
* weight × weekday, Pulse owns mood × pulse, …), so the overview
|
||||
* no longer renders a duplicate correlation row.
|
||||
* - The CoachDrawer is mounted in the mother-page body only (the maintainer
|
||||
* directive). Navigating to a sub-page unmounts the drawer.
|
||||
*
|
||||
* The per-section status cards (BP/Weight/Pulse/etc.) and their
|
||||
* heavy chart wiring moved to the matching sub-pages.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The mother page only checks whether the comprehensive payload arrived
|
||||
* (the EmptyState gates on `!data`); the metric-specific shape lives on
|
||||
* the sub-pages now. Keep this slim — anything more is dead weight here.
|
||||
*/
|
||||
interface ComprehensiveData {
|
||||
totalMeasurements: number;
|
||||
}
|
||||
|
||||
export default function InsightsPage() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const mounted = useMounted();
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// v1.30.1 M12 — the overview fans out into ~6 independent queries
|
||||
// (comprehensive / advisor / analytics / snapshot / derived batch /
|
||||
// layout) mounted by different hooks below, so there's no single
|
||||
// `refetch()` that covers the page. Refetching every currently-mounted
|
||||
// ("active") query is the same blanket approach the audit's fallback
|
||||
// suggests for PWA-resume staleness, and it's the right shape here
|
||||
// regardless of resume vs. an explicit pull.
|
||||
const refreshInsights = useCallback(
|
||||
() => queryClient.refetchQueries({ type: "active" }),
|
||||
[queryClient],
|
||||
);
|
||||
const pull = usePullToRefresh({ onRefresh: refreshInsights });
|
||||
|
||||
// v1.4.33 IW9 — scroll-to-top on route mount centralised in the
|
||||
// shared `useScrollResetOnRoute()` hook. The mother page + the
|
||||
// `<SubPageShell>` both consume the same hook; the legacy duplicate
|
||||
// RAF that lived here pre-v1.4.33 produced a visible double-snap on
|
||||
// slow hydrates (chart skeletons inflating between the two
|
||||
// callbacks).
|
||||
useScrollResetOnRoute();
|
||||
|
||||
// v1.4.27 R3d MB4 — Coach drawer state lives in the layout-level
|
||||
// `<CoachLaunchProvider>`; the drawer is mounted next to the provider in
|
||||
// `src/app/insights/layout.tsx`. v1.18.7 removed the hero-band coach
|
||||
// entry points (the action button + suggested-prompt chips), so the
|
||||
// overview page no longer reads the launch context — the drawer + its
|
||||
// own launcher stay untouched.
|
||||
const flags = useFeatureFlags();
|
||||
|
||||
// v1.4.36 W1 — drop the page-level `isLoading` gate that used to
|
||||
// block the entire shell on `/api/insights/comprehensive`. The
|
||||
// comprehensive payload still feeds the empty-state decision but
|
||||
// every other section now mounts in parallel under its own
|
||||
// <Suspense> boundary, so the user sees the hero + tile skeletons
|
||||
// within ~500 ms instead of waiting on the slowest fan-out. The
|
||||
// empty-state branch only fires once the query has resolved AND
|
||||
// reported zero measurements; while it's in-flight the page paints
|
||||
// the regular shell and the tiles fill in as their data lands.
|
||||
const { data, isLoading, isFetched, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.insightsComprehensive(),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await apiGet<ComprehensiveData>("/api/insights/comprehensive");
|
||||
} catch {
|
||||
throw new Error(t("insights.loadError"));
|
||||
}
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
// The advisor query is also mounted by the layout shell; the page
|
||||
// consumer re-reads from the same cache key so this call is free
|
||||
// beyond the React-state subscription.
|
||||
const advisor = useInsightsAdvisorQuery(isAuthenticated);
|
||||
|
||||
// v1.4.33 IW2 — the mother page reads `healthScore` (a thick-only
|
||||
// field) for the hero score card, so it stays on the default thick
|
||||
// slice. The shared hook still centralises the cache settings so the
|
||||
// consumer dedups with the sub-page mounts that ride the slim slice
|
||||
// instead. Correlations now live on the per-metric pages, so the
|
||||
// overview no longer reads `analytics.correlations`.
|
||||
const analyticsQuery = useAnalyticsQuery();
|
||||
const analytics = analyticsQuery.data as AnalyticsData | undefined;
|
||||
|
||||
// v1.21.2 (A5 / A6) — the dashboard snapshot is the single server source
|
||||
// for the ambient narrative the hero surfaces: the Tension Verdict and
|
||||
// return-to-baseline ride its `healthScore`. The hero score number itself
|
||||
// stays on the analytics payload (unchanged). Reads the same shared
|
||||
// `queryKeys.dashboardSnapshot()` cell the dashboard warms, so the insights
|
||||
// visit pays at most one extra round-trip and usually a warm cache hit.
|
||||
// (The A4 recall/forward-look block left the briefing card — the card now
|
||||
// opens straight on the day's signals.)
|
||||
const snapshotQuery = useDashboardSnapshot(isAuthenticated);
|
||||
const snapshotHealthScore = snapshotQuery.data?.healthScore ?? null;
|
||||
const heroTension = snapshotHealthScore?.tension ?? null;
|
||||
const heroReturnToBand = snapshotHealthScore?.returnToBand ?? null;
|
||||
|
||||
// v1.12.6 — the page owns the ONE overview derived batch. The wellness
|
||||
// strip (above the briefing) and the vitals grid (below it) both read this
|
||||
// single query instance, so the wellness lift adds no second request.
|
||||
const dashboardDerived = useDashboardDerived(isAuthenticated);
|
||||
|
||||
// v1.15.11 W2 — the resolved overview layout (sections + tiles). Drives
|
||||
// both the section render order/visibility below and the Vitals grid's
|
||||
// per-tile order/visibility (passed into <VitalsDashboard>). Defaults to
|
||||
// the canonical layout while in-flight so the first paint matches the
|
||||
// default order with no flicker.
|
||||
// v1.15.18 — the overview layout (sections + tiles) still drives the section
|
||||
// render order/visibility + the Vitals grid below. CUSTOMISING it (and the
|
||||
// nav-pill order) moved out of an in-page "Anpassen" edit mode into
|
||||
// Settings → Insights, reached via the top-right cog on the tab strip.
|
||||
const { layout } = useInsightsLayoutQuery(isAuthenticated);
|
||||
|
||||
// Error branch — a transient 500 / network drop (after the query's
|
||||
// retries are exhausted) settles the comprehensive query with no data.
|
||||
// Without this gate the page would fall through to the "no data yet —
|
||||
// add a measurement" empty-state, which reads false for a user with
|
||||
// history. Surface an error + a Retry that refetches the one query,
|
||||
// mirroring the <VitalsDashboard> error pattern.
|
||||
if (isError) {
|
||||
return (
|
||||
<div data-slot="insights-overview-error">
|
||||
<QueryErrorCard
|
||||
description={t("insights.loadError")}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
export default async function InsightsPage() {
|
||||
// Global SSR-prefetch kill-switch shared with the dashboard wrapper. The e2e
|
||||
// server sets `DASHBOARD_SSR_PREFETCH=false` so Playwright route mocks — which
|
||||
// only see CLIENT fetches — keep governing what every prefetched page paints.
|
||||
if (process.env.DASHBOARD_SSR_PREFETCH === "false") {
|
||||
return <InsightsPageClient />;
|
||||
}
|
||||
|
||||
// Empty-state shortcut — only paint once the comprehensive query has
|
||||
// resolved AND reported zero measurements. While it's in-flight we
|
||||
// fall through to the streamed shell so the user gets the hero +
|
||||
// skeleton tiles inside the first paint budget.
|
||||
if (!isLoading && isFetched && !data) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<TrendingUp className="size-6" />}
|
||||
title={t("insights.emptyTitle")}
|
||||
description={t("insights.emptyDescription")}
|
||||
ctaSize="lg"
|
||||
action={
|
||||
<Button size="sm" asChild>
|
||||
<Link href="/measurements">
|
||||
{t("insights.emptyAddMeasurement")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
let dehydratedState = null;
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (session) {
|
||||
const { user } = session;
|
||||
const { body, locale } = await readDashboardSnapshotCached(user);
|
||||
// Match the client cell's wire shape exactly (JSON semantics, ISO date
|
||||
// strings) — same-key-different-shape is silent cache poison.
|
||||
const wireBody = JSON.parse(JSON.stringify(body)) as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const queryClient = new QueryClient();
|
||||
queryClient.setQueryData(queryKeys.dashboardSnapshot(locale), wireBody);
|
||||
queryClient.setQueryData(queryKeys.dashboardWidgets(), wireBody.layout);
|
||||
dehydratedState = dehydrate(queryClient);
|
||||
}
|
||||
} catch {
|
||||
// Prefetch is an accelerator, never a gate — the client path stands.
|
||||
}
|
||||
|
||||
// Gated on `mounted`: the auth query can resolve before this
|
||||
// boundary hydrates, and a greeting that personalises during the
|
||||
// hydration render disagrees with the name-less SSR text — React
|
||||
// #418. See `useMounted`.
|
||||
const heroGreetingName =
|
||||
mounted && user?.username?.trim() && user.username.trim().length > 0
|
||||
? user.username.split(/\s+/)[0]
|
||||
: null;
|
||||
const briefingPayload = advisor.payload?.dailyBriefing ?? null;
|
||||
const heroStripUpdatedAt = advisor.payload?.cachedAt ?? null;
|
||||
|
||||
// v1.4.36 QA C2 — no `<Suspense>` wrappers below. The mother page is
|
||||
// `"use client"`, the below-the-fold blocks load via `next/dynamic`
|
||||
// with `{ ssr: false }` (no Promise-throw on hydrate), and the
|
||||
// TanStack Query hooks each return their own loading state without
|
||||
// ever throwing a thenable. Wrapping these in `<Suspense>` would be
|
||||
// dead code — Suspense never engages and the `loading` props inside
|
||||
// each section already drive the skeleton. The perceptual win we
|
||||
// ship is "early-skeleton paint": the page-level `isLoading` gate is
|
||||
// gone, the hero + each section's own loader skeleton paints inside
|
||||
// the first paint budget while data fills in. Genuinely streamed
|
||||
// server children would require a Server-Component refactor; that's
|
||||
// a v1.5.x track.
|
||||
|
||||
// v1.12.6 — overview section order, top → bottom:
|
||||
// 1. "Guten Morgen" Hero + Coach questions (HeroStrip).
|
||||
// 2. Wellnesswerte — the wellness-score strip (lifted OUT of
|
||||
// <VitalsDashboard> so it sits ABOVE the briefing as its own
|
||||
// full-width section).
|
||||
// 3. "Heute auf einen Blick" briefing (DailyBriefing).
|
||||
// 4. Vitalwerte — the vitals grid (+ mobility) inside <VitalsDashboard>,
|
||||
// which no longer renders the wellness strip.
|
||||
// 5. Trends (TrendsRow).
|
||||
// 6. "Dein Zeitraum im Rückblick" retrospective (PeriodNarrativeCard).
|
||||
// 7. "Signale des Tages" — today's signal (CoincidentDeviationCard) plus
|
||||
// the rhythm-events alert timeline, kept out of the AI gate so a
|
||||
// health alert is never hidden.
|
||||
// The wellness strip and the vitals grid share the ONE `dashboardDerived`
|
||||
// batch the page owns — one request, two sections. The per-metric
|
||||
// correlation cards moved onto the metric pages, so the overview renders no
|
||||
// correlation row. The duplicate footer "prepare assessments" control was
|
||||
// removed in v1.12.4 — the tab-strip regenerate button is the single
|
||||
// affordance. The generic disclaimer lives once in the layout-shell footer.
|
||||
|
||||
// v1.15.11 W2 — decouple the section render order from the JSX. Each section
|
||||
// id maps to its existing node here; the customizable region below renders
|
||||
// `orderedVisibleSectionIds(layout)` against this registry. Every existing
|
||||
// feature/data gate is preserved INSIDE each entry: a section the layout
|
||||
// marks visible but whose gate is off (briefing flag, cycle-enabled) still
|
||||
// resolves to `null`, exactly as today. With the default layout the order +
|
||||
// gates reproduce the pre-v1.15.11 page byte-for-byte. HeroStrip stays
|
||||
// anchored above this region, OUTSIDE the customizable set.
|
||||
const SECTION_REGISTRY: Record<InsightsSectionId, ReactNode> = {
|
||||
"wellness-scores": (
|
||||
<WellnessScores
|
||||
read={dashboardDerived.read}
|
||||
isLoading={dashboardDerived.isLoading}
|
||||
isError={dashboardDerived.isError}
|
||||
refetch={dashboardDerived.refetch}
|
||||
// v1.15.3 — the cycle ring rides the scores strip as a gated sibling
|
||||
// tile, only for a cycle-tracking account, so its calendar read never
|
||||
// fires otherwise (the same `/api/auth/me` gate the sidebar nav uses).
|
||||
// v1.18.0 — gate on the resolved `modules.cycle` flag (per-user toggle
|
||||
// AND the operator server-wide kill-switch) so an operator-off
|
||||
// instance never renders the ring nor fires its calendar read.
|
||||
extraTile={
|
||||
user?.modules?.cycle === true ? <CycleRingTile /> : undefined
|
||||
}
|
||||
// v1.15.5 — when the cycle ring is shown it TAKES the Strain slot:
|
||||
// hide Strain so the strip stays compact instead of growing a sixth
|
||||
// tile. Strain stays visible for non-cycle accounts.
|
||||
hideStrain={user?.modules?.cycle === true}
|
||||
/>
|
||||
),
|
||||
"daily-briefing": flags.briefing ? (
|
||||
<DailyBriefing
|
||||
briefing={briefingPayload}
|
||||
updatedAt={heroStripUpdatedAt}
|
||||
loading={advisor.isLoading}
|
||||
onRegenerate={advisor.regenerate}
|
||||
regenerating={advisor.isRegenerating}
|
||||
// v1.15.20 — the read path reports a 422 (no provider configured)
|
||||
// as its own outcome; swap the futile regenerate CTA for a quiet
|
||||
// Settings → AI hint instead of an eternal "preparing" loop.
|
||||
noProvider={advisor.readOutcome === "no-provider"}
|
||||
// v1.18.9 (#4) — a (stale) cached briefing is shown but no provider
|
||||
// is connected, so it can never refresh. Pair the relative-age
|
||||
// footer with a discreet connect-provider hint. Only fires when a
|
||||
// briefing is actually rendered (the empty-state path owns the
|
||||
// `noProvider` branch above).
|
||||
noProviderStale={briefingPayload !== null && !advisor.hasProvider}
|
||||
// v1.25 — the last generation attempt failed. On a held briefing this
|
||||
// adds a discreet "couldn't refresh — retry" footer hint; on an empty
|
||||
// card it swaps the generic empty state for an honest "couldn't
|
||||
// generate" one. Suppressed when no provider is configured (that hint
|
||||
// owns the surface and a retry would be futile).
|
||||
generationFailed={advisor.generationFailed && advisor.hasProvider}
|
||||
// v1.25.3 — failure class points the empty-state hint at the right
|
||||
// lever (raise the response timeout vs re-check the provider).
|
||||
generationFailureClass={advisor.generationFailureClass}
|
||||
// v1.28.28 (#470) — the grounding gate stripped the last regenerated
|
||||
// briefing; the card explains the omission instead of "no briefing
|
||||
// yet" (which made the regenerate button read as doing nothing).
|
||||
omittedReason={advisor.briefingOmittedReason}
|
||||
/>
|
||||
) : null,
|
||||
vitals: <VitalsDashboard batch={dashboardDerived} layout={layout} />,
|
||||
trends: (
|
||||
<TrendsRow
|
||||
briefing={briefingPayload}
|
||||
annotations={advisor.payload?.trendAnnotations ?? null}
|
||||
loading={advisor.isLoading || advisor.isRegenerating}
|
||||
/>
|
||||
),
|
||||
"period-review": flags.briefing ? (
|
||||
<PeriodNarrativeCard enabled={isAuthenticated} />
|
||||
) : null,
|
||||
// v1.15.2 — gated cycle teaser. Render only for a cycle-tracking account;
|
||||
// for everyone else this is nothing (no card, no layout gap). The card
|
||||
// itself stays silent until its reads resolve.
|
||||
"cycle-summary": user?.cycleTrackingEnabled ? (
|
||||
<CycleInsightSummaryCard />
|
||||
) : null,
|
||||
signals: <CoincidentDeviationCard enabled={isAuthenticated} />,
|
||||
"rhythm-events": <RhythmEventsCard enabled={isAuthenticated} />,
|
||||
"health-status": <HealthStatusCard enabled={isAuthenticated} />,
|
||||
breathing: <BreathingScreeningCard enabled={isAuthenticated} />,
|
||||
"labs-changes": <LabsChangesCard enabled={isAuthenticated} />,
|
||||
ecg: <EcgSection enabled={isAuthenticated} />,
|
||||
};
|
||||
|
||||
const orderedSectionIds = orderedVisibleSectionIds(layout);
|
||||
const everySectionHidden = orderedSectionIds.length === 0;
|
||||
|
||||
if (dehydratedState === null) {
|
||||
return <InsightsPageClient />;
|
||||
}
|
||||
return (
|
||||
// v1.12.7 (L3) — one consistent vertical rhythm down the overview. The
|
||||
// page used `space-y-8` (32 px) between top-level blocks while the vitals
|
||||
// wrap used `space-y-6` (24 px), so the overview read as two tiers. Unify
|
||||
// to `space-y-6` — it matches the vitals wrap and tightens the overview in
|
||||
// line with the "Insights gives away too much space" direction.
|
||||
//
|
||||
// v1.15.10 — `space-y-6` is now the SINGLE inter-section gap, top to
|
||||
// bottom. Every section renders as a real `<section>` (or `null` — never a
|
||||
// `display: contents` wrapper, which used to collapse the cycle-summary box
|
||||
// so `space-y` skipped its margin and the cycle→signals seam read as
|
||||
// zero-gap). Each section owns its OWN `SectionHeading` + `space-y-3` to its
|
||||
// card, so the rhythm is even regardless of which sections are present.
|
||||
<div className="space-y-6">
|
||||
<PullToRefreshIndicator {...pull} />
|
||||
<HeroStrip
|
||||
briefing={briefingPayload}
|
||||
updatedAt={heroStripUpdatedAt}
|
||||
userName={heroGreetingName}
|
||||
healthScore={analytics?.healthScore ?? undefined}
|
||||
// v1.16.8 — reserve the score card's column while the thick
|
||||
// analytics payload is in flight. Every overview query
|
||||
// (comprehensive, advisor, analytics, derived batch, layout)
|
||||
// already fires in parallel at mount; the "greeting first,
|
||||
// score card later" stagger came from the right column not
|
||||
// existing until the slowest payload resolved. `isPending`
|
||||
// settles false on success AND error, so a no-score account
|
||||
// collapses the column exactly once.
|
||||
healthScorePending={analyticsQuery.isPending && !analytics}
|
||||
// v1.18.9 (#4) — the hero subtitle is the cached briefing paragraph;
|
||||
// when it can never refresh (no AI provider) pair the "Generated
|
||||
// <relative>" line with a discreet connect-provider hint, matching
|
||||
// the briefing card footer. Only when a briefing is actually shown.
|
||||
noProviderStale={briefingPayload !== null && !advisor.hasProvider}
|
||||
// v1.21.2 (A5 / A6) — server-resolved Tension Verdict + return-to-
|
||||
// baseline off the dashboard snapshot. The hero localises the keys and
|
||||
// forwards them to the score card; null leaves the card quiet.
|
||||
tension={heroTension}
|
||||
returnToBand={heroReturnToBand}
|
||||
/>
|
||||
|
||||
{/* v1.15.18 — the inline "Anpassen" toggle was removed. Customising the
|
||||
overview (section show/hide + order) and sorting the nav pills now
|
||||
live ONLY in Settings → Insights, reached via the top-right cog on the
|
||||
tab strip — no duplicate in-page entry point. */}
|
||||
{everySectionHidden ? (
|
||||
/* v1.15.18 — empty-state: every section hidden. The page is never
|
||||
blank — surface a hint + a link to the Insights settings section so
|
||||
the user can bring sections back. */
|
||||
<EmptyState
|
||||
icon={<SlidersHorizontal className="size-6" />}
|
||||
title={t("insights.editMode.emptyTitle")}
|
||||
description={t("insights.editMode.emptyDescription")}
|
||||
ctaSize="lg"
|
||||
action={
|
||||
<Button size="sm" asChild>
|
||||
<Link href="/settings/layout/insights">
|
||||
{t("insights.customize")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
/* v1.15.11 W2 — the customizable region: sections render in the
|
||||
resolved layout order, skipping any the user has hidden. A
|
||||
layout-visible-but-gate-off section resolves to `null` from the
|
||||
registry, so the `space-y-6` rhythm closes the gap with no hole.
|
||||
Each registry node is wrapped in a keyed Fragment so React keeps a
|
||||
stable identity across a reorder. */
|
||||
orderedSectionIds.map((id) => (
|
||||
<Fragment key={id}>{SECTION_REGISTRY[id]}</Fragment>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<HydrationBoundary state={dehydratedState}>
|
||||
<InsightsPageClient />
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
@@ -11,22 +11,34 @@ import { BackLink } from "@/components/ui/back-link";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import {
|
||||
WorkoutDetailHeader,
|
||||
WorkoutDetailHRChart,
|
||||
WorkoutDetailRoute,
|
||||
WorkoutDetailStats,
|
||||
WorkoutDetailHrSection,
|
||||
WorkoutDetailZones,
|
||||
WorkoutDetailRoute,
|
||||
WorkoutDetailSplits,
|
||||
WorkoutDetailDayLinks,
|
||||
WorkoutInsightCard,
|
||||
} from "@/components/insights/workout-detail";
|
||||
|
||||
/**
|
||||
* v1.4.32 — `/insights/workouts/[id]`.
|
||||
* `/insights/workouts/[id]` — workout detail surface.
|
||||
*
|
||||
* Workout detail surface. Mounts the header + stats + optional GPS
|
||||
* route + heart-rate chart slot. The route data flows from
|
||||
* `GET /api/workouts/{id}` (v1.4.32) and includes the cross-source
|
||||
* canonical-id pointer so a deep-link into a non-canonical twin
|
||||
* surfaces a graceful redirect cue (handled inline on the header for
|
||||
* v1.4.32; iOS-side redirect arrives in v1.5).
|
||||
* Layout, top to bottom (mobile-first single column):
|
||||
* hero header → reserved Activity-Insight seam (renders nothing today)
|
||||
* → stats grid + sport-average line → HR curve → effort zones → GPS
|
||||
* route → per-km splits → "that day" cross-links → coach launch.
|
||||
*
|
||||
* Every data-less section returns `null` (hide, don't render empty), so
|
||||
* an aggregates-only workout (a Strava ride with no wearable, a manual
|
||||
* entry) reads as hero + stats + "that day" + coach — compact but
|
||||
* honest, no empty shells.
|
||||
*
|
||||
* Data flows from `GET /api/workouts/{id}?compact=1` (v1.4.32 + the #67
|
||||
* enrichment fields). The `canonicalId` pointer still resolves a
|
||||
* deep-link into a non-canonical twin; the header carries the source.
|
||||
*/
|
||||
export default function InsightsWorkoutDetailPage({
|
||||
params,
|
||||
@@ -36,7 +48,7 @@ export default function InsightsWorkoutDetailPage({
|
||||
const { t } = useTranslations();
|
||||
const { id } = use(params);
|
||||
const { ready } = useModulePageGuard("workouts");
|
||||
const { data, isLoading, error } = useWorkoutDetail(id);
|
||||
const { data, isLoading, error, refetch } = useWorkoutDetail(id);
|
||||
|
||||
// v1.18.0 B1 — bounce a direct URL hit on a disabled-workouts account.
|
||||
if (!ready) {
|
||||
@@ -63,9 +75,13 @@ export default function InsightsWorkoutDetailPage({
|
||||
<div data-slot="workout-detail-loading" className="space-y-3">
|
||||
<Skeleton className="h-20 w-full rounded-lg" />
|
||||
<Skeleton className="h-40 w-full rounded-lg" />
|
||||
<Skeleton className="h-56 w-full rounded-lg" />
|
||||
<Skeleton className="h-60 w-full rounded-lg" />
|
||||
</div>
|
||||
) : error || !data ? (
|
||||
) : error ? (
|
||||
// A failed query must never read as "no data" (UI-STANDARDS §6).
|
||||
<QueryErrorCard onRetry={() => refetch()} />
|
||||
) : !data ? (
|
||||
<p
|
||||
data-slot="workout-detail-error"
|
||||
className="text-muted-foreground text-sm"
|
||||
@@ -75,13 +91,20 @@ export default function InsightsWorkoutDetailPage({
|
||||
) : (
|
||||
<>
|
||||
<WorkoutDetailHeader workout={data} />
|
||||
{/* Reserved Activity-Insight seam — `aiInsight` is always null
|
||||
today, so this renders nothing. When the Phase-2 job
|
||||
populates it, the card mounts here with zero layout rework. */}
|
||||
{data.aiInsight ? (
|
||||
<WorkoutInsightCard insight={data.aiInsight} />
|
||||
) : null}
|
||||
<WorkoutDetailStats workout={data} />
|
||||
<WorkoutDetailHrSection workout={data} />
|
||||
<WorkoutDetailZones workout={data} />
|
||||
<WorkoutDetailRoute workout={data} />
|
||||
<WorkoutDetailHRChart workout={data} />
|
||||
{/* 2026-07-17 UX-flows audit F6-1 — the button used to open an
|
||||
unscoped chat; the user had to restate which session they
|
||||
meant. `workouts` narrows the snapshot the first turn reads
|
||||
(pairs with the coach-scope param added for F4-1). */}
|
||||
<WorkoutDetailSplits workout={data} />
|
||||
<WorkoutDetailDayLinks workout={data} />
|
||||
{/* 2026-07-17 UX-flows audit F6-1 — `workouts` narrows the
|
||||
snapshot the first coach turn reads. */}
|
||||
<CoachLaunchButton scope={{ metric: "workouts" }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { HydrationBoundary, hashKey } from "@tanstack/react-query";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* The `/insights/workouts` server-prefetch key crux + fail-soft.
|
||||
*
|
||||
* The RSC wrapper (`src/app/insights/workouts/page.tsx`) dehydrates the
|
||||
* workouts list so the client `useWorkouts({ limit: 100 })` cell reads it back
|
||||
* on mount instead of flashing the loading skeleton. These tests pin the
|
||||
* load-bearing rules:
|
||||
* - the server dehydrates under the EXACT key the hook builds (byte-identical
|
||||
* hash) — the hook passes `{ limit, offset, since, sportType }` with the
|
||||
* last three `undefined`, so the seeded key must carry the same shape;
|
||||
* - the value is JSON-round-tripped to the wire shape (Dates → ISO strings);
|
||||
* - any prefetch error / a disabled module / no session fails soft to the bare
|
||||
* client path (the client cell owns the fetch).
|
||||
*/
|
||||
|
||||
const getSession = vi.fn();
|
||||
const resolveModuleMap = vi.fn();
|
||||
const readWorkoutsListCached = vi.fn();
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: () => getSession() }));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
resolveModuleMap: (id: string) => resolveModuleMap(id),
|
||||
}));
|
||||
vi.mock("@/lib/workouts/list-read", () => ({
|
||||
readWorkoutsListCached: (u: unknown, p: unknown) =>
|
||||
readWorkoutsListCached(u, p),
|
||||
}));
|
||||
vi.mock("../page-client", () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
import InsightsWorkoutsPage from "../page";
|
||||
|
||||
const SESSION = { user: { id: "u1", timezone: "Europe/Berlin" } };
|
||||
|
||||
const LIST = {
|
||||
workouts: [{ id: "w1", sportType: "Running" }],
|
||||
meta: { total: 1, limit: 100, offset: 0, droppedDuplicates: 0 },
|
||||
};
|
||||
|
||||
/**
|
||||
* The key EXACTLY as `useWorkouts({ limit: 100 })` builds it — the hook spreads
|
||||
* its options object through, so `offset` / `since` / `sportType` arrive as
|
||||
* `undefined` rather than being omitted.
|
||||
*/
|
||||
const CLIENT_KEY = queryKeys.workoutsRecentList({
|
||||
limit: 100,
|
||||
offset: undefined,
|
||||
since: undefined,
|
||||
sportType: undefined,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env.DASHBOARD_SSR_PREFETCH;
|
||||
});
|
||||
|
||||
/** The single dehydrated query on a HydrationBoundary element, or null. */
|
||||
function dehydratedQuery(
|
||||
el: ReactElement,
|
||||
): { queryHash: string; state: { data: unknown } } | null {
|
||||
if (el.type !== HydrationBoundary) return null;
|
||||
const props = el.props as {
|
||||
state?: { queries: { queryHash: string; state: { data: unknown } }[] };
|
||||
};
|
||||
const q = props.state?.queries?.[0];
|
||||
return q ? { queryHash: q.queryHash, state: q.state } : null;
|
||||
}
|
||||
|
||||
describe("/insights/workouts RSC prefetch", () => {
|
||||
it("dehydrates the list under the EXACT client key (byte-identical hash)", async () => {
|
||||
getSession.mockResolvedValue(SESSION);
|
||||
resolveModuleMap.mockResolvedValue({ workouts: true });
|
||||
readWorkoutsListCached.mockResolvedValue(LIST);
|
||||
|
||||
const el = (await InsightsWorkoutsPage()) as ReactElement;
|
||||
const q = dehydratedQuery(el);
|
||||
expect(q).not.toBeNull();
|
||||
// The client cell looks this key up; a drift here silently no-ops the
|
||||
// prefetch and the skeleton flash comes back.
|
||||
expect(q!.queryHash).toBe(hashKey(CLIENT_KEY));
|
||||
expect(q!.state.data).toEqual(LIST);
|
||||
});
|
||||
|
||||
it("reads the same filter the client's `limit: 100` request would", async () => {
|
||||
getSession.mockResolvedValue(SESSION);
|
||||
resolveModuleMap.mockResolvedValue({ workouts: true });
|
||||
readWorkoutsListCached.mockResolvedValue(LIST);
|
||||
|
||||
await InsightsWorkoutsPage();
|
||||
// All-null filter params → the same `userId|||` projection cache slot the
|
||||
// `?limit=100` API request builds, so the two callers share one dedup pass.
|
||||
expect(readWorkoutsListCached).toHaveBeenCalledWith("u1", {
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
since: null,
|
||||
until: null,
|
||||
sportType: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("JSON-round-trips the value to the wire shape (Dates → ISO strings)", async () => {
|
||||
getSession.mockResolvedValue(SESSION);
|
||||
resolveModuleMap.mockResolvedValue({ workouts: true });
|
||||
const startedAt = new Date("2026-07-18T08:30:00.000Z");
|
||||
readWorkoutsListCached.mockResolvedValue({
|
||||
...LIST,
|
||||
workouts: [{ id: "w1", startedAt }],
|
||||
});
|
||||
|
||||
const el = (await InsightsWorkoutsPage()) as ReactElement;
|
||||
const q = dehydratedQuery(el);
|
||||
// A Date must land as its ISO string — the shape the client's
|
||||
// (await res.json()).data would carry — never a live Date object.
|
||||
expect(
|
||||
(q!.state.data as { workouts: { startedAt: unknown }[] }).workouts[0]
|
||||
.startedAt,
|
||||
).toBe(startedAt.toISOString());
|
||||
});
|
||||
|
||||
it("fails soft to the bare client when the read throws", async () => {
|
||||
getSession.mockResolvedValue(SESSION);
|
||||
resolveModuleMap.mockResolvedValue({ workouts: true });
|
||||
readWorkoutsListCached.mockRejectedValue(new Error("db blip"));
|
||||
|
||||
const el = (await InsightsWorkoutsPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
});
|
||||
|
||||
it("skips the prefetch when the workouts module is off", async () => {
|
||||
getSession.mockResolvedValue(SESSION);
|
||||
resolveModuleMap.mockResolvedValue({ workouts: false });
|
||||
|
||||
const el = (await InsightsWorkoutsPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(readWorkoutsListCached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails soft when there is no session", async () => {
|
||||
getSession.mockResolvedValue(null);
|
||||
const el = (await InsightsWorkoutsPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(readWorkoutsListCached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("honours the DASHBOARD_SSR_PREFETCH kill-switch (no session read)", async () => {
|
||||
process.env.DASHBOARD_SSR_PREFETCH = "false";
|
||||
const el = (await InsightsWorkoutsPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(getSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { Activity, Loader2 } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useWorkouts } from "@/hooks/use-workouts";
|
||||
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { WorkoutList } from "@/components/insights/workout-list";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* v1.4.32 — `/insights/workouts`.
|
||||
*
|
||||
* Workouts list sub-page. Pairs with the iOS HKWorkout ingest path
|
||||
* (`POST /api/workouts/batch`) and the v1.4.30 cross-source picker so
|
||||
* twin workouts from Apple Watch + Withings ScanWatch collapse to a
|
||||
* single row. The page is the user-visible surface for the workout
|
||||
* data the iOS client already syncs.
|
||||
*
|
||||
* v1.30.14 — the `page.tsx` RSC wrapper server-prefetches this list's
|
||||
* `useWorkouts({ limit: 100 })` cell so the first paint shows the rows
|
||||
* instead of the loading skeleton (the "Sport lädt langsam" report).
|
||||
* The `useWorkouts` cell keeps its own fetch — the prefetch only warms
|
||||
* the cache; a background refresh still runs when the data goes stale.
|
||||
*
|
||||
* Renders three states:
|
||||
* - loading shell while the workouts query resolves,
|
||||
* - empty state when the user has no workouts yet (CTA points at
|
||||
* the iOS / Apple Health onboarding cue — there is no manual
|
||||
* workout-entry form today),
|
||||
* - the deduped list otherwise. Each row links to
|
||||
* `/insights/workouts/[id]` for the detail surface.
|
||||
*/
|
||||
export default function InsightsWorkoutsPageClient() {
|
||||
const { t } = useTranslations();
|
||||
const { ready } = useModulePageGuard("workouts");
|
||||
const { data, isLoading, isEmpty } = useWorkouts({ limit: 100 });
|
||||
|
||||
// v1.18.0 B1 — bounce a direct URL hit on a disabled-workouts account.
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("insights.workouts.title")}
|
||||
description={t("insights.workouts.description")}
|
||||
explainerMetric="workouts"
|
||||
coachLaunch
|
||||
>
|
||||
{/* No `<MetricRangeControls>` here: workouts are session records, not a
|
||||
MeasurementType series, so the period-over-period range read has
|
||||
nothing to aggregate. */}
|
||||
{isLoading ? (
|
||||
<div data-slot="workouts-loading" className="space-y-2">
|
||||
<Skeleton className="h-14 w-full rounded-lg" />
|
||||
<Skeleton className="h-14 w-full rounded-lg" />
|
||||
<Skeleton className="h-14 w-full rounded-lg" />
|
||||
</div>
|
||||
) : isEmpty ? (
|
||||
<MetricEmptyState
|
||||
icon={<Activity className="size-6" />}
|
||||
title={t("insights.workouts.emptyState.title")}
|
||||
description={t("insights.workouts.emptyState.description")}
|
||||
cta={null}
|
||||
coachPrefill="I haven't logged any workouts yet — why does tracking them matter, and what should I focus on first?"
|
||||
/>
|
||||
) : data ? (
|
||||
<WorkoutList workouts={data.workouts} />
|
||||
) : null}
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +1,103 @@
|
||||
"use client";
|
||||
import {
|
||||
dehydrate,
|
||||
HydrationBoundary,
|
||||
QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { Activity, Loader2 } from "lucide-react";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { readWorkoutsListCached } from "@/lib/workouts/list-read";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useWorkouts } from "@/hooks/use-workouts";
|
||||
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { WorkoutList } from "@/components/insights/workout-list";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import InsightsWorkoutsPageClient from "./page-client";
|
||||
|
||||
/**
|
||||
* v1.4.32 — `/insights/workouts`.
|
||||
* Thin RSC wrapper around the (client) workouts list page.
|
||||
*
|
||||
* Workouts list sub-page. Pairs with the iOS HKWorkout ingest path
|
||||
* (`POST /api/workouts/batch`) and the v1.4.30 cross-source picker so
|
||||
* twin workouts from Apple Watch + Withings ScanWatch collapse to a
|
||||
* single row. The page is the user-visible surface for the workout
|
||||
* data the iOS client already syncs.
|
||||
* `/insights/workouts` is the workouts surface. Its above-the-fold content —
|
||||
* the workout rows — waited for the client `useWorkouts({ limit: 100 })` cell
|
||||
* to fetch `/api/workouts?limit=100` after hydrate, so the first paint flashed
|
||||
* the loading skeleton before the list filled in. This wrapper runs the SAME
|
||||
* cached read the API route uses (`readWorkoutsListCached`, the shared
|
||||
* `caches.workouts` projection cell) during SSR and hands it to TanStack
|
||||
* through `HydrationBoundary`, so the mounted list cell starts warm instead of
|
||||
* skeleton-first.
|
||||
*
|
||||
* Renders three states:
|
||||
* - loading shell while the workouts query resolves,
|
||||
* - empty state when the user has no workouts yet (CTA points at
|
||||
* the iOS / Apple Health onboarding cue — there is no manual
|
||||
* workout-entry form today),
|
||||
* - the deduped list otherwise. Each row links to
|
||||
* `/insights/workouts/[id]` for the detail surface.
|
||||
* Contract notes (the v1.30.9 dashboard / v1.30.13 medications template):
|
||||
* - The query key comes ONLY from the central factory. The client cell keys on
|
||||
* `queryKeys.workoutsRecentList({ limit: 100, offset, since, sportType })`
|
||||
* with `offset` / `since` / `sportType` all `undefined`, so this seeded key
|
||||
* is built from the same factory call with the same argument shape — the
|
||||
* server-seeded key equals the client's `useQuery` key by construction.
|
||||
* - The projection filter matches the route's too: all-null filter params hash
|
||||
* to the same `userId|||` projection cache slot a `?limit=100` request
|
||||
* builds, so the prefetch and the API path share one dedup pass.
|
||||
* - The dehydrated VALUE is JSON-round-tripped so the hydrated shape is exactly
|
||||
* what the client `queryFn` produces from the wire ((await res.json()).data —
|
||||
* Prisma `Date`s as ISO strings), never a Date-carrying sibling that would
|
||||
* poison the cell.
|
||||
* - Module-gate parity: the client page bounces a workouts-off account
|
||||
* (`useModulePageGuard`), and the API route refuses it server-side; skip the
|
||||
* prefetch when the module is off so a disabled page never seeds a cache it
|
||||
* will not read.
|
||||
* - The client cell keeps its own fetch (`staleTime: 60s`): the prefetch seeds
|
||||
* fresh data, so the mounted cell paints immediately and only refetches once
|
||||
* the TTL lapses — the "empty then fills" flash is gone without dropping the
|
||||
* freshness path.
|
||||
* - Fail-soft: no session, a module lookup hiccup, or a DB blip renders the
|
||||
* page exactly as before this wrapper existed — the client cell owns the
|
||||
* fetch. The prefetch is an accelerator, never a gate.
|
||||
*/
|
||||
export default function InsightsWorkoutsPage() {
|
||||
const { t } = useTranslations();
|
||||
const { ready } = useModulePageGuard("workouts");
|
||||
const { data, isLoading, isEmpty } = useWorkouts({ limit: 100 });
|
||||
|
||||
// v1.18.0 B1 — bounce a direct URL hit on a disabled-workouts account.
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
export default async function InsightsWorkoutsPage() {
|
||||
// Global SSR-prefetch kill-switch shared with the dashboard wrapper. The e2e
|
||||
// server sets `DASHBOARD_SSR_PREFETCH=false` so Playwright route mocks —
|
||||
// which only see CLIENT fetches — keep governing what every prefetched page
|
||||
// paints.
|
||||
if (process.env.DASHBOARD_SSR_PREFETCH === "false") {
|
||||
return <InsightsWorkoutsPageClient />;
|
||||
}
|
||||
|
||||
let dehydratedState = null;
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (session) {
|
||||
const { user } = session;
|
||||
const modules = await resolveModuleMap(user.id);
|
||||
// Mirror the client guard (`user?.modules?.workouts !== false`): an
|
||||
// absent key reads as enabled; only an explicit `false` disables.
|
||||
if (modules.workouts !== false) {
|
||||
const list = await readWorkoutsListCached(user.id, {
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
since: null,
|
||||
until: null,
|
||||
sportType: null,
|
||||
});
|
||||
const queryClient = new QueryClient();
|
||||
// Match the client cell's key + wire shape exactly (JSON semantics, ISO
|
||||
// date strings) — same-key-different-shape is silent cache poison.
|
||||
queryClient.setQueryData(
|
||||
queryKeys.workoutsRecentList({
|
||||
limit: 100,
|
||||
offset: undefined,
|
||||
since: undefined,
|
||||
sportType: undefined,
|
||||
}),
|
||||
JSON.parse(JSON.stringify(list)),
|
||||
);
|
||||
dehydratedState = dehydrate(queryClient);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Prefetch is an accelerator, never a gate — the client path stands.
|
||||
}
|
||||
|
||||
if (dehydratedState === null) {
|
||||
return <InsightsWorkoutsPageClient />;
|
||||
}
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("insights.workouts.title")}
|
||||
description={t("insights.workouts.description")}
|
||||
explainerMetric="workouts"
|
||||
coachLaunch
|
||||
>
|
||||
{/* No `<MetricRangeControls>` here: workouts are session records, not a
|
||||
MeasurementType series, so the period-over-period range read has
|
||||
nothing to aggregate. */}
|
||||
{isLoading ? (
|
||||
<div data-slot="workouts-loading" className="space-y-2">
|
||||
<Skeleton className="h-14 w-full rounded-lg" />
|
||||
<Skeleton className="h-14 w-full rounded-lg" />
|
||||
<Skeleton className="h-14 w-full rounded-lg" />
|
||||
</div>
|
||||
) : isEmpty ? (
|
||||
<MetricEmptyState
|
||||
icon={<Activity className="size-6" />}
|
||||
title={t("insights.workouts.emptyState.title")}
|
||||
description={t("insights.workouts.emptyState.description")}
|
||||
cta={null}
|
||||
coachPrefill="I haven't logged any workouts yet — why does tracking them matter, and what should I focus on first?"
|
||||
/>
|
||||
) : data ? (
|
||||
<WorkoutList workouts={data.workouts} />
|
||||
) : null}
|
||||
</SubPageShell>
|
||||
<HydrationBoundary state={dehydratedState}>
|
||||
<InsightsWorkoutsPageClient />
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,9 @@ vi.mock("@/components/medications/log-intake-dialog", () => ({
|
||||
LogIntakeDialog: () => null,
|
||||
}));
|
||||
|
||||
import MedicationsPage from "@/app/medications/page";
|
||||
// v1.30.x — `@/app/medications/page` is now an async RSC prefetch wrapper; the
|
||||
// interactive surface this test renders is the client leaf it wraps.
|
||||
import MedicationsPage from "@/app/medications/page-client";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { MedicationListLayout } from "@/lib/medication-list-layout";
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { HydrationBoundary, hashKey } from "@tanstack/react-query";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* v1.30.x — the `/medications` server-prefetch key crux + fail-soft.
|
||||
*
|
||||
* The RSC wrapper (`src/app/medications/page.tsx`) dehydrates the medications
|
||||
* list under `queryKeys.medications()` so the client cell reads it back on
|
||||
* mount instead of flashing skeletons. These tests pin the load-bearing rules:
|
||||
* - the server dehydrates under the EXACT client key (byte-identical hash);
|
||||
* - the value is JSON-round-tripped to the wire shape (Dates → ISO strings);
|
||||
* - any prefetch error / a disabled module / no session fails soft to the
|
||||
* bare client path (the client cell owns the fetch).
|
||||
*/
|
||||
|
||||
const getSession = vi.fn();
|
||||
const resolveModuleMap = vi.fn();
|
||||
const readMedicationsListCached = vi.fn();
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: () => getSession() }));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
resolveModuleMap: (id: string) => resolveModuleMap(id),
|
||||
}));
|
||||
vi.mock("@/lib/medications/list-read", () => ({
|
||||
readMedicationsListCached: (u: unknown) => readMedicationsListCached(u),
|
||||
}));
|
||||
vi.mock("../page-client", () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
import MedicationsPage from "../page";
|
||||
|
||||
const SESSION = {
|
||||
user: { id: "u1", timezone: "Europe/Berlin", disableCoach: false },
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env.DASHBOARD_SSR_PREFETCH;
|
||||
});
|
||||
|
||||
/** The single dehydrated query on a HydrationBoundary element, or null. */
|
||||
function dehydratedQuery(
|
||||
el: ReactElement,
|
||||
): { queryHash: string; state: { data: unknown } } | null {
|
||||
if (el.type !== HydrationBoundary) return null;
|
||||
const props = el.props as {
|
||||
state?: { queries: { queryHash: string; state: { data: unknown } }[] };
|
||||
};
|
||||
const q = props.state?.queries?.[0];
|
||||
return q ? { queryHash: q.queryHash, state: q.state } : null;
|
||||
}
|
||||
|
||||
describe("/medications RSC prefetch", () => {
|
||||
it("dehydrates the list under the EXACT client key (byte-identical hash)", async () => {
|
||||
getSession.mockResolvedValue(SESSION);
|
||||
resolveModuleMap.mockResolvedValue({ medications: true });
|
||||
readMedicationsListCached.mockResolvedValue([
|
||||
{ id: "m1", name: "Ramipril" },
|
||||
]);
|
||||
|
||||
const el = (await MedicationsPage()) as ReactElement;
|
||||
const q = dehydratedQuery(el);
|
||||
expect(q).not.toBeNull();
|
||||
// The client cell looks up `queryKeys.medications()`; the server MUST have
|
||||
// dehydrated under the same hash or the prefetch silently no-ops.
|
||||
expect(q!.queryHash).toBe(hashKey(queryKeys.medications()));
|
||||
expect(q!.state.data).toEqual([{ id: "m1", name: "Ramipril" }]);
|
||||
});
|
||||
|
||||
it("JSON-round-trips the value to the wire shape (Dates → ISO strings)", async () => {
|
||||
getSession.mockResolvedValue(SESSION);
|
||||
resolveModuleMap.mockResolvedValue({ medications: true });
|
||||
const takenAt = new Date("2026-07-18T08:30:00.000Z");
|
||||
readMedicationsListCached.mockResolvedValue([
|
||||
{ id: "m1", createdAt: takenAt, schedules: [{ id: "s1" }] },
|
||||
]);
|
||||
|
||||
const el = (await MedicationsPage()) as ReactElement;
|
||||
const q = dehydratedQuery(el);
|
||||
// A Date must land as its ISO string — the shape the client's
|
||||
// (await res.json()).data would carry — never a live Date object.
|
||||
expect((q!.state.data as { createdAt: unknown }[])[0].createdAt).toBe(
|
||||
takenAt.toISOString(),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails soft to the bare client when the read throws", async () => {
|
||||
getSession.mockResolvedValue(SESSION);
|
||||
resolveModuleMap.mockResolvedValue({ medications: true });
|
||||
readMedicationsListCached.mockRejectedValue(new Error("db blip"));
|
||||
|
||||
const el = (await MedicationsPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
});
|
||||
|
||||
it("skips the prefetch when the medications module is off", async () => {
|
||||
getSession.mockResolvedValue(SESSION);
|
||||
resolveModuleMap.mockResolvedValue({ medications: false });
|
||||
|
||||
const el = (await MedicationsPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(readMedicationsListCached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails soft when there is no session", async () => {
|
||||
getSession.mockResolvedValue(null);
|
||||
const el = (await MedicationsPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(readMedicationsListCached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("honours the DASHBOARD_SSR_PREFETCH kill-switch (no session read)", async () => {
|
||||
process.env.DASHBOARD_SSR_PREFETCH = "false";
|
||||
const el = (await MedicationsPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(getSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,636 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
invalidateKeys,
|
||||
medicationDependentKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { MedicationWizardDialog } from "@/components/medications/wizard/medication-wizard-dialog";
|
||||
import { MedicationCard } from "@/components/medications/medication-card";
|
||||
import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card";
|
||||
import { LogIntakeDialog } from "@/components/medications/log-intake-dialog";
|
||||
import { TakeAllDueDialog } from "@/components/medications/take-all-due-dialog";
|
||||
import { deriveDueMedications } from "@/components/medications/take-all-due";
|
||||
import {
|
||||
MedicationTable,
|
||||
MedicationTableSkeleton,
|
||||
} from "@/components/medications/medication-table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
CheckCheck,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Pill,
|
||||
Plus,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useMedicationComplianceSummaryAll } from "@/lib/queries/use-medication-compliance-summary";
|
||||
import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layout";
|
||||
import { applyMedicationOrder } from "@/lib/medications/medication-order";
|
||||
|
||||
interface Schedule {
|
||||
id: string;
|
||||
windowStart: string;
|
||||
windowEnd: string;
|
||||
label: string | null;
|
||||
dose: string | null;
|
||||
daysOfWeek: string | null;
|
||||
/** v1.5 — first-class times-of-day. */
|
||||
timesOfDay?: string[];
|
||||
/** v1.5 — RFC 5545 RRULE string for calendar-anchored cadences. */
|
||||
rrule?: string | null;
|
||||
/** v1.5 — flexible-rolling interval in days. */
|
||||
rollingIntervalDays?: number | null;
|
||||
/** v1.5 — reminder grace window in minutes. */
|
||||
reminderGraceMinutes?: number | null;
|
||||
/** v1.16.1 — explicit per-dose on-time windows (band model). */
|
||||
doseWindows?: { timeOfDay: string; start: string; end: string }[] | null;
|
||||
}
|
||||
|
||||
interface Medication {
|
||||
id: string;
|
||||
name: string;
|
||||
dose: string;
|
||||
category: string;
|
||||
/** v1.4.25 W4d — Prisma treatment class (GENERIC | GLP1). */
|
||||
treatmentClass?: string;
|
||||
/** v1.4.25 W4d — doses per pen/vial for inventory math. */
|
||||
dosesPerUnit?: number | null;
|
||||
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
|
||||
deliveryForm?: string;
|
||||
/** v1.8.5 — per-medication injection-site tracking opt-in. */
|
||||
trackInjectionSites?: boolean;
|
||||
/** v1.8.5 — per-medication allowed / preferred injection sites. */
|
||||
allowedInjectionSites?: string[];
|
||||
active: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
pausedAt: string | null;
|
||||
lastTakenAt: string | null;
|
||||
/** List-only count of today's ACTIONED intake events (taken/skipped). */
|
||||
todayEventCount?: number;
|
||||
/**
|
||||
* v1.8.4 — server-computed next due instant (canonical recurrence
|
||||
* engine). Threaded straight through to both card variants, which render
|
||||
* it instead of re-deriving the timestamp client-side.
|
||||
*/
|
||||
nextDueAt?: string | null;
|
||||
/** v1.16.4 — true when `nextDueAt` is an OPEN overdue slot. */
|
||||
nextDueOverdue?: boolean;
|
||||
/** v1.5 — medication-level course start date (ISO string). */
|
||||
startsOn?: string | null;
|
||||
/** v1.5 — medication-level course end date (ISO string). */
|
||||
endsOn?: string | null;
|
||||
/** v1.5 — single-administration medication. */
|
||||
oneShot?: boolean;
|
||||
/** v1.16.11 (#316) — as-needed (PRN): no schedules, never due. */
|
||||
asNeeded?: boolean;
|
||||
/** v1.9.0 — optional WHO ATC classification code for the FHIR export. */
|
||||
atcCode?: string | null;
|
||||
/** v1.9.0 — optional RxNorm RxCUI (secondary FHIR coding). */
|
||||
rxNormCode?: string | null;
|
||||
/**
|
||||
* v1.16.10 — usable inventory units left across the medication's
|
||||
* containers (ACTIVE / IN_USE with units remaining). Null = inventory
|
||||
* tracking off (no items ever registered).
|
||||
*/
|
||||
stockUnitsRemaining?: number | null;
|
||||
/** v1.16.10 — dose-derived stock (`stockUnitsRemaining / unitsPerDose`). */
|
||||
stockDosesRemaining?: number | null;
|
||||
schedules: Schedule[];
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.11.3 C4 — loading placeholder for one medication card. Renders the
|
||||
* real `Card`/`CardContent` shell (so padding, radius and gap match the
|
||||
* loaded card exactly) with the shared `Skeleton` primitive standing in
|
||||
* for each populated slot: header title + dose, status pill, next / last
|
||||
* line, the two compliance bars and the action row. Replacing the bare
|
||||
* centred spinner with a grid of these stops the layout jump when the
|
||||
* cards resolve.
|
||||
*/
|
||||
function MedicationCardSkeleton() {
|
||||
return (
|
||||
<Card className="h-full" aria-hidden="true">
|
||||
<div className="flex items-start justify-between gap-2 px-4 md:px-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-3.5 w-20" />
|
||||
</div>
|
||||
<Skeleton className="size-8 rounded-md" />
|
||||
</div>
|
||||
<CardContent className="flex flex-col space-y-3.5">
|
||||
<Skeleton className="h-6 w-40 rounded-full" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3.5 w-48" />
|
||||
<Skeleton className="h-3.5 w-36" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3.5 w-full" />
|
||||
<Skeleton className="h-3.5 w-full" />
|
||||
</div>
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Skeleton className="h-9 flex-1" />
|
||||
<Skeleton className="h-9 flex-1" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MedicationsPageClient() {
|
||||
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
// v1.18.1 (D3) — medications is an opt-out module. When the account has it
|
||||
// turned off the whole page disappears (the nav entry is hidden by the same
|
||||
// gate) and the list query never fires (the API would 403 anyway).
|
||||
// Default-on: an absent key reads as enabled, so the page only hides on an
|
||||
// explicit `false`.
|
||||
const medicationsEnabled = user?.modules?.medications !== false;
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const searchParams = useSearchParams();
|
||||
// v1.5.4 — the retired `/medications/new` route redirects here with
|
||||
// `?new=1`, so legacy bookmarks keep landing on the create wizard.
|
||||
// The initial open state reads the query param synchronously so the
|
||||
// dialog opens on the very first render; a follow-up effect strips
|
||||
// the param from the URL so a manual close + refresh stays closed.
|
||||
const shouldOpenFromUrl = searchParams?.get("new") === "1";
|
||||
const [dialogOpen, setDialogOpen] = useState(shouldOpenFromUrl);
|
||||
// v1.29.x — the Today digest's "Log dose" action deep-links straight to
|
||||
// the overdue medication (`/medications?highlight=<id>`) instead of the
|
||||
// bare list. Read once on mount; a scroll effect below (once the list
|
||||
// resolves) brings the target card into view and rings it briefly, then
|
||||
// clears the state so the ring doesn't linger indefinitely.
|
||||
const [highlightId, setHighlightId] = useState<string | null>(
|
||||
() => searchParams?.get("highlight") ?? null,
|
||||
);
|
||||
// v1.14.0 — the medications-page "Add" choice. The top button now offers
|
||||
// two paths: log an intake (incl. a backdated one) against an existing
|
||||
// medication, or create a new medication (the existing wizard).
|
||||
const [logIntakeOpen, setLogIntakeOpen] = useState(false);
|
||||
// v1.16.11 (#316) — "Alle fälligen einnehmen" confirm dialog. The header
|
||||
// button earns its slot only while ≥ 2 medications are currently due.
|
||||
const [takeAllOpen, setTakeAllOpen] = useState(false);
|
||||
|
||||
// v1.16.10 — the persisted list presentation: cards vs table plus the
|
||||
// manual medication order, server-side per user
|
||||
// (`GET`/`PUT /api/medications/layout`). The toggle writes
|
||||
// optimistically; the order editor lives at /settings/medications.
|
||||
const { layout, isLayoutLoading } = useMedicationListLayout(isAuthenticated);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldOpenFromUrl) {
|
||||
// Drop the query param so a refresh after closing the dialog
|
||||
// doesn't keep reopening it.
|
||||
router.replace("/medications");
|
||||
}
|
||||
}, [shouldOpenFromUrl, router]);
|
||||
|
||||
const {
|
||||
data: medications,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch: refetchMedications,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.medications(),
|
||||
queryFn: async () => {
|
||||
return apiGet<Medication[]>("/api/medications");
|
||||
},
|
||||
enabled: isAuthenticated && medicationsEnabled,
|
||||
// v1.16.12 (#316) — refetch on every mount, even inside the global
|
||||
// staleTime window. App Router unmounts this page on navigation away
|
||||
// and remounts it on return; without this, a return within 5 min
|
||||
// serves the cached list and shows nothing the user changed on
|
||||
// another surface (the iOS app, a Withings sync, a second tab) — the
|
||||
// client cache can't know to invalidate a cross-device write. "always"
|
||||
// makes a return-visit fetch fresh; the per-user server cache keeps it
|
||||
// cheap. Mutation-driven invalidation still covers same-tab writes.
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
|
||||
// v1.30.1 M12 — pull-to-refresh parity with labs/checkups/mood/measurements.
|
||||
// Medications is one of the sharpest PWA-resume-staleness surfaces (a
|
||||
// background-refreshed take/skip from a notification, or a cross-device
|
||||
// write, wouldn't otherwise show up until the 5-min staleTime lapses or a
|
||||
// full remount re-triggers `refetchOnMount: "always"` above).
|
||||
const refreshMedications = useCallback(
|
||||
() => invalidateKeys(queryClient, medicationDependentKeys),
|
||||
[queryClient],
|
||||
);
|
||||
const pull = usePullToRefresh({ onRefresh: refreshMedications });
|
||||
|
||||
// v1.29.x — once the highlighted card is in the DOM, scroll it into view
|
||||
// and clear the highlight state after a few seconds so the ring is a
|
||||
// one-time "you tapped here" cue, not a permanent tint (mirrors the
|
||||
// vault's upload-duplicate flash-highlight, `HIGHLIGHT_MS` in
|
||||
// `use-document-upload.ts`).
|
||||
useEffect(() => {
|
||||
if (!highlightId || !medications?.length) return;
|
||||
const card = document.querySelector(
|
||||
`[data-medication-id="${highlightId}"]`,
|
||||
);
|
||||
card?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
const timer = setTimeout(() => setHighlightId(null), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [highlightId, medications]);
|
||||
|
||||
// v1.16.11 — the same reminder-thresholds read the cards make (shared
|
||||
// key + cache), so the header's due-set derivation tiers late /
|
||||
// very_late exactly like the card pills. Null on failure keeps the
|
||||
// derivation on the 120/240 defaults the cards also fall back to.
|
||||
const { data: thresholds } = useQuery({
|
||||
queryKey: queryKeys.settingsReminderThresholds(),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await apiGet<{
|
||||
lateMinutes: number;
|
||||
missedMinutes: number;
|
||||
lowStockRunwayDays: number | null;
|
||||
}>("/api/settings/reminder-thresholds");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// v1.16.11 — the SAME batched compliance rows the cards / table read
|
||||
// (shared key + cache, no extra round trip). The due-set derivation
|
||||
// needs each medication's compliance dose status so an escalated
|
||||
// (overdue / missed past its band tail) medication — which still
|
||||
// renders an active take button on its red card — joins the
|
||||
// take-all-due set.
|
||||
const { data: complianceRows } = useMedicationComplianceSummaryAll();
|
||||
|
||||
function openCreate() {
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
// v1.15.20 — seed the detail page's query from the list row before
|
||||
// navigating. The list response is a superset of the detail GET
|
||||
// (`{...medication, category, nextDueAt}` plus the list-only
|
||||
// `lastTakenAt` / `todayEventCount`), so the detail shell paints
|
||||
// instantly from the seeded cache while its own un-gated query
|
||||
// refetches the authoritative row in the background.
|
||||
function seedDetail(med: Medication) {
|
||||
queryClient.setQueryData(queryKeys.medicationDetail(med.id), med);
|
||||
}
|
||||
|
||||
// v1.16.1 — the kebab's Edit lands on the detail page's Übersicht tab,
|
||||
// exactly like tapping the medication name. Every everyday edit lives
|
||||
// inline in the tabs there; the structural wizard is reachable via
|
||||
// Erweitert → Lebenszyklus (and still answers `?edit=1` deep-links).
|
||||
// History still lands on the Verlauf tab.
|
||||
function openEdit(med: Medication) {
|
||||
seedDetail(med);
|
||||
router.push(`/medications/${med.id}`);
|
||||
}
|
||||
|
||||
function openHistory(med: Medication) {
|
||||
seedDetail(med);
|
||||
router.push(`/medications/${med.id}?tab=verlauf`);
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
setDialogOpen(false);
|
||||
}
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Module off ⇒ the surface disappears entirely. The nav entry is hidden by
|
||||
// the same gate, so a direct hit renders nothing rather than an orphaned
|
||||
// shell.
|
||||
if (isAuthenticated && !medicationsEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("medications.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("medications.loginRequired")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Defensive against stale service-worker responses or any future API
|
||||
// shape change: only filter when we actually have an array.
|
||||
const medsArray = Array.isArray(medications) ? medications : [];
|
||||
// v1.16.10 — the user-defined manual order applies to BOTH views
|
||||
// (cards and table). Medications not in the saved order keep the
|
||||
// alphabetical default, appended after the ordered block.
|
||||
const activeMeds = applyMedicationOrder(
|
||||
medsArray.filter((m) => m.active),
|
||||
layout.order,
|
||||
);
|
||||
const inactiveMeds = applyMedicationOrder(
|
||||
medsArray.filter((m) => !m.active),
|
||||
layout.order,
|
||||
);
|
||||
const tableView = layout.view === "table";
|
||||
|
||||
// v1.16.11 (#316) — the currently-due set, derived from the list payload
|
||||
// the page already holds via the SAME pipeline a card's pill runs (band
|
||||
// model + server display-due gate + taken-early downgrade), plus the
|
||||
// batched compliance dose status so escalated (overdue / missed)
|
||||
// medications past their band tail stay takeable from the batch. Page
|
||||
// order is preserved so the confirm dialog lists medications as rendered.
|
||||
const dueMeds = deriveDueMedications(activeMeds, {
|
||||
tz: user?.timezone || "Europe/Berlin",
|
||||
thresholds: thresholds ?? undefined,
|
||||
doseStatusById: new Map(
|
||||
(complianceRows ?? []).map((row) => [
|
||||
row.medicationId,
|
||||
row.complianceDisplay?.currentDose.status ?? "upcoming",
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PullToRefreshIndicator {...pull} />
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<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
|
||||
H1 isn't an unframed label. */}
|
||||
<p className="text-muted-foreground truncate text-xs sm:text-sm">
|
||||
{t("medications.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* 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
|
||||
card's own one-tap job). Calm outline variant so the primary
|
||||
Add button keeps the visual lead; same responsive 44-px
|
||||
mobile tap floor as its neighbours. */}
|
||||
{dueMeds.length >= 2 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setTakeAllOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<CheckCheck className="h-4 w-4" aria-hidden="true" />
|
||||
{t("medications.takeAllDue.button")}
|
||||
</Button>
|
||||
)}
|
||||
{/* v1.16.11 — the wrench is the one customize entry point:
|
||||
it links to /settings/medications, which owns the view
|
||||
preference (cards ⇄ table) and the manual-order editor.
|
||||
Same glyph, slot (left of the add button) and responsive
|
||||
44-px mobile tap floor as the dashboard and insights
|
||||
headers. */}
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/layout/medications"
|
||||
aria-label={t("medications.customize")}
|
||||
title={t("medications.customize")}
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
{/* v1.14.0 — the "Add" button is now a choice: log an intake
|
||||
(incl. a backdated one) against an existing medication, or
|
||||
create a new medication. v1.12.2 — match the dashboard Add
|
||||
button's responsive tap-target floor (`min-h-11 sm:min-h-9`) so
|
||||
both primary "add" entry points clear the WCAG 2.5.5 44px mobile
|
||||
minimum identically. */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="min-h-11 sm:min-h-9">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("medications.addMedication")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setLogIntakeOpen(true)}
|
||||
disabled={activeMeds.length === 0}
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
{t("medications.addChoice.logIntake")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={openCreate}>
|
||||
<Pill className="mr-2 h-4 w-4" />
|
||||
{t("medications.addChoice.newMedication")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading || isLayoutLoading ? (
|
||||
// v1.11.3 C4 — skeletons instead of a bare centred spinner so the
|
||||
// page reserves the loaded layout and does not jump when the
|
||||
// medications resolve. v1.16.10 — the skeleton follows the
|
||||
// persisted view (table rows vs card grid) so resolving the data
|
||||
// never swaps the footprint; while the view preference itself is
|
||||
// still loading the default cards skeleton stands in.
|
||||
<div role="status" aria-busy="true" aria-label={t("medications.title")}>
|
||||
{tableView && !isLayoutLoading ? (
|
||||
<MedicationTableSkeleton />
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<MedicationCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<QueryErrorCard
|
||||
description={t("medications.loadFailed")}
|
||||
retryLabel={t("medications.retryLoad")}
|
||||
onRetry={() => void refetchMedications()}
|
||||
/>
|
||||
) : !medications?.length ? (
|
||||
// v1.4.15 phase-C5: refactor the inline icon+text+button block
|
||||
// to the shared EmptyState primitive so the empty path matches
|
||||
// every other list page in the app (role=status, dashed
|
||||
// border, consistent icon-bubble + spacing).
|
||||
<EmptyState
|
||||
icon={<Pill className="size-6" />}
|
||||
title={t("medications.emptyTitle")}
|
||||
description={t("medications.emptyDescription")}
|
||||
action={
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("medications.firstMedication")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : tableView ? (
|
||||
// v1.16.10 — the compact table view. Same payloads, same status
|
||||
// gating, same mutation hooks as the cards; one row per
|
||||
// medication, inactive rows pinned after the active block.
|
||||
<MedicationTable
|
||||
activeMedications={activeMeds}
|
||||
inactiveMedications={inactiveMeds}
|
||||
highlightId={highlightId}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Active medications */}
|
||||
{activeMeds.length > 0 && (
|
||||
<div className="space-y-3.5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{activeMeds.map((med) =>
|
||||
// v1.16.11 — an as-needed medication always renders the
|
||||
// generic card: the GLP-1 variant is built around the
|
||||
// rolling injection cadence an as-needed med doesn't have.
|
||||
med.treatmentClass === "GLP1" && !med.asNeeded ? (
|
||||
<Glp1MedicationCard
|
||||
key={med.id}
|
||||
medication={med}
|
||||
onEdit={openEdit}
|
||||
onOpenHistory={openHistory}
|
||||
highlighted={highlightId === med.id}
|
||||
/>
|
||||
) : (
|
||||
<MedicationCard
|
||||
key={med.id}
|
||||
medication={med}
|
||||
onEdit={openEdit}
|
||||
onOpenHistory={openHistory}
|
||||
highlighted={highlightId === med.id}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inactive medications */}
|
||||
{inactiveMeds.length > 0 && (
|
||||
<div className="space-y-3.5">
|
||||
<h2 className="text-muted-foreground text-sm font-medium">
|
||||
{t("common.inactive")} ({inactiveMeds.length})
|
||||
</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{inactiveMeds.map((med) =>
|
||||
med.treatmentClass === "GLP1" && !med.asNeeded ? (
|
||||
<Glp1MedicationCard
|
||||
key={med.id}
|
||||
medication={med}
|
||||
onEdit={openEdit}
|
||||
onOpenHistory={openHistory}
|
||||
highlighted={highlightId === med.id}
|
||||
/>
|
||||
) : (
|
||||
<MedicationCard
|
||||
key={med.id}
|
||||
medication={med}
|
||||
onEdit={openEdit}
|
||||
onOpenHistory={openHistory}
|
||||
highlighted={highlightId === med.id}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* v1.5.5 — the per-card kebab triggers used to open inline
|
||||
IntakeImportDialog / ApiEndpointDialog mounts here, but both
|
||||
state slots were only ever set from inside the dialogs
|
||||
themselves (never from a list-card prop) so the inline
|
||||
mounts were dead code. v1.5.5 retires both on the list page:
|
||||
the import dialog lives in the detail-page intake-history
|
||||
header (see `src/components/medications/intake-import-dialog.tsx`),
|
||||
the API endpoint moves to the detail-page Settings → Externe
|
||||
Integration sub-row. */}
|
||||
|
||||
{/* v1.15.18 — create-only wizard mount. Editing an existing
|
||||
medication now navigates to the detail page's tabs (the hero's
|
||||
"Vollständig bearbeiten" reopens the wizard in edit mode from
|
||||
there); the list page wizard only ever creates. The wizard owns
|
||||
its own ResponsiveSheet shell with the sticky footer. */}
|
||||
<MedicationWizardDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
mode="create"
|
||||
onSuccess={closeDialog}
|
||||
/>
|
||||
|
||||
{/* v1.14.0 — log-intake path of the "Add" choice. Lists the active
|
||||
medications, lets the user pin a schedule slot, and picks a
|
||||
date+time that supports backdating. Submits to the existing
|
||||
per-medication intake route (same slot upsert as a live "Taken"
|
||||
tap) so the one-row-per-dose-slot invariant holds. */}
|
||||
{/* v1.16.11 (#316) — confirm-all dialog for the due set. Records
|
||||
through the per-medication intake route in a loop (slot
|
||||
attribution + inventory consumption identical to N individual
|
||||
taps — see take-all-due.ts); failed medications stay due. */}
|
||||
{takeAllOpen && (
|
||||
<TakeAllDueDialog
|
||||
open={takeAllOpen}
|
||||
onOpenChange={setTakeAllOpen}
|
||||
dueMedications={dueMeds}
|
||||
/>
|
||||
)}
|
||||
|
||||
{logIntakeOpen && (
|
||||
<LogIntakeDialog
|
||||
open={logIntakeOpen}
|
||||
onOpenChange={setLogIntakeOpen}
|
||||
medications={activeMeds.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
dose: m.dose,
|
||||
schedules: m.schedules.map((s) => ({
|
||||
windowStart: s.windowStart,
|
||||
label: s.label,
|
||||
dose: s.dose,
|
||||
timesOfDay: s.timesOfDay,
|
||||
})),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+74
-623
@@ -1,636 +1,87 @@
|
||||
"use client";
|
||||
import {
|
||||
dehydrate,
|
||||
HydrationBoundary,
|
||||
QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
invalidateKeys,
|
||||
medicationDependentKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { MedicationWizardDialog } from "@/components/medications/wizard/medication-wizard-dialog";
|
||||
import { MedicationCard } from "@/components/medications/medication-card";
|
||||
import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card";
|
||||
import { LogIntakeDialog } from "@/components/medications/log-intake-dialog";
|
||||
import { TakeAllDueDialog } from "@/components/medications/take-all-due-dialog";
|
||||
import { deriveDueMedications } from "@/components/medications/take-all-due";
|
||||
import {
|
||||
MedicationTable,
|
||||
MedicationTableSkeleton,
|
||||
} from "@/components/medications/medication-table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
CheckCheck,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Pill,
|
||||
Plus,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useMedicationComplianceSummaryAll } from "@/lib/queries/use-medication-compliance-summary";
|
||||
import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layout";
|
||||
import { applyMedicationOrder } from "@/lib/medications/medication-order";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { readMedicationsListCached } from "@/lib/medications/list-read";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
interface Schedule {
|
||||
id: string;
|
||||
windowStart: string;
|
||||
windowEnd: string;
|
||||
label: string | null;
|
||||
dose: string | null;
|
||||
daysOfWeek: string | null;
|
||||
/** v1.5 — first-class times-of-day. */
|
||||
timesOfDay?: string[];
|
||||
/** v1.5 — RFC 5545 RRULE string for calendar-anchored cadences. */
|
||||
rrule?: string | null;
|
||||
/** v1.5 — flexible-rolling interval in days. */
|
||||
rollingIntervalDays?: number | null;
|
||||
/** v1.5 — reminder grace window in minutes. */
|
||||
reminderGraceMinutes?: number | null;
|
||||
/** v1.16.1 — explicit per-dose on-time windows (band model). */
|
||||
doseWindows?: { timeOfDay: string; start: string; end: string }[] | null;
|
||||
}
|
||||
|
||||
interface Medication {
|
||||
id: string;
|
||||
name: string;
|
||||
dose: string;
|
||||
category: string;
|
||||
/** v1.4.25 W4d — Prisma treatment class (GENERIC | GLP1). */
|
||||
treatmentClass?: string;
|
||||
/** v1.4.25 W4d — doses per pen/vial for inventory math. */
|
||||
dosesPerUnit?: number | null;
|
||||
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
|
||||
deliveryForm?: string;
|
||||
/** v1.8.5 — per-medication injection-site tracking opt-in. */
|
||||
trackInjectionSites?: boolean;
|
||||
/** v1.8.5 — per-medication allowed / preferred injection sites. */
|
||||
allowedInjectionSites?: string[];
|
||||
active: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
pausedAt: string | null;
|
||||
lastTakenAt: string | null;
|
||||
/** List-only count of today's ACTIONED intake events (taken/skipped). */
|
||||
todayEventCount?: number;
|
||||
/**
|
||||
* v1.8.4 — server-computed next due instant (canonical recurrence
|
||||
* engine). Threaded straight through to both card variants, which render
|
||||
* it instead of re-deriving the timestamp client-side.
|
||||
*/
|
||||
nextDueAt?: string | null;
|
||||
/** v1.16.4 — true when `nextDueAt` is an OPEN overdue slot. */
|
||||
nextDueOverdue?: boolean;
|
||||
/** v1.5 — medication-level course start date (ISO string). */
|
||||
startsOn?: string | null;
|
||||
/** v1.5 — medication-level course end date (ISO string). */
|
||||
endsOn?: string | null;
|
||||
/** v1.5 — single-administration medication. */
|
||||
oneShot?: boolean;
|
||||
/** v1.16.11 (#316) — as-needed (PRN): no schedules, never due. */
|
||||
asNeeded?: boolean;
|
||||
/** v1.9.0 — optional WHO ATC classification code for the FHIR export. */
|
||||
atcCode?: string | null;
|
||||
/** v1.9.0 — optional RxNorm RxCUI (secondary FHIR coding). */
|
||||
rxNormCode?: string | null;
|
||||
/**
|
||||
* v1.16.10 — usable inventory units left across the medication's
|
||||
* containers (ACTIVE / IN_USE with units remaining). Null = inventory
|
||||
* tracking off (no items ever registered).
|
||||
*/
|
||||
stockUnitsRemaining?: number | null;
|
||||
/** v1.16.10 — dose-derived stock (`stockUnitsRemaining / unitsPerDose`). */
|
||||
stockDosesRemaining?: number | null;
|
||||
schedules: Schedule[];
|
||||
}
|
||||
import MedicationsPageClient from "./page-client";
|
||||
|
||||
/**
|
||||
* v1.11.3 C4 — loading placeholder for one medication card. Renders the
|
||||
* real `Card`/`CardContent` shell (so padding, radius and gap match the
|
||||
* loaded card exactly) with the shared `Skeleton` primitive standing in
|
||||
* for each populated slot: header title + dose, status pill, next / last
|
||||
* line, the two compliance bars and the action row. Replacing the bare
|
||||
* centred spinner with a grid of these stops the layout jump when the
|
||||
* cards resolve.
|
||||
* Thin RSC wrapper around the (client) medications page.
|
||||
*
|
||||
* `/medications` is a high-traffic surface whose above-the-fold content — the
|
||||
* medication cards — waited for the client `queryKeys.medications()` cell to
|
||||
* fetch `/api/medications` after hydrate, so the first paint flashed skeletons
|
||||
* before the list filled in. This wrapper runs the SAME cached read the API
|
||||
* route uses (`readMedicationsListCached`, the shared `caches.medications` SWR
|
||||
* cell) during SSR and hands it to TanStack through `HydrationBoundary`, so the
|
||||
* mounted list cell starts warm instead of skeleton-first.
|
||||
*
|
||||
* Contract notes (the v1.30.9 dashboard template):
|
||||
* - The query key comes ONLY from the central factory — `queryKeys.medications()`
|
||||
* is a deterministic zero-arg tuple, so the server-seeded key equals the
|
||||
* client's `useQuery` key by construction (no window/locale param to drift).
|
||||
* - The dehydrated VALUE is JSON-round-tripped so the hydrated shape is exactly
|
||||
* what the client `queryFn` produces from the wire ((await res.json()).data —
|
||||
* Prisma `Date`s as ISO strings, Decimals as their JSON form), never a
|
||||
* Date-carrying sibling that would poison the cell.
|
||||
* - Module-gate parity: the client list query is gated on the `medications`
|
||||
* module being on; skip the prefetch when it is off (the client cell is
|
||||
* disabled then too, and the page renders nothing).
|
||||
* - The client cell keeps `refetchOnMount: "always"` — it is a correctness
|
||||
* gate, not a redundancy: a cross-device / cross-surface take/skip (the iOS
|
||||
* app, a notification action, a second tab) produces no client mutation
|
||||
* event, so the list must re-verify on every mount. With the prefetch in
|
||||
* place the hydrated cards paint immediately and that refetch runs in the
|
||||
* background — the "empty then fills" flash is gone WITHOUT dropping the
|
||||
* freshness guarantee.
|
||||
* - Fail-soft: no session, a module lookup hiccup, or a DB blip renders the
|
||||
* page exactly as before this wrapper existed — the client cell owns the
|
||||
* fetch. The prefetch is an accelerator, never a gate.
|
||||
*/
|
||||
function MedicationCardSkeleton() {
|
||||
return (
|
||||
<Card className="h-full" aria-hidden="true">
|
||||
<div className="flex items-start justify-between gap-2 px-4 md:px-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-3.5 w-20" />
|
||||
</div>
|
||||
<Skeleton className="size-8 rounded-md" />
|
||||
</div>
|
||||
<CardContent className="flex flex-col space-y-3.5">
|
||||
<Skeleton className="h-6 w-40 rounded-full" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3.5 w-48" />
|
||||
<Skeleton className="h-3.5 w-36" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3.5 w-full" />
|
||||
<Skeleton className="h-3.5 w-full" />
|
||||
</div>
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Skeleton className="h-9 flex-1" />
|
||||
<Skeleton className="h-9 flex-1" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
export default async function MedicationsPage() {
|
||||
// Global SSR-prefetch kill-switch shared with the dashboard wrapper. The e2e
|
||||
// server sets `DASHBOARD_SSR_PREFETCH=false` so Playwright route mocks — which
|
||||
// only see CLIENT fetches — keep governing what every prefetched page paints.
|
||||
if (process.env.DASHBOARD_SSR_PREFETCH === "false") {
|
||||
return <MedicationsPageClient />;
|
||||
}
|
||||
|
||||
export default function MedicationsPage() {
|
||||
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
// v1.18.1 (D3) — medications is an opt-out module. When the account has it
|
||||
// turned off the whole page disappears (the nav entry is hidden by the same
|
||||
// gate) and the list query never fires (the API would 403 anyway).
|
||||
// Default-on: an absent key reads as enabled, so the page only hides on an
|
||||
// explicit `false`.
|
||||
const medicationsEnabled = user?.modules?.medications !== false;
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const searchParams = useSearchParams();
|
||||
// v1.5.4 — the retired `/medications/new` route redirects here with
|
||||
// `?new=1`, so legacy bookmarks keep landing on the create wizard.
|
||||
// The initial open state reads the query param synchronously so the
|
||||
// dialog opens on the very first render; a follow-up effect strips
|
||||
// the param from the URL so a manual close + refresh stays closed.
|
||||
const shouldOpenFromUrl = searchParams?.get("new") === "1";
|
||||
const [dialogOpen, setDialogOpen] = useState(shouldOpenFromUrl);
|
||||
// v1.29.x — the Today digest's "Log dose" action deep-links straight to
|
||||
// the overdue medication (`/medications?highlight=<id>`) instead of the
|
||||
// bare list. Read once on mount; a scroll effect below (once the list
|
||||
// resolves) brings the target card into view and rings it briefly, then
|
||||
// clears the state so the ring doesn't linger indefinitely.
|
||||
const [highlightId, setHighlightId] = useState<string | null>(
|
||||
() => searchParams?.get("highlight") ?? null,
|
||||
);
|
||||
// v1.14.0 — the medications-page "Add" choice. The top button now offers
|
||||
// two paths: log an intake (incl. a backdated one) against an existing
|
||||
// medication, or create a new medication (the existing wizard).
|
||||
const [logIntakeOpen, setLogIntakeOpen] = useState(false);
|
||||
// v1.16.11 (#316) — "Alle fälligen einnehmen" confirm dialog. The header
|
||||
// button earns its slot only while ≥ 2 medications are currently due.
|
||||
const [takeAllOpen, setTakeAllOpen] = useState(false);
|
||||
|
||||
// v1.16.10 — the persisted list presentation: cards vs table plus the
|
||||
// manual medication order, server-side per user
|
||||
// (`GET`/`PUT /api/medications/layout`). The toggle writes
|
||||
// optimistically; the order editor lives at /settings/medications.
|
||||
const { layout, isLayoutLoading } = useMedicationListLayout(isAuthenticated);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldOpenFromUrl) {
|
||||
// Drop the query param so a refresh after closing the dialog
|
||||
// doesn't keep reopening it.
|
||||
router.replace("/medications");
|
||||
}
|
||||
}, [shouldOpenFromUrl, router]);
|
||||
|
||||
const {
|
||||
data: medications,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch: refetchMedications,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.medications(),
|
||||
queryFn: async () => {
|
||||
return apiGet<Medication[]>("/api/medications");
|
||||
},
|
||||
enabled: isAuthenticated && medicationsEnabled,
|
||||
// v1.16.12 (#316) — refetch on every mount, even inside the global
|
||||
// staleTime window. App Router unmounts this page on navigation away
|
||||
// and remounts it on return; without this, a return within 5 min
|
||||
// serves the cached list and shows nothing the user changed on
|
||||
// another surface (the iOS app, a Withings sync, a second tab) — the
|
||||
// client cache can't know to invalidate a cross-device write. "always"
|
||||
// makes a return-visit fetch fresh; the per-user server cache keeps it
|
||||
// cheap. Mutation-driven invalidation still covers same-tab writes.
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
|
||||
// v1.30.1 M12 — pull-to-refresh parity with labs/checkups/mood/measurements.
|
||||
// Medications is one of the sharpest PWA-resume-staleness surfaces (a
|
||||
// background-refreshed take/skip from a notification, or a cross-device
|
||||
// write, wouldn't otherwise show up until the 5-min staleTime lapses or a
|
||||
// full remount re-triggers `refetchOnMount: "always"` above).
|
||||
const refreshMedications = useCallback(
|
||||
() => invalidateKeys(queryClient, medicationDependentKeys),
|
||||
[queryClient],
|
||||
);
|
||||
const pull = usePullToRefresh({ onRefresh: refreshMedications });
|
||||
|
||||
// v1.29.x — once the highlighted card is in the DOM, scroll it into view
|
||||
// and clear the highlight state after a few seconds so the ring is a
|
||||
// one-time "you tapped here" cue, not a permanent tint (mirrors the
|
||||
// vault's upload-duplicate flash-highlight, `HIGHLIGHT_MS` in
|
||||
// `use-document-upload.ts`).
|
||||
useEffect(() => {
|
||||
if (!highlightId || !medications?.length) return;
|
||||
const card = document.querySelector(
|
||||
`[data-medication-id="${highlightId}"]`,
|
||||
);
|
||||
card?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
const timer = setTimeout(() => setHighlightId(null), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [highlightId, medications]);
|
||||
|
||||
// v1.16.11 — the same reminder-thresholds read the cards make (shared
|
||||
// key + cache), so the header's due-set derivation tiers late /
|
||||
// very_late exactly like the card pills. Null on failure keeps the
|
||||
// derivation on the 120/240 defaults the cards also fall back to.
|
||||
const { data: thresholds } = useQuery({
|
||||
queryKey: queryKeys.settingsReminderThresholds(),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await apiGet<{
|
||||
lateMinutes: number;
|
||||
missedMinutes: number;
|
||||
lowStockRunwayDays: number | null;
|
||||
}>("/api/settings/reminder-thresholds");
|
||||
} catch {
|
||||
return null;
|
||||
let dehydratedState = null;
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (session) {
|
||||
const { user } = session;
|
||||
const modules = await resolveModuleMap(user.id);
|
||||
// Mirror the client gate (`user?.modules?.medications !== false`): an
|
||||
// absent key reads as enabled; only an explicit `false` disables.
|
||||
if (modules.medications !== false) {
|
||||
const list = await readMedicationsListCached(user);
|
||||
const queryClient = new QueryClient();
|
||||
// Match the client cell's wire shape exactly (JSON semantics, ISO date
|
||||
// strings) — same-key-different-shape is silent cache poison.
|
||||
queryClient.setQueryData(
|
||||
queryKeys.medications(),
|
||||
JSON.parse(JSON.stringify(list)),
|
||||
);
|
||||
dehydratedState = dehydrate(queryClient);
|
||||
}
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// v1.16.11 — the SAME batched compliance rows the cards / table read
|
||||
// (shared key + cache, no extra round trip). The due-set derivation
|
||||
// needs each medication's compliance dose status so an escalated
|
||||
// (overdue / missed past its band tail) medication — which still
|
||||
// renders an active take button on its red card — joins the
|
||||
// take-all-due set.
|
||||
const { data: complianceRows } = useMedicationComplianceSummaryAll();
|
||||
|
||||
function openCreate() {
|
||||
setDialogOpen(true);
|
||||
}
|
||||
} catch {
|
||||
// Prefetch is an accelerator, never a gate — the client path stands.
|
||||
}
|
||||
|
||||
// v1.15.20 — seed the detail page's query from the list row before
|
||||
// navigating. The list response is a superset of the detail GET
|
||||
// (`{...medication, category, nextDueAt}` plus the list-only
|
||||
// `lastTakenAt` / `todayEventCount`), so the detail shell paints
|
||||
// instantly from the seeded cache while its own un-gated query
|
||||
// refetches the authoritative row in the background.
|
||||
function seedDetail(med: Medication) {
|
||||
queryClient.setQueryData(queryKeys.medicationDetail(med.id), med);
|
||||
if (dehydratedState === null) {
|
||||
return <MedicationsPageClient />;
|
||||
}
|
||||
|
||||
// v1.16.1 — the kebab's Edit lands on the detail page's Übersicht tab,
|
||||
// exactly like tapping the medication name. Every everyday edit lives
|
||||
// inline in the tabs there; the structural wizard is reachable via
|
||||
// Erweitert → Lebenszyklus (and still answers `?edit=1` deep-links).
|
||||
// History still lands on the Verlauf tab.
|
||||
function openEdit(med: Medication) {
|
||||
seedDetail(med);
|
||||
router.push(`/medications/${med.id}`);
|
||||
}
|
||||
|
||||
function openHistory(med: Medication) {
|
||||
seedDetail(med);
|
||||
router.push(`/medications/${med.id}?tab=verlauf`);
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
setDialogOpen(false);
|
||||
}
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Module off ⇒ the surface disappears entirely. The nav entry is hidden by
|
||||
// the same gate, so a direct hit renders nothing rather than an orphaned
|
||||
// shell.
|
||||
if (isAuthenticated && !medicationsEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("medications.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("medications.loginRequired")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Defensive against stale service-worker responses or any future API
|
||||
// shape change: only filter when we actually have an array.
|
||||
const medsArray = Array.isArray(medications) ? medications : [];
|
||||
// v1.16.10 — the user-defined manual order applies to BOTH views
|
||||
// (cards and table). Medications not in the saved order keep the
|
||||
// alphabetical default, appended after the ordered block.
|
||||
const activeMeds = applyMedicationOrder(
|
||||
medsArray.filter((m) => m.active),
|
||||
layout.order,
|
||||
);
|
||||
const inactiveMeds = applyMedicationOrder(
|
||||
medsArray.filter((m) => !m.active),
|
||||
layout.order,
|
||||
);
|
||||
const tableView = layout.view === "table";
|
||||
|
||||
// v1.16.11 (#316) — the currently-due set, derived from the list payload
|
||||
// the page already holds via the SAME pipeline a card's pill runs (band
|
||||
// model + server display-due gate + taken-early downgrade), plus the
|
||||
// batched compliance dose status so escalated (overdue / missed)
|
||||
// medications past their band tail stay takeable from the batch. Page
|
||||
// order is preserved so the confirm dialog lists medications as rendered.
|
||||
const dueMeds = deriveDueMedications(activeMeds, {
|
||||
tz: user?.timezone || "Europe/Berlin",
|
||||
thresholds: thresholds ?? undefined,
|
||||
doseStatusById: new Map(
|
||||
(complianceRows ?? []).map((row) => [
|
||||
row.medicationId,
|
||||
row.complianceDisplay?.currentDose.status ?? "upcoming",
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PullToRefreshIndicator {...pull} />
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<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
|
||||
H1 isn't an unframed label. */}
|
||||
<p className="text-muted-foreground truncate text-xs sm:text-sm">
|
||||
{t("medications.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* 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
|
||||
card's own one-tap job). Calm outline variant so the primary
|
||||
Add button keeps the visual lead; same responsive 44-px
|
||||
mobile tap floor as its neighbours. */}
|
||||
{dueMeds.length >= 2 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setTakeAllOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<CheckCheck className="h-4 w-4" aria-hidden="true" />
|
||||
{t("medications.takeAllDue.button")}
|
||||
</Button>
|
||||
)}
|
||||
{/* v1.16.11 — the wrench is the one customize entry point:
|
||||
it links to /settings/medications, which owns the view
|
||||
preference (cards ⇄ table) and the manual-order editor.
|
||||
Same glyph, slot (left of the add button) and responsive
|
||||
44-px mobile tap floor as the dashboard and insights
|
||||
headers. */}
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/layout/medications"
|
||||
aria-label={t("medications.customize")}
|
||||
title={t("medications.customize")}
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
{/* v1.14.0 — the "Add" button is now a choice: log an intake
|
||||
(incl. a backdated one) against an existing medication, or
|
||||
create a new medication. v1.12.2 — match the dashboard Add
|
||||
button's responsive tap-target floor (`min-h-11 sm:min-h-9`) so
|
||||
both primary "add" entry points clear the WCAG 2.5.5 44px mobile
|
||||
minimum identically. */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="min-h-11 sm:min-h-9">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("medications.addMedication")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setLogIntakeOpen(true)}
|
||||
disabled={activeMeds.length === 0}
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
{t("medications.addChoice.logIntake")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={openCreate}>
|
||||
<Pill className="mr-2 h-4 w-4" />
|
||||
{t("medications.addChoice.newMedication")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading || isLayoutLoading ? (
|
||||
// v1.11.3 C4 — skeletons instead of a bare centred spinner so the
|
||||
// page reserves the loaded layout and does not jump when the
|
||||
// medications resolve. v1.16.10 — the skeleton follows the
|
||||
// persisted view (table rows vs card grid) so resolving the data
|
||||
// never swaps the footprint; while the view preference itself is
|
||||
// still loading the default cards skeleton stands in.
|
||||
<div role="status" aria-busy="true" aria-label={t("medications.title")}>
|
||||
{tableView && !isLayoutLoading ? (
|
||||
<MedicationTableSkeleton />
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<MedicationCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<QueryErrorCard
|
||||
description={t("medications.loadFailed")}
|
||||
retryLabel={t("medications.retryLoad")}
|
||||
onRetry={() => void refetchMedications()}
|
||||
/>
|
||||
) : !medications?.length ? (
|
||||
// v1.4.15 phase-C5: refactor the inline icon+text+button block
|
||||
// to the shared EmptyState primitive so the empty path matches
|
||||
// every other list page in the app (role=status, dashed
|
||||
// border, consistent icon-bubble + spacing).
|
||||
<EmptyState
|
||||
icon={<Pill className="size-6" />}
|
||||
title={t("medications.emptyTitle")}
|
||||
description={t("medications.emptyDescription")}
|
||||
action={
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("medications.firstMedication")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : tableView ? (
|
||||
// v1.16.10 — the compact table view. Same payloads, same status
|
||||
// gating, same mutation hooks as the cards; one row per
|
||||
// medication, inactive rows pinned after the active block.
|
||||
<MedicationTable
|
||||
activeMedications={activeMeds}
|
||||
inactiveMedications={inactiveMeds}
|
||||
highlightId={highlightId}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Active medications */}
|
||||
{activeMeds.length > 0 && (
|
||||
<div className="space-y-3.5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{activeMeds.map((med) =>
|
||||
// v1.16.11 — an as-needed medication always renders the
|
||||
// generic card: the GLP-1 variant is built around the
|
||||
// rolling injection cadence an as-needed med doesn't have.
|
||||
med.treatmentClass === "GLP1" && !med.asNeeded ? (
|
||||
<Glp1MedicationCard
|
||||
key={med.id}
|
||||
medication={med}
|
||||
onEdit={openEdit}
|
||||
onOpenHistory={openHistory}
|
||||
highlighted={highlightId === med.id}
|
||||
/>
|
||||
) : (
|
||||
<MedicationCard
|
||||
key={med.id}
|
||||
medication={med}
|
||||
onEdit={openEdit}
|
||||
onOpenHistory={openHistory}
|
||||
highlighted={highlightId === med.id}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inactive medications */}
|
||||
{inactiveMeds.length > 0 && (
|
||||
<div className="space-y-3.5">
|
||||
<h2 className="text-muted-foreground text-sm font-medium">
|
||||
{t("common.inactive")} ({inactiveMeds.length})
|
||||
</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{inactiveMeds.map((med) =>
|
||||
med.treatmentClass === "GLP1" && !med.asNeeded ? (
|
||||
<Glp1MedicationCard
|
||||
key={med.id}
|
||||
medication={med}
|
||||
onEdit={openEdit}
|
||||
onOpenHistory={openHistory}
|
||||
highlighted={highlightId === med.id}
|
||||
/>
|
||||
) : (
|
||||
<MedicationCard
|
||||
key={med.id}
|
||||
medication={med}
|
||||
onEdit={openEdit}
|
||||
onOpenHistory={openHistory}
|
||||
highlighted={highlightId === med.id}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* v1.5.5 — the per-card kebab triggers used to open inline
|
||||
IntakeImportDialog / ApiEndpointDialog mounts here, but both
|
||||
state slots were only ever set from inside the dialogs
|
||||
themselves (never from a list-card prop) so the inline
|
||||
mounts were dead code. v1.5.5 retires both on the list page:
|
||||
the import dialog lives in the detail-page intake-history
|
||||
header (see `src/components/medications/intake-import-dialog.tsx`),
|
||||
the API endpoint moves to the detail-page Settings → Externe
|
||||
Integration sub-row. */}
|
||||
|
||||
{/* v1.15.18 — create-only wizard mount. Editing an existing
|
||||
medication now navigates to the detail page's tabs (the hero's
|
||||
"Vollständig bearbeiten" reopens the wizard in edit mode from
|
||||
there); the list page wizard only ever creates. The wizard owns
|
||||
its own ResponsiveSheet shell with the sticky footer. */}
|
||||
<MedicationWizardDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
mode="create"
|
||||
onSuccess={closeDialog}
|
||||
/>
|
||||
|
||||
{/* v1.14.0 — log-intake path of the "Add" choice. Lists the active
|
||||
medications, lets the user pin a schedule slot, and picks a
|
||||
date+time that supports backdating. Submits to the existing
|
||||
per-medication intake route (same slot upsert as a live "Taken"
|
||||
tap) so the one-row-per-dose-slot invariant holds. */}
|
||||
{/* v1.16.11 (#316) — confirm-all dialog for the due set. Records
|
||||
through the per-medication intake route in a loop (slot
|
||||
attribution + inventory consumption identical to N individual
|
||||
taps — see take-all-due.ts); failed medications stay due. */}
|
||||
{takeAllOpen && (
|
||||
<TakeAllDueDialog
|
||||
open={takeAllOpen}
|
||||
onOpenChange={setTakeAllOpen}
|
||||
dueMedications={dueMeds}
|
||||
/>
|
||||
)}
|
||||
|
||||
{logIntakeOpen && (
|
||||
<LogIntakeDialog
|
||||
open={logIntakeOpen}
|
||||
onOpenChange={setLogIntakeOpen}
|
||||
medications={activeMeds.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
dose: m.dose,
|
||||
schedules: m.schedules.map((s) => ({
|
||||
windowStart: s.windowStart,
|
||||
label: s.label,
|
||||
dose: s.dose,
|
||||
timesOfDay: s.timesOfDay,
|
||||
})),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<HydrationBoundary state={dehydratedState}>
|
||||
<MedicationsPageClient />
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
+76
-30
@@ -87,10 +87,12 @@ const DASHBOARD_QUERY_OPTS = {
|
||||
// v1.19.0 — day-span the batched dashboard series (`series-batch`) fetches.
|
||||
// Threaded to every chart as `preloadedCoverageDays` so a chart reads the
|
||||
// batched slice ONLY for a range tab whose window fits within it (7 / 30);
|
||||
// the 90 / All tabs exceed it and self-fetch the wider window. Pre-fix the
|
||||
// chart trusted the batch for any window > 7 days, so 90 / All silently
|
||||
// re-rendered this ~30-day slice. Keep this in lockstep with `batchWindow`.
|
||||
const BATCH_COVERAGE_DAYS = 31;
|
||||
// the 90 / All tabs exceed it and self-fetch the wider window.
|
||||
//
|
||||
// v1.30.9 — `BATCH_COVERAGE_DAYS` + the type-set + window derivations moved to
|
||||
// the client-safe `@/lib/dashboard/batch-chart-types` module so the RSC
|
||||
// prefetch (page.tsx) and this client read the SAME code — the batched-series
|
||||
// cache key is byte-identical on both sides or the prefetch silently no-ops.
|
||||
|
||||
// v1.16.8 — the loaders retry a rejected chunk import once (a lazy
|
||||
// import caches its rejection permanently, so a transient 404 from a
|
||||
@@ -123,6 +125,17 @@ const MedicationComplianceChart = (
|
||||
<MedicationComplianceChartLazy {...props} />
|
||||
</ChartErrorBoundary>
|
||||
);
|
||||
|
||||
// v1.30.9 — warm the shared `chart-runtime` chunk during hydration. The
|
||||
// dashboard charts mount through `ssr:false` dynamics, which begin their
|
||||
// import only at mount (post-hydration); firing the import at module
|
||||
// evaluation lets the chunk download overlap hydration instead of queueing
|
||||
// behind the first chart mount. No render-path change — the dynamics still
|
||||
// resolve through the same `importWithRetry` promise (a warm module cache),
|
||||
// and the `<ChartSkeleton>` fallback is untouched.
|
||||
if (typeof window !== "undefined") {
|
||||
void importWithRetry(() => import("@/components/charts/chart-runtime"));
|
||||
}
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
|
||||
@@ -130,6 +143,12 @@ import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
|
||||
import { useDailyDigest } from "@/lib/queries/use-daily-digest";
|
||||
import { useModuleEnabled } from "@/hooks/use-module-enabled";
|
||||
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
|
||||
import {
|
||||
BATCH_COVERAGE_DAYS,
|
||||
deriveBatchChartTypes,
|
||||
computeLocalBatchWindow,
|
||||
type BatchWindow,
|
||||
} from "@/lib/dashboard/batch-chart-types";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { mergeSlimAndThickAnalytics } from "@/lib/analytics/merge-slim-thick";
|
||||
import { isWindowSufficient } from "@/lib/analytics/window-confidence";
|
||||
@@ -187,7 +206,20 @@ function tileInsightHref(id: string): string | null {
|
||||
return TILE_INSIGHT_HREF[id] ?? null;
|
||||
}
|
||||
|
||||
export default function DashboardPageClient() {
|
||||
export default function DashboardPageClient({
|
||||
batchWindow: batchWindowProp,
|
||||
}: {
|
||||
/**
|
||||
* v1.30.9 — the batched-series ISO window computed server-side from the
|
||||
* PROFILE timezone and threaded down as an RSC prop. Present only when the
|
||||
* RSC prefetch ran (snapshot mode, `DASHBOARD_SSR_PREFETCH !== "false"`);
|
||||
* absent on the legacy / prefetch-off / fail-soft paths, where the client
|
||||
* falls back to its browser-local window. Adopting the prop verbatim keeps
|
||||
* the `chartSeriesBatch` key byte-identical to the key the RSC dehydrated
|
||||
* under, so the prefetched slice lands instead of triggering a refetch.
|
||||
*/
|
||||
batchWindow?: BatchWindow;
|
||||
} = {}) {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const mounted = useMounted();
|
||||
const { t } = useTranslations();
|
||||
@@ -723,27 +755,28 @@ export default function DashboardPageClient() {
|
||||
// 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);
|
||||
//
|
||||
// v1.30.9 — the type-set derivation is the shared `deriveBatchChartTypes`,
|
||||
// the SAME function the RSC prefetch runs over the SAME snapshot payload
|
||||
// (`layout` + `data.summaries` in snapshot mode ARE the snapshot fields the
|
||||
// server dehydrated). One implementation → the CSV, and therefore the
|
||||
// batched-series cache key, is byte-identical on both sides by construction.
|
||||
const batchChartTypes = deriveBatchChartTypes(layout, data?.summaries);
|
||||
|
||||
// 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() - BATCH_COVERAGE_DAYS * 86_400_000);
|
||||
return { from: from.toISOString(), to: to.toISOString() };
|
||||
});
|
||||
//
|
||||
// v1.30.9 — the server-computed PROFILE-tz window wins when present (it
|
||||
// rides the serialized RSC payload, so SSR and the first hydration render
|
||||
// see the identical object → the `chartSeriesBatch` key matches the key the
|
||||
// RSC dehydrated under, zero hydration seam). The browser-local fallback
|
||||
// stands for the prefetch-off / legacy / fail-soft paths where no prop
|
||||
// arrives. Lazy `useState` with a prop-derived initial value is
|
||||
// deterministic across the SSR/CSR pair.
|
||||
const [batchWindow] = useState(
|
||||
() => batchWindowProp ?? computeLocalBatchWindow(),
|
||||
);
|
||||
|
||||
const { data: batchedSeries } = useQuery({
|
||||
queryKey: queryKeys.chartSeriesBatch(
|
||||
@@ -815,17 +848,30 @@ export default function DashboardPageClient() {
|
||||
falling through to nothing. The dense tile grid + charts below
|
||||
are untouched. */}
|
||||
{insightsEnabled &&
|
||||
// `!mounted` pins the SSR pass AND the first hydration render to the
|
||||
// skeleton (v1.16.4 pattern): the digest query rehydrates from the
|
||||
// persisted cache on the client, so without this gate the client's
|
||||
// first render would paint the hero while the server rendered the
|
||||
// skeleton — a text-content hydration mismatch (React #418).
|
||||
(!mounted || digestQuery.isLoading ? (
|
||||
// v1.30.9 — DATA-FIRST gate so the SSR pass paints the POPULATED hero
|
||||
// (the LCP element) instead of a skeleton. This is hydration-safe ONLY
|
||||
// because of a bound invariant: the digest is server-DEHYDRATED
|
||||
// (`page.tsx` prefetch → `setQueryData(queryKeys.dailyDigest(), …)`),
|
||||
// so `digestQuery.data` is present on BOTH the SSR pass AND the first
|
||||
// hydration render (HydrationBoundary hydrates synchronously before
|
||||
// first render; `enabled:false` only gates FETCHING, not cached-data
|
||||
// reads) — both sides render identical HTML, no React #418.
|
||||
//
|
||||
// The one divergence class the old `!mounted` gate guarded against —
|
||||
// a client-only IndexedDB-restored cache painting the hero while the
|
||||
// server rendered a skeleton — CANNOT bite here: `["daily", …]` is NOT
|
||||
// in the persist allowlist (`PERSIST_ALLOWLIST_HEADS` in
|
||||
// `@/lib/pwa/query-persister` — `dashboard` / `chart-data` + the exact
|
||||
// `["user","dashboardWidgets"]` tuple only), so the digest never
|
||||
// rehydrates from disk. The error / empty branches stay reachable only
|
||||
// post-mount. DO NOT add `["daily", …]` to that allowlist, or render
|
||||
// the hero from any client-only source, without revisiting this gate.
|
||||
(digestQuery.data ? (
|
||||
<TodayHero digest={digestQuery.data} />
|
||||
) : !mounted || digestQuery.isLoading ? (
|
||||
<TodayHeroSkeleton />
|
||||
) : digestQuery.isError ? (
|
||||
<QueryErrorCard onRetry={() => digestQuery.refetch()} />
|
||||
) : digestQuery.data ? (
|
||||
<TodayHero digest={digestQuery.data} />
|
||||
) : null)}
|
||||
|
||||
{/* v1.18.6 — the spotlight tour launcher moved to the app-shell
|
||||
|
||||
+126
-25
@@ -6,52 +6,83 @@ import {
|
||||
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read";
|
||||
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
|
||||
import {
|
||||
computeBatchWindow,
|
||||
deriveBatchChartTypes,
|
||||
type BatchWindow,
|
||||
} from "@/lib/dashboard/batch-chart-types";
|
||||
import { resolveDashboardLayout } from "@/lib/dashboard-layout";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { loadDailyDigest } from "@/lib/daily/load-digest";
|
||||
import { readSeriesBatch } from "@/lib/measurements/series-batch-read";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
import DashboardPageClient from "./page-client";
|
||||
|
||||
/**
|
||||
* Soft budget on the cold-day digest prefetch. `loadDailyDigest`'s only
|
||||
* heavy input is the `daily-digest-extras` SWR cell: on the FIRST digest read
|
||||
* of a user's local day it rebuilds the streak scan + intraday-pulse read cold
|
||||
* (low hundreds of ms). Racing it against this budget bounds the worst-case
|
||||
* TTFB regression — the abandoned work still runs to completion and warms the
|
||||
* SWR cell, so the client fetch that follows a skipped dehydrate lands warm.
|
||||
*/
|
||||
const DIGEST_SOFT_BUDGET_MS = 400;
|
||||
|
||||
/** Resolve `undefined` after `ms` — the soft-budget loser in the digest race. */
|
||||
function softTimeout<T>(ms: number): Promise<T | undefined> {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(undefined), ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin RSC wrapper around the (client) dashboard page.
|
||||
*
|
||||
* The measured first-load gap on `/` was never the server: FCP painted the
|
||||
* SSR skeleton shell at ~1.8 s but LCP landed at ~5.5 s (mobile-throttled)
|
||||
* because the real tile content waited for the full JS download + hydrate +
|
||||
* a CLIENT-side snapshot fetch — while `/api/dashboard/snapshot` itself
|
||||
* answers in ~8 ms server-side. This wrapper runs that same cached read
|
||||
* during SSR and hands it to TanStack through `HydrationBoundary`, so the
|
||||
* first HTML already carries real tile data and the mounted snapshot cell
|
||||
* (`staleTime` 60 s, `refetchOnMount: false`) starts warm instead of
|
||||
* skeleton-first.
|
||||
* because the real above-the-fold content waited for the full JS download +
|
||||
* hydrate + CLIENT-side fetches. This wrapper runs the same cached reads the
|
||||
* routes use during SSR and hands them to TanStack through `HydrationBoundary`,
|
||||
* so the first HTML already carries real content and the mounted cells start
|
||||
* warm instead of skeleton-first:
|
||||
*
|
||||
* - the dashboard snapshot (`/api/dashboard/snapshot`) — every tile;
|
||||
* - the Today digest (`/api/daily/digest` → `loadDailyDigest`) — the hero;
|
||||
* - the batched chart series (`/api/measurements/series-batch` →
|
||||
* `readSeriesBatch`) — the chart row.
|
||||
*
|
||||
* Contract notes:
|
||||
* - Query keys come ONLY from the central factory. The live cell is
|
||||
* locale-keyed (`dashboardSnapshot(locale)`), and the locale here is the
|
||||
* same resolution the API route uses (`resolveServerLocale`), which the
|
||||
* client provider mirrors via the layout's `resolveInitialLocale`.
|
||||
* - The payload is JSON-round-tripped so the hydrated shape is exactly
|
||||
* what the client `queryFn` would produce from the wire ((await
|
||||
* res.json()).data — Dates as ISO strings), never a Date-carrying
|
||||
* sibling that would poison the cell for later readers.
|
||||
* - `dashboardWidgets` is seeded alongside, mirroring the client cell's
|
||||
* own warm-up in `use-dashboard-snapshot.ts` (the overlay-prefs hook
|
||||
* reads it before the chart cells fire).
|
||||
* - Fail-soft: no session (proxy is mid-redirect), a builder hiccup, or a
|
||||
* DB blip renders the page exactly as before this wrapper existed — the
|
||||
* client cell owns the fetch then.
|
||||
* - Query keys come ONLY from the central factory. Every dehydrated VALUE is
|
||||
* JSON-round-tripped so the hydrated shape is exactly what the client
|
||||
* `queryFn` would produce from the wire ((await res.json()).data — Dates as
|
||||
* ISO strings), never a Date-carrying sibling that poisons the cell.
|
||||
* - The batched-series key is the crux: the CSV type list comes from the
|
||||
* SHARED `deriveBatchChartTypes` over the SAME snapshot payload, and the ISO
|
||||
* window from `computeBatchWindow(now, user.timezone)` threaded to the
|
||||
* client as the `batchWindow` prop. Server and client build the identical
|
||||
* key by construction, so the prefetched slice lands instead of refetching.
|
||||
* - Module-gate parity: the digest route gates on `insights`; skip its
|
||||
* prefetch when the module is off (the client hook is disabled then too).
|
||||
* - Fail-soft: no session, a builder hiccup, or a DB blip renders the page
|
||||
* exactly as before this wrapper existed — the client cells own the fetch.
|
||||
* Each prefetch is independent; one failing never blocks the others.
|
||||
*/
|
||||
export default async function DashboardPage() {
|
||||
// Operator/test escape hatch: `DASHBOARD_SSR_PREFETCH=false` renders the
|
||||
// pure client-fetch dashboard (pre-prefetch behaviour). The e2e server
|
||||
// sets it so Playwright route mocks — which only see CLIENT fetches —
|
||||
// keep governing what the dashboard paints.
|
||||
// keep governing what the dashboard paints. It gates EVERY prefetch below.
|
||||
if (process.env.DASHBOARD_SSR_PREFETCH === "false") {
|
||||
return <DashboardPageClient />;
|
||||
}
|
||||
let dehydratedState = null;
|
||||
let batchWindow: BatchWindow | undefined;
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (session) {
|
||||
const { body, locale } = await readDashboardSnapshotCached(session.user);
|
||||
const { user } = session;
|
||||
const { body, locale } = await readDashboardSnapshotCached(user);
|
||||
// Match the client cell's wire shape exactly (JSON semantics, ISO
|
||||
// date strings) — same-key-different-shape is silent cache poison.
|
||||
const wireBody = JSON.parse(JSON.stringify(body)) as unknown as Record<
|
||||
@@ -61,6 +92,76 @@ export default async function DashboardPage() {
|
||||
const queryClient = new QueryClient();
|
||||
queryClient.setQueryData(queryKeys.dashboardSnapshot(locale), wireBody);
|
||||
queryClient.setQueryData(queryKeys.dashboardWidgets(), wireBody.layout);
|
||||
|
||||
// Above-the-fold prefetch runs ONLY in snapshot mode: legacy mode derives
|
||||
// its chart-type list from analytics queries the RSC did not run, so there
|
||||
// is no byte-identical key to dehydrate the series under. In legacy mode
|
||||
// `batchWindow` stays undefined and the client falls back to its own
|
||||
// browser-local window exactly as before — no behaviour change off-flag.
|
||||
const snapshotMode = isDashboardSnapshotEnabled();
|
||||
|
||||
// The profile-tz window is computed ONCE (snapshot mode only) and threaded
|
||||
// to the client so the batched-series key matches byte-for-byte.
|
||||
if (snapshotMode) {
|
||||
batchWindow = computeBatchWindow(new Date(), user.timezone);
|
||||
}
|
||||
|
||||
const modules = await resolveModuleMap(user.id);
|
||||
const insightsEnabled = modules.insights !== false;
|
||||
|
||||
const seriesTypes = snapshotMode
|
||||
? deriveBatchChartTypes(
|
||||
resolveDashboardLayout(wireBody.layout),
|
||||
(
|
||||
wireBody.tiles as {
|
||||
summaries?: Record<string, { count?: number }>;
|
||||
}
|
||||
)?.summaries,
|
||||
)
|
||||
: [];
|
||||
|
||||
// Digest carries a soft budget (cold-day extras rebuild); series is a
|
||||
// warm rollup read (< 100 ms) with none. Both fail soft to `undefined`.
|
||||
const digestWork = insightsEnabled
|
||||
? loadDailyDigest(user).catch(() => undefined)
|
||||
: Promise.resolve(undefined);
|
||||
const seriesWork =
|
||||
batchWindow && seriesTypes.length > 0
|
||||
? readSeriesBatch(
|
||||
user.id,
|
||||
seriesTypes as MeasurementType[],
|
||||
new Date(batchWindow.from),
|
||||
new Date(batchWindow.to),
|
||||
)
|
||||
.then((result) => result.series)
|
||||
.catch(() => undefined)
|
||||
: Promise.resolve(undefined);
|
||||
|
||||
const [digest, series] = await Promise.all([
|
||||
Promise.race([digestWork, softTimeout(DIGEST_SOFT_BUDGET_MS)]),
|
||||
seriesWork,
|
||||
]);
|
||||
|
||||
// JSON-round-trip both to the client wire shape before seeding.
|
||||
if (digest !== undefined) {
|
||||
queryClient.setQueryData(
|
||||
queryKeys.dailyDigest(),
|
||||
JSON.parse(JSON.stringify(digest)),
|
||||
);
|
||||
}
|
||||
// `series` is only ever set when `batchWindow` was (snapshot mode) — the
|
||||
// explicit `batchWindow` guard just narrows the type for the key build.
|
||||
if (series !== undefined && batchWindow) {
|
||||
queryClient.setQueryData(
|
||||
queryKeys.chartSeriesBatch(
|
||||
seriesTypes.join(","),
|
||||
batchWindow.from,
|
||||
batchWindow.to,
|
||||
),
|
||||
JSON.parse(JSON.stringify(series)),
|
||||
);
|
||||
}
|
||||
|
||||
dehydratedState = dehydrate(queryClient);
|
||||
}
|
||||
} catch {
|
||||
@@ -68,11 +169,11 @@ export default async function DashboardPage() {
|
||||
}
|
||||
|
||||
if (dehydratedState === null) {
|
||||
return <DashboardPageClient />;
|
||||
return <DashboardPageClient batchWindow={batchWindow} />;
|
||||
}
|
||||
return (
|
||||
<HydrationBoundary state={dehydratedState}>
|
||||
<DashboardPageClient />
|
||||
<DashboardPageClient batchWindow={batchWindow} />
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
@@ -44,3 +44,5 @@ export { EfficacyChart } from "@/components/medications/detail/efficacy/efficacy
|
||||
export { AssessmentHistoryChart } from "@/components/mental-health/assessment-history-chart";
|
||||
export { IntradayPulseChart } from "@/components/insights/intraday-pulse-chart";
|
||||
export { NutrientDailyBarChart } from "./nutrient-daily-bar-chart";
|
||||
export { WorkoutHrChart } from "./workout-hr-chart";
|
||||
export { WorkoutElevationChart } from "./workout-elevation-chart";
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* Workout elevation profile — a small cumulative-distance / altitude
|
||||
* area under the route map. Rendered ONLY through `chart-runtime.ts`.
|
||||
* Present only when ≥ 60 % of route coordinates carry altitude (the
|
||||
* caller gates it); partial altimeter data would draw a lie.
|
||||
*/
|
||||
|
||||
export interface WorkoutElevationPoint {
|
||||
distanceM: number;
|
||||
altitude: number;
|
||||
}
|
||||
|
||||
export function WorkoutElevationChart({
|
||||
points,
|
||||
}: {
|
||||
points: WorkoutElevationPoint[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const data = points.map((p) => ({ km: p.distanceM / 1000, alt: p.altitude }));
|
||||
|
||||
return (
|
||||
<div className="h-24 w-full" data-slot="workout-elevation-chart">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={data}
|
||||
margin={{ top: 4, right: 8, bottom: 0, left: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="workoutElevationFill"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="0%" stopColor="var(--chart-2)" stopOpacity={0.35} />
|
||||
<stop offset="100%" stopColor="var(--chart-2)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
opacity={0.4}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="km"
|
||||
type="number"
|
||||
domain={[0, "dataMax"]}
|
||||
tick={{ fontSize: 10, fill: "var(--muted-foreground)" }}
|
||||
tickFormatter={(v) => Number(v).toFixed(1)}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
domain={["dataMin - 5", "dataMax + 5"]}
|
||||
tick={{ fontSize: 10, fill: "var(--muted-foreground)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={36}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
labelFormatter={(km) => `${Number(km).toFixed(2)} km`}
|
||||
formatter={(value) => [
|
||||
`${Math.round(Number(value))} m`,
|
||||
t("insights.workouts.detail.elevationTitle"),
|
||||
]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="alt"
|
||||
stroke="var(--chart-2)"
|
||||
strokeWidth={2}
|
||||
fill="url(#workoutElevationFill)"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
ComposedChart,
|
||||
Area,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ReferenceLine,
|
||||
ReferenceArea,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* Per-workout heart-rate curve. Rendered ONLY through the shared
|
||||
* `chart-runtime.ts` barrel (the one recharts async boundary) so the
|
||||
* library stays a single shared chunk — never import this file directly
|
||||
* at a call site.
|
||||
*
|
||||
* `ComposedChart`: an `Area` for the min→max envelope (drawn only when
|
||||
* the series is dense enough to be honest), a `Line` for the bucket
|
||||
* mean, optional %HRmax zone bands behind them, and a dashed reference
|
||||
* line at the workout's average HR. Gaps stay gaps (`connectNulls`
|
||||
* false) — a hole in the recording never reads as an interpolated
|
||||
* curve.
|
||||
*/
|
||||
|
||||
export interface WorkoutHrChartPoint {
|
||||
tSec: number;
|
||||
mean: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export interface WorkoutHrZoneBand {
|
||||
zone: number;
|
||||
lowBpm: number | null;
|
||||
highBpm: number | null;
|
||||
}
|
||||
|
||||
export interface WorkoutHrChartProps {
|
||||
points: WorkoutHrChartPoint[];
|
||||
bucketSec: number;
|
||||
/** Draw the min→max envelope band behind the mean line. */
|
||||
envelope: boolean;
|
||||
/** Workout average HR for the reference line. */
|
||||
avgHr: number | null;
|
||||
/** Optional %HRmax zone bands for the shaded background. */
|
||||
zones?: WorkoutHrZoneBand[] | null;
|
||||
}
|
||||
|
||||
const ZONE_FILLS = [
|
||||
"var(--chart-1)",
|
||||
"var(--chart-2)",
|
||||
"var(--chart-3)",
|
||||
"var(--chart-4)",
|
||||
"var(--chart-5)",
|
||||
];
|
||||
|
||||
function minuteTick(seconds: number): string {
|
||||
return String(Math.round(seconds / 60));
|
||||
}
|
||||
|
||||
export function WorkoutHrChart({
|
||||
points,
|
||||
bucketSec,
|
||||
envelope,
|
||||
avgHr,
|
||||
zones,
|
||||
}: WorkoutHrChartProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// Build a full elapsed-time grid so a gap of missing buckets breaks
|
||||
// the line rather than interpolating across it.
|
||||
const data = useMemo(() => {
|
||||
if (points.length === 0) return [];
|
||||
const byT = new Map(points.map((p) => [p.tSec, p]));
|
||||
const maxT = points[points.length - 1].tSec;
|
||||
const grid: Array<{
|
||||
tSec: number;
|
||||
mean: number | null;
|
||||
lo: number | null;
|
||||
hi: number | null;
|
||||
}> = [];
|
||||
for (let tSec = 0; tSec <= maxT; tSec += bucketSec) {
|
||||
const p = byT.get(tSec);
|
||||
grid.push({
|
||||
tSec,
|
||||
mean: p ? p.mean : null,
|
||||
lo: p ? p.min : null,
|
||||
hi: p && envelope ? p.max - p.min : null,
|
||||
});
|
||||
}
|
||||
return grid;
|
||||
}, [points, bucketSec, envelope]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-48 w-full touch-pan-y sm:h-64"
|
||||
data-slot="workout-hr-chart"
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={data}
|
||||
margin={{ top: 8, right: 8, bottom: 0, left: 0 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
opacity={0.5}
|
||||
/>
|
||||
{zones?.map((z) =>
|
||||
z.lowBpm != null ? (
|
||||
<ReferenceArea
|
||||
key={z.zone}
|
||||
y1={z.lowBpm}
|
||||
y2={z.highBpm ?? undefined}
|
||||
fill={ZONE_FILLS[z.zone - 1]}
|
||||
fillOpacity={0.06}
|
||||
ifOverflow="hidden"
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
<XAxis
|
||||
dataKey="tSec"
|
||||
type="number"
|
||||
domain={[0, "dataMax"]}
|
||||
tickFormatter={minuteTick}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
unit=""
|
||||
/>
|
||||
<YAxis
|
||||
domain={["dataMin - 10", "dataMax + 10"]}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={44}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
labelFormatter={(tSec) =>
|
||||
`${minuteTick(Number(tSec))} ${t("insights.workouts.detail.hrAxisMinutes")}`
|
||||
}
|
||||
formatter={(value, name) =>
|
||||
name === "mean"
|
||||
? [`${value} bpm`, t("insights.workouts.detail.hrChartTitle")]
|
||||
: [null, null]
|
||||
}
|
||||
/>
|
||||
{/* Envelope band: a transparent base at `lo` plus a stacked
|
||||
`hi` (= max − min) renders the min→max ribbon. Only present
|
||||
when `envelope` gated it in. */}
|
||||
{envelope ? (
|
||||
<>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="lo"
|
||||
stackId="band"
|
||||
stroke="none"
|
||||
fill="transparent"
|
||||
connectNulls={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="hi"
|
||||
stackId="band"
|
||||
stroke="none"
|
||||
fill="var(--chart-1)"
|
||||
fillOpacity={0.15}
|
||||
connectNulls={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{avgHr != null ? (
|
||||
<ReferenceLine
|
||||
y={avgHr}
|
||||
stroke="var(--muted-foreground)"
|
||||
strokeDasharray="5 5"
|
||||
strokeOpacity={0.7}
|
||||
/>
|
||||
) : null}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="mean"
|
||||
stroke="var(--chart-1)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
connectNulls={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
} from "@/lib/query-keys";
|
||||
import {
|
||||
reduceCurrentWindowStatus,
|
||||
toZonedDate,
|
||||
type ScheduleWindowInput,
|
||||
} from "@/lib/medications/window-status";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -142,7 +141,6 @@ export function pickDefaultMedicationId(
|
||||
if (actives.length === 0) return null;
|
||||
if (actives.length === 1) return actives[0].id;
|
||||
|
||||
const nowLocal = toZonedDate(now, tz);
|
||||
const due = actives.find((m) => {
|
||||
// v1.16.9 — the same server display-due gate the cards apply: a
|
||||
// future (non-overdue) next-due suppresses the overdue tiers and a
|
||||
@@ -150,7 +148,7 @@ export function pickDefaultMedicationId(
|
||||
const nextDueMs = m.nextDueAt ? new Date(m.nextDueAt).getTime() : NaN;
|
||||
const status = reduceCurrentWindowStatus({
|
||||
schedules: m.schedules,
|
||||
nowBerlin: nowLocal,
|
||||
now,
|
||||
lateMinutes: thresholds.lateMinutes,
|
||||
missedMinutes: thresholds.missedMinutes,
|
||||
active: true,
|
||||
|
||||
@@ -4,19 +4,21 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
WorkoutDetailHeader,
|
||||
WorkoutDetailHRChart,
|
||||
WorkoutDetailRoute,
|
||||
WorkoutDetailStats,
|
||||
WorkoutDetailRoute,
|
||||
WorkoutDetailHrSection,
|
||||
WorkoutDetailZones,
|
||||
WorkoutDetailSplits,
|
||||
WorkoutDetailDayLinks,
|
||||
} from "../workout-detail";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
import type { RouteCoordinate } from "@/lib/workouts/route-svg";
|
||||
|
||||
/**
|
||||
* v1.4.32 — `<WorkoutDetail*>` unit tests.
|
||||
*
|
||||
* Each primitive runs through SSR with a single canonical fixture.
|
||||
* The tests pin the visible labels + the graceful-fallback shapes so
|
||||
* the page renders something useful even when the optional fields
|
||||
* (route, HR samples, energy) are absent.
|
||||
* #67 — `<WorkoutDetail*>` unit tests over the split `workout-detail/`
|
||||
* directory. Each primitive runs through SSR with a canonical fixture;
|
||||
* the tests pin the visible labels and the hide-don't-render contract
|
||||
* (every data-less section returns null).
|
||||
*/
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
@@ -43,6 +45,12 @@ const FIXTURE: WorkoutDetailPayload = {
|
||||
externalId: "ext-w-1",
|
||||
metadata: null,
|
||||
route: null,
|
||||
samples: null,
|
||||
hrSeries: null,
|
||||
zones: null,
|
||||
splits: null,
|
||||
sportContext: null,
|
||||
aiInsight: null,
|
||||
canonicalId: "w-1",
|
||||
};
|
||||
|
||||
@@ -62,7 +70,6 @@ describe("<WorkoutDetailStats>", () => {
|
||||
expect(html).toContain("Duration");
|
||||
expect(html).toContain("Distance");
|
||||
expect(html).toContain("Active energy");
|
||||
expect(html).toContain("Average HR");
|
||||
expect(html).toContain("145 bpm");
|
||||
expect(html).toContain("Steps");
|
||||
expect(html).toContain("Pace");
|
||||
@@ -82,68 +89,176 @@ describe("<WorkoutDetailStats>", () => {
|
||||
const html = render(<WorkoutDetailStats workout={minimal} />);
|
||||
expect(html).toContain("Duration");
|
||||
expect(html).not.toContain("Distance");
|
||||
expect(html).not.toContain("Active energy");
|
||||
expect(html).not.toContain("Steps");
|
||||
expect(html).not.toContain("Average HR");
|
||||
});
|
||||
|
||||
it("renders the own-history average line when ≥ 2 sessions exist", () => {
|
||||
const withCtx: WorkoutDetailPayload = {
|
||||
...FIXTURE,
|
||||
sportContext: {
|
||||
count: 8,
|
||||
avgDurationSec: 2040,
|
||||
avgDistanceM: 5800,
|
||||
avgAvgHr: 148,
|
||||
},
|
||||
};
|
||||
const html = render(<WorkoutDetailStats workout={withCtx} />);
|
||||
expect(html).toContain('data-slot="workout-detail-sport-average"');
|
||||
expect(html).toContain("148 bpm");
|
||||
});
|
||||
|
||||
it("hides the average line for a lone session", () => {
|
||||
const lone: WorkoutDetailPayload = {
|
||||
...FIXTURE,
|
||||
sportContext: {
|
||||
count: 1,
|
||||
avgDurationSec: 1800,
|
||||
avgDistanceM: 5200,
|
||||
avgAvgHr: 145,
|
||||
},
|
||||
};
|
||||
const html = render(<WorkoutDetailStats workout={lone} />);
|
||||
expect(html).not.toContain('data-slot="workout-detail-sport-average"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<WorkoutDetailRoute>", () => {
|
||||
it("renders the empty-state when the workout has no route", () => {
|
||||
it("returns null when the workout has no route", () => {
|
||||
const html = render(<WorkoutDetailRoute workout={FIXTURE} />);
|
||||
expect(html).toContain('data-slot="workout-detail-route-empty"');
|
||||
expect(html).toContain("No GPS route");
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("renders an SVG polyline when GeoJSON LineString geometry is present", () => {
|
||||
it("renders an SVG path for a real route", () => {
|
||||
const coords: RouteCoordinate[] = [];
|
||||
for (let i = 0; i < 20; i++)
|
||||
coords.push([11.0 + i * 0.0004, 50.0 + i * 0.0003]);
|
||||
const withRoute: WorkoutDetailPayload = {
|
||||
...FIXTURE,
|
||||
route: {
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: [
|
||||
[11.0, 49.0],
|
||||
[11.01, 49.005],
|
||||
[11.02, 49.01],
|
||||
],
|
||||
},
|
||||
geometry: { type: "LineString", coordinates: coords },
|
||||
sampleTimestamps: null,
|
||||
},
|
||||
};
|
||||
const html = render(<WorkoutDetailRoute workout={withRoute} />);
|
||||
expect(html).toContain('data-slot="workout-detail-route"');
|
||||
expect(html).toContain("<polyline");
|
||||
expect(html).toContain("<path");
|
||||
expect(html).toContain("Export GPX");
|
||||
});
|
||||
|
||||
it("falls back to the empty state for a LineString with fewer than 2 points", () => {
|
||||
it("returns null for a degenerate point-shaped route", () => {
|
||||
const stub: WorkoutDetailPayload = {
|
||||
...FIXTURE,
|
||||
route: {
|
||||
geometry: { type: "LineString", coordinates: [[11, 49]] },
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: [
|
||||
[11, 49],
|
||||
[11.0001, 49.0001],
|
||||
],
|
||||
},
|
||||
sampleTimestamps: null,
|
||||
},
|
||||
};
|
||||
const html = render(<WorkoutDetailRoute workout={stub} />);
|
||||
expect(html).toContain('data-slot="workout-detail-route-empty"');
|
||||
expect(render(<WorkoutDetailRoute workout={stub} />)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<WorkoutDetailHRChart>", () => {
|
||||
it("renders the unavailable notice with aggregate HR values when present", () => {
|
||||
const html = render(<WorkoutDetailHRChart workout={FIXTURE} />);
|
||||
expect(html).toContain('data-slot="workout-detail-hr-chart"');
|
||||
expect(html).toContain("145 bpm");
|
||||
expect(html).toContain("170 bpm");
|
||||
describe("<WorkoutDetailHrSection>", () => {
|
||||
it("returns null without an HR series", () => {
|
||||
expect(render(<WorkoutDetailHrSection workout={FIXTURE} />)).toBe("");
|
||||
});
|
||||
|
||||
it("renders just the unavailable notice when no HR aggregates are present", () => {
|
||||
const noHr: WorkoutDetailPayload = {
|
||||
it("renders the card and a provenance chip for a pulse-window series", () => {
|
||||
const withHr: WorkoutDetailPayload = {
|
||||
...FIXTURE,
|
||||
avgHr: null,
|
||||
maxHr: null,
|
||||
hrSeries: {
|
||||
source: "pulse_window",
|
||||
bucketSec: 8,
|
||||
points: [
|
||||
{ tSec: 0, mean: 130, min: 125, max: 135 },
|
||||
{ tSec: 8, mean: 140, min: 135, max: 145 },
|
||||
],
|
||||
envelope: false,
|
||||
},
|
||||
};
|
||||
const html = render(<WorkoutDetailHRChart workout={noHr} />);
|
||||
expect(html).toContain("Per-second heart-rate");
|
||||
expect(html).not.toContain("145 bpm");
|
||||
const html = render(<WorkoutDetailHrSection workout={withHr} />);
|
||||
expect(html).toContain('data-slot="workout-detail-hr"');
|
||||
expect(html).toContain('data-slot="workout-detail-hr-provenance"');
|
||||
expect(html).toContain("From your heart-rate data");
|
||||
});
|
||||
|
||||
it("omits the provenance chip for a stored series", () => {
|
||||
const withHr: WorkoutDetailPayload = {
|
||||
...FIXTURE,
|
||||
hrSeries: {
|
||||
source: "workout_series",
|
||||
bucketSec: 8,
|
||||
points: [
|
||||
{ tSec: 0, mean: 130, min: 125, max: 135 },
|
||||
{ tSec: 8, mean: 140, min: 135, max: 145 },
|
||||
],
|
||||
envelope: true,
|
||||
},
|
||||
};
|
||||
const html = render(<WorkoutDetailHrSection workout={withHr} />);
|
||||
expect(html).toContain('data-slot="workout-detail-hr"');
|
||||
expect(html).not.toContain('data-slot="workout-detail-hr-provenance"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<WorkoutDetailZones>", () => {
|
||||
it("returns null without zone data", () => {
|
||||
expect(render(<WorkoutDetailZones workout={FIXTURE} />)).toBe("");
|
||||
});
|
||||
|
||||
it("renders the stacked bar and per-zone minutes", () => {
|
||||
const withZones: WorkoutDetailPayload = {
|
||||
...FIXTURE,
|
||||
zones: {
|
||||
model: "tanaka",
|
||||
hrMax: 180,
|
||||
zones: [
|
||||
{ zone: 1, lowBpm: 90, highBpm: 108, seconds: 300 },
|
||||
{ zone: 2, lowBpm: 108, highBpm: 126, seconds: 600 },
|
||||
{ zone: 3, lowBpm: 126, highBpm: 144, seconds: 500 },
|
||||
{ zone: 4, lowBpm: 144, highBpm: 162, seconds: 200 },
|
||||
{ zone: 5, lowBpm: 162, highBpm: null, seconds: 60 },
|
||||
],
|
||||
},
|
||||
};
|
||||
const html = render(<WorkoutDetailZones workout={withZones} />);
|
||||
expect(html).toContain('data-slot="workout-detail-zones"');
|
||||
expect(html).toContain("Effort zones");
|
||||
expect(html).toContain("Z3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<WorkoutDetailSplits>", () => {
|
||||
it("returns null without splits", () => {
|
||||
expect(render(<WorkoutDetailSplits workout={FIXTURE} />)).toBe("");
|
||||
});
|
||||
|
||||
it("renders a row per kilometre", () => {
|
||||
const withSplits: WorkoutDetailPayload = {
|
||||
...FIXTURE,
|
||||
splits: [
|
||||
{ km: 1, durationSec: 300, paceSecPerKm: 300 },
|
||||
{ km: 2, durationSec: 288, paceSecPerKm: 288 },
|
||||
],
|
||||
};
|
||||
const html = render(<WorkoutDetailSplits workout={withSplits} />);
|
||||
expect(html).toContain('data-slot="workout-detail-splits"');
|
||||
expect(html).toContain("5:00");
|
||||
expect(html).toContain("4:48 /km");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<WorkoutDetailDayLinks>", () => {
|
||||
it("renders the that-day navigation links", () => {
|
||||
const html = render(<WorkoutDetailDayLinks workout={FIXTURE} />);
|
||||
expect(html).toContain('data-slot="workout-detail-day-links"');
|
||||
expect(html).toContain('href="/insights/pulse"');
|
||||
expect(html).toContain('href="/insights/sleep"');
|
||||
expect(html).toContain('href="/insights/mood"');
|
||||
});
|
||||
});
|
||||
@@ -1,438 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Flame,
|
||||
Footprints,
|
||||
HeartPulse,
|
||||
Map,
|
||||
Mountain,
|
||||
Timer,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
|
||||
import {
|
||||
hourCycleOptions,
|
||||
type TimeFormatPreference,
|
||||
} from "@/lib/format-locale";
|
||||
import { iconForSport } from "@/lib/workouts/sport-icons";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
|
||||
/**
|
||||
* v1.4.32 — workout-detail components: header, stats, optional route,
|
||||
* optional HR chart.
|
||||
*
|
||||
* Mounted by `/insights/workouts/[id]/page.tsx`. The page itself owns
|
||||
* the data fetch + the empty / loading / error branches; each
|
||||
* component below is a pure render primitive over a non-null
|
||||
* `WorkoutDetailPayload`.
|
||||
*/
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
if (h > 0) {
|
||||
return `${h}h ${m.toString().padStart(2, "0")}m ${s.toString().padStart(2, "0")}s`;
|
||||
}
|
||||
return `${m}m ${s.toString().padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
function formatDistanceKm(meters: number, locale: string): string {
|
||||
const km = meters / 1000;
|
||||
return getNumberFormat(locale, {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: km < 10 ? 2 : 1,
|
||||
}).format(km);
|
||||
}
|
||||
|
||||
function formatNumber(
|
||||
value: number,
|
||||
locale: string,
|
||||
fractionDigits = 0,
|
||||
): string {
|
||||
return getNumberFormat(locale, {
|
||||
maximumFractionDigits: fractionDigits,
|
||||
minimumFractionDigits: fractionDigits,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatPaceMinPerKm(
|
||||
durationSec: number,
|
||||
meters: number,
|
||||
locale: string,
|
||||
): string {
|
||||
// Pace is the seconds-per-kilometre value. Only meaningful for
|
||||
// run / walk / hike. The caller already gated on `meters > 0`.
|
||||
const secPerKm = (durationSec / meters) * 1000;
|
||||
const m = Math.floor(secPerKm / 60);
|
||||
const s = Math.round(secPerKm % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")} ${locale === "en" ? "/km" : "/km"}`;
|
||||
}
|
||||
|
||||
function formatDateRange(
|
||||
startedAt: string,
|
||||
endedAt: string,
|
||||
locale: string,
|
||||
timeFormat: TimeFormatPreference,
|
||||
): string {
|
||||
const start = new Date(startedAt);
|
||||
const end = new Date(endedAt);
|
||||
const dateFmt = getDateTimeFormat(locale, {
|
||||
weekday: "long",
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
const timeFmt = getDateTimeFormat(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
...hourCycleOptions(timeFormat),
|
||||
});
|
||||
return `${dateFmt.format(start)} · ${timeFmt.format(start)} – ${timeFmt.format(end)}`;
|
||||
}
|
||||
|
||||
// ── Header ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface WorkoutDetailHeaderProps {
|
||||
workout: WorkoutDetailPayload;
|
||||
}
|
||||
|
||||
function renderSportIconBadge(sportType: string) {
|
||||
const Icon = iconForSport(sportType);
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-muted text-muted-foreground flex size-12 shrink-0 items-center justify-center rounded-full"
|
||||
>
|
||||
<Icon className="size-6" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const timeFormat = useTimeFormatPreference();
|
||||
const sportLabelKey = `insights.workouts.sport.${workout.sportType}`;
|
||||
const sportLabel = t(sportLabelKey);
|
||||
const sportName =
|
||||
sportLabel === sportLabelKey ? workout.sportType : sportLabel;
|
||||
|
||||
return (
|
||||
<header
|
||||
data-slot="workout-detail-header"
|
||||
// v1.30 mobile audit (W-1) — the three columns (icon + title block +
|
||||
// duration) held at every width, so at 360px the middle column got
|
||||
// ~120px and the long-format date line wrapped 4-5 lines beside a
|
||||
// text-2xl duration. Stack below sm: duration drops under the title
|
||||
// block as its own row instead of squeezing beside it.
|
||||
className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4"
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
{renderSportIconBadge(workout.sportType)}
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="text-muted-foreground text-xs tracking-wide uppercase">
|
||||
{workout.source}
|
||||
</p>
|
||||
<h2 className="truncate text-lg font-semibold sm:text-xl">
|
||||
{sportName}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{formatDateRange(
|
||||
workout.startedAt,
|
||||
workout.endedAt,
|
||||
locale,
|
||||
timeFormat,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:flex-col sm:items-end sm:gap-0 sm:text-right">
|
||||
<span className="text-2xl font-semibold tabular-nums">
|
||||
{formatDuration(workout.durationSec)}
|
||||
</span>
|
||||
{workout.distanceM != null ? (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatDistanceKm(workout.distanceM, locale)} km
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stats ────────────────────────────────────────────────────────────
|
||||
|
||||
interface StatTileProps {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
function StatTile({ icon, label, value, hint }: StatTileProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="workout-detail-stat"
|
||||
className="flex flex-col gap-1 rounded-lg border p-3"
|
||||
>
|
||||
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||
<span aria-hidden="true" className="size-4">
|
||||
{icon}
|
||||
</span>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-base font-semibold tabular-nums">{value}</span>
|
||||
{hint ? (
|
||||
<span className="text-muted-foreground text-xs">{hint}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface WorkoutDetailStatsProps {
|
||||
workout: WorkoutDetailPayload;
|
||||
}
|
||||
|
||||
export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const tiles: StatTileProps[] = [];
|
||||
|
||||
tiles.push({
|
||||
icon: <Timer className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsDuration"),
|
||||
value: formatDuration(workout.durationSec),
|
||||
});
|
||||
|
||||
if (workout.distanceM != null && workout.distanceM > 0) {
|
||||
tiles.push({
|
||||
icon: <Map className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsDistance"),
|
||||
value: `${formatDistanceKm(workout.distanceM, locale)} km`,
|
||||
});
|
||||
}
|
||||
|
||||
if (workout.activeEnergyKcal != null) {
|
||||
tiles.push({
|
||||
icon: <Flame className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsActiveEnergy"),
|
||||
value: `${formatNumber(workout.activeEnergyKcal, locale)} kcal`,
|
||||
});
|
||||
}
|
||||
|
||||
if (workout.avgHr != null) {
|
||||
tiles.push({
|
||||
icon: <HeartPulse className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsAvgHr"),
|
||||
value: `${workout.avgHr} bpm`,
|
||||
hint:
|
||||
workout.maxHr != null
|
||||
? `${t("insights.workouts.detail.statsMaxHr")}: ${workout.maxHr} bpm`
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (workout.minHr != null) {
|
||||
tiles.push({
|
||||
icon: <HeartPulse className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsMinHr"),
|
||||
value: `${workout.minHr} bpm`,
|
||||
});
|
||||
}
|
||||
|
||||
if (workout.stepCount != null && workout.stepCount > 0) {
|
||||
tiles.push({
|
||||
icon: <Footprints className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsStepCount"),
|
||||
value: formatNumber(workout.stepCount, locale),
|
||||
});
|
||||
}
|
||||
|
||||
if (workout.elevationM != null && workout.elevationM !== 0) {
|
||||
tiles.push({
|
||||
icon: <Mountain className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsElevation"),
|
||||
value: `${formatNumber(workout.elevationM, locale, 1)} m`,
|
||||
});
|
||||
}
|
||||
|
||||
// Pace is only meaningful for foot- and ride-paced sports. Surface
|
||||
// it when the workout has both a distance and a sport with a
|
||||
// canonical pace unit; the fallback covers the long-tail HK enum
|
||||
// values without lighting up a misleading number.
|
||||
const PACE_SPORTS = new Set(["walking", "running", "hiking", "cycling"]);
|
||||
if (
|
||||
workout.distanceM != null &&
|
||||
workout.distanceM > 100 &&
|
||||
PACE_SPORTS.has(workout.sportType)
|
||||
) {
|
||||
tiles.push({
|
||||
icon: <Timer className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsPace"),
|
||||
value: formatPaceMinPerKm(workout.durationSec, workout.distanceM, locale),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card data-slot="workout-detail-stats">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("insights.workouts.detail.statsTitle")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent
|
||||
className={cn(
|
||||
"grid gap-2",
|
||||
"grid-cols-2 sm:grid-cols-3 lg:grid-cols-4",
|
||||
)}
|
||||
>
|
||||
{tiles.map((tile) => (
|
||||
<StatTile key={tile.label} {...tile} />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Route ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface WorkoutDetailRouteProps {
|
||||
workout: WorkoutDetailPayload;
|
||||
}
|
||||
|
||||
interface LineStringGeometry {
|
||||
type: "LineString";
|
||||
coordinates: Array<[number, number] | [number, number, number]>;
|
||||
}
|
||||
|
||||
function isLineString(value: unknown): value is LineStringGeometry {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return v.type === "LineString" && Array.isArray(v.coordinates);
|
||||
}
|
||||
|
||||
export function WorkoutDetailRoute({ workout }: WorkoutDetailRouteProps) {
|
||||
const { t } = useTranslations();
|
||||
const geometry = workout.route?.geometry;
|
||||
|
||||
if (!isLineString(geometry) || geometry.coordinates.length < 2) {
|
||||
return (
|
||||
<Card data-slot="workout-detail-route-empty">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("insights.workouts.detail.routeTitle")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-muted-foreground text-sm">
|
||||
{t("insights.workouts.detail.routeUnavailable")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// The map widget itself defers to v1.5.x (MapLibre / Leaflet not yet
|
||||
// pulled in). For v1.4.32 we paint a lightweight SVG polyline of the
|
||||
// route geometry — projection-free, normalised against the bounding
|
||||
// box, no third-party dependency. Resolution is fine on screen
|
||||
// because routes are bounded to 20 000 points by the ingest cap.
|
||||
const coordinates = geometry.coordinates;
|
||||
let minLon = Infinity;
|
||||
let maxLon = -Infinity;
|
||||
let minLat = Infinity;
|
||||
let maxLat = -Infinity;
|
||||
for (const [lon, lat] of coordinates) {
|
||||
if (lon < minLon) minLon = lon;
|
||||
if (lon > maxLon) maxLon = lon;
|
||||
if (lat < minLat) minLat = lat;
|
||||
if (lat > maxLat) maxLat = lat;
|
||||
}
|
||||
const padLon = (maxLon - minLon) * 0.05 || 0.0001;
|
||||
const padLat = (maxLat - minLat) * 0.05 || 0.0001;
|
||||
minLon -= padLon;
|
||||
maxLon += padLon;
|
||||
minLat -= padLat;
|
||||
maxLat += padLat;
|
||||
const VIEW_W = 400;
|
||||
const VIEW_H = 240;
|
||||
const points = coordinates
|
||||
.map(([lon, lat]) => {
|
||||
const x = ((lon - minLon) / (maxLon - minLon)) * VIEW_W;
|
||||
// SVG y-axis grows downward; flip lat → y.
|
||||
const y = VIEW_H - ((lat - minLat) / (maxLat - minLat)) * VIEW_H;
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<Card data-slot="workout-detail-route">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("insights.workouts.detail.routeTitle")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<svg
|
||||
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
|
||||
className="bg-muted/40 h-60 w-full rounded-md"
|
||||
role="img"
|
||||
aria-label={t("insights.workouts.detail.routeTitle")}
|
||||
>
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-primary"
|
||||
/>
|
||||
</svg>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Heart-rate chart ─────────────────────────────────────────────────
|
||||
|
||||
export function WorkoutDetailHRChart({
|
||||
workout,
|
||||
}: {
|
||||
workout: WorkoutDetailPayload;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// Per-second HR samples are not persisted on the Workout row today —
|
||||
// the W8d ingest schema stores aggregates only (avg / max / min).
|
||||
// The per-second series lives on the corresponding `Measurement`
|
||||
// rows posted alongside the workout via `POST /api/measurements/batch`.
|
||||
// Wiring that into the detail page consumes a future v1.5.x sprint
|
||||
// (see R-F T2 + W8d outline); for v1.4.32 the slot renders a
|
||||
// graceful empty state with the aggregates already surfaced on
|
||||
// `<WorkoutDetailStats>`.
|
||||
return (
|
||||
<Card data-slot="workout-detail-hr-chart">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("insights.workouts.detail.hrChartTitle")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-muted-foreground space-y-2 text-sm">
|
||||
<p>{t("insights.workouts.detail.hrChartUnavailable")}</p>
|
||||
{workout.avgHr != null || workout.maxHr != null ? (
|
||||
<p>
|
||||
{workout.avgHr != null
|
||||
? `${t("insights.workouts.detail.statsAvgHr")}: ${workout.avgHr} bpm`
|
||||
: null}
|
||||
{workout.avgHr != null && workout.maxHr != null ? " · " : ""}
|
||||
{workout.maxHr != null
|
||||
? `${t("insights.workouts.detail.statsMaxHr")}: ${workout.maxHr} bpm`
|
||||
: null}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Activity, Moon, Smile } from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
|
||||
interface DayLink {
|
||||
href: string;
|
||||
labelKey: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const LINKS: DayLink[] = [
|
||||
{
|
||||
href: "/insights/pulse",
|
||||
labelKey: "insights.workouts.detail.dayPulse",
|
||||
icon: Activity,
|
||||
},
|
||||
{
|
||||
href: "/insights/sleep",
|
||||
labelKey: "insights.workouts.detail.daySleep",
|
||||
icon: Moon,
|
||||
},
|
||||
{
|
||||
href: "/insights/mood",
|
||||
labelKey: "insights.workouts.detail.dayMood",
|
||||
icon: Smile,
|
||||
},
|
||||
];
|
||||
|
||||
export interface WorkoutDetailDayLinksProps {
|
||||
workout: WorkoutDetailPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* "That day" — a plain inline link row to the day's other signals
|
||||
* (intraday pulse, that night's sleep, mood). No reads; just navigation
|
||||
* so the workout sits in the context of the rest of the day.
|
||||
*/
|
||||
export function WorkoutDetailDayLinks({ workout }: WorkoutDetailDayLinksProps) {
|
||||
const { t } = useTranslations();
|
||||
void workout;
|
||||
|
||||
return (
|
||||
<Card data-slot="workout-detail-day-links">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={CalendarDays}
|
||||
title={t("insights.workouts.detail.thatDayTitle")}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{LINKS.map(({ href, labelKey, icon: Icon }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className="border-border hover:bg-muted inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors"
|
||||
>
|
||||
<Icon
|
||||
className="text-muted-foreground size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t(labelKey)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
import {
|
||||
hourCycleOptions,
|
||||
type TimeFormatPreference,
|
||||
} from "@/lib/format-locale";
|
||||
|
||||
/**
|
||||
* Shared formatters for the workout-detail surface. Pure helpers pulled
|
||||
* out of the former single `workout-detail.tsx` when it was split into a
|
||||
* `workout-detail/` directory (#67), so header / stats / splits format
|
||||
* durations and distances identically.
|
||||
*/
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
if (h > 0) {
|
||||
return `${h}h ${m.toString().padStart(2, "0")}m ${s.toString().padStart(2, "0")}s`;
|
||||
}
|
||||
return `${m}m ${s.toString().padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
/** Compact "34 min" style duration for the sport-average comparison line. */
|
||||
export function formatDurationMinutes(seconds: number, locale: string): string {
|
||||
const minutes = Math.round(seconds / 60);
|
||||
return formatNumber(minutes, locale);
|
||||
}
|
||||
|
||||
export function formatDistanceKm(meters: number, locale: string): string {
|
||||
const km = meters / 1000;
|
||||
return getNumberFormat(locale, {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: km < 10 ? 2 : 1,
|
||||
}).format(km);
|
||||
}
|
||||
|
||||
export function formatNumber(
|
||||
value: number,
|
||||
locale: string,
|
||||
fractionDigits = 0,
|
||||
): string {
|
||||
return getNumberFormat(locale, {
|
||||
maximumFractionDigits: fractionDigits,
|
||||
minimumFractionDigits: fractionDigits,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export function formatPaceMinPerKm(
|
||||
durationSec: number,
|
||||
meters: number,
|
||||
): string {
|
||||
// Seconds-per-kilometre. Only meaningful for run / walk / hike / ride;
|
||||
// the caller gates on distance + sport.
|
||||
const secPerKm = (durationSec / meters) * 1000;
|
||||
return formatPaceSeconds(secPerKm);
|
||||
}
|
||||
|
||||
/** "m:ss /km" from a seconds-per-km value (used by pace + splits). */
|
||||
export function formatPaceSeconds(secPerKm: number): string {
|
||||
const m = Math.floor(secPerKm / 60);
|
||||
const s = Math.round(secPerKm % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")} /km`;
|
||||
}
|
||||
|
||||
export function formatDateRange(
|
||||
startedAt: string,
|
||||
endedAt: string,
|
||||
locale: string,
|
||||
timeFormat: TimeFormatPreference,
|
||||
): string {
|
||||
const start = new Date(startedAt);
|
||||
const end = new Date(endedAt);
|
||||
const dateFmt = getDateTimeFormat(locale, {
|
||||
weekday: "long",
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
const timeFmt = getDateTimeFormat(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
...hourCycleOptions(timeFormat),
|
||||
});
|
||||
return `${dateFmt.format(start)} · ${timeFmt.format(start)} – ${timeFmt.format(end)}`;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
|
||||
import { iconForSport } from "@/lib/workouts/sport-icons";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
|
||||
import { formatDuration, formatDistanceKm, formatDateRange } from "./format";
|
||||
|
||||
/**
|
||||
* Hero header — the at-a-glance verdict row. Sport icon badge, source
|
||||
* provenance line, sport name, date/time range, big duration + distance.
|
||||
*/
|
||||
|
||||
export interface WorkoutDetailHeaderProps {
|
||||
workout: WorkoutDetailPayload;
|
||||
}
|
||||
|
||||
function renderSportIconBadge(sportType: string) {
|
||||
const Icon = iconForSport(sportType);
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-muted text-muted-foreground flex size-12 shrink-0 items-center justify-center rounded-full"
|
||||
>
|
||||
<Icon className="size-6" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const timeFormat = useTimeFormatPreference();
|
||||
const sportLabelKey = `insights.workouts.sport.${workout.sportType}`;
|
||||
const sportLabel = t(sportLabelKey);
|
||||
const sportName =
|
||||
sportLabel === sportLabelKey ? workout.sportType : sportLabel;
|
||||
|
||||
return (
|
||||
<header
|
||||
data-slot="workout-detail-header"
|
||||
// v1.30 mobile audit (W-1) — stack below sm so the long-format date
|
||||
// line never squeezes beside a text-2xl duration at 360px.
|
||||
className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4"
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
{renderSportIconBadge(workout.sportType)}
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="text-muted-foreground text-xs tracking-wide uppercase">
|
||||
{workout.source}
|
||||
</p>
|
||||
<h2 className="truncate text-lg font-semibold sm:text-xl">
|
||||
{sportName}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{formatDateRange(
|
||||
workout.startedAt,
|
||||
workout.endedAt,
|
||||
locale,
|
||||
timeFormat,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:flex-col sm:items-end sm:gap-0 sm:text-right">
|
||||
<span className="text-2xl font-semibold tabular-nums">
|
||||
{formatDuration(workout.durationSec)}
|
||||
</span>
|
||||
{workout.distanceM != null ? (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatDistanceKm(workout.distanceM, locale)} km
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { HeartPulse } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { importWithRetry } from "@/lib/retry-import";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
|
||||
// Route the HR curve through the ONE recharts async boundary so the
|
||||
// library stays a single shared chunk (chart-runtime.ts header rule).
|
||||
const WorkoutHrChartLazy = dynamic(
|
||||
() =>
|
||||
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
|
||||
(mod) => ({ default: mod.WorkoutHrChart }),
|
||||
),
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
);
|
||||
function WorkoutHrChart(props: ComponentProps<typeof WorkoutHrChartLazy>) {
|
||||
return (
|
||||
<ChartErrorBoundary>
|
||||
<WorkoutHrChartLazy {...props} />
|
||||
</ChartErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export interface WorkoutDetailHrSectionProps {
|
||||
workout: WorkoutDetailPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Heart-rate curve card — mean line + optional min→max envelope + %HRmax
|
||||
* zone bands + an average-HR reference line. A muted provenance chip
|
||||
* discloses when the curve was reconstructed from pulse data around the
|
||||
* session rather than the workout's own sensor stream. Returns `null`
|
||||
* when no series is available (hide, don't render empty).
|
||||
*/
|
||||
export function WorkoutDetailHrSection({
|
||||
workout,
|
||||
}: WorkoutDetailHrSectionProps) {
|
||||
const { t } = useTranslations();
|
||||
const series = workout.hrSeries;
|
||||
if (!series || series.points.length < 2) return null;
|
||||
|
||||
return (
|
||||
<Card data-slot="workout-detail-hr">
|
||||
<CardHeader className="gap-1">
|
||||
<TileHeader
|
||||
icon={HeartPulse}
|
||||
title={t("insights.workouts.detail.hrChartTitle")}
|
||||
/>
|
||||
{series.source === "pulse_window" ? (
|
||||
<p
|
||||
data-slot="workout-detail-hr-provenance"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("insights.workouts.detail.hrFromPulse")}
|
||||
</p>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<WorkoutHrChart
|
||||
points={series.points}
|
||||
bucketSec={series.bucketSec}
|
||||
envelope={series.envelope}
|
||||
avgHr={workout.avgHr}
|
||||
zones={workout.zones?.zones ?? null}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Barrel for the workout-detail surface (#67). The former single
|
||||
* `workout-detail.tsx` grew past a comfortable size once the HR curve,
|
||||
* zones, route SVG, splits and day-links landed, so it was split into
|
||||
* this directory. Import path stays `@/components/insights/workout-detail`.
|
||||
*/
|
||||
export { WorkoutDetailHeader } from "./header";
|
||||
export { WorkoutDetailStats } from "./stats";
|
||||
export { WorkoutDetailHrSection } from "./hr-section";
|
||||
export { WorkoutDetailZones } from "./zones";
|
||||
export { WorkoutDetailRoute } from "./route-map";
|
||||
export { WorkoutDetailSplits } from "./splits";
|
||||
export { WorkoutDetailDayLinks } from "./day-links";
|
||||
export {
|
||||
WorkoutInsightCard,
|
||||
type WorkoutActivityInsightData,
|
||||
} from "./insight-slot";
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* Reserved Activity-Insight seam (strategic-concept Wave C, bet 5).
|
||||
*
|
||||
* NOTHING renders here today: the workout payload's `aiInsight` field is
|
||||
* always `null`, so the page composes `{aiInsight ? <WorkoutInsightCard
|
||||
* insight={aiInsight}/> : null}` and this component never mounts. The
|
||||
* shape below is what the Phase-2 pg-boss job will write onto the
|
||||
* workout row — when it lands, the server maps the row to `aiInsight`,
|
||||
* the union in `use-workouts.ts` widens from `null` to
|
||||
* `WorkoutActivityInsightData | null`, and the card mounts right under
|
||||
* the hero header with zero layout rework. Non-diagnostic by
|
||||
* construction: a descriptive paragraph, device-attributed, never a
|
||||
* verdict.
|
||||
*/
|
||||
export interface WorkoutActivityInsightData {
|
||||
/** The cached, plain-text insight paragraph. Rendered as text children. */
|
||||
paragraph: string;
|
||||
/** ISO timestamp the paragraph was generated. */
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export function WorkoutInsightCard({
|
||||
insight,
|
||||
}: {
|
||||
insight: WorkoutActivityInsightData;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<Card data-slot="workout-detail-insight">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={Sparkles}
|
||||
title={t("insights.workouts.detail.insightTitle")}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm leading-relaxed">{insight.paragraph}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, type ComponentProps } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Map as MapIcon, Download } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
|
||||
import { importWithRetry } from "@/lib/retry-import";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
projectRoute,
|
||||
buildElevationProfile,
|
||||
buildGpxDocument,
|
||||
type RouteCoordinate,
|
||||
} from "@/lib/workouts/route-svg";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
|
||||
const WorkoutElevationChartLazy = dynamic(
|
||||
() =>
|
||||
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
|
||||
(mod) => ({ default: mod.WorkoutElevationChart }),
|
||||
),
|
||||
{ ssr: false, loading: () => <div className="h-24 w-full" /> },
|
||||
);
|
||||
function WorkoutElevationChart(
|
||||
props: ComponentProps<typeof WorkoutElevationChartLazy>,
|
||||
) {
|
||||
return (
|
||||
<ChartErrorBoundary>
|
||||
<WorkoutElevationChartLazy {...props} />
|
||||
</ChartErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
interface LineStringGeometry {
|
||||
type: "LineString";
|
||||
coordinates: RouteCoordinate[];
|
||||
}
|
||||
|
||||
function isLineString(value: unknown): value is LineStringGeometry {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return v.type === "LineString" && Array.isArray(v.coordinates);
|
||||
}
|
||||
|
||||
export interface WorkoutDetailRouteProps {
|
||||
workout: WorkoutDetailPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* GPS route card — a self-rendered, aspect-true SVG polyline over the
|
||||
* user's own stored geometry. Zero external requests: no tile server, no
|
||||
* geocoder, nothing leaves the origin (binding privacy/CSP property).
|
||||
* Start = filled `--success` dot, end = `--foreground` ring. Optional
|
||||
* elevation profile below when the track carries altitude. A GPX export
|
||||
* lets the user take their track with them — built client-side, zero
|
||||
* egress. Returns `null` when there is no usable geometry (hide, don't
|
||||
* render empty).
|
||||
*/
|
||||
export function WorkoutDetailRoute({ workout }: WorkoutDetailRouteProps) {
|
||||
const { t } = useTranslations();
|
||||
const geometry = workout.route?.geometry;
|
||||
const coordinates = isLineString(geometry) ? geometry.coordinates : null;
|
||||
|
||||
const projected = useMemo(
|
||||
() => (coordinates ? projectRoute(coordinates) : null),
|
||||
[coordinates],
|
||||
);
|
||||
const elevation = useMemo(
|
||||
() => (coordinates ? buildElevationProfile(coordinates) : null),
|
||||
[coordinates],
|
||||
);
|
||||
|
||||
if (!coordinates || !projected) return null;
|
||||
|
||||
const exportGpx = () => {
|
||||
const gpx = buildGpxDocument({
|
||||
coordinates,
|
||||
timestamps: workout.route?.sampleTimestamps ?? null,
|
||||
sportType: workout.sportType,
|
||||
startedAt: workout.startedAt,
|
||||
});
|
||||
const blob = new Blob([gpx], { type: "application/gpx+xml" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `workout-${workout.id}.gpx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const markerR = 1.6;
|
||||
|
||||
return (
|
||||
<Card data-slot="workout-detail-route">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={MapIcon}
|
||||
title={t("insights.workouts.detail.routeTitle")}
|
||||
right={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={exportGpx}
|
||||
data-slot="workout-detail-gpx-export"
|
||||
>
|
||||
<Download className="size-4" aria-hidden="true" />
|
||||
{t("insights.workouts.detail.exportGpx")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<svg
|
||||
viewBox={projected.viewBox}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
className="bg-muted/40 max-h-72 w-full rounded-md"
|
||||
role="img"
|
||||
aria-label={t("insights.workouts.detail.routeTitle")}
|
||||
>
|
||||
<path
|
||||
d={projected.path}
|
||||
fill="none"
|
||||
stroke="var(--chart-1)"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{/* End ring first so the start dot always sits on top on a loop. */}
|
||||
<circle
|
||||
cx={projected.end.x}
|
||||
cy={projected.end.y}
|
||||
r={markerR}
|
||||
fill="none"
|
||||
stroke="var(--foreground)"
|
||||
strokeWidth={1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<circle
|
||||
cx={projected.start.x}
|
||||
cy={projected.start.y}
|
||||
r={markerR}
|
||||
fill="var(--success)"
|
||||
/>
|
||||
</svg>
|
||||
{elevation ? (
|
||||
<div data-slot="workout-detail-elevation">
|
||||
<p className="text-muted-foreground mb-1 text-xs">
|
||||
{t("insights.workouts.detail.elevationTitle")}
|
||||
</p>
|
||||
<WorkoutElevationChart points={elevation} />
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { Flag } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
|
||||
import { formatPaceSeconds } from "./format";
|
||||
|
||||
function formatSplitTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.round(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export interface WorkoutDetailSplitsProps {
|
||||
workout: WorkoutDetailPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-kilometre splits table, derived server-side from the route
|
||||
* geometry + timestamps. Returns `null` when the workout has no
|
||||
* split data (hide, don't render empty). The fastest km is marked
|
||||
* with a discreet muted accent — descriptive, not a verdict.
|
||||
*/
|
||||
export function WorkoutDetailSplits({ workout }: WorkoutDetailSplitsProps) {
|
||||
const { t } = useTranslations();
|
||||
const splits = workout.splits;
|
||||
if (!splits || splits.length === 0) return null;
|
||||
|
||||
const fastest = Math.min(...splits.map((s) => s.paceSecPerKm));
|
||||
|
||||
return (
|
||||
<Card data-slot="workout-detail-splits">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={Flag}
|
||||
title={t("insights.workouts.detail.splitsTitle")}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground text-left text-xs">
|
||||
<th className="pb-2 font-medium">
|
||||
{t("insights.workouts.detail.splitsKm")}
|
||||
</th>
|
||||
<th className="pb-2 text-right font-medium">
|
||||
{t("insights.workouts.detail.splitsTime")}
|
||||
</th>
|
||||
<th className="pb-2 text-right font-medium">
|
||||
{t("insights.workouts.detail.splitsPace")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{splits.map((s) => (
|
||||
<tr key={s.km} className="border-t">
|
||||
<td className="py-1.5 tabular-nums">{s.km}</td>
|
||||
<td className="py-1.5 text-right tabular-nums">
|
||||
{formatSplitTime(s.durationSec)}
|
||||
</td>
|
||||
<td
|
||||
className={
|
||||
s.paceSecPerKm === fastest
|
||||
? "py-1.5 text-right font-semibold tabular-nums"
|
||||
: "py-1.5 text-right tabular-nums"
|
||||
}
|
||||
>
|
||||
{formatPaceSeconds(s.paceSecPerKm)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Flame,
|
||||
Footprints,
|
||||
HeartPulse,
|
||||
Map,
|
||||
Mountain,
|
||||
Timer,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
|
||||
import {
|
||||
formatDuration,
|
||||
formatDistanceKm,
|
||||
formatNumber,
|
||||
formatPaceMinPerKm,
|
||||
formatDurationMinutes,
|
||||
} from "./format";
|
||||
|
||||
interface StatTileProps {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
function StatTile({ icon, label, value, hint }: StatTileProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="workout-detail-stat"
|
||||
className="flex flex-col gap-1 rounded-lg border p-3"
|
||||
>
|
||||
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||
<span aria-hidden="true" className="size-4">
|
||||
{icon}
|
||||
</span>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-base font-semibold tabular-nums">{value}</span>
|
||||
{hint ? (
|
||||
<span className="text-muted-foreground text-xs">{hint}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface WorkoutDetailStatsProps {
|
||||
workout: WorkoutDetailPayload;
|
||||
}
|
||||
|
||||
const PACE_SPORTS = new Set(["walking", "running", "hiking", "cycling"]);
|
||||
|
||||
/**
|
||||
* Build the muted own-history comparison line ("Your recent average:
|
||||
* 5.8 km · 34 min · 148 bpm"). Own-history only — never a population
|
||||
* comparison (non-diagnostic standard). Rendered only when at least two
|
||||
* sessions of the sport exist, so it never restates the single workout
|
||||
* you are already looking at.
|
||||
*/
|
||||
function sportAverageLine(
|
||||
workout: WorkoutDetailPayload,
|
||||
locale: string,
|
||||
): string | null {
|
||||
const ctx = workout.sportContext;
|
||||
if (!ctx || ctx.count < 2) return null;
|
||||
const parts: string[] = [];
|
||||
if (ctx.avgDistanceM != null && ctx.avgDistanceM > 0) {
|
||||
parts.push(`${formatDistanceKm(ctx.avgDistanceM, locale)} km`);
|
||||
}
|
||||
parts.push(`${formatDurationMinutes(ctx.avgDurationSec, locale)} min`);
|
||||
if (ctx.avgAvgHr != null) parts.push(`${ctx.avgAvgHr} bpm`);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const tiles: StatTileProps[] = [];
|
||||
|
||||
tiles.push({
|
||||
icon: <Timer className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsDuration"),
|
||||
value: formatDuration(workout.durationSec),
|
||||
});
|
||||
|
||||
if (workout.distanceM != null && workout.distanceM > 0) {
|
||||
tiles.push({
|
||||
icon: <Map className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsDistance"),
|
||||
value: `${formatDistanceKm(workout.distanceM, locale)} km`,
|
||||
});
|
||||
}
|
||||
|
||||
if (workout.activeEnergyKcal != null) {
|
||||
tiles.push({
|
||||
icon: <Flame className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsActiveEnergy"),
|
||||
value: `${formatNumber(workout.activeEnergyKcal, locale)} kcal`,
|
||||
});
|
||||
}
|
||||
|
||||
if (workout.avgHr != null) {
|
||||
tiles.push({
|
||||
icon: <HeartPulse className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsAvgHr"),
|
||||
value: `${workout.avgHr} bpm`,
|
||||
hint:
|
||||
workout.maxHr != null
|
||||
? `${t("insights.workouts.detail.statsMaxHr")}: ${workout.maxHr} bpm`
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (workout.minHr != null) {
|
||||
tiles.push({
|
||||
icon: <HeartPulse className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsMinHr"),
|
||||
value: `${workout.minHr} bpm`,
|
||||
});
|
||||
}
|
||||
|
||||
if (workout.stepCount != null && workout.stepCount > 0) {
|
||||
tiles.push({
|
||||
icon: <Footprints className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsStepCount"),
|
||||
value: formatNumber(workout.stepCount, locale),
|
||||
});
|
||||
}
|
||||
|
||||
if (workout.elevationM != null && workout.elevationM !== 0) {
|
||||
tiles.push({
|
||||
icon: <Mountain className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsElevation"),
|
||||
value: `${formatNumber(workout.elevationM, locale, 1)} m`,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
workout.distanceM != null &&
|
||||
workout.distanceM > 100 &&
|
||||
PACE_SPORTS.has(workout.sportType)
|
||||
) {
|
||||
tiles.push({
|
||||
icon: <Timer className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsPace"),
|
||||
value: formatPaceMinPerKm(workout.durationSec, workout.distanceM),
|
||||
});
|
||||
}
|
||||
|
||||
const averageLine = sportAverageLine(workout, locale);
|
||||
|
||||
return (
|
||||
<Card data-slot="workout-detail-stats">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("insights.workouts.detail.statsTitle")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-2",
|
||||
"grid-cols-2 sm:grid-cols-3 lg:grid-cols-4",
|
||||
)}
|
||||
>
|
||||
{tiles.map((tile) => (
|
||||
<StatTile key={tile.label} {...tile} />
|
||||
))}
|
||||
</div>
|
||||
{averageLine ? (
|
||||
<p
|
||||
data-slot="workout-detail-sport-average"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("insights.workouts.detail.sportAverageLabel")}{" "}
|
||||
<span className="tabular-nums">{averageLine}</span>
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { Gauge } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
|
||||
const ZONE_COLORS = [
|
||||
"var(--chart-1)",
|
||||
"var(--chart-2)",
|
||||
"var(--chart-3)",
|
||||
"var(--chart-4)",
|
||||
"var(--chart-5)",
|
||||
];
|
||||
|
||||
function formatMinutes(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.round(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export interface WorkoutDetailZonesProps {
|
||||
workout: WorkoutDetailPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effort-zone distribution — a single horizontal stacked bar (%HRmax
|
||||
* zones) with minutes per zone underneath. Descriptive only: "how the
|
||||
* effort was distributed", never a training prescription or cardiac
|
||||
* read (non-diagnostic standard). Returns `null` when zones could not
|
||||
* be derived (no profile age and no device zones).
|
||||
*/
|
||||
export function WorkoutDetailZones({ workout }: WorkoutDetailZonesProps) {
|
||||
const { t } = useTranslations();
|
||||
const zones = workout.zones;
|
||||
if (!zones) return null;
|
||||
|
||||
const total = zones.zones.reduce((sum, z) => sum + z.seconds, 0);
|
||||
if (total <= 0) return null;
|
||||
|
||||
return (
|
||||
<Card data-slot="workout-detail-zones">
|
||||
<CardHeader className="gap-1">
|
||||
<TileHeader
|
||||
icon={Gauge}
|
||||
title={t("insights.workouts.detail.zonesTitle")}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{zones.model === "whoop"
|
||||
? t("insights.workouts.detail.zonesCaptionDevice")
|
||||
: t("insights.workouts.detail.zonesCaption")}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div
|
||||
className="flex h-3 w-full overflow-hidden rounded-full"
|
||||
role="img"
|
||||
aria-label={t("insights.workouts.detail.zonesTitle")}
|
||||
>
|
||||
{zones.zones.map((z, i) =>
|
||||
z.seconds > 0 ? (
|
||||
<div
|
||||
key={z.zone}
|
||||
style={{
|
||||
width: `${(z.seconds / total) * 100}%`,
|
||||
backgroundColor: ZONE_COLORS[i],
|
||||
}}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-1 text-center">
|
||||
{zones.zones.map((z, i) => (
|
||||
<div key={z.zone} className="flex flex-col items-center gap-1">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-2 rounded-full"
|
||||
style={{ backgroundColor: ZONE_COLORS[i] }}
|
||||
/>
|
||||
<span className="text-xs font-medium">Z{z.zone}</span>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatMinutes(z.seconds)}
|
||||
</span>
|
||||
{z.lowBpm != null ? (
|
||||
<span className="text-muted-foreground text-[10px] tabular-nums">
|
||||
{z.lowBpm}
|
||||
{z.highBpm != null ? `–${z.highBpm}` : "+"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Map as MapIcon, HeartPulse } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
@@ -106,7 +107,26 @@ export function WorkoutList({ workouts, className }: WorkoutListProps) {
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate font-medium">{sportName}</span>
|
||||
<span className="flex items-center gap-1.5 truncate font-medium">
|
||||
{sportName}
|
||||
{/* #67 — discreet glyphs flag which sessions open rich
|
||||
(map / HR curve). Muted so they never compete with
|
||||
the sport name. */}
|
||||
{workout.hasRoute ? (
|
||||
<MapIcon
|
||||
className="text-muted-foreground size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
data-slot="workout-list-glyph-route"
|
||||
/>
|
||||
) : null}
|
||||
{workout.hasHrSeries ? (
|
||||
<HeartPulse
|
||||
className="text-muted-foreground size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
data-slot="workout-list-glyph-hr"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{formatDate(workout.startedAt, locale, timeFormat)}
|
||||
</span>
|
||||
|
||||
@@ -508,7 +508,7 @@ describe("<TakeAllDueDialog> — confirm dialog lists the due medications", () =
|
||||
"utf8",
|
||||
);
|
||||
const pageSrc = readFileSync(
|
||||
resolve(__dirname, "../../../app/medications/page.tsx"),
|
||||
resolve(__dirname, "../../../app/medications/page-client.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
|
||||
@@ -54,10 +54,10 @@ export interface ResolveDisplayedSlotInstantInput {
|
||||
/** Wall-clock reference (injected for testability). */
|
||||
now: Date;
|
||||
/**
|
||||
* IANA timezone the card's day boundary is reasoned in. The cards model
|
||||
* the user's day in Europe/Berlin (see `toBerlinDate`), so this defaults
|
||||
* to that; passing the user's actual zone keeps the slot on the right
|
||||
* calendar day across the rare cross-midnight edge.
|
||||
* IANA timezone the card's day boundary is reasoned in. Berlin is the
|
||||
* last-resort fallback the card surfaces share; passing the user's
|
||||
* actual zone keeps the slot on the right calendar day across the rare
|
||||
* cross-midnight edge.
|
||||
*/
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
@@ -27,10 +27,7 @@ import { getDayOfWeekInTz } from "@/lib/tz/local-day";
|
||||
import { type InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog";
|
||||
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
|
||||
import {
|
||||
reduceCurrentWindowStatus,
|
||||
toZonedDate,
|
||||
} from "@/lib/medications/window-status";
|
||||
import { reduceCurrentWindowStatus } from "@/lib/medications/window-status";
|
||||
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
|
||||
import {
|
||||
estimateDailyDoseCount,
|
||||
@@ -325,12 +322,11 @@ export function Glp1MedicationCard({
|
||||
a.windowStart.localeCompare(b.windowStart) ||
|
||||
a.windowEnd.localeCompare(b.windowEnd),
|
||||
);
|
||||
const nowBerlin = toZonedDate(now, userTz);
|
||||
const lateMinutes = thresholds?.lateMinutes ?? 120;
|
||||
const missedMinutes = thresholds?.missedMinutes ?? 240;
|
||||
const currentWindowStatus = reduceCurrentWindowStatus({
|
||||
schedules: sortedSchedules,
|
||||
nowBerlin,
|
||||
now,
|
||||
lateMinutes,
|
||||
missedMinutes,
|
||||
active: medication.active,
|
||||
|
||||
@@ -13,10 +13,8 @@ import { formatDateTime, formatTime } from "@/lib/format";
|
||||
import { getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
|
||||
import { formatDose } from "@/lib/medications/format-dose";
|
||||
import {
|
||||
reduceCurrentWindowStatus,
|
||||
toZonedDate,
|
||||
} from "@/lib/medications/window-status";
|
||||
import { reduceCurrentWindowStatus } from "@/lib/medications/window-status";
|
||||
import { resolveNextDueDayLabel } from "@/lib/medications/next-due-day-label";
|
||||
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
|
||||
import {
|
||||
estimateDailyDoseCount,
|
||||
@@ -242,7 +240,7 @@ export function MedicationCard({
|
||||
a.windowStart.localeCompare(b.windowStart) ||
|
||||
a.windowEnd.localeCompare(b.windowEnd),
|
||||
);
|
||||
const nowBerlin = toZonedDate(new Date(), userTz);
|
||||
const now = new Date();
|
||||
// v1.8.4 — the next-due instant comes from the server (`nextDueAt`,
|
||||
// computed by the canonical recurrence engine anchored on the last
|
||||
// intake). The day label below derives from it; the window-range /
|
||||
@@ -261,7 +259,7 @@ export function MedicationCard({
|
||||
|
||||
const currentWindowStatus = reduceCurrentWindowStatus({
|
||||
schedules: sortedSchedules,
|
||||
nowBerlin,
|
||||
now,
|
||||
lateMinutes,
|
||||
missedMinutes,
|
||||
active: medication.active,
|
||||
@@ -418,27 +416,15 @@ export function MedicationCard({
|
||||
// Format day label relative to today
|
||||
let dayLabel = "";
|
||||
if (nextAt) {
|
||||
const nextDate = toZonedDate(new Date(nextAt), userTz);
|
||||
const todayStr = `${nowBerlin.getFullYear()}-${nowBerlin.getMonth()}-${nowBerlin.getDate()}`;
|
||||
const nextStr = `${nextDate.getFullYear()}-${nextDate.getMonth()}-${nextDate.getDate()}`;
|
||||
const tomorrow = new Date(nowBerlin);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const tomorrowStr = `${tomorrow.getFullYear()}-${tomorrow.getMonth()}-${tomorrow.getDate()}`;
|
||||
|
||||
const diffDays = Math.round(
|
||||
(nextDate.getTime() - nowBerlin.getTime()) /
|
||||
(24 * 60 * 60 * 1000),
|
||||
);
|
||||
|
||||
if (nextStr === todayStr) {
|
||||
dayLabel = t("medications.today");
|
||||
} else if (nextStr === tomorrowStr) {
|
||||
dayLabel = t("medications.tomorrow");
|
||||
} else if (diffDays <= 5) {
|
||||
dayLabel = weekdayLabel(nextDate.getDay());
|
||||
} else {
|
||||
dayLabel = fmt.dateWithWeekdaySmart(nextDate);
|
||||
}
|
||||
const label = resolveNextDueDayLabel(new Date(nextAt), now, userTz);
|
||||
dayLabel =
|
||||
label.kind === "today"
|
||||
? t("medications.today")
|
||||
: label.kind === "tomorrow"
|
||||
? t("medications.tomorrow")
|
||||
: label.kind === "weekday"
|
||||
? weekdayLabel(label.weekday)
|
||||
: fmt.dateWithWeekdaySmart(label.instant);
|
||||
}
|
||||
|
||||
// v1.16.1 — the TIME is the canonical next-due instant (the
|
||||
|
||||
@@ -28,10 +28,8 @@ import { useWeekdayLabel } from "@/components/medications/card-parts/medication-
|
||||
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
|
||||
import { estimateRunwayDays } from "@/components/medications/detail/supply-runway";
|
||||
import { useMedicationIntake } from "@/components/medications/use-medication-intake";
|
||||
import {
|
||||
reduceCurrentWindowStatus,
|
||||
toZonedDate,
|
||||
} from "@/lib/medications/window-status";
|
||||
import { reduceCurrentWindowStatus } from "@/lib/medications/window-status";
|
||||
import { resolveNextDueDayLabel } from "@/lib/medications/next-due-day-label";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import { formatTimeWindowRange } from "@/lib/time-window-format";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
@@ -420,7 +418,6 @@ function MedicationTableRowItem({
|
||||
a.windowEnd.localeCompare(b.windowEnd),
|
||||
);
|
||||
const now = new Date();
|
||||
const nowZoned = toZonedDate(now, userTz);
|
||||
const nextDueMs = medication.nextDueAt
|
||||
? new Date(medication.nextDueAt).getTime()
|
||||
: NaN;
|
||||
@@ -431,7 +428,7 @@ function MedicationTableRowItem({
|
||||
// next dose is tomorrow can never paint an overdue pill today.
|
||||
const currentWindowStatus = reduceCurrentWindowStatus({
|
||||
schedules: sortedSchedules,
|
||||
nowBerlin: nowZoned,
|
||||
now,
|
||||
lateMinutes,
|
||||
missedMinutes,
|
||||
active: medication.active,
|
||||
@@ -535,23 +532,15 @@ function MedicationTableRowItem({
|
||||
</span>
|
||||
);
|
||||
} else if (nextAt) {
|
||||
const nextDate = toZonedDate(new Date(nextAt), userTz);
|
||||
const todayStr = `${nowZoned.getFullYear()}-${nowZoned.getMonth()}-${nowZoned.getDate()}`;
|
||||
const nextStr = `${nextDate.getFullYear()}-${nextDate.getMonth()}-${nextDate.getDate()}`;
|
||||
const tomorrow = new Date(nowZoned);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const tomorrowStr = `${tomorrow.getFullYear()}-${tomorrow.getMonth()}-${tomorrow.getDate()}`;
|
||||
const diffDays = Math.round(
|
||||
(nextDate.getTime() - nowZoned.getTime()) / (24 * 60 * 60 * 1000),
|
||||
);
|
||||
const label = resolveNextDueDayLabel(new Date(nextAt), now, userTz);
|
||||
const dayLabel =
|
||||
nextStr === todayStr
|
||||
label.kind === "today"
|
||||
? t("medications.today")
|
||||
: nextStr === tomorrowStr
|
||||
: label.kind === "tomorrow"
|
||||
? t("medications.tomorrow")
|
||||
: diffDays <= 5
|
||||
? weekdayLabel(nextDate.getDay())
|
||||
: fmt.dateWithWeekdaySmart(nextDate);
|
||||
: label.kind === "weekday"
|
||||
? weekdayLabel(label.weekday)
|
||||
: fmt.dateWithWeekdaySmart(label.instant);
|
||||
nextCell = (
|
||||
<span>
|
||||
{dayLabel}, {formatTime(new Date(nextAt).toISOString())}
|
||||
|
||||
@@ -42,7 +42,6 @@ import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
reduceCurrentWindowStatus,
|
||||
toZonedDate,
|
||||
type ScheduleWindowInput,
|
||||
} from "@/lib/medications/window-status";
|
||||
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
|
||||
@@ -133,7 +132,6 @@ export function deriveDueMedications(
|
||||
const tz = options.tz ?? "Europe/Berlin";
|
||||
const { lateMinutes, missedMinutes } =
|
||||
options.thresholds ?? DEFAULT_THRESHOLDS;
|
||||
const nowLocal = toZonedDate(now, tz);
|
||||
|
||||
const due: DueMedication[] = [];
|
||||
for (const m of medications) {
|
||||
@@ -146,7 +144,7 @@ export function deriveDueMedications(
|
||||
const nextDueMs = m.nextDueAt ? new Date(m.nextDueAt).getTime() : NaN;
|
||||
const status = reduceCurrentWindowStatus({
|
||||
schedules: m.schedules,
|
||||
nowBerlin: nowLocal,
|
||||
now,
|
||||
lateMinutes,
|
||||
missedMinutes,
|
||||
active: m.active,
|
||||
|
||||
Loaded 100 of 233 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user