From c7a0ff214bba9e239c65e4b2c3c1952652f1ff28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 16:04:21 +0200 Subject: [PATCH] chore(release): v1.4.32 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.4.32 — HealthKit Tier 1 wave A: workouts API + list/detail pages + dashboard tile + 5 metric sub-pages; HRV/RESTING_HR moved to cardiovascular; latent workouts-list 500 fixed. --- .planning/round-v1431-closure-report.md | 197 ++++++++ .planning/round-v1432-blocker.md | 116 +++++ CHANGELOG.md | 71 +++ docs/api/openapi.yaml | 327 +++++++++++++ messages/de.json | 118 ++++- messages/en.json | 118 ++++- messages/es.json | 118 ++++- messages/fr.json | 118 ++++- messages/it.json | 118 ++++- messages/pl.json | 118 ++++- package.json | 2 +- src/app/api/workouts/[id]/route.ts | 136 ++++++ .../__tests__/canonical-dedup.test.ts | 112 +++-- src/app/api/workouts/__tests__/detail.test.ts | 199 ++++++++ src/app/api/workouts/route.ts | 109 ++--- src/app/insights/aktive-energie/page.tsx | 31 ++ src/app/insights/hrv/page.tsx | 31 ++ src/app/insights/koerpertemperatur/page.tsx | 30 ++ src/app/insights/ruhepuls/page.tsx | 31 ++ src/app/insights/sauerstoff/page.tsx | 40 ++ src/app/insights/workouts/[id]/page.tsx | 80 ++++ src/app/insights/workouts/page.tsx | 61 +++ src/app/page.tsx | 14 + .../__tests__/recent-workouts-tile.test.tsx | 134 ++++++ .../dashboard/recent-workouts-tile.tsx | 173 +++++++ .../__tests__/workout-detail.test.tsx | 149 ++++++ .../insights/__tests__/workout-list.test.tsx | 71 +++ .../insights/healthkit-metric-page.tsx | 139 ++++++ .../insights/insights-layout-shell.tsx | 12 +- .../insights/insights-tab-strip.tsx | 23 + src/components/insights/workout-detail.tsx | 438 ++++++++++++++++++ src/components/insights/workout-list.tsx | 159 +++++++ .../settings/dashboard-layout-section.tsx | 2 + src/hooks/use-workouts.ts | 160 +++++++ src/lib/__tests__/dashboard-layout.test.ts | 9 + src/lib/dashboard-layout.ts | 21 + src/lib/insights/metric-availability.ts | 19 +- src/lib/insights/sub-page-metric.ts | 11 + src/lib/measurements/categories.ts | 12 +- src/lib/openapi/routes.ts | 116 +++++ src/lib/query-keys.ts | 8 + 41 files changed, 3858 insertions(+), 93 deletions(-) create mode 100644 .planning/round-v1431-closure-report.md create mode 100644 .planning/round-v1432-blocker.md create mode 100644 src/app/api/workouts/[id]/route.ts create mode 100644 src/app/api/workouts/__tests__/detail.test.ts create mode 100644 src/app/insights/aktive-energie/page.tsx create mode 100644 src/app/insights/hrv/page.tsx create mode 100644 src/app/insights/koerpertemperatur/page.tsx create mode 100644 src/app/insights/ruhepuls/page.tsx create mode 100644 src/app/insights/sauerstoff/page.tsx create mode 100644 src/app/insights/workouts/[id]/page.tsx create mode 100644 src/app/insights/workouts/page.tsx create mode 100644 src/components/dashboard/__tests__/recent-workouts-tile.test.tsx create mode 100644 src/components/dashboard/recent-workouts-tile.tsx create mode 100644 src/components/insights/__tests__/workout-detail.test.tsx create mode 100644 src/components/insights/__tests__/workout-list.test.tsx create mode 100644 src/components/insights/healthkit-metric-page.tsx create mode 100644 src/components/insights/workout-detail.tsx create mode 100644 src/components/insights/workout-list.tsx create mode 100644 src/hooks/use-workouts.ts diff --git a/.planning/round-v1431-closure-report.md b/.planning/round-v1431-closure-report.md new file mode 100644 index 000000000..f62d48c2f --- /dev/null +++ b/.planning/round-v1431-closure-report.md @@ -0,0 +1,197 @@ +--- +file: .planning/round-v1431-closure-report.md +purpose: v1.4.31 release closure — operator toggles + insights tab-strip + Coolify auto-deploy fix +created: 2026-05-16 +tag: v1.4.31 +--- + +# v1.4.31 — release closure + +Shipped 2026-05-16 mid-day. Three orthogonal patches in one +release: the operator-side assistant feature-flag matrix that +lets the maintainer carve which model-driven surfaces stay +visible, the root-cause fix for the /insights tab-strip blocking +on mobile, and the GHCR-propagation-race fix for the Coolify +auto-deploy path. + +## Outcome + +- GitHub Release: + . +- Tag `v1.4.31` → `5ea813e20c1f0deb4763e5b1c8c5ecd9c4d9027b` on + `main` (PR #176 squash). +- Sister-repos: + - `healthlog-docs@092dd31` + - `healthlog-landing@a1f2dde` +- GHCR build conclusion: SUCCESS — `1.4.31` + `1.4` + `1` + + `latest` + the `sha-` tags all pushed cleanly, the + v1.4.30 enum extensions stayed consistent across the manifest + list. + +## Commits on develop since v1.4.30.1 + +| SHA | Subject | +|---|---| +| `b68206ec` | feat(settings): add 6 assistant-surface feature flags to AppSettings | +| `61c9a1e7` | feat(api): expose the assistant feature-flag matrix | +| `aeff47af` | feat(api): gate assistant endpoints on the feature-flag matrix | +| `03d0b64b` | feat(insights): gate assistant surfaces on operator feature flags | +| `3b56bac5` | feat(admin): assistant feature-flag toggle panel | +| `e778835c` | i18n: localise the operator-disabled assistant notices | +| `88bd9ddd` | fix(insights): unblock the tab strip during initial mobile load | +| `477e57f1` | ci(image): close the Coolify auto-deploy race against GHCR cache propagation | +| `8e432b31` | chore(ci): catch OpenAPI drift via a pre-commit hook | +| `8f0da1b4` | docs(handoff): lock the assistant feature-flag scope across server-routed and on-device surfaces | +| `00a6b676` | chore(release): v1.4.31 | +| `4876182a` | chore(merge): reconcile main into develop for v1.4.31 release | + +Squashed on `main` at `5ea813e2`; tag `v1.4.31` points there. + +## Full unit suite — green + +- typecheck: pass +- lint: pass +- `pnpm test --run`: 377 files / 4083 passed / 1 skipped (the + legacy skipped spec carries through from v1.4.30.x). +- `pnpm openapi:check`: in sync. + +## Findings closed + +- **Assistant-optional operator toggles.** Migration 0065 adds + six boolean columns to `app_settings`. `getAssistantFlags()` + in `src/lib/feature-flags/index.ts` resolves the master vs + sub-flag rule (master kills every sub-flag) and exposes + `AssistantDisabledError` + the `requireAssistantSurface()` + guard. Server-side every assistant endpoint gates on the + relevant flag and returns 403 + + `meta.errorCode: "assistant.disabled."`. Client-side + the new `useFeatureFlags()` hook (fails open) gates the seven + consumers. Admin panel at `/admin/assistant` with the + dedicated `PUT /api/admin/settings/assistant-flags` endpoint. +- **Insights tab-strip blocking fix.** Three commits in one: + the 8 s `AbortController` on the advisor fetch, the + `React.memo` + `useMemo` pair on the strip + availability + prop, and the `next/dynamic` lazy-load on ``. +- **Coolify auto-deploy race fix.** 90 s sleep before the + webhook trigger in `.github/workflows/docker-publish.yml`. + Root cause + remediation note at + `.planning/round-coolify-auto-deploy-fix-2026-05-16.md`. +- **OpenAPI pre-commit hook.** `.githooks/pre-commit` + + `scripts/install-hooks.sh` + `AGENTS.md` workflow entry. +- **R5 contract lock.** + `.planning/v15-ios-handoff/08-locked-contracts.md` gains §14 + carrying the flag matrix scope rule verbatim from + `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5. + +## Coolify auto-deploy proof — INCONCLUSIVE / NOT PROVEN + +The Coolify auto-deploy fix landed in the workflow, but the v1.4.31 +release itself did NOT exercise the fix. Two issues compound: + +1. **`COOLIFY_WEBHOOK` + `COOLIFY_TOKEN` secrets remain unset on + the repo** — same blocker as v1.4.27 / v1.4.28 / v1.4.29 / + v1.4.29.1 / v1.4.30 / v1.4.30.1. The workflow's `Trigger + Coolify deploy` step warned and skipped (the + `COOLIFY_AUTO_DEPLOY` variable is not set to `on`, so the warn- + and-skip legacy path fires). The 90 s sleep code did not + execute because the step short-circuits before reaching it. + +2. **Manual Coolify deploy via the MCP** triggered twice + (`qryjfzh91pqavvwnromn8nyl` and `m10pf35xy0w1xznz6nmtjnx5`) + both reported `status: finished` within 18 s but + `/api/version` on apps01 continued to report `1.4.30.1`. + Coolify's deploy log reads "Pulling & building required + images" then immediately "Removing old containers / Starting + new application" — the 6 s window between the two suggests + no actual `docker pull` fired against GHCR; Coolify's + internal image-cache treated `:latest` as already-known + (Hypothesis H5 from the root-cause note). + +The fix landed in the workflow IS correct (the 90 s sleep +addresses the GHCR-CDN propagation race documented as H4), but +proving it on v1.4.31 requires the operator to: + +- Set `COOLIFY_WEBHOOK` + `COOLIFY_TOKEN` repo secrets, AND +- Set `COOLIFY_AUTO_DEPLOY=on` repo variable. + +The next tag-build will then exercise the full path. v1.4.31's +own deploy needs the host-side SSH fallback documented at +`.planning/coolify-auto-deploy-howto.md` (this run did not have +SSH access to apps-01 from the implementation environment). + +## Deploy state — BLOCKED on operator action + +- `healthlog.bombeck.io/api/version` → `1.4.30.1` (not yet + v1.4.31). `/privacy` → 200. +- `demo.healthlog.dev/api/version` → `1.4.30.1`. `/privacy` → + 200. + +The release artifacts are all in place: image is on GHCR, +`v1.4.31` tag is on `main`, GitHub Release is published, sister- +repos bumped. The only step left is the operator-side host pull: + +```bash +# apps-01 +ssh apps-01 +docker pull ghcr.io/mbombeck/healthlog:latest +cd /path/to/coolify/healthlog/compose +docker compose up -d --force-recreate --no-deps app +exit + +# edge-01 (pinned to explicit tag) +ssh edge-01 +sed -i.pre-v1431.bak 's|healthlog:1.4.30.1|healthlog:1.4.31|g' \ + /path/to/edge01/healthlog/docker-compose.yml +docker compose pull +docker compose up -d --force-recreate --no-deps app +exit +``` + +After the host-side pull lands, both hosts will report +`1.4.31` via `/api/version` and `/privacy` will stay 200. + +## iOS-contract notes — additive across every change + +- `AppSettings` gains six new boolean columns. All default + `true`; existing rows keep behaving identically. +- `GET /api/feature-flags` is net-new. Pre-v1.4.31 iOS never + calls it and implicitly behaves as if every flag is true. +- `PUT /api/admin/settings/assistant-flags` is net-new admin-only. +- The 403 + `meta.errorCode: "assistant.disabled."` + envelope is additive. Older iOS sees a generic 403; v1.4.31+ + iOS reads the errorCode to render the empty-state copy. +- No Prisma column shape changed for existing iOS-visible + surfaces (Measurement, MoodEntry, MedicationIntakeLog). + +## What's NOT done + +- Coolify auto-deploy proof of fix — blocked on operator-side + secrets configuration. The fix itself is in the workflow. +- apps01 + edge-01 1.4.31 deploy — blocked on operator-side + SSH fallback execution. + +## v1.4.32 scope seed + +Carried forward per +`.planning/v15-strategic-plan.md` §2: + +- HealthKit Tier 1 web surfaces, wave A: workouts end-to-end on + the web + chart cards for 5 of the 10 invisible-but-stored + metrics (HRV, RestingHR, SpO2, BodyTemperature, + ActiveEnergyBurned). + +Plus from v1.4.31 fallout: + +- Operator secrets paste reminder: `COOLIFY_WEBHOOK` / + `COOLIFY_TOKEN` repo secrets + `COOLIFY_AUTO_DEPLOY=on` repo + variable. Until these land, the auto-deploy fix cannot be + proven on a tag-build. + +## Closure complete + +v1.4.31 ships as an additive operator-side patch plus the long- +overdue insights-blocking root-cause fix. The Coolify auto-deploy +fix lands in CI; demonstration awaits operator secrets. The web +side remains under freeze planning (v1.4.34 marker per the +strategic plan), with the iOS-coordinated patch series continuing +into v1.4.32-34. diff --git a/.planning/round-v1432-blocker.md b/.planning/round-v1432-blocker.md new file mode 100644 index 000000000..2ebd73ede --- /dev/null +++ b/.planning/round-v1432-blocker.md @@ -0,0 +1,116 @@ +--- +file: .planning/round-v1432-blocker.md +purpose: v1.4.32 implementation handoff — implementation commits landed, release pipeline remains +created: 2026-05-15 +tag: pending +--- + +# v1.4.32 — implementation complete, release pipeline pending + +All six implementation commits landed on `develop`. Full unit suite +green at 4108 passed / 1 skipped / 0 failed. Pausing before the +release pipeline because the contributor context budget dropped +below the threshold where an unforeseen GHCR / Coolify / SSH error +would have safe room to triage. + +## What is in develop + +| SHA | Subject | +|---|---| +| `8d514ed1` | feat(api): expose workouts list + detail endpoints | +| `beaee579` | feat(insights): workouts list page at /insights/workouts | +| `61969fb0` | feat(insights): workout detail page with HR + route + stats | +| `3f581611` | feat(dashboard): recent workouts tile | +| `1f01b941` | feat(insights): chart cards for HRV, resting HR, SpO2, body temperature, active energy | +| `3da0b38e` | fix(i18n): drop placeholder cta keys on the wave-A empty-state blocks | + +Commit 6 (categorisation map update) was absorbed into Commit 5 per +the briefing's "skip this commit if all 5 already mapped; absorb the +diff into Commit 5" clause — HRV + RESTING_HEART_RATE moved from +`vitals` to `cardiovascular` to align with the iOS handoff brief's +category table. + +## Quality gates run + +- `pnpm typecheck` — green +- `pnpm lint` — green +- `pnpm test --run` — 381 files / 4108 passed / 1 skipped / 0 + failed (full suite) +- OpenAPI pre-commit hook fired on Commit 1 and resynced cleanly + +## What remains + +1. CHANGELOG.md — prepend `## [1.4.32] — — HealthKit Tier 1 + web surfaces wave A` with Marc-Voice ~5-sentence headline plus + Added / Changed / iOS contract sections. ~50-80 lines. Reference + the workout flow + the 5 new sub-pages + the recent-workouts + tile + the i18n footprint. +2. `package.json` version bump `1.4.31` → `1.4.32`. +3. Commit `chore(release): v1.4.32`, push develop, open PR develop + → main. +4. Watch CI green, squash + tag `v1.4.32` on main. +5. Wait GHCR build (the 4-segment workflow fix + 90 s sleep from + v1.4.31 should produce `1.4.32` cleanly). +6. Deploy apps01 + edge-01 via SSH fallback — Coolify auto-deploy + is `off` per maintainer convention. Precise sed targeting + `:1.4.31` → `:1.4.32`, not the broad `[^[:space:]]*` pattern. +7. Verify `/api/version` returns `1.4.32` on both hosts; `/privacy` + returns 200. +8. GitHub Release. +9. Sister-repos: `healthlog-docs` + `healthlog-landing` version + bumps. +10. Closure doc at `.planning/round-v1432-closure-report.md` + (~50-70 lines). + +## Iceberg notes for the resumer + +- The list endpoint `GET /api/workouts` had a latent + Prisma-field-name bug (`distanceMeters` / `energyKcal` — + non-existent columns; tests mocked Prisma with `as never` so the + bug never surfaced). Commit 1 fixed it alongside the picker swap + to v1.4.30 `pickCanonicalWorkoutRows()` and the wire-shape + realignment to the iOS contract names (`distanceM`, + `activeEnergyKcal`, `avgHr`, `maxHr`). +- New widget id `recentWorkouts` defaults to + `visible: true, tileVisible: true`. The maintainer can opt out + via Settings → Dashboard. The tile self-gates on a non-empty + workouts list and renders an Apple-Health-onboarding hint + otherwise. +- The 5 new `CHART_OVERLAY_KEYS` (`hrv`, `restingHr`, + `oxygenSaturation`, `bodyTemperature`, `activeEnergy`) each + carry their own chart-cog popover state slot. +- HRV + RestingHR + SpO2 + ActiveEnergy intentionally have no + empty-state CTA — those four metrics have no manual-entry form; + the Apple Health / Withings ingest is the only path. The + template renders the empty state without a primary action when + `emptyStateCtaType` is null. BodyTemperature is the only one of + the five that surfaces the existing `/measurements?add=BODY_TEMPERATURE` + CTA. +- The `` scaffold is the single source for + the 5 sub-pages. Adding metric six is a four-line page module + + a CHART_OVERLAY_KEYS entry + an i18n block + an + `InsightMetric` enum addition. +- iOS contract: every change is additive on the wire. The new + `GET /api/workouts/{id}` is net-new; the list endpoint's response + field renames (`distanceM` instead of `distanceMeters`) are + forward-looking — iOS clients didn't consume the list endpoint + before v1.4.32 because it 500'd in production from the + Prisma-field bug, so there is no pre-v1.4.32 contract to honour. +- Insights tab strip now carries six new pills (workouts + the 5 + HK metrics). The pills self-gate on data presence; brand-new + accounts see the seven pre-existing pills only. + +## Decision log + +- Slug for active-energy sub-page chosen as `aktive-energie` (with + the hyphen) to keep Marc-Voice German URL hygiene; the + underscore-free hyphenated form matches Next.js routing + convention. +- The HR-chart slot on the workout detail page renders a graceful + unavailable notice for v1.4.32 — per-second HR samples are not + persisted on the Workout row today; surfacing them is a v1.5.x + consumer of the existing Measurement-batch-side HR series. +- The route preview is rendered as an inline SVG polyline rather + than pulling in MapLibre / Leaflet — keeps the v1.4.32 dependency + footprint zero; the third-party map widget rides v1.5.x along + with the HR-chart wire-up. diff --git a/CHANGELOG.md b/CHANGELOG.md index 776751edc..e027b2c65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,76 @@ # Changelog +## [1.4.32] — 2026-05-17 — HealthKit Tier 1 wave A + +First public surface wave for the HealthKit Tier 1 metrics that +the iOS contributor brief locked in for v1.5. The headline item +is a workouts flow that lands end-to-end on the web: a list page +at `/insights/workouts`, a detail page with route preview and +summary stats, and a dashboard tile that surfaces the three most +recent sessions. Alongside the workouts surface arrives a family +of five new metric sub-pages — HRV, resting heart rate, blood +oxygen, body temperature, and active energy — each carried by a +shared scaffold so adding the next HealthKit metric is a four-line +page module. The release also cleans up two latent issues uncovered +during the wave-A audit: the workouts list endpoint had a Prisma +field-name bug that would have produced a 500 the moment a real +client called it, and HRV plus resting heart rate were sitting +in the `vitals` insight bucket where they did not belong. + +### Added + +- **Workouts API.** `GET /api/workouts` returns a paginated list + shaped to the iOS contract (`distanceM`, `activeEnergyKcal`, + `avgHr`, `maxHr`) using the v1.4.30 canonical-row picker; + `GET /api/workouts/{id}` returns a single workout with the same + field shape. Both endpoints honour `requireAuth()` and respect + the existing per-user isolation guarantees. +- **`/insights/workouts` list page.** Browsable workout archive + with newest-first ordering, type-filter pills, and an + Apple-Health-onboarding hint when the list is empty. +- **`/insights/workouts/[id]` detail page.** Per-workout view + with summary tiles (duration, distance, energy, average and + max heart rate), an inline-SVG route preview when GPS samples + are present, and a graceful-unavailable notice for per-second + HR samples. +- **Dashboard recent-workouts tile.** New widget id + `recentWorkouts` defaults to visible and surfaces the three + most recent sessions on the home page. Self-gates on a + non-empty list and renders an Apple-Health-onboarding hint + otherwise. +- **Five HealthKit metric sub-pages.** New pages at + `/insights/hrv`, `/insights/ruhepuls`, `/insights/sauerstoffsaettigung`, + `/insights/koerpertemperatur`, and `/insights/aktive-energie`, + each rendered through the shared `` + scaffold with its own chart-cog popover state slot. Body + temperature surfaces the existing manual-entry CTA; the other + four intentionally render their empty state without a primary + action because the Apple Health / Withings ingest is the only + path for those metrics. +- **Insights tab-strip pills.** Six new pills (workouts plus the + five HealthKit metrics) join the strip and self-gate on data + presence, so brand-new accounts still see the seven pre-existing + pills only. + +### Changed + +- **HRV + resting HR realigned to the cardiovascular bucket.** + Both metrics moved out of `vitals` into `cardiovascular` to + match the iOS handoff brief's category table. The insight + bucket map, the per-metric status card grouping, and the + comprehensive narration all read the new placement; no client + contract changes because the bucket only drives in-app + grouping. + +### Fixed + +- **Latent 500 on workouts list endpoint.** The list query + referenced two non-existent Prisma columns (`distanceMeters` + and `energyKcal`); tests mocked Prisma with `as never` so the + bug never surfaced in CI. The endpoint now uses the v1.4.30 + `pickCanonicalWorkoutRows()` helper and projects the iOS + contract field shape end-to-end. + ## [1.4.31] — 2026-05-16 — Operator toggles + insights tab-strip + Coolify auto-deploy fix Three orthogonal patches in one release. The biggest item is a diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e88ef4000..5754fbd42 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -724,6 +724,23 @@ components: - data - error type: object + GetWorkoutResponse: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/WorkoutDetailResponse" + error: + type: "null" + meta: + additionalProperties: false + properties: + requestId: + type: string + type: object + required: + - data + - error + type: object InsightsComprehensiveResponse: additionalProperties: false description: AI-generated insights bundle. Strict-schema validated server-side; Coach-routed when the insight surface @@ -831,6 +848,23 @@ components: - data - error type: object + ListWorkoutsResponse: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/WorkoutListResponse" + error: + type: "null" + meta: + additionalProperties: false + properties: + requestId: + type: string + type: object + required: + - data + - error + type: object LoginResponse: additionalProperties: false properties: @@ -1503,6 +1537,221 @@ components: - skipped - entries type: object + WorkoutDetailResponse: + additionalProperties: false + properties: + activeEnergyKcal: + anyOf: + - type: number + - type: "null" + avgHr: + anyOf: + - maximum: 9007199254740991 + minimum: -9007199254740991 + type: integer + - type: "null" + canonicalId: + type: string + distanceM: + anyOf: + - type: number + - type: "null" + durationSec: + maximum: 9007199254740991 + minimum: 0 + type: integer + elevationM: + anyOf: + - type: number + - type: "null" + endedAt: + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + type: string + externalId: + anyOf: + - type: string + - type: "null" + id: + type: string + maxHr: + anyOf: + - maximum: 9007199254740991 + minimum: -9007199254740991 + type: integer + - type: "null" + metadata: + anyOf: + - {} + - type: "null" + minHr: + anyOf: + - maximum: 9007199254740991 + minimum: -9007199254740991 + type: integer + - type: "null" + pauseDurationSec: + anyOf: + - maximum: 9007199254740991 + minimum: -9007199254740991 + type: integer + - type: "null" + route: + anyOf: + - $ref: "#/components/schemas/WorkoutRouteGeometry" + - type: "null" + source: + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + type: string + sportType: + type: string + startedAt: + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + type: string + stepCount: + anyOf: + - maximum: 9007199254740991 + minimum: -9007199254740991 + type: integer + - type: "null" + required: + - id + - sportType + - startedAt + - endedAt + - durationSec + - distanceM + - activeEnergyKcal + - avgHr + - maxHr + - minHr + - stepCount + - elevationM + - pauseDurationSec + - source + - externalId + - metadata + - route + - canonicalId + type: object + WorkoutListEntry: + additionalProperties: false + properties: + activeEnergyKcal: + anyOf: + - type: number + - type: "null" + avgHr: + anyOf: + - maximum: 9007199254740991 + minimum: -9007199254740991 + type: integer + - type: "null" + distanceM: + anyOf: + - type: number + - type: "null" + durationSec: + maximum: 9007199254740991 + minimum: 0 + type: integer + endedAt: + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + type: string + externalId: + anyOf: + - type: string + - type: "null" + id: + type: string + maxHr: + anyOf: + - maximum: 9007199254740991 + minimum: -9007199254740991 + type: integer + - type: "null" + source: + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + type: string + sportType: + type: string + startedAt: + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + type: string + required: + - id + - sportType + - startedAt + - endedAt + - durationSec + - distanceM + - activeEnergyKcal + - avgHr + - maxHr + - source + - externalId + type: object + WorkoutListResponse: + additionalProperties: false + properties: + meta: + additionalProperties: false + properties: + droppedDuplicates: + maximum: 9007199254740991 + minimum: 0 + type: integer + limit: + exclusiveMinimum: 0 + maximum: 9007199254740991 + type: integer + offset: + maximum: 9007199254740991 + minimum: 0 + type: integer + total: + maximum: 9007199254740991 + minimum: 0 + type: integer + required: + - total + - limit + - offset + - droppedDuplicates + type: object + workouts: + items: + $ref: "#/components/schemas/WorkoutListEntry" + type: array + required: + - workouts + - meta + type: object + WorkoutRouteGeometry: + additionalProperties: false + properties: + geometry: {} + sampleTimestamps: + anyOf: + - items: + type: string + type: array + - type: "null" + required: + - geometry + - sampleTimestamps + type: object securitySchemes: bearerAuth: bearerFormat: hlk_<64hex> @@ -2387,6 +2636,54 @@ paths: summary: Delete measurements by external ID (iOS deletion-sync) tags: - Measurements + /api/workouts: + get: + description: "Paginated workout list with cross-source canonical-row dedup. The picker reads the per-user + source-priority ladder and buckets by `(startedAt 5 min slot, sportType)` — twin workouts (Apple Watch + + Withings ScanWatch) collapse to a single canonical row per cluster. Field names mirror the iOS handoff contract: + `distanceM`, `activeEnergyKcal`, `avgHr`, `maxHr`." + parameters: + - in: query + name: limit + schema: + maximum: 200 + minimum: 1 + type: integer + - in: query + name: offset + schema: + maximum: 9007199254740991 + minimum: 0 + type: integer + - in: query + name: since + schema: + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + type: string + - in: query + name: until + schema: + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + type: string + - in: query + name: sportType + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ListWorkoutsResponse" + description: Canonical workout page. + "401": *a1 + "422": *a2 + "429": *a3 + summary: List workouts (v1.4.32) + tags: + - Measurements /api/workouts/batch: post: description: Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up @@ -2570,6 +2867,36 @@ paths: summary: Typed workout batch ingest (v1.4.25 W16b) tags: - Measurements + /api/workouts/{id}: + get: + description: Single-workout envelope. Owns the optional `WorkoutRoute` GeoJSON geometry + `canonicalId` pointer that + resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Cross-user rows + surface as 404 (existence channel sealed). + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/GetWorkoutResponse" + description: Workout detail. + "401": *a1 + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + description: Workout not found (or owned by another user). + "422": *a2 + "429": *a3 + summary: Workout detail (v1.4.32) + tags: + - Measurements servers: - description: Production url: https://healthlog.bombeck.io diff --git a/messages/de.json b/messages/de.json index d33d35a17..57f95616c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -258,7 +258,16 @@ "layoutChartColumn": "Chart", "emptyTitle": "Dein Dashboard ist leer", "emptyDescription": "Erfasse den ersten Messwert oder Stimmungseintrag. Kacheln und Charts erscheinen automatisch, sobald Daten vorhanden sind.", - "emptyAddMeasurement": "Ersten Messwert erfassen" + "emptyAddMeasurement": "Ersten Messwert erfassen", + "recentWorkouts": { + "title": "Letzte Workouts", + "viewAll": "Alle anzeigen", + "loading": "Workouts werden geladen…", + "empty": { + "title": "Noch keine Workouts", + "cta": "Öffne die Apple-Health-App auf dem iPhone, damit Workouts hier erscheinen." + } + } }, "measurements": { "title": "Messungen", @@ -1103,7 +1112,58 @@ "navMedication": "Medikamente", "navBmi": "BMI", "navSleep": "Schlaf", + "navHrv": "HRV", + "navRestingHr": "Ruhepuls", + "navOxygenSaturation": "Sauerstoff", + "navBodyTemperature": "Temperatur", + "navActiveEnergy": "Aktive Energie", "navAriaLabel": "Zu Abschnitt springen", + "hrv": { + "title": "HRV", + "description": "Herzratenvariabilität im Verlauf — dein Erholungssignal des autonomen Nervensystems, nächtlich von Apple Watch und kompatiblen Wearables erfasst.", + "chartTitle": "Herzratenvariabilität", + "emptyState": { + "title": "Noch keine HRV-Werte", + "description": "HRV-Werte erscheinen hier, sobald die Apple Watch (oder ein kompatibles Withings-Gerät) nächtliche Messungen synchronisiert." + } + }, + "restingHr": { + "title": "Ruhepuls", + "description": "Die tägliche Grundlinie deines Herzschlags — unterschieden vom Spot-Puls. Apple Health liefert einen Wert pro Tag.", + "chartTitle": "Ruhepuls", + "emptyState": { + "title": "Noch kein Ruhepuls", + "description": "Der Ruhepuls erscheint, sobald die Apple Watch oder ein kompatibles Withings-Gerät einen ruhigen Tag durchgetragen hat." + } + }, + "oxygenSaturation": { + "title": "Sauerstoffsättigung", + "description": "Blutsauerstoff (SpO₂) — klinischer Normalbereich 95–100 %; Zielzonen sind am Chart hinterlegt.", + "chartTitle": "Sauerstoffsättigung", + "emptyState": { + "title": "Noch keine SpO₂-Werte", + "description": "SpO₂-Werte erscheinen, sobald ein kompatibles Wearable (Apple Watch, Withings ScanWatch) eine Messung erfasst." + } + }, + "bodyTemperature": { + "title": "Körpertemperatur", + "description": "Verlauf der Körperkerntemperatur in Celsius. Hauttemperaturwerte von Withings laufen über eine separate Metrik.", + "chartTitle": "Körpertemperatur", + "emptyState": { + "title": "Noch keine Körpertemperatur", + "description": "Erfasse eine Körpertemperatur oder lass dein Wearable einen Wert synchronisieren — der Verlauf erscheint dann hier.", + "cta": "Körpertemperatur erfassen" + } + }, + "activeEnergy": { + "title": "Aktive Energie", + "description": "Tägliche aktive-Energie-Summe in Kilokalorien — eine Zeile pro Tag, aus Apple Health oder Withings-Aktivität.", + "chartTitle": "Aktive Energie", + "emptyState": { + "title": "Noch keine Aktive-Energie-Werte", + "description": "Aktive-Energie-Summen erscheinen hier, sobald Apple Health oder ein Withings-Aktivitätsgerät einen Tagesumfang synchronisiert." + } + }, "subPage": { "blutdruckDescription": "Dein systolisch-/diastolisch-Verlauf mit altersangepassten Zielbereichen und einer textbasierten KI-Einschätzung.", "gewichtDescription": "Gewichtsverlauf gegen das aus deiner Größe abgeleitete gesunde Band, mit KI-Einschätzung.", @@ -1202,6 +1262,62 @@ "description": "Sobald Schlafdaten vorliegen (manuell oder via Withings / Apple Health), erscheint hier der nächtliche Stadien-Verlauf.", "cta": "Withings verbinden" } + }, + "navWorkouts": "Workouts", + "workouts": { + "title": "Workouts", + "description": "Letzte Läufe, Touren, Spaziergänge und Krafteinheiten — quellenübergreifend entdoppelt zwischen Apple Watch und Withings.", + "emptyState": { + "title": "Noch keine Workouts", + "description": "Öffne die Apple-Health-App auf dem iPhone, damit Workouts hier synchronisiert werden. Withings-Aktivitätsimport pflegt Geh- und Radtouren mit ein." + }, + "row": { + "duration": "Dauer", + "distance": "Distanz", + "energy": "Aktive Energie" + }, + "detail": { + "backToList": "Zurück zu Workouts", + "statsTitle": "Werte", + "statsDuration": "Dauer", + "statsDistance": "Distanz", + "statsActiveEnergy": "Aktive Energie", + "statsAvgHr": "Ø Herzfrequenz", + "statsMaxHr": "Max. Herzfrequenz", + "statsMinHr": "Min. Herzfrequenz", + "statsStepCount": "Schritte", + "statsElevation": "Höhenmeter", + "statsPace": "Pace", + "statsSource": "Quelle", + "routeTitle": "Strecke", + "routeUnavailable": "Für dieses Workout liegt keine GPS-Strecke vor.", + "hrChartTitle": "Herzfrequenz", + "hrChartUnavailable": "Sekundengenaue Herzfrequenz-Werte werden für dieses Workout nicht gespeichert.", + "notFound": "Workout nicht gefunden.", + "loading": "Workout wird geladen…" + }, + "sport": { + "walking": "Gehen", + "running": "Laufen", + "cycling": "Radfahren", + "hiking": "Wandern", + "swimming": "Schwimmen", + "rowing": "Rudern", + "elliptical": "Crosstrainer", + "stairClimber": "Stepper", + "yoga": "Yoga", + "mindAndBody": "Mind and Body", + "strength": "Kraft", + "hiit": "HIIT", + "dance": "Tanz", + "golf": "Golf", + "tennis": "Tennis", + "basketball": "Basketball", + "soccer": "Fußball", + "crossTraining": "Cross-Training", + "mixedCardio": "Mixed Cardio", + "other": "Sonstiges" + } } }, "thresholds": { diff --git a/messages/en.json b/messages/en.json index 2d2e03d76..effd8cddd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -258,7 +258,16 @@ "layoutChartColumn": "Chart", "emptyTitle": "Your dashboard is empty", "emptyDescription": "Log your first measurement or mood entry. Tiles and charts appear automatically as data lands.", - "emptyAddMeasurement": "Add your first measurement" + "emptyAddMeasurement": "Add your first measurement", + "recentWorkouts": { + "title": "Recent workouts", + "viewAll": "View all", + "loading": "Loading workouts…", + "empty": { + "title": "No workouts yet", + "cta": "Open Apple Health on your iPhone to sync workouts here." + } + } }, "measurements": { "title": "Measurements", @@ -1103,7 +1112,58 @@ "navMedication": "Medication", "navBmi": "BMI", "navSleep": "Sleep", + "navHrv": "HRV", + "navRestingHr": "Resting HR", + "navOxygenSaturation": "Oxygen", + "navBodyTemperature": "Temperature", + "navActiveEnergy": "Active energy", "navAriaLabel": "Skip to section", + "hrv": { + "title": "HRV", + "description": "Heart-rate variability over time — your nervous-system recovery signal, captured during sleep by Apple Watch and other compatible wearables.", + "chartTitle": "Heart-rate variability", + "emptyState": { + "title": "No HRV samples yet", + "description": "HRV samples land here once Apple Watch (or a compatible Withings device) starts syncing nightly readings." + } + }, + "restingHr": { + "title": "Resting heart rate", + "description": "The daily floor your heart settles at — distinct from the spot pulse samples on the pulse page. Apple Health surfaces one value per day.", + "chartTitle": "Resting heart rate", + "emptyState": { + "title": "No resting heart rate yet", + "description": "Resting heart rate arrives once Apple Watch or a compatible Withings device has worn through one quiet day." + } + }, + "oxygenSaturation": { + "title": "Oxygen saturation", + "description": "Blood oxygen (SpO₂) — clinical normal is 95–100 %, with target zones shaded on the chart.", + "chartTitle": "Oxygen saturation", + "emptyState": { + "title": "No SpO₂ samples yet", + "description": "SpO₂ samples land here once a compatible wearable (Apple Watch, Withings ScanWatch) captures a reading." + } + }, + "bodyTemperature": { + "title": "Body temperature", + "description": "Core body temperature trend — Celsius. Skin-temperature readings from Withings live on a separate metric.", + "chartTitle": "Body temperature", + "emptyState": { + "title": "No body temperature yet", + "description": "Log a body-temperature reading or let your wearable sync one, and the trend will appear here.", + "cta": "Log body temperature" + } + }, + "activeEnergy": { + "title": "Active energy", + "description": "Daily active-energy total in kilocalories — one row per day, sourced from Apple Health or Withings activity.", + "chartTitle": "Active energy", + "emptyState": { + "title": "No active-energy data yet", + "description": "Active-energy totals land here once Apple Health or a Withings activity device syncs a day's worth of movement." + } + }, "subPage": { "blutdruckDescription": "Your systolic / diastolic trend with age-personalised target zones and a written AI assessment.", "gewichtDescription": "Weight trend mapped against your height-derived healthy band, plus an AI assessment.", @@ -1202,6 +1262,62 @@ "description": "Once sleep data lands (manual or via Withings / Apple Health), the per-night stage breakdown will appear here.", "cta": "Connect Withings" } + }, + "navWorkouts": "Workouts", + "workouts": { + "title": "Workouts", + "description": "Recent runs, rides, walks and strength sessions, deduped across Apple Watch and Withings sources.", + "emptyState": { + "title": "No workouts yet", + "description": "Open the Apple Health app on your iPhone to sync workouts here. Withings activity import keeps walking and cycling sessions flowing in too." + }, + "row": { + "duration": "Duration", + "distance": "Distance", + "energy": "Active energy" + }, + "detail": { + "backToList": "Back to workouts", + "statsTitle": "Stats", + "statsDuration": "Duration", + "statsDistance": "Distance", + "statsActiveEnergy": "Active energy", + "statsAvgHr": "Average HR", + "statsMaxHr": "Max HR", + "statsMinHr": "Min HR", + "statsStepCount": "Steps", + "statsElevation": "Elevation", + "statsPace": "Pace", + "statsSource": "Source", + "routeTitle": "Route", + "routeUnavailable": "No GPS route recorded for this workout.", + "hrChartTitle": "Heart rate", + "hrChartUnavailable": "Per-second heart-rate samples are not stored for this workout.", + "notFound": "Workout not found.", + "loading": "Loading workout…" + }, + "sport": { + "walking": "Walking", + "running": "Running", + "cycling": "Cycling", + "hiking": "Hiking", + "swimming": "Swimming", + "rowing": "Rowing", + "elliptical": "Elliptical", + "stairClimber": "Stair climber", + "yoga": "Yoga", + "mindAndBody": "Mind and body", + "strength": "Strength", + "hiit": "HIIT", + "dance": "Dance", + "golf": "Golf", + "tennis": "Tennis", + "basketball": "Basketball", + "soccer": "Soccer", + "crossTraining": "Cross training", + "mixedCardio": "Mixed cardio", + "other": "Other" + } } }, "thresholds": { diff --git a/messages/es.json b/messages/es.json index 53ceb97b6..b9a2f3b83 100644 --- a/messages/es.json +++ b/messages/es.json @@ -258,7 +258,16 @@ "layoutChartColumn": "Gráfico", "emptyTitle": "Your dashboard is empty", "emptyDescription": "Log your first measurement or mood entry. Tiles and charts appear automatically as data lands.", - "emptyAddMeasurement": "Add your first measurement" + "emptyAddMeasurement": "Add your first measurement", + "recentWorkouts": { + "title": "Entrenamientos recientes", + "viewAll": "Ver todos", + "loading": "Cargando entrenamientos…", + "empty": { + "title": "Aún no hay entrenamientos", + "cta": "Abre la app Apple Salud en el iPhone para sincronizar los entrenamientos." + } + } }, "measurements": { "title": "Measurements", @@ -1103,7 +1112,58 @@ "navMedication": "Medicamento", "navBmi": "IMC", "navSleep": "Sueño", + "navHrv": "VFC", + "navRestingHr": "FC reposo", + "navOxygenSaturation": "Oxígeno", + "navBodyTemperature": "Temperatura", + "navActiveEnergy": "Energía activa", "navAriaLabel": "Skip to section", + "hrv": { + "title": "VFC", + "description": "Variabilidad de la frecuencia cardíaca a lo largo del tiempo — señal de recuperación del sistema nervioso, captada por la noche por Apple Watch y otros wearables compatibles.", + "chartTitle": "Variabilidad de la frecuencia cardíaca", + "emptyState": { + "title": "Aún no hay valores de VFC", + "description": "Los valores de VFC aparecerán aquí en cuanto Apple Watch (o un dispositivo Withings compatible) sincronice las mediciones nocturnas." + } + }, + "restingHr": { + "title": "FC en reposo", + "description": "El nivel basal diario del corazón — distinto del pulso puntual de la página de pulso. Apple Salud aporta un valor al día.", + "chartTitle": "Frecuencia cardíaca en reposo", + "emptyState": { + "title": "Aún no hay FC en reposo", + "description": "La FC en reposo aparece cuando Apple Watch o un Withings compatible ha llevado puesto un día tranquilo." + } + }, + "oxygenSaturation": { + "title": "Saturación de oxígeno", + "description": "Oxígeno en sangre (SpO₂) — rango clínico normal 95–100 %, con zonas objetivo sombreadas en el gráfico.", + "chartTitle": "Saturación de oxígeno", + "emptyState": { + "title": "Aún no hay muestras de SpO₂", + "description": "Las muestras de SpO₂ llegan aquí en cuanto un wearable compatible (Apple Watch, Withings ScanWatch) capture una lectura." + } + }, + "bodyTemperature": { + "title": "Temperatura corporal", + "description": "Tendencia de temperatura corporal central en grados Celsius. La temperatura cutánea de Withings vive en una métrica distinta.", + "chartTitle": "Temperatura corporal", + "emptyState": { + "title": "Aún no hay temperatura corporal", + "description": "Registra una lectura o deja que tu wearable sincronice una — la tendencia aparecerá aquí.", + "cta": "Registrar temperatura corporal" + } + }, + "activeEnergy": { + "title": "Energía activa", + "description": "Total diario de energía activa en kilocalorías — una fila por día, desde Apple Salud o la actividad de Withings.", + "chartTitle": "Energía activa", + "emptyState": { + "title": "Aún no hay datos de energía activa", + "description": "Los totales de energía activa aparecerán aquí en cuanto Apple Salud o un dispositivo de actividad Withings sincronice un día completo." + } + }, "subPage": { "blutdruckDescription": "Your systolic / diastolic trend with age-personalised target zones and a written AI assessment.", "gewichtDescription": "Weight trend mapped against your height-derived healthy band, plus an AI assessment.", @@ -1202,6 +1262,62 @@ "description": "Una vez disponibles los datos de sueño (manual o vía Withings / Apple Health), aquí aparecerá el desglose por fases.", "cta": "Conectar Withings" } + }, + "navWorkouts": "Entrenamientos", + "workouts": { + "title": "Entrenamientos", + "description": "Carreras, salidas en bici, paseos y sesiones de fuerza recientes, deduplicados entre Apple Watch y Withings.", + "emptyState": { + "title": "Aún no hay entrenamientos", + "description": "Abre la app Apple Salud en el iPhone para sincronizar tus entrenamientos. La importación de actividad de Withings también incorpora caminatas y rutas en bici." + }, + "row": { + "duration": "Duración", + "distance": "Distancia", + "energy": "Energía activa" + }, + "detail": { + "backToList": "Volver a entrenamientos", + "statsTitle": "Estadísticas", + "statsDuration": "Duración", + "statsDistance": "Distancia", + "statsActiveEnergy": "Energía activa", + "statsAvgHr": "FC media", + "statsMaxHr": "FC máx.", + "statsMinHr": "FC mín.", + "statsStepCount": "Pasos", + "statsElevation": "Desnivel", + "statsPace": "Ritmo", + "statsSource": "Fuente", + "routeTitle": "Ruta", + "routeUnavailable": "No se registró una ruta GPS para este entrenamiento.", + "hrChartTitle": "Frecuencia cardíaca", + "hrChartUnavailable": "Las muestras de frecuencia cardíaca por segundo no se almacenan para este entrenamiento.", + "notFound": "Entrenamiento no encontrado.", + "loading": "Cargando entrenamiento…" + }, + "sport": { + "walking": "Caminar", + "running": "Correr", + "cycling": "Bici", + "hiking": "Senderismo", + "swimming": "Natación", + "rowing": "Remo", + "elliptical": "Elíptica", + "stairClimber": "Escalera", + "yoga": "Yoga", + "mindAndBody": "Mente y cuerpo", + "strength": "Fuerza", + "hiit": "HIIT", + "dance": "Baile", + "golf": "Golf", + "tennis": "Tenis", + "basketball": "Baloncesto", + "soccer": "Fútbol", + "crossTraining": "Entreno cruzado", + "mixedCardio": "Cardio mixto", + "other": "Otro" + } } }, "thresholds": { diff --git a/messages/fr.json b/messages/fr.json index 96bab27c1..9ad6defc2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -258,7 +258,16 @@ "layoutChartColumn": "Graphique", "emptyTitle": "Your dashboard is empty", "emptyDescription": "Log your first measurement or mood entry. Tiles and charts appear automatically as data lands.", - "emptyAddMeasurement": "Add your first measurement" + "emptyAddMeasurement": "Add your first measurement", + "recentWorkouts": { + "title": "Entraînements récents", + "viewAll": "Tout voir", + "loading": "Chargement des entraînements…", + "empty": { + "title": "Aucun entraînement", + "cta": "Ouvre l'app Apple Santé sur ton iPhone pour synchroniser les entraînements." + } + } }, "measurements": { "title": "Measurements", @@ -1103,7 +1112,58 @@ "navMedication": "Médicament", "navBmi": "IMC", "navSleep": "Sommeil", + "navHrv": "VFC", + "navRestingHr": "FC repos", + "navOxygenSaturation": "Oxygène", + "navBodyTemperature": "Température", + "navActiveEnergy": "Énergie active", "navAriaLabel": "Skip to section", + "hrv": { + "title": "VFC", + "description": "Variabilité de la fréquence cardiaque dans le temps — signal de récupération du système nerveux, capté la nuit par l'Apple Watch et d'autres wearables compatibles.", + "chartTitle": "Variabilité de la fréquence cardiaque", + "emptyState": { + "title": "Aucune mesure de VFC", + "description": "Les mesures de VFC apparaîtront ici dès que l'Apple Watch (ou un appareil Withings compatible) synchronisera des relevés nocturnes." + } + }, + "restingHr": { + "title": "FC au repos", + "description": "La fréquence cardiaque de base sur la journée — distincte des pointages ponctuels de la page Pulse. Apple Santé fournit une valeur par jour.", + "chartTitle": "Fréquence cardiaque au repos", + "emptyState": { + "title": "Aucune FC au repos", + "description": "La FC au repos arrive dès que l'Apple Watch ou un appareil Withings compatible a porté une journée calme." + } + }, + "oxygenSaturation": { + "title": "Saturation en oxygène", + "description": "Oxygène sanguin (SpO₂) — plage clinique normale 95–100 %, avec zones cibles ombrées sur le graphique.", + "chartTitle": "Saturation en oxygène", + "emptyState": { + "title": "Aucune mesure SpO₂", + "description": "Les mesures SpO₂ apparaîtront dès qu'un wearable compatible (Apple Watch, Withings ScanWatch) en capturera une." + } + }, + "bodyTemperature": { + "title": "Température corporelle", + "description": "Tendance de température corporelle centrale en Celsius. La température cutanée Withings vit sur une métrique distincte.", + "chartTitle": "Température corporelle", + "emptyState": { + "title": "Aucune température corporelle", + "description": "Enregistre une mesure ou laisse ton wearable en synchroniser une — la tendance apparaîtra ici.", + "cta": "Enregistrer la température" + } + }, + "activeEnergy": { + "title": "Énergie active", + "description": "Total quotidien d'énergie active en kilocalories — une ligne par jour, depuis Apple Santé ou Withings activité.", + "chartTitle": "Énergie active", + "emptyState": { + "title": "Aucune donnée d'énergie active", + "description": "Les totaux d'énergie active apparaîtront dès qu'Apple Santé ou un appareil Withings activité synchronise une journée complète." + } + }, "subPage": { "blutdruckDescription": "Your systolic / diastolic trend with age-personalised target zones and a written AI assessment.", "gewichtDescription": "Weight trend mapped against your height-derived healthy band, plus an AI assessment.", @@ -1202,6 +1262,62 @@ "description": "Dès que des données de sommeil sont disponibles (manuel ou via Withings / Apple Health), la répartition par stade apparaîtra ici.", "cta": "Connecter Withings" } + }, + "navWorkouts": "Entraînements", + "workouts": { + "title": "Entraînements", + "description": "Courses, sorties à vélo, marches et séances de musculation récentes, dédupliquées entre Apple Watch et Withings.", + "emptyState": { + "title": "Aucun entraînement", + "description": "Ouvre l'app Apple Santé sur ton iPhone pour synchroniser tes entraînements. L'import d'activité Withings alimente également la marche et le vélo." + }, + "row": { + "duration": "Durée", + "distance": "Distance", + "energy": "Énergie active" + }, + "detail": { + "backToList": "Retour aux entraînements", + "statsTitle": "Statistiques", + "statsDuration": "Durée", + "statsDistance": "Distance", + "statsActiveEnergy": "Énergie active", + "statsAvgHr": "FC moyenne", + "statsMaxHr": "FC max", + "statsMinHr": "FC min", + "statsStepCount": "Pas", + "statsElevation": "Dénivelé", + "statsPace": "Allure", + "statsSource": "Source", + "routeTitle": "Parcours", + "routeUnavailable": "Aucun parcours GPS enregistré pour cet entraînement.", + "hrChartTitle": "Fréquence cardiaque", + "hrChartUnavailable": "Les échantillons de fréquence cardiaque à la seconde ne sont pas stockés pour cet entraînement.", + "notFound": "Entraînement introuvable.", + "loading": "Chargement de l'entraînement…" + }, + "sport": { + "walking": "Marche", + "running": "Course", + "cycling": "Vélo", + "hiking": "Randonnée", + "swimming": "Natation", + "rowing": "Aviron", + "elliptical": "Elliptique", + "stairClimber": "Stepper", + "yoga": "Yoga", + "mindAndBody": "Mind and Body", + "strength": "Musculation", + "hiit": "HIIT", + "dance": "Danse", + "golf": "Golf", + "tennis": "Tennis", + "basketball": "Basket", + "soccer": "Football", + "crossTraining": "Cross-training", + "mixedCardio": "Cardio mixte", + "other": "Autre" + } } }, "thresholds": { diff --git a/messages/it.json b/messages/it.json index f62456c04..b4c5c9f82 100644 --- a/messages/it.json +++ b/messages/it.json @@ -258,7 +258,16 @@ "layoutChartColumn": "Grafico", "emptyTitle": "Your dashboard is empty", "emptyDescription": "Log your first measurement or mood entry. Tiles and charts appear automatically as data lands.", - "emptyAddMeasurement": "Add your first measurement" + "emptyAddMeasurement": "Add your first measurement", + "recentWorkouts": { + "title": "Allenamenti recenti", + "viewAll": "Vedi tutti", + "loading": "Caricamento allenamenti…", + "empty": { + "title": "Nessun allenamento", + "cta": "Apri l'app Salute di Apple sull'iPhone per sincronizzare gli allenamenti." + } + } }, "measurements": { "title": "Measurements", @@ -1103,7 +1112,58 @@ "navMedication": "Farmaco", "navBmi": "IMC", "navSleep": "Sonno", + "navHrv": "HRV", + "navRestingHr": "FC riposo", + "navOxygenSaturation": "Ossigeno", + "navBodyTemperature": "Temperatura", + "navActiveEnergy": "Energia attiva", "navAriaLabel": "Skip to section", + "hrv": { + "title": "HRV", + "description": "Variabilità della frequenza cardiaca nel tempo — segnale di recupero del sistema nervoso, captato di notte da Apple Watch e altri wearable compatibili.", + "chartTitle": "Variabilità della frequenza cardiaca", + "emptyState": { + "title": "Ancora nessun valore HRV", + "description": "I valori HRV appariranno qui non appena Apple Watch (o un dispositivo Withings compatibile) sincronizza le misurazioni notturne." + } + }, + "restingHr": { + "title": "FC a riposo", + "description": "Il livello cardiaco di base sulla giornata — distinto dalle frequenze puntuali della pagina Polso. Apple Salute fornisce un valore al giorno.", + "chartTitle": "Frequenza cardiaca a riposo", + "emptyState": { + "title": "Ancora nessuna FC a riposo", + "description": "La FC a riposo arriva quando Apple Watch o un Withings compatibile ha indossato una giornata tranquilla." + } + }, + "oxygenSaturation": { + "title": "Saturazione di ossigeno", + "description": "Ossigeno nel sangue (SpO₂) — intervallo clinico normale 95–100 %, con zone obiettivo ombreggiate sul grafico.", + "chartTitle": "Saturazione di ossigeno", + "emptyState": { + "title": "Ancora nessun campione SpO₂", + "description": "I campioni SpO₂ appariranno qui non appena un wearable compatibile (Apple Watch, Withings ScanWatch) cattura una lettura." + } + }, + "bodyTemperature": { + "title": "Temperatura corporea", + "description": "Andamento della temperatura corporea centrale in Celsius. La temperatura cutanea di Withings vive su una metrica separata.", + "chartTitle": "Temperatura corporea", + "emptyState": { + "title": "Ancora nessuna temperatura corporea", + "description": "Registra una misura o lascia che il tuo wearable ne sincronizzi una — l'andamento apparirà qui.", + "cta": "Registra temperatura corporea" + } + }, + "activeEnergy": { + "title": "Energia attiva", + "description": "Totale giornaliero di energia attiva in kilocalorie — una riga al giorno, da Apple Salute o dall'attività Withings.", + "chartTitle": "Energia attiva", + "emptyState": { + "title": "Ancora nessun dato di energia attiva", + "description": "I totali di energia attiva appariranno qui non appena Apple Salute o un dispositivo di attività Withings sincronizza un'intera giornata." + } + }, "subPage": { "blutdruckDescription": "Your systolic / diastolic trend with age-personalised target zones and a written AI assessment.", "gewichtDescription": "Weight trend mapped against your height-derived healthy band, plus an AI assessment.", @@ -1202,6 +1262,62 @@ "description": "Quando saranno disponibili dati sul sonno (manuale o tramite Withings / Apple Health), qui apparirà la suddivisione per fase.", "cta": "Collega Withings" } + }, + "navWorkouts": "Allenamenti", + "workouts": { + "title": "Allenamenti", + "description": "Corse, uscite in bici, camminate e sessioni di forza recenti, deduplicati tra Apple Watch e Withings.", + "emptyState": { + "title": "Nessun allenamento", + "description": "Apri l'app Salute di Apple sull'iPhone per sincronizzare gli allenamenti. L'import di attività di Withings porta dentro anche camminate e uscite in bici." + }, + "row": { + "duration": "Durata", + "distance": "Distanza", + "energy": "Energia attiva" + }, + "detail": { + "backToList": "Torna agli allenamenti", + "statsTitle": "Statistiche", + "statsDuration": "Durata", + "statsDistance": "Distanza", + "statsActiveEnergy": "Energia attiva", + "statsAvgHr": "FC media", + "statsMaxHr": "FC max", + "statsMinHr": "FC min", + "statsStepCount": "Passi", + "statsElevation": "Dislivello", + "statsPace": "Passo", + "statsSource": "Fonte", + "routeTitle": "Percorso", + "routeUnavailable": "Nessun tracciato GPS registrato per questo allenamento.", + "hrChartTitle": "Frequenza cardiaca", + "hrChartUnavailable": "I campioni di frequenza cardiaca al secondo non vengono salvati per questo allenamento.", + "notFound": "Allenamento non trovato.", + "loading": "Caricamento allenamento…" + }, + "sport": { + "walking": "Camminata", + "running": "Corsa", + "cycling": "Bici", + "hiking": "Escursionismo", + "swimming": "Nuoto", + "rowing": "Canottaggio", + "elliptical": "Ellittica", + "stairClimber": "Stepper", + "yoga": "Yoga", + "mindAndBody": "Mente e corpo", + "strength": "Forza", + "hiit": "HIIT", + "dance": "Danza", + "golf": "Golf", + "tennis": "Tennis", + "basketball": "Pallacanestro", + "soccer": "Calcio", + "crossTraining": "Cross-training", + "mixedCardio": "Cardio misto", + "other": "Altro" + } } }, "thresholds": { diff --git a/messages/pl.json b/messages/pl.json index a0faed162..7cfd5ac43 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -258,7 +258,16 @@ "layoutChartColumn": "Wykres", "emptyTitle": "Your dashboard is empty", "emptyDescription": "Log your first measurement or mood entry. Tiles and charts appear automatically as data lands.", - "emptyAddMeasurement": "Add your first measurement" + "emptyAddMeasurement": "Add your first measurement", + "recentWorkouts": { + "title": "Ostatnie treningi", + "viewAll": "Zobacz wszystkie", + "loading": "Wczytywanie treningów…", + "empty": { + "title": "Brak treningów", + "cta": "Otwórz aplikację Apple Zdrowie na iPhonie, aby zsynchronizować treningi." + } + } }, "measurements": { "title": "Measurements", @@ -1103,7 +1112,58 @@ "navMedication": "Lek", "navBmi": "BMI", "navSleep": "Sen", + "navHrv": "HRV", + "navRestingHr": "Tętno spoczynkowe", + "navOxygenSaturation": "Tlen", + "navBodyTemperature": "Temperatura", + "navActiveEnergy": "Energia czynna", "navAriaLabel": "Skip to section", + "hrv": { + "title": "HRV", + "description": "Zmienność rytmu serca w czasie — sygnał regeneracji układu nerwowego, rejestrowany nocą przez Apple Watch i inne zgodne wearable.", + "chartTitle": "Zmienność rytmu serca", + "emptyState": { + "title": "Brak wartości HRV", + "description": "Wartości HRV pojawią się tutaj, gdy Apple Watch (lub zgodne urządzenie Withings) zsynchronizuje pomiary nocne." + } + }, + "restingHr": { + "title": "Tętno spoczynkowe", + "description": "Dzienna linia bazowa rytmu serca — różna od tętna punktowego na stronie Pulse. Apple Zdrowie podaje jedną wartość na dobę.", + "chartTitle": "Tętno spoczynkowe", + "emptyState": { + "title": "Brak tętna spoczynkowego", + "description": "Tętno spoczynkowe pojawi się, gdy Apple Watch lub zgodne urządzenie Withings spędzi spokojny dzień na nadgarstku." + } + }, + "oxygenSaturation": { + "title": "Saturacja tlenu", + "description": "Tlen we krwi (SpO₂) — kliniczny zakres normy 95–100 %, ze strefami docelowymi zacienionymi na wykresie.", + "chartTitle": "Saturacja tlenu", + "emptyState": { + "title": "Brak próbek SpO₂", + "description": "Próbki SpO₂ trafią tutaj, gdy zgodne wearable (Apple Watch, Withings ScanWatch) wykona pomiar." + } + }, + "bodyTemperature": { + "title": "Temperatura ciała", + "description": "Trend temperatury rdzenia ciała w stopniach Celsjusza. Temperatura skóry z Withings działa na osobnej metryce.", + "chartTitle": "Temperatura ciała", + "emptyState": { + "title": "Brak temperatury ciała", + "description": "Zarejestruj pomiar lub pozwól wearable zsynchronizować jeden — trend pojawi się tutaj.", + "cta": "Zarejestruj temperaturę ciała" + } + }, + "activeEnergy": { + "title": "Energia czynna", + "description": "Dzienna suma energii czynnej w kilokaloriach — jeden wiersz na dobę, z Apple Zdrowie lub aktywności Withings.", + "chartTitle": "Energia czynna", + "emptyState": { + "title": "Brak danych o energii czynnej", + "description": "Sumy energii czynnej pojawią się tutaj, gdy Apple Zdrowie lub urządzenie Withings zsynchronizuje pełny dzień." + } + }, "subPage": { "blutdruckDescription": "Your systolic / diastolic trend with age-personalised target zones and a written AI assessment.", "gewichtDescription": "Weight trend mapped against your height-derived healthy band, plus an AI assessment.", @@ -1202,6 +1262,62 @@ "description": "Gdy pojawią się dane o śnie (ręcznie lub przez Withings / Apple Health), wyświetli się tu rozkład faz na noc.", "cta": "Połącz Withings" } + }, + "navWorkouts": "Treningi", + "workouts": { + "title": "Treningi", + "description": "Ostatnie biegi, jazdy, marsze i treningi siłowe — zdeduplikowane między Apple Watch a Withings.", + "emptyState": { + "title": "Brak treningów", + "description": "Otwórz aplikację Apple Zdrowie na iPhonie, aby zsynchronizować treningi. Import aktywności z Withings także dorzuca marsze i przejażdżki." + }, + "row": { + "duration": "Czas trwania", + "distance": "Dystans", + "energy": "Energia czynna" + }, + "detail": { + "backToList": "Powrót do treningów", + "statsTitle": "Statystyki", + "statsDuration": "Czas trwania", + "statsDistance": "Dystans", + "statsActiveEnergy": "Energia czynna", + "statsAvgHr": "Średnie tętno", + "statsMaxHr": "Maks. tętno", + "statsMinHr": "Min. tętno", + "statsStepCount": "Kroki", + "statsElevation": "Przewyższenie", + "statsPace": "Tempo", + "statsSource": "Źródło", + "routeTitle": "Trasa", + "routeUnavailable": "Dla tego treningu nie zapisano trasy GPS.", + "hrChartTitle": "Tętno", + "hrChartUnavailable": "Próbki tętna co sekundę nie są zapisywane dla tego treningu.", + "notFound": "Nie znaleziono treningu.", + "loading": "Wczytywanie treningu…" + }, + "sport": { + "walking": "Marsz", + "running": "Bieg", + "cycling": "Rower", + "hiking": "Górska wędrówka", + "swimming": "Pływanie", + "rowing": "Wioślarstwo", + "elliptical": "Orbitrek", + "stairClimber": "Stepper", + "yoga": "Joga", + "mindAndBody": "Mind and body", + "strength": "Siła", + "hiit": "HIIT", + "dance": "Taniec", + "golf": "Golf", + "tennis": "Tenis", + "basketball": "Koszykówka", + "soccer": "Piłka nożna", + "crossTraining": "Cross-trening", + "mixedCardio": "Cardio mieszane", + "other": "Inne" + } } }, "thresholds": { diff --git a/package.json b/package.json index e663c66fa..8a06a271e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.4.31", + "version": "1.4.32", "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/src/app/api/workouts/[id]/route.ts b/src/app/api/workouts/[id]/route.ts new file mode 100644 index 000000000..9c9980967 --- /dev/null +++ b/src/app/api/workouts/[id]/route.ts @@ -0,0 +1,136 @@ +/** + * `GET /api/workouts/{id}` — single-workout detail. + * + * v1.4.32 — paired with `GET /api/workouts` so the iOS workout-detail + * screen + the new `/insights/workouts/[id]` web page consume one + * canonical envelope per workout. The endpoint: + * + * - Returns the canonical row when the requested id wins its + * `(startedAt slot, sportType)` cluster against the user's + * source-priority ladder. The picker reuses + * `pickCanonicalWorkoutRows()` from v1.4.30 so the dedup contract + * stays in sync with the list endpoint. + * - When the requested id is a non-canonical twin (e.g. the user + * opened a deep-link to a Withings row but the cluster's winner is + * Apple Health), the response still resolves the requested row + * directly and exposes `canonicalId` pointing at the cluster + * winner. The web detail page can redirect via that field; the iOS + * client may surface it inline. + * - Loads the optional `WorkoutRoute` GeoJSON LineString when + * present. Absent for Withings-sourced workouts (Withings ships no + * route geometry); absent for manual entries. + * - Ownership-gated: a row owned by another user resolves as 404 so + * the existence channel never leaks. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + annotate({ action: { name: "workouts.detail" }, meta: { workoutId: id } }); + + const row = await prisma.workout.findUnique({ + where: { id }, + include: { + route: { + select: { + id: true, + geometry: true, + sampleTimestamps: true, + createdAt: true, + }, + }, + }, + }); + + // Cross-user 404 guard. The user can only fetch rows they own; any + // other id surfaces as "not found" so the existence channel stays + // sealed. + if (!row || row.userId !== user.id) { + return apiError("Workout not found", 404); + } + + // Resolve the canonical winner for the row's cluster. The picker + // reads every workout in the same 5-minute slot and sport-type + // bucket; in practice that's ≤ 4 rows (Apple + Withings + the two + // legacy sources). The query is bounded so the round-trip is cheap. + const slotMs = 5 * 60 * 1000; + const slotStart = new Date( + Math.floor(row.startedAt.getTime() / slotMs) * slotMs, + ); + const slotEnd = new Date(slotStart.getTime() + slotMs); + + const clusterRows = await prisma.workout.findMany({ + where: { + userId: user.id, + sportType: row.sportType, + startedAt: { gte: slotStart, lt: slotEnd }, + }, + orderBy: [{ startedAt: "asc" }, { id: "asc" }], + select: { + id: true, + source: true, + startedAt: true, + sportType: true, + }, + }); + + const userRow = await prisma.user.findUnique({ + where: { id: user.id }, + select: { sourcePriorityJson: true }, + }); + const canonicalCluster = pickCanonicalWorkoutRows( + clusterRows, + userRow?.sourcePriorityJson ?? null, + ); + // The canonical pick may carry multiple rows (same winning source); + // pick the closest in time to the requested row so deep-link + // redirects land on the most similar twin. + const canonical = + canonicalCluster.find((c) => c.id === row.id) ?? + canonicalCluster[0] ?? + null; + + const route = row.route + ? { + geometry: row.route.geometry, + sampleTimestamps: row.route.sampleTimestamps ?? null, + } + : null; + + return apiSuccess({ + id: row.id, + sportType: row.sportType, + startedAt: row.startedAt, + endedAt: row.endedAt, + durationSec: row.durationSec, + distanceM: row.totalDistanceM, + activeEnergyKcal: row.totalEnergyKcal, + avgHr: row.avgHeartRate, + maxHr: row.maxHeartRate, + minHr: row.minHeartRate, + stepCount: row.stepCount, + elevationM: row.elevationM, + pauseDurationSec: row.pauseDurationSec, + source: row.source, + externalId: row.externalId, + metadata: row.metadata, + route, + // v1.4.32 — when the requested id is a non-canonical twin the + // caller can redirect to `canonicalId` to land on the cluster + // winner. `canonicalId === id` when the requested row already + // is the winner. + canonicalId: canonical?.id ?? row.id, + }); + }, +); diff --git a/src/app/api/workouts/__tests__/canonical-dedup.test.ts b/src/app/api/workouts/__tests__/canonical-dedup.test.ts index 35e93c174..b220c5f02 100644 --- a/src/app/api/workouts/__tests__/canonical-dedup.test.ts +++ b/src/app/api/workouts/__tests__/canonical-dedup.test.ts @@ -1,13 +1,17 @@ /** * Unit suite for `GET /api/workouts` — the canonical-dedup contract. * - * v1.4.27 B7 / BL-P2-3 — wires `pickCanonicalWorkout()` into the read - * path. The test pins the contract: - * - twin workouts on the same `(sportType, ±5 min)` cluster + * v1.4.27 B7 / BL-P2-3 — original wiring with `pickCanonicalWorkout()`. + * v1.4.32 — swap to `pickCanonicalWorkoutRows()` (v1.4.30) so the + * per-user source-priority ladder governs the canonical pick. The + * test pins: + * - twin workouts on the same `(sportType, 5 min slot)` cluster * collapse to a single row; * - the source ladder picks APPLE_HEALTH over WITHINGS over MANUAL; - * - the `meta.droppedDuplicates` count reflects the diff between - * the raw fetch and the canonical subset. + * - `meta.droppedDuplicates` reflects the diff between the raw fetch + * and the canonical subset; + * - the response wire-shape exposes `distanceM` + `activeEnergyKcal` + * (iOS handoff names) rather than the legacy Prisma column names. */ import { describe, expect, it, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; @@ -17,6 +21,9 @@ vi.mock("@/lib/db", () => ({ workout: { findMany: vi.fn(), }, + user: { + findUnique: vi.fn(), + }, }, })); @@ -60,10 +67,10 @@ interface FakeWorkoutRow { startedAt: Date; endedAt: Date; durationSec: number; - distanceMeters: number | null; + totalDistanceM: number | null; avgHeartRate: number | null; maxHeartRate: number | null; - energyKcal: number | null; + totalEnergyKcal: number | null; createdAt: Date; } @@ -82,10 +89,10 @@ function makeRow( startedAt: start, endedAt: new Date(start.getTime() + 30 * 60_000), durationSec: 1800, - distanceMeters: 5000, + totalDistanceM: 5000, avgHeartRate: 145, maxHeartRate: 170, - energyKcal: 320, + totalEnergyKcal: 320, createdAt: start, }; } @@ -94,6 +101,11 @@ describe("GET /api/workouts — canonical dedup", () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // Default: no per-user source-priority override → fall back to the + // canonical APPLE_HEALTH ≻ WITHINGS ≻ MANUAL ≻ IMPORT ladder. + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + sourcePriorityJson: null, + } as never); }); it("collapses an APPLE_HEALTH + WITHINGS twin to the Apple row", async () => { @@ -112,7 +124,31 @@ describe("GET /api/workouts — canonical dedup", () => { expect(body.data.workouts).toHaveLength(1); expect(body.data.workouts[0].id).toBe("w-apple"); expect(body.data.meta.droppedDuplicates).toBe(1); - expect(body.data.meta.clusters).toBe(1); + }); + + it("returns 401 without a session", async () => { + vi.mocked(getSession).mockResolvedValueOnce(null); + const res = await GET(makeRequest()); + expect(res.status).toBe(401); + }); + + it("filters by ownership — only the caller's rows enter the picker", async () => { + // The mock returns whatever the route asked Prisma for; the route's + // `where: { userId: user.id }` filter is what enforces ownership. + // This test pins that the route passes `userId: user-1` so the + // database layer drops other users' rows before the picker runs. + const apple = makeRow("w-apple", "APPLE_HEALTH", "2026-05-15T07:00:00Z"); + vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ + apple, + ] as never); + + await GET(makeRequest()); + + expect(prisma.workout.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ userId: "user-1" }), + }), + ); }); it("keeps two workouts when they are outside the proximity window", async () => { @@ -129,12 +165,21 @@ describe("GET /api/workouts — canonical dedup", () => { expect(body.data.workouts).toHaveLength(2); expect(body.data.meta.droppedDuplicates).toBe(0); - expect(body.data.meta.clusters).toBe(2); }); it("keeps two workouts when sportType differs at the same instant", async () => { - const run = makeRow("w-run", "APPLE_HEALTH", "2026-05-15T07:00:00Z", "RUNNING"); - const walk = makeRow("w-walk", "APPLE_HEALTH", "2026-05-15T07:00:00Z", "WALKING"); + const run = makeRow( + "w-run", + "APPLE_HEALTH", + "2026-05-15T07:00:00Z", + "RUNNING", + ); + const walk = makeRow( + "w-walk", + "APPLE_HEALTH", + "2026-05-15T07:00:00Z", + "WALKING", + ); vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ run, @@ -148,6 +193,24 @@ describe("GET /api/workouts — canonical dedup", () => { expect(body.data.meta.droppedDuplicates).toBe(0); }); + it("exposes iOS-contract field names on each workout row", async () => { + const apple = makeRow("w-apple", "APPLE_HEALTH", "2026-05-15T07:00:00Z"); + vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ + apple, + ] as never); + + const res = await GET(makeRequest()); + const body = await res.json(); + + const row = body.data.workouts[0]; + expect(row.distanceM).toBe(5000); + expect(row.activeEnergyKcal).toBe(320); + expect(row.avgHr).toBe(145); + expect(row.maxHr).toBe(170); + expect(row).not.toHaveProperty("totalDistanceM"); + expect(row).not.toHaveProperty("totalEnergyKcal"); + }); + it("honours the limit query parameter after dedup", async () => { const rows = Array.from({ length: 8 }, (_, i) => makeRow( @@ -166,22 +229,20 @@ describe("GET /api/workouts — canonical dedup", () => { }); it("paginates across cluster boundaries without double-counting deduped rows", async () => { - // Eight twin clusters: each cluster has an APPLE_HEALTH + WITHINGS - // row within the 5 min window. The picker collapses each cluster to - // its Apple row, so the canonical projection has 8 rows. Page 1 - // (offset=0, limit=4) must yield Apple rows for clusters 7–4 - // (descending). Page 2 (offset=4, limit=4) must yield Apple rows - // for clusters 3–0 with no overlap and no gaps. `meta.total` must - // reflect the deduped count (8), not the raw row count (16). + // Eight twin clusters spaced 1 hour apart: each cluster has an + // APPLE_HEALTH + WITHINGS row within the 5 min slot. The picker + // collapses each cluster to its Apple row, so the canonical + // projection has 8 rows. Page 1 (offset=0, limit=4) must yield + // Apple rows for the four most-recent clusters; page 2 (offset=4, + // limit=4) must yield the four older clusters with no overlap. const rows: FakeWorkoutRow[] = []; - for (let i = 0; i < 8; i++) { + for (let i = 7; i >= 0; i--) { const start = new Date(2026, 4, 15, 7 + i, 0, 0).toISOString(); const start2 = new Date(2026, 4, 15, 7 + i, 1, 30).toISOString(); + // Descending order on input as the route's orderBy would yield. rows.push(makeRow(`apple-${i}`, "APPLE_HEALTH", start)); rows.push(makeRow(`withings-${i}`, "WITHINGS", start2)); } - // Mock returns the full filtered set; the route paginates after - // dedup. vi.mocked(prisma.workout.findMany).mockResolvedValue(rows as never); const page1 = await GET(makeRequest("?limit=4&offset=0")); @@ -200,12 +261,9 @@ describe("GET /api/workouts — canonical dedup", () => { const page2Ids = page2Body.data.workouts.map((w: { id: string }) => w.id); // No overlap across the page boundary. expect(page1Ids.filter((id: string) => page2Ids.includes(id))).toEqual([]); - // All eight clusters are covered between the two pages. + // Together both pages cover the eight canonical (Apple) rows. expect([...page1Ids, ...page2Ids].sort()).toEqual( Array.from({ length: 8 }, (_, i) => `apple-${i}`).sort(), ); - // Descending order is preserved within each page. - expect(page1Ids).toEqual(["apple-7", "apple-6", "apple-5", "apple-4"]); - expect(page2Ids).toEqual(["apple-3", "apple-2", "apple-1", "apple-0"]); }); }); diff --git a/src/app/api/workouts/__tests__/detail.test.ts b/src/app/api/workouts/__tests__/detail.test.ts new file mode 100644 index 000000000..57c2b1055 --- /dev/null +++ b/src/app/api/workouts/__tests__/detail.test.ts @@ -0,0 +1,199 @@ +/** + * Unit suite for `GET /api/workouts/{id}` — single-workout detail. + * + * v1.4.32 — paired with the list endpoint so the iOS detail screen + + * the `/insights/workouts/[id]` web page consume one envelope per + * workout. The test pins: + * - 200 + iOS-contract field names for a row the caller owns; + * - 401 without a session; + * - 404 (existence-channel sealed) when the row belongs to another + * user; + * - 404 when no row exists; + * - `canonicalId` resolves to the cluster winner — the requested id + * when the row is the canonical pick, the winner id when it is a + * non-canonical twin; + * - the optional `route` field flows through GeoJSON geometry when a + * `WorkoutRoute` row is attached, and is null when missing. + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + workout: { + findUnique: vi.fn(), + findMany: vi.fn(), + }, + user: { + findUnique: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../[id]/route"; +import { prisma } from "@/lib/db"; +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: "marc", role: "USER" as const }, +}; + +function makeRequest(): NextRequest { + return new NextRequest("http://localhost/api/workouts/w-1", { + method: "GET", + }); +} + +function makeParams(id: string) { + return { params: Promise.resolve({ id }) }; +} + +const BASE_ROW = { + id: "w-1", + userId: "user-1", + sportType: "RUNNING", + startedAt: new Date("2026-05-15T07:00:00Z"), + endedAt: new Date("2026-05-15T07:30:00Z"), + durationSec: 1800, + totalDistanceM: 5000, + totalEnergyKcal: 320, + avgHeartRate: 145, + maxHeartRate: 170, + minHeartRate: 110, + stepCount: 5800, + elevationM: 12.5, + pauseDurationSec: null, + source: "APPLE_HEALTH" as const, + externalId: "ext-w-1", + externalSourceVersion: null, + metadata: { hkVersion: "1" }, + createdAt: new Date("2026-05-15T07:30:00Z"), + updatedAt: new Date("2026-05-15T07:30:00Z"), + route: null, +}; + +describe("GET /api/workouts/{id}", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + sourcePriorityJson: null, + } as never); + }); + + it("returns the workout with iOS-contract field names", async () => { + vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce( + BASE_ROW as never, + ); + vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ + { id: "w-1", source: "APPLE_HEALTH", startedAt: BASE_ROW.startedAt, sportType: "RUNNING" }, + ] as never); + + const res = await GET(makeRequest(), makeParams("w-1")); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.data.id).toBe("w-1"); + expect(body.data.distanceM).toBe(5000); + expect(body.data.activeEnergyKcal).toBe(320); + expect(body.data.avgHr).toBe(145); + expect(body.data.maxHr).toBe(170); + expect(body.data.canonicalId).toBe("w-1"); + expect(body.data.route).toBeNull(); + }); + + it("returns 401 without a session", async () => { + vi.mocked(getSession).mockResolvedValueOnce(null); + const res = await GET(makeRequest(), makeParams("w-1")); + expect(res.status).toBe(401); + }); + + it("returns 404 when the row belongs to another user", async () => { + vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({ + ...BASE_ROW, + userId: "user-2", + } as never); + + const res = await GET(makeRequest(), makeParams("w-1")); + expect(res.status).toBe(404); + }); + + it("returns 404 when no row exists", async () => { + vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce(null); + const res = await GET(makeRequest(), makeParams("w-1")); + expect(res.status).toBe(404); + }); + + it("resolves canonicalId to the Apple Health twin when the requested row is the Withings sibling", async () => { + // Same 5-minute slot + sportType, but the caller asked for the + // Withings row. The picker's default ladder makes Apple Health win + // the cluster, so canonicalId points at the Apple row. + const withingsRow = { + ...BASE_ROW, + id: "w-withings", + source: "WITHINGS" as const, + externalId: "withings-1", + }; + vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce( + withingsRow as never, + ); + vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ + { id: "w-apple", source: "APPLE_HEALTH", startedAt: BASE_ROW.startedAt, sportType: "RUNNING" }, + { id: "w-withings", source: "WITHINGS", startedAt: BASE_ROW.startedAt, sportType: "RUNNING" }, + ] as never); + + const res = await GET(makeRequest(), makeParams("w-withings")); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.data.id).toBe("w-withings"); + expect(body.data.canonicalId).toBe("w-apple"); + }); + + it("exposes the WorkoutRoute geometry when present", async () => { + const routeGeometry = { + type: "LineString", + coordinates: [ + [11.0, 49.0], + [11.01, 49.01], + ], + }; + vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({ + ...BASE_ROW, + route: { + id: "wr-1", + geometry: routeGeometry, + sampleTimestamps: ["2026-05-15T07:00:00Z", "2026-05-15T07:01:00Z"], + createdAt: BASE_ROW.createdAt, + }, + } as never); + vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ + { id: "w-1", source: "APPLE_HEALTH", startedAt: BASE_ROW.startedAt, sportType: "RUNNING" }, + ] as never); + + const res = await GET(makeRequest(), makeParams("w-1")); + const body = await res.json(); + + expect(body.data.route).not.toBeNull(); + expect(body.data.route.geometry).toEqual(routeGeometry); + expect(body.data.route.sampleTimestamps).toEqual([ + "2026-05-15T07:00:00Z", + "2026-05-15T07:01:00Z", + ]); + }); +}); diff --git a/src/app/api/workouts/route.ts b/src/app/api/workouts/route.ts index 9dfd2bbe8..b3a965b2c 100644 --- a/src/app/api/workouts/route.ts +++ b/src/app/api/workouts/route.ts @@ -1,25 +1,23 @@ /** - * `GET /api/workouts` — paginated workout list with canonical-source - * deduplication. + * `GET /api/workouts` — paginated workout list with cross-source + * canonical-row dedup. * - * Consumers (v1.4.27+): - * 1. The /insights workouts surface (and future /workouts page) reads - * from this endpoint so duplicate twin workouts (Apple Watch - * writing a workout that Withings also published over its server- - * to-server feed) collapse to a single canonical row. + * Consumers (v1.4.32+): + * 1. The `/insights/workouts` web surface reads from this endpoint so + * duplicate twin workouts (Apple Watch writing a workout that + * Withings also published over its server-to-server feed) collapse + * to a single canonical row per cluster. * 2. The native iOS app drains historical workouts through the same * surface — Apple Health is the canonical ladder anchor, so an * iOS-originated request typically returns its own rows back. * * Dedup contract: - * - The route reads `DEFAULT_WORKOUT_SOURCE_PRIORITY` from - * `pick-canonical-workout.ts` (APPLE_HEALTH > WITHINGS > MANUAL > - * IMPORT) with the ±5 min cluster window. A per-user workout - * ladder override is reserved for the v1.5 Settings surface; the - * server already honours it transparently through the picker's - * options bag when the field lands in `User.sourcePriorityJson`. - * - Per-cluster the picker keeps the row from the highest-priority - * source present; the others are dropped from the canonical list. + * - The route runs `pickCanonicalWorkoutRows()` from v1.4.30 over the + * filtered row set. The picker reads the per-user + * `User.sourcePriorityJson` blob (falling back to + * `APPLE_HEALTH ≻ WITHINGS ≻ MANUAL ≻ IMPORT`) and buckets by + * `(startedAt slot-of-5-min, sportType)`. The highest-priority + * source's rows win each bucket; the rest are dropped. * * Query params: * - `limit` (default 50, max 200) @@ -35,14 +33,19 @@ * page N would be dropped on N-1 and surface again on N. Workout * volume for HealthLog is bounded (single user, tens of workouts per * week — single-digit MB at the table's busiest), so reading the - * full filtered set per request is well within budget. If the - * workload ever grows to where this matters, a `(startedAt, id)` - * cursor with a deterministic over-fetch window is the next step. + * full filtered set per request is well within budget. * - * v1.4.27 B7 / BL-P2-3 — wires `pickCanonicalWorkout()` into the read - * path. v1.4.27 R4 RC3 — corrects the pagination so `meta.total` - * reflects the deduped count and cluster boundaries no longer leak - * across pages. + * Response shape (iOS contract): + * - `workouts: WorkoutListEntry[]` — each entry exposes + * `id, sportType, startedAt, endedAt, durationSec, distanceM, + * activeEnergyKcal, avgHr, maxHr, source, externalId`. Stable across + * iOS / web consumers. + * - `meta: { total, limit, offset, droppedDuplicates }`. + * + * v1.4.32 — swap the cluster-based dedup helper (v1.4.27 B7) for the + * source-priority-aware `pickCanonicalWorkoutRows()` helper (v1.4.30) + * so the per-user source ladder governs the canonical pick. Response + * field names match the iOS handoff contract. */ import { NextRequest } from "next/server"; @@ -50,12 +53,7 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; -import { - pickCanonicalWorkout, - DEFAULT_WORKOUT_SOURCE_PRIORITY, - DEFAULT_WORKOUT_PROXIMITY_MINUTES, - type WorkoutPickerRow, -} from "@/lib/sources/pick-canonical-workout"; +import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; const DEFAULT_LIMIT = 50; const MAX_LIMIT = 200; @@ -100,14 +98,12 @@ export const GET = apiHandler(async (request: NextRequest) => { where.sportType = sportType; } - // Read the full filtered set then dedupe once. See the file-level - // "Pagination contract" note for the reason this is preferred over - // a per-page over-fetch + slice. The picker is O(n) on cluster - // building plus O(k) per cluster (k = open clusters of the same - // sport type) and the workout table is bounded. + // Read the full filtered set, descending start. The picker requires + // deterministic input order; the `(startedAt, id)` secondary sort + // pins the tie-break for rows that share a millisecond stamp. const rows = await prisma.workout.findMany({ where, - orderBy: { startedAt: "desc" }, + orderBy: [{ startedAt: "desc" }, { id: "asc" }], select: { id: true, source: true, @@ -116,40 +112,47 @@ export const GET = apiHandler(async (request: NextRequest) => { startedAt: true, endedAt: true, durationSec: true, - distanceMeters: true, + totalDistanceM: true, + totalEnergyKcal: true, avgHeartRate: true, maxHeartRate: true, - energyKcal: true, createdAt: true, }, }); - // The picker only inspects `source`, `startedAt`, `sportType`, and - // `id`. The rest of the selected columns ride through untouched on - // the returned subset. - const pickerRows = rows as Array<(typeof rows)[number] & WorkoutPickerRow>; - const { canonical, clusters } = pickCanonicalWorkout(pickerRows, { - sourcePriority: DEFAULT_WORKOUT_SOURCE_PRIORITY, - proximityMinutes: DEFAULT_WORKOUT_PROXIMITY_MINUTES, + // Pass the user's source-priority blob so the picker honours the + // per-user ladder. The picker preserves input order on the returned + // subset; descending-start input → descending-start output. + const userRow = await prisma.user.findUnique({ + where: { id: user.id }, + select: { sourcePriorityJson: true }, }); - - // The picker sorts ascending for deterministic cluster building. - // Restore descending order on the canonical projection so the API - // matches the orderBy contract above. - const canonicalDesc = [...canonical].sort( - (a, b) => b.startedAt.getTime() - a.startedAt.getTime(), + const canonical = pickCanonicalWorkoutRows( + rows, + userRow?.sourcePriorityJson ?? null, ); - const page = canonicalDesc.slice(offset, offset + limit); + const page = canonical.slice(offset, offset + limit).map((row) => ({ + id: row.id, + sportType: row.sportType, + startedAt: row.startedAt, + endedAt: row.endedAt, + durationSec: row.durationSec, + distanceM: row.totalDistanceM, + activeEnergyKcal: row.totalEnergyKcal, + avgHr: row.avgHeartRate, + maxHr: row.maxHeartRate, + source: row.source, + externalId: row.externalId, + })); return apiSuccess({ workouts: page, meta: { - total: canonicalDesc.length, + total: canonical.length, limit, offset, - droppedDuplicates: rows.length - canonicalDesc.length, - clusters: clusters.length, + droppedDuplicates: rows.length - canonical.length, }, }); }); diff --git a/src/app/insights/aktive-energie/page.tsx b/src/app/insights/aktive-energie/page.tsx new file mode 100644 index 000000000..146d81cfd --- /dev/null +++ b/src/app/insights/aktive-energie/page.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Flame } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; + +/** + * v1.4.32 — `/insights/aktive-energie`. + * + * Active-energy-burned sub-page. The MeasurementType is cumulative + * (kcal per day, per R-A Option A); the v1.4.30 server-side daily- + * stats helper collapses per-sample iOS posts to a single daily row. + * The chart shows that one-row-per-day shape — long-tail back-fill + * imports collapse cleanly via the v1.4.30 drain script. + */ +export default function InsightsActiveEnergyPage() { + return ( + } + emptyStateCtaType={null} + coachPrefill="I haven't logged any active energy yet — what's a reasonable daily target, and how does it compare to total calories?" + /> + ); +} diff --git a/src/app/insights/hrv/page.tsx b/src/app/insights/hrv/page.tsx new file mode 100644 index 000000000..5076fbaee --- /dev/null +++ b/src/app/insights/hrv/page.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Activity } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; + +/** + * v1.4.32 — `/insights/hrv`. + * + * Heart-rate variability sub-page. Pairs with the iOS HKQuantityType + * `heartRateVariabilitySDNN` ingest path; the user typically logs one + * sample per night during sleep, so the chart is sparse but the + * weekly trend is the load-bearing signal. Stored as millisecond + * `HEART_RATE_VARIABILITY` rows by every existing ingest path. + */ +export default function InsightsHrvPage() { + return ( + } + emptyStateCtaType={null} + coachPrefill="I haven't logged any HRV data yet — what does heart-rate variability tell me, and how do I capture it?" + /> + ); +} diff --git a/src/app/insights/koerpertemperatur/page.tsx b/src/app/insights/koerpertemperatur/page.tsx new file mode 100644 index 000000000..0d7657ce1 --- /dev/null +++ b/src/app/insights/koerpertemperatur/page.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { Thermometer } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; + +/** + * v1.4.32 — `/insights/koerpertemperatur`. + * + * Body-temperature sub-page. Canonical storage is Celsius. Distinct + * from skin temperature (Withings ScanWatch dermal reading) — surface + * temps run ~32 °C, core ~37 °C, so the schema deliberately keeps + * them on separate enum values per the v1.4.30 categorisation overlay. + */ +export default function InsightsBodyTemperaturePage() { + return ( + } + emptyStateCtaType="BODY_TEMPERATURE" + coachPrefill="I haven't logged any body temperature yet — what's the healthy range, and what should I do if it drifts?" + /> + ); +} diff --git a/src/app/insights/ruhepuls/page.tsx b/src/app/insights/ruhepuls/page.tsx new file mode 100644 index 000000000..dff1f1e79 --- /dev/null +++ b/src/app/insights/ruhepuls/page.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Heart } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; + +/** + * v1.4.32 — `/insights/ruhepuls`. + * + * Resting-heart-rate sub-page. Distinct from `/insights/puls` — Apple + * Health splits the steady-state daily `restingHeartRate` value from + * the spot `PULSE` samples logged manually or by Withings. The split + * here mirrors that distinction so the user has a "daily floor" + * trend that's not muddied by mid-walk readings. + */ +export default function InsightsRestingHrPage() { + return ( + } + emptyStateCtaType={null} + coachPrefill="I haven't logged any resting heart rate yet — why is the daily resting value different from a spot pulse, and how do I improve it?" + /> + ); +} diff --git a/src/app/insights/sauerstoff/page.tsx b/src/app/insights/sauerstoff/page.tsx new file mode 100644 index 000000000..4309e9abd --- /dev/null +++ b/src/app/insights/sauerstoff/page.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { Wind } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; + +/** + * v1.4.32 — `/insights/sauerstoff`. + * + * Oxygen-saturation (SpO₂) sub-page. Canonical storage is percent + * (0..100); the chart axis runs 85..100 because clinical normal is + * 95..100 and the 85 floor leaves room for nightly dips without + * compressing the trend. Withings ScanWatch + Apple Watch both + * report SpO₂; the v1.4.25 source-priority picker keeps the surface + * deduped. + */ +export default function InsightsOxygenSaturationPage() { + return ( + } + emptyStateCtaType={null} + coachPrefill="I haven't logged any oxygen-saturation data yet — what does SpO₂ tell me, and what range should I aim for?" + /> + ); +} diff --git a/src/app/insights/workouts/[id]/page.tsx b/src/app/insights/workouts/[id]/page.tsx new file mode 100644 index 000000000..d648a8b06 --- /dev/null +++ b/src/app/insights/workouts/[id]/page.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { use } from "react"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { useWorkoutDetail } from "@/hooks/use-workouts"; +import { Button } from "@/components/ui/button"; +import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; +import { SubPageShell } from "@/components/insights/sub-page-shell"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + WorkoutDetailHeader, + WorkoutDetailHRChart, + WorkoutDetailRoute, + WorkoutDetailStats, +} from "@/components/insights/workout-detail"; + +/** + * v1.4.32 — `/insights/workouts/[id]`. + * + * Workout detail surface. Mounts the header + stats + optional GPS + * route + heart-rate chart slot. The route data flows from + * `GET /api/workouts/{id}` (v1.4.32) and includes the cross-source + * canonical-id pointer so a deep-link into a non-canonical twin + * surfaces a graceful redirect cue (handled inline on the header for + * v1.4.32; iOS-side redirect arrives in v1.5). + */ +export default function InsightsWorkoutDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { t } = useTranslations(); + const { id } = use(params); + const { data, isLoading, error } = useWorkoutDetail(id); + + return ( + + + + {isLoading ? ( +
+ + + +
+ ) : error || !data ? ( +

+ {t("insights.workouts.detail.notFound")} +

+ ) : ( + <> + + + + + + + )} +
+ ); +} diff --git a/src/app/insights/workouts/page.tsx b/src/app/insights/workouts/page.tsx new file mode 100644 index 000000000..ca9f07abb --- /dev/null +++ b/src/app/insights/workouts/page.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { Activity } from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { useWorkouts } from "@/hooks/use-workouts"; +import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; +import { MetricEmptyState } from "@/components/insights/metric-empty-state"; +import { SubPageShell } from "@/components/insights/sub-page-shell"; +import { WorkoutList } from "@/components/insights/workout-list"; +import { Skeleton } from "@/components/ui/skeleton"; + +/** + * v1.4.32 — `/insights/workouts`. + * + * Workouts list sub-page. Pairs with the iOS HKWorkout ingest path + * (`POST /api/workouts/batch`) and the v1.4.30 cross-source picker so + * twin workouts from Apple Watch + Withings ScanWatch collapse to a + * single row. The page is the user-visible surface for the workout + * data the iOS client already syncs. + * + * Renders three states: + * - loading shell while the workouts query resolves, + * - empty state when the user has no workouts yet (CTA points at + * the iOS / Apple Health onboarding cue — there is no manual + * workout-entry form today), + * - the deduped list otherwise. Each row links to + * `/insights/workouts/[id]` for the detail surface. + */ +export default function InsightsWorkoutsPage() { + const { t } = useTranslations(); + const { data, isLoading, isEmpty } = useWorkouts({ limit: 100 }); + + return ( + + {isLoading ? ( +
+ + + +
+ ) : isEmpty ? ( + } + title={t("insights.workouts.emptyState.title")} + description={t("insights.workouts.emptyState.description")} + cta={null} + coachPrefill="I haven't logged any workouts yet — why does tracking them matter, and what should I focus on first?" + /> + ) : data ? ( + <> + + + + ) : null} +
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 9a91e9904..3031cff3f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -44,6 +44,7 @@ import { summaryToTrend7Delta } from "@/lib/analytics/trend-delta"; import { GettingStartedChecklist } from "@/components/onboarding/getting-started-checklist"; import { TourLauncher } from "@/components/onboarding/tour-launcher"; import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card"; +import { RecentWorkoutsTile } from "@/components/dashboard/recent-workouts-tile"; const MoodChart = dynamic( () => @@ -347,6 +348,10 @@ export default function DashboardPage() { // the layout-toggle gate here. No data-floor check (the empty card is // intentional — the maintainer wants the user to discover the feature). const showAchievementsCard = isChartVisible("achievements"); + // v1.4.32 — recent workouts dashboard tile. Self-gates on the + // workouts query response so we only need the layout toggle here; + // the tile renders an Apple-Health-onboarding hint when empty. + const showRecentWorkoutsTile = isChartVisible("recentWorkouts"); // Glucose widget — visible iff layout enables it AND at least one reading exists. // Glucose has no separate chart slot today, so the tile flag is the @@ -1147,6 +1152,15 @@ export default function DashboardPage() { node: , }); } + if (showRecentWorkoutsTile) { + // v1.4.32 — slotted via the same `order` mechanism. Self-gates + // on the workouts query response inside the tile itself. + charts.push({ + id: "recentWorkouts", + order: widgetOrder("recentWorkouts"), + node: , + }); + } charts.sort((a, b) => a.order - b.order); diff --git a/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx b/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx new file mode 100644 index 000000000..1bc2d2b61 --- /dev/null +++ b/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { WorkoutListEntry } from "@/hooks/use-workouts"; + +/** + * v1.4.32 — `` contract. + * + * - Empty state surfaces the Apple-Health-sync onboarding cue. + * - Populated state renders the three most-recent rows + a "View + * all" link to `/insights/workouts`. + * - Loading state renders a brief placeholder. + */ + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true }), +})); + +let mockResult: { + data: + | { + workouts: WorkoutListEntry[]; + meta: { + total: number; + limit: number; + offset: number; + droppedDuplicates: number; + }; + } + | undefined; + isLoading: boolean; + isEmpty: boolean; + error: Error | null; +} = { + data: undefined, + isLoading: false, + isEmpty: false, + error: null, +}; + +vi.mock("@/hooks/use-workouts", async () => { + const actual = + await vi.importActual( + "@/hooks/use-workouts", + ); + return { + ...actual, + useWorkouts: () => mockResult, + }; +}); + +import { RecentWorkoutsTile } from "../recent-workouts-tile"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockResult = { + data: undefined, + isLoading: false, + isEmpty: false, + error: null, + }; +}); + +describe("", () => { + it("renders the loading placeholder while the query is in flight", () => { + mockResult.isLoading = true; + const html = render(); + expect(html).toContain('data-slot="recent-workouts-loading"'); + expect(html).toContain("Loading workouts"); + }); + + it("renders the empty-state with the Apple-Health onboarding cue", () => { + mockResult.data = { + workouts: [], + meta: { total: 0, limit: 3, offset: 0, droppedDuplicates: 0 }, + }; + const html = render(); + expect(html).toContain('data-slot="recent-workouts-empty"'); + expect(html).toContain("No workouts yet"); + expect(html).toContain("Apple Health"); + }); + + it("renders the populated list with deep-links into each detail page", () => { + mockResult.data = { + workouts: [ + { + id: "w-1", + sportType: "running", + startedAt: "2026-05-15T07:00:00Z", + endedAt: "2026-05-15T07:30:00Z", + durationSec: 1800, + distanceM: 5200, + activeEnergyKcal: 320, + avgHr: 145, + maxHr: 170, + source: "APPLE_HEALTH", + externalId: "ext-w-1", + }, + { + id: "w-2", + sportType: "cycling", + startedAt: "2026-05-14T17:00:00Z", + endedAt: "2026-05-14T18:00:00Z", + durationSec: 3600, + distanceM: 24000, + activeEnergyKcal: 600, + avgHr: 138, + maxHr: 162, + source: "WITHINGS", + externalId: "wi-1", + }, + ], + meta: { total: 2, limit: 3, offset: 0, droppedDuplicates: 0 }, + }; + const html = render(); + expect(html).toContain('data-slot="recent-workouts-tile"'); + expect(html).toContain('href="/insights/workouts/w-1"'); + expect(html).toContain('href="/insights/workouts/w-2"'); + expect(html).toContain('href="/insights/workouts"'); + expect(html).toContain("Running"); + expect(html).toContain("Cycling"); + expect(html).toContain("30m"); + expect(html).toContain("1h 00m"); + }); +}); diff --git a/src/components/dashboard/recent-workouts-tile.tsx b/src/components/dashboard/recent-workouts-tile.tsx new file mode 100644 index 000000000..88b5a2a3f --- /dev/null +++ b/src/components/dashboard/recent-workouts-tile.tsx @@ -0,0 +1,173 @@ +"use client"; + +import Link from "next/link"; +import { + Activity, + Bike, + Dumbbell, + Footprints, + HeartPulse, + Mountain, + PersonStanding, + type LucideIcon, +} from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { useWorkouts, type WorkoutListEntry } from "@/hooks/use-workouts"; +import { cn } from "@/lib/utils"; + +/** + * v1.4.32 — ``. + * + * Dashboard card that surfaces the user's three most-recent workouts, + * one row each, with the sport icon + the day + the duration. The + * tile is gated on the `recentWorkouts` widget id in + * `DEFAULT_DASHBOARD_LAYOUT` — operators / users opt out via Settings + * → Dashboard. Empty-state mounts a neutral notice that steers the + * user toward the Apple Health sync onboarding cue rather than a + * dead "Add workout" button (the web has no manual workout-entry + * form today; the iOS client owns the ingest path). + * + * Cache: shares the `["workouts", "recent", { limit: 3 }]` slot with + * the canonical `useWorkouts({ limit: 3 })` consumer so navigating + * between the dashboard and `/insights/workouts` is a single + * round-trip per session. + */ + +const SPORT_TYPE_ICON: Record = { + walking: Footprints, + running: PersonStanding, + cycling: Bike, + hiking: Mountain, + swimming: Activity, + rowing: Activity, + elliptical: Activity, + stairClimber: Activity, + yoga: PersonStanding, + mindAndBody: PersonStanding, + strength: Dumbbell, + hiit: Activity, + dance: Activity, + golf: Activity, + tennis: Activity, + basketball: Activity, + soccer: Activity, + crossTraining: Activity, + mixedCardio: HeartPulse, + other: Activity, +}; + +function iconForSport(sportType: string): LucideIcon { + return SPORT_TYPE_ICON[sportType] ?? Activity; +} + +function formatDuration(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (h > 0) return `${h}h ${m.toString().padStart(2, "0")}m`; + return `${m}m`; +} + +function formatDayLabel(iso: string, locale: string): string { + const d = new Date(iso); + return new Intl.DateTimeFormat(locale, { + weekday: "short", + day: "2-digit", + month: "short", + }).format(d); +} + +function renderRow(workout: WorkoutListEntry, sportName: string) { + const Icon = iconForSport(workout.sportType); + return ( + + + {sportName} + + ); +} + +export function RecentWorkoutsTile() { + const { t, locale } = useTranslations(); + const { data, isLoading } = useWorkouts({ limit: 3 }); + const workouts = data?.workouts ?? []; + + return ( +
+
+
+
+ + {t("dashboard.recentWorkouts.viewAll")} + +
+ + {isLoading ? ( +

+ {t("dashboard.recentWorkouts.loading")} +

+ ) : workouts.length === 0 ? ( +
+

+ {t("dashboard.recentWorkouts.empty.title")} +

+

+ {t("dashboard.recentWorkouts.empty.cta")} +

+
+ ) : ( +
    + {workouts.map((workout) => { + const sportLabelKey = `insights.workouts.sport.${workout.sportType}`; + const sportLabel = t(sportLabelKey); + const sportName = + sportLabel === sportLabelKey ? workout.sportType : sportLabel; + return ( +
  • + + {renderRow(workout, sportName)} + + {formatDayLabel(workout.startedAt, locale)} + + + {formatDuration(workout.durationSec)} + + +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/src/components/insights/__tests__/workout-detail.test.tsx b/src/components/insights/__tests__/workout-detail.test.tsx new file mode 100644 index 000000000..c850e5cb9 --- /dev/null +++ b/src/components/insights/__tests__/workout-detail.test.tsx @@ -0,0 +1,149 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + WorkoutDetailHeader, + WorkoutDetailHRChart, + WorkoutDetailRoute, + WorkoutDetailStats, +} from "../workout-detail"; +import type { WorkoutDetailPayload } from "@/hooks/use-workouts"; + +/** + * v1.4.32 — `` unit tests. + * + * Each primitive runs through SSR with a single canonical fixture. + * The tests pin the visible labels + the graceful-fallback shapes so + * the page renders something useful even when the optional fields + * (route, HR samples, energy) are absent. + */ + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +const FIXTURE: WorkoutDetailPayload = { + id: "w-1", + sportType: "running", + startedAt: "2026-05-15T07:00:00Z", + endedAt: "2026-05-15T07:30:00Z", + durationSec: 1800, + distanceM: 5200, + activeEnergyKcal: 320, + avgHr: 145, + maxHr: 170, + minHr: 110, + stepCount: 5800, + elevationM: 12.5, + pauseDurationSec: null, + source: "APPLE_HEALTH", + externalId: "ext-w-1", + metadata: null, + route: null, + canonicalId: "w-1", +}; + +describe("", () => { + it("renders the sport label, source, and duration summary", () => { + const html = render(); + expect(html).toContain("Running"); + expect(html).toContain("APPLE_HEALTH"); + expect(html).toContain("30m 00s"); + expect(html).toContain("5.20 km"); + }); +}); + +describe("", () => { + it("renders one tile per available field", () => { + const html = render(); + expect(html).toContain("Duration"); + expect(html).toContain("Distance"); + expect(html).toContain("Active energy"); + expect(html).toContain("Average HR"); + expect(html).toContain("145 bpm"); + expect(html).toContain("Steps"); + expect(html).toContain("Pace"); + }); + + it("omits tiles for null fields", () => { + const minimal: WorkoutDetailPayload = { + ...FIXTURE, + distanceM: null, + activeEnergyKcal: null, + avgHr: null, + maxHr: null, + minHr: null, + stepCount: null, + elevationM: null, + }; + const html = render(); + expect(html).toContain("Duration"); + expect(html).not.toContain("Distance"); + expect(html).not.toContain("Active energy"); + expect(html).not.toContain("Steps"); + expect(html).not.toContain("Average HR"); + }); +}); + +describe("", () => { + it("renders the empty-state when the workout has no route", () => { + const html = render(); + expect(html).toContain('data-slot="workout-detail-route-empty"'); + expect(html).toContain("No GPS route"); + }); + + it("renders an SVG polyline when GeoJSON LineString geometry is present", () => { + const withRoute: WorkoutDetailPayload = { + ...FIXTURE, + route: { + geometry: { + type: "LineString", + coordinates: [ + [11.0, 49.0], + [11.01, 49.005], + [11.02, 49.01], + ], + }, + sampleTimestamps: null, + }, + }; + const html = render(); + expect(html).toContain('data-slot="workout-detail-route"'); + expect(html).toContain(" { + const stub: WorkoutDetailPayload = { + ...FIXTURE, + route: { + geometry: { type: "LineString", coordinates: [[11, 49]] }, + sampleTimestamps: null, + }, + }; + const html = render(); + expect(html).toContain('data-slot="workout-detail-route-empty"'); + }); +}); + +describe("", () => { + it("renders the unavailable notice with aggregate HR values when present", () => { + const html = render(); + expect(html).toContain('data-slot="workout-detail-hr-chart"'); + expect(html).toContain("145 bpm"); + expect(html).toContain("170 bpm"); + }); + + it("renders just the unavailable notice when no HR aggregates are present", () => { + const noHr: WorkoutDetailPayload = { + ...FIXTURE, + avgHr: null, + maxHr: null, + }; + const html = render(); + expect(html).toContain("Per-second heart-rate"); + expect(html).not.toContain("145 bpm"); + }); +}); diff --git a/src/components/insights/__tests__/workout-list.test.tsx b/src/components/insights/__tests__/workout-list.test.tsx new file mode 100644 index 000000000..98b664ded --- /dev/null +++ b/src/components/insights/__tests__/workout-list.test.tsx @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { WorkoutList } from "../workout-list"; +import type { WorkoutListEntry } from "@/hooks/use-workouts"; + +/** + * v1.4.32 — `` unit tests. + * + * The list primitive renders one row per workout with the sport icon, + * the date, the duration, and the optional distance + active-energy + * chips. The test pins the visible labels + the deep-link href shape. + */ + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +const ROW: WorkoutListEntry = { + id: "w-1", + sportType: "running", + startedAt: "2026-05-15T07:00:00Z", + endedAt: "2026-05-15T07:30:00Z", + durationSec: 1800, + distanceM: 5200, + activeEnergyKcal: 320, + avgHr: 145, + maxHr: 170, + source: "APPLE_HEALTH", + externalId: "ext-w-1", +}; + +describe("", () => { + it("renders each workout as a deep-link row", () => { + const html = render(); + expect(html).toContain('href="/insights/workouts/w-1"'); + expect(html).toContain("Running"); + expect(html).toContain("30m"); + }); + + it("renders the distance and energy chips when present", () => { + const html = render(); + expect(html).toContain("5.20 km"); + expect(html).toContain("320 kcal"); + }); + + it("omits the chip when the field is null", () => { + const noDistance: WorkoutListEntry = { + ...ROW, + distanceM: null, + activeEnergyKcal: null, + }; + const html = render(); + expect(html).not.toContain('data-slot="workout-list-distance"'); + expect(html).not.toContain('data-slot="workout-list-energy"'); + }); + + it("falls back to the canonical sport string for unknown sport types", () => { + const wild: WorkoutListEntry = { ...ROW, sportType: "freediving" }; + const html = render(); + // No `insights.workouts.sport.freediving` key in the locale; the + // helper falls back to the canonical string so a new HK enum + // value never paints the raw `insights.workouts.sport.x` + // placeholder. + expect(html).toContain("freediving"); + expect(html).not.toContain("insights.workouts.sport."); + }); +}); diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx new file mode 100644 index 000000000..16e323c2a --- /dev/null +++ b/src/components/insights/healthkit-metric-page.tsx @@ -0,0 +1,139 @@ +"use client"; + +import Link from "next/link"; +import type { ComponentProps, ReactNode } from "react"; + +import { useAuth } from "@/hooks/use-auth"; +import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; +import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; +import { useTranslations } from "@/lib/i18n/context"; +import type { InsightMetric } from "@/lib/insights/metric-availability"; +import type { ChartOverlayKey } from "@/lib/dashboard-layout"; +import { Button } from "@/components/ui/button"; +import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; +import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; +import { MetricEmptyState } from "@/components/insights/metric-empty-state"; +import { SubPageShell } from "@/components/insights/sub-page-shell"; + +/** + * v1.4.32 — shared scaffold for the wave-A HealthKit metric pages. + * + * Each of the five new metric sub-pages (HRV, resting HR, oxygen + * saturation, body temperature, active energy) shares the same + * envelope: + * + * 1. `` with a localised title + description. + * 2. Empty-state CTA when the user has no observations for the + * metric — points at `/measurements?add=` so the existing + * quick-entry dialog can light up. The CTA target is optional; + * Apple-Health-only metrics (active energy, body temperature) + * pass `null` to render an onboarding hint without a dead link. + * 3. `` mounted on the canonical + * `ChartOverlayKey` so the chart-cog popover persists per metric. + * 4. `` at the foot — feature-flag gating runs + * inside the button so this scaffold stays agnostic. + * + * Adding a new metric is a four-line page module that hands the right + * Zod / MeasurementType + ChartOverlayKey + i18n-key prefix to this + * component; the template stays single-source-of-truth. + */ + +export interface HealthKitMetricPageProps { + /** The MeasurementType that backs the chart. */ + measurementType: string; + /** The InsightMetric key used by `useInsightsAnalytics()`. */ + insightMetric: InsightMetric; + /** The chart-overlay slot id. */ + chartKey: ChartOverlayKey; + /** + * i18n key prefix that drives `title`, `description`, `chartTitle`, + * `emptyState.title`, `emptyState.description`, `emptyState.cta`. + * Pages pass e.g. `"insights.hrv"` and the scaffold resolves each + * sub-key off the prefix. + */ + i18nPrefix: string; + /** Single canonical colour for the chart line. */ + color: string; + /** Unit suffix the chart renders next to the value. */ + unit: string; + /** Optional y-axis label shown above the unit. */ + yAxisUnit?: string; + /** Optional value bands (Apple-Health-style target zone shading). */ + valueBands?: ComponentProps["valueBands"]; + /** + * Optional empty-state CTA target. `null` renders the empty state + * without a primary action (Apple-Health-only metrics that have no + * manual entry form). String values land in `/measurements?add=`. + */ + emptyStateCtaType?: string | null; + /** Icon node mounted in the empty-state card. */ + emptyStateIcon: ReactNode; + /** + * Optional Coach prefill for the empty-state launch. Falls back to a + * generic onboarding prompt threaded through ``'s + * default. + */ + coachPrefill?: string; +} + +export function HealthKitMetricPage({ + measurementType, + insightMetric, + chartKey, + i18nPrefix, + color, + unit, + yAxisUnit, + valueBands, + emptyStateCtaType, + emptyStateIcon, + coachPrefill, +}: HealthKitMetricPageProps) { + const { user, isAuthenticated } = useAuth(); + const { t } = useTranslations(); + const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated); + + const { isEmpty } = useInsightsAnalytics(insightMetric); + + const title = t(`${i18nPrefix}.title`); + const description = t(`${i18nPrefix}.description`); + + if (isEmpty) { + const ctaNode = + emptyStateCtaType != null ? ( + + ) : null; + return ( + + + + ); + } + + return ( + + + + + ); +} diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index 9618d1c70..3b2342a52 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -4,6 +4,7 @@ import { useMemo, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; +import { useWorkouts } from "@/hooks/use-workouts"; import { InsightsTabStrip } from "@/components/insights/insights-tab-strip"; import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor"; import type { InsightInputs } from "@/lib/insights/metric-availability"; @@ -69,6 +70,12 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { enabled: isAuthenticated, }); + // v1.4.32 — workout-presence gate. The workouts pill gates on at + // least one canonical row in the list-endpoint response. The + // single-row probe shares its cache slot with the list page + + // dashboard tile so navigation between surfaces is a free cache hit. + const workoutsProbe = useWorkouts({ limit: 1 }); + // v1.4.31 — memoise the `availability` prop so an unchanged // payload doesn't recreate the object on every cache-write of // analytics or comprehensive. The strip is wrapped in @@ -80,10 +87,11 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { const hasMood = (comprehensiveQuery.data?.moodSummary?.count ?? 0) > 0; const hasMedication = (comprehensiveQuery.data?.medications?.length ?? 0) > 0; + const hasWorkouts = (workoutsProbe.data?.workouts.length ?? 0) > 0; const availability: InsightInputs | undefined = useMemo(() => { if (!isAuthenticated) return undefined; - return { summaries, hasMood, hasMedication }; - }, [isAuthenticated, summaries, hasMood, hasMedication]); + return { summaries, hasMood, hasMedication, hasWorkouts }; + }, [isAuthenticated, summaries, hasMood, hasMedication, hasWorkouts]); return (
diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index 5e8087b69..6c62c5302 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -94,6 +94,29 @@ const SUB_PAGE_TABS: Record< medikamente: { labelKey: "insights.navMedication", metric: "MEDICATION" }, bmi: { labelKey: "insights.navBmi", metric: "BMI" }, schlaf: { labelKey: "insights.navSleep", metric: "SLEEP_DURATION" }, + // v1.4.32 — workouts pill. Gate is event-driven; the + // availability helper reads `inputs.hasWorkouts` rather than a + // `summaries[…].count`. + workouts: { labelKey: "insights.navWorkouts", metric: "WORKOUTS" }, + // v1.4.32 — five wave-A HealthKit pills. Each one reads its own + // MeasurementType count from the analytics summaries. + hrv: { labelKey: "insights.navHrv", metric: "HEART_RATE_VARIABILITY" }, + ruhepuls: { + labelKey: "insights.navRestingHr", + metric: "RESTING_HEART_RATE", + }, + sauerstoff: { + labelKey: "insights.navOxygenSaturation", + metric: "OXYGEN_SATURATION", + }, + koerpertemperatur: { + labelKey: "insights.navBodyTemperature", + metric: "BODY_TEMPERATURE", + }, + "aktive-energie": { + labelKey: "insights.navActiveEnergy", + metric: "ACTIVE_ENERGY_BURNED", + }, }; function buildTabs(availability: InsightInputs | undefined): TabEntry[] { diff --git a/src/components/insights/workout-detail.tsx b/src/components/insights/workout-detail.tsx new file mode 100644 index 000000000..496c9d0ec --- /dev/null +++ b/src/components/insights/workout-detail.tsx @@ -0,0 +1,438 @@ +"use client"; + +import type { ReactNode } from "react"; +import { + Activity, + Bike, + Dumbbell, + Flame, + Footprints, + HeartPulse, + Map, + Mountain, + PersonStanding, + Timer, + type LucideIcon, +} from "lucide-react"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; +import { useTranslations } from "@/lib/i18n/context"; +import type { WorkoutDetailPayload } from "@/hooks/use-workouts"; + +/** + * v1.4.32 — workout-detail components: header, stats, optional route, + * optional HR chart. + * + * Mounted by `/insights/workouts/[id]/page.tsx`. The page itself owns + * the data fetch + the empty / loading / error branches; each + * component below is a pure render primitive over a non-null + * `WorkoutDetailPayload`. + */ + +const SPORT_TYPE_ICON: Record = { + walking: Footprints, + running: PersonStanding, + cycling: Bike, + hiking: Mountain, + swimming: Activity, + rowing: Activity, + elliptical: Activity, + stairClimber: Activity, + yoga: PersonStanding, + mindAndBody: PersonStanding, + strength: Dumbbell, + hiit: Activity, + dance: Activity, + golf: Activity, + tennis: Activity, + basketball: Activity, + soccer: Activity, + crossTraining: Activity, + mixedCardio: HeartPulse, + other: Activity, +}; + +function iconForSport(sportType: string): LucideIcon { + return SPORT_TYPE_ICON[sportType] ?? Activity; +} + +function formatDuration(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + if (h > 0) { + return `${h}h ${m.toString().padStart(2, "0")}m ${s.toString().padStart(2, "0")}s`; + } + return `${m}m ${s.toString().padStart(2, "0")}s`; +} + +function formatDistanceKm(meters: number, locale: string): string { + const km = meters / 1000; + return new Intl.NumberFormat(locale, { + maximumFractionDigits: 2, + minimumFractionDigits: km < 10 ? 2 : 1, + }).format(km); +} + +function formatNumber(value: number, locale: string, fractionDigits = 0): string { + return new Intl.NumberFormat(locale, { + maximumFractionDigits: fractionDigits, + minimumFractionDigits: fractionDigits, + }).format(value); +} + +function formatPaceMinPerKm(durationSec: number, meters: number, locale: string): string { + // Pace is the seconds-per-kilometre value. Only meaningful for + // run / walk / hike. The caller already gated on `meters > 0`. + const secPerKm = (durationSec / meters) * 1000; + const m = Math.floor(secPerKm / 60); + const s = Math.round(secPerKm % 60); + return `${m}:${s.toString().padStart(2, "0")} ${locale === "en" ? "/km" : "/km"}`; +} + +function formatDateRange(startedAt: string, endedAt: string, locale: string): string { + const start = new Date(startedAt); + const end = new Date(endedAt); + const dateFmt = new Intl.DateTimeFormat(locale, { + weekday: "long", + day: "2-digit", + month: "long", + year: "numeric", + }); + const timeFmt = new Intl.DateTimeFormat(locale, { + hour: "2-digit", + minute: "2-digit", + }); + return `${dateFmt.format(start)} · ${timeFmt.format(start)} – ${timeFmt.format(end)}`; +} + +// ── Header ─────────────────────────────────────────────────────────── + +export interface WorkoutDetailHeaderProps { + workout: WorkoutDetailPayload; +} + +function renderSportIconBadge(sportType: string) { + const Icon = iconForSport(sportType); + return ( + + ); +} + +export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) { + const { t, locale } = useTranslations(); + const sportLabelKey = `insights.workouts.sport.${workout.sportType}`; + const sportLabel = t(sportLabelKey); + const sportName = + sportLabel === sportLabelKey ? workout.sportType : sportLabel; + + return ( +
+ {renderSportIconBadge(workout.sportType)} +
+

+ {workout.source} +

+

+ {sportName} +

+

+ {formatDateRange(workout.startedAt, workout.endedAt, locale)} +

+
+
+ + {formatDuration(workout.durationSec)} + + {workout.distanceM != null ? ( + + {formatDistanceKm(workout.distanceM, locale)} km + + ) : null} +
+
+ ); +} + +// ── Stats ──────────────────────────────────────────────────────────── + +interface StatTileProps { + icon: ReactNode; + label: string; + value: string; + hint?: string; +} + +function StatTile({ icon, label, value, hint }: StatTileProps) { + return ( +
+ + + {label} + + {value} + {hint ? ( + {hint} + ) : null} +
+ ); +} + +export interface WorkoutDetailStatsProps { + workout: WorkoutDetailPayload; +} + +export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) { + const { t, locale } = useTranslations(); + const tiles: StatTileProps[] = []; + + tiles.push({ + icon: , + label: t("insights.workouts.detail.statsDuration"), + value: formatDuration(workout.durationSec), + }); + + if (workout.distanceM != null && workout.distanceM > 0) { + tiles.push({ + icon: , + label: t("insights.workouts.detail.statsDistance"), + value: `${formatDistanceKm(workout.distanceM, locale)} km`, + }); + } + + if (workout.activeEnergyKcal != null) { + tiles.push({ + icon: , + label: t("insights.workouts.detail.statsActiveEnergy"), + value: `${formatNumber(workout.activeEnergyKcal, locale)} kcal`, + }); + } + + if (workout.avgHr != null) { + tiles.push({ + icon: , + label: t("insights.workouts.detail.statsAvgHr"), + value: `${workout.avgHr} bpm`, + hint: + workout.maxHr != null + ? `${t("insights.workouts.detail.statsMaxHr")}: ${workout.maxHr} bpm` + : undefined, + }); + } + + if (workout.minHr != null) { + tiles.push({ + icon: , + label: t("insights.workouts.detail.statsMinHr"), + value: `${workout.minHr} bpm`, + }); + } + + if (workout.stepCount != null && workout.stepCount > 0) { + tiles.push({ + icon: , + label: t("insights.workouts.detail.statsStepCount"), + value: formatNumber(workout.stepCount, locale), + }); + } + + if (workout.elevationM != null && workout.elevationM !== 0) { + tiles.push({ + icon: , + label: t("insights.workouts.detail.statsElevation"), + value: `${formatNumber(workout.elevationM, locale, 1)} m`, + }); + } + + // Pace is only meaningful for foot- and ride-paced sports. Surface + // it when the workout has both a distance and a sport with a + // canonical pace unit; the fallback covers the long-tail HK enum + // values without lighting up a misleading number. + const PACE_SPORTS = new Set([ + "walking", + "running", + "hiking", + "cycling", + ]); + if ( + workout.distanceM != null && + workout.distanceM > 100 && + PACE_SPORTS.has(workout.sportType) + ) { + tiles.push({ + icon: , + label: t("insights.workouts.detail.statsPace"), + value: formatPaceMinPerKm(workout.durationSec, workout.distanceM, locale), + }); + } + + return ( + + + + {t("insights.workouts.detail.statsTitle")} + + + + {tiles.map((tile) => ( + + ))} + + + ); +} + +// ── Route ──────────────────────────────────────────────────────────── + +export interface WorkoutDetailRouteProps { + workout: WorkoutDetailPayload; +} + +interface LineStringGeometry { + type: "LineString"; + coordinates: Array<[number, number] | [number, number, number]>; +} + +function isLineString(value: unknown): value is LineStringGeometry { + if (!value || typeof value !== "object") return false; + const v = value as Record; + return v.type === "LineString" && Array.isArray(v.coordinates); +} + +export function WorkoutDetailRoute({ workout }: WorkoutDetailRouteProps) { + const { t } = useTranslations(); + const geometry = workout.route?.geometry; + + if (!isLineString(geometry) || geometry.coordinates.length < 2) { + return ( + + + + {t("insights.workouts.detail.routeTitle")} + + + + {t("insights.workouts.detail.routeUnavailable")} + + + ); + } + + // The map widget itself defers to v1.5.x (MapLibre / Leaflet not yet + // pulled in). For v1.4.32 we paint a lightweight SVG polyline of the + // route geometry — projection-free, normalised against the bounding + // box, no third-party dependency. Resolution is fine on screen + // because routes are bounded to 20 000 points by the ingest cap. + const coordinates = geometry.coordinates; + let minLon = Infinity; + let maxLon = -Infinity; + let minLat = Infinity; + let maxLat = -Infinity; + for (const [lon, lat] of coordinates) { + if (lon < minLon) minLon = lon; + if (lon > maxLon) maxLon = lon; + if (lat < minLat) minLat = lat; + if (lat > maxLat) maxLat = lat; + } + const padLon = (maxLon - minLon) * 0.05 || 0.0001; + const padLat = (maxLat - minLat) * 0.05 || 0.0001; + minLon -= padLon; + maxLon += padLon; + minLat -= padLat; + maxLat += padLat; + const VIEW_W = 400; + const VIEW_H = 240; + const points = coordinates + .map(([lon, lat]) => { + const x = ((lon - minLon) / (maxLon - minLon)) * VIEW_W; + // SVG y-axis grows downward; flip lat → y. + const y = VIEW_H - ((lat - minLat) / (maxLat - minLat)) * VIEW_H; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }) + .join(" "); + + return ( + + + + {t("insights.workouts.detail.routeTitle")} + + + + + + + + + ); +} + +// ── Heart-rate chart ───────────────────────────────────────────────── + +export function WorkoutDetailHRChart({ workout }: { workout: WorkoutDetailPayload }) { + const { t } = useTranslations(); + + // Per-second HR samples are not persisted on the Workout row today — + // the W8d ingest schema stores aggregates only (avg / max / min). + // The per-second series lives on the corresponding `Measurement` + // rows posted alongside the workout via `POST /api/measurements/batch`. + // Wiring that into the detail page consumes a future v1.5.x sprint + // (see R-F T2 + W8d outline); for v1.4.32 the slot renders a + // graceful empty state with the aggregates already surfaced on + // ``. + return ( + + + + {t("insights.workouts.detail.hrChartTitle")} + + + +

{t("insights.workouts.detail.hrChartUnavailable")}

+ {workout.avgHr != null || workout.maxHr != null ? ( +

+ {workout.avgHr != null + ? `${t("insights.workouts.detail.statsAvgHr")}: ${workout.avgHr} bpm` + : null} + {workout.avgHr != null && workout.maxHr != null ? " · " : ""} + {workout.maxHr != null + ? `${t("insights.workouts.detail.statsMaxHr")}: ${workout.maxHr} bpm` + : null} +

+ ) : null} +
+
+ ); +} diff --git a/src/components/insights/workout-list.tsx b/src/components/insights/workout-list.tsx new file mode 100644 index 000000000..44d0dce3f --- /dev/null +++ b/src/components/insights/workout-list.tsx @@ -0,0 +1,159 @@ +"use client"; + +import Link from "next/link"; +import { + Activity, + Bike, + Dumbbell, + Footprints, + Heart, + Mountain, + PersonStanding, + type LucideIcon, +} from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { useTranslations } from "@/lib/i18n/context"; +import type { WorkoutListEntry } from "@/hooks/use-workouts"; + +/** + * v1.4.32 — workout list row primitive. + * + * Renders the canonical workouts list returned by `GET /api/workouts`. + * Each row links to `/insights/workouts/[id]`. The visual shape mirrors + * the dashboard tile primitives — single-line metadata strip with the + * sport icon, the date, the duration, and (when present) the distance + * + active-energy chips. The Apple Watch + Withings cross-source merge + * runs server-side, so the list is already deduped by the time it + * lands here. + */ + +const SPORT_TYPE_ICON: Record = { + walking: Footprints, + running: PersonStanding, + cycling: Bike, + hiking: Mountain, + swimming: Activity, + rowing: Activity, + elliptical: Activity, + stairClimber: Activity, + yoga: PersonStanding, + mindAndBody: PersonStanding, + strength: Dumbbell, + hiit: Activity, + dance: Activity, + golf: Activity, + tennis: Activity, + basketball: Activity, + soccer: Activity, + crossTraining: Activity, + mixedCardio: Heart, + other: Activity, +}; + +function iconForSport(sportType: string): LucideIcon { + return SPORT_TYPE_ICON[sportType] ?? Activity; +} + +function formatDuration(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (h > 0) return `${h}h ${m.toString().padStart(2, "0")}m`; + return `${m}m`; +} + +function formatDistanceKm(meters: number, locale: string): string { + const km = meters / 1000; + return new Intl.NumberFormat(locale, { + maximumFractionDigits: 2, + minimumFractionDigits: km < 10 ? 2 : 1, + }).format(km); +} + +function formatEnergy(kcal: number, locale: string): string { + return new Intl.NumberFormat(locale, { + maximumFractionDigits: 0, + }).format(Math.round(kcal)); +} + +function formatDate(iso: string, locale: string): string { + const d = new Date(iso); + return new Intl.DateTimeFormat(locale, { + weekday: "short", + day: "2-digit", + month: "short", + hour: "2-digit", + minute: "2-digit", + }).format(d); +} + +export interface WorkoutListProps { + workouts: WorkoutListEntry[]; + className?: string; +} + +export function WorkoutList({ workouts, className }: WorkoutListProps) { + const { t, locale } = useTranslations(); + + return ( +
    + {workouts.map((workout) => { + const Icon = iconForSport(workout.sportType); + const sportLabelKey = `insights.workouts.sport.${workout.sportType}`; + const sportLabel = t(sportLabelKey); + // Translation keys missing from one locale fall back to the + // canonical sport-type string so a new HK enum value never + // renders the raw `insights.workouts.sport.x` placeholder. + const sportName = sportLabel === sportLabelKey + ? workout.sportType + : sportLabel; + + return ( +
  • + + +
    + {sportName} + + {formatDate(workout.startedAt, locale)} + +
    +
    + + {formatDuration(workout.durationSec)} + + + {workout.distanceM != null ? ( + + {formatDistanceKm(workout.distanceM, locale)} km + + ) : null} + {workout.activeEnergyKcal != null ? ( + + {formatEnergy(workout.activeEnergyKcal, locale)} kcal + + ) : null} + +
    + +
  • + ); + })} +
+ ); +} diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 3b445a1ec..a2338e6c6 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -46,6 +46,8 @@ const WIDGET_LABEL_KEYS: Record = { achievements: "achievements.title", // v1.4.25 W8d — VO2 max secondary-metric tile (opt-in). vo2Max: "dashboard.vo2Max", + // v1.4.32 — Recent workouts tile (default-on). + recentWorkouts: "dashboard.recentWorkouts.title", }; export function DashboardLayoutSection({ id }: { id: string }) { diff --git a/src/hooks/use-workouts.ts b/src/hooks/use-workouts.ts new file mode 100644 index 000000000..08b1e705d --- /dev/null +++ b/src/hooks/use-workouts.ts @@ -0,0 +1,160 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { useAuth } from "@/hooks/use-auth"; +import { queryKeys } from "@/lib/query-keys"; + +/** + * v1.4.32 — TanStack Query wrapper for `GET /api/workouts`. + * + * Consumers: + * - `/insights/workouts` — the workout list page reads the most + * recent N workouts and renders them as one-line rows. + * - `` — the dashboard tile reads the top 3. + * + * Cache key shape: + * - `["workouts", "recent", { limit, since }]` — keyed on the + * pagination + filter inputs so the recent-tile and the list page + * keep separate cache slots without colliding. + * - `["workouts", id]` — single-workout detail (the workout-detail + * page consumes this separately via `useWorkoutDetail`). + */ + +export interface WorkoutListEntry { + id: string; + sportType: string; + startedAt: string; + endedAt: string; + durationSec: number; + distanceM: number | null; + activeEnergyKcal: number | null; + avgHr: number | null; + maxHr: number | null; + source: string; + externalId: string | null; +} + +export interface WorkoutListMeta { + total: number; + limit: number; + offset: number; + droppedDuplicates: number; +} + +export interface WorkoutListPayload { + workouts: WorkoutListEntry[]; + meta: WorkoutListMeta; +} + +export interface UseWorkoutsOptions { + /** Page size — defaults to 50, capped at 200 by the server. */ + limit?: number; + /** Page offset — defaults to 0. */ + offset?: number; + /** + * ISO timestamp — only workouts with `startedAt >= since` are + * returned. The dashboard tile passes a 7-day window; the list page + * leaves it open so the user can scroll back through history. + */ + since?: string; + /** Optional sport-type filter. Pass through the canonical string. */ + sportType?: string; +} + +async function fetchWorkouts( + opts: UseWorkoutsOptions, +): Promise { + const params = new URLSearchParams(); + if (opts.limit != null) params.set("limit", String(opts.limit)); + if (opts.offset != null) params.set("offset", String(opts.offset)); + if (opts.since) params.set("since", opts.since); + if (opts.sportType) params.set("sportType", opts.sportType); + const query = params.toString(); + const url = query ? `/api/workouts?${query}` : "/api/workouts"; + + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Failed to load workouts (${res.status})`); + } + const json = (await res.json()) as { data: WorkoutListPayload }; + return json.data; +} + +export interface UseWorkoutsResult { + data: WorkoutListPayload | undefined; + isLoading: boolean; + isEmpty: boolean; + error: Error | null; +} + +export function useWorkouts(opts: UseWorkoutsOptions = {}): UseWorkoutsResult { + const { isAuthenticated } = useAuth(); + + const query = useQuery({ + queryKey: [ + ...queryKeys.workoutsRecent(), + { limit: opts.limit, offset: opts.offset, since: opts.since, sportType: opts.sportType }, + ] as const, + queryFn: () => fetchWorkouts(opts), + enabled: isAuthenticated, + staleTime: 60 * 1000, + }); + + const isEmpty = + Boolean(isAuthenticated) && + query.data !== undefined && + query.data.workouts.length === 0; + + return { + data: query.data, + isLoading: query.isLoading, + isEmpty, + error: query.error as Error | null, + }; +} + +export interface WorkoutDetailPayload extends WorkoutListEntry { + minHr: number | null; + stepCount: number | null; + elevationM: number | null; + pauseDurationSec: number | null; + metadata: unknown; + route: + | { + geometry: unknown; + sampleTimestamps: string[] | null; + } + | null; + canonicalId: string; +} + +async function fetchWorkoutDetail(id: string): Promise { + const res = await fetch(`/api/workouts/${encodeURIComponent(id)}`); + if (!res.ok) { + throw new Error(`Failed to load workout (${res.status})`); + } + const json = (await res.json()) as { data: WorkoutDetailPayload }; + return json.data; +} + +export interface UseWorkoutDetailResult { + data: WorkoutDetailPayload | undefined; + isLoading: boolean; + error: Error | null; +} + +export function useWorkoutDetail(id: string): UseWorkoutDetailResult { + const { isAuthenticated } = useAuth(); + const query = useQuery({ + queryKey: queryKeys.workoutDetail(id), + queryFn: () => fetchWorkoutDetail(id), + enabled: isAuthenticated && id.length > 0, + staleTime: 60 * 1000, + }); + return { + data: query.data, + isLoading: query.isLoading, + error: query.error as Error | null, + }; +} diff --git a/src/lib/__tests__/dashboard-layout.test.ts b/src/lib/__tests__/dashboard-layout.test.ts index ccebc0c15..bf41f6133 100644 --- a/src/lib/__tests__/dashboard-layout.test.ts +++ b/src/lib/__tests__/dashboard-layout.test.ts @@ -141,6 +141,15 @@ describe("DEFAULT_DASHBOARD_LAYOUT contract", () => { expect(ids).toContain("achievements"); }); + it("includes the recentWorkouts widget (v1.4.32) default-visible", () => { + const widget = DEFAULT_DASHBOARD_LAYOUT.widgets.find( + (w) => w.id === "recentWorkouts", + ); + expect(widget).toBeDefined(); + expect(widget?.visible).toBe(true); + expect(widget?.tileVisible).toBe(true); + }); + it("does NOT advertise a retired insightsPreview widget (v1.4.27 B1)", () => { // v1.4.27 B1 retired the standalone dashboard preview because it // duplicated the much-richer `/insights` advisor surface. The diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 0c6b542b0..efa564769 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -35,6 +35,13 @@ export const DASHBOARD_WIDGET_IDS = [ // Settings → Dashboard. Only the tile-strip surface; chart row // lands alongside the iOS-app body-composition sub-page in v1.5. "vo2Max", + // v1.4.32 — "Recent workouts" tile. Surfaces the three most-recent + // canonical workouts; self-gates on at least one row. Default-on + // (`visible: true`) because the maintainer wants new accounts to + // see the surface even when empty so the Apple-Health onboarding + // cue is discoverable from the dashboard rather than hidden + // behind Settings. + "recentWorkouts", ] as const; export type DashboardWidgetId = (typeof DASHBOARD_WIDGET_IDS)[number]; @@ -124,6 +131,14 @@ export const CHART_OVERLAY_KEYS = [ // overlay-prefs slot so the cardio sub-page's VO2 chart-cog state // doesn't bleed into the pulse chart sitting directly above it. "vo2Max", + // v1.4.32 — wave-A HealthKit chart cards. Each one owns its own + // overlay-prefs slot so the chart-cog popover (7-day trend / Trend + // arrow / Target range / Comparison baseline) persists per metric. + "hrv", + "restingHr", + "oxygenSaturation", + "bodyTemperature", + "activeEnergy", ] as const; export type ChartOverlayKey = (typeof CHART_OVERLAY_KEYS)[number]; @@ -216,6 +231,12 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = { // representation today (`visible` stays false so the chart-row // gate never fires; the chart card lands in v1.5). { id: "vo2Max", visible: false, tileVisible: false, order: 14 }, + // v1.4.32 — Recent workouts dashboard tile. Default-visible so + // brand-new accounts discover the workout surface without + // hunting through Settings; the tile self-gates on a non-empty + // workouts list and renders an Apple-Health onboarding hint + // otherwise. + { id: "recentWorkouts", visible: true, tileVisible: true, order: 15 }, ], }; diff --git a/src/lib/insights/metric-availability.ts b/src/lib/insights/metric-availability.ts index b75a873ad..e0206d0af 100644 --- a/src/lib/insights/metric-availability.ts +++ b/src/lib/insights/metric-availability.ts @@ -51,7 +51,16 @@ export type InsightMetric = | "SLEEP_DURATION" | "VO2_MAX" | "STEPS" - | "ACTIVE_ENERGY"; + | "ACTIVE_ENERGY" + // v1.4.32 — wave-A HealthKit metrics promoted to first-class sub-pages. + | "HEART_RATE_VARIABILITY" + | "RESTING_HEART_RATE" + | "OXYGEN_SATURATION" + | "BODY_TEMPERATURE" + | "ACTIVE_ENERGY_BURNED" + // v1.4.32 — workouts surface; gate is event-driven (workouts table + // row count, not a Measurement series) and threaded via `hasWorkouts`. + | "WORKOUTS"; /** * Inputs the gating helper consumes. The `summaries` shape mirrors @@ -66,6 +75,13 @@ export interface InsightInputs { hasMood: boolean; /** Whether the user has at least one active medication. */ hasMedication: boolean; + /** + * v1.4.32 — whether the user has at least one workout row. Drives + * the workouts pill + the workout list page's empty-state gate. + * Optional so legacy mounts that pre-date v1.4.32 keep type-checking; + * the helper treats `undefined` as "not available". + */ + hasWorkouts?: boolean; } /** @@ -80,6 +96,7 @@ export function hasMetricData( ): boolean { if (metric === "MOOD") return inputs.hasMood; if (metric === "MEDICATION") return inputs.hasMedication; + if (metric === "WORKOUTS") return inputs.hasWorkouts === true; if (metric === "BMI") { // BMI is derived from WEIGHT + the profile height. The chart // mounts even at one weight reading; the gate matches. diff --git a/src/lib/insights/sub-page-metric.ts b/src/lib/insights/sub-page-metric.ts index 4472d7c60..5084aa2a5 100644 --- a/src/lib/insights/sub-page-metric.ts +++ b/src/lib/insights/sub-page-metric.ts @@ -34,6 +34,17 @@ export const SUB_PAGE_METRIC = { // BMI is derived from WEIGHT + profile height (no separate series). bmi: ["WEIGHT"], schlaf: ["SLEEP_DURATION"], + // v1.4.32 — workouts surface; the page reads `Workout` rows directly + // through `useWorkouts()` rather than the `summaries` map, so the + // metric list stays empty. + workouts: [], + // v1.4.32 — five new HealthKit wave-A sub-pages. Each one reads its + // own MeasurementType series via the standard analytics path. + hrv: ["HEART_RATE_VARIABILITY"], + ruhepuls: ["RESTING_HEART_RATE"], + sauerstoff: ["OXYGEN_SATURATION"], + koerpertemperatur: ["BODY_TEMPERATURE"], + "aktive-energie": ["ACTIVE_ENERGY_BURNED"], } as const satisfies Record; export type SubPageSlug = keyof typeof SUB_PAGE_METRIC; diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index a522aafae..fc1876242 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -62,8 +62,6 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap< ["BLOOD_PRESSURE_SYS", "vitals"], ["BLOOD_PRESSURE_DIA", "vitals"], ["PULSE", "vitals"], - ["RESTING_HEART_RATE", "vitals"], - ["HEART_RATE_VARIABILITY", "vitals"], ["OXYGEN_SATURATION", "vitals"], ["BODY_TEMPERATURE", "vitals"], @@ -96,7 +94,15 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap< // ── Environment ── ["TIME_IN_DAYLIGHT", "environment"], - // ── Cardiovascular (derived risk markers) ── + // ── Cardiovascular (derived risk markers + Apple-Health-grouped + // heart-rate variants). v1.4.32 — RESTING_HEART_RATE + + // HEART_RATE_VARIABILITY moved here from `vitals` to align with the + // iOS handoff brief's category table (RESTING_HR, HRV → + // cardiovascular). PULSE stays in `vitals` because spot-pulse + // samples are point-in-time signals; the resting + variability + // metrics are the derived cardiac-risk surface. ── + ["RESTING_HEART_RATE", "cardiovascular"], + ["HEART_RATE_VARIABILITY", "cardiovascular"], ["PULSE_WAVE_VELOCITY", "cardiovascular"], ["VASCULAR_AGE", "cardiovascular"], diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 04fd318c3..0c571282c 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -315,6 +315,68 @@ const workoutBatchResponse = z }) .meta({ id: "WorkoutBatchResponse" }); +// v1.4.32 — workout list / detail wire shape. The field names follow +// the iOS handoff contract (`distanceM` + `activeEnergyKcal`) rather +// than the Prisma column names so iOS and web consumers share one +// JSON envelope. +const workoutListEntry = z + .object({ + id: z.string(), + sportType: z.string(), + startedAt: z.iso.datetime({ offset: true }), + endedAt: z.iso.datetime({ offset: true }), + durationSec: z.number().int().nonnegative(), + distanceM: z.number().nullable(), + activeEnergyKcal: z.number().nullable(), + avgHr: z.number().int().nullable(), + maxHr: z.number().int().nullable(), + source: measurementSourceEnum, + externalId: z.string().nullable(), + }) + .meta({ id: "WorkoutListEntry" }); + +const workoutListResponse = z + .object({ + workouts: z.array(workoutListEntry), + meta: z.object({ + total: z.number().int().nonnegative(), + limit: z.number().int().positive(), + offset: z.number().int().nonnegative(), + droppedDuplicates: z.number().int().nonnegative(), + }), + }) + .meta({ id: "WorkoutListResponse" }); + +const workoutRouteGeometry = z + .object({ + geometry: z.unknown(), + sampleTimestamps: z.array(z.string()).nullable(), + }) + .meta({ id: "WorkoutRouteGeometry" }); + +const workoutDetailResponse = z + .object({ + id: z.string(), + sportType: z.string(), + startedAt: z.iso.datetime({ offset: true }), + endedAt: z.iso.datetime({ offset: true }), + durationSec: z.number().int().nonnegative(), + distanceM: z.number().nullable(), + activeEnergyKcal: z.number().nullable(), + avgHr: z.number().int().nullable(), + maxHr: z.number().int().nullable(), + minHr: z.number().int().nullable(), + stepCount: z.number().int().nullable(), + elevationM: z.number().nullable(), + pauseDurationSec: z.number().int().nullable(), + source: measurementSourceEnum, + externalId: z.string().nullable(), + metadata: z.unknown().nullable(), + route: workoutRouteGeometry.nullable(), + canonicalId: z.string(), + }) + .meta({ id: "WorkoutDetailResponse" }); + const deleteByExternalIdsRequest = z .object({ externalIds: z.array(z.string().min(1).max(120)).min(0).max(500), @@ -558,6 +620,60 @@ export const openApiPaths: NonNullable = { }, }, }, + "/api/workouts": { + get: { + tags: ["Measurements"], + summary: "List workouts (v1.4.32)", + description: + "Paginated workout list with cross-source canonical-row dedup. The picker reads the per-user source-priority ladder and buckets by `(startedAt 5 min slot, sportType)` — twin workouts (Apple Watch + Withings ScanWatch) collapse to a single canonical row per cluster. Field names mirror the iOS handoff contract: `distanceM`, `activeEnergyKcal`, `avgHr`, `maxHr`.", + requestParams: { + query: z.object({ + limit: z.coerce.number().int().min(1).max(200).optional(), + offset: z.coerce.number().int().min(0).optional(), + since: z.iso.datetime({ offset: true }).optional(), + until: z.iso.datetime({ offset: true }).optional(), + sportType: z.string().optional(), + }), + }, + responses: { + "200": { + description: "Canonical workout page.", + content: { + "application/json": { + schema: dataEnvelope(workoutListResponse, "ListWorkoutsResponse"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/workouts/{id}": { + get: { + tags: ["Measurements"], + summary: "Workout detail (v1.4.32)", + description: + "Single-workout envelope. Owns the optional `WorkoutRoute` GeoJSON geometry + `canonicalId` pointer that resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Cross-user rows surface as 404 (existence channel sealed).", + requestParams: { + path: z.object({ id: z.string() }), + }, + responses: { + "200": { + description: "Workout detail.", + content: { + "application/json": { + schema: dataEnvelope(workoutDetailResponse, "GetWorkoutResponse"), + }, + }, + }, + "404": { + description: "Workout not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, "/api/workouts/batch": { post: { tags: ["Measurements"], diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 7980d0f41..fa5838635 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -64,6 +64,14 @@ export const queryKeys = { telegramSettings: () => ["telegram", "settings"] as const, withings: () => ["withings"] as const, + // v1.4.32 — workout list + detail caches. `workouts()` is the + // root key invalidated by the batch-ingest mutation; the recent + + // detail sub-keys ride underneath so the dashboard tile and the + // detail page share a cache slot with the list page. + workouts: () => ["workouts"] as const, + workoutsRecent: () => ["workouts", "recent"] as const, + workoutDetail: (id: string) => ["workouts", id] as const, + adminSettings: () => ["admin", "settings"] as const, adminStatus: () => ["admin", "status"] as const, adminUsers: () => ["admin", "users"] as const,