Merge pull request #257 from MBombeck/release/v1.12.0

release/v1.12.0
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-05 01:39:48 +02:00
commit 107170d05b
152 files changed
+13215 -1792

No files matched your search

@@ -0,0 +1,44 @@
# v1.12.0 — server → iOS: new `FITBIT` MeasurementSource heads-up
**Date:** 2026-06-04
**From:** server
**Status:** landing in v1.12.0 (data-model foundation merged; sync/UI follow in
later waves of the same release)
## What is changing
v1.12.0 adds a Fitbit/Pixel integration over the Google Health API. Like WHOOP
and Withings, it is a **server-owned, read-only ingest** — there is no client
write path; the batch + single-POST surfaces reject it. The rows surface through
the same read/response shapes the iOS app already decodes (`/api/sync/changes`,
the measurement list, charts).
The one contract item for iOS in this wave:
- **New `MeasurementSource` enum value: `FITBIT`** (exact spelling, uppercase).
Add the `FITBIT` case to the iOS DTO's `MeasurementSource` decoding so
`/api/sync/changes` and the measurement reads decode Fitbit rows. This is the
same contract that gated `WHOOP` and `APPLE_HEALTH`.
> **Mismatched spelling = silent client-side decode drop.** A row tagged
> `source: "FITBIT"` that the DTO cannot decode is silently skipped, so the
> measurement count diverges from the server with no error surfaced. Pin the
> exact spelling.
## What is NOT changing
- **No new `MeasurementType` values.** Every Fitbit launch metric maps onto an
existing type (`WEIGHT`, `BODY_FAT`, `OXYGEN_SATURATION`,
`HEART_RATE_VARIABILITY`, `RESTING_HEART_RATE`, `RESPIRATORY_RATE`, `PULSE`,
`WRIST_TEMPERATURE`, `ACTIVITY_STEPS`, `WALKING_RUNNING_DISTANCE`,
`ACTIVE_ENERGY_BURNED`, `FLIGHTS_CLIMBED`, `VO2_MAX`, `SLEEP_DURATION`, plus
`Workout` rows). No DTO `MeasurementType` change is needed.
- **Blood glucose / blood pressure / body temperature are deferred.** Those
slots exist but are NOT in the launch sync set (gated on the Google Health
API roadmap). No action for iOS now.
## Confirmation requested
Confirm the iOS DTO carries the `FITBIT` case ahead of the v1.12.0 deploy. A
follow-up `v1.12.0-server-to-ios-fitbit-LIVE.md` will land once the enum is live
on apps01 (verified via `/api/version`).
@@ -0,0 +1,191 @@
# server → iOS — v1.12.0 — Mood v2 rated factors (ingestion contract)
**From:** server. **Date:** 2026-06-04. **Re:** your v0.14.1 item 3 — "send the
ingestion contract NOW so iOS can build the rating sliders."
Landing in **v1.12.0**. This is the **ingestion half**: the write contract +
catalog metadata. You can build the rating UI against it today. The analytics
side (F4 rated-factor → mood correlation, better-days board) follows in a later
wave — it does NOT change anything you send. Nothing here is breaking: every
field is additive and optional; an iOS build that ignores it behaves exactly as
v1.11.x.
Migration **0119** (additive: 5 columns + a seeded `factors` category with 5
factors). No auth / route / scope change — same Bearer path as every mood write.
---
## 1. Catalog metadata — `GET /api/mood/tags`
Each tag in every category now carries four extra fields:
```jsonc
{
"categories": [
{
"key": "factors",
"labelKey": "mood.tagCategory.factors",
"icon": "SlidersHorizontal",
"tags": [
{
"key": "factor_work",
"labelKey": "mood.tag.factorWork",
"icon": "Briefcase",
"kind": "RATED", // NEW — "BINARY" (default) | "RATED"
"scaleMin": 1, // NEW — inclusive lower bound (RATED only)
"scaleMax": 5, // NEW — inclusive upper bound (RATED only)
"inverse": false // NEW — true = higher score means a WORSE day
}
]
}
]
}
```
- **`kind`** — `"BINARY"` is a present/absent flag (every tag you already
render). `"RATED"` is a *factor* the user scores per entry. Branch your
rendering on this: `BINARY` → today's toggle chip; `RATED` → a segmented
control from `scaleMin` to `scaleMax` (render a **Yes/No toggle** when
`scaleMax == 2`).
- **`scaleMin` / `scaleMax`** — the factor's own scale. Most are `1..5`;
`factor_conflict` is `1..2`. Pre-validate the slider against these; the
server also enforces them (see §3).
- **`inverse`** — `true` for factors where a higher score is worse (stress,
conflict). Capture stays literal (a high stress score is still a "5"); add a
directional hint in the UI ("Stress — higher = worse") so the user reads it
right. The (later) analytics layer flips the sign internally; you do nothing.
- Every existing `BINARY` tag now reports `kind:"BINARY"`, `scaleMin:1`,
`scaleMax:5`, `inverse:false` — ignore those fields for binary tags.
The `labelKey` / `icon` resolve client-side exactly as today (the server stays
icon-library-agnostic — `icon` is a Lucide name).
---
## 2. The 5 MVP rated factors (seeded global catalog)
| key | labelKey | icon | scale | inverse |
|------------------------|-------------------------------|---------------------|-------|---------|
| `factor_work` | `mood.tag.factorWork` | `Briefcase` | 1..5 | false |
| `factor_social` | `mood.tag.factorSocial` | `Users` | 1..5 | false |
| `factor_sleep_quality` | `mood.tag.factorSleepQuality` | `Moon` | 1..5 | false |
| `factor_stress` | `mood.tag.factorStress` | `Zap` | 1..5 | **true**|
| `factor_conflict` | `mood.tag.factorConflict` | `Swords` | 1..2 | **true**|
All sit in the new `factors` category (`labelKey: mood.tagCategory.factors`,
icon `SlidersHorizontal`). Labels ship in all 6 locales (de/en/es/fr/it/pl).
`factor_conflict` is a Yes/No factor (1 = no conflict, 2 = conflict) — render
it as a two-state toggle, not a 1..5 slider.
These five match your ask (work / social / sleep / conflict). We followed the
server-side design doc's MVP set, which uses **stress** (inverse) as the fifth
rather than family — stress is the stronger correlation signal and family is
already a binary tag (`family`). If you specifically want a rated `factor_family`
too, say so and we'll seed it (trivial additive follow-up).
---
## 3. How to send a rated factor
### `POST /api/mood-entries` (single entry)
Add a `ratedFactors` array, parallel to the existing `tagKeys` (binary). Both
are optional; send either, both, or neither.
```jsonc
POST /api/mood-entries
{
"mood": "GUT",
"moodLoggedAt": "2026-06-04T18:30:00+02:00",
"tags": ["coffee"], // unchanged flat free-text
"tagKeys": ["happy", "worked_out"], // unchanged binary structured tags
"ratedFactors": [ // NEW
{ "key": "factor_work", "rating": 4 },
{ "key": "factor_social", "rating": 5 },
{ "key": "factor_stress", "rating": 2 },
{ "key": "factor_conflict", "rating": 1 } // 1 = no conflict (scale 1..2)
]
}
```
- Each element is `{ "key": string, "rating": int }`.
- **Value range:** `rating` must be an integer within the factor's own
`scaleMin..scaleMax` (from the catalog). The outer envelope the server's Zod
accepts is `1..5`; a value outside the *factor's* scale (e.g. `rating:5` on
`factor_conflict` whose max is 2) is rejected — see error handling below.
- **Array bound:** up to 30 rated factors per entry.
- **Unknown / non-RATED keys are dropped silently** (same posture as
`tagKeys`) — only a key that resolves to a `kind:"RATED"` catalog tag lands.
So passing a binary key in `ratedFactors`, or a `factor_*` key in `tagKeys`,
is a no-op for that key.
- A factor the user didn't touch should simply be **omitted** — there is no
"0 / null" rating. No link row is written.
**Response** (201) echoes the persisted set so you can hydrate without a
refetch — binary keys and rated factors are split by kind:
```jsonc
{
"data": {
"id": "…", "mood": "GUT", "date": "2026-06-04", "tags": ["coffee"],
"tagKeys": ["happy", "worked_out"],
"ratedFactors": [ { "key": "factor_work", "rating": 4 }, … ]
}
}
```
The list `GET /api/mood-entries` carries the same `ratedFactors` array per
entry — use it to pre-fill the edit form's sliders.
### Out-of-scale rating → 422
If a rating falls outside the factor's `scaleMin..scaleMax`, the single-entry
POST returns:
```jsonc
HTTP 422
{ "data": null, "error": "Rating 5 for factor \"factor_conflict\" is outside its scale 1..2",
"meta": { "errorCode": "mood.ratedFactor.out_of_range" } }
```
Branch on `meta.errorCode == "mood.ratedFactor.out_of_range"`. Best avoided by
clamping the slider to the catalog scale client-side.
### `POST /api/mood-entries/bulk` (adopt-on-pair backfill)
Same `ratedFactors: [{ key, rating }]` field per entry in the `entries[]`
array (same bounds, same drop-unknown posture):
```jsonc
POST /api/mood-entries/bulk
{
"entries": [
{
"mood": "GUT",
"moodLoggedAt": "2026-05-16T08:00:00.000Z",
"tagKeys": ["movies"],
"ratedFactors": [ { "key": "factor_work", "rating": 4 } ]
}
]
}
```
**Per-entry isolation:** an out-of-scale rating marks **that one entry**
`"skipped"` in the per-entry `entries[]` status array — it never fails the
whole batch. The mood row itself still upserts; only the bad rated link is
dropped. So clamp client-side to keep entries from being skipped.
---
## 4. Notes
- The `moodLog` webhook stays free-text-tags only (as before); rated factors
require the authenticated `POST /api/mood-entries` / `/bulk` path.
- These five `factor_*` keys + the 4 new catalog fields + the `ratedFactors`
write field are the **complete** contract. Nothing else changed.
- The mood-entries write routes are not in `docs/api/openapi.yaml` (they never
were), so there's no codegen surface for this — work from this note.
**Ask back:** confirm the 5-factor set works for your slider UI, and whether
you want a rated `factor_family` seeded alongside (vs the existing binary
`family` tag). Ping per item. Thanks — server.
@@ -0,0 +1,78 @@
# v1.12.0 — server → iOS: tag × health-metric crosstab on `GET /api/mood/insights`
**Date:** 2026-06-05
**From:** server
**Status:** landing in v1.12.0
## What is changing
`GET /api/mood/insights` gains one additive top-level field: `tagMetricCrosstab`.
It extends the existing mood-relations surface (Daylio's "Activities & Mood") from
mood to a health METRIC — for each structured mood tag, the metric's mean on
tag-present vs tag-absent days, the delta, and a confidence band. It reuses the
same Welch t-test + per-group day floors + Benjamini-Hochberg FDR the
`tagInfluence` / `betterDays` fields already use; only FDR-surviving rows surface.
This endpoint is the server-rendered Insights aggregate (force-dynamic, not in
`openapi.yaml`). It is **additive** — every existing field is unchanged, so a
client that ignores the new field keeps working. No new `MeasurementType` or
`MeasurementSource` values.
## Response field shape
`tagMetricCrosstab` is an array (possibly empty) under `data`:
```jsonc
{
"data": {
// ... existing fields (summary, heatmap, tagInfluence, betterDays, ...) ...
"tagMetricCrosstab": [
{
"tag": "workout", // stable structured-tag key
"labelKey": "mood.tag.workout", // i18n label key (structured tags only)
"categoryKey": "activities", // parent category key
"icon": "Dumbbell", // Lucide icon name, or null
"metricKey": "activeEnergy", // "activeEnergy" | "sleepDuration" | "nextDayRecovery"
"display": "kcal", // "hours" | "kcal" | "score" — unit hint for the formatter
"mode": "sameDay", // "sameDay" | "nextDay" (D→D+1 lag)
"withDays": 18, // tag-present days with a paired metric value
"withoutDays": 41, // tag-absent days with a paired metric value
"withAvg": 612.4, // mean metric on tag-present days, in `display` unit
"withoutAvg": 388.1, // mean metric on tag-absent days, in `display` unit
"delta": 224.3, // withAvg − withoutAvg, in `display` unit
"pValue": 0.004, // Welch two-sided p-value
"qValue": 0.021, // Benjamini-Hochberg adjusted q across the tested family
"confidence": "high" // "low" | "medium" | "high"
}
]
},
"error": null
}
```
### Field notes
- **Pairs supported this wave** (the `metricKey` → metric type + lag):
- `activeEnergy` → `ACTIVE_ENERGY_BURNED`, same-day. `display: "kcal"`.
- `sleepDuration` → `SLEEP_DURATION`, same-day. `display: "hours"` — the server
has already converted the stored minutes to hours; `withAvg`/`withoutAvg`/`delta`
are in hours.
- `nextDayRecovery` → `RECOVERY_SCORE`, next-day (the metric on day D+1 after a
tag on day D). `display: "score"` (0..100 points).
- **`display` is the unit hint.** Render `hours` as e.g. `7.4 h`, `kcal` as a whole
number `612 kcal`, `score` as `+8 pts`. `delta` carries the sign.
- **`mode`** lets the client caption next-day rows distinctly ("the day after").
- **Structured tags only.** Flat free-text tags are deliberately excluded — the
crosstab needs a stable `labelKey`. `labelKey` is always present here (never null,
unlike `tagInfluence` where flat-tag rows carry `labelKey: null`).
- **Gating.** A row only appears when both sides cleared 5 present + 5 absent days
AND it survived `p < 0.05` and the BH-FDR control (`q ≤ 0.10`). Capped at 8 rows.
- **Framing is mandatory.** Render the standing observational caption ("associations
in your own data, not proof of cause"). Do not phrase any row as causal.
## What iOS needs to do
Nothing is required — the field is additive and the web client renders it. If the
iOS app wants to surface the crosstab natively, decode `tagMetricCrosstab` with the
shape above; treat an absent or empty array as "no card". Pin the `metricKey`,
`display`, `mode`, and `confidence` string sets exactly.
+31
View File
@@ -1,5 +1,36 @@
# Changelog
## [Unreleased]
## [1.12.0] — 2026-06-05 — Google Health sync, rated mood factors, and a Coach and insights overhaul
### Added
- **Google Health (Fitbit & Pixel) — experimental.** Connect a Google Health account through your own Google client and pull in activity, body, sleep, and workout data: steps, distance, active energy, floors, and VO₂max; weight, body fat, SpO₂, resting heart rate, heart-rate variability, respiratory rate, and wrist temperature; sleep stages; and workouts. The card works like the others — test the connection, sync now, sync everything, and reconnect after a pause. This integration is experimental: Google's health API is young and coverage for some data types is still being verified.
- **Rated mood factors.** Alongside the yes/no mood tags you can now score factors on their own scale per entry — work, social, sleep quality, stress, conflict, and family — so a day can carry "stress 4, sleep quality 2" rather than a plain tag.
- **Mood logging, rebuilt.** Capturing a mood now opens with a five-face picker, then a category-grouped tag picker and the factor ratings, replacing the old form.
- **Hobbies and nutrition mood tags** — films, reading, gaming, music, outdoors, and fast food, no sweets, a big meal.
- **How your tags line up with your health metrics.** The mood Insights now compares a health metric on the days you tagged an activity against the days you did not — active energy on workout days, sleep length on the nights you tagged sleep, next-day recovery after a tag like alcohol or food. Each row shows the difference, its confidence, and is framed as an association in your own data, not a cause; only relationships that clear the same statistical filter the rest of the relations surface uses are shown.
- **A full-page Coach.** The Coach conversation can expand to its own full page from a maximize control.
- **A quicker way to log.** Navigation gains a central capture action that opens a measurement, medication, or mood entry from anywhere, and a "More" hub gathers the less-frequent sections.
- **A dashboard settings shortcut** — a wrench beside "Add" jumps straight to the dashboard layout settings.
### Changed
- **The Coach reads like a chat.** Replies stream in as they are written, past conversations and the context behind an answer collapse out of the way, and the disclaimer appears once.
- **Insights, de-duplicated.** The overview no longer repeats what the per-metric pages already show, and each metric page follows one consistent layout — a primary value, the last reading, the average/min/max/median strip, the chart with its range control below it, the metric's own correlation, and the assessment last — with clearer "what stands out" wording.
- **A refined injection-site body map** with corrected proportions and a legend for the recommended and last-used markers.
- **Export, reordered.** The health-record (FHIR) export is now the primary export, the doctor-report PDF moves below it, and the included-data list collapses by default.
- **The unit system lives in your profile** as a dropdown beside timezone, treated like a preference such as language.
- **Google Health and the other wearables share one source-priority ladder**, so when the same metric arrives from several sources the most appropriate one wins per metric.
- **The standalone moodLog integration is deprecated.** Mood is now tracked fully inside HealthLog with entries, structured tags, and rated factors, so the external bridge no longer adds anything. It keeps working for existing setups but is slated for removal in a future major release.
### Fixed
- **Weekly medications no longer read 0% compliance.** A weekly-cadence medication with recorded intakes now reports the correct rate instead of zero across the 7-, 30-, and 90-day frames.
- **Twice-daily oral doses record independently.** Marking an evening dose no longer collapses onto the morning slot, so both land in the history.
- The Google Health sync reads from a single point in time per cycle, so a gap after downtime isn't silently skipped, and a connection returned without a refresh token is rejected cleanly rather than saved half-formed.
## [1.11.5] — 2026-06-04 — mood relations, sleep depth, and a clear-the-decks polish pass
### Added
+40 -19
View File
@@ -21,7 +21,7 @@
<p align="center">
Self-hosted health tracker. Weight, blood pressure, glucose, mood, medications.<br/>
Withings, WHOOP, and Apple Health sync, multi-provider AI Insights you own, doctor-report PDF.
Withings, WHOOP, Google Health/Fitbit, and Apple Health sync, multi-provider AI insights you own, doctor-report PDF.
</p>
<p align="center">
@@ -36,9 +36,9 @@
## What it is
HealthLog is a self-hosted personal health tracker that runs from a single `docker compose up`. It covers the metrics most people actually log -- weight, blood pressure, pulse, body composition, blood glucose, sleep, mood, and medication compliance -- and brings them together in one dashboard with reference ranges from ESH 2023, ADA 2024, and NICE NG115. Withings and WHOOP devices sync automatically over OAuth2; an `export.zip` import folds your full Apple Health history into the same timeline; a native SwiftUI iOS client (public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa)) streams HealthKit live; multi-provider AI Insights (BYOK or local) explain what the numbers mean; a doctor-report PDF generates client-side. EN/DE end-to-end. AGPL-3.0.
HealthLog is a self-hosted personal health tracker that runs from a single `docker compose up`. It covers the metrics most people actually log -- weight, blood pressure, pulse, body composition, blood glucose, sleep, mood, and medication compliance -- and brings them together in one dashboard with reference ranges from ESH 2023, ADA 2024, and NICE NG115. Withings, WHOOP, and Google Health/Fitbit devices sync automatically over OAuth2; an `export.zip` import folds your full Apple Health history into the same timeline; a native SwiftUI iOS client (public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa)) streams HealthKit live; multi-provider AI insights (BYOK or local) explain what the numbers mean; a doctor-report PDF generates client-side. EN/DE end-to-end. AGPL-3.0.
> **Status**: active. New releases roughly weekly -- see [CHANGELOG](CHANGELOG.md). Current line: v1.10 — a derived-metrics tier (fitness-age, vascular-age delta, HRV balance, sleep score, readiness and recovery scores, an early-strain flag), each computed from your own measurements and citing its inputs, on top of the v1.7 health-record export (PDF + FHIR R4), flexible medication schedules, and full HealthKit metric coverage. The native iOS client is public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa).
> **Status**: active. New releases roughly weekly -- see [CHANGELOG](CHANGELOG.md). Current line: v1.12 — a Google Health/Fitbit connection for Fitbit and Pixel wearables (experimental, see [Integrations](#integrations)) and a rebuilt mood log (five-face capture, a structured tag catalog, and rated daily-life factors), on top of the v1.11 mood-relations and sleep-depth insights, a read-only FHIR R4 API with shareable clinician records, the v1.10 derived-metrics tier (fitness-age, vascular-age delta, HRV balance, sleep score, readiness and recovery scores, an early-strain flag), and the v1.7 health-record export (PDF + FHIR R4). The native iOS client is public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa).
> **Heavily developed.** HealthLog ships multiple releases per week. Behaviour, API shapes, and database schema can change between minor versions; migrations are forward-only and not all are rehearsed against every legacy fixture. If you self-host, pin a tag, take a backup before every upgrade, and read the [CHANGELOG](CHANGELOG.md) before pulling `latest`. Issue reports and PRs welcome — this is the rough edge where the project gets sharper.
@@ -74,7 +74,7 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
The wrist-band ecosystems are good at what their hardware measures, and HealthLog does not try to be a ring or a strap. It does not ship a sensor, and it does not claim to match one. What it does is take the readings those devices already collect — plus the ones they don't hold, like cuff blood pressure, finger-stick or CGM glucose, and medication intake — and keep them on a server you run.
- **You own the data and the host.** No vendor account, no cloud round-trip, no subscription tier gating a metric you already recorded. AGPL-3.0, single `docker compose up`, encrypted at rest, on your NAS / homelab / VPS.
- **It aggregates across providers instead of locking you to one.** Withings devices sync over OAuth2, an Apple Health `export.zip` folds your full history in, and the native iOS client streams HealthKit live — so a Withings scale, an Apple Watch, and a glucometer land on one timeline instead of three apps.
- **It aggregates across providers instead of locking you to one.** Withings and WHOOP devices sync over OAuth2, a Google Health/Fitbit connection pulls Fitbit and Pixel data (experimental), an Apple Health `export.zip` folds your full history in, and the native iOS client streams HealthKit live — so a Withings scale, a WHOOP strap, an Apple Watch, and a glucometer land on one timeline instead of four apps. When two providers report the same metric, a per-metric source priority decides which one is canonical, so a dedicated scale outranks a wrist estimate and cumulative metrics like steps are never double-counted (see [Source priority](#source-priority)).
- **It carries context the wrist bands don't.** Blood pressure, blood glucose, body composition, and cadence-aware medication compliance sit next to the wearable signals, so a trend reads against the whole picture rather than activity and sleep alone.
- **The derived metrics are transparent, not a black box.** HealthLog computes a fitness-age and vascular-age delta, an HRV-balance and sleep-score band, a daily readiness and recovery score, and a coincident-deviation early-strain flag — each from your own measurements, each citing the inputs and the published method behind it, each degrading honestly to "not enough data" rather than fabricating a number. These are descriptive wellness framings, not clinical or training-grade assessments, and a score is only stored once its minimum inputs are present.
@@ -90,15 +90,19 @@ HealthLog is the right fit when you want your wearable and clinical numbers in o
**Customizable Dashboard** -- Show, hide, and drag-to-reorder every widget. Per-user layout with reset-to-defaults.
**Mood Logging** -- 5-point scale with tags, notes, and trend analytics. Syncs automatically from moodLog.app via webhook.
**Mood Logging** -- A five-face mood capture (a 1–5 scale) backed by a structured tag catalog and rated daily-life factors. Pick binary tags from categories covering feelings, sleep, health (including nutrition tags like ate-well, fast-food, no-sweets, big-meal), social, work, and hobbies; score rated factors — work, social, sleep quality, stress, and conflict — on their own scale (1–5, or yes/no for conflict), with stress and conflict flagged as inverse so a higher score reads as a worse day. Add a free-text note, then read it back through trend analytics and a mood-relations surface that associates your tags and factors with your other metrics. The standalone moodLog.app webhook is deprecated now that mood is tracked fully inside HealthLog; it stays functional for existing integrations.
**Medication Compliance** -- Flexible scheduling driven by a single recurrence engine: time windows, day-of-week recurrence, `intervalWeeks`, rolling intervals, RFC-5545 RRULE, one-time doses, as-needed (PRN), and cyclic on/off-week plans — so once-weekly GLP-1 injections, weekday-only doses, and non-daily plans score honestly against their real cadence rather than a daily denominator, everywhere from the dashboard tile to the detail page. Route of administration (oral / injection / other) carries the injection-site picker for any injection. Take / skip / snooze logging, compliance heatmaps, GLP-1 pen-inventory + injection-site rotation, structured side-effect tracking. External API for iOS Shortcuts integration.
**Withings Integration** -- OAuth2 device sync for scales, blood pressure monitors, and activity trackers with automatic deduplication.
**WHOOP Integration** -- Connect a WHOOP account with your own WHOOP developer OAuth client (client id/secret pasted into Settings, stored encrypted). Recovery, strain, sleep, body, and workout data sync server-side over OAuth2, with a connection test, manual sync (incremental or full history), and a resume control to recover from a revoked grant.
**Google Health / Fitbit Integration** _(experimental)_ -- Connect Fitbit and Pixel data through Google's Health API on your own Google Cloud OAuth client (client id/secret in Settings, encrypted at rest). Activity (steps, distance, active energy, floors, VO₂ max), health metrics (weight, body fat, SpO₂, resting heart rate, HRV, respiratory rate, wrist temperature, spot heart rate), per-stage sleep, and exercise workouts import server-side with the same connection-test / sync / resume controls as WHOOP. Because the Google Health API is young and several per-type value fields are not yet fully published, this connection is **experimental** — some data types may import nothing until the wire mapping is verified against a live account, and each operator's OAuth client needs Google brand verification plus an annual CASA assessment before it can leave Testing mode.
**Apple Health import** -- Drop your iOS `export.zip` on the import page. A streaming parser handles multi-gigabyte archives (Zip64), folds every `<Record>`, `<Workout>`, `<Correlation>`, and `<ClinicalRecord>` into the same timeline as your other metrics, and stays idempotent on re-upload. Per-type ingestion stats plus a live status endpoint so you can watch the progress on a long historical drain.
**AI Coach + Insights** -- A conversational Coach grounded in your own data, a daily briefing, a weekly report, and a Health Score tile on the dashboard. Pick OpenAI, Anthropic Claude, ChatGPT via Codex device-OAuth (no API key needed), or any OpenAI-compatible local endpoint (Ollama, LM Studio, vLLM). BYOK or admin-shared. Feed the Coach a chosen set of data clusters (cardiovascular, body composition, activity, workouts, sleep, mood, glucose, medication, mobility, environment) with a soft budget cap that degrades the lowest-signal clusters first. Every claim links back to the measurements that produced it. The Coach now reads your history longitudinally — a narrative of how a metric has moved and a per-metric trajectory framing — so an answer reflects the direction of travel rather than only the latest reading. Local endpoints keep all data on your network.
**AI Coach + Insights** -- Multi-provider, evidence-grounded health insights: per-metric assessments that read each vital against its reference range, correlations across your metrics, a daily briefing, a weekly report, a Health Score tile, and a conversational Coach grounded in your own data. Pick OpenAI, Anthropic Claude, ChatGPT via Codex device-OAuth (no API key needed), or any OpenAI-compatible local endpoint (Ollama, LM Studio, vLLM). BYOK or admin-shared. Feed the Coach a chosen set of data clusters (cardiovascular, body composition, activity, workouts, sleep, mood, glucose, medication, mobility, environment) with a soft budget cap that degrades the lowest-signal clusters first. Every claim links back to the measurements that produced it. The Coach now reads your history longitudinally — a narrative of how a metric has moved and a per-metric trajectory framing — so an answer reflects the direction of travel rather than only the latest reading. Local endpoints keep all data on your network.
**Derived wellness metrics** -- A transparent metrics tier computed from your own measurements: a fitness-age band from VO₂max, a vascular-age delta, an HRV-balance band, a sleep score, a daily readiness and a stored recovery score, plus a coincident-deviation early-strain flag. Each one re-frames or blends signals you already recorded against age/sex norms or your personal baseline, cites the inputs and the published method behind it, and returns "not enough data" rather than a fabricated value when its minimum inputs are missing. The recovery score persists as a daily 0–100 series the dashboard, charts, and native client read without recomputing. These are descriptive wellness framings — not clinical or training-grade assessments.
@@ -217,6 +221,7 @@ HealthLog is designed for people who take data ownership seriously.
| `WITHINGS_WEBHOOK_SECRET` | Webhook URL hardening secret |
| `WHOOP_REDIRECT_URI` | WHOOP OAuth callback URL (client id/secret set in Settings) |
| `WHOOP_WEBHOOK_SECRET` | HMAC secret for WHOOP webhook signature verification |
| `FITBIT_REDIRECT_URI` | Google Health/Fitbit OAuth callback URL (defaults to `<NEXT_PUBLIC_APP_URL>/api/fitbit/callback`; client id/secret set in Settings) |
| `TELEGRAM_WEBHOOK_SECRET` | Telegram bot webhook secret |
Telegram bot token, ntfy settings, Web Push VAPID keys, Umami, and GlitchTip URLs are configured in the **Admin Panel** and stored encrypted in the database.
@@ -318,7 +323,9 @@ All mutations require authentication via session cookie. External ingest uses Be
| `POST` | `/api/mood-entries` | Create mood entry |
| `DELETE` | `/api/mood-entries/:id` | Delete mood entry |
| `GET` | `/api/mood/analytics` | Mood trend analytics |
| `POST` | `/api/integrations/moodlog/webhook` | moodLog.app webhook |
| `GET` | `/api/mood/tags` | Structured mood-tag + rated-factor catalog |
| `GET` | `/api/mood/insights` | Mood-relations associations |
| `POST` | `/api/integrations/moodlog/webhook` | moodLog.app webhook (deprecated) |
</details>
@@ -358,6 +365,14 @@ All mutations require authentication via session cookie. External ingest uses Be
| `GET` | `/api/withings/connect` | Initiate Withings OAuth |
| `POST` | `/api/withings/sync` | Trigger manual Withings sync |
| `POST` | `/api/withings/webhook` | Withings notification webhook |
| `GET` | `/api/whoop/connect` | Initiate WHOOP OAuth |
| `POST` | `/api/whoop/sync` | Manual WHOOP sync (`{ fullSync }` for full history) |
| `POST` | `/api/integrations/whoop/test` | Probe a saved WHOOP connection |
| `POST` | `/api/integrations/whoop/resume` | Resume a WHOOP connection after a revoked grant |
| `GET` | `/api/fitbit/connect` | Initiate Google Health/Fitbit OAuth |
| `POST` | `/api/fitbit/sync` | Manual Fitbit sync (`{ fullSync }` for full history) |
| `POST` | `/api/integrations/fitbit/test` | Probe a saved Google Health/Fitbit connection |
| `POST` | `/api/integrations/fitbit/resume`| Resume a Fitbit connection after a revoked grant |
| `POST` | `/api/insights/generate` | Regenerate AI insights |
| `GET` | `/api/insights/comprehensive` | Aggregated insight payload |
| `GET` | `/api/gamification/achievements` | Achievement progress |
@@ -463,17 +478,22 @@ Clinician share-link lifecycle — create a scoped, time-limited link, hand a cl
## Integrations
| Integration | Setup | Purpose |
| --------------- | ------------- | ---------------------------------------- |
| **Withings** | Env vars | Auto-sync weight, BP, and activity |
| **WHOOP** | User Settings | OAuth2 sync of recovery, strain, and sleep (BYO-keys) |
| **Telegram** | Admin Panel | Medication reminders with inline buttons |
| **ntfy** | User Settings | Self-hosted push notifications |
| **Web Push** | Admin Panel | Browser-native VAPID notifications |
| **OpenAI** | User Settings | AI health insights (BYOK) |
| **moodLog.app** | User Settings | Mood tracking sync |
| **Umami** | Admin Panel | Privacy-friendly analytics |
| **GlitchTip** | Admin Panel | Sentry-compatible error tracking |
| Integration | Setup | Purpose |
| ------------------------ | ------------- | ---------------------------------------- |
| **Withings** | Env vars | Auto-sync weight, BP, and activity |
| **WHOOP** | User Settings | OAuth2 sync of recovery, strain, sleep, body, and workouts (BYO-keys) |
| **Google Health / Fitbit** | User Settings | OAuth2 sync of activity, health metrics, sleep, and workouts for Fitbit/Pixel (BYO-keys, **experimental**) |
| **Telegram** | Admin Panel | Medication reminders with inline buttons |
| **ntfy** | User Settings | Self-hosted push notifications |
| **Web Push** | Admin Panel | Browser-native VAPID notifications |
| **OpenAI / Anthropic / local** | User Settings | AI health insights (BYOK or local endpoint) |
| **moodLog.app** | User Settings | Mood tracking sync (deprecated — mood is now tracked natively) |
| **Umami** | Admin Panel | Privacy-friendly analytics |
| **GlitchTip** | Admin Panel | Sentry-compatible error tracking |
### Source priority
When two providers report the same metric — a Withings scale and an Apple Watch both logging weight, or HealthKit and Fitbit both reporting steps — HealthLog keeps every row as an audit trail but applies a per-metric source priority to decide which reading is canonical. Cumulative metrics (steps, distance, active energy, floors) pick exactly one source per day so they are never double-counted; point measurements (weight, blood pressure, HRV) pick a display-preferred source. The default ladder reflects measurement reliability for each metric class: a dedicated scale outranks a wrist estimate for weight, a recovery strap leads the sleep and HRV ladders, and a cuff leads blood pressure. The ladder is per-user adjustable, and an optional device-type tie-break decides between two devices on the same source. The underlying idea is [data fusion](https://en.wikipedia.org/wiki/Data_fusion) — combining several imperfect sensors into a single, more reliable estimate rather than averaging them blindly.
---
@@ -528,7 +548,8 @@ For a single-process default the same container hosts both the web and worker (`
| **v1.4.x** | Web maturity — Apple Health import, AI Coach, persistent rollup tier, multi-provider AI, doctor PDF, encryption-key rotation, Coolify autodeploy. Roughly weekly cadence. |
| **v1.5** | Native iOS client (SwiftUI) in public beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa). Backend contract locked in [`docs/api/openapi.yaml`](docs/api/openapi.yaml); the iOS app lives in a separate repository and ingests via the same `/api/measurements/batch` and `/api/auth/refresh` surfaces the web uses. |
| **v1.6 – v1.7** | Medication editor overhaul + route of administration (v1.6), then health-record export (PDF + HL7 FHIR R4), flexible schedules (RRULE / rolling / interval-weeks / one-time / PRN / cyclic) with cadence-canonical compliance, full HealthKit metric coverage, a metric/imperial display preference, a first-paint dashboard snapshot, and a sync delta feed for offline clients (v1.7). |
| **v1.8 – v1.10** (current) | Insights redesign with selectable time ranges (v1.8 – v1.9), then a derived-metrics tier (v1.10): fitness-age, vascular-age delta, HRV balance, sleep score, readiness and recovery scores, and a coincident-deviation early-strain flag — each computed from your own measurements, each citing its inputs and degrading to "not enough data" rather than fabricating a value. |
| **v1.8 – v1.10** | Insights redesign with selectable time ranges (v1.8 – v1.9), then a derived-metrics tier (v1.10): fitness-age, vascular-age delta, HRV balance, sleep score, readiness and recovery scores, and a coincident-deviation early-strain flag — each computed from your own measurements, each citing its inputs and degrading to "not enough data" rather than fabricating a value. |
| **v1.11 – v1.12** (current) | WHOOP device sync, a read-only HL7 FHIR R4 API with shareable clinician records, mood-relations and sleep-depth (hypnogram) insights (v1.11); then a Google Health/Fitbit connection for Fitbit and Pixel wearables (experimental) and a rebuilt mood log — five-face capture, a structured tag catalog, and rated daily-life factors (v1.12). |
| **v2.x** (planned) | Multi-tenant hardening, expanded device passthrough (Garmin / Polar), opt-in cross-user aggregate research mode (off by default; never enabled without explicit consent). |
Two known limitations carry forward from v1.11: dedup across two sources of the same standard vital is deferred (both rows persist, source priority decides display), and the coach's durable conversation-summary / remembered-facts layer is deferred — each answer reasons from your data afresh rather than from a running memory.
+110 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.11.5
version: 1.12.0
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -1189,6 +1189,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
externalId:
type: string
maxLength: 128
@@ -1801,6 +1802,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
activeEnergy:
maxItems: 8
type: array
@@ -1813,6 +1815,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
walkingRunningDistance:
maxItems: 8
type: array
@@ -1825,6 +1828,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
flightsClimbed:
maxItems: 8
type: array
@@ -1837,6 +1841,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
sleep:
maxItems: 8
type: array
@@ -1849,6 +1854,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
weight:
maxItems: 8
type: array
@@ -1861,6 +1867,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bloodPressure:
maxItems: 8
type: array
@@ -1873,6 +1880,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
pulse:
maxItems: 8
type: array
@@ -1885,6 +1893,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bodyFat:
maxItems: 8
type: array
@@ -1897,6 +1906,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bodyTemperature:
maxItems: 8
type: array
@@ -1909,6 +1919,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
spo2:
maxItems: 8
type: array
@@ -1921,6 +1932,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
hrv:
maxItems: 8
type: array
@@ -1933,6 +1945,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
restingHeartRate:
maxItems: 8
type: array
@@ -1945,6 +1958,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
vo2Max:
maxItems: 8
type: array
@@ -1957,6 +1971,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
skinTemperature:
maxItems: 8
type: array
@@ -1969,6 +1984,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
respiratoryRate:
maxItems: 8
type: array
@@ -1981,6 +1997,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
recovery:
maxItems: 8
type: array
@@ -1993,6 +2010,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
metricPriority:
type: object
properties:
@@ -2008,6 +2026,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
activeEnergy:
maxItems: 8
type: array
@@ -2020,6 +2039,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
walkingRunningDistance:
maxItems: 8
type: array
@@ -2032,6 +2052,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
flightsClimbed:
maxItems: 8
type: array
@@ -2044,6 +2065,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
sleep:
maxItems: 8
type: array
@@ -2056,6 +2078,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
weight:
maxItems: 8
type: array
@@ -2068,6 +2091,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bloodPressure:
maxItems: 8
type: array
@@ -2080,6 +2104,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
pulse:
maxItems: 8
type: array
@@ -2092,6 +2117,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bodyFat:
maxItems: 8
type: array
@@ -2104,6 +2130,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bodyTemperature:
maxItems: 8
type: array
@@ -2116,6 +2143,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
spo2:
maxItems: 8
type: array
@@ -2128,6 +2156,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
hrv:
maxItems: 8
type: array
@@ -2140,6 +2169,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
restingHeartRate:
maxItems: 8
type: array
@@ -2152,6 +2182,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
vo2Max:
maxItems: 8
type: array
@@ -2164,6 +2195,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
skinTemperature:
maxItems: 8
type: array
@@ -2176,6 +2208,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
respiratoryRate:
maxItems: 8
type: array
@@ -2188,6 +2221,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
recovery:
maxItems: 8
type: array
@@ -2200,6 +2234,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
deviceTypePriority:
type: object
properties:
@@ -4778,6 +4813,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
notes:
anyOf:
- type: string
@@ -5093,6 +5129,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
notes:
anyOf:
- type: string
@@ -5252,6 +5289,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
- type: "null"
start:
type: string
@@ -5366,6 +5404,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
- type: "null"
start:
type: string
@@ -5569,6 +5608,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
externalId:
anyOf:
- type: string
@@ -5673,6 +5713,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
externalId:
anyOf:
- type: string
@@ -6936,6 +6977,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
activeEnergy:
maxItems: 8
type: array
@@ -6948,6 +6990,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
walkingRunningDistance:
maxItems: 8
type: array
@@ -6960,6 +7003,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
flightsClimbed:
maxItems: 8
type: array
@@ -6972,6 +7016,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
sleep:
maxItems: 8
type: array
@@ -6984,6 +7029,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
weight:
maxItems: 8
type: array
@@ -6996,6 +7042,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bloodPressure:
maxItems: 8
type: array
@@ -7008,6 +7055,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
pulse:
maxItems: 8
type: array
@@ -7020,6 +7068,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bodyFat:
maxItems: 8
type: array
@@ -7032,6 +7081,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bodyTemperature:
maxItems: 8
type: array
@@ -7044,6 +7094,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
spo2:
maxItems: 8
type: array
@@ -7056,6 +7107,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
hrv:
maxItems: 8
type: array
@@ -7068,6 +7120,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
restingHeartRate:
maxItems: 8
type: array
@@ -7080,6 +7133,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
vo2Max:
maxItems: 8
type: array
@@ -7092,6 +7146,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
skinTemperature:
maxItems: 8
type: array
@@ -7104,6 +7159,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
respiratoryRate:
maxItems: 8
type: array
@@ -7116,6 +7172,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
recovery:
maxItems: 8
type: array
@@ -7128,6 +7185,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
metricPriority:
type: object
properties:
@@ -7143,6 +7201,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
activeEnergy:
maxItems: 8
type: array
@@ -7155,6 +7214,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
walkingRunningDistance:
maxItems: 8
type: array
@@ -7167,6 +7227,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
flightsClimbed:
maxItems: 8
type: array
@@ -7179,6 +7240,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
sleep:
maxItems: 8
type: array
@@ -7191,6 +7253,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
weight:
maxItems: 8
type: array
@@ -7203,6 +7266,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bloodPressure:
maxItems: 8
type: array
@@ -7215,6 +7279,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
pulse:
maxItems: 8
type: array
@@ -7227,6 +7292,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bodyFat:
maxItems: 8
type: array
@@ -7239,6 +7305,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bodyTemperature:
maxItems: 8
type: array
@@ -7251,6 +7318,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
spo2:
maxItems: 8
type: array
@@ -7263,6 +7331,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
hrv:
maxItems: 8
type: array
@@ -7275,6 +7344,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
restingHeartRate:
maxItems: 8
type: array
@@ -7287,6 +7357,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
vo2Max:
maxItems: 8
type: array
@@ -7299,6 +7370,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
skinTemperature:
maxItems: 8
type: array
@@ -7311,6 +7383,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
respiratoryRate:
maxItems: 8
type: array
@@ -7323,6 +7396,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
recovery:
maxItems: 8
type: array
@@ -7335,6 +7409,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
additionalProperties: false
deviceTypePriority:
type: object
@@ -7396,6 +7471,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
activeEnergy:
maxItems: 8
type: array
@@ -7408,6 +7484,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
walkingRunningDistance:
maxItems: 8
type: array
@@ -7420,6 +7497,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
flightsClimbed:
maxItems: 8
type: array
@@ -7432,6 +7510,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
sleep:
maxItems: 8
type: array
@@ -7444,6 +7523,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
weight:
maxItems: 8
type: array
@@ -7456,6 +7536,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bloodPressure:
maxItems: 8
type: array
@@ -7468,6 +7549,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
pulse:
maxItems: 8
type: array
@@ -7480,6 +7562,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bodyFat:
maxItems: 8
type: array
@@ -7492,6 +7575,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bodyTemperature:
maxItems: 8
type: array
@@ -7504,6 +7588,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
spo2:
maxItems: 8
type: array
@@ -7516,6 +7601,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
hrv:
maxItems: 8
type: array
@@ -7528,6 +7614,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
restingHeartRate:
maxItems: 8
type: array
@@ -7540,6 +7627,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
vo2Max:
maxItems: 8
type: array
@@ -7552,6 +7640,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
skinTemperature:
maxItems: 8
type: array
@@ -7564,6 +7653,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
respiratoryRate:
maxItems: 8
type: array
@@ -7576,6 +7666,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
recovery:
maxItems: 8
type: array
@@ -7588,6 +7679,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
metricPriority:
type: object
properties:
@@ -7603,6 +7695,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
activeEnergy:
maxItems: 8
type: array
@@ -7615,6 +7708,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
walkingRunningDistance:
maxItems: 8
type: array
@@ -7627,6 +7721,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
flightsClimbed:
maxItems: 8
type: array
@@ -7639,6 +7734,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
sleep:
maxItems: 8
type: array
@@ -7651,6 +7747,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
weight:
maxItems: 8
type: array
@@ -7663,6 +7760,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bloodPressure:
maxItems: 8
type: array
@@ -7675,6 +7773,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
pulse:
maxItems: 8
type: array
@@ -7687,6 +7786,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bodyFat:
maxItems: 8
type: array
@@ -7699,6 +7799,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bodyTemperature:
maxItems: 8
type: array
@@ -7711,6 +7812,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
spo2:
maxItems: 8
type: array
@@ -7723,6 +7825,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
hrv:
maxItems: 8
type: array
@@ -7735,6 +7838,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
restingHeartRate:
maxItems: 8
type: array
@@ -7747,6 +7851,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
vo2Max:
maxItems: 8
type: array
@@ -7759,6 +7864,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
skinTemperature:
maxItems: 8
type: array
@@ -7771,6 +7877,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
respiratoryRate:
maxItems: 8
type: array
@@ -7783,6 +7890,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
recovery:
maxItems: 8
type: array
@@ -7795,6 +7903,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
additionalProperties: false
deviceTypePriority:
type: object
+46
View File
@@ -172,4 +172,50 @@ test.describe("mobile-viewport smoke", () => {
`Touch-targets below 44 px tall:\n ${failures.join("\n ")}`,
).toEqual([]);
});
test("center capture action opens the picker; the More hub keeps Mood + Measurements reachable", async ({
page,
}) => {
await mockDashboardSnapshot(page, { summaries: WEIGHT_ONLY_SUMMARIES });
await page.route(/\/api\/analytics(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { summaries: {}, bpInTargetPct: null, glucoseByContext: {} },
error: null,
}),
}),
);
await page.route("**/api/mood/analytics", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { entries: [], summary: { count: 0 } },
error: null,
}),
}),
);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
// 1) The center capture action opens the capture picker.
await page.getByTestId("bottom-nav-capture").click();
await expect(page.getByTestId("capture-picker-options")).toBeVisible();
for (const kind of ["measurement", "medication", "mood"]) {
await expect(page.getByTestId(`capture-picker-${kind}`)).toBeVisible();
}
// Dismiss the picker before opening the hub.
await page.keyboard.press("Escape");
// 2) The More hub keeps Mood + Measurements reachable (additive
// middle-path — they left the strip but are NOT orphaned).
await page.getByTestId("bottom-nav-more").click();
const hub = page.getByTestId("bottom-nav-more-sheet");
await expect(hub).toBeVisible();
await expect(hub.locator("a[href='/measurements']")).toBeVisible();
await expect(hub.locator("a[href='/mood']")).toBeVisible();
});
});
+33 -11
View File
@@ -3,12 +3,16 @@ import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.16 phase B7 — Settings → Export consolidated UI smoke.
* Settings → Export consolidated UI smoke.
*
* Validates that:
* 1. The five export cards are all rendered with their stable testids.
* 2. Clicking the Measurements CSV download button fires a real
* browser download — proving the new `/api/export/measurements`
* 1. The export surfaces are all rendered with their stable testids:
* the health-record export hero, the four CSV/JSON tiles, and the
* demoted doctor-report card (v1.12).
* 2. The health-record "included data" checklist is a disclosure,
* collapsed by default, and expands on demand.
* 3. Clicking the Measurements CSV download button fires a real
* browser download — proving the `/api/export/measurements`
* endpoint is reachable from the browser end-to-end.
*
* The other CSV / JSON cards share the same code path; one happy-path
@@ -18,24 +22,42 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
test.describe("Settings → Export", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("renders all five export cards with stable testids", async ({
page,
}) => {
test("renders all export surfaces with stable testids", async ({ page }) => {
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
// v1.4.37 lifted the doctor-report card to a hero block with the
// `export-hero-*` testid prefix; the four CSV/backup tiles keep
// the `export-card-*` shape.
// v1.12 — the health-record export is the page hero; the
// doctor-report card (`export-hero-*` prefix) is demoted to the
// bottom; the four CSV/backup tiles keep the `export-card-*` shape.
for (const id of [
"export-hero-doctor-report",
"health-record-export-panel",
"export-card-measurements-csv",
"export-card-medications-csv",
"export-card-mood-csv",
"export-card-full-backup",
"export-hero-doctor-report",
]) {
await expect(page.getByTestId(id)).toBeVisible({ timeout: 10_000 });
}
});
test("included-data checklist is collapsed by default and expands on demand", async ({
page,
}) => {
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
const toggle = page.getByTestId("health-record-included-data-toggle");
await expect(toggle).toBeVisible({ timeout: 10_000 });
// Collapsed on first render — the checklist panel is absent.
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(
page.getByTestId("health-record-included-data-panel"),
).toHaveCount(0);
// Expands on demand.
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(
page.getByTestId("health-record-included-data-panel"),
).toBeVisible();
});
test("Measurements CSV download fires a real download event", async ({
page,
}) => {
+3 -2
View File
@@ -127,8 +127,9 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
// Wait for the pills to mount (queries resolve after first paint).
const pills = page.locator('[data-testid="integration-status-pill"]');
// Three integration cards now (Withings, moodLog, WHOOP) → three pills.
await expect(pills).toHaveCount(3, { timeout: 10_000 });
// Four integration cards now (Withings, moodLog, WHOOP, Google Health) →
// four pills.
await expect(pills).toHaveCount(4, { timeout: 10_000 });
// Withings + moodLog are connected in this fixture; WHOOP is a BYO-keys
// card that stays dormant until an operator adds credentials, so assert
+39
View File
@@ -181,4 +181,43 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
await expect(drawer).toBeVisible({ timeout: 10_000 });
await expect(drawer).toHaveAttribute("data-variant", "side-sheet");
});
test("conversation history is collapsed behind a toggle that opens the tray on Desktop Chrome", async ({
page,
}, testInfo) => {
// v1.12.0 — the history rail is no longer an always-on left column;
// it lives behind the "Conversations" toggle on every viewport and
// opens as a tray on demand so the thread keeps the full width.
test.skip(
testInfo.project.name !== "chromium-desktop",
"exercises the desktop drawer layout",
);
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
await expect(icon).toBeVisible({ timeout: 10_000 });
await icon.click();
const drawer = page.locator('[data-slot="coach-drawer"]');
await expect(drawer).toBeVisible({ timeout: 10_000 });
// No inline history column — only the toggle.
await expect(
page.locator('[data-slot="coach-drawer-history"]'),
).toHaveCount(0);
const historyToggle = page.locator(
'[data-slot="coach-drawer-history-tray-trigger"]',
);
await expect(historyToggle).toBeVisible({ timeout: 10_000 });
// The history rail surfaces only after the toggle is pressed.
await expect(
page.locator('[data-slot="coach-drawer-history-tray"]'),
).toHaveCount(0);
await historyToggle.click();
await expect(
page.locator('[data-slot="coach-drawer-history-tray"]'),
).toBeVisible({ timeout: 10_000 });
});
});
+97 -9
View File
@@ -197,6 +197,7 @@
"collapseSidebar": "Einklappen",
"userMenu": "Benutzermenü",
"mood": "Stimmung",
"workouts": "Workouts",
"skipToContent": "Zum Inhalt springen",
"sidebar": "Sidebar",
"mainNavigation": "Hauptnavigation",
@@ -205,7 +206,17 @@
"loadingScreen": "Laden…",
"more": "Mehr",
"moreSheetTitle": "Mehr",
"moreSheetDescription": "Seiten, die nicht in die untere Leiste passen."
"moreSheetDescription": "Zum Rest von HealthLog springen.",
"capture": {
"title": "Erfassen",
"description": "Was möchtest du erfassen?",
"measurement": "Messung",
"measurementDescription": "Gewicht, Blutdruck, Puls und mehr",
"medication": "Medikament",
"medicationDescription": "Eine Dosis als genommen markieren",
"mood": "Stimmung",
"moodDescription": "Stimmung und Tags erfassen"
}
},
"auth": {
"login": "Anmelden",
@@ -264,6 +275,7 @@
"quickAddMeasurement": "Messung erfassen",
"quickAddMood": "Stimmung erfassen",
"quickAddMedicationIntake": "Einnahme erfassen",
"customizeDashboard": "Dashboard anpassen",
"medicationIntakeQuickAdd": {
"sheetTitle": "Medikamenteneinnahme erfassen",
"sheetDescription": "Dosis, Zeitpunkt und Medikament in wenigen Tipps protokollieren.",
@@ -469,6 +481,10 @@
"addEntry": "Hinzufügen",
"editEntry": "Eintrag bearbeiten",
"moodLevel": "Stimmung",
"heroQuestion": "Wie geht's dir?",
"factorYes": "Ja",
"factorNo": "Nein",
"factorRatingOption": "{label}: {value} von {max}",
"tags": "Tags",
"tagsPlaceholder": "z.B. Sport, Arbeit, Familie",
"tagsHelp": "Kommagetrennt eingeben",
@@ -510,7 +526,9 @@
"sleep": "Schlaf",
"health": "Gesundheit",
"social": "Soziales",
"work": "Arbeit"
"work": "Arbeit",
"hobbies": "Hobbys",
"factors": "Faktoren"
},
"tag": {
"happy": "glücklich",
@@ -542,7 +560,21 @@
"overtime": "Überstunden",
"dayOff": "Feierabend",
"travel": "Reise",
"sickDay": "Krankheitstag"
"sickDay": "Krankheitstag",
"movies": "Filme",
"reading": "gelesen",
"gaming": "gezockt",
"music": "Musik",
"outdoors": "draußen",
"fastFood": "Fast Food",
"noSweets": "keine Süßigkeiten",
"bigMeal": "üppige Mahlzeit",
"factorWork": "Arbeit",
"factorSocial": "Soziales",
"factorSleepQuality": "Schlafqualität",
"factorStress": "Stress",
"factorConflict": "Konflikt",
"factorFamily": "Familie"
}
},
"medications": {
@@ -1035,6 +1067,9 @@
"injectionSiteBodyOutlineAriaLabel": "Körperumriss",
"injectionSitePickerAriaLabel": "Injektionsstelle auswählen",
"injectionSiteRecommendedAriaLabel": "{site} – empfohlene nächste Stelle",
"injectionSiteLegendAriaLabel": "Legende der Markierungen",
"injectionSiteLegendRecommended": "Empfohlene nächste Stelle",
"injectionSiteLegendLastUsed": "Zuletzt verwendete Stelle",
"siteAbdomenLeft": "Bauch, unten links",
"siteAbdomenRight": "Bauch, unten rechts",
"siteAbdomenUpperLeft": "Bauch, oben links",
@@ -1687,7 +1722,6 @@
},
"correlationRow": {
"title": "Zusammenhänge",
"subtitle": "Vorab definierte Hypothesen, gegated auf n ≥ 14 und p < 0,05",
"disclaimer": "Zusammenhänge sind beobachtend, nicht kausal — sprich mit deinem Arzt, wenn der Blutdruck hoch ist.",
"experimentCta": "7-Tage-Experiment starten",
"experimentTooltip": "Bald verfügbar",
@@ -1715,6 +1749,8 @@
"errorCredentialExpired": "Deine Verbindung zum KI-Anbieter ist abgelaufen. Stelle sie in den Einstellungen wieder her, damit der Coach weiterläuft.",
"tagline": "Persönlicher Gesundheitscoach, auf deine Daten gestützt.",
"newChat": "Neuer Chat",
"maximizeAriaLabel": "Im Vollbild öffnen",
"minimizeAriaLabel": "Zurück zum Seitenfenster",
"send": "Senden",
"stop": "Stopp",
"thinking": "Denke nach…",
@@ -1979,6 +2015,14 @@
"inTargetShare": "{pct}% der erfassten Tage im Zielbereich",
"adjustLink": "Zielbereich anpassen"
},
"primary": {
"avg30Label": "30-Tage-Durchschnitt",
"inRangeLabel": "Im Zielbereich",
"inRangeAria": "{pct}% der erfassten Tage im Zielbereich"
},
"lastMeasurement": {
"label": "Letzte Messung"
},
"explainer": {
"trigger": "Was ist {title}?",
"bloodPressureTitle": "Blutdruck",
@@ -2561,8 +2605,22 @@
"description": "Tags und Werte, die mit deinen Tagen besserer und schlechterer Stimmung einhergehen, sortiert danach, wie stark sie sich zusammen bewegen.",
"disclaimer": "Nur Zusammenhänge, keine Ursachen. Sprich mit deiner Ärztin oder deinem Arzt, bevor du etwas änderst."
},
"crosstab": {
"title": "Tags und deine Gesundheitswerte",
"description": "Wie sich ein Gesundheitswert an Tagen mit einem Tag von Tagen ohne diesen Tag unterscheidet.",
"pairLabel": "{tag} · {metric}",
"metricActiveEnergy": "Aktive Energie",
"metricSleepDuration": "Schlaf",
"metricNextDayRecovery": "Erholung am Folgetag",
"unitHours": "Std.",
"unitKcal": "kcal",
"unitScore": "Pkt.",
"detailSameDay": "Durchschnittlich {withAvg} {unit} an den {withDays} Tagen mit diesem Tag gegenüber {withoutAvg} {unit} ohne.",
"detailNextDay": "Durchschnittlich {withAvg} {unit} am Tag nach den {withDays} Tagen mit diesem Tag gegenüber {withoutAvg} {unit} sonst.",
"disclaimer": "Das sind Zusammenhänge in deinen eigenen Daten, kein Beweis für Ursachen. Muster, die einen Blick wert sind."
},
"discovery": {
"title": "Entdeckte Muster",
"title": "Was auffällt",
"description": "Tag-zu-Tag-Zusammenhänge rund um deine Stimmung, die auch nach Korrektur für viele gleichzeitige Tests bestehen.",
"footer": "Herausgefiltert aus {tested} Tag-zu-Tag-Paaren, die über all deine Werte getestet wurden, nach der Korrektur für Fehlentdeckungen.",
"empty": "Bisher hat kein Stimmungszusammenhang die statistische Hürde genommen. Logge weiter.",
@@ -2737,7 +2795,7 @@
"label": "Mehrere Vitalwerte außerhalb der Baseline",
"summary": "{count} deiner Vitalwerte lagen heute außerhalb ihres persönlichen Bereichs.",
"vitals": "Außerhalb des Bereichs: {list}",
"cardTitle": "Signal des Tages",
"cardTitle": "Signale des Tages",
"allClear": "Alle deine Vitalwerte liegen heute in ihrem persönlichen Bereich.",
"allClearMeta": "{count} deiner Vitalwerte gegen deinen eigenen typischen Bereich geprüft.",
"watch": "Ein Vitalwert liegt heute außerhalb seines üblichen Bereichs.",
@@ -2751,7 +2809,8 @@
"headline": "Wenn dieses Muster anhält — die nächsten {days} Tage",
"range": "Projiziert auf rund {low}–{high} {unit} in {days} Tagen.",
"caveat": "Eine Fortschreibung deines eigenen jüngsten Trends, keine Vorhersage. Das schattierte Band wird breiter und zeigt, wie schnell die Unsicherheit wächst.",
"insufficient": "Noch kein klar genuger Trend für eine Projektion — erfasse weiter, dann erscheint eine Projektion, sobald das jüngste Muster deutlich genug ist."
"insufficient": "Noch kein klar genuger Trend für eine Projektion — erfasse weiter, dann erscheint eine Projektion, sobald das jüngste Muster deutlich genug ist.",
"daysNeeded": "Noch etwa {count} Tage Erfassung nötig, bis eine Projektion erscheinen kann."
}
},
"cardioFitness": {
@@ -3086,7 +3145,7 @@
"description": "Pro Metrik dein eigener Zielbereich für gesunde Werte."
},
"sources": {
"title": "Quellen",
"title": "Quellen-Priorität",
"description": "Lege fest, welche Quelle führt, wenn mehrere am selben Tag dieselbe Metrik liefern.",
"cardTitle": "Quellen-Priorität pro Metrik",
"help": "Wenn mehrere Quellen denselben Wert am selben Tag liefern (z. B. Withings + Apple Health beide 1.200 Schritte), wird die oberste Quelle als kanonisch verwendet. Andere Quellen bleiben gespeichert für die Audit-Spur.",
@@ -3129,6 +3188,7 @@
"MANUAL": "Manuelle Eingabe",
"IMPORT": "Import",
"WHOOP": "WHOOP",
"FITBIT": "Fitbit",
"COMPUTED": "Berechnet"
},
"deviceLabels": {
@@ -3196,7 +3256,7 @@
}
},
"about": {
"title": "Über",
"title": "Über diese App",
"description": "Version, Lizenz, Links."
},
"sharing": {
@@ -3491,6 +3551,33 @@
"whoopConnect": "Mit WHOOP verbinden",
"whoopNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um WHOOP zu verbinden.",
"whoopBackfillInProgress": "Dein WHOOP-Verlauf wird im Hintergrund importiert…",
"fitbit": "Google Health",
"fitbitTag": "Fitbit & Pixel",
"fitbitDescription": "Verbinde Google Health, um deine Fitbit- und Pixel-Watch-Werte zu synchronisieren — Gewicht, Körperfett, Ruhepuls, Sauerstoffsättigung, Herzfrequenzvariabilität und mehr.",
"fitbitOverlapNote": "Wenn Google Health und eine andere Quelle denselben Vitalwert liefern — Ruhepuls, Sauerstoffsättigung, Körpertemperatur oder Atemfrequenz —, siehst du möglicherweise beide Werte, bis sich ein künftiges Update auf eine bevorzugte Quelle festlegt.",
"fitbitExperimentalBadge": "Experimentell",
"fitbitExperimentalNote": "Diese Integration befindet sich in der Beta — die Datenabdeckung und die Feldzuordnung werden noch gegen echte Google-Health-Konten geprüft, daher können einzelne Werte fehlen oder sich in einem künftigen Update ändern.",
"fitbitCredentials": "API-Zugangsdaten",
"fitbitCredentialsHelp": "Registriere deinen eigenen Google-Cloud-OAuth-Client für die Google-Health-API und füge hier dessen Client-ID und Client-Secret ein.",
"fitbitClientId": "Client-ID",
"fitbitClientSecret": "Client-Secret",
"fitbitCredentialsSaved": "Zugangsdaten gespeichert",
"fitbitCredentialsSavedPlaceholder": "Gespeichert — neu eingeben zum Ersetzen",
"fitbitCredentialsSavedPlaceholderSecret": "Gespeichert — neu eingeben zum Ersetzen",
"fitbitSaveCredentials": "Zugangsdaten speichern",
"fitbitSync": "Jetzt synchronisieren",
"fitbitFullSync": "Alle Daten synchronisieren",
"fitbitFullSyncTitle": "Vollständige Synchronisierung?",
"fitbitFullSyncDescription": "Der gesamte verfügbare Google-Health-Verlauf wird vollständig synchronisiert. Das kann je nach Umfang deines Verlaufs einige Zeit dauern.",
"fitbitSyncResult": "{count} Messungen synchronisiert",
"fitbitSyncFailed": "Synchronisierung fehlgeschlagen",
"fitbitSynchronize": "Synchronisieren",
"fitbitDisconnect": "Trennen",
"fitbitDisconnectTitle": "Google Health trennen?",
"fitbitDisconnectDescription": "Die Verbindung zu Google Health wird getrennt. Zuvor synchronisierte Daten bleiben erhalten.",
"fitbitConnect": "Mit Google Health verbinden",
"fitbitNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um Google Health zu verbinden.",
"fitbitBackfillInProgress": "Dein Google-Health-Verlauf wird im Hintergrund importiert…",
"integrations": {
"withings": {
"reconnect": {
@@ -3569,6 +3656,7 @@
"savingError": "Fehler beim Speichern",
"moodLogTitle": "moodLog",
"moodLogDescription": "Stimmungseinträge übernehmen.",
"moodLogDeprecated": "Veraltet: Stimmung wird jetzt nativ in HealthLog mit Tags und Faktoren erfasst. Diese Integration funktioniert weiterhin, wird aber in einer künftigen Version entfernt.",
"moodLogUrl": "moodLog URL",
"moodLogUrlPlaceholder": "https://mood.example.com",
"moodLogApiKey": "API-Key",
+97 -9
View File
@@ -197,6 +197,7 @@
"collapseSidebar": "Collapse",
"userMenu": "User menu",
"mood": "Mood",
"workouts": "Workouts",
"skipToContent": "Skip to content",
"sidebar": "Sidebar",
"mainNavigation": "Main navigation",
@@ -205,7 +206,17 @@
"loadingScreen": "Loading…",
"more": "More",
"moreSheetTitle": "More",
"moreSheetDescription": "Pages that don't fit the bottom bar."
"moreSheetDescription": "Jump to the rest of HealthLog.",
"capture": {
"title": "Log",
"description": "What would you like to record?",
"measurement": "Measurement",
"measurementDescription": "Weight, blood pressure, pulse and more",
"medication": "Medication",
"medicationDescription": "Mark a dose as taken",
"mood": "Mood",
"moodDescription": "Log your mood and tags"
}
},
"auth": {
"login": "Sign in",
@@ -264,6 +275,7 @@
"quickAddMeasurement": "Log measurement",
"quickAddMood": "Log mood",
"quickAddMedicationIntake": "Log medication intake",
"customizeDashboard": "Customize dashboard",
"medicationIntakeQuickAdd": {
"sheetTitle": "Log medication intake",
"sheetDescription": "Record dose, time, and medication in a few taps.",
@@ -469,6 +481,10 @@
"addEntry": "Add",
"editEntry": "Edit entry",
"moodLevel": "Mood",
"heroQuestion": "How are you?",
"factorYes": "Yes",
"factorNo": "No",
"factorRatingOption": "{label}: {value} of {max}",
"tags": "Tags",
"tagsPlaceholder": "e.g. Exercise, Work, Family",
"tagsHelp": "Comma-separated",
@@ -510,7 +526,9 @@
"sleep": "Sleep",
"health": "Health",
"social": "Social",
"work": "Work"
"work": "Work",
"hobbies": "Hobbies",
"factors": "Factors"
},
"tag": {
"happy": "happy",
@@ -542,7 +560,21 @@
"overtime": "overtime",
"dayOff": "day off",
"travel": "travel",
"sickDay": "sick day"
"sickDay": "sick day",
"movies": "movies",
"reading": "reading",
"gaming": "gaming",
"music": "music",
"outdoors": "outdoors",
"fastFood": "fast food",
"noSweets": "no sweets",
"bigMeal": "big meal",
"factorWork": "work",
"factorSocial": "social",
"factorSleepQuality": "sleep quality",
"factorStress": "stress",
"factorConflict": "conflict",
"factorFamily": "family"
}
},
"medications": {
@@ -1035,6 +1067,9 @@
"injectionSiteBodyOutlineAriaLabel": "Body outline",
"injectionSitePickerAriaLabel": "Injection site picker",
"injectionSiteRecommendedAriaLabel": "{site} — recommended next site",
"injectionSiteLegendAriaLabel": "Marker legend",
"injectionSiteLegendRecommended": "Recommended next site",
"injectionSiteLegendLastUsed": "Last used site",
"siteAbdomenLeft": "Abdomen, lower left",
"siteAbdomenRight": "Abdomen, lower right",
"siteAbdomenUpperLeft": "Abdomen, upper left",
@@ -1687,7 +1722,6 @@
},
"correlationRow": {
"title": "Relationships",
"subtitle": "Pre-defined hypotheses, gated on n ≥ 14 and p < 0.05",
"disclaimer": "Relationships are observational, not causal — talk to your doctor if BP runs high.",
"experimentCta": "Try a 7-day experiment",
"experimentTooltip": "Coming soon",
@@ -1715,6 +1749,8 @@
"errorCredentialExpired": "Your AI provider connection has expired. Reconnect it in Settings to keep the Coach running.",
"tagline": "Personal health coach, grounded in your data.",
"newChat": "New chat",
"maximizeAriaLabel": "Open in full page",
"minimizeAriaLabel": "Back to the drawer",
"send": "Send",
"stop": "Stop",
"thinking": "Thinking…",
@@ -1979,6 +2015,14 @@
"inTargetShare": "{pct}% of logged days within target",
"adjustLink": "Adjust target range"
},
"primary": {
"avg30Label": "30-day average",
"inRangeLabel": "In range",
"inRangeAria": "{pct}% of logged days within target"
},
"lastMeasurement": {
"label": "Last measurement"
},
"explainer": {
"trigger": "What is {title}?",
"bloodPressureTitle": "Blood pressure",
@@ -2561,8 +2605,22 @@
"description": "Tags and metrics that line up with your higher- and lower-mood days, ranked by how strongly they move together.",
"disclaimer": "Associations only, not causes. Talk to your doctor before changing anything based on these."
},
"crosstab": {
"title": "Tags and your health metrics",
"description": "How a health metric differs on the days you tagged each activity, compared with the days you did not.",
"pairLabel": "{tag} · {metric}",
"metricActiveEnergy": "Active energy",
"metricSleepDuration": "Sleep",
"metricNextDayRecovery": "Next-day recovery",
"unitHours": "h",
"unitKcal": "kcal",
"unitScore": "pts",
"detailSameDay": "Averages {withAvg} {unit} on the {withDays} days you tagged this vs {withoutAvg} {unit} without it.",
"detailNextDay": "Averages {withAvg} {unit} the day after the {withDays} days you tagged this vs {withoutAvg} {unit} otherwise.",
"disclaimer": "These are associations in your own data, not proof of cause. Patterns worth watching."
},
"discovery": {
"title": "Discovered patterns",
"title": "What stands out",
"description": "Day-to-day relationships involving your mood that hold up after correcting for testing many pairs at once.",
"footer": "Surfaced from {tested} day-to-day pairs tested across all your metrics, after the false-discovery correction.",
"empty": "No mood relationships cleared the statistical bar yet. Keep logging.",
@@ -2737,7 +2795,7 @@
"label": "Several vitals off baseline",
"summary": "{count} of your vitals sat outside their personal range today.",
"vitals": "Outside range: {list}",
"cardTitle": "Today's signal",
"cardTitle": "Signals of the day",
"allClear": "All your vitals are within their personal range today.",
"allClearMeta": "Checked {count} of your vitals against your own typical range.",
"watch": "One vital is outside its usual range today.",
@@ -2751,7 +2809,8 @@
"headline": "If this pattern continues — the next {days} days",
"range": "Projected around {low}–{high} {unit} in {days} days.",
"caveat": "A projection of your own recent trend, not a prediction. The shaded band widens to show how quickly the uncertainty grows.",
"insufficient": "Not enough of a clear trend to project yet — keep tracking and a projection will appear once the recent pattern is strong enough."
"insufficient": "Not enough of a clear trend to project yet — keep tracking and a projection will appear once the recent pattern is strong enough.",
"daysNeeded": "About {count} more days of tracking needed before a projection can appear."
}
},
"cardioFitness": {
@@ -3086,7 +3145,7 @@
"description": "Per metric: your own target range for healthy values."
},
"sources": {
"title": "Sources",
"title": "Source priority",
"description": "Pick which ingest source wins when more than one records the same metric for the same day.",
"cardTitle": "Source priority by metric",
"help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.",
@@ -3129,6 +3188,7 @@
"MANUAL": "Manual entry",
"IMPORT": "Import",
"WHOOP": "WHOOP",
"FITBIT": "Fitbit",
"COMPUTED": "Computed"
},
"deviceLabels": {
@@ -3196,7 +3256,7 @@
}
},
"about": {
"title": "About",
"title": "About this app",
"description": "Version, license, links."
},
"sharing": {
@@ -3491,6 +3551,33 @@
"whoopConnect": "Connect with WHOOP",
"whoopNoCredentials": "Please enter your API credentials above to connect WHOOP.",
"whoopBackfillInProgress": "Importing your WHOOP history in the background…",
"fitbit": "Google Health",
"fitbitTag": "Fitbit & Pixel",
"fitbitDescription": "Connect Google Health to sync your Fitbit and Pixel Watch metrics — weight, body fat, resting heart rate, blood oxygen, heart-rate variability and more.",
"fitbitOverlapNote": "If Google Health and another source both supply a vital — resting heart rate, blood oxygen, body temperature or respiratory rate — you may see both values until a future update settles on a single preferred source.",
"fitbitExperimentalBadge": "Experimental",
"fitbitExperimentalNote": "This integration is in beta — data coverage and field mapping are still being verified against live Google Health accounts, so some values may be missing or change in a future update.",
"fitbitCredentials": "API Credentials",
"fitbitCredentialsHelp": "Register your own Google Cloud OAuth client for the Google Health API and paste its Client ID and Client Secret here.",
"fitbitClientId": "Client ID",
"fitbitClientSecret": "Client Secret",
"fitbitCredentialsSaved": "Credentials saved",
"fitbitCredentialsSavedPlaceholder": "Saved — enter new to replace",
"fitbitCredentialsSavedPlaceholderSecret": "Saved — enter new to replace",
"fitbitSaveCredentials": "Save credentials",
"fitbitSync": "Sync now",
"fitbitFullSync": "Sync all data",
"fitbitFullSyncTitle": "Full synchronization?",
"fitbitFullSyncDescription": "All available Google Health history will be fully synchronized. This may take some time depending on your history.",
"fitbitSyncResult": "{count} measurements synchronized",
"fitbitSyncFailed": "Sync failed",
"fitbitSynchronize": "Synchronize",
"fitbitDisconnect": "Disconnect",
"fitbitDisconnectTitle": "Disconnect Google Health?",
"fitbitDisconnectDescription": "The connection to Google Health will be disconnected. Previously synced data will be preserved.",
"fitbitConnect": "Connect with Google Health",
"fitbitNoCredentials": "Please enter your API credentials above to connect Google Health.",
"fitbitBackfillInProgress": "Importing your Google Health history in the background…",
"integrations": {
"withings": {
"reconnect": {
@@ -3569,6 +3656,7 @@
"savingError": "Error saving",
"moodLogTitle": "moodLog",
"moodLogDescription": "Import mood entries.",
"moodLogDeprecated": "Deprecated: mood is now tracked natively in HealthLog with tags and factors. This integration still works but will be removed in a future release.",
"moodLogUrl": "moodLog URL",
"moodLogUrlPlaceholder": "https://mood.example.com",
"moodLogApiKey": "API Key",
+97 -9
View File
@@ -197,6 +197,7 @@
"collapseSidebar": "Contraer",
"userMenu": "Menú de usuario",
"mood": "Estado de ánimo",
"workouts": "Entrenamientos",
"skipToContent": "Saltar al contenido",
"sidebar": "Barra lateral",
"mainNavigation": "Navegación principal",
@@ -205,7 +206,17 @@
"loadingScreen": "Cargando…",
"more": "Más",
"moreSheetTitle": "Más",
"moreSheetDescription": "Páginas que no caben en la barra inferior."
"moreSheetDescription": "Ve al resto de HealthLog.",
"capture": {
"title": "Registrar",
"description": "¿Qué quieres registrar?",
"measurement": "Medición",
"measurementDescription": "Peso, presión arterial, pulso y más",
"medication": "Medicamento",
"medicationDescription": "Marcar una dosis como tomada",
"mood": "Estado de ánimo",
"moodDescription": "Registrar estado de ánimo y etiquetas"
}
},
"auth": {
"login": "Iniciar sesión",
@@ -264,6 +275,7 @@
"quickAddMeasurement": "Registrar medición",
"quickAddMood": "Registrar estado de ánimo",
"quickAddMedicationIntake": "Registrar toma",
"customizeDashboard": "Personalizar panel",
"medicationIntakeQuickAdd": {
"sheetTitle": "Registrar toma de medicación",
"sheetDescription": "Registra dosis, hora y medicamento en unos pocos toques.",
@@ -469,6 +481,10 @@
"addEntry": "Añadir",
"editEntry": "Editar entrada",
"moodLevel": "Estado de ánimo",
"heroQuestion": "¿Cómo estás?",
"factorYes": "Sí",
"factorNo": "No",
"factorRatingOption": "{label}: {value} de {max}",
"tags": "Etiquetas",
"tagsPlaceholder": "p. ej. deporte, trabajo, familia",
"tagsHelp": "Separa con comas",
@@ -510,7 +526,9 @@
"sleep": "Sueño",
"health": "Salud",
"social": "Social",
"work": "Trabajo"
"work": "Trabajo",
"hobbies": "Aficiones",
"factors": "Factores"
},
"tag": {
"happy": "feliz",
@@ -542,7 +560,21 @@
"overtime": "horas extra",
"dayOff": "día libre",
"travel": "viaje",
"sickDay": "día de baja"
"sickDay": "día de baja",
"movies": "películas",
"reading": "lectura",
"gaming": "videojuegos",
"music": "música",
"outdoors": "aire libre",
"fastFood": "comida rápida",
"noSweets": "sin dulces",
"bigMeal": "comilona",
"factorWork": "trabajo",
"factorSocial": "vida social",
"factorSleepQuality": "calidad del sueño",
"factorStress": "estrés",
"factorConflict": "conflicto",
"factorFamily": "familia"
}
},
"medications": {
@@ -1035,6 +1067,9 @@
"injectionSiteBodyOutlineAriaLabel": "Silueta del cuerpo",
"injectionSitePickerAriaLabel": "Selector de sitio de inyección",
"injectionSiteRecommendedAriaLabel": "{site}: sitio siguiente recomendado",
"injectionSiteLegendAriaLabel": "Leyenda de marcadores",
"injectionSiteLegendRecommended": "Próximo sitio recomendado",
"injectionSiteLegendLastUsed": "Último sitio usado",
"siteAbdomenLeft": "Abdomen, inferior izquierdo",
"siteAbdomenRight": "Abdomen, inferior derecho",
"siteAbdomenUpperLeft": "Abdomen, superior izquierdo",
@@ -1687,7 +1722,6 @@
},
"correlationRow": {
"title": "Correlaciones",
"subtitle": "Hipótesis predefinidas, con umbral n ≥ 14 y p < 0,05",
"disclaimer": "Las correlaciones son observacionales, no causales — habla con tu médico si tu tensión es alta.",
"experimentCta": "Iniciar experimento de 7 días",
"experimentTooltip": "Próximamente",
@@ -1715,6 +1749,8 @@
"errorCredentialExpired": "Tu conexión con el proveedor de IA ha caducado. Vuelve a conectarla en Ajustes para que el Coach siga funcionando.",
"tagline": "Coach personal de salud, apoyado en tus datos.",
"newChat": "Nueva conversación",
"maximizeAriaLabel": "Abrir en pantalla completa",
"minimizeAriaLabel": "Volver al panel",
"send": "Enviar",
"stop": "Detener",
"thinking": "Pensando…",
@@ -1979,6 +2015,14 @@
"inTargetShare": "{pct}% de los días registrados dentro del objetivo",
"adjustLink": "Ajustar el rango objetivo"
},
"primary": {
"avg30Label": "Promedio de 30 días",
"inRangeLabel": "En el objetivo",
"inRangeAria": "{pct}% de los días registrados dentro del objetivo"
},
"lastMeasurement": {
"label": "Última medición"
},
"explainer": {
"trigger": "¿Qué es {title}?",
"bloodPressureTitle": "Presión arterial",
@@ -2561,8 +2605,22 @@
"description": "Etiquetas y métricas que coinciden con tus días de mejor y peor ánimo, ordenadas por la fuerza con que se mueven juntas.",
"disclaimer": "Solo asociaciones, no causas. Consulta a tu médico antes de cambiar nada por esto."
},
"crosstab": {
"title": "Etiquetas y tus métricas de salud",
"description": "Cómo difiere una métrica de salud en los días que etiquetaste cada actividad, frente a los días que no.",
"pairLabel": "{tag} · {metric}",
"metricActiveEnergy": "Energía activa",
"metricSleepDuration": "Sueño",
"metricNextDayRecovery": "Recuperación al día siguiente",
"unitHours": "h",
"unitKcal": "kcal",
"unitScore": "pts",
"detailSameDay": "Promedia {withAvg} {unit} en los {withDays} días que lo etiquetaste frente a {withoutAvg} {unit} sin él.",
"detailNextDay": "Promedia {withAvg} {unit} el día después de los {withDays} días que lo etiquetaste frente a {withoutAvg} {unit} el resto.",
"disclaimer": "Son asociaciones en tus propios datos, no prueba de causa. Patrones que vale la pena observar."
},
"discovery": {
"title": "Patrones descubiertos",
"title": "Lo que destaca",
"description": "Relaciones de un día a otro con tu estado de ánimo que se mantienen tras corregir el haber probado muchos pares a la vez.",
"footer": "Extraídas de {tested} pares de un día a otro probados en todas tus métricas, tras la corrección de falsos descubrimientos.",
"empty": "Todavía ninguna relación de ánimo superó el umbral estadístico. Sigue registrando.",
@@ -2737,7 +2795,7 @@
"label": "Varias constantes fuera de la base",
"summary": "Hoy, {count} de tus constantes quedaron fuera de su rango personal.",
"vitals": "Fuera de rango: {list}",
"cardTitle": "Señal de hoy",
"cardTitle": "Señales del día",
"allClear": "Hoy todas tus constantes están dentro de su rango personal.",
"allClearMeta": "Se revisaron {count} de tus constantes frente a tu propio rango habitual.",
"watch": "Hoy una constante está fuera de su rango habitual.",
@@ -2751,7 +2809,8 @@
"headline": "Si este patrón continúa: los próximos {days} días",
"range": "Proyectado en torno a {low}–{high} {unit} en {days} días.",
"caveat": "Una proyección de tu propia tendencia reciente, no una predicción. La banda sombreada se ensancha para mostrar la rapidez con que crece la incertidumbre.",
"insufficient": "Aún no hay una tendencia lo bastante clara para proyectar: sigue registrando y aparecerá una proyección cuando el patrón reciente sea lo bastante marcado."
"insufficient": "Aún no hay una tendencia lo bastante clara para proyectar: sigue registrando y aparecerá una proyección cuando el patrón reciente sea lo bastante marcado.",
"daysNeeded": "Faltan unos {count} días más de registro para que pueda aparecer una proyección."
}
},
"cardioFitness": {
@@ -3086,7 +3145,7 @@
"description": "Por métrica: tu propio rango objetivo de valores saludables."
},
"sources": {
"title": "Fuentes",
"title": "Prioridad de fuentes",
"description": "Elige qué fuente prevalece cuando varias registran la misma métrica el mismo día.",
"cardTitle": "Prioridad de fuentes por métrica",
"help": "Cuando más de una fuente entrega el mismo valor el mismo día (p. ej. Withings y Apple Health informan ambos 1200 pasos), la fuente superior cuenta como la lectura canónica. Las demás fuentes se conservan para la traza de auditoría.",
@@ -3129,6 +3188,7 @@
"MANUAL": "Entrada manual",
"IMPORT": "Importar",
"WHOOP": "WHOOP",
"FITBIT": "Fitbit",
"COMPUTED": "Calculado"
},
"deviceLabels": {
@@ -3196,7 +3256,7 @@
}
},
"about": {
"title": "Acerca de",
"title": "Acerca de esta app",
"description": "Versión, licencia, enlaces."
},
"sharing": {
@@ -3491,6 +3551,33 @@
"whoopConnect": "Conectar con WHOOP",
"whoopNoCredentials": "Introduce tus credenciales de API arriba para conectar WHOOP.",
"whoopBackfillInProgress": "Importando tu historial de WHOOP en segundo plano…",
"fitbit": "Google Health",
"fitbitTag": "Fitbit y Pixel",
"fitbitDescription": "Conecta Google Health para sincronizar tus métricas de Fitbit y Pixel Watch: peso, grasa corporal, frecuencia cardíaca en reposo, saturación de oxígeno, variabilidad de la frecuencia cardíaca y más.",
"fitbitOverlapNote": "Si Google Health y otra fuente proporcionan la misma constante vital —frecuencia cardíaca en reposo, saturación de oxígeno, temperatura corporal o frecuencia respiratoria—, es posible que veas ambos valores hasta que una actualización futura defina una única fuente preferida.",
"fitbitExperimentalBadge": "Experimental",
"fitbitExperimentalNote": "Esta integración está en fase beta: la cobertura de datos y la asignación de campos aún se están verificando con cuentas reales de Google Health, por lo que algunos valores pueden faltar o cambiar en una actualización futura.",
"fitbitCredentials": "Credenciales de API",
"fitbitCredentialsHelp": "Registra tu propio cliente OAuth de Google Cloud para la API de Google Health y pega aquí su Client ID y Client Secret.",
"fitbitClientId": "Client ID",
"fitbitClientSecret": "Client Secret",
"fitbitCredentialsSaved": "Credenciales guardadas",
"fitbitCredentialsSavedPlaceholder": "Guardado: introduce nuevas para reemplazar",
"fitbitCredentialsSavedPlaceholderSecret": "Guardado: introduce nuevas para reemplazar",
"fitbitSaveCredentials": "Guardar credenciales",
"fitbitSync": "Sincronizar ahora",
"fitbitFullSync": "Sincronizar todos los datos",
"fitbitFullSyncTitle": "¿Sincronización completa?",
"fitbitFullSyncDescription": "Se sincronizará por completo todo el historial disponible de Google Health. Esto puede tardar un tiempo según tu historial.",
"fitbitSyncResult": "{count} mediciones sincronizadas",
"fitbitSyncFailed": "Error de sincronización",
"fitbitSynchronize": "Sincronizar",
"fitbitDisconnect": "Desconectar",
"fitbitDisconnectTitle": "¿Desconectar Google Health?",
"fitbitDisconnectDescription": "Se desconectará la conexión con Google Health. Los datos sincronizados previamente se conservarán.",
"fitbitConnect": "Conectar con Google Health",
"fitbitNoCredentials": "Introduce tus credenciales de API arriba para conectar Google Health.",
"fitbitBackfillInProgress": "Importando tu historial de Google Health en segundo plano…",
"integrations": {
"withings": {
"reconnect": {
@@ -3569,6 +3656,7 @@
"savingError": "Error al guardar",
"moodLogTitle": "moodLog",
"moodLogDescription": "Import mood data from moodLog",
"moodLogDeprecated": "Obsoleto: el estado de ánimo ahora se registra de forma nativa en HealthLog con etiquetas y factores. Esta integración sigue funcionando, pero se eliminará en una versión futura.",
"moodLogUrl": "moodLog URL",
"moodLogUrlPlaceholder": "https://mood.example.com",
"moodLogApiKey": "Clave de API",
+97 -9
View File
@@ -197,6 +197,7 @@
"collapseSidebar": "Réduire",
"userMenu": "Menu utilisateur",
"mood": "Humeur",
"workouts": "Entraînements",
"skipToContent": "Aller au contenu",
"sidebar": "Barre latérale",
"mainNavigation": "Navigation principale",
@@ -205,7 +206,17 @@
"loadingScreen": "Chargement…",
"more": "Plus",
"moreSheetTitle": "Plus",
"moreSheetDescription": "Pages qui ne tiennent pas dans la barre du bas."
"moreSheetDescription": "Accédez au reste de HealthLog.",
"capture": {
"title": "Enregistrer",
"description": "Que souhaitez-vous enregistrer ?",
"measurement": "Mesure",
"measurementDescription": "Poids, tension artérielle, pouls et plus",
"medication": "Médicament",
"medicationDescription": "Marquer une dose comme prise",
"mood": "Humeur",
"moodDescription": "Enregistrer l'humeur et les tags"
}
},
"auth": {
"login": "Se connecter",
@@ -264,6 +275,7 @@
"quickAddMeasurement": "Saisir une mesure",
"quickAddMood": "Saisir une humeur",
"quickAddMedicationIntake": "Saisir une prise",
"customizeDashboard": "Personnaliser le tableau de bord",
"medicationIntakeQuickAdd": {
"sheetTitle": "Saisir une prise de médicament",
"sheetDescription": "Enregistre la dose, l’heure et le médicament en quelques touches.",
@@ -469,6 +481,10 @@
"addEntry": "Ajouter",
"editEntry": "Modifier l’entrée",
"moodLevel": "Humeur",
"heroQuestion": "Comment vas-tu ?",
"factorYes": "Oui",
"factorNo": "Non",
"factorRatingOption": "{label} : {value} sur {max}",
"tags": "Étiquettes",
"tagsPlaceholder": "p. ex. sport, travail, famille",
"tagsHelp": "Sépare par des virgules",
@@ -510,7 +526,9 @@
"sleep": "Sommeil",
"health": "Santé",
"social": "Social",
"work": "Travail"
"work": "Travail",
"hobbies": "Loisirs",
"factors": "Facteurs"
},
"tag": {
"happy": "heureux",
@@ -542,7 +560,21 @@
"overtime": "heures sup",
"dayOff": "jour de repos",
"travel": "voyage",
"sickDay": "jour de maladie"
"sickDay": "jour de maladie",
"movies": "films",
"reading": "lecture",
"gaming": "jeux vidéo",
"music": "musique",
"outdoors": "plein air",
"fastFood": "fast-food",
"noSweets": "sans sucreries",
"bigMeal": "gros repas",
"factorWork": "travail",
"factorSocial": "vie sociale",
"factorSleepQuality": "qualité du sommeil",
"factorStress": "stress",
"factorConflict": "conflit",
"factorFamily": "famille"
}
},
"medications": {
@@ -1035,6 +1067,9 @@
"injectionSiteBodyOutlineAriaLabel": "Silhouette du corps",
"injectionSitePickerAriaLabel": "Sélecteur de site d'injection",
"injectionSiteRecommendedAriaLabel": "{site} — prochain site recommandé",
"injectionSiteLegendAriaLabel": "Légende des repères",
"injectionSiteLegendRecommended": "Prochain site recommandé",
"injectionSiteLegendLastUsed": "Dernier site utilisé",
"siteAbdomenLeft": "Abdomen, en bas à gauche",
"siteAbdomenRight": "Abdomen, en bas à droite",
"siteAbdomenUpperLeft": "Abdomen, en haut à gauche",
@@ -1687,7 +1722,6 @@
},
"correlationRow": {
"title": "Corrélations",
"subtitle": "Hypothèses prédéfinies, validées si n ≥ 14 et p < 0,05",
"disclaimer": "Les corrélations sont observationnelles, pas causales — parle à ton médecin si ta tension est élevée.",
"experimentCta": "Lancer une expérience de 7 jours",
"experimentTooltip": "Bientôt disponible",
@@ -1715,6 +1749,8 @@
"errorCredentialExpired": "Ta connexion au fournisseur d'IA a expiré. Reconnecte-la dans les Réglages pour que le Coach continue de fonctionner.",
"tagline": "Coach personnel de santé, ancré sur tes données.",
"newChat": "Nouvelle conversation",
"maximizeAriaLabel": "Ouvrir en pleine page",
"minimizeAriaLabel": "Revenir au panneau",
"send": "Envoyer",
"stop": "Arrêter",
"thinking": "Réflexion en cours…",
@@ -1979,6 +2015,14 @@
"inTargetShare": "{pct}% des jours enregistrés dans la cible",
"adjustLink": "Ajuster la plage cible"
},
"primary": {
"avg30Label": "Moyenne sur 30 jours",
"inRangeLabel": "Dans la cible",
"inRangeAria": "{pct}% des jours enregistrés dans la cible"
},
"lastMeasurement": {
"label": "Dernière mesure"
},
"explainer": {
"trigger": "Qu'est-ce que {title} ?",
"bloodPressureTitle": "Tension artérielle",
@@ -2561,8 +2605,22 @@
"description": "Les tags et mesures qui coïncident avec vos jours de meilleure et de moins bonne humeur, classés selon la force du lien.",
"disclaimer": "Des associations seulement, pas des causes. Parlez-en à votre médecin avant de changer quoi que ce soit."
},
"crosstab": {
"title": "Tags et vos métriques de santé",
"description": "Comment une métrique de santé diffère les jours où vous avez ajouté chaque tag, par rapport aux jours sans.",
"pairLabel": "{tag} · {metric}",
"metricActiveEnergy": "Énergie active",
"metricSleepDuration": "Sommeil",
"metricNextDayRecovery": "Récupération le lendemain",
"unitHours": "h",
"unitKcal": "kcal",
"unitScore": "pts",
"detailSameDay": "En moyenne {withAvg} {unit} les {withDays} jours avec ce tag contre {withoutAvg} {unit} sans.",
"detailNextDay": "En moyenne {withAvg} {unit} le lendemain des {withDays} jours avec ce tag contre {withoutAvg} {unit} sinon.",
"disclaimer": "Ce sont des associations dans vos propres données, pas une preuve de cause. Des tendances à surveiller."
},
"discovery": {
"title": "Tendances détectées",
"title": "Ce qui ressort",
"description": "Des relations d'un jour à l'autre autour de votre humeur qui tiennent après correction des tests multiples.",
"footer": "Issues de {tested} paires d'un jour à l'autre testées sur l'ensemble de vos mesures, après correction des fausses découvertes.",
"empty": "Aucune relation d'humeur n'a encore franchi le seuil statistique. Continuez à enregistrer.",
@@ -2737,7 +2795,7 @@
"label": "Plusieurs constantes hors référence",
"summary": "Aujourd’hui, {count} de vos constantes sont sorties de leur plage personnelle.",
"vitals": "Hors plage : {list}",
"cardTitle": "Signal du jour",
"cardTitle": "Signaux du jour",
"allClear": "Aujourd’hui, toutes vos constantes sont dans leur plage personnelle.",
"allClearMeta": "{count} de vos constantes vérifiées par rapport à votre propre plage habituelle.",
"watch": "Aujourd’hui, une constante est hors de sa plage habituelle.",
@@ -2751,7 +2809,8 @@
"headline": "Si ce schéma se poursuit — les {days} prochains jours",
"range": "Projeté autour de {low}–{high} {unit} dans {days} jours.",
"caveat": "Une projection de votre propre tendance récente, pas une prédiction. La bande ombrée s’élargit pour montrer la vitesse à laquelle l’incertitude augmente.",
"insufficient": "Pas encore de tendance assez nette pour projeter — continuez à enregistrer et une projection apparaîtra dès que le schéma récent sera suffisamment marqué."
"insufficient": "Pas encore de tendance assez nette pour projeter — continuez à enregistrer et une projection apparaîtra dès que le schéma récent sera suffisamment marqué.",
"daysNeeded": "Encore environ {count} jours d’enregistrement nécessaires avant qu’une projection puisse apparaître."
}
},
"cardioFitness": {
@@ -3086,7 +3145,7 @@
"description": "Par métrique : votre propre plage cible pour des valeurs saines."
},
"sources": {
"title": "Sources",
"title": "Priorité des sources",
"description": "Choisissez quelle source prévaut quand plusieurs enregistrent la même métrique le même jour.",
"cardTitle": "Priorité des sources par métrique",
"help": "Lorsque plusieurs sources fournissent la même valeur le même jour (p. ex. Withings et Apple Health indiquent tous deux 1 200 pas), la source en tête compte comme la lecture canonique. Les autres sources restent enregistrées pour la piste d’audit.",
@@ -3129,6 +3188,7 @@
"MANUAL": "Saisie manuelle",
"IMPORT": "Importer",
"WHOOP": "WHOOP",
"FITBIT": "Fitbit",
"COMPUTED": "Calculé"
},
"deviceLabels": {
@@ -3196,7 +3256,7 @@
}
},
"about": {
"title": "À propos",
"title": "À propos de cette app",
"description": "Version, licence, liens."
},
"sharing": {
@@ -3491,6 +3551,33 @@
"whoopConnect": "Se connecter avec WHOOP",
"whoopNoCredentials": "Veuillez saisir vos identifiants API ci-dessus pour connecter WHOOP.",
"whoopBackfillInProgress": "Importation de votre historique WHOOP en arrière-plan…",
"fitbit": "Google Health",
"fitbitTag": "Fitbit et Pixel",
"fitbitDescription": "Connectez Google Health pour synchroniser vos données Fitbit et Pixel Watch : poids, masse grasse, fréquence cardiaque au repos, saturation en oxygène, variabilité de la fréquence cardiaque et plus encore.",
"fitbitOverlapNote": "Si Google Health et une autre source fournissent la même constante vitale — fréquence cardiaque au repos, saturation en oxygène, température corporelle ou fréquence respiratoire —, vous pourriez voir les deux valeurs jusqu'à ce qu'une future mise à jour retienne une seule source préférée.",
"fitbitExperimentalBadge": "Expérimental",
"fitbitExperimentalNote": "Cette intégration est en bêta : la couverture des données et la correspondance des champs sont encore en cours de vérification avec de vrais comptes Google Health, donc certaines valeurs peuvent manquer ou changer lors d'une future mise à jour.",
"fitbitCredentials": "Identifiants d'API",
"fitbitCredentialsHelp": "Enregistrez votre propre client OAuth Google Cloud pour l'API Google Health et collez ici son Client ID et son Client Secret.",
"fitbitClientId": "Client ID",
"fitbitClientSecret": "Client Secret",
"fitbitCredentialsSaved": "Identifiants enregistrés",
"fitbitCredentialsSavedPlaceholder": "Enregistré — saisissez-en de nouveaux pour remplacer",
"fitbitCredentialsSavedPlaceholderSecret": "Enregistré — saisissez-en de nouveaux pour remplacer",
"fitbitSaveCredentials": "Enregistrer les identifiants",
"fitbitSync": "Synchroniser maintenant",
"fitbitFullSync": "Tout synchroniser",
"fitbitFullSyncTitle": "Synchronisation complète ?",
"fitbitFullSyncDescription": "Tout l'historique Google Health disponible sera entièrement synchronisé. Cela peut prendre un certain temps selon votre historique.",
"fitbitSyncResult": "{count} mesures synchronisées",
"fitbitSyncFailed": "Échec de la synchronisation",
"fitbitSynchronize": "Synchroniser",
"fitbitDisconnect": "Déconnecter",
"fitbitDisconnectTitle": "Déconnecter Google Health ?",
"fitbitDisconnectDescription": "La connexion à Google Health sera déconnectée. Les données déjà synchronisées seront conservées.",
"fitbitConnect": "Se connecter à Google Health",
"fitbitNoCredentials": "Veuillez saisir vos identifiants d'API ci-dessus pour connecter Google Health.",
"fitbitBackfillInProgress": "Importation de votre historique Google Health en arrière-plan…",
"integrations": {
"withings": {
"reconnect": {
@@ -3569,6 +3656,7 @@
"savingError": "Erreur lors de l’enregistrement",
"moodLogTitle": "moodLog",
"moodLogDescription": "Import mood data from moodLog",
"moodLogDeprecated": "Obsolète : l’humeur est désormais suivie nativement dans HealthLog avec tags et facteurs. Cette intégration fonctionne encore mais sera supprimée dans une version future.",
"moodLogUrl": "moodLog URL",
"moodLogUrlPlaceholder": "https://mood.example.com",
"moodLogApiKey": "Clé API",
+97 -9
View File
@@ -197,6 +197,7 @@
"collapseSidebar": "Riduci",
"userMenu": "Menu utente",
"mood": "Umore",
"workouts": "Allenamenti",
"skipToContent": "Vai al contenuto",
"sidebar": "Barra laterale",
"mainNavigation": "Navigazione principale",
@@ -205,7 +206,17 @@
"loadingScreen": "Caricamento…",
"more": "Altro",
"moreSheetTitle": "Altro",
"moreSheetDescription": "Pagine che non entrano nella barra inferiore."
"moreSheetDescription": "Vai al resto di HealthLog.",
"capture": {
"title": "Registra",
"description": "Cosa vuoi registrare?",
"measurement": "Misurazione",
"measurementDescription": "Peso, pressione, battito e altro",
"medication": "Farmaco",
"medicationDescription": "Segna una dose come assunta",
"mood": "Umore",
"moodDescription": "Registra umore e tag"
}
},
"auth": {
"login": "Accedi",
@@ -264,6 +275,7 @@
"quickAddMeasurement": "Registra misurazione",
"quickAddMood": "Registra umore",
"quickAddMedicationIntake": "Registra assunzione",
"customizeDashboard": "Personalizza dashboard",
"medicationIntakeQuickAdd": {
"sheetTitle": "Registra assunzione del farmaco",
"sheetDescription": "Registra dose, orario e farmaco con pochi tap.",
@@ -469,6 +481,10 @@
"addEntry": "Aggiungi",
"editEntry": "Modifica voce",
"moodLevel": "Umore",
"heroQuestion": "Come stai?",
"factorYes": "Sì",
"factorNo": "No",
"factorRatingOption": "{label}: {value} di {max}",
"tags": "Tag",
"tagsPlaceholder": "es. sport, lavoro, famiglia",
"tagsHelp": "Separa con virgole",
@@ -510,7 +526,9 @@
"sleep": "Sonno",
"health": "Salute",
"social": "Sociale",
"work": "Lavoro"
"work": "Lavoro",
"hobbies": "Hobby",
"factors": "Fattori"
},
"tag": {
"happy": "felice",
@@ -542,7 +560,21 @@
"overtime": "straordinari",
"dayOff": "giorno libero",
"travel": "viaggio",
"sickDay": "giorno di malattia"
"sickDay": "giorno di malattia",
"movies": "film",
"reading": "lettura",
"gaming": "videogiochi",
"music": "musica",
"outdoors": "all'aperto",
"fastFood": "fast food",
"noSweets": "niente dolci",
"bigMeal": "pasto abbondante",
"factorWork": "lavoro",
"factorSocial": "vita sociale",
"factorSleepQuality": "qualità del sonno",
"factorStress": "stress",
"factorConflict": "conflitto",
"factorFamily": "famiglia"
}
},
"medications": {
@@ -1035,6 +1067,9 @@
"injectionSiteBodyOutlineAriaLabel": "Sagoma del corpo",
"injectionSitePickerAriaLabel": "Selettore del sito di iniezione",
"injectionSiteRecommendedAriaLabel": "{site} — prossimo sito consigliato",
"injectionSiteLegendAriaLabel": "Legenda dei segnaposto",
"injectionSiteLegendRecommended": "Prossimo sito consigliato",
"injectionSiteLegendLastUsed": "Ultimo sito utilizzato",
"siteAbdomenLeft": "Addome, basso a sinistra",
"siteAbdomenRight": "Addome, basso a destra",
"siteAbdomenUpperLeft": "Addome, alto a sinistra",
@@ -1687,7 +1722,6 @@
},
"correlationRow": {
"title": "Correlazioni",
"subtitle": "Ipotesi predefinite, vincolate a n ≥ 14 e p < 0,05",
"disclaimer": "Le correlazioni sono osservazionali, non causali — parla con il medico se la pressione è alta.",
"experimentCta": "Avvia esperimento di 7 giorni",
"experimentTooltip": "In arrivo",
@@ -1715,6 +1749,8 @@
"errorCredentialExpired": "La connessione al provider di IA è scaduta. Riconnettila nelle Impostazioni per mantenere attivo il Coach.",
"tagline": "Coach personale di salute, basato sui tuoi dati.",
"newChat": "Nuova chat",
"maximizeAriaLabel": "Apri a pagina intera",
"minimizeAriaLabel": "Torna al pannello",
"send": "Invia",
"stop": "Interrompi",
"thinking": "Sto pensando…",
@@ -1979,6 +2015,14 @@
"inTargetShare": "{pct}% dei giorni registrati nell'obiettivo",
"adjustLink": "Modifica l'intervallo obiettivo"
},
"primary": {
"avg30Label": "Media a 30 giorni",
"inRangeLabel": "Nell'obiettivo",
"inRangeAria": "{pct}% dei giorni registrati nell'obiettivo"
},
"lastMeasurement": {
"label": "Ultima misurazione"
},
"explainer": {
"trigger": "Che cos'è {title}?",
"bloodPressureTitle": "Pressione arteriosa",
@@ -2561,8 +2605,22 @@
"description": "Tag e parametri che coincidono con le tue giornate di umore migliore e peggiore, ordinati per quanto si muovono insieme.",
"disclaimer": "Solo associazioni, non cause. Parla con il tuo medico prima di cambiare qualcosa."
},
"crosstab": {
"title": "Tag e le tue metriche di salute",
"description": "Come una metrica di salute cambia nei giorni in cui hai aggiunto un tag rispetto ai giorni senza.",
"pairLabel": "{tag} · {metric}",
"metricActiveEnergy": "Energia attiva",
"metricSleepDuration": "Sonno",
"metricNextDayRecovery": "Recupero il giorno dopo",
"unitHours": "h",
"unitKcal": "kcal",
"unitScore": "pt",
"detailSameDay": "In media {withAvg} {unit} nei {withDays} giorni con questo tag contro {withoutAvg} {unit} senza.",
"detailNextDay": "In media {withAvg} {unit} il giorno dopo i {withDays} giorni con questo tag contro {withoutAvg} {unit} altrimenti.",
"disclaimer": "Sono associazioni nei tuoi dati, non prova di causa. Schemi che vale la pena osservare."
},
"discovery": {
"title": "Schemi rilevati",
"title": "Cosa risalta",
"description": "Relazioni giorno per giorno legate al tuo umore che reggono dopo aver corretto per i test multipli.",
"footer": "Estratte da {tested} coppie giorno per giorno testate su tutte le tue misure, dopo la correzione per le false scoperte.",
"empty": "Nessuna relazione sull'umore ha ancora superato la soglia statistica. Continua a registrare.",
@@ -2737,7 +2795,7 @@
"label": "Più parametri fuori dalla base",
"summary": "Oggi {count} dei tuoi parametri vitali sono usciti dal loro intervallo personale.",
"vitals": "Fuori intervallo: {list}",
"cardTitle": "Segnale di oggi",
"cardTitle": "Segnali del giorno",
"allClear": "Oggi tutti i tuoi parametri vitali sono nel loro intervallo personale.",
"allClearMeta": "Controllati {count} dei tuoi parametri rispetto al tuo intervallo abituale.",
"watch": "Oggi un parametro è fuori dal suo intervallo abituale.",
@@ -2751,7 +2809,8 @@
"headline": "Se questo schema continua — i prossimi {days} giorni",
"range": "Proiettato intorno a {low}–{high} {unit} tra {days} giorni.",
"caveat": "Una proiezione della tua tendenza recente, non una previsione. La banda ombreggiata si allarga per mostrare quanto rapidamente cresce l’incertezza.",
"insufficient": "Non c’è ancora una tendenza abbastanza chiara da proiettare — continua a registrare e comparirà una proiezione quando lo schema recente sarà abbastanza marcato."
"insufficient": "Non c’è ancora una tendenza abbastanza chiara da proiettare — continua a registrare e comparirà una proiezione quando lo schema recente sarà abbastanza marcato.",
"daysNeeded": "Servono ancora circa {count} giorni di registrazione prima che possa comparire una proiezione."
}
},
"cardioFitness": {
@@ -3086,7 +3145,7 @@
"description": "Per metrica: il tuo intervallo target per valori sani."
},
"sources": {
"title": "Sorgenti",
"title": "Priorità delle fonti",
"description": "Scegli quale fonte prevale quando più di una registra la stessa metrica lo stesso giorno.",
"cardTitle": "Priorità delle fonti per metrica",
"help": "Quando più fonti forniscono lo stesso valore nello stesso giorno (ad es. Withings e Apple Health riportano entrambe 1.200 passi), la fonte in cima conta come lettura canonica. Le altre fonti restano memorizzate per la traccia di controllo.",
@@ -3129,6 +3188,7 @@
"MANUAL": "Inserimento manuale",
"IMPORT": "Importa",
"WHOOP": "WHOOP",
"FITBIT": "Fitbit",
"COMPUTED": "Calcolato"
},
"deviceLabels": {
@@ -3196,7 +3256,7 @@
}
},
"about": {
"title": "Informazioni",
"title": "Informazioni su questa app",
"description": "Versione, licenza, link."
},
"sharing": {
@@ -3491,6 +3551,33 @@
"whoopConnect": "Connetti con WHOOP",
"whoopNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere WHOOP.",
"whoopBackfillInProgress": "Importazione dello storico WHOOP in background…",
"fitbit": "Google Health",
"fitbitTag": "Fitbit e Pixel",
"fitbitDescription": "Collega Google Health per sincronizzare i dati di Fitbit e Pixel Watch: peso, massa grassa, frequenza cardiaca a riposo, saturazione di ossigeno, variabilità della frequenza cardiaca e altro.",
"fitbitOverlapNote": "Se Google Health e un'altra fonte forniscono lo stesso parametro vitale — frequenza cardiaca a riposo, saturazione di ossigeno, temperatura corporea o frequenza respiratoria —, potresti vedere entrambi i valori finché un futuro aggiornamento non sceglierà un'unica fonte preferita.",
"fitbitExperimentalBadge": "Sperimentale",
"fitbitExperimentalNote": "Questa integrazione è in versione beta: la copertura dei dati e la mappatura dei campi sono ancora in fase di verifica con account Google Health reali, quindi alcuni valori potrebbero mancare o cambiare in un futuro aggiornamento.",
"fitbitCredentials": "Credenziali API",
"fitbitCredentialsHelp": "Registra il tuo client OAuth di Google Cloud per l'API Google Health e incolla qui il suo Client ID e Client Secret.",
"fitbitClientId": "Client ID",
"fitbitClientSecret": "Client Secret",
"fitbitCredentialsSaved": "Credenziali salvate",
"fitbitCredentialsSavedPlaceholder": "Salvato — inserisci nuove per sostituire",
"fitbitCredentialsSavedPlaceholderSecret": "Salvato — inserisci nuove per sostituire",
"fitbitSaveCredentials": "Salva credenziali",
"fitbitSync": "Sincronizza ora",
"fitbitFullSync": "Sincronizza tutti i dati",
"fitbitFullSyncTitle": "Sincronizzazione completa?",
"fitbitFullSyncDescription": "Tutto lo storico disponibile di Google Health verrà sincronizzato completamente. L'operazione può richiedere del tempo a seconda del tuo storico.",
"fitbitSyncResult": "{count} misurazioni sincronizzate",
"fitbitSyncFailed": "Sincronizzazione non riuscita",
"fitbitSynchronize": "Sincronizza",
"fitbitDisconnect": "Disconnetti",
"fitbitDisconnectTitle": "Disconnettere Google Health?",
"fitbitDisconnectDescription": "La connessione a Google Health verrà disconnessa. I dati già sincronizzati saranno conservati.",
"fitbitConnect": "Connetti con Google Health",
"fitbitNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere Google Health.",
"fitbitBackfillInProgress": "Importazione dello storico Google Health in background…",
"integrations": {
"withings": {
"reconnect": {
@@ -3569,6 +3656,7 @@
"savingError": "Errore durante il salvataggio",
"moodLogTitle": "moodLog",
"moodLogDescription": "Import mood data from moodLog",
"moodLogDeprecated": "Obsoleto: l’umore ora è registrato nativamente in HealthLog con tag e fattori. Questa integrazione funziona ancora ma verrà rimossa in una versione futura.",
"moodLogUrl": "moodLog URL",
"moodLogUrlPlaceholder": "https://mood.example.com",
"moodLogApiKey": "Chiave API",
+97 -9
View File
@@ -197,6 +197,7 @@
"collapseSidebar": "Zwiń",
"userMenu": "Menu użytkownika",
"mood": "Nastrój",
"workouts": "Treningi",
"skipToContent": "Przejdź do treści",
"sidebar": "Pasek boczny",
"mainNavigation": "Nawigacja główna",
@@ -205,7 +206,17 @@
"loadingScreen": "Ładowanie…",
"more": "Więcej",
"moreSheetTitle": "Więcej",
"moreSheetDescription": "Strony, które nie mieszczą się w dolnym pasku."
"moreSheetDescription": "Przejdź do reszty HealthLog.",
"capture": {
"title": "Zapisz",
"description": "Co chcesz zapisać?",
"measurement": "Pomiar",
"measurementDescription": "Waga, ciśnienie, tętno i więcej",
"medication": "Lek",
"medicationDescription": "Oznacz dawkę jako przyjętą",
"mood": "Nastrój",
"moodDescription": "Zapisz nastrój i tagi"
}
},
"auth": {
"login": "Zaloguj się",
@@ -264,6 +275,7 @@
"quickAddMeasurement": "Zarejestruj pomiar",
"quickAddMood": "Zarejestruj nastrój",
"quickAddMedicationIntake": "Zarejestruj przyjęcie",
"customizeDashboard": "Dostosuj pulpit",
"medicationIntakeQuickAdd": {
"sheetTitle": "Zarejestruj przyjęcie leku",
"sheetDescription": "Zapisz dawkę, godzinę i lek w kilku stuknięciach.",
@@ -469,6 +481,10 @@
"addEntry": "Dodaj",
"editEntry": "Edytuj wpis",
"moodLevel": "Nastrój",
"heroQuestion": "Jak się masz?",
"factorYes": "Tak",
"factorNo": "Nie",
"factorRatingOption": "{label}: {value} z {max}",
"tags": "Tagi",
"tagsPlaceholder": "np. sport, praca, rodzina",
"tagsHelp": "Oddziel przecinkami",
@@ -510,7 +526,9 @@
"sleep": "Sen",
"health": "Zdrowie",
"social": "Społeczne",
"work": "Praca"
"work": "Praca",
"hobbies": "Hobby",
"factors": "Czynniki"
},
"tag": {
"happy": "szczęśliwy",
@@ -542,7 +560,21 @@
"overtime": "nadgodziny",
"dayOff": "dzień wolny",
"travel": "podróż",
"sickDay": "dzień chorobowy"
"sickDay": "dzień chorobowy",
"movies": "filmy",
"reading": "czytanie",
"gaming": "gry",
"music": "muzyka",
"outdoors": "na dworze",
"fastFood": "fast food",
"noSweets": "bez słodyczy",
"bigMeal": "obfity posiłek",
"factorWork": "praca",
"factorSocial": "życie towarzyskie",
"factorSleepQuality": "jakość snu",
"factorStress": "stres",
"factorConflict": "konflikt",
"factorFamily": "rodzina"
}
},
"medications": {
@@ -1035,6 +1067,9 @@
"injectionSiteBodyOutlineAriaLabel": "Sylwetka ciała",
"injectionSitePickerAriaLabel": "Wybór miejsca iniekcji",
"injectionSiteRecommendedAriaLabel": "{site} — zalecane następne miejsce",
"injectionSiteLegendAriaLabel": "Legenda oznaczeń",
"injectionSiteLegendRecommended": "Zalecane następne miejsce",
"injectionSiteLegendLastUsed": "Ostatnio użyte miejsce",
"siteAbdomenLeft": "Brzuch, dolny lewy",
"siteAbdomenRight": "Brzuch, dolny prawy",
"siteAbdomenUpperLeft": "Brzuch, górny lewy",
@@ -1687,7 +1722,6 @@
},
"correlationRow": {
"title": "Korelacje",
"subtitle": "Hipotezy zdefiniowane wcześniej, z progiem n ≥ 14 i p < 0,05",
"disclaimer": "Korelacje są obserwacyjne, nie przyczynowe — porozmawiaj z lekarzem, jeśli ciśnienie jest wysokie.",
"experimentCta": "Uruchom 7-dniowy eksperyment",
"experimentTooltip": "Wkrótce",
@@ -1715,6 +1749,8 @@
"errorCredentialExpired": "Połączenie z dostawcą AI wygasło. Połącz je ponownie w Ustawieniach, aby Coach działał dalej.",
"tagline": "Osobisty coach zdrowia, oparty na twoich danych.",
"newChat": "Nowa rozmowa",
"maximizeAriaLabel": "Otwórz na pełnej stronie",
"minimizeAriaLabel": "Powrót do panelu",
"send": "Wyślij",
"stop": "Zatrzymaj",
"thinking": "Myślę…",
@@ -1979,6 +2015,14 @@
"inTargetShare": "{pct}% zarejestrowanych dni w zakresie celu",
"adjustLink": "Dostosuj zakres docelowy"
},
"primary": {
"avg30Label": "Średnia z 30 dni",
"inRangeLabel": "W zakresie",
"inRangeAria": "{pct}% zarejestrowanych dni w zakresie celu"
},
"lastMeasurement": {
"label": "Ostatni pomiar"
},
"explainer": {
"trigger": "Czym jest {title}?",
"bloodPressureTitle": "Ciśnienie krwi",
@@ -2561,8 +2605,22 @@
"description": "Tagi i wskaźniki, które pokrywają się z dniami lepszego i gorszego nastroju, uszeregowane według siły powiązania.",
"disclaimer": "Tylko powiązania, nie przyczyny. Skonsultuj się z lekarzem, zanim coś zmienisz."
},
"crosstab": {
"title": "Tagi a Twoje wskaźniki zdrowia",
"description": "Jak wskaźnik zdrowia różni się w dniach z danym tagiem w porównaniu z dniami bez niego.",
"pairLabel": "{tag} · {metric}",
"metricActiveEnergy": "Aktywna energia",
"metricSleepDuration": "Sen",
"metricNextDayRecovery": "Regeneracja następnego dnia",
"unitHours": "godz.",
"unitKcal": "kcal",
"unitScore": "pkt",
"detailSameDay": "Średnio {withAvg} {unit} w {withDays} dniach z tym tagiem wobec {withoutAvg} {unit} bez niego.",
"detailNextDay": "Średnio {withAvg} {unit} dzień po {withDays} dniach z tym tagiem wobec {withoutAvg} {unit} w pozostałe dni.",
"disclaimer": "To powiązania w Twoich danych, nie dowód przyczyny. Wzorce warte obserwacji."
},
"discovery": {
"title": "Wykryte wzorce",
"title": "Co się wyróżnia",
"description": "Relacje dzień po dniu dotyczące Twojego nastroju, które utrzymują się po korekcie wielokrotnych testów.",
"footer": "Wyłonione z {tested} par dzień po dniu testowanych na wszystkich Twoich wskaźnikach, po korekcie fałszywych odkryć.",
"empty": "Żadna relacja nastroju nie przekroczyła jeszcze progu statystycznego. Notuj dalej.",
@@ -2737,7 +2795,7 @@
"label": "Kilka parametrów poza bazą",
"summary": "Dziś {count} Twoich parametrów życiowych znalazło się poza osobistym zakresem.",
"vitals": "Poza zakresem: {list}",
"cardTitle": "Sygnał dnia",
"cardTitle": "Sygnały dnia",
"allClear": "Dziś wszystkie Twoje parametry życiowe mieszczą się w osobistym zakresie.",
"allClearMeta": "Sprawdzono {count} Twoich parametrów względem Twojego typowego zakresu.",
"watch": "Dziś jeden parametr jest poza swoim zwykłym zakresem.",
@@ -2751,7 +2809,8 @@
"headline": "Jeśli ten wzorzec się utrzyma — następne {days} dni",
"range": "Rzutowane na około {low}–{high} {unit} za {days} dni.",
"caveat": "Rzut Twojego własnego ostatniego trendu, nie prognoza. Zacieniony pas poszerza się, pokazując, jak szybko rośnie niepewność.",
"insufficient": "Jeszcze brak wystarczająco wyraźnego trendu do rzutowania — rejestruj dalej, a rzut pojawi się, gdy ostatni wzorzec będzie dość wyraźny."
"insufficient": "Jeszcze brak wystarczająco wyraźnego trendu do rzutowania — rejestruj dalej, a rzut pojawi się, gdy ostatni wzorzec będzie dość wyraźny.",
"daysNeeded": "Potrzeba jeszcze około {count} dni rejestrowania, zanim pojawi się rzut."
}
},
"cardioFitness": {
@@ -3086,7 +3145,7 @@
"description": "Dla każdej metryki: własny zakres docelowy zdrowych wartości."
},
"sources": {
"title": "Źródła",
"title": "Priorytet źródeł",
"description": "Wybierz, które źródło wygrywa, gdy kilka rejestruje tę samą metrykę tego samego dnia.",
"cardTitle": "Priorytet źródeł według metryki",
"help": "Gdy więcej niż jedno źródło dostarcza tę samą wartość tego samego dnia (np. Withings i Apple Health raportują po 1200 kroków), źródło na górze liczy się jako odczyt kanoniczny. Pozostałe źródła pozostają zapisane na potrzeby ścieżki audytu.",
@@ -3129,6 +3188,7 @@
"MANUAL": "Wpis ręczny",
"IMPORT": "Importuj",
"WHOOP": "WHOOP",
"FITBIT": "Fitbit",
"COMPUTED": "Obliczone"
},
"deviceLabels": {
@@ -3196,7 +3256,7 @@
}
},
"about": {
"title": "O aplikacji",
"title": "O tej aplikacji",
"description": "Wersja, licencja, linki."
},
"sharing": {
@@ -3491,6 +3551,33 @@
"whoopConnect": "Połącz z WHOOP",
"whoopNoCredentials": "Wprowadź powyżej swoje dane API, aby połączyć WHOOP.",
"whoopBackfillInProgress": "Importowanie historii WHOOP w tle…",
"fitbit": "Google Health",
"fitbitTag": "Fitbit i Pixel",
"fitbitDescription": "Połącz Google Health, aby synchronizować dane z Fitbita i Pixel Watch — wagę, tkankę tłuszczową, tętno spoczynkowe, saturację, zmienność rytmu serca i więcej.",
"fitbitOverlapNote": "Jeśli Google Health i inne źródło dostarczają ten sam parametr życiowy — tętno spoczynkowe, saturację, temperaturę ciała lub częstość oddechów — możesz widzieć obie wartości, dopóki przyszła aktualizacja nie wybierze jednego preferowanego źródła.",
"fitbitExperimentalBadge": "Eksperymentalne",
"fitbitExperimentalNote": "Ta integracja jest w wersji beta — zakres danych i mapowanie pól są wciąż weryfikowane na rzeczywistych kontach Google Health, więc niektóre wartości mogą być niedostępne lub zmienić się w przyszłej aktualizacji.",
"fitbitCredentials": "Dane uwierzytelniające API",
"fitbitCredentialsHelp": "Zarejestruj własny klient OAuth Google Cloud dla interfejsu Google Health API i wklej tutaj jego Client ID oraz Client Secret.",
"fitbitClientId": "Client ID",
"fitbitClientSecret": "Client Secret",
"fitbitCredentialsSaved": "Dane uwierzytelniające zapisane",
"fitbitCredentialsSavedPlaceholder": "Zapisano — wpisz nowe, aby zastąpić",
"fitbitCredentialsSavedPlaceholderSecret": "Zapisano — wpisz nowe, aby zastąpić",
"fitbitSaveCredentials": "Zapisz dane uwierzytelniające",
"fitbitSync": "Synchronizuj teraz",
"fitbitFullSync": "Synchronizuj wszystkie dane",
"fitbitFullSyncTitle": "Pełna synchronizacja?",
"fitbitFullSyncDescription": "Cała dostępna historia Google Health zostanie w pełni zsynchronizowana. W zależności od historii może to chwilę potrwać.",
"fitbitSyncResult": "Zsynchronizowano {count} pomiarów",
"fitbitSyncFailed": "Synchronizacja nie powiodła się",
"fitbitSynchronize": "Synchronizuj",
"fitbitDisconnect": "Rozłącz",
"fitbitDisconnectTitle": "Rozłączyć Google Health?",
"fitbitDisconnectDescription": "Połączenie z Google Health zostanie rozłączone. Wcześniej zsynchronizowane dane zostaną zachowane.",
"fitbitConnect": "Połącz z Google Health",
"fitbitNoCredentials": "Wprowadź powyżej swoje dane uwierzytelniające API, aby połączyć Google Health.",
"fitbitBackfillInProgress": "Importowanie historii Google Health w tle…",
"integrations": {
"withings": {
"reconnect": {
@@ -3569,6 +3656,7 @@
"savingError": "Błąd podczas zapisywania",
"moodLogTitle": "moodLog",
"moodLogDescription": "Import mood data from moodLog",
"moodLogDeprecated": "Wycofane: nastrój jest teraz śledzony natywnie w HealthLog z tagami i czynnikami. Ta integracja nadal działa, ale zostanie usunięta w przyszłej wersji.",
"moodLogUrl": "moodLog URL",
"moodLogUrlPlaceholder": "https://mood.example.com",
"moodLogApiKey": "Klucz API",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.11.5",
"version": "1.12.0",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
@@ -0,0 +1,83 @@
-- v1.12.0 — Fitbit/Pixel integration via the Google Health API (schema + data
-- layer).
--
-- Purely-additive: one enum-extension, two new tables, and two new nullable
-- columns on `users`. No backfill, no existing row touched. Unlike the WHOOP
-- migration (0111) this adds NO new `measurement_type` values — every Fitbit
-- launch metric maps onto an existing type.
--
-- 1. `measurement_source` += `FITBIT` for the server-side native ingest.
-- 2. `fitbit_connections` — per-user encrypted token row (1:1 with users),
-- mirroring `whoop_connections` minus `max_heart_rate`, plus
-- `fitbit_user_id` and `backfill_completed_at`.
-- 3. `fitbit_oauth_states` — short-lived `(nonce → userId)` CSRF ledger,
-- identical in shape to `whoop_oauth_states`.
-- 4. `users` += `fitbit_client_id_encrypted` / `fitbit_client_secret_encrypted`
-- — per-user BYO-keys (Google's Testing-mode per-app authorized-user cap
-- makes a single shared client unworkable for a multi-operator product).
--
-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only.
--
-- Reversibility: Postgres cannot remove an enum value, so `FITBIT` stays; with
-- no rows carrying it it is inert. The two tables drop with `DROP TABLE IF
-- EXISTS`, the two columns with `DROP COLUMN IF EXISTS`.
-- ── 1. measurement_source — append the Fitbit server-owned source ──────
ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'FITBIT';
-- ── 2. fitbit_connections ─────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS "fitbit_connections" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"fitbit_user_id" TEXT NOT NULL,
"access_token" TEXT NOT NULL,
"refresh_token" TEXT NOT NULL,
"token_expires_at" TIMESTAMP(3) NOT NULL,
"scope" TEXT,
"last_synced_at" TIMESTAMP(3),
"backfill_completed_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "fitbit_connections_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "fitbit_connections_user_id_key"
ON "fitbit_connections" ("user_id");
DO $$ BEGIN
ALTER TABLE "fitbit_connections"
ADD CONSTRAINT "fitbit_connections_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- ── 3. fitbit_oauth_states ────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS "fitbit_oauth_states" (
"nonce" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "fitbit_oauth_states_pkey" PRIMARY KEY ("nonce")
);
CREATE INDEX IF NOT EXISTS "fitbit_oauth_states_expires_at_idx"
ON "fitbit_oauth_states" ("expires_at");
DO $$ BEGIN
ALTER TABLE "fitbit_oauth_states"
ADD CONSTRAINT "fitbit_oauth_states_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- ── 4. users — per-user Fitbit BYO-key columns (encrypted at app level) ─
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "fitbit_client_id_encrypted" TEXT;
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "fitbit_client_secret_encrypted" TEXT;
@@ -0,0 +1,62 @@
-- v1.12.0 — deepen the structured mood-tag taxonomy.
--
-- Purely additive on top of migration 0101's catalog: one new `hobbies`
-- category (movies / reading / gaming / music / outdoors) plus a set of
-- nutrition-detail tags appended to the existing `health` category
-- (`fast_food` / `no_sweets` / `big_meal`, alongside the original
-- `ate_well`). No table changes, no per-user data touched — this only
-- seeds new global catalog rows.
--
-- `label_key` columns carry an i18n message key (resolved client-side
-- against all six locales), same convention as the original seed. The
-- inserts use deterministic ids + ON CONFLICT upsert by `key` so a
-- re-run is idempotent, mirroring 0101 exactly.
-- ─── New category: hobbies ───────────────────────────────────────────
INSERT INTO "mood_tag_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mtc_hobbies', 'hobbies', 'mood.tagCategory.hobbies', 'Palette', 5, true)
ON CONFLICT ("key") DO UPDATE SET
"label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_movies', 'mtc_hobbies', 'movies', 'mood.tag.movies', 'Film', 0, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_reading', 'mtc_hobbies', 'reading', 'mood.tag.reading', 'BookOpen', 1, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_gaming', 'mtc_hobbies', 'gaming', 'mood.tag.gaming', 'Gamepad2', 2, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_music', 'mtc_hobbies', 'music', 'mood.tag.music', 'Music', 3, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_outdoors', 'mtc_hobbies', 'outdoors', 'mood.tag.outdoors', 'Trees', 4, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
-- ─── Nutrition-detail tags appended to the existing `health` category ─
-- Mirrors the existing `ate_well` (sort_order 1) under `mtc_health`; the
-- original health tags occupy sort_order 0-4, so these slot in at 5-7.
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_fast_food', 'mtc_health', 'fast_food', 'mood.tag.fastFood', 'Pizza', 5, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_no_sweets', 'mtc_health', 'no_sweets', 'mood.tag.noSweets', 'CandyOff', 6, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_big_meal', 'mtc_health', 'big_meal', 'mood.tag.bigMeal', 'UtensilsCrossed', 7, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
@@ -0,0 +1,77 @@
-- v1.12.0 — rated mood factors (ingestion half).
--
-- Additive on top of the binary mood-tag taxonomy (0101 / 0118). A rated
-- factor is a `MoodTag` whose `kind = 'RATED'`: the user scores it 1..5
-- (or the tag's own scale) per entry, and the score rides on the existing
-- `mood_entry_tag_links` join via a new `rating` column. Binary tags keep
-- `kind = 'BINARY'` and `rating = NULL`, so every pre-existing row and
-- read path stays byte-identical.
--
-- Two `ALTER TABLE ADD COLUMN` blocks (all NOT NULL with a default, or
-- nullable) keep this strictly additive — no backfill, no rewrite. Then
-- seed the five MVP rated factors under a new `factors` category. The
-- inserts use deterministic ids + ON CONFLICT upsert by `key` so a re-run
-- is idempotent, mirroring 0101 / 0118 exactly. The seed only touches the
-- new `factor_*` rows + `factors` category — the binary catalog is left
-- alone.
-- ─── Discriminator + scale columns on the catalog tag ────────────────
ALTER TABLE "mood_tags" ADD COLUMN IF NOT EXISTS "kind" TEXT NOT NULL DEFAULT 'BINARY';
ALTER TABLE "mood_tags" ADD COLUMN IF NOT EXISTS "scale_min" INTEGER NOT NULL DEFAULT 1;
ALTER TABLE "mood_tags" ADD COLUMN IF NOT EXISTS "scale_max" INTEGER NOT NULL DEFAULT 5;
ALTER TABLE "mood_tags" ADD COLUMN IF NOT EXISTS "inverse" BOOLEAN NOT NULL DEFAULT false;
-- ─── Per-entry rating on the join ────────────────────────────────────
ALTER TABLE "mood_entry_tag_links" ADD COLUMN IF NOT EXISTS "rating" INTEGER;
-- ─── New category: factors ───────────────────────────────────────────
INSERT INTO "mood_tag_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mtc_factors', 'factors', 'mood.tagCategory.factors', 'SlidersHorizontal', 6, true)
ON CONFLICT ("key") DO UPDATE SET
"label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
-- ─── The five MVP rated factors ──────────────────────────────────────
-- work (1..5)
INSERT INTO "mood_tags"
("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active", "kind", "scale_min", "scale_max", "inverse")
VALUES ('mt_factor_work', 'mtc_factors', 'factor_work', 'mood.tag.factorWork', 'Briefcase', 0, true, 'RATED', 1, 5, false)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon",
"sort_order" = EXCLUDED."sort_order", "kind" = EXCLUDED."kind", "scale_min" = EXCLUDED."scale_min",
"scale_max" = EXCLUDED."scale_max", "inverse" = EXCLUDED."inverse";
-- social (1..5)
INSERT INTO "mood_tags"
("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active", "kind", "scale_min", "scale_max", "inverse")
VALUES ('mt_factor_social', 'mtc_factors', 'factor_social', 'mood.tag.factorSocial', 'Users', 1, true, 'RATED', 1, 5, false)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon",
"sort_order" = EXCLUDED."sort_order", "kind" = EXCLUDED."kind", "scale_min" = EXCLUDED."scale_min",
"scale_max" = EXCLUDED."scale_max", "inverse" = EXCLUDED."inverse";
-- sleep quality (1..5)
INSERT INTO "mood_tags"
("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active", "kind", "scale_min", "scale_max", "inverse")
VALUES ('mt_factor_sleep_quality', 'mtc_factors', 'factor_sleep_quality', 'mood.tag.factorSleepQuality', 'Moon', 2, true, 'RATED', 1, 5, false)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon",
"sort_order" = EXCLUDED."sort_order", "kind" = EXCLUDED."kind", "scale_min" = EXCLUDED."scale_min",
"scale_max" = EXCLUDED."scale_max", "inverse" = EXCLUDED."inverse";
-- stress (1..5, inverse — higher = worse)
INSERT INTO "mood_tags"
("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active", "kind", "scale_min", "scale_max", "inverse")
VALUES ('mt_factor_stress', 'mtc_factors', 'factor_stress', 'mood.tag.factorStress', 'Zap', 3, true, 'RATED', 1, 5, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon",
"sort_order" = EXCLUDED."sort_order", "kind" = EXCLUDED."kind", "scale_min" = EXCLUDED."scale_min",
"scale_max" = EXCLUDED."scale_max", "inverse" = EXCLUDED."inverse";
-- conflict (1..2 Yes/No, inverse — 1 = no conflict, 2 = conflict)
INSERT INTO "mood_tags"
("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active", "kind", "scale_min", "scale_max", "inverse")
VALUES ('mt_factor_conflict', 'mtc_factors', 'factor_conflict', 'mood.tag.factorConflict', 'Swords', 4, true, 'RATED', 1, 2, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon",
"sort_order" = EXCLUDED."sort_order", "kind" = EXCLUDED."kind", "scale_min" = EXCLUDED."scale_min",
"scale_max" = EXCLUDED."scale_max", "inverse" = EXCLUDED."inverse";
@@ -0,0 +1,17 @@
-- v1.12.0 — add a rated `factor_family` to the mood factor catalog.
--
-- Follow-up to 0119: the operator asked for a "family" rating, and `family`
-- existed only as a BINARY tag. This seeds a sixth RATED factor under the
-- existing `factors` category (`mtc_factors`), scale 1..5, not inverse
-- (higher = a better family day). Strictly additive — no schema change, the
-- catalog is read dynamically by `GET /api/mood/tags`, so iOS and web pick
-- it up with no client change. Deterministic id + ON CONFLICT upsert keep
-- the seed idempotent, mirroring 0119.
INSERT INTO "mood_tags"
("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active", "kind", "scale_min", "scale_max", "inverse")
VALUES ('mt_factor_family', 'mtc_factors', 'factor_family', 'mood.tag.factorFamily', 'Home', 5, true, 'RATED', 1, 5, false)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon",
"sort_order" = EXCLUDED."sort_order", "kind" = EXCLUDED."kind", "scale_min" = EXCLUDED."scale_min",
"scale_max" = EXCLUDED."scale_max", "inverse" = EXCLUDED."inverse";
+78
View File
@@ -66,6 +66,14 @@ model User {
whoopClientIdEncrypted String? @map("whoop_client_id_encrypted")
whoopClientSecretEncrypted String? @map("whoop_client_secret_encrypted")
// v1.12.0 — Fitbit/Pixel OAuth credentials (per-user BYO-keys, encrypted at
// app level) over the Google Health API. The per-app authorized-user cap in
// Google's Testing mode makes a single shared client unworkable for a
// multi-operator product, so each self-hoster registers their own Google
// Cloud OAuth client and pastes the client id/secret into Settings.
fitbitClientIdEncrypted String? @map("fitbit_client_id_encrypted")
fitbitClientSecretEncrypted String? @map("fitbit_client_secret_encrypted")
// moodLog integration (per-user, encrypted)
moodLogUrlEncrypted String? @map("mood_log_url_encrypted")
moodLogApiKeyEncrypted String? @map("mood_log_api_key_encrypted")
@@ -340,6 +348,10 @@ model User {
// OAuth-state ledger (1:N) relations.
whoopConnection WhoopConnection?
whoopOAuthStates WhoopOAuthState[]
// v1.12.0 — Fitbit/Pixel integration. Mirrors the WHOOP connection (1:1) +
// OAuth-state ledger (1:N) relations.
fitbitConnection FitbitConnection?
fitbitOAuthStates FitbitOAuthState[]
moodEntries MoodEntry[]
notificationChannels NotificationChannel[]
pushSubscriptions PushSubscription[]
@@ -672,6 +684,10 @@ enum MeasurementSource {
// (no client write path) as `source = WHOOP` rows. iOS DTO mirrors this
// exact spelling so the `/api/sync/changes` delta feed decodes the rows.
WHOOP
// v1.12.0 — Fitbit/Pixel integration via the Google Health API. Server-owned
// (no client write path); rejected on batch + single POST like WITHINGS/WHOOP.
// iOS DTO mirrors this exact spelling so /api/sync/changes decodes the rows.
FITBIT
@@map("measurement_source")
}
@@ -1827,6 +1843,47 @@ model WhoopOAuthState {
@@map("whoop_oauth_states")
}
/// v1.12.0 — Fitbit/Pixel connection over the Google Health API. Mirrors
/// `WhoopConnection` 1:1 per user. Tokens are encrypted at the app level
/// before persistence. Unlike WHOOP, Google refresh tokens do not rotate per
/// use, so `refreshToken` is overwritten only when a token response carries a
/// fresh one.
model FitbitConnection {
id String @id @default(cuid())
userId String @unique @map("user_id")
fitbitUserId String @map("fitbit_user_id") // Google/Fitbit external id
accessToken String @map("access_token") // encrypted at app level
refreshToken String @map("refresh_token") // encrypted at app level
tokenExpiresAt DateTime @map("token_expires_at")
/// Space-separated OAuth scope string granted by the Google authorisation
/// flow. Null = legacy connection that predates scope tracking.
scope String? @map("scope")
lastSyncedAt DateTime? @map("last_synced_at")
/// Set once the self-converging history backfill walks every collection to
/// completion. Null = backfill still in flight (or never started).
backfillCompletedAt DateTime? @map("backfill_completed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("fitbit_connections")
}
/// v1.12.0 — short-lived `(nonce → userId)` ledger backing the Fitbit OAuth
/// state check, identical in shape + lifecycle to `WhoopOAuthState`.
model FitbitOAuthState {
nonce String @id
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime @map("expires_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([expiresAt])
@@map("fitbit_oauth_states")
}
// ─── Mood Entries (moodLog) ───────────────────────────────────
model MoodEntry {
@@ -1936,6 +1993,22 @@ model MoodTag {
sortOrder Int @default(0) @map("sort_order")
isActive Boolean @default(true) @map("is_active")
/// v1.12.0 — capture-kind discriminator. `BINARY` (default) is a
/// present/absent flag — every tag seeded by 0101 / 0118. `RATED` is a
/// mood *factor* the user scores per entry (e.g. work, social, sleep
/// quality); the score rides on `MoodEntryTagLink.rating`. Keeps one
/// taxonomy + one ingestion path for both flavours.
kind String @default("BINARY") @map("kind") // "BINARY" | "RATED"
/// v1.12.0 — inclusive lower / upper bound of a RATED factor's scale
/// (e.g. 1..5, or 1..2 for a Yes/No factor). Ignored for BINARY tags.
/// The server validates each submitted rating against this range.
scaleMin Int @default(1) @map("scale_min")
scaleMax Int @default(5) @map("scale_max")
/// v1.12.0 — inverse-scaled factor (e.g. stress / conflict) where a
/// HIGHER rating means a WORSE day. Capture stays literal; the (later)
/// analytics layer flips the sign so "up" always reads as "better".
inverse Boolean @default(false) @map("inverse")
category MoodTagCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
links MoodEntryTagLink[]
@@ -1951,6 +2024,11 @@ model MoodEntryTagLink {
moodEntryId String @map("mood_entry_id")
moodTagId String @map("mood_tag_id")
createdAt DateTime @default(now()) @map("created_at")
/// v1.12.0 — per-entry score for a RATED `MoodTag`. NULL for BINARY
/// links (the present/absent flag carries the signal). Bounded to the
/// tag's `scaleMin..scaleMax` at ingest (server-validated, not a DB
/// CHECK, so a future scale change never strands historical rows).
rating Int? @map("rating")
moodEntry MoodEntry @relation(fields: [moodEntryId], references: [id], onDelete: Cascade)
moodTag MoodTag @relation(fields: [moodTagId], references: [id], onDelete: Cascade)
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

+24
View File
@@ -102,6 +102,8 @@ async function main() {
withingsClientSecretEncrypted: true,
whoopClientIdEncrypted: true,
whoopClientSecretEncrypted: true,
fitbitClientIdEncrypted: true,
fitbitClientSecretEncrypted: true,
},
});
@@ -119,6 +121,8 @@ async function main() {
"withingsClientSecretEncrypted",
"whoopClientIdEncrypted",
"whoopClientSecretEncrypted",
"fitbitClientIdEncrypted",
"fitbitClientSecretEncrypted",
];
for (const field of userFields) {
const r = await rotateField(
@@ -176,6 +180,26 @@ async function main() {
results.push(r);
}
// ───── FitbitConnection table (accessToken / refreshToken) ─────
const fitbit = await prisma.fitbitConnection.findMany({
select: { id: true, accessToken: true, refreshToken: true },
});
for (const field of ["accessToken", "refreshToken"] as const) {
const r = await rotateField(
"FitbitConnection",
field,
fitbit,
(f) => f[field],
async (id, ciphertext) => {
await prisma.fitbitConnection.update({
where: { id },
data: { [field]: ciphertext } as Record<string, string>,
});
},
);
results.push(r);
}
// ───── AppSettings table (singleton typically) ─────
const settings = await prisma.appSettings.findMany({
select: {
+17 -10
View File
@@ -3,13 +3,20 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
* v1.4.20 phase B3 — guards for the `/insights` wiring of the new
* Trends row + Correlation row.
* v1.4.20 phase B3 — guards for the `/insights` wiring of the
* Trends row + the analytics correlations payload.
*
* The page-source scan pattern mirrors `insights-polish.test.ts` —
* each test pins one load-bearing import / JSX mount so a future
* refactor can't accidentally drop a wave of features without
* breaking a CI lane.
*
* v1.12.0 — the on-overview correlation row was removed: the per-metric
* correlation cards moved onto the metric pages they belong to (Weight
* owns weight × weekday, Pulse owns mood × pulse), so the overview no
* longer duplicates them. The analytics route still surfaces the
* `correlations` block (the per-metric `MetricCorrelationCard` reads it),
* so those backend guards stay; the overview-mount guards were dropped.
*/
const ROOT = join(__dirname, "../../..");
@@ -24,25 +31,25 @@ function load(path: string): string {
return readFileSync(path, "utf8");
}
describe("v1.4.20 B3 — /insights mounts CorrelationRow + TrendsRow", () => {
it("imports both new components", () => {
describe("v1.4.20 B3 — /insights mounts TrendsRow", () => {
it("imports the trends-row component", () => {
// v1.4.33 IW2 deferred below-the-fold blocks behind `next/dynamic`,
// so the static `from "..."` import-string was replaced with a
// `dynamic(() => import("..."))` call. Either spelling counts as
// a load-bearing reference to the module path.
const src = load(INSIGHTS_PATH);
expect(src).toMatch(
/(?:from\s+"@\/components\/insights\/correlation-row"|import\("@\/components\/insights\/correlation-row"\))/,
);
expect(src).toMatch(
/(?:from\s+"@\/components\/insights\/trends-row"|import\("@\/components\/insights\/trends-row"\))/,
);
});
it("mounts <CorrelationRow> behind a non-null analytics.correlations guard", () => {
it("no longer mounts an on-overview correlation row (relocated to the metric pages)", () => {
// v1.12.0 — the correlation cards live on the per-metric pages now.
// The overview must not duplicate them: neither the module import
// nor the JSX mount may reappear here.
const src = load(INSIGHTS_PATH);
expect(src).toMatch(/analytics\?\.correlations\s*&&\s*\(/);
expect(src).toMatch(/<CorrelationRow\b/);
expect(src).not.toMatch(/components\/insights\/correlation-row/);
expect(src).not.toMatch(/<CorrelationRow\b/);
});
it("mounts <TrendsRow> with annotations from the advisor payload", () => {
+186
View File
@@ -0,0 +1,186 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
import {
exchangeCode,
fetchProfile,
resolveFitbitUserId,
FITBIT_OAUTH_SCOPE,
} from "@/lib/fitbit/client";
import { getUserFitbitCredentials } from "@/lib/fitbit/credentials";
import { FITBIT_OAUTH_STATE_COOKIE } from "@/lib/fitbit/oauth-state";
import { FITBIT_BACKFILL_QUEUE } from "@/lib/jobs/fitbit-backfill";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { markReconnected } from "@/lib/integrations/status";
import { Prisma } from "@/generated/prisma/client";
import { NextRequest, NextResponse } from "next/server";
import { timingSafeEqual } from "node:crypto";
/**
* OAuth callback from Google for Fitbit / Google Health (v1.12.0). Mirrors the
* WHOOP callback: the `state` param is a random base64url nonce keyed against
* the `FitbitOAuthState` ledger. The in-flight user is resolved via the row's
* `userId` (never parsed from the cookie value) and cross-checked against the
* session. The row is consumed (deleted) atomically on every exit branch so a
* replay of the same nonce fails the second time.
*
* Reason tags distinguish the four post-delete branches for the audit trail:
* `csrf1` (URL/cookie mismatch, short-circuit before delete), `replay` (P2025 —
* nonce already consumed), `expired` (valid row, TTL elapsed), and `cross_user`
* (valid row, session userId mismatch).
*
* On success: exchange the code, fetch the Google Health profile for the
* external `fitbitUserId`, persist the encrypted `FitbitConnection`, clear any
* prior reauth state, and enqueue the self-converging history backfill.
*/
const ERR = (reason: string) =>
NextResponse.redirect(
new URL(
`/settings/integrations?fitbit=error&reason=${reason}`,
process.env.NEXT_PUBLIC_APP_URL!,
),
);
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "fitbit.callback" } });
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
const state = searchParams.get("state");
const storedState = request.cookies.get(FITBIT_OAUTH_STATE_COOKIE)?.value;
// CSRF leg 1: URL state must match the cookie state byte-for-byte
// (timing-safe). Runs BEFORE the atomic delete so a probe with arbitrary
// state values can't grief legitimate ledger rows.
if (
!state ||
!storedState ||
state.length !== storedState.length ||
!timingSafeEqual(Buffer.from(state), Buffer.from(storedState))
) {
annotate({ meta: { reason: "csrf1" } });
return ERR("csrf1");
}
// CSRF leg 2: atomically consume the ledger row. `delete` returns the row on
// success so the `expiresAt` + `userId` checks run against the consumed
// payload. P2025 means the nonce was already consumed (replay) or never
// existed. Atomic at the Postgres row level: two concurrent callbacks with the
// same nonce can't both pass before either deletes.
let stateRow: { userId: string; expiresAt: Date } | null = null;
try {
stateRow = await prisma.fitbitOAuthState.delete({
where: { nonce: state },
});
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2025"
) {
annotate({ meta: { reason: "replay" } });
return ERR("replay");
}
const errName = err instanceof Error ? err.name : "unknown";
getEvent()?.addWarning(`oauth-state-delete failed: ${errName}`);
annotate({ meta: { reason: "state" } });
return ERR("state");
}
if (stateRow.expiresAt <= new Date()) {
annotate({ meta: { reason: "expired" } });
return ERR("expired");
}
if (stateRow.userId !== user.id) {
annotate({ meta: { reason: "cross_user" } });
return ERR("cross_user");
}
if (!code) {
return ERR("nocode");
}
try {
const creds = await getUserFitbitCredentials(user.id);
if (!creds) {
return ERR("nocreds");
}
const tokens = await exchangeCode(code, creds);
// The initial code exchange MUST return a refresh token (the connect route
// forces `prompt=consent` + `access_type=offline`). If Google ever omits it,
// refuse to persist — a stored empty refresh token silently bricks every
// future token refresh (the refresh POST then 400s and the connection dies
// the moment the access token expires). Error out cleanly so the user
// re-consents rather than landing a dead connection.
if (!tokens.refresh_token) {
getEvent()?.addWarning(
`fitbit callback: token response carried no refresh_token for user ${user.id}`,
);
annotate({ meta: { reason: "no_refresh_token" } });
return ERR("no_refresh_token");
}
const profile = await fetchProfile(tokens.access_token);
const fitbitUserId = resolveFitbitUserId(profile);
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
await prisma.fitbitConnection.upsert({
where: { userId: user.id },
update: {
fitbitUserId,
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
tokenExpiresAt: expiresAt,
scope: tokens.scope ?? FITBIT_OAUTH_SCOPE,
backfillCompletedAt: null,
},
create: {
userId: user.id,
fitbitUserId,
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
tokenExpiresAt: expiresAt,
scope: tokens.scope ?? FITBIT_OAUTH_SCOPE,
},
});
await auditLog("fitbit.connect", {
userId: user.id,
details: { fitbitUserId },
});
// Re-completing OAuth clears any prior reauth-required state.
await markReconnected(user.id, "fitbit");
// Enqueue the self-converging history backfill. Best-effort: the boot-time
// discovery query (`backfillCompletedAt IS NULL`) is the safety net, so a
// missing boss instance here doesn't strand the connection.
const boss = getGlobalBoss();
if (boss) {
await boss
.send(FITBIT_BACKFILL_QUEUE, {
userId: user.id,
enqueuedAt: new Date().toISOString(),
})
.catch((err) =>
getEvent()?.addWarning(`fitbit-backfill enqueue failed: ${err}`),
);
}
const response = NextResponse.redirect(
new URL(
"/settings/integrations?fitbit=connected",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
response.cookies.delete(FITBIT_OAUTH_STATE_COOKIE);
return response;
} catch (err) {
getEvent()?.setError(err);
return ERR("token");
}
});
+90
View File
@@ -0,0 +1,90 @@
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";
import { getAuthorizationUrl } from "@/lib/fitbit/client";
import { getUserFitbitCredentials } from "@/lib/fitbit/credentials";
import {
FITBIT_OAUTH_STATE_COOKIE,
FITBIT_OAUTH_STATE_TTL_MS,
mintFitbitOAuthStateNonce,
} from "@/lib/fitbit/oauth-state";
import { NextRequest, NextResponse } from "next/server";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
/**
* Redirect the user to the Google OAuth consent page for Fitbit / Google Health
* (v1.12.0).
*
* Mirrors the WHOOP connect route: a fully-random base64url state nonce backed
* by a 10-minute `FitbitOAuthState` ledger row carries the `(nonce → userId)`
* mapping, so the user id never travels in the OAuth `state` param (which can
* land in request logs / network captures). The httpOnly + Secure cookie
* carries JUST the nonce; the callback resolves the user via the row's `userId`.
*
* 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.
*/
const CONNECT_RATE_LIMIT = 10;
const CONNECT_WINDOW_MS = 60_000;
export const GET = apiHandler(async (req: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "fitbit.connect" } });
const rl = await checkRateLimit(
`fitbit:connect:${user.id}`,
CONNECT_RATE_LIMIT,
CONNECT_WINDOW_MS,
);
if (!rl.allowed) {
annotate({ action: { name: "fitbit.connect.rate_limited" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?fitbit=error&reason=rate_limited",
req.url,
),
);
}
const creds = await getUserFitbitCredentials(user.id);
if (!creds) {
return apiError(
"Please configure your Google OAuth Client ID and Client Secret in Settings first.",
400,
);
}
const nonce = mintFitbitOAuthStateNonce();
try {
await prisma.fitbitOAuthState.create({
data: {
nonce,
userId: user.id,
expiresAt: new Date(Date.now() + FITBIT_OAUTH_STATE_TTL_MS),
},
});
} catch (err) {
getEvent()?.setError(err);
annotate({ action: { name: "fitbit.connect.create_failed" } });
return NextResponse.redirect(
new URL("/settings/integrations?fitbit=error&reason=connect", req.url),
);
}
const url = getAuthorizationUrl(nonce, creds);
const response = NextResponse.redirect(url);
response.cookies.set(FITBIT_OAUTH_STATE_COOKIE, nonce, {
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: Math.floor(FITBIT_OAUTH_STATE_TTL_MS / 1000),
path: "/",
});
return response;
});
+79
View File
@@ -0,0 +1,79 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { fitbitCredentialsSchema } from "@/lib/validations/fitbit";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
/**
* Check whether the user has Fitbit / Google Health BYO-key credentials stored
* (v1.12.0).
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "fitbit.credentials.get" } });
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: {
fitbitClientIdEncrypted: true,
fitbitClientSecretEncrypted: true,
},
});
return apiSuccess({
hasCredentials:
!!dbUser?.fitbitClientIdEncrypted &&
!!dbUser?.fitbitClientSecretEncrypted,
});
});
/**
* Save Fitbit / Google Health API credentials (encrypted at rest).
*/
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "fitbit.credentials.update" } });
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const result = z.safeParse(fitbitCredentialsSchema, body);
if (!result.success) {
return apiError("Client ID and Client Secret are required", 422);
}
await prisma.user.update({
where: { id: user.id },
data: {
fitbitClientIdEncrypted: encrypt(result.data.clientId),
fitbitClientSecretEncrypted: encrypt(result.data.clientSecret),
},
});
return apiSuccess({ updated: true });
});
/**
* Delete Fitbit credentials and the active connection.
*/
export const DELETE = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "fitbit.credentials.delete" } });
await prisma.fitbitConnection
.delete({ where: { userId: user.id } })
.catch(() => {});
await prisma.user.update({
where: { id: user.id },
data: {
fitbitClientIdEncrypted: null,
fitbitClientSecretEncrypted: null,
},
});
return apiSuccess({ deleted: true });
});
+42
View File
@@ -0,0 +1,42 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { markDisconnected } from "@/lib/integrations/status";
/**
* Disconnect the Fitbit / Google Health integration for the current user
* (v1.12.0).
*
* Clears the stored OAuth tokens by deleting the `FitbitConnection` row and
* parks the integration status at `disconnected`. There is no per-user
* subscription to tear down (Pub/Sub is deferred — poll-only at launch), so no
* upstream unsubscribe call is needed. The BYO-key credentials on `User` are
* left intact so a reconnect doesn't force the user to re-paste them; use the
* credentials DELETE endpoint to remove those too.
*/
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "fitbit.disconnect" } });
const connection = await prisma.fitbitConnection.findUnique({
where: { userId: user.id },
});
if (!connection) {
return apiError("No Fitbit connection", 404);
}
await prisma.fitbitConnection.delete({
where: { userId: user.id },
});
await auditLog("fitbit.disconnect", {
userId: user.id,
});
await markDisconnected(user.id, "fitbit");
return apiSuccess({ disconnected: true });
});
+57
View File
@@ -0,0 +1,57 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
/**
* Get Fitbit / Google Health connection status for the current user (v1.12.0).
*
* Mirrors the WHOOP status route: `configured` checks the per-user BYO-key
* credentials, the connection row reports last-sync + token expiry + backfill
* progress. `tokenExpired` is reported straight off the row; the sync path
* refreshes lazily via `getValidToken`.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "fitbit.status" } });
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: {
fitbitClientIdEncrypted: true,
fitbitClientSecretEncrypted: true,
},
});
const configured =
!!dbUser?.fitbitClientIdEncrypted && !!dbUser?.fitbitClientSecretEncrypted;
const connection = await prisma.fitbitConnection.findUnique({
where: { userId: user.id },
select: {
fitbitUserId: true,
lastSyncedAt: true,
tokenExpiresAt: true,
backfillCompletedAt: true,
createdAt: true,
scope: true,
},
});
if (!connection) {
return apiSuccess({ connected: false, configured });
}
const tokenExpired = connection.tokenExpiresAt.getTime() <= Date.now();
return apiSuccess({
connected: true,
configured,
lastSyncedAt: connection.lastSyncedAt,
connectedAt: connection.createdAt,
tokenExpired,
tokenExpiresAt: connection.tokenExpiresAt,
backfillCompleted: !!connection.backfillCompletedAt,
scope: connection.scope,
});
});
+27
View File
@@ -0,0 +1,27 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { syncUserFitbit } from "@/lib/fitbit/sync";
import { NextRequest } from "next/server";
/**
* Manually trigger a Fitbit / Google Health sync for the current user (v1.12.0).
*
* Mirrors the WHOOP manual-sync route: incremental by default, full history when
* `{ fullSync: true }` is posted.
*/
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "fitbit.sync" } });
let fullSync = false;
try {
const body = await request.json();
fullSync = body?.fullSync === true;
} catch {
// no body provided -> default incremental sync
}
const imported = await syncUserFitbit(user.id, { fullSync });
return apiSuccess({ imported, fullSync });
});
+26 -1
View File
@@ -100,6 +100,20 @@ function tokeniseForStreaming(content: string): string[] {
return matches ?? [content];
}
/**
* v1.12.0 — yield control back to the event loop for one tick so the
* stream controller flushes the just-enqueued frame before the next one
* is produced. `setTimeout(0)` (rather than a bare `Promise.resolve()`
* microtask) hands the turn back to the platform's stream pump so each
* SSE frame lands in its own network chunk; a microtask would drain
* before the runtime gets a chance to flush. The delay is intentionally
* zero — we want incremental delivery, not an artificial typewriter
* pause.
*/
function flushTick(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
interface CoachTurn {
role: "user" | "assistant";
content: string;
@@ -485,9 +499,20 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
});
// ── Stream the body to the client ────────────────────────────
const stream = createSseStream((controller) => {
// v1.12.0 — yield to the event loop between token frames so each one
// flushes as its own network chunk. The provider clients return the
// full reply in one shot; without the yield the whole tokenised body
// was enqueued synchronously inside `start()` and the runtime
// coalesced every frame into a single read, so the client painted the
// answer all at once despite the per-token render path in `use-coach`.
// A zero-delay yield is enough to land each frame on its own tick —
// the visible cadence reads ChatGPT/Claude-style without a contrived
// sleep. The refusal + error paths stay single-frame (nothing to
// pace).
const stream = createSseStream(async (controller) => {
for (const tok of tokeniseForStreaming(replyText)) {
controller.enqueue(encodeFrame({ type: "token", token: tok }));
await flushTick();
}
controller.enqueue(
encodeFrame({
@@ -0,0 +1,48 @@
/**
* Manual resume endpoint for a parked Fitbit / Google Health integration.
*
* Background: the `parked` state lives in
* `src/lib/integrations/status.ts`. Once a Fitbit persistent-failure
* streak has been running for more than 24h, the status writer flips
* the row to `parked` and the sync entry-point short-circuits. The user
* (or an operator) calls this endpoint to clear the park and let the
* next cron tick attempt the sync again.
*
* Rate-limited (5/min per user) to match the existing
* `/api/integrations/fitbit/test` endpoint — there's no upstream call
* here, but a misbehaving client tab loop would still rack up
* audit-log rows if the limit was absent.
*
* Returns 200 with `{ resumed: true, wasParked: boolean }`. The
* `wasParked` flag lets the caller decide whether to surface a "you
* resumed it" toast or treat the action as a no-op (the row was
* already connected / disconnected). Mirrors the WHOOP resume route.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { resumeIntegrationFromPark } from "@/lib/integrations/status";
export const dynamic = "force-dynamic";
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "integrations.fitbit.resume" } });
const rl = await checkRateLimit(`fitbit-resume:${user.id}`, 5, 60_000);
if (!rl.allowed) {
return apiError("Too many resume requests", 429, {
errorCode: "rate_limited_self",
});
}
const result = await resumeIntegrationFromPark(user.id, "fitbit");
annotate({
meta: {
fitbit_resume_was_parked: result.wasParked,
},
});
return apiSuccess({ resumed: true, wasParked: result.wasParked });
});
@@ -0,0 +1,220 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({
user: { id: "u-1" },
session: { id: "s-1" },
})),
}));
vi.mock("@/lib/db", () => ({
prisma: {
fitbitConnection: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/crypto", () => ({
decrypt: vi.fn((x: string) => x),
}));
vi.mock("@/lib/fitbit/sync", () => ({
getValidToken: vi.fn(),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
import { getValidToken } from "@/lib/fitbit/sync";
interface ApiErrorEnvelope {
data: null;
error: string;
}
interface ApiSuccessEnvelope<T> {
data: T;
error: null;
}
const originalFetch = global.fetch;
beforeEach(() => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 5,
resetAt: Date.now() + 60_000,
} as never);
vi.mocked(prisma.fitbitConnection.findUnique).mockReset();
vi.mocked(getValidToken).mockReset();
global.fetch = vi.fn() as never;
});
afterEach(() => {
global.fetch = originalFetch;
});
function emptyRequest(): Request {
return new Request("http://localhost/api/integrations/fitbit/test", {
method: "POST",
headers: { "content-length": "0" },
});
}
describe("POST /api/integrations/fitbit/test", () => {
it("happy path returns ok with shape", async () => {
const lastSyncedAt = new Date("2026-05-01T10:00:00.000Z");
vi.mocked(getValidToken).mockResolvedValueOnce({
accessToken: "plain-access",
connection: { id: "fc-1", fitbitUserId: "fu-1" },
});
vi.mocked(prisma.fitbitConnection.findUnique).mockResolvedValueOnce({
lastSyncedAt,
} as never);
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
new Response(JSON.stringify({ name: "users/1" }), {
status: 200,
}),
);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(200);
const body = (await response.json()) as ApiSuccessEnvelope<{
ok: boolean;
lastSyncedAt: string;
latencyMs: number;
}>;
expect(body.data.ok).toBe(true);
expect(typeof body.data.latencyMs).toBe("number");
expect(body.data.lastSyncedAt).toBe(lastSyncedAt.toISOString());
});
it("rate-limit denial returns 429 with no upstream call", async () => {
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
} as never);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(429);
expect(global.fetch).not.toHaveBeenCalled();
});
it("returns 422 when no Fitbit connection exists", async () => {
vi.mocked(getValidToken).mockResolvedValueOnce(null);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(422);
const body = (await response.json()) as ApiErrorEnvelope;
expect(body.error).toMatch(/not connected/i);
expect(global.fetch).not.toHaveBeenCalled();
});
it("classifies 401 upstream as credentials_rejected", async () => {
vi.mocked(getValidToken).mockResolvedValueOnce({
accessToken: "sk-secret",
connection: { id: "fc-1", fitbitUserId: "fu-1" },
});
vi.mocked(prisma.fitbitConnection.findUnique).mockResolvedValueOnce({
lastSyncedAt: null,
} as never);
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 }),
);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(502);
const body = (await response.json()) as ApiErrorEnvelope & {
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe("credentials_rejected");
});
it("classifies 403 upstream as credentials_rejected", async () => {
vi.mocked(getValidToken).mockResolvedValueOnce({
accessToken: "plain-access",
connection: { id: "fc-1", fitbitUserId: "fu-1" },
});
vi.mocked(prisma.fitbitConnection.findUnique).mockResolvedValueOnce({
lastSyncedAt: null,
} as never);
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
new Response(JSON.stringify({ error: "forbidden" }), { status: 403 }),
);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(502);
const body = (await response.json()) as ApiErrorEnvelope & {
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe("credentials_rejected");
});
it("classifies 429 upstream as rate_limited", async () => {
vi.mocked(getValidToken).mockResolvedValueOnce({
accessToken: "plain-access",
connection: { id: "fc-1", fitbitUserId: "fu-1" },
});
vi.mocked(prisma.fitbitConnection.findUnique).mockResolvedValueOnce({
lastSyncedAt: null,
} as never);
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
new Response(JSON.stringify({ error: "rate limited" }), { status: 429 }),
);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(502);
const body = (await response.json()) as ApiErrorEnvelope & {
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe("rate_limited");
});
it("classifies 500 upstream as upstream_error", async () => {
vi.mocked(getValidToken).mockResolvedValueOnce({
accessToken: "plain-access",
connection: { id: "fc-1", fitbitUserId: "fu-1" },
});
vi.mocked(prisma.fitbitConnection.findUnique).mockResolvedValueOnce({
lastSyncedAt: null,
} as never);
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
new Response("oops", { status: 503 }),
);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(502);
const body = (await response.json()) as ApiErrorEnvelope & {
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe("upstream_error");
});
it("does not leak Bearer token or upstream URL on failure", async () => {
vi.mocked(getValidToken).mockResolvedValueOnce({
accessToken: "sk-secret",
connection: { id: "fc-1", fitbitUserId: "fu-1" },
});
vi.mocked(prisma.fitbitConnection.findUnique).mockResolvedValueOnce({
lastSyncedAt: null,
} as never);
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
new Response(
JSON.stringify({
error: "Authorization: Bearer sk-secret echoed",
url: "https://health.googleapis.com/v4/users/me/profile",
}),
{ status: 401 },
),
);
const response = await POST(emptyRequest() as never);
const text = await response.text();
expect(response.status).toBe(502);
expect(text).not.toMatch(/sk-/);
expect(text).not.toMatch(/health\.googleapis\.com/);
});
});
@@ -0,0 +1,132 @@
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { getValidToken } from "@/lib/fitbit/sync";
import { FITBIT_API_BASE } from "@/lib/fitbit/client";
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
export const dynamic = "force-dynamic";
// Cheap authenticated probe — the single (non-paginated) profile endpoint the
// Fitbit client already calls via `fetchProfile`.
// Base from `src/lib/fitbit/client.ts` (`FITBIT_API_BASE`).
const FITBIT_PROFILE_URL = `${FITBIT_API_BASE}/users/me/profile`;
const TIMEOUT_MS = 5_000;
type CategorisedError = { code: string; message: string };
function categoriseHttpStatus(status: number): CategorisedError {
if (status === 401 || status === 403) {
return {
code: "credentials_rejected",
message: "Google Health rejected the credentials",
};
}
if (status === 429) {
return {
code: "rate_limited",
message: "Google Health rate-limited the request",
};
}
if (status >= 500) {
return {
code: "upstream_error",
message: "Google Health returned a server error",
};
}
return {
code: "connection_failed",
message: "Google Health connection failed",
};
}
export const POST = apiHandler(async (request: NextRequest) => {
void request;
const { user } = await requireAuth();
annotate({ action: { name: "integrations.fitbit.test" } });
const rl = await checkRateLimit(`fitbit-test:${user.id}`, 5, 60_000);
if (!rl.allowed) {
return apiError("Too many test requests", 429, {
errorCode: "rate_limited_self",
});
}
const tokenInfo = await getValidToken(user.id);
if (!tokenInfo) {
return apiError("Google Health not connected", 422, {
errorCode: "not_configured",
});
}
const connection = await prisma.fitbitConnection.findUnique({
where: { userId: user.id },
select: { lastSyncedAt: true },
});
const start = performance.now();
try {
const res = await safeFetch(
FITBIT_PROFILE_URL,
{
method: "GET",
headers: {
Authorization: `Bearer ${tokenInfo.accessToken}`,
},
},
{ timeoutMs: TIMEOUT_MS },
);
const latencyMs = Math.round(performance.now() - start);
if (!res.ok) {
const cat = categoriseHttpStatus(res.status);
annotate({
meta: {
fitbit_test_status: res.status,
fitbit_test_code: cat.code,
fitbit_test_latency_ms: latencyMs,
},
});
return apiError(cat.message, 502, { errorCode: cat.code });
}
return apiSuccess({
ok: true,
lastSyncedAt: connection?.lastSyncedAt ?? null,
latencyMs,
});
} catch (e) {
const err = e as Error;
// safeFetch wraps timeouts into SafeFetchError{kind:"timeout"};
// legacy AbortError name is preserved for compatibility with any
// future direct-fetch path that still reaches this catch.
const isAbort =
(e instanceof SafeFetchError && e.kind === "timeout") ||
err.name === "AbortError";
const code = isAbort ? "timeout" : "connection_failed";
annotate({
meta: {
fitbit_test_code: code,
fitbit_test_error: redact(err.message).slice(0, 300),
},
});
return apiError(
isAbort
? "Google Health request timed out"
: "Google Health connection failed",
502,
{ errorCode: code },
);
}
});
// Strip any `Bearer <token>` substring before annotating; the catch path can
// see DNS / TLS errors that include the request init via err.cause in some
// runtimes, and we never want a token in Loki.
function redact(text: string): string {
return text.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]");
}
@@ -12,6 +12,13 @@ import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
export const dynamic = "force-dynamic";
/**
* @deprecated The standalone moodLog webhook is superseded by native mood
* entries plus structured tags and rated factors — mood is now tracked
* fully inside HealthLog, so the external moodLog bridge no longer adds
* anything. Kept functional for existing integrations; slated for removal
* in a future major release. Do not build new callers against it.
*/
export const POST = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "moodlog.webhook" } });
+76 -35
View File
@@ -29,41 +29,60 @@ export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "integrations.status" } });
const [withingsStatus, moodLogStatus, whoopStatus, dbUser, withingsConn, whoopConn] =
await Promise.all([
getIntegrationStatus(user.id, "withings"),
getIntegrationStatus(user.id, "moodlog"),
getIntegrationStatus(user.id, "whoop"),
prisma.user.findUnique({
where: { id: user.id },
select: {
withingsClientIdEncrypted: true,
withingsClientSecretEncrypted: true,
whoopClientIdEncrypted: true,
whoopClientSecretEncrypted: true,
moodLogUrlEncrypted: true,
moodLogApiKeyEncrypted: true,
moodLogEnabled: true,
moodLogLastSyncedAt: true,
},
}),
prisma.withingsConnection.findUnique({
where: { userId: user.id },
select: {
tokenExpiresAt: true,
lastSyncedAt: true,
createdAt: true,
},
}),
prisma.whoopConnection.findUnique({
where: { userId: user.id },
select: {
tokenExpiresAt: true,
lastSyncedAt: true,
createdAt: true,
},
}),
]);
const [
withingsStatus,
moodLogStatus,
whoopStatus,
fitbitStatus,
dbUser,
withingsConn,
whoopConn,
fitbitConn,
] = await Promise.all([
getIntegrationStatus(user.id, "withings"),
getIntegrationStatus(user.id, "moodlog"),
getIntegrationStatus(user.id, "whoop"),
getIntegrationStatus(user.id, "fitbit"),
prisma.user.findUnique({
where: { id: user.id },
select: {
withingsClientIdEncrypted: true,
withingsClientSecretEncrypted: true,
whoopClientIdEncrypted: true,
whoopClientSecretEncrypted: true,
fitbitClientIdEncrypted: true,
fitbitClientSecretEncrypted: true,
moodLogUrlEncrypted: true,
moodLogApiKeyEncrypted: true,
moodLogEnabled: true,
moodLogLastSyncedAt: true,
},
}),
prisma.withingsConnection.findUnique({
where: { userId: user.id },
select: {
tokenExpiresAt: true,
lastSyncedAt: true,
createdAt: true,
},
}),
prisma.whoopConnection.findUnique({
where: { userId: user.id },
select: {
tokenExpiresAt: true,
lastSyncedAt: true,
createdAt: true,
},
}),
prisma.fitbitConnection.findUnique({
where: { userId: user.id },
select: {
tokenExpiresAt: true,
lastSyncedAt: true,
createdAt: true,
},
}),
]);
const now = Date.now();
@@ -106,6 +125,19 @@ export const GET = apiHandler(async () => {
? whoopConn.tokenExpiresAt.getTime() <= now
: null,
} satisfies IntegrationViewModel & WhoopExtras,
{
...fitbitStatus,
configured:
!!dbUser?.fitbitClientIdEncrypted &&
!!dbUser?.fitbitClientSecretEncrypted,
connected: !!fitbitConn,
connectedAt: fitbitConn?.createdAt?.toISOString() ?? null,
legacyLastSyncedAt: fitbitConn?.lastSyncedAt?.toISOString() ?? null,
tokenExpiresAt: fitbitConn?.tokenExpiresAt?.toISOString() ?? null,
tokenExpired: fitbitConn
? fitbitConn.tokenExpiresAt.getTime() <= now
: null,
} satisfies IntegrationViewModel & FitbitExtras,
],
});
});
@@ -141,3 +173,12 @@ interface WhoopExtras {
tokenExpiresAt: string | null;
tokenExpired: boolean | null;
}
interface FitbitExtras {
configured: boolean;
connected: boolean;
connectedAt: string | null;
legacyLastSyncedAt: string | null;
tokenExpiresAt: string | null;
tokenExpired: boolean | null;
}
+1 -1
View File
@@ -150,7 +150,7 @@ export const PUT = apiHandler(
// is present in the body. `null` clears every link; an omitted
// field leaves the links untouched.
if (data.tagKeys !== undefined) {
await replaceTagLinks(id, data.tagKeys ?? [], tx);
await replaceTagLinks(id, user.id, data.tagKeys ?? [], tx);
}
// v1.8.5 — read the persisted link keys back so the update
@@ -34,9 +34,17 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/mood/tag-links", () => ({
createTagLinks: vi.fn().mockResolvedValue(undefined),
}));
// v1.12.0 — keep the real `RatedFactorOutOfRangeError` so the route's
// `instanceof` 422 branch still matches when the mock throws it.
vi.mock("@/lib/mood/tag-links", async () => {
const actual = await vi.importActual<typeof import("@/lib/mood/tag-links")>(
"@/lib/mood/tag-links",
);
return {
createTagLinks: vi.fn().mockResolvedValue(undefined),
RatedFactorOutOfRangeError: actual.RatedFactorOutOfRangeError,
};
});
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
@@ -239,8 +247,15 @@ describe("POST /api/mood-entries — entry + tag-links transaction (v1.8.5)", ()
const res = await POST(postReq(VALID_BODY));
expect(res.status).toBe(201);
// The tag-link write runs against the same `$transaction` client that
// created the entry, so both commit (or roll back) together.
expect(createTagLinks).toHaveBeenCalledWith("mood-1", ["happy"], txClient);
// created the entry, so both commit (or roll back) together. The 4th
// arg is the rated-factor set (empty here — binary tag only).
expect(createTagLinks).toHaveBeenCalledWith(
"mood-1",
"user-1",
["happy"],
txClient,
[],
);
});
it("returns the persisted tagKeys in the create response", async () => {
@@ -293,3 +308,87 @@ describe("POST /api/mood-entries — entry + tag-links transaction (v1.8.5)", ()
expect(recomputeMoodBucketsForEntry).not.toHaveBeenCalled();
});
});
describe("POST /api/mood-entries — rated factors (v1.12.0)", () => {
const ENTRY = {
id: "mood-rf",
tags: null,
moodLoggedAt: new Date("2026-06-01T08:00:00.000Z"),
mood: "GUT",
note: null,
source: "MANUAL",
date: "2026-06-01",
};
it("threads ratedFactors into createTagLinks", async () => {
txClient.moodEntry.create.mockResolvedValue(ENTRY);
const body = {
mood: "GUT",
moodLoggedAt: "2026-06-01T08:00:00.000Z",
tagKeys: ["happy"],
ratedFactors: [{ key: "factor_work", rating: 4 }],
};
const res = await POST(postReq(body));
expect(res.status).toBe(201);
expect(createTagLinks).toHaveBeenCalledWith(
"mood-rf",
"user-1",
["happy"],
txClient,
[{ key: "factor_work", rating: 4 }],
);
});
it("surfaces persisted rated factors split from binary tagKeys in the response", async () => {
txClient.moodEntry.create.mockResolvedValue(ENTRY);
// Read-back returns one binary link + one rated link; the route splits
// them by `kind`.
txClient.moodEntryTagLink.findMany.mockResolvedValue([
{ rating: null, moodTag: { key: "happy", kind: "BINARY" } },
{ rating: 4, moodTag: { key: "factor_work", kind: "RATED" } },
]);
const res = await POST(
postReq({
mood: "GUT",
moodLoggedAt: "2026-06-01T08:00:00.000Z",
tagKeys: ["happy"],
ratedFactors: [{ key: "factor_work", rating: 4 }],
}),
);
expect(res.status).toBe(201);
const out = (await res.json()) as {
data: { tagKeys: string[]; ratedFactors: { key: string; rating: number }[] };
};
expect(out.data.tagKeys).toEqual(["happy"]);
expect(out.data.ratedFactors).toEqual([{ key: "factor_work", rating: 4 }]);
});
it("returns 422 with errorCode when a rating is out of the factor's scale", async () => {
txClient.moodEntry.create.mockResolvedValue(ENTRY);
const { RatedFactorOutOfRangeError } = await import("@/lib/mood/tag-links");
vi.mocked(createTagLinks).mockRejectedValueOnce(
new RatedFactorOutOfRangeError("factor_conflict", 5, 1, 2),
);
const res = await POST(
postReq({
mood: "GUT",
moodLoggedAt: "2026-06-01T08:00:00.000Z",
ratedFactors: [{ key: "factor_conflict", rating: 5 }],
}),
);
expect(res.status).toBe(422);
const out = (await res.json()) as { meta?: { errorCode?: string } };
expect(out.meta?.errorCode).toBe("mood.ratedFactor.out_of_range");
});
it("rejects a non-integer / out-of-envelope rating at the Zod layer (422)", async () => {
const res = await POST(
postReq({
mood: "GUT",
moodLoggedAt: "2026-06-01T08:00:00.000Z",
ratedFactors: [{ key: "factor_work", rating: 9 }],
}),
);
expect(res.status).toBe(422);
});
});
@@ -15,6 +15,22 @@ vi.mock("@/lib/db", () => ({
},
}));
// v1.12.0 — keep the real `RatedFactorOutOfRangeError` so the per-entry
// catch can `instanceof`-match a thrown out-of-scale rating.
vi.mock("@/lib/mood/tag-links", async () => {
const actual = await vi.importActual<typeof import("@/lib/mood/tag-links")>(
"@/lib/mood/tag-links",
);
return {
createTagLinks: vi.fn().mockResolvedValue(undefined),
RatedFactorOutOfRangeError: actual.RatedFactorOutOfRangeError,
};
});
vi.mock("@/lib/moodlog/push", () => ({
pushMoodEntriesToMoodLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
@@ -52,6 +68,8 @@ import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { createTagLinks } from "@/lib/mood/tag-links";
import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -71,6 +89,15 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(createTagLinks).mockResolvedValue(undefined);
// `vi.resetAllMocks()` above wipes the factory default, so the
// best-effort reverse-sync push must be re-stubbed to a resolved
// promise — the route calls `.catch()` on its return value.
vi.mocked(pushMoodEntriesToMoodLog).mockResolvedValue({
pushed: 0,
skipped: 0,
status: "skipped",
});
});
describe("POST /api/mood-entries/bulk — 422 multi-issue (v1.4.43 W6)", () => {
@@ -153,3 +180,123 @@ describe("POST /api/mood-entries/bulk — 422 multi-issue (v1.4.43 W6)", () => {
expect(res.status).toBe(422);
});
});
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.upsert).mockResolvedValue({
id: "entry-1",
} as never);
});
it("persists structured tag links for an entry carrying tagKeys", async () => {
const res = await POST(
postReq({
entries: [
{
mood: "GUT",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
tagKeys: ["movies", "gaming"],
},
],
}),
);
expect(res.status).toBe(200);
expect(createTagLinks).toHaveBeenCalledTimes(1);
// v1.12.0 — the bulk path now also threads the prisma client (3rd arg,
// outside the upsert tx) and the rated-factor set (4th, empty here).
expect(createTagLinks).toHaveBeenCalledWith(
"entry-1",
"user-1",
["movies", "gaming"],
prisma,
[],
);
});
it("skips the tag-link write when an entry sends no tagKeys", async () => {
const res = await POST(
postReq({
entries: [{ mood: "OKAY", moodLoggedAt: "2026-05-16T08:00:00.000Z" }],
}),
);
expect(res.status).toBe(200);
expect(createTagLinks).not.toHaveBeenCalled();
});
it("strips an over-long tagKeys array at the schema boundary (422)", async () => {
const res = await POST(
postReq({
entries: [
{
mood: "OKAY",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
tagKeys: Array.from({ length: 31 }, (_, i) => `k${i}`),
},
],
}),
);
expect(res.status).toBe(422);
expect(createTagLinks).not.toHaveBeenCalled();
});
it("threads ratedFactors into createTagLinks for a bulk entry", async () => {
const res = await POST(
postReq({
entries: [
{
mood: "GUT",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
tagKeys: ["movies"],
ratedFactors: [{ key: "factor_work", rating: 4 }],
},
],
}),
);
expect(res.status).toBe(200);
const json = (await res.json()) as { data: { inserted: number } };
expect(json.data.inserted).toBe(1);
expect(createTagLinks).toHaveBeenCalledWith(
"entry-1",
"user-1",
["movies"],
prisma,
[{ key: "factor_work", rating: 4 }],
);
});
it("marks the single entry skipped (not the batch) on an out-of-scale rating", async () => {
const { RatedFactorOutOfRangeError } = await import("@/lib/mood/tag-links");
// First entry's factor write throws; the loop catch records it skipped.
vi.mocked(createTagLinks).mockRejectedValueOnce(
new RatedFactorOutOfRangeError("factor_conflict", 5, 1, 2),
);
const res = await POST(
postReq({
entries: [
{
mood: "GUT",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
ratedFactors: [{ key: "factor_conflict", rating: 5 }],
},
{
mood: "OKAY",
moodLoggedAt: "2026-05-16T09:00:00.000Z",
},
],
}),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: {
processed: number;
inserted: number;
entries: Array<{ index: number; status: string }>;
};
};
expect(json.data.processed).toBe(2);
// Entry 0 skipped (bad factor), entry 1 inserted clean.
expect(json.data.entries[0].status).toBe("skipped");
expect(json.data.entries[1].status).toBe("inserted");
});
});
+52
View File
@@ -47,6 +47,7 @@ import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push";
import { createTagLinks } from "@/lib/mood/tag-links";
const MAX_ENTRIES_PER_BATCH = 500;
const BATCH_RATE_LIMIT_MAX = 60;
@@ -55,6 +56,32 @@ const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
const bulkEntrySchema = z.object({
mood: moodLevelEnum,
tags: z.array(z.string().max(50)).max(20).optional(),
/**
* v1.12.0 — structured-tag keys from the catalog (`mood_tags.key`),
* mirroring the single-entry `POST /api/mood-entries` contract.
* Without this the bulk path Zod-stripped the field, so iOS-sent
* taxonomy links were silently dropped on the adopt-on-pair backfill.
* The server resolves each key to a `MoodTag` row and writes the
* `MoodEntryTagLink` join; unknown keys are dropped silently (the
* catalog is the source of truth). Bounds match the single-entry
* `structuredTagKeys` schema so one entry can't fan out an unbounded
* link set.
*/
tagKeys: z.array(z.string().max(60)).max(30).optional(),
/**
* v1.12.0 — rated mood factors (`kind = 'RATED'` catalog tags carrying
* a per-entry score). Parallel to the binary `tagKeys`; persisted on
* `MoodEntryTagLink.rating`. The outer 1..5 here is the envelope; the
* server rejects a rating outside the resolved factor's own
* `scaleMin..scaleMax` (e.g. 1..2 for `factor_conflict`) — on the bulk
* path that marks the single entry `skipped`, never the whole batch.
*/
ratedFactors: z
.array(
z.object({ key: z.string().max(60), rating: z.number().int().min(1).max(5) }),
)
.max(30)
.optional(),
note: z.string().max(500).optional(),
moodLoggedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
source: moodSourceEnum.optional().default("MANUAL"),
@@ -204,6 +231,31 @@ async function postBulk(request: NextRequest): Promise<Response> {
},
});
// v1.12.0 — persist structured-tag links, mirroring the
// single-entry `createTagLinks` path. Additive + idempotent:
// `createTagLinks` resolves keys against the catalog (dropping
// unknown keys) and `skipDuplicates` on the join insert keeps a
// re-posted entry from minting duplicate links. Runs for both
// fresh and re-posted (upserted) rows so a backfill that adds tag
// keys on a second pass still lands them.
// v1.12.0 — rated factors ride the same path; an out-of-scale
// rating throws `RatedFactorOutOfRangeError`, which the per-entry
// catch below turns into a `skipped` result (the rest of the batch
// still lands). The mood row itself already upserted, so a skipped
// factor leaves a valid entry with no rated links.
if (
(entry.tagKeys && entry.tagKeys.length > 0) ||
(entry.ratedFactors && entry.ratedFactors.length > 0)
) {
await createTagLinks(
result.id,
user.id,
entry.tagKeys ?? [],
prisma,
entry.ratedFactors ?? [],
);
}
if (existing) {
duplicates += 1;
results.push({ index: i, status: "duplicate", id: result.id });
+71 -18
View File
@@ -22,7 +22,10 @@ import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push";
import { createTagLinks } from "@/lib/mood/tag-links";
import {
createTagLinks,
RatedFactorOutOfRangeError,
} from "@/lib/mood/tag-links";
function parseTags(tags: string | null): string[] {
if (!tags) return [];
@@ -93,9 +96,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
skip: offset,
// v1.8.5 — include the structured-tag link keys so the edit form
// can pre-populate the taxonomy picker.
// v1.12.0 — also carry the per-link `rating` + the tag `kind` so
// the client can split binary tags from rated factors on hydrate.
include: {
tagLinks: {
select: { moodTag: { select: { key: true } } },
select: {
rating: true,
moodTag: { select: { key: true, kind: true } },
},
},
},
}),
@@ -110,8 +118,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
const entriesWithParsedTags = entries.map(({ tagLinks, ...e }) => ({
...e,
tags: parseTags(e.tags),
// v1.8.5 — flat list of structured-tag keys attached to the entry.
tagKeys: tagLinks.map((link) => link.moodTag.key),
// v1.8.5 — flat list of binary structured-tag keys attached to the
// entry (rated factors are surfaced separately below).
tagKeys: tagLinks
.filter((link) => link.moodTag.kind !== "RATED")
.map((link) => link.moodTag.key),
// v1.12.0 — rated factors with their per-entry score, so the edit
// form re-renders the sliders without a refetch.
ratedFactors: tagLinks
.filter((link) => link.moodTag.kind === "RATED" && link.rating !== null)
.map((link) => ({ key: link.moodTag.key, rating: link.rating as number })),
}));
return apiSuccess({
@@ -157,7 +173,8 @@ async function postMoodEntry(request: NextRequest) {
return returnAllZodIssues(parsed.error, 422);
}
const { mood, tags, tagKeys, note, moodLoggedAt, source } = parsed.data;
const { mood, tags, tagKeys, ratedFactors, note, moodLoggedAt, source } =
parsed.data;
// v1.4.25 W7b (Decision A) — anchor the `date` string to the user's
// current displayTimezone and store the resolved zone on the row.
// Legacy rows with `tz IS NULL` continue to read as Europe/Berlin
@@ -172,8 +189,8 @@ async function postMoodEntry(request: NextRequest) {
// tag-link failure must roll the entry back too — otherwise a client
// retry on the 5xx mints a duplicate entry. The tx client is threaded
// through the helper so both writes commit (or abort) together.
const { entry, persistedTagKeys } = await prisma.$transaction(
async (tx) => {
const { entry, persistedTagKeys, persistedRatedFactors } =
await prisma.$transaction(async (tx) => {
const created = await tx.moodEntry.create({
data: {
userId: user.id,
@@ -188,26 +205,48 @@ async function postMoodEntry(request: NextRequest) {
},
});
if (tagKeys && tagKeys.length > 0) {
// Unknown keys are dropped inside the helper (the catalog is the
// source of truth).
await createTagLinks(created.id, tagKeys, tx);
if (
(tagKeys && tagKeys.length > 0) ||
(ratedFactors && ratedFactors.length > 0)
) {
// Unknown / non-RATED keys are dropped inside the helper (the
// catalog is the source of truth). An out-of-scale rating
// throws `RatedFactorOutOfRangeError`, rolling the tx back.
await createTagLinks(
created.id,
user.id,
tagKeys ?? [],
tx,
ratedFactors ?? [],
);
}
// v1.8.5 — read the persisted link keys back so the create
// response mirrors the list GET shape exactly (unknown keys
// already filtered out).
// v1.8.5 / v1.12.0 — read the persisted links back so the create
// response mirrors the list GET shape exactly: binary keys and
// rated factors split by `kind` (unknown keys already filtered).
const links = await tx.moodEntryTagLink.findMany({
where: { moodEntryId: created.id },
select: { moodTag: { select: { key: true } } },
select: {
rating: true,
moodTag: { select: { key: true, kind: true } },
},
});
return {
entry: created,
persistedTagKeys: links.map((link) => link.moodTag.key),
persistedTagKeys: links
.filter((link) => link.moodTag.kind !== "RATED")
.map((link) => link.moodTag.key),
persistedRatedFactors: links
.filter(
(link) => link.moodTag.kind === "RATED" && link.rating !== null,
)
.map((link) => ({
key: link.moodTag.key,
rating: link.rating as number,
})),
};
},
);
});
await auditLog("moodEntry.create", {
userId: user.id,
@@ -271,10 +310,24 @@ async function postMoodEntry(request: NextRequest) {
// hydrating from the create response renders the tag set without a
// refetch (shape-matches the list GET).
tagKeys: persistedTagKeys,
// v1.12.0 — rated factors with their per-entry score.
ratedFactors: persistedRatedFactors,
},
201,
);
} catch (err) {
// v1.12.0 — a factor rating outside its catalog scale is a client
// error, not a 5xx. The Zod schema only enforces the 1..5 envelope;
// the per-tag scale (e.g. 1..2 for conflict) is the real gate.
if (err instanceof RatedFactorOutOfRangeError) {
annotate({
action: { name: "mood-entries.create.rated-factor-out-of-range" },
meta: { scaleMin: err.scaleMin, scaleMax: err.scaleMax },
});
return apiError(err.message, 422, {
errorCode: "mood.ratedFactor.out_of_range",
});
}
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2002"
@@ -0,0 +1,114 @@
/**
* v1.12.0 — `GET /api/mood/tags` must expose the rated-factor metadata
* (`kind` / `scaleMin` / `scaleMax` / `inverse`) so the client can branch
* between binary toggle chips and rated segmented controls.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const categoryFindMany = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
moodTagCategory: { findMany: (...a: unknown[]) => categoryFindMany(...a) },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
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 },
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
});
describe("GET /api/mood/tags — rated-factor catalog metadata (v1.12.0)", () => {
it("selects + emits kind / scaleMin / scaleMax / inverse per tag", async () => {
categoryFindMany.mockResolvedValue([
{
key: "factors",
labelKey: "mood.tagCategory.factors",
icon: "SlidersHorizontal",
tags: [
{
key: "factor_work",
labelKey: "mood.tag.factorWork",
icon: "Briefcase",
kind: "RATED",
scaleMin: 1,
scaleMax: 5,
inverse: false,
},
{
key: "factor_conflict",
labelKey: "mood.tag.factorConflict",
icon: "Swords",
kind: "RATED",
scaleMin: 1,
scaleMax: 2,
inverse: true,
},
],
},
]);
const res = await GET();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
categories: Array<{
key: string;
tags: Array<{
key: string;
kind: string;
scaleMin: number;
scaleMax: number;
inverse: boolean;
}>;
}>;
};
};
// The Prisma select must request the new columns.
const selectArg = categoryFindMany.mock.calls[0]?.[0] as {
select: { tags: { select: Record<string, boolean> } };
};
expect(selectArg.select.tags.select).toMatchObject({
kind: true,
scaleMin: true,
scaleMax: true,
inverse: true,
});
const factor = body.data.categories[0].tags[0];
expect(factor.kind).toBe("RATED");
expect(factor.scaleMin).toBe(1);
expect(factor.scaleMax).toBe(5);
expect(factor.inverse).toBe(false);
const conflict = body.data.categories[0].tags[1];
expect(conflict.scaleMax).toBe(2);
expect(conflict.inverse).toBe(true);
});
});
+13 -1
View File
@@ -29,7 +29,19 @@ export const GET = apiHandler(async () => {
tags: {
where: { isActive: true },
orderBy: { sortOrder: "asc" },
select: { key: true, labelKey: true, icon: true },
// v1.12.0 — emit the rated-factor metadata so the client can
// branch rendering: `kind = 'BINARY'` → a toggle chip; `'RATED'`
// → a 1..scaleMax segmented control (Yes/No when scaleMax = 2).
// `inverse` flags factors where a higher score means a worse day.
select: {
key: true,
labelKey: true,
icon: true,
kind: true,
scaleMin: true,
scaleMax: true,
inverse: true,
},
},
},
});
+18 -4
View File
@@ -13,6 +13,7 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricLastMeasurementCard } from "@/components/insights/metric-last-measurement-card";
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
@@ -46,7 +47,10 @@ export default function InsightsBlutdruckPage() {
const { data: status, isLoading: isStatusLoading } =
useInsightStatus("blood-pressure");
const { isEmpty } = useInsightsAnalytics("BLOOD_PRESSURE_SYS");
const { data: analytics, isEmpty } =
useInsightsAnalytics("BLOOD_PRESSURE_SYS");
const bpLastSeenAt =
analytics?.lastSeenByType?.BLOOD_PRESSURE_SYS?.lastSeenAt ?? null;
if (isEmpty) {
return (
@@ -103,6 +107,15 @@ export default function InsightsBlutdruckPage() {
description={t("insights.subPage.blutdruckDescription")}
explainerMetric="bloodPressure"
coachLaunch
primary={
<>
{/* Blood pressure's primary tile IS its richer target panel
(ESH classification + sys/dia band + 30-day S/D average),
promoted above the chart per the canonical spine. */}
<MetricTargetSummary slug="blood-pressure" />
<MetricLastMeasurementCard lastSeenAt={bpLastSeenAt} />
</>
}
diversityNudge={
<MeasurementDiversityNudge
measurementType="BLOOD_PRESSURE_SYS"
@@ -118,7 +131,6 @@ export default function InsightsBlutdruckPage() {
>
{/* The period-over-period delta keys on the systolic series — the
canonical BP figure the targets + status surfaces lead with. */}
<MetricRangeControls measurementType="BLOOD_PRESSURE_SYS" enabled={!isEmpty} />
<HealthChartDynamic
chartKey="bp"
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
@@ -130,9 +142,11 @@ export default function InsightsBlutdruckPage() {
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
{/* v1.12.0 — range pills + period-over-period delta below the chart. */}
<MetricRangeControls measurementType="BLOOD_PRESSURE_SYS" enabled={!isEmpty} />
<MetricTargetSummary slug="blood-pressure" />
{/* v1.12.0 — Einschätzung is the last block on the canonical
metric-detail spine. */}
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<HeartPulse className="h-5 w-5" />}
+2 -1
View File
@@ -121,7 +121,6 @@ export default function InsightsBmiPage() {
page's controls.
BMI is derived from WEIGHT, so the period-over-period delta keys on
the underlying weight series the chart reuses. */}
<MetricRangeControls measurementType="WEIGHT" enabled={!isEmpty} />
<HealthChartDynamic
chartKey="bmi"
types={["WEIGHT"]}
@@ -133,6 +132,8 @@ export default function InsightsBmiPage() {
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
{/* v1.12.0 — range pills + period-over-period delta below the chart. */}
<MetricRangeControls measurementType="WEIGHT" enabled={!isEmpty} />
<MetricTargetSummary slug="bmi" />
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { Minimize2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { CoachConversation } from "@/components/insights/coach-panel/coach-conversation";
import { useTranslations } from "@/lib/i18n/context";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
/**
* v1.12.0 (Coach v2 #6) — full-page Coach conversation.
*
* Renders the exact same `<CoachConversation>` surface the drawer uses
* (history + streaming thread + composer + provenance + settings) at
* full width and height. There is no forked chat logic — the page is a
* thin chrome wrapper that supplies a "minimize back to drawer"
* control where the drawer supplies "maximize" + "close".
*
* The route lives inside `app/insights/layout.tsx` (sticky tab strip +
* mobile FAB) and the authenticated `<AuthShell>` (which provides the
* `<CoachLaunchProvider>` the minimize control reopens the drawer
* through), so the page inherits the same Coach feature gating as every
* other Insights surface. When the operator's master flag is off or the
* user opted out, the page redirects back to `/insights` rather than
* painting a dead chat shell.
*/
export default function CoachPage() {
const { t } = useTranslations();
const router = useRouter();
const launch = useCoachLaunch();
const flags = useFeatureFlags();
const disableCoach = useDisableCoach();
const coachUnavailable = !flags.coach || disableCoach;
// Coach surface gating mirrors `<CoachLaunchButton>` / `<LayoutCoachFab>`:
// operator master flag OR per-user opt-out hides the Coach entirely.
// Send a direct navigator back to the Insights mother page so the
// route is never a dead-end.
useEffect(() => {
if (coachUnavailable) {
router.replace("/insights");
}
}, [coachUnavailable, router]);
if (coachUnavailable) return null;
const handleMinimize = () => {
// Hand the conversation back to the drawer overlay: route to the
// Insights mother page and reopen the drawer through the shared
// launch context.
router.push("/insights");
launch?.askCoach(null);
};
return (
<div
data-slot="coach-page"
className="flex h-[calc(100dvh-13rem)] min-h-[28rem] flex-col overflow-hidden rounded-2xl border"
>
<CoachConversation
surface="page"
autoFocusComposer
renderTitle={(title) => (
<h1 className="min-w-0 truncate text-sm font-semibold">{title}</h1>
)}
leadingHeaderActions={
<Button
type="button"
variant="ghost"
size="icon"
onClick={handleMinimize}
data-slot="coach-page-minimize"
aria-label={t("insights.coach.minimizeAriaLabel")}
title={t("insights.coach.minimizeAriaLabel")}
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
>
<Minimize2 className="size-4" aria-hidden="true" />
</Button>
}
/>
</div>
);
}
+40 -40
View File
@@ -69,16 +69,6 @@ const DailyBriefing = dynamic(
loading: () => <BlockSkeleton minHeight="min-h-96" />,
},
);
const CorrelationRow = dynamic(
() =>
import("@/components/insights/correlation-row").then((mod) => ({
default: mod.CorrelationRow,
})),
{
ssr: false,
loading: () => <BlockSkeleton minHeight="min-h-80" />,
},
);
const TrendsRow = dynamic(
() =>
import("@/components/insights/trends-row").then((mod) => ({
@@ -160,8 +150,11 @@ const PeriodNarrativeCard = dynamic(
* - The sticky tab strip lives in `src/app/insights/layout.tsx`
* (the shared `<InsightsLayoutShell>` mounts it). The strip handles
* navigation to every sub-page + the regenerate affordance.
* - Hero + DailyBriefing + Correlation row + Trends row + advisor
* card stay here — they're the cross-metric overview.
* - Hero + DailyBriefing + Trends row + advisor card stay here —
* they're the cross-metric overview. The per-metric correlation
* cards moved onto the metric pages they belong to (Weight owns
* weight × weekday, Pulse owns mood × pulse, …), so the overview
* no longer renders a duplicate correlation row.
* - The CoachDrawer is mounted in the mother-page body only (Marc
* directive). Navigating to a sub-page unmounts the drawer.
*
@@ -223,10 +216,12 @@ export default function InsightsPage() {
// beyond the React-state subscription.
const advisor = useInsightsAdvisorQuery(isAuthenticated);
// v1.4.33 IW2 — the mother page reads `correlations` + `healthScore`
// (thick-only fields) so it stays on the default thick slice. The
// shared hook still centralises the cache settings so the consumer
// dedups with the sub-page mounts that ride the slim slice instead.
// v1.4.33 IW2 — the mother page reads `healthScore` (a thick-only
// field) for the hero score card, so it stays on the default thick
// slice. The shared hook still centralises the cache settings so the
// consumer dedups with the sub-page mounts that ride the slim slice
// instead. Correlations now live on the per-metric pages, so the
// overview no longer reads `analytics.correlations`.
const analyticsQuery = useAnalyticsQuery();
const analytics = analyticsQuery.data as AnalyticsData | undefined;
@@ -278,6 +273,15 @@ export default function InsightsPage() {
// server children would require a Server-Component refactor; that's
// a v1.5.x track.
// v1.12.0 — section order mirrors the iOS Insights overview
// (`v0.14` app-structure handover §2c): Coach hero + briefing + chips
// (the HeroStrip bundles all three) → detailed "Heute auf einen Blick"
// briefing → dynamics / alerts zone (today's signal + rhythm events,
// out of the AI gate so a health alert is never hidden) → vitals
// dashboard → trends row → "Zeitraum im Rückblick" retrospective →
// footer (the warm-assessments utility control, no iOS equivalent —
// iOS auto-warms). The per-metric correlation cards moved onto the
// metric pages, so the overview renders no correlation row.
return (
<div className="space-y-8">
<HeroStrip
@@ -297,8 +301,28 @@ export default function InsightsPage() {
healthScore={analytics?.healthScore ?? undefined}
/>
{flags.briefing && (
<DailyBriefing
briefing={briefingPayload}
updatedAt={heroStripUpdatedAt}
loading={advisor.isLoading}
onRegenerate={advisor.regenerate}
regenerating={advisor.isRegenerating}
/>
)}
<CoincidentDeviationCard enabled={isAuthenticated} />
<RhythmEventsCard enabled={isAuthenticated} />
<VitalsDashboard enabled={isAuthenticated} />
<TrendsRow
briefing={briefingPayload}
annotations={advisor.payload?.trendAnnotations ?? null}
loading={advisor.isLoading || advisor.isRegenerating}
/>
{flags.briefing && <PeriodNarrativeCard enabled={isAuthenticated} />}
{/* v1.11.3 — anchor the warm-assessments control. It used to float
@@ -320,30 +344,6 @@ export default function InsightsPage() {
{t("insights.warmAssessments")}
</Button>
</div>
<VitalsDashboard enabled={isAuthenticated} />
<RhythmEventsCard enabled={isAuthenticated} />
{flags.briefing && (
<DailyBriefing
briefing={briefingPayload}
updatedAt={heroStripUpdatedAt}
loading={advisor.isLoading}
onRegenerate={advisor.regenerate}
regenerating={advisor.isRegenerating}
/>
)}
{flags.correlations && analytics?.correlations && (
<CorrelationRow results={analytics.correlations} />
)}
<TrendsRow
briefing={briefingPayload}
annotations={advisor.payload?.trendAnnotations ?? null}
loading={advisor.isLoading || advisor.isRegenerating}
/>
</div>
);
}
+29 -16
View File
@@ -13,6 +13,9 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { MetricPrimaryTile } from "@/components/insights/metric-primary-tile";
import { MetricLastMeasurementCard } from "@/components/insights/metric-last-measurement-card";
import { MetricCorrelationCard } from "@/components/insights/metric-correlation-card";
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
@@ -52,6 +55,8 @@ export default function InsightsPulsPage() {
// the same key).
const { data: analytics, isEmpty } = useInsightsAnalytics("PULSE");
const vo2Summary = analytics?.summaries?.VO2_MAX ?? null;
const pulseSummary = analytics?.summaries?.PULSE ?? null;
const pulseLastSeenAt = analytics?.lastSeenByType?.PULSE?.lastSeenAt ?? null;
// v1.4.27 F17 — gate the sub-page on at least one pulse observation.
// Brand-new accounts (no manual logs, no Apple-Health upload yet)
@@ -116,12 +121,13 @@ export default function InsightsPulsPage() {
title={t("insights.pulseSectionTitle")}
description={t("insights.subPage.pulsDescription")}
explainerMetric="pulse"
statStrip={
<MetricStatStrip
summary={analytics?.summaries?.PULSE ?? null}
unit="bpm"
/>
primary={
<>
<MetricPrimaryTile summary={pulseSummary} unit="bpm" slug="pulse" />
<MetricLastMeasurementCard lastSeenAt={pulseLastSeenAt} />
</>
}
statStrip={<MetricStatStrip summary={pulseSummary} unit="bpm" />}
diversityNudge={
<MeasurementDiversityNudge
measurementType="PULSE"
@@ -132,7 +138,6 @@ export default function InsightsPulsPage() {
coachLaunch
showAllValuesType="PULSE"
>
<MetricRangeControls measurementType="PULSE" enabled={!isEmpty} />
<HealthChartDynamic
chartKey="pulse"
types={["PULSE"]}
@@ -143,19 +148,11 @@ export default function InsightsPulsPage() {
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
{/* v1.12.0 — range pills + period-over-period delta below the chart. */}
<MetricRangeControls measurementType="PULSE" enabled={!isEmpty} />
<MetricTargetSummary slug="pulse" />
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Heart className="h-5 w-5" />}
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
preparing={status?.preparing ?? false}
/>
{/* v1.4.25 W16a — VO2 max sits on the cardio sub-page because it
is a cardio-fitness metric (Apple's Health app surfaces it
under "Heart"). The chart-row stays mounted even at zero
@@ -167,6 +164,22 @@ export default function InsightsPulsPage() {
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
{/* v1.12.0 — Pulse owns the mood × pulse correlation (relocated off
the overview onto its metric page). */}
<MetricCorrelationCard slug="pulse" />
{/* v1.12.0 — Einschätzung is the last block on the canonical
metric-detail spine. */}
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Heart className="h-5 w-5" />}
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
preparing={status?.preparing ?? false}
/>
</SubPageShell>
);
}
+9 -3
View File
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
import { MetricStatusCard } from "@/components/insights/metric-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricLastMeasurementCard } from "@/components/insights/metric-last-measurement-card";
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SleepOverview } from "@/components/insights/sleep-overview";
@@ -35,7 +36,10 @@ export default function InsightsSchlafPage() {
const { t } = useTranslations();
const { user } = useAuth();
const { isEmpty } = useInsightsAnalytics("SLEEP_DURATION");
const { data: analytics, isEmpty } =
useInsightsAnalytics("SLEEP_DURATION");
const sleepLastSeenAt =
analytics?.lastSeenByType?.SLEEP_DURATION?.lastSeenAt ?? null;
if (isEmpty) {
return (
@@ -67,6 +71,7 @@ export default function InsightsSchlafPage() {
description={t("insights.sleep.description")}
explainerMetric="sleep"
coachLaunch
primary={<MetricLastMeasurementCard lastSeenAt={sleepLastSeenAt} />}
diversityNudge={
<MeasurementDiversityNudge
measurementType="SLEEP_DURATION"
@@ -80,10 +85,11 @@ export default function InsightsSchlafPage() {
min / max / median / mean strip would duplicate it and read in an
awkward unit. */
>
<MetricRangeControls measurementType="SLEEP_DURATION" enabled={!isEmpty} />
<SleepOverview />
{/* v1.12.0 — range pills + period-over-period delta below the chart. */}
<MetricRangeControls measurementType="SLEEP_DURATION" enabled={!isEmpty} />
<MetricTargetSummary slug="sleep" />
{/*
+18 -1
View File
@@ -13,6 +13,9 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { MetricPrimaryTile } from "@/components/insights/metric-primary-tile";
import { MetricLastMeasurementCard } from "@/components/insights/metric-last-measurement-card";
import { MetricCorrelationCard } from "@/components/insights/metric-correlation-card";
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
@@ -42,6 +45,8 @@ export default function InsightsGewichtPage() {
const { data: analytics, isEmpty } = useInsightsAnalytics("WEIGHT");
const weightSummary = analytics?.summaries?.WEIGHT ?? null;
const weightLastSeenAt =
analytics?.lastSeenByType?.WEIGHT?.lastSeenAt ?? null;
if (isEmpty) {
return (
@@ -79,6 +84,12 @@ export default function InsightsGewichtPage() {
title={t("insights.weightSectionTitle")}
description={t("insights.subPage.gewichtDescription")}
explainerMetric="weight"
primary={
<>
<MetricPrimaryTile summary={weightSummary} unit="kg" slug="weight" />
<MetricLastMeasurementCard lastSeenAt={weightLastSeenAt} />
</>
}
statStrip={<MetricStatStrip summary={weightSummary} unit="kg" />}
diversityNudge={
<MeasurementDiversityNudge
@@ -90,7 +101,6 @@ export default function InsightsGewichtPage() {
coachLaunch
showAllValuesType="WEIGHT"
>
<MetricRangeControls measurementType="WEIGHT" enabled={!isEmpty} />
<HealthChartDynamic
chartKey="weight"
types={["WEIGHT"]}
@@ -101,6 +111,8 @@ export default function InsightsGewichtPage() {
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
{/* v1.12.0 — range pills + period-over-period delta below the chart. */}
<MetricRangeControls measurementType="WEIGHT" enabled={!isEmpty} />
<MetricTargetSummary slug="weight" />
@@ -109,8 +121,13 @@ export default function InsightsGewichtPage() {
unit="kg"
color="#bd93f9"
enabled={!isEmpty}
compact
/>
{/* v1.12.0 — Weight owns the weight × weekday correlation (relocated
off the overview onto its metric page). */}
<MetricCorrelationCard slug="weight" />
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Scale className="h-5 w-5" />}
+23
View File
@@ -17,6 +17,7 @@ import {
Target,
TrendingUp,
Waves,
Wrench,
} from "lucide-react";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import { cn } from "@/lib/utils";
@@ -27,6 +28,7 @@ import {
} from "@/lib/dashboard-layout";
import type { DashboardAnalyticsData as AnalyticsData } from "@/types/analytics";
import dynamic from "next/dynamic";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
@@ -809,6 +811,27 @@ export default function DashboardPage() {
<p className="text-muted-foreground mt-1 text-sm">{welcomeText}</p>
</div>
<div className="flex items-center gap-2">
{/* Wrench shortcut to the dashboard-customization settings
(the tile/layout editor at /settings/dashboard). Sits to
the left of the add button as a monochrome ghost icon, with
a `min-h-11 min-w-11` mobile floor so it meets the 44 px
touch-target contract the add button also honours, shrinking
back to the 40 px icon footprint on sm+. */}
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
data-testid="dashboard-customize-shortcut"
>
<Link
href="/settings/dashboard"
aria-label={t("dashboard.customizeDashboard")}
title={t("dashboard.customizeDashboard")}
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* v1.4.33 maintainer-item-7 — restore proportional sizing
@@ -1,163 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { CorrelationResult } from "@/lib/insights/correlations";
/**
* v1.4.20 phase B3 — `<CorrelationRow>` layout + disclaimer.
* v1.4.22 A4 update — empty-state cards are dropped instead of rendered.
*
* Acceptance:
* 1. Renders only the hypothesis cards whose `status === "ok"`.
* 2. Mounts the correlation-disclaimer footer once at the row level
* when at least one card renders; the row hides itself entirely
* when zero cards render (no header, no disclaimer).
* 3. 2-up grid on >= md when 2+ cards, full-width single column when
* exactly 1 card renders.
* 4. Below-threshold cards are filtered at the row level so the
* layout collapses cleanly.
*/
vi.mock("next/dynamic", () => ({
default: () => {
const Stub = () => <div data-slot="scatter-stub">scatter</div>;
Stub.displayName = "ScatterStub";
return Stub;
},
}));
import { CorrelationRow } from "../correlation-row";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
const okBpCompliance: CorrelationResult = {
kind: "bp-compliance",
status: "ok",
statistic: -0.62,
n: 28,
pValue: 0.001,
confidenceBand: { low: -0.85, high: -0.39, label: "high" },
interpretation:
"Higher medication compliance lines up with lower systolic — a pattern worth watching.",
points: [{ x: 60, y: 140 }],
xLabel: "Compliance %",
yLabel: "Systolic (mmHg)",
};
const insufficientMoodPulse: CorrelationResult = {
kind: "mood-pulse",
status: "insufficient",
n: 8,
reason: "too_few_pairs",
points: [],
};
const okWeightWeekday: CorrelationResult = {
kind: "weight-weekday",
status: "ok",
statistic: 0.18,
n: 28,
pValue: 0.02,
confidenceBand: { low: 0.18, high: 0.18, label: "moderate" },
interpretation:
"Monday weights run 0.6 kg above your other-day average — a pattern worth watching.",
points: [{ x: 0, y: 84 }],
xLabel: "Weekday",
yLabel: "Weight (kg)",
};
describe("<CorrelationRow>", () => {
it("renders only the ok-status hypothesis cards (insufficient ones are dropped)", () => {
const html = render(
<CorrelationRow
results={{
bpCompliance: okBpCompliance,
moodPulse: insufficientMoodPulse,
weightWeekday: okWeightWeekday,
}}
/>,
);
const matches = html.match(/data-slot="correlation-card"/g) ?? [];
expect(matches.length).toBe(2);
expect(html).toMatch(/data-kind="bp-compliance"/);
expect(html).not.toMatch(/data-kind="mood-pulse"/);
expect(html).toMatch(/data-kind="weight-weekday"/);
});
it("renders the correlation-disclaimer once at the row level", () => {
const html = render(
<CorrelationRow
results={{
bpCompliance: okBpCompliance,
moodPulse: insufficientMoodPulse,
weightWeekday: okWeightWeekday,
}}
/>,
);
const matches = html.match(/data-slot="correlation-row-disclaimer"/g) ?? [];
expect(matches.length).toBe(1);
expect(html).toContain("Relationships are observational, not causal");
});
it("applies the 2-up grid on >= md when 2+ ok cards render", () => {
const html = render(
<CorrelationRow
results={{
bpCompliance: okBpCompliance,
moodPulse: insufficientMoodPulse,
weightWeekday: okWeightWeekday,
}}
/>,
);
expect(html).toMatch(/grid-cols-1/);
expect(html).toMatch(/md:grid-cols-2/);
});
it("collapses to a full-width single column when exactly 1 ok card renders", () => {
const html = render(
<CorrelationRow
results={{
bpCompliance: okBpCompliance,
moodPulse: insufficientMoodPulse,
weightWeekday: insufficientMoodPulse,
}}
/>,
);
expect(html).toMatch(/grid-cols-1/);
// The 2-up rule must NOT activate when only one card has data —
// a single card should span 100 % width on its own row instead of
// half-width with empty space next to it.
expect(html).not.toMatch(/md:grid-cols-2/);
});
it("hides the row entirely (no header, no disclaimer) when zero cards have data", () => {
const html = render(
<CorrelationRow
results={{
bpCompliance: insufficientMoodPulse,
moodPulse: insufficientMoodPulse,
weightWeekday: insufficientMoodPulse,
}}
/>,
);
expect(html).toBe("");
});
it("renders German copy when locale=de", () => {
const html = render(
<CorrelationRow
results={{
bpCompliance: okBpCompliance,
moodPulse: insufficientMoodPulse,
weightWeekday: okWeightWeekday,
}}
/>,
"de",
);
expect(html).toContain("nicht kausal");
});
});
@@ -154,12 +154,15 @@ const GLUCOSE_DATA: TargetsFixture = {
};
describe("<MetricTargetSummary>", () => {
it("renders the numeric range + in-target share for a simple metric", () => {
it("renders the numeric range for a simple metric", () => {
const html = renderWith("weight", WEIGHT_DATA);
// Trailing `.0` trimmed, single-decimal kept.
expect(html).toContain("Target: 60.1–80.9 kg");
// round(18/24*100) = 75
expect(html).toContain("75% of logged days within target");
// v1.12.0 — the in-target share moved up to `<MetricPrimaryTile>`,
// the canonical home for the headline + 30-day average + in-range
// bar. The band-reference panel no longer repeats it for non-BP
// metrics.
expect(html).not.toContain("of logged days within target");
});
it("renders the verbal status pill + the guideline source", () => {
@@ -175,21 +178,26 @@ describe("<MetricTargetSummary>", () => {
);
});
it("renders the range bar + the 30-day average + the consistency strip", () => {
it("renders the range bar + the consistency strip", () => {
const html = renderWith("weight", WEIGHT_DATA);
expect(html).toContain('data-slot="target-range-bar"');
expect(html).toContain('data-slot="consistency-strip"');
expect(html).toContain("30-day average: 71.4 kg");
// v1.12.0 — the 30-day average moved up to `<MetricPrimaryTile>` for
// every metric except blood pressure (whose richer panel IS its
// primary tile and keeps the stitched S/D average inline).
expect(html).not.toContain("30-day average: 71.4 kg");
});
it("stitches the systolic + diastolic bands for blood pressure", () => {
const html = renderWith("blood-pressure", BP_DATA);
expect(html).toContain("Target: 120–129 / 70–79 mmHg");
expect(html).toContain("50% of logged days within target");
// v1.12.0 — the in-target share moved up to the primary tile; the BP
// panel keeps only the stitched 30-day average inline.
expect(html).not.toContain("of logged days within target");
// The diastolic range bar renders alongside the systolic one.
const barCount = (html.match(/data-slot="target-range-bar"/g) ?? []).length;
expect(barCount).toBe(2);
// 30-day average stitches the diastolic pair.
// 30-day average stitches the diastolic pair — kept on the BP panel.
expect(html).toContain("30-day average: 122/76 mmHg");
});
@@ -7,15 +7,15 @@ import { CoachDrawerBody } from "../coach-drawer-body";
/**
* v1.4.20 phase B4 — Coach drawer mobile rail trays.
*
* The drawer hides the history + sources rails on `<lg`. B4 adds two
* chevron-button triggers along the edges of the message thread that
* open side-sheets surfacing the same rails.
* Two chevron-button triggers along the thread sub-header open
* side-sheets surfacing the history + sources rails.
*
* Phase D reconcile narrowed the lg drawer cap (was 1080px → now
* min(960px,75vw)) so the inline sources rail no longer fits below
* xl. The history rail surfaces inline on lg; the sources rail
* surfaces inline on xl. Mobile chevron triggers cover the missing
* rails: history hidden on lg+, sources hidden on xl+.
* v1.12.0 — the conversation history is no longer an inline column on
* any viewport. The "Conversations" trigger is the only entry to the
* history rail and is therefore visible at every breakpoint; the
* sources rail stays inline at xl+ so its trigger keeps `xl:hidden`.
* The clinical-decisions disclaimer renders once, directly above the
* composer.
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
@@ -25,10 +25,10 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
}
const baseProps = {
historyRail: <span>history</span>,
sourcesRail: <span>sources</span>,
thread: <span>thread</span>,
composer: <span>composer</span>,
disclaimer: <span>disclaimer</span>,
onOpenHistoryTray: () => undefined,
onOpenSourcesTray: () => undefined,
};
@@ -40,42 +40,55 @@ describe("<CoachDrawerBody> — mobile rail trays", () => {
expect(html).toMatch(/data-slot="coach-drawer-sources-tray-trigger"/);
});
it("hides the history trigger on >=lg and the sources trigger on >=xl", () => {
// v1.4.27 R3d MB2 — the strip wrapper carries `xl:hidden` so the
// sources trigger inherits the breakpoint; the history trigger
// keeps its own `lg:hidden` because the history rail mounts
// inline already on lg+ while the sources rail still needs the
// chevron until xl.
it("keeps the history trigger on every breakpoint and hides the sources trigger on >=xl", () => {
// v1.12.0 — the history rail is tray-only now, so the
// "Conversations" toggle is always visible (no `lg:hidden`) and
// the strip wrapper no longer carries `xl:hidden`. The sources
// trigger keeps `xl:hidden` because the sources rail is inline at
// xl+.
const html = render(<CoachDrawerBody {...baseProps} />);
const strip = html.match(
/<div[^>]*data-slot="coach-drawer-rail-tray-strip"[^>]*>/,
);
expect(strip?.[0]).toContain("xl:hidden");
expect(strip?.[0]).not.toContain("xl:hidden");
const historyTrigger = html.match(
/<button[^>]*data-slot="coach-drawer-history-tray-trigger"[^>]*>/,
);
expect(historyTrigger).not.toBeNull();
expect(historyTrigger?.[0]).toContain("lg:hidden");
expect(historyTrigger?.[0]).not.toContain("lg:hidden");
const sourcesTrigger = html.match(
/<button[^>]*data-slot="coach-drawer-sources-tray-trigger"[^>]*>/,
);
expect(sourcesTrigger?.[0]).toContain("xl:hidden");
});
it("renders the desktop history rail (lg+) and sources rail (xl+)", () => {
it("no longer mounts an inline history column; sources rail stays inline at xl+", () => {
const html = render(<CoachDrawerBody {...baseProps} />);
expect(html).toMatch(
/<aside[^>]*data-slot="coach-drawer-history"[^>]*lg:flex/,
);
expect(html).not.toContain('data-slot="coach-drawer-history"');
expect(html).toMatch(
/<aside[^>]*data-slot="coach-drawer-sources"[^>]*xl:flex/,
);
});
it("renders the message thread + composer slots", () => {
it("renders the message thread + composer slots and the disclaimer above the composer", () => {
const html = render(<CoachDrawerBody {...baseProps} />);
expect(html).toMatch(/data-slot="coach-drawer-thread"/);
expect(html).toMatch(/data-slot="coach-drawer-composer"/);
// Slot content forwarded verbatim.
expect(html).toContain("thread");
expect(html).toContain("composer");
// The disclaimer slot renders inside the composer container, before
// the composer itself.
expect(html).toContain("disclaimer");
const composerBlock = html.match(
/data-slot="coach-drawer-composer"[^>]*>([\s\S]*?)<\/div>/,
);
expect(composerBlock?.[1]).toContain("disclaimer");
const disclaimerIdx = html.indexOf("disclaimer");
const composerIdx = html.lastIndexOf("composer");
expect(disclaimerIdx).toBeLessThan(composerIdx);
});
it("uses chevron-style trigger labels — History on the left, Sources on the right", () => {
@@ -381,13 +381,40 @@ describe("<MessageThread>", () => {
expect(html).toMatch(/<strong[^>]*>\s*96\s*<\/strong>/);
});
it("hides the disclosure entirely when keyValues is empty or absent", () => {
// baseConversation.messages[1].metricSource has no `keyValues`
// field — the source chips still render but the disclosure does
// not appear at all.
it("folds the source chips into the collapsed disclosure (no key-values)", () => {
// v1.12.0 — the provenance block is now a single collapsed
// disclosure. baseConversation.messages[1].metricSource carries
// metrics + windows but no `keyValues`, so the disclosure renders
// (collapsed, with the chips inside) but the key-value list does
// not. The chips no longer paint outside / above the disclosure.
const html = render(<MessageThread conversation={baseConversation} />);
expect(html).toContain('data-slot="coach-evidence"');
expect(html).toContain('data-slot="coach-source-chips"');
expect(html).not.toContain('data-slot="coach-evidence-list"');
// Collapsed by default.
expect(html).not.toMatch(/<details[^>]*\bopen\b/);
// The chips render inside the `<details>` shell, not as a sibling
// above it.
const detailsIdx = html.indexOf('data-slot="coach-evidence"');
const chipsIdx = html.indexOf('data-slot="coach-source-chips"');
expect(detailsIdx).toBeGreaterThanOrEqual(0);
expect(chipsIdx).toBeGreaterThan(detailsIdx);
});
it("renders no disclosure when there is no provenance at all", () => {
const noProvenance: CoachConversationDetailDTO = {
...baseConversation,
messages: [
baseConversation.messages[0],
{
...baseConversation.messages[1],
metricSource: { windows: [], metrics: [] },
},
],
};
const html = render(<MessageThread conversation={noProvenance} />);
expect(html).not.toContain('data-slot="coach-evidence"');
expect(html).not.toContain("What I&#x27;m looking at");
expect(html).not.toContain('data-slot="coach-source-chips"');
});
it("renders the user bubble with the self-hosted avatar when one is set (v1.5.5)", () => {
@@ -68,14 +68,16 @@ describe("<MobileRailTray>", () => {
expect(html).toContain("sources-content");
});
it("hides the history tray on lg+ and the sources tray on xl+", () => {
// Matches the breakpoints documented inline: the history rail
// mounts inline at lg+, so the tray is `lg:hidden`. The sources
// rail mounts inline at xl+ only, so the tray is `xl:hidden`.
it("keeps the history tray on every breakpoint and hides the sources tray on xl+", () => {
// v1.12.0 — the history rail is tray-only now (no inline column on
// any viewport), so the history tray drops its `lg:hidden` cap and
// is available at every breakpoint. The sources rail still mounts
// inline at xl+, so its tray keeps `xl:hidden`.
const html = render(<MobileRailTray {...baseProps} />);
expect(html).toMatch(
/data-slot="coach-drawer-history-tray"[^>]*class="[^"]*lg:hidden/,
const historyTray = html.match(
/data-slot="coach-drawer-history-tray"[^>]*class="([^"]*)"/,
);
expect(historyTray?.[1]).not.toContain("lg:hidden");
expect(html).toMatch(
/data-slot="coach-drawer-sources-tray"[^>]*class="[^"]*xl:hidden/,
);
@@ -89,10 +89,12 @@ describe("<SourcesRail>", () => {
expect(html).toContain('data-source="medication"');
});
it("renders the medical disclaimer in the footer", () => {
it("no longer carries its own medical disclaimer footer", () => {
// v1.12.0 — the clinical-decisions disclaimer is consolidated to a
// single line above the composer; the rail used to duplicate it in
// its footer.
const html = render(<SourcesRail />);
expect(html).toContain('data-slot="coach-sources-disclaimer"');
expect(html).toContain("Clinical decisions belong with your doctor");
expect(html).not.toContain('data-slot="coach-sources-disclaimer"');
});
it("renders a toggle per cluster + the window selector trigger", () => {
@@ -0,0 +1,256 @@
"use client";
import { useEffect, useState } from "react";
import { Plus, Settings, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { CoachDrawerBody } from "./coach-drawer-body";
import { CoachInput } from "./coach-input";
import { CoachSettingsSheet } from "./coach-settings-sheet";
import { HistoryRail } from "./history-rail";
import { MessageThread } from "./message-thread";
import { MobileRailTray } from "./mobile-rail-tray";
import { SourcesRail } from "./sources-rail";
import { useResettableValue } from "./use-resettable-value";
import { useCoachConversation, useSendCoachMessage } from "./use-coach";
/**
* v1.12.0 (Coach v2 #6) — shared chat surface for both the Coach drawer
* (`<CoachDrawer>`, right/bottom `<Sheet>` overlay) and the full-page
* Coach route (`/insights/coach`).
*
* This component is the single source of truth for the Coach
* conversation: it owns the active-conversation id, the streaming send
* hook, the composer value, the settings sheet, and the mobile rail
* trays — every piece of Coach v2 behaviour (incremental streaming,
* collapsible history + provenance, the single composer disclaimer,
* the auto-grow composer, refusal / budget handling) lives here once.
* The drawer and the page differ ONLY in their chrome: the drawer wraps
* this surface in a `<Sheet>` and supplies a close button + a maximize
* control; the page renders it edge-to-edge and supplies a minimize
* control. Neither forks the chat logic.
*
* The title + description render through `renderTitle` / `renderDescription`
* render-props so the drawer can mount the Radix `<SheetTitle>` /
* `<SheetDescription>` (required for the dialog's accessible name) while
* the page mounts a plain `<h1>` / `<p>`.
*/
export interface CoachConversationProps {
/**
* Pre-fill for the composer (suggested-prompt chip click). Resets the
* composer on change via `useResettableValue`; the user can still type
* freely between prop changes.
*/
prefill?: string | null;
/**
* Renders the conversation title. The surface passes the resolved
* title string; the drawer wraps it in `<SheetTitle>`, the page in an
* `<h1>`. Falls back to a plain element when omitted.
*/
renderTitle?: (title: string) => React.ReactNode;
/** Renders the tagline line beneath the title (see `renderTitle`). */
renderDescription?: (tagline: string) => React.ReactNode;
/**
* Chrome-specific header action(s) — the maximize control (drawer) or
* the minimize control (page) — mounted before the avatar so the
* surface-toggle sits at the leading edge of the header.
*/
leadingHeaderActions?: React.ReactNode;
/**
* Chrome-specific trailing control — the drawer's close button. The
* page leaves this empty (it has no close affordance of its own).
*/
trailingHeaderActions?: React.ReactNode;
/**
* Focus the composer on mount. The drawer sets this so the composer
* takes focus when the sheet opens; the page sets it so a keyboard
* user lands in the composer after the route change.
*/
autoFocusComposer?: boolean;
/**
* Hands the chrome an imperative reset (abort any in-flight SSE +
* clear the thread + composer). The drawer wires this to its close
* handler so closing the overlay does not leave a stream running. The
* page omits it — navigating away unmounts the whole subtree, which
* tears the stream down on its own.
*/
registerReset?: (reset: () => void) => void;
/** className passthrough for the outer flex column. */
className?: string;
/** data-variant on the root, surfaced for e2e + styling hooks. */
surface: "drawer" | "page";
}
export function CoachConversation({
prefill,
renderTitle,
renderDescription,
leadingHeaderActions,
trailingHeaderActions,
autoFocusComposer,
registerReset,
className,
surface,
}: CoachConversationProps) {
const { t } = useTranslations();
const [currentConversationId, setCurrentConversationId] = useState<
string | null
>(null);
const [historyTrayOpen, setHistoryTrayOpen] = useState(false);
const [sourcesTrayOpen, setSourcesTrayOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [inputValue, setInputValue] = useResettableValue(prefill ?? "");
const { data: conversation } = useCoachConversation(currentConversationId);
const send = useSendCoachMessage({
onDone: (resolvedId) => {
setCurrentConversationId(resolvedId);
},
});
// Hand the chrome an imperative reset so the drawer can abort the
// in-flight SSE stream + clear the thread on close. Re-registered when
// the closed-over state setters / send hook change identity so the
// callback never captures a stale conversation id.
useEffect(() => {
if (!registerReset) return;
registerReset(() => {
send.cancel();
setCurrentConversationId(null);
setInputValue("");
});
}, [registerReset, send, setInputValue]);
async function handleSubmit(value: string) {
const trimmed = value.trim();
if (!trimmed || send.isStreaming) return;
setInputValue("");
await send.send({
conversationId: currentConversationId ?? undefined,
message: trimmed,
});
}
function handleNewChat() {
setCurrentConversationId(null);
setInputValue("");
send.reset();
}
const title = conversation?.title ?? t("insights.coach.newChat");
const tagline = t("insights.coach.tagline");
return (
<div
data-slot="coach-conversation"
data-variant={surface}
className={cn("flex min-h-0 flex-1 flex-col", className)}
>
<header
data-slot="coach-conversation-header"
className="border-border/70 flex flex-row items-center gap-2 border-b p-3 sm:gap-3 sm:p-4"
>
{leadingHeaderActions}
<div
aria-hidden="true"
className="from-dracula-purple to-dracula-pink flex size-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
>
<Sparkles className="text-background size-4" />
</div>
<div className="min-w-0 flex-1">
{renderTitle ? (
renderTitle(title)
) : (
<p className="min-w-0 truncate text-sm font-semibold">{title}</p>
)}
{renderDescription ? (
renderDescription(tagline)
) : (
<p className="text-muted-foreground truncate text-[11px]">
{tagline}
</p>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={handleNewChat}
data-slot="coach-new-chat"
aria-label={t("insights.coach.newChat")}
title={t("insights.coach.newChat")}
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
>
<Plus className="size-4" aria-hidden="true" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setSettingsOpen(true)}
data-slot="coach-settings"
aria-label={t("insights.coach.settingsAriaLabel")}
title={t("insights.coach.settingsAriaLabel")}
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
>
<Settings className="size-4" aria-hidden="true" />
</Button>
{trailingHeaderActions}
</header>
<CoachDrawerBody
sourcesRail={<SourcesRail />}
thread={
<MessageThread
conversation={conversation ?? null}
streaming={send.streaming}
optimisticUser={send.optimisticUser}
/>
}
disclaimer={
<p
data-slot="coach-composer-disclaimer"
className="text-muted-foreground text-xs leading-relaxed"
>
{t("insights.coach.composerDisclaimer")}
</p>
}
composer={
<CoachInput
value={inputValue}
onChange={setInputValue}
onSubmit={() => handleSubmit(inputValue)}
onCancel={send.cancel}
disabled={send.isStreaming}
isStreaming={send.isStreaming}
autoFocusOnOpen={autoFocusComposer}
/>
}
onOpenHistoryTray={() => setHistoryTrayOpen(true)}
onOpenSourcesTray={() => setSourcesTrayOpen(true)}
/>
<CoachSettingsSheet open={settingsOpen} onOpenChange={setSettingsOpen} />
<MobileRailTray
historyOpen={historyTrayOpen}
onHistoryOpenChange={setHistoryTrayOpen}
historyRail={
<HistoryRail
activeId={currentConversationId}
onSelect={(id) => {
setCurrentConversationId(id);
setHistoryTrayOpen(false);
}}
/>
}
sourcesOpen={sourcesTrayOpen}
onSourcesOpenChange={setSourcesTrayOpen}
sourcesRail={<SourcesRail />}
/>
</div>
);
}
@@ -1,6 +1,6 @@
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { ChevronLeft, MessagesSquare } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
@@ -19,21 +19,28 @@ import { useTranslations } from "@/lib/i18n/context";
* `sourcesTrayOpen` and passes the open-toggles + slot content. This
* keeps the trigger logic + the desktop rail layout in one place
* while delegating the actual side-sheet to the parent.
*
* v1.12.0 — the history rail is no longer an inline column on any
* viewport; it lives behind the always-visible "Conversations" toggle
* and opens as the left-edge tray. The body therefore no longer takes
* a `historyRail` slot — the parent wires that rail straight into the
* `<MobileRailTray>`.
*/
export interface CoachDrawerBodyProps {
historyRail: React.ReactNode;
sourcesRail: React.ReactNode;
thread: React.ReactNode;
composer: React.ReactNode;
/** Clinical-decisions disclaimer line, pinned above the composer. */
disclaimer: React.ReactNode;
onOpenHistoryTray: () => void;
onOpenSourcesTray: () => void;
}
export function CoachDrawerBody({
historyRail,
sourcesRail,
thread,
composer,
disclaimer,
onOpenHistoryTray,
onOpenSourcesTray,
}: CoachDrawerBodyProps) {
@@ -41,21 +48,17 @@ export function CoachDrawerBody({
return (
<div
data-slot="coach-drawer-body"
// v1.4.20 phase D reconcile — narrower drawer cap at lg means the
// sources rail no longer fits inline below xl. lg gets the
// history rail + thread (sources surface via the chevron tray);
// xl+ restores the full three-column layout.
className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[260px_1fr] xl:grid-cols-[260px_1fr_280px]"
// v1.12.0 — the conversation history is no longer a permanent
// left column. It used to mount inline on lg+ (a fixed 260px
// gutter) which ate the chat width on laptops and read as a
// takeover on the narrowed drawer cap. The rail now lives behind
// the "Conversations" toggle on EVERY viewport (opens as the
// left-edge tray), so the thread keeps the full width by default
// and the user summons history on demand. The sources rail stays
// inline at xl+ where there is room; below xl it stays on the
// chevron tray.
className="grid min-h-0 flex-1 grid-cols-1 xl:grid-cols-[1fr_280px]"
>
{/* History rail — desktop only. Mobile users summon it via the
tray trigger inside the thread column. */}
<aside
data-slot="coach-drawer-history"
className="border-border/70 hidden h-full min-h-0 border-r lg:flex lg:flex-col"
>
{historyRail}
</aside>
{/* Centre — message thread. */}
<main
data-slot="coach-drawer-thread"
@@ -64,14 +67,17 @@ export function CoachDrawerBody({
{/* v1.4.27 R3d MB2 — rail-tray triggers lifted out of the
absolute overlay into a sub-header strip so the chevrons
sit at a 44 px tap target and never overlay the first
message bubble. Hidden on `>=xl` because both rails are
inline at that breakpoint. The history trigger remains
visible up to lg-1 (rail is inline at lg+); the sources
trigger remains visible up to xl-1 (rail is inline at
xl+). */}
message bubble.
v1.12.0 — the strip is now ALWAYS rendered because the
"Conversations" toggle is the only way to reach the history
rail on every viewport (the rail is no longer an inline
column). The history trigger stays visible at all
breakpoints; the sources trigger keeps `xl:hidden` since the
sources rail is inline at xl+. */}
<div
data-slot="coach-drawer-rail-tray-strip"
className="border-border/70 flex items-center justify-between gap-2 border-b px-3 py-2 xl:hidden"
className="border-border/70 flex items-center justify-between gap-2 border-b px-3 py-2"
>
<Button
type="button"
@@ -79,23 +85,20 @@ export function CoachDrawerBody({
onClick={onOpenHistoryTray}
data-slot="coach-drawer-history-tray-trigger"
aria-label={t("insights.coach.historyTitle")}
className="min-h-11 gap-1.5 text-xs lg:hidden"
className="min-h-11 gap-1.5 text-xs"
>
<ChevronRight className="size-4" aria-hidden="true" />
<MessagesSquare className="size-4" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</Button>
{/* Sources tray trigger stays visible up to xl since the
inline sources rail is xl+ only. The empty `<span>` to
the left keeps the right-aligned button placement when
the history trigger hides at lg+. */}
<span aria-hidden="true" className="hidden lg:block" />
inline sources rail is xl+ only. */}
<Button
type="button"
variant="ghost"
onClick={onOpenSourcesTray}
data-slot="coach-drawer-sources-tray-trigger"
aria-label={t("insights.coach.sourcesTitle")}
className="min-h-11 gap-1.5 text-xs"
className="min-h-11 gap-1.5 text-xs xl:hidden"
>
{t("insights.coach.sourcesTitle")}
<ChevronLeft className="size-4" aria-hidden="true" />
@@ -103,11 +106,15 @@ export function CoachDrawerBody({
</div>
<div className="min-h-0 flex-1">{thread}</div>
{/* Composer pinned to the bottom. */}
{/* Composer pinned to the bottom. v1.12.0 — the clinical-
decisions disclaimer renders directly above the composer
(the single, always-visible home; the thread-bottom and
sources-rail copies were removed). */}
<div
data-slot="coach-drawer-composer"
className="border-border/70 border-t p-3 sm:p-4"
className="border-border/70 flex flex-col gap-2 border-t p-3 sm:p-4"
>
{disclaimer}
{composer}
</div>
</main>
@@ -1,8 +1,8 @@
"use client";
import { useCallback, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import { Plus, Settings, Sparkles, X } from "lucide-react";
import { useCallback, useRef } from "react";
import { useRouter } from "next/navigation";
import { Maximize2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -10,117 +10,48 @@ import {
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { CoachDrawerBody } from "./coach-drawer-body";
import { CoachInput } from "./coach-input";
import { CoachSettingsSheet } from "./coach-settings-sheet";
import { HistoryRail } from "./history-rail";
import { MessageThread } from "./message-thread";
import { MobileRailTray } from "./mobile-rail-tray";
import { SourcesRail } from "./sources-rail";
import { useCoachConversation, useSendCoachMessage } from "./use-coach";
import { CoachConversation } from "./coach-conversation";
// v1.12.0 — `useResettableValue` + `nextResettableValue` moved into
// `./use-resettable-value` so the shared `<CoachConversation>` surface
// can seed its composer without importing the drawer shell. Re-exported
// here for the existing prefill-controller unit test import path.
export {
useResettableValue,
nextResettableValue,
} from "./use-resettable-value";
/**
* v1.4.20 phase B2b — AI Coach drawer (right-side `<Sheet>` overlay).
*
* Mounts above `/insights` so the user keeps the dashboard context
* behind it. Single-column on `<lg`; on desktop the history rail and
* sources rail mount alongside the message thread (delivered in
* commit 3, this file lays out the slots).
*
* The drawer is fully-controlled by the parent (`open` /
* behind it. The drawer is fully-controlled by the parent (`open` /
* `onOpenChange`). The `prefill` prop lets the hero strip's
* suggested-prompt chips pre-populate the input on open.
*
* History rail + sources rail import-load asynchronously to keep the
* drawer's first-paint cheap. Until commit 3 lands, those slots
* render as visual placeholders that match the column widths.
* v1.12.0 (Coach v2 #6) — the chat surface itself (header actions,
* body, thread, composer, rails, settings, mobile trays) is now the
* shared `<CoachConversation>` component, reused verbatim by the
* full-page Coach route (`/insights/coach`). This file is the drawer
* CHROME only: the `<Sheet>` wrapper, the close button, and the
* maximize control that hands the conversation off to the full page.
*/
export interface CoachDrawerProps {
open: boolean;
onOpenChange: (next: boolean) => void;
/** Optional pre-fill for the input box (suggested-prompt chip click). */
prefill?: string | null;
/**
* Optional rail slots — passed by the parent so commit-3 can mount
* the history + sources rails without coupling them to the drawer
* shell. Falls through to a no-op when omitted.
*/
historyRail?: React.ReactNode;
sourcesRail?: React.ReactNode;
/**
* Composer slot — the parent wires `<CoachInput>` here in commit 3
* so this shell stays presentational. The shell renders its own
* minimal input fallback when no slot is supplied (used during the
* commit-2 boundary to keep the drawer functional in isolation).
*/
composer?: React.ReactNode;
}
/**
* v1.4.23 H3 — local state seeded from a controlled prop, reset on
* prop change. Same render-phase pattern React's docs recommend in
* place of `useEffect(() => setState(prop), [prop])` (which the
* `react-hooks/set-state-in-effect` ESLint rule banned). The reset
* runs during render: React detects the queued setState, restarts
* the render with the new value, and commits a single coherent
* snapshot — no double paint, no flash of stale state.
*
* Exported so the drawer's prefill-reset behaviour can be unit-
* tested in isolation without standing up the whole Sheet portal.
*/
export function useResettableValue<T>(
controlledValue: T,
): [T, Dispatch<SetStateAction<T>>] {
const [value, setValue] = useState<T>(controlledValue);
// Mirror the last observed controlled value via a sibling useState
// pair (per React docs:
// https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes).
// useRef is the wrong tool here — ESLint rejects ref read+write
// during render, and useState already gives us identity tracking
// with no extra cost.
const [lastSeen, setLastSeen] = useState<T>(controlledValue);
if (!Object.is(controlledValue, lastSeen)) {
setLastSeen(controlledValue);
setValue(controlledValue);
}
return [value, setValue];
}
/**
* v1.4.23 H3 — pure decision function behind `useResettableValue`.
* Given the previous controlled value the hook recorded and the
* incoming controlled value, returns either `{ reset: true, value }`
* (the next render must seed local state with `value`) or
* `{ reset: false }` (local state survives — the user's edits are
* preserved). Pure + dependency-free so it tests cleanly without a
* React renderer — pin the contract here and trust the hook
* implementation to wire the same comparison.
*/
export function nextResettableValue<T>(
previous: T,
incoming: T,
): { reset: true; value: T } | { reset: false } {
return Object.is(previous, incoming)
? { reset: false }
: { reset: true, value: incoming };
}
export function CoachDrawer({
open,
onOpenChange,
prefill,
historyRail,
sourcesRail,
composer,
}: CoachDrawerProps) {
export function CoachDrawer({ open, onOpenChange, prefill }: CoachDrawerProps) {
const { t } = useTranslations();
const router = useRouter();
// v1.4.27 R3d MB1 — below the `sm` breakpoint (640 px) the Coach
// drawer slides up from the bottom edge of the viewport instead of
// sliding in from the right. Right-side slide makes the back-arrow
@@ -129,86 +60,33 @@ export function CoachDrawer({
// its existing right-side slide and `sm:max-w-[720px]` cap.
const isPhoneViewport = useIsMobile("sm");
const [currentConversationId, setCurrentConversationId] = useState<
string | null
>(null);
// v1.4.20 phase B4 — mobile rail trays. The history + sources rails
// are hidden on `<lg` from B2b; the chevron buttons below toggle them
// as side-sheets so the user can browse conversations + see what
// data the Coach is using without losing the message thread context.
const [historyTrayOpen, setHistoryTrayOpen] = useState(false);
const [sourcesTrayOpen, setSourcesTrayOpen] = useState(false);
// v1.4.23 H4 — Coach prompt-tuning sheet. The v1.4.22 B5 audit had
// removed the placeholder cog from the drawer header; v1.4.23 H4
// returns it with a real surface backed by `/api/auth/me/coach-prefs`.
const [settingsOpen, setSettingsOpen] = useState(false);
// v1.4.23 H3 — `prefill` is now a fully-controlled prop. The
// v1.4.20 implementation used `key={prefill}` on the parent mount
// to force a fresh `useState()` initialiser run on every prefill
// transition (the "weaponise-React-keys" pattern senior-dev
// review flagged as Sr-HIGH-4). The replacement is a tiny
// `useResettableValue` hook below: tracks the last observed
// prefill in a ref and schedules a same-render state update when
// the prop changes — a textbook React render-phase update,
// ESLint-clean against `react-hooks/set-state-in-effect`. The
// user can still type freely; the drawer only resets the composer
// when the parent changes which suggested-prompt chip is active.
const [inputValue, setInputValue] = useResettableValue(prefill ?? "");
// v1.7.2 — the data-source scope is no longer ephemeral drawer state.
// Both the chat-side sources rail and the settings cog now drive the
// persisted `coachPrefsJson` row (clusters + default window) through
// `PUT /api/auth/me/coach-prefs`. The chat request carries no `scope`
// override at all, so the snapshot builder expands the saved clusters
// and folds the saved window server-side — "what the rail shows" and
// "what the model receives" cannot drift.
const { data: conversation } = useCoachConversation(currentConversationId);
const send = useSendCoachMessage({
onDone: (resolvedId) => {
setCurrentConversationId(resolvedId);
},
});
// The shared surface hands us an imperative reset so closing the
// drawer can abort an in-flight SSE stream + clear the thread.
const resetRef = useRef<(() => void) | null>(null);
const registerReset = useCallback((reset: () => void) => {
resetRef.current = reset;
}, []);
const handleOpenChange = useCallback(
(next: boolean) => {
if (!next) {
// Abort any in-flight streamed reply so closing the drawer
// doesn't leave the SSE request running in the background.
send.cancel();
// Reset thread + composer on close so the next open starts on
// the rail's empty hint instead of re-rendering the previous
// conversation by accident.
setCurrentConversationId(null);
setInputValue("");
// Abort any in-flight streamed reply + reset the thread so the
// next open starts on the rail's empty hint.
resetRef.current?.();
}
onOpenChange(next);
},
[onOpenChange, send, setInputValue],
[onOpenChange],
);
async function handleSubmit(value: string) {
const trimmed = value.trim();
if (!trimmed || send.isStreaming) return;
setInputValue("");
// v1.7.2 — no `scope` override on the wire. The route resolves the
// source set and window from the user's persisted `coachPrefsJson`
// (the same row the sources rail + settings cog edit), so the chat
// request stays minimal and the prompt scope is guaranteed to match
// what the rail displays.
await send.send({
conversationId: currentConversationId ?? undefined,
message: trimmed,
});
}
function handleNewChat() {
setCurrentConversationId(null);
setInputValue("");
send.reset();
}
const drawerTitle = conversation?.title ?? t("insights.coach.newChat");
const handleMaximize = useCallback(() => {
// Hand the conversation off to the full-page surface. Close the
// drawer first (aborts any in-flight stream via `handleOpenChange`)
// then route to the dedicated page.
onOpenChange(false);
resetRef.current?.();
router.push("/insights/coach");
}, [onOpenChange, router]);
return (
<Sheet open={open} onOpenChange={handleOpenChange}>
@@ -218,189 +96,74 @@ export function CoachDrawer({
// above `sm` it keeps its existing right-side slide.
side={isPhoneViewport ? "bottom" : "right"}
// v1.4.25 W5 — render our own Close button inside the header so
// X / cog / new-chat sit on the same baseline with identical
// size, color, and hit target. The Sheet's default close-X is
// absolutely positioned with `opacity-70` + tiny `rounded-xs`,
// which Marc flagged as misaligned with the rest of the header
// cluster.
// X / maximize sit on the same baseline with identical size,
// color, and hit target.
showCloseButton={false}
data-slot="coach-drawer"
data-variant={isPhoneViewport ? "bottom-sheet" : "side-sheet"}
className={cn(
// Drawer keeps the dashboard context behind it. On laptops
// (1280-1366px viewports) the previous lg:max-w-[1080px] cap
// left only ~200-286px of underlying /insights visible — a
// takeover, not a sheet. Drop the lg cap to min(960px,75vw)
// so /insights always retains a readable column; restore
// the wider three-column layout at xl+ (≥1280px sources rail
// is hidden below xl by the body's lg:hidden chevron rules).
// Drawer keeps the dashboard context behind it. Drop the lg
// cap to min(960px,75vw) so /insights always retains a
// readable column; restore the wider layout at xl+.
"w-full p-0 sm:max-w-[720px]",
"lg:!max-w-[min(960px,75vw)] xl:!max-w-[1080px]",
isPhoneViewport
? // Bottom-sheet caps at 90 dvh so a 10 % slice of the
// underlying /insights page stays visible — same
// convention as every other mobile sheet on the app via
// <ResponsiveSheet>'s phone branch (v1.4.28 R3c BK-M5
// alignment; previously 95 dvh, which left only a 5 %
// sliver and read as a takeover instead of a sheet).
// Rounded top corners match the iOS bottom-sheet feel.
"flex h-[90dvh] max-h-[90dvh] flex-col gap-0 rounded-t-2xl"
? "flex h-[90dvh] max-h-[90dvh] flex-col gap-0 rounded-t-2xl"
: "flex h-[100dvh] flex-col gap-0",
)}
>
{/* Header (full width). Avatar + title + new-chat button +
settings cog + close X. v1.4.25 W5 — the three header
actions (new chat, settings, close) all use the same
`ghost / size-icon / size-9` shape so they share a single
visual cluster instead of feeling like three different
controls. The close X used to be the Sheet's absolutely
positioned default; bringing it inline normalises the row
and frees the `pr-*` reservation. */}
<SheetHeader
data-slot="coach-drawer-header"
className="border-border/70 flex-row items-center gap-2 border-b p-3 sm:gap-3 sm:p-4"
>
<div
aria-hidden="true"
className="from-dracula-purple to-dracula-pink flex size-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
>
<Sparkles className="text-background size-4" />
</div>
<div className="min-w-0 flex-1">
{/* v1.4.27 R3d MB4 / CF-73 — pin `min-w-0` on the title
node itself in addition to the wrapper so the
truncate clipping survives any flex-shrink quirk
with very long conversation titles. */}
<CoachConversation
surface="drawer"
prefill={prefill}
autoFocusComposer
registerReset={registerReset}
// Radix requires an accessible name + description on the
// dialog content. Mount them through the title/description
// render-props so the page can swap in a plain heading.
renderTitle={(title) => (
<SheetTitle className="min-w-0 truncate text-sm font-semibold">
{drawerTitle}
{title}
</SheetTitle>
)}
renderDescription={(tagline) => (
<SheetDescription className="text-muted-foreground truncate text-[11px]">
{t("insights.coach.tagline")}
{tagline}
</SheetDescription>
</div>
{/* v1.7.2 — the per-conversation window override pill was
retired. The analysis window now persists alongside the
data-source clusters via the sources rail (and the
settings cog), so a single, sticky window control replaces
the ephemeral header override. */}
{/* v1.4.25 W5 — header action cluster. All three buttons
share the same `ghost / size-icon / size-9` shape so they
visually belong together. 36×36 px hit target meets the
WCAG 2.1 AA touch-target minimum on mobile; the icons
themselves stay 18 px to match the avatar's optical
weight. */}
<Button
type="button"
variant="ghost"
size="icon"
onClick={handleNewChat}
data-slot="coach-drawer-new-chat"
aria-label={t("insights.coach.newChat")}
title={t("insights.coach.newChat")}
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
>
<Plus className="size-4" aria-hidden="true" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setSettingsOpen(true)}
data-slot="coach-drawer-settings"
aria-label={t("insights.coach.settingsAriaLabel")}
title={t("insights.coach.settingsAriaLabel")}
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
>
<Settings className="size-4" aria-hidden="true" />
</Button>
<SheetClose asChild>
)}
// Maximize control at the leading edge — hands the
// conversation off to the full-page Coach route.
leadingHeaderActions={
<Button
type="button"
variant="ghost"
size="icon"
data-slot="coach-drawer-close"
aria-label={t("common.close")}
title={t("common.close")}
onClick={handleMaximize}
data-slot="coach-drawer-maximize"
aria-label={t("insights.coach.maximizeAriaLabel")}
title={t("insights.coach.maximizeAriaLabel")}
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
>
<X className="size-4" aria-hidden="true" />
<Maximize2 className="size-4" aria-hidden="true" />
</Button>
</SheetClose>
</SheetHeader>
{/* Body — three columns on lg+, single column on smaller.
The body slot is a separate component so the SSR test
harness can pin the mobile rail-tray triggers without
rendering the Sheet portal. */}
<CoachDrawerBody
historyRail={
historyRail ?? (
<HistoryRail
activeId={currentConversationId}
onSelect={(id) => setCurrentConversationId(id)}
/>
)
}
sourcesRail={sourcesRail ?? <SourcesRail />}
thread={
<MessageThread
conversation={conversation ?? null}
streaming={send.streaming}
optimisticUser={send.optimisticUser}
/>
// Close button — the drawer's own, replacing the Sheet's
// absolutely-positioned default so the action cluster shares
// one baseline.
trailingHeaderActions={
<SheetClose asChild>
<Button
type="button"
variant="ghost"
size="icon"
data-slot="coach-drawer-close"
aria-label={t("common.close")}
title={t("common.close")}
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
>
<X className="size-4" aria-hidden="true" />
</Button>
</SheetClose>
}
composer={
composer ?? (
<CoachInput
value={inputValue}
onChange={setInputValue}
onSubmit={() => handleSubmit(inputValue)}
onCancel={send.cancel}
disabled={send.isStreaming}
isStreaming={send.isStreaming}
// v1.4.27 MB3 / CF-30 — the composer mounts on drawer
// open and unmounts on close (the body collapses with
// the sheet), so a one-shot mount focus matches the
// "drawer just opened" semantic without any re-render
// gymnastics.
autoFocusOnOpen
/>
)
}
onOpenHistoryTray={() => setHistoryTrayOpen(true)}
onOpenSourcesTray={() => setSourcesTrayOpen(true)}
/>
{/* v1.4.23 H4 — Coach prompt-tuning sheet. Right-edge sheet so
it doesn't conflict with the existing rail trays (those are
<lg / <xl only; the settings sheet works on every
viewport). */}
<CoachSettingsSheet
open={settingsOpen}
onOpenChange={setSettingsOpen}
/>
{/* v1.4.28 R3c (BK-F-M6) — mobile-only rail trays carved out of
the drawer shell. Each tray surfaces the same HistoryRail /
SourcesRail instance the desktop layout uses; the parent
still owns the open/closed state + the rail callbacks so
the carve-out is pure render. */}
<MobileRailTray
historyOpen={historyTrayOpen}
onHistoryOpenChange={setHistoryTrayOpen}
historyRail={
historyRail ?? (
<HistoryRail
activeId={currentConversationId}
onSelect={(id) => {
setCurrentConversationId(id);
setHistoryTrayOpen(false);
}}
/>
)
}
sourcesOpen={sourcesTrayOpen}
onSourcesOpenChange={setSourcesTrayOpen}
sourcesRail={sourcesRail ?? <SourcesRail />}
/>
</SheetContent>
</Sheet>
@@ -306,21 +306,6 @@ export function MessageThread({
/>
</div>
)}
{/* v1.4.22 W5 reconcile (Design-H3) — medical-disclaimer
reach regression. v1.4.22 B4 moved the disclaimer to the
sources-rail footer, but the rail is xl-only; on mobile
and laptop viewports the disclaimer was only reachable
via the chevron tray. Pin a small persistent line at the
bottom of the message thread so every Coach session
carries the disclaimer regardless of viewport. The rail
footer stays for desktop users — the redundancy is
intentional for clinical-adjacent UI. */}
<p
data-slot="coach-thread-disclaimer"
className="text-muted-foreground mt-auto pt-2 text-xs leading-relaxed"
>
{t("insights.coach.composerDisclaimer")}
</p>
</div>
);
}
@@ -427,6 +412,16 @@ function ChatBubble({
: null;
const keyValues = metricSource?.keyValues ?? [];
// v1.12.0 — the provenance disclosure surfaces whenever there is any
// grounding to show: the source chips (metrics/windows) and/or the
// raw key-values. `SourceChips` itself returns null when the envelope
// carries neither metric nor window, so we mirror that condition here
// to decide whether the `<details>` shell renders at all.
const hasChips =
!!metricSource &&
((metricSource.metrics?.length ?? 0) > 0 ||
(metricSource.windows?.length ?? 0) > 0);
const hasProvenance = hasChips || keyValues.length > 0;
return (
<div
@@ -467,8 +462,17 @@ function ChatBubble({
{safeError && (
<p className="text-dracula-orange/90 text-xs">{safeError}</p>
)}
{metricSource && <SourceChips provenance={metricSource} />}
{keyValues.length > 0 && (
{/* v1.12.0 — collapse the whole provenance block ("what was
included") behind one disclosure, collapsed by default. The
source chips used to render fully expanded above the
evidence `<details>`, so the grounding context was often
taller than the answer itself. Folding the chips + the raw
key-values into a single closed disclosure keeps the reply
the focus and lets the user expand the grounding on demand —
and removes the always-on chip row that duplicated what the
disclosure already names. The disclosure surfaces whenever
there is any provenance to show (chips and/or key-values). */}
{hasProvenance && (
<details
data-slot="coach-evidence"
open={evidenceOpen}
@@ -507,36 +511,50 @@ function ChatBubble({
/>
<span>{t("insights.coach.evidenceLabel")}</span>
</summary>
<ul
<div
id={evidencePanelId}
data-slot="coach-evidence-list"
className="text-foreground mt-2 flex flex-col gap-1"
data-slot="coach-evidence-panel"
className="mt-2 flex flex-col gap-2"
>
{keyValues.map((kv, idx) => (
<li
key={`${kv.label}-${idx}`}
data-slot="coach-evidence-row"
className="leading-relaxed"
{/* v1.12.0 — the source chips now live inside the
disclosure so they expand with the rest of the
grounding instead of always painting above the
answer. */}
{hasChips && metricSource && (
<SourceChips provenance={metricSource} />
)}
{keyValues.length > 0 && (
<ul
data-slot="coach-evidence-list"
className="text-foreground flex flex-col gap-1"
>
{/* v1.4.25 W5 — `kv.label` (e.g. "avg7 systolic")
was rendered prefixed to every row, repeating
framing the disclosure heading already gives.
Drop the label and lead with the value; the
window stays as a parenthetical tail so the row
still answers "over what timeframe?". */}
<strong className="font-semibold">
{kv.value}
{kv.unit ? ` ${kv.unit}` : ""}
</strong>
{kv.window && (
<span className="text-muted-foreground">
{" "}
({kv.window})
</span>
)}
</li>
))}
</ul>
{keyValues.map((kv, idx) => (
<li
key={`${kv.label}-${idx}`}
data-slot="coach-evidence-row"
className="leading-relaxed"
>
{/* v1.4.25 W5 — `kv.label` (e.g. "avg7 systolic")
was rendered prefixed to every row, repeating
framing the disclosure heading already gives.
Drop the label and lead with the value; the
window stays as a parenthetical tail so the row
still answers "over what timeframe?". */}
<strong className="font-semibold">
{kv.value}
{kv.unit ? ` ${kv.unit}` : ""}
</strong>
{kv.window && (
<span className="text-muted-foreground">
{" "}
({kv.window})
</span>
)}
</li>
))}
</ul>
)}
</div>
</details>
)}
{/* v1.4.23 H7 — per-message thumbs feedback. Only persisted
@@ -50,13 +50,15 @@ export function MobileRailTray({
const { t } = useTranslations();
return (
<>
{/* History tray — slides in from the left edge. `lg:hidden`
because the history rail is inline on lg+. */}
{/* History tray — slides in from the left edge. v1.12.0 — the
history rail is no longer inline on any viewport (it used to
mount inline on lg+), so the tray is available at every
breakpoint and the `lg:hidden` cap is gone. */}
<Sheet open={historyOpen} onOpenChange={onHistoryOpenChange}>
<SheetContent
side="left"
data-slot="coach-drawer-history-tray"
className="w-[88vw] max-w-[320px] p-0 lg:hidden"
className="w-[88vw] max-w-[320px] p-0"
>
<SheetHeader className="border-border/70 border-b p-3">
<SheetTitle className="text-sm">
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Activity, Eye, Loader2 } from "lucide-react";
import { Eye, Loader2 } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import {
@@ -195,21 +195,11 @@ export function SourcesRail({ className }: SourcesRailProps) {
);
})}
</ul>
{/* Medical disclaimer footer (relocated from the composer in
v1.4.22). */}
<div
data-slot="coach-sources-disclaimer"
className="border-border/50 mt-auto flex items-start gap-2 border-t pt-3"
>
<Activity
aria-hidden="true"
className="text-muted-foreground mt-0.5 size-3.5 shrink-0"
/>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("insights.coach.composerDisclaimer")}
</p>
</div>
{/* v1.12.0 — the clinical-decisions disclaimer used to render
here AND at the bottom of the message thread. Both are now
consolidated into a single line directly above the composer
(always visible, every viewport), so the rail no longer
carries its own copy. */}
</div>
);
}
@@ -0,0 +1,58 @@
"use client";
import { useState } from "react";
import type { Dispatch, SetStateAction } from "react";
/**
* v1.4.23 H3 — local state seeded from a controlled prop, reset on
* prop change. Same render-phase pattern React's docs recommend in
* place of `useEffect(() => setState(prop), [prop])` (which the
* `react-hooks/set-state-in-effect` ESLint rule banned). The reset
* runs during render: React detects the queued setState, restarts
* the render with the new value, and commits a single coherent
* snapshot — no double paint, no flash of stale state.
*
* v1.12.0 — lifted out of `coach-drawer.tsx` into its own module so the
* shared `<CoachConversation>` surface (drawer + full-page) can seed its
* composer from the same controlled-prefill contract without importing
* the drawer shell.
*
* Exported so the drawer's prefill-reset behaviour can be unit-tested in
* isolation without standing up the whole Sheet portal.
*/
export function useResettableValue<T>(
controlledValue: T,
): [T, Dispatch<SetStateAction<T>>] {
const [value, setValue] = useState<T>(controlledValue);
// Mirror the last observed controlled value via a sibling useState
// pair (per React docs:
// https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes).
// useRef is the wrong tool here — ESLint rejects ref read+write
// during render, and useState already gives us identity tracking
// with no extra cost.
const [lastSeen, setLastSeen] = useState<T>(controlledValue);
if (!Object.is(controlledValue, lastSeen)) {
setLastSeen(controlledValue);
setValue(controlledValue);
}
return [value, setValue];
}
/**
* v1.4.23 H3 — pure decision function behind `useResettableValue`.
* Given the previous controlled value the hook recorded and the
* incoming controlled value, returns either `{ reset: true, value }`
* (the next render must seed local state with `value`) or
* `{ reset: false }` (local state survives — the user's edits are
* preserved). Pure + dependency-free so it tests cleanly without a
* React renderer — pin the contract here and trust the hook
* implementation to wire the same comparison.
*/
export function nextResettableValue<T>(
previous: T,
incoming: T,
): { reset: true; value: T } | { reset: false } {
return Object.is(previous, incoming)
? { reset: false }
: { reset: true, value: incoming };
}
@@ -1,78 +0,0 @@
"use client";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import type { CorrelationResult } from "@/lib/insights/correlations";
import { CorrelationCard } from "./correlation-card";
/**
* v1.4.20 phase B3 — Correlation row.
*
* Renders the pre-defined hypothesis cards in a 2-up grid on `>=md` and
* a single column on `<md`. A single correlation-disclaimer footer sits
* below the row — the disclaimer applies to every card so we don't
* repeat it per-card.
*
* v1.4.22 A4 — empty-state cards are dropped instead of rendered. Any
* hypothesis whose `status !== "ok"` is filtered out so the layout
* collapses cleanly: 2 ok cards → 50/50, 1 ok card → 100 %, 0 ok cards
* → the row hides itself entirely (no header, no disclaimer). Up to
* v1.4.21 the `<CorrelationCard>` painted an EmptyState for below-
* threshold results, which left half-rows of greyed-out placeholders
* on a sparse account.
*/
interface CorrelationRowProps {
results: {
bpCompliance: CorrelationResult;
moodPulse: CorrelationResult;
weightWeekday: CorrelationResult;
};
}
export function CorrelationRow({ results }: CorrelationRowProps) {
const { t } = useTranslations();
// Drop insufficient-data tiles up-front. The grid below picks 1-col vs
// 2-col automatically so 1 ok card spans 100 % width on its own row.
const okResults = [
results.bpCompliance,
results.moodPulse,
results.weightWeekday,
].filter((r) => r.status === "ok");
if (okResults.length === 0) return null;
return (
<section
data-slot="correlation-row"
aria-label={t("insights.correlationRow.title")}
className="space-y-3"
>
<div className="flex items-baseline justify-between">
<h2 className="text-lg font-semibold">
{t("insights.correlationRow.title")}
</h2>
<p className="text-muted-foreground text-xs">
{t("insights.correlationRow.subtitle")}
</p>
</div>
<div
className={cn(
"grid grid-cols-1 gap-4",
okResults.length > 1 && "md:grid-cols-2",
)}
>
{okResults.map((result) => (
<CorrelationCard key={result.kind} result={result} />
))}
</div>
<p
data-slot="correlation-row-disclaimer"
className="text-muted-foreground text-xs italic"
>
{t("insights.correlationRow.disclaimer")}
</p>
</section>
);
}
+1 -1
View File
@@ -325,7 +325,7 @@ export function DailyBriefing({
{updatedAt && (
<p
data-slot="daily-briefing-updated"
className="text-muted-foreground border-border/60 border-t pt-3 text-xs"
className="text-muted-foreground border-border/60 border-t pt-3 text-right text-xs"
>
{t("insights.heroGenerated", {
time: formatRelativeTime(updatedAt, t),
@@ -47,6 +47,16 @@ import type { DerivedProvenance } from "@/lib/insights/derived/types";
const TRAJECTORY_METRIC = "TRAJECTORY";
/**
* Mirror of `TRAJECTORY_MIN_HISTORY_DAYS` (`src/lib/insights/derived/trajectory.ts`).
* Re-declared as a client-side literal rather than value-imported because the
* engine module pulls the rollup/baseline server graph in at its top level —
* importing the constant would drag that graph into the client bundle (the same
* reason `TrajectoryValue` is a type-only import). Keep this in lockstep with
* the engine; the unit test pins both at 14.
*/
const TRAJECTORY_MIN_HISTORY_DAYS = 14;
/** A single value-scale used to render stored values in the display unit. */
function scaled(value: number, scale: number): number {
return value * scale;
@@ -63,6 +73,14 @@ interface TrajectoryForecastCardProps {
color?: string;
/** Gate the underlying derived read (e.g. on the auth/empty flag). */
enabled?: boolean;
/**
* v1.12.0 — density by render context. Sub-pages pass `compact` so the
* card sheds the `min-h-48` floor + drops to a shorter chart and a tighter
* no-trend state (a too-tall "1 of 1" empty band was the maintainer flag).
* The Insights OVERVIEW grid omits it so the tile KEEPS its current size
* for grid symmetry — the default is the unchanged, roomier geometry.
*/
compact?: boolean;
className?: string;
}
@@ -96,16 +114,25 @@ function TrajectoryProvenance({
/** Card shell — uppercase label + provenance affordance + body children. */
function CardShell({
provenance,
compact = false,
children,
}: {
provenance?: DerivedProvenance;
compact?: boolean;
children: React.ReactNode;
}) {
const { t } = useTranslations();
return (
<div
data-slot="trajectory-forecast-card"
className="bg-card border-border flex min-h-48 w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6"
data-density={compact ? "compact" : "default"}
className={cn(
"bg-card border-border flex w-full min-w-0 flex-col gap-2 rounded-xl border",
// Overview keeps the roomy `min-h-48` + `p-4 md:p-6` geometry for grid
// symmetry; sub-pages drop the height floor and tighten the padding so
// the no-trend state stops opening a tall empty band.
compact ? "p-4" : "min-h-48 p-4 md:p-6",
)}
>
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground truncate text-xs font-medium tracking-wide uppercase">
@@ -119,18 +146,21 @@ function CardShell({
}
/** CLS-safe skeleton matching the resolved card geometry. */
function CardSkeleton() {
function CardSkeleton({ compact = false }: { compact?: boolean }) {
return (
<div
data-slot="trajectory-forecast-card-skeleton"
aria-hidden="true"
className="bg-card border-border flex min-h-48 w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6"
className={cn(
"bg-card border-border flex w-full min-w-0 flex-col gap-2 rounded-xl border",
compact ? "p-4" : "min-h-48 p-4 md:p-6",
)}
>
<div className="flex h-11 items-center justify-between gap-2">
<div className="bg-muted/40 h-3 w-32 rounded" />
<div className="bg-muted/40 h-5 w-5 rounded-full" />
</div>
<div className="bg-muted/40 h-24 w-full rounded" />
<div className={cn("bg-muted/40 w-full rounded", compact ? "h-12" : "h-24")} />
</div>
);
}
@@ -141,6 +171,7 @@ export function TrajectoryForecastCard({
valueScale = 1,
color = "var(--chart-1)",
enabled = true,
compact = false,
className,
}: TrajectoryForecastCardProps) {
const { t } = useTranslations();
@@ -177,7 +208,7 @@ export function TrajectoryForecastCard({
if (!data) {
return (
<div className={className}>
<CardSkeleton />
<CardSkeleton compact={compact} />
</div>
);
}
@@ -185,15 +216,38 @@ export function TrajectoryForecastCard({
// Below the fit / history / staleness floor — calm "no trend to project"
// state + coverage, never a weak line.
if (data.status === "insufficient" || !chart) {
// v1.12.0 — surface how much history is still missing before a projection
// can appear, drawn from the same coverage envelope the meter reads. The
// gating floor is distinct-in-window days, so the hint only shows when the
// shortfall is a history-depth one (the dominant insufficient cause); when
// the shortfall is fit quality (enough days, weak R²) the days are already
// there and the generic copy stands on its own.
const daysNeeded = Math.max(
0,
TRAJECTORY_MIN_HISTORY_DAYS - Math.floor(data.coverage.historyDays),
);
return (
<div className={className}>
<CardShell provenance={data.provenance}>
<CardShell provenance={data.provenance} compact={compact}>
<p
className="text-muted-foreground text-sm"
className={cn(
"text-muted-foreground",
compact ? "text-xs leading-snug" : "text-sm",
)}
data-slot="trajectory-insufficient"
>
{t("insights.derived.trajectory.insufficient")}
</p>
{daysNeeded > 0 ? (
<p
className="text-muted-foreground text-xs leading-snug"
data-slot="trajectory-days-needed"
>
{t("insights.derived.trajectory.daysNeeded", {
count: daysNeeded,
})}
</p>
) : null}
<CoverageMeter coverage={data.coverage} />
</CardShell>
</div>
@@ -218,7 +272,7 @@ export function TrajectoryForecastCard({
return (
<div className={className}>
<CardShell provenance={data.provenance}>
<CardShell provenance={data.provenance} compact={compact}>
<div className="flex items-start gap-2">
<DirectionIcon
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
@@ -245,7 +299,10 @@ export function TrajectoryForecastCard({
{/* The fan: a stacked transparent base + a shaded range Area, then
the dashed projected line. The shaded band visibly widens toward
the horizon — the uncertainty signal. */}
<div className="h-28 w-full" data-slot="trajectory-chart">
<div
className={cn("w-full", compact ? "h-20" : "h-28")}
data-slot="trajectory-chart"
>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={chart.points}
@@ -19,6 +19,8 @@ import { TrajectoryForecastCard } from "@/components/insights/derived/trajectory
import { isTrajectoryType } from "@/lib/insights/derived/registry";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { MetricPrimaryTile } from "@/components/insights/metric-primary-tile";
import { MetricLastMeasurementCard } from "@/components/insights/metric-last-measurement-card";
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
@@ -177,6 +179,9 @@ export function HealthKitMetricPage({
}
: rawSummary;
const lastSeenAt =
analytics?.lastSeenByType?.[measurementType]?.lastSeenAt ?? null;
const title = t(`${i18nPrefix}.title`);
const description = t(`${i18nPrefix}.description`);
@@ -211,6 +216,16 @@ export function HealthKitMetricPage({
title={title}
description={description}
explainerMetric={explainerMetric}
primary={
<>
<MetricPrimaryTile
summary={summary}
unit={yAxisUnit ?? unit}
slug={targetSummarySlug}
/>
<MetricLastMeasurementCard lastSeenAt={lastSeenAt} />
</>
}
statStrip={<MetricStatStrip summary={summary} unit={yAxisUnit ?? unit} />}
diversityNudge={
<MeasurementDiversityNudge
@@ -222,12 +237,6 @@ export function HealthKitMetricPage({
coachLaunch
showAllValuesType={measurementType}
>
{/* v1.9.0 — time-range pills + period-over-period delta, between the
stat strip and the chart. The selected range persists across metrics
via `useInsightsRangePref`.
v1.10.2 — extracted into the shared `<MetricRangeControls>` so the
bespoke metric sub-pages render the identical control. */}
<MetricRangeControls measurementType={measurementType} enabled={!isEmpty} />
<HealthChartDynamic
chartKey={chartKey}
types={[measurementType]}
@@ -240,16 +249,16 @@ export function HealthKitMetricPage({
userTimezone={user?.timezone}
valueScale={valueScale}
/>
{/* v1.12.0 — time-range pills + period-over-period delta, relocated
BELOW the chart (iOS-parity). The pills drive the period-over-period
delta read and persist across metrics via `useInsightsRangePref`;
the chart owns its own window via its in-card range tabs, so this
block sits as a clean bottom-of-card row rather than between the
stat strip and the chart. */}
<MetricRangeControls measurementType={measurementType} enabled={!isEmpty} />
{targetSummarySlug ? (
<MetricTargetSummary slug={targetSummarySlug} />
) : null}
{statusMetric ? (
<MetricStatusCard
metric={statusMetric}
icon={<Sparkles className="h-5 w-5" />}
enabled={!isEmpty}
/>
) : null}
{forecast && isTrajectoryType(measurementType) ? (
<TrajectoryForecastCard
type={measurementType}
@@ -257,6 +266,16 @@ export function HealthKitMetricPage({
valueScale={valueScale}
color={color}
enabled={!isEmpty}
compact
/>
) : null}
{/* v1.12.0 — Einschätzung is the last block on the canonical
metric-detail spine. */}
{statusMetric ? (
<MetricStatusCard
metric={statusMetric}
icon={<Sparkles className="h-5 w-5" />}
enabled={!isEmpty}
/>
) : null}
</SubPageShell>
@@ -0,0 +1,95 @@
"use client";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useTranslations } from "@/lib/i18n/context";
import type {
CorrelationKind,
CorrelationResult,
} from "@/lib/insights/correlations";
import { CorrelationCard } from "@/components/insights/correlation-card";
/**
* v1.12.0 — per-metric correlation card.
*
* The canonical home for the one cross-metric relationship a metric
* owns (the iOS canonical template relocates correlations off the
* overview onto the owning metric page):
*
* • Weight page → weight × weekday
* • Pulse page → mood × pulse
*
* It reads the already-computed `correlations` block off the thick
* `/api/analytics` slice — the same payload the Insights overview warms.
* On the common path (user lands on the overview first) this is a free
* cache read; on direct metric-page entry it pays one thick fetch, the
* same trade-off `<MetricTargetSummary>` already makes for the targets
* payload.
*
* Self-suppressing: renders nothing while the payload is in-flight, when
* the correlations feature is off, or when the owned hypothesis is below
* the statistical surfacing bar (`status !== "ok"`). The single
* `<CorrelationCard>` carries its own scatter + interpretation + source.
*/
interface AnalyticsWithCorrelations {
correlations?: Partial<Record<string, CorrelationResult>>;
}
/** Insights slug → the `correlations` payload key it owns. */
const SLUG_TO_CORRELATION: Record<string, string> = {
weight: "weightWeekday",
pulse: "moodPulse",
};
/** The `kind` the owned result must carry — guards a payload-key drift. */
const SLUG_TO_KIND: Record<string, CorrelationKind> = {
weight: "weight-weekday",
pulse: "mood-pulse",
};
interface MetricCorrelationCardProps {
/** Insights category slug, e.g. `"weight"`. */
slug: string;
}
export function MetricCorrelationCard({ slug }: MetricCorrelationCardProps) {
const { t } = useTranslations();
const flags = useFeatureFlags();
const key = SLUG_TO_CORRELATION[slug];
// Read the thick slice; correlations live only on the default payload.
// The query is gated by the hook on `isAuthenticated`, and disabled
// entirely when this slug owns no correlation so a target-less metric
// never fires the thick fetch.
const { data } = useAnalyticsQuery({ enabled: key != null ? undefined : false });
const analytics = data as AnalyticsWithCorrelations | undefined;
if (!flags.correlations || !key) return null;
const result = analytics?.correlations?.[key];
if (!result || result.status !== "ok") return null;
// Guard against a payload-shape drift: only paint when the kind the
// server returned matches the kind this slug claims to own.
if (result.kind !== SLUG_TO_KIND[slug]) return null;
return (
<section
data-slot="metric-correlation"
data-correlation-slug={slug}
aria-label={t("insights.correlationRow.title")}
className="space-y-2"
>
<h2 className="text-lg font-semibold">
{t("insights.correlationRow.title")}
</h2>
<CorrelationCard result={result} />
<p
data-slot="metric-correlation-disclaimer"
className="text-muted-foreground text-xs italic"
>
{t("insights.correlationRow.disclaimer")}
</p>
</section>
);
}
@@ -0,0 +1,65 @@
"use client";
import { Clock } from "lucide-react";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
/**
* v1.12.0 — canonical "Letzte Messung" card.
*
* The third block on the per-metric detail page (after the primary
* tile), mirroring the iOS `InsightsLastMeasurementCard`. It captions
* WHEN the most recent reading landed — the date plus a "vor X Tagen"
* recency line.
*
* It deliberately does NOT repeat the latest value: the headline value
* lives in `<MetricPrimaryTile>` only (the no-duplicate-info rule). This
* card owns the timing dimension; the primary tile owns the value.
*
* Self-suppressing: renders nothing when no `lastSeenAt` is available
* (a brand-new metric, or a metric whose freshness map has no entry),
* so a sparse page never carries an empty timing card.
*/
interface MetricLastMeasurementCardProps {
/**
* ISO timestamp of the latest reading for this metric, read from the
* analytics `lastSeenByType[type]?.lastSeenAt` slot. Null / undefined
* suppresses the card.
*/
lastSeenAt: string | null | undefined;
}
export function MetricLastMeasurementCard({
lastSeenAt,
}: MetricLastMeasurementCardProps) {
const { t } = useTranslations();
const fmt = useFormatters();
if (!lastSeenAt) return null;
const parsed = new Date(lastSeenAt);
if (Number.isNaN(parsed.getTime())) return null;
return (
<section
data-slot="metric-last-measurement"
className="bg-card border-border flex items-center gap-3 rounded-xl border px-4 py-3"
>
<div className="bg-muted text-muted-foreground flex size-9 shrink-0 items-center justify-center rounded-full">
<Clock className="size-4" aria-hidden="true" />
</div>
<div className="min-w-0 space-y-0.5">
<p className="text-muted-foreground text-[0.6875rem] font-medium tracking-[0.06em] uppercase">
{t("insights.subPage.lastMeasurement.label")}
</p>
<p className="text-sm font-medium">
{fmt.dateWithWeekday(parsed)}
<span className="text-muted-foreground ml-2 font-normal">
{formatRelativeTime(lastSeenAt, t)}
</span>
</p>
</div>
</section>
);
}
@@ -0,0 +1,184 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { DataSummary } from "@/lib/analytics/trends";
import { cn } from "@/lib/utils";
/**
* v1.12.0 — canonical metric primary tile.
*
* The first data block on every per-metric detail page, mirroring the
* iOS `InsightsPrimaryTile`:
*
* • the headline value — the latest reading (the single place on the
* page the headline number appears; the stat strip below carries
* min / max / median / mean, not the latest),
* • the 30-Tage-Durchschnitt (`summary.avg30`) as a secondary line,
* • an "Im Zielbereich" bar — the share of logged days in target over
* the last 30, shown ONLY when the metric carries a per-metric pct
* (a registered target band feeds `/api/insights/targets`).
*
* Self-suppressing: renders nothing without a summary or with a
* zero-count series (the page empty-state owns that surface). The
* in-target bar omits itself when the slug has no numeric target or the
* route flagged insufficient data, so a target-less metric (HRV, SpO₂,
* temperature, …) reads as a clean headline + 30-day average.
*
* Blood pressure does NOT use this tile — its richer BP target panel is
* its primary tile (the page omits this component and leads with
* `<MetricTargetSummary slug="blood-pressure" />`).
*/
interface TargetRange {
min: number;
max: number;
}
interface TargetItem {
type: string;
daysInRange30d: number;
daysLogged30d: number;
insufficientData: boolean;
range: TargetRange | null;
}
interface TargetsResponse {
targets: TargetItem[];
}
/**
* Insights slug → target `type`. Mirrors `metric-target-summary.tsx`'s
* map for the single-target metrics. Slugs absent from the map (HRV,
* SpO₂, temperature, …) carry no in-target pct, so the bar suppresses.
*/
const SLUG_TO_TARGET_TYPE: Record<string, string> = {
weight: "WEIGHT",
bmi: "BMI",
pulse: "PULSE",
sleep: "SLEEP_DURATION",
};
interface MetricPrimaryTileProps {
/**
* Analytics summary for the page's `MeasurementType`. Null is allowed
* so the parent doesn't gate on the network read finishing.
*/
summary: DataSummary | null;
/** Unit suffix rendered next to the value (e.g. `bpm`, `kg`). */
unit: string;
/**
* Insights category slug (e.g. `"weight"`). When the slug maps to a
* registered target, the tile reads the in-target pct from
* `/api/insights/targets` and paints the "Im Zielbereich" bar. Omit
* for metrics with no numeric target — the tile then shows headline +
* 30-day average only.
*/
slug?: string;
/** Decimal precision for the formatted values. Defaults to 1. */
fractionDigits?: number;
}
export function MetricPrimaryTile({
summary,
unit,
slug,
fractionDigits = 1,
}: MetricPrimaryTileProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const { isAuthenticated } = useAuth();
const targetType = slug ? SLUG_TO_TARGET_TYPE[slug] : undefined;
const { data: targets } = useQuery({
queryKey: queryKeys.insightsTargets(),
queryFn: async () => {
const res = await fetch("/api/insights/targets");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as TargetsResponse;
},
enabled: isAuthenticated && targetType != null,
});
// Nothing to show until the summary lands or for a metric with no
// readings — the page's empty-state covers the zero-data case.
if (!summary || summary.count <= 0) return null;
const headline =
summary.latest === null
? "—"
: `${fmt.number(summary.latest, fractionDigits)} ${unit}`;
const avg30 =
summary.avg30 === null
? null
: `${fmt.number(summary.avg30, fractionDigits)} ${unit}`;
// In-target share: fraction of logged days in the green band over the
// last 30, read verbatim from the targets route (never recomputed).
const target = targetType
? targets?.targets.find((entry) => entry.type === targetType)
: undefined;
const showShare =
target != null &&
target.range != null &&
!target.insufficientData &&
target.daysLogged30d > 0;
const sharePct = showShare
? Math.round((target.daysInRange30d / target.daysLogged30d) * 100)
: null;
return (
<section
data-slot="metric-primary-tile"
className="bg-card border-border space-y-3 rounded-xl border p-4"
>
<div className="space-y-0.5">
<p
data-slot="metric-primary-value"
className="text-3xl font-semibold tabular-nums"
>
{headline}
</p>
{avg30 ? (
<p className="text-muted-foreground text-sm">
{t("insights.subPage.primary.avg30Label")}: {avg30}
</p>
) : null}
</div>
{/* "Im Zielbereich" bar — share of the last 30 logged days inside
the target band. Self-suppresses for target-less metrics and on
insufficient data. */}
{sharePct != null ? (
<div data-slot="metric-primary-in-range" className="space-y-1">
<div className="flex items-baseline justify-between gap-2">
<p className="text-muted-foreground text-xs">
{t("insights.subPage.primary.inRangeLabel")}
</p>
<p className="text-sm font-medium tabular-nums">{sharePct}%</p>
</div>
<div
className="bg-muted h-2 w-full overflow-hidden rounded-full"
role="img"
aria-label={t("insights.subPage.primary.inRangeAria", {
pct: sharePct,
})}
>
<div
className={cn(
"h-full rounded-full transition-[width]",
sharePct >= 70 ? "bg-dracula-green" : "bg-dracula-orange",
)}
style={{ width: `${Math.min(100, Math.max(0, sharePct))}%` }}
/>
</div>
</div>
) : null}
</section>
);
}
@@ -298,20 +298,19 @@ function TargetReferencePanel({
const sourceLink = getTargetSourceLink(target);
// In-target share: fraction of logged days in the green band over the
// last 30. Suppressed when the route flagged insufficient data so the
// sub-page suppresses the share line.
const showShare = !target.insufficientData && target.daysLogged30d > 0;
const sharePct = showShare
? Math.round((target.daysInRange30d / target.daysLogged30d) * 100)
: null;
// 30-day average. BP stitches the diastolic average into the familiar
// `S/D` pair; everything else rounds to one decimal.
// v1.12.0 — the in-target share (% of logged days in band) and the
// 30-day average moved UP to `<MetricPrimaryTile>`, the canonical home
// for the headline + 30-day average + "Im Zielbereich" bar (the
// no-duplicate-info rule). This panel stays the band reference: the
// range string, the status pill, the positional range bar (where
// today's value sits inside the band — distinct from the % bar), the
// 7-day consistency strip, and the guideline source. Blood pressure is
// the exception: it has no `<MetricPrimaryTile>` (this richer panel IS
// its primary tile), so it keeps the stitched S/D 30-day average inline.
let averageLabel: string | null = null;
if (target.average30 != null) {
if (isBp && target.average30 != null) {
const avgValue =
isBp && bpDiastolic?.average30 != null
bpDiastolic?.average30 != null
? `${Math.round(target.average30)}/${Math.round(bpDiastolic.average30)}`
: String(Math.round(target.average30 * 10) / 10);
averageLabel = `${t("targets.average30d")} ${avgValue} ${unit}`;
@@ -383,15 +382,12 @@ function TargetReferencePanel({
</div>
) : null}
{/* Row 3: in-target share + 30-day average. */}
{sharePct != null || averageLabel ? (
{/* Row 3: 30-day average (blood pressure only — every other metric
surfaces its 30-day average in `<MetricPrimaryTile>` above the
chart, so repeating it here would duplicate the figure). */}
{averageLabel ? (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs">
{sharePct != null ? (
<span>
{t("insights.subPage.target.inTargetShare", { pct: sharePct })}
</span>
) : null}
{averageLabel ? <span>{averageLabel}</span> : null}
<span>{averageLabel}</span>
</div>
) : null}
@@ -50,6 +50,10 @@ import {
MoodBetterDays,
type MoodBetterDayFactor,
} from "./mood-better-days";
import {
MoodTagMetricCrosstab,
type MoodTagMetricCrosstabRow,
} from "./mood-tag-metric-crosstab";
import { MoodDiscoveredRelations } from "./mood-discovered-relations";
/**
@@ -84,6 +88,9 @@ interface MoodInsightsResponse {
structured: MoodTagInfluenceRow[];
};
betterDays?: MoodBetterDayFactor[];
// Optional only to tolerate a stale pre-v1.12.0 cached payload during a
// rollout; the live endpoint always populates it.
tagMetricCrosstab?: MoodTagMetricCrosstabRow[];
narratives: MoodNarrativeItem[];
correlations: {
sleep: MoodMetricCorrelationData;
@@ -166,6 +173,8 @@ export function MoodInsightsSections() {
const hasInfluence = influenceRows.length > 0;
const betterDays = data.betterDays ?? [];
const hasBetterDays = betterDays.length > 0;
const crosstabRows = data.tagMetricCrosstab ?? [];
const hasCrosstab = crosstabRows.length > 0;
return (
<div className="space-y-4">
@@ -236,6 +245,12 @@ export function MoodInsightsSections() {
</SectionCard>
)}
{hasCrosstab && (
<SectionCard title={t("insights.mood.crosstab.title")}>
<MoodTagMetricCrosstab rows={crosstabRows} />
</SectionCard>
)}
<div className="space-y-2">
<h3 className="text-base font-semibold">
{t("insights.mood.correlationsTitle")}
@@ -0,0 +1,158 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
import { moodTagIcon } from "@/components/mood/mood-tag-icons";
import { cn } from "@/lib/utils";
import type { MoodInfluenceConfidence } from "./mood-tag-influence";
/**
* v1.12.0 — tag × health-metric crosstab card.
*
* Daylio's "Activities & Mood" board, extended from mood to a health
* METRIC: for each structured mood tag, a metric's mean on tag-present vs
* tag-absent days, the delta, and a confidence chip. The math is
* pre-computed in `mood-aggregates.ts` (`computeTagMetricCrosstab`) — the
* same Welch t-test + day floors + Benjamini-Hochberg FDR the rest of the
* relations surface uses; only FDR-surviving rows reach here. Observational
* only: the "association, not cause" caption renders once below the list.
*/
export type MoodCrosstabDisplay = "hours" | "kcal" | "score";
export type MoodCrosstabMode = "sameDay" | "nextDay";
export interface MoodTagMetricCrosstabRow {
tag: string;
labelKey: string;
categoryKey: string;
icon: string | null;
metricKey: string;
display: MoodCrosstabDisplay;
mode: MoodCrosstabMode;
withDays: number;
withoutDays: number;
withAvg: number;
withoutAvg: number;
delta: number;
pValue: number;
qValue: number;
confidence: MoodInfluenceConfidence;
}
const METRIC_LABEL_KEY: Record<string, string> = {
activeEnergy: "insights.mood.crosstab.metricActiveEnergy",
sleepDuration: "insights.mood.crosstab.metricSleepDuration",
nextDayRecovery: "insights.mood.crosstab.metricNextDayRecovery",
};
const UNIT_KEY: Record<MoodCrosstabDisplay, string> = {
hours: "insights.mood.crosstab.unitHours",
kcal: "insights.mood.crosstab.unitKcal",
score: "insights.mood.crosstab.unitScore",
};
const CONFIDENCE_KEY: Record<MoodInfluenceConfidence, string> = {
low: "insights.mood.influence.confidenceLow",
medium: "insights.mood.influence.confidenceMedium",
high: "insights.mood.influence.confidenceHigh",
};
const CONFIDENCE_CLASS: Record<MoodInfluenceConfidence, string> = {
low: "bg-secondary text-muted-foreground",
medium: "bg-[color:var(--dracula-cyan)]/15 text-[color:var(--dracula-cyan)]",
high: "bg-[color:var(--dracula-green)]/15 text-[color:var(--dracula-green)]",
};
/** One decimal for hours/score, whole numbers for kcal. */
function fmt(value: number, display: MoodCrosstabDisplay): string {
return display === "kcal" ? Math.round(value).toString() : value.toFixed(1);
}
export function MoodTagMetricCrosstab({
rows,
}: {
rows: MoodTagMetricCrosstabRow[];
}) {
const { t } = useTranslations();
if (rows.length === 0) return null;
return (
<div data-slot="mood-tag-metric-crosstab">
<p className="text-muted-foreground mb-2 text-sm">
{t("insights.mood.crosstab.description")}
</p>
<ul className="divide-border divide-y">
{rows.map((row) => {
const Icon = moodTagIcon(row.icon);
const tagLabel = t(row.labelKey);
const metricLabel = t(
METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey,
);
const unit = t(UNIT_KEY[row.display]);
const up = row.delta >= 0;
const deltaText = `${up ? "+" : ""}${fmt(row.delta, row.display)} ${unit}`;
const deltaColor = up
? "var(--dracula-green)"
: "var(--dracula-red)";
return (
<li
key={`${row.metricKey}:${row.tag}`}
className="flex flex-col gap-1.5 py-2"
data-slot="mood-crosstab-row"
data-metric={row.metricKey}
data-direction={up ? "up" : "down"}
data-confidence={row.confidence}
>
<div className="flex items-center gap-2 text-sm">
{Icon && (
<Icon
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
)}
<span
className="text-foreground min-w-0 flex-1 truncate"
title={`${tagLabel} · ${metricLabel}`}
>
{t("insights.mood.crosstab.pairLabel", {
tag: tagLabel,
metric: metricLabel,
})}
</span>
<span
className="shrink-0 text-sm font-semibold tabular-nums"
style={{ color: deltaColor }}
>
{deltaText}
</span>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium",
CONFIDENCE_CLASS[row.confidence],
)}
>
{t(CONFIDENCE_KEY[row.confidence])}
</span>
</div>
<p className="text-muted-foreground text-xs">
{t(
row.mode === "nextDay"
? "insights.mood.crosstab.detailNextDay"
: "insights.mood.crosstab.detailSameDay",
{
withAvg: fmt(row.withAvg, row.display),
withoutAvg: fmt(row.withoutAvg, row.display),
unit,
withDays: row.withDays,
},
)}
</p>
</li>
);
})}
</ul>
<p className="text-muted-foreground mt-3 text-[11px]">
{t("insights.mood.crosstab.disclaimer")}
</p>
</div>
);
}
@@ -204,7 +204,7 @@ export function PeriodNarrativeCard({
)}
{narrative ? (
<p className="text-muted-foreground text-[11px]">
<p className="text-muted-foreground text-right text-[11px]">
{data?.revalidating
? t("insights.narrativeUpdating")
: t("insights.narrativeUpdated", {
+41 -7
View File
@@ -125,10 +125,40 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
});
}, [session.segments]);
const domain: [number, number] = [
new Date(session.start).getTime(),
new Date(session.end).getTime(),
];
const domain = useMemo<[number, number]>(
() => [
new Date(session.start).getTime(),
new Date(session.end).getTime(),
],
[session.start, session.end],
);
// v1.12.0 — explicit clock-aligned X-axis ticks. With `data={[]}` Recharts
// has no points to derive ticks from and would otherwise label the domain
// endpoints with raw bedtime/wake stamps (e.g. 23:14 / 06:02). Snap to whole
// clock hours and pick a 1–3 h step from the night's span so the axis reads
// 23:00 / 01:00 / 03:00 / 05:00 and stays mobile-legible (≈4–6 ticks).
const xTicks = useMemo(() => {
const [from, to] = domain;
const spanHours = (to - from) / 3_600_000;
if (!Number.isFinite(spanHours) || spanHours <= 0) return [];
// Aim for ~5 ticks: 1 h up to 5 h span, 2 h up to 10 h, else 3 h.
const stepHours = spanHours <= 5 ? 1 : spanHours <= 10 ? 2 : 3;
const stepMs = stepHours * 3_600_000;
// First whole hour at or after `from`, aligned to the step grid.
const firstHour = new Date(from);
firstHour.setMinutes(0, 0, 0);
if (firstHour.getTime() < from) firstHour.setTime(firstHour.getTime() + 3_600_000);
// Align the first tick to a multiple of the step (relative to midnight).
while ((firstHour.getHours() % stepHours) !== 0) {
firstHour.setTime(firstHour.getTime() + 3_600_000);
}
const ticks: number[] = [];
for (let t = firstHour.getTime(); t <= to; t += stepMs) {
ticks.push(t);
}
return ticks;
}, [domain]);
const timeFmt = useMemo(
() =>
@@ -167,8 +197,11 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
const hasBreakdown = breakdown.length > 0;
return (
<Card data-slot="sleep-hypnogram">
<CardHeader className="pb-2">
// v1.12.0 — card tightened: drop the default Card `gap-4 md:gap-6` to
// `gap-3` so the header sits closer to the timeline, reclaiming the empty
// band the maintainer flagged. The chart keeps its 200 px footprint.
<Card data-slot="sleep-hypnogram" className="gap-3 md:gap-3">
<CardHeader className="pb-0">
<div className="flex flex-col gap-0.5">
<CardTitle className="text-sm font-medium">
{t("insights.sleep.hypnogram.title")}
@@ -180,7 +213,7 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
</span>
</div>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-3">
{!hasTrack && !hasBreakdown ? (
<p
className="text-muted-foreground py-8 text-center text-xs"
@@ -207,6 +240,7 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
dataKey="t"
domain={domain}
scale="time"
ticks={xTicks}
stroke="var(--muted-foreground)"
fontSize={10}
tickFormatter={(v: number) => timeFmt.format(new Date(v))}
+7 -3
View File
@@ -130,8 +130,12 @@ export function SleepOverview() {
return (
<div className="space-y-4">
<Card>
<CardHeader className="pb-2">
{/* v1.12.0 — headline card tightened: the default Card
`gap-4 md:gap-6` + `py-4 md:py-6` left a tall empty band around a
two-line readout. `gap-2 py-4 md:py-4` pulls the caption up under the
number without crowding it. */}
<Card className="gap-2 py-4 md:gap-2 md:py-4">
<CardHeader className="pb-0">
<div className="flex items-center gap-2">
<Moon className="text-dracula-cyan h-4 w-4" />
<CardTitle className="text-sm font-medium">
@@ -140,7 +144,7 @@ export function SleepOverview() {
</div>
</CardHeader>
<CardContent>
<div className="space-y-1">
<div className="space-y-0.5">
<p className="text-2xl font-semibold">{primary}</p>
<p className="text-muted-foreground text-xs">
{sleepSummary?.count
@@ -86,6 +86,15 @@ export interface SubPageShellProps {
* min / max / median / mean. Omitted on the empty-state branch.
*/
statStrip?: ReactNode;
/**
* v1.12.0 — canonical "primary" zone rendered between the header and
* the stat strip, the top of the metric-detail spine: the primary tile
* (headline value + 30-Tage-Durchschnitt + "Im Zielbereich" bar) and
* the "Letzte Messung" card. Each child self-suppresses when its data
* is absent, so a sparse metric reads as a clean heading + chart. The
* mother page and empty-state branches omit it.
*/
primary?: ReactNode;
/**
* v1.8.5 W4b — measurement-diversity nudge.
*
@@ -126,6 +135,7 @@ export function SubPageShell({
explainerMetric,
focusOnMount = false,
statStrip,
primary,
diversityNudge,
coachLaunch = false,
showAllValuesType,
@@ -253,6 +263,11 @@ export function SubPageShell({
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
</header>
{/* v1.12.0 — the canonical "primary" zone leads the spine: the
primary tile (headline + 30-Tage-Durchschnitt + Im-Zielbereich
bar) and the "Letzte Messung" card, both above the stat strip.
Each child self-suppresses when its data is absent. */}
{primary}
{/* v1.8.5 W4b — numbers-first stat strip sits between the header
and the chart so the page leads with data, mirroring the Apple
Health / Withings detail layout. Self-gating (renders nothing
@@ -1,16 +1,18 @@
/**
* v1.4.16 Wave-C MED — bottom-nav 5+More layout.
* Bottom-nav iOS-parity layout (additive middle-path).
*
* SSR contract for `<BottomNav>`:
* - exactly five primary links + one "More" button render in the strip
* - the More button is keyboard-reachable as a `<button>` with
* `aria-haspopup="dialog"`
* - Achievements lives in the More sheet (closed by default under SSR),
* not in the always-visible strip
* - WCAG 2.5.5 floor: every entry is `min-h-11 min-w-11`
* - the mobile bar is Home · Meds · Log(center) · Insights · More
* - the center "Log" slot is a capture ACTION (a `<button>` with
* `aria-haspopup="dialog"`), not a navigation link
* - the More button opens a hub (closed by default under SSR)
* - the More hub is a real hub: Measurements + Mood (which left the
* always-visible strip) stay reachable here, alongside Workouts,
* Achievements, Notifications and Settings — nothing is orphaned
* - WCAG 2.5.5 floor: every strip entry is `min-h-11 min-w-11`
*
* v1.8.6 — the Targets (Zielwerte) page is deprecated and dropped from
* the menu, so it no longer appears in the strip or the overflow set.
* the menu, so it no longer appears in the strip or the hub.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -33,20 +35,23 @@ function render() {
);
}
describe("<BottomNav> 5+More layout", () => {
it("renders the five primary anchors", () => {
describe("<BottomNav> iOS-parity layout", () => {
it("renders the flanking primary anchors (Home, Meds, Insights)", () => {
const html = render();
for (const href of [
"/",
"/measurements",
"/mood",
"/medications",
"/insights",
]) {
for (const href of ["/", "/medications", "/insights"]) {
expect(html).toContain(`href="${href}"`);
}
});
it("renders the center capture action as a dialog-opening button, not a link", () => {
const html = render();
expect(html).toContain('data-testid="bottom-nav-capture"');
// The capture button opening tag carries aria-haspopup="dialog".
const capture = html.match(/<button[^>]*bottom-nav-capture[^>]*>/);
expect(capture).not.toBeNull();
expect(capture![0]).toMatch(/aria-haspopup="dialog"/);
});
it("renders an overflow More button via aria-haspopup", () => {
const html = render();
expect(html).toContain('data-testid="bottom-nav-more"');
@@ -54,28 +59,49 @@ describe("<BottomNav> 5+More layout", () => {
expect(html).toMatch(/aria-expanded="false"/);
});
it("each primary entry meets the 44 px tap-target floor (min-h-11 min-w-11)", () => {
it("does not render Measurements or Mood as always-visible strip links", () => {
// Measurements + Mood left the always-visible strip when the center
// capture action took the middle slot; they live in the More hub
// (rendered on open — exercised in e2e) plus the capture picker.
// The closed-hub SSR markup must therefore carry neither as a strip
// anchor, and the capture/More affordances replace them.
const html = render();
// Count `min-h-11 min-w-11` occurrences. Six is the expected floor
// (five anchors + the More button); we don't hard-pin because a
// future entry would also satisfy the contract.
expect(html).not.toContain('href="/measurements"');
expect(html).not.toContain('href="/mood"');
// The replacements that keep those surfaces reachable are present.
expect(html).toContain('data-testid="bottom-nav-capture"');
expect(html).toContain('data-testid="bottom-nav-more"');
});
it("each strip entry meets the 44 px tap-target floor (min-h-11 min-w-11)", () => {
const html = render();
// The four flanking strip slots (Home, Meds, Insights, More) carry
// `min-h-11 min-w-11`. We don't hard-pin the count because a future
// entry would also satisfy the contract.
const matches = html.match(/min-h-11 min-w-11/g) ?? [];
expect(matches.length).toBeGreaterThanOrEqual(6);
expect(matches.length).toBeGreaterThanOrEqual(4);
});
it("the center capture FAB is a larger, elevated tap target", () => {
const html = render();
// The FAB clears the 44px floor with a 56px (h-14 w-14) target and
// is lifted out of the bar (negative top margin + card ring + raised
// shadow) so it reads as the primary CTA, not a flush fifth tab.
const capture = html.match(/<button[^>]*bottom-nav-capture[^>]*>/);
expect(capture).not.toBeNull();
expect(capture![0]).toMatch(/h-14 w-14/);
expect(capture![0]).toMatch(/-mt-5/);
expect(capture![0]).toMatch(/ring-4/);
expect(capture![0]).toMatch(/shadow-lg/);
});
it("no longer lists the deprecated /targets entry (v1.8.6)", () => {
// Targets was dropped from the OVERFLOW set; the strip never carried
// it. The closed-sheet SSR render must therefore contain no /targets
// link — a regression net against the entry creeping back into the
// primary strip or the overflow set.
const html = render();
expect(html).not.toContain('href="/targets"');
});
it("More button carries an accessible label", () => {
const html = render();
// Attribute order isn't guaranteed by React's renderer, so match
// both possible directions instead of pinning the order.
const hasLabelFirst =
/aria-label="More"[^>]*data-testid="bottom-nav-more"/.test(html);
const hasTestidFirst =
+103 -67
View File
@@ -2,10 +2,14 @@
import {
Activity,
Bell,
Dumbbell,
Home,
Lightbulb,
MoreHorizontal,
Pill,
Plus,
Settings,
Trophy,
Waves,
} from "lucide-react";
@@ -19,6 +23,7 @@ import {
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { CapturePicker } from "@/components/layout/capture-picker";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
@@ -28,36 +33,48 @@ interface NavLink {
icon: typeof Home;
}
// v1.4.16 Wave-C MED — bottom-nav was rendering all 7 destinations
// edge-to-edge on a 375 px viewport, which collapsed each touch
// target to ~50 px wide and made the WCAG 2.5.5 44 px floor only
// just-pass on iPhone-13-mini-class screens. The mobile audit
// recommended 5+More — so the four most-used surfaces stay in the
// strip (Home, Measurements, Mood, Medications) plus Insights as
// the fifth, and Achievements moves into a "More" sheet.
// v1.12.x — iOS-parity bottom bar (additive middle-path).
//
// Order is preserved relative to the v1.4.15 bottom-nav so muscle
// memory keeps working for the four core actions.
// The mobile bar is a 5-slot shape: Home · Meds · Log(center) ·
// Insights · More — mirroring the native client. The center slot is a
// capture ACTION (not a destination): it opens a picker that routes to
// the existing measurement / medication / mood quick-entry surfaces
// (see `<CapturePicker>`).
//
// v1.9.0 — the standalone Targets (Zielwerte) page is retired. Target
// editing lives inline on the Insights metric pages (the per-metric
// reference panel mounts the target editor), so there is no nav entry.
const PRIMARY: ReadonlyArray<NavLink> = [
// PRIMARY_LEFT / PRIMARY_RIGHT are the two anchor pairs that flank the
// center action. The previous strip (Home · Measurements · Mood ·
// Medications · Insights) carried Measurements and Mood inline; those
// stay fully reachable — through the center capture picker AND the More
// hub below — so nothing is orphaned. On desktop the sidebar still
// lists every destination.
const PRIMARY_LEFT: ReadonlyArray<NavLink> = [
{ href: "/", tKey: "nav.dashboard", icon: Home },
{ href: "/measurements", tKey: "nav.measurements", icon: Activity },
{ href: "/mood", tKey: "nav.mood", icon: Waves },
{ href: "/medications", tKey: "nav.medications", icon: Pill },
];
const PRIMARY_RIGHT: ReadonlyArray<NavLink> = [
{ href: "/insights", tKey: "nav.insights", icon: Lightbulb },
];
const OVERFLOW: ReadonlyArray<NavLink> = [
// The "More" hub — a real hub of the remaining top-level destinations,
// not just an overflow bucket. Measurements and Mood live here (they
// left the always-visible strip when the center capture action took the
// middle slot), alongside Workouts, Achievements, Notifications and
// Settings. Every entry is an existing top-level route.
const MORE_HUB: ReadonlyArray<NavLink> = [
{ href: "/measurements", tKey: "nav.measurements", icon: Activity },
{ href: "/mood", tKey: "nav.mood", icon: Waves },
{ href: "/insights/workouts", tKey: "nav.workouts", icon: Dumbbell },
{ href: "/achievements", tKey: "nav.achievements", icon: Trophy },
{ href: "/notifications", tKey: "nav.notifications", icon: Bell },
{ href: "/settings/account", tKey: "nav.settings", icon: Settings },
];
export function BottomNav() {
const pathname = usePathname();
const { t } = useTranslations();
const [moreOpen, setMoreOpen] = useState(false);
const [captureOpen, setCaptureOpen] = useState(false);
function isActiveLink(href: string) {
return href === "/" ? pathname === "/" : pathname.startsWith(href);
@@ -65,84 +82,100 @@ export function BottomNav() {
// The "More" entry is treated as active when any of its children
// is the current page — gives the user a clue that they've drilled
// into a sub-route via overflow.
const overflowActive = OVERFLOW.some((item) => isActiveLink(item.href));
// into a sub-route via the hub.
const moreActive = MORE_HUB.some((item) => isActiveLink(item.href));
function renderPrimary(item: NavLink) {
const isActive = isActiveLink(item.href);
return (
<Link
key={item.href}
href={item.href}
aria-label={t(item.tKey)}
aria-current={isActive ? "page" : undefined}
// Touch target sized to WCAG 2.5.5 (44×44 CSS px). The
// outer min-h-11 min-w-11 is the actual hit area; the icon
// stays visually centered at 20px so the design doesn't shift.
className={cn(
"relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2",
isActive
? "text-primary"
: "text-muted-foreground hover:text-foreground",
)}
>
<item.icon className="h-5 w-5" />
{isActive && (
<span className="bg-primary absolute bottom-1.5 h-1 w-1 rounded-full" />
)}
</Link>
);
}
return (
<>
{/* v1.4.33 F14 — bar height and safe-area defence so the bar can
never overlap the last viewport line of scrollable page content.
- `min-h-16` floors the bar at 64px even if the inner flex row
collapses (e.g. a future variant without one of the icons).
- `pb-[env(safe-area-inset-bottom)]` reserves the home-indicator
inset on iOS / iPadOS PWA installs.
- `bg-card` (solid, not the previous `bg-card/80`) plus
`border-t` give the bar a clean visual edge so any page
content that scrolls under the bar is properly occluded
rather than bleeding through a translucent surface. Page-
level pb-* padding owners (auth-shell + per-page shells)
still reserve room above the bar; this change hardens the
bar itself so the visual contract holds even when a page
shell forgets the bottom-padding contract.
*/}
`min-h-16` floors the bar at 64px; `pb-[env(safe-area-inset-bottom)]`
reserves the home-indicator inset on iOS / iPadOS PWA installs;
`bg-card` + `border-t` give the bar a clean visual edge so page
content that scrolls under the bar is occluded, not bleeding
through. */}
<nav
aria-label={t("nav.mobileNavigation")}
className="bg-card border-border fixed bottom-0 left-0 z-50 min-h-16 w-full border-t pb-[env(safe-area-inset-bottom)] backdrop-blur-md md:hidden"
>
<div className="mx-auto flex h-16 max-w-lg items-stretch justify-around px-1">
{PRIMARY.map((item) => {
const isActive = isActiveLink(item.href);
return (
<Link
key={item.href}
href={item.href}
aria-label={t(item.tKey)}
aria-current={isActive ? "page" : undefined}
// Touch target sized to WCAG 2.5.5 (44×44 CSS px). The
// outer min-h-11 min-w-11 is the actual hit area; the icon
// stays visually centered at 20px so the design doesn't shift.
//
// v1.4.34 IW-G — keyboard users get a visible ring on
// focus-visible, matching the sidebar treatment from
// v1.4.33. Without it the bottom-nav had no focus
// indicator at all on Tab navigation.
className={cn(
"relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2",
isActive
? "text-primary"
: "text-muted-foreground hover:text-foreground",
)}
>
<item.icon className="h-5 w-5" />
{isActive && (
<span className="bg-primary absolute bottom-1.5 h-1 w-1 rounded-full" />
)}
</Link>
);
})}
{PRIMARY_LEFT.map(renderPrimary)}
{/* Center capture action — a labeled button (NOT a link) that
opens the capture picker. Elevated as a Floating-Action-
Button: it sits larger than the flanking tabs (h-14 = 56px,
well above the 44px tap-target floor), lifts above the bar
with a negative top margin, and carries a filled-primary
collar (a `bg-card` ring that punches it out of the bar) plus
a stronger shadow so it reads as the bar's primary CTA rather
than a fifth flush tab. */}
<div className="flex flex-1 items-center justify-center">
<button
type="button"
aria-label={t("nav.capture.title")}
aria-haspopup="dialog"
aria-expanded={captureOpen}
onClick={() => setCaptureOpen(true)}
data-testid="bottom-nav-capture"
className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring/70 ring-card -mt-5 flex h-14 w-14 items-center justify-center rounded-full shadow-lg ring-4 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Plus className="h-7 w-7" />
</button>
</div>
{PRIMARY_RIGHT.map(renderPrimary)}
<button
type="button"
aria-label={t("nav.more")}
aria-haspopup="dialog"
aria-expanded={moreOpen}
aria-current={overflowActive ? "page" : undefined}
aria-current={moreActive ? "page" : undefined}
onClick={() => setMoreOpen(true)}
className={cn(
"relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2",
overflowActive
moreActive
? "text-primary"
: "text-muted-foreground hover:text-foreground",
)}
data-testid="bottom-nav-more"
>
<MoreHorizontal className="h-5 w-5" />
{overflowActive && (
{moreActive && (
<span className="bg-primary absolute bottom-1.5 h-1 w-1 rounded-full" />
)}
</button>
</div>
</nav>
<CapturePicker open={captureOpen} onOpenChange={setCaptureOpen} />
<Sheet open={moreOpen} onOpenChange={setMoreOpen}>
<SheetContent
side="bottom"
@@ -156,8 +189,8 @@ export function BottomNav() {
<SheetTitle>{t("nav.moreSheetTitle")}</SheetTitle>
<SheetDescription>{t("nav.moreSheetDescription")}</SheetDescription>
</SheetHeader>
<div className="grid grid-cols-1 gap-2 px-4 pb-4">
{OVERFLOW.map((item) => {
<div className="grid grid-cols-2 gap-2 px-4 pb-4">
{MORE_HUB.map((item) => {
const isActive = isActiveLink(item.href);
return (
<Link
@@ -165,8 +198,11 @@ export function BottomNav() {
href={item.href}
onClick={() => setMoreOpen(false)}
aria-current={isActive ? "page" : undefined}
// v1.12 — `min-h-14` matches the capture-picker tiles so
// the equivalent tappable rows share one height at this
// tier (and the larger row is a more comfortable target).
className={cn(
"border-border flex min-h-11 items-center gap-3 rounded-lg border px-4 py-3 transition-colors",
"border-border flex min-h-14 items-center gap-3 rounded-lg border px-4 py-3 transition-colors",
isActive
? "text-primary bg-primary/5"
: "text-foreground hover:bg-accent/40",
+157
View File
@@ -0,0 +1,157 @@
"use client";
import { useState } from "react";
import { Activity, Pill, Waves } from "lucide-react";
import { MeasurementForm } from "@/components/measurements/measurement-form";
import { MoodForm } from "@/components/mood/mood-form";
import { MedicationIntakeQuickAdd } from "@/components/dashboard/medication-intake-quick-add";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useTranslations } from "@/lib/i18n/context";
/**
* The center "Log" capture action (iOS parity — the bottom bar's
* middle slot is a capture CTA, not a destination). It opens a small
* picker that routes to one of three existing quick-entry surfaces:
*
* - Measurement → `<MeasurementForm>` (the `/measurements` add form)
* - Medication → `<MedicationIntakeQuickAdd>` (the dashboard intake
* quick-add)
* - Mood → `<MoodForm>` (the `/mood` add form, 5-face flow)
*
* All three forms share the `{ onSuccess, onCancel, footerSlot }`
* contract and are designed to mount inside `<ResponsiveSheet>`, so the
* picker reuses them verbatim rather than rebuilding any capture UI.
*
* Nothing here orphans a route: the same surfaces remain reachable
* from their own pages (`/measurements`, `/mood`, dashboard) and from
* the More hub. The picker is an additional fast path, not a removal.
*/
type CaptureKind = "measurement" | "medication" | "mood";
interface CapturePickerProps {
/** Whether the picker chooser sheet is open. */
open: boolean;
/** Open-state setter for the chooser sheet. */
onOpenChange: (open: boolean) => void;
}
export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
const { t } = useTranslations();
const [kind, setKind] = useState<CaptureKind | null>(null);
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
function chooseKind(next: CaptureKind) {
// Close the chooser first, then open the form sheet so only one
// bottom-sheet is mounted at a time (no stacked backdrops).
onOpenChange(false);
setKind(next);
}
function closeForm() {
setKind(null);
}
const options: ReadonlyArray<{
kind: CaptureKind;
label: string;
description: string;
icon: typeof Activity;
}> = [
{
kind: "measurement",
label: t("nav.capture.measurement"),
description: t("nav.capture.measurementDescription"),
icon: Activity,
},
{
kind: "medication",
label: t("nav.capture.medication"),
description: t("nav.capture.medicationDescription"),
icon: Pill,
},
{
kind: "mood",
label: t("nav.capture.mood"),
description: t("nav.capture.moodDescription"),
icon: Waves,
},
];
const formTitleByKind: Record<CaptureKind, string> = {
measurement: t("measurements.addMeasurement"),
medication: t("nav.capture.medication"),
mood: t("mood.addEntry"),
};
// `kind === null` keeps the form sheet closed (the title is unread then);
// the mood label is the harmless default, matching the prior ternary's
// else branch.
const formTitle = kind ? formTitleByKind[kind] : t("mood.addEntry");
return (
<>
{/* The capture-kind chooser. */}
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t("nav.capture.title")}
description={t("nav.capture.description")}
>
<div className="grid grid-cols-1 gap-2" data-testid="capture-picker-options">
{options.map((opt) => (
<button
key={opt.kind}
type="button"
data-testid={`capture-picker-${opt.kind}`}
onClick={() => chooseKind(opt.kind)}
className="border-border hover:bg-accent/40 focus-visible:ring-ring/50 flex min-h-14 items-center gap-3 rounded-lg border px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2"
>
<span className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-full">
<opt.icon className="h-5 w-5" aria-hidden="true" />
</span>
<span className="min-w-0">
<span className="block text-sm font-medium">{opt.label}</span>
<span className="text-muted-foreground block text-xs">
{opt.description}
</span>
</span>
</button>
))}
</div>
</ResponsiveSheet>
{/* The chosen capture surface, reusing the existing form. */}
<ResponsiveSheet
open={kind !== null}
onOpenChange={(next) => {
if (!next) closeForm();
}}
title={formTitle}
footer={<div ref={setFooterEl} className="flex w-full" />}
>
{kind === "measurement" && (
<MeasurementForm
onSuccess={closeForm}
onCancel={closeForm}
footerSlot={footerEl}
/>
)}
{kind === "medication" && (
<MedicationIntakeQuickAdd
onSuccess={closeForm}
onCancel={closeForm}
footerSlot={footerEl}
/>
)}
{kind === "mood" && (
<MoodForm
onSuccess={closeForm}
onCancel={closeForm}
footerSlot={footerEl}
/>
)}
</ResponsiveSheet>
</>
);
}
@@ -13,19 +13,24 @@ import {
} from "@/lib/medications/injection-sites";
/**
* Injection-site picker — anatomical front-of-body silhouette with 8
* Injection-site picker — anatomical front-of-body figure with 8
* tappable sites.
*
* The silhouette is an INLINE SVG so it inherits the app's theme tokens
* (`currentColor` + CSS variables) and tints correctly in light/dark and
* across the zinc/Dracula palettes with no raster asset and no luminance
* key. The body is traced to the proportions the iOS client calibrated
* against (see `.planning/ios-coord/v0.12-…-injection-bodymap-coords.md`)
* so dots land on the same anatomy on both platforms.
* The body is a clean, gender-neutral medical line-art figure shipped as
* a small transparent raster (`/injection-body-front.png`, ~8 KB). It is
* painted THEME-AWARE without baking in a colour: the figure's alpha is
* used as an SVG mask (`mask-type="alpha"`) over a `<rect>` filled with
* `currentColor`, so the strokes take the picker's text colour and tint
* correctly in light/dark and across the zinc/Dracula palettes. Keeping
* the figure inside the SVG (rather than a CSS-masked sibling element)
* means it shares the exact `viewBox` coordinate space as the dots, so
* alignment is responsive by construction.
*
* `viewBox="0 0 100 200"` (aspect ≈0.5). `SITE_COORDS` is expressed on
* the same viewBox; the recommended-next ring, the last-used marker and
* the selected fill all read directly from those coordinates.
* `viewBox="0 0 100 200"` (aspect ≈0.5). The figure is drawn height-fit
* and horizontally centred on that box; `SITE_COORDS` is expressed on the
* same viewBox and measured to land each anchor on the figure's anatomy.
* The recommended-next ring, the last-used marker and the selected fill
* all read directly from those coordinates.
*
* Per the long-standing "nichts brickt" directive the picker is opt-in —
* mounted from the GLP-1 dashboard tile and the optional log dialog.
@@ -88,10 +93,10 @@ export function InjectionSitePicker({
// map (WCAG 2.4.7).
const [focusedSite, setFocusedSite] = useState<InjectionSiteKey | null>(null);
// Stable, instance-unique ids for the SVG paint servers so two pickers
// on one page can't collide their <defs>.
// Stable, instance-unique id for the SVG mask so two pickers on one
// page can't collide their <defs>.
const uid = useId().replace(/[:]/g, "");
const bodyFillId = `inj-body-${uid}`;
const bodyMaskId = `inj-body-mask-${uid}`;
// The overlay maps a pointer event to viewBox space through the live
// CTM, then resolves the nearest allowed site centre and selects it.
@@ -133,69 +138,50 @@ export function InjectionSitePicker({
aria-label={t("medications.injectionSiteBodyOutlineAriaLabel")}
>
<defs>
{/* A whisper-soft vertical wash gives the flat silhouette a
little depth without fighting the theme — both stops are the
current text colour at low alpha, so it tints with the body. */}
<linearGradient id={bodyFillId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="currentColor" stopOpacity="0.06" />
<stop offset="1" stopColor="currentColor" stopOpacity="0.10" />
</linearGradient>
{/* The body figure's alpha drives an SVG mask. `mask-type="alpha"`
keys on the PNG's transparency (strokes opaque, surround
transparent) rather than luminance, so a `<rect fill=
currentColor>` painted through it renders the figure in the
picker's text colour — theme-aware in light/dark and across
the zinc/Dracula palettes with no baked-in tint. The image is
height-fit + horizontally centred on the 100×200 box so it
shares the dots' coordinate space exactly. */}
<mask
id={bodyMaskId}
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="100"
height="200"
style={{ maskType: "alpha" }}
>
<image
href="/injection-body-front.png"
x="0"
y="0"
width="100"
height="200"
preserveAspectRatio="xMidYMid meet"
/>
</mask>
</defs>
{/* Anatomical front-of-body silhouette — one continuous outline
traced to the iOS reference proportions (upright, arms slightly
out, plain oval head). Filled with the soft wash + a hairline
stroke so it reads as a calm medical illustration. */}
<path
d="M 50 4
C 44.2 4 39.8 8.6 39.8 15
C 39.8 19.6 41.4 23.4 44 25.6
C 43.4 28.4 42 30.4 39.2 31.4
C 35 32.9 30.4 34.2 27.4 36.6
C 24.4 39 23 42.8 22.2 47.4
C 21.2 53.2 20.2 60 18.2 67
C 16.4 73.4 14 80.4 12.6 86.4
C 11.8 89.8 12.4 92.2 14.2 92.8
C 16 93.4 17.6 91.8 18.4 88.6
C 19.8 83 21.8 76.6 23.6 71
C 24.4 68.4 25.4 67.8 25.8 69
C 26.2 70.2 26 74 25.6 79
C 25.2 84.6 24.8 90.6 25.2 95.6
C 25.6 100.4 26.8 104.4 28 107.8
C 27.2 119 26.2 133 26.6 145
C 27 156.6 28.6 170 30 182
C 30.6 187.4 31.4 191.6 33 193.4
C 34.4 195 37 195.2 39 194.6
C 40.8 194 41.4 192.2 41.6 189.4
C 42.2 181.4 43 169 44 158
C 44.8 149.4 45.6 141.4 46.4 136
C 47 131.8 48.2 129.6 50 129.6
C 51.8 129.6 53 131.8 53.6 136
C 54.4 141.4 55.2 149.4 56 158
C 57 169 57.8 181.4 58.4 189.4
C 58.6 192.2 59.2 194 61 194.6
C 63 195.2 65.6 195 67 193.4
C 68.6 191.6 69.4 187.4 70 182
C 71.4 170 73 156.6 73.4 145
C 73.8 133 72.8 119 72 107.8
C 73.2 104.4 74.4 100.4 74.8 95.6
C 75.2 90.6 74.8 84.6 74.4 79
C 74 74 73.8 70.2 74.2 69
C 74.6 67.8 75.6 68.4 76.4 71
C 78.2 76.6 80.2 83 81.6 88.6
C 82.4 91.8 84 93.4 85.8 92.8
C 87.6 92.2 88.2 89.8 87.4 86.4
C 86 80.4 83.6 73.4 81.8 67
C 79.8 60 78.8 53.2 77.8 47.4
C 77 42.8 75.6 39 72.6 36.6
C 69.6 34.2 65 32.9 60.8 31.4
C 58 30.4 56.6 28.4 56 25.6
C 58.6 23.4 60.2 19.6 60.2 15
C 60.2 8.6 55.8 4 50 4 Z"
fill={`url(#${bodyFillId})`}
stroke="currentColor"
strokeWidth="0.9"
strokeLinejoin="round"
{/* Anatomical front-of-body figure — a clean, gender-neutral
medical line-art body painted in `currentColor` through the
alpha mask above. The figure is calibrated so the upper-arm
anchors (x23/77 @ y78) sit on the arm mass clear of the torso,
the abdomen quadrants (x43.5/56.5 @ y81/95) sit on the torso
either side of the midline, and the thigh anchors (x40/60 @
y140) sit on the centre of each thigh — the dots land on real
anatomy. Rendered at a soft opacity so it reads as a calm
backdrop the dots sit on rather than competing with them. */}
<rect
x="0"
y="0"
width="100"
height="200"
fill="currentColor"
mask={`url(#${bodyMaskId})`}
/>
{/* Centre + waist references — barely-there guides that help the
@@ -211,9 +197,9 @@ export function InjectionSitePicker({
strokeOpacity="0.35"
/>
<line
x1="29"
x1="40"
y1="88"
x2="71"
x2="60"
y2="88"
stroke="currentColor"
strokeWidth="0.4"
@@ -248,18 +234,20 @@ export function InjectionSitePicker({
strokeWidth="1.8"
/>
)}
{/* Last-used marker — a soft solid ring so the user can see
{/* Last-used marker — a solid amber ring so the user can see
where they injected most recently at a glance, distinct
from the dashed recommendation ring. Suppressed on the
active selection (the filled dot already says "here"). */}
from the dashed primary recommendation ring. The amber tone
matches the dialog legend and reads in light + dark.
Suppressed on the active selection (the filled dot already
says "here"). */}
{isLastUsed && !disabled && (
<circle
cx={coord.x}
cy={coord.y}
r="7"
fill="none"
className="stroke-foreground/35"
strokeWidth="1"
className="stroke-amber-500"
strokeWidth="1.2"
/>
)}
{isRecommended && !isActive && !disabled && (
@@ -109,12 +109,57 @@ export function LogInjectionSiteDialog({
{t("medications.logInjectionSiteNoneAvailable")}
</p>
) : (
<InjectionSitePicker
value={selected}
history={history}
allowed={allowed}
onChange={setSelected}
/>
<>
<InjectionSitePicker
value={selected}
history={history}
allowed={allowed}
onChange={setSelected}
/>
{/* Marker legend — explains the two body-map annotations so the
dashed primary ring and the amber ring read at a glance. The
swatches mirror the picker's own marker strokes exactly. */}
<ul
className="text-muted-foreground flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-xs"
aria-label={t("medications.injectionSiteLegendAriaLabel")}
>
<li className="flex items-center gap-1.5">
<svg
viewBox="0 0 16 16"
className="h-3.5 w-3.5 shrink-0"
aria-hidden="true"
>
<circle
cx="8"
cy="8"
r="6"
fill="none"
className="stroke-primary"
strokeWidth="1.6"
strokeDasharray="3 3"
/>
</svg>
{t("medications.injectionSiteLegendRecommended")}
</li>
<li className="flex items-center gap-1.5">
<svg
viewBox="0 0 16 16"
className="h-3.5 w-3.5 shrink-0"
aria-hidden="true"
>
<circle
cx="8"
cy="8"
r="6"
fill="none"
className="stroke-amber-500"
strokeWidth="1.8"
/>
</svg>
{t("medications.injectionSiteLegendLastUsed")}
</li>
</ul>
</>
)}
<DialogFooter className="gap-2 sm:gap-2">
@@ -1,17 +1,17 @@
/**
* v1.11.5 — QoL-Lows coverage for the mood form + list fixes.
* v1.11.5 / v1.12.0 — coverage for the mood form + list.
*
* Project convention is SSR-only component tests (`renderToStaticMarkup`,
* no `@testing-library/react`) plus source-string structural assertions
* for the interactive plumbing that an SSR mount can't exercise. The
* three fixes pinned here:
* for the interactive plumbing that an SSR mount can't exercise.
*
* 1. `<MoodList>` — the delete mutation surfaces a toast on failure
* (it previously had no `onError`, so a rejected DELETE was silent).
* 2. `<MoodForm>` — Reset confirms first when the form holds typed
* input, and resets immediately when pristine.
* 3. `<MoodForm>` — the note field shows a live character counter so
* the `maxLength` cap no longer truncates without feedback.
* v1.12.0 rebuilt the logging surface into the "How are you?" 5-face
* hero: the five mood faces are the primary input (rendered on a
* pristine form), and the annotate panel — time, tags, factor ratings,
* the note field + its counter — reveals only after a face is picked.
* A pristine SSR mount therefore shows the hero but not the counter;
* the counter / reset / note plumbing is pinned via source-string
* assertions below.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
@@ -44,13 +44,43 @@ const listSrc = readFileSync(
"utf8",
);
describe("MoodForm — note character counter (v1.11.5)", () => {
it("renders the counter at 0/500 for a pristine form", () => {
describe("MoodForm — 5-face hero (v1.12.0)", () => {
it("renders the five mood faces as a radiogroup on a pristine form", () => {
const html = renderForm();
expect(html).toContain('data-testid="mood-note-counter"');
expect(html).toContain("0/500");
// The "How are you?" hero is the primary input — present before any
// mood is picked.
expect(html).toContain('data-slot="mood-face-hero"');
expect(html).toContain("How are you?");
// Best-on-the-left order: every enum value renders one face button.
for (const mood of [
"SUPER_GUT",
"GUT",
"OKAY",
"SCHLECHT",
"LAUSIG",
]) {
expect(html).toContain(`data-mood="${mood}"`);
}
// The first face is SUPER_GUT (best on the left).
expect(html.indexOf('data-mood="SUPER_GUT"')).toBeLessThan(
html.indexOf('data-mood="LAUSIG"'),
);
});
it("hides the annotate panel (and note counter) until a face is picked", () => {
// A pristine SSR mount cannot pick a mood, so the panel stays closed.
const html = renderForm();
expect(html).not.toContain('data-slot="mood-annotate-panel"');
expect(html).not.toContain('data-testid="mood-note-counter"');
// The panel is gated on `moodPicked` (mood !== "").
expect(formSrc).toContain("const moodPicked = mood !== \"\"");
expect(formSrc).toMatch(
/moodPicked &&[\s\S]*data-slot="mood-annotate-panel"/,
);
});
});
describe("MoodForm — note character counter (v1.11.5)", () => {
it("ties the textarea cap to the shared NOTE_MAX_LENGTH constant", () => {
expect(formSrc).toContain("const NOTE_MAX_LENGTH = 500");
expect(formSrc).toContain("maxLength={NOTE_MAX_LENGTH}");
@@ -67,6 +97,7 @@ describe("MoodForm — Reset confirm when dirty (v1.11.5)", () => {
expect(formSrc).toMatch(/const isDirty =\s*\n?\s*mood !== ""/);
expect(formSrc).toContain('tagsInput.trim() !== ""');
expect(formSrc).toContain("tagKeys.length > 0");
expect(formSrc).toContain("ratedFactors.length > 0");
expect(formSrc).toContain('note.trim() !== ""');
// The `isDirty` expression itself must not read `moodLoggedAt`.
const isDirtyExpr = formSrc.slice(
@@ -94,6 +125,28 @@ describe("MoodForm — Reset confirm when dirty (v1.11.5)", () => {
});
});
describe("MoodForm — rated factors (v1.12.0)", () => {
it("lifts a ratedFactors set and threads it to the tag picker", () => {
expect(formSrc).toContain("const [ratedFactors, setRatedFactors]");
expect(formSrc).toMatch(/function rateFactor\(key: string, rating: number \| null\)/);
// The picker receives both the binary toggle and the factor rate
// callbacks so it can render BINARY chips and RATED controls.
expect(formSrc).toMatch(
/<MoodTagPicker[\s\S]*ratedFactors=\{ratedFactors\}[\s\S]*onRateFactor=\{rateFactor\}/,
);
});
it("sends ratedFactors as a parallel array on POST (omitted when empty)", () => {
expect(formSrc).toContain(
"ratedFactors: ratedFactors.length > 0 ? ratedFactors : undefined,",
);
// The binary structured-tag contract stays byte-identical.
expect(formSrc).toContain(
"tagKeys: tagKeys.length > 0 ? tagKeys : undefined,",
);
});
});
describe("MoodList — delete failure toast (v1.11.5)", () => {
it("surfaces a toast on a rejected delete instead of failing silently", () => {
expect(listSrc).toContain('import { toast } from "sonner"');
+71 -13
View File
@@ -29,8 +29,13 @@ import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import { invalidateKeys, moodDependentKeys } from "@/lib/query-keys";
import { MoodTagPicker } from "./mood-tag-picker";
import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker";
import { moodFaceIcon } from "./mood-tag-icons";
// v1.12.0 — best-on-the-left face order for the "How are you?" hero,
// mirroring the iOS `Mood1…Mood5` imageset order. The numeric `score`
// is the server's 1..5 scale; the face icon resolves through
// `moodFaceIcon`.
const MOOD_LEVELS = [
{ value: "SUPER_GUT", score: 5, labelKey: "mood.levelSuperGut" },
{ value: "GUT", score: 4, labelKey: "mood.levelGut" },
@@ -96,6 +101,9 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
const [tagsInput, setTagsInput] = useState("");
// v1.8.5 — structured-tag keys picked from the taxonomy catalog.
const [tagKeys, setTagKeys] = useState<string[]>([]);
// v1.12.0 — rated mood factors (FACTOR-kind catalog tags scored on
// their own 1..scaleMax scale). Sent as `ratedFactors: [{key,rating}]`.
const [ratedFactors, setRatedFactors] = useState<RatedFactor[]>([]);
// v1.8.5 (C1) — first-class free-text note. The model + API already
// accepted `note`; the web form was the only surface that couldn't
// write it.
@@ -111,6 +119,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
mood !== "" ||
tagsInput.trim() !== "" ||
tagKeys.length > 0 ||
ratedFactors.length > 0 ||
note.trim() !== "";
function toggleTagKey(key: string) {
@@ -119,6 +128,15 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
);
}
// v1.12.0 — set or clear a rated factor's score. `rating === null`
// removes the factor from the set (re-tapping the active step clears).
function rateFactor(key: string, rating: number | null) {
setRatedFactors((prev) => {
const rest = prev.filter((f) => f.key !== key);
return rating === null ? rest : [...rest, { key, rating }];
});
}
// v1.4.27 MB3 — error banner descriptor for the timestamp input.
const errorId = useId();
const errorDescriptor = error ? errorId : undefined;
@@ -132,6 +150,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
setMood("");
setTagsInput("");
setTagKeys([]);
setRatedFactors([]);
setNote("");
setMoodLoggedAt(getDefaultMoodLoggedAtValue());
setError(null);
@@ -170,6 +189,8 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
mood,
tags: tags.length > 0 ? tags : undefined,
tagKeys: tagKeys.length > 0 ? tagKeys : undefined,
// v1.12.0 — rated factors as a parallel [{key,rating}] array.
ratedFactors: ratedFactors.length > 0 ? ratedFactors : undefined,
note: trimmedNote.length > 0 ? trimmedNote : undefined,
moodLoggedAt: timestamp,
}),
@@ -243,34 +264,60 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
</div>
);
// v1.12.0 — the annotate panel (tags / factors / note / time) reveals
// only after a mood face is picked, so the icon-first hero is the
// primary input and a fast log is a single tap + Save.
const moodPicked = mood !== "";
return (
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label id="mood-level-label">{t("mood.moodLevel")}</Label>
{/* v1.12.0 — "How are you?" 5-face hero. The five faces are a
radiogroup (best-on-the-left order); tapping one picks the mood
and reveals the annotate panel below. */}
<div className="space-y-2" data-slot="mood-face-hero">
{/* v1.12 — the sheet title ("Add") is the single primary heading;
the hero question stays as the face row's field label rather
than a second heading-weight line, so the sheet header isn't a
doubled heading. */}
<Label
id="mood-level-label"
className="text-muted-foreground text-sm font-normal"
>
{t("mood.heroQuestion")}
</Label>
<div
role="radiogroup"
aria-labelledby="mood-level-label"
className="grid grid-cols-5 gap-2"
className="grid grid-cols-5 gap-1.5 sm:gap-2"
>
{MOOD_LEVELS.map((level, index) => {
const isSelected = mood === level.value;
const FaceIcon = moodFaceIcon(level.value);
return (
<button
key={level.value}
type="button"
role="radio"
aria-checked={isSelected}
aria-label={t(level.labelKey)}
data-slot="mood-face"
data-mood={level.value}
onClick={() => setMood(level.value)}
{...getMoodRadioProps(index)}
className={`flex flex-col items-center gap-1 rounded-lg border p-2 text-center transition-colors ${
// v1.12 — selected-state unified with the tag tiles and
// factor steps: `border-primary bg-primary/15 text-primary`
// on a single 1px border (no `border-2` jump) so the
// selectable controls read as one design language.
className={`flex flex-col items-center gap-1.5 rounded-xl border p-2 text-center transition-colors sm:p-3 ${
isSelected
? "border-primary bg-primary/10 text-primary border-2"
: "border-border hover:bg-accent"
? "border-primary bg-primary/15 text-primary"
: "border-border text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
<span className="text-lg font-semibold tabular-nums">
{level.score}
</span>
<FaceIcon
className="h-7 w-7 sm:h-8 sm:w-8"
aria-hidden="true"
/>
<span className="text-[10px] leading-tight sm:text-xs">
{t(level.labelKey)}
</span>
@@ -280,6 +327,8 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
</div>
</div>
{moodPicked && (
<div className="space-y-4 border-t pt-4" data-slot="mood-annotate-panel">
<div className="space-y-2">
<Label htmlFor="mood-logged-at">{t("mood.timestamp")}</Label>
<DateTimeInput
@@ -363,8 +412,10 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
</div>
</div>
{/* v1.8.5 — structured-tag taxonomy picker. Additive next to the
free-text input above; an entry can carry both axes. */}
{/* v1.8.5 / v1.12.0 — structured-tag taxonomy picker. Additive next
to the free-text input above; an entry can carry both axes. The
picker also surfaces the FACTOR-kind tags as 1..scaleMax rating
controls, lifted here as `ratedFactors`. */}
<div className="space-y-2">
<Label>
{t("mood.tagPicker")}{" "}
@@ -372,7 +423,12 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
({t("common.optional")})
</span>
</Label>
<MoodTagPicker selected={tagKeys} onToggle={toggleTagKey} />
<MoodTagPicker
selected={tagKeys}
onToggle={toggleTagKey}
ratedFactors={ratedFactors}
onRateFactor={rateFactor}
/>
</div>
{/* v1.8.5 (C1) — free-text note. */}
@@ -411,6 +467,8 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
rows={3}
/>
</div>
</div>
)}
{error && (
<div
+44
View File
@@ -1,35 +1,48 @@
import {
AlertTriangle,
Angry,
Apple,
BedDouble,
BookOpen,
Brain,
Briefcase,
CandyOff,
CheckCircle,
Clock,
CloudMoon,
CloudSun,
Dumbbell,
Film,
Flame,
Footprints,
Frown,
Gamepad2,
GlassWater,
HandHeart,
Heart,
HeartPulse,
HelpCircle,
Home,
Laugh,
LogOut,
Meh,
Moon,
MoonStar,
Music,
Palette,
PartyPopper,
Pizza,
Plane,
SlidersHorizontal,
Smile,
Swords,
Tag,
Thermometer,
ThumbsUp,
Trees,
User,
Users,
UtensilsCrossed,
Wine,
Zap,
type LucideIcon,
@@ -46,16 +59,20 @@ const MOOD_TAG_ICONS: Record<string, LucideIcon> = {
AlertTriangle,
Apple,
BedDouble,
BookOpen,
Brain,
Briefcase,
CandyOff,
CheckCircle,
Clock,
CloudMoon,
CloudSun,
Dumbbell,
Film,
Flame,
Footprints,
Frown,
Gamepad2,
GlassWater,
HandHeart,
Heart,
@@ -66,13 +83,20 @@ const MOOD_TAG_ICONS: Record<string, LucideIcon> = {
Meh,
Moon,
MoonStar,
Music,
Palette,
PartyPopper,
Pizza,
Plane,
SlidersHorizontal,
Smile,
Swords,
Thermometer,
ThumbsUp,
Trees,
User,
Users,
UtensilsCrossed,
Wine,
Zap,
};
@@ -81,3 +105,23 @@ export function moodTagIcon(name: string | null | undefined): LucideIcon {
if (!name) return Tag;
return MOOD_TAG_ICONS[name] ?? Tag;
}
/**
* v1.12.0 — face icon per mood enum for the "How are you?" 5-face hero.
*
* The logging surface renders these five faces best-on-the-left
* (`SUPER_GUT` … `LAUSIG`) as the primary mood input. Monochrome by
* default; the selection accent is applied by the caller (the one
* sanctioned tint), matching the iOS `Mood1…Mood5` imageset order.
*/
const MOOD_FACE_ICONS: Record<string, LucideIcon> = {
SUPER_GUT: Laugh,
GUT: Smile,
OKAY: Meh,
SCHLECHT: Frown,
LAUSIG: Angry,
};
export function moodFaceIcon(mood: string): LucideIcon {
return MOOD_FACE_ICONS[mood] ?? Meh;
}
+147 -29
View File
@@ -9,12 +9,19 @@ import { useTranslations } from "@/lib/i18n/context";
import { moodTagIcon } from "./mood-tag-icons";
/**
* v1.8.5 — structured mood-tag capture surface.
* v1.8.5 / v1.12.0 — structured mood-tag capture surface (Daylio-style).
*
* Loads the global Category -> Tag catalog from `/api/mood/tags` and
* renders one labelled group per category with icon chips the user can
* toggle. Selection is a controlled set of tag keys lifted to the
* parent form, which sends them as `tagKeys` on create / update.
* renders one labelled group per category. Two tag kinds are surfaced:
*
* - `BINARY` — an icon-above-label toggle tile. Multi-select across
* categories; the selection is a controlled set of tag keys lifted
* to the parent form, which sends them as `tagKeys` on create.
* - `RATED` — a 1..scaleMax segmented control (a Yes/No pair when
* `scaleMax === 2`). The score is lifted as a `{ key, rating }`
* entry and sent as `ratedFactors`. `inverse` factors (higher =
* worse day) carry the same control; the inversion is a read-side
* concern handled server-side.
*
* Additive next to the legacy free-text tag input: a user can use
* either or both. Renders nothing if the catalog is empty so a
@@ -25,6 +32,10 @@ interface CatalogTag {
key: string;
labelKey: string;
icon: string | null;
kind: "BINARY" | "RATED";
scaleMin: number;
scaleMax: number;
inverse: boolean;
}
interface CatalogCategory {
@@ -38,12 +49,28 @@ interface CatalogResponse {
categories: CatalogCategory[];
}
export interface RatedFactor {
key: string;
rating: number;
}
export function MoodTagPicker({
selected,
onToggle,
ratedFactors = [],
onRateFactor,
}: {
/** Selected binary tag keys (sent as `tagKeys`). */
selected: string[];
onToggle: (tagKey: string) => void;
/** Current rated-factor scores (sent as `ratedFactors`). */
ratedFactors?: RatedFactor[];
/**
* Sets (or, when `rating === null`, clears) a rated factor's score.
* Required for the RATED segmented controls to render interactively;
* when omitted the picker only surfaces binary tags.
*/
onRateFactor?: (key: string, rating: number | null) => void;
}) {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
@@ -70,40 +97,131 @@ export function MoodTagPicker({
}
const selectedSet = new Set(selected);
const ratingByKey = new Map(ratedFactors.map((f) => [f.key, f.rating]));
return (
<div className="space-y-3" data-slot="mood-tag-picker">
<div className="space-y-4" data-slot="mood-tag-picker">
{data.categories.map((category) => {
const CategoryIcon = moodTagIcon(category.icon);
const binaryTags = category.tags.filter((tag) => tag.kind !== "RATED");
const ratedTags = category.tags.filter((tag) => tag.kind === "RATED");
// Self-gate an empty category (e.g. a RATED-only category when no
// `onRateFactor` is wired) so the picker never shows a bare heading.
if (binaryTags.length === 0 && ratedTags.length === 0) return null;
if (ratedTags.length > 0 && !onRateFactor && binaryTags.length === 0) {
return null;
}
return (
<div key={category.key} className="space-y-1.5">
<div key={category.key} className="space-y-2">
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
<CategoryIcon className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t(category.labelKey)}</span>
</div>
<div className="flex flex-wrap gap-1.5">
{category.tags.map((tag) => {
const TagIcon = moodTagIcon(tag.icon);
const isActive = selectedSet.has(tag.key);
const label = t(tag.labelKey);
return (
<button
key={tag.key}
type="button"
onClick={() => onToggle(tag.key)}
aria-pressed={isActive}
className={`inline-flex min-h-11 items-center gap-1.5 rounded-full border px-3 py-2 text-xs transition-colors ${
isActive
? "border-primary bg-primary/15 text-primary"
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
}`}
>
<TagIcon className="h-3.5 w-3.5" aria-hidden="true" />
{label}
</button>
);
})}
</div>
{binaryTags.length > 0 && (
<div className="flex flex-wrap gap-2">
{binaryTags.map((tag) => {
const TagIcon = moodTagIcon(tag.icon);
const isActive = selectedSet.has(tag.key);
const label = t(tag.labelKey);
return (
<button
key={tag.key}
type="button"
onClick={() => onToggle(tag.key)}
aria-pressed={isActive}
data-slot="mood-tag-tile"
className={`flex min-h-16 w-[4.5rem] flex-col items-center justify-center gap-1 rounded-xl border p-2 text-center transition-colors ${
isActive
? "border-primary bg-primary/15 text-primary"
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
}`}
>
<TagIcon className="h-5 w-5" aria-hidden="true" />
<span className="text-[11px] leading-tight">{label}</span>
</button>
);
})}
</div>
)}
{ratedTags.length > 0 && onRateFactor && (
<div className="space-y-2" data-slot="mood-factor-ratings">
{ratedTags.map((tag) => {
const TagIcon = moodTagIcon(tag.icon);
const label = t(tag.labelKey);
const current = ratingByKey.get(tag.key);
const isBinaryScale = tag.scaleMax - tag.scaleMin === 1;
const steps: number[] = [];
for (let v = tag.scaleMin; v <= tag.scaleMax; v++) {
steps.push(v);
}
const groupLabelId = `mood-factor-${tag.key}`;
return (
<div
key={tag.key}
className="flex items-center justify-between gap-3"
data-slot="mood-factor-rating"
>
<span
id={groupLabelId}
className="text-foreground/85 flex items-center gap-1.5 text-xs"
>
<TagIcon className="h-4 w-4" aria-hidden="true" />
{label}
</span>
<div
role="radiogroup"
aria-labelledby={groupLabelId}
className="flex items-center gap-1"
>
{steps.map((value) => {
const isActive = current === value;
const optionLabel = isBinaryScale
? value === tag.scaleMax
? t("mood.factorYes")
: t("mood.factorNo")
: String(value);
const a11yLabel = isBinaryScale
? optionLabel
: t("mood.factorRatingOption", {
label,
value: String(value),
max: String(tag.scaleMax),
});
return (
<button
key={value}
type="button"
role="radio"
aria-checked={isActive}
aria-label={a11yLabel}
// Re-tap the active step to clear the score.
onClick={() =>
onRateFactor(tag.key, isActive ? null : value)
}
// v1.12 — `rounded-lg` aligns the step
// radius with the tag tiles and mood faces;
// selected-state matches them too.
className={`flex h-8 items-center justify-center rounded-lg border px-2 text-xs tabular-nums transition-colors ${
isBinaryScale ? "min-w-12" : "min-w-8"
} ${
isActive
? "border-primary bg-primary/15 text-primary"
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
}`}
>
{optionLabel}
</button>
);
})}
</div>
</div>
);
})}
</div>
)}
</div>
);
})}
@@ -1,11 +1,13 @@
/**
* v1.4.37 W7a — Arztbericht hero card contract.
* Arztbericht (doctor-report) card contract.
*
* The flagship doctor-report PDF surface was promoted from one of five
* grid cards into a hero card at the top of `/settings/export`. This
* suite pins the SSR contract:
* v1.4.37 W7a promoted this surface to the page hero; v1.12 demotes it
* back to a small secondary card at the bottom of `/settings/export`
* (the health-record export now owns the hero). The card stays fully
* functional — only the framing and visual weight changed. This suite
* pins the SSR contract:
*
* 1. The hero renders with the expected `data-testid` slot.
* 1. The card renders with the expected `data-testid` slot.
* 2. The value statement is present (Marc-Voice copy, no AI mention).
* 3. The CTA button is present, clears the 44 px touch floor, and
* carries an `aria-describedby` link to the value statement.
@@ -37,7 +39,7 @@ function renderSSR(node: React.ReactElement, locale: "en" | "de" = "en") {
}
describe("<ArztberichtHeroCard> — SSR contract", () => {
it("renders the hero slot with eyebrow + title", () => {
it("renders the card slot with eyebrow + title", () => {
const html = renderSSR(<ArztberichtHeroCard />);
expect(html).toContain('data-testid="export-hero-doctor-report"');
// Title falls back to the existing doctorReport.title key — keeps
@@ -59,10 +61,10 @@ describe("<ArztberichtHeroCard> — SSR contract", () => {
it("renders the CTA with min-h-11 touch target + aria-describedby", () => {
const html = renderSSR(<ArztberichtHeroCard />);
expect(html).toContain('data-testid="export-hero-doctor-report-action"');
// 44 px touch floor (WCAG 2.5.5) — the hero CTA pins `h-11` on
// mobile and falls back to the compact `sm:h-10` on pointer
// 44 px touch floor (WCAG 2.5.5) — the demoted CTA keeps `min-h-11`
// on mobile and falls back to the compact `sm:min-h-9` on pointer
// devices.
expect(html).toContain("h-11");
expect(html).toContain("min-h-11");
// Aria-describedby links the CTA to the value statement so screen
// readers announce the framing alongside the action label.
expect(html).toMatch(/aria-describedby="[^"]+"/);
@@ -84,12 +86,15 @@ describe("<ArztberichtHeroCard> — SSR contract", () => {
expect(html).not.toContain("settings.sections.export.hero");
});
it("uses the hero-gradient + glow-purple visual treatment", () => {
it("renders as a plain secondary card, not a hero", () => {
const html = renderSSR(<ArztberichtHeroCard />);
// Same surface utilities as the Insights hero strip — keeps the
// app's hero language consistent.
expect(html).toContain("hero-gradient");
expect(html).toContain("glow-purple");
// v1.12 demoted the doctor-report from the page hero — the
// health-record export now owns the hero treatment, so this card
// must NOT carry the hero gradient/glow utilities.
expect(html).not.toContain("hero-gradient");
expect(html).not.toContain("glow-purple");
// It renders with the standard secondary-card surface.
expect(html).toContain("bg-card");
});
it("does not paint dialog markup in the closed-state SSR pass", () => {
@@ -1,24 +1,24 @@
/**
* v1.4.16 Phase B7 — Settings → Export section.
*
* The acceptance criterion for the consolidated Export menu was "five
* cards":
* 1. Doctor Report (PDF) — promoted to the hero in v1.4.37 W7a;
* now lives at the top of the page, NOT in the grid
* 2. Measurements CSV
* 3. Medications CSV (with optional intake-history toggle)
* 4. Mood CSV
* 5. Full JSON Backup
* The consolidated Export page surfaces:
* - Health Record export panel (the page hero, v1.12)
* - Measurements CSV
* - Medications CSV (with optional intake-history toggle)
* - Mood CSV
* - Full JSON Backup
* - Doctor Report (PDF) — small secondary card at the bottom (v1.12)
*
* Each card must surface a title, a 1-line description, and a
* download/generate button. SSR-only smoke test — interaction is
* exercised by the e2e suite.
*
* v1.4.37 W7a — the doctor-report card was extracted into
* `<ArztberichtHeroCard>` and rendered above the grid. The remaining
* four cards stay under a "Weitere Export-Optionen" / "Other export
* options" sub-heading. The hero owns its own contract test; this
* suite pins the page-level shape.
* v1.12 — the health-record export takes the page hero; the
* doctor-report card (`<ArztberichtHeroCard>`) is demoted to a small
* secondary card at the bottom of the page. The four CSV/JSON tiles
* stay under a "Weitere Export-Optionen" / "Other export options"
* sub-heading. Each component owns its own contract test; this suite
* pins the page-level shape.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -60,11 +60,19 @@ describe("<ExportSection> — SSR smoke", () => {
expect(html).not.toContain("settings.sections.export.");
});
it("renders the Arztbericht hero card at the top of the page", () => {
it("mounts the health-record export panel as the page hero", () => {
const html = render(<ExportSection />);
// The hero owns its own contract suite; here we pin that the page
// v1.12 — the health-record export owns the hero treatment.
expect(html).toContain('data-testid="health-record-export-panel"');
expect(html).toContain("hero-gradient");
expect(html).toContain("glow-purple");
});
it("mounts the demoted doctor-report card", () => {
const html = render(<ExportSection />);
// The card owns its own contract suite; here we pin that the page
// mounts it, and that the legacy in-grid doctor-report card is
// gone (v1.4.37 W7a moved it into the hero).
// gone. The card no longer carries the hero gradient/glow (v1.12).
expect(html).toContain('data-testid="export-hero-doctor-report"');
expect(html).not.toContain('data-testid="export-card-doctor-report"');
});
@@ -163,8 +163,9 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
});
const html = render();
// Exactly one pill per card → 3 pills total (Withings, moodLog, WHOOP).
expect(count(html, 'data-testid="integration-status-pill"')).toBe(3);
// Exactly one pill per card → 4 pills total (Withings, moodLog, WHOOP,
// Fitbit / Google Health).
expect(count(html, 'data-testid="integration-status-pill"')).toBe(4);
// The redundant banner from v1.4.15 is gone.
expect(html).not.toContain('data-testid="integration-status-banner"');
// Card-body "letzter Sync" repetition is gone — no
@@ -354,7 +355,8 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
const html = render();
// Every integration card includes the section divider data-testid so the
// header → body separation is visually consistent.
expect(count(html, 'data-testid="integration-card-divider"')).toBe(3);
// header → body separation is visually consistent (Withings, moodLog,
// WHOOP, Fitbit / Google Health).
expect(count(html, 'data-testid="integration-card-divider"')).toBe(4);
});
});
@@ -169,7 +169,9 @@ describe("<SettingsShell>", () => {
expect(html).toContain('href="/settings/thresholds"');
expect(html).toContain('href="/settings/sources"');
expect(html).toContain("Targets");
expect(html).toContain("Sources");
// v1.12.0 — the source-priority entry adopts the canonical
// "Source priority" / "Quellen-Priorität" label (handover §5).
expect(html).toContain("Source priority");
});
it("resolves every section title via the i18n provider — German", () => {
@@ -1,195 +0,0 @@
/**
* v1.7.0 — Settings → Display metric/imperial control contract.
*
* The card is a two-option segmented control that reads the current
* value from `useAuth().unitPreference` and PATCHes
* `/api/auth/me/unit-preference` on change, invalidating
* `queryKeys.authMe()` so the chart display transforms re-render.
*
* Test strategy mirrors `<DisableCoachCard>` and the project's
* SSR-only convention (no `@testing-library/react`): real
* `useMutation` + `QueryClient`, a spied `useAuth`, and a stubbed
* `fetch`. The render pass pins the current-value SSR shape and the
* interactive / disabled state of the two segments; the mutation
* contract (endpoint + method + body) is pinned against the same
* `fetch` stub the click handler issues.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { AuthUser } from "@/hooks/use-auth";
const authSpy = vi.fn<
() => { user: AuthUser | null; isAuthenticated: boolean }
>(() => ({ user: buildUser("metric"), isAuthenticated: true }));
vi.mock("@/hooks/use-auth", async () => {
const actual =
await vi.importActual<typeof import("@/hooks/use-auth")>(
"@/hooks/use-auth",
);
return { ...actual, useAuth: () => authSpy() };
});
function buildUser(unitPreference: "metric" | "imperial"): AuthUser {
return {
id: "user-1",
username: "user",
email: null,
role: "USER",
heightCm: null,
dateOfBirth: null,
gender: null,
timezone: "Europe/Berlin",
onboardingCompletedAt: null,
onboardingTourCompleted: true,
avatarUrl: null,
glucoseUnit: null,
unitPreference,
disableCoach: false,
fullName: null,
insurerName: null,
insurerIkNumber: null,
insuranceNumber: null,
};
}
import { UnitPreferenceCard } from "../unit-preference-card";
let fetchMock: ReturnType<typeof vi.fn>;
function makeFetch() {
return vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => {
const body = init?.body ? JSON.parse(init.body as string) : {};
return new Response(
JSON.stringify({ data: { unitPreference: body.unitPreference } }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});
}
beforeEach(() => {
authSpy.mockClear();
authSpy.mockImplementation(() => ({
user: buildUser("metric"),
isAuthenticated: true,
}));
fetchMock = makeFetch();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function render(): string {
const client = new QueryClient({
defaultOptions: { queries: { retry: 0 } },
});
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<UnitPreferenceCard isAuthenticated />
</I18nProvider>
</QueryClientProvider>,
);
}
// React renders the boolean `disabled` attribute as `disabled=""`. The
// Tailwind `disabled:opacity-50` class string also contains the word
// "disabled", so callers match the attribute form specifically. The
// helper isolates a single button's opening tag so attribute order
// (which React does not guarantee) doesn't make assertions brittle.
function segmentMarkup(html: string, key: "metric" | "imperial"): string {
const m = html.match(
new RegExp(`<button[^>]*settings-unit-preference-${key}[^>]*>`),
);
expect(m).not.toBeNull();
return m![0];
}
describe("Settings — UnitPreferenceCard", () => {
it("renders the metric option as selected for a default user", () => {
const html = render();
expect(html).toContain('data-testid="settings-unit-preference-control"');
// The metric radio carries aria-checked="true"; imperial false.
expect(segmentMarkup(html, "metric")).toContain('aria-checked="true"');
expect(segmentMarkup(html, "imperial")).toContain('aria-checked="false"');
});
it("reflects an imperial user's stored preference", () => {
authSpy.mockImplementation(() => ({
user: buildUser("imperial"),
isAuthenticated: true,
}));
const html = render();
expect(segmentMarkup(html, "imperial")).toContain('aria-checked="true"');
expect(segmentMarkup(html, "metric")).toContain('aria-checked="false"');
});
it("renders both segments as enabled radios for an authenticated user", () => {
const html = render();
// Both segments are interactive (no `disabled=""` attribute) so the
// user can switch between them; the click handler PATCHes the
// endpoint with the chosen value.
expect(segmentMarkup(html, "metric")).not.toContain('disabled=""');
expect(segmentMarkup(html, "imperial")).not.toContain('disabled=""');
expect(html).toContain("Metric");
expect(html).toContain("Imperial");
});
it("applies a roving tabindex — selected segment is the tab stop", () => {
// Roving tabindex: the radiogroup is a single tab stop. The selected
// (metric) option carries tabindex=0; the unselected (imperial) option
// carries tabindex=-1 so Tab skips it and Arrow keys move within the
// group instead. Arrow-key navigation itself is exercised at the
// `rovingRadioNextIndex` unit level (SSR can't dispatch keydown).
const html = render();
expect(segmentMarkup(html, "metric")).toContain('tabindex="0"');
expect(segmentMarkup(html, "imperial")).toContain('tabindex="-1"');
});
it("moves the tab stop to the imperial segment when it is selected", () => {
authSpy.mockImplementation(() => ({
user: buildUser("imperial"),
isAuthenticated: true,
}));
const html = render();
expect(segmentMarkup(html, "imperial")).toContain('tabindex="0"');
expect(segmentMarkup(html, "metric")).toContain('tabindex="-1"');
});
it("disables both segments when unauthenticated", () => {
authSpy.mockImplementation(() => ({ user: null, isAuthenticated: false }));
const html = renderToStaticMarkup(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: 0 } } })}
>
<I18nProvider initialLocale="en">
<UnitPreferenceCard isAuthenticated={false} />
</I18nProvider>
</QueryClientProvider>,
);
expect(segmentMarkup(html, "metric")).toContain('disabled=""');
expect(segmentMarkup(html, "imperial")).toContain('disabled=""');
});
it("targets the unit-preference endpoint with a PATCH body", async () => {
// SSR can't dispatch a click, so pin the mutation contract against
// the same stub the handler issues — endpoint, method, and the
// field-by-field body shape the route's Zod schema accepts.
await fetch("/api/auth/me/unit-preference", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ unitPreference: "imperial" }),
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe("/api/auth/me/unit-preference");
expect(init?.method).toBe("PATCH");
expect(JSON.parse(init?.body as string)).toEqual({
unitPreference: "imperial",
});
});
});
@@ -0,0 +1,152 @@
/**
* v1.12.x — Settings → Profile unit-system dropdown contract.
*
* The unit system moved from a standalone card into a `<NativeSelect>`
* in the Profile form beside the timezone picker. The control reads the
* current value from `useAuth().unitPreference` and PATCHes
* `/api/auth/me/unit-preference` on change, invalidating
* `queryKeys.authMe()` so the chart display transforms re-render.
*
* Test strategy mirrors the project's SSR-only convention (no
* `@testing-library/react`): real `useMutation` + `QueryClient`, a
* spied `useAuth`, and a stubbed `fetch`. The render pass pins the
* selected option SSR shape and the disabled state; the mutation
* contract (endpoint + method + body) is pinned against the same
* `fetch` stub the change handler issues.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { AuthUser } from "@/hooks/use-auth";
const authSpy = vi.fn<
() => { user: AuthUser | null; isAuthenticated: boolean }
>(() => ({ user: buildUser("metric"), isAuthenticated: true }));
vi.mock("@/hooks/use-auth", async () => {
const actual =
await vi.importActual<typeof import("@/hooks/use-auth")>(
"@/hooks/use-auth",
);
return { ...actual, useAuth: () => authSpy() };
});
function buildUser(unitPreference: "metric" | "imperial"): AuthUser {
return {
id: "user-1",
username: "user",
email: null,
role: "USER",
heightCm: null,
dateOfBirth: null,
gender: null,
timezone: "Europe/Berlin",
onboardingCompletedAt: null,
onboardingTourCompleted: true,
avatarUrl: null,
glucoseUnit: null,
unitPreference,
disableCoach: false,
fullName: null,
insurerName: null,
insurerIkNumber: null,
insuranceNumber: null,
};
}
import { UnitPreferenceSelect } from "../unit-preference-select";
let fetchMock: ReturnType<typeof vi.fn>;
function makeFetch() {
return vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => {
const body = init?.body ? JSON.parse(init.body as string) : {};
return new Response(
JSON.stringify({ data: { unitPreference: body.unitPreference } }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});
}
beforeEach(() => {
authSpy.mockClear();
authSpy.mockImplementation(() => ({
user: buildUser("metric"),
isAuthenticated: true,
}));
fetchMock = makeFetch();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function render(isAuthenticated = true): string {
const client = new QueryClient({
defaultOptions: { queries: { retry: 0 } },
});
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<UnitPreferenceSelect isAuthenticated={isAuthenticated} />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("Settings — UnitPreferenceSelect", () => {
it("renders a labeled native select with both options", () => {
const html = render();
expect(html).toContain('data-testid="settings-unit-preference-select"');
expect(html).toContain('value="metric"');
expect(html).toContain('value="imperial"');
expect(html).toContain("Metric");
expect(html).toContain("Imperial");
});
it("selects the current value for a metric user", () => {
const html = render();
// React renders the controlled select value via the option's
// `selected` attribute under SSR.
expect(html).toMatch(/<option[^>]*value="metric"[^>]*>/);
expect(html).toContain('value="metric"');
});
it("reflects an imperial user's stored preference", () => {
authSpy.mockImplementation(() => ({
user: buildUser("imperial"),
isAuthenticated: true,
}));
const html = render();
expect(html).toContain('value="imperial"');
});
it("disables the select when unauthenticated", () => {
const html = render(false);
const select = html.match(
/<select[^>]*settings-unit-preference-select[^>]*>/,
);
expect(select).not.toBeNull();
expect(select![0]).toContain("disabled");
});
it("targets the unit-preference endpoint with a PATCH body", async () => {
// SSR can't dispatch a change event, so pin the mutation contract
// against the same stub the handler issues — endpoint, method, and
// the field-by-field body shape the route's Zod schema accepts.
await fetch("/api/auth/me/unit-preference", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ unitPreference: "imperial" }),
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe("/api/auth/me/unit-preference");
expect(init?.method).toBe("PATCH");
expect(JSON.parse(init?.body as string)).toEqual({
unitPreference: "imperial",
});
});
});
+13 -7
View File
@@ -53,7 +53,7 @@ import { restartOnboardingTour } from "@/lib/onboarding/tour-restart";
import { describePasskeyError } from "@/lib/passkey-errors";
import { queryKeys } from "@/lib/query-keys";
import { TimezonePicker } from "@/components/settings/timezone-picker";
import { UnitPreferenceCard } from "@/components/settings/unit-preference-card";
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
import { InjectionSitesCard } from "@/components/settings/injection-sites-card";
import { detectBrowserTimezone, DEFAULT_TIMEZONE } from "@/lib/tz/format";
@@ -506,7 +506,14 @@ export function AccountSection() {
</div>
</div>
<TimezonePicker value={timezone} onChange={setTimezone} />
{/* Timezone + unit system share one grid row — both are
personal display preferences (like language above). The
unit dropdown PATCHes its own endpoint on change; the
timezone saves through the form's submit handler. */}
<div className="grid gap-4 sm:grid-cols-2">
<TimezonePicker value={timezone} onChange={setTimezone} />
<UnitPreferenceSelect isAuthenticated={isAuthenticated} />
</div>
{/* v1.7.0 — optional patient-identity fields surfaced on the
health-record export cover + FHIR Patient. All optional;
@@ -585,11 +592,10 @@ export function AccountSection() {
</form>
</div>
{/* Unit system + global injection-site exclusions live with the
account profile: both are personal preferences that apply
app-wide, not dashboard-layout controls. */}
<UnitPreferenceCard isAuthenticated={isAuthenticated} />
{/* Global injection-site exclusions live with the account
profile — a personal preference that applies app-wide. The
unit system moved into the profile form above, beside the
timezone dropdown. */}
<InjectionSitesCard isAuthenticated={isAuthenticated} />
{/* Passkeys card */}
@@ -1,46 +1,30 @@
"use client";
/**
* v1.4.37 W7a — Settings → Export Arztbericht hero card.
* Settings → Export Arztbericht (doctor-report) card.
*
* The doctor-report PDF is HealthLog's flagship export — the artefact a
* user actually carries into a doctor's appointment. The v1.4.16 phase
* B7 layout treated it as one of five equal cards in the grid, which
* undersold the feature. Marc 2026-05-17:
* The doctor-report PDF is the artefact a user carries into a doctor's
* appointment. v1.4.37 W7a promoted it to the page hero; v1.12 demotes
* it back to a small secondary card at the bottom of the export page —
* the health-record export now owns the page hero. The card is fully
* functional: the CTA routes through the existing `<DoctorReportDialog>`
* + `/api/doctor-report` flow exactly as before. Only the framing and
* visual weight changed.
*
* "Können wir unter Einstellungen > Export den Arztbericht als Hero
* Card machen?"
* Layout (compact card, no hero gradient):
*
* This component lifts the doctor-report card out of the grid and
* paints it with the same `hero-gradient + glow-purple` visual treatment
* as the Insights hero strip (`src/components/insights/hero-strip.tsx`),
* so the surface feels consistent with the rest of the app. The CTA
* routes through the existing `<DoctorReportDialog>` + `/api/doctor-
* report` flow — only the framing changed.
*
* Layout:
*
* ┌─ hero ────────────────────────────────────────────┐
* │ [icon] eyebrow │
* │ H2 title (settings.sections.export.cards. │
* │ doctorReport.title) │
* ┌─ card ────────────────────────────────────────────┐
* │ [icon] H2 title eyebrow │
* │ one-line value statement │
* │ ───── │
* │ [Configure & generate] PDF · printable │
* └───────────────────────────────────────────────────┘
*
* Mobile-first: at < sm the CTA stacks below the value statement (the
* action row is `flex-wrap`), the hero pads down to `px-4 py-5`, and
* the title clamps to a single line.
*
* a11y:
* - The CTA button clears the 44 px touch floor (`min-h-11`).
* - `focus-visible:ring` ships via the default `<Button>` variant.
* - The CTA is `aria-describedby={valueStatementId}` so a screen
* reader announces the value statement alongside the action.
* - The hero's `<h2>` slots beneath the page-level `<h1>` so the
* outline reads h1 (Export) → h2 (Arztbericht hero) → h2
* (Weitere Export-Optionen) → h2 (each remaining card).
* - The card's `<h2>` slots beneath the page-level `<h1>`.
*/
import { useEffect, useId, useState } from "react";
@@ -52,7 +36,6 @@ import {
type DoctorReportSubmitPayload,
} from "@/components/doctor-report/doctor-report-dialog";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
export function ArztberichtHeroCard() {
const { t, locale } = useTranslations();
@@ -122,52 +105,47 @@ export function ArztberichtHeroCard() {
<section
data-testid="export-hero-doctor-report"
aria-labelledby="export-hero-doctor-report-title"
className={cn(
"hero-gradient glow-purple animate-insight-in",
// `isolate` traps the purple glow inside the hero so the
// shadow doesn't leak through the cards below — same trick
// the Insights `<HeroStrip>` uses.
"relative isolate overflow-hidden rounded-xl px-4 py-5 sm:px-6 sm:py-6",
)}
className="bg-card border-border rounded-xl border p-5 sm:p-6"
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Stethoscope
className="text-dracula-purple h-5 w-5 shrink-0"
aria-hidden="true"
/>
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Stethoscope
className="text-primary h-5 w-5 shrink-0"
aria-hidden="true"
/>
<h2
id="export-hero-doctor-report-title"
className="text-base font-semibold tracking-tight"
>
{t("settings.sections.export.cards.doctorReport.title")}
</h2>
</div>
<span className="text-muted-foreground text-[11px] font-medium tracking-wide uppercase">
{t("settings.sections.export.hero.eyebrow")}
</span>
</div>
<h2
id="export-hero-doctor-report-title"
className="text-2xl leading-tight font-semibold tracking-tight sm:text-[28px]"
>
{t("settings.sections.export.cards.doctorReport.title")}
</h2>
<p
id={valueStatementId}
data-testid="export-hero-doctor-report-value"
className="text-muted-foreground max-w-2xl text-sm leading-relaxed"
className="text-muted-foreground max-w-2xl text-xs leading-relaxed"
>
{t("settings.sections.export.hero.valueStatement")}
</p>
</div>
<div className="flex flex-wrap items-center gap-3 border-t border-border/50 pt-4">
<div className="flex flex-wrap items-center gap-3">
<Button
data-testid="export-hero-doctor-report-action"
variant="default"
variant="outline"
size="sm"
// `min-h-11` (44 px) is the WCAG 2.5.5 touch-target floor;
// we override the default `<Button>` height on mobile and
// fall back to the compact `sm:h-10` on pointer devices.
// v1.4.38 W-D P3-2 — pair the explicit `h-11` with a
// `min-h-11` floor (and a `sm:min-h-9` desktop floor) so a
// future className override can't accidentally re-lift the
// mobile minimum below the touch-target contract.
className="h-11 min-h-11 px-5 text-sm font-medium sm:h-10 sm:min-h-9"
// we keep it on mobile and fall back to the compact
// `sm:min-h-9` on pointer devices so a future className
// override can't re-lift the mobile minimum below the
// touch-target contract.
className="min-h-11 sm:min-h-9"
onClick={() => setOpen(true)}
disabled={busy}
aria-describedby={valueStatementId}
+20 -19
View File
@@ -9,18 +9,20 @@
* per export type, each with its own filter inputs and a clear
* "Download" / "Generate" button.
*
* 1. Doctor Report (PDF) — hero card at the top of the page
* (v1.4.37 W7a, see
* `<ArztberichtHeroCard>`)
* 1. Health Record export — hero panel at the top of the page
* (PDF + FHIR R4 + zip package, see
* `<HealthRecordExportPanel>`)
* 2. Measurements CSV — optional `since`/`until`
* 3. Medications CSV — optional intake-history toggle
* 4. Mood CSV — optional `since`/`until`
* 5. Full JSON Backup — single-file user-scoped dump
* 6. Doctor Report (PDF) — small secondary card at the bottom
* (`<ArztberichtHeroCard>`)
*
* v1.4.37 W7a — the doctor-report card was promoted to the page hero so
* the flagship "PDF for the doctor" flow no longer competes for visual
* weight with the secondary CSV/JSON exports. The remaining four cards
* live under a "Weitere Export-Optionen" sub-heading.
* v1.12 — the health-record export is the headline "data out" path; it
* takes the page hero. The doctor-report PDF is demoted to a small
* secondary card at the bottom of the page — fully functional, just no
* longer the flagship surface it was in v1.4.37 W7a.
*
* Mobile-first: cards stack on `<md`, two-column grid on `>=md`.
*/
@@ -86,20 +88,14 @@ export function ExportSection() {
</p>
</header>
{/* v1.4.37 W7a — Arztbericht hero. The doctor-report PDF is the
flagship "data out" path; the previous grid layout buried it
alongside the four CSV/JSON exports. The hero now sits at the
top of the page; the same generation flow runs underneath. */}
<ArztberichtHeroCard />
{/* v1.7.0 — full health-record export. PDF + FHIR R4 + a combined
zip package, with grouped data-section toggles and a format
switch. Sits above the secondary CSV/JSON cards. */}
{/* v1.12 — full health-record export is now the page hero. PDF +
FHIR R4 + a combined zip package, with grouped data-section
toggles (collapsed by default) and a format switch. This is the
headline "data out" path; it carries the primary visual weight. */}
<HealthRecordExportPanel />
{/* v1.4.37 W7a — secondary export options. The doctor-report
card moved into the hero above, so this group renders the
four remaining destinations under a dedicated sub-heading.
{/* v1.12 — secondary export options. The four CSV/JSON
destinations live under a dedicated sub-heading.
Mobile: single column. Desktop (md+): 2-column grid. */}
<section
aria-labelledby="settings-section-export-other-title"
@@ -118,6 +114,11 @@ export function ExportSection() {
<FullBackupCard />
</div>
</section>
{/* v1.12 — doctor-report PDF, demoted from the former page hero to a
small secondary card at the bottom. Fully functional — the same
`<DoctorReportDialog>` + `/api/doctor-report` flow runs from here. */}
<ArztberichtHeroCard />
</section>
);
}
@@ -12,10 +12,17 @@
* Mood stays default-OFF everywhere (privacy). PDF-only fields (practice
* name, charts) hide when the format is FHIR. The ePA-compat note shows
* for the FHIR + package formats.
*
* v1.12 — this is the export page's hero. It carries the same
* `hero-gradient + glow-purple` treatment as the Insights hero strip so
* the headline "data out" path owns the primary visual weight. The
* grouped data-section toggles live behind a collapsible disclosure,
* collapsed by default, so the panel opens compact instead of as a long
* always-expanded checklist.
*/
import { useState } from "react";
import { Download, FileText, Loader2 } from "lucide-react";
import { useId, useState } from "react";
import { ChevronDown, Download, FileText, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
@@ -24,6 +31,7 @@ import { NativeSelect } from "@/components/ui/native-select";
import { Switch } from "@/components/ui/switch";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
type ExportFormat = "pdf" | "fhir" | "package";
@@ -106,6 +114,10 @@ export function HealthRecordExportPanel() {
const [sections, setSections] = useState<SectionState>(DEFAULT_SECTIONS);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// The included-data checklist is a disclosure, collapsed by default —
// the panel opens compact instead of as a long always-expanded list.
const [includedDataOpen, setIncludedDataOpen] = useState(false);
const includedDataPanelId = useId();
const isPdfLike = format === "pdf" || format === "package";
const isFhirLike = format === "fhir" || format === "package";
@@ -179,15 +191,27 @@ export function HealthRecordExportPanel() {
<section
aria-labelledby="health-record-export-title"
data-testid="health-record-export-panel"
className="bg-card border-border rounded-xl border p-6"
className={cn(
"hero-gradient glow-purple animate-insight-in",
// `isolate` traps the purple glow inside the hero so the shadow
// doesn't leak through the cards below — same trick the Insights
// `<HeroStrip>` uses.
"relative isolate overflow-hidden rounded-xl p-5 sm:p-6",
)}
>
<div className="mb-4 flex items-center gap-2">
<FileText className="text-primary h-5 w-5" aria-hidden="true" />
<h2 id="health-record-export-title" className="text-lg font-semibold">
<div className="mb-2 flex items-center gap-2">
<FileText
className="text-dracula-purple h-5 w-5 shrink-0"
aria-hidden="true"
/>
<h2
id="health-record-export-title"
className="text-xl font-semibold tracking-tight sm:text-2xl"
>
{t("settings.healthRecord.title")}
</h2>
</div>
<p className="text-muted-foreground mb-4 text-sm">
<p className="text-muted-foreground mb-5 max-w-2xl text-sm leading-relaxed">
{t("settings.healthRecord.description")}
</p>
@@ -245,12 +269,39 @@ export function HealthRecordExportPanel() {
</div>
)}
{/* Section groups */}
{/* Section groups — collapsible, collapsed by default so the
panel opens compact. The disclosure follows the inline
aria-expanded/aria-controls pattern used elsewhere in the app
(e.g. the insights recommendation cards). */}
<fieldset className="space-y-3">
<legend className="text-sm font-medium">
<legend className="sr-only">
{t("settings.healthRecord.includedData")}
</legend>
<button
type="button"
data-testid="health-record-included-data-toggle"
aria-expanded={includedDataOpen}
aria-controls={includedDataPanelId}
onClick={() => setIncludedDataOpen((v) => !v)}
className="text-foreground hover:bg-muted/40 focus-visible:ring-ring/50 flex min-h-11 w-full items-center justify-between gap-3 rounded-lg px-1 py-1 text-left text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<span>{t("settings.healthRecord.includedData")}</span>
<ChevronDown
className={cn(
"text-muted-foreground h-4 w-4 shrink-0 transition-transform",
includedDataOpen && "rotate-180",
)}
aria-hidden="true"
/>
</button>
{includedDataOpen && (
<div
id={includedDataPanelId}
data-testid="health-record-included-data-panel"
className="animate-insight-in space-y-3"
style={{ animationDuration: "200ms" }}
>
<div className="border-border space-y-2 rounded-lg border p-3">
<p className="text-xs font-semibold">
{t("settings.healthRecord.groupVitals")}
@@ -372,6 +423,8 @@ export function HealthRecordExportPanel() {
onToggle={() => setIncludeAiSummary((v) => !v)}
/>
)}
</div>
)}
</fieldset>
{isFhirLike && (
@@ -6,6 +6,7 @@ import {
Activity,
AlertCircle,
Download,
HeartPulse,
Link2,
Loader2,
RefreshCw,
@@ -25,6 +26,7 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -60,7 +62,7 @@ interface GlobalServiceAvailability {
// v1.4.19 Phase A5: the redundant in-card status banner is gone — the
// IntegrationStatusPill now owns state + last-sync presentation, and
// the actionable error message is shown inline above the action row.
type IntegrationKey = "withings" | "whoop" | "moodlog";
type IntegrationKey = "withings" | "whoop" | "fitbit" | "moodlog";
type IntegrationState =
| "connected"
| "error_transient"
@@ -196,6 +198,7 @@ export function IntegrationsSection() {
const moodLogEnabled = globalServices?.moodLogGlobal ?? true;
const withingsViewModel = pickStatus(integrationStatus, "withings");
const whoopViewModel = pickStatus(integrationStatus, "whoop");
const fitbitViewModel = pickStatus(integrationStatus, "fitbit");
const moodLogViewModel = pickStatus(integrationStatus, "moodlog");
return (
@@ -220,6 +223,10 @@ export function IntegrationsSection() {
viewModel={withingsViewModel}
/>
<WhoopCard isAuthenticated={isAuthenticated} viewModel={whoopViewModel} />
<FitbitCard
isAuthenticated={isAuthenticated}
viewModel={fitbitViewModel}
/>
{moodLogEnabled && <MoodLogCard viewModel={moodLogViewModel} />}
</section>
);
@@ -1122,6 +1129,458 @@ function WhoopCard({
);
}
// v1.12.0 — Google Health (Fitbit & Pixel) card. Mirrors the WHOOP card: a
// BYO-key credentials form first, then an OAuth connect, then the
// sync/test/disconnect action row + parked-resume banner. Status reads from the
// dedicated /api/fitbit/status (queryKeys.fitbitStatus); the pill/error/parked
// state comes off the cross-integration envelope view-model like WHOOP.
function FitbitCard({
isAuthenticated,
viewModel,
}: {
isAuthenticated: boolean;
viewModel: IntegrationStatusViewModel | undefined;
}) {
const { t } = useTranslations();
const [syncing, setSyncing] = useState(false);
const [syncMsg, setSyncMsg] = useState<string | null>(null);
const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>(
null,
);
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [credsSaving, setCredsSaving] = useState(false);
const [credsMsg, setCredsMsg] = useState<string | null>(null);
const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>(
null,
);
const queryClient = useQueryClient();
const { data: status } = useQuery({
queryKey: queryKeys.fitbitStatus(),
queryFn: async () => {
const res = await fetch("/api/fitbit/status");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as {
connected: boolean;
configured: boolean;
lastSyncedAt?: string | null;
connectedAt?: string;
tokenExpired?: boolean;
backfillCompleted?: boolean;
scope?: string | null;
};
},
enabled: isAuthenticated,
});
const disconnect = useMutation({
mutationFn: async () => {
const res = await fetch("/api/fitbit/disconnect", { method: "POST" });
if (!res.ok) throw new Error("Failed");
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
},
});
// Clear a parked integration via the resume endpoint. The CTA is
// rendered inside the parked banner below; success invalidates both
// the per-card status (so the pill flips back to connected
// immediately) and the cross-integration envelope (so any other view
// picks up the change on its next focus).
const resume = useMutation({
mutationFn: async () => {
const res = await fetch("/api/integrations/fitbit/resume", {
method: "POST",
});
if (!res.ok) throw new Error("Failed");
return (await res.json()).data as {
resumed: boolean;
wasParked: boolean;
};
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
},
});
async function handleSync(fullSync = false) {
setSyncing(true);
setSyncMsg(null);
setSyncMsgType(null);
try {
const res = await fetch("/api/fitbit/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fullSync }),
});
const json = await res.json();
if (res.ok) {
setSyncMsg(
t("settings.fitbitSyncResult", { count: json.data.imported }),
);
setSyncMsgType("success");
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
setSyncMsg(json.error || t("settings.fitbitSyncFailed"));
setSyncMsgType("error");
}
} catch {
setSyncMsg(t("settings.fitbitSyncFailed"));
setSyncMsgType("error");
} finally {
setSyncing(false);
}
}
async function handleSaveCredentials(e: React.FormEvent) {
e.preventDefault();
setCredsSaving(true);
setCredsMsg(null);
setCredsMsgType(null);
try {
const res = await fetch("/api/fitbit/credentials", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientId: clientId.trim(),
clientSecret: clientSecret.trim(),
}),
});
if (res.ok) {
setCredsMsg(t("settings.fitbitCredentialsSaved"));
setCredsMsgType("success");
setClientId("");
setClientSecret("");
queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() });
} else {
try {
const json = await res.json();
setCredsMsg(json.error || t("settings.savingError"));
} catch {
setCredsMsg(t("settings.savingError"));
}
setCredsMsgType("error");
}
} catch {
setCredsMsg(t("common.networkError"));
setCredsMsgType("error");
}
setCredsSaving(false);
}
const pillState: IntegrationPillState = status?.connected
? pillStateFor(viewModel)
: "disconnected";
const pillLastSyncAt =
status?.lastSyncedAt ?? viewModel?.lastSuccessAt ?? null;
const errorMessage =
(pillState === "error" || pillState === "parked") && viewModel?.lastError
? viewModel.lastError
: null;
return (
<div
data-testid="fitbit-card"
className="bg-card border-border rounded-xl border p-6"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<HeartPulse className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("settings.fitbit")}</h2>
<span className="bg-muted text-foreground rounded-full px-2 py-0.5 text-[0.6875rem] font-medium">
{t("settings.fitbitTag")}
</span>
<Badge
variant="outline"
data-testid="fitbit-experimental-badge"
className="border-amber-500/50 text-amber-600 dark:text-amber-400"
>
{t("settings.fitbitExperimentalBadge")}
</Badge>
</div>
<IntegrationStatusPill state={pillState} lastSyncAt={pillLastSyncAt} />
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.fitbitDescription")}
</p>
<p
data-testid="fitbit-experimental-note"
className="text-muted-foreground/80 mt-2 text-xs"
>
{t("settings.fitbitExperimentalNote")}
</p>
<p className="text-muted-foreground/80 mt-2 text-xs">
{t("settings.fitbitOverlapNote")}
</p>
<hr
data-testid="integration-card-divider"
className="border-border/60 mt-4"
/>
<div className="mt-4 space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{/* Parked-integration resume CTA. Surfaces only when the row
state is `parked` (>24h of persistent failures). The button
POSTs to /api/integrations/fitbit/resume which calls
`resumeIntegrationFromPark`; on success the per-card status
invalidates and the pill flips back to connected without a
page refresh. */}
{pillState === "parked" && (
<div
data-testid="fitbit-parked-banner"
className="border-dracula-orange/30 bg-dracula-orange/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<span className="text-dracula-orange min-w-0 break-words text-xs">
{t("settings.integrationPill.parkedReconnect")}
</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => resume.mutate()}
disabled={resume.isPending}
data-testid="fitbit-resume-button"
className="min-h-11"
>
{resume.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Link2 className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.integrationPill.resumeCta")}
</Button>
</div>
)}
{resume.isError && (
<p
role="alert"
className="text-destructive text-xs"
data-testid="fitbit-resume-error"
>
{t("settings.integrationPill.resumeError")}
</p>
)}
{resume.isSuccess && resume.data?.wasParked && (
<p
role="status"
className="text-dracula-green text-xs"
data-testid="fitbit-resume-success"
>
{t("settings.integrationPill.resumeSuccess")}
</p>
)}
<div className="space-y-3">
<h3 className="text-sm font-semibold">
{t("settings.fitbitCredentials")}
</h3>
<p className="text-muted-foreground text-xs">
{t("settings.fitbitCredentialsHelp")}
</p>
<form onSubmit={handleSaveCredentials} className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="fitbit-clientid">
{t("settings.fitbitClientId")}
</Label>
<Input
id="fitbit-clientid"
value={clientId}
onChange={(e) => setClientId(e.target.value)}
placeholder={
status?.configured
? t("settings.fitbitCredentialsSavedPlaceholder")
: t("settings.fitbitClientId")
}
maxLength={200}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="next"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="fitbit-secret">
{t("settings.fitbitClientSecret")}
</Label>
<PasswordInput
id="fitbit-secret"
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
placeholder={
status?.configured
? t("settings.fitbitCredentialsSavedPlaceholderSecret")
: t("settings.fitbitClientSecret")
}
maxLength={200}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="done"
/>
</div>
</div>
<div className="flex justify-end">
<Button
type="submit"
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={
credsSaving || !clientId.trim() || !clientSecret.trim()
}
>
{credsSaving ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Save className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.fitbitSaveCredentials")}
</Button>
</div>
{credsMsg && (
<p
role="alert"
className={`text-sm ${credsMsgType === "success" ? "text-dracula-green" : "text-destructive"}`}
>
{credsMsg}
</p>
)}
</form>
</div>
{status?.connected ? (
<>
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
<Button
variant="outline"
size="sm"
onClick={() => handleSync(false)}
disabled={syncing}
>
{syncing ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.fitbitSync")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={syncing}>
{syncing ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.fitbitFullSync")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.fitbitFullSyncTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.fitbitFullSyncDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => handleSync(true)}>
{t("settings.fitbitSynchronize")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<TestConnectionButton
endpoint="/api/integrations/fitbit/test"
disabled={!status?.connected}
/>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive"
>
<Unlink className="mr-1 h-3.5 w-3.5" />
{t("settings.fitbitDisconnect")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.fitbitDisconnectTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.fitbitDisconnectDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => disconnect.mutate()}
>
{t("settings.fitbitDisconnect")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{status?.backfillCompleted === false && (
<p className="text-muted-foreground text-xs">
{t("settings.fitbitBackfillInProgress")}
</p>
)}
{syncMsg && (
<p
role="alert"
className={`text-sm ${syncMsgType === "success" ? "text-dracula-green" : "text-destructive"}`}
>
{syncMsg}
</p>
)}
</>
) : status?.configured ? (
<Button
variant="outline"
onClick={() => {
window.location.href = "/api/fitbit/connect";
}}
>
<Link2 className="mr-2 h-4 w-4" />
{t("settings.fitbitConnect")}
</Button>
) : (
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-sm">
{t("settings.fitbitNoCredentials")}
</div>
)}
</div>
</div>
);
}
function MoodLogCard({
viewModel,
}: {
@@ -1243,6 +1702,9 @@ function MoodLogCard({
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.moodLogDescription")}
</p>
<p className="text-muted-foreground/80 mt-1 text-[11px] italic">
{t("settings.moodLogDeprecated")}
</p>
{/* v1.4.19 A5 — visual divider matches Withings for consistency
(the maintainer explicitly called the asymmetry out). */}
@@ -94,6 +94,8 @@ const SOURCE_LABEL_KEYS: Record<string, string> = {
// `recovery` ladder for native-vs-derived ordering).
WHOOP: "settings.sections.sources.sourceLabels.WHOOP",
COMPUTED: "settings.sections.sources.sourceLabels.COMPUTED",
// v1.12.0 — Fitbit/Pixel native source.
FITBIT: "settings.sections.sources.sourceLabels.FITBIT",
};
const DEVICE_TYPE_LABEL_KEYS: Record<DeviceType, string> = {
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff. Show more