Compare commits

...
273 Commits
Author SHA1 Message Date
Marc-André Bombeck fe099acd67 Merge pull request #248 from MBombeck/release/v1.10.2
v1.10.2 — honest AI connection test, consistent insights, retention re-enabled
2026-06-03 09:27:33 +02:00
Marc-André Bombeck 44028b2a6e chore(release): v1.10.2 — honest AI connection test, consistent insights, retention re-enabled 2026-06-03 09:18:55 +02:00
Marc-André Bombeck 9bdf76ea11 fix(settings): localise AI connection-test failure reasons
Return a stable, secret-free reasonCode from /api/ai/test alongside the
English reason, map it to a localised string in the AI settings panel,
and fall back to the plain reason for any unmapped code. A German
operator no longer sees an English failure sentence in an otherwise
localised panel.
2026-06-03 09:17:13 +02:00
Marc-André Bombeck d8f623aea3 fix(measurements): close residual P2002 in the dense intra-day fold
Resolve the canonical daily row deterministically: prefer the row already
carrying the target stats: externalId before falling back to the row on the
canonical local-noon instant, with a stable orderBy tiebreaker. Adopting a
sibling and stamping the target externalId onto it could otherwise collide on
the (userId, type, source, externalId) index with the real canonical row.

Re-anchor the adopted row to local-noon only when that slot is free, so an
externalId row at a tz/DST-shifted instant cannot collide on the measured_at
composite during adopt.

Guard the create against a concurrent insert on the same canonical composite:
catch P2002 outside the transaction (a caught error inside leaves the tx in
the aborted state) and retry the fold once, which then resolves and adopts the
winning row instead of creating a duplicate.

Add a real-Postgres case covering a row already on the canonical instant under
the target stats: externalId plus a fresh out-of-window batch: the fold adopts
in place, no P2002, one canonical row with the correctly folded mean.
2026-06-03 09:17:13 +02:00
Marc-André Bombeck 8456d3bbc3 chore: correct body-cap comments, dockerignore licence posture, stale log copy
- workouts/batch + safeJson: the body cap is a DoS ceiling, not a bound
  the worst-case payload always fits under; an extreme GPS-heavy backlog
  can exceed it and falls back to smaller batches client-side. Note that
  request.text() still buffers the raw body, so the cap bounds the parse
  cost, not the initial read.
- .dockerignore: keep LICENSE / LICENSE.md in the image — AGPL-3.0
  conveys with the software.
- reminder-worker: the dense walk flag is an operator kill-switch now
  that the fold is reworked, not a pending-rework gate.
2026-06-03 09:10:46 +02:00
Marc-André Bombeck 33358c7960 test(medications): pin the GLP-1 card clock off the schedule window
Two assertions depended on the live wall-clock: the card suppresses the
upcoming-injection line while now sits inside a schedule window, so a CI
run during the 08:00-20:00 window rendered the pill instead of the
Next/Recommended-next line. Pin to 07:00 Berlin on a non-scheduled day.
2026-06-03 08:59:31 +02:00
Marc-André Bombeck 0ec5f7552f chore(api): regenerate OpenAPI for the capabilities endpoint 2026-06-03 08:59:31 +02:00
Marc-André Bombeck dbdc99a4a1 fix(insights): return values sub-page back-link to the originating metric
The show-all-readings link now carries the originating metric page as a
sanitised from param, so a weight to show-all drill-in returns to weight
rather than the Insights overview. The param is constrained to an internal
/insights/<slug> path so a crafted value cannot redirect off-site.
2026-06-03 08:56:20 +02:00
Marc-André Bombeck 732e85e4df fix(insights): add scores drill-down back-link and unify back-link styling
The composite-score detail page (/insights/scores/[metric]) gains a leading
back-link to the Insights overview, matching the other drill-down surfaces.
Align the medication detail + history back-links onto the canonical ghost
recipe (-ml-2 w-fit, mr-1 size-4 icon) used by the values and workout-detail
pages, replacing the divergent -ml-2 gap-1 variant.
2026-06-03 08:56:20 +02:00
Marc-André Bombeck 692b091637 feat(insights): unify range controls and part set across bespoke metric pages
Mount MetricRangeControls on the measurement-series bespoke pages (weight,
blood-pressure, pulse, bmi, sleep) so the range selector no longer vanishes
when navigating from a HealthKit metric. Mood, workouts, and medications are
event-driven rather than MeasurementType series, so they document the
omission inline.

Apply the canonical part set where the data supports it: blood-pressure and
sleep gain the diversity nudge + show-all-values foot link. Blood pressure
omits the single-series stat strip (it is sys+dia) and bmi omits the strip /
nudge / show-all entry (it is derived from WEIGHT), each with an inline note.
2026-06-03 08:56:20 +02:00
Marc-André Bombeck 73255f817d refactor(insights): extract shared MetricRangeControls from HealthKit page
Lift the time-range pills + period-over-period delta block out of
HealthKitMetricPage into a standalone MetricRangeControls component that
owns the range pref, the range read, and the direction-sentiment lookup.
The HealthKit scaffold now consumes the shared piece so the bespoke metric
pages can render the identical control.
2026-06-03 08:56:20 +02:00
Marc-André Bombeck 5b5ab5795c fix(settings): guard AI test and regenerate against non-JSON responses
The test and regenerate handlers called res.json() unconditionally. A
proxy-rewritten HTML error page made that throw "Unexpected token '<'",
which leaked into the toast. Both handlers now check the content-type and
wrap the parse, showing a clean message on a non-JSON body. The test
handler also renders the new { ok:false, reason } payload verbatim.
2026-06-03 08:56:20 +02:00
Marc-André Bombeck 564d7dec77 fix(ai): never 5xx from the connection test and report a categorised reason
The connection-test route mapped a provider 5xx to a 502, which a reverse
proxy rewrites to its own HTML error page; the browser's res.json() then
threw "Unexpected token '<'". Every provider-call failure now returns
HTTP 200 with a secret-free { ok:false, providerType, reason }. Reasons
are categorised: credential/auth (401/403, or a 5xx whose body signals an
invalidated/expired session), rate-limit (429), provider server error
(other 5xx) and network/timeout.

The empty-override "test my saved config" path now resolves through the
same provider chain generation uses, instead of the legacy codex->admin
probe that could test a different provider than the one generation runs.

The chain runner already advances past an auth-class failure to the next
enabled provider; add explicit coverage for that on the raw generation
path (401 -> next succeeds, fallback count incremented).
2026-06-03 08:56:19 +02:00
Marc-André Bombeck 192fe45469 feat(api): add GET /api/meta/capabilities discovery endpoint
Expose the server's live id vocabularies (derived-metric ids,
vitals-baseline types, insights layout tile-ids, metric-status ids,
the HealthKit ingest mapping, computed-score types, write allowlist) and
the FHIR coding constants, plus the running contract version, behind
requireAuth (cookie or Bearer, not admin).

Every list is sourced from its canonical server constant / registry, so
the endpoint cannot drift from what the routes actually accept and emit —
retiring the doc-vs-server enum-drift class for the native client. Export
the FHIR ATC/SNOMED URIs and a shared german-ATC default-locale set so the
export route and the discovery surface share one source.

Register the route in the OpenAPI table; drift-guard tests pin
derivedMetricIds to the registry and writeAllowlist to the constant.
2026-06-03 08:56:19 +02:00
Marc-André Bombeck 5ae7aa7570 feat(insights): add load, error, and empty states to the vitals dashboard
Render a skeleton row matching the resolved tile height while the derived
batch loads so the grid reserves its final footprint with no layout shift.
Hide the section heading until there is content (or a skeleton placeholder)
under it, removing the stranded-heading layout shift on empty data. Surface
an inline error message plus a Retry that refetches the shared batch query
when the batch fails, instead of the whole surface silently vanishing. Mark
the tiles container aria-busy while loading and aria-live polite, matching
the insights a11y pattern.
2026-06-03 08:56:19 +02:00
Marc-André Bombeck 786454ee2f fix(ui): repoint medication + admin status colours to theme tokens for light-mode AA
The intake-history taken badge and the admin reminders status glyphs used
bare Tailwind -400/-500 shades chosen for the dark theme. On the white
Alucard light card those land well below WCAG AA (green-400 1.74:1,
red-400 2.77:1, yellow-400 1.53:1). Repoint to the success / destructive /
warning theme tokens (light values 6.1:1 / 5.0:1 / 5.8:1 on white) and pin
the original shades behind dark: so the dark rendering stays identical.
2026-06-03 08:56:19 +02:00
Marc-André Bombeck 48f20f4622 feat(api): cap request-body size on batch and export ingest routes
Apply safeJson's opt-in maxBytes ceiling to the body-accepting batch
and export routes that lacked one. The cap measures the raw text
before JSON.parse, so an over-limit payload returns a clean 413
without buffering onto the heap.

- workouts/batch: reuse the existing 5 MB ceiling so chunked uploads
  with no Content-Length are bounded too.
- measurements/batch: 4 MB — an order of magnitude above a 500-entry
  batch's few-hundred-KB worst case.
- export/health-record: 64 KB — the selection payload is small and
  bounded.

Each cap is a DoS ceiling sized well above any legitimate payload,
never a tight bound on a real batch.
2026-06-03 08:56:19 +02:00
tarantila 81b9606f6f fix(docs): improve README language 2026-06-03 08:11:07 +02:00
tarantila 2795a50eef fix(docker): harden Docker build context 2026-06-03 08:11:05 +02:00
Marc-André Bombeck 64965162c7 feat(measurements): re-enable the dense intra-day retention drain
The v1.10.0.2 kill-switch defaulted the drain OFF after the P2002 fold
collision. With the fold reworked to coexist with the canonical daily row
and integration-tested against real rows, flip the default back ON
(DENSE_INTRADAY_RETENTION_ENABLED=true unless explicitly set to false). The
boot-discovery still defers past the startup storm via the boot-delay so it
never contends for the connection pool on a data-heavy tenant; the nightly
03:50 walk and the per-user handler run again.
2026-06-03 07:41:20 +02:00
Marc-André Bombeck f049eeb8b1 fix(measurements): rework dense intra-day fold to coexist with the canonical daily row
The dense intra-day retention fold minted its daily-mean stats: row with
an externalId-keyed upsert. On real data a row already occupies the
canonical local-noon instant — a sibling daily stats: row or a per-sample
HRV/PULSE reading that fell on noon — so the upsert lookup missed (the
existing row carries a different externalId) and the INSERT then violated
the (user_id, type, measured_at, source, sleep_stage) unique index with
P2002. The mocked unit tests could not surface it.

Resolve the canonical row by the measured_at composite the collision is on
(sleep_stage IS NULL for these continuous types, matched via the
NULLS NOT DISTINCT index) and adopt it in place — refresh the mean, stamp
the canonical stats: externalId, un-tombstone — or create it when absent.
Exclude the adopted row id from the per-sample soft-delete so a noon-instant
sample becomes the daily row instead of being tombstoned.

Add a testcontainer integration test that seeds the colliding shape (a
pre-existing canonical-instant row plus out-of-window per-sample rows) and
pins no-P2002, coexistence, the noon-sample case, idempotency, and the
retention bound. Update the unit coverage for the adopt-in-place path.
2026-06-03 07:41:20 +02:00
Marc-André Bombeck e6ade6b03d Merge pull request #247 from MBombeck/release/v1.10.1
v1.10.1 — consistent medication cards
2026-06-03 07:22:47 +02:00
Marc-André Bombeck b7b7e327ce chore(release): v1.10.1 — consistent medication cards 2026-06-03 07:17:01 +02:00
Marc-André Bombeck 2a584f6210 test(medications): pin card next/last slot symmetry + equal height
Extend the symmetry suite to assert both cards render the byte-identical
next/last slot wrapper (same reserved min-height, spacing, muted colour,
next-then-last order, no em-dash placeholder, no brighter foreground
token) and a constant-height bottom-pinned body across oral / injection
/ GLP-1 / PRN / one-shot, including the compliance-skeleton path. Update
the dose-accent assertions onto text-dose-accent and the GLP-1 rotation
test onto the folded-into-line nudge. Clock stays pinned.
2026-06-03 07:07:48 +02:00
Marc-André Bombeck b3e8f43ab8 fix(medications): make the card identical across every type
Route both the generic and GLP-1 cards through the shared next/last
slot, ending the divergence that made two meds of different types read
differently in the same grid row:

- the GLP-1 card showed last-then-next; both now show next-then-last;
- its next line painted text-foreground/85; both now use the muted token;
- its inner spacing was space-y-1; both now use space-y-3.5;
- it always rendered the next line and printed a literal em-dash when
  there was no next injection; both now gate the line on an actual next
  and drop the placeholder.

Make CardContent a flex column and pin the action row with mt-auto.
Combined with the reserved next/last slot and the constant-height
compliance skeleton, every card body is now equal height, so the pin
aligns the take/skip buttons across a grid row without opening the void
that an unequal-height pin produced before.

Fold the GLP-1 injection-site rotation nudge into the upcoming-injection
line instead of a separate bordered block, so the GLP-1 card keeps the
same row inventory and height as the generic card.

Repoint both cards' dose accent onto text-dose-accent, drop the GLP-1
compliance query's active-only enabled guard so both cards make the same
calls, and share the weekday-label helper.
2026-06-03 07:07:48 +02:00
Marc-André Bombeck 0255dbad7d fix(medications): repoint dose accent onto an AA-safe theme token
The medication cards stamped the upcoming schedule dose with Tailwind
stock text-purple-400, which reads ~3.1:1 on the white Alucard light
card and fails WCAG AA for body text. Add a theme-aware --dose-accent
token: the exact text-purple-400 oklch in dark mode (so the dark card
stays pixel-identical) and a darker AA-safe purple on the light card,
mirroring how the semantic feedback tokens land each tone above AA in
both themes. The cards switch to text-dose-accent in the follow-up
commit.
2026-06-03 07:07:48 +02:00
Marc-André Bombeck e4bec11c2f feat(medications): add shared next/last slot + compliance skeleton card-parts
Extract the medication-card middle slot into a shared
<MedicationNextLastSlot> so the generic and GLP-1 cards render an
identical next/last region — same order (next then last), same
text-muted-foreground colour, same space-y-3.5 spacing, same reserved
min-height, and the same gating (the caller passes a next line only when
one should show; the part never prints a placeholder). A shared
useWeekdayLabel helper collapses the two weekday i18n key families onto
the abbreviated form so the same weekday no longer renders 'Monday' on
one card and 'Mon' on the other.

Add a MedicationComplianceSkeleton that reserves the compliance block's
footprint while the per-card query is in flight or returns null, so a
card without compliance is no longer ~5rem shorter than its grid-row
sibling.
2026-06-03 07:07:48 +02:00
Marc-André Bombeck e1ea0f6645 Merge pull request #246 from MBombeck/fix/disable-dense-retention
v1.10.0.2 — disable intra-day retention drain (P2002 fold collision)
2026-06-03 03:45:37 +02:00
Marc-André Bombeck 7bd66615b6 fix(jobs): disable the dense intra-day retention drain (P2002 fold collision) 2026-06-03 03:39:01 +02:00
Marc-André Bombeck 1bd668ae88 Merge pull request #245 from MBombeck/fix/dense-retention-boot-defer
v1.10.0.1 — defer intra-day retention drain past startup
2026-06-03 02:29:26 +02:00
Marc-André Bombeck 3080828fb7 fix(jobs): defer the dense intra-day retention boot drain past startup to avoid pool exhaustion 2026-06-03 02:22:35 +02:00
Marc-André Bombeck 1fe70b27f5 Merge pull request #244 from MBombeck/release/v1.10.0
v1.10.0 — derived wellness metrics, device-event awareness, deeper ingestion
2026-06-03 01:17:14 +02:00
Marc-André Bombeck 56e9816def test(medications): pin the card clock so window-status and day labels are deterministic 2026-06-03 01:07:36 +02:00
Marc-André Bombeck fbab8cfd30 fix(jobs): break consolidation import cycle that crashed the worker at boot
The reminder worker failed to start in the production bundle with
`ReferenceError: Cannot access 'l' before initialization`, taking every
nightly pg-boss job down with it (score jobs + dense-intraday retention).

Root cause is a value-level import cycle between `consolidation-base.ts`
and `drain-per-sample-cumulative.ts`: each imported values from the other.
The bundler merges the tightly-cyclic pair into one module body and hoists
`DRAIN_CUMULATIVE_CUTOFF_HOURS = CONSOLIDATION_GRACE_CUTOFF_HOURS` ahead of
the constant it reads, so the eager read hits a temporal dead zone. It only
surfaced in the production build because dev/test run the modules unbundled.

Hoist the shared leaf — `CONSOLIDATION_GRACE_CUTOFF_HOURS`, the `PerSampleRow`
shape, and the timezone day-math helpers — into a new dependency-free module
`consolidation-tz.ts`. Both sides now import from the leaf, removing the cycle
at its root. Re-export the moved names from their former homes so every
existing import site resolves unchanged.
2026-06-03 01:01:21 +02:00
Marc-André Bombeck d105849026 fix(fhir): resolve the medication-administration cap per call so an operator override takes effect 2026-06-03 01:00:04 +02:00
Marc-André Bombeck e1d4182a03 test(medications): pin the clock for the next-due day-label assertion 2026-06-03 01:00:04 +02:00
Marc-André Bombeck 8dbfc6989a refactor(measurements): drop the unused score-type set and rhythm-classification enum 2026-06-03 01:00:04 +02:00
Marc-André Bombeck 56e14aa9fa Merge remote-tracking branch 'origin/main' into release/v1.10.0
# Conflicts:
#	CHANGELOG.md
#	docs/api/openapi.yaml
#	package.json
2026-06-03 00:40:41 +02:00
Marc-André Bombeck d43cbf3a3b chore(release): v1.10.0 — derived wellness metrics, device-event awareness, deeper ingestion 2026-06-03 00:08:22 +02:00
Marc-André Bombeck d369b406ac test(insights): align trend-sentiment + gate-inventory tests with the changes
The semantic-token swap means the trend arrow/delta now emit
text-success/text-warning (pixel-identical in dark, AA-safe in light), and
the batched derived route joins the insightStatus allowlist alongside the
single route. Update the trend-card sentiment-colour assertions and the
coach-route gate inventory accordingly.
2026-06-03 00:04:56 +02:00
Marc-André Bombeck 551d352581 feat(insights): surface inputs, method and a cited standard for all 11 derived metrics
Only Sleep and Readiness reached a provenance surface; the other nine
shipped a number with the cited standard buried in a code comment. Give
every derived metric a path to its inputs + method + cited standard:

- Recovery, Stress and Strain get a ScoreAnatomyView detail page and a
  tappable ring (they were read-only dead-ends). Stress surfaces its
  'HRV-derived proxy, NOT an EDA/galvanic measurement' caveat; Strain
  cites the TRIMP load model; Recovery the HRV-guided recovery literature.
- The vitals tiles (baseline, HRV balance, BMI, fitness age, vascular age)
  gain an info explainer affordance carrying the method + standard, since
  they render as tiles with no detail page.
- The coincident-deviation flag gains a dashboard tile (shown only when it
  fired) with the same explainer (median ± k·MAD basis).
- A single standards map (src/components/insights/derived/standards.ts)
  pins each metric's method copy key, standard name and a real linkable URL
  (DOI / WHO / Wikipedia) so the citation a metric exposes stays one source.

Add the per-metric method/caveat/description copy across all six locales.
Map storage units to display symbols so a baseline tile reads '36.6 °C',
not '36.6 celsius'. Show the band word under the strip rings so a value
reads 'low 62', not a bare reddish 62. Drop the empty dashed sparkline
placeholder when no trailing series exists rather than reserving dead
space, and give the tile a series + provenance slot for when one does.
2026-06-03 00:04:56 +02:00
Marc-André Bombeck b92d5cec82 feat(insights): batch the derived-metric reads behind one route
The Insights cold mount fired 14+ independent GET /api/insights/derived
requests — Readiness, Sleep and the vitals baselines are compute-on-visit,
and all of them share one Prisma pool. That is the pool-starvation class
that surfaces as the app hanging then recovering.

Add GET /api/insights/derived/batch?metrics=a,b,c[:type] that resolves the
requested metrics server-side under a bounded p-limit(4) with the profile
loaded once via the shared loadBaselineProfile, returning a map of the same
flat Derived<T> envelope keyed by the request token. Add the useDerivedBatch
hook + the insightsDerivedBatch query-key factory entry; the single-metric
route + hook stay for the per-score detail pages. The single route now also
adopts loadBaselineProfile instead of re-inlining the profile read. Honors
the existing 8s ceiling, retry:0, replace-with-fallback, and warms nothing
on visit. Register the new route in the OpenAPI contract.
2026-06-03 00:04:56 +02:00
Marc-André Bombeck 3f8c8e750c fix(i18n): translate the device-notification namespace for es/fr/it/pl
The entire insights.rhythmEvents namespace — section title, intro, the
load-error, and the medical-adjacent device-detection disclaimer — shipped
the English string verbatim in es/fr/it/pl. The locale-integrity guard only
checks key shape + placeholders, so the copy-paste passed CI while rendering
English on a Spanish/French/Italian/Polish device-notifications card,
disclaimer included. Translate the four keys into real localised copy.
2026-06-03 00:04:56 +02:00
Marc-André Bombeck 60c0ba4483 fix(insights): point derived score-band tokens at the AA-corrected semantic colours
The band primitives (score ring, coverage dots, contributor bars,
provenance chips + standard link, sparkline tint, trend arrow/delta)
painted straight from the raw `--dracula-*` variables, which carry no
`:root.light` override. On the white card the Alucard light theme uses,
the green/orange/yellow cleared roughly 1.1-1.5:1 — under AA — and the
`bg-dracula-yellow` confidence dot read near-invisible. The file comment
claimed they 'track automatically'; they did not.

Point BAND_NUMBER/PROGRESS/BORDER/VAR, the coverage-meter dot tint, the
provenance cyan chips, the sparkline stroke, and sentimentColorClass at
the semantic `--success`/`--warning`/`--destructive`/`--info` tokens
instead. Those alias the same Dracula tones in dark mode (pixel-identical
there, the primary target) but carry the darker AA-safe values under
`:root.light`. Clamp the score-ring provisional caption to one line so a
longer localised string cannot wrap over the ring arc.
2026-06-03 00:04:56 +02:00
Marc-André Bombeck ecf93317dd docs: document the wellness-summary report section and correct the computed-scores note 2026-06-02 23:43:22 +02:00
Marc-André BombeckandClaude Opus 4.8 f170198a83 fix(measurements): validate single-POST source against the writable allowlist
WRITABLE_MEASUREMENT_SOURCES was a dead export whose docblock claimed it
gated the single-entry POST, but the schema only refined out COMPUTED —
WITHINGS and IMPORT stayed client-writable, so a client could attribute a row
to a source the server owns (the Withings webhook / CSV importer) and pollute
the per-source canonical picker. Wire createMeasurementSchema.source to a Zod
enum built from the now-correct {MANUAL, APPLE_HEALTH} allowlist; the enum
subsumes the old COMPUTED refine. Regenerate the OpenAPI contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:40:24 +02:00
Marc-André BombeckandClaude Opus 4.8 dabdfdf68e fix(insights): confidence reflects real history depth; readiness source folds sleep + mood
The readiness and coincident-deviation composites passed historyDays =
windowDays (a constant) into deriveCoverage, pinning historyFraction to 1 so
a 7-day and a 30-day blend reported identical confidence. Pass the real
distinct-history-days backing the composite (the depth of the
best-supported present contributor) so confidence tracks actual depth.

The readiness provenance source was derived only from RHR/HRV/respiratory, so
a blend backed solely by sleep + mood could mislabel as DAY. Fold the present
sleep + mood provenance in so the source chip matches what actually backed
the headline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:40:24 +02:00
Marc-André BombeckandClaude Opus 4.8 fc6924d52f fix(insights): circular sleep-midpoint distance and explicit efficiency overlap
The sleep-score consistency + timing sub-scores measured the midpoint as a
linear minutes-of-day difference, so a sleeper whose midpoint straddles
midnight (and any night across a DST shift) read a spurious ~24-hour spread
that inflated the SD and timing distance. Measure the midpoint on the
circular 24-hour clock (mod 1440) — a circular mean for the habitual midpoint
and a circular distance for the SD and timing offset.

Efficiency clamped asleep > in-bed (overlapping HealthKit stages) silently to
100, masking a data problem. Handle the overlap explicitly: a small overshoot
caps at the AASM ceiling of 100 (asleep cannot exceed time in bed), a gross
overshoot drops the sub-score because the in-bed denominator is not usable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:40:24 +02:00
Marc-André BombeckandClaude Opus 4.8 cd8e34dac7 fix(insights): place HRV balance against the recent trend, not the band center
computeHrvBalance read recentAvg = baseline.value.center, which is the band
midpoint by construction and therefore always inside [low, high] — so the
"low" and "unbalanced" arms were dead and the card could only ever read
"balanced". Compute recentAvg from the last <= 7 DAY means (the recent
trend) and place that against the longer-window personal band, so a
suppressed recent trend flags "low" and an elevated one flags "unbalanced".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:40:24 +02:00
Marc-André BombeckandClaude Opus 4.8 148e6cd600 fix(insights): score the previous day in the nightly score engines
The Recovery/Stress/Strain crons fire in the small hours (04:45-04:55
Europe/Berlin, ~03 UTC) but scored the CURRENT UTC day, only a few hours
old: Strain missed the just-completed day's workouts and Stress saw a
near-empty intra-day SDNN set and falsely gated as insufficient. Score the
day that just ended (now - 1 day) across all three engines and their
measuredAt / externalId day stamps.

Resolve the previous-day key in one place by extracting the shared score-row
helpers the three engines each duplicated: scoreDayKey / scoreMeasuredAt /
scoreExternalId, the idempotent upsertScoreRow, and the cohort runScoreBatch
tally loop into src/lib/insights/score-row.ts; and loadBaselineProfile into
the baseline engine (the three byte-identical profile loaders + the partial
strain variant now route through it). The derived route should adopt
loadBaselineProfile too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:40:24 +02:00
Marc-André Bombeck 78c8177cd3 feat(insights): fold a confidence-gated derived signal into the daily briefing
When a derived wellness score (readiness or recovery) crosses a notability gate
— outside the green band AND confidence at least medium — the insights
generator now appends a SYSTEM CONTEXT block so the briefing can narrate the
shift observationally. Mirrors the GLP-1 plateau detector's append mechanism:
reads the one derived contract, returns null (the common case) at zero token
cost, and instructs the model to frame the signal as an observed pattern, never
a diagnosis or a dose recommendation. Add the readiness/recovery sourceMetric
enum values plus their briefing href + icon + trend-chart mappings.
2026-06-02 23:13:26 +02:00
Marc-André Bombeck 8c24f3ec2a feat(insights): add a wellness-score summary to the doctor report and FHIR export
Surface the server-derived nightly scores (recovery / stress / strain) in the
health-record export without polluting the clinical layer. The aggregator now
emits a wellnessScores summary (latest / avg / min / max / count per score over
the window) built from the already-fetched COMPUTED rows. The PDF renders them
in a clearly-labelled "Wellness summary" section, separate from the clinical
vitals table, with a "descriptive, not a clinical assessment" disclaimer. The
FHIR bundle emits one Observation per score under the survey category with a
text-only concept and the same disclaimer as an Annotation note, so a viewer
never mistakes a band for a diagnosis.
2026-06-02 23:13:26 +02:00
Marc-André Bombeck 3487bdaec8 feat(coach): fold the derived wellness layer into the snapshot as compact summaries
Add a "derived" block to the Coach snapshot: one tiny object per high-signal
derived metric (readiness, recovery, sleep, stress, strain, HRV balance,
cardio-fitness band, vascular-age delta) carrying value + band + confidence +
historyDays — never the raw series. A fully-populated block is ~250 tokens,
comfortably inside the snapshot budget. Insufficient metrics are omitted so the
model never sees no-data noise; each metric's compute is fault-isolated so a
transient failure on one never sinks the turn. Gated on the underlying signals
staying in scope. The system prompt now documents the block and frames the
bands as descriptive composites, not clinical findings.
2026-06-02 23:13:26 +02:00
Marc-André Bombeck d9405e17b9 feat(dashboard): surface the wellness scores as a data-gated ring strip
Add a wellness-scores section to the vitals dashboard: a row of score rings for
Readiness, Recovery, Sleep, Stress, and Strain. Every ring is
data-availability-gated — a score with no underlying data simply does not
render, so the strip never shows an apologetic empty ring. Readiness and Sleep
are tappable and route to their existing score-anatomy detail pages; the
persisted nightly scores render read-only. Every ring reads the one
/api/insights/derived route through the shared useDerivedMetric hook.
2026-06-02 23:13:26 +02:00
Marc-André Bombeck a8daef7376 feat(insights): read the persisted wellness scores through the derived contract
Register RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE as derived metrics that
read the nightly-persisted COMPUTED Measurement rows — a passthrough, never a
recompute. The dispatcher routes the three ids to a thin reader that returns
the latest score, a trailing-window trend, and a direction-aware band (stress
and strain invert, so a high stress reads red). This lets every surface — the
dashboard, the Coach snapshot, the doctor report — consume the scores through
the one Derived<T> route + hook rather than each growing its own read path.
Regenerate the OpenAPI enum, which picks up the three ids from the registry.
2026-06-02 23:13:26 +02:00
Marc-André Bombeck 02c50acd46 feat(insights): unify the derived-metric query hook onto one factory key
Two parallel loaders for the generic derived-metric route had grown — a
components-local hook keyed on a 4-element tuple and a hooks/ hook keyed on a
3-or-4-element tuple. Both shared the ["insights","derived",metric,…] prefix
with different tuple shapes, which silently poisons the TanStack cache between
surfaces. Collapse to the single components-local hook (auth-gated, 8 s client
ceiling, retry-free) on the one insightsDerived factory key; the fourth slot is
always present so the metric prefix can no longer carry two shapes.
2026-06-02 23:13:26 +02:00
Marc-André Bombeck c5ba38cebb feat(insights): register the stress, strain, and dense-retention jobs
Wire the two nightly score passes and the dense intra-day retention drain
into the worker. The stress (04:50) and strain (04:55) crons each get a
queue, a schedule, and a single-flight handler; the dense-retention drain
gets a per-user backfill queue with boot discovery (like mean-consolidation)
and folds its steady-state nightly walk onto the existing drain-cumulative
tick, after the daily-mean fold.

A queue not registered in allQueues silently never drains, so a source-grep
guard pins every registration site for all three jobs.
2026-06-02 22:49:43 +02:00
Marc-André Bombeck 6935feda15 feat(insights): compute and store a nightly strain score from workout load
Derive a daily cardio-load proxy from the per-workout heart-rate series
plus active energy. Each workout's HR series scores via Banister's
gender-weighted exponential TRIMP over the fractional heart-rate reserve
(Tanaka age-predicted HRmax, the user's resting HR); the day's TRIMP totals
map to a 0-100 saturating scale. Days with no usable HR series but a
non-trivial active-energy total fall back to an energy-only proxy, with the
path recorded in the result.

Stored as a COMPUTED STRAIN_SCORE row, idempotent per user per day, gated
when neither a usable HR series nor active energy is present.

Standard: Banister 1991; Morton, Fitz-Clarke & Banister 1990 (TRIMP);
Tanaka, Monahan & Seals 2001 (HRmax).
2026-06-02 22:49:43 +02:00
Marc-André Bombeck 57d9cc1674 feat(insights): compute and store a nightly stress score from intra-day HRV
Apple Watch has no electrodermal sensor, so there is no direct stress
measurement to read — what HealthKit does give is discrete SDNN samples
through the day. Derive an honest HRV-through-day PROXY: a suppressed,
below-baseline intra-day SDNN is a recognised marker of higher sympathetic
load. The provenance and label say "HRV-derived proxy" and never imply a
galvanic/EDA reading.

The engine reuses the shared vitals-baseline math (the same SDNN baseline
the wellness index reads), maps the day's below-baseline suppression to a
0-100 score, and stores it as a COMPUTED STRESS_SCORE row — server-owned, a
client can never POST it. It gates and writes nothing below the minimum
intra-day sample floor or without a usable baseline.

Standard: Kim et al. 2018 (HRV and stress meta-analysis); Thayer et al. 2012
(neurovisceral integration). Idempotent per user per day via the nightly job.
2026-06-02 22:49:43 +02:00
Marc-André Bombeck ce2b745cc3 feat(measurements): add a bounded dense intra-day retention tier for HRV/HR
Daytime heart-rate-variability (SDNN) and heart-rate samples need to keep
their intra-day shape long enough for the stress engine to read them, but
the raw per-sample volume must stay bounded. Add a retention tier that
keeps the last 14 days of raw HRV/HR samples and folds everything older to
one daily-mean row via the proven mean-consolidation drain (same idempotent
stats:<HK>:<day> fold, soft-delete, and rollup recompute).

The two dense-tier types are deliberately EXEMPT from the destructive
daily-mean drain allowlist (HIGH_FREQUENCY_MEAN_TYPES) — that drain would
collapse the intra-day shape and soft-delete the raw rows. A regression test
pins both exclusions so a future edit that adds either type to the drain
allowlist fails loudly.

Migration 0108 adds only the partial index that keeps the retention drain's
discovery and per-user scan cheap; the retention bound lives in code so
tuning it never needs a migration.
2026-06-02 22:49:43 +02:00
Marc-André Bombeck 56fc9cefbe docs(meta): sharpen the base SEO description
Replace the generic page description with the self-hosted positioning —
Withings + Apple Health sync, transparent derived wellness metrics, and
AI Insights you own — so the search-result snippet matches the README
and landing copy. OpenGraph and Twitter card descriptions are unchanged.
2026-06-02 22:39:40 +02:00
Marc-André Bombeck 4ddf385211 docs(readme): add wearable-ecosystem positioning and v1.10 derived metrics
Add an honest "Versus WHOOP / Oura / Fitbit ecosystems" subsection that
positions HealthLog on self-hosting, data ownership, multi-provider
aggregation, and the blood-pressure / glucose / medication context the
wrist bands don't hold — without claiming to be or match a wearable
device. Refresh the comparison table with BP+glucose, medication
compliance, derived metrics, and FHIR rows.

Fold the v1.10 derived-metrics tier into the status line, Key Features,
and roadmap: fitness-age, vascular-age delta, HRV balance, sleep score,
readiness and the stored recovery score, and the coincident-deviation
early-strain flag — each computed from the user's own measurements,
citing its inputs, degrading to "not enough data" rather than
fabricating a value. Stress and strain scores stay out of the copy: the
types are reserved but no engine computes them in v1.10.
2026-06-02 22:39:40 +02:00
Marc-André Bombeck 3dcec54726 feat(insights): compute and store a nightly recovery score
Add a recovery-score engine that turns a user's already-stored nightly
signals into a stored 0-100 score. It reuses the readiness blend — the
resting-heart-rate, HRV (SDNN), sleep and respiratory-rate deviation
from the personal baseline, with the null-redistribution the health
score already uses — so the stored series and the live readiness surface
can never drift. The lineage is the recovery-marker literature (Plews
2013, Buchheit 2014): a higher score reads as more recovered. It is a
descriptive wellness proxy, not a clinical or training-recovery
assessment, and it is only stored when enough components are present —
below that floor no row is written rather than a headline from one
signal.

A nightly job computes each eligible user's score and upserts it as a
COMPUTED RECOVERY_SCORE row, idempotent per user per day on the
externalId day key, so a re-fired tick overwrites in place instead of
duplicating. The score is never computed on a page visit. The queue is
registered in the worker's queue inventory, scheduled, and wired to a
handler; a grep guard pins all four points so it cannot silently never
run.
2026-06-02 22:31:21 +02:00
Marc-André Bombeck 33b75361f3 feat(measurements): add a COMPUTED source and first-class score types
Add a server-owned COMPUTED measurement source for rows a server engine
derives rather than a device or a person reports, plus three first-class
0-100 score types — RECOVERY_SCORE, STRESS_SCORE, STRAIN_SCORE (unit
"score", plausibility range 0..100).

COMPUTED is read-only from a client's perspective: it joins the read /
decode enum so the native client can decode the rows it queries and
charts, but every client-facing write surface rejects it exactly like
WITHINGS and IMPORT — the batch ingest allowlist stays {APPLE_HEALTH,
MANUAL} and the single-entry POST 422s a COMPUTED source.

All three score types thread through every type-exhaustiveness site: the
unit + plausibility-range maps, the PR-direction resolver (scores carry
no record direction, like the device events), the category overlay (a
new "scores" cluster), the list-view label / icon / colour maps with
locale strings across all six bundles, and the insight chart-token
allowlist + orphan-enum stripper. They are not clinical vitals, so they
stay out of the doctor-report PDF.

Only the recovery score is computed today; the stress and strain types
are defined now so the engines that compute them later need no schema
change. Migration 0106 is purely additive (ADD VALUE IF NOT EXISTS).
2026-06-02 22:31:21 +02:00
Marc-André Bombeck 51fc48f93c feat(fhir): raise the MedicationAdministration cap to 5000 and make it configurable
Raise the per-export administration ceiling from 1000 to 5000 (~3.5 years
at four doses a day) and resolve it from FHIR_MAX_MEDICATION_ADMINISTRATIONS,
bounded 1..50000 with a fallback to 5000 on an unset or out-of-range value.
The windowed-then-capped newest-first behaviour and the honest-truncation
narrative are unchanged; this only raises the ceiling so it bites far less
often. Add the var to the compose environment whitelist and the production
env example so it reaches the container and check-env stays green.
2026-06-02 22:22:50 +02:00
Marc-André Bombeck e893a43005 feat(fhir): wire the German BfArM ATC locale toggle into the export route
The builder already accepts a germanAtc option that appends the BfArM ATC
URI after the WHO entry. Thread it from the health-record export route: an
explicit germanAtc flag on the selection payload wins, otherwise it derives
from a German-region locale. The WHO coding stays coding[0], byte-identical
and locale-independent, so a WHO-only consumer is unaffected.

Add the optional germanAtc field to the strict export selection schema and
regenerate the OpenAPI contract.
2026-06-02 22:22:50 +02:00
Marc-André Bombeck 9990815efe feat(fhir): add additive SNOMED route and site codings to MedicationAdministration
Emit a SNOMED CT coding alongside the existing .text anchor on the
administration dosage. Route maps ORAL to 26643006 (Oral route) and
INJECTION to 34206005 (Subcutaneous route, the form HealthLog injections
take). Site keys on the eight InjectionSite enum values, collapsing to the
three gross body-region concepts (abdomen 818983003, thigh 68367000, upper
arm 40983000); laterality and the abdominal quadrant stay verbatim in the
.text anchor rather than inventing lateralised concepts.

Referencing concept ids in a FHIR instance is not redistributing the
licensed SNOMED distribution, so this is safe to ship. The .text anchor is
unchanged, so a consumer without a SNOMED binding is unaffected.

Reconcile the dose fixture to the real uppercase enum value the live
builder emits (the previous lowercased value never matched production).
2026-06-02 22:22:50 +02:00
Marc-André Bombeck 95064b405a test(healthkit): cover device-event ingest + the awareness surface
Add mapping tests for the five event classes (verdict resolution,
severity grading, the value-pinned-to-1 contract, unknown-codepoint
fallback, and that a continuous measurement never gets a classification),
the awareness-card gating + device-verdict framing + disclaimer presence,
and the events route narrowing + empty-state contract. Extend the enum,
chart-token and Coach-gate drift guards for the new event types.
2026-06-02 22:10:16 +02:00
Marc-André Bombeck 135141a750 feat(insights): surface device-flagged events as an awareness timeline
Add a timeline of the health notifications the user's device produced on
its own, reachable from the Insights overview. The surface reflects the
device's own decision: each row reads "Your device flagged …" and a
permanent disclaimer states this is not a medical assessment by
HealthLog and that HealthLog does not diagnose any condition.

The surface is data-availability-gated — it un-mounts entirely when the
user has no such events, so an account that never triggered one never
sees an empty or alarming card. Copy renders as plain React text
children; no markdown library is involved.
2026-06-02 22:08:12 +02:00
Marc-André Bombeck a45325af52 feat(healthkit): thread the device-event verdict through batch ingest
Accept the optional HKCategoryValue codepoint on each batch entry and
persist the resolved rhythm_classification on the stored row. The wire
field is additive and ignored for non-event identifiers, so every
existing caller is byte-for-byte unchanged. Regenerate the OpenAPI
contract.
2026-06-02 22:07:45 +02:00
Marc-André Bombeck b06b1cada4 feat(healthkit): add a categorical device-event row class
Add a discrete EVENT row class to the Measurement model for the
device-flagged health notifications a wearable produces on its own —
irregular-rhythm, high/low heart rate, walking-steadiness and
breathing-disturbance alerts.

Following the sleep-stage / glucose-context nullable-context precedent,
this is an additive nullable column on the existing table, not a new
storage paradigm. Five new MeasurementType values carry the event class
and a new rhythm_classification enum column carries the device's own
verdict / severity (migration 0105, with a CHECK that pins the column to
the event types). An event row stores value 1 and the device verdict;
it never participates in trend, rollup, chart or personal-record
analytics.

For the irregular-rhythm class, HealthLog ingests only the
classification RESULT the device's own certified on-device algorithm
already produced. It never ingests a raw ECG waveform, never
re-classifies, and never derives a verdict of its own.

The Apple Health batch ingest maps the five category identifiers, with
the device's HKCategoryValue codepoint resolved to the stored
classification; an unknown codepoint degrades to a fallback verdict
rather than dropping the event.
2026-06-02 22:07:28 +02:00
Marc-André Bombeck 635e64ace7 feat(healthkit): ingest seven additive HealthKit signals end-to-end
Wire seven previously-deferred HealthKit quantity identifiers through
the full pipeline — enum, mapping, plausibility range, category, list-UI
metadata, FHIR coding, and the generic metric-status assessment:

  CARDIO_RECOVERY          heartRateRecoveryOneMinute   bpm
  WRIST_TEMPERATURE        appleSleepingWristTemperature celsius
  FALL_COUNT               numberOfTimesFallen           count
  SIX_MINUTE_WALK_DISTANCE sixMinuteWalkTestDistance     m
  STAIR_ASCENT_SPEED       stairAscentSpeed              m/s
  STAIR_DESCENT_SPEED      stairDescentSpeed             m/s
  BREATHING_DISTURBANCES   appleSleepingBreathingDisturbances count

Each value rides raw on the wire (identity conversion). Fall count is
cumulative (daily SUM); the stair speeds drain to a daily mean; the rest
store as latest per-workout / per-night spot readings. Wrist temperature
and breathing disturbances are flagged privacy-sensitive. The category
sleep-apnea and walking-steadiness EVENT identifiers stay deferred — they
are events, not quantities.

Each new metric gains an Insights sub-page on the shared scaffold with the
generic assessment card, localised across all six locales. Cardio fitness
(VO2 max) — ingested since v1.4.23 but assessment-less — gains its own
/insights/cardio-fitness page so it picks up the assessment instead of
riding as a bare chart-row on the pulse page.

Normal-range anchors are cited where defensible (six-minute walk 400-700 m,
ATS guideline + population reference); the rest defer to the user's own
baseline rather than fabricate a band.

Migration 0104 appends the enum values additively (IF NOT EXISTS, forward-only).
2026-06-02 22:02:50 +02:00
Marc-André Bombeck 30be0c4e0d feat(workouts): accept an optional HR series on the batch contract
Extend POST /api/workouts/batch with an optional per-workout `samples`
array — a route-independent heart-rate series so indoor workouts still
yield a strain-relevant signal. Each entry is `{ t, hr?, speedMs?,
power?, cadence? }`; the array is capped at 30 000 points
(MAX_WORKOUT_HR_SAMPLES), covering a long endurance session sampled at
1 Hz while bounding the largest new workout-side write stream the same
way the 20 000-point route cap bounds the geometry.

Persistence rides the existing route-attachment path: the freshly
written workout id is resolved once (by composite key, or the manual
tuple), then both the route geometry and the HR series attach by FK
with skipDuplicates so a re-submitted batch stays idempotent. A workout
may carry both a GPS route and a canonical HR series, or just one.

GET /api/workouts/{id} returns the series alongside the route. OpenAPI
regenerated.

`samples` keeps first-write-wins/overwrite parity with the workout row
it hangs off: the 1:1 FK plus skipDuplicates means a re-ingest of an
existing workout never duplicates or mutates its series.
2026-06-02 22:02:46 +02:00
Marc-André Bombeck d247f6966a feat(workouts): add a route-independent per-workout heart-rate series
Indoor workouts had no home for their per-sample heart-rate series: it
rode on WorkoutRoute.sampleTimestamps, which only exists when a workout
carries GPS geometry. A treadmill or trainer session therefore lost the
signal the training-strain reader needs.

Add a WorkoutSamples child (workout_samples), 1:1 with the workout like
WorkoutRoute, holding a JSONB `[{ t, hr?, speedMs?, power?, cadence? }]`
array plus a denormalised sample_count. JSONB keeps the self-hosted
Postgres extension-free, mirroring the route table's decision. Migration
0107.

Also retire the stale cross-source workout-dedup marker on the Workout
model: that work landed as the write-time dedupeWorkoutBatch() picker
plus the read-time pickCanonicalWorkoutRows() picker, both resolving the
user's source-priority ladder. The comment now documents the two layers
instead of tracking them as open.
2026-06-02 22:02:46 +02:00
Marc-André Bombeck 14d79ab453 fix(insights): reconcile the derived vitals + composite waves
Resolve the overlap where the vitals tier and the composite tier both extend
the derived-metric registry, dispatch, query-key factory, i18n bundles and the
OpenAPI contract: every derived metric (vitals baseline, fitness/vascular age,
HRV balance, BMI, sleep score, readiness, coincident-deviation) is now wired and
implemented, the dispatch test asserts the full set, and the locale bundles carry
both the vitals and the score-anatomy/composite strings.
2026-06-02 21:25:58 +02:00
Marc-André Bombeck 440f24bff2 fix(medications): drop the bottom-pinned action gap on the cards
The v1.9.0 equal-height change set the card content to a flex column and
pinned the action buttons to the bottom with mt-auto. On a shorter card next
to a taller one in the grid, that opened an empty gap between the content and
the buttons. The cards already stretch to a shared height via the grid, so the
buttons can sit directly after the content: drop the flex column and the
mt-auto wrapper on both card variants. The reserved dose-row slot stays, so the
dose lines still align across the grid; only the gap is gone.
2026-06-02 21:20:02 +02:00
Marc-André Bombeck 95a2a7692f feat(insights): add composite scores, score-anatomy view, and correlation discovery
Build the composite tier of the derived-metrics layer on the shared seam:

- Sleep score — a transparent 0–100 composite from sufficiency, efficiency,
  consistency, timing and composition sub-scores over the per-stage sleep
  rows, reweighting around any sub-score the data cannot support (a legacy
  asleep-only night drops composition gracefully rather than fabricating it).
- Wellness/readiness index — extends the health-score null-redistribution
  blend with resting-HR, HRV (SDNN), sleep, respiratory-rate and mood
  deviation-from-baseline components; returns insufficient below two present
  components so it never headlines from one signal.
- Coincident-deviation flag — counts vitals outside their personal band on
  the latest day and fires when two or more coincide, listing the
  contributing vitals as possible factors, never a cause or a diagnosis.

Each composite cites its standard, carries coverage + confidence +
provenance, and reweights around missing inputs.

Add an FDR-controlled correlation discovery engine behind
GET /api/insights/correlations: it scans a curated behaviour x outcome
matrix, lag-joins each behaviour day to the next day's outcome, runs Pearson
with the exact Student-t p-value, and applies Benjamini-Hochberg control
across every tested pair so only statistically-defensible patterns surface —
each tagged with n, r, p and the adjusted q, framed descriptive not causal.

Add the score-anatomy detail view at /insights/scores/<metric>: a full
score ring, ranked contributor rows each showing its push/pull and weight
share, and the cited-standard provenance explainer — reused by every
composite, mobile-first and accessible.

Regenerate the OpenAPI contract for the new metric values + the correlation
discovery response, and add the i18n keys across all six locales.
2026-06-02 21:19:56 +02:00
Marc-André Bombeck 663a3ee09d feat(insights): add the vitals dashboard + its derived metric tier
Build the Apple-Health-Highlights grid of personal-vital tiles on the
Insights overview, backed by four new pure derived-metric compute
functions over the established seam:

- cardio-fitness band ("Fitness Age" frame) — VO2max placed against the
  FRIEND-registry age/sex norm (Kaminsky 2015), surfaced + trended, never
  re-derived.
- vascular-age delta — Withings vascular-age vs chronological age, a
  relative wellness indicator (Vlachopoulos 2010 PWV framing), no medical
  claim.
- HRV (SDNN) balance — reuses the typical-range engine for the band and
  places the recent average as balanced / above / below; SDNN never
  relabelled RMSSD; cadence-gated.
- BMI from weight + height — exact WHO classification, the fallback when
  no device BMI exists.

Each metric carries coverage + confidence + provenance and cites its
standard through the provenance explainer. The dashboard renders one
SparklineDeltaTile per available vital: an absent vital does not render,
a still-building one shows the honest coverage meter rather than a
headline from too little history. Tiles read the generic
/api/insights/derived route through a TanStack Query hook keyed off the
centralised factory.

Regenerates the OpenAPI contract for the two new metric ids and adds the
vitals copy across all six locales.
2026-06-02 21:17:05 +02:00
Marc-André Bombeck 789eef35c3 Merge pull request #243 from MBombeck/release/v1.9.3
Release v1.9.3 — medication card layout polish
2026-06-02 21:08:33 +02:00
Marc-André Bombeck e7f406ecc7 test(e2e): pin the clock so the medication next-due chip is deterministic
The recurring medication stub schedules an 08:00-09:00 window and the card
derives its header from the real clock. When CI ran inside that window the card
showed the in-window 'take now' pill and correctly suppressed the 'Nächste
Einnahme:' next-due line, so the chip assertion failed depending on wall-clock
time. Pin the clock to a fixed afternoon before rendering the list, putting the
morning window in the past so the next-due line always renders.
2026-06-02 21:02:42 +02:00
Marc-André Bombeck 0c8506d7d4 test(e2e): give the next-due chip assertion the 10s budget the name has
The medication-list next-due line is computed client-side from the schedule
after hydration, so on a loaded CI runner it can appear a beat after the
medication name. The chip assertion used the default 5s timeout while the name
assertion above it uses 10s; align them so the check isn't racing hydration.
2026-06-02 20:52:28 +02:00
Marc-André Bombeck 7b3094eeef chore(release): v1.9.3 — medication card layout polish 2026-06-02 20:36:40 +02:00
Marc-André Bombeck 26fb461cc7 fix(medications): drop the bottom-pinned action gap on the cards
The v1.9.0 equal-height change set the card content to a flex column and
pinned the action buttons to the bottom with mt-auto. On a shorter card next
to a taller one in the grid, that opened an empty gap between the content and
the buttons. The cards already stretch to a shared height via the grid, so the
buttons can sit directly after the content: drop the flex column and the
mt-auto wrapper on both card variants. The reserved dose-row slot stays, so the
dose lines still align across the grid; only the gap is gone.
2026-06-02 20:36:39 +02:00
Marc-André Bombeck 32bc11f968 feat(insights): add the derived-metrics design-system primitives
Build the four reusable presentational components the v1.10 vitals and
score-anatomy surfaces share: a radial score ring, a coverage meter, a
sparkline delta tile, and a provenance explainer. Each extends the
existing dracula band + trend-sentiment vocabulary rather than a new
colour system, and ships its populated, provisional/empty, and stale
states up front so a sparse-data surface never goes blank.

- score-ring: centred tabular-nums number on a Recharts RadialBarChart,
  band-coloured, size prop (sm tile / md / lg anatomy), count-up + sweep
  on mount via a 0 KB requestAnimationFrame hook; role=img aria-label
  restates the number so the ring is never colour-only.
- coverage-meter: CSS-only filled/hollow dots driven by the Derived<T>
  coverage + confidence facets, with an accessible breakdown tooltip
  (present vs required inputs, history days, missing list).
- sparkline-delta-tile: extends the trend-card grammar with an inline
  Recharts area sparkline, a signed period delta, and an age/normal-range
  framing slot.
- provenance-explainer: collapsible Popover (desktop) / bottom-sheet
  (mobile) rendering inputs, plain-language method, and an optional cited
  standard as React text children — no markdown library.

All four read their data off the Derived<T> union as a type-only import
so the client bundle stays free of the server graph. Recharts only, no
new runtime dependency. New i18n keys propagated across all six locales.
2026-06-02 20:14:49 +02:00
Marc-André Bombeck aa281d6649 feat(insights): sharpen the metric-status normalRange with age/sex norms
Thread the reference-range enabler (derived/norms.ts) into the existing
generic assessment path. When lookupNormalRange resolves an age/sex
adjusted band for the metric + profile, the snapshot uses it in place of
the coarse flat registry anchor and tags it normalRangeSource:
age-sex-adjusted; otherwise the flat anchor is kept unchanged. Strictly
additive — a metric with no sharper norm, or a profile missing
demographics, behaves exactly as before.
2026-06-02 20:01:48 +02:00
Marc-André Bombeck 4ad65a5b7d feat(insights): add GET /api/insights/derived + OpenAPI contract
Generic route mirroring the v1.8.7.1 metric-status precedent:
GET /api/insights/derived?metric=<DERIVED_METRIC_ID>[&type=<vital>].
apiHandler + Zod safeParse on the query, closed registry enum (unknown
id 422s via returnAllZodIssues), cookie-or-Bearer auth, userId narrowed
from the session (never a query field). The profile is read once and
passed into the pure compute function. Returns the flat Derived<T> union
in the standard { data, error, meta } envelope so the native client
decodes one stable shape and combines values across metrics.

Register the query + response schemas (DerivedMetricQuery,
DerivedMetricResponse + DerivedCoverage/Confidence/Provenance) and the
path operation in the OpenAPI registry; regenerate docs/api/openapi.yaml.
Add the route to the insights gate inventory (insightStatus-gated, no
Coach prose).

Route tests cover the 200 flat-envelope shape, session-narrowed userId,
422 on an unknown / missing metric, 401 unauthenticated, and the
insufficient flattening (null value + confidence, populated reason).
2026-06-02 20:01:42 +02:00
Marc-André Bombeck 6dac56b659 feat(insights): add the derived-metrics compute foundation
Introduce src/lib/insights/derived/ — the shared, compute-once layer for
derived wellness metrics. Core pieces:

- types.ts: the Derived<T> discriminated union (ok carries value +
  coverage + confidence + provenance; insufficient carries coverage +
  provenance + reason). Client-safe — pure types, no server imports.
- coverage.ts: deriveCoverage() + the buildOk/buildInsufficient terminal
  constructors. Confidence blends the history fraction and the
  input-presence fraction so it drops monotonically as inputs go missing.
- registry.ts: the closed DERIVED_METRIC_ID enum + per-metric metadata
  (inputs, archetype, min-history / min-inputs floors, implemented flag).
- norms.ts: age/sex reference-range tables (VO2max FRIEND registry,
  resting-HR + respiratory-rate by age) behind lookupNormalRange().
  Age + sex only — no ancestry/region ranges.
- baseline.ts: the flagship engine — computeVitalsBaseline() returns the
  personal typical-range band (median +/- k*MAD) for one vital. Center
  reads the composable DAY rollup mean; the spread is DAY-native only
  (the sd/slope/r2-don't-compose invariant), with a per-type bounded
  live-SQL fallback on a coverage miss.
- dispatch.ts: maps a DERIVED_METRIC_ID to its compute function; stubbed
  metrics return an insufficient with reason not_implemented.
- index.ts: the append-only public barrel.

Unit tests cover the coverage model, the Derived<T> ok/insufficient
branching, the norms lookup, the baseline ok / insufficient /
coverage-miss-fallback paths + the composed-vs-raw-DAY band parity, and
the dispatch stub guard.
2026-06-02 20:01:34 +02:00
Marc-André Bombeck 744e8e37ec Merge pull request #242 from MBombeck/release/v1.9.2
Release v1.9.2 — document the medication compliance endpoint
2026-06-02 19:19:23 +02:00
Marc-André Bombeck e2917cb012 chore(release): v1.9.2 — document the medication compliance endpoint 2026-06-02 19:13:24 +02:00
Marc-André Bombeck 9106489196 docs(api): document the medication compliance endpoint
GET /api/medications/{id}/compliance was absent from the OpenAPI document, so
a client generating types from the contract could conflate it with the separate
cadence endpoint (a different shape). Register the route with its exact response
— compliance7/compliance30 summaries, the per-day dailyCompliance grid, and the
two-row complianceDisplay block — documenting that compliance30.totalExpected is
the 30-day denominator and that a per-day glyph is drawn only where due is true.
Handler unchanged; this closes the contract gap.
2026-06-02 19:13:24 +02:00
Marc-André Bombeck cc927f7921 Merge pull request #241 from MBombeck/release/v1.9.1
Release v1.9.1 — no assessment warm on a page visit
2026-06-02 18:47:11 +02:00
Marc-André Bombeck ad471a5110 docs(planning): refresh roadmap and state to the current release line 2026-06-02 18:39:53 +02:00
Marc-André Bombeck fc7386a364 chore(release): v1.9.1 — no assessment warm on a page visit 2026-06-02 18:39:53 +02:00
Marc-André Bombeck 879833b372 fix(insights): stop warming every assessment on an overview visit
Opening the Insights overview fired a once-per-session background warm that
forced the provider to regenerate the comprehensive briefing and every category
and metric assessment at once — a multi-minute burst that contended with
foreground requests and made the app feel unresponsive until it finished. The
nightly pass keeps the caches warm and the per-metric status GETs revalidate
gently on their own, so the warm-on-mount is redundant. Remove it; the manual
'prepare assessments' button remains the explicit on-demand re-warm.
2026-06-02 18:39:53 +02:00
Marc-André Bombeck ed5d453431 Merge pull request #240 from MBombeck/release/v1.9.0
Release v1.9.0 — Insights time-ranges, deeper mood, medication coding, stability
2026-06-02 18:16:10 +02:00
Marc-André Bombeck eb63cd3df3 chore(release): v1.9.0 — Insights time-ranges, deeper mood, medication coding, stability
Insights gain selectable time ranges with period-over-period deltas, and the
mood page adds time-of-day, stability and weight/BP correlation reads. Medications
can carry ATC + RxNorm codes that flow into the health-record export alongside a
MedicationAdministration per acted dose. The connection pool is sized for real
concurrency to remove the under-load stalls, and the medication advanced-settings
page is rebuilt. The standalone /targets page is retired in favour of inline editing.
2026-06-02 18:08:52 +02:00
Marc-André Bombeck a87d6b94e4 fix(insights): keep the server-only rollup readers out of the insights client bundle
The v1.9.0 time-range work imported ANALYTICS_RANGES as a value into the
time-range pills and the insights layout-prefs hook from range-delta.ts.
That module also pulls the rollup readers and the Postgres client, so the
value import dragged pg (dns/fs/net/tls) into the browser bundle and broke
the production build with eight "Module not found" errors.

Move the client-safe pieces — the range list, its type, the per-range
window length, and the period-over-period result shapes — into a new
range-shared module that imports nothing server-side. The insights client
components import from there; range-delta.ts re-exports the same names so
the server route and the OpenAPI registry keep their import paths. The
window-composition logic and the rollup/db imports stay server-side.

No runtime behaviour change — a pure module-boundary split.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 75124e76c1e987d8a022fb39d891049340199b38)
2026-06-02 17:41:33 +02:00
Marc-André Bombeck 1c91abcf2a docs(medications): correct the GLP-1 card equal-height comment
The reserved-slot comment claimed the compliance bars and action row
align "row-for-row across the grid" with a generic sibling. That
overstates it: the GLP-1-only rotation-hint block sits between the
reserved slot and the compliance bars, so the bars are pushed down by
its height and do not share a Y with a generic card's bars. What the
layout actually guarantees is equal card height and a shared bottom
action baseline (`h-full` + `mt-auto`); only the header, the reserved
slot, and the pinned action row align across a mixed-variant row.
Reword the comment to claim exactly that.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 7320c8b1b9f704d22eaba10be42a560594a5ca11)
2026-06-02 17:34:52 +02:00
Marc-André Bombeck ada9006d9c fix(export): cap the FHIR MedicationAdministration count per export
The administration set was windowed only to the report period (up to 730
days). A chronic medication dosed several times a day across that window,
multiplied across several medications, can still materialise thousands of
MedicationAdministration resources into a single Bundle — bounded by the
window but unbounded by dose frequency.

Cap the set at the most-recent 1000 acted intakes overall. The omitted
rows are the oldest in the window, so recent adherence is preserved
intact. When the cap trims rows the aggregator records the original and
included counts and the FHIR document narrative discloses the
truncation, so the export is honest about the omitted history rather
than silently dropping it. Cover the cap in the report aggregator and
the narrative note in the bundle builder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit c6ce7df2bf6efb5e2c9d948d8f189d95d1289758)
2026-06-02 17:34:52 +02:00
Marc-André Bombeck f0f2caab79 fix(medications): bound the RxNorm code length
The RxCUI field validates against `^\d+$`, which is unbounded — a caller
could submit an arbitrarily long digit string. Add a `.max(20)` ceiling
as defence-in-depth so the column write stays tight; real RxCUIs are at
most a handful of digits. `atcCode` needs no max — its regex already
fixes the length. Regenerate the OpenAPI contract for the new
`maxLength` on both the create and update bodies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 8b97d35bc375ef3b2e70f938c0d6729a5fa6bccb)
2026-06-02 17:34:52 +02:00
Marc-André Bombeck bfdf0a12a0 fix(medications): route injection-site taps to the nearest quadrant centre
The two stacked abdomen quadrants sit 14 viewBox units apart, yet each
per-dot hit-target was an r=14 transparent circle (Ø=28u), so the
targets overlapped by ~14 units. SVG hit-testing resolves to the
topmost-painted element rather than the nearest centre, so a tap in the
overlap band logged the wrong quadrant — defeating the rotation feature.

Replace the overlapping per-dot hit-circles with a single transparent
overlay that projects the pointer into viewBox space and resolves it to
the nearest site centre (new `nearestSiteAt` helper, constrained to the
effective allowed set). Every pixel now maps to its closest site, so
full-area routing holds without sub-target dead zones, and the abdomen
quadrants can no longer mis-route. Keyboard and assistive-tech users
keep a per-site focusable r=6 circle (non-overlapping at this spacing)
with the Space/Enter handler and aria-label. Add tests asserting a tap
in the former overlap band maps to the nearer quadrant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit a07988f139b0067e82ccc5ad7e5890e0517fe869)
2026-06-02 17:34:52 +02:00
Marc-André Bombeck 107a111545 fix(insights): polish range pills, zero-delta caption, scrollbar gutter, mood tiles
- Range pills: make the radiogroup a single tab stop with roving tabindex and
  Arrow / Home / End key handling, matching the WAI-ARIA radiogroup pattern.
  Previously every pill was its own tab stop and the arrow keys were dead.
- Range delta caption: render a neutral dash + "no change" for a perfectly
  stable metric instead of a down-arrow with "−0%". Adds the `noChange`
  string across all six locales.
- Scrollbar gutter: scope the document-level `scrollbar-gutter: stable` to the
  body-scrolled shells (login / register / onboarding / legal) via a
  `data-scroll` attribute the shell sets, so the height-locked authenticated
  branch no longer reserves a second, never-painted gutter on classic
  scrollbar platforms.
- Mood tiles: a lone in-target / stability tile now spans full width instead
  of leaving a half-width orphan beside it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 12b434a0c262e29ced1ac0aaf631db6678c3ad05)
2026-06-02 17:34:52 +02:00
Marc-André Bombeck b13721cc97 fix(insights): stop a timed-out warm from evicting its own freshly-warmed caches
The on-demand force-warm bounds the comprehensive briefing with a 45 s
timeout and then warms the per-status + generic-metric caches itself. The
timeout could not cancel the underlying generation, so a comprehensive that
resolved after the timeout reached its own per-status eviction and deleted
the rows the warm passes had just written — leaving the user back on a cold
cache.

Thread an AbortSignal into the comprehensive generation and abort it when the
timeout fires; the generation now bails out before its write + evict once the
signal is aborted, so a late resolve can never undo the warm.

Also align the force-warm gating with the route: the route admits the job on
the per-user assessment surface, so warm the per-status + generic-metric
cards whenever that surface is enabled, even when the global briefing switch
is off. Only the comprehensive briefing stays gated on the briefing switch.

Factor the per-status cache-action shape into a single helper so the key is
built in one place rather than hand-assembled across the generators, the
eviction sweep, and the ingest debounce.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 75e0b5a9129893915f7a213ec96253df19341d15)
2026-06-02 17:34:52 +02:00
Marc-André Bombeck 94415e468e fix(layout): reserve the scrollbar gutter so the nav no longer shifts
The authenticated shell scrolls inside its own `<main>` (the body is height-
locked with `flex h-dvh`), and that `<main>` centres its content with
`mx-auto`. A tall section paints the vertical scrollbar and narrows the
content box; a short one hides it and widens the box, so the whole column —
including the admin/settings sidebar — recentres a few px sideways on every
view switch. The admin "Insights Quality" ↔ "Integrations" toggle was the
visible case.

Reserve the gutter with `scrollbar-gutter: stable` on the `<main>` scroll
viewport, and on `html` for the body-scrolled login / onboarding / standalone
pages. The content box width is now constant whether or not the bar is
painted, so the left nav no longer shifts when a scrollbar appears. The fix is
global: every signed-in route renders inside the same `<main>`, so the same
shift on settings, insights, and dashboard navigation is fixed at once. The
height-locked authenticated body never scrolls the document, so no double
gutter results.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit c1459e5ee42b7f7acbfa933a9eb7d1f5a1c6890a)
2026-06-02 17:10:36 +02:00
Marc-André BombeckandClaude Opus 4.8 6d1587531f test(settings): expect the shortened notifications section label
The settings section reverts to the single-line "Notifications" /
"Benachrichtigungen"; update the shell title assertions to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:04:40 +02:00
Marc-André Bombeck 170862fdf8 fix(targets): convert glucose targets back to mg/dL on save
The per-metric target editor receives a glucose range already projected
into the user's display unit (mmol/L) along with `unit="mmol/L"`, but it
seeded, validated and saved that value verbatim. HealthLog stores glucose
canonically in mg/dL, so a user on the mmol/L preference either had a
valid target (e.g. 3.9–5.5 mmol/L) rejected by the 40–400 mg/dL bounds,
or — when an override already existed — saw a mg/dL number rendered as if
it were mmol/L and wrote it straight back.

Add `mmolToMgdl` / `toCanonicalMgdl` (the inverse of `convertGlucose`) and
teach the edit sheet to treat the display unit at its boundary: seed the
persisted mg/dL override into the display unit, validate against bounds
expressed in the display unit, and convert the typed value back to
canonical mg/dL before the PUT. A no-op for every non-glucose metric and
for users on the mg/dL preference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 0cc0361058a85f32d7135e5f2fd425c98f62d885)
2026-06-02 17:01:14 +02:00
Marc-André Bombeck 09af60303d feat(medications): redraw the injection-site picker as an anatomical silhouette
Replace the stick-figure outline with a traced front-of-body silhouette,
still an inline SVG so it inherits the theme tokens (currentColor + a
soft currentColor wash) and tints correctly across light/dark and the
zinc/Dracula palettes with no raster asset.

Recalibrate SITE_COORDS onto the new 100x200 viewBox (aspect ≈0.5) using
the iOS client's hands-on fractional coordinates, so both apps land their
dots on the same anatomy. The coordinates double as the Euclidean basis
for the rotation recommender, so picker and recommendation stay in sync.

Dot states: selected (filled), recommended-next (dashed primary ring),
and a new last-used solid ring; hit targets are r=14 on the viewBox
(≈45 CSS px at the rendered scale), clearing the 44px floor. Props,
callbacks, the screen-left=="links" convention and the v1.8.5 site→enum
bridge are unchanged — graphic and coordinates only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 46125c3bbec5573910f41a319ae717d37f39c228)
2026-06-02 17:01:14 +02:00
Marc-André Bombeck 7857aa3dd2 fix(settings): shorten the notifications section label to one line
The notification-channels section heading and the left-nav label both
render `settings.sections.notifications.title`, whose German value
("Benachrichtigungs-Kanäle") is a long compound that wraps to two lines
in the nav rail. Shorten the displayed value to the plain plural across
all six locales (de "Benachrichtigungen"; en "Notifications"; es
"Notificaciones"; fr "Notifications"; it "Notifiche"; pl
"Powiadomienia"). Key id is unchanged; the breadcrumb on the
notification-center page keeps its own wording.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit af437f48b09b9f99b6a80709ddacd565c10132ab)
2026-06-02 17:01:14 +02:00
Marc-André Bombeck d880385635 refactor(targets): retire the standalone Targets page
The standalone /targets (Zielwerte) page is retired. Target editing
moved inline onto the Insights metric pages in v1.8.6 — each metric's
reference panel mounts the editor and writes the same
PUT /api/user/thresholds, reading the same /api/insights/targets
payload. The page carried a deprecation notice and had already dropped
out of the navigation, so removing it is a UI-only change: no data
model, API route, or inline editor moves.

Remove the page and its tests, the components reachable only from it
(target card, per-card coach button, summary header, target prompt /
scope helpers, coach-handoff hook), the now-dead /zielwerte→/targets
proxy redirect, and the orphaned targets.* i18n keys across all six
locales. Repoint the handful of code comments that pointed readers at
the page to the inline Insights panel, and prune the two coach
gate-inventory allowlists plus the queryKey factory guard root that
named the removed files.

The reference panel keeps the range bar, consistency strip, status
pill, edit sheet, and source-link helpers — those are the live editing
surface now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 7f842c5620087127e980fe22a56133135b7c0531)
2026-06-02 17:01:14 +02:00
Marc-André Bombeck 8c316ee3b0 ops: add a post-deploy version assertion script
scripts/assert-deploy.ts polls /api/version on prod + demo and asserts the
served data.version matches an expected value, printing each target's
buildSha and exiting non-zero on a mismatch or after a bounded retry loop.
This catches the BuildKit layer-cache pitfall where the deploy reports
clean but /api/version still answers the prior release. Run via
`pnpm dlx tsx scripts/assert-deploy.ts <version>` — not bundled into the
standalone image. Document it in the deploy runbook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit a86a9151ef670846c4b33a1c068da40324393308)
2026-06-02 17:01:14 +02:00
Marc-André Bombeck 4feac6b7e4 fix(insights): gate the assessment warm on insightStatus, not coach
POST /api/insights/pregenerate warms the assessment cards — the comprehensive
insight plus the specialised status cards — all of which the read-only status
GETs gate on the insightStatus surface. It was gating on coach, so a user with
assessments enabled but the Coach disabled got a 403 and could not warm. Move
the gate to insightStatus to match the surface it actually serves, add it to
the route-gate inventory allowlist, and cover the coach-disabled-still-warms
path with a route test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit ec041db6e2c81b95a429625bb765f1f41ebce40b)
2026-06-02 17:01:14 +02:00
Marc-André Bombeck 18cde65af7 docs(openapi): register the six specialised insight status routes
The generic metric-status route set the OpenAPI pattern for the assessment
cards; retrofit the six specialised routes onto it. Add registry entries
for /api/insights/{blood-pressure,pulse,weight,bmi,mood,medication-compliance}-status
mirroring the metric-status envelope (hasProvider/text/cached/updatedAt plus
the preparing + revalidating flags). Medication-compliance carries its richer
summary + per-medication note array as its own schema. Regenerate the YAML.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 91887a13b30e8978e91f615604163331bdc54950)
2026-06-02 17:01:14 +02:00
Marc-André Bombeck 7fa2866154 feat(fhir): emit Coverage on a bare KVNR + add Composition.text
Align the server's `Coverage` emission with the iOS exporter: a
Coverage is now emitted whenever ANY payor signal is present, including
a bare KVNR with no insurer name or IKNR. The KVNR-only case carries the
`subscriberId` with no contained payor Organization; the insurer-known
case is unchanged. A bundle with no payor signal at all still omits
Coverage entirely.

Also add a top-level `Composition.text` narrative (reusing the same
escaped summary the section narrative carries) so a strict
US-Core-style validator sees the document-level narrative it expects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 22c5ed7fbe02993307c3f04cd0498d199c79cf43)
2026-06-02 16:45:23 +02:00
Marc-André Bombeck ca8781867b feat(fhir): add a MedicationAdministration per acted intake
Emit one `MedicationAdministration` per intake the user actually
actioned — `completed` for a taken dose (`effectiveDateTime = takenAt`),
`not-done` for an explicit skip (`effectiveDateTime = scheduledFor`).
Pending / missed slots and soft-deleted tombstones are never exported,
so the record only asserts administrations that happened.

Each administration reuses the statement's ATC/RxNorm + text concept,
so it is self-describing without a cross-resource reference. A `dosage`
is emitted only when a structured dose Quantity is available (resolved
from the dose-change history in effect at the administration instant) —
a dosage carrying only `.text` would violate the R4 dose-or-rate
invariant. Route + injection site ride along as text-only concepts. The
administrations are referenced from the existing Medications section, so
the document's two-section graph is preserved.

`FhirMedicationAdministration` is new; `FhirDosage` gains `dose`,
`route`, and `site`. The source rows come from the same aggregator the
PDF consumes, keeping FHIR and PDF aligned by construction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit dd22512f56b7b6dfa42386bbc88d1ced1d71255d)
2026-06-02 16:45:23 +02:00
Marc-André Bombeck 2fcd2e75f5 feat(fhir): emit ATC + RxNorm codings on the medication concept
The health-record export's `MedicationStatement.medicationCodeableConcept`
gains additive `coding[]` entries when the medication carries a stored
code: ATC primary (`http://www.whocc.no/atc`), RxNorm secondary
(`http://www.nlm.nih.gov/research/umls/rxnorm`). The free-text name
stays the `.text` anchor and the concept collapses to exactly the
prior text-only shape when no code is present — the export never
machine-guesses a code, only emits what the user stored.

The aggregator now carries `atcCode` / `rxNormCode` on `medications[]`
and assembles the acted-intake source rows
(`medicationAdministrations`) the administration builder consumes, so
the FHIR export keeps describing the same numbers as the PDF by
construction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 740c86b3a830efa7e76d69b29ae376addeee6811)
2026-06-02 16:45:23 +02:00
Marc-André Bombeck 0a8c6e307c feat(medications): add optional ATC + RxNorm codes to the medication row
Add two nullable, additive columns to `Medication` (`atc_code`,
`rxnorm_code`) so a user or clinician can assert a drug-classification
code that the health-record FHIR export emits on the medication concept.
Codes are taken verbatim, never machine-guessed: a wrong code is a
clinical-safety problem, an absent one is merely a (conformant)
text-only concept.

- Migration 0103 — additive, nullable, no backfill. Pre-existing rows
  read NULL, which the exporter already treats as "no code".
- Validate on write — ATC `^[A-Z]\d{2}[A-Z]{2}\d{2}$`, RxCUI `^\d+$`;
  422 on a malformed value. Wired field-by-field through the create +
  update routes (no mass assignment); `null` clears a code on update.
- Surface both codes in the medication advanced-settings sheet's Data
  group with a self-saving input, and echo them on the GET/POST/PUT
  response shape. OpenAPI regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 2e0051d42826fa3f043c32e78a34dab7c8050165)
2026-06-02 16:45:23 +02:00
Marc-André Bombeck eff7b42348 feat(medications): collapse the API request examples and quiet the URL
The external-API row was the tallest thing in the advanced-settings sheet:
a full-width endpoint URL block plus an always-expanded 4-9 line request
snippet, on screen for every user whether or not they touch the API.

Collapse the multi-language request examples behind a chevron disclosure,
closed by default — the language picker and the live-interpolated bearer
token now only render once the user opts in, keeping the token-shaped
payload out of the default-visible DOM. De-emphasise the raw endpoint URL
to a muted, single-line truncated `<code>`: the copy button already carries
the full value, so the long absolute URL was redundant noise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 6310ed49f4523e3210b26c05f2c934748967af88)
2026-06-02 16:45:23 +02:00
Marc-André Bombeck 555138110c feat(medications): give the external API its own settings group
The advanced-settings sheet crammed the external ingest endpoint into the
Data group next to CSV import/export — two unrelated audiences sharing one
frame. Pull `ApiTokensRow` into a dedicated "Externe API" group so the
developer-facing surface reads as a distinct, lowest-frequency block. New
group order: Reminders -> Lifecycle -> Data -> Externe API -> Danger.

The new group reuses the existing `SettingsGroup` bordered-card chrome, so
it stays visually consistent with its siblings. Adds the `externalApi`
group label across all six locales and updates the composition test to pin
the five-block order plus the API row's new home.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit f9f3a1277b5aad1710815511ddf7f6b210a652b2)
2026-06-02 16:45:23 +02:00
Marc-André Bombeck f65181bbf2 feat(insights): surface mood time-of-day, stability, and weight/BP correlations
Render the new mood analyses on the insights surface:

- a four-bar time-of-day chart (morning / afternoon / evening / night),
  shown only when the server marks the pattern reliable
- a stability tile beside the in-target tile, with the descriptive band
- the time-of-day takeaway in the narrative feed (new Clock icon, daypart
  label resolved from the bundle)

Also wire the mood x weight and mood x systolic-blood-pressure scatter
cards. Their Pearson coefficients and points were already computed and
returned by the endpoint but never rendered; they now sit alongside the
sleep / activity / pulse cards, each gated on the same minimum-pairs
threshold so a thin pairing renders an empty state rather than a noisy r.

New i18n keys land EN-first and propagate across de / es / fr / it / pl.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 12902b0cdbc6aa272b6da3c35ba3781f9216cf9f)
2026-06-02 16:45:14 +02:00
Marc-André Bombeck 141c083ff6 feat(insights): add time-of-day and stability mood analyses
Extend the mood-aggregate engine with two pure helpers over the rows it
already fetches:

- computeTimeOfDayAverages buckets every entry into morning / afternoon /
  evening / night in the entry's own timezone (legacy null-tz rows fall
  back to UTC). A spread guard keeps the once-a-day logger — whose entries
  cluster in a single bucket — from producing a misleading daypart claim:
  the pattern is only marked reliable once at least two buckets each clear
  the per-bucket sample floor.
- computeMoodStability maps the population standard deviation of the daily
  means onto a 0-100 score (higher = steadier) with a descriptive,
  non-judgemental band. Sparse loggers below the minimum-days floor get
  null rather than a meaningless number.

Both feed the MoodAggregates object that drives the visible mood page and
the LLM snapshot, so the prose and the charts agree. A new time-of-day
narrative takeaway joins the ranked feed, gated on the same reliability
flag plus the existing effect-magnitude bar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 685b53c0fd994324c270df2b9a5ac964bb442c82)
2026-06-02 16:45:14 +02:00
Marc-André Bombeck 3694efe6e8 fix(insights): keep the assessment card polling while a stale text revalidates
The stale-while-revalidate path serves the last-good assessment on a cache
miss and returns a terminal payload, so the status hook stopped polling. The
worker warmed a fresh assessment out of band seconds later, but the open card
never picked it up — the stale text stuck until the page remounted.

Add a `revalidating` flag to the read-only miss outcome, set only when
last-good text is served AND a refresh was enqueued (false on the
suppressed-stub branch and when no prior text exists). Thread it through the
generic metric-status generator and the seven specialised generators onto the
wire, type it on the client, and make `nextStatusPollInterval` keep polling on
`preparing || revalidating`. The existing 12-attempt ceiling bounds both
signals, so a persistently failing generation still cannot poll forever.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit c9217534fbdb304c4df1b6eb55fb19e4360b8a10)
2026-06-02 16:45:14 +02:00
Marc-André Bombeck 03925ddece feat(insights): time-range pills with period-over-period deltas on metric pages
Add a 7d / 30d / 90d / 1y segmented control to every HealthKit metric page,
between the stat strip and the chart, with a sentiment-coloured "vs prior
period" delta caption beside it.

New route GET /api/analytics/range?type=<MeasurementType>&range=<7d|30d|90d|1y>
returns the current-window aggregate, the previous comparable window, and the
composed delta. Single metric type by construction, so the read is one
rollup-tier call covering the trailing 2N days sliced into the two halves —
no per-type fan-out, no Prisma-pool burst. Reuses the WMY readers
(readBestGranularityRollups + aggregateWmyBuckets) and the two-window pattern
the year-ago baseline already uses; count/min/max/mean/sum compose across
buckets, SD/slope/r2 stay out (not composable). The cached summaries slice is
untouched so the hottest dashboard cache key never fragments.

The delta caption reuses the dashboard's metric-aware up-good/up-bad colour
logic — extracted from trend-card into a shared trend-sentiment module — with
the metric's good direction sourced from the metric-status registry
(target-band metrics render neutral, matching deviation framing). When the
prior window has no data the caption shows "no prior-period data" rather than
a misleading 0%. The range choice persists across metrics via a localStorage
seam and defaults to 30d so nothing regresses. Cache keyed per (type, range)
through the query-key factory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit eac87a9f8b144f4d8e7a8b950528a19d5dcf8726)
2026-06-02 16:45:14 +02:00
Marc-André Bombeck f1145c0e91 fix(insights): debounce ingest-driven status invalidation to coalesce the regeneration storm
A constantly-syncing client drips measurement batches every few minutes.
Every batch deleted the dirtied per-metric assessment cache rows and
re-enqueued a regeneration. The per-(user,scope,locale) enqueue singleton
is only 120 s, so it could not coalesce across batches that arrive minutes
apart, and each delete dropped the row a fresh enqueue would otherwise have
found warm — so a metric card regenerated several times an hour and its
assessment felt regenerated on every visit.

Before evicting and re-enqueuing, check whether the scope's cached
assessment was already (re)generated within a 30 min debounce window. A
scope that is fresh is left intact and not re-enqueued; a genuinely stale
or missing scope still refreshes immediately, so correctness holds. A
timeout stub never counts as fresh, so a scope that recently stalled still
retries. One indexed read per user answers it for every candidate scope at
once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 024baea664490ef0494add78c87492f01e61c6b9)
2026-06-02 16:23:16 +02:00
Marc-André Bombeck 2c6a9f8165 fix(insights): decouple the forced warm sections so a slow comprehensive step cannot starve the rest
The on-demand force-warm ran the comprehensive insight first and only ran
the per-status (7) and generic-metric (~30) assessment passes when that
step returned generated or cached. A slow or stalled comprehensive
generation therefore aborted the entire warm — observed in production as a
102 s run ending with assessments_warmed: 0 — leaving every card cold and
the user back on the lazy 30-60 s on-first-click path.

Give the comprehensive step its own bounded budget and treat a timeout,
error, or skip as non-fatal: the per-status and generic-metric passes now
always run afterwards. Each per-card generator resolves its own provider
chain and bounds its own provider call, so running them after a failed
comprehensive costs a cheap chain-resolve when no provider is configured
and is capped per card otherwise. The result reports each section's
outcome independently, and a timed-out comprehensive is reported distinctly
from a failed one so a partial warm is observable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit f52f8e9c3b32590f3b085d082418ebdd3a923f1d)
2026-06-02 16:23:16 +02:00
Marc-André Bombeck 4a0ad1fc64 fix(medications): restore equal-height cards and aligned dose rows
The v1.8.6 compliance rework left the last/next-dose and compliance rows in
a plain vertical stack with independently conditional rows. A card missing
its last-dose line (never taken) or its streak flame (zero streak) rendered
shorter, so the dose rows and action buttons drifted out of alignment across
the two-column grid.

Pin the layout on both card variants identically: the Card fills the
stretched grid cell with h-full, the CardContent becomes a flex column, the
next/last-dose pair sits in a reserved min-height slot so a missing line
keeps its footprint, and the intake-actions block is pushed to the bottom
with mt-auto for a shared action baseline. Generic and GLP-1 cards now align
row-for-row regardless of which optional rows render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit a7e541eeb5123abb63514f56d12b93b0023ce417)
2026-06-02 16:23:16 +02:00
Marc-André BombeckandClaude Opus 4.8 5f8e191e2b fix(compose): set an explicit, tunable database connection_limit
The app pins DATABASE_URL in the compose environment block, which shadows
any connection_limit an operator sets in .env, so Prisma fell back to its
default pool (cpu_count*2+1 — 9 on a 4-core host). A combined web+worker
process starves that pool under background AI and rollup load, leaving
HTTP requests waiting on the pool until they time out. Set connection_limit
explicitly (default 20, override via DB_CONNECTION_LIMIT) plus a pool_timeout
override, so the pool can be sized to the host and its database.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:06:55 +02:00
Marc-André Bombeck e9906c8111 Merge pull request #239 from MBombeck/release/v1.8.7.1
v1.8.7.1 — assessments for every HealthKit metric, one-tap pre-generation
2026-06-02 14:40:13 +02:00
Marc-André BombeckandClaude Opus 4.8 a9ed2ed661 chore(release): v1.8.7.1 — assessments for every HealthKit metric, one-tap pre-generation
Adds a plain-language assessment to every HealthKit metric page via a
data-driven generator (archetype templates + per-metric metadata, no
per-metric prompt), a one-tap/auto background pre-generation of all
assessments, and the iOS-facing widening of the Insights layout to the
full metric set. Updates the ChatGPT Codex model-slug ladder so
assessments generate again after the backend rotated its identifiers.
Splits the Targets/Sources settings pages, renames the section to
AI Insights, and applies the metric-detail back-nav, dropdown padding,
heading, and Sources-translation polish.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:32:51 +02:00
Marc-André Bombeck 2472579059 feat(insights): warm assessments from the overview page
Wire the on-demand full assessment warm into the Insights overview. A new
useInsightsWarm hook POSTs /api/insights/pregenerate; the cards then fill
through their existing stale-while-revalidate reads with nothing to
invalidate locally.

Two entry points share the one mutation. A background warm fires once per
browser session (sessionStorage-gated) as soon as the overview confirms
the user has data, so a returning user lands on warm caches without
acting; it is silent and tolerates the 429 the server returns while a
warm is already in flight. A manual button ('Prepare assessments') runs
the same warm and shows a toast.

Add the insightsPregenerate mutationKey to the factory and the label +
toast strings across all six locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 1273a6253dc0d00c7c7ce774c5fbe352664de2e9)
2026-06-02 14:29:49 +02:00
Marc-André Bombeck bf5052bb01 feat(insights): add on-demand full assessment warm
Add POST /api/insights/pregenerate so every AI assessment text — the
comprehensive insight, the seven specialised status cards, and every
data-bearing generic metric assessment — can be warmed for the calling
user on demand, in the active locale, instead of waiting for the nightly
cron or a lazy first-visit generation.

The route narrows userId from the session/Bearer, runs the assistant
surface gate, and enqueues a forced full-warm job onto the existing
insight-pregenerate queue, returning immediately. A short per-user bucket
(insights-warm:<userId>, one per three minutes) returns 429 on a tight
loop so the bypassed nightly 20h budget cannot be turned into a provider
cost amplifier.

The worker branches on the payload: a job carrying { userId, force }
runs forceWarmUser for that user, skipping the candidate scan and the
20h budget; a scheduled tick with no payload keeps its existing batch
behaviour. forceWarmUser reuses the existing comprehensive generator and
warm helpers — no new generation logic — and stays safe by construction:
the comprehensive generator no-ops without a provider, the generic warm
pass filters to metrics the user has rows for so an empty metric never
reaches the provider, and every write is idempotent.

The queue name and a generator-free enqueue helper move to
insight-pregenerate-shared.ts, mirroring the insight-status-generate
split, so the route enqueues without pulling the generator tree.

Regenerate the OpenAPI contract for the new route.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit e10ae9bfeca544c7e5aecd9102459d97ddfad6db)
2026-06-02 14:29:49 +02:00
Marc-André Bombeck d81ab69dc8 feat(insights): widen the layout tile-id set to the full metric universe
Derive `INSIGHTS_TILE_IDS` from `SUB_PAGE_SLUGS` rather than maintaining a
separate 14-id list. The layout tile-id universe and the routed tab strip
now share one source of truth, so a client can persist a layout covering
every metric page the web exposes (37 slugs + the overview mother page)
instead of tripping a 422 on the ~25 slugs the fixed allow-list did not
know about.

Rebuild `DEFAULT_INSIGHTS_LAYOUT` from the derived id list with an explicit
default-visible set; the long-tail HealthKit, body-composition, mobility,
and audio tiles stay default-invisible. The accepted set stays closed — an
unknown id still 422s — and the legacy German aliases keep round-tripping,
so no persisted layout breaks.

Regenerate the OpenAPI contract and extend the route deck: a PUT carrying
the previously-rejected slugs now succeeds, a full-universe PUT round-trips
through GET, and a genuinely-unknown id still 422s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 0a161d430533845b0c4c322047701b93aea49a06)
2026-06-02 14:15:31 +02:00
Marc-André Bombeck 2123c63af4 a11y(insights): hide the workouts back-link arrow from assistive tech
The workouts detail back-link's `ArrowLeft` glyph carried no
`aria-hidden`, so a screen reader announced it alongside the link text. Add
`aria-hidden="true"`, matching the sibling values detail page's back-link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 108c3ff583a707b100570e2999b129055f90c76f)
2026-06-02 14:03:00 +02:00
Marc-André Bombeck 4364f3b509 i18n(settings): translate the source-priority editor body for es/fr/it/pl
Splitting Sources back out into a standalone settings page re-exposed the
source-priority editor body — card title, help text, reorder buttons, the
move-up / move-down ARIA labels, per-metric and device-type controls — which
were still English placeholders for es/fr/it/pl. Provide natural
translations so a non-de/en screen-reader user no longer hears English.

Drop the orphaned `settings.sections.sources.redirectNotice` key from all six
bundles; the redirect it captioned was deleted when Sources became a real
route.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 945d3ed8a6981c23dc51f45c22a1cd3f2625c859)
2026-06-02 14:03:00 +02:00
Marc-André Bombeck 746e002104 fix(insights): re-warm generic metric assessments on measurement ingest
The ingest invalidation path mapped a fresh measurement only to the seven
specialised assessment scopes, so a freshly-synced HealthKit metric's generic
assessment card lagged its real-time data until the nightly warm pass —
an asymmetry against the near-real-time specialised cards.

Add a reverse `MeasurementType → metric id` index to the registry and extend
`invalidateStatusInsightsForTypes` so a reading of a type that backs a
registered generic metric also evicts and re-enqueues the matching
`metric:<ID>` scope, using the same single-locale, singleton-debounced
enqueue as the specialised scopes. Unregistered types and the seven
specialised metrics resolve to no generic scope, so the constant sync cannot
fan out to unwanted cards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 9bc0f116b46950c8ffa79d127d5dcce32ea644a4)
2026-06-02 14:03:00 +02:00
Marc-André Bombeck bd8a4e7990 refactor(insights): type-check the generic metric previous-context scope
The generic metric-status generator read its previous-context row through an
`as never` cast, because `getPreviousInsightContext` was typed to the seven
specialised scopes only. Widen the helper's `scope` param to a
`PreviousContextScope` union that also accepts the `metric:<ID>-status` scope
form, so the generic call type-checks honestly instead of silently disabling
type checking on the argument. No behaviour change — the helper only
interpolates the scope into the audit action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 899d59b6fa219a14ba39eacded2e042f0f95c2c5)
2026-06-02 14:03:00 +02:00
Marc-André Bombeck 58b793ee4a fix(insights): correct the active-energy assessment id and share the status card
The active-energy page passed `ACTIVE_ENERGY_BURNED` as `statusMetric`, but
that is the DB MeasurementType, not the registry id the metric-status route
accepts. The route's closed enum only knows `ACTIVE_ENERGY`, so every fetch
422'd and the assessment card silently fell back to its empty body.

Point the page at the `ACTIVE_ENERGY` registry id and type `statusMetric`
(plus the `useInsightMetricStatus` metric param) to the closed
`MetricStatusMetricId` union instead of a bare string, so a future
MeasurementType-vs-registry-id mismatch is a compile error rather than a
silent 422. A typecheck pass over all 29 pages confirms active-energy was the
only diverging one.

Extract a shared `MetricStatusCard` that owns the `useInsightMetricStatus`
hook and the assessment-card wiring, collapsing the duplicated mount block in
the HealthKit metric scaffold and the bespoke sleep page to a single line
each (only the icon differs). Behaviour is unchanged: the card mounts only on
the data-bearing branch, so a source-less account never fires an assessment
round-trip. Add a test asserting the active-energy page wires an accepted
registry id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 6e371aaeb9698f7b4251c22e1d8f381ddfa70a00)
2026-06-02 14:03:00 +02:00
Marc-André BombeckandClaude Opus 4.8 5827ef58cb refactor(insights): drop the unused metric-to-insight key helper
The identity-cast helper had no caller and tripped the dead-code gate.
Remove it and its now-orphaned type import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:32:30 +02:00
Marc-André Bombeck 78b76d2ac5 feat(settings): split targets and sources, rename AI Insights section
Three related settings changes:

- Split the combined Targets & Sources page back into two pages: Targets
  (/settings/thresholds) owns the per-metric target-range editor and
  Sources (/settings/sources) owns the source-priority and device-type
  ladders. Each is its own left-nav entry and route, served by the
  dynamic [section] route. The former /settings/sources permanent
  redirect is removed since the route now renders the page directly.
- Rename the AI Insights settings section and nav label so the AI nature
  is explicit: 'AI Insights' (EN) / 'KI-Auswertungen' (DE), replacing the
  bare 'Insights' / 'Auswertungen'.
- Each page renders its own h1 heading consistent with sibling settings
  pages.

Drop the now-unused embedded mode from SourcesSection. Update the section
titles and descriptions across all six locale bundles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 5d8842934b985eb8ce037993a63bb0736ac0c7bd)
2026-06-02 13:26:10 +02:00
Marc-André Bombeck 708d012d28 fix(ui): make select trigger horizontal padding symmetric
The select trigger used pl-3 pr-2 (12 px left, 8 px right), parking the
chevron visibly closer to the right edge than the value text sits from
the left. Use px-3 so both gutters are 12 px; the gap-2 between value and
chevron and the chevron's shrink-0 keep it from crowding or clipping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit aaea4ea8102d8f74ea039fc6764ee2e3e5474afc)
2026-06-02 13:26:10 +02:00
Marc-André Bombeck ee845946fa fix(insights): place metric detail back-nav above the heading
The metric values subpage (/insights/values/[type]) and the workout
detail page rendered the back link as the first child of SubPageShell,
which paints it below the page heading. Add an optional backLink slot to
SubPageShell that renders above the header — the standard back-nav
placement — and move both pages' back links into it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 72a73a52eb8295c9e70a3456972476b8c4469f07)
2026-06-02 13:26:10 +02:00
Marc-André Bombeck a9aa0b8531 fix(ai): track the ChatGPT Codex backend's current model slugs
The ChatGPT Codex backend rotated its ChatGPT-auth model slugs off the
`-codex` specialist line onto the plain `gpt-5.x` line, so the in-code
default ladder (`gpt-5.3-codex`, `gpt-5-codex`, `gpt-5`, `gpt-4o`) is
now rejected and insight generation fails with a 503 on a fresh deploy
that has no `CODEX_MODEL_FALLBACK_CHAIN` override.

Replace the default ladder with the current accepted set, in order:
`gpt-5.5` (current default across paid tiers), `gpt-5.4` and
`gpt-5.4-mini` (documented fallbacks), `gpt-5.3-codex` (late rung, still
accepted intermittently), `gpt-5.2` (legacy floor). The
fall-through-on-rejection walk, the working-slug cache, and the env
override path are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit bced19de2d4f0788bfd6fa56a78e719347776a1f)
2026-06-02 13:26:10 +02:00
Marc-André Bombeck b1b2e22eeb feat(insights): mount per-metric assessment on HealthKit sub-pages
Add an optional statusMetric prop to HealthKitMetricPage that mounts
InsightStatusCard beneath the chart, pointed at the generic
metric-status route. The card renders only on the data-bearing branch;
the insufficient-data branch keeps the existing empty-state note and
never fires an assessment fetch. Wire statusMetric on the 29
HealthKit metric sub-pages and on the sleep page, which closes the
assessment slot its deferral comment had left open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 083044201af7715740ec0ee2f14c7240c4999fbf)
2026-06-02 13:26:10 +02:00
Marc-André Bombeck 16b0c349f2 feat(insights): add generic per-metric assessment loader
Add a parameterised query key (insightsMetricStatus) and a
useInsightMetricStatus hook that reads the generic per-metric
assessment route GET /api/insights/metric-status?metric=<id>. The
seven bespoke status routes keep their own keys and hook union; this
one covers the HealthKit metric sub-pages that share a single route
keyed by the metric id. The hook mirrors the existing status loader:
8 s client ceiling, no inline retry, and the bounded preparing poll.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit a3407a2f6c1e9f54c92a6f182db894ae0593e69f)
2026-06-02 13:26:10 +02:00
Marc-André Bombeck e33911e2d4 feat(insights): generic per-metric assessment coverage for HealthKit metrics
Extend the assessment-card system beyond the seven specialised scopes to
the ~30 HealthKit metric pages, without hand-writing a prompt per metric.

- Add a metric-metadata registry keyed by HealthKit id (display name,
  unit, direction, optional normal range, archetype, scope), covering the
  data-bearing HealthKit metrics. The seven specialised metrics keep
  their own scopes and are excluded from the generic set; an unregistered
  id gets no card.
- Add a generic data-driven generator that reuses the existing graded
  snapshot, provider runner, stale-while-revalidate cache, and persist
  path. One prompt template per archetype (physiological-vital,
  activity-fitness, body-composition, mobility-gait,
  environmental-exposure) plus a dedicated sleep template, parameterised
  by metric metadata. Empty-data metrics return an insufficient marker
  with no provider call. Normal ranges use age + sex where the profile
  stores them; no ancestry/region ranges.
- Add GET /api/insights/metric-status?metric=<id>: apiHandler-wrapped,
  Zod-validated against the closed registry id set (422 on unknown),
  read-only with stale-while-revalidate. First insights route with
  OpenAPI coverage; regenerate docs/api/openapi.yaml.
- Route the generic metric:<id> scope through the on-demand status queue
  worker dispatch.
- Extend the nightly warm pass to the generic metric caches for a user's
  data-bearing metrics only (one distinct read gates the set), keeping
  de+en warm and never calling a provider for an empty metric.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 41453fbdfee60793c16269b9a9e5f04b6c3ea450)
2026-06-02 13:26:10 +02:00
Marc-André BombeckandClaude Opus 4.8 2717297dae docs(contributing): make main the single trunk, retire develop
The long-lived develop branch carried no work absent from main and had
drifted 1216 ahead / 85 behind. Retire it (archived as the tag
archive/develop-pre-v1.5) and rewrite the branch model to match how
releases actually ship: main is the single trunk, releases build on
short-lived release/vX.Y.Z branches off main, merge back, and tag.
This removes the back-merge debt by construction and keeps the tagged
commit on main the single source of the OpenAPI contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:40:30 +02:00
Marc-André Bombeck 3ea6bde8a3 Merge pull request #238 from MBombeck/release/v1.8.7
v1.8.7 — Instant insight assessments, GLP-1 blood-level chart, tile + caption polish
2026-06-02 10:17:32 +02:00
Marc-André Bombeck d5f75d2e03 chore(release): v1.8.7 — instant insight assessments, GLP-1 blood-level chart, tile + caption polish 2026-06-02 10:11:26 +02:00
Marc-André Bombeck 04615d1bc7 fix(medications): make the drug-level estimate disclaimer clearer
The pharmacokinetic-estimate disclaimer rendered as a low-contrast muted
italic footnote, easy to miss. The curve is a modelled value, never a
measured blood concentration, and that framing must read clearly. Lift it
to a bordered note with stronger text contrast and weight, keeping the
wording unchanged and the tone informative rather than alarming.
2026-06-02 10:10:39 +02:00
Marc-André Bombeck ecf4f06870 fix(dashboard): clip the tile card bound on a pathological value
The headline value span is shrink-0 whitespace-nowrap so it never
truncates a digit — correct. A worst-case very wide value could still
push the card's content past its grid column and break the layout. Add
overflow-hidden to the tile card wrapper so a pathological value clips at
the card bound rather than the grid, without re-introducing truncation of
the number for realistic readings.
2026-06-02 10:10:39 +02:00
Marc-André Bombeck aeb72d1a08 fix(insights): regenerate only the user's locale on a measurement invalidation
The write-path regenerate-on-invalidate enqueued a fresh assessment for
both de and en on every dirtying measurement, while the read-path only
ever serves the user's resolved locale. A frequent Apple-Health sync thus
fanned out two provider calls per scope, half for a language the user
never opens. Resolve the user's locale the way the read-path does
(normalizeLocale of user.locale) and enqueue that single locale. The
nightly warm pass still covers both locales for the rare locale switch.
2026-06-02 10:10:39 +02:00
Marc-André Bombeck ae82b3e484 fix(insights): style the fallback trend caption like the advisor caption
Extract the advisor caption's bordered card + icon + foreground prose
shell into a shared TrendCaptionCard and render the additive-metric
fallback caption through it, so the Trends-row captions read with one
typography instead of the plain muted line standing out below the
chart. The two paths now share a single presentational shell and can't
drift apart.
2026-06-02 10:00:48 +02:00
Marc-André Bombeck be90c4b9ad fix(dashboard): never truncate the tile value, let the unit yield instead
The small KPI tiles in the dashboard strip clipped the numeric value at
narrow widths: the headline value span carried `min-w-0 truncate` while
the unit span was `shrink-0`, so on a dense strip (many tiles → narrow
grid column) the number — not the unit — shrank below its content width
and ellipsis-clipped, rendering "130 mmHg" as a truncated "13…".

Flip the priority. The value span is now `shrink-0 whitespace-nowrap` so
every digit always renders in full on one line, and the unit span takes
`min-w-0 truncate` so it yields when the column is too narrow. The
paired-value font step-down (text-3xl → text-2xl for BP systolic/
diastolic) stays as headroom.

Add guards asserting the value node never carries truncate/min-w-0 and
the full reading (single and paired) renders inside it.
2026-06-02 10:00:48 +02:00
Marc-André Bombeck 9652047e8e fix(medications): order the drug-level chart above the dose-strength curve
Lift the GLP-1 drug-level estimate and the dose-strength (titration)
curve out of the default-closed disclosure on both the medication
detail and history pages so they are visible without a click. Render
the drug-level estimate first — it is the curve the medication concerns
— with the titration curve below it.
2026-06-02 10:00:48 +02:00
Marc-André Bombeck 28af138691 feat(medications): show the GLP-1 drug-level estimate by default
Render the estimated drug-level-in-blood curve for any recognised GLP-1
medication without requiring the Research Mode opt-in. The chart still
self-gates on a recognised brand and on having intake data, and the
pharmacokinetic-estimate disclaimer is now always attached so the curve
reads as a modelled value, not a measured blood concentration.

Drop the now-dead Research-Mode gated-placeholder branch and its copy.
The researchModeEnabled flag is unchanged; it still gates the advanced
settings surface and its acknowledgment dialog.
2026-06-02 10:00:48 +02:00
Marc-André Bombeck 9e8b536ff6 fix(insights): place the mood assessment under the first chart
The mood assessment rendered last on the page, below the target summary,
the calendar, the distribution, the weekday and correlation breakdowns.
Move it directly under the mood chart so the narration sits next to the
trend it describes, matching the other category pages.
2026-06-02 10:00:48 +02:00
Marc-André Bombeck 60f347bf8d feat(insights): serve assessments stale-while-revalidate on a cache miss
A category open whose today-cache had been evicted dropped to the
preparing skeleton even when a perfectly good assessment from an earlier
day was still on record. Read the most recent non-stub assessment for the
scope and serve it instantly while the worker re-warms the cache; only
fall back to the preparing state when no assessment was ever produced.
The user no longer waits on an open if an assessment ever existed.
2026-06-02 10:00:48 +02:00
Marc-André Bombeck f4d5114dcd fix(insights): regenerate cached assessments when a measurement invalidates them
The measurement-sync invalidation deleted the dirtied per-scope
assessment rows and waited for the user's next category open (a cache
miss → a worker round-trip while they wait) or the nightly cron to
re-warm them. Enqueue a debounced regenerate for each dirtied scope
instead, coalesced per (user, scope, locale) through the queue's
singleton key so the constant Apple-Health sync collapses a burst of
samples into one regenerate per scope rather than a job storm.
2026-06-02 10:00:48 +02:00
Marc-André Bombeck a8e7325a08 Merge pull request #237 from MBombeck/release/v1.8.6
v1.8.6 — Compliance two-row return, insights polish, targets deprecation
2026-06-02 00:01:59 +02:00
Marc-André Bombeck cfeb98f0a7 chore(gitleaks): allowlist the trend caption i18n keys 2026-06-01 23:56:19 +02:00
Marc-André Bombeck 9b3cb5ec7e test(e2e): assert the wizard step via the dialog root data attributes 2026-06-01 23:56:19 +02:00
Marc-André Bombeck a4bb4947f9 chore(release): v1.8.6 — compliance two-row return, insights polish, targets deprecation 2026-06-01 23:46:07 +02:00
Marc-André Bombeck b868a72a91 fix(targets): merge the profile-incomplete hint into the deprecation banner
The deprecation notice and the profile-incomplete hint rendered as two
near-identical warning cards stacked on top of each other, so an
incomplete-profile user saw two warning banners. Fold the profile detail
into the single deprecation card as a secondary line beneath the headline
message, shown only when height or age is missing. The page now carries
one warning banner.
2026-06-01 23:43:39 +02:00
Marc-André Bombeck 712c5707ee fix(auth): cap insurerIkNumber length before the digit refine
The insurerIkNumber field went straight to the 9-digit regex refine with
no upper bound, unlike its siblings insurerName and fullName which cap at
120 characters. Add a defensive .max(32) before the transform — well
above the 9 digits the value ever holds — so an oversized string is
rejected at the length gate rather than reaching the regex. The refine
already rejects malformed input; this is hardening for consistency with
the surrounding fields.
2026-06-01 23:43:39 +02:00
Marc-André Bombeck 0ec4a16153 fix(insights): pin category-page heading height and coach icon placement
The relocated diversity-nudge lightbulb is a 44px touch target that only
mounts on pages where readings cluster, so the heading row sat taller on
pages with a nudge than on those without — category-page headings did not
align. Reserve a constant min-height on the heading row so the baseline
holds whether or not the glyph is present.

Split the row into a wrapping left group (title, nudge, badge) and a
shrink-0 Coach icon pinned top-right via justify-between, replacing the
ml-auto member of a single flex-wrap row. A long or wrapped title at
375px can no longer isolate or crowd the Coach icon; it stays cleanly
top-right and the title reflows in the remaining space.
2026-06-01 23:43:39 +02:00
Marc-André Bombeck 3f757f7fc2 fix(medications): name the active wizard step on small screens
On narrow viewports the dot stepper collapsed every per-dot label, so
the user faced an unlabelled row of bullets with no sense of position.
Add a compact caption below the dots that names where they stand ("Step
4 of 8 · How often") on small screens, and let the per-dot labels show
only from the small breakpoint up where there is room. The dots stay
tappable throughout.
2026-06-01 23:43:39 +02:00
Marc-André Bombeck 2c91dc7b75 refactor(medications): share the wizard cadence-line builder
`summariseCadence` and `summariseScheduleDraft` each re-spelled the
cadence phrase, its detail clause, and the interpolation count. Extract
a single `{mode, cadence, subControls}`-shaped helper both call so the
medication-wide and per-schedule summaries can't drift apart, and drop
the now-redundant `summaryKeyForCadence` wrapper. Behaviour is
identical; add coverage pinning the per-schedule summary to the
medication-wide summary's leading cadence segment.
2026-06-01 23:43:39 +02:00
Marc-André Bombeck 4038cfcffe fix(medications): drop the empty streak row from the compliance bars
The streak flame's flex wrapper rendered unconditionally, so a
zero-streak medication left a residual gap below the two compliance bars
from the wrapper's row plus the stack's vertical spacing. Mount the
wrapper only when there is a streak to show.
2026-06-01 23:43:39 +02:00
Marc-André Bombeck a357332a32 refactor(medications): import the shared ComplianceDisplay type on the cards
The generic and GLP-1 medication cards each re-spelled the
`ComplianceDisplay` interface already exported from the compliance lib.
Drop both hand-copies and import the canonical type so a future field
change lands in one place.
2026-06-01 23:43:39 +02:00
Marc-André Bombeck 068cb168d9 fix(medications): clamp compliance expected slots to creation date
`expectedSlotsBetween` walked the full requested window even for a
medication younger than that window. The legacy weekday expander floors
on `startsOn` but not on `createdAt`, so a two-day-old daily med
reported seven expected doses over a seven-day window and thirty over a
thirty-day window — counting days before it existed. Clamp the window
lower bound to `createdAt` so the expected-dose counts, and the window
selection that reads them, reflect the medication's real age. The
displayed rates already clamp independently via `calculateCompliance`,
so only the slot counts change.
2026-06-01 23:43:39 +02:00
Marc-André Bombeck 38867439f3 feat(insights): fold structured mood tags into the tag takeaways
The structured taxonomy tags carry a separate, richer signal than the
free-text tag list: a curated catalog pick with a label key and category,
attached to a mood entry through the tag-link join. The tag-mood takeaway
builder only ever read the flat tags, so the structured tags never reached
the ranked lift/drop pool despite both call sites already passing them.

Merge both axes into one ranked pool. A structured candidate emits its
catalog label key as tagKey, which the feed resolves to the active locale
the same way the weekday key already resolves; flat tags keep their
verbatim label. The lean status snapshot now loads the structured-tag
links too, so the takeaways the model sees match the ones on screen.
2026-06-01 23:43:39 +02:00
Marc-André Bombeck d1e6befa5c fix(insights): render the mood in-target share once
The mood page stacked the in-target tile directly above the narrative
feed, and the feed carried a same-number in-target takeaway built from
the identical share — so the percentage showed twice, one row apart.
Suppress the in-target narrative in the feed whenever the tile renders
(the tile is the canonical surface). The takeaway still rides the API
and LLM snapshot payload unchanged.
2026-06-01 23:43:39 +02:00
Marc-André Bombeck 90586d6279 feat(export): emit a FHIR Coverage resource for the insurer
The health-record FHIR bundle now carries a Coverage resource right
after the Patient. Coverage.status is active, beneficiary references
the Patient, and subscriberId carries the KVNR when present. The payor
is a contained Organization referenced by a local #-ref: its name is
the insurer name and its identifier carries the IKNR under the
http://fhir.de/sid/arge-ik/iknr system.

When the IKNR is absent the Organization keeps its name only; when
neither insurer name nor IKNR is known the Coverage is omitted rather
than emitting an empty payor. The KVNR continues to ride on
Patient.identifier unchanged — Coverage is purely additive.
2026-06-01 23:24:10 +02:00
Marc-André Bombeck b99b07ad38 feat(profile): capture the insurer IK number
Add an optional German insurer institution number (IKNR, 9 digits) to
the user profile, alongside the existing insurer name and KVNR. The
IKNR is a public registry identifier for the institution, not a
per-person quasi-identifier, so it is stored plaintext, matching
insurer_name rather than the encrypted KVNR column.

The profile GET echoes the field; PATCH/PUT accept it via Zod —
optional, empty string maps to null, and a non-empty value must be
exactly 9 digits (422 otherwise, no checksum). The write is built
field-by-field. /api/user/profile gains an OpenAPI registration with
the profile read/write schemas.

Migration 0102 adds the nullable column, NULL-defaulted, forward-only.
2026-06-01 23:24:10 +02:00
Marc-André Bombeck 9ecd798119 chore(targets): keep the EOL note in code, not a planning file 2026-06-01 23:10:22 +02:00
Marc-André Bombeck cd1eaa1a4e chore(targets): deprecate the targets page and drop it from the menu
Target editing now lives inline on the insights pages, so the standalone
targets page is no longer a navigation destination. Remove its sidebar
and bottom-nav (overflow) entries and add a top deprecation banner that
points the user at the insights pages and notes the page will be removed
in a future release.

The page stays routable: the /zielwerte to /targets redirect remains and
the route still renders. Removal later is UI-only because the insights
panel consumes the same targets read and threshold-override write. New
deprecation strings land across all six locales.
2026-06-01 23:07:32 +02:00
Marc-André Bombeck c72715bb62 feat(insights): edit target ranges inline instead of routing to the targets page
The per-metric reference panel on each insights category page now opens
the self-contained target editor in place rather than linking out to the
targets page. The panel mounts the same <TargetEditSheet> the targets
card uses, seeded with the metric's type, unit, and range (plus the
diastolic band for blood pressure); the sheet writes the threshold
override and invalidates the shared targets cache on save, so the panel
repaints without a navigation.

Switch the sheet's targets-cache invalidation to the query-key factory
call so it stays aligned with the centralised key.
2026-06-01 23:07:32 +02:00
Marc-André Bombeck 68517240e1 fix(insights): always caption a briefing-driven trend card
The Trends row charts the metrics the daily briefing flags, but only
the legacy BP / weight / mood triple carried an advisor annotation.
Every additive metric (pulse, sleep, steps, HRV, resting heart rate,
active energy, flights, distance, VO2 max, body temperature) rendered
the chart with empty space underneath because the caption only painted
when a slot carried an annotation key.

Give every chart config a standard one-line description (captionKey)
and render it for slots that carry no advisor annotation, so no card
is ever caption-less. The legacy triple keeps its annotation slot. Add
the caption strings across all six locales and regression tests that
pin a caption on every tile.
2026-06-01 22:59:14 +02:00
Marc-André Bombeck 3f9fd9095f feat(medications): show the GLP-1 dose-strength curve instead of the empty therapy course
The GLP-1 detail disclosure carried a titration block that only ever
rendered the standard EMA *reference* ladder — a static schedule with no
values from the user's own therapy, blank whenever the medication's brand
fell outside the catalog. Replace it with a dose-strength curve over the
user's recorded dose-change history.

The MedicationDoseChange stream is the history of record for titration
(2.5 -> 5 -> 7.5 mg escalation steps). The new curve plots that series as
a step line, carrying the latest dose forward to now so the current
strength anchors the right edge. With a single dose change there is no
escalation to draw, so the surface states the current dose plainly with a
"no titration history yet" caption rather than a flat single-point chart;
with none it shows the empty state. The curve plots only logged facts, so
unlike the estimated-PK drug-level chart it needs no Research-Mode gate.

Drop the now-unused reference-ladder section, its read route, the ladder
helpers, the titration query key, and the orphaned i18n block.
2026-06-01 22:59:14 +02:00
Marc-André Bombeck c1f30466e7 feat(medications): replace the wizard progress bar with a jumpable dot stepper 2026-06-01 22:59:14 +02:00
Marc-André Bombeck 38b12607c2 feat(medications): add a multi-step forward-jump lookahead to the wizard payload 2026-06-01 22:59:14 +02:00
Marc-André Bombeck 308d9c9d6d i18n(medications): add short step labels and jump-navigation copy for the wizard stepper 2026-06-01 22:59:14 +02:00
Marc-André Bombeck 5d2ae7732e feat(api): accept an optional source on the measurements batch endpoint
Add an optional per-entry `source` to POST /api/measurements/batch,
restricted to the {APPLE_HEALTH, MANUAL} subset of MeasurementSource and
defaulting to APPLE_HEALTH when absent so existing web and iOS callers
are unchanged. The standalone iOS client can now tag adopt-on-pair
backfill rows that the user entered by hand as MANUAL instead of having
them mis-attributed to HealthKit.

source is threaded into the (userId, type, source, externalId) dedup
key, the existence probe, and the stats:* overwrite scope, so a MANUAL
row and an APPLE_HEALTH row sharing an externalId are distinct rows and
the stats:* re-post overwrite only touches the matching source.
WITHINGS and IMPORT stay server-owned and are rejected here with 422.

Regenerate the OpenAPI spec and add integration coverage for the
absent-source default, explicit MANUAL, cross-source distinctness, and
the rejected-source 422 path.
2026-06-01 22:54:20 +02:00
Marc-André Bombeck ca4425b2e0 fix(medications): make the danger zone a symmetric matched pair
Replace the space-y-4 + interleaved-separator danger card, whose two
rows the outer group double-framed into unequal spacing, with one
tinted card: divide-destructive/20 dividers, equal py-3 per row, and
two identical destructive buttons for a true matched pair.
2026-06-01 22:54:20 +02:00
Marc-André Bombeck 795898bb0a fix(medications): rebuild the advanced-settings sheet as a single column
Drop the lg:grid-cols-2 data|reminders grid that read as overloaded.
The sheet now narrows from 4xl to 2xl and stacks four bordered section
cards in most-used-first order: Reminders, Lifecycle, Data, Danger. One
spacing rule throughout — space-y-4 between cards, divide-y plus py-3
rows inside each card, with the SettingsGroup card owning the dividers
so no row interleaves its own separator.
2026-06-01 22:54:20 +02:00
Marc-André Bombeck a3b34bc0cf chore(mood): drop the notes timeline from the insights page
The notes timeline display comes off the mood insights surface; note
capture on the entry form stays. Add a structural guard that the
timeline component is gone, the insights sections no longer render it,
and the narrative feed and in-target tile take its place.
2026-06-01 22:51:10 +02:00
Marc-André Bombeck ac51cfdc18 feat(mood): add a ranked narrative takeaway feed
Surface plain-language takeaways above the mood charts: the weekday dip
and peak, the overall trend direction, the weekend-vs-weekday effect,
the strongest tag-to-mood lift and drop, the in-target share, and the
current logging streak. Each takeaway is threshold-gated — every minimum
sample count and minimum effect size is a named constant, and a takeaway
only renders once its bar is cleared, so the feed never falls back to
filler. The list is ranked by effect size and capped.

The takeaways are pure helpers computed from the existing mood
aggregates, so the same feed reaches both the insights endpoint and the
mood snapshot the model reasons over — what the page shows matches what
the model is sent. Also promote the previously snapshot-only in-target
percentage to a visible headline tile.
2026-06-01 22:51:10 +02:00
Marc-André Bombeck 3ecfa3a898 fix(mood): truncate long notes in the entries table
A long free-text note forced the mood entries table into horizontal
scroll. Cap the note column width, clamp the note to two lines, and
surface the full text on hover through a tooltip. The complete note
stays editable in the entry sheet.
2026-06-01 22:51:10 +02:00
Marc-André Bombeck 6e61713fc6 fix(insights): move coach-ask to the header and de-emphasise the assessment border
The Coach launch sat at the foot of every category page; move it into the
sub-page header as an icon-only button at heading height, top-right, so
"ask the coach" reads as a header action and the page no longer ends on a
stray button. The shared shell mounts it (gated on the Coach flag plus the
per-user opt-out), so every category page picks it up and the foot
instance is dropped. Also drop the purple left accent on the assessment
("Einschätzung") card — the coloured rule made it read as restless against
the calmer surrounding cards.
2026-06-01 22:50:59 +02:00
Marc-André Bombeck 4935a9b000 feat(insights): surface the diversity hint as a heading lightbulb
The measurement-diversity nudge used to render as an inline note block on
each category page; read in context it looked like a data finding rather
than a tip and confused. Move it onto the heading as a small Lightbulb
glyph that only mounts when the spread is genuinely lopsided; the hint
text it used to show inline now lives in a hover / focus Tooltip with an
accessible name. The sub-page shell carries the glyph in the heading row,
drops the removed explainer trigger, and normalises the foot-of-page
"show all readings" control to the standard secondary-button height.
2026-06-01 22:50:59 +02:00
Marc-André Bombeck 0e9fa7bf80 fix(insights): drop the question-mark explainer circle from the heading
The round `?` popover next to each category heading read as restless
beside the title and duplicated copy already shown inline. Remove the
`MetricExplainer` trigger entirely; the static one- to two-sentence
definition stays as the muted caption directly beneath the heading, so
the definition is still always visible without a tap. The bundle keeps
the per-metric Title leaf for stable key pairs even though only the Body
caption now renders.
2026-06-01 22:50:59 +02:00
Marc-André Bombeck e5b873833a fix(medications): restore the two-row compliance display, scaled to cadence
v1.8.5 swapped the long-standing 7-/30-day compliance rows for a per-dose
adherence timeline on sparse-cadence medications. The timeline breaks the
card layout and reads as restless, so every medication is back to two
percentage rows.

The two windows are no longer hardcoded — they scale with the dosing
cadence. selectComplianceWindows walks a 7/30 -> 30/90 -> 90/365 ladder and
returns the densest rung whose both windows clear MIN_STABLE_DOSES realised
expected doses, so a daily med stays on 7/30, a weekly med steps up to
30/90, and a rolling 35-day injection lands on the widest 90/365 rung. When
even the top rung can't clear the floor (a brand-new or very rare med) the
widest rung is returned so the card still shows two honest rows. The row
labels follow the chosen windows via a parametrised complianceWindow string.

The compliance percentage math is unchanged: each row is the existing
cadence-aware calculateCompliance over the selected window. compliance7 /
compliance30 stay on the wire verbatim for iOS and the Health Score.

- compliance.ts: replace the timeline display with selectComplianceWindows +
  the window ladder; buildComplianceDisplay now returns the two scaled rows
- remove the dose-adherence-timeline component and its now-dead strings
- medication-compliance-bars: parametrise the row labels on the day-counts
- switch the standard and GLP-1 cards back to the always-two-row bars
2026-06-01 22:44:13 +02:00
Marc-André Bombeck 6c44d4a2c1 Merge pull request #236 from MBombeck/release/v1.8.5
v1.8.5 — Insights reference panels, mood depth, injection tracking
2026-06-01 21:42:03 +02:00
Marc-André Bombeck 0fe2ac6d51 chore(api): regenerate the OpenAPI spec for 1.8.5 2026-06-01 21:36:40 +02:00
Marc-André Bombeck bdacd78b0a chore(release): v1.8.5 — Insights reference panels, mood depth, injection tracking 2026-06-01 21:21:02 +02:00
Marc-André Bombeck 6a2762ec35 fix(a11y): raise the target status-pill text contrast to AA
The status pill painted full-saturation Dracula green/orange/red text on
a 12-14% same-hue tint. That fails WCAG AA (4.5:1) for the out tone on
the muted insights reference panel in dark mode (3.87:1) and for every
tone on a white card in light mode (1.2-2.7:1), since the pill used the
raw Dracula palette rather than the theme-aware feedback tokens.

Switch the pill to dedicated theme-aware tokens (status-pill-in/near/out)
and a 10% tint: dark mode keeps Dracula green/orange and lifts the out
tone off #ff5555 to a lighter red; light mode adopts the darker
AA-corrected family already used for the chart and destructive colours.
All three tones now clear 4.5:1 on both bg-card and bg-muted/30 in both
themes (worst case 4.62:1).
2026-06-01 21:17:09 +02:00
Marc-André Bombeck 0b10932c53 docs(insights): pin the median window contract across the summary paths
DataSummary.median carries two windows under one field name: the SQL
summaries-slice path windows PERCENTILE_CONT to the trailing 90 days,
while the JS summarize() takes the median over the full series it is
handed (a caller-defined window). No user-visible bug today — the stat
strip only reads the 90-day SQL path — but a future consumer reading
summary.median off a summarize()-derived payload would silently get a
different number. Document the window on the type and at both call sites
so the divergence is explicit; the percentile algorithm matches, only
the window differs.
2026-06-01 21:17:09 +02:00
Marc-André Bombeck 146867b09f fix(insights): show one adjust-target link for the glucose panel group
The blood-glucose reference panel fans out to up to four per-context
panels (fasting / postprandial / random / bedtime), each previously
emitting its own identical "Adjust target range" link to /targets — so
the page stacked up to four copies of the same link. Lift the link to
the group wrapper and render it once; keep the per-panel link for the
non-glucose single-panel path.
2026-06-01 21:17:09 +02:00
Marc-André Bombeck b10569dc44 fix(insights): give the mood distribution chart a zero-bucket track
A mood level with no days painted a zero-height bar with no affordance,
reading as missing rather than "0". Add a faint full-height track behind
each bar so empty buckets stay legible; the coloured bar paints over the
track for the populated levels.
2026-06-01 21:17:02 +02:00
Marc-André Bombeck bd0b284201 fix(a11y): enlarge mood tag chips to the 44px tap target
The structured-tag picker chips and the side-effect chips sat at 32px
(min-h-8), under the 44px minimum and a fat-finger risk on a phone where
they wrap tightly. Bump both to min-h-11 with matched padding so the tap
target clears 44px without loosening the visual density.
2026-06-01 21:17:02 +02:00
Marc-André Bombeck baa1ff250c refactor(insights): table-drive the mood correlations
The five mood-metric correlations and the measurement query's type
filter were two hand-kept copies of the same channel list. Introduce a
CORRELATION_METRICS map and single-source both the correlations object
and the fetch filter from it so they cannot drift.
2026-06-01 21:17:02 +02:00
Marc-André Bombeck 78d3478ad2 fix(mood): return tag keys from create and update
The list GET surfaces tagKeys per entry but the create and update
responses omitted it, so a client hydrating from the response body
showed an empty tag set until the next refetch. Read the persisted
link keys back inside the write transaction and include them in both
response bodies.
2026-06-01 21:17:02 +02:00
Marc-André Bombeck d5580917cc fix(mood): write tag links inside the entry transaction
The mood entry was committed before createTagLinks/replaceTagLinks ran,
so a tag-link failure 5xx'd after the entry already existed and a client
retry minted a duplicate entry. Wrap the entry write and the tag-link
write in one $transaction and thread the tx client through both helpers
so they commit or roll back together.
2026-06-01 21:17:02 +02:00
Marc-André Bombeck fcf5114620 chore(mood): drop the superseded tag-taxonomy seeder
The structured tag catalog is seeded directly by migration
0101_v185_mood_tag_taxonomy and served from Postgres via the
/api/mood/tags route, so the parallel TypeScript copy had no
importer and tripped the knip gate.
2026-06-01 21:17:02 +02:00
Marc-André Bombeck a5c894b58e i18n(medications): translate the injection-site strings for es/fr/it/pl
The injection-site / log-dialog / settings cluster shipped translated in
de and en but verbatim English in es, fr, it and pl, so those users saw
English copy in the medication wizard, the log-injection-site dialog and
the settings injection card. Localise the eleven keys and drop the two
orphaned injection-rotation keys in the same bundles.
2026-06-01 21:16:56 +02:00
Marc-André Bombeck 6bc51249ba chore(i18n): drop two orphaned injection-rotation keys
medications.injectionRotationOverviewTitle and
medications.injectionRotationNextSuggested have no t() reference anywhere
in the tree — leftovers from an injection-rotation overview that did not
land. Remove them so the bundle carries only live keys.
2026-06-01 21:16:56 +02:00
Marc-André Bombeck f29ac6d96e fix(a11y): give the injection-site picker a visible focus state and accessible names
The SVG site picker painted its focus ring as the stroke of a transparent
hit-target circle — effectively invisible on the muted body map, a WCAG
2.4.7 risk — and conveyed the recommended next site only through the
dashed ring (no screen-reader cue). Track keyboard focus and paint a
contrasting halo on the filled dot, and fold the recommendation into the
recommended site's aria-label so the rotation guidance reaches assistive
tech. Adds injectionSiteRecommendedAriaLabel across all six locales.
2026-06-01 21:16:56 +02:00
Marc-André Bombeck ad693cd862 refactor(medications): extract the compliance slot-context builders
expectedSlotCountForDay and expectedSlotsBetween each rebuilt the same
canonical-schedule and recurrence-context literals inline, differing only
by id prefix and count-vs-collect. Extract toRecurrenceCtx and
toCanonicalSchedule so both share one adapter; behaviour identical.
2026-06-01 21:16:56 +02:00
Marc-André Bombeck 7e7702d6c9 refactor(medications): single-source the injection-site allow check
resolveInjectionSiteForWrite reimplemented the effectiveAllowedSites(...)
.includes(...) membership test inline, duplicating the isSiteAllowed
helper that already wraps it. Call isSiteAllowed so the write-path guard
and the helper share one definition.
2026-06-01 21:16:56 +02:00
Marc-André Bombeck fef88787e9 fix(medications): surface a multi-day-overdue first rolling dose
A rolling cadence with no intake logged anchors its first dose at
startsOn ?? createdAt. When that anchor falls on a prior calendar day
the dose is overdue by a day or more, but nextOccurrenceAfter floored
the search window to the start of the user's current day and dropped it
— so the dashboard showed no next-due and the reminder worker, which
shares this engine, emitted nothing. Clamp the floor to the first-dose
day in the no-intake branch so a multi-day-overdue start dose surfaces
as overdue. The post-intake re-anchor (lastIntakeAt + N) is unaffected.
2026-06-01 21:16:56 +02:00
Marc-André Bombeck b5e98533b8 fix(export): move HealthKit placeholder codes off the LOINC namespace
Six HealthKit metrics without a published LOINC term were coded under
http://loinc.org, which fails FHIR R4 conformance (a non-LOINC code must
not sit in the LOINC namespace). Route any HKQuantityTypeIdentifier code
onto a shared custom CodeSystem (https://healthlog.dev/fhir/CodeSystem/healthkit),
keeping the raw HealthKit identifier as the code so the export stays
byte-aligned with the native client's exporter.
2026-06-01 20:46:49 +02:00
Marc-André Bombeck 0f7ca61e2f feat(insights): port the target reference panel onto the category pages
Grow the per-category insights target summary from a one-line range card
into a compact reference panel that surfaces the band range, the verbal
status, the guideline source, the in-target share, the 30-day average,
the range bar and the 7-day consistency strip — all from the Targets
data already on the wire, reading the same shared cache. Extract the
status pill and the source-link resolver into shared primitives so the
Targets card and the insights panel can never disagree, and wire blood
glucose into the summary with its per-context split and unit conversion.
2026-06-01 20:42:40 +02:00
Marc-André Bombeck 5de9ddb0cc feat(insights): drive the Trends charts from the daily briefing
The overview Trends row used to render a fixed BP / weight / mood
triple regardless of what the day's data surfaced. It now mirrors the
daily briefing: the row charts exactly the metrics the briefing flags,
in the briefing's priority order, deduped and capped at three. When no
briefing is available — cold mount, a web-only account with no
findings, or a pre-briefing cached payload — selection falls back to
the legacy BP / weight / mood triple so the row never paints empty.

Selection lives in `trend-chart-select.ts` as a pure, unit-tested
mapping from each briefing `sourceMetric` to a chart descriptor
(series, colour, unit, title key). Adherence-context findings
(compliance, GLP-1 plateau) have no standalone trend series and are
skipped so the next finding fills the slot. Annotations stay on the
legacy triple; additive HealthKit metrics render chart-only. The chart
set is derived from the briefing payload already on the page, so there
is no new blocking call.
2026-06-01 20:26:31 +02:00
Marc-André Bombeck 6ce09cc76d feat(insights): stat strip, range band, all-readings subpage, diversity nudge
Add the data-value features to the insights category pages on top of the
density pass:

- Stat strip: lead each metric page with min / max / median / mean,
  read from the already-fetched analytics summaries slice (no new
  network). Median is now computed in both summary producers — exact in
  the live path, a windowed PERCENTILE_CONT in the rollup-tier slim
  slice. The thin HealthKit pages mount it too for parity; no AI
  assessment is added there.
- Range band: shade an Apple-Health-style min-max area around the daily
  mean line where the data supports it (non-cumulative metrics). The
  daily-aggregate read now emits per-day min / max from the rollup
  buckets; the band rides the existing target-range toggle.
- All readings: a dedicated per-metric subpage at /insights/values/<type>
  reusing the measurement list pinned to one type (pagination + inline
  edit/delete). A clear entry button mounts at the foot of each
  category page.
- Diversity nudge: a gentle hint when a metric's readings cluster on one
  weekday or time of day, computed from a bounded, cached timestamp
  window — non-blocking, self-gating.

All new reads stay cache-friendly and off the navigation path.
2026-06-01 20:26:26 +02:00
Marc-André Bombeck cf649f72ce feat(mood): structured tag taxonomy + note capture and timeline
Add the Category -> Tag taxonomy the mood surface was missing and the
free-text note the web form could never write, then surface both on the
Insights mood page.

Schema (migration 0101): a global MoodTagCategory -> MoodTag catalog
plus a MoodEntryTagLink join, seeded with a default five-category set
(i18n-keyed labels, Lucide icons). Purely additive — the legacy flat
MoodEntry.tags JSON column, the GLP-1 quick-tags, and the Telegram /
MoodLog sync are untouched; an entry can carry both axes.

Capture: the mood-logging form and the edit sheet gain a Textarea for
the note (model + API already accepted it) and an icon-chip picker over
the taxonomy catalog (GET /api/mood/tags). Create / update resolve the
picked keys to links; unknown keys drop silently against the catalog.

Insights: a structured-tag breakdown (icon + per-tag avg-mood lift) and
a notes timeline join the existing distribution / weekday / flat-tag
sections. The aggregate extension also fixes the flat-tag axis, which
read the raw JSON string and so never populated.

i18n across all six locales. Tests cover the taxonomy aggregation, the
note round-trip, the structured-tag schema, and the notes timeline.
2026-06-01 20:25:42 +02:00
Marc-André Bombeck b99a015266 feat(medications): make injection-site tracking and rotation real
The injection_site column on intake events was read across several
surfaces but never written, so site tracking was effectively dead. Wire
it end-to-end as a per-medication opt-in, gated on an INJECTION delivery
form and default off.

- Schema: add medications.track_injection_sites + allowed_injection_sites
  and users.global_excluded_injection_sites (migrations 0099, 0100;
  additive, defaulted, no backfill).
- Intake write path: accept an optional injectionSite on the per-med,
  status-toggle, and bulk intake routes. Persist it only on a taken
  injection with tracking enabled, validated against the medication's
  effective allowed set (per-med allowed sites minus the user-level
  global exclusion, deny wins) — a disallowed site returns 422 (the bulk
  route skips the entry).
- Rotation: extend the least-recently-used recommender with an allowed
  filter; the picker disables sites outside the effective set.
- UI: per-medication tracking toggle + allowed-sites editor in the dose
  wizard step, a skippable post-dose site prompt on both medication
  cards, and a Settings card for the user-level global exclusion.
- Contract: injectionSite is additive on the intake request; OpenAPI
  regenerated. New GET/PATCH /api/auth/me/injection-site-prefs.

The site capture is always optional and never blocks logging a dose.
2026-06-01 20:25:34 +02:00
Marc-André Bombeck 84e592b17e feat(insights): enrich the mood page with calendar, distribution, weekday, and correlation views
Lift the mood aggregates the AI status generator already computes —
tag frequency + per-tag average, in-target share, and the
mood × metric Pearson correlations — into a shared
`mood-aggregates.ts` so the prose and the new charts read off one
implementation. `mood-status.ts` now consumes the shared helpers
instead of its own inline copies.

Add a cached `/api/mood/insights` endpoint that returns the
pre-computed shapes (summary, heatmap, distribution, weekday, tags,
correlations) behind a 60-second server cache and a bounded 365-day
read — a cheap read-only path, never a synchronous model call. The
bucket invalidates alongside the mood-analytics cache on a mood write.

Render the shapes on the mood Insights page:

- a calendar heatmap forked from the compliance heatmap, coloured by
  the same mood-score bands as the line chart, with an adaptive
  window (30 days until ~90 days of history, then 90, then a full
  year past 180 days) so the grid is never mostly empty;
- a mood distribution bar chart (share of days per level);
- average mood by weekday with a best/toughest-day callout;
- a tag / trigger breakdown (frequency bars + per-tag average);
- mood vs. sleep / activity / pulse correlation cards reusing the
  existing scatter chart.

Add the supporting i18n keys across all six locales.
2026-06-01 19:54:26 +02:00
Marc-André Bombeck 1fcc5ba96b feat(medications): adapt the compliance display to cadence density
A rolling 7-/30-day percentage is meaningful for daily and weekday
medications but noise for sparse cadences: a med dosed every few weeks
has at most one expected dose in a 7-day window, so the bar swings
between 0 and 100 off a single sample.

Decide the display server-side from expected-dose density. When the
30-day window holds at least four expected doses the percentage bars
stay; below that the card swaps to an uptime-style per-dose timeline
where each cell is one expected dose — taken, missed, skipped or
upcoming — with a "last N doses" summary and a dose-indexed streak.

The compliance route returns an additive complianceDisplay block
(mode, expected7/30, the dose timeline, the summary) alongside the
existing compliance7/30 fields, which the iOS client and the Health
Score read unchanged. Read-path only; no schema change.

- compliance.ts: add expectedSlotsBetween and buildComplianceDisplay
  over the canonical recurrence engine
- dose-adherence-timeline: plain flex cells reusing the heatmap colour
  tokens and tap-to-pin tooltip
- switch the standard and GLP-1 cards on the server-decided mode
2026-06-01 19:54:26 +02:00
Marc-André Bombeck 93790aa043 feat(measurements): consolidate gait/mobility HealthKit metrics nightly
Extend the daily-mean consolidation to cover the high-frequency
gait/mobility metrics that previously mapped to no consolidation set and
piled up at sampling granularity: walking asymmetry, walking
double-support, walking steadiness, and walking heart-rate average. The
correct daily reduction for these is the mean, matching the Health-app
display; all four stay disjoint from the cumulative (sum) set.

Fold the daily-mean drain onto the existing nightly cumulative-drain tick
so new high-frequency spot samples no longer accumulate raw between worker
reboots. The two passes share one concurrency-1 cron, keeping the
maintenance window a single sequential walk. Boot-time discovery stays as
the back-fill for accounts that accumulated raw rows before this shipped.

Recompute the affected day's rollup bucket after the consolidation
commits, the same way the ingest path does, so a rollup-covered read
reflects the single consolidated row rather than a stale mean derived from
the now soft-deleted per-sample rows.

Regular heart-rate / pulse stays raw: it is a discrete clinical reading
and feeds correlation and scatter analytics. No schema change.
2026-06-01 19:54:26 +02:00
Marc-André Bombeck 5771b36899 feat(insights): tighten category-page density and balance the overview hero
Polish the Insights surfaces for a denser, more deliberate rhythm.

Category pages:
- Collapse the double caption under the heading. The explainer
  definition and the description paragraph used to stack, so every
  metric page opened with two near-duplicative muted lines before any
  data. Keep the explainer body as the single caption; render the
  description only when no explainer is set.
- Tighten the header gap (space-y-1.5) and the block rhythm
  (space-y-4 md:space-y-5) so the heading-to-first-data distance
  shrinks by roughly one text block.
- Demote the target summary from a full bordered card to an inline
  band (bg-muted/30 rounded-lg px-3 py-2). One line of range text no
  longer reserves the card primitive's py-4/py-6 shell padding.
- Close the gap under the assessment heading. The card primitive's
  gap-4/gap-6 flex gap floated the title well above its prose; override
  to gap-2 md:gap-3 with py-4/py-5 across every status variant.

Overview hero:
- Condense the "ask the coach" band — denser prompt chips and a
  bottom-pinned strip so the left column stops out-running the
  Health Score card.
- Give the Health Score breakdown more vertical presence (looser row
  rhythm, taller component bars) so the right column fills the row
  height instead of leaving the bottom empty.
2026-06-01 19:54:26 +02:00
Marc-André Bombeck d660279c97 fix(medications): stop the wizard showing each time-of-day suggestion twice
The schedule wizard's time-of-day step rendered the four suggested times
twice: once as the icon chips from the step itself, and again as the
labelled preset row built into the chip component below. Add an opt-out
on the chip component and pass it from the step so only the icon-based
suggestions remain. The period is still conveyed by the icon plus an
aria-label; the offered times and selection logic are unchanged. The
preset row stays on by default for every other caller.
2026-06-01 19:37:58 +02:00
Marc-André Bombeck ddb08b048a fix(medications): stop the delete confirm hanging in a spinner
Deleting a medication from the danger zone awaited a prefix-invalidate of
the "medications" key bundle before navigating. That bundle covers the
detail page's own active query, so the invalidation refetched the just-
deleted resource, hit a 404, retried with backoff, and never resolved —
the confirm button spun forever even though the delete had persisted.

Evict the detail query before invalidating, navigate to the list first,
then fire-and-forget the dependent-bundle invalidation. The busy state is
also cleared on the failed-response path. The detail query opts out of
retries so a deleted-resource refetch never burns a backoff cycle.
2026-06-01 19:36:33 +02:00
Marc-André Bombeck b3f61d0c75 fix(medications): surface the first rolling dose at the start date
Rolling schedules anchored next-due on start + N days when no intake had
been logged yet, which skipped the dose due AT the start date and
suppressed its reminder (the worker shares this engine). The +N cadence
now begins only after the first intake is logged: with no intake the
first dose is due at startsOn (or createdAt). A past start date with no
intake surfaces the dose as due/overdue rather than rolling it forward —
nextOccurrenceAfter floors the rolling search to the start of the user's
day so an overdue or due-earlier-today dose stays visible.

The post-intake re-anchor (lastIntakeAt + N) and the endsOn cap are
unchanged; PRN, one-shot, RRULE and legacy cadences are unaffected.
2026-06-01 19:35:11 +02:00
dependabot[bot] eea6385bc5 chore(deps): Bump actions/setup-node from 4 to 6 (#222)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-01 18:00:21 +02:00
dependabot[bot] b1cc1f86f5 chore(deps): Bump gitleaks/gitleaks-action from 2 to 3 (#221)
Bumps [gitleaks/gitleaks-action](https://github.com/gitleaks/gitleaks-action) from 2 to 3.
- [Release notes](https://github.com/gitleaks/gitleaks-action/releases)
- [Commits](https://github.com/gitleaks/gitleaks-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: gitleaks/gitleaks-action
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-01 18:00:16 +02:00
Marc-André Bombeck 16fdc75513 chore(release): v1.8.4 — medication card accuracy + Insights polish
The medication card reads the schedule engine's next-due (fixes
interval/one-time schedules that never advanced after a dose), logging a
dose confirms itself, Insights status polling is bounded, each Insights
category explains itself inline, medication history shows each dose's
origin, and an idempotent migration adds the app_settings columns that
were only ever applied ad-hoc.
2026-06-01 17:37:11 +02:00
Marc-André Bombeck 65bbedd83d fix(seed): repair demo seed against the current schema
Two gaps stopped a fresh demo seed:

- medication_intake_events gained a NOT NULL updated_at column in the
  v1.7.0 sync keyset work. The raw INSERTs omitted it; set it alongside
  created_at on every intake row so the seed does not lean on the
  column's migration-only default.

- app_settings carried four columns on the schema (default_locale plus
  the telegram/ntfy/web-push global toggles) that never landed in the
  migration history — applied via db push to existing environments but
  never committed. A database built from migrations alone is missing
  them, and the admin settings read path selects default_locale, so a
  fresh deploy reports "column default_locale does not exist" on first
  read. Add an idempotent ADD COLUMN IF NOT EXISTS drift-fix migration
  mirroring 0025_user_locale_drift_fix: a no-op where the columns exist,
  a clean add on a migrations-only build.

Verified end to end against a fresh Postgres 16 built from the full
migration set: the seed completes and re-runs cleanly.
2026-06-01 17:31:45 +02:00
Marc-André Bombeck 65cfa2b83a fix(insights): bound the status preparing-poll to a fixed ceiling
The per-metric status cards and the inline medication-compliance query
poll the read-only status route every 4 s while the payload reports
preparing:true. A configured provider whose generation persistently
fails leaves preparing pinned true, so an open Insights page would poll
indefinitely — steady battery and network draw on a screen the user has
walked away from.

Cap the poll at 12 attempts (~48 s) through a shared
nextStatusPollInterval helper both call sites route through. Past the
ceiling the card settles on its existing static / empty state. The happy
path is unchanged: a generation that lands within a couple of intervals
clears preparing and stops the timer exactly as before, and a terminal
no-provider payload never carried preparing so it stays terminal.
2026-06-01 17:31:45 +02:00
Marc-André Bombeck 8dced00188 feat(medications): label each intake history row with its logging origin
The intake history surfaced the coarse IntakeSource enum directly, so a
dose logged from the iOS app showed nothing meaningful. Map the enum to
the practical client it represents — WEB to Web, API to iOS App,
REMINDER to Reminder, IMPORT to Import — and render it as a muted
"via {origin}" caption on each intake row next to the status. No schema
or API change; the source value already rides the existing intake
response. Tighten the en/de labels that read "External API" and
"Telegram / Reminder" into the same origin vocabulary across all six
locales.
2026-06-01 17:31:02 +02:00
Marc-André Bombeck c2808a9c70 feat(insights): show the metric explainer inline under each category heading
The per-category explainer text only lived behind the "?" popover next to
the heading. Render the same localised definition inline as a muted caption
directly beneath the title, where the short scaffold line already sits, so
the one- to two-sentence definition is discoverable without a tap.

Both surfaces read the identical insights.subPage.explainer.<metric>Body
key, so there is no duplicated copy and all six locales stay covered. The
"?" glyph stays in place as the always-available pointer to the same text.
HealthkitMetricPage already forwards explainerMetric to the shell, so every
routed metric sub-page picks up the inline caption from this one change.
2026-06-01 17:31:02 +02:00
Marc-André BombeckandClaude Opus 4.8 0e3a8f9cd3 fix(medications): read next-due from the server and confirm intake actions
Two fixes on the medication cards.

Next-due: the card recomputed "next intake" client-side from
`daysOfWeek` + `windowStart` alone, ignoring rolling intervals, RRULE,
one-shot doses, and the last-intake anchor. A rolling medication (e.g. a
35-day interval) showed "tomorrow" and never re-anchored after a dose.
`GET /api/medications` already returns `nextDueAt` per medication,
computed by the canonical recurrence engine anchored on the last intake.
Both card variants now render that value directly: the generic card
drops its `getNextOccurrenceTimestamp` walker, and the GLP-1 card drops
its weekly-only predictor that hardcoded a 7-day step. The day-label and
window-range rendering are unchanged; only the timestamp source moves to
the server. `nextDueAt` is threaded through the list page's medication
type into both cards.

Intake confirmation: tapping Taken / Skipped now raises a toast naming
the medication ("… recorded as taken" / "… skipped"), so the action has
clear feedback. The next-intake line refreshes on the invalidated list
query, which the next-due fix above makes accurate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:31:02 +02:00
Marc-André Bombeck 96f0b225fd chore(release): v1.8.3 — Insights no longer block the interface
Per-category Insights assessments are served read-only from cache and
generated out of band, the client request is capped, the overnight warm
pass keys on the shown language, a stalled model is briefly remembered,
and Insights/dashboard stream a loading skeleton — so opening and
switching Insights categories stays responsive instead of freezing.
2026-06-01 09:11:36 +02:00
Marc-André Bombeck 35800becbb fix(insights): make per-category status cards read-only so navigation never blocks on the LLM
Opening an Insights category page froze the whole UI: the per-metric
status card fired a cold, uncapped LLM fetch on mount with no client
abort, so the request hung up to the server's 60s ceiling and the
pending/resolve re-renders thrashed the tab strip and ate touch events.
Timeouts were never negatively cached, so every visit re-blocked.

Fix the class holistically:

- Bound every status GET on the client. The shared use-insight-status
  hook and the inline medication-compliance query now wrap fetch in an
  8s AbortController and stop retrying inline; navigation can no longer
  await an uncapped round-trip.

- Make the status routes read-only. On a cache miss the generator
  enqueues an out-of-band pg-boss job (insight-status-generate) and
  returns a preparing envelope; the client polls a bounded interval
  until the worker warms the cache. Models the dashboard snapshot's
  ready/preparing pattern. The queue is registered in the worker
  allQueues loop + a boss.work handler, with a guard test.

- Fix the nightly warm-pass regression: warm every supported locale
  (the client reads against the active UI locale, not the persisted
  User.locale) and warm on a cached comprehensive outcome too (its
  write still evicts the per-status caches). Add a short-TTL negative
  cache on the timeout path so a transient provider stall no longer
  makes every subsequent visit re-block or re-enqueue.

- Add loading.tsx for the insights segment + the root so any future
  RSC await streams a skeleton instead of blocking the transition.

- Stabilise the advisor regenerate callback so a status-query flip
  can't re-render the memoised tab strip mid-gesture.

New i18n key insights.assessmentPreparing across all six locales.
2026-06-01 08:59:59 +02:00
Marc-André Bombeck 6a603ad587 chore(release): v1.8.2 — one intake row per dose slot
Guarantees a single intake entry per scheduled dose: intake writes snap
to the canonical slot instant and update in place rather than inserting a
sibling row, a re-sync can no longer clear a recorded dose, concurrent
logs converge, and a one-time startup pass collapses existing duplicates.
2026-06-01 08:33:20 +02:00
Marc-André Bombeck 710902a43c fix(medications): harden the slot upsert against races, downgrades, and double-decrements
Review of the slot-convergence change surfaced gaps that could mis-record
a dose. Fold the write logic into one shared `applyCanonicalSlotWrite`
used by both intake routes and close them:

- Concurrency: catch the unique-key race on insert and converge by
  re-finding the slot row and updating it, instead of returning 500 (and
  dropping the tap) or leaving a duplicate.
- No downgrade: a pending projection echo replayed onto a slot already
  marked taken or skipped is a no-op — it never clears `takenAt`. A
  recorded dose is immutable, so an offline re-sync cannot flip a taken
  dose back to missed.
- Inventory: decrement a medication's remaining count only on a genuine
  pending→taken transition, not on an idempotent re-post.
- Deterministic slot-row selection: prefer an existing actioned row, then
  the pending row, ordered by createdAt then id.
- Snap instant: derive the canonical instant via the same `localHmAsUtc`
  the projector and reminder worker mint with, so the value is identical
  by construction across a DST transition rather than only test-pinned.
- Cleanup job: isolate each slot's collapse in its own try/catch and
  guard the instant-normalisation against a tombstone collision, so one
  slot can no longer dead-letter a user's whole dedup pass.
2026-06-01 08:32:42 +02:00
Marc-André Bombeck 5047eb7a20 fix(medications): collapse pre-existing duplicate dose-slot rows at boot
The write-path convergence stops new duplicate slot rows, but accounts
that already accrued a pending + taken pair for one dose keep an inflated
expected count, a phantom taken marker, and a suppressed "due now" prompt
until the bad rows are cleared.

Add a boot-time, idempotent cleanup queue (mirroring the existing
backfill queues): discover only the users holding a same-slot duplicate,
then per medication snap every live intake to its canonical slot, keep
the most-actioned row per slot (taken over skipped over pending),
normalise its instant, soft-delete the losers, and recompute the
affected compliance rollups. Rows that resolve to no scheduled slot
(PRN / as-needed) are never collapsed. A second pass is a no-op.

Registered in the worker's queue list with a static guard test, after a
prior incident where a queue was built but never registered and silently
never ran.
2026-06-01 08:12:48 +02:00
Marc-André Bombeck 0d7d08f616 fix(medications): converge intake writes onto one row per scheduled slot
A twice-daily medication ended up with two rows for one dose slot: the
projector / reminder worker mints a pending row (source REMINDER) at the
slot's `localHmAsUtc` instant, while a "taken" write from the per-med or
bulk intake route inserted a fresh row (source WEB / API). Because
`source` is part of the `(userId, medicationId, scheduledFor, source)`
unique key — and the written instant could drift by a minute from the
projected one — the two rows coexisted: one taken, one still pending and
overdue. The card then read the slot as taken, counted 100%, and
suppressed the "due now" prompt for a dose the user had not taken.

Resolve every scheduled intake write to the slot's canonical instant
before writing and upsert in place, source-agnostic:

- `resolve-slot-instant.ts` snaps an incoming instant to the nearest
  scheduled slot for that day (enumerated through the canonical
  recurrence engine, so PRN and cyclic off-weeks resolve to no slot) and
  anchors it on the same `localHmAsUtc` the projector and worker use.
  Off-slot and as-needed writes resolve to null and keep insert semantics.
- The per-med and bulk routes look up the existing row at the canonical
  `(userId, medicationId, scheduledFor)` ignoring source and update it in
  place — mirroring the status-toggle route — instead of inserting a
  sibling. The bulk per-entry response gains an additive `updated` status.
- The reminder worker's missed-dose mint now skips a slot that already
  carries an actioned row (taken or skipped) from any source, not only an
  existing pending REMINDER row.

PRN / as-needed writes are unaffected. Regression tests pin the collapse
(exact and one-minute drift), the PRN exemption, and the worker guard.
2026-06-01 08:06:37 +02:00
Marc-André Bombeck 4578078cc1 chore(release): v1.8.1 — multi-time medication compliance
Closes the two surfaces the 1.7.3 multi-time compliance fix did not
reach: the today-tile projection now mints one pending dose per
scheduled time, and the compliance heatmap classifies each dose against
its nearest scheduled time instead of the first window.
2026-06-01 07:27:50 +02:00
Marc-André Bombeck 7f7bf75e34 fix(medications): classify each time-of-day slot in the compliance heatmap
The History compliance heatmap matched each taken intake event to the
closest schedule by `windowStart` alone, ignoring `timesOfDay`. For a
twice-daily medication stored as one schedule row with
`timesOfDay = ["07:00","19:00"]` and `windowStart = "07:00"`, the evening
dose matched the lone 07:00 window and classified as very-late (~12h),
painting the day's cell orange even when both doses were taken on time —
so the heatmap never recovered to green for single-row multi-time
schedules.

Expand every schedule into its effective dose slots (its `timesOfDay`,
else `[windowStart]`) and classify each event against the nearest slot's
window — the slot time anchoring a window that spans the schedule's
`windowEnd − windowStart` duration, overnight spans included. Single-time
schedules collapse to one slot at `windowStart` and stay byte-identical.
The denominator already routed through the canonical engine; only the
numerator's timing classification is corrected.
2026-06-01 07:26:47 +02:00
Marc-André Bombeck fcb517df87 fix(medications): fan out the today-tile projector over every time-of-day
The today-window projector minted a single pending intake row per
schedule, anchored at `windowStart`, dropping a schedule's first-class
`timesOfDay`. A twice-daily medication stored as one schedule row with
`timesOfDay = ["07:00","19:00"]` therefore surfaced only its morning
dose in the today tile and the "Erfassen" sheet, and the event-count
compliance rollup recorded one expected dose per day instead of two — a
perfectly adherent user read 50% the moment the second dose was logged,
and deleting then re-adding one dose left the rate stuck at 50%.

Fan the projection out over every `timesOfDay` entry, mirroring the
reminder worker's per-slot mint and dedup against the
`(userId, medicationId, scheduledFor, source)` unique index. Absent
first-class `timesOfDay` the projector still emits one slot at
`windowStart`, byte-stable against the worker's legacy single-window row.

Adds regression coverage across the projector (dashboard summary), the
legacy compliance walker, and the cadence expansion pinning the
single-row twice-daily case at the correct two-dose-per-day shape.
2026-06-01 07:26:26 +02:00
Marc-André Bombeck 869f13612a Merge v1.8.0 Insights release into main
# Conflicts:
#	CHANGELOG.md
#	docs/api/openapi.yaml
#	package.json
2026-05-31 23:00:08 +02:00
Marc-André Bombeck 3743f91bce chore(release): v1.7.3 — health-record pdf fixes + multi-time compliance 2026-05-31 22:49:16 +02:00
Marc-André Bombeck 013ad5d967 fix(compliance): expand multi-time legacy schedules through the canonical engine
A medication whose schedule row carried multiple timesOfDay plus a legacy
daysOfWeek (and no rrule / rollingIntervalDays) reported 50% compliance for a
fully-adherent 2x/day dose. The expected-slot denominator always ran the
canonical recurrence engine and counted both daily slots, but the numerator
only delegated to the engine for rrule / rolling / one-shot / non-SCHEDULED
rows; a plain-legacy row fell to the local cadence walker, which emitted a
single windowStart slot per day and ignored timesOfDay. 1 taken / 2 expected
read as 50 percent.

Route expandScheduleSlots through the engine for every schedule shape whenever
a medication context is supplied, so the numerator and denominator read the
same source of truth. Harden the context-less legacy walker to iterate
timesOfDay as well, mirroring the engine's legacy branch, so the single
remaining non-engine path stays correct for multi-time rows. Drop the now-dead
expander gate helper.

Add regression guards: a 2x/day legacy row reports 2/2 through
calculateCompliance, and expectedSlotCountForDay agrees with
buildCadenceTimeline on the slot count for an identical schedule.
2026-05-31 22:47:44 +02:00
Marc-André Bombeck aed83b7747 fix(report): keep modules together, label the chart time axis, fix glyph stretch
Four layout defects in the clinical PDF renderer:

- Stretched lines: the built-in Helvetica is WinAnsi-encoded, so glyphs
  outside it (the trend arrows the summary injects, the superscript-two in
  kg/m2) resolved to the .notdef box at a fallback advance width and stretched
  the surrounding words. Route every drawn string (direct text, wrapped
  paragraphs, and autoTable cells) through a central WinAnsi sanitiser that
  maps the offenders onto safe equivalents. Embedding a Unicode TTF so the
  arrows render as true glyphs is a documented follow-up.

- The sparkline dropped each reading's timestamp and plotted on an index
  axis, so a trend gave no sense of its time span. Plot points by their real
  timestamp and print the first/last dates under the baseline.

- No consistent bottom safe area: scattered y > 240 magic numbers and tables
  with no bottom margin let content run into the footer. Introduce a single
  contentMaxY derived from the page height, footer band, and bottom margin;
  every break check and every autoTable bottom margin refers to it.

- Modules tore mid-block. Add an ensureSpace guard called before each module
  heading and a per-line guard in every free-text loop; give the short
  titration and side-effect tables rowPageBreak: avoid.
2026-05-31 22:47:44 +02:00
Marc-André Bombeck c6e782cdb8 fix(report): pass the in-app locale through to the pdf export
The health-record export panel sent no locale, so the server resolved the
report language from the browser's Accept-Language header. A German user on
an English-default browser got an English doctor report.

Send the active in-app locale from the panel, and on the server resolve
most-specific-first: explicit selection, then the healthlog-locale cookie
(what the user actually chose in the UI), then Accept-Language as the
weakest fallback.
2026-05-31 22:47:44 +02:00
Marc-André Bombeck 6842b52346 chore(release): v1.8.0 — insights data-driven assessments, embedded targets, explainers 2026-05-31 22:46:18 +02:00
Marc-André Bombeck 67a72b1023 perf(insights): parallelise the nightly per-user assessment warm pass
The warm pass forced the seven per-metric status generators strictly
back to back per user, so a fully configured account paid ~7× one LLM
round-trip serially. Bound the inner pass with a small `p-limit(3)` so
the cards warm a few at a time without saturating the Prisma pool or
fanning out an unbounded number of provider calls. The per-user budget
gate still bounds total cost per night.
2026-05-31 22:43:24 +02:00
Marc-André Bombeck c775861d8c fix(insights): invalidate per-metric assessment caches on measurement edit and delete
The targeted status-insight invalidator only fired on insert and batch
ingest, so editing or deleting a reading left the affected per-metric
assessment cards serving their pre-mutation text for the rest of the day.
Add the fire-and-forget invalidation to the single-measurement PUT
(covering both the old and the new type when an edit re-types a row),
the single-measurement DELETE, and the iOS by-external-ids
reconciliation DELETE (keyed off the distinct types it tombstoned).
2026-05-31 22:43:24 +02:00
Marc-André Bombeck eee6ab2e7e refactor(insights): wire the WMY rollup tier into status snapshots and share generator helpers
Read the MONTH / YEAR rollup tier for the monthly / yearly slices of the
focused single-metric status snapshots (weight, pulse, bmi,
blood-pressure) via `buildGradedSeriesWithRollups`, so the coarse tiers
finally earn their write amplification. BMI has no tier of its own, so it
scales the WEIGHT tier by 1 / heightCm² (`scaleGradedSeries`) — an exact
linear transform. The multi-metric general overview and the tier-less
mood / adherence series keep their bounded in-memory fold.

Fix the coverage-miss path that returned an always-empty monthly / yearly
slice: the bounded ~90-day recent read can never supply those slices, so
on a cold-tier miss the builder now folds the full history in memory
rather than shipping empty coarse arrays when years of raw history exist.
Excludes tombstoned rows from both reads.

Lift the byte-identical `round`, `normalizeSummaryText`,
`normalizeLocale`, `summarizeSeries`, the `{summary}`-envelope parse, and
the cache-row persist block out of the seven generators into
`status-shared.ts`. Drop the per-medication expectation in the compliance
generator: the prompt returns only `{summary}`, so the per-med cards are
server-side placeholders rather than a dead model-authored branch. Remove
the unused `summarizeGradedSeries` export.

Behaviour is unchanged on the in-memory path; the rollup path is exact
where the tier composes (count / min / max / mean) and the payload stays
under the snapshot char cap.
2026-05-31 22:43:24 +02:00
Marc-André Bombeck 75380d5f39 fix(insights): lift explainer popover body to 12px + correct target-summary doc
Two small polish items on the insights category surface.

The desktop explainer popover body rendered at 11px while the mobile
sheet used the larger `text-sm`, an uncomfortable and asymmetric read.
Lift the popover body to `text-xs` (12px) so both surfaces sit on the
type scale.

The `MetricTargetSummary` docstring claimed both the range and the
in-target share are hidden when the route flags `insufficientData`. The
range stays visible — only the share is suppressed. Correct the comment
to match the code.
2026-05-31 22:35:02 +02:00
Marc-André Bombeck 36d5c79526 test(insights): pin explainerMetric props against the en.json explainer keys
`<MetricExplainer>` resolves its copy through a template literal
(`insights.subPage.explainer.${metric}{Title,Body}`), so the static
call-site coverage guard never sees these keys — a typo in an
`explainerMetric` prop would paint the raw key path with no CI signal.

Walk every `explainerMetric="…"` value under `src/app/insights/**` plus
the shared healthkit-metric-page scaffold and assert each resolves a
Title and Body leaf in messages/en.json. Also pin the literal-only prop
contract and a non-empty match count so a future rename can't silently
disable the guard. The locale-integrity test then carries the EN
guarantee to the other five bundles.
2026-05-31 22:35:02 +02:00
Marc-André Bombeck 05097df1ee feat(insights): give every category page a metric explainer + normalise keys to english
Two parts to the explainer surface, landed together because they share
the same i18n block and page props.

Coverage: every routed insights category page now passes an
`explainerMetric` prop, so the heading-side `?` glyph is present on all
37 pages instead of 13. The 24 newly covered metrics — body
composition (body water, bone/fat/muscle/lean mass, fat-free mass,
visceral fat), mobility (flights, walking distance/speed/steadiness,
asymmetry, double-support, step length, walking heart rate),
cardiovascular (pulse-wave velocity, vascular age), hearing
(environmental + headphone levels, loud-sound events), respiratory
rate, daylight, blood glucose, and skin temperature — each gain a one
to two sentence layperson definition grounded in standard clinical
references, shipped in all six locales.

Keys: the explainer key segments were a German/English mix
(`blutdruck`, `puls`, `gewicht`, `schlaf`, `stimmung`, `medikamente`
alongside English ones). Rename them to English to match the
naming-convention ADR — `bloodPressure`, `pulse`, `weight`, `sleep`,
`mood`, `medications` — across every locale, and update the
`explainerMetric` prop values that reference them. These are i18n key
segments, not route slugs, so the slug contract is untouched.
2026-05-31 22:35:02 +02:00
Marc-André Bombeck 8f120cfdef fix(insights): generalise the data-gating threshold across metric kinds
The hard '~5 measurement points' floor did not fit mood (counted in
entries) or medication adherence (counted in scheduled doses). Re-word
the gate as 'a few measurement points/entries/events' and note that the
unit depends on the metric. Also fix the gating-field path: the recency
field lives at dataCoverage.newestMeasurementDaysAgo, not on summary.
2026-05-31 22:32:20 +02:00
Marc-André Bombeck f683e8fd6d feat(insights): add a calm support pointer for sustained low mood
The mood card noted persistent low phases but offered no gentle path to
support. When mood stays clearly low for several days, the model now
offers an autonomy-preserving pointer toward talking it through with a
trusted person or a professional — never alarming, never a diagnosis, no
emergency tone. The safety-contract matrix does not cover this pathway
and does not reach the per-metric assessment prompts, so it lives here.
2026-05-31 22:32:20 +02:00
Marc-André Bombeck 0e9e9bc4ba fix(insights): weight 30-day adherence over the noisy 7-day window
For an infrequent schedule such as a weekly injection, the 7-day
compliance figure is dominated by a single dose: one miss swings the
percentage sharply and reads as a collapse it is not. Tell the model to
lean on compliance30 as the load-bearing value when schedulesPerDay is
low and treat compliance7 as a rough pointer only. Output stays {summary}.
2026-05-31 22:32:20 +02:00
Marc-André Bombeck 4c873ebb37 fix(insights): give the overall assessment its own length budget
The overall-status card summarises up to six metrics, but it inherited
the single-metric 30-60 word budget, which forced the model to either go
generic or overrun. Grant the section an explicit 3-5 sentence / 50-80
word budget and cap it at naming two metrics by name, with the rest
mentioned only when they deviate notably from their own mean.
2026-05-31 22:32:20 +02:00
Marc-André Bombeck d29b11cdf4 refactor(insights): split bmi and weight assessment focus
The bmi and weight cards produced near-identical trend text for a user
with both metrics, since bmi is weight scaled by a constant. Re-aim each
card so they carry distinct information: bmi now centres on WHO-band
placement (call out a band crossing or a near-boundary value against the
person's own baseline), and weight stays on the continuous trend and
pace (kg/week, plateau, milestone). Each defers the other concern to its
sibling card.
2026-05-31 22:32:20 +02:00
Marc-André Bombeck 262a3ad70a docs(insights): document english tile-id contract + naming ADR
Regenerate `docs/api/openapi.yaml` so the `InsightsLayoutBody` enum
carries the canonical English tile ids first, then the
accepted-but-deprecated legacy German ids, with the normalisation +
deprecation spelled out in the schema description. Add ADR 0001 fixing
the convention — localised UI copy, English internal identifiers — and
the non-breaking alias strategy. Add an iOS coordination note covering
the new canonical ids, the continued acceptance of the legacy ids, and
the recommended migration path.
2026-05-31 22:12:25 +02:00
Marc-André Bombeck 607dd9e5df feat(insights): accept legacy german tile ids as aliases
The tile-layout enum migrates from German to English to match the route
slugs. The contract on `GET/PUT /api/insights/layout` stays
non-breaking for clients that persisted the old German ids (the iOS
layout sync among them):

- the Zod enum is built from `ACCEPTED_INSIGHTS_TILE_IDS` (canonical
  English plus the legacy German aliases), so a PUT carrying old ids
  validates rather than tripping a 422;
- `normalizeInsightsTileId` collapses each legacy id onto its canonical
  English replacement on both the read path (`resolveInsightsLayout`)
  and the write path (`serializeInsightsLayout`), so a pre-rename layout
  surfaces English ids on GET without a forced re-PUT and a legacy PUT
  persists English ids;
- a layout carrying both a legacy and a canonical id for one tile
  dedupes to a single canonical entry.

The legacy ids are accepted-but-deprecated. Tests cover the read-side
normalisation and the write-side 200 (no 422) + canonical persistence.
2026-05-31 22:12:25 +02:00
Marc-André Bombeck c4ccd7e297 refactor(insights): migrate route slugs to english with redirects
Rename every routed Insights sub-page slug from German to English
(`/insights/blutdruck` -> `/insights/blood-pressure`, `gewicht` ->
`weight`, `medikamente` -> `medications`, and the full v1.7.0 metric
tail). UI copy stays localised through `t()` keys; only the internal
slugs change. The slug registry in `src/lib/insights/sub-page-metric.ts`
is the single source of truth, so the tab strip, target summary, and
daily-briefing routing targets follow it.

`next.config.ts` `redirects()` emits a permanent redirect from every
legacy German slug to its English target, including a `:path*` variant
so nested routes survive. Bookmarks and cached PWA navigation keep
resolving.
2026-05-31 22:12:25 +02:00
Marc-André Bombeck b416cabbf1 feat(insights): rewrite the overall assessment prompt to the assessment spec
Drop the dead-field guidance (avg90/allTime, historicalComparison,
ratePressureProduct, bodyCompositionDivergence, seasonalVariation) and
the chart-token instruction. Describe the per-type measurementSeries map
and the adherence/mood/target context, pull them into one coherent
picture against the person's own baseline, and close with the single
most important doable step.
2026-05-31 22:06:17 +02:00
Marc-André Bombeck 56cff68ec5 feat(insights): rewrite the medication-adherence assessment prompt to the assessment spec
Replace the historicalComparison and moodAdherenceRisk guidance with the
actual per-medication snapshot fields (compliance7/30, streak,
taken/skipped/missed, dailySeries). Read the recent direction as
compliance7 vs compliance30 against the person's own baseline, frame
positively, and close with one blame-free doable step.
2026-05-31 22:06:17 +02:00
Marc-André Bombeck 0f25226fdd feat(insights): rewrite the mood assessment prompt to the assessment spec
Describe the graded mood series, target band and tag list, lead on the
weekly pattern against the person's own baseline, name persistent low or
high phases calmly as an observation rather than a diagnosis, and close
with one kind doable step.
2026-05-31 22:06:17 +02:00
Marc-André Bombeck 4e04e375ad feat(insights): rewrite the BMI assessment prompt to the assessment spec
Describe the graded BMI series and target band, lead on the trend over
the single value, keep the WHO bands as placement anchors rather than a
label for the person, and name BMI's body-composition limits plainly.
Drop the chart-token instruction the status path strips out.
2026-05-31 22:06:17 +02:00
Marc-André Bombeck 052bbbfd7c feat(insights): rewrite the weight assessment prompt to the assessment spec
Replace the dead-field guidance (historicalComparison.weight,
allTimeMax, slope30, bodyCompositionDivergence, sleep.avg7) with the
graded weight series. Lead on the weekly/monthly trend rather than the
single value, define plateau and sustainable pace against the person's
own baseline, anchor milestones to their own maximum, and gate BMI
context on a known profile height.
2026-05-31 22:06:17 +02:00
Marc-André Bombeck 4f48160b3d feat(insights): rewrite the pulse assessment prompt to the assessment spec
Drop the stale snapshot vocabulary (avg7/30/90, historicalComparison,
slope30, rate-pressure product) and the chart-token instruction the
status path strips out. Describe the graded pulse series and target
band, compare the recent days to the person's own weekly/monthly
baseline at a ~5 bpm significance threshold, and frame a rising or
falling resting pulse as a possible explanation rather than a diagnosis.
2026-05-31 22:06:17 +02:00
Marc-André Bombeck 0f9fc70440 feat(insights): rewrite the base + blood-pressure assessment prompts to the graded-snapshot spec
The status cards render only the model's summary field, yet the prompt
described a snapshot vocabulary (avg7/30/90, historicalComparison,
ratePressureProduct, seasonalVariation) that the graded snapshot no
longer carries, and demanded a full findings/correlations JSON schema
that this path never reads.

Rebuild the shared base prompt around the actual graded shape (summary +
recent/weekly/monthly/yearly with min/max/mean/slope), the own-baseline
comparison, a name -> place -> one-step structure, a warm
autonomy-supporting tone, a 2-4 sentence length, honest data-gating, and
a { summary } output. The blood-pressure section judges systolic and
diastolic together against the person's own baseline with ESH 2023 as
the placement anchor.
2026-05-31 22:06:17 +02:00
Marc-André Bombeck cd8849cd51 feat(insights): drop the per-metric assessment cache on fresh measurements
Hang `invalidateStatusInsightsForTypes` off every measurement ingest
path so a new reading of a type drops the cached assessment rows that
reading dirties — the next mount or nightly warm pass then regenerates
them against the new data instead of serving the pre-measurement text
for the rest of the day.

Wired into both `/api/measurements` modes (single + multi-entry batch),
`/api/measurements/batch`, and the three Withings sync paths
(measures + activity + sleep), reusing the `collapseToTypeDayKeys`
result each already computes for its rollup recompute. The call is
fire-and-forget after the user's write commits: idempotent, never a
blocker on the request path, and a failure only warns.
2026-05-31 22:05:31 +02:00
Marc-André Bombeck 177ce576d3 feat(insights): warm the per-metric assessment caches in the nightly pre-gen
The comprehensive generator evicts the seven `insights.<scope>-status`
caches as part of its write so a stale comprehensive insight can never
leave a stale per-metric card behind — but it does not re-fill them.
Before this, the morning after the nightly pre-gen cron ran the
per-metric status caches were empty, so the first `/insights` mount
still ran one live status generation per card.

After a successful comprehensive generation the cron now forces the
seven `*StatusForUser` generators so their caches are warm, not empty,
by the user's first visit. The warm pass only runs on a `generated`
outcome (a no-provider `skipped` has nothing to warm; a `failed` run
means the chain is unhealthy this tick — re-running it seven more times
would only multiply the failure). Each generator resolves the provider
chain internally, so a provider-less account stays cheap. The per-user
budget bucket that already bounds the comprehensive pass also gates the
warm pass — both run behind the same once-per-20h gate.

`runInsightPregenerate` now reports `assessmentsWarmed` and emits an
`insights.pregenerate.run` wide-event with the full per-run tally so the
nightly dashboard can track how many assessments each tick wrote.

Also adds `invalidateStatusInsightsForTypes` + `PER_STATUS_SCOPES` to
the comprehensive generator: a type → scope map (WEIGHT → weight + bmi +
general, BP → blood-pressure + general, PULSE/resting-HR → pulse +
general, everything else → general) so the ingest path can drop only the
scopes a fresh reading dirties.
2026-05-31 22:05:31 +02:00
Marc-André Bombeck 68a9f114a5 test(insights): cover graded compression and stub-skip
Pin the snapshot payload budget: a 2-year daily weigher (weight + daily
BP) and a six-type dense account both serialise under the 24 000-char
cap, proving the graded rewrite holds the line on the worst-case
accounts. The stub-skip and timeout-no-persist behaviour is covered by
the per-generator cache-read tests + the shared status-cache suite.
2026-05-31 21:51:18 +02:00
Marc-André Bombeck 11a06196f3 perf(insights): compress status snapshots via graded series (raw->week->month->year)
The seven per-card generators embedded the full ~360-day daily array
per metric plus correlation pair arrays, and never touched the
WEEK/MONTH/YEAR rollup tier. A dense daily weigher ran the weight
snapshot ~80-110 KB; a many-type account could push general-status past
100 K tokens.

Embed the shared graded series instead of the full daily array, cap the
embedded correlation/paired arrays to their most recent entries, skip
measurement types with no data in general-status, and apply a generic
24 000-char snapshot budget that sheds yearly -> weekly -> array-tails
-> recent until it fits. The applyPayloadBudget daily buckets stay
in-process to drive the latest-day focus, in-target gates, and
correlation pairing; only the compact shape reaches the prompt.

Measured on a 2-year daily account: weight-status ~15 KB, general-status
~12 KB across six dense types — both well under the cap.
2026-05-31 21:51:18 +02:00
Marc-André Bombeck 1b78df345d feat(insights): add a shared graded-series compressor for AI snapshots
Turn a metric's history into a small, bucketed, AI-ready shape: the
last ~21 days verbatim at daily granularity, the ~10 weeks before that
as ISO-week means, the ~12 months before that as calendar-month means,
and everything older as yearly buckets with a least-squares trend
slope. ~30-45 rows per metric regardless of history depth, never an
individual reading beyond the recent window.

A pure in-memory builder folds raw points; a rollup-backed builder
sources the monthly/yearly slices from the pre-aggregated MONTH/YEAR
tier (readBestGranularityRollups) and the recent/weekly slices from a
bounded ~90-day raw read, so the coarse rollup tier finally earns its
write amplification. The recent/weekly bucketing mirrors the coach's
timeline split, duplicated here so the coach contract stays put.
2026-05-31 21:51:18 +02:00
Marc-André Bombeck 5432063f5c fix(insights): stop caching the timeout fallback and align the status timeout with the coach
The per-card status path capped provider generation at 20 s and, on
timeout, persisted the generic no-key fallback as a day-keyed cache row.
The cache-read only checked the day key and text, so that stub looked
like a real assessment and stuck until midnight — hiding the real,
data-driven text for the rest of the day on any account that hit a
single transient stall.

Align the budget with the provider clients' 60 s ceiling, route the
status path through resolveProviderChain + runRawCompletionWithFallback
so a degraded provider cascades like the coach, and stop persisting the
fallback: a timeout or error is a transient miss that serves the
fallback for the current render and re-attempts on the next mount. A
shared cache-read rejects any lingering timeout-stub rows by their
markers, and withTimeout now annotates real errors instead of folding
them silently into the timeout path.
2026-05-31 21:51:18 +02:00
Marc-André Bombeck 16b4768fd1 feat(insights): surface target range and explainer on category pages
Each insights category page now carries two additions placed beside the
chart and assessment:

- a target-range card (MetricTargetSummary) showing the numeric goal
  band and the share of logged days within it, read from the shared
  /api/insights/targets payload so the bands match the /targets editor
  exactly. Blood pressure stitches the systolic and diastolic bands into
  one S/D string. A discreet link routes to /targets to adjust the band.
- a question-mark explainer next to each heading (MetricExplainer in
  SubPageShell) opening a static, layperson 'What is X?' read. Popover on
  desktop, bottom-sheet on phones; keyboard- and touch-reachable with a
  44px hit surface. Copy ships in all six locales.

The /targets page stays the canonical place to tune the bands.
2026-05-31 21:32:32 +02:00
540 changed files with 57318 additions and 10050 deletions

No files matched your search

+50 -8
View File
@@ -7,30 +7,41 @@
# compose `environment:` block at boot — `.env` should never ship.
.env
.env.*
!.env.example
!.env.production.example
.env.example
.env.production.example
# Version control + CI metadata.
.git
.github
.gitignore
.gitleaks.toml
# Build / install output that gets regenerated inside the builder stage.
node_modules
.next
out
dist
coverage
test-results
playwright-report
playwright/.cache
playwright.config.ts
vitest.config.mts
vitest.integration.config.mts
tests
tests/
e2e
e2e/
e2e-results
.eslintcache
eslint-plugins
eslint-plugins/
knip.json
# Persisted session state from e2e runs.
e2e/setup/storageState.json
# Planning scratch + per-release audit notes (operator-local).
.planning
docs/audit
# Generated local cache files
*.tsbuildinfo
next-env.d.ts
public/sw-version.js
# Captures from local debugging sessions.
*.har
@@ -43,3 +54,34 @@ docs/audit
.vscode
.idea
.DS_Store
*.swp
*.swo
# Logs / Debug
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Docker compose / local ops files
docker-compose*.yml
compose*.yml
Dockerfile
.dockerignore
# Documentation not needed for runtime image
README.md
CHANGELOG.md
CLAUDE.md
CONTRIBUTING.md
CONTRIBUTING-AI.md
CODE_OF_CONDUCT.md
SECURITY.md
# LICENSE / LICENSE.md are deliberately NOT ignored: AGPL-3.0 conveys with
# the software, so the licence text ships inside the image.
AGENTS.md
.planning
docs
docs/*.md
docs/**/*.md
+11
View File
@@ -62,6 +62,17 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# BACKUP_ENCRYPTION_KEY=""
# -----------------------------------------------------------------------------
# FHIR health-record export -- optional
# -----------------------------------------------------------------------------
# Per-export ceiling on the number of MedicationAdministration rows in the
# FHIR Bundle. The report window already scopes the set; this is a coarse
# safety ceiling on the serialised Bundle size. Bounded 1..50000; any unset
# or out-of-range value falls back to the default of 5000. Raise it only for
# an unusual multi-year, many-medication workload.
# FHIR_MAX_MEDICATION_ADMINISTRATIONS="5000"
# -----------------------------------------------------------------------------
# Dashboard snapshot -- BUILD-TIME toggle (default ON)
# -----------------------------------------------------------------------------
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
uses: pnpm/action-setup@v4
- name: Set up Node
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
+1 -1
View File
@@ -87,7 +87,7 @@ jobs:
fetch-depth: 0
- name: Gitleaks secret scan
uses: gitleaks/gitleaks-action@v2
uses: gitleaks/gitleaks-action@v3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+3
View File
@@ -22,6 +22,9 @@ regexes = [
'''idempotency-key''',
# Allow the placeholder prefix used in OpenAPI examples + docs.
'''hlk_example''',
# i18n caption keys for the briefing-driven trend charts are dotted message
# keys, not secrets — one trips the generic-api-key entropy heuristic.
'''insights\.trendsRow\.caption''',
]
# Specific commit fingerprints from prior false-positive findings (kept for reproducibility).
+108 -254
View File
@@ -1,270 +1,124 @@
# HealthLog v1.4.24 — roadmap
# HealthLog — roadmap
Milestone: **v1.4.24 — pre-iOS final polish** (not yet kicked off)
Latest tag at start: v1.4.23 (live in prod)
Current line: **v1.9.0** (shipping — Insights time-ranges, deeper mood, medication
drug-coding into FHIR, advanced-settings rebuild, connection-pool stability).
Trunk: `main`, always releasable. Release model in CLAUDE.md.
Carry-over candidates already captured in
`.planning/v1423-backlog.md`:
- Pearson incomplete-beta replacement — v1.4.23 W5 H6 raised
`MIN_PAIRED_N` from 14 → 20 as a conservative surfacing-gate
patch. Land the rigorous incomplete-beta or normal-approx
replacement before v1.5/v1.6 auto-correlation discovery ships.
- OpenAPI drift gate flip from warn-only to hard-fail — generator
covers ~880 lines vs the legacy hand-maintained spec's 5 468;
complete registry coverage in parallel so the lockstep happens
before the iOS DTO codegen is downloading the spec
automatically.
- Settings-cog vs per-message-controls UX consolidation — Design
pushback during W6 raised concern that the dual surface
duplicates intent. Wait on first-week thumbs data, decide
whether per-user prompt prefs drift from per-message ratings.
- `coach-prefs.test.ts` integration `NextRequest` URL mock
regression — pre-existing failure that predates v1.4.23.
Investigate the harness's `cookies()` mock interaction with
`NextRequest`'s URL parsing.
- Sec-MED-1 + LOW cluster — intra-batch dedup accounting
(Sec-LOW-1), idempotency 422 retry hint in the OpenAPI 422
description (Sec-LOW-2), APNs key-file path redaction in
wide-event meta (Sec-LOW-3), refresh-token failure audit
`userId` extension (Sec-LOW-4).
- S-05 simplify deferral (Session B) — touched too many call
sites for a single reconcile commit.
Reserved next strategic milestones:
- **v1.5** — iOS app + Apple Health integration + per-metric APNs
alerts. Refreshed strategic plan with concrete file paths at
`.planning/phase-W6-v1423-product-lead-review.md`. P1 is now a
~5-day iOS Swift sprint because every server-side contract
landed in v1.4.23. Per-user timezone foundation
(`.planning/feature-user-timezone.md`, issue #167) is now scoped
into v1.4.25 — Marc directive 2026-05-14, packs the iOS-launch
prep into the v1.4.25 polish window instead of slipping it.
- **v1.6+** — Auto-correlation discovery (FDR-controlled), Coach
full-page route at `/insights/coach`, conversation-driven goal
setting.
Next milestone: **v1.10.0 — derived / synthesized wellness metrics + provenance + docs**.
---
## Previous milestone — v1.4.23 (completed 2026-05-11)
## v1.10.0 — derived metrics, provenance, docs, discoverability
| Wave | Goal | State |
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- |
| 1 | Research — Apple Health type mapping, APNs library decision (`@parse/node-apn`), OpenAPI tooling choice (`zod-openapi` + `yaml@^2`) | done |
| 2 | Apple Health foundation — F1 `MeasurementType` extension + sleep-stage enum + composite dedup index, F2 `APPLE_HEALTH` source + UI badge, F3 `POST /api/measurements/batch` + sleep-stage analytics aggregation | done |
| 3 | APNs scaffolding — F4 `@parse/node-apn` sender + dispatcher cascade rewire, `Device` columns + paired CHECK constraint, `POST /api/devices` apnsToken acceptance + cross-user-hijack guard | done |
| 4 | OpenAPI generator + Coach metric slot + native-auth hardening + Coolify runbook — F5 `zod-openapi` registry + warn-only CI gate, F6 nine HealthKit categories in strict schema + PROMPT_VERSION 4.23.0, F7 per-device refresh-token replay-detection + device-management endpoints, F8 Coolify runbook + `::notice::` line | done |
| 5 | Hygiene (H1-H7) — sentinel parser malformed-enum annotation, analytics chunked-paged aggregate, drawer controlled-prop refactor, per-user prompt prefs surface, `medication_schedules.days_of_week` deploy, Pearson n≥20 gate, Coach feedback observation view | done |
| 6 | Multi-agent QA + reconcile — six reviews (code, security, design, senior-dev, simplify, product-lead); 1 CRIT + 9 HIGH + 4 simplify + 3 MED applied; LOWs triaged into v1423-backlog | done |
| 7 | Release v1.4.23 — bump, CHANGELOG, release-merge, tag, GHCR, Coolify deploy, /api/version=1.4.23, smoke, GH release, docs + landing sync, brief | done |
The headline is derived wellness metrics that are honest on the data HealthLog
actually has — daily snapshots, not continuous streams. The governing constraint:
HRV is a single nightly SDNN value, there is no continuous HR, no EDA, no raw ECG/PPG,
and the nightly drain tombstones per-sample rows to a daily mean. So anything built on
intraday physiology is out of scope; day-scale deviation-from-personal-baseline
statistics are the sweet spot. Full synthesis in
`.planning/v1.10-derived-metrics-PROPOSAL.md`, implementation plan in
`.planning/v1.9.1-derived-metrics-IMPLEMENTATION-PLAN.md` (targeted at v1.10.0, not
1.9.1), research reports under `.planning/v1.10-research/`.
Milestone completed 2026-05-11 — v1.4.23 LIVE in prod.
Release brief: `docs/audit/v1423-summary.md`.
Backlog seeded to `.planning/v1423-backlog.md`. v1.5 strategic plan refresh
at `.planning/phase-W6-v1423-product-lead-review.md`.
### Ordering (data-availability first)
A derived metric appears only when its required inputs exist; composites degrade
gracefully and never present a headline number secretly computed from one of N signals.
Precedent: the Personal Health Score null-redistributes missing pillars.
(`.planning/v1.10-research/DESIGN-PRINCIPLE-data-availability.md`.)
1. **Per-metric Vitals dashboard** (flagship) — each available signal shows its own
personal-baseline card; absent signals don't render, so it scales 1→30 metrics with
no data assumptions. Pure rolling stats over inputs we 100% have, plus a multi-signal
early-strain flag. Build this first.
2. **Pre-computed easy wins** — age/sex-adjusted reference ranges (cross-cutting
enabler: `dateOfBirth` / `gender` are stored but unused for norms), Fitness Age /
cardio band from VO2max, Vascular Age framing from PWV. Render only when the device
value exists.
3. **Composites** — Sleep Score and a Readiness / Wellness index (extending the Personal
Health Score), each with a minimum-inputs threshold, reweighting around missing
inputs, a visible coverage / confidence indicator, and a "track BP/HRV to sharpen
this" nudge.
4. **Broader correlation discovery** — expand the fixed-hypothesis correlation engine
into an FDR-controlled behaviour↔outcome discovery engine, surfacing only pairs with
enough paired n (the existing n≥20 gate pattern).
Out of scope by construction (require sensing we deliberately don't retain): Body
Battery, all-day / real-time Stress, Strain / Training Load, a faithful Recovery score,
AFib / ECG derivation, breathing-disturbance / AHI, Oura Resilience, minute-by-minute
time-in-zone. State this plainly so we never over-promise; surface a device-computed
value as a passthrough where one exists, never re-derive it.
### Provenance / transparency (acceptance criterion, not an afterthought)
Every derived metric openly states what it's based on and links the basis:
- exact inputs + method / formula (plain-language + the math),
- why this method (rationale),
- a citation / link to the underlying authority (IEEE / RFC / WHO / LOINC /
peer-reviewed studies / Wikipedia — whichever is right),
- confidence / limitations stated honestly (SDNN≠RMSSD, snapshot-not-continuous,
consumer-sensor validity caveats),
- rendered through the existing collapsible provenance / explainer pattern — no
black-box number.
Needs a shared provenance / citation data model so each derived metric carries its
sources, surfaced via the explainer component everywhere it appears.
(`.planning/v1.10-research/REQUIREMENT-docs-and-provenance.md`.)
### Documentation audit + continuation
- Audit the docs site (`healthlog-docs`), in-repo `docs/`, README, and the OpenAPI
contract for accuracy vs the shipped state — much drifted across the v1.8.x → v1.9.0
arc.
- Continue / extend docs to cover the new derived metrics with the same provenance:
meaning, computation, linked standard, required data, graceful-degradation behaviour.
- This `ROADMAP.md` / `STATE.md` refresh folds into the audit.
### Discoverability / growth
A discoverability / growth workstream stands alongside the milestone (scope to be
shaped during planning).
---
## Previous milestone — v1.4.22 (completed 2026-05-10T22:43:50+02:00)
## Standing deferred items
| Wave | Goal | State |
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- |
| 1 | Research + probe — Playwright PROD probe (BD-Zielbereich 5th attempt, api-tokens 5th attempt, metric token leaks, Targets page brainstorm); health-coach prompt research | done |
| 2 | Insights surface polish — A1 BD framing, A2 BD-Kachel parity, A3 comparison-toggle global, A4 grid normalisation, A5 Muster rename + tabs above hero, A6 token-leak fix | done |
| 3 | Coach polish — B1 prompt rewrite (PROMPT_VERSION 4.20.2 → 4.22.0), B2 collapsible evidence, B3 Gravatar parity, B4 disclaimer move, B5 settings cog removal | done |
| 4 | Other surfaces + backlog cleanup — C1 Zielwerte sparkline + Δ-vs-last-month, C2 api-tokens 5th attempt, C3 Coolify auto-deploy runbook, C4 AuthShell flicker → proxy.ts, C5 node-26-alpine deferred, D backlog wave (12 items) | done |
| 5 | Multi-agent QA + Product-Lead — code, security, design, senior-dev, simplify, product-lead; 0 CRIT, 7 HIGH all applied inline, ~6 MED applied, rest deferred | done |
| 6 | Release v1.4.22 — bump, CHANGELOG, release-merge, tag, GHCR, host-side retag deploy (Coolify secrets still missing), /api/version=1.4.22, smoke, GH release, docs+landing sync, brief | done |
Carried forward; pick up as they become relevant or fold into a milestone.
Milestone completed 2026-05-10T22:43:50+02:00 — v1.4.22 LIVE in prod.
Release brief: `docs/audit/v1422-summary.md`.
Image digest:
`sha256:865154614303fdc362ee3941776f73ec0f60e1f16112ec272a75cbbe28e2cffb`.
Backlog seeded to `.planning/v1422-backlog.md`. v1.5 strategic plan at
`.planning/phase-W5-v1422-product-lead-review.md`.
- **BYOK AI fallback** — resilience gap: no provider fallback is configured, so a single
provider outage stalls assessment generation.
- **`MedicationAdministration` per-export cap tuning** — the per-dose administration
emission added in v1.9.0 needs a sensible cap for high-volume histories.
- **SNOMED route / site upgrade** — route-of-administration + injection-site could carry
SNOMED codes rather than the current local enum.
- **German bfarm ATC URI behind a locale toggle** — the export codes ATC against the WHO
system; a German deployment may prefer the bfarm URI.
---
## Previous patch — v1.4.21 (completed 2026-05-10T17:46+00:00)
## Shipped (coarse — CHANGELOG.md is the record)
| Phase | Goal | State |
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- |
| F | Same-day patch on top of v1.4.20 — Daily Briefing regenerate fix, Coach day-level snapshot, duplicate streaming bubble, drawer header, scope picker UI | done |
| E | Release v1.4.21 — develop → main release-merge, bump, CHANGELOG, tag, GHCR, host-side retag fallback, /api/version=1.4.21, smoke, GH release, brief | done |
Patch completed 2026-05-10T17:46+00:00 — v1.4.21 LIVE in prod.
Release brief: `docs/audit/v1421-summary.md`.
Image digest:
`sha256:4e818d44702c3581a14d6480a953fd20d16cbbaf21c41e0c778c07340d3c4b1c`.
---
## Previous milestone — v1.4.20 (completed 2026-05-10T16:49:25Z)
| Phase | Goal | State |
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- |
| F0 | Bootstrap — commit dangling v1.4.19 reports, scaffold STATE+ROADMAP | done |
| F1 | Branch model — long-lived `develop` from `main` HEAD; GHCR builds on `main` + `v*` only | done |
| F2 | Document branch + release model — CONTRIBUTING.md + docs site mirror page | done |
| FX | User-facing artifact cleanup — PII (no real names, no health figures), internal jargon, German leaks; CHANGELOG + docs/audit + GH releases + docs + landing | done |
| F5 | Best-practice GitHub repo audit — CODE_OF_CONDUCT.md, issue + PR templates, dependabot expansion, package.json metadata | done |
| F6 | Multi-agent QA on new docs — 38 docs pages cross-checked vs deployed state, CRIT + HIGH inline | done |
| B1 | Hero strip + Daily Briefing + Suggested-prompts | done |
| B2 | AI Coach drawer + SSE streaming + encrypted persistence | done |
| B3 | Correlation discovery + Trends row with AI annotations | done |
| B4 | Weekly Report + Storyboard markers + Mobile passes | done |
| B5 | Personal Health Score (composite 0–100, 3 bands) | done |
| D | Multi-agent QA — code, security, design, senior-dev, simplify, product-lead; 13 HIGH + 6 MED + 5 simplify-yes inline; 22 MED + 16 LOW deferred | done |
| E | Release v1.4.20 — develop → main release-merge, bump, CHANGELOG, tag, GHCR, host-side retag deploy fallback, /api/version=1.4.20, smoke, docs+landing sync | done |
Milestone completed 2026-05-10T16:49:25Z — v1.4.20 LIVE in prod.
Release brief: `docs/audit/v1420-summary.md`. Backlog seeded to
`.planning/v1421-backlog.md`. v1.5 strategic plan at
`.planning/phase-D-v1420-product-lead-review.md`.
---
## Previous milestone — v1.4.19 (completed 2026-05-10T12:39:59Z)
| Phase | Goal | State |
| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ----- |
| 0 | Bootstrap — STATE+ROADMAP for v1.4.19 | done |
| A1 | BD-Zielbereich constant 50% (4th attempt) — live-DB root-cause + integration test | done |
| A2 | Charts mobile audit — universal X-tick density helper, mobile-first stacked headers across HealthChart / MoodChart / MedicationComplianceChart | done |
| A3 | `/insights` polish — Comparison-toggle relocated, single page-level refresh, BP/Weight tile strip removed, raw-token leak guarded | done |
| A4 | AI prompt — GROUND RULE 7 forbids default-positivity opener, PROMPT_VERSION 4.16.1 → 4.19.0 | done |
| A5 | Settings/Integrations status-UI — `<IntegrationStatusPill>` consolidates Withings + Mood Log, divider parity, locale-aware relative-time | done |
| A6 | Settings mobile audit — input heights equalised at 36 px, action buttons standardised, Sprache select hoisted to its own row | done |
| A7 | Admin polish — feedback tab strip scrollbar, api-tokens 4th-attempt truncate+tooltip, Einklappen toggle removed, Zielwerte i18n | done |
| A8 | Quality-of-life audit — 78 findings prioritised CRITICAL / HIGH / MED / LOW | done |
| B | Apply A8 — 6/6 CRITICAL + 21/25 HIGH inline; 31 MED + 16 LOW carried to v1.4.20 backlog | done |
| D | Multi-agent QA (5 reviewers) + Product-Lead → v1.5 redesign plan filed | done |
| E1-E3 | Release v1.4.19 — tag, GHCR build green, host-side retag deploy via fallback (Coolify deploy hung, retried via SSH), GH release, docs+landing sync | done |
Milestone completed 2026-05-10T12:39:59Z — v1.4.19 LIVE in prod.
Release brief: `docs/audit/v1419-summary.md`. Backlog seeded to
`.planning/v1420-backlog.md` (carry-over) and v1.5 strategic items in
`.planning/v15-backlog.md` + `.planning/phase-D-v1419-product-lead-review.md`.
---
## Previous milestone — v1.4.18 (completed 2026-05-10T11:45+02:00)
| Phase | Goal | State |
| ----- | ------------------------------------------------------------------------------------------------------------ | ----- |
| 0 | Bootstrap — STATE+ROADMAP for v1.4.18 | done |
| A1 | BD-Zielbereich tile — 7T/30T sub-values render real numbers (currently "—" even with data) | done |
| A2 | `/admin/api-tokens` table scrollbar (3rd attempt) — Playwright live-verify against prod, fix actual offender | done |
| A3 | Chart visual revert + per-chart toggles — drop gradient/emoji/auto-mean, ship 3 opt-in overlay toggles | done |
| B1 | Achievements expansion — research + 15-25 new, hidden Easter-eggs, lock filter, hidden cards | done |
| D | Multi-agent QA + Product-Lead — code-reviewer, security, design, senior, simplify, product | done |
| E | Release v1.4.18 — bump, CHANGELOG, tag, GHCR, deploy, /api/version=1.4.18, smoke, docs+landing sync | done |
Marathon completed 2026-05-10T11:45+02:00 — v1.4.18 LIVE in prod.
Full report: `docs/audit/v1418-summary.md`. Backlog seeded to
`.planning/v1419-backlog.md` (tactical) and `.planning/v15-backlog.md`
- `.planning/phase-D-v1418-product-lead-review.md` (strategic).
---
## Previous milestone — v1.4.17 hotfix (live 2026-05-10T07:58+00:00)
| Phase | Goal | State |
| ------ | ---------------------------------------------------------------------------------------------------------- | ----- |
| Hotfix | `/insights` TypeError on legacy cached blob — `isLegacyInsightPayload()` flag + advisor card short-circuit | done |
| Audit | `git grep -nE '\.replace\(' src/` — 82 hits scanned, crash site is the only fragile case | done |
| E | Release v1.4.17 — bump, tag, GHCR, host-side retag deploy, GH release | done |
Hotfix completed 2026-05-10T07:58+00:00 — v1.4.17 LIVE in prod.
Detailed report: `.planning/phase-v1417-hotfix-report.md`.
---
## Previous milestone — v1.4.16 (completed 2026-05-10T04:05+02:00)
| Phase | Goal | State |
| ----- | ----------------------------------------------------------------------------------------------------- | ----- |
| 0 | Bootstrap — STATE+ROADMAP for v1.4.16 | done |
| A1 | Sidebar admin-expand bug — Admin nav from non-admin route + Gravatar dropdown side-effects | done |
| A2 | BD-Zielbereich real-fix (regression from v1.4.15 A4) — root-cause + E2E | done |
| A3 | `/admin/api-tokens` table responsive — card-list mobile fallback | done |
| A4 | "7-Tage-Schnitt" → "7-Tage-Trend" DE + indicator on ALL charts | done |
| A5 | Top-tile-selector real-fix — widget-id enum drift fixed | done |
| A6 | Medication-chart 7d-trend + target-range — match other charts | done |
| A7 | AI Generator rate-limit 10/h + cache-invalidate-on-new | done |
| A8 | Umlaute encoding bug + login-overview UTF-8 audit + long-window split-half delta | done |
| B1a | Charts visual leap — gradients, baseline, rich tooltip, mood emoji glyphs | done |
| B1b | Insights surface visual leap — page hero, recs grid, summary typography, dashboard preview | done |
| B2 | AI provider settings UX cleanup — single pulldown-driven form, fallback chain editor | done |
| B3 | Admin System-Status host-load chart — CPU/memory/disk-io graph last 2h | done |
| B4 | Admin logs visibility deepening — filterable audit log + structured wide-event tail | done |
| B5a | Medical-reference grounding — AHA / ESC / ESH / WHO bundle + UI citations | done |
| B5b | Multi-provider redundancy — try-each-on-hard-failure, configurable fallback order | done |
| B5c | Per-recommendation explainability — WHY / WINDOW / CITATIONS card with mini-chart | done |
| B5d | Confidence score per recommendation — deterministic 0-100 from sufficiency / recency / signal | done |
| B5e | User-feedback loop — thumbs persisted to RecommendationFeedback + daily aggregator | done |
| B6 | Settings naming-audit — stringency, consistency, no double naming | done |
| B7 | Settings → Export menu (Arztbrief consolidated) — new `/settings/export` route | done |
| B8 | Extended comparison views — Vormonat / Vorjahr overlay across charts + tiles + insights | done |
| C | Catch-up — 5 of 8 deferred HIGH + 3 of 5 mobile MED + docker-publish drop qemu-arm64 | done |
| D | Multi-agent QA + Product-Lead — code-review, security, design, senior, simplify, product-lead | done |
| E1-E3 | Release v1.4.16 — bump, tag, GHCR (both green), host-side retag deploy, GH release, docs+landing sync | done |
Marathon completed 2026-05-10T04:05+02:00 — v1.4.16 LIVE in prod.
Full report: `docs/audit/v1416-summary.md`. v1.5 backlog seeded at
`.planning/v15-backlog.md`.
---
## Previous milestone — v1.4.15 (completed 2026-05-09T22:50+02:00)
| Phase | Goal | State |
| -------- | ------------------------------------------------------------------------------------------------------------ | ----- |
| 0 | Bootstrap — STATE+ROADMAP for v1.4.15 | done |
| A1 | Nav conditionals + sidebar context-awareness | done |
| A2 | `/admin` overview redesign + `/admin/api-tokens` responsive | done |
| A3 | Quick-add labels + Stimmung-Card mobile + onboarding flicker | done |
| A4 | Dashboard analytics fixes (BD-Zielbereich, Medikamente graph, Stimmung agg, 7-Tage-Trend, top-tile selector) | done |
| A5 | Mobile UX audit + chart scroll-lockup fix | done |
| B-mobile | Mobile audit fix-application (5 commits, 2/2 CRITICAL, 6/8 HIGH) | done |
| B1 | Backup completeness — restore, download, upload, audit, docs | done |
| B2 | Withings + moodLog sync robustness — status UI, refresh rotation, telegram on fail | done |
| B3 | Notification reliability — auto-disable on 410, exp backoff, status visible | done |
| B4 | Achievements UI — `/achievements` page + dashboard card | done |
| B5 | Onboarding tour first-run — spotlight, Esc/arrows, restart from Settings | done |
| B6 | Doctor-report v2 — configurable date range, practice-name on cover | done |
| C1 | AI/Codex hardening — provider abstraction, JSON schema, citations, scope, slug-drift defense | done |
| C2 | Auto-deployment — Coolify webhook on GHCR push, failure-notify | done |
| C3 | CI/e2e reliability audit — root-cause flaky specs, docker-publish cache fix | done |
| C4 | i18n coverage audit — EN+DE parity, non-empty test guards | done |
| C5 | Empty-states audit — 13 surfaces upgraded | done |
| D | Multi-agent QA (5 reviewers + reconcile) | done |
| E1–E3 | Release v1.4.15 — bump, tag, GHCR, host-side retag deploy, docs+landing sync, Marc-Brief | done |
Marathon completed 2026-05-09T22:50+02:00 — v1.4.15 LIVE in prod.
Full report: `docs/audit/v1415-summary.md`.
---
## Previous milestone — v1.5 (relabelled v1.4.14, completed 2026-05-09)
| Phase | Goal | State |
| ----- | -------------------------------------------------------------------------------------------------------------------------------------- | ----- |
| 0 | Bootstrap — STATE+ROADMAP, git/CI/prod sanity, single chore commit | done |
| 1 | Verify Codex-OAuth end-to-end (device-start shape, prod logs, gpt-5.3-codex slug) | done |
| 2 | v1.4.6 deferred backlog — T2.1..T2.7 (wipe scope, DST math, 5xx → 422/503, status-card test, redact regex, Backups view, /admin/users) | done |
| 3 | End-to-end test coverage — authed dashboard, add-measurement, KI flow, doctor PDF, insights, mobile smoke, axe-core extended | done |
| 4 | Performance audit — Playwright capture, `docs/audit/v15-performance.md`, top-3 wins inline | done |
| 4b | Admin Panel refactor — `/admin/[section]` dynamic routes mirror Settings; sidebar Admin group; legacy hash redirects; bundle-size win | done |
| 5 | UX polish — design-review top-10 friction points, CRITICAL/HIGH triage | done |
| 6 | Multi-agent QA (parallel) — code-reviewer, security, visual/UX, simplify | done |
| 7 | Pre-release verification — typecheck / lint / format / test / integration / build / e2e | done |
| 8 | Release v1.4.14 — rebrand patch deploy (host-side retag, GHCR `:1.4.14`, image digest `0ced46004a54…`) | done |
| 9 | Docs + landing site sync to v1.4.14 | done |
| 10 | Backfill GitHub releases v1.4.7..v1.4.13 from CHANGELOG | done |
| 11 | Final summary `docs/audit/v1414-summary.md` with Marc-Brief | done |
Marathon completed 2026-05-09T18:35+02:00 — v1.4.14 LIVE in prod.
Full report: `docs/audit/v1414-summary.md`.
- **v1.9.0** (2026-06-02) — Insights time-ranges + period-over-period deltas, deeper
mood insights, medication ATC + RxNorm into the FHIR export (+ MedicationAdministration
per dose, Coverage-from-bare-KVNR, Composition narrative), advanced-settings rebuild,
inline targets (`/targets` retired), connection-pool sizing fix, decoupled insight
warm. Migration `0103`.
- **v1.8.7.1** (2026-06-02) — assessment on every HealthKit metric page, one-tap
pre-generation.
- **v1.8.x** (2026-05-31 → 06-02) — Insights big release (reliable assessments, graded
compression, embedded targets, explainers, English slugs), compliance + intake-slot
medical fixes, injection-site tracking, FHIR codesystem + Coverage, instant
assessments, GLP-1 blood-level chart.
- **v1.7.x** (2026-05-31) — health-record PDF + FHIR R4 export, PRN + cyclic schedules,
unified dashboard snapshot + nightly insight pre-gen, offline sync delta feed, full
HealthKit chart coverage, Coach data clustering.
- **v1.6.0** (2026-05-30) — medication editor overhaul + route of administration,
one-time injection, today-tile read-flip, profile-photo upload.
- **v1.5.x** (2026-05-24 → 29) — native iOS client public beta + Apple Health sync,
medication scheduling (RRULE / rolling / one-shot), step consolidation, `safeFetch`
egress hardening, avatar storage, `SESSION_COOKIE_SECURE` through compose.
- **v1.4.24 → v1.4.50** (2026-05-11 → 24) — rollup tiers + perf, APNs + `push_attempts`,
reminder suppression, self-healing stale-shell, localisation, MoodLog reverse-sync.
- **v1.4.23** (2026-05-11) — pre-iOS backend foundation + hygiene (Apple Health enum +
batch ingest + APNs scaffolding + OpenAPI generator). Earlier history archived.
</content>
+95 -223
View File
@@ -1,245 +1,117 @@
# v1.4.23 marathon — state log
# State log — where we are now
Status: SHIPPED — v1.4.23 LIVE in prod
Last update: 2026-05-11 (release wave)
Status: **v1.9.0 shipping** — on branch `release/v1.9.0`, Draft PR #240 → `main`,
about to tag + docker-publish + deploy.
Last update: 2026-06-02
> Previous milestone: v1.4.22 live (image digest
> `sha256:865154614303…`, `/api/version=1.4.22`).
> v1.4.23 = pre-iOS prep + hygiene. Backend foundation so the iOS
> Health app can ship in v1.5 without contract churn, plus 7
> hygiene items from `.planning/v1422-backlog.md`.
> Single trunk: `main` is always releasable. `develop` is retired (archived as the
> tag `archive/develop-pre-v1.5`; never recreate it). Releases cut on a short-lived
> `release/vX.Y.Z` branch → PR → merge → tag → `docker-publish.yml` → manual deploy →
> verify `/api/version` on every target. See CLAUDE.md for the full model.
## Wave 1 — Research (single agent, three streams)
---
- [ ] Apple Health `HKQuantityTypeIdentifier` → `MeasurementType`
mapping, with units + canonical aggregation rules
- [ ] APNs Node library decision (`apn` vs `@parse/node-apn` vs
raw HTTP/2) with maintenance / type-safety / footprint pros/cons
- [ ] OpenAPI 3.1 generator tooling for Next.js routes
(`@asteasolutions/zod-to-openapi`, `ts-rest`, `next-rest`,
hand-rolled)
- Detailed report: `.planning/phase-W1-v1423-research.md`
## v1.9.0 — what it carries
## Wave 2 — Apple Health foundation (F1+F2+F3)
Insights time-ranges + period-over-period deltas, deeper mood insights, medication
drug-coding into the FHIR export, an advanced-settings rebuild, an inline-targets
move, and a stability fix to the connection pool.
### F1 — `MeasurementType` enum extension
### Added
- [x] Added `HEART_RATE_VARIABILITY`, `RESTING_HEART_RATE`,
`ACTIVE_ENERGY_BURNED`, `FLIGHTS_CLIMBED`,
`WALKING_RUNNING_DISTANCE`, `VO2_MAX`, `BODY_TEMPERATURE`
(W1 recommended reusing `ACTIVITY_STEPS` + `SLEEP_DURATION`
verbatim; the iOS DTO already shipped `BODY_TEMPERATURE` so
that joined the additive set — net new = 7)
- [x] Prisma migration `0036_apple_health_measurement_types`
- [x] Sleep unit shifted from hours to minutes; new `SleepStage`
enum + nullable `Measurement.sleepStage` column (CHECK
constraint scopes it to SLEEP_DURATION rows)
- [x] Composite unique index `(user_id, type, source, external_id)`
— Apple Health batch dedup key
- [x] Unit conversions in
`src/lib/measurements/apple-health-mapping.ts` (16 unit tests)
- **Selectable time ranges on the Insights pages** — week / month / quarter / year
range pills per metric, with a period-over-period delta (the change in the average,
stated plainly with its direction).
- **Deeper mood insights** — time-of-day pattern (highest / lowest), a stability read,
and correlation cards against weight and blood pressure. Every correlation is
paired-n gated (only shown when there are enough paired days to mean anything).
- **Standard drug codes on a medication** — an optional ATC and RxNorm code (entered,
never guessed; migration `0103` adds the nullable `atc_code` / `rxnorm_code`).
These flow into the health-record export: the `MedicationStatement` codes the drug
with the WHO ATC system + RxNorm alongside the plain name, and the export adds a
`MedicationAdministration` for every dose actually taken or skipped.
- **`scripts/assert-deploy.ts`** — a deploy-verification script that checks a target
reports the expected version after a release.
### F2 — `MeasurementSource` enum expansion
### Changed
- [x] `APPLE_HEALTH` appended to the enum
- [x] UI badge in measurement-list (Dracula-pink chip) + bilingual
`measurements.sourceAppleHealth` keys + admin restore-route
enum guard sync
- **Medication advanced-settings page rebuilt** — import / intake-import / export in
their own group; the external-API endpoints listed one by one with collapsible
request examples; tidier layout.
- **Targets edited inline** — the standalone `/targets` page is retired; a metric's
target range is set from the metric itself.
- **Health-record export emits insurer Coverage from a bare KVNR** (not only with a
full insurer organisation present) and carries a top-level Composition narrative
summary.
### F3 — `POST /api/measurements/batch`
### Fixed
- [x] New route at `src/app/api/measurements/batch/route.ts`,
idempotent via `Idempotency-Key`
- [x] Validates batch ≤ 500 entries (returns 422
`coach.batch.too_large`)
- [x] Dedupes via `(userId, type, source, externalId)` composite
- [x] Returns per-entry status (`inserted` / `duplicate` /
`skipped`) + idempotency-replay-safe
- [x] Integration test (`tests/integration/measurements-batch.test.ts`,
6 cases including idempotency replay)
- [x] Sleep-stage aggregation in `/api/analytics`:
per-Berlin-day summary + `sleepStages` block
(`tests/integration/analytics-sleep-stages.test.ts`)
- Detailed report: `.planning/phase-W2-v1423-report.md`
- **App stays responsive under load** — `DB_CONNECTION_LIMIT` is now sized for real
concurrency (the 9 default could starve while a background insight warm and a
foreground request competed, surfacing as the self-recovering "server not
responding"); tunable for larger self-hosts.
- **Background insight warm decoupled + sync-burst invalidation debounced** — a warm
no longer blocks a page, and a burst of Apple Health / Withings sync no longer
triggers a storm of regenerations.
- **Medication card rows hold an equal height** ("last dose" / "next dose" alignment).
- **Glucose entered in mmol/L converts correctly** in the editor.
- **Scrollbar-gutter reserved** — admin + global layout no longer shifts when a
scrollbar appears.
- **Six legacy `*-status` routes documented** in the OpenAPI contract.
## Wave 3 — APNs scaffolding (F4)
---
- [x] Library install + lock-file (`@parse/node-apn@8.1.0`,
W1 stream-2 decision)
- [x] `src/lib/notifications/senders/apns.ts` — `sendApnsPush()`
one-shot helper plus `sendViaApns(userId, payload)` dispatcher
entry mirroring the web-push contract
- [x] Env-var contract: `APNS_KEY_ID`, `APNS_TEAM_ID`,
`APNS_BUNDLE_ID`, one of `APNS_KEY` / `APNS_KEY_FILE`,
optional `APNS_PRODUCTION` — documented in `.env.example`,
all-or-none guard with one-shot warning when partially set
- [x] `Device` Prisma model extension via migration
`0037_apns_device_columns`: nullable `apnsToken` +
`apnsEnvironment`, paired CHECK constraint, single-column
index on `apns_token` for the dispatcher fan-out lookup
- [x] Dispatcher wiring: `APNS` joins the cascade as channel-type
4; cascade order is now deterministic
(APNs → Telegram → ntfy → Web Push)
- [x] Mock provider at `src/lib/notifications/senders/__mocks__/apns.ts`
with queued per-token responses + recorded calls
- [x] Integration test
`tests/integration/apns-dispatch.test.ts` — round-trip,
hard-reject + cleanup, cascade fall-through (3 tests)
- [x] `POST /api/devices` accepts paired `apnsToken` +
`apnsEnvironment`, enforces hex format, applies the
cross-user-hijack guard at the APNs-token layer
(409 `apns_token_owned_by_other_user`)
- Detailed report: `.planning/phase-W3-v1423-report.md`
## What is live (the release arc since v1.4.23)
## Wave 4 — OpenAPI + Coach schema + native auth + Coolify
Coarse summary; CHANGELOG.md is the accurate per-release record.
### F5 — OpenAPI 3.1 generator + CI gate
- **v1.4.24 → v1.4.50** — perf + rollup tiers (measurement / mood / compliance /
cumulative-sum rollups, read-swap with live fallback, auto-converging boot
backfill), APNs delivery + per-channel test endpoint + `push_attempts` ledger,
server-side reminder suppression (`clientManaged`), self-healing stale-shell after
deploy, full localisation push, MoodLog reverse-sync.
- **v1.5.x** — native iOS client public beta + Apple Health sync (`POST /api/measurements/batch`,
per-day cumulative `stats:` overwrite), medication scheduling (RRULE / rolling /
one-shot lifecycle, creation wizard → modal-dialog compose-mode), per-day-cumulative
step consolidation, `safeFetch` egress hardening, self-hosted avatar storage,
`SESSION_COOKIE_SECURE` plumbed through compose.
- **v1.6.0** — medication editor overhaul + route of administration (`deliveryForm`,
migration `0088` ORAL backfill), one-time injection, today-tile read-flip onto the
canonical recurrence engine, profile-photo upload.
- **v1.7.x** — health-record export (enriched PDF + HL7 FHIR R4 bundle), PRN + cyclic
schedules, unified dashboard snapshot + nightly insight pre-generation, offline sync
delta feed (tombstones), full HealthKit chart coverage + display-unit preference,
Coach data clustering, multi-time compliance fixes.
- **v1.8.x** — Insights big release (reliable data-driven assessments, graded
compression, embedded targets, explainers, English slugs + tile-id aliases),
multi-time compliance, one-intake-row-per-dose-slot medical fix, injection-site
tracking, FHIR codesystem for HealthKit-only metrics, FHIR `Coverage`, instant
assessments (stale-while-revalidate), GLP-1 blood-level chart.
- **v1.8.7.1** — a plain-language assessment on every HealthKit metric page (~30
metrics), one-tap pre-generation of every assessment.
- [x] Tool wired (W1 decision — `zod-openapi` (samchungy) + `yaml@^2`)
- [x] `pnpm openapi:generate` script emits
`docs/api/openapi.yaml` from Zod schemas
- [x] CI step compares generated vs committed; PR warns on drift
(`continue-on-error: true` — flips to hard-fail in v1.4.24+)
- [x] Legacy hand-maintained spec preserved at
`docs/api/openapi-v1422-legacy.yaml` during the incremental
migration
apps01 + demo are LIVE on the latest tag; v1.9.0 deploy is pending the tag.
### F6 — Coach + Daily Briefing schema slot for new metrics
---
- [x] `aiInsightResponseSchema.dailyBriefing.keyFindings[].sourceMetric`
enum extends to include the 9 Apple Health categories
(hrv, sleep, resting_hr, steps, active_energy, flights,
distance, vo2_max, body_temp)
- [x] `trendAnnotations` schema mirrors the same additive enum
- [x] `coach/snapshot.ts` queries the new measurement types when
scope toggles them on; web-only accounts pay zero extra SQL
- [x] `CoachProvenance.metrics` + `counts` extended symmetrically
- [x] PROMPT_VERSION 4.22.0 → 4.23.0 with new GROUND RULE 12
("treat Apple Health categories as silent when absent")
- [x] EN + DE prompt bodies, OUTPUT FORMAT block, and i18n strings
all updated
## Deferred set (carry-forward from v1.9.0)
### F7 — Native API hardening (refresh-token per-device)
| Item | Where it goes | Note |
|---|---|---|
| Derived / synthesized wellness metrics | **v1.10.0** | per-metric Vitals dashboard first, then composites with coverage/confidence gating. Plans in `.planning/v1.10-derived-metrics-PROPOSAL.md` + `.planning/v1.9.1-derived-metrics-IMPLEMENTATION-PLAN.md` (targeted at v1.10.0) + `.planning/v1.10-research/*`. |
| Docs / provenance audit | **v1.10.0** | docs site + in-repo `docs/` + README + OpenAPI drifted across the v1.8.x → v1.9.0 arc; audit + continue, and surface every derived metric's sources via the explainer pattern. Requirement: `.planning/v1.10-research/REQUIREMENT-docs-and-provenance.md`. |
| Discoverability / growth workstream | **v1.10.0** | standing alongside the derived-metrics milestone. |
| BYOK AI fallback | open | resilience gap — no provider fallback is configured, so a single provider outage stalls assessment generation. |
| `MedicationAdministration` per-export cap tuning | open | the per-dose administration emission needs a sensible cap for high-volume histories. |
| SNOMED route / site upgrade | open | route-of-administration + injection-site could carry SNOMED codes rather than the current local enum. |
| German bfarm ATC URI behind a locale toggle | open | the export codes ATC against the WHO system; a German deployment may prefer the bfarm URI — gate behind a locale toggle. |
- [x] Refresh-token reuse-detection switches from "revoke all" to
per-device-token revocation (legacy null-deviceId tokens fall
back to user-wide revoke — safety hatch)
- [x] New route `GET /api/auth/me/devices` lists active devices
with last-seen, label, channels, isCurrent marker
- [x] New route `DELETE /api/auth/me/devices/[id]` revokes one device
(refresh + access tokens + Device row)
- [x] Alternate `DELETE /api/devices/[id]` for the iOS token-rotation
cleanup path (mirror of the auth/me variant)
- [x] Ownership-boundary 404 on cross-user attempts
---
### F8 — Coolify auto-deploy fix for real
## Next milestone — v1.10.0
- [x] `COOLIFY_WEBHOOK` + `COOLIFY_TOKEN` GitHub repo secrets
documented (maintainer action; see
`.planning/coolify-auto-deploy-howto.md` for the verbatim
recipe)
- [x] Coolify "Watch image registry for new digests" UI toggle
documented as the load-bearing piece (maintainer action — can't
be flipped from CI)
- [x] Workflow step gains a `::notice::` line so future runs surface
the deploy timestamp + sha without opening the verbose log
- [x] Verification recipe (`curl /api/version | jq .data.version`) + host-side fallback documented inline
## Wave 5 — Hygiene (H1-H7)
- [x] H1 sentinel parser malformed-enum hardening
(`SentinelParseResult.malformedEntries[]` + per-line typed
reasons; chat route splits `coach.keyvalues.parse_partial` from
the full-block `coach.keyvalues.parse_failed`)
- [x] H2 analytics-route unbounded `findMany` pagination
(cursor-paged 5 000-row chunks via `fetchBpSeriesChunked`,
`analytics.bp_in_target.row_count` wide-event meta for slow-
query attribution; integration test seeds 6 000 rows across a
chunk boundary)
- [x] H3 `<CoachDrawer key={prefill}>` controlled-prop refactor
(`useResettableValue` hook + pure `nextResettableValue` helper;
`key={prefill}` removed from the parent mount)
- [x] H4 per-user prompt-tuning surface (Coach settings cog return)
(`User.coachPrefsJson` migration `0038_coach_prefs`; new
`GET/PUT /api/auth/me/coach-prefs`; settings cog opens a
right-edge `<Sheet>`; system-prompt prepends a per-user
OVERRIDE; snapshot reads prefs BEFORE measurement queries so
`excludeMetrics` filters before the snapshot lands)
- [x] H5 schema drift on `medication_schedules.days_of_week`
(decision: deploy — column is referenced by 9 source files
across 4 user-visible surfaces; migration
`0039_medication_schedule_days_of_week`, NULL = daily)
- [x] H6 Pearson p-value normal-approx replacement
(raised `MIN_PAIRED_N` from 14 → 20 — conservative patch;
rigorous incomplete-beta queued as a v1.4.24 candidate)
- [x] H7 Coach helpful/unhelpful first-week observation view
(polymorphic `RecommendationFeedback.target_type` migration
`0040_recommendation_feedback_target_type`; new POST
`/api/insights/chat/messages/:id/feedback`; aggregator slice
`buildCoachFeedbackBuckets` by (promptVersion, tone, verbosity);
admin section `/admin/coach-feedback` with the H7 i18n bundle
both EN + DE)
W5 commits (8): 58ae9bc, fa07748, 9413d29, 3f60c81, 0eda1de, 1faee95,
05c7f14, plus this STATE tick.
Test deltas: 2191 → 2223 unit (+32). Integration suite added 3
files: `coach-prefs`, `coach-feedback`, plus the 6000-row chunk
boundary regression in the existing `bp-in-target` test.
OpenAPI registry coverage: +3 routes (GET / PUT
`/api/auth/me/coach-prefs`, POST
`/api/insights/chat/messages/{id}/feedback`).
## Wave 6 — Multi-agent QA + Product-Lead review
- [x] code-reviewer
- [x] security review
- [x] design / UX review
- [x] senior-dev review
- [x] simplify
- [x] Product Lead — v1.5 P1 (iOS first launch) plan refresh with
concrete file paths now that backend contracts exist
- [x] Reconcile — 1 CRITICAL + all 9 HIGH + 4 of 5 simplify + 3 MED
applied; remainder triaged into `.planning/v1423-backlog.md`
W6 reconcile commits (11): 5486507, 13977bc, d5202e5, a2dfc5e (Session A
— CRIT + HIGH 1-3); 3ee6dab, dfffe6b, d25e50e (Session B — HIGH 4 +
S-02-S-04 simplify + HIGH 6); deadc73 (Session D — HIGH 5); 650f150
(Session E — HIGH 7); 1a46bfe (Session F — MED cluster); plus this
STATE tick.
Detailed reconcile report: `.planning/phase-W6-v1423-reconcile-report.md`.
Deferred items: `.planning/v1423-backlog.md` (settings-cog vs
per-message-controls debate, rigorous Pearson incomplete-beta, surplus
MEDs, all LOWs, S-05 simplify, pre-existing `coach-prefs.test.ts` mock
issue, sandbox `git commit` no-op).
Test deltas: W5 ended at 2223 unit. Session A added the `revoke.test.ts`
- `apns-dispatch` device tests landing at 2227. Session E ended at 2235.
Session F added the forged-X-Device-Id regression — final at 2236 unit
(+45 across the W5+W6 reconcile arc).
## Wave 7 — Release v1.4.23
- [x] Pre-release verify (typecheck / lint / openapi:check / 2236
unit / 110 of 112 integration — 2 failures are the pre-existing
`coach-prefs.test.ts` `NextRequest` URL mock carryover from
v1.4.22, documented in `v1423-backlog.md`, non-blocking)
- [x] Format sweep commit (`f1e6630` prettier sweep on `develop`) +
W1 research + W5/W6 review packs commit (`766a9ae`) +
OpenAPI regen (`4183552`)
- [x] Bump `package.json` 1.4.22 → 1.4.23 + CHANGELOG (`d2331d9`
`chore(release): v1.4.23`)
- [x] Release-merge develop → main (`0dc0e16 Release v1.4.23`)
- [x] Tag + push v1.4.23
- [x] GHCR build green
- [x] Coolify deploy + `/api/version=1.4.23` confirmed
- [x] Production smoke (gated routes 307; `/api/version=1.4.23`)
- [x] GH release published
- [x] Docs site + landing site sync (image pins 1.4.22 → 1.4.23 +
ai-insights v1.4.23 callout; softwareVersion JSON-LD bump)
- [x] `docs/audit/v1423-summary.md` release brief
- [x] v1.5 P1 plan refresh recorded
(`.planning/phase-W6-v1423-product-lead-review.md` section C)
Derived / synthesized wellness metrics, honest on the daily-snapshot data HealthLog
actually stores; full provenance + standards-linking; full docs audit;
discoverability / growth. See `ROADMAP.md`.
</content>
</invoke>
@@ -0,0 +1,80 @@
# v1.8.0 — server → iOS: Insights tile-id rename (non-breaking)
Date: 2026-05-31
From: server
To: iOS
Status: informational — no action required to keep working; action recommended before a future major
## Summary
v1.8.0 renames the Insights tile ids (and the matching web route slugs) from
German to English to make the identifier space consistent. **This is
non-breaking for the iOS layout sync.** The `GET/PUT /api/insights/layout`
endpoint keeps accepting the old German ids. You do not need to ship anything to
stay working. When convenient, migrate to the canonical English ids.
## What changed
The canonical tile ids in `GET/PUT /api/insights/layout` are now English:
| Legacy (≤ v1.7.x) | Canonical (v1.8.0+) |
|---|---|
| `blutdruck` | `blood-pressure` |
| `puls` | `pulse` |
| `sauerstoff` | `oxygen` |
| `koerpertemperatur` | `body-temperature` |
| `gewicht` | `weight` |
| `aktive-energie` | `active-energy` |
| `schlaf` | `sleep` |
| `ruhepuls` | `resting-pulse` |
| `stimmung` | `mood` |
| `medikamente` | `medications` |
`overview`, `bmi`, `hrv`, and `workouts` were already language-neutral and are
unchanged.
## Why it does not break you
- **PUT still accepts the legacy German ids.** The server's validation enum is
the union of the canonical English ids and the legacy German aliases, so a
`PUT` carrying `blutdruck` / `puls` / … validates (no 422).
- **The server normalises legacy → canonical on write.** A `PUT` with German ids
persists the English equivalents. So after any save, the stored layout is
canonical English regardless of what you sent.
- **GET always returns canonical English ids.** A layout you persisted before
v1.8.0 (German ids) is normalised on read — `GET` surfaces the English ids
without forcing a re-`PUT`.
- **Dedupe.** If a payload carries both a legacy and a canonical id for one tile,
the server keeps a single canonical entry.
So the worst case for an un-migrated iOS build is: it keeps sending German ids,
the server keeps accepting and normalising them, and on the next `GET` the app
sees English ids it does not recognise in its local enum. If your client filters
unknown tile ids defensively, that filter must learn the English ids — see
below.
## What iOS should do (recommended, before a future major)
1. **Add the English ids to your tile-id enum / model** (keep the German ones as
decode-only aliases if your decoder is strict, or just map both).
2. **Send English ids on `PUT`.** Once your build is out, you stop relying on the
server's alias acceptance.
3. **Decode `GET` responses expecting English ids.** Because the server
normalises on read, a current `GET` already returns English; an app that hard-
filters to the old German enum would drop every tile. Decode the English ids
before you rely on this endpoint in a v1.8.0+ environment.
## Deprecation timeline
The legacy German ids are accepted-but-deprecated. They will be **removed from
the accepted input set in a future major version** once we confirm clients have
migrated. No date is fixed yet; we will give notice through this channel before
the removal. Until then, both id sets validate.
## References
- Contract: `docs/api/openapi.yaml` → `InsightsLayoutBody` (lists canonical +
legacy ids, with the deprecation noted in the schema description).
- Rationale: `docs/adr/0001-insights-naming-convention.md`.
- Web route slugs got the same English rename with 301 redirects from the legacy
German URLs; that is web-only and does not affect the iOS contract.
@@ -0,0 +1,82 @@
# v1.8.5 — server → iOS: injection-site capture on intake
**Direction:** server → iOS
**Status:** additive, non-breaking. iOS adoption is optional.
## What changed
Injection-site tracking is now real on the server. The `injectionSite`
column on the intake event existed but was never written; v1.8.5 makes
the capture + rotation path end-to-end.
### 1. New optional request field: `injectionSite`
`POST /api/medications/{id}/intake`, `POST /api/medications/intake`
(status toggle), and `POST /api/medications/intake/bulk` now accept an
optional `injectionSite` on a **taken** write. Enum values:
```
ABDOMEN_LEFT, ABDOMEN_RIGHT, ABDOMEN_UPPER_LEFT, ABDOMEN_UPPER_RIGHT,
THIGH_LEFT, THIGH_RIGHT, UPPER_ARM_LEFT, UPPER_ARM_RIGHT
```
The field is **optional everywhere** — omit it and the dose records
exactly as before. There is no behaviour change for any existing client
that never sends it. (It is the same enum the response already echoes via
the GLP-1 detail endpoint, so the type is already known to the client.)
### 2. Server-side gating + validation (important for iOS UX)
The server only persists `injectionSite` when ALL hold:
- the dose is being recorded **taken** (never on a skip),
- the medication is `deliveryForm === "INJECTION"`,
- the medication has `trackInjectionSites === true`,
- the submitted site is in the medication's **effective allowed set**.
The effective allowed set = the per-medication `allowedInjectionSites`
(empty = all eight) **minus** the user-level
`globalExcludedInjectionSites` deny-list. **Deny always wins**: a site the
user globally excluded is never valid even if a medication lists it as
preferred.
A submitted site outside the effective set:
- per-medication + status-toggle routes → **422**
`errorCode: "medications.intake.injection_site.disallowed"`.
- bulk route → that entry is reported `status: "skipped"`,
`reason: "injection_site_not_allowed"` (the batch still succeeds).
A site sent for a non-injection / tracking-off medication, or on a skip,
is silently dropped (the dose still records, no error).
iOS should only offer the picker when the medication is an injection with
tracking enabled, and restrict the choices to the effective allowed set,
so the 422 path stays an edge case (stale UI / race).
### 3. New medication fields (read + write)
`GET /api/medications` and `GET /api/medications/{id}` now echo:
- `trackInjectionSites: boolean` (default `false`)
- `allowedInjectionSites: InjectionSite[]` (default `[]` = no restriction)
`POST /api/medications` + `PUT /api/medications/{id}` accept both as
optional fields (field-by-field; `false` / `[]` are valid explicit
values — `false` deactivates tracking, `[]` clears the per-med list).
### 4. New user-level endpoint
`GET` / `PATCH /api/auth/me/injection-site-prefs`
```
{ "globalExcludedInjectionSites": InjectionSite[] }
```
The PATCH hard-sets the deny-list (empty array clears it). 60/min rate
limit, same envelope as `unit-preference`.
## Contract source
All of the above is in `docs/api/openapi.yaml` (regenerated this
release). No field was removed or renamed; every addition is optional /
defaulted. Existing iOS builds keep working untouched.
@@ -0,0 +1,67 @@
# v1.8.5 — server folds high-frequency mean metrics nightly (informational, no iOS change)
Direction: server → iOS. Status: informational + optional. No breaking
change. iOS needs zero changes for correctness.
## What changed on the server
The server now consolidates the high-frequency HealthKit "spot" metrics
into one canonical daily row per metric per day, server-side, on a nightly
cron — the same treatment cumulative metrics (steps, energy, distance)
already get. The daily reduction is the **MEAN** of the day's samples,
written at local-noon under the existing
`stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>` externalId shape, and the
per-sample rows are soft-deleted (tombstoned, pruned later).
v1.8.5 extends the consolidated set to cover the gait/mobility metrics that
previously piled up at sampling granularity:
- `WalkingAsymmetryPercentage` (WALKING_ASYMMETRY)
- `WalkingDoubleSupportPercentage` (WALKING_DOUBLE_SUPPORT)
- `AppleWalkingSteadiness` (WALKING_STEADINESS)
- `WalkingHeartRateAverage` (WALKING_HEART_RATE_AVERAGE)
joining the metrics already folded since v1.7.0: `RespiratoryRate`,
`EnvironmentalAudioExposure`, `HeadphoneAudioExposure`, `WalkingSpeed`,
`WalkingStepLength`.
The fold now runs on the nightly maintenance tick (alongside the cumulative
drain), not just at worker boot. A 36-hour grace window keeps the current
day's in-flight watch syncs raw so the live "today" view stays current.
## What this means for iOS
**Nothing is required.** Keep sending these metrics as per-sample rows with
opaque UUID externalIds exactly as today. The server folds them nightly;
the daily read path averages over the single consolidated row, so the
metric pages and detail history read consistently with no client change.
Existing accumulated per-sample rows are back-consolidated automatically on
the first deploy by the boot-time discovery pass — no migration, no manual
backfill.
## Pulse is unchanged — still raw
Regular heart-rate / `PULSE` is deliberately NOT consolidated. It is a
discrete clinical reading and feeds correlation + scatter analytics, which
read raw PULSE rows. Keep posting PULSE per-sample as today. Its display
already daily-averages via the read-path AVG; storage stays raw.
## Optional future optimisation (not required, not yet scheduled)
If iOS later wants to cut upload volume for these mean metrics, it MAY send
them pre-aggregated as a daily value under the same `stats:` shape it
already uses for cumulative metrics — e.g.
`stats:HKQuantityTypeIdentifierWalkingSpeed:2026-06-01`. The server's batch
route already routes any `stats:*` externalId to the idempotent overwrite
path, so no server change is needed to accept it.
One caveat if iOS adopts this: for a mean metric the daily `stats:` value
must carry the **average**, not the sum. Compute it on-device with
`HKStatisticsCollectionQuery` using the `discreteAverage` option (NOT
`cumulativeSum`, which is correct only for the cumulative metrics). The
server-side nightly fold remains the safety net for any client not on that
contract, so this stays a pure bandwidth optimisation rather than a
correctness dependency.
No `docs/api/openapi.yaml` change — the batch contract is unchanged.
+255
View File
@@ -1,5 +1,260 @@
# Changelog
## [1.10.2] — 2026-06-03 — Honest AI connection test, consistent insights, retention re-enabled
### Fixed
- **The AI connection test now tells you what actually went wrong.** A failed test used to surface a cryptic parse error ("unexpected token") because the server answered with a gateway-level error page the settings screen could not read; worse, the test probed a different provider than the one your nightly insights are generated with, so it could report a problem with a fallback you do not even use. The test now always returns a readable, translated reason — rejected credentials, rate-limited, a provider server error, or unreachable — and it checks the same provider your insights run on, so the result is the truth about your setup.
- **Every insight metric page carries the same time-range controls.** The core-metric pages (weight, blood pressure, pulse, BMI, sleep) were missing the period selector that the other metric pages already had; they now match. Drill-down back-links are consistent and a "show all values" link returns you to the metric you came from.
- **The derived-metrics dashboard no longer collapses or jumps while loading.** It shows a placeholder row at the right height, keeps its heading hidden until there is something to show, and offers an inline retry if the data fails to load instead of silently vanishing.
- **More status colours meet contrast on the light theme** across the medication and admin surfaces.
### Changed
- **The intra-day retention drain is back on.** The storage optimisation behind the stress reading was switched off in 1.10.0.2 after a daily-summary collision kept the app cycling post-deploy. The fold now adopts the existing daily row deterministically and recovers cleanly if a concurrent write reaches the same slot first — verified against real data — and the start-up defer that protects the connection pool stays in place.
- **The batch-ingest and health-record export endpoints cap request-body size**, rejecting an oversized payload before parsing it.
### Added
- **A capability endpoint for API clients.** `GET /api/meta/capabilities` returns the server's live metric, tile, ingest and FHIR vocabularies alongside a contract version, so a client renders what it recognises and ignores what it does not — no more transcribing each release's new identifiers by hand.
## [1.10.1] — 2026-06-03 — Consistent medication cards
### Fixed
- **Every medication card looks the same now, whatever the type.** Oral, injection, GLP-1, as-needed, cyclic and one-time medications previously drew their "next / last intake" line, dose accent, spacing and compliance bars slightly differently, so cards in the same row could sit at different heights with the action buttons misaligned. The cards now share one layout: the same intake line, a reserved-height compliance area, and the take/skip buttons pinned to the bottom — so a row of mixed medications lines up cleanly. The dose accent also meets contrast on the light theme.
## [1.10.0.2] — 2026-06-03 — Disable the intra-day retention drain
### Fixed
- **Stops the post-deploy restart loop for good.** The intra-day retention drain (a storage optimisation behind the new stress reading) folded older samples into a daily summary, but on real data that summary collided with an existing daily row and the drain failed on every run, which kept the app cycling after a deploy. The drain is now switched off until it is reworked and tested against real data; the nightly pass is skipped, nothing is queued at start-up, and any work already queued completes harmlessly. No user-facing feature is lost — the stress reading simply falls back to its sparse-data behaviour.
## [1.10.0.1] — 2026-06-03 — Defer the intra-day retention drain past startup
### Fixed
- **The app no longer turns unresponsive shortly after a deploy or restart.** The new intra-day retention drain ran its catch-up pass the moment the background worker started, at the same time as the database migration, the other start-up backfills, and the first health checks — all sharing one connection pool. On an instance with a lot of history this exhausted the pool, so the app stopped answering until it restarted, then repeated. The catch-up now waits until start-up has settled before it runs; it is unchanged otherwise and the nightly pass is unaffected.
## [1.10.0] — 2026-06-03 — Derived wellness metrics, device-event awareness, deeper ingestion
### Added
- **Derived wellness metrics, each shown with how it is worked out.** From data you already sync, the Insights surface now derives a personal typical range for a vital, a cardio-fitness band, a vascular-age delta, an HRV (SDNN) balance read, body-mass index, a sleep score, a readiness index, and a coincident-deviation flag that notices when several vitals sit off their personal range at once. Every metric states its inputs, its method in plain language, and the published standard it is grounded in, with an honest read of how much data backs it — never a bare number. A metric appears only when there is enough history to mean something.
- **Recovery, Stress, and Strain scores, computed each night.** Recovery blends your resting-heart-rate, HRV, sleep and respiratory trends against your own baseline; Strain reads the heart-rate load of your workouts; Stress is an HRV-derived proxy and is labelled as exactly that — not a sensor-measured stress reading. Each is stored as a first-class value you can chart, and each opens to a breakdown of its contributors and its cited basis.
- **More of what your watch already measures.** Cardio recovery, overnight wrist temperature, fall count, six-minute-walk distance, stair ascent and descent speed, and the sleep breathing-disturbance index are now ingested and surfaced.
- **An awareness timeline for device-flagged events.** Irregular-rhythm, high- and low-heart-rate, walking-steadiness, and breathing-disturbance notifications your device produced are shown as a timeline — strictly as awareness of your own device's certified result, never re-assessed and never a HealthLog diagnosis, with a permanent disclaimer to that effect. The section stays hidden until there is something to show.
- **A per-workout heart-rate series**, so an indoor workout with no GPS still yields a training-load signal.
- **A wellness summary in the doctor report.** The derived scores and metrics appear in their own section, kept out of the clinical vitals table, labelled descriptive rather than clinical, and exported to FHIR as `survey`-category observations with the same note — so a clinician's system never mistakes them for a vital sign or a diagnosis.
### Changed
- **The health-record export carries richer medication codings.** A `MedicationAdministration` now adds a SNOMED route and injection-site coding alongside the existing plain text, a German-locale export can append the German BfArM ATC coding next to the unchanged WHO ATC entry, and the number of administrations an export can carry is raised and made configurable by the operator.
- **The Insights overview reads its metrics through a single request** instead of one per tile, so opening the page no longer fans out into a burst of concurrent reads.
- **Score-band colours meet contrast on the light theme**, and the device-notification copy — including its medical disclaimer — is translated across every supported language.
## [1.9.3] — 2026-06-02 — Medication card layout polish
### Fixed
- **The medication cards look right again.** A recent alignment change pushed each card's action buttons to the very bottom of the cell, which left an empty gap between a shorter card's content and its buttons when it sat next to a taller card in the grid. The buttons now sit directly under the card's content again; the cards still share a height and the dose rows still line up, without the gap.
## [1.9.2] — 2026-06-02 — Document the medication compliance endpoint
### Changed
- **The medication compliance endpoint is now in the public API contract.** `GET /api/medications/{id}/compliance` was missing from the OpenAPI document, so a client generating its types from the contract could mistake the separate cadence endpoint (a different shape) for it. The route is now documented with its exact response — the 7- and 30-day adherence summaries, the per-day compliance grid, and the two-row display block — with the field a client should read for the headline 30-day rate and how to draw the per-day history. No behaviour change; the handler was already correct.
## [1.9.1] — 2026-06-02 — No assessment warm on a page visit
### Fixed
- **Opening the Insights overview no longer regenerates every assessment.** A returning visit fired a one-per-session background warm that asked the provider to regenerate the comprehensive briefing and every category and metric assessment at once — a burst that could run for minutes and make the whole app feel unresponsive while it ran, recovering on its own once it finished. Assessments are kept fresh by the nightly pass, and each metric's text refreshes gently on its own when you open it; the "prepare assessments" button still regenerates everything on demand. A page visit now only reads the cached text.
## [1.9.0] — 2026-06-02 — Insights time-ranges, deeper mood, medication coding, stability
### Added
- **A time range you can choose on the Insights pages.** Each metric now carries range pills (week, month, quarter, year) and shows how the period compares with the one before it — the change in your average, stated plainly with its direction — so a trend reads at a glance instead of by eye.
- **Deeper mood insights.** The mood page now surfaces your time-of-day pattern (when your mood tends to sit highest and lowest), a stability read, and correlation cards that line mood up against weight and blood pressure — each shown only when there are enough paired days to mean something.
- **Standard drug codes on a medication.** A medication can carry an ATC and an RxNorm code (entered, never guessed). These flow into the health-record export: the exported `MedicationStatement` now codes the drug with the WHO ATC system and RxNorm alongside the plain name, and the export adds a `MedicationAdministration` for every dose you actually took or skipped, so a clinician's system can read the record without relying on free text.
- **A deploy-verification script** (`scripts/assert-deploy.ts`) that checks a target reports the expected version after a release.
### Changed
- **The medication advanced-settings page is rebuilt.** Import, intake-import and export sit in their own group, the external-API endpoints are listed one by one with collapsible request examples, and the layout is tidied so the technical settings read clearly instead of crowding together.
- **Targets are edited inline where they belong.** The separate `/targets` page is retired — you set a metric's target range from the metric itself, with no detour to a standalone editor.
- **The health-record export emits insurer coverage from a bare insurance number**, not only when a full insurer organisation is present, and carries a top-level narrative summary.
- **Notification settings wording** is tightened for clarity.
### Fixed
- **The app stays responsive under load.** The database connection pool was sized far too small by default and could starve while a background insight warm and a foreground request competed, which showed up as the occasional "server not responding" that recovered on its own; the pool is now sized for real concurrency and tunable for larger self-hosts.
- **A background insight warm no longer blocks a page**, and a burst of Apple Health / Withings sync no longer triggers a storm of regenerations — the warm is decoupled and the invalidation is debounced.
- **The medication card rows stay aligned.** "Last dose" and "next dose" could break onto different heights and look misaligned; the rows now hold an equal height.
- **Glucose entered in mmol/L converts correctly** in the editor instead of being stored against the wrong unit.
- **The admin and global layout no longer shifts** when a scrollbar appears, by reserving the gutter.
- **The legacy status endpoints are documented** — six older `*-status` routes were missing from the API contract and are now included.
## [1.8.7.1] — 2026-06-02 — Assessments for every HealthKit metric, one-tap pre-generation
### Added
- **A plain-language assessment on every HealthKit metric page.** The HealthKit metric pages (resting heart rate, heart-rate variability, blood oxygen, glucose, body composition, gait, audio exposure, sleep, and the rest) showed charts but no written assessment, because the core metrics' bespoke per-metric prompts don't scale to thirty more. Each page now carries an assessment, generated from the metric's own normal range and the direction a healthy value moves, anchored on your individual baseline — the same treatment the core metrics already had. A metric with no data keeps its existing insufficient-data note.
- **One-tap pre-generation of every assessment.** A button on the Insights overview generates all assessments in the background so they are ready immediately the next time you open them. The overview also warms a cold cache on its own, and the nightly pass keeps everything fresh — so a page opens to its assessment instead of a wait.
### Changed
- **"Auswertungen" is now "KI-Auswertungen" / "AI Insights"** in the settings heading and the menu, so it's clear the section is AI-generated.
- **Targets and Sources are two separate settings pages** instead of one combined page, each with its own heading.
- **The back link sits above the heading** on a metric's detail page.
- **Dropdown fields have symmetric padding**, so the chevron no longer crowds the right edge.
- **The Insights layout accepts the full metric set**, so a saved layout (order and visibility) can include every metric page, not just the core ones.
### Fixed
- **Assessments generate again.** The assessment provider's accepted model identifiers had rotated, so generation failed and an assessment could sit on "preparing" indefinitely; the model ladder is updated and falls through to the current identifiers.
- **The Active Energy assessment loads.** Its page requested an assessment under a mismatched identifier and silently fell back to "no analysis yet"; the identifier is corrected and now type-checked, so the same class of mismatch is caught at build time.
- **The source-priority editor is fully translated** for Spanish, French, Italian and Polish — it was English-only after the settings split.
## [1.8.7] — 2026-06-02 — Instant insight assessments, GLP-1 blood-level chart, tile + caption polish
### Fixed
- **Insight assessments are instant again.** A category's assessment was being re-prepared on almost every visit: the constant Apple Health / Withings sync kept evicting the cached text, and a miss only ever deleted-and-waited. Assessments now serve the last good text immediately (stale-while-revalidate) and refresh in the background — "preparing" shows only when an assessment has genuinely never been generated. The background refresh is also debounced per metric, so a sync burst no longer triggers a storm of regenerations, and it warms only the active language (halving the work).
- **The dashboard tiles never truncate the value.** In a dense tile strip the number could clip or wrap mid-value (e.g. "130 mmHg" showing as "13"); the value now always renders in full and the unit yields the space instead.
- **GLP-1 medications show the drug-level-in-blood estimate by default.** The estimated concentration curve was hidden behind the Research-Mode opt-in and below the dose-strength curve; for GLP-1 medications it now shows by default, above the dose-strength curve, with the "educational estimate, not a measurement" disclaimer kept clearly attached.
- **The Trends captions read consistently.** Metrics without an advisor note previously rendered their caption in a different, plainer style that broke the row; every Trends caption now uses the same treatment.
### Changed
- **The mood assessment sits directly under the first chart** on the mood insights page.
## [1.8.6] — 2026-06-01 — Compliance two-row return, insights polish, targets deprecation
### Added
- **Mood narrative takeaways above the charts** — a ranked feed of plain-language findings (the weekday your mood dips, the overall trend, the tags that lift or lower it, your in-target share, your logging streak, the weekend effect), now drawing on the structured tags as well as free-text ones.
- **A GLP-1 dose-strength curve** that plots the titration history (2.5 → 5 → 7.5 mg …) in place of the previous empty therapy-course block.
- **A FHIR `Coverage` resource in the health-record export**, with an optional insurer institution number (IKNR) captured on the profile — making the insurer machine-resolvable for German systems.
- **An optional `source` on the measurements batch endpoint**, so a client backfilling manually-entered history can tag it correctly instead of defaulting to Apple Health.
### Changed
- **Medication compliance returns to two rows for every medication.** The cadence-aware timeline is gone; the two windows now scale to how often a medication is due — a daily medication keeps 7-day and 30-day, a rarely-taken one steps up to longer windows (up to twelve months) so both rows stay meaningful and the layout stays steady.
- **The medication advanced settings are a single, calmer column** with a symmetric danger zone (matched delete-history / delete-medication actions), replacing the cramped two-column layout.
- **The medication wizard uses a jumpable dot stepper** with a "next step" hint; in edit mode every step is reachable in one tap.
- **Insights category headers are quieter.** The question-mark circle is gone (the metric definition stays inline beneath the heading), the measurement-diversity hint moved to a heading lightbulb with the explanation on hover, the assessment border is de-emphasised, and "Coach fragen" moved to the header as an icon.
- **Target ranges are now edited inline from Insights.** The Targets page is deprecated — it carries a notice, is removed from the menu, and will be retired in a future release; editing happens where you read the trend.
### Fixed
- **Every briefing-driven Trend card now shows its caption** — additive metrics no longer render a chart with empty space below it.
- **A long mood note no longer stretches the entries table** — it truncates with the full text on hover.
- **The mood in-target share is shown once**, not duplicated between the headline tile and the takeaway feed.
- **Medication compliance no longer counts days before a medication existed**, so a newly-added medication's expected-dose figures reflect its real age.
## [1.8.5] — 2026-06-01 — Insights reference panels, mood depth, injection tracking
### Added
- **A target reference panel on every Insights category page.** Each metric now shows the target band, where the latest reading sits inside it, a plain-language status, the guideline source behind the range, the 30-day average and a seven-day consistency strip — the same reference the Targets page carries, now on the page where you read the trend. Blood glucose surfaces one panel per context (fasting, post-meal, random, bedtime).
- **A statistics strip per metric** — minimum, maximum, median and mean — plus a dedicated page that lists every underlying reading with edit and delete, reachable from each Insights category.
- **Injection-site tracking and rotation.** Opt-in per medication (injections only): record the site after a dose, see the history on a body map, and get a rotation suggestion. Sites can be restricted per medication, and an account-level exclusion always wins.
- **Structured mood tags and a per-entry note**, with a notes timeline so the context behind a mood is kept alongside the score.
- **A richer mood page** — calendar heatmap, score distribution, weekday pattern, and correlations with sleep and activity.
- **A measurement-diversity nudge** that suggests logging at other times or days when readings cluster, for a fuller picture.
### Changed
- **The medication compliance display adapts to how often a dose is due** — a percentage for frequent schedules, a dose-by-dose adherence timeline for sparse ones, so an every-few-weeks medication reads sensibly.
- **The Trends section now follows the daily briefing**, charting the metrics the briefing actually flags rather than a fixed set.
- **Tightened the Insights category pages and rebalanced the overview** for a denser, calmer layout.
- **Gait and mobility metrics from Apple Health are consolidated to a daily mean overnight**, while discrete clinical readings (weight, blood pressure, glucose, mood, pulse) stay raw.
- **The health-record FHIR export codes HealthKit-only metrics under a dedicated code system** instead of the LOINC namespace, for FHIR R4 conformance and alignment with the native client.
### Fixed
- **A rolling-interval medication now surfaces its first dose at the start date** — including when that date is already in the past — so the dashboard and reminders no longer skip the opening dose.
- **The medication wizard no longer shows each time-of-day suggestion twice.**
- **Deleting a medication no longer hangs on a spinner.**
- **Mood entries with tags save atomically** — the entry and its tags commit together, and create and update return the saved tags immediately rather than after a refresh.
- **Accessibility:** the injection-site picker has a visible focus state and screen-reader labels; mood tag chips and the status pill meet the contrast and minimum tap-target guidelines.
## [1.8.4] — 2026-06-01 — Medication card accuracy + Insights polish
### Fixed
- **The medication card shows the correct next intake for every schedule.** Flexible "interval after the last dose" and one-time medications previously showed a next-intake derived from a simplified day-of-week rule that ignored the interval and the last logged dose — so a medication taken every few weeks proposed tomorrow and never moved after a dose was logged. Both the standard and the injection card now read the schedule engine's computed next-due, so the next intake is right on creation and advances the moment a dose is recorded.
- **Insights status polling is bounded.** When a model is configured but generation stalls, the per-category assessment card no longer polls indefinitely on an open page — it stops after a fixed ceiling and shows its static state.
- **Clean deploys no longer hit a missing settings column.** Four `app_settings` columns existed in the schema but had only ever been applied ad-hoc, so a migrations-only deploy could fail on first read; an idempotent migration now adds them (a no-op on instances that already have them).
### Changed
- **Logging a dose confirms itself.** Marking a medication taken or skipped now shows a brief confirmation, and the card's next-intake updates immediately.
- **Each Insights category explains itself inline.** The short metric definition now also appears as a caption under the category heading, alongside the existing question-mark tooltip.
- **Medication history shows where each dose was logged** — web, the iOS app, a reminder, or an import — as a small origin label on each entry.
## [1.8.3] — 2026-06-01 — Insights no longer block the interface
Opening an Insights category and switching between them could freeze the whole interface for several seconds while the per-category assessment was generated. The interface now stays responsive throughout.
### Fixed
- **Switching Insights categories no longer freezes the interface.** Each category's short assessment is now served from cache as an immediate, read-only response; when it is not yet ready the page shows a brief preparing state and the assessment is generated in the background, so navigation and taps stay responsive. The client request is capped, so a slow or unreachable model can never block the interface — the worst case is a preparing state, never a frozen screen.
- **The overnight pre-generation warms the assessment actually shown.** The nightly warm pass keyed its cache differently from what the page requests, so a returning visit often fell back to an on-demand generation; it now warms the languages the interface uses and runs even when the daily summary was already cached, so a returning visit lands on a ready assessment.
- **A stalled model no longer re-blocks every visit.** A generation timeout is briefly remembered so the next visits return immediately instead of retrying the slow path each time.
- **Streamed loading states.** Insights and the dashboard now render a skeleton while their first segment loads, rather than waiting on a blank transition.
## [1.8.2] — 2026-06-01 — One intake row per dose slot
A multi-time medication could end up with two entries for the same dose — one still open, one marked taken — for a dose the user had not taken. This release guarantees a single entry per scheduled dose.
### Fixed
- **A dose slot can no longer hold a duplicate entry.** Logging a dose now updates the slot's existing entry in place instead of inserting a second row, regardless of where the log came from, and the logged time is aligned to the scheduled dose rather than the exact moment of the tap. A twice-daily medication no longer shows the same time twice (once open, once taken), the daily count is no longer inflated, and the "due now" prompt is no longer suppressed for a dose that has not actually been taken.
- **A recorded dose is never silently un-recorded.** A background re-sync that replays an open dose onto a slot already marked taken is now ignored rather than clearing the taken mark, so an offline sync can never flip a taken dose back to missed.
- **Concurrent logs converge.** Two logs landing on the same dose at once resolve to one entry instead of failing or duplicating.
- **Existing duplicates are cleaned up on startup.** A one-time pass collapses any duplicate dose entries already stored, keeping the recorded dose and correcting the affected compliance figures.
## [1.8.1] — 2026-06-01 — Multi-time medication compliance
A follow-up to the multi-time compliance work in 1.7.3, closing the two surfaces it did not reach.
### Fixed
- **Every scheduled time of a multi-time medication is now loggable** — a medication taken more than once a day on a single schedule with several times (e.g. 07:00 and 19:00) surfaced only its first dose in the today tile and the logging sheet, so the second dose could never be recorded and the day's compliance was counted against a single expected dose. The today-window projection now creates one pending dose per scheduled time, matching how reminders are issued.
- **The compliance heatmap classifies each dose against its own time** — a twice-daily dose logged in the evening was measured against the morning time and read as very late, painting the day amber even when both doses were taken on time. Each logged dose is now matched to its nearest scheduled time, so a day with every dose taken on time reads as on time. Single-time schedules are unaffected.
## [1.8.0] — 2026-05-31 — Insights: reliable data-driven assessments, embedded targets, explainers
The per-metric Insights pages become the coherent heart of the app. The data-driven assessment — a short, honest reading of your own trend — now reliably reaches the page: it previously timed out and cached a generic placeholder for the rest of the day, so most users only ever saw the platitude. The generation budget is aligned with the provider, the placeholder is never cached, and the AI payload is compressed into a graded shape (recent days raw, then weekly, monthly, and yearly aggregates drawn from the rollup tier) so a heavy logger no longer ships thousands of raw points. Each metric's prompt is rewritten to a consistent house style — name the finding, place it against your own baseline, offer one concrete step; honest on poor values, never alarmist, never diagnostic. Target ranges move onto each category page beside the chart, every category gains a plain-language "what is this?" tooltip, and the assessment is warmed overnight so the page is a cache read instead of a live call. Route slugs and tile ids move to English (German tile ids stay accepted as aliases, so existing clients keep working).
### Added
- **Per-category target ranges** — each Insights category page shows its numeric target range and the share of recent days within target, beside the chart and the assessment, with a link to adjust the range. The dedicated targets page remains the editing surface.
- **Per-category explainer tooltips** — a question-mark affordance next to every category heading opens a short, plain-language definition of the metric, available offline in all six languages and reachable by keyboard and touch.
- **Nightly assessment pre-generation** — a budget-gated overnight job warms the per-metric assessment caches (and invalidates them when fresh measurements arrive), so opening a category page is a cache read rather than a synchronous model call.
- **Graded measurement compression for assessments** — a shared compressor folds a metric's history into recent (daily), weekly, monthly, and yearly buckets, drawing the monthly/yearly tiers from the rollup store, so the model receives a compact picture instead of the full daily series.
- **Naming decision record** — `docs/adr/0001-insights-naming-convention.md` documents the convention: localised UI, English internal keys and route slugs.
### Changed
- **Data-driven assessments reach the page reliably** — the per-metric assessment generation budget is aligned with the provider (was capped well below the provider floor), runs through the provider chain with fallback, and a transient timeout or error is treated as a passing miss rather than cached as a placeholder for the rest of the day.
- **Assessment wording** — every metric prompt follows one house style (name → place against own baseline → one actionable step; 2–4 sentences; honest, autonomy-supporting, non-diagnostic; says so plainly when the data is too thin). Blood pressure reads systolic and diastolic together; weight tracks the trend while BMI speaks to threshold bands; the mood assessment carries a calm support cue on a sustained low.
- **Insights route slugs are English** — `/insights/<german>` moves to `/insights/<english>` with permanent redirects from the old paths. The dashboard-layout tile ids are English canonically; the previous German ids stay accepted as aliases and are normalised on read and write, so a client that stored the old ids keeps working.
- **Per-metric assessment caches drop on measurement changes** — a fresh, edited, or deleted measurement invalidates the affected category's cached assessment so it regenerates rather than serving a stale reading.
## [1.7.3] — 2026-05-31 — Health-record PDF fixes, multi-time compliance
Two fixes to live behaviour.
### Fixed
- **Health-record export renders in the account language** — the PDF came out in English regardless of the in-app language because the export only consulted the browser's `Accept-Language`. The client now sends the active language and the server falls back to the language cookie before the browser header.
- **Health-record export layout** — charts now label their time axis (start and end date of the plotted window), modules are kept together instead of breaking across a page, a consistent bottom margin is held on every page, and a glyph-encoding issue that stretched a few lines (trend arrows and the `m²` unit) is resolved by sanitising to the font's supported characters.
- **Compliance counts every scheduled time on multi-time plans** — a medication taken twice a day on a weekday plan with two times but no recurrence rule reported 50% even when both doses were logged: the expected-dose count and the taken count expanded the schedule through two different code paths. Both now run through the one canonical recurrence engine, so the rate is correct for every schedule shape.
## [1.7.2] — 2026-05-31 — Coach source parity, first-paint snapshot, medication card cleanup
A focused follow-up to v1.7.0. The unified dashboard snapshot that shipped in v1.7.0 is now the default, so a cold dashboard paints its above-the-fold tiles together instead of letting the mood tile arrive ahead of the rest. The Coach's in-chat data-source rail now reads and writes the same persisted data clusters as the settings sheet — one source of truth that survives a reload, so what the panel shows always matches what reaches the model. The medication overview cards collapse their action row into a single overflow menu, and the detail page is pared back to the intake history under a read-only plan summary. The advanced-settings sheet widens into a two-column layout and offers a medications export beside the existing import.
+7 -5
View File
@@ -18,11 +18,13 @@ If a draft commit message or release note would violate one of the three, rewrit
## Branch and release model
- `main` is release-only. Every commit on `main` is either a release commit (`chore(release): vX.Y.Z`), the matching tag push, or a maintenance commit (docs, gitignore, audit findings) that the maintainer chose to apply directly.
- Feature work targets the `develop` branch via topic branches (`fix/X`, `feat/X`), merged into `develop` through PRs with green CI.
- Releases merge `develop` → `main` (squash or merge-commit, document each), then a tag push (`v1.X.Y`) triggers the `docker-publish.yml` workflow.
- Hotfixes branch from `main`, merge back into both `main` and `develop`.
- Never force-push `main`. Never skip hooks (`--no-verify`). Never `--no-gpg-sign` unless explicitly asked.
`main` is the single trunk and is always releasable. There is no long-lived `develop` branch — the former one was retired and archived as the tag `archive/develop-pre-v1.5` (it carried no work absent from `main`; a long-lived integration branch has no role in a single-maintainer shop and only accrued back-merge debt). This is trunk-based development with release tags: short-lived branches off `main`, work always converges back onto `main`, and the immutable `vX.Y.Z` image built from a tag on `main` is the one source of truth — including for the OpenAPI contract the iOS client consumes.
- **Trunk.** `main` always builds, passes CI, and is deployable. Every commit on it is a release commit (`chore(release): vX.Y.Z`), a release-branch merge, or a maintenance commit (docs, gitignore, audit findings) applied directly.
- **A release** is built on a short-lived `release/vX.Y.Z` branch cut off `main` — built wave-by-wave, then opened as a PR `release/vX.Y.Z` → `main` with green CI, merged (merge-commit), and tagged `vX.Y.Z`. The tag push triggers `docker-publish.yml` (GHCR multi-arch + `:latest`). Deploy is manual (see `docs/ops/deploy.md`); after a deploy, verify `/api/version` (it returns `version` + `buildSha` + `builtAt`) on every target, since the `:latest` pull is the source of truth, not the queued deploy status. Delete the release branch after merge.
- **Small standalone changes** (a single fix, a docs touch) can go through a short-lived `fix/X` / `feat/X` branch + PR, or directly onto `main` when the maintainer chooses — `main` is the target either way.
- **Hotfixes** branch from `main` and merge straight back to `main`. There is no second long-lived line to forward-port to, so the old back-merge debt cannot recur by construction.
- Never force-push `main` or any release branch. Never skip hooks (`--no-verify`). Never `--no-gpg-sign` unless explicitly asked.
## Semver
+21 -4
View File
@@ -38,7 +38,7 @@
HealthLog is a self-hosted personal health tracker that runs from a single `docker compose up`. It covers the metrics most people actually log -- weight, blood pressure, pulse, body composition, blood glucose, sleep, mood, and medication compliance -- and brings them together in one dashboard with reference ranges from ESH 2023, ADA 2024, and NICE NG115. Withings devices sync automatically; an `export.zip` import folds your full Apple Health history into the same timeline; a native SwiftUI iOS client (public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa)) streams HealthKit live; multi-provider AI Insights (BYOK or local) explain what the numbers mean; a doctor-report PDF generates client-side. EN/DE end-to-end. AGPL-3.0.
> **Status**: active. New releases roughly weekly -- see [CHANGELOG](CHANGELOG.md). Current line: v1.7 — health-record export (PDF + FHIR R4), flexible medication schedules, full HealthKit metric coverage, and a first-paint dashboard snapshot. The native iOS client is public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa).
> **Status**: active. New releases roughly weekly -- see [CHANGELOG](CHANGELOG.md). Current line: v1.10 — a derived-metrics tier (fitness-age, vascular-age delta, HRV balance, sleep score, readiness and recovery scores, an early-strain flag), each computed from your own measurements and citing its inputs, on top of the v1.7 health-record export (PDF + FHIR R4), flexible medication schedules, and full HealthKit metric coverage. The native iOS client is public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa).
> **Heavily developed.** HealthLog ships multiple releases per week. Behaviour, API shapes, and database schema can change between minor versions; migrations are forward-only and not all are rehearsed against every legacy fixture. If you self-host, pin a tag, take a backup before every upgrade, and read the [CHANGELOG](CHANGELOG.md) before pulling `latest`. Issue reports and PRs welcome — this is the rough edge where the project gets sharper.
@@ -60,12 +60,26 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
| Open source | AGPL-3.0 | No | No | No | n/a |
| Withings device sync | Yes (OAuth2) | Yes (native) | Via shortcut | No | No |
| Apple Health import | Yes (`export.zip`) | No | Native | No | Manual |
| Blood pressure + glucose | First-class | BP only | Manual | No | Manual |
| Medication compliance | Cadence-aware | No | No | No | Manual |
| Derived wellness metrics | Yes (transparent) | Limited | Limited | Yes (closed)| No |
| Custom clinician targets | Yes (audit-logged) | Limited | No | No | n/a |
| Doctor-report PDF | Yes (client-side) | No | No | No | n/a |
| Doctor-report PDF + FHIR | Yes (client-side) | No | No | No | n/a |
| AI Insights | Multi-provider BYOK | No | Limited | Subscription| n/a |
| Subscription required | No | For some metrics| No | Yes | No |
| Your data leaves device | Never | Withings cloud | Apple cloud | Oura cloud | Depends |
### Versus WHOOP / Oura / Fitbit ecosystems
The wrist-band ecosystems are good at what their hardware measures, and HealthLog does not try to be a ring or a strap. It does not ship a sensor, and it does not claim to match one. What it does is take the readings those devices already collect — plus the ones they don't hold, like cuff blood pressure, finger-stick or CGM glucose, and medication intake — and keep them on a server you run.
- **You own the data and the host.** No vendor account, no cloud round-trip, no subscription tier gating a metric you already recorded. AGPL-3.0, single `docker compose up`, encrypted at rest, on your NAS / homelab / VPS.
- **It aggregates across providers instead of locking you to one.** Withings devices sync over OAuth2, an Apple Health `export.zip` folds your full history in, and the native iOS client streams HealthKit live — so a Withings scale, an Apple Watch, and a glucometer land on one timeline instead of three apps.
- **It carries context the wrist bands don't.** Blood pressure, blood glucose, body composition, and cadence-aware medication compliance sit next to the wearable signals, so a trend reads against the whole picture rather than activity and sleep alone.
- **The derived metrics are transparent, not a black box.** HealthLog computes a fitness-age and vascular-age delta, an HRV-balance and sleep-score band, a daily readiness and recovery score, and a coincident-deviation early-strain flag — each from your own measurements, each citing the inputs and the published method behind it, each degrading honestly to "not enough data" rather than fabricating a number. These are descriptive wellness framings, not clinical or training-grade assessments, and a score is only stored once its minimum inputs are present.
HealthLog is the right fit when you want your wearable and clinical numbers in one place that you control, with explainable analytics on top — not when you want a closed device that scores you and keeps the data.
---
## Key Features
@@ -86,6 +100,8 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
**AI Coach + Insights** -- A conversational Coach grounded in your own data, a daily briefing, a weekly report, and a Health Score tile on the dashboard. Pick OpenAI, Anthropic Claude, ChatGPT via Codex device-OAuth (no API key needed), or any OpenAI-compatible local endpoint (Ollama, LM Studio, vLLM). BYOK or admin-shared. Feed the Coach a chosen set of data clusters (cardiovascular, body composition, activity, workouts, sleep, mood, glucose, medication, mobility, environment) with a soft budget cap that degrades the lowest-signal clusters first. Every claim links back to the measurements that produced it. Local endpoints keep all data on your network.
**Derived wellness metrics** -- A transparent metrics tier computed from your own measurements: a fitness-age band from VO₂max, a vascular-age delta, an HRV-balance band, a sleep score, a daily readiness and a stored recovery score, plus a coincident-deviation early-strain flag. Each one re-frames or blends signals you already recorded against age/sex norms or your personal baseline, cites the inputs and the published method behind it, and returns "not enough data" rather than a fabricated value when its minimum inputs are missing. The recovery score persists as a daily 0–100 series the dashboard, charts, and native client read without recomputing. These are descriptive wellness framings — not clinical or training-grade assessments.
**Doctor Report PDF Export** -- Generate professional medical reports client-side. Locale-aware (English/German), with vital sign summaries, BP/BMI/glucose classification, compliance rates, custom-threshold badges, and optional AI analysis.
**Health-record export (PDF + FHIR R4)** -- `POST /api/export/health-record` produces a selectable export: an enriched clinical PDF, a machine-readable HL7 FHIR R4 document bundle (LOINC-coded observations, a BP panel, medication statements, a diagnostic report), or both packaged as one zip. The selection chooses date range and per-domain sections; both formats read the same aggregator so they describe identical numbers, and the optional AI summary is an explicit opt-in section marked as not clinically validated. Optional patient identity (name, insurer, insurance number) on Account feeds the report cover and the FHIR `Patient`.
@@ -488,7 +504,8 @@ For a single-process default the same container hosts both the web and worker (`
| ------------ | ----- |
| **v1.4.x** | Web maturity — Apple Health import, AI Coach, persistent rollup tier, multi-provider AI, doctor PDF, encryption-key rotation, Coolify autodeploy. Roughly weekly cadence. |
| **v1.5** | Native iOS client (SwiftUI) in public beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa). Backend contract locked in [`docs/api/openapi.yaml`](docs/api/openapi.yaml); the iOS app lives in a separate repository and ingests via the same `/api/measurements/batch` and `/api/auth/refresh` surfaces the web uses. |
| **v1.6 – v1.7** (current) | Medication editor overhaul + route of administration (v1.6), then health-record export (PDF + HL7 FHIR R4), flexible schedules (RRULE / rolling / interval-weeks / one-time / PRN / cyclic) with cadence-canonical compliance, full HealthKit metric coverage, a metric/imperial display preference, a first-paint dashboard snapshot, and a sync delta feed for offline clients (v1.7). |
| **v1.6 – v1.7** | Medication editor overhaul + route of administration (v1.6), then health-record export (PDF + HL7 FHIR R4), flexible schedules (RRULE / rolling / interval-weeks / one-time / PRN / cyclic) with cadence-canonical compliance, full HealthKit metric coverage, a metric/imperial display preference, a first-paint dashboard snapshot, and a sync delta feed for offline clients (v1.7). |
| **v1.8 – v1.10** (current) | Insights redesign with selectable time ranges (v1.8 – v1.9), then a derived-metrics tier (v1.10): fitness-age, vascular-age delta, HRV balance, sleep score, readiness and recovery scores, and a coincident-deviation early-strain flag — each computed from your own measurements, each citing its inputs and degrading to "not enough data" rather than fabricating a value. |
| **v2.x** (planned) | Multi-tenant hardening, expanded device passthrough (Garmin / Polar), opt-in cross-user aggregate research mode (off by default; never enabled without explicit consent). |
The detailed changelog lives in [`CHANGELOG.md`](CHANGELOG.md).
@@ -513,7 +530,7 @@ A SwiftUI iOS companion that lives on the same `/api` surfaces as the web client
### What the phone unlocks
- **HealthKit two-way sync.** Today's step count, weight, blood pressure, HRV, sleep, glucose, etc. read live from Apple Health and round-trip to your server through `HKObserverQuery` + `HKAnchoredObjectQuery` + a `BGProcessingTask` for guaranteed delivery. `HKMetadataKeyExternalUUID` on every iOS-side write keeps the server and Apple Health from echoing each other.
- **Medication reminders that actually fire.** Local notifications with action buttons (Genommen / Snooze 15 min / Übersprungen) that hit the server's mark-intake endpoint directly — without opening the app.
- **Medication reminders that actually fire.** Local notifications with action buttons (Taken / Snooze 15 min / Skipped) that hit the server's mark-intake endpoint directly — without opening the app.
- **On-device AI Coach.** A conversational surface powered by Apple's Foundation Models framework on iOS 26+ Apple-Intelligence-eligible iPhones. The prompt and the response never leave the device — your numbers, your health questions, your phone.
- **Passkey-first auth.** Sign in with Face ID or Touch ID via WebAuthn; email + magic-link is the fallback. SPKI public-key pinning on every authenticated request, Keychain with `afterFirstUnlockThisDeviceOnly`, and a log sanitizer that filters tokens and hostnames out of every line.
- **Doctor-report export.** Generate a FHIR-flavoured PDF bundle from the iPhone based on the LOINC mappings reviewed on the server's clinician surface.
+13 -1
View File
@@ -18,7 +18,14 @@ services:
ports:
- "3000:3000"
environment:
DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public"
# connection_limit is set explicitly: Prisma's own default is
# cpu_count*2+1, which starves the combined web+worker process
# (HEALTHLOG_PROCESS_TYPE=all) under background AI/rollup load. Default
# 20 suits a small host; raise DB_CONNECTION_LIMIT on a busy one (keep it
# below the database's max_connections, leaving headroom for pg-boss,
# migrations, and backups). pool_timeout is the seconds a query waits for
# a free connection before erroring.
DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public&connection_limit=${DB_CONNECTION_LIMIT:-20}&pool_timeout=${DB_POOL_TIMEOUT:-20}"
NODE_ENV: production
ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}"
@@ -43,6 +50,11 @@ services:
BACKUP_S3_SECRET_KEY: "${BACKUP_S3_SECRET_KEY:-}"
BACKUP_S3_REGION: "${BACKUP_S3_REGION:-auto}"
BACKUP_RETENTION_DAYS: "${BACKUP_RETENTION_DAYS:-30}"
# Per-export ceiling on MedicationAdministration rows in the FHIR
# health-record Bundle. Bounded 1..50000, default 5000. Listed here so
# an operator override in .env actually reaches the container (the
# `environment:` block is a whitelist).
FHIR_MAX_MEDICATION_ADMINISTRATIONS: "${FHIR_MAX_MEDICATION_ADMINISTRATIONS:-}"
depends_on:
db:
condition: service_healthy
@@ -0,0 +1,82 @@
# 0001 — Insights naming convention: localised UI, English identifiers
- Status: accepted
- Date: 2026-05-31
## Context
The Insights surface grew bilingually. The mother page (`/insights`) and its
routed metric sub-pages were keyed by German slugs (`/insights/blutdruck`,
`/insights/gewicht`, …) because German was the primary author voice when the
routed strip first shipped. Over time the surface accumulated a mix: route
folders, the tile-layout enum, and several internal maps were German, while
later additions (`hrv`, `oxygenSaturation`, `restingHr`, `activeEnergy`,
`workouts`) landed in English. The result was an inconsistent identifier space
where a contributor could not predict whether a given key was German or English.
Identifiers leak. A German route slug shows up in URLs, in browser history, in
the OpenAPI contract the iOS client reads, and in every internal map that pivots
on the slug. Mixed-language identifiers make the codebase harder to navigate and
make the public API contract read inconsistently.
Two surfaces also speak these identifiers across a boundary we do not control:
1. **URLs** — bookmarked and cached by the PWA.
2. **The tile-layout contract** — `GET/PUT /api/insights/layout`, which the
native iOS client persists against. The tile ids in that payload are a
client-visible contract.
## Decision
Separate the two naming axes cleanly:
- **UI copy is always localised.** Every visible string resolves through an
i18n `t()` key and is translated across all six locales (`de / en / es / fr /
it / pl`). The user never sees a raw identifier.
- **Internal identifiers are English.** Route slugs (`/insights/<slug>`), the
tile-layout enum (`INSIGHTS_TILE_IDS`), query keys, and the internal
slug→metric / slug→target maps all use established English terms. English is
the lingua franca of the codebase and of the public API contract.
Concretely, v1.8.0 migrates the routed Insights slugs and the tile-layout enum
from German to English (e.g. `blutdruck` → `blood-pressure`, `gewicht` →
`weight`, `medikamente` → `medications`). The full rename table lives in
`src/lib/insights/sub-page-metric.ts` (slugs) and `src/lib/insights-layout.ts`
(tile ids).
## Non-breaking strategy
The migration must not break either client-visible boundary:
- **URLs.** `next.config.ts` `redirects()` emits a `permanent: true` (301)
redirect from every legacy German slug to its English target, including a
`:path*` variant so any nested route survives. Bookmarks and cached PWA
navigation keep resolving.
- **Tile-layout contract.** The layout endpoint keeps **accepting** the legacy
German tile ids on input. The Zod enum is built from
`ACCEPTED_INSIGHTS_TILE_IDS` (canonical English ids *plus* the legacy German
aliases), so a `PUT` carrying old ids validates rather than tripping a 422.
`normalizeInsightsTileId` collapses each legacy id onto its canonical English
replacement; this normalisation runs on **both** the read path
(`resolveInsightsLayout`) and the write path (`serializeInsightsLayout`), so:
- a layout persisted by a `≤ v1.7.x` client surfaces canonical English ids on
`GET` without forcing a re-`PUT`;
- a `PUT` sending legacy ids persists canonical English ids;
- a layout that carries both a legacy and a canonical id for one tile dedupes
to a single canonical entry.
The legacy ids are accepted-but-deprecated. They will be removed from the
accepted set in a future major version once telemetry shows clients have
migrated. The OpenAPI `InsightsLayoutBody` schema documents the canonical ids,
the accepted legacy ids, and the deprecation.
## Consequences
- The Insights identifier space is uniformly English and predictable.
- No client breaks on upgrade: old URLs redirect, old tile ids normalise.
- The legacy-alias map and the redirect table are deliberate, finite debt to be
retired in a future major. Until then they are covered by tests
(`src/app/api/insights/layout/__tests__/route.test.ts`) that assert the
non-breaking round-trip.
- New Insights surfaces follow the convention from the start: English slug,
English tile id, localised `t()` copy.
+2300 -30
View File
File diff suppressed because it is too large. Load diff
+19
View File
@@ -44,6 +44,25 @@ PDF in a new tab without preserving the filename or fails the blob save).
`500` if rendering fails.
- Audit action: `doctor-report.pdf.generate`.
## Wellness summary (derived scores)
Since v1.10.0 the report carries a **Wellness summary** section below the
clinical content, holding the server-derived wellness scores (Recovery,
Stress, Strain) and the other derived metrics where present. It is kept
deliberately separate from the clinical vitals table:
- Each entry is labelled **descriptive, not a clinical assessment**, and the
scores never enter the vitals table or the clinical aggregations.
- The Stress score is shown as an HRV-derived proxy, not a sensor-measured
stress value.
- In the FHIR export the same scores are emitted as `survey`-category
`Observation`s (text-only `code`, no fabricated LOINC) with a per-Observation
non-clinical note — so a downstream system never mistakes them for a vital
sign or a diagnosis.
Each derived metric states its inputs, method, and the cited standard it is
grounded in (the same provenance the in-app explainer shows).
## Implementation notes
- `src/lib/doctor-report-pdf-core.ts` contains the isomorphic renderer.
+17
View File
@@ -59,3 +59,20 @@ roll back:
dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
commit, and re-deploy. Never `migrate resolve --rolled-back` against
production unless you've taken a fresh `pg_dump` first.
## Verify the served version
The queued-deploy status is **not** the source of truth — the version
`/api/version` actually answers is. A BuildKit layer-cache hit can
re-ship the prior image even when Coolify reports a clean deploy, so
always confirm the served build after a deploy:
```sh
# Asserts prod + demo serve 1.9.0, prints each target's buildSha,
# exits non-zero on a mismatch or timeout after a bounded retry loop.
pnpm dlx tsx scripts/assert-deploy.ts 1.9.0
```
Pass `--only=prod` / `--only=demo` to check a single target, or
`--attempts=N --interval=ms` to widen the retry window while a slow
pull settles.
+21 -5
View File
@@ -217,10 +217,16 @@ export async function expectStep(
displayIndex: number,
totalSteps?: number,
): Promise<void> {
const pattern = totalSteps
? new RegExp(`Schritt ${displayIndex} von ${totalSteps}`, "i")
: new RegExp(`Schritt ${displayIndex} von \\d+`, "i");
await expect(page.getByText(pattern)).toBeVisible({ timeout: 5_000 });
// The "Schritt X von Y" caption is mobile-only (`sm:hidden`) since the dot
// stepper landed, so it is hidden on the desktop e2e viewport. Assert the
// viewport-independent root data-attributes the dialog always carries.
const root = page.locator('[data-slot="medication-wizard-dialog"]');
await expect(root).toHaveAttribute("data-display-step", String(displayIndex), {
timeout: 5_000,
});
if (totalSteps != null) {
await expect(root).toHaveAttribute("data-total-steps", String(totalSteps));
}
}
/**
@@ -245,10 +251,20 @@ export async function expectMedicationOnList(
page: Page,
{ name, withNextDueChip }: { name: string; withNextDueChip: boolean },
): Promise<void> {
// Pin the clock to a fixed afternoon before rendering the list. The
// recurring stub schedules an 08:00–09:00 window; the card derives its
// header from the real clock, so when CI happens to run inside that
// window the card shows the "take now" (in_window) pill and the
// "Nächste Einnahme:" next-due line is correctly suppressed — making the
// chip assertion below depend on the wall-clock. A fixed afternoon puts
// the morning window in the past so the next-due line always renders.
await page.clock.setFixedTime(new Date("2026-06-04T13:00:00Z"));
await page.goto("/medications", { waitUntil: "domcontentloaded" });
const main = page.locator("main");
await expect(main.getByText(name).first()).toBeVisible({ timeout: 10_000 });
if (withNextDueChip) {
await expect(main.getByText(/Nächste Einnahme:/i).first()).toBeVisible();
await expect(main.getByText(/Nächste Einnahme:/i).first()).toBeVisible({
timeout: 10_000,
});
}
}
+1
View File
@@ -37,6 +37,7 @@ function summary(partial: Partial<DataSummary>): DataSummary {
min: null,
max: null,
mean: null,
median: null,
avg7: null,
avg30: null,
slope7: null,
+17 -24
View File
@@ -8,13 +8,13 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
*
* Two contracts under test:
*
* 1. Every routed Insights sub-page mounts a `<CoachLaunchButton>`.
* The component renders BOTH a sticky-bottom FAB
* (`data-slot="coach-launch-fab"`, visible on `<lg`) and an
* inline action (`data-slot="coach-launch-inline"`, visible on
* `lg+`). The DOM carries both nodes; CSS picks the visible one.
* The spec asserts the FAB renders on Pixel-5 and the inline
* action renders on Desktop Chrome.
* 1. Every routed Insights sub-page mounts a Coach launch surface.
* The layout renders a sticky-bottom FAB
* (`data-slot="coach-launch-fab"`, visible on `<lg`); v1.8.6 moved
* the per-page action into the sub-page header as an icon-only
* button (`data-slot="coach-launch-icon"`, mounted by the shell at
* heading height across breakpoints). The spec asserts the FAB
* renders on Pixel-5 and the header icon renders on Desktop Chrome.
*
* 2. Opening the Coach drawer on `<sm` mounts it as a bottom-sheet
* (`data-variant="bottom-sheet"`). On `>=sm` it mounts as a
@@ -121,28 +121,23 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
.locator('[data-slot="coach-launch-fab"]')
.first();
await expect(fab).toBeVisible({ timeout: 10_000 });
// The inline branch is in the DOM but hidden via `hidden lg:inline-flex`.
const inline = page.locator('[data-slot="coach-launch-inline"]').first();
await expect(inline).toBeHidden();
});
test(`/insights/${slug} mounts the Coach launch button on Desktop Chrome (inline branch)`, async ({
test(`/insights/${slug} mounts the header Coach icon on Desktop Chrome`, async ({
page,
}, testInfo) => {
// Desktop Chrome viewport is 1280 — `lg` (1024) is matched so the
// inline branch wins; the FAB is hidden by `lg:hidden`.
// layout FAB is hidden by `lg:hidden`; the header icon the shell
// mounts is the Coach entry on the page itself.
test.skip(
testInfo.project.name !== "chromium-desktop",
"desktop inline branch",
"desktop header-icon branch",
);
await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" });
const inline = page
.locator('[data-slot="coach-launch-inline"]')
.first();
await expect(inline).toBeVisible({ timeout: 10_000 });
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
await expect(icon).toBeVisible({ timeout: 10_000 });
const fab = page.locator('[data-slot="coach-launch-fab"]').first();
await expect(fab).toBeHidden();
@@ -168,7 +163,7 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
await expect(drawer).toHaveAttribute("data-variant", "bottom-sheet");
});
test("clicking the inline action opens the Coach drawer as a side-sheet on Desktop Chrome", async ({
test("clicking the header Coach icon opens the Coach drawer as a side-sheet on Desktop Chrome", async ({
page,
}, testInfo) => {
test.skip(
@@ -178,11 +173,9 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
const inline = page
.locator('[data-slot="coach-launch-inline"]')
.first();
await expect(inline).toBeVisible({ timeout: 10_000 });
await inline.click();
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
await expect(icon).toBeVisible({ timeout: 10_000 });
await icon.click();
const drawer = page.locator('[data-slot="coach-drawer"]');
await expect(drawer).toBeVisible({ timeout: 10_000 });
+612 -87
View File
@@ -158,7 +158,12 @@
"chartsTitle": "Trend-Diagramme",
"aiSummaryTitle": "KI-Zusammenfassung — nicht klinisch validiert",
"aiSummaryDisclaimer": "Dieser Abschnitt wurde automatisch von einem KI-Modell erzeugt und ist nicht klinisch validiert. Er ersetzt keine ärztliche Beurteilung.",
"packageReadme": "HealthLog Gesundheitsakte\n\nDieses Paket enthält:\n- report.pdf: den Gesundheitsbericht für die ärztliche Vorlage.\n- bundle.json: dieselben Daten als HL7-FHIR-R4-Dokument (Bundle), ePA-kompatibel.\n\nErstellt mit HealthLog."
"packageReadme": "HealthLog Gesundheitsakte\n\nDieses Paket enthält:\n- report.pdf: den Gesundheitsbericht für die ärztliche Vorlage.\n- bundle.json: dieselben Daten als HL7-FHIR-R4-Dokument (Bundle), ePA-kompatibel.\n\nErstellt mit HealthLog.",
"wellnessTitle": "Wellness-Zusammenfassung",
"wellnessDisclaimer": "Dies sind beschreibende Wellness-Werte, die aus deinen erfassten Signalen berechnet werden (0–100). Sie sind keine klinischen Beurteilungen oder Diagnosen.",
"wellnessRecovery": "Erholung",
"wellnessStress": "Stress",
"wellnessStrain": "Belastung"
},
"errorBoundary": {
"title": "Da ist etwas schiefgelaufen",
@@ -425,7 +430,22 @@
"dailyTotalCaption": "({count} Einzelwerte)",
"expandDay": "Einzelwerte dieses Tages anzeigen",
"collapseDay": "Einzelwerte dieses Tages ausblenden",
"loadError": "Messwerte konnten nicht geladen werden."
"loadError": "Messwerte konnten nicht geladen werden.",
"typeCardioRecovery": "Erholungspuls",
"typeWristTemperature": "Handgelenktemperatur",
"typeFallCount": "Stürze",
"typeSixMinuteWalkDistance": "6-Minuten-Gehstrecke",
"typeStairAscentSpeed": "Treppensteig-Tempo",
"typeStairDescentSpeed": "Treppab-Tempo",
"typeBreathingDisturbances": "Atemaussetzer im Schlaf",
"typeIrregularRhythmNotification": "Hinweis auf unregelmäßigen Rhythmus",
"typeHighHeartRateEvent": "Hinweis auf hohe Herzfrequenz",
"typeLowHeartRateEvent": "Hinweis auf niedrige Herzfrequenz",
"typeWalkingSteadinessEvent": "Hinweis zur Gehstabilität",
"typeBreathingDisturbanceEvent": "Atemstörung",
"typeRecoveryScore": "Erholungs-Score",
"typeStressScore": "Stress-Score",
"typeStrainScore": "Belastungs-Score"
},
"mood": {
"title": "Stimmung",
@@ -461,7 +481,49 @@
"deleteConfirmTitle": "Eintrag löschen?",
"deleteConfirmDescription": "Dieser Stimmungseintrag wird unwiderruflich gelöscht.",
"pageInfo": "Seite {page} von {total}",
"loginRequired": "Bitte anmelden, um Stimmungseinträge zu sehen."
"loginRequired": "Bitte anmelden, um Stimmungseinträge zu sehen.",
"note": "Notiz",
"notePlaceholder": "Notiz dazu, wie du dich fühlst…",
"tagPicker": "Tags",
"tagCategory": {
"feelings": "Gefühle",
"sleep": "Schlaf",
"health": "Gesundheit",
"social": "Soziales",
"work": "Arbeit"
},
"tag": {
"happy": "glücklich",
"excited": "aufgeregt",
"grateful": "dankbar",
"relaxed": "entspannt",
"content": "zufrieden",
"tired": "müde",
"unsure": "unsicher",
"bored": "gelangweilt",
"tense": "angespannt",
"angry": "wütend",
"stressed": "gestresst",
"sad": "traurig",
"sleptWell": "gut geschlafen",
"sleptOk": "mäßig geschlafen",
"sleptPoorly": "schlecht geschlafen",
"earlyNight": "früh im Bett",
"workedOut": "trainiert",
"ateWell": "gesund gegessen",
"hydrated": "Wasser getrunken",
"walked": "spazieren",
"alcohol": "Alkohol",
"family": "Familie",
"friends": "Freunde",
"party": "Party",
"alone": "allein",
"productive": "produktiv",
"overtime": "Überstunden",
"dayOff": "Feierabend",
"travel": "Reise",
"sickDay": "Krankheitstag"
}
},
"medications": {
"title": "Medikamente",
@@ -544,11 +606,14 @@
"veryOverdue": "Stark überfällig",
"compliance7d": "7-Tage-Compliance",
"compliance30d": "30-Tage-Compliance",
"complianceWindow": "{days}-Tage-Compliance",
"lastIntake": "Letzte Einnahme:",
"nextIntake": "Nächste Einnahme:",
"dayStreak": "Tage Serie",
"taken": "Genommen",
"skipped": "Übersprungen",
"intakeToastTaken": "{name} als genommen erfasst",
"intakeToastSkipped": "{name} übersprungen",
"weekdaySunday": "Sonntag",
"weekdayMonday": "Montag",
"weekdayTuesday": "Dienstag",
@@ -759,7 +824,11 @@
"back": "Zurück",
"next": "Weiter",
"save": "Speichern",
"saveEdit": "Änderungen speichern"
"saveEdit": "Änderungen speichern",
"nextHint": "Weiter: {step}",
"reviewHint": "Weiter: Speichern",
"jumpFirst": "Zum ersten Schritt springen",
"jumpLast": "Zur Übersicht und zum Speichern springen"
},
"nl": {
"button": "Beschreiben"
@@ -769,11 +838,13 @@
"title": "Wie heißt das Medikament?",
"subline": "Genau so wie es auf der Packung steht.",
"label": "Name",
"placeholder": "z. B. Ramipril"
"placeholder": "z. B. Ramipril",
"short": "Name"
},
"step2": {
"title": "Was für ein Medikament ist das?",
"subline": "Wir nutzen das für passende Vorlagen und Auswertungen."
"subline": "Wir nutzen das für passende Vorlagen und Auswertungen.",
"short": "Art"
},
"step3": {
"title": "Wie hoch ist die Dosis?",
@@ -805,15 +876,18 @@
"dosesPerUnitPlaceholder": "z. B. 4",
"dosesPerUnitHint": "Dosen pro Pen / Ampulle — leer lassen, wenn kein Bestand getrackt wird.",
"injectionSiteToggle": "Injektionsstellen-Rotation",
"injectionSiteHint": "Optionaler Rotationshelfer. Die Stelle jeder Dosis wird beim Eintragen der Einnahme erfasst."
"injectionSiteHint": "Optionaler Rotationshelfer. Die Stelle jeder Dosis wird beim Eintragen der Einnahme erfasst.",
"short": "Dosis"
},
"step4": {
"title": "Über welchen Zeitraum nimmst du es?",
"subline": "Heute reicht ein Startdatum. Ein Ende kannst du später setzen."
"subline": "Heute reicht ein Startdatum. Ein Ende kannst du später setzen.",
"short": "Zeitraum"
},
"step5": {
"title": "Wie oft nimmst du es?",
"subline": "Wähle die Regelmäßigkeit — die Details kommen im nächsten Schritt."
"subline": "Wähle die Regelmäßigkeit — die Details kommen im nächsten Schritt.",
"short": "Takt"
},
"step6": {
"title": "Wie sieht das im Detail aus?",
@@ -829,7 +903,8 @@
"rollingDays": {
"label": "Alle",
"suffix": "Tage ab der letzten Einnahme"
}
},
"short": "Details"
},
"step7": {
"title": "Wann nimmst du es?",
@@ -840,14 +915,16 @@
"noon": "Mittags",
"evening": "Abends",
"night": "Nachts"
}
},
"short": "Zeiten"
},
"step8": {
"title": "Fertig — soll HealthLog dich erinnern?",
"subline": "Du kannst alles unten nochmal prüfen, bevor du speicherst.",
"remindersLabel": "Erinnerungen aktiv",
"remindersDescription": "Push, Telegram oder ntfy — je nach deinen Einstellungen.",
"multiScheduleNote": "Dieses Medikament hat mehrere Zeitpläne. Den Detail-Editor öffnen wir in einer späteren Version."
"multiScheduleNote": "Dieses Medikament hat mehrere Zeitpläne. Den Detail-Editor öffnen wir in einer späteren Version.",
"short": "Fertig"
}
},
"classRow": {
@@ -923,26 +1000,17 @@
"submitFailed": "Medikament konnte nicht gespeichert werden — bitte erneut versuchen."
}
},
"titration": {
"section": "Titrationsstufen",
"loading": "Lade Titrationsdaten",
"loadFailed": "Titrationsdaten konnten nicht geladen werden.",
"drugLadderHeader": "Standard-Schema (EMA-Referenz)",
"typicalWeeksOnStep": "≥ {weeks} Wo. typisch",
"youAreHere": "Hier stehst du",
"nextStepCaption": "Nächste Stufe (typisch): {dose} mg nach ~{weeks} Wochen auf der aktuellen Stufe.",
"ceilingMessage": "Du bist auf der höchsten Standardstufe.",
"escalationDueHint": "Du bist seit {weeks} Wochen auf dieser Stufe; das Schema sieht eine Steigerung typischerweise nach ~{typical} Wochen vor.",
"currentStepDwell": "Seit {weeks} Wochen auf dieser Stufe",
"doseUnitMg": "mg",
"emptyState": "Noch keine Dosis-Historie. Erfasse eine Dosis, um deine Position im Schema zu sehen.",
"nonStandardDose": "Aktuelle Dosis liegt außerhalb des Standard-Schemas.",
"disclaimer": "EMA-Referenzwerte. Sprich mit deiner Ärztin oder deinem Arzt, bevor du Dosen änderst.",
"emaSourceCta": "EMA-Quelle",
"stepLabel": "Stufe {n}"
"doseStrength": {
"title": "Dosisstärke im Zeitverlauf",
"axisLabel": "Erfasste Dosis ({unit})",
"tooltipLabel": "Dosis",
"currentDose": "Aktuelle Dosis: {dose} {unit}",
"noCurrentDose": "Noch keine Dosis erfasst.",
"empty": "Noch keine Titrations-Historie. Erfasse eine Dosisänderung, um die Stärkekurve zu sehen."
},
"injectionSiteBodyOutlineAriaLabel": "Körperumriss",
"injectionSitePickerAriaLabel": "Injektionsstelle auswählen",
"injectionSiteRecommendedAriaLabel": "{site} – empfohlene nächste Stelle",
"siteAbdomenLeft": "Bauch, unten links",
"siteAbdomenRight": "Bauch, unten rechts",
"siteAbdomenUpperLeft": "Bauch, oben links",
@@ -1004,10 +1072,11 @@
"phaseResetDefaults": "Auf Defaults zurücksetzen",
"phaseSaved": "Erinnerungsphasen gespeichert",
"phaseReset": "Auf Defaults zurückgesetzt",
"sourceWeb": "Webseite",
"sourceApi": "Externe API",
"sourceReminder": "Telegram / Erinnerung",
"sourceImport": "CSV-Import",
"sourceWeb": "Web",
"sourceApi": "iOS-App",
"sourceReminder": "Erinnerung",
"sourceImport": "Import",
"intakeSourceVia": "über {origin}",
"intakeHistoryEmptyTitle": "Noch keine Einnahmen erfasst",
"intakeHistoryEmptyDescription": "Trage eine Einnahme auf der Tagesübersicht ein, um den Verlauf aufzubauen.",
"intakeHistoryEmptyAction": "Zur Tagesübersicht",
@@ -1078,11 +1147,6 @@
"emptyState": "Noch keine Einnahmen erfasst.",
"emptyStateCta": "Trage eine Dosis ein, damit die geschätzte Verlaufskurve Gestalt annimmt.",
"estimateNote": "Edukative Schätzung aus EMA-publizierter Populations-Pharmakokinetik. Keine Messung.",
"gatedTitle": "Forschungsmodus ist aus",
"gatedBody": "Aktiviere den Forschungsmodus in den Einstellungen, um die geschätzte Wirkstoffkurve zu sehen. Die Kurve ist opt-in, weil sie eine Lernhilfe und keine klinische Messung ist.",
"gatedStaleTitle": "Disclaimer muss neu bestätigt werden",
"gatedStaleBody": "Der Disclaimer des Forschungsmodus wurde seit deiner letzten Bestätigung aktualisiert. Lies ihn in den Einstellungen erneut und bestätige, um die Kurve wieder einzublenden.",
"gatedCta": "Forschungsmodus in den Einstellungen öffnen",
"unknownDrug": "{name} ist nicht im EMA-zugelassenen GLP-1-Katalog enthalten. HealthLog kann dafür keine Populations-Pharmakokinetik-Kurve erzeugen."
}
},
@@ -1170,6 +1234,14 @@
"unit": "Minuten",
"saved": "Erinnerungsfenster gespeichert",
"failed": "Erinnerungsfenster konnte nicht gespeichert werden."
},
"codes": {
"label": "Klinische Codes (ATC / RxNorm)",
"note": "Optional. Werden beim FHIR-Export der Gesundheitsakte verwendet. Codes werden genau wie eingegeben übernommen — nie geraten.",
"atcLabel": "ATC",
"rxNormLabel": "RxNorm",
"saved": "Klinische Codes gespeichert",
"failed": "Klinische Codes konnten nicht gespeichert werden."
}
},
"zone": {
@@ -1235,7 +1307,7 @@
"data": "Daten",
"reminders": "Erinnerungen",
"lifecycle": "Lebenszyklus",
"danger": "Gefahrenzone"
"externalApi": "Externe API"
},
"dataPortability": {
"import": {
@@ -1258,10 +1330,18 @@
"history": {
"back": "Zurück zum Medikament",
"subtitle": "Vollständiger Einnahmeverlauf",
"plannedSuffix": "geplant",
"drugLevelDisclosure": "Geschätzte Wirkstoffkurve anzeigen"
"plannedSuffix": "geplant"
}
}
},
"logInjectionSiteTitle": "Injektionsstelle erfassen",
"logInjectionSiteDescription": "Wo hast du {name} gespritzt? Optional — du kannst dies überspringen.",
"logInjectionSiteSkip": "Überspringen",
"logInjectionSiteConfirm": "Stelle speichern",
"logInjectionSiteNoneAvailable": "Keine Injektionsstelle verfügbar — alle Stellen sind in deinen Einstellungen ausgeschlossen.",
"trackInjectionSitesToggle": "Injektionsstellen erfassen",
"trackInjectionSitesHint": "Nach einer eingenommenen Dosis nach der verwendeten Stelle fragen und die nächste Rotation vorschlagen.",
"allowedSitesLabel": "Erlaubte Stellen",
"allowedSitesHint": "Alle ausgewählt lassen für keine Einschränkung. Global ausgeschlossene Stellen werden nie angeboten."
},
"charts": {
"loadingLabel": "Diagramm wird geladen…",
@@ -1272,6 +1352,13 @@
"bodyFat": "Körperfett",
"sleep": "Schlaf",
"steps": "Schritte",
"hrv": "HRV",
"restingHeartRate": "Ruhepuls",
"activeEnergy": "Aktive Energie",
"flights": "Treppen",
"distance": "Geh- + Laufstrecke",
"vo2Max": "VO₂max",
"bodyTemperature": "Körpertemperatur",
"noData": "Keine Daten im gewählten Zeitraum",
"bucketWeekly": "Wochendurchschnitt",
"bucketMonthly": "Monatsdurchschnitt",
@@ -1336,7 +1423,17 @@
"personalBaseline": "Dein Mittel",
"deltaVsBaseline": "{delta} vs. dein Mittel",
"deltaUnchanged": "entspricht deinem Mittel",
"deltaVsTarget": "{delta} vs. Ziel"
"deltaVsTarget": "{delta} vs. Ziel",
"weekdaysFull": {
"mon": "Montag",
"tue": "Dienstag",
"wed": "Mittwoch",
"thu": "Donnerstag",
"fri": "Freitag",
"sat": "Samstag",
"sun": "Sonntag"
},
"rangeBand": "Bereich"
},
"chart": {
"overlay": {
@@ -1382,6 +1479,7 @@
"bmiEmptyDescription": "Für den BMI wird deine Körpergröße benötigt. Hinterlege sie unter Einstellungen → Account.",
"bmiEmptyAction": "Account-Einstellungen öffnen",
"noAnalysisYet": "Noch keine Analyse vorhanden.",
"assessmentPreparing": "Einschätzung wird vorbereitet…",
"noProviderConfigured": "Auswertung nicht verfügbar.",
"startAnalysis": "Analyse starten",
"refreshAnalysis": "Analyse aktualisieren",
@@ -1432,6 +1530,8 @@
"heroRegenerating": "Wird generiert",
"regenerateAnalysis": "Analyse neu starten",
"regenerateSuccess": "Analyse wurde neu erstellt",
"warmAssessments": "Auswertungen vorbereiten",
"warmStarted": "Auswertungen werden im Hintergrund erstellt",
"heroFallbackSubtitle": "Täglicher Blick auf deine Trends — direkt aus den Zahlen, die du protokollierst.",
"heroGreetingMorning": "Guten Morgen",
"heroGreetingAfternoon": "Guten Tag",
@@ -1498,7 +1598,22 @@
},
"trendsRow": {
"title": "Trends",
"subtitle": "Die letzten 30 Tage auf einen Blick"
"subtitle": "Die letzten 30 Tage auf einen Blick",
"caption": {
"bp": "Systolische und diastolische Werte der letzten 30 Tage.",
"weight": "Gewichtsverlauf der letzten 30 Tage.",
"pulse": "Ruhepuls der letzten 30 Tage.",
"mood": "Täglicher Stimmungswert der letzten 30 Tage.",
"sleep": "Nächtliche Schlafdauer der letzten 30 Tage.",
"steps": "Tägliche Schrittzahl der letzten 30 Tage.",
"hrv": "Herzratenvariabilität der letzten 30 Tage.",
"resting_hr": "Ruheherzfrequenz der letzten 30 Tage.",
"active_energy": "Aktiv verbrauchte Energie der letzten 30 Tage.",
"flights": "Erklommene Stockwerke der letzten 30 Tage.",
"distance": "Geh- und Laufstrecke der letzten 30 Tage.",
"vo2_max": "VO₂max der letzten 30 Tage.",
"body_temp": "Körpertemperatur der letzten 30 Tage."
}
},
"vo2Max": {
"statLatest": "Aktuell",
@@ -1515,6 +1630,23 @@
"confidenceLow": "Geringe Konfidenz",
"pendingLabel": "Trend-Kommentar wird generiert"
},
"range": {
"label": "Zeitraum",
"noPriorData": "keine Daten im Vorzeitraum",
"option": {
"7d": "7T",
"30d": "30T",
"90d": "90T",
"1y": "1J"
},
"priorLabel": {
"7d": "ggü. vorherigen 7T",
"30d": "ggü. vorherigen 30T",
"90d": "ggü. vorherigen 90T",
"1y": "ggü. Vorjahr"
},
"noChange": "keine Änderung"
},
"correlationRow": {
"title": "Zusammenhänge",
"subtitle": "Vorab definierte Hypothesen, gegated auf n ≥ 14 und p < 0,05",
@@ -1800,7 +1932,124 @@
"medikamenteEmptyDescription": "Adhärenz-Insights brauchen mindestens ein erfasstes Medikament.",
"medikamenteEmptyAction": "Medikamente öffnen",
"therapyTimelineDescription": "Chronologisches GLP-1-Therapieprotokoll — Dosis-Änderungen, Injektionen, Bestand, Nebenwirkungen.",
"bmiDescription": "BMI aus deinem Gewicht und deiner Größe abgeleitet, mit Einschätzung."
"bmiDescription": "BMI aus deinem Gewicht und deiner Größe abgeleitet, mit Einschätzung.",
"target": {
"range": "Ziel: {min}–{max} {unit}",
"bpRange": "Ziel: {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}",
"inTargetShare": "{pct}% der erfassten Tage im Zielbereich",
"adjustLink": "Zielbereich anpassen"
},
"explainer": {
"trigger": "Was ist {title}?",
"bloodPressureTitle": "Blutdruck",
"bloodPressureBody": "Der Blutdruck ist die Kraft, die das Blut auf die Gefäßwände ausübt, angegeben als systolisch zu diastolisch (Spitzenwert beim Herzschlag über dem Ruhewert dazwischen). Dauerhaft hohe Werte belasten Herz und Gefäße auf Dauer.",
"pulseTitle": "Puls",
"pulseBody": "Der Puls gibt an, wie oft das Herz pro Minute schlägt. Eine Einzelmessung spiegelt den Moment wider – in Ruhe liegt er meist bei 60–100 Schlägen pro Minute und steigt natürlicherweise bei Bewegung, Stress oder Koffein.",
"weightTitle": "Gewicht",
"weightBody": "Das Körpergewicht ist eines der einfachsten Signale für die langfristige Energiebilanz und die allgemeine Gesundheit. Der Trend über Wochen zählt weit mehr als ein einzelner Wert, der mit Flüssigkeit, Ernährung und Tageszeit schwankt.",
"bmiTitle": "Body-Mass-Index (BMI)",
"bmiBody": "Der BMI ist das Gewicht geteilt durch die Körpergröße zum Quadrat und ordnet Sie einer groben Gewichtskategorie zu. Er ist ein Orientierungswert, keine Diagnose, und unterscheidet nicht zwischen Muskeln und Fett.",
"sleepTitle": "Schlaf",
"sleepBody": "Die Schlafdauer ist die tatsächlich geschlafene Zeit pro Nacht, aufgeteilt in Leicht-, Tief- und REM-Schlaf. Die meisten Erwachsenen brauchen etwa sieben bis neun Stunden; gleichmäßige Menge und Qualität unterstützen Erholung, Stimmung und Stoffwechsel.",
"moodTitle": "Stimmung",
"moodBody": "Die Stimmungserfassung hält auf einer einfachen Skala fest, wie Sie sich fühlen. Regelmäßiges Protokollieren zeigt Muster – wie Schlaf, Aktivität oder Medikamente mit dem Befinden zusammenhängen –, die ein einzelner Tag nie offenbart.",
"medicationsTitle": "Medikamenten-Therapietreue",
"medicationsBody": "Die Therapietreue zeigt, wie zuverlässig Sie Ihre Medikamente wie verordnet einnehmen, hier als Anteil der erwarteten Dosen, die Sie erfasst haben. Eine gleichmäßige Einnahme entscheidet oft darüber, ob eine Therapie wie beabsichtigt wirkt.",
"restingHrTitle": "Ruhepuls",
"restingHrBody": "Der Ruhepuls ist die Herzfrequenz in völliger Ruhe, meist über Nacht gemessen. Ein niedriger Ruhepuls deutet in der Regel auf eine bessere Herz-Kreislauf-Fitness hin, ein steigender Trend kann ein frühes Zeichen für Stress oder Krankheit sein.",
"hrvTitle": "Herzfrequenzvariabilität (HRV)",
"hrvBody": "Die HRV ist die kleine Schwankung der Zeit zwischen aufeinanderfolgenden Herzschlägen. Eine höhere Variabilität spiegelt meist ein gut erholtes, anpassungsfähiges Nervensystem wider, ein anhaltender Rückgang kann auf Erschöpfung, Stress oder mangelnde Erholung hinweisen.",
"oxygenSaturationTitle": "Sauerstoffsättigung (SpO₂)",
"oxygenSaturationBody": "Die SpO₂ ist der Prozentsatz der roten Blutkörperchen, die Sauerstoff transportieren. Werte von 95–100 % sind in Ruhe normal; Werte, die dauerhaft unter 90 % liegen, verdienen Aufmerksamkeit, kurze nächtliche Abfälle sind jedoch häufig.",
"bodyTemperatureTitle": "Körpertemperatur",
"bodyTemperatureBody": "Die Körpertemperatur spiegelt Ihren Kernwärmezustand wider, normalerweise um 37 °C mit kleinen Tagesschwankungen. Ein anhaltender Anstieg kann auf eine Infektion hindeuten; nächtliche Messungen am Handgelenk erfassen vor allem relative Änderungen, keinen klinischen Wert.",
"activeEnergyTitle": "Aktive Energie",
"activeEnergyBody": "Die aktive Energie sind die Kalorien, die Sie durch Bewegung verbrennen, zusätzlich zum Grundumsatz in Ruhe. Sie ist ein direktes Maß dafür, wie körperlich aktiv Ihr Tag war.",
"workoutsTitle": "Workouts",
"workoutsBody": "Workouts sind die strukturierten Trainingseinheiten, die Ihre Geräte mit Art, Dauer und Energie aufzeichnen. Ihre Erfassung zeigt, wie sich regelmäßiges Training über Wochen summiert und wie es mit Ihren anderen Gesundheitssignalen zusammenhängt.",
"respiratoryRateTitle": "Atemfrequenz",
"respiratoryRateBody": "Die Atemfrequenz ist die Anzahl der Atemzüge pro Minute. In Ruhe atmen die meisten Erwachsenen etwa zwölf- bis zwanzigmal pro Minute; ein anhaltender Anstieg kann mit Krankheit, Anstrengung oder Stress einhergehen.",
"bodyWaterTitle": "Gesamtkörperwasser",
"bodyWaterBody": "Das Gesamtkörperwasser ist der Anteil deines Körpergewichts, der aus Flüssigkeit in und um die Zellen besteht. Es schwankt mit Flüssigkeitshaushalt, Ernährung und Aktivität, daher sagt der Trend über Wochen mehr aus als ein einzelner Wert.",
"boneMassTitle": "Knochenmasse",
"boneMassBody": "Die Knochenmasse ist das geschätzte Gewicht der Mineralien in deinem Skelett. Waagengestützte Schätzungen sind ungefähr, doch ein gleichbleibender oder steigender Trend unterstützt die langfristige Knochenstärke.",
"fatFreeMassTitle": "Fettfreie Masse",
"fatFreeMassBody": "Die fettfreie Masse ist alles in deinem Körper, das kein Fett ist — Muskeln, Knochen, Organe und Wasser. Sie zu halten oder aufzubauen, während das Gewicht sinkt, deutet meist auf eine gesündere Körperzusammensetzung hin.",
"fatMassTitle": "Fettmasse",
"fatMassBody": "Die Fettmasse ist das Gesamtgewicht des Körperfetts. Etwas Fett ist für Hormone und Energiespeicher unverzichtbar, doch zu viel — besonders am Bauch — erhöht das langfristige Gesundheitsrisiko.",
"muscleMassTitle": "Muskelmasse",
"muscleMassBody": "Die Muskelmasse ist das geschätzte Gewicht der Muskeln in deinem Körper. Mehr Muskeln unterstützen Kraft, Stoffwechsel und Beweglichkeit und müssen mit zunehmendem Alter aktiv erhalten werden.",
"visceralFatTitle": "Viszeralfett",
"visceralFatBody": "Viszeralfett ist das Fett, das tief im Bauchraum um die Organe gespeichert wird. Anders als das Unterhautfett ist ein hoher Wert eng mit Herzkrankheiten und Typ-2-Diabetes verbunden, daher ist niedriger meist besser.",
"leanBodyMassTitle": "Magermasse",
"leanBodyMassBody": "Die Magermasse ist dein Gesamtgewicht abzüglich des Körperfetts — Muskeln, Knochen, Organe und Wasser zusammen. Sie neben dem Gewicht zu verfolgen zeigt, ob Veränderungen aus Muskeln oder aus Fett stammen.",
"flightsClimbedTitle": "Etagen gestiegen",
"flightsClimbedBody": "Die gestiegenen Etagen zählen die Stockwerke, die du täglich nach oben gehst, wobei eine Etage etwa drei Metern Höhe entspricht. Es ist ein einfacher Marker für Alltagsaktivität und Beinarbeit.",
"walkingDistanceTitle": "Geh- + Laufstrecke",
"walkingDistanceBody": "Die Geh- und Laufstrecke ist die Strecke, die du täglich zu Fuß zurücklegst. Sie ergänzt die Schrittzahl, indem sie zeigt, wie weit du dich bewegt hast, nicht nur wie oft du getreten bist.",
"walkingSteadinessTitle": "Gehstabilität",
"walkingSteadinessBody": "Die Gehstabilität schätzt, wie stabil und ausgewogen dein Gangbild ist, eingeteilt von OK bis niedrig. Ein niedriger Wert deutet auf ein höheres Sturzrisiko hin und sollte ärztlich besprochen werden.",
"walkingHeartRateTitle": "Herzfrequenz beim Gehen",
"walkingHeartRateBody": "Die Herzfrequenz beim Gehen ist dein durchschnittlicher Puls bei einem gleichmäßigen Spaziergang. Ein über die Zeit niedrigerer Wert spiegelt bei gleicher Anstrengung oft eine bessere Herz-Kreislauf-Fitness wider.",
"walkingAsymmetryTitle": "Gangasymmetrie",
"walkingAsymmetryBody": "Die Gangasymmetrie ist der Anteil der Zeit, in der deine Schritte zwischen linkem und rechtem Bein ungleichmäßig sind. Ein höherer Prozentsatz kann auf Schmerzen, eine Verletzung oder ein Gangproblem hindeuten, das näher betrachtet werden sollte.",
"doubleSupportTimeTitle": "Doppelunterstützungszeit",
"doubleSupportTimeBody": "Die Doppelunterstützungszeit ist der Anteil jedes Gehzyklus, in dem beide Füße gleichzeitig den Boden berühren. Ein höherer Prozentsatz bedeutet meist ein vorsichtigeres, weniger stabiles Gangbild.",
"stepLengthTitle": "Schrittlänge",
"stepLengthBody": "Die Schrittlänge ist der durchschnittliche Abstand von einem Fußauftritt zum nächsten. Eine über die Zeit kürzere Schrittlänge kann Müdigkeit, Vorsicht oder eine veränderte Beweglichkeit widerspiegeln.",
"walkingSpeedTitle": "Gehgeschwindigkeit",
"walkingSpeedBody": "Die Gehgeschwindigkeit ist, wie schnell du dich auf ebenem Untergrund fortbewegst. Sie ist ein gut untersuchter Marker für allgemeine Gesundheit und Beweglichkeit, und ein gleichbleibendes oder steigendes Tempo ist ein gutes Zeichen.",
"pulseWaveVelocityTitle": "Pulswellengeschwindigkeit",
"pulseWaveVelocityBody": "Die Pulswellengeschwindigkeit misst, wie schnell die Druckwelle jedes Herzschlags durch deine Arterien läuft. Eine schnellere Welle weist auf steifere Arterien hin, was mit einem höheren Herz-Kreislauf-Risiko verbunden ist.",
"vascularAgeTitle": "Gefäßalter",
"vascularAgeBody": "Das Gefäßalter schätzt anhand der Arteriensteifigkeit, wie alt sich deine Arterien im Vergleich zu deinem tatsächlichen Alter verhalten. Ein Gefäßalter unter dem echten Alter deutet auf gesündere Blutgefäße hin.",
"environmentalAudioTitle": "Umgebungslautstärke",
"environmentalAudioBody": "Die Umgebungslautstärke erfasst die Lautstärke deiner Umwelt in Dezibel. Anhaltende Belastung über etwa 70 dB kann das Gehör mit der Zeit schädigen, daher sind niedrigere Tagesmittel sicherer.",
"headphoneAudioTitle": "Kopfhörerlautstärke",
"headphoneAudioBody": "Die Kopfhörerlautstärke erfasst in Dezibel, wie laut du über Ohrhörer oder Kopfhörer hörst. Die Lautstärke niedrig zu halten und Pausen einzulegen schützt dein Gehör langfristig.",
"audioEventsTitle": "Laute Geräuschereignisse",
"audioEventsBody": "Laute Geräuschereignisse markieren Momente, in denen der Lärm um dich herum ein Niveau erreicht hat, das bei ausreichender Belastung das Gehör schädigen kann. Häufige Ereignisse sind ein Hinweis, eine ruhigere Umgebung oder Gehörschutz zu suchen.",
"daylightTitle": "Zeit im Tageslicht",
"daylightBody": "Die Zeit im Tageslicht ist, wie lange du dich täglich im Freien im Licht aufhältst. Regelmäßiges Tageslicht unterstützt einen gesunden Schlaf-Wach-Rhythmus und die Stimmung, und Zeit im Freien ist mit der Augengesundheit verbunden.",
"bloodGlucoseTitle": "Blutzucker",
"bloodGlucoseBody": "Der Blutzucker ist die Zuckermenge in deinem Blut, die wichtigste Energiequelle des Körpers. Er steigt nach dem Essen und pendelt sich zwischen den Mahlzeiten ein; dauerhaft hohe Werte können auf Diabetes oder Prädiabetes hinweisen.",
"skinTemperatureTitle": "Hauttemperatur",
"skinTemperatureBody": "Die Hauttemperatur ist hier die kleine nächtliche Abweichung deiner Handgelenkstemperatur von deinem eigenen Ausgangswert, keine absolute Fiebermessung. Veränderungen können Schlaf, Umgebung, Krankheit oder Zyklusphase widerspiegeln.",
"cardioFitnessTitle": "Cardiofitness (VO₂max)",
"cardioFitnessBody": "VO₂max gibt an, wie viel Sauerstoff dein Körper bei maximaler Anstrengung verwerten kann. Es ist der aussagekräftigste Laborwert für die Ausdauerfitness; ein höherer oder steigender Wert spricht für eine gute Herz-Kreislauf-Gesundheit.",
"cardioRecoveryTitle": "Erholungspuls",
"cardioRecoveryBody": "Der Erholungspuls gibt an, um wie viele Schläge pro Minute dein Puls in der ersten Minute nach dem Ende einer anstrengenden Belastung fällt. Ein größerer Abfall bedeutet, dass sich dein Herz schneller beruhigt — ein Zeichen guter Fitness.",
"wristTemperatureTitle": "Handgelenktemperatur",
"wristTemperatureBody": "Die Handgelenktemperatur wird im Schlaf gemessen. Apple zeigt sie als Abweichung von deinem persönlichen Ausgangswert; ein gleichmäßiger Wert ist normal, ein anhaltender Anstieg kann auf Krankheit, intensives Training oder eine Zyklusveränderung hindeuten.",
"fallsTitle": "Stürze",
"fallsBody": "Hier werden die von deiner Apple Watch erkannten schweren Stürze gezählt. Null ist das Ziel; eine steigende Zahl ist ein Hinweis auf ein erhöhtes Sturzrisiko, das du — besonders im höheren Alter — mit einer Ärztin oder einem Arzt besprechen solltest.",
"sixMinuteWalkTitle": "6-Minuten-Gehstrecke",
"sixMinuteWalkBody": "Dies ist Apples Schätzung, wie weit du in sechs Minuten gehen könntest — ein etablierter klinischer Test für Mobilität und kardiopulmonale Ausdauer. Eine gleichbleibende oder steigende Strecke ist ein gutes Zeichen.",
"stairAscentSpeedTitle": "Treppensteig-Tempo",
"stairAscentSpeedBody": "Dies gibt an, wie schnell du eine Treppe hinaufgehst. Es ist ein empfindlicher Marker für Beinkraft und Mobilität — ein gleichbleibendes oder steigendes Tempo ist gut, ein sinkendes solltest du im Blick behalten.",
"stairDescentSpeedTitle": "Treppab-Tempo",
"stairDescentSpeedBody": "Dies gibt an, wie schnell du eine Treppe hinabgehst. Zusammen mit dem Steig-Tempo zeigt es Beinkontrolle und Gleichgewicht — ein gleichmäßiges Tempo ist gut, ein sinkendes solltest du im Blick behalten.",
"breathingDisturbancesTitle": "Atemaussetzer im Schlaf",
"breathingDisturbancesBody": "Deine Apple Watch kann schätzen, wie oft deine Atmung im Schlaf unterbrochen war. Ein niedriger Index ist besser; ein von Apple als erhöht eingestufter Wert — vor allem über viele Nächte — sollte mit einer Ärztin oder einem Arzt besprochen werden."
},
"stats": {
"label": "Zusammenfassende Statistik",
"min": "Min",
"max": "Max",
"median": "Median",
"mean": "Mittelwert"
},
"showAllValues": "Alle Werte anzeigen",
"valuesPageTitle": "{metric} — alle Werte",
"valuesPageDescription": "Jeder einzelne Wert hinter dieser Kennzahl. Einträge lassen sich direkt bearbeiten oder entfernen.",
"diversity": {
"trigger": "Messhinweis",
"weekday": "Die meisten deiner {metric}-Werte fallen auf einen Wochentag. Messungen an anderen Tagen ergeben ein vollständigeres Bild.",
"timeOfDay": "Die meisten deiner {metric}-Werte fallen auf eine ähnliche Tageszeit. Unterschiedliche Zeiten ergeben ein vollständigeres Bild."
},
"valuesBack": "Zurück zu Insights",
"valuesBackToMetric": "Zurück zu {metric}",
"scoresBack": "Zurück zu Insights"
},
"sleep": {
"title": "Schlaf",
@@ -2181,7 +2430,312 @@
"description": "Werte erscheinen hier, sobald Apple Health synchronisiert."
}
},
"navWalkingSpeed": "Gehgeschwindigkeit"
"navWalkingSpeed": "Gehgeschwindigkeit",
"mood": {
"heatmapTitle": "Stimmungskalender",
"heatmapNoEntry": "Kein Eintrag",
"legendGreat": "Super",
"legendGood": "Gut",
"legendOkay": "Okay",
"legendLow": "Niedrig",
"distributionTitle": "Stimmungsverteilung",
"distributionDaysUnit": "Tage",
"weekdayTitle": "Durchschnitt nach Wochentag",
"weekdayNoData": "Keine Daten",
"weekdayBestWorst": "Bester Tag: {best} · Schwerster Tag: {worst}",
"tagsTitle": "Tags und Auslöser",
"tagAvgMood": "Durchschnittliche Stimmung mit diesem Tag",
"correlationsTitle": "Stimmung und andere Werte",
"correlationsDescription": "Wie sich deine Stimmung am selben Tag mit Schlaf, Aktivität und Puls bewegt.",
"correlation": {
"sleepTitle": "Stimmung vs. Schlaf",
"stepsTitle": "Stimmung vs. Aktivität",
"pulseTitle": "Stimmung vs. Puls",
"sleepAxis": "Schlaf (Min.)",
"stepsAxis": "Schritte",
"pulseAxis": "Puls (bpm)",
"strengthStrong": "Stark",
"strengthModerate": "Moderat",
"strengthWeak": "Schwach",
"strengthNone": "Keine",
"source": "{n} gepaarte Tage · r = {r}",
"emptyTitle": "Noch nicht genug gepaarte Daten",
"emptyDescription": "Erfasse Stimmung und diesen Wert an denselben Tagen, um einen Zusammenhang zu sehen.",
"weightTitle": "Stimmung vs. Gewicht",
"bloodPressureTitle": "Stimmung vs. Blutdruck",
"weightAxis": "Gewicht",
"bloodPressureAxis": "Systolisch (mmHg)"
},
"structuredTagsTitle": "Tag-Auswertung",
"inTargetValue": "{pct} %",
"inTargetCaption": "der letzten Tage im guten Stimmungsbereich",
"narrative": {
"title": "Das fällt auf",
"weekdayDip": "Deine Stimmung sinkt am stärksten am {weekday} – {delta} unter deinem Wochenschnitt.",
"weekdayPeak": "{weekday} ist durchgehend dein bester Tag mit einem Schnitt von {value}.",
"trendUp": "Deine Stimmung steigt über die letzten {days} Tage (+{delta} gegenüber dem Vorzeitraum).",
"trendDown": "Deine Stimmung sinkt über die letzten {days} Tage (-{delta} gegenüber dem Vorzeitraum).",
"weekendUp": "Am Wochenende geht es dir besser (+{delta} gegenüber Wochentagen).",
"weekendDown": "Am Wochenende ist deine Stimmung niedriger (-{delta} gegenüber Wochentagen).",
"tagLift": "Tage mit „{tag}“ liegen im Schnitt {delta} über deiner allgemeinen Stimmung.",
"tagDrop": "Tage mit „{tag}“ liegen {delta} unter deinem Durchschnitt.",
"inTarget": "Du warst an {pct} % der letzten 30 Tage in deinem guten Stimmungsbereich.",
"streak": "Du hast deine Stimmung {days} Tage in Folge erfasst – weiter so.",
"timeOfDay": "Am {bucket} fühlst du dich am besten, im Schnitt {value}."
},
"timeOfDay": {
"title": "Stimmung nach Tageszeit",
"morning": "Morgen",
"afternoon": "Nachmittag",
"evening": "Abend",
"night": "Nacht",
"bestCaption": "Am {bucket} fühlst du dich tendenziell am besten."
},
"stability": {
"value": "{score}/100",
"bandVerySteady": "Deine Stimmung war sehr gleichmäßig",
"bandSteady": "Deine Stimmung war recht gleichmäßig",
"bandVariable": "Deine Stimmung war merklich schwankend",
"bandVeryVariable": "Deine Stimmung hat zuletzt stark geschwankt"
}
},
"derived": {
"coverage": {
"summary": "{present} von {required} Eingaben · {percent}%",
"ratioLabel": "{present}/{required}",
"historyDays": "Basiert auf {count} Tagen Verlauf",
"missing": "Erfasse diese, um den Wert zu schärfen: {list}"
},
"scoreRing": {
"aria": "Wert {score} von 100, {band}",
"ariaProvisional": "Wert noch nicht verfügbar — zu wenig Daten",
"provisionalCaption": "Noch zu wenig Daten",
"band": {
"green": "gut",
"yellow": "mittel",
"red": "niedrig"
}
},
"provenance": {
"title": "So wird das berechnet",
"trigger": "So wird das berechnet",
"inputsLabel": "Eingaben",
"noInputs": "Noch keine Eingaben für diesen Wert vorhanden.",
"methodLabel": "Methode",
"window": "Aus deinen letzten {days} Tagen · {source}-Daten",
"asOf": "Stand {date}",
"standardLink": "Standard: {name}"
},
"vitals": {
"sectionTitle": "Deine Vitalwerte",
"loadingLabel": "Vitalwerte werden geladen",
"loadError": "Deine Vitalwerte konnten gerade nicht geladen werden.",
"building": "Typischer Bereich entsteht — {count} von {target} Tagen",
"typicalRange": "Typisch für dich: {low}–{high}",
"fitnessYounger": "Ausdauer wie ~{years} J. jünger",
"fitnessOlder": "Ausdauer ~{years} J. über deinem Alter",
"fitnessTypical": "Ausdauer typisch für dein Alter",
"fitnessNoNorm": "Alter und Geschlecht ergänzen, um dies einzuordnen",
"vascularBelow": "{years} J. unter deinem Alter",
"vascularAbove": "{years} J. über deinem Alter",
"vascularMatch": "Im Einklang mit deinem Alter",
"vascularNoAge": "Geburtsdatum ergänzen, um dies einzuordnen",
"hrvBand": {
"balanced": "Für dich ausgewogen",
"unbalanced": "Über deinem üblichen Bereich",
"low": "Unter deinem üblichen Bereich"
},
"bmiCategory": {
"underweight": "Untergewicht (WHO)",
"normal": "Normalbereich (WHO)",
"overweight": "Übergewicht (WHO)",
"obese": "Adipositas (WHO)"
}
},
"anatomy": {
"outOf": "/100",
"contributorsLabel": "Faktoren",
"contributorValue": "{value} · {weight}% Gewicht",
"contributorMissing": "keine Daten",
"disclaimer": "Richtwert — keine klinische Bewertung.",
"insufficient": "Noch nicht genug Daten für eine Bewertung. Tracke weiter, dann erscheint sie hier."
},
"composite": {
"SLEEP_SCORE": {
"title": "Schlaf-Score",
"description": "Ein transparenter Schlafqualitäts-Score mit allen Teilwerten.",
"method": "Eine 0–100-Mischung aus Schlafmenge (Schlaf vs. altersbasierter Bedarf), Effizienz (Schlaf ÷ Bettzeit), Konstanz (wie stabil deine Schlafmitte ist), Timing (vs. deine gewohnte Schlafmitte) und Zusammensetzung (REM- + Tiefschlafanteil). Teilwerte werden umgewichtet, wenn deine Daten einen nicht stützen; nichts wird erfunden.",
"sub": {
"sufficiency": "Schlafmenge",
"efficiency": "Effizienz",
"consistency": "Konstanz",
"timing": "Timing",
"composition": "Zusammensetzung"
}
},
"READINESS": {
"title": "Bereitschaft",
"description": "Ein täglicher Wohlbefindens-Indikator daraus, wie deine Werte zu deiner eigenen Baseline stehen.",
"method": "Eine 0–100-Mischung aus Ruhepuls-Abweichung, HRV-(SDNN-)Abweichung, Schlaf, Atemfrequenz-Stabilität und Stimmungsstabilität — jeweils gegen deine persönliche Baseline gemessen. Gewichte werden über die von deinen Daten gestützten Komponenten neu verteilt. Ein Wohlbefindens-Indikator, kein Erholungs- oder klinischer Score.",
"component": {
"rhr": "Ruhepuls",
"hrv": "HRV (SDNN)",
"sleep": "Schlaf",
"respiratory": "Atemfrequenz",
"mood": "Stimmungsstabilität"
},
"caveat": "Ein Wellness-Proxy, kein Erholungs- oder klinischer Wert."
},
"VITALS_BASELINE": {
"method": "Dein persönlicher Normalbereich ist der Median deiner Tageswerte über den Zeitraum, ± eine robuste Streuung (skalierte mittlere absolute Abweichung). Er folgt DEINEN Werten, keiner Bevölkerungsnorm — ein Wert im Band ist also typisch für dich."
},
"HRV_BALANCE": {
"method": "Dein jüngster HRV-(SDNN-)Durchschnitt im Vergleich zu deiner eigenen gleitenden Baseline (Median ± robuste Streuung). Ausgeglichen heißt: er liegt in deinem persönlichen Bereich; das Band stammt aus deinen Daten, nie aus einem Bevölkerungswert."
},
"BMI": {
"method": "Gewicht ÷ Größe², eingeordnet nach den WHO-Kategorien für Erwachsene (Untergewicht / Normal / Übergewicht / Adipositas). Nur ein Screening-Verhältnis — es unterscheidet nicht Muskel von Fett."
},
"FITNESS_AGE": {
"method": "Dein VO₂max im Vergleich zu alters- und geschlechtsbezogenen Perzentilen der kardiorespiratorischen Fitness, als Fitnessalter-Differenz. Das VO₂max ist die Schätzung deines Geräts — hier nie neu berechnet."
},
"VASCULAR_AGE_DELTA": {
"method": "Die Gefäßalter- bzw. Pulswellengeschwindigkeits-Schätzung deines Geräts im Vergleich zu deinem chronologischen Alter, als Differenz in Jahren. Die Schätzung stammt vom Gerät; HealthLog ordnet sie nur ein.",
"caveat": "Die eigene Schätzung deines Geräts, nur eingeordnet — beschreibend, keine klinische Gefäßbewertung.",
"description": "Wie die Gefäßalter-Schätzung deines Geräts zu deinem chronologischen Alter steht."
},
"COINCIDENT_DEVIATION": {
"method": "Ein Hinweis, wenn zwei oder mehr deiner Vitalwerte am selben Tag außerhalb ihres persönlichen Normalbereichs liegen. Jedes Band ist der robuste Median ± k·MAD deiner eigenen Verlaufsdaten und passt sich an dich an, nicht an einen Bevölkerungswert.",
"caveat": "Ein Hinweis aus deinen eigenen Baselines — beschreibend, keine Diagnose und kein klinischer Alarm."
},
"RECOVERY_SCORE": {
"method": "Ein nächtlicher 0–100-Erholungs-Proxy aus HRV, Ruhepuls und Schlaf gegenüber deiner Baseline. Höher heißt besser erholt. Einmal pro Nacht berechnet und hier nur ausgelesen — nie spontan neu berechnet.",
"caveat": "Ein beschreibender Wellness-Proxy, kein trainings- oder klinikgerechtes Erholungsmaß.",
"description": "Ein nächtlicher Erholungs-Proxy aus HRV, Ruhepuls und Schlaf."
},
"STRESS_SCORE": {
"method": "Ein nächtlicher 0–100-Stress-Proxy aus dem Verlauf deiner HRV über den Tag gegenüber deiner Baseline. Höher heißt mehr physiologische Belastung.",
"caveat": "Ein aus der HRV abgeleiteter Proxy, KEINE EDA-/Hautleitwert-Stressmessung — deine Uhr hat keinen EDA-Sensor. Beschreibend, nicht klinisch.",
"description": "Ein nächtlicher Stress-Proxy aus dem Tagesverlauf deiner HRV."
},
"STRAIN_SCORE": {
"method": "Ein nächtlicher 0–100-Belastungs-Proxy aus deiner herzfrequenzgewichteten Aktivitätslast (ein TRIMP-ähnliches Trainingsimpuls-Modell). Höher heißt ein anstrengenderer Tag.",
"caveat": "Ein beschreibender Aktivitätslast-Proxy, kein klinisches oder trainingsgerechtes Lastmaß.",
"description": "Ein nächtlicher Aktivitätslast-Proxy aus deiner herzfrequenzgewichteten Anstrengung."
}
},
"scores": {
"sectionTitle": "Wellness-Werte",
"recovery": "Erholung",
"stress": "Stress",
"strain": "Belastung"
},
"coincident": {
"label": "Mehrere Vitalwerte außerhalb der Baseline",
"summary": "{count} deiner Vitalwerte lagen heute außerhalb ihres persönlichen Bereichs.",
"vitals": "Außerhalb des Bereichs: {list}"
}
},
"cardioFitness": {
"title": "Fitness",
"description": "Deine VO₂max im Zeitverlauf — das wichtigste Maß für die Ausdauerfitness.",
"chartTitle": "VO₂max (mL/(kg·min))",
"emptyState": {
"title": "Noch keine Fitnessdaten",
"description": "VO₂max-Werte erscheinen hier, sobald Apple Health sie aus deinen Workouts synchronisiert."
}
},
"navCardioFitness": "Fitness",
"cardioRecovery": {
"title": "Erholungspuls",
"description": "Wie stark dein Puls eine Minute nach maximaler Belastung abfällt.",
"chartTitle": "Erholungspuls (bpm)",
"emptyState": {
"title": "Noch keine Erholungspuls-Daten",
"description": "Erholungspuls-Werte erscheinen hier, sobald Apple Health sie aus einem Workout synchronisiert."
}
},
"navCardioRecovery": "Erholungspuls",
"wristTemperature": {
"title": "Handgelenktemperatur",
"description": "Deine nächtliche Handgelenktemperatur, gemessen an deinem eigenen Ausgangswert.",
"chartTitle": "Handgelenktemperatur (°C)",
"emptyState": {
"title": "Noch keine Handgelenktemperatur-Daten",
"description": "Werte erscheinen hier, sobald Apple Health das nächtliche Tragen synchronisiert."
}
},
"navWristTemperature": "Handgelenk-Temp.",
"falls": {
"title": "Stürze",
"description": "Von deiner Apple Watch erkannte schwere Stürze, pro Tag gezählt.",
"chartTitle": "Stürze pro Tag",
"emptyState": {
"title": "Noch keine Sturzdaten",
"description": "Erkannte Stürze erscheinen hier, sobald Apple Health synchronisiert. Keine Stürze ist das Ziel."
}
},
"navFalls": "Stürze",
"sixMinuteWalk": {
"title": "6-Minuten-Gehtest",
"description": "Apples Schätzung, wie weit du in sechs Minuten gehen könntest.",
"chartTitle": "6-Minuten-Gehstrecke (m)",
"emptyState": {
"title": "Noch keine 6-Minuten-Gehtest-Daten",
"description": "Schätzwerte erscheinen hier, sobald Apple Health synchronisiert."
}
},
"navSixMinuteWalk": "6-Min-Gehtest",
"stairAscentSpeed": {
"title": "Treppensteig-Tempo",
"description": "Wie schnell du Treppen hinaufgehst, gemessen von iPhone oder Watch.",
"chartTitle": "Treppensteig-Tempo (m/s)",
"emptyState": {
"title": "Noch keine Daten zum Treppensteig-Tempo",
"description": "Werte erscheinen hier, sobald Apple Health synchronisiert."
}
},
"navStairAscentSpeed": "Treppe hoch",
"stairDescentSpeed": {
"title": "Treppab-Tempo",
"description": "Wie schnell du Treppen hinabgehst, gemessen von iPhone oder Watch.",
"chartTitle": "Treppab-Tempo (m/s)",
"emptyState": {
"title": "Noch keine Daten zum Treppab-Tempo",
"description": "Werte erscheinen hier, sobald Apple Health synchronisiert."
}
},
"navStairDescentSpeed": "Treppe runter",
"breathingDisturbances": {
"title": "Atemaussetzer",
"description": "Wie oft deine Atmung im Schlaf gestört war.",
"chartTitle": "Atemaussetzer im Schlaf",
"emptyState": {
"title": "Noch keine Daten zu Atemaussetzern",
"description": "Werte erscheinen hier, sobald Apple Health sie von einer unterstützten Apple Watch synchronisiert."
}
},
"navBreathingDisturbances": "Atmung",
"rhythmEvents": {
"sectionTitle": "Gesundheitshinweise des Geräts",
"sectionIntro": "Hinweise, die deine Uhr eigenständig gesendet hat. HealthLog zeigt diese nur an — es erkennt, bestätigt oder interpretiert sie nicht.",
"loadError": "Die Gerätehinweise konnten nicht geladen werden.",
"disclaimer": "Dies sind Hinweise, die dein Gerät mit seiner eigenen integrierten Erkennung erzeugt hat. Sie werden hier nur zu deiner Information angezeigt. Dies ist keine medizinische Bewertung durch HealthLog, und HealthLog stellt keine Diagnose. Wenn dich ein Hinweis beunruhigt, sprich mit deiner Ärztin oder deinem Arzt.",
"event": {
"irregularRhythm": "Hinweis auf unregelmäßigen Rhythmus",
"highHeartRate": "Hinweis auf hohe Herzfrequenz",
"lowHeartRate": "Hinweis auf niedrige Herzfrequenz",
"walkingSteadiness": "Hinweis zur Gehstabilität",
"breathingDisturbance": "Atemstörung während des Schlafs"
},
"verdict": {
"irregular": "Dein Gerät hat einen möglichen unregelmäßigen Rhythmus erkannt.",
"notDetected": "Dein Gerät hat keinen unregelmäßigen Rhythmus erkannt.",
"inconclusive": "Dein Gerät konnte keine Messung vornehmen.",
"low": "Dein Gerät hat eine niedrige Gehstabilität erkannt.",
"veryLow": "Dein Gerät hat eine sehr niedrige Gehstabilität erkannt.",
"fired": "Dein Gerät hat dieses Ereignis erkannt."
}
}
},
"thresholds": {
"defaultLabel": "Default",
@@ -2393,7 +2947,7 @@
"description": "Withings, moodLog und weitere verbundene Dienste."
},
"notifications": {
"title": "Benachrichtigungs-Kanäle",
"title": "Benachrichtigungen",
"description": "Live-Übersicht aller konfigurierten Kanäle."
},
"dashboard": {
@@ -2401,13 +2955,12 @@
"description": "Kachel-Layout und Reihenfolge."
},
"thresholds": {
"title": "Zielwerte & Quellen",
"description": "Pro Metrik: eigener Zielbereich und welche Quelle führt, wenn mehrere am selben Tag denselben Wert liefern."
"title": "Zielwerte",
"description": "Pro Metrik dein eigener Zielbereich für gesunde Werte."
},
"sources": {
"title": "Quellen",
"description": "Lege fest, welche Quelle führt, wenn mehrere am selben Tag dieselbe Metrik liefern.",
"redirectNotice": "Quellen sind jetzt Teil von „Zielwerte & Quellen“.",
"cardTitle": "Quellen-Priorität pro Metrik",
"help": "Wenn mehrere Quellen denselben Wert am selben Tag liefern (z. B. Withings + Apple Health beide 1.200 Schritte), wird die oberste Quelle als kanonisch verwendet. Andere Quellen bleiben gespeichert für die Audit-Spur.",
"resetDefaults": "Auf Standard zurücksetzen",
@@ -2457,7 +3010,7 @@
}
},
"ai": {
"title": "Auswertungen",
"title": "KI-Auswertungen",
"description": "Provider, Modell, API-Key."
},
"api": {
@@ -2706,6 +3259,11 @@
"testProvider": "Aktiven Provider testen",
"testSuccess": "OK — {provider} ({model})",
"testFailedShort": "Test fehlgeschlagen: {message}",
"testUnexpectedResponse": "Verbindung zum KI-Anbieter fehlgeschlagen — unerwartete Antwort vom Server.",
"testReasonCredentials": "Der Anbieter hat die Anmeldedaten abgelehnt — melde dich in den KI-Einstellungen erneut an.",
"testReasonRateLimited": "Der Anbieter hat die Anfrage gedrosselt — versuche es in Kürze erneut.",
"testReasonServerError": "Der KI-Anbieter hat einen Serverfehler zurückgegeben.",
"testReasonUnreachable": "Der KI-Anbieter war nicht erreichbar.",
"disableCoach": {
"title": "Coach ausblenden",
"description": "Versteckt den Coach-Button und das Coach-Panel auf allen Seiten.",
@@ -2939,7 +3497,9 @@
"fhirNote": "Die strukturierte Ausgabe folgt dem FHIR-R4-Standard (ePA-kompatibel).",
"generate": "Erstellen",
"error": "Export fehlgeschlagen ({code})."
}
},
"globalExcludedInjectionSitesLabel": "Global ausgeschlossene Injektionsstellen",
"globalExcludedInjectionSitesHint": "Hier gelistete Stellen werden für keine Medikation angeboten und beim Erfassen einer Dosis abgelehnt — auch wenn eine Medikation sie bevorzugt."
},
"admin": {
"title": "Administration",
@@ -3760,8 +4320,6 @@
}
},
"targets": {
"title": "Zielwerte",
"introText": "Deine aktuellen Werte im Vergleich zu empfohlenen Zielbereichen. Quellen der Leitlinien sind auf jeder Karte verlinkt.",
"bmi": "BMI",
"average30d": "30-Tage-Schnitt:",
"consistency": {
@@ -3783,23 +4341,11 @@
"sixDaysAgo": "vor 6 Tagen"
}
},
"card": {
"lastMet": "Zuletzt im Ziel: {when}",
"streak": "{count} Tage in Folge"
},
"relativeDay": {
"today": "heute",
"yesterday": "gestern",
"daysAgo": "vor {count} Tagen"
},
"summary": {
"weekTitle": "{met} von {total} Zielwerten diese Woche erreicht",
"weekTitleAll": "Alle {total} Zielwerte diese Woche erreicht",
"streakHighlight": "{count} Tage in Folge: {metric}"
},
"coach": {
"cta": "Coach fragen"
},
"edit": {
"openLabel": "Zielbereich für {metric} bearbeiten",
"title": "Zielbereich bearbeiten — {metric}",
@@ -3831,31 +4377,10 @@
"aboveTarget": "{delta} {unit} über Zielbereich",
"belowTarget": "{delta} {unit} unter Zielbereich",
"diastolic": "Diastolisch",
"noMeasurementYet": "Noch keine Messungen",
"noMeasurementData": "Keine Messdaten verfügbar. Füge Messwerte hinzu, um deine Ziele zu sehen.",
"profileIncomplete": "Profil unvollständig",
"profileIncompleteHeight": "Trage deine Körpergröße im Profil ein, um BMI-Ziele zu aktivieren.",
"profileIncompleteAge": "Trage dein Geburtsdatum im Profil ein, um altersspezifische Ziele zu aktivieren.",
"profileIncompleteHeightAge": "Trage Körpergröße und Geburtsdatum im Profil ein, um personalisierte Ziele zu sehen.",
"compliancePerMedication": "Einnahmetreue pro Medikament",
"glucoseFasting": "Glukose — nüchtern",
"glucosePostprandial": "Glukose — postprandial",
"glucoseRandom": "Glukose — beliebig",
"glucoseBedtime": "Glukose — vor dem Schlafen",
"loadError": "Zielwerte konnten nicht geladen werden",
"label": {
"WEIGHT": "Gewicht",
"BLOOD_PRESSURE": "Blutdruck",
"BLOOD_PRESSURE_IN_TARGET": "Blutdruck im Zielbereich",
"PULSE": "Ruhepuls",
"SLEEP_DURATION": "Schlafdauer",
"BMI": "BMI",
"BODY_FAT": "Körperfett",
"ACTIVITY_STEPS": "Schritte/Tag",
"MEDICATION_COMPLIANCE": "Einnahmetreue",
"MOOD_SCORE": "Stimmung",
"MOOD_STABILITY": "Stimmungsstabilität"
},
"status": {
"underweight": "Untergewicht",
"normal": "Normal",
+612 -87
View File
@@ -158,7 +158,12 @@
"chartsTitle": "Trend charts",
"aiSummaryTitle": "AI summary — not clinically validated",
"aiSummaryDisclaimer": "This section was generated automatically by an AI model and is not clinically validated. It does not replace a medical assessment.",
"packageReadme": "HealthLog health record\n\nThis package contains:\n- report.pdf: the health report for your doctor.\n- bundle.json: the same data as an HL7 FHIR R4 document Bundle, ePA-compatible.\n\nGenerated with HealthLog."
"packageReadme": "HealthLog health record\n\nThis package contains:\n- report.pdf: the health report for your doctor.\n- bundle.json: the same data as an HL7 FHIR R4 document Bundle, ePA-compatible.\n\nGenerated with HealthLog.",
"wellnessTitle": "Wellness summary",
"wellnessDisclaimer": "These are descriptive wellness scores computed from your tracked signals (0–100). They are not clinical assessments or diagnoses.",
"wellnessRecovery": "Recovery",
"wellnessStress": "Stress",
"wellnessStrain": "Strain"
},
"errorBoundary": {
"title": "Something went wrong",
@@ -425,7 +430,22 @@
"dailyTotalCaption": "({count} samples)",
"expandDay": "Show samples for this day",
"collapseDay": "Hide samples for this day",
"loadError": "Could not load measurements."
"loadError": "Could not load measurements.",
"typeCardioRecovery": "Cardio recovery",
"typeWristTemperature": "Wrist temperature",
"typeFallCount": "Falls",
"typeSixMinuteWalkDistance": "Six-minute walk distance",
"typeStairAscentSpeed": "Stair ascent speed",
"typeStairDescentSpeed": "Stair descent speed",
"typeBreathingDisturbances": "Sleep breathing disturbances",
"typeIrregularRhythmNotification": "Irregular rhythm notification",
"typeHighHeartRateEvent": "High heart rate notification",
"typeLowHeartRateEvent": "Low heart rate notification",
"typeWalkingSteadinessEvent": "Walking steadiness alert",
"typeBreathingDisturbanceEvent": "Breathing disturbance",
"typeRecoveryScore": "Recovery score",
"typeStressScore": "Stress score",
"typeStrainScore": "Strain score"
},
"mood": {
"title": "Mood",
@@ -461,7 +481,49 @@
"deleteConfirmTitle": "Delete entry?",
"deleteConfirmDescription": "This mood entry will be permanently deleted.",
"pageInfo": "Page {page} of {total}",
"loginRequired": "Please sign in to view mood entries."
"loginRequired": "Please sign in to view mood entries.",
"note": "Note",
"notePlaceholder": "Add a note about how you're feeling…",
"tagPicker": "Tags",
"tagCategory": {
"feelings": "Feelings",
"sleep": "Sleep",
"health": "Health",
"social": "Social",
"work": "Work"
},
"tag": {
"happy": "happy",
"excited": "excited",
"grateful": "grateful",
"relaxed": "relaxed",
"content": "content",
"tired": "tired",
"unsure": "unsure",
"bored": "bored",
"tense": "tense",
"angry": "angry",
"stressed": "stressed",
"sad": "sad",
"sleptWell": "slept well",
"sleptOk": "slept okay",
"sleptPoorly": "slept poorly",
"earlyNight": "early night",
"workedOut": "worked out",
"ateWell": "ate well",
"hydrated": "hydrated",
"walked": "walked",
"alcohol": "alcohol",
"family": "family",
"friends": "friends",
"party": "party",
"alone": "alone",
"productive": "productive",
"overtime": "overtime",
"dayOff": "day off",
"travel": "travel",
"sickDay": "sick day"
}
},
"medications": {
"title": "Medications",
@@ -544,11 +606,14 @@
"veryOverdue": "Very overdue",
"compliance7d": "7-day compliance",
"compliance30d": "30-day compliance",
"complianceWindow": "{days}-day compliance",
"lastIntake": "Last intake:",
"nextIntake": "Next intake:",
"dayStreak": "day streak",
"taken": "Taken",
"skipped": "Skipped",
"intakeToastTaken": "{name} recorded as taken",
"intakeToastSkipped": "{name} skipped",
"weekdaySunday": "Sunday",
"weekdayMonday": "Monday",
"weekdayTuesday": "Tuesday",
@@ -759,7 +824,11 @@
"back": "Back",
"next": "Next",
"save": "Save",
"saveEdit": "Save changes"
"saveEdit": "Save changes",
"nextHint": "Next: {step}",
"reviewHint": "Next: Save",
"jumpFirst": "Jump to the first step",
"jumpLast": "Jump to review and save"
},
"nl": {
"button": "Describe"
@@ -769,11 +838,13 @@
"title": "What's the medication called?",
"subline": "Exactly as it appears on the box.",
"label": "Name",
"placeholder": "e.g. Ramipril"
"placeholder": "e.g. Ramipril",
"short": "Name"
},
"step2": {
"title": "What kind of medication is it?",
"subline": "We use this for the right templates and analytics."
"subline": "We use this for the right templates and analytics.",
"short": "Type"
},
"step3": {
"title": "What's the dose?",
@@ -805,15 +876,18 @@
"dosesPerUnitPlaceholder": "e.g. 4",
"dosesPerUnitHint": "Doses per pen / vial — leave empty for no inventory tracking.",
"injectionSiteToggle": "Injection-site rotation",
"injectionSiteHint": "Optional rotation guide. The site for each dose is recorded when you log the intake."
"injectionSiteHint": "Optional rotation guide. The site for each dose is recorded when you log the intake.",
"short": "Dose"
},
"step4": {
"title": "Over what period are you taking it?",
"subline": "A start date is enough for today. You can add an end date later."
"subline": "A start date is enough for today. You can add an end date later.",
"short": "When"
},
"step5": {
"title": "How often do you take it?",
"subline": "Pick the cadence — the details follow on the next step."
"subline": "Pick the cadence — the details follow on the next step.",
"short": "Cadence"
},
"step6": {
"title": "What does that look like in detail?",
@@ -829,7 +903,8 @@
"rollingDays": {
"label": "Every",
"suffix": "days from the last intake"
}
},
"short": "Detail"
},
"step7": {
"title": "When do you take it?",
@@ -840,14 +915,16 @@
"noon": "Noon",
"evening": "Evening",
"night": "Night"
}
},
"short": "Times"
},
"step8": {
"title": "All done — should HealthLog remind you?",
"subline": "Review everything below before you save.",
"remindersLabel": "Reminders on",
"remindersDescription": "Push, Telegram or ntfy — based on your settings.",
"multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release."
"multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release.",
"short": "Done"
}
},
"classRow": {
@@ -923,26 +1000,17 @@
"submitFailed": "Could not save the medication — please retry."
}
},
"titration": {
"section": "Titration ladder",
"loading": "Loading titration data",
"loadFailed": "Titration data failed to load.",
"drugLadderHeader": "Standard ladder (EMA reference)",
"typicalWeeksOnStep": "≥ {weeks} wks typical",
"youAreHere": "You are here",
"nextStepCaption": "Next step (typical): {dose} mg after ~{weeks} weeks on the current step.",
"ceilingMessage": "You are at the top of the standard ladder.",
"escalationDueHint": "You've been on this step for {weeks} weeks; the ladder typically steps up around {typical} weeks.",
"currentStepDwell": "On this step for {weeks} weeks",
"doseUnitMg": "mg",
"emptyState": "No dose history yet. Log a dose to see where you are on the ladder.",
"nonStandardDose": "Current dose is outside the standard ladder buckets.",
"disclaimer": "EMA reference values. Talk to your doctor before changing doses.",
"emaSourceCta": "EMA source",
"stepLabel": "Step {n}"
"doseStrength": {
"title": "Dose strength over time",
"axisLabel": "Recorded dose ({unit})",
"tooltipLabel": "Dose",
"currentDose": "Current dose: {dose} {unit}",
"noCurrentDose": "No dose recorded yet.",
"empty": "No titration history yet. Log a dose change to see the strength curve."
},
"injectionSiteBodyOutlineAriaLabel": "Body outline",
"injectionSitePickerAriaLabel": "Injection site picker",
"injectionSiteRecommendedAriaLabel": "{site} — recommended next site",
"siteAbdomenLeft": "Abdomen, lower left",
"siteAbdomenRight": "Abdomen, lower right",
"siteAbdomenUpperLeft": "Abdomen, upper left",
@@ -1004,10 +1072,11 @@
"phaseResetDefaults": "Reset to defaults",
"phaseSaved": "Reminder phases saved",
"phaseReset": "Reset to defaults",
"sourceWeb": "Website",
"sourceApi": "External API",
"sourceReminder": "Telegram / Reminder",
"sourceImport": "CSV Import",
"sourceWeb": "Web",
"sourceApi": "iOS App",
"sourceReminder": "Reminder",
"sourceImport": "Import",
"intakeSourceVia": "via {origin}",
"intakeHistoryEmptyTitle": "No intakes recorded yet",
"intakeHistoryEmptyDescription": "Log a dose on the daily intake page to start building the history.",
"intakeHistoryEmptyAction": "Open daily intake",
@@ -1078,11 +1147,6 @@
"emptyState": "No intake events yet.",
"emptyStateCta": "Log a dose to see the estimated level take shape.",
"estimateNote": "Educational estimate from EMA-published population pharmacokinetics. Not a measurement.",
"gatedTitle": "Research Mode is off",
"gatedBody": "Turn on Research Mode in Settings to see the estimated drug-level curve. The curve is opt-in because it is a learning aid, not a clinical measurement.",
"gatedStaleTitle": "Disclaimer needs re-acknowledgment",
"gatedStaleBody": "The Research Mode disclaimer was updated since you last acknowledged it. Re-read and confirm in Settings to bring the chart back.",
"gatedCta": "Open Research Mode in Settings",
"unknownDrug": "{name} is not in the EMA-approved GLP-1 catalog, so HealthLog cannot render a population-PK curve for it."
}
},
@@ -1170,6 +1234,14 @@
"unit": "minutes",
"saved": "Reminder window saved",
"failed": "Could not save the reminder window."
},
"codes": {
"label": "Clinical codes (ATC / RxNorm)",
"note": "Optional. Used on the FHIR health-record export. Codes are taken exactly as entered — never guessed.",
"atcLabel": "ATC",
"rxNormLabel": "RxNorm",
"saved": "Clinical codes saved",
"failed": "Could not save the clinical codes."
}
},
"zone": {
@@ -1235,7 +1307,7 @@
"data": "Data",
"reminders": "Reminders",
"lifecycle": "Lifecycle",
"danger": "Danger zone"
"externalApi": "External API"
},
"dataPortability": {
"import": {
@@ -1258,10 +1330,18 @@
"history": {
"back": "Back to medication",
"subtitle": "Full intake history",
"plannedSuffix": "planned",
"drugLevelDisclosure": "Show estimated drug-level curve"
"plannedSuffix": "planned"
}
}
},
"logInjectionSiteTitle": "Log injection site",
"logInjectionSiteDescription": "Where did you inject {name}? Optional — you can skip this.",
"logInjectionSiteSkip": "Skip",
"logInjectionSiteConfirm": "Save site",
"logInjectionSiteNoneAvailable": "No injection site is available — every site is excluded in your settings.",
"trackInjectionSitesToggle": "Track injection sites",
"trackInjectionSitesHint": "After a dose is taken, ask which site was used and suggest the next rotation.",
"allowedSitesLabel": "Allowed sites",
"allowedSitesHint": "Leave all selected for no restriction. Sites you excluded globally are never offered."
},
"charts": {
"loadingLabel": "Loading chart…",
@@ -1272,6 +1352,13 @@
"bodyFat": "Body Fat",
"sleep": "Sleep",
"steps": "Steps",
"hrv": "HRV",
"restingHeartRate": "Resting heart rate",
"activeEnergy": "Active energy",
"flights": "Flights climbed",
"distance": "Walking + running distance",
"vo2Max": "VO₂ max",
"bodyTemperature": "Body temperature",
"noData": "No data in selected time range",
"bucketWeekly": "Weekly avg",
"bucketMonthly": "Monthly avg",
@@ -1336,7 +1423,17 @@
"personalBaseline": "Your normal",
"deltaVsBaseline": "{delta} vs. your normal",
"deltaUnchanged": "matches your normal",
"deltaVsTarget": "{delta} vs. target"
"deltaVsTarget": "{delta} vs. target",
"weekdaysFull": {
"mon": "Monday",
"tue": "Tuesday",
"wed": "Wednesday",
"thu": "Thursday",
"fri": "Friday",
"sat": "Saturday",
"sun": "Sunday"
},
"rangeBand": "Range"
},
"chart": {
"overlay": {
@@ -1376,6 +1473,7 @@
},
"insights": {
"noAnalysisYet": "No analysis yet.",
"assessmentPreparing": "Preparing your assessment…",
"emptyTitle": "Not enough data yet",
"emptyDescription": "Insights light up once you've logged a handful of measurements. Start with weight or blood pressure.",
"emptyAddMeasurement": "Add a measurement",
@@ -1432,6 +1530,8 @@
"heroRegenerating": "Regenerating",
"regenerateAnalysis": "Re-run analysis",
"regenerateSuccess": "Analysis refreshed",
"warmAssessments": "Prepare assessments",
"warmStarted": "Assessments are being prepared in the background",
"heroFallbackSubtitle": "A daily read of your trends, drawn straight from the numbers you've logged.",
"heroGreetingMorning": "Good morning",
"heroGreetingAfternoon": "Good afternoon",
@@ -1498,7 +1598,22 @@
},
"trendsRow": {
"title": "Trends",
"subtitle": "Last 30 days at a glance"
"subtitle": "Last 30 days at a glance",
"caption": {
"bp": "Systolic and diastolic readings over the last 30 days.",
"weight": "Body-weight trend over the last 30 days.",
"pulse": "Resting pulse over the last 30 days.",
"mood": "Daily mood score over the last 30 days.",
"sleep": "Nightly sleep duration over the last 30 days.",
"steps": "Daily step count over the last 30 days.",
"hrv": "Heart-rate variability over the last 30 days.",
"resting_hr": "Resting heart rate over the last 30 days.",
"active_energy": "Active energy burned over the last 30 days.",
"flights": "Flights of stairs climbed over the last 30 days.",
"distance": "Walking and running distance over the last 30 days.",
"vo2_max": "VO₂ max over the last 30 days.",
"body_temp": "Body temperature over the last 30 days."
}
},
"vo2Max": {
"statLatest": "Latest",
@@ -1515,6 +1630,23 @@
"confidenceLow": "Low confidence",
"pendingLabel": "Generating trend commentary"
},
"range": {
"label": "Time range",
"noPriorData": "no prior-period data",
"option": {
"7d": "7d",
"30d": "30d",
"90d": "90d",
"1y": "1y"
},
"priorLabel": {
"7d": "vs prior 7d",
"30d": "vs prior 30d",
"90d": "vs prior 90d",
"1y": "vs prior year"
},
"noChange": "no change"
},
"correlationRow": {
"title": "Relationships",
"subtitle": "Pre-defined hypotheses, gated on n ≥ 14 and p < 0.05",
@@ -1800,7 +1932,124 @@
"medikamenteEmptyDescription": "Compliance insights need at least one tracked medication.",
"medikamenteEmptyAction": "Open medications",
"therapyTimelineDescription": "Chronological GLP-1 therapy log — dose changes, injections, inventory, side effects.",
"bmiDescription": "BMI derived from your weight and height, with a written assessment."
"bmiDescription": "BMI derived from your weight and height, with a written assessment.",
"target": {
"range": "Target: {min}–{max} {unit}",
"bpRange": "Target: {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}",
"inTargetShare": "{pct}% of logged days within target",
"adjustLink": "Adjust target range"
},
"explainer": {
"trigger": "What is {title}?",
"bloodPressureTitle": "Blood pressure",
"bloodPressureBody": "Blood pressure is the force your blood exerts on the artery walls, written as systolic over diastolic (the peak during a heartbeat over the resting value between beats). Consistently high readings strain the heart and blood vessels over time.",
"pulseTitle": "Pulse",
"pulseBody": "Your pulse is how many times your heart beats per minute. A spot reading reflects what you were doing in the moment — at rest it is typically 60–100 beats per minute, and it rises naturally with movement, stress, or caffeine.",
"weightTitle": "Weight",
"weightBody": "Body weight is one of the simplest signals of long-term energy balance and overall health. The trend over weeks matters far more than any single reading, which fluctuates with hydration, food, and time of day.",
"bmiTitle": "Body mass index (BMI)",
"bmiBody": "BMI is your weight divided by your height squared, giving a single number that places you in a broad weight category. It is a population-level screen, not a diagnosis, and does not distinguish muscle from fat.",
"sleepTitle": "Sleep",
"sleepBody": "Sleep duration is how long you actually slept across the night, split into light, deep, and REM stages. Most adults need roughly seven to nine hours; both consistent quantity and quality support recovery, mood, and metabolism.",
"moodTitle": "Mood",
"moodBody": "Mood tracking records how you feel over time on a simple scale. Logging it regularly reveals patterns — how sleep, activity, or medication line up with how you feel — that a single day never shows.",
"medicationsTitle": "Medication adherence",
"medicationsBody": "Adherence is how reliably you take your medication as prescribed, shown here as the share of expected doses you actually logged. Steady intake is often what makes a treatment work as intended.",
"restingHrTitle": "Resting heart rate",
"restingHrBody": "Resting heart rate is your heart rate when you are completely at rest, usually measured overnight. A lower resting rate generally points to better cardiovascular fitness, and a rising trend can be an early sign of stress or illness.",
"hrvTitle": "Heart rate variability (HRV)",
"hrvBody": "HRV is the tiny variation in time between consecutive heartbeats. Higher variability usually reflects a well-recovered, adaptable nervous system, while a sustained drop can signal fatigue, stress, or insufficient recovery.",
"oxygenSaturationTitle": "Oxygen saturation (SpO₂)",
"oxygenSaturationBody": "SpO₂ is the percentage of your red blood cells carrying oxygen. Readings of 95–100% are normal at rest; values that stay below 90% warrant attention, though brief nightly dips are common.",
"bodyTemperatureTitle": "Body temperature",
"bodyTemperatureBody": "Body temperature reflects your core thermal state, normally around 37 °C with small daily swings. A sustained rise can indicate infection, while overnight wrist readings mainly track relative changes rather than a clinical value.",
"activeEnergyTitle": "Active energy",
"activeEnergyBody": "Active energy is the calories you burn through movement, on top of the baseline your body uses at rest. It is a direct measure of how physically active your day was.",
"workoutsTitle": "Workouts",
"workoutsBody": "Workouts are the structured exercise sessions your devices record, with type, duration, and energy. Tracking them shows how regular training accumulates over weeks and how it relates to your other health signals.",
"respiratoryRateTitle": "Respiratory rate",
"respiratoryRateBody": "Respiratory rate is the number of breaths you take per minute. At rest most adults breathe roughly twelve to twenty times a minute; a sustained rise can accompany illness, exertion, or stress.",
"bodyWaterTitle": "Total body water",
"bodyWaterBody": "Total body water is the share of your body weight made up of fluid, inside and around your cells. It shifts with hydration, food, and activity, so the trend over weeks tells you more than any single reading.",
"boneMassTitle": "Bone mass",
"boneMassBody": "Bone mass is the estimated weight of the mineral in your skeleton. Scale-based estimates are approximate, but a steady or rising trend supports long-term bone strength.",
"fatFreeMassTitle": "Fat-free mass",
"fatFreeMassBody": "Fat-free mass is everything in your body that is not fat — muscle, bone, organs, and water. Holding or building it while losing weight usually signals healthier body composition.",
"fatMassTitle": "Fat mass",
"fatMassBody": "Fat mass is the total weight of fat in your body. Some fat is essential for hormones and energy storage, but too much — especially around the abdomen — raises long-term health risk.",
"muscleMassTitle": "Muscle mass",
"muscleMassBody": "Muscle mass is the estimated weight of the muscle in your body. More muscle supports strength, metabolism, and mobility, and it tends to need active effort to preserve as you age.",
"visceralFatTitle": "Visceral fat",
"visceralFatBody": "Visceral fat is the fat stored deep in the abdomen around your organs. Unlike fat under the skin, a high level is closely linked to heart disease and type 2 diabetes, so lower is generally better.",
"leanBodyMassTitle": "Lean body mass",
"leanBodyMassBody": "Lean body mass is your total weight minus body fat — muscle, bone, organs, and water combined. Tracking it alongside weight shows whether changes come from muscle or from fat.",
"flightsClimbedTitle": "Flights climbed",
"flightsClimbedBody": "Flights climbed counts the floors you go up each day, with one flight roughly ten feet of elevation. It is a simple marker of incidental activity and lower-body effort.",
"walkingDistanceTitle": "Walking + running distance",
"walkingDistanceBody": "Walking and running distance is the ground you cover on foot each day. It complements step count by reflecting how far you moved, not just how often you stepped.",
"walkingSteadinessTitle": "Walking steadiness",
"walkingSteadinessBody": "Walking steadiness estimates how stable and balanced your gait is, classed from OK to low. A lower level points to a higher chance of a fall and is worth discussing with a clinician.",
"walkingHeartRateTitle": "Walking heart rate",
"walkingHeartRateBody": "Walking heart rate is your average pulse during a steady walk. A lower walking rate over time often reflects improving cardiovascular fitness for the same effort.",
"walkingAsymmetryTitle": "Walking asymmetry",
"walkingAsymmetryBody": "Walking asymmetry is the share of time your steps are uneven between the left and right leg. A higher percentage can point to pain, injury, or a gait problem worth looking into.",
"doubleSupportTimeTitle": "Double support time",
"doubleSupportTimeBody": "Double support time is the share of each walking cycle when both feet touch the ground at once. A higher percentage usually means a more cautious, less stable gait.",
"stepLengthTitle": "Step length",
"stepLengthBody": "Step length is the average distance from one footfall to the next. A shorter step over time can reflect fatigue, caution, or a change in mobility.",
"walkingSpeedTitle": "Walking speed",
"walkingSpeedBody": "Walking speed is how fast you move on a level walk. It is a well-studied marker of overall health and mobility, and a steady or rising pace is a good sign.",
"pulseWaveVelocityTitle": "Pulse wave velocity",
"pulseWaveVelocityBody": "Pulse wave velocity measures how fast the pressure wave from each heartbeat travels along your arteries. A faster wave indicates stiffer arteries, which is linked to higher cardiovascular risk.",
"vascularAgeTitle": "Vascular age",
"vascularAgeBody": "Vascular age estimates how old your arteries behave compared with your actual age, based on arterial stiffness. A vascular age below your real age suggests healthier blood vessels.",
"environmentalAudioTitle": "Environmental sound levels",
"environmentalAudioBody": "Environmental sound levels track the loudness of the world around you in decibels. Prolonged exposure above about 70 dB can damage hearing over time, so lower daily averages are safer.",
"headphoneAudioTitle": "Headphone audio levels",
"headphoneAudioBody": "Headphone audio levels track how loudly you listen through earbuds or headphones, in decibels. Keeping the volume down and taking breaks protects your hearing over the long run.",
"audioEventsTitle": "Loud sound events",
"audioEventsBody": "Loud sound events flag moments when noise around you reached a level that can harm hearing with enough exposure. Frequent events are a cue to seek quieter surroundings or hearing protection.",
"daylightTitle": "Time in daylight",
"daylightBody": "Time in daylight is how long you spend in outdoor light each day. Regular daylight supports a healthy sleep-wake rhythm and mood, and time outdoors is linked to eye health.",
"bloodGlucoseTitle": "Blood glucose",
"bloodGlucoseBody": "Blood glucose is the amount of sugar in your blood, the body's main energy source. It rises after eating and settles between meals; readings that stay high over time can signal diabetes or prediabetes.",
"skinTemperatureTitle": "Skin temperature",
"skinTemperatureBody": "Skin temperature here is the small nightly change in your wrist temperature from your own baseline, not an absolute fever reading. Shifts can track sleep, environment, illness, or menstrual-cycle phase.",
"cardioFitnessTitle": "Cardio fitness (VO₂ max)",
"cardioFitnessBody": "VO₂ max is how much oxygen your body can use at peak effort. It is the single best lab marker of aerobic fitness, and a higher or rising value is a strong sign of cardiovascular health.",
"cardioRecoveryTitle": "Cardio recovery",
"cardioRecoveryBody": "Cardio recovery is how many beats per minute your heart rate falls in the first minute after you stop hard exercise. A bigger drop means your heart settles down faster, which is a sign of good fitness.",
"wristTemperatureTitle": "Wrist temperature",
"wristTemperatureBody": "Wrist temperature is measured while you sleep. Apple shows it as the difference from your personal baseline; a steady reading is normal, while a sustained rise can hint at illness, intense training, or a cycle change.",
"fallsTitle": "Falls",
"fallsBody": "This counts the hard falls your Apple Watch detects. Zero is the goal; a rising count is a mobility-risk signal worth raising with a clinician, especially for older adults.",
"sixMinuteWalkTitle": "Six-minute walk distance",
"sixMinuteWalkBody": "This is Apple's estimate of how far you could walk in six minutes — a long-standing clinical test of mobility and cardiopulmonary endurance. A steady or rising distance is a good sign.",
"stairAscentSpeedTitle": "Stair ascent speed",
"stairAscentSpeedBody": "This is how fast you climb a flight of stairs. It is a sensitive marker of leg strength and mobility — a steady or rising pace is a good sign, and a falling one is worth watching.",
"stairDescentSpeedTitle": "Stair descent speed",
"stairDescentSpeedBody": "This is how fast you go down a flight of stairs. Together with ascent speed it tracks leg control and balance — a steady pace is a good sign, and a falling one is worth watching.",
"breathingDisturbancesTitle": "Sleep breathing disturbances",
"breathingDisturbancesBody": "Your Apple Watch can estimate how often your breathing was interrupted during sleep. A lower index is better; a level Apple flags as elevated, especially over many nights, is worth raising with a clinician."
},
"stats": {
"label": "Summary statistics",
"min": "Min",
"max": "Max",
"median": "Median",
"mean": "Mean"
},
"showAllValues": "Show all readings",
"valuesPageTitle": "{metric} — all readings",
"valuesPageDescription": "Every reading behind this metric. Edit or remove an entry directly.",
"diversity": {
"trigger": "Measurement tip",
"weekday": "Most of your {metric} readings land on one weekday. Logging on other days gives a fuller picture.",
"timeOfDay": "Most of your {metric} readings land at a similar time of day. Varying the time gives a fuller picture."
},
"valuesBack": "Back to insights",
"valuesBackToMetric": "Back to {metric}",
"scoresBack": "Back to insights"
},
"sleep": {
"title": "Sleep",
@@ -2181,7 +2430,312 @@
"description": "Walking-speed readings appear here once Apple Health syncs."
}
},
"navWalkingSpeed": "Walking Speed"
"navWalkingSpeed": "Walking Speed",
"mood": {
"heatmapTitle": "Mood calendar",
"heatmapNoEntry": "No entry",
"legendGreat": "Great",
"legendGood": "Good",
"legendOkay": "Okay",
"legendLow": "Low",
"distributionTitle": "Mood distribution",
"distributionDaysUnit": "days",
"weekdayTitle": "Average by weekday",
"weekdayNoData": "No data",
"weekdayBestWorst": "Best day: {best} · Toughest day: {worst}",
"tagsTitle": "Tags and triggers",
"tagAvgMood": "Average mood when tagged",
"correlationsTitle": "Mood and other metrics",
"correlationsDescription": "How your mood moves with sleep, activity, and pulse on the same day.",
"correlation": {
"sleepTitle": "Mood vs. sleep",
"stepsTitle": "Mood vs. activity",
"pulseTitle": "Mood vs. pulse",
"sleepAxis": "Sleep (min)",
"stepsAxis": "Steps",
"pulseAxis": "Pulse (bpm)",
"strengthStrong": "Strong",
"strengthModerate": "Moderate",
"strengthWeak": "Weak",
"strengthNone": "None",
"source": "{n} paired days · r = {r}",
"emptyTitle": "Not enough paired data yet",
"emptyDescription": "Log mood and this metric on the same days to see a correlation.",
"weightTitle": "Mood vs. weight",
"bloodPressureTitle": "Mood vs. blood pressure",
"weightAxis": "Weight",
"bloodPressureAxis": "Systolic (mmHg)"
},
"structuredTagsTitle": "Tag breakdown",
"inTargetValue": "{pct}%",
"inTargetCaption": "of recent days in your good-mood range",
"narrative": {
"title": "What stands out",
"weekdayDip": "Your mood dips most on {weekday} — {delta} below your week.",
"weekdayPeak": "{weekday} is consistently your best day, averaging {value}.",
"trendUp": "Your mood is trending up over the last {days} days (+{delta} vs the prior period).",
"trendDown": "Your mood is trending down over the last {days} days (-{delta} vs the prior period).",
"weekendUp": "You feel better on weekends (+{delta} vs weekdays).",
"weekendDown": "Your mood runs lower on weekends (-{delta} vs weekdays).",
"tagLift": "Days tagged \"{tag}\" average {delta} above your overall mood.",
"tagDrop": "Days tagged \"{tag}\" run {delta} below your average.",
"inTarget": "You've been in your good-mood range {pct}% of the last 30 days.",
"streak": "You've logged your mood {days} days in a row — keep it up.",
"timeOfDay": "You feel best in the {bucket}, averaging {value}."
},
"timeOfDay": {
"title": "Mood by time of day",
"morning": "Morning",
"afternoon": "Afternoon",
"evening": "Evening",
"night": "Night",
"bestCaption": "You tend to feel best in the {bucket}."
},
"stability": {
"value": "{score}/100",
"bandVerySteady": "Your mood has been very steady",
"bandSteady": "Your mood has been fairly steady",
"bandVariable": "Your mood has been noticeably variable",
"bandVeryVariable": "Your mood has swung a lot lately"
}
},
"derived": {
"coverage": {
"summary": "{present} of {required} inputs · {percent}%",
"ratioLabel": "{present}/{required}",
"historyDays": "Based on {count} days of history",
"missing": "Track these to sharpen this: {list}"
},
"scoreRing": {
"aria": "Score {score} out of 100, {band}",
"ariaProvisional": "Score not available yet — not enough data",
"provisionalCaption": "Not enough data yet",
"band": {
"green": "good",
"yellow": "fair",
"red": "low"
}
},
"provenance": {
"title": "How this is computed",
"trigger": "How this is computed",
"inputsLabel": "Inputs",
"noInputs": "No inputs backed this value yet.",
"methodLabel": "Method",
"window": "From your last {days} days · {source} data",
"asOf": "as of {date}",
"standardLink": "Standard: {name}"
},
"vitals": {
"sectionTitle": "Your vitals",
"loadingLabel": "Loading your vitals",
"loadError": "Could not load your vitals right now.",
"building": "Building your typical range — {count} of {target} days",
"typicalRange": "Typical for you: {low}–{high}",
"fitnessYounger": "Cardio fitness of someone ~{years} yr younger",
"fitnessOlder": "Cardio fitness ~{years} yr above your age",
"fitnessTypical": "Cardio fitness typical for your age",
"fitnessNoNorm": "Add your age and sex to frame this",
"vascularBelow": "{years} yr below your age",
"vascularAbove": "{years} yr above your age",
"vascularMatch": "In line with your age",
"vascularNoAge": "Add your date of birth to frame this",
"hrvBand": {
"balanced": "Balanced for you",
"unbalanced": "Above your usual range",
"low": "Below your usual range"
},
"bmiCategory": {
"underweight": "Underweight (WHO)",
"normal": "Normal range (WHO)",
"overweight": "Overweight (WHO)",
"obese": "Obese (WHO)"
}
},
"anatomy": {
"outOf": "/100",
"contributorsLabel": "Contributors",
"contributorValue": "{value} · {weight}% weight",
"contributorMissing": "no data",
"disclaimer": "Indicative — not a clinical assessment.",
"insufficient": "Not enough data yet to score this. Keep tracking and it will appear here."
},
"composite": {
"SLEEP_SCORE": {
"title": "Sleep score",
"description": "A transparent sleep-quality score with every sub-score shown.",
"method": "A 0–100 blend of sufficiency (asleep vs your age-based need), efficiency (asleep ÷ in-bed), consistency (how steady your sleep midpoint is), timing (vs your habitual midpoint) and composition (REM + deep share). Sub-scores reweight around any that your data cannot support; nothing is fabricated.",
"sub": {
"sufficiency": "Sufficiency",
"efficiency": "Efficiency",
"consistency": "Consistency",
"timing": "Timing",
"composition": "Composition"
}
},
"READINESS": {
"title": "Readiness",
"description": "A daily wellness proxy from how your signals sit against your own baseline.",
"method": "A 0–100 blend of resting-HR deviation, HRV (SDNN) deviation, sleep adequacy, respiratory-rate stability and mood stability — each measured against your personal baseline. Weights renormalise over the components your data supports. A wellness proxy, not a recovery or clinical score.",
"component": {
"rhr": "Resting heart rate",
"hrv": "HRV (SDNN)",
"sleep": "Sleep",
"respiratory": "Respiratory rate",
"mood": "Mood stability"
},
"caveat": "A wellness proxy, not a recovery or clinical score."
},
"VITALS_BASELINE": {
"method": "Your personal typical range is the median of your daily values over the window, ± a robust spread (median absolute deviation, scaled). It tracks YOUR readings, not a population norm — so a value inside the band is typical for you."
},
"HRV_BALANCE": {
"method": "Your recent HRV (SDNN) average placed against your own rolling baseline (median ± a robust spread). Balanced means it sits inside your personal range; the band is built from your data, never a population cut-off."
},
"BMI": {
"method": "Weight ÷ height², classified against the WHO adult categories (underweight / normal / overweight / obese). A screening ratio only — it does not distinguish muscle from fat."
},
"FITNESS_AGE": {
"method": "Your VO₂max placed against age- and sex-referenced cardiorespiratory-fitness percentiles, expressed as a fitness-age delta. The VO₂max is your device's estimate — never recomputed here."
},
"VASCULAR_AGE_DELTA": {
"method": "Your device's vascular-age / pulse-wave-velocity estimate compared with your chronological age, expressed as a delta in years. The estimate is the device's; HealthLog only re-frames it.",
"caveat": "Your device's own estimate, re-framed — descriptive, not a clinical vascular assessment.",
"description": "How your device's vascular-age estimate sits against your chronological age."
},
"COINCIDENT_DEVIATION": {
"method": "A flag raised when two or more of your vitals sit outside their personal typical range on the same day. Each band is the robust median ± k·MAD of your own history, so it adapts to you rather than to a population cut-off.",
"caveat": "An awareness flag from your own baselines — descriptive, not a diagnosis or a clinical alert."
},
"RECOVERY_SCORE": {
"method": "A nightly 0–100 recovery proxy blended from your HRV, resting heart rate and sleep against your own baseline. Higher means better-recovered. Computed once each night and read back here — never recomputed on the fly.",
"caveat": "A descriptive wellness proxy, not a training-grade or clinical recovery measure.",
"description": "A nightly recovery proxy from your HRV, resting heart rate and sleep."
},
"STRESS_SCORE": {
"method": "A nightly 0–100 stress proxy from the shape of your intra-day HRV against your baseline. Higher means more physiological load.",
"caveat": "An HRV-derived proxy, NOT an EDA / galvanic-skin stress measurement — your watch has no EDA sensor. Descriptive, not clinical.",
"description": "A nightly stress proxy from the shape of your intra-day HRV."
},
"STRAIN_SCORE": {
"method": "A nightly 0–100 strain proxy from your heart-rate-weighted activity load (a TRIMP-style training-impulse model). Higher means a harder day.",
"caveat": "A descriptive activity-load proxy, not a clinical or training-grade load measure.",
"description": "A nightly activity-load proxy from your heart-rate-weighted effort."
}
},
"scores": {
"sectionTitle": "Wellness scores",
"recovery": "Recovery",
"stress": "Stress",
"strain": "Strain"
},
"coincident": {
"label": "Several vitals off baseline",
"summary": "{count} of your vitals sat outside their personal range today.",
"vitals": "Outside range: {list}"
}
},
"cardioFitness": {
"title": "Cardio Fitness",
"description": "Your VO₂ max over time — the headline measure of aerobic fitness.",
"chartTitle": "VO₂ max (mL/(kg·min))",
"emptyState": {
"title": "No cardio-fitness data yet",
"description": "VO₂ max readings appear here once Apple Health syncs from your workouts."
}
},
"navCardioFitness": "Cardio Fitness",
"cardioRecovery": {
"title": "Cardio Recovery",
"description": "How far your heart rate drops one minute after peak exercise.",
"chartTitle": "Cardio recovery (bpm)",
"emptyState": {
"title": "No cardio-recovery data yet",
"description": "Cardio-recovery readings appear here once Apple Health syncs from a workout."
}
},
"navCardioRecovery": "Cardio Recovery",
"wristTemperature": {
"title": "Wrist Temperature",
"description": "Your overnight wrist temperature, tracked against your own baseline.",
"chartTitle": "Wrist temperature (°C)",
"emptyState": {
"title": "No wrist-temperature data yet",
"description": "Wrist-temperature readings appear here once Apple Health syncs overnight wear."
}
},
"navWristTemperature": "Wrist Temp",
"falls": {
"title": "Falls",
"description": "Hard falls your Apple Watch detected, counted per day.",
"chartTitle": "Falls per day",
"emptyState": {
"title": "No fall data yet",
"description": "Detected falls appear here once Apple Health syncs. No falls is the goal."
}
},
"navFalls": "Falls",
"sixMinuteWalk": {
"title": "Six-Minute Walk",
"description": "Apple's estimate of how far you could walk in six minutes.",
"chartTitle": "Six-minute walk distance (m)",
"emptyState": {
"title": "No six-minute-walk data yet",
"description": "Six-minute-walk estimates appear here once Apple Health syncs."
}
},
"navSixMinuteWalk": "6-Min Walk",
"stairAscentSpeed": {
"title": "Stair Ascent Speed",
"description": "How fast you climb stairs, measured by your iPhone or Watch.",
"chartTitle": "Stair ascent speed (m/s)",
"emptyState": {
"title": "No stair-ascent-speed data yet",
"description": "Stair-ascent-speed readings appear here once Apple Health syncs."
}
},
"navStairAscentSpeed": "Stair Ascent",
"stairDescentSpeed": {
"title": "Stair Descent Speed",
"description": "How fast you go down stairs, measured by your iPhone or Watch.",
"chartTitle": "Stair descent speed (m/s)",
"emptyState": {
"title": "No stair-descent-speed data yet",
"description": "Stair-descent-speed readings appear here once Apple Health syncs."
}
},
"navStairDescentSpeed": "Stair Descent",
"breathingDisturbances": {
"title": "Breathing Disturbances",
"description": "How often your breathing was disturbed during sleep.",
"chartTitle": "Sleep breathing disturbances",
"emptyState": {
"title": "No breathing-disturbance data yet",
"description": "Sleep-breathing readings appear here once Apple Health syncs from a supported Apple Watch."
}
},
"navBreathingDisturbances": "Breathing",
"rhythmEvents": {
"sectionTitle": "Device health notifications",
"sectionIntro": "Notifications your watch sent on its own. HealthLog only shows these — it does not detect, confirm, or interpret them.",
"loadError": "Could not load your device notifications.",
"disclaimer": "These are alerts your device produced using its own built-in detection. They are shown here for your awareness only. This is not a medical assessment by HealthLog, and HealthLog does not diagnose any condition. If a notification concerns you, talk to your doctor.",
"event": {
"irregularRhythm": "Irregular rhythm notification",
"highHeartRate": "High heart rate notification",
"lowHeartRate": "Low heart rate notification",
"walkingSteadiness": "Walking steadiness alert",
"breathingDisturbance": "Breathing disturbance during sleep"
},
"verdict": {
"irregular": "Your device flagged a possible irregular rhythm.",
"notDetected": "Your device did not flag an irregular rhythm.",
"inconclusive": "Your device could not make a reading.",
"low": "Your device flagged low walking steadiness.",
"veryLow": "Your device flagged very low walking steadiness.",
"fired": "Your device flagged this event."
}
}
},
"thresholds": {
"defaultLabel": "Default",
@@ -2393,7 +2947,7 @@
"description": "Withings, moodLog, and other connected services."
},
"notifications": {
"title": "Notification channels",
"title": "Notifications",
"description": "Live overview of every configured channel."
},
"dashboard": {
@@ -2401,13 +2955,12 @@
"description": "Tile layout and order."
},
"thresholds": {
"title": "Targets & Sources",
"description": "Per metric: your own target range and which source wins when more than one records the same value on the same day."
"title": "Targets",
"description": "Per metric: your own target range for healthy values."
},
"sources": {
"title": "Sources",
"description": "Pick which ingest source wins when more than one records the same metric for the same day.",
"redirectNotice": "Sources are now part of “Targets & Sources”.",
"cardTitle": "Source priority by metric",
"help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.",
"resetDefaults": "Reset to defaults",
@@ -2457,7 +3010,7 @@
}
},
"ai": {
"title": "Insights",
"title": "AI Insights",
"description": "Provider, model, key."
},
"api": {
@@ -2706,6 +3259,11 @@
"testProvider": "Test active provider",
"testSuccess": "OK — {provider} ({model})",
"testFailedShort": "Test failed: {message}",
"testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.",
"testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.",
"testReasonRateLimited": "Provider rate-limited the request — try again shortly.",
"testReasonServerError": "The AI provider returned a server error.",
"testReasonUnreachable": "Could not reach the AI provider.",
"disableCoach": {
"title": "Hide Coach",
"description": "Hides the Coach button and drawer everywhere.",
@@ -2939,7 +3497,9 @@
"fhirNote": "The structured output follows the FHIR R4 standard (ePA-compatible).",
"generate": "Generate",
"error": "Export failed ({code})."
}
},
"globalExcludedInjectionSitesLabel": "Globally excluded injection sites",
"globalExcludedInjectionSitesHint": "Sites listed here are never offered for any medication and rejected when logging a dose, even if a medication prefers them."
},
"admin": {
"title": "Administration",
@@ -3760,8 +4320,6 @@
}
},
"targets": {
"title": "Targets",
"introText": "Your current values compared to recommended target ranges. Guideline sources are linked on each card.",
"bmi": "BMI",
"average30d": "30-day average:",
"consistency": {
@@ -3783,23 +4341,11 @@
"sixDaysAgo": "6 days ago"
}
},
"card": {
"lastMet": "Last met goal: {when}",
"streak": "{count}-day streak"
},
"relativeDay": {
"today": "today",
"yesterday": "yesterday",
"daysAgo": "{count} days ago"
},
"summary": {
"weekTitle": "{met} of {total} targets met this week",
"weekTitleAll": "All {total} targets met this week",
"streakHighlight": "{count}-day streak: {metric}"
},
"coach": {
"cta": "Ask Coach"
},
"edit": {
"openLabel": "Edit target range for {metric}",
"title": "Edit target range — {metric}",
@@ -3831,31 +4377,10 @@
"aboveTarget": "{delta} {unit} above target",
"belowTarget": "{delta} {unit} below target",
"diastolic": "Diastolic",
"noMeasurementYet": "No measurements yet",
"noMeasurementData": "No measurement data available. Add some measurements to see your targets.",
"profileIncomplete": "Profile incomplete",
"profileIncompleteHeight": "Add your height in profile settings to enable BMI targets.",
"profileIncompleteAge": "Add your date of birth in profile settings for age-specific targets.",
"profileIncompleteHeightAge": "Add your height and date of birth in profile settings for personalized targets.",
"compliancePerMedication": "Compliance per medication",
"glucoseFasting": "Glucose — fasting",
"glucosePostprandial": "Glucose — post-meal",
"glucoseRandom": "Glucose — random",
"glucoseBedtime": "Glucose — bedtime",
"loadError": "Failed to load targets",
"label": {
"WEIGHT": "Weight",
"BLOOD_PRESSURE": "Blood pressure",
"BLOOD_PRESSURE_IN_TARGET": "Blood pressure on target",
"PULSE": "Resting pulse",
"SLEEP_DURATION": "Sleep duration",
"BMI": "BMI",
"BODY_FAT": "Body fat",
"ACTIVITY_STEPS": "Steps/day",
"MEDICATION_COMPLIANCE": "Medication compliance",
"MOOD_SCORE": "Mood",
"MOOD_STABILITY": "Mood stability"
},
"status": {
"underweight": "Underweight",
"normal": "Normal",
+640 -115
View File
@@ -158,7 +158,12 @@
"chartsTitle": "Gráficos de tendencia",
"aiSummaryTitle": "Resumen de IA — no validado clínicamente",
"aiSummaryDisclaimer": "Esta sección fue generada automáticamente por un modelo de IA y no está validada clínicamente. No sustituye una evaluación médica.",
"packageReadme": "Historial de salud de HealthLog\n\nEste paquete contiene:\n- report.pdf: el informe de salud para su médico.\n- bundle.json: los mismos datos como Bundle de documento HL7 FHIR R4, compatible con ePA.\n\nGenerado con HealthLog."
"packageReadme": "Historial de salud de HealthLog\n\nEste paquete contiene:\n- report.pdf: el informe de salud para su médico.\n- bundle.json: los mismos datos como Bundle de documento HL7 FHIR R4, compatible con ePA.\n\nGenerado con HealthLog.",
"wellnessTitle": "Resumen de bienestar",
"wellnessDisclaimer": "Son puntuaciones de bienestar descriptivas calculadas a partir de tus señales registradas (0–100). No son evaluaciones clínicas ni diagnósticos.",
"wellnessRecovery": "Recuperación",
"wellnessStress": "Estrés",
"wellnessStrain": "Esfuerzo"
},
"errorBoundary": {
"title": "Algo ha salido mal",
@@ -425,7 +430,22 @@
"dailyTotalCaption": "({count} muestras)",
"expandDay": "Mostrar las muestras de este día",
"collapseDay": "Ocultar las muestras de este día",
"loadError": "No se pudieron cargar las mediciones."
"loadError": "No se pudieron cargar las mediciones.",
"typeCardioRecovery": "Recuperación cardiaca",
"typeWristTemperature": "Temperatura de muñeca",
"typeFallCount": "Caídas",
"typeSixMinuteWalkDistance": "Distancia en marcha de 6 minutos",
"typeStairAscentSpeed": "Velocidad subiendo escaleras",
"typeStairDescentSpeed": "Velocidad bajando escaleras",
"typeBreathingDisturbances": "Alteraciones respiratorias del sueño",
"typeIrregularRhythmNotification": "Irregular rhythm notification",
"typeHighHeartRateEvent": "High heart rate notification",
"typeLowHeartRateEvent": "Low heart rate notification",
"typeWalkingSteadinessEvent": "Walking steadiness alert",
"typeBreathingDisturbanceEvent": "Breathing disturbance",
"typeRecoveryScore": "Recovery score",
"typeStressScore": "Stress score",
"typeStrainScore": "Strain score"
},
"mood": {
"title": "Estado de ánimo",
@@ -461,7 +481,49 @@
"deleteConfirmTitle": "¿Eliminar entrada?",
"deleteConfirmDescription": "Esta entrada de estado de ánimo se eliminará de forma permanente.",
"pageInfo": "Página {page} de {total}",
"loginRequired": "Inicia sesión para ver tus estados de ánimo."
"loginRequired": "Inicia sesión para ver tus estados de ánimo.",
"note": "Nota",
"notePlaceholder": "Añade una nota sobre cómo te sientes…",
"tagPicker": "Etiquetas",
"tagCategory": {
"feelings": "Sentimientos",
"sleep": "Sueño",
"health": "Salud",
"social": "Social",
"work": "Trabajo"
},
"tag": {
"happy": "feliz",
"excited": "emocionado",
"grateful": "agradecido",
"relaxed": "relajado",
"content": "satisfecho",
"tired": "cansado",
"unsure": "inseguro",
"bored": "aburrido",
"tense": "tenso",
"angry": "enfadado",
"stressed": "estresado",
"sad": "triste",
"sleptWell": "dormí bien",
"sleptOk": "dormí regular",
"sleptPoorly": "dormí mal",
"earlyNight": "me acosté pronto",
"workedOut": "entrené",
"ateWell": "comí bien",
"hydrated": "bebí agua",
"walked": "caminé",
"alcohol": "alcohol",
"family": "familia",
"friends": "amigos",
"party": "fiesta",
"alone": "solo",
"productive": "productivo",
"overtime": "horas extra",
"dayOff": "día libre",
"travel": "viaje",
"sickDay": "día de baja"
}
},
"medications": {
"title": "Medicamentos",
@@ -544,11 +606,14 @@
"veryOverdue": "Muy vencida",
"compliance7d": "Cumplimiento 7 días",
"compliance30d": "Cumplimiento 30 días",
"complianceWindow": "Cumplimiento {days} días",
"lastIntake": "Última toma:",
"nextIntake": "Próxima toma:",
"dayStreak": "días de racha",
"taken": "Tomada",
"skipped": "Omitida",
"intakeToastTaken": "{name} registrada como tomada",
"intakeToastSkipped": "{name} omitida",
"weekdaySunday": "Domingo",
"weekdayMonday": "Lunes",
"weekdayTuesday": "Martes",
@@ -759,7 +824,11 @@
"back": "Back",
"next": "Next",
"save": "Save",
"saveEdit": "Save changes"
"saveEdit": "Save changes",
"nextHint": "Siguiente: {step}",
"reviewHint": "Siguiente: Guardar",
"jumpFirst": "Ir al primer paso",
"jumpLast": "Ir a revisar y guardar"
},
"nl": {
"button": "Describe"
@@ -769,11 +838,13 @@
"title": "What's the medication called?",
"subline": "Exactly as it appears on the box.",
"label": "Name",
"placeholder": "e.g. Ramipril"
"placeholder": "e.g. Ramipril",
"short": "Nombre"
},
"step2": {
"title": "What kind of medication is it?",
"subline": "We use this for the right templates and analytics."
"subline": "We use this for the right templates and analytics.",
"short": "Tipo"
},
"step3": {
"title": "What's the dose?",
@@ -805,15 +876,18 @@
"dosesPerUnitPlaceholder": "p. ej. 4",
"dosesPerUnitHint": "Dosis por pluma / vial — déjalo vacío para no controlar el inventario.",
"injectionSiteToggle": "Rotación del sitio de inyección",
"injectionSiteHint": "Guía de rotación opcional. El sitio de cada dosis se registra al anotar la toma."
"injectionSiteHint": "Guía de rotación opcional. El sitio de cada dosis se registra al anotar la toma.",
"short": "Dosis"
},
"step4": {
"title": "Over what period are you taking it?",
"subline": "A start date is enough for today. You can add an end date later."
"subline": "A start date is enough for today. You can add an end date later.",
"short": "Cuándo"
},
"step5": {
"title": "How often do you take it?",
"subline": "Pick the cadence — the details follow on the next step."
"subline": "Pick the cadence — the details follow on the next step.",
"short": "Frecuencia"
},
"step6": {
"title": "What does that look like in detail?",
@@ -829,7 +903,8 @@
"rollingDays": {
"label": "Every",
"suffix": "days from the last intake"
}
},
"short": "Detalle"
},
"step7": {
"title": "When do you take it?",
@@ -840,14 +915,16 @@
"noon": "Noon",
"evening": "Evening",
"night": "Night"
}
},
"short": "Horas"
},
"step8": {
"title": "All done — should HealthLog remind you?",
"subline": "Review everything below before you save.",
"remindersLabel": "Reminders on",
"remindersDescription": "Push, Telegram or ntfy — based on your settings.",
"multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release."
"multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release.",
"short": "Listo"
}
},
"classRow": {
@@ -923,26 +1000,17 @@
"submitFailed": "Could not save the medication — please retry."
}
},
"titration": {
"section": "Escalado de titulación",
"loading": "Cargando datos de titulación",
"loadFailed": "No se pudieron cargar los datos de titulación.",
"drugLadderHeader": "Escalado estándar (referencia EMA)",
"typicalWeeksOnStep": "≥ {weeks} sem. típico",
"youAreHere": "Estás aquí",
"nextStepCaption": "Siguiente escalón (típico): {dose} mg tras ~{weeks} semanas en el escalón actual.",
"ceilingMessage": "Estás en el último escalón estándar.",
"escalationDueHint": "Llevas {weeks} semanas en este escalón; el esquema suele avanzar tras ~{typical} semanas.",
"currentStepDwell": "En este escalón desde hace {weeks} semanas",
"doseUnitMg": "mg",
"emptyState": "Aún no hay historial de dosis. Registra una dosis para ver tu posición en el escalado.",
"nonStandardDose": "La dosis actual está fuera de los escalones estándar.",
"disclaimer": "Valores de referencia EMA. Consulta a tu médico antes de cambiar la dosis.",
"emaSourceCta": "Fuente EMA",
"stepLabel": "Escalón {n}"
"doseStrength": {
"title": "Dosis a lo largo del tiempo",
"axisLabel": "Dosis registrada ({unit})",
"tooltipLabel": "Dosis",
"currentDose": "Dosis actual: {dose} {unit}",
"noCurrentDose": "Aún no hay dosis registrada.",
"empty": "Aún no hay historial de titulación. Registra un cambio de dosis para ver la curva de dosis."
},
"injectionSiteBodyOutlineAriaLabel": "Silueta del cuerpo",
"injectionSitePickerAriaLabel": "Selector de sitio de inyección",
"injectionSiteRecommendedAriaLabel": "{site}: sitio siguiente recomendado",
"siteAbdomenLeft": "Abdomen, inferior izquierdo",
"siteAbdomenRight": "Abdomen, inferior derecho",
"siteAbdomenUpperLeft": "Abdomen, superior izquierdo",
@@ -1005,9 +1073,10 @@
"phaseSaved": "Fases de recordatorio guardadas",
"phaseReset": "Restablecidas a valores por defecto",
"sourceWeb": "Web",
"sourceApi": "API",
"sourceApi": "App de iOS",
"sourceReminder": "Recordatorio",
"sourceImport": "Importación",
"intakeSourceVia": "vía {origin}",
"intakeHistoryEmptyTitle": "Aún no hay tomas",
"intakeHistoryEmptyDescription": "En cuanto registres una toma, aparecerá aquí con la hora y la fuente.",
"intakeHistoryEmptyAction": "Registrar toma",
@@ -1078,11 +1147,6 @@
"emptyState": "Sin tomas registradas todavía.",
"emptyStateCta": "Registra una dosis para ver la curva estimada cobrar forma.",
"estimateNote": "Estimación educativa a partir de la farmacocinética poblacional publicada por la EMA. No es una medición.",
"gatedTitle": "Modo Investigación desactivado",
"gatedBody": "Activa el modo Investigación en los ajustes para ver la curva estimada del nivel de fármaco. La curva es de inclusión voluntaria porque es una ayuda de aprendizaje, no una medición clínica.",
"gatedStaleTitle": "El disclaimer requiere reconfirmación",
"gatedStaleBody": "El disclaimer del modo Investigación se actualizó desde tu última confirmación. Relelo en los ajustes y confirma de nuevo para volver a ver la curva.",
"gatedCta": "Abrir el modo Investigación en los ajustes",
"unknownDrug": "{name} no está en el catálogo de GLP-1 aprobado por la EMA, por lo que HealthLog no puede trazar una curva de farmacocinética poblacional."
}
},
@@ -1170,6 +1234,14 @@
"unit": "minutes",
"saved": "Reminder window saved",
"failed": "Could not save the reminder window."
},
"codes": {
"label": "Códigos clínicos (ATC / RxNorm)",
"note": "Opcional. Se usan en la exportación FHIR del historial de salud. Los códigos se toman tal cual se introducen — nunca se adivinan.",
"atcLabel": "ATC",
"rxNormLabel": "RxNorm",
"saved": "Códigos clínicos guardados",
"failed": "No se pudieron guardar los códigos clínicos."
}
},
"zone": {
@@ -1235,7 +1307,7 @@
"data": "Datos",
"reminders": "Recordatorios",
"lifecycle": "Ciclo de vida",
"danger": "Zona de peligro"
"externalApi": "API externa"
},
"dataPortability": {
"import": {
@@ -1258,10 +1330,18 @@
"history": {
"back": "Volver al medicamento",
"subtitle": "Historial completo de tomas",
"plannedSuffix": "planificado",
"drugLevelDisclosure": "Mostrar curva estimada del principio activo"
"plannedSuffix": "planificado"
}
}
},
"logInjectionSiteTitle": "Registrar sitio de inyección",
"logInjectionSiteDescription": "¿Dónde te inyectaste {name}? Opcional: puedes omitirlo.",
"logInjectionSiteSkip": "Omitir",
"logInjectionSiteConfirm": "Guardar sitio",
"logInjectionSiteNoneAvailable": "No hay ningún sitio de inyección disponible: todos los sitios están excluidos en tu configuración.",
"trackInjectionSitesToggle": "Registrar sitios de inyección",
"trackInjectionSitesHint": "Tras administrar una dosis, pregunta qué sitio se usó y sugiere la siguiente rotación.",
"allowedSitesLabel": "Sitios permitidos",
"allowedSitesHint": "Deja todos seleccionados para no aplicar restricciones. Los sitios que excluiste globalmente nunca se ofrecen."
},
"charts": {
"loadingLabel": "Cargando la gráfica…",
@@ -1272,6 +1352,13 @@
"bodyFat": "Grasa corporal",
"sleep": "Sueño",
"steps": "Pasos",
"hrv": "VFC",
"restingHeartRate": "FC en reposo",
"activeEnergy": "Energía activa",
"flights": "Pisos subidos",
"distance": "Distancia a pie + corriendo",
"vo2Max": "VO₂ máx",
"bodyTemperature": "Temperatura corporal",
"noData": "Sin datos en el periodo seleccionado",
"bucketWeekly": "Media semanal",
"bucketMonthly": "Media mensual",
@@ -1336,7 +1423,17 @@
"personalBaseline": "Tu media",
"deltaVsBaseline": "{delta} vs. tu media",
"deltaUnchanged": "coincide con tu media",
"deltaVsTarget": "{delta} vs. objetivo"
"deltaVsTarget": "{delta} vs. objetivo",
"weekdaysFull": {
"mon": "Lunes",
"tue": "Martes",
"wed": "Miércoles",
"thu": "Jueves",
"fri": "Viernes",
"sat": "Sábado",
"sun": "Domingo"
},
"rangeBand": "Rango"
},
"chart": {
"overlay": {
@@ -1376,6 +1473,7 @@
},
"insights": {
"noAnalysisYet": "Aún no hay análisis.",
"assessmentPreparing": "Preparando tu evaluación…",
"emptyTitle": "Aún no hay datos suficientes",
"emptyDescription": "Los insights aparecen en cuanto registres algunas mediciones. Empieza por peso o tensión arterial.",
"emptyAddMeasurement": "Registrar medición",
@@ -1432,6 +1530,8 @@
"heroRegenerating": "Generando",
"regenerateAnalysis": "Volver a iniciar el análisis",
"regenerateSuccess": "Análisis regenerado",
"warmAssessments": "Preparar evaluaciones",
"warmStarted": "Las evaluaciones se están preparando en segundo plano",
"heroFallbackSubtitle": "Una mirada diaria a tus tendencias — directamente de los datos que registras.",
"heroGreetingMorning": "Buenos días",
"heroGreetingAfternoon": "Buenas tardes",
@@ -1498,7 +1598,22 @@
},
"trendsRow": {
"title": "Tendencias",
"subtitle": "Los últimos 30 días de un vistazo"
"subtitle": "Los últimos 30 días de un vistazo",
"caption": {
"bp": "Valores sistólicos y diastólicos de los últimos 30 días.",
"weight": "Evolución del peso corporal de los últimos 30 días.",
"pulse": "Pulso en reposo de los últimos 30 días.",
"mood": "Puntuación diaria del estado de ánimo de los últimos 30 días.",
"sleep": "Duración del sueño nocturno de los últimos 30 días.",
"steps": "Recuento diario de pasos de los últimos 30 días.",
"hrv": "Variabilidad de la frecuencia cardíaca de los últimos 30 días.",
"resting_hr": "Frecuencia cardíaca en reposo de los últimos 30 días.",
"active_energy": "Energía activa quemada en los últimos 30 días.",
"flights": "Pisos subidos en los últimos 30 días.",
"distance": "Distancia caminando y corriendo de los últimos 30 días.",
"vo2_max": "VO₂ máx de los últimos 30 días.",
"body_temp": "Temperatura corporal de los últimos 30 días."
}
},
"vo2Max": {
"statLatest": "Actual",
@@ -1515,6 +1630,23 @@
"confidenceLow": "Confianza baja",
"pendingLabel": "Generando comentario de tendencia"
},
"range": {
"label": "Período",
"noPriorData": "sin datos del período anterior",
"option": {
"7d": "7d",
"30d": "30d",
"90d": "90d",
"1y": "1a"
},
"priorLabel": {
"7d": "vs 7d anteriores",
"30d": "vs 30d anteriores",
"90d": "vs 90d anteriores",
"1y": "vs año anterior"
},
"noChange": "sin cambios"
},
"correlationRow": {
"title": "Correlaciones",
"subtitle": "Hipótesis predefinidas, con umbral n ≥ 14 y p < 0,05",
@@ -1800,7 +1932,124 @@
"medikamenteEmptyDescription": "Los insights de adherencia necesitan al menos un medicamento registrado.",
"medikamenteEmptyAction": "Abrir medicamentos",
"therapyTimelineDescription": "Protocolo cronológico de terapia GLP-1 — cambios de dosis, inyecciones, existencias, efectos secundarios.",
"bmiDescription": "IMC derivado de tu peso y altura, con valoración."
"bmiDescription": "IMC derivado de tu peso y altura, con valoración.",
"target": {
"range": "Objetivo: {min}–{max} {unit}",
"bpRange": "Objetivo: {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}",
"inTargetShare": "{pct}% de los días registrados dentro del objetivo",
"adjustLink": "Ajustar el rango objetivo"
},
"explainer": {
"trigger": "¿Qué es {title}?",
"bloodPressureTitle": "Presión arterial",
"bloodPressureBody": "La presión arterial es la fuerza que ejerce la sangre sobre las paredes de las arterias, expresada como sistólica sobre diastólica (el pico durante un latido sobre el valor en reposo entre latidos). Los valores altos de forma constante sobrecargan el corazón y los vasos con el tiempo.",
"pulseTitle": "Pulso",
"pulseBody": "El pulso es cuántas veces late el corazón por minuto. Una medición puntual refleja lo que hacías en ese momento: en reposo suele ser de 60 a 100 latidos por minuto y sube de forma natural con el movimiento, el estrés o la cafeína.",
"weightTitle": "Peso",
"weightBody": "El peso corporal es una de las señales más simples del balance energético a largo plazo y de la salud general. La tendencia a lo largo de las semanas importa mucho más que cualquier lectura individual, que varía con la hidratación, la comida y la hora del día.",
"bmiTitle": "Índice de masa corporal (IMC)",
"bmiBody": "El IMC es tu peso dividido por tu altura al cuadrado, un único número que te sitúa en una categoría amplia de peso. Es un cribado poblacional, no un diagnóstico, y no distingue el músculo de la grasa.",
"sleepTitle": "Sueño",
"sleepBody": "La duración del sueño es cuánto dormiste realmente durante la noche, dividido en fases ligera, profunda y REM. La mayoría de los adultos necesita entre siete y nueve horas; tanto la cantidad como la calidad constantes favorecen la recuperación, el ánimo y el metabolismo.",
"moodTitle": "Estado de ánimo",
"moodBody": "El registro del estado de ánimo anota cómo te sientes con el tiempo en una escala sencilla. Anotarlo con regularidad revela patrones —cómo el sueño, la actividad o la medicación se relacionan con cómo te sientes— que un solo día nunca muestra.",
"medicationsTitle": "Adherencia a la medicación",
"medicationsBody": "La adherencia es la fiabilidad con la que tomas tu medicación según lo prescrito, aquí como la proporción de dosis previstas que registraste. Una toma constante suele ser lo que hace que un tratamiento funcione como se pretende.",
"restingHrTitle": "Frecuencia cardíaca en reposo",
"restingHrBody": "La frecuencia cardíaca en reposo es tu ritmo cardíaco cuando estás completamente en reposo, normalmente medido durante la noche. Una frecuencia más baja suele indicar mejor forma cardiovascular, y una tendencia al alza puede ser una señal temprana de estrés o enfermedad.",
"hrvTitle": "Variabilidad de la frecuencia cardíaca (VFC)",
"hrvBody": "La VFC es la pequeña variación en el tiempo entre latidos consecutivos. Una mayor variabilidad suele reflejar un sistema nervioso bien recuperado y adaptable, mientras que una caída sostenida puede señalar fatiga, estrés o recuperación insuficiente.",
"oxygenSaturationTitle": "Saturación de oxígeno (SpO₂)",
"oxygenSaturationBody": "La SpO₂ es el porcentaje de glóbulos rojos que transportan oxígeno. Los valores de 95 a 100 % son normales en reposo; los valores que se mantienen por debajo del 90 % merecen atención, aunque las bajadas breves nocturnas son frecuentes.",
"bodyTemperatureTitle": "Temperatura corporal",
"bodyTemperatureBody": "La temperatura corporal refleja tu estado térmico central, normalmente en torno a 37 °C con pequeñas variaciones diarias. Un aumento sostenido puede indicar una infección, mientras que las lecturas nocturnas en la muñeca reflejan sobre todo cambios relativos, no un valor clínico.",
"activeEnergyTitle": "Energía activa",
"activeEnergyBody": "La energía activa son las calorías que quemas con el movimiento, además del consumo basal en reposo. Es una medida directa de lo físicamente activo que fue tu día.",
"workoutsTitle": "Entrenamientos",
"workoutsBody": "Los entrenamientos son las sesiones de ejercicio estructurado que registran tus dispositivos, con tipo, duración y energía. Registrarlos muestra cómo el entrenamiento regular se acumula a lo largo de las semanas y cómo se relaciona con tus otras señales de salud.",
"respiratoryRateTitle": "Frecuencia respiratoria",
"respiratoryRateBody": "La frecuencia respiratoria es el número de respiraciones que haces por minuto. En reposo, la mayoría de los adultos respiran entre doce y veinte veces por minuto; un aumento sostenido puede acompañar a una enfermedad, el esfuerzo o el estrés.",
"bodyWaterTitle": "Agua corporal total",
"bodyWaterBody": "El agua corporal total es la parte de tu peso compuesta por líquido, dentro y alrededor de las células. Varía con la hidratación, la comida y la actividad, así que la tendencia a lo largo de semanas dice más que una sola medición.",
"boneMassTitle": "Masa ósea",
"boneMassBody": "La masa ósea es el peso estimado del mineral de tu esqueleto. Las estimaciones de la báscula son aproximadas, pero una tendencia estable o al alza favorece la fortaleza ósea a largo plazo.",
"fatFreeMassTitle": "Masa libre de grasa",
"fatFreeMassBody": "La masa libre de grasa es todo lo que en tu cuerpo no es grasa: músculo, hueso, órganos y agua. Mantenerla o aumentarla mientras pierdes peso suele indicar una composición corporal más sana.",
"fatMassTitle": "Masa grasa",
"fatMassBody": "La masa grasa es el peso total de la grasa de tu cuerpo. Algo de grasa es esencial para las hormonas y la reserva de energía, pero un exceso —sobre todo en el abdomen— eleva el riesgo para la salud a largo plazo.",
"muscleMassTitle": "Masa muscular",
"muscleMassBody": "La masa muscular es el peso estimado del músculo de tu cuerpo. Más músculo favorece la fuerza, el metabolismo y la movilidad, y suele requerir esfuerzo activo para conservarse con la edad.",
"visceralFatTitle": "Grasa visceral",
"visceralFatBody": "La grasa visceral es la grasa almacenada en lo profundo del abdomen, alrededor de los órganos. A diferencia de la grasa bajo la piel, un nivel alto se asocia estrechamente con enfermedad cardíaca y diabetes tipo 2, por lo que cuanto más baja, mejor.",
"leanBodyMassTitle": "Masa magra corporal",
"leanBodyMassBody": "La masa magra corporal es tu peso total menos la grasa: músculo, hueso, órganos y agua en conjunto. Seguirla junto al peso muestra si los cambios vienen del músculo o de la grasa.",
"flightsClimbedTitle": "Pisos subidos",
"flightsClimbedBody": "Los pisos subidos cuentan las plantas que subes cada día, con un piso equivalente a unos tres metros de altura. Es un indicador sencillo de la actividad cotidiana y el esfuerzo del tren inferior.",
"walkingDistanceTitle": "Distancia a pie y corriendo",
"walkingDistanceBody": "La distancia a pie y corriendo es el terreno que recorres caminando cada día. Complementa el recuento de pasos al reflejar cuánto te moviste, no solo con qué frecuencia diste pasos.",
"walkingSteadinessTitle": "Estabilidad al caminar",
"walkingSteadinessBody": "La estabilidad al caminar estima cuán estable y equilibrada es tu marcha, clasificada de correcta a baja. Un nivel bajo indica mayor probabilidad de caída y conviene comentarlo con un profesional.",
"walkingHeartRateTitle": "Frecuencia cardíaca al caminar",
"walkingHeartRateBody": "La frecuencia cardíaca al caminar es tu pulso medio durante una caminata constante. Un valor más bajo con el tiempo suele reflejar una mejor forma cardiovascular para el mismo esfuerzo.",
"walkingAsymmetryTitle": "Asimetría al caminar",
"walkingAsymmetryBody": "La asimetría al caminar es la proporción de tiempo en que tus pasos son desiguales entre la pierna izquierda y la derecha. Un porcentaje más alto puede indicar dolor, lesión o un problema de marcha que conviene revisar.",
"doubleSupportTimeTitle": "Tiempo de doble apoyo",
"doubleSupportTimeBody": "El tiempo de doble apoyo es la proporción de cada ciclo de marcha en que ambos pies tocan el suelo a la vez. Un porcentaje más alto suele indicar una marcha más cautelosa y menos estable.",
"stepLengthTitle": "Longitud de zancada",
"stepLengthBody": "La longitud de zancada es la distancia media de una pisada a la siguiente. Una zancada más corta con el tiempo puede reflejar fatiga, cautela o un cambio en la movilidad.",
"walkingSpeedTitle": "Velocidad al caminar",
"walkingSpeedBody": "La velocidad al caminar es lo rápido que te mueves en terreno llano. Es un marcador bien estudiado de la salud general y la movilidad, y un ritmo estable o al alza es buena señal.",
"pulseWaveVelocityTitle": "Velocidad de la onda de pulso",
"pulseWaveVelocityBody": "La velocidad de la onda de pulso mide la rapidez con que la onda de presión de cada latido viaja por tus arterias. Una onda más rápida indica arterias más rígidas, lo que se asocia a mayor riesgo cardiovascular.",
"vascularAgeTitle": "Edad vascular",
"vascularAgeBody": "La edad vascular estima qué edad aparentan tus arterias en comparación con tu edad real, según la rigidez arterial. Una edad vascular por debajo de la real sugiere vasos sanguíneos más sanos.",
"environmentalAudioTitle": "Niveles de sonido ambiental",
"environmentalAudioBody": "Los niveles de sonido ambiental registran el volumen del entorno en decibelios. La exposición prolongada por encima de unos 70 dB puede dañar la audición con el tiempo, así que los promedios diarios más bajos son más seguros.",
"headphoneAudioTitle": "Niveles de audio con auriculares",
"headphoneAudioBody": "Los niveles de audio con auriculares registran en decibelios el volumen al que escuchas con auriculares. Bajar el volumen y hacer pausas protege tu audición a largo plazo.",
"audioEventsTitle": "Eventos de sonido fuerte",
"audioEventsBody": "Los eventos de sonido fuerte señalan momentos en que el ruido a tu alrededor alcanzó un nivel que puede dañar la audición con suficiente exposición. Los eventos frecuentes son una señal para buscar un entorno más silencioso o protección auditiva.",
"daylightTitle": "Tiempo a la luz del día",
"daylightBody": "El tiempo a la luz del día es cuánto pasas bajo luz exterior cada día. La luz diurna regular favorece un ritmo sueño-vigilia y un estado de ánimo saludables, y el tiempo al aire libre se asocia con la salud ocular.",
"bloodGlucoseTitle": "Glucosa en sangre",
"bloodGlucoseBody": "La glucosa en sangre es la cantidad de azúcar en tu sangre, la principal fuente de energía del cuerpo. Sube tras comer y se estabiliza entre comidas; valores que se mantienen altos con el tiempo pueden indicar diabetes o prediabetes.",
"skinTemperatureTitle": "Temperatura de la piel",
"skinTemperatureBody": "La temperatura de la piel aquí es el pequeño cambio nocturno de la temperatura de tu muñeca respecto a tu propio valor de referencia, no una medición absoluta de fiebre. Las variaciones pueden seguir el sueño, el entorno, una enfermedad o la fase del ciclo menstrual.",
"cardioFitnessTitle": "Condición cardiovascular (VO₂ máx)",
"cardioFitnessBody": "El VO₂ máx es la cantidad de oxígeno que tu cuerpo puede usar al máximo esfuerzo. Es el mejor marcador de laboratorio de la forma aeróbica, y un valor más alto o en aumento es una buena señal de salud cardiovascular.",
"cardioRecoveryTitle": "Recuperación cardiaca",
"cardioRecoveryBody": "La recuperación cardiaca es cuántas pulsaciones por minuto baja tu frecuencia cardiaca en el primer minuto tras parar un ejercicio intenso. Una caída mayor significa que tu corazón se calma más rápido, señal de buena forma física.",
"wristTemperatureTitle": "Temperatura de muñeca",
"wristTemperatureBody": "La temperatura de muñeca se mide mientras duermes. Apple la muestra como la diferencia respecto a tu valor de referencia; un valor estable es normal, mientras que una subida sostenida puede indicar enfermedad, entrenamiento intenso o un cambio del ciclo.",
"fallsTitle": "Caídas",
"fallsBody": "Esto cuenta las caídas fuertes que detecta tu Apple Watch. El objetivo es cero; un número creciente es una señal de riesgo de movilidad que conviene comentar con un profesional, sobre todo en personas mayores.",
"sixMinuteWalkTitle": "Distancia en marcha de 6 minutos",
"sixMinuteWalkBody": "Es la estimación de Apple de cuánto podrías caminar en seis minutos, una prueba clínica clásica de movilidad y resistencia cardiopulmonar. Una distancia estable o en aumento es una buena señal.",
"stairAscentSpeedTitle": "Velocidad subiendo escaleras",
"stairAscentSpeedBody": "Es lo rápido que subes un tramo de escaleras. Es un marcador sensible de fuerza de piernas y movilidad: un ritmo estable o en aumento es buena señal y uno que baja conviene vigilar.",
"stairDescentSpeedTitle": "Velocidad bajando escaleras",
"stairDescentSpeedBody": "Es lo rápido que bajas un tramo de escaleras. Junto con la velocidad de subida refleja el control de piernas y el equilibrio: un ritmo estable es buena señal y uno que baja conviene vigilar.",
"breathingDisturbancesTitle": "Alteraciones respiratorias del sueño",
"breathingDisturbancesBody": "Tu Apple Watch puede estimar con qué frecuencia se interrumpió tu respiración mientras dormías. Un índice más bajo es mejor; un nivel que Apple marca como elevado, sobre todo durante muchas noches, conviene comentarlo con un profesional."
},
"stats": {
"label": "Estadísticas resumidas",
"min": "Mín",
"max": "Máx",
"median": "Mediana",
"mean": "Media"
},
"showAllValues": "Mostrar todas las mediciones",
"valuesPageTitle": "{metric} — todas las mediciones",
"valuesPageDescription": "Cada medición detrás de esta métrica. Edita o elimina una entrada directamente.",
"diversity": {
"trigger": "Consejo de medición",
"weekday": "La mayoría de tus mediciones de {metric} caen en un mismo día de la semana. Registrar en otros días ofrece una imagen más completa.",
"timeOfDay": "La mayoría de tus mediciones de {metric} caen a una hora similar del día. Variar la hora ofrece una imagen más completa."
},
"valuesBack": "Volver a Insights",
"valuesBackToMetric": "Volver a {metric}",
"scoresBack": "Volver a Insights"
},
"sleep": {
"title": "Sueño",
@@ -2181,7 +2430,312 @@
"description": "Las lecturas aparecen aquí cuando Apple Health sincroniza."
}
},
"navWalkingSpeed": "Velocidad al andar"
"navWalkingSpeed": "Velocidad al andar",
"mood": {
"heatmapTitle": "Calendario de ánimo",
"heatmapNoEntry": "Sin registro",
"legendGreat": "Genial",
"legendGood": "Bien",
"legendOkay": "Regular",
"legendLow": "Bajo",
"distributionTitle": "Distribución del ánimo",
"distributionDaysUnit": "días",
"weekdayTitle": "Promedio por día de la semana",
"weekdayNoData": "Sin datos",
"weekdayBestWorst": "Mejor día: {best} · Día más difícil: {worst}",
"tagsTitle": "Etiquetas y desencadenantes",
"tagAvgMood": "Ánimo promedio con esta etiqueta",
"correlationsTitle": "Ánimo y otras métricas",
"correlationsDescription": "Cómo se mueve tu ánimo con el sueño, la actividad y el pulso el mismo día.",
"correlation": {
"sleepTitle": "Ánimo vs. sueño",
"stepsTitle": "Ánimo vs. actividad",
"pulseTitle": "Ánimo vs. pulso",
"sleepAxis": "Sueño (min)",
"stepsAxis": "Pasos",
"pulseAxis": "Pulso (lpm)",
"strengthStrong": "Fuerte",
"strengthModerate": "Moderada",
"strengthWeak": "Débil",
"strengthNone": "Ninguna",
"source": "{n} días emparejados · r = {r}",
"emptyTitle": "Aún no hay suficientes datos emparejados",
"emptyDescription": "Registra el ánimo y esta métrica los mismos días para ver una correlación.",
"weightTitle": "Ánimo vs. peso",
"bloodPressureTitle": "Ánimo vs. presión arterial",
"weightAxis": "Peso",
"bloodPressureAxis": "Sistólica (mmHg)"
},
"structuredTagsTitle": "Desglose de etiquetas",
"inTargetValue": "{pct} %",
"inTargetCaption": "de los últimos días en tu rango de buen ánimo",
"narrative": {
"title": "Lo que destaca",
"weekdayDip": "Tu ánimo baja más el {weekday}: {delta} por debajo de tu semana.",
"weekdayPeak": "El {weekday} es siempre tu mejor día, con una media de {value}.",
"trendUp": "Tu ánimo va al alza en los últimos {days} días (+{delta} frente al periodo anterior).",
"trendDown": "Tu ánimo va a la baja en los últimos {days} días (-{delta} frente al periodo anterior).",
"weekendUp": "Te sientes mejor los fines de semana (+{delta} frente a entre semana).",
"weekendDown": "Tu ánimo es más bajo los fines de semana (-{delta} frente a entre semana).",
"tagLift": "Los días con la etiqueta «{tag}» promedian {delta} por encima de tu ánimo general.",
"tagDrop": "Los días con la etiqueta «{tag}» quedan {delta} por debajo de tu media.",
"inTarget": "Has estado en tu rango de buen ánimo el {pct} % de los últimos 30 días.",
"streak": "Has registrado tu ánimo {days} días seguidos: sigue así.",
"timeOfDay": "Te sientes mejor por la {bucket}, con una media de {value}."
},
"timeOfDay": {
"title": "Estado de ánimo por hora del día",
"morning": "Mañana",
"afternoon": "Tarde",
"evening": "Noche",
"night": "Madrugada",
"bestCaption": "Sueles sentirte mejor por la {bucket}."
},
"stability": {
"value": "{score}/100",
"bandVerySteady": "Tu estado de ánimo ha sido muy estable",
"bandSteady": "Tu estado de ánimo ha sido bastante estable",
"bandVariable": "Tu estado de ánimo ha sido notablemente variable",
"bandVeryVariable": "Tu estado de ánimo ha fluctuado mucho últimamente"
}
},
"derived": {
"coverage": {
"summary": "{present} de {required} entradas · {percent}%",
"ratioLabel": "{present}/{required}",
"historyDays": "Basado en {count} días de historial",
"missing": "Registra estos para afinar el valor: {list}"
},
"scoreRing": {
"aria": "Puntuación {score} de 100, {band}",
"ariaProvisional": "Puntuación aún no disponible — datos insuficientes",
"provisionalCaption": "Datos aún insuficientes",
"band": {
"green": "bueno",
"yellow": "medio",
"red": "bajo"
}
},
"provenance": {
"title": "Cómo se calcula esto",
"trigger": "Cómo se calcula esto",
"inputsLabel": "Entradas",
"noInputs": "Aún no hay entradas que respalden este valor.",
"methodLabel": "Método",
"window": "De tus últimos {days} días · datos de {source}",
"asOf": "a fecha de {date}",
"standardLink": "Estándar: {name}"
},
"vitals": {
"sectionTitle": "Tus constantes",
"loadingLabel": "Cargando tus constantes",
"loadError": "No se pudieron cargar tus constantes en este momento.",
"building": "Creando tu rango habitual — {count} de {target} días",
"typicalRange": "Habitual para ti: {low}–{high}",
"fitnessYounger": "Forma cardiovascular de alguien ~{years} años más joven",
"fitnessOlder": "Forma cardiovascular ~{years} años por encima de tu edad",
"fitnessTypical": "Forma cardiovascular típica para tu edad",
"fitnessNoNorm": "Añade tu edad y sexo para enmarcar esto",
"vascularBelow": "{years} años por debajo de tu edad",
"vascularAbove": "{years} años por encima de tu edad",
"vascularMatch": "Acorde con tu edad",
"vascularNoAge": "Añade tu fecha de nacimiento para enmarcar esto",
"hrvBand": {
"balanced": "Equilibrado para ti",
"unbalanced": "Por encima de tu rango habitual",
"low": "Por debajo de tu rango habitual"
},
"bmiCategory": {
"underweight": "Bajo peso (OMS)",
"normal": "Rango normal (OMS)",
"overweight": "Sobrepeso (OMS)",
"obese": "Obesidad (OMS)"
}
},
"anatomy": {
"outOf": "/100",
"contributorsLabel": "Factores",
"contributorValue": "{value} · {weight}% de peso",
"contributorMissing": "sin datos",
"disclaimer": "Indicativo — no es una evaluación clínica.",
"insufficient": "Aún no hay datos suficientes para puntuarlo. Sigue registrando y aparecerá aquí."
},
"composite": {
"SLEEP_SCORE": {
"title": "Puntuación de sueño",
"description": "Una puntuación de calidad del sueño transparente con todos sus subvalores.",
"method": "Una mezcla de 0–100 de suficiencia (sueño frente a tu necesidad por edad), eficiencia (sueño ÷ tiempo en cama), consistencia (qué tan estable es tu punto medio de sueño), horario (frente a tu punto medio habitual) y composición (proporción de REM + sueño profundo). Los subvalores se reponderan en torno a los que tus datos no pueden sustentar; nada se inventa.",
"sub": {
"sufficiency": "Suficiencia",
"efficiency": "Eficiencia",
"consistency": "Consistencia",
"timing": "Horario",
"composition": "Composición"
}
},
"READINESS": {
"title": "Disposición",
"description": "Un indicador diario de bienestar según cómo se sitúan tus señales frente a tu propia base.",
"method": "Una mezcla de 0–100 de desviación de la frecuencia cardíaca en reposo, desviación de la VFC (SDNN), calidad del sueño, estabilidad de la frecuencia respiratoria y estabilidad del estado de ánimo — cada una medida frente a tu base personal. Los pesos se renormalizan según los componentes que tus datos sustentan. Un indicador de bienestar, no una puntuación de recuperación ni clínica.",
"component": {
"rhr": "Frecuencia cardíaca en reposo",
"hrv": "VFC (SDNN)",
"sleep": "Sueño",
"respiratory": "Frecuencia respiratoria",
"mood": "Estabilidad del ánimo"
},
"caveat": "Un indicador de bienestar, no una puntuación de recuperación ni clínica."
},
"VITALS_BASELINE": {
"method": "Tu rango típico personal es la mediana de tus valores diarios en el periodo, ± una dispersión robusta (desviación absoluta mediana, escalada). Sigue TUS lecturas, no una norma poblacional, así que un valor dentro de la banda es típico para ti."
},
"HRV_BALANCE": {
"method": "Tu media reciente de VFC (SDNN) frente a tu propia línea base móvil (mediana ± dispersión robusta). Equilibrada significa que está dentro de tu rango personal; la banda se construye con tus datos, nunca con un umbral poblacional."
},
"BMI": {
"method": "Peso ÷ altura², clasificado según las categorías de la OMS para adultos (bajo peso / normal / sobrepeso / obesidad). Solo es una proporción de cribado: no distingue músculo de grasa."
},
"FITNESS_AGE": {
"method": "Tu VO₂máx situado frente a percentiles de forma cardiorrespiratoria por edad y sexo, expresado como una diferencia de edad de forma física. El VO₂máx es la estimación de tu dispositivo, nunca se recalcula aquí."
},
"VASCULAR_AGE_DELTA": {
"method": "La estimación de edad vascular / velocidad de onda de pulso de tu dispositivo comparada con tu edad cronológica, expresada como una diferencia en años. La estimación es del dispositivo; HealthLog solo la reinterpreta.",
"caveat": "La propia estimación de tu dispositivo, solo reinterpretada: descriptiva, no una evaluación vascular clínica.",
"description": "Cómo se sitúa la estimación de edad vascular de tu dispositivo frente a tu edad cronológica."
},
"COINCIDENT_DEVIATION": {
"method": "Un aviso que aparece cuando dos o más de tus constantes quedan fuera de su rango típico personal el mismo día. Cada banda es la mediana robusta ± k·MAD de tu propio historial, así que se adapta a ti y no a un umbral poblacional.",
"caveat": "Un aviso a partir de tus propias líneas base: descriptivo, no un diagnóstico ni una alerta clínica."
},
"RECOVERY_SCORE": {
"method": "Un indicador nocturno de recuperación de 0 a 100 que combina tu VFC, frecuencia cardíaca en reposo y sueño frente a tu línea base. Más alto significa mejor recuperado. Se calcula una vez por noche y aquí solo se lee, nunca se recalcula al vuelo.",
"caveat": "Un indicador de bienestar descriptivo, no una medida de recuperación clínica ni de nivel de entrenamiento.",
"description": "Un indicador nocturno de recuperación a partir de tu VFC, frecuencia en reposo y sueño."
},
"STRESS_SCORE": {
"method": "Un indicador nocturno de estrés de 0 a 100 a partir de la forma de tu VFC a lo largo del día frente a tu línea base. Más alto significa más carga fisiológica.",
"caveat": "Un indicador derivado de la VFC, NO una medición de estrés por EDA / actividad electrodérmica: tu reloj no tiene sensor EDA. Descriptivo, no clínico.",
"description": "Un indicador nocturno de estrés a partir de la forma de tu VFC durante el día."
},
"STRAIN_SCORE": {
"method": "Un indicador nocturno de esfuerzo de 0 a 100 a partir de tu carga de actividad ponderada por frecuencia cardíaca (un modelo de impulso de entrenamiento tipo TRIMP). Más alto significa un día más exigente.",
"caveat": "Un indicador descriptivo de carga de actividad, no una medida de carga clínica ni de nivel de entrenamiento.",
"description": "Un indicador nocturno de carga de actividad a partir de tu esfuerzo ponderado por frecuencia cardíaca."
}
},
"scores": {
"sectionTitle": "Puntuaciones de bienestar",
"recovery": "Recuperación",
"stress": "Estrés",
"strain": "Esfuerzo"
},
"coincident": {
"label": "Varias constantes fuera de la base",
"summary": "Hoy, {count} de tus constantes quedaron fuera de su rango personal.",
"vitals": "Fuera de rango: {list}"
}
},
"cardioFitness": {
"title": "Condición física",
"description": "Tu VO₂ máx a lo largo del tiempo, la medida principal de la forma aeróbica.",
"chartTitle": "VO₂ máx (mL/(kg·min))",
"emptyState": {
"title": "Aún no hay datos de condición física",
"description": "Los valores de VO₂ máx aparecen aquí cuando Apple Health los sincroniza desde tus entrenamientos."
}
},
"navCardioFitness": "Condición física",
"cardioRecovery": {
"title": "Recuperación cardiaca",
"description": "Cuánto baja tu frecuencia cardiaca un minuto después del esfuerzo máximo.",
"chartTitle": "Recuperación cardiaca (lpm)",
"emptyState": {
"title": "Aún no hay datos de recuperación cardiaca",
"description": "Los valores de recuperación cardiaca aparecen aquí cuando Apple Health los sincroniza desde un entrenamiento."
}
},
"navCardioRecovery": "Recuperación cardiaca",
"wristTemperature": {
"title": "Temperatura de muñeca",
"description": "Tu temperatura de muñeca durante la noche, comparada con tu valor de referencia.",
"chartTitle": "Temperatura de muñeca (°C)",
"emptyState": {
"title": "Aún no hay datos de temperatura de muñeca",
"description": "Los valores aparecen aquí cuando Apple Health sincroniza el uso nocturno."
}
},
"navWristTemperature": "Temp. muñeca",
"falls": {
"title": "Caídas",
"description": "Caídas fuertes que detectó tu Apple Watch, contadas por día.",
"chartTitle": "Caídas por día",
"emptyState": {
"title": "Aún no hay datos de caídas",
"description": "Las caídas detectadas aparecen aquí cuando Apple Health se sincroniza. Cero caídas es el objetivo."
}
},
"navFalls": "Caídas",
"sixMinuteWalk": {
"title": "Marcha de 6 minutos",
"description": "La estimación de Apple de cuánto podrías caminar en seis minutos.",
"chartTitle": "Distancia en 6 minutos (m)",
"emptyState": {
"title": "Aún no hay datos de marcha de 6 minutos",
"description": "Las estimaciones aparecen aquí cuando Apple Health se sincroniza."
}
},
"navSixMinuteWalk": "Marcha 6 min",
"stairAscentSpeed": {
"title": "Velocidad subiendo escaleras",
"description": "Lo rápido que subes escaleras, medido por tu iPhone o Watch.",
"chartTitle": "Velocidad de subida (m/s)",
"emptyState": {
"title": "Aún no hay datos de subida de escaleras",
"description": "Los valores aparecen aquí cuando Apple Health se sincroniza."
}
},
"navStairAscentSpeed": "Subir escaleras",
"stairDescentSpeed": {
"title": "Velocidad bajando escaleras",
"description": "Lo rápido que bajas escaleras, medido por tu iPhone o Watch.",
"chartTitle": "Velocidad de bajada (m/s)",
"emptyState": {
"title": "Aún no hay datos de bajada de escaleras",
"description": "Los valores aparecen aquí cuando Apple Health se sincroniza."
}
},
"navStairDescentSpeed": "Bajar escaleras",
"breathingDisturbances": {
"title": "Alteraciones respiratorias",
"description": "Con qué frecuencia se alteró tu respiración durante el sueño.",
"chartTitle": "Alteraciones respiratorias del sueño",
"emptyState": {
"title": "Aún no hay datos de alteraciones respiratorias",
"description": "Los valores aparecen aquí cuando Apple Health los sincroniza desde un Apple Watch compatible."
}
},
"navBreathingDisturbances": "Respiración",
"rhythmEvents": {
"sectionTitle": "Notificaciones de salud del dispositivo",
"sectionIntro": "Notificaciones que tu reloj envió por su cuenta. HealthLog solo las muestra — no las detecta, confirma ni interpreta.",
"loadError": "No se pudieron cargar las notificaciones de tu dispositivo.",
"disclaimer": "Estas son alertas que tu dispositivo generó con su propia detección integrada. Se muestran aquí solo para tu información. No es una evaluación médica de HealthLog, y HealthLog no diagnostica ninguna afección. Si una notificación te preocupa, habla con tu médico.",
"event": {
"irregularRhythm": "Irregular rhythm notification",
"highHeartRate": "High heart rate notification",
"lowHeartRate": "Low heart rate notification",
"walkingSteadiness": "Walking steadiness alert",
"breathingDisturbance": "Breathing disturbance during sleep"
},
"verdict": {
"irregular": "Your device flagged a possible irregular rhythm.",
"notDetected": "Your device did not flag an irregular rhythm.",
"inconclusive": "Your device could not make a reading.",
"low": "Your device flagged low walking steadiness.",
"veryLow": "Your device flagged very low walking steadiness.",
"fired": "Your device flagged this event."
}
}
},
"thresholds": {
"defaultLabel": "Por defecto",
@@ -2393,7 +2947,7 @@
"description": "Withings, moodLog, and other connected services."
},
"notifications": {
"title": "Canales de notificación",
"title": "Notificaciones",
"description": "Live overview of every configured channel."
},
"dashboard": {
@@ -2401,63 +2955,62 @@
"description": "Tile layout and order."
},
"thresholds": {
"title": "Objetivos y fuentes",
"description": "Por métrica: tu propio rango objetivo y qué fuente prevalece cuando varias registran el mismo valor el mismo día."
"title": "Objetivos",
"description": "Por métrica: tu propio rango objetivo de valores saludables."
},
"sources": {
"title": "Sources",
"description": "Pick which ingest source wins when more than one records the same metric for the same day.",
"redirectNotice": "Las fuentes ahora forman parte de «Objetivos y fuentes».",
"cardTitle": "Source priority by metric",
"help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.",
"resetDefaults": "Reset to defaults",
"moveUp": "Move up",
"moveDown": "Move down",
"saveSuccess": "Source priority saved.",
"saveError": "Saving the source priority failed.",
"resetSuccess": "Source priority reset to defaults.",
"perMetricToggle": "Per-metric overrides ({count})",
"perMetricHelp": "Metrics you have customised away from the default ladder. Use this list to roll any single metric back to its constant default without touching the rest.",
"perMetricEmpty": "No per-metric overrides set — every metric uses the global ladder above.",
"title": "Fuentes",
"description": "Elige qué fuente prevalece cuando varias registran la misma métrica el mismo día.",
"cardTitle": "Prioridad de fuentes por métrica",
"help": "Cuando más de una fuente entrega el mismo valor el mismo día (p. ej. Withings y Apple Health informan ambos 1200 pasos), la fuente superior cuenta como la lectura canónica. Las demás fuentes se conservan para la traza de auditoría.",
"resetDefaults": "Restablecer valores predeterminados",
"moveUp": "Subir",
"moveDown": "Bajar",
"saveSuccess": "Prioridad de fuentes guardada.",
"saveError": "No se pudo guardar la prioridad de fuentes.",
"resetSuccess": "Prioridad de fuentes restablecida a los valores predeterminados.",
"perMetricToggle": "Ajustes por métrica ({count})",
"perMetricHelp": "Métricas que has personalizado respecto al orden predeterminado. Usa esta lista para devolver cualquier métrica concreta a su valor predeterminado sin tocar las demás.",
"perMetricEmpty": "No hay ajustes por métrica — todas las métricas usan el orden global de arriba.",
"resetMetric": "Restablecer",
"deviceTypeToggle": "Device-type priority ({count})",
"deviceTypeHelp": "Within a source, which device wins. When an Apple Watch and an iPhone both record steps, the higher-ranked device-type counts as the canonical reading.",
"deviceTypeDefault": "Default device-type ladder",
"resetDeviceTypes": "Reset device-types",
"deviceTypeToggle": "Prioridad por tipo de dispositivo ({count})",
"deviceTypeHelp": "Dentro de una fuente, qué dispositivo prevalece. Cuando un Apple Watch y un iPhone registran ambos los pasos, el tipo de dispositivo mejor clasificado cuenta como la lectura canónica.",
"deviceTypeDefault": "Orden predeterminado por tipo de dispositivo",
"resetDeviceTypes": "Restablecer tipos de dispositivo",
"metrics": {
"steps": "Pasos",
"activeEnergy": "Active energy",
"walkingRunningDistance": "Walking + running distance",
"flightsClimbed": "Flights climbed",
"activeEnergy": "Energía activa",
"walkingRunningDistance": "Distancia a pie + corriendo",
"flightsClimbed": "Pisos subidos",
"sleep": "Sueño",
"weight": "Peso",
"bloodPressure": "Presión arterial",
"pulse": "Pulso",
"bodyFat": "Grasa corporal",
"bodyTemperature": "Body temperature",
"spo2": "Oxygen saturation",
"hrv": "Heart-rate variability",
"bodyTemperature": "Temperatura corporal",
"spo2": "Saturación de oxígeno",
"hrv": "Variabilidad de la frecuencia cardíaca",
"restingHeartRate": "Frecuencia cardíaca en reposo",
"vo2Max": "VO2 max"
"vo2Max": "VO2 máx."
},
"sourceLabels": {
"WITHINGS": "Withings",
"APPLE_HEALTH": "Apple Health",
"MANUAL": "Manual entry",
"MANUAL": "Entrada manual",
"IMPORT": "Importar"
},
"deviceLabels": {
"watch": "Watch",
"band": "Band",
"ring": "Ring",
"phone": "Phone",
"scale": "Scale",
"other": "Other",
"watch": "Reloj",
"band": "Pulsera",
"ring": "Anillo",
"phone": "Teléfono",
"scale": "Báscula",
"other": "Otro",
"unknown": "Desconocido"
}
},
"ai": {
"title": "Insights",
"title": "AI Insights",
"description": "Provider, model, key."
},
"api": {
@@ -2706,6 +3259,11 @@
"testProvider": "Test active provider",
"testSuccess": "OK — {provider} ({model})",
"testFailedShort": "Test failed: {message}",
"testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.",
"testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.",
"testReasonRateLimited": "Provider rate-limited the request — try again shortly.",
"testReasonServerError": "The AI provider returned a server error.",
"testReasonUnreachable": "Could not reach the AI provider.",
"disableCoach": {
"title": "Ocultar el Coach",
"description": "Oculta el botón del Coach y el panel en todas las páginas.",
@@ -2939,7 +3497,9 @@
"fhirNote": "La salida estructurada sigue el estándar FHIR R4 (compatible con ePA).",
"generate": "Generar",
"error": "Error en la exportación ({code})."
}
},
"globalExcludedInjectionSitesLabel": "Sitios de inyección excluidos globalmente",
"globalExcludedInjectionSitesHint": "Los sitios listados aquí no se ofrecen para ningún medicamento y se rechazan al registrar una dosis, aunque un medicamento los prefiera."
},
"admin": {
"title": "Administration",
@@ -3760,8 +4320,6 @@
}
},
"targets": {
"title": "Valores objetivo",
"introText": "Tus valores actuales frente a los rangos objetivo recomendados. Las fuentes de las guías clínicas están enlazadas en cada tarjeta.",
"bmi": "IMC",
"average30d": "Media 30 días:",
"consistency": {
@@ -3783,23 +4341,11 @@
"sixDaysAgo": "hace 6 días"
}
},
"card": {
"lastMet": "Última vez en objetivo: {when}",
"streak": "{count} días seguidos"
},
"relativeDay": {
"today": "Hoy",
"yesterday": "Ayer",
"daysAgo": "hace {count} días"
},
"summary": {
"weekTitle": "{met} de {total} objetivos cumplidos esta semana",
"weekTitleAll": "Todo{total} targets met this week",
"streakHighlight": "{count} días seguidos: {metric}"
},
"coach": {
"cta": "Preguntar al coach"
},
"edit": {
"openLabel": "Editar el rango objetivo de {metric}",
"title": "Editar rango objetivo — {metric}",
@@ -3831,31 +4377,10 @@
"aboveTarget": "{delta} {unit} por encima del rango",
"belowTarget": "{delta} {unit} por debajo del rango",
"diastolic": "Diastólica",
"noMeasurementYet": "Aún no hay mediciones",
"noMeasurementData": "No hay datos de mediciones. Añade mediciones para ver tus objetivos.",
"profileIncomplete": "Perfil incompleto",
"profileIncompleteHeight": "Indica tu altura en el perfil para activar los objetivos de IMC.",
"profileIncompleteAge": "Indica tu fecha de nacimiento en el perfil para activar los objetivos por edad.",
"profileIncompleteHeightAge": "Indica altura y fecha de nacimiento en el perfil para ver objetivos personalizados.",
"compliancePerMedication": "Cumplimiento por medicamento",
"glucoseFasting": "Glucemia — en ayunas",
"glucosePostprandial": "Glucemia — posprandial",
"glucoseRandom": "Glucemia — cualquiera",
"glucoseBedtime": "Glucemia — antes de dormir",
"loadError": "No se han podido cargar los valores objetivo",
"label": {
"WEIGHT": "Peso",
"BLOOD_PRESSURE": "Presión arterial",
"BLOOD_PRESSURE_IN_TARGET": "Tensión arterial en rango",
"PULSE": "Pulso en reposo",
"SLEEP_DURATION": "Duración del sueño",
"BMI": "IMC",
"BODY_FAT": "Grasa corporal",
"ACTIVITY_STEPS": "Pasos/día",
"MEDICATION_COMPLIANCE": "Cumplimiento",
"MOOD_SCORE": "Estado de ánimo",
"MOOD_STABILITY": "Estabilidad del ánimo"
},
"status": {
"underweight": "Peso insuficiente",
"normal": "Normal",
+638 -113
View File
@@ -158,7 +158,12 @@
"chartsTitle": "Graphiques de tendance",
"aiSummaryTitle": "Résumé IA — non validé cliniquement",
"aiSummaryDisclaimer": "Cette section a été générée automatiquement par un modèle d’IA et n’est pas validée cliniquement. Elle ne remplace pas une évaluation médicale.",
"packageReadme": "Dossier de santé HealthLog\n\nCe paquet contient :\n- report.pdf : le rapport de santé pour votre médecin.\n- bundle.json : les mêmes données sous forme de Bundle document HL7 FHIR R4, compatible ePA.\n\nGénéré avec HealthLog."
"packageReadme": "Dossier de santé HealthLog\n\nCe paquet contient :\n- report.pdf : le rapport de santé pour votre médecin.\n- bundle.json : les mêmes données sous forme de Bundle document HL7 FHIR R4, compatible ePA.\n\nGénéré avec HealthLog.",
"wellnessTitle": "Résumé de bien-être",
"wellnessDisclaimer": "Ce sont des scores de bien-être descriptifs calculés à partir de vos signaux suivis (0–100). Ce ne sont pas des évaluations cliniques ni des diagnostics.",
"wellnessRecovery": "Récupération",
"wellnessStress": "Stress",
"wellnessStrain": "Effort"
},
"errorBoundary": {
"title": "Une erreur est survenue",
@@ -425,7 +430,22 @@
"dailyTotalCaption": "({count} échantillons)",
"expandDay": "Afficher les échantillons de ce jour",
"collapseDay": "Masquer les échantillons de ce jour",
"loadError": "Impossible de charger les mesures."
"loadError": "Impossible de charger les mesures.",
"typeCardioRecovery": "Récupération cardiaque",
"typeWristTemperature": "Température du poignet",
"typeFallCount": "Chutes",
"typeSixMinuteWalkDistance": "Distance de marche de 6 minutes",
"typeStairAscentSpeed": "Vitesse en montée d'escaliers",
"typeStairDescentSpeed": "Vitesse en descente d'escaliers",
"typeBreathingDisturbances": "Troubles respiratoires du sommeil",
"typeIrregularRhythmNotification": "Irregular rhythm notification",
"typeHighHeartRateEvent": "High heart rate notification",
"typeLowHeartRateEvent": "Low heart rate notification",
"typeWalkingSteadinessEvent": "Walking steadiness alert",
"typeBreathingDisturbanceEvent": "Breathing disturbance",
"typeRecoveryScore": "Recovery score",
"typeStressScore": "Stress score",
"typeStrainScore": "Strain score"
},
"mood": {
"title": "Humeur",
@@ -461,7 +481,49 @@
"deleteConfirmTitle": "Supprimer l’entrée ?",
"deleteConfirmDescription": "Cette entrée d’humeur sera supprimée définitivement.",
"pageInfo": "Page {page} sur {total}",
"loginRequired": "Connecte-toi pour voir tes entrées d’humeur."
"loginRequired": "Connecte-toi pour voir tes entrées d’humeur.",
"note": "Note",
"notePlaceholder": "Ajoutez une note sur votre ressenti…",
"tagPicker": "Étiquettes",
"tagCategory": {
"feelings": "Émotions",
"sleep": "Sommeil",
"health": "Santé",
"social": "Social",
"work": "Travail"
},
"tag": {
"happy": "heureux",
"excited": "enthousiaste",
"grateful": "reconnaissant",
"relaxed": "détendu",
"content": "satisfait",
"tired": "fatigué",
"unsure": "incertain",
"bored": "ennuyé",
"tense": "tendu",
"angry": "en colère",
"stressed": "stressé",
"sad": "triste",
"sleptWell": "bien dormi",
"sleptOk": "sommeil moyen",
"sleptPoorly": "mal dormi",
"earlyNight": "couché tôt",
"workedOut": "fait du sport",
"ateWell": "bien mangé",
"hydrated": "bu de l'eau",
"walked": "marché",
"alcohol": "alcool",
"family": "famille",
"friends": "amis",
"party": "fête",
"alone": "seul",
"productive": "productif",
"overtime": "heures sup",
"dayOff": "jour de repos",
"travel": "voyage",
"sickDay": "jour de maladie"
}
},
"medications": {
"title": "Médicaments",
@@ -544,11 +606,14 @@
"veryOverdue": "Très en retard",
"compliance7d": "Observance 7 jours",
"compliance30d": "Observance 30 jours",
"complianceWindow": "Observance {days} jours",
"lastIntake": "Dernière prise :",
"nextIntake": "Prochaine prise :",
"dayStreak": "jours de série",
"taken": "Pris",
"skipped": "Sauté",
"intakeToastTaken": "{name} enregistré comme pris",
"intakeToastSkipped": "{name} sauté",
"weekdaySunday": "Dimanche",
"weekdayMonday": "Lundi",
"weekdayTuesday": "Mardi",
@@ -759,7 +824,11 @@
"back": "Back",
"next": "Next",
"save": "Save",
"saveEdit": "Save changes"
"saveEdit": "Save changes",
"nextHint": "Suivant : {step}",
"reviewHint": "Suivant : Enregistrer",
"jumpFirst": "Aller à la première étape",
"jumpLast": "Aller à la vérification et à l’enregistrement"
},
"nl": {
"button": "Describe"
@@ -769,11 +838,13 @@
"title": "What's the medication called?",
"subline": "Exactly as it appears on the box.",
"label": "Name",
"placeholder": "e.g. Ramipril"
"placeholder": "e.g. Ramipril",
"short": "Nom"
},
"step2": {
"title": "What kind of medication is it?",
"subline": "We use this for the right templates and analytics."
"subline": "We use this for the right templates and analytics.",
"short": "Type"
},
"step3": {
"title": "What's the dose?",
@@ -805,15 +876,18 @@
"dosesPerUnitPlaceholder": "p. ex. 4",
"dosesPerUnitHint": "Doses par stylo / flacon — laisser vide pour ne pas suivre le stock.",
"injectionSiteToggle": "Rotation des sites d'injection",
"injectionSiteHint": "Guide de rotation facultatif. Le site de chaque dose est enregistré lors de la saisie de la prise."
"injectionSiteHint": "Guide de rotation facultatif. Le site de chaque dose est enregistré lors de la saisie de la prise.",
"short": "Dose"
},
"step4": {
"title": "Over what period are you taking it?",
"subline": "A start date is enough for today. You can add an end date later."
"subline": "A start date is enough for today. You can add an end date later.",
"short": "Quand"
},
"step5": {
"title": "How often do you take it?",
"subline": "Pick the cadence — the details follow on the next step."
"subline": "Pick the cadence — the details follow on the next step.",
"short": "Fréquence"
},
"step6": {
"title": "What does that look like in detail?",
@@ -829,7 +903,8 @@
"rollingDays": {
"label": "Every",
"suffix": "days from the last intake"
}
},
"short": "Détail"
},
"step7": {
"title": "When do you take it?",
@@ -840,14 +915,16 @@
"noon": "Noon",
"evening": "Evening",
"night": "Night"
}
},
"short": "Heures"
},
"step8": {
"title": "All done — should HealthLog remind you?",
"subline": "Review everything below before you save.",
"remindersLabel": "Reminders on",
"remindersDescription": "Push, Telegram or ntfy — based on your settings.",
"multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release."
"multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release.",
"short": "Fini"
}
},
"classRow": {
@@ -923,26 +1000,17 @@
"submitFailed": "Could not save the medication — please retry."
}
},
"titration": {
"section": "Paliers de titration",
"loading": "Chargement des données de titration",
"loadFailed": "Échec du chargement des données de titration.",
"drugLadderHeader": "Paliers standard (référence EMA)",
"typicalWeeksOnStep": "≥ {weeks} sem. typique",
"youAreHere": "Vous êtes ici",
"nextStepCaption": "Palier suivant (typique) : {dose} mg après ~{weeks} semaines au palier actuel.",
"ceilingMessage": "Vous êtes au sommet du palier standard.",
"escalationDueHint": "Vous êtes à ce palier depuis {weeks} semaines ; le schéma prévoit une progression typique après ~{typical} semaines.",
"currentStepDwell": "À ce palier depuis {weeks} semaines",
"doseUnitMg": "mg",
"emptyState": "Aucun historique de dose. Enregistrez une dose pour voir votre position dans le schéma.",
"nonStandardDose": "La dose actuelle est en dehors des paliers standard.",
"disclaimer": "Valeurs de référence EMA. Consultez votre médecin avant toute modification de dose.",
"emaSourceCta": "Source EMA",
"stepLabel": "Palier {n}"
"doseStrength": {
"title": "Dose au fil du temps",
"axisLabel": "Dose enregistrée ({unit})",
"tooltipLabel": "Dose",
"currentDose": "Dose actuelle : {dose} {unit}",
"noCurrentDose": "Aucune dose enregistrée pour l'instant.",
"empty": "Aucun historique de titration. Enregistrez un changement de dose pour voir la courbe de dose."
},
"injectionSiteBodyOutlineAriaLabel": "Silhouette du corps",
"injectionSitePickerAriaLabel": "Sélecteur de site d'injection",
"injectionSiteRecommendedAriaLabel": "{site} — prochain site recommandé",
"siteAbdomenLeft": "Abdomen, en bas à gauche",
"siteAbdomenRight": "Abdomen, en bas à droite",
"siteAbdomenUpperLeft": "Abdomen, en haut à gauche",
@@ -1005,9 +1073,10 @@
"phaseSaved": "Phases de rappel enregistrées",
"phaseReset": "Réinitialisées par défaut",
"sourceWeb": "Web",
"sourceApi": "API",
"sourceApi": "App iOS",
"sourceReminder": "Rappel",
"sourceImport": "Import",
"intakeSourceVia": "via {origin}",
"intakeHistoryEmptyTitle": "Aucune prise pour l’instant",
"intakeHistoryEmptyDescription": "Dès que tu enregistreras une prise, elle apparaîtra ici avec l’heure et la source.",
"intakeHistoryEmptyAction": "Enregistrer une prise",
@@ -1078,11 +1147,6 @@
"emptyState": "Aucune prise enregistrée.",
"emptyStateCta": "Enregistrez une dose pour voir la courbe estimée prendre forme.",
"estimateNote": "Estimation éducative à partir de la pharmacocinétique de population publiée par l'EMA. Pas une mesure.",
"gatedTitle": "Mode Recherche désactivé",
"gatedBody": "Activez le mode Recherche dans les paramètres pour voir la courbe estimée du niveau de médicament. La courbe est en opt-in parce que c'est un outil d'apprentissage, pas une mesure clinique.",
"gatedStaleTitle": "Le disclaimer doit être reconfirmé",
"gatedStaleBody": "Le disclaimer du mode Recherche a été mis à jour depuis votre dernière confirmation. Relisez-le dans les paramètres et confirmez à nouveau pour réafficher la courbe.",
"gatedCta": "Ouvrir le mode Recherche dans les paramètres",
"unknownDrug": "{name} n'est pas dans le catalogue GLP-1 approuvé par l'EMA, donc HealthLog ne peut pas tracer de courbe de pharmacocinétique de population."
}
},
@@ -1170,6 +1234,14 @@
"unit": "minutes",
"saved": "Reminder window saved",
"failed": "Could not save the reminder window."
},
"codes": {
"label": "Codes cliniques (ATC / RxNorm)",
"note": "Facultatif. Utilisés lors de l'export FHIR du dossier de santé. Les codes sont repris tels qu'ils sont saisis — jamais devinés.",
"atcLabel": "ATC",
"rxNormLabel": "RxNorm",
"saved": "Codes cliniques enregistrés",
"failed": "Impossible d'enregistrer les codes cliniques."
}
},
"zone": {
@@ -1235,7 +1307,7 @@
"data": "Données",
"reminders": "Rappels",
"lifecycle": "Cycle de vie",
"danger": "Zone de danger"
"externalApi": "API externe"
},
"dataPortability": {
"import": {
@@ -1258,10 +1330,18 @@
"history": {
"back": "Retour au médicament",
"subtitle": "Historique complet des prises",
"plannedSuffix": "prévu",
"drugLevelDisclosure": "Afficher la courbe estimée du principe actif"
"plannedSuffix": "prévu"
}
}
},
"logInjectionSiteTitle": "Enregistrer le site d'injection",
"logInjectionSiteDescription": "Où avez-vous injecté {name} ? Facultatif — vous pouvez l'ignorer.",
"logInjectionSiteSkip": "Ignorer",
"logInjectionSiteConfirm": "Enregistrer le site",
"logInjectionSiteNoneAvailable": "Aucun site d'injection disponible — tous les sites sont exclus dans vos réglages.",
"trackInjectionSitesToggle": "Suivre les sites d'injection",
"trackInjectionSitesHint": "Après la prise d'une dose, demander quel site a été utilisé et suggérer la prochaine rotation.",
"allowedSitesLabel": "Sites autorisés",
"allowedSitesHint": "Laissez tout sélectionné pour aucune restriction. Les sites exclus globalement ne sont jamais proposés."
},
"charts": {
"loadingLabel": "Chargement du graphique…",
@@ -1272,6 +1352,13 @@
"bodyFat": "Masse grasse",
"sleep": "Sommeil",
"steps": "Pas",
"hrv": "VFC",
"restingHeartRate": "FC au repos",
"activeEnergy": "Énergie active",
"flights": "Étages gravis",
"distance": "Distance marche + course",
"vo2Max": "VO₂ max",
"bodyTemperature": "Température corporelle",
"noData": "Aucune donnée sur la période sélectionnée",
"bucketWeekly": "Moyenne hebdomadaire",
"bucketMonthly": "Moyenne mensuelle",
@@ -1336,7 +1423,17 @@
"personalBaseline": "Ta moyenne",
"deltaVsBaseline": "{delta} vs. ta moyenne",
"deltaUnchanged": "correspond à ta moyenne",
"deltaVsTarget": "{delta} vs. cible"
"deltaVsTarget": "{delta} vs. cible",
"weekdaysFull": {
"mon": "Lundi",
"tue": "Mardi",
"wed": "Mercredi",
"thu": "Jeudi",
"fri": "Vendredi",
"sat": "Samedi",
"sun": "Dimanche"
},
"rangeBand": "Plage"
},
"chart": {
"overlay": {
@@ -1376,6 +1473,7 @@
},
"insights": {
"noAnalysisYet": "Pas encore d’analyse.",
"assessmentPreparing": "Préparation de votre évaluation…",
"emptyTitle": "Pas encore assez de données",
"emptyDescription": "Les insights apparaissent dès que tu auras saisi quelques mesures. Commence par le poids ou la tension.",
"emptyAddMeasurement": "Saisir une mesure",
@@ -1432,6 +1530,8 @@
"heroRegenerating": "Génération en cours",
"regenerateAnalysis": "Relancer l’analyse",
"regenerateSuccess": "Analyse regénérée",
"warmAssessments": "Préparer les évaluations",
"warmStarted": "Les évaluations sont en cours de préparation en arrière-plan",
"heroFallbackSubtitle": "Un coup d’œil quotidien sur tes tendances — directement issu des chiffres que tu saisis.",
"heroGreetingMorning": "Bonjour",
"heroGreetingAfternoon": "Bonjour",
@@ -1498,7 +1598,22 @@
},
"trendsRow": {
"title": "Tendances",
"subtitle": "Les 30 derniers jours en un coup d’œil"
"subtitle": "Les 30 derniers jours en un coup d’œil",
"caption": {
"bp": "Valeurs systoliques et diastoliques des 30 derniers jours.",
"weight": "Évolution du poids corporel sur les 30 derniers jours.",
"pulse": "Pouls au repos des 30 derniers jours.",
"mood": "Score d'humeur quotidien des 30 derniers jours.",
"sleep": "Durée du sommeil nocturne des 30 derniers jours.",
"steps": "Nombre de pas quotidiens des 30 derniers jours.",
"hrv": "Variabilité de la fréquence cardiaque des 30 derniers jours.",
"resting_hr": "Fréquence cardiaque au repos des 30 derniers jours.",
"active_energy": "Énergie active dépensée sur les 30 derniers jours.",
"flights": "Étages gravis sur les 30 derniers jours.",
"distance": "Distance de marche et de course des 30 derniers jours.",
"vo2_max": "VO₂ max des 30 derniers jours.",
"body_temp": "Température corporelle des 30 derniers jours."
}
},
"vo2Max": {
"statLatest": "Actuel",
@@ -1515,6 +1630,23 @@
"confidenceLow": "Confiance faible",
"pendingLabel": "Commentaire de tendance en cours de génération"
},
"range": {
"label": "Période",
"noPriorData": "aucune donnée sur la période précédente",
"option": {
"7d": "7j",
"30d": "30j",
"90d": "90j",
"1y": "1an"
},
"priorLabel": {
"7d": "vs 7j précédents",
"30d": "vs 30j précédents",
"90d": "vs 90j précédents",
"1y": "vs année précédente"
},
"noChange": "aucun changement"
},
"correlationRow": {
"title": "Corrélations",
"subtitle": "Hypothèses prédéfinies, validées si n ≥ 14 et p < 0,05",
@@ -1800,7 +1932,124 @@
"medikamenteEmptyDescription": "Les insights d’observance ont besoin d’au moins un médicament enregistré.",
"medikamenteEmptyAction": "Ouvrir les médicaments",
"therapyTimelineDescription": "Journal chronologique de la thérapie GLP-1 — changements de dose, injections, stock, effets secondaires.",
"bmiDescription": "IMC dérivé de ton poids et de ta taille, avec évaluation."
"bmiDescription": "IMC dérivé de ton poids et de ta taille, avec évaluation.",
"target": {
"range": "Cible : {min}–{max} {unit}",
"bpRange": "Cible : {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}",
"inTargetShare": "{pct}% des jours enregistrés dans la cible",
"adjustLink": "Ajuster la plage cible"
},
"explainer": {
"trigger": "Qu'est-ce que {title} ?",
"bloodPressureTitle": "Tension artérielle",
"bloodPressureBody": "La tension artérielle est la force exercée par le sang sur les parois des artères, exprimée en systolique sur diastolique (le pic lors d'un battement sur la valeur de repos entre les battements). Des valeurs durablement élevées sollicitent le cœur et les vaisseaux à la longue.",
"pulseTitle": "Pouls",
"pulseBody": "Le pouls correspond au nombre de battements du cœur par minute. Une mesure ponctuelle reflète ce que vous faisiez à l'instant : au repos, il est généralement de 60 à 100 battements par minute et augmente naturellement avec le mouvement, le stress ou la caféine.",
"weightTitle": "Poids",
"weightBody": "Le poids corporel est l'un des signaux les plus simples de l'équilibre énergétique à long terme et de la santé globale. La tendance sur plusieurs semaines compte bien plus qu'une mesure isolée, qui varie avec l'hydratation, l'alimentation et l'heure de la journée.",
"bmiTitle": "Indice de masse corporelle (IMC)",
"bmiBody": "L'IMC correspond à votre poids divisé par votre taille au carré, un seul chiffre qui vous situe dans une large catégorie de poids. C'est un dépistage de population, pas un diagnostic, et il ne distingue pas le muscle de la graisse.",
"sleepTitle": "Sommeil",
"sleepBody": "La durée du sommeil est le temps réellement dormi au cours de la nuit, réparti entre sommeil léger, profond et paradoxal. La plupart des adultes ont besoin d'environ sept à neuf heures ; une quantité et une qualité régulières soutiennent la récupération, l'humeur et le métabolisme.",
"moodTitle": "Humeur",
"moodBody": "Le suivi de l'humeur consigne ce que vous ressentez au fil du temps sur une échelle simple. Le noter régulièrement révèle des tendances — comment le sommeil, l'activité ou les médicaments s'alignent sur votre ressenti — qu'une seule journée ne montre jamais.",
"medicationsTitle": "Observance du traitement",
"medicationsBody": "L'observance mesure la fiabilité avec laquelle vous prenez votre traitement comme prescrit, ici sous forme de la part des doses attendues que vous avez enregistrées. Une prise régulière est souvent ce qui permet à un traitement d'agir comme prévu.",
"restingHrTitle": "Fréquence cardiaque au repos",
"restingHrBody": "La fréquence cardiaque au repos est votre rythme cardiaque lorsque vous êtes totalement au repos, généralement mesuré la nuit. Une fréquence plus basse indique souvent une meilleure forme cardiovasculaire, et une tendance à la hausse peut être un signe précoce de stress ou de maladie.",
"hrvTitle": "Variabilité de la fréquence cardiaque (VFC)",
"hrvBody": "La VFC est la petite variation du temps entre des battements consécutifs. Une variabilité plus élevée reflète généralement un système nerveux bien récupéré et adaptable, tandis qu'une baisse durable peut signaler de la fatigue, du stress ou une récupération insuffisante.",
"oxygenSaturationTitle": "Saturation en oxygène (SpO₂)",
"oxygenSaturationBody": "La SpO₂ est le pourcentage de globules rouges transportant de l'oxygène. Des valeurs de 95 à 100 % sont normales au repos ; des valeurs qui restent sous 90 % méritent attention, bien que de brèves baisses nocturnes soient fréquentes.",
"bodyTemperatureTitle": "Température corporelle",
"bodyTemperatureBody": "La température corporelle reflète votre état thermique central, normalement autour de 37 °C avec de petites variations quotidiennes. Une hausse durable peut indiquer une infection, tandis que les mesures nocturnes au poignet suivent surtout des variations relatives plutôt qu'une valeur clinique.",
"activeEnergyTitle": "Énergie active",
"activeEnergyBody": "L'énergie active correspond aux calories que vous brûlez par le mouvement, en plus de la dépense de base au repos. C'est une mesure directe du niveau d'activité physique de votre journée.",
"workoutsTitle": "Entraînements",
"workoutsBody": "Les entraînements sont les séances d'exercice structurées que vos appareils enregistrent, avec le type, la durée et l'énergie. Les suivre montre comment un entraînement régulier s'accumule au fil des semaines et comment il se relie à vos autres signaux de santé.",
"respiratoryRateTitle": "Fréquence respiratoire",
"respiratoryRateBody": "La fréquence respiratoire est le nombre de respirations que vous prenez par minute. Au repos, la plupart des adultes respirent environ douze à vingt fois par minute ; une hausse durable peut accompagner une maladie, un effort ou du stress.",
"bodyWaterTitle": "Eau corporelle totale",
"bodyWaterBody": "L'eau corporelle totale est la part de votre poids constituée de liquide, à l'intérieur et autour des cellules. Elle varie avec l'hydratation, l'alimentation et l'activité, si bien que la tendance sur plusieurs semaines en dit plus qu'une seule mesure.",
"boneMassTitle": "Masse osseuse",
"boneMassBody": "La masse osseuse est le poids estimé des minéraux de votre squelette. Les estimations sur balance sont approximatives, mais une tendance stable ou en hausse soutient la solidité osseuse à long terme.",
"fatFreeMassTitle": "Masse non grasse",
"fatFreeMassBody": "La masse non grasse est tout ce qui, dans votre corps, n'est pas de la graisse : muscle, os, organes et eau. La maintenir ou la développer tout en perdant du poids signale en général une composition corporelle plus saine.",
"fatMassTitle": "Masse grasse",
"fatMassBody": "La masse grasse est le poids total de la graisse de votre corps. Une part de graisse est indispensable aux hormones et au stockage d'énergie, mais un excès — surtout au niveau de l'abdomen — augmente le risque pour la santé à long terme.",
"muscleMassTitle": "Masse musculaire",
"muscleMassBody": "La masse musculaire est le poids estimé des muscles de votre corps. Plus de muscle soutient la force, le métabolisme et la mobilité, et demande généralement un effort actif pour être préservé avec l'âge.",
"visceralFatTitle": "Graisse viscérale",
"visceralFatBody": "La graisse viscérale est la graisse stockée au plus profond de l'abdomen, autour des organes. Contrairement à la graisse sous la peau, un niveau élevé est étroitement lié aux maladies cardiaques et au diabète de type 2 : plus elle est basse, mieux c'est.",
"leanBodyMassTitle": "Masse maigre",
"leanBodyMassBody": "La masse maigre est votre poids total moins la graisse corporelle : muscle, os, organes et eau réunis. La suivre en parallèle du poids montre si les variations viennent du muscle ou de la graisse.",
"flightsClimbedTitle": "Étages montés",
"flightsClimbedBody": "Les étages montés comptent les niveaux que vous gravissez chaque jour, un étage correspondant à environ trois mètres de dénivelé. C'est un indicateur simple de l'activité du quotidien et de l'effort des jambes.",
"walkingDistanceTitle": "Distance à pied et en course",
"walkingDistanceBody": "La distance à pied et en course est le trajet que vous parcourez à pied chaque jour. Elle complète le nombre de pas en reflétant la distance parcourue, pas seulement la fréquence des pas.",
"walkingSteadinessTitle": "Stabilité de la marche",
"walkingSteadinessBody": "La stabilité de la marche estime à quel point votre démarche est stable et équilibrée, classée d'acceptable à faible. Un niveau bas indique un risque de chute plus élevé et mérite d'être évoqué avec un professionnel de santé.",
"walkingHeartRateTitle": "Fréquence cardiaque à la marche",
"walkingHeartRateBody": "La fréquence cardiaque à la marche est votre pouls moyen lors d'une marche régulière. Une valeur plus basse au fil du temps reflète souvent une meilleure forme cardiovasculaire pour le même effort.",
"walkingAsymmetryTitle": "Asymétrie de la marche",
"walkingAsymmetryBody": "L'asymétrie de la marche est la part du temps où vos pas sont inégaux entre la jambe gauche et la droite. Un pourcentage plus élevé peut signaler une douleur, une blessure ou un trouble de la marche à examiner.",
"doubleSupportTimeTitle": "Temps de double appui",
"doubleSupportTimeBody": "Le temps de double appui est la part de chaque cycle de marche où les deux pieds touchent le sol en même temps. Un pourcentage plus élevé traduit en général une démarche plus prudente et moins stable.",
"stepLengthTitle": "Longueur de pas",
"stepLengthBody": "La longueur de pas est la distance moyenne d'une foulée à la suivante. Un pas plus court au fil du temps peut traduire de la fatigue, de la prudence ou un changement de mobilité.",
"walkingSpeedTitle": "Vitesse de marche",
"walkingSpeedBody": "La vitesse de marche est la rapidité de votre déplacement sur terrain plat. C'est un marqueur bien étudié de la santé générale et de la mobilité, et une allure stable ou en hausse est bon signe.",
"pulseWaveVelocityTitle": "Vitesse de l'onde de pouls",
"pulseWaveVelocityBody": "La vitesse de l'onde de pouls mesure la rapidité avec laquelle l'onde de pression de chaque battement parcourt vos artères. Une onde plus rapide indique des artères plus rigides, liées à un risque cardiovasculaire accru.",
"vascularAgeTitle": "Âge vasculaire",
"vascularAgeBody": "L'âge vasculaire estime, d'après la rigidité artérielle, l'âge apparent de vos artères par rapport à votre âge réel. Un âge vasculaire inférieur à votre âge réel suggère des vaisseaux sanguins en meilleure santé.",
"environmentalAudioTitle": "Niveaux sonores ambiants",
"environmentalAudioBody": "Les niveaux sonores ambiants suivent le volume de votre environnement en décibels. Une exposition prolongée au-delà d'environ 70 dB peut endommager l'audition avec le temps : des moyennes journalières plus basses sont plus sûres.",
"headphoneAudioTitle": "Niveaux audio au casque",
"headphoneAudioBody": "Les niveaux audio au casque suivent, en décibels, le volume auquel vous écoutez avec des écouteurs ou un casque. Baisser le volume et faire des pauses protège votre audition sur le long terme.",
"audioEventsTitle": "Événements sonores forts",
"audioEventsBody": "Les événements sonores forts signalent les moments où le bruit autour de vous a atteint un niveau pouvant nuire à l'audition en cas d'exposition suffisante. Des événements fréquents invitent à rechercher un environnement plus calme ou une protection auditive.",
"daylightTitle": "Temps à la lumière du jour",
"daylightBody": "Le temps à la lumière du jour correspond à la durée passée en lumière extérieure chaque jour. Une lumière naturelle régulière soutient un rythme veille-sommeil et une humeur sains, et le temps passé dehors est lié à la santé oculaire.",
"bloodGlucoseTitle": "Glycémie",
"bloodGlucoseBody": "La glycémie est la quantité de sucre dans votre sang, principale source d'énergie de l'organisme. Elle augmente après les repas et se stabilise entre eux ; des valeurs durablement élevées peuvent signaler un diabète ou un prédiabète.",
"skinTemperatureTitle": "Température cutanée",
"skinTemperatureBody": "La température cutanée correspond ici à la petite variation nocturne de la température de votre poignet par rapport à votre propre référence, et non à une mesure absolue de fièvre. Les variations peuvent suivre le sommeil, l'environnement, une maladie ou la phase du cycle menstruel.",
"cardioFitnessTitle": "Capacité cardio (VO₂ max)",
"cardioFitnessBody": "Le VO₂ max correspond à la quantité d'oxygène que votre corps peut utiliser à l'effort maximal. C'est le meilleur marqueur de laboratoire de la forme aérobie, et une valeur plus élevée ou en hausse est un bon signe de santé cardiovasculaire.",
"cardioRecoveryTitle": "Récupération cardiaque",
"cardioRecoveryBody": "La récupération cardiaque correspond au nombre de battements par minute dont votre fréquence cardiaque diminue dans la première minute après l'arrêt d'un effort intense. Une baisse plus forte signifie que votre cœur se calme plus vite, signe d'une bonne condition physique.",
"wristTemperatureTitle": "Température du poignet",
"wristTemperatureBody": "La température du poignet est mesurée pendant votre sommeil. Apple l'affiche comme l'écart par rapport à votre valeur de référence ; une valeur stable est normale, tandis qu'une hausse durable peut signaler une maladie, un entraînement intense ou un changement de cycle.",
"fallsTitle": "Chutes",
"fallsBody": "Ceci compte les chutes lourdes détectées par votre Apple Watch. L'objectif est zéro ; un nombre en hausse est un signal de risque de mobilité à évoquer avec un professionnel, surtout pour les personnes âgées.",
"sixMinuteWalkTitle": "Distance de marche de 6 minutes",
"sixMinuteWalkBody": "C'est l'estimation d'Apple de la distance que vous pourriez parcourir en six minutes, un test clinique classique de mobilité et d'endurance cardiopulmonaire. Une distance stable ou en hausse est un bon signe.",
"stairAscentSpeedTitle": "Vitesse en montée d'escaliers",
"stairAscentSpeedBody": "C'est la vitesse à laquelle vous montez une volée de marches. C'est un marqueur sensible de la force des jambes et de la mobilité : un rythme stable ou en hausse est bon signe, un rythme en baisse mérite attention.",
"stairDescentSpeedTitle": "Vitesse en descente d'escaliers",
"stairDescentSpeedBody": "C'est la vitesse à laquelle vous descendez une volée de marches. Avec la vitesse de montée, elle reflète le contrôle des jambes et l'équilibre : un rythme stable est bon signe, un rythme en baisse mérite attention.",
"breathingDisturbancesTitle": "Troubles respiratoires du sommeil",
"breathingDisturbancesBody": "Votre Apple Watch peut estimer à quelle fréquence votre respiration a été interrompue pendant le sommeil. Un indice plus bas est préférable ; un niveau qu'Apple signale comme élevé, surtout sur plusieurs nuits, mérite d'être évoqué avec un professionnel."
},
"stats": {
"label": "Statistiques récapitulatives",
"min": "Min",
"max": "Max",
"median": "Médiane",
"mean": "Moyenne"
},
"showAllValues": "Afficher toutes les mesures",
"valuesPageTitle": "{metric} — toutes les mesures",
"valuesPageDescription": "Chaque mesure derrière cette métrique. Modifiez ou supprimez une entrée directement.",
"diversity": {
"trigger": "Conseil de mesure",
"weekday": "La plupart de vos mesures de {metric} tombent le même jour de la semaine. Enregistrer d'autres jours donne une image plus complète.",
"timeOfDay": "La plupart de vos mesures de {metric} tombent à une heure similaire de la journée. Varier l'heure donne une image plus complète."
},
"valuesBack": "Retour aux Insights",
"valuesBackToMetric": "Retour à {metric}",
"scoresBack": "Retour aux Insights"
},
"sleep": {
"title": "Sommeil",
@@ -2181,7 +2430,312 @@
"description": "Les relevés apparaissent ici dès qu’Apple Health se synchronise."
}
},
"navWalkingSpeed": "Vitesse de marche"
"navWalkingSpeed": "Vitesse de marche",
"mood": {
"heatmapTitle": "Calendrier d'humeur",
"heatmapNoEntry": "Aucune entrée",
"legendGreat": "Excellent",
"legendGood": "Bien",
"legendOkay": "Correct",
"legendLow": "Bas",
"distributionTitle": "Répartition de l'humeur",
"distributionDaysUnit": "jours",
"weekdayTitle": "Moyenne par jour de la semaine",
"weekdayNoData": "Aucune donnée",
"weekdayBestWorst": "Meilleur jour : {best} · Jour le plus difficile : {worst}",
"tagsTitle": "Étiquettes et déclencheurs",
"tagAvgMood": "Humeur moyenne avec cette étiquette",
"correlationsTitle": "Humeur et autres mesures",
"correlationsDescription": "Comment votre humeur évolue avec le sommeil, l'activité et le pouls le même jour.",
"correlation": {
"sleepTitle": "Humeur vs. sommeil",
"stepsTitle": "Humeur vs. activité",
"pulseTitle": "Humeur vs. pouls",
"sleepAxis": "Sommeil (min)",
"stepsAxis": "Pas",
"pulseAxis": "Pouls (bpm)",
"strengthStrong": "Forte",
"strengthModerate": "Modérée",
"strengthWeak": "Faible",
"strengthNone": "Aucune",
"source": "{n} jours appariés · r = {r}",
"emptyTitle": "Pas encore assez de données appariées",
"emptyDescription": "Enregistrez l'humeur et cette mesure les mêmes jours pour voir une corrélation.",
"weightTitle": "Humeur vs. poids",
"bloodPressureTitle": "Humeur vs. tension artérielle",
"weightAxis": "Poids",
"bloodPressureAxis": "Systolique (mmHg)"
},
"structuredTagsTitle": "Répartition des étiquettes",
"inTargetValue": "{pct} %",
"inTargetCaption": "des derniers jours dans votre plage de bonne humeur",
"narrative": {
"title": "Ce qui ressort",
"weekdayDip": "Votre humeur baisse surtout le {weekday} — {delta} sous votre semaine.",
"weekdayPeak": "Le {weekday} est toujours votre meilleur jour, avec une moyenne de {value}.",
"trendUp": "Votre humeur progresse sur les {days} derniers jours (+{delta} par rapport à la période précédente).",
"trendDown": "Votre humeur recule sur les {days} derniers jours (-{delta} par rapport à la période précédente).",
"weekendUp": "Vous vous sentez mieux le week-end (+{delta} par rapport à la semaine).",
"weekendDown": "Votre humeur est plus basse le week-end (-{delta} par rapport à la semaine).",
"tagLift": "Les jours marqués « {tag} » dépassent votre humeur générale de {delta} en moyenne.",
"tagDrop": "Les jours marqués « {tag} » sont {delta} sous votre moyenne.",
"inTarget": "Vous avez été dans votre plage de bonne humeur {pct} % des 30 derniers jours.",
"streak": "Vous avez noté votre humeur {days} jours d'affilée — continuez.",
"timeOfDay": "Vous vous sentez mieux le {bucket}, en moyenne {value}."
},
"timeOfDay": {
"title": "Humeur selon l'heure de la journée",
"morning": "Matin",
"afternoon": "Après-midi",
"evening": "Soir",
"night": "Nuit",
"bestCaption": "Vous vous sentez généralement mieux le {bucket}."
},
"stability": {
"value": "{score}/100",
"bandVerySteady": "Votre humeur a été très stable",
"bandSteady": "Votre humeur a été plutôt stable",
"bandVariable": "Votre humeur a été nettement variable",
"bandVeryVariable": "Votre humeur a beaucoup fluctué récemment"
}
},
"derived": {
"coverage": {
"summary": "{present} entrées sur {required} · {percent}%",
"ratioLabel": "{present}/{required}",
"historyDays": "Basé sur {count} jours d'historique",
"missing": "Enregistre ceci pour affiner la valeur : {list}"
},
"scoreRing": {
"aria": "Score {score} sur 100, {band}",
"ariaProvisional": "Score pas encore disponible — données insuffisantes",
"provisionalCaption": "Données encore insuffisantes",
"band": {
"green": "bon",
"yellow": "moyen",
"red": "faible"
}
},
"provenance": {
"title": "Comment c'est calculé",
"trigger": "Comment c'est calculé",
"inputsLabel": "Entrées",
"noInputs": "Aucune entrée ne soutient encore cette valeur.",
"methodLabel": "Méthode",
"window": "Sur tes {days} derniers jours · données {source}",
"asOf": "au {date}",
"standardLink": "Norme : {name}"
},
"vitals": {
"sectionTitle": "Vos constantes",
"loadingLabel": "Chargement de vos constantes",
"loadError": "Impossible de charger vos constantes pour le moment.",
"building": "Construction de votre plage habituelle — {count} sur {target} jours",
"typicalRange": "Habituel pour vous : {low}–{high}",
"fitnessYounger": "Forme cardio d'une personne ~{years} ans plus jeune",
"fitnessOlder": "Forme cardio ~{years} ans au-dessus de votre âge",
"fitnessTypical": "Forme cardio typique pour votre âge",
"fitnessNoNorm": "Ajoutez votre âge et votre sexe pour situer cela",
"vascularBelow": "{years} ans en dessous de votre âge",
"vascularAbove": "{years} ans au-dessus de votre âge",
"vascularMatch": "En phase avec votre âge",
"vascularNoAge": "Ajoutez votre date de naissance pour situer cela",
"hrvBand": {
"balanced": "Équilibré pour vous",
"unbalanced": "Au-dessus de votre plage habituelle",
"low": "En dessous de votre plage habituelle"
},
"bmiCategory": {
"underweight": "Insuffisance pondérale (OMS)",
"normal": "Plage normale (OMS)",
"overweight": "Surpoids (OMS)",
"obese": "Obésité (OMS)"
}
},
"anatomy": {
"outOf": "/100",
"contributorsLabel": "Facteurs",
"contributorValue": "{value} · {weight}% du poids",
"contributorMissing": "aucune donnée",
"disclaimer": "Indicatif — pas une évaluation clinique.",
"insufficient": "Pas encore assez de données pour l’évaluer. Continuez à enregistrer et cela apparaîtra ici."
},
"composite": {
"SLEEP_SCORE": {
"title": "Score de sommeil",
"description": "Un score de qualité du sommeil transparent avec chaque sous-score affiché.",
"method": "Un mélange de 0–100 entre suffisance (sommeil vs votre besoin selon l’âge), efficacité (sommeil ÷ temps au lit), régularité (stabilité de votre milieu de sommeil), timing (vs votre milieu habituel) et composition (part de REM + sommeil profond). Les sous-scores sont repondérés autour de ceux que vos données ne peuvent pas étayer ; rien n’est inventé.",
"sub": {
"sufficiency": "Suffisance",
"efficiency": "Efficacité",
"consistency": "Régularité",
"timing": "Timing",
"composition": "Composition"
}
},
"READINESS": {
"title": "Disponibilité",
"description": "Un indicateur quotidien de bien-être selon la position de vos signaux par rapport à votre propre référence.",
"method": "Un mélange de 0–100 entre l’écart de la fréquence cardiaque au repos, l’écart de la VFC (SDNN), la qualité du sommeil, la stabilité de la fréquence respiratoire et la stabilité de l’humeur — chacun mesuré par rapport à votre référence personnelle. Les poids sont renormalisés sur les composantes que vos données étayent. Un indicateur de bien-être, pas un score de récupération ni clinique.",
"component": {
"rhr": "Fréquence cardiaque au repos",
"hrv": "VFC (SDNN)",
"sleep": "Sommeil",
"respiratory": "Fréquence respiratoire",
"mood": "Stabilité de l’humeur"
},
"caveat": "Un indicateur de bien-être, pas un score de récupération ni clinique."
},
"VITALS_BASELINE": {
"method": "Votre plage typique personnelle est la médiane de vos valeurs quotidiennes sur la période, ± une dispersion robuste (écart absolu médian, mis à l’échelle). Elle suit VOS mesures, pas une norme de population : une valeur dans la bande est donc typique pour vous."
},
"HRV_BALANCE": {
"method": "Votre moyenne récente de VFC (SDNN) comparée à votre propre référence glissante (médiane ± dispersion robuste). Équilibrée signifie qu’elle se situe dans votre plage personnelle ; la bande est construite à partir de vos données, jamais d’un seuil de population."
},
"BMI": {
"method": "Poids ÷ taille², classé selon les catégories adultes de l’OMS (maigreur / normal / surpoids / obésité). Un simple ratio de dépistage : il ne distingue pas le muscle de la graisse."
},
"FITNESS_AGE": {
"method": "Votre VO₂max situé par rapport aux percentiles de condition cardiorespiratoire selon l’âge et le sexe, exprimé comme un écart d’âge de forme. Le VO₂max est l’estimation de votre appareil, jamais recalculée ici."
},
"VASCULAR_AGE_DELTA": {
"method": "L’estimation d’âge vasculaire / vitesse de l’onde de pouls de votre appareil comparée à votre âge chronologique, exprimée comme un écart en années. L’estimation vient de l’appareil ; HealthLog ne fait que la reformuler.",
"caveat": "La propre estimation de votre appareil, simplement reformulée : descriptive, pas une évaluation vasculaire clinique.",
"description": "Comment l’estimation d’âge vasculaire de votre appareil se situe par rapport à votre âge chronologique."
},
"COINCIDENT_DEVIATION": {
"method": "Un indicateur qui se déclenche lorsque deux de vos constantes ou plus sortent de leur plage typique personnelle le même jour. Chaque bande est la médiane robuste ± k·MAD de votre propre historique : elle s’adapte à vous et non à un seuil de population.",
"caveat": "Un indicateur issu de vos propres références : descriptif, ni un diagnostic ni une alerte clinique."
},
"RECOVERY_SCORE": {
"method": "Un indice de récupération nocturne de 0 à 100 combinant votre VFC, votre fréquence cardiaque au repos et votre sommeil par rapport à votre référence. Plus élevé signifie mieux récupéré. Calculé une fois par nuit et seulement relu ici, jamais recalculé à la volée.",
"caveat": "Un indicateur de bien-être descriptif, pas une mesure de récupération clinique ou de niveau d’entraînement.",
"description": "Un indice de récupération nocturne à partir de votre VFC, fréquence au repos et sommeil."
},
"STRESS_SCORE": {
"method": "Un indice de stress nocturne de 0 à 100 à partir de la forme de votre VFC au cours de la journée par rapport à votre référence. Plus élevé signifie une charge physiologique plus forte.",
"caveat": "Un indicateur dérivé de la VFC, PAS une mesure de stress par EDA / activité électrodermale : votre montre n’a pas de capteur EDA. Descriptif, non clinique.",
"description": "Un indice de stress nocturne à partir de la forme de votre VFC sur la journée."
},
"STRAIN_SCORE": {
"method": "Un indice d’effort nocturne de 0 à 100 à partir de votre charge d’activité pondérée par la fréquence cardiaque (un modèle d’impulsion d’entraînement de type TRIMP). Plus élevé signifie une journée plus exigeante.",
"caveat": "Un indicateur descriptif de charge d’activité, pas une mesure de charge clinique ou de niveau d’entraînement.",
"description": "Un indice de charge d’activité nocturne à partir de votre effort pondéré par la fréquence cardiaque."
}
},
"scores": {
"sectionTitle": "Scores de bien-être",
"recovery": "Récupération",
"stress": "Stress",
"strain": "Effort"
},
"coincident": {
"label": "Plusieurs constantes hors référence",
"summary": "Aujourd’hui, {count} de vos constantes sont sorties de leur plage personnelle.",
"vitals": "Hors plage : {list}"
}
},
"cardioFitness": {
"title": "Condition physique",
"description": "Votre VO₂ max au fil du temps — la principale mesure de la forme aérobie.",
"chartTitle": "VO₂ max (mL/(kg·min))",
"emptyState": {
"title": "Pas encore de données de condition physique",
"description": "Les valeurs de VO₂ max apparaissent ici dès qu'Apple Santé les synchronise depuis vos entraînements."
}
},
"navCardioFitness": "Condition physique",
"cardioRecovery": {
"title": "Récupération cardiaque",
"description": "De combien votre fréquence cardiaque baisse une minute après l'effort maximal.",
"chartTitle": "Récupération cardiaque (bpm)",
"emptyState": {
"title": "Pas encore de données de récupération cardiaque",
"description": "Les valeurs de récupération cardiaque apparaissent ici dès qu'Apple Santé les synchronise depuis un entraînement."
}
},
"navCardioRecovery": "Récupération cardiaque",
"wristTemperature": {
"title": "Température du poignet",
"description": "Votre température du poignet pendant la nuit, comparée à votre valeur de référence.",
"chartTitle": "Température du poignet (°C)",
"emptyState": {
"title": "Pas encore de données de température du poignet",
"description": "Les valeurs apparaissent ici dès qu'Apple Santé synchronise le port nocturne."
}
},
"navWristTemperature": "Temp. poignet",
"falls": {
"title": "Chutes",
"description": "Chutes lourdes détectées par votre Apple Watch, comptées par jour.",
"chartTitle": "Chutes par jour",
"emptyState": {
"title": "Pas encore de données de chutes",
"description": "Les chutes détectées apparaissent ici dès qu'Apple Santé se synchronise. L'objectif est zéro chute."
}
},
"navFalls": "Chutes",
"sixMinuteWalk": {
"title": "Marche de 6 minutes",
"description": "L'estimation d'Apple de la distance que vous pourriez parcourir en six minutes.",
"chartTitle": "Distance sur 6 minutes (m)",
"emptyState": {
"title": "Pas encore de données de marche de 6 minutes",
"description": "Les estimations apparaissent ici dès qu'Apple Santé se synchronise."
}
},
"navSixMinuteWalk": "Marche 6 min",
"stairAscentSpeed": {
"title": "Vitesse en montée d'escaliers",
"description": "À quelle vitesse vous montez les escaliers, mesurée par votre iPhone ou Watch.",
"chartTitle": "Vitesse de montée (m/s)",
"emptyState": {
"title": "Pas encore de données de montée d'escaliers",
"description": "Les valeurs apparaissent ici dès qu'Apple Santé se synchronise."
}
},
"navStairAscentSpeed": "Montée escaliers",
"stairDescentSpeed": {
"title": "Vitesse en descente d'escaliers",
"description": "À quelle vitesse vous descendez les escaliers, mesurée par votre iPhone ou Watch.",
"chartTitle": "Vitesse de descente (m/s)",
"emptyState": {
"title": "Pas encore de données de descente d'escaliers",
"description": "Les valeurs apparaissent ici dès qu'Apple Santé se synchronise."
}
},
"navStairDescentSpeed": "Descente escaliers",
"breathingDisturbances": {
"title": "Troubles respiratoires",
"description": "À quelle fréquence votre respiration a été perturbée pendant le sommeil.",
"chartTitle": "Troubles respiratoires du sommeil",
"emptyState": {
"title": "Pas encore de données de troubles respiratoires",
"description": "Les valeurs apparaissent ici dès qu'Apple Santé les synchronise depuis une Apple Watch compatible."
}
},
"navBreathingDisturbances": "Respiration",
"rhythmEvents": {
"sectionTitle": "Notifications de santé de l’appareil",
"sectionIntro": "Notifications que votre montre a envoyées d’elle-même. HealthLog se contente de les afficher — il ne les détecte, ne les confirme ni ne les interprète.",
"loadError": "Impossible de charger les notifications de votre appareil.",
"disclaimer": "Ce sont des alertes que votre appareil a produites avec sa propre détection intégrée. Elles sont affichées ici uniquement à titre informatif. Il ne s’agit pas d’une évaluation médicale de HealthLog, et HealthLog ne pose aucun diagnostic. Si une notification vous inquiète, parlez-en à votre médecin.",
"event": {
"irregularRhythm": "Irregular rhythm notification",
"highHeartRate": "High heart rate notification",
"lowHeartRate": "Low heart rate notification",
"walkingSteadiness": "Walking steadiness alert",
"breathingDisturbance": "Breathing disturbance during sleep"
},
"verdict": {
"irregular": "Your device flagged a possible irregular rhythm.",
"notDetected": "Your device did not flag an irregular rhythm.",
"inconclusive": "Your device could not make a reading.",
"low": "Your device flagged low walking steadiness.",
"veryLow": "Your device flagged very low walking steadiness.",
"fired": "Your device flagged this event."
}
}
},
"thresholds": {
"defaultLabel": "Par défaut",
@@ -2393,7 +2947,7 @@
"description": "Withings, moodLog, and other connected services."
},
"notifications": {
"title": "Canaux de notification",
"title": "Notifications",
"description": "Live overview of every configured channel."
},
"dashboard": {
@@ -2401,63 +2955,62 @@
"description": "Tile layout and order."
},
"thresholds": {
"title": "Objectifs et sources",
"description": "Par métrique : votre propre plage cible et quelle source prévaut quand plusieurs enregistrent la même valeur le même jour."
"title": "Objectifs",
"description": "Par métrique : votre propre plage cible pour des valeurs saines."
},
"sources": {
"title": "Sources",
"description": "Pick which ingest source wins when more than one records the same metric for the same day.",
"redirectNotice": "Les sources font désormais partie de « Objectifs et sources ».",
"cardTitle": "Source priority by metric",
"help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.",
"resetDefaults": "Reset to defaults",
"moveUp": "Move up",
"moveDown": "Move down",
"saveSuccess": "Source priority saved.",
"saveError": "Saving the source priority failed.",
"resetSuccess": "Source priority reset to defaults.",
"perMetricToggle": "Per-metric overrides ({count})",
"perMetricHelp": "Metrics you have customised away from the default ladder. Use this list to roll any single metric back to its constant default without touching the rest.",
"perMetricEmpty": "No per-metric overrides set — every metric uses the global ladder above.",
"description": "Choisissez quelle source prévaut quand plusieurs enregistrent la même métrique le même jour.",
"cardTitle": "Priorité des sources par métrique",
"help": "Lorsque plusieurs sources fournissent la même valeur le même jour (p. ex. Withings et Apple Health indiquent tous deux 1 200 pas), la source en tête compte comme la lecture canonique. Les autres sources restent enregistrées pour la piste d’audit.",
"resetDefaults": "Rétablir les valeurs par défaut",
"moveUp": "Monter",
"moveDown": "Descendre",
"saveSuccess": "Priorité des sources enregistrée.",
"saveError": "Échec de l’enregistrement de la priorité des sources.",
"resetSuccess": "Priorité des sources rétablie aux valeurs par défaut.",
"perMetricToggle": "Réglages par métrique ({count})",
"perMetricHelp": "Métriques que vous avez personnalisées par rapport à l’ordre par défaut. Utilisez cette liste pour rétablir une métrique donnée à sa valeur par défaut sans toucher aux autres.",
"perMetricEmpty": "Aucun réglage par métrique — chaque métrique utilise l’ordre global ci-dessus.",
"resetMetric": "Réinitialiser",
"deviceTypeToggle": "Device-type priority ({count})",
"deviceTypeHelp": "Within a source, which device wins. When an Apple Watch and an iPhone both record steps, the higher-ranked device-type counts as the canonical reading.",
"deviceTypeDefault": "Default device-type ladder",
"resetDeviceTypes": "Reset device-types",
"deviceTypeToggle": "Priorité par type d’appareil ({count})",
"deviceTypeHelp": "Au sein d’une source, quel appareil l’emporte. Lorsqu’une Apple Watch et un iPhone enregistrent tous deux les pas, le type d’appareil le mieux classé compte comme la lecture canonique.",
"deviceTypeDefault": "Ordre par défaut des types d’appareil",
"resetDeviceTypes": "Réinitialiser les types d’appareil",
"metrics": {
"steps": "Pas",
"activeEnergy": "Active energy",
"walkingRunningDistance": "Walking + running distance",
"flightsClimbed": "Flights climbed",
"activeEnergy": "Énergie active",
"walkingRunningDistance": "Distance marche + course",
"flightsClimbed": "Étages gravis",
"sleep": "Sommeil",
"weight": "Poids",
"bloodPressure": "Tension artérielle",
"pulse": "Pouls",
"bodyFat": "Masse grasse",
"bodyTemperature": "Body temperature",
"spo2": "Oxygen saturation",
"hrv": "Heart-rate variability",
"bodyTemperature": "Température corporelle",
"spo2": "Saturation en oxygène",
"hrv": "Variabilité de la fréquence cardiaque",
"restingHeartRate": "Fréquence cardiaque au repos",
"vo2Max": "VO2 max"
},
"sourceLabels": {
"WITHINGS": "Withings",
"APPLE_HEALTH": "Apple Health",
"MANUAL": "Manual entry",
"MANUAL": "Saisie manuelle",
"IMPORT": "Importer"
},
"deviceLabels": {
"watch": "Watch",
"band": "Band",
"ring": "Ring",
"phone": "Phone",
"scale": "Scale",
"other": "Other",
"watch": "Montre",
"band": "Bracelet",
"ring": "Bague",
"phone": "Téléphone",
"scale": "Balance",
"other": "Autre",
"unknown": "Inconnu"
}
},
"ai": {
"title": "Insights",
"title": "AI Insights",
"description": "Provider, model, key."
},
"api": {
@@ -2706,6 +3259,11 @@
"testProvider": "Test active provider",
"testSuccess": "OK — {provider} ({model})",
"testFailedShort": "Test failed: {message}",
"testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.",
"testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.",
"testReasonRateLimited": "Provider rate-limited the request — try again shortly.",
"testReasonServerError": "The AI provider returned a server error.",
"testReasonUnreachable": "Could not reach the AI provider.",
"disableCoach": {
"title": "Masquer le Coach",
"description": "Masque le bouton et le panneau du Coach sur toutes les pages.",
@@ -2939,7 +3497,9 @@
"fhirNote": "La sortie structurée suit la norme FHIR R4 (compatible ePA).",
"generate": "Générer",
"error": "Échec de l’export ({code})."
}
},
"globalExcludedInjectionSitesLabel": "Sites d'injection exclus globalement",
"globalExcludedInjectionSitesHint": "Les sites listés ici ne sont proposés pour aucun médicament et sont refusés lors de l'enregistrement d'une dose, même si un médicament les privilégie."
},
"admin": {
"title": "Administration",
@@ -3760,8 +4320,6 @@
}
},
"targets": {
"title": "Valeurs cibles",
"introText": "Tes valeurs actuelles face aux plages cibles recommandées. Les sources des recommandations sont liées sur chaque carte.",
"bmi": "IMC",
"average30d": "Moy. 30 jours :",
"consistency": {
@@ -3783,23 +4341,11 @@
"sixDaysAgo": "il y a 6 jours"
}
},
"card": {
"lastMet": "Dernier objectif atteint : {when}",
"streak": "{count} jours d’affilée"
},
"relativeDay": {
"today": "Aujourd'hui",
"yesterday": "Hier",
"daysAgo": "il y a {count} jours"
},
"summary": {
"weekTitle": "{met} cibles sur {total} atteintes cette semaine",
"weekTitleAll": "Tout{total} targets met this week",
"streakHighlight": "{count} jours d’affilée : {metric}"
},
"coach": {
"cta": "Demander au coach"
},
"edit": {
"openLabel": "Modifier la plage cible de {metric}",
"title": "Modifier la plage cible — {metric}",
@@ -3831,31 +4377,10 @@
"aboveTarget": "{delta} {unit} au-dessus de la plage",
"belowTarget": "{delta} {unit} en dessous de la plage",
"diastolic": "Diastolique",
"noMeasurementYet": "Pas encore de mesures",
"noMeasurementData": "Pas de données de mesure. Ajoute des mesures pour voir tes objectifs.",
"profileIncomplete": "Profil incomplet",
"profileIncompleteHeight": "Renseigne ta taille dans le profil pour activer les objectifs IMC.",
"profileIncompleteAge": "Renseigne ta date de naissance dans le profil pour activer les objectifs liés à l’âge.",
"profileIncompleteHeightAge": "Renseigne taille et date de naissance dans le profil pour voir des objectifs personnalisés.",
"compliancePerMedication": "Observance par médicament",
"glucoseFasting": "Glycémie — à jeun",
"glucosePostprandial": "Glycémie — postprandiale",
"glucoseRandom": "Glycémie — indifférente",
"glucoseBedtime": "Glycémie — avant le coucher",
"loadError": "Impossible de charger les valeurs cibles",
"label": {
"WEIGHT": "Poids",
"BLOOD_PRESSURE": "Tension artérielle",
"BLOOD_PRESSURE_IN_TARGET": "Tension dans la cible",
"PULSE": "Pouls au repos",
"SLEEP_DURATION": "Durée du sommeil",
"BMI": "IMC",
"BODY_FAT": "Masse grasse",
"ACTIVITY_STEPS": "Pas/jour",
"MEDICATION_COMPLIANCE": "Observance",
"MOOD_SCORE": "Humeur",
"MOOD_STABILITY": "Stabilité de l’humeur"
},
"status": {
"underweight": "Insuffisance pondérale",
"normal": "Normal",
+639 -114
View File
@@ -158,7 +158,12 @@
"chartsTitle": "Grafici di tendenza",
"aiSummaryTitle": "Riepilogo IA — non validato clinicamente",
"aiSummaryDisclaimer": "Questa sezione è stata generata automaticamente da un modello di IA e non è validata clinicamente. Non sostituisce una valutazione medica.",
"packageReadme": "Cartella clinica HealthLog\n\nQuesto pacchetto contiene:\n- report.pdf: il referto sanitario per il medico.\n- bundle.json: gli stessi dati come Bundle documento HL7 FHIR R4, compatibile ePA.\n\nGenerato con HealthLog."
"packageReadme": "Cartella clinica HealthLog\n\nQuesto pacchetto contiene:\n- report.pdf: il referto sanitario per il medico.\n- bundle.json: gli stessi dati come Bundle documento HL7 FHIR R4, compatibile ePA.\n\nGenerato con HealthLog.",
"wellnessTitle": "Riepilogo del benessere",
"wellnessDisclaimer": "Sono punteggi di benessere descrittivi calcolati dai tuoi segnali registrati (0–100). Non sono valutazioni cliniche né diagnosi.",
"wellnessRecovery": "Recupero",
"wellnessStress": "Stress",
"wellnessStrain": "Sforzo"
},
"errorBoundary": {
"title": "Qualcosa è andato storto",
@@ -425,7 +430,22 @@
"dailyTotalCaption": "({count} campioni)",
"expandDay": "Mostra i campioni di questa giornata",
"collapseDay": "Nascondi i campioni di questa giornata",
"loadError": "Impossibile caricare le misurazioni."
"loadError": "Impossibile caricare le misurazioni.",
"typeCardioRecovery": "Recupero cardiaco",
"typeWristTemperature": "Temperatura del polso",
"typeFallCount": "Cadute",
"typeSixMinuteWalkDistance": "Distanza nel cammino di 6 minuti",
"typeStairAscentSpeed": "Velocità in salita scale",
"typeStairDescentSpeed": "Velocità in discesa scale",
"typeBreathingDisturbances": "Disturbi respiratori nel sonno",
"typeIrregularRhythmNotification": "Irregular rhythm notification",
"typeHighHeartRateEvent": "High heart rate notification",
"typeLowHeartRateEvent": "Low heart rate notification",
"typeWalkingSteadinessEvent": "Walking steadiness alert",
"typeBreathingDisturbanceEvent": "Breathing disturbance",
"typeRecoveryScore": "Recovery score",
"typeStressScore": "Stress score",
"typeStrainScore": "Strain score"
},
"mood": {
"title": "Umore",
@@ -461,7 +481,49 @@
"deleteConfirmTitle": "Eliminare la voce?",
"deleteConfirmDescription": "Questa voce dell’umore sarà eliminata in modo definitivo.",
"pageInfo": "Pagina {page} di {total}",
"loginRequired": "Accedi per vedere le voci dell’umore."
"loginRequired": "Accedi per vedere le voci dell’umore.",
"note": "Nota",
"notePlaceholder": "Aggiungi una nota su come ti senti…",
"tagPicker": "Etichette",
"tagCategory": {
"feelings": "Emozioni",
"sleep": "Sonno",
"health": "Salute",
"social": "Sociale",
"work": "Lavoro"
},
"tag": {
"happy": "felice",
"excited": "entusiasta",
"grateful": "grato",
"relaxed": "rilassato",
"content": "soddisfatto",
"tired": "stanco",
"unsure": "incerto",
"bored": "annoiato",
"tense": "teso",
"angry": "arrabbiato",
"stressed": "stressato",
"sad": "triste",
"sleptWell": "dormito bene",
"sleptOk": "dormito così così",
"sleptPoorly": "dormito male",
"earlyNight": "a letto presto",
"workedOut": "allenato",
"ateWell": "mangiato bene",
"hydrated": "bevuto acqua",
"walked": "camminato",
"alcohol": "alcol",
"family": "famiglia",
"friends": "amici",
"party": "festa",
"alone": "da solo",
"productive": "produttivo",
"overtime": "straordinari",
"dayOff": "giorno libero",
"travel": "viaggio",
"sickDay": "giorno di malattia"
}
},
"medications": {
"title": "Farmaci",
@@ -544,11 +606,14 @@
"veryOverdue": "Molto in ritardo",
"compliance7d": "Aderenza 7 giorni",
"compliance30d": "Aderenza 30 giorni",
"complianceWindow": "Aderenza {days} giorni",
"lastIntake": "Ultima assunzione:",
"nextIntake": "Prossima assunzione:",
"dayStreak": "giorni di serie",
"taken": "Assunta",
"skipped": "Saltata",
"intakeToastTaken": "{name} registrata come assunta",
"intakeToastSkipped": "{name} saltata",
"weekdaySunday": "Domenica",
"weekdayMonday": "Lunedì",
"weekdayTuesday": "Martedì",
@@ -759,7 +824,11 @@
"back": "Back",
"next": "Next",
"save": "Save",
"saveEdit": "Save changes"
"saveEdit": "Save changes",
"nextHint": "Avanti: {step}",
"reviewHint": "Avanti: Salva",
"jumpFirst": "Vai al primo passaggio",
"jumpLast": "Vai a revisione e salvataggio"
},
"nl": {
"button": "Describe"
@@ -769,11 +838,13 @@
"title": "What's the medication called?",
"subline": "Exactly as it appears on the box.",
"label": "Name",
"placeholder": "e.g. Ramipril"
"placeholder": "e.g. Ramipril",
"short": "Nome"
},
"step2": {
"title": "What kind of medication is it?",
"subline": "We use this for the right templates and analytics."
"subline": "We use this for the right templates and analytics.",
"short": "Tipo"
},
"step3": {
"title": "What's the dose?",
@@ -805,15 +876,18 @@
"dosesPerUnitPlaceholder": "es. 4",
"dosesPerUnitHint": "Dosi per penna / fiala — lascia vuoto per non tracciare le scorte.",
"injectionSiteToggle": "Rotazione del sito di iniezione",
"injectionSiteHint": "Guida alla rotazione facoltativa. Il sito di ogni dose viene registrato al momento della registrazione dell'assunzione."
"injectionSiteHint": "Guida alla rotazione facoltativa. Il sito di ogni dose viene registrato al momento della registrazione dell'assunzione.",
"short": "Dose"
},
"step4": {
"title": "Over what period are you taking it?",
"subline": "A start date is enough for today. You can add an end date later."
"subline": "A start date is enough for today. You can add an end date later.",
"short": "Quando"
},
"step5": {
"title": "How often do you take it?",
"subline": "Pick the cadence — the details follow on the next step."
"subline": "Pick the cadence — the details follow on the next step.",
"short": "Frequenza"
},
"step6": {
"title": "What does that look like in detail?",
@@ -829,7 +903,8 @@
"rollingDays": {
"label": "Every",
"suffix": "days from the last intake"
}
},
"short": "Dettaglio"
},
"step7": {
"title": "When do you take it?",
@@ -840,14 +915,16 @@
"noon": "Noon",
"evening": "Evening",
"night": "Night"
}
},
"short": "Orari"
},
"step8": {
"title": "All done — should HealthLog remind you?",
"subline": "Review everything below before you save.",
"remindersLabel": "Reminders on",
"remindersDescription": "Push, Telegram or ntfy — based on your settings.",
"multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release."
"multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release.",
"short": "Fatto"
}
},
"classRow": {
@@ -923,26 +1000,17 @@
"submitFailed": "Could not save the medication — please retry."
}
},
"titration": {
"section": "Scala di titolazione",
"loading": "Caricamento dati di titolazione",
"loadFailed": "Impossibile caricare i dati di titolazione.",
"drugLadderHeader": "Scala standard (riferimento EMA)",
"typicalWeeksOnStep": "≥ {weeks} sett. tipico",
"youAreHere": "Sei qui",
"nextStepCaption": "Prossimo gradino (tipico): {dose} mg dopo ~{weeks} settimane al gradino attuale.",
"ceilingMessage": "Sei al gradino più alto della scala standard.",
"escalationDueHint": "Sei su questo gradino da {weeks} settimane; lo schema prevede un avanzamento tipico dopo ~{typical} settimane.",
"currentStepDwell": "Su questo gradino da {weeks} settimane",
"doseUnitMg": "mg",
"emptyState": "Nessuna cronologia delle dosi. Registra una dose per vedere la tua posizione sulla scala.",
"nonStandardDose": "La dose attuale è fuori dai gradini standard.",
"disclaimer": "Valori di riferimento EMA. Consulta il tuo medico prima di cambiare la dose.",
"emaSourceCta": "Fonte EMA",
"stepLabel": "Gradino {n}"
"doseStrength": {
"title": "Dose nel tempo",
"axisLabel": "Dose registrata ({unit})",
"tooltipLabel": "Dose",
"currentDose": "Dose attuale: {dose} {unit}",
"noCurrentDose": "Nessuna dose ancora registrata.",
"empty": "Nessuna cronologia di titolazione. Registra un cambio di dose per vedere la curva della dose."
},
"injectionSiteBodyOutlineAriaLabel": "Sagoma del corpo",
"injectionSitePickerAriaLabel": "Selettore del sito di iniezione",
"injectionSiteRecommendedAriaLabel": "{site} — prossimo sito consigliato",
"siteAbdomenLeft": "Addome, basso a sinistra",
"siteAbdomenRight": "Addome, basso a destra",
"siteAbdomenUpperLeft": "Addome, alto a sinistra",
@@ -1005,9 +1073,10 @@
"phaseSaved": "Fasi di promemoria salvate",
"phaseReset": "Ripristinate ai valori predefiniti",
"sourceWeb": "Web",
"sourceApi": "API",
"sourceApi": "App iOS",
"sourceReminder": "Promemoria",
"sourceImport": "Importazione",
"intakeSourceVia": "tramite {origin}",
"intakeHistoryEmptyTitle": "Ancora nessuna assunzione",
"intakeHistoryEmptyDescription": "Appena registrerai un’assunzione, comparirà qui con orario e sorgente.",
"intakeHistoryEmptyAction": "Registra un’assunzione",
@@ -1078,11 +1147,6 @@
"emptyState": "Nessuna assunzione registrata.",
"emptyStateCta": "Registra una dose per vedere prendere forma la curva stimata.",
"estimateNote": "Stima educativa dalla farmacocinetica di popolazione pubblicata dall'EMA. Non è una misura.",
"gatedTitle": "Modalità Ricerca disattivata",
"gatedBody": "Attiva la modalità Ricerca nelle impostazioni per vedere la curva stimata del livello del farmaco. La curva è opt-in perché è uno strumento di apprendimento, non una misura clinica.",
"gatedStaleTitle": "Il disclaimer richiede una nuova conferma",
"gatedStaleBody": "Il disclaimer della modalità Ricerca è stato aggiornato dall'ultima tua conferma. Rileggilo nelle impostazioni e conferma di nuovo per rivedere la curva.",
"gatedCta": "Apri la modalità Ricerca nelle impostazioni",
"unknownDrug": "{name} non è nel catalogo GLP-1 approvato dall'EMA, quindi HealthLog non può tracciare una curva di farmacocinetica di popolazione."
}
},
@@ -1170,6 +1234,14 @@
"unit": "minutes",
"saved": "Reminder window saved",
"failed": "Could not save the reminder window."
},
"codes": {
"label": "Codici clinici (ATC / RxNorm)",
"note": "Facoltativo. Usati nell'esportazione FHIR della cartella sanitaria. I codici vengono presi esattamente come inseriti — mai indovinati.",
"atcLabel": "ATC",
"rxNormLabel": "RxNorm",
"saved": "Codici clinici salvati",
"failed": "Impossibile salvare i codici clinici."
}
},
"zone": {
@@ -1235,7 +1307,7 @@
"data": "Dati",
"reminders": "Promemoria",
"lifecycle": "Ciclo di vita",
"danger": "Zona pericolosa"
"externalApi": "API esterna"
},
"dataPortability": {
"import": {
@@ -1258,10 +1330,18 @@
"history": {
"back": "Torna al farmaco",
"subtitle": "Cronologia completa delle assunzioni",
"plannedSuffix": "pianificato",
"drugLevelDisclosure": "Mostra la curva stimata del principio attivo"
"plannedSuffix": "pianificato"
}
}
},
"logInjectionSiteTitle": "Registra il sito di iniezione",
"logInjectionSiteDescription": "Dove hai iniettato {name}? Facoltativo — puoi saltarlo.",
"logInjectionSiteSkip": "Salta",
"logInjectionSiteConfirm": "Salva il sito",
"logInjectionSiteNoneAvailable": "Nessun sito di iniezione disponibile: tutti i siti sono esclusi nelle tue impostazioni.",
"trackInjectionSitesToggle": "Registra i siti di iniezione",
"trackInjectionSitesHint": "Dopo l'assunzione di una dose, chiedi quale sito è stato usato e suggerisci la rotazione successiva.",
"allowedSitesLabel": "Siti consentiti",
"allowedSitesHint": "Lascia tutti selezionati per nessuna restrizione. I siti esclusi globalmente non vengono mai proposti."
},
"charts": {
"loadingLabel": "Caricamento del grafico…",
@@ -1272,6 +1352,13 @@
"bodyFat": "Massa grassa",
"sleep": "Sonno",
"steps": "Passi",
"hrv": "HRV",
"restingHeartRate": "FC a riposo",
"activeEnergy": "Energia attiva",
"flights": "Piani saliti",
"distance": "Distanza camminata + corsa",
"vo2Max": "VO₂ max",
"bodyTemperature": "Temperatura corporea",
"noData": "Nessun dato nel periodo selezionato",
"bucketWeekly": "Media settimanale",
"bucketMonthly": "Media mensile",
@@ -1336,7 +1423,17 @@
"personalBaseline": "La tua media",
"deltaVsBaseline": "{delta} vs. la tua media",
"deltaUnchanged": "corrisponde alla tua media",
"deltaVsTarget": "{delta} vs. obiettivo"
"deltaVsTarget": "{delta} vs. obiettivo",
"weekdaysFull": {
"mon": "Lunedì",
"tue": "Martedì",
"wed": "Mercoledì",
"thu": "Giovedì",
"fri": "Venerdì",
"sat": "Sabato",
"sun": "Domenica"
},
"rangeBand": "Intervallo"
},
"chart": {
"overlay": {
@@ -1376,6 +1473,7 @@
},
"insights": {
"noAnalysisYet": "Ancora nessuna analisi.",
"assessmentPreparing": "Preparazione della valutazione…",
"emptyTitle": "Dati ancora insufficienti",
"emptyDescription": "Gli insights compaiono appena registri qualche misurazione. Inizia da peso o pressione.",
"emptyAddMeasurement": "Registra misurazione",
@@ -1432,6 +1530,8 @@
"heroRegenerating": "Generazione in corso",
"regenerateAnalysis": "Riavvia l’analisi",
"regenerateSuccess": "Analisi rigenerata",
"warmAssessments": "Prepara le valutazioni",
"warmStarted": "Le valutazioni vengono preparate in background",
"heroFallbackSubtitle": "Uno sguardo quotidiano ai tuoi trend — direttamente dai numeri che registri.",
"heroGreetingMorning": "Buongiorno",
"heroGreetingAfternoon": "Buon pomeriggio",
@@ -1498,7 +1598,22 @@
},
"trendsRow": {
"title": "Tendenze",
"subtitle": "Gli ultimi 30 giorni a colpo d’occhio"
"subtitle": "Gli ultimi 30 giorni a colpo d’occhio",
"caption": {
"bp": "Valori sistolici e diastolici degli ultimi 30 giorni.",
"weight": "Andamento del peso corporeo degli ultimi 30 giorni.",
"pulse": "Battito a riposo degli ultimi 30 giorni.",
"mood": "Punteggio giornaliero dell'umore degli ultimi 30 giorni.",
"sleep": "Durata del sonno notturno degli ultimi 30 giorni.",
"steps": "Conteggio giornaliero dei passi degli ultimi 30 giorni.",
"hrv": "Variabilità della frequenza cardiaca degli ultimi 30 giorni.",
"resting_hr": "Frequenza cardiaca a riposo degli ultimi 30 giorni.",
"active_energy": "Energia attiva bruciata negli ultimi 30 giorni.",
"flights": "Piani saliti negli ultimi 30 giorni.",
"distance": "Distanza a piedi e di corsa degli ultimi 30 giorni.",
"vo2_max": "VO₂ max degli ultimi 30 giorni.",
"body_temp": "Temperatura corporea degli ultimi 30 giorni."
}
},
"vo2Max": {
"statLatest": "Attuale",
@@ -1515,6 +1630,23 @@
"confidenceLow": "Confidenza bassa",
"pendingLabel": "Commento di trend in generazione"
},
"range": {
"label": "Periodo",
"noPriorData": "nessun dato nel periodo precedente",
"option": {
"7d": "7g",
"30d": "30g",
"90d": "90g",
"1y": "1a"
},
"priorLabel": {
"7d": "vs 7g precedenti",
"30d": "vs 30g precedenti",
"90d": "vs 90g precedenti",
"1y": "vs anno precedente"
},
"noChange": "nessuna variazione"
},
"correlationRow": {
"title": "Correlazioni",
"subtitle": "Ipotesi predefinite, vincolate a n ≥ 14 e p < 0,05",
@@ -1800,7 +1932,124 @@
"medikamenteEmptyDescription": "Gli insights di aderenza richiedono almeno un farmaco registrato.",
"medikamenteEmptyAction": "Apri farmaci",
"therapyTimelineDescription": "Protocollo cronologico della terapia GLP-1 — cambi di dose, iniezioni, scorte, effetti collaterali.",
"bmiDescription": "BMI derivato dal tuo peso e dalla tua altezza, con valutazione."
"bmiDescription": "BMI derivato dal tuo peso e dalla tua altezza, con valutazione.",
"target": {
"range": "Obiettivo: {min}–{max} {unit}",
"bpRange": "Obiettivo: {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}",
"inTargetShare": "{pct}% dei giorni registrati nell'obiettivo",
"adjustLink": "Modifica l'intervallo obiettivo"
},
"explainer": {
"trigger": "Che cos'è {title}?",
"bloodPressureTitle": "Pressione arteriosa",
"bloodPressureBody": "La pressione arteriosa è la forza che il sangue esercita sulle pareti delle arterie, indicata come sistolica su diastolica (il picco durante un battito sul valore a riposo tra i battiti). Valori costantemente alti affaticano il cuore e i vasi nel tempo.",
"pulseTitle": "Polso",
"pulseBody": "Il polso indica quante volte il cuore batte al minuto. Una misurazione singola riflette ciò che stavi facendo in quel momento: a riposo è di solito tra 60 e 100 battiti al minuto e aumenta naturalmente con il movimento, lo stress o la caffeina.",
"weightTitle": "Peso",
"weightBody": "Il peso corporeo è uno dei segnali più semplici dell'equilibrio energetico a lungo termine e della salute generale. L'andamento nelle settimane conta molto più di una singola misurazione, che varia con l'idratazione, il cibo e l'ora del giorno.",
"bmiTitle": "Indice di massa corporea (IMC)",
"bmiBody": "L'IMC è il peso diviso per l'altezza al quadrato, un singolo numero che ti colloca in un'ampia categoria di peso. È uno screening di popolazione, non una diagnosi, e non distingue il muscolo dal grasso.",
"sleepTitle": "Sonno",
"sleepBody": "La durata del sonno è quanto hai effettivamente dormito nella notte, suddivisa in fasi leggera, profonda e REM. La maggior parte degli adulti necessita di circa sette-nove ore; quantità e qualità costanti favoriscono recupero, umore e metabolismo.",
"moodTitle": "Umore",
"moodBody": "Il monitoraggio dell'umore registra come ti senti nel tempo su una scala semplice. Annotarlo con regolarità rivela schemi — come sonno, attività o farmaci si allineano a come ti senti — che un singolo giorno non mostra mai.",
"medicationsTitle": "Aderenza alla terapia",
"medicationsBody": "L'aderenza misura quanto regolarmente assumi i farmaci come prescritto, qui come quota delle dosi previste che hai registrato. Un'assunzione costante è spesso ciò che fa funzionare una terapia come previsto.",
"restingHrTitle": "Frequenza cardiaca a riposo",
"restingHrBody": "La frequenza cardiaca a riposo è il battito quando sei completamente a riposo, di solito misurato durante la notte. Una frequenza più bassa indica in genere una migliore forma cardiovascolare, mentre un andamento in aumento può essere un segnale precoce di stress o malattia.",
"hrvTitle": "Variabilità della frequenza cardiaca (HRV)",
"hrvBody": "L'HRV è la piccola variazione del tempo tra battiti consecutivi. Una variabilità più alta riflette di solito un sistema nervoso ben recuperato e adattabile, mentre un calo prolungato può segnalare affaticamento, stress o recupero insufficiente.",
"oxygenSaturationTitle": "Saturazione di ossigeno (SpO₂)",
"oxygenSaturationBody": "La SpO₂ è la percentuale di globuli rossi che trasportano ossigeno. Valori tra 95 e 100% sono normali a riposo; valori che restano sotto il 90% meritano attenzione, anche se brevi cali notturni sono comuni.",
"bodyTemperatureTitle": "Temperatura corporea",
"bodyTemperatureBody": "La temperatura corporea riflette il tuo stato termico centrale, normalmente intorno ai 37 °C con piccole oscillazioni giornaliere. Un aumento prolungato può indicare un'infezione, mentre le misurazioni notturne al polso seguono soprattutto variazioni relative, non un valore clinico.",
"activeEnergyTitle": "Energia attiva",
"activeEnergyBody": "L'energia attiva sono le calorie che bruci con il movimento, oltre al consumo di base a riposo. È una misura diretta di quanto è stata fisicamente attiva la tua giornata.",
"workoutsTitle": "Allenamenti",
"workoutsBody": "Gli allenamenti sono le sessioni di esercizio strutturato che i tuoi dispositivi registrano, con tipo, durata ed energia. Tracciarli mostra come l'allenamento regolare si accumula nel corso delle settimane e come si collega agli altri segnali di salute.",
"respiratoryRateTitle": "Frequenza respiratoria",
"respiratoryRateBody": "La frequenza respiratoria è il numero di respiri che fai al minuto. A riposo la maggior parte degli adulti respira circa dodici-venti volte al minuto; un aumento prolungato può accompagnare malattia, sforzo o stress.",
"bodyWaterTitle": "Acqua corporea totale",
"bodyWaterBody": "L'acqua corporea totale è la quota del tuo peso costituita da liquido, dentro e intorno alle cellule. Varia con l'idratazione, l'alimentazione e l'attività, quindi l'andamento nelle settimane dice più di una singola misura.",
"boneMassTitle": "Massa ossea",
"boneMassBody": "La massa ossea è il peso stimato dei minerali del tuo scheletro. Le stime da bilancia sono approssimative, ma un andamento stabile o in aumento sostiene la robustezza ossea a lungo termine.",
"fatFreeMassTitle": "Massa magra",
"fatFreeMassBody": "La massa magra è tutto ciò che nel corpo non è grasso: muscoli, ossa, organi e acqua. Mantenerla o aumentarla mentre si perde peso indica di solito una composizione corporea più sana.",
"fatMassTitle": "Massa grassa",
"fatMassBody": "La massa grassa è il peso totale del grasso nel corpo. Un po' di grasso è essenziale per gli ormoni e le riserve di energia, ma un eccesso — soprattutto sull'addome — aumenta il rischio per la salute a lungo termine.",
"muscleMassTitle": "Massa muscolare",
"muscleMassBody": "La massa muscolare è il peso stimato dei muscoli del corpo. Più muscolo sostiene forza, metabolismo e mobilità, e di solito richiede impegno attivo per essere mantenuto con l'età.",
"visceralFatTitle": "Grasso viscerale",
"visceralFatBody": "Il grasso viscerale è il grasso depositato in profondità nell'addome, intorno agli organi. A differenza del grasso sottocutaneo, un livello elevato è strettamente legato a malattie cardiache e diabete di tipo 2, quindi più basso è in genere meglio.",
"leanBodyMassTitle": "Massa magra corporea",
"leanBodyMassBody": "La massa magra corporea è il peso totale meno il grasso: muscoli, ossa, organi e acqua insieme. Seguirla accanto al peso mostra se i cambiamenti derivano dal muscolo o dal grasso.",
"flightsClimbedTitle": "Piani saliti",
"flightsClimbedBody": "I piani saliti contano i livelli che sali ogni giorno, dove un piano corrisponde a circa tre metri di dislivello. È un indicatore semplice dell'attività quotidiana e dello sforzo degli arti inferiori.",
"walkingDistanceTitle": "Distanza a piedi e di corsa",
"walkingDistanceBody": "La distanza a piedi e di corsa è il percorso che copri a piedi ogni giorno. Integra il conteggio dei passi mostrando quanto ti sei mosso, non solo quanto spesso hai mosso il passo.",
"walkingSteadinessTitle": "Stabilità del cammino",
"walkingSteadinessBody": "La stabilità del cammino stima quanto è stabile ed equilibrata la tua andatura, classificata da buona a bassa. Un livello basso indica una maggiore probabilità di caduta ed è opportuno parlarne con un medico.",
"walkingHeartRateTitle": "Frequenza cardiaca in camminata",
"walkingHeartRateBody": "La frequenza cardiaca in camminata è il battito medio durante una camminata costante. Un valore più basso nel tempo riflette spesso una migliore forma cardiovascolare a parità di sforzo.",
"walkingAsymmetryTitle": "Asimmetria del cammino",
"walkingAsymmetryBody": "L'asimmetria del cammino è la quota di tempo in cui i tuoi passi sono diseguali tra gamba sinistra e destra. Una percentuale più alta può indicare dolore, un infortunio o un problema di andatura da approfondire.",
"doubleSupportTimeTitle": "Tempo di doppio appoggio",
"doubleSupportTimeBody": "Il tempo di doppio appoggio è la quota di ogni ciclo del passo in cui entrambi i piedi toccano terra contemporaneamente. Una percentuale più alta indica di solito un'andatura più prudente e meno stabile.",
"stepLengthTitle": "Lunghezza del passo",
"stepLengthBody": "La lunghezza del passo è la distanza media da un appoggio del piede al successivo. Un passo più corto nel tempo può riflettere stanchezza, prudenza o un cambiamento nella mobilità.",
"walkingSpeedTitle": "Velocità del cammino",
"walkingSpeedBody": "La velocità del cammino è quanto rapidamente ti muovi in piano. È un marcatore ben studiato della salute generale e della mobilità, e un ritmo stabile o in aumento è un buon segno.",
"pulseWaveVelocityTitle": "Velocità dell'onda di polso",
"pulseWaveVelocityBody": "La velocità dell'onda di polso misura la rapidità con cui l'onda di pressione di ogni battito percorre le arterie. Un'onda più veloce indica arterie più rigide, associate a un rischio cardiovascolare più alto.",
"vascularAgeTitle": "Età vascolare",
"vascularAgeBody": "L'età vascolare stima quanto si comportano vecchie le tue arterie rispetto alla tua età reale, in base alla rigidità arteriosa. Un'età vascolare inferiore a quella reale suggerisce vasi sanguigni più sani.",
"environmentalAudioTitle": "Livelli sonori ambientali",
"environmentalAudioBody": "I livelli sonori ambientali rilevano il volume dell'ambiente intorno a te in decibel. Un'esposizione prolungata sopra i 70 dB circa può danneggiare l'udito nel tempo, quindi medie giornaliere più basse sono più sicure.",
"headphoneAudioTitle": "Livelli audio in cuffia",
"headphoneAudioBody": "I livelli audio in cuffia rilevano in decibel quanto forte ascolti con auricolari o cuffie. Tenere basso il volume e fare pause protegge l'udito nel lungo periodo.",
"audioEventsTitle": "Eventi sonori intensi",
"audioEventsBody": "Gli eventi sonori intensi segnalano i momenti in cui il rumore intorno a te ha raggiunto un livello che può danneggiare l'udito con esposizione sufficiente. Eventi frequenti sono un invito a cercare un ambiente più silenzioso o protezioni acustiche.",
"daylightTitle": "Tempo alla luce del giorno",
"daylightBody": "Il tempo alla luce del giorno è quanto trascorri sotto la luce esterna ogni giorno. La luce diurna regolare sostiene un sano ritmo sonno-veglia e l'umore, e il tempo all'aperto è collegato alla salute degli occhi.",
"bloodGlucoseTitle": "Glicemia",
"bloodGlucoseBody": "La glicemia è la quantità di zucchero nel sangue, la principale fonte di energia del corpo. Sale dopo i pasti e si stabilizza tra un pasto e l'altro; valori che restano alti nel tempo possono indicare diabete o prediabete.",
"skinTemperatureTitle": "Temperatura cutanea",
"skinTemperatureBody": "La temperatura cutanea qui è la piccola variazione notturna della temperatura del polso rispetto al tuo valore di riferimento, non una misura assoluta della febbre. Le variazioni possono seguire sonno, ambiente, malattia o fase del ciclo mestruale.",
"cardioFitnessTitle": "Forma cardio (VO₂ max)",
"cardioFitnessBody": "Il VO₂ max indica quanto ossigeno il tuo corpo riesce a utilizzare al massimo sforzo. È il miglior marcatore di laboratorio della forma aerobica e un valore più alto o in aumento è un buon segno di salute cardiovascolare.",
"cardioRecoveryTitle": "Recupero cardiaco",
"cardioRecoveryBody": "Il recupero cardiaco è di quanti battiti al minuto cala la frequenza cardiaca nel primo minuto dopo aver interrotto uno sforzo intenso. Un calo maggiore significa che il cuore si calma più in fretta, segno di buona forma fisica.",
"wristTemperatureTitle": "Temperatura del polso",
"wristTemperatureBody": "La temperatura del polso viene misurata mentre dormi. Apple la mostra come differenza dal tuo valore di riferimento; un valore stabile è normale, mentre un aumento prolungato può indicare malattia, allenamento intenso o un cambiamento del ciclo.",
"fallsTitle": "Cadute",
"fallsBody": "Qui vengono contate le cadute forti rilevate dal tuo Apple Watch. L'obiettivo è zero; un numero in aumento è un segnale di rischio di mobilità da segnalare a un medico, soprattutto per le persone anziane.",
"sixMinuteWalkTitle": "Distanza nel cammino di 6 minuti",
"sixMinuteWalkBody": "È la stima di Apple di quanto potresti camminare in sei minuti, un classico test clinico di mobilità e resistenza cardiopolmonare. Una distanza stabile o in aumento è un buon segno.",
"stairAscentSpeedTitle": "Velocità in salita scale",
"stairAscentSpeedBody": "È la velocità con cui sali una rampa di scale. È un marcatore sensibile di forza delle gambe e mobilità: un ritmo stabile o in aumento è un buon segno, mentre uno in calo va tenuto d'occhio.",
"stairDescentSpeedTitle": "Velocità in discesa scale",
"stairDescentSpeedBody": "È la velocità con cui scendi una rampa di scale. Insieme alla velocità in salita riflette il controllo delle gambe e l'equilibrio: un ritmo stabile è un buon segno, mentre uno in calo va tenuto d'occhio.",
"breathingDisturbancesTitle": "Disturbi respiratori nel sonno",
"breathingDisturbancesBody": "Il tuo Apple Watch può stimare quanto spesso la respirazione si è interrotta durante il sonno. Un indice più basso è migliore; un livello che Apple segnala come elevato, soprattutto su molte notti, andrebbe segnalato a un medico."
},
"stats": {
"label": "Statistiche riepilogative",
"min": "Min",
"max": "Max",
"median": "Mediana",
"mean": "Media"
},
"showAllValues": "Mostra tutte le misurazioni",
"valuesPageTitle": "{metric} — tutte le misurazioni",
"valuesPageDescription": "Ogni misurazione dietro questa metrica. Modifica o rimuovi una voce direttamente.",
"diversity": {
"trigger": "Suggerimento di misurazione",
"weekday": "La maggior parte delle tue misurazioni di {metric} cade in un solo giorno della settimana. Registrare in altri giorni offre un quadro più completo.",
"timeOfDay": "La maggior parte delle tue misurazioni di {metric} cade in un orario simile della giornata. Variare l'orario offre un quadro più completo."
},
"valuesBack": "Torna a Insights",
"valuesBackToMetric": "Torna a {metric}",
"scoresBack": "Torna a Insights"
},
"sleep": {
"title": "Sonno",
@@ -2181,7 +2430,312 @@
"description": "Le letture appaiono qui quando Apple Health si sincronizza."
}
},
"navWalkingSpeed": "Velocità di cammino"
"navWalkingSpeed": "Velocità di cammino",
"mood": {
"heatmapTitle": "Calendario dell'umore",
"heatmapNoEntry": "Nessun dato",
"legendGreat": "Ottimo",
"legendGood": "Buono",
"legendOkay": "Discreto",
"legendLow": "Basso",
"distributionTitle": "Distribuzione dell'umore",
"distributionDaysUnit": "giorni",
"weekdayTitle": "Media per giorno della settimana",
"weekdayNoData": "Nessun dato",
"weekdayBestWorst": "Giorno migliore: {best} · Giorno più difficile: {worst}",
"tagsTitle": "Tag e fattori scatenanti",
"tagAvgMood": "Umore medio con questo tag",
"correlationsTitle": "Umore e altre metriche",
"correlationsDescription": "Come il tuo umore si muove con sonno, attività e battito nello stesso giorno.",
"correlation": {
"sleepTitle": "Umore vs. sonno",
"stepsTitle": "Umore vs. attività",
"pulseTitle": "Umore vs. battito",
"sleepAxis": "Sonno (min)",
"stepsAxis": "Passi",
"pulseAxis": "Battito (bpm)",
"strengthStrong": "Forte",
"strengthModerate": "Moderata",
"strengthWeak": "Debole",
"strengthNone": "Nessuna",
"source": "{n} giorni appaiati · r = {r}",
"emptyTitle": "Non ci sono ancora abbastanza dati appaiati",
"emptyDescription": "Registra umore e questa metrica negli stessi giorni per vedere una correlazione.",
"weightTitle": "Umore vs. peso",
"bloodPressureTitle": "Umore vs. pressione sanguigna",
"weightAxis": "Peso",
"bloodPressureAxis": "Sistolica (mmHg)"
},
"structuredTagsTitle": "Analisi etichette",
"inTargetValue": "{pct} %",
"inTargetCaption": "degli ultimi giorni nel tuo intervallo di buon umore",
"narrative": {
"title": "Cosa risalta",
"weekdayDip": "Il tuo umore cala di più il {weekday}: {delta} sotto la tua settimana.",
"weekdayPeak": "Il {weekday} è sempre il tuo giorno migliore, con una media di {value}.",
"trendUp": "Il tuo umore è in crescita negli ultimi {days} giorni (+{delta} rispetto al periodo precedente).",
"trendDown": "Il tuo umore è in calo negli ultimi {days} giorni (-{delta} rispetto al periodo precedente).",
"weekendUp": "Ti senti meglio nel weekend (+{delta} rispetto ai giorni feriali).",
"weekendDown": "Il tuo umore è più basso nel weekend (-{delta} rispetto ai giorni feriali).",
"tagLift": "I giorni con l'etichetta «{tag}» superano in media il tuo umore generale di {delta}.",
"tagDrop": "I giorni con l'etichetta «{tag}» restano {delta} sotto la tua media.",
"inTarget": "Sei stato nel tuo intervallo di buon umore il {pct} % degli ultimi 30 giorni.",
"streak": "Hai registrato il tuo umore per {days} giorni di fila — continua così.",
"timeOfDay": "Ti senti meglio di {bucket}, con una media di {value}."
},
"timeOfDay": {
"title": "Umore per fascia oraria",
"morning": "Mattina",
"afternoon": "Pomeriggio",
"evening": "Sera",
"night": "Notte",
"bestCaption": "Tendi a sentirti meglio di {bucket}."
},
"stability": {
"value": "{score}/100",
"bandVerySteady": "Il tuo umore è stato molto stabile",
"bandSteady": "Il tuo umore è stato abbastanza stabile",
"bandVariable": "Il tuo umore è stato notevolmente variabile",
"bandVeryVariable": "Il tuo umore ha oscillato molto di recente"
}
},
"derived": {
"coverage": {
"summary": "{present} di {required} input · {percent}%",
"ratioLabel": "{present}/{required}",
"historyDays": "Basato su {count} giorni di cronologia",
"missing": "Registra questi per affinare il valore: {list}"
},
"scoreRing": {
"aria": "Punteggio {score} su 100, {band}",
"ariaProvisional": "Punteggio non ancora disponibile — dati insufficienti",
"provisionalCaption": "Dati ancora insufficienti",
"band": {
"green": "buono",
"yellow": "medio",
"red": "basso"
}
},
"provenance": {
"title": "Come viene calcolato",
"trigger": "Come viene calcolato",
"inputsLabel": "Input",
"noInputs": "Nessun input supporta ancora questo valore.",
"methodLabel": "Metodo",
"window": "Dai tuoi ultimi {days} giorni · dati {source}",
"asOf": "al {date}",
"standardLink": "Standard: {name}"
},
"vitals": {
"sectionTitle": "I tuoi parametri vitali",
"loadingLabel": "Caricamento dei tuoi parametri vitali",
"loadError": "Al momento non è stato possibile caricare i tuoi parametri vitali.",
"building": "Creazione del tuo intervallo abituale — {count} di {target} giorni",
"typicalRange": "Abituale per te: {low}–{high}",
"fitnessYounger": "Forma cardio di chi ha ~{years} anni in meno",
"fitnessOlder": "Forma cardio ~{years} anni oltre la tua età",
"fitnessTypical": "Forma cardio tipica per la tua età",
"fitnessNoNorm": "Aggiungi età e sesso per inquadrare questo dato",
"vascularBelow": "{years} anni sotto la tua età",
"vascularAbove": "{years} anni sopra la tua età",
"vascularMatch": "In linea con la tua età",
"vascularNoAge": "Aggiungi la data di nascita per inquadrare questo dato",
"hrvBand": {
"balanced": "Equilibrato per te",
"unbalanced": "Sopra il tuo intervallo abituale",
"low": "Sotto il tuo intervallo abituale"
},
"bmiCategory": {
"underweight": "Sottopeso (OMS)",
"normal": "Intervallo normale (OMS)",
"overweight": "Sovrappeso (OMS)",
"obese": "Obesità (OMS)"
}
},
"anatomy": {
"outOf": "/100",
"contributorsLabel": "Fattori",
"contributorValue": "{value} · {weight}% di peso",
"contributorMissing": "nessun dato",
"disclaimer": "Indicativo — non è una valutazione clinica.",
"insufficient": "Dati ancora insufficienti per assegnare un punteggio. Continua a registrare e comparirà qui."
},
"composite": {
"SLEEP_SCORE": {
"title": "Punteggio del sonno",
"description": "Un punteggio di qualità del sonno trasparente con ogni sotto-punteggio mostrato.",
"method": "Una combinazione 0–100 di sufficienza (sonno vs il tuo bisogno in base all’età), efficienza (sonno ÷ tempo a letto), costanza (quanto è stabile il punto medio del tuo sonno), orario (vs il tuo punto medio abituale) e composizione (quota di REM + sonno profondo). I sotto-punteggi vengono ripesati attorno a quelli che i tuoi dati non possono supportare; nulla è inventato.",
"sub": {
"sufficiency": "Sufficienza",
"efficiency": "Efficienza",
"consistency": "Costanza",
"timing": "Orario",
"composition": "Composizione"
}
},
"READINESS": {
"title": "Prontezza",
"description": "Un indicatore quotidiano di benessere in base a come i tuoi segnali si collocano rispetto alla tua baseline.",
"method": "Una combinazione 0–100 di deviazione della frequenza cardiaca a riposo, deviazione della HRV (SDNN), qualità del sonno, stabilità della frequenza respiratoria e stabilità dell’umore — ciascuna misurata rispetto alla tua baseline personale. I pesi vengono rinormalizzati sui componenti supportati dai tuoi dati. Un indicatore di benessere, non un punteggio di recupero né clinico.",
"component": {
"rhr": "Frequenza cardiaca a riposo",
"hrv": "HRV (SDNN)",
"sleep": "Sonno",
"respiratory": "Frequenza respiratoria",
"mood": "Stabilità dell’umore"
},
"caveat": "Un indicatore di benessere, non un punteggio di recupero né clinico."
},
"VITALS_BASELINE": {
"method": "Il tuo intervallo tipico personale è la mediana dei tuoi valori giornalieri nel periodo, ± una dispersione robusta (deviazione assoluta mediana, scalata). Segue le TUE letture, non una norma di popolazione: un valore dentro la banda è quindi tipico per te."
},
"HRV_BALANCE": {
"method": "La tua media recente di VFC (SDNN) confrontata con la tua linea di base mobile (mediana ± dispersione robusta). Equilibrata significa che rientra nel tuo intervallo personale; la banda è costruita sui tuoi dati, mai su una soglia di popolazione."
},
"BMI": {
"method": "Peso ÷ altezza², classificato secondo le categorie OMS per adulti (sottopeso / normale / sovrappeso / obesità). Solo un rapporto di screening: non distingue il muscolo dal grasso."
},
"FITNESS_AGE": {
"method": "Il tuo VO₂max collocato rispetto ai percentili di fitness cardiorespiratoria per età e sesso, espresso come scarto di età di forma. Il VO₂max è la stima del tuo dispositivo, qui mai ricalcolata."
},
"VASCULAR_AGE_DELTA": {
"method": "La stima di età vascolare / velocità dell’onda di polso del tuo dispositivo confrontata con la tua età anagrafica, espressa come scarto in anni. La stima è del dispositivo; HealthLog si limita a reinquadrarla.",
"caveat": "La stima del tuo dispositivo, solo reinquadrata: descrittiva, non una valutazione vascolare clinica.",
"description": "Come la stima di età vascolare del tuo dispositivo si colloca rispetto alla tua età anagrafica."
},
"COINCIDENT_DEVIATION": {
"method": "Un avviso che compare quando due o più dei tuoi parametri vitali escono dal loro intervallo tipico personale nello stesso giorno. Ogni banda è la mediana robusta ± k·MAD del tuo storico, quindi si adatta a te e non a una soglia di popolazione.",
"caveat": "Un avviso dalle tue linee di base: descrittivo, non una diagnosi né un allarme clinico."
},
"RECOVERY_SCORE": {
"method": "Un indicatore notturno di recupero da 0 a 100 che combina VFC, frequenza cardiaca a riposo e sonno rispetto alla tua linea di base. Più alto significa più recuperato. Calcolato una volta a notte e qui solo riletto, mai ricalcolato al volo.",
"caveat": "Un indicatore di benessere descrittivo, non una misura di recupero clinica o da allenamento.",
"description": "Un indicatore notturno di recupero da VFC, frequenza a riposo e sonno."
},
"STRESS_SCORE": {
"method": "Un indicatore notturno di stress da 0 a 100 dalla forma della tua VFC nel corso della giornata rispetto alla tua linea di base. Più alto significa maggiore carico fisiologico.",
"caveat": "Un indicatore derivato dalla VFC, NON una misura di stress da EDA / attività elettrodermica: il tuo orologio non ha un sensore EDA. Descrittivo, non clinico.",
"description": "Un indicatore notturno di stress dalla forma della tua VFC nel corso della giornata."
},
"STRAIN_SCORE": {
"method": "Un indicatore notturno di sforzo da 0 a 100 dal tuo carico di attività pesato sulla frequenza cardiaca (un modello di impulso d’allenamento tipo TRIMP). Più alto significa una giornata più impegnativa.",
"caveat": "Un indicatore descrittivo di carico di attività, non una misura di carico clinica o da allenamento.",
"description": "Un indicatore notturno di carico di attività dal tuo sforzo pesato sulla frequenza cardiaca."
}
},
"scores": {
"sectionTitle": "Punteggi di benessere",
"recovery": "Recupero",
"stress": "Stress",
"strain": "Sforzo"
},
"coincident": {
"label": "Più parametri fuori dalla base",
"summary": "Oggi {count} dei tuoi parametri vitali sono usciti dal loro intervallo personale.",
"vitals": "Fuori intervallo: {list}"
}
},
"cardioFitness": {
"title": "Forma fisica",
"description": "Il tuo VO₂ max nel tempo: la misura principale della forma aerobica.",
"chartTitle": "VO₂ max (mL/(kg·min))",
"emptyState": {
"title": "Nessun dato sulla forma fisica",
"description": "I valori di VO₂ max compaiono qui quando Apple Health li sincronizza dai tuoi allenamenti."
}
},
"navCardioFitness": "Forma fisica",
"cardioRecovery": {
"title": "Recupero cardiaco",
"description": "Di quanto cala la frequenza cardiaca un minuto dopo lo sforzo massimo.",
"chartTitle": "Recupero cardiaco (bpm)",
"emptyState": {
"title": "Nessun dato sul recupero cardiaco",
"description": "I valori di recupero cardiaco compaiono qui quando Apple Health li sincronizza da un allenamento."
}
},
"navCardioRecovery": "Recupero cardiaco",
"wristTemperature": {
"title": "Temperatura del polso",
"description": "La temperatura del polso durante la notte, rispetto al tuo valore di riferimento.",
"chartTitle": "Temperatura del polso (°C)",
"emptyState": {
"title": "Nessun dato sulla temperatura del polso",
"description": "I valori compaiono qui quando Apple Health sincronizza l'uso notturno."
}
},
"navWristTemperature": "Temp. polso",
"falls": {
"title": "Cadute",
"description": "Cadute forti rilevate dal tuo Apple Watch, contate al giorno.",
"chartTitle": "Cadute al giorno",
"emptyState": {
"title": "Nessun dato sulle cadute",
"description": "Le cadute rilevate compaiono qui quando Apple Health si sincronizza. Nessuna caduta è l'obiettivo."
}
},
"navFalls": "Cadute",
"sixMinuteWalk": {
"title": "Cammino di 6 minuti",
"description": "La stima di Apple di quanto potresti camminare in sei minuti.",
"chartTitle": "Distanza in 6 minuti (m)",
"emptyState": {
"title": "Nessun dato sul cammino di 6 minuti",
"description": "Le stime compaiono qui quando Apple Health si sincronizza."
}
},
"navSixMinuteWalk": "Cammino 6 min",
"stairAscentSpeed": {
"title": "Velocità in salita scale",
"description": "Quanto velocemente sali le scale, misurato da iPhone o Watch.",
"chartTitle": "Velocità in salita (m/s)",
"emptyState": {
"title": "Nessun dato sulla salita delle scale",
"description": "I valori compaiono qui quando Apple Health si sincronizza."
}
},
"navStairAscentSpeed": "Salita scale",
"stairDescentSpeed": {
"title": "Velocità in discesa scale",
"description": "Quanto velocemente scendi le scale, misurato da iPhone o Watch.",
"chartTitle": "Velocità in discesa (m/s)",
"emptyState": {
"title": "Nessun dato sulla discesa delle scale",
"description": "I valori compaiono qui quando Apple Health si sincronizza."
}
},
"navStairDescentSpeed": "Discesa scale",
"breathingDisturbances": {
"title": "Disturbi respiratori",
"description": "Quanto spesso la tua respirazione è stata disturbata durante il sonno.",
"chartTitle": "Disturbi respiratori nel sonno",
"emptyState": {
"title": "Nessun dato sui disturbi respiratori",
"description": "I valori compaiono qui quando Apple Health li sincronizza da un Apple Watch compatibile."
}
},
"navBreathingDisturbances": "Respirazione",
"rhythmEvents": {
"sectionTitle": "Notifiche sulla salute del dispositivo",
"sectionIntro": "Notifiche che il tuo orologio ha inviato autonomamente. HealthLog si limita a mostrarle — non le rileva, conferma né interpreta.",
"loadError": "Impossibile caricare le notifiche del dispositivo.",
"disclaimer": "Questi sono avvisi generati dal tuo dispositivo con il proprio rilevamento integrato. Sono mostrati qui solo a scopo informativo. Non è una valutazione medica da parte di HealthLog, e HealthLog non diagnostica alcuna condizione. Se una notifica ti preoccupa, parla con il tuo medico.",
"event": {
"irregularRhythm": "Irregular rhythm notification",
"highHeartRate": "High heart rate notification",
"lowHeartRate": "Low heart rate notification",
"walkingSteadiness": "Walking steadiness alert",
"breathingDisturbance": "Breathing disturbance during sleep"
},
"verdict": {
"irregular": "Your device flagged a possible irregular rhythm.",
"notDetected": "Your device did not flag an irregular rhythm.",
"inconclusive": "Your device could not make a reading.",
"low": "Your device flagged low walking steadiness.",
"veryLow": "Your device flagged very low walking steadiness.",
"fired": "Your device flagged this event."
}
}
},
"thresholds": {
"defaultLabel": "Predefinito",
@@ -2393,7 +2947,7 @@
"description": "Withings, moodLog, and other connected services."
},
"notifications": {
"title": "Canali di notifica",
"title": "Notifiche",
"description": "Live overview of every configured channel."
},
"dashboard": {
@@ -2401,63 +2955,62 @@
"description": "Tile layout and order."
},
"thresholds": {
"title": "Obiettivi e fonti",
"description": "Per metrica: il tuo intervallo target e quale fonte prevale quando più di una registra lo stesso valore lo stesso giorno."
"title": "Obiettivi",
"description": "Per metrica: il tuo intervallo target per valori sani."
},
"sources": {
"title": "Sources",
"description": "Pick which ingest source wins when more than one records the same metric for the same day.",
"redirectNotice": "Le fonti ora fanno parte di «Obiettivi e fonti».",
"cardTitle": "Source priority by metric",
"help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.",
"resetDefaults": "Reset to defaults",
"moveUp": "Move up",
"moveDown": "Move down",
"saveSuccess": "Source priority saved.",
"saveError": "Saving the source priority failed.",
"resetSuccess": "Source priority reset to defaults.",
"perMetricToggle": "Per-metric overrides ({count})",
"perMetricHelp": "Metrics you have customised away from the default ladder. Use this list to roll any single metric back to its constant default without touching the rest.",
"perMetricEmpty": "No per-metric overrides set — every metric uses the global ladder above.",
"title": "Fonti",
"description": "Scegli quale fonte prevale quando più di una registra la stessa metrica lo stesso giorno.",
"cardTitle": "Priorità delle fonti per metrica",
"help": "Quando più fonti forniscono lo stesso valore nello stesso giorno (ad es. Withings e Apple Health riportano entrambe 1.200 passi), la fonte in cima conta come lettura canonica. Le altre fonti restano memorizzate per la traccia di controllo.",
"resetDefaults": "Ripristina i valori predefiniti",
"moveUp": "Sposta su",
"moveDown": "Sposta giù",
"saveSuccess": "Priorità delle fonti salvata.",
"saveError": "Salvataggio della priorità delle fonti non riuscito.",
"resetSuccess": "Priorità delle fonti ripristinata ai valori predefiniti.",
"perMetricToggle": "Personalizzazioni per metrica ({count})",
"perMetricHelp": "Metriche che hai personalizzato rispetto all’ordine predefinito. Usa questo elenco per riportare una singola metrica al suo valore predefinito senza toccare le altre.",
"perMetricEmpty": "Nessuna personalizzazione per metrica — ogni metrica usa l’ordine globale qui sopra.",
"resetMetric": "Reimposta",
"deviceTypeToggle": "Device-type priority ({count})",
"deviceTypeHelp": "Within a source, which device wins. When an Apple Watch and an iPhone both record steps, the higher-ranked device-type counts as the canonical reading.",
"deviceTypeDefault": "Default device-type ladder",
"resetDeviceTypes": "Reset device-types",
"deviceTypeToggle": "Priorità per tipo di dispositivo ({count})",
"deviceTypeHelp": "All’interno di una fonte, quale dispositivo prevale. Quando un Apple Watch e un iPhone registrano entrambi i passi, il tipo di dispositivo più in alto conta come lettura canonica.",
"deviceTypeDefault": "Ordine predefinito per tipo di dispositivo",
"resetDeviceTypes": "Reimposta i tipi di dispositivo",
"metrics": {
"steps": "Passi",
"activeEnergy": "Active energy",
"walkingRunningDistance": "Walking + running distance",
"flightsClimbed": "Flights climbed",
"activeEnergy": "Energia attiva",
"walkingRunningDistance": "Distanza a piedi + corsa",
"flightsClimbed": "Piani saliti",
"sleep": "Sonno",
"weight": "Peso",
"bloodPressure": "Pressione arteriosa",
"pulse": "Polso",
"bodyFat": "Massa grassa",
"bodyTemperature": "Body temperature",
"spo2": "Oxygen saturation",
"hrv": "Heart-rate variability",
"bodyTemperature": "Temperatura corporea",
"spo2": "Saturazione di ossigeno",
"hrv": "Variabilità della frequenza cardiaca",
"restingHeartRate": "Frequenza cardiaca a riposo",
"vo2Max": "VO2 max"
},
"sourceLabels": {
"WITHINGS": "Withings",
"APPLE_HEALTH": "Apple Health",
"MANUAL": "Manual entry",
"MANUAL": "Inserimento manuale",
"IMPORT": "Importa"
},
"deviceLabels": {
"watch": "Watch",
"band": "Band",
"ring": "Ring",
"phone": "Phone",
"scale": "Scale",
"other": "Other",
"watch": "Orologio",
"band": "Bracciale",
"ring": "Anello",
"phone": "Telefono",
"scale": "Bilancia",
"other": "Altro",
"unknown": "Sconosciuto"
}
},
"ai": {
"title": "Insights",
"title": "AI Insights",
"description": "Provider, model, key."
},
"api": {
@@ -2706,6 +3259,11 @@
"testProvider": "Test active provider",
"testSuccess": "OK — {provider} ({model})",
"testFailedShort": "Test failed: {message}",
"testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.",
"testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.",
"testReasonRateLimited": "Provider rate-limited the request — try again shortly.",
"testReasonServerError": "The AI provider returned a server error.",
"testReasonUnreachable": "Could not reach the AI provider.",
"disableCoach": {
"title": "Nascondi il Coach",
"description": "Nasconde il pulsante e il pannello del Coach su tutte le pagine.",
@@ -2939,7 +3497,9 @@
"fhirNote": "L’output strutturato segue lo standard FHIR R4 (compatibile ePA).",
"generate": "Genera",
"error": "Esportazione non riuscita ({code})."
}
},
"globalExcludedInjectionSitesLabel": "Siti di iniezione esclusi globalmente",
"globalExcludedInjectionSitesHint": "I siti elencati qui non vengono proposti per alcun farmaco e vengono rifiutati al momento della registrazione di una dose, anche se un farmaco li preferisce."
},
"admin": {
"title": "Administration",
@@ -3760,8 +4320,6 @@
}
},
"targets": {
"title": "Valori obiettivo",
"introText": "I tuoi valori attuali rispetto agli intervalli obiettivo consigliati. Le fonti delle linee guida sono collegate su ogni card.",
"bmi": "IMC",
"average30d": "Media 30 giorni:",
"consistency": {
@@ -3783,23 +4341,11 @@
"sixDaysAgo": "6 giorni fa"
}
},
"card": {
"lastMet": "Ultima volta in obiettivo: {when}",
"streak": "{count} giorni di fila"
},
"relativeDay": {
"today": "Oggi",
"yesterday": "Ieri",
"daysAgo": "{count} giorni fa"
},
"summary": {
"weekTitle": "{met} di {total} obiettivi raggiunti questa settimana",
"weekTitleAll": "Tutto{total} targets met this week",
"streakHighlight": "{count} giorni di fila: {metric}"
},
"coach": {
"cta": "Chiedi al coach"
},
"edit": {
"openLabel": "Modifica il range obiettivo per {metric}",
"title": "Modifica range obiettivo — {metric}",
@@ -3831,31 +4377,10 @@
"aboveTarget": "{delta} {unit} sopra il range",
"belowTarget": "{delta} {unit} sotto il range",
"diastolic": "Diastolica",
"noMeasurementYet": "Ancora nessuna misurazione",
"noMeasurementData": "Nessun dato di misurazione. Aggiungi misurazioni per vedere gli obiettivi.",
"profileIncomplete": "Profilo incompleto",
"profileIncompleteHeight": "Inserisci la tua altezza nel profilo per attivare gli obiettivi BMI.",
"profileIncompleteAge": "Inserisci la tua data di nascita nel profilo per attivare gli obiettivi legati all’età.",
"profileIncompleteHeightAge": "Inserisci altezza e data di nascita nel profilo per vedere obiettivi personalizzati.",
"compliancePerMedication": "Aderenza per farmaco",
"glucoseFasting": "Glicemia — a digiuno",
"glucosePostprandial": "Glicemia — postprandiale",
"glucoseRandom": "Glicemia — qualsiasi",
"glucoseBedtime": "Glicemia — prima di coricarsi",
"loadError": "Impossibile caricare i valori obiettivo",
"label": {
"WEIGHT": "Peso",
"BLOOD_PRESSURE": "Pressione arteriosa",
"BLOOD_PRESSURE_IN_TARGET": "Pressione nel range",
"PULSE": "Pulsazioni a riposo",
"SLEEP_DURATION": "Durata del sonno",
"BMI": "IMC",
"BODY_FAT": "Massa grassa",
"ACTIVITY_STEPS": "Passi/giorno",
"MEDICATION_COMPLIANCE": "Aderenza",
"MOOD_SCORE": "Umore",
"MOOD_STABILITY": "Stabilità umore"
},
"status": {
"underweight": "Sottopeso",
"normal": "Normal",
+639 -114
View File
@@ -158,7 +158,12 @@
"chartsTitle": "Wykresy trendów",
"aiSummaryTitle": "Podsumowanie AI — niezweryfikowane klinicznie",
"aiSummaryDisclaimer": "Ta sekcja została wygenerowana automatycznie przez model AI i nie jest zweryfikowana klinicznie. Nie zastępuje oceny lekarskiej.",
"packageReadme": "Dokumentacja zdrowotna HealthLog\n\nTen pakiet zawiera:\n- report.pdf: raport zdrowotny dla lekarza.\n- bundle.json: te same dane jako dokument HL7 FHIR R4 (Bundle), zgodny z ePA.\n\nWygenerowano w HealthLog."
"packageReadme": "Dokumentacja zdrowotna HealthLog\n\nTen pakiet zawiera:\n- report.pdf: raport zdrowotny dla lekarza.\n- bundle.json: te same dane jako dokument HL7 FHIR R4 (Bundle), zgodny z ePA.\n\nWygenerowano w HealthLog.",
"wellnessTitle": "Podsumowanie dobrostanu",
"wellnessDisclaimer": "To opisowe wyniki dobrostanu obliczone na podstawie śledzonych sygnałów (0–100). Nie są to oceny kliniczne ani diagnozy.",
"wellnessRecovery": "Regeneracja",
"wellnessStress": "Stres",
"wellnessStrain": "Obciążenie"
},
"errorBoundary": {
"title": "Coś poszło nie tak",
@@ -425,7 +430,22 @@
"dailyTotalCaption": "({count} pomiarów)",
"expandDay": "Pokaż pomiary z tego dnia",
"collapseDay": "Ukryj pomiary z tego dnia",
"loadError": "Nie udało się załadować pomiarów."
"loadError": "Nie udało się załadować pomiarów.",
"typeCardioRecovery": "Regeneracja serca",
"typeWristTemperature": "Temperatura nadgarstka",
"typeFallCount": "Upadki",
"typeSixMinuteWalkDistance": "Dystans marszu 6-minutowego",
"typeStairAscentSpeed": "Prędkość wchodzenia po schodach",
"typeStairDescentSpeed": "Prędkość schodzenia po schodach",
"typeBreathingDisturbances": "Zaburzenia oddychania we śnie",
"typeIrregularRhythmNotification": "Irregular rhythm notification",
"typeHighHeartRateEvent": "High heart rate notification",
"typeLowHeartRateEvent": "Low heart rate notification",
"typeWalkingSteadinessEvent": "Walking steadiness alert",
"typeBreathingDisturbanceEvent": "Breathing disturbance",
"typeRecoveryScore": "Recovery score",
"typeStressScore": "Stress score",
"typeStrainScore": "Strain score"
},
"mood": {
"title": "Nastrój",
@@ -461,7 +481,49 @@
"deleteConfirmTitle": "Usunąć wpis?",
"deleteConfirmDescription": "Ten wpis nastroju zostanie nieodwracalnie usunięty.",
"pageInfo": "Strona {page} z {total}",
"loginRequired": "Zaloguj się, aby zobaczyć wpisy nastroju."
"loginRequired": "Zaloguj się, aby zobaczyć wpisy nastroju.",
"note": "Notatka",
"notePlaceholder": "Dodaj notatkę o swoim samopoczuciu…",
"tagPicker": "Tagi",
"tagCategory": {
"feelings": "Uczucia",
"sleep": "Sen",
"health": "Zdrowie",
"social": "Społeczne",
"work": "Praca"
},
"tag": {
"happy": "szczęśliwy",
"excited": "podekscytowany",
"grateful": "wdzięczny",
"relaxed": "zrelaksowany",
"content": "zadowolony",
"tired": "zmęczony",
"unsure": "niepewny",
"bored": "znudzony",
"tense": "spięty",
"angry": "zły",
"stressed": "zestresowany",
"sad": "smutny",
"sleptWell": "dobrze spałem",
"sleptOk": "sen średni",
"sleptPoorly": "źle spałem",
"earlyNight": "wcześnie spać",
"workedOut": "trening",
"ateWell": "zdrowo zjadłem",
"hydrated": "piłem wodę",
"walked": "spacer",
"alcohol": "alkohol",
"family": "rodzina",
"friends": "znajomi",
"party": "impreza",
"alone": "sam",
"productive": "produktywny",
"overtime": "nadgodziny",
"dayOff": "dzień wolny",
"travel": "podróż",
"sickDay": "dzień chorobowy"
}
},
"medications": {
"title": "Leki",
@@ -544,11 +606,14 @@
"veryOverdue": "Mocno po terminie",
"compliance7d": "Przestrzeganie 7 dni",
"compliance30d": "Przestrzeganie 30 dni",
"complianceWindow": "Przestrzeganie {days} dni",
"lastIntake": "Ostatnie przyjęcie:",
"nextIntake": "Następne przyjęcie:",
"dayStreak": "dni serii",
"taken": "Przyjęte",
"skipped": "Pominięte",
"intakeToastTaken": "{name} oznaczone jako przyjęte",
"intakeToastSkipped": "{name} pominięte",
"weekdaySunday": "Niedziela",
"weekdayMonday": "Poniedziałek",
"weekdayTuesday": "Wtorek",
@@ -759,7 +824,11 @@
"back": "Back",
"next": "Next",
"save": "Save",
"saveEdit": "Save changes"
"saveEdit": "Save changes",
"nextHint": "Dalej: {step}",
"reviewHint": "Dalej: Zapisz",
"jumpFirst": "Przejdź do pierwszego kroku",
"jumpLast": "Przejdź do podsumowania i zapisu"
},
"nl": {
"button": "Describe"
@@ -769,11 +838,13 @@
"title": "What's the medication called?",
"subline": "Exactly as it appears on the box.",
"label": "Name",
"placeholder": "e.g. Ramipril"
"placeholder": "e.g. Ramipril",
"short": "Nazwa"
},
"step2": {
"title": "What kind of medication is it?",
"subline": "We use this for the right templates and analytics."
"subline": "We use this for the right templates and analytics.",
"short": "Typ"
},
"step3": {
"title": "What's the dose?",
@@ -805,15 +876,18 @@
"dosesPerUnitPlaceholder": "np. 4",
"dosesPerUnitHint": "Dawki na wstrzykiwacz / fiolkę — zostaw puste, aby nie śledzić zapasów.",
"injectionSiteToggle": "Rotacja miejsc wstrzyknięcia",
"injectionSiteHint": "Opcjonalny przewodnik rotacji. Miejsce każdej dawki zapisywane jest przy rejestrowaniu przyjęcia."
"injectionSiteHint": "Opcjonalny przewodnik rotacji. Miejsce każdej dawki zapisywane jest przy rejestrowaniu przyjęcia.",
"short": "Dawka"
},
"step4": {
"title": "Over what period are you taking it?",
"subline": "A start date is enough for today. You can add an end date later."
"subline": "A start date is enough for today. You can add an end date later.",
"short": "Kiedy"
},
"step5": {
"title": "How often do you take it?",
"subline": "Pick the cadence — the details follow on the next step."
"subline": "Pick the cadence — the details follow on the next step.",
"short": "Częstotliwość"
},
"step6": {
"title": "What does that look like in detail?",
@@ -829,7 +903,8 @@
"rollingDays": {
"label": "Every",
"suffix": "days from the last intake"
}
},
"short": "Szczegóły"
},
"step7": {
"title": "When do you take it?",
@@ -840,14 +915,16 @@
"noon": "Noon",
"evening": "Evening",
"night": "Night"
}
},
"short": "Godziny"
},
"step8": {
"title": "All done — should HealthLog remind you?",
"subline": "Review everything below before you save.",
"remindersLabel": "Reminders on",
"remindersDescription": "Push, Telegram or ntfy — based on your settings.",
"multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release."
"multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release.",
"short": "Gotowe"
}
},
"classRow": {
@@ -923,26 +1000,17 @@
"submitFailed": "Could not save the medication — please retry."
}
},
"titration": {
"section": "Schemat titracji",
"loading": "Wczytywanie danych titracji",
"loadFailed": "Nie udało się wczytać danych titracji.",
"drugLadderHeader": "Standardowy schemat (referencja EMA)",
"typicalWeeksOnStep": "≥ {weeks} tyg. typowo",
"youAreHere": "Jesteś tutaj",
"nextStepCaption": "Następny stopień (typowo): {dose} mg po ~{weeks} tygodniach na obecnym stopniu.",
"ceilingMessage": "Jesteś na najwyższym stopniu schematu standardowego.",
"escalationDueHint": "Jesteś na tym stopniu od {weeks} tygodni; schemat zwykle przewiduje przejście wyżej po ~{typical} tygodniach.",
"currentStepDwell": "Na tym stopniu od {weeks} tygodni",
"doseUnitMg": "mg",
"emptyState": "Brak historii dawek. Zarejestruj dawkę, aby zobaczyć swoją pozycję w schemacie.",
"nonStandardDose": "Aktualna dawka jest poza standardowymi stopniami schematu.",
"disclaimer": "Wartości referencyjne EMA. Skonsultuj się z lekarzem przed zmianą dawki.",
"emaSourceCta": "Źródło EMA",
"stepLabel": "Stopień {n}"
"doseStrength": {
"title": "Dawka w czasie",
"axisLabel": "Zapisana dawka ({unit})",
"tooltipLabel": "Dawka",
"currentDose": "Aktualna dawka: {dose} {unit}",
"noCurrentDose": "Nie zapisano jeszcze żadnej dawki.",
"empty": "Brak historii titracji. Zapisz zmianę dawki, aby zobaczyć krzywą dawki."
},
"injectionSiteBodyOutlineAriaLabel": "Sylwetka ciała",
"injectionSitePickerAriaLabel": "Wybór miejsca iniekcji",
"injectionSiteRecommendedAriaLabel": "{site} — zalecane następne miejsce",
"siteAbdomenLeft": "Brzuch, dolny lewy",
"siteAbdomenRight": "Brzuch, dolny prawy",
"siteAbdomenUpperLeft": "Brzuch, górny lewy",
@@ -1005,9 +1073,10 @@
"phaseSaved": "Fazy przypomnień zapisane",
"phaseReset": "Przywrócono wartości domyślne",
"sourceWeb": "Web",
"sourceApi": "API",
"sourceApi": "Aplikacja iOS",
"sourceReminder": "Przypomnienie",
"sourceImport": "Import",
"intakeSourceVia": "przez {origin}",
"intakeHistoryEmptyTitle": "Brak przyjęć",
"intakeHistoryEmptyDescription": "Gdy tylko zarejestrujesz przyjęcie, pojawi się tu wraz z godziną i źródłem.",
"intakeHistoryEmptyAction": "Zarejestruj przyjęcie",
@@ -1078,11 +1147,6 @@
"emptyState": "Brak zarejestrowanych przyjęć.",
"emptyStateCta": "Zarejestruj dawkę, aby zobaczyć kształtującą się krzywą szacunkową.",
"estimateNote": "Edukacyjne oszacowanie z opublikowanej przez EMA farmakokinetyki populacyjnej. To nie jest pomiar.",
"gatedTitle": "Tryb Badawczy wyłączony",
"gatedBody": "Włącz tryb Badawczy w Ustawieniach, aby zobaczyć szacowaną krzywą poziomu leku. Krzywa jest opt-in, ponieważ to pomoc edukacyjna, a nie pomiar kliniczny.",
"gatedStaleTitle": "Disclaimer wymaga ponownego potwierdzenia",
"gatedStaleBody": "Disclaimer trybu Badawczego został zaktualizowany od czasu twojego ostatniego potwierdzenia. Przeczytaj go ponownie w Ustawieniach i potwierdź, aby krzywa znów się pojawiła.",
"gatedCta": "Otwórz tryb Badawczy w Ustawieniach",
"unknownDrug": "{name} nie znajduje się w katalogu GLP-1 zatwierdzonym przez EMA, więc HealthLog nie może wyrysować krzywej farmakokinetyki populacyjnej."
}
},
@@ -1170,6 +1234,14 @@
"unit": "minutes",
"saved": "Reminder window saved",
"failed": "Could not save the reminder window."
},
"codes": {
"label": "Kody kliniczne (ATC / RxNorm)",
"note": "Opcjonalne. Używane w eksporcie FHIR dokumentacji zdrowotnej. Kody są zapisywane dokładnie tak, jak wprowadzone — nigdy nie są zgadywane.",
"atcLabel": "ATC",
"rxNormLabel": "RxNorm",
"saved": "Kody kliniczne zapisane",
"failed": "Nie udało się zapisać kodów klinicznych."
}
},
"zone": {
@@ -1235,7 +1307,7 @@
"data": "Dane",
"reminders": "Przypomnienia",
"lifecycle": "Cykl życia",
"danger": "Strefa zagrożenia"
"externalApi": "Zewnętrzne API"
},
"dataPortability": {
"import": {
@@ -1258,10 +1330,18 @@
"history": {
"back": "Powrót do leku",
"subtitle": "Pełna historia przyjęć",
"plannedSuffix": "zaplanowane",
"drugLevelDisclosure": "Pokaż szacowaną krzywą stężenia leku"
"plannedSuffix": "zaplanowane"
}
}
},
"logInjectionSiteTitle": "Zarejestruj miejsce iniekcji",
"logInjectionSiteDescription": "Gdzie wstrzyknięto {name}? Opcjonalnie — możesz to pominąć.",
"logInjectionSiteSkip": "Pomiń",
"logInjectionSiteConfirm": "Zapisz miejsce",
"logInjectionSiteNoneAvailable": "Brak dostępnego miejsca iniekcji — wszystkie miejsca są wykluczone w Twoich ustawieniach.",
"trackInjectionSitesToggle": "Rejestruj miejsca iniekcji",
"trackInjectionSitesHint": "Po przyjęciu dawki zapytaj, którego miejsca użyto, i zaproponuj kolejną rotację.",
"allowedSitesLabel": "Dozwolone miejsca",
"allowedSitesHint": "Pozostaw wszystkie zaznaczone, aby nie stosować ograniczeń. Miejsca wykluczone globalnie nigdy nie są proponowane."
},
"charts": {
"loadingLabel": "Ładowanie wykresu…",
@@ -1272,6 +1352,13 @@
"bodyFat": "Tkanka tłuszczowa",
"sleep": "Sen",
"steps": "Kroki",
"hrv": "HRV",
"restingHeartRate": "Tętno spoczynkowe",
"activeEnergy": "Energia czynna",
"flights": "Pokonane piętra",
"distance": "Dystans marsz + bieg",
"vo2Max": "VO₂ max",
"bodyTemperature": "Temperatura ciała",
"noData": "Brak danych w wybranym okresie",
"bucketWeekly": "Średnia tygodniowa",
"bucketMonthly": "Średnia miesięczna",
@@ -1336,7 +1423,17 @@
"personalBaseline": "Twoja średnia",
"deltaVsBaseline": "{delta} vs. twoja średnia",
"deltaUnchanged": "odpowiada twojej średniej",
"deltaVsTarget": "{delta} vs. cel"
"deltaVsTarget": "{delta} vs. cel",
"weekdaysFull": {
"mon": "Poniedziałek",
"tue": "Wtorek",
"wed": "Środa",
"thu": "Czwartek",
"fri": "Piątek",
"sat": "Sobota",
"sun": "Niedziela"
},
"rangeBand": "Zakres"
},
"chart": {
"overlay": {
@@ -1376,6 +1473,7 @@
},
"insights": {
"noAnalysisYet": "Brak analizy.",
"assessmentPreparing": "Przygotowywanie oceny…",
"emptyTitle": "Jeszcze za mało danych",
"emptyDescription": "Insights pojawią się, gdy zarejestrujesz kilka pomiarów. Zacznij od wagi lub ciśnienia.",
"emptyAddMeasurement": "Zarejestruj pomiar",
@@ -1432,6 +1530,8 @@
"heroRegenerating": "Generowanie",
"regenerateAnalysis": "Uruchom analizę ponownie",
"regenerateSuccess": "Analiza wygenerowana ponownie",
"warmAssessments": "Przygotuj analizy",
"warmStarted": "Analizy są przygotowywane w tle",
"heroFallbackSubtitle": "Codzienne spojrzenie na twoje trendy — prosto z liczb, które rejestrujesz.",
"heroGreetingMorning": "Dzień dobry",
"heroGreetingAfternoon": "Dzień dobry",
@@ -1498,7 +1598,22 @@
},
"trendsRow": {
"title": "Trendy",
"subtitle": "Ostatnie 30 dni w skrócie"
"subtitle": "Ostatnie 30 dni w skrócie",
"caption": {
"bp": "Wartości skurczowe i rozkurczowe z ostatnich 30 dni.",
"weight": "Trend masy ciała z ostatnich 30 dni.",
"pulse": "Tętno spoczynkowe z ostatnich 30 dni.",
"mood": "Dzienna ocena nastroju z ostatnich 30 dni.",
"sleep": "Długość snu nocnego z ostatnich 30 dni.",
"steps": "Dzienna liczba kroków z ostatnich 30 dni.",
"hrv": "Zmienność rytmu serca z ostatnich 30 dni.",
"resting_hr": "Spoczynkowe tętno z ostatnich 30 dni.",
"active_energy": "Aktywnie spalona energia z ostatnich 30 dni.",
"flights": "Pokonane piętra z ostatnich 30 dni.",
"distance": "Dystans marszu i biegu z ostatnich 30 dni.",
"vo2_max": "VO₂ max z ostatnich 30 dni.",
"body_temp": "Temperatura ciała z ostatnich 30 dni."
}
},
"vo2Max": {
"statLatest": "Aktualne",
@@ -1515,6 +1630,23 @@
"confidenceLow": "Niska pewność",
"pendingLabel": "Komentarz trendu jest generowany"
},
"range": {
"label": "Okres",
"noPriorData": "brak danych z poprzedniego okresu",
"option": {
"7d": "7d",
"30d": "30d",
"90d": "90d",
"1y": "1r"
},
"priorLabel": {
"7d": "vs poprzednie 7d",
"30d": "vs poprzednie 30d",
"90d": "vs poprzednie 90d",
"1y": "vs poprzedni rok"
},
"noChange": "bez zmian"
},
"correlationRow": {
"title": "Korelacje",
"subtitle": "Hipotezy zdefiniowane wcześniej, z progiem n ≥ 14 i p < 0,05",
@@ -1800,7 +1932,124 @@
"medikamenteEmptyDescription": "Insights przestrzegania wymagają przynajmniej jednego zarejestrowanego leku.",
"medikamenteEmptyAction": "Otwórz leki",
"therapyTimelineDescription": "Chronologiczny protokół terapii GLP-1 — zmiany dawki, iniekcje, stan, działania niepożądane.",
"bmiDescription": "BMI obliczone z twojej wagi i wzrostu, z oceną."
"bmiDescription": "BMI obliczone z twojej wagi i wzrostu, z oceną.",
"target": {
"range": "Cel: {min}–{max} {unit}",
"bpRange": "Cel: {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}",
"inTargetShare": "{pct}% zarejestrowanych dni w zakresie celu",
"adjustLink": "Dostosuj zakres docelowy"
},
"explainer": {
"trigger": "Czym jest {title}?",
"bloodPressureTitle": "Ciśnienie krwi",
"bloodPressureBody": "Ciśnienie krwi to siła, z jaką krew napiera na ściany tętnic, zapisywana jako skurczowe do rozkurczowego (szczyt podczas uderzenia serca do wartości spoczynkowej między uderzeniami). Stale wysokie wartości z czasem obciążają serce i naczynia.",
"pulseTitle": "Tętno",
"pulseBody": "Tętno to liczba uderzeń serca na minutę. Pojedynczy pomiar odzwierciedla to, co robiłeś w danej chwili — w spoczynku wynosi zwykle 60–100 uderzeń na minutę i naturalnie rośnie przy ruchu, stresie lub kofeinie.",
"weightTitle": "Waga",
"weightBody": "Masa ciała to jeden z najprostszych sygnałów długoterminowego bilansu energetycznego i ogólnego zdrowia. Trend na przestrzeni tygodni liczy się znacznie bardziej niż pojedynczy odczyt, który zmienia się wraz z nawodnieniem, jedzeniem i porą dnia.",
"bmiTitle": "Wskaźnik masy ciała (BMI)",
"bmiBody": "BMI to waga podzielona przez wzrost do kwadratu — jedna liczba przypisująca Cię do szerokiej kategorii wagowej. To badanie przesiewowe dla populacji, a nie diagnoza, i nie odróżnia mięśni od tłuszczu.",
"sleepTitle": "Sen",
"sleepBody": "Długość snu to czas, który faktycznie przespałeś w nocy, podzielony na fazy lekką, głęboką i REM. Większość dorosłych potrzebuje około siedmiu do dziewięciu godzin; stała ilość i jakość wspierają regenerację, nastrój i metabolizm.",
"moodTitle": "Nastrój",
"moodBody": "Śledzenie nastroju rejestruje Twoje samopoczucie w czasie na prostej skali. Regularne zapisywanie ujawnia wzorce — jak sen, aktywność czy leki łączą się z samopoczuciem — których pojedynczy dzień nigdy nie pokaże.",
"medicationsTitle": "Przestrzeganie zaleceń lekowych",
"medicationsBody": "Przestrzeganie zaleceń to rzetelność przyjmowania leków zgodnie z zaleceniami, pokazana tu jako odsetek oczekiwanych dawek, które zarejestrowałeś. Regularne przyjmowanie często decyduje o tym, czy terapia działa zgodnie z zamierzeniem.",
"restingHrTitle": "Tętno spoczynkowe",
"restingHrBody": "Tętno spoczynkowe to częstość uderzeń serca w pełnym spoczynku, zwykle mierzona w nocy. Niższe tętno zwykle wskazuje na lepszą wydolność krążeniową, a rosnący trend może być wczesnym sygnałem stresu lub choroby.",
"hrvTitle": "Zmienność rytmu serca (HRV)",
"hrvBody": "HRV to drobna zmienność czasu między kolejnymi uderzeniami serca. Wyższa zmienność zwykle odzwierciedla dobrze zregenerowany, elastyczny układ nerwowy, a utrzymujący się spadek może sygnalizować zmęczenie, stres lub niewystarczającą regenerację.",
"oxygenSaturationTitle": "Saturacja tlenem (SpO₂)",
"oxygenSaturationBody": "SpO₂ to odsetek czerwonych krwinek przenoszących tlen. Wartości 95–100% są normalne w spoczynku; wartości utrzymujące się poniżej 90% wymagają uwagi, choć krótkie nocne spadki są częste.",
"bodyTemperatureTitle": "Temperatura ciała",
"bodyTemperatureBody": "Temperatura ciała odzwierciedla stan cieplny rdzenia, zwykle około 37 °C z niewielkimi dobowymi wahaniami. Utrzymujący się wzrost może wskazywać na infekcję, podczas gdy nocne pomiary na nadgarstku śledzą głównie zmiany względne, a nie wartość kliniczną.",
"activeEnergyTitle": "Energia aktywna",
"activeEnergyBody": "Energia aktywna to kalorie spalane przez ruch, ponad podstawowy wydatek w spoczynku. To bezpośrednia miara tego, jak aktywny fizycznie był Twój dzień.",
"workoutsTitle": "Treningi",
"workoutsBody": "Treningi to ustrukturyzowane sesje ćwiczeń rejestrowane przez Twoje urządzenia, wraz z typem, czasem trwania i energią. Ich śledzenie pokazuje, jak regularny trening kumuluje się przez tygodnie i jak wiąże się z innymi sygnałami zdrowotnymi.",
"respiratoryRateTitle": "Częstość oddechów",
"respiratoryRateBody": "Częstość oddechów to liczba wdechów na minutę. W spoczynku większość dorosłych oddycha około dwunastu do dwudziestu razy na minutę; utrzymujący się wzrost może towarzyszyć chorobie, wysiłkowi lub stresowi.",
"bodyWaterTitle": "Całkowita woda w organizmie",
"bodyWaterBody": "Całkowita woda w organizmie to udział płynów w masie ciała, wewnątrz i wokół komórek. Zmienia się wraz z nawodnieniem, posiłkami i aktywnością, więc trend w ciągu tygodni mówi więcej niż pojedynczy pomiar.",
"boneMassTitle": "Masa kostna",
"boneMassBody": "Masa kostna to szacowana waga minerałów w szkielecie. Pomiary z wagi są przybliżone, ale stabilny lub rosnący trend sprzyja długotrwałej wytrzymałości kości.",
"fatFreeMassTitle": "Masa beztłuszczowa",
"fatFreeMassBody": "Masa beztłuszczowa to wszystko w ciele, co nie jest tłuszczem — mięśnie, kości, narządy i woda. Utrzymanie lub jej budowanie przy spadku wagi zwykle wskazuje na zdrowszy skład ciała.",
"fatMassTitle": "Masa tłuszczowa",
"fatMassBody": "Masa tłuszczowa to łączna waga tłuszczu w ciele. Pewna ilość tłuszczu jest niezbędna dla hormonów i magazynowania energii, ale jego nadmiar — zwłaszcza w okolicy brzucha — zwiększa długoterminowe ryzyko zdrowotne.",
"muscleMassTitle": "Masa mięśniowa",
"muscleMassBody": "Masa mięśniowa to szacowana waga mięśni w ciele. Więcej mięśni wspiera siłę, metabolizm i sprawność, a ich utrzymanie z wiekiem zwykle wymaga aktywnego wysiłku.",
"visceralFatTitle": "Tłuszcz trzewny",
"visceralFatBody": "Tłuszcz trzewny to tłuszcz gromadzony głęboko w jamie brzusznej wokół narządów. W odróżnieniu od tłuszczu podskórnego jego wysoki poziom jest ściśle powiązany z chorobami serca i cukrzycą typu 2, więc im niżej, tym lepiej.",
"leanBodyMassTitle": "Beztłuszczowa masa ciała",
"leanBodyMassBody": "Beztłuszczowa masa ciała to całkowita waga pomniejszona o tłuszcz — mięśnie, kości, narządy i woda razem. Śledzenie jej obok wagi pokazuje, czy zmiany wynikają z mięśni, czy z tłuszczu.",
"flightsClimbedTitle": "Pokonane piętra",
"flightsClimbedBody": "Pokonane piętra liczą kondygnacje, które pokonujesz w górę każdego dnia, gdzie jedno piętro to około trzech metrów wzniesienia. To prosty wskaźnik codziennej aktywności i wysiłku nóg.",
"walkingDistanceTitle": "Dystans marszu i biegu",
"walkingDistanceBody": "Dystans marszu i biegu to droga, jaką pokonujesz pieszo każdego dnia. Uzupełnia liczbę kroków, pokazując, jak daleko się poruszałeś, a nie tylko jak często stawiałeś kroki.",
"walkingSteadinessTitle": "Stabilność chodu",
"walkingSteadinessBody": "Stabilność chodu szacuje, jak stabilny i zrównoważony jest twój sposób chodzenia, w skali od dobrej do niskiej. Niski poziom wskazuje na większe ryzyko upadku i warto omówić go z lekarzem.",
"walkingHeartRateTitle": "Tętno podczas chodzenia",
"walkingHeartRateBody": "Tętno podczas chodzenia to średni puls w trakcie równego marszu. Niższa wartość w czasie często odzwierciedla poprawę wydolności krążeniowej przy tym samym wysiłku.",
"walkingAsymmetryTitle": "Asymetria chodu",
"walkingAsymmetryBody": "Asymetria chodu to odsetek czasu, w którym kroki są nierówne między lewą a prawą nogą. Wyższy procent może wskazywać na ból, kontuzję lub problem z chodem wart sprawdzenia.",
"doubleSupportTimeTitle": "Czas podwójnego podparcia",
"doubleSupportTimeBody": "Czas podwójnego podparcia to udział każdego cyklu chodu, w którym obie stopy dotykają ziemi jednocześnie. Wyższy procent zwykle oznacza ostrożniejszy, mniej stabilny chód.",
"stepLengthTitle": "Długość kroku",
"stepLengthBody": "Długość kroku to średnia odległość od jednego postawienia stopy do następnego. Krótszy krok w czasie może odzwierciedlać zmęczenie, ostrożność lub zmianę sprawności ruchowej.",
"walkingSpeedTitle": "Prędkość chodu",
"walkingSpeedBody": "Prędkość chodu to tempo poruszania się po płaskim terenie. To dobrze zbadany wskaźnik ogólnego zdrowia i sprawności, a stałe lub rosnące tempo to dobry znak.",
"pulseWaveVelocityTitle": "Prędkość fali tętna",
"pulseWaveVelocityBody": "Prędkość fali tętna mierzy, jak szybko fala ciśnienia z każdego uderzenia serca przemieszcza się przez tętnice. Szybsza fala wskazuje na sztywniejsze tętnice, co wiąże się z wyższym ryzykiem sercowo-naczyniowym.",
"vascularAgeTitle": "Wiek naczyniowy",
"vascularAgeBody": "Wiek naczyniowy szacuje, jak stare zachowują się twoje tętnice w porównaniu z rzeczywistym wiekiem, na podstawie sztywności tętnic. Wiek naczyniowy niższy od rzeczywistego sugeruje zdrowsze naczynia krwionośne.",
"environmentalAudioTitle": "Poziom dźwięku otoczenia",
"environmentalAudioBody": "Poziom dźwięku otoczenia śledzi głośność otoczenia w decybelach. Długotrwała ekspozycja powyżej około 70 dB może z czasem uszkodzić słuch, więc niższe średnie dzienne są bezpieczniejsze.",
"headphoneAudioTitle": "Poziom dźwięku w słuchawkach",
"headphoneAudioBody": "Poziom dźwięku w słuchawkach śledzi w decybelach, jak głośno słuchasz przez słuchawki. Ściszanie głośności i robienie przerw chroni słuch w dłuższej perspektywie.",
"audioEventsTitle": "Zdarzenia głośnego dźwięku",
"audioEventsBody": "Zdarzenia głośnego dźwięku oznaczają momenty, gdy hałas wokół ciebie osiągnął poziom mogący przy wystarczającej ekspozycji uszkodzić słuch. Częste zdarzenia to sygnał, by poszukać cichszego otoczenia lub ochrony słuchu.",
"daylightTitle": "Czas w świetle dziennym",
"daylightBody": "Czas w świetle dziennym to ilość czasu spędzanego w świetle na zewnątrz każdego dnia. Regularne światło dzienne wspiera zdrowy rytm snu i czuwania oraz nastrój, a czas na świeżym powietrzu wiąże się ze zdrowiem oczu.",
"bloodGlucoseTitle": "Poziom glukozy we krwi",
"bloodGlucoseBody": "Poziom glukozy we krwi to ilość cukru we krwi, głównego źródła energii organizmu. Rośnie po jedzeniu i ustala się między posiłkami; wartości utrzymujące się wysoko mogą sygnalizować cukrzycę lub stan przedcukrzycowy.",
"skinTemperatureTitle": "Temperatura skóry",
"skinTemperatureBody": "Temperatura skóry to tutaj niewielka nocna zmiana temperatury nadgarstka względem twojej własnej wartości bazowej, a nie bezwzględny pomiar gorączki. Zmiany mogą odzwierciedlać sen, otoczenie, chorobę lub fazę cyklu menstruacyjnego.",
"cardioFitnessTitle": "Wydolność krążeniowa (VO₂ max)",
"cardioFitnessBody": "VO₂ max to ilość tlenu, jaką organizm potrafi wykorzystać przy maksymalnym wysiłku. To najlepszy laboratoryjny wskaźnik wydolności tlenowej, a wyższa lub rosnąca wartość świadczy o dobrym zdrowiu układu krążenia.",
"cardioRecoveryTitle": "Regeneracja serca",
"cardioRecoveryBody": "Regeneracja serca to liczba uderzeń na minutę, o jaką spada tętno w pierwszej minucie po zakończeniu intensywnego wysiłku. Większy spadek oznacza, że serce szybciej się uspokaja, co świadczy o dobrej kondycji.",
"wristTemperatureTitle": "Temperatura nadgarstka",
"wristTemperatureBody": "Temperatura nadgarstka jest mierzona podczas snu. Apple pokazuje ją jako różnicę względem Twojej wartości bazowej; stabilny odczyt jest normalny, a utrzymujący się wzrost może wskazywać na chorobę, intensywny trening lub zmianę cyklu.",
"fallsTitle": "Upadki",
"fallsBody": "Tutaj liczone są poważne upadki wykryte przez Apple Watch. Celem jest zero; rosnąca liczba to sygnał ryzyka związanego z mobilnością, który warto omówić z lekarzem, zwłaszcza u osób starszych.",
"sixMinuteWalkTitle": "Dystans marszu 6-minutowego",
"sixMinuteWalkBody": "To szacunek Apple, jak daleko mógłbyś przejść w sześć minut — klasyczny test kliniczny mobilności i wydolności krążeniowo-oddechowej. Stały lub rosnący dystans to dobry znak.",
"stairAscentSpeedTitle": "Prędkość wchodzenia po schodach",
"stairAscentSpeedBody": "To prędkość, z jaką pokonujesz bieg schodów. To czuły wskaźnik siły nóg i mobilności — stałe lub rosnące tempo to dobry znak, a spadające warto obserwować.",
"stairDescentSpeedTitle": "Prędkość schodzenia po schodach",
"stairDescentSpeedBody": "To prędkość, z jaką schodzisz bieg schodów. Razem z prędkością wchodzenia odzwierciedla kontrolę nóg i równowagę — stałe tempo to dobry znak, a spadające warto obserwować.",
"breathingDisturbancesTitle": "Zaburzenia oddychania we śnie",
"breathingDisturbancesBody": "Twój Apple Watch potrafi oszacować, jak często Twój oddech był przerywany podczas snu. Niższy wskaźnik jest lepszy; poziom, który Apple oznacza jako podwyższony — zwłaszcza przez wiele nocy — warto omówić z lekarzem."
},
"stats": {
"label": "Statystyki podsumowujące",
"min": "Min",
"max": "Maks",
"median": "Mediana",
"mean": "Średnia"
},
"showAllValues": "Pokaż wszystkie pomiary",
"valuesPageTitle": "{metric} — wszystkie pomiary",
"valuesPageDescription": "Każdy pomiar stojący za tą metryką. Edytuj lub usuń wpis bezpośrednio.",
"diversity": {
"trigger": "Wskazówka pomiarowa",
"weekday": "Większość Twoich pomiarów {metric} przypada na jeden dzień tygodnia. Pomiary w inne dni dają pełniejszy obraz.",
"timeOfDay": "Większość Twoich pomiarów {metric} przypada o podobnej porze dnia. Różne pory dają pełniejszy obraz."
},
"valuesBack": "Powrót do Insights",
"valuesBackToMetric": "Powrót do {metric}",
"scoresBack": "Powrót do Insights"
},
"sleep": {
"title": "Sen",
@@ -2181,7 +2430,312 @@
"description": "Odczyty pojawią się tutaj po synchronizacji Apple Health."
}
},
"navWalkingSpeed": "Prędkość chodu"
"navWalkingSpeed": "Prędkość chodu",
"mood": {
"heatmapTitle": "Kalendarz nastroju",
"heatmapNoEntry": "Brak wpisu",
"legendGreat": "Świetnie",
"legendGood": "Dobrze",
"legendOkay": "W porządku",
"legendLow": "Nisko",
"distributionTitle": "Rozkład nastroju",
"distributionDaysUnit": "dni",
"weekdayTitle": "Średnia wg dnia tygodnia",
"weekdayNoData": "Brak danych",
"weekdayBestWorst": "Najlepszy dzień: {best} · Najtrudniejszy dzień: {worst}",
"tagsTitle": "Tagi i wyzwalacze",
"tagAvgMood": "Średni nastrój z tym tagiem",
"correlationsTitle": "Nastrój a inne wskaźniki",
"correlationsDescription": "Jak Twój nastrój zmienia się wraz ze snem, aktywnością i tętnem tego samego dnia.",
"correlation": {
"sleepTitle": "Nastrój a sen",
"stepsTitle": "Nastrój a aktywność",
"pulseTitle": "Nastrój a tętno",
"sleepAxis": "Sen (min)",
"stepsAxis": "Kroki",
"pulseAxis": "Tętno (bpm)",
"strengthStrong": "Silna",
"strengthModerate": "Umiarkowana",
"strengthWeak": "Słaba",
"strengthNone": "Brak",
"source": "{n} sparowanych dni · r = {r}",
"emptyTitle": "Za mało sparowanych danych",
"emptyDescription": "Rejestruj nastrój i ten wskaźnik w te same dni, aby zobaczyć korelację.",
"weightTitle": "Nastrój vs. waga",
"bloodPressureTitle": "Nastrój vs. ciśnienie krwi",
"weightAxis": "Waga",
"bloodPressureAxis": "Skurczowe (mmHg)"
},
"structuredTagsTitle": "Podział tagów",
"inTargetValue": "{pct}%",
"inTargetCaption": "ostatnich dni w zakresie dobrego nastroju",
"narrative": {
"title": "Co się wyróżnia",
"weekdayDip": "Twój nastrój najbardziej spada w {weekday} — {delta} poniżej twojego tygodnia.",
"weekdayPeak": "{weekday} to zawsze twój najlepszy dzień, średnio {value}.",
"trendUp": "Twój nastrój rośnie w ciągu ostatnich {days} dni (+{delta} względem poprzedniego okresu).",
"trendDown": "Twój nastrój spada w ciągu ostatnich {days} dni (-{delta} względem poprzedniego okresu).",
"weekendUp": "W weekendy czujesz się lepiej (+{delta} względem dni roboczych).",
"weekendDown": "Twój nastrój jest niższy w weekendy (-{delta} względem dni roboczych).",
"tagLift": "Dni z tagiem „{tag}” są średnio o {delta} powyżej twojego ogólnego nastroju.",
"tagDrop": "Dni z tagiem „{tag}” są o {delta} poniżej twojej średniej.",
"inTarget": "Przez {pct}% ostatnich 30 dni byłeś w zakresie dobrego nastroju.",
"streak": "Rejestrujesz nastrój od {days} dni z rzędu — tak trzymaj.",
"timeOfDay": "Najlepiej czujesz się {bucket}, średnio {value}."
},
"timeOfDay": {
"title": "Nastrój według pory dnia",
"morning": "Rano",
"afternoon": "Popołudnie",
"evening": "Wieczór",
"night": "Noc",
"bestCaption": "Zwykle najlepiej czujesz się {bucket}."
},
"stability": {
"value": "{score}/100",
"bandVerySteady": "Twój nastrój był bardzo stabilny",
"bandSteady": "Twój nastrój był dość stabilny",
"bandVariable": "Twój nastrój był zauważalnie zmienny",
"bandVeryVariable": "Twój nastrój ostatnio mocno się wahał"
}
},
"derived": {
"coverage": {
"summary": "{present} z {required} danych · {percent}%",
"ratioLabel": "{present}/{required}",
"historyDays": "Na podstawie {count} dni historii",
"missing": "Zapisuj te dane, aby uściślić wartość: {list}"
},
"scoreRing": {
"aria": "Wynik {score} na 100, {band}",
"ariaProvisional": "Wynik jeszcze niedostępny — za mało danych",
"provisionalCaption": "Wciąż za mało danych",
"band": {
"green": "dobry",
"yellow": "średni",
"red": "niski"
}
},
"provenance": {
"title": "Jak to jest obliczane",
"trigger": "Jak to jest obliczane",
"inputsLabel": "Dane wejściowe",
"noInputs": "Żadne dane nie potwierdzają jeszcze tej wartości.",
"methodLabel": "Metoda",
"window": "Z ostatnich {days} dni · dane {source}",
"asOf": "na dzień {date}",
"standardLink": "Standard: {name}"
},
"vitals": {
"sectionTitle": "Twoje parametry życiowe",
"loadingLabel": "Wczytywanie parametrów życiowych",
"loadError": "Nie udało się teraz wczytać Twoich parametrów życiowych.",
"building": "Budowanie Twojego typowego zakresu — {count} z {target} dni",
"typicalRange": "Typowe dla Ciebie: {low}–{high}",
"fitnessYounger": "Kondycja jak u osoby ~{years} lat młodszej",
"fitnessOlder": "Kondycja ~{years} lat powyżej Twojego wieku",
"fitnessTypical": "Kondycja typowa dla Twojego wieku",
"fitnessNoNorm": "Dodaj wiek i płeć, aby to ująć w kontekście",
"vascularBelow": "{years} lat poniżej Twojego wieku",
"vascularAbove": "{years} lat powyżej Twojego wieku",
"vascularMatch": "Zgodnie z Twoim wiekiem",
"vascularNoAge": "Dodaj datę urodzenia, aby to ująć w kontekście",
"hrvBand": {
"balanced": "Zrównoważone dla Ciebie",
"unbalanced": "Powyżej Twojego zwykłego zakresu",
"low": "Poniżej Twojego zwykłego zakresu"
},
"bmiCategory": {
"underweight": "Niedowaga (WHO)",
"normal": "Zakres prawidłowy (WHO)",
"overweight": "Nadwaga (WHO)",
"obese": "Otyłość (WHO)"
}
},
"anatomy": {
"outOf": "/100",
"contributorsLabel": "Czynniki",
"contributorValue": "{value} · {weight}% wagi",
"contributorMissing": "brak danych",
"disclaimer": "Orientacyjne — to nie ocena kliniczna.",
"insufficient": "Za mało danych, by to ocenić. Rejestruj dalej, a wynik pojawi się tutaj."
},
"composite": {
"SLEEP_SCORE": {
"title": "Wynik snu",
"description": "Przejrzysty wynik jakości snu z każdym podwynikiem.",
"method": "Mieszanka 0–100 z wystarczalności (sen vs zapotrzebowanie według wieku), wydajności (sen ÷ czas w łóżku), regularności (jak stabilny jest środek twojego snu), pory (vs twój zwykły środek snu) i struktury (udział REM + snu głębokiego). Podwyniki są ważone na nowo wokół tych, których dane nie wspierają; nic nie jest zmyślane.",
"sub": {
"sufficiency": "Wystarczalność",
"efficiency": "Wydajność",
"consistency": "Regularność",
"timing": "Pora",
"composition": "Struktura"
}
},
"READINESS": {
"title": "Gotowość",
"description": "Codzienny wskaźnik samopoczucia oparty na tym, jak twoje sygnały wypadają względem twojej własnej bazy.",
"method": "Mieszanka 0–100 z odchylenia tętna spoczynkowego, odchylenia HRV (SDNN), jakości snu, stabilności częstości oddechu i stabilności nastroju — każde mierzone względem twojej osobistej bazy. Wagi są renormalizowane na komponentach wspieranych przez twoje dane. Wskaźnik samopoczucia, nie wynik regeneracji ani kliniczny.",
"component": {
"rhr": "Tętno spoczynkowe",
"hrv": "HRV (SDNN)",
"sleep": "Sen",
"respiratory": "Częstość oddechu",
"mood": "Stabilność nastroju"
},
"caveat": "Wskaźnik dobrostanu, nie ocena regeneracji ani wynik kliniczny."
},
"VITALS_BASELINE": {
"method": "Twój osobisty typowy zakres to mediana Twoich dziennych wartości w danym okresie ± odporne rozproszenie (skalowane medianowe odchylenie bezwzględne). Śledzi TWOJE odczyty, a nie normę populacyjną — wartość w obrębie pasma jest więc typowa dla Ciebie."
},
"HRV_BALANCE": {
"method": "Twoja niedawna średnia HRV (SDNN) na tle Twojej własnej ruchomej linii bazowej (mediana ± odporne rozproszenie). Zrównoważone oznacza, że mieści się w Twoim osobistym zakresie; pasmo budowane jest z Twoich danych, nigdy z progu populacyjnego."
},
"BMI": {
"method": "Masa ÷ wzrost², sklasyfikowane według kategorii WHO dla dorosłych (niedowaga / norma / nadwaga / otyłość). To tylko wskaźnik przesiewowy — nie odróżnia mięśni od tłuszczu."
},
"FITNESS_AGE": {
"method": "Twoje VO₂max odniesione do percentyli wydolności krążeniowo-oddechowej według wieku i płci, wyrażone jako różnica wieku sprawnościowego. VO₂max to szacunek Twojego urządzenia — tutaj nigdy nie jest przeliczany ponownie."
},
"VASCULAR_AGE_DELTA": {
"method": "Szacunek wieku naczyniowego / prędkości fali tętna z Twojego urządzenia porównany z Twoim wiekiem metrykalnym, wyrażony jako różnica w latach. Szacunek pochodzi z urządzenia; HealthLog jedynie go przedstawia.",
"caveat": "Własny szacunek Twojego urządzenia, jedynie przedstawiony — opisowy, nie kliniczna ocena naczyniowa.",
"description": "Jak szacunek wieku naczyniowego Twojego urządzenia wypada na tle Twojego wieku metrykalnego."
},
"COINCIDENT_DEVIATION": {
"method": "Sygnał pojawiający się, gdy co najmniej dwa Twoje parametry życiowe wykraczają tego samego dnia poza swój osobisty typowy zakres. Każde pasmo to odporna mediana ± k·MAD Twojej historii, więc dostosowuje się do Ciebie, a nie do progu populacyjnego.",
"caveat": "Sygnał z Twoich własnych linii bazowych — opisowy, nie diagnoza ani alarm kliniczny."
},
"RECOVERY_SCORE": {
"method": "Nocny wskaźnik regeneracji 0–100 łączący Twoje HRV, tętno spoczynkowe i sen względem Twojej linii bazowej. Wyższy oznacza lepszą regenerację. Liczony raz na noc i tu tylko odczytywany — nigdy nieprzeliczany na bieżąco.",
"caveat": "Opisowy wskaźnik dobrostanu, nie kliniczna ani treningowa miara regeneracji.",
"description": "Nocny wskaźnik regeneracji z Twojego HRV, tętna spoczynkowego i snu."
},
"STRESS_SCORE": {
"method": "Nocny wskaźnik stresu 0–100 na podstawie kształtu Twojego HRV w ciągu dnia względem linii bazowej. Wyższy oznacza większe obciążenie fizjologiczne.",
"caveat": "Wskaźnik pochodzący z HRV, a NIE pomiar stresu metodą EDA / reakcji skórno-galwanicznej — Twój zegarek nie ma czujnika EDA. Opisowy, nie kliniczny.",
"description": "Nocny wskaźnik stresu na podstawie kształtu Twojego HRV w ciągu dnia."
},
"STRAIN_SCORE": {
"method": "Nocny wskaźnik obciążenia 0–100 na podstawie Twojego obciążenia aktywnością ważonego tętnem (model impulsu treningowego typu TRIMP). Wyższy oznacza cięższy dzień.",
"caveat": "Opisowy wskaźnik obciążenia aktywnością, nie kliniczna ani treningowa miara obciążenia.",
"description": "Nocny wskaźnik obciążenia aktywnością z Twojego wysiłku ważonego tętnem."
}
},
"scores": {
"sectionTitle": "Wyniki dobrostanu",
"recovery": "Regeneracja",
"stress": "Stres",
"strain": "Obciążenie"
},
"coincident": {
"label": "Kilka parametrów poza bazą",
"summary": "Dziś {count} Twoich parametrów życiowych znalazło się poza osobistym zakresem.",
"vitals": "Poza zakresem: {list}"
}
},
"cardioFitness": {
"title": "Wydolność",
"description": "Twoje VO₂ max w czasie — główna miara wydolności tlenowej.",
"chartTitle": "VO₂ max (mL/(kg·min))",
"emptyState": {
"title": "Brak danych o wydolności",
"description": "Wartości VO₂ max pojawią się tutaj, gdy Apple Health zsynchronizuje je z Twoich treningów."
}
},
"navCardioFitness": "Wydolność",
"cardioRecovery": {
"title": "Regeneracja serca",
"description": "O ile spada tętno minutę po szczytowym wysiłku.",
"chartTitle": "Regeneracja serca (bpm)",
"emptyState": {
"title": "Brak danych o regeneracji serca",
"description": "Wartości regeneracji serca pojawią się tutaj, gdy Apple Health zsynchronizuje je z treningu."
}
},
"navCardioRecovery": "Regeneracja serca",
"wristTemperature": {
"title": "Temperatura nadgarstka",
"description": "Nocna temperatura nadgarstka, odniesiona do Twojej wartości bazowej.",
"chartTitle": "Temperatura nadgarstka (°C)",
"emptyState": {
"title": "Brak danych o temperaturze nadgarstka",
"description": "Wartości pojawią się tutaj, gdy Apple Health zsynchronizuje nocne noszenie."
}
},
"navWristTemperature": "Temp. nadgarstka",
"falls": {
"title": "Upadki",
"description": "Poważne upadki wykryte przez Apple Watch, liczone na dzień.",
"chartTitle": "Upadki na dzień",
"emptyState": {
"title": "Brak danych o upadkach",
"description": "Wykryte upadki pojawią się tutaj, gdy Apple Health się zsynchronizuje. Celem jest brak upadków."
}
},
"navFalls": "Upadki",
"sixMinuteWalk": {
"title": "Marsz 6-minutowy",
"description": "Szacunek Apple, jak daleko możesz przejść w sześć minut.",
"chartTitle": "Dystans w 6 minut (m)",
"emptyState": {
"title": "Brak danych o marszu 6-minutowym",
"description": "Szacunki pojawią się tutaj, gdy Apple Health się zsynchronizuje."
}
},
"navSixMinuteWalk": "Marsz 6 min",
"stairAscentSpeed": {
"title": "Prędkość wchodzenia po schodach",
"description": "Jak szybko wchodzisz po schodach, mierzone przez iPhone'a lub Watch.",
"chartTitle": "Prędkość wchodzenia (m/s)",
"emptyState": {
"title": "Brak danych o wchodzeniu po schodach",
"description": "Wartości pojawią się tutaj, gdy Apple Health się zsynchronizuje."
}
},
"navStairAscentSpeed": "Wchodzenie",
"stairDescentSpeed": {
"title": "Prędkość schodzenia po schodach",
"description": "Jak szybko schodzisz po schodach, mierzone przez iPhone'a lub Watch.",
"chartTitle": "Prędkość schodzenia (m/s)",
"emptyState": {
"title": "Brak danych o schodzeniu po schodach",
"description": "Wartości pojawią się tutaj, gdy Apple Health się zsynchronizuje."
}
},
"navStairDescentSpeed": "Schodzenie",
"breathingDisturbances": {
"title": "Zaburzenia oddychania",
"description": "Jak często Twój oddech był zakłócany podczas snu.",
"chartTitle": "Zaburzenia oddychania we śnie",
"emptyState": {
"title": "Brak danych o zaburzeniach oddychania",
"description": "Wartości pojawią się tutaj, gdy Apple Health zsynchronizuje je z obsługiwanego Apple Watch."
}
},
"navBreathingDisturbances": "Oddychanie",
"rhythmEvents": {
"sectionTitle": "Powiadomienia zdrowotne urządzenia",
"sectionIntro": "Powiadomienia, które zegarek wysłał samodzielnie. HealthLog jedynie je wyświetla — nie wykrywa ich, nie potwierdza ani nie interpretuje.",
"loadError": "Nie udało się wczytać powiadomień urządzenia.",
"disclaimer": "To alerty wygenerowane przez Twoje urządzenie za pomocą jego wbudowanego wykrywania. Są tu pokazywane wyłącznie dla Twojej informacji. To nie jest ocena medyczna wykonana przez HealthLog, a HealthLog nie diagnozuje żadnych schorzeń. Jeśli powiadomienie Cię niepokoi, skontaktuj się z lekarzem.",
"event": {
"irregularRhythm": "Irregular rhythm notification",
"highHeartRate": "High heart rate notification",
"lowHeartRate": "Low heart rate notification",
"walkingSteadiness": "Walking steadiness alert",
"breathingDisturbance": "Breathing disturbance during sleep"
},
"verdict": {
"irregular": "Your device flagged a possible irregular rhythm.",
"notDetected": "Your device did not flag an irregular rhythm.",
"inconclusive": "Your device could not make a reading.",
"low": "Your device flagged low walking steadiness.",
"veryLow": "Your device flagged very low walking steadiness.",
"fired": "Your device flagged this event."
}
}
},
"thresholds": {
"defaultLabel": "Domyślne",
@@ -2393,7 +2947,7 @@
"description": "Withings, moodLog, and other connected services."
},
"notifications": {
"title": "Kanały powiadomień",
"title": "Powiadomienia",
"description": "Live overview of every configured channel."
},
"dashboard": {
@@ -2401,63 +2955,62 @@
"description": "Tile layout and order."
},
"thresholds": {
"title": "Cele i źródła",
"description": "Dla każdej metryki: własny zakres docelowy i które źródło wygrywa, gdy kilka rejestruje tę samą wartość tego samego dnia."
"title": "Cele",
"description": "Dla każdej metryki: własny zakres docelowy zdrowych wartości."
},
"sources": {
"title": "Sources",
"description": "Pick which ingest source wins when more than one records the same metric for the same day.",
"redirectNotice": "Źródła są teraz częścią „Cele i źródła”.",
"cardTitle": "Source priority by metric",
"help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.",
"resetDefaults": "Reset to defaults",
"moveUp": "Move up",
"moveDown": "Move down",
"saveSuccess": "Source priority saved.",
"saveError": "Saving the source priority failed.",
"resetSuccess": "Source priority reset to defaults.",
"perMetricToggle": "Per-metric overrides ({count})",
"perMetricHelp": "Metrics you have customised away from the default ladder. Use this list to roll any single metric back to its constant default without touching the rest.",
"perMetricEmpty": "No per-metric overrides set — every metric uses the global ladder above.",
"title": "Źródła",
"description": "Wybierz, które źródło wygrywa, gdy kilka rejestruje tę samą metrykę tego samego dnia.",
"cardTitle": "Priorytet źródeł według metryki",
"help": "Gdy więcej niż jedno źródło dostarcza tę samą wartość tego samego dnia (np. Withings i Apple Health raportują po 1200 kroków), źródło na górze liczy się jako odczyt kanoniczny. Pozostałe źródła pozostają zapisane na potrzeby ścieżki audytu.",
"resetDefaults": "Przywróć wartości domyślne",
"moveUp": "W górę",
"moveDown": "W dół",
"saveSuccess": "Zapisano priorytet źródeł.",
"saveError": "Zapisanie priorytetu źródeł nie powiodło się.",
"resetSuccess": "Przywrócono domyślny priorytet źródeł.",
"perMetricToggle": "Ustawienia dla poszczególnych metryk ({count})",
"perMetricHelp": "Metryki dostosowane względem domyślnej kolejności. Użyj tej listy, aby przywrócić pojedynczą metrykę do wartości domyślnej, nie zmieniając pozostałych.",
"perMetricEmpty": "Brak indywidualnych ustawień — każda metryka korzysta z globalnej kolejności powyżej.",
"resetMetric": "Resetuj",
"deviceTypeToggle": "Device-type priority ({count})",
"deviceTypeHelp": "Within a source, which device wins. When an Apple Watch and an iPhone both record steps, the higher-ranked device-type counts as the canonical reading.",
"deviceTypeDefault": "Default device-type ladder",
"resetDeviceTypes": "Reset device-types",
"deviceTypeToggle": "Priorytet według typu urządzenia ({count})",
"deviceTypeHelp": "W obrębie źródła decyduje, które urządzenie wygrywa. Gdy Apple Watch i iPhone rejestrują kroki, wyżej sklasyfikowany typ urządzenia liczy się jako odczyt kanoniczny.",
"deviceTypeDefault": "Domyślna kolejność typów urządzeń",
"resetDeviceTypes": "Resetuj typy urządzeń",
"metrics": {
"steps": "Kroki",
"activeEnergy": "Active energy",
"walkingRunningDistance": "Walking + running distance",
"flightsClimbed": "Flights climbed",
"activeEnergy": "Aktywna energia",
"walkingRunningDistance": "Dystans marszu + biegu",
"flightsClimbed": "Pokonane piętra",
"sleep": "Sen",
"weight": "Waga",
"bloodPressure": "Ciśnienie krwi",
"pulse": "Tętno",
"bodyFat": "Tkanka tłuszczowa",
"bodyTemperature": "Body temperature",
"spo2": "Oxygen saturation",
"hrv": "Heart-rate variability",
"bodyTemperature": "Temperatura ciała",
"spo2": "Saturacja tlenem",
"hrv": "Zmienność rytmu serca",
"restingHeartRate": "Tętno spoczynkowe",
"vo2Max": "VO2 max"
},
"sourceLabels": {
"WITHINGS": "Withings",
"APPLE_HEALTH": "Apple Health",
"MANUAL": "Manual entry",
"MANUAL": "Wpis ręczny",
"IMPORT": "Importuj"
},
"deviceLabels": {
"watch": "Watch",
"band": "Band",
"ring": "Ring",
"phone": "Phone",
"scale": "Scale",
"other": "Other",
"watch": "Zegarek",
"band": "Opaska",
"ring": "Pierścień",
"phone": "Telefon",
"scale": "Waga",
"other": "Inne",
"unknown": "Nieznane"
}
},
"ai": {
"title": "Insights",
"title": "AI Insights",
"description": "Provider, model, key."
},
"api": {
@@ -2706,6 +3259,11 @@
"testProvider": "Test active provider",
"testSuccess": "OK — {provider} ({model})",
"testFailedShort": "Test failed: {message}",
"testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.",
"testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.",
"testReasonRateLimited": "Provider rate-limited the request — try again shortly.",
"testReasonServerError": "The AI provider returned a server error.",
"testReasonUnreachable": "Could not reach the AI provider.",
"disableCoach": {
"title": "Ukryj Coacha",
"description": "Ukrywa przycisk Coacha i panel na wszystkich stronach.",
@@ -2939,7 +3497,9 @@
"fhirNote": "Dane ustrukturyzowane są zgodne ze standardem FHIR R4 (kompatybilne z ePA).",
"generate": "Generuj",
"error": "Eksport nie powiódł się ({code})."
}
},
"globalExcludedInjectionSitesLabel": "Globalnie wykluczone miejsca iniekcji",
"globalExcludedInjectionSitesHint": "Miejsca wymienione tutaj nie są proponowane dla żadnego leku i są odrzucane przy rejestrowaniu dawki, nawet jeśli lek je preferuje."
},
"admin": {
"title": "Administration",
@@ -3760,8 +4320,6 @@
}
},
"targets": {
"title": "Wartości docelowe",
"introText": "Twoje aktualne wartości na tle zalecanych zakresów docelowych. Źródła wytycznych podlinkowane na każdej karcie.",
"bmi": "BMI",
"average30d": "Średnia 30-dniowa:",
"consistency": {
@@ -3783,23 +4341,11 @@
"sixDaysAgo": "6 dni temu"
}
},
"card": {
"lastMet": "Ostatnio w celu: {when}",
"streak": "{count} dni z rzędu"
},
"relativeDay": {
"today": "Dzisiaj",
"yesterday": "Wczoraj",
"daysAgo": "{count} dni temu"
},
"summary": {
"weekTitle": "{met} z {total} celów osiągniętych w tym tygodniu",
"weekTitleAll": "Wszystko{total} targets met this week",
"streakHighlight": "{count} dni z rzędu: {metric}"
},
"coach": {
"cta": "Zapytaj coacha"
},
"edit": {
"openLabel": "Edytuj zakres docelowy dla {metric}",
"title": "Edytuj zakres docelowy — {metric}",
@@ -3831,31 +4377,10 @@
"aboveTarget": "{delta} {unit} powyżej zakresu",
"belowTarget": "{delta} {unit} poniżej zakresu",
"diastolic": "Rozkurczowe",
"noMeasurementYet": "Brak pomiarów",
"noMeasurementData": "Brak danych pomiarowych. Dodaj pomiary, aby zobaczyć cele.",
"profileIncomplete": "Profil niekompletny",
"profileIncompleteHeight": "Wpisz swój wzrost w profilu, aby aktywować cele BMI.",
"profileIncompleteAge": "Wpisz datę urodzenia w profilu, aby aktywować cele zależne od wieku.",
"profileIncompleteHeightAge": "Wpisz wzrost i datę urodzenia w profilu, aby zobaczyć spersonalizowane cele.",
"compliancePerMedication": "Przestrzeganie według leku",
"glucoseFasting": "Glikemia — na czczo",
"glucosePostprandial": "Glikemia — po posiłku",
"glucoseRandom": "Glikemia — dowolna",
"glucoseBedtime": "Glikemia — przed snem",
"loadError": "Nie udało się załadować wartości docelowych",
"label": {
"WEIGHT": "Waga",
"BLOOD_PRESSURE": "Ciśnienie krwi",
"BLOOD_PRESSURE_IN_TARGET": "Ciśnienie w zakresie",
"PULSE": "Tętno spoczynkowe",
"SLEEP_DURATION": "Czas snu",
"BMI": "BMI",
"BODY_FAT": "Tkanka tłuszczowa",
"ACTIVITY_STEPS": "Kroki/dzień",
"MEDICATION_COMPLIANCE": "Przestrzeganie",
"MOOD_SCORE": "Nastrój",
"MOOD_STABILITY": "Stabilność nastroju"
},
"status": {
"underweight": "Niedowaga",
"normal": "Normal",
+59
View File
@@ -53,6 +53,65 @@ const nextConfig: NextConfig = {
// from memory instead of paying a full reload. See
// `src/lib/http/cache-headers.ts` for the typed constant reused by
// route handlers that opt into the same posture.
// v1.8.0 — the routed Insights sub-pages migrated from German to
// English slugs (`/insights/blutdruck` → `/insights/blood-pressure`,
// …). Every legacy German URL 301-redirects to its English target so
// bookmarks, the PWA's cached navigation, and any external link keep
// resolving. The redirect set is exhaustive and matches the rename
// table in `docs/adr/0001-insights-naming-convention.md`; the slug
// registry itself lives in `src/lib/insights/sub-page-metric.ts`.
// `bmi`, `hrv`, and `workouts` were already English and need no entry.
async redirects() {
const insightsSlugRenames: Array<[string, string]> = [
["blutdruck", "blood-pressure"],
["puls", "pulse"],
["sauerstoff", "oxygen"],
["koerpertemperatur", "body-temperature"],
["atemfrequenz", "respiratory-rate"],
["gewicht", "weight"],
["koerperwasser", "body-water"],
["knochenmasse", "bone-mass"],
["fettfreie-masse", "fat-free-mass"],
["fettmasse", "fat-mass"],
["muskelmasse", "muscle-mass"],
["viszeralfett", "visceral-fat"],
["magermasse", "lean-body-mass"],
["aktive-energie", "active-energy"],
["stockwerke", "flights-climbed"],
["gehstrecke", "walking-distance"],
["gangstabilitaet", "walking-steadiness"],
["gehpuls", "walking-heart-rate"],
["gangasymmetrie", "walking-asymmetry"],
["doppelstandphase", "double-support-time"],
["schrittlaenge", "step-length"],
["gehgeschwindigkeit", "walking-speed"],
["schlaf", "sleep"],
["ruhepuls", "resting-pulse"],
["pulswellengeschwindigkeit", "pulse-wave-velocity"],
["gefaessalter", "vascular-age"],
["laermbelastung", "environmental-audio"],
["kopfhoererpegel", "headphone-audio"],
["laermereignisse", "audio-events"],
["tageslicht", "daylight"],
["blutzucker", "blood-glucose"],
["hauttemperatur", "skin-temperature"],
["stimmung", "mood"],
["medikamente", "medications"],
];
return insightsSlugRenames.flatMap(([from, to]) => [
{
source: `/insights/${from}`,
destination: `/insights/${to}`,
permanent: true,
},
// Preserve any nested path (e.g. a future `/insights/sleep/2026-05-31`).
{
source: `/insights/${from}/:path*`,
destination: `/insights/${to}/:path*`,
permanent: true,
},
]);
},
async headers() {
return [
{
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.7.2",
"version": "1.10.2",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
@@ -0,0 +1,20 @@
-- 0098_app_settings_global_toggles_drift_fix
-- Four `app_settings` columns have been on `prisma/schema.prisma` since the
-- per-channel global notification toggles + admin default-locale work but
-- never landed in the migration history (applied via `prisma db push` to
-- existing environments but not committed as a migration). The drift is
-- invisible on a database kept in sync via `db push`, but a database built
-- from migrations alone is missing the columns — and the admin settings
-- read path (`/api/admin/settings`) selects `default_locale`, so a fresh
-- deploy reports `column "default_locale" does not exist` on first read.
--
-- Same class of drift as 0025_user_locale_drift_fix. Each add uses
-- `IF NOT EXISTS` with the schema default, so the migration is a safe no-op
-- against any environment already carrying the columns and a clean add
-- against one built strictly from this migrations directory (CI test
-- containers, fresh deploys, demo seeds).
ALTER TABLE "app_settings"
ADD COLUMN IF NOT EXISTS "default_locale" TEXT NOT NULL DEFAULT 'de',
ADD COLUMN IF NOT EXISTS "telegram_global" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN IF NOT EXISTS "ntfy_global" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN IF NOT EXISTS "web_push_global" BOOLEAN NOT NULL DEFAULT true;
@@ -0,0 +1,34 @@
-- v1.8.5 — per-medication injection-site tracking.
--
-- Makes injection-site tracking + rotation real. Prior to this release
-- the `injection_site` column on `medication_intake_events` was read in
-- several surfaces but never written; tracking was effectively dead.
--
-- Two additive, defaulted columns on `medications`:
--
-- - `track_injection_sites` — per-medication opt-in. Only meaningful
-- (and only offered in the UI) when `delivery_form = 'INJECTION'`.
-- Default false: the post-dose site prompt fires only when this is
-- true, and the prompt is always skippable. Deactivatable any time.
--
-- - `allowed_injection_sites` — per-medication allowed / preferred
-- sites. Empty = no per-medication restriction (every site offered).
-- The effective pickable set is this list MINUS the user-level
-- `users.global_excluded_injection_sites` deny-list (deny wins). The
-- intake write path validates a submitted site against the effective
-- set and rejects (422) a disallowed value.
--
-- Both columns are additive + defaulted; no backfill is required. The
-- `injection_site` enum type already exists (migration 0046).
--
-- Idempotent guards (`IF NOT EXISTS`) make reruns safe.
--
-- Reversibility:
-- ALTER TABLE "medications" DROP COLUMN IF EXISTS "track_injection_sites";
-- ALTER TABLE "medications" DROP COLUMN IF EXISTS "allowed_injection_sites";
-- A roll-back loses the opt-in flag + the per-medication site list; the
-- application reads tolerate a missing column / default-empty value.
ALTER TABLE "medications"
ADD COLUMN IF NOT EXISTS "track_injection_sites" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS "allowed_injection_sites" "injection_site"[] NOT NULL DEFAULT ARRAY[]::"injection_site"[];
@@ -0,0 +1,30 @@
-- v1.8.5 — user-level global injection-site exclusion.
--
-- A per-user deny-list of injection sites. Sites listed here are never
-- offered for ANY medication's injection-site picker, and the intake
-- write path rejects (422) a submitted site that lands on this list —
-- even when the per-medication `medications.allowed_injection_sites`
-- lists it as preferred. Deny always wins.
--
-- Typical use: a user develops lipohypertrophy at one site and wants it
-- excluded across every injectable they track, without editing each
-- medication's allowed list individually.
--
-- Single additive, defaulted enum-array column on `users`. Default
-- `'{}'` = no global exclusion (the pre-v1.8.5 behaviour). No backfill.
-- The `injection_site` enum type already exists (migration 0046).
--
-- Idempotent guard (`IF NOT EXISTS`) makes reruns safe.
--
-- Reversibility:
-- ALTER TABLE "users" DROP COLUMN IF EXISTS "global_excluded_injection_sites";
-- A roll-back loses the global deny-list (every site becomes pickable
-- again, subject to each medication's own allowed list); the
-- application reads tolerate a missing column / default-empty value.
--
-- No index — reads are per-user at request time (`WHERE id = $1`),
-- matching `insights_exclude_metrics` and the other display-pref
-- columns on the same table.
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "global_excluded_injection_sites" "injection_site"[] NOT NULL DEFAULT ARRAY[]::"injection_site"[];
@@ -0,0 +1,252 @@
-- v1.8.5 — structured mood-tag taxonomy.
--
-- Adds a global Category -> Tag catalog plus a per-entry join, modelled
-- on the standalone mood diary's structured taxonomy. Purely additive:
-- the legacy flat `mood_entries.tags` JSON column is untouched and keeps
-- working for every existing row, the GLP-1 quick-tag chips, and the
-- Telegram / MoodLog sync. The structured taxonomy is a second, richer
-- capture surface; the tag-frequency / lift breakdown folds the two
-- axes together.
--
-- `label_key` columns carry an i18n message key (resolved client-side
-- against all six locales) rather than a hard-coded label, so the seeded
-- catalog renders localised everywhere. The seed at the bottom uses
-- deterministic ids + ON CONFLICT upsert so re-running is idempotent.
--
-- Reversibility (down):
-- DROP TABLE IF EXISTS "mood_entry_tag_links";
-- DROP TABLE IF EXISTS "mood_tags";
-- DROP TABLE IF EXISTS "mood_tag_categories";
-- CreateTable
CREATE TABLE "mood_tag_categories" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"label_key" TEXT NOT NULL,
"icon" TEXT,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"is_active" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "mood_tag_categories_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "mood_tags" (
"id" TEXT NOT NULL,
"category_id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"label_key" TEXT NOT NULL,
"icon" TEXT,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"is_active" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "mood_tags_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "mood_entry_tag_links" (
"mood_entry_id" TEXT NOT NULL,
"mood_tag_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "mood_entry_tag_links_pkey" PRIMARY KEY ("mood_entry_id","mood_tag_id")
);
-- CreateIndex
CREATE UNIQUE INDEX "mood_tag_categories_key_key" ON "mood_tag_categories"("key");
-- CreateIndex
CREATE UNIQUE INDEX "mood_tags_key_key" ON "mood_tags"("key");
-- CreateIndex
CREATE INDEX "mood_tags_category_id_sort_order_idx" ON "mood_tags"("category_id", "sort_order");
-- CreateIndex
CREATE INDEX "mood_entry_tag_links_mood_tag_id_idx" ON "mood_entry_tag_links"("mood_tag_id");
-- AddForeignKey
ALTER TABLE "mood_tags" ADD CONSTRAINT "mood_tags_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "mood_tag_categories"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "mood_entry_tag_links" ADD CONSTRAINT "mood_entry_tag_links_mood_entry_id_fkey" FOREIGN KEY ("mood_entry_id") REFERENCES "mood_entries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "mood_entry_tag_links" ADD CONSTRAINT "mood_entry_tag_links_mood_tag_id_fkey" FOREIGN KEY ("mood_tag_id") REFERENCES "mood_tags"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Seed the default taxonomy (idempotent upsert by `key`). Mirrors
-- `src/lib/mood/tag-taxonomy.ts` — keep the two in sync if either
-- changes.
INSERT INTO "mood_tag_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mtc_feelings', 'feelings', 'mood.tagCategory.feelings', 'Heart', 0, true)
ON CONFLICT ("key") DO UPDATE SET
"label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_happy', 'mtc_feelings', 'happy', 'mood.tag.happy', 'Smile', 0, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_excited', 'mtc_feelings', 'excited', 'mood.tag.excited', 'Zap', 1, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_grateful', 'mtc_feelings', 'grateful', 'mood.tag.grateful', 'HandHeart', 2, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_relaxed', 'mtc_feelings', 'relaxed', 'mood.tag.relaxed', 'CloudSun', 3, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_content', 'mtc_feelings', 'content', 'mood.tag.content', 'ThumbsUp', 4, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_tired', 'mtc_feelings', 'tired', 'mood.tag.tired', 'Moon', 5, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_unsure', 'mtc_feelings', 'unsure', 'mood.tag.unsure', 'HelpCircle', 6, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_bored', 'mtc_feelings', 'bored', 'mood.tag.bored', 'Meh', 7, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_tense', 'mtc_feelings', 'tense', 'mood.tag.tense', 'AlertTriangle', 8, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_angry', 'mtc_feelings', 'angry', 'mood.tag.angry', 'Flame', 9, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_stressed', 'mtc_feelings', 'stressed', 'mood.tag.stressed', 'Brain', 10, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_sad', 'mtc_feelings', 'sad', 'mood.tag.sad', 'Frown', 11, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tag_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mtc_sleep', 'sleep', 'mood.tagCategory.sleep', 'BedDouble', 1, true)
ON CONFLICT ("key") DO UPDATE SET
"label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_slept_well', 'mtc_sleep', 'slept_well', 'mood.tag.sleptWell', 'Moon', 0, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_slept_ok', 'mtc_sleep', 'slept_ok', 'mood.tag.sleptOk', 'CloudMoon', 1, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_slept_poorly', 'mtc_sleep', 'slept_poorly', 'mood.tag.sleptPoorly', 'MoonStar', 2, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_early_night', 'mtc_sleep', 'early_night', 'mood.tag.earlyNight', 'Clock', 3, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tag_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mtc_health', 'health', 'mood.tagCategory.health', 'HeartPulse', 2, true)
ON CONFLICT ("key") DO UPDATE SET
"label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_worked_out', 'mtc_health', 'worked_out', 'mood.tag.workedOut', 'Dumbbell', 0, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_ate_well', 'mtc_health', 'ate_well', 'mood.tag.ateWell', 'Apple', 1, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_hydrated', 'mtc_health', 'hydrated', 'mood.tag.hydrated', 'GlassWater', 2, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_walked', 'mtc_health', 'walked', 'mood.tag.walked', 'Footprints', 3, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_alcohol', 'mtc_health', 'alcohol', 'mood.tag.alcohol', 'Wine', 4, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tag_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mtc_social', 'social', 'mood.tagCategory.social', 'Users', 3, true)
ON CONFLICT ("key") DO UPDATE SET
"label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_family', 'mtc_social', 'family', 'mood.tag.family', 'Home', 0, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_friends', 'mtc_social', 'friends', 'mood.tag.friends', 'Users', 1, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_party', 'mtc_social', 'party', 'mood.tag.party', 'PartyPopper', 2, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_alone', 'mtc_social', 'alone', 'mood.tag.alone', 'User', 3, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tag_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mtc_work', 'work', 'mood.tagCategory.work', 'Briefcase', 4, true)
ON CONFLICT ("key") DO UPDATE SET
"label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_productive', 'mtc_work', 'productive', 'mood.tag.productive', 'CheckCircle', 0, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_overtime', 'mtc_work', 'overtime', 'mood.tag.overtime', 'Clock', 1, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_day_off', 'mtc_work', 'day_off', 'mood.tag.dayOff', 'LogOut', 2, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_travel', 'mtc_work', 'travel', 'mood.tag.travel', 'Plane', 3, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_sick_day', 'mtc_work', 'sick_day', 'mood.tag.sickDay', 'Thermometer', 4, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
@@ -0,0 +1,32 @@
-- v1.8.6 — optional German insurer institution number (IKNR) on the user
-- profile.
--
-- The IKNR ("Institutionskennzeichen", 9 digits) identifies the statutory
-- or private health insurer. It sits next to the existing plaintext
-- `insurer_name` and the encrypted `insurance_number_encrypted` (KVNR).
-- Unlike the KVNR — a per-person quasi-identifier kept AES-256-GCM
-- encrypted at rest — the IKNR is a public registry number for the
-- institution, so it is stored plaintext, matching `insurer_name`.
--
-- Surfaced on the health-record FHIR export as the contained payor
-- `Organization` identifier (system `http://fhir.de/sid/arge-ik/iknr`)
-- inside the new `Coverage` resource.
--
-- Single additive, nullable, NULL-defaulted column on `users`. No
-- backfill — pre-v1.8.6 rows read NULL (no IKNR captured yet), which the
-- exporter treats identically to an absent value.
--
-- Idempotent guard (`IF NOT EXISTS`) makes reruns safe. Forward-only.
--
-- Reversibility:
-- ALTER TABLE "users" DROP COLUMN IF EXISTS "insurer_ik_number";
-- A roll-back loses the captured IKNR; the FHIR export simply emits the
-- payor `Organization` with a name only (no identifier), and the
-- application reads tolerate a missing column / NULL value.
--
-- No index — reads are per-user at request time (`WHERE id = $1`),
-- matching `insurer_name` and the other display-identity columns on the
-- same table.
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "insurer_ik_number" TEXT;
@@ -0,0 +1,38 @@
-- v1.9.0 — optional drug-classification codes on the medication row.
--
-- Two additive, nullable, NULL-defaulted columns on `medications`:
--
-- * `atc_code` — the WHO ATC active-substance class (e.g. "A10BX10").
-- Emitted on the FHIR `MedicationStatement` /
-- `MedicationAdministration` `medicationCodeableConcept`
-- under the system URI `http://www.whocc.no/atc`.
-- * `rxnorm_code` — the RxNorm RxCUI (US identifier). Secondary coding
-- under `http://www.nlm.nih.gov/research/umls/rxnorm`.
--
-- Both are user/clinician-asserted, never machine-guessed: a wrong code
-- is a clinical-safety problem, an absent code is merely a (conformant)
-- text-only concept. The application validates the format on write
-- (ATC `^[A-Z]\d{2}[A-Z]{2}\d{2}$`, RxCUI `^\d+$`) and 422s on a
-- malformed value; the DB column is a plain nullable TEXT field.
--
-- No backfill — pre-v1.9.0 rows read NULL, which the exporter treats
-- identically to an absent value (the medicationCodeableConcept stays
-- text-only, the pre-v1.9.0 shape).
--
-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only.
--
-- Reversibility:
-- ALTER TABLE "medications" DROP COLUMN IF EXISTS "atc_code";
-- ALTER TABLE "medications" DROP COLUMN IF EXISTS "rxnorm_code";
-- A roll-back loses the captured codes; the FHIR export falls back to the
-- text-only medicationCodeableConcept and the application reads tolerate a
-- missing column / NULL value.
--
-- No index — reads are per-user at request time (`WHERE user_id = $1`),
-- matching the other display-identity columns on the same table.
ALTER TABLE "medications"
ADD COLUMN IF NOT EXISTS "atc_code" TEXT;
ALTER TABLE "medications"
ADD COLUMN IF NOT EXISTS "rxnorm_code" TEXT;
@@ -0,0 +1,36 @@
-- v1.10.0 — extend `measurement_type` with seven additive HealthKit
-- signals that previously sat in the deferred set.
--
-- Background: each identifier below was emitted by the iOS client but
-- mapped to no `MeasurementType`, so the batch route ack-200'd a
-- per-entry `skipped:"unmappable_identifier"` and the sample was lost.
-- They are now wired end-to-end (mapping + plausibility range + the
-- generic metric-status assessment surface).
--
-- CARDIO_RECOVERY ← HKQuantityTypeIdentifierHeartRateRecoveryOneMinute
-- WRIST_TEMPERATURE ← HKQuantityTypeIdentifierAppleSleepingWristTemperature
-- FALL_COUNT ← HKQuantityTypeIdentifierNumberOfTimesFallen
-- SIX_MINUTE_WALK_DISTANCE ← HKQuantityTypeIdentifierSixMinuteWalkTestDistance
-- STAIR_ASCENT_SPEED ← HKQuantityTypeIdentifierStairAscentSpeed
-- STAIR_DESCENT_SPEED ← HKQuantityTypeIdentifierStairDescentSpeed
-- BREATHING_DISTURBANCES ← HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances
--
-- Unit convention: every one of these ships raw on the wire and passes
-- through `convertToDbUnit` as identity — none is a 0..1 fraction, so the
-- ×100 percent-scaling path the gait-percent metrics use does NOT apply.
-- The convention block at the top of
-- `src/lib/measurements/apple-health-mapping.ts` spells the split out.
--
-- Pure additive — no existing row carries any of these values. The
-- `IF NOT EXISTS` guard keeps the migration idempotent so the runner can
-- replay it on a partially-applied environment without failing on the
-- duplicate-enum-value error code. Forward-only; enum values cannot be
-- dropped in PostgreSQL, so there is no automatic down path.
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'CARDIO_RECOVERY';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WRIST_TEMPERATURE';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'FALL_COUNT';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'SIX_MINUTE_WALK_DISTANCE';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STAIR_ASCENT_SPEED';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STAIR_DESCENT_SPEED';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BREATHING_DISTURBANCES';
@@ -0,0 +1,95 @@
-- v1.10.0 — categorical EVENT row class + device rhythm classification.
--
-- Adds a discrete EVENT row class to the existing `Measurement` model,
-- following the `sleep_stage` / `glucose_context` nullable-context
-- precedent (migration 0021 + 0036) — an additive nullable column on the
-- existing table, NOT a new storage paradigm. An EVENT row stores ONE
-- on-device notification the user's wearable already produced (Apple Watch
-- irregular-rhythm / high-HR / low-HR / walking-steadiness, ScanWatch AFib
-- screening, sleep breathing-disturbance). `value` is always 1; the
-- device's own verdict / severity rides in the new `rhythm_classification`
-- column.
--
-- ── Regulatory framing (load-bearing) ──────────────────────────────────
-- HealthLog stores ONLY the classification RESULT the device's
-- FDA-cleared / CE-marked on-device algorithm emitted. It never ingests a
-- raw ECG waveform, never re-classifies, and never produces a HealthLog
-- diagnosis. The surface is awareness/screening of the DEVICE's decision.
--
-- Two schema objects:
--
-- 1. Five new `measurement_type` enum values (the EVENT classes).
-- 2. A new `rhythm_classification` enum + a nullable column carrying the
-- device verdict / severity, NULL for every continuous measurement.
--
-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only.
--
-- Reversibility:
-- ALTER TABLE "measurements" DROP CONSTRAINT IF EXISTS "measurements_rhythm_classification_requires_event_type";
-- ALTER TABLE "measurements" DROP COLUMN IF EXISTS "rhythm_classification";
-- DROP TYPE IF EXISTS "rhythm_classification";
-- Postgres does not support removing an enum value, so the five new
-- `measurement_type` members stay; with no rows carrying them after a
-- column drop they are inert.
-- ── 1. measurement_type — append the five EVENT classes ────────────────
-- Pure additive; no existing row carries any of these values. The
-- `IF NOT EXISTS` guard keeps the migration idempotent across a
-- partially-applied environment.
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'IRREGULAR_RHYTHM_NOTIFICATION';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'HIGH_HEART_RATE_EVENT';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'LOW_HEART_RATE_EVENT';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WALKING_STEADINESS_EVENT';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BREATHING_DISTURBANCE_EVENT';
-- ── 2. rhythm_classification — the device verdict / severity enum ──────
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'rhythm_classification') THEN
CREATE TYPE "rhythm_classification" AS ENUM (
'IRREGULAR',
'NOT_DETECTED',
'INCONCLUSIVE',
'LOW',
'VERY_LOW',
'FIRED'
);
END IF;
END
$$;
-- Nullable column. NULL for every continuous measurement; non-NULL only
-- for the EVENT classes. Mirrors the `sleep_stage` additive-column shape.
ALTER TABLE "measurements"
ADD COLUMN IF NOT EXISTS "rhythm_classification" "rhythm_classification";
-- Enforce: rhythm_classification is only set for the EVENT-class rows.
-- Mirrors the `measurements_sleep_stage_requires_type` CHECK (migration
-- 0036) and the glucose_context CHECK (migration 0021).
--
-- The comparison casts `type` to text so the predicate references the
-- newly-added enum members as string literals. Postgres forbids using an
-- enum value in the same transaction that adds it (the ALTER TYPE ... ADD
-- VALUE statements above run in this same migration transaction); the
-- `::text` cast sidesteps that restriction without weakening the check.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'measurements_rhythm_classification_requires_event_type'
) THEN
ALTER TABLE "measurements"
ADD CONSTRAINT "measurements_rhythm_classification_requires_event_type"
CHECK (
"rhythm_classification" IS NULL
OR "type"::text IN (
'IRREGULAR_RHYTHM_NOTIFICATION',
'HIGH_HEART_RATE_EVENT',
'LOW_HEART_RATE_EVENT',
'WALKING_STEADINESS_EVENT',
'BREATHING_DISTURBANCE_EVENT'
)
);
END IF;
END
$$;
@@ -0,0 +1,37 @@
-- v1.10.0 — computed scores (WX-C): COMPUTED source + the three `*_SCORE`
-- measurement types.
--
-- Two purely-additive enum extensions, no new columns and no backfill:
--
-- 1. A new `measurement_source` value `COMPUTED` for server-derived rows.
-- A `COMPUTED` Measurement is minted by a nightly engine from the user's
-- already-stored signals (the Recovery score is the first); it is NEVER
-- ingested from a client. The client-facing batch + single-POST write
-- surfaces reject it exactly like `WITHINGS` / `IMPORT`. It is part of
-- the enum so the read/response shapes (and the iOS decoder) can decode
-- the rows it surfaces.
--
-- 2. Three new `measurement_type` values — the server-derived wellness
-- scores `RECOVERY_SCORE`, `STRESS_SCORE`, `STRAIN_SCORE` (0–100, unit
-- `score`), each minted nightly by its own engine. These are NOT
-- clinical vitals: they stay out of the clinical vitals table and
-- surface only in a clearly-labelled, descriptive-not-clinical wellness
-- summary (and as FHIR `survey`-category observations).
--
-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only.
--
-- Reversibility:
-- Postgres does not support removing an enum value, so the four new
-- members stay; with no rows carrying them (a `COMPUTED` source / a
-- `*_SCORE` type) after the nightly job is disabled they are inert.
-- ── 1. measurement_source — append the COMPUTED server-owned source ────
-- Pure additive; no existing row carries this value. The `IF NOT EXISTS`
-- guard keeps the migration idempotent across a partially-applied environment.
ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'COMPUTED';
-- ── 2. measurement_type — append the three computed-score classes ──────
-- Pure additive; no existing row carries any of these values.
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'RECOVERY_SCORE';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STRESS_SCORE';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STRAIN_SCORE';
@@ -0,0 +1,44 @@
-- v1.10.0 — per-workout heart-rate series (route-independent).
--
-- A companion to `workout_routes` that holds the per-sample HR series
-- WITHOUT requiring GPS geometry. The route table only exists when a
-- workout has a GeoJSON LineString, so an INDOOR session (treadmill,
-- trainer, strength) had nowhere to store its heart-rate samples even
-- though HealthKit ships them. That series feeds the training-strain
-- engine (TRIMP / time-in-HR-zone), so decoupling it from the route is
-- what unblocks indoor strain.
--
-- Shape rationale:
-- * `samples` JSONB — `[{ t: ISO string, hr?: int, speedMs?, power?,
-- cadence? }]`, one entry per recorded sample. Same JSONB decision
-- as `workout_routes.geometry` / `sample_timestamps`: no PostGIS
-- extension dependency on a self-hosted Postgres install, and the
-- blob mirrors HealthKit's per-sample shape directly.
-- * `sample_count` — denormalised `samples.length` so the strain
-- reader + observability can size the series without deserialising
-- the JSONB blob. The application caps the array length on write
-- (MAX_WORKOUT_HR_SAMPLES in src/lib/validations/workout.ts) so the
-- per-workout payload — and the largest new workout-side write
-- stream — stays bounded.
--
-- 1:1 with the workout: `workout_id` is UNIQUE with an ON DELETE CASCADE
-- FK, exactly like `workout_routes`. A re-submitted batch that wins the
-- workout-row race but loses the sample-row race is an idempotent no-op
-- (the ingest path uses `skipDuplicates`), not a hard error.
--
-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only.
--
-- Reversibility:
-- DROP TABLE IF EXISTS "workout_samples";
-- A roll-back loses the captured per-workout HR series; the workout row,
-- its aggregates (avg/max/min HR), and the route survive untouched.
CREATE TABLE IF NOT EXISTS "workout_samples" (
"id" TEXT PRIMARY KEY,
"workout_id" TEXT NOT NULL UNIQUE REFERENCES "workouts"("id") ON DELETE CASCADE,
-- JSONB array: [{ t: ISO string, hr?: int, speedMs?, power?, cadence? }]
"samples" JSONB NOT NULL,
-- Denormalised samples.length — sized without parsing the blob.
"sample_count" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -0,0 +1,54 @@
-- v1.10.0 — dense intra-day retention tier for daytime HRV / heart-rate.
--
-- The Stress engine reads the intra-day SDNN (and daytime heart-rate)
-- SHAPE across the day, not a single daily mean. Apple Health ships those
-- as discrete per-sample rows, so the engine needs the raw samples to
-- survive long enough to read. Two facts make that safe:
--
-- 1. `HEART_RATE_VARIABILITY` and `PULSE` are NOT in the destructive
-- daily-mean drain's allowlist (`HIGH_FREQUENCY_MEAN_TYPES` in
-- src/lib/measurements/apple-health-mapping.ts), so the nightly
-- mean-consolidation never collapses them — the intra-day shape is
-- preserved by construction. (PULSE was already excluded for the
-- correlation/scatter readers; HRV is exempted for the same reason
-- the Stress proxy needs it.)
--
-- 2. A BOUNDED retention window contains the volume: per-sample HRV / HR
-- rows older than the window are folded to one daily-mean `stats:` row
-- (the same idempotent fold + soft-delete the mean-consolidation uses)
-- and the raw rows are tombstoned. Within the window the raw samples
-- stay, so the Stress engine always has its intra-day inputs for the
-- days it scores.
--
-- This migration adds ONLY the partial index that keeps the dense-tier
-- drain's discovery + per-user scan cheap. No new column, no backfill, no
-- table rewrite. The retention bound itself lives in application code
-- (DENSE_INTRADAY_RETENTION_DAYS in
-- src/lib/measurements/dense-intraday-retention.ts) so tuning it never
-- needs a migration.
--
-- The index is keyed on `(user_id, measured_at)` and partial over the two
-- dense-tier types on live rows only, so it shrinks toward the in-window
-- working set as out-of-window samples are folded away — it carries no
-- steady-state cost once a tenant's history is past the retention window.
--
-- Idempotent guard (`IF NOT EXISTS`) makes reruns safe. Forward-only.
--
-- Lock note (conscious trade-off): a plain, non-`CONCURRENTLY` build, so
-- it runs inside Prisma's migration transaction and holds an ACCESS
-- EXCLUSIVE lock on `measurements` for the build. The partial predicate
-- (two types, `deleted_at IS NULL`) keeps the index small on a typical
-- self-host, so the build is fast and the lock is not felt; the largest
-- multi-year HealthKit tenants should deploy at a low-traffic window. A
-- `CONCURRENTLY` build cannot run inside the migration transaction and is
-- not warranted given the partial-index size on the vast majority of
-- deployments.
--
-- Reversibility:
-- DROP INDEX IF EXISTS "measurements_dense_intraday_retention_idx";
-- A roll-back loses only the scan acceleration; no data is touched.
CREATE INDEX IF NOT EXISTS "measurements_dense_intraday_retention_idx"
ON "measurements" ("user_id", "measured_at")
WHERE "type" IN ('HEART_RATE_VARIABILITY', 'PULSE')
AND "deleted_at" IS NULL;
+265 -11
View File
@@ -43,6 +43,13 @@ model User {
// behaviour for every existing row.
insightsExcludeMetrics String[] @default([]) @map("insights_exclude_metrics")
// v1.8.5 — user-level injection-site deny-list. Sites listed here are
// never offered for ANY medication's injection-site picker and the
// intake write path 422s a submitted site that lands on this list,
// even when the per-medication `allowedInjectionSites` lists it as
// preferred (deny always wins). Default `[]` = no global exclusion.
globalExcludedInjectionSites InjectionSite[] @default([]) @map("global_excluded_injection_sites")
// Telegram notifications (per-user)
telegramBotToken String? @map("telegram_bot_token") // AES-256-GCM encrypted
telegramChatId String? @map("telegram_chat_id")
@@ -411,6 +418,16 @@ model User {
insurerName String? @map("insurer_name")
insuranceNumberEncrypted String? @map("insurance_number_encrypted")
// v1.8.6 profile — optional German insurer institution number (IKNR,
// 9 digits). Surfaced on the FHIR `Coverage` resource's contained
// payor `Organization` (system `http://fhir.de/sid/arge-ik/iknr`).
// Stored plaintext, matching `insurerName` — the IKNR is a public
// registry identifier, not a quasi-identifier like the KVNR, so it
// does not warrant the encrypted column treatment. Optional; an unset
// field collapses the Coverage payor identifier exactly like an
// unset `insurerName` collapses its name.
insurerIkNumber String? @map("insurer_ik_number")
@@map("users")
}
@@ -515,10 +532,74 @@ enum MeasurementType {
// and pass through `convertToDbUnit` as identity.
WALKING_STEP_LENGTH // metres — HealthKit `walkingStepLength`. Mobility section gait metric; adult walking typically 0.5–0.8 m.
WALKING_SPEED // metres per second — HealthKit `walkingSpeed`. Mobility section gait metric; healthy adult casual gait ≈ 1.2–1.4 m/s.
// v1.10.0 — additive HealthKit signals (WX-A)
// Seven previously-deferred quantity identifiers, wired end-to-end with
// the generic metric-status assessment treatment. Each rides the same
// server-side `convertToDbUnit` + plausibility-range path as the rest of
// the HealthKit surface; see `src/lib/measurements/apple-health-mapping.ts`.
CARDIO_RECOVERY // bpm — HealthKit `heartRateRecoveryOneMinute` (iOS 16+). The drop in heart rate one minute after peak exercise; a larger drop signals fitter autonomic recovery (higher-better).
WRIST_TEMPERATURE // celsius — HealthKit `appleSleepingWristTemperature` (iOS 16+). Overnight wrist temperature; Apple surfaces it as a baseline deviation, we store the absolute reading. DISTINCT from BODY_TEMPERATURE (core) and SKIN_TEMPERATURE (Withings dermal spot).
FALL_COUNT // count — HealthKit `numberOfTimesFallen`. Hard-fall detections; cumulative daily count, lower-better.
SIX_MINUTE_WALK_DISTANCE // metres — HealthKit `sixMinuteWalkTestDistance`. Apple's estimated 6-minute-walk-test distance; mobility + cardiopulmonary endurance signal, higher-better.
STAIR_ASCENT_SPEED // metres per second — HealthKit `stairAscentSpeed`. Mobility section gait metric measured while climbing stairs, higher-better.
STAIR_DESCENT_SPEED // metres per second — HealthKit `stairDescentSpeed`. Mobility section gait metric measured while descending stairs, higher-better.
BREATHING_DISTURBANCES // count — HealthKit `appleSleepingBreathingDisturbances` (iOS 18+). Per-night sleep-breathing-disturbance index Apple classifies as NotElevated / Elevated; lower-better.
// v1.10.0 — categorical events (WX-B)
// Device-flagged EVENT rows. These are NOT continuous measurements —
// each row records a discrete on-device notification the user's wearable
// produced (Apple Watch / Withings ScanWatch). `value` is always 1 (one
// fired event); the device's verdict / severity lands in the
// `rhythmClassification` column (see the `RhythmClassification` enum and
// the CHECK constraint in migration 0105). HealthLog never re-derives or
// re-classifies the underlying signal — it only stores and surfaces the
// result the device's own certified algorithm already emitted.
IRREGULAR_RHYTHM_NOTIFICATION // HealthKit `HKCategoryTypeIdentifierIrregularHeartRhythmEvent` + ScanWatch AFib screening. The certified on-device algorithm flagged a possible irregular rhythm. AWARENESS ONLY — never a HealthLog diagnosis.
HIGH_HEART_RATE_EVENT // HealthKit `HKCategoryTypeIdentifierHighHeartRateEvent`. The watch detected a sustained high heart rate above the user's configured threshold while apparently inactive.
LOW_HEART_RATE_EVENT // HealthKit `HKCategoryTypeIdentifierLowHeartRateEvent`. The watch detected a sustained low heart rate below the user's configured threshold.
WALKING_STEADINESS_EVENT // HealthKit `HKCategoryTypeIdentifierAppleWalkingSteadinessEvent`. The device flagged low / very-low walking steadiness (a falls-risk mobility signal); the severity rides in `rhythmClassification`.
BREATHING_DISTURBANCE_EVENT // HealthKit `HKCategoryTypeIdentifierSleepApneaEvent` + `AppleSleepingBreathingDisturbances`. The device flagged an elevated breathing-disturbance / possible sleep-apnea signal during sleep. SCREENING SIGNAL ONLY — never a HealthLog diagnosis.
// v1.10.0 — computed scores (WX-C)
// Server-derived wellness scores (0–100, unit `score`). Each is minted by a
// nightly engine from the user's already-stored signals and persisted as a
// `COMPUTED`-source Measurement row — never ingested from a client. Only
// RECOVERY_SCORE is computed in v1.10.0; STRESS_SCORE + STRAIN_SCORE are
// defined now so the later engines that compute them need no schema change.
// These are NOT clinical vitals — they never appear in the doctor-report PDF.
RECOVERY_SCORE // Daily recovery / readiness proxy. Higher = more recovered. Blends RHR + HRV (SDNN) + sleep + respiratory-rate deviation from the personal baseline (Plews 2013 / Buchheit 2014 lineage). Descriptive, not a clinical or training-recovery assessment.
STRESS_SCORE // Reserved for the later strain/stress engine. Higher = more physiological stress. No engine computes it in v1.10.0.
STRAIN_SCORE // Reserved for the later strain/stress engine. Higher = more cardiovascular strain (workout-load lineage). No engine computes it in v1.10.0.
@@map("measurement_type")
}
/// v1.10.0 — categorical events (WX-B)
/// The device's own classification RESULT / severity for an EVENT row.
/// Non-NULL only for the categorical EVENT `MeasurementType`s
/// (IRREGULAR_RHYTHM_NOTIFICATION, HIGH/LOW_HEART_RATE_EVENT,
/// WALKING_STEADINESS_EVENT, BREATHING_DISTURBANCE_EVENT), NULL for every
/// continuous measurement — mirroring the `GlucoseContext` / `SleepStage`
/// nullable-context precedent.
///
/// HealthLog stores ONLY the verdict the device's FDA-cleared / CE-marked
/// on-device algorithm already produced. It never re-classifies a raw
/// signal and never emits a diagnosis of its own. `IRREGULAR` means the
/// device flagged a possible irregular rhythm; `NOT_DETECTED` /
/// `INCONCLUSIVE` carry the device's own non-positive verdicts so the
/// awareness timeline can be honest rather than only ever showing alarms.
/// `LOW` / `VERY_LOW` carry the walking-steadiness severity; `FIRED` is the
/// neutral "this event occurred" verdict for the high/low-HR + breathing
/// events that have no finer device-side grading.
enum RhythmClassification {
IRREGULAR // device flagged a possible irregular rhythm (AFib screening)
NOT_DETECTED // device's algorithm ran and did not flag an irregular rhythm
INCONCLUSIVE // device could not classify (poor signal / out-of-range HR)
LOW // walking-steadiness: low
VERY_LOW // walking-steadiness: very low
FIRED // neutral "event occurred" verdict (high/low-HR, breathing disturbance)
@@map("rhythm_classification")
}
/// Context for glucose measurements — affects which target range applies.
/// NULL for non-glucose measurements.
enum GlucoseContext {
@@ -535,6 +616,14 @@ enum MeasurementSource {
WITHINGS
IMPORT
APPLE_HEALTH // v1.4.23 — HealthKit batch ingest. iOS DTO mirrors this exact spelling (not "HEALTHKIT").
// v1.10.0 — computed scores (WX-C)
// Server-derived rows. A `COMPUTED` measurement is minted by a nightly
// engine from the user's already-stored signals (the Recovery score is the
// first); it is NEVER ingested from a client. Server-owned / read-only:
// the client-facing batch + single-POST write surfaces reject it exactly
// like WITHINGS / IMPORT, but it is part of the read enum so iOS can decode
// the rows it queries / charts.
COMPUTED
@@map("measurement_source")
}
@@ -579,6 +668,15 @@ model Measurement {
// only for `type = SLEEP_DURATION`. Mirrors the glucoseContext
// pattern. A single night produces ~5 rows (one per stage).
sleepStage SleepStage? @map("sleep_stage")
// v1.10.0 — categorical events (WX-B). The device's own classification
// verdict / severity for an EVENT row. NULL for every continuous
// measurement; non-NULL only for the categorical EVENT `MeasurementType`s.
// Mirrors the `sleepStage` / `glucoseContext` nullable-context columns. A
// CHECK constraint (migration 0105) enforces the type pairing so a
// continuous row can never carry a classification. NOT part of any unique
// index — dedup stays on `(userId, type, source, externalId)` keyed by the
// device's `HKSample.uuid`, so a re-posted event collapses to a duplicate.
rhythmClassification RhythmClassification? @map("rhythm_classification")
// v1.4.25 W8c — device-type tag for the cross-source canonical
// picker. Stored as a free-text string (kept simple — Prisma enum
// would require coordinated client + server bumps every time we
@@ -638,6 +736,11 @@ model Measurement {
// migration 0087. It accelerates the v1.5.6 legacy step-consolidation
// discovery scan. Prisma can't express a partial-predicate index, so
// it lives in raw migration SQL and is intentionally omitted here.
// `measurements_dense_intraday_retention_idx` is a partial index over
// `type IN ('HEART_RATE_VARIABILITY','PULSE') AND deleted_at IS NULL`
// — see migration 0108. It accelerates the v1.10.0 dense intra-day
// retention drain's discovery + per-user scan. Same Prisma-can't-
// express-a-partial-predicate reason it is omitted here.
@@map("measurements")
}
@@ -741,21 +844,28 @@ model Workout {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
route WorkoutRoute?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
route WorkoutRoute?
samples WorkoutSamples?
/// Ingest dedup. NULL externalId is distinct so two manual
/// entries on the same day never collide here.
///
/// TODO(v1.5 iOS): cross-source workout dedup. The current key is
/// (userId, source, externalId) — so the SAME workout ingested from
/// MANUAL plus the iOS HealthKit passthrough lands two rows because
/// `source` differs. Measurements already solved this via
/// `pickCanonicalSourceRows()` (the read-time canonical-source
/// picker in src/lib/analytics/source-priority.ts); workouts need
/// the symmetric helper before the dashboard "recent workouts" tile
/// can show one row per workout instead of one per source. Tracked
/// for v1.5 P1 alongside the iOS app.
/// Cross-source dedup (the SAME logical workout arriving from two
/// paired sensors — e.g. Apple Watch + Withings ScanWatch — under
/// different `source`/`externalId` pairs) is handled in two layers,
/// mirroring the measurement-side `pickCanonicalSourceRows()`:
/// * WRITE-time — `dedupeWorkoutBatch()` in
/// src/lib/workouts/canonical-rows.ts groups an inbound batch by
/// (userId, activityType, startedAt ± 90 s) and keeps one
/// canonical row per logical workout BEFORE the insert, so the
/// duplicate never reaches storage.
/// * READ-time — `pickCanonicalWorkoutRows()` in
/// src/lib/measurements/pick-canonical-workout-rows.ts collapses
/// any cross-batch twins (e.g. one source's row from Monday's
/// batch + the other's from Tuesday's backfill) so the dashboard
/// "recent workouts" tile shows one row per logical workout.
/// Both pickers resolve the user's `sourcePriorityJson` ladder.
@@unique([userId, source, externalId])
/// Dashboard "recent workouts" query path.
@@index([userId, startedAt])
@@ -784,6 +894,45 @@ model WorkoutRoute {
@@map("workout_routes")
}
// ─── Per-workout heart-rate (+ optional speed/power/cadence) series ──
//
// v1.10.0 — a route-independent companion to `WorkoutRoute`.
//
// The route table only exists when a workout has GPS geometry, so an
// INDOOR session (treadmill, bike trainer, strength) had no home for
// its per-sample heart-rate series even though HealthKit ships one.
// That series is the input the training-strain engine (TRIMP /
// time-in-HR-zone) needs, so decoupling it from the route is what
// unblocks indoor strain.
//
// `samples` is a JSONB array `[{ t, hr?, speedMs?, power?, cadence? }]`
// — `t` is an ISO timestamp, `hr` beats/min. Same JSONB-over-PostGIS
// decision as `WorkoutRoute` (no extension dependency on a self-hosted
// Postgres). 1:1 with the workout (FK `workout_id` UNIQUE) so a
// re-ingested batch that wins the workout-row race but loses the
// sample-row race is an idempotent no-op, exactly like the route table.
//
// `sample_count` is denormalised so the strain reader + observability
// can size the series without deserialising the JSONB blob. The ingest
// path caps the count (see MAX_WORKOUT_HR_SAMPLES in
// src/lib/validations/workout.ts) to keep the per-workout payload — and
// the largest new workout-side write stream — bounded.
model WorkoutSamples {
id String @id @default(cuid())
workoutId String @unique @map("workout_id")
/// JSONB array of `{ t: ISO string, hr?: int, speedMs?, power?,
/// cadence? }`. One entry per recorded sample; ordered by `t`
/// ascending at ingest. Capped at MAX_WORKOUT_HR_SAMPLES.
samples Json
/// Denormalised `samples.length` — sized without parsing the blob.
sampleCount Int @map("sample_count")
createdAt DateTime @default(now()) @map("created_at")
workout Workout @relation(fields: [workoutId], references: [id], onDelete: Cascade)
@@map("workout_samples")
}
// ─── Personal records (v1.4.25 W8d — schema only) ──────────
//
// MAX vs MIN direction is stored on the row so the future detection
@@ -917,6 +1066,19 @@ model Medication {
/// A one-time injection is `oneShot = true` + `deliveryForm =
/// INJECTION`. The migration backfills ORAL for every existing row.
deliveryForm MedicationDeliveryForm @default(ORAL) @map("delivery_form")
/// v1.8.5 — per-medication injection-site tracking opt-in. Only
/// meaningful (and only offered in the UI) when `deliveryForm ===
/// INJECTION`. Default false: after marking a dose taken the client
/// prompts for the site only when this is true, and the prompt is
/// always skippable (never blocking). Deactivatable at any time.
trackInjectionSites Boolean @default(false) @map("track_injection_sites")
/// v1.8.5 — per-medication allowed / preferred injection sites. Empty
/// = no per-medication restriction (every site is offered). The
/// effective pickable set is this list MINUS the user-level
/// `globalExcludedInjectionSites` deny-list (deny always wins). The
/// intake write path validates a submitted site against the effective
/// set and 422s on a disallowed value.
allowedInjectionSites InjectionSite[] @default([]) @map("allowed_injection_sites")
/// v1.7.0 — iOS Live Activity opt-in for this medication's reminders.
/// Default false; existing rows backfill false. The server stores +
/// echoes the flag; the iOS client owns the ActivityKit lifecycle. No
@@ -927,6 +1089,18 @@ model Medication {
/// stores the preference only — it does not itself authorise anything
/// server-side.
criticalAlarmEnabled Boolean @default(false) @map("critical_alarm_enabled")
/// v1.9.0 — optional ATC classification code (active-substance class,
/// e.g. `A10BX10`). User/clinician-asserted; never machine-guessed on
/// the export path. NULL = no code (the FHIR exporter emits a text-only
/// `medicationCodeableConcept`, the pre-v1.9.0 behaviour). The WHO URI
/// `http://www.whocc.no/atc` is the emitted system. Validated on write
/// against `^[A-Z]\d{2}[A-Z]{2}\d{2}$`.
atcCode String? @map("atc_code")
/// v1.9.0 — optional RxNorm RxCUI (US; secondary coding). Same NULL
/// semantics as `atcCode`. Validated on write against `^\d+$`. Emitted
/// under `http://www.nlm.nih.gov/research/umls/rxnorm` alongside any
/// ATC code, never instead of the free-text `.text` anchor.
rxNormCode String? @map("rxnorm_code")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -1540,6 +1714,13 @@ model MoodEntry {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// v1.8.5 — structured-tag links. Additive alongside the legacy flat
// `tags` JSON string: an entry can carry both (a free-text tag the user
// typed AND a structured tag they picked from the taxonomy). The
// aggregates read whichever is present; existing rows keep their flat
// `tags` untouched.
tagLinks MoodEntryTagLink[]
@@unique([userId, date, moodLoggedAt])
@@index([userId, date])
// v1.7.0 sync — keyset support for the `/api/sync/changes` mood feed
@@ -1548,6 +1729,79 @@ model MoodEntry {
@@map("mood_entries")
}
// ─── Mood Tag Taxonomy (v1.8.5 W6-tax) ────────────────────────
//
// Structured, icon-bearing tag catalog modelled on the standalone mood
// diary's Category → TagItem shape. Global reference data (one shared
// catalog for the deployment, not per-user): a self-hoster's account
// inherits the seeded categories the same way the metric-type enum is
// shared. `MoodEntryTagLink` is the per-entry join.
//
// Backward-compat: this is purely ADDITIVE. The legacy flat
// `MoodEntry.tags` (a JSON string array of free-text keys) keeps working
// unchanged — the GLP-1 quick-tag chips, the Telegram/MoodLog sync, and
// every existing row stay on the flat axis. The structured taxonomy is a
// second, richer capture surface that the tag-frequency / lift breakdown
// folds in next to the flat tags (`mood-aggregates.ts`).
//
// `labelKey` carries an i18n key (resolved client-side against all six
// locales) rather than a hard-coded German label, so the seeded catalog
// renders localised everywhere. `icon` is a Lucide icon name rendered by
// the capture UI + breakdown.
model MoodTagCategory {
id String @id @default(cuid())
/// Stable machine key (e.g. `feelings`). Unique across the catalog so
/// the seed migration can upsert idempotently.
key String @unique
/// i18n message key for the category label (`mood.tagCategory.<key>`).
labelKey String @map("label_key")
/// Lucide icon name for the category header.
icon String?
sortOrder Int @default(0) @map("sort_order")
/// Soft-hide a category without deleting its historical links.
isActive Boolean @default(true) @map("is_active")
tags MoodTag[]
@@map("mood_tag_categories")
}
model MoodTag {
id String @id @default(cuid())
categoryId String @map("category_id")
/// Stable machine key (e.g. `happy`). Unique across the catalog.
key String @unique
/// i18n message key for the tag label (`mood.tag.<key>`).
labelKey String @map("label_key")
/// Lucide icon name for the tag chip.
icon String?
sortOrder Int @default(0) @map("sort_order")
isActive Boolean @default(true) @map("is_active")
category MoodTagCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
links MoodEntryTagLink[]
@@index([categoryId, sortOrder])
@@map("mood_tags")
}
// Join: which structured tags a mood entry carries. Composite PK keeps
// the pairing unique; cascade on both sides so deleting an entry (or a
// catalog tag) cleans up its links. Distinct from the flat `tags` JSON
// column on `MoodEntry`.
model MoodEntryTagLink {
moodEntryId String @map("mood_entry_id")
moodTagId String @map("mood_tag_id")
createdAt DateTime @default(now()) @map("created_at")
moodEntry MoodEntry @relation(fields: [moodEntryId], references: [id], onDelete: Cascade)
moodTag MoodTag @relation(fields: [moodTagId], references: [id], onDelete: Cascade)
@@id([moodEntryId, moodTagId])
@@index([moodTagId])
@@map("mood_entry_tag_links")
}
// ─── Mood Reminder Dispatch Ledger (v0.5.4 ios-coord) ─────
//
// Idempotency guard for the daily 22:00 MOOD_REMINDER push. The
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env tsx
/**
* Post-deploy version assertion.
*
* Polls `/api/version` on production + demo and asserts the running
* build's `data.version` matches an expected value, printing each
* target's `buildSha`. Exits non-zero on a mismatch or after the retry
* window elapses — so a CI step or a manual deploy can fail loudly when
* the `:latest` pull served the prior image (the recurring BuildKit
* layer-cache pitfall where Coolify reports "deployed" while
* `/api/version` still answers the old number).
*
* Run it after every deploy — the queued-deploy status is not the source
* of truth, the served version is:
*
* pnpm dlx tsx scripts/assert-deploy.ts <expected-version>
*
* Examples:
*
* # assert both prod + demo serve 1.9.0
* pnpm dlx tsx scripts/assert-deploy.ts 1.9.0
*
* # assert prod only
* pnpm dlx tsx scripts/assert-deploy.ts 1.9.0 --only=prod
*
* # widen the retry window (default 24 attempts × 5s = 2 min)
* pnpm dlx tsx scripts/assert-deploy.ts 1.9.0 --attempts=60 --interval=5000
*
* Pure script — strips the leading `v` from the expected version so both
* `v1.9.0` and `1.9.0` work. No app-library import, no runtime wiring;
* the standalone image never ships this file.
*/
interface Target {
name: string;
url: string;
}
const TARGETS: Record<string, Target> = {
prod: { name: "prod", url: "https://healthlog.bombeck.io/api/version" },
demo: { name: "demo", url: "https://demo.healthlog.dev/api/version" },
};
interface VersionPayload {
version?: string;
buildSha?: string | null;
builtAt?: string | null;
}
function parseArgs(argv: string[]): {
expected: string;
attempts: number;
intervalMs: number;
only: string | null;
} {
const positional: string[] = [];
let attempts = 24;
let intervalMs = 5_000;
let only: string | null = null;
for (const arg of argv) {
if (arg.startsWith("--attempts=")) {
attempts = Number.parseInt(arg.slice("--attempts=".length), 10);
} else if (arg.startsWith("--interval=")) {
intervalMs = Number.parseInt(arg.slice("--interval=".length), 10);
} else if (arg.startsWith("--only=")) {
only = arg.slice("--only=".length);
} else {
positional.push(arg);
}
}
const expected = (positional[0] ?? "").trim().replace(/^v/, "");
if (!expected) {
console.error(
"usage: pnpm dlx tsx scripts/assert-deploy.ts <expected-version> [--only=prod|demo] [--attempts=N] [--interval=ms]",
);
process.exit(2);
}
if (!Number.isFinite(attempts) || attempts < 1) attempts = 24;
if (!Number.isFinite(intervalMs) || intervalMs < 0) intervalMs = 5_000;
return { expected, attempts, intervalMs, only };
}
const sleep = (ms: number): Promise<void> =>
new Promise((res) => setTimeout(res, ms));
/** One probe. Returns the parsed payload or null on any transport / shape error. */
async function probe(url: string): Promise<VersionPayload | null> {
try {
const res = await fetch(url, {
redirect: "manual",
signal: AbortSignal.timeout(10_000),
headers: { accept: "application/json" },
});
if (!res.ok) return null;
const body = (await res.json()) as { data?: VersionPayload } | null;
return body?.data ?? null;
} catch {
return null;
}
}
/**
* Poll one target until its served version matches `expected` or the
* retry window elapses. Resolves true on a match, false otherwise.
*/
async function assertTarget(
target: Target,
expected: string,
attempts: number,
intervalMs: number,
): Promise<boolean> {
for (let attempt = 1; attempt <= attempts; attempt += 1) {
const payload = await probe(target.url);
const version = payload?.version?.trim() ?? null;
if (version === expected) {
console.log(
`[${target.name}] OK version=${version} buildSha=${payload?.buildSha ?? "—"}`,
);
return true;
}
const seen = version ?? "unreachable";
console.log(
`[${target.name}] attempt ${attempt}/${attempts}: expected ${expected}, got ${seen}`,
);
if (attempt < attempts) await sleep(intervalMs);
}
console.error(
`[${target.name}] FAIL: ${target.url} did not report ${expected} within ${attempts} attempts`,
);
return false;
}
async function main(): Promise<void> {
const { expected, attempts, intervalMs, only } = parseArgs(
process.argv.slice(2),
);
let selected: Target[];
if (only) {
const picked = TARGETS[only];
if (!picked) {
console.error(`unknown --only target: ${only} (use prod or demo)`);
process.exit(2);
}
selected = [picked];
} else {
selected = Object.values(TARGETS);
}
console.log(
`Asserting version ${expected} on: ${selected.map((t) => t.name).join(", ")}`,
);
// Probe targets in parallel; each runs its own bounded retry loop.
const results = await Promise.all(
selected.map((t) => assertTarget(t, expected, attempts, intervalMs)),
);
if (results.every((ok) => ok)) {
console.log("All targets serve the expected version.");
process.exit(0);
}
process.exit(1);
}
void main();
+12 -12
View File
@@ -247,14 +247,14 @@ async function seed() {
const takenTime = new Date(date);
takenTime.setHours(8, Math.floor(Math.random() * 45), 0);
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5)`,
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5, $5)`,
[cuid(), userId, med1Id, date, takenTime],
);
} else {
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4)`,
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4, $4)`,
[cuid(), userId, med1Id, date],
);
}
@@ -265,14 +265,14 @@ async function seed() {
const takenTime = new Date(date);
takenTime.setHours(8, Math.floor(5 + Math.random() * 50), 0);
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5)`,
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5, $5)`,
[cuid(), userId, med2Id, date, takenTime],
);
} else {
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4)`,
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4, $4)`,
[cuid(), userId, med2Id, date],
);
}
@@ -284,14 +284,14 @@ async function seed() {
const takenTime = new Date(date);
takenTime.setHours(20, Math.floor(Math.random() * 60), 0);
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5)`,
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5, $5)`,
[cuid(), userId, med3Id, date, takenTime],
);
} else {
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4)`,
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4, $4)`,
[cuid(), userId, med3Id, date],
);
}
@@ -1,175 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.25 W3e — Coach drawer mount + per-card CTA gate.
*
* Three scenarios:
* 1. AI provider configured → per-card "Ask Coach" CTAs render
* alongside each card; the drawer is mounted at the page level.
* 2. AI provider NOT configured → CTAs are completely absent (no
* broken-button state).
* 3. v1.4.37 W5 — operator's global Coach flag off → both the CTAs
* and the page-level drawer mount vanish. The flag default is
* all-on for fresh installs and on any fetch error, so the path
* stays open by default (mocked via `useFeatureFlags` below).
*/
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/targets",
}));
let coachFlag = true;
vi.mock("@/hooks/use-feature-flags", async () => {
const actual = await vi.importActual<
typeof import("@/hooks/use-feature-flags")
>("@/hooks/use-feature-flags");
return {
...actual,
useFeatureFlags: () => ({
...actual.DEFAULT_ASSISTANT_FLAGS,
coach: coachFlag,
}),
};
});
const sampleData = {
targets: [
{
type: "WEIGHT",
label: "Weight",
current: 78,
average30: 78,
trend: "stable",
unit: "kg",
range: { min: 60, max: 80 },
classification: { category: "Normal", color: "#50fa7b" },
source: "WHO BMI",
daysInRange7d: 5,
daysLogged7d: 7,
lastMetGoalAt: null,
streakDays: 0,
insufficientData: false,
consistency7d: ["in", "in", "in", "in", "in", "in", "in"],
},
],
pageSummary: {
targetsMetThisWeek: 1,
totalTargets: 1,
streakHighlight: null,
},
bpDiastolic: { current: null, average30: null, range: null },
profile: { heightCm: 180, age: 35, gender: "MALE", glucoseUnit: null },
};
let providerChainResponse: { activeProvider: string | null } | null = null;
vi.mock("@tanstack/react-query", () => {
const noopClient = {
invalidateQueries: vi.fn(),
setQueryData: vi.fn(),
getQueryData: vi.fn(),
refetchQueries: vi.fn(),
};
return {
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") {
return {
data: providerChainResponse,
isLoading: false,
isError: false,
refetch: vi.fn(),
};
}
return {
data: sampleData,
isLoading: false,
isError: false,
refetch: vi.fn(),
};
},
useQueryClient: () => noopClient,
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
reset: vi.fn(),
}),
};
});
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "marc",
email: "marc@example.com",
role: "USER",
},
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import TargetsPage from "../targets/page";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<TargetsPage />
</I18nProvider>,
);
}
describe("/targets page — Coach drawer mount + CTA gate", () => {
beforeEach(() => {
coachFlag = true;
});
it("renders the per-card Coach CTA when an AI provider is configured", () => {
providerChainResponse = { activeProvider: "openai" };
const html = render();
expect(html).toContain('data-slot="target-coach-cta"');
expect(html).toContain("Ask Coach");
});
it("hides every per-card Coach CTA when no AI provider is configured", () => {
providerChainResponse = { activeProvider: null };
const html = render();
expect(html).not.toContain('data-slot="target-coach-cta"');
});
it("renders nothing different when chainStatus query is still loading", () => {
providerChainResponse = null;
const html = render();
// Same as the "no provider" branch — until we know the provider is
// configured we don't render a CTA whose click would 404.
expect(html).not.toContain('data-slot="target-coach-cta"');
});
// v1.4.37 W5 — Marc directive: when the operator turns off the
// global Coach flag, every Coach affordance on /targets must vanish.
// That is BOTH the per-card CTAs (currently gated through `aiEnabled`)
// AND the page-level <CoachDrawer> mount — so the SSE machinery,
// Sheet portal and source-chip thread never load.
it("hides every per-card Coach CTA when the global Coach flag is off", () => {
coachFlag = false;
providerChainResponse = { activeProvider: "openai" };
const html = render();
expect(html).not.toContain('data-slot="target-coach-cta"');
});
it("does not mount the CoachDrawer when the global Coach flag is off", () => {
coachFlag = false;
providerChainResponse = { activeProvider: "openai" };
const html = render();
// The drawer renders a Sheet that the body markup exposes via a
// role + the i18n key it carries. We grep on the localised
// composer label that the drawer always paints.
expect(html).not.toContain('data-slot="coach-drawer"');
expect(html).not.toContain('data-slot="coach-drawer-composer"');
});
});
-204
View File
@@ -1,204 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.19 phase A7 — the maintainer reported the `/targets` (Zielwerte) page
* still surfaces English status labels even when the rest of the UI
* renders in German: "Low / On Target / Stable / Moderate" stayed in
* the source language. The card titles ("Weight", "Blood pressure",
* "Body fat" …) had the same issue — server returns English
* strings, the page renders them verbatim.
*
* Translate every classification category and card label on the
* client. The server keeps emitting stable English keys (so external
* consumers / logs don't churn); the page resolves them to i18n
* strings via `useTranslations()`.
*/
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/targets",
}));
const sampleData = {
targets: [
{
type: "WEIGHT",
label: "Weight",
current: 88.2,
average30: 88.6,
trend: "stable",
unit: "kg",
range: { min: 60, max: 80 },
classification: { category: "Normal", color: "#50fa7b" },
source: "WHO BMI",
},
{
type: "BLOOD_PRESSURE_IN_TARGET",
label: "Blood pressure on target",
current: 65,
average30: 65,
trend: null,
unit: "%",
range: { min: 70, max: 100 },
classification: { category: "Moderate", color: "#f1fa8c" },
source: "ESH 2023",
},
{
type: "PULSE",
label: "Resting pulse",
current: 72,
average30: 72,
trend: "stable",
unit: "bpm",
range: { min: 60, max: 100 },
classification: { category: "On target", color: "#50fa7b" },
source: "AHA",
},
{
type: "MOOD_STABILITY",
label: "Mood stability",
current: 0.6,
average30: 0.6,
trend: null,
unit: "σ",
range: { min: 0, max: 0.5 },
classification: { category: "Stable", color: "#f1fa8c" },
source: "moodLog",
},
{
type: "MEDICATION_COMPLIANCE",
label: "Medication compliance",
current: 60,
average30: 60,
trend: null,
unit: "%",
range: { min: 90, max: 100 },
classification: { category: "Low", color: "#ff5555" },
source: "7-day",
},
],
bpDiastolic: { current: null, average30: null, range: null },
profile: { heightCm: 180, age: 35, gender: "MALE", glucoseUnit: null },
};
vi.mock("@tanstack/react-query", () => {
const noopClient = {
invalidateQueries: vi.fn(),
setQueryData: vi.fn(),
getQueryData: vi.fn(),
refetchQueries: vi.fn(),
};
return {
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
// v1.4.25 W3e — the targets page now also queries
// ["insights", "provider-chain"] to gate the per-card Coach
// CTA. Return null for that query (no AI provider configured)
// so the CTA hides and the existing test cases keep asserting
// the unchanged visual surface.
if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") {
return {
data: null,
isLoading: false,
isError: false,
refetch: vi.fn(),
};
}
return {
data: sampleData,
isLoading: false,
isError: false,
refetch: vi.fn(),
};
},
useQueryClient: () => noopClient,
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
reset: vi.fn(),
}),
};
});
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "marc",
email: "marc@example.com",
role: "USER",
},
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
// v1.4.37 W5 — `/targets/page.tsx` now reads `useFeatureFlags` to gate
// the Coach drawer + per-card CTAs. The hook resolves QueryClientContext
// from `@tanstack/react-query`, and this test mocks the library
// without exposing that context. Mock the hook directly with the
// all-on default so the i18n assertions below run against the visible
// Coach surfaces.
vi.mock("@/hooks/use-feature-flags", async () => {
const actual = await vi.importActual<
typeof import("@/hooks/use-feature-flags")
>("@/hooks/use-feature-flags");
return {
...actual,
useFeatureFlags: () => actual.DEFAULT_ASSISTANT_FLAGS,
};
});
import { I18nProvider } from "@/lib/i18n/context";
import TargetsPage from "../targets/page";
function render(locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<TargetsPage />
</I18nProvider>,
);
}
describe("/targets page — i18n status labels and titles", () => {
it("translates card titles to German when the locale is `de`", () => {
const html = render("de");
expect(html).toContain("Gewicht"); // Weight
expect(html).toContain("Ruhepuls"); // Resting pulse
expect(html).toContain("Stimmungsstabilität"); // Mood stability
expect(html).toContain("Einnahmetreue"); // Medication compliance
// Server-emitted English labels must not leak.
expect(html).not.toContain("Resting pulse");
expect(html).not.toContain("Mood stability");
expect(html).not.toContain("Medication compliance");
});
it("translates every status badge category to German", () => {
const html = render("de");
// the maintainer explicitly named these in his bug report.
expect(html).not.toMatch(/>Low</);
expect(html).not.toMatch(/>On target</);
expect(html).not.toMatch(/>Stable</);
expect(html).not.toMatch(/>Moderate</);
// Their German renderings must show up instead.
expect(html).toContain("Niedrig"); // Low
expect(html).toContain("Im Zielbereich"); // On target
expect(html).toContain("Stabil"); // Stable
expect(html).toContain("Moderat"); // Moderate
// "Normal" is identical in EN and DE — the badge should still
// render the localised key (`targets.status.normal` → "Normal"),
// not the raw server string.
expect(html).toContain("Normal");
});
it("English locale still surfaces the original English status labels (no breakage)", () => {
const html = render("en");
expect(html).toContain("Normal");
expect(html).toContain("Moderate");
expect(html).toContain("On target");
expect(html).toContain("Stable");
expect(html).toContain("Low");
});
});
@@ -1,148 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.25 W3e — mobile-first responsive layout audit.
*
* Marc directive: "mobile layout must be designed alongside desktop,
* not as an afterthought". This test pins the Tailwind responsive
* utilities so a future refactor cannot quietly collapse the grid
* back to the v1.4.22 `sm:grid-cols-2` and remove the lg three-col
* row.
*
* The three breakpoints we care about:
* • default (<640px): one column, full-width cards
* • sm (640-1023px): two columns
* • lg (1024px+): three columns, slightly wider gutters
*/
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/targets",
}));
const sampleData = {
targets: [
{
type: "WEIGHT",
label: "Weight",
current: 78,
average30: 78,
trend: "stable",
unit: "kg",
range: { min: 60, max: 80 },
classification: { category: "Normal", color: "#50fa7b" },
source: "WHO BMI",
daysInRange7d: 5,
daysLogged7d: 7,
lastMetGoalAt: null,
streakDays: 0,
insufficientData: false,
consistency7d: ["in", "in", "in", "in", "in", "in", "in"],
},
],
pageSummary: {
targetsMetThisWeek: 1,
totalTargets: 1,
streakHighlight: null,
},
bpDiastolic: { current: null, average30: null, range: null },
profile: { heightCm: 180, age: 35, gender: "MALE", glucoseUnit: null },
};
vi.mock("@tanstack/react-query", () => {
const noopClient = {
invalidateQueries: vi.fn(),
setQueryData: vi.fn(),
getQueryData: vi.fn(),
refetchQueries: vi.fn(),
};
return {
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") {
return {
data: { activeProvider: "openai" },
isLoading: false,
isError: false,
refetch: vi.fn(),
};
}
return {
data: sampleData,
isLoading: false,
isError: false,
refetch: vi.fn(),
};
},
useQueryClient: () => noopClient,
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
reset: vi.fn(),
}),
};
});
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "marc",
email: "marc@example.com",
role: "USER",
},
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
// v1.4.37 W5 — `/targets/page.tsx` now reads `useFeatureFlags` to gate
// the Coach drawer + per-card CTAs. The hook resolves QueryClientContext
// from `@tanstack/react-query`, and this test mocks the library
// without exposing that context. Mock the hook directly so the page
// runs with the all-on default (CTAs + drawer mount visible) the
// responsive assertions below pin.
vi.mock("@/hooks/use-feature-flags", async () => {
const actual = await vi.importActual<
typeof import("@/hooks/use-feature-flags")
>("@/hooks/use-feature-flags");
return {
...actual,
useFeatureFlags: () => actual.DEFAULT_ASSISTANT_FLAGS,
};
});
import { I18nProvider } from "@/lib/i18n/context";
import TargetsPage from "../targets/page";
function render(locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<TargetsPage />
</I18nProvider>,
);
}
describe("/targets page — mobile-first responsive grid", () => {
it("uses one column by default, two at sm, three at lg", () => {
const html = render();
expect(html).toMatch(/grid-cols-1/);
expect(html).toMatch(/sm:grid-cols-2/);
expect(html).toMatch(/lg:grid-cols-3/);
});
it("uses gap-4 at mobile/sm, gap-6 at lg", () => {
const html = render();
expect(html).toMatch(/\bgap-4\b/);
expect(html).toMatch(/lg:gap-6/);
});
it("renders the Coach CTA as an icon-only affordance on every target card", () => {
const html = render();
expect(html).toContain('data-slot="target-coach-cta"');
expect(html).toContain('aria-label="Ask Coach"');
expect(html).toContain("size-10");
});
});
-111
View File
@@ -1,111 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.19 phase A7 — `/targets` (Zielwerte) had `space-y-8` between
* the page header (h1 + intro) and the first card grid. At Pixel-5
* the maintainer reported "relativ viel Platz" wasted between the overview
* and the first values. Tighten the rhythm to `space-y-6` (24px)
* which matches the rest of the admin / settings pages.
*/
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/targets",
}));
vi.mock("@tanstack/react-query", () => {
const noopClient = {
invalidateQueries: vi.fn(),
setQueryData: vi.fn(),
getQueryData: vi.fn(),
refetchQueries: vi.fn(),
};
return {
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") {
return {
data: null,
isLoading: false,
isError: false,
refetch: vi.fn(),
};
}
return {
data: {
targets: [],
bpDiastolic: { current: null, average30: null, range: null },
profile: {
heightCm: null,
age: null,
gender: null,
glucoseUnit: null,
},
},
isLoading: false,
isError: false,
refetch: vi.fn(),
};
},
useQueryClient: () => noopClient,
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
reset: vi.fn(),
}),
};
});
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "marc",
email: "marc@example.com",
role: "USER",
},
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
// v1.4.37 W5 — `/targets/page.tsx` now reads `useFeatureFlags` to gate
// the Coach drawer + per-card CTAs. The hook resolves QueryClientContext
// from `@tanstack/react-query`, and this test mocks the library
// without exposing that context. Mock the hook directly with the
// all-on default so the spacing assertions below run against the
// fully-painted page shape.
vi.mock("@/hooks/use-feature-flags", async () => {
const actual = await vi.importActual<
typeof import("@/hooks/use-feature-flags")
>("@/hooks/use-feature-flags");
return {
...actual,
useFeatureFlags: () => actual.DEFAULT_ASSISTANT_FLAGS,
};
});
import { I18nProvider } from "@/lib/i18n/context";
import TargetsPage from "../targets/page";
function render(locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<TargetsPage />
</I18nProvider>,
);
}
describe("/targets page — vertical spacing", () => {
it("uses space-y-6 (not space-y-8) between header and card grid", () => {
const html = render();
// Outer wrapper rhythm. `space-y-8` would leave ~32px of
// unnecessary breathing room between the intro paragraph and the
// first card; `space-y-6` (24px) brings it in line with the
// admin / settings pages.
expect(html).toMatch(/<div[^>]*\bspace-y-6\b/);
expect(html).not.toMatch(/<div[^>]*\bspace-y-8\b/);
});
});
+64 -30
View File
@@ -65,11 +65,27 @@ function jsonRequest(body: unknown): Request {
});
}
interface TestFailureEnvelope {
data: {
ok: false;
providerType: string;
reasonCode: "credentials" | "rate_limited" | "server_error" | "unreachable";
reason: string;
};
error: null;
}
// V3 audit: /api/ai/test was returning provider err.message + bodyExcerpt
// directly to the client, leaking provider URLs / partial keys / internal
// headers. Server now logs full details via annotate() and responds with
// a categorised, generic message.
describe("POST /api/ai/test — provider error leak guard", () => {
// a categorised, secret-free reason.
//
// This route MUST NEVER return a 5xx: a 5xx origin response is rewritten
// by Cloudflare to its own HTML error page, so the browser's res.json()
// crashes with `Unexpected token '<', "<!DOCTYPE "`. Every provider-call
// failure returns HTTP 200 with `{ ok:false, reason }` the client shows
// verbatim.
describe("POST /api/ai/test — provider error leak guard + non-5xx contract", () => {
function makeProviderThatThrows(
err: Error & { httpStatus?: number; bodyExcerpt?: string },
) {
@@ -81,7 +97,7 @@ describe("POST /api/ai/test — provider error leak guard", () => {
} as never);
}
it("does not echo provider err.message back to the client (HIGH coverage gap)", async () => {
it("never returns a 5xx and does not echo provider err.message back to the client", async () => {
const err = Object.assign(
new Error("OpenAI 401 from https://api.openai.com/v1 sk-leaked-key"),
{
@@ -92,49 +108,67 @@ describe("POST /api/ai/test — provider error leak guard", () => {
makeProviderThatThrows(err);
const response = await POST(emptyRequest() as never);
const body = (await response.json()) as ApiErrorEnvelope;
// v1.4.5: credential failures map to 422 (not 502) so Cloudflare
// doesn't replace our JSON body with its HTML error page — that
// rewrite was the root cause of the "Unexpected token '<'…" client
// crash the maintainer hit when typing a bad OpenAI key.
expect(response.status).toBe(422);
expect(body.error ?? "").not.toMatch(/sk-/);
expect(body.error ?? "").not.toMatch(/api\.openai\.com/);
expect(body.error ?? "").not.toMatch(/invalid api key/i);
expect(body.error).toBe("Provider rejected the credentials");
// Always JSON-parseable, never a 5xx (the Cloudflare HTML-rewrite
// root cause of "Unexpected token '<'…").
expect(response.status).toBe(200);
const body = (await response.json()) as TestFailureEnvelope;
expect(body.data.ok).toBe(false);
expect(body.data.reasonCode).toBe("credentials");
// The stable machine code must itself be secret-free.
expect(body.data.reasonCode).not.toMatch(/sk-/);
expect(body.data.reason).not.toMatch(/sk-/);
expect(body.data.reason).not.toMatch(/api\.openai\.com/);
expect(body.data.reason).not.toMatch(/invalid api key/i);
expect(body.data.reason).toMatch(/re-authenticate/i);
});
it("returns the 429-categorised message when the provider rate-limits", async () => {
it("reclassifies a 5xx whose body signals an invalidated session as a credential failure", async () => {
// The shape an operator hit: re-auth, gateway answers 500 with
// "authentication token has been invalidated" instead of a 401.
makeProviderThatThrows(
Object.assign(new Error("upstream 500"), {
httpStatus: 500 as const,
bodyExcerpt:
'{"error":{"message":"Your authentication token has been invalidated. Please try signing in again."}}',
}),
);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(200);
const body = (await response.json()) as TestFailureEnvelope;
expect(body.data.ok).toBe(false);
expect(body.data.reasonCode).toBe("credentials");
expect(body.data.reason).toMatch(/re-authenticate/i);
});
it("categorises a 429 rate-limit", async () => {
makeProviderThatThrows(
Object.assign(new Error("429 from openai"), { httpStatus: 429 as const }),
);
const response = await POST(emptyRequest() as never);
// v1.4.5: rate-limit passes through as 429 (was 502 in v1.4.4) so
// the React Query mutation can read the JSON body instead of
// tripping over Cloudflare's HTML 502.
expect(response.status).toBe(429);
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
"Provider rate-limited the request",
);
expect(response.status).toBe(200);
const body = (await response.json()) as TestFailureEnvelope;
expect(body.data.reasonCode).toBe("rate_limited");
expect(body.data.reason).toMatch(/rate-limited/i);
});
it("returns the 5xx-categorised message when the provider has a server error", async () => {
it("categorises a plain 5xx provider server error (no auth signal)", async () => {
makeProviderThatThrows(
Object.assign(new Error("503 upstream"), { httpStatus: 503 as const }),
);
const response = await POST(emptyRequest() as never);
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
"Provider returned a server error",
);
expect(response.status).toBe(200);
const body = (await response.json()) as TestFailureEnvelope;
expect(body.data.reasonCode).toBe("server_error");
expect(body.data.reason).toMatch(/server error/i);
});
it("returns the unknown-error fallback otherwise", async () => {
it("categorises a network/timeout failure", async () => {
makeProviderThatThrows(new Error("ECONNRESET"));
const response = await POST(emptyRequest() as never);
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
"Provider connection failed",
);
expect(response.status).toBe(200);
const body = (await response.json()) as TestFailureEnvelope;
expect(body.data.reasonCode).toBe("unreachable");
expect(body.data.reason).toMatch(/could not reach/i);
});
});
+68 -24
View File
@@ -81,7 +81,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// V3 audit: do not return provider error message + bodyExcerpt to the
// client (leaks provider URL / partial keys / internal headers).
// Log full details server-side for the operator and respond with a
// categorised, generic message.
// categorised, generic reason.
const err = e as Error & { httpStatus?: number; bodyExcerpt?: string };
annotate({
meta: {
@@ -91,28 +91,72 @@ export const POST = apiHandler(async (request: NextRequest) => {
ai_test_provider: provider.type,
},
});
// Map upstream status to a *client-side* status code that won't be
// rewritten by Cloudflare. 5xx from origin causes Cloudflare to swap
// the body for its own HTML error page — `await res.json()` in the
// browser then crashes with `Unexpected token '<', "<!DOCTYPE "`,
// which is exactly how the maintainer encountered this when typing a bad
// OpenAI key. 4xx codes pass through untouched.
const status = err.httpStatus ?? 0;
let outboundStatus: number;
let safeMessage: string;
if (status === 401 || status === 403) {
outboundStatus = 422;
safeMessage = "Provider rejected the credentials";
} else if (status === 429) {
outboundStatus = 429;
safeMessage = "Provider rate-limited the request";
} else if (status >= 500) {
outboundStatus = 502;
safeMessage = "Provider returned a server error";
} else {
outboundStatus = 422;
safeMessage = "Provider connection failed";
}
return apiError(safeMessage, outboundStatus);
// This route MUST NEVER return a 5xx. A 5xx origin response is
// rewritten by Cloudflare / the reverse proxy to its own HTML error
// page, so the browser's `res.json()` crashes with
// `Unexpected token '<', "<!DOCTYPE "` (Safari: "The string did not
// match the expected pattern"). That is exactly the failure an
// operator hit re-authenticating a provider whose token came back as
// an invalidated-session 500. We always reply 200 with a categorised,
// secret-free `{ ok:false, reason }` the client can show verbatim.
const { reasonCode, reason } = classifyTestFailure(err);
return apiSuccess({
ok: false,
providerType: provider.type,
reasonCode,
reason,
});
}
});
/**
* Stable, machine-readable failure categories. The client maps each to a
* localised string; the English `reason` stays as a fallback for any
* legacy / unmapped code. Secret-free by construction.
*/
type TestFailureCode =
| "credentials"
| "rate_limited"
| "server_error"
| "unreachable";
/**
* Map an upstream provider failure to a human-readable, secret-free
* reason string. Distinguishes credential/auth, rate-limit, provider
* server-error and network/timeout classes. A 5xx whose body carries an
* auth signal (invalidated session token, expired key, "sign in again")
* is reclassified as a credential failure — that is the shape the
* operator's re-auth produced, where the gateway answered 500 instead of
* the expected 401.
*/
function classifyTestFailure(
err: Error & { httpStatus?: number; bodyExcerpt?: string },
): { reasonCode: TestFailureCode; reason: string } {
const status = err.httpStatus ?? 0;
const haystack = `${err.message} ${err.bodyExcerpt ?? ""}`;
const looksLikeAuth =
/authentication|invalidated|expired|sign in again|api key/i.test(haystack);
if (status === 401 || status === 403 || (status >= 500 && looksLikeAuth)) {
return {
reasonCode: "credentials",
reason: "Provider rejected the credentials — re-authenticate in AI settings.",
};
}
if (status === 429) {
return {
reasonCode: "rate_limited",
reason: "Provider rate-limited the request — try again shortly.",
};
}
if (status >= 500) {
return {
reasonCode: "server_error",
reason: "The AI provider returned a server error.",
};
}
return {
reasonCode: "unreachable",
reason: "Could not reach the AI provider.",
};
}
+72
View File
@@ -0,0 +1,72 @@
/**
* v1.9.0 — single-metric period-over-period range read.
*
* `GET /api/analytics/range?type=<MeasurementType>&range=7d|30d|90d|1y`
* returns the current-window aggregate, the previous comparable window, and
* the composed delta. Single metric type by construction — the Insights
* metric page is single-metric — so the read is one
* `readBestGranularityRollups` covering the trailing `2N` days, sliced into
* the two halves. No per-type fan-out, no Prisma-pool burst (the opposite of
* the deleted 15-way live walk).
*
* The cached `/api/analytics?slice=summaries` slice is deliberately left
* untouched: adding a range dimension to its single shared cache key would
* fragment the hottest cache on the dashboard. This dedicated route carries
* its own per-(type, range) cache slot on the client (`queryKeys.analyticsRange`).
*
* iOS-safe: additive new route, no change to the `/api/analytics` envelope.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import {
ANALYTICS_RANGES,
computeRangeDelta,
type AnalyticsRange,
} from "@/lib/analytics/range-delta";
import type { MeasurementType } from "@/generated/prisma/client";
export const dynamic = "force-dynamic";
const rangeQuerySchema = z.object({
type: measurementTypeEnum,
range: z.enum(ANALYTICS_RANGES),
});
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const parsed = rangeQuerySchema.safeParse({
type: request.nextUrl.searchParams.get("type"),
range: request.nextUrl.searchParams.get("range"),
});
if (!parsed.success) {
annotate({
action: { name: "analytics.range.invalid-params" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422);
}
const type = parsed.data.type as MeasurementType;
const range = parsed.data.range as AnalyticsRange;
const result = await computeRangeDelta(user.id, type, range);
annotate({
action: { name: "analytics.range.read" },
meta: {
type,
range,
granularity: result.granularity,
current_count: result.current.count,
previous_count: result.previous.count,
},
});
return apiSuccess(result);
});
@@ -0,0 +1,129 @@
/**
* v1.8.5 — user-level injection-site preferences endpoint.
*
* GET /api/auth/me/injection-site-prefs
* → `{ globalExcludedInjectionSites: InjectionSite[] }`
*
* PATCH /api/auth/me/injection-site-prefs
* Body: `{ globalExcludedInjectionSites: InjectionSite[] }`
* → the resolved next state (idempotent hard-set).
*
* The global exclusion is a per-user deny-list. Sites listed here are
* never offered for ANY medication's injection-site picker, and the
* intake write path rejects (422) a submitted site that lands on this
* list — even when a medication's `allowedInjectionSites` lists it as
* preferred (deny always wins).
*
* Mirrors the `unit-preference` per-user-scalar pattern: 60/min rate
* limit, Zod safeParse → 422 via `returnAllZodIssues`, audit-log row,
* field-by-field write (no mass assignment).
*/
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
injectionSiteEnum,
INJECTION_SITE_VALUES,
type InjectionSiteValue,
} from "@/lib/validations/medication";
import { NextRequest } from "next/server";
export const dynamic = "force-dynamic";
const PATCH_RATE_LIMIT = 60;
const PATCH_WINDOW_MS = 60_000;
const patchBodySchema = z.object({
// Dedup at write time; cap at the full enum size. An empty array
// clears the exclusion.
globalExcludedInjectionSites: z
.array(injectionSiteEnum)
.max(INJECTION_SITE_VALUES.length)
.meta({
description:
"User-level injection-site deny-list. Sites here are never offered for any medication and rejected at intake (deny wins). Empty array clears the exclusion.",
}),
});
interface InjectionSitePrefsResponse {
globalExcludedInjectionSites: InjectionSiteValue[];
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.injection-site-prefs.get" } });
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { globalExcludedInjectionSites: true },
});
const payload: InjectionSitePrefsResponse = {
globalExcludedInjectionSites: (row?.globalExcludedInjectionSites ??
[]) as InjectionSiteValue[],
};
return apiSuccess(payload);
});
export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`injection-site-prefs:patch:${user.id}`,
PATCH_RATE_LIMIT,
PATCH_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = patchBodySchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "auth.me.injection-site-prefs.patch.invalid_shape" },
});
return returnAllZodIssues(parsed.error, 422);
}
// Dedup while preserving canonical order from the enum tuple.
const submitted = new Set(parsed.data.globalExcludedInjectionSites);
const next = INJECTION_SITE_VALUES.filter((s) => submitted.has(s));
await prisma.user.update({
where: { id: user.id },
data: { globalExcludedInjectionSites: next },
});
await auditLog("user.injection-site-prefs.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { count: next.length },
});
annotate({
action: { name: "auth.me.injection-site-prefs.patch" },
meta: { excluded_count: next.length },
});
const payload: InjectionSitePrefsResponse = {
globalExcludedInjectionSites: next,
};
return apiSuccess(payload);
});
+1
View File
@@ -66,6 +66,7 @@ export const GET = apiHandler(async () => {
// v1.7.0 — health-record export identity fields. All optional.
fullName: user.fullName ?? null,
insurerName: user.insurerName ?? null,
insurerIkNumber: user.insurerIkNumber ?? null,
insuranceNumber,
});
});
+1
View File
@@ -33,6 +33,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
timezone: result.user.timezone,
fullName: result.user.fullName,
insurerName: result.user.insurerName,
insurerIkNumber: result.user.insurerIkNumber,
hasInsuranceNumber: result.user.hasInsuranceNumber,
});
});
@@ -623,6 +623,68 @@ describe("GET /api/dashboard/summary", () => {
]);
});
it("projects one row per timesOfDay for a twice-daily schedule", async () => {
// A single MedicationSchedule row carrying two first-class
// `timesOfDay` is two distinct dose slots per day. Pre-fix the
// projector minted only the `windowStart` slot, so the second daily
// dose never appeared in the today-tile and the event-count
// compliance rollup read half the expected doses (a 2×/day med
// showed 50%). Assert the backfill mints both slots.
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-21T03:00:00.000Z"));
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([
{
id: "med-twice-daily",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00Z"),
schedules: [
{
id: "sched-bid",
medicationId: "med-twice-daily",
windowStart: "07:00",
windowEnd: "07:30",
daysOfWeek: null,
timesOfDay: ["07:00", "19:00"],
reminderGraceMinutes: null,
rrule: null,
rollingIntervalDays: null,
},
],
},
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockImplementation(((
args: unknown,
) => {
const a = args as { where: { OR?: unknown }; select?: { id?: boolean } };
if (a.where.OR) return Promise.resolve([]) as never;
if (!a.select?.id) return Promise.resolve([]) as never;
return Promise.resolve([]) as never;
}) as never);
vi.mocked(prisma.medicationIntakeEvent.createMany).mockResolvedValue({
count: 2,
} as never);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
expect(prisma.medicationIntakeEvent.createMany).toHaveBeenCalledTimes(1);
const args = vi.mocked(prisma.medicationIntakeEvent.createMany).mock
.calls[0][0] as {
data: Array<{ medicationId: string; scheduledFor: Date }>;
};
expect(args.data).toHaveLength(2);
const hours = args.data
.map((r) => new Date(r.scheduledFor).getUTCHours())
.sort((a, b) => a - b);
// 07:00 and 19:00 in the default zone (Europe/Berlin, UTC+2 in May)
// materialise to 05:00 and 17:00 UTC. The assertion that matters is
// two distinct slots, not one.
expect(hours).toEqual([5, 17]);
vi.useRealTimers();
});
it("is idempotent on a second pass (v1.4.39 W-SERVER-FIX-2)", async () => {
// When the existence probe already sees a row for the projected
// slot (e.g. the intake route or the reminder worker minted it
@@ -9,6 +9,7 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { PDFParse } from "pdf-parse";
vi.mock("@/lib/db", () => ({
prisma: {
@@ -38,14 +39,27 @@ const SESSION_OK = {
user: { id: "user-1", email: "test@example.com", role: "USER" },
} as const;
function mkReq(body: unknown): NextRequest {
function mkReq(
body: unknown,
headers: Record<string, string> = {},
): NextRequest {
return new NextRequest("http://localhost/api/export/health-record", {
method: "POST",
headers: { "content-type": "application/json" },
headers: { "content-type": "application/json", ...headers },
body: JSON.stringify(body),
});
}
async function pdfText(res: Response): Promise<string> {
const bytes = new Uint8Array(await res.arrayBuffer());
const parser = new PDFParse({ data: bytes });
try {
return (await parser.getText()).text;
} finally {
await parser.destroy();
}
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
@@ -137,6 +151,46 @@ describe("POST /api/export/health-record — outputs", () => {
expect(buf.subarray(0, 2).toString("latin1")).toBe("PK");
});
it("renders the PDF in the explicit selection locale", async () => {
const { POST } = await import("../route");
const res = await POST(
mkReq({ format: "pdf", locale: "de", practiceName: "Sample Practice" }),
);
const text = await pdfText(res);
// German cover label.
expect(text).toContain("Praxis:");
});
it("falls back to the healthlog-locale cookie when no locale is sent", async () => {
const { POST } = await import("../route");
const res = await POST(
mkReq(
{ format: "pdf", practiceName: "Sample Practice" },
{
// Browser default English, but the in-app cookie says German — the
// cookie must win over Accept-Language.
"accept-language": "en-US,en;q=0.9",
cookie: "healthlog-locale=de",
},
),
);
const text = await pdfText(res);
expect(text).toContain("Praxis:");
expect(text).not.toContain("Practice:");
});
it("uses Accept-Language only when neither selection nor cookie is present", async () => {
const { POST } = await import("../route");
const res = await POST(
mkReq(
{ format: "pdf", practiceName: "Sample Practice" },
{ "accept-language": "en-US,en;q=0.9" },
),
);
const text = await pdfText(res);
expect(text).toContain("Practice:");
});
it("scopes the aggregator measurement read to the session user", async () => {
const { POST } = await import("../route");
await POST(mkReq({ format: "fhir", userId: "user-2" } as never));
+43 -5
View File
@@ -39,14 +39,17 @@ import {
sanitisePracticeName,
} from "@/lib/doctor-report-data";
import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle";
import {
buildFhirDocumentBundle,
GERMAN_ATC_DEFAULT_LOCALES,
} from "@/lib/fhir/build-bundle";
import {
exportSelectionSchema,
toDoctorReportPrefs,
} from "@/lib/validations/health-record-export";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
import { type Locale } from "@/lib/i18n/config";
import { locales, type Locale } from "@/lib/i18n/config";
import { resolveUserTimezone } from "@/lib/tz/resolver";
export const POST = apiHandler(async (request: NextRequest) => {
@@ -58,7 +61,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Maximum 10 exports per hour", 429);
}
const { data: body, error: jsonError } = await safeJson(request);
// The selection payload is small and bounded — format, range,
// section toggles, a few flags, an optional practice-name string.
// 64 KB is far above any legitimate selection while still rejecting
// a multi-megabyte body before it reaches `JSON.parse`. A DoS
// ceiling, not a tight bound.
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = exportSelectionSchema.safeParse(body);
@@ -72,12 +82,30 @@ export const POST = apiHandler(async (request: NextRequest) => {
const sections = toDoctorReportPrefs(selection.sections);
const includeCharts = selection.includeCharts ?? true;
// Locale resolution, most-specific first: the explicit selection wins, then
// the in-app `healthlog-locale` cookie (what the user actually chose in the
// UI), and only then the browser's Accept-Language header. The header is the
// weakest signal — a German user on an English-default browser would
// otherwise get an English report.
const cookieValue = request.cookies.get("healthlog-locale")?.value;
const cookieLocale = (locales as readonly string[]).includes(
cookieValue ?? "",
)
? (cookieValue as Locale)
: null;
const locale: Locale =
selection.locale ??
cookieLocale ??
parseLocaleFromAcceptLanguage(
request.headers.get("accept-language") ?? "",
);
// BfArM ATC: an explicit selection flag wins; otherwise derive it from a
// German-region locale. The WHO ATC coding is unaffected either way.
const germanAtc =
selection.germanAtc ??
(GERMAN_ATC_DEFAULT_LOCALES as readonly string[]).includes(locale);
const [data, userTz, userRow] = await Promise.all([
collectDoctorReportData(user.id, range, { practiceName, sections }),
resolveUserTimezone(user.id),
@@ -124,7 +152,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
if (selection.format === "fhir") {
const bundle = buildFhirDocumentBundle(data, { insuranceNumber });
const bundle = buildFhirDocumentBundle(
data,
{ insuranceNumber },
undefined,
{ germanAtc },
);
const json = JSON.stringify(bundle);
annotate({
meta: { format: "fhir", bytes: json.length, days: range.days },
@@ -166,7 +199,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
// format === "package": one zip holding the PDF + FHIR Bundle + a README.
const bundle = buildFhirDocumentBundle(data, { insuranceNumber });
const bundle = buildFhirDocumentBundle(
data,
{ insuranceNumber },
undefined,
{ germanAtc },
);
const readme = t("doctorReport.packageReadme");
const zipped = zipSync(
{
@@ -39,9 +39,28 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray<string> = [
"src/app/api/insights/bmi-status/route.ts",
"src/app/api/insights/cards/route.ts",
"src/app/api/insights/correlations/route.ts",
// v1.10.0 — generic derived-wellness-metric route. Pure compute over
// the rollup tier; gates on the same `insightStatus` sub-flag as the
// assessment routes (no Coach prose).
"src/app/api/insights/derived/route.ts",
// v1.10.0 — batched derived-metric route (the dashboard fan-out fix).
// Same pure compute + `insightStatus` sub-flag as the single route.
"src/app/api/insights/derived/batch/route.ts",
"src/app/api/insights/medication-compliance-status/route.ts",
// v1.8.7.1 — generic per-HealthKit-metric assessment. Gated on the
// same `insightStatus` sub-flag as the seven specialised status routes.
"src/app/api/insights/metric-status/route.ts",
"src/app/api/insights/mood-status/route.ts",
// v1.9.0 — on-demand full assessment warm. Warms the same assessment
// cards the status routes serve, so it gates on `insightStatus`, not
// `coach`: a user with assessments enabled but Coach disabled can warm.
"src/app/api/insights/pregenerate/route.ts",
"src/app/api/insights/pulse-status/route.ts",
// v1.10.0 — device-flagged event awareness timeline (categorical
// events, WX-B). Pure DB read of the device's own verdicts; gates on
// the same `insightStatus` sub-flag as the assessment routes (no Coach
// prose).
"src/app/api/insights/rhythm-events/route.ts",
"src/app/api/insights/weight-status/route.ts",
];
@@ -23,9 +23,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
const locale = resolveBloodPressureStatusLocale(resolved);
// v1.8.3 — read-only: serve the cache, enqueue generation out of band on
// a miss. The GET never awaits the provider, so opening /insights/<metric>
// can no longer pin the main thread behind a cold LLM round-trip.
const result = await generateBloodPressureStatusForUser(user.id, {
locale,
force: false,
readOnly: true,
});
annotate({ action: { name: "insights.blood-pressure-status" } });
+4
View File
@@ -23,9 +23,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
const locale = resolveBmiStatusLocale(resolved);
// v1.8.3 — read-only: serve the cache, enqueue generation out of band on
// a miss. The GET never awaits the provider, so opening /insights/<metric>
// can no longer pin the main thread behind a cold LLM round-trip.
const result = await generateBmiStatusForUser(user.id, {
locale,
force: false,
readOnly: true,
});
annotate({ action: { name: "insights.bmi-status" } });
@@ -5,6 +5,9 @@ vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { create: vi.fn() },
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
user: { findUnique: vi.fn().mockResolvedValue({ timezone: "Europe/Berlin" }) },
measurement: { findMany: vi.fn().mockResolvedValue([]) },
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
},
}));
@@ -31,7 +34,6 @@ vi.mock("next/headers", () => ({
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
const SESSION_OK = {
@@ -47,6 +49,16 @@ beforeEach(() => {
(prisma.appSettings.findUnique as ReturnType<typeof vi.fn>).mockResolvedValue(
null,
);
// v1.10.0 — the discovery engine reads the profile timezone + the
// measurement / mood series. Default to an empty corpus so the engine
// returns no discovered pairs (the n ≥ 20 gate trivially fails).
(prisma.user.findUnique as ReturnType<typeof vi.fn>).mockResolvedValue({
timezone: "Europe/Berlin",
});
(prisma.measurement.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
[],
);
(prisma.moodEntry.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([]);
});
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
@@ -61,21 +73,16 @@ describe("GET /api/insights/correlations", () => {
expect(res.status).toBe(401);
});
it("returns an empty array as the placeholder", async () => {
it("returns the discovery result shape (no pairs on an empty corpus)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as { data: unknown[] };
expect(body.data).toEqual([]);
});
it("writes the empty-state audit log", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
await callGet(makeReq());
expect(auditLog).toHaveBeenCalledWith(
"insights.correlations.empty",
expect.objectContaining({ userId: "user-1" }),
);
const body = (await res.json()) as {
data: { discovered: unknown[]; pairsTested: number; fdrQ: number };
};
expect(body.data.discovered).toEqual([]);
expect(body.data.pairsTested).toBe(0);
expect(body.data.fdrQ).toBeGreaterThan(0);
});
it("returns 403 + errorCode when the correlations flag is off", async () => {
+125 -12
View File
@@ -1,29 +1,142 @@
/**
* GET /api/insights/correlations — placeholder iOS endpoint.
* GET /api/insights/correlations — FDR-controlled correlation discovery.
*
* The full correlations engine is part of `/api/insights/comprehensive`
* and is intentionally not re-exposed yet — iOS only needs a stable
* shape to render the empty state. We return `[]` and emit an audit log
* so the gap is visible until Phase 7 fills it in.
* v1.10.0 — promotes the former placeholder to the real discovery engine.
* Scans a curated behaviour × outcome matrix (daylight / mood / glucose /
* BP / steps × sleep / HRV / resting HR / weight), lag-joins each behaviour
* day to the NEXT day's outcome, runs Pearson with the exact Student-t
* p-value, and applies Benjamini-Hochberg FDR control across every tested
* pair so only statistically-defensible patterns surface. Every surfaced
* pair carries n, r, p, and the BH-adjusted q, framed descriptive — never
* causal.
*
* Reads daily series bounded to a trailing window, day-keyed in the user's
* display timezone (late-night readings mis-bucket under UTC). Pure compute
* lives in `src/lib/insights/correlation-discovery.ts`; this route only
* fetches + day-keys + responds. No LLM, no narrative, no cache table.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { prisma } from "@/lib/db";
import { getLocalDateParts } from "@/lib/timezone";
import type { MeasurementType } from "@/generated/prisma/client";
import {
discoverCorrelations,
DISCOVERY_BEHAVIOURS,
DISCOVERY_OUTCOMES,
type DailySeriesPoint,
type NamedSeries,
} from "@/lib/insights/correlation-discovery";
export const dynamic = "force-dynamic";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/** Trailing window for the discovery scan (days). */
const WINDOW_DAYS = 180;
/** Day key (YYYY-MM-DD) for an instant in the user's display timezone. */
function tzDayKey(at: Date, tz: string): string {
const { year, month, day } = getLocalDateParts(at, tz);
const mm = String(month).padStart(2, "0");
const dd = String(day).padStart(2, "0");
return `${year}-${mm}-${dd}`;
}
/** Collapse rows to per-day means keyed in the user's tz. */
function toDailyMeans(
rows: Array<{ value: number; at: Date }>,
tz: string,
): DailySeriesPoint[] {
const byDay = new Map<string, { sum: number; count: number }>();
for (const r of rows) {
if (!Number.isFinite(r.value)) continue;
const day = tzDayKey(r.at, tz);
const acc = byDay.get(day) ?? { sum: 0, count: 0 };
acc.sum += r.value;
acc.count += 1;
byDay.set(day, acc);
}
return [...byDay.entries()]
.map(([day, acc]) => ({ day, value: acc.sum / acc.count }))
.sort((a, b) => (a.day < b.day ? -1 : 1));
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// v1.4.31 — operator can hide the correlation narration tile.
// Operator can hide the correlation surface entirely.
await requireAssistantSurface("correlations");
annotate({ action: { name: "insights.correlations" } });
await auditLog("insights.correlations.empty", {
userId: user.id,
details: { reason: "phase7_pending" },
const profile = await prisma.user.findUnique({
where: { id: user.id },
select: { timezone: true },
});
const tz = profile?.timezone ?? "Europe/Berlin";
const since = new Date(Date.now() - WINDOW_DAYS * MS_PER_DAY);
// Behaviour channels are measurement-backed except MOOD (mood entries).
const behaviourTypes = DISCOVERY_BEHAVIOURS.filter(
(k) => k !== "MOOD",
) as MeasurementType[];
const outcomeTypes = [...DISCOVERY_OUTCOMES] as MeasurementType[];
const [measurements, moodEntries] = await Promise.all([
prisma.measurement.findMany({
where: {
userId: user.id,
deletedAt: null,
measuredAt: { gte: since },
type: { in: [...behaviourTypes, ...outcomeTypes] },
},
orderBy: { measuredAt: "asc" },
take: 20000,
select: { type: true, value: true, measuredAt: true },
}),
prisma.moodEntry.findMany({
where: { userId: user.id, deletedAt: null, moodLoggedAt: { gte: since } },
orderBy: { moodLoggedAt: "asc" },
take: 5000,
select: { score: true, moodLoggedAt: true },
}),
]);
const measurementsByType = new Map<string, Array<{ value: number; at: Date }>>();
for (const m of measurements) {
const list = measurementsByType.get(m.type) ?? [];
list.push({ value: m.value, at: m.measuredAt });
measurementsByType.set(m.type, list);
}
const series: NamedSeries[] = [];
for (const key of DISCOVERY_BEHAVIOURS) {
const points =
key === "MOOD"
? toDailyMeans(
moodEntries.map((e) => ({ value: e.score, at: e.moodLoggedAt })),
tz,
)
: toDailyMeans(measurementsByType.get(key) ?? [], tz);
series.push({ key, role: "behaviour", points });
}
for (const key of DISCOVERY_OUTCOMES) {
series.push({
key,
role: "outcome",
points: toDailyMeans(measurementsByType.get(key) ?? [], tz),
});
}
const result = discoverCorrelations(series);
annotate({
action: { name: "insights.correlations.discover" },
meta: {
pairs_tested: result.pairsTested,
discovered: result.discovered.length,
fdr_q: result.fdrQ,
},
});
return apiSuccess([]);
return apiSuccess(result);
});
@@ -0,0 +1,148 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
user: {
findUnique: vi.fn().mockResolvedValue({
dateOfBirth: new Date("1986-01-01"),
gender: "MALE",
}),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
// Stub the compute layer so the route test stays isolated.
vi.mock("@/lib/insights/derived", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/insights/derived")>();
return {
...actual,
computeDerivedMetric: vi.fn(async () => ({
status: "ok",
value: { type: "RESTING_HEART_RATE", center: 55, low: 48, high: 62, spread: 7, sampleDays: 30, k: 3 },
coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] },
confidence: { score: 100, band: "high" },
provenance: { inputs: ["RESTING_HEART_RATE"], source: "DAY", windowDays: 30, computedAt: "2026-06-02T07:00:00+02:00" },
})),
};
});
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { computeDerivedMetric } from "@/lib/insights/derived";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
};
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
function makeReq(metric?: string, type?: string): NextRequest {
const url = new URL("http://localhost/api/insights/derived");
if (metric !== undefined) url.searchParams.set("metric", metric);
if (type !== undefined) url.searchParams.set("type", type);
return new NextRequest(url);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: new Date("1986-01-01"),
gender: "MALE",
} as never);
});
describe("GET /api/insights/derived", () => {
it("returns 200 + a flat Derived envelope for a valid metric", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeReq("VITALS_BASELINE", "RESTING_HEART_RATE"));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
metric: string;
status: string;
value: { center: number } | null;
coverage: { historyDays: number };
confidence: { band: string } | null;
provenance: { source: string };
reason: string | null;
} | null;
error: string | null;
};
expect(body.error).toBeNull();
expect(body.data?.metric).toBe("VITALS_BASELINE");
expect(body.data?.status).toBe("ok");
expect(body.data?.value?.center).toBe(55);
expect(body.data?.confidence?.band).toBe("high");
expect(body.data?.provenance.source).toBe("DAY");
expect(body.data?.reason).toBeNull();
});
it("narrows userId from the session into the compute call (never a query field)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
await callGet(makeReq("VITALS_BASELINE"));
expect(computeDerivedMetric).toHaveBeenCalledWith(
expect.objectContaining({ userId: "user-1", metric: "VITALS_BASELINE" }),
);
});
it("422s on an unknown metric without calling compute", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeReq("NOT_A_METRIC"));
expect(res.status).toBe(422);
expect(computeDerivedMetric).not.toHaveBeenCalled();
});
it("422s on a missing metric param", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeReq());
expect(res.status).toBe(422);
expect(computeDerivedMetric).not.toHaveBeenCalled();
});
it("401s when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await callGet(makeReq("VITALS_BASELINE"));
expect(res.status).toBe(401);
});
it("flattens an insufficient result with a null value + reason", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(computeDerivedMetric).mockResolvedValueOnce({
status: "insufficient",
coverage: { requiredInputs: 1, presentInputs: 0, historyDays: 0, missing: ["RESTING_HEART_RATE"] },
provenance: { inputs: ["RESTING_HEART_RATE"], source: "none", windowDays: 30, computedAt: "2026-06-02T07:00:00+02:00" },
reason: "no_readings_in_window",
} as never);
const res = await callGet(makeReq("VITALS_BASELINE"));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { status: string; value: unknown; confidence: unknown; reason: string } | null;
};
expect(body.data?.status).toBe("insufficient");
expect(body.data?.value).toBeNull();
expect(body.data?.confidence).toBeNull();
expect(body.data?.reason).toBe("no_readings_in_window");
});
});
@@ -0,0 +1,146 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
user: {
findUnique: vi.fn().mockResolvedValue({
dateOfBirth: new Date("1986-01-01"),
gender: "MALE",
heightCm: 180,
}),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
// Stub the compute layer; keep the real registry + loadBaselineProfile.
vi.mock("@/lib/insights/derived", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/insights/derived")>();
return {
...actual,
computeDerivedMetric: vi.fn(async (args: { metric: string }) => ({
status: "ok",
value: { metric: args.metric, n: 1 },
coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] },
confidence: { score: 100, band: "high" },
provenance: { inputs: [], source: "DAY", windowDays: 30, computedAt: "2026-06-02T07:00:00+02:00" },
})),
};
});
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { computeDerivedMetric } from "@/lib/insights/derived";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
};
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
function makeReq(metrics?: string): NextRequest {
const url = new URL("http://localhost/api/insights/derived/batch");
if (metrics !== undefined) url.searchParams.set("metrics", metrics);
return new NextRequest(url);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: new Date("1986-01-01"),
gender: "MALE",
heightCm: 180,
} as never);
});
describe("GET /api/insights/derived/batch", () => {
it("resolves several metrics into one keyed map", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(
makeReq("READINESS,BMI,VITALS_BASELINE:WEIGHT"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { metrics: Record<string, { metric: string; status: string }> };
error: string | null;
};
expect(body.error).toBeNull();
expect(Object.keys(body.data.metrics).sort()).toEqual([
"BMI",
"READINESS",
"VITALS_BASELINE:WEIGHT",
]);
expect(body.data.metrics["VITALS_BASELINE:WEIGHT"].status).toBe("ok");
expect(body.data.metrics.READINESS.metric).toBe("READINESS");
});
it("loads the profile once regardless of metric count", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
await callGet(makeReq("READINESS,BMI,FITNESS_AGE,HRV_BALANCE"));
// One User read for the whole batch — the pool-contention mitigation.
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
expect(computeDerivedMetric).toHaveBeenCalledTimes(4);
});
it("narrows userId from the session into every compute call", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
await callGet(makeReq("READINESS"));
expect(computeDerivedMetric).toHaveBeenCalledWith(
expect.objectContaining({ userId: "user-1", metric: "READINESS" }),
);
});
it("collapses a duplicate token so it cannot inflate the fan-out", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
await callGet(makeReq("BMI,BMI,BMI"));
expect(computeDerivedMetric).toHaveBeenCalledTimes(1);
});
it("422s when any token is an unknown metric id", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeReq("READINESS,NOT_A_METRIC"));
expect(res.status).toBe(422);
expect(computeDerivedMetric).not.toHaveBeenCalled();
});
it("422s on a missing metrics param", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeReq());
expect(res.status).toBe(422);
expect(computeDerivedMetric).not.toHaveBeenCalled();
});
it("422s on an empty metrics list", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeReq(" , , "));
expect(res.status).toBe(422);
expect(computeDerivedMetric).not.toHaveBeenCalled();
});
it("401s when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await callGet(makeReq("READINESS"));
expect(res.status).toBe(401);
});
});
+190
View File
@@ -0,0 +1,190 @@
/**
* v1.10.0 — batched derived-wellness-metric route.
*
* `GET /api/insights/derived/batch?metrics=<id,id,…>[&types=<type,…>]`
* resolves several derived metrics in ONE request and returns a map keyed
* by the per-request key (`<metric>` or `<metric>:<type>` for a
* VITALS_BASELINE sub-target). It exists to collapse the Insights cold
* mount's 14+ independent `GET /api/insights/derived` requests — each
* sharing one Prisma pool — into a single request that fans out
* server-side under a bounded `p-limit`, the exact pool-starvation class
* that bit v1.4.49.1 / v1.4.40 (the "app hangs then recovers" symptom).
*
* Honors the same contract as the single route: closed registry enum
* (unknown id → 422), cookie OR Bearer auth, `userId` narrowed from the
* session (never a query field), pure compute over the rollup tier (no
* provider call, no cache table). The profile is loaded ONCE via the
* shared `loadBaselineProfile` and threaded into every compute, so the
* batch never re-reads the `User` row per metric.
*
* The single-metric route stays for the anatomy detail pages (one metric
* per page); this route backs the dashboard's one-shot grid read.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import pLimit from "p-limit";
import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { prisma } from "@/lib/db";
import {
computeDerivedMetric,
loadBaselineProfile,
isDerivedMetricId,
type DerivedMetricId,
} from "@/lib/insights/derived";
export const dynamic = "force-dynamic";
// Bounded internal fan-out — matches the W-POOL `p-limit(4)` discipline
// v1.4.40 applied to the per-type analytics walk so a batch of 14 metrics
// never floods the shared Prisma pool.
const BATCH_CONCURRENCY = 4;
// Defensive ceiling — the dashboard requests ~11 metrics + ~6 baseline
// sub-targets; cap the list so a crafted query can't fan out unbounded.
const MAX_METRICS = 24;
/**
* A single batch request item: a registered metric id with an optional
* VITALS_BASELINE sub-target. The wire form is `metric` or `metric:type`
* inside the `metrics` CSV; an entry's `type` is parsed off the colon.
*/
interface BatchItem {
metric: DerivedMetricId;
type: string | null;
/** The map key the response is keyed by (mirrors the request token). */
key: string;
}
const batchQuerySchema = z.object({
metrics: z
.string()
.min(1)
.max(1024)
.describe("CSV of derived-metric tokens (`metric` or `metric:type`)."),
});
/**
* Parse the `metrics` CSV into validated items. An entry `metric:type`
* sub-targets a VITALS_BASELINE vital; a bare `metric` carries no type.
* Unknown ids are returned as `invalid` so the route 422s the whole
* request rather than silently dropping a metric (the closed-enum rule).
* Duplicate keys collapse to one item (last write wins) so a repeated
* token can't inflate the fan-out.
*/
function parseBatchItems(csv: string): {
items: BatchItem[];
invalid: string[];
} {
const seen = new Map<string, BatchItem>();
const invalid: string[] = [];
for (const raw of csv.split(",")) {
const token = raw.trim();
if (token === "") continue;
const colon = token.indexOf(":");
const metric = colon === -1 ? token : token.slice(0, colon);
const type = colon === -1 ? null : token.slice(colon + 1) || null;
if (!isDerivedMetricId(metric)) {
invalid.push(metric);
continue;
}
const key = type ? `${metric}:${type}` : metric;
seen.set(key, { metric, type, key });
}
return { items: [...seen.values()], invalid };
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
await requireAssistantSurface("insightStatus");
const parsed = batchQuerySchema.safeParse({
metrics: request.nextUrl.searchParams.get("metrics"),
});
if (!parsed.success) {
annotate({
action: { name: "insights.derived.batch.invalid-query" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422);
}
const { items, invalid } = parseBatchItems(parsed.data.metrics);
if (invalid.length > 0) {
// Mirror the single route's closed-enum rule — an unknown id is a
// contract violation, not a silent drop. The id name is the caller's
// own input, never user health data, so it is safe to echo.
annotate({
action: { name: "insights.derived.batch.invalid-metric" },
meta: { invalid_count: invalid.length },
});
return apiError(
`Unknown derived metric id(s): ${invalid.join(", ")}`,
422,
);
}
if (items.length === 0 || items.length > MAX_METRICS) {
annotate({
action: { name: "insights.derived.batch.invalid-count" },
meta: { requested: items.length, max: MAX_METRICS },
});
return apiError(
items.length === 0
? "metrics must name at least one registered derived metric"
: `metrics must name at most ${MAX_METRICS} entries`,
422,
);
}
// Profile read ONCE — the pool-contention mitigation; the same shared
// loader the nightly score jobs use, so the batch never re-reads the
// User row per metric.
const profile = await loadBaselineProfile(prisma, user.id);
// Fan out under a bounded limiter so a 17-item dashboard read resolves
// four computes at a time against the shared pool, not seventeen.
const limit = pLimit(BATCH_CONCURRENCY);
const now = new Date();
const results = await Promise.all(
items.map((item) =>
limit(async () => {
const derived = await computeDerivedMetric({
metric: item.metric,
userId: user.id,
profile,
type: item.type,
now,
});
return {
key: item.key,
payload: {
metric: item.metric,
status: derived.status,
value: derived.status === "ok" ? derived.value : null,
coverage: derived.coverage,
confidence: derived.status === "ok" ? derived.confidence : null,
provenance: derived.provenance,
reason:
derived.status === "insufficient" ? derived.reason : null,
},
};
}),
),
);
const map: Record<string, (typeof results)[number]["payload"]> = {};
for (const r of results) map[r.key] = r.payload;
annotate({
action: { name: "insights.derived.batch" },
meta: {
requested: items.length,
ok: results.filter((r) => r.payload.status === "ok").length,
},
});
// `metrics` is a map keyed by the per-request token so the client reads
// back exactly what it asked for (`VITALS_BASELINE:WEIGHT`, `READINESS`).
return apiSuccess({ metrics: map });
});
+94
View File
@@ -0,0 +1,94 @@
/**
* v1.10.0 — generic derived-wellness-metric route.
*
* `GET /api/insights/derived?metric=<DERIVED_METRIC_ID>[&type=<MeasurementType>]`
* serves the compute-once `Derived<T>` value for any metric registered
* in `derived/registry.ts`. Mirrors the v1.8.7.1 `metric-status` route
* precedent: `apiHandler` wrapper, Zod `safeParse` on the query, a closed
* registry enum (unknown id → 422 via `returnAllZodIssues`), cookie OR
* Bearer auth, `userId` narrowed from the session/Bearer (never a query
* field).
*
* The `data` payload is the flat `Derived<T>` union so iOS can decode +
* combine values across metrics: `{ metric, status, value?, coverage,
* confidence?, provenance, reason? }`. Numbers are pure compute over the
* rollup tier (Tier-1, no narrative) — no provider call, no cache table.
*
* Wave 1 implements `VITALS_BASELINE` end-to-end; other registered ids
* return a `not_implemented` insufficient until their compute lands in a
* later wave (never a 500, never a fabricated value).
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { prisma } from "@/lib/db";
import {
computeDerivedMetric,
loadBaselineProfile,
DERIVED_METRIC_IDS,
type DerivedMetricId,
} from "@/lib/insights/derived";
export const dynamic = "force-dynamic";
// Closed enum derived from the registry so the route + registry cannot
// drift — an id added to the registry is accepted automatically, an
// unknown id 422s rather than silently resolving to no metric.
const derivedQuerySchema = z.object({
metric: z.enum(DERIVED_METRIC_IDS as [string, ...string[]]),
// Optional sub-target for metrics that baseline a single chosen vital
// (VITALS_BASELINE). Validated against the engine's supported set in
// the dispatcher; an unsupported value yields an `insufficient`, not a
// 422, so the contract stays forgiving for iOS combinations.
type: z.string().optional(),
});
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
await requireAssistantSurface("insightStatus");
const parsed = derivedQuerySchema.safeParse({
metric: request.nextUrl.searchParams.get("metric"),
type: request.nextUrl.searchParams.get("type") ?? undefined,
});
if (!parsed.success) {
annotate({
action: { name: "insights.derived.invalid-metric" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422);
}
const metric = parsed.data.metric as DerivedMetricId;
// Profile read once via the shared loader (the same one the batch route
// and the nightly score jobs use), passed into the pure compute function
// — never re-fetched per metric.
const profile = await loadBaselineProfile(prisma, user.id);
const derived = await computeDerivedMetric({
metric,
userId: user.id,
profile,
type: parsed.data.type ?? null,
});
annotate({
action: { name: "insights.derived" },
meta: { metric, status: derived.status },
});
// Flatten the discriminated union for the wire: `metric` tags it,
// `value`/`reason` are nullable so iOS decodes one stable shape.
return apiSuccess({
metric,
status: derived.status,
value: derived.status === "ok" ? derived.value : null,
coverage: derived.coverage,
confidence: derived.status === "ok" ? derived.confidence : null,
provenance: derived.provenance,
reason: derived.status === "insufficient" ? derived.reason : null,
});
});
+24
View File
@@ -11,6 +11,11 @@ import {
detectGlp1Plateau,
buildGlp1PlateauPrompt,
} from "@/lib/insights/glp1-plateau";
import {
detectDerivedBriefingSignals,
buildDerivedBriefingPrompt,
} from "@/lib/insights/derived-briefing";
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
import {
buildUserPrompt,
type ComparisonSnapshot,
@@ -199,6 +204,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
// LLM never sees the excluded blocks.
insightsExcludeMetrics: true,
locale: true,
// v1.10.0 — profile for the derived-signal briefing detector.
dateOfBirth: true,
gender: true,
heightCm: true,
},
});
@@ -397,6 +406,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (plateauContext) {
userPrompt += buildGlp1PlateauPrompt(plateauContext, locale);
}
// v1.10.0 — fold a notable derived wellness signal (readiness / recovery
// shift, confidence-gated) into the briefing as a SYSTEM CONTEXT block.
// Reads the one derived contract; null (the common case) costs nothing.
const derivedSex =
dbUser?.gender === "MALE" || dbUser?.gender === "FEMALE"
? (dbUser.gender as "MALE" | "FEMALE")
: null;
const derivedBriefing = await detectDerivedBriefingSignals(userId, {
ageYears: getAgeFromDateOfBirth(dbUser?.dateOfBirth ?? null),
sex: derivedSex,
heightCm: dbUser?.heightCm ?? null,
});
if (derivedBriefing) {
userPrompt += buildDerivedBriefingPrompt(derivedBriefing, locale);
}
let result;
let workingProviderType: string;
+1 -1
View File
@@ -4,7 +4,7 @@
* Returns a chronological merge of every GLP-1-relevant event the user
* has logged: dose changes, injections (with site if recorded),
* inventory events, and side-effect-tagged mood entries from the
* trailing 90 days. Used by /insights/medikamente's TherapyTimeline
* trailing 90 days. Used by /insights/medications' TherapyTimeline
* component. Web-only users without a GLP-1 medication get
* `hasGlp1: false` so the component hides cleanly.
*/
@@ -60,6 +60,7 @@ import { annotate } from "@/lib/logging/context";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
import { __resetAuditDedupMemoForTests } from "@/lib/audit-dedup";
import { DEFAULT_INSIGHTS_LAYOUT } from "@/lib/insights-layout";
import { SUB_PAGE_SLUGS } from "@/lib/insights/sub-page-metric";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -122,7 +123,7 @@ describe("GET /api/insights/layout", () => {
version: 1,
tiles: [
{ id: "overview", visible: false, order: 0 },
{ id: "blutdruck", visible: true, order: 1 },
{ id: "blood-pressure", visible: true, order: 1 },
],
},
} as never);
@@ -141,12 +142,42 @@ describe("GET /api/insights/layout", () => {
order: 0,
});
expect(body.data.tiles[1]).toEqual({
id: "blutdruck",
id: "blood-pressure",
visible: true,
order: 1,
});
expect(body.data.tiles.length).toBe(DEFAULT_INSIGHTS_LAYOUT.tiles.length);
});
it("normalises a legacy German tile id stored by a ≤ v1.7.x client to its canonical English id on read", async () => {
// A layout persisted before the v1.8.0 rename (or by an iOS client
// still speaking the German ids). The resolver must surface the
// canonical English id so the GET contract is current-build clean
// without forcing a re-PUT.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
insightsLayoutJson: {
version: 1,
tiles: [
{ id: "overview", visible: true, order: 0 },
{ id: "blutdruck", visible: true, order: 1 },
{ id: "stimmung", visible: false, order: 2 },
],
},
} as never);
const res = await callGet();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { tiles: Array<{ id: string; visible: boolean; order: number }> };
};
const ids = body.data.tiles.map((t) => t.id);
// Legacy ids collapse to their English replacement…
expect(ids).toContain("blood-pressure");
expect(ids).toContain("mood");
// …and the German originals never leak back out.
expect(ids).not.toContain("blutdruck");
expect(ids).not.toContain("stimmung");
});
});
describe("PUT /api/insights/layout — happy path", () => {
@@ -157,8 +188,8 @@ describe("PUT /api/insights/layout — happy path", () => {
version: 1,
tiles: [
{ id: "overview", visible: true, order: 0 },
{ id: "blutdruck", visible: true, order: 1 },
{ id: "puls", visible: false, order: 2 },
{ id: "blood-pressure", visible: true, order: 1 },
{ id: "pulse", visible: false, order: 2 },
],
};
const res = await callPut(makeReq(payload));
@@ -168,7 +199,7 @@ describe("PUT /api/insights/layout — happy path", () => {
};
expect(body.data.tiles[0]?.id).toBe("overview");
expect(body.data.tiles[0]?.order).toBe(0);
expect(body.data.tiles[2]?.id).toBe("puls");
expect(body.data.tiles[2]?.id).toBe("pulse");
expect(body.data.tiles[2]?.visible).toBe(false);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
@@ -179,6 +210,152 @@ describe("PUT /api/insights/layout — happy path", () => {
expect(updateCall.where.id).toBe("user-1");
expect(updateCall.data.insightsLayoutJson).toBeTruthy();
});
it("accepts a body carrying legacy German tile ids and persists the canonical English ones", async () => {
// Non-breaking iOS contract: a client still sending the pre-v1.8.0
// German ids must NOT 422. The route validates the legacy ids and
// `serializeInsightsLayout` normalises them to English before the
// row persists, so the stored blob is always canonical.
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const payload = {
version: 1,
tiles: [
{ id: "overview", visible: true, order: 0 },
{ id: "blutdruck", visible: true, order: 1 },
{ id: "medikamente", visible: true, order: 2 },
],
};
const res = await callPut(makeReq(payload));
// No 422 — the legacy ids pass validation.
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { tiles: Array<{ id: string }> };
};
const returnedIds = body.data.tiles.map((t) => t.id);
expect(returnedIds).toContain("blood-pressure");
expect(returnedIds).toContain("medications");
expect(returnedIds).not.toContain("blutdruck");
expect(returnedIds).not.toContain("medikamente");
// The persisted blob is canonical too.
const updateCall = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as unknown as {
data: { insightsLayoutJson: { tiles: Array<{ id: string }> } };
};
const persistedIds = updateCall.data.insightsLayoutJson.tiles.map(
(t) => t.id,
);
expect(persistedIds).toContain("blood-pressure");
expect(persistedIds).toContain("medications");
expect(persistedIds).not.toContain("blutdruck");
expect(persistedIds).not.toContain("medikamente");
});
});
describe("PUT /api/insights/layout — full metric-slug universe", () => {
// v1.8.7.1 — the layout tile-id enum derives from `SUB_PAGE_SLUGS`, so
// the long-tail HealthKit + body-composition + mobility + audio slugs
// the routed tab strip already exposes are now valid layout ids. iOS
// can persist a layout covering the full metric set rather than 422ing
// on the ~25 slugs the fixed allow-list did not know about.
const NEWLY_ACCEPTED_SLUGS = [
"blood-glucose",
"skin-temperature",
"respiratory-rate",
"flights-climbed",
"walking-speed",
"vascular-age",
"headphone-audio",
"muscle-mass",
"daylight",
] as const;
it("accepts a layout that includes every previously-rejected sub-page slug", async () => {
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const tiles = NEWLY_ACCEPTED_SLUGS.map((id, order) => ({
id,
visible: order % 2 === 0,
order,
}));
const res = await callPut(makeReq({ version: 1, tiles }));
// No 422 — these are all canonical sub-page slugs now.
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { tiles: Array<{ id: string; visible: boolean }> };
};
const returnedIds = body.data.tiles.map((t) => t.id);
for (const slug of NEWLY_ACCEPTED_SLUGS) {
expect(returnedIds).toContain(slug);
}
});
it("accepts a layout covering the ENTIRE slug universe in one PUT", async () => {
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const tiles = ["overview", ...SUB_PAGE_SLUGS].map((id, order) => ({
id,
visible: true,
order,
}));
const res = await callPut(makeReq({ version: 1, tiles }));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { tiles: Array<{ id: string }> };
};
const returnedIds = new Set(body.data.tiles.map((t) => t.id));
// Every slug the web tab strip enumerates round-trips through the PUT.
for (const slug of SUB_PAGE_SLUGS) {
expect(returnedIds.has(slug)).toBe(true);
}
expect(returnedIds.has("overview")).toBe(true);
});
it("round-trips the new slugs back through GET after a save", async () => {
const savedTiles = NEWLY_ACCEPTED_SLUGS.map((id, order) => ({
id,
visible: true,
order: order + 1,
}));
vi.mocked(prisma.user.findUnique).mockResolvedValue({
insightsLayoutJson: {
version: 1,
tiles: [{ id: "overview", visible: true, order: 0 }, ...savedTiles],
},
} as never);
const res = await callGet();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { tiles: Array<{ id: string }> };
};
const ids = body.data.tiles.map((t) => t.id);
for (const slug of NEWLY_ACCEPTED_SLUGS) {
expect(ids).toContain(slug);
}
});
it("still rejects a genuinely-unknown id even with the wider set", async () => {
const res = await callPut(
makeReq({
version: 1,
tiles: [
{ id: "blood-glucose", visible: true, order: 0 },
{ id: "not-a-metric-at-all", visible: true, order: 1 },
],
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string }> };
};
expect(
body.details.issues.some((i) => i.path.startsWith("tiles")),
).toBe(true);
});
});
describe("PUT /api/insights/layout — 422 multi-issue envelope", () => {
+13 -6
View File
@@ -27,7 +27,7 @@ import {
resolveInsightsLayout,
serializeInsightsLayout,
DEFAULT_INSIGHTS_LAYOUT,
INSIGHTS_TILE_IDS,
ACCEPTED_INSIGHTS_TILE_IDS,
type InsightsLayout,
} from "@/lib/insights-layout";
import { Prisma } from "@/generated/prisma/client";
@@ -38,11 +38,18 @@ import { redactSensitiveFields } from "@/lib/observability/redact-payload";
import { shouldEmitAuditRow } from "@/lib/audit-dedup";
import type { NextRequest } from "next/server";
// Derive the enum from `INSIGHTS_TILE_IDS` so the schema + the default
// layout cannot drift — the same root-cause class that produced the
// v1.4.16 A5 silent-422 on dashboard widgets when `achievements` was
// added to the layout but not to the validation enum.
const tileIdEnum = z.enum(INSIGHTS_TILE_IDS);
// Derive the enum from `ACCEPTED_INSIGHTS_TILE_IDS` so the schema + the
// default layout cannot drift — the same root-cause class that produced
// the v1.4.16 A5 silent-422 on dashboard widgets when `achievements`
// was added to the layout but not to the validation enum.
//
// v1.8.0 — the accepted set is canonical English ids PLUS the legacy
// German aliases, so an iOS client still PUTting `blutdruck` / `puls` /
// … passes validation rather than tripping a 422 on the rename.
// `serializeInsightsLayout` normalises any legacy id onto its canonical
// English replacement before the row persists, so the stored blob is
// always canonical regardless of which id the client sent.
const tileIdEnum = z.enum(ACCEPTED_INSIGHTS_TILE_IDS);
const layoutSchema = z.object({
version: z.literal(1),
@@ -23,9 +23,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
const locale = resolveMedicationComplianceStatusLocale(resolved);
// v1.8.3 — read-only: serve the cache, enqueue generation out of band on
// a miss. The GET never awaits the provider, so opening /insights/<metric>
// can no longer pin the main thread behind a cold LLM round-trip.
const result = await generateMedicationComplianceStatusForUser(user.id, {
locale,
force: false,
readOnly: true,
});
annotate({ action: { name: "insights.medication-compliance-status" } });
@@ -0,0 +1,127 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
// Stub the heavy generator so the route test stays isolated from the
// real status pipeline.
vi.mock("@/lib/insights/metric-status", () => ({
generateMetricStatus: vi.fn(async () => ({
hasProvider: true,
text: "ok",
cached: true,
updatedAt: new Date().toISOString(),
})),
resolveMetricStatusLocale: () => "en",
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { generateMetricStatus } from "@/lib/insights/metric-status";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
};
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
function makeReq(metric?: string): NextRequest {
const url = new URL("http://localhost/api/insights/metric-status");
if (metric !== undefined) url.searchParams.set("metric", metric);
return new NextRequest(url);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
});
describe("GET /api/insights/metric-status", () => {
it("returns 200 + envelope for a valid registered metric", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeReq("RESTING_HEART_RATE"));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { text: string; hasProvider: boolean } | null;
error: string | null;
};
expect(body.error).toBeNull();
expect(body.data?.text).toBe("ok");
expect(generateMetricStatus).toHaveBeenCalledWith(
expect.objectContaining({
metric: "RESTING_HEART_RATE",
userId: "user-1",
readOnly: true,
}),
);
});
it("422s on an unknown metric without calling the generator", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeReq("NOT_A_METRIC"));
expect(res.status).toBe(422);
expect(generateMetricStatus).not.toHaveBeenCalled();
});
it("422s on a missing metric param", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeReq());
expect(res.status).toBe(422);
expect(generateMetricStatus).not.toHaveBeenCalled();
});
it("422s when a specialised metric is requested through the generic route", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeReq("WEIGHT"));
expect(res.status).toBe(422);
expect(generateMetricStatus).not.toHaveBeenCalled();
});
it("401s when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await callGet(makeReq("SLEEP_DURATION"));
expect(res.status).toBe(401);
});
it("403s + errorCode when insightStatus is disabled", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: false,
assistantCorrelationsEnabled: true,
assistantHealthScoreExplainerEnabled: true,
} as never);
const res = await callGet(makeReq("SLEEP_DURATION"));
expect(res.status).toBe(403);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus");
});
});
@@ -0,0 +1,86 @@
/**
* v1.8.7.1 — generic per-HealthKit-metric assessment route.
*
* `GET /api/insights/metric-status?metric=<METRIC_ID>` serves the
* data-driven assessment for any metric registered in
* `metric-status-registry.ts` (RESTING_HEART_RATE, SLEEP_DURATION, …).
* The seven specialised metrics keep their own `*-status` routes; this
* single generic route covers the ~30 HealthKit pages without one route
* per metric.
*
* Read-only by construction (v1.8.3 freeze fix): a cache miss enqueues an
* out-of-band generation and serves the last-good (stale-while-revalidate)
* text, never blocking on the provider. An unknown `metric` 422s via the
* closed registry enum.
*
* Unlike the seven legacy `*-status` routes, this one carries OpenAPI
* coverage (the route schema lives in `src/lib/openapi/routes.ts`),
* establishing the pattern the legacy routes can be backfilled onto later.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
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 { requireAssistantSurface } from "@/lib/feature-flags";
import {
generateMetricStatus,
resolveMetricStatusLocale,
} from "@/lib/insights/metric-status";
import {
METRIC_STATUS_IDS,
type MetricStatusMetricId,
} from "@/lib/insights/metric-status-registry";
export const dynamic = "force-dynamic";
// Closed enum derived from the registry so the route + registry cannot
// drift — an id added to the registry is accepted automatically, and an
// unknown id 422s rather than silently resolving to no card.
const metricQuerySchema = z.object({
metric: z.enum(METRIC_STATUS_IDS as [string, ...string[]]),
});
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
await requireAssistantSurface("insightStatus");
const parsed = metricQuerySchema.safeParse({
metric: request.nextUrl.searchParams.get("metric"),
});
if (!parsed.success) {
annotate({
action: { name: "insights.metric-status.invalid-metric" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422);
}
const metric = parsed.data.metric as MetricStatusMetricId;
const localeParam = request.nextUrl.searchParams.get("locale");
const resolved = await resolveServerLocale({
request,
userLocale: user.locale ?? null,
override: localeParam,
});
const locale = resolveMetricStatusLocale(resolved);
const result = await generateMetricStatus({
metric,
userId: user.id,
locale,
force: false,
readOnly: true,
});
annotate({
action: { name: "insights.metric-status" },
meta: { metric },
});
return apiSuccess(result);
});
@@ -23,9 +23,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
const locale = resolveMoodStatusLocale(resolved);
// v1.8.3 — read-only: serve the cache, enqueue generation out of band on
// a miss. The GET never awaits the provider, so opening /insights/<metric>
// can no longer pin the main thread behind a cold LLM round-trip.
const result = await generateMoodStatusForUser(user.id, {
locale,
force: false,
readOnly: true,
});
annotate({ action: { name: "insights.mood-status" } });
@@ -0,0 +1,119 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
// Always allow the anti-spam bucket so the surface gate is what the test
// exercises.
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
// Stub the worker enqueue so the route test stays isolated from pg-boss.
vi.mock("@/lib/jobs/insight-pregenerate-shared", () => ({
enqueueForceWarm: vi.fn(async () => undefined),
}));
import { POST } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
};
const callPost = POST as unknown as (req: NextRequest) => Promise<Response>;
function makeReq(): NextRequest {
return new NextRequest(
new URL("http://localhost/api/insights/pregenerate"),
{ method: "POST" },
);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
});
describe("POST /api/insights/pregenerate", () => {
it("enqueues a warm when insightStatus is enabled", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callPost(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { queued: boolean } | null;
error: string | null;
};
expect(body.error).toBeNull();
expect(body.data?.queued).toBe(true);
expect(enqueueForceWarm).toHaveBeenCalledWith(
expect.objectContaining({ userId: "user-1" }),
);
});
it("gates on insightStatus, not coach — a coach-disabled user can still warm", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
assistantEnabled: true,
assistantCoachEnabled: false,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: true,
assistantHealthScoreExplainerEnabled: true,
} as never);
const res = await callPost(makeReq());
expect(res.status).toBe(200);
expect(enqueueForceWarm).toHaveBeenCalledTimes(1);
});
it("403s + errorCode when insightStatus is disabled", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: false,
assistantCorrelationsEnabled: true,
assistantHealthScoreExplainerEnabled: true,
} as never);
const res = await callPost(makeReq());
expect(res.status).toBe(403);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus");
expect(enqueueForceWarm).not.toHaveBeenCalled();
});
it("401s when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await callPost(makeReq());
expect(res.status).toBe(401);
expect(enqueueForceWarm).not.toHaveBeenCalled();
});
});
+71
View File
@@ -0,0 +1,71 @@
/**
* v1.8.7.1 — on-demand full assessment warm.
*
* `POST /api/insights/pregenerate` enqueues a forced full warm of every
* AI assessment for the calling user — the comprehensive insight (daily
* briefing), the seven specialised `*-status` cards, and every
* data-bearing generic `metric:<ID>` assessment — for the user's active
* locale. The heavy generation runs on the worker (the
* `insight-pregenerate` queue) so this route returns immediately; the
* cards then fill via the existing read-only stale-while-revalidate GETs
* without the user waiting on a provider round-trip.
*
* The forced path bypasses the nightly cron's per-user 20 h budget (so a
* user can warm on demand the moment they land on the page), so this
* route carries its own short anti-spam bucket — one warm per
* `WARM_WINDOW_MS` per user. Empty metrics never reach the provider (the
* worker filters to data-bearing types) and the comprehensive generator
* no-ops without a configured provider, so a spam-free call on a
* provider-less account costs one cheap chain-resolve and no LLM call.
*
* `userId` is always narrowed from the session / Bearer — never a body
* field. Both web and the iOS client can call it; they read the same
* cached routes afterward.
*/
import { NextRequest } from "next/server";
import { apiSuccess, apiError } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
export const dynamic = "force-dynamic";
/** Anti-spam window — one forced warm per user per 3 minutes. */
const WARM_WINDOW_MS = 3 * 60 * 1000;
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// Gate on the same surface as the read-only status routes: this warms
// the assessment cards (the `insightStatus` surface), not the Coach.
// A user with assessments enabled but Coach disabled can still warm.
await requireAssistantSurface("insightStatus");
const userId = user.id;
// Short per-user bucket so the bypassed nightly budget can't be abused
// into a provider-cost amplifier by a tight POST loop. A blocked call
// is harmless — the caches are already being warmed by the prior call.
const rl = await checkRateLimit(`insights-warm:${userId}`, 1, WARM_WINDOW_MS);
if (!rl.allowed) {
return apiError("A warm is already in progress. Try again shortly.", 429);
}
const resolved = await resolveServerLocale({
request,
userLocale: user.locale ?? null,
});
const locale = resolved === "en" ? "en" : "de";
await enqueueForceWarm({ userId, locale });
annotate({
action: { name: "insights.pregenerate.requested" },
meta: { locale },
});
// The work runs on the worker; report that it was accepted, not that it
// is done. The client polls the read-only status GETs for the text.
return apiSuccess({ queued: true, locale });
});
@@ -23,9 +23,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
const locale = resolvePulseStatusLocale(resolved);
// v1.8.3 — read-only: serve the cache, enqueue generation out of band on
// a miss. The GET never awaits the provider, so opening /insights/<metric>
// can no longer pin the main thread behind a cold LLM round-trip.
const result = await generatePulseStatusForUser(user.id, {
locale,
force: false,
readOnly: true,
});
annotate({ action: { name: "insights.pulse-status" } });
@@ -0,0 +1,129 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
/**
* v1.10.0 — categorical events (WX-B). `GET /api/insights/rhythm-events`.
*
* The route serves the device-flagged event awareness timeline. The
* load-bearing behaviour under test: it narrows the query to the EVENT
* MeasurementTypes for the authenticated user, returns the device's own
* verdict per row, and reports `hasEvents: false` (the data-availability
* gate the UI keys off) when the user has none.
*/
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
measurement: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
};
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
function makeReq(): NextRequest {
return new NextRequest(new URL("http://localhost/api/insights/rhythm-events"));
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
});
describe("GET /api/insights/rhythm-events", () => {
it("returns hasEvents:false with an empty list when the user has no events", async () => {
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { events: unknown[]; hasEvents: boolean };
};
expect(body.data.hasEvents).toBe(false);
expect(body.data.events).toEqual([]);
});
it("narrows the query to the EVENT MeasurementTypes for the session user only", async () => {
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
await callGet(makeReq());
const where = vi.mocked(prisma.measurement.findMany).mock.calls[0][0]
?.where as {
userId: string;
type: { in: string[] };
deletedAt: null;
};
expect(where.userId).toBe("user-1");
expect(where.deletedAt).toBeNull();
expect(where.type.in).toEqual(
expect.arrayContaining([
"IRREGULAR_RHYTHM_NOTIFICATION",
"HIGH_HEART_RATE_EVENT",
"LOW_HEART_RATE_EVENT",
"WALKING_STEADINESS_EVENT",
"BREATHING_DISTURBANCE_EVENT",
]),
);
// Never a continuous metric — only the five event classes.
expect(where.type.in).toHaveLength(5);
});
it("maps each row to the device verdict + occurrence timestamp", async () => {
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
id: "evt_1",
type: "IRREGULAR_RHYTHM_NOTIFICATION",
rhythmClassification: "IRREGULAR",
measuredAt: new Date("2026-06-01T09:15:00.000Z"),
source: "APPLE_HEALTH",
deviceType: "watch",
},
] as never);
const res = await callGet(makeReq());
const body = (await res.json()) as {
data: {
hasEvents: boolean;
events: Array<{
id: string;
type: string;
classification: string | null;
occurredAt: string;
}>;
};
};
expect(body.data.hasEvents).toBe(true);
expect(body.data.events).toEqual([
{
id: "evt_1",
type: "IRREGULAR_RHYTHM_NOTIFICATION",
classification: "IRREGULAR",
occurredAt: "2026-06-01T09:15:00.000Z",
source: "APPLE_HEALTH",
deviceType: "watch",
},
]);
});
});
@@ -0,0 +1,83 @@
/**
* v1.10.0 — device-flagged event awareness route (categorical events,
* WX-B).
*
* `GET /api/insights/rhythm-events` returns the authenticated user's
* timeline of device-flagged EVENT rows — irregular-rhythm /
* high-HR / low-HR / walking-steadiness / breathing-disturbance
* notifications the user's wearable (Apple Watch / Withings ScanWatch)
* already produced and synced.
*
* Regulatory framing (load-bearing): this surface is AWARENESS / SCREENING
* of the DEVICE's own decision. HealthLog stores and reflects ONLY the
* classification RESULT the device's FDA-cleared / CE-marked on-device
* algorithm emitted. It never ingests a raw ECG waveform, never
* re-classifies, and never produces a HealthLog diagnosis. The verdict the
* client renders (`rhythmClassification`) is the device's, verbatim.
*
* Data-availability-gated by construction: an account with no event rows
* gets `{ events: [], hasEvents: false }`, and the client un-mounts the
* whole surface rather than painting an empty / alarming card.
*
* Follows the `metric-status` / `derived` route precedent: `apiHandler`
* wrapper, cookie OR Bearer auth, `userId` narrowed from the session
* (never a query field), the `insightStatus` assistant-surface gate (no AI
* provider call — this is a pure DB read).
*/
import { apiSuccess } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { prisma } from "@/lib/db";
import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement";
import type { MeasurementType } from "@/generated/prisma/client";
export const dynamic = "force-dynamic";
// Cap the returned timeline. A healthy account produces a handful of
// these a year; the cap is a defensive bound, not an expected ceiling.
const MAX_EVENTS = 200;
const EVENT_TYPE_LIST = Array.from(EVENT_MEASUREMENT_TYPES) as MeasurementType[];
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
await requireAssistantSurface("insightStatus");
const rows = await prisma.measurement.findMany({
where: {
userId: user.id,
type: { in: EVENT_TYPE_LIST },
deletedAt: null,
},
select: {
id: true,
type: true,
rhythmClassification: true,
measuredAt: true,
source: true,
deviceType: true,
},
orderBy: { measuredAt: "desc" },
take: MAX_EVENTS,
});
const events = rows.map((r) => ({
id: r.id,
type: r.type,
classification: r.rhythmClassification,
occurredAt: r.measuredAt.toISOString(),
source: r.source,
deviceType: r.deviceType,
}));
annotate({
action: { name: "insights.rhythm-events" },
meta: { count: events.length },
});
return apiSuccess({
events,
hasEvents: events.length > 0,
});
});
@@ -23,9 +23,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
const locale = resolveWeightStatusLocale(resolved);
// v1.8.3 — read-only: serve the cache, enqueue generation out of band on
// a miss. The GET never awaits the provider, so opening /insights/<metric>
// can no longer pin the main thread behind a cold LLM round-trip.
const result = await generateWeightStatusForUser(user.id, {
locale,
force: false,
readOnly: true,
});
annotate({ action: { name: "insights.weight-status" } });
+20
View File
@@ -12,6 +12,7 @@ import {
} from "@/lib/api-response";
import { updateMeasurementSchema } from "@/lib/validations/measurement";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
import { Prisma } from "@/generated/prisma/client";
import { NextRequest } from "next/server";
@@ -172,6 +173,18 @@ export const PUT = apiHandler(
console.warn("[measurements] rollup recompute failed", err);
}
// v1.8.0 — drop the cached per-metric assessment rows this edit
// dirties so the next mount / nightly warm pass regenerates against
// the new value. An edit can re-type a row, so invalidate both the
// old and the new type's scopes. Fire-and-forget: never blocks the
// user's edit.
invalidateStatusInsightsForTypes(user.id, [
existing.type,
measurement.type,
]).catch((err) => {
console.warn("[measurements] status-insight invalidate failed", err);
});
return apiSuccess(measurement);
},
);
@@ -238,6 +251,13 @@ export const DELETE = apiHandler(
console.warn("[measurements] rollup recompute failed", err);
}
// v1.8.0 — drop the cached per-metric assessment rows this deletion
// dirties so the next mount / nightly warm pass regenerates against
// the reduced history. Fire-and-forget: never blocks the user's delete.
invalidateStatusInsightsForTypes(user.id, [existing.type]).catch((err) => {
console.warn("[measurements] status-insight invalidate failed", err);
});
return apiSuccess({ deleted: true });
},
);
+70 -7
View File
@@ -46,6 +46,7 @@ import { deviceTypeEnum } from "@/lib/validations/source-priority";
import { checkRateLimit } from "@/lib/rate-limit";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
recomputeBucketsForMeasurement,
collapseToTypeDayKeys,
@@ -61,6 +62,15 @@ const PR_DETECTION_SILENT_THRESHOLD = 50;
const MAX_BATCH_ENTRIES = 500;
// Body-size ceiling backstopping the per-entry cap. A worst-case entry
// (max-length hkIdentifier + unit + externalId + two ISO dates + the
// numeric fields) serialises to well under 1 KB, so a legitimate
// 500-entry batch tops out a few hundred KB. 4 MB leaves an order of
// magnitude of headroom above any real batch while still rejecting a
// multi-megabyte payload before it reaches `JSON.parse` and the heap.
// The cap is a DoS ceiling, not a tight bound.
const MAX_BODY_BYTES = 4 * 1024 * 1024;
// v1.4.25 W10 reconcile (security H-2): cap batch ingest at 60
// batches per user per minute. Healthy iOS sync drains its
// HealthKit observer queue in well under one batch per minute (the
@@ -71,6 +81,17 @@ const MAX_BATCH_ENTRIES = 500;
const BATCH_RATE_LIMIT_MAX = 60;
const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
// v1.8.6 W6 — the set of `source` values this client-facing ingest
// route accepts. Deliberately narrower than the full `MeasurementSource`
// enum: `WITHINGS` and `IMPORT` are server-owned (the Withings webhook
// and the CSV importer mint those rows respectively), so letting an iOS
// client forge rows attributed to them would let an authenticated client
// pollute the per-source canonical picker with rows the server never saw
// from those integrations. The standalone iOS client only needs to tag
// its own adopt-on-pair backfill as `MANUAL` vs the default
// `APPLE_HEALTH` passthrough, so we accept exactly that pair.
const batchSourceEnum = z.enum(["APPLE_HEALTH", "MANUAL"]);
const batchEntrySchema = z.object({
hkIdentifier: z.string().min(1).max(120),
value: z.number().finite(),
@@ -78,8 +99,22 @@ const batchEntrySchema = z.object({
startDate: z.iso.datetime({ offset: true }),
endDate: z.iso.datetime({ offset: true }),
sleepStage: z.number().int().min(0).max(20).optional(),
// v1.10.0 — categorical events (WX-B). The integer `HKCategoryValue`
// codepoint for an EVENT-class category sample (irregular-rhythm,
// high/low-HR, walking-steadiness, breathing-disturbance). Carries the
// device's own verdict / severity; resolved to a `rhythmClassification`
// by `mapAppleHealthEntry`. Ignored for every non-event identifier.
categoryValue: z.number().int().min(0).max(20).optional(),
externalId: z.string().min(1).max(120),
externalSourceVersion: z.string().min(1).max(120).optional(),
// v1.8.6 W6 — optional per-entry source tag. Defaults to
// `APPLE_HEALTH` so every pre-W6 caller (web + current iOS) stays
// byte-for-byte unchanged. The iOS standalone adopt-on-pair backfill
// sends `MANUAL` for rows the user entered by hand on-device so they
// are not mis-attributed to HealthKit. `source` is part of the
// `(userId, type, source, externalId)` dedup key, so a `MANUAL` row
// and an `APPLE_HEALTH` row sharing an externalId are distinct rows.
source: batchSourceEnum.optional(),
// v1.4.25 W8c — optional device-type tag. The iOS client maps the
// `HKDevice.model` of each sample to one of the canonical device
// classes (watch | band | ring | phone | scale | other | unknown).
@@ -151,7 +186,9 @@ async function postBatch(request: NextRequest): Promise<Response> {
return apiError("Too many batch submissions, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson<unknown>(request);
const { data: rawBody, error: jsonError } = await safeJson<unknown>(request, {
maxBytes: MAX_BODY_BYTES,
});
if (jsonError) return jsonError;
// Distinguish "too many entries" from "validation failed" so the
@@ -195,6 +232,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
startDate: entry.startDate,
endDate: entry.endDate,
sleepStage: entry.sleepStage,
categoryValue: entry.categoryValue,
});
if (!mapped) {
@@ -227,11 +265,16 @@ async function postBatch(request: NextRequest): Promise<Response> {
type: mapped.type,
value: mapped.value,
unit: mapped.unit,
source: "APPLE_HEALTH",
// v1.8.6 W6 — honour the per-entry source tag, defaulting to
// `APPLE_HEALTH` when absent so legacy callers are unchanged.
source: entry.source ?? "APPLE_HEALTH",
measuredAt: mapped.takenAt,
externalId: entry.externalId,
externalSourceVersion: entry.externalSourceVersion ?? null,
sleepStage: mapped.sleepStage ?? null,
// v1.10.0 — categorical events (WX-B). The device's own verdict /
// severity for an EVENT row; null for every continuous reading.
rhythmClassification: mapped.rhythmClassification ?? null,
// v1.4.25 W8c — pass the iOS-supplied device-type through to
// the row. Stays null for pre-W8c iOS builds; the canonical
// picker treats null as `unknown` so legacy ingest keeps
@@ -250,31 +293,37 @@ async function postBatch(request: NextRequest): Promise<Response> {
// (createMany with skipDuplicates does the dedup but doesn't tell
// us *which* rows it skipped). We look up existing rows under the
// composite unique key first, then createMany the survivors.
// v1.8.6 W6 — `source` is now part of the dedup key, so the lookup
// matches on (type, source, externalId) per incoming row rather than
// a single hardcoded `APPLE_HEALTH`. A `MANUAL` row and an
// `APPLE_HEALTH` row sharing an externalId are distinct, so each must
// carry its source through the existence probe and the composite key.
const incomingKeys = prepared.map((p) => ({
type: p.row.type as Prisma.MeasurementCreateManyInput["type"],
source: p.row.source as Prisma.MeasurementCreateManyInput["source"],
externalId: p.row.externalId as string,
}));
const existing = await prisma.measurement.findMany({
where: {
userId: user.id,
source: "APPLE_HEALTH",
OR: incomingKeys.map((k) => ({
type: k.type,
source: k.source,
externalId: k.externalId,
})),
},
select: { type: true, externalId: true },
select: { type: true, source: true, externalId: true },
});
const existingSet = new Set(
existing.map((row) => `${row.type}::${row.externalId}`),
existing.map((row) => `${row.type}::${row.source}::${row.externalId}`),
);
const toInsert: Prisma.MeasurementCreateManyInput[] = [];
const toOverwrite: Prepared[] = [];
for (const p of prepared) {
const key = `${p.row.type}::${p.row.externalId}`;
const key = `${p.row.type}::${p.row.source}::${p.row.externalId}`;
if (existingSet.has(key)) {
// v1.5.0 issue #213 — per-day cumulative `stats:*` rows are
// intentionally overwritten on a re-post so today's tile reflects
@@ -299,7 +348,10 @@ async function postBatch(request: NextRequest): Promise<Response> {
await tx.measurement.updateMany({
where: {
userId: user.id,
source: "APPLE_HEALTH",
// v1.8.6 W6 — scope the overwrite to the row's own source
// so a `stats:*` re-post only touches the matching
// (type, source, externalId) row.
source: p.row.source as Prisma.MeasurementCreateManyInput["source"],
type: p.row.type as MeasurementType,
externalId: p.row.externalId as string,
},
@@ -484,6 +536,17 @@ async function postBatch(request: NextRequest): Promise<Response> {
for (const k of insertedKeys) {
await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt);
}
// v1.8.0 — drop the cached per-metric assessment rows the ingested
// types dirty so the next mount / nightly warm pass regenerates
// them against the new data instead of serving stale text.
// Fire-and-forget: never a blocker on the user's ingest.
invalidateStatusInsightsForTypes(
user.id,
insertedKeys.map((k) => k.type),
).catch((err) => {
console.warn("[measurements] status-insight invalidate failed", err);
});
} catch (err) {
console.warn("[measurements] rollup recompute failed", err);
}
@@ -35,6 +35,7 @@ import {
safeJson,
} from "@/lib/api-response";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
recomputeBucketsForMeasurement,
collapseToTypeDayKeys,
@@ -152,6 +153,17 @@ async function deleteByExternalIds(request: NextRequest): Promise<Response> {
} catch (err) {
console.warn("[measurements] rollup recompute failed", err);
}
// v1.8.0 — drop the cached per-metric assessment rows the deleted
// types dirty so the next mount / nightly warm pass regenerates
// against the reconciled history. Fire-and-forget: never blocks the
// iOS reconciliation.
invalidateStatusInsightsForTypes(
user.id,
affectedRows.map((row) => row.type),
).catch((err) => {
console.warn("[measurements] status-insight invalidate failed", err);
});
}
return apiSuccess({ deletedCount: result.count });
+29
View File
@@ -28,6 +28,7 @@ import {
} from "@/lib/measurements/drain-per-sample-cumulative";
import { withIdempotency } from "@/lib/idempotency";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
recomputeBucketsForMeasurement,
collapseToTypeDayKeys,
@@ -327,6 +328,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
// legacy fallback below covers the boot-backfill convergence
// window when an existing bucket pre-dates v1.4.39.
sumValue: true,
minValue: true,
maxValue: true,
},
});
// v1.4.39.2 — coverage-mismatch fallback. v1.4.39.1 wired the
@@ -416,6 +419,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
mean: true,
count: true,
sumValue: true,
minValue: true,
maxValue: true,
},
});
} catch (err) {
@@ -453,6 +458,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
: r.mean,
measuredAt: r.bucketStart.toISOString(),
count: r.count,
// v1.8.5 — per-day min / max so the chart can shade an
// Apple-Health-style range band around the daily mean line.
// Omitted for cumulative metrics (steps, distance, …) where the
// daily value is a SUM, not a central tendency, so an intra-day
// spread band carries no meaning.
minValue: useSum ? undefined : r.minValue,
maxValue: useSum ? undefined : r.maxValue,
}));
annotate({
action: { name: "measurement.list" },
@@ -664,6 +676,16 @@ async function postMeasurement(request: NextRequest) {
for (const k of keys) {
await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt);
}
// v1.8.0 — drop the cached per-metric assessment rows the ingested
// types dirty so the next mount / nightly warm pass regenerates
// them against the new data. Fire-and-forget: never blocks ingest.
invalidateStatusInsightsForTypes(
user.id,
keys.map((k) => k.type),
).catch((err) => {
console.warn("[measurements] status-insight invalidate failed", err);
});
} catch (err) {
console.warn("[measurements] rollup recompute failed", err);
}
@@ -768,5 +790,12 @@ async function postMeasurement(request: NextRequest) {
console.warn("[measurements] rollup recompute failed", err);
}
// v1.8.0 — drop the cached per-metric assessment rows this type
// dirties so the next mount / nightly warm pass regenerates against
// the new row. Fire-and-forget: never blocks the user's write.
invalidateStatusInsightsForTypes(user.id, [measurement.type]).catch((err) => {
console.warn("[measurements] status-insight invalidate failed", err);
});
return apiSuccess(measurement, 201);
}
@@ -0,0 +1,342 @@
/**
* v1.8.1 B15 follow-on — per-slot timing classification for the History
* compliance heatmap.
*
* The `dailyCompliance` builder in `GET /api/medications/[id]/compliance`
* classifies each taken intake into early / on_time / late / very_late
* buckets and paints the heatmap cell off those counts. The bug: a
* twice-daily medication stored as ONE schedule row
* (`timesOfDay = ["07:00","19:00"]`, `windowStart = "07:00"`) matched the
* 19:00 dose against the single `windowStart` "07:00" — classifying a
* perfectly on-time evening dose as `very_late` (~12h late) and painting
* the cell orange even though both doses were taken on time.
*
* The fix matches each taken event to the closest effective time-of-day
* SLOT across every schedule (candidate slots = `timesOfDay` when
* non-empty, else `[windowStart]`) and classifies against that slot's
* window (slot..slot+span, span = windowEnd − windowStart). Single-time
* schedules stay byte-identical to the pre-fix behaviour.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findUnique: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/medications/route-guards", () => ({
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { getUserTodayBounds } from "@/lib/timezone";
import { userDayKey } from "@/lib/tz/format";
// UTC keeps the half-open day slice (`[dayStart, dayEnd)`) and the
// classifier's window anchor (`setUTCHours` on the day-start date) in the
// same reference frame, so the fixture instants below land deterministically
// in range regardless of the host timezone.
const TZ = "UTC";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const, timezone: TZ },
};
const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
// A representative "yesterday" so the day is fully in the past and the
// medication's createdAt clearly precedes it. The route walks 90 days
// back from `new Date()`; this instant lands well inside that window.
const DAY_OFFSET = 1;
function dayBoundsForOffset(offset: number) {
const now = new Date();
const representative = new Date(
now.getTime() - offset * 24 * 60 * 60 * 1000 - 12 * 60 * 60 * 1000,
);
const { start, end } = getUserTodayBounds(representative, TZ);
return { start, endInclusive: end };
}
// Build a UTC instant for a given "HH:mm" anchored on the day-start date,
// mirroring how `classifyIntakeTiming` interprets the window via
// `setUTCHours` on the scheduled date. Keeps the fixture's takenAt aligned
// with the classifier's reference frame regardless of host timezone.
function atSlotUTC(dayStart: Date, hhmm: string): Date {
const [h, m] = hhmm.split(":").map(Number);
const d = new Date(dayStart);
d.setUTCHours(h, m, 0, 0);
return d;
}
function medication(schedules: unknown[]) {
return {
id: "med-1",
userId: "user-1",
createdAt: new Date(Date.now() - 200 * 24 * 60 * 60 * 1000),
startsOn: null,
endsOn: null,
oneShot: false,
schedules,
};
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
});
async function callRoute(): Promise<Record<string, unknown>> {
const res = await GET(new Request("http://localhost"), ROUTE_PARAMS);
expect(res.status).toBe(200);
const body = await res.json();
return body.data.dailyCompliance as Record<string, unknown>;
}
async function callRouteData(): Promise<Record<string, unknown>> {
const res = await GET(new Request("http://localhost"), ROUTE_PARAMS);
expect(res.status).toBe(200);
const body = await res.json();
return body.data as Record<string, unknown>;
}
describe("GET /api/medications/[id]/compliance — per-slot timing", () => {
it("classifies both doses of a single-row twice-daily schedule as on_time (green cell)", async () => {
const { start: dayStart } = dayBoundsForOffset(DAY_OFFSET);
const dateKey = userDayKey(dayStart, TZ);
// ONE schedule row, two times-of-day, a 1h window per slot.
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
medication([
{
id: "sched-1",
windowStart: "07:00",
windowEnd: "08:00",
timesOfDay: ["07:00", "19:00"],
daysOfWeek: null,
rrule: null,
rollingIntervalDays: null,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
]) as never,
);
// Both doses logged right at their slot times.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{
takenAt: atSlotUTC(dayStart, "07:05"),
skipped: false,
scheduledFor: atSlotUTC(dayStart, "07:00"),
},
{
takenAt: atSlotUTC(dayStart, "19:05"),
skipped: false,
scheduledFor: atSlotUTC(dayStart, "19:00"),
},
] as never);
const daily = await callRoute();
const entry = daily[dateKey] as Record<string, number> | undefined;
expect(entry).toBeDefined();
expect(entry!.veryLate).toBe(0);
expect(entry!.late).toBe(0);
// Both doses compliant → green cell.
expect(entry!.onTime).toBe(2);
});
it("keeps single-time schedules byte-identical: a 12h-late dose is still very_late", async () => {
const { start: dayStart } = dayBoundsForOffset(DAY_OFFSET);
const dateKey = userDayKey(dayStart, TZ);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
medication([
{
id: "sched-1",
windowStart: "07:00",
windowEnd: "08:00",
timesOfDay: [], // single-time: candidate = [windowStart]
daysOfWeek: null,
rrule: null,
rollingIntervalDays: null,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
]) as never,
);
// Single 07:00 schedule, dose taken at 19:00 → ~12h late → very_late.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{
takenAt: atSlotUTC(dayStart, "19:00"),
skipped: false,
scheduledFor: atSlotUTC(dayStart, "07:00"),
},
] as never);
const daily = await callRoute();
const entry = daily[dateKey] as Record<string, number> | undefined;
expect(entry).toBeDefined();
expect(entry!.veryLate).toBe(1);
expect(entry!.onTime).toBe(0);
expect(entry!.late).toBe(0);
});
it("handles an overnight per-slot window (23:00 slot, 1h span) as on_time near the slot", async () => {
const { start: dayStart } = dayBoundsForOffset(DAY_OFFSET);
const dateKey = userDayKey(dayStart, TZ);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
medication([
{
id: "sched-1",
windowStart: "23:00",
windowEnd: "00:00", // overnight: end <= start
timesOfDay: ["09:00", "23:00"],
daysOfWeek: null,
rrule: null,
rollingIntervalDays: null,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
]) as never,
);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{
takenAt: atSlotUTC(dayStart, "23:10"),
skipped: false,
scheduledFor: atSlotUTC(dayStart, "23:00"),
},
] as never);
const daily = await callRoute();
const entry = daily[dateKey] as Record<string, number> | undefined;
expect(entry).toBeDefined();
expect(entry!.veryLate).toBe(0);
expect(entry!.late).toBe(0);
expect(entry!.onTime).toBe(1);
});
});
// v1.8.6 — the `complianceDisplay` block is always two percentage rows; the
// server scales the two windows to the dosing cadence. A daily med keeps
// 7 / 30 days; a sparse rolling med steps both windows up to the widest rung.
// The existing `compliance7` / `compliance30` fields stay untouched.
describe("GET /api/medications/[id]/compliance — complianceDisplay", () => {
it("daily med → 7 / 30-day windows", async () => {
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
medication([
{
id: "sched-1",
windowStart: "08:00",
windowEnd: "09:00",
timesOfDay: ["08:00"],
daysOfWeek: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
]) as never,
);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
const data = await callRouteData();
const display = data.complianceDisplay as {
shortDays: number;
longDays: number;
expectedLong: number;
minStableDoses: number;
short: { rate: number; streak: number };
long: { rate: number };
};
expect(display.shortDays).toBe(7);
expect(display.longDays).toBe(30);
expect(display.expectedLong).toBeGreaterThanOrEqual(display.minStableDoses);
expect(display.short).toBeDefined();
expect(display.long).toBeDefined();
// No timeline field — the display is two rows only.
expect(display).not.toHaveProperty("mode");
// The legacy fields stay on the wire.
expect(data.compliance7).toBeDefined();
expect(data.compliance30).toBeDefined();
});
it("35-day-interval med → steps the windows up past 7 / 30 days", async () => {
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
{
id: "med-1",
userId: "user-1",
createdAt: new Date(Date.now() - 400 * 24 * 60 * 60 * 1000),
startsOn: new Date(Date.now() - 380 * 24 * 60 * 60 * 1000),
endsOn: null,
oneShot: false,
schedules: [
{
id: "sched-1",
windowStart: "10:00",
windowEnd: "11:00",
timesOfDay: ["10:00"],
daysOfWeek: null,
rrule: null,
rollingIntervalDays: 35,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
],
} as never,
);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{
takenAt: new Date(Date.now() - 25 * 24 * 60 * 60 * 1000),
skipped: false,
scheduledFor: new Date(Date.now() - 25 * 24 * 60 * 60 * 1000),
},
] as never);
const data = await callRouteData();
const display = data.complianceDisplay as {
shortDays: number;
longDays: number;
};
// A 35-day cadence can't clear four expected doses in a 30-day window, so
// the ladder steps the short window beyond 7 days and the long beyond 30.
expect(display.shortDays).toBeGreaterThan(7);
expect(display.longDays).toBeGreaterThan(30);
});
});
+118 -11
View File
@@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError } from "@/lib/api-response";
import {
buildComplianceDisplay,
buildComplianceMedicationContext,
calculateCompliance,
classifyIntakeTiming,
@@ -16,6 +17,74 @@ import { userDayKey } from "@/lib/tz/format";
type RouteParams = { params: Promise<{ id: string }> };
/** A single effective dose slot with its derived punctuality window. */
interface SlotWindow {
/** Minute-of-day of the slot's start time (used for nearest-slot match). */
startMinutes: number;
/** "HH:mm" slot start — fed to `classifyIntakeTiming` as `windowStart`. */
windowStart: string;
/** "HH:mm" slot end = slot start + the schedule's window span. */
windowEnd: string;
}
const MINUTES_PER_DAY = 24 * 60;
/** Parse "HH:mm" into minute-of-day. */
function hhmmToMinutes(time: string): number {
const [h, m] = time.split(":").map(Number);
return h * 60 + m;
}
/** Format a minute-of-day (wrapped into 0..1439) back to "HH:mm". */
function minutesToHHmm(minutes: number): string {
const wrapped = ((minutes % MINUTES_PER_DAY) + MINUTES_PER_DAY) %
MINUTES_PER_DAY;
const h = Math.floor(wrapped / 60);
const m = wrapped % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
/**
* v1.8.1 B15 follow-on — expand every schedule into its effective dose
* slots so timing classification matches against the right time-of-day.
*
* Candidate slot times for a schedule are its `timesOfDay` when non-empty,
* else `[windowStart]` (so single-time schedules collapse to one slot at
* `windowStart` and stay byte-identical to the pre-fix matcher). Each
* slot's punctuality window is anchored at the slot time and spans the
* schedule's `windowEnd − windowStart` duration; overnight windows
* (`windowEnd ≤ windowStart`) add a day to the span before deriving the
* per-slot end. Multi-schedule meds union their slots — the matcher then
* picks the single closest slot across all of them.
*/
function buildSlotCandidates(
schedules: { windowStart: string; windowEnd: string; timesOfDay: string[] }[],
): SlotWindow[] {
const slots: SlotWindow[] = [];
for (const sched of schedules) {
const startMin = hhmmToMinutes(sched.windowStart);
let endMin = hhmmToMinutes(sched.windowEnd);
// Overnight window: the end is on the following day.
if (endMin <= startMin) endMin += MINUTES_PER_DAY;
const spanMinutes = endMin - startMin;
const times =
sched.timesOfDay.length > 0 ? sched.timesOfDay : [sched.windowStart];
for (const time of times) {
const slotStartMin = hhmmToMinutes(time);
slots.push({
startMinutes: slotStartMin,
windowStart: minutesToHHmm(slotStartMin),
// `classifyIntakeTiming` re-detects the overnight wrap from the
// HH:mm pair, so a wrapped end formats back into 0..1439 cleanly.
windowEnd: minutesToHHmm(slotStartMin + spanMinutes),
});
}
}
return slots;
}
export const GET = apiHandler(
async (_request: Request, { params }: RouteParams) => {
const { user } = await requireAuth();
@@ -73,6 +142,18 @@ export const GET = apiHandler(
{ medicationContext },
);
// v1.8.6 — the two-row compliance display. The card always shows two
// percentage rows; the server scales the two windows to the dosing
// cadence (dense meds keep 7 / 30 days, sparse meds step both windows
// up) and computes each row's rate over the chosen span. `compliance7`
// / `compliance30` above are untouched — iOS + the Health Score read
// them verbatim.
const complianceDisplay = buildComplianceDisplay(
mapped,
medication.schedules,
medicationContext,
);
// Build daily compliance map for heatmap/line chart (90 days)
const now = new Date();
const dailyCompliance: Record<string, DailyComplianceEntry> = {};
@@ -115,33 +196,49 @@ export const GET = apiHandler(
let veryLate = 0;
let early = 0;
// v1.8.1 B15 follow-on — match each taken event to the closest
// effective time-of-day SLOT, not merely `windowStart`. A single
// schedule row carrying `timesOfDay = ["07:00","19:00"]` is two
// distinct dose slots; matching the evening dose against the lone
// `windowStart` "07:00" mis-classified it as `very_late` (~12h late)
// and painted the heatmap cell orange even when both doses were
// taken on time. Candidate slots for a schedule are its `timesOfDay`
// when non-empty, else `[windowStart]` — so single-time schedules
// collapse to exactly one candidate at `windowStart` and stay
// byte-identical to the pre-fix behaviour. The per-slot window
// mirrors the projector / cadence anchor: effective windowStart is
// the slot time, effective windowEnd is the slot time plus the
// schedule's `windowEnd − windowStart` span (overnight spans where
// `windowEnd ≤ windowStart` add a day).
const slotCandidates = buildSlotCandidates(medication.schedules);
for (const evt of takenEvents) {
if (medication.schedules.length === 0) {
if (slotCandidates.length === 0) {
// No schedule info: treat all taken as on_time
onTime++;
continue;
}
// Match event to the closest schedule window by scheduledFor time
// Match event to the closest slot window by scheduledFor time.
const evtHour = evt.scheduledFor.getUTCHours();
const evtMin = evt.scheduledFor.getUTCMinutes();
const evtMinutes = evtHour * 60 + evtMin;
let bestSchedule = medication.schedules[0];
let bestSlot = slotCandidates[0];
let bestDist = Infinity;
for (const sched of medication.schedules) {
const [sh, sm] = sched.windowStart.split(":").map(Number);
const dist = Math.abs(evtHour * 60 + evtMin - (sh * 60 + sm));
for (const slot of slotCandidates) {
const dist = Math.abs(evtMinutes - slot.startMinutes);
if (dist < bestDist) {
bestDist = dist;
bestSchedule = sched;
bestSlot = slot;
}
}
const timing = classifyIntakeTiming(
evt.takenAt,
bestSchedule.windowStart,
bestSchedule.windowEnd,
bestSlot.windowStart,
bestSlot.windowEnd,
dayStart, // the scheduled date
);
@@ -189,9 +286,19 @@ export const GET = apiHandler(
entity_type: "medication",
entity_id: id,
},
meta: { compliance7: compliance7.rate, compliance30: compliance30.rate },
meta: {
compliance7: compliance7.rate,
compliance30: compliance30.rate,
complianceShortDays: complianceDisplay.shortDays,
complianceLongDays: complianceDisplay.longDays,
},
});
return apiSuccess({ compliance7, compliance30, dailyCompliance });
return apiSuccess({
compliance7,
compliance30,
dailyCompliance,
complianceDisplay,
});
},
);
@@ -26,6 +26,10 @@ vi.mock("@/lib/db", () => ({
prisma: {
medication: {
findUnique: vi.fn(),
// v1.8.2 — the slot-snap resolver loads the medication via
// findFirst; default to a schedule-less med so the resolver returns
// null (unscheduled path) and the original insert behaviour holds.
findFirst: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
@@ -34,6 +38,8 @@ vi.mock("@/lib/db", () => ({
findFirst: vi.fn(),
count: vi.fn(),
create: vi.fn(),
// v1.8.2 reconcile — the shared slot upsert updates in place.
update: vi.fn(),
},
auditLog: { create: vi.fn() },
$transaction: vi.fn(),
@@ -307,6 +313,16 @@ describe("POST /api/medications/[id]/intake — one-shot lifecycle", () => {
skipped: false,
};
vi.mocked(prisma.$transaction).mockResolvedValue([createdEvent] as never);
// v1.8.2 — slot-snap resolver loads the med; no schedules → null slot
// → original insert + dedup path preserved.
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce({
id: "med-1",
startsOn: null,
endsOn: null,
oneShot: true,
createdAt: new Date(),
schedules: [],
} as never);
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce(
null as never, // dedup probe
);
@@ -336,3 +352,184 @@ describe("POST /api/medications/[id]/intake — one-shot lifecycle", () => {
});
});
});
// ────────────────────────────────────────────────────────────────────
// v1.8.2 reconcile — slot-snap upsert invariants through the route
// ────────────────────────────────────────────────────────────────────
describe("POST /api/medications/[id]/intake — v1.8.2 reconcile (M2 inventory)", () => {
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/medications/med-1/intake", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
// A scheduled med (07:00 / 19:00) so the resolver returns a canonical
// slot and the write routes through the shared upsert.
const SCHEDULED_MED = {
id: "med-1",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00Z"),
schedules: [
{
id: "s1",
windowStart: "07:00",
windowEnd: "07:00",
daysOfWeek: null,
timesOfDay: ["07:00", "19:00"],
reminderGraceMinutes: null,
rrule: null,
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
],
};
it("does NOT decrement inventory on a re-post of an already-taken slot (M2)", async () => {
const { consumeOneDose } = await import(
"@/lib/medications/inventory/service"
);
// Resolver load → scheduled med.
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(
SCHEDULED_MED as never,
);
// Slot find (shared upsert) → an existing TAKEN row at the 07:00 slot.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
{
id: "row-taken",
takenAt: new Date("2026-06-15T05:01:00Z"),
skipped: false,
idempotencyKey: null,
scheduledFor: new Date("2026-06-15T05:00:00Z"),
source: "WEB",
createdAt: new Date("2026-06-15T05:01:00Z"),
},
] as never);
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
id: "row-taken",
takenAt: new Date("2026-06-15T05:02:00Z"),
skipped: false,
scheduledFor: new Date("2026-06-15T05:00:00Z"),
source: "WEB",
} as never);
vi.mocked(prisma.medication.update).mockResolvedValue({} as never);
// Reconcile probes (non-one-shot → noop).
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
oneShot: false,
active: true,
} as never);
const res = await POST(
postReq({
scheduledFor: "2026-06-15T05:00:30.000Z",
takenAt: "2026-06-15T05:02:00.000Z",
}),
ROUTE_PARAMS,
);
expect(res.status).toBe(201);
// Re-post of an already-taken slot → no pending→taken transition →
// inventory must NOT be consumed.
expect(consumeOneDose).not.toHaveBeenCalled();
// The row was updated, not duplicated.
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1);
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
});
it("DOES decrement inventory on a genuine pending→taken move (M2)", async () => {
const { consumeOneDose } = await import(
"@/lib/medications/inventory/service"
);
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(
SCHEDULED_MED as never,
);
// Slot find → a PENDING REMINDER row (no takenAt).
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
{
id: "row-pending",
takenAt: null,
skipped: false,
idempotencyKey: null,
scheduledFor: new Date("2026-06-15T05:00:00Z"),
source: "REMINDER",
createdAt: new Date("2026-06-15T00:00:00Z"),
},
] as never);
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
id: "row-pending",
takenAt: new Date("2026-06-15T05:02:00Z"),
skipped: false,
scheduledFor: new Date("2026-06-15T05:00:00Z"),
source: "REMINDER",
} as never);
vi.mocked(prisma.medication.update).mockResolvedValue({} as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
oneShot: false,
active: true,
} as never);
const res = await POST(
postReq({
scheduledFor: "2026-06-15T05:00:30.000Z",
takenAt: "2026-06-15T05:02:00.000Z",
}),
ROUTE_PARAMS,
);
expect(res.status).toBe(201);
expect(consumeOneDose).toHaveBeenCalledTimes(1);
});
it("re-finds and updates the slot when the create races a P2002 (C1)", async () => {
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(
SCHEDULED_MED as never,
);
// First slot find → empty. Create throws P2002. Re-find → the racing
// pending row. Update applies the taken write onto it.
vi.mocked(prisma.medicationIntakeEvent.findMany)
.mockResolvedValueOnce([] as never)
.mockResolvedValueOnce([
{
id: "row-raced",
takenAt: null,
skipped: false,
idempotencyKey: null,
scheduledFor: new Date("2026-06-15T05:00:00Z"),
source: "REMINDER",
createdAt: new Date("2026-06-15T00:00:00Z"),
},
] as never);
vi.mocked(prisma.medicationIntakeEvent.create).mockRejectedValueOnce(
Object.assign(new Error("unique"), { code: "P2002" }),
);
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
id: "row-raced",
takenAt: new Date("2026-06-15T05:02:00Z"),
skipped: false,
scheduledFor: new Date("2026-06-15T05:00:00Z"),
source: "REMINDER",
} as never);
vi.mocked(prisma.medication.update).mockResolvedValue({} as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
oneShot: false,
active: true,
} as never);
const res = await POST(
postReq({
scheduledFor: "2026-06-15T05:00:30.000Z",
takenAt: "2026-06-15T05:02:00.000Z",
}),
ROUTE_PARAMS,
);
// The dose tap is NOT dropped — converges to the racing row, 201.
expect(res.status).toBe(201);
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: "row-raced" } }),
);
});
});
+160 -25
View File
@@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
@@ -13,12 +14,18 @@ import {
intakeSchema,
listIntakeEventsSchema,
} from "@/lib/validations/medication";
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
import { withIdempotency } from "@/lib/idempotency";
import { consumeOneDose } from "@/lib/medications/inventory/service";
import { reconcileOneShotState } from "@/lib/medications/lifecycle";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
import {
applyCanonicalSlotWrite,
resolveSlotInstantForWrite,
} from "@/lib/medications/scheduling/slot-upsert";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
@@ -72,7 +79,83 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
return returnAllZodIssues(parsed.error, 422);
}
const { scheduledFor, takenAt, skipped, idempotencyKey } = parsed.data;
const { scheduledFor, takenAt, skipped, idempotencyKey, injectionSite } =
parsed.data;
// v1.8.5 — resolve + server-validate the optional injection site. Load
// the medication's delivery form + tracking opt-in + per-medication
// allowed sites, plus the user's global exclusion deny-list. A site
// outside the effective allowed set is a hard 422; a site on a
// non-injection / tracking-off med (or a skip) is silently dropped.
let resolvedInjectionSite: InjectionSiteKey | null = null;
if (injectionSite !== undefined) {
const [med, userRow] = await Promise.all([
prisma.medication.findUnique({
where: { id },
select: {
deliveryForm: true,
trackInjectionSites: true,
allowedInjectionSites: true,
},
}),
prisma.user.findUnique({
where: { id: user.id },
select: { globalExcludedInjectionSites: true },
}),
]);
const resolution = resolveInjectionSiteForWrite({
submitted: injectionSite,
taken: !skipped,
deliveryForm: med?.deliveryForm ?? "ORAL",
trackInjectionSites: med?.trackInjectionSites ?? false,
allowedInjectionSites: (med?.allowedInjectionSites ??
[]) as InjectionSiteKey[],
globalExcludedInjectionSites: (userRow?.globalExcludedInjectionSites ??
[]) as InjectionSiteKey[],
});
if (resolution.kind === "disallowed") {
annotate({
action: { name: "medication.intake.injection_site.disallowed" },
meta: { medication_id: id, site: resolution.site },
});
return apiError("Injection site is not allowed for this medication", 422, {
errorCode: "medications.intake.injection_site.disallowed",
});
}
resolvedInjectionSite = resolution.site;
}
const resolvedTakenAt = skipped ? null : (takenAt ?? new Date());
const incomingScheduledFor = scheduledFor ?? takenAt ?? new Date();
// C2 — the per-med route only ever carries an explicit user gesture:
// `resolvedTakenAt` is `now()` for any non-skip POST (there is no
// "mark pending" write on this route), so a non-skip is an explicit
// taken and a `skipped:true` body is an explicit skip. Neither is a
// pending projection echo, so the no-downgrade guard never trips here —
// it is the bulk/sync route that replays pending echoes. The flags are
// threaded through so the shared upsert applies last-write-wins.
const isExplicitTaken = !skipped;
const isExplicitSkip = skipped === true;
// v1.8.2 — source-agnostic slot snap. A twice-daily med carries a
// pending REMINDER row the projector/worker minted at the canonical
// `localHmAsUtc` slot instant. Without this, a manual "Genommen" write
// (source WEB) inserted a SECOND row for the slot because the unique
// key includes `source` and the iOS-vs-server `scheduledFor` can drift
// by a minute — inflating compliance to 100% and suppressing the
// "take now" prompt for a dose the user hadn't taken. Snap the write
// to the canonical slot and update the existing row in place.
//
// Resolved BEFORE the idempotency/dedup window so a scheduled dose
// routes through the slot upsert (which is itself the dedup): the
// legacy 60-second window would otherwise short-circuit by returning
// the slot's pending REMINDER row WITHOUT applying the user's takenAt.
const canonicalSlot = await resolveSlotInstantForWrite({
userId: user.id,
medicationId: id,
userTz: user.timezone,
incoming: incomingScheduledFor,
});
// Idempotency check (explicit key or server-side dedup window)
if (idempotencyKey) {
@@ -86,8 +169,10 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
if (existing) {
return apiSuccess(existing);
}
} else {
// Server-side dedup: prevent double-logging within 60 seconds
} else if (!canonicalSlot) {
// Unscheduled / PRN only — the slot upsert handles dedup for
// scheduled doses by collapsing onto the canonical slot row.
// Server-side dedup: prevent double-logging within 60 seconds.
const recentDuplicate = await prisma.medicationIntakeEvent.findFirst({
where: {
userId: user.id,
@@ -102,28 +187,72 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
}
}
const [event] = await prisma.$transaction([
prisma.medicationIntakeEvent.create({
data: {
userId: user.id,
medicationId: id,
scheduledFor: scheduledFor ?? takenAt ?? new Date(),
takenAt: skipped ? null : (takenAt ?? new Date()),
skipped,
source: "WEB",
idempotencyKey: idempotencyKey ?? null,
},
}),
// Reset snooze when medication is taken
...(!skipped
? [
prisma.medication.update({
where: { id },
data: { snoozedUntil: null },
let event;
// v1.8.2 reconcile — whether this write moved the slot pending→taken.
// Only that transition decrements pen inventory (M2). For the
// unscheduled/PRN branch a non-skip write always records a fresh dose,
// so it consumes when not skipped.
let consumedTransition = !skipped;
if (canonicalSlot) {
// Scheduled dose — converge onto the one canonical slot row regardless
// of `source` (the pending REMINDER row, or any prior row for this
// slot) through the shared upsert: H1 deterministic selection, C2
// no-downgrade guard, and a C1 race-safe create that re-finds + updates
// on a P2002 collision rather than 500-ing or duplicating.
const applied = await applyCanonicalSlotWrite({
client: prisma,
userId: user.id,
medicationId: id,
canonicalSlot,
takenAt: resolvedTakenAt,
skipped,
isExplicitTaken,
isExplicitSkip,
idempotencyKey: idempotencyKey ?? null,
createSource: "WEB",
// v1.8.5 — resolved + validated site (null unless a tracking-on
// injection taken write supplied an allowed site).
injectionSite: resolvedInjectionSite,
});
event = applied.row;
consumedTransition = applied.consumedTransition;
// Reset the snooze when a dose is actually recorded (not on a
// no-downgrade no-op, which left the prior taken row untouched).
if (!skipped && !applied.noDowngradeNoOp) {
await prisma.medication.update({
where: { id },
data: { snoozedUntil: null },
});
}
} else {
// Unscheduled / PRN / off-slot — keep the original insert behaviour.
[event] = await prisma.$transaction([
prisma.medicationIntakeEvent.create({
data: {
userId: user.id,
medicationId: id,
scheduledFor: incomingScheduledFor,
takenAt: resolvedTakenAt,
skipped,
source: "WEB",
idempotencyKey: idempotencyKey ?? null,
// v1.8.5 — site only on a resolved taken-injection write.
...(resolvedInjectionSite !== null && {
injectionSite: resolvedInjectionSite,
}),
]
: []),
]);
},
}),
// Reset snooze when medication is taken
...(!skipped
? [
prisma.medication.update({
where: { id },
data: { snoozedUntil: null },
}),
]
: []),
]);
}
// v1.4.25 W19b — pen-inventory dose decrement. Only fires for
// non-skipped intakes; a skipped event is not a consumption event.
@@ -131,8 +260,14 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
// meds). Failures here must never block the intake write, so
// errors are swallowed and logged — the intake is the source of
// truth, the inventory is an opt-in companion.
//
// v1.8.2 M2 — gate on an ACTUAL pending→taken transition. An idempotent
// re-post of an already-taken slot updates the row in place but must NOT
// decrement again, else a repeated iOS sync drifts the GLP-1 pen count
// down on every replay. `consumedTransition` is false when the slot was
// already taken (or on a no-downgrade no-op).
let inventoryOutcome: Awaited<ReturnType<typeof consumeOneDose>> = null;
if (!skipped) {
if (!skipped && consumedTransition) {
try {
inventoryOutcome = await consumeOneDose({
userId: user.id,
+13
View File
@@ -130,10 +130,14 @@ export const PUT = apiHandler(
treatmentClass,
dosesPerUnit,
deliveryForm,
trackInjectionSites,
allowedInjectionSites,
active,
notificationsEnabled,
liveActivityEnabled,
criticalAlarmEnabled,
atcCode,
rxNormCode,
schedules,
startsOn,
endsOn,
@@ -222,11 +226,20 @@ export const PUT = apiHandler(
...(treatmentClass !== undefined && { treatmentClass }),
...(dosesPerUnit !== undefined && { dosesPerUnit }),
...(deliveryForm !== undefined && { deliveryForm }),
// v1.8.5 — injection-site tracking opt-in + per-medication allowed
// sites. Field-by-field; `false` / `[]` are valid explicit values
// (deactivate tracking / clear the per-med restriction).
...(trackInjectionSites !== undefined && { trackInjectionSites }),
...(allowedInjectionSites !== undefined && { allowedInjectionSites }),
...(active !== undefined && { active }),
...(notificationsEnabled !== undefined && { notificationsEnabled }),
// v1.7.0 — iOS reminder flags, field-by-field.
...(liveActivityEnabled !== undefined && { liveActivityEnabled }),
...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }),
// v1.9.0 — optional drug-classification codes (ATC / RxNorm).
// Field-by-field; `null` is a valid explicit value (clears the code).
...(atcCode !== undefined && { atcCode }),
...(rxNormCode !== undefined && { rxNormCode }),
// v1.5 scheduling primitives — pass-through when supplied.
// `startsOn` / `endsOn` are `Date | null | undefined` (the
// schema lets the user clear them explicitly with null).
@@ -1,227 +0,0 @@
/**
* v1.4.25 W19f — titration-ladder route tests.
*
* Mirrors the W19e cadence-route test fixture: external dependencies
* are mocked at module boundaries (prisma, session, logging
* transport) so each test pins one contract case. The pure ladder
* helpers are covered separately in
* `src/lib/medications/titration/__tests__/ladder.test.ts`.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findUnique: vi.fn() },
},
}));
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),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function makeMed(
overrides: Partial<{
name: string;
dose: string;
treatmentClass: "GLP1" | "GENERIC";
userId: string;
doseChanges: Array<{ effectiveFrom: Date; doseValue: number }>;
}> = {},
) {
return {
id: "med-1",
name: overrides.name ?? "Mounjaro",
dose: overrides.dose ?? "5 mg",
treatmentClass: overrides.treatmentClass ?? ("GLP1" as const),
userId: overrides.userId ?? "user-1",
createdAt: new Date(),
doseChanges: overrides.doseChanges ?? [],
};
}
beforeEach(() => {
vi.resetAllMocks();
});
describe("GET /api/medications/[id]/titration", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 404 when the medication is not owned by the caller", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
makeMed({ userId: "OTHER" }) as never,
);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(404);
});
it("returns 404 when the medication is not GLP-1", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
makeMed({ treatmentClass: "GENERIC" }) as never,
);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(404);
});
it("returns 404 when the medication brand is not in the GLP-1 catalog", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
makeMed({ name: "MysteryDrug" }) as never,
);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(404);
});
it("returns the ladder + current step for a Mounjaro user on 5 mg", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
makeMed({
name: "Mounjaro",
dose: "5 mg",
doseChanges: [
{
effectiveFrom: new Date(Date.now() - 6 * 7 * 24 * 60 * 60 * 1000),
doseValue: 2.5,
},
{
effectiveFrom: new Date(Date.now() - 2 * 7 * 24 * 60 * 60 * 1000),
doseValue: 5,
},
],
}) as never,
);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.drugId).toBe("tirzepatide");
expect(json.data.ladder.length).toBe(6);
expect(json.data.currentStep.doseMg).toBe(5);
expect(json.data.currentStepIndex).toBe(1);
expect(json.data.weeksOnCurrentStep).toBe(2);
expect(json.data.nextStep.doseMg).toBe(7.5);
expect(json.data.escalationDue).toBe(false);
});
it("returns a null current step when the user dose is outside any ladder bucket", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
makeMed({
name: "Mounjaro",
dose: "6 mg", // outside the ±10 % window of 5 and 7.5
doseChanges: [],
}) as never,
);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.currentStep).toBeNull();
expect(json.data.currentStepIndex).toBeNull();
expect(json.data.nextStep).toBeNull();
expect(json.data.escalationDue).toBe(false);
});
it("returns a null next-step at the ladder ceiling", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
makeMed({
name: "Mounjaro",
dose: "15 mg",
doseChanges: [
{
effectiveFrom: new Date(Date.now() - 5 * 7 * 24 * 60 * 60 * 1000),
doseValue: 15,
},
],
}) as never,
);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.currentStep.doseMg).toBe(15);
expect(json.data.nextStep).toBeNull();
expect(json.data.escalationDue).toBe(false);
});
it("flags escalationDue=true past the EMA dwell-time on a non-ceiling step", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
makeMed({
name: "Mounjaro",
dose: "2.5 mg",
doseChanges: [
{
// Six weeks ago, past tirzepatide's 4-week interval.
effectiveFrom: new Date(Date.now() - 6 * 7 * 24 * 60 * 60 * 1000),
doseValue: 2.5,
},
],
}) as never,
);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.currentStep.doseMg).toBe(2.5);
expect(json.data.weeksOnCurrentStep).toBe(6);
expect(json.data.escalationDue).toBe(true);
});
});
@@ -1,136 +0,0 @@
/**
* v1.4.25 W19f — GLP-1 titration-ladder read endpoint.
*
* GET /api/medications/[id]/titration
* - returns the EMA-reference ladder for the medication's drug,
* the user's current step (matched within ±10 % tolerance to
* their latest dose), how long they've been on that step, the
* immediate-next step (if any), and the observational
* escalation-due flag.
*
* Pure computation — no writes. Delegates math to the
* `src/lib/medications/titration/ladder` module so the route, the
* Coach snapshot (future wave), and the detail-page section all read
* the same numbers.
*
* Auth: cookie-session via requireAuth(); medication is verified to
* belong to the caller before any read. Defence-in-depth: returns
* 404 for non-GLP-1 medications (the UI already gates on
* `treatmentClass === "GLP1"`).
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiError, apiSuccess } from "@/lib/api-response";
import {
findDrugByBrand,
findDrugIdByBrand,
} from "@/lib/medications/glp1-knowledge";
import { parseDoseMgOrNull } from "@/lib/medications/dose-string";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import {
escalationDue,
findCurrentStep,
ladderFromRecord,
nextStep,
weeksOnCurrentStep,
} from "@/lib/medications/titration/ladder";
type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper so
// the 404 leak shape stays consistent across every medication
// sub-route.
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const med = await prisma.medication.findUnique({
where: { id },
include: {
doseChanges: { orderBy: { effectiveFrom: "asc" } },
},
});
if (!med) {
return apiError("Medication not found", 404);
}
if (med.treatmentClass !== "GLP1") {
// Defence-in-depth: the UI already gates the section on this
// class, but the route refuses to surface the ladder for a
// non-GLP-1 row so we never leak a misleading "you are here"
// on a generic medication.
return apiError("Medication not found", 404);
}
// Resolve the GLP-1 drug record from the medication name. Same
// path the DrugLevelChart uses; null when the medication name
// doesn't map to a catalog brand (e.g. user typed a generic
// INN instead of a brand). v1.4.25 W21 Fix-N — both lookups go
// through the shared helpers in glp1-knowledge.
const record = findDrugByBrand(med.name);
const drugId = findDrugIdByBrand(med.name);
if (!record || !drugId) {
return apiError("Medication not found", 404);
}
const ladder = ladderFromRecord(record);
// Resolve the user's most-recent dose. Prefer the
// MedicationDoseChange stream (the history of record); fall back
// to the legacy free-text `medication.dose` if the stream is empty.
const latestChange =
med.doseChanges.length > 0
? med.doseChanges[med.doseChanges.length - 1]
: null;
const latestDoseMg = latestChange
? latestChange.doseValue
: parseDoseMgOrNull(med.dose);
const currentStep = findCurrentStep(drugId, latestDoseMg);
const next = nextStep(drugId, currentStep);
const asOf = new Date();
const weeks = weeksOnCurrentStep(
drugId,
currentStep,
med.doseChanges.map((dc) => ({
effectiveFrom: dc.effectiveFrom,
doseValue: dc.doseValue,
})),
asOf,
);
const due = escalationDue(drugId, currentStep, weeks);
annotate({
action: {
name: "medication.titration",
entity_type: "medication",
entity_id: id,
},
meta: {
drug_id: drugId,
step_index: currentStep?.stepIndex ?? null,
weeks_on_step: weeks,
},
});
return apiSuccess({
drugId,
drugInn: record.inn,
ladder,
currentStep,
currentStepIndex: currentStep?.stepIndex ?? null,
weeksOnCurrentStep: weeks,
nextStep: next,
escalationDue: due,
sourceEMA: record.sourceEMA,
});
},
);
@@ -9,10 +9,15 @@ vi.mock("@/lib/db", () => ({
prisma: {
medication: {
findMany: vi.fn(),
// v1.8.2 — the slot resolver loads the med via findFirst.
findFirst: vi.fn(),
},
medicationIntakeEvent: {
create: vi.fn(),
findUnique: vi.fn(),
// v1.8.2 reconcile — shared slot upsert reads + updates in place.
findMany: vi.fn(),
update: vi.fn(),
},
auditLog: { create: vi.fn() },
},
@@ -151,3 +156,164 @@ describe("POST /api/medications/intake/bulk — 422 multi-issue (v1.4.43 W6)", (
expect(res.status).toBe(422);
});
});
// ────────────────────────────────────────────────────────────────────
// v1.8.2 reconcile — bulk slot-snap upsert invariants (C2 + C1)
// ────────────────────────────────────────────────────────────────────
describe("POST /api/medications/intake/bulk — v1.8.2 reconcile", () => {
const SCHEDULED_MED = {
id: "med-1",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00Z"),
schedules: [
{
id: "s1",
windowStart: "07:00",
windowEnd: "07:00",
daysOfWeek: null,
timesOfDay: ["07:00", "19:00"],
reminderGraceMinutes: null,
rrule: null,
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
],
};
beforeEach(() => {
vi.mocked(prisma.medication.findMany).mockResolvedValue([
{ id: "med-1" },
] as never);
vi.mocked(prisma.medication.findFirst).mockResolvedValue(
SCHEDULED_MED as never,
);
});
it("C2 — a pending echo onto an already-TAKEN slot is reported duplicate, NOT a downgrade", async () => {
// Existing TAKEN row at the 07:00 slot. The bulk entry is a pending
// echo (no takenAt, skipped false) — must NOT clear takenAt.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
{
id: "row-taken",
takenAt: new Date("2026-06-15T05:01:00Z"),
skipped: false,
idempotencyKey: null,
scheduledFor: new Date("2026-06-15T05:00:00Z"),
source: "WEB",
createdAt: new Date("2026-06-15T05:01:00Z"),
},
] as never);
const res = await POST(
postReq({
entries: [
{
medicationId: "med-1",
scheduledFor: "2026-06-15T05:00:30.000Z",
// no takenAt, skipped defaults false → pending echo
},
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
duplicates: number;
updated: number;
entries: Array<{ status: string; id?: string }>;
};
};
expect(body.data.duplicates).toBe(1);
expect(body.data.updated).toBe(0);
expect(body.data.entries[0].status).toBe("duplicate");
// The recorded dose was NEVER touched — no update issued.
expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled();
});
it("C2 — an explicit takenAt still applies onto a pending slot (status updated)", async () => {
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
{
id: "row-pending",
takenAt: null,
skipped: false,
idempotencyKey: null,
scheduledFor: new Date("2026-06-15T05:00:00Z"),
source: "REMINDER",
createdAt: new Date("2026-06-15T00:00:00Z"),
},
] as never);
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
id: "row-pending",
} as never);
const res = await POST(
postReq({
entries: [
{
medicationId: "med-1",
scheduledFor: "2026-06-15T05:00:30.000Z",
takenAt: "2026-06-15T05:02:00.000Z",
},
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { updated: number; entries: Array<{ status: string }> };
};
expect(body.data.updated).toBe(1);
expect(body.data.entries[0].status).toBe("updated");
});
it("C1 — a same-slot P2002 on create converges via re-find+update, not dropped as duplicate", async () => {
// First slot find empty → create races a P2002 → re-find returns the
// racing pending row → update applies the incoming takenAt.
vi.mocked(prisma.medicationIntakeEvent.findMany)
.mockResolvedValueOnce([] as never)
.mockResolvedValueOnce([
{
id: "row-raced",
takenAt: null,
skipped: false,
idempotencyKey: null,
scheduledFor: new Date("2026-06-15T05:00:00Z"),
source: "REMINDER",
createdAt: new Date("2026-06-15T00:00:00Z"),
},
] as never);
vi.mocked(prisma.medicationIntakeEvent.create).mockRejectedValueOnce(
Object.assign(new Error("unique"), { code: "P2002" }),
);
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
id: "row-raced",
} as never);
const res = await POST(
postReq({
entries: [
{
medicationId: "med-1",
scheduledFor: "2026-06-15T05:00:30.000Z",
takenAt: "2026-06-15T05:02:00.000Z",
},
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { updated: number; entries: Array<{ status: string; id?: string }> };
};
// The dose is APPLIED to the existing row, not silently dropped.
expect(body.data.updated).toBe(1);
expect(body.data.entries[0].status).toBe("updated");
expect(body.data.entries[0].id).toBe("row-raced");
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: "row-raced" } }),
);
});
});
+159 -17
View File
@@ -47,6 +47,16 @@ import {
recomputeMedicationComplianceForDay,
dayKeyForScheduledFor,
} from "@/lib/rollups/medication-compliance-rollups";
import {
applyCanonicalSlotWrite,
resolveSlotInstantForWrite,
} from "@/lib/medications/scheduling/slot-upsert";
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
import {
injectionSiteEnum,
type InjectionSiteValue,
} from "@/lib/validations/medication";
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
const MAX_ENTRIES_PER_BATCH = 500;
const BATCH_RATE_LIMIT_MAX = 60;
@@ -64,13 +74,23 @@ const bulkEntrySchema = z.object({
.optional(),
skipped: z.boolean().optional().default(false),
idempotencyKey: z.string().min(1).max(128).optional(),
// v1.8.5 — optional per-entry injection site. Validated against the
// medication's effective allowed set; a disallowed site marks the
// entry skipped (`injection_site_not_allowed`) without failing the
// whole batch, matching the bulk endpoint's per-entry contract.
injectionSite: injectionSiteEnum.optional(),
});
const bulkPayloadSchema = z.object({
entries: z.array(bulkEntrySchema).min(1).max(MAX_ENTRIES_PER_BATCH),
});
type EntryStatus = "inserted" | "duplicate" | "skipped";
// v1.8.2 — `updated` joins the per-entry status vocabulary: a write that
// snaps onto an existing scheduled-slot row (e.g. the pending REMINDER
// row) updates it in place rather than inserting. The iOS sync engine
// treats `updated` the same as `inserted` for cursor advancement — both
// mean "the server accepted this entry and produced a row id".
type EntryStatus = "inserted" | "updated" | "duplicate" | "skipped";
interface EntryResult {
index: number;
status: EntryStatus;
@@ -148,12 +168,39 @@ async function postBulk(request: NextRequest): Promise<Response> {
const medicationIds = Array.from(new Set(entries.map((e) => e.medicationId)));
const ownedMedications = await prisma.medication.findMany({
where: { id: { in: medicationIds }, userId: user.id },
select: { id: true },
// v1.8.5 — pull the injection-tracking fields so a per-entry
// `injectionSite` can be resolved + validated without an extra
// per-entry read.
select: {
id: true,
deliveryForm: true,
trackInjectionSites: true,
allowedInjectionSites: true,
},
});
const ownedSet = new Set(ownedMedications.map((m) => m.id));
const medById = new Map(ownedMedications.map((m) => [m.id, m]));
// v1.8.5 — the user's global exclusion deny-list, loaded once for the
// whole batch (it is user-scoped, not per-medication). Only read when
// at least one entry actually carries an `injectionSite`, so the hot
// sync path (no site) pays for zero extra round-trips.
const batchHasInjectionSite = entries.some(
(e) => e.injectionSite !== undefined,
);
let globalExcluded: InjectionSiteValue[] = [];
if (batchHasInjectionSite) {
const userRow = await prisma.user.findUnique({
where: { id: user.id },
select: { globalExcludedInjectionSites: true },
});
globalExcluded = (userRow?.globalExcludedInjectionSites ??
[]) as InjectionSiteValue[];
}
const results: EntryResult[] = [];
let inserted = 0;
let updated = 0;
let duplicates = 0;
const skipped: Array<{ index: number; reason: string }> = [];
// v1.4.39 W-MED — collect distinct `(medicationId, dayKey)` pairs
@@ -173,31 +220,123 @@ async function postBulk(request: NextRequest): Promise<Response> {
}
try {
const scheduledFor = entry.scheduledFor ?? new Date();
// The idempotencyKey, when supplied, has a UNIQUE index. A
// re-submission of the same key returns the existing row via
// P2002 → status: "duplicate".
const row = await prisma.medicationIntakeEvent.create({
data: {
const incomingScheduledFor =
entry.scheduledFor ?? entry.takenAt ?? new Date();
// v1.8.2 — source-agnostic slot snap. iOS posts the "Genommen"
// reminder action here (source API); without this it inserted a
// SECOND row for a slot already carrying the projector/worker's
// pending REMINDER row (the unique key includes `source`, and the
// iOS-vs-server `scheduledFor` drifts by a minute). Snap onto the
// canonical slot instant and update the existing row in place.
const canonicalSlot = await resolveSlotInstantForWrite({
userId: user.id,
medicationId: entry.medicationId,
userTz: user.timezone,
incoming: incomingScheduledFor,
});
const scheduledFor = canonicalSlot ?? incomingScheduledFor;
// C2 — classify the incoming write. An offline-sync replay echoes a
// PENDING projection (no `takenAt`, `skipped:false`) for a slot the
// user may already have actioned; that echo must NEVER clear a
// recorded `takenAt`. An explicit `takenAt` or `skipped:true` is a
// real user action and applies last-write-wins.
const isExplicitTaken = !entry.skipped && entry.takenAt !== undefined;
const isExplicitSkip = entry.skipped === true;
// v1.8.5 — resolve + validate the optional per-entry injection
// site. A disallowed site marks the entry skipped without failing
// the batch (per-entry contract); a non-injection / tracking-off /
// non-taken entry silently drops it.
let resolvedInjectionSite: InjectionSiteKey | null = null;
if (entry.injectionSite !== undefined) {
const med = medById.get(entry.medicationId);
const resolution = resolveInjectionSiteForWrite({
submitted: entry.injectionSite,
taken: isExplicitTaken,
deliveryForm: med?.deliveryForm ?? "ORAL",
trackInjectionSites: med?.trackInjectionSites ?? false,
allowedInjectionSites: (med?.allowedInjectionSites ??
[]) as InjectionSiteKey[],
globalExcludedInjectionSites: globalExcluded as InjectionSiteKey[],
});
if (resolution.kind === "disallowed") {
const reason = "injection_site_not_allowed";
skipped.push({ index: i, reason });
results.push({ index: i, status: "skipped", reason });
continue;
}
resolvedInjectionSite = resolution.site;
}
if (canonicalSlot) {
// Scheduled dose — converge onto the one canonical slot row through
// the shared upsert: H1 deterministic selection, C2 no-downgrade
// guard, and a C1 race-safe create that re-finds + updates on a
// P2002 collision rather than misclassifying it as a duplicate and
// dropping the dose.
const applied = await applyCanonicalSlotWrite({
client: prisma,
userId: user.id,
medicationId: entry.medicationId,
scheduledFor,
canonicalSlot,
takenAt: entry.takenAt ?? null,
skipped: entry.skipped,
source: "API",
isExplicitTaken,
isExplicitSkip,
idempotencyKey: entry.idempotencyKey ?? null,
},
});
inserted += 1;
results.push({ index: i, status: "inserted", id: row.id });
createSource: "API",
injectionSite: resolvedInjectionSite,
});
if (applied.noDowngradeNoOp) {
// C2 — pending echo onto an already-actioned slot. Report it as a
// duplicate so the iOS cursor advances WITHOUT downgrading the
// recorded dose.
duplicates += 1;
results.push({ index: i, status: "duplicate", id: applied.row.id });
} else if (applied.outcome === "updated") {
updated += 1;
results.push({ index: i, status: "updated", id: applied.row.id });
} else {
inserted += 1;
results.push({ index: i, status: "inserted", id: applied.row.id });
}
} else {
// Unscheduled / PRN — insert. The idempotencyKey, when supplied,
// has a UNIQUE index; a re-submission returns the existing row via
// P2002 → status: "duplicate".
const row = await prisma.medicationIntakeEvent.create({
data: {
userId: user.id,
medicationId: entry.medicationId,
scheduledFor,
takenAt: entry.takenAt ?? null,
skipped: entry.skipped,
source: "API",
idempotencyKey: entry.idempotencyKey ?? null,
// v1.8.5 — site only on a resolved taken-injection entry.
...(resolvedInjectionSite !== null && {
injectionSite: resolvedInjectionSite,
}),
},
});
inserted += 1;
results.push({ index: i, status: "inserted", id: row.id });
}
const dayKey = dayKeyForScheduledFor(scheduledFor, user.timezone);
touchedDays.set(`${entry.medicationId}|${dayKey}`, {
medicationId: entry.medicationId,
dayKey,
});
} catch (err: unknown) {
// P2002 = unique-constraint violation; the idempotencyKey
// already exists → "duplicate", not an error.
// P2002 = unique-constraint violation. Two shapes reach here:
// 1. an idempotencyKey collision on the unscheduled/PRN insert →
// "duplicate" (the canonical-slot path already absorbs its own
// same-slot P2002 race inside `applyCanonicalSlotWrite`);
// 2. a same-slot collision on the unscheduled insert. In both
// cases we surface the existing row id so the cursor advances.
if (
typeof err === "object" &&
err !== null &&
@@ -231,6 +370,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
details: {
processed: entries.length,
inserted,
updated,
duplicates,
skipped: skipped.length,
},
@@ -241,6 +381,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
meta: {
processed: entries.length,
inserted,
updated,
duplicates,
skipped: skipped.length,
},
@@ -249,7 +390,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches when at least one row landed so the next read reflects the
// ingested batch.
if (inserted > 0) {
if (inserted > 0 || updated > 0) {
invalidateUserMedications(user.id);
}
@@ -282,6 +423,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
return apiSuccess({
processed: entries.length,
inserted,
updated,
duplicates,
skipped,
entries: results,
+55 -1
View File
@@ -36,6 +36,11 @@ import {
type ComplianceBucket as RollupComplianceBucket,
} from "@/lib/rollups/medication-compliance-rollups";
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
import {
injectionSiteEnum,
type InjectionSiteValue,
} from "@/lib/validations/medication";
const querySchema = z.object({
scope: z.enum(["today", "compliance"]),
@@ -53,6 +58,10 @@ const updateSchema = z.object({
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
// v1.8.5 — optional injection site captured alongside a "taken"
// status toggle. Validated server-side against the medication's
// effective allowed set; ignored for skipped / snoozed.
injectionSite: injectionSiteEnum.optional(),
});
function startOfDayInTz(date: Date, tz: string): Date {
@@ -314,17 +323,58 @@ export const POST = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error, 422);
}
const { intakeId, status, takenAt, snoozedUntil } = parsed.data;
const { intakeId, status, takenAt, snoozedUntil, injectionSite } =
parsed.data;
// v1.7.0 sync — a tombstoned intake 404s on a status toggle; the
// `deletedAt: null` filter refuses to mutate a soft-deleted row.
const existing = await prisma.medicationIntakeEvent.findFirst({
where: { id: intakeId, deletedAt: null },
include: {
medication: {
select: {
deliveryForm: true,
trackInjectionSites: true,
allowedInjectionSites: true,
},
},
},
});
if (!existing || existing.userId !== user.id) {
return apiError("Intake event not found", 404);
}
// v1.8.5 — resolve + server-validate the optional injection site for a
// "taken" toggle. A site outside the medication's effective allowed
// set is a hard 422; non-injection / tracking-off / non-taken drops it.
let resolvedInjectionSite: InjectionSiteValue | null = null;
if (injectionSite !== undefined) {
const userRow = await prisma.user.findUnique({
where: { id: user.id },
select: { globalExcludedInjectionSites: true },
});
const resolution = resolveInjectionSiteForWrite({
submitted: injectionSite,
taken: status === "taken",
deliveryForm: existing.medication.deliveryForm,
trackInjectionSites: existing.medication.trackInjectionSites,
allowedInjectionSites:
existing.medication.allowedInjectionSites as InjectionSiteValue[],
globalExcludedInjectionSites: (userRow?.globalExcludedInjectionSites ??
[]) as InjectionSiteValue[],
});
if (resolution.kind === "disallowed") {
annotate({
action: { name: "medication.intake.injection_site.disallowed" },
meta: { medication_id: existing.medicationId, site: resolution.site },
});
return apiError("Injection site is not allowed for this medication", 422, {
errorCode: "medications.intake.injection_site.disallowed",
});
}
resolvedInjectionSite = resolution.site;
}
const userTzForHook = user.timezone ?? DEFAULT_TIMEZONE;
let updated;
@@ -338,6 +388,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
takenAt: takenAt ?? new Date(),
skipped: false,
syncVersion: { increment: 1 },
// v1.8.5 — persist the resolved site on the taken branch.
...(resolvedInjectionSite !== null && {
injectionSite: resolvedInjectionSite,
}),
},
}),
prisma.medication.update({
+12
View File
@@ -155,9 +155,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
treatmentClass,
dosesPerUnit,
deliveryForm,
trackInjectionSites,
allowedInjectionSites,
notificationsEnabled,
liveActivityEnabled,
criticalAlarmEnabled,
atcCode,
rxNormCode,
schedules,
startsOn,
endsOn,
@@ -219,9 +223,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
...(dosesPerUnit !== undefined && { dosesPerUnit }),
// v1.6.0 — route of administration; Prisma defaults to ORAL when omitted.
...(deliveryForm !== undefined && { deliveryForm }),
// v1.8.5 — injection-site tracking opt-in + per-medication allowed
// sites; Prisma defaults to false / [] when omitted.
...(trackInjectionSites !== undefined && { trackInjectionSites }),
...(allowedInjectionSites !== undefined && { allowedInjectionSites }),
// v1.7.0 — iOS reminder flags; Prisma defaults both to false.
...(liveActivityEnabled !== undefined && { liveActivityEnabled }),
...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }),
// v1.9.0 — optional drug-classification codes (ATC / RxNorm).
// Validated for format by Zod; stored verbatim when present.
...(atcCode !== undefined && { atcCode }),
...(rxNormCode !== undefined && { rxNormCode }),
// v1.5 — wizard's reminders toggle now ships through the create
// payload (was orphaned in the initial diff). Prisma defaults to
// true when omitted, matching the legacy form's behaviour.
@@ -0,0 +1,172 @@
/**
* GET /api/meta/capabilities route tests.
*
* This endpoint exists to retire the "doc says N, server ships M" enum-drift
* class for the native client — so the drift-guard assertions ARE the point:
*
* 1. 401 when unauthenticated.
* 2. `derivedMetricIds` is exactly the derived registry's id set (same
* length AND members) — the endpoint can never silently diverge from
* the registry it claims to mirror.
* 3. `ingest.writeAllowlist` deep-equals the `WRITABLE_MEASUREMENT_SOURCES`
* constant — a client reads the live write allowlist, not a copy.
* 4. Every quantity-mapping row + the FHIR constants + the contract version
* are sourced from the canonical server constants, not hand-typed here.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
// The route transitively imports `@/lib/insights/derived/wellness-scores`,
// which imports the Prisma client; mock it so the module graph loads without
// a real DB. The route itself only reads `WELLNESS_SCORE_TYPES` (a plain
// object), never the client.
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { DERIVED_METRIC_IDS } from "@/lib/insights/derived/registry";
import { WRITABLE_MEASUREMENT_SOURCES } from "@/lib/validations/measurement";
import { APPLE_HEALTH_TYPE_MAP } from "@/lib/measurements/apple-health-mapping";
import {
ATC_SYSTEM,
SNOMED_SYSTEM,
GERMAN_ATC_DEFAULT_LOCALES,
} from "@/lib/fhir/build-bundle";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
type CapabilitiesBody = {
data: {
apiContractVersion: string;
derivedMetricIds: string[];
vitalsBaselineTypes: string[];
layoutTileIds: string[];
metricStatusIds: string[];
ingest: {
quantityTypes: { type: string; hk: string; unit: string }[];
eventTypes: string[];
computedScores: string[];
writeAllowlist: string[];
};
fhir: {
atcSystem: string;
snomedRoute: string;
germanAtcDefaultLocales: string[];
};
};
};
async function call(): Promise<Response> {
return (GET as unknown as () => Promise<Response>)();
}
beforeEach(() => {
vi.resetAllMocks();
});
describe("GET /api/meta/capabilities — auth", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await call();
expect(res.status).toBe(401);
});
});
describe("GET /api/meta/capabilities — drift guards", () => {
beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
});
it("derivedMetricIds is exactly the derived registry's id set", async () => {
const res = await call();
expect(res.status).toBe(200);
const body = (await res.json()) as CapabilitiesBody;
// Length AND membership — the endpoint cannot silently drift from the
// registry (the whole reason it exists).
expect(body.data.derivedMetricIds.length).toBe(DERIVED_METRIC_IDS.length);
expect([...body.data.derivedMetricIds].sort()).toEqual(
[...DERIVED_METRIC_IDS].sort(),
);
});
it("ingest.writeAllowlist deep-equals WRITABLE_MEASUREMENT_SOURCES", async () => {
const res = await call();
const body = (await res.json()) as CapabilitiesBody;
expect(body.data.ingest.writeAllowlist).toEqual([
...WRITABLE_MEASUREMENT_SOURCES,
]);
});
it("computedScores are the three persisted wellness scores", async () => {
const res = await call();
const body = (await res.json()) as CapabilitiesBody;
expect([...body.data.ingest.computedScores].sort()).toEqual(
["RECOVERY_SCORE", "STRAIN_SCORE", "STRESS_SCORE"],
);
});
it("fhir constants mirror the FHIR builder source", async () => {
const res = await call();
const body = (await res.json()) as CapabilitiesBody;
expect(body.data.fhir.atcSystem).toBe(ATC_SYSTEM);
expect(body.data.fhir.snomedRoute).toBe(SNOMED_SYSTEM);
expect(body.data.fhir.germanAtcDefaultLocales).toEqual([
...GERMAN_ATC_DEFAULT_LOCALES,
]);
});
it("quantityTypes are sourced from the HealthKit ingest mapping", async () => {
const res = await call();
const body = (await res.json()) as CapabilitiesBody;
// Every emitted quantity row must trace back to a real mapping entry —
// never an entry the ingest path doesn't actually accept.
const byHk = new Map(
Object.values(APPLE_HEALTH_TYPE_MAP).map((m) => [m.hkIdentifier, m]),
);
expect(body.data.ingest.quantityTypes.length).toBeGreaterThan(0);
for (const q of body.data.ingest.quantityTypes) {
const mapping = byHk.get(q.hk);
expect(mapping).toBeDefined();
expect(q.type).toBe(mapping!.measurementType);
expect(q.unit).toBe(mapping!.dbUnit);
// Quantity rows are the non-event-class mappings.
expect(
Boolean(
mapping!.eventClassificationMap || mapping!.fallbackClassification,
),
).toBe(false);
}
});
it("apiContractVersion is a non-empty string", async () => {
const res = await call();
const body = (await res.json()) as CapabilitiesBody;
expect(typeof body.data.apiContractVersion).toBe("string");
expect(body.data.apiContractVersion.length).toBeGreaterThan(0);
});
});
+101
View File
@@ -0,0 +1,101 @@
/**
* GET /api/meta/capabilities
*
* Live capability / discovery surface for the native client. Returns the
* server's REAL id vocabularies — derived-metric ids, vitals-baseline
* types, insights layout tile-ids, metric-status ids, the HealthKit ingest
* mapping, and the FHIR coding constants — plus the running API contract
* version. The iOS app reads this once on launch (or on a version bump) and
* gates its UI / decoder against what the server actually ships, retiring
* the recurring "doc says N, server ships M" enum-drift class.
*
* Auth: cookie session OR Bearer token (`requireAuth`). NOT admin — every
* authenticated client may discover the contract.
*
* DESIGN RULE: every list is SOURCED from the existing canonical server
* constant / Zod enum / registry — never hand-duplicated here. Adding a
* derived metric, a tile, a writable source, or a HealthKit mapping
* auto-updates this endpoint, so it cannot drift from the real registries.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import packageJson from "../../../../../package.json";
import {
DERIVED_METRIC_IDS,
VITALS_BASELINE_TYPES,
} from "@/lib/insights/derived/registry";
import { INSIGHTS_TILE_IDS } from "@/lib/insights-layout";
import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry";
import { WELLNESS_SCORE_TYPES } from "@/lib/insights/derived/wellness-scores";
import { WRITABLE_MEASUREMENT_SOURCES } from "@/lib/validations/measurement";
import {
APPLE_HEALTH_TYPE_MAP,
type AppleHealthMapping,
} from "@/lib/measurements/apple-health-mapping";
import {
ATC_SYSTEM,
SNOMED_SYSTEM,
GERMAN_ATC_DEFAULT_LOCALES,
} from "@/lib/fhir/build-bundle";
export const dynamic = "force-dynamic";
/**
* An EVENT-class HealthKit identifier carries a device-produced verdict, not
* a continuous reading — it is marked by an `eventClassificationMap` or a
* `fallbackClassification` in the ingest mapping. Everything else is a
* quantity sample. Splitting the single mapping by this predicate keeps the
* two ingest lists derived from one registry.
*/
function isEventMapping(m: AppleHealthMapping): boolean {
return Boolean(m.eventClassificationMap || m.fallbackClassification);
}
export const GET = apiHandler(async () => {
await requireAuth();
annotate({ action: { name: "meta.capabilities.read" } });
// Mirror the /api/version source so the contract version cannot drift from
// the running build's reported version (build-arg env wins over the
// package.json fallback for local dev).
const apiContractVersion =
process.env.NEXT_PUBLIC_APP_VERSION?.trim() || packageJson.version;
const mappings = Object.values(APPLE_HEALTH_TYPE_MAP);
const quantityTypes = mappings
.filter((m) => !isEventMapping(m))
.map((m) => ({
type: m.measurementType,
hk: m.hkIdentifier,
unit: m.dbUnit,
}));
// Distinct event-class MeasurementTypes (several HK event identifiers can
// share one MeasurementType, e.g. the audio-exposure pair).
const eventTypes = Array.from(
new Set(mappings.filter(isEventMapping).map((m) => m.measurementType)),
);
return apiSuccess({
apiContractVersion,
derivedMetricIds: DERIVED_METRIC_IDS,
vitalsBaselineTypes: VITALS_BASELINE_TYPES,
layoutTileIds: INSIGHTS_TILE_IDS,
metricStatusIds: METRIC_STATUS_IDS,
ingest: {
quantityTypes,
eventTypes,
computedScores: Object.values(WELLNESS_SCORE_TYPES),
writeAllowlist: WRITABLE_MEASUREMENT_SOURCES,
},
fhir: {
atcSystem: ATC_SYSTEM,
snomedRoute: SNOMED_SYSTEM,
germanAtcDefaultLocales: GERMAN_ATC_DEFAULT_LOCALES,
},
});
});
+41 -5
View File
@@ -18,6 +18,7 @@ import { annotate } from "@/lib/logging/context";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
import { replaceTagLinks } from "@/lib/mood/tag-links";
type RouteParams = { params: Promise<{ id: string }> };
@@ -133,10 +134,38 @@ export const PUT = apiHandler(
// monotonic value and paired clients reconcile higher-wins.
updateData.syncVersion = { increment: 1 };
const entry = await prisma.moodEntry.update({
where: { id },
data: updateData,
});
// v1.8.5 — update the entry and replace its structured-tag links in
// one transaction. A tag-link failure must roll the entry update back
// too — otherwise a client retry on the 5xx double-applies the edit
// (and the `syncVersion` increment). The tx client is threaded through
// the helper so both writes commit (or abort) together.
const { entry, persistedTagKeys } = await prisma.$transaction(
async (tx) => {
const updated = await tx.moodEntry.update({
where: { id },
data: updateData,
});
// Full replacement of the structured-tag link set when `tagKeys`
// is present in the body. `null` clears every link; an omitted
// field leaves the links untouched.
if (data.tagKeys !== undefined) {
await replaceTagLinks(id, data.tagKeys ?? [], tx);
}
// v1.8.5 — read the persisted link keys back so the update
// response mirrors the list GET shape exactly.
const links = await tx.moodEntryTagLink.findMany({
where: { moodEntryId: id },
select: { moodTag: { select: { key: true } } },
});
return {
entry: updated,
persistedTagKeys: links.map((link) => link.moodTag.key),
};
},
);
await auditLog("moodEntry.update", {
userId: user.id,
@@ -177,7 +206,14 @@ export const PUT = apiHandler(
});
}
return apiSuccess({ ...entry, tags: parseTags(entry.tags) });
return apiSuccess({
...entry,
tags: parseTags(entry.tags),
// v1.8.5 — surface the persisted structured-tag keys so a client
// hydrating from the update response renders the tag set without a
// refetch (shape-matches the list GET).
tagKeys: persistedTagKeys,
});
},
);
@@ -4,6 +4,19 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// v1.8.5 — the POST happy path writes the entry + its structured-tag
// links inside one `$transaction`. The mock hands the same client object
// to the interactive-transaction callback so the route's tx-scoped writes
// (entry create + tag-link read-back) resolve against the same fakes.
const txClient = {
moodEntry: {
create: vi.fn(),
},
moodEntryTagLink: {
findMany: vi.fn().mockResolvedValue([]),
},
};
vi.mock("@/lib/db", () => ({
prisma: {
moodEntry: {
@@ -11,10 +24,20 @@ vi.mock("@/lib/db", () => ({
count: vi.fn().mockResolvedValue(0),
create: vi.fn(),
},
moodEntryTagLink: {
findMany: vi.fn().mockResolvedValue([]),
},
auditLog: { create: vi.fn() },
$transaction: vi.fn(
async (fn: (tx: typeof txClient) => unknown) => fn(txClient),
),
},
}));
vi.mock("@/lib/mood/tag-links", () => ({
createTagLinks: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
@@ -31,6 +54,9 @@ vi.mock("@/lib/cache/invalidate", () => ({
vi.mock("@/lib/rollups/mood-rollups", () => ({
recomputeMoodBucketsForEntry: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/moodlog/push", () => ({
pushMoodEntriesToMoodLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
@@ -47,6 +73,9 @@ vi.mock("next/headers", () => ({
import { GET, POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { createTagLinks } from "@/lib/mood/tag-links";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -69,6 +98,20 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
// Re-establish the interactive-transaction passthrough after the reset.
vi.mocked(prisma.$transaction).mockImplementation(
async (fn: unknown) => (fn as (tx: typeof txClient) => unknown)(txClient),
);
txClient.moodEntryTagLink.findMany.mockResolvedValue([]);
vi.mocked(createTagLinks).mockResolvedValue(undefined);
// `reset` blanks the post-commit best-effort mocks; restore the promise
// returns so `recompute(...)` awaits and `push(...).catch()` is callable.
vi.mocked(recomputeMoodBucketsForEntry).mockResolvedValue(undefined);
vi.mocked(pushMoodEntriesToMoodLog).mockResolvedValue({
pushed: 0,
skipped: 0,
status: "ok",
});
});
describe("GET /api/mood-entries — 422 multi-issue (v1.4.43 W6)", () => {
@@ -174,3 +217,79 @@ describe("POST /api/mood-entries — 422 multi-issue (v1.4.43 W6)", () => {
expect(res.status).toBe(422);
});
});
describe("POST /api/mood-entries — entry + tag-links transaction (v1.8.5)", () => {
const VALID_BODY = {
mood: "GUT",
moodLoggedAt: "2026-06-01T08:00:00.000Z",
tagKeys: ["happy"],
};
it("writes the entry + tag links through the transaction client", async () => {
txClient.moodEntry.create.mockResolvedValue({
id: "mood-1",
tags: null,
moodLoggedAt: new Date(VALID_BODY.moodLoggedAt),
mood: "GUT",
note: null,
source: "MANUAL",
date: "2026-06-01",
});
const res = await POST(postReq(VALID_BODY));
expect(res.status).toBe(201);
// The tag-link write runs against the same `$transaction` client that
// created the entry, so both commit (or roll back) together.
expect(createTagLinks).toHaveBeenCalledWith("mood-1", ["happy"], txClient);
});
it("returns the persisted tagKeys in the create response", async () => {
txClient.moodEntry.create.mockResolvedValue({
id: "mood-1",
tags: null,
moodLoggedAt: new Date(VALID_BODY.moodLoggedAt),
mood: "GUT",
note: null,
source: "MANUAL",
date: "2026-06-01",
});
txClient.moodEntryTagLink.findMany.mockResolvedValue([
{ moodTag: { key: "happy" } },
]);
const res = await POST(postReq(VALID_BODY));
expect(res.status).toBe(201);
const body = (await res.json()) as {
data: { id: string; tagKeys: string[] };
};
// Read-after-write parity: the create response carries the same
// `tagKeys` the list GET surfaces.
expect(body.data.tagKeys).toEqual(["happy"]);
});
it("rolls the entry back when the tag-link write fails (no commit)", async () => {
txClient.moodEntry.create.mockResolvedValue({
id: "mood-2",
tags: null,
moodLoggedAt: new Date(VALID_BODY.moodLoggedAt),
mood: "GUT",
note: null,
source: "MANUAL",
date: "2026-06-01",
});
// A failing tag-link write must propagate out of the transaction so
// Prisma aborts the entry create — the route surfaces it as a 5xx and
// the entry is never committed (no audit row, no rollup recompute).
vi.mocked(createTagLinks).mockRejectedValueOnce(new Error("link write"));
const res = await POST(postReq(VALID_BODY));
expect(res.status).toBe(500);
const { auditLog } = await import("@/lib/auth/audit");
expect(auditLog).not.toHaveBeenCalled();
const { recomputeMoodBucketsForEntry } = await import(
"@/lib/rollups/mood-rollups"
);
expect(recomputeMoodBucketsForEntry).not.toHaveBeenCalled();
});
});
+63 -15
View File
@@ -22,6 +22,7 @@ import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push";
import { createTagLinks } from "@/lib/mood/tag-links";
function parseTags(tags: string | null): string[] {
if (!tags) return [];
@@ -90,6 +91,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
orderBy: { [sortBy]: sortDir },
take: limit,
skip: offset,
// v1.8.5 — include the structured-tag link keys so the edit form
// can pre-populate the taxonomy picker.
include: {
tagLinks: {
select: { moodTag: { select: { key: true } } },
},
},
}),
prisma.moodEntry.count({ where }),
]);
@@ -99,9 +107,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
meta: { total, limit, offset },
});
const entriesWithParsedTags = entries.map((e) => ({
const entriesWithParsedTags = entries.map(({ tagLinks, ...e }) => ({
...e,
tags: parseTags(e.tags),
// v1.8.5 — flat list of structured-tag keys attached to the entry.
tagKeys: tagLinks.map((link) => link.moodTag.key),
}));
return apiSuccess({
@@ -147,7 +157,7 @@ async function postMoodEntry(request: NextRequest) {
return returnAllZodIssues(parsed.error, 422);
}
const { mood, tags, note, moodLoggedAt, source } = parsed.data;
const { mood, tags, tagKeys, note, moodLoggedAt, source } = parsed.data;
// v1.4.25 W7b (Decision A) — anchor the `date` string to the user's
// current displayTimezone and store the resolved zone on the row.
// Legacy rows with `tz IS NULL` continue to read as Europe/Berlin
@@ -157,19 +167,47 @@ async function postMoodEntry(request: NextRequest) {
const score = getScoreForMood(mood);
try {
const entry = await prisma.moodEntry.create({
data: {
userId: user.id,
date,
tz,
mood,
score,
tags: tags ? JSON.stringify(tags) : null,
note: note ?? null,
source: source ?? "MANUAL",
moodLoggedAt,
// v1.8.5 — write the entry and its structured-tag links in one
// transaction. The links are user-intended content, not a cache, so a
// tag-link failure must roll the entry back too — otherwise a client
// retry on the 5xx mints a duplicate entry. The tx client is threaded
// through the helper so both writes commit (or abort) together.
const { entry, persistedTagKeys } = await prisma.$transaction(
async (tx) => {
const created = await tx.moodEntry.create({
data: {
userId: user.id,
date,
tz,
mood,
score,
tags: tags ? JSON.stringify(tags) : null,
note: note ?? null,
source: source ?? "MANUAL",
moodLoggedAt,
},
});
if (tagKeys && tagKeys.length > 0) {
// Unknown keys are dropped inside the helper (the catalog is the
// source of truth).
await createTagLinks(created.id, tagKeys, tx);
}
// v1.8.5 — read the persisted link keys back so the create
// response mirrors the list GET shape exactly (unknown keys
// already filtered out).
const links = await tx.moodEntryTagLink.findMany({
where: { moodEntryId: created.id },
select: { moodTag: { select: { key: true } } },
});
return {
entry: created,
persistedTagKeys: links.map((link) => link.moodTag.key),
};
},
});
);
await auditLog("moodEntry.create", {
userId: user.id,
@@ -225,7 +263,17 @@ async function postMoodEntry(request: NextRequest) {
// rejection in the Next.js runtime.
});
return apiSuccess({ ...entry, tags: parseTags(entry.tags) }, 201);
return apiSuccess(
{
...entry,
tags: parseTags(entry.tags),
// v1.8.5 — surface the persisted structured-tag keys so a client
// hydrating from the create response renders the tag set without a
// refetch (shape-matches the list GET).
tagKeys: persistedTagKeys,
},
201,
);
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
+43
View File
@@ -0,0 +1,43 @@
import { apiSuccess } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import {
fetchMoodAggregates,
type MoodAggregates,
} from "@/lib/insights/mood-aggregates";
export const dynamic = "force-dynamic";
/**
* v1.8.5 — pre-computed mood-insights aggregates for the Mood Insights
* page (heatmap, distribution, weekday pattern, tag breakdown,
* cross-metric correlations, summary headline).
*
* The page stays a client component for the interactive line chart but
* reads the heavy aggregates from here: the compute runs server-side
* once per 60-s cache window, the browser only ever receives pre-shaped
* data (no raw mood rows). This follows the v1.8.3 anti-freeze posture
* — a cheap cached read, never a synchronous LLM call.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const result = await cached(
caches.moodInsights as ServerCache<MoodAggregates>,
user.id,
() => fetchMoodAggregates(user.id),
annotate,
);
annotate({
action: { name: "mood.insights.read" },
meta: {
total_entries: result.summary.totalEntries,
heatmap_window_days: result.heatmap.windowDays,
tag_count: result.tags.length,
},
});
return apiSuccess(result);
});
Loaded 100 of 540 files, more files were not shown because too many files have changed in this diff. Show more