Compare commits

..
50 Commits
Author SHA1 Message Date
Marc-André Bombeck 40ad2dfc96 Merge pull request #541 from MBombeck/release/v1.30.17
release/v1.30.17 — API tokens now enforce their scope
2026-07-19 01:25:37 +02:00
Marc-André Bombeck 586c4318ca chore(release): v1.30.17 — enforce Bearer token scope 2026-07-19 01:13:11 +02:00
Marc-André Bombeck ff6956decb test(auth): pin the fail-closed Bearer-scope contract
Four structural guards freeze the invariants the change rests on: the set of
files that can resolve a Bearer token to a user, the single deliberate
any-valid-token posture, the mint sites with their scope sets, and the rule
that a declared scope is an exported constant rather than a literal.

Thirteen behavioural cases run against a real Postgres and the real resolver.
Four of them go red on the pre-fix tree — a narrow token reading the
full-backup export and the labs surface, and an MCP token on a REST read.
The rest pin the flows that must NOT change: the per-medication ingest pair
still works, the token without the per-medication grant is still refused, a
wildcard token still reaches the export and the batch ingest, an MCP token
still resolves on /mcp with its write scope intact, a declared fhir:read
still opens the FHIR face and nothing else, cookie sessions are unaffected,
and requireAdmin still refuses a wildcard Bearer.

The workout-batch case that asserted narrow tokens are admitted claimed the
iOS sync depended on it. It does not: workouts:ingest has no mint site
anywhere, and iOS holds a ["*"] token. Replaced with a case that asserts both
halves of the real contract.

Documentation updated for the enforcement rule and the retired mint.
2026-07-19 01:07:43 +02:00
Marc-André Bombeck 8012aa095b feat(tokens): retire the generic API-token mint
POST /api/tokens minted ["medication:ingest"], and that token could never do
the job the settings copy advertised: /api/ingest/medication gates on the
per-medication medication:<id>:ingest grant as well, which this endpoint never
issued. It was refused at the ingest surface and admitted everywhere else.

With Bearer scope now fail-closed the token is inert rather than dangerous,
which is a worse thing to hand a user than the bug it replaces. Remove the
mint. The working credential comes from the per-medication API-endpoint
toggle, which issues both grants scoped to one medication.

List and revoke stay so existing tokens remain visible and revocable, and the
settings card now points at the per-medication toggle instead of offering a
name field. Six locales updated.
2026-07-19 00:51:29 +02:00
Marc-André Bombeck df611227f9 fix(auth): make Bearer scope fail closed on every route
resolveBearerToken took an optional permission, and an unset one admitted
any valid token. 324 of the 330 route files pass no permission, so a token
minted with a narrow scope reached the full-backup export, the labs and
measurement surfaces, the coach, and the bulk deletes — the settings copy
tells users to hand exactly that token to third-party automations.

Invert the default. The scope argument becomes a required ScopeRequirement
union: a route that names no scope now accepts cookie sessions and
cookie-equivalent ["*"] tokens only, and refuses a narrow token with 403 and
an undeclared_scope audit row. A route that means to accept a narrow token
names it, as the FHIR routes already do.

No route file changes. The absence of an argument becomes a positive
statement rather than an omission, so a newly added route denies a newly
minted narrow scope by default — the safe outcome is the one you get by
doing nothing.

/mcp keeps its own posture through an explicit any-valid-token requirement:
it authenticates here and authorises downstream on audience and write scope.
That is the single deliberate fail-open call site in the tree, and making the
parameter required is what keeps it greppable.

An MCP-audience token loses its REST read leg as a result, narrowing it to
/mcp only. The isMcpAudienceToken guard stays as defence in depth.
2026-07-19 00:44:10 +02:00
Marc-André Bombeck 17033f9966 Merge pull request #540 from MBombeck/release/v1.30.16
release/v1.30.16 — stop an ongoing loss of resting heart-rate history
2026-07-19 00:25:14 +02:00
Marc-André Bombeck 20ff0c1868 fix(measurements): stop the derived resting mint from suppressing itself
The dense-intraday fold tombstones the raw heart-rate readings a proxy
account's resting figure is derived from, and mints one derived
RESTING_HEART_RATE row per folded day to carry the figure forward. The
mint is skipped for accounts that already hold native resting rows,
because the read resolver ignores the proxy for them.

The probe behind that skip matched every source, including the COMPUTED
rows the mint itself writes. From the second run onward it therefore
reported native resting data for every proxy account, skipped the mint,
and let the fold delete that day's raw readings anyway - losing the
resting figure permanently, once per run.

Scope the probe to non-computed sources. Adds a regression test that
reproduces the second-run state and fails on the previous code.

chore(release): v1.30.16
2026-07-19 00:13:54 +02:00
Marc-André Bombeck 1920321e44 Merge pull request #539 from MBombeck/release/v1.30.15
release/v1.30.15 — write generated health texts in the account's own language
2026-07-19 00:06:05 +02:00
Marc-André Bombeck e04383f015 chore(release): v1.30.15 — write generated health texts in the account's own language 2026-07-18 23:54:56 +02:00
Marc-André Bombeck d0a74e37da fix(ai): keep the output-language directive terminal on composed prompts
getBaseSystemPrompt appended the directive, then each metric module
appended its own section after it, so the directive was no longer the
last instruction the model reads - the one property that makes it
authoritative. The base now exposes its body, and every composing module
wraps the finished prompt so the directive ends it.

Also drops a duplicate import that survived the merge of the two prompt
branches.
2026-07-18 23:54:09 +02:00
Marc-André Bombeck 5c83156dcf Merge branch 'wt/l4-pipeline' into release/v1.30.15 2026-07-18 23:48:03 +02:00
Marc-André Bombeck f8c4032515 Merge branch 'wt/l5-narrative' into release/v1.30.15 2026-07-18 23:48:03 +02:00
Marc-André Bombeck d6692e3a4b fix(coach): compose deterministic memory facts in English by default
The deterministic fact-pattern pass fell back to German when no locale was
passed, the same de-default polarity the narrative surface just lost. Fact
text is model-facing memory, so English is the fallback for every locale
without a reviewed body; German stays for German readers.

Also correct the InsightNarrative.locale doc comment — the column holds any
shipped UI locale, not just de/en. Comment only, no SQL change.
2026-07-18 23:46:34 +02:00
Marc-André Bombeck ae3d291feb refactor(insights): name the instruction-body rule on the derived grid
The derived batch grid carries only the deterministic assessment, whose
templates ship de/en bodies. Its `locale === "de" ? "de" : "en"` was
correct but indistinguishable from the accidental binaries that were just
removed; route it through instructionLocale so the intent is explicit.
Behaviour unchanged.
2026-07-18 23:33:37 +02:00
Marc-André Bombeck 93738ae968 test(insights): pin the period-narrative and coach output-language contract
Three layers. The German and English narrative prompts are frozen by content
hash, normalised for the prompt-version string that a prompt change is
required to move — any other drift fails. The four riding locales must name
their language, carry the verbatim reply-language directive from their own
safety-contract file as the last instruction, and contain no German-body
sentinel. A generator-level test drives a French run end to end and asserts
the row is keyed fr, the cache action is locale-scoped, and the system prompt
reaches the provider in English with the French directive.

Alongside it a source-level ban on the exact shape the bug took
(`=== "en" ? "en" : "de"`, `?? "de"`) across the coach and narrative
surfaces, with a self-check so a typo in the pattern cannot turn the guard
into a silent pass, and coach-memory recall cases for all four locales.
2026-07-18 23:32:49 +02:00
Marc-André Bombeck 81065f3f9d test(insights): pin the reader's locale from entry point to prompt
Covers the assertion that would have caught the narrowing: normalizeLocale
passes all six locales through and defaults an unknown value to English, the
de/en cache keys stay byte-identical while the four widened locales get their
own, and a fr locale entering a status generator reaches the prompt builder
as fr and writes the fr cache row. The forced-warm case pins the same for the
comprehensive and per-status warms.
2026-07-18 23:30:57 +02:00
Marc-André Bombeck decc6681f6 test(ai): pin the output-language adoption across the prompt family
Holds every sibling prompt module to one invariant: strip the directive
and the interpolated language name out of a French, Spanish, Italian or
Polish prompt and what remains is the English prompt, byte for byte. A
German instruction body leaking back into a non-German prompt, an
English body drifting away from what the four locales receive, and a
module appending a second directive on top of the one the base prompt
already carries all fail here.

Also drops the now-unused narrow locale alias from the two modules whose
prompt builders take the full locale.
2026-07-18 23:29:25 +02:00
Marc-André Bombeck e4ec0eec49 fix(insights): stop sending German period narratives to fr/es/it/pl readers
The narrative read route narrowed every non-English locale to German, so a
French, Spanish, Italian or Polish account read the German narrative row and
warmed it from the German prompt body. Carry the resolved locale through
instead: the row is keyed (user, period, locale) across the full UI locale
union, and the four locales now compose the English instruction body with
their language named and the locale's own reply-language directive appended
last. The German and English prompts are unchanged apart from the version
string.

The nightly warm keeps the user's stored locale for the same reason — it has
to write the key the read route reads — and the coach and dashboard narrative
recalls read that same key. Coach memory stays de/en: it is model-facing
context, not user-facing prose, so the four locales compose and key English
rather than German.

NARRATIVE_PROMPT_VERSION 1.13.0 -> 1.14.0.
2026-07-18 23:25:44 +02:00
Marc-André Bombeck 37c33d0205 fix(insights): carry the reader's locale through the assessment pipeline
The per-metric assessment pipeline typed every locale as `de | en` and
narrowed on the way in, so a French, Spanish, Italian or Polish account
never reached a prompt with its own locale — the output-language directive
could not fire and the reader got English, or German where the binary
defaulted that way.

Widen `SupportedLocale` to the six locales the UI ships and replace the
narrowing binaries with one validating `normalizeLocale` that passes a
recognised locale through unchanged and defaults an unknown value to
English. The status-generate payload, status batch, status cache,
comprehensive generation, the pre-generate warm passes and the forced-warm
handlers all carry the full locale now.

Fix the collapse sites that discarded it: the warm enqueue on the
comprehensive route and the pregenerate route, the nightly batch cron, and
the forced-warm job handler — the last two defaulted to German outright.
The deterministic derived-score templates and the previous-context block
ship de/en bodies only, so they route through `instructionLocale` (German
for German readers, English otherwise) while the prompt names the reader's
own language.
2026-07-18 23:23:40 +02:00
Marc-André Bombeck 13197c7ee9 fix(ai): route the score, biomarker and coach prompts by instruction locale
The derived-score archetype and the Coach preference overrides branched
on the raw locale, so fr/es/it/pl composed German instruction text. Both
now route through `instructionLocale`. The score archetype rides on
`getBaseSystemPrompt` and therefore appends no directive of its own —
the base prompt already carries it.

