mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
427b5f0b7c | ||
|
|
d4f78811d9 | ||
|
|
6af0e36c88 | ||
|
|
9a6a05a0ef | ||
|
|
0b37c15a52 | ||
|
|
5fa18a3754 | ||
|
|
389bdb5d5d | ||
|
|
cf3f217e01 | ||
|
|
47a78896c5 | ||
|
|
bfe23c17b3 | ||
|
|
156c4574b5 | ||
|
|
e1c6941b20 | ||
|
|
b6a1fc1c2b | ||
|
|
0f5b18d946 | ||
|
|
4ca34d4b00 | ||
|
|
adf80da989 | ||
|
|
e8f4bb3dba | ||
|
|
0c5049cd69 | ||
|
|
c93d02c089 | ||
|
|
d2e36e2546 | ||
|
|
a70c98d2b4 | ||
|
|
fdc8ed81a0 | ||
|
|
c6b8d33b0c | ||
|
|
e062721812 | ||
|
|
c6885bc7d3 | ||
|
|
6b259c2c28 | ||
|
|
aaa05241b3 | ||
|
|
009e5adc43 | ||
|
|
a977df24a0 | ||
|
|
1d559d7ed0 | ||
|
|
56a32c528e | ||
|
|
e51e81c76f | ||
|
|
dfa4533c7c | ||
|
|
1e8e46f58c | ||
|
|
6c2eb15f05 | ||
|
|
635d7e2618 | ||
|
|
de8368cf01 | ||
|
|
1a642f1173 | ||
|
|
8dd04e0170 | ||
|
|
e9e186dca5 | ||
|
|
44ab74e125 | ||
|
|
71d7ca78cb | ||
|
|
5169be5887 | ||
|
|
a2cb4fb4a9 | ||
|
|
513e5309ac | ||
|
|
ccfda544f8 | ||
|
|
2c08201695 | ||
|
|
f223ca8979 | ||
|
|
3a45fcc3bd | ||
|
|
a2f926576e | ||
|
|
b00450aec8 | ||
|
|
c1c3aa9754 | ||
|
|
7ac9e7833e | ||
|
|
8c385b9ac2 | ||
|
|
9531bbdcec | ||
|
|
3c468b5e00 | ||
|
|
a66a346d0d | ||
|
|
20f0d13eba | ||
|
|
07f161bf25 | ||
|
|
5532a42ff6 | ||
|
|
66fe69b493 | ||
|
|
d2ad8e03e0 | ||
|
|
a0bde126dd | ||
|
|
749fb8eef1 | ||
|
|
efdcafb08d | ||
|
|
3dd5dbeb42 | ||
|
|
837c44e4c2 | ||
|
|
3dc6c7436f | ||
|
|
55186ccd3e | ||
|
|
a7c82eb582 | ||
|
|
d978c2b662 | ||
|
|
e7802701ba | ||
|
|
4c436d7dfc | ||
|
|
247bc4f33c | ||
|
|
1ff179178f | ||
|
|
7f44e2fa37 | ||
|
|
e8327109b1 | ||
|
|
e3dfe9f400 | ||
|
|
2c6699f682 | ||
|
|
2bc706a445 | ||
|
|
2b89259cce | ||
|
|
95ab0514b9 | ||
|
|
1417137162 | ||
|
|
2b3ff0e58f | ||
|
|
844b24c7ba | ||
|
|
bf1c052797 | ||
|
|
5f253f7d09 | ||
|
|
d124a3239d | ||
|
|
fb73c97125 | ||
|
|
4f7420905a | ||
|
|
8902d3985c | ||
|
|
30a0528ad0 | ||
|
|
cffebd78db | ||
|
|
8c0b200a5b | ||
|
|
0518c39050 | ||
|
|
a08e4378a0 | ||
|
|
0a08b166cf | ||
|
|
ba362f1d10 | ||
|
|
5cff7473c9 | ||
|
|
85a05efb4d | ||
|
|
d8e48a6304 | ||
|
|
60658be776 | ||
|
|
b11ecca8d2 | ||
|
|
43eddb63b0 | ||
|
|
351cf7dd85 | ||
|
|
05f80ad378 | ||
|
|
546c5c7d8f | ||
|
|
c527cd5aa4 | ||
|
|
16d142762a | ||
|
|
b396b51d35 | ||
|
|
da2de015fe |
No files matched your search
+168
@@ -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
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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/*`
|
||||
|
||||
@@ -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]),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
+49
-14
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* v1.19.0 (iOS #34) — go-forward aggregated heart-rate wire contract on
|
||||
* `POST /api/measurements/batch`.
|
||||
* v1.30.7/v1.30.8 (iOS #34) — go-forward aggregated 10-min heart-rate wire
|
||||
* contract on `POST /api/measurements/batch`.
|
||||
*
|
||||
* Asserts:
|
||||
* - a fresh hourly HR bucket inserts;
|
||||
* - a re-post of the same hour OVERWRITES (status `updated`, not a
|
||||
* - a fresh 10-min HR bucket inserts, with its per-bucket min/max spread;
|
||||
* - a re-post of the same bucket OVERWRITES (status `updated`, not a
|
||||
* duplicate row);
|
||||
* - a malformed hourly-HR bucket externalId is `skipped`;
|
||||
* - a malformed / off-grid aggregated HR bucket externalId is `skipped`;
|
||||
* - v1.30.8: an out-of-range or mis-ordered spread is dropped to null while
|
||||
* the trustworthy average survives;
|
||||
* - the per-sample uuid HR path is unaffected (immutable duplicate);
|
||||
* - the existing per-day step `stats:` overwrite path is unaffected.
|
||||
*/
|
||||
@@ -131,8 +133,8 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ count: 1 });
|
||||
});
|
||||
|
||||
describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", () => {
|
||||
it("inserts a fresh hourly HR bucket as a PULSE row", async () => {
|
||||
describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34)", () => {
|
||||
it("inserts a fresh 10-min HR bucket as a PULSE row", async () => {
|
||||
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
|
||||
const res = await POST(
|
||||
makeRequest({ entries: [hrBucketEntry(HR_BUCKET_ID, 72)] }),
|
||||
@@ -143,7 +145,7 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
|
||||
expect(data.updated).toBe(0);
|
||||
expect(data.entries[0].status).toBe("inserted");
|
||||
|
||||
// Stored as a PULSE row carrying the hourly average value.
|
||||
// Stored as a PULSE row carrying the 10-min average value.
|
||||
const createArg = vi.mocked(prisma.measurement.createMany).mock.calls[0][0];
|
||||
const rows = (createArg as { data: { type: string; value: number }[] })
|
||||
.data;
|
||||
@@ -151,7 +153,7 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
|
||||
expect(rows[0].value).toBe(72);
|
||||
});
|
||||
|
||||
it("persists per-bucket min/max on a fresh hourly HR bucket (iOS #34 ext)", async () => {
|
||||
it("persists per-bucket min/max on a fresh 10-min HR bucket (iOS #34 ext)", async () => {
|
||||
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
@@ -172,6 +174,39 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
|
||||
expect(rows[0].valueMax).toBe(96);
|
||||
});
|
||||
|
||||
it.each([
|
||||
// out-of-plausible-range spread (a sensor glitch / spurious discreteMax)
|
||||
{ valueMin: 58, valueMax: 99999 },
|
||||
{ valueMin: -9999, valueMax: 96 },
|
||||
// mis-ordered: valueMin above the average, or valueMax below it
|
||||
{ valueMin: 80, valueMax: 96 },
|
||||
{ valueMin: 58, valueMax: 70 },
|
||||
])(
|
||||
"v1.30.8 — drops an out-of-range or mis-ordered spread to null but keeps the average %o",
|
||||
async (spread) => {
|
||||
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
|
||||
const res = await POST(
|
||||
makeRequest({ entries: [hrBucketEntry(HR_BUCKET_ID, 72, spread)] }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const createArg = vi.mocked(prisma.measurement.createMany).mock
|
||||
.calls[0][0];
|
||||
const rows = (
|
||||
createArg as {
|
||||
data: {
|
||||
value: number;
|
||||
valueMin: number | null;
|
||||
valueMax: number | null;
|
||||
}[];
|
||||
}
|
||||
).data;
|
||||
// The trustworthy average survives; the invalid spread is dropped.
|
||||
expect(rows[0].value).toBe(72);
|
||||
expect(rows[0].valueMin).toBeNull();
|
||||
expect(rows[0].valueMax).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("overwrites min/max alongside the average on a re-post", async () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ type: "PULSE", source: "APPLE_HEALTH", externalId: HR_BUCKET_ID },
|
||||
@@ -214,8 +249,8 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
|
||||
expect(rows[0].valueMax).toBeNull();
|
||||
});
|
||||
|
||||
it("overwrites the same hour bucket on a re-post (updated, not duplicate)", async () => {
|
||||
// The bucket already exists from an earlier within-hour post.
|
||||
it("overwrites the same bucket on a re-post (updated, not duplicate)", async () => {
|
||||
// The bucket already exists from an earlier within-bucket post.
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{
|
||||
type: "PULSE",
|
||||
@@ -234,19 +269,19 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
|
||||
expect(data.duplicates).toBe(0);
|
||||
expect(data.entries[0].status).toBe("updated");
|
||||
|
||||
// The overwrite went through updateMany with the new hourly average.
|
||||
// The overwrite went through updateMany with the new 10-min average.
|
||||
expect(prisma.measurement.updateMany).toHaveBeenCalledTimes(1);
|
||||
const updateArg = vi.mocked(prisma.measurement.updateMany).mock.calls[0][0];
|
||||
expect((updateArg as { data: { value: number } }).data.value).toBe(78);
|
||||
expect(prisma.measurement.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips a malformed hourly HR bucket externalId", async () => {
|
||||
it("skips a malformed aggregated HR bucket externalId", async () => {
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
hrBucketEntry(
|
||||
"stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:30:00.000Z",
|
||||
"stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:35:00.000Z",
|
||||
70,
|
||||
),
|
||||
],
|
||||
@@ -34,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", {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}$/) },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
Reference in new issue
Block a user