diff --git a/.planning/ios-coord/v1.12.0-server-to-ios-fitbit-source-heads-up.md b/.planning/ios-coord/v1.12.0-server-to-ios-fitbit-source-heads-up.md new file mode 100644 index 000000000..04b6216b2 --- /dev/null +++ b/.planning/ios-coord/v1.12.0-server-to-ios-fitbit-source-heads-up.md @@ -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`). diff --git a/.planning/ios-coord/v1.12.0-server-to-ios-mood-v2-rated-factors-contract.md b/.planning/ios-coord/v1.12.0-server-to-ios-mood-v2-rated-factors-contract.md new file mode 100644 index 000000000..edd1db5ae --- /dev/null +++ b/.planning/ios-coord/v1.12.0-server-to-ios-mood-v2-rated-factors-contract.md @@ -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. diff --git a/.planning/ios-coord/v1.12.0-server-to-ios-tag-hk-crosstab.md b/.planning/ios-coord/v1.12.0-server-to-ios-tag-hk-crosstab.md new file mode 100644 index 000000000..482f1dfd9 --- /dev/null +++ b/.planning/ios-coord/v1.12.0-server-to-ios-tag-hk-crosstab.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 26d2ddc4a..9c3fcba96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index d342f07b8..22a2ee1e9 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@

Self-hosted health tracker. Weight, blood pressure, glucose, mood, medications.
- 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.

@@ -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 ``, ``, ``, and `` 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 `/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) | @@ -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. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index eee70699e..48133526a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/e2e/mobile-viewport.spec.ts b/e2e/mobile-viewport.spec.ts index af4b3d224..32deb2de9 100644 --- a/e2e/mobile-viewport.spec.ts +++ b/e2e/mobile-viewport.spec.ts @@ -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(); + }); }); diff --git a/e2e/settings-export.spec.ts b/e2e/settings-export.spec.ts index b8150aa4f..d38f04aff 100644 --- a/e2e/settings-export.spec.ts +++ b/e2e/settings-export.spec.ts @@ -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, }) => { diff --git a/e2e/settings-integrations-mobile.spec.ts b/e2e/settings-integrations-mobile.spec.ts index a70235ce0..17f4a1fbb 100644 --- a/e2e/settings-integrations-mobile.spec.ts +++ b/e2e/settings-integrations-mobile.spec.ts @@ -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 diff --git a/e2e/v1427-coach-launch.spec.ts b/e2e/v1427-coach-launch.spec.ts index 0cacdaaa8..3ca8c9604 100644 --- a/e2e/v1427-coach-launch.spec.ts +++ b/e2e/v1427-coach-launch.spec.ts @@ -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 }); + }); }); diff --git a/messages/de.json b/messages/de.json index a7421034d..01f86b281 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 4d483d001..d9a6e01b2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index a2e3a9331..2dc15688b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 3e43c1dbd..b85affb7c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index a1fa85e22..f20349f8f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index eef8da327..7a5ee5662 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/package.json b/package.json index 76d1007dc..7e395df39 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/0117_v1120_fitbit_integration/migration.sql b/prisma/migrations/0117_v1120_fitbit_integration/migration.sql new file mode 100644 index 000000000..d909b7466 --- /dev/null +++ b/prisma/migrations/0117_v1120_fitbit_integration/migration.sql @@ -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; diff --git a/prisma/migrations/0118_v1120_mood_tag_hobbies_nutrition/migration.sql b/prisma/migrations/0118_v1120_mood_tag_hobbies_nutrition/migration.sql new file mode 100644 index 000000000..fe2155038 --- /dev/null +++ b/prisma/migrations/0118_v1120_mood_tag_hobbies_nutrition/migration.sql @@ -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"; diff --git a/prisma/migrations/0119_v1120_mood_rated_factors/migration.sql b/prisma/migrations/0119_v1120_mood_rated_factors/migration.sql new file mode 100644 index 000000000..65b2450e7 --- /dev/null +++ b/prisma/migrations/0119_v1120_mood_rated_factors/migration.sql @@ -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"; diff --git a/prisma/migrations/0120_v1120_mood_factor_family/migration.sql b/prisma/migrations/0120_v1120_mood_factor_family/migration.sql new file mode 100644 index 000000000..ac7544753 --- /dev/null +++ b/prisma/migrations/0120_v1120_mood_factor_family/migration.sql @@ -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"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 358f0c0ad..b0e928cd1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) diff --git a/public/injection-body-front.png b/public/injection-body-front.png new file mode 100644 index 000000000..2464532b2 Binary files /dev/null and b/public/injection-body-front.png differ diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 92c730a50..9b14c2aab 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -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, + }); + }, + ); + results.push(r); + } + // ───── AppSettings table (singleton typically) ───── const settings = await prisma.appSettings.findMany({ select: { diff --git a/src/app/__tests__/insights-b3-wiring.test.ts b/src/app/__tests__/insights-b3-wiring.test.ts index 27a44a02a..1598c3e7c 100644 --- a/src/app/__tests__/insights-b3-wiring.test.ts +++ b/src/app/__tests__/insights-b3-wiring.test.ts @@ -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 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(/ with annotations from the advisor payload", () => { diff --git a/src/app/api/fitbit/callback/route.ts b/src/app/api/fitbit/callback/route.ts new file mode 100644 index 000000000..95b83ce63 --- /dev/null +++ b/src/app/api/fitbit/callback/route.ts @@ -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"); + } +}); diff --git a/src/app/api/fitbit/connect/route.ts b/src/app/api/fitbit/connect/route.ts new file mode 100644 index 000000000..93a30fb5a --- /dev/null +++ b/src/app/api/fitbit/connect/route.ts @@ -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; +}); diff --git a/src/app/api/fitbit/credentials/route.ts b/src/app/api/fitbit/credentials/route.ts new file mode 100644 index 000000000..b2cabfee1 --- /dev/null +++ b/src/app/api/fitbit/credentials/route.ts @@ -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 }); +}); diff --git a/src/app/api/fitbit/disconnect/route.ts b/src/app/api/fitbit/disconnect/route.ts new file mode 100644 index 000000000..96fa88fea --- /dev/null +++ b/src/app/api/fitbit/disconnect/route.ts @@ -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 }); +}); diff --git a/src/app/api/fitbit/status/route.ts b/src/app/api/fitbit/status/route.ts new file mode 100644 index 000000000..ac51d14f1 --- /dev/null +++ b/src/app/api/fitbit/status/route.ts @@ -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, + }); +}); diff --git a/src/app/api/fitbit/sync/route.ts b/src/app/api/fitbit/sync/route.ts new file mode 100644 index 000000000..e99c8873f --- /dev/null +++ b/src/app/api/fitbit/sync/route.ts @@ -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 }); +}); diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index bd612c595..4a944a0d7 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -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 { + 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({ diff --git a/src/app/api/integrations/fitbit/resume/route.ts b/src/app/api/integrations/fitbit/resume/route.ts new file mode 100644 index 000000000..10435516f --- /dev/null +++ b/src/app/api/integrations/fitbit/resume/route.ts @@ -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 }); +}); diff --git a/src/app/api/integrations/fitbit/test/__tests__/route.test.ts b/src/app/api/integrations/fitbit/test/__tests__/route.test.ts new file mode 100644 index 000000000..bef0ba2ba --- /dev/null +++ b/src/app/api/integrations/fitbit/test/__tests__/route.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: 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 { + 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).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).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).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).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).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).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/); + }); +}); diff --git a/src/app/api/integrations/fitbit/test/route.ts b/src/app/api/integrations/fitbit/test/route.ts new file mode 100644 index 000000000..899576742 --- /dev/null +++ b/src/app/api/integrations/fitbit/test/route.ts @@ -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 ` 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]"); +} diff --git a/src/app/api/integrations/moodlog/webhook/route.ts b/src/app/api/integrations/moodlog/webhook/route.ts index d9c7fd2b5..ac2dec883 100644 --- a/src/app/api/integrations/moodlog/webhook/route.ts +++ b/src/app/api/integrations/moodlog/webhook/route.ts @@ -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" } }); diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts index dee8ec44b..128ae54d4 100644 --- a/src/app/api/integrations/status/route.ts +++ b/src/app/api/integrations/status/route.ts @@ -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; +} diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index f0e940154..23b61e8ea 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -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 diff --git a/src/app/api/mood-entries/__tests__/route.test.ts b/src/app/api/mood-entries/__tests__/route.test.ts index d15b8852c..b6831c601 100644 --- a/src/app/api/mood-entries/__tests__/route.test.ts +++ b/src/app/api/mood-entries/__tests__/route.test.ts @@ -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( + "@/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); + }); +}); diff --git a/src/app/api/mood-entries/bulk/__tests__/route.test.ts b/src/app/api/mood-entries/bulk/__tests__/route.test.ts index 489e29c02..6e40a22ec 100644 --- a/src/app/api/mood-entries/bulk/__tests__/route.test.ts +++ b/src/app/api/mood-entries/bulk/__tests__/route.test.ts @@ -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( + "@/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"); + }); +}); diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index c65608b8b..2879727eb 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -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 { }, }); + // 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 }); diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 40a95fe20..e0e0451df 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -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" diff --git a/src/app/api/mood/tags/__tests__/route.test.ts b/src/app/api/mood/tags/__tests__/route.test.ts new file mode 100644 index 000000000..a890bca48 --- /dev/null +++ b/src/app/api/mood/tags/__tests__/route.test.ts @@ -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 } }; + }; + 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); + }); +}); diff --git a/src/app/api/mood/tags/route.ts b/src/app/api/mood/tags/route.ts index 6cf10e667..ba13023ba 100644 --- a/src/app/api/mood/tags/route.ts +++ b/src/app/api/mood/tags/route.ts @@ -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, + }, }, }, }); diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index 87fd0d52c..12553fc69 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -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. */} + + + + } diversityNudge={ {/* The period-over-period delta keys on the systolic series — the canonical BP figure the targets + status surfaces lead with. */} - + {/* v1.12.0 — range pills + period-over-period delta below the chart. */} + - - + {/* v1.12.0 — Einschätzung is the last block on the canonical + metric-detail spine. */} } diff --git a/src/app/insights/bmi/page.tsx b/src/app/insights/bmi/page.tsx index a405931e1..5b74d78dc 100644 --- a/src/app/insights/bmi/page.tsx +++ b/src/app/insights/bmi/page.tsx @@ -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. */} - + {/* v1.12.0 — range pills + period-over-period delta below the chart. */} + diff --git a/src/app/insights/coach/page.tsx b/src/app/insights/coach/page.tsx new file mode 100644 index 000000000..a601d6f9b --- /dev/null +++ b/src/app/insights/coach/page.tsx @@ -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 `` 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 `` (which provides the + * `` 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 `` / ``: + // 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 ( +