The biomarker assessment is the one prompt in the family that does not
compose the base scaffold, so it appends the reader's directive itself;
the append collapses into the shared path once that prompt moves onto
the base scaffold.

The two prompt builders in each of those modules now take the full
`Locale` rather than the narrowed de/en alias, so the routing is right
the moment the pipeline stops collapsing the four locales on the way in.

Also targets the output-language contract test's regex at the compile
target instead of the dotAll flag, which does not compile.

German and English prompts are unchanged, byte for byte.
2026-07-18 23:22:15 +02:00
Marc-André Bombeck fff84ea84f fix(ai): route the metric assessment prompts by instruction locale
The per-metric system and user prompt builders branched
`locale === "en" ? EN : DE`, so a French, Spanish, Italian or Polish
reader composed the German instruction body — whose output clause asks
for German prose. Route them through `instructionLocale` instead, so
German is the only locale that takes the German body and everyone else
takes the English one plus the reader's own output-language directive
already carried by the base prompt.

The German and English prompts are unchanged, byte for byte.
2026-07-18 23:18:58 +02:00
Marc-André Bombeck 2032b74fc6 test(ai): pin the output-language contract across all six locales
The parity test joined raw fragments, which no longer matches now that
the English output clause carries a language token; it substitutes the
expected name from a literal map instead of from the module under test.

Adds the contract test: which body each locale composes, that its own
language is named, that its own translated directive is present and
last, that no German reaches a non-German prompt, and that no two
locales collapse to the same prompt. What it cannot show - whether the
prose reads well in the four languages - is stated in the file.
2026-07-18 23:14:45 +02:00
Marc-André Bombeck ec4e3adfdc fix(ai): write assessments in the reader's language, not German
getBaseSystemPrompt collapsed every non-English locale to the German
instruction body, whose output clause asks for a German assessment. A
French, Spanish, Italian or Polish reader therefore received German
prose on any surface that reached this prompt.

Those four locales now compose the English instruction body with their
language named in the output clause, and the locale's own reply-language
directive - already carried in production by the native insights and
briefing prompts - appended last. German and English compose exactly as
before, byte for byte.

Adds the shared output-language helper the remaining prompt modules
adopt, replacing the scattered en-or-else-German binaries.
2026-07-18 23:13:34 +02:00
Marc-André Bombeck 7fa6498ad8 chore(hooks): reject tooling co-author trailers at commit time
The voice rule states that commits are authored by the maintainer's git
identity alone. Editors and CLIs append co-author trailers and session
links by default, and once such a line is in the object store only a
history rewrite removes it — so enforce it before the commit exists.

The guard rejects tooling co-author trailers, session links, and
generated-with lines; human co-authors and ordinary messages pass.
2026-07-18 23:05:12 +02:00
Marc-André Bombeck 15d63c3842 Merge pull request #538 from MBombeck/release/v1.30.14
release/v1.30.14 — server-prefetch the workouts list; correct the aggregated heart-rate bucket docs
2026-07-18 22:53:31 +02:00
Marc-André Bombeck 5b464b26de chore(release): v1.30.14 — server-prefetch the workouts list; correct the aggregated heart-rate bucket docs
Move the workouts list read into a shared cached module so the RSC
wrapper and GET /api/workouts share one deduplicated projection pass,
and dehydrate it under the client cell's exact query key so the page
paints its rows instead of a loading skeleton.

Correct the aggregated heart-rate externalId documentation: the example
and the min/max field notes still described the original hourly bucket,
while the accepted contract has been 10-minute buckets since v1.30.7.
2026-07-18 22:42:23 +02:00
Marc-André Bombeck 521f5d354c Merge pull request #537 from MBombeck/release/v1.30.13
release/v1.30.13 — server-prefetch the insights, medications, and coach pages
2026-07-18 20:55:36 +02:00
Marc-André Bombeck 7802903d93 chore(release): v1.30.13 — server-prefetch the insights, medications, and coach pages 2026-07-18 20:38:35 +02:00
Marc-André Bombeck e0387c5ceb perf(prefetch): server-prefetch above-the-fold data for /insights, /medications, /coach
Extend the dashboard's RSC server-prefetch pattern to three more route roots
so their above-the-fold content paints populated on first load instead of
flashing a shell that fills in on mount.

Each route gains a thin RSC wrapper that runs the same cached read its API
route uses, dehydrates the result under the exact client query key, and hands
it to the client leaf through a HydrationBoundary. Every wrapper fails soft to
today's client-fetch path on any error and honours the DASHBOARD_SSR_PREFETCH
kill-switch the e2e server sets.

- /insights: prefetch the dashboard snapshot — the same locale-keyed cell the
  dashboard warms, resolved once server-side so the key matches by
  construction. The AI briefing and the heavy analytics aggregations stay
  client-lazy (the briefing must never trigger generation; a cold analytics
  read would trade FCP for TTFB).
- /medications: prefetch the medications list through the shared
  caches.medications cell (extracted to src/lib/medications/list-read.ts). The
  list cell keeps refetchOnMount: "always" — a cross-device take/skip produces
  no client mutation event, so the mount refetch is a correctness gate; the
  prefetch removes the initial skeleton flash without dropping that guarantee.
- /coach: prefetch the nudge status (extracted to
  src/lib/ai/coach/nudge-status.ts) so the auto-open-most-recent decision is
  ready at hydrate, collapsing the one sequential waterfall. Only runs when the
  Coach surface is reachable; the streaming conversation is never warmed.

Add per-route tests pinning server-key === client-key, JSON wire-shape
round-trip, and the fail-soft / module-gate paths. Repoint the source-reading
insights, take-all-due, and Coach gate-cascade tests at the client leaves.
2026-07-18 20:36:19 +02:00
Marc-André Bombeck 3743a46a40 Merge pull request #536 from MBombeck/release/v1.30.12
release/v1.30.12 — index the dashboard medication reads
2026-07-18 18:27:30 +02:00
Marc-André Bombeck 9e2c445ad7 chore(release): v1.30.12 — index the dashboard medication reads 2026-07-18 18:04:41 +02:00
Marc-André Bombeck b75771a445 perf(medications): index the dashboard meds-today read shapes
Two additive indexes on medication_intake_events: (user_id, scheduled_for)
for the sibling reads that filter without medication_id, and
(user_id, medication_id, taken_at) for the per-medication last-taken
group-by that scanned the whole intake history on every dashboard load.
Additive migration 0253, no data change, no behaviour change.
2026-07-18 18:04:26 +02:00
Marc-André Bombeck 64e7c57734 Merge pull request #535 from MBombeck/release/v1.30.11
release/v1.30.11 — native OIDC SSO sign-in
2026-07-18 17:31:32 +02:00
Marc-André Bombeck c2420d7f02 chore(release): v1.30.11 — native OIDC SSO sign-in 2026-07-18 17:03:35 +02:00
Marc-André Bombeck 16fd4966d0 fix(auth): force the native-bundle branch on the OIDC token exchange
Adversarial review found the transport gate (isCookielessNativeCaller) was
broader than finishLogin's bearer predicate: a blank-UA caller with no
X-Client-Type could pass it and fall into the cookie branch, receiving a
Set-Cookie session instead of the token bundle (inert, not exploitable, but
the endpoint's own invariant was false). Also require isNativeClientRequest
so the cookie path is structurally unreachable here, and report the actual
revoke outcome on a replay so the wide event never overclaims.
2026-07-18 17:02:45 +02:00
Marc-André Bombeck ada147522f test(auth): cover the native OIDC SSO handoff
Unit-test the handoff library (mint stores only the hash + binds the
challenge + 90s TTL; consume covers not-found / replay-with-revoke /
expiry-burn / PKCE-burn / atomic consume / lost-race), the
token-exchange route (rate limit, transport gate, one identical generic
401 across every invalid-code class, success with issued-hash stamp +
audit, and OIDC_ONLY does not block it), the callback native branches
(handoff mint + scheme redirect with no session, MFA ticket on the
scheme with no session/cookie, post-identity + IdP errors to the
scheme, CSRF web-fallback), the login native-param handling, and the
by-hash refresh-token revoke used for replay containment.
2026-07-18 16:50:56 +02:00
Marc-André Bombeck e6d9cc229a feat(auth): native OIDC SSO session handoff for the iOS client
The web OIDC flow ends in a session cookie the Bearer-transport native
client cannot consume, so on an OIDC_ONLY instance the iOS app had no
sign-in. Add a native leg: the app opens the login URL with
client=native and its own PKCE S256 challenge inside an in-app browser;
the callback resolves identity exactly as before, then mints a one-time
handoff code and 302s to the fixed compiled-in scheme
healthlog://oidc-callback (or hands back the existing MFA ticket when
the account has a second factor). The app exchanges the code plus its
PKCE verifier at POST /api/auth/oidc/native/token for the standard
native token bundle.

The return address is a compile-time constant — no redirect_uri
parameter and no env allowlist, so the open-redirect / scheme-hijack
class is removed by construction. The native flag and app challenge
travel only inside the AES-256-GCM state blob, so a network attacker or
the IdP cannot flip a web login into a native one. The handoff code is
256-bit CSPRNG, HMAC-hashed at rest, 90-second TTL, single-use via a
guarded consume; a failed PKCE check burns it. A replay of a consumed
code revokes exactly the pair the first exchange issued. The token pair
never rides a URL; only the opaque code or the MFA ticket do. The SSO
login can never satisfy requireFreshMfa step-up — the transport is
Bearer and step-up is cookie-only.

