Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Route every correlation narrative through the server translator:

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

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

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

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

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

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

- The bucket externalId guard accepted a non-canonical timestamp that V8
  silently rolls over (T24:00, 04-31, non-leap 02-29). Such a row would key
  under a literal string the canonical client never emits, so the two would
  never collapse on the overwrite key, and it would plot on the wrong local
  day. Require canonical round-trip (toISOString() === suffix) in both the
  guard and the bucket-start parser.
- The per-bucket min/max bypassed the plausibility-range guard that value
  passes; now that they drive the DAY min/max band and the intraday envelope,
  an out-of-range or mis-ordered spread poisoned the aggregate. Persist the
  spread only when both extremes are in range and ordered around value;
  otherwise drop to null and keep the average.
2026-07-18 11:58:51 +02:00
Marc-André Bombeck 9a6a05a0ef Merge pull request #531 from MBombeck/release/v1.30.7
release/v1.30.7 — 10-minute aggregated heart-rate upload contract
2026-07-18 11:36:53 +02:00
Marc-André Bombeck 0b37c15a52 chore(release): v1.30.7 — 10-minute aggregated heart-rate upload contract 2026-07-18 11:21:17 +02:00
Marc-André Bombeck 5fa18a3754 feat(measurements): 10-minute aggregated heart-rate upload contract
Refine the go-forward aggregated HR wire contract (iOS #34) from the never-
adopted hourly bucket to a 10-minute UTC bucket, so one contract serves both
the measurements-list de-explosion and the intraday-pulse 10-min curve.

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

No schema change (value_min/value_max exist since v1.19.2). Reconciliation is
go-forward cutover; the nightly retention fold handles the raw backlog.
2026-07-18 11:20:26 +02:00
Marc-André Bombeck 389bdb5d5d Merge pull request #530 from MBombeck/release/v1.30.6
release/v1.30.6 — accessibility pass
2026-07-18 10:46:19 +02:00
Marc-André Bombeck cf3f217e01 chore(release): v1.30.6 — accessibility pass 2026-07-18 10:29:43 +02:00
Marc-André Bombeck 47a78896c5 fix(a11y): heading semantics for two metric sub-page sections
The target-summary and coach-read strip tiles sit directly under a
metric sub-page's h1, alongside the stat strip that already renders its
title as an h2. Render their titles as real h2 headings too via the
existing titleAs prop, so heading navigation lands on each section. A
test pins that the dashboard exposes its page-level h1 through the
shared page header (no duplicate sr-only heading needed).
2026-07-18 10:27:55 +02:00
Marc-André Bombeck bfe23c17b3 fix(a11y): give the heatmaps a screen-reader day list
The mood and medication-compliance heatmaps exposed per-day values only
through pointer tooltips. The cells now carry a visually-hidden day list
beside the grid — one entry per day that holds a value — so a screen
reader reaches the same figures without a tab stop per cell. A cell
subtree nested under the SVG's aggregate role=img is pruned from the
accessibility tree anyway, and a year-long grid would otherwise flood
the keyboard order; the rects stay a pure pointer affordance and the
aggregate summary label is unchanged. Tests pin the list and that the
cells carry no tab stops.
2026-07-18 10:27:55 +02:00
Marc-André Bombeck 156c4574b5 test(admin): match the token-overview meta assertion to the text-xs floor
The mobile meta lines moved off the sub-xs px size; anchor the wrap
assertion on the muted text-xs paragraph instead of the old px class.
2026-07-18 10:27:55 +02:00
Marc-André Bombeck e1c6941b20 fix(ui): lift muted meta text off the sub-xs floor
Bump text-[10px]/text-[11px] to text-xs on muted description, hint, and
status-label surfaces that had drifted below the type-scale floor —
coach conversation captions, settings/admin status badges, mood
confidence pills, side-effect and cycle meta lines. Tabular-nums
badges, chart-plot legend/axis chrome, ring geometry, and
width-constrained tag tiles keep their compact size by design.
2026-07-18 10:27:55 +02:00
Marc-André Bombeck b6a1fc1c2b Merge pull request #529 from MBombeck/release/v1.30.5
release/v1.30.5 — reachable ECG surface and a sync-status fix
2026-07-18 09:44:36 +02:00
Marc-André Bombeck 0f5b18d946 chore(release): v1.30.5 — reachable ECG surface and a sync-status fix 2026-07-18 09:26:58 +02:00
Marc-André Bombeck 4ca34d4b00 fix(insights): point the ECG cross-link at the routed sub-page
Repoint EcgCrossLink from the overview `#ecg` fragment to `/insights/ecg`.
The fragment is absent until the overview section's own fetch resolves, so
the deep link no-oped on navigation (UX-flows finding F1-1); the routed
sub-page always mounts.
2026-07-18 09:25:29 +02:00
Marc-André Bombeck adf80da989 feat(insights): give ECG a routed home and index-surface presence
ECG recordings had no pill in the tab strip, no entry in the sub-page
manager, and no routed page — the only inbound pointer was a cross-link
card on the spot-pulse page. Hiding the overview ECG teaser left no route
back to a user's recordings.

- Add a routed `/insights/ecg` sub-page that reuses the existing ECG
  section (heading suppressed so the page shell owns the title); the
  overview teaser stays.
- Add an ECG entry to the Heart group of the tab strip, gated on a
  dedicated `hasEcgRecordings` signal since ECG carries no measurement
  type. The Heart group surfaces for an ECG-only device too. No ECG pill
  appears without recordings.
- Mount the ECG cross-link on the resting-pulse and HRV pages, matching
  its placement on the pulse page.
- List ECG in the sub-page manager under Heart, its eye toggling the ECG
  overview section so hiding it is reversible.
- Point the metric catalog's ECG row at the new page.
- Add `navEcg`, the sub-page description, and the empty-state copy across
  all locales.
2026-07-18 09:24:28 +02:00
Marc-André Bombeck e8f4bb3dba fix(whoop): advance sync-status ledger on per-resource webhook syncs
The webhook-driven per-resource sync advanced the resource cursor but never
touched IntegrationStatus, so lastSuccessAt stayed frozen at the last full
poll — the Settings 'last synced' pill and the integration-status read
reported stale for up to an hour after a webhook had already landed last
night's data. Stamp recordSyncSuccess per resource, reusing the full sync's
exact success guard (a genuine import clears the error state; a 403 soft-skip
leaves the looks-healthy window closed).
2026-07-18 08:44:58 +02:00
Marc-André Bombeck 0c5049cd69 Merge pull request #528 from MBombeck/release/v1.30.4
release/v1.30.4 — assistant connector reaches the full v1.30 record
2026-07-18 08:25:29 +02:00
Marc-André Bombeck c93d02c089 chore(release): v1.30.4 — read-only assistant connector reaches the full record 2026-07-18 08:07:35 +02:00
Marc-André Bombeck d2e36e2546 fix(mcp): exempt the reviewed discovery allowlist from the mcp:false veto
The signal registry's `mcp:false` flag vetoes a metric-status resolution
so an unreviewed non-exposed signal cannot leak through the id/display-name
path. The metric-status discovery allowlist is a deliberate, human-named
exposure — co-equal to `mcp:true` — so its ids must stay resolvable, or
compare_metric / get_metric_baseline would advertise a metric in discovery
that then refuses to resolve. Exempt the allowlist from the veto; the guard
still fires for genuinely-unreviewed mcp:false ids (workout HR stats).
2026-07-18 08:06:42 +02:00
Marc-André Bombeck a70c98d2b4 feat(mcp): surface metric-status-only rich metrics for discovery
Wrist temperature, cardio recovery, sleep score, breathing
disturbances, strain/load, and the rest of the v1.10/v1.18.1
metric-status-registry ids were already resolvable through
compare_metric / get_metric_baseline / detect_changepoints (an exact
registry-id or display-name match), but sat off the Coach snapshot
inventory entirely — so list_metrics, the measurements-inventory
resource, and search never advertised them. A discover-before-fetch
assistant, which the server's own instructions direct, could never
learn they existed.

Adds a fixed, reviewed allowlist (MCP_METRIC_STATUS_DISCOVERY) plus a
metricStatusDiscoveryRows() presence probe, mirroring the existing
MCP_CLINICAL_SIGNALS pattern: one grouped presence query, appended as
supplement rows to list_metrics and the inventory resource, and merged
into the same probe search already runs for the v1.25 clinical
signals. fetch(id) now hydrates these ids via the baseline read the
same way it already does for clinical signals.
2026-07-18 07:57:04 +02:00
Marc-André Bombeck fdc8ed81a0 feat(mcp): add get_ecg_recordings read tool
Adds MCP coverage for ECG recording metadata (recorded time, duration,
sampling rate, sample count, average heart rate, lead, and the
device's own rhythm classification) — previously unreachable because
ECG has no MeasurementType and so cannot appear in list_metrics or the
metric resource templates.

get_ecg_recordings mirrors GET /api/insights/ecg exactly, down to the
select clause: waveformEncrypted is never selected, so a decrypt can
never happen on this path. Non-diagnostic framing carries through:
classificationSource is a fixed "device" literal, and the description
states plainly that HealthLog never re-classifies. Gated on both the
insights module and the insightStatus assistant-surface flag, mirroring
the route's own two-layer gate.
2026-07-18 07:56:14 +02:00
Marc-André Bombeck c6b8d33b0c feat(mcp): add get_intraday_pulse read tool
Adds MCP coverage for the intraday pulse / "shape of the day" —
previously unreachable, since get_metric_series only serves daily
aggregates and cannot answer "what happened this afternoon?" or "was I
tense during the meeting?".

get_intraday_pulse is a thin wrapper over loadIntradayPulse, returning
the same DTO GET /api/insights/pulse/intraday serves verbatim: the
10-minute mean shape (dense-90d, hourly-grain fallback outside that
window per the resolution field), the resting baseline, and the
cautious elevated-at-rest tension window when every confidence gate
holds. Gated on the insights module; never fabricates a reading for a
day with no data.
2026-07-18 07:56:14 +02:00
Marc-André Bombeck e062721812 feat(mcp): add get_nutrients read tool
Adds MCP coverage for the NutrientIntakeDay pipeline (water, caffeine,
24 micronutrients) — previously fully unreachable over MCP, and
search("water") / search("vitamin") silently miscollided or read as
honest absence even for a daily logger.

get_nutrients is a thin facade over the same two reads GET
/api/nutrients and GET /api/nutrients/daily run: the tz-anchored
day-key math, the sum-across-sources fold, and the sex-aware EFSA
reference resolution. Gated on the opt-in nutrients module, mirroring
both backing routes. Discoverable via search (a grouped presence
probe) and fetch, plus a healthlog://nutrient/{code} resource template
with completion + listing scoped to the user's own logged codes.
2026-07-18 07:56:14 +02:00
Marc-André Bombeck c6885bc7d3 fix(coach,mcp): collapse workout dedup and union HRV flavours for MCP parity
The Coach snapshot read raw prisma.workout rows while GET /api/workouts
runs pickCanonicalWorkoutRows, so a dual-source user (e.g. Apple Watch +
Withings recording the same run) got the session double-counted in the
Coach prompt and the get_workouts MCP tool. Route the snapshot's workout
block through the same picker, keyed off the user's own sourcePriorityJson.

HRV resolves SDNN only on every MCP/coach path, so an Oura/Polar/WHOOP-only
account (RMSSD, not SDNN) saw a false empty HRV block even though the app's
HRV sub-page already unions both flavours. The coach snapshot now queries
both types; the MCP rich reads (compare_metric, get_metric_baseline,
detect_changepoints) fall back to RMSSD when the user has zero SDNN rows,
mirroring the app's primary-then-fallback rule.

Also aligns surfaces.mcp as a real gate: a metric-status id the signal
registry marks mcp:false (cardio recovery, wrist temperature, sleep score,
...) no longer resolves through the rich-read registry path, closing a
contract gap between the two registries (no data was actually exposed
before this, but a future mcp:false signal without this guard would have
leaked silently).

The remaining day-edge tz mismatch between the rollup-backed rich reads
and the user-tz coach timelines is left documented rather than patched:
the underlying rollup buckets are themselves written on UTC calendar
boundaries, so shifting only the read-side window would trade one
one-sided inconsistency for another without fixing the root cause.
2026-07-18 07:56:14 +02:00
Marc-André Bombeck 6b259c2c28 Merge pull request #527 from MBombeck/release/v1.30.3
release/v1.30.3
2026-07-18 07:03:42 +02:00
Marc-André Bombeck aaa05241b3 chore(release): v1.30.3 — quality re-check follow-ups 2026-07-18 06:49:16 +02:00
Marc-André Bombeck 009e5adc43 fix(i18n): close medical-content gaps in explainer, watchdog, and backup copy
Visceral-fat explainer was missing its unitless-scale caveat in every
non-English locale, so those readers got the heart-disease/diabetes framing
without the "not interchangeable with imaging thresholds" caveat. Translate
the full sentence into de/es/fr/it/pl, and mirror the study citations
(fat mass, visceral fat, daylight, falls) that had only been added to the
English strings.

Condition the "automatic screening" watchdog copy on a source actually being
connected — silence with nothing connected means nothing is being watched,
not that everything looks fine — across all six locales.

Disclose in the full-backup scope note that document metadata and any
AI-generated summary are included in the plaintext export; only the
original files and workout GPS/sample data are excluded.
2026-07-18 06:47:32 +02:00
Marc-André Bombeck a977df24a0 fix(ui): catalog loading/error truth, info-popover dedupe, touch sweep
Metric catalog page painted every metric as "Not tracked yet" (with
Connect CTAs) while its four availability probes were still in flight,
and stayed there forever on a failed batch — a load failure indistinguishable
from an honest absence. Gate rows on the probes' aggregate loading/error
state: row skeletons while loading, a QueryErrorCard with retry on failure.

Repoint ComplianceInfoTip onto InfoPopover instead of carrying a verbatim
structural copy (adds icon/align/data-slot override props to the shared
primitive); migrate the cycle log-sheet's FieldInfo off a hover Tooltip
onto the same click-opened InfoPopover, since a hover trigger never opens
reliably on the sheet's thumb-first touch surface.

Lift ViewToggle/SegmentedToggle off their stale 40px tap floor onto the
44px/36px floor the sibling intake and history-rail controls already
carry, and correct the docstrings to match. Restore the dropped
safe-area-inset-top term in /coach/conversations' height calc so it
matches /coach on notched devices. Bring the coach history rail's
off-scale spacing and sub-floor timestamp text onto the standard scale.
2026-07-18 06:47:32 +02:00
Marc-André Bombeck 1d559d7ed0 fix(nutrients): evict the dashboard snapshot on a landed batch row
POST /api/nutrients/batch performed zero cache invalidation, so an
Apple-Health-synced water/nutrient total kept the dashboard's tile
serving the pre-sync figure until the analytics fresh TTL lapsed
(~3 min) — the manual water POST already hard-evicts on its own write.

Calls invalidateUserDashboardSnapshot(user.id) once at least one row
inserted or updated, mirroring the manual route's own posture so a
synced total reflects on the very next dashboard read.
2026-07-18 06:47:32 +02:00
Marc-André Bombeck 56a32c528e fix(analytics): pair BP readings on the user's own tz, not a hardcoded Berlin day
computeBpInTargetPct's same-calendar-day pairing fallback (for readings
whose sys/dia timestamps drift past the 5-minute session window) keyed
on toBerlinDayKey regardless of the caller's actual user. A non-Berlin
user's two same-local-evening readings straddling Berlin midnight (but
not their own midnight) were rejected as a pair, running the in-target
percentage on fewer readings than it should have — precisely the
population the cross-tz runtime guard in bp-in-target-fast-path.ts
force-routes to this same pairing fallback to protect.

Threads an optional tz through computeBpInTargetPct / collectBpPairs /
computeBpInTargetWindows (default Berlin for untouched legacy
callers), wires it through the fast-path's live fallback and the
comprehensive-insights route's own computeBpInTargetPct call — the
other caller with the user's tz already in scope.

Investigated but left the comprehensive route's resting-pulse-proxy
day-key and BP-medication-continuity day-key on their current UTC
convention: both are joined by an exact YYYY-MM-DD key against a
UTC-anchored partner (the mood rollup's bucketStart, and the
aggregator's date_trunc'd BLOOD_PRESSURE_SYS daily series). Switching
only one side to the user's tz would trade "evening readings shift a
day" for "evening readings mismatch their pairing partner" — a
different bug, not a fix. Documented in place; a real fix needs the
mood-rollup / aggregate tiers themselves to bucket per-user-tz.
2026-07-18 06:47:32 +02:00
Marc-André Bombeck e51e81c76f fix(measurements): flag the oldest groupBy=day bucket as partial when the cap hits
The raw-row read backing the day-collapsed list is desc + take(limit).
On a dense account whose in-window row count crosses the cap, the
oldest returned row sits mid-day — every earlier sample on that same
calendar day was cut off, so its bucket's SUM silently understates the
real day total with no marker.

Flags exactly that one bucket partial: true when the cap was actually
hit, so a page that came in under the cap (the whole window covered)
carries no flag on any bucket.
2026-07-18 06:47:32 +02:00
Marc-André Bombeck dfa4533c7c fix(insights): roll the daily card cache over at the user's own midnight
The eleven per-metric/derived-score status helpers (pulse, weight,
bmi, mood, blood-pressure, medication-compliance, general, the generic
metric card, biomarker, the shared feature cache, the derived-score AI
warm layer) computed todayKey from toBerlinDayKey(new Date()) before
resolving the user's own timezone — several fetched the profile only
for downstream day-bucketing, after the cache read had already run on
the Berlin key. A non-Berlin user's card regenerated at their local
evening (Berlin midnight) and, for biomarker series and the
derived-score prompt label, mis-keyed data or displayed a hardcoded
"(Europe/Berlin)" regardless of their actual zone.

Threads resolveUserTimezone()/userDayKey() ahead of every cache read
so the day-key and the data bucketing agree, and reuses the one
resolved value everywhere downstream instead of re-deriving it.
2026-07-18 06:47:32 +02:00
Marc-André Bombeck 1e8e46f58c fix(personal-records): re-derive the cumulative-type records the pre-fix SUM inflated
Before the source-collapse fix, a day with both an Apple Health AND a
Withings write for the same cumulative metric (steps, active energy,
flights, distance, daylight, falls) summed both sources into one
inflated total and locked it in as the all-time PersonalRecord. The
worker's improvement gate only ever compares a freshly-computed day
against the stored best, so the false record silently outranked every
honest day forever and the affected metric could never set a genuine
record again.

Adds a boot-time one-shot repair (queue: cumulative-pr-rederive),
mirroring the existing step-consolidation-repair pattern: deletes the
suspect measurement-driven records created before the fix landed, then
re-runs detection with silent:true so the honest re-derived best
replaces it without firing a "new record" push for what is data
hygiene, not an achievement. Self-converging by a fixed cutoff instant
rather than a marker column or reused SQL migration number.
2026-07-18 06:47:32 +02:00
Marc-André Bombeck 6c2eb15f05 fix(insights): hoist the correlation window fetch so every reader shares one grain
The asc+take(20000) window-cap bug (fixed on the correlations route
alone) and the mixed-grain toDailyMeans bug (fixed on three of four
readers) both survived on the Coach get_correlations tool, the
per-metric assessment card, and the period narrative. Dense accounts
saw the cap drop the newest rows on the coach/card readers and drop
the CURRENT period on the narrative — backwards for a recent-window
and a current-vs-prior surface respectively; the narrative's own local
toDailyMeans twin also averaged sleep stage-segments and blended
per-sample step chunks with drained daily totals instead of summing.

Hoists the fetch + grain resolution into
fetchMeasurementWindowSeries/fetchMoodWindowSeries
(correlation-channel-series.ts) — desc+cap+resort, full column select
for buildMeasurementDailySeries's per-type grain — and routes the
route, the coach tool, the per-metric card, and the period narrative
through it, deleting every local copy.
2026-07-18 06:47:32 +02:00
Marc-André Bombeck 635d7e2618 Merge pull request #526 from MBombeck/release/v1.30.2
release/v1.30.2
2026-07-18 05:26:28 +02:00
Marc-André Bombeck de8368cf01 chore(release): v1.30.2 — coach history, navigation, correctness, and performance 2026-07-18 05:08:06 +02:00
Marc-André Bombeck 1a642f1173 feat(coach): full-history search + pagination; document the labs watchdog
Coach conversation history was capped at the rail's first ~20 threads and
searched only that loaded page. The rail and the standalone conversations
page now walk the caller's entire history via cursor pagination
(useInfiniteQuery + a scroll sentinel), and search runs server-side against
the conversation title (GET /api/insights/chat?q=) instead of filtering an
already-truncated client array.

Also extends the "automatic screening" info line in the metric catalog to
name the labs "what changed since your last panel" card alongside the
rhythm/breathing/health-status watchdogs it already covered — it un-mounts
the same way when there's nothing to flag and was missing from the copy.
2026-07-18 05:06:24 +02:00
Marc-André Bombeck 8dd04e0170 fix(ux): close medium UX-audit findings across flows, IA, and onboarding
Coach deep-links now carry launch context in the URL instead of dropping it
at the route boundary: /coach reads ?scope= (validated against the closed
CoachScopeSource enum) and ?ask= to seed the drawer's own prefill/scope
props, the Today check-in's "Adjust" action seeds ?ask= with the plan's own
words instead of opening a blank chat, and the workout detail page scopes
its coach button to workouts.

IA: give the document vault a "View N lab values" link on LAB_RESULT-shaped
documents (the auto-stage/OCR-commit linkage existed server-side with no UI
surface); disambiguate the two "Recovery" pages (RECOVERY_SCORE anatomy vs
the composite wearable page) with a distinct label plus a two-way
cross-link; move the opt-in nutrients sync card from Source priority to
Integrations, where every other sync surface lives; rename the user-menu
theme switcher off "Appearance" (collides with the Settings hub) and the
Environment settings section to "Location & weather"; cross-link mood,
mental-wellbeing check-ins, and mood insights, which were three
unconnected islands.

Onboarding: the getting-started checklist's data-source/notifications rows
now gate their fetch on the same checklistRelevant condition the other rows
use, instead of only-while-onboarding — they used to be stuck undone
forever for anyone who finished the wizard with under 5 measurements; the
tour wrap-up no longer promises a replay affordance that was removed;
the wizard's Withings card shows a connected badge once the OAuth
round-trip lands instead of leaving the button looking untouched in the
tab it was opened from; the done screen and Modules hub get a quiet
cross-link; the sleep empty-state CTA reads "Connect a source" instead of
being pinned to one vendor.

All copy changes land across every locale; de.json reviewed for register.
2026-07-18 05:06:24 +02:00
Marc-André Bombeck e9e186dca5 fix(gamification): bucket achievement streaks by the badge owner's own tz
Every day-key derivation feeding the achievement streak/count badges
(login, entry, weekend, miss-free, sleep-log, on-time, compliance-80)
plus the hidden night-owl/early-bird triggers hardcoded Europe/Berlin,
including the SQL vitals-bucket query's `AT TIME ZONE` literal. A
non-CET user's evening entries could fold into the wrong calendar day,
or two genuinely distinct local days could merge into one, silently
breaking streak badges for every non-CET account.

Threads the account's own `User.timezone` through
`buildAchievementsResult` into the SQL query (bound as a parameter,
not spliced — Postgres accepts a text value on the right side of
`AT TIME ZONE`, verified against a live instance) and every JS-side
day-key helper. The pure helpers in `expansion-metrics.ts` and
`care-metrics.ts` keep the Berlin default when `tz` is omitted, so
every existing caller/fixture is unaffected.
2026-07-18 05:06:24 +02:00
Marc-André Bombeck 44ab74e125 fix(nutrients): count each day once and anchor the overview window on user tz
GET /api/nutrients had two correctness bugs:

- `daysWithData` incremented on every row whose day differed from the
  PINNED latest-day field, so a second source's row on an already-seen
  older day (water logged via both Apple Health and a manual entry on
  the same day) opened a phantom second "day". Track a per-nutrient
  set of distinct day keys instead.
- The window floor used a UTC day key against a local-timezone `day`
  column, off by up to a day at the edge for non-UTC users. Anchor on
  the caller's own local "today" instead, mirroring the sibling
  `GET /api/nutrients/daily` route.
2026-07-18 05:06:24 +02:00
Marc-André Bombeck 71d7ca78cb fix(insights): scope intraday-pulse reads to the local day and fix the baseline tz
Two bugs in the S11 intraday pulse seam:

- The PULSE/step reads queried a ±15h/39h padded superset around the
  viewed day, then capped the row count ascending. On a dense account
  the cap could be entirely consumed by the PREVIOUS local day's
  samples before reaching the viewed day at all, truncating the chart
  partway through the afternoon. Query the exact local-day window
  (DST-aware) instead — the workout-overlap read keeps the wider
  padding since it has no cap and a workout can legitimately span a
  local-day boundary.
- The resting-baseline PULSE-proxy fallback bucketed every user's
  samples by a hardcoded Berlin day, not the caller's own timezone,
  shifting the personal floor the tension detector judges the day
  against for every non-Berlin user. Threads `timezone` into
  `resolveBaseline`'s `dayKeyOf`.
2026-07-18 05:06:24 +02:00
Marc-André Bombeck 5169be5887 fix(insights): order the correlations read desc so a hit cap keeps recent rows
The FDR discovery read capped a dense account's 180-day window at
orderBy asc + take, so exceeding the cap silently dropped the NEWEST
rows. The early-detection pass exists specifically to see the recent
window, so a capped read gave it nothing to work with.

Order desc for both the measurement and mood reads, re-sort ascending
in JS afterward (every downstream day-bucketing pass groups by day
key, so pre-sort order doesn't matter to them), and annotate when a
read actually hit its cap.
2026-07-18 05:06:24 +02:00
Marc-André Bombeck a2cb4fb4a9 perf(workouts): cache the canonical dedup projection per filter, not per page
GET /api/workouts read and deduped the full filtered row set on EVERY
page request because the cache key included offset/limit. A historical
drain (the iOS app's multi-page pull, thousands of rows on a long Apple
Health import) paid the full read + dedup pass once per page instead of
once per filter combination.

Split the route into a cached projection builder (keyed on
userId|since|until|sportType, deliberately excluding offset/limit) and a
cheap in-memory slice per page. Also runs the row read and the
sourcePriorityJson lookup concurrently instead of sequentially.
2026-07-18 05:06:24 +02:00
Marc-André Bombeck 513e5309ac Merge pull request #525 from MBombeck/release/v1.30.1
release/v1.30.1
2026-07-18 04:08:44 +02:00
Marc-André Bombeck ccfda544f8 chore(release): v1.30.1 — mobile, feedback, and consistency polish 2026-07-18 03:55:34 +02:00
Marc-André Bombeck 2c08201695 fix(content): German register breaks, stale disclaimer comments, safety-floor wording
Three German strings broke register: the doctor-report wellness
disclaimer and the Sie-register clinician view's attachment hint both
leaked a du-form word into otherwise neutral/Sie text handed to a
clinician, and the sharing settings' "shownOnce" hint used a Sie
imperative inside the app's own du-form settings surface. All three
now match the register their surface actually uses.

daily.line.allClear ("everything's tracking normally") edged toward an
all-clear verdict the safety-floor module deliberately never makes;
drops the second clause to match the already-existing, safer
daily.today.allClear wording, across all six locales.

Four insights explainers (fat mass, visceral fat, daylight, falls)
asserted health-outcome links with no citation while their siblings
(resting HR, pulse wave velocity, VO2 max) all cite a named guideline
body inline; adds the same parenthetical anchor to each.

Two coach-panel comments still described a per-page "always visible"
clinical-decisions disclaimer that DISC-01 (v1.18.6) replaced with the
one-time onboarding acknowledgment — the code was already correct, the
comments were stale and now say so.
2026-07-18 03:52:33 +02:00
Marc-André Bombeck f223ca8979 fix(a11y): explicit accessibilityLayer, tile headings, stronger focus ring
Health-chart and mood-chart now pass accessibilityLayer explicitly on
their ComposedChart root — Recharts 3 already defaults it on, but
pinning it here documents the choice and survives a future default
flip on the two busiest chart surfaces in the app.

TileHeader's titleAs="h2" was previously read as a single-owner
exception; the docstring undersold it — sibling h2 tiles in one grid
nest correctly under the page h1 same as a lone owning block. Wires it
through the dashboard's recent-workouts tile and the insights vitals
grid (both the loaded SparklineDeltaTile card and the two provisional/
building states), so a rotor user gets one heading per tile instead of
landing on the page's single h1 and nothing else.

The coach composer's focus ring rode ring-primary/15 — likely under
the 3:1 non-text-contrast floor next to its /50 border. Raises it to
/50, matching the family of "wrapper focus-within ring, outline-none
inner control" the standard already uses elsewhere at full strength.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck 3a45fcc3bd fix(ui): close remaining consistency-audit findings
Sweeps the banned gap-5/space-y-5/pl-5 spacing residue the audit found
scattered across coach pages, settings coach cards, the labs/records/
custom-metrics form family, onboarding, and a handful of one-off
surfaces (glucose panel, clinician view, illness correlation card,
invite-tokens, the mental-health check-in wizard including its stray
sm:p-6 shell steps) down onto the 4/6 scale.

Two medication chart colours (dose-strength-curve, drug-level-chart)
painted raw --dracula-purple with no light-theme override; both now
read --chart-1, the same alias the mood-chart fix already established.

Raises ~40 sub-text-xs muted-prose sites below the UI-STANDARDS §3/§5
floor (settings API/MCP/sharing/coach-prefs cards, insights workout
detail, suggested prompts, tab-strip group popovers, sources rail,
metric-stat-strip, the health-score provisional pill) to text-xs,
leaving genuine tabular-nums Badges untouched — the one exemption the
standard carries.

Routes the 10 hand-rolled settings/insights "no data yet" paragraphs
(coach memory, coach reminders, security sessions/activity/keys, API
tokens, sharing, insights pill order, per-metric overrides, nutrient
intake) through the shared EmptyState primitive instead of a bare
muted <p>, so they read the same, centered, icon-ready shape every
other empty list in the app already uses. The notification-status
card's empty copy stays on SettingsCardHeader's description slot —
converting it would mean restructuring the card, not routing it.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck a2f926576e feat(labs): add "save & add another" to the manual result form
A real lab report is 10-20 analytes sharing one blood-draw date,
usually in the past; the add form closed on every save, resetting
takenAt to now and clearing the biomarker pick, so entering a full
panel meant re-setting the date away from now on every single row.
The new button keeps the sheet open, clears the reading, and
deliberately preserves takenAt (and the biomarker pick when not
locked to one detail page) so the next analyte in the same panel just
needs a pick + a value.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck b00450aec8 fix(pwa): fix pull-to-refresh's at-top check and extend coverage
The authenticated shell's actual scroll container is <main
id="main-content"> (AuthShell sets it overflow-y-auto inside a
height-locked layout); document.scrollingElement never moves there, so
the gesture's "only arm at the top of the page" check was permanently
true regardless of how far a user had scrolled a long list. Point it
at the real scroll container instead, with the old document-root check
kept as a fallback for the few body-scrolled surfaces outside the
shell.

Extend pull-to-refresh from 4 pages (labs/checkups/mood/measurements)
to the dashboard, /insights, /medications, and the documents vault —
the sharpest PWA-resume-staleness surfaces, per the audit: a
background-refreshed dose or a cross-device write otherwise sits
stale until a full remount or the staleTime window lapses.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck c1c3aa9754 fix(settings): resync threshold rows after a reset
Each MetricRow is keyed on the stable metric name, so a per-row or
reset-all mutation that lands a new override (typically null) never
re-ran the useState initializers — the switch, min/max fields, and
override mode kept showing the just-cleared values while the
"Überschrieben" badge (driven straight off the prop) vanished, a
contradictory row until a reload. Re-derive an identity from the
override prop and resync local state during render when it changes;
typing in the fields never touches that identity, so this never
fights a live edit.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck 7ac9e7833e fix(medications): give backdated/manual intakes the same Undo the card has
Logging a dose via Medications → Add → "Log intake" (incl. backdated)
or the dashboard's medication-intake quick-add toasted success with no
Undo, unlike the card's own take/skip path — a dose logged against the
wrong medication (easy on the quick-add, which auto-picks) was only
correctable via detail page → history → find row → delete. Both paths
now capture the created event id and attach the same Undo action.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck 8c385b9ac2 fix(coach): delete-with-undo replaces the arm-forever confirm
The history rail + /coach/conversations armed a row on the first tap
and only disarmed on a successful delete — no timeout, no
outside-click reset — so a row armed minutes earlier deleted on a
later stray tap, and there was no way back once it fired. Replace it
with the same delayed-commit delete-with-undo the Documents surface
already uses: one tap hides the row and schedules the real DELETE a
few seconds out; the toast's Undo cancels it. Centralised in
useDeleteCoachConversationWithUndo so both surfaces, which carried
byte-identical arm/confirm logic, share one implementation.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck 9531bbdcec fix(measurements): remember the last-used type on the add form
The add form always defaulted to BLOOD_PRESSURE regardless of what a
user actually logs day to day, forcing a re-pick from a 15-entry
select on every entry unless arriving via a ?add= deep link. Seed the
default from the last manually-saved type (localStorage, client-only)
when no deep link overrides it, and record the type on every
successful save.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck 3c468b5e00 fix(charts): persist the range-tab pick like the overlay toggles
rangePoints lived in plain useState(30), so every chart reset to the
30 d tab on mount while the sibling trend/comparison/target-band
toggles on the same card persisted per chart. Add rangePoints to
ChartOverlayPrefs (server-validated against the closed 0/7/30/90 set)
and hydrate it once the prefs resolve; a range-tab tap now persists
through the same PUT the toggles already use.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck a66a346d0d fix(capture): guard the bottom-nav Log sheet against silent discard
CapturePicker mounted the same measurement/mood/medication-intake forms
the dashboard quick-entry sheets do, but closed unconditionally on a
backdrop tap, Escape, or mobile swipe-down — the dashboard's
sheetBodyHasUnsavedInput() confirm guard never made it into this newer
picker. Reuse it here too, so a half-filled form from the primary
mobile capture path asks before dropping typed input instead of
losing it.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck 20f0d13eba style: prettier reflow on the mobile-audit touch-up files 2026-07-18 03:52:32 +02:00
Marc-André Bombeck 07f161bf25 fix(ui): workout header stacking, chart-shell inset parity, silent retarget failure
WorkoutDetailHeader held three columns at every width, wrapping the
long-format date to 4-5 lines beside a text-2xl duration at 360px;
stack below sm with duration moving under the title block. Same pass
drops a stray text-[11px] muted hint below the size floor.

The intraday pulse chart and the VO2/cardio-fitness link card carried
a static p-4 shell with no md:p-6 step, and the intraday loading state
was a raw pulse div instead of the Skeleton primitive under-reserving
the chart height — both now match the card inset contract used
elsewhere. Same one-line fix on the insights hero shell (sm-stepped
inset instead of the standard md-stepped one) and the glucose advanced
panel's 3-up grid at a 320px fold.

The medication efficacy tab's retarget control failed saves silently —
no toast, no inline alert, an unhandled rejection escaping past the
disabled-button reset. Surface a toast on failure. Also swaps the
level-shift date off a raw toISOString slice onto the locale
formatter, and the primary-target tag off bare muted text onto the
Badge component the rest of the app uses for this kind of marker.

The caffeine card collapsed its 280px skeleton to nothing on every
nutrients-page visit for an account with no logged caffeine; a 60s
staleTime (already the convention on the sibling insights queries)
keeps the flash to once per session. The efficacy chart's dynamic-
import fallback was the full card-chrome ChartSkeleton nested inside
its own Card, snapping ~100px when the real bare chart mounted — swap
for a plain Skeleton sized to the actual chart.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck 5532a42ff6 fix(ui): mobile stacked intake history, icon-collapse bulk bar, ResponsiveSheet parity, tap-target floor
Intake history stayed a 4-5 column table with two full datetime columns
on mobile, forcing sideways scroll of a primary history surface; add
the stacked card-list split below md (the measurement-list pattern) and
lift the row kebab off a hard 32px box onto the size-11/size-9 floor.

The documents bulk-action bar wrapped five labelled buttons into a
2-3 row floating slab at 360px; collapse to icon-only (aria-label
carries the name) below sm, matching the document-detail-sheet
footer's established pattern.

Two coach/medication modals were still on raw Dialog while every
sibling had moved to ResponsiveSheet: the schedule-era editor and the
document attachment picker. Both now share the bottom-sheet-on-mobile
primitive; the era editor's submit button associates to its form via
the native `form` attribute since the footer slot sits outside the
form's DOM subtree (the log-intake-dialog precedent).

Tap-target floor: side-effect entry chips and the coach attachment-pill
remove control were below the 44px floor; the chips get a min-h-11
flex-center, the pill remove gets an invisible hit-slop instead of
growing the pill's visual footprint. Two insights empty-state CTAs
were missing the existing `ctaSize="lg"` treatment already used by
their sibling empty states.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck 66fe69b493 fix(ui): break-words on coach prose, hero/wellness skeleton parity, priority-card pending state
Coach chat bubbles and the document OCR text panel wrapped long unbroken
tokens (URLs, IDs) past their column width on narrow viewports — add
break-words. Drop the /90 alpha on the coach error text so it stays on the
AA-retuned status token.

Today hero's loading skeleton had drifted from the v1.29.1 tightened hero
(gap/items-start/space-y/rail-heading/xl:grid-cols-3), causing a visible
shift on every dashboard load; mirror the loaded classes 1:1.

The wellness-scores strip returned null while its shared batch loaded,
so it popped in late and pushed the page below it down — render the
section heading + tile-shaped skeletons instead, matching the vitals
dashboard's loading pattern.

Priority-card actions (coach check-in keep/let-go) had no pending state,
so a slow round trip could be double-tapped into firing the mutation
twice; disable the action row while in flight. Priority-item dismiss is
now optimistic (removes the card from the rail on tap, rolls back on
error) instead of waiting on invalidate-after-success.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck d2ad8e03e0 Merge pull request #524 from MBombeck/release/v1.30.0
release/v1.30.0
2026-07-18 02:41:27 +02:00
Marc-André Bombeck a0bde126dd test(withings): expect the no-credentials connect to redirect, not 400 2026-07-18 02:30:33 +02:00
Marc-André Bombeck 749fb8eef1 chore(dead-code): drop the unused settings group-order export + stale knip ignore 2026-07-18 02:16:08 +02:00
Marc-André Bombeck efdcafb08d chore(release): v1.30.0 — quality pass, document summaries, efficacy chart
Correctness (multi-source dedup, ordering, Strava sport, PR/correlations), medical-safety fixes, faster Today view, onboarding, app-wide year-on-dates, a metric catalog, a fuller backup, accessibility, and internal refactors — plus document auto-summaries and the medication efficacy chart.
2026-07-18 02:12:52 +02:00
Marc-André Bombeck 3dd5dbeb42 feat(export): make the full backup actually full
The full-backup export (plaintext + passphrase-encrypted, and the weekly
auto-backup) only ever covered measurements, medications, mood, and cycle
data. Labs, illness episodes, allergies, family history, and workouts were
silently missing, and the export UI implied "everything" was included.

Adds a shared records-backup section (src/lib/export/records-backup.ts) that
both the on-demand export routes and the weekly data-backup worker read from:
lab results + the biomarker catalog, illness episodes (including flares via
parentConditionId) with their day-logs, allergies, family history, and
workout summaries. Every read is owner-scoped and soft-delete-filtered;
encrypted free-text columns decrypt fail-soft so one bad row never aborts
the whole backup.

Document original files and workout GPS/per-sample time series stay
deliberately out of the JSON payload (size). The payload now carries an
explicit manifest disclosing exactly that, and the export UI states it in
plain text instead of implying completeness. These new domains are
export-only for now — admin restore still only recreates the original
measurements/medications/mood/cycle domains; the payload validates and
reports counts for the rest, but restoring a snapshot does not yet recreate
them.

Also fixes the admin backup-upload total to count every domain, not just
the original four.
2026-07-18 02:09:33 +02:00
Marc-André Bombeck 837c44e4c2 test(migrations): seed→apply→assert harness for the workout-sport backfills
The raw-SQL data backfills 0247 (WHOOP) and 0251 (Strava) rewrite
historical Workout.sport_type rows with no test — the thinnest-covered
class in the tree (audits L2). Add an integration test that seeds
pre-backfill rows, applies the EXACT migration SQL from disk against the
testcontainer Postgres, and asserts:

  - canonical relabel across both value shapes (id placeholder + raw
    label), case/normalisation, unknown→other fallback;
  - the source guard (a WHOOP pass leaves Strava/MANUAL rows verbatim and
    vice-versa);
  - idempotency (a second apply reports zero affected rows).

Runs in CI's integration job; locally needs Docker/OrbStack via
pnpm test:integration.
2026-07-18 02:09:33 +02:00
Marc-André Bombeck 3dc6c7436f fix(analytics): replace NUL-byte map-key separators with a visible one
Two in-memory key builders joined their parts with a literal NUL
(`${a}\x00${b}`): the sleep writer-bucket key and the correlation
pair key. The embedded NUL made grep classify both files as binary, so a
plain `grep symbol src/**` skipped them silently.

Swap the separator for U+241F (␟, the unit separator). It cannot appear
in a `MeasurementSource` enum literal, a device-type tag, or a
correlation-channel name, so the key semantics are identical — the sleep
key still slices its source prefix unambiguously and the correlation Set
still excludes double-counts. Both keys are ephemeral (one pass, never
persisted), so the byte change is invisible to any stored state.
2026-07-18 02:09:33 +02:00
Marc-André Bombeck 55186ccd3e feat(observability): fireAndForget breadcrumb for background side effects
Best-effort background work was swallowed with `.catch(() => {})` — a
persistent failure (a broken reminder enqueue, a lost re-auth transition)
left no annotate() or logger trail, against the project's own
'annotate() on every interesting path' rule.

Add `fireAndForget(promise, { action })`: it annotates the rejection into
the current wide event under a per-action meta key (so it rides the
central redaction at emit time — the raw message never hits the console)
and warns the action label only for failures that settle after the event
flushed. Control flow is unchanged; the promise stays fire-and-forget.

Adopt it at the meaningful sites: reminder-satisfy enqueues (measurement
+ labs writes and the five integration sync workers), the post-write
safety-floor dispatch, and the consent audit-log write. The Withings
re-auth mark stays awaited (its persisted state feeds the same response)
but now breadcrumbs its failure instead of swallowing it.
2026-07-18 02:09:33 +02:00
Marc-André Bombeck a7c82eb582 refactor(types): restore Prisma's type-checker on the cycle + PR write paths
The cycle write path and the PR worker cast Prisma inputs to `never`,
which switches off the compiler exactly where a schema/enum drift would
otherwise be caught.

- day-log-write: `ExistingRow` and `SensitiveFields` widened every enum
  column to `string`, forcing 21 `as never` writes. Type them to the
  generated enums (FlowLevel, OvulationTest, CervicalMucus, Cervix*,
  HomeTestResult, ContraceptiveKind) — the Zod input enums already match
  member-for-member — and build the create row from the typed locals
  instead of re-casting the update-input shape.
- cycle/backup: thread the enum element type through `enumOrNull` and pin
  each allow-set to its enum, so the 12 restore writes type-check against
  the generated create input rather than `never`.
- pr-detection-worker: type the candidate `source` as MeasurementSource
  (both Workout and PersonalRecord use it) and give `findBestWorkout` a
  real `Prisma.WorkoutWhereInput` + `WorkoutOrderByWithRelationInput`.

No behaviour change; the writes are now compiler-checked. No latent
drift surfaced — every enum set lines up with the schema.
2026-07-18 02:09:33 +02:00
Marc-André Bombeck d978c2b662 refactor(compliance): split the god-module into focused units behind a barrel
The medication-compliance engine lived in one 2,099-line file with 38
exports feeding eight surfaces (Coach prompt, BP-status gate, dashboard
pillar, per-medication tile, insight targets, doctor report, …). A single
edit intended for one surface could silently shift all of them.

Split along the existing internal seams into `analytics/compliance/`:
parsing, types, adapters, dose-status, occurrences, windows, cycle,
ledger, display. `analytics/compliance.ts` is now a thin barrel that
re-exports every public symbol, so no consumer import path changes. Pure
move — no logic edits; the tally, window ladder, ledger and cycle math
are byte-for-byte the code they were. Tests move alongside under
`compliance/__tests__/` and keep exercising the public barrel.
2026-07-18 02:09:33 +02:00
Marc-André Bombeck e7802701ba refactor(insights, settings): unify the Heart group and add settings sidebar groups
The insights tab strip's "Cardiovascular" popover held only three rare
Withings markers while pulse, resting heart rate, HRV, and cardio
recovery sat scattered across a flat pill and the unrelated Vitals
group. Cardiovascular is now Heart and gathers all six; bmi and
workouts — flat only by historical accident — collapse into their
correct Body / Activity groups, and breathing disturbances moves out
of Vitals into the Sleep manager bucket instead of vanishing whenever
the sleep module is off. Blood pressure, weight, sleep, mood, and
medications stay pinned as flat top-level pills — now a documented
`PINNED_SUB_PAGE_SLUGS` set instead of an unexplained "predates
v1.4.32" carve-out. Active energy moves from Vitals to Activity to
match its own tab-strip section comment. All changes are grouping /
presentation data only — no slug, route, or redirect changed.

The Settings sidebar's 19 entries rendered as one flat list with no
visual structure. They now cluster under seven group headers (Account,
Your data, Display, Health profile, AI, Connectivity, System),
reordered to match; the desktop sidebar gets a non-interactive text
header per group, the mobile chip strip a quiet divider. Presentation
only — no route changed.

Reference: 2026-07-17 UX/IA audit, H2 + M1 + M2 + M3 + M6.
2026-07-18 01:23:37 +02:00
Marc-André Bombeck 4c436d7dfc fix(a11y): keyboard-reachable admin log rows, range-bar marker, heatmap cells
Admin app-log rows opened their JSON detail dialog through a click-only
<tr> with no keyboard path; the Action cell now wraps a real <button>
that carries the same handler plus a descriptive aria-label, leaving
the row onClick as a pointer convenience.

The target range-bar's current-value marker showed value + band only
in a hover-only tooltip on a non-focusable div. It is now focusable
(tabIndex, role=img) with an aria-label that reuses the same value /
range / delta copy the tooltip already renders, so focus opens the
tooltip and a screen reader gets the text alternative either way.

Mood and medication-compliance heatmap day cells were pointer-only SVG
rects. Each cell is now focusable with its own per-day aria-label
(reusing the existing tooltip copy), and Escape dismisses any open
tooltip — pinned (touch) or focus-shown.

Reference: 2026-07-17 a11y audit, H1 + M1 + M2.
2026-07-18 01:23:37 +02:00
Marc-André Bombeck 247bc4f33c fix(ui): close consistency-audit contrast and duplication findings
Today hero's top-signal delta used opacity-on-muted text, landing below
AA on the app's most prominent card; five mood charts and the sleep-
stage IN_BED fill painted raw --dracula-*/hardcoded-hex hues with no
(or an unused) light-theme counterpart. Route all of them through
existing semantic/chart tokens instead (destructive/warning/success,
chart-1..5, and a newly minted --chart-inbed with its own Alucard
override) so every hue is AA-tuned per theme.

Also collapses three duplicated primitives the audit flagged: the
click-Popover VitalsInfoTip and hover-Tooltip InfoHint merge into one
touch-first ui/info-popover; MedicationViewToggle and ModuleViewToggle
were verbatim copy-paste and now wrap one shared ui/view-toggle; the
triplicated sport-icon map (already drifted — mixedCardio rendered a
different glyph in the list vs the detail/dashboard views) moves to
lib/workouts/sport-icons.ts with HeartPulse as the settled icon.

Adds a no-restricted-imports lint rule banning direct recharts imports
outside the chart-runtime barrel, catching the bundle-duplication
mistake at local lint instead of PR-CI.
2026-07-18 01:23:37 +02:00
Marc-André Bombeck 1ff179178f fix(dates): show the year whenever a date is not the current one
Sweeps every surface that renders a date from before this year without
a year suffix — illness episodes/flares/day-logs, lab and custom-metric
charts, the mood chart family, medication titration/side-effects/dose-
history/next-dose captions (incl. the Dec→Jan rollover), cycle
predictions and history, the BBT chart, the intraday-pulse day-nav, the
date-range filter chip, and the nutrient/compliance charts. Each now
routes through dateShortSmart / dateWithWeekdaySmart (format-locale.ts)
or the matching date-format.ts helper, which add the year only when it
differs from the current one — mirroring the workout list's existing
model instead of a bare "19.02." that reads as this year's.

Also fixes two sites that bypassed the app's locale entirely: the
trusted-devices expiry (bare toLocaleDateString, host locale) and the
admin token-name timestamp (hard-coded de-DE + Europe/Berlin) now both
render through the user's own locale, hour-cycle, and timezone
preference.
2026-07-18 01:23:37 +02:00
Marc-André Bombeck 7f44e2fa37 feat(format): add conditional-year date formatters
dateShortSmart / dateWithWeekdaySmart add the year only when a date
falls outside the current calendar year, so a chart axis or history
row never shows a bare "19.02." for a reading from last year. Also
adds monthYear for Dec-to-Jan rollover labels (next-dose captions).
Year comparison runs in the formatter's own timezone so UTC-pinned
bucket-label formatters agree with what they print.
2026-07-18 01:23:37 +02:00
Marc-André Bombeck e8327109b1 test(nutrients): use relative day-keys so the daily-window test can't date-bomb 2026-07-18 00:38:33 +02:00
Marc-André Bombeck e3dfe9f400 perf(medications): batch the intake CSV import duplicate probe
The CSV import loop ran a findUnique duplicate check before every
single create — up to 2000 sequential round trips on a 1000-row
import. Resolve takenAt + the idempotency key for every row first,
then read every key's existence in one indexed query before the write
loop; create and the per-row inventory consumption stay unchanged.

Also runs prettier over the batch-route test files touched in the
prior two commits.
2026-07-18 00:37:24 +02:00
Marc-André Bombeck 2c6699f682 perf(batch): replace per-entry existence probes with batched reads
Three batch-ingest routes ran a per-entry findUnique/upsert loop —
up to 1000 sequential round trips on a 500-entry batch.

- nutrients/batch: one indexed findMany existence read splits the
  batch into a bulk createMany (new day/nutrient pairs) and a
  $transaction of per-row updates (existing pairs), the shape
  measurements/batch already uses. A write failure now marks its
  whole group skipped rather than one entry, documented at the call
  site; validation failures stay isolated per entry as before.
- mood-entries/bulk: the per-entry findUnique probe is replaced by
  one batched findMany keyed exactly like each entry's own dedup
  contract (externalId when carried, else date+moodLoggedAt). The
  per-entry upsert, tombstone check, tag-link writes and reverse push
  are unchanged — only the existence read is batched.
2026-07-18 00:37:24 +02:00
Marc-André Bombeck 2bc706a445 perf(insights): read PULSE cards through the day-rollup tier
/api/insights/cards pulled every raw PULSE row from the last 90 days
into JS to compute an average and an anomaly count — on a dense
wearable account that's 100k+ rows the DAY rollups already summarise.
WEIGHT and blood pressure stay on the bounded raw read (manual-entry
volume, and blood pressure needs exact-timestamp pairing for the
in-target percentage); PULSE now reads through the existing rollup
read-swap helper, falling back to a bounded per-day-grouped live read
on a coverage miss.
2026-07-18 00:37:24 +02:00
Marc-André Bombeck 2b89259cce perf(daily): cache the digest's milestone + tension gather
Every 120s poll of every open tab re-derived the S11 tension window
and the S12 milestone from ~8.5k rows even though both are usually
null and change at most once a day. Wrap the two independent reads in
one SWR cell under the existing analytics bucket, keyed per user and
local day, with a TTL that mirrors the dashboard-snapshot cell so a
scheduled poll lands warm. Riding the analytics bucket's `${userId}|`
prefix means the same measurement-write invalidation the snapshot cell
already relies on covers this cell too, so a fresh sleep landing still
refreshes it. Also hoists the milestone gather and the tension read
into one Promise.all instead of two sequential awaits.
2026-07-18 00:37:24 +02:00
Marc-André Bombeck 95ab0514b9 chore(charts): drop two unused recharts chart components
sleep-hypnogram.tsx and compliance-line-chart.tsx are not imported
anywhere; a future import would mint a second recharts chunk and blow
the bundle budget. Delete both plus the dedicated hypnogram test file,
and drop the now-missing filename from the touch-action guard's file
list.
2026-07-18 00:37:24 +02:00
Marc-André Bombeck 1417137162 fix(daily-loop): stop bad ?doc= links from trapping the coach, add upload processing feedback
An invalid ?doc=<id> deep-link (a deleted document, a mistyped id) 404s
its detail fetch forever. The fenced send-gate never cleared, so every
message stayed blocked behind a phantom "untitled" attachment banner
that could never resolve — a bad link was a dead end instead of
degrading to a normal chat. Dropping a staged id the moment its detail
fetch settles into an error now clears the doc-scope, drops the banner,
and lets the coach send normally; a toast explains the link is gone.

Also gives the document vault processing feedback: the auto-index (and
optional AI summary) job that starts right after upload is
fire-and-forget server-side, so a freshly uploaded card sat with no
signal while it indexed. The vault list now polls while any recently
uploaded document is still unindexed (bounded to a few minutes so an
old, permanently-unindexed document never keeps it polling), and the
card shows a "Processing…"/"Ready" chip that clears on its own once the
index lands.
2026-07-18 00:37:24 +02:00
Marc-André Bombeck 2b3ff0e58f fix(daily-loop): thread the overdue medication id into the digest's Log-dose link
The Today digest's dose-window rail item linked to the bare /medications
list even though the overdue medication's id was already resolved
server-side alongside its name — a tap landed the user on a generic list
they then had to scan themselves.

buildMedsTodayBlock now carries nextDueMedicationId; the digest's
"Log dose" action links to /medications?highlight=<id> when the id is
known, falling back to the bare list for an older cached block. The
medications page reads the highlight param, scrolls the matching card
into view, and rings it briefly (mirrors the vault's duplicate-upload
highlight) in both the card grid and the table view. Regenerates
docs/api/openapi.yaml for the new medsToday field.
2026-07-18 00:37:24 +02:00
Marc-André Bombeck 844b24c7ba fix(daily-loop): invalidate the Today digest on measurement/mood/water writes
Manual measurement, mood, and water writes never carried queryKeys.dailyDigest()
in their invalidation bundles — only medication intake did (v1.29.1). The digest
query also runs with refetchOnMount/WindowFocus off, so a write from the values
list, the mood list, or the hydration quick-add sheet left the Today hero's
score and rail items stale until a hard reload.

Adds dailyDigest() to measurementDependentKeys and moodDependentKeys, and adds
a shared refetchInactiveDailyReads() helper (mirrors the existing medication
seam) that forces the dashboard snapshot and digest to refetch even while
unmounted. Every measurement/mood/water write call site now runs it alongside
its existing *DependentKeys bundle.
2026-07-18 00:37:24 +02:00
Marc-André Bombeck bf1c052797 feat(insights): add a metric catalog for discoverability
Metrics with zero data stay hidden from the dashboard and Insights nav
by design — that keeps the daily views uncluttered but leaves no
reachable surface telling a sparse-device-kit account which
capabilities exist at all. Add /insights/catalog: every metric domain
grouped by category, each row showing whether the account has data
for it and what would supply it, with a connect CTA into
settings/integrations when absent and device-sourceable.

Reuses the existing tab-strip gating (hasMetricData over the shared
analytics/comprehensive/workouts/nutrients reads) and the per-metric
empty-state copy already shown on each sub-page, so names, sources,
and presence can't drift from what's shown elsewhere. Adds one quiet,
always-visible pill at the tail of the Insights tab strip as the entry
point — no new loud nav destination.
2026-07-17 23:50:06 +02:00
Marc-André Bombeck 5f253f7d09 fix(insights): make the nutrients pill reachable once the module is on
The nutrients pill required BOTH the opt-in module on AND at least one
NutrientIntakeDay row — but the in-context enable CTA lives on
/insights/nutrients itself, so a user who had just turned the module on
(or wanted to) had no way to reach the page that would let them. Drop
the row-count floor for this one metric: the pill now gates on the
module toggle alone, matching the deliberate opt-in the user already
made. The page itself is unchanged and still degrades from "enable" to
"no rows yet" to the three-card spine.
2026-07-17 23:50:06 +02:00
Marc-André Bombeck d124a3239d fix(insights): stop the pulse chart from faking a continuous shape, add honest error states
Intraday pulse rendered sparse spot readings as a smooth interpolated
curve across hours-wide gaps, implying continuous monitoring the data
never had. The chart now grids one point per bucket, breaks the area
over any missing bucket (connectNulls={false}), and discloses coverage
("based on N readings across M hours") whenever that produces a visible
break.

Four surfaces also collapsed a failed fetch into the honest empty/no-
data state instead of surfacing the error: the Today hero, the
medication efficacy tab (previously stuck on the loading skeleton
forever on error), the intraday pulse chart, and the recent-workouts
dashboard tile. All four now branch on isError and render
QueryErrorCard with a retry action; the tile's "loading…" text line is
replaced with row-shaped skeletons.
2026-07-17 23:50:06 +02:00
Marc-André Bombeck fb73c97125 fix(onboarding): stop the Withings connect dead-end and disclose BYO setup
The onboarding "Connect Withings" tap opened /api/withings/connect in a new
tab, which returned a raw JSON 400 for every brand-new account (Withings
credentials are per-user BYO with no env fallback, and the wizard never
collects them). WHOOP, Fitbit, Polar, Oura, and Strava shared the identical
raw-JSON-on-no-creds pattern on their own /connect routes, while every other
failure branch already redirected with a toast reason. All six now redirect
to /settings/integrations?<provider>=error&reason=nocreds instead, and the
wizard's Withings card checks the integrations-status envelope first: unless
credentials are already configured, the CTA reads "Set up in Settings" and
links straight to the credentials form rather than launching a doomed OAuth
handshake.

Also surfaces the BYO-developer-app requirement, which was invisible until a
user was deep inside a Settings card. The source-picker step now says
up front that device sources need a free developer app on the user's own
account; each of the six provider cards gets a compact 3-step guide with the
exact callback URL to register, shown until the user has saved their own
credentials.
2026-07-17 23:12:12 +02:00
Marc-André Bombeck 4f7420905a fix(strava): normalise raw sport labels to canonical WorkoutSportType
Strava was the second workout integration, after WHOOP, that wrote its
raw vendor sport label ("Run", "Ride", "VirtualRide", ...) straight into
Workout.sportType instead of a canonical bucket. Every Strava row read
as generic, and the read-time picker treats two generic rows as
sport-compatible — a same-source brick session (Strava ride ending
~15:00, Strava run starting 15:03) could cluster into one and silently
drop a leg.

Add mapStravaSportType() (54 documented Strava sport_type values,
mirroring the WHOOP sport-map shape) and apply it at write time in
mapActivity(); the raw label stays in metadata.stravaType for
provenance. Backfill existing Strava rows via a hand-written migration
generated from the same table. Extend the cross-provider sport-map
parity test to cover Strava, and add a picker-level test proving a
back-to-back Ride+Run pair now survives as two rows instead of one.
2026-07-17 23:12:12 +02:00
Marc-André Bombeck 8902d3985c fix(measurements,insights): source-collapse + timezone parity across multi-source surfaces
Four data-correctness fixes in the same class as the workouts-list
ordering bug (05f80ad37):

- GET /api/measurements groupBy=day: the raw-row scan ordered oldest
  first and sliced to `limit` before bucketing, so a long history never
  surfaced its recent days. Reads newest-first now. The per-day sum
  also ran over every source unfiltered — an Apple Health + Withings
  write for the same day doubled the total. Routes through
  pickCanonicalSourceRows first, matching the rollup tile's collapse.

- PR detection's cumulative-day picker summed every row on a calendar
  day regardless of source (false "record" days) and bucketed on the
  UTC calendar day instead of the user's timezone. Rewritten to
  source-collapse before summing and bucket on the user's IANA zone,
  reading through the worker's injected Prisma client so the existing
  dependency-injection contract for tests stays intact.

- The correlation-discovery matrix (the insights route, the per-metric
  assessment card, and the Coach's get_correlations tool — three
  independent copies of the same fetch) averaged cumulative types
  (steps, daylight) and sleep across mismatched grains: per-sample
  chunk means blended with drained daily totals, and sleep averaged
  per-stage segment durations instead of summing a night's total.
  Hoisted the grain resolution into one shared
  buildMeasurementDailySeries helper (source-collapsed day-sum for
  cumulative types, per-night reconstruction for sleep, mean
  unchanged for spot vitals) so all three surfaces agree.

Regression tests cover newest-first ordering, cross-source collapse
matching the rollup, PR no-double-count, and the shared grain helper.
2026-07-17 23:12:12 +02:00
Marc-André Bombeck 30a0528ad0 fix(analytics): resting-pulse pill drops the diagnosis-shaped verdict
classifyPulse() labelled resting pulse < 60 "Bradycardia" and > 120
"Tachycardia", the latter painted destructive-red. The interpretation layer
already frames the same signal calmly for the same range ("below the
standard resting range (common in trained hearts)", neutral valence) — a
trained user at 55 bpm resting read a diagnosis-shaped verdict on one
surface and a calm contextual band on the other.

Rename the categories to "Below typical range" / "Above typical range"
(matching the interpretation layer's own wording) and step the high band
down from destructive/danger to warning/amber — still flagged, never a
verdict noun. Neither category paints the pill red anymore.
2026-07-17 23:12:12 +02:00
Marc-André Bombeck cffebd78db fix(mental-health): crisis card no longer sends AT/CH users DE-only numbers
The PHQ-9 item-9 crisis card routed every "de" locale to the Germany-only
TelefonSeelsorge freephone lines. Those numbers don't connect from Austria
or Switzerland, so a Swiss or Austrian user in crisis was shown dead numbers
first on the one surface where that costs the most.

Add dedicated Austria (Telefonseelsorge 142) and Switzerland (Die
Dargebotene Hand 143) resource sets. `User.locale` only ever stores a bare
short code today, so an unqualified "de" now resolves to a combined
DE/AT/CH set (112 first, then every country's own line) instead of
defaulting to Germany; a region-qualified tag resolves to the exact country
when one is available.
2026-07-17 23:12:12 +02:00
Marc-André Bombeck 8c0b200a5b fix(doctor-report): footer states the user's own timezone, not Berlin
The clinician PDF footer read "Timezone: Europe/Berlin" for every user
regardless of their actual timezone — a maintainer-anchored default leaking
into a document handed to a clinician, who then reads every timestamp on
the page against the wrong basis for a non-Berlin operator.

`userTz` already carries the resolved per-user zone into the formatters;
thread the same resolution (valid IANA name, else the Berlin fallback) into
the footer string as {timezone} instead of the literal text.
2026-07-17 23:12:12 +02:00
Marc-André Bombeck 0518c39050 fix(illness): glucose safety-floor push honours the user's display unit
The hypoglycemia/hyperglycemia escalation push hardcoded mg/dL in all six
locale strings and interpolated the raw mg/dL reading regardless of the
user's glucose display preference. A mmol/L user hitting the confirmed-hypo
alarm read "70 mg/dL (65 mg/dL)" — the wrong unit at the single most
safety-critical moment.

Resolve User.glucoseUnit in the safety-floor write hook and convert both the
reading and the floor threshold before handing them to the notification
copy. The bundle strings now take {threshold} {unit} / {value} {unit}
instead of a baked-in "70 mg/dL" / "54 mg/dL".
2026-07-17 23:12:12 +02:00
Marc-André Bombeck a08e4378a0 Merge pull request #521 from MBombeck/feat/efficacy-scale-history
feat(medications): scale efficacy y-axis and reach older doses
2026-07-17 22:41:36 +02:00
Marc-André Bombeck 0a08b166cf Merge pull request #523 from MBombeck/feat/document-auto-summary
feat(documents): persist an auto summary for uploaded documents
2026-07-17 22:41:30 +02:00
Marc-André Bombeck ba362f1d10 Merge pull request #522 from MBombeck/release/v1.29.5
release/v1.29.5
2026-07-17 17:47:16 +02:00
Marc-André Bombeck 5cff7473c9 feat(documents): persist an auto summary for uploaded documents
When the auto-AI-read opt-in is on, a freshly uploaded document is summarised
once in the background and the encrypted summary is stored and shown on the
document detail view.

- A background step runs after upload alongside the existing content-index and
  thumbnail steps. It generates a short (3-4 sentence) plain-language summary
  only when the user's documentsAutoAiRead opt-in is on (that flag is also the
  standing AI-egress consent), skips a document that already has one, and
  no-ops when no vision provider is configured. It reuses the same provider
  resolution, egress-consent re-assertion, and AI-budget reserve/reconcile
  helpers as the on-demand route; a provider error refunds the reservation and
  never fails the upload.
- The summary is stored encrypted (summaryEncrypted Bytes) with a
  summaryGeneratedAt timestamp on inbound_documents, registered for key
  rotation, and returned decrypted in the document detail response.
- The on-demand transient summary route is unchanged.

Gated by the same consent, budget, and module checks.
2026-07-17 17:46:28 +02:00
Marc-André Bombeck 85a05efb4d chore(release): v1.29.5 — OIDC redirect base + same-origin redirect hardening 2026-07-17 17:30:54 +02:00
Marc-André Bombeck d8e48a6304 fix(security): reject a same-origin next path that re-resolves protocol-relative
sanitizeSameOriginPath returned pathname+search+hash, so a crafted relative next like /..//evil.com passed the origin check but left a //evil.com pathname that re-resolves to a foreign origin when a consumer re-feeds it to new URL(result, base) or router.push. Re-verify the reconstructed path resolves on-origin; reject to / otherwise. Covers the OIDC callback and the password/passkey login redirect.
2026-07-17 17:30:04 +02:00
Marc-André Bombeck 60658be776 Merge pull request #519 from balajiv113/fix-url
fix(auth): build OIDC redirects from NEXT_PUBLIC_APP_URL, not the request URL
2026-07-17 17:26:57 +02:00
Marc-André Bombeck b11ecca8d2 feat(medications): scale efficacy y-axis and reach older doses
- Efficacy target chart: replace the implicit zero-based y-axis with a
  padded data-range domain so a small relative trend fills the plot
  instead of compressing into the top band.
- Add a "% since start" chart mode that reindexes the target series to a
  percentage of the start-anchored baseline; the absolute reference band
  is hidden in that mode.
- Intake history tab: toggle between the trailing 90-day dose ledger and
  the full paginated intake history so older doses stay reachable.
2026-07-17 17:17:54 +02:00
Marc-André Bombeck 43eddb63b0 Merge pull request #520 from MBombeck/release/v1.29.4
release/v1.29.4
2026-07-17 17:09:49 +02:00
Balaji Vijayakumar 351cf7dd85 fix(auth): build OIDC redirects from NEXT_PUBLIC_APP_URL, not the request URL
Every browser-facing redirect in the OIDC login/callback routes was built
from req.url. Behind a reverse proxy that doesn't forward the original
host, that reflects the address the app process was addressed as inside
the container rather than the public domain, sending users to an
unreachable internal URL after login. Route every redirect through the
trusted, operator-set NEXT_PUBLIC_APP_URL instead, matching the
convention the Withings integration already uses.
2026-07-17 20:30:41 +05:30
Marc-André Bombeck 05f80ad378 fix(workouts): order the list newest-first; show the year on past-year dates
The canonical picker re-sorts its output to startedAt ASC for its rollup callers, so the list route sliced the oldest page and recent workouts never surfaced on a long history — re-establish newest-first after the pick. And a workout from a previous year now shows its year so it is not read as this year's.
2026-07-17 16:53:06 +02:00
Marc-André Bombeck 546c5c7d8f Merge pull request #518 from MBombeck/release/v1.29.3
release/v1.29.3
2026-07-17 16:29:00 +02:00
Marc-André Bombeck c527cd5aa4 chore(release): v1.29.3 — hydration and micronutrients surface
Fluid intake and device-recorded vitamins/minerals get a home: a dashboard water tile with quick-add, and a Nutrients page under Insights showing hydration over the last month, caffeine, and the micronutrients with data — each against its reference intake as context. Manual and device-synced water coexist via a source-aware day-total key.
2026-07-17 16:13:41 +02:00
Marc-André Bombeck 16d142762a feat(insights): /insights/nutrients — hydration, caffeine, vitamins
New routed sub-page, gated on the opt-in nutrients module with an
in-context enable CTA (the module stays opt-in — the 2026-07-17
consent memo's reasoning holds; this just makes the toggle
discoverable instead of buried in Settings):

- Hydration card (hero): today's total, a muted EFSA reference-intake
  line (sex-resolved, omitted when the profile has no sex on file),
  a 30-day bar chart with a dashed reference line, and quick-add
  chips (+200/+300/+500 mL + custom + edit-today's-total) in a
  ResponsiveSheet, POSTing to /api/nutrients/water.
- Caffeine card: same anatomy, rendered only when the window has
  data; the EFSA safe-level reads as a ceiling, never "exceeded"
  colouring.
- Micronutrients card: rows only for the 24 vitamin/mineral codes
  with data in the window (catalog order), a muted "M of 24 tracked"
  footer, and one honest sentence that sparse logging isn't a
  deficiency. Zero rows collapse to a single EmptyState.

No ring, no attainment colour anywhere — the bar chart plus its
dashed reference line IS the context (UI-STANDARDS: values stay
foreground).

The bar chart is a new NutrientDailyBarChart, registered in the
shared chart-runtime barrel behind its own next/dynamic boundary so
recharts stays off the route's first-load JS.

Tab-strip wiring: nutrients joins the flat pill set (event/store
driven like medications/workouts — a lightweight 1-day probe read
drives the pill's data-availability gate, module gate on top).

~20 new i18n keys across en/de/es/fr/it/pl.
2026-07-17 16:08:39 +02:00
Marc-André Bombeck b396b51d35 feat(dashboard): fluid-intake strip tile, nutrients-store-backed
Reuses the TrendCard strip-tile pattern (icon, neutral direction
sentiment, stale-days caption) for a new waterIntake widget, but
sources it from NutrientIntakeDay instead of introducing a parallel
MeasurementType. The dashboard snapshot builder reads the last 30
day-keys for nutrient="water" (summed across sources), folds them
into a synthetic NUTRIENT_WATER summary key alongside the real
MeasurementType summaries, and the existing module-gating machinery
(SUMMARY_TYPE_MODULE, WIDGET_MODULE_BY_ID) strips it when the opt-in
nutrients module is off — no new gating code path, just two new map
entries.

The tile links out to /insights/nutrients instead of a measurement
detail page. Widget catalogue grows 37 -> 38 ids.
2026-07-17 16:08:39 +02:00
Marc-André Bombeck da2de015fe feat(nutrients): source-aware day totals; manual water write path
NutrientIntakeDay's composite primary key gains `source` (migration
0249_nutrient_intake_day_source): a manual water entry and the
Apple-synced day total now live as two rows instead of the manual
write being silently clobbered by the next Apple last-writer-wins
sync. Every existing row already defaults to source=APPLE_HEALTH, so
the migration is a pure PK-constraint change with no data rewrite.

- POST /api/nutrients/batch keeps writing the APPLE_HEALTH row,
  byte-identical upsert semantics against the wider key.
- GET /api/nutrients sums amounts within (nutrient, day) across
  sources before folding into the window summary.
- POST /api/nutrients/water (new): manual water quick-add, add/set
  modes against the MANUAL row only, module-gated, rate-limited,
  idempotency-key aware, hard-evicts the dashboard snapshot cache.
- GET /api/nutrients/daily (new): dense per-day series for one
  nutrient (summed across sources) plus the EFSA reference resolved
  against the caller's profile sex — omitted when sex is unknown.
- catalog.ts gains resolveNutrientReference(), the sex-resolution
  helper both new routes and the insights cards share.

docs/api/openapi.yaml regenerated for the two new routes.
2026-07-17 16:08:39 +02:00
Marc-André Bombeck bef3b9dd3f Merge pull request #517 from MBombeck/release/v1.29.2
release/v1.29.2
2026-07-17 14:46:26 +02:00
Marc-André Bombeck 2b5fb617ba chore(release): v1.29.2 — WHOOP workout sport, intraday history, Today dismiss, vitals info-tip
WHOOP workouts map to canonical sports (bike rides show as cycling) with a history backfill and a richer cross-source merge; the intraday heart-rate view gains a day navigator with 90-day dense retention and an hourly fallback for older days; milestone/ECG/tension cards can be dismissed from Today; vitals context moves into an (i) popover and the VO2max heading no longer clips; Oura cycle phases feed the cycle tracker where available.
2026-07-17 14:35:06 +02:00
Marc-André Bombeck 7445925d58 fix(insights): move vitals method context into an (i) popover; unclip VO2max
The always-on "typical range = median ± MAD" caption was dropped from
the vitals tiles because it repeated across every baseline tile — but
that same inline caption, still rendered unconditionally on the four
derived re-frame tiles (cardio fitness, vascular age, HRV, BMI), was
squeezing the header row hard enough to clip the heading next to it:
measured on the cardio-fitness tile, the "VO2 max" title's rendered
width collapsed from 64px to 15px with the caption in the row, cutting
it down to "V.". Confirmed and re-measured with a Playwright pass over
the actual TileHeader/SparklineDeltaTile markup before touching the CSS.

Replace the inline caption with a small (i) trigger (VitalsInfoTip) on
the tile heading's trailing edge — a click-opened popover carrying the
same method/caveat text plus the cited standard, matching the existing
click-to-open info-tip pattern used elsewhere (touch-friendly, unlike a
hover tooltip). Restore it on the VITALS_BASELINE tiles too, since a
compact icon repeated across tiles doesn't clutter the way the full
sentence did. The heading now keeps its full width regardless of how
long the method copy runs.
2026-07-17 14:29:07 +02:00
Marc-André Bombeck 270f33edb2 feat(daily): let the Today rail dismiss milestone / ECG / tension cards
The worth-a-look rail mixed actionable items (an overdue dose, a broken
sync) with observational ones (a milestone, a new ECG pointer, a tension
window) that have nothing to act on beyond "seen it" — there was no way
to clear them short of them expiring on their own.

Add server-side dismissal scoped to the three observational kinds only:
a new DismissedPriorityItem table (userId + itemKey, composite-unique),
a POST /api/daily/digest/dismiss route, and a deterministic itemKey the
digest builder stamps per kind (metric + reach day for a milestone, the
recording's own timestamp for an ECG, the local day + part of day for a
tension window) so a dismissal never bleeds into a later day's instance.
The actionable kinds never carry an itemKey, so the dismiss affordance
and the endpoint are both structurally unreachable for them — an
itemKey not namespaced under a dismissible kind 422s before any lookup.
2026-07-17 14:29:07 +02:00
Marc-André Bombeck 4f198ec25f feat(insights): day navigator on the intraday pulse chart; 90-day dense retention with hourly fallback
Adds prev/next-day paging to the intraday pulse "shape of the day" chart
(TileHeader right slot, next disabled at today) so a user can step back
through prior days on the existing date-scoped route.

Raises DENSE_INTRADAY_RETENTION_DAYS from 14 to 90 — a self-hoster's own
storage is the trade-off for three months of full 10-minute-resolution
history instead of two weeks. Days outside the window keep folding to
hourly-mean stats: rows as before; loadIntradayPulse now falls back to
that hourly tier when a day's raw samples are gone, so the navigator
shows a coarser shape instead of an empty chart. The tension-window
read is suppressed on hourly days — it needs per-sample resolution to
stay honest. A new resolution field on the DTO ("tenMin" | "hourly")
drives a small note in the chart.
2026-07-17 14:29:07 +02:00
Marc-André Bombeck 3aa11b0531 feat(oura): feed Cycle Insights phases into the cycle tracker
Pull Oura's daily_cycle_phases collection (undocumented in the published
OpenAPI spec, but confirmed live and auth-gated identically to every other
daily_* collection) on the existing hourly Oura poll. A luteal→follicular
transition, or a literal "menstrual" phase day, writes a single-day
CycleDayLog(flow=LIGHT, source=OURA) — the same conservative "logged but
unspecified intensity" marker the HealthKit unspecified-flow mapping uses.

Manual entries always win: the write only ever creates a day that has no
existing CycleDayLog row at all, regardless of source, and never updates a
row it did not create itself. No MenstrualCycle span is synthesised, mirroring
the Apple Health importer precedent. A 403/404 on the endpoint (the expected
outcome for most connections — Oura's reproductive-health resources are all
absent from its public spec) is swallowed without touching the rest of the
Oura connection's sync status.

No migration: reuses the existing CycleDayLog.source column and the OURA
value already present on MeasurementSource.
2026-07-17 14:29:07 +02:00
Marc-André Bombeck b3d586066a fix(workouts): backfill richer fields on the canonical-picker winner
pickCanonicalWorkout() picked one whole row per cluster by the source
ladder and dropped the rest, so a higher-priority twin with a null HR
(e.g. a Withings summary) discarded a lower-priority twin's real reading
(e.g. WHOOP's HR strap) instead of keeping it. The ladder winner is now
the base row, backfilled with avgHeartRate, maxHeartRate,
totalEnergyKcal, totalDistanceM, and elevationM from the ladder-highest
member that actually has each field — filling gaps, never overwriting a
value the base row already carries.

The base row also adopts a specific sport type from a cluster member
when its own is generic ("other" / unrecognised). Reaching that case
needs clustering to accept a generic↔specific sport pairing within the
proximity window, alongside the existing exact-match rule — two
different SPECIFIC sports (Walking vs Running) still never cluster, so
the core invariant holds.
2026-07-17 14:28:56 +02:00
Marc-André Bombeck 86e92f38ca fix(db): backfill non-canonical WHOOP workout sport_type values
Existing WHOOP Workout rows still carry whatever the pre-fix write path
stored — a whoop_sport_<id> placeholder or WHOOP's raw sport_name — so
the sport-map fix only corrects new syncs. This one-shot UPDATE
re-derives every existing WHOOP row's sport_type from the same table
sport-map.ts uses, generated directly from that table so the historical
data and the live mapper can't drift apart. Unmatched values default to
"other", matching the mapper's fallback. Idempotent: the WHERE clause
skips rows already on a canonical value, so a re-run touches zero rows.
2026-07-17 14:28:56 +02:00
Marc-André Bombeck 43884569d3 fix(whoop): map sport_id/sport_name to the canonical WorkoutSportType
WHOOP was the only workout integration that never normalised its sport,
unlike the Fitbit and Google Health mappers — it wrote WHOOP's raw
sport_name or a literal whoop_sport_<id> placeholder straight into
Workout.sportType. Confirmed in prod: a user's WHOOP-tracked cycling
workouts never rendered as cycling on /insights/workouts because nothing
in the UI could map the non-canonical string to an icon or label.

sport-map.ts adds a sport_id/sport_name table sourced from WHOOP's public
Workout-object reference and mirrors the Fitbit/Google mapper shape:
sport_id is preferred (retired past 2025-09-01 but still exact when
present), sport_name is normalised before the fallback lookup, and
anything unrecognised resolves to "other" — never a placeholder. The raw
WHOOP fields stay in Workout.metadata for traceability.

Adds a shared table-driven test that checks Fitbit, Google Health, and
WHOOP sport mappers together so the three integrations can't silently
drift out of parity on common sports.
2026-07-17 14:28:56 +02:00
Marc-André Bombeck 63cdd709af Merge pull request #516 from MBombeck/release/v1.29.1
release/v1.29.1
2026-07-17 11:25:00 +02:00
Marc-André Bombeck 99d80f8512 chore(release): v1.29.1 — polish and fixes from the daily view
Today refreshes right after a dose; a calmer Today hero; the Coach names the attached document; a fresh install starts with tracking on; the vitals explainer line is removed; the illness journal gains an editable end and a settled course line; the morning refresh now triggers from every sleep source and records each skip.
2026-07-17 11:12:30 +02:00
Marc-André BombeckandClaude Opus 4.8 2da6a192a7 feat(polish): default tracking modules on; illness backdate + course fixes
A clean install now defaults every tracking module on (weight, blood
pressure, glucose, sleep, mood, medications, labs, documents, insights,
mental wellbeing, cycle, illness, environment, workouts) so the app is
usable without enabling each surface by hand. Only the two channels that
open an egress or attack surface stay opt-in — the remote MCP endpoint and
the micronutrient sync; the document AI-read and the daily-briefing push
remain separately gated and off. The change touches defaults only:
accounts with a saved preference resolve exactly as before, the new default
applies solely where no preference exists.

Drop the "typical range = median ± MAD" method caption from the vitals
baseline tiles — it repeated across every tile and read as out of place at
the top of the vitals surface. The dedicated bands and composite scores
keep their method copy.

Illness episodes: the capture sheet now carries an editable end date
alongside the start, so a whole past episode can be recorded after the
fact and an open-ended course can be resolved (or re-opened) later. The
end is bounded to on-or-after the start.

Fix the per-episode "how it progressed" card rendering an empty body when
the engine returned a valid result with no notable findings — it now shows
a calm, honest line instead of looking as though nothing was recorded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018V8V9qapgHi7fhgtMJ8SkM
2026-07-17 11:02:57 +02:00
Marc-André Bombeck f6e6593bcb fix(coach): name the attached document(s) in the fenced banner; keep the attach menu within its popover
The fenced-conversation banner said a generic "N documents attached", which
gave no hint what a thread was about when returning to it later. It now names
the attached document(s): one document shows its title, two show both, three or
more show the first two plus an "and N more" tail. Reuses the existing
"Chatting about: …" doc-scope phrasing and the attachment DTO titles; the title
truncates within the pill so a long name never breaks the banner. Falls back to
a plain count only when no title has resolved yet.

Also constrain the composer attach menu: a long localised label — e.g. "Neues
Dokument hochladen" — could run past the popover edge. The menu items now shrink
and truncate within the menu width, mirroring the vault picker items.

Adds a "chattingAboutMore" string across all six locales.
2026-07-17 11:02:47 +02:00
Marc-André BombeckandClaude Opus 4.8 65f90160c7 fix(daily): refresh the Today digest after a dose, drop the hero ring cluster
The Today digest went stale after logging a dose: nothing invalidated the
`["daily","digest"]` query, and it reads meds from the same server snapshot
the intake routes already hard-evict — so the "dose due" rail item lingered
until a hard reload. Add the digest key to the medication-dependent bundle and
force the inactive digest (unmounted while the user is on the medication card)
to refetch alongside the dashboard snapshot; the server snapshot eviction seam
is reused, so the refetch returns post-write dose state immediately.

Remove the score-ring cluster from the Today hero — it read as uneven and
wasted tile space. The health-score ring is the hero's only ring now; the
score-ring selection contract stays server-side for the native client. Tighten
the hero's vertical rhythm so the worth-a-look rail sits close under the lead
instead of floating below a half-empty column.

Wire the remaining sleep transports into the morning-refresh trigger: Google
Health, Fitbit, Oura, and Polar now enqueue the debounced morning refresh on a
last-night segment landing, matching the Withings / WHOOP / Apple seams — those
sources had no trigger at all. Make the trigger observable: each early-return
gate now annotates a distinct `daily.morning_refresh.skipped` reason (the
not-last-night gate also carries the samples' resolved local dates, today's key
and the tz) so a real landing shows exactly which gate fired.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018V8V9qapgHi7fhgtMJ8SkM
2026-07-17 11:00:22 +02:00
Marc-André Bombeck f8c0ea5120 Merge pull request #515 from MBombeck/release/v1.29.0
v1.29.0 — A daily health companion
2026-07-17 09:56:24 +02:00
Marc-André Bombeck af4cad46fe fix(insights): load the intraday pulse chart through the shared chart runtime
The pulse page imported the intraday area chart directly, so its
`from "recharts"` import minted a second recharts fingerprint chunk and
pushed total client JS past its budget. Route it through the shared
chart-runtime barrel via the same single next/dynamic boundary every other
chart uses, so recharts stays one shared chunk.
2026-07-17 09:46:09 +02:00
Marc-André Bombeck ba5a17a54e fix(daily): keep the coach check-in constants out of the client bundle
The Today hero and the check-in hook imported the check-in intent constants
from digest.ts, which — since the milestone slice — transitively reaches the
database through the streak baseline engine. That pulled the Postgres driver
into the client bundle (a Turbopack 'Can't resolve net/tls/dns' build failure
the worktree build couldn't surface). Move the four constants to a tiny,
dependency-free coach-checkin-intents module; the client imports them from
there, and digest.ts (the server builder) stays server-only.
2026-07-17 09:37:39 +02:00
Marc-André Bombeck 2d9237fbad chore(release): v1.29.0 — a daily health companion
The capstone of the daily-value milestone. On top of the Today view, the daily
loop (coach check-in, morning freshness, daily note) and the documents/coach
work already shipped, this completes the picture:
- ECG woven into the insights narrative (device-attributed only, never the
  app's own reading; a new recording surfaces gently in Today).
- Intraday heart rate as the shape of the day, with a careful elevated-at-rest
  tension note (never during activity, never a verdict).
- Calm milestones — states reached, not streaks to keep.
- The cohesion layer: the chosen health rings return to the Today hero, each
  surface carries its metric's calm colour identity, and a quiet reached-moment
  for a milestone — one design throughout.

No new data collected; every daily surface reads the overnight digest. No
migration in this release.
2026-07-17 09:10:56 +02:00
Marc-André Bombeck cb883e1470 feat(dashboard): cohesion polish — hero score rings return, per-metric accents, quiet milestone moment
- Resurface the selected hero score rings on the Today hero: the Settings
  picker's selection and drag order render again as a calm ring cluster
  beneath the health ring, each ring in its wellness hue, flat-painted,
  and linking to its existing detail surface (score anatomy pages,
  medications). The health ring now opens the Insights overview like its
  cluster siblings. Empty selection keeps the hero exactly as before.
- Per-metric identity accents on the daily rail via the existing --tile-*
  hue vocabulary: a soft inset edge (stress for tension windows, strain
  for ECG pointers, primary for dose and coach cards); the same mark ties
  the ECG cross-link and the intraday pulse card shell to their rail
  siblings. Utility items (sync, preventive care) stay accent-less.
- Quiet "reached" treatment for the milestone card: a soft success halo
  over the theme card plus a one-shot spring reveal on --ring-spring,
  with its reduced-motion fallback beside the keyframes.
- The Today hero carries the one sanctioned atmosphere wash (a faint
  primary mix over the card in the wellness-tile pattern); the loading
  skeleton shares it so the swap changes content, never the surface.
- Skeleton primitive upgraded from the whole-block pulse to a single soft
  shimmer sweep, reduced-motion guarded in globals.css; dependent test
  assertions repointed.
- Ring link / dose-tally aria strings restored in all six locales.
2026-07-17 09:05:24 +02:00
Marc-André Bombeck 8ac38926c4 merge: assemble capstone signal slices (S10 ECG narrative, S11 intraday pulse, S12 milestones) 2026-07-17 08:38:44 +02:00
Marc-André Bombeck 2f1423fa09 Merge branch 'feat/cap-s11-pulse' into release/v1.29.0
# Conflicts:
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/pl.json
#	src/lib/daily/__tests__/digest.test.ts
#	src/lib/daily/digest.ts
#	src/lib/daily/load-digest.ts
2026-07-17 08:25:44 +02:00
Marc-André Bombeck d82862b384 feat(insights): weave ECG into the briefing narrative and daily rail
Fold the ECG silo into the insights narrative without crossing the
non-diagnostic line — the app reports the recording device's own verdict
and the existence/count of recordings, never an interpretation of the trace.

- Feature snapshot: a bounded, device-verdict-only ECG descriptor (recording
  count, device-verdict distribution, latest recorded-days-ago + average HR)
  flows into the nightly briefing prompt. The waveform never enters the
  payload; the grounding gate admits the pre-computed counts so a
  device-attributed sentence stays grounded. A prompt rule (EN + DE)
  constrains any mention to the device's attribution and forbids reading
  morphology, measuring intervals, or emitting a verdict of the app's own.
- Pulse cross-link: a device-attributed pointer from the resting-HR / pulse
  page into the ECG viewer, un-mounting when no recordings exist.
- Daily rail: an ecg_new_recording item surfaces once when a recording landed
  within the last day — calm, info-level, deep-linking the viewer. Gated on
  the insights module; the DTO carries no waveform.
- i18n across all six locales; non-diagnostic disclaimer copy per language.
2026-07-17 08:19:17 +02:00
Marc-André Bombeck a33527da57 feat(insights): intraday pulse shape and elevated-at-rest tension window
Add an on-demand intraday layer to the pulse insight: 10-minute mean
heart rate for the viewed day, folded from raw samples via the read-swap
pattern (never persisted as 10-minute rollups for all history). Draw the
personal resting baseline and softly shade any sustained
elevated-at-rest stretch.

The tension signal is not raw heart rate — it is heart rate held above
the personal resting baseline while movement is low (the elevated-at-rest
window), so a workout or a walk never reads as tension. Detection is
deliberately conservative: baseline maturity, per-bucket sample density,
a mandatory step-based low-movement gate, and a minimum sustained
duration must all hold before anything is reported. Silence over
speculation.

Surface the same signal as a calm, non-diagnostic tension_window item in
the daily digest — at most once per day, honest-absent otherwise, gated
on the insights module and yielding the rail to time-sensitive actions.
Cautious copy across all six locales; framed as "possible tension", never
a clinical stress verdict.
2026-07-17 08:16:32 +02:00
Marc-André Bombeck 8b7bb7914e feat(daily): milestones — the calm reward layer
Add the durable-states Milestone primitive and a milestone card to the
daily digest. A milestone marks a state REACHED, never days maintained:
a metric settled back inside its own range, a run of weeks in range, or
a fresh personal best. It is deliberately not a streak — the type carries
a reached-date and no running counter, so nothing here can be tuned into
a flame, and a lapse is invisible rather than mourned.

The primitive is a thin wrapper over engines that already exist: the pure
detectStreak band engine (return-to-range + sustained-in-range) and the
PersonalRecord detection output (new bests). Reached-once needs no new
storage — a milestone's reached-date is a stable fact, so it surfaces only
on the day it was reached and never resurfaces the next day.

The digest emits one calm success card per day when a milestone is freshly
reached, below an overdue dose and above ambient items, with a single
action into the metric's insight. Module- and data-gated: a disabled
insights account or a day with nothing reached shows nothing. Copy carries
no streak, loss, or failure vocabulary, in all six locales.
2026-07-17 08:10:58 +02:00
Marc-André Bombeck 1427125b7e Merge pull request #514 from MBombeck/feat/documents-coach-bundle
v1.28.56 — Documents, closer to hand
2026-07-17 00:22:20 +02:00
Marc-André Bombeck b87f7b50d1 chore(release): v1.28.56 — documents, closer to hand
Bundles the documents/coach slices onto the daily-system release:
- Ask the Coach about a document opens in a side panel (expandable to full
  page), not a full-page jump.
- Attach one or more documents to a Coach chat from the composer (pick from the
  vault or upload); the conversation runs fenced — grounded in those documents
  only, no health data or tools — and can never reach the tool loop (a sticky
  document-scoped flag plus dual client/server enforcement; migration 0245).
- A read document that looks like lab work has its values detected and proposed
  for your labs, added only on your one-tap confirm.
- A lab scan is also filed in your vault (encrypted) and linked to the readings.

Adversarially security-reviewed (multi-document attach): all clear.
2026-07-17 00:10:16 +02:00
Marc-André Bombeck d470d406a7 Merge remote-tracking branch 'origin/main' into feat/documents-coach-bundle 2026-07-17 00:02:51 +02:00
Marc-André Bombeck 73de8f6a52 feat(documents): bidirectional labs ↔ documents cross-link
Two directions, folding the vault and labs pipelines into one linking path
without weakening either manual flow.

Vault document → lab values (auto-stage). After the per-document auto-index
job transcribes a stored document, a lab-looking document is run through the
same inbound extraction the manual "Extract" button uses, but the facts land
PENDING for the existing human review — nothing is committed. Gated on both
the documents and labs modules plus the auto-AI-read consent gate the
auto-index external path already uses; a text-structuring pass over the
transcription the index just produced, so no second vision call is spent. The
staging is a guarded STORED→EXTRACTED transition, and a keyword heuristic (or
an explicit LAB_RESULT kind) keeps an arbitrary photo from burning a call.
Everyone without the opt-in keeps the manual extract button.

Lab scan → vault. A lab report scanned in the labs area is now also filed
into the Documents vault (kind LAB_RESULT) through the existing upload —
encrypted at rest, EXIF-stripped, thumbnailed, sha256-deduped — and the
committed labs are cross-linked back to it with the same APPROVED-observation
provenance the manual vault → labs path produces. Text-mode scans keep the
image on-device and file nothing. Idempotent: a re-commit inserts no rows and
links nothing new, and the vault dedupe means no duplicate document.

Owner-scoped and module-gated on both sides; the cross-link is best-effort so
it never fails the authoritative write.
2026-07-17 00:01:45 +02:00
Marc-André Bombeck 46d25361da Merge pull request #513 from MBombeck/release/v1.28.55
v1.28.55 — The Today view comes to life
2026-07-16 23:44:57 +02:00
Marc-André Bombeck e272453327 chore(release): v1.28.55 — the Today view comes to life
Bundles three daily-loop slices onto the Today view:
- Coach check-in: a due plan review surfaces as a calm Today card with
  keep/adjust/let-go, wired to the existing plan lifecycle; silence never
  changes a plan.
- Morning freshness: the sleep-dependent parts of the digest and score refresh
  the moment last night's sleep arrives (a debounced, timezone-correct per-user
  job across the Withings, WHOOP and Apple transports), with an honest
  provisional state until then. Adds migration 0244 (a per-user refresh marker).
- Daily push: an opt-in, once-a-day morning notification carrying the day's
  read, off by default, riding the existing dispatch cascade — a calm note,
  never an alert.

All read the overnight digest; nothing generates on open.
2026-07-16 23:33:33 +02:00
Marc-André Bombeck 502783a3ea feat(coach): attach multiple documents to a conversation
Replace the scalar document_id discriminator on a coach conversation with a
CoachConversationDocument join table and a sticky documentScoped flag, so a
conversation can be grounded on several stored documents at once while the
prompt-injection fence stays intact across the data-model change.

The fence is re-established at both enforcement points, fail-closed:

- Client: resolveCoachSendTarget routes any turn on a fenced conversation (or a
  fresh chat with staged attachments) to the new /api/insights/chat/fenced
  endpoint; the fenced body is strict and never carries scope, guided-question,
  or prefill hints.
- Server: the tool route fetches with documentScoped:false AND 404s when the
  fetched row still carries an attachment (flag/join drift), emitting a
  fence_drift audit alarm. Document text now enters an LLM prompt in exactly one
  code path — the fenced pipeline — with no tools and no health snapshot; a
  module-graph test pins that the tool loop is unreachable from it.

The fenced turn fences each document separately with a server-built, scrubbed
header line (title and filename are untrusted text), runs a per-document egress
consent check on the single picked provider, and grounds numerically over the
live attachment union only. Attachments are capped at five documents and 192 KB
of combined text with honest truncation.

Attach and detach ride dedicated endpoints: attaching validates the document is
owned, live, indexed, and within cap, then flips the conversation fenced (the
one legal, privilege-reducing transition, audit-logged); detaching removes the
document but never clears the flag. The document-sheet chat keeps its public
wire contract and shares the same fenced pipeline over the join.

Migration 0245 adds the join table and the flag, backfills every legacy
single-document chat into a one-attachment fenced conversation, and drops the
scalar column in the same transaction — no dual-truth window.
2026-07-16 23:32:28 +02:00
Marc-André Bombeck 04cb80da6a feat(daily): a calm opt-in morning briefing push
Add a once-daily briefing notification that reads the cached daily digest
and never makes a fresh model call. It rides the existing dispatch cascade
(APNs, Telegram, ntfy, webhook, email, web push), so it inherits the
hard-reject classification and the push-attempts ledger unchanged.

- New DAILY_BRIEFING event type, default off: the briefing stays silent
  until the user opts a channel in from the notifications matrix, so the
  channel choice is explicit and a fresh account never gets an unasked-for
  push.
- One decision seam both triggers share: the sleep-arrival morning refresh
  fires it the moment the day finalises, and a fixed local-morning fallback
  slot covers the case where last night's sleep never synced. Whichever
  wins the morning writes the ledger row; the once-per-local-day cap
  suppresses the other.
- The body is the digest line — the cached briefing lead when present, else
  the deterministic floor — so a keyless self-hoster still gets a
  first-class push. Provisional days carry an honest "sleep not in yet"
  note.
- Non-clinical and non-urgent by construction, fired only inside a local
  morning window: it is a nudge, never the anomaly channel, and never
  bypasses Focus. Empty accounts are left alone rather than nudged with a
  hollow all-clear.
- Localised across all six locales.
2026-07-16 23:23:41 +02:00
Marc-André Bombeck b6ded4d1f9 feat(daily): finalise the morning digest when last night's sleep lands
The nightly insight pre-pass runs ~04:30, before last night's sleep has
synced, so the morning digest and health score reference the day without
the current sleep. Close the gap the event-driven way instead of an
on-mount generation.

A day's digest is now provisional until the sleep-dependent output has
been regenerated with last night's sleep, and final after. The lifecycle
is driven by a real marker (User.morningDigestRefreshedOn, the user-local
date the refresh completed), not a heuristic: it flips the instant the
refresh runs, ahead of the snapshot cache that feeds the sleep last-seen
fallback. A day with no sleep device stays honestly provisional and keeps
the sleep-pending note rather than showing a stale final score.

When a completed sleep segment for last night lands from any of the three
sleep transports — Withings sync-sleep, the WHOOP sync, the Apple-Health
measurement batch — a debounced morning-digest-refresh job is enqueued.
"Last night" is judged in the user's profile timezone, so a full-history
re-sync never re-triggers for an old night. The queue singletonKey plus
the marker collapse the many samples of one night, across all sources,
into one refresh per user per local morning; the job re-runs the same
comprehensive pipeline the nightly cron uses (forced, so the content-hash
gate turns a no-change regen into a cheap no-op) and stamps the marker. A
hard generation failure leaves the day provisional and hands recovery to
the existing 45-minute briefing-retry machinery.
2026-07-16 22:57:27 +02:00
Marc-André Bombeck d72f078764 feat(documents): open the doc-scoped Coach in the side drawer, not a full page
"Ask the Coach" on a document used to navigate to /coach?doc=<id> — a full-page
jump away from the vault. It now opens the real, doc-scoped conversation in the
shared side drawer, and the drawer's maximize control expands it to the full
/coach page while preserving scope.

- Thread an optional documentId through the launch context: askCoach gains a
  fourth argument, the provider tracks it alongside prefill/scope and clears it
  on close, and LayoutCoachMount forwards it to the drawer as initialDocumentId.
  The open-state precedence moves into a pure resolveLaunchState helper.
- The vault detail sheet closes itself and calls askCoach(..., doc.id) instead
  of routing away, so the drawer owns the surface.
- Maximize preserves continuity: the drawer reads the live conversation id via
  a new imperative getter out of the conversation surface (parallel to the
  reset registration) and hands off to /coach?c=<id> once a thread exists,
  falling back to /coach?doc=<id> for a chat maximized before its first turn.

The send path is untouched: every document turn still routes through the
hardened fenced endpoint keyed on documentId — only where the conversation
renders (drawer vs page) changed. The documents-chat e2e now asserts the
drawer opens in place, doc-scoped, rather than a URL change.
2026-07-16 22:34:17 +02:00
Marc-André Bombeck b7a6b4c898 feat(coach): close the plan check-in loop on the Today rail
Every accepted coach plan now earns a gentle weekly check-in. Confirming a
plan (proposed → active) without an explicit review date defaults one to a
week out, so a plan is always reviewed, not only the coach's own experiments.

The daily digest emits a single `coach_checkin` rail item when a standing
plan's review has come due — its pinned review date, or a defaulted
activation + 7 days for plans that predate it, or the read-back state a
reviewed plan carries. Capped at one check-in a day across all plans
(earliest-due wins); the rest wait for a following day. It reads existing
plan state only — no fresh generation, nothing warms on mount, and it is
gated on the coach module.

The card offers three one-tap actions through the existing plan-lifecycle
route: keep it (re-arm for another week), adjust it (open the coach), or let
it go (a guilt-free, respected end — the calm inversion of a streak). An
ignored check-in sits quiet and stops resurfacing after about two cycles;
its status is never changed behind the user's back.

Copy lands in all six locales.
2026-07-16 22:30:46 +02:00
Marc-André Bombeck 7c96abc8ec Merge pull request #512 from MBombeck/release/v1.28.54
v1.28.54 — One hero on the dashboard
2026-07-16 21:52:44 +02:00
Marc-André Bombeck cd3dfb81bc chore(release): v1.28.54 — one hero on the dashboard
Retires the legacy opt-in dashboard hero now that the Today view leads the
dashboard, so the two never stack. Removes the hero mount, its skeleton, the
carousel/spotlight components only it rendered, and the heroVisible layout
field (a stored layout that still carries it is ignored on read, never
re-persisted, so nothing to migrate). The greeting is now always shown. The
mobile-density guard measures the Today hero.

The score-ring selection stays in the layout contract for the native client;
its web picker is a follow-up decision (resurface in the Today view or leave it
native-only).
2026-07-16 21:41:00 +02:00
Marc-André Bombeck 1d592bef78 refactor(dashboard): retire the legacy opt-in hero
The digest-driven Today hero is the single promoted hero at the top of
the dashboard, so the old opt-in hero band is redundant — a user who had
opted in and has Insights on saw two heroes. Remove the legacy hero and
its heroVisible layout flag.

- Drop the DashboardHero / DashboardHeroSkeleton mount from the
  dashboard page; the header now renders the greeting unconditionally
  (its showGreeting gate is gone with the hero that owned it).
- Retire the heroVisible field from the dashboard layout (type,
  defaults, resolver, serializer) and the widgets PUT schema. The
  resolver is a whitelist constructor, so a stored heroVisible is
  dropped on read and never re-persisted — no stored layout breaks.
- Delete the now-orphaned hero components (band, skeleton, ring
  carousel, briefing spotlight) and their tests; drop the retired
  dashboard.hero copy across all locales.
- Remove the hero-visibility toggle from Settings → Dashboard. The
  score-ring picker no longer hangs off that flag and stays available,
  since the rings it configures still ride the server-computed
  dashboard snapshot.
- Repoint the phone-width density guard at the Today hero and mock the
  daily digest so it renders.
- Repoint the preserve-when-absent doc analogy off the removed field.
2026-07-16 21:34:06 +02:00
Marc-André Bombeck d6ad76b1e1 Merge pull request #511 from MBombeck/release/v1.28.53
v1.28.53 — A Today overview at the top of the dashboard
2026-07-16 20:15:13 +02:00
Marc-André Bombeck 08a3ae2ef1 fix(dashboard): pin the Today hero to the skeleton during hydration
The digest query rehydrates from the persisted client cache, so the Today
hero was painting on the first client render while the server had rendered the
skeleton — a text-content hydration mismatch (React #418) that the dashboard
e2e's no-significant-errors gate caught. Gate the hero on `mounted` like the
rest of the dashboard's late-hydrating surfaces, so SSR and the first client
render both show the skeleton and the hero appears once mounted.
2026-07-16 20:03:58 +02:00
Marc-André Bombeck 0be26473b4 fix(dashboard): keep the legacy hero rendering as before
Revert the Today-hero-owns-the-slot suppression from this branch: the legacy
opt-in dashboard hero is asserted by the mobile-density and dashboard e2e
landmarks, and retiring it cleanly (with its settings toggle and those specs)
is a separate change. For now both heroes render exactly as before this branch;
the default account (legacy hero off) still shows only the Today hero.
2026-07-16 19:49:56 +02:00
Marc-André Bombeck 25d720e6e7 chore(release): v1.28.53 — a Today overview at the top of the dashboard
Bundles the daily-digest spine (a single server-built digest read from the
overnight insight cache, never a fresh generation) and the Today hero that
surfaces it: the health score with an honest provisional/finalised face, the
day's lead read, and a short worth-a-look rail of one-tap items. The tiles and
charts stay below, unchanged. The Today hero takes the promoted slot when
Insights is on, so it never stacks with the legacy opt-in hero.

This is the shared foundation the later daily surfaces (morning refresh, an
optional daily note) build on — one digest, one card model, one design language.
2026-07-16 19:37:14 +02:00
Marc-André Bombeck 168c1c85d7 feat(dashboard): promote a Today hero above the tile strip
Surface the day's read at the top of the dashboard: a Today hero that
renders the daily digest — the health score with an honest
provisional/final and sleep-pending face, the briefing lead with a
read-the-full-briefing affordance, and the worth-a-look rail of priority
cards. The dense tile grid and chart rows stay exactly where they are,
one scroll below, so muscle memory survives and no new nav destination
is added.

The hero reads one cached digest cell (GET /api/daily/digest) through the
centralised query-key factory and unwraps the envelope; it recomputes
nothing and never warms a provider on mount. It is gated on the insights
module and self-gates its empty state: a genuinely empty account renders
nothing rather than an alarming card, while an account with a score but
nothing notable gets a calm all-clear line in the rail's place. A shimmer
skeleton holds the footprint while the digest loads.

Priority-card actions wire to their existing destinations by construction
(log dose, reconnect a sync, view check-ups). New copy lands in all six
locales.
2026-07-16 19:31:13 +02:00
Marc-André Bombeck ba6c0990bf feat(daily): add the daily-digest spine and priority-card primitive
Introduce the two foundational primitives of the unified daily-value
system, with no user surface yet:

- PriorityItem: the one wire-serialisable model behind every "worth a
  look" rail item, coach check-in, milestone, and device-verdict pointer.
  Its Lucide glyph is derived from the closed `kind` enum in the renderer
  so the model itself stays JSON-serialisable across the API and future
  native-client boundaries.
- PriorityCard: a calm, token-only, reduced-motion-aware card composed
  entirely from the shipped Card / TileHeader / ProseBlocks primitives —
  no new visual language, semantic-token status wash only, no markdown.
- buildDailyDigest: one pure, deterministic server composer that assembles
  the day's read from data that already exists — the nightly briefing
  lifted read-only from the insights cache plus the dashboard-snapshot
  health score, meds-today, and sleep freshness. It computes no analytics
  and never reaches an AI provider. It emits the DailyDigest DTO the Today
  surface, the daily push, and a future iOS widget will all consume.
  Deterministic item builders cover overdue doses, broken integrations,
  and due Vorsorge reminders; the push line prefers the cached briefing
  lead with a deterministic score floor so a keyless self-hoster still
  gets a first-class line.

The read seam GET /api/daily/digest gathers the cached inputs, gates on
the insights module (cookie or Bearer; userId from the session), and
returns the digest. OpenAPI regenerated; the route is covered by the
module gate inventory. Digest strings ship in all six locales.
2026-07-16 19:13:01 +02:00
Marc-André Bombeck ecc34880ba Merge pull request #510 from MBombeck/release/v1.28.52
v1.28.52 — Dashboard tiles for more of what you already track
2026-07-16 15:32:30 +02:00
Marc-André Bombeck 7c45c876a0 feat(dashboard): tiles for HRV, blood oxygen, breathing rate, temperature, and body composition
Several readings were synced and charted but had no place on the dashboard
strip: heart-rate variability, blood oxygen, breathing rate, wrist temperature,
and — for body-composition scale users — muscle mass, body water, and bone mass.
Each now has a tile.

The tiles are pure client additions: the dashboard snapshot already ships a
summary for every measurement type, so no backend, query, or migration changed.
Each tile is data-availability gated (renders only when the account has that
reading) and is toggleable and reorderable under Settings like the rest.

hrv and respiratoryRate graduate from iOS-only to web-writable widget ids, and
muscleMass is a new writable id (catalogue 36 to 37, within the 40-id PUT cap);
wristTemperature moves from pin-only to a web-rendered tile. All additive — the
widget enum only widens, which the iOS contract treats as non-breaking.
2026-07-16 15:21:03 +02:00
Marc-André Bombeck e883b64b1f Merge pull request #509 from MBombeck/release/v1.28.51
v1.28.51 — Document chats live in the Coach
2026-07-16 11:34:33 +02:00
Marc-André Bombeck 5440ac9070 test(e2e): repoint document-chat spec to the Coach hand-off
The bespoke document-chat drawer was removed with Design A, so the old e2e
asserting the drawer overlay no longer applies. The spec now pins the
documents-side contract that replaced it: with a provider available the Coach
affordance hands off to the document-scoped Coach (/coach?doc=<id>), and with
no provider the AI area shows only the settings pointer and no Coach entry.

The Coach-side scope banner (the Document badge, "Chatting about", and the
not-indexed hint) moves to a coach-conversation component test, replacing the
drawer's not-indexed/render coverage; the fenced-send guarantee stays pinned by
the coach-send-target unit test.
2026-07-16 11:24:22 +02:00
Marc-André Bombeck 5b24ca0fa1 feat(coach): document chats live in the Coach view and history
Asking the Coach about a document opened a separate, half-finished chat panel
that never appeared in Coach history. The bespoke drawer is gone; a document
conversation now opens the real Coach view, appears in the Coach list with a
document badge and a "Chatting about: <title>" note, and is read back through
the same history rail as any other thread.

The prompt-injection fence is unchanged and stays server-side. A document turn
is still sent through the hardened fenced endpoint (no tools, no health
snapshot, grounded in the document text only); the coach tool route continues
to fetch with documentId null, so a document-scoped conversation can never be
continued through the tool loop even from a hostile client. Only the surface
moved into the Coach; the boundary did not.

The relaxed conversation-list and detail filters drop only the documentId
predicate — userId scoping is unchanged, so a document thread is still visible
only to its owner. CoachConversationDTO gains documentId + documentTitle
(server-derived, title/filename only); no schema change (documentId exists
since 0230). OpenAPI regenerated.
2026-07-16 11:01:59 +02:00
Marc-André Bombeck 57539283c8 Merge pull request #508 from MBombeck/release/v1.28.50
v1.28.50 — View your ECG recordings in Insights
2026-07-16 10:06:06 +02:00
Marc-André Bombeck 49ed0f41df feat(insights): view ECG recordings
ECG recordings synced from a compatible watch were stored encrypted but
shown nowhere. They now have a surface in Insights: a list of recordings,
each opening the full single-lead waveform on a familiar ECG grid, with the
date, duration, average heart rate, and the result the device recorded.

The read routes narrow ownership in the query and decrypt the waveform
through the existing fail-closed codec; the overview is min/max-decimated so
the R-wave peaks survive the reduction, with a raw path for a calibrated
view. The result shown is the recording device's own classification,
attributed to the device — HealthLog does not read or interpret the trace
and produces no verdict of its own. A permanent note states this, and a
non-normal result carries a line to discuss the recording with a clinician.
2026-07-16 09:55:35 +02:00
Marc-André Bombeck 7a0a271c70 Merge pull request #507 from MBombeck/release/v1.28.49
release/v1.28.49 — Apple Health re-import no longer stops on a duplicate reading
2026-07-16 09:14:53 +02:00
Marc-André Bombeck 0f4e0be1a6 chore(release): v1.28.49 — Apple Health re-import survives a natural-key collision
The Apple Health ZIP import upserted each spot reading on the
(userId, type, source, externalId) index only. When two readings shared the
natural key (userId, type, measuredAt, source, sleepStage) with different
externalIds — a re-import of overlapping data, or two same-instant Apple
samples from different source apps — the externalId upsert missed and the
create collided on the second unique index (migration 0055), raising P2002
and aborting the entire import (issue #486, after the v1.28.36 dedup fix let
the upload run).

flushSpotBatch now wraps the per-row upsert: on P2002 it probes the occupying
row by the natural key (tombstone-inclusive) and adopts it in place — re-keys
to the incoming externalId, takes the new value/unit, resurrects if
soft-deleted — mirroring the v1.28.39 provider natural-key rescues (closest to
withings/sync-sleep). If no twin resolves it, the one reading is skipped and
the run continues; the catch sits in the per-row loop outside any transaction,
so a collision can never abort the batch or the import. Adopted rows count as
updated, skipped ones surface honestly. Workout + stats: paths verified unable
to hit this (no second unique index / deterministic externalId). Repro +
real-Postgres integration tests added.
2026-07-16 08:48:41 +02:00
Marc-André Bombeck a523d2cc79 Merge pull request #505 from MBombeck/release/v1.28.48
release/v1.28.48 — security follow-ups
2026-07-16 08:20:14 +02:00
Marc-André Bombeck a9a318d4a4 chore(release): v1.28.48 — security follow-ups
From the campaign's security re-verification pass (posture otherwise held
across the 10-release week — no Critical/High):

- MEDIUM: the OIDC auto-provision branch re-introduced the first-admin race
  the password-register route fixed in v1.28.42 — count-then-create with no
  lock, so two concurrent OIDC first-logins (or an OIDC first-login racing a
  password first-registration) in the empty-DB window could both mint ADMIN.
  Now wraps count+role+create in a $transaction taking the SAME advisory lock
  key ('register:first-admin') and re-counts inside, so the two paths
  serialise; every provisioned field preserved.
- LOW: the nutrients batch catch echoed the raw exception message into the
  client-facing reason (violating its closed reason-set). Now returns the
  fixed 'upsert_failed' member and logs the detail server-side only.

No migration (advisory lock is runtime).
2026-07-16 08:03:12 +02:00
Marc-André Bombeck f3a3b8f3a7 Merge pull request #504 from MBombeck/release/v1.28.47
release/v1.28.47 — job queue engine update (pg-boss 12.26)
2026-07-16 07:37:25 +02:00
Marc-André Bombeck 4ec6ae661e chore(release): v1.28.47 — job queue engine update (pg-boss 12.26)
Bumps pg-boss 12.20 → 12.26 (schema-drift detection, self-healing background
index builds, correctness fixes). Some of those fixes surface errors earlier
versions silently swallowed. Verified safe: the app attaches an error listener
to the boss instance (reminder-worker.ts) and uses none of the direct calls the
change affects (insert/fetch/deleteQueue) — only send/work/start/stop/schedule/
getJobById, and the one getJobById is already wrapped in try/catch. The full
integration suite (101 files, 497 tests) exercising the queue against real
Postgres passes unchanged under 12.26. No config change.
2026-07-16 07:26:07 +02:00
Marc-André Bombeck 5b136b91e6 Merge pull request #503 from MBombeck/release/v1.28.46
release/v1.28.46 — server performance on large instances
2026-07-16 07:03:47 +02:00
Marc-André Bombeck 656e96f72b chore(release): v1.28.46 — server performance on large instances
Deferred perf fixes from the campaign's verified perf-correctness audit,
correctness-first (each with an output-equivalence test):

- comprehensive-generate fan-out: replaced per-type 400d findMany+JS-average
  with DB prisma.aggregate(_avg) over the exact half-open windows the JS
  reducer used (current 30d, lastMonth (60d,30d], lastYear (395d,365d]), same
  rounding + null-on-empty, pLimit(4). SLEEP_DURATION stays on the raw path
  (per-night reconstruction dedup). Byte-equality test vs the old algorithm.
- boot-discovery: replaced Prisma in-memory distinct with DB SELECT DISTINCT
   in the 4 backfill discovery scans + migration 0243 (7 additive
  partial indexes matching each predicate). Integration test asserts identical
  discovered set against real Postgres.
- ChatBubble wrapped in React.memo with a comparator (onRegenerate compared by
  presence — the thread rebuilds it each render), so only the streaming bubble
  re-renders per token.
- doctor-report mood read narrowed to {score, tags} (the only fields the
  collector uses); the 730d main measurement read left as-is — a blind bound
  would corrupt clinical stats (documented deferred follow-up).

Migration 0243 additive-only.
2026-07-16 06:47:36 +02:00
Marc-André Bombeck a59a5d75e1 Merge pull request #502 from MBombeck/release/v1.28.45
release/v1.28.45 — doctor-report + privacy fixes
2026-07-16 06:01:21 +02:00
Marc-André Bombeck 0796d9fe14 chore(release): v1.28.45 — doctor-report + privacy fixes
Three CONFIRMED findings from the campaign's feature-wiring audit + structural
verification:

- Sleep rendered nothing in the doctor-report PDF: the sleep section is
  default-on and the data layer reconstructs per-night SLEEP_DURATION rows, but
  DOCTOR_REPORT_VITAL_TYPES omitted SLEEP_DURATION, so the vitals table showed
  nothing (FHIR already emitted it). Added it to the allowlist with a
  minutes-to-hours conversion; corrected the stale 'intentionally excluded'
  comment.
- Glucose had no doctor-report section toggle — it rendered whenever the global
  glucose module was on, with no report-level privacy control. The toggle
  actually already existed in the UI (with i18n) but was dead because the prefs
  fold dropped the key. Wired it end to end (glucose added to the prefs Zod
  schema + defaults, default-on to preserve behaviour; gated the render loop +
  the query). Also fixed a grouped-vs-flat prefs mis-detection: 'glucose' in
  GROUPED_SECTION_KEYS would have flagged flat blobs (incl. EMPTY prefs) as
  grouped and defaulted them all on. No migration (prefs is a JSON blob).
- environment fed the coach with no exclusion: MODULE_EXCLUDED_SOURCES omitted
  it, so module-off still leaked audio_env/audio_headphone/audio_event/daylight/
  skin_temp to the snapshot. Added, scoped exactly to the environment cluster's
  five sources.

Regression test per fix.
2026-07-16 05:51:04 +02:00
Marc-André Bombeck 15a09d77a0 Merge pull request #501 from MBombeck/release/v1.28.44
release/v1.28.44 — loose ends: broken link, API contract, module guards
2026-07-16 05:15:01 +02:00
Marc-André Bombeck cd0fbddd1d chore(release): v1.28.44 — loose ends: broken link, API contract, module guards
From the campaign's dead-code + code-health audits (grep-verified; the
custom-metrics query-key finding was verified-false and excluded):

- sleep-overview empty-state CTA linked to /settings/devices, not a valid
  settings slug (dynamicParams=false → 404). Repointed to
  /settings/integrations (where every device OAuth callback already lands).
- six live, iOS-consumed routes were absent from docs/api/openapi.yaml —
  auth/check-user, insights/cards, integrations/healthkit (GET+PATCH),
  measurement-categories, medications/intake/bulk, mood-entries/bulk —
  registered in the Zod OpenAPI registry mirroring each handler's real shape.
- mood/labs/medications module pages lacked the redirect-when-off self-guard
  their siblings have; added, mirroring cycle/achievements exactly.

No migration. Verifier REJECTED the custom-metrics bare-key finding (they are
invalidateQueries prefix filters, not colliding useQuery keys).
2026-07-16 04:59:27 +02:00
Marc-André Bombeck 8b6024f0d7 Merge pull request #500 from MBombeck/release/v1.28.43
release/v1.28.43 — visual consistency polish
2026-07-16 03:58:08 +02:00
Marc-André Bombeck 6d83115d16 chore(release): v1.28.43 — visual consistency polish
Verified UI-consistency findings from the campaign's design audit, each
pixel-faithful except the intended correction:

- insights hero band: off-scale py-5/gap-5 → the standard scale; H1
  font-semibold → font-bold; sm:text-[28px] → sm:text-3xl.
- medication-compliance-chart + recent-achievements-card: hand-rolled
  muted h-4 icon + text-sm headers → the standard TileHeader primitive so
  they match sibling tiles.
- achievements stat tiles + clinician-view shells: p-5 → p-4 md:p-6.
- two text-primary header glyphs (research-mode dialog, enroll-mfa) →
  text-foreground (never accent on a header glyph); the deliberate
  badge-boxed icons left untouched.
- SheetHeader now reserves the trailing close-X gutter like DialogHeader,
  closing the latent overlap class for future raw-Sheet consumers.
- five raw Dialogs on phone-reachable surfaces → ResponsiveSheet.
- new bg-card-scoped healthlog/spacing-scale lint (flags p-5/gap-5 etc.
  only on card shells; list-marker insets + half-steps excluded) at error,
  with the five offenders it flagged swept.
- dropped the stale CLAUDE.md '18 PascalCase outliers' line (0 remain).

Every data-slot preserved. No migration.
2026-07-16 03:44:00 +02:00
Marc-André Bombeck 3a9ed8dd0e Merge pull request #499 from MBombeck/release/v1.28.42
release/v1.28.42 — snappier lists, correct mood refresh, safer first sign-up
2026-07-16 03:33:46 +02:00
Marc-André Bombeck d4be568625 chore(release): v1.28.42 — snappier lists, correct mood refresh, safer first sign-up
Performance + correctness fixes from the campaign's verified perf-correctness
audit (the two server-query findings — comprehensive-generate + boot-discovery
distinct — are deferred to a careful separate perf release):

- format-locale built a fresh Intl.DateTimeFormat/NumberFormat per call
  (main-thread stall across every history list). Routed through the existing
  process-wide formatter-cache keyed on locale + options (timeZone + hour-cycle
  in the key); output byte-identical.
- measurement-list rendered every row twice (desktop table + mobile cards, one
  CSS-hidden), unvirtualized. Now renders one layout via the SSR-safe
  useIsMobile(md) hook; row markup + every data-slot unchanged.
- moodDependentKeys omitted dashboardSnapshot(), so a mood write left the
  dashboard tile/score stale ~120s (server cache was already correct). Added it.
- register first-user admin was a count-then-create race (two concurrent
  first sign-ups could both become ADMIN). Now an advisory lock + in-transaction
  re-count makes first-admin atomic; P2002 maps to 409 instead of 500.

Regression test per fix. No migration (0243 still free).
2026-07-16 03:18:55 +02:00
Marc-André Bombeck 9569d1c528 Merge pull request #498 from MBombeck/release/v1.28.41
release/v1.28.41 — complete the translations, guard the gap
2026-07-16 02:49:07 +02:00
Marc-André Bombeck 45fb909c16 chore(release): v1.28.41 — complete the translations, guard the gap
illness.vital.FUNCTIONAL_IMPACT (the recovery-driver track that wins ties
in the illness insights card's driver selection) was referenced but absent
from all six locale bundles, rendering its raw dotted key on a live
surface — the same dynamic-key seam that let the WHO-5/SCI reminder labels
ship untranslated. Added the label in de/en/es/fr/it/pl.

Closed the class structurally: a new enum-driven
dynamic-key-exhaustiveness test imports the source enum/const for each
high-value dynamic key space (illness scan types + functional-impact,
illness type/lifecycle, allergy + family-relationship records enums, the
mental-health instrument slugs — the WHO-5/SCI seam) and asserts every
member's built key resolves non-empty in all six locales, so a missing
translation fails CI instead of reaching a user. Fallback-protected spaces
(Strava raw sport_type) and structurally-unreachable keys are documented
as excluded. Removed one confirmed-orphan key (illness.vital.SKIN_TEMPERATURE,
zero call sites).
2026-07-16 02:35:22 +02:00
Marc-André Bombeck f05354796a Merge pull request #497 from MBombeck/release/v1.28.40
release/v1.28.40 — insight assessments lead with meaning, not a number
2026-07-16 02:25:23 +02:00
Marc-André Bombeck 461c1cfe09 chore(release): v1.28.40 — insight assessments lead with meaning, not a number
The per-metric insight assessments opened on the current value and read as
a data readout, while the overview texts lead with the day's verdict and
land the warm, motivating tone. Diagnosis (campaign insights-copy audit +
verification): identical warm persona/contract fragments, but the
per-metric USER prompt instructed number-first while the overview
instructed verdict-first, and the opener-hint rotation was wired into
briefing/scores/Coach but never the per-metric path (so the base prompt's
'do not open with the number' clause stayed dormant there).

Rewrote the seven per-metric assessment user prompts (generic archetype +
six bespoke: BP/weight/pulse/BMI/mood/compliance + overall) verdict-first
in EN+DE — meaning first, the number right after as support; grounding,
non-diagnostic framing, no-vendor and no-markdown all preserved (only
clause order changed). Wired openerArchetypeHint into metric-status and
the bespoke generators. Factored a shared openingShape contract into
shared-contracts so overview/per-metric/scores can't drift again, with a
coverage test. Lifted the three deterministic first-paint fallbacks to
verdict-led. Prompt-contract regression tests added. No new model calls,
token budget flat.
2026-07-16 02:14:46 +02:00
Marc-André Bombeck 273ead0304 Merge pull request #496 from MBombeck/release/v1.28.39
release/v1.28.39 — sync data-loss hardening
2026-07-16 01:56:12 +02:00
Marc-André Bombeck 7386630962 chore(release): v1.28.39 — sync data-loss hardening
Four verified HIGH data-integrity fixes, each in-place per-provider (no
shared helper), from the campaign's data-integrity audit + adversarial
verification:

F1 — drain-per-sample-cumulative buildScanWhere was the only one of four
sibling drains missing deletedAt: null; the nightly cumulative fold
re-summed soft-deleted Apple samples and hard-deleted the legacy-steps
audit tombstones. Added the filter (mirrors consolidate-daily-mean,
dense-intraday-retention, consolidate-legacy-steps).

F2 — the additive late-fold merge had no single-flight guard, so two
overlapping runs (deploy overlap, or the admin endpoint racing the cron)
produced a permanent 2x daily total. Added a pg_advisory_xact_lock per
(user, externalId) plus an in-transaction re-read of the live
contributors so the serialised-second fold contributes zero (a bare lock
alone would still re-add its stale scanned sum). Mirrors the documents
quota advisory-lock pattern.

F3 — Withings sleep externalId keyed on segment START while measuredAt +
the natural key use END; a re-scored segment missed the externalId probe,
the sweep tombstoned the old row, and the re-keyed insert collided on the
natural key and wedged. Added the natural-key rescue (re-key in place),
mirroring the same-file measure path.

F4 — Withings measure sync stamped lastSyncedAt + recordSyncSuccess
unconditionally after a warn-only per-row loop; a transient write failure
stranded the reading behind the 10-min overlap forever. Now holds the
watermark and records failure on a hard row error (mirrors
google-health/fitbit), while a benign P2002 still advances.

Regression test per finding (reverting the three source files fails
exactly four tests). Integration suite green against real Postgres.
2026-07-16 01:45:05 +02:00
Marc-André Bombeck 51d42f9422 Merge pull request #495 from MBombeck/release/v1.28.38
release/v1.28.38 — documents polish, waist reminders, bulk sharing
2026-07-16 01:05:35 +02:00
Marc-André Bombeck 3e917969c7 fix(documents): surface the read-by-AI provenance in the detail view
v1.28.38 removed the purple AI-read glyph from the vault card (it looked
wrong there), but that glyph had been the only visible surface for the
'read by AI' provenance since v1.28.22 — the detail sheet's AI section
shows no status and collapses entirely under auto-read. Add a discreet
muted 'Read by AI' line under the document's uploaded-metadata in the
detail view (data-slot document-detail-ai-read), independent of the AI
section's collapse logic; reinstate documents.ai.statusAiRead in all six
locales. Card glyph stays removed. The e2e that asserted the card marker
now deep-links the document and asserts the detail-view line.
2026-07-16 00:54:01 +02:00
Marc-André Bombeck 4852421f05 chore(release): v1.28.38 — documents polish, waist reminders, bulk sharing
Document vault: card list drops a stray glyph and gives the real filename
its own line (was truncating on the meta row); the detail sheet's close
button aligns correctly — ResponsiveSheet reserved the right gutter for a
close button it wasn't rendering, now conditional on showCloseButton. The
open-in-coach action reads 'Coach fragen'.

Bulk share: a multi-selection in the overview shares several documents in
ONE link (documents-only privacy preserved, owner-scoped, up to 50 per
link; >50 refuses with a toast rather than silently truncating).

Reminders can link to WAIST_CIRCUMFERENCE (additive MeasurementReminderType
enum value, OpenAPI regenerated). The WHO-5 and SCI well-being reminder
labels were absent in all six locales and rendered their raw dotted keys
(the shared 'measurementReminders.types' prefix is what showed as the stray
'Measurement Reminders Types'); added in every language. A new
type-labels-i18n test asserts every dynamic reminder-type key resolves
non-empty in all six bundles — the static coverage guard cannot see the
dynamic key that let this through.
2026-07-16 00:33:20 +02:00
Marc-André Bombeck ea71ba2f37 Merge pull request #494 from MBombeck/release/v1.28.37
release/v1.28.37 — provider step totals survive a restart
2026-07-15 23:07:17 +02:00
Marc-André Bombeck 0ba5334b57 fix(measurements): legacy step consolidation no longer sweeps provider daily totals
WIP-commit (worktree safeguard; final assembly + version bump happen in the main tree after v1.28.36 merges).
2026-07-15 22:49:07 +02:00
Marc-André Bombeck 8b1c5deac7 Merge pull request #493 from MBombeck/release/v1.28.36
release/v1.28.36 — re-importing after a failed Apple Health import actually retries
2026-07-15 22:34:21 +02:00
Marc-André Bombeck 7a6598f7c3 test(e2e): stabilise the profile-timezone sessions-card expand
The sessions card toggle's onClick could be dropped when Playwright
clicked before React hydrated the handler, leaving the region hidden for
the whole wait and failing intermittently in CI. Retry the click until
aria-expanded actually flips.
2026-07-15 22:23:04 +02:00
Marc-André Bombeck 82aa66b400 chore(release): v1.28.36 — re-importing after a failed Apple Health import actually retries
The v1.28.33 rollup + staging fixes were correct but never reached the
reporter's case: the upload dedup keys on the content SHA-256 and the
lookup matched a previously FAILED job too, so re-uploading the same
export.zip returned the dead job's id and replayed its stale
failureReason without ever staging or sending again. The dedup now
excludes failed rows (short-circuits only on queued/unpacking/parsing/
upserting/done); the same bytes retry cleanly. A redundant staged upload
is unlinked on a dedup hit instead of leaking in the staging dir.

reconcileOrphanImportJobs no longer unconditionally fails every
non-terminal ImportJob at boot: it now checks pg-boss job state plus a
progress heartbeat (new import_jobs.updated_at, migration 0242), so a
split or multi-worker deployment cannot kill an import running in
another worker. The missing-staging message names the separate-container
cause explicitly, with a matching note in docs/self-hosting/scaling.md.
2026-07-15 22:07:25 +02:00
Marc-André Bombeck a614542cc3 Merge pull request #485 from balajiv113/feat/oidc-sso-login
feat(auth): add OIDC SSO login
2026-07-15 21:37:20 +02:00
Marc-André Bombeck 132c113305 Merge pull request #491 from MBombeck/release/v1.28.35
release/v1.28.35 — timestamps honor the profile timezone
2026-07-14 20:32:36 +02:00
Marc-André Bombeck 9bbd8a89dc chore(release): v1.28.35 — timestamps honor the profile timezone
Client-rendered times fell back to the hardcoded display zone
(Europe/Berlin) because neither useFormatters() nor the legacy format.ts
helpers ever received users.timezone. A third localStorage mirror
(timezone-mirror.ts, following the time-format/date-format pattern)
carries the validated profile zone from /api/auth/me into both read
paths; the fallback chain is strictly profile → Europe/Berlin — never
the browser zone, so screen, PDF, and export cannot disagree.
makeFormatters validates its zone before Intl (a poison value falls back
instead of throwing inside every render).

Same pass, per the two-document audit: formatUpdatedLabel boundary and
clock now resolve one zone in every state (the undefined-tz browser hole
is closed); @db.Date calendar fields render UTC-pinned so profiles west
of UTC cannot shift a day; dose-history day headings and GLP-1 weekday
labels derive from the display zone; chart day/month labels are
encoding-derived and UTC-pinned (bucket math untouched, byte-identical
for Berlin profiles); the clinician share view renders in the patient's
timezone instead of the container's; Playwright pins Europe/Berlin.
Server day-key semantics, streak anchoring, insight cache keys, and
verbatim wall-clock strings are untouched by construction and pinned by
the new Auckland/New_York/Manila + DST test matrix.
2026-07-14 20:19:23 +02:00
Marc-André Bombeck 136653e3d7 Merge pull request #483 from MBombeck/dependabot/npm_and_yarn/shadcn-4.13.0
chore(deps-dev): bump shadcn from 4.9.0 to 4.13.0
2026-07-14 13:31:50 +02:00
Marc-André Bombeck 1e8d796c57 Merge pull request #489 from MBombeck/release/v1.28.34
release/v1.28.34 — nutrient intake lands as quiet context (server side)
2026-07-14 13:22:33 +02:00
Marc-André Bombeck 5445fc67ea chore(release): v1.28.34 — nutrient intake lands as quiet context (server side)
Supplement-style intake written to Apple Health by nutrition apps —
vitamins, minerals, water, caffeine — can now sync as daily totals into a
dedicated NutrientIntakeDay store (migration 0241): composite (user, day,
nutrient) identity, re-post replaces, no soft delete, no rollup coupling.
A code-side catalog carries the 26 nutrient codes with canonical units,
plausibility caps, and EFSA dietary reference values (direction-aware;
chromium cites NIH ODS where EFSA declined a DRV).

Deliberately not a nutrition feature: no diary, no calories, no dashboard
surface. POST /api/nutrients/batch is gated behind the new opt-in
nutrients module (default off — with the module off the server refuses
ingest entirely), rate-limited, idempotency-wrapped, with per-entry
unit-mismatch and out-of-range skips. GET /api/nutrients serves a
catalog-ordered summary; the only visible surface is a read-only card
under Settings → Sources. Coach/correlation access is a later slice; the
iOS reading side is coordinated in the companion-app tracker.
2026-07-14 13:10:47 +02:00
dependabot[bot] a074286951 chore(deps-dev): bump shadcn from 4.9.0 to 4.13.0
Bumps [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn) from 4.9.0 to 4.13.0.
- [Release notes](https://github.com/shadcn-ui/ui/releases)
- [Changelog](https://github.com/shadcn-ui/ui/blob/main/packages/shadcn/CHANGELOG.md)
- [Commits](https://github.com/shadcn-ui/ui/commits/shadcn@4.13.0/packages/shadcn)

---
updated-dependencies:
- dependency-name: shadcn
  dependency-version: 4.13.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-14 10:28:31 +00:00
Marc-André Bombeck 14a9a08a6e Merge pull request #482 from MBombeck/dependabot/npm_and_yarn/types/node-26.1.1
chore(deps-dev): bump @types/node from 26.1.0 to 26.1.1
2026-07-14 12:26:33 +02:00
Marc-André Bombeck 0b7d07dce4 Merge pull request #481 from MBombeck/dependabot/npm_and_yarn/lefthook-2.1.10
chore(deps-dev): bump lefthook from 2.1.9 to 2.1.10
2026-07-14 12:26:30 +02:00
Marc-André Bombeck bdae462948 Merge pull request #480 from MBombeck/dependabot/npm_and_yarn/simplewebauthn/server-13.3.2
chore(deps): bump @simplewebauthn/server from 13.3.1 to 13.3.2
2026-07-14 12:26:28 +02:00
Marc-André Bombeck bb1008f1a1 Merge pull request #478 from MBombeck/dependabot/github_actions/docker/login-action-4.4.0
chore(deps): bump docker/login-action from 4.2.0 to 4.4.0
2026-07-14 12:26:25 +02:00
Marc-André Bombeck ac1105751d Merge pull request #488 from MBombeck/release/v1.28.33
release/v1.28.33 — re-importing a cumulative Apple Health export works again
2026-07-14 12:09:13 +02:00
Marc-André Bombeck 6d8fe06148 chore(release): v1.28.33 — re-importing a cumulative Apple Health export works again
Rollup writes were delete-then-createMany inside a transaction; under READ
COMMITTED a concurrent recompute racing the import's full fold on the same
(user, type, granularity, bucket, source) partition tripped the composite
PK — and the P2002 swallow dropped the loser's freshly computed aggregate,
leaving a stale bucket. persistRollupRows now writes chunked
INSERT … ON CONFLICT DO UPDATE with deterministic (type, source, bucket)
lock ordering inside one transaction: a race can neither fail the import
nor lose an aggregate, and previously-staled buckets self-heal on their
next recompute. The vanished-source delete and in-transaction guarantees
are preserved; the now-unreachable P2002 swallow is removed.

The masked ENOENT is gone with it: long imports outlived the queue's
15-minute expiry, pg-boss redelivered the still-running job, and the
redelivery re-opened the consumed staging file — overwriting the real
terminal state with a raw ENOENT. Import sends now carry retryLimit 0 +
6h expiry (a retry after the staging file is consumed can only mask),
deliveries for terminal ImportJob rows are no-ops, a genuinely missing
staging file reports an honest re-upload message, and the extracted XML
is cleaned up on failure too.
2026-07-14 11:56:29 +02:00
Marc-André Bombeck b57c517929 docs: self-hosting SSO guide
Setup walk (byte-exact redirect URI), client-registration essentials
for Authentik / Keycloak / Authelia / Pocket-ID, the security model
(identity pinned to (issuer, sub) after a one-time verified-email link;
SSO sessions never satisfy step-up; native-2FA accounts still get their
second-factor step; MFA policy belongs at the IdP), the OIDC_ONLY
consequences incl. the iOS lockout and the break-glass runbook, and a
troubleshooting table for the recurring failure shapes (issuer
trailing slash, unfollowed discovery redirects, clock skew,
IdP-side redirect_uri mismatch).
2026-07-14 11:55:47 +02:00
Marc-André Bombeck 7592c73947 fix(auth): harden the OIDC token path and state-cookie lifecycle
Token verification: jwtVerify now carries a closed asymmetric algorithm
allowlist (RS256/PS256/ES256/EdDSA — an HMAC alg would let anyone with
the client secret forge identities) and a 60 s clock tolerance for the
usual self-host drift between IdP and app box. The JWKS fetch — jose's
own, outside safeFetch by design — is disciplined instead by pinning
the jwks_uri host to the configured issuer's host byte-exact and
passing the same 15 s timeout safeFetch defaults to. requirePublicHost
stays deliberately unused on this surface: LAN/Tailscale-only IdPs are
a legitimate topology and the issuer is operator config, not user
input.

Discovery: the issuer comparison now tolerates exactly one trailing
slash on either side (a recurring IdP/operator mismatch); token
verification still matches the provider-sent issuer byte-exact.

State cookie: the cookie is set under Path=/api/auth/oidc but was
deleted without a path — RFC 6265 keys cookies by (name, domain,
path), so the deletion never matched and the single-use blob survived
its whole TTL. Every delete now repeats the exact path, via a shared
constant.

Also: the IdP error code recorded in the wide event is whitelisted
against the closed OAuth error-code set (unknown values collapse to
"other"); the status route drops an unreachable try/catch and a
duplicated annotate; the passkey conditional-UI autofill stays unarmed
under OIDC_ONLY (passkey login is a server-enforced dead end there);
env-manifest gains the three optional OIDC_* tuning vars; the env
example notes why the issuer should be HTTPS.
2026-07-14 11:53:53 +02:00
Marc-André Bombeck 9b8b31c864 fix(auth): OIDC sessions never satisfy MFA step-up; 2FA accounts get the challenge step
Two second-factor gaps in the OIDC callback:

The session was minted with mfaVerifiedAt stamped to the login instant,
letting a bare SSO login satisfy requireFreshMfa step-up (MFA disable,
encryption-key rotation, encrypted export, account deletion). OIDC is a
delegated factor — whatever the IdP checked, this app did not see a
second factor — so the stamp is now null, the same treatment a
trusted-device login gets.

An account with a confirmed TOTP secret or a registered security key
was logged straight in, silently downgrading its native 2FA to
IdP-only. The callback now mints the same single-use MFA ticket
password login uses and hands it to the login page through a
short-lived, login-page-scoped cookie (the callback ends on a browser
redirect, so the ticket cannot ride an envelope meta like the password
flow). The login page picks it up once, clears it, and renders the
same challenge step; the login completes at /api/auth/mfa/verify with
identical continuation semantics. The cookie is deliberately not
httpOnly — the login page script must read the ticket, exactly the
exposure the password flow already has — and the ticket alone carries
no session authority (single-use, hashed at rest, 5-minute TTL,
attempt-capped).

Direct (factor-less) OIDC logins now also sync the admin-enforced-MFA
enrollment hint cookie, matching password login.
2026-07-14 11:46:40 +02:00
Marc-André Bombeck fa326c398a feat(auth): pin OIDC accounts to their (issuer, sub) identity
An OIDC login now resolves the account by the durable (issuer, sub)
pair instead of the email claim. First contact stamps the pair — by
auto-provision or a one-time link against an existing account matched
by verified email — and every later login matches on it alone, so an
IdP-side email change refreshes the display email but can never
re-point the login at a different account. An email that matches an
account already bound to a different identity is rejected and audited
(auth.oidc.link_conflict), never silently re-bound.

The email gate tightens with it: linking and provisioning now require
an explicit email_verified: true — an absent claim is a reject
(oidc_email_unverified) rather than a benefit of the doubt, since an
IdP that does not assert verification cannot anchor the account match.
Provisioned accounts get a lowercased email and the same server-default
timezone resolution the register route uses.

Migration 0240 adds the nullable users.oidc_issuer / users.oidc_sub
columns with a composite unique index.
2026-07-14 11:43:53 +02:00
Marc-André Bombeck 5b7ea48c46 docs: PR template targets main — the develop branch was retired 2026-07-14 10:36:25 +02:00
Balaji Vijayakumar f815e4a83f feat(auth): add OIDC SSO login
Adds an OpenID Connect relying-party login path (PKCE authorization-code
flow) against a single env-configured identity provider — additive to
password/passkey login by default, with an operator opt-in (OIDC_ONLY,
enforced server-side on every auth route) to disable them entirely.
First-time sign-in auto-provisions or links an account by verified email.

Closes an open redirect in the post-login `next` parameter (both the new
OIDC path and the pre-existing password/passkey flow shared the same
backslash-normalization bypass) by resolving the target through a real
URL parse and comparing origins instead of a prefix check.
2026-07-13 16:33:13 +05:30
Marc-André Bombeck 6712619495 Merge pull request #484 from MBombeck/release/v1.28.32
release/v1.28.32 — Fitbit: re-keyed re-imports can no longer be silently dropped
2026-07-12 20:42:48 +02:00
Marc-André Bombeck a70139c16a chore(release): v1.28.32 — Fitbit re-keyed re-imports can no longer be silently dropped
Single-fix release mirroring the Google Health natural-key rescue from
v1.28.28 into the Fitbit sync. The measurements natural-key unique index
(user, type, measured_at, source, sleep_stage — NULLS NOT DISTINCT,
migration 0055) covers tombstoned rows, so a re-import carrying NEW
externalIds for previously-removed rows collided on the natural key and
skipDuplicates dropped the insert silently. Every planned create now gets
a second chunked probe by natural key with no deletedAt filter; a match —
live or tombstoned — is updated in place with the fresh value, the new
externalId, and deletedAt: null. Probe failure notes the hard-failure
ledger so the watermark holds and the next tick retries. No other code
path touched.
2026-07-12 20:30:12 +02:00
dependabot[bot] c41ca511cb chore(deps-dev): bump @types/node from 26.1.0 to 26.1.1
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 26.1.0 to 26.1.1.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-12 15:55:10 +00:00
dependabot[bot] 9ba34befce chore(deps-dev): bump lefthook from 2.1.9 to 2.1.10
Bumps [lefthook](https://github.com/evilmartians/lefthook) from 2.1.9 to 2.1.10.
- [Release notes](https://github.com/evilmartians/lefthook/releases)
- [Changelog](https://github.com/evilmartians/lefthook/blob/master/CHANGELOG.md)
- [Commits](https://github.com/evilmartians/lefthook/compare/v2.1.9...v2.1.10)

---
updated-dependencies:
- dependency-name: lefthook
  dependency-version: 2.1.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-12 15:54:35 +00:00
dependabot[bot] a21dc50cab chore(deps): bump @simplewebauthn/server from 13.3.1 to 13.3.2
Bumps [@simplewebauthn/server](https://github.com/MasterKale/SimpleWebAuthn/tree/HEAD/packages/server) from 13.3.1 to 13.3.2.
- [Release notes](https://github.com/MasterKale/SimpleWebAuthn/releases)
- [Changelog](https://github.com/MasterKale/SimpleWebAuthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/MasterKale/SimpleWebAuthn/commits/v13.3.2/packages/server)

---
updated-dependencies:
- dependency-name: "@simplewebauthn/server"
  dependency-version: 13.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-12 15:53:54 +00:00
dependabot[bot] 4dfb207a6d chore(deps): bump docker/login-action from 4.2.0 to 4.4.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.2.0 to 4.4.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/650006c6eb7dba73a995cc03b0b2d7f5ca915bee...af1e73f918a031802d376d3c8bbc3fe56130a9b0)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-12 15:52:32 +00:00
Marc-André Bombeck 14b61e1084 Merge pull request #477 from MBombeck/release/v1.28.31
release/v1.28.31 — heart-rate history keeps its daily shape
2026-07-12 12:19:23 +02:00
Marc-André Bombeck 1375d20a9d chore(release): v1.28.31 — heart-rate history keeps its daily shape
Long-term retention for the dense intra-day tier (PULSE, HRV, SpO2) folds
to per-local-hour means instead of a single daily mean: the last 14 days
keep every raw sample, older days keep up to 24 points per metric — the
day's shape survives permanently at ~9k rows/metric/year. Hourly rows key
as stats:<HK>:<YYYY-MM-DD>T<HH> anchored at local HH:30, adopt-in-place
against both unique indexes with the P2002 retry outside the transaction.
The pre-fold DAY-rollup recompute extends to HRV (mean fidelity, not only
min/max) and the post-fold recompute is gone for the whole tier. Derived
resting rows for proxy users still mint from the day's raw samples.

A one-shot rebuild reconstructs the hourly shape for every already-folded
day from the soft-deleted raw samples and retires the daily row in the
same transaction — at no instant are both grains live, so no
AVG-over-live-rows reader can double-count. Boot discovery pairs live
daily-grain rows with surviving tombstones (parameter-bound SQL, pinned
against real Postgres) and converges to zero once converted; days whose
tombstones were pruned keep their daily mean. PULSE/SpO2 DAY buckets keep
the true pre-fold stats; HRV DAY buckets recompute from the hourly rows
(strictly better than the collapsed fold-time bucket); WEEK/MONTH/YEAR
respans enqueue whole-bucket recomputes.
2026-07-12 12:07:41 +02:00
Marc-André Bombeck 85267f15b3 Merge pull request #476 from MBombeck/release/v1.28.30
release/v1.28.30 — the daily briefing stops silently skipping days
2026-07-12 11:20:46 +02:00
Marc-André Bombeck a4feabec81 chore(release): v1.28.30 — the daily briefing stops silently skipping days
The recurring no-briefing day had a chain of causes, closed end to end:

Every failure path in the nightly pregenerate + the comprehensive generator
now annotates a queryable cause (several classes — features-error,
payload-too-large, aborted, generator-failed — previously bumped a counter
with zero trace). A failed nightly warm enqueues one bounded retry ~45
minutes later (singleton per user, existing budgets apply), so a transient
provider hiccup heals before morning. A cached payload that carries NO
briefing no longer satisfies the 24h POST short-circuit nor the
unchanged-content hash gate — a once-stripped briefing could previously
block its own regeneration indefinitely. The card surfaces the omission
reason persistently (grounding strip, generation failure, not attempted)
with a forcing retry.

Documentation corrected against code truth: Withings credentials are
per-user (the documented env path never existed; the dead example entries
and manifest rows are gone, and WITHINGS_WEBHOOK_SECRET is now actually
whitelisted in compose — stock deploys could never deliver it); the classic
Fitbit portal no longer accepts new registrations; the backup-restore
example no longer mints a fresh encryption key and uses pnpm dlx tsx; the
TLS guide describes the real client model (two CA-level SPKI pins,
any-match, leaf renewals need no build); scaling/admin references fixed;
the operator-shared subscription provider is documented.
2026-07-12 11:08:59 +02:00
Marc-André Bombeck 14a517387e Merge pull request #475 from MBombeck/release/v1.28.29
release/v1.28.29 — dashboard edits show up on the way back; one scrollbar again
2026-07-11 12:16:24 +02:00
Marc-André Bombeck ae57025728 chore(release): v1.28.29 — dashboard edits show up on the way back; one scrollbar again
The third and final layer of the stale-tile bug: saving invalidated the
dashboard snapshot with the default refetchType 'active', but the dashboard
page is unmounted while the user sits in settings — the cell only went stale,
and the snapshot query's deliberate refetchOnMount: false then suppressed the
re-read on same-tab navigation back. The save, reset and ring mutations now
invalidate with refetchType 'all', refreshing the cell mounted or not
(proven live in one SPA session against the real query client).

The sortable settings editors (dashboard layout, medication order, modules,
mood tags x2) painted a second page scrollbar: the sr-only drag hint below
each list is position: absolute, no ancestor was positioned, so it resolved
against the initial containing block, escaped the shell's overflow clipping
and lengthened the DOCUMENT by its static offset. The list wrappers are now
relative; the e2e overscroll guard asserts the one-scroll-floor on these
routes and was negative-tested against the revert.
2026-07-11 12:04:53 +02:00
Marc-André Bombeck 16f7869ffa Merge pull request #474 from MBombeck/release/v1.28.28
release/v1.28.28 — OpenAI-compatible gateways work; a re-keyed night can no longer vanish
2026-07-11 10:52:22 +02:00
Marc-André Bombeck 8ee9c53b83 fix(dashboard): refetch the snapshot on window focus
The dashboard is exactly the surface a user leaves open in a background tab
while editing its tile selection elsewhere; with focus refetch off, the stale
layout sat visible for up to the poll interval — a save appeared to do
nothing, then 'worked' minutes later. staleTime still bounds the refetch to
once a minute and the server side is SWR-cached, so a focus flick is a cheap
cache hit. Chart batch series stay on the poll (heavier payloads; the tile
selection itself lives on the snapshot query).
2026-07-11 10:39:24 +02:00
Marc-André Bombeck 13464b512e chore(release): v1.28.28 — OpenAI-compatible gateways work; a re-keyed night can no longer vanish
The Local provider speaks the standard JSON wire: response_format by default,
one self-healing fallback per endpoint when a server rejects it (dialect
cached per base URL) — LiteLLM, OpenRouter, vLLM, LM Studio and plain Ollama
all work from the same form, and the form + provider docs now say so. A
gateway that shims a Claude-family model behind a synthesized tool call is
parsed from the tool-call arguments instead of yielding silently empty
insights.

The insight token budget is configurable (INSIGHTS_MAX_TOKENS, default 2500,
clamped 500-8000) and unified across all six call sites; a reply cut off
mid-JSON returns a distinct truncation error instead of a generic parse
failure. A briefing withheld by the number-grounding check now says so on the
card instead of pretending nothing was generated. The provider test button
answers 4xx with a bad_request category instead of claiming the endpoint was
unreachable.

Sleep repair follow-up: measurements carries a second full unique index — the
natural key (type, measured_at, source, sleep_stage) — so a key-format
migration could tombstone the old rows while their re-keyed replacements
collided with that index and were silently dropped (live incident: a
full-history re-sync erased a user's sleep). Planned creates now run a
natural-key rescue probe: a match, live or tombstoned, is updated in place —
fresh value, the new externalId, deletedAt cleared. A rescue-probe failure
holds the watermark so nothing is lost past the overlap.
2026-07-11 10:36:02 +02:00
Marc-André Bombeck 132ede8833 Merge pull request #473 from MBombeck/release/v1.28.27
release/v1.28.27 — runs on CPUs without AVX2
2026-07-11 10:15:46 +02:00
Marc-André Bombeck 45da6c3c7d chore(release): v1.28.27 — runs on CPUs without AVX2
The bundled Skia x64 build (document thumbnails, scanned-PDF rasterization)
uses AVX2 instructions; on an x86-64 host without them — Celeron/Atom-class
NAS CPUs, pre-2013 Xeons, VMs masking CPU flags — the first canvas call is an
uncatchable SIGILL that kills the process and crash-loops the container (live
incident: a Synology DS1520+, exit 132 ~18 s after every boot, reported with
kernel traps and the correct root cause in hand).

The renderer is now gated on a one-time CPU-feature check before the module
ever loads: linux/x64 requires the avx2 cpuinfo flag; arm64 and non-Linux are
unaffected; unreadable cpuinfo fails open. Behind a closed gate, thumbnail
generation and PDF rasterization take their existing fail-soft paths (vault
shows type icons; PDFs read as text) and the boot-time thumbnail backfill
skips enqueueing. NATIVE_CANVAS=off|on overrides detection, documented in the
env example and whitelisted in compose.
2026-07-11 10:02:29 +02:00
Marc-André Bombeck da0e509095 Merge pull request #471 from MBombeck/release/v1.28.26
release/v1.28.26 — internal restructuring for maintainability
2026-07-11 08:55:16 +02:00
Marc-André Bombeck 5433fc8fbd chore(release): v1.28.26 — internal restructuring for maintainability
No user-facing change. Eight of the largest source files split along their
natural seams into focused modules, every move verbatim with stable import
paths:

- mood analytics calculators (mood-tag-influence / mood-crosstab /
  mood-better-days / mood-patterns; hub 2229 → 770 lines)
- insight status-invalidation machinery (status-invalidation.ts)
- feature extraction (feature-blocks / signals-of-day)
- doctor report types + pure helpers (doctor-report-types /
  doctor-report-helpers; collector 1756 → 1002)
- Google Health mapping layer (mappers.ts; client 2153 → 1022, acyclic
  client → mappers with type-only back-imports)
- coach chat bubbles + read-aloud controls (chat-bubble / read-aloud;
  message-thread 1713 → 464)
- Telegram webhook handlers to lib (route 1307 → 92 — handler thin, logic in
  lib; rate-limit-before-secret ordering byte-identical)
- coach snapshot cache, series helpers and the four largest per-metric blocks
  (snapshot-cache / snapshot-series / snapshot-blocks; 2691 → 1896)

The glucose-panel-window structural test follows the moved alias; two unused
imports left by the feature split are removed.
2026-07-11 08:43:41 +02:00
Marc-André Bombeck 4303ddaf37 Merge pull request #469 from MBombeck/release/v1.28.25
release/v1.28.25 — every integration held to the same standard
2026-07-11 02:05:44 +02:00
Marc-André Bombeck 9c63d3ce20 chore(release): v1.28.25 — every integration held to the same standard
Platform-wide hardening: the failure classes found live this week hunted down
across every integration.

Fitbit carried the same tombstone wedge fixed for Google — probe now matches
soft-deleted rows and the update resurrects them; the comment describing a
partial unique index that never existed is corrected. Sleep segment ids are
stable across re-scoring for Withings (session+segment-start), WHOOP/Polar
(one segment per stage, no positional index) and Oura (run-start keyed), each
with a session-scoped sweep that soft-deletes rows a re-score orphaned — so
legacy and affected nights heal as they are re-read.

One platform rule for deleted rows: source-owned readings resurrect on
re-import (Withings, WHOOP, Oura, Polar, Nightscout, mood webhook, CSV
re-imports, Apple stats day-totals); Apple sample-grain deletions stick per
the paired-client contract, and the mood bulk route's tombstone match is now
a true no-op reported as duplicate. Import counts reconcile against actual
createMany counts.

Google lifecycle: a dead token registers on the cycle's hard-fail ledger, so
an expired connection parks instead of stamping success hourly; backfill and
sleep-repair markers stamp only after a clean pass (pg-boss retries are now
real); failed measurement writes hold the watermark; no-op updates are
skipped; mapping.md tells the current truth.

Scale: doctor-report reads only consumed columns and enabled sections;
exports and backups keyset-paginate; achievements tally via SQL buckets;
series charts day-bucket dense kinds beyond 90 days; BP pairing is a linear
two-pointer; distinct-type scans run server-side; the graded-series cold
fallback is bounded; the fitbit defer tracker loops instead of spreading.
2026-07-11 01:54:12 +02:00
Marc-André Bombeck 1085a1e056 Merge pull request #468 from MBombeck/release/v1.28.24
release/v1.28.24 — a deleted Google reading no longer blocks its re-import
2026-07-11 00:46:35 +02:00
Marc-André Bombeck 2869d0ab27 chore(release): v1.28.24 — a deleted Google reading no longer blocks its re-import
The measurements unique index on (userId, type, source, externalId) is FULL —
it covers soft-deleted rows — so a tombstoned Google row permanently owned its
key: the live-only probe treated it as absent, planned a fresh insert, and
createMany's skipDuplicates dropped that insert silently against the
tombstone's key. Every sync, forever. Live incident: a self-hoster's step days
were stuck exactly this way (five tombstoned dates, confirmed in their data).

The probe now matches tombstoned rows too and the update branch carries
deletedAt: null unconditionally — a no-op on a live row, a deliberate
resurrection on a tombstoned one. Google remains the source of truth for its
own rows; removing Google data permanently means disconnecting the
integration. The stale 'partial unique index' comments are corrected.
2026-07-11 00:35:50 +02:00
964 changed files with 85900 additions and 24337 deletions

No files matched your search

+47 -3
View File
@@ -29,10 +29,11 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# -----------------------------------------------------------------------------
# Withings OAuth -- optional integration
# Withings -- optional integration
# -----------------------------------------------------------------------------
# WITHINGS_CLIENT_ID=""
# WITHINGS_CLIENT_SECRET=""
# The Withings OAuth client id/secret are per-user: each user registers their
# own Withings developer app and pastes the pair into Settings (stored
# encrypted in the DB). There is therefore no WITHINGS_CLIENT_ID/SECRET.
# WITHINGS_WEBHOOK_SECRET=""
@@ -102,6 +103,35 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# STRAVA_CLIENT_SECRET=""
# STRAVA_REDIRECT_URI=""
# -----------------------------------------------------------------------------
# OIDC SSO login -- optional (self-hosted identity provider)
# -----------------------------------------------------------------------------
# Register a confidential OIDC client for your IdP (Authentik, Keycloak,
# Authelia, Google Workspace, etc.) with redirect URI
# `${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback`. All three of ISSUER_URL/
# CLIENT_ID/CLIENT_SECRET together enable the "Sign in with SSO" button on
# the login page; set none to leave it disabled. First-time sign-in
# auto-provisions (or links, by verified email) a HealthLog account. OIDC_ONLY
# hides password/passkey login entirely once set to "true" -- but only takes
# effect when the three vars above are also fully set, so a typo can't lock
# every user out. Enforced server-side, not just a hidden button. The native
# iOS client signs in through the native OIDC SSO handoff (the app opens the
# login URL in an in-app browser and completes at a custom-scheme callback), so
# OIDC_ONLY="true" is supported for iOS -- but only on the app build that ships
# that flow. An older password/passkey-only iOS build is locked out of sign-in
# by OIDC_ONLY; confirm the app version before enabling it on a deployment with
# active iOS users.
# Serve the issuer over HTTPS wherever possible: sign-in material (the
# authorization code, the token-endpoint response carrying the ID token)
# crosses this connection. Plain HTTP is defensible only on a fully trusted
# private network (IdP and app on the same box, a Tailscale mesh, a VPN).
# OIDC_ISSUER_URL=""
# OIDC_CLIENT_ID=""
# OIDC_CLIENT_SECRET=""
# OIDC_SCOPES="openid email profile"
# OIDC_BUTTON_LABEL="Single Sign-On"
# OIDC_ONLY="false"
# -----------------------------------------------------------------------------
# Web Push -- VAPID keys (optional; the admin panel is the easier path)
@@ -184,6 +214,11 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# self-hosted Ollama / LM Studio endpoint. See docs/integrations/ai-providers.md.
# ALLOW_LOCAL_AI_PRIVATE_HOSTS=""
# Daily-briefing output-token ceiling (optional). Default 2500, clamped to
# 500-8000. Raise it when a verbose model's briefing JSON gets cut off
# ("AI response was cut off"). See docs/integrations/ai-providers.md.
# INSIGHTS_MAX_TOKENS=""
# -----------------------------------------------------------------------------
# Off-host backups -- all-or-none
@@ -270,3 +305,12 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# force the public-host guard, so an internal instance is not blocked).
# OPENMETEO_BASE_URL="https://archive-api.open-meteo.com"
# OPENMETEO_GEOCODING_URL="https://geocoding-api.open-meteo.com"
# ── Native canvas renderer (document thumbnails, scanned-PDF rasterization) ──
# The bundled renderer's x64 build uses AVX2 CPU instructions. On x86-64 hosts
# WITHOUT AVX2 (Celeron/Atom-class NAS CPUs) it is disabled automatically and
# those features degrade cleanly (no thumbnails; scanned PDFs read as text
# only). Override only if auto-detection misfires:
# off — force-disable the renderer
# on — force-enable (crashes the container if the CPU truly lacks AVX2)
# NATIVE_CANVAS="off"
+1 -1
View File
@@ -28,7 +28,7 @@
## Checklist
- [ ] Targets the `develop` branch (not `main` — see [CONTRIBUTING.md](../CONTRIBUTING.md))
- [ ] Targets the `main` branch (trunk-based — see [CONTRIBUTING.md](../CONTRIBUTING.md))
- [ ] `pnpm typecheck` passes locally
- [ ] `pnpm lint` passes locally
- [ ] `pnpm test` passes locally
+2 -2
View File
@@ -98,7 +98,7 @@ jobs:
# Image is built but not pushed in that case.
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -293,7 +293,7 @@ jobs:
echo "IMAGE_REF=${REGISTRY}/${IMAGE_NAME,,}" >> "$GITHUB_ENV"
- name: Log in to GitHub Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
+1 -1
View File
@@ -70,7 +70,7 @@ jobs:
fi
- name: Log in to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+809
View File
@@ -2,6 +2,815 @@
## [Unreleased]
## [1.30.15] — 2026-07-18
Generated health texts now come in the language you set.
- **French, Spanish, Italian and Polish accounts get their assessments in their own language.** Every locale other than English previously fell back to the German instruction set, so those four either read German text or — where the pipeline narrowed them earlier — English text. Insight cards, the per-metric assessments, derived scores, the biomarker card, the period narrative and the assistant's tone settings all now follow the account's own language, with the wording rules that were already translated for each of the six languages. German and English output is unchanged, character for character.
- **The language instruction is the last thing the model reads**, rather than being buried before the metric-specific section that follows it.
- Under the hood: one shared resolver replaces the scattered English-or-else-German branches, and the locale now survives the whole generation path instead of being narrowed on the way in. Stored texts for the four languages regenerate on their next scheduled run; German and English keep reading exactly the rows they already had.
No breaking changes.
## [1.30.14] — 2026-07-18
The workouts page joins the pages that paint on first load.
- **The workouts list opens with the sessions already there.** It was the last high-traffic surface still fetching its rows after the page mounted, so it showed a loading skeleton first and filled in a moment later. The list is now read on the server and handed to the page populated, the same way the dashboard, insights, medications, and coach pages already work. The list logic moved into a shared read so the page and the API endpoint go through one cached, deduplicated pass instead of two.
- **API docs: the heart-rate bucket example matches what the server accepts.** The aggregated heart-rate `externalId` was documented with an hourly example from its first version; the contract has been 10-minute buckets since v1.30.7. The description, the example timestamp, and the min/max field notes now describe the shipped contract.
No breaking changes.
## [1.30.13] — 2026-07-18
More pages that paint on first load.
- **The insights, medications, and coach pages open with their content already there**, the same way the dashboard now does — their above-the-fold data is read on the server and handed to the page populated, instead of a shell that fills in after a second round of requests. Nothing that would trigger AI work is pre-run: the daily briefing and the coach's own reads stay on-demand as before.
No breaking changes.
## [1.30.12] — 2026-07-18
Faster medication reads.
- **The dashboard's medication section stays fast as your log grows.** Its reads now use dedicated indexes instead of scanning the full intake history on every load, so a long history no longer slows down the daily view.
No breaking changes.
## [1.30.11] — 2026-07-18
Native single sign-on.
- **The iOS app can sign in through your identity provider (OIDC SSO).** Until now, single sign-on only worked in the browser — the native app couldn't complete it, which locked native users out entirely on an SSO-only instance. The server now supports a native sign-in handoff (an in-app authorization flow that returns the app's normal token, never a cookie), so the app can offer SSO once it adopts the flow. Existing password and passkey sign-in are unchanged. Multi-factor sign-in still applies, and an SSO session never satisfies a step-up prompt — those still require your own second factor.
No breaking changes.
## [1.30.10] — 2026-07-18
Localized insight text, and a richer workout view.
- **Correlation insights now read in your language.** The "this tends to go with that — a pattern worth watching, not a cause" lines under a metric were always written in English; they now render in your interface language (German, Spanish, French, Italian, Polish), with the association-not-causation framing preserved in each.
- **A workout you open shows more of what was recorded.** The detail view now draws the session's heart-rate curve over time, its route as a clean line, the time spent in each effort zone, per-kilometre splits, and how it compares to your own average for that sport — all from data already synced, with no new connection. Anything a given workout didn't record simply isn't shown.
No migration. No breaking changes.
## [1.30.9] — 2026-07-18
Faster dashboard first load.
- **The top card and the charts paint with their data on first load** instead of flashing empty and then filling in. The daily summary and the charts' 31-day series are now read on the server and handed to the page already populated, so there's no wait for a second round of requests after the page appears. The welcome/summary card, likely the slowest thing to show before, now lands with the first paint.
No migration. No breaking changes.
## [1.30.8] — 2026-07-18
Hardening for the new heart-rate upload validation.
- **A malformed bucket timestamp is rejected, not silently misfiled.** A bucket whose time rolled over (a 24:00, an impossible day) is now refused rather than stored under a non-standard key, where it would have failed to merge with a re-post and could land on the wrong day.
- **The per-window low/high is sanity-checked before it counts.** A window's low and high are kept only when they are in a plausible range and sit either side of the average; a glitched reading no longer skews the day's high/low band. The average is always kept.
No migration. No breaking changes.
## [1.30.7] — 2026-07-18
Groundwork for a tidier heart-rate history.
- **The server accepts a 10-minute aggregated heart-rate upload.** A watch records heart rate every few seconds, and uploading one row per raw sample fills the measurements list with the same value repeated many times a minute. The server now takes one row per 10-minute window — its average, with the low and high of the window — and the within-day pulse curve reads it directly, so the detailed 10-minute chart and the elevated-at-rest note are preserved. The de-duplication reaches the list once the mobile app sends the aggregated shape; existing per-sample history is unaffected and reconciles itself over time.
- **The daily high/low reflects the true spread.** A day built from 10-minute windows now reports the real intra-window extremes, not the range of the averages.
No migration. No breaking changes.
## [1.30.6] — 2026-07-18
Accessibility pass.
- **Heatmaps read to a screen reader.** The mood and medication-compliance grids now expose each recorded day — its date and value — through a hidden day list, so the per-day figures are no longer reachable by pointer only. The chart looks exactly the same.
- **Small labels meet the minimum size.** Muted meta text across settings, admin, the coach panel, and the insights cards is lifted off the sub-`text-xs` sizes; tabular figures and chart chrome are unchanged.
- **A couple of section headings.** Two metric-page sections became proper headings, so heading navigation lands where it should.
No migration. No breaking changes.
## [1.30.5] — 2026-07-18
Making a shipped feature findable, and a sync-status fix.
- **ECG has a home you can reach.** Recordings now have their own page in Insights, a pill in the Heart group that appears once you have a recording, and links to it from the resting-pulse and heart-rate-variability pages. Before this they were reachable from a single spot, and a deep link into the overview could land before the section had drawn. The page is unchanged in substance — the same non-diagnostic, device-attributed view.
- **The "last synced" time stops lagging.** A sync driven by an incoming update now records the connection's last-success time straight away, instead of leaving it frozen until the next full poll — so the Settings status and the assistant's integration read reflect a sync that already happened.
No migration. No breaking changes.
## [1.30.4] — 2026-07-18
The read-only assistant connector now reaches the full record.
- **More of your data is readable.** The connector gained direct reads for nutrition and vitamins, the intraday pulse curve, and stored ECG recordings, plus discovery for a set of device metrics that were collected but not advertised — wrist temperature, cardio recovery and load, day and workout strain, sleep score, breathing disturbances, fall count, the six-minute-walk distance, stair speeds, and energy expenditure. They can now be compared and baselined by name.
- **Correctness.** Heart-rate variability resolves whether the source records the SDNN or the RMSSD flavour, so a ring- or strap-only account is no longer reported as having none. A workout logged by two devices is counted once, matching the workouts list — this also corrects the figure the coach sees.
- **Exposure stays deliberate.** The mental-health screeners and the environmental signals remain off the connector by construction; a metric is only readable once it has been named for it.
No migration. No breaking changes.
## [1.30.3] — 2026-07-18
Follow-up fixes from a full quality re-check of the app.
- **Correctness reaches every surface.** The timezone, window, and grain fixes now also cover the coach's correlations, the per-metric cards, and the period narrative (they had kept an older copy). Daily insight cards roll over at your own midnight, not a fixed one. Personal records that a second device had inflated are re-derived on the next start, so a genuine record can be set again.
- **The metrics catalog** shows a loading state and a retry on error, instead of briefly reading as "not tracked".
- **Small.** One shared info popover, larger tap targets on the view toggles, a notch-safe height fix on the coach history page, and a few wording and translation gaps closed (a body-composition caveat, study citations, the backup scope note).
No breaking changes.
## [1.30.2] — 2026-07-18
More audit-backlog fixes.
- **Coach history is fully reachable.** The conversation list pages through your whole history and search runs on the server (by title), instead of only the first ~20 loaded chats.
- **Navigation and flows.** A document links to the lab values it produced; the two "Recovery" pages are named distinctly and cross-linked; mood, mental wellbeing, and mood insights link to each other; the nutrients card moved out of "Source priority"; the onboarding checklist and tour no longer point at dead ends; asking the coach from a metric or a plan carries that context in.
- **Correctness.** Correlations, the intraday chart, achievements, and the nutrients overview now bucket days by your own timezone and lead with the most recent data; a multi-source day is no longer counted twice.
- **Performance.** The workouts list caches its de-duplication per filter, so paging through a long history is quick.
No migration. No breaking changes.
## [1.30.1] — 2026-07-18
Polish and small fixes.
- **Mobile.** Coach and document text wraps instead of overflowing; the intake history is a card list on small screens instead of a sideways-scrolling table; tap targets meet the minimum size; two dialogs became bottom sheets; the workout detail header stacks cleanly.
- **Feedback.** Loading skeletons match the content that replaces them (no more layout jump), and the Today priority-card actions show a pending state and can’t fire twice on a double-tap.
- **Remembered choices.** A chart’s time range and the measurement type you last logged are remembered; a coach chat deletes with an Undo; a backdated dose can be undone; lab panels get “Save & add another”; pull-to-refresh works on more pages (and a scroll-position bug that made it fire anywhere is fixed).
- **Consistency, accessibility, wording.** Spacing and contrast tidied, more empty states use the shared component, headings and focus rings improved, a few German phrasings corrected, the daily line reads more calmly, and several explanations gained their guideline citation.
No migration. No breaking changes.
## [1.30.0] — 2026-07-18 — Quality pass
A broad correctness, performance, and polish release, plus two new features. Nothing new is required of you; existing data is untouched.
### Added
- **Document summaries.** An uploaded document can get a short plain-language summary, generated once at upload and stored encrypted. It runs only when the external-AI document reading you already opted into is on, and never touches older documents.
- **Efficacy chart, readable.** The medication efficacy chart scales its axis to the data (a real trend no longer looks flat), gains a “% since start” view, and the history tab reaches your full intake history, not just the last 90 days.
- **A metric catalog.** A quiet “All metrics” view lists everything HealthLog can track and which device supplies each — so a metric you don’t have data for stays out of your daily views but is still discoverable in one place.
### Fixed — correctness
- **The number shown matches reality.** The workouts and measurements lists now lead with the most recent entries (a long history no longer hid recent days), and a metric recorded by two devices on the same day is counted once, matching the dashboard — instead of being summed twice.
- **Strava workouts carry their real sport** (cycling, running, …) — like WHOOP already did — with a history backfill, and back-to-back rides no longer collapse into one.
- **Personal records and correlations** no longer double-count multi-source days or mix per-reading and daily-total values.
### Fixed — medical safety
- The hypoglycemia alert now uses your own glucose unit; the doctor-report PDF states your own timezone; the crisis card offers Austrian and Swiss lines, not only German ones; and resting-pulse labels drop clinical-verdict wording for calmer, neutral framing.
### Fixed — the daily view
- The Today view is faster (its heaviest reads are now cached instead of recomputed on every refresh) and updates immediately after you log a measurement, mood, or water — not only medication.
- The intraday heart-rate chart no longer draws a continuous shape across gaps in sparse data, and shows how many readings it’s based on. A failed load shows a retry instead of an empty state.
### Fixed — onboarding, dates, consistency, accessibility
- Connecting a device no longer dead-ends on a fresh account, and the setup steps (including the callback URL) are shown up front.
- A date from a previous year now shows the year, everywhere it’s displayed.
- Contrast, chart colours in the light theme, and a handful of duplicated components were tidied; keyboard access and headings improved on several surfaces; the insights and settings menus are grouped more sensibly.
### Changed
- **A fuller backup.** The full backup now also includes lab results, illness episodes, allergies, family history, workouts, and a documents list. What isn’t included (raw document files, workout GPS/heart-rate detail) is stated plainly.
### Performance
- The Today digest and the insights cards read through the pre-aggregated tier instead of scanning raw rows; batch imports write in one pass.
Two migrations (0250, 0251). No breaking changes.
## [1.29.5] — 2026-07-17
### Security
- **Hardened the same-origin redirect guard.** A crafted relative
post-login `next` path (e.g. `/..//evil.com`) passed the same-origin
check but left a protocol-relative pathname that could re-resolve to a
foreign origin when re-used in a redirect or client navigation. The
guard now re-verifies the reconstructed path resolves on-origin and
rejects it otherwise. Covers the OIDC callback and the password/passkey
login redirect.
### Fixed
- **OIDC login redirects use the operator app URL, not the request URL.**
Behind a reverse proxy that does not forward the original host, the
login and callback routes built their browser redirects from the
address the app was reached at inside the container, sending users to
an unreachable internal URL after login. Every OIDC redirect is now
built from the operator-set `NEXT_PUBLIC_APP_URL`, matching the
convention the other integrations already use.
## [1.29.4] — 2026-07-17
- **The workouts list shows the most recent first.** The list was ordered
oldest-first, so on a longer history the newest sessions never made the first
page. It now leads with the latest workout.
- **Past-year dates show the year.** A workout from a previous year now carries
its year in the list, so a December date is never mistaken for this year's.
## [1.29.3] — 2026-07-17
- **Hydration and micronutrients now have a home.** Your fluid intake and the
vitamins and minerals your device records get a surface. A water tile on the
dashboard shows the day's total with quick-add amounts; a new **Nutrients**
page under Insights shows hydration over the last month, caffeine when you
record it, and the vitamins and minerals you actually have data for — each
against its reference daily intake where that applies, shown as context, never
as a verdict. Manual water entries and device-synced totals now coexist
instead of overwriting each other. Sparse logging is never read as a
deficiency.
The nutrients tracking stays opt-in — enable it in Settings, or with the
one-tap prompt on the page. Device-synced micronutrients flow once your client
sends them.
One migration (0249). No breaking changes.
## [1.29.2] — 2026-07-17
- **WHOOP workouts carry their real sport.** WHOOP was the only source that
didn't translate its activity into the app's own sport names, so
WHOOP-tracked rides and runs landed unlabelled instead of as cycling or
running. New and existing WHOOP workouts are now mapped, so your bike rides
show up as cycling — history included. When the same session comes in from two
sources, the canonical row now keeps the richest reading (heart rate, energy,
distance) instead of dropping it.
- **Scroll back through the day's heart rate.** The intraday heart-rate view
gains a day navigator to step through previous days. The fine-grained history
now stays for 90 days (was 14); older days show the coarser hourly shape.
- **Clear a card from Today.** A milestone, a new ECG, or a tension note can be
dismissed from the Today rail once you've seen it; the section tightens when
the last one is gone. The cards that need an action stay until you act.
- **Vitals context moves into an info tap.** The vitals tiles carry an (i) with
the context — your reading against your personal range, and against your age
where that applies — instead of a permanent caption. The VO₂ max heading no
longer gets cut off.
- **Oura cycle phases feed the cycle tracker.** Where your Oura connection
provides cycle-phase data, it now fills the cycle tracker as a background
source; your own entries always win.
Two migrations (0247, 0248). No breaking changes.
## [1.29.1] — 2026-07-17
A round of fixes and small refinements from living with the daily view.
- **Today refreshes right after a dose.** Logging or undoing a medication now
updates the day's read immediately — the medication line no longer lingers on
a stale count until the next reload.
- **A calmer Today hero.** The extra score rings are gone; the hero keeps the
one health-score ring and a tighter layout.
- **The Coach names the document you attached.** Asking about a document now
shows its title in the chat instead of a plain "1 document" count, and the
attach menu stays within its popover on long labels.
- **A fresh install starts with tracking on.** New installs now enable the
tracking modules by default — you can still switch any of them off. The
external-AI options and the daily push stay off until you turn them on.
- **Insights vitals lose the explainer line.** The "your personal normal range
is the median…" caption is removed from the baseline tiles.
- **Illness journal: set and edit the end.** You can mark an episode's end date,
clear it back to ongoing, and backdate the start. When nothing strayed from
your personal range, the course section now reads a short settled line instead
of appearing empty.
- **The morning refresh covers every sleep source.** The refresh that folds last
night's sleep into the day now triggers from Google Health, Fitbit, Oura, and
Polar as well, not only Withings, WHOOP, and Apple Health. Each skipped refresh
is now recorded so a stale morning read can be traced to its cause.
No new data is collected and there is no migration.
## [1.29.0] — 2026-07-17 — A daily health companion
This release draws a line under a run of work that turns HealthLog from a place
you record your health into one that meets you each day — and it completes that
picture with the pieces that make it feel whole.
Over the last releases the dashboard gained a **Today** view that leads with the
day's read, a health score that refreshes the moment last night's sleep syncs, a
coach check-in when a plan is due for a look-back, and an optional morning note.
Documents came closer to hand — the Coach opens beside them, you can bring
several into a conversation, and lab values move between a document and your
labs. This release adds the layer that ties it together:
- **Your heart's rhythm, in context.** ECG recordings are no longer a page on
their own — they're referenced where your resting heart rate and rhythm are
discussed, always as the reading your device produced, never a reading of the
app's own. A new recording surfaces gently in Today.
- **The shape of your day.** Heart rate is now drawn across the day, not just as
a daily number — so you can see it settle and climb. When it stays elevated
while you're at rest and not moving, Today notes it plainly as possible
tension, carefully and never as a verdict.
- **Quiet milestones.** Reaching a steady stretch in your target range, or a new
personal best, is marked with a calm moment — states you've reached, not
streaks to keep, so there's nothing to break and nothing to nag.
- **One piece throughout.** The Today view brings back the health rings you
choose, each surface carries its metric's own calm colour, and the whole thing
reads as one design rather than a stack of features.
Nothing new generates when you open the app — every daily surface reads the
insight prepared overnight. No new data is collected; this makes more of what's
already yours.
Four changes bring documents and your record closer together:
- **Ask the Coach opens beside your document.** Asking about a document now
opens the Coach in a side panel rather than jumping to a full page — expand it
to full screen whenever you like, and it keeps its place.
- **Bring documents into a Coach chat.** From the composer's "+", attach one or
more documents — pick from your vault or upload a new one, at the start or
mid-conversation — and the Coach answers grounded in them. A conversation with
a document runs in its fenced mode: grounded in those documents only, with no
access to your health data or tools, so nothing in an uploaded file can reach
anything it shouldn't.
- **Lab values from a document, proposed for your labs.** When a document you've
had read looks like lab work, its values are detected and proposed — one tap to
add them to your labs; they're never added without your say-so.
- **A lab scan is also kept.** Scanning a lab report into your labs now also
files the document in your vault (encrypted, like every upload) and links the
two, so the reading and its source stay together.
Migration 0245 (attaching multiple documents to a conversation) accompanies this
release; existing document chats carry over unchanged.
Three pieces turn the Today view into a daily loop:
- **A coach check-in.** When a coach plan is due for a look-back, it surfaces as
a calm card in Today with keep, adjust, or let-go — one tap. Letting go retires
the plan quietly; silence never changes it.
- **A morning that reflects last night.** The score and read are prepared
overnight, but last night's sleep usually hasn't synced by then. Now, the
moment it arrives, the sleep-dependent parts refresh on their own — so by the
time you look, the day is up to date. Until sleep is in, Today says so plainly
instead of showing a stale score.
- **An optional daily nudge.** A once-a-day morning notification with the day's
read, off by default and opt-in per channel under Notifications. It carries the
refreshed digest when sleep is in, never repeats within a day, and is a calm
note — never an alert.
All three read the digest prepared overnight; nothing is generated when you open
the app.
With the Today view now leading the dashboard, the older opt-in hero card it
replaced is retired, so the two never stack. The greeting stays, the tiles and
charts are unchanged, and a layout stored with the old hero switched on simply
ignores that setting — nothing to redo.
## [1.28.53] — 2026-07-16 — A "Today" overview at the top of your dashboard
The dashboard opens with a Today view: your health score, the day's lead read,
and a short "worth a look" list — a dose window that's open, an integration to
reconnect, a check-up coming due — each a single tap to the right place. The
familiar tiles and charts sit just below, unchanged.
It reads the insight that's already prepared overnight, so it loads instantly
and never generates anything on open. When last night's sleep hasn't synced yet,
it says so plainly and fills in the sleep-dependent parts once the data arrives,
rather than showing a stale or empty score. This is the first piece of a single
daily view that later surfaces — a morning refresh, an optional daily note —
will all build on.
The dashboard gains tiles for readings that were synced and charted but never
had a place on the home strip: heart-rate variability, blood oxygen, breathing
rate, wrist temperature, and — for anyone with a body-composition scale — muscle
mass, body water, and bone mass.
Each tile appears only once you have that reading, so nothing new shows up on an
account that doesn't track it, and every tile can be toggled or reordered under
Settings → Dashboard like the rest. Tapping one opens its detail view. Nothing
changed in how the readings are stored or synced — this only surfaces what was
already there.
## [1.28.51] — 2026-07-16 — Chatting about a document lives in the Coach now
Asking the Coach about a document used to open a separate, half-finished chat
panel that never showed up in your Coach history. It now opens the real Coach:
the conversation appears in your Coach list like any other, marked with a small
document badge and a "Chatting about: <title>" note so it's clear which
document it's scoped to.
The safeguards that make document chat safe are unchanged. A conversation about
a document still runs in its fenced mode — grounded in that document's text
only, with no access to your health data or any tools — so text inside an
uploaded file can't reach anything it shouldn't. Only the surface moved into the
Coach; the boundary did not.
ECG recordings synced from a compatible watch were stored but never shown. They
now have a place in Insights: a list of your recordings, each opening the full
waveform on a familiar ECG grid, with the date, duration, average heart rate,
and the result your device recorded.
The result shown is the one your recording device produced — HealthLog does not
read or interpret the trace and does not provide a diagnosis. A recording is
single-lead and for personal awareness only; if a result concerns you, a note
alongside it points you to discuss the recording with a clinician.
## [1.28.49] — 2026-07-16 — Apple Health re-import no longer stops on a duplicate reading
Re-importing a cumulative export.zip could run for several minutes and then fail
outright on a unique-constraint error when two readings shared the same time,
type, and source but carried different internal ids — a single such reading
aborted the whole import. The importer now recognises that case: it updates the
existing reading in place (and brings back one that had been removed) instead of
failing, and if it still can't place a single reading it skips just that one and
carries on. A re-import completes cleanly.
## [1.28.48] — 2026-07-16 — Security follow-ups
Two security fixes:
- On a brand-new instance with single-sign-on configured, two people signing in
for the very first time at the same moment could both be made admin. The
first-admin selection now takes the same lock as password registration, so
exactly one admin is created no matter which sign-in path or how concurrent.
- A background nutrient-import error could echo a raw database message back to
the client; it now returns a fixed status and logs the detail server-side only.
## [1.28.47] — 2026-07-16 — Job queue engine update (pg-boss 12.26)
Updates the background-job engine (pg-boss) from 12.20 to 12.26, which adds
schema-drift detection, self-healing for background index builds, and a set of
correctness fixes. Some of those fixes surface errors that earlier versions
silently swallowed; the app already attaches an error listener to the queue and
does not use the direct calls affected by the change, so the upgrade is
transparent. No configuration change is needed.
## [1.28.46] — 2026-07-16 — Server performance on large instances
Performance work for accounts with a lot of history, none of it changing what
any number means:
- Insight generation computed its 30-day comparison averages by pulling every
matching row of the last 400 days into memory and averaging in code. It now
asks the database for the averages directly — the same figures, without
materialising the rows. (Sleep keeps its per-night reconstruction path.)
- The background scans that run at worker startup used an in-memory
de-duplication over full-table reads. They now de-duplicate in the database
and are backed by partial indexes (migration 0243, additive), so startup on a
large instance no longer does full scans.
- The coach chat re-rendered every message on every streamed token; now only
the message being streamed re-renders.
- The doctor-report mood read no longer pulls fields it doesn't use.
## [1.28.45] — 2026-07-16 — Doctor report + privacy fixes
- Sleep now actually appears in the doctor-report PDF. The sleep section was
enabled by default and its data was prepared, but the PDF's vitals table left
it out, so the section rendered empty — it now shows time asleep in hours.
- Glucose can now be included or withheld from a shared report like every other
clinical section. The toggle existed in settings but did nothing; it now
gates the glucose data end to end.
- Turning the environment module off now also keeps its data (audio exposure,
daylight, skin temperature) out of the coach, matching how the other modules
behave.
## [1.28.44] — 2026-07-16 — Loose ends: a broken link, the API contract, module guards
- The "connect a device" link on the sleep insights empty state pointed at a
settings page that doesn't exist and returned a 404; it now opens the
integrations settings.
- Six endpoints the iOS app already uses were missing from the published API
contract (`docs/api/openapi.yaml`); they are now documented, so the contract
is complete again.
- A few module pages didn't redirect away when their module was turned off, the
way the others do; they now behave consistently.
## [1.28.43] — 2026-07-16 — Visual consistency polish
A pass over small visual inconsistencies the design audit surfaced:
- The insights header now uses the standard heading weight and spacing scale.
- Two dashboard tiles that hand-rolled a dimmer header now use the same tile
header as their neighbours, so the cards read as one family.
- A few tiles sat a step denser than their siblings; they now match.
- Two header icons that used the accent colour are back to the standard.
- Several edit dialogs on phone-reachable surfaces now open as the same bottom
sheet the rest of the app uses.
A new lint rule keeps card spacing on the standard scale so this drift can't
quietly return.
## [1.28.42] — 2026-07-16 — Snappier lists, correct mood refresh, safer first sign-up
Performance and correctness fixes:
- The shared date/number formatters rebuilt a fresh locale formatter on every
single call, which stalled the main thread across long history lists. They
now reuse cached formatters — the same output, without the per-row cost.
- Long measurement lists (a year of dense readings) rendered every row twice —
a desktop and a mobile copy, one hidden with CSS. They now render one layout
for the current viewport.
- Logging a mood entry now refreshes the dashboard immediately; the mood tile
and score could previously stay stale for up to two minutes after a change.
- On a brand-new instance, two people signing up at the exact same moment could
both be made admin. First-admin selection is now atomic, so exactly one does.
## [1.28.41] — 2026-07-16 — Complete the translations, and guard the gap
One reminder-adjacent label — the recovery-driver "functional impact" track on
the illness insights card — was showing its internal name instead of a
translation in every language; it is now translated in all six. The class of
bug behind it (a label resolved through a computed key the static check can't
see, so a missing translation ships silently — the same seam as the well-being
labels fixed a release ago) is now closed structurally: a new test walks each
such key space against every language bundle, so a missing translation fails
the build instead of reaching a user.
## [1.28.40] — 2026-07-16 — Insight assessments lead with meaning, not a number
The per-metric insight assessments read like a data readout — they opened on
the current value. The overview texts already lead with the day's verdict in
plain words and land the warm, motivating tone; the per-metric cards were told
the opposite (name the level with a number first) and never received the
opener-variation the other insight surfaces use. Now every metric assessment
opens with what the reading MEANS and brings the number in right after as
support, in the same warm register as the overview. A shared opening-shape
contract keeps the surfaces from drifting apart again, and the deterministic
first-paint fallbacks lead with the verdict too. No change to what the numbers
say — only how the sentence is built.
## [1.28.39] — 2026-07-16 — Sync data-loss hardening
Four fixes to sync paths that could lose or corrupt data in edge cases:
- The nightly pass that folds raw cumulative samples (steps, energy, distance)
into daily totals no longer re-counts a sample you had deleted — it was the
one such pass missing the deleted-row filter its siblings all carry.
- That same pass is now safe when two runs overlap (a deploy-timing overlap, or
a manual admin trigger racing the nightly job): an advisory lock plus an
in-transaction re-read make the fold idempotent, so an overlap can no longer
permanently double a day's total.
- A re-scored Withings sleep segment (its end time shifts) now updates the
existing row in place instead of colliding with it and wedging that night —
the same re-key rescue the other providers already carry.
- Withings measurement sync now holds its position when a reading fails to
write, instead of advancing past it and stranding the reading; the next sync
retries it.
## [1.28.38] — 2026-07-16 — Documents polish, waist reminders, bulk sharing
The document vault gets a round of overdue polish. The list cards drop a
stray glyph — the "read by AI" mark it carried moves into the document's
detail view — and give the real filename its own line so it stops being cut
off; the detail view's close button is aligned correctly (a shared sheet
primitive was reserving space for a button it wasn't showing). The action
that opens a document in the coach now reads "Coach fragen".
From a multi-selection in the overview you can now share several documents
at once — one link holding the selection, with the same documents-only
privacy as a single share (up to 50 per link).
Reminders can now be linked to waist circumference. And the well-being
reminder types (WHO-5, sleep-condition) that were showing their internal
names instead of a label are now translated in every language.
## [1.28.37] — 2026-07-15 — Provider step totals survive a restart
A boot-time maintenance pass that folds genuinely old raw step samples into
daily totals had too broad a reach: it also matched the daily step totals that
Google Health, Fitbit, and Withings write, and soft-deleted them on every
worker start. Recent days came back on the next sync, but older days stayed
gone until a full re-sync — which the next restart swept again. The pass now
skips any row that is already a daily total and only ever touches the ingest
paths that produced real legacy raw samples, so a provider's step history is
left alone. A one-time repair restores the totals removed by earlier restarts
(within the retention window; a single full sync recovers anything older) and
cleans up the placeholder totals the bug minted in their place.
## [1.28.36] — 2026-07-15 — Re-importing after a failed Apple Health import actually retries
The v1.28.33 rollup and staging fixes were correct but never reached the
reporter's case: the upload deduplicates by content hash, and the lookup
matched a previously FAILED job too — so re-uploading the same export.zip
returned the old failed job and replayed its stale error without ever running
the import again. The dedup now ignores failed jobs; the same file retries
cleanly. A redundant staged upload is also cleaned up on a dedup hit instead
of lingering in the staging directory.
Two related hardenings: the boot-time reconcile that marks interrupted imports
failed now performs a real liveness check (queue state plus a progress
heartbeat) instead of unconditionally failing every in-flight job — so a split
or multi-worker deployment no longer kills an import running in another worker.
And the "staging file not found" message now names the separate-container cause
explicitly, with a matching note in the scaling guide.
## [1.28.35] — 2026-07-14 — Timestamps honor the profile timezone
Client-rendered times previously fell back to a hardcoded display zone
(Europe/Berlin) no matter what the profile timezone said; a user in Manila saw
Berlin clock times on every card, chart, and table. Display now follows the
profile timezone everywhere, with a strict fallback to the legacy display zone
— never the browser zone, so screen, PDF, and export can no longer disagree.
An invalid stored timezone can never break rendering; it falls back safely.
The same pass closed the latent inconsistencies the audit surfaced: the
"Updated today" cards no longer mix one zone for the day boundary and another
for the clock; calendar-date fields no longer shift a day for profiles west of
UTC; the dose-history day headings and weekday labels render in the display
zone; chart day/month labels stay correct for every zone (bucket math is
untouched); and the clinician share view renders in the patient's timezone
instead of the server container's. Day groupings, schedules, and aggregates
were already timezone-correct server-side and are byte-identical.
## [1.28.34] — 2026-07-14 — Nutrient intake lands as quiet context (server side)
Supplement-style intake written to Apple Health by nutrition apps — vitamins,
minerals, water, caffeine — can now sync to HealthLog as daily totals. This is
deliberately not a nutrition feature: no food diary, no calorie tracking, no
dashboard tile. The data lands in a dedicated store (26 nutrients with EFSA
reference values), is visible on a read-only card under Settings → Sources,
and will feed the Coach as context in a later release.
The module is opt-in and off by default; with it off, the server refuses the
data entirely. Re-posting a day replaces it, units are guarded against
µg/mg mix-ups, and implausible values are skipped per entry. This release is
inert until the iOS companion app ships its reading side (coordination ticket
filed); the wire contract is in the OpenAPI spec.
## [1.28.33] — 2026-07-14 — Re-importing a cumulative Apple Health export works again
Re-uploading a fresh export.zip over existing data could fail with a duplicate-key
error in the rollup tier: a concurrent recompute (a live sync, the nightly drain)
racing the import's full fold could collide on the same aggregation bucket — and
the losing side silently dropped its freshly computed aggregate, leaving a stale
bucket. Rollup writes are now conflict-safe upserts with deterministic lock
ordering: a race can neither fail the import nor lose an aggregate, and buckets
left stale by earlier races self-heal on their next recompute.
The misleading error is gone too. A long import could outlive its queue timeout;
the redelivery re-opened the already-consumed staging file and overwrote the real
failure — or even a successful completion — with a raw ENOENT. Import jobs now
finish terminally (no redelivery after the staging file is consumed, six-hour
timeout), a delivery for an already-finished import is a no-op, and a genuinely
missing staging file reports an honest "upload the export again" message.
## [1.28.32] — 2026-07-12 — Fitbit: re-keyed re-imports can no longer be silently dropped
Single-fix release for the Fitbit integration, mirroring the Google Health
fix from v1.28.28. The measurements table carries a second unique index on
the natural key (type, time, source, sleep stage) that also covers
soft-deleted rows; when an import carries new external ids for rows that
were previously removed — the re-keyed re-import case — the insert collided
with the old row and was dropped without a trace. Planned inserts now get a
second probe by natural key: a match, live or removed, is updated in place
with the fresh value and the new id instead. If the probe itself fails, the
sync reports the failure and holds its watermark so the next run retries —
nothing is lost either way. No other code path is touched in this release.
## [1.28.31] — 2026-07-12 — Heart-rate history keeps its daily shape
Long-term retention for the dense intra-day signals (heart rate, HRV, blood
oxygen) now folds to hourly means instead of a single daily mean. The last
14 days keep every raw sample as before; older days keep up to 24 points —
enough to see how a day actually went, at roughly nine thousand rows per
metric per year instead of an unbounded raw stream.
Because the old fold soft-deleted rather than erased, a one-time rebuild
reconstructs the hourly shape for every already-folded day from the retained
raw samples, then retires the old daily row in the same transaction — no
reader can ever double-count a day. The rebuild runs automatically once per
installation after the update; days whose raw samples are no longer
available keep their daily mean. Daily min/max/mean statistics captured at
fold time stay untouched throughout.
## [1.28.30] — 2026-07-12 — The daily briefing stops silently skipping days
The recurring "no briefing today" had a chain of causes, now closed end to end.
Every failure path in the nightly generation names its cause in the logs
(several classes previously failed without a trace). A failed night retries
itself about forty-five minutes later instead of waiting for the next night.
A cached insight payload that carries no briefing no longer counts as fresh —
previously it could satisfy the daily window and even the unchanged-data check,
so a once-stripped briefing could block its own regeneration indefinitely. And
the briefing card now reports persistently why a briefing is absent (withheld
by the number check, generation failed, or not yet attempted) with a retry
button that always generates.
Documentation corrections, verified against the code: Withings credentials are
per-user in the app (the documented environment-variable path never existed —
and the webhook secret is now actually passed through in the compose file);
the classic Fitbit portal no longer accepts new registrations (new users:
Google Health); the backup-restore example no longer generates a fresh
encryption key (which could never decrypt an existing backup) and uses the
invocation that works in the production image; the certificate-pinning guide
now describes the real model (two CA-level pins, leaf renewals need no app
build); scaling and admin-endpoint references corrected.
## [1.28.29] — 2026-07-11 — Dashboard edits show up on the way back; one scrollbar again
Changing the dashboard tile selection now shows up immediately when you
navigate back — the third and final layer of this bug. Saving marked the
dashboard's cached data stale but, with the dashboard page unmounted, nothing
re-read it until the next poll or a window-focus flick. The save now refreshes
that cache directly, mounted or not.
The settings pages with sortable lists (dashboard layout, medication order,
modules, mood tags) showed a second page scrollbar: an invisible
screen-reader hint below each list escaped its container and silently
lengthened the document. All five editors are fixed and the overscroll guard
now covers these routes.
## [1.28.28] — 2026-07-11 — OpenAI-compatible gateways work; a re-keyed night can no longer vanish
The user-level "Local (OpenAI-compatible)" provider now speaks the standard
JSON wire: it sends `response_format` and, when an endpoint rejects it, falls
back once and remembers that endpoint's dialect — so LiteLLM, OpenRouter,
vLLM, LM Studio and plain Ollama all work from the same settings form. The
form and the provider docs now say so explicitly. Gateways that wrap a
Claude-family model behind a synthesized tool call are parsed correctly
instead of yielding silently empty insights.
The briefing token budget is configurable (`INSIGHTS_MAX_TOKENS`, default
raised so full briefings stop truncating), and a reply that was cut off
mid-JSON now says exactly that instead of a generic parse error. When the
number-grounding check withholds a briefing, the card now explains why
instead of pretending nothing was generated. The provider test button
distinguishes "the endpoint rejected the request" from "could not reach the
endpoint".
Sleep repair follow-up: re-syncing a history whose sleep rows predate the
stable segment keys could erase those nights — the old rows were swept while
their replacements collided with a second uniqueness rule and were silently
dropped. Re-imports now recognise such rows by their natural identity
(type, instant, stage) and migrate them in place: fresh value, new key,
restored if deleted. A full sync after updating heals any history affected.
## [1.28.27] — 2026-07-11 — Runs on CPUs without AVX2
Self-hosts on older x86-64 CPUs — Celeron/Atom-class NAS boxes, pre-2013
Xeons, and VMs that mask newer CPU flags — crashed in a restart loop since the
document renderer arrived: the bundled rasterizer uses AVX2 instructions, and
a CPU without them kills the whole process the first time a thumbnail or a
scanned-PDF render runs. The renderer is now gated on a one-time CPU-feature
check: on unsupported hardware, thumbnails and scanned-PDF rasterization
simply switch off (the vault shows type icons; PDFs are read as text) while
everything else runs normally. `NATIVE_CANVAS=off|on` overrides the detection
if ever needed. Reported by a self-hoster with kernel traps in hand — thanks,
that made it a same-day fix.
## [1.28.26] — 2026-07-11 — Internal restructuring for maintainability
No user-facing change. Eight of the largest source files were split along
their natural seams into focused modules — mood analytics calculators, the
insight status-invalidation machinery, feature extraction blocks, the doctor
report's types and helpers, the Google Health mapping layer, the coach chat
bubbles and read-aloud controls, the Telegram webhook handlers (the route now
holds only auth and dispatch), and the coach snapshot's cache, series helpers
and largest per-metric blocks. Every move is verbatim with stable import
paths; behavior is pinned by the full test suite.
## [1.28.25] — 2026-07-11 — Every integration held to the same standard
A platform-wide hardening pass: the failure classes found live this week were
hunted down across every integration, not just where they first surfaced.
Fitbit carried the same silent wedge fixed for Google Health — a soft-deleted
reading permanently blocked its own re-import, hidden behind a comment
describing a database index that never existed. Fixed the same way: a re-import
revives the deleted row in place.
Sleep segments now keep stable identities across re-scoring for Withings,
WHOOP, Polar and Oura — previously a source refining a night could duplicate or
orphan its stage rows, quietly inflating the night's total (the class fixed for
Google earlier this week). Each sync now also sweeps rows a re-score left
behind, so already-affected nights heal themselves as they are re-read.
One platform rule for deleted rows: a reading owned by a connected source comes
back when the source reports it again — across Withings, WHOOP, Oura, Polar,
Nightscout, the mood webhook, CSV re-imports and Apple Health day totals.
Deleting an Apple Health sample in the Health app still sticks, as the paired
client expects. Import counts and per-entry statuses now tell the truth in
every one of these paths.
Google Health lifecycle: an expired connection no longer lets the hourly sync
stamp success while importing nothing (it parks cleanly for reconnect instead
of retrying against a dead token forever); history backfills and the sleep
repair only mark themselves done after a clean pass, so a transient error now
retries instead of silently leaving a gap; and a failed database write holds
the sync watermark so the affected window is re-fetched.
Scale: the doctor report reads only the columns and sections it renders;
exports and backups read the measurement table in pages instead of one giant
query; long-window charts for sensor-dense metrics (glucose, pulse) aggregate
per day in the database beyond 90 days instead of shipping every raw sample;
blood-pressure pairing, achievement tallies and several "which types exist"
scans moved from in-memory loops to the database. A year of continuous sensor
data can no longer stall or crash a request.
## [1.28.24] — 2026-07-11 — A deleted Google reading no longer blocks its re-import
A Google Health reading that had been soft-deleted could never be imported
again: the sync treated the deleted row as absent and planned a fresh insert,
but the database's uniqueness rule still saw the deleted row and silently
dropped the insert — every sync, forever. A self-hoster's step days were stuck
exactly this way. The sync now recognises the deleted row and revives it in
place with the freshly fetched value: Google remains the source of truth for
its own readings, so a re-import deliberately brings a deleted one back. To
remove Google data permanently, disconnect the integration.
## [1.28.23] — 2026-07-11 — Sleep disagreement visible on the dashboard; Apple Health medication groundwork
When two sources disagree about last night's sleep — a sleep mat's time in bed
+2 -1
View File
@@ -95,7 +95,7 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file:
- **`annotate()` on every interesting code path.** Wide-event observability. Action names follow `<surface>.<noun>.<verb>` (`coach.budget.exceeded`, `measurement.batch.ingest`). Don't free-text in `meta` — pin the shape so dashboards stay stable.
- **i18n keys exist for every `t()` call.** `i18n-call-site-coverage.test.ts` walks every `.ts(x)` under `src/`, extracts every `t("ns.key")` literal, and asserts each key resolves in `messages/en.json`. `i18n-locale-integrity.test.ts` propagates the EN guarantee across `de / en / es / fr / it / pl`. If a guard fails, fix the bundle, don't suppress the test.
- **OpenAPI registry stays in sync.** Zod schemas carry `.meta()` annotations under `src/lib/openapi/`; `pnpm openapi:generate` emits `docs/api/openapi.yaml`. CI fails on drift. Re-run the generator after touching a request / response schema and commit the YAML alongside the Zod change.
- **File naming.** Components and lib files: kebab-case (`daily-briefing.tsx`, `secure-cookie.ts`). Hooks: `use-` prefix kebab-case (`use-coach-prefs.ts`). React component exports stay PascalCase regardless of filename. The 18 PascalCase outliers under `src/components/medications/` + `src/components/onboarding/` are pre-existing drift and worth cleaning up when the surrounding files come up for edit.
- **File naming.** Components and lib files: kebab-case (`daily-briefing.tsx`, `secure-cookie.ts`). Hooks: `use-` prefix kebab-case (`use-coach-prefs.ts`). React component exports stay PascalCase regardless of filename.
## Tests and commands
@@ -165,6 +165,7 @@ The native SwiftUI client lives in a separate repository and rides public beta v
- The `clientManaged` opt-in on `PATCH /api/auth/me/notification-prefs` suppresses server-side `MEDICATION_REMINDER` APNs for clients that manage their own local reminders. The cron skips dose-due APNs when the flag is `true` and emits a `medication_reminder.suppressed_client_managed` wide-event annotation per skip.
- `push_attempts` table holds the last 90 days of APNS / Web-Push / Telegram / NTFY delivery attempts (channel + eventType + result + reason + createdAt). The admin diagnostic endpoint at `/api/admin/notifications/diagnostic` surfaces masked device tokens + channel state + last 20 attempts for the calling user.
- Any request / response schema change must regen `docs/api/openapi.yaml` and commit the result. CI fails on drift.
- The iOS app signs in via the native OIDC SSO handoff **or** password/passkey. The native SSO leg: the app opens `GET /api/auth/oidc/login?client=native&code_challenge=<S256>` in an `ASWebAuthenticationSession`, the callback returns to the fixed compiled-in scheme `healthlog://oidc-callback` with a one-time `code=hlh_…` (or an `mfa_ticket` when the account has a second factor), and the app exchanges the code at `POST /api/auth/oidc/native/token` for the standard native bundle (`src/lib/auth/oidc-native-handoff.ts`). The `healthlog://` scheme is a compile-time constant — no `redirect_uri` param, no env allowlist (the open-redirect class is removed by construction). The token pair NEVER rides a URL; only the opaque handoff code / MFA ticket do. The SSO login can never satisfy `requireFreshMfa` step-up — the native transport is Bearer and `requireFreshMfa` is cookie-only, and even the web path sets `mfaVerifiedAt` only from the app's own factor. `OIDC_ONLY=true` is now supported for iOS but requires the app build that ships this flow; an older password/passkey-only build is locked out. The MFA-verify and `/api/auth/refresh` routes that complete/rotate an OIDC login are deliberately not OIDC-only-blocked.
## DO-NOTs
+22
View File
@@ -80,6 +80,8 @@ services:
# whitelist — vars not listed here don't propagate even though
# docker-compose reads .env for `${VAR}` substitution at compose-up time).
SESSION_COOKIE_SECURE: "${SESSION_COOKIE_SECURE:-}"
NATIVE_CANVAS: "${NATIVE_CANVAS:-}"
WITHINGS_WEBHOOK_SECRET: "${WITHINGS_WEBHOOK_SECRET:-}"
# v1.18.11 (W3) — client-IP resolution + geo lookup. Behind Cloudflare
# the real visitor IP is in `cf-connecting-ip`; set
# TRUST_CF_CONNECTING_IP=1 ONLY when Cloudflare is in front (off by
@@ -140,6 +142,21 @@ services:
STRAVA_CLIENT_ID: "${STRAVA_CLIENT_ID:-}"
STRAVA_CLIENT_SECRET: "${STRAVA_CLIENT_SECRET:-}"
STRAVA_REDIRECT_URI: "${STRAVA_REDIRECT_URI:-}"
# OIDC SSO login (self-hosted IdP: Authentik/Keycloak/Authelia/etc).
# All three of ISSUER_URL/CLIENT_ID/CLIENT_SECRET together enable the
# "Sign in with SSO" button; set none to leave it disabled. OIDC_ONLY
# hides password/passkey login entirely, but only takes effect when
# the three above are also fully set — a half-configured group never
# locks every user out. It IS enforced server-side, and there is no
# OIDC flow for the native iOS client yet — enabling it locks iOS
# users out of sign-in entirely. Listed here so operator values in
# .env reach the container (the `environment:` block is a whitelist).
OIDC_ISSUER_URL: "${OIDC_ISSUER_URL:-}"
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID:-}"
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET:-}"
OIDC_SCOPES: "${OIDC_SCOPES:-}"
OIDC_BUTTON_LABEL: "${OIDC_BUTTON_LABEL:-}"
OIDC_ONLY: "${OIDC_ONLY:-}"
# Deploy-result webhook (optional). DEPLOY_WEBHOOK_SECRET authenticates
# POST /api/internal/deploy-webhook; DEPLOY_LOGS_URL is the
# deployment-dashboard URL (e.g. the Coolify UI) linked in
@@ -165,6 +182,11 @@ services:
# in .env reaches the container (the `environment:` block is a whitelist).
# See docs/integrations/ai-providers.md.
ALLOW_LOCAL_AI_PRIVATE_HOSTS: "${ALLOW_LOCAL_AI_PRIVATE_HOSTS:-}"
# Daily-briefing output-token ceiling (optional; default 2500, clamped
# 500-8000). Raise when a verbose model's briefing JSON is cut off.
# Listed here so an operator value in .env reaches the container (the
# `environment:` block is a whitelist). See docs/integrations/ai-providers.md.
INSIGHTS_MAX_TOKENS: "${INSIGHTS_MAX_TOKENS:-}"
# v1.17.1 — SMTP transport for the email notification channel (optional,
# all-or-none: HOST + PORT + FROM enable it; USER/PASS optional; SECURE
# toggles implicit TLS). Listed here so operator values in .env reach the
+3 -1
View File
@@ -21,7 +21,9 @@ notes, and machine-readable specs that ship alongside the source:
recipe. Routine install steps stay on `docs.healthlog.dev`. Includes
[`self-hosting/mcp.md`](./self-hosting/mcp.md) — enabling the
off-by-default MCP connector and pointing Claude.ai / ChatGPT /
Claude Desktop at it.
Claude Desktop at it, and [`self-hosting/sso.md`](./self-hosting/sso.md)
— OIDC SSO login: IdP setup, the identity-pinning security model,
`OIDC_ONLY` consequences, and the break-glass runbook.
- [`migration/`](./migration/) — release-by-release migration notes.
Read the entry for the version you're upgrading from.
- [`audit/`](./audit/) — per-release audit summaries archived for
+2410 -28
View File
File diff suppressed because it is too large. Load diff
+73 -1
View File
@@ -33,7 +33,9 @@ HealthLog supports two key-provisioning modes that coexist:
`AppSettings.adminAiKeyEncrypted`. Users who have not configured
their own key fall back to the admin key. This is the last entry
in the default provider chain so it acts as a safety net rather
than the default route.
than the default route. Since v1.28.14 the operator can also share
a subscription-based account that users opt into — see
[Admin-shared subscription access](#admin-shared-subscription-access).
Single-user instances usually pick one mode and stop there.
Multi-user instances tend to run BYOK with the admin-shared key as a
@@ -148,6 +150,35 @@ internal endpoint.
16-24 GB GPU / M-series Pro/Max (sweet spot for BYOK-style usage),
70B+ for 48 GB+ / multi-GPU (comparable to mid-tier hosted models).
### OpenAI-compatible gateways (LiteLLM, OpenRouter, …)
The **Local** provider is also the gateway path: any service that
speaks the OpenAI `/v1/chat/completions` wire plugs in the same way,
whether it runs on your host or not. The OPENAI provider deliberately
pins `api.openai.com` (an OpenAI key is never forwarded to a custom
host), so a gateway always goes through **Local**:
1. In `/settings/ai`, set provider to **Local (OpenAI-compatible)**.
2. Base URL is the gateway's OpenAI-compatible root and **ends with
`/v1`** — e.g. `https://litellm.example.com/v1` or
`https://openrouter.ai/api/v1`.
3. API key is **optional** — set it when the gateway requires a
Bearer token (OpenRouter key, LiteLLM master key), leave it blank
otherwise. It is encrypted at rest like every other credential.
4. Model is whatever the gateway routes — e.g.
`anthropic/claude-sonnet-4-6` on OpenRouter or a LiteLLM alias.
JSON surfaces send the standard
`response_format: { type: "json_object" }`; an endpoint that rejects
the field is detected on the first refusal and retried without it,
so strict gateways and older local servers both work unmodified.
The **admin** server-key path has a separate guard: an admin-set
custom base URL must additionally be allowed via
`ADMIN_AI_BASE_URL_ALLOWLIST` in the container environment. The
user-level Local provider does not read that variable — it is
governed by the SSRF guard above.
## ChatGPT OAuth (Codex)
The `CHATGPT_OAUTH` provider routes generations via the
@@ -180,6 +211,41 @@ documented in `docs/codex-protocol-spec.md` for anyone who wants to
audit the request shape; users who just want the integration to
work do not need to read it.
## Admin-shared subscription access
Besides the shared API key, the operator can connect a single
subscription-based AI account for the whole instance — an
operator-shared subscription provider that users without any
personal AI setup can opt into.
- **Admin side.** Under **Admin → AI** the operator runs the same
device-code OAuth flow as the per-user connection
(`POST /api/admin/central-codex/device-start` +
`device-poll`). The tokens land AES-256-GCM-encrypted in the
`AppSettings` singleton and are never returned by any endpoint —
status and disconnect go through `GET` / `DELETE
/api/admin/central-codex`, which sit behind the cookie-only
`requireAdmin()` boundary.
- **User side.** Each user opts in individually in `/settings/ai`
(`PATCH /api/auth/me/use-central-codex`, body
`{ "useCentralCodex": boolean }`). The opt-in is off by default.
- **Chain semantics.** The shared connection is the internal
`admin-codex` chain entry. It is never part of the persisted chain
and never offered in the chain editor — it is appended **last** to
the resolved chain, and only when the user opted in AND the
operator has connected the account
(`src/lib/ai/provider-chain.ts`). A hand-crafted persisted entry
resolves to nothing.
- **Cost.** Generations through the shared connection run under the
operator's account, so usage and rate limits land on the operator,
not the user.
The settings UI states this plainly: the shared access is a single
signed-in AI account the operator connected for everyone on the
server — not the user's own key. Users who want their data to reach
only a provider under their own contract should configure a personal
provider instead; their own entries always resolve first.
## Privacy stance
- **OpenAI** and **Anthropic** see the prompt + structured snapshot
@@ -283,6 +349,12 @@ Lower it when running on a tight budget — the 24h cache already
short-circuits read traffic; only force-regens and cache-misses cost
tokens.
`INSIGHTS_MAX_TOKENS` (default `2500`, clamped to 500–8000) sets the
output-token ceiling for the daily-briefing generation. Raise it if a
verbose model gets its briefing cut off mid-JSON — the API reports
that case as "AI response was cut off" rather than the generic
invalid-JSON error.
## Connection test
`POST /api/ai/test` runs a one-shot probe against the resolved
+20 -17
View File
@@ -5,10 +5,10 @@
HealthLog reads Fitbit and Pixel Watch data through the **classic Fitbit Web
API** (`api.fitbit.com`). The OAuth app is a **Fitbit developer app registered
at [dev.fitbit.com](https://dev.fitbit.com/apps/new)** — **not** a Google Cloud
at [dev.fitbit.com](https://dev.fitbit.com/)** — **not** a Google Cloud
OAuth client. Like WHOOP, credentials are brought per user: each user (or the
operator on their behalf) registers their own Fitbit app and pastes the client
id/secret into Settings. The internal integration key is `fitbit`.
operator on their behalf) supplies their own Fitbit app's client id/secret in
Settings. The internal integration key is `fitbit`.
> **Common mistake:** the field wants a **Fitbit** Client ID (a short numeric
> id from dev.fitbit.com), **not** a Google Cloud client id ending in
@@ -16,30 +16,33 @@ id/secret into Settings. The internal integration key is `fitbit`.
> consent screen with `unauthorized_client — Invalid client_id`, because Fitbit
> and Google Cloud are separate client registries.
> **Heads-up — September 2026 sunset.** Google is retiring the classic Fitbit
> Web API in **September 2026** and moving to the Google Health API behind Google
> sign-in. That replacement currently requires Google Restricted-scope brand
> verification plus an annual third-party CASA security assessment, which does
> not fit a self-hosted bring-your-own-credentials model, so the path beyond the
> sunset is still being worked out. Until then the setup below is the supported
> way to connect Fitbit.
> **Heads-up — new registrations are closed.** Fitbit has **discontinued
> self-serve app registration**: the form at `dev.fitbit.com/apps/new` is
> disabled for new applications, and the classic Fitbit Web API sunsets in
> **September 2026**. If you already hold a registered Fitbit app, it keeps
> working until the sunset and the setup below still applies. If you are
> connecting Fitbit data for the first time, use the
> [Google Health integration](google-health.md) instead — Fitbit devices sync
> into Google's health stack, which HealthLog reads directly.
## 1. Register a Fitbit developer app
## 1. Use an existing Fitbit developer app
1. Sign in at <https://dev.fitbit.com/apps/new> and register a new application.
2. **OAuth 2.0 Application Type:**
Registration of new apps is no longer possible (see the note above). For an
app you registered before the shutdown, these are the settings that matter:
1. **OAuth 2.0 Application Type:**
- **Personal** — the app only ever sees the **owner's own** Fitbit account,
and gets intraday (per-minute) heart-rate/steps automatically. This is the
right choice for a single-person self-host.
- **Server** (or **Client**) — needed when the instance connects **other
people's** accounts. Intraday/heart-rate access is then granted case by
case via Fitbit's Intraday Data Access request form.
3. **Callback URL:** `https://your-instance.example.com/api/fitbit/callback`
2. **Callback URL:** `https://your-instance.example.com/api/fitbit/callback`
— absolute, **HTTPS**, and matching the `redirect_uri` HealthLog sends (see
the redirect-URI note below). One entry per environment you connect from.
4. **Scopes:** tick the ones HealthLog requests (next section).
5. Save. Fitbit issues a **Client ID** (short, numeric) and a **Client Secret**
— keep the secret out of any chat log.
3. **Scopes:** the ones HealthLog requests (next section).
4. The app's settings page shows the **Client ID** (short, numeric) and the
**Client Secret** — keep the secret out of any chat log.
## 2. Scopes
+4 -2
View File
@@ -50,8 +50,10 @@ Click **Connect WHOOP**. The flow:
2. The user signs in on WHOOP and grants the requested scopes.
3. WHOOP redirects back to `/api/whoop/callback` with an auth code.
4. HealthLog exchanges the code for an access + refresh token, stores
them encrypted, registers the webhook, and triggers an initial
sync of recent history.
them encrypted, and triggers an initial sync of recent history.
(The webhook is **not** registered per user — it is configured once
at app level in the WHOOP developer console; see the env-var
section below.)
5. Subsequent syncs run on every webhook delivery plus a safety-net
cron pull.
+20 -17
View File
@@ -38,20 +38,25 @@ arrive via the sleep series endpoint.
needed — `user.metrics` covers weight / BP / heart-list / sleep /
SpO₂ / body comp / temperature / VO₂ max; `user.activity` is the
workout-equivalent stream for steps / energy / distance / floors.
See `src/lib/withings/client.ts:42` (`WITHINGS_OAUTH_SCOPE`).
See `WITHINGS_OAUTH_SCOPE` in `src/lib/withings/client.ts`.
4. Save the application. Withings will issue a **Client ID** and a
**Client Secret** — keep the secret out of any chat log; it grants
read access to every linked account's full health history.
## 2. Wire the credentials into HealthLog
## 2. Wire the credentials in Settings → Integrations
Paste the client ID and secret into `.env`:
Withings credentials are stored per user, not in `.env`. Sign in as
the target user, open **Settings → Integrations → Withings**, and
paste the client ID and secret. They are encrypted AES-256-GCM at
rest (`src/lib/withings/credentials.ts`). On a shared instance every
user brings their own Withings app credentials; there is no
server-level client-id/secret fallback.
Two server-level env vars remain:
```env
WITHINGS_CLIENT_ID="your-client-id"
WITHINGS_CLIENT_SECRET="your-client-secret"
WITHINGS_REDIRECT_URI="https://your-instance.example.com/api/withings/callback"
WITHINGS_WEBHOOK_SECRET="$(openssl rand -hex 32)"
WITHINGS_REDIRECT_URI="https://your-instance.example.com/api/withings/callback"
```
`WITHINGS_WEBHOOK_SECRET` is the path-segment token the webhook
@@ -60,10 +65,9 @@ that fits in a URL path segment works, but a 64-character hex string
keeps it indistinguishable from random. Restart the `app` container
after saving.
Per-user override is also possible: the Settings UI accepts a
client-ID/secret pair scoped to one user, useful if you want a
shared instance where every user brings their own Withings app
credentials. Server-level env vars cover the default case.
`WITHINGS_REDIRECT_URI` is optional — when unset the callback URL
defaults to `${NEXT_PUBLIC_APP_URL}/api/withings/callback`. Set it
only when the OAuth callback must differ from that default.
## 3. Set the webhook URL
@@ -90,9 +94,8 @@ callback. The portal only owns the OAuth callback URL.
## 4. Link from the Settings page
With env vars in place and the app restarted, sign in as the target
user and open `/settings/integrations/withings`. Click **Connect
Withings**. The flow:
With the credentials saved in Settings, open
`/settings/integrations/withings` and click **Connect Withings**. The flow:
1. The Connect button redirects to Withings' `oauth2/authorize`.
2. The user signs in on Withings and grants the requested scopes.
@@ -133,10 +136,10 @@ Override per-user via the Sources section of `/settings/thresholds`.
### The Connect button errors out
- **`Withings credentials not configured`** — `WITHINGS_CLIENT_ID`
or `WITHINGS_CLIENT_SECRET` is missing from `.env`. Confirm both
are set in the container's runtime environment (`docker compose
exec app env | grep WITHINGS`).
- **`Withings credentials not configured`** — the signed-in user has
not saved a client ID + secret under **Settings → Integrations →
Withings**. Credentials live per user in the database, not in
`.env` — paste the pair from the developer portal and retry.
- **`Callback URI mismatch`** — the URL HealthLog sends to Withings
must match an entry in the developer-portal app's callback list.
Add the public URL exactly, including the `https://` scheme and
+14 -8
View File
@@ -68,20 +68,26 @@ The credentials are never returned.
## Restore
Pick a key (e.g. `2026-05-08/user-clx123.json.enc`) from the bucket
and run:
and run the restore script with the same backup credentials and
encryption key the backup was written under — a freshly generated
`BACKUP_ENCRYPTION_KEY` cannot decrypt any existing object:
```bash
BACKUP_S3_ENDPOINT=https://...r2.cloudflarestorage.com \
BACKUP_S3_BUCKET=healthlog-backups \
BACKUP_S3_ACCESS_KEY=... \
BACKUP_S3_SECRET_KEY=... \
BACKUP_S3_REGION=auto \
BACKUP_ENCRYPTION_KEY=$(openssl rand -hex 32) \
pnpm tsx scripts/restore-backup.ts \
BACKUP_S3_ENDPOINT=https://...r2.cloudflarestorage.com \
BACKUP_S3_BUCKET=healthlog-backups \
BACKUP_S3_ACCESS_KEY=... \
BACKUP_S3_SECRET_KEY=... \
BACKUP_S3_REGION=auto \
BACKUP_ENCRYPTION_KEY=<the key the backup was written under> \
pnpm dlx tsx scripts/restore-backup.ts \
2026-05-08/user-clx123.json.enc \
/tmp/restored.json
```
The production standalone image strips `tsx`, so a bare
`pnpm tsx scripts/...` fails inside the container — always invoke
one-shot scripts via `pnpm dlx tsx`.
The script downloads the object, decrypts it, and writes the JSON dump
to disk. Importing the JSON back into a HealthLog instance is left to
the operator (use `prisma db seed` or a custom script).
+2 -2
View File
@@ -44,7 +44,7 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
3. **Run the rotation script** to re-encrypt every existing encrypted column
under the new active key:
```
pnpm tsx scripts/rotate-encryption-key.ts v2
pnpm dlx tsx scripts/rotate-encryption-key.ts v2
```
The script is idempotent — running it again is a no-op for rows already
prefixed with `v2.`. It rotates User, WithingsConnection, AppSettings,
@@ -69,7 +69,7 @@ script reports zero `v2.`-prefixed rows remaining.
```
ENCRYPTION_KEYS='{"v2":"<old>","v3":"<new>"}'
ENCRYPTION_ACTIVE_KEY_ID="v3"
pnpm tsx scripts/rotate-encryption-key.ts v3
pnpm dlx tsx scripts/rotate-encryption-key.ts v3
ENCRYPTION_KEYS='{"v3":"<new>"}'
```
+65 -48
View File
@@ -1,26 +1,34 @@
# TLS leaf SPKI pin — coupling, alarm, and re-pin runbook
# TLS SPKI pinning — coupling, alarm, and re-pin runbook
The native client SPKI-pins the server's TLS **leaf** certificate. This is a
deliberate hardening choice on the client side, but it couples the client to
the exact leaf keypair the server serves: when that leaf rotates, a client
that was only ever shipped the old pin refuses to connect. This page explains
the coupling, the server-side alarm that watches for a rotation, and the
operator / release-owner steps when it fires.
The native client SPKI-pins the server's TLS chain at the **CA level**. It
ships a small pin set — currently the issuing intermediate plus the root —
and accepts a connection only when the served chain passes baseline X.509
evaluation AND at least one certificate anywhere in the chain matches a pin
(any-match). The leaf is deliberately **not** pinned, so routine leaf
renewals are a client no-op. The outage risk is a **chain change**: the
served chain stops terminating in the pinned CA keys — the server moves to a
different CA, or the CA rotates its intermediate / root keys. This page
explains the coupling, the server-side alarm that watches the served leaf,
and the operator / release-owner steps when it fires.
## The coupling
A SPKI pin is `base64(sha256(DER subjectPublicKeyInfo))` — a hash of the
A SPKI pin is `base64(sha256(DER subjectPublicKeyInfo))` — a hash of a
certificate's public key, not of the whole certificate. The native client
carries a set of these pins and rejects any TLS connection whose leaf public
key is not in the set.
carries a set of these pins (build-time config, ≥ 2 well-formed pins
enforced for release builds) and rejects any TLS connection whose validated
chain contains no pinned key. Hosts outside the client's pinned-host list
fall back to plain system-trust validation, which is the documented model
for self-hosted instances that ship their own chain.
The app host's certificate is issued by Google Trust Services (GTS) and
auto-renews roughly every 90 days. Each renewal mints a **new leaf keypair**,
so the SPKI pin changes on every renewal. Intermediate and root rolls have
the same effect on the leaf chain. Because the client pins the leaf, every
such change is a hard outage for any client that hasn't been shipped the new
pin — and there is otherwise no server-side signal that a client is pinning
the served leaf at all.
The app host's certificate auto-renews roughly every 90 days and each
renewal mints a new leaf keypair. Because the client pins the CA keys rather
than the leaf, those renewals pass without a client build. What DOES break
every pinned client is a chain that no longer terminates in a pinned key —
and there is no server-side signal that clients are pinning the served chain
at all. The alarm below exists to catch exactly that early: it watches the
served **leaf**, the most frequently rotating element, as the canary for any
chain movement.
## The alarm
@@ -46,13 +54,15 @@ successful probe returning a pin genuinely outside the known set.
### Baseline source: an env var, not auto-learning
`TLS_LEAF_SPKI_PINS` (comma-separated) is the known-good set. The baseline is
an env var on purpose, **not** a persisted "last-seen" row:
`TLS_LEAF_SPKI_PINS` (comma-separated) is the known-good set of **served
leaf** pins. It is the operator's explicit record of what the server is
expected to serve — distinct from the client's CA-level pin set, which lives
in the client build config. The baseline is an env var on purpose, **not** a
persisted "last-seen" row:
- The pinned client only trusts the pins it was shipped, so the operator must
derive the client's pin set from a single explicit source of truth. The env
var **is** that source — the same value goes into the client's pin set and
into `TLS_LEAF_SPKI_PINS`.
- An explicit baseline forces the operator to acknowledge every leaf
rotation and check the new chain against the client's pinned CA keys
before the alarm goes quiet.
- A persisted last-seen baseline would silently adopt the first rotated pin
and suppress the very alarm the pinned client needs.
- When the env var is unset, the monitor fails **loud, not open**: it logs
@@ -84,36 +94,43 @@ The variable is on the compose `environment:` whitelist and the
whether it is set. It is optional: without it the monitor still runs and logs
the served pin, but cannot alarm on a change.
## When the alarm fires — re-pin procedure
## When the alarm fires
When you see a `tls.pin.leaf_changed` alert / `system.tls.pin_changed` audit
row, the served leaf has rotated. The pinned client will stop connecting once
its shipped pin no longer matches a served leaf. Act inside the certificate's
remaining validity, with margin for app review and client roll-out — target
**≥ 11 days before the old pin's certificate expires** (the alert body and
audit row both carry the new leaf's `validTo`).
row, the served leaf has rotated. First determine which of two cases you are
in:
1. **Re-extract the new leaf pin** with the `openssl` pipeline above. It
should equal the `servedPin` in the alert.
2. **Dual-pin.** Add the new pin to the client's pin set **alongside** the old
one (do not replace it yet), and update `TLS_LEAF_SPKI_PINS` to the
comma-separated pair, e.g.
`TLS_LEAF_SPKI_PINS="<old-pin>,<new-pin>"`. Holding both pins means clients
on the old build keep working through the cutover and the server-side
alarm goes quiet because the served pin is now in the known set. Re-deploy
the server so the new env value takes effect.
1. **Routine renewal, same chain.** Inspect the new chain (the `openssl`
pipeline above, or the alert payload). If the issuing intermediate and
root are unchanged, the client's CA-level pins still match and no client
is at risk. Update `TLS_LEAF_SPKI_PINS` to the new served-leaf pin and
re-deploy so the alarm goes quiet. No client build.
2. **Chain change.** The new leaf chains through a different intermediate /
root (CA switch, or the CA rotated its keys). Every pinned client will
stop connecting once the old chain is no longer served — run the re-pin
procedure below inside the old chain's remaining validity, with margin
for app review and client roll-out (the alert body and audit row carry
the new leaf's `validTo`).
### Re-pin procedure (chain change)
1. **Extract the new chain's CA pins** (intermediate + root) with the
`openssl` pipeline / the client repo's SPKI-extraction script.
2. **Dual-pin.** Add the new CA pins to the client's pin set **alongside**
the old ones (do not replace them yet). Clients on the old build keep
working as long as the old chain is still served; the dual-pinned build
works against both chains. Update `TLS_LEAF_SPKI_PINS` to the currently
served leaf pin(s) so the server-side alarm reflects reality.
3. **Ship the client build** carrying the dual-pin set to TestFlight (and
onward to release). Give it long enough to roll out to the installed base
before the next renewal.
4. **Retire the old pin** once the old leaf is no longer served and the
dual-pinned client build has reached the installed base: drop the old pin
from both the client pin set and `TLS_LEAF_SPKI_PINS`, leaving only the
current leaf pin.
onward to release). Give it long enough to roll out to the installed
base before the old chain disappears.
4. **Retire the old pins** once the old chain is no longer served and the
dual-pinned client build has reached the installed base.
Treat the dual-pin window as standing practice: always ship the **next** pin
before the current leaf rotates, so a renewal never catches a single-pinned
client. If GTS renews on a fixed cadence, pre-extracting and dual-pinning the
upcoming leaf ahead of the renewal turns every rotation into a no-op.
CA-level pins move rarely — intermediates and roots carry multi-year
lifetimes and rotations are announced well ahead. Track the pinned
certificates' expiry dates and pre-stage the successor pins before the
cutover, so a chain change never catches a single-chain client.
## Environment assumption
+32 -3
View File
@@ -24,15 +24,44 @@ deployed in three modes:
3. `docker compose up -d --build`.
Both containers connect to the same Postgres. pg-boss claims jobs
atomically via row-level locking, so it is safe to run multiple worker
replicas — they will share the load.
atomically via row-level locking (`FOR UPDATE SKIP LOCKED`), so
running multiple worker replicas is **safe** — no job runs twice —
but it doubles the DB load and muddies telemetry without adding
throughput a personal instance needs (`src/lib/process-type.ts`).
Run **one** worker container and scale the `web` containers instead.
## Apple Health import staging (shared volume required for a split)
The Apple Health `export.zip` import streams the upload to a staging
file on local disk (under the container's temp dir) and hands the
worker only the file **path**. In single-container mode
(`HEALTHLOG_PROCESS_TYPE=all`, the default) the web handler and the
worker share the same filesystem, so the handoff just works.
In a web/worker split — or any topology where the container that
accepts the upload is not the one that runs the job (a `web` +
`worker` split, extra `all` replicas, or old+new containers coexisting
during a rolling deploy) — the worker cannot see the web container's
staging file. The import then fails with a staging-file-missing error
("the import staging file is not visible to the worker"). Two things to
know:
- A pure `web` container has no pg-boss handle, so an import kicked off
there currently returns `503 Background worker is not running`. Run
imports from a container that also runs the worker until send-only
boss support lands.
- If you run web and worker as separate containers, they **must share
the import staging directory** — mount the same named volume at the
temp path on both — or run imports in single-container (`all`) mode.
Without a shared staging volume, imports in split mode cannot
complete.
## Healthchecks
- `app` (web) healthcheck: `wget /api/version` every 30s.
- `app-worker` does not expose a port; its liveness is reported into
the `worker_status` table on every reminder pass and surfaced through
`/api/admin/worker-status`.
`/api/admin/status`.
The web container does NOT depend on the worker, and vice versa, so
neither container's startup can deadlock the other. The shared
+150
View File
@@ -0,0 +1,150 @@
# SSO login (OIDC)
HealthLog can delegate sign-in to a self-hosted OpenID Connect identity
provider — Authentik, Keycloak, Authelia, Pocket-ID, or anything else
that serves a standard discovery document. The flow is the OIDC
authorization-code flow with PKCE against a single, env-configured
provider; by default it is **additive** (the SSO button appears next to
password and passkey login), with an explicit opt-in to make it the only
way in.
## Setup
1. Register a **confidential** OIDC client at your IdP with the redirect
URI
```
${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback
```
The comparison at the IdP is byte-exact: scheme, host, port, and path
all matter. `https://healthlog.example.com/api/auth/oidc/callback` and
`http://healthlog.example.com/api/auth/oidc/callback` are different
URIs, and a trailing slash breaks the match.
2. Set the three provider vars (all three together enable the login-page
button; anything less keeps SSO fully disabled):
```env
OIDC_ISSUER_URL="https://idp.example.com"
OIDC_CLIENT_ID="healthlog"
OIDC_CLIENT_SECRET="..."
```
The issuer must serve
`<issuer>/.well-known/openid-configuration`. Prefer HTTPS — the
authorization code and the token response carrying the ID token cross
this connection. Plain HTTP is defensible only on a fully trusted
private network (IdP and app on the same box, a Tailscale mesh, a
VPN); a LAN-only issuer is explicitly supported.
3. Optional tuning:
```env
OIDC_SCOPES="openid email profile" # default shown
OIDC_BUTTON_LABEL="Single Sign-On" # default shown
OIDC_ONLY="false" # see below before changing
```
4. Remember the compose env whitelist: every `OIDC_*` var must appear
under `environment:` in `docker-compose.yml` (the shipped file lists
them) — vars not on the whitelist never reach the container even when
`.env` has them.
`pnpm check-env` knows all six vars and flags a partially configured
provider group.
## Per-IdP notes
Client registration essentials only — the IdP's own docs cover the rest.
- **Authentik.** Create a _Provider_ of type OAuth2/OpenID (confidential
client), bind it to an _Application_, and use the provider's issuer URL
(shaped like `https://authentik.example.com/application/o/<app-slug>/`
— the trailing slash is part of Authentik's issuer; the discovery
check tolerates the difference). Signing key: any RSA/EC key; the
default RS256 works.
- **Keycloak.** Create a client with _Client authentication_ ON and the
_Standard flow_ enabled. The issuer is
`https://keycloak.example.com/realms/<realm>`. Keep the default RS256
signature algorithm; do not switch the client to `HS256` — HealthLog
rejects HMAC-signed ID tokens.
- **Authelia.** Define a client under `identity_providers.oidc.clients`
with `token_endpoint_auth_method: client_secret_post` (HealthLog sends
the secret in the POST body) and the redirect URI above. The issuer is
Authelia's root URL.
- **Pocket-ID.** Add an OIDC client, copy the generated ID and secret,
and use the Pocket-ID base URL as the issuer. Pocket-ID authenticates
users by passkey at the IdP; to HealthLog it is a provider like any
other.
## Security model
- **Identity is pinned, not guessed.** The first OIDC sign-in either
provisions a fresh account or links to an existing account matched by
**verified** email (`email_verified: true` — an IdP that does not
assert verification is rejected). Either way the account is stamped
with the provider's `(issuer, sub)` pair, and every later login
matches on that pair alone. An email change at the IdP updates the
displayed email; it can never re-point the login at a different
account. An email that matches an account already pinned to a
_different_ identity is rejected and audited.
- **SSO sessions never satisfy step-up.** Destructive actions (disabling
MFA, rotating encryption keys, encrypted export, account deletion)
require a _fresh_ second factor confirmed by HealthLog itself. An OIDC
login is a delegated factor — whatever the IdP checked, this app did
not see it — so an SSO session always has to confirm a native factor
(or the password path) when it hits one of those gates.
- **Accounts with native 2FA keep it.** If the account has a confirmed
TOTP secret or a registered security key, an OIDC login does not
complete on its own: after the IdP round-trip the user lands in the
same second-factor step password login uses. SSO never downgrades an
account's own MFA.
- **MFA belongs at the IdP.** For SSO-provisioned accounts, enforce
MFA in the IdP's policy engine — that is the point of delegating
authentication. HealthLog-side 2FA remains available on top for the
step-up gates above.
- **Linking is once.** There is no unlink/relink flow in this cut; the
pin is deliberately sticky. Moving an account to a new IdP identity is
an operator decision (a manual `users.oidc_issuer` / `users.oidc_sub`
update), not something a login can do.
## OIDC_ONLY
`OIDC_ONLY="true"` disables password and passkey login **server-side**
(the routes return 403; hiding the buttons is not the boundary).
Consequences to accept before turning it on:
- **The native iOS app cannot sign in.** It authenticates via
password/passkey only; there is no native SSO flow yet. Do not enable
`OIDC_ONLY` on a deployment with active iOS users.
- **Existing sessions survive.** The flag gates new sign-ins, not live
sessions — nobody is thrown out at flip time, including
password-authenticated sessions.
- **Self-disabling on half-config.** The flag only takes effect while
the three provider vars are fully set, so a typo in the provider group
can never lock every user out.
### Break-glass (IdP down or misconfigured)
1. Unset `OIDC_ONLY` (or set it to `"false"`) in `.env`, then
`docker compose up -d`. Password and passkey login are live again
immediately.
2. Accounts that were **provisioned** through SSO have no password. Set
one from the host, per [docs/ops/password-reset.md](../ops/password-reset.md):
```sh
docker compose exec app node scripts/reset-password.mjs <username-or-email>
```
## Troubleshooting
| Symptom | Cause / fix |
| --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Login page shows "Single sign-on failed", log says `discovery issuer mismatch` | `OIDC_ISSUER_URL` must equal the `issuer` inside the discovery document. A single trailing slash either way is tolerated; anything else (scheme, host, port, path, `//`) is a hard reject. |
| `discovery request failed: 3xx` | Discovery redirects are not followed (egress policy pins `redirect: "manual"`). Point `OIDC_ISSUER_URL` at the final URL — e.g. include the realm/application path, match `http` vs `https`, add/remove `www`. |
| IdP shows its own "invalid redirect URI" page | The mismatch happens **at the IdP**, before HealthLog is involved. Compare the registered URI against `${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback` byte for byte — scheme and port included. |
| Sign-in loops back with "failed" and the log shows an `exp`/`nbf` claim error | Clock skew beyond the 60 s tolerance. Fix NTP on the IdP or app box. |
| "Your identity provider did not mark your email address as verified" | The IdP omits `email_verified` or sends `false`. Mark the address verified at the IdP (Keycloak: user → Email verified; Authentik: verify the email stage) — HealthLog will not link or provision without it. |
| "That email address belongs to an account that is already linked to a different SSO identity" | Deliberate: identities never re-bind silently. See "Linking is once" above. |
| SSO button missing | One of the three provider vars is empty or not whitelisted in compose. `pnpm check-env` shows which. |
+75
View File
@@ -98,6 +98,70 @@ test.describe("authenticated dashboard render", () => {
}),
);
// S2 — the Today hero reads the unified daily digest from
// `/api/daily/digest`. Mock it with a deterministic digest (a score +
// one worth-a-look item) so the hero paints without reaching the real
// route, and so a slow/absent digest can never hang the render or
// surface a console error under the significant-error gate below.
await page.route(/\/api\/daily\/digest(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
generatedAt: new Date().toISOString(),
phase: "final",
sleepPending: false,
score: { value: 82, band: "green", delta: 2 },
topSignal: null,
briefingLead: "Your week is trending steady.",
line: "Your week is trending steady.",
worthALook: [
{
kind: "sync_issue",
title: "Sync needs attention",
body: "Withings isn't syncing.",
status: "warning",
actions: [
{
labelKey: "daily.action.reconnect",
intent: "sync.reconnect",
href: "/settings/integrations",
},
],
},
// S3 — a coach check-in card (keep / adjust / let-go). Rendered
// only; the mutating taps PATCH the plan route, which this spec
// never clicks, so it stays green without a plan-route stub.
{
kind: "coach_checkin",
title: "Time to check in on a plan",
body: "About a week ago you set: “every morning → weigh in”.",
status: "info",
moduleKey: "coach",
actions: [
{
labelKey: "daily.action.checkinKeep",
intent: "coach.checkin.keep:p1",
},
{
labelKey: "daily.action.checkinAdjust",
intent: "coach.checkin.adjust",
href: "/coach",
},
{
labelKey: "daily.action.checkinLetGo",
intent: "coach.checkin.letGo:p1",
},
],
},
],
},
error: null,
}),
}),
);
// v1.18.6 — the dashboard now batches every visible chart's daily
// series into ONE `/api/measurements/series-batch` round-trip and
// hands each chart its slice via `preloadedSeries`. The route is a
@@ -209,6 +273,17 @@ test.describe("authenticated dashboard render", () => {
const tileCount = Number(await strip.getAttribute("data-tile-count"));
expect(tileCount).toBeGreaterThan(0);
// S2 — the Today hero paints above the tile strip from the mocked
// digest: its score ring and its worth-a-look rail render.
const todayHero = page.locator('[data-slot="today-hero"]');
await expect(todayHero).toBeVisible({ timeout: 10_000 });
await expect(
todayHero.locator('[data-slot="today-hero-score"]'),
).toBeVisible();
await expect(
todayHero.locator('[data-slot="today-hero-rail"]'),
).toBeVisible();
// At least one chart must render. Recharts mounts a `<svg>` with
// `class="recharts-surface"` (or a `<div class="recharts-wrapper">`
// around it). Use the recharts class so we don't accidentally match
+11 -19
View File
@@ -357,26 +357,18 @@ test.describe("document vault — AI assist + content search", () => {
page,
}) => {
// CONTENT_DOC_ID is seeded with source "vision" (an AI provider read it).
// No mocks: the real list GET threads the provenance through. v1.28.22 —
// the marker lives on the vault CARD's meta row (an inline glyph beside
// date · size), not in the sheet. Surface the card through search (the
// grid is virtualized, so a targeted card must be brought into view the
// way a user would).
await page.goto("/documents");
await expect(
page.getByRole("heading", { name: "Documents" }),
).toBeVisible();
await page
.getByRole("searchbox", { name: "Search documents" })
.fill(CONTENT_BODY_WORD);
await expect(
page.getByRole("button", { name: "Open Radiology note" }),
).toBeVisible({ timeout: 10_000 });
// No mocks: the real detail GET threads the provenance through. v1.28.38 —
// the vault card no longer carries the AI-read glyph (it looked wrong on
// the card face); the provenance now lives as a calm muted meta line in the
// document DETAIL view. Deep-link straight to the document so the sheet
// opens deterministically (the vault grid is virtualized).
await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
const marker = page.locator(
`[data-document-id="${CONTENT_DOC_ID}"] [data-slot="document-card-ai-read"]`,
);
await expect(marker.first()).toBeVisible();
const marker = sheet.locator('[data-slot="document-detail-ai-read"]');
await expect(marker).toBeVisible();
await expect(marker).toHaveText("Read by AI");
});
// ── (f) Text-mode refuses a non-image before any OCR/upload ──────────────
+39 -339
View File
@@ -1,20 +1,26 @@
/**
* Document vault — the P4 "chat about this document" acceptance suite.
* Document vault — the "chat about a document" entry point (v1.28.52, R3).
*
* Pins the scoped chat that lives in the detail sheet's AI area: the entry is
* offered only when a provider is available AND the document is content-indexed;
* a non-indexed document shows a calm read-it-first hint (never an error); a
* conversation streams the assistant reply and settles it as plain text with the
* always-on "not medical advice" safety note; an injection-laced document is
* described, never obeyed, and any HTML the model emits renders inert (the XSS
* posture); and with NO provider the AI area collapses to the settings pointer
* with no chat surface at all.
* The bespoke in-sheet chat drawer is gone (Documents R3). Asking the Coach
* about a document now opens the REAL Coach conversation, scoped to that
* document, in the shared SIDE DRAWER: the detail sheet's Coach affordance
* closes the sheet and opens the coach drawer pre-scoped to the document (its
* maximize control expands to the full `/coach` page). The conversation runs on
* the same hardened fenced endpoint — only WHERE it renders changed.
*
* v1.27.31 lesson: the panel depends on new endpoints (the capability probe, the
* chat history GET, the streaming chat POST), so every one is mocked here —
* otherwise the panel never renders and the specs time out waiting for elements.
* The provider round-trips are mocked, so the SSE reply is deterministic. The
* seeded demo user has no AI provider, so the no-provider state is the default.
* This suite pins the documents-side contract: the affordance is offered only
* when a provider is available, and it OPENS THE DOC-SCOPED DRAWER in place (no
* full-page nav); with NO provider the AI area collapses to the settings
* pointer and no Coach entry renders at all. The Coach-side rendering (the
* "Chatting about" scope banner + the not-indexed hint) is covered by the
* coach-conversation component test; the fenced-send guarantee (a document turn
* never reaches the tool route) by the coach-send-target + persistence-scope
* unit tests.
*
* v1.27.31 lesson kept: the sheet's AI area depends on the capability probe +
* usage endpoints, so both are mocked — otherwise the AI area never renders and
* the specs time out. The seeded demo user has no AI provider, so the
* no-provider state is the default.
*/
import { expect, test, type Page } from "@playwright/test";
@@ -22,7 +28,6 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
ensureVaultFixture,
ensureVaultAiFixture,
AI_PROBE_DOC_ID,
CONTENT_DOC_ID,
} from "./setup/vault-fixture";
@@ -54,20 +59,11 @@ function usageBody() {
};
}
/** Serialise SSE frames the way the chat route emits them. */
function sse(frames: Array<Record<string, unknown>>): string {
return frames.map((f) => `data: ${JSON.stringify(f)}\n\n`).join("");
}
/**
* Mock the AI-availability surface (usage + the document-scoped capability
* probe) so the sheet offers the AI area and the chat entry. `egress` drives the
* vendor-blind notice; default external.
* probe) so the sheet offers the AI area and the Coach entry.
*/
async function mockAiEnabled(
page: Page,
opts: { egress?: "local" | "external" } = {},
): Promise<void> {
async function mockAiEnabled(page: Page): Promise<void> {
await page.route("**/api/documents/inbound/usage", (route) =>
fulfilJson(route, usageBody()),
);
@@ -78,13 +74,11 @@ async function mockAiEnabled(
mode: "vision",
reason: null,
pdfSupported: true,
egress: opts.egress ?? "external",
egress: "external",
},
error: null,
}),
);
// The detail sheet reads the auto-AI-read opt-in; default it OFF so the AI
// section renders its full set of controls alongside the chat entry.
await page.route("**/api/auth/me/documents-auto-ai-read", (route) =>
fulfilJson(route, { data: { documentsAutoAiRead: false }, error: null }),
);
@@ -98,334 +92,45 @@ test.describe("document vault — chat about a document", () => {
await ensureVaultAiFixture();
});
// The chat drives a mocked provider round-trip; on a loaded CI runner it races
// the default 30s, so the whole group earns the tripled timeout.
test.beforeEach(() => {
test.slow();
});
// ── (a) Indexed + available: the entry opens an empty, safe chat surface ──
// ── (a) Provider available: the Coach entry opens the doc-scoped drawer ──
test("offers the chat entry and opens an empty, safety-noted surface", async ({
test("offers the Coach entry and opens the document-scoped Coach drawer in place", async ({
page,
}) => {
await mockAiEnabled(page);
// The document has no thread yet → the history list is empty.
await page.route(
`**/api/documents/inbound/${CONTENT_DOC_ID}/chat**`,
(route) => {
if (route.request().method() !== "GET") return route.fallback();
return fulfilJson(route, {
data: { conversations: [], nextCursor: null },
error: null,
});
},
);
await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
const sheet = page.locator('[data-slot="responsive-sheet-content"]');
await expect(sheet).toBeVisible();
// The neutral Coach icon lives in the detail-sheet header; tapping it
// opens the doc-scoped chat drawer (a sibling overlay).
// The neutral Coach affordance lives in the detail-sheet AI area; tapping it
// closes the sheet and opens the REAL coach drawer scoped to this document.
const open = sheet.locator('[data-slot="document-chat-open"]');
await expect(open).toBeVisible();
await open.click();
const drawer = page.locator('[data-slot="document-chat-drawer"]');
await expect(drawer).toBeVisible();
await expect(
drawer.locator('[data-slot="document-chat-log"]'),
).toBeVisible();
await expect(
drawer.locator('[data-slot="document-chat-empty"]'),
).toBeVisible();
await expect(
drawer.locator('[data-slot="document-chat-input"]'),
).toBeVisible();
// The always-on safety note is present.
await expect(
drawer.locator('[data-slot="document-chat-safety"]'),
).toContainText("not medical advice");
// The documents-side contract: the coach drawer opens IN PLACE (no
// full-page nav) scoped to this document — its doc-scope banner renders.
const drawer = page.locator('[data-slot="coach-drawer"]');
await expect(drawer).toBeVisible({ timeout: 10_000 });
await expect(drawer.locator('[data-slot="coach-doc-scope"]')).toBeVisible({
timeout: 10_000,
});
// Still on /documents — the drawer expands to the page only via maximize.
expect(new URL(page.url()).pathname).not.toContain("/coach");
});
// ── (b) A turn streams a grounded reply that settles as plain text ──
// ── (b) No provider: the calm settings pointer, no Coach entry at all ──
test("streams the assistant reply and settles the conversation", async ({
page,
}) => {
await mockAiEnabled(page);
const CONV_ID = "e2edocchatconv0000000001";
const QUESTION = "What does the Impression say?";
const REPLY = "Per the report's Impression, the findings are unremarkable.";
let posted = false;
await page.route(
`**/api/documents/inbound/${CONTENT_DOC_ID}/chat**`,
async (route) => {
const req = route.request();
if (req.method() === "POST") {
posted = true;
await route.fulfill({
status: 200,
contentType: "text/event-stream; charset=utf-8",
body: sse([
{ type: "token", token: REPLY },
{
type: "done",
conversationId: CONV_ID,
messageId: "e2edocchatmsg0000000001",
},
]),
});
return;
}
const url = new URL(req.url());
if (url.searchParams.get("conversationId")) {
// The persisted thread the post-`done` refetch reads back.
await fulfilJson(route, {
data: {
id: CONV_ID,
title: QUESTION,
createdAt: "2026-07-07T10:00:00.000Z",
updatedAt: "2026-07-07T10:00:02.000Z",
messageCount: 2,
messages: [
{
id: "e2edocchatu0000000001",
role: "user",
content: QUESTION,
createdAt: "2026-07-07T10:00:00.000Z",
metricSource: null,
providerType: null,
promptVersion: null,
tokensUsed: null,
model: null,
},
{
id: "e2edocchatmsg0000000001",
role: "assistant",
content: REPLY,
createdAt: "2026-07-07T10:00:02.000Z",
metricSource: null,
providerType: "mock",
promptVersion: null,
tokensUsed: 42,
model: "mock-1",
},
],
},
error: null,
});
return;
}
// The thread list: empty until the turn is posted, then the new thread.
await fulfilJson(route, {
data: {
conversations: posted
? [
{
id: CONV_ID,
title: QUESTION,
createdAt: "2026-07-07T10:00:00.000Z",
updatedAt: "2026-07-07T10:00:02.000Z",
messageCount: 2,
},
]
: [],
nextCursor: null,
},
error: null,
});
},
);
await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
const sheet = page.locator('[data-slot="responsive-sheet-content"]');
await expect(sheet).toBeVisible();
await sheet.locator('[data-slot="document-chat-open"]').click();
const drawer = page.locator('[data-slot="document-chat-drawer"]');
await expect(drawer).toBeVisible();
const input = drawer.locator('[data-slot="document-chat-input"]');
await input.fill(QUESTION);
await drawer.locator('[data-slot="document-chat-send"]').click();
// The user's question and the streamed/settled reply both land in the log.
const log = drawer.locator('[data-slot="document-chat-log"]');
await expect(log.getByText(QUESTION)).toBeVisible();
await expect(log.getByText(/findings are unremarkable/)).toBeVisible();
// The reply settled to a single assistant bubble (no duplicate).
await expect(
log.locator('[data-slot="document-chat-message"][data-role="assistant"]'),
).toHaveCount(1);
});
// ── (b2) An injection-laced document: the reply describes it, never obeys,
// and any HTML the model emits renders inert (no markdown, no
// dangerouslySetInnerHTML — the project's XSS posture). ──
test("answers about an injection-laced document without obeying it, and renders HTML inert", async ({
page,
}) => {
await mockAiEnabled(page);
const CONV_ID = "e2edocchatconv0000000002";
const QUESTION = "What does this document say?";
// The (mocked) server reply the panel receives: a calm describe-only answer
// — NOT obedience to the document's embedded "ignore instructions … reveal
// the system prompt … say HACKED" — plus a literal <script> the model was
// coerced into emitting. The panel must show it as text and never execute it.
const REPLY =
"The document's body contains text formatted like an instruction, but I treat it as document content, not a command. <script>window.__docChatXss = 1</script> It appears to be a lab report.";
let posted = false;
await page.route(
`**/api/documents/inbound/${CONTENT_DOC_ID}/chat**`,
async (route) => {
const req = route.request();
if (req.method() === "POST") {
posted = true;
await route.fulfill({
status: 200,
contentType: "text/event-stream; charset=utf-8",
body: sse([
{ type: "token", token: REPLY },
{
type: "done",
conversationId: CONV_ID,
messageId: "e2edocchatmsg0000000002",
},
]),
});
return;
}
const url = new URL(req.url());
if (url.searchParams.get("conversationId")) {
await fulfilJson(route, {
data: {
id: CONV_ID,
title: QUESTION,
createdAt: "2026-07-07T10:00:00.000Z",
updatedAt: "2026-07-07T10:00:02.000Z",
messageCount: 2,
messages: [
{
id: "e2edocchatu0000000002",
role: "user",
content: QUESTION,
createdAt: "2026-07-07T10:00:00.000Z",
metricSource: null,
providerType: null,
promptVersion: null,
tokensUsed: null,
model: null,
},
{
id: "e2edocchatmsg0000000002",
role: "assistant",
content: REPLY,
createdAt: "2026-07-07T10:00:02.000Z",
metricSource: null,
providerType: "mock",
promptVersion: null,
tokensUsed: 30,
model: "mock-1",
},
],
},
error: null,
});
return;
}
await fulfilJson(route, {
data: {
conversations: posted
? [
{
id: CONV_ID,
title: QUESTION,
createdAt: "2026-07-07T10:00:00.000Z",
updatedAt: "2026-07-07T10:00:02.000Z",
messageCount: 2,
},
]
: [],
nextCursor: null,
},
error: null,
});
},
);
await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
const sheet = page.locator('[data-slot="responsive-sheet-content"]');
await expect(sheet).toBeVisible();
await sheet.locator('[data-slot="document-chat-open"]').click();
const drawer = page.locator('[data-slot="document-chat-drawer"]');
await expect(drawer).toBeVisible();
const input = drawer.locator('[data-slot="document-chat-input"]');
await input.fill(QUESTION);
await drawer.locator('[data-slot="document-chat-send"]').click();
const log = drawer.locator('[data-slot="document-chat-log"]');
// The describe-only answer lands; the panel renders the model's <script>
// payload as literal text (proof it is a React text child, not HTML).
await expect(log.getByText(/treat it as document content/)).toBeVisible();
await expect(log.getByText(/window\.__docChatXss/)).toBeVisible();
// The injected script never executed — no markdown lib, no innerHTML sink.
const executed = await page.evaluate(
() =>
(window as unknown as { __docChatXss?: number }).__docChatXss ?? null,
);
expect(executed).toBeNull();
// And no real <script> element was injected into the document from the reply.
const scriptCount = await page.locator("script#doc-chat-xss").count();
expect(scriptCount).toBe(0);
});
// ── (c) Not indexed: a calm read-it-first hint, never an error ──
test("shows a calm hint when the document is not indexed", async ({
page,
}) => {
// AI_PROBE_DOC_ID carries no content index (hasContentIndex === false).
await mockAiEnabled(page);
await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`);
const sheet = page.locator('[data-slot="responsive-sheet-content"]');
await expect(sheet).toBeVisible();
// A provider is available, so the Coach entry still shows — it opens the
// drawer, whose body carries the calm read-it-first hint (never an error)
// and no composer, because there is nothing indexed to ground on yet.
const open = sheet.locator('[data-slot="document-chat-open"]');
await expect(open).toBeVisible();
await open.click();
const drawer = page.locator('[data-slot="document-chat-drawer"]');
await expect(drawer).toBeVisible();
await expect(
drawer.locator('[data-slot="document-chat-not-indexed"]'),
).toBeVisible();
// No composer, and no error styling.
await expect(
drawer.locator('[data-slot="document-chat-input"]'),
).toHaveCount(0);
await expect(
drawer.locator('[data-slot="document-chat"] [role="alert"]'),
).toHaveCount(0);
});
// ── (d) No provider: the calm settings pointer, no chat surface at all ──
test("with no provider there is no chat surface, only the settings pointer", async ({
test("with no provider there is no Coach entry, only the settings pointer", async ({
page,
}) => {
// No mocks — the seeded demo user has no AI provider configured, so the AI
// area collapses to the calm pointer and the chat slot never renders.
// area collapses to the calm pointer and the Coach entry never renders.
await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
const sheet = page.locator('[data-slot="responsive-sheet-content"]');
await expect(sheet).toBeVisible();
@@ -436,13 +141,8 @@ test.describe("document vault — chat about a document", () => {
await expect(
sheet.getByRole("link", { name: "Open AI settings" }),
).toBeVisible();
// No provider → no Coach entry in the header and no chat drawer mounted.
await expect(sheet.locator('[data-slot="document-chat-open"]')).toHaveCount(
0,
);
await expect(
page.locator('[data-slot="document-chat-drawer"]'),
).toHaveCount(0);
await expect(page.locator('[data-slot="document-chat"]')).toHaveCount(0);
});
});
+49 -15
View File
@@ -13,21 +13,21 @@ import { mockMoodInsights } from "./utils/mock-mood-insights";
* Phone-width density guard (390×844 iPhone class, 360×780 small
* Android class).
*
* Two invariants, per the report against the dashboard hero (a long
* briefing-signal headline squeezed into a one-word-per-line column
* beside a wide delta, the delta spilling past the tile edge at 360 px):
* Two invariants, per the original overflow report (long headline text
* squeezed into a one-word-per-line column beside a wide delta, the
* delta spilling past the tile edge at 360 px):
*
* 1. No horizontal page overflow — `scrollWidth` never exceeds the
* viewport width (1 px sub-pixel tolerance).
* 2. Nothing escapes its tile — for every card / hero band / list
* 2. Nothing escapes its tile — for every card / Today hero / list
* row, no descendant's visible box crosses the tile's left or
* right edge (clip-aware: content genuinely hidden by an inner
* `overflow-hidden` wrapper below the tile is fine; content cut
* off at the tile's own edge is the bug).
*
* The snapshot mock pins the worst case: hero band on, three score
* rings, and a fresh briefing whose signal rows pair wrapping German
* headlines with wide delta strings.
* The mocks pin the worst case: the Today hero (its score face + a
* worth-a-look rail) plus a fresh briefing whose rows pair wrapping
* German headlines with wide delta strings.
*
* Desktop project only — the assertions are viewport-driven via
* `setViewportSize` (the `ipad-viewport.spec.ts` pattern).
@@ -51,7 +51,7 @@ interface TileEscape {
function collectTileEscapes(): TileEscape[] {
const escapes: TileEscape[] = [];
const tiles = document.querySelectorAll(
'[data-slot="card"], [data-slot="dashboard-hero"], [data-slot="list-row"]',
'[data-slot="card"], [data-slot="today-hero"], [data-slot="list-row"]',
);
const TOLERANCE = 1;
for (const tile of tiles) {
@@ -113,10 +113,46 @@ test.describe("phone-width density guard", () => {
await page.setViewportSize(viewport);
await mockDashboardSnapshot(page, {
summaries: POPULATED_SUMMARIES,
heroVisible: true,
briefing: LONG_HEADLINE_BRIEFING,
scoreRings: MOCK_SCORE_RINGS,
});
// The Today hero reads the unified daily digest from
// `/api/daily/digest`. Mock it with a deterministic digest (a
// score + one worth-a-look item) so the hero paints without
// reaching the real route — mirror of the dashboard.spec mock.
await page.route(/\/api\/daily\/digest(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
generatedAt: new Date().toISOString(),
phase: "final",
sleepPending: false,
score: { value: 82, band: "green", delta: 2 },
topSignal: null,
briefingLead: "Your week is trending steady.",
line: "Your week is trending steady.",
worthALook: [
{
kind: "sync_issue",
title: "Sync needs attention",
body: "Withings isn't syncing.",
status: "warning",
actions: [
{
labelKey: "daily.action.reconnect",
intent: "sync.reconnect",
href: "/settings/integrations",
},
],
},
],
},
error: null,
}),
}),
);
if (route === "/insights/mood") {
await mockMoodInsights(page);
}
@@ -124,14 +160,12 @@ test.describe("phone-width density guard", () => {
await page.waitForLoadState("networkidle");
if (route === "/") {
// The guard must not pass vacuously — the hero band and its
// long-headline spotlight rows have to actually render.
// The guard must not pass vacuously — the Today hero and its
// score face have to actually render.
await expect(page.locator('[data-slot="today-hero"]')).toBeVisible();
await expect(
page.locator('[data-slot="dashboard-hero"]'),
page.locator('[data-slot="today-hero-score"]'),
).toBeVisible();
await expect(
page.locator('[data-slot="dashboard-briefing-spotlight-row"]'),
).toHaveCount(3);
}
if (route === "/insights/mood") {
+27
View File
@@ -27,7 +27,13 @@ const ROUTES = [
// Settings sub-shell — short hub, long sortable-list subpages, long form.
"/settings/layout",
"/settings/layout/dashboard",
// The other sortable-list subpages + the Modules hub: each hosts an
// order editor with the sr-only drag-hint paragraph (see the
// one-scroll-floor assertion below).
"/settings/layout/insights",
"/settings/layout/medications",
"/settings/layout/mood",
"/settings/modules",
"/settings/account",
"/settings/notifications",
// Admin sub-shell — short overview + a longer list page. Guards that the
@@ -116,6 +122,27 @@ test.describe("settings + admin vertical over-scroll guard", () => {
expect(dims, "main-content / wrapper present").not.toBeNull();
if (!dims) return;
// One-scroll-floor (UI-STANDARDS §9): `<main>` is the ONLY vertical
// scroll surface — the document itself must never become scrollable
// next to it (a second painted scrollbar + a dead dark band under
// the shell). The historic offender class: an absolutely-positioned
// sr-only element (the dnd drag-hint paragraph) whose containing
// block resolves to the initial containing block because no ancestor
// between it and the root is positioned — its static position below
// a long list then extends the DOCUMENT's scrollable overflow while
// `<main>` clips everything in normal flow.
const doc = await page.evaluate(() => ({
scrollHeight: document.documentElement.scrollHeight,
clientHeight: document.documentElement.clientHeight,
}));
expect(
doc.scrollHeight,
`document scrollHeight=${doc.scrollHeight} > viewport ` +
`${doc.clientHeight} — a second vertical scroll surface exists ` +
`beside <main> (likely an absolutely-positioned element escaping ` +
`to the initial containing block)`,
).toBeLessThanOrEqual(doc.clientHeight + 1);
// A page shorter than the viewport cannot over-scroll at all.
if (dims.scrollHeight <= dims.clientHeight) return;
+108
View File
@@ -0,0 +1,108 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Issue #490 — client-rendered timestamps follow the PROFILE timezone.
*
* `/api/auth/me` is mocked with an `Asia/Manila` profile; the sessions
* card on /settings/privacy renders `lastActiveAt` through
* `useFormatters()`, which reads the localStorage timezone mirror that
* `fetchMe` fills from the mocked payload. A fixed UTC instant must
* therefore paint the MANILA wall clock (14:30), never the Berlin
* fallback (08:30) and never the browser zone (the Playwright context is
* pinned to Europe/Berlin precisely so this assertion is meaningful).
*
* Asserts against the stable `data-slot="security-session-row"` hook,
* not viewport-dependent text.
*/
// 06:30 UTC = 14:30 Asia/Manila (+8, no DST) = 08:30 Europe/Berlin (CEST).
const LAST_ACTIVE_UTC = "2026-07-14T06:30:00.000Z";
test.describe("profile-timezone display (#490)", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("sessions card renders lastActiveAt in the Manila profile zone", async ({
page,
}) => {
await page.route("**/api/auth/me", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
id: "user_e2e",
username: "e2e-tester",
email: "e2e@healthlog.test",
role: "USER",
heightCm: 180,
dateOfBirth: "1990-01-01",
gender: "MALE",
timezone: "Asia/Manila",
timeFormat: "H24",
dateFormat: "AUTO",
onboardingCompletedAt: "2025-01-01T00:00:00.000Z",
onboardingTourCompleted: true,
gravatarUrl: null,
glucoseUnit: "mg/dL",
},
error: null,
}),
}),
);
await page.route("**/api/auth/me/sessions", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
sessions: [
{
id: "sess_e2e_current",
device: "Chrome on macOS",
ipMasked: "192.168.1.xxx",
location: null,
lastActiveAt: LAST_ACTIVE_UTC,
createdAt: LAST_ACTIVE_UTC,
isCurrent: true,
},
],
},
error: null,
}),
}),
);
await page.goto("/settings/privacy", { waitUntil: "domcontentloaded" });
// The sessions card is collapsed by default — expand it first. The
// toggle's onClick can be dropped if Playwright clicks before React has
// hydrated the handler, leaving the region `hidden` for the whole wait.
// Retry the click until `aria-expanded` actually flips.
const toggle = page.locator(
'[data-slot="settings-security-sessions-toggle"]',
);
await expect(toggle).toBeVisible({ timeout: 10_000 });
await expect(async () => {
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "true", {
timeout: 2_000,
});
}).toPass({ timeout: 15_000 });
const row = page.locator('[data-slot="security-session-row"]').first();
await expect(row).toBeVisible({ timeout: 10_000 });
// Manila wall clock (H24). The mirror write from the mocked /me may
// land after the first paint of the sessions row — the poll absorbs
// the re-render.
await expect(row).toContainText("14:30", { timeout: 10_000 });
// Never the Berlin fallback for this instant…
await expect(row).not.toContainText("08:30");
// …and never a 12-hour rendering (the H24 preference rides the same
// mirror pipeline).
await expect(row).not.toContainText("PM");
});
});
+4 -7
View File
@@ -231,11 +231,9 @@ export interface MockSnapshotOptions {
summaries?: Record<string, DataSummary>;
/** Artificial latency in ms before the route fulfils (race specs). */
delayMs?: number;
/** Render the dashboard hero band (`layout.heroVisible` opt-in). */
heroVisible?: boolean;
/** Fresh (`ready`, non-stale) briefing for the hero spotlight rows. */
/** Fresh (`ready`, non-stale) briefing for the dashboard briefing card. */
briefing?: DailyBriefing;
/** Selected hero score rings (renders beside the health-score ring). */
/** Selected score rings carried on the snapshot (server-computed). */
scoreRings?: DashboardScoreRing[];
}
@@ -263,9 +261,7 @@ export function buildMockSnapshot(
onboardingTourCompleted: true,
greetingHour: 9,
},
layout: options.heroVisible
? { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true }
: DEFAULT_DASHBOARD_LAYOUT,
layout: DEFAULT_DASHBOARD_LAYOUT,
layoutCatalogue: buildLayoutCatalogue(),
metricStates: {},
targetBands: mockTargetBands(),
@@ -283,6 +279,7 @@ export function buildMockSnapshot(
nextDueAt: null,
nextDueOverdue: false,
nextDueMedicationName: null,
nextDueMedicationId: null,
},
healthScore: options.briefing
? { score: 84, band: "green", delta: -2, restMode: null }
@@ -60,6 +60,32 @@ ruleTester.run("spacing-scale", rule, {
code: '<CardHeader className="pb-2" />',
filename: "/repo/src/components/__tests__/example.test.tsx",
},
// The card-shell `5` check is scoped to the bg-card+border pair, so the
// justified list-marker inset never trips it (no shell).
{
code: '<ul className="list-disc pl-5 space-y-1" />',
filename: APP_FILE,
},
// A directional inset ON a shell is out of scope (pl-5/pr-5 not banned).
{
code: '<div className="bg-card border-border rounded-xl border pl-5" />',
filename: APP_FILE,
},
// Form-body rhythm without a shell surface is fine.
{
code: '<form className="space-y-5" />',
filename: APP_FILE,
},
// A shell already on-scale is fine.
{
code: '<div className="bg-card border-border rounded-xl border p-4 md:p-6" />',
filename: APP_FILE,
},
// bg-card without a border is not treated as a shell.
{
code: '<div className="bg-card p-5" />',
filename: APP_FILE,
},
],
invalid: [
{
@@ -108,5 +134,34 @@ ruleTester.run("spacing-scale", rule, {
filename: APP_FILE,
errors: [{ messageId: "slotPadding" }],
},
// Card-shell `5` step: bg-card + border + p-5 on one element.
{
code: '<div className="bg-card border-border rounded-xl border p-5" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellStep5" }],
},
// …py-5, …space-y-5, …gap-5 on a shell all trip it.
{
code: '<section className="bg-card rounded-lg border py-5" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellStep5" }],
},
{
code: '<div className="bg-card border-border rounded-xl border gap-5 flex" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellStep5" }],
},
// Split across cn() args — the join still sees the shell + the step-5.
{
code: '<div className={cn("bg-card border rounded-xl", "p-5")} />',
filename: APP_FILE,
errors: [{ messageId: "cardShellStep5" }],
},
// Modifier-prefixed step on a shell.
{
code: '<div className="bg-card border-border rounded-xl border sm:p-5" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellStep5" }],
},
],
});
+2 -1
View File
@@ -6,7 +6,8 @@
* - `safe-fetch-required` — outbound fetch must route through safeFetch.
* - `api-fetch-required` — client /api/ calls must route through apiFetch.
* - `no-raw-palette-color` — ban raw Tailwind palette utilities in app UI.
* - `spacing-scale` — no pt-/pb- overrides on gap-based Card slots.
* - `spacing-scale` — no pt-/pb- overrides on gap-based Card slots,
* and no off-scale `5` step on a bg-card shell.
*/
"use strict";
+77 -32
View File
@@ -1,34 +1,47 @@
/**
* @fileoverview ESLint rule — no `pt-*` / `pb-*` overrides on the
* gap-based Card slots.
* @fileoverview ESLint rule — spacing-scale discipline. Two checks:
*
* The Card primitive is gap-based, not padding-based
* (`src/components/ui/card.tsx`): the header→body distance comes from
* the PARENT flex gap, never from slot padding. A `pb-*` on
* `<CardHeader>` or a `pt-*` on `<CardContent>` therefore adds on top
* of the gap (or is a no-op) and inverts its own intent — the drift
* class the sl-001 sweep cleaned (~35 stale overrides). Density is
* expressed on the Card itself (`<Card className="gap-2 py-3 md:py-4">`,
* the sanctioned compact tier), never on a slot.
* 1. `slotPadding` — no `pt-*` / `pb-*` overrides on the gap-based Card
* slots. The Card primitive is gap-based, not padding-based
* (`src/components/ui/card.tsx`): the header→body distance comes from
* the PARENT flex gap, never from slot padding. A `pb-*` on
* `<CardHeader>` or a `pt-*` on `<CardContent>` therefore adds on top
* of the gap (or is a no-op) and inverts its own intent — the drift
* class the sl-001 sweep cleaned (~35 stale overrides). Density is
* expressed on the Card itself (`<Card className="gap-2 py-3 md:py-4">`,
* the sanctioned compact tier), never on a slot.
*
* Detection: JSX elements named `CardHeader` / `CardContent` (any
* member-expression tail, so `UI.CardHeader` matches too) whose
* `className` attribute carries a static `pt-<n>` / `pb-<n>` fragment —
* including modifier-prefixed (`md:pb-2`) and arbitrary-value
* (`pb-[10px]`) forms. Strings are collected from the whole attribute
* subtree, so `cn("pb-2", …)`, ternaries, and template literals are all
* seen. Non-numeric suffixes (`pb-safe`) never match.
* Detection: JSX elements named `CardHeader` / `CardContent` (any
* member-expression tail, so `UI.CardHeader` matches too) whose
* `className` carries a static `pt-<n>` / `pb-<n>` fragment — including
* modifier-prefixed (`md:pb-2`) and arbitrary-value (`pb-[10px]`) forms.
* Non-numeric suffixes (`pb-safe`) never match.
*
* 2. `cardShellStep5` — no off-scale `5` step (20 px, banned by §2) on a
* hand-rolled card SHELL. The shell heuristic (§7.2) keys off the
* `bg-card` + `border` pair on ONE element: a `p-5` / `px-5` / `py-5` /
* `space-y-5` / `gap-5` there sits denser than every sibling `<Card>`
* (which is `p-4 md:p-6`), and the 4 px step reads next to a conformant
* card on the same viewport. Deliberately NARROW: it fires only when the
* same element paints a card surface, so justified `pl-5`/`pl-7`
* list-marker insets (no `bg-card`) and form-body `space-y-5` rhythm (no
* shell) never trip it. Hand-rolled shells sweep to `p-4 md:p-6`
* (or `p-3` for a dense inner tile); new surfaces compose `<Card>`.
*
* Both checks collect strings from the whole `className` subtree, so
* `cn("…", cond && "…")`, ternaries, and template literals are all seen.
*
* Scope: `src/components/**` + `src/app/**`; test files exempt — same
* gating as the sibling rules. `src/components/ui/card.tsx` itself
* composes raw `<div>`s and is untouched by construction.
* gating as the sibling rules. `src/components/ui/*` primitives compose the
* shells themselves and are untouched by construction (they carry no
* `bg-card` + `border` + step-5 combination).
*
* There is deliberately NO allowlist: no current site has a documented
* load-bearing reason for a slot padding override. If one ever appears,
* it carries an inline `eslint-disable-next-line` with the reason —
* visible in review — rather than a silent registry entry here.
* load-bearing reason. If one ever appears it carries an inline
* `eslint-disable-next-line` with the reason — visible in review — rather
* than a silent registry entry here.
*
* @see .planning/audits/2026-07-05-fable/UI-STANDARDS.md §1 + §2.
* @see .planning/audits/2026-07-05-fable/UI-STANDARDS.md §1 + §2 + §7.
*/
"use strict";
@@ -44,6 +57,19 @@ const SLOT_PADDING_RE =
const SLOT_NAMES = new Set(["CardHeader", "CardContent"]);
// Card-shell heuristic: the element paints a card surface (`bg-card`) AND a
// border. `\bborder\b` matches `border`, `border-border`, `border-b`,
// `border-dashed` — any border utility qualifies as a shell.
const SHELL_BG_RE = /\bbg-card\b/;
const SHELL_BORDER_RE = /\bborder(?:-|\b)/;
// The banned `5` step (20 px) on a shell — the subset §7.2 scopes to an
// error-clean rule: p-5 / px-5 / py-5 / space-y-5 / gap-5, with optional
// modifier prefixes (`sm:p-5`). Directional insets (`pl-5`/`pr-5`) and
// half-steps are intentionally OUT of scope (see the header).
const SHELL_STEP5_RE =
/(?:^|\s)(?:[^\s"']*:)*((?:p|px|py|space-y|gap)-5)(?=\s|$)/;
function toPosix(filename) {
return filename.replace(/\\/g, "/");
}
@@ -122,12 +148,14 @@ const spacingScaleRule = {
type: "problem",
docs: {
description:
"Ban pt-*/pb-* overrides on CardHeader/CardContent — the Card is gap-based; density is expressed via the Card's own gap/py, never via slot padding.",
"Spacing-scale discipline — no pt-*/pb-* overrides on gap-based Card slots, and no off-scale `5` step on a bg-card+border shell.",
},
schema: [],
messages: {
slotPadding:
'"{{match}}" on <{{element}}> fights the gap-based Card contract — the header→body distance comes from the parent flex gap, so slot padding stacks on top of it (or is a no-op). Tune density on the Card itself instead: <Card className="gap-2 py-3 md:py-4">.',
cardShellStep5:
'Off-scale "{{match}}" (20 px) on a bg-card+border shell — the `5` step is banned (UI-STANDARDS §2) and reads denser than every sibling <Card> (p-4 md:p-6). Sweep the shell to `p-4 md:p-6` (or `p-3` for a dense inner tile); new surfaces should compose <Card>.',
},
},
create(context) {
@@ -138,9 +166,6 @@ const spacingScaleRule = {
return {
JSXOpeningElement(node) {
const name = elementName(node);
if (!name || !SLOT_NAMES.has(name)) return;
const classNameAttr = node.attributes.find(
(attr) =>
attr.type === "JSXAttribute" &&
@@ -151,13 +176,33 @@ const spacingScaleRule = {
const strings = [];
collectStrings(classNameAttr.value, strings);
for (const { node: strNode, text } of strings) {
const m = SLOT_PADDING_RE.exec(text);
// Check 1 — pt-/pb- slot padding on the gap-based Card slots.
const name = elementName(node);
if (name && SLOT_NAMES.has(name)) {
for (const { node: strNode, text } of strings) {
const m = SLOT_PADDING_RE.exec(text);
if (m) {
context.report({
node: strNode,
messageId: "slotPadding",
data: { match: m[1], element: name },
});
}
}
}
// Check 2 — off-scale `5` step on a card shell. Join every collected
// string so the `bg-card` + `border` pair and the step-5 utility are
// seen together even when split across cn() args, then report once.
const joined = strings.map((s) => s.text).join(" ");
if (SHELL_BG_RE.test(joined) && SHELL_BORDER_RE.test(joined)) {
const m = SHELL_STEP5_RE.exec(joined);
if (m) {
context.report({
node: strNode,
messageId: "slotPadding",
data: { match: m[1], element: name },
node: classNameAttr,
messageId: "cardShellStep5",
data: { match: m[1] },
});
}
}
+58 -3
View File
@@ -66,12 +66,67 @@ const eslintConfig = defineConfig([
// token references, not utilities, and stay legal — the light-theme
// overrides in globals.css keep them AA on both themes.
"healthlog/no-dracula-utility": ["error", { checks: ["dracula"] }],
// The Card primitive is gap-based; pt-/pb- on CardHeader/CardContent
// fights the gap and re-opens the sl-001 drift class. Density lives
// on the Card itself (`gap-2 py-3 md:py-4`). See the rule header.
// Spacing-scale discipline. Two error-clean checks: pt-/pb- on
// CardHeader/CardContent fights the gap-based Card contract (re-opens
// the sl-001 drift class), and an off-scale `5` step (p-5/py-5/space-y-5/
// gap-5) on a bg-card+border shell reads denser than every sibling
// <Card>. The shell check is scoped to the bg-card+border pair so
// justified list-marker insets (pl-5/pl-7) and form-body rhythm never
// trip it. Density lives on the Card itself (`gap-2 py-3 md:py-4`) or on
// a swept `p-4 md:p-6` shell. See the rule header.
"healthlog/spacing-scale": "error",
},
},
// v1.28.17 — every recharts-rendering component funnels through the
// shared `chart-runtime.ts` barrel (see its file header): pointing N
// `next/dynamic` boundaries at N different modules mints a separate
// ~312 KB recharts chunk per boundary, a regression the project already
// paid for once. Direct `from "recharts"` imports outside the barrel's
// own static import graph previously only tripped the bundle-budget
// gate at PR-CI; this rule catches the same mistake at local lint,
// mirroring how `healthlog/safe-fetch-required` scopes its own
// wrapper-internals exemption by file list.
{
files: ["src/**/*.{ts,tsx}"],
ignores: [
"src/components/charts/chart-runtime.ts",
"src/components/admin/host-metrics-chart.tsx",
"src/components/charts/health-chart.tsx",
"src/components/charts/medication-compliance-chart.tsx",
"src/components/charts/mood-chart.tsx",
"src/components/charts/nutrient-daily-bar-chart.tsx",
"src/components/charts/scatter-correlation-chart.tsx",
"src/components/custom-metrics/custom-metric-chart.tsx",
"src/components/cycle/bbt-chart.tsx",
"src/components/insights/derived/delta-sparkline.tsx",
"src/components/insights/intraday-pulse-chart.tsx",
"src/components/insights/mood/mood-distribution-chart.tsx",
"src/components/insights/mood/mood-time-of-day-chart.tsx",
"src/components/insights/mood/mood-weekday-chart.tsx",
"src/components/insights/sleep-stage-stacked-bar.tsx",
"src/components/labs/lab-biomarker-chart.tsx",
"src/components/medications/detail/efficacy/efficacy-chart.tsx",
"src/components/medications/dose-strength-curve.tsx",
"src/components/medications/drug-level-chart.tsx",
"src/components/mental-health/assessment-history-chart.tsx",
"src/components/charts/workout-hr-chart.tsx",
"src/components/charts/workout-elevation-chart.tsx",
],
rules: {
"no-restricted-imports": [
"error",
{
paths: [
{
name: "recharts",
message:
"Import chart components through @/components/charts/chart-runtime (the shared dynamic-import barrel), not recharts directly — a second static import mints a duplicate recharts chunk. Add the component to chart-runtime.ts's export list if it's a new chart.",
},
],
},
],
},
},
]);
export default eslintConfig;
-1
View File
@@ -47,7 +47,6 @@
".next/**",
"**/__mocks__/**",
"e2e/setup/test-helpers.ts",
"src/components/charts/compliance-line-chart.tsx",
"src/lib/logging/index.ts"
],
"ignoreDependencies": [
+18
View File
@@ -16,6 +16,24 @@
# Per project policy (`--no-verify` is banned), prefer fixing the
# finding over bypassing the hook.
# Commit-message guard: the project voice rule (CLAUDE.md, "Voice and
# privacy") states that commits are authored by the maintainer's git
# identity alone and carry no tooling co-author trailer or session link.
# Editors and CLIs add those trailers by default, and a message that
# picks one up is only removable from the object store by a history
# rewrite — so the check belongs here, before the commit exists, not in
# a review pass afterwards.
commit-msg:
commands:
no-tooling-trailer:
run: |
if grep -qiE '^(Co-Authored-By:.*(claude|anthropic|copilot|gpt|gemini)|Claude-Session:|Generated with)' "{1}"; then
echo "ERROR: the commit message carries a tooling co-author trailer or session link." >&2
echo "Project policy: commits are authored by the maintainer's git identity alone." >&2
echo "Remove the offending line(s) from the message and commit again." >&2
exit 1
fi
pre-commit:
commands:
gitleaks:
+487 -101
View File
@@ -91,7 +91,7 @@
"colShare": "Anteil",
"footerDisclaimer1": "Dieser Bericht wurde automatisch aus selbst erfassten Daten generiert und dient ausschließlich zur Unterstützung",
"footerDisclaimer2": "des Arzt-Patienten-Gesprächs. Er ersetzt keine ärztliche Diagnose. Korrelationen implizieren keine Kausalität.",
"footerSource": "Quelle: HealthLog | Zeitzone: Europe/Berlin | Erstellt: {timestamp}",
"footerSource": "Quelle: HealthLog | Zeitzone: {timezone} | Erstellt: {timestamp}",
"typeWeight": "Körpergewicht",
"typeBpSys": "Systolischer Blutdruck",
"typeBpDia": "Diastolischer Blutdruck",
@@ -141,7 +141,7 @@
"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.",
"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.",
"wellnessDisclaimer": "Dies sind beschreibende Wellness-Werte, die aus den erfassten Signalen berechnet werden (0–100). Sie sind keine klinischen Beurteilungen oder Diagnosen.",
"wellnessRecovery": "Erholung",
"wellnessStress": "Stress",
"wellnessStrain": "Belastung",
@@ -228,7 +228,7 @@
"logout": "Abmelden",
"login": "Anmelden",
"notifications": "Benachrichtigungen",
"theme": "Darstellung",
"theme": "Design",
"themeSystem": "System",
"themeDark": "Dunkel",
"themeLight": "Hell",
@@ -286,6 +286,15 @@
"passwordTooShort": "Passwort muss mindestens {minLength} Zeichen lang sein.",
"privacyPolicy": "Datenschutzerklärung",
"passwordBreached": "Dieses Passwort taucht in bekannten Datenlecks auf. Bitte wähle ein anderes.",
"oidc": {
"signInDefault": "Mit SSO anmelden",
"errorDenied": "Die Anmeldung wurde abgebrochen.",
"errorNoEmail": "Dein Identitätsanbieter hat keine bestätigte E-Mail-Adresse übermittelt.",
"errorEmailUnverified": "Dein Identitätsanbieter hat deine E-Mail-Adresse nicht als bestätigt markiert.",
"errorIdentityConflict": "Diese E-Mail-Adresse gehört zu einem Konto, das bereits mit einer anderen SSO-Identität verknüpft ist.",
"errorRegistrationDisabled": "Kein Konto passt zu dieser Identität, und neue Registrierungen sind derzeit geschlossen.",
"errorFailed": "Single Sign-On ist fehlgeschlagen. Bitte versuche es erneut."
},
"mfa": {
"title": "Zwei-Faktor-Authentifizierung",
"totpHint": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.",
@@ -318,37 +327,7 @@
"welcomeBackWithName": "{greeting} {name}, willkommen zurück.",
"hero": {
"scoreLabel": "Health Score",
"ringLink": "{metric}-Details öffnen",
"scoreProvisional": "Noch nicht genug Daten",
"scoreComputing": "Score wird berechnet…",
"ringDoses": "Dosen heute",
"ringDosesAria": "{taken} von {scheduled} Dosen heute genommen",
"ringCarousel": {
"aria": "Wellness-Ringe",
"dot": "Ring {index} von {total} anzeigen"
},
"verdict": {
"doseOverdue": "Eine Dosis {name} ist überfällig.",
"doseOverdueNoName": "Eine Dosis ist überfällig.",
"bpHigh": "Dein letzter Blutdruck war sehr hoch ({sys}/{dia}). Miss in Ruhe noch einmal nach.",
"doseUpcoming": "Um {time} steht {name} an.",
"doseUpcomingNoName": "Um {time} steht eine Dosis an.",
"weightDrift": "Dein Gewicht bewegt sich diese Woche vom Zielbereich weg.",
"shortNights": "Deine Nächte waren zuletzt kurz — im Schnitt {hours} h.",
"silence": "Seit {days} Tagen keine Messung. Ein kurzer Eintrag hält dein Bild aktuell.",
"scoreDrop": "Dein Score liegt {points} Punkte unter der Vorwoche.",
"allQuiet": "Alles ruhig — heute gibt es nichts, das deine Aufmerksamkeit braucht."
},
"action": {
"takeDose": "Jetzt eintragen",
"viewBp": "Blutdruck ansehen",
"viewWeight": "Gewicht ansehen",
"viewSleep": "Schlaf ansehen",
"logMeasurement": "Messung erfassen"
},
"briefing": {
"title": "Tagesbriefing"
}
"ringDoses": "Dosen heute"
},
"greetingSalutation": "Hallo, {name}",
"streakLabel": "Tage in Folge",
@@ -401,8 +380,6 @@
"compliance7d": "Therapietreue (7 Tage)",
"mood": "Stimmung",
"customizeTitle": "Dashboard-Layout",
"heroToggleLabel": "Tagesüberblick",
"heroToggleDescription": "Zeigt Begrüßung, Tagesfokus und Score-Ringe ganz oben auf dem Dashboard.",
"scoreRingsTitle": "Score-Ringe",
"scoreRingsDescription": "Bis zu drei zusätzliche Ringe neben dem Health Score – zum Beispiel Bereitschaft, Erholung, Schlaf oder Therapietreue. Zum Umsortieren ziehen; der Health Score steht standardmäßig vorne.",
"layoutReset": "Auf Defaults zurücksetzen",
@@ -428,7 +405,6 @@
"recentWorkouts": {
"title": "Letzte Workouts",
"viewAll": "Alle anzeigen",
"loading": "Workouts werden geladen…",
"empty": {
"title": "Noch keine Workouts",
"cta": "Öffne die Apple-Health-App auf dem iPhone, damit Workouts hier erscheinen."
@@ -610,9 +586,27 @@
"typeWho5Score": "WHO-5-Wert",
"typeSciScore": "SCI-Wert"
},
"metricCatalog": {
"title": "Was HealthLog erfassen kann",
"description": "Alle Messwert-Bereiche, die HealthLog unterstützt — auch die, für die noch keine Daten vorliegen — und was sie liefert.",
"statusPresent": "Erfasst",
"statusAbsent": "Noch nicht erfasst",
"statusModuleOff": "Aus",
"loadError": "Erfassungsstatus konnte nicht geladen werden.",
"connectCta": "Quelle verbinden",
"viewCta": "Ansehen",
"ecg": {
"source": "Einkanal-EKG-Streifen von einer kompatiblen Uhr — aktuell Withings ScanWatch — werden automatisch synchronisiert."
},
"watchdog": {
"title": "Automatische Überwachung",
"description": "HealthLog beobachtet außerdem still im Hintergrund — Benachrichtigungen zu unregelmäßigem Rhythmus und Atemstörungen von einer verbundenen Uhr, Warnungen bei Abweichungen von deiner Baseline sowie auffällige Veränderungen zwischen deinen letzten beiden Laborwerten — und zeigt eine Karte nur, wenn etwas auffällt. Ist mindestens eine dieser Quellen verbunden, ist keine Karte ein gutes Zeichen, kein fehlendes Feature; ist noch keine verbunden, wird schlicht noch nichts überwacht."
}
},
"mood": {
"title": "Stimmung",
"subtitle": "Stimmungsverlauf und Einträge",
"mentalWellbeingLink": "Check-in zum seelischen Wohlbefinden machen",
"addEntry": "Hinzufügen",
"editEntry": "Eintrag bearbeiten",
"moodLevel": "Stimmung",
@@ -1426,7 +1420,10 @@
"confirmAction": "Löschen",
"toast": "Einnahme gelöscht",
"failed": "Einnahme konnte nicht gelöscht werden."
}
},
"viewRecent90": "Letzte 90 Tage",
"viewAll": "Komplette Historie",
"viewToggleLabel": "Verlaufsbereich"
},
"verlauf": {
"loading": "Verlauf wird geladen…",
@@ -1814,7 +1811,8 @@
"label": "Nicht der richtige Messwert?",
"placeholder": "Verfolgen anhand…",
"apply": "Übernehmen",
"reset": "Auf Standard zurücksetzen"
"reset": "Auf Standard zurücksetzen",
"error": "Diese Änderung konnte nicht gespeichert werden. Bitte erneut versuchen."
},
"beforeAfter": {
"summary": "Ø {before} davor → Ø {after} seitdem – eine Veränderung von {delta}.",
@@ -1829,7 +1827,10 @@
"subtitle": "Was mit den Zielwerten deiner Medikamente zeitlich zusammenfällt – nur ein Zusammenhang, kein Urteil.",
"link": "Wirkung öffnen",
"empty": "Noch nicht genug Daten, um deine Medikamente mit ihren Zielwerten in Beziehung zu setzen."
}
},
"scaleAbsolute": "Absolut",
"scalePercentSinceStart": "% seit Start",
"scaleToggleLabel": "Diagrammskala"
}
},
"charts": {
@@ -1978,6 +1979,61 @@
"notAvailableForCompliance": "Vergleich für Therapietreue nicht verfügbar"
},
"insights": {
"correlation": {
"direction": {
"higher": "höher",
"lower": "niedriger"
},
"weekdayDirection": {
"above": "über",
"below": "unter"
},
"channelFactor": "die Bewertung von {factor}",
"channel": {
"WEIGHT": "das Gewicht",
"BLOOD_GLUCOSE": "der Blutzucker",
"BLOOD_PRESSURE_SYS": "der systolische Blutdruck",
"BLOOD_PRESSURE_DIA": "der diastolische Blutdruck",
"ACTIVITY_STEPS": "die tägliche Schrittzahl",
"SLEEP_DURATION": "die Schlafdauer",
"HEART_RATE_VARIABILITY": "die Herzratenvariabilität",
"RESTING_HEART_RATE": "die Ruheherzfrequenz",
"TIME_IN_DAYLIGHT": "die Zeit im Tageslicht",
"MOOD": "die Stimmung",
"PULSE": "der Ruhepuls",
"MEDICATION_COMPLIANCE": "die Medikamententreue",
"SYMPTOM_SEVERITY": "die Symptomstärke",
"ENV_TEMP_MEAN": "die Tagestemperatur",
"ENV_TEMP_MIN": "die nächtliche Tiefsttemperatur",
"ENV_SUNSHINE": "der Sonnenschein",
"ENV_DAYLIGHT": "das Tageslicht",
"ENV_PRECIP": "der Niederschlag",
"ENV_PRESSURE_MEAN": "der Luftdruck",
"ENV_PRESSURE_DELTA": "die Luftdruckschwankung am Tag"
},
"daily": {
"high": "Wenn {behaviour} höher liegt, fällt {outcome} am Folgetag in deinen Daten tendenziell {direction} aus — ein Muster, das Beachtung verdient, keine Ursache.",
"moderate": "Wenn {behaviour} höher liegt, scheint {outcome} am Folgetag nach bisheriger Datenlage eher {direction} auszufallen — ein Muster, das Beachtung verdient, keine Ursache.",
"faint": "Wenn {behaviour} höher liegt, gibt es in deinen Daten allenfalls einen schwachen Hinweis darauf, dass {outcome} am Folgetag {direction} ausfällt — zu gering, um sich darauf zu verlassen, niemals eine Ursache."
},
"lab": {
"default": "Höhere {lab}-Werte gehen in deinen Daten damit einher, dass {outcome} im selben Zeitraum {direction} ausfällt — ein Zusammenhang, den du ärztlich im Blick behalten solltest, niemals eine Ursache.",
"faint": "Höhere {lab}-Werte deuten in deinen Daten allenfalls schwach darauf hin, dass {outcome} im selben Zeitraum {direction} ausfällt — zu gering, um sich darauf zu verlassen, ein Zusammenhang, den du ärztlich abklären lassen solltest, niemals eine Ursache."
},
"hypothesis": {
"bpCompliance": {
"support": "Höhere Medikamententreue geht mit niedrigeren systolischen Werten einher — ein Muster, das Beachtung verdient.",
"surprising": "Medikamententreue und systolischer Wert bewegen sich in deinen Daten gemeinsam — überraschend; sprich mit deiner Ärztin oder deinem Arzt, bevor du etwas änderst.",
"none": "Medikamententreue und systolischer Wert bewegen sich in diesem Zeitraum nicht deutlich gemeinsam."
},
"moodPulse": {
"support": "Tage mit schlechterer Stimmung gehen mit höherem Ruhepuls einher — ein Muster, das Beachtung verdient.",
"surprising": "Tage mit besserer Stimmung gehen in deinen Daten mit höherem Ruhepuls einher — überraschend.",
"none": "Stimmung und Ruhepuls bewegen sich in diesem Zeitraum nicht deutlich gemeinsam."
},
"weightWeekday": "Das Gewicht am {weekday} liegt {delta} kg {direction} deinem Durchschnitt der übrigen Tage — ein Muster, das Beachtung verdient."
}
},
"emptyTitle": "Noch nicht genug Daten",
"emptyDescription": "Insights erscheinen, sobald du ein paar Messwerte erfasst hast. Beginne mit Gewicht oder Blutdruck.",
"emptyAddMeasurement": "Messwert erfassen",
@@ -2131,7 +2187,9 @@
"failedDescription": "Der KI-Anbieter hat das Zeitlimit überschritten oder einen Fehler zurückgegeben. Versuche es erneut oder erhöhe in den KI-Einstellungen das Antwort-Zeitlimit für ein langsames lokales Modell.",
"failedDescriptionTimeout": "Die KI-Anfrage hat das Zeitlimit überschritten, bevor das Briefing fertig war. Erhöhe für ein langsames lokales Modell in den Einstellungen das Antwort-Zeitlimit und versuche es dann erneut.",
"failedDescriptionProvider": "Der KI-Anbieter hat die Anfrage abgelehnt. Prüfe Anbieter und API-Schlüssel in den KI-Einstellungen und versuche es erneut.",
"failedDescriptionRateLimit": "Der KI-Anbieter drosselt derzeit die Anfragen. Versuche es in Kürze erneut."
"failedDescriptionRateLimit": "Der KI-Anbieter drosselt derzeit die Anfragen. Versuche es in Kürze erneut.",
"omittedTitle": "Briefing zurückgehalten",
"omittedUngroundedDescription": "Das erstellte Briefing nannte eine Zahl, die sich nicht anhand deiner Daten überprüfen ließ, deshalb wurde es nicht angezeigt. Versuche es erneut."
},
"trendsRow": {
"title": "Trends",
@@ -2202,6 +2260,12 @@
"errorCredentialExpired": "Deine Verbindung zum KI-Anbieter ist abgelaufen. Stelle sie in den Einstellungen wieder her, damit der Coach weiterläuft.",
"tagline": "Persönlicher Gesundheitscoach, auf deine Daten gestützt.",
"newChat": "Neuer Chat",
"docScope": {
"badge": "Dokument",
"chattingAbout": "Chat über: {title}",
"chattingAboutMore": "Chat über: {titles} und {count} weitere",
"notIndexedHint": "Lies dieses Dokument zuerst im Archiv mit KI ein, damit der Coach dazu antworten kann."
},
"actionsMenu": "Aktionen",
"maximizeAriaLabel": "Im Vollbild öffnen",
"minimizeAriaLabel": "Zurück zum Seitenfenster",
@@ -2255,7 +2319,9 @@
"historySearchPlaceholder": "Unterhaltungen durchsuchen…",
"historyEmpty": "Deine Unterhaltungen erscheinen hier. Stell eine Frage, um zu starten.",
"historyDeleteAria": "Unterhaltung löschen",
"historyDeleteConfirm": "Diese Unterhaltung löschen? Das lässt sich nicht rückgängig machen.",
"historyDeleted": "Unterhaltung gelöscht",
"historySearchEmpty": "Keine Unterhaltungen entsprechen deiner Suche.",
"historyLoadingMore": "Mehr wird geladen…",
"sourcesTitle": "Worauf ich zugreife",
"sourcesFresh": "Aktuell",
"sourcesStale": "Veraltet",
@@ -2466,7 +2532,28 @@
"correlationWeightWeekday": "Folgt mein Gewicht einem Wochenmuster? Was sagt mir dieser Wochentags-Zusammenhang?",
"recommendation": "Erzähl mir mehr zu dieser Empfehlung: \"{text}\" — warum trifft sie auf mich zu, und was sollte ich tun?"
},
"assessmentPrompt": "Erkläre mir meine {metric}-Einschätzung — was bedeutet sie und was sollte ich tun?"
"assessmentPrompt": "Erkläre mir meine {metric}-Einschätzung — was bedeutet sie und was sollte ich tun?",
"attach": {
"menuChooseFromDocuments": "Aus Dokumenten wählen",
"menuUploadNew": "Neues Dokument hochladen",
"trigger": "Dokumente anhängen",
"pickerTitle": "Dokumente anhängen",
"pickerDescription": "Der Coach liest nur die angehängten Dokumente – nicht deine Gesundheitsdaten.",
"pickerSearchPlaceholder": "Dokumente durchsuchen…",
"pickerEmpty": "Noch keine Dokumente in deinem Tresor.",
"pickerNotIndexed": "Noch nicht indexiert",
"pickerCap": "Du kannst noch bis zu {max} anhängen.",
"pickerConfirm": "Anhängen ({count})",
"pillRemove": "{title} entfernen",
"indexing": "Wird indexiert…",
"indexingHint": "Ein Dokument wird noch indexiert – du kannst senden, sobald es bereit ist.",
"fencedHint": "Diese Unterhaltung läuft ohne Zugriff auf deine Gesundheitsdaten – der Coach liest nur die angehängten Dokumente.",
"documentsAttachedOne": "1 Dokument angehängt",
"documentsAttachedOther": "{count} Dokumente angehängt",
"railBadge": "Dokument-Chat (abgeschottet)",
"uploadError": "Upload fehlgeschlagen. Bitte versuche es erneut.",
"notFoundError": "Dieser Dokumentlink ist nicht mehr gültig – es wird ein normaler Chat gestartet."
}
},
"relativeJustNow": "gerade eben",
"relativeMinutesAgoOne": "vor {count} Minute",
@@ -2476,11 +2563,13 @@
"relativeDaysAgoOne": "vor {count} Tag",
"relativeDaysAgoOther": "vor {count} Tagen",
"navOverview": "Übersicht",
"navCatalog": "Alle Messwerte",
"navBloodPressure": "Blutdruck",
"navWeight": "Gewicht",
"navPulse": "Puls",
"navMood": "Stimmung",
"navMedication": "Medikamente",
"navNutrients": "Nährstoffe",
"navBmi": "BMI",
"navSleep": "Schlaf",
"navHrv": "HRV",
@@ -2505,9 +2594,9 @@
"label": "Aktivität",
"header": "Aktivität & Mobilität"
},
"cardiovascularParent": {
"label": "Herz-Kreislauf",
"header": "Herz-Kreislauf-Marker"
"heartParent": {
"label": "Herz",
"header": "Herzgesundheit"
},
"hearingParent": {
"label": "Gehör",
@@ -2632,11 +2721,11 @@
"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.",
"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 (WHO 2024).",
"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.",
"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 (AHA, Circulation 2021), daher ist niedriger meist besser. Die Skala zeigt einen einheitenlosen Wert (etwa 1–12 gilt als gesunder Bereich, ab 13 als erhöht); das ist eine andere Skala als die bildgebenden cm²-Schwellenwerte, die in der Klinik verwendet werden, und nicht mit ihnen austauschbar.",
"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",
@@ -2666,7 +2755,7 @@
"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.",
"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 (CDC 2024), 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",
@@ -2678,7 +2767,7 @@
"wristTemperatureTitle": "Handgelenktemperatur",
"wristTemperatureBody": "Die Handgelenktemperatur wird im Schlaf gemessen und als Abweichung von deinem persönlichen Ausgangswert angegeben, nicht als absoluter Wert. 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 deinem Wearable 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.",
"fallsBody": "Hier werden die von deinem Wearable 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 (CDC STEADI 2024).",
"sixMinuteWalkTitle": "6-Minuten-Gehstrecke",
"sixMinuteWalkBody": "Dies ist eine Schätzung, wie weit du in sechs Minuten gehen könntest — ein etablierter klinischer Test für Mobilität und kardiopulmonale Ausdauer. Der Wearable-Wert ist ein Näherungswert, nicht der überwachte klinische Test; aussagekräftig ist der gleichbleibende oder steigende Trend.",
"stairAscentSpeedTitle": "Treppensteig-Tempo",
@@ -2726,7 +2815,8 @@
},
"valuesBack": "Zurück zu Insights",
"valuesBackToMetric": "Zurück zu {metric}",
"scoresBack": "Zurück zu Insights"
"scoresBack": "Zurück zu Insights",
"ecgDescription": "Einkanal-EKG-Aufzeichnungen deines Geräts, jeweils mit dem Ergebnis des Geräts. HealthLog zeigt die Kurve und die Aufzeichnung – die Kurve wird nicht interpretiert."
},
"sleep": {
"title": "Schlaf",
@@ -2864,7 +2954,7 @@
"sleep": {
"title": "Noch keine Schlafwerte",
"description": "Sobald Schlafdaten vorliegen (manuell oder via Withings / Apple Health), erscheint hier der nächtliche Stadien-Verlauf.",
"cta": "Withings verbinden"
"cta": "Quelle verbinden"
}
},
"navWorkouts": "Workouts",
@@ -2899,7 +2989,24 @@
"hrChartTitle": "Herzfrequenz",
"hrChartUnavailable": "Sekundengenaue Herzfrequenz-Werte werden für dieses Workout nicht gespeichert.",
"notFound": "Workout nicht gefunden.",
"loading": "Workout wird geladen…"
"loading": "Workout wird geladen…",
"hrFromPulse": "Aus deinen Herzfrequenzdaten rund um diese Einheit",
"hrAxisMinutes": "Min.",
"sportAverageLabel": "Dein aktueller Durchschnitt:",
"zonesTitle": "Belastungszonen",
"zonesCaption": "Wie sich deine Belastung auf die Herzfrequenzzonen verteilt hat.",
"zonesCaptionDevice": "Zeit pro Zone laut deinem Gerät.",
"exportGpx": "GPX exportieren",
"elevationTitle": "Höhe",
"splitsTitle": "Kilometerzeiten",
"splitsKm": "km",
"splitsTime": "Zeit",
"splitsPace": "Tempo",
"thatDayTitle": "An diesem Tag",
"dayPulse": "Puls",
"daySleep": "Schlaf",
"dayMood": "Stimmung",
"insightTitle": "Aktivitäts-Einblick"
},
"sport": {
"walking": "Gehen",
@@ -3232,6 +3339,8 @@
},
"navWalkingSpeed": "Gehgeschwindigkeit",
"mood": {
"logLink": "Stimmung eintragen",
"mentalWellbeingLink": "Check-in zum seelischen Wohlbefinden machen",
"heatmapTitle": "Stimmungskalender",
"heatmapNoEntry": "Kein Eintrag",
"legendGreat": "Super",
@@ -3392,6 +3501,7 @@
"sectionSubtitle": "Ruhewerte von Herz-Kreislauf und Atmung im Vergleich zu deiner eigenen Baseline",
"loadingLabel": "Vitalwerte werden geladen",
"loadError": "Deine Vitalwerte konnten gerade nicht geladen werden.",
"infoLabel": "Wie wird das berechnet?",
"building": "Typischer Bereich entsteht — {count} von {target} Tagen",
"typicalRange": "Typisch für dich: {low}–{high}",
"fitnessYounger": "Ausdauer wie ~{years} J. jünger",
@@ -3518,7 +3628,9 @@
"sectionTitle": "Deine Gesundheitswerte",
"sectionSubtitle": "Zusammengesetzte Werte für Erholung, Stress und Belastung aus deinen verbundenen Geräten",
"loadError": "Deine Wellness-Werte konnten gerade nicht geladen werden.",
"recovery": "Erholung",
"loadingLabel": "Deine Gesundheitswerte werden geladen",
"recovery": "Erholungs-Score",
"recoveryCrossLink": "Erholungs- und Belastungsdaten deines Wearables ansehen",
"stress": "Stress",
"strain": "Belastung"
},
@@ -3689,6 +3801,7 @@
"recovery": {
"title": "Erholung",
"description": "Wie gut sich dein Körper erholt hat – die Erholungs-, Belastungs- und Load-Signale, die dein Wearable über Nacht aufzeichnet.",
"scoreCrossLink": "Den zusammengesetzten Erholungs-Score ansehen",
"emptyTitle": "Noch keine Erholungsdaten",
"empty": "Noch keine Erholungsdaten. Verbinde ein WHOOP-, Polar- oder Oura-Konto, dann füllen sich diese Werte, sobald dein Gerät synchronisiert.",
"block": {
@@ -3823,7 +3936,66 @@
"cta": "Taille-zu-Größe-Verhältnis erfassen"
},
"coachPrefill": "Ich habe noch kein Taille-zu-Größe-Verhältnis erfasst — warum ist es eine nützliche Zahl, und wie halte ich es unter 0,5?"
}
},
"ecg": {
"sectionTitle": "EKG-Aufzeichnungen",
"sectionIntro": "Einkanal-EKG-Streifen, die dein Gerät aufgezeichnet hat. HealthLog zeigt nur die Aufzeichnung und das von deinem Gerät erstellte Ergebnis – die Kurve wird nicht gelesen oder ausgewertet.",
"loadError": "Deine EKG-Aufzeichnungen konnten nicht geladen werden.",
"backToList": "Zurück zu den Aufzeichnungen",
"disclaimer": "Dies ist das Ergebnis, das dein Aufzeichnungsgerät erstellt hat. HealthLog liest oder interpretiert EKG-Aufzeichnungen nicht und stellt keine Diagnose.",
"clinicianNote": "Wenn dich dieses Ergebnis beunruhigt, besprich diese Aufzeichnung mit einer Ärztin oder einem Arzt.",
"resultAttribution": "Aufgezeichnetes Ergebnis: {result}, wie vom Aufzeichnungsgerät gemeldet.",
"result": {
"irregular": "Vorhofflimmern erkannt",
"notDetected": "Keine Anzeichen von Vorhofflimmern",
"inconclusive": "Nicht eindeutige Aufzeichnung",
"none": "Kein Ergebnis gemeldet"
},
"meta": {
"recorded": "Aufgezeichnet",
"duration": "Dauer",
"lead": "Ableitung",
"averageHeartRate": "Durchschnittliche Herzfrequenz",
"samplingRate": "Abtastrate",
"leadSingle": "Einkanal (Ableitung I)",
"durationValue": "{seconds} s",
"bpmValue": "{bpm} bpm",
"hzValue": "{hz} Hz",
"unknown": "—"
},
"waveform": {
"ariaLabel": "EKG-Streifen aufgezeichnet {date}, Dauer {duration}, durchschnittliche Herzfrequenz {heartRate}, aufgezeichnetes Ergebnis: {result}."
},
"crossLink": {
"title": "EKG-Aufzeichnungen",
"recordingsOne": "1 Aufzeichnung vorhanden.",
"recordingsMany": "{count} Aufzeichnungen vorhanden.",
"latestResult": "Letztes Geräte-Ergebnis: {result}.",
"cta": "Aufzeichnungen ansehen"
},
"emptyTitle": "Noch keine EKG-Aufzeichnungen",
"emptyDescription": "EKG-Aufzeichnungen werden von einem kompatiblen Einkanal-Gerät synchronisiert. Sobald dein Gerät eine Aufzeichnung überträgt, erscheint sie hier.",
"emptyCta": "Gerät verbinden"
},
"intradayPulse": {
"title": "Der Verlauf deines Tages",
"caption": "Dein Puls über den Tag, gemittelt in 10-Minuten-Schritten.",
"hourlyNote": "Stündlicher Durchschnitt für diesen Tag — aktuelle Tage zeigen das volle 10-Minuten-Detail.",
"empty": "Für diesen Tag noch nicht genug Pulswerte, um den Tagesverlauf zu zeigen.",
"coverageOne": "Basierend auf 1 Messung über {hours}h.",
"coverageMany": "Basierend auf {count} Messungen über {hours}h.",
"baseline": "Ruhe",
"today": "Heute",
"previousDay": "Vorheriger Tag",
"nextDay": "Nächster Tag",
"tension": {
"morning": "Eine anhaltend erhöhte Ruhephase heute Vormittag — möglicherweise etwas Anspannung.",
"afternoon": "Eine anhaltend erhöhte Ruhephase heute Nachmittag — möglicherweise etwas Anspannung.",
"evening": "Eine anhaltend erhöhte Ruhephase heute Abend — möglicherweise etwas Anspannung.",
"night": "Eine anhaltend erhöhte Ruhephase in der Nacht — möglicherweise etwas Anspannung."
}
},
"navEcg": "EKG"
},
"thresholds": {
"defaultLabel": "Default",
@@ -3922,7 +4094,7 @@
},
"wrapUp": {
"title": "Das war die Tour",
"body": "Du kannst jedes Modul jederzeit einzeln erneut ansehen („Diese Tour zeigen“ auf jeder Seite) oder die komplette Tour unter Einstellungen → Erweitert neu starten."
"body": "Diese Führung läuft einmalig – sie öffnet sich nicht von selbst erneut. Alles Gezeigte bleibt erhalten, und du kannst Funktionen jederzeit unter Einstellungen → Module ein- oder ausschalten."
}
}
},
@@ -3950,7 +4122,7 @@
},
"source": {
"title": "Quelle verbinden",
"body": "Werte automatisch übernehmen oder weiter manuell eintragen. Hier wird nichts für dich eingerichtet – jede Karte zeigt nur, wo du sie verbindest.",
"body": "Werte automatisch übernehmen oder weiter manuell eintragen. Hier wird nichts für dich eingerichtet – jede Karte zeigt nur, wo du sie verbindest. Geräte-Quellen brauchen eine kostenlose Entwickler-App auf deinem eigenen Konto (etwa 5 Minuten); die Anleitung auf jeder Karte führt dich durch.",
"recommended": "Empfohlen",
"manual": {
"title": "Manuelle Eingabe",
@@ -3958,12 +4130,14 @@
},
"withings": {
"title": "Withings",
"body": "Waagen, Blutdruckmesser und Tracker von Withings synchronisieren automatisch.",
"cta": "Withings verbinden"
"body": "Waagen, Blutdruckmesser und Tracker von Withings synchronisieren automatisch, sobald verbunden.",
"cta": "Withings verbinden",
"setupCta": "In den Einstellungen einrichten",
"setupHint": "Braucht zuerst deine eigene, kostenlose Withings-Entwickler-App – etwa 5 Minuten, einmalig."
},
"more": {
"title": "Weitere Quellen",
"body": "Verbinde eine davon in den Einstellungen, wann immer du bereit bist.",
"body": "Verbinde eine davon in den Einstellungen, wann immer du bereit bist – die meisten brauchen zuerst eine kostenlose Entwickler-App auf deinem eigenen Konto.",
"appleHealth": "Apple Health",
"whoop": "WHOOP",
"oura": "Oura",
@@ -3995,7 +4169,8 @@
"learning": "Manche Einblicke – Glukose, Schlafrhythmus, Blutdrucktrends, Zyklus – schärfen sich in den ersten ein bis zwei Wochen, sobald sich deine Daten ansammeln. Leere Kacheln heißen jetzt nur, dass wir deine Basislinie noch lernen.",
"connectCta": "Gerät verbinden",
"logCta": "Ersten Wert eintragen",
"importCta": "Bestehende Daten importieren"
"importCta": "Bestehende Daten importieren",
"modulesCta": "Module auswählen"
},
"welcomeBack": {
"title": "Willkommen zurück",
@@ -4105,7 +4280,16 @@
"disableError": "Forschungsmodus konnte nicht deaktiviert werden. Bitte erneut versuchen."
},
"shell": {
"sectionsNav": "Einstellungs-Bereiche"
"sectionsNav": "Einstellungs-Bereiche",
"groups": {
"account": "Konto",
"yourData": "Deine Daten",
"display": "Anzeige",
"healthProfile": "Gesundheitsprofil",
"ai": "KI",
"connectivity": "Konnektivität",
"system": "System"
}
},
"sections": {
"account": {
@@ -4236,7 +4420,16 @@
"other": "Sonstige",
"unknown": "Unbekannt"
},
"subtitle": "Lege fest, welche Quelle gewinnt, wenn zwei dieselbe Messgröße liefern."
"subtitle": "Lege fest, welche Quelle gewinnt, wenn zwei dieselbe Messgröße liefern.",
"nutrients": {
"title": "Synchronisierte Nährstoffzufuhr",
"description": "Tagessummen aus Apple Health der letzten {days} Tage. Nur lesend — die Quell-App besitzt die Werte.",
"empty": "Noch keine Nährstoffdaten synchronisiert.",
"latest": "{amount} {unit} am {date}",
"daysWithData": "{count} von {days} Tagen",
"moduleHint": "Die Synchronisierung steuert das Modul „Nährstoffzufuhr“.",
"moduleHintLink": "Module verwalten"
}
},
"ai": {
"title": "KI-Anbieter",
@@ -4281,7 +4474,8 @@
},
"fullBackup": {
"title": "Voll-Backup",
"description": "Eine einzige JSON-Datei mit allen Daten — gleiches Format wie das wöchentliche Auto-Backup. Praktisch zum Selbst-Wiederherstellen via Admin-Upload.",
"description": "Alle deine Daten in einer JSON-Datei — Messwerte, Medikamente, Stimmung, Zyklus, Laborwerte, Krankheitsepisoden, Allergien, Familienanamnese und Workouts. Gleiches Format wie das wöchentliche Auto-Backup.",
"scopeNote": "Dokumentmetadaten und die KI-Zusammenfassung, falls vorhanden, sind als Klartext enthalten; die Originaldateien der Dokumente sowie GPS-Routen und Einzelmesswerte von Workouts sind es nicht — lade diese separat aus dem Dokumententresor bzw. dem Workout-Export herunter.",
"encryptToggle": "Mit einer Passphrase verschlüsseln",
"passphrasePlaceholder": "Passphrase (mind. 12 Zeichen)",
"passphraseHelp": "Leitet den Archivschlüssel per Argon2id ab und versiegelt ihn mit AES-256-GCM. Speichere die Datei mit der Endung .hlx.",
@@ -4441,7 +4635,7 @@
"description": "Strukturierte Datensätze zu Allergien und Familienanamnese."
},
"environment": {
"title": "Umwelt",
"title": "Standort & Wetter",
"subtitle": "Wetter, Tageslicht und Standort-Kontext.",
"disabledHint": "Aktiviere das Umwelt-Modul, um einen Heimatort festzulegen und tägliches Wetter und Tageslicht abzurufen.",
"searchPlaceholder": "Stadt suchen",
@@ -4629,6 +4823,7 @@
"anthropicKeyLabel": "Anthropic API-Key",
"baseUrlLabel": "Base URL",
"localKeyLabel": "API-Key (optional)",
"localDescription": "Hier funktioniert jeder OpenAI-kompatible Endpunkt — ein lokales Modell (Ollama, LM Studio) genauso wie ein Gateway wie LiteLLM, OpenRouter oder vLLM. Die Basis-URL endet auf /v1; der API-Schlüssel ist optional.",
"savedPreview": "(gespeichert {preview})",
"savedShort": "(gespeichert)",
"saveCta": "Speichern",
@@ -4704,6 +4899,7 @@
"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.",
"testReasonBadRequest": "Der Anbieter hat die Anfrage abgelehnt (HTTP {status}) — der Endpunkt hat geantwortet; das Problem liegt also am Anfrageformat oder Modellnamen, nicht an der Verbindung.",
"testReasonUnreachable": "Der KI-Anbieter war nicht erreichbar.",
"coachMemory": {
"title": "Woran sich der Coach erinnert",
@@ -5186,7 +5382,7 @@
"statusRevoked": "Widerrufen",
"statusExpired": "Abgelaufen",
"inactiveTitle": "Widerrufen und abgelaufen ({count})",
"shownOnce": "Nur einmal sichtbar. Erfassen Sie QR-Code und Passphrase jetzt — beide lassen sich später nicht wiederherstellen.",
"shownOnce": "Nur einmal sichtbar. Erfasse QR-Code und Passphrase jetzt — beide lassen sich später nicht wiederherstellen.",
"qrLabel": "QR-Code",
"qrAlt": "QR-Code mit Freigabelink und Passphrase",
"linkLabel": "Link",
@@ -5312,6 +5508,12 @@
"ouraDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
"nightscoutDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
"integrationCredentialsHint": "Wo du diese Zugangsdaten findest und wie du deine eigene App registrierst, steht in der Einrichtungsanleitung oben.",
"integrationRedirectGuide": {
"intro": "Die Verbindung läuft über deine eigene {provider}-Entwickler-App – etwa 5 Minuten Einrichtung:",
"step1": "Erstelle eine App im {provider}-Entwicklerportal.",
"step2": "Setze deren Weiterleitungs-/Callback-URL auf:",
"step3": "Trage die dort erhaltene Client-ID und das Client-Secret oben ein."
},
"security": {
"sessionsTitle": "Aktive Sitzungen",
"sessionsDescription": "Geräte und Browser, die aktuell bei deinem Konto angemeldet sind.",
@@ -5952,6 +6154,7 @@
"colAction": "Aktion",
"colDuration": "Dauer",
"colTraceId": "Trace",
"viewDetails": "Details anzeigen für {action} um {timestamp}",
"showing": "{count} von bis zu {bufferMax} gepufferten Events angezeigt",
"empty": "Puffer ist leer",
"emptyDescription": "Es passen noch keine Events zu den Filtern. Nach etwas Traffic auf Aktualisieren tippen.",
@@ -6491,6 +6694,8 @@
"eventMeasurementReminderDesc": "Wird gesendet, wenn eine erfasste Messung fällig ist.",
"eventMedicationIntakeSync": "Einnahme-Synchronisierung",
"eventMedicationIntakeSyncDesc": "Geräteübergreifende Aktualisierung, wenn eine Dosis protokolliert, eingenommen oder übersprungen wird.",
"eventDailyBriefing": "Täglicher Überblick",
"eventDailyBriefingDesc": "Eine ruhige Morgenzusammenfassung deines Tages. Standardmäßig aus, bis du sie aktivierst.",
"security": {
"newDeviceTitle": "Neue Anmeldung bei deinem Konto",
"newDeviceBody": "Eine neue Anmeldung von {device} ({location}). Falls du das nicht warst, ändere sofort dein Passwort.",
@@ -6570,9 +6775,9 @@
"hypertensionGrade1": "Hypertonie Grad 1",
"hypertensionGrade2": "Hypertonie Grad 2",
"hypertensionGrade3": "Hypertonie Grad 3",
"bradycardia": "Bradykardie",
"belowTypicalRange": "Unter dem typischen Bereich",
"elevated": "Erhöht",
"tachycardia": "Tachykardie",
"aboveTypicalRange": "Über dem typischen Bereich",
"significantlyLow": "Deutlich niedrig",
"slightlyLow": "Leicht niedrig",
"onTarget": "Im Zielbereich",
@@ -6737,7 +6942,7 @@
"title": "Dokumente",
"untitled": "Unbenanntes Dokument",
"download": "Herunterladen",
"attachmentHint": "Diese Datei wird auf dein Gerät heruntergeladen (keine Vorschau in der Seite).",
"attachmentHint": "Diese Datei wird auf Ihr Gerät heruntergeladen (keine Vorschau in der Seite).",
"exifNote": "Kamera-Metadaten (EXIF/GPS) wurden aus geteilten JPEG-, PNG- und WebP-Bildern entfernt."
}
},
@@ -7156,8 +7361,11 @@
"TOTAL_BODY_WATER": "Körperwasser",
"VISCERAL_FAT": "Viszeralfett",
"BODY_MASS_INDEX": "Body-Mass-Index (BMI)",
"WAIST_CIRCUMFERENCE": "Taillenumfang",
"PHQ9_SCORE": "PHQ-9 · Stimmung",
"GAD7_SCORE": "GAD-7 · Sorgen"
"GAD7_SCORE": "GAD-7 · Sorgen",
"WHO5_SCORE": "WHO-5 · Wohlbefinden",
"SCI_SCORE": "SCI · Schlaf"
},
"typeGroups": {
"vitals": "Vitalwerte",
@@ -7275,6 +7483,7 @@
"notePlaceholder": "Optionaler Kontext zu diesem Wert",
"requiredError": "Bitte einen Wert eingeben.",
"save": "Speichern",
"saveAndAddAnother": "Speichern & nächsten Wert",
"savedToast": "Wert gespeichert",
"saveError": "Wert konnte nicht gespeichert werden",
"biomarker": "Biomarker",
@@ -7566,6 +7775,10 @@
"mentalHealth": {
"label": "Seelisches Wohlbefinden",
"description": "Freiwillige PHQ-9- und GAD-7-Selbsttests ergänzend zum Stimmungstagebuch. Standardmäßig aus; Antworten sind verschlüsselt und bleiben aus dem KI-Coach heraus."
},
"nutrients": {
"label": "Nährstoffzufuhr",
"description": "Speichert tägliche Mikronährstoff-Summen (Vitamine, Mineralstoffe, Wasser, Koffein) aus Apple Health. Standardmäßig aus."
}
},
"illness": {
@@ -7619,7 +7832,10 @@
"notePlaceholder": "Was erwähnenswert ist (verschlüsselt).",
"saveError": "Die Episode konnte nicht gespeichert werden. Bitte erneut versuchen.",
"parent": "Teil von",
"parentNone": "Nicht verknüpft"
"parentNone": "Nicht verknüpft",
"resolved": "Beendet am",
"resolvedHelp": "Leer lassen, solange die Episode noch andauert.",
"markOngoing": "Noch andauernd"
},
"sheet": {
"title": "{date} erfassen",
@@ -7671,7 +7887,8 @@
"direction": {
"above": "über deinem Üblichen",
"below": "unter deinem Üblichen"
}
},
"settled": "Während dieser Episode wich nichts Auffälliges von deinem üblichen Bereich ab."
},
"insights": {
"title": "Dein Krankheitsverlauf",
@@ -7722,12 +7939,12 @@
"RECOVERY_SCORE": "Recovery",
"OXYGEN_SATURATION": "Sauerstoffsättigung",
"BODY_TEMPERATURE": "Körpertemperatur",
"SKIN_TEMPERATURE": "Hauttemperatur",
"RESPIRATORY_RATE": "Atemfrequenz",
"BLOOD_PRESSURE_SYS": "Systolischer Blutdruck",
"BLOOD_PRESSURE_DIA": "Diastolischer Blutdruck",
"PULSE": "Pulse",
"WEIGHT": "Weight"
"WEIGHT": "Weight",
"FUNCTIONAL_IMPACT": "Funktionelle Beeinträchtigung"
},
"listLoadError": "Episoden konnten nicht geladen werden."
},
@@ -7808,14 +8025,14 @@
"bpLowDoctor": "Zwei aktuelle Messungen hatten einen systolischen Wert unter {systolic} mmHg. Wenn dir schwindelig oder schummrig ist, setz oder leg dich hin und kontaktiere deine Ärztin oder deinen Arzt, wenn es anhält.",
"bpLowEmergency": "Zwei aktuelle Messungen hatten einen systolischen Wert unter {systolic} mmHg und du hast Beschwerden angegeben. Bei Ohnmachtsgefühl, Verwirrtheit oder kalter, feuchter Haut sofort den Notruf wählen.",
"glucoseLowTitle": "Niedriger Blutzucker",
"glucoseLowDoctor": "Zwei aktuelle Messungen lagen unter 70 mg/dL ({value} mg/dL). Nimm schnell wirkende Kohlenhydrate, miss in 15 Minuten erneut und kontaktiere deine Ärztin oder deinen Arzt, wenn die Tiefs anhalten.",
"glucoseLowEmergency": "Zwei aktuelle Messungen lagen unter 70 mg/dL ({value} mg/dL) und du hast Beschwerden angegeben. Behandle jetzt mit schnellem Zucker; wenn das nicht geht oder dir sehr unwohl ist, wähle den Notruf.",
"glucoseLowDoctor": "Zwei aktuelle Messungen lagen unter {threshold} {unit} ({value} {unit}). Nimm schnell wirkende Kohlenhydrate, miss in 15 Minuten erneut und kontaktiere deine Ärztin oder deinen Arzt, wenn die Tiefs anhalten.",
"glucoseLowEmergency": "Zwei aktuelle Messungen lagen unter {threshold} {unit} ({value} {unit}) und du hast Beschwerden angegeben. Behandle jetzt mit schnellem Zucker; wenn das nicht geht oder dir sehr unwohl ist, wähle den Notruf.",
"glucoseLowSevereTitle": "Sehr niedriger Blutzucker",
"glucoseLowSevereDoctor": "Zwei aktuelle Messungen lagen unter 54 mg/dL ({value} mg/dL). Behandle sofort mit schnell wirkenden Kohlenhydraten, miss in 15 Minuten erneut und kontaktiere deine Ärztin oder deinen Arzt.",
"glucoseLowSevereEmergency": "Zwei aktuelle Messungen lagen unter 54 mg/dL ({value} mg/dL) und du hast Beschwerden angegeben. Behandle jetzt; wenn du nichts bei dir behalten kannst oder dir sehr unwohl ist, wähle den Notruf.",
"glucoseLowSevereDoctor": "Zwei aktuelle Messungen lagen unter {threshold} {unit} ({value} {unit}). Behandle sofort mit schnell wirkenden Kohlenhydraten, miss in 15 Minuten erneut und kontaktiere deine Ärztin oder deinen Arzt.",
"glucoseLowSevereEmergency": "Zwei aktuelle Messungen lagen unter {threshold} {unit} ({value} {unit}) und du hast Beschwerden angegeben. Behandle jetzt; wenn du nichts bei dir behalten kannst oder dir sehr unwohl ist, wähle den Notruf.",
"glucoseHighTitle": "Sehr hoher Blutzucker",
"glucoseHighDoctor": "Zwei aktuelle Messungen lagen bei {value} mg/dL oder höher. Miss erneut, trink ausreichend und kontaktiere möglichst bald deine Ärztin oder deinen Arzt, wenn es so hoch bleibt.",
"glucoseHighEmergency": "Zwei aktuelle Messungen lagen bei {value} mg/dL oder höher und du hast Beschwerden angegeben. Bei Erbrechen, schneller Atmung oder Verwirrtheit sofort den Notruf wählen."
"glucoseHighDoctor": "Zwei aktuelle Messungen lagen bei {value} {unit} oder höher. Miss erneut, trink ausreichend und kontaktiere möglichst bald deine Ärztin oder deinen Arzt, wenn es so hoch bleibt.",
"glucoseHighEmergency": "Zwei aktuelle Messungen lagen bei {value} {unit} oder höher und du hast Beschwerden angegeben. Bei Erbrechen, schneller Atmung oder Verwirrtheit sofort den Notruf wählen."
}
},
"version": {
@@ -7828,6 +8045,7 @@
"mentalHealth": {
"pageTitle": "Seelisches Wohlbefinden",
"pageDescription": "Vier validierte, freiwillige Selbsttests — PHQ-9 (Stimmung), GAD-7 (Sorgen), WHO-5 (Wohlbefinden) und SCI (Schlaf) — ergänzend zu deinem Stimmungstagebuch. Aussagekräftig ist der Verlauf über die Zeit, nicht ein einzelner Wert.",
"moodLink": "Deinen Stimmungsverlauf ansehen",
"attributionLabel": "Quelle",
"attributionShow": "Quelle und Lizenz für {instrument}",
"choosePrompt": "Check-in auswählen",
@@ -7952,6 +8170,12 @@
"nummerGegenKummer": {
"name": "Nummer gegen Kummer (für junge Menschen)"
},
"telefonSeelsorgeAt": {
"name": "Telefonseelsorge Österreich (rund um die Uhr, kostenlos, vertraulich)"
},
"dargeboteneHand": {
"name": "Die Dargebotene Hand (rund um die Uhr, kostenlos, vertraulich)"
},
"krisenchat": {
"name": "krisenchat (24/7-Chat, junge Menschen)"
},
@@ -8205,7 +8429,9 @@
"openLabel": "{title} öffnen",
"attachmentBadge": "Nur Download",
"uploading": "Wird hochgeladen…",
"dismissFailed": "Fehlgeschlagenen Upload ausblenden"
"dismissFailed": "Fehlgeschlagenen Upload ausblenden",
"processing": "Wird verarbeitet…",
"ready": "Bereit"
},
"error": {
"fileTooLarge": "Diese Datei ist größer als das Limit von {maxSize}.",
@@ -8247,6 +8473,10 @@
},
"detail": {
"title": "Dokumentdetails",
"summary": {
"title": "Zusammenfassung",
"pending": "Die Zusammenfassung wird erstellt."
},
"loadError": "Dieses Dokument konnte nicht geladen werden.",
"saveError": "Die Änderung konnte nicht gespeichert werden.",
"deleteError": "Das Dokument konnte nicht gelöscht werden.",
@@ -8261,7 +8491,8 @@
"noConditions": "Mit keiner Erkrankung verknüpft",
"previewUnavailable": "Keine Inline-Vorschau für diesen Dateityp",
"uploadedMeta": "Hinzugefügt am {date} · {size}",
"share": "Teilen"
"share": "Teilen",
"labValuesLink": "{count} Laborwerte ansehen"
},
"dropOverlay": {
"title": "Zum Hochladen ablegen",
@@ -8276,7 +8507,9 @@
"deleted": "{count} Dokument(e) gelöscht",
"restored": "{count} Dokument(e) wiederhergestellt",
"partialFailure": "{failed} von {total} konnten nicht aktualisiert werden",
"failed": "Die Aktion konnte nicht ausgeführt werden"
"failed": "Die Aktion konnte nicht ausgeführt werden",
"share": "Teilen",
"shareTooMany": "Ein Freigabelink fasst höchstens {max} Dokumente. Wähle einige ab und versuche es erneut."
},
"episodeCard": {
"title": "Dokumente",
@@ -8340,28 +8573,14 @@
},
"share": {
"title": "Dieses Dokument teilen",
"multiTitle": "Diese Dokumente teilen",
"multiLabel": "{count} Dokumente",
"description": "Erstelle einen schreibgeschützten Ärzte-Link mit diesem angehängten Dokument. Link und Passphrase erscheinen nur einmal."
},
"chat": {
"title": "Über dieses Dokument chatten",
"notIndexed": "Lies das Dokument zuerst mit KI, um darüber zu chatten.",
"empty": "Stelle eine Frage zu diesem Dokument, um zu beginnen.",
"placeholder": "Frage zu diesem Dokument…",
"inputLabel": "Deine Frage zu diesem Dokument",
"send": "Senden",
"thinking": "Dokument wird gelesen…",
"safety": "Antworten beschreiben dieses Dokument und sind keine medizinische Beratung.",
"close": "Chat schließen",
"errorHistory": "Unterhaltung konnte nicht geladen werden. Bitte erneut versuchen.",
"errorGeneric": "Etwas ist schiefgelaufen. Bitte erneut versuchen.",
"errorRateLimited": "Zu viele Nachrichten. Bitte kurz warten und erneut versuchen.",
"errorBudget": "Das tägliche KI-Limit ist erreicht. Bitte morgen erneut versuchen.",
"errorCredential": "Der KI-Anbieter muss in den Einstellungen neu verbunden werden.",
"errorConsent": "Aktiviere die KI-Zustimmung in den Einstellungen, um über dieses Dokument zu chatten.",
"errorNetwork": "Du scheinst offline zu sein. Prüfe deine Verbindung und versuche es erneut.",
"openAria": "Über dieses Dokument chatten",
"openButton": "Zu diesem Dokument fragen",
"scopeBadge": "Chat über: {title}"
"openAria": "Coach über dieses Dokument fragen",
"openButton": "Coach fragen"
}
},
"selfDescription": {
@@ -8448,5 +8667,172 @@
"valuesDescription": "Alle erfassten Werte dieser Metrik.",
"backToMetric": "Zurück zu {metric}"
}
},
"nutrients": {
"names": {
"vitamin_a": "Vitamin A",
"thiamin": "Thiamin (B1)",
"riboflavin": "Riboflavin (B2)",
"niacin": "Niacin (B3)",
"pantothenic_acid": "Pantothensäure (B5)",
"vitamin_b6": "Vitamin B6",
"biotin": "Biotin (B7)",
"folate": "Folat (B9)",
"vitamin_b12": "Vitamin B12",
"vitamin_c": "Vitamin C",
"vitamin_d": "Vitamin D",
"vitamin_e": "Vitamin E",
"vitamin_k": "Vitamin K",
"calcium": "Calcium",
"iron": "Eisen",
"magnesium": "Magnesium",
"phosphorus": "Phosphor",
"zinc": "Zink",
"copper": "Kupfer",
"manganese": "Mangan",
"selenium": "Selen",
"chromium": "Chrom",
"molybdenum": "Molybdän",
"iodine": "Jod",
"water": "Wasser",
"caffeine": "Koffein"
},
"page": {
"title": "Nährstoffaufnahme",
"description": "Flüssigkeits- und Vitamin-/Mineralstoffaufnahme aus der Gesundheits-App deines Telefons, plus manuelle Wassereinträge.",
"moduleOffTitle": "Nährstoffaufnahme ist aus",
"moduleOffDescription": "Aktiviere das Nährstoffe-Modul, um Flüssigkeits- und Vitamin-/Mineralstoffaufnahme hier zu sehen. Synchronisierte Werte werden Teil der Aufzeichnung, die der Coach lesen kann.",
"moduleOffCta": "Nährstoffe aktivieren",
"emptyTitle": "Noch keine Nährstoffdaten",
"emptyDescription": "Daten kommen aus der Synchronisierung der Gesundheits-App deines Telefons oder einem manuellen Wassereintrag."
},
"hydration": {
"referenceMeta": "Richtwert {value} {unit}/Tag · EFSA",
"quickAddTitle": "Wasser hinzufügen",
"quickAddCustomLabel": "Eigene Menge (ml)",
"quickAddCustomPlaceholder": "250",
"quickAddSubmit": "Hinzufügen",
"quickAddError": "Konnte nicht gespeichert werden — versuch's noch mal.",
"editTotal": "Heutige Summe bearbeiten"
},
"caffeine": {
"ceilingMeta": "Sichere Obergrenze {value} {unit}/Tag · EFSA"
},
"micronutrients": {
"title": "Vitamine & Mineralstoffe",
"rowMeta": "{days} von {window} Tagen · Richtwert {value} {unit} (EFSA)",
"rowMetaNoRef": "{days} von {window} Tagen",
"footer": "{tracked} von {total} erfassten Mikronährstoffen haben Daten in diesem Zeitraum.",
"emptyTitle": "Noch keine Mikronährstoffdaten synchronisiert"
},
"sparseNote": "Ein niedriger Wert bedeutet hier meist lückenhafte Erfassung, keinen Mangel — die Tagessummen aus Apple Health spiegeln wider, was deine Apps erfasst haben, nicht die gesamte Nahrungsaufnahme."
},
"daily": {
"line": {
"score": "Dein Gesundheitsscore heute liegt bei {score}.",
"allClear": "Heute braucht nichts deine Aufmerksamkeit."
},
"push": {
"title": "Dein Morgenüberblick",
"bodyProvisional": "{line} · Der Schlaf von letzter Nacht fehlt noch."
},
"item": {
"doseWindow": {
"title": "Medikament fällig",
"body": "Heute ist eine Dosis fällig.",
"bodyNamed": "{name} ist heute fällig."
},
"preventiveCare": {
"title": "Vorsorge fällig",
"body": "{label} ist fällig.",
"bodyMany": "{count} Vorsorgetermine sind fällig."
},
"syncIssue": {
"title": "Synchronisierung braucht Aufmerksamkeit",
"body": "{integration} synchronisiert nicht — verbinde neu, um deine Daten aktuell zu halten."
},
"coachCheckin": {
"title": "Zeit, einen Plan anzuschauen",
"body": "Es ist etwa eine Woche her, seit du diesen Plan gesetzt hast — behalte ihn, passe ihn an oder lass ihn los. Ganz ohne Druck.",
"bodyPlan": "Vor etwa einer Woche hast du dir vorgenommen: „{plan}“. Behalte es, passe es an oder lass es los — ganz ohne Druck.",
"adjustPrompt": "Ich möchte meinen Check-in-Plan anpassen.",
"adjustPromptPlan": "Ich möchte diesen Plan anpassen: „{plan}“"
},
"tensionWindow": {
"title": "Vielleicht eine angespannte Phase",
"body": {
"morning": "Dein Puls lag heute Vormittag über längere Zeit über deinem Ruhewert, während du ruhig warst — möglicherweise etwas Anspannung.",
"afternoon": "Dein Puls lag heute Nachmittag über längere Zeit über deinem Ruhewert, während du ruhig warst — möglicherweise etwas Anspannung.",
"evening": "Dein Puls lag heute Abend über längere Zeit über deinem Ruhewert, während du ruhig warst — möglicherweise etwas Anspannung.",
"night": "Dein Puls lag in der Nacht über längere Zeit über deinem Ruhewert, während du ruhig warst — möglicherweise etwas Anspannung."
}
},
"ecgNewRecording": {
"title": "Neue EKG-Aufzeichnung",
"body": "Dein Gerät hat ein neues EKG aufgezeichnet — bereit zum Ansehen.",
"bodyVerdict": "Dein Gerät hat ein neues EKG aufgezeichnet und meldet: {verdict}.",
"verdict": {
"irregular": "einen möglichen unregelmäßigen Rhythmus",
"notDetected": "keinen unregelmäßigen Rhythmus",
"inconclusive": "ein nicht eindeutiges Ergebnis"
}
}
},
"action": {
"logDose": "Dosis erfassen",
"reconnect": "Neu verbinden",
"viewCheckups": "Vorsorge ansehen",
"checkinKeep": "Behalten",
"checkinAdjust": "Anpassen",
"checkinLetGo": "Loslassen",
"viewMilestone": "Ansehen",
"viewPulse": "Puls ansehen",
"viewEcg": "Aufzeichnung ansehen"
},
"today": {
"scoreLabel": "Gesundheitsscore",
"deltaVsBaseline": "{delta} ggü. deinem Ausgangswert",
"deltaFlat": "Im Bereich deines Ausgangswerts",
"sleepPending": "Der Schlaf von letzter Nacht fehlt noch – der heutige Wert ist vorläufig.",
"readFullBriefing": "Vollständiges Briefing lesen",
"worthALook": "Einen Blick wert",
"allClear": "Heute braucht nichts deine Aufmerksamkeit.",
"ringLink": "{metric}-Details öffnen"
},
"milestone": {
"metric": {
"restingHeartRate": "Ruhepuls",
"heartRateVariability": "Herzfrequenzvariabilität",
"respiratoryRate": "Atemfrequenz",
"weight": "Gewicht",
"generic": "dieser Wert"
},
"record": {
"title": "Neue Bestmarke: {metric}",
"body": "Heute ein persönlicher Bestwert für {metric} — ganz ruhig festgehalten, du musst nichts tun."
},
"returnToBaseline": {
"title": "Wieder im Bereich: {metric}",
"body": "{metric} liegt wieder in deinem gewohnten Bereich. Was auch immer es war — es ist vorbei."
},
"sustainedInRange": {
"week1": {
"title": "Eine Woche im Bereich",
"body": "{metric} liegt seit einer Woche in deinem gewohnten Bereich. Schön gleichmäßig."
},
"week2": {
"title": "Zwei Wochen im Bereich",
"body": "{metric} bleibt seit zwei Wochen in deinem gewohnten Bereich. Ruhig und stetig."
},
"week3": {
"title": "Drei Wochen im Bereich",
"body": "Drei Wochen, in denen {metric} in deinem gewohnten Bereich liegt. Eine echte ruhige Strecke."
},
"week4": {
"title": "Ein Monat im Bereich",
"body": "Ein ganzer Monat mit {metric} in deinem gewohnten Bereich. Wunderbar gleichmäßig."
}
}
}
}
}
+484 -98
View File
@@ -91,7 +91,7 @@
"colShare": "Share",
"footerDisclaimer1": "This report was generated automatically from self-tracked data and is intended to support",
"footerDisclaimer2": "the doctor–patient conversation. It does not replace a medical diagnosis. Correlations do not imply causation.",
"footerSource": "Source: HealthLog | Timezone: Europe/Berlin | Created: {timestamp}",
"footerSource": "Source: HealthLog | Timezone: {timezone} | Created: {timestamp}",
"typeWeight": "Body weight",
"typeBpSys": "Systolic blood pressure",
"typeBpDia": "Diastolic blood pressure",
@@ -228,7 +228,7 @@
"logout": "Sign out",
"login": "Sign in",
"notifications": "Notifications",
"theme": "Appearance",
"theme": "Theme",
"themeSystem": "System",
"themeDark": "Dark",
"themeLight": "Light",
@@ -286,6 +286,15 @@
"passwordTooShort": "Password must be at least {minLength} characters long.",
"privacyPolicy": "Privacy policy",
"passwordBreached": "This password has appeared in known data breaches. Please choose a different one.",
"oidc": {
"signInDefault": "Sign in with SSO",
"errorDenied": "Sign-in was cancelled.",
"errorNoEmail": "Your identity provider did not share a verified email address.",
"errorEmailUnverified": "Your identity provider did not mark your email address as verified.",
"errorIdentityConflict": "That email address belongs to an account that is already linked to a different SSO identity.",
"errorRegistrationDisabled": "No account matches that identity, and new sign-ups are currently closed.",
"errorFailed": "Single sign-on failed. Please try again."
},
"mfa": {
"title": "Two-factor authentication",
"totpHint": "Enter the 6-digit code from your authenticator app.",
@@ -318,37 +327,7 @@
"welcomeBackWithName": "{greeting} {name}, welcome back.",
"hero": {
"scoreLabel": "Health Score",
"ringLink": "Open {metric} details",
"scoreProvisional": "Not enough data yet",
"scoreComputing": "Calculating score…",
"ringDoses": "Doses today",
"ringDosesAria": "{taken} of {scheduled} doses taken today",
"ringCarousel": {
"aria": "Wellness rings",
"dot": "Show ring {index} of {total}"
},
"verdict": {
"doseOverdue": "A dose of {name} is overdue.",
"doseOverdueNoName": "A dose is overdue.",
"bpHigh": "Your last blood pressure was very high ({sys}/{dia}). Take another reading at rest.",
"doseUpcoming": "{name} is due at {time}.",
"doseUpcomingNoName": "A dose is due at {time}.",
"weightDrift": "Your weight is moving away from the target range this week.",
"shortNights": "Your nights have been short lately — {hours} h on average.",
"silence": "No measurement for {days} days. A quick entry keeps your picture current.",
"scoreDrop": "Your score sits {points} points below last week.",
"allQuiet": "All quiet — nothing needs your attention today."
},
"action": {
"takeDose": "Log it now",
"viewBp": "View blood pressure",
"viewWeight": "View weight",
"viewSleep": "View sleep",
"logMeasurement": "Log measurement"
},
"briefing": {
"title": "Today’s briefing"
}
"ringDoses": "Doses today"
},
"greetingSalutation": "Hi, {name}",
"streakLabel": "days in a row",
@@ -401,8 +380,6 @@
"compliance7d": "Adherence (7 days)",
"mood": "Mood",
"customizeTitle": "Dashboard layout",
"heroToggleLabel": "Daily overview",
"heroToggleDescription": "Shows your greeting, daily focus and score rings at the top of the dashboard.",
"scoreRingsTitle": "Score rings",
"scoreRingsDescription": "Up to three additional rings next to the health score — for example readiness, recovery, sleep or adherence. Drag to reorder; the health score leads by default.",
"layoutReset": "Reset to defaults",
@@ -428,7 +405,6 @@
"recentWorkouts": {
"title": "Recent workouts",
"viewAll": "View all",
"loading": "Loading workouts…",
"empty": {
"title": "No workouts yet",
"cta": "Open Apple Health on your iPhone to sync workouts here."
@@ -610,9 +586,27 @@
"typeWho5Score": "WHO-5 score",
"typeSciScore": "SCI score"
},
"metricCatalog": {
"title": "What HealthLog can track",
"description": "Every metric domain HealthLog supports, whether or not you have data for it yet — and what would supply it.",
"statusPresent": "Tracked",
"statusAbsent": "Not tracked yet",
"statusModuleOff": "Off",
"loadError": "Could not load your tracking status.",
"connectCta": "Connect a source",
"viewCta": "View",
"ecg": {
"source": "Single-lead ECG strips from a compatible watch — Withings ScanWatch today — synced automatically."
},
"watchdog": {
"title": "Automatic screening",
"description": "HealthLog also watches quietly in the background — irregular-rhythm and breathing-disturbance notifications from a connected watch, baseline-drift health-status alerts, and notable shifts between your two most recent lab panels — and surfaces a card only when something stands out. With at least one of those sources connected, no card showing is a good sign, not a missing feature; with none connected yet, there's simply nothing being watched."
}
},
"mood": {
"title": "Mood",
"subtitle": "Mood tracker and entries",
"mentalWellbeingLink": "Take a mental-wellbeing check-in",
"addEntry": "Add",
"editEntry": "Edit entry",
"moodLevel": "Mood",
@@ -1426,7 +1420,10 @@
"confirmAction": "Delete",
"toast": "Intake deleted",
"failed": "Could not delete the intake."
}
},
"viewRecent90": "Last 90 days",
"viewAll": "Full history",
"viewToggleLabel": "History range"
},
"verlauf": {
"loading": "Loading history…",
@@ -1814,7 +1811,8 @@
"label": "Not the right measurement?",
"placeholder": "Track against…",
"apply": "Apply",
"reset": "Reset to default"
"reset": "Reset to default",
"error": "Could not save that change. Try again."
},
"beforeAfter": {
"summary": "Ø {before} before → Ø {after} since — a change of {delta}.",
@@ -1829,7 +1827,10 @@
"subtitle": "What lines up with the measurements your medications target — association only, never a verdict.",
"link": "Open the effect view",
"empty": "Not enough data yet to relate your medications to their target measurements."
}
},
"scaleAbsolute": "Absolute",
"scalePercentSinceStart": "% since start",
"scaleToggleLabel": "Chart scale"
}
},
"charts": {
@@ -1978,6 +1979,61 @@
"notAvailableForCompliance": "Comparison N/A for adherence"
},
"insights": {
"correlation": {
"direction": {
"higher": "higher",
"lower": "lower"
},
"weekdayDirection": {
"above": "above",
"below": "below"
},
"channelFactor": "rated {factor}",
"channel": {
"WEIGHT": "weight",
"BLOOD_GLUCOSE": "blood glucose",
"BLOOD_PRESSURE_SYS": "systolic blood pressure",
"BLOOD_PRESSURE_DIA": "diastolic blood pressure",
"ACTIVITY_STEPS": "daily steps",
"SLEEP_DURATION": "sleep duration",
"HEART_RATE_VARIABILITY": "heart-rate variability",
"RESTING_HEART_RATE": "resting heart rate",
"TIME_IN_DAYLIGHT": "time in daylight",
"MOOD": "mood",
"PULSE": "resting pulse",
"MEDICATION_COMPLIANCE": "medication compliance",
"SYMPTOM_SEVERITY": "symptom severity",
"ENV_TEMP_MEAN": "daily temperature",
"ENV_TEMP_MIN": "overnight low temperature",
"ENV_SUNSHINE": "sunshine",
"ENV_DAYLIGHT": "daylight",
"ENV_PRECIP": "precipitation",
"ENV_PRESSURE_MEAN": "barometric pressure",
"ENV_PRESSURE_DELTA": "intraday pressure swing"
},
"daily": {
"high": "Higher {behaviour} tends to go with {direction} next-day {outcome} in your data — a pattern worth watching, not a cause.",
"moderate": "Higher {behaviour} looks like it goes with {direction} next-day {outcome} in your data, on the evidence so far — a pattern worth watching, not a cause.",
"faint": "Higher {behaviour} shows a faint hint, if anything, of {direction} next-day {outcome} in your data — too small to lean on, never a cause."
},
"lab": {
"default": "Higher {lab} readings line up with {direction} {outcome} over the same periods in your data — an association worth watching with your clinician, never a cause.",
"faint": "Higher {lab} readings show a faint hint, if anything, of {direction} {outcome} over the same periods in your data — too small to lean on, an association to raise with your clinician, never a cause."
},
"hypothesis": {
"bpCompliance": {
"support": "Higher medication compliance lines up with lower systolic readings — a pattern worth watching.",
"surprising": "Compliance and systolic move together in your data — surprising; talk to your doctor before adjusting anything.",
"none": "Compliance and systolic do not move together strongly in this window."
},
"moodPulse": {
"support": "Lower-mood days line up with higher resting pulse — a pattern worth watching.",
"surprising": "Higher-mood days line up with higher resting pulse in your data — surprising.",
"none": "Mood and resting pulse do not move together strongly in this window."
},
"weightWeekday": "{weekday} weights run {delta} kg {direction} your other-day average — a pattern worth watching."
}
},
"noAnalysisYet": "No assessment yet.",
"assessmentPreparing": "Preparing your assessment…",
"emptyTitle": "Not enough data yet",
@@ -2131,7 +2187,9 @@
"failedDescription": "The AI provider timed out or returned an error. Try again, or raise the response timeout in AI settings for a slow local model.",
"failedDescriptionTimeout": "The AI request ran out of time before the briefing was ready. For a slow local model, raise the AI response timeout in Settings, then try again.",
"failedDescriptionProvider": "The AI provider rejected the request. Re-check the provider and API key in AI settings, then try again.",
"failedDescriptionRateLimit": "The AI provider is rate-limiting requests right now. Try again in a little while."
"failedDescriptionRateLimit": "The AI provider is rate-limiting requests right now. Try again in a little while.",
"omittedTitle": "Briefing withheld",
"omittedUngroundedDescription": "The generated briefing referenced a figure that couldn't be verified against your data, so it wasn't shown. Try again."
},
"trendsRow": {
"title": "Trends",
@@ -2202,6 +2260,12 @@
"errorCredentialExpired": "Your AI provider connection has expired. Reconnect it in Settings to keep the Coach running.",
"tagline": "Personal health coach, grounded in your data.",
"newChat": "New chat",
"docScope": {
"badge": "Document",
"chattingAbout": "Chatting about: {title}",
"chattingAboutMore": "Chatting about: {titles} and {count} more",
"notIndexedHint": "Read this document with AI first in the vault so the Coach can answer about it."
},
"actionsMenu": "Actions",
"maximizeAriaLabel": "Open in full page",
"minimizeAriaLabel": "Back to the drawer",
@@ -2255,7 +2319,9 @@
"historySearchPlaceholder": "Search conversations…",
"historyEmpty": "Your conversations will appear here. Ask anything to start.",
"historyDeleteAria": "Delete conversation",
"historyDeleteConfirm": "Delete this conversation? This cannot be undone.",
"historyDeleted": "Conversation deleted",
"historySearchEmpty": "No conversations match your search.",
"historyLoadingMore": "Loading more…",
"sourcesTitle": "What I can see",
"sourcesFresh": "Fresh",
"sourcesStale": "Stale",
@@ -2466,7 +2532,28 @@
"correlationWeightWeekday": "Does my weight follow a weekly pattern? What does this day-of-week correlation tell me?",
"recommendation": "Tell me more about this recommendation: \"{text}\" — why does it apply to me, and what should I do about it?"
},
"assessmentPrompt": "Walk me through my {metric} assessment — what does it mean and what should I do about it?"
"assessmentPrompt": "Walk me through my {metric} assessment — what does it mean and what should I do about it?",
"attach": {
"menuChooseFromDocuments": "Choose from documents",
"menuUploadNew": "Upload new document",
"trigger": "Attach documents",
"pickerTitle": "Attach documents",
"pickerDescription": "The coach reads only the documents you attach — not your health data.",
"pickerSearchPlaceholder": "Search documents…",
"pickerEmpty": "No documents in your vault yet.",
"pickerNotIndexed": "Not yet indexed",
"pickerCap": "You can attach up to {max} more.",
"pickerConfirm": "Attach ({count})",
"pillRemove": "Remove {title}",
"indexing": "Indexing…",
"indexingHint": "A document is still being indexed — you can send once it's ready.",
"fencedHint": "This conversation runs without access to your health data — the coach only reads the attached documents.",
"documentsAttachedOne": "1 document attached",
"documentsAttachedOther": "{count} documents attached",
"railBadge": "Fenced document chat",
"uploadError": "Upload failed. Please try again.",
"notFoundError": "That document link is no longer valid — starting a normal chat instead."
}
},
"relativeJustNow": "just now",
"relativeMinutesAgoOne": "{count} minute ago",
@@ -2476,11 +2563,13 @@
"relativeDaysAgoOne": "{count} day ago",
"relativeDaysAgoOther": "{count} days ago",
"navOverview": "Overview",
"navCatalog": "All metrics",
"navBloodPressure": "Blood Pressure",
"navWeight": "Weight",
"navPulse": "Pulse",
"navMood": "Mood",
"navMedication": "Medication",
"navNutrients": "Nutrients",
"navBmi": "BMI",
"navSleep": "Sleep",
"navHrv": "HRV",
@@ -2505,9 +2594,9 @@
"label": "Activity",
"header": "Activity & mobility"
},
"cardiovascularParent": {
"label": "Cardiovascular",
"header": "Cardiovascular markers"
"heartParent": {
"label": "Heart",
"header": "Heart health"
},
"hearingParent": {
"label": "Hearing",
@@ -2632,11 +2721,11 @@
"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.",
"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 (WHO 2024).",
"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. The scale shows a unitless rating (about 1–12 is the healthy range, 13 and up elevated); this is a separate scale from the imaging cm² thresholds clinicians use and is not interchangeable with them.",
"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 (AHA, Circulation 2021), so lower is generally better. The scale shows a unitless rating (about 1–12 is the healthy range, 13 and up elevated); this is a separate scale from the imaging cm² thresholds clinicians use and is not interchangeable with them.",
"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",
@@ -2666,7 +2755,7 @@
"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.",
"daylightBody": "Time in daylight is how long you spend in outdoor light each day. Regular daylight supports a healthy sleep-wake rhythm and mood (CDC 2024), 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. As general guidance for adults without diabetes, a fasting reading under 100 mg/dL is normal, 100–125 is prediabetes, and 126 or above falls in the diabetes range (ADA 2024). A single high reading does not diagnose — a clinician confirms with a repeat test, and people managing diabetes use different, clinician-set targets.",
"skinTemperatureTitle": "Skin temperature",
@@ -2678,7 +2767,7 @@
"wristTemperatureTitle": "Wrist temperature",
"wristTemperatureBody": "Wrist temperature is measured while you sleep and reported as the difference from your personal baseline, not an absolute value. A steady reading is normal, while a sustained rise can hint at illness, intense training, or a menstrual-cycle change.",
"fallsTitle": "Falls",
"fallsBody": "This counts the hard falls your wearable detects. Zero is the goal; a rising count is a mobility-risk signal worth raising with a clinician, especially for older adults.",
"fallsBody": "This counts the hard falls your wearable detects. Zero is the goal; a rising count is a mobility-risk signal worth raising with a clinician, especially for older adults (CDC STEADI 2024).",
"sixMinuteWalkTitle": "Six-minute walk distance",
"sixMinuteWalkBody": "This is an estimate of how far you could walk in six minutes — a long-standing clinical test of mobility and cardiopulmonary endurance. The wearable figure is a proxy, not the supervised clinical test; a steady or rising trend is the useful read.",
"stairAscentSpeedTitle": "Stair ascent speed",
@@ -2726,7 +2815,8 @@
},
"valuesBack": "Back to insights",
"valuesBackToMetric": "Back to {metric}",
"scoresBack": "Back to insights"
"scoresBack": "Back to insights",
"ecgDescription": "Single-lead ECG recordings from your device, each with the device's own result. HealthLog shows the trace and the recording — it does not interpret the waveform."
},
"sleep": {
"title": "Sleep",
@@ -2864,7 +2954,7 @@
"sleep": {
"title": "No sleep entries yet",
"description": "Once sleep data lands (manual or via Withings / Apple Health), the per-night stage breakdown will appear here.",
"cta": "Connect Withings"
"cta": "Connect a source"
}
},
"navWorkouts": "Workouts",
@@ -2899,7 +2989,24 @@
"hrChartTitle": "Heart rate",
"hrChartUnavailable": "Per-second heart-rate samples are not stored for this workout.",
"notFound": "Workout not found.",
"loading": "Loading workout…"
"loading": "Loading workout…",
"hrFromPulse": "From your heart-rate data around this session",
"hrAxisMinutes": "min",
"sportAverageLabel": "Your recent average:",
"zonesTitle": "Effort zones",
"zonesCaption": "How your effort was distributed across heart-rate zones.",
"zonesCaptionDevice": "Time in zone as reported by your device.",
"exportGpx": "Export GPX",
"elevationTitle": "Elevation",
"splitsTitle": "Splits",
"splitsKm": "km",
"splitsTime": "Time",
"splitsPace": "Pace",
"thatDayTitle": "That day",
"dayPulse": "Pulse",
"daySleep": "Sleep",
"dayMood": "Mood",
"insightTitle": "Activity insight"
},
"sport": {
"walking": "Walking",
@@ -3232,6 +3339,8 @@
},
"navWalkingSpeed": "Walking Speed",
"mood": {
"logLink": "Log a mood entry",
"mentalWellbeingLink": "Take a mental-wellbeing check-in",
"heatmapTitle": "Mood calendar",
"heatmapNoEntry": "No entry",
"legendGreat": "Great",
@@ -3392,6 +3501,7 @@
"sectionSubtitle": "Resting cardiovascular and respiratory signals against your own baseline",
"loadingLabel": "Loading your vitals",
"loadError": "Could not load your vitals right now.",
"infoLabel": "How is this calculated?",
"building": "Building your typical range — {count} of {target} days",
"typicalRange": "Typical for you: {low}–{high}",
"fitnessYounger": "Cardio fitness of someone ~{years} yr younger",
@@ -3518,7 +3628,9 @@
"sectionTitle": "Your health scores",
"sectionSubtitle": "Composite recovery, stress and strain read from your connected devices",
"loadError": "Could not load your wellness scores right now.",
"recovery": "Recovery",
"loadingLabel": "Loading your health scores",
"recovery": "Recovery score",
"recoveryCrossLink": "See your wearable's recovery & strain data",
"stress": "Stress",
"strain": "Strain"
},
@@ -3689,6 +3801,7 @@
"recovery": {
"title": "Recovery",
"description": "How well your body has bounced back — the recovery, strain and load signals your wearable records overnight.",
"scoreCrossLink": "See the composite recovery score",
"emptyTitle": "No recovery data yet",
"empty": "No recovery data yet. Connect a WHOOP, Polar or Oura account and these scores fill in once your device syncs.",
"block": {
@@ -3823,7 +3936,66 @@
"cta": "Log waist-to-height ratio"
},
"coachPrefill": "I haven't logged a waist-to-height ratio yet — why is it a useful number, and how do I keep it under 0.5?"
}
},
"ecg": {
"sectionTitle": "ECG recordings",
"sectionIntro": "Single-lead ECG strips recorded by your device. HealthLog only shows the recording and the result your device produced — it does not read or interpret the waveform.",
"loadError": "Could not load your ECG recordings.",
"backToList": "Back to recordings",
"disclaimer": "This is the result your recording device produced. HealthLog does not read or interpret ECG recordings and does not provide a diagnosis.",
"clinicianNote": "If this result concerns you, discuss this recording with a clinician.",
"resultAttribution": "Recorded result: {result}, as reported by the recording device.",
"result": {
"irregular": "Atrial fibrillation detected",
"notDetected": "No signs of atrial fibrillation",
"inconclusive": "Inconclusive recording",
"none": "No result reported"
},
"meta": {
"recorded": "Recorded",
"duration": "Duration",
"lead": "Lead",
"averageHeartRate": "Average heart rate",
"samplingRate": "Sampling rate",
"leadSingle": "Single lead (Lead I)",
"durationValue": "{seconds} s",
"bpmValue": "{bpm} bpm",
"hzValue": "{hz} Hz",
"unknown": "—"
},
"waveform": {
"ariaLabel": "ECG strip recorded {date}, duration {duration}, average heart rate {heartRate}, recorded result: {result}."
},
"crossLink": {
"title": "ECG recordings",
"recordingsOne": "1 recording on file.",
"recordingsMany": "{count} recordings on file.",
"latestResult": "Latest device result: {result}.",
"cta": "View recordings"
},
"emptyTitle": "No ECG recordings yet",
"emptyDescription": "ECG recordings sync from a compatible single-lead device. Once your device uploads a recording, it appears here.",
"emptyCta": "Connect a device"
},
"intradayPulse": {
"title": "Your day's shape",
"caption": "Your heart rate through the day, averaged in 10-minute steps.",
"hourlyNote": "Showing this day's hourly average — recent days show the full 10-minute detail.",
"empty": "Not enough heart-rate readings for this day to show its shape yet.",
"coverageOne": "Based on 1 reading across {hours}h.",
"coverageMany": "Based on {count} readings across {hours}h.",
"baseline": "Resting",
"today": "Today",
"previousDay": "Previous day",
"nextDay": "Next day",
"tension": {
"morning": "A sustained elevated-at-rest stretch this morning — possibly some tension.",
"afternoon": "A sustained elevated-at-rest stretch this afternoon — possibly some tension.",
"evening": "A sustained elevated-at-rest stretch this evening — possibly some tension.",
"night": "A sustained elevated-at-rest stretch overnight — possibly some tension."
}
},
"navEcg": "ECG"
},
"thresholds": {
"defaultLabel": "Default",
@@ -3922,7 +4094,7 @@
},
"wrapUp": {
"title": "That’s the tour",
"body": "You can revisit any module on its own at any time (“Show this tour” on every page) or restart the whole tour under Settings → Advanced."
"body": "This walkthrough runs once — it won't reopen on its own. Everything it covered stays put, and you can turn features on or off any time under Settings → Modules."
}
}
},
@@ -3950,7 +4122,7 @@
},
"source": {
"title": "Connect a source",
"body": "Bring in measurements automatically, or stay on manual entry. Nothing here is configured for you — each card just shows where to connect it.",
"body": "Bring in measurements automatically, or stay on manual entry. Nothing here is configured for you — each card just shows where to connect it. Device sources need a free developer app registered on your own account (about 5 minutes); the setup guide on each card walks through it.",
"recommended": "Recommended",
"manual": {
"title": "Manual entry",
@@ -3958,12 +4130,14 @@
},
"withings": {
"title": "Withings",
"body": "Scales, blood-pressure cuffs and trackers from Withings sync automatically.",
"cta": "Connect Withings"
"body": "Scales, blood-pressure cuffs and trackers from Withings sync automatically once connected.",
"cta": "Connect Withings",
"setupCta": "Set up in Settings",
"setupHint": "Needs your own free Withings developer app first — about 5 minutes, one time."
},
"more": {
"title": "More sources",
"body": "Connect any of these in Settings whenever you're ready.",
"body": "Connect any of these in Settings whenever you're ready — most need a free developer app registered on your own account first.",
"appleHealth": "Apple Health",
"whoop": "WHOOP",
"oura": "Oura",
@@ -3995,7 +4169,8 @@
"learning": "Some insights — glucose, sleep rhythm, blood-pressure trends, cycle — sharpen over the first week or two as your data builds up. Empty tiles now just mean we're still learning your baseline.",
"connectCta": "Connect a device",
"logCta": "Log your first measurement",
"importCta": "Import existing history"
"importCta": "Import existing history",
"modulesCta": "Choose your modules"
},
"welcomeBack": {
"title": "Welcome back",
@@ -4105,7 +4280,16 @@
"disableError": "Could not disable Research Mode. Please try again."
},
"shell": {
"sectionsNav": "Settings sections"
"sectionsNav": "Settings sections",
"groups": {
"account": "Account",
"yourData": "Your data",
"display": "Display",
"healthProfile": "Health profile",
"ai": "AI",
"connectivity": "Connectivity",
"system": "System"
}
},
"sections": {
"account": {
@@ -4236,7 +4420,16 @@
"other": "Other",
"unknown": "Unknown"
},
"subtitle": "Decide which source wins when two report the same metric."
"subtitle": "Decide which source wins when two report the same metric.",
"nutrients": {
"title": "Synced nutrient intake",
"description": "Daily totals synced from Apple Health over the last {days} days. Read-only — the source app owns the numbers.",
"empty": "No nutrient data has been synced yet.",
"latest": "{amount} {unit} on {date}",
"daysWithData": "{count} of {days} days",
"moduleHint": "Syncing is controlled by the Nutrient intake module.",
"moduleHintLink": "Manage modules"
}
},
"ai": {
"title": "AI provider",
@@ -4281,7 +4474,8 @@
},
"fullBackup": {
"title": "Full backup",
"description": "Single JSON file with everything — same shape as the weekly auto-backup. Useful for self-restore via admin upload.",
"description": "Every record you own in one JSON file — measurements, medications, mood, cycle, labs, illness episodes, allergies, family history, and workouts. Same shape as the weekly auto-backup.",
"scopeNote": "Document metadata and the AI-generated summary, if one exists, are included as plain text; original document files and workout GPS/sample data are not — download those separately from the vault or the workout export.",
"encryptToggle": "Encrypt with a passphrase",
"passphrasePlaceholder": "Passphrase (min. 12 characters)",
"passphraseHelp": "Derives the archive key with Argon2id and seals it with AES-256-GCM. Save the file with a .hlx extension.",
@@ -4441,7 +4635,7 @@
"description": "Structured allergies and family history records."
},
"environment": {
"title": "Environment",
"title": "Location & weather",
"subtitle": "Weather, daylight, and location context.",
"disabledHint": "Turn on the Environment module to set a home location and pull in daily weather and daylight.",
"searchPlaceholder": "Search for a city",
@@ -4629,6 +4823,7 @@
"anthropicKeyLabel": "Anthropic API key",
"baseUrlLabel": "Base URL",
"localKeyLabel": "API key (optional)",
"localDescription": "Any OpenAI-compatible endpoint works here — a local model (Ollama, LM Studio) as well as a gateway such as LiteLLM, OpenRouter or vLLM. The base URL ends with /v1; the API key is optional.",
"savedPreview": "(saved {preview})",
"savedShort": "(saved)",
"saveCta": "Save",
@@ -4704,6 +4899,7 @@
"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.",
"testReasonBadRequest": "The provider rejected the request (HTTP {status}) — the endpoint answered, so this is a request-shape or model-name problem, not connectivity.",
"testReasonUnreachable": "Could not reach the AI provider.",
"coachMemory": {
"title": "What the Coach remembers",
@@ -5312,6 +5508,12 @@
"ouraDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
"nightscoutDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
"integrationCredentialsHint": "Where to find these credentials and how to register your own app is covered in the setup guide above.",
"integrationRedirectGuide": {
"intro": "This connects through your own {provider} developer app — about 5 minutes to set up:",
"step1": "Create an app in the {provider} developer portal.",
"step2": "Set its redirect / callback URL to:",
"step3": "Paste the client ID and client secret it gives you above."
},
"security": {
"sessionsTitle": "Active sessions",
"sessionsDescription": "Devices and browsers currently signed in to your account.",
@@ -5952,6 +6154,7 @@
"colAction": "Action",
"colDuration": "Duration",
"colTraceId": "Trace",
"viewDetails": "View details for {action} at {timestamp}",
"showing": "Showing {count} of up to {bufferMax} buffered events",
"empty": "Buffer is empty",
"emptyDescription": "No events match the current filters yet. Hit refresh after triggering some traffic.",
@@ -6491,6 +6694,8 @@
"eventMeasurementReminderDesc": "Sent when a tracked measurement is due.",
"eventMedicationIntakeSync": "Medication intake sync",
"eventMedicationIntakeSyncDesc": "Cross-device update when a dose is logged, taken or skipped.",
"eventDailyBriefing": "Daily briefing",
"eventDailyBriefingDesc": "One calm morning summary of your day's read. Off until you turn it on.",
"security": {
"newDeviceTitle": "New sign-in to your account",
"newDeviceBody": "A new sign-in from {device} ({location}). If this wasn't you, change your password immediately.",
@@ -6570,9 +6775,9 @@
"hypertensionGrade1": "Hypertension Grade 1",
"hypertensionGrade2": "Hypertension Grade 2",
"hypertensionGrade3": "Hypertension Grade 3",
"bradycardia": "Bradycardia",
"belowTypicalRange": "Below typical range",
"elevated": "Elevated",
"tachycardia": "Tachycardia",
"aboveTypicalRange": "Above typical range",
"significantlyLow": "Significantly low",
"slightlyLow": "Slightly low",
"onTarget": "On target",
@@ -7156,8 +7361,11 @@
"TOTAL_BODY_WATER": "Body water",
"VISCERAL_FAT": "Visceral fat",
"BODY_MASS_INDEX": "Body mass index (BMI)",
"WAIST_CIRCUMFERENCE": "Waist circumference",
"PHQ9_SCORE": "PHQ-9 · mood",
"GAD7_SCORE": "GAD-7 · worry"
"GAD7_SCORE": "GAD-7 · worry",
"WHO5_SCORE": "WHO-5 · wellbeing",
"SCI_SCORE": "SCI · sleep"
},
"typeGroups": {
"vitals": "Vitals",
@@ -7275,6 +7483,7 @@
"notePlaceholder": "Optional context for this result",
"requiredError": "Enter a value.",
"save": "Save",
"saveAndAddAnother": "Save & add another",
"savedToast": "Result saved",
"saveError": "Could not save the result",
"biomarker": "Biomarker",
@@ -7566,6 +7775,10 @@
"mentalHealth": {
"label": "Mental wellbeing",
"description": "Opt-in PHQ-9 and GAD-7 screeners beside mood tracking. Off by default; answers are encrypted and kept out of the AI Coach."
},
"nutrients": {
"label": "Nutrient intake",
"description": "Store daily micronutrient totals (vitamins, minerals, water, caffeine) synced from Apple Health. Off by default."
}
},
"illness": {
@@ -7619,7 +7832,10 @@
"notePlaceholder": "Anything worth remembering (encrypted).",
"saveError": "Could not save the episode. Please try again.",
"parent": "Part of",
"parentNone": "Not linked"
"parentNone": "Not linked",
"resolved": "Ended on",
"resolvedHelp": "Leave empty while the episode is still ongoing.",
"markOngoing": "Still ongoing"
},
"sheet": {
"title": "Log {date}",
@@ -7671,7 +7887,8 @@
"direction": {
"above": "above your usual",
"below": "below your usual"
}
},
"settled": "Nothing notable stood out from your usual range during this episode."
},
"insights": {
"title": "Your illness history",
@@ -7722,12 +7939,12 @@
"RECOVERY_SCORE": "Recovery",
"OXYGEN_SATURATION": "Oxygen saturation",
"BODY_TEMPERATURE": "Body temperature",
"SKIN_TEMPERATURE": "Skin temperature",
"RESPIRATORY_RATE": "Respiratory rate",
"BLOOD_PRESSURE_SYS": "Systolic blood pressure",
"BLOOD_PRESSURE_DIA": "Diastolic blood pressure",
"PULSE": "Pulse",
"WEIGHT": "Weight"
"WEIGHT": "Weight",
"FUNCTIONAL_IMPACT": "Functional impact"
},
"listLoadError": "Could not load your episodes."
},
@@ -7808,14 +8025,14 @@
"bpLowDoctor": "Two recent readings had a systolic below {systolic} mmHg. If you feel dizzy or faint, sit or lie down, and contact your doctor if it continues.",
"bpLowEmergency": "Two recent readings had a systolic below {systolic} mmHg and you noted symptoms. If you feel faint, confused, or have cold clammy skin, call emergency services now.",
"glucoseLowTitle": "Low blood sugar",
"glucoseLowDoctor": "Two recent readings were below 70 mg/dL ({value} mg/dL). Take fast-acting carbohydrate, re-check in 15 minutes, and contact your doctor if lows continue.",
"glucoseLowEmergency": "Two recent readings were below 70 mg/dL ({value} mg/dL) and you noted symptoms. Treat now with fast-acting sugar; if you cannot, or you feel very unwell, call emergency services.",
"glucoseLowDoctor": "Two recent readings were below {threshold} {unit} ({value} {unit}). Take fast-acting carbohydrate, re-check in 15 minutes, and contact your doctor if lows continue.",
"glucoseLowEmergency": "Two recent readings were below {threshold} {unit} ({value} {unit}) and you noted symptoms. Treat now with fast-acting sugar; if you cannot, or you feel very unwell, call emergency services.",
"glucoseLowSevereTitle": "Very low blood sugar",
"glucoseLowSevereDoctor": "Two recent readings were below 54 mg/dL ({value} mg/dL). Treat immediately with fast-acting carbohydrate, re-check in 15 minutes, and contact your doctor.",
"glucoseLowSevereEmergency": "Two recent readings were below 54 mg/dL ({value} mg/dL) and you noted symptoms. Treat now; if you cannot keep food down or feel very unwell, call emergency services.",
"glucoseLowSevereDoctor": "Two recent readings were below {threshold} {unit} ({value} {unit}). Treat immediately with fast-acting carbohydrate, re-check in 15 minutes, and contact your doctor.",
"glucoseLowSevereEmergency": "Two recent readings were below {threshold} {unit} ({value} {unit}) and you noted symptoms. Treat now; if you cannot keep food down or feel very unwell, call emergency services.",
"glucoseHighTitle": "Very high blood sugar",
"glucoseHighDoctor": "Two recent readings were {value} mg/dL or higher. Re-check, hydrate, and contact your doctor as soon as possible if it stays this high.",
"glucoseHighEmergency": "Two recent readings were {value} mg/dL or higher and you noted symptoms. If you have vomiting, fast breathing, or confusion, call emergency services now."
"glucoseHighDoctor": "Two recent readings were {value} {unit} or higher. Re-check, hydrate, and contact your doctor as soon as possible if it stays this high.",
"glucoseHighEmergency": "Two recent readings were {value} {unit} or higher and you noted symptoms. If you have vomiting, fast breathing, or confusion, call emergency services now."
}
},
"version": {
@@ -7828,6 +8045,7 @@
"mentalHealth": {
"pageTitle": "Mental wellbeing",
"pageDescription": "Validated, opt-in self-checks — PHQ-9 (mood), GAD-7 (worry), WHO-5 (well-being) and SCI (sleep) — that sit beside your mood tracking. The value is the trend over time, not any single number.",
"moodLink": "View your mood history",
"attributionLabel": "Attribution",
"attributionShow": "Source and licence for {instrument}",
"choosePrompt": "Choose a check-in",
@@ -7952,6 +8170,12 @@
"nummerGegenKummer": {
"name": "Nummer gegen Kummer (for young people)"
},
"telefonSeelsorgeAt": {
"name": "Telefonseelsorge Österreich (24/7, free, confidential)"
},
"dargeboteneHand": {
"name": "Die Dargebotene Hand (24/7, free, confidential)"
},
"krisenchat": {
"name": "krisenchat (24/7 chat, young people)"
},
@@ -8205,7 +8429,9 @@
"openLabel": "Open {title}",
"attachmentBadge": "Download only",
"uploading": "Uploading…",
"dismissFailed": "Dismiss failed upload"
"dismissFailed": "Dismiss failed upload",
"processing": "Processing…",
"ready": "Ready"
},
"error": {
"fileTooLarge": "This file is larger than the {maxSize} limit.",
@@ -8247,6 +8473,10 @@
},
"detail": {
"title": "Document details",
"summary": {
"title": "Summary",
"pending": "The summary is being prepared."
},
"loadError": "Couldn't load this document.",
"saveError": "Couldn't save the change.",
"deleteError": "Couldn't delete the document.",
@@ -8261,7 +8491,8 @@
"noConditions": "Not linked to a condition",
"previewUnavailable": "No inline preview for this file type",
"uploadedMeta": "Added {date} · {size}",
"share": "Share"
"share": "Share",
"labValuesLink": "View {count} lab values"
},
"dropOverlay": {
"title": "Drop files to upload",
@@ -8276,7 +8507,9 @@
"deleted": "{count} document(s) deleted",
"restored": "{count} document(s) restored",
"partialFailure": "{failed} of {total} could not be updated",
"failed": "The action could not be completed"
"failed": "The action could not be completed",
"share": "Share",
"shareTooMany": "A share link can hold at most {max} documents. Deselect some and try again."
},
"episodeCard": {
"title": "Documents",
@@ -8340,28 +8573,14 @@
},
"share": {
"title": "Share this document",
"multiTitle": "Share these documents",
"multiLabel": "{count} documents",
"description": "Create a read-only clinician link with this document attached. The link and its passphrase appear once."
},
"chat": {
"title": "Chat about this document",
"notIndexed": "Read this document with AI first to chat about it.",
"empty": "Ask a question about this document to get started.",
"placeholder": "Ask about this document…",
"inputLabel": "Your question about this document",
"send": "Send",
"thinking": "Reading the document…",
"safety": "Answers describe this document and are not medical advice.",
"close": "Close chat",
"errorHistory": "Couldn't load the conversation. Try again.",
"errorGeneric": "Something went wrong. Try again.",
"errorRateLimited": "Too many messages. Wait a moment and try again.",
"errorBudget": "The daily AI limit has been reached. Try again tomorrow.",
"errorCredential": "The AI provider needs reconnecting in settings.",
"errorConsent": "Turn on AI consent in settings to chat about this document.",
"errorNetwork": "You appear to be offline. Check your connection and try again.",
"openAria": "Chat about this document",
"openButton": "Ask about this document",
"scopeBadge": "Chatting about: {title}"
"openAria": "Ask the Coach about this document",
"openButton": "Ask the Coach"
}
},
"selfDescription": {
@@ -8448,5 +8667,172 @@
"valuesDescription": "Every logged value for this metric.",
"backToMetric": "Back to {metric}"
}
},
"nutrients": {
"names": {
"vitamin_a": "Vitamin A",
"thiamin": "Thiamin (B1)",
"riboflavin": "Riboflavin (B2)",
"niacin": "Niacin (B3)",
"pantothenic_acid": "Pantothenic acid (B5)",
"vitamin_b6": "Vitamin B6",
"biotin": "Biotin (B7)",
"folate": "Folate (B9)",
"vitamin_b12": "Vitamin B12",
"vitamin_c": "Vitamin C",
"vitamin_d": "Vitamin D",
"vitamin_e": "Vitamin E",
"vitamin_k": "Vitamin K",
"calcium": "Calcium",
"iron": "Iron",
"magnesium": "Magnesium",
"phosphorus": "Phosphorus",
"zinc": "Zinc",
"copper": "Copper",
"manganese": "Manganese",
"selenium": "Selenium",
"chromium": "Chromium",
"molybdenum": "Molybdenum",
"iodine": "Iodine",
"water": "Water",
"caffeine": "Caffeine"
},
"page": {
"title": "Nutrient intake",
"description": "Hydration and vitamin/mineral intake synced from your phone's health app, plus manual water entries.",
"moduleOffTitle": "Nutrient intake is off",
"moduleOffDescription": "Turn on the nutrients module to see hydration and vitamin/mineral intake here. Synced intake becomes part of the record the Coach can read.",
"moduleOffCta": "Turn on nutrients",
"emptyTitle": "No nutrient data yet",
"emptyDescription": "Data arrives from your phone's health app sync or a manual water entry."
},
"hydration": {
"referenceMeta": "Reference intake {value} {unit}/day · EFSA",
"quickAddTitle": "Add water",
"quickAddCustomLabel": "Custom amount (mL)",
"quickAddCustomPlaceholder": "250",
"quickAddSubmit": "Add",
"quickAddError": "Couldn't save that — try again.",
"editTotal": "Edit today's total"
},
"caffeine": {
"ceilingMeta": "Safe-level ceiling {value} {unit}/day · EFSA"
},
"micronutrients": {
"title": "Vitamins & minerals",
"rowMeta": "{days} of {window} days · ref {value} {unit} (EFSA)",
"rowMetaNoRef": "{days} of {window} days",
"footer": "{tracked} of {total} tracked micronutrients have data in this window.",
"emptyTitle": "No micronutrient data synced yet"
},
"sparseNote": "A low number here usually means sparse logging, not a deficiency — Apple Health day totals reflect what your apps logged, not total dietary intake."
},
"daily": {
"line": {
"score": "Your health score today is {score}.",
"allClear": "Nothing needs your attention today."
},
"push": {
"title": "Your morning briefing",
"bodyProvisional": "{line} · Last night's sleep isn't in yet."
},
"item": {
"doseWindow": {
"title": "Medication due",
"body": "A dose is due today.",
"bodyNamed": "{name} is due today."
},
"preventiveCare": {
"title": "Check-up due",
"body": "{label} is due.",
"bodyMany": "{count} check-ups are due."
},
"syncIssue": {
"title": "Sync needs attention",
"body": "{integration} isn't syncing — reconnect to keep your data current."
},
"coachCheckin": {
"title": "Time to check in on a plan",
"body": "It's been about a week since you set this plan — keep it, adjust it, or let it go. No pressure either way.",
"bodyPlan": "About a week ago you set: “{plan}”. Keep it, adjust it, or let it go — no pressure either way.",
"adjustPrompt": "I'd like to adjust my check-in plan.",
"adjustPromptPlan": "I'd like to adjust this plan: “{plan}”"
},
"tensionWindow": {
"title": "Possible tension today",
"body": {
"morning": "Your heart rate sat above its resting baseline for a sustained stretch this morning while you were still — possibly some tension.",
"afternoon": "Your heart rate sat above its resting baseline for a sustained stretch this afternoon while you were still — possibly some tension.",
"evening": "Your heart rate sat above its resting baseline for a sustained stretch this evening while you were still — possibly some tension.",
"night": "Your heart rate sat above its resting baseline for a sustained stretch overnight while you were still — possibly some tension."
}
},
"ecgNewRecording": {
"title": "New ECG recording",
"body": "Your device recorded a new ECG — it's ready to view.",
"bodyVerdict": "Your device recorded a new ECG and reported: {verdict}.",
"verdict": {
"irregular": "a possible irregular rhythm",
"notDetected": "no irregular rhythm",
"inconclusive": "an inconclusive result"
}
}
},
"action": {
"logDose": "Log dose",
"reconnect": "Reconnect",
"viewCheckups": "View check-ups",
"checkinKeep": "Keep it",
"checkinAdjust": "Adjust",
"checkinLetGo": "Let it go",
"viewMilestone": "View",
"viewPulse": "View pulse",
"viewEcg": "View recording"
},
"today": {
"scoreLabel": "Health score",
"deltaVsBaseline": "{delta} vs your baseline",
"deltaFlat": "In line with your baseline",
"sleepPending": "Last night's sleep isn't in yet — today's read is provisional.",
"readFullBriefing": "Read the full briefing",
"worthALook": "Worth a look",
"allClear": "Nothing needs your attention today.",
"ringLink": "Open {metric} details"
},
"milestone": {
"metric": {
"restingHeartRate": "resting heart rate",
"heartRateVariability": "heart-rate variability",
"respiratoryRate": "respiratory rate",
"weight": "weight",
"generic": "this metric"
},
"record": {
"title": "A new {metric} best",
"body": "A personal best for your {metric} today — quietly noted, nothing to do."
},
"returnToBaseline": {
"title": "Back in range: {metric}",
"body": "Your {metric} has settled back inside your usual range. Whatever that was, it has passed."
},
"sustainedInRange": {
"week1": {
"title": "A week in range",
"body": "Your {metric} has held inside your usual range for a week now. Nice and steady."
},
"week2": {
"title": "Two weeks in range",
"body": "Your {metric} has stayed in your usual range for two weeks. Calm and steady."
},
"week3": {
"title": "Three weeks in range",
"body": "Three weeks of your {metric} sitting in your usual range. A real stretch of steadiness."
},
"week4": {
"title": "A month in range",
"body": "A full month with your {metric} in your usual range. Beautifully steady."
}
}
}
}
}
+484 -98
View File
@@ -91,7 +91,7 @@
"colShare": "Porcentaje",
"footerDisclaimer1": "Este informe se generó automáticamente a partir de datos autorregistrados y está destinado a apoyar",
"footerDisclaimer2": "la conversación entre médico y paciente. No sustituye un diagnóstico médico. Las correlaciones no implican causalidad.",
"footerSource": "Fuente: HealthLog | Zona horaria: Europe/Berlin | Creado: {timestamp}",
"footerSource": "Fuente: HealthLog | Zona horaria: {timezone} | Creado: {timestamp}",
"typeWeight": "Peso corporal",
"typeBpSys": "Tensión arterial sistólica",
"typeBpDia": "Tensión arterial diastólica",
@@ -228,7 +228,7 @@
"logout": "Cerrar sesión",
"login": "Iniciar sesión",
"notifications": "Notificaciones",
"theme": "Apariencia",
"theme": "Tema",
"themeSystem": "Sistema",
"themeDark": "Oscuro",
"themeLight": "Claro",
@@ -286,6 +286,15 @@
"passwordTooShort": "La contraseña debe tener al menos {minLength} caracteres.",
"privacyPolicy": "Política de privacidad",
"passwordBreached": "Esta contraseña aparece en filtraciones de datos conocidas. Elige otra.",
"oidc": {
"signInDefault": "Iniciar sesión con SSO",
"errorDenied": "El inicio de sesión se canceló.",
"errorNoEmail": "Tu proveedor de identidad no compartió un correo electrónico verificado.",
"errorEmailUnverified": "Tu proveedor de identidad no marcó tu correo electrónico como verificado.",
"errorIdentityConflict": "Ese correo electrónico pertenece a una cuenta que ya está vinculada a otra identidad SSO.",
"errorRegistrationDisabled": "Ninguna cuenta coincide con esa identidad y los nuevos registros están cerrados actualmente.",
"errorFailed": "El inicio de sesión único falló. Inténtalo de nuevo."
},
"mfa": {
"title": "Autenticación de dos factores",
"totpHint": "Introduce el código de 6 dígitos de tu aplicación de autenticación.",
@@ -318,37 +327,7 @@
"welcomeBackWithName": "{greeting} {name}, te damos la bienvenida de nuevo.",
"hero": {
"scoreLabel": "Health Score",
"ringLink": "Abrir detalles de {metric}",
"scoreProvisional": "Aún no hay datos suficientes",
"scoreComputing": "Calculando la puntuación …",
"ringDoses": "Dosis de hoy",
"ringDosesAria": "{taken} de {scheduled} dosis tomadas hoy",
"ringCarousel": {
"aria": "Anillos de bienestar",
"dot": "Mostrar anillo {index} de {total}"
},
"verdict": {
"doseOverdue": "Una dosis de {name} está atrasada.",
"doseOverdueNoName": "Una dosis está atrasada.",
"bpHigh": "Tu última tensión arterial fue muy alta ({sys}/{dia}). Vuelve a medirla con calma.",
"doseUpcoming": "A las {time} toca {name}.",
"doseUpcomingNoName": "A las {time} toca una dosis.",
"weightDrift": "Tu peso se está alejando del rango objetivo esta semana.",
"shortNights": "Tus noches han sido cortas últimamente — {hours} h de media.",
"silence": "Sin mediciones desde hace {days} días. Un registro rápido mantiene tu panorama al día.",
"scoreDrop": "Tu puntuación está {points} puntos por debajo de la semana pasada.",
"allQuiet": "Todo tranquilo — hoy no hay nada que requiera tu atención."
},
"action": {
"takeDose": "Registrar ahora",
"viewBp": "Ver tensión arterial",
"viewWeight": "Ver peso",
"viewSleep": "Ver sueño",
"logMeasurement": "Registrar medición"
},
"briefing": {
"title": "Resumen de hoy"
}
"ringDoses": "Dosis de hoy"
},
"greetingSalutation": "Hola, {name}",
"streakLabel": "días seguidos",
@@ -401,8 +380,6 @@
"compliance7d": "Cumplimiento (7 días)",
"mood": "Estado de ánimo",
"customizeTitle": "Diseño del panel",
"heroToggleLabel": "Resumen del día",
"heroToggleDescription": "Muestra el saludo, el foco del día y los anillos de puntuación en la parte superior del panel.",
"scoreRingsTitle": "Anillos de puntuación",
"scoreRingsDescription": "Hasta tres anillos adicionales junto a la puntuación de salud, por ejemplo preparación, recuperación, sueño o cumplimiento. Arrastra para reordenar; la puntuación de salud va primero de forma predeterminada.",
"layoutReset": "Restablecer valores por defecto",
@@ -428,7 +405,6 @@
"recentWorkouts": {
"title": "Entrenamientos recientes",
"viewAll": "Ver todos",
"loading": "Cargando entrenamientos…",
"empty": {
"title": "Aún no hay entrenamientos",
"cta": "Abre la app Apple Salud en el iPhone para sincronizar los entrenamientos."
@@ -610,9 +586,27 @@
"typeWho5Score": "Puntuación WHO-5",
"typeSciScore": "Puntuación SCI"
},
"metricCatalog": {
"title": "Lo que HealthLog puede registrar",
"description": "Todos los ámbitos de métricas que admite HealthLog, tengas datos o no todavía, y qué los proporciona.",
"statusPresent": "Registrado",
"statusAbsent": "Aún sin registrar",
"statusModuleOff": "Desactivado",
"loadError": "No se pudo cargar el estado de seguimiento.",
"connectCta": "Conectar una fuente",
"viewCta": "Ver",
"ecg": {
"source": "Tiras de ECG de una derivación desde un reloj compatible — hoy, Withings ScanWatch — sincronizadas automáticamente."
},
"watchdog": {
"title": "Supervisión automática",
"description": "HealthLog también vigila en segundo plano — notificaciones de ritmo irregular y alteraciones respiratorias de un reloj conectado, alertas de desviación de tu valor de referencia, y cambios destacables entre tus dos últimos paneles de laboratorio — y solo muestra una tarjeta cuando algo destaca. Si al menos una de esas fuentes está conectada, que no aparezca ninguna tarjeta es una buena señal, no una función ausente; si todavía no hay ninguna conectada, sencillamente no se está vigilando nada."
}
},
"mood": {
"title": "Estado de ánimo",
"subtitle": "Tendencia y registros de estado de ánimo",
"mentalWellbeingLink": "Hacer un chequeo de bienestar mental",
"addEntry": "Añadir",
"editEntry": "Editar entrada",
"moodLevel": "Estado de ánimo",
@@ -1426,7 +1420,10 @@
"confirmAction": "Eliminar",
"toast": "Toma eliminada",
"failed": "No se pudo eliminar la toma."
}
},
"viewRecent90": "Últimos 90 días",
"viewAll": "Historial completo",
"viewToggleLabel": "Rango del historial"
},
"verlauf": {
"loading": "Cargando historial …",
@@ -1814,7 +1811,8 @@
"label": "¿No es la medición correcta?",
"placeholder": "Seguir según…",
"apply": "Aplicar",
"reset": "Restablecer al valor predeterminado"
"reset": "Restablecer al valor predeterminado",
"error": "No se pudo guardar ese cambio. Inténtalo de nuevo."
},
"beforeAfter": {
"summary": "Ø {before} antes → Ø {after} desde entonces: un cambio de {delta}.",
@@ -1829,7 +1827,10 @@
"subtitle": "Lo que coincide con las mediciones que persiguen tus medicamentos: solo asociación, nunca un veredicto.",
"link": "Abrir la vista de efecto",
"empty": "Aún no hay suficientes datos para relacionar tus medicamentos con sus mediciones objetivo."
}
},
"scaleAbsolute": "Absoluto",
"scalePercentSinceStart": "% desde el inicio",
"scaleToggleLabel": "Escala del gráfico"
}
},
"charts": {
@@ -1978,6 +1979,61 @@
"notAvailableForCompliance": "Comparación no disponible para el cumplimiento"
},
"insights": {
"correlation": {
"direction": {
"higher": "subir",
"lower": "bajar"
},
"weekdayDirection": {
"above": "por encima de",
"below": "por debajo de"
},
"channelFactor": "la valoración de {factor}",
"channel": {
"WEIGHT": "el peso",
"BLOOD_GLUCOSE": "la glucosa en sangre",
"BLOOD_PRESSURE_SYS": "la presión sistólica",
"BLOOD_PRESSURE_DIA": "la presión diastólica",
"ACTIVITY_STEPS": "los pasos diarios",
"SLEEP_DURATION": "la duración del sueño",
"HEART_RATE_VARIABILITY": "la variabilidad de la frecuencia cardíaca",
"RESTING_HEART_RATE": "la frecuencia cardíaca en reposo",
"TIME_IN_DAYLIGHT": "el tiempo con luz natural",
"MOOD": "el estado de ánimo",
"PULSE": "el pulso en reposo",
"MEDICATION_COMPLIANCE": "la adherencia a la medicación",
"SYMPTOM_SEVERITY": "la intensidad de los síntomas",
"ENV_TEMP_MEAN": "la temperatura diaria",
"ENV_TEMP_MIN": "la temperatura mínima nocturna",
"ENV_SUNSHINE": "las horas de sol",
"ENV_DAYLIGHT": "la luz natural",
"ENV_PRECIP": "las precipitaciones",
"ENV_PRESSURE_MEAN": "la presión atmosférica",
"ENV_PRESSURE_DELTA": "la variación de presión del día"
},
"daily": {
"high": "Cuando {behaviour} es mayor, {outcome} del día siguiente tiende a {direction} en tus datos — un patrón que conviene observar, no una causa.",
"moderate": "Cuando {behaviour} es mayor, {outcome} del día siguiente parece tender a {direction}, según los datos disponibles — un patrón que conviene observar, no una causa.",
"faint": "Cuando {behaviour} es mayor, apenas se insinúa —si acaso— que {outcome} del día siguiente tienda a {direction} en tus datos — demasiado leve para fiarse, nunca una causa."
},
"lab": {
"default": "Unos valores de {lab} más altos coinciden con que {outcome} tienda a {direction} en los mismos periodos de tus datos — una asociación que conviene vigilar con tu médico, nunca una causa.",
"faint": "Unos valores de {lab} más altos apenas insinúan —si acaso— que {outcome} tienda a {direction} en los mismos periodos de tus datos — demasiado leve para fiarse, una asociación que conviene comentar con tu médico, nunca una causa."
},
"hypothesis": {
"bpCompliance": {
"support": "Una mayor adherencia a la medicación coincide con valores sistólicos más bajos — un patrón que conviene observar.",
"surprising": "La adherencia y la sistólica se mueven juntas en tus datos — sorprendente; consulta con tu médico antes de cambiar nada.",
"none": "La adherencia y la sistólica no se mueven juntas de forma marcada en este periodo."
},
"moodPulse": {
"support": "Los días de peor ánimo coinciden con un pulso en reposo más alto — un patrón que conviene observar.",
"surprising": "Los días de mejor ánimo coinciden con un pulso en reposo más alto en tus datos — sorprendente.",
"none": "El ánimo y el pulso en reposo no se mueven juntos de forma marcada en este periodo."
},
"weightWeekday": "El peso de los {weekday} está {delta} kg {direction} tu media de los demás días — un patrón que conviene observar."
}
},
"noAnalysisYet": "Aún no hay valoración.",
"assessmentPreparing": "Preparando tu valoración…",
"emptyTitle": "Aún no hay datos suficientes",
@@ -2131,7 +2187,9 @@
"failedDescription": "El proveedor de IA agotó el tiempo de espera o devolvió un error. Inténtalo de nuevo o aumenta el tiempo de espera de respuesta en los ajustes de IA para un modelo local lento.",
"failedDescriptionTimeout": "La solicitud de IA agotó el tiempo de espera antes de que el resumen estuviera listo. Para un modelo local lento, aumenta el tiempo de espera de respuesta en Ajustes y vuelve a intentarlo.",
"failedDescriptionProvider": "El proveedor de IA rechazó la solicitud. Revisa el proveedor y la clave de API en los ajustes de IA y vuelve a intentarlo.",
"failedDescriptionRateLimit": "El proveedor de IA está limitando las solicitudes en este momento. Vuelve a intentarlo en un rato."
"failedDescriptionRateLimit": "El proveedor de IA está limitando las solicitudes en este momento. Vuelve a intentarlo en un rato.",
"omittedTitle": "Resumen retenido",
"omittedUngroundedDescription": "El resumen generado mencionaba una cifra que no se pudo verificar con tus datos, así que no se mostró. Inténtalo de nuevo."
},
"trendsRow": {
"title": "Tendencias",
@@ -2202,6 +2260,12 @@
"errorCredentialExpired": "Tu conexión con el proveedor de IA ha caducado. Vuelve a conectarla en Ajustes para que el Coach siga funcionando.",
"tagline": "Coach personal de salud, apoyado en tus datos.",
"newChat": "Nueva conversación",
"docScope": {
"badge": "Documento",
"chattingAbout": "Hablando sobre: {title}",
"chattingAboutMore": "Hablando sobre: {titles} y {count} más",
"notIndexedHint": "Primero lee este documento con IA en el archivo para que el Coach pueda responder sobre él."
},
"actionsMenu": "Acciones",
"maximizeAriaLabel": "Abrir en pantalla completa",
"minimizeAriaLabel": "Volver al panel",
@@ -2255,7 +2319,9 @@
"historySearchPlaceholder": "Buscar en las conversaciones…",
"historyEmpty": "Tus conversaciones aparecen aquí. Lanza una pregunta para empezar.",
"historyDeleteAria": "Eliminar conversación",
"historyDeleteConfirm": "¿Eliminar esta conversación? No se puede deshacer.",
"historyDeleted": "Conversación eliminada",
"historySearchEmpty": "Ninguna conversación coincide con tu búsqueda.",
"historyLoadingMore": "Cargando más…",
"sourcesTitle": "A qué accedo",
"sourcesFresh": "Actuales",
"sourcesStale": "Desactualizados",
@@ -2466,7 +2532,28 @@
"correlationWeightWeekday": "¿Mi peso sigue un patrón semanal? ¿Qué me dice esta correlación por día de la semana?",
"recommendation": "Cuéntame más sobre esta recomendación: \"{text}\" — ¿por qué se aplica a mí y qué debería hacer al respecto?"
},
"assessmentPrompt": "Explícame mi evaluación de {metric}: ¿qué significa y qué debería hacer al respecto?"
"assessmentPrompt": "Explícame mi evaluación de {metric}: ¿qué significa y qué debería hacer al respecto?",
"attach": {
"menuChooseFromDocuments": "Elegir de documentos",
"menuUploadNew": "Subir documento nuevo",
"trigger": "Adjuntar documentos",
"pickerTitle": "Adjuntar documentos",
"pickerDescription": "El coach solo lee los documentos que adjuntes, no tus datos de salud.",
"pickerSearchPlaceholder": "Buscar documentos…",
"pickerEmpty": "Aún no tienes documentos en tu archivo.",
"pickerNotIndexed": "Aún no indexado",
"pickerCap": "Puedes adjuntar hasta {max} más.",
"pickerConfirm": "Adjuntar ({count})",
"pillRemove": "Quitar {title}",
"indexing": "Indexando…",
"indexingHint": "Un documento aún se está indexando; podrás enviar cuando esté listo.",
"fencedHint": "Esta conversación se ejecuta sin acceso a tus datos de salud: el coach solo lee los documentos adjuntos.",
"documentsAttachedOne": "1 documento adjunto",
"documentsAttachedOther": "{count} documentos adjuntos",
"railBadge": "Chat de documento aislado",
"uploadError": "Error al subir. Inténtalo de nuevo.",
"notFoundError": "Ese enlace de documento ya no es válido: se iniciará un chat normal."
}
},
"relativeJustNow": "ahora mismo",
"relativeMinutesAgoOne": "hace {count} minuto",
@@ -2476,11 +2563,13 @@
"relativeDaysAgoOne": "hace {count} día",
"relativeDaysAgoOther": "hace {count} días",
"navOverview": "Resumen",
"navCatalog": "Todas las métricas",
"navBloodPressure": "Presión arterial",
"navWeight": "Peso",
"navPulse": "Pulso",
"navMood": "Estado de ánimo",
"navMedication": "Medicamento",
"navNutrients": "Nutrientes",
"navBmi": "IMC",
"navSleep": "Sueño",
"navHrv": "VFC",
@@ -2505,9 +2594,9 @@
"label": "Actividad",
"header": "Actividad y movilidad"
},
"cardiovascularParent": {
"label": "Cardiovascular",
"header": "Marcadores cardiovasculares"
"heartParent": {
"label": "Corazón",
"header": "Salud cardíaca"
},
"hearingParent": {
"label": "Audición",
@@ -2632,11 +2721,11 @@
"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.",
"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 (WHO 2024).",
"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.",
"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 (AHA, Circulation 2021), por lo que cuanto más baja, mejor. La escala muestra una puntuación sin unidades (aproximadamente 1–12 es el rango saludable, 13 o más se considera elevado); es una escala distinta de los umbrales en cm² por imagen que usan los profesionales clínicos y no es intercambiable con ellos.",
"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",
@@ -2666,7 +2755,7 @@
"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.",
"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 (CDC 2024), 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",
@@ -2678,7 +2767,7 @@
"wristTemperatureTitle": "Temperatura de muñeca",
"wristTemperatureBody": "La temperatura de muñeca se mide mientras duermes y se indica como la diferencia respecto a tu valor de referencia, no como un valor absoluto. 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 wearable. 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.",
"fallsBody": "Esto cuenta las caídas fuertes que detecta tu wearable. 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 (CDC STEADI 2024).",
"sixMinuteWalkTitle": "Distancia en marcha de 6 minutos",
"sixMinuteWalkBody": "Es una estimación de cuánto podrías caminar en seis minutos, una prueba clínica clásica de movilidad y resistencia cardiopulmonar. El valor del wearable es una aproximación, no la prueba clínica supervisada; lo útil es la tendencia estable o en aumento.",
"stairAscentSpeedTitle": "Velocidad subiendo escaleras",
@@ -2726,7 +2815,8 @@
},
"valuesBack": "Volver a Insights",
"valuesBackToMetric": "Volver a {metric}",
"scoresBack": "Volver a Insights"
"scoresBack": "Volver a Insights",
"ecgDescription": "Registros de ECG de una derivación de tu dispositivo, cada uno con el resultado del propio dispositivo. HealthLog muestra el trazado y el registro, pero no interpreta la onda."
},
"sleep": {
"title": "Sueño",
@@ -2864,7 +2954,7 @@
"sleep": {
"title": "Aún no hay registros de sueño",
"description": "Una vez disponibles los datos de sueño (manual o vía Withings / Apple Health), aquí aparecerá el desglose por fases.",
"cta": "Conectar Withings"
"cta": "Conectar una fuente"
}
},
"navWorkouts": "Entrenamientos",
@@ -2899,7 +2989,24 @@
"hrChartTitle": "Frecuencia cardíaca",
"hrChartUnavailable": "Las muestras de frecuencia cardíaca por segundo no se almacenan para este entrenamiento.",
"notFound": "Entrenamiento no encontrado.",
"loading": "Cargando entrenamiento…"
"loading": "Cargando entrenamiento…",
"hrFromPulse": "A partir de tus datos de frecuencia cardíaca en torno a esta sesión",
"hrAxisMinutes": "min",
"sportAverageLabel": "Tu promedio reciente:",
"zonesTitle": "Zonas de esfuerzo",
"zonesCaption": "Cómo se distribuyó tu esfuerzo entre las zonas de frecuencia cardíaca.",
"zonesCaptionDevice": "Tiempo por zona según tu dispositivo.",
"exportGpx": "Exportar GPX",
"elevationTitle": "Altitud",
"splitsTitle": "Parciales",
"splitsKm": "km",
"splitsTime": "Tiempo",
"splitsPace": "Ritmo",
"thatDayTitle": "Ese día",
"dayPulse": "Pulso",
"daySleep": "Sueño",
"dayMood": "Ánimo",
"insightTitle": "Análisis de la actividad"
},
"sport": {
"walking": "Caminar",
@@ -3232,6 +3339,8 @@
},
"navWalkingSpeed": "Velocidad al andar",
"mood": {
"logLink": "Registrar un estado de ánimo",
"mentalWellbeingLink": "Hacer un chequeo de bienestar mental",
"heatmapTitle": "Calendario de ánimo",
"heatmapNoEntry": "Sin registro",
"legendGreat": "Genial",
@@ -3392,6 +3501,7 @@
"sectionSubtitle": "Señales cardiovasculares y respiratorias en reposo frente a tu propia línea base",
"loadingLabel": "Cargando tus constantes",
"loadError": "No se pudieron cargar tus constantes en este momento.",
"infoLabel": "¿Cómo se calcula esto?",
"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",
@@ -3518,7 +3628,9 @@
"sectionTitle": "Tus indicadores de salud",
"sectionSubtitle": "Valores combinados de recuperación, estrés y esfuerzo leídos de tus dispositivos conectados",
"loadError": "No se pudieron cargar tus puntuaciones de bienestar en este momento.",
"recovery": "Recuperación",
"loadingLabel": "Cargando tus indicadores de salud",
"recovery": "Puntuación de recuperación",
"recoveryCrossLink": "Ver los datos de recuperación y esfuerzo de tu wearable",
"stress": "Estrés",
"strain": "Esfuerzo"
},
@@ -3689,6 +3801,7 @@
"recovery": {
"title": "Recuperación",
"description": "Cómo se ha recuperado tu cuerpo: las señales de recuperación, esfuerzo y carga que tu wearable registra durante la noche.",
"scoreCrossLink": "Ver la puntuación de recuperación combinada",
"emptyTitle": "Aún no hay datos de recuperación",
"empty": "Aún no hay datos de recuperación. Conecta una cuenta de WHOOP, Polar u Oura y estas puntuaciones se completarán cuando tu dispositivo sincronice.",
"block": {
@@ -3823,7 +3936,66 @@
"cta": "Registrar índice cintura-altura"
},
"coachPrefill": "Aún no he registrado un índice cintura-altura: ¿por qué es un número útil y cómo lo mantengo por debajo de 0,5?"
}
},
"ecg": {
"sectionTitle": "Registros de ECG",
"sectionIntro": "Tiras de ECG de una sola derivación registradas por tu dispositivo. HealthLog solo muestra el registro y el resultado que produjo tu dispositivo; no lee ni interpreta la onda.",
"loadError": "No se pudieron cargar tus registros de ECG.",
"backToList": "Volver a los registros",
"disclaimer": "Este es el resultado que produjo tu dispositivo de registro. HealthLog no lee ni interpreta los registros de ECG y no proporciona un diagnóstico.",
"clinicianNote": "Si este resultado te preocupa, comenta este registro con un profesional médico.",
"resultAttribution": "Resultado registrado: {result}, según lo informado por el dispositivo de registro.",
"result": {
"irregular": "Fibrilación auricular detectada",
"notDetected": "Sin signos de fibrilación auricular",
"inconclusive": "Registro no concluyente",
"none": "Sin resultado informado"
},
"meta": {
"recorded": "Registrado",
"duration": "Duración",
"lead": "Derivación",
"averageHeartRate": "Frecuencia cardíaca media",
"samplingRate": "Frecuencia de muestreo",
"leadSingle": "Una sola derivación (derivación I)",
"durationValue": "{seconds} s",
"bpmValue": "{bpm} bpm",
"hzValue": "{hz} Hz",
"unknown": "—"
},
"waveform": {
"ariaLabel": "Tira de ECG registrada el {date}, duración {duration}, frecuencia cardíaca media {heartRate}, resultado registrado: {result}."
},
"crossLink": {
"title": "Registros de ECG",
"recordingsOne": "1 registro guardado.",
"recordingsMany": "{count} registros guardados.",
"latestResult": "Último resultado del dispositivo: {result}.",
"cta": "Ver registros"
},
"emptyTitle": "Aún no hay registros de ECG",
"emptyDescription": "Los registros de ECG se sincronizan desde un dispositivo de una derivación compatible. En cuanto tu dispositivo suba un registro, aparecerá aquí.",
"emptyCta": "Conectar un dispositivo"
},
"intradayPulse": {
"title": "La forma de tu día",
"caption": "Tu frecuencia cardíaca a lo largo del día, promediada en tramos de 10 minutos.",
"hourlyNote": "Mostrando el promedio por hora de este día — los días recientes muestran el detalle completo de 10 minutos.",
"empty": "Aún no hay suficientes lecturas para este día para mostrar su forma.",
"coverageOne": "Basado en 1 lectura a lo largo de {hours}h.",
"coverageMany": "Basado en {count} lecturas a lo largo de {hours}h.",
"baseline": "Reposo",
"today": "Hoy",
"previousDay": "Día anterior",
"nextDay": "Día siguiente",
"tension": {
"morning": "Un tramo elevado en reposo esta mañana; posiblemente algo de tensión.",
"afternoon": "Un tramo elevado en reposo esta tarde; posiblemente algo de tensión.",
"evening": "Un tramo elevado en reposo al anochecer; posiblemente algo de tensión.",
"night": "Un tramo elevado en reposo por la noche; posiblemente algo de tensión."
}
},
"navEcg": "ECG"
},
"thresholds": {
"defaultLabel": "Por defecto",
@@ -3922,7 +4094,7 @@
},
"wrapUp": {
"title": "Esto es la visita",
"body": "Puedes volver a ver cualquier módulo por separado cuando quieras («Mostrar esta visita» en cada página) o reiniciar la visita completa en Ajustes → Avanzado."
"body": "Esta visita guiada se muestra una sola vez, no volverá a abrirse por sí sola. Todo lo que has visto sigue disponible, y puedes activar o desactivar funciones cuando quieras en Ajustes → Módulos."
}
}
},
@@ -3950,7 +4122,7 @@
},
"source": {
"title": "Conectar una fuente",
"body": "Importa las mediciones automáticamente o sigue con la entrada manual. Aquí no se configura nada por ti: cada tarjeta solo muestra dónde conectarla.",
"body": "Importa las mediciones automáticamente o sigue con la entrada manual. Aquí no se configura nada por ti: cada tarjeta solo muestra dónde conectarla. Las fuentes de dispositivo necesitan una app de desarrollador gratuita en tu propia cuenta (unos 5 minutos); la guía de cada tarjeta te lo explica.",
"recommended": "Recomendado",
"manual": {
"title": "Entrada manual",
@@ -3958,12 +4130,14 @@
},
"withings": {
"title": "Withings",
"body": "Las básculas, los tensiómetros y los trackers de Withings se sincronizan automáticamente.",
"cta": "Conectar Withings"
"body": "Las básculas, los tensiómetros y los trackers de Withings se sincronizan automáticamente una vez conectados.",
"cta": "Conectar Withings",
"setupCta": "Configurar en Ajustes",
"setupHint": "Necesita primero tu propia app de desarrollador de Withings, gratuita — unos 5 minutos, una sola vez."
},
"more": {
"title": "Más fuentes",
"body": "Conecta cualquiera de ellas en Ajustes cuando quieras.",
"body": "Conecta cualquiera de ellas en Ajustes cuando quieras — la mayoría necesita antes una app de desarrollador gratuita en tu propia cuenta.",
"appleHealth": "Apple Health",
"whoop": "WHOOP",
"oura": "Oura",
@@ -3995,7 +4169,8 @@
"learning": "Algunas conclusiones —glucosa, ritmo de sueño, tendencias de presión arterial, ciclo— se afinan durante la primera semana o dos a medida que se acumulan tus datos. Las casillas vacías ahora solo significan que aún estamos aprendiendo tu línea base.",
"connectCta": "Conectar un dispositivo",
"logCta": "Registra tu primera medición",
"importCta": "Importar historial existente"
"importCta": "Importar historial existente",
"modulesCta": "Elige tus módulos"
},
"welcomeBack": {
"title": "Bienvenido de nuevo",
@@ -4105,7 +4280,16 @@
"disableError": "No se pudo desactivar el modo Investigación. Inténtalo de nuevo."
},
"shell": {
"sectionsNav": "Secciones de ajustes"
"sectionsNav": "Secciones de ajustes",
"groups": {
"account": "Cuenta",
"yourData": "Tus datos",
"display": "Visualización",
"healthProfile": "Perfil de salud",
"ai": "IA",
"connectivity": "Conectividad",
"system": "Sistema"
}
},
"sections": {
"account": {
@@ -4236,7 +4420,16 @@
"other": "Otro",
"unknown": "Desconocido"
},
"subtitle": "Decide qué fuente prevalece cuando dos informan la misma métrica."
"subtitle": "Decide qué fuente prevalece cuando dos informan la misma métrica.",
"nutrients": {
"title": "Ingesta de nutrientes sincronizada",
"description": "Totales diarios sincronizados desde Apple Health en los últimos {days} días. Solo lectura: la app de origen es la dueña de los valores.",
"empty": "Todavía no se han sincronizado datos de nutrientes.",
"latest": "{amount} {unit} el {date}",
"daysWithData": "{count} de {days} días",
"moduleHint": "La sincronización se controla con el módulo de ingesta de nutrientes.",
"moduleHintLink": "Gestionar módulos"
}
},
"ai": {
"title": "Proveedor de IA",
@@ -4281,7 +4474,8 @@
},
"fullBackup": {
"title": "Copia completa",
"description": "Un único archivo JSON con todo — el mismo formato que la copia automática semanal. Útil para restaurar tú mismo mediante la carga de administrador.",
"description": "Todos tus datos en un único archivo JSON: mediciones, medicamentos, estado de ánimo, ciclo, analíticas, episodios de enfermedad, alergias, antecedentes familiares y entrenamientos. Mismo formato que la copia automática semanal.",
"scopeNote": "Los metadatos de los documentos y el resumen de IA, si existe, se incluyen en texto plano; los archivos originales de los documentos y los datos de rutas GPS/muestras de los entrenamientos no están incluidos — descárgalos por separado desde el archivo de documentos o la exportación de entrenamientos.",
"encryptToggle": "Cifrar con una contraseña",
"passphrasePlaceholder": "Contraseña (mín. 12 caracteres)",
"passphraseHelp": "Deriva la clave del archivo con Argon2id y lo sella con AES-256-GCM. Guarda el archivo con la extensión .hlx.",
@@ -4441,7 +4635,7 @@
"description": "Registros estructurados de alergias y antecedentes familiares."
},
"environment": {
"title": "Entorno",
"title": "Ubicación y clima",
"subtitle": "Contexto de tiempo, luz diurna y ubicación.",
"disabledHint": "Activa el módulo Entorno para definir una ubicación principal e incorporar el tiempo y las horas de luz diarias.",
"searchPlaceholder": "Buscar una ciudad",
@@ -4629,6 +4823,7 @@
"anthropicKeyLabel": "Clave de API de Anthropic",
"baseUrlLabel": "Base URL",
"localKeyLabel": "Clave de API (opcional)",
"localDescription": "Aquí funciona cualquier endpoint compatible con OpenAI: un modelo local (Ollama, LM Studio) o una pasarela como LiteLLM, OpenRouter o vLLM. La URL base termina en /v1; la clave de API es opcional.",
"savedPreview": "(guardada {preview})",
"savedShort": "(guardada)",
"saveCta": "Guardar",
@@ -4704,6 +4899,7 @@
"testReasonCredentials": "El proveedor rechazó las credenciales — vuelve a autenticarte en los ajustes de IA.",
"testReasonRateLimited": "El proveedor limitó la solicitud — inténtalo de nuevo en breve.",
"testReasonServerError": "El proveedor de IA devolvió un error del servidor.",
"testReasonBadRequest": "El proveedor rechazó la solicitud (HTTP {status}): el endpoint respondió, así que el problema está en el formato de la solicitud o en el nombre del modelo, no en la conexión.",
"testReasonUnreachable": "No se pudo contactar con el proveedor de IA.",
"coachMemory": {
"title": "Lo que el Coach recuerda",
@@ -5312,6 +5508,12 @@
"ouraDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
"nightscoutDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
"integrationCredentialsHint": "Dónde encontrar estas credenciales y cómo registrar tu propia aplicación se explica en la guía de configuración de arriba.",
"integrationRedirectGuide": {
"intro": "Esto se conecta mediante tu propia app de desarrollador de {provider} — unos 5 minutos de configuración:",
"step1": "Crea una app en el portal de desarrolladores de {provider}.",
"step2": "Configura su URL de redirección/callback en:",
"step3": "Pega arriba el ID de cliente y el secreto de cliente que te da."
},
"security": {
"sessionsTitle": "Sesiones activas",
"sessionsDescription": "Dispositivos y navegadores con sesión iniciada en tu cuenta.",
@@ -5952,6 +6154,7 @@
"colAction": "Acción",
"colDuration": "Duración",
"colTraceId": "Trace",
"viewDetails": "Ver detalles de {action} a las {timestamp}",
"showing": "Mostrando {count} de hasta {bufferMax} eventos en búfer",
"empty": "El búfer está vacío",
"emptyDescription": "Aún no hay eventos que coincidan con los filtros actuales. Pulsa actualizar tras generar algo de tráfico.",
@@ -6491,6 +6694,8 @@
"eventMeasurementReminderDesc": "Se envía cuando una medición registrada está pendiente.",
"eventMedicationIntakeSync": "Sincronización de tomas",
"eventMedicationIntakeSyncDesc": "Actualización entre dispositivos cuando se registra, toma u omite una dosis.",
"eventDailyBriefing": "Resumen diario",
"eventDailyBriefingDesc": "Un resumen matinal y tranquilo de tu día. Desactivado hasta que lo actives.",
"security": {
"newDeviceTitle": "Nuevo inicio de sesión en tu cuenta",
"newDeviceBody": "Un nuevo inicio de sesión desde {device} ({location}). Si no fuiste tú, cambia tu contraseña de inmediato.",
@@ -6570,9 +6775,9 @@
"hypertensionGrade1": "Hipertensión grado 1",
"hypertensionGrade2": "Hipertensión grado 2",
"hypertensionGrade3": "Hipertensión grado 3",
"bradycardia": "Bradicardia",
"belowTypicalRange": "Por debajo del rango típico",
"elevated": "Elevado",
"tachycardia": "Taquicardia",
"aboveTypicalRange": "Por encima del rango típico",
"significantlyLow": "Significativamente bajo",
"slightlyLow": "Ligeramente bajo",
"onTarget": "En rango objetivo",
@@ -7156,8 +7361,11 @@
"TOTAL_BODY_WATER": "Agua corporal",
"VISCERAL_FAT": "Grasa visceral",
"BODY_MASS_INDEX": "Índice de masa corporal (IMC)",
"WAIST_CIRCUMFERENCE": "Perímetro de cintura",
"PHQ9_SCORE": "PHQ-9 · ánimo",
"GAD7_SCORE": "GAD-7 · preocupación"
"GAD7_SCORE": "GAD-7 · preocupación",
"WHO5_SCORE": "WHO-5 · bienestar",
"SCI_SCORE": "SCI · sueño"
},
"typeGroups": {
"vitals": "Constantes vitales",
@@ -7275,6 +7483,7 @@
"notePlaceholder": "Contexto opcional para este resultado",
"requiredError": "Introduce un valor.",
"save": "Guardar",
"saveAndAddAnother": "Guardar y añadir otro",
"savedToast": "Resultado guardado",
"saveError": "No se pudo guardar el resultado",
"biomarker": "Biomarcador",
@@ -7566,6 +7775,10 @@
"mentalHealth": {
"label": "Bienestar mental",
"description": "Cuestionarios PHQ-9 y GAD-7 opcionales, junto al registro de ánimo. Desactivado por defecto; las respuestas se cifran y se mantienen fuera del Coach de IA."
},
"nutrients": {
"label": "Ingesta de nutrientes",
"description": "Guarda los totales diarios de micronutrientes (vitaminas, minerales, agua, cafeína) sincronizados desde Apple Health. Desactivado por defecto."
}
},
"illness": {
@@ -7619,7 +7832,10 @@
"notePlaceholder": "Algo que valga la pena recordar (cifrado).",
"saveError": "No se pudo guardar el episodio. Inténtalo de nuevo.",
"parent": "Parte de",
"parentNone": "Sin vincular"
"parentNone": "Sin vincular",
"resolved": "Finalizó el",
"resolvedHelp": "Déjalo vacío mientras el episodio siga en curso.",
"markOngoing": "Aún en curso"
},
"sheet": {
"title": "Registrar {date}",
@@ -7671,7 +7887,8 @@
"direction": {
"above": "por encima de lo habitual",
"below": "por debajo de lo habitual"
}
},
"settled": "Durante este episodio nada destacó respecto a tu rango habitual."
},
"insights": {
"title": "Tu historial de enfermedades",
@@ -7722,12 +7939,12 @@
"RECOVERY_SCORE": "Recovery",
"OXYGEN_SATURATION": "Saturación de oxígeno",
"BODY_TEMPERATURE": "Temperatura corporal",
"SKIN_TEMPERATURE": "Temperatura de la piel",
"RESPIRATORY_RATE": "Frecuencia respiratoria",
"BLOOD_PRESSURE_SYS": "Presión arterial sistólica",
"BLOOD_PRESSURE_DIA": "Presión arterial diastólica",
"PULSE": "Pulse",
"WEIGHT": "Weight"
"WEIGHT": "Weight",
"FUNCTIONAL_IMPACT": "Impacto funcional"
},
"listLoadError": "No se pudieron cargar tus episodios."
},
@@ -7808,14 +8025,14 @@
"bpLowDoctor": "Dos lecturas recientes tuvieron una sistólica por debajo de {systolic} mmHg. Si te mareas o te sientes débil, siéntate o recuéstate y contacta a tu médico si continúa.",
"bpLowEmergency": "Dos lecturas recientes tuvieron una sistólica por debajo de {systolic} mmHg e indicaste síntomas. Si te sientes a punto de desmayar, confuso o con piel fría y húmeda, llama a emergencias ya.",
"glucoseLowTitle": "Glucosa baja",
"glucoseLowDoctor": "Dos lecturas recientes estuvieron por debajo de 70 mg/dL ({value} mg/dL). Toma carbohidratos de acción rápida, vuelve a medir en 15 minutos y contacta a tu médico si los bajones continúan.",
"glucoseLowEmergency": "Dos lecturas recientes estuvieron por debajo de 70 mg/dL ({value} mg/dL) e indicaste síntomas. Trátalo ahora con azúcar rápida; si no puedes o te sientes muy mal, llama a emergencias.",
"glucoseLowDoctor": "Dos lecturas recientes estuvieron por debajo de {threshold} {unit} ({value} {unit}). Toma carbohidratos de acción rápida, vuelve a medir en 15 minutos y contacta a tu médico si los bajones continúan.",
"glucoseLowEmergency": "Dos lecturas recientes estuvieron por debajo de {threshold} {unit} ({value} {unit}) e indicaste síntomas. Trátalo ahora con azúcar rápida; si no puedes o te sientes muy mal, llama a emergencias.",
"glucoseLowSevereTitle": "Glucosa muy baja",
"glucoseLowSevereDoctor": "Dos lecturas recientes estuvieron por debajo de 54 mg/dL ({value} mg/dL). Trátalo de inmediato con carbohidratos de acción rápida, vuelve a medir en 15 minutos y contacta a tu médico.",
"glucoseLowSevereEmergency": "Dos lecturas recientes estuvieron por debajo de 54 mg/dL ({value} mg/dL) e indicaste síntomas. Trátalo ahora; si no retienes alimentos o te sientes muy mal, llama a emergencias.",
"glucoseLowSevereDoctor": "Dos lecturas recientes estuvieron por debajo de {threshold} {unit} ({value} {unit}). Trátalo de inmediato con carbohidratos de acción rápida, vuelve a medir en 15 minutos y contacta a tu médico.",
"glucoseLowSevereEmergency": "Dos lecturas recientes estuvieron por debajo de {threshold} {unit} ({value} {unit}) e indicaste síntomas. Trátalo ahora; si no retienes alimentos o te sientes muy mal, llama a emergencias.",
"glucoseHighTitle": "Glucosa muy alta",
"glucoseHighDoctor": "Dos lecturas recientes fueron de {value} mg/dL o más. Vuelve a medir, hidrátate y contacta a tu médico cuanto antes si sigue así de alta.",
"glucoseHighEmergency": "Dos lecturas recientes fueron de {value} mg/dL o más e indicaste síntomas. Si tienes vómitos, respiración rápida o confusión, llama a emergencias ya."
"glucoseHighDoctor": "Dos lecturas recientes fueron de {value} {unit} o más. Vuelve a medir, hidrátate y contacta a tu médico cuanto antes si sigue así de alta.",
"glucoseHighEmergency": "Dos lecturas recientes fueron de {value} {unit} o más e indicaste síntomas. Si tienes vómitos, respiración rápida o confusión, llama a emergencias ya."
}
},
"version": {
@@ -7828,6 +8045,7 @@
"mentalHealth": {
"pageTitle": "Bienestar mental",
"pageDescription": "Cuatro autoevaluaciones validadas y opcionales — PHQ-9 (estado de ánimo), GAD-7 (preocupación), WHO-5 (bienestar) y SCI (sueño) — junto a tu registro de ánimo. Lo valioso es la tendencia en el tiempo, no un único número.",
"moodLink": "Ver tu historial de estado de ánimo",
"attributionLabel": "Fuente",
"attributionShow": "Fuente y licencia de {instrument}",
"choosePrompt": "Elige una revisión",
@@ -7952,6 +8170,12 @@
"nummerGegenKummer": {
"name": "Nummer gegen Kummer (para jóvenes)"
},
"telefonSeelsorgeAt": {
"name": "Telefonseelsorge Österreich (24/7, gratuito y confidencial)"
},
"dargeboteneHand": {
"name": "Die Dargebotene Hand (24/7, gratuito y confidencial)"
},
"krisenchat": {
"name": "krisenchat (chat 24/7, para jóvenes)"
},
@@ -8205,7 +8429,9 @@
"openLabel": "Abrir {title}",
"attachmentBadge": "Solo descarga",
"uploading": "Subiendo…",
"dismissFailed": "Descartar subida fallida"
"dismissFailed": "Descartar subida fallida",
"processing": "Procesando…",
"ready": "Listo"
},
"error": {
"fileTooLarge": "Este archivo supera el límite de {maxSize}.",
@@ -8247,6 +8473,10 @@
},
"detail": {
"title": "Detalles del documento",
"summary": {
"title": "Resumen",
"pending": "Se está preparando el resumen."
},
"loadError": "No se pudo cargar este documento.",
"saveError": "No se pudo guardar el cambio.",
"deleteError": "No se pudo eliminar el documento.",
@@ -8261,7 +8491,8 @@
"noConditions": "Sin vincular a una afección",
"previewUnavailable": "Sin vista previa integrada para este tipo de archivo",
"uploadedMeta": "Añadido el {date} · {size}",
"share": "Compartir"
"share": "Compartir",
"labValuesLink": "Ver {count} valores de laboratorio"
},
"dropOverlay": {
"title": "Suelta los archivos para subirlos",
@@ -8276,7 +8507,9 @@
"deleted": "{count} documento(s) eliminados",
"restored": "{count} documento(s) restaurados",
"partialFailure": "No se pudieron actualizar {failed} de {total}",
"failed": "No se pudo completar la acción"
"failed": "No se pudo completar la acción",
"share": "Compartir",
"shareTooMany": "Un enlace para compartir admite como máximo {max} documentos. Deselecciona algunos e inténtalo de nuevo."
},
"episodeCard": {
"title": "Documentos",
@@ -8340,28 +8573,14 @@
},
"share": {
"title": "Compartir este documento",
"multiTitle": "Compartir estos documentos",
"multiLabel": "{count} documentos",
"description": "Crea un enlace de solo lectura para profesionales con este documento adjunto. El enlace y su frase de contraseña aparecen una sola vez."
},
"chat": {
"title": "Chatea sobre este documento",
"notIndexed": "Primero lee este documento con IA para poder chatear sobre él.",
"empty": "Haz una pregunta sobre este documento para empezar.",
"placeholder": "Pregunta sobre este documento…",
"inputLabel": "Tu pregunta sobre este documento",
"send": "Enviar",
"thinking": "Leyendo el documento…",
"safety": "Las respuestas describen este documento y no son consejo médico.",
"close": "Cerrar chat",
"errorHistory": "No se pudo cargar la conversación. Inténtalo de nuevo.",
"errorGeneric": "Algo salió mal. Inténtalo de nuevo.",
"errorRateLimited": "Demasiados mensajes. Espera un momento e inténtalo de nuevo.",
"errorBudget": "Se alcanzó el límite diario de IA. Inténtalo de nuevo mañana.",
"errorCredential": "Hay que volver a conectar el proveedor de IA en los ajustes.",
"errorConsent": "Activa el consentimiento de IA en los ajustes para chatear sobre este documento.",
"errorNetwork": "Parece que no tienes conexión. Comprueba tu red e inténtalo de nuevo.",
"openAria": "Chatea sobre este documento",
"openButton": "Pregunta sobre este documento",
"scopeBadge": "Chateando sobre: {title}"
"openAria": "Pregunta al Coach sobre este documento",
"openButton": "Preguntar al Coach"
}
},
"selfDescription": {
@@ -8448,5 +8667,172 @@
"valuesDescription": "Todos los valores registrados de esta métrica.",
"backToMetric": "Volver a {metric}"
}
},
"nutrients": {
"names": {
"vitamin_a": "Vitamina A",
"thiamin": "Tiamina (B1)",
"riboflavin": "Riboflavina (B2)",
"niacin": "Niacina (B3)",
"pantothenic_acid": "Ácido pantoténico (B5)",
"vitamin_b6": "Vitamina B6",
"biotin": "Biotina (B7)",
"folate": "Folato (B9)",
"vitamin_b12": "Vitamina B12",
"vitamin_c": "Vitamina C",
"vitamin_d": "Vitamina D",
"vitamin_e": "Vitamina E",
"vitamin_k": "Vitamina K",
"calcium": "Calcio",
"iron": "Hierro",
"magnesium": "Magnesio",
"phosphorus": "Fósforo",
"zinc": "Zinc",
"copper": "Cobre",
"manganese": "Manganeso",
"selenium": "Selenio",
"chromium": "Cromo",
"molybdenum": "Molibdeno",
"iodine": "Yodo",
"water": "Agua",
"caffeine": "Cafeína"
},
"page": {
"title": "Ingesta de nutrientes",
"description": "Hidratación e ingesta de vitaminas/minerales sincronizadas desde la app de salud de tu teléfono, más entradas manuales de agua.",
"moduleOffTitle": "La ingesta de nutrientes está desactivada",
"moduleOffDescription": "Activa el módulo de nutrientes para ver aquí la hidratación y la ingesta de vitaminas/minerales. Los datos sincronizados pasarán a formar parte del registro que el Coach puede leer.",
"moduleOffCta": "Activar nutrientes",
"emptyTitle": "Aún no hay datos de nutrientes",
"emptyDescription": "Los datos llegan desde la sincronización de la app de salud de tu teléfono o desde una entrada manual de agua."
},
"hydration": {
"referenceMeta": "Ingesta de referencia {value} {unit}/día · EFSA",
"quickAddTitle": "Añadir agua",
"quickAddCustomLabel": "Cantidad personalizada (mL)",
"quickAddCustomPlaceholder": "250",
"quickAddSubmit": "Añadir",
"quickAddError": "No se pudo guardar. Inténtalo de nuevo.",
"editTotal": "Editar el total de hoy"
},
"caffeine": {
"ceilingMeta": "Límite de seguridad {value} {unit}/día · EFSA"
},
"micronutrients": {
"title": "Vitaminas y minerales",
"rowMeta": "{days} de {window} días · ref. {value} {unit} (EFSA)",
"rowMetaNoRef": "{days} de {window} días",
"footer": "{tracked} de {total} micronutrientes registrados tienen datos en este periodo.",
"emptyTitle": "Aún no hay datos de micronutrientes sincronizados"
},
"sparseNote": "Un valor bajo aquí suele significar un registro escaso, no una deficiencia: los totales diarios de Apple Health reflejan lo que registraron tus apps, no la ingesta dietética total."
},
"daily": {
"line": {
"score": "Tu puntuación de salud hoy es {score}.",
"allClear": "Hoy nada necesita tu atención."
},
"push": {
"title": "Tu resumen matinal",
"bodyProvisional": "{line} · El sueño de anoche aún no está disponible."
},
"item": {
"doseWindow": {
"title": "Medicación pendiente",
"body": "Hoy toca una dosis.",
"bodyNamed": "{name} toca hoy."
},
"preventiveCare": {
"title": "Revisión pendiente",
"body": "{label} está pendiente.",
"bodyMany": "Hay {count} revisiones pendientes."
},
"syncIssue": {
"title": "La sincronización necesita atención",
"body": "{integration} no se está sincronizando: vuelve a conectarlo para mantener tus datos al día."
},
"coachCheckin": {
"title": "Es buen momento para revisar un plan",
"body": "Ha pasado alrededor de una semana desde que definiste este plan: consérvalo, ajústalo o déjalo ir. Sin presión.",
"bodyPlan": "Hace aproximadamente una semana te propusiste: «{plan}». Consérvalo, ajústalo o déjalo ir, sin presión.",
"adjustPrompt": "Me gustaría ajustar mi plan de seguimiento.",
"adjustPromptPlan": "Me gustaría ajustar este plan: «{plan}»"
},
"tensionWindow": {
"title": "Quizá algo de tensión hoy",
"body": {
"morning": "Tu frecuencia cardíaca estuvo por encima de tu valor en reposo durante un buen rato esta mañana mientras estabas en calma; posiblemente algo de tensión.",
"afternoon": "Tu frecuencia cardíaca estuvo por encima de tu valor en reposo durante un buen rato esta tarde mientras estabas en calma; posiblemente algo de tensión.",
"evening": "Tu frecuencia cardíaca estuvo por encima de tu valor en reposo durante un buen rato al anochecer mientras estabas en calma; posiblemente algo de tensión.",
"night": "Tu frecuencia cardíaca estuvo por encima de tu valor en reposo durante un buen rato por la noche mientras estabas en calma; posiblemente algo de tensión."
}
},
"ecgNewRecording": {
"title": "Nuevo registro de ECG",
"body": "Tu dispositivo registró un nuevo ECG: está listo para verlo.",
"bodyVerdict": "Tu dispositivo registró un nuevo ECG e informó: {verdict}.",
"verdict": {
"irregular": "un posible ritmo irregular",
"notDetected": "ningún ritmo irregular",
"inconclusive": "un resultado no concluyente"
}
}
},
"action": {
"logDose": "Registrar dosis",
"reconnect": "Reconectar",
"viewCheckups": "Ver revisiones",
"checkinKeep": "Conservar",
"checkinAdjust": "Ajustar",
"checkinLetGo": "Dejarlo ir",
"viewMilestone": "Ver",
"viewPulse": "Ver pulso",
"viewEcg": "Ver registro"
},
"today": {
"scoreLabel": "Puntuación de salud",
"deltaVsBaseline": "{delta} frente a tu valor de referencia",
"deltaFlat": "En línea con tu valor de referencia",
"sleepPending": "El sueño de anoche aún no ha llegado; la lectura de hoy es provisional.",
"readFullBriefing": "Leer el informe completo",
"worthALook": "Merece un vistazo",
"allClear": "Hoy nada necesita tu atención.",
"ringLink": "Abrir detalles de {metric}"
},
"milestone": {
"metric": {
"restingHeartRate": "frecuencia cardíaca en reposo",
"heartRateVariability": "variabilidad de la frecuencia cardíaca",
"respiratoryRate": "frecuencia respiratoria",
"weight": "peso",
"generic": "este valor"
},
"record": {
"title": "Nuevo récord: {metric}",
"body": "Hoy has alcanzado tu mejor marca de {metric}. Anotado con calma, sin nada que hacer."
},
"returnToBaseline": {
"title": "De nuevo en tu rango: {metric}",
"body": "Tu {metric} ha vuelto a tu rango habitual. Fuera lo que fuera, ya pasó."
},
"sustainedInRange": {
"week1": {
"title": "Una semana en rango",
"body": "Tu {metric} lleva una semana dentro de tu rango habitual. Con buena estabilidad."
},
"week2": {
"title": "Dos semanas en rango",
"body": "Tu {metric} lleva dos semanas dentro de tu rango habitual. Tranquilo y constante."
},
"week3": {
"title": "Tres semanas en rango",
"body": "Tres semanas con tu {metric} dentro de tu rango habitual. Un buen tramo de calma."
},
"week4": {
"title": "Un mes en rango",
"body": "Un mes entero con tu {metric} dentro de tu rango habitual. Maravillosamente estable."
}
}
}
}
}
+484 -98
View File
@@ -91,7 +91,7 @@
"colShare": "Part",
"footerDisclaimer1": "Ce rapport a été généré automatiquement à partir de données auto-déclarées et vise à soutenir",
"footerDisclaimer2": "la conversation médecin-patient. Il ne remplace pas un diagnostic médical. Les corrélations n'impliquent pas la causalité.",
"footerSource": "Source : HealthLog | Fuseau horaire : Europe/Berlin | Créé : {timestamp}",
"footerSource": "Source : HealthLog | Fuseau horaire : {timezone} | Créé : {timestamp}",
"typeWeight": "Poids corporel",
"typeBpSys": "Tension artérielle systolique",
"typeBpDia": "Tension artérielle diastolique",
@@ -228,7 +228,7 @@
"logout": "Se déconnecter",
"login": "Se connecter",
"notifications": "Notifications",
"theme": "Apparence",
"theme": "Thème",
"themeSystem": "Système",
"themeDark": "Sombre",
"themeLight": "Clair",
@@ -286,6 +286,15 @@
"passwordTooShort": "Le mot de passe doit comporter au moins {minLength} caractères.",
"privacyPolicy": "Politique de confidentialité",
"passwordBreached": "Ce mot de passe figure dans des fuites de données connues. Choisissez-en un autre.",
"oidc": {
"signInDefault": "Se connecter avec le SSO",
"errorDenied": "La connexion a été annulée.",
"errorNoEmail": "Votre fournisseur d'identité n'a pas transmis d'adresse e-mail vérifiée.",
"errorEmailUnverified": "Votre fournisseur d'identité n'a pas marqué votre adresse e-mail comme vérifiée.",
"errorIdentityConflict": "Cette adresse e-mail appartient à un compte déjà lié à une autre identité SSO.",
"errorRegistrationDisabled": "Aucun compte ne correspond à cette identité et les nouvelles inscriptions sont actuellement fermées.",
"errorFailed": "L'authentification unique a échoué. Veuillez réessayer."
},
"mfa": {
"title": "Authentification à deux facteurs",
"totpHint": "Saisissez le code à 6 chiffres de votre application d'authentification.",
@@ -318,37 +327,7 @@
"welcomeBackWithName": "{greeting} {name}, content de te revoir.",
"hero": {
"scoreLabel": "Health Score",
"ringLink": "Ouvrir les détails de {metric}",
"scoreProvisional": "Pas encore assez de données",
"scoreComputing": "Calcul du score en cours …",
"ringDoses": "Doses du jour",
"ringDosesAria": "{taken} des {scheduled} doses prises aujourd'hui",
"ringCarousel": {
"aria": "Anneaux de bien-être",
"dot": "Afficher l'anneau {index} sur {total}"
},
"verdict": {
"doseOverdue": "Une dose de {name} est en retard.",
"doseOverdueNoName": "Une dose est en retard.",
"bpHigh": "Votre dernière tension était très élevée ({sys}/{dia}). Reprenez une mesure au calme.",
"doseUpcoming": "{name} est prévu à {time}.",
"doseUpcomingNoName": "Une dose est prévue à {time}.",
"weightDrift": "Votre poids s’éloigne de la plage cible cette semaine.",
"shortNights": "Vos nuits ont été courtes récemment — {hours} h en moyenne.",
"silence": "Aucune mesure depuis {days} jours. Une saisie rapide garde votre tableau à jour.",
"scoreDrop": "Votre score est {points} points sous la semaine dernière.",
"allQuiet": "Tout est calme — rien ne demande votre attention aujourd’hui."
},
"action": {
"takeDose": "Enregistrer maintenant",
"viewBp": "Voir la tension",
"viewWeight": "Voir le poids",
"viewSleep": "Voir le sommeil",
"logMeasurement": "Saisir une mesure"
},
"briefing": {
"title": "Briefing du jour"
}
"ringDoses": "Doses du jour"
},
"greetingSalutation": "Bonjour, {name}",
"streakLabel": "jours d’affilée",
@@ -401,8 +380,6 @@
"compliance7d": "Observance (7 jours)",
"mood": "Humeur",
"customizeTitle": "Mise en page du tableau",
"heroToggleLabel": "Aperçu du jour",
"heroToggleDescription": "Affiche le message d’accueil, le focus du jour et les anneaux de score tout en haut du tableau de bord.",
"scoreRingsTitle": "Anneaux de score",
"scoreRingsDescription": "Jusqu’à trois anneaux supplémentaires à côté du score de santé — par exemple préparation, récupération, sommeil ou observance. Glissez pour réordonner ; le score de santé est placé en premier par défaut.",
"layoutReset": "Rétablir les valeurs par défaut",
@@ -428,7 +405,6 @@
"recentWorkouts": {
"title": "Entraînements récents",
"viewAll": "Tout voir",
"loading": "Chargement des entraînements…",
"empty": {
"title": "Aucun entraînement",
"cta": "Ouvre l'app Apple Santé sur ton iPhone pour synchroniser les entraînements."
@@ -610,9 +586,27 @@
"typeWho5Score": "Score WHO-5",
"typeSciScore": "Score SCI"
},
"metricCatalog": {
"title": "Ce que HealthLog peut suivre",
"description": "Tous les domaines de mesures pris en charge par HealthLog, que tu aies déjà des données ou non — et ce qui les fournirait.",
"statusPresent": "Suivi",
"statusAbsent": "Pas encore suivi",
"statusModuleOff": "Désactivé",
"loadError": "Impossible de charger l'état de suivi.",
"connectCta": "Connecter une source",
"viewCta": "Voir",
"ecg": {
"source": "Tracés ECG à une dérivation depuis une montre compatible — Withings ScanWatch aujourd'hui — synchronisés automatiquement."
},
"watchdog": {
"title": "Surveillance automatique",
"description": "HealthLog surveille aussi discrètement en arrière-plan — notifications de rythme irrégulier et de perturbations respiratoires envoyées par une montre connectée, alertes d'écart par rapport à ta valeur de référence, ainsi que des changements notables entre tes deux derniers bilans de laboratoire — et n'affiche une carte que si quelque chose se démarque. Si au moins une de ces sources est connectée, l'absence de carte est bon signe, pas une fonctionnalité manquante ; si aucune ne l'est encore, c'est simplement qu'il n'y a rien à surveiller."
}
},
"mood": {
"title": "Humeur",
"subtitle": "Tendance et entrées d’humeur",
"mentalWellbeingLink": "Faire un bilan de bien-être mental",
"addEntry": "Ajouter",
"editEntry": "Modifier l’entrée",
"moodLevel": "Humeur",
@@ -1426,7 +1420,10 @@
"confirmAction": "Supprimer",
"toast": "Prise supprimée",
"failed": "Impossible de supprimer la prise."
}
},
"viewRecent90": "90 derniers jours",
"viewAll": "Historique complet",
"viewToggleLabel": "Plage de l'historique"
},
"verlauf": {
"loading": "Chargement de l'historique …",
@@ -1814,7 +1811,8 @@
"label": "Ce n'est pas la bonne mesure ?",
"placeholder": "Suivre selon…",
"apply": "Appliquer",
"reset": "Réinitialiser par défaut"
"reset": "Réinitialiser par défaut",
"error": "Impossible d'enregistrer cette modification. Réessaie."
},
"beforeAfter": {
"summary": "Ø {before} avant → Ø {after} depuis — un écart de {delta}.",
@@ -1829,7 +1827,10 @@
"subtitle": "Ce qui coïncide avec les mesures visées par vos médicaments — association seulement, jamais un verdict.",
"link": "Ouvrir la vue Effet",
"empty": "Pas encore assez de données pour relier vos médicaments à leurs mesures cibles."
}
},
"scaleAbsolute": "Absolu",
"scalePercentSinceStart": "% depuis le début",
"scaleToggleLabel": "Échelle du graphique"
}
},
"charts": {
@@ -1978,6 +1979,61 @@
"notAvailableForCompliance": "Comparaison indisponible pour l’observance"
},
"insights": {
"correlation": {
"direction": {
"higher": "augmenter",
"lower": "diminuer"
},
"weekdayDirection": {
"above": "au-dessus de",
"below": "en dessous de"
},
"channelFactor": "l'évaluation de {factor}",
"channel": {
"WEIGHT": "le poids",
"BLOOD_GLUCOSE": "la glycémie",
"BLOOD_PRESSURE_SYS": "la pression systolique",
"BLOOD_PRESSURE_DIA": "la pression diastolique",
"ACTIVITY_STEPS": "le nombre de pas quotidien",
"SLEEP_DURATION": "la durée de sommeil",
"HEART_RATE_VARIABILITY": "la variabilité de la fréquence cardiaque",
"RESTING_HEART_RATE": "la fréquence cardiaque au repos",
"TIME_IN_DAYLIGHT": "le temps passé à la lumière du jour",
"MOOD": "l'humeur",
"PULSE": "le pouls au repos",
"MEDICATION_COMPLIANCE": "l'observance des médicaments",
"SYMPTOM_SEVERITY": "la sévérité des symptômes",
"ENV_TEMP_MEAN": "la température quotidienne",
"ENV_TEMP_MIN": "la température minimale nocturne",
"ENV_SUNSHINE": "l'ensoleillement",
"ENV_DAYLIGHT": "la lumière du jour",
"ENV_PRECIP": "les précipitations",
"ENV_PRESSURE_MEAN": "la pression atmosphérique",
"ENV_PRESSURE_DELTA": "la variation de pression sur la journée"
},
"daily": {
"high": "Quand {behaviour} monte, {outcome} du lendemain tend à {direction} dans tes données — une tendance à surveiller, pas une cause.",
"moderate": "Quand {behaviour} monte, {outcome} du lendemain semble avoir tendance à {direction}, au vu des données actuelles — une tendance à surveiller, pas une cause.",
"faint": "Quand {behaviour} monte, tout au plus se dessine un faible indice que {outcome} du lendemain tende à {direction} dans tes données — trop ténu pour s'y fier, jamais une cause."
},
"lab": {
"default": "Des valeurs de {lab} plus élevées vont de pair avec {outcome} qui tend à {direction} sur les mêmes périodes dans tes données — une association à surveiller avec ton médecin, jamais une cause.",
"faint": "Des valeurs de {lab} plus élevées laissent tout au plus entrevoir que {outcome} tende à {direction} sur les mêmes périodes dans tes données — trop ténu pour s'y fier, une association à évoquer avec ton médecin, jamais une cause."
},
"hypothesis": {
"bpCompliance": {
"support": "Une meilleure observance des médicaments va de pair avec des valeurs systoliques plus basses — une tendance à surveiller.",
"surprising": "L'observance et la systolique évoluent ensemble dans tes données — surprenant ; parles-en à ton médecin avant de changer quoi que ce soit.",
"none": "L'observance et la systolique n'évoluent pas nettement ensemble sur cette période."
},
"moodPulse": {
"support": "Les jours de moral plus bas vont de pair avec un pouls au repos plus élevé — une tendance à surveiller.",
"surprising": "Les jours de meilleur moral vont de pair avec un pouls au repos plus élevé dans tes données — surprenant.",
"none": "L'humeur et le pouls au repos n'évoluent pas nettement ensemble sur cette période."
},
"weightWeekday": "Le poids du {weekday} est {delta} kg {direction} ta moyenne des autres jours — une tendance à surveiller."
}
},
"noAnalysisYet": "Pas encore d’évaluation.",
"assessmentPreparing": "Préparation de votre évaluation…",
"emptyTitle": "Pas encore assez de données",
@@ -2131,7 +2187,9 @@
"failedDescription": "Le fournisseur d'IA a dépassé le délai ou renvoyé une erreur. Réessayez ou augmentez le délai de réponse dans les paramètres d'IA pour un modèle local lent.",
"failedDescriptionTimeout": "La requête IA a dépassé le délai avant que le briefing soit prêt. Pour un modèle local lent, augmentez le délai de réponse dans les Paramètres, puis réessayez.",
"failedDescriptionProvider": "Le fournisseur d'IA a rejeté la requête. Vérifiez le fournisseur et la clé API dans les paramètres d'IA, puis réessayez.",
"failedDescriptionRateLimit": "Le fournisseur d'IA limite les requêtes pour le moment. Réessayez dans un instant."
"failedDescriptionRateLimit": "Le fournisseur d'IA limite les requêtes pour le moment. Réessayez dans un instant.",
"omittedTitle": "Briefing non affiché",
"omittedUngroundedDescription": "Le briefing généré mentionnait un chiffre qui n'a pas pu être vérifié avec tes données, il n'a donc pas été affiché. Réessaie."
},
"trendsRow": {
"title": "Tendances",
@@ -2202,6 +2260,12 @@
"errorCredentialExpired": "Ta connexion au fournisseur d'IA a expiré. Reconnecte-la dans les Réglages pour que le Coach continue de fonctionner.",
"tagline": "Coach personnel de santé, ancré sur tes données.",
"newChat": "Nouvelle conversation",
"docScope": {
"badge": "Document",
"chattingAbout": "Discussion sur : {title}",
"chattingAboutMore": "Discussion sur : {titles} et {count} de plus",
"notIndexedHint": "Lis d'abord ce document avec l'IA dans le coffre pour que le Coach puisse y répondre."
},
"actionsMenu": "Actions",
"maximizeAriaLabel": "Ouvrir en pleine page",
"minimizeAriaLabel": "Revenir au panneau",
@@ -2255,7 +2319,9 @@
"historySearchPlaceholder": "Rechercher dans les conversations…",
"historyEmpty": "Tes conversations s’affichent ici. Lance une question pour commencer.",
"historyDeleteAria": "Supprimer la conversation",
"historyDeleteConfirm": "Supprimer cette conversation ? Action irréversible.",
"historyDeleted": "Conversation supprimée",
"historySearchEmpty": "Aucune conversation ne correspond à ta recherche.",
"historyLoadingMore": "Chargement…",
"sourcesTitle": "Ce à quoi j’accède",
"sourcesFresh": "À jour",
"sourcesStale": "Obsolètes",
@@ -2466,7 +2532,28 @@
"correlationWeightWeekday": "Mon poids suit-il un schéma hebdomadaire ? Que m'indique cette corrélation selon le jour de la semaine ?",
"recommendation": "Parle-moi davantage de cette recommandation : \"{text}\" — pourquoi s'applique-t-elle à moi, et que devrais-je faire ?"
},
"assessmentPrompt": "Explique-moi mon évaluation de {metric} : qu'est-ce que cela signifie et que dois-je faire ?"
"assessmentPrompt": "Explique-moi mon évaluation de {metric} : qu'est-ce que cela signifie et que dois-je faire ?",
"attach": {
"menuChooseFromDocuments": "Choisir parmi les documents",
"menuUploadNew": "Téléverser un nouveau document",
"trigger": "Joindre des documents",
"pickerTitle": "Joindre des documents",
"pickerDescription": "Le coach lit uniquement les documents que vous joignez, pas vos données de santé.",
"pickerSearchPlaceholder": "Rechercher des documents…",
"pickerEmpty": "Aucun document dans votre coffre pour l'instant.",
"pickerNotIndexed": "Pas encore indexé",
"pickerCap": "Vous pouvez encore en joindre jusqu'à {max}.",
"pickerConfirm": "Joindre ({count})",
"pillRemove": "Retirer {title}",
"indexing": "Indexation…",
"indexingHint": "Un document est encore en cours d'indexation ; vous pourrez envoyer une fois prêt.",
"fencedHint": "Cette conversation s'exécute sans accès à vos données de santé : le coach lit uniquement les documents joints.",
"documentsAttachedOne": "1 document joint",
"documentsAttachedOther": "{count} documents joints",
"railBadge": "Chat de document cloisonné",
"uploadError": "Échec du téléversement. Veuillez réessayer.",
"notFoundError": "Ce lien de document n'est plus valide : une conversation normale démarre à la place."
}
},
"relativeJustNow": "à l’instant",
"relativeMinutesAgoOne": "il y a {count} minute",
@@ -2476,11 +2563,13 @@
"relativeDaysAgoOne": "il y a {count} jour",
"relativeDaysAgoOther": "il y a {count} jours",
"navOverview": "Vue d'ensemble",
"navCatalog": "Toutes les mesures",
"navBloodPressure": "Tension artérielle",
"navWeight": "Poids",
"navPulse": "Pouls",
"navMood": "Humeur",
"navMedication": "Médicament",
"navNutrients": "Nutriments",
"navBmi": "IMC",
"navSleep": "Sommeil",
"navHrv": "VFC",
@@ -2505,9 +2594,9 @@
"label": "Activité",
"header": "Activité et mobilité"
},
"cardiovascularParent": {
"label": "Cardiovasculaire",
"header": "Marqueurs cardiovasculaires"
"heartParent": {
"label": "Cœur",
"header": "Santé cardiaque"
},
"hearingParent": {
"label": "Audition",
@@ -2632,11 +2721,11 @@
"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.",
"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 (WHO 2024).",
"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.",
"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 (AHA, Circulation 2021) : plus elle est basse, mieux c'est. L'échelle indique une valeur sans unité (environ 1 à 12 correspond à la plage saine, 13 et plus étant élevé) ; il s'agit d'une échelle distincte des seuils en cm² obtenus par imagerie qu'utilisent les cliniciens, et elle n'est pas interchangeable avec eux.",
"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",
@@ -2666,7 +2755,7 @@
"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.",
"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 (CDC 2024), 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",
@@ -2678,7 +2767,7 @@
"wristTemperatureTitle": "Température du poignet",
"wristTemperatureBody": "La température du poignet est mesurée pendant votre sommeil et indiquée comme l'écart par rapport à votre valeur de référence, et non comme une valeur absolue. 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 wearable. 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.",
"fallsBody": "Ceci compte les chutes lourdes détectées par votre wearable. 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 (CDC STEADI 2024).",
"sixMinuteWalkTitle": "Distance de marche de 6 minutes",
"sixMinuteWalkBody": "C'est une estimation de la distance que vous pourriez parcourir en six minutes, un test clinique classique de mobilité et d'endurance cardiopulmonaire. La valeur du wearable est une approximation, pas le test clinique encadré ; c'est la tendance stable ou en hausse qui compte.",
"stairAscentSpeedTitle": "Vitesse en montée d'escaliers",
@@ -2726,7 +2815,8 @@
},
"valuesBack": "Retour aux Insights",
"valuesBackToMetric": "Retour à {metric}",
"scoresBack": "Retour aux Insights"
"scoresBack": "Retour aux Insights",
"ecgDescription": "Enregistrements ECG à dérivation unique de votre appareil, chacun accompagné du résultat de l'appareil. HealthLog affiche le tracé et l'enregistrement, mais n'interprète pas la courbe."
},
"sleep": {
"title": "Sommeil",
@@ -2864,7 +2954,7 @@
"sleep": {
"title": "Aucune donnée de sommeil",
"description": "Dès que des données de sommeil sont disponibles (manuel ou via Withings / Apple Health), la répartition par stade apparaîtra ici.",
"cta": "Connecter Withings"
"cta": "Connecter une source"
}
},
"navWorkouts": "Entraînements",
@@ -2899,7 +2989,24 @@
"hrChartTitle": "Fréquence cardiaque",
"hrChartUnavailable": "Les échantillons de fréquence cardiaque à la seconde ne sont pas stockés pour cet entraînement.",
"notFound": "Entraînement introuvable.",
"loading": "Chargement de l'entraînement…"
"loading": "Chargement de l'entraînement…",
"hrFromPulse": "À partir de vos données de fréquence cardiaque autour de cette séance",
"hrAxisMinutes": "min",
"sportAverageLabel": "Votre moyenne récente :",
"zonesTitle": "Zones d'effort",
"zonesCaption": "Répartition de votre effort entre les zones de fréquence cardiaque.",
"zonesCaptionDevice": "Temps par zone indiqué par votre appareil.",
"exportGpx": "Exporter le GPX",
"elevationTitle": "Dénivelé",
"splitsTitle": "Temps par kilomètre",
"splitsKm": "km",
"splitsTime": "Temps",
"splitsPace": "Allure",
"thatDayTitle": "Ce jour-là",
"dayPulse": "Pouls",
"daySleep": "Sommeil",
"dayMood": "Humeur",
"insightTitle": "Analyse de l'activité"
},
"sport": {
"walking": "Marche",
@@ -3232,6 +3339,8 @@
},
"navWalkingSpeed": "Vitesse de marche",
"mood": {
"logLink": "Enregistrer une humeur",
"mentalWellbeingLink": "Faire un bilan de bien-être mental",
"heatmapTitle": "Calendrier d'humeur",
"heatmapNoEntry": "Aucune entrée",
"legendGreat": "Excellent",
@@ -3392,6 +3501,7 @@
"sectionSubtitle": "Signaux cardiovasculaires et respiratoires au repos par rapport à ta propre référence",
"loadingLabel": "Chargement de vos constantes",
"loadError": "Impossible de charger vos constantes pour le moment.",
"infoLabel": "Comment ce résultat est-il calculé ?",
"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",
@@ -3518,7 +3628,9 @@
"sectionTitle": "Tes indicateurs de santé",
"sectionSubtitle": "Valeurs combinées de récupération, de stress et d'effort issues de tes appareils connectés",
"loadError": "Impossible de charger vos scores de bien-être pour le moment.",
"recovery": "Récupération",
"loadingLabel": "Chargement de tes indicateurs de santé",
"recovery": "Score de récupération",
"recoveryCrossLink": "Voir les données de récupération et d'effort de votre wearable",
"stress": "Stress",
"strain": "Effort"
},
@@ -3689,6 +3801,7 @@
"recovery": {
"title": "Récupération",
"description": "À quel point votre corps a récupéré : les signaux de récupération, de charge et d'effort que votre wearable enregistre la nuit.",
"scoreCrossLink": "Voir le score de récupération composite",
"emptyTitle": "Pas encore de données de récupération",
"empty": "Pas encore de données de récupération. Connectez un compte WHOOP, Polar ou Oura et ces scores se rempliront dès que votre appareil se synchronise.",
"block": {
@@ -3823,7 +3936,66 @@
"cta": "Enregistrer le rapport taille-hauteur"
},
"coachPrefill": "Je n'ai pas encore enregistré de rapport taille-hauteur — pourquoi est-ce un chiffre utile, et comment le maintenir sous 0,5 ?"
}
},
"ecg": {
"sectionTitle": "Enregistrements ECG",
"sectionIntro": "Tracés ECG à une seule dérivation enregistrés par votre appareil. HealthLog affiche uniquement l'enregistrement et le résultat produit par votre appareil ; il ne lit ni n'interprète le tracé.",
"loadError": "Impossible de charger vos enregistrements ECG.",
"backToList": "Retour aux enregistrements",
"disclaimer": "Il s'agit du résultat produit par votre appareil d'enregistrement. HealthLog ne lit ni n'interprète les enregistrements ECG et ne fournit aucun diagnostic.",
"clinicianNote": "Si ce résultat vous préoccupe, parlez de cet enregistrement à un professionnel de santé.",
"resultAttribution": "Résultat enregistré : {result}, tel que signalé par l'appareil d'enregistrement.",
"result": {
"irregular": "Fibrillation auriculaire détectée",
"notDetected": "Aucun signe de fibrillation auriculaire",
"inconclusive": "Enregistrement non concluant",
"none": "Aucun résultat signalé"
},
"meta": {
"recorded": "Enregistré",
"duration": "Durée",
"lead": "Dérivation",
"averageHeartRate": "Fréquence cardiaque moyenne",
"samplingRate": "Fréquence d'échantillonnage",
"leadSingle": "Une seule dérivation (dérivation I)",
"durationValue": "{seconds} s",
"bpmValue": "{bpm} bpm",
"hzValue": "{hz} Hz",
"unknown": "—"
},
"waveform": {
"ariaLabel": "Tracé ECG enregistré le {date}, durée {duration}, fréquence cardiaque moyenne {heartRate}, résultat enregistré : {result}."
},
"crossLink": {
"title": "Enregistrements ECG",
"recordingsOne": "1 enregistrement disponible.",
"recordingsMany": "{count} enregistrements disponibles.",
"latestResult": "Dernier résultat de l’appareil : {result}.",
"cta": "Voir les enregistrements"
},
"emptyTitle": "Aucun enregistrement ECG pour l'instant",
"emptyDescription": "Les enregistrements ECG se synchronisent depuis un appareil à dérivation unique compatible. Dès que votre appareil transfère un enregistrement, il apparaît ici.",
"emptyCta": "Connecter un appareil"
},
"intradayPulse": {
"title": "Le rythme de votre journée",
"caption": "Votre fréquence cardiaque au fil de la journée, moyennée par tranches de 10 minutes.",
"hourlyNote": "Moyenne horaire affichée pour cette journée — les jours récents affichent le détail complet par tranches de 10 minutes.",
"empty": "Pas encore assez de mesures pour cette journée pour en afficher le rythme.",
"coverageOne": "Basé sur 1 mesure sur {hours}h.",
"coverageMany": "Basé sur {count} mesures sur {hours}h.",
"baseline": "Repos",
"today": "Aujourd'hui",
"previousDay": "Jour précédent",
"nextDay": "Jour suivant",
"tension": {
"morning": "Une période élevée au repos ce matin — peut-être un peu de tension.",
"afternoon": "Une période élevée au repos cet après-midi — peut-être un peu de tension.",
"evening": "Une période élevée au repos ce soir — peut-être un peu de tension.",
"night": "Une période élevée au repos cette nuit — peut-être un peu de tension."
}
},
"navEcg": "ECG"
},
"thresholds": {
"defaultLabel": "Par défaut",
@@ -3922,7 +4094,7 @@
},
"wrapUp": {
"title": "C’est la fin de la visite",
"body": "Vous pouvez revoir chaque module séparément à tout moment (« Afficher cette visite » sur chaque page) ou relancer la visite complète dans Réglages → Avancé."
"body": "Cette visite guidée ne s'affiche qu'une fois — elle ne se rouvrira pas d'elle-même. Tout ce qu'elle a montré reste en place, et vous pouvez activer ou désactiver des fonctionnalités à tout moment dans Réglages → Modules."
}
}
},
@@ -3950,7 +4122,7 @@
},
"source": {
"title": "Connecter une source",
"body": "Importez les mesures automatiquement ou conservez la saisie manuelle. Rien n'est configuré à votre place : chaque carte indique simplement où la connecter.",
"body": "Importez les mesures automatiquement ou conservez la saisie manuelle. Rien n'est configuré à votre place : chaque carte indique simplement où la connecter. Les sources d'appareil nécessitent une application développeur gratuite sur votre propre compte (environ 5 minutes) ; le guide de chaque carte vous accompagne.",
"recommended": "Recommandé",
"manual": {
"title": "Saisie manuelle",
@@ -3958,12 +4130,14 @@
},
"withings": {
"title": "Withings",
"body": "Balances, tensiomètres et trackers Withings se synchronisent automatiquement.",
"cta": "Connecter Withings"
"body": "Balances, tensiomètres et trackers Withings se synchronisent automatiquement une fois connectés.",
"cta": "Connecter Withings",
"setupCta": "Configurer dans les Réglages",
"setupHint": "Nécessite d'abord votre propre application développeur Withings, gratuite — environ 5 minutes, une seule fois."
},
"more": {
"title": "Autres sources",
"body": "Connectez l'une d'elles dans les Réglages quand vous le souhaitez.",
"body": "Connectez l'une d'elles dans les Réglages quand vous le souhaitez — la plupart nécessitent d'abord une application développeur gratuite sur votre propre compte.",
"appleHealth": "Apple Health",
"whoop": "WHOOP",
"oura": "Oura",
@@ -3995,7 +4169,8 @@
"learning": "Certaines analyses — glycémie, rythme de sommeil, tendances de tension, cycle — s'affinent au cours de la première semaine ou deux à mesure que vos données s'accumulent. Des tuiles vides signifient simplement que nous apprenons encore votre référence.",
"connectCta": "Connecter un appareil",
"logCta": "Enregistrez votre première mesure",
"importCta": "Importer un historique existant"
"importCta": "Importer un historique existant",
"modulesCta": "Choisir vos modules"
},
"welcomeBack": {
"title": "Bon retour",
@@ -4105,7 +4280,16 @@
"disableError": "Impossible de désactiver le mode Recherche. Veuillez réessayer."
},
"shell": {
"sectionsNav": "Sections des réglages"
"sectionsNav": "Sections des réglages",
"groups": {
"account": "Compte",
"yourData": "Vos données",
"display": "Affichage",
"healthProfile": "Profil de santé",
"ai": "IA",
"connectivity": "Connectivité",
"system": "Système"
}
},
"sections": {
"account": {
@@ -4236,7 +4420,16 @@
"other": "Autre",
"unknown": "Inconnu"
},
"subtitle": "Choisissez la source prioritaire en cas de doublon de mesure."
"subtitle": "Choisissez la source prioritaire en cas de doublon de mesure.",
"nutrients": {
"title": "Apports en nutriments synchronisés",
"description": "Totaux quotidiens synchronisés depuis Apple Health sur les {days} derniers jours. Lecture seule : l'app source reste propriétaire des valeurs.",
"empty": "Aucune donnée de nutriments synchronisée pour l'instant.",
"latest": "{amount} {unit} le {date}",
"daysWithData": "{count} jours sur {days}",
"moduleHint": "La synchronisation est contrôlée par le module Apports en nutriments.",
"moduleHintLink": "Gérer les modules"
}
},
"ai": {
"title": "Fournisseur d'IA",
@@ -4281,7 +4474,8 @@
},
"fullBackup": {
"title": "Sauvegarde complète",
"description": "Un seul fichier JSON contenant tout — même format que la sauvegarde automatique hebdomadaire. Utile pour une restauration personnelle via le téléversement administrateur.",
"description": "Toutes vos données dans un seul fichier JSON : mesures, médicaments, humeur, cycle, analyses de laboratoire, épisodes de maladie, allergies, antécédents familiaux et entraînements. Même format que la sauvegarde automatique hebdomadaire.",
"scopeNote": "Les métadonnées des documents et le résumé IA, s'il existe, sont inclus en texte clair ; les fichiers originaux des documents ainsi que les tracés GPS/données d'échantillons des entraînements ne le sont pas — téléchargez-les séparément depuis le coffre de documents ou l'export des entraînements.",
"encryptToggle": "Chiffrer avec une phrase secrète",
"passphrasePlaceholder": "Phrase secrète (12 caractères min.)",
"passphraseHelp": "Dérive la clé de l'archive avec Argon2id et la scelle avec AES-256-GCM. Enregistrez le fichier avec l'extension .hlx.",
@@ -4441,7 +4635,7 @@
"description": "Dossiers structurés des allergies et des antécédents familiaux."
},
"environment": {
"title": "Environnement",
"title": "Localisation et météo",
"subtitle": "Contexte météo, lumière du jour et lieu.",
"disabledHint": "Activez le module Environnement pour définir un lieu de référence et intégrer la météo et la durée du jour quotidiennes.",
"searchPlaceholder": "Rechercher une ville",
@@ -4629,6 +4823,7 @@
"anthropicKeyLabel": "Clé API Anthropic",
"baseUrlLabel": "Base URL",
"localKeyLabel": "Clé API (facultative)",
"localDescription": "Tout point de terminaison compatible OpenAI fonctionne ici — un modèle local (Ollama, LM Studio) comme une passerelle telle que LiteLLM, OpenRouter ou vLLM. L'URL de base se termine par /v1 ; la clé API est facultative.",
"savedPreview": "(enregistrée {preview})",
"savedShort": "(enregistrée)",
"saveCta": "Enregistrer",
@@ -4704,6 +4899,7 @@
"testReasonCredentials": "Le fournisseur a rejeté les identifiants — réauthentifiez-vous dans les réglages IA.",
"testReasonRateLimited": "Le fournisseur a limité la requête — réessayez sous peu.",
"testReasonServerError": "Le fournisseur d’IA a renvoyé une erreur serveur.",
"testReasonBadRequest": "Le fournisseur a rejeté la requête (HTTP {status}) — le point de terminaison a répondu ; le problème vient donc du format de la requête ou du nom du modèle, pas de la connexion.",
"testReasonUnreachable": "Impossible de joindre le fournisseur d’IA.",
"coachMemory": {
"title": "Ce dont le Coach se souvient",
@@ -5312,6 +5508,12 @@
"ouraDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
"nightscoutDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
"integrationCredentialsHint": "Où trouver ces identifiants et comment enregistrer ta propre application est expliqué dans le guide de configuration ci-dessus.",
"integrationRedirectGuide": {
"intro": "Cette connexion passe par votre propre application développeur {provider} — environ 5 minutes de configuration :",
"step1": "Créez une application dans le portail développeur {provider}.",
"step2": "Réglez son URL de redirection/callback sur :",
"step3": "Collez ci-dessus l'ID client et le secret client fournis."
},
"security": {
"sessionsTitle": "Sessions actives",
"sessionsDescription": "Appareils et navigateurs actuellement connectés à votre compte.",
@@ -5952,6 +6154,7 @@
"colAction": "Action",
"colDuration": "Durée",
"colTraceId": "Trace",
"viewDetails": "Voir les détails de {action} à {timestamp}",
"showing": "Affichage de {count} sur jusqu'à {bufferMax} événements en mémoire tampon",
"empty": "Le tampon est vide",
"emptyDescription": "Aucun événement ne correspond encore aux filtres actuels. Cliquez sur Actualiser après avoir généré du trafic.",
@@ -6491,6 +6694,8 @@
"eventMeasurementReminderDesc": "Envoyé lorsqu'une mesure suivie est due.",
"eventMedicationIntakeSync": "Synchronisation des prises",
"eventMedicationIntakeSyncDesc": "Mise à jour entre appareils quand une dose est enregistrée, prise ou ignorée.",
"eventDailyBriefing": "Résumé quotidien",
"eventDailyBriefingDesc": "Un résumé matinal et posé de votre journée. Désactivé jusqu'à ce que vous l'activiez.",
"security": {
"newDeviceTitle": "Nouvelle connexion à votre compte",
"newDeviceBody": "Une nouvelle connexion depuis {device} ({location}). Si ce n'était pas vous, changez votre mot de passe immédiatement.",
@@ -6570,9 +6775,9 @@
"hypertensionGrade1": "Hypertension grade 1",
"hypertensionGrade2": "Hypertension grade 2",
"hypertensionGrade3": "Hypertension grade 3",
"bradycardia": "Bradycardie",
"belowTypicalRange": "En dessous de la plage habituelle",
"elevated": "Élevé",
"tachycardia": "Tachycardie",
"aboveTypicalRange": "Au-dessus de la plage habituelle",
"significantlyLow": "Nettement bas",
"slightlyLow": "Légèrement bas",
"onTarget": "Dans la plage cible",
@@ -7156,8 +7361,11 @@
"TOTAL_BODY_WATER": "Eau corporelle",
"VISCERAL_FAT": "Graisse viscérale",
"BODY_MASS_INDEX": "Indice de masse corporelle (IMC)",
"WAIST_CIRCUMFERENCE": "Tour de taille",
"PHQ9_SCORE": "PHQ-9 · humeur",
"GAD7_SCORE": "GAD-7 · inquiétude"
"GAD7_SCORE": "GAD-7 · inquiétude",
"WHO5_SCORE": "WHO-5 · bien-être",
"SCI_SCORE": "SCI · sommeil"
},
"typeGroups": {
"vitals": "Constantes vitales",
@@ -7275,6 +7483,7 @@
"notePlaceholder": "Contexte facultatif pour ce résultat",
"requiredError": "Saisissez une valeur.",
"save": "Enregistrer",
"saveAndAddAnother": "Enregistrer et ajouter un autre",
"savedToast": "Résultat enregistré",
"saveError": "Impossible d'enregistrer le résultat",
"biomarker": "Biomarqueur",
@@ -7566,6 +7775,10 @@
"mentalHealth": {
"label": "Bien-être mental",
"description": "Questionnaires PHQ-9 et GAD-7 facultatifs, en complément du suivi de l'humeur. Désactivé par défaut ; les réponses sont chiffrées et tenues à l'écart du Coach IA."
},
"nutrients": {
"label": "Apports en nutriments",
"description": "Enregistre les totaux quotidiens de micronutriments (vitamines, minéraux, eau, caféine) synchronisés depuis Apple Health. Désactivé par défaut."
}
},
"illness": {
@@ -7619,7 +7832,10 @@
"notePlaceholder": "Ce qui mérite d'être noté (chiffré).",
"saveError": "Impossible d'enregistrer l'épisode. Veuillez réessayer.",
"parent": "Fait partie de",
"parentNone": "Non lié"
"parentNone": "Non lié",
"resolved": "Terminée le",
"resolvedHelp": "Laissez vide tant que l'épisode est en cours.",
"markOngoing": "Toujours en cours"
},
"sheet": {
"title": "Enregistrer {date}",
@@ -7671,7 +7887,8 @@
"direction": {
"above": "au-dessus de ton habituel",
"below": "en dessous de ton habituel"
}
},
"settled": "Rien de notable ne s'est écarté de ta plage habituelle pendant cet épisode."
},
"insights": {
"title": "Ton historique de maladies",
@@ -7722,12 +7939,12 @@
"RECOVERY_SCORE": "Recovery",
"OXYGEN_SATURATION": "Saturation en oxygène",
"BODY_TEMPERATURE": "Température corporelle",
"SKIN_TEMPERATURE": "Température cutanée",
"RESPIRATORY_RATE": "Fréquence respiratoire",
"BLOOD_PRESSURE_SYS": "Pression artérielle systolique",
"BLOOD_PRESSURE_DIA": "Pression artérielle diastolique",
"PULSE": "Pulse",
"WEIGHT": "Weight"
"WEIGHT": "Weight",
"FUNCTIONAL_IMPACT": "Retentissement fonctionnel"
},
"listLoadError": "Impossible de charger vos épisodes."
},
@@ -7808,14 +8025,14 @@
"bpLowDoctor": "Deux mesures récentes avaient une systolique inférieure à {systolic} mmHg. Si tu te sens étourdi ou faible, assieds-toi ou allonge-toi et contacte ton médecin si cela persiste.",
"bpLowEmergency": "Deux mesures récentes avaient une systolique inférieure à {systolic} mmHg et tu as signalé des symptômes. Si tu te sens près de t'évanouir, confus ou as la peau froide et moite, appelle les secours immédiatement.",
"glucoseLowTitle": "Glycémie basse",
"glucoseLowDoctor": "Deux mesures récentes étaient en dessous de 70 mg/dL ({value} mg/dL). Prends des glucides à action rapide, remesure dans 15 minutes et contacte ton médecin si les baisses persistent.",
"glucoseLowEmergency": "Deux mesures récentes étaient en dessous de 70 mg/dL ({value} mg/dL) et tu as signalé des symptômes. Traite maintenant avec du sucre rapide ; si tu ne peux pas ou te sens très mal, appelle les secours.",
"glucoseLowDoctor": "Deux mesures récentes étaient en dessous de {threshold} {unit} ({value} {unit}). Prends des glucides à action rapide, remesure dans 15 minutes et contacte ton médecin si les baisses persistent.",
"glucoseLowEmergency": "Deux mesures récentes étaient en dessous de {threshold} {unit} ({value} {unit}) et tu as signalé des symptômes. Traite maintenant avec du sucre rapide ; si tu ne peux pas ou te sens très mal, appelle les secours.",
"glucoseLowSevereTitle": "Glycémie très basse",
"glucoseLowSevereDoctor": "Deux mesures récentes étaient en dessous de 54 mg/dL ({value} mg/dL). Traite immédiatement avec des glucides à action rapide, remesure dans 15 minutes et contacte ton médecin.",
"glucoseLowSevereEmergency": "Deux mesures récentes étaient en dessous de 54 mg/dL ({value} mg/dL) et tu as signalé des symptômes. Traite maintenant ; si tu ne gardes pas la nourriture ou te sens très mal, appelle les secours.",
"glucoseLowSevereDoctor": "Deux mesures récentes étaient en dessous de {threshold} {unit} ({value} {unit}). Traite immédiatement avec des glucides à action rapide, remesure dans 15 minutes et contacte ton médecin.",
"glucoseLowSevereEmergency": "Deux mesures récentes étaient en dessous de {threshold} {unit} ({value} {unit}) et tu as signalé des symptômes. Traite maintenant ; si tu ne gardes pas la nourriture ou te sens très mal, appelle les secours.",
"glucoseHighTitle": "Glycémie très élevée",
"glucoseHighDoctor": "Deux mesures récentes étaient à {value} mg/dL ou plus. Remesure, hydrate-toi et contacte ton médecin dès que possible si cela reste aussi élevé.",
"glucoseHighEmergency": "Deux mesures récentes étaient à {value} mg/dL ou plus et tu as signalé des symptômes. En cas de vomissements, de respiration rapide ou de confusion, appelle les secours immédiatement."
"glucoseHighDoctor": "Deux mesures récentes étaient à {value} {unit} ou plus. Remesure, hydrate-toi et contacte ton médecin dès que possible si cela reste aussi élevé.",
"glucoseHighEmergency": "Deux mesures récentes étaient à {value} {unit} ou plus et tu as signalé des symptômes. En cas de vomissements, de respiration rapide ou de confusion, appelle les secours immédiatement."
}
},
"version": {
@@ -7828,6 +8045,7 @@
"mentalHealth": {
"pageTitle": "Bien-être mental",
"pageDescription": "Quatre auto-évaluations validées et facultatives — PHQ-9 (humeur), GAD-7 (inquiétude), WHO-5 (bien-être) et SCI (sommeil) — en complément de ton suivi de l'humeur. Ce qui compte, c'est la tendance dans le temps, pas un chiffre isolé.",
"moodLink": "Voir ton historique d'humeur",
"attributionLabel": "Source",
"attributionShow": "Source et licence pour {instrument}",
"choosePrompt": "Choisis un bilan",
@@ -7952,6 +8170,12 @@
"nummerGegenKummer": {
"name": "Nummer gegen Kummer (pour les jeunes)"
},
"telefonSeelsorgeAt": {
"name": "Telefonseelsorge Österreich (24h/24, 7j/7, gratuit et confidentiel)"
},
"dargeboteneHand": {
"name": "Die Dargebotene Hand (24h/24, 7j/7, gratuit et confidentiel)"
},
"krisenchat": {
"name": "krisenchat (chat 24h/24, 7j/7, pour les jeunes)"
},
@@ -8205,7 +8429,9 @@
"openLabel": "Ouvrir {title}",
"attachmentBadge": "Téléchargement uniquement",
"uploading": "Téléversement…",
"dismissFailed": "Ignorer le téléversement échoué"
"dismissFailed": "Ignorer le téléversement échoué",
"processing": "Traitement en cours…",
"ready": "Prêt"
},
"error": {
"fileTooLarge": "Ce fichier dépasse la limite de {maxSize}.",
@@ -8247,6 +8473,10 @@
},
"detail": {
"title": "Détails du document",
"summary": {
"title": "Résumé",
"pending": "Le résumé est en cours de préparation."
},
"loadError": "Impossible de charger ce document.",
"saveError": "Impossible d'enregistrer la modification.",
"deleteError": "Impossible de supprimer le document.",
@@ -8261,7 +8491,8 @@
"noConditions": "Lié à aucune affection",
"previewUnavailable": "Pas d'aperçu intégré pour ce type de fichier",
"uploadedMeta": "Ajouté le {date} · {size}",
"share": "Partager"
"share": "Partager",
"labValuesLink": "Voir {count} valeurs de laboratoire"
},
"dropOverlay": {
"title": "Déposez les fichiers pour les téléverser",
@@ -8276,7 +8507,9 @@
"deleted": "{count} document(s) supprimés",
"restored": "{count} document(s) restaurés",
"partialFailure": "Impossible de mettre à jour {failed} sur {total}",
"failed": "L'action n'a pas pu être effectuée"
"failed": "L'action n'a pas pu être effectuée",
"share": "Partager",
"shareTooMany": "Un lien de partage contient au maximum {max} documents. Désélectionnez-en quelques-uns et réessayez."
},
"episodeCard": {
"title": "Documents",
@@ -8340,28 +8573,14 @@
},
"share": {
"title": "Partager ce document",
"multiTitle": "Partager ces documents",
"multiLabel": "{count} documents",
"description": "Créez un lien clinicien en lecture seule avec ce document joint. Le lien et sa phrase secrète n’apparaissent qu’une seule fois."
},
"chat": {
"title": "Discuter de ce document",
"notIndexed": "Lisez d'abord ce document avec l'IA pour pouvoir en discuter.",
"empty": "Posez une question sur ce document pour commencer.",
"placeholder": "Une question sur ce document…",
"inputLabel": "Votre question sur ce document",
"send": "Envoyer",
"thinking": "Lecture du document…",
"safety": "Les réponses décrivent ce document et ne constituent pas un avis médical.",
"close": "Fermer le chat",
"errorHistory": "Impossible de charger la conversation. Réessayez.",
"errorGeneric": "Une erreur s'est produite. Réessayez.",
"errorRateLimited": "Trop de messages. Patientez un instant et réessayez.",
"errorBudget": "La limite quotidienne d'IA est atteinte. Réessayez demain.",
"errorCredential": "Le fournisseur d'IA doit être reconnecté dans les paramètres.",
"errorConsent": "Activez le consentement à l'IA dans les paramètres pour discuter de ce document.",
"errorNetwork": "Vous semblez hors ligne. Vérifiez votre connexion et réessayez.",
"openAria": "Discuter de ce document",
"openButton": "Poser une question sur ce document",
"scopeBadge": "Discussion sur : {title}"
"openAria": "Interroger le Coach sur ce document",
"openButton": "Demander au Coach"
}
},
"selfDescription": {
@@ -8448,5 +8667,172 @@
"valuesDescription": "Toutes les valeurs enregistrées pour cette mesure.",
"backToMetric": "Retour à {metric}"
}
},
"nutrients": {
"names": {
"vitamin_a": "Vitamine A",
"thiamin": "Thiamine (B1)",
"riboflavin": "Riboflavine (B2)",
"niacin": "Niacine (B3)",
"pantothenic_acid": "Acide pantothénique (B5)",
"vitamin_b6": "Vitamine B6",
"biotin": "Biotine (B7)",
"folate": "Folates (B9)",
"vitamin_b12": "Vitamine B12",
"vitamin_c": "Vitamine C",
"vitamin_d": "Vitamine D",
"vitamin_e": "Vitamine E",
"vitamin_k": "Vitamine K",
"calcium": "Calcium",
"iron": "Fer",
"magnesium": "Magnésium",
"phosphorus": "Phosphore",
"zinc": "Zinc",
"copper": "Cuivre",
"manganese": "Manganèse",
"selenium": "Sélénium",
"chromium": "Chrome",
"molybdenum": "Molybdène",
"iodine": "Iode",
"water": "Eau",
"caffeine": "Caféine"
},
"page": {
"title": "Apports en nutriments",
"description": "Hydratation et apports en vitamines/minéraux synchronisés depuis l'appli santé de votre téléphone, plus les entrées manuelles d'eau.",
"moduleOffTitle": "Le suivi des nutriments est désactivé",
"moduleOffDescription": "Activez le module nutriments pour voir ici l'hydratation et les apports en vitamines/minéraux. Les données synchronisées feront partie du dossier que le Coach peut lire.",
"moduleOffCta": "Activer les nutriments",
"emptyTitle": "Pas encore de données nutritionnelles",
"emptyDescription": "Les données proviennent de la synchronisation de l'appli santé de votre téléphone ou d'une saisie manuelle d'eau."
},
"hydration": {
"referenceMeta": "Apport de référence {value} {unit}/jour · EFSA",
"quickAddTitle": "Ajouter de l'eau",
"quickAddCustomLabel": "Quantité personnalisée (mL)",
"quickAddCustomPlaceholder": "250",
"quickAddSubmit": "Ajouter",
"quickAddError": "Échec de l'enregistrement — réessayez.",
"editTotal": "Modifier le total du jour"
},
"caffeine": {
"ceilingMeta": "Plafond de sécurité {value} {unit}/jour · EFSA"
},
"micronutrients": {
"title": "Vitamines et minéraux",
"rowMeta": "{days} sur {window} jours · réf. {value} {unit} (EFSA)",
"rowMetaNoRef": "{days} sur {window} jours",
"footer": "{tracked} des {total} micronutriments suivis ont des données sur cette période.",
"emptyTitle": "Aucune donnée de micronutriments synchronisée pour l'instant"
},
"sparseNote": "Une valeur basse ici signifie généralement un suivi partiel, pas une carence — les totaux journaliers Apple Health reflètent ce que vos applis ont enregistré, pas l'apport alimentaire total."
},
"daily": {
"line": {
"score": "Ton score de santé aujourd'hui est de {score}.",
"allClear": "Rien ne requiert ton attention aujourd'hui."
},
"push": {
"title": "Votre résumé du matin",
"bodyProvisional": "{line} · Le sommeil de la nuit dernière n'est pas encore disponible."
},
"item": {
"doseWindow": {
"title": "Médicament à prendre",
"body": "Une dose est prévue aujourd'hui.",
"bodyNamed": "{name} est prévu aujourd'hui."
},
"preventiveCare": {
"title": "Examen à faire",
"body": "{label} est à faire.",
"bodyMany": "{count} examens sont à faire."
},
"syncIssue": {
"title": "La synchronisation demande votre attention",
"body": "{integration} ne se synchronise pas — reconnecte-le pour garder tes données à jour."
},
"coachCheckin": {
"title": "Il est temps de revoir un plan",
"body": "Cela fait environ une semaine que vous avez défini ce plan : gardez-le, ajustez-le ou laissez-le. Sans pression.",
"bodyPlan": "Il y a environ une semaine, vous vous étiez fixé : « {plan} ». Gardez-le, ajustez-le ou laissez-le, sans pression.",
"adjustPrompt": "J'aimerais ajuster mon plan de suivi.",
"adjustPromptPlan": "J'aimerais ajuster ce plan : « {plan} »"
},
"tensionWindow": {
"title": "Peut-être un peu de tension",
"body": {
"morning": "Votre fréquence cardiaque est restée au-dessus de votre valeur de repos un bon moment ce matin alors que vous étiez au calme — peut-être un peu de tension.",
"afternoon": "Votre fréquence cardiaque est restée au-dessus de votre valeur de repos un bon moment cet après-midi alors que vous étiez au calme — peut-être un peu de tension.",
"evening": "Votre fréquence cardiaque est restée au-dessus de votre valeur de repos un bon moment ce soir alors que vous étiez au calme — peut-être un peu de tension.",
"night": "Votre fréquence cardiaque est restée au-dessus de votre valeur de repos un bon moment cette nuit alors que vous étiez au calme — peut-être un peu de tension."
}
},
"ecgNewRecording": {
"title": "Nouvel enregistrement ECG",
"body": "Votre appareil a enregistré un nouvel ECG : prêt à être consulté.",
"bodyVerdict": "Votre appareil a enregistré un nouvel ECG et a signalé : {verdict}.",
"verdict": {
"irregular": "un possible rythme irrégulier",
"notDetected": "aucun rythme irrégulier",
"inconclusive": "un résultat non concluant"
}
}
},
"action": {
"logDose": "Enregistrer la dose",
"reconnect": "Reconnecter",
"viewCheckups": "Voir les examens",
"checkinKeep": "Garder",
"checkinAdjust": "Ajuster",
"checkinLetGo": "Laisser tomber",
"viewMilestone": "Voir",
"viewPulse": "Voir le pouls",
"viewEcg": "Voir l’enregistrement"
},
"today": {
"scoreLabel": "Score de santé",
"deltaVsBaseline": "{delta} par rapport à ta référence",
"deltaFlat": "Conforme à ta référence",
"sleepPending": "Le sommeil de la nuit dernière n'est pas encore arrivé — la lecture du jour est provisoire.",
"readFullBriefing": "Lire le briefing complet",
"worthALook": "À regarder",
"allClear": "Rien ne requiert ton attention aujourd'hui.",
"ringLink": "Ouvrir les détails de {metric}"
},
"milestone": {
"metric": {
"restingHeartRate": "fréquence cardiaque au repos",
"heartRateVariability": "variabilité de la fréquence cardiaque",
"respiratoryRate": "fréquence respiratoire",
"weight": "poids",
"generic": "cette valeur"
},
"record": {
"title": "Nouveau record : {metric}",
"body": "Vous avez atteint un record personnel de {metric} aujourd'hui. Noté en toute tranquillité, rien à faire."
},
"returnToBaseline": {
"title": "De retour dans votre plage : {metric}",
"body": "Votre {metric} est revenue dans votre plage habituelle. Quoi que ce fût, c'est passé."
},
"sustainedInRange": {
"week1": {
"title": "Une semaine dans la plage",
"body": "Votre {metric} reste dans votre plage habituelle depuis une semaine. Bien régulier."
},
"week2": {
"title": "Deux semaines dans la plage",
"body": "Votre {metric} reste dans votre plage habituelle depuis deux semaines. Calme et régulier."
},
"week3": {
"title": "Trois semaines dans la plage",
"body": "Trois semaines avec votre {metric} dans votre plage habituelle. Une vraie période de stabilité."
},
"week4": {
"title": "Un mois dans la plage",
"body": "Un mois entier avec votre {metric} dans votre plage habituelle. Merveilleusement stable."
}
}
}
}
}
+484 -98
View File
@@ -91,7 +91,7 @@
"colShare": "Quota",
"footerDisclaimer1": "Questo rapporto è stato generato automaticamente da dati auto-registrati ed è destinato a supportare",
"footerDisclaimer2": "il dialogo medico-paziente. Non sostituisce una diagnosi medica. Le correlazioni non implicano causalità.",
"footerSource": "Fonte: HealthLog | Fuso orario: Europe/Berlin | Creato: {timestamp}",
"footerSource": "Fonte: HealthLog | Fuso orario: {timezone} | Creato: {timestamp}",
"typeWeight": "Peso corporeo",
"typeBpSys": "Pressione arteriosa sistolica",
"typeBpDia": "Pressione arteriosa diastolica",
@@ -228,7 +228,7 @@
"logout": "Esci",
"login": "Accedi",
"notifications": "Notifiche",
"theme": "Aspetto",
"theme": "Tema",
"themeSystem": "Sistema",
"themeDark": "Scuro",
"themeLight": "Chiaro",
@@ -286,6 +286,15 @@
"passwordTooShort": "La password deve avere almeno {minLength} caratteri.",
"privacyPolicy": "Informativa sulla privacy",
"passwordBreached": "Questa password compare in violazioni di dati note. Scegline un'altra.",
"oidc": {
"signInDefault": "Accedi con SSO",
"errorDenied": "L'accesso è stato annullato.",
"errorNoEmail": "Il tuo provider di identità non ha condiviso un indirizzo email verificato.",
"errorEmailUnverified": "Il tuo provider di identità non ha contrassegnato il tuo indirizzo email come verificato.",
"errorIdentityConflict": "Questo indirizzo email appartiene a un account già collegato a un'altra identità SSO.",
"errorRegistrationDisabled": "Nessun account corrisponde a questa identità e le nuove registrazioni sono attualmente chiuse.",
"errorFailed": "L'accesso Single Sign-On non è riuscito. Riprova."
},
"mfa": {
"title": "Autenticazione a due fattori",
"totpHint": "Inserisci il codice a 6 cifre della tua app di autenticazione.",
@@ -318,37 +327,7 @@
"welcomeBackWithName": "{greeting} {name}, bentornato.",
"hero": {
"scoreLabel": "Health Score",
"ringLink": "Apri i dettagli di {metric}",
"scoreProvisional": "Dati ancora insufficienti",
"scoreComputing": "Calcolo del punteggio in corso …",
"ringDoses": "Dosi di oggi",
"ringDosesAria": "{taken} di {scheduled} dosi assunte oggi",
"ringCarousel": {
"aria": "Anelli del benessere",
"dot": "Mostra anello {index} di {total}"
},
"verdict": {
"doseOverdue": "Una dose di {name} è in ritardo.",
"doseOverdueNoName": "Una dose è in ritardo.",
"bpHigh": "L’ultima pressione era molto alta ({sys}/{dia}). Misurala di nuovo con calma.",
"doseUpcoming": "Alle {time} è prevista {name}.",
"doseUpcomingNoName": "Alle {time} è prevista una dose.",
"weightDrift": "Questa settimana il tuo peso si sta allontanando dall’intervallo obiettivo.",
"shortNights": "Le tue notti sono state corte di recente — in media {hours} h.",
"silence": "Nessuna misurazione da {days} giorni. Una voce rapida tiene aggiornato il quadro.",
"scoreDrop": "Il tuo punteggio è {points} punti sotto la settimana scorsa.",
"allQuiet": "Tutto tranquillo — oggi non c’è nulla che richieda la tua attenzione."
},
"action": {
"takeDose": "Registra ora",
"viewBp": "Vedi pressione",
"viewWeight": "Vedi peso",
"viewSleep": "Vedi sonno",
"logMeasurement": "Registra misurazione"
},
"briefing": {
"title": "Briefing di oggi"
}
"ringDoses": "Dosi di oggi"
},
"greetingSalutation": "Ciao, {name}",
"streakLabel": "giorni di fila",
@@ -401,8 +380,6 @@
"compliance7d": "Aderenza (7 giorni)",
"mood": "Umore",
"customizeTitle": "Layout della dashboard",
"heroToggleLabel": "Panoramica del giorno",
"heroToggleDescription": "Mostra saluto, focus del giorno e anelli dei punteggi in cima alla dashboard.",
"scoreRingsTitle": "Anelli dei punteggi",
"scoreRingsDescription": "Fino a tre anelli aggiuntivi accanto al punteggio di salute, ad esempio prontezza, recupero, sonno o aderenza. Trascina per riordinare; il punteggio di salute è primo per impostazione predefinita.",
"layoutReset": "Ripristina i valori predefiniti",
@@ -428,7 +405,6 @@
"recentWorkouts": {
"title": "Allenamenti recenti",
"viewAll": "Vedi tutti",
"loading": "Caricamento allenamenti…",
"empty": {
"title": "Nessun allenamento",
"cta": "Apri l'app Salute di Apple sull'iPhone per sincronizzare gli allenamenti."
@@ -610,9 +586,27 @@
"typeWho5Score": "Punteggio WHO-5",
"typeSciScore": "Punteggio SCI"
},
"metricCatalog": {
"title": "Cosa può monitorare HealthLog",
"description": "Tutti gli ambiti di metriche supportati da HealthLog, che tu abbia già dei dati oppure no — e cosa li fornirebbe.",
"statusPresent": "Monitorato",
"statusAbsent": "Non ancora monitorato",
"statusModuleOff": "Disattivato",
"loadError": "Impossibile caricare lo stato di monitoraggio.",
"connectCta": "Collega una fonte",
"viewCta": "Visualizza",
"ecg": {
"source": "Tracciati ECG a una derivazione da un orologio compatibile — oggi Withings ScanWatch — sincronizzati automaticamente."
},
"watchdog": {
"title": "Monitoraggio automatico",
"description": "HealthLog osserva anche silenziosamente in background — notifiche di ritmo irregolare e disturbi respiratori da un orologio connesso, avvisi di scostamento dalla tua base di riferimento, e variazioni degne di nota tra i tuoi due ultimi pannelli di laboratorio — mostrando una scheda solo quando qualcosa emerge. Se almeno una di queste fonti è collegata, nessuna scheda visibile è un buon segno, non una funzione mancante; se non ne è ancora collegata nessuna, semplicemente non c'è ancora nulla sotto controllo."
}
},
"mood": {
"title": "Umore",
"subtitle": "Andamento e voci dell’umore",
"mentalWellbeingLink": "Fai un check-in sul benessere mentale",
"addEntry": "Aggiungi",
"editEntry": "Modifica voce",
"moodLevel": "Umore",
@@ -1426,7 +1420,10 @@
"confirmAction": "Elimina",
"toast": "Assunzione eliminata",
"failed": "Impossibile eliminare l’assunzione."
}
},
"viewRecent90": "Ultimi 90 giorni",
"viewAll": "Storico completo",
"viewToggleLabel": "Intervallo dello storico"
},
"verlauf": {
"loading": "Caricamento cronologia …",
@@ -1814,7 +1811,8 @@
"label": "Non è la misurazione giusta?",
"placeholder": "Monitora in base a…",
"apply": "Applica",
"reset": "Ripristina predefinito"
"reset": "Ripristina predefinito",
"error": "Impossibile salvare questa modifica. Riprova."
},
"beforeAfter": {
"summary": "Ø {before} prima → Ø {after} da allora — una variazione di {delta}.",
@@ -1829,7 +1827,10 @@
"subtitle": "Ciò che coincide con le misurazioni a cui mirano i tuoi farmaci: solo associazione, mai un verdetto.",
"link": "Apri la vista Effetto",
"empty": "Ancora non ci sono dati sufficienti per collegare i tuoi farmaci alle loro misurazioni obiettivo."
}
},
"scaleAbsolute": "Assoluto",
"scalePercentSinceStart": "% dall'inizio",
"scaleToggleLabel": "Scala del grafico"
}
},
"charts": {
@@ -1978,6 +1979,61 @@
"notAvailableForCompliance": "Confronto non disponibile per l’aderenza"
},
"insights": {
"correlation": {
"direction": {
"higher": "salire",
"lower": "scendere"
},
"weekdayDirection": {
"above": "sopra",
"below": "sotto"
},
"channelFactor": "la valutazione di {factor}",
"channel": {
"WEIGHT": "il peso",
"BLOOD_GLUCOSE": "la glicemia",
"BLOOD_PRESSURE_SYS": "la pressione sistolica",
"BLOOD_PRESSURE_DIA": "la pressione diastolica",
"ACTIVITY_STEPS": "i passi giornalieri",
"SLEEP_DURATION": "la durata del sonno",
"HEART_RATE_VARIABILITY": "la variabilità della frequenza cardiaca",
"RESTING_HEART_RATE": "la frequenza cardiaca a riposo",
"TIME_IN_DAYLIGHT": "il tempo alla luce del giorno",
"MOOD": "l'umore",
"PULSE": "il polso a riposo",
"MEDICATION_COMPLIANCE": "l'aderenza ai farmaci",
"SYMPTOM_SEVERITY": "la gravità dei sintomi",
"ENV_TEMP_MEAN": "la temperatura giornaliera",
"ENV_TEMP_MIN": "la temperatura minima notturna",
"ENV_SUNSHINE": "il soleggiamento",
"ENV_DAYLIGHT": "la luce del giorno",
"ENV_PRECIP": "le precipitazioni",
"ENV_PRESSURE_MEAN": "la pressione atmosferica",
"ENV_PRESSURE_DELTA": "l'oscillazione di pressione nella giornata"
},
"daily": {
"high": "Quando {behaviour} sale, {outcome} del giorno dopo tende a {direction} nei tuoi dati — un andamento da osservare, non una causa.",
"moderate": "Quando {behaviour} sale, {outcome} del giorno dopo sembra tendere a {direction}, per quanto emerge finora — un andamento da osservare, non una causa.",
"faint": "Quando {behaviour} sale, al più si intravede un debole indizio che {outcome} del giorno dopo tenda a {direction} nei tuoi dati — troppo lieve per farci affidamento, mai una causa."
},
"lab": {
"default": "Valori di {lab} più alti si accompagnano al fatto che {outcome} tende a {direction} negli stessi periodi dei tuoi dati — un'associazione da tenere d'occhio con il tuo medico, mai una causa.",
"faint": "Valori di {lab} più alti lasciano al più intravedere che {outcome} tenda a {direction} negli stessi periodi dei tuoi dati — troppo lieve per farci affidamento, un'associazione da discutere con il tuo medico, mai una causa."
},
"hypothesis": {
"bpCompliance": {
"support": "Una maggiore aderenza ai farmaci si accompagna a valori sistolici più bassi — un andamento da osservare.",
"surprising": "L'aderenza e la sistolica si muovono insieme nei tuoi dati — sorprendente; parlane con il tuo medico prima di modificare qualcosa.",
"none": "L'aderenza e la sistolica non si muovono insieme in modo marcato in questo periodo."
},
"moodPulse": {
"support": "I giorni di umore più basso si accompagnano a un polso a riposo più alto — un andamento da osservare.",
"surprising": "I giorni di umore migliore si accompagnano a un polso a riposo più alto nei tuoi dati — sorprendente.",
"none": "L'umore e il polso a riposo non si muovono insieme in modo marcato in questo periodo."
},
"weightWeekday": "Il peso del {weekday} è {delta} kg {direction} la tua media degli altri giorni — un andamento da osservare."
}
},
"noAnalysisYet": "Ancora nessuna valutazione.",
"assessmentPreparing": "Preparazione della valutazione…",
"emptyTitle": "Dati ancora insufficienti",
@@ -2131,7 +2187,9 @@
"failedDescription": "Il provider IA ha superato il tempo limite o ha restituito un errore. Riprova oppure aumenta il timeout di risposta nelle impostazioni IA per un modello locale lento.",
"failedDescriptionTimeout": "La richiesta IA ha superato il tempo limite prima che il briefing fosse pronto. Per un modello locale lento, aumenta il timeout di risposta nelle Impostazioni e riprova.",
"failedDescriptionProvider": "Il provider IA ha rifiutato la richiesta. Controlla il provider e la chiave API nelle impostazioni IA e riprova.",
"failedDescriptionRateLimit": "Il provider IA sta limitando le richieste in questo momento. Riprova tra poco."
"failedDescriptionRateLimit": "Il provider IA sta limitando le richieste in questo momento. Riprova tra poco.",
"omittedTitle": "Briefing non mostrato",
"omittedUngroundedDescription": "Il briefing generato citava un valore che non è stato possibile verificare con i tuoi dati, quindi non è stato mostrato. Riprova."
},
"trendsRow": {
"title": "Tendenze",
@@ -2202,6 +2260,12 @@
"errorCredentialExpired": "La connessione al provider di IA è scaduta. Riconnettila nelle Impostazioni per mantenere attivo il Coach.",
"tagline": "Coach personale di salute, basato sui tuoi dati.",
"newChat": "Nuova chat",
"docScope": {
"badge": "Documento",
"chattingAbout": "Stai parlando di: {title}",
"chattingAboutMore": "Stai parlando di: {titles} e altri {count}",
"notIndexedHint": "Leggi prima questo documento con l'IA nell'archivio così il Coach può rispondere."
},
"actionsMenu": "Azioni",
"maximizeAriaLabel": "Apri a pagina intera",
"minimizeAriaLabel": "Torna al pannello",
@@ -2255,7 +2319,9 @@
"historySearchPlaceholder": "Cerca nelle conversazioni…",
"historyEmpty": "Le tue conversazioni compaiono qui. Fai una domanda per iniziare.",
"historyDeleteAria": "Elimina conversazione",
"historyDeleteConfirm": "Eliminare questa conversazione? Non si può annullare.",
"historyDeleted": "Conversazione eliminata",
"historySearchEmpty": "Nessuna conversazione corrisponde alla tua ricerca.",
"historyLoadingMore": "Caricamento…",
"sourcesTitle": "A cosa accedo",
"sourcesFresh": "Aggiornate",
"sourcesStale": "Datate",
@@ -2466,7 +2532,28 @@
"correlationWeightWeekday": "Il mio peso segue uno schema settimanale? Cosa mi dice questa correlazione per giorno della settimana?",
"recommendation": "Dimmi di più su questa raccomandazione: \"{text}\" — perché si applica a me e cosa dovrei fare?"
},
"assessmentPrompt": "Spiegami la mia valutazione di {metric}: cosa significa e cosa dovrei fare?"
"assessmentPrompt": "Spiegami la mia valutazione di {metric}: cosa significa e cosa dovrei fare?",
"attach": {
"menuChooseFromDocuments": "Scegli dai documenti",
"menuUploadNew": "Carica nuovo documento",
"trigger": "Allega documenti",
"pickerTitle": "Allega documenti",
"pickerDescription": "Il coach legge solo i documenti che alleghi, non i tuoi dati sanitari.",
"pickerSearchPlaceholder": "Cerca documenti…",
"pickerEmpty": "Ancora nessun documento nel tuo archivio.",
"pickerNotIndexed": "Non ancora indicizzato",
"pickerCap": "Puoi allegarne ancora fino a {max}.",
"pickerConfirm": "Allega ({count})",
"pillRemove": "Rimuovi {title}",
"indexing": "Indicizzazione…",
"indexingHint": "Un documento è ancora in fase di indicizzazione; potrai inviare quando è pronto.",
"fencedHint": "Questa conversazione funziona senza accesso ai tuoi dati sanitari: il coach legge solo i documenti allegati.",
"documentsAttachedOne": "1 documento allegato",
"documentsAttachedOther": "{count} documenti allegati",
"railBadge": "Chat documento isolata",
"uploadError": "Caricamento non riuscito. Riprova.",
"notFoundError": "Quel link al documento non è più valido: verrà avviata una chat normale."
}
},
"relativeJustNow": "proprio ora",
"relativeMinutesAgoOne": "{count} minuto fa",
@@ -2476,11 +2563,13 @@
"relativeDaysAgoOne": "{count} giorno fa",
"relativeDaysAgoOther": "{count} giorni fa",
"navOverview": "Panoramica",
"navCatalog": "Tutte le metriche",
"navBloodPressure": "Pressione arteriosa",
"navWeight": "Peso",
"navPulse": "Polso",
"navMood": "Umore",
"navMedication": "Farmaco",
"navNutrients": "Nutrienti",
"navBmi": "IMC",
"navSleep": "Sonno",
"navHrv": "HRV",
@@ -2505,9 +2594,9 @@
"label": "Attività",
"header": "Attività e mobilità"
},
"cardiovascularParent": {
"label": "Cardiovascolare",
"header": "Marcatori cardiovascolari"
"heartParent": {
"label": "Cuore",
"header": "Salute del cuore"
},
"hearingParent": {
"label": "Udito",
@@ -2632,11 +2721,11 @@
"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.",
"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 (WHO 2024).",
"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.",
"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 (AHA, Circulation 2021), quindi più basso è in genere meglio. La scala mostra un valore senza unità di misura (circa 1–12 è l'intervallo salutare, 13 e oltre è elevato); si tratta di una scala distinta dalle soglie in cm² ottenute per immagini che i clinici utilizzano, e non è intercambiabile con esse.",
"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",
@@ -2666,7 +2755,7 @@
"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.",
"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 (CDC 2024), 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",
@@ -2678,7 +2767,7 @@
"wristTemperatureTitle": "Temperatura del polso",
"wristTemperatureBody": "La temperatura del polso viene misurata mentre dormi ed è indicata come differenza dal tuo valore di riferimento, non come valore assoluto. 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 wearable. L'obiettivo è zero; un numero in aumento è un segnale di rischio di mobilità da segnalare a un medico, soprattutto per le persone anziane.",
"fallsBody": "Qui vengono contate le cadute forti rilevate dal tuo wearable. L'obiettivo è zero; un numero in aumento è un segnale di rischio di mobilità da segnalare a un medico, soprattutto per le persone anziane (CDC STEADI 2024).",
"sixMinuteWalkTitle": "Distanza nel cammino di 6 minuti",
"sixMinuteWalkBody": "È una stima di quanto potresti camminare in sei minuti, un classico test clinico di mobilità e resistenza cardiopolmonare. Il valore del wearable è un'approssimazione, non il test clinico supervisionato; ciò che conta è la tendenza stabile o in aumento.",
"stairAscentSpeedTitle": "Velocità in salita scale",
@@ -2726,7 +2815,8 @@
},
"valuesBack": "Torna a Insights",
"valuesBackToMetric": "Torna a {metric}",
"scoresBack": "Torna a Insights"
"scoresBack": "Torna a Insights",
"ecgDescription": "Registrazioni ECG a singola derivazione dal tuo dispositivo, ciascuna con il risultato del dispositivo. HealthLog mostra il tracciato e la registrazione, ma non interpreta l'onda."
},
"sleep": {
"title": "Sonno",
@@ -2864,7 +2954,7 @@
"sleep": {
"title": "Nessuna voce di sonno",
"description": "Quando saranno disponibili dati sul sonno (manuale o tramite Withings / Apple Health), qui apparirà la suddivisione per fase.",
"cta": "Collega Withings"
"cta": "Collega una fonte"
}
},
"navWorkouts": "Allenamenti",
@@ -2899,7 +2989,24 @@
"hrChartTitle": "Frequenza cardiaca",
"hrChartUnavailable": "I campioni di frequenza cardiaca al secondo non vengono salvati per questo allenamento.",
"notFound": "Allenamento non trovato.",
"loading": "Caricamento allenamento…"
"loading": "Caricamento allenamento…",
"hrFromPulse": "Dai tuoi dati di frequenza cardiaca intorno a questa sessione",
"hrAxisMinutes": "min",
"sportAverageLabel": "La tua media recente:",
"zonesTitle": "Zone di sforzo",
"zonesCaption": "Come si è distribuito il tuo sforzo tra le zone di frequenza cardiaca.",
"zonesCaptionDevice": "Tempo per zona riportato dal tuo dispositivo.",
"exportGpx": "Esporta GPX",
"elevationTitle": "Dislivello",
"splitsTitle": "Parziali",
"splitsKm": "km",
"splitsTime": "Tempo",
"splitsPace": "Ritmo",
"thatDayTitle": "Quel giorno",
"dayPulse": "Battito",
"daySleep": "Sonno",
"dayMood": "Umore",
"insightTitle": "Analisi dell'attività"
},
"sport": {
"walking": "Camminata",
@@ -3232,6 +3339,8 @@
},
"navWalkingSpeed": "Velocità di cammino",
"mood": {
"logLink": "Registra un umore",
"mentalWellbeingLink": "Fai un check-in sul benessere mentale",
"heatmapTitle": "Calendario dell'umore",
"heatmapNoEntry": "Nessun dato",
"legendGreat": "Ottimo",
@@ -3392,6 +3501,7 @@
"sectionSubtitle": "Segnali cardiovascolari e respiratori a riposo rispetto alla tua baseline personale",
"loadingLabel": "Caricamento dei tuoi parametri vitali",
"loadError": "Al momento non è stato possibile caricare i tuoi parametri vitali.",
"infoLabel": "Come viene calcolato questo dato?",
"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",
@@ -3518,7 +3628,9 @@
"sectionTitle": "I tuoi indicatori di salute",
"sectionSubtitle": "Valori combinati di recupero, stress e sforzo letti dai tuoi dispositivi connessi",
"loadError": "Al momento non è stato possibile caricare i tuoi punteggi di benessere.",
"recovery": "Recupero",
"loadingLabel": "Caricamento dei tuoi indicatori di salute",
"recovery": "Punteggio di recupero",
"recoveryCrossLink": "Vedi i dati di recupero e sforzo del tuo wearable",
"stress": "Stress",
"strain": "Sforzo"
},
@@ -3689,6 +3801,7 @@
"recovery": {
"title": "Recupero",
"description": "Quanto bene il tuo corpo si è ripreso: i segnali di recupero, sforzo e carico che il tuo wearable registra durante la notte.",
"scoreCrossLink": "Vedi il punteggio di recupero composito",
"emptyTitle": "Ancora nessun dato di recupero",
"empty": "Ancora nessun dato di recupero. Collega un account WHOOP, Polar o Oura e questi punteggi si popoleranno appena il dispositivo si sincronizza.",
"block": {
@@ -3823,7 +3936,66 @@
"cta": "Registra il rapporto vita-altezza"
},
"coachPrefill": "Non ho ancora registrato un rapporto vita-altezza — perché è un numero utile e come lo tengo sotto 0,5?"
}
},
"ecg": {
"sectionTitle": "Registrazioni ECG",
"sectionIntro": "Tracciati ECG a derivazione singola registrati dal tuo dispositivo. HealthLog mostra solo la registrazione e il risultato prodotto dal dispositivo; non legge né interpreta il tracciato.",
"loadError": "Impossibile caricare le tue registrazioni ECG.",
"backToList": "Torna alle registrazioni",
"disclaimer": "Questo è il risultato prodotto dal tuo dispositivo di registrazione. HealthLog non legge né interpreta le registrazioni ECG e non fornisce una diagnosi.",
"clinicianNote": "Se questo risultato ti preoccupa, parla di questa registrazione con un medico.",
"resultAttribution": "Risultato registrato: {result}, come riportato dal dispositivo di registrazione.",
"result": {
"irregular": "Fibrillazione atriale rilevata",
"notDetected": "Nessun segno di fibrillazione atriale",
"inconclusive": "Registrazione non conclusiva",
"none": "Nessun risultato riportato"
},
"meta": {
"recorded": "Registrato",
"duration": "Durata",
"lead": "Derivazione",
"averageHeartRate": "Frequenza cardiaca media",
"samplingRate": "Frequenza di campionamento",
"leadSingle": "Derivazione singola (derivazione I)",
"durationValue": "{seconds} s",
"bpmValue": "{bpm} bpm",
"hzValue": "{hz} Hz",
"unknown": "—"
},
"waveform": {
"ariaLabel": "Tracciato ECG registrato il {date}, durata {duration}, frequenza cardiaca media {heartRate}, risultato registrato: {result}."
},
"crossLink": {
"title": "Registrazioni ECG",
"recordingsOne": "1 registrazione disponibile.",
"recordingsMany": "{count} registrazioni disponibili.",
"latestResult": "Ultimo risultato del dispositivo: {result}.",
"cta": "Vedi registrazioni"
},
"emptyTitle": "Ancora nessuna registrazione ECG",
"emptyDescription": "Le registrazioni ECG si sincronizzano da un dispositivo a singola derivazione compatibile. Non appena il dispositivo carica una registrazione, comparirà qui.",
"emptyCta": "Collega un dispositivo"
},
"intradayPulse": {
"title": "L'andamento della tua giornata",
"caption": "La tua frequenza cardiaca durante il giorno, mediata in intervalli di 10 minuti.",
"hourlyNote": "Media oraria per questo giorno — i giorni recenti mostrano il dettaglio completo a 10 minuti.",
"empty": "Non ci sono ancora abbastanza misurazioni per questo giorno per mostrarne l'andamento.",
"coverageOne": "Basato su 1 misurazione in {hours}h.",
"coverageMany": "Basato su {count} misurazioni in {hours}h.",
"baseline": "Riposo",
"today": "Oggi",
"previousDay": "Giorno precedente",
"nextDay": "Giorno successivo",
"tension": {
"morning": "Un tratto elevato a riposo stamattina — forse un po' di tensione.",
"afternoon": "Un tratto elevato a riposo questo pomeriggio — forse un po' di tensione.",
"evening": "Un tratto elevato a riposo stasera — forse un po' di tensione.",
"night": "Un tratto elevato a riposo durante la notte — forse un po' di tensione."
}
},
"navEcg": "ECG"
},
"thresholds": {
"defaultLabel": "Predefinito",
@@ -3922,7 +4094,7 @@
},
"wrapUp": {
"title": "Questa è la visita guidata",
"body": "Puoi rivedere ogni modulo singolarmente in qualsiasi momento («Mostra questa visita» su ogni pagina) o riavviare l’intera visita da Impostazioni → Avanzate."
"body": "Questa visita guidata viene mostrata una sola volta: non si riaprirà da sola. Tutto ciò che hai visto resta al suo posto e puoi attivare o disattivare le funzioni quando vuoi da Impostazioni → Moduli."
}
}
},
@@ -3950,7 +4122,7 @@
},
"source": {
"title": "Connetti una sorgente",
"body": "Importa le misurazioni in automatico oppure resta sull'inserimento manuale. Qui non viene configurato nulla al posto tuo: ogni scheda mostra solo dove collegarla.",
"body": "Importa le misurazioni in automatico oppure resta sull'inserimento manuale. Qui non viene configurato nulla al posto tuo: ogni scheda mostra solo dove collegarla. Le fonti su dispositivo richiedono un'app sviluppatore gratuita sul tuo account (circa 5 minuti); la guida su ogni scheda ti accompagna.",
"recommended": "Consigliato",
"manual": {
"title": "Inserimento manuale",
@@ -3958,12 +4130,14 @@
},
"withings": {
"title": "Withings",
"body": "Bilance, misuratori di pressione e tracker Withings si sincronizzano automaticamente.",
"cta": "Connetti Withings"
"body": "Bilance, misuratori di pressione e tracker Withings si sincronizzano automaticamente una volta collegati.",
"cta": "Connetti Withings",
"setupCta": "Configura nelle Impostazioni",
"setupHint": "Richiede prima la tua app sviluppatore Withings gratuita — circa 5 minuti, una tantum."
},
"more": {
"title": "Altre fonti",
"body": "Collega una di queste nelle Impostazioni quando vuoi.",
"body": "Collega una di queste nelle Impostazioni quando vuoi — la maggior parte richiede prima un'app sviluppatore gratuita sul tuo account.",
"appleHealth": "Apple Health",
"whoop": "WHOOP",
"oura": "Oura",
@@ -3995,7 +4169,8 @@
"learning": "Alcune analisi — glicemia, ritmo del sonno, tendenze della pressione, ciclo — si affinano nella prima settimana o due man mano che i tuoi dati si accumulano. Le caselle vuote ora indicano solo che stiamo ancora imparando il tuo riferimento.",
"connectCta": "Collega un dispositivo",
"logCta": "Registra la tua prima misurazione",
"importCta": "Importa la cronologia esistente"
"importCta": "Importa la cronologia esistente",
"modulesCta": "Scegli i tuoi moduli"
},
"welcomeBack": {
"title": "Bentornato",
@@ -4105,7 +4280,16 @@
"disableError": "Impossibile disattivare la modalità Ricerca. Riprova."
},
"shell": {
"sectionsNav": "Sezioni delle impostazioni"
"sectionsNav": "Sezioni delle impostazioni",
"groups": {
"account": "Account",
"yourData": "I tuoi dati",
"display": "Visualizzazione",
"healthProfile": "Profilo sanitario",
"ai": "IA",
"connectivity": "Connettività",
"system": "Sistema"
}
},
"sections": {
"account": {
@@ -4236,7 +4420,16 @@
"other": "Altro",
"unknown": "Sconosciuto"
},
"subtitle": "Decidi quale fonte prevale quando due riportano la stessa metrica."
"subtitle": "Decidi quale fonte prevale quando due riportano la stessa metrica.",
"nutrients": {
"title": "Assunzione di nutrienti sincronizzata",
"description": "Totali giornalieri sincronizzati da Apple Health negli ultimi {days} giorni. Sola lettura: i valori appartengono all'app di origine.",
"empty": "Nessun dato sui nutrienti ancora sincronizzato.",
"latest": "{amount} {unit} il {date}",
"daysWithData": "{count} giorni su {days}",
"moduleHint": "La sincronizzazione è controllata dal modulo Assunzione di nutrienti.",
"moduleHintLink": "Gestisci moduli"
}
},
"ai": {
"title": "Provider IA",
@@ -4281,7 +4474,8 @@
},
"fullBackup": {
"title": "Backup completo",
"description": "Un unico file JSON con tutto — stesso formato del backup automatico settimanale. Utile per il ripristino autonomo tramite il caricamento da amministratore.",
"description": "Tutti i tuoi dati in un unico file JSON: misurazioni, farmaci, umore, ciclo, esami di laboratorio, episodi di malattia, allergie, anamnesi familiare e allenamenti. Stesso formato del backup automatico settimanale.",
"scopeNote": "I metadati dei documenti e il riepilogo IA, se presente, sono inclusi in chiaro; i file originali dei documenti e i dati di percorso GPS/campionamento degli allenamenti non lo sono — scaricali separatamente dall'archivio documenti o dall'esportazione degli allenamenti.",
"encryptToggle": "Cifra con una passphrase",
"passphrasePlaceholder": "Passphrase (min. 12 caratteri)",
"passphraseHelp": "Deriva la chiave dell'archivio con Argon2id e lo sigilla con AES-256-GCM. Salva il file con estensione .hlx.",
@@ -4441,7 +4635,7 @@
"description": "Record strutturati di allergie e anamnesi familiare."
},
"environment": {
"title": "Ambiente",
"title": "Posizione e meteo",
"subtitle": "Contesto meteo, luce del giorno e posizione.",
"disabledHint": "Attiva il modulo Ambiente per impostare una posizione principale e integrare il meteo e le ore di luce giornaliere.",
"searchPlaceholder": "Cerca una città",
@@ -4629,6 +4823,7 @@
"anthropicKeyLabel": "Chiave API Anthropic",
"baseUrlLabel": "Base URL",
"localKeyLabel": "Chiave API (facoltativa)",
"localDescription": "Qui funziona qualsiasi endpoint compatibile con OpenAI: un modello locale (Ollama, LM Studio) oppure un gateway come LiteLLM, OpenRouter o vLLM. L'URL di base termina con /v1; la chiave API è facoltativa.",
"savedPreview": "(salvata {preview})",
"savedShort": "(salvata)",
"saveCta": "Salva",
@@ -4704,6 +4899,7 @@
"testReasonCredentials": "Il provider ha rifiutato le credenziali — autenticati di nuovo nelle impostazioni IA.",
"testReasonRateLimited": "Il provider ha limitato la richiesta — riprova a breve.",
"testReasonServerError": "Il provider IA ha restituito un errore del server.",
"testReasonBadRequest": "Il provider ha rifiutato la richiesta (HTTP {status}): l'endpoint ha risposto, quindi il problema riguarda il formato della richiesta o il nome del modello, non la connessione.",
"testReasonUnreachable": "Impossibile raggiungere il provider IA.",
"coachMemory": {
"title": "Ciò che il Coach ricorda",
@@ -5312,6 +5508,12 @@
"ouraDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
"nightscoutDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
"integrationCredentialsHint": "Dove trovare queste credenziali e come registrare la tua app è spiegato nella guida alla configurazione qui sopra.",
"integrationRedirectGuide": {
"intro": "Questa connessione passa dalla tua app sviluppatore {provider} — circa 5 minuti di configurazione:",
"step1": "Crea un'app nel portale sviluppatori {provider}.",
"step2": "Imposta il suo URL di reindirizzamento/callback su:",
"step3": "Incolla qui sopra l'ID client e il client secret ricevuti."
},
"security": {
"sessionsTitle": "Sessioni attive",
"sessionsDescription": "Dispositivi e browser attualmente connessi al tuo account.",
@@ -5952,6 +6154,7 @@
"colAction": "Azione",
"colDuration": "Durata",
"colTraceId": "Trace",
"viewDetails": "Visualizza i dettagli di {action} alle {timestamp}",
"showing": "Visualizzati {count} di un massimo di {bufferMax} eventi nel buffer",
"empty": "Il buffer è vuoto",
"emptyDescription": "Nessun evento corrisponde ancora ai filtri attuali. Premi Aggiorna dopo aver generato un po' di traffico.",
@@ -6491,6 +6694,8 @@
"eventMeasurementReminderDesc": "Inviato quando una misurazione tracciata è in scadenza.",
"eventMedicationIntakeSync": "Sincronizzazione assunzioni",
"eventMedicationIntakeSyncDesc": "Aggiornamento tra dispositivi quando una dose viene registrata, assunta o saltata.",
"eventDailyBriefing": "Riepilogo quotidiano",
"eventDailyBriefingDesc": "Un riepilogo mattutino e tranquillo della tua giornata. Disattivato finché non lo attivi.",
"security": {
"newDeviceTitle": "Nuovo accesso al tuo account",
"newDeviceBody": "Un nuovo accesso da {device} ({location}). Se non sei stato tu, cambia subito la password.",
@@ -6570,9 +6775,9 @@
"hypertensionGrade1": "Ipertensione grado 1",
"hypertensionGrade2": "Ipertensione grado 2",
"hypertensionGrade3": "Ipertensione grado 3",
"bradycardia": "Bradicardia",
"belowTypicalRange": "Sotto il range tipico",
"elevated": "Elevato",
"tachycardia": "Tachicardia",
"aboveTypicalRange": "Sopra il range tipico",
"significantlyLow": "Significativamente basso",
"slightlyLow": "Lievemente basso",
"onTarget": "Nel range obiettivo",
@@ -7156,8 +7361,11 @@
"TOTAL_BODY_WATER": "Acqua corporea",
"VISCERAL_FAT": "Grasso viscerale",
"BODY_MASS_INDEX": "Indice di massa corporea (IMC)",
"WAIST_CIRCUMFERENCE": "Circonferenza vita",
"PHQ9_SCORE": "PHQ-9 · umore",
"GAD7_SCORE": "GAD-7 · preoccupazione"
"GAD7_SCORE": "GAD-7 · preoccupazione",
"WHO5_SCORE": "WHO-5 · benessere",
"SCI_SCORE": "SCI · sonno"
},
"typeGroups": {
"vitals": "Parametri vitali",
@@ -7275,6 +7483,7 @@
"notePlaceholder": "Contesto facoltativo per questo risultato",
"requiredError": "Inserisci un valore.",
"save": "Salva",
"saveAndAddAnother": "Salva e aggiungi un altro",
"savedToast": "Risultato salvato",
"saveError": "Impossibile salvare il risultato",
"biomarker": "Biomarcatore",
@@ -7566,6 +7775,10 @@
"mentalHealth": {
"label": "Benessere mentale",
"description": "Questionari PHQ-9 e GAD-7 facoltativi, accanto al diario dell'umore. Disattivato per impostazione predefinita; le risposte sono cifrate e tenute fuori dal Coach IA."
},
"nutrients": {
"label": "Assunzione di nutrienti",
"description": "Salva i totali giornalieri di micronutrienti (vitamine, minerali, acqua, caffeina) sincronizzati da Apple Health. Disattivato per impostazione predefinita."
}
},
"illness": {
@@ -7619,7 +7832,10 @@
"notePlaceholder": "Qualcosa da ricordare (cifrato).",
"saveError": "Impossibile salvare l'episodio. Riprova.",
"parent": "Parte di",
"parentNone": "Non collegato"
"parentNone": "Non collegato",
"resolved": "Conclusa il",
"resolvedHelp": "Lascia vuoto finché l'episodio è ancora in corso.",
"markOngoing": "Ancora in corso"
},
"sheet": {
"title": "Registra {date}",
@@ -7671,7 +7887,8 @@
"direction": {
"above": "sopra il tuo solito",
"below": "sotto il tuo solito"
}
},
"settled": "Durante questo episodio nulla si è discostato in modo evidente dal tuo intervallo abituale."
},
"insights": {
"title": "La tua storia di malattie",
@@ -7722,12 +7939,12 @@
"RECOVERY_SCORE": "Recovery",
"OXYGEN_SATURATION": "Saturazione di ossigeno",
"BODY_TEMPERATURE": "Temperatura corporea",
"SKIN_TEMPERATURE": "Temperatura cutanea",
"RESPIRATORY_RATE": "Frequenza respiratoria",
"BLOOD_PRESSURE_SYS": "Pressione arteriosa sistolica",
"BLOOD_PRESSURE_DIA": "Pressione arteriosa diastolica",
"PULSE": "Pulse",
"WEIGHT": "Weight"
"WEIGHT": "Weight",
"FUNCTIONAL_IMPACT": "Impatto funzionale"
},
"listLoadError": "Impossibile caricare gli episodi."
},
@@ -7808,14 +8025,14 @@
"bpLowDoctor": "Due misurazioni recenti avevano una sistolica sotto {systolic} mmHg. Se ti senti stordito o debole, siediti o sdraiati e contatta il medico se continua.",
"bpLowEmergency": "Due misurazioni recenti avevano una sistolica sotto {systolic} mmHg e hai segnalato sintomi. Se senti che stai per svenire, sei confuso o hai la pelle fredda e sudata, chiama subito i soccorsi.",
"glucoseLowTitle": "Glicemia bassa",
"glucoseLowDoctor": "Due misurazioni recenti erano sotto 70 mg/dL ({value} mg/dL). Assumi carboidrati ad azione rapida, rimisura tra 15 minuti e contatta il medico se i cali continuano.",
"glucoseLowEmergency": "Due misurazioni recenti erano sotto 70 mg/dL ({value} mg/dL) e hai segnalato sintomi. Tratta subito con zucchero rapido; se non puoi o ti senti molto male, chiama i soccorsi.",
"glucoseLowDoctor": "Due misurazioni recenti erano sotto {threshold} {unit} ({value} {unit}). Assumi carboidrati ad azione rapida, rimisura tra 15 minuti e contatta il medico se i cali continuano.",
"glucoseLowEmergency": "Due misurazioni recenti erano sotto {threshold} {unit} ({value} {unit}) e hai segnalato sintomi. Tratta subito con zucchero rapido; se non puoi o ti senti molto male, chiama i soccorsi.",
"glucoseLowSevereTitle": "Glicemia molto bassa",
"glucoseLowSevereDoctor": "Due misurazioni recenti erano sotto 54 mg/dL ({value} mg/dL). Tratta subito con carboidrati ad azione rapida, rimisura tra 15 minuti e contatta il medico.",
"glucoseLowSevereEmergency": "Due misurazioni recenti erano sotto 54 mg/dL ({value} mg/dL) e hai segnalato sintomi. Tratta subito; se non trattieni il cibo o ti senti molto male, chiama i soccorsi.",
"glucoseLowSevereDoctor": "Due misurazioni recenti erano sotto {threshold} {unit} ({value} {unit}). Tratta subito con carboidrati ad azione rapida, rimisura tra 15 minuti e contatta il medico.",
"glucoseLowSevereEmergency": "Due misurazioni recenti erano sotto {threshold} {unit} ({value} {unit}) e hai segnalato sintomi. Tratta subito; se non trattieni il cibo o ti senti molto male, chiama i soccorsi.",
"glucoseHighTitle": "Glicemia molto alta",
"glucoseHighDoctor": "Due misurazioni recenti erano di {value} mg/dL o superiori. Rimisura, idratati e contatta il medico al più presto se resta così alta.",
"glucoseHighEmergency": "Due misurazioni recenti erano di {value} mg/dL o superiori e hai segnalato sintomi. In caso di vomito, respiro affannoso o confusione, chiama subito i soccorsi."
"glucoseHighDoctor": "Due misurazioni recenti erano di {value} {unit} o superiori. Rimisura, idratati e contatta il medico al più presto se resta così alta.",
"glucoseHighEmergency": "Due misurazioni recenti erano di {value} {unit} o superiori e hai segnalato sintomi. In caso di vomito, respiro affannoso o confusione, chiama subito i soccorsi."
}
},
"version": {
@@ -7828,6 +8045,7 @@
"mentalHealth": {
"pageTitle": "Benessere mentale",
"pageDescription": "Quattro autovalutazioni validate e facoltative — PHQ-9 (umore), GAD-7 (preoccupazione), WHO-5 (benessere) e SCI (sonno) — accanto al tuo diario dell'umore. Ciò che conta è l'andamento nel tempo, non un singolo numero.",
"moodLink": "Vedi il tuo andamento dell'umore",
"attributionLabel": "Fonte",
"attributionShow": "Fonte e licenza di {instrument}",
"choosePrompt": "Scegli un check-in",
@@ -7952,6 +8170,12 @@
"nummerGegenKummer": {
"name": "Nummer gegen Kummer (per i giovani)"
},
"telefonSeelsorgeAt": {
"name": "Telefonseelsorge Österreich (24/7, gratuito e riservato)"
},
"dargeboteneHand": {
"name": "Die Dargebotene Hand (24/7, gratuito e riservato)"
},
"krisenchat": {
"name": "krisenchat (chat 24/7, per i giovani)"
},
@@ -8205,7 +8429,9 @@
"openLabel": "Apri {title}",
"attachmentBadge": "Solo download",
"uploading": "Caricamento…",
"dismissFailed": "Ignora caricamento non riuscito"
"dismissFailed": "Ignora caricamento non riuscito",
"processing": "Elaborazione…",
"ready": "Pronto"
},
"error": {
"fileTooLarge": "Questo file supera il limite di {maxSize}.",
@@ -8247,6 +8473,10 @@
},
"detail": {
"title": "Dettagli del documento",
"summary": {
"title": "Riepilogo",
"pending": "Il riepilogo è in preparazione."
},
"loadError": "Impossibile caricare questo documento.",
"saveError": "Impossibile salvare la modifica.",
"deleteError": "Impossibile eliminare il documento.",
@@ -8261,7 +8491,8 @@
"noConditions": "Non collegato a una patologia",
"previewUnavailable": "Nessuna anteprima integrata per questo tipo di file",
"uploadedMeta": "Aggiunto il {date} · {size}",
"share": "Condividi"
"share": "Condividi",
"labValuesLink": "Vedi {count} valori di laboratorio"
},
"dropOverlay": {
"title": "Rilascia i file per caricarli",
@@ -8276,7 +8507,9 @@
"deleted": "{count} documenti eliminati",
"restored": "{count} documenti ripristinati",
"partialFailure": "Impossibile aggiornare {failed} su {total}",
"failed": "Impossibile completare l'azione"
"failed": "Impossibile completare l'azione",
"share": "Condividi",
"shareTooMany": "Un link di condivisione contiene al massimo {max} documenti. Deseleziona alcuni documenti e riprova."
},
"episodeCard": {
"title": "Documenti",
@@ -8340,28 +8573,14 @@
},
"share": {
"title": "Condividi questo documento",
"multiTitle": "Condividi questi documenti",
"multiLabel": "{count} documenti",
"description": "Crea un link di sola lettura per il medico con questo documento allegato. Il link e la relativa passphrase compaiono una sola volta."
},
"chat": {
"title": "Chatta su questo documento",
"notIndexed": "Leggi prima questo documento con l'IA per poterne parlare.",
"empty": "Fai una domanda su questo documento per iniziare.",
"placeholder": "Una domanda su questo documento…",
"inputLabel": "La tua domanda su questo documento",
"send": "Invia",
"thinking": "Lettura del documento…",
"safety": "Le risposte descrivono questo documento e non sono un consiglio medico.",
"close": "Chiudi chat",
"errorHistory": "Impossibile caricare la conversazione. Riprova.",
"errorGeneric": "Qualcosa è andato storto. Riprova.",
"errorRateLimited": "Troppi messaggi. Attendi un momento e riprova.",
"errorBudget": "Limite giornaliero di IA raggiunto. Riprova domani.",
"errorCredential": "Il provider IA va ricollegato nelle impostazioni.",
"errorConsent": "Attiva il consenso all'IA nelle impostazioni per chattare su questo documento.",
"errorNetwork": "Sembri offline. Controlla la connessione e riprova.",
"openAria": "Chatta su questo documento",
"openButton": "Fai una domanda su questo documento",
"scopeBadge": "Stai chattando su: {title}"
"openAria": "Chiedi al Coach su questo documento",
"openButton": "Chiedi al Coach"
}
},
"selfDescription": {
@@ -8448,5 +8667,172 @@
"valuesDescription": "Tutti i valori registrati per questa metrica.",
"backToMetric": "Torna a {metric}"
}
},
"nutrients": {
"names": {
"vitamin_a": "Vitamina A",
"thiamin": "Tiamina (B1)",
"riboflavin": "Riboflavina (B2)",
"niacin": "Niacina (B3)",
"pantothenic_acid": "Acido pantotenico (B5)",
"vitamin_b6": "Vitamina B6",
"biotin": "Biotina (B7)",
"folate": "Folati (B9)",
"vitamin_b12": "Vitamina B12",
"vitamin_c": "Vitamina C",
"vitamin_d": "Vitamina D",
"vitamin_e": "Vitamina E",
"vitamin_k": "Vitamina K",
"calcium": "Calcio",
"iron": "Ferro",
"magnesium": "Magnesio",
"phosphorus": "Fosforo",
"zinc": "Zinco",
"copper": "Rame",
"manganese": "Manganese",
"selenium": "Selenio",
"chromium": "Cromo",
"molybdenum": "Molibdeno",
"iodine": "Iodio",
"water": "Acqua",
"caffeine": "Caffeina"
},
"page": {
"title": "Apporto di nutrienti",
"description": "Idratazione e apporto di vitamine/minerali sincronizzati dall'app salute del telefono, più le voci di acqua inserite manualmente.",
"moduleOffTitle": "L'apporto di nutrienti è disattivato",
"moduleOffDescription": "Attiva il modulo nutrienti per vedere qui idratazione e apporto di vitamine/minerali. I dati sincronizzati diventano parte dei dati che il Coach può leggere.",
"moduleOffCta": "Attiva nutrienti",
"emptyTitle": "Ancora nessun dato sui nutrienti",
"emptyDescription": "I dati arrivano dalla sincronizzazione dell'app salute del telefono o da una voce di acqua inserita manualmente."
},
"hydration": {
"referenceMeta": "Apporto di riferimento {value} {unit}/giorno · EFSA",
"quickAddTitle": "Aggiungi acqua",
"quickAddCustomLabel": "Quantità personalizzata (mL)",
"quickAddCustomPlaceholder": "250",
"quickAddSubmit": "Aggiungi",
"quickAddError": "Salvataggio non riuscito. Riprova.",
"editTotal": "Modifica il totale di oggi"
},
"caffeine": {
"ceilingMeta": "Limite di sicurezza {value} {unit}/giorno · EFSA"
},
"micronutrients": {
"title": "Vitamine e minerali",
"rowMeta": "{days} di {window} giorni · rif. {value} {unit} (EFSA)",
"rowMetaNoRef": "{days} di {window} giorni",
"footer": "{tracked} di {total} micronutrienti monitorati hanno dati in questo periodo.",
"emptyTitle": "Nessun dato sui micronutrienti ancora sincronizzato"
},
"sparseNote": "Un valore basso qui di solito indica una registrazione poco frequente, non una carenza — i totali giornalieri di Apple Health riflettono ciò che le tue app hanno registrato, non l'apporto alimentare totale."
},
"daily": {
"line": {
"score": "Il tuo punteggio di salute oggi è {score}.",
"allClear": "Oggi nulla richiede la tua attenzione."
},
"push": {
"title": "Il tuo riepilogo mattutino",
"bodyProvisional": "{line} · Il sonno della scorsa notte non è ancora disponibile."
},
"item": {
"doseWindow": {
"title": "Farmaco da assumere",
"body": "Oggi è prevista una dose.",
"bodyNamed": "{name} è previsto oggi."
},
"preventiveCare": {
"title": "Controllo da fare",
"body": "{label} è da fare.",
"bodyMany": "Ci sono {count} controlli da fare."
},
"syncIssue": {
"title": "La sincronizzazione richiede attenzione",
"body": "{integration} non si sincronizza — riconnettilo per mantenere aggiornati i tuoi dati."
},
"coachCheckin": {
"title": "È il momento di rivedere un piano",
"body": "È passata circa una settimana da quando hai definito questo piano: mantienilo, modificalo o lascialo andare. Senza pressioni.",
"bodyPlan": "Circa una settimana fa ti eri proposto: «{plan}». Mantienilo, modificalo o lascialo andare, senza pressioni.",
"adjustPrompt": "Vorrei modificare il mio piano di controllo.",
"adjustPromptPlan": "Vorrei modificare questo piano: «{plan}»"
},
"tensionWindow": {
"title": "Forse un po' di tensione oggi",
"body": {
"morning": "La tua frequenza cardiaca è rimasta sopra il valore a riposo per un tratto prolungato stamattina mentre eri fermo — forse un po' di tensione.",
"afternoon": "La tua frequenza cardiaca è rimasta sopra il valore a riposo per un tratto prolungato questo pomeriggio mentre eri fermo — forse un po' di tensione.",
"evening": "La tua frequenza cardiaca è rimasta sopra il valore a riposo per un tratto prolungato stasera mentre eri fermo — forse un po' di tensione.",
"night": "La tua frequenza cardiaca è rimasta sopra il valore a riposo per un tratto prolungato durante la notte mentre eri fermo — forse un po' di tensione."
}
},
"ecgNewRecording": {
"title": "Nuova registrazione ECG",
"body": "Il tuo dispositivo ha registrato un nuovo ECG: è pronto da vedere.",
"bodyVerdict": "Il tuo dispositivo ha registrato un nuovo ECG e ha segnalato: {verdict}.",
"verdict": {
"irregular": "un possibile ritmo irregolare",
"notDetected": "nessun ritmo irregolare",
"inconclusive": "un risultato non conclusivo"
}
}
},
"action": {
"logDose": "Registra dose",
"reconnect": "Riconnetti",
"viewCheckups": "Vedi controlli",
"checkinKeep": "Mantieni",
"checkinAdjust": "Modifica",
"checkinLetGo": "Lascia andare",
"viewMilestone": "Vedi",
"viewPulse": "Vedi battito",
"viewEcg": "Vedi registrazione"
},
"today": {
"scoreLabel": "Punteggio di salute",
"deltaVsBaseline": "{delta} rispetto al tuo valore di riferimento",
"deltaFlat": "In linea con il tuo valore di riferimento",
"sleepPending": "Il sonno di stanotte non è ancora arrivato: la lettura di oggi è provvisoria.",
"readFullBriefing": "Leggi il briefing completo",
"worthALook": "Da tenere d'occhio",
"allClear": "Oggi nulla richiede la tua attenzione.",
"ringLink": "Apri i dettagli di {metric}"
},
"milestone": {
"metric": {
"restingHeartRate": "frequenza cardiaca a riposo",
"heartRateVariability": "variabilità della frequenza cardiaca",
"respiratoryRate": "frequenza respiratoria",
"weight": "peso",
"generic": "questo valore"
},
"record": {
"title": "Nuovo record: {metric}",
"body": "Oggi hai raggiunto il tuo record personale di {metric}. Annotato con calma, senza nulla da fare."
},
"returnToBaseline": {
"title": "Di nuovo nel tuo intervallo: {metric}",
"body": "La tua {metric} è tornata nel tuo intervallo abituale. Qualunque cosa fosse, è passata."
},
"sustainedInRange": {
"week1": {
"title": "Una settimana nell'intervallo",
"body": "La tua {metric} resta nel tuo intervallo abituale da una settimana. Bella costante."
},
"week2": {
"title": "Due settimane nell'intervallo",
"body": "La tua {metric} resta nel tuo intervallo abituale da due settimane. Calma e costante."
},
"week3": {
"title": "Tre settimane nell'intervallo",
"body": "Tre settimane con la tua {metric} nel tuo intervallo abituale. Un bel tratto di stabilità."
},
"week4": {
"title": "Un mese nell'intervallo",
"body": "Un mese intero con la tua {metric} nel tuo intervallo abituale. Meravigliosamente costante."
}
}
}
}
}
+484 -98
View File
@@ -91,7 +91,7 @@
"colShare": "Udział",
"footerDisclaimer1": "Ten raport został wygenerowany automatycznie z danych samodzielnie rejestrowanych i ma na celu wspierać",
"footerDisclaimer2": "rozmowę lekarza z pacjentem. Nie zastępuje diagnozy medycznej. Korelacje nie oznaczają związku przyczynowego.",
"footerSource": "Źródło: HealthLog | Strefa czasowa: Europe/Berlin | Utworzono: {timestamp}",
"footerSource": "Źródło: HealthLog | Strefa czasowa: {timezone} | Utworzono: {timestamp}",
"typeWeight": "Masa ciała",
"typeBpSys": "Ciśnienie skurczowe",
"typeBpDia": "Ciśnienie rozkurczowe",
@@ -228,7 +228,7 @@
"logout": "Wyloguj",
"login": "Zaloguj",
"notifications": "Powiadomienia",
"theme": "Wygląd",
"theme": "Motyw",
"themeSystem": "Systemowy",
"themeDark": "Ciemny",
"themeLight": "Jasny",
@@ -286,6 +286,15 @@
"passwordTooShort": "Hasło musi mieć co najmniej {minLength} znaków.",
"privacyPolicy": "Polityka prywatności",
"passwordBreached": "To hasło pojawia się w znanych wyciekach danych. Wybierz inne.",
"oidc": {
"signInDefault": "Zaloguj się przez SSO",
"errorDenied": "Logowanie zostało anulowane.",
"errorNoEmail": "Twój dostawca tożsamości nie przekazał zweryfikowanego adresu e-mail.",
"errorEmailUnverified": "Twój dostawca tożsamości nie oznaczył Twojego adresu e-mail jako zweryfikowanego.",
"errorIdentityConflict": "Ten adres e-mail należy do konta powiązanego już z inną tożsamością SSO.",
"errorRegistrationDisabled": "Żadne konto nie pasuje do tej tożsamości, a nowe rejestracje są obecnie zamknięte.",
"errorFailed": "Logowanie jednokrotne nie powiodło się. Spróbuj ponownie."
},
"mfa": {
"title": "Uwierzytelnianie dwuskładnikowe",
"totpHint": "Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej.",
@@ -318,37 +327,7 @@
"welcomeBackWithName": "{greeting} {name}, witaj ponownie.",
"hero": {
"scoreLabel": "Health Score",
"ringLink": "Otwórz szczegóły: {metric}",
"scoreProvisional": "Za mało danych",
"scoreComputing": "Trwa obliczanie wyniku …",
"ringDoses": "Dzisiejsze dawki",
"ringDosesAria": "Przyjęto {taken} z {scheduled} dawek dzisiaj",
"ringCarousel": {
"aria": "Pierścienie dobrostanu",
"dot": "Pokaż pierścień {index} z {total}"
},
"verdict": {
"doseOverdue": "Dawka {name} jest zaległa.",
"doseOverdueNoName": "Dawka jest zaległa.",
"bpHigh": "Twoje ostatnie ciśnienie było bardzo wysokie ({sys}/{dia}). Zmierz je ponownie w spokoju.",
"doseUpcoming": "O {time} pora na {name}.",
"doseUpcomingNoName": "O {time} pora na dawkę.",
"weightDrift": "Twoja waga oddala się w tym tygodniu od zakresu docelowego.",
"shortNights": "Twoje noce były ostatnio krótkie — średnio {hours} h.",
"silence": "Brak pomiarów od {days} dni. Szybki wpis utrzyma aktualny obraz.",
"scoreDrop": "Twój wynik jest o {points} punktów niższy niż w zeszłym tygodniu.",
"allQuiet": "Spokojnie — dziś nic nie wymaga Twojej uwagi."
},
"action": {
"takeDose": "Zapisz teraz",
"viewBp": "Zobacz ciśnienie",
"viewWeight": "Zobacz wagę",
"viewSleep": "Zobacz sen",
"logMeasurement": "Zapisz pomiar"
},
"briefing": {
"title": "Briefing dnia"
}
"ringDoses": "Dzisiejsze dawki"
},
"greetingSalutation": "Cześć, {name}",
"streakLabel": "dni z rzędu",
@@ -401,8 +380,6 @@
"compliance7d": "Przestrzeganie (7 dni)",
"mood": "Nastrój",
"customizeTitle": "Układ panelu",
"heroToggleLabel": "Przegląd dnia",
"heroToggleDescription": "Pokazuje powitanie, fokus dnia i pierścienie wyników na górze pulpitu.",
"scoreRingsTitle": "Pierścienie wyników",
"scoreRingsDescription": "Maksymalnie trzy dodatkowe pierścienie obok wskaźnika zdrowia — np. gotowość, regeneracja, sen lub przestrzeganie zaleceń. Przeciągnij, aby zmienić kolejność; wskaźnik zdrowia jest domyślnie na początku.",
"layoutReset": "Przywróć wartości domyślne",
@@ -428,7 +405,6 @@
"recentWorkouts": {
"title": "Ostatnie treningi",
"viewAll": "Zobacz wszystkie",
"loading": "Wczytywanie treningów…",
"empty": {
"title": "Brak treningów",
"cta": "Otwórz aplikację Apple Zdrowie na iPhonie, aby zsynchronizować treningi."
@@ -610,9 +586,27 @@
"typeWho5Score": "Wynik WHO-5",
"typeSciScore": "Wynik SCI"
},
"metricCatalog": {
"title": "Co HealthLog potrafi śledzić",
"description": "Wszystkie obszary metryk obsługiwane przez HealthLog — również te, dla których nie masz jeszcze danych — oraz co je dostarcza.",
"statusPresent": "Śledzone",
"statusAbsent": "Jeszcze nieśledzone",
"statusModuleOff": "Wyłączone",
"loadError": "Nie udało się załadować statusu śledzenia.",
"connectCta": "Połącz źródło",
"viewCta": "Zobacz",
"ecg": {
"source": "Jednoodprowadzeniowe zapisy EKG z kompatybilnego zegarka — obecnie Withings ScanWatch — synchronizowane automatycznie."
},
"watchdog": {
"title": "Automatyczny nadzór",
"description": "HealthLog dyskretnie obserwuje też dane w tle — powiadomienia o nieregularnym rytmie i zaburzeniach oddychania z podłączonego zegarka, alerty o odchyleniu od Twojej wartości bazowej oraz zauważalne zmiany między dwoma ostatnimi panelami badań laboratoryjnych — i pokazuje kartę tylko wtedy, gdy coś zwraca uwagę. Jeśli podłączone jest co najmniej jedno z tych źródeł, brak karty to dobry znak, a nie brakująca funkcja; jeśli żadne nie jest jeszcze podłączone, po prostu nic nie jest jeszcze monitorowane."
}
},
"mood": {
"title": "Nastrój",
"subtitle": "Trend i wpisy nastroju",
"mentalWellbeingLink": "Wykonaj sprawdzian dobrostanu psychicznego",
"addEntry": "Dodaj",
"editEntry": "Edytuj wpis",
"moodLevel": "Nastrój",
@@ -1426,7 +1420,10 @@
"confirmAction": "Usuń",
"toast": "Przyjęcie usunięte",
"failed": "Nie udało się usunąć przyjęcia."
}
},
"viewRecent90": "Ostatnie 90 dni",
"viewAll": "Pełna historia",
"viewToggleLabel": "Zakres historii"
},
"verlauf": {
"loading": "Wczytywanie historii …",
@@ -1814,7 +1811,8 @@
"label": "To nie ten pomiar?",
"placeholder": "Śledź według…",
"apply": "Zastosuj",
"reset": "Przywróć domyślne"
"reset": "Przywróć domyślne",
"error": "Nie udało się zapisać tej zmiany. Spróbuj ponownie."
},
"beforeAfter": {
"summary": "Ø {before} przedtem → Ø {after} od tego czasu — zmiana o {delta}.",
@@ -1829,7 +1827,10 @@
"subtitle": "Co zbiega się w czasie z pomiarami, na które celują Twoje leki — tylko powiązanie, nigdy werdykt.",
"link": "Otwórz widok działania",
"empty": "Za mało danych, aby powiązać Twoje leki z docelowymi pomiarami."
}
},
"scaleAbsolute": "Bezwzględne",
"scalePercentSinceStart": "% od początku",
"scaleToggleLabel": "Skala wykresu"
}
},
"charts": {
@@ -1978,6 +1979,61 @@
"notAvailableForCompliance": "Porównanie niedostępne dla przestrzegania"
},
"insights": {
"correlation": {
"direction": {
"higher": "wzrostu",
"lower": "spadku"
},
"weekdayDirection": {
"above": "powyżej",
"below": "poniżej"
},
"channelFactor": "ocena czynnika {factor}",
"channel": {
"WEIGHT": "waga",
"BLOOD_GLUCOSE": "poziom cukru we krwi",
"BLOOD_PRESSURE_SYS": "ciśnienie skurczowe",
"BLOOD_PRESSURE_DIA": "ciśnienie rozkurczowe",
"ACTIVITY_STEPS": "dzienna liczba kroków",
"SLEEP_DURATION": "długość snu",
"HEART_RATE_VARIABILITY": "zmienność rytmu serca",
"RESTING_HEART_RATE": "spoczynkowe tętno",
"TIME_IN_DAYLIGHT": "czas w świetle dziennym",
"MOOD": "nastrój",
"PULSE": "tętno spoczynkowe",
"MEDICATION_COMPLIANCE": "przyjmowanie leków",
"SYMPTOM_SEVERITY": "nasilenie objawów",
"ENV_TEMP_MEAN": "temperatura dobowa",
"ENV_TEMP_MIN": "nocna temperatura minimalna",
"ENV_SUNSHINE": "nasłonecznienie",
"ENV_DAYLIGHT": "światło dzienne",
"ENV_PRECIP": "opady",
"ENV_PRESSURE_MEAN": "ciśnienie atmosferyczne",
"ENV_PRESSURE_DELTA": "dobowa zmiana ciśnienia"
},
"daily": {
"high": "Gdy {behaviour} rośnie, {outcome} następnego dnia zwykle wykazuje tendencję do {direction} w Twoich danych — wzorzec wart obserwacji, nie przyczyna.",
"moderate": "Gdy {behaviour} rośnie, {outcome} następnego dnia wydaje się wykazywać tendencję do {direction}, na podstawie dostępnych danych — wzorzec wart obserwacji, nie przyczyna.",
"faint": "Gdy {behaviour} rośnie, w Twoich danych pojawia się co najwyżej słaba wskazówka, że {outcome} następnego dnia wykazuje tendencję do {direction} — zbyt słaba, by na niej polegać, nigdy przyczyna."
},
"lab": {
"default": "Wyższe wartości {lab} idą w parze z tym, że {outcome} wykazuje tendencję do {direction} w tych samych okresach w Twoich danych — zależność, którą warto obserwować z lekarzem, nigdy przyczyna.",
"faint": "Wyższe wartości {lab} co najwyżej słabo sugerują, że {outcome} wykazuje tendencję do {direction} w tych samych okresach w Twoich danych — zbyt słabo, by na tym polegać, zależność do omówienia z lekarzem, nigdy przyczyna."
},
"hypothesis": {
"bpCompliance": {
"support": "Lepsze przyjmowanie leków idzie w parze z niższymi wartościami skurczowymi — wzorzec wart obserwacji.",
"surprising": "Przyjmowanie leków i ciśnienie skurczowe zmieniają się razem w Twoich danych — zaskakujące; skonsultuj się z lekarzem, zanim coś zmienisz.",
"none": "Przyjmowanie leków i ciśnienie skurczowe nie zmieniają się wyraźnie razem w tym okresie."
},
"moodPulse": {
"support": "Dni z gorszym nastrojem idą w parze z wyższym tętnem spoczynkowym — wzorzec wart obserwacji.",
"surprising": "Dni z lepszym nastrojem idą w parze z wyższym tętnem spoczynkowym w Twoich danych — zaskakujące.",
"none": "Nastrój i tętno spoczynkowe nie zmieniają się wyraźnie razem w tym okresie."
},
"weightWeekday": "Waga w {weekday} jest {delta} kg {direction} Twojej średniej z pozostałych dni — wzorzec wart obserwacji."
}
},
"noAnalysisYet": "Brak oceny.",
"assessmentPreparing": "Przygotowywanie oceny…",
"emptyTitle": "Jeszcze za mało danych",
@@ -2131,7 +2187,9 @@
"failedDescription": "Dostawca AI przekroczył limit czasu lub zwrócił błąd. Spróbuj ponownie albo zwiększ limit czasu odpowiedzi w ustawieniach AI dla wolnego modelu lokalnego.",
"failedDescriptionTimeout": "Żądanie AI przekroczyło limit czasu, zanim briefing był gotowy. W przypadku wolnego modelu lokalnego zwiększ limit czasu odpowiedzi w Ustawieniach i spróbuj ponownie.",
"failedDescriptionProvider": "Dostawca AI odrzucił żądanie. Sprawdź dostawcę i klucz API w ustawieniach AI, a następnie spróbuj ponownie.",
"failedDescriptionRateLimit": "Dostawca AI ogranicza teraz liczbę żądań. Spróbuj ponownie za chwilę."
"failedDescriptionRateLimit": "Dostawca AI ogranicza teraz liczbę żądań. Spróbuj ponownie za chwilę.",
"omittedTitle": "Briefing wstrzymany",
"omittedUngroundedDescription": "Wygenerowany briefing zawierał liczbę, której nie udało się zweryfikować na podstawie Twoich danych, więc nie został wyświetlony. Spróbuj ponownie."
},
"trendsRow": {
"title": "Trendy",
@@ -2202,6 +2260,12 @@
"errorCredentialExpired": "Połączenie z dostawcą AI wygasło. Połącz je ponownie w Ustawieniach, aby Coach działał dalej.",
"tagline": "Osobisty coach zdrowia, oparty na twoich danych.",
"newChat": "Nowa rozmowa",
"docScope": {
"badge": "Dokument",
"chattingAbout": "Rozmowa o: {title}",
"chattingAboutMore": "Rozmowa o: {titles} i {count} więcej",
"notIndexedHint": "Najpierw wczytaj ten dokument z pomocą AI w archiwum, aby Coach mógł na jego temat odpowiadać."
},
"actionsMenu": "Działania",
"maximizeAriaLabel": "Otwórz na pełnej stronie",
"minimizeAriaLabel": "Powrót do panelu",
@@ -2255,7 +2319,9 @@
"historySearchPlaceholder": "Szukaj w rozmowach…",
"historyEmpty": "Twoje rozmowy pojawią się tutaj. Zadaj pytanie, aby zacząć.",
"historyDeleteAria": "Usuń rozmowę",
"historyDeleteConfirm": "Usunąć tę rozmowę? Tego nie da się cofnąć.",
"historyDeleted": "Rozmowa usunięta",
"historySearchEmpty": "Żadna rozmowa nie pasuje do wyszukiwania.",
"historyLoadingMore": "Wczytywanie…",
"sourcesTitle": "Do czego mam dostęp",
"sourcesFresh": "Aktualne",
"sourcesStale": "Nieaktualne",
@@ -2466,7 +2532,28 @@
"correlationWeightWeekday": "Czy moja waga podlega cotygodniowemu wzorcowi? Co mówi mi ta korelacja według dnia tygodnia?",
"recommendation": "Powiedz mi więcej o tej rekomendacji: \"{text}\" — dlaczego mnie dotyczy i co powinienem z nią zrobić?"
},
"assessmentPrompt": "Wyjaśnij mi moją ocenę {metric} — co oznacza i co powinienem z tym zrobić?"
"assessmentPrompt": "Wyjaśnij mi moją ocenę {metric} — co oznacza i co powinienem z tym zrobić?",
"attach": {
"menuChooseFromDocuments": "Wybierz z dokumentów",
"menuUploadNew": "Prześlij nowy dokument",
"trigger": "Załącz dokumenty",
"pickerTitle": "Załącz dokumenty",
"pickerDescription": "Coach czyta tylko załączone dokumenty, a nie Twoje dane zdrowotne.",
"pickerSearchPlaceholder": "Szukaj dokumentów…",
"pickerEmpty": "Nie masz jeszcze dokumentów w archiwum.",
"pickerNotIndexed": "Jeszcze nie zindeksowano",
"pickerCap": "Możesz załączyć jeszcze do {max}.",
"pickerConfirm": "Załącz ({count})",
"pillRemove": "Usuń {title}",
"indexing": "Indeksowanie…",
"indexingHint": "Dokument jest jeszcze indeksowany – wyślesz, gdy będzie gotowy.",
"fencedHint": "Ta rozmowa działa bez dostępu do Twoich danych zdrowotnych – coach czyta tylko załączone dokumenty.",
"documentsAttachedOne": "1 załączony dokument",
"documentsAttachedOther": "Załączone dokumenty: {count}",
"railBadge": "Odizolowany czat dokumentu",
"uploadError": "Przesyłanie nie powiodło się. Spróbuj ponownie.",
"notFoundError": "Ten link do dokumentu jest już nieprawidłowy – zamiast tego rozpoczynamy zwykły czat."
}
},
"relativeJustNow": "przed chwilą",
"relativeMinutesAgoOne": "{count} minutę temu",
@@ -2476,11 +2563,13 @@
"relativeDaysAgoOne": "{count} dzień temu",
"relativeDaysAgoOther": "{count} dni temu",
"navOverview": "Przegląd",
"navCatalog": "Wszystkie metryki",
"navBloodPressure": "Ciśnienie krwi",
"navWeight": "Waga",
"navPulse": "Tętno",
"navMood": "Nastrój",
"navMedication": "Lek",
"navNutrients": "Składniki odżywcze",
"navBmi": "BMI",
"navSleep": "Sen",
"navHrv": "HRV",
@@ -2505,9 +2594,9 @@
"label": "Aktywność",
"header": "Aktywność i mobilność"
},
"cardiovascularParent": {
"label": "Sercowo-naczyniowe",
"header": "Markery sercowo-naczyniowe"
"heartParent": {
"label": "Serce",
"header": "Zdrowie serca"
},
"hearingParent": {
"label": "Słuch",
@@ -2632,11 +2721,11 @@
"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.",
"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 (WHO 2024).",
"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.",
"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 (AHA, Circulation 2021), więc im niżej, tym lepiej. Skala pokazuje wartość bezjednostkową (mniej więcej 1–12 to zakres zdrowy, 13 i więcej to wartość podwyższona); to inna skala niż progi w cm² z badań obrazowych stosowane przez klinicystów i nie należy jej traktować z nimi zamiennie.",
"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",
@@ -2666,7 +2755,7 @@
"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.",
"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 (CDC 2024), 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",
@@ -2678,7 +2767,7 @@
"wristTemperatureTitle": "Temperatura nadgarstka",
"wristTemperatureBody": "Temperatura nadgarstka jest mierzona podczas snu i podawana jako różnica względem Twojej wartości bazowej, a nie jako wartość bezwzględna. 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 urządzenie noszone. 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.",
"fallsBody": "Tutaj liczone są poważne upadki wykryte przez urządzenie noszone. 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 (CDC STEADI 2024).",
"sixMinuteWalkTitle": "Dystans marszu 6-minutowego",
"sixMinuteWalkBody": "To szacunek, jak daleko mógłbyś przejść w sześć minut — klasyczny test kliniczny mobilności i wydolności krążeniowo-oddechowej. Wartość z urządzenia noszonego to przybliżenie, a nie nadzorowany test kliniczny; liczy się stały lub rosnący trend.",
"stairAscentSpeedTitle": "Prędkość wchodzenia po schodach",
@@ -2726,7 +2815,8 @@
},
"valuesBack": "Powrót do Insights",
"valuesBackToMetric": "Powrót do {metric}",
"scoresBack": "Powrót do Insights"
"scoresBack": "Powrót do Insights",
"ecgDescription": "Zapisy EKG jednoodprowadzeniowe z Twojego urządzenia, każdy z wynikiem samego urządzenia. HealthLog pokazuje przebieg i zapis — nie interpretuje krzywej."
},
"sleep": {
"title": "Sen",
@@ -2864,7 +2954,7 @@
"sleep": {
"title": "Brak danych o śnie",
"description": "Gdy pojawią się dane o śnie (ręcznie lub przez Withings / Apple Health), wyświetli się tu rozkład faz na noc.",
"cta": "Połącz Withings"
"cta": "Połącz źródło"
}
},
"navWorkouts": "Treningi",
@@ -2899,7 +2989,24 @@
"hrChartTitle": "Tętno",
"hrChartUnavailable": "Próbki tętna co sekundę nie są zapisywane dla tego treningu.",
"notFound": "Nie znaleziono treningu.",
"loading": "Wczytywanie treningu…"
"loading": "Wczytywanie treningu…",
"hrFromPulse": "Na podstawie Twoich danych tętna z okolic tej sesji",
"hrAxisMinutes": "min",
"sportAverageLabel": "Twoja ostatnia średnia:",
"zonesTitle": "Strefy wysiłku",
"zonesCaption": "Jak Twój wysiłek rozłożył się na strefy tętna.",
"zonesCaptionDevice": "Czas w strefach według Twojego urządzenia.",
"exportGpx": "Eksportuj GPX",
"elevationTitle": "Wznios",
"splitsTitle": "Międzyczasy",
"splitsKm": "km",
"splitsTime": "Czas",
"splitsPace": "Tempo",
"thatDayTitle": "Tego dnia",
"dayPulse": "Tętno",
"daySleep": "Sen",
"dayMood": "Nastrój",
"insightTitle": "Analiza aktywności"
},
"sport": {
"walking": "Marsz",
@@ -3232,6 +3339,8 @@
},
"navWalkingSpeed": "Prędkość chodu",
"mood": {
"logLink": "Zapisz nastrój",
"mentalWellbeingLink": "Wykonaj sprawdzian dobrostanu psychicznego",
"heatmapTitle": "Kalendarz nastroju",
"heatmapNoEntry": "Brak wpisu",
"legendGreat": "Świetnie",
@@ -3392,6 +3501,7 @@
"sectionSubtitle": "Spoczynkowe sygnały sercowo-naczyniowe i oddechowe na tle Twojej własnej linii bazowej",
"loadingLabel": "Wczytywanie parametrów życiowych",
"loadError": "Nie udało się teraz wczytać Twoich parametrów życiowych.",
"infoLabel": "Jak to jest obliczane?",
"building": "Budowanie Twojego typowego zakresu — {count} z {target} dni",
"typicalRange": "Typowe dla Ciebie: {low}–{high}",
"fitnessYounger": "Kondycja jak u osoby ~{years} lat młodszej",
@@ -3518,7 +3628,9 @@
"sectionTitle": "Twoje wskaźniki zdrowia",
"sectionSubtitle": "Złożone wartości regeneracji, stresu i obciążenia odczytane z Twoich połączonych urządzeń",
"loadError": "Nie udało się teraz wczytać Twoich wyników dobrostanu.",
"recovery": "Regeneracja",
"loadingLabel": "Wczytywanie Twoich wskaźników zdrowia",
"recovery": "Wynik regeneracji",
"recoveryCrossLink": "Zobacz dane regeneracji i obciążenia z Twojego urządzenia",
"stress": "Stres",
"strain": "Obciążenie"
},
@@ -3689,6 +3801,7 @@
"recovery": {
"title": "Regeneracja",
"description": "Jak dobrze Twój organizm się zregenerował – sygnały regeneracji, obciążenia i load, które Twoje urządzenie rejestruje w nocy.",
"scoreCrossLink": "Zobacz łączny wynik regeneracji",
"emptyTitle": "Brak danych o regeneracji",
"empty": "Brak danych o regeneracji. Połącz konto WHOOP, Polar lub Oura, a te wyniki uzupełnią się po synchronizacji urządzenia.",
"block": {
@@ -3823,7 +3936,66 @@
"cta": "Zapisz stosunek talii do wzrostu"
},
"coachPrefill": "Nie zapisałem jeszcze stosunku talii do wzrostu — dlaczego to przydatna liczba i jak utrzymać ją poniżej 0,5?"
}
},
"ecg": {
"sectionTitle": "Zapisy EKG",
"sectionIntro": "Jednoodprowadzeniowe zapisy EKG zarejestrowane przez Twoje urządzenie. HealthLog pokazuje tylko zapis i wynik wygenerowany przez urządzenie — nie odczytuje ani nie interpretuje przebiegu.",
"loadError": "Nie udało się wczytać Twoich zapisów EKG.",
"backToList": "Powrót do zapisów",
"disclaimer": "To jest wynik wygenerowany przez Twoje urządzenie rejestrujące. HealthLog nie odczytuje ani nie interpretuje zapisów EKG i nie stawia diagnozy.",
"clinicianNote": "Jeśli ten wynik Cię niepokoi, omów ten zapis z lekarzem.",
"resultAttribution": "Zarejestrowany wynik: {result}, zgodnie z informacją z urządzenia rejestrującego.",
"result": {
"irregular": "Wykryto migotanie przedsionków",
"notDetected": "Brak oznak migotania przedsionków",
"inconclusive": "Zapis niejednoznaczny",
"none": "Nie zgłoszono wyniku"
},
"meta": {
"recorded": "Zarejestrowano",
"duration": "Czas trwania",
"lead": "Odprowadzenie",
"averageHeartRate": "Średnie tętno",
"samplingRate": "Częstotliwość próbkowania",
"leadSingle": "Jedno odprowadzenie (odprowadzenie I)",
"durationValue": "{seconds} s",
"bpmValue": "{bpm} bpm",
"hzValue": "{hz} Hz",
"unknown": "—"
},
"waveform": {
"ariaLabel": "Zapis EKG zarejestrowany {date}, czas trwania {duration}, średnie tętno {heartRate}, zarejestrowany wynik: {result}."
},
"crossLink": {
"title": "Zapisy EKG",
"recordingsOne": "1 zapis w kartotece.",
"recordingsMany": "Zapisy w kartotece: {count}.",
"latestResult": "Ostatni wynik urządzenia: {result}.",
"cta": "Zobacz zapisy"
},
"emptyTitle": "Brak zapisów EKG",
"emptyDescription": "Zapisy EKG synchronizują się z kompatybilnego urządzenia jednoodprowadzeniowego. Gdy urządzenie prześle zapis, pojawi się tutaj.",
"emptyCta": "Połącz urządzenie"
},
"intradayPulse": {
"title": "Przebieg Twojego dnia",
"caption": "Twoje tętno w ciągu dnia, uśrednione w 10-minutowych odcinkach.",
"hourlyNote": "Pokazano średnią godzinową dla tego dnia — ostatnie dni pokazują pełny, 10-minutowy szczegół.",
"empty": "Za mało pomiarów z tego dnia, aby pokazać jego przebieg.",
"coverageOne": "Na podstawie 1 pomiaru w ciągu {hours}h.",
"coverageMany": "Na podstawie {count} pomiarów w ciągu {hours}h.",
"baseline": "Spoczynek",
"today": "Dziś",
"previousDay": "Poprzedni dzień",
"nextDay": "Następny dzień",
"tension": {
"morning": "Utrzymujący się podwyższony spoczynek rano — możliwe napięcie.",
"afternoon": "Utrzymujący się podwyższony spoczynek po południu — możliwe napięcie.",
"evening": "Utrzymujący się podwyższony spoczynek wieczorem — możliwe napięcie.",
"night": "Utrzymujący się podwyższony spoczynek w nocy — możliwe napięcie."
}
},
"navEcg": "EKG"
},
"thresholds": {
"defaultLabel": "Domyślne",
@@ -3922,7 +4094,7 @@
},
"wrapUp": {
"title": "To koniec przewodnika",
"body": "Każdy moduł możesz obejrzeć osobno w dowolnej chwili („Pokaż ten przewodnik” na każdej stronie) lub uruchomić cały przewodnik ponownie w Ustawienia → Zaawansowane."
"body": "Ten przewodnik pokazuje się tylko raz — nie otworzy się ponownie sam. Wszystko, co pokazał, zostaje na miejscu, a funkcje możesz włączać i wyłączać w dowolnym momencie w Ustawienia → Moduły."
}
}
},
@@ -3950,7 +4122,7 @@
},
"source": {
"title": "Połącz źródło",
"body": "Pobieraj pomiary automatycznie lub zostań przy wpisywaniu ręcznym. Nic nie jest tu konfigurowane za Ciebie — każda karta pokazuje tylko, gdzie to połączyć.",
"body": "Pobieraj pomiary automatycznie lub zostań przy wpisywaniu ręcznym. Nic nie jest tu konfigurowane za Ciebie — każda karta pokazuje tylko, gdzie to połączyć. Źródła sprzętowe wymagają darmowej aplikacji deweloperskiej na Twoim własnym koncie (około 5 minut); przewodnik na każdej karcie prowadzi krok po kroku.",
"recommended": "Polecane",
"manual": {
"title": "Wpis ręczny",
@@ -3958,12 +4130,14 @@
},
"withings": {
"title": "Withings",
"body": "Wagi, ciśnieniomierze i trackery Withings synchronizują się automatycznie.",
"cta": "Połącz Withings"
"body": "Wagi, ciśnieniomierze i trackery Withings synchronizują się automatycznie po połączeniu.",
"cta": "Połącz Withings",
"setupCta": "Skonfiguruj w Ustawieniach",
"setupHint": "Najpierw wymaga własnej, darmowej aplikacji deweloperskiej Withings — około 5 minut, jednorazowo."
},
"more": {
"title": "Więcej źródeł",
"body": "Połącz dowolne z nich w Ustawieniach, gdy zechcesz.",
"body": "Połącz dowolne z nich w Ustawieniach, gdy zechcesz — większość wymaga najpierw darmowej aplikacji deweloperskiej na Twoim własnym koncie.",
"appleHealth": "Apple Health",
"whoop": "WHOOP",
"oura": "Oura",
@@ -3995,7 +4169,8 @@
"learning": "Niektóre wnioski — glukoza, rytm snu, trendy ciśnienia, cykl — wyostrzają się w ciągu pierwszego tygodnia lub dwóch, w miarę gromadzenia danych. Puste kafelki oznaczają teraz tylko, że wciąż poznajemy Twój punkt odniesienia.",
"connectCta": "Połącz urządzenie",
"logCta": "Zapisz pierwszy pomiar",
"importCta": "Importuj istniejącą historię"
"importCta": "Importuj istniejącą historię",
"modulesCta": "Wybierz swoje moduły"
},
"welcomeBack": {
"title": "Witaj ponownie",
@@ -4105,7 +4280,16 @@
"disableError": "Nie udało się wyłączyć trybu Badawczego. Spróbuj ponownie."
},
"shell": {
"sectionsNav": "Sekcje ustawień"
"sectionsNav": "Sekcje ustawień",
"groups": {
"account": "Konto",
"yourData": "Twoje dane",
"display": "Wyświetlanie",
"healthProfile": "Profil zdrowotny",
"ai": "AI",
"connectivity": "Łączność",
"system": "System"
}
},
"sections": {
"account": {
@@ -4236,7 +4420,16 @@
"other": "Inne",
"unknown": "Nieznane"
},
"subtitle": "Zdecyduj, które źródło wygrywa, gdy dwa podają tę samą metrykę."
"subtitle": "Zdecyduj, które źródło wygrywa, gdy dwa podają tę samą metrykę.",
"nutrients": {
"title": "Zsynchronizowane spożycie składników",
"description": "Dzienne sumy z Apple Health z ostatnich {days} dni. Tylko do odczytu — wartości należą do aplikacji źródłowej.",
"empty": "Nie zsynchronizowano jeszcze danych o składnikach odżywczych.",
"latest": "{amount} {unit} dnia {date}",
"daysWithData": "{count} z {days} dni",
"moduleHint": "Synchronizacją steruje moduł spożycia składników odżywczych.",
"moduleHintLink": "Zarządzaj modułami"
}
},
"ai": {
"title": "Dostawca AI",
@@ -4281,7 +4474,8 @@
},
"fullBackup": {
"title": "Pełna kopia zapasowa",
"description": "Pojedynczy plik JSON ze wszystkim — taki sam format jak cotygodniowa automatyczna kopia. Przydatny do samodzielnego przywracania przez przesłanie w panelu administratora.",
"description": "Wszystkie Twoje dane w jednym pliku JSON — pomiary, leki, nastrój, cykl, wyniki badań laboratoryjnych, epizody chorobowe, alergie, wywiad rodzinny i treningi. Taki sam format jak cotygodniowa automatyczna kopia.",
"scopeNote": "Metadane dokumentów oraz podsumowanie AI, jeśli istnieje, są uwzględnione jako czysty tekst; oryginalne pliki dokumentów oraz dane tras GPS/próbek treningów nie są uwzględnione — pobierz je osobno z archiwum dokumentów lub eksportu treningów.",
"encryptToggle": "Zaszyfruj hasłem-frazą",
"passphrasePlaceholder": "Hasło-fraza (min. 12 znaków)",
"passphraseHelp": "Wyprowadza klucz archiwum za pomocą Argon2id i pieczętuje je AES-256-GCM. Zapisz plik z rozszerzeniem .hlx.",
@@ -4441,7 +4635,7 @@
"description": "Ustrukturyzowane rekordy alergii i wywiadu rodzinnego."
},
"environment": {
"title": "Środowisko",
"title": "Lokalizacja i pogoda",
"subtitle": "Kontekst pogody, światła dziennego i lokalizacji.",
"disabledHint": "Włącz moduł Środowisko, aby ustawić lokalizację domową i pobierać dzienną pogodę oraz długość dnia.",
"searchPlaceholder": "Szukaj miasta",
@@ -4629,6 +4823,7 @@
"anthropicKeyLabel": "Klucz API Anthropic",
"baseUrlLabel": "Base URL",
"localKeyLabel": "Klucz API (opcjonalny)",
"localDescription": "Działa tu każdy punkt końcowy zgodny z OpenAI — model lokalny (Ollama, LM Studio) albo brama taka jak LiteLLM, OpenRouter czy vLLM. Adres bazowy kończy się na /v1; klucz API jest opcjonalny.",
"savedPreview": "(zapisano {preview})",
"savedShort": "(zapisano)",
"saveCta": "Zapisz",
@@ -4704,6 +4899,7 @@
"testReasonCredentials": "Dostawca odrzucił dane logowania — uwierzytelnij się ponownie w ustawieniach AI.",
"testReasonRateLimited": "Dostawca ograniczył żądanie — spróbuj ponownie za chwilę.",
"testReasonServerError": "Dostawca AI zwrócił błąd serwera.",
"testReasonBadRequest": "Dostawca odrzucił żądanie (HTTP {status}) — punkt końcowy odpowiedział, więc problem dotyczy formatu żądania lub nazwy modelu, a nie łączności.",
"testReasonUnreachable": "Nie udało się połączyć z dostawcą AI.",
"coachMemory": {
"title": "Co Coach pamięta",
@@ -5312,6 +5508,12 @@
"ouraDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
"nightscoutDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
"integrationCredentialsHint": "Gdzie znaleźć te dane logowania i jak zarejestrować własną aplikację, opisano w przewodniku konfiguracji powyżej.",
"integrationRedirectGuide": {
"intro": "To połączenie działa przez Twoją własną aplikację deweloperską {provider} — około 5 minut konfiguracji:",
"step1": "Utwórz aplikację w portalu deweloperskim {provider}.",
"step2": "Ustaw jej URL przekierowania/callback na:",
"step3": "Wklej powyżej otrzymany identyfikator klienta i sekret klienta."
},
"security": {
"sessionsTitle": "Aktywne sesje",
"sessionsDescription": "Urządzenia i przeglądarki obecnie zalogowane na Twoje konto.",
@@ -5952,6 +6154,7 @@
"colAction": "Akcja",
"colDuration": "Czas trwania",
"colTraceId": "Trace",
"viewDetails": "Zobacz szczegóły dla {action} o {timestamp}",
"showing": "Wyświetlono {count} z maksymalnie {bufferMax} buforowanych zdarzeń",
"empty": "Bufor jest pusty",
"emptyDescription": "Żadne zdarzenie nie pasuje jeszcze do bieżących filtrów. Kliknij Odśwież po wygenerowaniu ruchu.",
@@ -6491,6 +6694,8 @@
"eventMeasurementReminderDesc": "Wysyłane, gdy śledzony pomiar jest wymagany.",
"eventMedicationIntakeSync": "Synchronizacja przyjęć leku",
"eventMedicationIntakeSyncDesc": "Aktualizacja między urządzeniami, gdy dawka zostanie zapisana, przyjęta lub pominięta.",
"eventDailyBriefing": "Codzienne podsumowanie",
"eventDailyBriefingDesc": "Spokojne poranne podsumowanie Twojego dnia. Wyłączone, dopóki go nie włączysz.",
"security": {
"newDeviceTitle": "Nowe logowanie do Twojego konta",
"newDeviceBody": "Nowe logowanie z {device} ({location}). Jeśli to nie Ty, natychmiast zmień hasło.",
@@ -6570,9 +6775,9 @@
"hypertensionGrade1": "Nadciśnienie 1 stopnia",
"hypertensionGrade2": "Nadciśnienie 2 stopnia",
"hypertensionGrade3": "Nadciśnienie 3 stopnia",
"bradycardia": "Bradykardia",
"belowTypicalRange": "Poniżej typowego zakresu",
"elevated": "Podwyższone",
"tachycardia": "Tachykardia",
"aboveTypicalRange": "Powyżej typowego zakresu",
"significantlyLow": "Znacząco niskie",
"slightlyLow": "Lekko niskie",
"onTarget": "W zakresie docelowym",
@@ -7156,8 +7361,11 @@
"TOTAL_BODY_WATER": "Woda w organizmie",
"VISCERAL_FAT": "Tłuszcz trzewny",
"BODY_MASS_INDEX": "Wskaźnik masy ciała (BMI)",
"WAIST_CIRCUMFERENCE": "Obwód talii",
"PHQ9_SCORE": "PHQ-9 · nastrój",
"GAD7_SCORE": "GAD-7 · lęk"
"GAD7_SCORE": "GAD-7 · lęk",
"WHO5_SCORE": "WHO-5 · dobrostan",
"SCI_SCORE": "SCI · sen"
},
"typeGroups": {
"vitals": "Parametry życiowe",
@@ -7275,6 +7483,7 @@
"notePlaceholder": "Opcjonalny kontekst tego wyniku",
"requiredError": "Podaj wartość.",
"save": "Zapisz",
"saveAndAddAnother": "Zapisz i dodaj kolejny",
"savedToast": "Wynik zapisany",
"saveError": "Nie udało się zapisać wyniku",
"biomarker": "Biomarker",
@@ -7566,6 +7775,10 @@
"mentalHealth": {
"label": "Dobrostan psychiczny",
"description": "Dobrowolne testy PHQ-9 i GAD-7 obok dziennika nastroju. Domyślnie wyłączone; odpowiedzi są szyfrowane i pozostają poza Coachem AI."
},
"nutrients": {
"label": "Spożycie składników odżywczych",
"description": "Zapisuje dzienne sumy mikroskładników (witaminy, minerały, woda, kofeina) synchronizowane z Apple Health. Domyślnie wyłączone."
}
},
"illness": {
@@ -7619,7 +7832,10 @@
"notePlaceholder": "Cokolwiek wartego zapamiętania (zaszyfrowane).",
"saveError": "Nie udało się zapisać epizodu. Spróbuj ponownie.",
"parent": "Część",
"parentNone": "Niepowiązane"
"parentNone": "Niepowiązane",
"resolved": "Zakończono",
"resolvedHelp": "Pozostaw puste, dopóki epizod nadal trwa.",
"markOngoing": "Nadal trwa"
},
"sheet": {
"title": "Zapisz {date}",
@@ -7671,7 +7887,8 @@
"direction": {
"above": "powyżej Twojej normy",
"below": "poniżej Twojej normy"
}
},
"settled": "Podczas tego epizodu nic istotnego nie odbiegało od twojego zwykłego zakresu."
},
"insights": {
"title": "Twoja historia chorób",
@@ -7722,12 +7939,12 @@
"RECOVERY_SCORE": "Recovery",
"OXYGEN_SATURATION": "Saturacja tlenem",
"BODY_TEMPERATURE": "Temperatura ciała",
"SKIN_TEMPERATURE": "Temperatura skóry",
"RESPIRATORY_RATE": "Częstość oddechów",
"BLOOD_PRESSURE_SYS": "Ciśnienie skurczowe",
"BLOOD_PRESSURE_DIA": "Ciśnienie rozkurczowe",
"PULSE": "Pulse",
"WEIGHT": "Weight"
"WEIGHT": "Weight",
"FUNCTIONAL_IMPACT": "Wpływ na funkcjonowanie"
},
"listLoadError": "Nie udało się załadować epizodów."
},
@@ -7808,14 +8025,14 @@
"bpLowDoctor": "Dwa ostatnie pomiary miały ciśnienie skurczowe poniżej {systolic} mmHg. Jeśli kręci ci się w głowie lub czujesz osłabienie, usiądź lub połóż się i skontaktuj się z lekarzem, jeśli to się utrzymuje.",
"bpLowEmergency": "Dwa ostatnie pomiary miały ciśnienie skurczowe poniżej {systolic} mmHg i zgłosiłeś objawy. Jeśli czujesz, że możesz zemdleć, jesteś zdezorientowany lub masz zimną, lepką skórę, natychmiast zadzwoń po pomoc.",
"glucoseLowTitle": "Niski poziom cukru",
"glucoseLowDoctor": "Dwa ostatnie pomiary były poniżej 70 mg/dL ({value} mg/dL). Przyjmij szybko działające węglowodany, zmierz ponownie za 15 minut i skontaktuj się z lekarzem, jeśli spadki się utrzymują.",
"glucoseLowEmergency": "Dwa ostatnie pomiary były poniżej 70 mg/dL ({value} mg/dL) i zgłosiłeś objawy. Lecz to teraz szybkim cukrem; jeśli nie możesz lub czujesz się bardzo źle, zadzwoń po pomoc.",
"glucoseLowDoctor": "Dwa ostatnie pomiary były poniżej {threshold} {unit} ({value} {unit}). Przyjmij szybko działające węglowodany, zmierz ponownie za 15 minut i skontaktuj się z lekarzem, jeśli spadki się utrzymują.",
"glucoseLowEmergency": "Dwa ostatnie pomiary były poniżej {threshold} {unit} ({value} {unit}) i zgłosiłeś objawy. Lecz to teraz szybkim cukrem; jeśli nie możesz lub czujesz się bardzo źle, zadzwoń po pomoc.",
"glucoseLowSevereTitle": "Bardzo niski poziom cukru",
"glucoseLowSevereDoctor": "Dwa ostatnie pomiary były poniżej 54 mg/dL ({value} mg/dL). Lecz natychmiast szybko działającymi węglowodanami, zmierz ponownie za 15 minut i skontaktuj się z lekarzem.",
"glucoseLowSevereEmergency": "Dwa ostatnie pomiary były poniżej 54 mg/dL ({value} mg/dL) i zgłosiłeś objawy. Lecz to teraz; jeśli nie utrzymujesz pokarmu lub czujesz się bardzo źle, zadzwoń po pomoc.",
"glucoseLowSevereDoctor": "Dwa ostatnie pomiary były poniżej {threshold} {unit} ({value} {unit}). Lecz natychmiast szybko działającymi węglowodanami, zmierz ponownie za 15 minut i skontaktuj się z lekarzem.",
"glucoseLowSevereEmergency": "Dwa ostatnie pomiary były poniżej {threshold} {unit} ({value} {unit}) i zgłosiłeś objawy. Lecz to teraz; jeśli nie utrzymujesz pokarmu lub czujesz się bardzo źle, zadzwoń po pomoc.",
"glucoseHighTitle": "Bardzo wysoki poziom cukru",
"glucoseHighDoctor": "Dwa ostatnie pomiary wyniosły {value} mg/dL lub więcej. Zmierz ponownie, nawodnij się i jak najszybciej skontaktuj się z lekarzem, jeśli pozostaje tak wysoki.",
"glucoseHighEmergency": "Dwa ostatnie pomiary wyniosły {value} mg/dL lub więcej i zgłosiłeś objawy. W razie wymiotów, przyspieszonego oddechu lub dezorientacji natychmiast zadzwoń po pomoc."
"glucoseHighDoctor": "Dwa ostatnie pomiary wyniosły {value} {unit} lub więcej. Zmierz ponownie, nawodnij się i jak najszybciej skontaktuj się z lekarzem, jeśli pozostaje tak wysoki.",
"glucoseHighEmergency": "Dwa ostatnie pomiary wyniosły {value} {unit} lub więcej i zgłosiłeś objawy. W razie wymiotów, przyspieszonego oddechu lub dezorientacji natychmiast zadzwoń po pomoc."
}
},
"version": {
@@ -7828,6 +8045,7 @@
"mentalHealth": {
"pageTitle": "Dobrostan psychiczny",
"pageDescription": "Cztery zwalidowane, dobrowolne testy — PHQ-9 (nastrój), GAD-7 (lęk), WHO-5 (samopoczucie) i SCI (sen) — obok Twojego dziennika nastroju. Liczy się trend w czasie, a nie pojedynczy wynik.",
"moodLink": "Zobacz historię swojego nastroju",
"attributionLabel": "Źródło",
"attributionShow": "Źródło i licencja: {instrument}",
"choosePrompt": "Wybierz sprawdzenie",
@@ -7952,6 +8170,12 @@
"nummerGegenKummer": {
"name": "Nummer gegen Kummer (dla młodych ludzi)"
},
"telefonSeelsorgeAt": {
"name": "Telefonseelsorge Österreich (całodobowo, bezpłatnie i poufnie)"
},
"dargeboteneHand": {
"name": "Die Dargebotene Hand (całodobowo, bezpłatnie i poufnie)"
},
"krisenchat": {
"name": "krisenchat (czat całodobowy, dla młodych ludzi)"
},
@@ -8205,7 +8429,9 @@
"openLabel": "Otwórz {title}",
"attachmentBadge": "Tylko do pobrania",
"uploading": "Przesyłanie…",
"dismissFailed": "Odrzuć nieudane przesyłanie"
"dismissFailed": "Odrzuć nieudane przesyłanie",
"processing": "Przetwarzanie…",
"ready": "Gotowe"
},
"error": {
"fileTooLarge": "Ten plik przekracza limit {maxSize}.",
@@ -8247,6 +8473,10 @@
},
"detail": {
"title": "Szczegóły dokumentu",
"summary": {
"title": "Podsumowanie",
"pending": "Podsumowanie jest przygotowywane."
},
"loadError": "Nie udało się wczytać tego dokumentu.",
"saveError": "Nie udało się zapisać zmiany.",
"deleteError": "Nie udało się usunąć dokumentu.",
@@ -8261,7 +8491,8 @@
"noConditions": "Niepowiązany ze schorzeniem",
"previewUnavailable": "Brak podglądu dla tego typu pliku",
"uploadedMeta": "Dodano {date} · {size}",
"share": "Udostępnij"
"share": "Udostępnij",
"labValuesLink": "Zobacz {count} wyników laboratoryjnych"
},
"dropOverlay": {
"title": "Upuść pliki, aby je przesłać",
@@ -8276,7 +8507,9 @@
"deleted": "Usunięto dokumenty: {count}",
"restored": "Przywrócono dokumenty: {count}",
"partialFailure": "Nie udało się zaktualizować {failed} z {total}",
"failed": "Nie udało się wykonać akcji"
"failed": "Nie udało się wykonać akcji",
"share": "Udostępnij",
"shareTooMany": "Link do udostępniania obejmuje maksymalnie {max} dokumentów. Odznacz kilka i spróbuj ponownie."
},
"episodeCard": {
"title": "Dokumenty",
@@ -8340,28 +8573,14 @@
},
"share": {
"title": "Udostępnij ten dokument",
"multiTitle": "Udostępnij te dokumenty",
"multiLabel": "{count} dokumentów",
"description": "Utwórz link tylko do odczytu dla lekarza z dołączonym tym dokumentem. Link i hasło pojawią się tylko raz."
},
"chat": {
"title": "Porozmawiaj o tym dokumencie",
"notIndexed": "Najpierw przeczytaj ten dokument z pomocą AI, aby o nim porozmawiać.",
"empty": "Zadaj pytanie o ten dokument, aby zacząć.",
"placeholder": "Zapytaj o ten dokument…",
"inputLabel": "Twoje pytanie o ten dokument",
"send": "Wyślij",
"thinking": "Czytanie dokumentu…",
"safety": "Odpowiedzi opisują ten dokument i nie są poradą medyczną.",
"close": "Zamknij czat",
"errorHistory": "Nie udało się wczytać rozmowy. Spróbuj ponownie.",
"errorGeneric": "Coś poszło nie tak. Spróbuj ponownie.",
"errorRateLimited": "Zbyt wiele wiadomości. Poczekaj chwilę i spróbuj ponownie.",
"errorBudget": "Osiągnięto dzienny limit AI. Spróbuj ponownie jutro.",
"errorCredential": "Dostawcę AI trzeba ponownie połączyć w ustawieniach.",
"errorConsent": "Włącz zgodę na AI w ustawieniach, aby porozmawiać o tym dokumencie.",
"errorNetwork": "Wygląda na to, że jesteś offline. Sprawdź połączenie i spróbuj ponownie.",
"openAria": "Porozmawiaj o tym dokumencie",
"openButton": "Zapytaj o ten dokument",
"scopeBadge": "Rozmowa o: {title}"
"openAria": "Zapytaj Coacha o ten dokument",
"openButton": "Zapytaj Coacha"
}
},
"selfDescription": {
@@ -8448,5 +8667,172 @@
"valuesDescription": "Wszystkie zapisane wartości tej metryki.",
"backToMetric": "Powrót do {metric}"
}
},
"nutrients": {
"names": {
"vitamin_a": "Witamina A",
"thiamin": "Tiamina (B1)",
"riboflavin": "Ryboflawina (B2)",
"niacin": "Niacyna (B3)",
"pantothenic_acid": "Kwas pantotenowy (B5)",
"vitamin_b6": "Witamina B6",
"biotin": "Biotyna (B7)",
"folate": "Foliany (B9)",
"vitamin_b12": "Witamina B12",
"vitamin_c": "Witamina C",
"vitamin_d": "Witamina D",
"vitamin_e": "Witamina E",
"vitamin_k": "Witamina K",
"calcium": "Wapń",
"iron": "Żelazo",
"magnesium": "Magnez",
"phosphorus": "Fosfor",
"zinc": "Cynk",
"copper": "Miedź",
"manganese": "Mangan",
"selenium": "Selen",
"chromium": "Chrom",
"molybdenum": "Molibden",
"iodine": "Jod",
"water": "Woda",
"caffeine": "Kofeina"
},
"page": {
"title": "Spożycie składników odżywczych",
"description": "Nawodnienie oraz spożycie witamin/minerałów zsynchronizowane z aplikacji zdrowia telefonu, plus ręczne wpisy wody.",
"moduleOffTitle": "Śledzenie składników odżywczych jest wyłączone",
"moduleOffDescription": "Włącz moduł składników odżywczych, aby zobaczyć tu nawodnienie i spożycie witamin/minerałów. Zsynchronizowane dane staną się częścią zapisu, który może odczytać Coach.",
"moduleOffCta": "Włącz składniki odżywcze",
"emptyTitle": "Brak jeszcze danych o składnikach odżywczych",
"emptyDescription": "Dane pochodzą z synchronizacji aplikacji zdrowia telefonu lub z ręcznego wpisu wody."
},
"hydration": {
"referenceMeta": "Zalecane spożycie {value} {unit}/dzień · EFSA",
"quickAddTitle": "Dodaj wodę",
"quickAddCustomLabel": "Własna ilość (mL)",
"quickAddCustomPlaceholder": "250",
"quickAddSubmit": "Dodaj",
"quickAddError": "Nie udało się zapisać — spróbuj ponownie.",
"editTotal": "Edytuj dzisiejszą sumę"
},
"caffeine": {
"ceilingMeta": "Bezpieczny limit {value} {unit}/dzień · EFSA"
},
"micronutrients": {
"title": "Witaminy i minerały",
"rowMeta": "{days} z {window} dni · zalecane {value} {unit} (EFSA)",
"rowMetaNoRef": "{days} z {window} dni",
"footer": "{tracked} z {total} śledzonych mikroskładników ma dane w tym okresie.",
"emptyTitle": "Brak jeszcze zsynchronizowanych danych o mikroskładnikach"
},
"sparseNote": "Niska wartość zwykle oznacza tu rzadkie zapisywanie, a nie niedobór — dzienne sumy z Apple Health odzwierciedlają to, co zapisały twoje aplikacje, a nie całkowite spożycie w diecie."
},
"daily": {
"line": {
"score": "Twój dzisiejszy wynik zdrowia to {score}.",
"allClear": "Dziś nic nie wymaga Twojej uwagi."
},
"push": {
"title": "Twoje poranne podsumowanie",
"bodyProvisional": "{line} · Brakuje jeszcze snu z zeszłej nocy."
},
"item": {
"doseWindow": {
"title": "Lek do przyjęcia",
"body": "Dziś przypada dawka.",
"bodyNamed": "{name} przypada dziś."
},
"preventiveCare": {
"title": "Badanie do wykonania",
"body": "{label} jest do wykonania.",
"bodyMany": "Do wykonania jest {count} badań."
},
"syncIssue": {
"title": "Synchronizacja wymaga uwagi",
"body": "{integration} się nie synchronizuje — połącz ponownie, aby dane były aktualne."
},
"coachCheckin": {
"title": "Czas przyjrzeć się planowi",
"body": "Minął mniej więcej tydzień, odkąd ustaliłeś ten plan — zachowaj go, dostosuj lub odpuść. Bez presji.",
"bodyPlan": "Około tygodnia temu postanowiłeś: „{plan}”. Zachowaj to, dostosuj lub odpuść — bez presji.",
"adjustPrompt": "Chciałbym dostosować mój plan kontrolny.",
"adjustPromptPlan": "Chciałbym dostosować ten plan: „{plan}”"
},
"tensionWindow": {
"title": "Może chwila napięcia dzisiaj",
"body": {
"morning": "Twoje tętno przez dłuższą chwilę rano utrzymywało się powyżej wartości spoczynkowej, gdy byłeś w bezruchu — możliwe napięcie.",
"afternoon": "Twoje tętno przez dłuższą chwilę po południu utrzymywało się powyżej wartości spoczynkowej, gdy byłeś w bezruchu — możliwe napięcie.",
"evening": "Twoje tętno przez dłuższą chwilę wieczorem utrzymywało się powyżej wartości spoczynkowej, gdy byłeś w bezruchu — możliwe napięcie.",
"night": "Twoje tętno przez dłuższą chwilę w nocy utrzymywało się powyżej wartości spoczynkowej, gdy byłeś w bezruchu — możliwe napięcie."
}
},
"ecgNewRecording": {
"title": "Nowy zapis EKG",
"body": "Twoje urządzenie zarejestrowało nowe EKG — gotowe do podglądu.",
"bodyVerdict": "Twoje urządzenie zarejestrowało nowe EKG i zgłosiło: {verdict}.",
"verdict": {
"irregular": "możliwy nieregularny rytm",
"notDetected": "brak nieregularnego rytmu",
"inconclusive": "wynik niejednoznaczny"
}
}
},
"action": {
"logDose": "Zapisz dawkę",
"reconnect": "Połącz ponownie",
"viewCheckups": "Zobacz badania",
"checkinKeep": "Zachowaj",
"checkinAdjust": "Dostosuj",
"checkinLetGo": "Odpuść",
"viewMilestone": "Zobacz",
"viewPulse": "Zobacz tętno",
"viewEcg": "Zobacz zapis"
},
"today": {
"scoreLabel": "Wynik zdrowia",
"deltaVsBaseline": "{delta} wzgl. Twojej wartości bazowej",
"deltaFlat": "Zgodnie z Twoją wartością bazową",
"sleepPending": "Sen z ostatniej nocy jeszcze nie dotarł — dzisiejszy odczyt jest wstępny.",
"readFullBriefing": "Przeczytaj pełny briefing",
"worthALook": "Warto sprawdzić",
"allClear": "Dziś nic nie wymaga Twojej uwagi.",
"ringLink": "Otwórz szczegóły: {metric}"
},
"milestone": {
"metric": {
"restingHeartRate": "tętno spoczynkowe",
"heartRateVariability": "zmienność rytmu serca",
"respiratoryRate": "częstość oddechów",
"weight": "waga",
"generic": "ta wartość"
},
"record": {
"title": "Nowy rekord: {metric}",
"body": "Dziś osiągnięto Twój najlepszy wynik dla {metric}. Spokojnie odnotowane, nic nie musisz robić."
},
"returnToBaseline": {
"title": "Znów w Twoim zakresie: {metric}",
"body": "Twoja {metric} wróciła do zwykłego zakresu. Cokolwiek to było, minęło."
},
"sustainedInRange": {
"week1": {
"title": "Tydzień w zakresie",
"body": "Twoja {metric} od tygodnia mieści się w zwykłym zakresie. Ładnie i spokojnie."
},
"week2": {
"title": "Dwa tygodnie w zakresie",
"body": "Twoja {metric} od dwóch tygodni mieści się w zwykłym zakresie. Spokojnie i stabilnie."
},
"week3": {
"title": "Trzy tygodnie w zakresie",
"body": "Trzy tygodnie z {metric} w zwykłym zakresie. Naprawdę spokojny odcinek."
},
"week4": {
"title": "Miesiąc w zakresie",
"body": "Cały miesiąc z {metric} w zwykłym zakresie. Wspaniale stabilnie."
}
}
}
}
}
+7 -6
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.28.23",
"version": "1.30.15",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -65,7 +65,7 @@
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.1",
"@simplewebauthn/server": "^13.3.2",
"@tanstack/react-query": "5.101.2",
"@tanstack/react-virtual": "^3.14.5",
"@types/sax": "^1.2.7",
@@ -74,6 +74,7 @@
"date-fns": "^4.4.0",
"dotenv": "^17.4.2",
"fflate": "^0.8.3",
"jose": "^6.2.3",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8",
"lucide-react": "^1.18.0",
@@ -83,7 +84,7 @@
"nodemailer": "9.0.3",
"otpauth": "^9.5.1",
"p-limit": "^7.3.0",
"pg-boss": "^12.20.0",
"pg-boss": "^12.26.0",
"prisma": "^7.8.0",
"qrcode": "^1.5.4",
"radix-ui": "^1.6.0",
@@ -110,7 +111,7 @@
"@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4",
"@testcontainers/postgresql": "^12.0.3",
"@types/node": "26.1.0",
"@types/node": "26.1.1",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.20.0",
"@types/qrcode": "^1.5.6",
@@ -122,13 +123,13 @@
"eslint": "^9",
"eslint-config-next": "16.2.6",
"knip": "^6.16.1",
"lefthook": "^2.1.9",
"lefthook": "^2.1.10",
"pdf-parse": "^2.4.5",
"pdfjs-dist": "5.4.296",
"pg": "^8.22.0",
"prettier": "^3.9.1",
"prettier-plugin-tailwindcss": "^0.8.0",
"shadcn": "^4.9.0",
"shadcn": "^4.13.0",
"tailwindcss": "^4",
"testcontainers": "^12.0.3",
"tw-animate-css": "^1.4.0",
+6
View File
@@ -59,6 +59,12 @@ export default defineConfig({
// matches what real users render on first paint and is the only honest
// a11y baseline for this app.
colorScheme: "dark",
// Issue #490 — pin the browser timezone. Without this the context
// inherits the HOST zone (UTC on CI, Europe/Berlin on a local Mac),
// so any surface that renders a clock or a day boundary could pass
// locally and skew two hours / a calendar day on CI. Berlin matches
// the app's own display fallback, so CI and local render identically.
timezoneId: "Europe/Berlin",
},
projects: [
+562 -417
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,13 @@
-- v1.28 — OIDC SSO identity pinning.
--
-- The (oidc_issuer, oidc_sub) pair pins an account to exactly one IdP
-- identity: stamped on first OIDC login (auto-provision or one-time link by
-- verified email), matched on every later OIDC login. Email stays a display
-- field after the stamp — an IdP-side email change can never re-point the
-- login at a different account. Both columns are NULL on accounts that never
-- signed in via SSO; Postgres unique indexes ignore NULL pairs, so the
-- constraint only bites on actual duplicate identities.
ALTER TABLE "users" ADD COLUMN "oidc_issuer" TEXT;
ALTER TABLE "users" ADD COLUMN "oidc_sub" TEXT;
CREATE UNIQUE INDEX "users_oidc_issuer_oidc_sub_key" ON "users"("oidc_issuer", "oidc_sub");
@@ -0,0 +1,37 @@
-- v1.28 — micronutrient intake daily totals (opt-in `nutrients` module).
--
-- One row per (user, local day, nutrient): vitamins, minerals, water and
-- caffeine as supplement-style day totals synced from Apple Health. The
-- composite PK is the idempotency key — an iOS re-post of a grown day
-- total replaces the row in place (last-writer-wins day-total contract),
-- so there is no externalId column and no soft delete. `day` is a
-- YYYY-MM-DD TEXT key anchored to the user's IANA timezone (the
-- EnvironmentContext / MedicationComplianceRollup day-key precedent);
-- `nutrient` is a bare TEXT code enforced in code against the closed
-- catalog (`src/lib/nutrients/catalog.ts`) so adding a code never needs
-- a migration. `unit` is denormalised from the catalog on purpose: rows
-- stay self-describing in exports even if the catalog ever drifts.
CREATE TABLE "nutrient_intake_days" (
"user_id" TEXT NOT NULL,
"day" TEXT NOT NULL,
"nutrient" TEXT NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"unit" TEXT NOT NULL,
"source" TEXT NOT NULL DEFAULT 'APPLE_HEALTH',
"external_source_version" TEXT,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "nutrient_intake_days_pkey" PRIMARY KEY ("user_id", "day", "nutrient")
);
-- Read path: the settings-card / read-endpoint window scan per user, and
-- the later per-nutrient series fetch (day DESC for "latest first").
CREATE INDEX "nutrient_intake_days_user_id_nutrient_day_idx"
ON "nutrient_intake_days" ("user_id", "nutrient", "day" DESC);
-- Account deletion cascades; rows are worthless without their owner.
ALTER TABLE "nutrient_intake_days"
ADD CONSTRAINT "nutrient_intake_days_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,12 @@
-- Import-job liveness heartbeat (issue #486 follow-up).
--
-- `reconcileOrphanImportJobs` used to flip EVERY non-terminal ImportJob
-- row to `failed` on any worker boot — killing an import that was still
-- actively parsing in another worker (multi-replica / rolling deploy).
-- The reconcile now gates on this heartbeat: a non-terminal row is only
-- treated as orphaned when its pg-boss job is gone/terminal OR its
-- heartbeat has gone stale. `@updatedAt` bumps this column on every
-- write, including each mid-run progress tick, so a live import keeps it
-- fresh. Existing rows backfill to the ALTER time via the DB default.
ALTER TABLE "import_jobs"
ADD COLUMN "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
@@ -0,0 +1,54 @@
-- v1.28.46 perf (H4) — partial indexes for the boot-time converging-backfill
-- discovery scans. Each discovery now runs a DB-level `SELECT DISTINCT user_id`
-- over a growth table filtered by a "still-un-migrated" predicate; without an
-- index that is a full filtered-partition scan at worker boot (the boot-storm
-- class on a heavy tenant). Each index below is PARTIAL on exactly the
-- discovery predicate, so the scan is index-only and shrinks to nothing as the
-- one-time backfill converges (a migrated row drops out of the predicate).
--
-- Additive-only: every statement is `CREATE INDEX IF NOT EXISTS`, no table
-- rewrite, no data change. NON-CONCURRENT on purpose — Prisma Migrate wraps a
-- migration file in a transaction and `CREATE INDEX CONCURRENTLY` cannot run in
-- one; these build at boot (docker-entrypoint runs `migrate deploy` BEFORE the
-- app serves traffic), so the brief build lock lands in the pre-traffic window.
-- Partial predicates match the write-side note convention (partial indexes are
-- raw-SQL-only here; Prisma cannot express a partial predicate — see the 0087 /
-- 0108 / 0183 precedents documented in schema.prisma).
-- note-encryption-backfill: measurements (densest table) + mood_entries.
CREATE INDEX IF NOT EXISTS "measurements_note_backfill_idx"
ON "measurements" ("user_id")
WHERE "notes" IS NOT NULL AND "notes_encrypted" IS NULL;
CREATE INDEX IF NOT EXISTS "mood_entries_note_backfill_idx"
ON "mood_entries" ("user_id")
WHERE "note" IS NOT NULL AND "note_encrypted" IS NULL;
-- med-notes-encryption-backfill: side-effects, inventory items, dose-changes.
-- Dose-changes carry no user_id; the discovery joins to medications on
-- medication_id, so the partial index leads with that join key.
CREATE INDEX IF NOT EXISTS "medication_side_effects_note_backfill_idx"
ON "medication_side_effects" ("user_id")
WHERE "notes" IS NOT NULL AND "notes_encrypted" IS NULL;
CREATE INDEX IF NOT EXISTS "medication_inventory_items_note_backfill_idx"
ON "medication_inventory_items" ("user_id")
WHERE "notes" IS NOT NULL AND "notes_encrypted" IS NULL;
CREATE INDEX IF NOT EXISTS "medication_dose_changes_note_backfill_idx"
ON "medication_dose_changes" ("medication_id")
WHERE "note" IS NOT NULL AND "note_encrypted" IS NULL;
-- lab-biomarker-backfill: unlinked live lab results.
CREATE INDEX IF NOT EXISTS "lab_results_biomarker_backfill_idx"
ON "lab_results" ("user_id")
WHERE "deleted_at" IS NULL AND "biomarker_id" IS NULL;
-- document-thumbnail-backfill: live thumbnailable documents. The thumbnail
-- absence lives on the `document_thumbnails` side table (a cross-table
-- anti-join, not indexable here); this partial index bounds the document-side
-- scan on `(user_id, mime_type)` for live rows, and the anti-join rides
-- `document_thumbnails.document_id` (already UNIQUE via the 1:1 relation).
CREATE INDEX IF NOT EXISTS "inbound_documents_thumbnail_backfill_idx"
ON "inbound_documents" ("user_id", "mime_type")
WHERE "deleted_at" IS NULL;
@@ -0,0 +1,15 @@
-- Morning-freshness marker (S4 of the unified daily-value system).
--
-- The nightly insight pre-pass runs ~04:30, before last night's sleep has
-- synced, so the morning digest/health-score reference the day WITHOUT the
-- current sleep. This column records the user-local YYYY-MM-DD (their profile
-- timezone) for which the event-driven morning refresh has completed: last
-- night's sleep arrived AND the sleep-dependent insight/score cache was
-- regenerated with it. The daily digest reports `final` once this equals the
-- user's current local date, `provisional` otherwise (honest degradation when
-- sleep is late or never arrives). Stamped by the `morning-digest-refresh`
-- pg-boss job the sleep-arrival trigger enqueues, debounced once per local
-- morning. NULL for every existing row = provisional until the next sleep
-- landing, which is the correct pre-feature reading.
ALTER TABLE "users"
ADD COLUMN "morning_digest_refreshed_on" TEXT;
@@ -0,0 +1,71 @@
-- S7: attach multiple documents to a coach conversation.
--
-- Replaces the scalar `coach_conversations.document_id` discriminator (which
-- could express only ONE document and would UNFENCE a conversation when that
-- document was deleted) with a join table + a sticky `document_scoped` flag.
--
-- The flag is the fail-closed half of the fenced-only discriminator: it is set
-- true at fenced creation / first attach and NEVER cleared. The join table
-- holds the LIVE attachment set. A count-only predicate would let a deleted
-- attachment flip a conversation (whose history may contain document-derived
-- text) back onto the tool loop; the sticky flag makes "fenced" a one-way latch
-- independent of attachment liveness.
--
-- Backfill runs in the SAME transaction as the DDL (Postgres allows it) BEFORE
-- the scalar column is dropped, so there is no dual-source-of-truth window:
-- every legacy single-doc chat becomes a one-attachment fenced conversation.
-- CreateTable
CREATE TABLE "coach_conversation_documents" (
"conversation_id" TEXT NOT NULL,
"document_id" TEXT NOT NULL,
"added_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "coach_conversation_documents_pkey" PRIMARY KEY ("conversation_id", "document_id")
);
-- CreateIndex — the document-sheet "chats about this doc" scan + the path-id
-- join filter the single-doc route uses.
CREATE INDEX "coach_conversation_documents_document_id_idx"
ON "coach_conversation_documents" ("document_id");
-- AddForeignKey — deleting a conversation cascades its attachment rows away.
ALTER TABLE "coach_conversation_documents"
ADD CONSTRAINT "coach_conversation_documents_conversation_id_fkey"
FOREIGN KEY ("conversation_id") REFERENCES "coach_conversations" ("id")
ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey — deleting a document cascades its join rows away (its text
-- leaves every conversation's context) but NEVER the conversation itself.
ALTER TABLE "coach_conversation_documents"
ADD CONSTRAINT "coach_conversation_documents_document_id_fkey"
FOREIGN KEY ("document_id") REFERENCES "inbound_documents" ("id")
ON DELETE CASCADE ON UPDATE CASCADE;
-- AlterTable — the sticky fence flag.
ALTER TABLE "coach_conversations"
ADD COLUMN "document_scoped" BOOLEAN NOT NULL DEFAULT false;
-- Backfill: every legacy single-doc chat becomes a one-attachment fenced
-- conversation. Runs BEFORE the DROP COLUMN below.
INSERT INTO "coach_conversation_documents" ("conversation_id", "document_id", "added_at")
SELECT "id", "document_id", "created_at"
FROM "coach_conversations"
WHERE "document_id" IS NOT NULL;
UPDATE "coach_conversations" SET "document_scoped" = true
WHERE "document_id" IS NOT NULL;
-- The scalar discriminator goes away in the SAME migration — no dual-source-of-
-- truth window, no "in case" shim. Every reader is repointed in the same
-- release. Drop the FK + covering index first, then the column.
ALTER TABLE "coach_conversations"
DROP CONSTRAINT "coach_conversations_document_id_fkey";
DROP INDEX "coach_conversations_user_id_document_id_updated_at_idx";
ALTER TABLE "coach_conversations"
DROP COLUMN "document_id";
-- CreateIndex — the rail + tool-route-send predicate on the sticky flag.
CREATE INDEX "coach_conversations_user_id_document_scoped_updated_at_idx"
ON "coach_conversations" ("user_id", "document_scoped", "updated_at");
@@ -0,0 +1,289 @@
-- v1.29.x — backfill non-canonical WHOOP `Workout.sport_type` values.
--
-- Root cause (confirmed from prod data): WHOOP was the only workout
-- integration that never normalised its sport to HealthLog's canonical
-- `WorkoutSportType` enum (see `workoutSportTypeEnum` in
-- src/lib/validations/workout.ts). `upsertWhoopWorkout()` in
-- src/lib/whoop/sync-workout.ts wrote WHOOP's raw `sport_name` verbatim, or
-- a literal `whoop_sport_<sport_id>` placeholder when WHOOP sent only the
-- (now-retired) numeric id — so existing WHOOP rows carry values the
-- `/insights/workouts` list can't map to an icon/label (a WHOOP-tracked bike
-- ride never rendered as "cycling"). `mapWhoopSportType()`
-- (src/lib/whoop/sport-map.ts) fixes the write path going forward; this
-- migration re-derives every EXISTING WHOOP `Workout` row from the same
-- table so historical data and the live mapper never drift apart.
--
-- The WHEN arms below were generated FROM `WHOOP_SPORT_TABLE` in
-- src/lib/whoop/sport-map.ts (one JS-side script run against that exact
-- table, not hand-transcribed) and cover both shapes a pre-fix row could
-- carry:
-- - the literal `whoop_sport_<id>` placeholder (id present, no name), and
-- - the raw `sport_name` WHOOP sent (name present), normalised the same
-- way the SQL expression below normalises the stored column: lowercase,
-- any run of characters outside `[a-z0-9_]` collapsed to a single `_`.
-- (`normaliseSportKey()` in sport-map.ts mirrors this exact rule so a
-- future re-generation of this list stays consistent with the SQL.)
--
-- Anything neither arm recognises falls to `ELSE 'other'` — same fallback
-- `mapWhoopSportType()` uses, never leaves a row on a `whoop_sport_<n>`
-- placeholder.
--
-- Idempotent: the WHERE clause excludes rows whose `sport_type` is ALREADY
-- one of the 20 canonical values, so a re-run touches zero rows once the
-- first run has completed. Safe to re-run after a partial failure.
UPDATE "workouts"
SET "sport_type" = CASE
lower(regexp_replace(trim("sport_type"), '[^a-zA-Z0-9_]+', '_', 'g'))
WHEN 'whoop_sport__1' THEN 'other'
WHEN 'whoop_sport_0' THEN 'running'
WHEN 'whoop_sport_1' THEN 'cycling'
WHEN 'whoop_sport_16' THEN 'other'
WHEN 'whoop_sport_17' THEN 'basketball'
WHEN 'whoop_sport_18' THEN 'rowing'
WHEN 'whoop_sport_19' THEN 'other'
WHEN 'whoop_sport_20' THEN 'other'
WHEN 'whoop_sport_21' THEN 'other'
WHEN 'whoop_sport_22' THEN 'golf'
WHEN 'whoop_sport_24' THEN 'other'
WHEN 'whoop_sport_25' THEN 'other'
WHEN 'whoop_sport_27' THEN 'other'
WHEN 'whoop_sport_28' THEN 'other'
WHEN 'whoop_sport_29' THEN 'other'
WHEN 'whoop_sport_30' THEN 'soccer'
WHEN 'whoop_sport_31' THEN 'other'
WHEN 'whoop_sport_32' THEN 'tennis'
WHEN 'whoop_sport_33' THEN 'swimming'
WHEN 'whoop_sport_34' THEN 'tennis'
WHEN 'whoop_sport_35' THEN 'running'
WHEN 'whoop_sport_36' THEN 'other'
WHEN 'whoop_sport_37' THEN 'swimming'
WHEN 'whoop_sport_38' THEN 'strength'
WHEN 'whoop_sport_39' THEN 'hiit'
WHEN 'whoop_sport_42' THEN 'dance'
WHEN 'whoop_sport_43' THEN 'mindAndBody'
WHEN 'whoop_sport_44' THEN 'yoga'
WHEN 'whoop_sport_45' THEN 'strength'
WHEN 'whoop_sport_47' THEN 'other'
WHEN 'whoop_sport_48' THEN 'crossTraining'
WHEN 'whoop_sport_49' THEN 'mixedCardio'
WHEN 'whoop_sport_51' THEN 'crossTraining'
WHEN 'whoop_sport_52' THEN 'hiking'
WHEN 'whoop_sport_53' THEN 'other'
WHEN 'whoop_sport_55' THEN 'rowing'
WHEN 'whoop_sport_56' THEN 'hiit'
WHEN 'whoop_sport_57' THEN 'cycling'
WHEN 'whoop_sport_59' THEN 'strength'
WHEN 'whoop_sport_60' THEN 'crossTraining'
WHEN 'whoop_sport_61' THEN 'rowing'
WHEN 'whoop_sport_62' THEN 'mixedCardio'
WHEN 'whoop_sport_63' THEN 'walking'
WHEN 'whoop_sport_64' THEN 'other'
WHEN 'whoop_sport_65' THEN 'elliptical'
WHEN 'whoop_sport_66' THEN 'stairClimber'
WHEN 'whoop_sport_70' THEN 'mindAndBody'
WHEN 'whoop_sport_71' THEN 'other'
WHEN 'whoop_sport_73' THEN 'other'
WHEN 'whoop_sport_74' THEN 'other'
WHEN 'whoop_sport_75' THEN 'other'
WHEN 'whoop_sport_76' THEN 'other'
WHEN 'whoop_sport_77' THEN 'other'
WHEN 'whoop_sport_82' THEN 'other'
WHEN 'whoop_sport_83' THEN 'stairClimber'
WHEN 'whoop_sport_84' THEN 'hiit'
WHEN 'whoop_sport_85' THEN 'other'
WHEN 'whoop_sport_86' THEN 'other'
WHEN 'whoop_sport_87' THEN 'other'
WHEN 'whoop_sport_88' THEN 'other'
WHEN 'whoop_sport_89' THEN 'other'
WHEN 'whoop_sport_90' THEN 'other'
WHEN 'whoop_sport_91' THEN 'other'
WHEN 'whoop_sport_92' THEN 'other'
WHEN 'whoop_sport_93' THEN 'other'
WHEN 'whoop_sport_94' THEN 'crossTraining'
WHEN 'whoop_sport_95' THEN 'other'
WHEN 'whoop_sport_96' THEN 'hiit'
WHEN 'whoop_sport_97' THEN 'cycling'
WHEN 'whoop_sport_98' THEN 'hiit'
WHEN 'whoop_sport_99' THEN 'other'
WHEN 'whoop_sport_100' THEN 'other'
WHEN 'whoop_sport_101' THEN 'tennis'
WHEN 'whoop_sport_102' THEN 'other'
WHEN 'whoop_sport_103' THEN 'hiit'
WHEN 'whoop_sport_104' THEN 'other'
WHEN 'whoop_sport_105' THEN 'other'
WHEN 'whoop_sport_106' THEN 'tennis'
WHEN 'whoop_sport_107' THEN 'mindAndBody'
WHEN 'whoop_sport_108' THEN 'other'
WHEN 'whoop_sport_109' THEN 'other'
WHEN 'whoop_sport_110' THEN 'crossTraining'
WHEN 'whoop_sport_111' THEN 'other'
WHEN 'whoop_sport_112' THEN 'other'
WHEN 'whoop_sport_113' THEN 'other'
WHEN 'whoop_sport_121' THEN 'other'
WHEN 'whoop_sport_123' THEN 'strength'
WHEN 'whoop_sport_125' THEN 'other'
WHEN 'whoop_sport_126' THEN 'cycling'
WHEN 'whoop_sport_127' THEN 'hiit'
WHEN 'whoop_sport_128' THEN 'mindAndBody'
WHEN 'whoop_sport_230' THEN 'tennis'
WHEN 'whoop_sport_231' THEN 'tennis'
WHEN 'whoop_sport_232' THEN 'basketball'
WHEN 'whoop_sport_233' THEN 'other'
WHEN 'whoop_sport_234' THEN 'golf'
WHEN 'whoop_sport_235' THEN 'other'
WHEN 'whoop_sport_236' THEN 'other'
WHEN 'whoop_sport_237' THEN 'other'
WHEN 'whoop_sport_238' THEN 'other'
WHEN 'whoop_sport_239' THEN 'other'
WHEN 'whoop_sport_240' THEN 'other'
WHEN 'whoop_sport_248' THEN 'crossTraining'
WHEN 'whoop_sport_249' THEN 'tennis'
WHEN 'whoop_sport_250' THEN 'crossTraining'
WHEN 'whoop_sport_251' THEN 'other'
WHEN 'whoop_sport_252' THEN 'walking'
WHEN 'whoop_sport_253' THEN 'running'
WHEN 'whoop_sport_254' THEN 'other'
WHEN 'whoop_sport_255' THEN 'other'
WHEN 'whoop_sport_258' THEN 'mindAndBody'
WHEN 'whoop_sport_259' THEN 'yoga'
WHEN 'whoop_sport_261' THEN 'stairClimber'
WHEN 'whoop_sport_262' THEN 'other'
WHEN 'whoop_sport_263' THEN 'other'
WHEN 'whoop_sport_264' THEN 'other'
WHEN 'whoop_sport_266' THEN 'walking'
WHEN 'whoop_sport_267' THEN 'other'
WHEN 'whoop_sport_268' THEN 'other'
WHEN 'whoop_sport_269' THEN 'other'
WHEN 'whoop_sport_270' THEN 'other'
WHEN 'whoop_sport_272' THEN 'other'
WHEN 'activity' THEN 'other'
WHEN 'running' THEN 'running'
WHEN 'cycling' THEN 'cycling'
WHEN 'baseball' THEN 'other'
WHEN 'basketball' THEN 'basketball'
WHEN 'rowing' THEN 'rowing'
WHEN 'fencing' THEN 'other'
WHEN 'field_hockey' THEN 'other'
WHEN 'football' THEN 'other'
WHEN 'golf' THEN 'golf'
WHEN 'ice_hockey' THEN 'other'
WHEN 'lacrosse' THEN 'other'
WHEN 'rugby' THEN 'other'
WHEN 'sailing' THEN 'other'
WHEN 'skiing' THEN 'other'
WHEN 'soccer' THEN 'soccer'
WHEN 'softball' THEN 'other'
WHEN 'squash' THEN 'tennis'
WHEN 'swimming' THEN 'swimming'
WHEN 'tennis' THEN 'tennis'
WHEN 'track_field' THEN 'running'
WHEN 'volleyball' THEN 'other'
WHEN 'water_polo' THEN 'swimming'
WHEN 'wrestling' THEN 'strength'
WHEN 'boxing' THEN 'hiit'
WHEN 'dance' THEN 'dance'
WHEN 'pilates' THEN 'mindAndBody'
WHEN 'yoga' THEN 'yoga'
WHEN 'weightlifting' THEN 'strength'
WHEN 'cross_country_skiing' THEN 'other'
WHEN 'functional_fitness' THEN 'crossTraining'
WHEN 'duathlon' THEN 'mixedCardio'
WHEN 'gymnastics' THEN 'crossTraining'
WHEN 'hiking_rucking' THEN 'hiking'
WHEN 'horseback_riding' THEN 'other'
WHEN 'kayaking' THEN 'rowing'
WHEN 'martial_arts' THEN 'hiit'
WHEN 'mountain_biking' THEN 'cycling'
WHEN 'powerlifting' THEN 'strength'
WHEN 'rock_climbing' THEN 'crossTraining'
WHEN 'paddleboarding' THEN 'rowing'
WHEN 'triathlon' THEN 'mixedCardio'
WHEN 'walking' THEN 'walking'
WHEN 'surfing' THEN 'other'
WHEN 'elliptical' THEN 'elliptical'
WHEN 'stairmaster' THEN 'stairClimber'
WHEN 'meditation' THEN 'mindAndBody'
WHEN 'other' THEN 'other'
WHEN 'diving' THEN 'other'
WHEN 'operations_tactical' THEN 'other'
WHEN 'operations_medical' THEN 'other'
WHEN 'operations_flying' THEN 'other'
WHEN 'operations_water' THEN 'other'
WHEN 'ultimate' THEN 'other'
WHEN 'climber' THEN 'stairClimber'
WHEN 'jumping_rope' THEN 'hiit'
WHEN 'australian_football' THEN 'other'
WHEN 'skateboarding' THEN 'other'
WHEN 'coaching' THEN 'other'
WHEN 'ice_bath' THEN 'other'
WHEN 'commuting' THEN 'other'
WHEN 'gaming' THEN 'other'
WHEN 'snowboarding' THEN 'other'
WHEN 'motocross' THEN 'other'
WHEN 'caddying' THEN 'other'
WHEN 'obstacle_course_racing' THEN 'crossTraining'
WHEN 'motor_racing' THEN 'other'
WHEN 'hiit' THEN 'hiit'
WHEN 'spin' THEN 'cycling'
WHEN 'jiu_jitsu' THEN 'hiit'
WHEN 'manual_labor' THEN 'other'
WHEN 'cricket' THEN 'other'
WHEN 'pickleball' THEN 'tennis'
WHEN 'inline_skating' THEN 'other'
WHEN 'box_fitness' THEN 'hiit'
WHEN 'spikeball' THEN 'other'
WHEN 'wheelchair_pushing' THEN 'other'
WHEN 'paddle_tennis' THEN 'tennis'
WHEN 'barre' THEN 'mindAndBody'
WHEN 'stage_performance' THEN 'other'
WHEN 'high_stress_work' THEN 'other'
WHEN 'parkour' THEN 'crossTraining'
WHEN 'gaelic_football' THEN 'other'
WHEN 'hurling_camogie' THEN 'other'
WHEN 'circus_arts' THEN 'other'
WHEN 'massage_therapy' THEN 'other'
WHEN 'strength_trainer' THEN 'strength'
WHEN 'watching_sports' THEN 'other'
WHEN 'assault_bike' THEN 'cycling'
WHEN 'kickboxing' THEN 'hiit'
WHEN 'stretching' THEN 'mindAndBody'
WHEN 'table_tennis' THEN 'tennis'
WHEN 'badminton' THEN 'tennis'
WHEN 'netball' THEN 'basketball'
WHEN 'sauna' THEN 'other'
WHEN 'disc_golf' THEN 'golf'
WHEN 'yard_work' THEN 'other'
WHEN 'air_compression' THEN 'other'
WHEN 'percussive_massage' THEN 'other'
WHEN 'paintball' THEN 'other'
WHEN 'ice_skating' THEN 'other'
WHEN 'handball' THEN 'other'
WHEN 'f45_training' THEN 'crossTraining'
WHEN 'padel' THEN 'tennis'
WHEN 'barry_s' THEN 'crossTraining'
WHEN 'dedicated_parenting' THEN 'other'
WHEN 'stroller_walking' THEN 'walking'
WHEN 'stroller_jogging' THEN 'running'
WHEN 'toddlerwearing' THEN 'other'
WHEN 'babywearing' THEN 'other'
WHEN 'barre3' THEN 'mindAndBody'
WHEN 'hot_yoga' THEN 'yoga'
WHEN 'stadium_steps' THEN 'stairClimber'
WHEN 'polo' THEN 'other'
WHEN 'musical_performance' THEN 'other'
WHEN 'kite_boarding' THEN 'other'
WHEN 'dog_walking' THEN 'walking'
WHEN 'water_skiing' THEN 'other'
WHEN 'wakeboarding' THEN 'other'
WHEN 'cooking' THEN 'other'
WHEN 'cleaning' THEN 'other'
WHEN 'public_speaking' THEN 'other'
ELSE 'other'
END
WHERE "source" = 'WHOOP'
AND "sport_type" NOT IN (
'walking', 'running', 'cycling', 'hiking', 'swimming', 'rowing',
'elliptical', 'stairClimber', 'yoga', 'mindAndBody', 'strength', 'hiit',
'dance', 'golf', 'tennis', 'basketball', 'soccer', 'crossTraining',
'mixedCardio', 'other'
);
@@ -0,0 +1,30 @@
-- Server-side "dismiss / mark seen" ledger for the Today rail's OBSERVATIONAL
-- PriorityItem kinds (milestone, ecg_new_recording, tension_window). The
-- ACTIONABLE kinds (dose_window, sync_issue, preventive_care, coach_checkin)
-- never write a row here — they clear on their own once the user acts.
--
-- `item_key` is a deterministic identity for the candidate INSTANCE the
-- digest builder already computes (kind + metric + reach day for a
-- milestone, the recording's own timestamp for an ECG, the local day for a
-- tension window) — not the bare kind — so dismissing today's milestone never
-- suppresses tomorrow's.
-- CreateTable
CREATE TABLE "dismissed_priority_items" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"item_key" TEXT NOT NULL,
"dismissed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "dismissed_priority_items_pkey" PRIMARY KEY ("id")
);
-- CreateIndex — the dismiss upsert's natural key + the digest read's
-- `userId + itemKey IN (...)` lookup.
CREATE UNIQUE INDEX "dismissed_priority_items_user_id_item_key_key"
ON "dismissed_priority_items" ("user_id", "item_key");
-- AddForeignKey — deleting a user cascades its dismissal rows away.
ALTER TABLE "dismissed_priority_items"
ADD CONSTRAINT "dismissed_priority_items_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users" ("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,19 @@
-- v1.29 — `source` joins the `nutrient_intake_days` primary key so a
-- manual water entry and the Apple-synced day total coexist as two rows
-- instead of the manual write being silently clobbered by the next
-- Apple last-writer-wins sync (the batch route always upserts the
-- (userId, day, nutrient) row with the CURRENT running total, so a
-- manual increment folded into that same row would vanish on the next
-- sync — a data-loss bug by construction, not a rare race).
--
-- No data rewrite: every existing row already carries
-- `source = 'APPLE_HEALTH'` (the column default since migration 0241),
-- so dropping and recreating the PRIMARY KEY over the four columns is a
-- pure constraint change — same rows, same values, wider key. The
-- existing `(user_id, nutrient, day DESC)` index is untouched; it does
-- not reference `source` and continues to serve the window-scan reads.
ALTER TABLE "nutrient_intake_days" DROP CONSTRAINT "nutrient_intake_days_pkey";
ALTER TABLE "nutrient_intake_days"
ADD CONSTRAINT "nutrient_intake_days_pkey"
PRIMARY KEY ("user_id", "day", "nutrient", "source");
@@ -0,0 +1,23 @@
-- Persist a short plain-language summary of an uploaded document.
--
-- Adds two nullable columns to `inbound_documents`:
-- - `summary_encrypted` — AES-256-GCM ciphertext of a 3-4 sentence
-- plain-language description of WHAT the document is, generated ONCE in
-- the background right after upload when the user's `documents_auto_ai_read`
-- opt-in is ON (that flag is also the standing AI-egress consent). NULL
-- when auto-read is OFF, no provider is configured, or generation has not
-- run yet.
-- - `summary_generated_at` — when that background summary was generated
-- (NULL until it runs).
--
-- STORAGE CONVENTION: Bytes (`bytea`), matching the dominant free-text
-- encrypted-note convention already in the schema (the `encrypt()` ciphertext
-- string stored UTF-8 as `bytea`, the shape the rotation script's
-- `rotateBytesColumn` already covers). The column is registered in
-- ENCRYPTED_COLUMNS + the rotation script in the same change.
--
-- ADDITIVE ONLY: existing rows keep NULL summaries; the on-demand transient
-- summary route is unchanged.
ALTER TABLE "inbound_documents"
ADD COLUMN IF NOT EXISTS "summary_encrypted" BYTEA,
ADD COLUMN IF NOT EXISTS "summary_generated_at" TIMESTAMP(3);
@@ -0,0 +1,103 @@
-- v1.29.x — backfill non-canonical Strava `Workout.sport_type` values.
--
-- Root cause (the same class the 0247 WHOOP migration fixed): Strava was the
-- second workout integration that never normalised its sport to HealthLog's
-- canonical `WorkoutSportType` enum (see `workoutSportTypeEnum` in
-- src/lib/validations/workout.ts). `mapActivity()` in
-- src/lib/strava/client.ts wrote Strava's raw PascalCase `sport_type` (or
-- the legacy `type`, e.g. "Run", "Ride", "VirtualRide", "TrailRun") — or the
-- literal fallback "workout" when neither field was present — straight into
-- `Workout.sport_type`. Existing Strava rows therefore carry values the
-- `/insights/workouts` list can't map to an icon/label, AND the read-time
-- canonical picker in src/lib/sources/pick-canonical-workout.ts treats any
-- two non-canonical ("generic") rows as sport-compatible — a same-source
-- brick session (a Strava "Ride" ending 15:00 immediately followed by a
-- Strava "Run" starting 15:03) could cluster into one and silently drop a
-- leg. `mapStravaSportType()` (src/lib/strava/sport-map.ts) fixes the write
-- path going forward; this migration re-derives every EXISTING Strava
-- `Workout` row from the same table so historical data and the live mapper
-- never drift apart.
--
-- The WHEN arms below were generated FROM `STRAVA_SPORT_TABLE` in
-- src/lib/strava/sport-map.ts (one JS-side script run against that exact
-- table, not hand-transcribed). The match expression lower-cases the stored
-- column and strips every character outside `[a-zA-Z0-9]`, mirroring
-- `normaliseStravaSportKey()` in sport-map.ts exactly, so "TrailRun",
-- "trailrun", and any stray-separator variant all resolve to the same key —
-- and so the pre-fix literal fallback "workout" resolves to the same row as
-- Strava's own "Workout" catch-all.
--
-- Anything neither arm recognises falls to `ELSE 'other'` — same fallback
-- `mapStravaSportType()` uses, never leaves a row on a raw Strava label.
--
-- Idempotent: the WHERE clause excludes rows whose `sport_type` is ALREADY
-- one of the 20 canonical values, so a re-run touches zero rows once the
-- first run has completed. Safe to re-run after a partial failure.
UPDATE "workouts"
SET "sport_type" = CASE
lower(regexp_replace(trim("sport_type"), '[^a-zA-Z0-9]+', '', 'g'))
WHEN 'alpineski' THEN 'other'
WHEN 'backcountryski' THEN 'other'
WHEN 'badminton' THEN 'tennis'
WHEN 'basketball' THEN 'basketball'
WHEN 'canoeing' THEN 'rowing'
WHEN 'cricket' THEN 'other'
WHEN 'crossfit' THEN 'crossTraining'
WHEN 'dance' THEN 'dance'
WHEN 'ebikeride' THEN 'cycling'
WHEN 'elliptical' THEN 'elliptical'
WHEN 'emountainbikeride' THEN 'cycling'
WHEN 'golf' THEN 'golf'
WHEN 'gravelride' THEN 'cycling'
WHEN 'handcycle' THEN 'cycling'
WHEN 'highintensityintervaltraining' THEN 'hiit'
WHEN 'hike' THEN 'hiking'
WHEN 'iceskate' THEN 'other'
WHEN 'inlineskate' THEN 'other'
WHEN 'kayaking' THEN 'rowing'
WHEN 'kitesurf' THEN 'other'
WHEN 'mountainbikeride' THEN 'cycling'
WHEN 'nordicski' THEN 'other'
WHEN 'padel' THEN 'tennis'
WHEN 'physicaltherapy' THEN 'mindAndBody'
WHEN 'pickleball' THEN 'tennis'
WHEN 'pilates' THEN 'mindAndBody'
WHEN 'racquetball' THEN 'tennis'
WHEN 'ride' THEN 'cycling'
WHEN 'rockclimbing' THEN 'crossTraining'
WHEN 'rollerski' THEN 'other'
WHEN 'rowing' THEN 'rowing'
WHEN 'run' THEN 'running'
WHEN 'sail' THEN 'other'
WHEN 'skateboard' THEN 'other'
WHEN 'snowboard' THEN 'other'
WHEN 'snowshoe' THEN 'hiking'
WHEN 'soccer' THEN 'soccer'
WHEN 'squash' THEN 'tennis'
WHEN 'stairstepper' THEN 'stairClimber'
WHEN 'standuppaddling' THEN 'rowing'
WHEN 'surfing' THEN 'other'
WHEN 'swim' THEN 'swimming'
WHEN 'tabletennis' THEN 'tennis'
WHEN 'tennis' THEN 'tennis'
WHEN 'trailrun' THEN 'running'
WHEN 'velomobile' THEN 'cycling'
WHEN 'virtualride' THEN 'cycling'
WHEN 'virtualrow' THEN 'rowing'
WHEN 'virtualrun' THEN 'running'
WHEN 'volleyball' THEN 'other'
WHEN 'walk' THEN 'walking'
WHEN 'weighttraining' THEN 'strength'
WHEN 'wheelchair' THEN 'other'
WHEN 'windsurf' THEN 'other'
WHEN 'workout' THEN 'other'
WHEN 'yoga' THEN 'yoga'
ELSE 'other'
END
WHERE "source" = 'STRAVA'
AND "sport_type" NOT IN (
'walking', 'running', 'cycling', 'hiking', 'swimming', 'rowing',
'elliptical', 'stairClimber', 'yoga', 'mindAndBody', 'strength', 'hiit',
'dance', 'golf', 'tennis', 'basketball', 'soccer', 'crossTraining',
'mixedCardio', 'other'
);
@@ -0,0 +1,48 @@
-- Native OIDC SSO session handoff (iOS#49).
--
-- One-time handoff codes bridging the browser-context OIDC callback to the
-- cookie-less native token exchange. The web OIDC flow ends in a session
-- cookie a Bearer-transport native client can never consume; this table lets
-- the callback mint an opaque, PKCE-locked, single-use code that the app
-- exchanges for the standard native token bundle at
-- POST /api/auth/oidc/native/token.
--
-- The raw code (`hlh_<…>`) rides ONLY the custom-scheme redirect; only its
-- HMAC-SHA256 hash is stored, mirroring the refresh-token / MFA-challenge
-- hash-at-rest posture. `issued_refresh_token_hash` records the pair minted at
-- exchange so a replay (a second presentation after consumption) can revoke
-- exactly that family member — refresh-token reuse-detection reach-back.
-- CreateTable
CREATE TABLE "oidc_native_handoffs" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"code_hash" TEXT NOT NULL,
"code_challenge" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"consumed_at" TIMESTAMP(3),
"issued_refresh_token_hash" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ip_address" TEXT,
"user_agent" TEXT,
CONSTRAINT "oidc_native_handoffs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex — the exchange loads a row by the code's hash; unique so a hash
-- collision can never resolve to two live codes.
CREATE UNIQUE INDEX "oidc_native_handoffs_code_hash_key"
ON "oidc_native_handoffs" ("code_hash");
-- CreateIndex — per-user audit scans + the FK-cascade delete path.
CREATE INDEX "oidc_native_handoffs_user_id_idx"
ON "oidc_native_handoffs" ("user_id");
-- CreateIndex — the daily cleanup sweep filters on `expires_at`.
CREATE INDEX "oidc_native_handoffs_expires_at_idx"
ON "oidc_native_handoffs" ("expires_at");
-- AddForeignKey — deleting a user cascades their handoff rows away.
ALTER TABLE "oidc_native_handoffs"
ADD CONSTRAINT "oidc_native_handoffs_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users" ("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,11 @@
-- v1.30.12 perf — index the two medication-intake read shapes the dashboard
-- "meds today" builder fires that could not index-seek before: the sibling
-- reads filtering (user_id, scheduled_for) without medication_id, and the
-- per-medication last-taken group-by that scanned the whole intake history.
-- Additive; no data change.
-- CreateIndex
CREATE INDEX "medication_intake_events_user_id_scheduled_for_idx" ON "medication_intake_events"("user_id", "scheduled_for");
-- CreateIndex
CREATE INDEX "medication_intake_events_user_id_medication_id_taken_at_idx" ON "medication_intake_events"("user_id", "medication_id", "taken_at");
+257 -40
View File
@@ -42,6 +42,13 @@ model User {
email String? @unique
passwordHash String? @map("password_hash") // argon2id, optional fallback
role String @default("USER") // "ADMIN" | "USER"
// OIDC SSO — the (issuer, sub) pair pins the account to exactly one IdP
// identity. Stamped on first OIDC login (auto-provision or one-time link
// by verified email); every later OIDC login matches on this pair, never
// on the email, so an IdP-side email change can update the display email
// but can never re-point the login at a different account.
oidcIssuer String? @map("oidc_issuer")
oidcSub String? @map("oidc_sub")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -104,6 +111,15 @@ model User {
// without re-paying for the findings. This stamps the day a re-roll ran so
// the gate stays at most one extra call per day.
insightsBriefingRerollDate String? @map("insights_briefing_reroll_date")
// Morning-freshness marker: the user-local YYYY-MM-DD (their profile
// timezone) for which the event-driven morning refresh has run — i.e. last
// night's sleep has arrived AND the sleep-dependent insight/score cache was
// regenerated with it. The daily digest reads `final` when this equals the
// user's current local date, `provisional` otherwise. Stamped by the
// `morning-digest-refresh` job the sleep-arrival trigger enqueues; the
// string form makes the "same local morning" comparison timezone-honest
// without a second timestamp read.
morningDigestRefreshedOn String? @map("morning_digest_refreshed_on")
// v1.4.36 W3 T3 — per-user AI Insights exclude-metrics list, mirrors
// `coachPrefsJson.excludeMetrics`. The /api/insights/generate route
// filters matching keys off `features` before JSON.stringify so the
@@ -651,6 +667,9 @@ model User {
// authorization for an MCP client, audience-bound to health:read/health:write.
mcpOAuthConnections McpOAuthConnection[]
refreshTokens RefreshToken[]
// v1.30.x — one-time native OIDC SSO handoff codes. Each row bridges the
// browser-context OIDC callback to the cookie-less native token exchange.
oidcNativeHandoffs OidcNativeHandoff[]
auditLogs AuditLog[]
withingsConnection WithingsConnection?
// v1.4.47 W6 — short-lived `(nonce → userId)` ledger backing the
@@ -703,6 +722,8 @@ model User {
// ships with v1.4.25 so the iOS app can build its query path
// against a stable contract.
personalRecords PersonalRecord[]
// Today rail dismiss ledger — observational PriorityItem kinds only.
dismissedPriorityItems DismissedPriorityItem[]
// v1.4.25 W19b — per-pen / per-vial inventory entities, the
// companion to the running-sum MedicationInventoryEvent ledger.
// Carries the EMA §6.3 30-day in-use clock per opened pen.
@@ -805,6 +826,10 @@ model User {
// that win over the home location for the days they cover.
environmentContexts EnvironmentContext[]
environmentTravelLocations EnvironmentTravelLocation[]
// v1.28 — micronutrient intake daily totals synced from Apple Health
// (vitamins, minerals, water, caffeine). Opt-in `nutrients` module, off
// by default; the ingest route refuses while the module is off.
nutrientIntakeDays NutrientIntakeDay[]
// v1.25 (W-DOCS-IN) — inbound clinical documents (doctor reports /
// discharge letters) the user uploads. The raw document is encrypted at
// rest; extracted facts land in a review-then-confirm staging area
@@ -867,6 +892,10 @@ model User {
// v1.16.0 — per-signup invite redemption ledger (multi-use history).
inviteRedemptions InviteRedemption[] @relation("InviteRedemptions")
// One account per IdP identity — the (issuer, sub) pair is the durable
// OIDC key (both NULL on accounts that never signed in via SSO; Postgres
// unique indexes ignore NULL pairs).
@@unique([oidcIssuer, oidcSub])
@@map("users")
}
@@ -1692,6 +1721,11 @@ model Measurement {
// and series reads for every type NOT covered by the two partials above
// and removes the `deleted_at` heap re-check. Same Prisma-can't-express-a-
// partial-predicate reason it is omitted here.
// `measurements_note_backfill_idx` is a partial index over `(user_id)
// WHERE notes IS NOT NULL AND notes_encrypted IS NULL` — see migration 0243
// (v1.28.46 H4). It makes the note-encryption boot-discovery `SELECT DISTINCT
// user_id` an index-only scan that converges to empty. Same partial-predicate
// reason it is omitted here.
@@map("measurements")
}
@@ -2071,6 +2105,34 @@ model PersonalRecord {
@@map("personal_records")
}
/// The Today rail's server-side "dismiss / mark seen" ledger — OBSERVATIONAL
/// `PriorityItem` kinds only (`milestone`, `ecg_new_recording`,
/// `tension_window`). The ACTIONABLE kinds (dose_window, sync_issue,
/// preventive_care, coach_checkin) never write a row here — they clear on
/// their own once the user acts, so a dismiss affordance on them would just
/// duplicate the existing resolution path.
///
/// `itemKey` is a deterministic identity for the candidate INSTANCE the
/// digest builder already computes (kind + metric + reach day for a
/// milestone, the recording's own timestamp for an ECG, the local day for a
/// tension window) — not the bare `kind` — so dismissing today's milestone
/// never suppresses tomorrow's; the underlying item's own freshness window
/// (reached-once / 24h / one-per-day) is what actually retires the row's
/// relevance, this table only fast-forwards that for the rest of today.
model DismissedPriorityItem {
id String @id @default(cuid())
userId String @map("user_id")
itemKey String @map("item_key")
dismissedAt DateTime @default(now()) @map("dismissed_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// The dismiss upsert's natural key + the digest read's
/// `userId + itemKey IN (...)` lookup.
@@unique([userId, itemKey])
@@map("dismissed_priority_items")
}
// ─── Medications ───────────────────────────────────────────
/// Treatment-class discriminator (v1.4.25 W4d). GLP1 turns on the
@@ -2671,6 +2733,12 @@ model MedicationIntakeEvent {
// v1.7.0 sync — keyset support for the `/api/sync/changes` intake feed
// (`where userId` ordered by `(updatedAt asc, id asc)`).
@@index([userId, updatedAt, id])
// v1.30.12 perf — the dashboard "meds today" read fires sibling queries that
// filter `(userId, scheduledFor)` WITHOUT `medicationId` (the composite above
// cannot range-seek those) and a per-medication `_max(takenAt)` group-by that
// scanned the whole intake history. These two let both index-seek instead.
@@index([userId, scheduledFor])
@@index([userId, medicationId, takenAt])
@@map("medication_intake_events")
}
@@ -2704,6 +2772,11 @@ model MedicationDoseChange {
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
@@index([medicationId, effectiveFrom])
// `medication_dose_changes_note_backfill_idx` is a partial index over
// `(medication_id) WHERE note IS NOT NULL AND note_encrypted IS NULL` — see
// migration 0243 (v1.28.46 H4). The med-notes boot-discovery joins dose-
// changes to medications on medication_id to reach the owner; the index
// leads with that join key. Raw SQL only (partial predicate).
@@map("medication_dose_changes")
}
@@ -2802,6 +2875,10 @@ model MedicationInventoryItem {
@@index([userId, medicationId, state])
@@index([userId, expiresAt])
// `medication_inventory_items_note_backfill_idx` is a partial index over
// `(user_id) WHERE notes IS NOT NULL AND notes_encrypted IS NULL` — see
// migration 0243 (v1.28.46 H4) for the med-notes boot-discovery scan. Raw
// SQL only (partial predicate not expressible in Prisma).
@@map("medication_inventory_items")
}
@@ -2861,6 +2938,10 @@ model MedicationSideEffect {
@@index([userId, medicationId, occurredAt])
@@index([userId, occurredAt])
// `medication_side_effects_note_backfill_idx` is a partial index over
// `(user_id) WHERE notes IS NOT NULL AND notes_encrypted IS NULL` — see
// migration 0243 (v1.28.46 H4) for the med-notes boot-discovery scan. Raw
// SQL only (partial predicate not expressible in Prisma).
@@map("medication_side_effects")
}
@@ -3142,6 +3223,37 @@ model RefreshToken {
@@map("refresh_tokens")
}
// v1.30.x — one-time native OIDC handoff code. Bridges the browser-context
// OIDC callback to the cookie-less native token exchange. The raw value
// `hlh_<…>` rides ONLY the custom-scheme redirect (healthlog://oidc-callback);
// only its HMAC-SHA256 hash (hashToken, keyed by API_TOKEN_HMAC_KEY) is stored,
// so a database dump reconstructs nothing. Single-use via the guarded consume
// (`WHERE consumed_at IS NULL`); a second presentation after consumption revokes
// the token pair the first exchange issued (refresh-token reuse-detection
// posture). 90-second TTL enforced at read; a daily sweep prunes stale rows.
model OidcNativeHandoff {
id String @id @default(cuid())
userId String @map("user_id")
codeHash String @unique @map("code_hash") // hashToken(hlh_<…>)
/// The app's S256 PKCE challenge, bound at mint; the exchange verifies
/// s256(code_verifier) against it (constant-time) before consuming.
codeChallenge String @map("code_challenge")
expiresAt DateTime @map("expires_at")
consumedAt DateTime? @map("consumed_at")
/// hashToken of the refresh token issued at exchange — replay containment
/// revokes exactly this family member (+ its paired access token).
issuedRefreshTokenHash String? @map("issued_refresh_token_hash")
createdAt DateTime @default(now()) @map("created_at")
ipAddress String? @map("ip_address")
userAgent String? @map("user_agent")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([expiresAt])
@@map("oidc_native_handoffs")
}
// ─── Withings ──────────────────────────────────────────────
model WithingsConnection {
@@ -3524,6 +3636,10 @@ model MoodEntry {
// v1.7.0 sync — keyset support for the `/api/sync/changes` mood feed
// (`where userId` ordered by `(updatedAt asc, id asc)`).
@@index([userId, updatedAt, id])
// `mood_entries_note_backfill_idx` is a partial index over `(user_id)
// WHERE note IS NOT NULL AND note_encrypted IS NULL` — see migration 0243
// (v1.28.46 H4), serving the note-encryption boot-discovery scan. Raw SQL
// only (partial predicate not expressible in Prisma).
@@map("mood_entries")
}
@@ -4074,6 +4190,12 @@ model ImportJob {
exportedAt DateTime? @map("exported_at")
startedAt DateTime @default(now()) @map("started_at")
completedAt DateTime? @map("completed_at")
/// Bumped on every write, including each mid-run progress tick. Acts
/// as a liveness heartbeat: `reconcileOrphanImportJobs` treats a
/// non-terminal row whose heartbeat has gone stale as orphaned, so a
/// job still actively parsing in another worker (fresh heartbeat) is
/// never flipped to `failed` (issue #486 follow-up).
updatedAt DateTime @updatedAt @map("updated_at")
/// JSON envelope mirroring `ImportJobProgress` (`src/lib/measurements/
/// import-apple-health-export.ts`). Updated mid-run.
progress Json @default("{}")
@@ -4352,6 +4474,10 @@ model LabResult {
@@index([userId, analyte, takenAt])
@@index([userId, deletedAt])
@@index([biomarkerId])
// `lab_results_biomarker_backfill_idx` is a partial index over `(user_id)
// WHERE deleted_at IS NULL AND biomarker_id IS NULL` — see migration 0243
// (v1.28.46 H4), serving the lab-biomarker boot-discovery `SELECT DISTINCT
// user_id`. Raw SQL only (partial predicate not expressible in Prisma).
@@map("lab_results")
}
@@ -4601,27 +4727,53 @@ model CoachConversation {
summaryUpdatedAt DateTime? @map("summary_updated_at")
summaryTurnCount Int @default(0) @map("summary_turn_count")
// v1.27.33 (Document vault P4 — chat about a document) — the discriminator
// that turns a Coach conversation into a DOCUMENT chat. NULL = a normal Coach
// conversation (health-record surface, snapshot + tools). Non-NULL = a chat
// scoped to exactly one stored document: its only context is that document's
// text, no health snapshot, no tools. The two never mix — the Coach rail
// filters `documentId: null` and the document sheet filters `documentId: <id>`.
// Cascades on document delete so a discarded document takes its chats with it.
documentId String? @map("document_id")
// v1.29.x (S7 — attach multiple documents to a coach conversation) — PERMANENT
// fence marker. Set true when the conversation is created via the fenced
// endpoint or when the FIRST document is attached. NO code path ever sets it
// back to false — detaching every document, or a document being deleted,
// leaves it true. A conversation whose history may contain document-derived
// text must never regain the tool loop. The LIVE attachment set lives in the
// `CoachConversationDocument` join table; this sticky flag is the fail-closed
// half of the fenced-only discriminator (see the dual predicate on the tool
// route). Replaces the pre-S7 scalar `documentId` discriminator, which could
// only express one document and would UNFENCE on that document's delete.
documentScoped Boolean @default(false) @map("document_scoped")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
document InboundDocument? @relation(fields: [documentId], references: [id], onDelete: Cascade)
messages CoachMessage[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
messages CoachMessage[]
attachments CoachConversationDocument[]
@@index([userId, updatedAt])
// v1.27.33 — the document sheet lists a document's chats newest-first; the
// Coach rail lists `documentId IS NULL` newest-first. This composite covers
// both scans without a separate index per surface.
@@index([userId, documentId, updatedAt])
// v1.29.x (S7) — the rail lists health threads + fenced threads newest-first
// and the tool-route send path filters on the flag. Replaces the retired
// [userId, documentId, updatedAt] composite.
@@index([userId, documentScoped, updatedAt])
@@map("coach_conversations")
}
/// v1.29.x (S7) — a document attached to a coach conversation. The EXISTENCE of
/// any row for a conversation is one half of the fenced-only discriminator (the
/// other is `CoachConversation.documentScoped`). Composite PK so the SQL
/// backfill needs no client-side id generation and duplicate attaches are
/// impossible by construction. Two cascades:
/// - from DOCUMENT: deleting a vault document removes its join rows (its text
/// leaves every conversation's context) but NEVER the conversation — a
/// fenced thread survives via the sticky flag.
/// - from CONVERSATION: deleting a chat removes its attachment rows, never the
/// documents.
model CoachConversationDocument {
conversationId String @map("conversation_id")
documentId String @map("document_id")
addedAt DateTime @default(now()) @map("added_at")
conversation CoachConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
@@id([conversationId, documentId])
@@index([documentId]) // document-sheet "chats about this doc" scan + path-id join filter
@@map("coach_conversation_documents")
}
// One message in a Coach conversation. `role` is "user" or "assistant"
// — same shape as the OpenAI/Anthropic chat-completion APIs.
//
@@ -5074,7 +5226,8 @@ model InsightNarrative {
userId String @map("user_id")
/// "week" | "month".
period String
/// Resolved UI locale of the prose ("de" | "en").
/// Resolved UI locale of the prose — any shipped UI locale, not just de/en:
/// the prose is generated per locale, so the row is keyed by it.
locale String
/// UTC YYYY-MM-DD boundary the narrative was generated for.
dateKey String @map("date_key")
@@ -5949,9 +6102,9 @@ enum ExtractedFactStatus {
}
model InboundDocument {
id String @id @default(cuid())
userId String @map("user_id")
kind InboundDocumentKind @default(OTHER)
id String @id @default(cuid())
userId String @map("user_id")
kind InboundDocumentKind @default(OTHER)
/// User-given label. Deliberately plaintext (like `filename`) so the library
/// list can ILIKE-search + ORDER BY in SQL. The document BODY stays
/// encrypted; only this short, user-authored title is clear-text. Tradeoff:
@@ -5959,18 +6112,18 @@ model InboundDocument {
/// cleartext at rest / in backups — the same exposure the pre-existing
/// plaintext `filename` already carries. Accepted to keep server-side search
/// and sort.
title String?
title String?
/// Original filename as uploaded (sanitised, optional).
filename String?
filename String?
/// Magic-byte-sniffed MIME type the document was stored as.
mimeType String @map("mime_type")
mimeType String @map("mime_type")
/// Stored byte length of the original (pre-encryption) document.
byteSize Int @map("byte_size")
byteSize Int @map("byte_size")
/// AES-256-GCM ciphertext of the raw document bytes (base64 of the binary
/// → `encrypt()` string → UTF-8 BYTEA), the `*_encrypted` Bytes convention
/// shared with the note columns. The original is sensitive and never
/// stored in the clear or logged.
contentEncrypted Bytes @map("content_encrypted")
contentEncrypted Bytes @map("content_encrypted")
/// Lowercase-hex SHA-256 of the PLAINTEXT document bytes, computed at
/// upload for same-user duplicate detection. Nullable: rows stored before
/// the vault release are not backfilled (only new uploads dedupe). A
@@ -5979,47 +6132,59 @@ model InboundDocument {
/// CREATE UNIQUE INDEX inbound_documents_user_sha_live
/// ON inbound_documents (user_id, content_sha256)
/// WHERE deleted_at IS NULL AND content_sha256 IS NOT NULL;
contentSha256 String? @map("content_sha256") @db.VarChar(64)
contentSha256 String? @map("content_sha256") @db.VarChar(64)
/// Storage codec of `contentEncrypted`. "base64v1" = legacy string path
/// (base64-of-binary → `encrypt()` string → UTF-8 bytes); "binary2" = the
/// binary AES-256-GCM layout written by `encryptBytes()` (no base64
/// detour). Explicit column, never header-sniffed; the default keeps every
/// pre-existing row correct.
contentCodec String @default("base64v1") @map("content_codec")
status InboundDocumentStatus @default(STORED)
contentCodec String @default("base64v1") @map("content_codec")
status InboundDocumentStatus @default(STORED)
/// Provider type that ran the extraction (e.g. "anthropic", "openai",
/// "local"). Diagnostic only. Null on a store-only document.
providerType String? @map("provider_type")
providerType String? @map("provider_type")
/// The document's stated report/collection date (YYYY-MM-DD at UTC), as
/// transcribed by the model — never inferred, never user-set.
reportDate DateTime? @map("report_date")
reportDate DateTime? @map("report_date")
/// Effective filing date the library sorts, groups, and range-filters on
/// (editable). Distinct from `reportDate`, which is the model-transcribed
/// value. On store it defaults to the upload day (now) when the user does
/// not supply one, so a stored row's filing date is never NULL and display
/// == sort == filter. It stays user-editable and MAY be cleared back to
/// NULL via PATCH (the list then sorts such rows last).
documentDate DateTime? @map("document_date")
documentDate DateTime? @map("document_date")
/// Failure reason when status = FAILED (a short code, never the document).
errorReason String? @map("error_reason")
errorReason String? @map("error_reason")
/// AES-256-GCM ciphertext of a short (3-4 sentence) plain-language summary
/// of WHAT the document is, generated ONCE in the background right after
/// upload when the user's `documentsAutoAiRead` opt-in is ON (that flag is
/// also the standing AI-egress consent). Absent when auto-read is OFF, no
/// provider is configured, or generation has not run yet. Descriptive only —
/// never a diagnosis. `encrypt()`-string-as-UTF-8 BYTEA, the shared Bytes
/// codec every other note column uses; PHI, never logged.
summaryEncrypted Bytes? @map("summary_encrypted")
/// When the background summary was generated (null until it runs). Surfaced
/// as meta beside the summary in the document detail view.
summaryGeneratedAt DateTime? @map("summary_generated_at")
facts ExtractedFact[]
conditionLinks DocumentConditionLink[]
facts ExtractedFact[]
conditionLinks DocumentConditionLink[]
/// 1:1 blind content-search index (encrypted extracted text + HMAC token
/// array). Absent until the document is indexed; cascades on delete.
contentIndex DocumentContentIndex?
contentIndex DocumentContentIndex?
/// 1:1 encrypted preview thumbnail (small JPEG, AES-256-GCM at rest).
/// Absent until the background thumbnail job runs; a side table (not a
/// column) so the list/detail `omit: { contentEncrypted }` queries never
/// drag the blob into the SELECT. Cascades on delete.
thumbnail DocumentThumbnail?
/// v1.27.33 (Document vault P4) — "chat about a document" threads. Each is a
/// `CoachConversation` with `documentId` set to this document; cascades on
/// delete so a discarded document takes its chats with it.
chats CoachConversation[]
thumbnail DocumentThumbnail?
/// v1.29.x (S7) — coach-conversation attachments (m:n via
/// `CoachConversationDocument`). Deleting this document cascades its join rows
/// away (its text leaves every conversation's context) but never the
/// conversation itself — a fenced thread survives via its sticky flag.
chatAttachments CoachConversationDocument[]
/// share-link memberships (m:n via `ClinicianShareLinkDocument`).
/// Cascades: deleting this document removes it from every share it was on.
shareLinks ClinicianShareLinkDocument[]
shareLinks ClinicianShareLinkDocument[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -6034,6 +6199,11 @@ model InboundDocument {
// a superset of the plain `(userId, deletedAt)` index, which is therefore
// redundant for read planning and dropped.
@@index([userId, deletedAt, documentDate])
// `inbound_documents_thumbnail_backfill_idx` is a partial index over
// `(user_id, mime_type) WHERE deleted_at IS NULL` — see migration 0243
// (v1.28.46 H4). It bounds the document-side scan of the thumbnail
// boot-discovery (which anti-joins document_thumbnails for the missing
// preview). Raw SQL only (partial predicate not expressible in Prisma).
@@map("inbound_documents")
}
@@ -6201,3 +6371,50 @@ model DocumentThumbnail {
@@index([userId])
@@map("document_thumbnails")
}
// ─── Nutrient intake (v1.28) ───────────────────────────────────────
//
// Micronutrient daily totals synced from Apple Health — vitamins,
// minerals, water, caffeine as supplement-style "substances taken",
// never a food diary (energy / macros are out of scope by directive).
// One row per (user, local day, nutrient); the composite PK IS the
// idempotency key, so an iOS re-post of a grown day total replaces the
// row in place (last-writer-wins day-total contract — the `stats:`
// overwrite semantics without externalId string parsing). `nutrient`
// is a bare string enforced in code against the closed catalog in
// `src/lib/nutrients/catalog.ts` (the IntegrationStatus precedent: no
// migration to add a code). No soft delete: rows are source-owned day
// totals; replace-on-repost is the correction mechanism and account
// deletion cascades. Behind the opt-in `nutrients` module
// (refuse-ingest-when-off).
model NutrientIntakeDay {
userId String @map("user_id")
/// YYYY-MM-DD anchored to the user's IANA timezone (iOS-computed —
/// the same day-key contract as the `stats:` externalIds).
day String
/// Closed catalog code ("vitamin_d", "magnesium", "water", ...).
nutrient String
/// Daily total in the catalog's canonical unit.
amount Float
/// Canonical unit echoed from the catalog ("mg" | "ug" | "ml") —
/// denormalised on purpose: rows stay self-describing in exports
/// even if the catalog ever drifts.
unit String
/// "APPLE_HEALTH" | "MANUAL" (closed set enforced in app code, not a
/// DB enum). v1.29 — joined the composite PK (migration 0249) so a
/// manual water entry and the Apple-synced day total coexist as two
/// rows instead of the manual write being silently clobbered by the
/// next Apple last-writer-wins sync. Apple owns its row (LWW on
/// re-post); manual owns its row (increment/set via
/// `POST /api/nutrients/water`); reads SUM across sources per day.
source String @default("APPLE_HEALTH")
externalSourceVersion String? @map("external_source_version")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([userId, day, nutrient, source])
@@index([userId, nutrient, day(sort: Desc)])
@@map("nutrient_intake_days")
}
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.28.23";
/* @sw-version-fallback */ "v1.30.15";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
+52 -12
View File
@@ -37,17 +37,9 @@
},
{
"name": "Withings OAuth",
"description": "Required to enable the Withings integration. App boots without these but Withings sync stays disabled.",
"description": "Withings API credentials are entered per user in Settings → Integrations (no env-var path). Only the webhook shared secret is operator-level.",
"required": false,
"variables": [
{
"name": "WITHINGS_CLIENT_ID",
"purpose": "Withings OAuth client_id from https://developer.withings.com."
},
{
"name": "WITHINGS_CLIENT_SECRET",
"purpose": "Withings OAuth client_secret."
},
{
"name": "WITHINGS_WEBHOOK_SECRET",
"purpose": "Random secret carried as a path segment in the Withings webhook callback URL. Generate with `openssl rand -hex 32`."
@@ -86,6 +78,45 @@
}
]
},
{
"name": "OIDC SSO login",
"description": "Required to enable SSO login against a self-hosted identity provider (Authentik, Keycloak, Authelia, Google Workspace, etc.). App boots without these but the login page's SSO button stays hidden. Register a confidential client with redirect URI ${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback.",
"required": false,
"allOrNone": true,
"variables": [
{
"name": "OIDC_ISSUER_URL",
"purpose": "OIDC provider issuer URL (discovery doc served at <issuer>/.well-known/openid-configuration)."
},
{
"name": "OIDC_CLIENT_ID",
"purpose": "OIDC confidential client id."
},
{
"name": "OIDC_CLIENT_SECRET",
"purpose": "OIDC confidential client secret."
}
]
},
{
"name": "OIDC SSO options",
"description": "Optional tuning for an enabled OIDC provider. Each stands alone (deliberately NOT allOrNone — any subset is valid) and each has a safe default when unset.",
"required": false,
"variables": [
{
"name": "OIDC_SCOPES",
"purpose": "Space-separated scopes requested at authorization. Default: \"openid email profile\"."
},
{
"name": "OIDC_BUTTON_LABEL",
"purpose": "Label on the login page's SSO button. Default: \"Single Sign-On\"."
},
{
"name": "OIDC_ONLY",
"purpose": "\"true\" disables password/passkey login server-side (only effective when the OIDC provider group is fully configured). Default: additive login. Locks the native iOS app out of sign-in."
}
]
},
{
"name": "APNs (iOS push)",
"description": "All four core APNS_* vars together. Missing any disables APNs silently — the v1.4.40 AP-2 gap that motivated this manifest. allOrNone trips the synthetic <all-or-none> required row whenever 1-3 of the 4 are set.",
@@ -167,9 +198,18 @@
"name": "BACKUP_S3_ENDPOINT",
"purpose": "S3-compatible endpoint URL."
},
{ "name": "BACKUP_S3_BUCKET", "purpose": "Bucket name." },
{ "name": "BACKUP_S3_ACCESS_KEY", "purpose": "S3 access key." },
{ "name": "BACKUP_S3_SECRET_KEY", "purpose": "S3 secret key." },
{
"name": "BACKUP_S3_BUCKET",
"purpose": "Bucket name."
},
{
"name": "BACKUP_S3_ACCESS_KEY",
"purpose": "S3 access key."
},
{
"name": "BACKUP_S3_SECRET_KEY",
"purpose": "S3 secret key."
},
{
"name": "BACKUP_ENCRYPTION_KEY",
"purpose": "Separate AES key for the backup body — MUST NOT match ENCRYPTION_KEY."
+12
View File
@@ -540,6 +540,18 @@ async function main() {
prisma.extractedFact,
),
);
// The short background document summary. Small `encrypt()`-string-as-UTF-8
// Bytes payload (never the multi-megabyte original blob — the select is
// scoped to id + this column), so the generic Bytes walk covers it. NULL on
// rows uploaded with auto-read OFF / no provider — `rotateBytesColumn` skips
// those.
results.push(
await rotateBytesColumn(
"InboundDocument",
"summaryEncrypted",
prisma.inboundDocument,
),
);
// ───── v1.27.22 document content-search index (Bytes text + re-tokenise) ─────
// The blind content index carries TWO coupled artefacts under the index key
@@ -0,0 +1,169 @@
import { describe, expect, it } from "vitest";
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { resolveKey } from "@/lib/i18n/resolve-key";
import {
ILLNESS_SCAN_TYPES,
FUNCTIONAL_IMPACT_RETURN_KEY,
} from "@/lib/illness/correlation";
import {
illnessTypeEnum,
illnessLifecycleEnum,
} from "@/lib/validations/illness";
import {
allergyCategoryEnum,
allergyTypeEnum,
allergySeverityEnum,
allergyStatusEnum,
} from "@/lib/validations/allergy";
import { familyRelationshipEnum } from "@/lib/validations/family-history";
import { INSTRUMENTS } from "@/lib/mental-health/instruments";
/**
* Enum-driven exhaustiveness guard for DYNAMICALLY-built i18n keys.
*
* The three existing i18n guards each see one face of the problem and miss the
* fourth:
* - `i18n-call-site-coverage.test.ts` sees only literal `t("ns.key")` calls,
* so a runtime-assembled `t(\`ns.${x}\`)` key is invisible to it.
* - `i18n-reverse-coverage.test.ts` sees bundle orphans, but treats any
* `ns.${x}` template as covering its whole subtree — so a MEMBER missing
* from the subtree is invisible.
* - `i18n-locale-integrity.test.ts` sees cross-locale parity, so it only
* fires when a key is present in SOME locale but not all.
*
* None of them assert the class that actually ships broken: "for enum E, every
* member's dynamically-built key resolves, non-empty, in every locale." Because
* parity is perfect, a key missing from ONE locale is missing from ALL SIX at
* once — exactly the seam that let `measurementReminders.types.{WHO5_SCORE,
* SCI_SCORE,WAIST_CIRCUMFERENCE}` (v1.28.38) and `illness.vital.FUNCTIONAL_IMPACT`
* (v1.28.41) ship absent from every bundle.
*
* This test derives the EXPECTED key set from the SOURCE enum/const and asserts
* every `prefix + member` resolves. Adding an enum member without a bundle key
* fails CI by construction — no hand-maintained floor to drift.
*
* ── Covered key spaces (source is a clean, finite, import-safe enum/const) ──
* - illness.vital.* ILLNESS_SCAN_TYPES ∪ FUNCTIONAL_IMPACT_RETURN_KEY
* - illness.type.* illnessTypeEnum
* - illness.lifecycle.* illnessLifecycleEnum
* - records.allergies.category.* allergyCategoryEnum
* - records.allergies.type.* allergyTypeEnum
* - records.allergies.severity.* allergySeverityEnum ∪ "NONE" sentinel
* - records.allergies.status.* allergyStatusEnum
* - records.family.relationship.* familyRelationshipEnum
* - mentalHealth.instrument.* } INSTRUMENTS i18nKey slugs
* - mentalHealth.instrumentSub.* } (PHQ-9 / GAD-7 / WHO-5 / SCI —
* - mentalHealth.instrumentDescription.* } the WHO-5/SCI class that bit before)
*
* ── Deliberately EXCLUDED (would produce false positives) ──
* - `insights.workouts.sport.*` — the Strava render path stores a raw
* `sport_type` and the render site legitimately falls back to that raw
* string, so a "missing" key is a graceful degradation, not a broken token
* (audit LOW #2). Asserting it would fail on a path that is by-design lax.
* - `settings.sections.*.description`, `settings.ai.providerChain.types.admin-codex`,
* `insights.<metric>.emptyState.cta` — structurally unreachable behind a
* tsc-exhaustive map / a `!= null` guard / a server-only filter (audit LOW).
* Registering them would demand bundle keys for code paths users never hit.
* - `measurementReminders.types.*` — already exhaustively covered by
* `measurement-reminders/__tests__/type-labels-i18n.test.ts`; its source
* const lives in a client component, not a lib-level export.
* - Prisma cycle enums, medication/document/lab/nutrient catalogs — clean
* enums the audit flags as candidates; left for a follow-up extension. Add
* them here once their exact prefix→member mapping is confirmed 1:1 (the
* cycle cervix* prefixes and catalog slug maps need per-space verification
* to stay false-positive-free).
*/
const MESSAGES_DIR = join(__dirname, "../../messages");
const LOCALES = readdirSync(MESSAGES_DIR)
.filter((f) => f.endsWith(".json"))
.map((f) => ({
locale: f.replace(/\.json$/, ""),
messages: JSON.parse(readFileSync(join(MESSAGES_DIR, f), "utf8")) as Record<
string,
unknown
>,
}));
/** PHQ-9 / GAD-7 / WHO-5 / SCI lowercase i18n slugs, straight from the source. */
const INSTRUMENT_SLUGS = Object.values(INSTRUMENTS).map((i) => i.i18nKey);
interface KeySpace {
/** The dotted prefix the key is built from (`t(\`prefix.${member}\`)`). */
prefix: string;
/** The finite member set, derived from the SOURCE enum/const. */
members: readonly string[];
}
const REGISTRY: readonly KeySpace[] = [
// illness.* — the currently-broken seed (FUNCTIONAL_IMPACT wins recovery-gap ties).
{
prefix: "illness.vital",
members: [...ILLNESS_SCAN_TYPES.map(String), FUNCTIONAL_IMPACT_RETURN_KEY],
},
{ prefix: "illness.type", members: illnessTypeEnum.options },
{ prefix: "illness.lifecycle", members: illnessLifecycleEnum.options },
// records.allergies.* — Zod enums; severity carries a "NONE" (not-assessed) sentinel.
{
prefix: "records.allergies.category",
members: allergyCategoryEnum.options,
},
{ prefix: "records.allergies.type", members: allergyTypeEnum.options },
{
prefix: "records.allergies.severity",
members: [...allergySeverityEnum.options, "NONE"],
},
{ prefix: "records.allergies.status", members: allergyStatusEnum.options },
// records.family.relationship.*
{
prefix: "records.family.relationship",
members: familyRelationshipEnum.options,
},
// mentalHealth.* — the WHO-5/SCI class whose absence originally motivated this guard.
{ prefix: "mentalHealth.instrument", members: INSTRUMENT_SLUGS },
{ prefix: "mentalHealth.instrumentSub", members: INSTRUMENT_SLUGS },
{ prefix: "mentalHealth.instrumentDescription", members: INSTRUMENT_SLUGS },
];
describe("dynamic-key exhaustiveness (enum-derived)", () => {
it("discovers all six shipped locales", () => {
expect(LOCALES.map((l) => l.locale).sort()).toEqual([
"de",
"en",
"es",
"fr",
"it",
"pl",
]);
});
it("every registered key space has a non-empty member set", () => {
for (const space of REGISTRY) {
expect(
space.members.length,
`${space.prefix} resolved to an empty member set — source import broke`,
).toBeGreaterThan(0);
}
});
for (const { locale, messages } of LOCALES) {
for (const space of REGISTRY) {
for (const member of space.members) {
const key = `${space.prefix}.${member}`;
it(`resolves ${key} in ${locale}`, () => {
const value = resolveKey(messages, key);
expect(value, `${key} missing in ${locale}.json`).toBeTypeOf(
"string",
);
expect((value ?? "").trim().length).toBeGreaterThan(0);
});
}
}
}
});
@@ -138,8 +138,10 @@ describe("TrendCardSkeleton", () => {
// Same outer chrome + min-height floor as the real TrendCard slot.
expect(html).toContain("rounded-xl");
expect(html).toContain("min-h-[8rem]");
// Reduced motion honoured via the Skeleton primitive.
expect(html).toContain("motion-reduce:animate-none");
// Reduced motion honoured via the Skeleton primitive's shimmer class
// (its prefers-reduced-motion guard lives beside the keyframes in
// globals.css).
expect(html).toContain("skeleton-shimmer");
});
});
+30 -49
View File
@@ -5,19 +5,16 @@ import { join } from "node:path";
/**
* Dashboard hero — page + header wiring pins.
*
* The hero band mounts between the page header and the tile strip:
* The legacy opt-in `DashboardHero` (gated on a layout `heroVisible`
* flag) was retired: the digest-driven `TodayHero` is the single,
* unambiguous hero, mounted above the tile strip. Pinned here:
*
* - it gates on the layout's `heroVisible` flag (only the literal
* `true` renders — the hero is opt-in) AND on snapshot mode (the
* verdict needs the snapshot payload);
* - while `primaryLoading` is true (same `!mounted ||` pin the tile
* silhouettes use) a footprint-identical skeleton holds the band,
* so the hero and the tiles swap to content in the SAME render
* pass;
* - the greeting paragraph lives in the hero when that renders; when
* the hero does NOT render (snapshot flag off, or hidden via the
* layout toggle) the header takes it back behind `showGreeting`,
* fed from the page's hero gate — the greeting never disappears.
* - the page mounts `TodayHero` and no longer references the legacy
* hero, its skeleton, or the `heroVisible` gate;
* - the greeting paragraph lives in the header on every mount (the
* header owns it unconditionally now that the legacy hero — which
* once carried it — is gone), while the page itself derives no
* welcome text.
*/
const PAGE_PATH = join(process.cwd(), "src/app/page-client.tsx");
const HEADER_PATH = join(
@@ -28,60 +25,44 @@ const HEADER_PATH = join(
describe("dashboard hero — page wiring", () => {
const src = readFileSync(PAGE_PATH, "utf8");
it("gates the hero on snapshot mode + the layout's heroVisible flag", () => {
expect(src).toMatch(
/const\s+heroVisible\s*=\s*snapshotEnabled\s*&&\s*layout\.heroVisible\s*===\s*true;/,
);
});
it("holds the skeleton while primaryLoading and swaps the hero in the same pass", () => {
const block = src.match(
/\{heroVisible\s*&&[\s\S]*?<DashboardHero[\s\S]*?\)\}/,
);
expect(block).not.toBeNull();
expect(block![0]).toMatch(
/primaryLoading\s*\|\|\s*!heroSnapshot\s*\?\s*\(\s*<DashboardHeroSkeleton\s*\/>/,
);
expect(block![0]).toContain("onQuickEntry={setQuickEntryDialog}");
});
it("derives primaryLoading once in the component body (shared with the tile gate)", () => {
const matches = src.match(/const\s+primaryLoading\s*=\s*!mounted/g) ?? [];
expect(matches).toHaveLength(1);
});
it("the hero sits between the header and the strip", () => {
const headerIdx = src.indexOf("<DashboardHeader");
const heroIdx = src.indexOf("{heroVisible &&");
it("mounts the TodayHero above the tile strip", () => {
const heroIdx = src.indexOf("<TodayHero");
const stripIdx = src.indexOf("dashboard-tile-strip");
expect(headerIdx).toBeGreaterThan(-1);
expect(heroIdx).toBeGreaterThan(headerIdx);
expect(heroIdx).toBeGreaterThan(-1);
expect(stripIdx).toBeGreaterThan(heroIdx);
});
it("the page no longer derives the welcome text (it moved into the hero)", () => {
it("no longer references the retired legacy hero", () => {
expect(src).not.toContain("DashboardHero");
expect(src).not.toContain("DashboardHeroSkeleton");
expect(src).not.toContain("heroVisible");
});
it("the page derives no welcome text (the header owns the greeting)", () => {
expect(src).not.toContain("welcomeText");
expect(src).not.toContain("welcomeBackWithName");
expect(src).not.toContain("getHourForTimeZone");
});
});
describe("dashboard hero — header handoff", () => {
describe("dashboard hero — header greeting", () => {
const header = readFileSync(HEADER_PATH, "utf8");
it("the header keeps the greeting behind showGreeting (hero-hidden fallback)", () => {
// The greeting renders here ONLY when the hero band does not — the
// prop gates the paragraph, and the `min-h-5` line-box reservation
// keeps the header stable through the post-hydration name swap.
expect(header).toContain("showGreeting");
expect(header).toMatch(/\{\s*showGreeting\s*\?/);
it("renders the greeting unconditionally (no showGreeting gate)", () => {
// The greeting line renders on every mount — the legacy hero that
// once owned it is gone, so there is no `showGreeting` prop to gate
// it. The `min-h-5` line-box reservation keeps the header stable
// through the post-hydration name swap.
expect(header).not.toContain("showGreeting");
expect(header).toContain("min-h-5");
expect(header).toContain('data-slot="dashboard-header-greeting"');
});
it("the page feeds showGreeting from the inverted hero gate", () => {
it("the page mounts the header without a greeting prop", () => {
const src = readFileSync(PAGE_PATH, "utf8");
expect(src).toMatch(/showGreeting=\{!heroVisible\}/);
expect(src).toContain(
"<DashboardHeader onQuickEntry={setQuickEntryDialog} />",
);
});
it("the header keeps the title + customize + add actions", () => {
@@ -77,10 +77,10 @@ describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => {
it("keeps the per-render Intl hour walk off the page (audit-H4 successor)", () => {
const src = load(PAGE_PATH);
// The audit-H4 `useMemo(getHourForTimeZone, [userTimezone])` pin
// retired with the greeting itself: the hero band derives the
// daypart from the snapshot's server-computed `greetingHour`
// (`dashboard-hero.tsx`), so the page no longer instantiates any
// `Intl.DateTimeFormat` for the greeting — per render or otherwise.
// moved off the page with the greeting itself: the greeting now
// lives in the header (`dashboard-header.tsx`), so the page no
// longer instantiates any `Intl.DateTimeFormat` for the greeting —
// per render or otherwise.
expect(src).not.toContain("getHourForTimeZone");
expect(src).not.toContain("welcomeText");
});
@@ -108,8 +108,12 @@ describe("resolveConfiguredTileCount (v1.16.8)", () => {
// plus the v1.20.0 sleep / steps / glucose flips are tile-visible AND
// tile-capable; medications + recentWorkouts are tile-visible in the
// stored layout but paint NO strip tile, so they must not inflate the
// silhouette. bp paints sys + dia = 2.
expect(resolveConfiguredTileCount(DEFAULT_DASHBOARD_LAYOUT)).toBe(11);
// silhouette. bp paints sys + dia = 2. v1.28.52 added seven vitals +
// body-composition strip tiles (hrv, oxygenSaturation, respiratoryRate,
// wristTemperature, muscleMass, totalBodyWater, boneMass), each
// single-count: 11 + 7 = 18. v1.29 added the fluid-intake strip tile
// (waterIntake), single-count: 18 + 1 = 19.
expect(resolveConfiguredTileCount(DEFAULT_DASHBOARD_LAYOUT)).toBe(19);
});
it("ignores chart-only and iOS-pin-only widget ids", () => {
+1 -1
View File
@@ -20,7 +20,7 @@ import { join } from "node:path";
*/
const ROOT = join(__dirname, "../../..");
const INSIGHTS_PATH = join(ROOT, "src/app/insights/page.tsx");
const INSIGHTS_PATH = join(ROOT, "src/app/insights/page-client.tsx");
const ANALYTICS_ROUTE_PATH = join(ROOT, "src/app/api/analytics/route.ts");
const ADVISOR_HOOK_PATH = join(
ROOT,
+1 -1
View File
@@ -29,7 +29,7 @@ import { join } from "node:path";
const ROOT = join(__dirname, "../../..");
const DASHBOARD_PATH = join(ROOT, "src/app/page-client.tsx");
const INSIGHTS_PATH = join(ROOT, "src/app/insights/page.tsx");
const INSIGHTS_PATH = join(ROOT, "src/app/insights/page-client.tsx");
const INSIGHTS_LAYOUT_PATH = join(ROOT, "src/app/insights/layout.tsx");
function load(path: string): string {
+3 -3
View File
@@ -334,7 +334,7 @@ export default function AchievementsPage() {
/>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-5">
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-4 md:p-6">
<p className="text-muted-foreground text-sm">
{t("achievements.points")}
</p>
@@ -354,7 +354,7 @@ export default function AchievementsPage() {
aria-label={t("achievements.points")}
/>
</div>
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-5">
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-4 md:p-6">
<p className="text-muted-foreground text-sm">
{t("achievements.unlocked")}
</p>
@@ -368,7 +368,7 @@ export default function AchievementsPage() {
{t("achievements.remainingUnlocks", { count: remainingUnlocks })}
</p>
</div>
<div className="bg-card border-border min-h-34 rounded-xl border p-5 sm:col-span-2 lg:col-span-1">
<div className="bg-card border-border min-h-34 rounded-xl border p-4 sm:col-span-2 md:p-6 lg:col-span-1">
<p className="text-muted-foreground text-sm">
{t("achievements.nextGoal")}
</p>
@@ -111,6 +111,19 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
// ungated insights AI route fails this test BY NAME rather than leaking the
// surface over a Bearer token when the account turned insights off.
"src/app/api/insights",
// v1.28 — the unified daily-digest read (`GET /api/daily/digest`). The digest
// is the AI-narrative daily layer, so it gates on the `insights` module via
// `requireModuleEnabled(user.id, "insights")` directly. Walking the tree means
// a NEW ungated daily route fails BY NAME rather than leaking over a Bearer
// token when the account turned insights off.
"src/app/api/daily",
// v1.28 — the nutrient-intake sync (opt-in `nutrients` module). Unlike the
// data-layer-exempt siblings this domain is REFUSE-INGEST-WHEN-OFF (the
// mental-health posture): both the batch ingest and the window-summary
// read call `requireModuleEnabled(user.id, "nutrients")` directly, so a
// phone whose user never opted in cannot land rows server-side. Walking
// the tree means a NEW ungated nutrients route fails BY NAME.
"src/app/api/nutrients",
];
/**
@@ -57,6 +57,12 @@ export const dynamic = "force-dynamic";
interface RestoreResponse {
restored: true;
// v1.28 — `summary` reports every domain PRESENT in the uploaded file
// (informational — it now includes labs/illness/allergies/family-history/
// workouts/documents counts too). `cleared` below is the narrower,
// authoritative list of what this restore actually wiped + recreated —
// those newer domains are export-only for now and are NOT touched by a
// restore, so they are deliberately absent from `cleared`.
summary: BackupSummary;
cleared: {
measurements: number;
@@ -218,6 +218,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
moodEntries: summary.moodEntries,
cycles: summary.cycles,
cycleDayLogs: summary.cycleDayLogs,
labResults: summary.labResults,
biomarkers: summary.biomarkers,
illnessEpisodes: summary.illnessEpisodes,
illnessDayLogs: summary.illnessDayLogs,
allergies: summary.allergies,
familyHistory: summary.familyHistory,
workouts: summary.workouts,
documents: summary.documents,
},
},
});
@@ -32,6 +32,8 @@ const mocks = vi.hoisted(() => ({
importJobFindFirst: vi.fn().mockResolvedValue(null),
importJobUpdate: vi.fn(),
userFindUnique: vi.fn(),
streamToDisk: vi.fn(),
unlink: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
@@ -42,6 +44,15 @@ vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: mocks.getGlobalBoss,
}));
vi.mock("@/lib/multipart/stream-to-disk", () => ({
streamMultipartToDisk: mocks.streamToDisk,
}));
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return { ...actual, unlink: mocks.unlink };
});
vi.mock("@/lib/db", () => ({
prisma: {
importJob: {
@@ -67,6 +78,8 @@ const USER_SESSION = {
user: { id: "user-2", username: "alice", role: "USER" as const },
};
const STAGED_PATH = "/tmp/healthlog-admin-apple-health-import-abc.bin";
beforeEach(() => {
vi.resetAllMocks();
mocks.checkRateLimit.mockResolvedValue({ allowed: true });
@@ -76,8 +89,27 @@ beforeEach(() => {
mocks.importJobFindFirst.mockResolvedValue(null);
mocks.importJobUpdate.mockResolvedValue({ id: "ij-1" });
mocks.userFindUnique.mockResolvedValue({ id: "user-2", username: "alice" });
mocks.streamToDisk.mockResolvedValue({
filePath: STAGED_PATH,
bytes: 100,
sha256: "deadbeef",
originalFilename: "export.zip",
textFields: { userId: "user-2" },
});
mocks.unlink.mockResolvedValue(undefined);
});
function multipartReq(): NextRequest {
return new NextRequest(
"http://localhost/api/admin/import-apple-health-export",
{
method: "POST",
headers: { "content-type": "multipart/form-data; boundary=---x" },
body: "stub",
},
);
}
describe("POST /api/admin/import-apple-health-export", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
@@ -139,4 +171,45 @@ describe("POST /api/admin/import-apple-health-export", () => {
const res = await POST(req);
expect(res.status).toBe(429);
});
it("re-stages and enqueues when the only prior job for the same bytes is failed (issue #486)", async () => {
vi.mocked(getSession).mockResolvedValue(ADMIN_SESSION as never);
mocks.importJobFindFirst.mockImplementation(async (args: unknown) => {
const where = (args as { where?: { status?: { not?: string } } }).where;
if (where?.status?.not === "failed") return null;
return { id: "old-failed-job", status: "failed" };
});
const res = await POST(multipartReq());
expect(res.status).toBe(202);
const body = await res.json();
expect(body.data.status).toBe("queued");
expect(body.data.idempotent).toBeUndefined();
expect(body.data.jobId).not.toBe("old-failed-job");
expect(mocks.bossSend).toHaveBeenCalledTimes(1);
expect(mocks.importJobCreate).toHaveBeenCalledTimes(1);
expect(mocks.importJobFindFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ status: { not: "failed" } }),
}),
);
expect(mocks.unlink).not.toHaveBeenCalled();
});
it("short-circuits to a viable prior job and unlinks the redundant upload", async () => {
vi.mocked(getSession).mockResolvedValue(ADMIN_SESSION as never);
mocks.importJobFindFirst.mockResolvedValue({
id: "live-job",
status: "parsing",
});
const res = await POST(multipartReq());
expect(res.status).toBe(202);
const body = await res.json();
expect(body.data.idempotent).toBe(true);
expect(body.data.jobId).toBe("live-job");
expect(mocks.bossSend).not.toHaveBeenCalled();
expect(mocks.importJobCreate).not.toHaveBeenCalled();
expect(mocks.unlink).toHaveBeenCalledWith(STAGED_PATH);
});
});
@@ -22,9 +22,11 @@ import { checkRateLimit } from "@/lib/rate-limit";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import {
APPLE_HEALTH_IMPORT_QUEUE,
APPLE_HEALTH_IMPORT_SEND_OPTIONS,
type AppleHealthImportPayload,
} from "@/lib/jobs/apple-health-import-worker";
import { streamMultipartToDisk } from "@/lib/multipart/stream-to-disk";
import { unlink } from "node:fs/promises";
export const dynamic = "force-dynamic";
@@ -111,15 +113,22 @@ export const POST = apiHandler(async (request: NextRequest) => {
// Same content-hash idempotency as the user-facing route, but scoped
// by the target user — an admin re-uploading the same file for the
// same user resolves to the previous job.
// same user resolves to the previous job. As on the user route, a
// prior `failed` job is excluded (issue #486) so the same export can
// be retried; only still-viable jobs (queued / in-flight / done)
// short-circuit.
const existing = await prisma.importJob.findFirst({
where: {
userId: targetUser.id,
uploadSha256: uploaded.sha256,
status: { not: "failed" },
},
orderBy: { startedAt: "desc" },
});
if (existing) {
// Redundant staging file — the existing viable job owns the bytes.
// Unlink so a deduped admin re-upload does not leak `/tmp`.
await unlink(uploaded.filePath).catch(() => {});
annotate({ meta: { idempotent_hit: true, job_id: existing.id } });
return apiSuccess(
{
@@ -158,7 +167,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
uploadBytes: uploaded.bytes,
enqueuedAt: new Date().toISOString(),
};
const bossJobId = await boss.send(APPLE_HEALTH_IMPORT_QUEUE, payload);
// No retries + wide expiration: the staged upload is consumed by the
// first run, so a redelivery could only fail on the deleted `/tmp`
// file and mask the real outcome (see the worker's send-options doc).
const bossJobId = await boss.send(
APPLE_HEALTH_IMPORT_QUEUE,
payload,
APPLE_HEALTH_IMPORT_SEND_OPTIONS,
);
await prisma.importJob.update({
where: { id: importJob.id },
+41 -1
View File
@@ -69,8 +69,14 @@ interface TestFailureEnvelope {
data: {
ok: false;
providerType: string;
reasonCode: "credentials" | "rate_limited" | "server_error" | "unreachable";
reasonCode:
| "credentials"
| "rate_limited"
| "server_error"
| "bad_request"
| "unreachable";
reason: string;
httpStatus: number | null;
};
error: null;
}
@@ -170,6 +176,40 @@ describe("POST /api/ai/test — provider error leak guard + non-5xx contract", (
expect(body.data.reasonCode).toBe("unreachable");
expect(body.data.reason).toMatch(/could not reach/i);
});
// v1.28.28 (#470) — a 4xx means the endpoint ANSWERED and rejected the
// request shape / model name; lumping it into "unreachable" sent operators
// debugging connectivity when the fix was the model field or a gateway's
// parameter strictness.
it("categorises a 400 as bad_request, not unreachable", async () => {
makeProviderThatThrows(
Object.assign(new Error("Local AI request failed (400)"), {
httpStatus: 400 as const,
bodyExcerpt: '{"error":{"message":"unknown model: llama9"}}',
}),
);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(200);
const body = (await response.json()) as TestFailureEnvelope;
expect(body.data.reasonCode).toBe("bad_request");
expect(body.data.reason).toMatch(/HTTP 400/);
expect(body.data.reason).toMatch(/not connectivity/i);
expect(body.data.httpStatus).toBe(400);
// Still secret-free: the upstream body excerpt is never echoed.
expect(body.data.reason).not.toMatch(/llama9/);
});
it("categorises a 404 (wrong path / model route) as bad_request", async () => {
makeProviderThatThrows(
Object.assign(new Error("Local AI request failed (404)"), {
httpStatus: 404 as const,
}),
);
const response = await POST(emptyRequest() as never);
const body = (await response.json()) as TestFailureEnvelope;
expect(body.data.reasonCode).toBe("bad_request");
expect(body.data.httpStatus).toBe(404);
});
});
// v1.4 fix: the dropdown in /settings was not honoured — the test always
+18 -1
View File
@@ -113,6 +113,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
providerType: provider.type,
reasonCode,
reason,
// v1.28.28 (#470) — the upstream status (secret-free) so the client
// can localise "rejected the request (HTTP 400)" with the real number.
httpStatus: err.httpStatus ?? null,
});
}
});
@@ -123,7 +126,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
* legacy / unmapped code. Secret-free by construction.
*/
type TestFailureCode =
"credentials" | "rate_limited" | "server_error" | "unreachable";
| "credentials"
| "rate_limited"
| "server_error"
| "bad_request"
| "unreachable";
/**
* Map an upstream provider failure to a human-readable, secret-free
@@ -161,6 +168,16 @@ function classifyTestFailure(
reason: "The AI provider returned a server error.",
};
}
// v1.28.28 (#470) — any other 4xx (400 / 404 / 405 / 415 / 422, …) means
// the endpoint ANSWERED and rejected the request shape or model name. The
// old lump into "unreachable" sent operators debugging connectivity when
// the fix was the model field or a gateway's parameter strictness.
if (status >= 400 && status < 500) {
return {
reasonCode: "bad_request",
reason: `The provider rejected the request (HTTP ${status}) — the endpoint answered, so this is a request-shape or model-name problem, not connectivity.`,
};
}
return {
reasonCode: "unreachable",
reason: "Could not reach the AI provider.",
+12 -3
View File
@@ -22,6 +22,8 @@ import { resolveRestMode } from "@/lib/illness/rest-mode";
import { buildHealthScoreBpInputs } from "@/lib/analytics/health-score-inputs";
import { deriveBpWindow90 } from "@/lib/analytics/window-confidence";
import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { type Locale } from "@/lib/i18n/config";
import {
computeGlucoseClinicalMetrics,
GLUCOSE_PANEL_WINDOW_DAYS,
@@ -96,12 +98,18 @@ export const GET = apiHandler(async (request?: Request) => {
// thick body is the 30-query chain feeding the dashboard + insights
// hero score; after a measurement sync the next mount serves the
// prior body instantly while one background recompute refreshes it.
// v1.30.10 — the `correlations` block carries user-facing narration that is
// localised per reader, so the cache key MUST include the locale; otherwise a
// language switch would serve another locale's cached interpretation for up to
// the TTL. Every other block is locale-independent, so per-locale entries only
// cost a little duplication (most users have one locale).
const locale = await resolveServerLocale({ userLocale: user.locale ?? null });
const cachedBody = await cachedSwr(
caches.analytics as ServerCache<
Awaited<ReturnType<typeof buildAnalyticsResponse>>
>,
`${user.id}|default`,
() => buildAnalyticsResponse(user),
`${user.id}|default|${locale}`,
() => buildAnalyticsResponse(user, locale),
annotate,
);
@@ -173,7 +181,7 @@ function enrichLastSeenDaysAgo<
* matches the v1.4.33 snapshot LRU's `buildCoachSnapshot` →
* `buildCoachSnapshotImpl` shape.
*/
async function buildAnalyticsResponse(user: AuthedUser) {
async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
// v1.4.37.1 hotfix — fire-and-forget the rollup-fresh check.
// Awaiting it on the read path can stall the Node event loop for
// 30–60 s on a power-user account whose iOS step samples keep the
@@ -405,6 +413,7 @@ async function buildAnalyticsResponse(user: AuthedUser) {
userTz,
now: new Date(),
coverage,
locale,
});
// v1.4.20 phase B5 — Personal Health Score. Server-deterministic
+46 -1
View File
@@ -5,7 +5,7 @@
* only signal operators need; the identifier itself is PII and stays
* out of the row.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
@@ -135,3 +135,48 @@ describe("POST /api/auth/login — auth.login.failed audit row (H-1)", () => {
expect(JSON.stringify(payload)).not.toContain(TYPED_EMAIL);
});
});
describe("POST /api/auth/login — OIDC_ONLY server-side enforcement", () => {
const OIDC_ENV_KEYS = [
"OIDC_ISSUER_URL",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_ONLY",
] as const;
const original: Record<string, string | undefined> = {};
beforeEach(() => {
for (const key of OIDC_ENV_KEYS) original[key] = process.env[key];
});
afterEach(() => {
for (const key of OIDC_ENV_KEYS) {
if (original[key] === undefined) delete process.env[key];
else process.env[key] = original[key];
}
});
it("rejects password login before touching the DB when OIDC_ONLY is set", async () => {
process.env.OIDC_ISSUER_URL = "https://idp.example.com";
process.env.OIDC_CLIENT_ID = "client-1";
process.env.OIDC_CLIENT_SECRET = "secret-1";
process.env.OIDC_ONLY = "true";
const res = await POST(makeRequest());
expect(res.status).toBe(403);
expect(prisma.user.findFirst).not.toHaveBeenCalled();
});
it("still allows password login when OIDC_ONLY is set but the provider is half-configured", async () => {
delete process.env.OIDC_ISSUER_URL;
process.env.OIDC_CLIENT_ID = "client-1";
process.env.OIDC_CLIENT_SECRET = "secret-1";
process.env.OIDC_ONLY = "true";
const res = await POST(makeRequest());
// Falls through to normal password verification (which fails here
// since verifyPassword is mocked to return false) rather than 403 —
// a half-set OIDC group must never lock everyone out.
expect(res.status).toBe(401);
});
});
+10
View File
@@ -13,8 +13,18 @@ import { finishLogin } from "@/lib/auth/login-response";
import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
import { consumeTrustedDevice } from "@/lib/auth/trusted-device";
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { isOidcOnly } from "@/lib/auth/oidc";
export const POST = apiHandler(async (request: NextRequest) => {
// OIDC_ONLY means password login must be a dead end, not just a hidden
// button — otherwise a pre-existing password (or a leaked/reused one)
// stays a live bypass of the operator's SSO-only policy.
if (isOidcOnly()) {
return apiError("Password login is disabled. Sign in with SSO.", 403, {
errorCode: "oidc_only",
});
}
// v1.4.43 W13 M-4 — `checkAuthSurfaceRateLimit` swaps to a tighter
// global bucket when the trust chain is misconfigured; otherwise it
// is byte-equivalent to the previous per-IP `auth:login:{ip}` key.
@@ -90,6 +90,7 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => {
mood: true,
compliance: true,
sleep: false,
glucose: true,
cycle: false,
labs: true,
allergies: true,
@@ -116,6 +117,7 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => {
mood: true,
compliance: false,
sleep: false,
glucose: false,
cycle: false,
labs: false,
allergies: false,
@@ -0,0 +1,716 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => ({ setError: vi.fn() }),
}));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn(),
}));
vi.mock("@/lib/crypto", () => ({
decrypt: vi.fn((s: string) => s.replace(/^enc\(/, "").replace(/\)$/, "")),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findFirst: vi.fn(),
findUnique: vi.fn(),
count: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
appSettings: { findUnique: vi.fn() },
webauthnMfaCredential: { count: vi.fn() },
// v1.28.48 — the first-admin provision count+insert now runs inside a
// transaction-scoped advisory lock. Default `$transaction` to run the
// callback with the same prisma mock as its `tx` handle.
$transaction: vi.fn(),
$queryRaw: vi.fn(),
},
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/auth/session", () => ({ createSession: vi.fn() }));
vi.mock("@/lib/auth/login-alert", () => ({ recordSignInDevice: vi.fn() }));
vi.mock("@/lib/auth/mfa/challenge", () => ({
createMfaChallenge: vi.fn(async () => ({
ticket: "mfa-ticket-1",
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
})),
}));
vi.mock("@/lib/auth/mfa-enrollment", () => ({
syncMfaEnrollCookie: vi.fn(),
}));
vi.mock("@/lib/auth/secure-cookie", () => ({
shouldEmitSecureCookie: () => false,
}));
vi.mock("@/lib/tz/resolver", () => ({
resolveServerDefaultTimezone: vi.fn(async () => "Europe/Berlin"),
}));
vi.mock("@/lib/auth/oidc", () => ({
getOidcConfig: vi.fn(),
discoverOidcMetadata: vi.fn(),
exchangeCodeForTokens: vi.fn(),
verifyIdToken: vi.fn(),
fetchUserinfoEmail: vi.fn(),
getOidcRedirectUri: () =>
"https://healthlog.example.com/api/auth/oidc/callback",
oidcAppUrl: (path: string) => new URL(path, "https://healthlog.example.com"),
deriveUniqueUsername: vi.fn(async (email: string) => email.split("@")[0]),
}));
// The handoff lib is unit-tested separately; here we assert the callback
// branches into it and redirects to the custom scheme (real
// `buildNativeCallbackUrl`, mocked `mintNativeHandoff`).
vi.mock("@/lib/auth/oidc-native-handoff", () => ({
buildNativeCallbackUrl: (params: Record<string, string>) =>
`healthlog://oidc-callback?${new URLSearchParams(params).toString()}`,
mintNativeHandoff: vi.fn(async () => ({
code: "hlh_test_code",
handoffId: "ho-1",
})),
}));
import { GET } from "../route";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { createSession } from "@/lib/auth/session";
import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
import {
getOidcConfig,
discoverOidcMetadata,
exchangeCodeForTokens,
verifyIdToken,
} from "@/lib/auth/oidc";
import { mintNativeHandoff } from "@/lib/auth/oidc-native-handoff";
const CONFIG = {
issuerUrl: "https://idp.example.com",
clientId: "client-1",
clientSecret: "secret-1",
scopes: "openid email profile",
buttonLabel: "SSO",
};
const METADATA = {
issuer: "https://idp.example.com",
authorization_endpoint: "https://idp.example.com/authorize",
token_endpoint: "https://idp.example.com/token",
jwks_uri: "https://idp.example.com/jwks",
};
/** A minimal user row shaped like what the callback consumes. */
function userRow(overrides: Record<string, unknown> = {}) {
return {
id: "user-1",
email: "existing@example.com",
onboardingCompletedAt: new Date(),
oidcIssuer: null,
oidcSub: null,
totpConfirmedAt: null,
mfaEnforced: false,
...overrides,
};
}
/**
* The callback's `prisma.user.findFirst` calls are distinguishable by
* their `where` shape: the (issuer, sub) identity lookup, the display-
* email collision probe (`NOT`), and the link/provision email match.
*/
function mockUserLookups(opts: {
byIdentity?: ReturnType<typeof userRow> | null;
byEmail?: ReturnType<typeof userRow> | null;
emailTaken?: boolean;
}) {
vi.mocked(prisma.user.findFirst).mockImplementation(((args: {
where: Record<string, unknown>;
}) => {
if (args.where.oidcIssuer !== undefined) {
return Promise.resolve(opts.byIdentity ?? null);
}
if (args.where.NOT !== undefined) {
return Promise.resolve(opts.emailTaken ? { id: "user-other" } : null);
}
return Promise.resolve(opts.byEmail ?? null);
}) as never);
}
function makeRequest(opts: { query?: string; cookie?: string }): NextRequest {
const req = new NextRequest(
`http://localhost/api/auth/oidc/callback${opts.query ?? ""}`,
);
if (opts.cookie) {
req.cookies.set("oidc_auth_state", opts.cookie);
}
return req;
}
function stateCookie(payload: Record<string, unknown>): string {
return `enc(${JSON.stringify(payload)})`;
}
/** A request whose query/state pass the CSRF gate — the default fixture. */
function validRequest(next = "/"): NextRequest {
return makeRequest({
query: "?code=abc&state=STATE",
cookie: stateCookie({
state: "STATE",
nonce: "nonce-1",
codeVerifier: "verifier-1",
next,
}),
});
}
function mockIdentity(overrides: Record<string, unknown> = {}) {
vi.mocked(exchangeCodeForTokens).mockResolvedValue({ id_token: "idt" });
vi.mocked(verifyIdToken).mockResolvedValue({
sub: "sub-1",
email: "existing@example.com",
emailVerified: true,
name: null,
...overrides,
} as never);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
remaining: 20,
reset: 0,
ip: "1.2.3.4",
} as never);
vi.mocked(getOidcConfig).mockReturnValue(CONFIG);
vi.mocked(discoverOidcMetadata).mockResolvedValue(METADATA);
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0);
vi.mocked(createMfaChallenge).mockResolvedValue({
ticket: "mfa-ticket-1",
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
});
// Run the provision transaction callback against the same prisma mock as
// its `tx` handle, and let the advisory-lock `$queryRaw` resolve.
vi.mocked(prisma.$transaction).mockImplementation(((
fn: (tx: typeof prisma) => unknown,
) => fn(prisma)) as never);
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ locked: 1 }] as never);
});
describe("GET /api/auth/oidc/callback", () => {
it("redirects with oidc_denied when the IdP reports an error", async () => {
const res = await GET(makeRequest({ query: "?error=access_denied" }));
expect(res.headers.get("location")).toContain("error=oidc_denied");
});
it("redirects with oidc_state when code/state are missing", async () => {
const res = await GET(makeRequest({}));
expect(res.headers.get("location")).toContain("error=oidc_state");
});
it("redirects with oidc_state when the state cookie is missing", async () => {
const res = await GET(makeRequest({ query: "?code=abc&state=xyz" }));
expect(res.headers.get("location")).toContain("error=oidc_state");
});
it("redirects with oidc_state on a state/cookie mismatch (CSRF)", async () => {
const res = await GET(
makeRequest({
query: "?code=abc&state=WRONG",
cookie: stateCookie({
state: "RIGHT",
nonce: "nonce-1",
codeVerifier: "verifier-1",
next: "/",
}),
}),
);
expect(res.headers.get("location")).toContain("error=oidc_state");
});
it("redirects with oidc_no_email when the identity has no email at all", async () => {
mockIdentity({ email: null, emailVerified: undefined });
mockUserLookups({ byIdentity: null });
const res = await GET(validRequest());
expect(res.headers.get("location")).toContain("error=oidc_no_email");
});
it("redirects with oidc_email_unverified when email_verified is absent", async () => {
// An IdP that never asserts verification cannot anchor a link or a
// provision — absent is a reject, not a benefit of the doubt.
mockIdentity({ emailVerified: undefined });
mockUserLookups({ byIdentity: null, byEmail: null });
const res = await GET(validRequest());
expect(res.headers.get("location")).toContain(
"error=oidc_email_unverified",
);
expect(prisma.user.create).not.toHaveBeenCalled();
expect(createSession).not.toHaveBeenCalled();
});
it("redirects with oidc_email_unverified when email_verified is false", async () => {
mockIdentity({ emailVerified: false });
mockUserLookups({ byIdentity: null, byEmail: null });
const res = await GET(validRequest());
expect(res.headers.get("location")).toContain(
"error=oidc_email_unverified",
);
});
it("logs in by (issuer, sub) without consulting the email", async () => {
// Even with a brand-new email at the IdP, the stamped identity wins —
// no second account is provisioned and no re-link happens.
mockIdentity({ email: "renamed@example.com", emailVerified: true });
mockUserLookups({
byIdentity: userRow({
oidcIssuer: METADATA.issuer,
oidcSub: "sub-1",
}),
emailTaken: false,
});
vi.mocked(prisma.user.update).mockResolvedValue(
userRow({
email: "renamed@example.com",
oidcIssuer: METADATA.issuer,
oidcSub: "sub-1",
}) as never,
);
const res = await GET(validRequest("/dashboard"));
expect(prisma.user.create).not.toHaveBeenCalled();
// Display email refresh — the only field the update touches.
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { email: "renamed@example.com" },
});
expect(createSession).toHaveBeenCalled();
expect(res.headers.get("location")).toContain("/dashboard");
});
it("skips the display-email refresh when another account holds the address", async () => {
mockIdentity({ email: "collide@example.com", emailVerified: true });
mockUserLookups({
byIdentity: userRow({
oidcIssuer: METADATA.issuer,
oidcSub: "sub-1",
}),
emailTaken: true,
});
const res = await GET(validRequest());
expect(prisma.user.update).not.toHaveBeenCalled();
expect(createSession).toHaveBeenCalled();
expect(res.status).toBe(307);
});
it("links an existing email-matched account ONCE, stamping (issuer, sub)", async () => {
mockIdentity();
mockUserLookups({ byIdentity: null, byEmail: userRow() });
vi.mocked(prisma.user.update).mockResolvedValue(
userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }) as never,
);
const res = await GET(validRequest("/dashboard"));
expect(prisma.user.create).not.toHaveBeenCalled();
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { oidcIssuer: METADATA.issuer, oidcSub: "sub-1" },
});
expect(auditLog).toHaveBeenCalledWith(
"auth.oidc.linked",
expect.objectContaining({ userId: "user-1" }),
);
expect(createSession).toHaveBeenCalled();
expect(res.headers.get("location")).toContain("/dashboard");
});
it("rejects a login whose email matches an account bound to a DIFFERENT identity", async () => {
mockIdentity({ sub: "sub-intruder" });
mockUserLookups({
byIdentity: null,
byEmail: userRow({
oidcIssuer: "https://other-idp.example.com",
oidcSub: "sub-original",
}),
});
const res = await GET(validRequest());
expect(res.headers.get("location")).toContain(
"error=oidc_identity_conflict",
);
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.create).not.toHaveBeenCalled();
expect(createSession).not.toHaveBeenCalled();
expect(auditLog).toHaveBeenCalledWith(
"auth.oidc.link_conflict",
expect.objectContaining({ userId: "user-1" }),
);
});
it("auto-provisions a new user when registration is open", async () => {
mockIdentity({
sub: "sub-2",
email: "New@Example.com",
emailVerified: true,
});
mockUserLookups({ byIdentity: null, byEmail: null });
vi.mocked(prisma.user.count).mockResolvedValue(3);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
registrationEnabled: true,
} as never);
vi.mocked(prisma.user.create).mockResolvedValue(
userRow({ id: "user-new", onboardingCompletedAt: null }) as never,
);
const res = await GET(validRequest());
expect(prisma.user.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
// Lowercased at provision time; stamped with the durable identity
// and the same server-default timezone the register route resolves.
email: "new@example.com",
passwordHash: null,
role: "USER",
timezone: "Europe/Berlin",
oidcIssuer: METADATA.issuer,
oidcSub: "sub-2",
}),
}),
);
expect(createSession).toHaveBeenCalled();
expect(res.status).toBe(307);
});
it("mints ADMIN for a genuine first OIDC user (in-lock count 0)", async () => {
mockIdentity({
sub: "sub-first",
email: "first@example.com",
emailVerified: true,
});
mockUserLookups({ byIdentity: null, byEmail: null });
// Both the outer registration gate and the in-lock re-count observe an
// empty DB → the provisioned account is the operator's first ADMIN.
vi.mocked(prisma.user.count).mockResolvedValue(0);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
registrationEnabled: true,
} as never);
vi.mocked(prisma.user.create).mockResolvedValue(
userRow({ id: "user-new", onboardingCompletedAt: null }) as never,
);
const res = await GET(validRequest());
const createArgs = vi.mocked(prisma.user.create).mock.calls[0]?.[0] as {
data: { role: string };
};
expect(createArgs.data.role).toBe("ADMIN");
expect(createSession).toHaveBeenCalled();
expect(res.status).toBe(307);
});
it("mints USER when a concurrent first user committed first (stale outer read 0, in-lock re-count 1)", async () => {
// The outer read observed the empty-DB window (0), but by the time the
// advisory-locked transaction re-counts, the racing first provision has
// committed → 1. Without the in-lock re-count this second user would also
// be minted ADMIN (the double-admin bug); the fresh count closes it. The
// shared lock key also serialises this against a racing password register.
mockIdentity({
sub: "sub-second",
email: "second@example.com",
emailVerified: true,
});
mockUserLookups({ byIdentity: null, byEmail: null });
vi.mocked(prisma.user.count)
.mockResolvedValueOnce(0) // outer registrationEnabled gate
.mockResolvedValueOnce(1); // inside the advisory-locked transaction
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
registrationEnabled: true,
} as never);
vi.mocked(prisma.user.create).mockResolvedValue(
userRow({ id: "user-new", onboardingCompletedAt: null }) as never,
);
const res = await GET(validRequest());
const createArgs = vi.mocked(prisma.user.create).mock.calls[0]?.[0] as {
data: { role: string };
};
expect(createArgs.data.role).toBe("USER");
expect(res.status).toBe(307);
});
it("acquires the transaction-scoped advisory lock before provisioning", async () => {
mockIdentity({
sub: "sub-lock",
email: "lock@example.com",
emailVerified: true,
});
mockUserLookups({ byIdentity: null, byEmail: null });
vi.mocked(prisma.user.count).mockResolvedValue(0);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
registrationEnabled: true,
} as never);
vi.mocked(prisma.user.create).mockResolvedValue(
userRow({ id: "user-new", onboardingCompletedAt: null }) as never,
);
await GET(validRequest());
// The lock (`$queryRaw` on `pg_advisory_xact_lock`) runs inside the
// `$transaction`, and the create is gated behind both.
expect(prisma.$queryRaw).toHaveBeenCalled();
expect(prisma.$transaction).toHaveBeenCalled();
expect(
vi.mocked(prisma.$queryRaw).mock.invocationCallOrder[0],
).toBeLessThan(vi.mocked(prisma.user.create).mock.invocationCallOrder[0]);
});
it("refuses to auto-provision when registration is closed", async () => {
mockIdentity({ sub: "sub-3", email: "blocked@example.com" });
mockUserLookups({ byIdentity: null, byEmail: null });
vi.mocked(prisma.user.count).mockResolvedValue(5);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
registrationEnabled: false,
} as never);
const res = await GET(validRequest());
expect(prisma.user.create).not.toHaveBeenCalled();
expect(createSession).not.toHaveBeenCalled();
expect(res.headers.get("location")).toContain(
"error=oidc_registration_disabled",
);
});
it("never stamps the session step-up fresh — the 5th createSession arg is null", async () => {
mockIdentity();
mockUserLookups({ byIdentity: null, byEmail: userRow() });
vi.mocked(prisma.user.update).mockResolvedValue(
userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }) as never,
);
await GET(validRequest());
expect(createSession).toHaveBeenCalledWith(
"user-1",
false,
"1.2.3.4",
null,
null,
);
});
it("routes a TOTP-enrolled account through the MFA challenge instead of logging in", async () => {
mockIdentity();
mockUserLookups({
byIdentity: userRow({
oidcIssuer: METADATA.issuer,
oidcSub: "sub-1",
totpConfirmedAt: new Date(),
}),
});
const res = await GET(validRequest("/dashboard"));
// No session — the partial state lives in the single-use ticket.
expect(createSession).not.toHaveBeenCalled();
expect(createMfaChallenge).toHaveBeenCalledWith("user-1", "login");
const location = res.headers.get("location")!;
expect(location).toContain("/auth/login");
expect(location).not.toContain("error=");
// `next` continuation rides the query string into the login page.
expect(location).toContain(`next=${encodeURIComponent("/dashboard")}`);
// The ticket + methods ride the login-page-scoped handoff cookie.
const setCookie = res.headers.getSetCookie().join("\n");
expect(setCookie).toContain("oidc_mfa_handoff=");
expect(setCookie).toContain(encodeURIComponent("mfa-ticket-1"));
expect(setCookie.toLowerCase()).toContain("path=/auth/login");
expect(decodeURIComponent(setCookie)).toContain('"totp"');
});
it("offers only the security-key method for a WebAuthn-only account", async () => {
mockIdentity();
mockUserLookups({
byIdentity: userRow({
oidcIssuer: METADATA.issuer,
oidcSub: "sub-1",
totpConfirmedAt: null,
}),
});
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1);
const res = await GET(validRequest());
expect(createSession).not.toHaveBeenCalled();
const setCookie = decodeURIComponent(res.headers.getSetCookie().join("\n"));
expect(setCookie).toContain('"webauthn"');
expect(setCookie).not.toContain('"totp"');
});
it("redirects with oidc_failed when the token exchange throws", async () => {
vi.mocked(exchangeCodeForTokens).mockRejectedValue(new Error("network"));
const res = await GET(validRequest());
expect(res.headers.get("location")).toContain("error=oidc_failed");
});
it("deletes the state cookie with the exact path it was set under", async () => {
// RFC 6265 keys cookies by (name, domain, path) — a deletion without
// `Path=/api/auth/oidc` would target a `/`-scoped cookie that does not
// exist and leave the real single-use blob alive.
const errorRes = await GET(makeRequest({ query: "?error=access_denied" }));
const errorCookie = errorRes.headers
.getSetCookie()
.find((c) => c.startsWith("oidc_auth_state="))!;
expect(errorCookie.toLowerCase()).toContain("path=/api/auth/oidc");
mockIdentity();
mockUserLookups({
byIdentity: userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }),
});
const successRes = await GET(validRequest());
const successCookie = successRes.headers
.getSetCookie()
.find((c) => c.startsWith("oidc_auth_state="))!;
expect(successCookie.toLowerCase()).toContain("path=/api/auth/oidc");
});
});
describe("GET /api/auth/oidc/callback — native branch", () => {
beforeEach(() => {
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
remaining: 20,
reset: 0,
ip: "1.2.3.4",
} as never);
vi.mocked(getOidcConfig).mockReturnValue(CONFIG);
vi.mocked(discoverOidcMetadata).mockResolvedValue(METADATA);
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0);
vi.mocked(mintNativeHandoff).mockResolvedValue({
code: "hlh_test_code",
handoffId: "ho-1",
});
});
function nativeStateCookie(overrides: Record<string, unknown> = {}): string {
return stateCookie({
state: "STATE",
nonce: "nonce-1",
codeVerifier: "verifier-1",
next: "/",
native: true,
appCodeChallenge: "app-challenge-123",
...overrides,
});
}
function nativeRequest(): NextRequest {
return makeRequest({
query: "?code=abc&state=STATE",
cookie: nativeStateCookie(),
});
}
it("mints a handoff code and 302s to the custom scheme — no session", async () => {
mockIdentity();
mockUserLookups({
byIdentity: userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }),
});
const res = await GET(nativeRequest());
const location = res.headers.get("location")!;
expect(location.startsWith("healthlog://oidc-callback?code=")).toBe(true);
expect(location).toContain("code=hlh_test_code");
// The app's challenge (from the tamper-authenticated blob) is bound at mint.
expect(mintNativeHandoff).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
appCodeChallenge: "app-challenge-123",
}),
);
// No web session is minted on the native branch.
expect(createSession).not.toHaveBeenCalled();
});
it("hands the MFA ticket to the custom scheme (no session, no MFA cookie)", async () => {
mockIdentity();
mockUserLookups({
byIdentity: userRow({
oidcIssuer: METADATA.issuer,
oidcSub: "sub-1",
totpConfirmedAt: new Date(),
}),
});
const res = await GET(nativeRequest());
const location = res.headers.get("location")!;
expect(location.startsWith("healthlog://oidc-callback?")).toBe(true);
expect(location).toContain("mfa_ticket=mfa-ticket-1");
expect(location).toContain("methods=totp%2Crecovery");
// No handoff code on the MFA branch, and no session.
expect(location).not.toContain("code=hlh_");
expect(mintNativeHandoff).not.toHaveBeenCalled();
expect(createSession).not.toHaveBeenCalled();
// The web-only MFA handoff cookie is never set on the native branch.
const mfaCookie = res.headers
.getSetCookie()
.find((c) => c.startsWith("oidc_mfa_handoff="));
expect(mfaCookie).toBeUndefined();
});
it("delivers a post-identity error to the custom scheme, not a web page", async () => {
mockIdentity();
// An account already pinned to a different IdP identity → identity_conflict.
mockUserLookups({
byIdentity: null,
byEmail: userRow({ oidcIssuer: "https://other.example", oidcSub: "x" }),
});
const res = await GET(nativeRequest());
const location = res.headers.get("location")!;
expect(location).toBe(
"healthlog://oidc-callback?error=oidc_identity_conflict",
);
});
it("delivers an IdP denial to the custom scheme", async () => {
const res = await GET(
makeRequest({
query: "?error=access_denied&state=STATE",
cookie: nativeStateCookie(),
}),
);
expect(res.headers.get("location")).toBe(
"healthlog://oidc-callback?error=oidc_denied",
);
});
it("falls back to the WEB oidc_state page on a CSRF mismatch (broken flow)", async () => {
const res = await GET(
makeRequest({
query: "?code=abc&state=WRONG",
cookie: nativeStateCookie(),
}),
);
// A tampered/broken round-trip never hands the app a scheme redirect.
expect(res.headers.get("location")).toContain(
"/auth/login?error=oidc_state",
);
});
});
+516
View File
@@ -0,0 +1,516 @@
import { timingSafeEqual } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { decrypt } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { createSession } from "@/lib/auth/session";
import { recordSignInDevice } from "@/lib/auth/login-alert";
import {
deriveUniqueUsername,
discoverOidcMetadata,
exchangeCodeForTokens,
fetchUserinfoEmail,
getOidcConfig,
getOidcRedirectUri,
oidcAppUrl,
verifyIdToken,
} from "@/lib/auth/oidc";
import {
OIDC_MFA_COOKIE,
OIDC_MFA_COOKIE_PATH,
OIDC_MFA_TTL_MS,
OIDC_STATE_COOKIE,
OIDC_STATE_COOKIE_PATH,
} from "@/lib/auth/oidc-cookie";
import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { resolveServerDefaultTimezone } from "@/lib/tz/resolver";
import {
buildNativeCallbackUrl,
mintNativeHandoff,
} from "@/lib/auth/oidc-native-handoff";
const LOGIN_ERROR_URL = "/auth/login";
function errorRedirect(reason: string): NextResponse {
const response = NextResponse.redirect(
oidcAppUrl(`${LOGIN_ERROR_URL}?error=${reason}`),
);
deleteStateCookie(response);
return response;
}
/**
* The state cookie is set with `path: "/api/auth/oidc"`; the delete must
* repeat that path (RFC 6265 keys cookies by name+domain+path — a bare
* delete would target `/` and never match, leaving the single-use blob
* alive for the rest of its TTL).
*/
function deleteStateCookie(response: NextResponse): void {
response.cookies.delete({
name: OIDC_STATE_COOKIE,
path: OIDC_STATE_COOKIE_PATH,
});
}
/**
* Closed set of RFC 6749 §4.1.2.1 / OIDC Core §3.1.2.6 authorization error
* codes. The wide-event meta only ever carries a member of this set (or
* "other") — never the raw, attacker-influenceable query value.
*/
const KNOWN_IDP_ERROR_CODES = new Set([
"access_denied",
"invalid_request",
"unauthorized_client",
"unsupported_response_type",
"invalid_scope",
"server_error",
"temporarily_unavailable",
"interaction_required",
"login_required",
"account_selection_required",
"consent_required",
"invalid_request_uri",
"invalid_request_object",
"request_not_supported",
"request_uri_not_supported",
"registration_not_supported",
]);
interface StoredOidcState {
state: string;
nonce: string;
codeVerifier: string;
next: string;
/**
* v1.30.x — set only when the login GET carried `client=native`. When true
* the callback mints a handoff code (or MFA ticket) and redirects to the
* custom scheme instead of minting a session. Both fields live ONLY inside
* the AES-256-GCM blob, so they are tamper-authenticated: a network attacker
* or the IdP cannot flip a web login into a native one or swap the app
* challenge.
*/
native?: boolean;
/** The app's S256 PKCE challenge, bound at login; verified at the exchange. */
appCodeChallenge?: string;
}
function isStoredOidcState(value: unknown): value is StoredOidcState {
const v = value as Record<string, unknown>;
if (
typeof value !== "object" ||
value === null ||
typeof v.state !== "string" ||
typeof v.nonce !== "string" ||
typeof v.codeVerifier !== "string" ||
typeof v.next !== "string"
) {
return false;
}
// Optional native fields: when present they must be the right shape, and a
// native start MUST carry the challenge the exchange verifies against.
if (v.native !== undefined) {
if (v.native !== true) return false;
if (typeof v.appCodeChallenge !== "string") return false;
}
return true;
}
/**
* OIDC callback. Must work with NO existing session — this route
* authenticates the user in the first place, unlike the per-account
* integration callbacks (Withings/Polar/etc.) which all require
* `requireAuth()`. Every failure branch redirects to `/auth/login?error=...`
* (never JSON — this is a top-level browser navigation) and deletes the
* single-use state cookie, mirroring `src/app/api/withings/callback/route.ts`.
*/
export const GET = apiHandler(async (req: NextRequest) => {
annotate({ action: { name: "auth.oidc.callback" } });
// Decrypt the single-use state blob FIRST. The `native` flag lives inside the
// AES-256-GCM payload (tamper-authenticated), so decrypting it up front lets
// every post-decrypt error branch redirect to the custom scheme instead of
// the web login page — the app parses `?error=<reason>` and never lands on a
// dead web page. A missing/undecryptable blob leaves `stored` null (isNative
// false) and those branches fall back to the web redirect by construction.
const rawCookie = req.cookies.get(OIDC_STATE_COOKIE)?.value;
let stored: StoredOidcState | null = null;
if (rawCookie) {
try {
const parsed: unknown = JSON.parse(decrypt(rawCookie));
if (isStoredOidcState(parsed)) stored = parsed;
} catch {
stored = null;
}
}
const isNative = stored?.native === true;
// Native-aware error redirect: the custom scheme for a decrypted native flow,
// the web login page otherwise. Deletes the single-use state cookie either
// way. An error redirect carries no code, ticket, or session, so routing it
// to the scheme leaks nothing (spec §1).
const failRedirect = (reason: string): NextResponse => {
if (isNative) {
const response = NextResponse.redirect(
buildNativeCallbackUrl({ error: reason }),
);
deleteStateCookie(response);
return response;
}
return errorRedirect(reason);
};
const config = getOidcConfig();
if (!config) return failRedirect("oidc_disabled");
const rl = await checkAuthSurfaceRateLimit(
req,
"auth:oidc:callback",
20,
15 * 60 * 1000,
);
const ip = rl.ip ?? "unknown";
if (!rl.allowed) return failRedirect("oidc_rate_limited");
const { searchParams } = req.nextUrl;
const idpError = searchParams.get("error");
if (idpError) {
annotate({
meta: {
reason: "idp_denied",
idpError: KNOWN_IDP_ERROR_CODES.has(idpError) ? idpError : "other",
},
});
return failRedirect("oidc_denied");
}
const code = searchParams.get("code");
const state = searchParams.get("state");
// Broken round-trip / missing-or-unreadable state blob / CSRF mismatch all
// resolve to the web `oidc_state` fallback: the flow is fundamentally broken
// and there is nothing safe to hand the app (spec §1 web-fallback).
if (!code || !state) return errorRedirect("oidc_state");
if (!stored) return errorRedirect("oidc_state");
// CSRF check — timing-safe, byte-for-byte, mirroring the Withings callback.
if (
state.length !== stored.state.length ||
!timingSafeEqual(Buffer.from(state), Buffer.from(stored.state))
) {
annotate({ meta: { reason: "csrf" } });
return errorRedirect("oidc_state");
}
try {
const metadata = await discoverOidcMetadata(config);
const redirectUri = getOidcRedirectUri();
const tokens = await exchangeCodeForTokens({
metadata,
config,
code,
codeVerifier: stored.codeVerifier,
redirectUri,
});
if (!tokens.id_token) return failRedirect("oidc_failed");
const identity = await verifyIdToken({
metadata,
config,
idToken: tokens.id_token,
nonce: stored.nonce,
});
let email = identity.email;
let emailVerified = identity.emailVerified;
if (!email && tokens.access_token) {
const userinfo = await fetchUserinfoEmail({
metadata,
accessToken: tokens.access_token,
});
email = userinfo.email;
emailVerified = userinfo.emailVerified;
}
// (1) Durable identity match — the (issuer, sub) pair stamped on a
// previous login, link, or provision. Email is deliberately NOT
// consulted here: after the stamp it is a display field, so an
// IdP-side email change can refresh it below but can never re-point
// the login at a different account.
let user = await prisma.user.findFirst({
where: { oidcIssuer: metadata.issuer, oidcSub: identity.sub },
});
if (user) {
// Refresh the display email only — and only from a claim the IdP
// explicitly marks verified. Skipped when another account already
// holds the address (`email` is unique); the login itself proceeds
// either way, keyed by (issuer, sub).
const displayEmail = email ? email.toLowerCase() : null;
if (
displayEmail &&
emailVerified === true &&
user.email?.toLowerCase() !== displayEmail
) {
const emailTaken = await prisma.user.findFirst({
where: {
email: { equals: displayEmail, mode: "insensitive" },
NOT: { id: user.id },
},
select: { id: true },
});
if (!emailTaken) {
user = await prisma.user.update({
where: { id: user.id },
data: { email: displayEmail },
});
}
}
} else {
// No stamped identity yet — both remaining paths (link-once,
// provision) bind this (issuer, sub) to an account keyed by email,
// so the claim must be explicitly `email_verified: true`. An ABSENT
// claim is a reject, not a benefit of the doubt: an IdP that does
// not assert verification cannot anchor a link that later gates a
// health record.
if (!email) {
annotate({ meta: { reason: "no_email" } });
return failRedirect("oidc_no_email");
}
if (emailVerified !== true) {
annotate({ meta: { reason: "email_unverified" } });
return failRedirect("oidc_email_unverified");
}
const byEmail = await prisma.user.findFirst({
where: { email: { equals: email, mode: "insensitive" } },
});
if (byEmail) {
// (2) Link ONCE. An account already pinned to a different IdP
// identity is never silently re-bound — a rebind would let a
// second IdP identity that acquires the same email capture the
// account. Audited so the operator can see the collision.
if (byEmail.oidcIssuer !== null || byEmail.oidcSub !== null) {
await auditLog("auth.oidc.link_conflict", {
userId: byEmail.id,
ipAddress: ip,
});
annotate({ meta: { reason: "identity_conflict" } });
return failRedirect("oidc_identity_conflict");
}
user = await prisma.user.update({
where: { id: byEmail.id },
data: { oidcIssuer: metadata.issuer, oidcSub: identity.sub },
});
await auditLog("auth.oidc.linked", {
userId: user.id,
ipAddress: ip,
});
} else {
// (3) Provision.
const userCount = await prisma.user.count();
let registrationEnabled = true;
try {
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
});
if (settings && !settings.registrationEnabled && userCount > 0) {
registrationEnabled = false;
}
} catch {
// Table may not exist yet; allow provisioning (matches register/route.ts).
}
if (!registrationEnabled) {
annotate({ meta: { reason: "registration_disabled" } });
return failRedirect("oidc_registration_disabled");
}
const username = await deriveUniqueUsername(email, (candidate) =>
prisma.user
.findUnique({ where: { username: candidate } })
.then((u: unknown) => u !== null),
);
// Same server-default resolution the register route uses — an
// SSO-provisioned account has no registration form to carry the
// browser-detected timezone.
const timezone = await resolveServerDefaultTimezone();
// v1.28.48 — the first provisioned user becomes ADMIN. Deriving that
// role from the early `userCount` (read above, before username
// derivation) and then creating without coordination is the same
// check-then-act race the password-register route closed in v1.28.42:
// two concurrent OIDC first-logins racing the empty-DB window would
// both observe `0` and both be minted ADMIN. Serialise the
// count+insert behind a transaction-scoped advisory lock (released on
// commit/rollback) and re-count *inside* the lock so the second
// provision observes the first's committed row and is minted USER.
// The lock key STRING matches the register route's — an OIDC
// first-login racing a password first-registration is serialised
// against the same lock, so only one ADMIN can ever be minted.
user = await prisma.$transaction(async (tx) => {
// `pg_advisory_xact_lock` returns void, which the client cannot
// deserialize as a column — selecting FROM it yields a plain int row.
await tx.$queryRaw`
SELECT 1 AS locked
FROM pg_advisory_xact_lock(hashtextextended('register:first-admin', 0))
`;
const priorUsers = await tx.user.count();
const role = priorUsers === 0 ? "ADMIN" : "USER";
return tx.user.create({
data: {
email: email.toLowerCase(),
username,
passwordHash: null,
role,
timezone,
oidcIssuer: metadata.issuer,
oidcSub: identity.sub,
},
});
});
await auditLog("auth.oidc.provisioned", {
userId: user.id,
ipAddress: ip,
});
}
}
// Native second factor. OIDC is a DELEGATED factor: it proves the IdP
// authenticated someone, but it never substitutes for this app's own
// second factor. An account with a confirmed TOTP secret or a
// registered security key gets the same MFA challenge step password
// login gets — no session is minted here; the single-use ticket rides
// the handoff cookie to the login page (web) or the custom scheme
// (native), which render the same challenge UI and complete at
// `/api/auth/mfa/verify`. (The trusted-device skip cannot apply on this
// path: its cookie is SameSite=Strict, which the browser withholds on an
// IdP-initiated redirect chain.)
const hasTotp = Boolean(user.totpConfirmedAt);
const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
where: { userId: user.id },
});
const hasWebauthn = webauthnKeyCount > 0;
if (hasTotp || hasWebauthn) {
const challenge = await createMfaChallenge(user.id, "login");
const methods: ("totp" | "recovery" | "webauthn")[] = [];
if (hasTotp) methods.push("totp", "recovery");
if (hasWebauthn) methods.push("webauthn");
await auditLog("auth.mfa.challenge", {
userId: user.id,
ipAddress: ip,
details: { source: isNative ? "login.oidc.native" : "login.oidc" },
});
annotate({
action: { name: "auth.mfa.challenge" },
meta: { mfa_required: true },
});
// Native: hand the app the SAME single-use ticket via the custom scheme.
// No `OIDC_MFA_COOKIE` (it exists only to ferry the ticket to the web
// login page's script) and no session — the app completes at the
// existing native-capable `/api/auth/mfa/verify{,/webauthn}` endpoints,
// which end in `finishLogin` and return the native bundle directly. The
// ticket alone is inert: single-use, hashed at rest, 5-minute TTL,
// 5-attempt cap, factor-gated.
if (isNative) {
const response = NextResponse.redirect(
buildNativeCallbackUrl({
mfa_ticket: challenge.ticket,
methods: methods.join(","),
}),
);
deleteStateCookie(response);
return response;
}
const loginUrl = oidcAppUrl(LOGIN_ERROR_URL);
if (stored.next !== "/") {
loginUrl.searchParams.set("next", stored.next);
}
const response = NextResponse.redirect(loginUrl);
deleteStateCookie(response);
response.cookies.set(
OIDC_MFA_COOKIE,
JSON.stringify({ ticket: challenge.ticket, methods }),
{
// Not httpOnly by design — see the constant's doc comment.
httpOnly: false,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: Math.floor(OIDC_MFA_TTL_MS / 1000),
path: OIDC_MFA_COOKIE_PATH,
},
);
return response;
}
const ua = req.headers.get("user-agent");
// Native, no MFA: mint a one-time handoff code and hand it to the app via
// the custom scheme. NO session and NO cookie are minted here — the app
// exchanges the code at `POST /api/auth/oidc/native/token` for the native
// bundle. `userId` is the resolved identity; the app's S256 challenge
// (bound at login, tamper-authenticated in the state blob) locks the code
// to the app instance that started the flow. The session/device are
// recorded at the exchange via `finishLogin`, not here.
if (isNative) {
const { code: handoffCode } = await mintNativeHandoff({
userId: user.id,
// Guaranteed present by `isStoredOidcState` when `native === true`.
appCodeChallenge: stored.appCodeChallenge as string,
ipAddress: ip,
userAgent: ua,
});
await auditLog("auth.oidc.native.handoff_minted", {
userId: user.id,
ipAddress: ip,
});
annotate({ action: { name: "auth.oidc.native.handoff_minted" } });
const response = NextResponse.redirect(
buildNativeCallbackUrl({ code: handoffCode }),
);
deleteStateCookie(response);
return response;
}
// v1.23 parity with password login — the admin-enforced-MFA hint cookie
// sends a single-factor account into forced enrollment after sign-in.
await syncMfaEnrollCookie(user.id, {
totpConfirmedAt: user.totpConfirmedAt,
mfaEnforced: user.mfaEnforced,
});
// The 5th argument stays null: OIDC is a delegated factor, so an SSO
// session must never satisfy `requireFreshMfa` step-up (MFA disable,
// encryption-key rotation, encrypted export, account deletion) — the
// same treatment a trusted-device login gets.
await createSession(
user.id,
user.onboardingCompletedAt === null,
ip,
ua,
null,
);
void recordSignInDevice({ userId: user.id, ip, userAgent: ua });
await auditLog("auth.oidc.login", { userId: user.id, ipAddress: ip });
const response = NextResponse.redirect(oidcAppUrl(stored.next));
deleteStateCookie(response);
return response;
} catch (err) {
getEvent()?.setError(err);
annotate({ meta: { reason: "exchange_or_verify_failed" } });
return failRedirect("oidc_failed");
}
});
@@ -0,0 +1,216 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => null,
}));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn(),
}));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((s: string) => `enc(${s})`),
}));
vi.mock("@/lib/auth/secure-cookie", () => ({
shouldEmitSecureCookie: () => true,
}));
vi.mock("@/lib/auth/oidc", async () => {
// Keep the real `sanitizeOidcNextPath` — it's the security-relevant part
// of this route, and the tests below assert its output round-trips into
// the state cookie rather than re-deriving the same logic here.
const actual =
await vi.importActual<typeof import("@/lib/auth/oidc")>("@/lib/auth/oidc");
return {
getOidcConfig: vi.fn(),
discoverOidcMetadata: vi.fn(),
buildAuthorizationUrl: vi.fn(() => "https://idp.example.com/authorize?x=1"),
getOidcRedirectUri: () =>
"https://healthlog.example.com/api/auth/oidc/callback",
oidcAppUrl: (path: string) =>
new URL(path, "https://healthlog.example.com"),
sanitizeOidcNextPath: actual.sanitizeOidcNextPath,
};
});
import { GET } from "../route";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { getOidcConfig, discoverOidcMetadata } from "@/lib/auth/oidc";
function makeRequest(path = "/api/auth/oidc/login"): NextRequest {
return new NextRequest(`http://localhost${path}`);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
remaining: 10,
reset: 0,
ip: "1.2.3.4",
} as never);
});
describe("GET /api/auth/oidc/login", () => {
it("redirects to /auth/login?error=oidc_disabled when unconfigured", async () => {
vi.mocked(getOidcConfig).mockReturnValue(null);
const res = await GET(makeRequest());
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain(
"/auth/login?error=oidc_disabled",
);
});
it("redirects to the rate-limit error page when throttled", async () => {
vi.mocked(getOidcConfig).mockReturnValue({
issuerUrl: "https://idp.example.com",
clientId: "client-1",
clientSecret: "secret-1",
scopes: "openid email profile",
buttonLabel: "SSO",
});
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
reset: 0,
ip: "1.2.3.4",
} as never);
const res = await GET(makeRequest());
expect(res.headers.get("location")).toContain("error=oidc_rate_limited");
});
it("redirects to the IdP and sets the encrypted state cookie on success", async () => {
vi.mocked(getOidcConfig).mockReturnValue({
issuerUrl: "https://idp.example.com",
clientId: "client-1",
clientSecret: "secret-1",
scopes: "openid email profile",
buttonLabel: "SSO",
});
vi.mocked(discoverOidcMetadata).mockResolvedValue({
issuer: "https://idp.example.com",
authorization_endpoint: "https://idp.example.com/authorize",
token_endpoint: "https://idp.example.com/token",
jwks_uri: "https://idp.example.com/jwks",
});
const res = await GET(makeRequest("/api/auth/oidc/login?next=/dashboard"));
expect(res.headers.get("location")).toBe(
"https://idp.example.com/authorize?x=1",
);
const cookie = res.cookies.get("oidc_auth_state");
expect(cookie?.value).toMatch(/^enc\(/);
expect(cookie?.httpOnly).toBe(true);
expect(cookie?.sameSite).toBe("lax");
});
it("neutralizes a backslash-normalization open-redirect payload in next", async () => {
vi.mocked(getOidcConfig).mockReturnValue({
issuerUrl: "https://idp.example.com",
clientId: "client-1",
clientSecret: "secret-1",
scopes: "openid email profile",
buttonLabel: "SSO",
});
vi.mocked(discoverOidcMetadata).mockResolvedValue({
issuer: "https://idp.example.com",
authorization_endpoint: "https://idp.example.com/authorize",
token_endpoint: "https://idp.example.com/token",
jwks_uri: "https://idp.example.com/jwks",
});
const res = await GET(
makeRequest(
`/api/auth/oidc/login?next=${encodeURIComponent("/\\evil.com")}`,
),
);
const cookie = res.cookies.get("oidc_auth_state");
const stored = JSON.parse(
cookie!.value.replace(/^enc\(/, "").replace(/\)$/, ""),
);
expect(stored.next).toBe("/");
});
it("falls back to the login error page on discovery failure", async () => {
vi.mocked(getOidcConfig).mockReturnValue({
issuerUrl: "https://idp.example.com",
clientId: "client-1",
clientSecret: "secret-1",
scopes: "openid email profile",
buttonLabel: "SSO",
});
vi.mocked(discoverOidcMetadata).mockRejectedValue(new Error("boom"));
const res = await GET(makeRequest());
expect(res.headers.get("location")).toContain("error=oidc_failed");
});
describe("native SSO start", () => {
const CONFIG = {
issuerUrl: "https://idp.example.com",
clientId: "client-1",
clientSecret: "secret-1",
scopes: "openid email profile",
buttonLabel: "SSO",
};
const CHALLENGE = "a".repeat(43); // valid S256 length
function storedFromCookie(res: Awaited<ReturnType<typeof GET>>) {
const cookie = res.cookies.get("oidc_auth_state");
return JSON.parse(cookie!.value.replace(/^enc\(/, "").replace(/\)$/, ""));
}
it("rejects a native start with a missing challenge to the custom scheme", async () => {
vi.mocked(getOidcConfig).mockReturnValue(CONFIG);
const res = await GET(makeRequest("/api/auth/oidc/login?client=native"));
expect(res.headers.get("location")).toBe(
"healthlog://oidc-callback?error=oidc_invalid_request",
);
});
it("routes native login errors (disabled) to the custom scheme", async () => {
vi.mocked(getOidcConfig).mockReturnValue(null);
const res = await GET(
makeRequest(
`/api/auth/oidc/login?client=native&code_challenge=${CHALLENGE}`,
),
);
expect(res.headers.get("location")).toBe(
"healthlog://oidc-callback?error=oidc_disabled",
);
});
it("folds native:true + appCodeChallenge into the state blob and pins next to /", async () => {
vi.mocked(getOidcConfig).mockReturnValue(CONFIG);
vi.mocked(discoverOidcMetadata).mockResolvedValue({
issuer: "https://idp.example.com",
authorization_endpoint: "https://idp.example.com/authorize",
token_endpoint: "https://idp.example.com/token",
jwks_uri: "https://idp.example.com/jwks",
});
const res = await GET(
makeRequest(
`/api/auth/oidc/login?client=native&code_challenge=${CHALLENGE}&next=/dashboard`,
),
);
// Still a normal redirect to the IdP — the native fact rides the blob.
expect(res.headers.get("location")).toBe(
"https://idp.example.com/authorize?x=1",
);
const stored = storedFromCookie(res);
expect(stored.native).toBe(true);
expect(stored.appCodeChallenge).toBe(CHALLENGE);
// `next` is meaningless to the app — pinned to "/".
expect(stored.next).toBe("/");
// The server↔IdP verifier stays independent and present.
expect(typeof stored.codeVerifier).toBe("string");
});
});
});
+132
View File
@@ -0,0 +1,132 @@
import { randomBytes } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { encrypt } from "@/lib/crypto";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { s256Challenge, isValidChallenge } from "@/lib/mcp/oauth/pkce";
import { buildNativeCallbackUrl } from "@/lib/auth/oidc-native-handoff";
import {
buildAuthorizationUrl,
discoverOidcMetadata,
getOidcConfig,
getOidcRedirectUri,
oidcAppUrl,
sanitizeOidcNextPath,
} from "@/lib/auth/oidc";
import {
OIDC_STATE_COOKIE,
OIDC_STATE_COOKIE_PATH,
OIDC_STATE_TTL_MS,
} from "@/lib/auth/oidc-cookie";
/**
* Redirects the browser to the configured OIDC provider's authorization
* endpoint. Full-page navigation, not a fetch — every failure branch
* redirects to `/auth/login?error=...` rather than returning a JSON
* envelope, mirroring `src/app/api/withings/connect/route.ts`.
*/
export const GET = apiHandler(async (req: NextRequest) => {
annotate({ action: { name: "auth.oidc.login" } });
// v1.30.x — native SSO. The iOS app opens this URL inside an
// `ASWebAuthenticationSession` with `client=native` and its own PKCE
// `code_challenge`. Both are query params here only; they are validated and
// folded into the AES-256-GCM state blob so nothing native-specific rides the
// IdP round-trip as a URL parameter. The `client` param is attacker-flippable
// at this route, but routing THIS request's error to the custom scheme leaks
// nothing — an error redirect carries no code, ticket, or session (spec §1).
const isNative = req.nextUrl.searchParams.get("client") === "native";
const appCodeChallenge = req.nextUrl.searchParams.get("code_challenge");
// Native-aware error redirect: the scheme for a native start, the web login
// page otherwise. Machine-readable `error=<reason>` in both.
const loginError = (reason: string): NextResponse => {
if (isNative) {
return NextResponse.redirect(buildNativeCallbackUrl({ error: reason }));
}
return NextResponse.redirect(oidcAppUrl(`/auth/login?error=${reason}`));
};
const config = getOidcConfig();
if (!config) {
return loginError("oidc_disabled");
}
const rl = await checkAuthSurfaceRateLimit(
req,
"auth:oidc:login",
10,
15 * 60 * 1000,
);
if (!rl.allowed) {
return loginError("oidc_rate_limited");
}
// A native start MUST carry a valid S256 challenge (43–128 chars). `plain` is
// structurally unsupported — the exchange only ever verifies S256.
if (isNative && !isValidChallenge(appCodeChallenge)) {
annotate({ meta: { reason: "invalid_native_request" } });
return loginError("oidc_invalid_request");
}
let metadata;
try {
metadata = await discoverOidcMetadata(config);
} catch (err) {
getEvent()?.setError(err);
return loginError("oidc_failed");
}
const state = randomBytes(32).toString("base64url");
const nonce = randomBytes(32).toString("base64url");
const codeVerifier = randomBytes(64).toString("base64url");
const codeChallenge = s256Challenge(codeVerifier);
// `next` is a post-login WEB path, meaningless to the app — pin it to "/" on
// the native branch (one less input to sanitize).
const safeNext = isNative
? "/"
: sanitizeOidcNextPath(req.nextUrl.searchParams.get("next"), req.url);
const redirectUri = getOidcRedirectUri();
const authorizationUrl = buildAuthorizationUrl({
metadata,
config,
state,
nonce,
codeChallenge,
redirectUri,
});
const response = NextResponse.redirect(authorizationUrl);
// The server↔IdP PKCE (`codeVerifier`) stays independent of the app↔server
// PKCE (`appCodeChallenge`). The native flag + challenge live ONLY inside the
// AES-256-GCM blob, so the callback branches on tamper-authenticated state a
// network attacker or the IdP cannot flip.
const cookiePayload = encrypt(
JSON.stringify({
state,
nonce,
codeVerifier,
next: safeNext,
...(isNative
? { native: true as const, appCodeChallenge: appCodeChallenge! }
: {}),
}),
);
response.cookies.set(OIDC_STATE_COOKIE, cookiePayload, {
httpOnly: true,
secure: shouldEmitSecureCookie(),
// Cross-site top-level redirect back from the IdP, same reasoning as
// the session cookie and the Withings OAuth state cookie.
sameSite: "lax",
maxAge: Math.floor(OIDC_STATE_TTL_MS / 1000),
// Shared constant — the callback's delete must repeat this exact path
// (RFC 6265 keys cookies by name+domain+path).
path: OIDC_STATE_COOKIE_PATH,
});
return response;
});
@@ -0,0 +1,231 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: (fn: unknown) => fn,
}));
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn() } },
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn(),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/auth/native-client", () => ({
isCookielessNativeCaller: vi.fn(() => true),
}));
vi.mock("@/lib/auth/oidc-native-handoff", () => ({
consumeNativeHandoff: vi.fn(),
stampIssuedRefreshToken: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/login-response", () => ({
finishLogin: vi.fn(),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { isCookielessNativeCaller } from "@/lib/auth/native-client";
import {
consumeNativeHandoff,
stampIssuedRefreshToken,
} from "@/lib/auth/oidc-native-handoff";
import { finishLogin } from "@/lib/auth/login-response";
import { auditLog } from "@/lib/auth/audit";
const VALID_BODY = {
code: "hlh_" + "a".repeat(43),
codeVerifier: "a".repeat(43),
};
function makeRequest(
body: unknown = VALID_BODY,
headers: Record<string, string> = {},
): NextRequest {
return new NextRequest("http://localhost/api/auth/oidc/native/token", {
method: "POST",
// The genuine iOS client always marks itself native; the transport gate
// requires it. A gate-rejection test overrides `x-client-type` explicitly.
headers: {
"content-type": "application/json",
"x-client-type": "native",
...headers,
},
body: typeof body === "string" ? body : JSON.stringify(body),
});
}
function nativeBundleResponse() {
return NextResponse.json({
data: {
user: { id: "user-1", username: "alice" },
token: "hlk_access",
tokenExpiresAt: new Date().toISOString(),
refreshToken: "hlr_refresh",
refreshTokenExpiresAt: new Date().toISOString(),
},
error: null,
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
remaining: 10,
resetAt: 0,
ip: "1.2.3.4",
} as never);
vi.mocked(isCookielessNativeCaller).mockReturnValue(true);
});
describe("POST /api/auth/oidc/native/token", () => {
it("429 when rate-limited (before any state is touched)", async () => {
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: 0,
ip: "1.2.3.4",
} as never);
const res = await POST(makeRequest());
expect(res.status).toBe(429);
expect(consumeNativeHandoff).not.toHaveBeenCalled();
});
it("401 and never consumes when the transport is not cookie-less native", async () => {
vi.mocked(isCookielessNativeCaller).mockReturnValue(false);
const res = await POST(makeRequest());
expect(res.status).toBe(401);
expect(consumeNativeHandoff).not.toHaveBeenCalled();
});
it("422 on a malformed body", async () => {
const res = await POST(makeRequest({ code: "nope", codeVerifier: "x" }));
expect(res.status).toBe(422);
expect(consumeNativeHandoff).not.toHaveBeenCalled();
});
it("returns ONE identical generic 401 across every invalid-code class", async () => {
const statuses = [
{ status: "not_found" },
{ status: "replayed", userId: "user-1" },
{ status: "expired" },
{ status: "pkce_mismatch" },
{ status: "race_lost" },
] as const;
const bodies: string[] = [];
for (const s of statuses) {
vi.mocked(consumeNativeHandoff).mockResolvedValue(s as never);
const res = await POST(makeRequest());
expect(res.status).toBe(401);
bodies.push(await res.text());
}
// Also the deleted-user-mid-flight branch.
vi.mocked(consumeNativeHandoff).mockResolvedValue({
status: "ok",
userId: "user-1",
handoffId: "ho-1",
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
const res = await POST(makeRequest());
expect(res.status).toBe(401);
bodies.push(await res.text());
// Byte-identical envelope — a code-guesser learns nothing.
expect(new Set(bodies).size).toBe(1);
});
it("does not mint on the deleted-user branch", async () => {
vi.mocked(consumeNativeHandoff).mockResolvedValue({
status: "ok",
userId: "user-1",
handoffId: "ho-1",
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
await POST(makeRequest());
expect(finishLogin).not.toHaveBeenCalled();
});
it("200 on success: mints via the native source, stamps the issued hash, audits", async () => {
vi.mocked(consumeNativeHandoff).mockResolvedValue({
status: "ok",
userId: "user-1",
handoffId: "ho-1",
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "user-1",
username: "alice",
onboardingCompletedAt: new Date(),
} as never);
vi.mocked(finishLogin).mockResolvedValue(nativeBundleResponse());
const res = await POST(
makeRequest(VALID_BODY, { "x-client-type": "native" }),
);
expect(res.status).toBe(200);
const finishArg = vi.mocked(finishLogin).mock.calls[0][0];
expect(finishArg.source).toBe("login.oidc.native");
// `mfaVerified` is never set — the SSO login can't satisfy step-up.
expect(finishArg.mfaVerified).toBeUndefined();
// The issued refresh token's hash is stamped for replay reach-back.
expect(stampIssuedRefreshToken).toHaveBeenCalledWith("ho-1", "hlr_refresh");
expect(auditLog).toHaveBeenCalledWith(
"auth.oidc.login",
expect.objectContaining({
userId: "user-1",
details: { transport: "native" },
}),
);
// The client still receives the bundle (clone left the stream intact).
const body = await res.json();
expect(body.data.refreshToken).toBe("hlr_refresh");
});
it("is NOT blocked by OIDC_ONLY (it completes an OIDC-initiated login)", async () => {
const prev = process.env.OIDC_ONLY;
process.env.OIDC_ONLY = "true";
try {
vi.mocked(consumeNativeHandoff).mockResolvedValue({
status: "ok",
userId: "user-1",
handoffId: "ho-1",
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "user-1",
username: "alice",
onboardingCompletedAt: new Date(),
} as never);
vi.mocked(finishLogin).mockResolvedValue(nativeBundleResponse());
const res = await POST(
makeRequest(VALID_BODY, { "x-client-type": "native" }),
);
expect(res.status).toBe(200);
} finally {
if (prev === undefined) delete process.env.OIDC_ONLY;
else process.env.OIDC_ONLY = prev;
}
});
});
+172
View File
@@ -0,0 +1,172 @@
/**
* POST /api/auth/oidc/native/token
*
* The cookie-less native leg of the OIDC SSO flow (design spec §4). The app
* presents the one-time handoff code it received on
* `healthlog://oidc-callback?code=` plus its PKCE `codeVerifier`; on success it
* receives the SAME native token bundle password login issues
* (`{ token, tokenExpiresAt, refreshToken, refreshTokenExpiresAt }`).
*
* Security-critical invariants:
* - It IS the credential mint (same class as `/api/auth/mfa/verify`): no
* session or Bearer is required to reach it.
* - The transport gate (`isCookielessNativeCaller`) makes the endpoint
* structurally incapable of minting a web session or handing a 60-day refresh
* token into a browser context — a Mozilla UA or an inbound session cookie is
* rejected before any state is touched.
* - `userId` is derived SOLELY from the server-side handoff row — the request
* carries no identity field.
* - A single generic 401 covers not-found / expired / used / PKCE-mismatch /
* deleted-user so a code-guesser learns nothing; distinct `annotate` names
* keep the wide events diagnosable.
* - `mfaVerifiedAt` is never set — a Bearer transport can never satisfy
* `requireFreshMfa` step-up, so an SSO login can never satisfy step-up.
*/
import { NextRequest } from "next/server";
import { apiError, safeJson } from "@/lib/api-response";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { isCookielessNativeCaller } from "@/lib/auth/native-client";
import { isNativeClientRequest } from "@/lib/auth/issue-token";
import { finishLogin } from "@/lib/auth/login-response";
import {
consumeNativeHandoff,
stampIssuedRefreshToken,
} from "@/lib/auth/oidc-native-handoff";
import { oidcNativeTokenSchema } from "@/lib/validations/oidc-native";
export const dynamic = "force-dynamic";
/** The single generic rejection for every invalid-code class (steps 4–9). */
function invalidCode() {
return apiError("Invalid or expired code", 401);
}
export const POST = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "auth.oidc.native.token" } });
// (1) Rate limit — byte-for-byte the MFA-verify surface's posture, including
// the trust-violation collapse semantics.
const rl = await checkAuthSurfaceRateLimit(
request,
"auth:oidc:native-token",
10,
15 * 60 * 1000,
);
const ip = rl.ip ?? "unknown";
if (!rl.allowed) {
return apiError("Too many attempts. Please try again later.", 429, {
headers: rateLimitHeaders(rl),
});
}
// (2) Transport gate — this endpoint only ever hands a native token bundle.
// A browser (Mozilla UA or an inbound session cookie) is rejected, AND the
// caller must present an explicit native marker (`isNativeClientRequest`, the
// SAME predicate `finishLogin` uses to pick its bearer branch) — otherwise a
// blank-UA caller could slip past `isCookielessNativeCaller` into the cookie
// branch and be handed a `Set-Cookie` session instead of the bundle. Both
// together make the cookie path structurally unreachable here.
if (
!isCookielessNativeCaller(request.headers) ||
!isNativeClientRequest(request.headers)
) {
annotate({ action: { name: "auth.oidc.native.token.wrong_transport" } });
return invalidCode();
}
await ensureDbCompatibility();
// (3) Body — bounded, then the exact wire contract.
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = oidcNativeTokenSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
}
const { code, codeVerifier } = parsed.data;
// (4–8) Lookup + replay/expiry/PKCE + atomic single-use consume.
const result = await consumeNativeHandoff(code, codeVerifier);
if (result.status !== "ok") {
switch (result.status) {
case "not_found":
annotate({ action: { name: "auth.oidc.native.token.not_found" } });
break;
case "replayed":
// Containment + the audit row already ran inside `consumeNativeHandoff`;
// report whether the issued pair was actually revoked (false only in the
// sub-ms pre-stamp window).
annotate({
action: { name: "auth.oidc.native.handoff_replay" },
meta: { revoked_issued_pair: result.revokedIssuedPair },
});
break;
case "expired":
annotate({ action: { name: "auth.oidc.native.token.expired" } });
break;
case "pkce_mismatch":
annotate({ action: { name: "auth.oidc.native.pkce_failed" } });
break;
case "race_lost":
annotate({ action: { name: "auth.oidc.native.token.race_lost" } });
break;
}
return invalidCode();
}
// (9) Load the user (may have been deleted mid-flight).
const user = await prisma.user.findUnique({
where: { id: result.userId },
select: { id: true, username: true, onboardingCompletedAt: true },
});
if (!user) {
annotate({ action: { name: "auth.oidc.native.token.user_missing" } });
return invalidCode();
}
// (10) Mint the native bundle. Step (2) guarantees the cookie-less-native
// branch of `finishLogin`, i.e. the 24h access + 60d rotating refresh with
// the `auth.token.autoissue.native` audit row. `mfaVerified` is deliberately
// unset — the SSO login can never satisfy step-up.
const ua = request.headers.get("user-agent");
const response = await finishLogin({
user,
request,
ip,
userAgent: ua,
source: "login.oidc.native",
});
// (11) Stamp the issued refresh token's hash for replay-containment
// reach-back, then audit the completed SSO login. Reading the bundle off a
// clone leaves the client's response stream intact. Best-effort: the code is
// already single-use-consumed, so a stamping hiccup only forgoes the
// replay-revoke for this one row.
try {
const bundle = (await response.clone().json()) as {
data?: { refreshToken?: unknown };
};
const refreshToken = bundle.data?.refreshToken;
if (typeof refreshToken === "string") {
await stampIssuedRefreshToken(result.handoffId, refreshToken);
}
} catch {
// Non-fatal — see comment above.
}
await auditLog("auth.oidc.login", {
userId: user.id,
ipAddress: ip,
details: { transport: "native" },
});
return response;
});
+19
View File
@@ -0,0 +1,19 @@
import { apiSuccess } from "@/lib/api-response";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { getOidcConfig, isOidcOnly } from "@/lib/auth/oidc";
export const dynamic = "force-dynamic";
/** Public — the login page reads this pre-session to decide whether to
* render the SSO button and whether to hide password/passkey login. Pure
* env reads; nothing here can throw. */
export const GET = apiHandler(async () => {
annotate({ action: { name: "auth.oidc.status" } });
const config = getOidcConfig();
return apiSuccess({
enabled: config !== null,
buttonLabel: config?.buttonLabel ?? null,
only: isOidcOnly(),
});
});
@@ -0,0 +1,82 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/auth/passkey", () => ({
createAuthenticationOptions: vi.fn().mockResolvedValue({
options: {},
challengeId: "ch-1",
}),
}));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 9,
reset: 0,
ip: "203.0.113.1",
}),
rateLimitHeaders: vi.fn(() => ({})),
}));
import { POST } from "../route";
import { createAuthenticationOptions } from "@/lib/auth/passkey";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
const OIDC_ENV_KEYS = [
"OIDC_ISSUER_URL",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_ONLY",
] as const;
const original: Record<string, string | undefined> = {};
function makeRequest(): NextRequest {
return new NextRequest("http://localhost/api/auth/passkey/login-options", {
method: "POST",
});
}
beforeEach(() => {
vi.resetAllMocks();
for (const key of OIDC_ENV_KEYS) original[key] = process.env[key];
vi.mocked(createAuthenticationOptions).mockResolvedValue({
options: {},
challengeId: "ch-1",
} as never);
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
reset: 0,
ip: "203.0.113.1",
} as never);
});
afterEach(() => {
for (const key of OIDC_ENV_KEYS) {
if (original[key] === undefined) delete process.env[key];
else process.env[key] = original[key];
}
});
describe("POST /api/auth/passkey/login-options — OIDC_ONLY server-side enforcement", () => {
it("rejects before minting a WebAuthn challenge when OIDC_ONLY is set", async () => {
process.env.OIDC_ISSUER_URL = "https://idp.example.com";
process.env.OIDC_CLIENT_ID = "client-1";
process.env.OIDC_CLIENT_SECRET = "secret-1";
process.env.OIDC_ONLY = "true";
const res = await POST(makeRequest());
expect(res.status).toBe(403);
expect(createAuthenticationOptions).not.toHaveBeenCalled();
});
it("still allows passkey login-options when OIDC_ONLY is set but the provider is half-configured", async () => {
delete process.env.OIDC_ISSUER_URL;
process.env.OIDC_CLIENT_ID = "client-1";
process.env.OIDC_CLIENT_SECRET = "secret-1";
process.env.OIDC_ONLY = "true";
const res = await POST(makeRequest());
expect(res.status).toBe(200);
});
});
@@ -1,11 +1,19 @@
import { createAuthenticationOptions } from "@/lib/auth/passkey";
import { apiSuccess } from "@/lib/api-response";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { isOidcOnly } from "@/lib/auth/oidc";
export const POST = apiHandler(async (request: Request) => {
// OIDC_ONLY must block passkey login too, not just password login.
if (isOidcOnly()) {
return apiError("Passkey login is disabled. Sign in with SSO.", 403, {
errorCode: "oidc_only",
});
}
// v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig.
const rl = await checkAuthSurfaceRateLimit(
request,
@@ -0,0 +1,129 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
apiToken: { create: vi.fn() },
refreshToken: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/passkey", () => ({
verifyAuthentication: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({
createSession: vi.fn().mockResolvedValue("session-id"),
setOnboardingPendingCookie: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/mfa-enrollment", () => ({
syncMfaEnrollCookie: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/login-alert", () => ({
recordSignInDevice: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
checkAuthSurfaceRateLimit: vi.fn(),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/hmac", () => ({
hashToken: vi.fn((raw: string) => `hashed:${raw}`),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { verifyAuthentication } from "@/lib/auth/passkey";
import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
const OIDC_ENV_KEYS = [
"OIDC_ISSUER_URL",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_ONLY",
] as const;
const original: Record<string, string | undefined> = {};
function makeRequest(): NextRequest {
return new NextRequest("http://localhost/api/auth/passkey/login-verify", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ challengeId: "ch-1", credential: { id: "cred-1" } }),
});
}
beforeEach(() => {
vi.resetAllMocks();
for (const key of OIDC_ENV_KEYS) original[key] = process.env[key];
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "user-1",
username: "testuser",
email: "user@example.com",
} as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
reset: 0,
} as never);
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: 0,
ip: "203.0.113.1",
} as never);
vi.mocked(verifyAuthentication).mockResolvedValue({
verification: { verified: true },
passkey: { userId: "user-1" },
} as never);
});
afterEach(() => {
for (const key of OIDC_ENV_KEYS) {
if (original[key] === undefined) delete process.env[key];
else process.env[key] = original[key];
}
});
describe("POST /api/auth/passkey/login-verify — OIDC_ONLY server-side enforcement", () => {
it("rejects passkey login before touching WebAuthn verification when OIDC_ONLY is set", async () => {
process.env.OIDC_ISSUER_URL = "https://idp.example.com";
process.env.OIDC_CLIENT_ID = "client-1";
process.env.OIDC_CLIENT_SECRET = "secret-1";
process.env.OIDC_ONLY = "true";
const res = await POST(makeRequest());
expect(res.status).toBe(403);
expect(verifyAuthentication).not.toHaveBeenCalled();
});
it("still allows passkey login when OIDC_ONLY is set but the provider is half-configured", async () => {
delete process.env.OIDC_ISSUER_URL;
process.env.OIDC_CLIENT_ID = "client-1";
process.env.OIDC_CLIENT_SECRET = "secret-1";
process.env.OIDC_ONLY = "true";
const res = await POST(makeRequest());
expect(res.status).toBe(200);
});
});
@@ -17,8 +17,17 @@ import {
isCookielessNativeCaller,
} from "@/lib/auth/native-client";
import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
import { isOidcOnly } from "@/lib/auth/oidc";
export const POST = apiHandler(async (request: NextRequest) => {
// OIDC_ONLY must block passkey login too, not just password login — a
// credential minted before SSO was mandated must not stay a live bypass.
if (isOidcOnly()) {
return apiError("Passkey login is disabled. Sign in with SSO.", 403, {
errorCode: "oidc_only",
});
}
await ensureDbCompatibility();
// v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig.
@@ -1,7 +1,7 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/auth/register.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
@@ -14,9 +14,23 @@ vi.mock("@/lib/db", () => ({
appSettings: {
findUnique: vi.fn().mockResolvedValue({ registrationEnabled: true }),
},
// v1.28.42 (M1) — the first-admin count+insert now runs inside a
// transaction-scoped advisory lock. Default `$transaction` to run the
// callback with the same prisma mock as its `tx` handle.
$transaction: vi.fn(),
$queryRaw: vi.fn().mockResolvedValue([{ locked: 1 }]),
},
}));
// v1.28.42 — fail-open breach check + silent device record; mock so the
// happy-path register tests don't reach the network / a real DB write.
vi.mock("@/lib/auth/hibp", () => ({
checkPasswordBreach: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/auth/login-alert", () => ({
recordSignInDevice: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/session", () => ({
createSession: vi.fn().mockResolvedValue(undefined),
}));
@@ -62,6 +76,10 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { checkPasswordStrength, hashPassword } from "@/lib/auth/password";
import { createSession } from "@/lib/auth/session";
import { Prisma } from "@/generated/prisma/client";
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/auth/register", {
@@ -118,3 +136,134 @@ describe("POST /api/auth/register — 422 multi-issue (v1.4.43 W6)", () => {
async function postPathThrough(body: unknown): Promise<Response> {
return POST(postReq(body));
}
// v1.28.42 (M1 + L4) — first-admin race + duplicate-under-race status code.
describe("POST /api/auth/register — first-admin atomicity (M1)", () => {
beforeEach(() => {
// Happy-path setup: reach the create call deterministically.
vi.mocked(checkPasswordStrength).mockReturnValue({
isAcceptable: true,
feedback: [],
} as never);
vi.mocked(hashPassword).mockResolvedValue("hashed");
vi.mocked(createSession).mockResolvedValue(undefined as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.user.create).mockImplementation((async (args: {
data: Record<string, unknown>;
}) => ({
id: "u1",
username: args.data.username,
email: args.data.email,
role: args.data.role,
})) as never);
// Run the transaction callback against the same prisma mock as `tx`.
vi.mocked(prisma.$transaction).mockImplementation(((
fn: (tx: typeof prisma) => unknown,
) => fn(prisma)) as never);
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ locked: 1 }] as never);
});
const validBody = {
email: "new@example.com",
username: "newuser",
password: "a-very-strong-password-123",
};
it("mints ADMIN when the in-transaction count is 0 (genuine first user)", async () => {
// Early gate count and in-tx count both 0.
vi.mocked(prisma.user.count).mockResolvedValue(0 as never);
const res = await postPathThrough(validBody);
expect(res.status).toBe(201);
const createArgs = vi.mocked(prisma.user.create).mock.calls[0]?.[0] as {
data: { role: string };
};
expect(createArgs.data.role).toBe("ADMIN");
});
it("mints USER when a concurrent registration committed first (stale early read, fresh in-tx re-count)", async () => {
// The early gate read observed the empty-DB window (0), but by the time
// the locked transaction re-counts, the racing first registration has
// committed → 1. Without the in-tx re-count this second user would also
// be minted ADMIN (the double-admin bug); the fresh count closes it.
vi.mocked(prisma.user.count)
.mockResolvedValueOnce(0 as never) // early registrationEnabled gate
.mockResolvedValueOnce(1 as never); // inside the advisory-locked tx
const res = await postPathThrough(validBody);
expect(res.status).toBe(201);
const createArgs = vi.mocked(prisma.user.create).mock.calls[0]?.[0] as {
data: { role: string };
};
expect(createArgs.data.role).toBe("USER");
});
it("acquires the transaction-scoped advisory lock before counting", async () => {
vi.mocked(prisma.user.count).mockResolvedValue(0 as never);
await postPathThrough(validBody);
expect(prisma.$queryRaw).toHaveBeenCalled();
expect(prisma.$transaction).toHaveBeenCalled();
});
it("maps a P2002 duplicate under the race to 409, not 500 (L4)", async () => {
vi.mocked(prisma.user.count).mockResolvedValue(1 as never);
vi.mocked(prisma.user.create).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
code: "P2002",
clientVersion: "test",
}) as never,
);
const res = await postPathThrough(validBody);
expect(res.status).toBe(409);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("Username or email already taken");
});
});
describe("POST /api/auth/register — OIDC_ONLY server-side enforcement", () => {
const OIDC_ENV_KEYS = [
"OIDC_ISSUER_URL",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_ONLY",
] as const;
const original: Record<string, string | undefined> = {};
beforeEach(() => {
for (const key of OIDC_ENV_KEYS) original[key] = process.env[key];
});
afterEach(() => {
for (const key of OIDC_ENV_KEYS) {
if (original[key] === undefined) delete process.env[key];
else process.env[key] = original[key];
}
});
it("rejects self-registration before any DB lookup when OIDC_ONLY is set", async () => {
process.env.OIDC_ISSUER_URL = "https://idp.example.com";
process.env.OIDC_CLIENT_ID = "client-1";
process.env.OIDC_CLIENT_SECRET = "secret-1";
process.env.OIDC_ONLY = "true";
const res = await postPathThrough({
email: "new@example.com",
username: "newuser",
password: "a-very-strong-password-123",
});
expect(res.status).toBe(403);
});
it("still allows registration when OIDC_ONLY is set but the provider is half-configured", async () => {
delete process.env.OIDC_ISSUER_URL;
process.env.OIDC_CLIENT_ID = "client-1";
process.env.OIDC_CLIENT_SECRET = "secret-1";
process.env.OIDC_ONLY = "true";
const res = await postPathThrough({ email: "not-an-email", username: "x" });
// Falls through to normal validation (422 here) rather than 403 — a
// half-set OIDC group must never lock everyone out.
expect(res.status).toBe(422);
});
});
+60 -12
View File
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
import {
consumeInviteToken,
recordInviteConsumer,
@@ -26,8 +27,18 @@ import {
isValidTimezone,
resolveServerDefaultTimezone,
} from "@/lib/tz/resolver";
import { isOidcOnly } from "@/lib/auth/oidc";
export const POST = apiHandler(async (request: NextRequest) => {
// OIDC_ONLY must block self-registration too — otherwise anyone can
// self-provision a fresh password account and bypass the operator's
// SSO-only policy outright, invite token or not.
if (isOidcOnly()) {
return apiError("Registration is disabled. Sign in with SSO.", 403, {
errorCode: "oidc_only",
});
}
// v1.4.43 W13 M-4 — tighten to a global bucket when the trust chain
// is misconfigured; otherwise byte-equivalent per-IP semantics.
const rl = await checkAuthSurfaceRateLimit(
@@ -169,19 +180,56 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
// First user becomes admin
const role = userCount === 0 ? "ADMIN" : "USER";
// v1.28.42 (M1) — the first registered user becomes ADMIN. Deriving that
// role from the early `userCount` (read at the top, before all the
// validation above) and then creating without coordination is a
// check-then-act race: on a freshly-exposed instance two registrations
// racing the empty-DB window both observe `0` and are BOTH minted ADMIN.
// Serialise the count+insert behind a transaction-scoped advisory lock
// (released on commit/rollback) and re-count *inside* the lock, so the
// second registration always observes the first's committed row and is
// minted USER. The lock scope is only the count + insert — none of the
// argon2 hash / HIBP / invite work above runs under it. Mirrors the
// advisory-lock single-flight in `drain-per-sample-cumulative.ts`.
//
// v1.28.42 (L4) — a concurrent same-email/username signup (both passed the
// findUnique probe above before either committed) surfaces the unique
// violation as `P2002`; map it to the unified 409 rather than an unhandled
// 500. The DB unique index is the real guard against duplicate accounts;
// this only fixes the status code under the race.
const user = await prisma
.$transaction(async (tx) => {
// `pg_advisory_xact_lock` returns void, which the client cannot
// deserialize as a column — selecting FROM it yields a plain int row.
await tx.$queryRaw`
SELECT 1 AS locked
FROM pg_advisory_xact_lock(hashtextextended('register:first-admin', 0))
`;
const priorUsers = await tx.user.count();
const role = priorUsers === 0 ? "ADMIN" : "USER";
return tx.user.create({
data: {
email,
username,
passwordHash,
role,
timezone,
},
});
})
.catch((err: unknown) => {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2002"
) {
return null;
}
throw err;
});
// Create user
const user = await prisma.user.create({
data: {
email,
username,
passwordHash,
role,
timezone,
},
});
if (user === null) {
return apiError("Username or email already taken", 409);
}
// Create session immediately. v1.4.22 W5 reconcile (Sr-H1) —
// `createSession` anchors the `hl_onboarding` cookie itself; fresh
+18
View File
@@ -34,6 +34,7 @@ import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { COACH_CHECKIN_REVIEW_DAYS } from "@/lib/daily/coach-checkin-intents";
import { coachPlanPatchSchema } from "@/lib/validations/coach-plan";
interface RouteCtx {
@@ -94,6 +95,23 @@ export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => {
: null;
}
// S3 (§2.3.1) — every accepted plan earns a check-in. When the coach
// activates a plan and set no review date of its own, default `reviewDate` to
// +COACH_CHECKIN_REVIEW_DAYS so the Today check-in card fires in a week. Read
// the current row first so an explicitly-pinned review date is never
// clobbered; the read stays owner-scoped, so it leaks no cross-account state.
if (parsed.data.status === "active" && parsed.data.reviewDate === undefined) {
const current = await prisma.coachPlan.findFirst({
where: { id, userId: user.id, deletedAt: null },
select: { reviewDate: true },
});
if (current && current.reviewDate === null) {
data.reviewDate = new Date(
Date.now() + COACH_CHECKIN_REVIEW_DAYS * 86_400_000,
);
}
}
// `updateMany` (not `update`) so an unknown / cross-user / already-deleted id
// is a 0-count no-op rather than a P2025 throw — the existence channel never
// leaks across accounts.
@@ -315,7 +315,68 @@ describe("PATCH /api/coach/plans/[id]", () => {
expect(arg.where.id).toBe("p1");
expect(arg.where.userId).toBe("user-1");
expect(arg.where.deletedAt).toBeNull();
// Only the lifecycle field is written — never metric / encrypted text.
// S3 (§2.3.1): activating a plan the coach never dated defaults a review
// checkpoint ~7d out. Only lifecycle fields are written (status +
// reviewDate) — never metric / encrypted text (no mass assignment).
expect(new Set(Object.keys(arg.data))).toEqual(
new Set(["status", "reviewDate"]),
);
expect(arg.data.status).toBe("active");
expect(arg.data.reviewDate).toBeInstanceOf(Date);
const days =
((arg.data.reviewDate as Date).getTime() - Date.now()) / 86_400_000;
expect(days).toBeGreaterThan(6.5);
expect(days).toBeLessThan(7.5);
});
it("does NOT clobber a review date the caller pinned on activate", async () => {
vi.mocked(prisma.coachPlan.updateMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(prisma.coachPlan.findFirst).mockResolvedValue({
id: "p1",
metric: "WEIGHT",
ifCueEncrypted: bytes("every morning"),
thenActionEncrypted: bytes("weigh in"),
targetEncrypted: null,
status: "active",
reviewDate: new Date("2026-08-01T00:00:00Z"),
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-03T00:00:00Z"),
} as never);
const res = await callPatch({
status: "active",
reviewDate: "2026-08-01T00:00:00.000Z",
});
expect(res.status).toBe(200);
const arg = vi.mocked(prisma.coachPlan.updateMany).mock.calls[0]?.[0] as {
data: { reviewDate: Date };
};
// The pinned date is honoured verbatim, not the +7d default.
expect(arg.data.reviewDate.toISOString()).toBe("2026-08-01T00:00:00.000Z");
});
it("does NOT default a review date when marking a plan met", async () => {
vi.mocked(prisma.coachPlan.updateMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(prisma.coachPlan.findFirst).mockResolvedValue({
id: "p1",
metric: "WEIGHT",
ifCueEncrypted: bytes("every morning"),
thenActionEncrypted: bytes("weigh in"),
targetEncrypted: null,
status: "met",
reviewDate: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-03T00:00:00Z"),
} as never);
await callPatch({ status: "met" });
const arg = vi.mocked(prisma.coachPlan.updateMany).mock.calls[0]?.[0] as {
data: Record<string, unknown>;
};
expect(Object.keys(arg.data)).toEqual(["status"]);
});
+8 -4
View File
@@ -9,6 +9,7 @@
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { fireAndForget } from "@/lib/logging/fire-and-forget";
import {
apiError,
apiSuccess,
@@ -56,10 +57,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
// reconstruct "who minted this and when" even if the receipts table
// is rebuilt from a backup. Fire-and-forget; audit failures must
// never bubble into a user-facing 500.
auditLog("consent.ai.grant", {
userId: user.id,
details: { kind, receiptId: receipt.id },
}).catch(() => {});
fireAndForget(
auditLog("consent.ai.grant", {
userId: user.id,
details: { kind, receiptId: receipt.id },
}),
{ action: "consent.ai.audit" },
);
annotate({
action: { name: "consent.ai.grant" },
@@ -0,0 +1,119 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { apiError } from "@/lib/api-response";
import type { DailyDigest } from "@/lib/daily/digest";
/**
* `GET /api/daily/digest` — the P3 read seam.
*
* Under test: cookie/Bearer auth narrows the user, the `insights` module gate
* returns a 403 `module.disabled` envelope when off (even for a valid
* session), and the happy path returns the `DailyDigest` DTO shape. The digest
* itself is composed by `loadDailyDigest`, mocked here — the route must reach
* no provider (there is nothing AI-shaped in this module graph).
*/
vi.mock("@/lib/db", () => ({
prisma: { appSettings: { findUnique: vi.fn().mockResolvedValue(null) } },
}));
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
}));
vi.mock("@/lib/daily/load-digest", () => ({ loadDailyDigest: 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 { requireModuleEnabled } from "@/lib/modules/gate";
import { loadDailyDigest } from "@/lib/daily/load-digest";
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,
locale: "en",
},
};
const DIGEST: DailyDigest = {
generatedAt: "2026-07-16T09:00:00.000Z",
phase: "final",
sleepPending: false,
score: { value: 82, band: "good", delta: 3 },
topSignal: null,
briefingLead: "Blood pressure is holding steady.",
line: "Blood pressure is holding steady.",
worthALook: [],
};
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
function makeReq(): NextRequest {
return new NextRequest(new URL("http://localhost/api/daily/digest"));
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
vi.mocked(loadDailyDigest).mockResolvedValue(DIGEST);
});
describe("GET /api/daily/digest", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null as never);
const res = await callGet(makeReq());
expect(res.status).toBe(401);
expect(vi.mocked(loadDailyDigest)).not.toHaveBeenCalled();
});
it("returns the 403 module.disabled envelope when insights is off", async () => {
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: false,
response: apiError('Module "insights" is not enabled', 403, {
errorCode: "module.disabled",
module: "insights",
}),
});
const res = await callGet(makeReq());
expect(res.status).toBe(403);
const body = await res.json();
expect(body.meta?.errorCode).toBe("module.disabled");
expect(vi.mocked(loadDailyDigest)).not.toHaveBeenCalled();
});
it("gates on the insights module key", async () => {
await callGet(makeReq());
expect(vi.mocked(requireModuleEnabled)).toHaveBeenCalledWith(
"user-1",
"insights",
);
});
it("returns 200 with the DailyDigest DTO on the happy path", async () => {
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = await res.json();
expect(body.error).toBeNull();
expect(body.data).toEqual(DIGEST);
expect(vi.mocked(loadDailyDigest)).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,164 @@
/**
* `POST /api/daily/digest/dismiss` — the Today rail dismiss surface.
*
* Under test: cookie/Bearer auth narrows the user, the `insights` module gate
* returns a 403 `module.disabled` envelope when off, an `itemKey` that isn't
* namespaced under a dismissible kind 422s BEFORE any DB write, and the happy
* path upserts the `DismissedPriorityItem` row scoped to the caller.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
dismissedPriorityItem: {
upsert: vi.fn().mockResolvedValue({}),
},
},
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({
allowed: true,
remaining: 39,
resetAt: Date.now() + 60_000,
})),
rateLimitHeaders: 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("@/lib/logging/context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
return { ...actual, annotate: vi.fn() };
});
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { apiError } from "@/lib/api-response";
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,
locale: "en",
},
};
const callPost = POST as unknown as (req: NextRequest) => Promise<Response>;
function makeReq(body: unknown): NextRequest {
return new NextRequest(new URL("http://localhost/api/daily/digest/dismiss"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
});
describe("POST /api/daily/digest/dismiss", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null as never);
const res = await callPost(
makeReq({ itemKey: "milestone:record_first:WEIGHT:2026-07-16" }),
);
expect(res.status).toBe(401);
expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled();
});
it("returns the 403 module.disabled envelope when insights is off", async () => {
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: false,
response: apiError('Module "insights" is not enabled', 403, {
errorCode: "module.disabled",
module: "insights",
}),
});
const res = await callPost(
makeReq({ itemKey: "milestone:record_first:WEIGHT:2026-07-16" }),
);
expect(res.status).toBe(403);
expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled();
});
it("422s an itemKey that isn't namespaced under a dismissible kind — an actionable item can never be dismissed", async () => {
const res = await callPost(makeReq({ itemKey: "dose_window:anything" }));
expect(res.status).toBe(422);
expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled();
});
it("422s a missing itemKey", async () => {
const res = await callPost(makeReq({}));
expect(res.status).toBe(422);
});
it("422s an unknown extra field (strict schema)", async () => {
const res = await callPost(
makeReq({
itemKey: "milestone:record_first:WEIGHT:2026-07-16",
userId: "someone-else",
}),
);
expect(res.status).toBe(422);
expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled();
});
it("upserts the dismissal scoped to the caller, for each dismissible kind", async () => {
for (const itemKey of [
"milestone:record_first:WEIGHT:2026-07-16",
"ecg_new_recording:2026-07-16T08:00:00.000Z",
"tension_window:2026-07-16:afternoon",
]) {
vi.mocked(prisma.dismissedPriorityItem.upsert).mockClear();
const res = await callPost(makeReq({ itemKey }));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toEqual({ dismissed: true });
expect(prisma.dismissedPriorityItem.upsert).toHaveBeenCalledWith({
where: { userId_itemKey: { userId: "user-1", itemKey } },
update: {},
create: { userId: "user-1", itemKey },
});
}
});
it("returns 429 when the caller's dismiss rate limit is exhausted", async () => {
const { checkRateLimit } = await import("@/lib/rate-limit");
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await callPost(
makeReq({ itemKey: "milestone:record_first:WEIGHT:2026-07-16" }),
);
expect(res.status).toBe(429);
expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled();
});
});
+82
View File
@@ -0,0 +1,82 @@
/**
* POST /api/daily/digest/dismiss
*
* "Dismiss / mark seen" for the Today rail's OBSERVATIONAL `PriorityItem`
* kinds only — `milestone`, `ecg_new_recording`, `tension_window`. The
* ACTIONABLE kinds (`dose_window`, `sync_issue`, `preventive_care`,
* `coach_checkin`) are never reachable here: `dismissPriorityItemSchema`
* rejects any `itemKey` that doesn't carry one of the three dismissible
* kind-prefixes before a lookup ever runs (§`isDismissibleItemKey`).
*
* Persisted server-side (`DismissedPriorityItem`, composite-unique on
* `userId` + `itemKey`) so the dismissal survives reload / a second device —
* an upsert, so a repeat dismiss of the same instance is a no-op rather than
* a conflict. `userId` is narrowed from `requireAuth()` only; the body never
* carries one. Cookie OR Bearer, mirroring the digest GET route's own auth
* policy, and gated on the same `insights` module (the daily rail's host
* surface).
*/
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { dismissPriorityItemSchema } from "@/lib/validations/daily";
export const dynamic = "force-dynamic";
// A cheap, owner-scoped upsert; the limit only caps a runaway client loop.
const DISMISS_RATE_LIMIT = 40;
const DISMISS_WINDOW_MS = 60_000;
export const POST = apiHandler(async (req: NextRequest) => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "insights");
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(
`daily-digest-dismiss:${user.id}`,
DISMISS_RATE_LIMIT,
DISMISS_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(req, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
const parsed = dismissPriorityItemSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
const { itemKey } = parsed.data;
await prisma.dismissedPriorityItem.upsert({
where: { userId_itemKey: { userId: user.id, itemKey } },
update: {},
create: { userId: user.id, itemKey },
});
annotate({
action: { name: "daily.digest.item.dismissed" },
meta: { daily_digest_item_kind: itemKey.split(":")[0] },
});
return apiSuccess({ dismissed: true });
});
+36
View File
@@ -0,0 +1,36 @@
/**
* GET /api/daily/digest
*
* The read seam for the unified daily-value system (P3). Returns the
* `DailyDigest` DTO the Today surface, the daily push, and a future iOS widget
* all consume — assembled by `loadDailyDigest` from ALREADY-CACHED data (the
* nightly briefing lifted read-only from `User.insightsCachedText`, the
* dashboard-snapshot health score / meds-today / sleep freshness) plus two
* light deterministic reads (broken integrations, overdue Vorsorge). No
* provider call is reachable from this path, and nothing warms on mount.
*
* Cookie OR Bearer auth via `requireAuth()`; `userId` is narrowed from the
* resolved session — never a body field. Gated on the `insights` module (the
* daily digest is the AI-narrative daily layer); a disabled account gets the
* standard 403 `module.disabled` envelope even over a Bearer token. The rail's
* data-tile inputs inherit their own module gates via the snapshot builder.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
import { loadDailyDigest } from "@/lib/daily/load-digest";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
const digest = await loadDailyDigest(user);
const response = apiSuccess(digest);
response.headers.set("Cache-Control", NO_STORE_BUT_BFCACHE);
return response;
});
@@ -44,6 +44,12 @@ const prefsSchema = z.object({
comparisonBaseline: z
.enum(["none", "lastMonth", "lastYear"])
.default("none"),
// v1.30.1 — persisted range-tab selection (M2 QoL fix). Optional so
// a client on an older cached bundle that never sends it doesn't
// 422. Literal set mirrors `CHART_RANGE_POINTS` in dashboard-layout.
rangePoints: z
.union([z.literal(0), z.literal(7), z.literal(30), z.literal(90)])
.optional(),
}),
});
@@ -407,132 +407,6 @@ describe("PUT /api/dashboard/widgets — accept-and-ignore unknown ids (v1.7.0 #
const callGet = GET as unknown as () => Promise<Response>;
describe("PUT /api/dashboard/widgets — heroVisible thread-through", () => {
const knownId = DASHBOARD_WIDGET_IDS[0];
it("persists an explicit heroVisible: false WITHOUT dropping stored chartOverlayPrefs", async () => {
// The regression class this route already guards for
// `chartOverlayPrefs`: a layout PUT that omits a sibling field must
// not wipe it. heroVisible sent + prefs omitted → prefs merge from
// the stored blob, heroVisible persists from the body.
const storedPrefs = {
bp: {
showTrendIndicator: true,
showTrendArrow: false,
showTargetRange: true,
comparisonBaseline: "lastMonth" as const,
},
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: serializeDashboardLayout({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
chartOverlayPrefs: storedPrefs,
}),
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
heroVisible: false,
}),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: {
dashboardWidgetsJson: {
heroVisible: boolean;
chartOverlayPrefs: Record<string, unknown>;
};
};
};
expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(false);
expect(updateArg.data.dashboardWidgetsJson.chartOverlayPrefs).toEqual(
storedPrefs,
);
// The response echoes the persisted value.
const body = (await res.json()) as { data: { heroVisible: boolean } };
expect(body.data.heroVisible).toBe(false);
});
it("preserves a stored heroVisible: false when the PUT omits the field", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: serializeDashboardLayout({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
heroVisible: false,
}),
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
}),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: { heroVisible: boolean } };
};
expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(false);
});
it("defaults heroVisible to false for a first save with no stored layout", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
}),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: { heroVisible: boolean } };
};
expect(updateArg.data.dashboardWidgetsJson.heroVisible).toBe(false);
});
it("422s on a non-boolean heroVisible", async () => {
const res = await callPut(
makeReq({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
heroVisible: "yes",
}),
);
expect(res.status).toBe(422);
});
it("GET resolves heroVisible alongside the widget layout", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: serializeDashboardLayout({
version: 1,
widgets: [{ id: knownId, visible: true, order: 0 }],
heroVisible: false,
}),
} as never);
const res = await callGet();
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { heroVisible: boolean } };
expect(body.data.heroVisible).toBe(false);
});
});
describe("dashboard widgets — 27-id catalogue round-trip (v1.7.0 W1)", () => {
it("PUT of a full 27-id layout persists every id (iOS-only round-trip)", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
+4 -16
View File
@@ -121,14 +121,8 @@ const layoutSchema = z.object({
}),
)
.optional(),
// Dashboard hero (daily verdict) visibility. Optional so clients
// that predate the field can still PUT; when omitted the handler
// preserves the stored value (same preserve-when-absent contract as
// `chartOverlayPrefs` — a Settings save that doesn't know the field
// must not silently reset the user's choice).
heroVisible: z.boolean().optional(),
// v1.27.7 — hero score rings (max 3, closed id set). Optional with the
// same preserve-when-absent contract as `heroVisible`; the resolver
// same preserve-when-absent contract as `chartOverlayPrefs`; the resolver
// dedupes on read/serialize, `.max()` bounds the wire length.
selectedScoreRings: z
.array(z.enum(SCORE_RING_IDS))
@@ -284,19 +278,17 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// didn't send. The dashboard-layout PUT typically saves widget
// visibility / order; chart prefs are PUT through their own route
// (`/api/dashboard/chart-overlay-prefs`) and would otherwise be
// wiped here on a subsequent layout save. `heroVisible` and
// `selectedScoreRings` ride the same preserve-when-absent contract —
// an older client's layout save must not reset either choice. One
// wiped here on a subsequent layout save. `selectedScoreRings` and
// `heroRingOrder` ride the same preserve-when-absent contract — an
// older client's layout save must not reset either choice. One
// stored-layout read covers all fallbacks.
let mergedChartOverlayPrefs: ChartOverlayPrefsMap | undefined = parsed.data
.chartOverlayPrefs as ChartOverlayPrefsMap | undefined;
let mergedHeroVisible: boolean | undefined = parsed.data.heroVisible;
let mergedScoreRings: ScoreRingId[] | undefined =
parsed.data.selectedScoreRings;
let mergedHeroRingOrder: HeroRingId[] | undefined = parsed.data.heroRingOrder;
if (
mergedChartOverlayPrefs === undefined ||
mergedHeroVisible === undefined ||
mergedScoreRings === undefined ||
mergedHeroRingOrder === undefined
) {
@@ -310,9 +302,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
if (mergedChartOverlayPrefs === undefined) {
mergedChartOverlayPrefs = existingLayout.chartOverlayPrefs ?? {};
}
if (mergedHeroVisible === undefined) {
mergedHeroVisible = existingLayout.heroVisible === true;
}
if (mergedScoreRings === undefined) {
mergedScoreRings = existingLayout.selectedScoreRings;
}
@@ -323,7 +312,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const normalized = serializeDashboardLayout({
...parsed.data,
chartOverlayPrefs: mergedChartOverlayPrefs,
heroVisible: mergedHeroVisible,
selectedScoreRings: mergedScoreRings,
heroRingOrder: mergedHeroRingOrder,
} as DashboardLayout);
@@ -187,6 +187,8 @@ beforeEach(() => {
createdAt: "",
updatedAt: "",
messageCount: 0,
fenced: true,
attachments: [],
});
vi.mocked(appendMessage).mockImplementation(
async (p) =>
@@ -328,19 +330,19 @@ describe("POST — security model (no tools, no snapshot, fenced)", () => {
});
describe("POST — conversation surface isolation", () => {
it("404s continuing a conversationId not scoped to this document, fetched with documentId", async () => {
it("404s continuing a conversationId that does not hold this document (path-id join filter)", async () => {
vi.mocked(fetchConversationWithMessages).mockResolvedValue(null);
const res = await POST(
req("doc-1", { conversationId: "other-conv", message: "hi" }) as never,
ctx("doc-1") as never,
);
expect(res.status).toBe(404);
// The fetch is document-scoped — a Coach thread (documentId null) or a
// foreign document's thread can never be loaded here.
// The fetch requires a LIVE join row for the path id (+ documentScoped) — a
// health thread or a foreign/other document's thread can never load here.
expect(fetchConversationWithMessages).toHaveBeenCalledWith(
"user-1",
"other-conv",
{ documentId: "doc-1" },
{ attachedDocumentId: "doc-1" },
);
expect(runStreamingRawCompletionWithFallback).not.toHaveBeenCalled();
});
Loaded 100 of 964 files, more files were not shown because too many files have changed in this diff. Show more