Compare commits

...
81 Commits
Author SHA1 Message Date
Marc-André Bombeck 42235076ac Merge pull request #546 from MBombeck/release/v1.30.22
release/v1.30.22 — background work stops piling up on itself
2026-07-19 03:11:26 +02:00
Marc-André Bombeck a6600f0215 chore(release): v1.30.22 — background work stops piling up on itself 2026-07-19 03:00:36 +02:00
Marc-André Bombeck e6862df048 Merge branch 'worktree-agent-a87a74abcff7c248e' into release/v1.30.22 2026-07-19 02:56:57 +02:00
Marc-André Bombeck 99dc3b6915 Merge pull request #545 from MBombeck/release/v1.30.21
release/v1.30.21 — dose reminders land on the right hour across a clock change
2026-07-19 02:56:53 +02:00
Marc-André Bombeck 81e564a724 chore(release): v1.30.21 — dose reminders land on the right hour across a clock change 2026-07-19 02:44:42 +02:00
Marc-André Bombeck caa37b74b5 Merge branch 'worktree-agent-af686749a7ca278d4' into release/v1.30.21 2026-07-19 02:38:34 +02:00
Marc-André Bombeck 44046eda22 Pin the queue policy decisions with tests
Two layers. The integration suite runs a real pg-boss against a real
Postgres and proves the semantics the policies rest on: that a bare
singletonKey under the default policy de-duplicates nothing, that
exclusive suppresses a duplicate while the first job is queued AND while
it is active, that short suppresses only while queued and admits a send
once the job is active, and that a distinct key always enqueues.

It also pins the two facts that make the fix reach a running instance:
createQueue does not change the policy of a queue that already exists,
and updateQueue refuses a policy change, so the reconcile is required.

The unit guard couples each enqueue site to its registrar entry. The key
and the policy live in different files with nothing in the type system
holding them together, so deleting an entry would leave the call site
looking correct while it silently stopped working.
2026-07-19 02:31:45 +02:00
Marc-André Bombeck 40ef4b40b0 test(medications): pin the slot dedupe contract across differing reference instants
The projector, the reminder worker and the intake write path key on the slot
instant byte-for-byte, and all three derive it from a DIFFERENT reference —
a cron tick, a later cron tick, and the moment the user pressed take. That
is the whole hazard: the primitive used to sample the zone offset at its
reference, so on a transition day two ticks on opposite sides minted
instants an hour apart for the same slot and the dedupe key stopped
matching.

The existing DST cases compare the resolver to a projector mint computed
from the SAME reference, which cannot see this. Pass deliberately different
references across both Berlin transitions.
2026-07-19 02:28:01 +02:00
Marc-André Bombeck a54cb0a47f Merge pull request #544 from MBombeck/release/v1.30.20
release/v1.30.20 — refuse a restore whose payload names a different owner
2026-07-19 02:27:15 +02:00
Marc-André Bombeck 4791d6f543 test: pin the suite's timezone and make the zone-sensitive fixtures say which zone they mean
The suite was not timezone-pinned. CI runs UTC, so a whole class of
day-bucketing defect was invisible to the gate while a contributor outside
UTC saw a red suite on unmodified main. Verified: a UTC+14 host fails five
tests on an otherwise green tree. Pin TZ=UTC in both vitest configs; tests
that need a different HOST zone now set and restore it explicitly.

The resting-pulse fixtures passed offset-less ISO literals, which parse as
host-local, so they could not distinguish the Berlin day-key the
implementation uses from a UTC one — swapping the day-key's zone left the
file green. Build each instant explicitly in Berlin and add cases whose
local day and UTC day genuinely differ; that swap now fails two of them.

Delete the 'DST boundary (spring-forward)' compliance case. It passed no
timezone, so the slot builder took the legacy UTC path where the date
carries no transition, and it asserted totalExpected === 7 — which holds in
every zone and therefore could never see the double-emit its own comment
described. Threading a real zone does not rescue it: the assertion stays
true in Berlin, UTC, UTC+14 and UTC-11 alike. The property it claimed is
already pinned properly in the scheduling suite, which walks
buildCadenceTimeline with an explicit zone across both the 23 h
spring-forward and the 25 h fall-back day.
2026-07-19 02:27:03 +02:00
Marc-André Bombeck 4ba600d908 refactor(medications): share the next-intake day label between card and table
The card and the table carried byte-identical copies of the day-label
arithmetic. Two copies is how they drift, and neither was reachable from a
test, which is why the double conversion in both survived review.

Lift it into resolveNextDueDayLabel, which returns a discriminated label the
callers map onto their own strings, and cover the case the old code could
not get right: a browser zone that differs from the profile zone. Those
tests pin the host zone explicitly — the defect was invisible whenever host
and profile agree, which is the only configuration a UTC-only suite runs in.

Also pin that the window-status verdict is identical from every host zone,
including one whose spring-forward gap swallows the profile wall clock.
2026-07-19 02:19:37 +02:00
Marc-André Bombeck f2083eba36 Give the status, integration-sync and maintenance queues explicit policies
The per-connection backfills and per-user one-shots take 'exclusive': a
worker restarting during a heavy account's multi-hour pass used to append
another identical full-history job per restart, because the discovery
predicate only stops offering a job once it has completed.

The morning-digest refresh takes 'exclusive' so its user+localDate key
becomes a real at-most-once-per-morning contract; a wall-clock
singletonSeconds window cannot express a local date.

The per-document queues take 'short' -- they have no discovery pass to
re-converge, so only queued duplicates may be collapsed or a re-process
after a content change would be lost.

Leave the environment-fetch queue on 'standard' and say why: its
explicit-range backfill deliberately sends without a key, so any policy
would silently merge two different requested ranges into one.
2026-07-19 02:17:57 +02:00
Marc-André Bombeck 3ffaf1906c fix(admin): refuse a restore whose payload names a different owner
The restore target came from the encrypted payload's own userId field
while the audit entry recorded the backup row's userId. A file declaring
a different account would therefore have been written into that account,
with the trail naming the one the operator selected.

Both values are in hand at that point, so compare them and refuse on
mismatch, recording both ids. Adds the first test coverage for this
route; the mismatch case fails without the guard.

Also correct the CLAUDE.md claim that every API route wraps apiHandler -
the three MCP OAuth routes deliberately use withBackgroundEvent so they
can return RFC-format error bodies, and carry the same protections by
hand.

chore(release): v1.30.20
2026-07-19 02:16:54 +02:00
Marc-André Bombeck 1045639076 Give the rollup queues an explicit pg-boss de-duplication policy
Every queue was created under pg-boss's default 'standard' policy, under
which no partial unique index constrains singleton_key. The singletonKey
each enqueue site passed was therefore inert and de-duplicated nothing.

Add a per-registrar policy table that records the decision and the reason
per queue. The recompute queues take 'short' (collapse only while queued,
because the handler re-reads the bucket at run time); the per-user
backfills take 'exclusive' (queued or active, because a second concurrent
run is pure duplicated work and discovery re-enqueues while work remains).

createQueue only applies a policy on first provision, so also reconcile
the stored policy for queues that already exist -- without that the tables
would be correct on a fresh database and inert on every running instance.
2026-07-19 02:16:06 +02:00
Marc-André Bombeck eb071b5dd8 fix(medications): drop the fabricated-Date wall clock from the window status
toZonedDate rendered an instant as an en-US string in the target zone and
re-parsed it as host-local. Two defects rode on that.

When the fabricated wall clock landed in the HOST zone's spring-forward gap
the engine silently advanced it an hour, so a dose inside its window could
be classified late and a just-taken dose could fail to suppress the take-now
pill — for users whose device zone differs from their profile zone.

The medication card and table then passed that already-shifted value into
dateWithWeekdaySmart, which applies the profile zone itself, so the next-
intake day label converted twice and could name the wrong calendar day.
Invisible whenever browser zone and profile zone agree.

Migrate every consumer to wallClockInTz, which returns the fields directly.
The status math only ever reads year/month/day/hour/minute/weekday, so it
now runs on WallClockParts throughout and reduceCurrentWindowStatus takes
the real instant instead of a pre-shifted Date — caller and helper can no
longer disagree about which zone the comparison runs in. Day labels use a
calendar-day distance (localDayIndex) rather than an hour count, so a 23 h
or 25 h day cannot round tomorrow into today, and the formatter receives
the raw instant.

Fixtures name the zone they mean instead of building host-local Dates.
2026-07-19 02:13:08 +02:00
Marc-André Bombeck 75943496f7 Merge pull request #543 from MBombeck/release/v1.30.19
release/v1.30.19 — switching a module off now holds everywhere
2026-07-19 02:10:00 +02:00
Marc-André Bombeck 91c4f789db fix(tz): settle localHmAsUtc's offset at the target local time
localHmAsUtc sampled the zone offset at the reference instant and applied
it to the target wall clock, so on a DST-transition day the answer depended
on which side of the transition the reference sat: a 20:00 slot resolved to
19:00 local when the caller ticked before the Berlin autumn change and
correctly when it ticked after.

That broke the slot-dedupe contract. The projector, the reminder worker and
the intake write path each pass their own reference instant for the same
slot and key on the resulting instant byte-for-byte, so on that day they
disagreed and a duplicate pending row became reachable.

Rebuild it on zonedWallClockToUtc: resolve the local calendar day from the
reference, then convert that day's hh:mm. The offset now settles at the
target local time, and because zonedWallClockToUtc runs the same two-pass
converge as startOfLocalDayInTz, localHmAsUtc(x, tz, 0, 0) equals
startOfLocalDayInTz(x, tz) by construction — the two used to disagree by an
hour on transition days, which opened the one-shot on-time band an hour into
the previous local day.

Cover both Berlin transitions from references on either side, plus a
southern-hemisphere zone whose transitions invert.
2026-07-19 02:05:24 +02:00
Marc-André Bombeck 6b08ffd7c5 chore(release): v1.30.19 — module switches hold on the aggregate surfaces; consent required before assistant egress 2026-07-19 01:58:47 +02:00
Marc-André Bombeck d2656a2d90 Merge branch 'worktree-agent-ad9c547e230ce64bd' into release/v1.30.19 2026-07-19 01:53:18 +02:00
Marc-André Bombeck aed541910c Merge branch 'worktree-agent-aad9f8f93f1f3817b' into release/v1.30.19 2026-07-19 01:53:18 +02:00
Marc-André Bombeck 9084737635 Make the module gate inventory prove a gate, not a mention
The inventory had a BUILDER-GATED bucket that accepted the mere presence
of an aggregator's name -- loadFhirContext, collectDoctorReportData -- as
proof a route was gated. That proved nothing. loadFhirContext filtered
per-domain modules but never evaluated doctorReport, so all five
/api/fhir data routes sat in a green bucket while serving the whole
record to an account that had the module off.

Drop the bucket. A route now counts as gated only when it calls a gate,
and the orphan message says plainly that importing a module-aware builder
is not one.

Add two assertions the old shape could not make. REQUIRED_TREE_MODULE
pins a tree to the key its routes must name, so /api/fhir cannot satisfy
the inventory by gating on some other, more permissive module.
BEHAVIOURAL_GATE_TESTS lists the tests that actually exercise a disabled
module end to end and fails if one goes missing, since this file reads
source text and can never prove a refusal itself.

Verified by removing the gate from the Patient route with the builder
import left in place: the old inventory passed, this one names the file.
2026-07-19 01:46:51 +02:00
Marc-André Bombeck 91a605de2a test(ai): freeze the consent boundary around provider resolution
Both consent gaps had the same shape: a module resolved a live provider and
nothing connected that to "check a receipt first". Behavioural tests cover
the two surfaces; this covers the class.

A module importing resolveProvider, resolveProviderChain or
resolveProviderForTest holds something it can call generateCompletion on, so
it must also import a consent helper — or sit on an allowlist with a written
reason. Four entries today, all of them probes or the credential connection
test, none of which send health data.

The guard is an import-level tripwire and its header says so: it cannot show
the gate is reached on every path, called before the egress, or passed the
right surface. What it does is stop a new ungated call site landing without a
reviewer seeing this file change.
2026-07-19 01:45:30 +02:00
Marc-André Bombeck 5211771e8d fix(ai): require consent before server-managed egress on two coach surfaces
The self-context clarifying questions and the AI-composed proactive nudge
both reached a provider without a consent check of any kind. Either could
resolve to the operator's server-managed credential (admin-openai or the
shared central Codex), so a user with no AI credentials and no receipt on
file had health data leave the server on the operator's key.

The questions path is the sharper of the two: its prompt carries the
complete Coach snapshot. Gate both with the chain predicate pair the status
path already uses, on the coach surface. The gate is skip-shaped on both —
the questions fall back to the deterministic hints, the nudge keeps its
deterministic template — so a missing receipt costs the user nothing but the
model's phrasing. BYOK, local and ChatGPT-OAuth chains stay ungated; that
egress is the user's own act.

Move the questions path off the read-then-write budget check onto the atomic
reserve/reconcile pair, so two concurrent saves can no longer both observe a
sub-cap spend and both call the provider. The cap now follows the chain's
cost owner instead of always charging the operator ceiling.
2026-07-19 01:43:27 +02:00
Marc-André Bombeck a83fb6f936 Gate the dashboard summary payload on the module map
GET /api/dashboard/summary builds its own payload instead of going
through readDashboardSnapshotCached, so it never picked up the module
gate the shared snapshot builder applies. It emitted glucose and sleep
cards on data presence alone while /api/dashboard/snapshot correctly
withheld them for the same account.

Lift the drop decision out of gateSummariesByModules into
disabledSummaryTypes in the client-safe widget-modules module, so the two
dashboard aggregates decide from one map. The snapshot keys its summaries
by MeasurementType and the summary route emits cards keyed by an iOS
MetricKind, so what is shared is the decision, not the filtering;
SUMMARY_METRIC_TYPE_BY_KIND is the join between the two.

Omit rather than refuse, matching the snapshot sibling: the payload spans
ten cards across several domains, and blanking the dashboard because one
module is off would be the wrong trade. Core vitals always pass through.

The filter runs outside the analytics cache on purpose. That LRU is keyed
by user with a 60 s fresh / 1 h stale window and a module toggle does not
evict it, so gating inside the builder would keep serving a disabled tile
for up to an hour after the user turned it off.