+ ( +

{title}

+ )} + leadingHeaderActions={ + + } + /> +
+ ); +} diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index bd35614f5..435c053a2 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -69,16 +69,6 @@ const DailyBriefing = dynamic( loading: () => , }, ); -const CorrelationRow = dynamic( - () => - import("@/components/insights/correlation-row").then((mod) => ({ - default: mod.CorrelationRow, - })), - { - ssr: false, - loading: () => , - }, -); 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 `` 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 (
+ {flags.briefing && ( + + )} + + + + + + + {flags.briefing && } {/* v1.11.3 — anchor the warm-assessments control. It used to float @@ -320,30 +344,6 @@ export default function InsightsPage() { {t("insights.warmAssessments")}
- - - - - - {flags.briefing && ( - - )} - - {flags.correlations && analytics?.correlations && ( - - )} - - ); } diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index becdb4e0a..b2186faba 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -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={ - + primary={ + <> + + + } + statStrip={} diversityNudge={ - + {/* v1.12.0 — range pills + period-over-period delta below the chart. */} + - } - 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). */} + + + {/* v1.12.0 — Einschätzung is the last block on the canonical + metric-detail spine. */} + } + text={status?.text ?? null} + hasProvider={status?.hasProvider ?? false} + updatedAt={status?.updatedAt ?? null} + loading={isStatusLoading} + preparing={status?.preparing ?? false} + /> ); } diff --git a/src/app/insights/sleep/page.tsx b/src/app/insights/sleep/page.tsx index f2a4642c2..116422bc8 100644 --- a/src/app/insights/sleep/page.tsx +++ b/src/app/insights/sleep/page.tsx @@ -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={} diversityNudge={ - - + {/* v1.12.0 — range pills + period-over-period delta below the chart. */} + + {/* diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index b9e4824b5..bce1b93a8 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -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={ + <> + + + + } statStrip={} diversityNudge={ - + {/* v1.12.0 — range pills + period-over-period delta below the chart. */} + @@ -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). */} + + } diff --git a/src/app/page.tsx b/src/app/page.tsx index 6aec28a80..c0635e6b2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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() {

{welcomeText}

+ {/* 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+. */} + {/* v1.4.33 maintainer-item-7 — restore proportional sizing diff --git a/src/components/insights/__tests__/correlation-row.test.tsx b/src/components/insights/__tests__/correlation-row.test.tsx deleted file mode 100644 index 9e5559c66..000000000 --- a/src/components/insights/__tests__/correlation-row.test.tsx +++ /dev/null @@ -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 — `` 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 = () =>
scatter
; - Stub.displayName = "ScatterStub"; - return Stub; - }, -})); - -import { CorrelationRow } from "../correlation-row"; - -function render(node: React.ReactNode, locale: "en" | "de" = "en") { - return renderToStaticMarkup( - {node}, - ); -} - -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("", () => { - it("renders only the ok-status hypothesis cards (insufficient ones are dropped)", () => { - const html = render( - , - ); - 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( - , - ); - 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( - , - ); - 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( - , - ); - 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( - , - ); - expect(html).toBe(""); - }); - - it("renders German copy when locale=de", () => { - const html = render( - , - "de", - ); - expect(html).toContain("nicht kausal"); - }); -}); diff --git a/src/components/insights/__tests__/metric-target-summary.test.tsx b/src/components/insights/__tests__/metric-target-summary.test.tsx index 3ddf78249..10faa398e 100644 --- a/src/components/insights/__tests__/metric-target-summary.test.tsx +++ b/src/components/insights/__tests__/metric-target-summary.test.tsx @@ -154,12 +154,15 @@ const GLUCOSE_DATA: TargetsFixture = { }; describe("", () => { - 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 ``, + // 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("", () => { ); }); - 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 `` 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"); }); diff --git a/src/components/insights/coach-panel/__tests__/coach-drawer-mobile-trays.test.tsx b/src/components/insights/coach-panel/__tests__/coach-drawer-mobile-trays.test.tsx index 186a12705..63831a031 100644 --- a/src/components/insights/coach-panel/__tests__/coach-drawer-mobile-trays.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-drawer-mobile-trays.test.tsx @@ -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 `
); } @@ -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 `
` shell renders at all. + const hasChips = + !!metricSource && + ((metricSource.metrics?.length ?? 0) > 0 || + (metricSource.windows?.length ?? 0) > 0); + const hasProvenance = hasChips || keyValues.length > 0; return (
{safeError}

)} - {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 `
`, 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 && (
{t("insights.coach.evidenceLabel")} -
    - {keyValues.map((kv, idx) => ( -
  • + )} + {keyValues.length > 0 && ( +
      - {/* 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?". */} - - {kv.value} - {kv.unit ? ` ${kv.unit}` : ""} - - {kv.window && ( - - {" "} - ({kv.window}) - - )} - - ))} -
    + {keyValues.map((kv, idx) => ( +
  • + {/* 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?". */} + + {kv.value} + {kv.unit ? ` ${kv.unit}` : ""} + + {kv.window && ( + + {" "} + ({kv.window}) + + )} +
  • + ))} +
