chore(release): v1.4.32

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.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-16 16:04:21 +02:00
1 parent 5ea813e20c
commit c7a0ff214b
41 files changed
+3858 -93

No files matched your search

+197
View File
@@ -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:
<https://github.com/MBombeck/HealthLog/releases/tag/v1.4.31>.
- 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-<short>` 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.<surface>"`. 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 `<CoachDrawer>`.
- **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.<surface>"`
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.
+116
View File
@@ -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] — <date> — 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 `<HealthKitMetricPage>` 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.
+71
View File
@@ -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 `<HealthKitMetricPage>`
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
+327
View File
@@ -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
+117 -1
View File
@@ -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": {
+117 -1
View File
@@ -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": {
+117 -1
View File
@@ -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": {
+117 -1
View File
@@ -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": {
+117 -1
View File
@@ -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": {
+117 -1
View File
@@ -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": {
+1 -1
View File
@@ -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",
+136
View File
@@ -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,
});
},
);
@@ -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"]);
});
});
@@ -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",
]);
});
});
+56 -53
View File
@@ -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,
},
});
});
+31
View File
@@ -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 (
<HealthKitMetricPage
measurementType="ACTIVE_ENERGY_BURNED"
insightMetric="ACTIVE_ENERGY_BURNED"
chartKey="activeEnergy"
i18nPrefix="insights.activeEnergy"
color="#ffb86c"
unit="kcal"
yAxisUnit="kcal"
emptyStateIcon={<Flame className="size-6" />}
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?"
/>
);
}
+31
View File
@@ -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 (
<HealthKitMetricPage
measurementType="HEART_RATE_VARIABILITY"
insightMetric="HEART_RATE_VARIABILITY"
chartKey="hrv"
i18nPrefix="insights.hrv"
color="#bd93f9"
unit="ms"
yAxisUnit="ms"
emptyStateIcon={<Activity className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any HRV data yet — what does heart-rate variability tell me, and how do I capture it?"
/>
);
}
@@ -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 (
<HealthKitMetricPage
measurementType="BODY_TEMPERATURE"
insightMetric="BODY_TEMPERATURE"
chartKey="bodyTemperature"
i18nPrefix="insights.bodyTemperature"
color="#ff79c6"
unit="°C"
yAxisUnit="°C"
emptyStateIcon={<Thermometer className="size-6" />}
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?"
/>
);
}
+31
View File
@@ -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 (
<HealthKitMetricPage
measurementType="RESTING_HEART_RATE"
insightMetric="RESTING_HEART_RATE"
chartKey="restingHr"
i18nPrefix="insights.restingHr"
color="#ff5555"
unit="bpm"
yAxisUnit="bpm"
emptyStateIcon={<Heart className="size-6" />}
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?"
/>
);
}
+40
View File
@@ -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 (
<HealthKitMetricPage
measurementType="OXYGEN_SATURATION"
insightMetric="OXYGEN_SATURATION"
chartKey="oxygenSaturation"
i18nPrefix="insights.oxygenSaturation"
color="#8be9fd"
unit="%"
yAxisUnit="%"
valueBands={[
// < 90 % — clinical hypoxia floor, paint the danger band.
{ min: 80, max: 90, color: "#ff5555", opacity: 0.18 },
// 90 .. 95 % — watch zone.
{ min: 90, max: 95, color: "#ffb86c", opacity: 0.18 },
// 95 .. 100 % — clinical normal.
{ min: 95, max: 100, color: "#50fa7b", opacity: 0.18 },
]}
emptyStateIcon={<Wind className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any oxygen-saturation data yet — what does SpO₂ tell me, and what range should I aim for?"
/>
);
}
+80
View File
@@ -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 (
<SubPageShell
title={t("insights.workouts.title")}
description={t("insights.workouts.description")}
>
<Button
asChild
variant="ghost"
size="sm"
data-slot="workout-detail-back"
>
<Link href="/insights/workouts">
<ArrowLeft className="mr-1 size-4" />
{t("insights.workouts.detail.backToList")}
</Link>
</Button>
{isLoading ? (
<div data-slot="workout-detail-loading" className="space-y-3">
<Skeleton className="h-20 w-full rounded-lg" />
<Skeleton className="h-40 w-full rounded-lg" />
<Skeleton className="h-60 w-full rounded-lg" />
</div>
) : error || !data ? (
<p
data-slot="workout-detail-error"
className="text-muted-foreground text-sm"
>
{t("insights.workouts.detail.notFound")}
</p>
) : (
<>
<WorkoutDetailHeader workout={data} />
<WorkoutDetailStats workout={data} />
<WorkoutDetailRoute workout={data} />
<WorkoutDetailHRChart workout={data} />
<CoachLaunchButton />
</>
)}
</SubPageShell>
);
}
+61
View File
@@ -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 (
<SubPageShell
title={t("insights.workouts.title")}
description={t("insights.workouts.description")}
>
{isLoading ? (
<div data-slot="workouts-loading" className="space-y-2">
<Skeleton className="h-14 w-full rounded-lg" />
<Skeleton className="h-14 w-full rounded-lg" />
<Skeleton className="h-14 w-full rounded-lg" />
</div>
) : isEmpty ? (
<MetricEmptyState
icon={<Activity className="size-6" />}
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 ? (
<>
<WorkoutList workouts={data.workouts} />
<CoachLaunchButton />
</>
) : null}
</SubPageShell>
);
}
+14
View File
@@ -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: <RecentAchievementsCard key="achievements" />,
});
}
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: <RecentWorkoutsTile key="recentWorkouts" />,
});
}
charts.sort((a, b) => a.order - b.order);
@@ -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 — `<RecentWorkoutsTile>` 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<typeof import("@/hooks/use-workouts")>(
"@/hooks/use-workouts",
);
return {
...actual,
useWorkouts: () => mockResult,
};
});
import { RecentWorkoutsTile } from "../recent-workouts-tile";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<RecentWorkoutsTile />
</I18nProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
mockResult = {
data: undefined,
isLoading: false,
isEmpty: false,
error: null,
};
});
describe("<RecentWorkoutsTile>", () => {
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");
});
});
@@ -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 — `<RecentWorkoutsTile>`.
*
* 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<string, LucideIcon> = {
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 (
<span
data-slot="recent-workouts-row"
className="flex items-center gap-3 text-sm"
>
<span
aria-hidden="true"
className="bg-muted text-muted-foreground flex size-7 shrink-0 items-center justify-center rounded-full"
>
<Icon className="size-3.5" />
</span>
<span className="min-w-0 flex-1 truncate font-medium">{sportName}</span>
</span>
);
}
export function RecentWorkoutsTile() {
const { t, locale } = useTranslations();
const { data, isLoading } = useWorkouts({ limit: 3 });
const workouts = data?.workouts ?? [];
return (
<div
data-slot="recent-workouts-tile"
className={cn(
"bg-card border-border space-y-3 rounded-xl border p-4",
)}
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Activity className="text-primary size-4" aria-hidden="true" />
<h2 className="text-sm font-semibold">
{t("dashboard.recentWorkouts.title")}
</h2>
</div>
<Link
href="/insights/workouts"
className="text-muted-foreground hover:text-foreground text-xs"
>
{t("dashboard.recentWorkouts.viewAll")}
</Link>
</div>
{isLoading ? (
<p
data-slot="recent-workouts-loading"
className="text-muted-foreground text-xs"
>
{t("dashboard.recentWorkouts.loading")}
</p>
) : workouts.length === 0 ? (
<div data-slot="recent-workouts-empty" className="space-y-1">
<p className="text-sm">
{t("dashboard.recentWorkouts.empty.title")}
</p>
<p className="text-muted-foreground text-xs">
{t("dashboard.recentWorkouts.empty.cta")}
</p>
</div>
) : (
<ul className="space-y-2">
{workouts.map((workout) => {
const sportLabelKey = `insights.workouts.sport.${workout.sportType}`;
const sportLabel = t(sportLabelKey);
const sportName =
sportLabel === sportLabelKey ? workout.sportType : sportLabel;
return (
<li key={workout.id}>
<Link
href={`/insights/workouts/${encodeURIComponent(workout.id)}`}
data-slot="recent-workouts-link"
className={cn(
"flex items-center gap-2 rounded-md px-1 py-1 transition-colors",
"hover:bg-accent focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
)}
>
{renderRow(workout, sportName)}
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
{formatDayLabel(workout.startedAt, locale)}
</span>
<span className="ml-1 shrink-0 text-xs font-medium tabular-nums">
{formatDuration(workout.durationSec)}
</span>
</Link>
</li>
);
})}
</ul>
)}
</div>
);
}
@@ -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 — `<WorkoutDetail*>` 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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
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("<WorkoutDetailHeader>", () => {
it("renders the sport label, source, and duration summary", () => {
const html = render(<WorkoutDetailHeader workout={FIXTURE} />);
expect(html).toContain("Running");
expect(html).toContain("APPLE_HEALTH");
expect(html).toContain("30m 00s");
expect(html).toContain("5.20 km");
});
});
describe("<WorkoutDetailStats>", () => {
it("renders one tile per available field", () => {
const html = render(<WorkoutDetailStats workout={FIXTURE} />);
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(<WorkoutDetailStats workout={minimal} />);
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("<WorkoutDetailRoute>", () => {
it("renders the empty-state when the workout has no route", () => {
const html = render(<WorkoutDetailRoute workout={FIXTURE} />);
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(<WorkoutDetailRoute workout={withRoute} />);
expect(html).toContain('data-slot="workout-detail-route"');
expect(html).toContain("<polyline");
});
it("falls back to the empty state for a LineString with fewer than 2 points", () => {
const stub: WorkoutDetailPayload = {
...FIXTURE,
route: {
geometry: { type: "LineString", coordinates: [[11, 49]] },
sampleTimestamps: null,
},
};
const html = render(<WorkoutDetailRoute workout={stub} />);
expect(html).toContain('data-slot="workout-detail-route-empty"');
});
});
describe("<WorkoutDetailHRChart>", () => {
it("renders the unavailable notice with aggregate HR values when present", () => {
const html = render(<WorkoutDetailHRChart workout={FIXTURE} />);
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(<WorkoutDetailHRChart workout={noHr} />);
expect(html).toContain("Per-second heart-rate");
expect(html).not.toContain("145 bpm");
});
});
@@ -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 — `<WorkoutList>` 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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
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("<WorkoutList>", () => {
it("renders each workout as a deep-link row", () => {
const html = render(<WorkoutList workouts={[ROW]} />);
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(<WorkoutList workouts={[ROW]} />);
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(<WorkoutList workouts={[noDistance]} />);
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(<WorkoutList workouts={[wild]} />);
// 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.");
});
});
@@ -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. `<SubPageShell>` with a localised title + description.
* 2. Empty-state CTA when the user has no observations for the
* metric — points at `/measurements?add=<TYPE>` 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. `<HealthChartDynamic>` mounted on the canonical
* `ChartOverlayKey` so the chart-cog popover persists per metric.
* 4. `<CoachLaunchButton>` 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<typeof HealthChartDynamic>["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=<x>`.
*/
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 `<CoachLaunchButton>`'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 ? (
<Button size="sm" asChild>
<Link href={`/measurements?add=${emptyStateCtaType}`}>
{t(`${i18nPrefix}.emptyState.cta`)}
</Link>
</Button>
) : null;
return (
<SubPageShell title={title} description={description}>
<MetricEmptyState
icon={emptyStateIcon}
title={t(`${i18nPrefix}.emptyState.title`)}
description={t(`${i18nPrefix}.emptyState.description`)}
cta={ctaNode}
coachPrefill={coachPrefill ?? null}
/>
</SubPageShell>
);
}
return (
<SubPageShell title={title} description={description}>
<HealthChartDynamic
chartKey={chartKey}
types={[measurementType]}
title={t(`${i18nPrefix}.chartTitle`)}
colors={[color]}
unit={unit}
yAxisUnit={yAxisUnit}
valueBands={valueBands}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
<CoachLaunchButton />
</SubPageShell>
);
}
@@ -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 (
<div className="space-y-8">
@@ -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[] {
+438
View File
@@ -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<string, LucideIcon> = {
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 (
<span
aria-hidden="true"
className="bg-muted text-muted-foreground flex size-12 shrink-0 items-center justify-center rounded-full"
>
<Icon className="size-6" />
</span>
);
}
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 (
<header
data-slot="workout-detail-header"
className="flex items-start gap-4"
>
{renderSportIconBadge(workout.sportType)}
<div className="min-w-0 flex-1 space-y-1">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
{workout.source}
</p>
<h2 className="truncate text-lg font-semibold sm:text-xl">
{sportName}
</h2>
<p className="text-muted-foreground text-sm">
{formatDateRange(workout.startedAt, workout.endedAt, locale)}
</p>
</div>
<div className="flex flex-col items-end text-right">
<span className="text-2xl font-semibold tabular-nums">
{formatDuration(workout.durationSec)}
</span>
{workout.distanceM != null ? (
<span className="text-muted-foreground text-xs tabular-nums">
{formatDistanceKm(workout.distanceM, locale)} km
</span>
) : null}
</div>
</header>
);
}
// ── Stats ────────────────────────────────────────────────────────────
interface StatTileProps {
icon: ReactNode;
label: string;
value: string;
hint?: string;
}
function StatTile({ icon, label, value, hint }: StatTileProps) {
return (
<div
data-slot="workout-detail-stat"
className="flex flex-col gap-1 rounded-lg border p-3"
>
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
<span aria-hidden="true" className="size-4">
{icon}
</span>
{label}
</span>
<span className="text-base font-semibold tabular-nums">{value}</span>
{hint ? (
<span className="text-muted-foreground text-[11px]">{hint}</span>
) : null}
</div>
);
}
export interface WorkoutDetailStatsProps {
workout: WorkoutDetailPayload;
}
export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
const { t, locale } = useTranslations();
const tiles: StatTileProps[] = [];
tiles.push({
icon: <Timer className="size-4" />,
label: t("insights.workouts.detail.statsDuration"),
value: formatDuration(workout.durationSec),
});
if (workout.distanceM != null && workout.distanceM > 0) {
tiles.push({
icon: <Map className="size-4" />,
label: t("insights.workouts.detail.statsDistance"),
value: `${formatDistanceKm(workout.distanceM, locale)} km`,
});
}
if (workout.activeEnergyKcal != null) {
tiles.push({
icon: <Flame className="size-4" />,
label: t("insights.workouts.detail.statsActiveEnergy"),
value: `${formatNumber(workout.activeEnergyKcal, locale)} kcal`,
});
}
if (workout.avgHr != null) {
tiles.push({
icon: <HeartPulse className="size-4" />,
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: <HeartPulse className="size-4" />,
label: t("insights.workouts.detail.statsMinHr"),
value: `${workout.minHr} bpm`,
});
}
if (workout.stepCount != null && workout.stepCount > 0) {
tiles.push({
icon: <Footprints className="size-4" />,
label: t("insights.workouts.detail.statsStepCount"),
value: formatNumber(workout.stepCount, locale),
});
}
if (workout.elevationM != null && workout.elevationM !== 0) {
tiles.push({
icon: <Mountain className="size-4" />,
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: <Timer className="size-4" />,
label: t("insights.workouts.detail.statsPace"),
value: formatPaceMinPerKm(workout.durationSec, workout.distanceM, locale),
});
}
return (
<Card data-slot="workout-detail-stats">
<CardHeader>
<CardTitle className="text-base">
{t("insights.workouts.detail.statsTitle")}
</CardTitle>
</CardHeader>
<CardContent
className={cn(
"grid gap-2",
"grid-cols-2 sm:grid-cols-3 lg:grid-cols-4",
)}
>
{tiles.map((tile) => (
<StatTile key={tile.label} {...tile} />
))}
</CardContent>
</Card>
);
}
// ── 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<string, unknown>;
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 (
<Card data-slot="workout-detail-route-empty">
<CardHeader>
<CardTitle className="text-base">
{t("insights.workouts.detail.routeTitle")}
</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground text-sm">
{t("insights.workouts.detail.routeUnavailable")}
</CardContent>
</Card>
);
}
// 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 (
<Card data-slot="workout-detail-route">
<CardHeader>
<CardTitle className="text-base">
{t("insights.workouts.detail.routeTitle")}
</CardTitle>
</CardHeader>
<CardContent>
<svg
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
className="bg-muted/40 h-60 w-full rounded-md"
role="img"
aria-label={t("insights.workouts.detail.routeTitle")}
>
<polyline
points={points}
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="text-primary"
/>
</svg>
</CardContent>
</Card>
);
}
// ── 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
// `<WorkoutDetailStats>`.
return (
<Card data-slot="workout-detail-hr-chart">
<CardHeader>
<CardTitle className="text-base">
{t("insights.workouts.detail.hrChartTitle")}
</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground space-y-2 text-sm">
<p>{t("insights.workouts.detail.hrChartUnavailable")}</p>
{workout.avgHr != null || workout.maxHr != null ? (
<p>
{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}
</p>
) : null}
</CardContent>
</Card>
);
}
+159
View File
@@ -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<string, LucideIcon> = {
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 (
<ul
data-slot="workout-list"
className={cn("divide-y divide-border rounded-lg border", className)}
>
{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 (
<li key={workout.id}>
<Link
href={`/insights/workouts/${encodeURIComponent(workout.id)}`}
data-slot="workout-list-row"
className={cn(
"flex items-center gap-3 px-3 py-3 text-sm transition-colors",
"hover:bg-accent focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
)}
>
<span
aria-hidden="true"
className="flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground"
>
<Icon className="size-4" />
</span>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate font-medium">{sportName}</span>
<span className="text-muted-foreground truncate text-xs">
{formatDate(workout.startedAt, locale)}
</span>
</div>
<div className="flex flex-col items-end gap-0.5 text-xs">
<span className="font-medium tabular-nums">
{formatDuration(workout.durationSec)}
</span>
<span className="text-muted-foreground flex flex-wrap justify-end gap-x-2 tabular-nums">
{workout.distanceM != null ? (
<span data-slot="workout-list-distance">
{formatDistanceKm(workout.distanceM, locale)} km
</span>
) : null}
{workout.activeEnergyKcal != null ? (
<span data-slot="workout-list-energy">
{formatEnergy(workout.activeEnergyKcal, locale)} kcal
</span>
) : null}
</span>
</div>
</Link>
</li>
);
})}
</ul>
);
}
@@ -46,6 +46,8 @@ const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
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 }) {
+160
View File
@@ -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.
* - `<RecentWorkoutsTile>` — 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<WorkoutListPayload> {
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<WorkoutDetailPayload> {
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,
};
}
@@ -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
+21
View File
@@ -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 },
],
};
+18 -1
View File
@@ -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.
+11
View File
@@ -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<string, readonly string[]>;
export type SubPageSlug = keyof typeof SUB_PAGE_METRIC;
+9 -3
View File
@@ -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"],
+116
View File
@@ -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<ZodOpenApiObject["paths"]> = {
},
},
},
"/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"],
+8
View File
@@ -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,