Wire shape unchanged -- a gated card is absent, not null.
2026-07-19 01:42:56 +02:00
Marc-André Bombeck 4548df84bf Gate the FHIR REST face on the doctorReport module
/api/fhir/* serves the same whole-record aggregate as
/api/export/health-record -- right down to the decrypted insurance number
on the Patient resource -- but never consulted the module gate. The
export refuses with a 403 module.disabled envelope when the account turns
the doctor-report module off; the FHIR face happily returned the full
Bundle to the same token.

Gate all five data routes (Patient, Observation, MedicationStatement,
MedicationAdministration, $everything). Refuse rather than omit: a
whole-record export has no truthful partial answer, and unlike the sync
delta feed no background client depends on it draining to stay
consistent. The refusal reuses the shared module.disabled envelope so the
errorCode clients already branch on stays the same.

loadFhirContext gains a fail-closed assertion as a second layer. It is
the one door to the aggregate, so it refuses outright instead of trusting
that every future caller remembers its gate; reaching it means a route is
missing one, which is a bug to surface rather than a flow to serve.

The static CapabilityStatement at /api/fhir/metadata stays open -- server
metadata, no user data.
2026-07-19 01:39:58 +02:00
Marc-André Bombeck 24b1269b28 Merge pull request #542 from MBombeck/release/v1.30.18
release/v1.30.18 — health data and credentials no longer reach the logs or the backups
2026-07-19 01:39:34 +02:00
Marc-André Bombeck 076339c274 Gate the cycle domains in the sync delta feed
The multi-domain feed at GET /api/sync/changes read cycleDayLog and
menstrualCycle without consulting the module gate, so an account with
cycle tracking off still received period days and per-day symptom rows
over the same token that every /api/cycle/* route refuses with a 403.

Resolve the cycle module once per request and skip both queries when it
is off, rather than filtering after the read. The blocks come back empty
and the request still succeeds: the feed multiplexes five domains over
one cursor, so refusing it outright would wedge measurement, mood and
intake catch-up for every account that has cycle tracking off. The two
cycle watermarks stay untouched while the module is off, so re-enabling
resumes where the client left off.

A client that synced cycle rows before the module was turned off keeps
its stale local mirror. Tombstones would be the wrong signal there --
they mean deleted, and disabling a module must leave the rows intact --
so purging a paired mirror needs an explicit signal in the feed contract
and is left for a coordinated contract cycle.

Wire shape unchanged.
2026-07-19 01:35:14 +02:00
Marc-André Bombeck e31c66e959 fix(security): scrub wide events at the builder, encrypt the replay cache
Three leaks of the same class - data leaving the process unprotected.

addMeta and addExternalCall wrote their values verbatim while setError
and setHttp scrubbed, so the builder's redaction contract held for two
entry points out of four. The finished event is serialised whole to
stdout and the log store, which made meta a trusted sink by accident: a
Telegram request timeout put the bot token there, and the AI provider
chain fed upstream error bodies through it. Both now scrub, at any depth.

Scrubbing cannot help with the second half. A gateway that echoes the
request it rejected echoes the prompt, and on that path the prompt
carries the health snapshot; no pattern separates that from any other
text. Upstream bodies are therefore classified rather than quoted - the
diagnostic question is which rejection occurred and whether it repeats,
which a classification answers without the content.

The idempotency replay cache stored response bodies unencrypted. The
create paths echo their own decrypted payload, so cycle notes, mood text
and allergy reactions sat in cleartext for 24 hours in a column that
lands in every backup; the existing guard missed it because it matches
secret shapes and health data is not secret-shaped. Bodies are encrypted
now, and when encryption is unavailable the response is not cached at
all rather than written in the clear.

chore(release): v1.30.18
2026-07-19 01:28:20 +02:00
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
Marc-André Bombeck 9a6a05a0ef Merge pull request #531 from MBombeck/release/v1.30.7
release/v1.30.7 — 10-minute aggregated heart-rate upload contract
2026-07-18 11:36:53 +02:00
Marc-André Bombeck 0b37c15a52 chore(release): v1.30.7 — 10-minute aggregated heart-rate upload contract 2026-07-18 11:21:17 +02:00
Marc-André Bombeck 5fa18a3754 feat(measurements): 10-minute aggregated heart-rate upload contract
Refine the go-forward aggregated HR wire contract (iOS #34) from the never-
adopted hourly bucket to a 10-minute UTC bucket, so one contract serves both
the measurements-list de-explosion and the intraday-pulse 10-min curve.

- Batch route accepts stats:HKQuantityTypeIdentifierHeartRate:<10-min ISO-Z>
  with avg + min/max, overwrite semantics, malformed-suffix rejection; the
  validator is identifier-generic (allowlist) so respiratoryRate/SpO2 can join
  later without a grammar change.
- The intraday reader classifies a day's PULSE rows three ways by externalId
  shape (raw / uploaded 10-min bucket / server-folded hourly), builds the
  10-min curve directly from uploaded buckets with the min/max envelope and
  tension detection enabled, and overlays buckets over raw on the one
  cutover-straddling day.
- DAY rollup min/max read COALESCE(value_min, value) so a bucket-native day's
  band reflects the true intra-bucket extremes.

No schema change (value_min/value_max exist since v1.19.2). Reconciliation is
go-forward cutover; the nightly retention fold handles the raw backlog.
2026-07-18 11:20:26 +02:00
233 changed files with 16592 additions and 3907 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
+147
View File
@@ -2,6 +2,153 @@
## [Unreleased]
## [1.30.22] — 2026-07-19
Background work stops piling up on itself.
- **Duplicate background jobs are now actually suppressed.** Jobs were queued with a de-duplication key, but the queues were configured in a way that made that key have no effect — so the key looked right in the code and did nothing at run time. A large sync could fan out thousands of identical recompute jobs where tens were intended; a restart during a long history import could append another full import per restart; and the once-per-morning refresh had no debounce, so several paths could each trigger their own run. Each queue now carries an explicit policy chosen for what that queue does, recorded with its reason.
- Existing installations are migrated on the next start — the setting could not be changed after a queue was created, so setting it at creation alone would have fixed nothing on any running instance. The first start after this release logs one line per migrated queue, which is how you can confirm it took effect.
- One queue is deliberately left as it was: the explicit-range environment backfill sends without a key on purpose, and any de-duplication there would merge two different requested date ranges into one.
No breaking changes.
## [1.30.21] — 2026-07-19
Dose reminders land on the right hour across a clock change.
- **A reminder on a clock-change day now fires at the time you set.** The conversion from your local time to a real instant read the timezone offset at the moment the scheduler happened to run, not at the time the dose was actually due. On the autumn change in a timezone that observes one, a 20:00 dose could resolve to 19:00 when the scheduler ticked before the change. Two days a year, and only in timezones that change their clocks — but a dose reminder should never be an hour off. Nothing stored changed; the same schedule now produces the correct instant.
- **A duplicate pending dose was possible on those same days.** The reminder projector, the worker and the intake write derived the instant separately and could disagree on the change day, which broke the matching that keeps them on one row.
- **The next-dose day label could show the wrong day** when your device timezone differs from the one in your profile, because the offset was applied twice. The two copies of that label — in the card and in the table — are now one.
- The tests could not have caught any of this: they used times with no timezone attached, so the day boundary could not be distinguished from a UTC one, and the suite ran only in one host timezone. They now state their timezone explicitly, cover both clock changes, and the suite is pinned to match the build.
No breaking changes.
## [1.30.20] — 2026-07-19
- **A restore now refuses when the backup file names a different account than the backup record.** The restore target was read from inside the encrypted file rather than from the record the operator selected, so a file claiming a different account would have been written into that account instead — while the audit entry recorded the one the operator picked. Both values were already available at that point; they are now compared, and a mismatch is refused and recorded with both.
No breaking changes.
## [1.30.19] — 2026-07-19
Switching a module off now holds everywhere.
- **Three surfaces ignored your module switches.** The per-topic endpoints have always honoured them, but the sync feed still sent cycle data, the FHIR export still returned the full record including the insurance number, and the dashboard summary still built glucose and sleep cards — all regardless of whether those modules were on. Each now honours the switch: the sync feed leaves the cycle rows out, the FHIR export refuses the same way the health-record export already did, and the summary omits the cards. Nothing is deleted — turning a module back on finds the history complete, and the sync feed picks up where it left off rather than skipping what changed while it was hidden.
- **Two assistant features reached a provider without a consent record.** The follow-up questions after saving your profile, and the assistant's own nudges, both sent data to a provider without checking whether consent was on file — and could fall back to the server-wide key. Both now check first and fall back to their non-generated text when consent is absent. The follow-up questions also moved to the same reservation-based spend accounting everything else uses; a self-hoster on their own key is no longer measured against the operator's daily ceiling.
- The check that was supposed to catch the module gaps accepted a helper being imported as proof that a gate existed. It now pins which module each surface must check and names the tests that exercise a disabled one.
No breaking changes to the web app. The native client may see a 403 from the FHIR endpoints and absent cards where a module is off — see the coordination note.
## [1.30.18] — 2026-07-19
Health data and credentials no longer reach the logs or the backups.
- **Cached responses are encrypted at rest.** A write endpoint's response is kept for 24 hours so a retried request cannot run twice. Those responses echo what was just saved — cycle notes, mood text, allergy reactions — and were stored unencrypted, in a column that lands in every backup. They are now encrypted like every other stored health field. If encryption is unavailable the response is simply not cached, rather than being written in the clear.
- **Diagnostic log entries are scrubbed at the point they are written.** Two of the places that build a log entry did not run the redaction the others did, so an outbound request that timed out could put a notification bot's credential into the logs verbatim.
- **A failed AI request no longer quotes what the provider sent back.** Some providers echo the request they rejected, which on this path contains the prompt and the health figures in it. The log entry now names the kind of rejection instead of quoting the response, which answers the same diagnostic question without carrying the content.
Operator note: no action required, and no configuration change. Log lines and backups written before this release may contain the values described above.
No breaking changes.
## [1.30.17] — 2026-07-19
API tokens now enforce their scope.
- **A token minted for medication intake is refused outside the ingest endpoint.** Previously any valid token reached every authenticated route regardless of the scope it carried, so a token handed to a third-party automation could read the full-account backup export, lab results, measurements and the assistant. A route that accepts a narrow-scope token now has to name that scope; a route that names none accepts browser sessions and the app's own full-access tokens only. Tokens issued to the web session, the native client and the MCP connector are unaffected.
- **Revoke and re-issue any token you handed to a third party.** A token that could reach the backup export should be treated as having had that reach for its whole lifetime, not only from the moment it was noticed.
- **The generic "create API token" button is gone from settings.** The token it minted never worked for the medication intake it advertised — the ingest endpoint also requires a per-medication grant that this button never issued — while reaching everything else. Tokens are issued per medication from the medication's own API-endpoint toggle, which grants exactly that one medication. Existing tokens stay listed and revocable.
- **MCP connector tokens are now bound to the MCP endpoint alone.** They previously also passed on read-only REST requests. No connector flow used that.
No breaking changes to the web session, the native client, or the MCP connector.
## [1.30.16] — 2026-07-19
Stops an ongoing loss of resting heart-rate history.
- **Resting heart rate is preserved again for accounts without a device-supplied resting figure.** Older heart-rate readings are condensed on a schedule to keep the database small, and a resting value is derived and stored before the raw readings go. The check that decides whether an account already has its own resting data was matching the derived values it had written itself, so from the second run onward it concluded the data was already there and stopped deriving — while the raw readings it derives from were still being cleared. Each run lost another day's resting figure permanently. The check now looks only at readings the account actually supplied.
- Days already affected cannot be reconstructed; from this release forward, nothing further is lost.
No breaking changes.
## [1.30.15] — 2026-07-18
Generated health texts now come in the language you set.
- **French, Spanish, Italian and Polish accounts get their assessments in their own language.** Every locale other than English previously fell back to the German instruction set, so those four either read German text or — where the pipeline narrowed them earlier — English text. Insight cards, the per-metric assessments, derived scores, the biomarker card, the period narrative and the assistant's tone settings all now follow the account's own language, with the wording rules that were already translated for each of the six languages. German and English output is unchanged, character for character.
- **The language instruction is the last thing the model reads**, rather than being buried before the metric-specific section that follows it.
- Under the hood: one shared resolver replaces the scattered English-or-else-German branches, and the locale now survives the whole generation path instead of being narrowed on the way in. Stored texts for the four languages regenerate on their next scheduled run; German and English keep reading exactly the rows they already had.
No breaking changes.
## [1.30.14] — 2026-07-18
The workouts page joins the pages that paint on first load.
- **The workouts list opens with the sessions already there.** It was the last high-traffic surface still fetching its rows after the page mounted, so it showed a loading skeleton first and filled in a moment later. The list is now read on the server and handed to the page populated, the same way the dashboard, insights, medications, and coach pages already work. The list logic moved into a shared read so the page and the API endpoint go through one cached, deduplicated pass instead of two.
- **API docs: the heart-rate bucket example matches what the server accepts.** The aggregated heart-rate `externalId` was documented with an hourly example from its first version; the contract has been 10-minute buckets since v1.30.7. The description, the example timestamp, and the min/max field notes now describe the shipped contract.
No breaking changes.
## [1.30.13] — 2026-07-18
More pages that paint on first load.
- **The insights, medications, and coach pages open with their content already there**, the same way the dashboard now does — their above-the-fold data is read on the server and handed to the page populated, instead of a shell that fills in after a second round of requests. Nothing that would trigger AI work is pre-run: the daily briefing and the coach's own reads stay on-demand as before.
No breaking changes.
## [1.30.12] — 2026-07-18
Faster medication reads.
- **The dashboard's medication section stays fast as your log grows.** Its reads now use dedicated indexes instead of scanning the full intake history on every load, so a long history no longer slows down the daily view.
No breaking changes.
## [1.30.11] — 2026-07-18
Native single sign-on.
- **The iOS app can sign in through your identity provider (OIDC SSO).** Until now, single sign-on only worked in the browser — the native app couldn't complete it, which locked native users out entirely on an SSO-only instance. The server now supports a native sign-in handoff (an in-app authorization flow that returns the app's normal token, never a cookie), so the app can offer SSO once it adopts the flow. Existing password and passkey sign-in are unchanged. Multi-factor sign-in still applies, and an SSO session never satisfies a step-up prompt — those still require your own second factor.
No breaking changes.
## [1.30.10] — 2026-07-18
Localized insight text, and a richer workout view.
- **Correlation insights now read in your language.** The "this tends to go with that — a pattern worth watching, not a cause" lines under a metric were always written in English; they now render in your interface language (German, Spanish, French, Italian, Polish), with the association-not-causation framing preserved in each.
- **A workout you open shows more of what was recorded.** The detail view now draws the session's heart-rate curve over time, its route as a clean line, the time spent in each effort zone, per-kilometre splits, and how it compares to your own average for that sport — all from data already synced, with no new connection. Anything a given workout didn't record simply isn't shown.
No migration. No breaking changes.
## [1.30.9] — 2026-07-18
Faster dashboard first load.
- **The top card and the charts paint with their data on first load** instead of flashing empty and then filling in. The daily summary and the charts' 31-day series are now read on the server and handed to the page already populated, so there's no wait for a second round of requests after the page appears. The welcome/summary card, likely the slowest thing to show before, now lands with the first paint.
No migration. No breaking changes.
## [1.30.8] — 2026-07-18
Hardening for the new heart-rate upload validation.
- **A malformed bucket timestamp is rejected, not silently misfiled.** A bucket whose time rolled over (a 24:00, an impossible day) is now refused rather than stored under a non-standard key, where it would have failed to merge with a re-post and could land on the wrong day.
- **The per-window low/high is sanity-checked before it counts.** A window's low and high are kept only when they are in a plausible range and sit either side of the average; a glitched reading no longer skews the day's high/low band. The average is always kept.
No migration. No breaking changes.
## [1.30.7] — 2026-07-18
Groundwork for a tidier heart-rate history.
- **The server accepts a 10-minute aggregated heart-rate upload.** A watch records heart rate every few seconds, and uploading one row per raw sample fills the measurements list with the same value repeated many times a minute. The server now takes one row per 10-minute window — its average, with the low and high of the window — and the within-day pulse curve reads it directly, so the detailed 10-minute chart and the elevated-at-rest note are preserved. The de-duplication reaches the list once the mobile app sends the aggregated shape; existing per-sample history is unaffected and reconciles itself over time.
- **The daily high/low reflects the true spread.** A day built from 10-minute windows now reports the real intra-window extremes, not the range of the averages.
No migration. No breaking changes.
## [1.30.6] — 2026-07-18
Accessibility pass.
+4 -3
View File
@@ -86,9 +86,10 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file:
- **UI work follows the design standards.** `.planning/audits/2026-07-05-fable/UI-STANDARDS.md` is the binding design rulebook (card anatomy via TileHeader, spacing scale, text-colour rules — foreground for content, muted for meta only —, token discipline, primitive mapping). Read it before touching any surface; deviations need a written reason in the PR. Where the standards conflict with older in-file comments, the standards win.
- **RSC by default.** Server components are the default; add `"use client"` only when a hook, state, or browser API actually needs it.
- **Every API route wraps in `apiHandler`.** Verified — zero exceptions in the current tree. Every body-accepting route runs Zod `safeParse` and returns 422 via `returnAllZodIssues` (multi-issue envelope sanitised against echoed input through `sanitiseZodIssues`).
- **Every API route wraps in `apiHandler`, with three documented exceptions.** The MCP OAuth routes (`mcp/oauth/{authorize,token,register}`) use `withBackgroundEvent` instead, because OAuth requires RFC-format error bodies that the standard envelope cannot express. They carry the same protections by hand — rate limit, body cap, kill switch, mandatory PKCE-S256, redirect-URI allowlist. Every other route wraps. Every body-accepting route runs Zod `safeParse` and returns 422 via `returnAllZodIssues` (multi-issue envelope sanitised against echoed input through `sanitiseZodIssues`).
- **`userId` is always narrowed from session or Bearer.** No route accepts `userId` as a body field — it comes from `requireAuth()` and feeds the Prisma `where`. The batch endpoints (`/api/measurements/batch`, `/api/mood-entries/bulk`, `/api/medications/intake/bulk`, `/api/workouts/batch`) don't even declare a `userId` field in their Zod schemas.
- **No mass assignment.** Every `prisma.X.{create,update}({ data: ... })` builds its `data` object field-by-field from `parsed.data`, never by spreading the parsed object whole. `tokens.create` hardcodes the narrow scope so the user-facing endpoint can never mint a wildcard token.
- **No mass assignment.** Every `prisma.X.{create,update}({ data: ... })` builds its `data` object field-by-field from `parsed.data`, never by spreading the parsed object whole. `tokens.create` builds `permissions` from a hardcoded literal, never from request input.
- **Bearer scope is fail-closed.** `requireAuth()` with no argument admits a cookie session or a wildcard (`["*"]`) token only — a narrow-scope token is refused with 403 and an `undeclared_scope` audit row. A route that means to accept a narrow token names the scope: `requireAuth(FHIR_READ_SCOPE)`. Adding a route therefore cannot silently widen an existing token's reach, because the default arm denies. Only the login / passkey / refresh paths mint `["*"]`; every user-facing mint is narrow, and narrow now means narrow. Two surfaces resolve a Bearer token outside `requireAuth` — `/api/ingest/medication` and `/mcp` — and that set is frozen by a structural test (`src/__tests__/bearer-scope-enforcement-guard.test.ts`).
- **`requireAdmin()` is cookie-only.** Bearer tokens — even with `["*"]` scope — cannot reach admin endpoints. This is a structural boundary in `requireAdmin()` (`src/lib/api-handler.ts`), not a runtime check that future code can soften.
- **Raw SQL is parameter-bound or whitelist-spliced.** Every `$queryRaw` uses tagged-template parameters. Every `$queryRawUnsafe` / `$executeRawUnsafe` either passes positional `$N` placeholders with an argument array OR splices a value already asserted against a closed enum + regex (see the whitelist-splice block in `src/lib/rollups/measurement-rollups.ts`, around the `$queryRawUnsafe` calls). Inline comments document the whitelist at the splice point.
- **TanStack Query keys live in the centralised factory.** `src/lib/query-keys/` (16 modules, `index.ts` barrel) is the only legal source of `queryKey` / `mutationKey` arrays. The in-repo ESLint rule `healthlog/queryKey-factory` (set to `error`) flags any bare array. Same-key + different `queryFn` shape silently poisons the cache; the factory prevents it. Every read unwraps `(await res.json()).data` from the envelope.
@@ -165,7 +166,7 @@ The native SwiftUI client lives in a separate repository and rides public beta v
- The `clientManaged` opt-in on `PATCH /api/auth/me/notification-prefs` suppresses server-side `MEDICATION_REMINDER` APNs for clients that manage their own local reminders. The cron skips dose-due APNs when the flag is `true` and emits a `medication_reminder.suppressed_client_managed` wide-event annotation per skip.
- `push_attempts` table holds the last 90 days of APNS / Web-Push / Telegram / NTFY delivery attempts (channel + eventType + result + reason + createdAt). The admin diagnostic endpoint at `/api/admin/notifications/diagnostic` surfaces masked device tokens + channel state + last 20 attempts for the calling user.
- Any request / response schema change must regen `docs/api/openapi.yaml` and commit the result. CI fails on drift.
- The iOS app authenticates via password/passkey only (`src/lib/auth/native-client.ts`) — there is no OIDC flow for native clients yet. `OIDC_ONLY=true` is enforced server-side on those same routes, so enabling it locks every iOS user out of sign-in until a native SSO flow ships. Don't recommend it to an operator running the iOS app.
- The iOS app signs in via the native OIDC SSO handoff **or** password/passkey. The native SSO leg: the app opens `GET /api/auth/oidc/login?client=native&code_challenge=<S256>` in an `ASWebAuthenticationSession`, the callback returns to the fixed compiled-in scheme `healthlog://oidc-callback` with a one-time `code=hlh_…` (or an `mfa_ticket` when the account has a second factor), and the app exchanges the code at `POST /api/auth/oidc/native/token` for the standard native bundle (`src/lib/auth/oidc-native-handoff.ts`). The `healthlog://` scheme is a compile-time constant — no `redirect_uri` param, no env allowlist (the open-redirect class is removed by construction). The token pair NEVER rides a URL; only the opaque handoff code / MFA ticket do. The SSO login can never satisfy `requireFreshMfa` step-up — the native transport is Bearer and `requireFreshMfa` is cookie-only, and even the web path sets `mfaVerifiedAt` only from the app's own factor. `OIDC_ONLY=true` is now supported for iOS but requires the app build that ships this flow; an older password/passkey-only build is locked out. The MFA-verify and `/api/auth/refresh` routes that complete/rotate an OIDC login are deliberately not OIDC-only-blocked.
## DO-NOTs
+267 -17
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.30.6
version: 1.30.22
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -1562,6 +1562,38 @@ paths:
"401": *a1
"422": *a3
"429": *a4
/api/auth/oidc/native/token:
post:
tags:
- Auth
summary: Exchange a native OIDC handoff code for the token bundle
description: >-
The cookie-less native leg of the OIDC SSO flow. The iOS app opens `GET
/api/auth/oidc/login?client=native&code_challenge=<S256>` inside an `ASWebAuthenticationSession`; the callback
returns a one-time handoff code on `healthlog://oidc-callback?code=hlh_…` (or an `mfa_ticket` when the account
has a second factor, completed at /api/auth/mfa/verify). This endpoint exchanges the code + its PKCE
`codeVerifier` for the SAME native bundle password login issues.
Requires the native transport (no cookie, non-browser UA) — a browser is rejected. The code is single-use and
expires in ~90 seconds; a replay of a consumed code revokes the pair the first exchange issued. A single generic
401 covers every invalid/expired/used/PKCE-mismatch case.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/OidcNativeTokenRequest"
responses:
"200":
description: Handoff accepted — native access + refresh bundle.
content:
application/json:
schema:
$ref: "#/components/schemas/OidcNativeTokenResponse"
"401": *a1
"422": *a3
"429": *a4
/api/auth/me/mfa/totp/setup:
post:
tags:
@@ -2668,14 +2700,23 @@ paths:
- Measurements
summary: Workout detail (v1.4.32)
description: Single-workout envelope. Owns the optional `WorkoutRoute` GeoJSON geometry + `canonicalId` pointer that
resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Cross-user rows
surface as 404 (existence channel sealed).
resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Additive
enrichment fields (`hrSeries`, `zones`, `splits`, `sportContext`, reserved `aiInsight`) are computed
server-side. `compact=1` (sent by the web client) drops the raw `samples.samples` HR blob and
`route.sampleTimestamps` array from the response; without it the payload is byte-identical to the v1.4.32
contract. Cross-user rows surface as 404 (existence channel sealed).
parameters:
- in: path
name: id
schema:
type: string
required: true
- in: query
name: compact
schema:
type: string
enum:
- "1"
responses:
"200":
description: Workout detail.
@@ -11006,6 +11047,21 @@ components:
- mfaTicket
- code
description: Complete a second-factor login challenge and receive the same token bundle the password path issues.
OidcNativeTokenRequest:
type: object
properties:
code:
type: string
pattern: ^hlh_[A-Za-z0-9_-]{43}$
description: One-time handoff code from the healthlog://oidc-callback?code= redirect.
codeVerifier:
type: string
pattern: ^[A-Za-z0-9\-._~]{43,128}$
description: The app's PKCE code_verifier (RFC 7636).
required:
- code
- codeVerifier
description: Exchange a one-time native OIDC handoff code (+ its PKCE verifier) for the standard native token bundle.
MfaDisableRequest:
type: object
properties:
@@ -11212,18 +11268,20 @@ components:
description: HealthKit identifier (e.g. `HKQuantityTypeIdentifierBodyMass`).
value:
type: number
description: Raw HealthKit reading in Apple's native unit; the server applies any canonical scaling at ingest. For an
hourly heart-rate bucket (see `externalId`) this is the hour's AVERAGE bpm; the hour's spread rides
description: Raw HealthKit reading in Apple's native unit; the server applies any canonical scaling at ingest. For a
10-minute heart-rate bucket (see `externalId`) this is the bucket's AVERAGE bpm; the bucket's spread rides
`valueMin` / `valueMax`.
valueMin:
description: "v1.19.2 (iOS #34 extension) — the hour's MINIMUM bpm for an hourly heart-rate bucket (see `externalId`).
Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:<hour>` row; ignored (stored null)
on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only contract."
description: "v1.19.2 (iOS #34 extension) — the bucket's MINIMUM bpm for a 10-minute heart-rate bucket (see
`externalId`). Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:<bucket-start>` row;
ignored (stored null) on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only
contract."
type: number
valueMax:
description: "v1.19.2 (iOS #34 extension) — the hour's MAXIMUM bpm for an hourly heart-rate bucket (see `externalId`).
Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:<hour>` row; ignored (stored null)
on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only contract."
description: "v1.19.2 (iOS #34 extension) — the bucket's MAXIMUM bpm for a 10-minute heart-rate bucket (see
`externalId`). Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:<bucket-start>` row;
ignored (stored null) on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only
contract."
type: number
unit:
type: string
@@ -11263,12 +11321,12 @@ components:
• `stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>` — a per-day cumulative total (Steps, Active Energy, Sleep
Duration, Walking/Running Distance, Flights Climbed). A re-post OVERWRITES the row and returns `updated`.
• `stats:HKQuantityTypeIdentifierHeartRate:<bucket-start-hour>` — v1.19.0 (iOS #34) hourly heart-rate bucket
carrying the hour's AVERAGE bpm as one PULSE row. `<bucket-start-hour>` is the ISO-8601 UTC hour boundary
with zeroed minutes/seconds/millis and a trailing `Z` (e.g. `2026-06-21T14:00:00.000Z`). A re-post within
the hour OVERWRITES the row and returns `updated`, so iOS uploads ~24 rows/day instead of one per raw HR
sample. A row that targets this prefix with a malformed hour suffix is `skipped` with reason
`malformed_hr_bucket_id`.
• `stats:HKQuantityTypeIdentifierHeartRate:<bucket-start>` — v1.19.0 (iOS #34), refined to 10-minute
granularity in v1.30.7, heart-rate bucket carrying the bucket's AVERAGE bpm as one PULSE row.
`<bucket-start>` is the ISO-8601 UTC 10-minute boundary — zeroed seconds/millis, minutes ∈
{00,10,20,30,40,50}, trailing `Z` (e.g. `2026-06-21T14:10:00.000Z`). A re-post OVERWRITES the row and
returns `updated`, so iOS uploads ~144 rows/day instead of one per raw HR sample. A row that targets this
prefix with a malformed bucket-start suffix is `skipped` with reason `malformed_hr_bucket_id`.
externalSourceVersion:
type: string
minLength: 1
@@ -14865,6 +14923,23 @@ components:
- data
- error
additionalProperties: false
OidcNativeTokenResponse:
type: object
properties:
data:
$ref: "#/components/schemas/AccessRefreshBundle"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
TotpSetupEnvelope:
type: object
properties:
@@ -17429,6 +17504,10 @@ components:
anyOf:
- type: string
- type: "null"
hasRoute:
type: boolean
hasHrSeries:
type: boolean
required:
- id
- sportType
@@ -17441,6 +17520,8 @@ components:
- maxHr
- source
- externalId
- hasRoute
- hasHrSeries
additionalProperties: false
GetWorkoutResponse:
type: object
@@ -17553,6 +17634,26 @@ components:
anyOf:
- $ref: "#/components/schemas/WorkoutHrSeries"
- type: "null"
hrSeries:
anyOf:
- $ref: "#/components/schemas/WorkoutHrCurve"
- type: "null"
zones:
anyOf:
- $ref: "#/components/schemas/WorkoutZones"
- type: "null"
splits:
anyOf:
- type: array
items:
$ref: "#/components/schemas/WorkoutSplit"
- type: "null"
sportContext:
anyOf:
- $ref: "#/components/schemas/WorkoutSportContext"
- type: "null"
aiInsight:
type: "null"
canonicalId:
type: string
required:
@@ -17574,6 +17675,11 @@ components:
- metadata
- route
- samples
- hrSeries
- zones
- splits
- sportContext
- aiInsight
- canonicalId
additionalProperties: false
WorkoutRouteGeometry:
@@ -17602,6 +17708,150 @@ components:
- sampleCount
- samples
additionalProperties: false
WorkoutHrCurve:
type: object
properties:
source:
type: string
enum:
- workout_series
- pulse_window
bucketSec:
type: integer
exclusiveMinimum: 0
maximum: 9007199254740991
points:
type: array
items:
type: object
properties:
tSec:
type: integer
minimum: 0
maximum: 9007199254740991
mean:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
min:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
max:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
required:
- tSec
- mean
- min
- max
additionalProperties: false
envelope:
type: boolean
required:
- source
- bucketSec
- points
- envelope
additionalProperties: false
WorkoutZones:
type: object
properties:
model:
type: string
enum:
- whoop
- tanaka
hrMax:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
zones:
type: array
items:
type: object
properties:
zone:
type: integer
minimum: 1
maximum: 5
lowBpm:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
highBpm:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
seconds:
type: integer
minimum: 0
maximum: 9007199254740991
required:
- zone
- lowBpm
- highBpm
- seconds
additionalProperties: false
required:
- model
- hrMax
- zones
additionalProperties: false
WorkoutSplit:
type: object
properties:
km:
type: integer
exclusiveMinimum: 0
maximum: 9007199254740991
durationSec:
type: integer
minimum: 0
maximum: 9007199254740991
paceSecPerKm:
type: integer
minimum: 0
maximum: 9007199254740991
required:
- km
- durationSec
- paceSecPerKm
additionalProperties: false
WorkoutSportContext:
type: object
properties:
count:
type: integer
minimum: 0
maximum: 9007199254740991
avgDurationSec:
type: integer
minimum: 0
maximum: 9007199254740991
avgDistanceM:
anyOf:
- type: number
- type: "null"
avgAvgHr:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
required:
- count
- avgDurationSec
- avgDistanceM
- avgAvgHr
additionalProperties: false
BatchWorkoutsResponse:
type: object
properties:
+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.6",
"version": "1.30.22",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -0,0 +1,48 @@
-- Native OIDC SSO session handoff (iOS#49).
--
-- One-time handoff codes bridging the browser-context OIDC callback to the
-- cookie-less native token exchange. The web OIDC flow ends in a session
-- cookie a Bearer-transport native client can never consume; this table lets
-- the callback mint an opaque, PKCE-locked, single-use code that the app
-- exchanges for the standard native token bundle at
-- POST /api/auth/oidc/native/token.
--
-- The raw code (`hlh_<…>`) rides ONLY the custom-scheme redirect; only its
-- HMAC-SHA256 hash is stored, mirroring the refresh-token / MFA-challenge
-- hash-at-rest posture. `issued_refresh_token_hash` records the pair minted at
-- exchange so a replay (a second presentation after consumption) can revoke
-- exactly that family member — refresh-token reuse-detection reach-back.
-- CreateTable
CREATE TABLE "oidc_native_handoffs" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"code_hash" TEXT NOT NULL,
"code_challenge" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"consumed_at" TIMESTAMP(3),
"issued_refresh_token_hash" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ip_address" TEXT,
"user_agent" TEXT,
CONSTRAINT "oidc_native_handoffs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex — the exchange loads a row by the code's hash; unique so a hash
-- collision can never resolve to two live codes.
CREATE UNIQUE INDEX "oidc_native_handoffs_code_hash_key"
ON "oidc_native_handoffs" ("code_hash");
-- CreateIndex — per-user audit scans + the FK-cascade delete path.
CREATE INDEX "oidc_native_handoffs_user_id_idx"
ON "oidc_native_handoffs" ("user_id");
-- CreateIndex — the daily cleanup sweep filters on `expires_at`.
CREATE INDEX "oidc_native_handoffs_expires_at_idx"
ON "oidc_native_handoffs" ("expires_at");
-- AddForeignKey — deleting a user cascades their handoff rows away.
ALTER TABLE "oidc_native_handoffs"
ADD CONSTRAINT "oidc_native_handoffs_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users" ("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,11 @@
-- v1.30.12 perf — index the two medication-intake read shapes the dashboard
-- "meds today" builder fires that could not index-seek before: the sibling
-- reads filtering (user_id, scheduled_for) without medication_id, and the
-- per-medication last-taken group-by that scanned the whole intake history.
-- Additive; no data change.
-- CreateIndex
CREATE INDEX "medication_intake_events_user_id_scheduled_for_idx" ON "medication_intake_events"("user_id", "scheduled_for");
-- CreateIndex
CREATE INDEX "medication_intake_events_user_id_medication_id_taken_at_idx" ON "medication_intake_events"("user_id", "medication_id", "taken_at");
+42 -1
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.6";
/* @sw-version-fallback */ "v1.30.22";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -0,0 +1,192 @@
/**
* Structural guard on the AI consent boundary.
*
* Two surfaces shipped without a consent check — the self-context clarifying
* questions and the AI-composed proactive nudge — for the same reason: nothing
* connected "I am holding a live provider instance" to "I must have checked a
* receipt first". Behavioural tests close the two we found. This closes the
* class, so the third one cannot appear quietly.
*
* The invariant: a module that resolves a provider INSTANCE for a user
* (`resolveProvider` / `resolveProviderChain` / `resolveProviderForTest`) holds
* something it can call `generateCompletion` on. Every such module must also
* pull in a consent helper — or sit on the allowlist below with a written
* reason a reviewer signed off.
*
* WHAT THIS CANNOT PROVE — read this before trusting it:
*
* - It is an IMPORT-level check. It proves a consent helper is in the
* module's scope, NOT that the gate is reached on every path through the
* file. A module could import `hasActiveConsentForSurface`, call it on one
* branch, and egress ungated on another; this guard stays green.
* - It cannot prove ORDER. A gate called after the provider call, or after
* the snapshot is built, passes here and is still wrong.
* - It cannot prove the SURFACE is right. Passing `"insights"` where the data
* is a Coach snapshot passes here.
* - It cannot prove an allowlist entry is HONEST. A reviewer who waves
* through a new entry with a plausible-sounding reason defeats it entirely.
* The allowlist is a review checkpoint, not a proof.
*
* What it does buy: a NEW ungated provider call site cannot land silently. The
* author must either wire a consent helper or edit this file, and editing this
* file puts the decision in front of a reviewer. That is the whole claim.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join, sep } from "node:path";
import { globSync } from "node:fs";
const SRC = join(process.cwd(), "src");
/** Every non-test `.ts` / `.tsx` under `src/`, minus the generated client. */
function sourceFiles(): string[] {
return globSync("**/*.{ts,tsx}", { cwd: SRC })
.filter(
(p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"),
)
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.map((p) => p.split(sep).join("/"))
.sort();
}
function read(rel: string): string {
return readFileSync(join(SRC, rel), "utf8");
}
/**
* The helpers that hand back a live, callable provider for a user. Deliberately
* NOT including the presence-only probes (`hasAnyConfiguredProvider`,
* `resolveProviderAvailability`, `userRowHasProviderCredential`): those never
* decrypt a key or construct a client, so they cannot egress anything.
*/
const RESOLUTION_HELPERS = [
"resolveProvider",
"resolveProviderChain",
"resolveProviderForTest",
] as const;
/** The two sanctioned consent APIs. Either satisfies the guard. */
const CONSENT_HELPERS = [
// Chain-shaped: throw-form and predicate-form.
"assertConsentForChain",
"chainRequiresServerManagedConsent",
"hasActiveConsentForSurface",
// Document-class, pick-shaped.
"assertDocumentEgressConsent",
"isExternalDocumentEgress",
] as const;
/**
* Extract the named bindings a file imports from a given module specifier.
* Handles the multi-line `import { a, b } from "…"` form the codebase uses.
* Comments mentioning a helper are NOT import statements and do not count —
* that distinction is the whole point of parsing the import rather than
* grepping the file.
*/
function importedNamesFrom(source: string, moduleSpecifier: string): string[] {
const re = new RegExp(
`import\\s+(?:type\\s+)?\\{([^}]*)\\}\\s*from\\s*["']${moduleSpecifier.replace(
/[/\\^$*+?.()|[\]{}]/g,
"\\$&",
)}["']`,
"g",
);
const names: string[] = [];
for (const match of source.matchAll(re)) {
for (const raw of match[1].split(",")) {
const name = raw
.trim()
.replace(/^type\s+/, "")
.split(/\s+as\s+/)[0];
if (name) names.push(name.trim());
}
}
return names;
}
function resolvesAProvider(source: string): boolean {
const imported = importedNamesFrom(source, "@/lib/ai/provider");
return RESOLUTION_HELPERS.some((h) => imported.includes(h));
}
function importsAConsentHelper(source: string): boolean {
const imported = importedNamesFrom(source, "@/lib/ai/consent-guard");
return CONSENT_HELPERS.some((h) => imported.includes(h));
}
/**
* Modules that resolve a provider and deliberately carry no consent helper.
* Every entry needs a reason that survives a reviewer asking "what PHI does
* this send?" — the answer here is "none" in every case.
*
* `lib/ai/provider.ts` needs no entry: it DEFINES the resolution helpers rather
* than importing them, so the detector never flags it. That is correct — the
* resolver constructs clients and never sends a prompt, and gating it would
* invert the layering.
*/
const ALLOWLIST: Record<string, string> = {
// --- Capability / config probes: resolve a chain, never call it. ---
"lib/labs/ocr-capability.ts":
"Resolves the chain to REPORT which entry could do vision/text OCR (mode, reason, pdfSupported). Makes no completion call. Every consumer that then egresses a document is gated at its own call site with assertDocumentEgressConsent.",
"app/api/insights/provider-chain/route.ts":
"Lists the user's configured chain and which entry is active, for the settings UI. Constructs clients but never calls generateCompletion. No health data in the request or the response.",
"app/api/insights/comprehensive/route.ts":
"Uses the resolver only as a boolean probe ((await resolveProvider(userId)).type !== 'none') to set hasProvider on the response. The generation path itself lives in lib/insights/comprehensive-generate.ts, which is gated.",
// --- Connection test: a real provider call, but with no user data. ---
"app/api/ai/test/route.ts":
"Sends a fixed synthetic prompt to verify credentials resolve and the endpoint answers. No health data is read, so there is no PHI egress for a receipt to authorise. Bounded by its own 5/min rate limit.",
};
describe("every provider-resolving module carries a consent helper", () => {
const resolvers = sourceFiles().filter((rel) => resolvesAProvider(read(rel)));
it("finds the provider-resolving modules at all (the guard is wired up)", () => {
// A refactor that renames the resolver or moves the module would otherwise
// silently empty the set and make every assertion below vacuous.
expect(resolvers.length).toBeGreaterThanOrEqual(8);
expect(resolvers).toContain("lib/ai/coach/self-context-questions.ts");
expect(resolvers).toContain("lib/jobs/coach-nudge-ai.ts");
});
it("gates every provider-resolving module that is not explicitly allowlisted", () => {
const ungated = resolvers.filter(
(rel) => !importsAConsentHelper(read(rel)) && !(rel in ALLOWLIST),
);
expect(ungated).toEqual([]);
});
it("keeps the allowlist free of stale entries", () => {
// An entry that no longer resolves a provider is dead weight that hides
// the next real one. Delete it rather than letting it rot.
const stale = Object.keys(ALLOWLIST).filter(
(rel) => !resolvers.includes(rel),
);
expect(stale).toEqual([]);
});
it("requires a substantive reason on every allowlist entry", () => {
const thin = Object.entries(ALLOWLIST)
.filter(([, reason]) => reason.trim().length < 40)
.map(([rel]) => rel);
expect(thin).toEqual([]);
});
});
describe("the consent guard itself stays the single source of the policy", () => {
it("keeps the server-managed provider set defined in exactly one place", () => {
// Both operator-credential tags must be classified in consent-guard.ts and
// nowhere else — a second copy of this list is how the policy drifts.
const guard = read("lib/ai/consent-guard.ts");
expect(guard).toContain('"admin-openai"');
expect(guard).toContain('"admin-codex"');
const copies = sourceFiles().filter(
(rel) =>
rel !== "lib/ai/consent-guard.ts" &&
/SERVER_MANAGED_PROVIDER_TYPES/.test(read(rel)),
);
expect(copies).toEqual([]);
});
});
@@ -0,0 +1,195 @@
/**
* Structural guards on the Bearer-scope enforcement boundary.
*
* The correctness argument for the fail-closed default is structural, not
* empirical: there is exactly ONE place a raw Bearer token becomes a user, and
* exactly ONE authorisation arm inside it. Seven behavioural tests cannot cover
* 300-odd routes; what they can do is rest on that invariant. These guards are
* what keep the invariant true.
*
* They are tripwires, not proofs. They cannot show an allowlist is correct —
* only that it has not changed without someone editing this file. A reviewer
* who waves through a bad addition defeats all four, and no test substitutes
* for that review.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join, sep } from "node:path";
import { globSync } from "node:fs";
const SRC = join(process.cwd(), "src");
/**
* Every non-test `.ts` / `.tsx` under `src/`, excluding the generated Prisma
* client (9 MB; never read it) and test files themselves.
*/
function sourceFiles(): string[] {
return globSync("**/*.{ts,tsx}", { cwd: SRC })
.filter(
(p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"),
)
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.map((p) => p.split(sep).join("/"))
.sort();
}
function read(rel: string): string {
return readFileSync(join(SRC, rel), "utf8");
}
function filesMatching(re: RegExp): string[] {
return sourceFiles().filter((rel) => re.test(read(rel)));
}
describe("T1 — the Bearer resolution set is frozen", () => {
/**
* Files allowed to turn a raw `Authorization: Bearer` value into a user.
* This is the set that can circumvent the enforcement point, so it is the
* only set that matters for the fail-closed guarantee.
*
* Two of these authenticate a request; the rest are the resolver itself and
* the two edges that wrap it.
*/
const RESOLUTION_ALLOWLIST = [
// The resolver. Owns the one authorisation arm.
"lib/auth/bearer.ts",
// The REST edge — maps `requireAuth`'s optional scope onto the union.
"lib/api-handler.ts",
// The MCP edge — the one deliberate `any-valid-token` posture.
"lib/mcp/auth.ts",
// The `/mcp` transport, via `resolveMcpAuthContext`.
"app/mcp/route.ts",
// Hand-rolled Bearer authentication: the external medication-ingest
// surface, which gates on BOTH `medication:ingest` and the per-medication
// `medication:<id>:ingest` grant and never touches `requireAuth`.
"app/api/ingest/medication/route.ts",
].sort();
it("no file outside the allowlist resolves a Bearer token to a user", () => {
// `resolveBearerToken` is the shared primitive; a `tokenHash` lookup is the
// hand-rolled equivalent. Either one turns a bearer credential into an
// identity, so both have to stay inside the allowlist.
const resolvers = filesMatching(
/resolveBearerToken|apiToken\.findUnique\([\s\S]*?where:\s*\{\s*tokenHash/,
);
expect(resolvers).toEqual(RESOLUTION_ALLOWLIST);
});
it("the resolver exposes exactly one authorisation arm", () => {
const src = read("lib/auth/bearer.ts");
// One wildcard escape hatch, guarding one deny block. If a second
// `permissions.includes("*")` short-circuit appears, the arm has forked.
const wildcardChecks = src.match(/permissions\.includes\("\*"\)/g) ?? [];
expect(wildcardChecks).toHaveLength(1);
});
});
describe("T2 — `any-valid-token` is the single deliberate fail-open posture", () => {
it("appears at exactly one call site, and that call site is the MCP edge", () => {
// `bearer.ts` necessarily names the variant to declare the union; what
// must stay unique is a caller PASSING it.
const optedOut = filesMatching(/kind:\s*"any-valid-token"/).filter(
(rel) => rel !== "lib/auth/bearer.ts",
);
expect(optedOut).toEqual(["lib/mcp/auth.ts"]);
});
it("no route file opts out of the fail-closed default", () => {
const routes = sourceFiles().filter((p) => p.endsWith("/route.ts"));
const optedOut = routes.filter((rel) =>
/kind:\s*"any-valid-token"/.test(read(rel)),
);
expect(optedOut).toEqual([]);
});
});
describe("T3 — the mint sites are frozen", () => {
/**
* Every place an `ApiToken` row is created, with the scope set it may mint.
* A new mint site, or a new scope on an existing one, has to be named here —
* which is the point: a user-facing mint that hands out a broad scope is the
* one failure mode the enforcement change cannot catch by itself.
*/
const MINT_SITES: Record<string, string> = {
// Cookie-equivalent. The ONLY `["*"]` mints, all behind a completed login.
"lib/auth/issue-token.ts": 'opts.permissions ?? ["*"]',
"lib/auth/login-response.ts": '["*"]',
"lib/auth/refresh-token.ts": '["*"]',
"app/api/auth/passkey/login-verify/route.ts": '["*"]',
// The working medication-ingest pair — family marker plus the per-
// medication grant that `/api/ingest/medication` actually gates on.
"app/api/medications/[id]/api-endpoint/route.ts":
'["medication:ingest", scope]',
// MCP, audience-bound to `/mcp`. `health:write` requires explicit consent.
"app/api/mcp/tokens/route.ts": "SCOPE_HEALTH_READ / SCOPE_HEALTH_WRITE",
"app/api/mcp/oauth/token/route.ts":
"SCOPE_HEALTH_READ / SCOPE_HEALTH_WRITE",
};
it("only the known files create ApiToken rows", () => {
const minters = filesMatching(/apiToken\.create\(|issueApiToken\(/).filter(
// `issue-token.ts` defines the helper; the others call it.
(rel) => rel !== "lib/auth/issue-token.ts" || true,
);
expect(minters).toEqual(Object.keys(MINT_SITES).sort());
});
it("no user-facing mint hands out a wildcard scope", () => {
// The four `["*"]` mints are all reached only by a completed
// password / passkey / refresh exchange. Every other mint is narrow.
const userFacing = [
"app/api/medications/[id]/api-endpoint/route.ts",
"app/api/mcp/tokens/route.ts",
"app/api/mcp/oauth/token/route.ts",
];
for (const rel of userFacing) {
expect(read(rel)).not.toMatch(/permissions:\s*\[\s*"\*"/);
}
});
it("the retired generic token mint is gone", () => {
// `POST /api/tokens` minted `["medication:ingest"]` — a token that could
// not perform its advertised job (it lacked the per-medication grant) and
// could reach every other authenticated route. List and revoke stay.
const tokensRoute = read("app/api/tokens/route.ts");
expect(tokensRoute).not.toMatch(/export const POST/);
expect(tokensRoute).toMatch(/export const GET/);
});
});
describe("T4 — declared scopes are exported constants, never string literals", () => {
it("every requireAuth argument is an identifier", () => {
const offenders: string[] = [];
for (const rel of sourceFiles()) {
const src = read(rel);
// Call sites only — skip the declaration in `api-handler.ts`, whose
// parameter list is not an argument.
for (const m of src.matchAll(
/(?<!function\s)requireAuth\(\s*([^)\s][^)]*)\)/g,
)) {
const arg = m[1].trim();
if (!/^[A-Za-z_$][\w$]*$/.test(arg)) {
offenders.push(`${rel}: requireAuth(${arg})`);
}
}
}
expect(offenders).toEqual([]);
});
it("the declared scope vocabulary is closed", () => {
// Scopes a route may name. Adding one means adding a route that accepts a
// narrow token, which is a widening — it belongs in a reviewed diff.
const DECLARED_SCOPES = ["FHIR_READ_SCOPE"];
const used = new Set<string>();
for (const rel of sourceFiles()) {
for (const m of read(rel).matchAll(
/(?<!function\s)requireAuth\(\s*([A-Za-z_$][\w$]*)\s*\)/g,
)) {
used.add(m[1]);
}
}
expect([...used].sort()).toEqual(DECLARED_SCOPES);
});
});
+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 {
@@ -1,4 +1,4 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import { describe, expect, it } from "vitest";
@@ -38,13 +38,7 @@ import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
* `coach-route-gate-inventory.test.ts`; listed here so
* coach-bearing routes are not flagged as ungated.
*
* 3. BUILDER-GATED — the route delegates its whole payload to an
* aggregator that resolves `resolveModuleMap(userId)` once and
* excludes disabled-module sections/resources from the build
* (`collectDoctorReportData`, `loadFhirContext`). The exclusion is
* structural at the builder, so the route carries no per-key call.
*
* 4. EXEMPT — an explicit, COMMENTED allowlist of routes that serve a
* 3. EXEMPT — an explicit, COMMENTED allowlist of routes that serve a
* toggleable domain but are deliberately NOT gated, each with the
* reason at the entry. The two reasons in play:
* - DATA LAYER: raw CRUD over the domain's own rows. Writing or
@@ -56,9 +50,30 @@ import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
* - INFRA / UI-ONLY: settings, availability probes, and the static
* FHIR CapabilityStatement carry no module data.
*
* Anything that doesn't fit one of the four buckets is an orphan and
* Anything that doesn't fit one of the three buckets is an orphan and
* fails the test BY NAME so the fix is one search-and-add: gate the route
* or move it onto the EXEMPT allowlist with a documented reason.
*
* WHAT THIS TEST DOES NOT PROVE, AND WHO PROVES IT
* -----------------------------------------------
* This file is a DISCOVERY test: it proves no route escapes classification.
* It cannot prove a gate actually refuses, because it only reads source text.
*
* That distinction used to be blurred. A fourth "BUILDER-GATED" bucket
* accepted the mere MENTION of an aggregator's name (`loadFhirContext`,
* `collectDoctorReportData`) as proof that the route was gated. It was not
* proof of anything: `loadFhirContext` filtered per-domain modules but never
* evaluated `doctorReport`, so all five `/api/fhir/*` data routes sat in a
* green bucket while serving the whole record — including the decrypted
* insurance number — to an account that had the doctor-report module off.
* The bucket is gone. A route is now only counted as gated when it CALLS a
* gate, and `REQUIRED_TREE_MODULE` below pins which key it must name.
*
* The behaviour itself — a disabled module actually producing a 403 or an
* omission — is proven by the per-surface tests listed in
* `BEHAVIOURAL_GATE_TESTS`. This test asserts those files still exist, so
* deleting the behavioural coverage fails here with a pointer to what was
* lost rather than silently leaving only a text-match behind.
*/
/**
@@ -99,9 +114,10 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
"src/app/api/medications",
// v1.18.0 B3 — the legacy `/api/doctor-report` tree (JSON + server-PDF +
// availability probe) was orphaned dead code (no production caller) and
// removed. The live doctor-report / FHIR surface is `/api/export/health-record`,
// which gates on the `doctorReport` module directly AND through the
// `collectDoctorReportData` builder.
// removed. The doctor-report surface is `/api/export/health-record` plus
// the FHIR REST face below; both gate on `doctorReport` directly. The FHIR
// routes previously relied on the removed builder-mention bucket and were
// in practice ungated — see the header note.
"src/app/api/fhir",
// v1.18.0 (B2) — the AI-narrative insights tree. Every status / cards /
// correlations / derived / narrative / pregenerate / rhythm-events route
@@ -273,12 +289,55 @@ const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")';
// `requireCycleEnabled`. Recognised as a delegated gate so illness routes
// are not flagged as ungated.
const ILLNESS_GATE_NEEDLE = "requireIllnessEnabled(";
// Builder aggregators that resolve `resolveModuleMap` once and exclude
// disabled-module sections/resources at the build boundary.
const BUILDER_GATE_NEEDLES: ReadonlyArray<string> = [
"collectDoctorReportData",
"loadFhirContext",
];
/**
* Trees whose routes must gate on ONE specific module key. Naming the key
* here means a future route in the tree cannot satisfy the inventory by
* gating on some other, more permissive module — the failure mode that a
* generic "is there any gate?" check cannot see.
*
* `/api/fhir` is the entry that matters: every data route there serves the
* whole-record doctor-report aggregate, so `doctorReport` is the only
* correct key.
*/
const REQUIRED_TREE_MODULE: Readonly<Record<string, ModuleKey>> = {
"src/app/api/fhir": "doctorReport",
};
/**
* Surfaces whose module behaviour is proven by a real behavioural test
* (module off ⇒ 403 or omission; module on ⇒ served). Listed so that
* deleting the proof fails the inventory instead of quietly downgrading the
* guarantee back to a text match.
*
* The three aggregate surfaces below are the ones that re-served per-domain
* data ungated: two of them (`/api/sync/changes`, `/api/dashboard/summary`)
* are cross-domain feeds that live outside `MODULE_ROUTE_TREES` entirely, so
* without this list nothing in the inventory would notice them at all.
*/
const BEHAVIOURAL_GATE_TESTS: ReadonlyArray<{ path: string; proves: string }> =
[
{
path: "src/app/api/fhir/__tests__/module-gate.test.ts",
proves:
"every /api/fhir data route 403s with module.disabled when doctorReport is off",
},
{
path: "src/lib/fhir/__tests__/rest-module-backstop.test.ts",
proves:
"loadFhirContext itself refuses to assemble the record when doctorReport is off",
},
{
path: "src/app/api/sync/changes/__tests__/module-gate.test.ts",
proves:
"the sync delta feed omits cycleDays/cycles (and never queries them) when cycle is off",
},
{
path: "src/app/api/dashboard/summary/__tests__/module-gate.test.ts",
proves:
"the dashboard summary drops disabled-module metric cards while core vitals still ship",
},
];
/**
* True when the file contains the needle on a line that is NOT a pure
@@ -373,7 +432,7 @@ describe("module API route gate inventory", () => {
}
});
it("every toggleable-module route is gated, delegated, builder-gated, or explicitly exempt", () => {
it("every toggleable-module route is gated, delegated, or explicitly exempt", () => {
const routes = findAllModuleRouteFiles();
expect(routes.length).toBeGreaterThan(0);
@@ -387,15 +446,16 @@ describe("module API route gate inventory", () => {
if (fileHasCall(text, CYCLE_GATE_NEEDLE)) continue;
if (fileHasCall(text, COACH_GATE_NEEDLE)) continue;
if (fileHasCall(text, ILLNESS_GATE_NEEDLE)) continue;
if (BUILDER_GATE_NEEDLES.some((n) => fileHasCall(text, n))) continue;
if (exempt.has(path)) continue;
orphans.push({
path,
reason:
'no module gate found — add requireModuleEnabled("<key>"), a delegated gate, ' +
"a module-aware builder, or move the route onto EXEMPT_ROUTES with a documented reason",
'no module gate found — add requireModuleEnabled("<key>") or a delegated gate, ' +
"or move the route onto EXEMPT_ROUTES with a documented reason. " +
"Importing a module-aware builder is NOT a gate: a builder can filter some " +
"modules and never evaluate yours.",
});
}
@@ -432,6 +492,55 @@ describe("module API route gate inventory", () => {
).toEqual([]);
});
it("routes in a key-pinned tree gate on that tree's module key", () => {
// A generic "is there any gate?" check cannot tell `doctorReport` from
// some other, more permissive key. Where a whole tree serves exactly one
// module, name it.
const wrong: Array<{ path: string; found: string[] }> = [];
for (const [tree, requiredKey] of Object.entries(REQUIRED_TREE_MODULE)) {
for (const path of findRouteFiles(tree)) {
if (EXEMPT_ROUTES.includes(path)) continue;
const text = readFileSync(resolve(repoRoot, path), "utf8");
const { keys } = extractModuleKeys(text);
if (!keys.has(requiredKey)) {
wrong.push({ path, found: [...keys] });
}
}
}
expect(
wrong,
[
"Routes that must gate on their tree's module key but do not:",
...wrong.map(
(w) =>
` - ${w.path}: found [${w.found.join(", ") || "no literal key"}]`,
),
].join("\n"),
).toEqual([]);
});
it("the behavioural gate tests that prove refusal still exist", () => {
// This inventory only reads source text. The listed files are what
// actually exercise a disabled module end to end; losing one silently
// downgrades the guarantee back to a text match, which is precisely the
// failure this test was rewritten to prevent.
const missing = BEHAVIOURAL_GATE_TESTS.filter(
(t) => !existsSync(resolve(repoRoot, t.path)),
);
expect(
missing,
[
"Behavioural module-gate coverage has gone missing.",
"This inventory cannot prove a gate refuses — these tests do:",
...missing.map((m) => ` - ${m.path}\n proves: ${m.proves}`),
"Restore the file, or drop the entry and say what replaces it.",
].join("\n"),
).toEqual([]);
});
it("EXEMPT_ROUTES does not reference deleted route files", () => {
const known = new Set(findAllModuleRouteFiles());
const stale = EXEMPT_ROUTES.filter((p) => !known.has(p));
@@ -457,8 +566,7 @@ describe("module API route gate inventory", () => {
fileHasCall(text, MODULE_GATE_NEEDLE) ||
fileHasCall(text, CYCLE_GATE_NEEDLE) ||
fileHasCall(text, COACH_GATE_NEEDLE) ||
fileHasCall(text, ILLNESS_GATE_NEEDLE) ||
BUILDER_GATE_NEEDLES.some((n) => fileHasCall(text, n))
fileHasCall(text, ILLNESS_GATE_NEEDLE)
) {
stillGated.push(path);
}
@@ -0,0 +1,129 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
/**
* A restore writes into the account the BACKUP RECORD names, not the one the
* encrypted payload claims.
*
* The route took its target from `payload.userId` — a field inside the blob —
* while logging `backup.userId` from the row. An admin selecting one user's
* backup could therefore write into a different account, and the audit trail
* would record the account they thought they picked. Both values are in hand at
* that point, so the only correct behaviour is to compare them.
*
* This is inside the admin privilege boundary, so it is an integrity problem
* rather than an escalation: the operator's intent was not honoured.
*/
vi.mock("@/lib/api-handler", () => ({
apiHandler: (fn: unknown) => fn,
HttpError: class extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
}
},
requireAdmin: vi.fn(async () => ({ user: { id: "admin-1" } })),
}));
vi.mock("@/lib/db", () => ({
prisma: {
dataBackup: { findUnique: vi.fn() },
user: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "{}") }));
vi.mock("@/lib/crypto/note-cipher", () => ({ encryptNote: vi.fn() }));
vi.mock("@/lib/validations/backup", () => ({
parseBackupPayload: vi.fn(),
summarizeBackup: vi.fn(() => ({})),
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/idempotency", () => ({
withIdempotency: (fn: unknown) => fn,
defaultUserIdResolver: vi.fn(),
}));
vi.mock("@/lib/rollups/mood-rollups", () => ({
recomputeUserMoodRollups: vi.fn(),
}));
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
recomputeUserMedicationCompliance: vi.fn(),
MEDICATION_COMPLIANCE_BACKFILL_DAYS: 30,
}));
vi.mock("@/lib/rollups/measurement-rollups", () => ({
recomputeUserRollups: vi.fn(),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { parseBackupPayload } from "@/lib/validations/backup";
import { auditLog } from "@/lib/auth/audit";
function request(): NextRequest {
return new NextRequest("http://localhost/api/admin/backups/b-1/restore", {
method: "POST",
headers: { "content-type": "application/json" },
// The route requires a typed confirmation before it will restore anything.
body: JSON.stringify({ confirm: "RESTORE" }),
});
}
const params = Promise.resolve({ id: "b-1" });
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.dataBackup.findUnique).mockResolvedValue({
id: "b-1",
userId: "user-A",
data: "cipher",
user: { id: "user-A", username: "alice" },
} as never);
});
describe("admin backup restore — declared owner", () => {
it("refuses when the payload names a different owner than the record", async () => {
// The blob claims user-B; the backup row says user-A.
vi.mocked(parseBackupPayload).mockReturnValue({
userId: "user-B",
} as never);
const res = await (
POST as unknown as (r: NextRequest, c: unknown) => Promise<Response>
)(request(), { params });
expect(res.status).toBe(409);
// The restore must not have gone looking for the claimed user at all.
expect(prisma.user.findUnique).not.toHaveBeenCalled();
// And the refusal is recorded with BOTH ids, so the attempt is reviewable.
expect(auditLog).toHaveBeenCalledWith(
"admin.backups.restore.failed",
expect.objectContaining({
details: expect.objectContaining({
reason: "owner_mismatch",
ownerId: "user-A",
declaredOwnerId: "user-B",
}),
}),
);
});
it("proceeds past the check when the two agree", async () => {
vi.mocked(parseBackupPayload).mockReturnValue({
userId: "user-A",
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
const res = await (
POST as unknown as (r: NextRequest, c: unknown) => Promise<Response>
)(request(), { params });
// Reaching the owner lookup proves the mismatch branch did not fire; this
// case then fails on the deleted-user branch, which is the next guard.
expect(prisma.user.findUnique).toHaveBeenCalled();
expect(res.status).not.toBe(409);
});
});
@@ -195,6 +195,29 @@ const handler = apiHandler(
return apiError("Backup payload failed schema validation", 500);
}
// The payload declares its own owner, and the backup ROW records who the
// backup was taken for. Those must agree. Taking the owner from the
// payload alone means the restored-into account is whatever the blob
// claims — so an admin who selects one user's backup could write into a
// different account without the interface ever showing it. Both values are
// already in hand here; refuse on mismatch rather than trusting the blob.
if (payload.userId !== backup.userId) {
await auditLog("admin.backups.restore.failed", {
userId: admin.id,
ipAddress: getClientIp(request),
details: {
backupId: id,
ownerId: backup.userId,
reason: "owner_mismatch",
declaredOwnerId: payload.userId,
},
});
return apiError(
"Backup payload declares a different owner than the backup record",
409,
);
}
// The target of the restore is whoever the backup is for, NOT the
// admin running the operation. Make sure that user still exists —
// an upload referencing a since-deleted user would otherwise leave
+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;
});
@@ -0,0 +1,212 @@
/**
* `GET /api/dashboard/summary` — module gate.
*
* This route builds its own payload instead of going through
* `readDashboardSnapshotCached`, so it never picked up the gate the shared
* snapshot builder applies: it emitted glucose and sleep cards on data
* presence alone while `/api/dashboard/snapshot` correctly withheld them for
* the very same account.
*
* OMIT, not 403 — the payload spans ten metric cards across several domains,
* and blanking an iOS dashboard because one module is off would be the wrong
* trade. Core vitals must keep flowing.
*
* Behavioural: the assertions read the emitted card list, so reverting the
* filter in the route turns them red.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
// The gate resolver runs for real; only its data sources are stubbed, so the
// module state is driven the way production drives it.
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
cycleProfile: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/modules/operator-availability", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@/lib/modules/operator-availability")
>();
return { ...actual, getOperatorModuleAvailability: vi.fn() };
});
vi.mock("@/lib/feature-flags", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/feature-flags")>();
return { ...actual, getAssistantFlags: vi.fn() };
});
// The heavy builder is stubbed: this test pins the GATE, not the aggregate
// (which has its own coverage). It returns one card per emitted kind.
vi.mock("@/lib/cache/server-cache", () => ({
caches: { analytics: {} },
cachedSwr: vi.fn(),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { cachedSwr } from "@/lib/cache/server-cache";
import { MODULE_KEYS } from "@/lib/modules/gate";
import { getOperatorModuleAvailability } from "@/lib/modules/operator-availability";
import { getAssistantFlags } from "@/lib/feature-flags";
import { prisma } from "@/lib/db";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "tester",
role: "USER" as const,
displayName: null,
locale: "en",
timezone: "UTC",
},
};
/** Every card kind the builder can emit, one card each. */
const ALL_KINDS = [
"weight",
"bloodPressure",
"pulse",
"bodyFat",
"glucose",
"sleep",
"steps",
"totalBodyWater",
"boneMass",
"oxygenSaturation",
] as const;
function card(kind: string) {
return {
id: kind,
kind,
titleKey: `dashboard.metric.title.${kind}`,
latestValue: 1,
secondaryValue: null,
unitKey: `dashboard.metric.unit.${kind}`,
unit: null,
sleepStages: null,
sleepSourceDiscrepancy: null,
trend: "flat",
sparkline: [1],
updatedAt: new Date().toISOString(),
allTimeCount: 5,
lastSeenAt: new Date().toISOString(),
};
}
/** Drive the real gate through the persisted disabled-allowlist. */
function setDisabledModules(disabled: string[]): void {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
gender: null,
disableCoach: false,
modulePreferencesJson: Object.fromEntries(disabled.map((k) => [k, false])),
} as never);
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
}
async function emittedKinds(): Promise<string[]> {
const res = await GET();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { metrics: Array<{ kind: string }> };
};
return body.data.metrics.map((m) => m.kind);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(getOperatorModuleAvailability).mockResolvedValue(
Object.fromEntries(MODULE_KEYS.map((k) => [k, true])) as never,
);
vi.mocked(getAssistantFlags).mockResolvedValue({ coach: true } as never);
vi.mocked(cachedSwr).mockImplementation((async () => ({
greeting: { salutation: "hi", date: new Date().toISOString() },
streak: { currentDays: 0, longest: 0, label: "" },
compliance: { scheduledToday: 0, takenToday: 0 },
highlightInsight: null,
metrics: ALL_KINDS.map(card),
sleepRhythm: null,
lastUpdated: new Date().toISOString(),
})) as never);
});
describe("GET /api/dashboard/summary — module gate", () => {
it("emits every card when no module is disabled", async () => {
setDisabledModules([]);
expect(await emittedKinds()).toEqual([...ALL_KINDS]);
});
it("drops the glucose card when the glucose module is off", async () => {
setDisabledModules(["glucose"]);
const kinds = await emittedKinds();
expect(kinds).not.toContain("glucose");
// Everything else survives — proves an omission, not a refusal.
expect(kinds).toContain("weight");
expect(kinds).toContain("sleep");
expect(kinds).toHaveLength(ALL_KINDS.length - 1);
});
it("drops the sleep card when the sleep module is off", async () => {
setDisabledModules(["sleep"]);
const kinds = await emittedKinds();
expect(kinds).not.toContain("sleep");
expect(kinds).toContain("glucose");
expect(kinds).toHaveLength(ALL_KINDS.length - 1);
});
it("drops both when both modules are off", async () => {
setDisabledModules(["glucose", "sleep"]);
const kinds = await emittedKinds();
expect(kinds).not.toContain("glucose");
expect(kinds).not.toContain("sleep");
expect(kinds).toHaveLength(ALL_KINDS.length - 2);
});
it("never drops a core vital, whatever is disabled", async () => {
// Core metrics carry no SUMMARY_TYPE_MODULE entry and must always ship.
setDisabledModules([...MODULE_KEYS]);
const kinds = await emittedKinds();
for (const core of [
"weight",
"bloodPressure",
"pulse",
"bodyFat",
"steps",
"totalBodyWater",
"boneMass",
"oxygenSaturation",
]) {
expect(kinds).toContain(core);
}
});
it("gates the cached body, so a toggle takes effect on the next request", async () => {
// The analytics LRU is not evicted by a module toggle. The filter must
// therefore run on the way out, not inside the cached build.
setDisabledModules([]);
expect(await emittedKinds()).toContain("glucose");
// Same cached body, module now off.
setDisabledModules(["glucose"]);
expect(await emittedKinds()).not.toContain("glucose");
});
});
@@ -1,6 +1,19 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
// Every module is on for the aggregate assertions below; the gate's own
// behaviour (which cards a disabled module drops) is pinned in the sibling
// `module-gate.test.ts`.
vi.mock("@/lib/modules/gate", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/modules/gate")>();
return {
...actual,
resolveModuleMap: vi.fn(async () =>
Object.fromEntries(actual.MODULE_KEYS.map((k) => [k, true])),
),
};
});
vi.mock("@/lib/db", () => ({
prisma: {
// v1.11.4 — `findMany` reads the raw per-stage SLEEP_DURATION rows for
+37 -1
View File
@@ -44,6 +44,12 @@
* subsequent iOS polls inside the window hit memory. Invalidation runs
* via the existing `invalidateUserMeasurements` + the v1.4.38-extended
* `invalidateUserMedications` hooks.
*
* Module gate: the emitted `metrics` array is filtered against the account's
* module map before it leaves the handler, so a card whose measurement type
* belongs to a disabled module never ships. The decision comes from the same
* map the shared snapshot builder uses (`disabledSummaryTypes`); the filter
* runs OUTSIDE the cache because a module toggle does not evict it.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
@@ -57,6 +63,8 @@ import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today";
import { resolveModuleMap } from "@/lib/modules/gate";
import { gateMetricCardsByModules } from "@/lib/dashboard/widget-modules";
import {
summarizeSleepNights,
reconstructSleepNights,
@@ -364,7 +372,35 @@ export const GET = apiHandler(async () => {
annotate,
);
return apiSuccess(body);
// ── module gate ───────────────────────────────────────────────────
// This route builds its own payload instead of going through
// `readDashboardSnapshotCached`, so it never picked up the module gate
// the shared snapshot builder applies — it emitted glucose and sleep
// tiles on data presence alone, while `/api/dashboard/snapshot` correctly
// withheld them for the same account.
//
// OMIT, NOT 403 — matching the snapshot sibling and the sync feed. This is
// an aggregate of ten metric cards across several domains; refusing the
// whole request would blank the iOS dashboard because one module is off.
// The card simply does not appear, which is exactly what a disabled module
// means on every other dashboard surface.
//
// Applied AFTER the cache read, deliberately. The analytics LRU is keyed
// `<userId>|dashboard-summary` with a 60 s fresh / 1 h stale window, and
// toggling a module does not evict it — gating inside the builder would
// leave the disabled tile served for up to an hour after the user turned
// it off. Filtering the cached body makes the toggle effective on the very
// next request.
const modules = await resolveModuleMap(user.id);
const metrics = gateMetricCardsByModules(body.metrics, modules);
annotate({
meta: {
metrics_emitted: metrics.length,
metrics_gated: body.metrics.length - metrics.length,
},
});
return apiSuccess({ ...body, metrics });
});
interface SummaryBuilderContext {
+16
View File
@@ -10,6 +10,7 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import {
@@ -32,6 +33,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth(FHIR_READ_SCOPE);
annotate({ action: { name: "fhir.everything.read" } });
// v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
// `/api/export/health-record` (the doctor-report builder), right down to
// the decrypted insurance number on the Patient resource. That export
// gates on the `doctorReport` module; this surface did not, so the module
// could be off and `/api/fhir/*` still handed out the full record.
//
// REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is
// a whole-record export, not an incremental feed: there is no partial
// answer that is still a truthful FHIR Bundle, and no background client
// depends on it draining to stay consistent. Mirroring the sibling export's
// 403 `module.disabled` envelope (rather than an OperationOutcome) keeps
// the errorCode the clients already branch on for a disabled module.
const gate = await requireModuleEnabled(user.id, "doctorReport");
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
if (!rl.allowed) {
return operationOutcome(429, "throttled", "Rate limit exceeded");
@@ -9,6 +9,7 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { medicationAdministrationsFromReportData } from "@/lib/fhir/resources";
@@ -24,6 +25,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth(FHIR_READ_SCOPE);
annotate({ action: { name: "fhir.medicationadministration.search" } });
// v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
// `/api/export/health-record` (the doctor-report builder), right down to
// the decrypted insurance number on the Patient resource. That export
// gates on the `doctorReport` module; this surface did not, so the module
// could be off and `/api/fhir/*` still handed out the full record.
//
// REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is
// a whole-record export, not an incremental feed: there is no partial
// answer that is still a truthful FHIR Bundle, and no background client
// depends on it draining to stay consistent. Mirroring the sibling export's
// 403 `module.disabled` envelope (rather than an OperationOutcome) keeps
// the errorCode the clients already branch on for a disabled module.
const gate = await requireModuleEnabled(user.id, "doctorReport");
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
if (!rl.allowed) {
return operationOutcome(429, "throttled", "Rate limit exceeded");
@@ -10,6 +10,7 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { medicationStatementsFromReportData } from "@/lib/fhir/resources";
@@ -25,6 +26,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth(FHIR_READ_SCOPE);
annotate({ action: { name: "fhir.medicationstatement.search" } });
// v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
// `/api/export/health-record` (the doctor-report builder), right down to
// the decrypted insurance number on the Patient resource. That export
// gates on the `doctorReport` module; this surface did not, so the module
// could be off and `/api/fhir/*` still handed out the full record.
//
// REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is
// a whole-record export, not an incremental feed: there is no partial
// answer that is still a truthful FHIR Bundle, and no background client
// depends on it draining to stay consistent. Mirroring the sibling export's
// 403 `module.disabled` envelope (rather than an OperationOutcome) keeps
// the errorCode the clients already branch on for a disabled module.
const gate = await requireModuleEnabled(user.id, "doctorReport");
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
if (!rl.allowed) {
return operationOutcome(429, "throttled", "Rate limit exceeded");
+16
View File
@@ -10,6 +10,7 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { observationsFromReportData } from "@/lib/fhir/resources";
@@ -25,6 +26,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth(FHIR_READ_SCOPE);
annotate({ action: { name: "fhir.observation.search" } });
// v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
// `/api/export/health-record` (the doctor-report builder), right down to
// the decrypted insurance number on the Patient resource. That export
// gates on the `doctorReport` module; this surface did not, so the module
// could be off and `/api/fhir/*` still handed out the full record.
//
// REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is
// a whole-record export, not an incremental feed: there is no partial
// answer that is still a truthful FHIR Bundle, and no background client
// depends on it draining to stay consistent. Mirroring the sibling export's
// 403 `module.disabled` envelope (rather than an OperationOutcome) keeps
// the errorCode the clients already branch on for a disabled module.
const gate = await requireModuleEnabled(user.id, "doctorReport");
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
if (!rl.allowed) {
return operationOutcome(429, "throttled", "Rate limit exceeded");
+16
View File
@@ -9,6 +9,7 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { patientResource } from "@/lib/fhir/resources";
@@ -24,6 +25,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth(FHIR_READ_SCOPE);
annotate({ action: { name: "fhir.patient.search" } });
// v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
// `/api/export/health-record` (the doctor-report builder), right down to
// the decrypted insurance number on the Patient resource. That export
// gates on the `doctorReport` module; this surface did not, so the module
// could be off and `/api/fhir/*` still handed out the full record.
//
// REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is
// a whole-record export, not an incremental feed: there is no partial
// answer that is still a truthful FHIR Bundle, and no background client
// depends on it draining to stay consistent. Mirroring the sibling export's
// 403 `module.disabled` envelope (rather than an OperationOutcome) keeps
// the errorCode the clients already branch on for a disabled module.
const gate = await requireModuleEnabled(user.id, "doctorReport");
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
if (!rl.allowed) {
return operationOutcome(429, "throttled", "Rate limit exceeded");
@@ -0,0 +1,179 @@
/**
* `/api/fhir/*` — doctorReport module gate.
*
* The FHIR REST face serves the same whole-record aggregate as
* `/api/export/health-record`, including the decrypted insurance number on
* the Patient resource. The export gates on the `doctorReport` module; the
* FHIR routes did not, so the module could be off and `$everything` still
* returned the full Bundle to the same token.
*
* REFUSE, not omit — a whole-record export has no truthful partial answer.
* Every data route 403s with the shared `module.disabled` envelope; only the
* static CapabilityStatement at `/api/fhir/metadata` stays open (server
* metadata, no user data).
*
* Behavioural: the assertions read status codes and check that no aggregate
* was ever loaded, so removing a gate turns them red.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
// The module gate itself is NOT mocked — the whole point is to exercise the
// real resolver and the real 403 envelope. Only its data sources are stubbed,
// so the module state is driven the same way production drives it: through
// the persisted `modulePreferencesJson` allowlist.
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
cycleProfile: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/modules/operator-availability", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@/lib/modules/operator-availability")
>();
return { ...actual, getOperatorModuleAvailability: vi.fn() };
});
vi.mock("@/lib/fhir/rest", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/fhir/rest")>();
return { ...actual, loadFhirContext: vi.fn() };
});
vi.mock("@/lib/fhir/resources", () => ({
GERMAN_ATC_DEFAULT_LOCALES: ["de"],
patientResource: vi.fn(() => ({ resourceType: "Patient", id: "p-1" })),
coverageResource: vi.fn(() => null),
observationsFromReportData: vi.fn(() => []),
medicationStatementsFromReportData: vi.fn(() => []),
medicationAdministrationsFromReportData: vi.fn(() => []),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET as patientGet } from "../Patient/route";
import { GET as observationGet } from "../Observation/route";
import { GET as medStatementGet } from "../MedicationStatement/route";
import { GET as medAdminGet } from "../MedicationAdministration/route";
import { GET as everythingGet } from "../$everything/route";
import { GET as metadataGet } from "../metadata/route";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { MODULE_DISABLED_ERROR_CODE, MODULE_KEYS } from "@/lib/modules/gate";
import { getOperatorModuleAvailability } from "@/lib/modules/operator-availability";
import { loadFhirContext } from "@/lib/fhir/rest";
import { prisma } from "@/lib/db";
/**
* Drive the REAL gate the way production does — through the persisted
* `modulePreferencesJson` disabled-allowlist, where only a literal `false`
* turns a module off.
*/
function setDoctorReportModule(enabled: boolean): void {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
gender: null,
disableCoach: false,
modulePreferencesJson: enabled ? {} : { doctorReport: false },
} as never);
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
}
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
/** Every data route under `/api/fhir`, by the path a caller would hit. */
const DATA_ROUTES: ReadonlyArray<
[string, (req: NextRequest) => Promise<Response>]
> = [
["Patient", patientGet],
["Observation", observationGet],
["MedicationStatement", medStatementGet],
["MedicationAdministration", medAdminGet],
["$everything", everythingGet],
];
function req(path: string): NextRequest {
return new NextRequest(`http://localhost/api/fhir/${path}`);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
count: 1,
resetAt: Date.now(),
} as never);
// Operator layer available for every module; the per-user layer is what
// each test drives.
vi.mocked(getOperatorModuleAvailability).mockResolvedValue(
Object.fromEntries(MODULE_KEYS.map((k) => [k, true])) as never,
);
vi.mocked(loadFhirContext).mockResolvedValue({
data: {} as never,
identity: { insuranceNumber: "A123456789" },
germanAtc: false,
});
});
describe("/api/fhir/* — doctorReport module gate", () => {
describe.each(DATA_ROUTES)("GET /api/fhir/%s", (path, handler) => {
it("403s with module.disabled when the module is off", async () => {
setDoctorReportModule(false);
const res = await handler(req(path));
expect(res.status).toBe(403);
const body = (await res.json()) as {
data: unknown;
error: string;
meta?: { errorCode?: string; module?: string };
};
expect(body.data).toBeNull();
expect(body.meta?.errorCode).toBe(MODULE_DISABLED_ERROR_CODE);
expect(body.meta?.module).toBe("doctorReport");
// Refused before any record was assembled — the insurance number
// never left the store.
expect(loadFhirContext).not.toHaveBeenCalled();
expect(JSON.stringify(body)).not.toContain("A123456789");
});
it("serves the Bundle when the module is on", async () => {
setDoctorReportModule(true);
const res = await handler(req(path));
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain(
"application/fhir+json",
);
const bundle = (await res.json()) as { resourceType: string };
expect(bundle.resourceType).toBe("Bundle");
expect(loadFhirContext).toHaveBeenCalledWith("user-1");
});
});
it("leaves the static CapabilityStatement reachable while the module is off", async () => {
// Server metadata, no user data — the one FHIR route that stays open.
setDoctorReportModule(false);
const res = await metadataGet();
expect(res.status).toBe(200);
const body = (await res.json()) as { resourceType: string };
expect(body.resourceType).toBe("CapabilityStatement");
});
});
+9
View File
@@ -16,6 +16,10 @@ vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(),
isModuleEnabled: vi.fn(),
}));
vi.mock("@/lib/fhir/rest", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/fhir/rest")>();
return { ...actual, loadFhirContext: vi.fn() };
@@ -36,6 +40,7 @@ import { GET as observationGet } from "../Observation/route";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { loadFhirContext, MAX_COUNT } from "@/lib/fhir/rest";
import { requireModuleEnabled, isModuleEnabled } from "@/lib/modules/gate";
import { observationsFromReportData } from "@/lib/fhir/resources";
const SESSION_OK = {
@@ -65,6 +70,10 @@ beforeEach(() => {
count: 1,
resetAt: Date.now(),
} as never);
// The doctorReport module is ON for the contract assertions below; the
// OFF behaviour is pinned in `module-gate.test.ts`.
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
vi.mocked(isModuleEnabled).mockResolvedValue(true);
vi.mocked(loadFhirContext).mockResolvedValue({
data: {} as never,
identity: { insuranceNumber: null },
+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 hourly HR bucket inserts;
* - a re-post of the same hour OVERWRITES (status `updated`, not a
* - a fresh 10-min HR bucket inserts, with its per-bucket min/max spread;
* - a re-post of the same bucket OVERWRITES (status `updated`, not a
* duplicate row);
* - a malformed hourly-HR bucket externalId is `skipped`;
* - a malformed / off-grid aggregated HR bucket externalId is `skipped`;
* - v1.30.8: an out-of-range or mis-ordered spread is dropped to null while
* the trustworthy average survives;
* - the per-sample uuid HR path is unaffected (immutable duplicate);
* - the existing per-day step `stats:` overwrite path is unaffected.
*/
@@ -131,8 +133,8 @@ beforeEach(() => {
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ count: 1 });
});
describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", () => {
it("inserts a fresh hourly HR bucket as a PULSE row", async () => {
describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34)", () => {
it("inserts a fresh 10-min HR bucket as a PULSE row", async () => {
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
const res = await POST(
makeRequest({ entries: [hrBucketEntry(HR_BUCKET_ID, 72)] }),
@@ -143,7 +145,7 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
expect(data.updated).toBe(0);
expect(data.entries[0].status).toBe("inserted");
// Stored as a PULSE row carrying the hourly average value.
// Stored as a PULSE row carrying the 10-min average value.
const createArg = vi.mocked(prisma.measurement.createMany).mock.calls[0][0];
const rows = (createArg as { data: { type: string; value: number }[] })
.data;
@@ -151,7 +153,7 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
expect(rows[0].value).toBe(72);
});
it("persists per-bucket min/max on a fresh hourly HR bucket (iOS #34 ext)", async () => {
it("persists per-bucket min/max on a fresh 10-min HR bucket (iOS #34 ext)", async () => {
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
const res = await POST(
makeRequest({
@@ -172,6 +174,39 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
expect(rows[0].valueMax).toBe(96);
});
it.each([
// out-of-plausible-range spread (a sensor glitch / spurious discreteMax)
{ valueMin: 58, valueMax: 99999 },
{ valueMin: -9999, valueMax: 96 },
// mis-ordered: valueMin above the average, or valueMax below it
{ valueMin: 80, valueMax: 96 },
{ valueMin: 58, valueMax: 70 },
])(
"v1.30.8 — drops an out-of-range or mis-ordered spread to null but keeps the average %o",
async (spread) => {
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
const res = await POST(
makeRequest({ entries: [hrBucketEntry(HR_BUCKET_ID, 72, spread)] }),
);
expect(res.status).toBe(200);
const createArg = vi.mocked(prisma.measurement.createMany).mock
.calls[0][0];
const rows = (
createArg as {
data: {
value: number;
valueMin: number | null;
valueMax: number | null;
}[];
}
).data;
// The trustworthy average survives; the invalid spread is dropped.
expect(rows[0].value).toBe(72);
expect(rows[0].valueMin).toBeNull();
expect(rows[0].valueMax).toBeNull();
},
);
it("overwrites min/max alongside the average on a re-post", async () => {
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "PULSE", source: "APPLE_HEALTH", externalId: HR_BUCKET_ID },
@@ -214,8 +249,8 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
expect(rows[0].valueMax).toBeNull();
});
it("overwrites the same hour bucket on a re-post (updated, not duplicate)", async () => {
// The bucket already exists from an earlier within-hour post.
it("overwrites the same bucket on a re-post (updated, not duplicate)", async () => {
// The bucket already exists from an earlier within-bucket post.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
type: "PULSE",
@@ -234,19 +269,19 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
expect(data.duplicates).toBe(0);
expect(data.entries[0].status).toBe("updated");
// The overwrite went through updateMany with the new hourly average.
// The overwrite went through updateMany with the new 10-min average.
expect(prisma.measurement.updateMany).toHaveBeenCalledTimes(1);
const updateArg = vi.mocked(prisma.measurement.updateMany).mock.calls[0][0];
expect((updateArg as { data: { value: number } }).data.value).toBe(78);
expect(prisma.measurement.createMany).not.toHaveBeenCalled();
});
it("skips a malformed hourly HR bucket externalId", async () => {
it("skips a malformed aggregated HR bucket externalId", async () => {
const res = await POST(
makeRequest({
entries: [
hrBucketEntry(
"stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:30:00.000Z",
"stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:35:00.000Z",
70,
),
],
+34 -25
View File
@@ -50,8 +50,8 @@ import {
import { withIdempotency } from "@/lib/idempotency";
import {
mapAppleHealthEntry,
isHourlyHeartRateStatsExternalId,
targetsHourlyHeartRateBucket,
isAggregatedBucketExternalId,
targetsAggregatedBucket,
} from "@/lib/measurements/apple-health-mapping";
import {
MEASURED_AT_TOLERANCE_MS,
@@ -292,19 +292,18 @@ async function postBatch(request: NextRequest): Promise<Response> {
continue;
}
// v1.19.0 (iOS #34) — go-forward aggregated heart-rate wire
// contract. A `stats:HKQuantityTypeIdentifierHeartRate:<hour>` row
// is the hourly-average PULSE bucket; it rides the generic `stats:`
// overwrite path below so a within-hour re-post (the running mean
// shifts) replaces the value instead of duplicating. Reject a row
// that targets the HR-bucket prefix but carries a malformed hour
// suffix — a garbage suffix would mint an un-overwriteable row that
// the next hour's re-post can't collapse onto. A well-formed bucket
// (and every non-HR `stats:` / per-sample `uuid` externalId) passes
// through unchanged.
// v1.30.7 (iOS #34) — go-forward aggregated heart-rate wire
// contract. A `stats:HKQuantityTypeIdentifierHeartRate:<10-min-ISO-Z>`
// row is the 10-minute-average PULSE bucket; it rides the generic
// `stats:` overwrite path below so a within-bucket re-post (the running
// mean shifts) replaces the value instead of duplicating. Reject a row
// that targets a bucket prefix but carries a malformed suffix — a
// garbage suffix would mint an un-overwriteable row the next re-post
// can't collapse onto. A well-formed bucket (and every non-bucket
// `stats:` / per-sample `uuid` externalId) passes through unchanged.
if (
targetsHourlyHeartRateBucket(entry.externalId) &&
!isHourlyHeartRateStatsExternalId(entry.externalId)
targetsAggregatedBucket(entry.externalId) &&
!isAggregatedBucketExternalId(entry.externalId)
) {
results[index] = {
index,
@@ -314,20 +313,30 @@ async function postBatch(request: NextRequest): Promise<Response> {
continue;
}
// v1.19.2 (iOS #34 extension) — the per-bucket MIN / MAX are persisted
// ONLY on a well-formed hourly HR bucket row. A per-sample reading, a
// v1.30.7 (iOS #34) — the per-bucket MIN / MAX are persisted ONLY on a
// well-formed 10-min aggregated bucket row. A per-sample reading, a
// per-day cumulative `stats:` total, or a manual entry never carries a
// spread, so we pin them to null there even if a client mis-sends the
// fields — `value` stays the single source of truth for those rows.
const isHrBucket = isHourlyHeartRateStatsExternalId(entry.externalId);
const valueMin =
isHrBucket && typeof mapped.valueMin === "number"
? mapped.valueMin
: null;
const valueMax =
isHrBucket && typeof mapped.valueMax === "number"
? mapped.valueMax
: null;
//
// v1.30.8 — validate the spread the same way `value` is guarded (above):
// it now drives the DAY min/max band + the intraday envelope, so an
// out-of-range or mis-ordered spread (a spurious `discreteMin` of 0, a
// sensor glitch) would poison the aggregate exactly like a bogus `value`
// would. Persist the spread ONLY when both extremes are present, in range,
// and correctly ordered around `value`; otherwise drop to null and keep the
// trustworthy average. Partial or invalid spread → avg-only, still valid.
const isHrBucket = isAggregatedBucketExternalId(entry.externalId);
const spreadValid =
isHrBucket &&
typeof mapped.valueMin === "number" &&
typeof mapped.valueMax === "number" &&
validateMeasurementRange(mapped.type, mapped.valueMin) === null &&
validateMeasurementRange(mapped.type, mapped.valueMax) === null &&
mapped.valueMin <= mapped.value &&
mapped.value <= mapped.valueMax;
const valueMin = spreadValid ? mapped.valueMin : null;
const valueMax = spreadValid ? mapped.valueMax : null;
prepared.push({
index,
+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" },
@@ -0,0 +1,198 @@
/**
* `GET /api/sync/changes` — cycle module gate.
*
* The delta feed multiplexes five domains over one cursor and used to serve
* `cycleDays` / `cycles` ungated, so an account with cycle tracking off got
* period days and per-day symptom rows over the very token that
* `/api/cycle/*` refuses with a 403.
*
* Behavioural, not structural: the assertions read the response body and the
* Prisma call log, so reverting the gate in the route turns them red.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() }));
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
cycleDayLog: { findMany: vi.fn() },
menstrualCycle: { findMany: vi.fn() },
},
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { isModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
import { decodeCursor } from "@/lib/sync/cursor";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
const NOW = new Date("2026-07-01T12:00:00.000Z");
/** One live cycle-day row carrying a symptom — the payload that must not leak. */
const CYCLE_DAY_ROW = {
id: "cd-1",
userId: "user-1",
externalId: "ext-cd-1",
date: NOW,
flow: "MEDIUM",
notes: null,
notesEncrypted: null,
syncVersion: 3,
deletedAt: null,
updatedAt: NOW,
createdAt: NOW,
symptoms: [{ id: "sym-1", symptom: "CRAMPS", severity: 2 }],
};
const CYCLE_ROW = {
id: "mc-1",
userId: "user-1",
startDate: NOW,
endDate: null,
cycleLength: 28,
periodLength: 5,
predicted: false,
syncVersion: 2,
deletedAt: null,
updatedAt: NOW,
createdAt: NOW,
};
interface FeedBody {
data: {
changes: {
measurements: { upserts: unknown[]; tombstones: unknown[] };
cycleDays: { upserts: unknown[]; tombstones: unknown[] };
cycles: { upserts: unknown[]; tombstones: unknown[] };
};
cursor: string | null;
};
}
function req(query = ""): NextRequest {
return new NextRequest(`http://localhost/api/sync/changes${query}`);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
count: 1,
resetAt: Date.now(),
} as never);
// Non-cycle domains always return rows so a 403/empty-everything result
// cannot be mistaken for a passing "cycle omitted" assertion.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
id: "m-1",
externalId: "ext-m-1",
type: "WEIGHT",
value: 80,
unit: "kg",
measuredAt: NOW,
source: "MANUAL",
notes: null,
notesEncrypted: null,
syncVersion: 1,
deletedAt: null,
updatedAt: NOW,
},
] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([
CYCLE_DAY_ROW,
] as never);
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([
CYCLE_ROW,
] as never);
});
describe("GET /api/sync/changes — cycle module gate", () => {
it("omits cycle rows and never queries them when the module is off", async () => {
vi.mocked(isModuleEnabled).mockResolvedValue(false);
const res = await GET(req());
// OMIT, not refuse — the other four domains must keep syncing.
expect(res.status).toBe(200);
const body = (await res.json()) as FeedBody;
expect(body.data.changes.cycleDays.upserts).toEqual([]);
expect(body.data.changes.cycleDays.tombstones).toEqual([]);
expect(body.data.changes.cycles.upserts).toEqual([]);
expect(body.data.changes.cycles.tombstones).toEqual([]);
// The rest of the feed is untouched — proves the empty cycle blocks are
// the gate, not a wholesale refusal.
expect(body.data.changes.measurements.upserts).toHaveLength(1);
// Not read out of Postgres at all.
expect(prisma.cycleDayLog.findMany).not.toHaveBeenCalled();
expect(prisma.menstrualCycle.findMany).not.toHaveBeenCalled();
// No symptom string anywhere in the serialised payload.
expect(JSON.stringify(body)).not.toContain("CRAMPS");
expect(JSON.stringify(body)).not.toContain("ext-cd-1");
});
it("serves cycle rows when the module is on", async () => {
vi.mocked(isModuleEnabled).mockResolvedValue(true);
const res = await GET(req());
expect(res.status).toBe(200);
const body = (await res.json()) as FeedBody;
expect(body.data.changes.cycleDays.upserts).toHaveLength(1);
expect(body.data.changes.cycles.upserts).toHaveLength(1);
expect(prisma.cycleDayLog.findMany).toHaveBeenCalledTimes(1);
expect(prisma.menstrualCycle.findMany).toHaveBeenCalledTimes(1);
});
it("gates on the cycle module key specifically", async () => {
vi.mocked(isModuleEnabled).mockResolvedValue(true);
await GET(req());
expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "cycle");
});
it("leaves the cycle cursor watermarks untouched while the module is off", async () => {
// A skipped domain must not advance its watermark, so re-enabling
// resumes from where the client left off rather than skipping the rows
// that changed while the module was hidden.
vi.mocked(isModuleEnabled).mockResolvedValue(false);
const res = await GET(req());
const body = (await res.json()) as FeedBody;
const cursor = decodeCursor(body.data.cursor as string);
expect(cursor).not.toBeNull();
expect(cursor!.cycleDays).toBeUndefined();
expect(cursor!.cycles).toBeUndefined();
// The domain that did return rows advanced normally.
expect(cursor!.measurements).toBeDefined();
});
});
+52 -11
View File
@@ -37,6 +37,9 @@
* been pruned, so an incremental delta could silently miss it).
* - `syncVersion` is echoed per row so the client can keep its mirror's
* version monotonic.
* - The `cycleDays` / `cycles` blocks are MODULE-GATED: an account with
* cycle tracking off gets both blocks empty (never a 403 — see the
* gate note in the handler) and their cursor watermarks untouched.
*
* `apiHandler` + `requireAuth` (cookie OR Bearer; iOS uses Bearer).
* Read-only — no idempotency, no write side-effect (unlike the legacy
@@ -67,6 +70,7 @@ import {
type CycleDayLogDTO,
type MenstrualCycleDTO,
} from "@/lib/cycle/dto";
import { isModuleEnabled } from "@/lib/modules/gate";
const DEFAULT_LIMIT = 200;
const MAX_LIMIT = 500;
@@ -192,6 +196,36 @@ export const GET = apiHandler(async (request: NextRequest) => {
const serverNow = new Date();
// ── module gate: cycle ────────────────────────────────────────────
// The `cycleDays` / `cycles` domains carry period days and per-day
// symptom rows — the same data every `/api/cycle/*` route refuses with a
// 403 when the module is off. Without this the delta feed re-served them
// ungated to the very same token.
//
// OMIT, NOT 403 — deliberately, and unlike the whole-record FHIR export.
// This feed multiplexes five domains over one cursor: refusing the whole
// request would wedge measurement / mood / intake catch-up for every
// account with cycle tracking off, which (the gate delegates to
// `isCycleEnabled`) is the default for most accounts. A partial payload
// is the only outcome that keeps the client syncing while still honouring
// the promise that a disabled module hands out nothing.
//
// The two cycle watermarks are deliberately NOT advanced while the module
// is off (the queries are skipped, so `advanceCursor` sees an empty page
// and leaves the prior watermark untouched). Re-enabling therefore resumes
// from where the client actually left off instead of silently skipping the
// rows that changed while the module was hidden.
//
// KNOWN GAP, out of scope here: a client that synced cycle rows BEFORE the
// module was turned off keeps its stale local mirror. Emitting tombstones
// for them would be wrong — a tombstone means deleted, and disabling a
// module must leave the rows intact so re-enabling finds a complete
// history. Purging a paired client's mirror needs an explicit
// "module went dark" signal in the feed contract plus client support for
// it; that is an additive wire change and an iOS-client change, not a
// server-side gate fix, so it is left for a coordinated contract cycle.
const cycleEnabled = await isModuleEnabled(user.id, "cycle");
// The retention horizon: tombstones older than this may have been
// pruned by the cleanup job, so a cursor that predates it can no longer
// be trusted to deliver every deletion incrementally.
@@ -291,17 +325,23 @@ export const GET = apiHandler(async (request: NextRequest) => {
updatedAt: true,
},
}),
prisma.cycleDayLog.findMany({
where: { userId: user.id, ...keysetFilter(cursor.cycleDays) },
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
take: limit + 1,
include: dayLogSymptomInclude,
}),
prisma.menstrualCycle.findMany({
where: { userId: user.id, ...keysetFilter(cursor.cycles) },
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
take: limit + 1,
}),
// Skipped outright when the module is off — not filtered after the
// fact — so the disabled rows are never read out of Postgres at all.
cycleEnabled
? prisma.cycleDayLog.findMany({
where: { userId: user.id, ...keysetFilter(cursor.cycleDays) },
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
take: limit + 1,
include: dayLogSymptomInclude,
})
: [],
cycleEnabled
? prisma.menstrualCycle.findMany({
where: { userId: user.id, ...keysetFilter(cursor.cycles) },
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
take: limit + 1,
})
: [],
]);
const nextCursor: SyncCursor = { ...cursor };
@@ -464,6 +504,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
cycle_day_tombstones: cycleDayTombstones.length,
cycle_upserts: cycleUpserts.length,
cycle_tombstones: cycleTombstones.length,
cycle_module_enabled: cycleEnabled,
has_more: hasMore,
cursor_present: Boolean(parsed.data.cursor),
},
-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>
);
}
@@ -25,7 +25,6 @@ import {
} from "@/lib/query-keys";
import {
reduceCurrentWindowStatus,
toZonedDate,
type ScheduleWindowInput,
} from "@/lib/medications/window-status";
import { useAuth } from "@/hooks/use-auth";
@@ -142,7 +141,6 @@ export function pickDefaultMedicationId(
if (actives.length === 0) return null;
if (actives.length === 1) return actives[0].id;
const nowLocal = toZonedDate(now, tz);
const due = actives.find((m) => {
// v1.16.9 — the same server display-due gate the cards apply: a
// future (non-overdue) next-due suppresses the overdue tiers and a
@@ -150,7 +148,7 @@ export function pickDefaultMedicationId(
const nextDueMs = m.nextDueAt ? new Date(m.nextDueAt).getTime() : NaN;
const status = reduceCurrentWindowStatus({
schedules: m.schedules,
nowBerlin: nowLocal,
now,
lateMinutes: thresholds.lateMinutes,
missedMinutes: thresholds.missedMinutes,
active: true,
@@ -4,19 +4,21 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
WorkoutDetailHeader,
WorkoutDetailHRChart,
WorkoutDetailRoute,
WorkoutDetailStats,
WorkoutDetailRoute,
WorkoutDetailHrSection,
WorkoutDetailZones,
WorkoutDetailSplits,
WorkoutDetailDayLinks,
} from "../workout-detail";
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
import type { RouteCoordinate } from "@/lib/workouts/route-svg";
/**
* v1.4.32 — `<WorkoutDetail*>` unit tests.
*
* Each primitive runs through SSR with a single canonical fixture.
* The tests pin the visible labels + the graceful-fallback shapes so
* the page renders something useful even when the optional fields
* (route, HR samples, energy) are absent.
* #67 — `<WorkoutDetail*>` unit tests over the split `workout-detail/`
* directory. Each primitive runs through SSR with a canonical fixture;
* the tests pin the visible labels and the hide-don't-render contract
* (every data-less section returns null).
*/
function render(node: React.ReactNode) {
@@ -43,6 +45,12 @@ const FIXTURE: WorkoutDetailPayload = {
externalId: "ext-w-1",
metadata: null,
route: null,
samples: null,
hrSeries: null,
zones: null,
splits: null,
sportContext: null,
aiInsight: null,
canonicalId: "w-1",
};
@@ -62,7 +70,6 @@ describe("<WorkoutDetailStats>", () => {
expect(html).toContain("Duration");
expect(html).toContain("Distance");
expect(html).toContain("Active energy");
expect(html).toContain("Average HR");
expect(html).toContain("145 bpm");
expect(html).toContain("Steps");
expect(html).toContain("Pace");
@@ -82,68 +89,176 @@ describe("<WorkoutDetailStats>", () => {
const html = render(<WorkoutDetailStats workout={minimal} />);
expect(html).toContain("Duration");
expect(html).not.toContain("Distance");
expect(html).not.toContain("Active energy");
expect(html).not.toContain("Steps");
expect(html).not.toContain("Average HR");
});
it("renders the own-history average line when ≥ 2 sessions exist", () => {
const withCtx: WorkoutDetailPayload = {
...FIXTURE,
sportContext: {
count: 8,
avgDurationSec: 2040,
avgDistanceM: 5800,
avgAvgHr: 148,
},
};
const html = render(<WorkoutDetailStats workout={withCtx} />);
expect(html).toContain('data-slot="workout-detail-sport-average"');
expect(html).toContain("148 bpm");
});
it("hides the average line for a lone session", () => {
const lone: WorkoutDetailPayload = {
...FIXTURE,
sportContext: {
count: 1,
avgDurationSec: 1800,
avgDistanceM: 5200,
avgAvgHr: 145,
},
};
const html = render(<WorkoutDetailStats workout={lone} />);
expect(html).not.toContain('data-slot="workout-detail-sport-average"');
});
});
describe("<WorkoutDetailRoute>", () => {
it("renders the empty-state when the workout has no route", () => {
it("returns null when the workout has no route", () => {
const html = render(<WorkoutDetailRoute workout={FIXTURE} />);
expect(html).toContain('data-slot="workout-detail-route-empty"');
expect(html).toContain("No GPS route");
expect(html).toBe("");
});
it("renders an SVG polyline when GeoJSON LineString geometry is present", () => {
it("renders an SVG path for a real route", () => {
const coords: RouteCoordinate[] = [];
for (let i = 0; i < 20; i++)
coords.push([11.0 + i * 0.0004, 50.0 + i * 0.0003]);
const withRoute: WorkoutDetailPayload = {
...FIXTURE,
route: {
geometry: {
type: "LineString",
coordinates: [
[11.0, 49.0],
[11.01, 49.005],
[11.02, 49.01],
],
},
geometry: { type: "LineString", coordinates: coords },
sampleTimestamps: null,
},
};
const html = render(<WorkoutDetailRoute workout={withRoute} />);
expect(html).toContain('data-slot="workout-detail-route"');
expect(html).toContain("<polyline");
expect(html).toContain("<path");
expect(html).toContain("Export GPX");
});
it("falls back to the empty state for a LineString with fewer than 2 points", () => {
it("returns null for a degenerate point-shaped route", () => {
const stub: WorkoutDetailPayload = {
...FIXTURE,
route: {
geometry: { type: "LineString", coordinates: [[11, 49]] },
geometry: {
type: "LineString",
coordinates: [
[11, 49],
[11.0001, 49.0001],
],
},
sampleTimestamps: null,
},
};
const html = render(<WorkoutDetailRoute workout={stub} />);
expect(html).toContain('data-slot="workout-detail-route-empty"');
expect(render(<WorkoutDetailRoute workout={stub} />)).toBe("");
});
});
describe("<WorkoutDetailHRChart>", () => {
it("renders the unavailable notice with aggregate HR values when present", () => {
const html = render(<WorkoutDetailHRChart workout={FIXTURE} />);
expect(html).toContain('data-slot="workout-detail-hr-chart"');
expect(html).toContain("145 bpm");
expect(html).toContain("170 bpm");
describe("<WorkoutDetailHrSection>", () => {
it("returns null without an HR series", () => {
expect(render(<WorkoutDetailHrSection workout={FIXTURE} />)).toBe("");
});
it("renders just the unavailable notice when no HR aggregates are present", () => {
const noHr: WorkoutDetailPayload = {
it("renders the card and a provenance chip for a pulse-window series", () => {
const withHr: WorkoutDetailPayload = {
...FIXTURE,
avgHr: null,
maxHr: null,
hrSeries: {
source: "pulse_window",
bucketSec: 8,
points: [
{ tSec: 0, mean: 130, min: 125, max: 135 },
{ tSec: 8, mean: 140, min: 135, max: 145 },
],
envelope: false,
},
};
const html = render(<WorkoutDetailHRChart workout={noHr} />);
expect(html).toContain("Per-second heart-rate");
expect(html).not.toContain("145 bpm");
const html = render(<WorkoutDetailHrSection workout={withHr} />);
expect(html).toContain('data-slot="workout-detail-hr"');
expect(html).toContain('data-slot="workout-detail-hr-provenance"');
expect(html).toContain("From your heart-rate data");
});
it("omits the provenance chip for a stored series", () => {
const withHr: WorkoutDetailPayload = {
...FIXTURE,
hrSeries: {
source: "workout_series",
bucketSec: 8,
points: [
{ tSec: 0, mean: 130, min: 125, max: 135 },
{ tSec: 8, mean: 140, min: 135, max: 145 },
],
envelope: true,
},
};
const html = render(<WorkoutDetailHrSection workout={withHr} />);
expect(html).toContain('data-slot="workout-detail-hr"');
expect(html).not.toContain('data-slot="workout-detail-hr-provenance"');
});
});
describe("<WorkoutDetailZones>", () => {
it("returns null without zone data", () => {
expect(render(<WorkoutDetailZones workout={FIXTURE} />)).toBe("");
});
it("renders the stacked bar and per-zone minutes", () => {
const withZones: WorkoutDetailPayload = {
...FIXTURE,
zones: {
model: "tanaka",
hrMax: 180,
zones: [
{ zone: 1, lowBpm: 90, highBpm: 108, seconds: 300 },
{ zone: 2, lowBpm: 108, highBpm: 126, seconds: 600 },
{ zone: 3, lowBpm: 126, highBpm: 144, seconds: 500 },
{ zone: 4, lowBpm: 144, highBpm: 162, seconds: 200 },
{ zone: 5, lowBpm: 162, highBpm: null, seconds: 60 },
],
},
};
const html = render(<WorkoutDetailZones workout={withZones} />);
expect(html).toContain('data-slot="workout-detail-zones"');
expect(html).toContain("Effort zones");
expect(html).toContain("Z3");
});
});
describe("<WorkoutDetailSplits>", () => {
it("returns null without splits", () => {
expect(render(<WorkoutDetailSplits workout={FIXTURE} />)).toBe("");
});
it("renders a row per kilometre", () => {
const withSplits: WorkoutDetailPayload = {
...FIXTURE,
splits: [
{ km: 1, durationSec: 300, paceSecPerKm: 300 },
{ km: 2, durationSec: 288, paceSecPerKm: 288 },
],
};
const html = render(<WorkoutDetailSplits workout={withSplits} />);
expect(html).toContain('data-slot="workout-detail-splits"');
expect(html).toContain("5:00");
expect(html).toContain("4:48 /km");
});
});
describe("<WorkoutDetailDayLinks>", () => {
it("renders the that-day navigation links", () => {
const html = render(<WorkoutDetailDayLinks workout={FIXTURE} />);
expect(html).toContain('data-slot="workout-detail-day-links"');
expect(html).toContain('href="/insights/pulse"');
expect(html).toContain('href="/insights/sleep"');
expect(html).toContain('href="/insights/mood"');
});
});
-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",
);
@@ -54,10 +54,10 @@ export interface ResolveDisplayedSlotInstantInput {
/** Wall-clock reference (injected for testability). */
now: Date;
/**
* IANA timezone the card's day boundary is reasoned in. The cards model
* the user's day in Europe/Berlin (see `toBerlinDate`), so this defaults
* to that; passing the user's actual zone keeps the slot on the right
* calendar day across the rare cross-midnight edge.
* IANA timezone the card's day boundary is reasoned in. Berlin is the
* last-resort fallback the card surfaces share; passing the user's
* actual zone keeps the slot on the right calendar day across the rare
* cross-midnight edge.
*/
timeZone?: string;
}
@@ -27,10 +27,7 @@ import { getDayOfWeekInTz } from "@/lib/tz/local-day";
import { type InjectionSiteKey } from "@/lib/medications/injection-sites";
import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog";
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
import {
reduceCurrentWindowStatus,
toZonedDate,
} from "@/lib/medications/window-status";
import { reduceCurrentWindowStatus } from "@/lib/medications/window-status";
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
import {
estimateDailyDoseCount,
@@ -325,12 +322,11 @@ export function Glp1MedicationCard({
a.windowStart.localeCompare(b.windowStart) ||
a.windowEnd.localeCompare(b.windowEnd),
);
const nowBerlin = toZonedDate(now, userTz);
const lateMinutes = thresholds?.lateMinutes ?? 120;
const missedMinutes = thresholds?.missedMinutes ?? 240;
const currentWindowStatus = reduceCurrentWindowStatus({
schedules: sortedSchedules,
nowBerlin,
now,
lateMinutes,
missedMinutes,
active: medication.active,
+13 -27
View File
@@ -13,10 +13,8 @@ import { formatDateTime, formatTime } from "@/lib/format";
import { getDateTimeFormat } from "@/lib/intl/formatter-cache";
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
import { formatDose } from "@/lib/medications/format-dose";
import {
reduceCurrentWindowStatus,
toZonedDate,
} from "@/lib/medications/window-status";
import { reduceCurrentWindowStatus } from "@/lib/medications/window-status";
import { resolveNextDueDayLabel } from "@/lib/medications/next-due-day-label";
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
import {
estimateDailyDoseCount,
@@ -242,7 +240,7 @@ export function MedicationCard({
a.windowStart.localeCompare(b.windowStart) ||
a.windowEnd.localeCompare(b.windowEnd),
);
const nowBerlin = toZonedDate(new Date(), userTz);
const now = new Date();
// v1.8.4 — the next-due instant comes from the server (`nextDueAt`,
// computed by the canonical recurrence engine anchored on the last
// intake). The day label below derives from it; the window-range /
@@ -261,7 +259,7 @@ export function MedicationCard({
const currentWindowStatus = reduceCurrentWindowStatus({
schedules: sortedSchedules,
nowBerlin,
now,
lateMinutes,
missedMinutes,
active: medication.active,
@@ -418,27 +416,15 @@ export function MedicationCard({
// Format day label relative to today
let dayLabel = "";
if (nextAt) {
const nextDate = toZonedDate(new Date(nextAt), userTz);
const todayStr = `${nowBerlin.getFullYear()}-${nowBerlin.getMonth()}-${nowBerlin.getDate()}`;
const nextStr = `${nextDate.getFullYear()}-${nextDate.getMonth()}-${nextDate.getDate()}`;
const tomorrow = new Date(nowBerlin);
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = `${tomorrow.getFullYear()}-${tomorrow.getMonth()}-${tomorrow.getDate()}`;
const diffDays = Math.round(
(nextDate.getTime() - nowBerlin.getTime()) /
(24 * 60 * 60 * 1000),
);
if (nextStr === todayStr) {
dayLabel = t("medications.today");
} else if (nextStr === tomorrowStr) {
dayLabel = t("medications.tomorrow");
} else if (diffDays <= 5) {
dayLabel = weekdayLabel(nextDate.getDay());
} else {
dayLabel = fmt.dateWithWeekdaySmart(nextDate);
}
const label = resolveNextDueDayLabel(new Date(nextAt), now, userTz);
dayLabel =
label.kind === "today"
? t("medications.today")
: label.kind === "tomorrow"
? t("medications.tomorrow")
: label.kind === "weekday"
? weekdayLabel(label.weekday)
: fmt.dateWithWeekdaySmart(label.instant);
}
// v1.16.1 — the TIME is the canonical next-due instant (the
@@ -28,10 +28,8 @@ import { useWeekdayLabel } from "@/components/medications/card-parts/medication-
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
import { estimateRunwayDays } from "@/components/medications/detail/supply-runway";
import { useMedicationIntake } from "@/components/medications/use-medication-intake";
import {
reduceCurrentWindowStatus,
toZonedDate,
} from "@/lib/medications/window-status";
import { reduceCurrentWindowStatus } from "@/lib/medications/window-status";
import { resolveNextDueDayLabel } from "@/lib/medications/next-due-day-label";
import { formatTime } from "@/lib/format";
import { formatTimeWindowRange } from "@/lib/time-window-format";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
@@ -420,7 +418,6 @@ function MedicationTableRowItem({
a.windowEnd.localeCompare(b.windowEnd),
);
const now = new Date();
const nowZoned = toZonedDate(now, userTz);
const nextDueMs = medication.nextDueAt
? new Date(medication.nextDueAt).getTime()
: NaN;
@@ -431,7 +428,7 @@ function MedicationTableRowItem({
// next dose is tomorrow can never paint an overdue pill today.
const currentWindowStatus = reduceCurrentWindowStatus({
schedules: sortedSchedules,
nowBerlin: nowZoned,
now,
lateMinutes,
missedMinutes,
active: medication.active,
@@ -535,23 +532,15 @@ function MedicationTableRowItem({
</span>
);
} else if (nextAt) {
const nextDate = toZonedDate(new Date(nextAt), userTz);
const todayStr = `${nowZoned.getFullYear()}-${nowZoned.getMonth()}-${nowZoned.getDate()}`;
const nextStr = `${nextDate.getFullYear()}-${nextDate.getMonth()}-${nextDate.getDate()}`;
const tomorrow = new Date(nowZoned);
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = `${tomorrow.getFullYear()}-${tomorrow.getMonth()}-${tomorrow.getDate()}`;
const diffDays = Math.round(
(nextDate.getTime() - nowZoned.getTime()) / (24 * 60 * 60 * 1000),
);
const label = resolveNextDueDayLabel(new Date(nextAt), now, userTz);
const dayLabel =
nextStr === todayStr
label.kind === "today"
? t("medications.today")
: nextStr === tomorrowStr
: label.kind === "tomorrow"
? t("medications.tomorrow")
: diffDays <= 5
? weekdayLabel(nextDate.getDay())
: fmt.dateWithWeekdaySmart(nextDate);
: label.kind === "weekday"
? weekdayLabel(label.weekday)
: fmt.dateWithWeekdaySmart(label.instant);
nextCell = (
<span>
{dayLabel}, {formatTime(new Date(nextAt).toISOString())}
+1 -3
View File
@@ -42,7 +42,6 @@ import { apiPost } from "@/lib/api/api-fetch";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
import {
reduceCurrentWindowStatus,
toZonedDate,
type ScheduleWindowInput,
} from "@/lib/medications/window-status";
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
@@ -133,7 +132,6 @@ export function deriveDueMedications(
const tz = options.tz ?? "Europe/Berlin";
const { lateMinutes, missedMinutes } =
options.thresholds ?? DEFAULT_THRESHOLDS;
const nowLocal = toZonedDate(now, tz);
const due: DueMedication[] = [];
for (const m of medications) {
@@ -146,7 +144,7 @@ export function deriveDueMedications(
const nextDueMs = m.nextDueAt ? new Date(m.nextDueAt).getTime() : NaN;
const status = reduceCurrentWindowStatus({
schedules: m.schedules,
nowBerlin: nowLocal,
now,
lateMinutes,
missedMinutes,
active: m.active,
Loaded 100 of 233 files, more files were not shown because too many files have changed in this diff. Show more