Adds the OidcNativeHandoff model (migration 0252), the token-exchange
route, a daily cleanup sweep, and the OpenAPI entry.
2026-07-18 16:50:56 +02:00
Marc-André Bombeck 74891e95f4 Merge pull request #534 from MBombeck/release/v1.30.10
release/v1.30.10 — localized correlation insights + richer workout detail
2026-07-18 16:25:51 +02:00
Marc-André Bombeck 57e855eeb3 chore(release): v1.30.10 — localized correlation insights + richer workout detail 2026-07-18 16:13:53 +02:00
Marc-André Bombeck dfa8675a89 chore(insights): drop the now-unused ENVIRONMENT_CHANNEL_KEYS const 2026-07-18 16:09:28 +02:00
Marc-André Bombeck 906acee978 chore(insights): drop the now-unused environment-field label helpers
The correlation-narrative localisation replaced the env-channel narration
labels (which read through getEnvironmentField().narrationLabel) with the
localised insights.correlation.channel.ENV_* keys, leaving these two helpers
dead.
2026-07-18 16:08:29 +02:00
Marc-André Bombeck e61fe42bd1 feat(workouts): enrich the per-workout detail view (#67)
The detail surface at /insights/workouts/[id] gains a real heart-rate
curve, effort zones, an aspect-true GPS route, and per-kilometre splits
— all read from data already stored, no new integration, no migration.

- HR curve: one server path with strict provenance — stored
  WorkoutSamples series first, a raw-PULSE window reconstruction second
  (quality-gated so sparse data hides rather than lies), nothing third.
  Adaptive bucketing keeps on-screen density constant across a 20-minute
  HIIT and a 6-hour ride; a min/max envelope surfaces intervals.
- Effort zones: five %HRmax bands via the Tanaka HRmax, with a device's
  own reported time-in-zone winning when present. Descriptive only.
- GPS route: self-rendered inline SVG with an equirectangular
  cos(midLat) longitude correction (fixes the ~40% horizontal stretch),
  aspect-clamped viewBox, RDP downsampling, start/end markers, and an
  optional elevation profile. Nothing leaves the origin — no map tiles.
- Per-km splits and a GPX export, both derived client/server-side from
  the geometry already on the payload.
- The endpoint extends additively (hrSeries, zones, splits, sportContext,
  a reserved insight seam) behind an opt-in compact=1 param that drops
  the raw sample/timestamp blobs for the web client; without the param
  the response is unchanged.
- Every data-less section now hides instead of rendering an empty card;
  the growing detail component is split into a workout-detail/ directory.
- List rows flag which sessions open into a rich detail.
2026-07-18 16:05:17 +02:00
Marc-André BombeckandClaude Opus 4.8 04320c415d fix(insights): localise correlation narratives across all six locales
The generated-insight narrative layer built its user-facing sentences by
string interpolation and never routed through the i18n bundle, so a
non-English UI rendered English text spliced into localised copy — e.g.
"Higher activity steps tends to go with lower next-day weight" surfacing
mid-German on the Weight insight. Because those strings bypassed t(), the
locale-integrity guard could not catch them.

Route every correlation narrative through the server translator:

- Correlation discovery (daily lagged pairs + labs-vs-outcome) now takes
  the reader's locale, resolved from the correlations route the same way
  other insight generators resolve it. Sentence templates, the direction
  word, and every channel label live under a new insights.correlation.*
  namespace, translated in de/en/es/fr/it/pl. The three fixed hypotheses
  (BP-compliance, mood-pulse, weight-weekday) and their weekday labels are
  localised the same way; the analytics cache key now includes the locale
  so a language switch cannot serve another locale's cached text.
- Metric labels resolve to stable localised channel labels; the never-
  causal framing ("not a cause" / "an association, never a cause") and the
  faint/moderate/high hedging tiers are preserved in every language.

English output is byte-for-byte unchanged. Adds a regression test pinning
German narration and asserting it is not the English string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018V8V9qapgHi7fhgtMJ8SkM
2026-07-18 16:05:17 +02:00
Marc-André Bombeck 36f73233b0 Merge pull request #533 from MBombeck/release/v1.30.9
release/v1.30.9 — faster dashboard first load
2026-07-18 15:04:43 +02:00
Marc-André Bombeck f3d92520d1 chore(release): v1.30.9 — faster dashboard first load 2026-07-18 14:43:57 +02:00
Marc-André Bombeck 5c60558d4f perf(dashboard): gate the profile-tz batch window on snapshot mode
Legacy mode (snapshot flag off) keeps its browser-local batch window
unchanged — the profile-tz window + series prefetch run only in snapshot
mode where a byte-identical dehydration key exists.
2026-07-18 14:42:18 +02:00
Marc-André Bombeck 271abd7b64 perf(dashboard): server-prefetch the Today hero + chart series into the dehydrated cache
Kill the above-the-fold first-load flash on `/`: the hero digest and the
batched chart series still fired client-side after JS download + hydrate,
so the hero and charts painted empty and filled in seconds later (LCP
~5.5 s mobile-throttled while the snapshot already dehydrated).

Extend the existing `page.tsx` snapshot prefetch to also seed the daily
digest and the chart series batch into the same dehydrated TanStack cache,
under keys byte-identical to the client's:

- Extract the series-batch route core into `readSeriesBatch(...)` so the
  RSC and the route share one code path (no HTTP-to-self); the route thins
  to auth + Zod + the shared reader, behaviour unchanged.
- New client-safe pure module `batch-chart-types.ts`: `deriveBatchChartTypes`
  (the single implementation of the visible-with-data type-set derivation,
  order + resting-HR-vs-pulse pick preserved) and `computeBatchWindow`
  (profile-tz end-of-day minus the coverage window). Server and client run
  the same function over the same snapshot payload, and the window is
  computed once server-side and threaded to the client as a prop, so the
  `chartSeriesBatch` key matches on both sides by construction.
- Render the hero data-first so the SSR HTML carries the populated hero
  (the LCP element) — hydration-safe because the digest is dehydrated and
  never restored from the IndexedDB persist allowlist; bound that invariant
  with comments on both the hero gate and the allowlist.
- Warm the shared chart-runtime chunk during hydration.

Both prefetches honour the `DASHBOARD_SSR_PREFETCH=false` escape and fail
soft to the client-fetch path on any error; a ~400 ms soft budget bounds
the cold-day digest cost. No contract change, no visual change.
2026-07-18 14:39:56 +02:00
Marc-André Bombeck 427b5f0b7c Merge pull request #532 from MBombeck/release/v1.30.8
release/v1.30.8 — harden aggregated heart-rate bucket validation
2026-07-18 12:16:26 +02:00
Marc-André Bombeck d4f78811d9 chore(release): v1.30.8 — harden aggregated heart-rate bucket validation 2026-07-18 11:59:38 +02:00
Marc-André Bombeck 6af0e36c88 fix(measurements): harden the aggregated HR bucket validation
Two data-integrity gaps found in review of the v1.30.7 contract:

- The bucket externalId guard accepted a non-canonical timestamp that V8
  silently rolls over (T24:00, 04-31, non-leap 02-29). Such a row would key
  under a literal string the canonical client never emits, so the two would
  never collapse on the overwrite key, and it would plot on the wrong local
  day. Require canonical round-trip (toISOString() === suffix) in both the
  guard and the bucket-start parser.
- The per-bucket min/max bypassed the plausibility-range guard that value
  passes; now that they drive the DAY min/max band and the intraday envelope,
  an out-of-range or mis-ordered spread poisoned the aggregate. Persist the
  spread only when both extremes are in range and ordered around value;
  otherwise drop to null and keep the average.
2026-07-18 11:58:51 +02:00
173 changed files with 12092 additions and 3372 deletions

No files matched your search

+6 -4
View File
@@ -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
+89
View File
@@ -2,6 +2,95 @@
## [Unreleased]
## [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.
+3 -2
View File
@@ -88,7 +88,8 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file:
- **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`).
- **`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
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.30.7
version: 1.30.17
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:
+4 -4
View File
@@ -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
+2
View File
@@ -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": [
+18
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.30.7",
"version": "1.30.17",
"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
View File
@@ -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
View File
@@ -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.7";
/* @sw-version-fallback */ "v1.30.17";
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,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);
});
});
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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 {
+12 -3
View File
@@ -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",
);
});
});
+145 -48
View File
@@ -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");
});
});
});
+49 -11
View File
@@ -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;
}
});
});
+172
View File
@@ -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;
});
+7 -3
View File
@@ -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";
+11 -2
View File
@@ -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" },
+8 -2
View File
@@ -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;
+3 -2
View File
@@ -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" },
+12 -2
View File
@@ -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) => {
+5 -1
View File
@@ -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 });
@@ -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 10-min HR bucket inserts;
* - 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 aggregated 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.
*/
@@ -172,6 +174,39 @@ describe("POST /api/measurements/batch — aggregated 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 },
+18 -8
View File
@@ -318,15 +318,25 @@ async function postBatch(request: NextRequest): Promise<Response> {
// 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.
//
// 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 valueMin =
isHrBucket && typeof mapped.valueMin === "number"
? mapped.valueMin
: null;
const valueMax =
isHrBucket && typeof mapped.valueMax === "number"
? mapped.valueMax
: null;
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,
+12 -51
View File
@@ -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,
},
});
+5 -243
View File
@@ -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" },
-120
View File
@@ -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
View File
@@ -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);
});
+157 -5
View File
@@ -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",
+8 -175
View File
@@ -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();
});
});
+199
View File
@@ -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
View File
@@ -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",
);
+611
View File
@@ -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
View File
@@ -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>
);
}
+39 -16
View File
@@ -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();
});
});
+79
View File
@@ -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>
);
}
+91 -61
View File
@@ -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();
});
});
+636
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
+2
View File
@@ -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>
);
}
+211
View File
@@ -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>
);
}
@@ -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"');
});
});
-438
View File
@@ -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>
);
}
+21 -1
View File
@@ -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",
);
+10 -108
View File
@@ -2,7 +2,7 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ChevronDown, Copy, Key, Loader2, Trash2 } from "lucide-react";
import { ChevronDown, Key, Trash2 } from "lucide-react";
import { toast } from "sonner";
import {
@@ -18,7 +18,6 @@ import {
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { EmptyState } from "@/components/ui/empty-state";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -149,12 +148,7 @@ function ApiTokensCard() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const [newName, setNewName] = useState("");
const [newToken, setNewToken] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [tokenMsg, setTokenMsg] = useState<string | null>(null);
const [showRevokedTokens, setShowRevokedTokens] = useState(false);
const [tokenCopied, setTokenCopied] = useState(false);
const { data: tokens } = useQuery({
queryKey: queryKeys.tokens(),
@@ -164,47 +158,6 @@ function ApiTokensCard() {
enabled: isAuthenticated,
});
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
setCreating(true);
setTokenMsg(null);
setNewToken(null);
try {
const res = await apiFetchRaw("/api/tokens", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName.trim() }),
});
const json = await res.json();
if (res.ok) {
setNewToken(json.data.token);
setNewName("");
queryClient.invalidateQueries({ queryKey: queryKeys.tokens() });
} else {
setTokenMsg(json.error || t("common.error"));
}
} catch {
setTokenMsg(t("common.networkError"));
} finally {
setCreating(false);
}
}
async function handleCopyToken() {
if (!newToken) return;
try {
await navigator.clipboard.writeText(newToken);
setTokenCopied(true);
toast.success(t("settings.tokenCopied"));
// Revert the inline check affordance after a short beat so a
// second copy reads as a fresh action.
setTimeout(() => setTokenCopied(false), 2_000);
} catch {
toast.error(t("settings.tokenCopyFailed"));
}
}
async function handleRevoke(tokenId: string) {
try {
const res = await apiFetchRaw(`/api/tokens/${tokenId}`, {
@@ -254,66 +207,15 @@ function ApiTokensCard() {
/>
<div className="mt-4 space-y-4 pl-7">
{/* The button mirrors the Input's responsive height (h-11 / sm:h-10)
so the row reads as one aligned control group instead of a tall
input next to a shorter `sm` button. */}
<form onSubmit={handleCreate} className="flex items-center gap-2">
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t("settings.tokenNamePlaceholder")}
maxLength={100}
className="flex-1"
/>
<Button
type="submit"
variant="outline"
className="h-11 sm:h-10"
disabled={creating || !newName.trim()}
>
{creating && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.create")}
</Button>
</form>
{newToken && (
<div
className="bg-success/10 rounded-lg p-3 text-sm"
data-slot="settings-api-token-created"
>
<p className="text-success mb-1 font-medium">
{t("settings.tokenCreated")}
</p>
<div className="flex items-start gap-2">
<code className="bg-muted block flex-1 rounded p-2 font-mono text-xs break-all">
{newToken}
</code>
<Button
type="button"
variant="outline"
size="icon"
className="min-h-11 min-w-11 shrink-0 sm:h-9 sm:w-9"
onClick={() => void handleCopyToken()}
aria-label={t("settings.tokenCopy")}
data-slot="settings-api-token-copy"
>
{tokenCopied ? (
<Check className="text-success h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
)}
{tokenMsg && (
<p role="alert" className="text-destructive text-sm">
{tokenMsg}
</p>
)}
{/* Tokens are no longer minted here. The generic mint issued a
`medication:ingest` token that the ingest endpoint refused (it
gates on the per-medication grant too) and that every other
authenticated route accepted. The per-medication API-endpoint
toggle issues the pair that actually works, scoped to one
medication. This card lists and revokes. */}
<p className="text-muted-foreground text-sm">
{t("settings.tokenMintMovedDescription")}
</p>
<div>
<p className="mb-2 text-sm font-medium">
+74 -1
View File
@@ -34,6 +34,14 @@ export interface WorkoutListEntry {
maxHr: number | null;
source: string;
externalId: string | null;
/**
* #67 list glyphs — whether the workout opens into a rich detail with
* a map / HR curve. Optional so pre-#67 fixtures (and the dashboard
* recent-tile) still satisfy the type; the list endpoint always sends
* them.
*/
hasRoute?: boolean;
hasHrSeries?: boolean;
}
export interface WorkoutListMeta {
@@ -118,6 +126,55 @@ export function useWorkouts(opts: UseWorkoutsOptions = {}): UseWorkoutsResult {
};
}
export interface WorkoutHrSeriesPoint {
tSec: number;
mean: number;
min: number;
max: number;
}
export interface WorkoutHrSeriesDto {
source: "workout_series" | "pulse_window";
bucketSec: number;
points: WorkoutHrSeriesPoint[];
envelope: boolean;
}
export interface WorkoutZoneBandDto {
zone: number;
lowBpm: number | null;
highBpm: number | null;
seconds: number;
}
export interface WorkoutZonesDto {
model: "whoop" | "tanaka";
hrMax: number | null;
zones: WorkoutZoneBandDto[];
}
export interface WorkoutSplitDto {
km: number;
durationSec: number;
paceSecPerKm: number;
}
export interface WorkoutSportContextDto {
count: number;
avgDurationSec: number;
avgDistanceM: number | null;
avgAvgHr: number | null;
}
/**
* Reserved Activity-Insight payload (strategic-concept Wave C, bet 5).
* The wire field is `null` today; the Phase-2 job will populate it and
* the detail page's seam mounts a card with zero rework. Declared here
* so the type is stable in advance — widen the union (never change the
* seam shape) when the job ships.
*/
export type WorkoutActivityInsight = null;
export interface WorkoutDetailPayload extends WorkoutListEntry {
minHr: number | null;
stepCount: number | null;
@@ -128,12 +185,25 @@ export interface WorkoutDetailPayload extends WorkoutListEntry {
geometry: unknown;
sampleTimestamps: string[] | null;
} | null;
/**
* Raw per-workout HR sample envelope. `samples.samples` is dropped
* (null) under `compact=1` — the web curve reads `hrSeries` instead —
* but `sampleCount` is retained. Finally declared as of #67.
*/
samples: { sampleCount: number; samples: unknown } | null;
hrSeries: WorkoutHrSeriesDto | null;
zones: WorkoutZonesDto | null;
splits: WorkoutSplitDto[] | null;
sportContext: WorkoutSportContextDto | null;
aiInsight: WorkoutActivityInsight;
canonicalId: string;
}
async function fetchWorkoutDetail(id: string): Promise<WorkoutDetailPayload> {
// Web always sends `compact=1`: the SVG needs geometry, not the raw
// 30k-sample / route-timestamp blobs (those ride the iOS path only).
return apiGet<WorkoutDetailPayload>(
`/api/workouts/${encodeURIComponent(id)}`,
`/api/workouts/${encodeURIComponent(id)}?compact=1`,
);
}
@@ -141,6 +211,8 @@ export interface UseWorkoutDetailResult {
data: WorkoutDetailPayload | undefined;
isLoading: boolean;
error: Error | null;
/** Re-runs the query — the retry action for the detail error card. */
refetch: () => void;
}
export function useWorkoutDetail(id: string): UseWorkoutDetailResult {
@@ -155,5 +227,6 @@ export function useWorkoutDetail(id: string): UseWorkoutDetailResult {
data: query.data,
isLoading: query.isLoading,
error: query.error as Error | null,
refetch: () => void query.refetch(),
};
}
+77 -78
View File
@@ -200,12 +200,17 @@ describe("requireAuth — Bearer token path", () => {
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
// v1.4.25 W10 reconcile (code-review H2): a route that does NOT declare
// a scope is content with any authenticated token. The previous policy
// 403'd every narrow-scope token here and broke `/api/measurements/by-
// external-ids`, `/api/personal-records`, `/api/medications/[id]/glp1`,
// and `/api/dashboard/glp1` — all four were called without a scope.
it("accepts a narrow-scope Bearer token when the route did not declare a scope", async () => {
// Supersedes the v1.4.25 "a route that declares no scope is content with any
// authenticated token" policy. That policy was adopted to unbreak four
// scopeless routes for narrow-token callers, and in doing so it handed every
// narrow token the full authenticated surface — the escalation this replaces.
// The right fix for a route that genuinely wants a narrow token is for that
// route to name the scope, which is a visible diff.
//
// The fail-closed default. Bare `requireAuth()` means "cookie session or
// cookie-equivalent token" — a narrow token is refused, and the audit row
// says why so an operator can name the token and its owner.
it("refuses a narrow-scope Bearer token when the route declared no scope", async () => {
vi.mocked(getSession).mockResolvedValue(null);
setBearerHeader(`Bearer ${RAW_TOKEN}`);
@@ -218,14 +223,40 @@ describe("requireAuth — Bearer token path", () => {
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
const ctx = await requireAuth();
expect(ctx.user.id).toBe("user-1");
expect(ctx.session.id).toBe("token-5a");
// v1.25 — no per-request success audit row on the accept path.
expect(auditLog).not.toHaveBeenCalledWith(
"auth.bearer.success",
expect.anything(),
await expect(requireAuth()).rejects.toMatchObject({
statusCode: 403,
message: "Insufficient permissions",
} satisfies Partial<HttpError>);
expect(auditLog).toHaveBeenCalledWith(
"auth.bearer.failure",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
reason: "undeclared_scope",
tokenId: "token-5a",
}),
}),
);
// The user row is never loaded — the deny happens on the token alone.
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("admits a narrow-scope Bearer token on a route that names its scope", async () => {
vi.mocked(getSession).mockResolvedValue(null);
setBearerHeader(`Bearer ${RAW_TOKEN}`);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "token-5a2",
userId: "user-1",
permissions: ["fhir:read"],
revoked: false,
expiresAt: null,
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
const ctx = await requireAuth("fhir:read");
expect(ctx.user.id).toBe("user-1");
expect(ctx.session.id).toBe("token-5a2");
});
it("accepts a wildcard Bearer token when the route did not declare a scope", async () => {
@@ -273,10 +304,18 @@ describe("requireAuth — Bearer token path", () => {
});
});
describe("requireAuth — H1 MCP health:read audience binding on REST", () => {
// Drive the FULL apiHandler path so the request method is in event context —
// the H1 check reads it to allow safe reads but block writes.
const route = apiHandler(async (req: Request) => {
describe("requireAuth — MCP-audience tokens have no REST reach", () => {
// Before the fail-closed default, an MCP-audience token (`health:read`, or
// `health:read health:write`) was admitted on safe REST methods and refused
// on writes by the `isMcpAudienceToken` guard. It now carries no `*` grant
// and no REST route names `health:read`, so the resolver refuses it on EVERY
// method — reads included. Its audience narrows from "/mcp + REST reads" to
// "/mcp only", which is what RFC 8707 audience binding asked for.
//
// The `isMcpAudienceToken` guard is retained in `api-handler.ts` as defence
// in depth; it is unreachable on this path, which is exactly why these tests
// assert `undeclared_scope` rather than `mcp_audience_write_blocked`.
const route = apiHandler(async (req) => {
await requireAuth();
return Response.json({ ok: true, method: req.method });
});
@@ -295,41 +334,36 @@ describe("requireAuth — H1 MCP health:read audience binding on REST", () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
});
function mockHealthReadToken(): void {
function mockMcpToken(permissions: string[]): void {
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "mcp-token",
userId: "user-1",
permissions: ["health:read"],
permissions,
revoked: false,
expiresAt: null,
} as never);
}
it("accepts a health:read token on a GET read route", async () => {
mockHealthReadToken();
const res = await call("GET");
expect(res.status).toBe(200);
});
for (const permissions of [
["health:read"],
["health:read", "health:write"],
]) {
const label = permissions.join(" ");
it("rejects a health:read token on POST with 403", async () => {
mockHealthReadToken();
const res = await call("POST");
expect(res.status).toBe(403);
expect(auditLog).toHaveBeenCalledWith(
"auth.bearer.failure",
expect.objectContaining({
details: expect.objectContaining({
reason: "mcp_audience_write_blocked",
}),
}),
);
});
it("rejects a health:read token on DELETE with 403", async () => {
mockHealthReadToken();
const res = await call("DELETE");
expect(res.status).toBe(403);
});
for (const method of ["GET", "POST", "DELETE"]) {
it(`refuses a ${label} token on REST ${method} with 403`, async () => {
mockMcpToken(permissions);
const res = await call(method);
expect(res.status).toBe(403);
expect(auditLog).toHaveBeenCalledWith(
"auth.bearer.failure",
expect.objectContaining({
details: expect.objectContaining({ reason: "undeclared_scope" }),
}),
);
});
}
}
it("does NOT restrict a wildcard token on POST (not MCP-audience)", async () => {
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
@@ -342,41 +376,6 @@ describe("requireAuth — H1 MCP health:read audience binding on REST", () => {
const res = await call("POST");
expect(res.status).toBe(200);
});
it("rejects a health:read+write token on POST with 403 (writes are /mcp-only)", async () => {
// A write-scoped MCP token is STILL audience-bound: its `health:write`
// grant admits the confirmed in-process `/mcp` write tools, never a REST
// write/delete. So it gets the same 403 as a read-only MCP token here.
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "rw",
userId: "user-1",
permissions: ["health:read", "health:write"],
revoked: false,
expiresAt: null,
} as never);
const res = await call("POST");
expect(res.status).toBe(403);
expect(auditLog).toHaveBeenCalledWith(
"auth.bearer.failure",
expect.objectContaining({
details: expect.objectContaining({
reason: "mcp_audience_write_blocked",
}),
}),
);
});
it("accepts a health:read+write token on a GET read route", async () => {
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "rw",
userId: "user-1",
permissions: ["health:read", "health:write"],
revoked: false,
expiresAt: null,
} as never);
const res = await call("GET");
expect(res.status).toBe(200);
});
});
describe("requireAuth — cookie path remains intact", () => {
@@ -0,0 +1,76 @@
/**
* Regression-class ban for the German-default locale binary.
*
* The bug this pins had exactly one shape: `locale === "en" ? "en" : "de"`
* (and its `=== "de" ? ... : ...` inversions with a `de` fallback). Every
* occurrence sent French, Spanish, Italian and Polish accounts down the German
* branch. This walks the source of the surfaces that carried it and fails on
* the shape itself, so a future edit cannot quietly reintroduce it.
*
* Scoped to the files this change owns — the coach memory refresh, the coach
* snapshot's narrative recall, the narrative route + generator + warm, and the
* dashboard briefing recall. Blunt by design: it matches text, not behaviour,
* which is precisely what makes it survive a refactor of the logic around it.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const ROOT = resolve(__dirname, "../../../..");
const GUARDED_FILES = [
"app/api/insights/chat/route.ts",
"app/api/insights/narrative/route.ts",
"lib/ai/coach/snapshot.ts",
"lib/ai/coach/memory-snapshot.ts",
"lib/ai/coach/coach-memory-refresh-worker.ts",
"lib/dashboard/snapshot.ts",
"lib/insights/narrative/period-narrative-generate.ts",
"lib/jobs/period-narrative-warm.ts",
"lib/jobs/period-narrative-shared.ts",
];
/**
* A ternary whose FALSE branch is the German literal — the de-default shape.
* Deliberately narrow: it must not flag `locale === "de" ? deBody : enBody`,
* which is the correct polarity (German only for German readers).
*/
const DE_DEFAULT_TERNARY = /\?\s*"[a-z]{2}"\s*:\s*"de"/;
/** `payload.locale ?? "de"` and friends — a German default by omission. */
const DE_DEFAULT_NULLISH = /\?\?\s*"de"/;
/**
* Read a guarded file with comments stripped.
*
* The comments at these call sites deliberately QUOTE the banned shape to
* explain what was fixed; scanning them would make the guard self-tripping.
* Only executable source is checked.
*/
function readCode(rel: string): string {
return readFileSync(resolve(ROOT, rel), "utf8")
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/^\s*\/\/.*$/gm, "");
}
describe("no German-default locale binary", () => {
it.each(GUARDED_FILES)("%s carries no de-default ternary", (rel) => {
expect(readCode(rel)).not.toMatch(DE_DEFAULT_TERNARY);
});
it.each(GUARDED_FILES)("%s carries no de-default fallback", (rel) => {
expect(readCode(rel)).not.toMatch(DE_DEFAULT_NULLISH);
});
it("recognises the shape it bans", () => {
// Guard the guard: the patterns must actually match the bug they describe,
// so a typo cannot turn this file into a silent pass.
expect(`locale === "en" ? "en" : "de"`).toMatch(DE_DEFAULT_TERNARY);
expect(`payload.locale ?? "de"`).toMatch(DE_DEFAULT_NULLISH);
// ...and must NOT match the correct polarity.
expect(`instructionLocale(locale)`).not.toMatch(DE_DEFAULT_TERNARY);
expect(`locale === "de" ? DE_BODY : EN_BODY`).not.toMatch(
DE_DEFAULT_TERNARY,
);
});
});
@@ -168,4 +168,19 @@ describe("buildCoachMemoryBlock", () => {
await buildCoachMemoryBlock(USER, PROFILE, NOW, "en");
expect(readPeriodNarrative).toHaveBeenCalledWith(USER, "month", "en");
});
// The narrative row is keyed by the full UI locale union. A French account's
// recall must read the `fr` row — narrowing it to `de` (the old behaviour)
// recalled a German narrative, and narrowing it to `en` would read a row
// that account never has.
it.each(["fr", "es", "it", "pl"] as const)(
"reads the %s narrative row without narrowing the locale",
async (locale) => {
readPeriodNarrative.mockResolvedValue(null);
buildPeriodNarrativeContext.mockResolvedValue(readyContext([]));
await buildCoachMemoryBlock(USER, PROFILE, NOW, locale);
expect(readPeriodNarrative).toHaveBeenCalledWith(USER, "month", locale);
},
);
});
@@ -19,7 +19,10 @@ export async function runCoachMemoryRefresh(
payload: CoachMemoryRefreshPayload,
): Promise<void> {
const { conversationId, userId } = payload;
const locale = payload.locale ?? "de";
// A payload without a locale (an older queued job) composes ENGLISH memory,
// not German — the memory prose is model-facing context and English is the
// fallback body for every locale that has no reviewed one.
const locale = payload.locale ?? "en";
let summaryStatus = "error";
try {
+1 -1
View File
@@ -22,7 +22,7 @@ export const COACH_MEMORY_REFRESH_QUEUE = "coach-memory-refresh";
export interface CoachMemoryRefreshPayload {
conversationId: string;
userId: string;
/** Locale to compose the summary / facts prose in; defaults to "de". */
/** Locale to compose the summary / facts prose in; defaults to "en". */
locale?: "de" | "en";
}
+4 -1
View File
@@ -558,7 +558,10 @@ function deterministicFactText(
subject: string,
locale: string | undefined,
): string {
const de = locale?.toLowerCase().startsWith("de") ?? true;
// German only for a German reader; every other locale — and an absent one —
// composes ENGLISH. The fact text is model-facing memory, so English is the
// fallback body; the previous `?? true` made an unknown locale German.
const de = locale?.toLowerCase().startsWith("de") ?? false;
switch (kind) {
case "allergy":
return de
+7 -1
View File
@@ -38,6 +38,7 @@ import {
type BandTransition,
} from "@/lib/insights/narrative/period-narrative";
import type { BaselineProfile } from "@/lib/insights/derived";
import type { Locale } from "@/lib/i18n/config";
import { buildCoachFactsBlock } from "./facts";
import {
buildCoachPlansBlock,
@@ -138,7 +139,12 @@ export async function buildCoachMemoryBlock(
userId: string,
_profile: BaselineProfile,
now: Date,
locale: "de" | "en",
/**
* The reader's UI locale — the narrative row is keyed by it, across the full
* six-locale union. Narrowing it here would read a row the narrative
* pipeline never writes for that user.
*/
locale: Locale,
): Promise<CoachMemoryBlock | null> {
let priorNarrative: PriorNarrativeRecall | undefined;
// Sub-source 1: the latest period-narrative headline + driver recall.
+54
View File
@@ -0,0 +1,54 @@
/**
* Shared Coach nudge-status read for the two entry points:
*
* - `GET /api/insights/coach/nudge-status` (the client cell's endpoint), and
* - the `/coach` RSC wrapper (`src/app/coach/page.tsx`), which
* server-prefetches the same payload into a dehydrated TanStack cache so
* the auto-open-most-recent decision is available at hydrate instead of
* after the nudge → auto-open sequential round-trip.
*
* `unread` is true when an assistant message exists that is newer than the last
* time the user opened the Coach (`User.coachLastSeenAt`, stamped by
* `POST /api/insights/coach/seen`). Server-authoritative so the signal is
* consistent across web + iOS.
*/
import { prisma } from "@/lib/db";
export interface CoachNudgeStatus {
/** ISO timestamp of the newest assistant message, or null. */
nudgedAt: string | null;
unread: boolean;
/** The conversation holding the newest assistant message, or null. */
conversationId: string | null;
}
export async function readCoachNudgeStatus(
userId: string,
): Promise<CoachNudgeStatus> {
// 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 } },
orderBy: { createdAt: "desc" },
select: { createdAt: true, conversationId: true },
}),
prisma.user.findUnique({
where: { id: userId },
select: { coachLastSeenAt: true },
}),
]);
const nudgedAt = lastAssistant?.createdAt ?? null;
const lastSeenAt = seen?.coachLastSeenAt ?? null;
const unread =
nudgedAt !== null && (lastSeenAt === null || lastSeenAt < nudgedAt);
return {
nudgedAt: nudgedAt ? nudgedAt.toISOString() : null,
unread,
conversationId: lastAssistant?.conversationId ?? null,
};
}
+8 -3
View File
@@ -23,6 +23,7 @@ import {
type CoachDataCluster,
} from "@/lib/validations/coach-prefs";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { locales, defaultLocale, type Locale } from "@/lib/i18n/config";
import { resolveGlucoseUnit } from "@/lib/glucose";
import type { SleepStageRow } from "@/lib/analytics/sleep-night";
import { compactSections } from "@/lib/ai/prompts/compact-sections";
@@ -339,9 +340,13 @@ async function buildCoachSnapshotImpl(
);
const prefs = parseCoachPrefs(prefsRow?.coachPrefsJson);
// Resolve the UI locale for the rolling-profile narrative recall. The
// narrative rows are keyed by ("de" | "en"); default to "de" (the app
// default locale) when the user never picked one.
const coachLocale: "de" | "en" = prefsRow?.locale === "en" ? "en" : "de";
// narrative rows are keyed by the full UI locale union, so the stored value
// is carried through as-is; an unset or unknown value falls back to the app
// default (`en`), never to German. The former `=== "en" ? "en" : "de"`
// binary made a French account recall a German narrative row.
const coachLocale: Locale = locales.includes(prefsRow?.locale as Locale)
? (prefsRow?.locale as Locale)
: defaultLocale;
const clusterDefault = clusterSourcesFromPrefs(prefs.dataClusters);
const { sources: scopedSources, window } = resolveScope(
scope,
+7 -2
View File
@@ -19,6 +19,7 @@
import type { Locale } from "@/lib/i18n/config";
import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator";
import { buildNativeCoachPrompt } from "@/lib/ai/prompts/native-prompts";
import { instructionLocale } from "@/lib/ai/prompts/output-language";
import { learnCatalogPromptBlock } from "./learn-catalog";
import {
DEFAULT_COACH_PREFS,
@@ -1224,7 +1225,7 @@ function buildCoachV122Addendum(
locale: Locale,
personalization: CoachPersonalization,
): string {
const isDe = locale === "de";
const isDe = instructionLocale(locale) === "de";
const parts: string[] = [];
parts.push(isDe ? "COACH-ZUSATZ (v1.22)" : "COACH ADDENDUM (v1.22)");
@@ -1347,7 +1348,11 @@ ${fenceSelfReport(aboutMe)}`;
*/
function buildPrefsPrefix(locale: Locale, prefs: CoachPrefs): string {
const parts: string[] = [];
const isEn = locale === "en";
// These override lines are internal instructions, so they follow the same
// routing as every other prompt body: German only for German readers,
// English for everyone else. The former `locale === "en"` sent a French
// Coach prompt a German tone override on top of its French body.
const isEn = instructionLocale(locale) === "en";
// Tone
if (prefs.tone === "neutral") {
@@ -40,13 +40,28 @@ describe("base-system assessment prompt — locale skeleton parity", () => {
});
it("the composed prompt equals the fragments joined by a blank line", () => {
// The English output clause carries a language token so a locale riding
// the English body can name its own language. For these two locales the
// substitution is their own language name — written as a literal here, not
// read from the module under test, so the assertion stays independent.
const EXPECTED_LANGUAGE_NAME = { en: "English", de: "German" } as const;
for (const locale of ["en", "de"] as const) {
const expected = ASSESSMENT_SECTION_PAIRS.map((s) => s[locale]).join(
"\n\n",
);
const expected = ASSESSMENT_SECTION_PAIRS.map((s) =>
s[locale]
.split("{{OUTPUT_LANGUAGE}}")
.join(EXPECTED_LANGUAGE_NAME[locale]),
).join("\n\n");
expect(getBaseSystemPrompt(locale)).toBe(expected);
}
});
it("de and en carry no appended output-language directive", () => {
// Their bodies name the language natively; appending a directive would
// change two prompts that are deliberately byte-stable across this change.
for (const locale of ["en", "de"] as const) {
expect(getBaseSystemPrompt(locale)).not.toContain("OUTPUT LANGUAGE:");
}
});
});
describe("insight system prompt — locale skeleton parity", () => {
@@ -0,0 +1,271 @@
import { describe, expect, it } from "vitest";
import { locales, type Locale } from "@/lib/i18n/config";
import {
outputLanguageDirective,
targetLanguageName,
} from "@/lib/ai/prompts/output-language";
import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt";
import {
getGeneralStatusSystemPrompt,
getGeneralStatusUserPrompt,
} from "@/lib/ai/prompts/general-status";
import { getBmiSystemPrompt, getBmiUserPrompt } from "@/lib/ai/prompts/bmi";
import {
getBloodPressureSystemPrompt,
getBloodPressureUserPrompt,
} from "@/lib/ai/prompts/blood-pressure";
import {
getWeightSystemPrompt,
getWeightUserPrompt,
} from "@/lib/ai/prompts/weight";
import {
getPulseSystemPrompt,
getPulseUserPrompt,
} from "@/lib/ai/prompts/pulse";
import { getMoodSystemPrompt, getMoodUserPrompt } from "@/lib/ai/prompts/mood";
import {
getMedicationComplianceSystemPrompt,
getMedicationComplianceUserPrompt,
} from "@/lib/ai/prompts/medication-compliance";
import {
getMetricArchetypeSystemPrompt,
getMetricArchetypeUserPrompt,
} from "@/lib/ai/prompts/metric-archetypes";
import { getStatusBatchSystemPrompt } from "@/lib/ai/prompts/status-batch";
import { buildInterpretationBlock } from "@/lib/ai/prompts/interpretation-block";
import {
buildUserPrompt,
buildComparisonBlock,
} from "@/lib/ai/prompts/insight-system-prompt";
import type { MetricStatusMeta } from "@/lib/insights/metric-status-registry";
/**
* Adoption contract for the assessment prompt family.
*
* `output-language-contract.test.ts` pins the helper and the base system
* prompt. This file pins the SIBLING modules that compose on top of it — the
* per-metric system prompts, their user-prompt scaffolding, the batch wrapper,
* the interpretation block, the insights user prompt and the Coach prefs
* prefix.
*
* The invariant each module is held to: strip the one directive out of the
* four-locale prompt and what remains is the English prompt, byte for byte.
* That single assertion carries three guarantees. The four locales compose the
* ENGLISH body (no German leak); the body they compose is byte-identical to
* the English one, so an English prompt edit can never silently diverge from
* what a French reader receives; and the directive rides EXACTLY once — the
* double-append a module would produce by adding its own directive on top of
* the one `getBaseSystemPrompt` already carries shows up here as a leftover,
* and again in the occurrence count below.
*
* Note where the directive sits. `getBaseSystemPrompt` appends it last, and
* each of these modules then appends its per-metric section AFTER that — so on
* a composed prompt the directive sits at the base/section boundary rather
* than at the very end. It is present and unambiguous either way; moving it
* back to terminal position would mean composing the base without the
* directive and re-appending after the section, which is a change to the
* shared base prompt, not to these modules.
*
* User-prompt scaffolding carries no directive of its own (it rides the system
* prompt), so there the invariant is plain equality with the English text.
*/
/** Words that appear in a German instruction body and in no English one. */
const GERMAN_SENTINEL =
/AUSGABEFORMAT|Antworte ausschließlich|Einschätzung|Schreibe eine|GEBÜNDELTE|EINORDNUNGS-KONTEXT|VERGLEICHSMODUS|Analysiere/;
/** The four locales that ride the English body plus their own directive. */
const RIDERS = ["fr", "es", "it", "pl"] as const;
const ARGS = ["{SNAPSHOT}", "2026-07-18"] as const;
const META: MetricStatusMeta = {
key: "STEP_COUNT",
displayName: "Steps",
unit: "steps",
direction: "higher-better",
archetype: "activity-fitness",
normalRange: { low: 7000, high: 12000 },
} as unknown as MetricStatusMeta;
/**
* System-prompt builders: every one of these composes `getBaseSystemPrompt`,
* which already carries the directive — so none of them may append its own.
*/
const SYSTEM_BUILDERS: ReadonlyArray<{
name: string;
build: (locale: Locale) => string;
}> = [
{ name: "general-status", build: getGeneralStatusSystemPrompt },
{ name: "bmi", build: getBmiSystemPrompt },
{ name: "blood-pressure", build: getBloodPressureSystemPrompt },
{ name: "weight", build: getWeightSystemPrompt },
{ name: "pulse", build: getPulseSystemPrompt },
{ name: "mood", build: getMoodSystemPrompt },
{
name: "medication-compliance",
build: getMedicationComplianceSystemPrompt,
},
{
name: "metric-archetypes",
build: (l) => getMetricArchetypeSystemPrompt(META, l),
},
{
name: "status-batch",
build: (l) => getStatusBatchSystemPrompt(l, ["bp", "weight"]),
},
];
/** User-prompt scaffolding — English text for every non-German locale. */
const USER_BUILDERS: ReadonlyArray<{
name: string;
build: (locale: Locale) => string | undefined;
}> = [
{
name: "general-status",
build: (l) => getGeneralStatusUserPrompt(...ARGS, l),
},
{ name: "bmi", build: (l) => getBmiUserPrompt(...ARGS, l) },
{
name: "blood-pressure",
build: (l) => getBloodPressureUserPrompt(...ARGS, l),
},
{ name: "weight", build: (l) => getWeightUserPrompt(...ARGS, l) },
{ name: "pulse", build: (l) => getPulseUserPrompt(...ARGS, l) },
{ name: "mood", build: (l) => getMoodUserPrompt(...ARGS, l) },
{
name: "medication-compliance",
build: (l) => getMedicationComplianceUserPrompt(...ARGS, l),
},
{
name: "metric-archetypes",
build: (l) => getMetricArchetypeUserPrompt(META, ...ARGS, l),
},
{
name: "interpretation-block",
build: (l) =>
buildInterpretationBlock({
metricKey: "RESTING_HEART_RATE",
value: 58,
sex: "MALE",
locale: l,
}),
},
{
name: "insight-system-prompt",
build: (l) => buildUserPrompt("{FEATURES}", "raw", l),
},
{
name: "insight-comparison-block",
build: (l) =>
buildComparisonBlock(l, {
baseline: "lastMonth",
metrics: [
{
type: "weight",
currentAvg: 82.1,
baselineAvg: 83.2,
delta: -1.1,
deltaPercent: -1.3,
unit: "kg",
},
],
}),
},
{
name: "insight-comparison-block (no metrics)",
build: (l) =>
buildComparisonBlock(l, { baseline: "lastYear", metrics: [] }),
},
];
describe.each(SYSTEM_BUILDERS)("$name system prompt", ({ build }) => {
it.each(RIDERS)("%s composes the English body plus its directive", (l) => {
const directive = outputLanguageDirective(l);
expect(directive).not.toBe("");
// Two things legitimately differ from the English prompt: the directive
// itself, and the language name the base body interpolates into its
// output clause ("the complete assessment in Polish"). Undo exactly those
// two and nothing else may remain.
const normalised = build(l)
.replace(`\n\n${directive}`, "")
.split(targetLanguageName(l))
.join("English");
expect(normalised).toBe(build("en"));
});
it("appends the directive exactly once — never twice", () => {
// A module that appended its own directive on top of the one
// `getBaseSystemPrompt` already carries would count two here.
for (const l of RIDERS) {
expect(occurrences(build(l), "OUTPUT LANGUAGE:"), `${l}`).toBe(1);
}
// de and en name their language inside their own body; no directive.
expect(occurrences(build("de"), "OUTPUT LANGUAGE:")).toBe(0);
expect(occurrences(build("en"), "OUTPUT LANGUAGE:")).toBe(0);
});
it("sends German instruction text to German readers only", () => {
expect(GERMAN_SENTINEL.test(build("de"))).toBe(true);
for (const l of locales) {
if (l === "de") continue;
expect(GERMAN_SENTINEL.test(build(l)), `${l}`).toBe(false);
}
});
it("keeps German and English distinct", () => {
expect(build("de")).not.toBe(build("en"));
});
});
describe.each(USER_BUILDERS)("$name user prompt", ({ build }) => {
it.each(RIDERS)("%s receives the English scaffolding verbatim", (l) => {
expect(build(l)).toBe(build("en"));
});
it("sends German scaffolding to German readers only", () => {
expect(GERMAN_SENTINEL.test(build("de") ?? "")).toBe(true);
for (const l of locales) {
if (l === "de") continue;
expect(GERMAN_SENTINEL.test(build(l) ?? ""), `${l}`).toBe(false);
}
});
});
describe("coach preference overrides", () => {
const prefs = { tone: "neutral", verbosity: "detailed" } as never;
it("never carries German override lines on a non-German prompt", () => {
for (const l of locales) {
const prompt = getCoachSystemPrompt(l, prefs);
if (l === "de") {
expect(prompt).toContain("TONFALL-OVERRIDE:");
continue;
}
// The French body is native; only the override prefix is at issue here.
expect(prompt, `${l} carries a German tone override`).not.toContain(
"TONFALL-OVERRIDE:",
);
expect(prompt).toContain("TONE OVERRIDE:");
expect(prompt).not.toContain("AUSFÜHRLICHKEITS-OVERRIDE:");
expect(prompt).toContain("VERBOSITY OVERRIDE:");
}
});
it("never carries a German v1.22 addendum on a non-German prompt", () => {
for (const l of locales) {
const prompt = getCoachSystemPrompt(l);
if (l === "de") {
expect(prompt).toContain("COACH-ZUSATZ (v1.22)");
continue;
}
expect(prompt, `${l}`).not.toContain("COACH-ZUSATZ (v1.22)");
expect(prompt).toContain("COACH ADDENDUM (v1.22)");
}
});
});
function occurrences(haystack: string, needle: string): number {
return haystack.split(needle).length - 1;
}
@@ -0,0 +1,151 @@
import { describe, expect, it } from "vitest";
import { locales, type Locale } from "@/lib/i18n/config";
import { getBaseSystemPrompt } from "@/lib/ai/prompts/base-system";
import {
instructionLocale,
outputLanguageDirective,
targetLanguageName,
withOutputLanguage,
} from "@/lib/ai/prompts/output-language";
import { loadSafetyContracts } from "@/lib/ai/prompts/safety-contracts";
/**
* The output-language contract.
*
* These tests exist because four of the six locales cannot be eyeballed at
* review time. They pin the mechanical guarantees — which body a locale
* composes, that its own language is named, that the locale's own directive is
* present and last, and that no German leaks into a non-German prompt.
*
* What they deliberately do NOT claim: that the model's prose is idiomatic or
* correct in French, Spanish, Italian or Polish. No offline test can show
* that. The precedent is the native insights/briefing prompts, which have
* carried the same directive in production, and the residual risk is covered
* by a production check, not by this file.
*/
/**
* A word that appears in the German instruction body and in no English one.
* If it shows up in a non-German prompt, the de-default collapse is back.
*/
const GERMAN_SENTINEL = /AUSGABEFORMAT|Antworte ausschließlich|Einschätzung/;
/** English names, written literally — never derived from the module under test. */
const EXPECTED_LANGUAGE_NAME: Record<Locale, string> = {
de: "German",
en: "English",
fr: "French",
es: "Spanish",
it: "Italian",
pl: "Polish",
};
/** Which body each locale is expected to compose. */
const EXPECTED_BODY: Record<Locale, "de" | "en"> = {
de: "de",
en: "en",
fr: "en",
es: "en",
it: "en",
pl: "en",
};
describe("output-language helper", () => {
it("routes only German to the German body — every other locale to English", () => {
for (const locale of locales) {
expect(instructionLocale(locale), `instruction body for ${locale}`).toBe(
EXPECTED_BODY[locale],
);
}
});
it("names each locale's language", () => {
for (const locale of locales) {
expect(targetLanguageName(locale)).toBe(EXPECTED_LANGUAGE_NAME[locale]);
}
});
it("emits the locale's OWN directive for the four riding the English body", () => {
for (const locale of ["fr", "es", "it", "pl"] as const) {
const directive = outputLanguageDirective(locale);
expect(directive, `directive for ${locale}`).toContain(
"OUTPUT LANGUAGE:",
);
// The directive text must be the locale's own translated clause, not a
// generic English one — that is what makes it authoritative to the model.
expect(directive).toContain(
loadSafetyContracts(locale).reply_language_directive,
);
}
});
it("emits no directive for de/en (their bodies name the language natively)", () => {
expect(outputLanguageDirective("de")).toBe("");
expect(outputLanguageDirective("en")).toBe("");
});
it("appends the directive last, blank-line separated", () => {
const composed = withOutputLanguage("BODY", "fr");
expect(composed.startsWith("BODY\n\n")).toBe(true);
expect(
composed.endsWith(loadSafetyContracts("fr").reply_language_directive),
).toBe(true);
});
it("leaves a de/en prompt untouched", () => {
expect(withOutputLanguage("BODY", "de")).toBe("BODY");
expect(withOutputLanguage("BODY", "en")).toBe("BODY");
});
});
describe("base system prompt — every locale", () => {
it("asks for the assessment in the reader's own language", () => {
for (const locale of locales) {
const prompt = getBaseSystemPrompt(locale);
if (locale === "de") {
// The German body states its language in German.
expect(prompt).toContain("auf Deutsch");
} else {
expect(
prompt,
`${locale} must name its own language in the output clause`,
).toContain(`complete assessment in ${EXPECTED_LANGUAGE_NAME[locale]}`);
}
}
});
it("never sends German instructions to a non-German locale", () => {
// This is the regression the whole change exists to prevent.
for (const locale of locales) {
if (locale === "de") continue;
expect(
GERMAN_SENTINEL.test(getBaseSystemPrompt(locale)),
`${locale} prompt contains German instruction text`,
).toBe(false);
}
});
it("ends the four non-de/en prompts with their own directive", () => {
for (const locale of ["fr", "es", "it", "pl"] as const) {
// `[\s\S]` rather than `.` + the `s` flag — the compile target predates
// dotAll, and the directive can span lines.
expect(getBaseSystemPrompt(locale).trimEnd()).toMatch(
/OUTPUT LANGUAGE: [\s\S]+$/,
);
}
});
it("produces a distinct prompt per locale (no silent collapse)", () => {
const seen = new Map<string, Locale>();
for (const locale of locales) {
const prompt = getBaseSystemPrompt(locale);
const clash = seen.get(prompt);
expect(
clash,
`${locale} produced a prompt identical to ${clash} — a locale collapsed`,
).toBeUndefined();
seen.set(prompt, locale);
}
});
});
+58 -6
View File
@@ -11,6 +11,19 @@ import {
forbiddenFiller,
formattingContract,
} from "./shared-contracts";
import {
instructionLocale,
targetLanguageName,
withOutputLanguage,
} from "./output-language";
/**
* Placeholder for the reply language inside the English output clause.
*
* Kept as a token (rather than a template literal in the section array) so the
* section array stays a plain data structure the parity tests can iterate.
*/
const OUTPUT_LANGUAGE_TOKEN = "{{OUTPUT_LANGUAGE}}";
/**
* Version of the per-metric assessment base prompt + its signal contract.
@@ -42,8 +55,13 @@ import {
* rotation. This activates the dormant "do NOT open with the number" branch so
* the per-metric card reads as warm as the overview. Clause ORDER only —
* grounding, non-diagnostic framing, and every safety contract are unchanged.
* 6.2.0 — output language: French, Spanish, Italian and Polish readers used to
* fall to the German instruction body, whose output clause asks for German
* prose. They now compose the English body with their language named in the
* output clause and the locale's own reply-language directive appended last.
* The German and English prompts are byte-identical to 6.1.0 (test-pinned).
*/
export const PROMPT_VERSION = "6.1.0" as const;
export const PROMPT_VERSION = "6.2.0" as const;
/**
* Base system prompt for the per-metric Insights *assessment* cards.
@@ -245,16 +263,30 @@ Synthese statt Aufzählung: die Geschichte dessen, was die Daten bedeuten, zähl
},
{
id: "output",
en: `OUTPUT FORMAT: Reply with valid JSON only, in exactly this schema. The "summary" field holds the complete assessment in English: 1-3 short paragraphs of 1-3 sentences each, separated by a blank line written as \\n\\n INSIDE the JSON string. A steady one-liner stays a single paragraph:
// The language name is interpolated so a locale riding the English
// instruction body still asks for prose in the reader's own language. For
// `en` it renders "English" — byte-identical to the pre-6.2.0 literal.
en: `OUTPUT FORMAT: Reply with valid JSON only, in exactly this schema. The "summary" field holds the complete assessment in ${OUTPUT_LANGUAGE_TOKEN}: 1-3 short paragraphs of 1-3 sentences each, separated by a blank line written as \\n\\n INSIDE the JSON string. A steady one-liner stays a single paragraph:
{ "summary": "..." }`,
de: `AUSGABEFORMAT: Antworte ausschließlich mit validem JSON in genau diesem Schema. Das Feld "summary" enthält die komplette Einschätzung auf Deutsch: 1-3 kurze Absätze mit je 1-3 Sätzen, getrennt durch eine Leerzeile als \\n\\n INNERHALB des JSON-Strings. Ein stabiler Einzeiler bleibt EIN Absatz:
{ "summary": "..." }`,
},
];
/** The locale text fragments are joined by a blank line, in section order. */
function composeAssessmentPrompt(locale: "en" | "de"): string {
return ASSESSMENT_SECTIONS.map((s) => s[locale]).join("\n\n");
/**
* The locale text fragments are joined by a blank line, in section order.
*
* `languageName` fills the output clause's language token on the English body.
* The German body names its language natively and carries no token, so the
* replacement is a no-op there.
*/
function composeAssessmentPrompt(
instructionBody: "en" | "de",
languageName: string,
): string {
return ASSESSMENT_SECTIONS.map((s) =>
s[instructionBody].split(OUTPUT_LANGUAGE_TOKEN).join(languageName),
).join("\n\n");
}
/**
@@ -271,6 +303,26 @@ export const ASSESSMENT_SECTION_PAIRS: readonly {
de: string;
}[] = ASSESSMENT_SECTIONS;
export function getBaseSystemPromptBody(locale: Locale): string {
// German readers compose the German body; every other locale composes the
// English body and is told, in its own language, which language to write in.
// The former `locale === "en" ? "en" : "de"` sent French, Spanish, Italian
// and Polish readers a German prompt that asked for German prose.
return composeAssessmentPrompt(
instructionLocale(locale),
targetLanguageName(locale),
);
}
/**
* The base prompt as a STANDALONE system prompt, language directive included.
*
* Use this only when the result is handed to the provider as-is. A module that
* appends its own metric section must instead compose
* `getBaseSystemPromptBody` and wrap the finished string in
* `withOutputLanguage`, so the directive stays the last instruction the model
* reads rather than being buried mid-prompt by the appended section.
*/
export function getBaseSystemPrompt(locale: Locale): string {
return composeAssessmentPrompt(locale === "en" ? "en" : "de");
return withOutputLanguage(getBaseSystemPromptBody(locale), locale);
}
+13 -5
View File
@@ -1,5 +1,7 @@
import type { Locale } from "@/lib/i18n/config";
import { getBaseSystemPrompt } from "./base-system";
import { getBaseSystemPromptBody } from "./base-system";
import { withOutputLanguage } from "./output-language";
import { instructionLocale } from "./output-language";
const BP_SECTION_DE = `METRIK — BLUTDRUCK:
- Der Snapshot trägt systolisch (bloodPressure.systolic) und diastolisch (bloodPressure.diastolic) jeweils mit signal (der fertige Vergleich) + summary + graded series; beurteile beide Komponenten GEMEINSAM, nie isoliert.
@@ -26,10 +28,16 @@ const BP_SECTION_EN = `METRIC — BLOOD PRESSURE:
- One message: if the values sit above the person's baseline, close with ONE doable step ONLY when the finding implies one (e.g. take a few readings at the same time of day for a few days, or — when adherence is patchy — make the medication routine more reliable). When values are stable and in target, say so honestly and name one thing worth keeping an eye on instead of manufacturing a step.`;
export function getBloodPressureSystemPrompt(locale: Locale): string {
const section = locale === "en" ? BP_SECTION_EN : BP_SECTION_DE;
return `${getBaseSystemPrompt(locale)}
// fr/es/it/pl compose the ENGLISH body (the base prompt names their
// language and appends their own directive); only de takes the German one.
const section =
instructionLocale(locale) === "en" ? BP_SECTION_EN : BP_SECTION_DE;
return withOutputLanguage(
`${getBaseSystemPromptBody(locale)}
${section}`;
${section}`,
locale,
);
}
export function getBloodPressureUserPrompt(
@@ -59,7 +67,7 @@ export function getBloodPressureUserPrompt(
openerHint && openerHint.trim().length > 0
? `\nOPENER HINT: ${openerHint}`
: "";
if (locale === "en") {
if (instructionLocale(locale) === "en") {
return `Date: ${todayKey} (Europe/Berlin)${openerLine}
Write one short assessment of this person's blood pressure. Open with what the reading MEANS in plain words — the overall read, not the number (e.g. "calm and right where you want it", "running a touch higher than your usual this week") — then bring in the systolic/diastolic figures right after as support, judged as ONE pair and placed against their own weekly/monthly baseline; never lead with the numbers. Close with one doable step only when the finding genuinely implies one; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock}
+13 -5
View File
@@ -1,5 +1,7 @@
import type { Locale } from "@/lib/i18n/config";
import { getBaseSystemPrompt } from "./base-system";
import { getBaseSystemPromptBody } from "./base-system";
import { withOutputLanguage } from "./output-language";
import { instructionLocale } from "./output-language";
const BMI_SECTION_DE = `METRIK — BMI:
- Der Snapshot trägt bmi.signal (der fertige Vergleich) + bmi.summary + bmi.series (graded). bmi.latestDayFocus zeigt den jüngsten Wert, dessen WHO-Klassifikation und den Schritt zum Vortag; bmi.target ist das grüne Band (18.5-24.9).
@@ -18,10 +20,16 @@ const BMI_SECTION_EN = `METRIC — BMI:
- One message: close with ONE doable step that fits the band placement ONLY when the finding implies one — when the value is stable in the favourable band, acknowledge that honestly and name one thing worth keeping an eye on rather than forcing a finding or a step.`;
export function getBmiSystemPrompt(locale: Locale): string {
const section = locale === "en" ? BMI_SECTION_EN : BMI_SECTION_DE;
return `${getBaseSystemPrompt(locale)}
// fr/es/it/pl compose the ENGLISH body (the base prompt names their
// language and appends their own directive); only de takes the German one.
const section =
instructionLocale(locale) === "en" ? BMI_SECTION_EN : BMI_SECTION_DE;
return withOutputLanguage(
`${getBaseSystemPromptBody(locale)}
${section}`;
${section}`,
locale,
);
}
export function getBmiUserPrompt(
@@ -46,7 +54,7 @@ export function getBmiUserPrompt(
openerHint && openerHint.trim().length > 0
? `\nOPENER HINT: ${openerHint}`
: "";
if (locale === "en") {
if (instructionLocale(locale) === "en") {
return `Date: ${todayKey} (Europe/Berlin)${openerLine}
Write one short assessment of this person's BMI. Open with what its band placement MEANS in plain words — where it sits and whether that's holding or shifting, not the number (e.g. "sitting comfortably in the healthy band", "edging toward the next band up") — then bring in the current value and WHO band right after as support, saying against their own weekly/monthly baseline whether it has crossed a band or is nearing a boundary; never lead with the value. Close with one doable step only when the finding genuinely implies one; when nothing is, skip the step rather than manufacture filler. Leave the weight pace to the weight card. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock}
+15 -5
View File
@@ -1,5 +1,7 @@
import type { Locale } from "@/lib/i18n/config";
import { getBaseSystemPrompt } from "./base-system";
import { getBaseSystemPromptBody } from "./base-system";
import { withOutputLanguage } from "./output-language";
import { instructionLocale } from "./output-language";
const GENERAL_SECTION_DE = `METRIK — GESAMTBEWERTUNG:
- Der Snapshot trägt measurementSeries (eine Map je Metriktyp mit summary + graded series), medicationAdherence (summary + series), bloodPressureTargets (falls vorhanden) und moodContext (falls ≥ 3 Tage). dataCoverage.avgDaysBetweenMeasurements zeigt die Messdichte.
@@ -20,10 +22,18 @@ const GENERAL_SECTION_EN = `METRIC — OVERALL ASSESSMENT:
- One message: even across all metrics, close with EXACTLY ONE most-important, doable suggestion ("one thing") ONLY when the overall picture implies one. When everything is steady and there is nothing useful to do, say so honestly and name one thing worth keeping an eye on instead of forcing a recommendation.`;
export function getGeneralStatusSystemPrompt(locale: Locale): string {
const section = locale === "en" ? GENERAL_SECTION_EN : GENERAL_SECTION_DE;
return `${getBaseSystemPrompt(locale)}
// fr/es/it/pl compose the ENGLISH body (the base prompt names their
// language and appends their own directive); only de takes the German one.
const section =
instructionLocale(locale) === "en"
? GENERAL_SECTION_EN
: GENERAL_SECTION_DE;
return withOutputLanguage(
`${getBaseSystemPromptBody(locale)}
${section}`;
${section}`,
locale,
);
}
export function getGeneralStatusUserPrompt(
@@ -52,7 +62,7 @@ export function getGeneralStatusUserPrompt(
openerHint && openerHint.trim().length > 0
? `\nOPENER HINT: ${openerHint}`
: "";
if (locale === "en") {
if (instructionLocale(locale) === "en") {
return `Date: ${todayKey} (Europe/Berlin)${openerLine}
Write one short overall assessment across the available health metrics. Open with the overall read in plain words — how things are looking taken together, not a number (e.g. "a steady stretch across the board", "one thing standing out this week") — then bring in the one or two metrics that stand out as support, each placed against the person's own weekly/monthly baseline; never lead with a value. Close with the single most important doable step only when the overall picture genuinely implies one; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count, density and recency.${ctxBlock}${extraBlock}
+5 -4
View File
@@ -2,6 +2,7 @@
* System prompt and output formatting for OpenAI insights.
*/
import type { Locale } from "@/lib/i18n/config";
import { instructionLocale } from "./output-language";
import type { ComparisonBaseline } from "@/lib/dashboard-layout";
/**
@@ -50,7 +51,7 @@ export function buildUserPrompt(
? buildComparisonBlock(locale, comparison)
: "";
if (locale === "en") {
if (instructionLocale(locale) === "en") {
const modeLabel =
privacyMode === "raw"
? "Aggregated data + raw values (entire available period, anonymised)"
@@ -92,7 +93,7 @@ export function buildComparisonBlock(
snapshot: ComparisonSnapshot,
): string {
const baselineLabel =
locale === "en"
instructionLocale(locale) === "en"
? snapshot.baseline === "lastMonth"
? "the same window 30 days ago"
: "the same window 365 days ago"
@@ -122,7 +123,7 @@ export function buildComparisonBlock(
};
if (snapshot.metrics.length === 0) {
return locale === "en"
return instructionLocale(locale) === "en"
? `
SYSTEM CONTEXT — COMPARISON MODE ACTIVE
@@ -133,7 +134,7 @@ SYSTEM CONTEXT — VERGLEICHSMODUS AKTIV
Der Nutzer hat den Vergleichsmodus gegen ${baselineLabel} aktiviert, aber für keine Metrik liegen ausreichend Daten aus der Vergleichsperiode vor. Erwähne das explizit im ersten Satz der Zusammenfassung.`;
}
if (locale === "en") {
if (instructionLocale(locale) === "en") {
return `
SYSTEM CONTEXT — COMPARISON MODE ACTIVE
+2 -1
View File
@@ -13,6 +13,7 @@
* assessment then stays personal-relative, exactly as before this landed).
*/
import type { Locale } from "@/lib/i18n/config";
import { instructionLocale } from "./output-language";
import {
classifyBandPosition,
resolveInterpretation,
@@ -136,7 +137,7 @@ export function buildInterpretationBlock(args: {
const resolved = resolveInterpretation(args.metricKey, args.sex ?? null);
if (!resolved) return undefined;
const loc: "en" | "de" = args.locale === "en" ? "en" : "de";
const loc: "en" | "de" = instructionLocale(args.locale);
const pos = classifyBandPosition(args.value, resolved.bands);
const rows = resolved.bands
+13 -5
View File
@@ -1,5 +1,7 @@
import type { Locale } from "@/lib/i18n/config";
import { getBaseSystemPrompt } from "./base-system";
import { getBaseSystemPromptBody } from "./base-system";
import { withOutputLanguage } from "./output-language";
import { instructionLocale } from "./output-language";
const MEDCO_SECTION_DE = `METRIK — MEDIKAMENTEN-EINNAHMETREUE:
- Der Snapshot trägt overall (medicationCount, averageCompliance7, averageCompliance30) und medications[] je Medikament mit name, dose, schedulesPerDay, compliance7, compliance30, streak, taken7/skipped7/missed7, signal (der fertige Trend-Vergleich), dailySeries (graded) und latestDay.
@@ -20,10 +22,16 @@ const MEDCO_SECTION_EN = `METRIC — MEDICATION ADHERENCE:
- One message: close with ONE doable step that fits the gap ONLY when the finding implies one (e.g. evening doses are missed more often — a fixed routine or a reminder at the critical time can help). When adherence is consistently high, acknowledge that honestly rather than manufacturing a step. No blame.`;
export function getMedicationComplianceSystemPrompt(locale: Locale): string {
const section = locale === "en" ? MEDCO_SECTION_EN : MEDCO_SECTION_DE;
return `${getBaseSystemPrompt(locale)}
// fr/es/it/pl compose the ENGLISH body (the base prompt names their
// language and appends their own directive); only de takes the German one.
const section =
instructionLocale(locale) === "en" ? MEDCO_SECTION_EN : MEDCO_SECTION_DE;
return withOutputLanguage(
`${getBaseSystemPromptBody(locale)}
${section}`;
${section}`,
locale,
);
}
export function getMedicationComplianceUserPrompt(
@@ -48,7 +56,7 @@ export function getMedicationComplianceUserPrompt(
openerHint && openerHint.trim().length > 0
? `\nOPENER HINT: ${openerHint}`
: "";
if (locale === "en") {
if (instructionLocale(locale) === "en") {
return `Date: ${todayKey} (Europe/Berlin)${openerLine}
Write one short assessment of this person's medication adherence. Open with what it MEANS in plain words — how reliable the routine is looking, not the number (e.g. "your routine's been rock-solid", "a few doses have slipped lately") — then bring in the recent rate right after as support, placed against their own baseline (compliance7 vs compliance30); never lead with the value. Close with one doable, blame-free step only when the finding genuinely implies one; when nothing is, skip the step rather than manufacture filler. Judge confidence from the event count and recency.${ctxBlock}${extraBlock}
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff. Show more