Compare commits

...
111 Commits
Author SHA1 Message Date
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
442 changed files with 22331 additions and 6267 deletions

No files matched your search

+168
View File
@@ -2,6 +2,174 @@
## [Unreleased]
## [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
+241 -8
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.29.2
version: 1.30.8
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -5068,8 +5068,10 @@ paths:
description: v1.18.0 — cursor-paginated list of the caller's Coach conversations for the history rail, most-recent
activity first. Metadata only (id, title, timestamps, message count); message bodies are not decrypted here.
`limit` defaults to 20, capped at 50; pass the returned `nextCursor` back as `cursor` for the next page (null at
the end). Coach-gated (`requireAssistantSurface("coach")`); a disabled surface 403s. Auth via cookie or Bearer;
the owner is narrowed from the session.
the end). v1.30.2 — optional `q` narrows the page to conversations whose TITLE contains the text
(case-insensitive substring, capped at 200 chars); message bodies are encrypted at rest and are not searched.
Coach-gated (`requireAssistantSurface("coach")`); a disabled surface 403s. Auth via cookie or Bearer; the owner
is narrowed from the session.
parameters:
- name: cursor
in: query
@@ -5086,6 +5088,14 @@ paths:
maximum: 50
default: 20
description: Page size. Defaults to 20, capped at 50.
- name: q
in: query
required: false
schema:
type: string
maxLength: 200
description: v1.30.2 — case-insensitive substring filter over the conversation TITLE only (message bodies are encrypted
and not searched). Omit for the unfiltered list.
responses:
"200":
description: A page of the caller's conversations.
@@ -10788,6 +10798,69 @@ paths:
"403": *a17
"422": *a3
"429": *a4
/api/nutrients/daily:
get:
tags:
- Nutrients
summary: One nutrient's day-bucketed series (v1.29)
description: Dense per-day series for one catalog code over the last `days` days (default 30) — one entry per calendar
day, 0 for a day with no logged data, summed ACROSS SOURCES (a day may carry both an APPLE_HEALTH row and a
MANUAL row since migration 0249). Feeds the `/insights/nutrients` hydration + caffeine charts. Also returns the
EFSA reference resolved against the caller's profile sex — `null` when the profile has no sex on file (never
guessed). Behind the opt-in `nutrients` module.
parameters:
- in: query
name: nutrient
schema:
$ref: "#/components/schemas/NutrientCode"
required: true
description: Closed nutrient catalog code — 24 HealthKit Dietary vitamin/mineral types plus water and caffeine. Energy,
macros and sodium/potassium are out of scope.
- in: query
name: days
schema:
default: 30
type: integer
minimum: 1
maximum: 90
responses:
"200":
description: The day-bucketed series + resolved reference.
content:
application/json:
schema:
$ref: "#/components/schemas/NutrientDailySeriesEnvelope"
"401": *a1
"403": *a17
"422": *a3
"429": *a4
/api/nutrients/water:
post:
tags:
- Nutrients
summary: Manual water quick-add (v1.29)
description: "Writes ONLY the `source=\"MANUAL\"` row for `(day, \"water\")` — never the `source=\"APPLE_HEALTH\"` row
the batch endpoint owns, so a manual entry and an Apple sync coexist instead of one clobbering the other. `mode:
\"add\"` increments the manual day total (quick-add chips); `mode: \"set\"` overwrites it (the \"edit today's
total\" undo path — there is no per-entry ledger). `day` defaults to the caller's current local day when
omitted. Idempotency-Key supported; rate limit 60/min. Behind the opt-in `nutrients` module."
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/NutrientWaterWriteRequest"
responses:
"200":
description: The MANUAL water row after the write.
content:
application/json:
schema:
$ref: "#/components/schemas/NutrientWaterWriteEnvelope"
"401": *a1
"403": *a17
"422": *a3
"429": *a4
/api/integrations/healthkit:
get:
tags:
@@ -12347,7 +12420,7 @@ components:
- order
tiles:
minItems: 1
maxItems: 61
maxItems: 62
type: array
items:
type: object
@@ -12406,6 +12479,7 @@ components:
- wrist-temperature
- mood
- medications
- nutrients
- blutdruck
- puls
- sauerstoff
@@ -12945,6 +13019,26 @@ components:
- caffeine
description: Closed nutrient catalog code — 24 HealthKit Dietary vitamin/mineral types plus water and caffeine. Energy,
macros and sodium/potassium are out of scope.
NutrientWaterWriteRequest:
type: object
properties:
amountMl:
type: number
minimum: 1
maximum: 20000
mode:
type: string
enum:
- add
- set
day:
type: string
pattern: ^\d{4}-\d{2}-\d{2}$
required:
- amountMl
- mode
description: Manual water quick-add against the MANUAL source row. add increments today's manual total; set overwrites
it. Never touches the APPLE_HEALTH row.
HealthKitConfigPatchRequest:
type: object
properties:
@@ -23903,6 +23997,10 @@ components:
anyOf:
- type: string
- type: "null"
nextDueMedicationId:
anyOf:
- type: string
- type: "null"
required:
- activeCount
- scheduledToday
@@ -23911,12 +24009,14 @@ components:
- nextDueAt
- nextDueOverdue
- nextDueMedicationName
- nextDueMedicationId
additionalProperties: false
description: "Today's medication block: active-medication count, today-window tally (scheduled / taken / skipped), and
the earliest next-due slot across active medications. `nextDueOverdue: true` marks an OPEN overdue slot
(anchor passed, still inside its catch-up band). Staleness contract: the snapshot is cache-served, so a
`nextDueAt` in the past with `nextDueOverdue: false` means the anchor passed after the snapshot was built —
render the plain day summary, never an overdue state."
(anchor passed, still inside its catch-up band). `nextDueMedicationId` deep-links a consumer straight to the
overdue medication's card. Staleness contract: the snapshot is cache-served, so a `nextDueAt` in the past
with `nextDueOverdue: false` means the anchor passed after the snapshot was built — render the plain day
summary, never an overdue state."
healthScore:
anyOf:
- type: object
@@ -29001,6 +29101,14 @@ components:
type: array
items:
$ref: "#/components/schemas/ExtractedFact"
summary:
anyOf:
- type: string
- type: "null"
summaryGeneratedAt:
anyOf:
- type: string
- type: "null"
required:
- id
- kind
@@ -29023,7 +29131,14 @@ components:
- createdAt
- updatedAt
- facts
- summary
- summaryGeneratedAt
additionalProperties: false
description: A stored document plus its staged facts. `summary` is a short (3-4 sentence) plain-language description of
WHAT the document is, generated once in the background right after upload when the `documentsAutoAiRead` opt-in
is ON (that flag is also the standing AI-egress consent); it is descriptive only, never a diagnosis, and is null
when auto-read is OFF, no provider is configured, or the background summary has not run yet.
`summaryGeneratedAt` is when it was generated (null until then).
ExtractedFact:
type: object
properties:
@@ -29987,6 +30102,123 @@ components:
additionalProperties: false
description: "Per-nutrient window summary in catalog order: latest synced day + total, and the count of days carrying
data inside the window. Nutrients without data in the window are omitted."
NutrientDailySeriesEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/NutrientDailySeries"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
NutrientDailySeries:
type: object
properties:
nutrient:
$ref: "#/components/schemas/NutrientCode"
unit:
type: string
windowDays:
type: integer
minimum: 1
maximum: 9007199254740991
days:
type: array
items:
type: object
properties:
day:
type: string
amount:
type: number
required:
- day
- amount
additionalProperties: false
reference:
anyOf:
- type: object
properties:
kind:
type: string
enum:
- PRI
- AI
- safeLevel
direction:
type: string
enum:
- target
- upperGuidance
value:
type: number
source:
type: string
required:
- kind
- direction
- value
- source
additionalProperties: false
- type: "null"
required:
- nutrient
- unit
- windowDays
- days
- reference
additionalProperties: false
description: Dense day-bucketed series (one entry per day in the window, 0 for a day with no data) summed across
sources, plus the EFSA reference resolved against the caller's profile sex (null when sex is unknown on the
profile).
NutrientWaterWriteEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/NutrientWaterWriteResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
NutrientWaterWriteResponse:
type: object
properties:
day:
type: string
nutrient:
type: string
const: water
source:
type: string
const: MANUAL
amount:
type: number
unit:
type: string
required:
- day
- nutrient
- source
- amount
- unit
additionalProperties: false
description: The MANUAL water row after the add/set write.
HealthKitConfigEnvelope:
type: object
properties:
@@ -30451,7 +30683,7 @@ components:
additionalProperties: false
tiles:
minItems: 1
maxItems: 61
maxItems: 62
type: array
items:
type: object
@@ -30510,6 +30742,7 @@ components:
- wrist-temperature
- mood
- medications
- nutrients
- blutdruck
- puls
- sauerstoff
+1
View File
@@ -279,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 }
+48
View File
@@ -77,6 +77,54 @@ const eslintConfig = defineConfig([
"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",
],
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": [
+152 -45
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",
@@ -405,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."
@@ -587,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",
@@ -1403,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…",
@@ -1791,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}.",
@@ -1806,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": {
@@ -2240,7 +2264,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",
@@ -2470,7 +2496,8 @@
"documentsAttachedOne": "1 Dokument angehängt",
"documentsAttachedOther": "{count} Dokumente angehängt",
"railBadge": "Dokument-Chat (abgeschottet)",
"uploadError": "Upload fehlgeschlagen. Bitte versuche es erneut."
"uploadError": "Upload fehlgeschlagen. Bitte versuche es erneut.",
"notFoundError": "Dieser Dokumentlink ist nicht mehr gültig – es wird ein normaler Chat gestartet."
}
},
"relativeJustNow": "gerade eben",
@@ -2481,11 +2508,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",
@@ -2510,9 +2539,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",
@@ -2637,11 +2666,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",
@@ -2671,7 +2700,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",
@@ -2683,7 +2712,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",
@@ -2731,7 +2760,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",
@@ -2869,7 +2899,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",
@@ -3237,6 +3267,8 @@
},
"navWalkingSpeed": "Gehgeschwindigkeit",
"mood": {
"logLink": "Stimmung eintragen",
"mentalWellbeingLink": "Check-in zum seelischen Wohlbefinden machen",
"heatmapTitle": "Stimmungskalender",
"heatmapNoEntry": "Kein Eintrag",
"legendGreat": "Super",
@@ -3524,7 +3556,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"
},
@@ -3695,6 +3729,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": {
@@ -3865,13 +3900,18 @@
"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",
@@ -3882,7 +3922,8 @@
"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",
@@ -3981,7 +4022,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."
}
}
},
@@ -4009,7 +4050,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",
@@ -4017,12 +4058,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",
@@ -4054,7 +4097,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",
@@ -4164,7 +4208,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": {
@@ -4349,7 +4402,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.",
@@ -4509,7 +4563,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",
@@ -5256,7 +5310,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",
@@ -5382,6 +5436,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.",
@@ -6022,6 +6082,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.",
@@ -6642,9 +6703,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",
@@ -6809,7 +6870,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."
}
},
@@ -7350,6 +7411,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",
@@ -7891,14 +7953,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": {
@@ -7911,6 +7973,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",
@@ -8035,6 +8098,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)"
},
@@ -8288,7 +8357,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}.",
@@ -8330,6 +8401,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.",
@@ -8344,7 +8419,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",
@@ -8548,12 +8624,41 @@
"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 — alles verläuft normal."
"allClear": "Heute braucht nichts deine Aufmerksamkeit."
},
"push": {
"title": "Dein Morgenüberblick",
@@ -8577,7 +8682,9 @@
"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."
"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",
+149 -42
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",
@@ -405,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."
@@ -587,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",
@@ -1403,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…",
@@ -1791,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}.",
@@ -1806,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": {
@@ -2240,7 +2264,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",
@@ -2470,7 +2496,8 @@
"documentsAttachedOne": "1 document attached",
"documentsAttachedOther": "{count} documents attached",
"railBadge": "Fenced document chat",
"uploadError": "Upload failed. Please try again."
"uploadError": "Upload failed. Please try again.",
"notFoundError": "That document link is no longer valid — starting a normal chat instead."
}
},
"relativeJustNow": "just now",
@@ -2481,11 +2508,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",
@@ -2510,9 +2539,9 @@
"label": "Activity",
"header": "Activity & mobility"
},
"cardiovascularParent": {
"label": "Cardiovascular",
"header": "Cardiovascular markers"
"heartParent": {
"label": "Heart",
"header": "Heart health"
},
"hearingParent": {
"label": "Hearing",
@@ -2637,11 +2666,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",
@@ -2671,7 +2700,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",
@@ -2683,7 +2712,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",
@@ -2731,7 +2760,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",
@@ -2869,7 +2899,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",
@@ -3237,6 +3267,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",
@@ -3524,7 +3556,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"
},
@@ -3695,6 +3729,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": {
@@ -3865,13 +3900,18 @@
"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",
@@ -3882,7 +3922,8 @@
"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",
@@ -3981,7 +4022,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."
}
}
},
@@ -4009,7 +4050,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",
@@ -4017,12 +4058,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",
@@ -4054,7 +4097,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",
@@ -4164,7 +4208,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": {
@@ -4349,7 +4402,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.",
@@ -4509,7 +4563,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",
@@ -5382,6 +5436,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.",
@@ -6022,6 +6082,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.",
@@ -6642,9 +6703,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",
@@ -7350,6 +7411,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",
@@ -7891,14 +7953,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": {
@@ -7911,6 +7973,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",
@@ -8035,6 +8098,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)"
},
@@ -8288,7 +8357,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.",
@@ -8330,6 +8401,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.",
@@ -8344,7 +8419,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",
@@ -8548,12 +8624,41 @@
"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 — everything's tracking normally."
"allClear": "Nothing needs your attention today."
},
"push": {
"title": "Your morning briefing",
@@ -8577,7 +8682,9 @@
"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."
"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",
+149 -42
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",
@@ -405,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."
@@ -587,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",
@@ -1403,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 …",
@@ -1791,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}.",
@@ -1806,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": {
@@ -2240,7 +2264,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",
@@ -2470,7 +2496,8 @@
"documentsAttachedOne": "1 documento adjunto",
"documentsAttachedOther": "{count} documentos adjuntos",
"railBadge": "Chat de documento aislado",
"uploadError": "Error al subir. Inténtalo de nuevo."
"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",
@@ -2481,11 +2508,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",
@@ -2510,9 +2539,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",
@@ -2637,11 +2666,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",
@@ -2671,7 +2700,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",
@@ -2683,7 +2712,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",
@@ -2731,7 +2760,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",
@@ -2869,7 +2899,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",
@@ -3237,6 +3267,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",
@@ -3524,7 +3556,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"
},
@@ -3695,6 +3729,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": {
@@ -3865,13 +3900,18 @@
"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",
@@ -3882,7 +3922,8 @@
"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",
@@ -3981,7 +4022,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."
}
}
},
@@ -4009,7 +4050,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",
@@ -4017,12 +4058,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",
@@ -4054,7 +4097,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",
@@ -4164,7 +4208,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": {
@@ -4349,7 +4402,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.",
@@ -4509,7 +4563,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",
@@ -5382,6 +5436,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.",
@@ -6022,6 +6082,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.",
@@ -6642,9 +6703,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",
@@ -7350,6 +7411,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",
@@ -7891,14 +7953,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": {
@@ -7911,6 +7973,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",
@@ -8035,6 +8098,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)"
},
@@ -8288,7 +8357,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}.",
@@ -8330,6 +8401,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.",
@@ -8344,7 +8419,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",
@@ -8548,12 +8624,41 @@
"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 requiere tu atención: todo evoluciona con normalidad."
"allClear": "Hoy nada necesita tu atención."
},
"push": {
"title": "Tu resumen matinal",
@@ -8577,7 +8682,9 @@
"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."
"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",
+149 -42
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",
@@ -405,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."
@@ -587,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",
@@ -1403,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 …",
@@ -1791,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}.",
@@ -1806,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": {
@@ -2240,7 +2264,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",
@@ -2470,7 +2496,8 @@
"documentsAttachedOne": "1 document joint",
"documentsAttachedOther": "{count} documents joints",
"railBadge": "Chat de document cloisonné",
"uploadError": "Échec du téléversement. Veuillez réessayer."
"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",
@@ -2481,11 +2508,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",
@@ -2510,9 +2539,9 @@
"label": "Activité",
"header": "Activité et mobilité"
},
"cardiovascularParent": {
"label": "Cardiovasculaire",
"header": "Marqueurs cardiovasculaires"
"heartParent": {
"label": "Cœur",
"header": "Santé cardiaque"
},
"hearingParent": {
"label": "Audition",
@@ -2637,11 +2666,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",
@@ -2671,7 +2700,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",
@@ -2683,7 +2712,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",
@@ -2731,7 +2760,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",
@@ -2869,7 +2899,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",
@@ -3237,6 +3267,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",
@@ -3524,7 +3556,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"
},
@@ -3695,6 +3729,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": {
@@ -3865,13 +3900,18 @@
"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",
@@ -3882,7 +3922,8 @@
"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",
@@ -3981,7 +4022,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."
}
}
},
@@ -4009,7 +4050,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",
@@ -4017,12 +4058,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",
@@ -4054,7 +4097,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",
@@ -4164,7 +4208,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": {
@@ -4349,7 +4402,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.",
@@ -4509,7 +4563,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",
@@ -5382,6 +5436,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.",
@@ -6022,6 +6082,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.",
@@ -6642,9 +6703,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",
@@ -7350,6 +7411,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",
@@ -7891,14 +7953,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": {
@@ -7911,6 +7973,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",
@@ -8035,6 +8098,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)"
},
@@ -8288,7 +8357,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}.",
@@ -8330,6 +8401,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.",
@@ -8344,7 +8419,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",
@@ -8548,12 +8624,41 @@
"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 — tout évolue normalement."
"allClear": "Rien ne requiert ton attention aujourd'hui."
},
"push": {
"title": "Votre résumé du matin",
@@ -8577,7 +8682,9 @@
"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."
"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",
+149 -42
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",
@@ -405,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."
@@ -587,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",
@@ -1403,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 …",
@@ -1791,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}.",
@@ -1806,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": {
@@ -2240,7 +2264,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",
@@ -2470,7 +2496,8 @@
"documentsAttachedOne": "1 documento allegato",
"documentsAttachedOther": "{count} documenti allegati",
"railBadge": "Chat documento isolata",
"uploadError": "Caricamento non riuscito. Riprova."
"uploadError": "Caricamento non riuscito. Riprova.",
"notFoundError": "Quel link al documento non è più valido: verrà avviata una chat normale."
}
},
"relativeJustNow": "proprio ora",
@@ -2481,11 +2508,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",
@@ -2510,9 +2539,9 @@
"label": "Attività",
"header": "Attività e mobilità"
},
"cardiovascularParent": {
"label": "Cardiovascolare",
"header": "Marcatori cardiovascolari"
"heartParent": {
"label": "Cuore",
"header": "Salute del cuore"
},
"hearingParent": {
"label": "Udito",
@@ -2637,11 +2666,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",
@@ -2671,7 +2700,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",
@@ -2683,7 +2712,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",
@@ -2731,7 +2760,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",
@@ -2869,7 +2899,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",
@@ -3237,6 +3267,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",
@@ -3524,7 +3556,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"
},
@@ -3695,6 +3729,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": {
@@ -3865,13 +3900,18 @@
"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",
@@ -3882,7 +3922,8 @@
"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",
@@ -3981,7 +4022,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."
}
}
},
@@ -4009,7 +4050,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",
@@ -4017,12 +4058,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",
@@ -4054,7 +4097,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",
@@ -4164,7 +4208,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": {
@@ -4349,7 +4402,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.",
@@ -4509,7 +4563,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à",
@@ -5382,6 +5436,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.",
@@ -6022,6 +6082,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.",
@@ -6642,9 +6703,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",
@@ -7350,6 +7411,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",
@@ -7891,14 +7953,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": {
@@ -7911,6 +7973,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",
@@ -8035,6 +8098,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)"
},
@@ -8288,7 +8357,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}.",
@@ -8330,6 +8401,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.",
@@ -8344,7 +8419,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",
@@ -8548,12 +8624,41 @@
"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 — tutto procede normalmente."
"allClear": "Oggi nulla richiede la tua attenzione."
},
"push": {
"title": "Il tuo riepilogo mattutino",
@@ -8577,7 +8682,9 @@
"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."
"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",
+149 -42
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",
@@ -405,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."
@@ -587,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",
@@ -1403,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 …",
@@ -1791,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}.",
@@ -1806,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": {
@@ -2240,7 +2264,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",
@@ -2470,7 +2496,8 @@
"documentsAttachedOne": "1 załączony dokument",
"documentsAttachedOther": "Załączone dokumenty: {count}",
"railBadge": "Odizolowany czat dokumentu",
"uploadError": "Przesyłanie nie powiodło się. Spróbuj ponownie."
"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ą",
@@ -2481,11 +2508,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",
@@ -2510,9 +2539,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",
@@ -2637,11 +2666,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",
@@ -2671,7 +2700,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",
@@ -2683,7 +2712,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",
@@ -2731,7 +2760,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",
@@ -2869,7 +2899,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",
@@ -3237,6 +3267,8 @@
},
"navWalkingSpeed": "Prędkość chodu",
"mood": {
"logLink": "Zapisz nastrój",
"mentalWellbeingLink": "Wykonaj sprawdzian dobrostanu psychicznego",
"heatmapTitle": "Kalendarz nastroju",
"heatmapNoEntry": "Brak wpisu",
"legendGreat": "Świetnie",
@@ -3524,7 +3556,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"
},
@@ -3695,6 +3729,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": {
@@ -3865,13 +3900,18 @@
"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ń",
@@ -3882,7 +3922,8 @@
"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",
@@ -3981,7 +4022,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."
}
}
},
@@ -4009,7 +4050,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",
@@ -4017,12 +4058,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",
@@ -4054,7 +4097,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",
@@ -4164,7 +4208,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": {
@@ -4349,7 +4402,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.",
@@ -4509,7 +4563,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",
@@ -5382,6 +5436,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.",
@@ -6022,6 +6082,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.",
@@ -6642,9 +6703,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",
@@ -7350,6 +7411,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",
@@ -7891,14 +7953,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": {
@@ -7911,6 +7973,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",
@@ -8035,6 +8098,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)"
},
@@ -8288,7 +8357,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}.",
@@ -8330,6 +8401,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.",
@@ -8344,7 +8419,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ć",
@@ -8548,12 +8624,41 @@
"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 — wszystko przebiega normalnie."
"allClear": "Dziś nic nie wymaga Twojej uwagi."
},
"push": {
"title": "Twoje poranne podsumowanie",
@@ -8577,7 +8682,9 @@
"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."
"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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.29.2",
"version": "1.30.8",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -0,0 +1,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'
);
+39 -21
View File
@@ -6061,9 +6061,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:
@@ -6071,18 +6071,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
@@ -6091,40 +6091,51 @@ 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?
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
@@ -6132,7 +6143,7 @@ model InboundDocument {
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")
@@ -6349,13 +6360,20 @@ model NutrientIntakeDay {
/// 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])
@@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.29.2";
/* @sw-version-fallback */ "v1.30.8";
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/*`
+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
@@ -111,8 +111,9 @@ describe("resolveConfiguredTileCount (v1.16.8)", () => {
// 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.
expect(resolveConfiguredTileCount(DEFAULT_DASHBOARD_LAYOUT)).toBe(18);
// 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", () => {
@@ -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,
},
},
});
@@ -64,6 +64,7 @@ vi.mock("@/lib/auth/oidc", () => ({
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]),
}));
+17 -16
View File
@@ -15,6 +15,7 @@ import {
fetchUserinfoEmail,
getOidcConfig,
getOidcRedirectUri,
oidcAppUrl,
verifyIdToken,
} from "@/lib/auth/oidc";
import {
@@ -31,9 +32,9 @@ import { resolveServerDefaultTimezone } from "@/lib/tz/resolver";
const LOGIN_ERROR_URL = "/auth/login";
function errorRedirect(req: NextRequest, reason: string): NextResponse {
function errorRedirect(reason: string): NextResponse {
const response = NextResponse.redirect(
new URL(`${LOGIN_ERROR_URL}?error=${reason}`, req.url),
oidcAppUrl(`${LOGIN_ERROR_URL}?error=${reason}`),
);
deleteStateCookie(response);
return response;
@@ -106,7 +107,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
annotate({ action: { name: "auth.oidc.callback" } });
const config = getOidcConfig();
if (!config) return errorRedirect(req, "oidc_disabled");
if (!config) return errorRedirect("oidc_disabled");
const rl = await checkAuthSurfaceRateLimit(
req,
@@ -115,7 +116,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
15 * 60 * 1000,
);
const ip = rl.ip ?? "unknown";
if (!rl.allowed) return errorRedirect(req, "oidc_rate_limited");
if (!rl.allowed) return errorRedirect("oidc_rate_limited");
const { searchParams } = req.nextUrl;
const idpError = searchParams.get("error");
@@ -126,12 +127,12 @@ export const GET = apiHandler(async (req: NextRequest) => {
idpError: KNOWN_IDP_ERROR_CODES.has(idpError) ? idpError : "other",
},
});
return errorRedirect(req, "oidc_denied");
return errorRedirect("oidc_denied");
}
const code = searchParams.get("code");
const state = searchParams.get("state");
if (!code || !state) return errorRedirect(req, "oidc_state");
if (!code || !state) return errorRedirect("oidc_state");
const rawCookie = req.cookies.get(OIDC_STATE_COOKIE)?.value;
let stored: StoredOidcState | null = null;
@@ -143,7 +144,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
stored = null;
}
}
if (!stored) return errorRedirect(req, "oidc_state");
if (!stored) return errorRedirect("oidc_state");
// CSRF check — timing-safe, byte-for-byte, mirroring the Withings callback.
if (
@@ -151,7 +152,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
!timingSafeEqual(Buffer.from(state), Buffer.from(stored.state))
) {
annotate({ meta: { reason: "csrf" } });
return errorRedirect(req, "oidc_state");
return errorRedirect("oidc_state");
}
try {
@@ -165,7 +166,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
codeVerifier: stored.codeVerifier,
redirectUri,
});
if (!tokens.id_token) return errorRedirect(req, "oidc_failed");
if (!tokens.id_token) return errorRedirect("oidc_failed");
const identity = await verifyIdToken({
metadata,
@@ -228,11 +229,11 @@ export const GET = apiHandler(async (req: NextRequest) => {
// health record.
if (!email) {
annotate({ meta: { reason: "no_email" } });
return errorRedirect(req, "oidc_no_email");
return errorRedirect("oidc_no_email");
}
if (emailVerified !== true) {
annotate({ meta: { reason: "email_unverified" } });
return errorRedirect(req, "oidc_email_unverified");
return errorRedirect("oidc_email_unverified");
}
const byEmail = await prisma.user.findFirst({
@@ -250,7 +251,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
ipAddress: ip,
});
annotate({ meta: { reason: "identity_conflict" } });
return errorRedirect(req, "oidc_identity_conflict");
return errorRedirect("oidc_identity_conflict");
}
user = await prisma.user.update({
where: { id: byEmail.id },
@@ -276,7 +277,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
}
if (!registrationEnabled) {
annotate({ meta: { reason: "registration_disabled" } });
return errorRedirect(req, "oidc_registration_disabled");
return errorRedirect("oidc_registration_disabled");
}
const username = await deriveUniqueUsername(email, (candidate) =>
@@ -361,7 +362,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
action: { name: "auth.mfa.challenge" },
meta: { mfa_required: true },
});
const loginUrl = new URL(LOGIN_ERROR_URL, req.url);
const loginUrl = oidcAppUrl(LOGIN_ERROR_URL);
if (stored.next !== "/") {
loginUrl.searchParams.set("next", stored.next);
}
@@ -407,12 +408,12 @@ export const GET = apiHandler(async (req: NextRequest) => {
await auditLog("auth.oidc.login", { userId: user.id, ipAddress: ip });
const response = NextResponse.redirect(new URL(stored.next, req.url));
const response = NextResponse.redirect(oidcAppUrl(stored.next));
deleteStateCookie(response);
return response;
} catch (err) {
getEvent()?.setError(err);
annotate({ meta: { reason: "exchange_or_verify_failed" } });
return errorRedirect(req, "oidc_failed");
return errorRedirect("oidc_failed");
}
});
@@ -34,6 +34,8 @@ vi.mock("@/lib/auth/oidc", async () => {
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,
};
});
+4 -7
View File
@@ -11,6 +11,7 @@ import {
discoverOidcMetadata,
getOidcConfig,
getOidcRedirectUri,
oidcAppUrl,
sanitizeOidcNextPath,
} from "@/lib/auth/oidc";
import {
@@ -30,9 +31,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
const config = getOidcConfig();
if (!config) {
return NextResponse.redirect(
new URL("/auth/login?error=oidc_disabled", req.url),
);
return NextResponse.redirect(oidcAppUrl("/auth/login?error=oidc_disabled"));
}
const rl = await checkAuthSurfaceRateLimit(
@@ -43,7 +42,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
);
if (!rl.allowed) {
return NextResponse.redirect(
new URL("/auth/login?error=oidc_rate_limited", req.url),
oidcAppUrl("/auth/login?error=oidc_rate_limited"),
);
}
@@ -52,9 +51,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
metadata = await discoverOidcMetadata(config);
} catch (err) {
getEvent()?.setError(err);
return NextResponse.redirect(
new URL("/auth/login?error=oidc_failed", req.url),
);
return NextResponse.redirect(oidcAppUrl("/auth/login?error=oidc_failed"));
}
const state = randomBytes(32).toString("base64url");
+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" },
@@ -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(),
}),
});
+8
View File
@@ -42,6 +42,7 @@ import {
type SerialisableDocument,
} from "@/lib/documents/store";
import { enqueueDocumentIndex } from "@/lib/jobs/document-index";
import { enqueueDocumentSummary } from "@/lib/jobs/document-summary";
import { enqueueDocumentThumbnail } from "@/lib/jobs/document-thumbnail";
import {
detectDocumentType,
@@ -378,6 +379,13 @@ async function postUpload(request: Request): Promise<Response> {
// Only fresh inserts reach here (a duplicate returns early above).
void enqueueDocumentThumbnail(user.id, document.id);
// Summarise the freshly stored document in the background — but ONLY when the
// `documentsAutoAiRead` opt-in is ON (the job re-checks it and the egress
// consent, and no-ops otherwise). The persisted summary shows on the detail
// view. Same fire-and-forget contract: the upload never blocks on or fails
// because of it. Only fresh inserts reach here (a duplicate returns early).
void enqueueDocumentSummary(user.id, document.id);
const links = await loadConditionLinks(user.id, [document.id]);
return apiSuccess(
serialiseDocument(
@@ -7,7 +7,7 @@
* action name, and rate-limit wiring. The full DB round-trip lives in
* the integration suite (`tests/integration/export-per-type.test.ts`).
*/
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", () => ({
@@ -20,6 +20,15 @@ vi.mock("@/lib/db", () => ({
cycleProfile: { findUnique: vi.fn() },
menstrualCycle: { findMany: vi.fn() },
cycleDayLog: { findMany: vi.fn() },
// v1.28 backup-completeness — the records section the full-backup
// helper now also reads (`buildRecordsBackupSection`).
labResult: { findMany: vi.fn() },
biomarker: { findMany: vi.fn() },
illnessEpisode: { findMany: vi.fn() },
allergy: { findMany: vi.fn() },
familyHistoryEntry: { findMany: vi.fn() },
workout: { findMany: vi.fn() },
inboundDocument: { findMany: vi.fn() },
user: { findUnique: vi.fn() },
auditLog: { create: vi.fn() },
},
@@ -63,6 +72,13 @@ beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
// The v1.28 records-domain end-to-end test stubs ENCRYPTION_KEY to
// exercise the real crypto path; unstub unconditionally (harmless no-op
// for every other test) so a stub never leaks past its own test.
vi.unstubAllEnvs();
});
describe("GET /api/export/measurements", () => {
it("rejects unauthenticated requests with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
@@ -385,6 +401,15 @@ describe("GET /api/export/full-backup", () => {
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([] as never);
const { GET } = await import("../full-backup/route");
const res = await GET(mkReq("http://localhost/api/export/full-backup"));
@@ -402,6 +427,20 @@ describe("GET /api/export/full-backup", () => {
medications: [],
intakeEvents: [],
moodEntries: [],
// v1.28 backup-completeness — the newer domains round-trip to empty
// arrays (not undefined/missing) when the account has no records,
// and the manifest still discloses the two deliberate exclusions.
labResults: [],
biomarkers: [],
illnessEpisodes: [],
allergies: [],
familyHistory: [],
workouts: [],
documents: [],
manifest: {
documents: { included: "metadata-only" },
workouts: { included: "summary-only" },
},
});
expect(typeof json.exportedAt).toBe("string");
expect(auditLog).toHaveBeenCalledWith(
@@ -409,4 +448,81 @@ describe("GET /api/export/full-backup", () => {
expect.objectContaining({ userId: "user-1" }),
);
});
it("includes decrypted v1.28 records-domain data end-to-end", async () => {
vi.stubEnv("ENCRYPTION_KEYS", "");
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", "a".repeat(64));
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never);
const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec");
vi.mocked(prisma.labResult.findMany).mockResolvedValue([
{
panel: null,
analyte: "HbA1c",
value: 5.4,
valueText: null,
unit: "%",
referenceLow: null,
referenceHigh: null,
takenAt: new Date("2026-04-01T09:00:00.000Z"),
source: "MANUAL",
biomarker: null,
noteEncrypted: null,
},
] as never);
vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([
{
id: "ep-1",
label: "Migraine flare",
type: "CHRONIC",
lifecycle: "FLARE",
onsetAt: new Date("2026-04-10T00:00:00.000Z"),
resolvedAt: null,
parentConditionId: "ep-parent",
noteEncrypted: encryptToBytes("Triggered by travel."),
createdAt: new Date("2026-04-10T00:00:00.000Z"),
updatedAt: new Date("2026-04-10T00:00:00.000Z"),
dayLogs: [],
},
] as never);
vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([] as never);
const { GET } = await import("../full-backup/route");
const res = await GET(mkReq("http://localhost/api/export/full-backup"));
expect(res.status).toBe(200);
const json = await res.json();
expect(json.labResults).toEqual([
expect.objectContaining({ analyte: "HbA1c", value: 5.4 }),
]);
// The flare's parent link + decrypted note round-trip in the JSON —
// never the ciphertext.
expect(json.illnessEpisodes[0].parentConditionId).toBe("ep-parent");
expect(json.illnessEpisodes[0].note).toBe("Triggered by travel.");
// The raw ciphertext column never leaves the server — only the
// decrypted `note` field is present on the DTO.
expect(json.illnessEpisodes[0]).not.toHaveProperty("noteEncrypted");
});
});
@@ -42,6 +42,18 @@ vi.mock("@/lib/db", () => ({
auditLog: { findMany: vi.fn() },
userHealthProfile: { findUnique: vi.fn() },
illnessEpisode: { findMany: vi.fn() },
// v1.28 backup-completeness — the cycle + records sections the
// full-backup builder now also reads (`buildCycleBackupSection` /
// `buildRecordsBackupSection`).
cycleProfile: { findUnique: vi.fn() },
menstrualCycle: { findMany: vi.fn() },
cycleDayLog: { findMany: vi.fn() },
labResult: { findMany: vi.fn() },
biomarker: { findMany: vi.fn() },
allergy: { findMany: vi.fn() },
familyHistoryEntry: { findMany: vi.fn() },
workout: { findMany: vi.fn() },
inboundDocument: { findMany: vi.fn() },
},
}));
@@ -125,6 +137,18 @@ beforeEach(() => {
null as never,
);
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
// v1.28 backup-completeness — the full-backup route now also reads the
// cycle + records sections; let them resolve to empty so the route
// completes instead of rejecting on an unmocked delegate.
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([] as never);
});
describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
@@ -148,6 +172,48 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
);
});
it("/api/export/full-backup scopes every v1.28 records domain to userId + deletedAt: null", async () => {
const { GET } = await import("../full-backup/route");
await GET(mkReq("http://localhost/api/export/full-backup"));
expect(prisma.labResult.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "user-1", deletedAt: null },
}),
);
expect(prisma.illnessEpisode.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "user-1", deletedAt: null },
}),
);
expect(prisma.allergy.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "user-1", deletedAt: null },
}),
);
expect(prisma.familyHistoryEntry.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "user-1", deletedAt: null },
}),
);
expect(prisma.inboundDocument.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "user-1", deletedAt: null },
}),
);
// Biomarker + Workout carry no soft-delete tombstone — userId-only scope.
expect(prisma.biomarker.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { userId: "user-1" } }),
);
expect(prisma.workout.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { userId: "user-1" } }),
);
// The document read must never select the encrypted content blob.
const documentCall = vi.mocked(prisma.inboundDocument.findMany).mock
.calls[0][0] as { select?: Record<string, unknown> };
expect(documentCall.select).not.toHaveProperty("contentEncrypted");
});
it("/api/export (legacy) scopes the measurement read to deletedAt: null", async () => {
const { GET } = await import("../route");
await GET(mkReq("http://localhost/api/export?type=measurements"));
@@ -17,6 +17,15 @@ vi.mock("@/lib/db", () => ({
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
// v1.28 backup-completeness — the records section the shared payload
// builder now also reads (`buildRecordsBackupSection`).
labResult: { findMany: vi.fn().mockResolvedValue([]) },
biomarker: { findMany: vi.fn().mockResolvedValue([]) },
illnessEpisode: { findMany: vi.fn().mockResolvedValue([]) },
allergy: { findMany: vi.fn().mockResolvedValue([]) },
familyHistoryEntry: { findMany: vi.fn().mockResolvedValue([]) },
workout: { findMany: vi.fn().mockResolvedValue([]) },
inboundDocument: { findMany: vi.fn().mockResolvedValue([]) },
session: { findUnique: vi.fn() },
webauthnMfaCredential: { count: vi.fn() },
},
@@ -77,6 +86,18 @@ describe("POST /api/export/encrypted", () => {
const payload = JSON.parse(plaintext);
expect(payload).toHaveProperty("schemaVersion");
expect(payload).toHaveProperty("measurements");
// v1.28 backup-completeness — the encrypted archive carries the same
// records domains as the plaintext export.
expect(payload).toHaveProperty("labResults");
expect(payload).toHaveProperty("illnessEpisodes");
expect(payload).toHaveProperty("allergies");
expect(payload).toHaveProperty("familyHistory");
expect(payload).toHaveProperty("workouts");
expect(payload).toHaveProperty("documents");
expect(payload.manifest).toMatchObject({
documents: { included: "metadata-only" },
workouts: { included: "summary-only" },
});
});
it("blocks an MFA account without a fresh second factor (401 step-up)", async () => {
+12 -3
View File
@@ -2,9 +2,10 @@
* POST /api/export/encrypted
*
* v1.23 — passphrase-encrypted variant of the full-backup export. Returns the
* same restore-compatible payload as `GET /api/export/full-backup`, but sealed
* into an `HLX1` archive (Argon2id-derived key + AES-256-GCM) under a passphrase
* the caller supplies in the request body. The binary archive is returned as
* same payload as `GET /api/export/full-backup` (see that route's doc comment
* for which domains restore recreates vs. export-only), sealed into an
* `HLX1` archive (Argon2id-derived key + AES-256-GCM) under a passphrase the
* caller supplies in the request body. The binary archive is returned as
* `application/octet-stream`.
*
* SECURITY:
@@ -107,6 +108,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
export_mood_count: counts.moodEntries,
export_cycle_count: counts.cycles,
export_cycle_day_log_count: counts.cycleDayLogs,
export_lab_result_count: counts.labResults,
export_biomarker_count: counts.biomarkers,
export_illness_episode_count: counts.illnessEpisodes,
export_illness_day_log_count: counts.illnessDayLogs,
export_allergy_count: counts.allergies,
export_family_history_count: counts.familyHistory,
export_workout_count: counts.workouts,
export_document_count: counts.documents,
export_archive_bytes: archive.byteLength,
},
});
+21 -1
View File
@@ -11,6 +11,16 @@
* `.json` so the browser writes a `.json` file even though the route
* segment doesn't carry the extension.
*
* Backup-completeness — the payload also carries lab results, the
* biomarker catalog, illness episodes (incl. flares/exacerbations) with
* their day-logs, allergies, family history, and workout summaries. These
* domains are EXPORT-ONLY today: `POST /api/admin/backups/[id]/restore`
* only re-creates the original measurements/medications/mood/cycle
* domains, so restoring a snapshot does not (yet) recreate these sections
* — see `src/lib/export/records-backup.ts`. Document original files and
* workout GPS/sample time series are never included; the payload's
* `manifest` field discloses both, and the export UI states this too.
*
* Auth: cookie session OR Bearer token (`requireAuth`).
* Rate-limit: shared `export:<userId>` bucket (10/h).
* Audit: `user.export.full-backup` with the row counts.
@@ -34,7 +44,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
// v1.23 — the payload builder is shared with the passphrase-encrypted
// export route so both emit the byte-for-byte same restore-compatible shape.
// export route so both emit the byte-for-byte same shape (the measurement/
// medication/mood/cycle domains are restore-compatible; the newer records
// domains are export-only for now — see the module doc comment above).
const { payload, counts } = await buildFullBackupPayload(prisma, user.id);
await auditLog("user.export.full-backup", {
@@ -51,6 +63,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
export_mood_count: counts.moodEntries,
export_cycle_count: counts.cycles,
export_cycle_day_log_count: counts.cycleDayLogs,
export_lab_result_count: counts.labResults,
export_biomarker_count: counts.biomarkers,
export_illness_episode_count: counts.illnessEpisodes,
export_illness_day_log_count: counts.illnessDayLogs,
export_allergy_count: counts.allergies,
export_family_history_count: counts.familyHistory,
export_workout_count: counts.workouts,
export_document_count: counts.documents,
},
});
@@ -0,0 +1,115 @@
/**
* GET /api/fitbit/connect
*
* Pins: happy path mints a nonce + PKCE pair and redirects to the Fitbit
* consent screen; rate-limit and no-credentials both redirect back to
* `/settings/integrations?fitbit=error&reason=<tag>` rather than returning
* JSON — a browser-navigation entry point should never surface an unstyled
* JSON page.
*/
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,
requireAuth: vi.fn(async () => ({ user: { id: "u-1" } })),
}));
vi.mock("@/lib/db", () => ({
prisma: { fitbitOAuthState: { create: vi.fn() } },
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/fitbit/credentials", () => ({
getUserFitbitCredentials: vi.fn(),
}));
vi.mock("@/lib/fitbit/client", () => ({
getAuthorizationUrl: vi.fn(
() => "https://www.fitbit.com/oauth2/authorize?state=nonce-stub",
),
generatePkcePair: vi.fn(() => ({
verifier: "verifier-stub",
challenge: "challenge-stub",
})),
}));
vi.mock("@/lib/fitbit/oauth-state", () => ({
FITBIT_OAUTH_STATE_COOKIE: "fitbit_oauth_state",
FITBIT_OAUTH_STATE_TTL_MS: 10 * 60 * 1000,
mintFitbitOAuthStateNonce: vi.fn(() => "nonce-stub"),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => ({ setError: vi.fn() })),
}));
vi.mock("@/lib/auth/secure-cookie", () => ({
shouldEmitSecureCookie: () => true,
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
import { getUserFitbitCredentials } from "@/lib/fitbit/credentials";
function connectRequest(): NextRequest {
return new NextRequest("http://localhost/api/fitbit/connect", {
method: "GET",
});
}
beforeEach(() => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 60_000,
} as never);
vi.mocked(prisma.fitbitOAuthState.create).mockReset();
vi.mocked(prisma.fitbitOAuthState.create).mockResolvedValue({} as never);
vi.mocked(getUserFitbitCredentials).mockResolvedValue({
clientId: "client-id",
clientSecret: "client-secret",
} as never);
});
describe("GET /api/fitbit/connect", () => {
it("redirects to the Fitbit authorize URL on the happy path", async () => {
const response = await GET(connectRequest());
expect(response.status).toBe(307);
expect(response.headers.get("location")).toContain(
"www.fitbit.com/oauth2/authorize",
);
expect(prisma.fitbitOAuthState.create).toHaveBeenCalledTimes(1);
});
it("redirects to reason=rate_limited when the bucket is exhausted", async () => {
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
} as never);
const response = await GET(connectRequest());
const location = response.headers.get("location") ?? "";
expect(location).toContain("/settings/integrations");
expect(location).toContain("fitbit=error");
expect(location).toContain("reason=rate_limited");
expect(prisma.fitbitOAuthState.create).not.toHaveBeenCalled();
});
it("redirects to reason=nocreds when no per-user credentials are stored", async () => {
vi.mocked(getUserFitbitCredentials).mockResolvedValueOnce(null);
const response = await GET(connectRequest());
const location = response.headers.get("location") ?? "";
expect(location).toContain("/settings/integrations");
expect(location).toContain("fitbit=error");
expect(location).toContain("reason=nocreds");
expect(prisma.fitbitOAuthState.create).not.toHaveBeenCalled();
});
});
+5 -5
View File
@@ -1,5 +1,4 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -32,7 +31,8 @@ import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
* Rate-limited per user (10 calls / 60 s) so a logged-in session can't spam
* ledger rows for the full 10-min TTL window. Both the rate-limit and
* create-failure paths redirect (not JSON) because the entry point is a browser
* navigation — a 429 envelope would surface as a blank page.
* navigation — a 429 envelope would surface as a blank page. The
* no-credentials branch redirects too (v1.29.x), matching Withings/WHOOP.
*/
const CONNECT_RATE_LIMIT = 10;
const CONNECT_WINDOW_MS = 60_000;
@@ -58,9 +58,9 @@ export const GET = apiHandler(async (req: NextRequest) => {
const creds = await getUserFitbitCredentials(user.id);
if (!creds) {
return apiError(
"Please configure your Fitbit OAuth Client ID and Client Secret in Settings first.",
400,
annotate({ action: { name: "fitbit.connect.no_credentials" } });
return NextResponse.redirect(
new URL("/settings/integrations?fitbit=error&reason=nocreds", req.url),
);
}
@@ -11,6 +11,12 @@ vi.mock("@/lib/db", () => ({
// back to the all-on default so existing assertions ride
// through unchanged.
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
// v1.28 perf — PULSE now reads through the DAY-rollup read-swap
// (`probeRollupCoverage` + `readDayMeanSeries`). An empty coverage
// probe sends every test down the live-fallback branch, which itself
// reads via the already-mocked `measurement.findMany` — no rollup
// rows needed for these tests.
$queryRaw: vi.fn().mockResolvedValue([]),
},
}));
@@ -69,6 +75,10 @@ beforeEach(() => {
aiProvider: null,
} as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
// v1.28 perf — empty coverage probe routes PULSE through the
// live-fallback branch of `readDayMeanSeries`, which reads via the
// already-mocked `measurement.findMany` above.
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
});
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
@@ -165,6 +175,56 @@ describe("GET /api/insights/cards", () => {
expect(body.data.some((c) => c.severity === "alert")).toBe(true);
});
it("reads PULSE through the day-rollup read-swap, never the manual raw-row query (perf)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// 27 quiet days + 3 outlier days, one raw sample per day — a large
// enough quiet cluster that the 3 outliers still clear the z-score > 2
// threshold once folded into day means by the live-fallback branch of
// `readDayMeanSeries` (a small n dilutes the mean/stddev too much for
// the outliers to stand out).
const pulseRows: Array<{ value: number; measuredAt: Date }> = [];
const now = new Date();
for (let i = 0; i < 30; i++) {
const at = new Date(now.getTime() - i * 86_400_000);
pulseRows.push({ value: i < 3 ? 300 : 68, measuredAt: at });
}
vi.mocked(prisma.measurement.findMany).mockImplementation((async (args: {
where?: { type?: unknown };
}) => {
// `readDayMeanSeries`'s live-fallback probes a single `type`
// equality filter; the manual WEIGHT/BP query below probes an
// `{ in: [...] }` list — the two calls are distinguishable by shape.
if (args?.where?.type === "PULSE") return pulseRows;
return [];
}) as never);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: Array<{ title: string; summary: string }>;
};
expect(
body.data.some((c) => c.summary.includes("unusual pulse readings")),
).toBe(true);
// The bounded manual-measurements query (WEIGHT / BP) must never ask
// for PULSE — a dense wearable account's raw PULSE rows only ever
// reach this route through the day-mean rollup read.
const manualCall = vi
.mocked(prisma.measurement.findMany)
.mock.calls.find(
(call) =>
typeof (call[0] as { where?: { type?: { in?: unknown[] } } })?.where
?.type === "object",
);
expect(manualCall).toBeDefined();
const typeIn = (manualCall?.[0] as { where: { type: { in: string[] } } })
.where.type.in;
expect(typeIn).not.toContain("PULSE");
});
it("does NOT flag a fully-adherent weekly injectable as low compliance (#214 regression)", async () => {
// Pre-fix this path used a naive `schedules.length × 7` denominator, so a
// weekly Monday injectable with its one dose taken computed ~14% and fired
+59 -34
View File
@@ -13,6 +13,8 @@ import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
import {
@@ -72,43 +74,66 @@ export const GET = apiHandler(async () => {
await requireAssistantSurface("insightStatus");
annotate({ action: { name: "insights.cards" } });
const ninetyDaysAgo = new Date(Date.now() - 90 * 86_400_000);
const NINETY_DAY_WINDOW = 90;
const ninetyDaysAgo = new Date(Date.now() - NINETY_DAY_WINDOW * 86_400_000);
const [dbUser, allMeasurements, medications] = await Promise.all([
prisma.user.findUnique({
where: { id: user.id },
select: { heightCm: true, dateOfBirth: true, aiProvider: true },
}),
prisma.measurement.findMany({
where: {
userId: user.id,
type: {
in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"],
const [dbUser, manualMeasurements, medications, rollupCoverage] =
await Promise.all([
prisma.user.findUnique({
where: { id: user.id },
select: { heightCm: true, dateOfBirth: true, aiProvider: true },
}),
// WEIGHT + BP are manual entries (low volume, and BP needs exact-
// timestamp pairing below for `bpPctInTarget`), so a bounded raw read
// stays cheap. PULSE is excluded here — a continuously-synced wearable
// account can carry 100k+ raw rows in the window; it reads through the
// rollup tier below instead.
prisma.measurement.findMany({
where: {
userId: user.id,
type: { in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"] },
measuredAt: { gte: ninetyDaysAgo },
deletedAt: null,
},
measuredAt: { gte: ninetyDaysAgo },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { type: true, value: true, measuredAt: true },
}),
prisma.medication.findMany({
// v1.16.11 — as-needed (PRN) medications never surface a compliance
// rate (no expected doses).
where: { userId: user.id, active: true, asNeeded: false },
include: {
// Schedules through the shared compliance select so the configured
// per-dose windows reach the engine like every other surface.
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
// v1.16.3 — archived schedule eras for era-aware expected counts.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
// v1.25 H-MED1 — pause eras so paused days drop out of the denominator.
pauseEras: { select: { pausedAt: true, resumedAt: true } },
},
}),
]);
orderBy: { measuredAt: "asc" },
select: { type: true, value: true, measuredAt: true },
}),
prisma.medication.findMany({
// v1.16.11 — as-needed (PRN) medications never surface a compliance
// rate (no expected doses).
where: { userId: user.id, active: true, asNeeded: false },
include: {
// Schedules through the shared compliance select so the configured
// per-dose windows reach the engine like every other surface.
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
// v1.16.3 — archived schedule eras for era-aware expected counts.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
// v1.25 H-MED1 — pause eras so paused days drop out of the denominator.
pauseEras: { select: { pausedAt: true, resumedAt: true } },
},
}),
probeRollupCoverage(user.id),
]);
// v1.28 perf — PULSE reads the DAY-rollup tier (read-swap; falls back to a
// bounded per-day-grouped live read on a coverage miss) instead of pulling
// every raw sample into JS. `pulseAvg30` / `pulseAnomalyCount` derive from
// the resulting day-mean series, so a dense wearable account's anomaly
// count reflects daily outliers rather than every noisy raw sample.
const { points: pulseDayMeans } = await readDayMeanSeries(
user.id,
"PULSE",
NINETY_DAY_WINDOW,
new Date(),
rollupCoverage,
);
const pulseData: DataPoint[] = pulseDayMeans.map((p) => ({
date: new Date(`${p.day}T00:00:00.000Z`),
value: p.mean,
}));
const byType = (t: MeasurementType): DataPoint[] =>
allMeasurements
manualMeasurements
.filter((m) => m.type === t)
.map((m) => ({ date: m.measuredAt, value: m.value }));
@@ -119,11 +144,11 @@ export const GET = apiHandler(async () => {
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
] as MeasurementType[]) {
const data = byType(t);
if (data.length > 0) summaries[t] = summarize(data);
}
if (pulseData.length > 0) summaries.PULSE = summarize(pulseData);
let bmi: number | null = null;
if (dbUser?.heightCm && summaries.WEIGHT?.latest) {
@@ -0,0 +1,176 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* v1.30.2 (QoL H1) — `GET /api/insights/chat` gained an optional `q` search
* param so the history rail's search reaches the caller's FULL conversation
* set (title-only, server-side) instead of filtering only the loaded page.
* This suite pins the route's parsing: trimmed, length-capped, and omitted
* (not an empty string) when absent — `listConversations` treats `undefined`
* and `""` identically, but keeping the route from ever forwarding a bare
* empty string documents the contract at the call site.
*/
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
HttpError: class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
}));
const { requireModuleEnabled, requireAssistantSurface } = vi.hoisted(() => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
requireAssistantSurface: vi.fn(async () => undefined),
}));
vi.mock("@/lib/modules/gate", () => ({ requireModuleEnabled }));
vi.mock("@/lib/feature-flags", () => ({ requireAssistantSurface }));
vi.mock("@/lib/api-response", () => ({
apiError: (error: string, status: number, meta?: unknown) => ({
data: null,
error,
status,
meta,
}),
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn(async () => "en"),
}));
vi.mock("@/lib/ai/provider-runner", () => ({
AllProvidersFailedError: class extends Error {},
runRawCompletionWithFallback: vi.fn(),
runStreamingRawCompletionWithFallback: vi.fn(),
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: vi.fn(),
resolveProvider: vi.fn(),
}));
vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain: vi.fn() }));
vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" }));
vi.mock("@/lib/ai/coach/types", () => ({
coachChatRequestSchema: { safeParse: () => ({ success: false }) },
}));
vi.mock("@/lib/ai/coach/tools", () => ({
COACH_TOOL_DEFS: [],
MAX_ROUNDS: 3,
buildCoachDataInventory: vi.fn(),
renderDataInventory: vi.fn(),
renderFocusHint: vi.fn(() => ""),
buildToolModeAddendum: vi.fn(),
runCoachToolLoop: vi.fn(),
}));
interface ListConversationsCallArgs {
userId: string;
cursor?: string | null;
limit?: number;
q?: string;
}
// `vi.mock` factories are hoisted above every top-level declaration in the
// file, so a plain `const listConversations = vi.fn(...)` referenced inside
// the factory below would hit the temporal dead zone. `vi.hoisted` runs
// before that hoisting and is the documented escape hatch.
const { listConversations } = vi.hoisted(() => ({
listConversations: vi.fn(async (params: ListConversationsCallArgs) => {
void params; // typed purely so `.mock.calls[n][0]` below is not `never`
return { conversations: [], nextCursor: null };
}),
}));
vi.mock("@/lib/ai/coach/persistence", () => ({
appendMessage: vi.fn(),
createConversation: vi.fn(),
fetchConversationWithMessages: vi.fn(),
listConversations,
}));
vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({
enqueueCoachMemoryRefresh: vi.fn(),
}));
vi.mock("@/lib/ai/coach/facts", () => ({ storeDeterministicFacts: vi.fn() }));
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(),
reserveBudget: vi.fn(),
reconcileSpend: vi.fn(),
resolveDailyCap: vi.fn(() => 200_000),
}));
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() }));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(),
}));
vi.mock("@/lib/ai/coach/snapshot", () => ({ buildCoachSnapshot: vi.fn() }));
vi.mock("@/lib/ai/coach/keyvalues", () => ({
parseKeyValuesSentinel: vi.fn(),
}));
vi.mock("@/lib/validations/coach-prefs", () => ({ parseCoachPrefs: vi.fn() }));
vi.mock("@/lib/sse/create-stream", () => ({ createSseStream: vi.fn() }));
import { GET } from "../route";
type Envelope = { data: unknown; error: string | null; status: number };
const get = GET as unknown as (req: Request) => Promise<Envelope>;
function listReq(qs = ""): Request {
return new Request(`http://localhost/api/insights/chat${qs}`, {
method: "GET",
});
}
describe("GET /api/insights/chat — q search param", () => {
beforeEach(() => {
vi.clearAllMocks();
requireModuleEnabled.mockResolvedValue({ enabled: true });
requireAssistantSurface.mockResolvedValue(undefined);
listConversations.mockResolvedValue({
conversations: [],
nextCursor: null,
});
});
it("omits q from listConversations when absent", async () => {
await get(listReq());
expect(listConversations).toHaveBeenCalledWith(
expect.objectContaining({ userId: "u1", q: undefined }),
);
});
it("trims and forwards q", async () => {
await get(listReq("?q=%20blood%20pressure%20"));
expect(listConversations).toHaveBeenCalledWith(
expect.objectContaining({ q: "blood pressure" }),
);
});
it("caps an oversized q at 200 chars", async () => {
const long = "a".repeat(500);
await get(listReq(`?q=${long}`));
const call = listConversations.mock.calls[0][0];
expect(call.q).toHaveLength(200);
});
it("treats a whitespace-only q the same as absent (undefined, not empty string)", async () => {
await get(listReq("?q=%20%20%20"));
const call = listConversations.mock.calls[0][0];
// Trimmed to "" — the route still passes the (falsy) trimmed string
// through; listConversations treats "" the same as undefined.
expect(call.q).toBe("");
});
it("still forwards cursor + limit unchanged alongside q", async () => {
await get(listReq("?cursor=abc&limit=10&q=weight"));
expect(listConversations).toHaveBeenCalledWith(
expect.objectContaining({ cursor: "abc", limit: 10, q: "weight" }),
);
});
});
+17 -2
View File
@@ -1435,13 +1435,25 @@ function streamProviderError(args: { code: string }): Response {
// the conversationId existence check + 20-turn cap.
export const POST = apiHandler(handleChatRequest);
/** v1.30.2 (QoL H1) — hard cap on the `?q=` search string length. */
const LIST_QUERY_MAX_LEN = 200;
/**
* GET /api/insights/chat?cursor=<id>&limit=<n>
* GET /api/insights/chat?cursor=<id>&limit=<n>&q=<text>
*
* Cursor-paginated list of the caller's conversations for the rail.
* Default limit 20, hard cap 50. Cursor is the id of the last item
* on the previous page; callers receive `{ nextCursor: null }` when
* they have reached the end.
*
* v1.30.2 (QoL H1) — optional `q` narrows the page to conversations whose
* TITLE contains the text (case-insensitive substring). This makes the
* history rail's search reach the caller's FULL conversation set instead
* of only the loaded page; the client re-issues the cursor walk under the
* new `q` whenever the search box changes rather than filtering client-
* side. Message bodies are encrypted at rest and are NOT searched — a
* decrypt-and-scan over every message would be prohibitively expensive
* for a live keystroke search and is out of scope for this pass.
*/
export const GET = apiHandler(async (request: NextRequest) => {
const auth = await requireAuth();
@@ -1456,11 +1468,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
const cursor = url.searchParams.get("cursor");
const limitRaw = url.searchParams.get("limit");
const limit = limitRaw ? Number.parseInt(limitRaw, 10) : undefined;
const qRaw = url.searchParams.get("q");
const q = qRaw ? qRaw.trim().slice(0, LIST_QUERY_MAX_LEN) : undefined;
const page = await listConversations({
userId: auth.user.id,
cursor,
limit: Number.isFinite(limit) ? (limit as number) : undefined,
q,
// v1.28.51 (Documents R3, Design A) — the rail now surfaces BOTH health
// threads and doc-scoped threads (the DTO carries `documentId` +
// `documentTitle` so the client badges the fenced ones). Omitting the
@@ -1472,7 +1487,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
annotate({
action: { name: "insights.coach.list" },
meta: { count: page.conversations.length },
meta: { count: page.conversations.length, hasQuery: Boolean(q) },
});
return apiSuccess(page);
@@ -305,6 +305,40 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
expect(body.data.bpClassification?.category).toBe("Normal");
});
// v1.30.3 (QA F7 — completes the "caller has it" pairing-tz fix across
// every `computeBpInTargetPct` call site, not just the fast-path).
it("pairs bpPctInTarget on the session user's own tz, not a hardcoded Berlin day", async () => {
vi.mocked(getSession).mockResolvedValue({
...SESSION_OK,
user: { ...SESSION_OK.user, timezone: "America/New_York" },
} as never);
// 2026-05-14T21:50Z = 23:50 Berlin (05-14) / 17:50 New York (05-14).
// 2026-05-14T22:10Z = 00:10 Berlin (05-15, the NEXT Berlin day) /
// 18:10 New York (05-14, the SAME New York day). >5-min gap, so only
// the same-calendar-day fallback can pair them — and only New York
// agrees with itself.
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
{
summaries: {},
bpRawRows: {
sys: [{ measuredAt: new Date("2026-05-14T21:50:00Z"), value: 125 }],
dia: [{ measuredAt: new Date("2026-05-14T22:10:00Z"), value: 75 }],
},
dailyByType: {},
firstMeasurementAt: new Date("2026-05-14T21:50:00Z"),
totalMeasurements: 2,
},
);
const res = await callGet(makeReq());
const body = (await res.json()) as {
data: { bpPctInTarget: number | null };
};
// A Berlin-day pairing would reject this pair (different Berlin
// calendar days) and read null; the user's own tz accepts it.
expect(body.data.bpPctInTarget).toBe(100);
});
it("consumes the mood-rollup tier and skips the raw findMany when DAY rows exist", async () => {
// v1.4.40 W-INSIGHTS — rollup-tier read swap parity. Three DAY rows
// populated → the route reads them directly and never touches the
+33 -4
View File
@@ -100,6 +100,11 @@ type AuthedUser = AuthContext["user"];
export async function buildComprehensiveResponse(user: AuthedUser) {
const userId = user.id;
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
// v1.30.3 (QA F8) — the one shared per-user tz for every day-bucketing
// pass in this route (resting-pulse proxy, medication-continuity, BP
// pairing). Was previously re-derived ad hoc (or hardcoded UTC) at each
// call site.
const userTz = user.timezone || "Europe/Berlin";
// Fetch user profile (height + DOB drive BMI + BP targets).
const dbUser = await prisma.user.findUnique({
@@ -221,10 +226,11 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
// sys+dia on the 5-minute window ONLY, dropping readings whose sys/dia
// timestamps drift past 5 min on Withings / Apple-Health imports, which
// skewed the share iOS renders on the Home Health Score). The helper
// adds the same-Berlin-day pairing fallback so those readings pair, and
// keeps the ESH-2023 band + 90/50 hypotension floor (both sys AND dia at
// or below the age-band ceiling). Window is the aggregator's 90-day raw
// pull — a "recent control" figure, not the all-time headline.
// adds the same-local-day pairing fallback (v1.30.3 QA F7 — keyed on
// the user's OWN tz, not a hardcoded Berlin day) so those readings
// pair, and keeps the ESH-2023 band + 90/50 hypotension floor (both sys
// AND dia at or below the age-band ceiling). Window is the aggregator's
// 90-day raw pull — a "recent control" figure, not the all-time headline.
let bpPctInTarget: number | null = null;
if (bpTargets) {
bpPctInTarget =
@@ -232,6 +238,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
aggregate.bpRawRows.sys,
aggregate.bpRawRows.dia,
bpTargets,
userTz,
)?.pct ?? null;
}
@@ -274,6 +281,20 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
// Day keys follow this route's UTC-bucket convention (the per-user-tz
// bucketing is the documented v1.5 follow-up) so the resting series pairs
// against the same UTC day keys as the mood series.
//
// v1.30.3 (QA F8) — investigated switching this to `userDayKey(d, userTz)`
// to match the intraday page's resting-proxy bucketing (`71d7ca78c`), but
// `dailyMoodEntries` below (this series' ONLY pairing partner, joined by
// an exact YYYY-MM-DD key in `pairMoodWithDaily`) is itself UTC-anchored —
// it reads `moodRollupDayRows[].bucketStart.toISOString().slice(0, 10)`,
// a real UTC aggregation boundary, not just a display label. Moving ONLY
// this side to the user's tz would not fix anything: it would swap
// "evening readings shift a day relative to the true local day" for
// "evening readings mismatch their mood pairing partner, which used to
// line up precisely because both sides shared the same UTC convention."
// A genuine fix needs the mood-rollup tier itself to bucket per-user-tz
// (the same v1.5 follow-up the comment above already flags) — out of
// scope for this pass. Left UTC on both sides intentionally.
const restingPulseRows = await prisma.measurement.findMany({
where: {
userId,
@@ -437,6 +458,14 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
if (expectedBpIntakesPerDay > 0) {
// Daily systolic means (already keyed YYYY-MM-DD) from the SQL pass.
// v1.30.3 (QA F8) — `sysDaily` is UTC `date_trunc`-bucketed by the
// aggregator (`comprehensive-aggregator.ts`), so `takenByDay` below
// MUST key on the same UTC day, not the user's own tz — pairing the
// two on different day spaces would silently drop pairs rather than
// fix anything. Making this whole correlation user-tz-consistent
// needs the aggregator's `dailyByType` to bucket in the user's tz
// first (a bigger, already-flagged follow-up); until then this stays
// UTC to match its only pairing partner.
const sysByDay = new Map<string, number>();
for (const row of sysDaily) {
sysByDay.set(row.day, row.value);
@@ -196,6 +196,27 @@ describe("GET /api/insights/correlations", () => {
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
});
// v1.30 (DATAINT M1) — `asc` + `take` truncated the RECENT end of the
// 180-day window on a dense account; the read must order `desc` so the
// cap keeps the newest rows (re-sorted `asc` in JS before series-building).
it("reads the measurement + mood window ordered desc so the cap keeps the newest rows", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
await callGet(makeReq());
expect(prisma.measurement.findMany).toHaveBeenCalledWith(
expect.objectContaining({
orderBy: { measuredAt: "desc" },
take: 20000,
}),
);
expect(prisma.moodEntry.findMany).toHaveBeenCalledWith(
expect.objectContaining({
orderBy: { moodLoggedAt: "desc" },
take: 5000,
}),
);
});
// v1.18.0 (B2) — the route now also requires the `insights` module.
it("returns 403 + module.disabled when the insights module is off", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+34 -55
View File
@@ -38,11 +38,15 @@ import {
type NamedSeries,
} from "@/lib/insights/correlation-discovery";
import {
buildMeasurementDailySeries,
fetchComplianceSeries,
fetchEnvironmentSeries,
fetchLabDraws,
fetchMeasurementWindowSeries,
fetchMoodWindowSeries,
fetchSymptomSeries,
} from "@/lib/insights/correlation-channel-series";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
export const dynamic = "force-dynamic";
@@ -58,25 +62,6 @@ function tzDayKey(at: Date, tz: string): string {
return `${year}-${mm}-${dd}`;
}
/** Collapse rows to per-day means keyed in the user's tz. */
function toDailyMeans(
rows: Array<{ value: number; at: Date }>,
tz: string,
): DailySeriesPoint[] {
const byDay = new Map<string, { sum: number; count: number }>();
for (const r of rows) {
if (!Number.isFinite(r.value)) continue;
const day = tzDayKey(r.at, tz);
const acc = byDay.get(day) ?? { sum: 0, count: 0 };
acc.sum += r.value;
acc.count += 1;
byDay.set(day, acc);
}
return [...byDay.entries()]
.map(([day, acc]) => ({ day, value: acc.sum / acc.count }))
.sort((a, b) => (a.day < b.day ? -1 : 1));
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
@@ -112,42 +97,24 @@ export const GET = apiHandler(async () => {
DISCOVERY_OUTCOMES,
) as MeasurementType[];
const [measurements, moodEntries] = await Promise.all([
prisma.measurement.findMany({
where: {
userId: user.id,
deletedAt: null,
measuredAt: { gte: since },
type: { in: [...behaviourTypes, ...outcomeTypes] },
},
orderBy: { measuredAt: "asc" },
take: 20000,
select: { type: true, value: true, measuredAt: true },
}),
prisma.moodEntry.findMany({
where: { userId: user.id, deletedAt: null, moodLoggedAt: { gte: since } },
orderBy: { moodLoggedAt: "asc" },
take: 5000,
select: { score: true, moodLoggedAt: true },
}),
// v1.30.3 (QA F1) — the fetch + desc/cap/resort discipline now lives in
// `fetchMeasurementWindowSeries` / `fetchMoodWindowSeries`
// (`correlation-channel-series.ts`), shared with the Coach tool, the
// per-metric card, and the period narrative so a fourth independently-
// maintained copy can't drift the way the period-narrative one did.
const [
{ byType: measurementsByType, measurementsCapped },
moodWindow,
priorityJson,
] = await Promise.all([
fetchMeasurementWindowSeries(user.id, since, [
...behaviourTypes,
...outcomeTypes,
]),
fetchMoodWindowSeries(user.id, tz, since),
loadUserSourcePriority(user.id),
]);
const measurementsByType = new Map<
string,
Array<{ value: number; at: Date }>
>();
for (const m of measurements) {
const list = measurementsByType.get(m.type) ?? [];
list.push({ value: m.value, at: m.measuredAt });
measurementsByType.set(m.type, list);
}
// MOOD's daily-mean series is shared between its behaviour and outcome
// roles (computed once).
const moodDaily = toDailyMeans(
moodEntries.map((e) => ({ value: e.score, at: e.moodLoggedAt })),
tz,
);
const { moodDaily, moodCapped } = moodWindow;
// v1.21.0 (FDREXTEND) — build the two non-measurement, non-mood channels from
// their own sources. Each degrades to an empty series when the user has no
@@ -167,7 +134,12 @@ export const GET = apiHandler(async () => {
const points = (key: string): DailySeriesPoint[] =>
key === "MOOD"
? moodDaily
: toDailyMeans(measurementsByType.get(key) ?? [], tz);
: buildMeasurementDailySeries(
key as MeasurementType,
measurementsByType.get(key) ?? [],
tz,
priorityJson,
);
const series: NamedSeries[] = [];
for (const key of DISCOVERY_BEHAVIOURS) {
@@ -216,6 +188,13 @@ export const GET = apiHandler(async () => {
pairs_tested: result.pairsTested,
discovered: result.discovered.length,
fdr_q: result.fdrQ,
// PERFAUDIT M1 — surfaces when a dense account's window exceeded the
// read cap. The cap now falls on the OLDEST rows (desc + take), so a
// capped read still covers the recent window `discoverEmergingCorrelations`
// needs; this only tells a dashboard the retrospective scan's older
// half of the window may be thin.
measurements_capped: measurementsCapped,
mood_entries_capped: moodCapped,
// FDREXTEND — per-channel day-counts so a dashboard can see whether the
// two sparse new channels reached the n ≥ 20 floor or degraded to absent.
compliance_days: complianceSeries.points.length,
+4 -1
View File
@@ -11,6 +11,7 @@
* `data` object is built field-by-field (no mass assignment).
*/
import { NextRequest } from "next/server";
import { fireAndForget } from "@/lib/logging/fire-and-forget";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
@@ -210,7 +211,9 @@ async function commitOcrRows(request: NextRequest) {
// A lab panel just landed — resolve any "annual blood panel" reminders now
// rather than waiting on the cron. Fire-and-forget.
if (inserted.length > 0) {
void enqueueReminderSatisfy(user.id).catch(() => {});
fireAndForget(enqueueReminderSatisfy(user.id), {
action: "reminder.satisfy.enqueue",
});
}
return apiSuccess({ inserted, skipped });
+4 -1
View File
@@ -1,4 +1,5 @@
import { NextRequest } from "next/server";
import { fireAndForget } from "@/lib/logging/fire-and-forget";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
@@ -271,7 +272,9 @@ async function postLabResult(request: NextRequest) {
// v1.18.1 (D2) — eventful Lab↔Vorsorge satisfaction. A lab panel just
// landed; resolve the user's free-text "annual blood panel" reminders
// now rather than waiting on the 15-min cron. Fire-and-forget.
void enqueueReminderSatisfy(user.id).catch(() => {});
fireAndForget(enqueueReminderSatisfy(user.id), {
action: "reminder.satisfy.enqueue",
});
return apiSuccess(serialiseLabResult(created, biomarker), 201);
}
@@ -27,6 +27,11 @@ vi.mock("@/lib/db", () => ({
measurementRollup: {
findMany: vi.fn(),
},
// v1.29.6 — the groupBy=day branch now runs its raw rows through
// `pickCanonicalSourceRows`, which loads the user's source-priority
// blob via `loadUserSourcePriority` (prisma.user.findUnique). `null`
// resolves to the default ladders.
user: { findUnique: vi.fn() },
// v1.4.43 W6 — validation-failed paths write an audit breadcrumb.
auditLog: { create: vi.fn().mockResolvedValue({}) },
$queryRaw: vi.fn(),
@@ -75,6 +80,9 @@ beforeEach(() => {
// write; the route swallows rejections so the test only needs a
// resolved mock to keep the catch-block silent.
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
// v1.29.6 — null source-priority blob → default rank ladders for the
// groupBy=day canonical-source collapse.
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
});
describe("GET /api/measurements — groupBy=day (W7c collapsed list)", () => {
@@ -165,6 +173,173 @@ describe("GET /api/measurements — groupBy=day (W7c collapsed list)", () => {
expect(day15.measuredAt).toContain("2026-05-15T10:00:00");
});
// v1.29.6 — the raw-row scan must read the MOST RECENT window before
// bucketing, mirroring the workouts-list ordering fix (05f80ad37): the
// canonical/day-bucket picker doesn't reorder its input, but the
// underlying `orderBy: asc` + `take: limit` sliced the OLDEST page of
// rows before the day-bucketing loop ran — on a long history the most
// recent days never appeared at all.
it("scans the raw rows newest-first before bucketing", async () => {
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
await GET(getRequest("type=ACTIVITY_STEPS&groupBy=day"));
const call = vi.mocked(prisma.measurement.findMany).mock.calls[0]?.[0] as {
orderBy?: { measuredAt?: "asc" | "desc" };
};
expect(call.orderBy).toEqual({ measuredAt: "desc" });
});
// v1.29.6 — a day with both an Apple Health AND a Withings write for the
// same cumulative metric must collapse to the ladder-canonical source
// before summing, exactly like the dashboard rollup tile
// (`collapseRollupRowsBySource`) does. Without the collapse, 9000
// Apple-Health steps + 8800 Withings steps summed to a fabricated 17800 —
// a number the tile never showed.
it("collapses cross-source rows to the ladder-canonical source before summing", async () => {
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
id: "m-apple",
type: "ACTIVITY_STEPS",
value: 9000,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-15T08:00:00.000Z"),
notes: null,
deviceType: null,
},
{
id: "m-withings",
type: "ACTIVITY_STEPS",
value: 8800,
unit: "steps",
source: "WITHINGS",
measuredAt: new Date("2026-05-15T09:00:00.000Z"),
notes: null,
deviceType: null,
},
] as never);
const res = await GET(
getRequest(
"type=ACTIVITY_STEPS&groupBy=day&from=2026-05-15T00:00:00Z&to=2026-05-16T00:00:00Z",
),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: {
measurements: Array<{
dayKey: string;
value: number;
source: string;
sampleCount: number;
}>;
meta: { droppedDuplicates?: number };
};
};
expect(json.data.measurements.length).toBe(1);
const [day] = json.data.measurements;
// APPLE_HEALTH beats WITHINGS on the default `steps` ladder — the
// Withings row drops out of the sum entirely rather than adding on top.
expect(day.value).toBe(9000);
expect(day.source).toBe("APPLE_HEALTH");
expect(day.sampleCount).toBe(1);
expect(json.data.meta.droppedDuplicates).toBe(1);
});
// v1.30.3 (QA F6) — a capped read's oldest bucket sits mid-day: every
// sample on that calendar day BEFORE the cutoff row was excluded, so the
// bucket's SUM understates the real day total. The row must carry a
// `partial: true` marker instead of rendering as if it were complete.
it("flags the oldest bucket partial when the raw-row read hits the cap", async () => {
// limit=3: the DESC-ordered read returns exactly 3 rows (the cap), so
// the oldest of the three (2026-05-14) is a truncated day — its bucket
// could be missing earlier same-day samples the cap cut off.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
id: "m-1",
type: "ACTIVITY_STEPS",
value: 3400,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-15T11:00:00.000Z"),
notes: null,
},
{
id: "m-2",
type: "ACTIVITY_STEPS",
value: 2500,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-15T07:00:00.000Z"),
notes: null,
},
{
id: "m-3",
type: "ACTIVITY_STEPS",
value: 800,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-14T16:00:00.000Z"),
notes: null,
},
] as never);
const res = await GET(
getRequest("type=ACTIVITY_STEPS&groupBy=day&limit=3"),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: {
measurements: Array<{
dayKey: string;
value: number;
partial?: boolean;
}>;
meta: { capped?: boolean };
};
};
const day15 = json.data.measurements.find((m) => m.dayKey === "2026-05-15");
const day14 = json.data.measurements.find((m) => m.dayKey === "2026-05-14");
expect(day15?.partial).toBeUndefined();
expect(day14?.partial).toBe(true);
});
it("flags no bucket partial when the read comes in under the cap (whole window covered)", async () => {
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
id: "m-1",
type: "ACTIVITY_STEPS",
value: 3400,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-15T11:00:00.000Z"),
notes: null,
},
{
id: "m-3",
type: "ACTIVITY_STEPS",
value: 800,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-14T16:00:00.000Z"),
notes: null,
},
] as never);
// Default limit (100) is nowhere near hit by 2 rows.
const res = await GET(getRequest("type=ACTIVITY_STEPS&groupBy=day"));
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: {
measurements: Array<{ dayKey: string; partial?: boolean }>;
};
};
expect(json.data.measurements.every((m) => m.partial === undefined)).toBe(
true,
);
});
it("falls back to the legacy findMany path when groupBy=day is set on a non-cumulative type", async () => {
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
@@ -1,12 +1,14 @@
/**
* v1.19.0 (iOS #34) — go-forward aggregated heart-rate wire contract on
* `POST /api/measurements/batch`.
* v1.30.7/v1.30.8 (iOS #34) — go-forward aggregated 10-min heart-rate wire
* contract on `POST /api/measurements/batch`.
*
* Asserts:
* - a fresh hourly HR bucket inserts;
* - a re-post of the same hour OVERWRITES (status `updated`, not a
* - a fresh 10-min HR bucket inserts, with its per-bucket min/max spread;
* - a re-post of the same bucket OVERWRITES (status `updated`, not a
* duplicate row);
* - a malformed hourly-HR bucket externalId is `skipped`;
* - a malformed / off-grid aggregated HR bucket externalId is `skipped`;
* - v1.30.8: an out-of-range or mis-ordered spread is dropped to null while
* the trustworthy average survives;
* - the per-sample uuid HR path is unaffected (immutable duplicate);
* - the existing per-day step `stats:` overwrite path is unaffected.
*/
@@ -131,8 +133,8 @@ beforeEach(() => {
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ count: 1 });
});
describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", () => {
it("inserts a fresh hourly HR bucket as a PULSE row", async () => {
describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34)", () => {
it("inserts a fresh 10-min HR bucket as a PULSE row", async () => {
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
const res = await POST(
makeRequest({ entries: [hrBucketEntry(HR_BUCKET_ID, 72)] }),
@@ -143,7 +145,7 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
expect(data.updated).toBe(0);
expect(data.entries[0].status).toBe("inserted");
// Stored as a PULSE row carrying the hourly average value.
// Stored as a PULSE row carrying the 10-min average value.
const createArg = vi.mocked(prisma.measurement.createMany).mock.calls[0][0];
const rows = (createArg as { data: { type: string; value: number }[] })
.data;
@@ -151,7 +153,7 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
expect(rows[0].value).toBe(72);
});
it("persists per-bucket min/max on a fresh hourly HR bucket (iOS #34 ext)", async () => {
it("persists per-bucket min/max on a fresh 10-min HR bucket (iOS #34 ext)", async () => {
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
const res = await POST(
makeRequest({
@@ -172,6 +174,39 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
expect(rows[0].valueMax).toBe(96);
});
it.each([
// out-of-plausible-range spread (a sensor glitch / spurious discreteMax)
{ valueMin: 58, valueMax: 99999 },
{ valueMin: -9999, valueMax: 96 },
// mis-ordered: valueMin above the average, or valueMax below it
{ valueMin: 80, valueMax: 96 },
{ valueMin: 58, valueMax: 70 },
])(
"v1.30.8 — drops an out-of-range or mis-ordered spread to null but keeps the average %o",
async (spread) => {
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
const res = await POST(
makeRequest({ entries: [hrBucketEntry(HR_BUCKET_ID, 72, spread)] }),
);
expect(res.status).toBe(200);
const createArg = vi.mocked(prisma.measurement.createMany).mock
.calls[0][0];
const rows = (
createArg as {
data: {
value: number;
valueMin: number | null;
valueMax: number | null;
}[];
}
).data;
// The trustworthy average survives; the invalid spread is dropped.
expect(rows[0].value).toBe(72);
expect(rows[0].valueMin).toBeNull();
expect(rows[0].valueMax).toBeNull();
},
);
it("overwrites min/max alongside the average on a re-post", async () => {
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "PULSE", source: "APPLE_HEALTH", externalId: HR_BUCKET_ID },
@@ -214,8 +249,8 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
expect(rows[0].valueMax).toBeNull();
});
it("overwrites the same hour bucket on a re-post (updated, not duplicate)", async () => {
// The bucket already exists from an earlier within-hour post.
it("overwrites the same bucket on a re-post (updated, not duplicate)", async () => {
// The bucket already exists from an earlier within-bucket post.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
type: "PULSE",
@@ -234,19 +269,19 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
expect(data.duplicates).toBe(0);
expect(data.entries[0].status).toBe("updated");
// The overwrite went through updateMany with the new hourly average.
// The overwrite went through updateMany with the new 10-min average.
expect(prisma.measurement.updateMany).toHaveBeenCalledTimes(1);
const updateArg = vi.mocked(prisma.measurement.updateMany).mock.calls[0][0];
expect((updateArg as { data: { value: number } }).data.value).toBe(78);
expect(prisma.measurement.createMany).not.toHaveBeenCalled();
});
it("skips a malformed hourly HR bucket externalId", async () => {
it("skips a malformed aggregated HR bucket externalId", async () => {
const res = await POST(
makeRequest({
entries: [
hrBucketEntry(
"stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:30:00.000Z",
"stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:35:00.000Z",
70,
),
],
+38 -26
View File
@@ -34,6 +34,7 @@
* contract.
*/
import { NextRequest } from "next/server";
import { fireAndForget } from "@/lib/logging/fire-and-forget";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
@@ -49,8 +50,8 @@ import {
import { withIdempotency } from "@/lib/idempotency";
import {
mapAppleHealthEntry,
isHourlyHeartRateStatsExternalId,
targetsHourlyHeartRateBucket,
isAggregatedBucketExternalId,
targetsAggregatedBucket,
} from "@/lib/measurements/apple-health-mapping";
import {
MEASURED_AT_TOLERANCE_MS,
@@ -291,19 +292,18 @@ async function postBatch(request: NextRequest): Promise<Response> {
continue;
}
// v1.19.0 (iOS #34) — go-forward aggregated heart-rate wire
// contract. A `stats:HKQuantityTypeIdentifierHeartRate:<hour>` row
// is the hourly-average PULSE bucket; it rides the generic `stats:`
// overwrite path below so a within-hour re-post (the running mean
// shifts) replaces the value instead of duplicating. Reject a row
// that targets the HR-bucket prefix but carries a malformed hour
// suffix — a garbage suffix would mint an un-overwriteable row that
// the next hour's re-post can't collapse onto. A well-formed bucket
// (and every non-HR `stats:` / per-sample `uuid` externalId) passes
// through unchanged.
// v1.30.7 (iOS #34) — go-forward aggregated heart-rate wire
// contract. A `stats:HKQuantityTypeIdentifierHeartRate:<10-min-ISO-Z>`
// row is the 10-minute-average PULSE bucket; it rides the generic
// `stats:` overwrite path below so a within-bucket re-post (the running
// mean shifts) replaces the value instead of duplicating. Reject a row
// that targets a bucket prefix but carries a malformed suffix — a
// garbage suffix would mint an un-overwriteable row the next re-post
// can't collapse onto. A well-formed bucket (and every non-bucket
// `stats:` / per-sample `uuid` externalId) passes through unchanged.
if (
targetsHourlyHeartRateBucket(entry.externalId) &&
!isHourlyHeartRateStatsExternalId(entry.externalId)
targetsAggregatedBucket(entry.externalId) &&
!isAggregatedBucketExternalId(entry.externalId)
) {
results[index] = {
index,
@@ -313,20 +313,30 @@ async function postBatch(request: NextRequest): Promise<Response> {
continue;
}
// v1.19.2 (iOS #34 extension) — the per-bucket MIN / MAX are persisted
// ONLY on a well-formed hourly HR bucket row. A per-sample reading, a
// v1.30.7 (iOS #34) — the per-bucket MIN / MAX are persisted ONLY on a
// well-formed 10-min aggregated bucket row. A per-sample reading, a
// per-day cumulative `stats:` total, or a manual entry never carries a
// spread, so we pin them to null there even if a client mis-sends the
// fields — `value` stays the single source of truth for those rows.
const isHrBucket = isHourlyHeartRateStatsExternalId(entry.externalId);
const valueMin =
isHrBucket && typeof mapped.valueMin === "number"
? mapped.valueMin
: null;
const valueMax =
isHrBucket && typeof mapped.valueMax === "number"
? mapped.valueMax
: null;
//
// v1.30.8 — validate the spread the same way `value` is guarded (above):
// it now drives the DAY min/max band + the intraday envelope, so an
// out-of-range or mis-ordered spread (a spurious `discreteMin` of 0, a
// sensor glitch) would poison the aggregate exactly like a bogus `value`
// would. Persist the spread ONLY when both extremes are present, in range,
// and correctly ordered around `value`; otherwise drop to null and keep the
// trustworthy average. Partial or invalid spread → avg-only, still valid.
const isHrBucket = isAggregatedBucketExternalId(entry.externalId);
const spreadValid =
isHrBucket &&
typeof mapped.valueMin === "number" &&
typeof mapped.valueMax === "number" &&
validateMeasurementRange(mapped.type, mapped.valueMin) === null &&
validateMeasurementRange(mapped.type, mapped.valueMax) === null &&
mapped.valueMin <= mapped.value &&
mapped.value <= mapped.valueMax;
const valueMin = spreadValid ? mapped.valueMin : null;
const valueMax = spreadValid ? mapped.valueMax : null;
prepared.push({
index,
@@ -682,7 +692,9 @@ async function postBatch(request: NextRequest): Promise<Response> {
// v1.18.1 — eventful Vorsorge satisfaction. A matching reading just
// landed; resolve the user's reminders now rather than waiting on the
// 15-min cron. Fire-and-forget — the cron reconciles on enqueue miss.
void enqueueReminderSatisfy(user.id).catch(() => {});
fireAndForget(enqueueReminderSatisfy(user.id), {
action: "reminder.satisfy.enqueue",
});
try {
await enqueuePrDetection(user.id, { silent });
await auditLog("personal_records.detection_enqueued", {
+99 -30
View File
@@ -1,6 +1,7 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { fireAndForget } from "@/lib/logging/fire-and-forget";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
@@ -21,6 +22,8 @@ import {
type AggregateGrain,
} from "@/lib/measurements/range-aggregation";
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum";
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
import {
canonicalDailyTimestamp,
dayKeyForUserTz,
@@ -214,9 +217,17 @@ export const GET = apiHandler(async (request: NextRequest) => {
user.timezone && user.timezone.length > 0
? user.timezone
: "Europe/Berlin";
// v1.29.6 — DESC + take so a long history returns the MOST RECENT
// window of raw samples for bucketing. The previous `orderBy: asc`
// + `take: limit` sliced the OLDEST page of rows before the
// day-bucketing loop ran below — on an account with more than
// `limit` cumulative samples in its history, the buckets were built
// entirely from year-old rows and the recent days silently never
// appeared. Mirrors the workouts-list ordering fix (05f80ad37):
// re-establish "most recent" as the page the caller actually gets.
const rows = await prisma.measurement.findMany({
where,
orderBy: { measuredAt: "asc" },
orderBy: { measuredAt: "desc" },
// Per-sample row scan within the requested window — bounded by
// the existing `limit` cap (default 100, max 5000) so a wide
// multi-year window can still walk the whole range when the
@@ -230,9 +241,34 @@ export const GET = apiHandler(async (request: NextRequest) => {
source: true,
measuredAt: true,
notes: true,
deviceType: true,
},
});
// v1.29.6 — collapse overlapping sources to the ladder-canonical
// reading per day BEFORE summing. Without this, a day with both an
// Apple Health AND a Withings write for the same cumulative metric
// (e.g. steps) summed BOTH sources into one inflated total — a day
// the dashboard tile (which reads through the source-collapsed
// rollup, `collapseRollupRowsBySource` in
// `src/lib/rollups/measurement-read.ts`) showed correctly. Uses the
// same `metricKeyForType` ladder lookup the rollup collapse uses, so
// this list and the tile agree on which source wins. A type with no
// ladder (e.g. TIME_IN_DAYLIGHT, FALL_COUNT) has nothing to collapse
// against — every row passes through unchanged, matching the
// pass-through fallback used at the other `pickCanonicalSourceRows`
// call sites.
const metricKey = metricKeyForType(type as MeasurementType);
const priorityJson = await loadUserSourcePriority(user.id);
const canonicalRows = metricKey
? pickCanonicalSourceRows(
rows.map((r) => ({ ...r, type: r.type as MeasurementType })),
metricKey,
priorityJson,
(d) => dayKeyForUserTz(d, tz),
).canonicalRows
: rows;
// Group by the user's calendar day, sum values, keep a representative
// unit/source from the bucket's first row so the rendered row carries
// a sensible label even when the bucket mixes sources.
@@ -245,7 +281,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
source: string;
};
const buckets = new Map<string, Bucket>();
for (const row of rows) {
for (const row of canonicalRows) {
const key = dayKeyForUserTz(row.measuredAt, tz);
const slot = buckets.get(key);
if (!slot) {
@@ -264,6 +300,23 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
}
// v1.30.3 (QA F6) — the read above is DESC + take(limit), so once a
// dense account's 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, and its bucket's SUM understates the real
// day total with no marker. Flag exactly that one bucket — the day
// containing the cutoff row (the minimum dayKey among the buckets
// actually built) — `partial: true` only when the cap was truly hit;
// a read that came in under the cap covers its whole window and no
// day is a truncation artifact.
const cappedRead = rows.length === limit;
let oldestDayKey: string | null = null;
if (cappedRead) {
for (const key of buckets.keys()) {
if (oldestDayKey === null || key < oldestDayKey) oldestDayKey = key;
}
}
const measurements = Array.from(buckets.values())
.map((b) => ({
// Synthetic id so the list-row key stays stable across pages
@@ -280,6 +333,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
notes: null,
dayKey: b.dayKey,
sampleCount: b.sampleCount,
...(b.dayKey === oldestDayKey ? { partial: true } : {}),
}))
.sort((a, b) => {
const cmp = a.dayKey < b.dayKey ? -1 : a.dayKey > b.dayKey ? 1 : 0;
@@ -293,6 +347,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
type,
groupBy: "day",
rowsScanned: rows.length,
droppedDuplicates: rows.length - canonicalRows.length,
// QA F6 — surfaces when the oldest bucket in this page is a
// partial-day sum (the read hit the cap).
capped: cappedRead,
},
});
return apiSuccess({
@@ -302,6 +360,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
limit,
offset: 0,
groupBy: "day",
droppedDuplicates: rows.length - canonicalRows.length,
},
});
}
@@ -783,7 +842,9 @@ async function postMeasurement(request: NextRequest) {
// v1.18.1 — eventful Vorsorge satisfaction. Resolve the user's
// reminders against the just-landed readings now. Fire-and-forget.
void enqueueReminderSatisfy(user.id).catch(() => {});
fireAndForget(enqueueReminderSatisfy(user.id), {
action: "reminder.satisfy.enqueue",
});
// v1.18.6 — absolute clinical safety-floor check on the just-written
// readings (confirm-gated, module-gated, never diagnoses). The combined
@@ -791,19 +852,22 @@ async function postMeasurement(request: NextRequest) {
// The transient per-entry `symptomsPresent` flag (ORed across the batch)
// lifts a confirmed breach to the symptom-coupled emergency copy.
// Fire-and-forget — a notification failure never fails the write.
void runSafetyFloorCheck({
userId: user.id,
written: results.map((r) => ({
type: r.type,
value: r.value,
measuredAt: r.measuredAt,
glucoseContext: r.glucoseContext,
})),
symptomsPresent: parsed.data.measurements.some(
(m) => m.symptomsPresent === true,
),
timezone: user.timezone ?? undefined,
}).catch(() => {});
fireAndForget(
runSafetyFloorCheck({
userId: user.id,
written: results.map((r) => ({
type: r.type,
value: r.value,
measuredAt: r.measuredAt,
glucoseContext: r.glucoseContext,
})),
symptomsPresent: parsed.data.measurements.some(
(m) => m.symptomsPresent === true,
),
timezone: user.timezone ?? undefined,
}),
{ action: "measurement.safety_floor.check" },
);
// v1.5.0 — refresh the persistent rollup table for every distinct
// (type, day) the batch touched so the next analytics / coach read
@@ -930,7 +994,9 @@ async function postMeasurement(request: NextRequest) {
// v1.18.1 — eventful Vorsorge satisfaction. Resolve the user's reminders
// against the just-landed reading now. Fire-and-forget.
void enqueueReminderSatisfy(user.id).catch(() => {});
fireAndForget(enqueueReminderSatisfy(user.id), {
action: "reminder.satisfy.enqueue",
});
// v1.18.6 — absolute clinical safety-floor check (confirm-gated,
// module-gated, never diagnoses). A single-entry POST carries only one arm
@@ -938,19 +1004,22 @@ async function postMeasurement(request: NextRequest) {
// batch path; a lone glucose reading is evaluated here. The transient
// `symptomsPresent` flag lifts a confirmed breach to the emergency copy.
// Fire-and-forget — a notification failure never fails the write.
void runSafetyFloorCheck({
userId: user.id,
written: [
{
type: measurement.type,
value: measurement.value,
measuredAt: measurement.measuredAt,
glucoseContext: measurement.glucoseContext,
},
],
symptomsPresent,
timezone: user.timezone ?? undefined,
}).catch(() => {});
fireAndForget(
runSafetyFloorCheck({
userId: user.id,
written: [
{
type: measurement.type,
value: measurement.value,
measuredAt: measurement.measuredAt,
glucoseContext: measurement.glucoseContext,
},
],
symptomsPresent,
timezone: user.timezone ?? undefined,
}),
{ action: "measurement.safety_floor.check" },
);
// v1.5.0 — refresh the persistent rollup row for the affected
// (type, day) tuple. Runs inline so the next read of the
@@ -3,6 +3,10 @@
* /api/medications/[id]/intake/import. Preserves the
* `medication.intake.import.invalid_format` errorCode meta so the CSV
* import client UI can branch on the prefix semantics.
*
* v1.29 perf fix — the per-row `findUnique` duplicate probe was replaced by
* one indexed `findMany` existence read over every row's idempotencyKey
* before the write loop. `create` + `consumeForIntake` stay per-row.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
@@ -13,7 +17,7 @@ vi.mock("@/lib/db", () => ({
findUnique: vi.fn(),
},
medicationIntakeEvent: {
findUnique: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
},
auditLog: { create: vi.fn() },
@@ -75,6 +79,12 @@ function postReq(body: unknown): NextRequest {
const ROUTE_CTX = { params: Promise.resolve({ id: "m1" }) };
/** Mirrors the route's own dedup-key derivation for a zaehler-less row. */
function keyFor(medId: string, datum: string, uhrzeit: string): string {
const takenAt = new Date(`${datum}T${uhrzeit}`);
return `import-${medId}-${takenAt.getTime()}`;
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
@@ -172,10 +182,9 @@ describe("POST /api/medications/[id]/intake/import — 422 multi-issue (v1.4.43
describe("POST /api/medications/[id]/intake/import — inventory consumption", () => {
beforeEach(() => {
// Fresh imports: no pre-existing dedup row, and create echoes an id.
vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValue(
null as never,
);
// Fresh imports: the batched existence read finds no prior rows, and
// create echoes an id.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([]);
let n = 0;
vi.mocked(prisma.medicationIntakeEvent.create).mockImplementation(
(async () => ({ id: `evt-${++n}`, takenAt: new Date() })) as never,
@@ -200,16 +209,24 @@ describe("POST /api/medications/[id]/intake/import — inventory consumption", (
expect(call?.userId).toBe("user-1");
expect(call?.eventId).toBe("evt-1");
// The three-row import reads existence in ONE indexed query, not one
// findUnique probe per row.
expect(prisma.medicationIntakeEvent.findMany).toHaveBeenCalledTimes(1);
const findManyArg = vi.mocked(prisma.medicationIntakeEvent.findMany).mock
.calls[0]?.[0] as { where: { idempotencyKey: { in: string[] } } };
expect(findManyArg.where.idempotencyKey.in).toHaveLength(3);
// (#22) — the whole import queues exactly ONE cross-device sync
// fan-out, not one per imported row.
expect(queueMedicationIntakeSync).toHaveBeenCalledTimes(1);
});
it("does not consume for duplicate (already-imported) rows", async () => {
// First entry is a duplicate (existing dedup row), second is fresh.
vi.mocked(prisma.medicationIntakeEvent.findUnique)
.mockResolvedValueOnce({ id: "existing" } as never)
.mockResolvedValueOnce(null as never);
// First entry's key is already present — the batched existence read
// reports it, the second entry's key is absent (fresh).
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{ idempotencyKey: keyFor("m1", "2026-01-01", "07:00:00") },
] as never);
const res = await POST(
postReq([
{ datum: "2026-01-01", uhrzeit: "07:00:00" },
@@ -99,6 +99,20 @@ export const POST = apiHandler(
// so the rollup pass fires once per day rather than once per row.
const touchedDays = new Set<string>();
// Perf — a CSV import can carry up to 1000 rows; the pre-fix loop ran a
// `findUnique` duplicate probe before every single `create`, up to 2000
// sequential round trips. Resolve `takenAt` + the dedup key for every
// row first, then read every key's existence in ONE indexed query
// (`idempotencyKey` carries its own unique index) before the write
// loop. `create` + `consumeForIntake` (inventory decrement) stay
// per-row — each has a real side effect keyed off the freshly-created
// event's id, so they aren't safely batchable without a deeper change
// to the inventory-consumption path.
interface PreparedIntake {
takenAt: Date;
idempotencyKey: string;
}
const prepared: PreparedIntake[] = [];
for (const entry of entries) {
// Parse as local datetime first; if invalid, fall back to CET offset.
let takenAt = new Date(`${entry.datum}T${entry.uhrzeit}`);
@@ -115,10 +129,26 @@ export const POST = apiHandler(
? `import-${id}-${String(entry.zaehler)}`
: `import-${id}-${takenAt.getTime()}`;
const existing = await prisma.medicationIntakeEvent.findUnique({
where: { idempotencyKey },
});
if (existing) {
prepared.push({ takenAt, idempotencyKey });
}
const existingRows =
prepared.length > 0
? await prisma.medicationIntakeEvent.findMany({
where: {
idempotencyKey: { in: prepared.map((p) => p.idempotencyKey) },
},
select: { idempotencyKey: true },
})
: [];
const existingKeys = new Set(
existingRows
.map((r) => r.idempotencyKey)
.filter((k): k is string => k !== null),
);
for (const { takenAt, idempotencyKey } of prepared) {
if (existingKeys.has(idempotencyKey)) {
skippedDuplicates++;
continue;
}
@@ -1,6 +1,14 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/mood-entries/bulk.
* Preserves the `mood.bulk.invalid` errorCode meta passthrough.
*
* v1.29 perf fix — the per-entry `findUnique` probe was replaced by one
* batched `findMany` existence read (a single indexed OR query); every
* entry still resolves its own "inserted" / "duplicate" state through the
* exact key its dedup contract uses (externalId when carried, else the
* legacy date+moodLoggedAt key). The per-entry `upsert` + tombstone check +
* tag links + reverse push are unchanged. Tests below drive `findMany`
* instead of `findUnique`.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
@@ -8,7 +16,7 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
moodEntry: {
findUnique: vi.fn(),
findMany: vi.fn(),
upsert: vi.fn(),
},
auditLog: { create: vi.fn() },
@@ -84,6 +92,29 @@ function postReq(body: unknown): NextRequest {
});
}
/**
* Shape the bulk pre-read's `findMany` select always returns. Every mocked
* "existing row" needs every field the route registers into its two lookup
* maps, even when a test only cares about one of them.
*/
function existingRow(overrides: {
id: string;
deletedAt?: Date | null;
source?: string;
externalId?: string | null;
date?: string;
moodLoggedAt?: Date;
}) {
return {
deletedAt: null,
source: "MANUAL",
externalId: null,
date: "2026-05-16",
moodLoggedAt: new Date("2026-05-16T08:00:00.000Z"),
...overrides,
};
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
@@ -190,7 +221,7 @@ describe("POST /api/mood-entries/bulk — 422 multi-issue (v1.4.43 W6)", () => {
describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => {
beforeEach(() => {
vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([]);
vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({
id: "entry-1",
} as never);
@@ -272,11 +303,7 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => {
);
});
it("dedups on (userId, source, externalId) when an entry carries externalId", async () => {
vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({
id: "entry-ext",
} as never);
it("reads existence once via a batched findMany, keyed on (source, externalId) when the entry carries one", async () => {
const res = await POST(
postReq({
entries: [
@@ -293,11 +320,16 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => {
data: { inserted: number; entries: Array<{ externalId?: string }> };
};
expect(json.data.inserted).toBe(1);
// The probe + upsert both key on the externalId compound, not the
// legacy wall-clock tuple.
const probeWhere = vi.mocked(prisma.moodEntry.findUnique).mock.calls[0]?.[0]
?.where as Record<string, unknown>;
expect(probeWhere).toHaveProperty("userId_source_externalId");
expect(prisma.moodEntry.findMany).toHaveBeenCalledTimes(1);
const findManyArg = vi.mocked(prisma.moodEntry.findMany).mock
.calls[0]?.[0] as {
where: { userId: string; OR: Array<Record<string, unknown>> };
};
expect(findManyArg.where.OR).toContainEqual({
source: "MANUAL",
externalId: "ios-uuid-1",
});
// The per-entry upsert still keys on the compound unique.
const upsertArg = vi.mocked(prisma.moodEntry.upsert).mock.calls[0]?.[0] as {
where: Record<string, unknown>;
create: { externalId: string | null; source: string };
@@ -308,11 +340,7 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => {
expect(json.data.entries[0].externalId).toBe("ios-uuid-1");
});
it("uses the legacy wall-clock key and omits externalId when none is sent", async () => {
vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({
id: "entry-legacy",
} as never);
it("keys the batched existence read on (date, moodLoggedAt) and omits externalId when none is sent", async () => {
const res = await POST(
postReq({
entries: [{ mood: "OKAY", moodLoggedAt: "2026-05-16T08:00:00.000Z" }],
@@ -322,18 +350,31 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => {
const json = (await res.json()) as {
data: { entries: Array<{ externalId?: string }> };
};
const probeWhere = vi.mocked(prisma.moodEntry.findUnique).mock.calls[0]?.[0]
?.where as Record<string, unknown>;
expect(probeWhere).toHaveProperty("userId_date_moodLoggedAt");
const findManyArg = vi.mocked(prisma.moodEntry.findMany).mock
.calls[0]?.[0] as {
where: { OR: Array<Record<string, unknown>> };
};
expect(findManyArg.where.OR).toContainEqual({
date: "2026-05-16",
moodLoggedAt: new Date("2026-05-16T08:00:00.000Z"),
});
const upsertArg = vi.mocked(prisma.moodEntry.upsert).mock.calls[0]?.[0] as {
where: Record<string, unknown>;
};
expect(upsertArg.where).toHaveProperty("userId_date_moodLoggedAt");
expect(json.data.entries[0].externalId).toBeUndefined();
});
it("reports a re-posted externalId entry as a duplicate (idempotent)", async () => {
// Probe finds the row → the response classifies it duplicate, not
// inserted, and the upsert update branch refreshes it in place.
vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue({
id: "entry-ext",
} as never);
// The batched pre-read finds the row → the response classifies it
// duplicate, not inserted, and the upsert update branch refreshes it.
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
existingRow({
id: "entry-ext",
source: "MANUAL",
externalId: "ios-uuid-1",
}),
] as never);
vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({
id: "entry-ext",
} as never);
@@ -416,13 +457,17 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => {
describe("POST /api/mood-entries/bulk — tombstone suppression", () => {
it("keeps a tombstoned match deleted: true no-op reported as duplicate", async () => {
// A soft-deleted row under the probe key: the sync feed already
// A soft-deleted row under the batched pre-read: the sync feed already
// surfaced the deletion to paired clients, so a stale offline re-post
// must NOT resurrect it — and must not churn the hidden row either.
vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue({
id: "entry-tomb",
deletedAt: new Date("2026-05-01T00:00:00.000Z"),
} as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
existingRow({
id: "entry-tomb",
deletedAt: new Date("2026-05-01T00:00:00.000Z"),
source: "MANUAL",
externalId: "ios-uuid-tomb",
}),
] as never);
const res = await POST(
postReq({
entries: [
@@ -455,10 +500,14 @@ describe("POST /api/mood-entries/bulk — tombstone suppression", () => {
});
it("still refreshes a LIVE match in place (duplicate + upsert runs)", async () => {
vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue({
id: "entry-live",
deletedAt: null,
} as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
existingRow({
id: "entry-live",
deletedAt: null,
source: "MANUAL",
externalId: "ios-uuid-live",
}),
] as never);
vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({
id: "entry-live",
} as never);
+60 -9
View File
@@ -189,6 +189,57 @@ async function postBulk(request: NextRequest): Promise<Response> {
let duplicates = 0;
const skipped: Array<{ index: number; reason: string }> = [];
// Perf — a single indexed existence read replaces the per-entry
// `findUnique` probe (up to 500 sequential round trips pre-fix). Every
// entry still resolves through exactly the key its own dedup contract
// uses (externalId when carried, else the legacy date+moodLoggedAt key),
// so lookups below are byte-identical to the per-entry probe — only the
// read is now batched. The per-entry `upsert` + tombstone check + tag
// links + reverse push all stay unchanged; batching those further would
// risk the correctness-sensitive per-row side effects for a write path
// that is usually small (a normal sync posts the current + previous
// local day, not a full historical backfill).
interface ExistingMoodRow {
id: string;
deletedAt: Date | null;
}
const orClauses: Array<
| { source: string; externalId: string }
| { date: string; moodLoggedAt: Date }
> = entries.map((entry) =>
entry.externalId
? { source: entry.source, externalId: entry.externalId }
: {
date: moodDateKey(entry.moodLoggedAt, tz),
moodLoggedAt: entry.moodLoggedAt,
},
);
const existingRows =
orClauses.length > 0
? await prisma.moodEntry.findMany({
where: { userId: user.id, OR: orClauses },
select: {
id: true,
deletedAt: true,
source: true,
externalId: true,
date: true,
moodLoggedAt: true,
},
})
: [];
const existingByExternalKey = new Map<string, ExistingMoodRow>();
const existingByDateKey = new Map<string, ExistingMoodRow>();
for (const row of existingRows) {
if (row.externalId !== null) {
existingByExternalKey.set(`${row.source}::${row.externalId}`, row);
}
existingByDateKey.set(
`${row.date}::${row.moodLoggedAt.toISOString()}`,
row,
);
}
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const date = moodDateKey(entry.moodLoggedAt, tz);
@@ -220,15 +271,15 @@ async function postBulk(request: NextRequest): Promise<Response> {
},
};
// Probe-then-upsert so the response reliably distinguishes
// "inserted" from "duplicate". Two round-trips per entry is
// acceptable given the 500-entry cap; a more cache-friendly
// shape (batched probe) is a v1.4.31 optimisation if the cap
// grows.
const existing = await prisma.moodEntry.findUnique({
where: probeWhere,
select: { id: true, deletedAt: true },
});
// The response reliably distinguishes "inserted" from "duplicate"
// via the batched pre-read above (keyed exactly like `probeWhere`),
// so the per-entry probe here is now a Map lookup, not a round trip.
const existing =
(entry.externalId
? existingByExternalKey.get(`${resolvedSource}::${entry.externalId}`)
: existingByDateKey.get(
`${date}::${entry.moodLoggedAt.toISOString()}`,
)) ?? null;
// Tombstone suppression: a soft-deleted match stays deleted. The
// user-facing DELETE route flips `deletedAt` so the `/api/sync/changes`
@@ -132,6 +132,32 @@ describe("GET /api/nutrients", () => {
]);
});
it("counts a multi-source day once (DATAINT M3 regression)", async () => {
// v1.29 migration 0249 lets a day carry one row per source. A non-latest
// day logged via both APPLE_HEALTH and MANUAL must still count as ONE
// day, not two.
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([
{ nutrient: "water", unit: "ml", day: "2026-07-13", amount: 900 },
{ nutrient: "water", unit: "ml", day: "2026-07-12", amount: 600 },
{ nutrient: "water", unit: "ml", day: "2026-07-12", amount: 400 },
{ nutrient: "water", unit: "ml", day: "2026-07-11", amount: 500 },
] as never);
const res = await GET(getReq("?days=7"));
const body = (await res.json()) as OverviewResponse;
const water = body.data.nutrients.find((n) => n.nutrient === "water");
expect(water).toEqual({
nutrient: "water",
unit: "ml",
latestDay: "2026-07-13",
latestAmount: 900,
// Three distinct days (13th, 12th, 11th) — the buggy version counted
// four, since the 12th's second source row didn't match the pinned
// `latestDay` (the 13th) and opened a phantom extra day.
daysWithData: 3,
});
});
it("scopes the query to the authenticated user and the window floor", async () => {
await GET(getReq("?days=14"));
expect(prisma.nutrientIntakeDay.findMany).toHaveBeenCalledWith(
@@ -3,8 +3,15 @@
*
* Pins the ingest posture: module gate first (403 `module.disabled`),
* per-entry skip guards (unit mismatch / plausibility / day key), the
* insert-vs-update statuses off the composite-PK probe, and the batch
* envelope errors (`too_large`, multi-issue `invalid`).
* insert-vs-update statuses off a single bulk existence probe, and the
* batch envelope errors (`too_large`, multi-issue `invalid`).
*
* v1.29 perf fix — the probe-then-upsert-per-entry pattern (up to 1000
* sequential round trips) was replaced by one indexed `findMany` existence
* read + a bulk `createMany` for new pairs + a `$transaction` of per-row
* `update` calls for existing pairs. `$transaction` is mocked to actually
* run the array of promises it's handed so the update path executes for
* real in these tests.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
@@ -12,9 +19,11 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
nutrientIntakeDay: {
findUnique: vi.fn(),
upsert: vi.fn(),
findMany: vi.fn(),
createMany: vi.fn(),
update: vi.fn(),
},
$transaction: vi.fn(),
},
}));
@@ -37,6 +46,9 @@ vi.mock("@/lib/idempotency", () => ({
fn(...args),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserDashboardSnapshot: vi.fn(),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
@@ -56,6 +68,7 @@ import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -93,8 +106,19 @@ beforeEach(() => {
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
vi.mocked(auditLog).mockResolvedValue(undefined as never);
vi.mocked(prisma.nutrientIntakeDay.findUnique).mockResolvedValue(null);
vi.mocked(prisma.nutrientIntakeDay.upsert).mockResolvedValue({} as never);
// No existing (day, nutrient) pairs by default — every valid entry
// takes the insert path unless a test overrides this.
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([]);
vi.mocked(prisma.nutrientIntakeDay.createMany).mockImplementation(
(async (args: { data: unknown[] }) => ({
count: args.data.length,
})) as never,
);
vi.mocked(prisma.nutrientIntakeDay.update).mockResolvedValue({} as never);
// Real Prisma resolves every promise in the `$transaction([...])` array;
// the mock does the same so the update path actually runs in these tests.
vi.mocked(prisma.$transaction).mockImplementation((async (ops: unknown[]) =>
Promise.all(ops)) as never);
});
describe("POST /api/nutrients/batch — module gate", () => {
@@ -122,7 +146,7 @@ describe("POST /api/nutrients/batch — module gate", () => {
expect(body.data).toBeNull();
expect(body.meta?.errorCode).toBe("module.disabled");
expect(body.meta?.module).toBe("nutrients");
expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled();
expect(prisma.nutrientIntakeDay.createMany).not.toHaveBeenCalled();
});
});
@@ -190,7 +214,8 @@ describe("POST /api/nutrients/batch — per-entry skip guards", () => {
reason: "unit_mismatch",
});
expect(body.data.skipped).toEqual([{ index: 0, reason: "unit_mismatch" }]);
expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled();
expect(prisma.nutrientIntakeDay.findMany).not.toHaveBeenCalled();
expect(prisma.nutrientIntakeDay.createMany).not.toHaveBeenCalled();
});
it("skips a value above the plausibility cap", async () => {
@@ -205,7 +230,7 @@ describe("POST /api/nutrients/batch — per-entry skip guards", () => {
const body = (await res.json()) as BatchResponse;
expect(body.data.entries[0].status).toBe("skipped");
expect(body.data.entries[0].reason).toBe("value_out_of_range");
expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled();
expect(prisma.nutrientIntakeDay.createMany).not.toHaveBeenCalled();
});
it("skips an impossible calendar day and a far-future day as day_invalid", async () => {
@@ -224,7 +249,7 @@ describe("POST /api/nutrients/batch — per-entry skip guards", () => {
"day_invalid",
"day_invalid",
]);
expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled();
expect(prisma.nutrientIntakeDay.createMany).not.toHaveBeenCalled();
});
it("a skipped entry never fails the batch — siblings still land", async () => {
@@ -240,12 +265,13 @@ describe("POST /api/nutrients/batch — per-entry skip guards", () => {
expect(body.data.processed).toBe(2);
expect(body.data.inserted).toBe(1);
expect(body.data.skipped).toHaveLength(1);
expect(prisma.nutrientIntakeDay.upsert).toHaveBeenCalledTimes(1);
// One bulk createMany call carries the single valid entry.
expect(prisma.nutrientIntakeDay.createMany).toHaveBeenCalledTimes(1);
});
});
describe("POST /api/nutrients/batch — upsert semantics", () => {
it("reports inserted for a fresh (day, nutrient) and writes the catalog's canonical unit", async () => {
describe("POST /api/nutrients/batch — write semantics", () => {
it("reports inserted for a fresh (day, nutrient), reads existence once, and writes the catalog's canonical unit", async () => {
const day = recentDay();
const res = await POST(
postReq({
@@ -265,49 +291,100 @@ describe("POST /api/nutrients/batch — upsert semantics", () => {
expect(body.data.updated).toBe(0);
expect(body.data.entries[0].status).toBe("inserted");
expect(prisma.nutrientIntakeDay.upsert).toHaveBeenCalledWith({
// Single indexed existence read, not a per-entry probe.
expect(prisma.nutrientIntakeDay.findMany).toHaveBeenCalledTimes(1);
expect(prisma.nutrientIntakeDay.findMany).toHaveBeenCalledWith({
where: {
userId_day_nutrient: {
userId: "user-1",
source: "APPLE_HEALTH",
OR: [{ day, nutrient: "vitamin_d" }],
},
select: { day: true, nutrient: true },
});
expect(prisma.nutrientIntakeDay.createMany).toHaveBeenCalledWith({
data: [
{
userId: "user-1",
day,
nutrient: "vitamin_d",
amount: 22.5,
unit: "ug",
source: "APPLE_HEALTH",
externalSourceVersion: "yazio-9.9",
},
},
create: {
userId: "user-1",
day,
nutrient: "vitamin_d",
amount: 22.5,
unit: "ug",
externalSourceVersion: "yazio-9.9",
},
update: {
amount: 22.5,
unit: "ug",
externalSourceVersion: "yazio-9.9",
},
],
skipDuplicates: true,
});
expect(prisma.nutrientIntakeDay.update).not.toHaveBeenCalled();
});
it("reports updated (never duplicate) when the composite key already exists", async () => {
vi.mocked(prisma.nutrientIntakeDay.findUnique).mockResolvedValue({
userId: "user-1",
} as never);
// v1.30.3 (QA F9) — a landed row must evict the dashboard snapshot so
// the water/nutrient tile reflects the new total on the very next read,
// matching the manual water POST's own posture (`nutrients/water/route.ts`).
it("evicts the dashboard snapshot when a row lands", async () => {
const day = recentDay();
const res = await POST(
postReq({
entries: [{ day, nutrient: "vitamin_d", unit: "ug", amount: 22.5 }],
}),
);
expect(res.status).toBe(200);
expect(invalidateUserDashboardSnapshot).toHaveBeenCalledTimes(1);
expect(invalidateUserDashboardSnapshot).toHaveBeenCalledWith("user-1");
});
it("does NOT evict the dashboard snapshot when nothing lands (every entry skipped)", async () => {
const res = await POST(
postReq({
entries: [
{ day: recentDay(), nutrient: "caffeine", unit: "mg", amount: 310 },
{
day: recentDay(),
nutrient: "vitamin_d",
unit: "mg", // wrong unit vs the catalog's canonical "ug" — skipped
amount: 22.5,
},
],
}),
);
const body = (await res.json()) as BatchResponse;
expect(body.data.inserted).toBe(0);
expect(body.data.updated).toBe(0);
expect(invalidateUserDashboardSnapshot).not.toHaveBeenCalled();
});
it("reports updated (never duplicate) when the composite key already exists", async () => {
const day = recentDay();
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([
{ day, nutrient: "caffeine" },
] as never);
const res = await POST(
postReq({
entries: [{ day, nutrient: "caffeine", unit: "mg", amount: 310 }],
}),
);
const body = (await res.json()) as BatchResponse;
expect(body.data.inserted).toBe(0);
expect(body.data.updated).toBe(1);
expect(body.data.entries[0].status).toBe("updated");
expect(
body.data.entries.some((e) => e.status === ("duplicate" as string)),
).toBe(false);
expect(prisma.nutrientIntakeDay.createMany).not.toHaveBeenCalled();
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(prisma.nutrientIntakeDay.update).toHaveBeenCalledWith({
where: {
userId_day_nutrient_source: {
userId: "user-1",
day,
nutrient: "caffeine",
source: "APPLE_HEALTH",
},
},
data: { amount: 310, unit: "mg", externalSourceVersion: null },
});
});
it("writes the audit-ledger breadcrumb only when at least one row landed", async () => {
@@ -333,19 +410,24 @@ describe("POST /api/nutrients/batch — upsert semantics", () => {
);
});
it("a thrown upsert marks only that entry skipped upsert_failed without leaking the raw error", async () => {
it("a failed bulk insert marks only the insert group skipped, without touching the update group", async () => {
// The exception message must never reach the client — the per-entry
// `reason` is a closed set. A raw-message echo was a v1.28.48 fix.
// `reason` is a closed set.
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.mocked(prisma.nutrientIntakeDay.upsert)
.mockRejectedValueOnce(new Error("boom: sensitive db detail"))
.mockResolvedValueOnce({} as never);
const day = recentDay();
// `zinc` is new (insert path); `iron` already exists (update path).
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([
{ day, nutrient: "iron" },
] as never);
vi.mocked(prisma.nutrientIntakeDay.createMany).mockRejectedValue(
new Error("boom: sensitive db detail"),
);
const res = await POST(
postReq({
entries: [
{ day: recentDay(), nutrient: "zinc", unit: "mg", amount: 10 },
{ day: recentDay(), nutrient: "iron", unit: "mg", amount: 12 },
{ day, nutrient: "zinc", unit: "mg", amount: 10 },
{ day, nutrient: "iron", unit: "mg", amount: 12 },
],
}),
);
@@ -356,8 +438,29 @@ describe("POST /api/nutrients/batch — upsert semantics", () => {
// The raw exception text is logged server-side only, never echoed.
expect(JSON.stringify(body)).not.toContain("sensitive db detail");
expect(spy).toHaveBeenCalled();
expect(body.data.entries[1].status).toBe("inserted");
expect(body.data.inserted).toBe(1);
// The independent update group still lands.
expect(body.data.entries[1].status).toBe("updated");
expect(body.data.updated).toBe(1);
spy.mockRestore();
});
it("a race that drops one createMany row still counts consistently and is observable", async () => {
vi.mocked(prisma.nutrientIntakeDay.createMany).mockResolvedValue({
count: 1,
} as never);
const res = await POST(
postReq({
entries: [
{ day: recentDay(), nutrient: "zinc", unit: "mg", amount: 10 },
{ day: recentDay(2), nutrient: "iron", unit: "mg", amount: 12 },
],
}),
);
const body = (await res.json()) as BatchResponse;
// Both entries are still labelled inserted (count-only trade-off,
// documented at the call site) — the response never fails.
expect(body.data.inserted).toBe(1);
expect(res.status).toBe(200);
});
});
+159 -57
View File
@@ -10,10 +10,14 @@
* Upsert key = the composite PK (userId, day, nutrient). Re-post
* replaces amount / unit / externalSourceVersion — last-writer-wins,
* the day-total contract; per-entry status is `updated` then, never
* `duplicate`. Per-entry failures come back `skipped` with a reason
* (`unit_mismatch` | `value_out_of_range` | `day_invalid` |
* `upsert_failed`), never a batch failure — the measurements-batch
* posture.
* `duplicate`. Validation failures (`unit_mismatch` | `value_out_of_range`
* | `day_invalid`) are always isolated per entry. The DB write itself runs
* as two batched groups — a single indexed existence read splits valid
* entries into a bulk `createMany` (new pairs) and a `$transaction` of
* per-row updates (existing pairs), the measurements-batch shape. A write
* failure marks its WHOLE group `skipped upsert_failed` rather than
* isolating the one entry that would have caused it (unavoidable once the
* write is batched) — the request itself never fails.
*
* Module gate FIRST: the opt-in `nutrients` module (default off)
* refuses ingest with the 403 `module.disabled` envelope, so a phone
@@ -36,6 +40,7 @@ import {
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog";
import {
MAX_NUTRIENT_ENTRIES_PER_BATCH,
@@ -130,11 +135,21 @@ async function postBatch(request: NextRequest): Promise<Response> {
const { entries } = parsed.data;
const dayCeiling = maxAcceptableDay(new Date());
const results: EntryResult[] = [];
const results: EntryResult[] = new Array(entries.length);
const skipped: Array<{ index: number; reason: string }> = [];
let inserted = 0;
let updated = 0;
interface ValidEntry {
index: number;
day: string;
nutrient: (typeof entries)[number]["nutrient"];
amount: number;
unit: string;
externalSourceVersion: string | null;
}
const validEntries: ValidEntry[] = [];
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const definition = NUTRIENT_CATALOG[entry.nutrient];
@@ -144,78 +159,158 @@ async function postBatch(request: NextRequest): Promise<Response> {
// unit skips the entry rather than converting.
if (entry.unit !== definition.unit) {
skipped.push({ index: i, reason: "unit_mismatch" });
results.push({ index: i, status: "skipped", reason: "unit_mismatch" });
results[i] = { index: i, status: "skipped", reason: "unit_mismatch" };
continue;
}
if (entry.amount > definition.plausibleDailyMax) {
skipped.push({ index: i, reason: "value_out_of_range" });
results.push({
results[i] = {
index: i,
status: "skipped",
reason: "value_out_of_range",
});
};
continue;
}
if (!isRealCalendarDay(entry.day) || entry.day > dayCeiling) {
skipped.push({ index: i, reason: "day_invalid" });
results.push({ index: i, status: "skipped", reason: "day_invalid" });
results[i] = { index: i, status: "skipped", reason: "day_invalid" };
continue;
}
try {
const key = {
userId_day_nutrient: {
userId: user.id,
day: entry.day,
nutrient: entry.nutrient,
},
};
validEntries.push({
index: i,
day: entry.day,
nutrient: entry.nutrient,
amount: entry.amount,
unit: definition.unit,
externalSourceVersion: entry.externalSourceVersion ?? null,
});
}
// Probe-then-upsert so the response reliably distinguishes
// `inserted` from `updated` (the mood-bulk pattern; two round
// trips per entry is fine under the 500-entry cap).
const existing = await prisma.nutrientIntakeDay.findUnique({
where: key,
select: { userId: true },
});
// v1.29 — `source` joined the composite PK (migration 0249) so a manual
// water entry (source=MANUAL, written by `POST /api/nutrients/water`)
// coexists with the Apple-synced day total instead of one clobbering the
// other on the next sync. The batch route always owns the APPLE_HEALTH
// row.
//
// Perf (measurements/batch shape): ONE indexed existence read replaces the
// per-entry probe-then-upsert (up to 1000 sequential round trips pre-fix).
// Genuinely new (day, nutrient) pairs land via a single chunked
// `createMany`; pairs that already exist update via a batched
// `$transaction` of per-row `update` calls (values differ per row, so a
// single bulk statement isn't available without raw SQL — this still
// collapses the per-entry PROBE, and pipelines the writes through one
// transaction instead of N standalone round trips).
if (validEntries.length > 0) {
const existing = await prisma.nutrientIntakeDay.findMany({
where: {
userId: user.id,
source: "APPLE_HEALTH",
OR: validEntries.map((e) => ({ day: e.day, nutrient: e.nutrient })),
},
select: { day: true, nutrient: true },
});
const existingKeys = new Set(
existing.map((r) => `${r.day}::${r.nutrient}`),
);
await prisma.nutrientIntakeDay.upsert({
where: key,
create: {
userId: user.id,
day: entry.day,
nutrient: entry.nutrient,
amount: entry.amount,
unit: definition.unit,
externalSourceVersion: entry.externalSourceVersion ?? null,
},
update: {
// Last-writer-wins on the day total: iOS re-posts the current
// and previous local day on every sync as the totals grow.
amount: entry.amount,
unit: definition.unit,
externalSourceVersion: entry.externalSourceVersion ?? null,
},
});
const toInsert: ValidEntry[] = [];
const toUpdate: ValidEntry[] = [];
for (const e of validEntries) {
(existingKeys.has(`${e.day}::${e.nutrient}`) ? toUpdate : toInsert).push(
e,
);
}
if (existing) {
updated += 1;
results.push({ index: i, status: "updated" });
} else {
inserted += 1;
results.push({ index: i, status: "inserted" });
if (toInsert.length > 0) {
try {
const result = await prisma.nutrientIntakeDay.createMany({
data: toInsert.map((e) => ({
userId: user.id,
day: e.day,
nutrient: e.nutrient,
amount: e.amount,
unit: e.unit,
source: "APPLE_HEALTH" as const,
externalSourceVersion: e.externalSourceVersion,
})),
skipDuplicates: true,
});
inserted += result.count;
for (const e of toInsert) {
results[e.index] = { index: e.index, status: "inserted" };
}
// Rare race: a concurrent request claimed one of these keys between
// our existence probe and this createMany, so skipDuplicates
// silently dropped that row's write here — measurements/batch
// accepts the same count-only trade-off for its own createMany race.
// The DB itself stays consistent (the racing writer's row is
// stored); only this response's per-row label may be stale for
// that one entry.
if (result.count < toInsert.length) {
annotate({
action: { name: "nutrient.batch.insert_race" },
meta: { attempted: toInsert.length, stored: result.count },
});
}
} catch (err: unknown) {
console.error("[nutrient-batch] bulk insert failed", err);
for (const e of toInsert) {
skipped.push({ index: e.index, reason: "upsert_failed" });
results[e.index] = {
index: e.index,
status: "skipped",
reason: "upsert_failed",
};
}
}
}
if (toUpdate.length > 0) {
try {
await prisma.$transaction(
toUpdate.map((e) =>
prisma.nutrientIntakeDay.update({
where: {
userId_day_nutrient_source: {
userId: user.id,
day: e.day,
nutrient: e.nutrient,
source: "APPLE_HEALTH",
},
},
data: {
// Last-writer-wins on the day total: iOS re-posts the
// current and previous local day on every sync as the
// totals grow.
amount: e.amount,
unit: e.unit,
externalSourceVersion: e.externalSourceVersion,
},
}),
),
);
updated += toUpdate.length;
for (const e of toUpdate) {
results[e.index] = { index: e.index, status: "updated" };
}
} catch (err: unknown) {
// The client-facing `reason` is a closed set (see the file header) —
// never echo raw exception text into it. Log the real error
// server-side only (SWC keeps `console.error` in prod) and return
// the fixed `upsert_failed` member.
console.error("[nutrient-batch] bulk update failed", err);
for (const e of toUpdate) {
skipped.push({ index: e.index, reason: "upsert_failed" });
results[e.index] = {
index: e.index,
status: "skipped",
reason: "upsert_failed",
};
}
}
} catch (err: unknown) {
// The client-facing `reason` is a closed set (see the file header) —
// never echo raw exception text into it. Log the real error
// server-side only (SWC keeps `console.error` in prod) and return the
// fixed `upsert_failed` member.
console.error("[nutrient-batch] upsert failed", err);
const reason = "upsert_failed";
skipped.push({ index: i, reason });
results.push({ index: i, status: "skipped", reason });
}
}
@@ -230,6 +325,13 @@ async function postBatch(request: NextRequest): Promise<Response> {
skipped: skipped.length,
},
});
// v1.30.3 (QA F9) — a landed row changes the dashboard's water/nutrient
// tile; without this the snapshot kept serving the pre-sync figure
// until the analytics fresh TTL lapsed (~3 min). Mirrors the manual
// water POST's own hard-evict (`nutrients/water/route.ts`) so an
// Apple-Health-synced total reflects on the very next dashboard read,
// same as a manually logged one.
invalidateUserDashboardSnapshot(user.id);
}
annotate({
@@ -0,0 +1,171 @@
/**
* v1.29 — `GET /api/nutrients/daily` contract tests.
*
* Pins the per-day-bucketed series feeding the hydration/caffeine
* charts: module gate, query validation, the dense (zero-filled) day
* range, summing across sources within a day, and the sex-resolved
* (or omitted) EFSA reference.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
nutrientIntakeDay: {
findMany: vi.fn(),
},
},
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { apiError } from "@/lib/api-response";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
function sessionFor(gender: string | null) {
return {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "tester",
role: "USER" as const,
timezone: "Europe/Berlin",
gender,
},
};
}
function getReq(query: string): NextRequest {
return new NextRequest(`http://localhost/api/nutrients/daily${query}`);
}
interface DailyResponse {
data: {
nutrient: string;
unit: string;
windowDays: number;
days: Array<{ day: string; amount: number }>;
reference: { kind: string; direction: string; value: number } | null;
};
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(sessionFor("FEMALE") as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([] as never);
});
describe("GET /api/nutrients/daily", () => {
it("refuses with the 403 module.disabled envelope when the module is off", async () => {
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: false,
response: apiError('Module "nutrients" is not enabled', 403, {
errorCode: "module.disabled",
module: "nutrients",
}),
} as never);
const res = await GET(getReq("?nutrient=water&days=30"));
expect(res.status).toBe(403);
expect(prisma.nutrientIntakeDay.findMany).not.toHaveBeenCalled();
});
it("rejects an unknown nutrient code with 422", async () => {
const res = await GET(getReq("?nutrient=not-a-code&days=30"));
expect(res.status).toBe(422);
});
it("rejects an out-of-range days value with 422", async () => {
for (const bad of ["0", "91", "abc"]) {
const res = await GET(getReq(`?nutrient=water&days=${bad}`));
expect(res.status, `days=${bad}`).toBe(422);
}
});
it("defaults to a 30-day dense window with zero-filled days for a fresh account", async () => {
const res = await GET(getReq("?nutrient=water"));
expect(res.status).toBe(200);
const body = (await res.json()) as DailyResponse;
expect(body.data.windowDays).toBe(30);
expect(body.data.unit).toBe("ml");
expect(body.data.days).toHaveLength(30);
expect(body.data.days.every((d) => d.amount === 0)).toBe(true);
// Ascending, contiguous day keys, ending on "today".
for (let i = 1; i < body.data.days.length; i++) {
const prev = new Date(`${body.data.days[i - 1].day}T00:00:00.000Z`);
const curr = new Date(`${body.data.days[i].day}T00:00:00.000Z`);
expect(curr.getTime() - prev.getTime()).toBe(24 * 60 * 60 * 1000);
}
});
it("sums amounts across sources within the same day before bucketing", async () => {
// Relative day-keys — a fixed literal slides out of the window as the
// calendar advances (a `?days=3` window only ever holds today..today-2).
const utcDay = (offset: number) =>
new Date(Date.now() - offset * 86_400_000).toISOString().slice(0, 10);
const today = utcDay(0);
const yesterday = utcDay(1);
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([
{ day: today, amount: 900 },
{ day: today, amount: 600 },
{ day: yesterday, amount: 400 },
] as never);
const res = await GET(getReq("?nutrient=water&days=3"));
const body = (await res.json()) as DailyResponse;
const byDay = new Map(body.data.days.map((d) => [d.day, d.amount]));
expect(byDay.get(today)).toBe(1500);
expect(byDay.get(yesterday)).toBe(400);
});
it("resolves the sex-split reference against the caller's profile sex", async () => {
vi.mocked(getSession).mockResolvedValue(sessionFor("FEMALE") as never);
const res = await GET(getReq("?nutrient=water&days=7"));
const body = (await res.json()) as DailyResponse;
expect(body.data.reference).toEqual({
kind: "AI",
direction: "target",
value: 2000,
source: expect.stringContaining("EFSA"),
});
});
it("omits the reference when the profile has no sex on file — never guesses", async () => {
vi.mocked(getSession).mockResolvedValue(sessionFor(null) as never);
const res = await GET(getReq("?nutrient=water&days=7"));
const body = (await res.json()) as DailyResponse;
expect(body.data.reference).toBeNull();
});
it("scopes the query to the authenticated user and the requested nutrient", async () => {
await GET(getReq("?nutrient=caffeine&days=14"));
expect(prisma.nutrientIntakeDay.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
userId: "user-1",
nutrient: "caffeine",
day: { gte: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) },
}),
}),
);
});
});
+91
View File
@@ -0,0 +1,91 @@
/**
* `GET /api/nutrients/daily?nutrient=<code>&days=N` — per-day summed
* series for one nutrient, plus its resolved EFSA reference (v1.29).
*
* Feeds the `/insights/nutrients` hydration + caffeine charts: a dense
* day-bucketed series (one row per calendar day in the window, 0 for a
* day with no logged data) summed ACROSS sources — a day can carry an
* APPLE_HEALTH row and a MANUAL row since migration 0249, and the
* chart shows one honest bar per day. The window is anchored to the
* caller's own local "today" (`User.timezone`), not a UTC floor —
* unlike the coarser window on `GET /api/nutrients`, a chart with a
* one-day edge wobble reads as a visible bug.
*
* The reference resolves against the caller's profile sex (from the
* session's own `User` row — no extra query) and is `null` when the
* profile has no sex on file, matching the catalog's own contract:
* omit, never guess. Module-gated like every other nutrients route.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import {
NUTRIENT_CATALOG,
resolveNutrientReference,
} from "@/lib/nutrients/catalog";
import { nutrientDailyQuerySchema } from "@/lib/validations/nutrients";
import { DEFAULT_TIMEZONE, shiftDateKey, userDayKey } from "@/lib/tz/format";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "nutrients");
if (!gate.enabled) return gate.response;
const parsed = nutrientDailyQuerySchema.safeParse({
nutrient: request.nextUrl.searchParams.get("nutrient") ?? undefined,
days: request.nextUrl.searchParams.get("days") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "nutrient.daily.invalid",
});
}
const { nutrient, days } = parsed.data;
const definition = NUTRIENT_CATALOG[nutrient];
const userTz = user.timezone || DEFAULT_TIMEZONE;
const todayKey = userDayKey(new Date(), userTz);
const sinceKey = shiftDateKey(todayKey, -(days - 1));
const rows = await prisma.nutrientIntakeDay.findMany({
where: { userId: user.id, nutrient, day: { gte: sinceKey } },
select: { day: true, amount: true },
});
// Sum across sources within a day BEFORE bucketing — the same fold
// `GET /api/nutrients` applies (migration 0249 note there).
const sumByDay = new Map<string, number>();
for (const row of rows) {
sumByDay.set(row.day, (sumByDay.get(row.day) ?? 0) + row.amount);
}
const daySeries: Array<{ day: string; amount: number }> = [];
for (let i = 0; i < days; i++) {
const key = shiftDateKey(sinceKey, i);
daySeries.push({ day: key, amount: sumByDay.get(key) ?? 0 });
}
const sex =
user.gender === "MALE" || user.gender === "FEMALE" ? user.gender : null;
const reference = resolveNutrientReference(nutrient, sex);
annotate({
action: { name: "nutrient.daily.read" },
meta: { nutrient, window_days: days, row_count: rows.length },
});
return apiSuccess({
nutrient,
unit: definition.unit,
windowDays: days,
days: daySeries,
reference,
});
});
+36 -14
View File
@@ -17,6 +17,7 @@ import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { NUTRIENT_CODES, isNutrientCode } from "@/lib/nutrients/catalog";
import { nutrientOverviewQuerySchema } from "@/lib/validations/nutrients";
import { DEFAULT_TIMEZONE, shiftDateKey, userDayKey } from "@/lib/tz/format";
export const dynamic = "force-dynamic";
@@ -36,12 +37,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const { days } = parsed.data;
// Window floor as a UTC day key. The stored `day` is a local-timezone
// key, so the UTC floor is off by at most one calendar day at the
// window edge — fine for a "last N days" summary card.
const since = new Date(Date.now() - (days - 1) * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
// v1.30 (DATAINT L4) — window floor anchored on the caller's own local
// "today" (`User.timezone`), mirroring the sibling `GET
// /api/nutrients/daily` route. The stored `day` is a local-timezone key,
// so a UTC floor was off by up to a calendar day at the window edge for
// any non-UTC user.
const userTz = user.timezone || DEFAULT_TIMEZONE;
const todayKey = userDayKey(new Date(), userTz);
const since = shiftDateKey(todayKey, -(days - 1));
const rows = await prisma.nutrientIntakeDay.findMany({
where: { userId: user.id, day: { gte: since } },
@@ -49,23 +52,42 @@ export const GET = apiHandler(async (request: NextRequest) => {
select: { nutrient: true, unit: true, day: true, amount: true },
});
// Fold to one summary row per nutrient. Rows arrive day-DESC inside
// each nutrient, so the first row seen per code is the latest.
// v1.29 — `source` joined the PK (migration 0249): a day can now carry
// an APPLE_HEALTH row AND a MANUAL row. Rows arrive day-DESC inside each
// nutrient, so the first DAY seen per code is the latest; a second row
// for that same day (the other source) adds to the running total instead
// of opening a new "day".
//
// v1.30 (DATAINT M3) — `daysWithData` used to increment on every row
// whose `day` differed from the PINNED `latestDay` field, so a second
// source's row on an already-counted OLDER day (e.g. water logged via
// both APPLE_HEALTH and MANUAL on the same non-latest day) opened a
// second "day" for that same calendar date. `daysSeen` is a per-nutrient
// set of distinct day keys — every row adds to it, so a repeated day
// (any number of source rows) is counted exactly once.
const byCode = new Map<
string,
{ unit: string; latestDay: string; latestAmount: number; days: number }
{
unit: string;
latestDay: string;
latestAmount: number;
daysSeen: Set<string>;
}
>();
for (const row of rows) {
const existing = byCode.get(row.nutrient);
if (existing) {
existing.days += 1;
} else {
if (!existing) {
byCode.set(row.nutrient, {
unit: row.unit,
latestDay: row.day,
latestAmount: row.amount,
days: 1,
daysSeen: new Set([row.day]),
});
continue;
}
existing.daysSeen.add(row.day);
if (row.day === existing.latestDay) {
existing.latestAmount += row.amount;
}
}
@@ -80,7 +102,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
unit: summary.unit,
latestDay: summary.latestDay,
latestAmount: summary.latestAmount,
daysWithData: summary.days,
daysWithData: summary.daysSeen.size,
};
});
@@ -0,0 +1,218 @@
/**
* v1.29 — `POST /api/nutrients/water` contract tests.
*
* Pins the manual quick-add posture: module gate first, rate limit,
* `add` vs `set` upsert shape, the MANUAL-only key (never touches the
* APPLE_HEALTH row), the local-day default, and the dashboard-snapshot
* hard-evict on success.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
nutrientIntakeDay: {
upsert: vi.fn(),
},
},
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserDashboardSnapshot: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...args: Args) =>
fn(...args),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { apiError } from "@/lib/api-response";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
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,
timezone: "Europe/Berlin",
gender: "FEMALE",
},
};
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/nutrients/water", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
interface WaterResponse {
data: {
day: string;
nutrient: string;
source: string;
amount: number;
unit: string;
};
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 59,
resetAt: Date.now() + 60_000,
});
vi.mocked(prisma.nutrientIntakeDay.upsert).mockResolvedValue({
day: "2026-07-16",
nutrient: "water",
source: "MANUAL",
amount: 500,
unit: "ml",
} as never);
});
describe("POST /api/nutrients/water", () => {
it("refuses with the 403 module.disabled envelope when the module is off", async () => {
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: false,
response: apiError('Module "nutrients" is not enabled', 403, {
errorCode: "module.disabled",
module: "nutrients",
}),
} as never);
const res = await POST(postReq({ amountMl: 200, mode: "add" }));
expect(res.status).toBe(403);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("module.disabled");
expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled();
});
it("returns 429 when the rate limit is exceeded", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await POST(postReq({ amountMl: 200, mode: "add" }));
expect(res.status).toBe(429);
expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled();
});
it("rejects an out-of-range or missing amountMl with 422", async () => {
for (const bad of [
{ amountMl: 0, mode: "add" },
{ amountMl: -5, mode: "add" },
{ amountMl: 30000, mode: "add" },
{ mode: "add" },
]) {
const res = await POST(postReq(bad));
expect(res.status, JSON.stringify(bad)).toBe(422);
}
});
it("rejects an unknown mode with 422", async () => {
const res = await POST(postReq({ amountMl: 200, mode: "double" }));
expect(res.status).toBe(422);
});
it("rejects a malformed day with 422 (calendar-invalid)", async () => {
const res = await POST(
postReq({ amountMl: 200, mode: "add", day: "2026-02-31" }),
);
expect(res.status).toBe(422);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("nutrient.water.invalid_day");
});
it("add mode upserts the MANUAL row with an atomic increment — never the APPLE_HEALTH row", async () => {
const res = await POST(
postReq({ amountMl: 250, mode: "add", day: "2026-07-16" }),
);
expect(res.status).toBe(200);
expect(prisma.nutrientIntakeDay.upsert).toHaveBeenCalledWith({
where: {
userId_day_nutrient_source: {
userId: "user-1",
day: "2026-07-16",
nutrient: "water",
source: "MANUAL",
},
},
create: {
userId: "user-1",
day: "2026-07-16",
nutrient: "water",
amount: 250,
unit: "ml",
source: "MANUAL",
},
update: { amount: { increment: 250 } },
});
expect(invalidateUserDashboardSnapshot).toHaveBeenCalledWith("user-1");
});
it("set mode overwrites the MANUAL row's amount instead of incrementing", async () => {
await POST(postReq({ amountMl: 1800, mode: "set", day: "2026-07-16" }));
expect(prisma.nutrientIntakeDay.upsert).toHaveBeenCalledWith(
expect.objectContaining({ update: { amount: 1800 } }),
);
});
it("defaults `day` to the caller's current local day when omitted", async () => {
await POST(postReq({ amountMl: 200, mode: "add" }));
const call = vi.mocked(prisma.nutrientIntakeDay.upsert).mock
.calls[0][0] as {
where: { userId_day_nutrient_source: { day: string } };
};
expect(call.where.userId_day_nutrient_source.day).toMatch(
/^\d{4}-\d{2}-\d{2}$/,
);
});
it("returns the MANUAL row shape on success", async () => {
const res = await POST(
postReq({ amountMl: 250, mode: "add", day: "2026-07-16" }),
);
const body = (await res.json()) as WaterResponse;
expect(body.data).toEqual({
day: "2026-07-16",
nutrient: "water",
source: "MANUAL",
amount: 500,
unit: "ml",
});
});
});
+131
View File
@@ -0,0 +1,131 @@
/**
* `POST /api/nutrients/water` — manual water quick-add (v1.29).
*
* Writes ONLY the `source="MANUAL"` row for `(userId, day, "water")` —
* migration 0249 widened the composite PK so this never touches the
* `source="APPLE_HEALTH"` row the batch route owns. `mode: "add"`
* increments the manual day total (the quick-add chips: +200/+300/
* +500 mL + a custom amount); `mode: "set"` overwrites it (the "edit
* today's total" undo path — there is no per-entry ledger, honest to
* the day-total storage model). `day` defaults to the caller's current
* local day (`User.timezone`) when omitted.
*
* Module gate first, like every other nutrients route. Idempotency-key
* aware (`withIdempotency`) so a network retry of a quick-add tap can
* never double-increment the day total.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog";
import { nutrientWaterWriteSchema } from "@/lib/validations/nutrients";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
const WRITE_RATE_LIMIT_MAX = 60;
const WRITE_RATE_LIMIT_WINDOW_MS = 60 * 1000;
/** Calendar sanity for a client-supplied YYYY-MM-DD key (2026-02-31 etc). */
function isRealCalendarDay(day: string): boolean {
const [y, m, d] = day.split("-").map(Number);
const dt = new Date(Date.UTC(y, m - 1, d));
return (
dt.getUTCFullYear() === y &&
dt.getUTCMonth() === m - 1 &&
dt.getUTCDate() === d
);
}
export const POST = apiHandler(withIdempotency<[NextRequest]>(postWater));
async function postWater(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "nutrients");
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(
`nutrients:water:${user.id}`,
WRITE_RATE_LIMIT_MAX,
WRITE_RATE_LIMIT_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many requests, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = nutrientWaterWriteSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "nutrient.water.invalid",
});
}
const { amountMl, mode } = parsed.data;
const userTz = user.timezone || DEFAULT_TIMEZONE;
const day = parsed.data.day ?? userDayKey(new Date(), userTz);
if (!isRealCalendarDay(day)) {
return apiError("Invalid day", 422, {
errorCode: "nutrient.water.invalid_day",
});
}
const definition = NUTRIENT_CATALOG.water;
const key = {
userId_day_nutrient_source: {
userId: user.id,
day,
nutrient: "water",
source: "MANUAL",
},
};
const row = await prisma.nutrientIntakeDay.upsert({
where: key,
create: {
userId: user.id,
day,
nutrient: "water",
amount: amountMl,
unit: definition.unit,
source: "MANUAL",
},
update:
mode === "add"
? { amount: { increment: amountMl } }
: { amount: amountMl },
});
// Interactive single-entry write — hard-evict (not mark-stale) so the
// dashboard water tile reflects the new total on the very next read,
// matching the mood / medication / measurement posture.
invalidateUserDashboardSnapshot(user.id);
annotate({
action: { name: "nutrient.water.write" },
meta: { mode, day, amount_ml: amountMl },
});
return apiSuccess({
day: row.day,
nutrient: "water" as const,
source: "MANUAL" as const,
amount: row.amount,
unit: row.unit,
});
}
@@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const { rateLimitMock, getCredsMock, mintMock, authUrlMock } = vi.hoisted(
() => ({
rateLimitMock: vi.fn(),
getCredsMock: vi.fn(),
mintMock: vi.fn(() => "signed-state"),
authUrlMock: vi.fn(() => "https://cloud.ouraring.com/oauth/authorize?x=1"),
}),
);
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/secure-cookie", () => ({
shouldEmitSecureCookie: () => true,
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: rateLimitMock }));
vi.mock("@/lib/oura/client", () => ({ getAuthorizationUrl: authUrlMock }));
vi.mock("@/lib/oura/credentials", () => ({
getOuraClientCredentials: getCredsMock,
}));
vi.mock("@/lib/oauth/signed-state", () => ({
OAUTH_STATE_TTL_MS: 600_000,
mintSignedState: mintMock,
oauthStateCookieName: () => "oura_oauth_state",
}));
import { GET } from "../route";
process.env.NEXT_PUBLIC_APP_URL = "https://app.example";
const run = GET as unknown as () => Promise<{
status: number;
headers: { get(k: string): string | null };
cookies: { get(k: string): { value: string } | undefined };
}>;
beforeEach(() => {
vi.clearAllMocks();
rateLimitMock.mockResolvedValue({ allowed: true });
getCredsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" });
});
describe("GET /api/oura/connect", () => {
it("redirects to the consent screen and sets the state cookie", async () => {
const res = await run();
expect(res.headers.get("location")).toContain("cloud.ouraring.com");
expect(res.cookies.get("oura_oauth_state")?.value).toBe("signed-state");
});
it("redirects to an error when the bucket is exhausted", async () => {
rateLimitMock.mockResolvedValue({ allowed: false });
const res = await run();
expect(res.headers.get("location")).toContain("reason=rate_limited");
expect(getCredsMock).not.toHaveBeenCalled();
});
it("redirects to reason=nocreds when neither BYO nor env credentials resolve", async () => {
getCredsMock.mockResolvedValue(null);
const res = await run();
const location = res.headers.get("location") ?? "";
expect(location).toContain("/settings/integrations");
expect(location).toContain("oura=error");
expect(location).toContain("reason=nocreds");
});
});
+11 -3
View File
@@ -1,5 +1,4 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -19,7 +18,10 @@ import { NextResponse } from "next/server";
* BYO Oura client id/secret wins, falling back to the shared env app
* (`OURA_CLIENT_ID` / `OURA_CLIENT_SECRET`) when none is stored. CSRF via the
* stateless signed state (`src/lib/oauth/signed-state.ts`): the same token is
* the `state` URL param AND an httpOnly cookie. Rate-limited per user.
* the `state` URL param AND an httpOnly cookie. Rate-limited per user. The
* no-credentials branch redirects too (v1.29.x) rather than returning a raw
* JSON 400 — every other failure path here already redirects, and a
* browser-navigation entry point should never surface an unstyled JSON page.
*/
const CONNECT_RATE_LIMIT = 10;
const CONNECT_WINDOW_MS = 60_000;
@@ -45,7 +47,13 @@ export const GET = apiHandler(async () => {
const creds = await getOuraClientCredentials(user.id);
if (!creds) {
return apiError("Oura integration is not configured on this server.", 400);
annotate({ action: { name: "oura.connect.no_credentials" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?oura=error&reason=nocreds",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
}
const state = mintSignedState("oura", user.id);
@@ -13,9 +13,6 @@ vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
}));
vi.mock("@/lib/api-response", () => ({
apiError: (error: string, status: number) => ({ data: null, error, status }),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/secure-cookie", () => ({
shouldEmitSecureCookie: () => true,
@@ -61,9 +58,12 @@ describe("GET /api/polar/connect", () => {
expect(getCredsMock).not.toHaveBeenCalled();
});
it("returns 400 when neither BYO nor env credentials resolve", async () => {
it("redirects to reason=nocreds when neither BYO nor env credentials resolve", async () => {
getCredsMock.mockResolvedValue(null);
const res = (await run()) as unknown as { status: number };
expect(res.status).toBe(400);
const res = await run();
const location = res.headers.get("location") ?? "";
expect(location).toContain("/settings/integrations");
expect(location).toContain("polar=error");
expect(location).toContain("reason=nocreds");
});
});
+11 -3
View File
@@ -1,5 +1,4 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -22,7 +21,10 @@ import { NextResponse } from "next/server";
* same token is the `state` URL param AND an httpOnly cookie, and the callback
* enforces both signature validity and a byte-exact cookie match.
*
* Rate-limited per user so a session can't spam the consent redirect.
* Rate-limited per user so a session can't spam the consent redirect. The
* no-credentials branch redirects too (v1.29.x) rather than returning a raw
* JSON 400 — every other failure path here already redirects, and a
* browser-navigation entry point should never surface an unstyled JSON page.
*/
const CONNECT_RATE_LIMIT = 10;
const CONNECT_WINDOW_MS = 60_000;
@@ -48,7 +50,13 @@ export const GET = apiHandler(async () => {
const creds = await getPolarClientCredentials(user.id);
if (!creds) {
return apiError("Polar integration is not configured on this server.", 400);
annotate({ action: { name: "polar.connect.no_credentials" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?polar=error&reason=nocreds",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
}
const state = mintSignedState("polar", user.id);
@@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const { rateLimitMock, getCredsMock, mintMock, authUrlMock } = vi.hoisted(
() => ({
rateLimitMock: vi.fn(),
getCredsMock: vi.fn(),
mintMock: vi.fn(() => "signed-state"),
authUrlMock: vi.fn(() => "https://www.strava.com/oauth/authorize?x=1"),
}),
);
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/secure-cookie", () => ({
shouldEmitSecureCookie: () => true,
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: rateLimitMock }));
vi.mock("@/lib/strava/client", () => ({ getAuthorizationUrl: authUrlMock }));
vi.mock("@/lib/strava/credentials", () => ({
getStravaClientCredentials: getCredsMock,
}));
vi.mock("@/lib/oauth/signed-state", () => ({
OAUTH_STATE_TTL_MS: 600_000,
mintSignedState: mintMock,
oauthStateCookieName: () => "strava_oauth_state",
}));
import { GET } from "../route";
process.env.NEXT_PUBLIC_APP_URL = "https://app.example";
const run = GET as unknown as () => Promise<{
status: number;
headers: { get(k: string): string | null };
cookies: { get(k: string): { value: string } | undefined };
}>;
beforeEach(() => {
vi.clearAllMocks();
rateLimitMock.mockResolvedValue({ allowed: true });
getCredsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" });
});
describe("GET /api/strava/connect", () => {
it("redirects to the consent screen and sets the state cookie", async () => {
const res = await run();
expect(res.headers.get("location")).toContain("www.strava.com");
expect(res.cookies.get("strava_oauth_state")?.value).toBe("signed-state");
});
it("redirects to an error when the bucket is exhausted", async () => {
rateLimitMock.mockResolvedValue({ allowed: false });
const res = await run();
expect(res.headers.get("location")).toContain("reason=rate_limited");
expect(getCredsMock).not.toHaveBeenCalled();
});
it("redirects to reason=nocreds when neither BYO nor env credentials resolve", async () => {
getCredsMock.mockResolvedValue(null);
const res = await run();
const location = res.headers.get("location") ?? "";
expect(location).toContain("/settings/integrations");
expect(location).toContain("strava=error");
expect(location).toContain("reason=nocreds");
});
});
+10 -5
View File
@@ -1,5 +1,4 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -19,7 +18,10 @@ import { NextResponse } from "next/server";
* user's BYO Strava client id/secret wins, falling back to the shared env app
* (`STRAVA_CLIENT_ID` / `STRAVA_CLIENT_SECRET`) when none is stored. CSRF via
* the stateless signed state (`src/lib/oauth/signed-state.ts`): the same token
* is the `state` URL param AND an httpOnly cookie. Rate-limited per user.
* is the `state` URL param AND an httpOnly cookie. Rate-limited per user. The
* no-credentials branch redirects too (v1.29.x) rather than returning a raw
* JSON 400 — every other failure path here already redirects, and a
* browser-navigation entry point should never surface an unstyled JSON page.
*/
const CONNECT_RATE_LIMIT = 10;
const CONNECT_WINDOW_MS = 60_000;
@@ -45,9 +47,12 @@ export const GET = apiHandler(async () => {
const creds = await getStravaClientCredentials(user.id);
if (!creds) {
return apiError(
"Strava integration is not configured on this server.",
400,
annotate({ action: { name: "strava.connect.no_credentials" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?strava=error&reason=nocreds",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
}
@@ -53,6 +53,7 @@ import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/api-handler";
import { consumeWhoopConnectTicket } from "@/lib/whoop/connect-ticket";
import { checkRateLimit } from "@/lib/rate-limit";
import { getUserWhoopCredentials } from "@/lib/whoop/credentials";
import type { NextRequest } from "next/server";
const create = prisma.whoopOAuthState.create as ReturnType<typeof vi.fn>;
@@ -133,6 +134,17 @@ describe("GET /api/whoop/connect", () => {
expect(create.mock.calls[0][0].data.returnScheme).toBeNull();
});
it("redirects to reason=nocreds when no per-user credentials are stored", async () => {
vi.mocked(getUserWhoopCredentials).mockResolvedValueOnce(null);
const res = await GET(makeReq("https://app.example/api/whoop/connect"));
expect(res.status).toBe(307);
const location = res.headers.get("location") ?? "";
expect(location).toContain("/settings/integrations");
expect(location).toContain("whoop=error");
expect(location).toContain("reason=nocreds");
expect(create).not.toHaveBeenCalled();
});
it("ticket + return_scheme combine", async () => {
consume.mockResolvedValue({ userId: "ticket-user" });
const res = await GET(
+7 -4
View File
@@ -40,7 +40,10 @@ import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
* Rate-limited per user (10 calls / 60 s) so a logged-in session can't spam
* ledger rows for the full 10-min TTL window. Both the rate-limit and
* create-failure paths redirect (not JSON) because the cookie entry point is a
* browser navigation — a 429 envelope would surface as a blank page.
* browser navigation — a 429 envelope would surface as a blank page. The
* no-credentials branch redirects too (v1.29.x), matching Withings — only the
* ticket-invalid branch stays a typed 401 JSON response, since that path is
* consumed by a native client, not a browser navigation.
*/
const CONNECT_RATE_LIMIT = 10;
const CONNECT_WINDOW_MS = 60_000;
@@ -89,9 +92,9 @@ export const GET = apiHandler(async (req: NextRequest) => {
const creds = await getUserWhoopCredentials(userId);
if (!creds) {
return apiError(
"Please configure your WHOOP Client ID and Client Secret in Settings first.",
400,
annotate({ action: { name: "whoop.connect.no_credentials" } });
return NextResponse.redirect(
new URL("/settings/integrations?whoop=error&reason=nocreds", req.url),
);
}
@@ -12,6 +12,10 @@
* redirects to
* `/settings/integrations?withings=error&reason=connect`
* instead of bubbling a 500.
* 4. No credentials: redirects to
* `/settings/integrations?withings=error&reason=nocreds` instead of
* returning a raw JSON 400 (v1.29.x — the flagship onboarding CTA
* dead-ended in an unstyled JSON tab for every brand-new account).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
@@ -132,4 +136,17 @@ describe("GET /api/withings/connect", () => {
expect(location).toContain("withings=error");
expect(location).toContain("reason=connect");
});
it("redirects to reason=nocreds when no per-user credentials are stored", async () => {
vi.mocked(getUserWithingsCredentials).mockResolvedValueOnce(null);
const response = await GET(connectRequest());
expect(response.status).toBe(307);
const location = response.headers.get("location") ?? "";
expect(location).toContain("/settings/integrations");
expect(location).toContain("withings=error");
expect(location).toContain("reason=nocreds");
expect(prisma.withingsOAuthState.create).not.toHaveBeenCalled();
});
});
+9 -4
View File
@@ -1,5 +1,4 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -32,6 +31,12 @@ import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
* instead of bubbling a 500. Both rate-limit and create-failure paths
* redirect rather than returning JSON because the entry point is a
* browser navigation — a 429 envelope would surface as a blank page.
*
* v1.29.x — the no-credentials branch redirects too, matching every
* other failure path in this route. It used to return a raw JSON 400,
* which every browser navigation into this route (onboarding's
* "Connect Withings" tap included) surfaced as an unstyled JSON page —
* the first "connect a device" moment for a brand-new self-hoster.
*/
const CONNECT_RATE_LIMIT = 10;
@@ -58,9 +63,9 @@ export const GET = apiHandler(async (req: NextRequest) => {
const creds = await getUserWithingsCredentials(user.id);
if (!creds) {
return apiError(
"Please configure your Withings Client ID and Client Secret in Settings first.",
400,
annotate({ action: { name: "withings.connect.no_credentials" } });
return NextResponse.redirect(
new URL("/settings/integrations?withings=error&reason=nocreds", req.url),
);
}
+16 -1
View File
@@ -83,7 +83,22 @@ export const GET = apiHandler(async () => {
// the sync path does.
const message = error instanceof Error ? error.message : String(error);
if (isWithingsRefreshReauthFailure(message)) {
await markReauthRequired(user.id, "withings", message).catch(() => {});
// Awaited so the persisted re-auth state is reflected in this same
// status response — but a persistence failure must not fail the read.
// Breadcrumb it instead of swallowing silently (the message rides the
// central annotate/redact path, never the raw console).
await markReauthRequired(user.id, "withings", message).catch(
(error: unknown) => {
annotate({
meta: {
"fire_and_forget.integration.reauth.mark": {
error: error instanceof Error ? error.message : String(error),
provider: "withings",
},
},
});
},
);
}
}
}
@@ -147,6 +147,28 @@ describe("GET /api/workouts — canonical dedup", () => {
expect(body.data.meta.droppedDuplicates).toBe(1);
});
it("returns the most recent workouts first", async () => {
// The canonical picker re-sorts its output to startedAt ASC (its rollup
// contract), so the list route must re-establish newest-first AFTER the
// pick — otherwise offset/limit slice the OLDEST page and recent workouts
// never surface. These two never cluster (different day + slot).
const older = makeRow("w-old", "APPLE_HEALTH", "2026-01-01T07:00:00Z");
const newer = makeRow("w-new", "APPLE_HEALTH", "2026-07-01T07:00:00Z");
vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([
newer,
older,
] as never);
const res = await GET(makeRequest());
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.workouts.map((w: { id: string }) => w.id)).toEqual([
"w-new",
"w-old",
]);
});
it("returns 401 without a session", async () => {
vi.mocked(getSession).mockResolvedValueOnce(null);
const res = await GET(makeRequest());
+114 -51
View File
@@ -30,10 +30,18 @@
* slice the canonical projection. A naive `skip` + per-window dedup
* double-counts boundary clusters across pages because a cluster
* whose first member sits in page N-1 and whose later member sits in
* page N would be dropped on N-1 and surface again on N. Workout
* volume for HealthLog is bounded (single user, tens of workouts per
* week — single-digit MB at the table's busiest), so reading the
* full filtered set per request is well within budget.
* page N would be dropped on N-1 and surface again on N.
*
* The canonical projection (the deduped, sorted row set) is cached
* keyed on the FILTER (`userId|since|until|sportType`) — deliberately
* WITHOUT `offset`/`limit` — so every page of a drain shares one cache
* entry: the first page's request builds it (one `findMany` + one
* dedup pass), every later page in the same TTL window slices the
* already-built array for free. Before this, each page carried its own
* cache key, so a full historical drain (the iOS app's "drain
* historical workouts" path, potentially thousands of rows on a
* 10-year Apple Health import) re-ran the full read + dedup once per
* page — O(pages × rows) instead of O(rows).
*
* Response shape (iOS contract):
* - `workouts: WorkoutListEntry[]` — each entry exposes
@@ -68,6 +76,32 @@ interface WorkoutsParams {
readonly sportType: string | null;
}
type WorkoutFilterParams = Pick<
WorkoutsParams,
"since" | "until" | "sportType"
>;
interface CanonicalWorkoutRow {
id: string;
source: string;
externalId: string | null;
sportType: string | null;
startedAt: Date | null;
endedAt: Date | null;
durationSec: number | null;
totalDistanceM: number | null;
totalEnergyKcal: number | null;
avgHeartRate: number | null;
maxHeartRate: number | null;
createdAt: Date;
}
/** The cached, deduped, most-recent-first row set for one filter combination. */
interface WorkoutsProjection {
canonical: CanonicalWorkoutRow[];
rawCount: number;
}
interface WorkoutsResult {
workouts: Array<{
id: string;
@@ -90,11 +124,11 @@ interface WorkoutsResult {
};
}
async function buildWorkoutsResponse(
async function buildWorkoutsProjection(
userId: string,
params: WorkoutsParams,
): Promise<WorkoutsResult> {
const { limit, offset, since, until, sportType } = params;
params: WorkoutFilterParams,
): Promise<WorkoutsProjection> {
const { since, until, sportType } = params;
const where: Record<string, unknown> = { userId };
if (since || until) {
@@ -115,55 +149,81 @@ async function buildWorkoutsResponse(
where.sportType = sportType;
}
const rows = await prisma.workout.findMany({
where,
orderBy: [{ startedAt: "desc" }, { id: "asc" }],
select: {
id: true,
source: true,
externalId: true,
sportType: true,
startedAt: true,
endedAt: true,
durationSec: true,
totalDistanceM: true,
totalEnergyKcal: true,
avgHeartRate: true,
maxHeartRate: true,
createdAt: true,
},
});
// Independent reads — the sourcePriorityJson lookup doesn't depend on the
// row read, so run them concurrently rather than paying two sequential
// round trips.
const [rows, userRow] = await Promise.all([
prisma.workout.findMany({
where,
orderBy: [{ startedAt: "desc" }, { id: "asc" }],
select: {
id: true,
source: true,
externalId: true,
sportType: true,
startedAt: true,
endedAt: true,
durationSec: true,
totalDistanceM: true,
totalEnergyKcal: true,
avgHeartRate: true,
maxHeartRate: true,
createdAt: true,
},
}),
prisma.user.findUnique({
where: { id: userId },
select: { sourcePriorityJson: true },
}),
]);
const userRow = await prisma.user.findUnique({
where: { id: userId },
select: { sourcePriorityJson: true },
});
const canonical = pickCanonicalWorkoutRows(
rows,
userRow?.sourcePriorityJson ?? null,
);
const page = canonical.slice(offset, offset + limit).map((row) => ({
id: row.id,
sportType: row.sportType,
startedAt: row.startedAt,
endedAt: row.endedAt,
durationSec: row.durationSec,
distanceM: row.totalDistanceM,
activeEnergyKcal: row.totalEnergyKcal,
avgHr: row.avgHeartRate,
maxHr: row.maxHeartRate,
source: row.source,
externalId: row.externalId,
}));
// `pickCanonicalWorkoutRows` re-sorts its output to `startedAt ASC` (its
// documented contract for the rollup callers). This surface is a
// most-recent-first list, so the DB `orderBy startedAt desc` must be
// re-established AFTER the pick — otherwise `offset`/`limit` slice the
// OLDEST page and a user with a long history never sees recent workouts.
canonical.sort((a, b) => {
const delta = (b.startedAt?.getTime() ?? 0) - (a.startedAt?.getTime() ?? 0);
if (delta !== 0) return delta;
return a.id.localeCompare(b.id);
});
return { canonical, rawCount: rows.length };
}
function sliceWorkoutsProjection(
projection: WorkoutsProjection,
limit: number,
offset: number,
): WorkoutsResult {
const page = projection.canonical
.slice(offset, offset + limit)
.map((row) => ({
id: row.id,
sportType: row.sportType,
startedAt: row.startedAt,
endedAt: row.endedAt,
durationSec: row.durationSec,
distanceM: row.totalDistanceM,
activeEnergyKcal: row.totalEnergyKcal,
avgHr: row.avgHeartRate,
maxHr: row.maxHeartRate,
source: row.source,
externalId: row.externalId,
}));
return {
workouts: page,
meta: {
total: canonical.length,
total: projection.canonical.length,
limit,
offset,
droppedDuplicates: rows.length - canonical.length,
droppedDuplicates: projection.rawCount - projection.canonical.length,
},
};
}
@@ -196,15 +256,18 @@ export const GET = apiHandler(async (request: NextRequest) => {
const until = searchParams.get("until");
const sportType = searchParams.get("sportType");
const params: WorkoutsParams = { limit, offset, since, until, sportType };
const cacheKey = `${user.id}|${limit}|${offset}|${since ?? ""}|${until ?? ""}|${sportType ?? ""}`;
// Deliberately excludes `limit`/`offset` — every page of one filter
// combination shares this cached projection (see the docblock above).
const projectionKey = `${user.id}|${since ?? ""}|${until ?? ""}|${sportType ?? ""}`;
const result = await cached(
caches.workouts as ServerCache<WorkoutsResult>,
cacheKey,
() => buildWorkoutsResponse(user.id, params),
const projection = await cached(
caches.workouts as ServerCache<WorkoutsProjection>,
projectionKey,
() => buildWorkoutsProjection(user.id, { since, until, sportType }),
annotate,
);
const result = sliceWorkoutsProjection(projection, limit, offset);
return apiSuccess(result);
});
+91 -39
View File
@@ -4,12 +4,14 @@ import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
Loader2,
MessagesSquare,
Paperclip,
Search,
Target,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -17,13 +19,15 @@ import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { COACH_SCROLLBAR } from "@/components/insights/coach-panel/message-thread";
import {
useCoachConversations,
useDeleteCoachConversation,
useCoachConversationHistory,
useDeleteCoachConversationWithUndo,
} from "@/components/insights/coach-panel/use-coach";
import type { CoachConversationDTO } from "@/lib/ai/coach/types";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
import { useLoadMoreSentinel } from "@/hooks/use-load-more-sentinel";
import { useTranslations } from "@/lib/i18n/context";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
import { cn } from "@/lib/utils";
@@ -36,8 +40,14 @@ import { cn } from "@/lib/utils";
* sibling of the Coach page itself: a search field at the top, then the
* recent conversations grouped by recency (Today / Yesterday / This week
* / Earlier). Selecting a row routes to `/coach?c=<id>` — the existing
* deep-link open mechanism — so there is NO new API and NO new state;
* the page is a thin read over `useCoachConversations()`.
* deep-link open mechanism. Reuses the SAME `/coach?c=<id>` open
* mechanism the rail uses.
*
* v1.30.2 (QoL H1) — the page is a thin read over
* `useCoachConversationHistory()` (cursor-paginated + server-searched, see
* the route's doc comment), which the drawer's `<HistoryRail>` also
* consumes — the two surfaces can no longer drift onto different
* pagination behaviour.
*
* Gating mirrors `/coach`: the operator master flag OR a per-user opt-out
* hides the Coach entirely and redirects back to `/insights` rather than
@@ -100,33 +110,59 @@ function groupByRecency(conversations: CoachConversationDTO[]): RecencyGroup[] {
function CoachConversationsBody() {
const { t } = useTranslations();
const router = useRouter();
const { conversations, isLoading, isError, refetch } =
useCoachConversations();
const deleteMutation = useDeleteCoachConversation();
const [filter, setFilter] = useState<string>("");
const [confirmId, setConfirmId] = useState<string | null>(null);
// v1.30.2 (QoL H1) — search now drives the server-side title query;
// debounce the keystroke draft instead of re-issuing a request per key.
const debouncedFilter = useDebouncedValue(filter, 200);
const {
conversations,
isLoading,
isError,
refetch,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useCoachConversationHistory({ search: debouncedFilter });
const { pendingDeleteIds, requestDelete, undoDelete } =
useDeleteCoachConversationWithUndo();
// Mirror the rail's title substring filter.
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return conversations;
return conversations.filter((c) => c.title.toLowerCase().includes(q));
}, [conversations, filter]);
// pendingDeleteIds is a client-only hide filter for the just-armed undo
// window — the server already resolved the rest of `visible` against the
// search term.
const visible = useMemo(
() => conversations.filter((c) => !pendingDeleteIds.has(c.id)),
[conversations, pendingDeleteIds],
);
const isSearching = debouncedFilter.trim().length > 0;
const groups = useMemo(() => groupByRecency(filtered), [filtered]);
const groups = useMemo(() => groupByRecency(visible), [visible]);
// Same callback-ref pattern as the rail: a `useState` setter (not a plain
// `useRef`) so the sentinel hook's `root` re-fires its effect once the
// real scroll-container node exists (null on the very first render).
const [listNode, setListNode] = useState<HTMLDivElement | null>(null);
const sentinelRef = useLoadMoreSentinel({
enabled: hasNextPage && !isFetchingNextPage,
onLoadMore: fetchNextPage,
root: listNode,
});
function handleSelect(id: string) {
// Reuse the existing `?c=<id>` open mechanism on the Coach page.
router.push(`/coach?c=${id}`);
}
// v1.30.1 M5 — one tap deletes (with an Undo toast); see
// `use-coach.ts`'s `useDeleteCoachConversationWithUndo` for the
// rationale — this mirrors the rail's identical fix.
function handleDeleteRequest(id: string) {
if (confirmId === id) {
deleteMutation.mutate(id);
setConfirmId(null);
} else {
setConfirmId(id);
}
requestDelete(id);
toast.success(t("insights.coach.historyDeleted"), {
action: {
label: t("common.undo"),
onClick: () => undoDelete(id),
},
});
}
return (
@@ -169,20 +205,21 @@ function CoachConversationsBody() {
</header>
<div
ref={setListNode}
data-slot="coach-conversations-list"
className={cn(
"-mx-1 flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto px-1",
"-mx-1 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-1",
COACH_SCROLLBAR,
)}
>
{isLoading && conversations.length === 0 ? (
{isLoading && visible.length === 0 ? (
<p
data-slot="coach-conversations-loading"
className="text-muted-foreground px-1 py-3 text-sm"
>
{t("common.loading")}
</p>
) : isError && conversations.length === 0 ? (
) : isError && visible.length === 0 ? (
// A load failure must not masquerade as "no conversations yet" —
// that reads as a data-loss scare and offers no way back. Mirror
// /coach/plans and surface a retry.
@@ -196,7 +233,9 @@ function CoachConversationsBody() {
<MessagesSquare className="size-6" aria-hidden="true" />
</span>
<p className="text-muted-foreground max-w-xs text-sm leading-relaxed">
{t("insights.coach.historyEmpty")}
{isSearching
? t("insights.coach.historySearchEmpty")
: t("insights.coach.historyEmpty")}
</p>
</div>
) : (
@@ -212,7 +251,6 @@ function CoachConversationsBody() {
</h2>
<ul className="flex flex-col gap-1">
{group.conversations.map((c) => {
const isConfirming = confirmId === c.id;
return (
<li
key={c.id}
@@ -260,18 +298,9 @@ function CoachConversationsBody() {
variant="ghost"
size="icon-lg"
onClick={() => handleDeleteRequest(c.id)}
aria-label={
isConfirming
? t("insights.coach.historyDeleteConfirm")
: t("insights.coach.historyDeleteAria")
}
aria-label={t("insights.coach.historyDeleteAria")}
data-slot="coach-conversations-delete"
data-confirming={isConfirming ? "true" : undefined}
className={cn(
"shrink-0",
isConfirming &&
"text-destructive hover:text-destructive",
)}
className="shrink-0"
>
<Trash2 className="size-4" aria-hidden="true" />
</Button>
@@ -282,6 +311,26 @@ function CoachConversationsBody() {
</section>
))
)}
{/* v1.30.2 (QoL H1) — same IntersectionObserver sentinel as the
drawer rail: scrolling this div into view pulls the next cursor
page. Empty + aria-hidden; the status row below is the a11y
signal. */}
{visible.length > 0 && hasNextPage ? (
<div ref={sentinelRef} aria-hidden="true" className="h-px" />
) : null}
{isFetchingNextPage ? (
<div
data-slot="coach-conversations-loading-more"
role="status"
className="text-muted-foreground flex items-center justify-center gap-2 py-3 text-xs"
>
<Loader2
className="size-3.5 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
{t("insights.coach.historyLoadingMore")}
</div>
) : null}
</div>
</div>
);
@@ -315,8 +364,11 @@ export default function CoachConversationsPage() {
data-slot="coach-conversations-page"
// Match `/coach`'s full-bleed sizing: cancel the AuthShell padding
// and claim the viewport height below the top bar (minus the
// mobile-only BottomNav band).
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]"
// mobile-only BottomNav band). L4 fix
// (`.planning/audits/2026-07-18-qa-ui.md`) — this had dropped the
// `safe-area-inset-top` term `/coach` subtracts, overshooting by
// ~50px on notched devices; restored to match exactly.
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]"
>
<CoachConversationsBody />
</div>
+42 -2
View File
@@ -6,11 +6,13 @@ import { useQuery } from "@tanstack/react-query";
import { CoachConversation } from "@/components/insights/coach-panel/coach-conversation";
import type { CoachNudgeStatus } from "@/components/insights/layout-coach-fab";
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { coachScopeSourceSchema } from "@/lib/ai/coach/types";
/**
* v1.12.0 (Coach v2 #6) — full-page Coach conversation.
@@ -57,6 +59,23 @@ import { apiGet } from "@/lib/api/api-fetch";
* most-recent thread so the user lands on what the Coach said.
* The search-param read sits in a Suspense child so the client-bailout
* never de-opts the route.
*
* 2026-07-17 UX-flows audit — the route understood only `?c=` / `?doc=`, so
* every other cross-surface hand-off (a metric card's "ask about this", the
* Today check-in's "Adjust", a workout detail's coach button) dropped its
* context at the URL boundary and landed on a blank composer (F1-2 / F4-1 /
* F6-1). Two additive params close that gap, both seeding the SAME props
* `<CoachConversation>` already exposes for the drawer's suggested-prompt
* chips — no new plumbing inside the conversation surface itself:
* - `?scope=<CoachScopeSource>` — narrows the snapshot the FIRST turn of a
* fresh conversation reads (validated against the closed enum; an
* unrecognised value is silently dropped rather than reaching the chat
* route with a free-form string).
* - `?ask=<text>` — seeds the composer prefill (mirrors `prefill` on the
* in-app `askCoach()` launch call). The user still reviews/sends it —
* this is a prefill, not an auto-send.
* Both are ignored once `?c=` or `?doc=` pin an existing/scoped thread — scope
* only ever applies to a fresh conversation's first turn.
*/
function CoachPageBody() {
const searchParams = useSearchParams();
@@ -71,13 +90,30 @@ function CoachPageBody() {
const rawDoc = searchParams.get("doc");
const seedDocumentId = deepLinkedId === null && rawDoc ? rawDoc : null;
// A fresh conversation only — an existing thread (`?c=`) or a doc-scoped
// chat (`?doc=`) keeps its own established scope.
const freshChat = deepLinkedId === null && seedDocumentId === null;
const rawScope = freshChat ? searchParams.get("scope") : null;
const scopeResult = rawScope
? coachScopeSourceSchema.safeParse(rawScope)
: null;
const launchScope: CoachLaunchScope | null = scopeResult?.success
? { metric: scopeResult.data }
: null;
const seedPrefill = freshChat ? searchParams.get("ask") : null;
// C1 exception — an unread coach-initiated message opens the most-recent
// conversation (which holds that proactive turn). Only consulted when the
// entry did not pin a specific thread or ask for a fresh chat.
// A `?doc=` open is an explicit fresh doc-scoped chat — never override it by
// resuming the most-recent thread on an unread nudge.
// resuming the most-recent thread on an unread nudge. A `?scope=`/`?ask=`
// hand-off is likewise an explicit fresh-chat request.
const exceptionEligible =
deepLinkedId === null && rawC !== "new" && seedDocumentId === null;
deepLinkedId === null &&
rawC !== "new" &&
seedDocumentId === null &&
launchScope === null &&
!seedPrefill;
const { data: nudge } = useQuery({
queryKey: queryKeys.coachNudgeStatus(),
queryFn: async (): Promise<CoachNudgeStatus> =>
@@ -95,6 +131,10 @@ function CoachPageBody() {
// v1.28.51 — seed the document scope for a `?doc=<id>` open so the first
// turn is created + sent through the hardened fenced document endpoint.
initialDocumentId={seedDocumentId}
// 2026-07-17 UX-flows audit F1-2/F4-1/F6-1 — seed the scope/prefill a
// cross-surface hand-off carried in the URL.
launchScope={launchScope}
prefill={seedPrefill}
// Default is the new-chat hero; only resume most-recent for the
// unread coach-initiated exception.
autoOpenMostRecent={hasUnreadCoachMessage}
+1 -1
View File
@@ -230,7 +230,7 @@ function CoachPlansBody() {
<div
data-slot="coach-plans-list"
className={cn(
"-mx-1 flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto px-1",
"-mx-1 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-1",
COACH_SCROLLBAR,
)}
>
+14
View File
@@ -160,6 +160,17 @@
--dracula-red: #ff5555;
--dracula-yellow: #f1fa8c;
--dracula-lavender: #d6acff;
/*
* v1.28.17 — the sleep-stage IN_BED fill. Distinct from `--dracula-comment`
* above (an orphaned token — no `@theme` utility was ever generated for
* it, per the `confidence-badge.ts` note — and its `#9aa3b3` value never
* matched the classic Dracula "Comment" blue-grey this chart actually
* paints). Minted here instead of retuning the unrelated token, so this
* default carries the exact hex the stacked bar hardcoded before
* (`#6272a4`) and the light override below (`:root.light`) is this
* token's own AA-tuned counterpart.
*/
--chart-inbed: #6272a4;
/*
* Semantic feedback tokens (see @theme `--color-success` / `--color-warning`
@@ -378,6 +389,9 @@
/* Lavender has no semantic twin; a violet one step lighter than purple,
* AA-tuned — 4.97:1 card / 4.63:1 bg. */
--dracula-lavender: #7c50e0;
/* IN_BED sleep-stage fill (see the `:root` default above) — same
* blue-grey hue family, darkened for AA — 6.66:1 card / 6.03:1 bg. */
--chart-inbed: #4a5b8a;
--sidebar: oklch(0.988 0.003 300);
--sidebar-foreground: oklch(0.3 0.012 295);
--sidebar-primary: #644ac9;
@@ -0,0 +1,100 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
/**
* v1.30 (UX/IA audit H1) — `<EcgCrossLink>` now mounts on the resting-pulse
* and HRV sub-pages (previously only on `/insights/pulse`), threaded through
* `<HealthKitMetricPage afterAssessment>`. The pointer self-gates on
* recordings, so the shared `insightsEcgList` cache is pre-seeded with one
* recording to exercise the mounted path.
*/
vi.mock("next/dynamic", () => ({
default: () => {
const Stub = () => <div data-slot="healthkit-chart-stub" />;
Stub.displayName = "HealthChartStub";
return Stub;
},
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { timezone: "UTC", dateOfBirth: null, gender: null },
isAuthenticated: true,
}),
}));
vi.mock("@/hooks/use-insights-layout-prefs", () => ({
useInsightsLayoutPrefs: () => ({ layout: null, compareBaseline: false }),
}));
const analyticsMock = vi.fn();
vi.mock("@/hooks/use-insights-analytics", () => ({
useInsightsAnalytics: () => analyticsMock(),
}));
const metricStatusMock = vi.fn();
vi.mock("@/hooks/use-insight-status", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/hooks/use-insight-status")>();
return {
...actual,
useInsightMetricStatus: (...args: unknown[]) => metricStatusMock(...args),
};
});
import RestingPulsePage from "@/app/insights/resting-pulse/page";
import HrvPage from "@/app/insights/hrv/page";
function render(node: React.ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
// Seed the ECG list the cross-link reads so its data-availability gate opens.
client.setQueryData(queryKeys.insightsEcgList(), {
recordings: [{ id: "a", classification: null }],
hasRecordings: true,
});
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
beforeEach(() => {
analyticsMock.mockReset();
metricStatusMock.mockReset();
// Data-bearing branch for both metric pages.
analyticsMock.mockReturnValue({
data: {
summaries: {
RESTING_HEART_RATE: { count: 5 },
HEART_RATE_VARIABILITY: { count: 5 },
},
},
isEmpty: false,
isLoading: false,
error: null,
refetch: vi.fn(),
});
metricStatusMock.mockReturnValue({ data: undefined, isLoading: false });
});
describe("EcgCrossLink mount on Heart sub-pages (H1)", () => {
it("mounts the ECG cross-link on the resting-pulse page", () => {
const html = render(<RestingPulsePage />);
expect(html).toContain('data-slot="ecg-cross-link"');
expect(html).toContain('href="/insights/ecg"');
});
it("mounts the ECG cross-link on the HRV page", () => {
const html = render(<HrvPage />);
expect(html).toContain('data-slot="ecg-cross-link"');
expect(html).toContain('href="/insights/ecg"');
});
});
+370
View File
@@ -0,0 +1,370 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import {
Activity,
ChevronRight,
Droplet,
Ear,
Footprints,
HeartPulse,
Moon,
Pill,
Scale,
Siren,
Sun,
Waves,
type LucideIcon,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { TileHeader } from "@/components/insights/tile-header";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { useAuth } from "@/hooks/use-auth";
import { useWorkouts } from "@/hooks/use-workouts";
import { useTranslations } from "@/lib/i18n/context";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import {
hasMetricData,
type InsightInputs,
} from "@/lib/insights/metric-availability";
import {
CATALOG_GROUP_HEADER_KEYS,
CATALOG_GROUP_ORDER,
catalogEntriesByGroup,
type CatalogEntry,
} from "@/lib/insights/metric-catalog";
import type { ManagerGroup } from "@/lib/insights/sub-page-metric";
/**
* `/insights/catalog` — "what can HealthLog track" (2026-07-17
* discoverability audit, recommendation R1).
*
* A single, on-purpose surface: every metric domain HealthLog supports,
* grouped, each row showing its name, whether THIS account has data for
* it yet, what supplies it (device-concrete, vendor-neutral), and — when
* absent and a device could supply it — a quiet CTA into
* `/settings/integrations`. This is deliberately NOT surfaced in the
* daily nav: metrics with no data stay hidden from the dashboard /
* Insights tab strip by design (`metric-availability.ts` — "a metric
* with zero observations doesn't surface a navigation target the user
* can't act on"), so an account with a sparse device kit would otherwise
* have no reachable evidence a capability exists at all. This page is
* that evidence, reached only when the user navigates here on purpose
* (the quiet "All metrics" pill at the tail of the Insights tab strip,
* see `insights-tab-strip.tsx`).
*
* Presence per row reuses the EXACT gating inputs the tab strip / nav
* pills already compute (`hasMetricData` over the shared analytics +
* comprehensive + workouts + nutrients reads) — this page adds no new
* server route and no new derivation of "does the user have this metric".
* The ECG row is the one exception: ECG carries no `MeasurementType`, so
* its presence comes from the same `hasRecordings` probe the overview's
* `<EcgSection>` already reads.
*/
interface ComprehensivePayload {
moodSummary: { count: number } | null;
medications: Array<{ id: string }>;
}
const GROUP_ICONS: Record<ManagerGroup, LucideIcon> = {
vitals: Activity,
body: Scale,
activity: Footprints,
sleep: Moon,
heart: HeartPulse,
hearing: Ear,
environment: Sun,
metabolic: Droplet,
mood: Waves,
events: Pill,
};
function StatusBadge({ present }: { present: boolean }) {
const { t } = useTranslations();
if (present) {
return (
<Badge
variant="outline"
className="border-success/40 bg-success/15 text-success shrink-0"
>
{t("metricCatalog.statusPresent")}
</Badge>
);
}
return (
<span className="text-muted-foreground shrink-0 text-xs">
{t("metricCatalog.statusAbsent")}
</span>
);
}
/**
* Row placeholder shown while the availability probes are still
* in flight — same anatomy as a loaded `CatalogRow` (name line + a
* trailing action-sized block) so the group cards don't reflow when
* the real status lands. Never renders the "Not tracked yet" copy or
* a CTA — an in-flight probe is not the same thing as a confirmed
* absence (M1, `.planning/audits/2026-07-18-qa-ui.md`).
*/
function CatalogRowSkeleton() {
return (
<div
data-slot="catalog-row-skeleton"
className="flex items-start justify-between gap-3 py-2"
>
<div className="min-w-0 flex-1 space-y-1.5">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48" />
</div>
<Skeleton className="h-8 w-20 shrink-0" />
</div>
);
}
function CatalogRow({
entry,
present,
}: {
entry: CatalogEntry;
present: boolean | null;
}) {
const { t } = useTranslations();
if (entry.kind === "info") {
return (
<div data-slot="catalog-row-info" className="flex items-start gap-2 py-2">
<Siren className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-foreground text-sm font-medium">
{t(entry.nameKey)}
</p>
<p className="text-muted-foreground text-xs">{t(entry.sourceKey)}</p>
</div>
</div>
);
}
const isPresent = present === true;
return (
<div
data-slot="catalog-row"
data-metric-id={entry.id}
className="flex items-start justify-between gap-3 py-2"
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-foreground text-sm font-medium">
{t(entry.nameKey)}
</span>
<StatusBadge present={isPresent} />
</div>
<p className="text-muted-foreground text-xs">{t(entry.sourceKey)}</p>
</div>
<div className="shrink-0">
{isPresent && entry.href ? (
<Button size="sm" variant="ghost" asChild>
<Link href={entry.href}>
{t("metricCatalog.viewCta")}
<ChevronRight className="size-3.5" />
</Link>
</Button>
) : !isPresent && entry.connectable ? (
<Button size="sm" variant="outline" asChild>
<Link href="/settings/integrations">
{t("metricCatalog.connectCta")}
</Link>
</Button>
) : !isPresent && entry.manualHref && entry.manualCtaKey ? (
<Button size="sm" variant="outline" asChild>
<Link href={entry.manualHref}>{t(entry.manualCtaKey)}</Link>
</Button>
) : null}
</div>
</div>
);
}
export default function MetricCatalogPage() {
const { t } = useTranslations();
const { isAuthenticated, user } = useAuth();
const analyticsQuery = useAnalyticsQuery({ slice: "summaries" });
const comprehensiveQuery = useQuery({
queryKey: queryKeys.insightsComprehensive(),
queryFn: () => apiGet<ComprehensivePayload>("/api/insights/comprehensive"),
enabled: isAuthenticated,
});
const workoutsProbe = useWorkouts({ limit: 1 });
const nutrientsModuleEnabled = user?.modules?.nutrients === true;
const nutrientsProbe = useQuery({
queryKey: queryKeys.nutrientIntake(1),
queryFn: () =>
apiGet<{ nutrients: Array<{ nutrient: string }> }>(
"/api/nutrients?days=1",
),
enabled: isAuthenticated && nutrientsModuleEnabled,
});
const ecgQuery = useQuery({
queryKey: queryKeys.insightsEcgList(),
queryFn: () => apiGet<{ hasRecordings: boolean }>("/api/insights/ecg"),
enabled: isAuthenticated,
});
const availability: InsightInputs | undefined = isAuthenticated
? {
summaries: analyticsQuery.data?.summaries,
hasMood: (comprehensiveQuery.data?.moodSummary?.count ?? 0) > 0,
hasMedication: (comprehensiveQuery.data?.medications?.length ?? 0) > 0,
hasWorkouts: (workoutsProbe.data?.workouts.length ?? 0) > 0,
hasNutrients: (nutrientsProbe.data?.nutrients.length ?? 0) > 0,
}
: undefined;
const grouped = catalogEntriesByGroup();
function isPresent(entry: CatalogEntry): boolean | null {
if (entry.kind === "info") return null;
if (entry.id === "ecg") return ecgQuery.data?.hasRecordings === true;
if (!availability || !entry.metric) return false;
return hasMetricData(entry.metric, availability);
}
// M1 fix (`.planning/audits/2026-07-18-qa-ui.md`) — every probe backing
// `availability` is async; while any of them is still in flight,
// `hasMetricData()` reads an undefined summary and reports "absent" for
// every metric. Gate the rows on the probes' aggregate loading/error
// state instead of letting a cold load or a failed batch masquerade as
// an honest "Not tracked yet".
const isLoading = Boolean(
isAuthenticated &&
(analyticsQuery.isLoading ||
comprehensiveQuery.isLoading ||
workoutsProbe.isLoading ||
ecgQuery.isLoading ||
(nutrientsModuleEnabled && nutrientsProbe.isLoading)),
);
const isError = Boolean(
analyticsQuery.isError ||
comprehensiveQuery.isError ||
workoutsProbe.isError ||
ecgQuery.isError ||
(nutrientsModuleEnabled && nutrientsProbe.isError),
);
function retryAll() {
void analyticsQuery.refetch();
void comprehensiveQuery.refetch();
workoutsProbe.refetch();
void ecgQuery.refetch();
if (nutrientsModuleEnabled) void nutrientsProbe.refetch();
}
if (isError) {
return (
<SubPageShell
title={t("metricCatalog.title")}
description={t("metricCatalog.description")}
>
<QueryErrorCard
description={t("metricCatalog.loadError")}
onRetry={retryAll}
/>
</SubPageShell>
);
}
return (
<SubPageShell
title={t("metricCatalog.title")}
description={t("metricCatalog.description")}
>
<div className="space-y-6">
{CATALOG_GROUP_ORDER.map((group) => {
const entries = grouped.get(group) ?? [];
if (entries.length === 0) return null;
const Icon = GROUP_ICONS[group];
// Nutrients rides the metabolic group but needs a module-off
// branch (B1 — the tab-strip pill requires the module ON before
// it ever probes for rows, so a module-off account never saw
// the in-context enable CTA; this row is the fix).
return (
<Card
key={group}
data-slot="catalog-group"
className="gap-2 py-3 md:py-4"
>
<CardHeader>
<TileHeader
icon={Icon}
title={t(CATALOG_GROUP_HEADER_KEYS[group])}
/>
</CardHeader>
<CardContent>
<div className="divide-border divide-y">
{entries.map((entry) => {
if (entry.id === "nutrients" && !nutrientsModuleEnabled) {
return (
<div
key={entry.id}
data-slot="catalog-row"
data-metric-id="nutrients"
className="flex items-start justify-between gap-3 py-2"
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-foreground text-sm font-medium">
{t(entry.nameKey)}
</span>
<span className="text-muted-foreground shrink-0 text-xs">
{t("metricCatalog.statusModuleOff")}
</span>
</div>
<p className="text-muted-foreground text-xs">
{t("nutrients.page.moduleOffDescription")}
</p>
</div>
<div className="shrink-0">
<Button size="sm" variant="outline" asChild>
<Link href="/settings/modules">
{t("nutrients.page.moduleOffCta")}
</Link>
</Button>
</div>
</div>
);
}
if (isLoading && entry.kind !== "info") {
return <CatalogRowSkeleton key={entry.id} />;
}
return (
<CatalogRow
key={entry.id}
entry={entry}
present={isPresent(entry)}
/>
);
})}
</div>
</CardContent>
</Card>
);
})}
</div>
</SubPageShell>
);
}
@@ -0,0 +1,88 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
/**
* v1.30 (UX/IA audit H1) — `/insights/ecg` routed sub-page.
*
* The page reuses `<EcgSection>` (verbatim, `hideHeading` set so the shell
* owns the heading) and self-gates to an empty state when the account has no
* recordings. `useAuth` is mocked; the shared `insightsEcgList` cache is
* pre-seeded so both the page probe and the section read the same payload.
*/
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true, user: null }),
}));
import InsightsEcgPage from "../page";
interface EcgRecord {
id: string;
recordedAt: string;
durationSeconds: number | null;
samplingFrequency: number;
sampleCount: number;
averageHeartRate: number | null;
lead: string | null;
classification: "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null;
source: string;
hasWaveform: boolean;
}
function seededRecording(): EcgRecord {
return {
id: "rec-1",
recordedAt: "2026-07-10T08:00:00.000Z",
durationSeconds: 30,
samplingFrequency: 300,
sampleCount: 9000,
averageHeartRate: 62,
lead: null,
classification: null,
source: "withings",
hasWaveform: true,
};
}
function render(data: { recordings: EcgRecord[]; hasRecordings: boolean }) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
client.setQueryData(queryKeys.insightsEcgList(), data);
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<InsightsEcgPage />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("/insights/ecg page (H1)", () => {
it("renders the EcgSection with its disclaimer when recordings exist", () => {
const html = render({
recordings: [seededRecording()],
hasRecordings: true,
});
// The reused section + its load-bearing non-diagnostic disclaimer render.
expect(html).toContain('data-slot="ecg-section"');
expect(html).toContain('data-slot="ecg-card"');
expect(html).toContain('data-slot="ecg-disclaimer"');
// The shell owns the page heading (`<h1>`); the section's own
// `<SectionHeading>` (`<h2>`) is suppressed via `hideHeading`.
expect(html).toContain('id="insights-subpage-title"');
expect(html).not.toContain("<h2");
});
it("shows the empty state (no section) when the account has no recordings", () => {
const html = render({ recordings: [], hasRecordings: false });
expect(html).not.toContain('data-slot="ecg-section"');
expect(html).toContain("No ECG recordings yet");
// The empty-state CTA points at the integrations surface.
expect(html).toContain('href="/settings/integrations"');
});
});
+82
View File
@@ -0,0 +1,82 @@
"use client";
import Link from "next/link";
import { Activity } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { Button } from "@/components/ui/button";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { EcgSection } from "@/components/insights/ecg-section";
/**
* v1.30 — `/insights/ecg` (UX/IA audit finding H1).
*
* Routed home for the ECG recording surface. Before this page the only
* inbound pointer to a user's ECGs was a cross-link card on the spot-pulse
* page; hiding the overview ECG teaser in "Anpassen" left no route back at
* all. This page gives ECG its own address, reached from the Heart-group
* pill in the tab strip (gated on `hasRecordings`) and the metric catalog.
*
* The surface itself is the existing `<EcgSection>`, reused verbatim — its
* non-diagnostic disclaimer + waveform logic are load-bearing and untouched.
* We only suppress its internal `<SectionHeading>` (via `hideHeading`) because
* `<SubPageShell>` already renders the page heading, mirroring how every other
* routed sub-page lets the shell own the `<h1>`.
*
* The overview `<EcgSection>` teaser on `/insights` stays as-is.
*/
export default function InsightsEcgPage() {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
// Reuse the SAME query cell `<EcgSection>` reads (`insightsEcgList`) so the
// page and the section share one cache entry. The page only needs the
// `hasRecordings` flag to choose between the empty state and the section.
const { data, isLoading } = useQuery({
queryKey: queryKeys.insightsEcgList(),
queryFn: () => apiGet<{ hasRecordings: boolean }>("/api/insights/ecg"),
enabled: isAuthenticated,
});
// Data-availability empty state — a direct URL hit (or deleted recordings)
// lands here without a section to show. Mirrors the sub-page convention:
// a calm one-line scaffold + a connect-a-device CTA, never a blank surface.
// While the probe is in flight we render nothing extra (the section self-
// gates too), so first paint stays quiet rather than flashing an empty card.
if (!isLoading && data && !data.hasRecordings) {
return (
<SubPageShell
title={t("insights.ecg.sectionTitle")}
description={t("insights.subPage.ecgDescription")}
>
<MetricEmptyState
icon={<Activity className="size-6" />}
title={t("insights.ecg.emptyTitle")}
description={t("insights.ecg.emptyDescription")}
cta={
<Button size="sm" asChild>
<Link href="/settings/integrations">
{t("insights.ecg.emptyCta")}
</Link>
</Button>
}
coachPrefill={null}
/>
</SubPageShell>
);
}
return (
<SubPageShell
title={t("insights.ecg.sectionTitle")}
description={t("insights.subPage.ecgDescription")}
>
<EcgSection enabled={isAuthenticated} hideHeading />
</SubPageShell>
);
}
+4
View File
@@ -3,6 +3,7 @@
import { Activity } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
import { EcgCrossLink } from "@/components/insights/ecg-cross-link";
/**
* v1.4.32 — `/insights/hrv`.
@@ -33,6 +34,9 @@ export default function InsightsHrvPage() {
emptyStateIcon={<Activity className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any HRV data yet — what does heart-rate variability tell me, and how do I capture it?"
// S10 / H1 — device-attributed pointer into the ECG viewer, in the
// HRV / heart context. Self-gates to nothing without recordings.
afterAssessment={<EcgCrossLink />}
/>
);
}
+28 -2
View File
@@ -3,7 +3,7 @@
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import Link from "next/link";
import { Smile } from "lucide-react";
import { ArrowRight, Smile } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
@@ -41,7 +41,7 @@ interface ComprehensiveMoodData {
}
export default function InsightsStimmungPage() {
const { isAuthenticated } = useAuth();
const { user, isAuthenticated } = useAuth();
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
@@ -101,6 +101,32 @@ export default function InsightsStimmungPage() {
The heatmap and the assessment are lifted out of `MoodInsightsSections`
into their own regions so they land in this exact order; all three
regions share one `moodInsights` query (TanStack dedups the fetch). */}
{/* 2026-07-17 UX/IA audit M9 — mood, the mental-wellbeing screeners, and
this insights page form one mental-health domain; this cross-links
into the other two so the trend a user reads here can lead
somewhere. */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
<Link
href="/mood"
data-slot="insights-mood-log-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 text-sm underline-offset-4 transition-colors hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("insights.mood.logLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
{user?.modules?.mentalHealth === true ? (
<Link
href="/mental-wellbeing"
data-slot="insights-mood-mental-wellbeing-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 text-sm underline-offset-4 transition-colors hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("insights.mood.mentalWellbeingLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
) : null}
</div>
<MoodInsightsSections region="heatmap" />
{/* No `<MetricRangeControls>` here: mood is event-driven, not a
+112
View File
@@ -0,0 +1,112 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Leaf } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { HydrationCard } from "@/components/insights/nutrients/hydration-card";
import { CaffeineCard } from "@/components/insights/nutrients/caffeine-card";
import { MicronutrientsCard } from "@/components/insights/nutrients/micronutrients-card";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { NutrientIntakeOverview } from "@/components/insights/nutrients/types";
const WINDOW_DAYS = 30;
/**
* `/insights/nutrients` — hydration + micronutrients (v1.29).
*
* Custom sub-page (NOT `HealthKitMetricPage` — that scaffold is
* Measurement-backed; this store is `NutrientIntakeDay`). Three cards:
* hydration hero (always renders once the module is on — even at 0 mL,
* the quick-add entry point IS the first-run invitation), caffeine
* (self-gates to nothing without data), micronutrients (self-gates to
* an EmptyState without data). Degradation ladder:
*
* - module off → one EmptyState with an in-context enable CTA. The
* module STAYS opt-in (2026-07-17 memo — the HealthKit read prompt
* on the device is not visible consent to a server / Coach holding
* a supplement pattern); this page just makes the toggle
* discoverable in context instead of buried in Settings.
* - module on, zero rows anywhere in the window → one EmptyState
* explaining where data comes from (phone health-app sync or
* manual water entry) instead of three near-empty cards.
* - otherwise → the three-card spine.
*/
export default function InsightsNutrientsPage() {
const { t } = useTranslations();
const { user } = useAuth();
const queryClient = useQueryClient();
const nutrientsEnabled = user?.modules?.nutrients === true;
const overview = useQuery({
queryKey: queryKeys.nutrientIntake(WINDOW_DAYS),
queryFn: () =>
apiGet<NutrientIntakeOverview>(`/api/nutrients?days=${WINDOW_DAYS}`),
enabled: nutrientsEnabled,
});
const enableModule = useMutation({
mutationFn: () => apiPatch("/api/auth/me/modules", { nutrients: true }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
},
onError: () => toast.error(t("nutrients.hydration.quickAddError")),
});
if (!nutrientsEnabled) {
return (
<SubPageShell title={t("nutrients.page.title")}>
<EmptyState
icon={<Leaf className="size-6" />}
title={t("nutrients.page.moduleOffTitle")}
description={t("nutrients.page.moduleOffDescription")}
ctaSize="lg"
action={
<Button
size="sm"
onClick={() => enableModule.mutate()}
disabled={enableModule.isPending}
data-slot="nutrients-enable-module"
>
{t("nutrients.page.moduleOffCta")}
</Button>
}
/>
</SubPageShell>
);
}
const hasAnyData = (overview.data?.nutrients.length ?? 0) > 0;
if (!overview.isLoading && !hasAnyData) {
return (
<SubPageShell
title={t("nutrients.page.title")}
description={t("nutrients.page.description")}
>
<EmptyState
icon={<Leaf className="size-6" />}
title={t("nutrients.page.emptyTitle")}
description={t("nutrients.page.emptyDescription")}
/>
</SubPageShell>
);
}
return (
<SubPageShell
title={t("nutrients.page.title")}
description={t("nutrients.page.description")}
>
<HydrationCard />
<CaffeineCard />
<MicronutrientsCard />
</SubPageShell>
);
}
+21 -2
View File
@@ -1,7 +1,7 @@
"use client";
import { Fragment, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { Fragment, useCallback, type ReactNode } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import Link from "next/link";
import { SlidersHorizontal, TrendingUp } from "lucide-react";
@@ -11,6 +11,8 @@ import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import {
orderedVisibleSectionIds,
type InsightsSectionId,
@@ -263,6 +265,20 @@ export default function InsightsPage() {
const { isAuthenticated, user } = useAuth();
const mounted = useMounted();
const { t } = useTranslations();
const queryClient = useQueryClient();
// v1.30.1 M12 — the overview fans out into ~6 independent queries
// (comprehensive / advisor / analytics / snapshot / derived batch /
// layout) mounted by different hooks below, so there's no single
// `refetch()` that covers the page. Refetching every currently-mounted
// ("active") query is the same blanket approach the audit's fallback
// suggests for PWA-resume staleness, and it's the right shape here
// regardless of resume vs. an explicit pull.
const refreshInsights = useCallback(
() => queryClient.refetchQueries({ type: "active" }),
[queryClient],
);
const pull = usePullToRefresh({ onRefresh: refreshInsights });
// v1.4.33 IW9 — scroll-to-top on route mount centralised in the
// shared `useScrollResetOnRoute()` hook. The mother page + the
@@ -371,6 +387,7 @@ export default function InsightsPage() {
icon={<TrendingUp className="size-6" />}
title={t("insights.emptyTitle")}
description={t("insights.emptyDescription")}
ctaSize="lg"
action={
<Button size="sm" asChild>
<Link href="/measurements">
@@ -530,6 +547,7 @@ export default function InsightsPage() {
// zero-gap). Each section owns its OWN `SectionHeading` + `space-y-3` to its
// card, so the rhythm is even regardless of which sections are present.
<div className="space-y-6">
<PullToRefreshIndicator {...pull} />
<HeroStrip
briefing={briefingPayload}
updatedAt={heroStripUpdatedAt}
@@ -568,6 +586,7 @@ export default function InsightsPage() {
icon={<SlidersHorizontal className="size-6" />}
title={t("insights.editMode.emptyTitle")}
description={t("insights.editMode.emptyDescription")}
ctaSize="lg"
action={
<Button size="sm" asChild>
<Link href="/settings/layout/insights">
+1 -1
View File
@@ -228,7 +228,7 @@ export default function InsightsPulsPage() {
<Link
href="/insights/cardio-fitness"
data-slot="vo2-cardio-link"
className="bg-card hover:bg-accent/40 focus-visible:ring-ring/50 block space-y-1.5 rounded-xl border p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none"
className="bg-card hover:bg-accent/40 focus-visible:ring-ring/50 block space-y-1.5 rounded-xl border p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none md:p-6"
>
<TileHeader
icon={Gauge}
+16 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { Loader2 } from "lucide-react";
import Link from "next/link";
import { ArrowRight, Loader2 } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
@@ -46,6 +47,20 @@ export default function InsightsRecoveryPage() {
explainerMetric="recoveryPage"
coachLaunch
>
{/* 2026-07-17 UX/IA audit M4 — reverse direction of the cross-link added
to the score-anatomy page: this composite wearable page shares the
"Recovery" nav label with the RECOVERY_SCORE anatomy view at
`/insights/scores/recovery`, so a user who arrived here looking for
the score ring / contributor breakdown gets a pointer instead of a
dead end. */}
<Link
href="/insights/scores/recovery"
data-slot="recovery-anatomy-cross-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 -mt-2 inline-flex items-center gap-1.5 text-sm transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("insights.recovery.scoreCrossLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
<RecoverySection />
</SubPageShell>
);
+4
View File
@@ -3,6 +3,7 @@
import { Heart } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
import { EcgCrossLink } from "@/components/insights/ecg-cross-link";
import { useTranslations } from "@/lib/i18n/context";
/**
@@ -30,6 +31,9 @@ export default function InsightsRestingHrPage() {
emptyStateIcon={<Heart className="size-6" />}
emptyStateCtaType={null}
coachPrefill={t("insights.restingHr.coachPrefill")}
// S10 / H1 — device-attributed pointer into the ECG viewer, in the
// resting-HR context. Self-gates to nothing without recordings.
afterAssessment={<EcgCrossLink />}
/>
);
}
+18
View File
@@ -1,6 +1,8 @@
"use client";
import { useParams } from "next/navigation";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { BackLink } from "@/components/ui/back-link";
@@ -94,6 +96,22 @@ export default function CompositeScorePage() {
}
coachLaunch
>
{/* 2026-07-17 UX/IA audit M4 — this anatomy view and the `/insights/recovery`
composite-wearable page both used to render as plain "Recovery", a
same-name/different-content trap: the score ring here promised one
thing, the nav pill led somewhere else with no pointer back. The
label above is now "Recovery score" (matches `measurements.typeRecoveryScore`);
this line closes the loop with an explicit cross-link. */}
{metric === "RECOVERY_SCORE" ? (
<Link
href="/insights/recovery"
data-slot="recovery-score-cross-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 -mt-2 inline-flex items-center gap-1.5 text-sm transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("insights.derived.scores.recoveryCrossLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
) : null}
<CompositeScoreAnatomy metric={metric} />
</SubPageShell>
);
+5 -1
View File
@@ -78,7 +78,11 @@ export default function InsightsWorkoutDetailPage({
<WorkoutDetailStats workout={data} />
<WorkoutDetailRoute workout={data} />
<WorkoutDetailHRChart workout={data} />
<CoachLaunchButton />
{/* 2026-07-17 UX-flows audit F6-1 — the button used to open an
unscoped chat; the user had to restate which session they
meant. `workouts` narrows the snapshot the first turn reads
(pairs with the coach-scope param added for F4-1). */}
<CoachLaunchButton scope={{ metric: "workouts" }} />
</>
)}
</SubPageShell>
+9
View File
@@ -177,6 +177,15 @@ export default function LabsPage() {
queryKey: queryKeys.labResults(),
});
}}
// v1.30.1 H3 — "Save & add another" keeps the sheet open; the
// form clears the reading itself, this callback just refreshes
// the list underneath so the just-saved row is there once the
// user is done and closes the sheet.
onSavedKeepOpen={() => {
queryClient.invalidateQueries({
queryKey: queryKeys.labResults(),
});
}}
onCancel={() => setDialogOpen(false)}
/>
</ResponsiveSheet>
+4
View File
@@ -93,6 +93,10 @@ const READ_TOOLS = [
"get_medication_schedule",
"get_integration_status",
"get_preventive_care",
// v1.30 coverage review — nutrients, intraday pulse, ECG metadata.
"get_nutrients",
"get_intraday_pulse",
"get_ecg_recordings",
].sort();
/** A valid, narrow-scope (`health:read`) token resolution. */
+48 -2
View File
@@ -1,12 +1,18 @@
"use client";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
invalidateKeys,
medicationDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { MedicationWizardDialog } from "@/components/medications/wizard/medication-wizard-dialog";
import { MedicationCard } from "@/components/medications/medication-card";
import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card";
@@ -169,6 +175,14 @@ export default function MedicationsPage() {
// the param from the URL so a manual close + refresh stays closed.
const shouldOpenFromUrl = searchParams?.get("new") === "1";
const [dialogOpen, setDialogOpen] = useState(shouldOpenFromUrl);
// v1.29.x — the Today digest's "Log dose" action deep-links straight to
// the overdue medication (`/medications?highlight=<id>`) instead of the
// bare list. Read once on mount; a scroll effect below (once the list
// resolves) brings the target card into view and rings it briefly, then
// clears the state so the ring doesn't linger indefinitely.
const [highlightId, setHighlightId] = useState<string | null>(
() => searchParams?.get("highlight") ?? null,
);
// v1.14.0 — the medications-page "Add" choice. The top button now offers
// two paths: log an intake (incl. a backdated one) against an existing
// medication, or create a new medication (the existing wizard).
@@ -213,6 +227,32 @@ export default function MedicationsPage() {
refetchOnMount: "always",
});
// v1.30.1 M12 — pull-to-refresh parity with labs/checkups/mood/measurements.
// Medications is one of the sharpest PWA-resume-staleness surfaces (a
// background-refreshed take/skip from a notification, or a cross-device
// write, wouldn't otherwise show up until the 5-min staleTime lapses or a
// full remount re-triggers `refetchOnMount: "always"` above).
const refreshMedications = useCallback(
() => invalidateKeys(queryClient, medicationDependentKeys),
[queryClient],
);
const pull = usePullToRefresh({ onRefresh: refreshMedications });
// v1.29.x — once the highlighted card is in the DOM, scroll it into view
// and clear the highlight state after a few seconds so the ring is a
// one-time "you tapped here" cue, not a permanent tint (mirrors the
// vault's upload-duplicate flash-highlight, `HIGHLIGHT_MS` in
// `use-document-upload.ts`).
useEffect(() => {
if (!highlightId || !medications?.length) return;
const card = document.querySelector(
`[data-medication-id="${highlightId}"]`,
);
card?.scrollIntoView({ behavior: "smooth", block: "center" });
const timer = setTimeout(() => setHighlightId(null), 3000);
return () => clearTimeout(timer);
}, [highlightId, medications]);
// v1.16.11 — the same reminder-thresholds read the cards make (shared
// key + cache), so the header's due-set derivation tiers late /
// very_late exactly like the card pills. Null on failure keeps the
@@ -340,6 +380,7 @@ export default function MedicationsPage() {
return (
<div className="space-y-6">
<PullToRefreshIndicator {...pull} />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<h1
@@ -468,6 +509,7 @@ export default function MedicationsPage() {
<MedicationTable
activeMedications={activeMeds}
inactiveMedications={inactiveMeds}
highlightId={highlightId}
/>
) : (
<div className="space-y-6">
@@ -485,6 +527,7 @@ export default function MedicationsPage() {
medication={med}
onEdit={openEdit}
onOpenHistory={openHistory}
highlighted={highlightId === med.id}
/>
) : (
<MedicationCard
@@ -492,6 +535,7 @@ export default function MedicationsPage() {
medication={med}
onEdit={openEdit}
onOpenHistory={openHistory}
highlighted={highlightId === med.id}
/>
),
)}
@@ -513,6 +557,7 @@ export default function MedicationsPage() {
medication={med}
onEdit={openEdit}
onOpenHistory={openHistory}
highlighted={highlightId === med.id}
/>
) : (
<MedicationCard
@@ -520,6 +565,7 @@ export default function MedicationsPage() {
medication={med}
onEdit={openEdit}
onOpenHistory={openHistory}
highlighted={highlightId === med.id}
/>
),
)}
+17 -1
View File
@@ -13,7 +13,7 @@ import { PageHeader } from "@/components/ui/page-header";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Plus, Wrench } from "lucide-react";
import { ArrowRight, Plus, Wrench } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useTranslations } from "@/lib/i18n/context";
@@ -120,6 +120,22 @@ export default function MoodPage() {
</ResponsiveSheet>
<MoodList onAddFirst={() => setDialogOpen(true)} />
{/* 2026-07-17 UX/IA audit M9 — mood tracking, the mental-wellbeing
screeners, and the mood insights page form one mental-health
domain but used to be three unconnected islands. A quiet pointer
here (module-gated — no nav change) rather than a merge: capture
vs. screener stay two defensible surfaces, just cross-linked. */}
{user?.modules?.mentalHealth === true ? (
<Link
href="/mental-wellbeing"
data-slot="mood-mental-wellbeing-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 text-sm underline-offset-4 transition-colors hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("mood.mentalWellbeingLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
) : null}
</div>
);
}
+1 -1
View File
@@ -99,7 +99,7 @@ export default async function OnboardingStepPage({ params }: PageProps) {
{completed ? (
<section
aria-labelledby="onboarding-welcomeback-title"
className="space-y-5"
className="space-y-4"
>
<h1
id="onboarding-welcomeback-title"
+67 -4
View File
@@ -1,9 +1,11 @@
"use client";
import React, { Suspense, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import React, { Suspense, useCallback, useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import {
Activity,
Bone,
@@ -12,6 +14,7 @@ import {
Dumbbell,
Footprints,
Gauge,
GlassWater,
Heart,
HeartPulse,
Moon,
@@ -34,6 +37,7 @@ import dynamic from "next/dynamic";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
@@ -173,6 +177,9 @@ const TILE_INSIGHT_HREF: Record<string, string> = {
muscleMass: "/insights/muscle-mass",
totalBodyWater: "/insights/body-water",
boneMass: "/insights/bone-mass",
// v1.29 — fluid intake links to the nutrients surface (hydration hero +
// quick-add), not a Measurement-backed detail page.
waterIntake: "/insights/nutrients",
};
function tileInsightHref(id: string): string | null {
@@ -185,8 +192,23 @@ export default function DashboardPageClient() {
const mounted = useMounted();
const { t } = useTranslations();
const fmt = useFormatters();
const queryClient = useQueryClient();
const [quickEntryDialog, setQuickEntryDialog] =
useState<QuickEntryDialog>(null);
// v1.30.1 M12 — pull-to-refresh parity for the dashboard, named as one
// of the sharpest PWA-resume-staleness surfaces in the audit (it
// already opts back into `refetchOnWindowFocus`, but a PWA resumed
// from the home screen/app-switcher, not a browser tab focus event,
// is the more common mobile path and that never fires a focus event).
// The dashboard fans out into a dozen-plus independent per-tile/chart
// queries, so — like `/insights` — refetching every currently-active
// query is the only tractable "refresh everything visible" shape.
const refreshDashboard = useCallback(
() => queryClient.refetchQueries({ type: "active" }),
[queryClient],
);
const pull = usePullToRefresh({ onRefresh: refreshDashboard });
// v1.4.29 M5 — the three inline dashboard queries default to a
// 0-ms `staleTime`, so a tab-focus-and-return triggered a refetch
// storm. None of these are real-time data; minute-scale staleness
@@ -394,6 +416,10 @@ export default function DashboardPageClient() {
const muscleMassSummary = data?.summaries?.MUSCLE_MASS;
const bodyWaterSummary = data?.summaries?.TOTAL_BODY_WATER;
const boneMassSummary = data?.summaries?.BONE_MASS;
// v1.29 — fluid intake strip tile. Synthetic summary key, not a real
// MeasurementType — derived server-side from `NutrientIntakeDay`
// (nutrient="water", summed across sources); see `dashboard/snapshot.ts`.
const waterIntakeSummary = data?.summaries?.NUTRIENT_WATER;
const moodSummary = moodData?.summary;
// Resolve full dashboard layout — controls visibility + order of every widget
@@ -478,6 +504,8 @@ export default function DashboardPageClient() {
const hasMuscleMass = (muscleMassSummary?.count ?? 0) > 0;
const hasBodyWater = (bodyWaterSummary?.count ?? 0) > 0;
const hasBoneMass = (boneMassSummary?.count ?? 0) > 0;
// v1.29 — data floor for the fluid-intake strip tile.
const hasWaterIntake = (waterIntakeSummary?.count ?? 0) > 0;
/**
* v1.4.33 F4 — gate the BD-Zielbereich tile so a literal "0,0 %"
* placeholder doesn't ride on the dashboard when none of the user's
@@ -526,6 +554,9 @@ export default function DashboardPageClient() {
const showMuscleMassTile = isTileVisible("muscleMass") && hasMuscleMass;
const showBodyWaterTile = isTileVisible("totalBodyWater") && hasBodyWater;
const showBoneMassTile = isTileVisible("boneMass") && hasBoneMass;
// v1.29 — fluid intake strip tile gate: layout toggle AND a non-empty
// summary, same precedent as the vitals/body-composition tiles above.
const showWaterIntakeTile = isTileVisible("waterIntake") && hasWaterIntake;
const showBpInTargetTile = isTileVisible("bpInTarget") && hasBpInTarget;
// Chart (lower row) gates — controlled by the legacy `visible` flag.
@@ -771,14 +802,18 @@ export default function DashboardPageClient() {
return (
<div className="space-y-6">
<PullToRefreshIndicator {...pull} />
<DashboardHeader onQuickEntry={setQuickEntryDialog} />
{/* S2 — the Today hero, promoted above the tile strip (MARC sign-off
decision 1). Renders the S1 daily digest: the day's read, the
honest score/freshness face, and the worth-a-look rail. Shimmer
skeleton while the cached digest loads; a genuinely empty account
degrades to nothing (the hero itself returns null). The dense
tile grid + charts below are untouched. */}
degrades to nothing (the hero itself returns null). A FAILED fetch
is a distinct state from that honest empty degrade — it renders
`<QueryErrorCard>` with a retry action rather than silently
falling through to nothing. The dense tile grid + charts below
are untouched. */}
{insightsEnabled &&
// `!mounted` pins the SSR pass AND the first hydration render to the
// skeleton (v1.16.4 pattern): the digest query rehydrates from the
@@ -787,6 +822,8 @@ export default function DashboardPageClient() {
// skeleton — a text-content hydration mismatch (React #418).
(!mounted || digestQuery.isLoading ? (
<TodayHeroSkeleton />
) : digestQuery.isError ? (
<QueryErrorCard onRetry={() => digestQuery.refetch()} />
) : digestQuery.data ? (
<TodayHero digest={digestQuery.data} />
) : null)}
@@ -1280,6 +1317,32 @@ export default function DashboardPageClient() {
),
});
}
// v1.29 — fluid intake strip tile. Neutral sentiment: more water is
// not unambiguously "better" (over-hydration is a real risk), so no
// up/down framing — same posture as wrist temperature / body water.
if (showWaterIntakeTile) {
trendCards.push({
id: "waterIntake",
order: widgetOrder("waterIntake"),
node: (
<TrendCard
key="waterIntake"
label={t("nutrients.names.water")}
latest={waterIntakeSummary?.latest ?? null}
unit="mL"
avg7={waterIntakeSummary?.avg7 ?? null}
avg30={waterIntakeSummary?.avg30 ?? null}
slope30={waterIntakeSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(waterIntakeSummary)}
icon={GlassWater}
directionSentiment="neutral"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(waterIntakeSummary)}
staleDays={tileStaleDays("NUTRIENT_WATER")}
/>
),
});
}
if (showBpInTargetTile) {
/* v1.4.22 A2 — feature parity with every other tile.
Synthesise a slope from the difference between the 7-day
@@ -153,14 +153,14 @@ describe("ApiTokenOverviewSection — responsive", () => {
const html = render();
const mobileMatch = html.match(/<ul[^>]*md:hidden[^>]*>([\s\S]*?)<\/ul>/);
expect(mobileMatch).not.toBeNull();
// The Last-used + Created lines use <p class="text-[11px]…"> — no
// whitespace-nowrap so a long German date+time can flow onto a
// The Last-used + Created lines are muted <p class="…text-xs"> meta —
// no whitespace-nowrap so a long German date+time can flow onto a
// second line within the card. The shadcn `<Badge>` primitive
// does carry `whitespace-nowrap`, which is fine for the short
// permission strings we render — those are scoped to a flex-wrap
// container that line-breaks at the badge boundary.
expect(mobileMatch![1]).toMatch(
/<p[^>]*text-\[11px\][^>]*>(?![^<]*whitespace-nowrap)/,
/<p[^>]*text-muted-foreground[^>]*text-xs[^>]*>(?![^<]*whitespace-nowrap)/,
);
});
});
@@ -98,3 +98,16 @@ describe("AppLogPreviewSection — summary lives outside the scroll wrapper", ()
expect(wrapper![0]).not.toContain("app-log-preview-summary");
});
});
describe("AppLogPreviewSection — row detail is keyboard-reachable (2026-07-17 a11y audit H1)", () => {
// The row itself stays a pointer-only convenience (`onClick` on the
// `<tr>`); a real `<button>` in the Action cell gives keyboard / switch
// users a focusable, activatable path to the same detail dialog.
it("renders a real button carrying the action label and a descriptive aria-label", () => {
const html = render();
expect(html).toMatch(
/<button[^>]*type="button"[^>]*>auth\.login\.success</,
);
expect(html).toContain('aria-label="View details for auth.login.success');
});
});
@@ -11,10 +11,11 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { formatDate, formatDateTime } from "@/lib/format";
import type { Formatters } from "@/lib/format-locale";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { ListRow } from "@/components/ui/list-row";
import { useTranslations } from "@/lib/i18n/context";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { type ApiTokenInfo } from "./_shared";
import { apiGet } from "@/lib/api/api-fetch";
@@ -114,35 +115,30 @@ function TokenStatusBadge({
const TOKEN_NAME_ISO_RE =
/^(.+?)\s+(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2}))(.*)$/;
// Locale-fixed dd.MM.yyyy HH:mm in the Europe/Berlin display zone — the
// rest of the app shows timestamps in Berlin time (`format-locale.ts`),
// so a token issued at 19:46 UTC must read as 21:46 in summer / 20:46
// in winter, not the raw UTC clock.
const TOKEN_NAME_DATE_FORMATTER = new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin",
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
function formatTokenName(name: string): string {
/**
* Issue #66 (date-format sweep) — this used to hard-code `de-DE` +
* `Europe/Berlin`, so a non-German admin saw a German-formatted
* timestamp baked into the token name regardless of their own locale
* or profile timezone. Routes through the same `fmt.dateTime` every
* other surface renders through, so the admin's locale, hour-cycle
* preference, and profile timezone all apply here too.
*/
function formatTokenName(name: string, fmt: Formatters): string {
const match = TOKEN_NAME_ISO_RE.exec(name);
if (!match) return name;
const [, prefix, iso, rest] = match;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return name;
// Intl emits "05.05.2026, 21:46" — strip the comma so the suffix
// reads as a single date+time chunk.
const stamp = TOKEN_NAME_DATE_FORMATTER.format(d).replace(",", "");
// `fmt.dateTime` emits "05.05.2026, 21:46" — strip the comma so the
// suffix reads as a single date+time chunk.
const stamp = fmt.dateTime(d).replace(",", "");
const formatted = `${prefix} · ${stamp}`;
return rest && rest.trim() ? `${formatted} ${rest.trim()}` : formatted;
}
export function ApiTokenOverviewSection() {
const { t } = useTranslations();
const fmt = useFormatters();
// v1.4.19 phase A7 — `/admin/api-tokens` is a dedicated single-
// section route. The previous pattern carried a "Collapse / Expand"
// toggle as an escape hatch from the v1.4 shared admin page where
@@ -232,7 +228,9 @@ export function ApiTokenOverviewSection() {
<TruncatedCell value={token.user.username} />
</td>
<td className="max-w-0 px-3 py-2">
<TruncatedCell value={formatTokenName(token.name)} />
<TruncatedCell
value={formatTokenName(token.name, fmt)}
/>
</td>
<td className="max-w-0 px-3 py-2">
<div className="flex flex-wrap gap-1">
@@ -303,7 +301,7 @@ export function ApiTokenOverviewSection() {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<TruncatedCell
value={formatTokenName(token.name)}
value={formatTokenName(token.name, fmt)}
className="min-w-0 flex-1 font-medium"
/>
<TokenStatusBadge token={token} size="xs" />
@@ -330,13 +328,13 @@ export function ApiTokenOverviewSection() {
</Badge>
))}
</div>
<p className="text-muted-foreground mt-2 text-[11px]">
<p className="text-muted-foreground mt-2 text-xs">
{t("admin.tokenLastUsed")}:{" "}
{token.lastUsedAt
? formatDateTime(token.lastUsedAt)
: t("admin.tokenNeverUsed")}
</p>
<p className="text-muted-foreground text-[11px]">
<p className="text-muted-foreground text-xs">
{t("admin.tokenCreated")}: {formatDate(token.createdAt)}
</p>
</li>
Loaded 100 of 442 files, more files were not shown because too many files have changed in this diff. Show more