+ )} +
)} {/* v1.4.23 H7 — per-message thumbs feedback. Only persisted diff --git a/src/components/insights/coach-panel/mobile-rail-tray.tsx b/src/components/insights/coach-panel/mobile-rail-tray.tsx index a48b75d08..a364c680d 100644 --- a/src/components/insights/coach-panel/mobile-rail-tray.tsx +++ b/src/components/insights/coach-panel/mobile-rail-tray.tsx @@ -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. */} diff --git a/src/components/insights/coach-panel/sources-rail.tsx b/src/components/insights/coach-panel/sources-rail.tsx index 2ba92d15b..49121a348 100644 --- a/src/components/insights/coach-panel/sources-rail.tsx +++ b/src/components/insights/coach-panel/sources-rail.tsx @@ -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) { ); })} - - {/* Medical disclaimer footer (relocated from the composer in - v1.4.22). */} -
-
+ {/* 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. */} ); } diff --git a/src/components/insights/coach-panel/use-resettable-value.ts b/src/components/insights/coach-panel/use-resettable-value.ts new file mode 100644 index 000000000..04904f95b --- /dev/null +++ b/src/components/insights/coach-panel/use-resettable-value.ts @@ -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 `` 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( + controlledValue: T, +): [T, Dispatch>] { + const [value, setValue] = useState(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(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( + previous: T, + incoming: T, +): { reset: true; value: T } | { reset: false } { + return Object.is(previous, incoming) + ? { reset: false } + : { reset: true, value: incoming }; +} diff --git a/src/components/insights/correlation-row.tsx b/src/components/insights/correlation-row.tsx deleted file mode 100644 index 586a41723..000000000 --- a/src/components/insights/correlation-row.tsx +++ /dev/null @@ -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 `` 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 ( -
-
-

- {t("insights.correlationRow.title")} -

-

- {t("insights.correlationRow.subtitle")} -

-
-
1 && "md:grid-cols-2", - )} - > - {okResults.map((result) => ( - - ))} -
-

- {t("insights.correlationRow.disclaimer")} -

-
- ); -} diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx index bd2cea693..a8d391d0d 100644 --- a/src/components/insights/daily-briefing.tsx +++ b/src/components/insights/daily-briefing.tsx @@ -325,7 +325,7 @@ export function DailyBriefing({ {updatedAt && (

{t("insights.heroGenerated", { time: formatRelativeTime(updatedAt, t), diff --git a/src/components/insights/derived/trajectory-forecast-card.tsx b/src/components/insights/derived/trajectory-forecast-card.tsx index bff5eb41b..af1e6cd6c 100644 --- a/src/components/insights/derived/trajectory-forecast-card.tsx +++ b/src/components/insights/derived/trajectory-forecast-card.tsx @@ -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 (

@@ -119,18 +146,21 @@ function CardShell({ } /** CLS-safe skeleton matching the resolved card geometry. */ -function CardSkeleton() { +function CardSkeleton({ compact = false }: { compact?: boolean }) { return (