mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
5ea813e20c
commit
c7a0ff214b
41 files changed
+3858
-93
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"],
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user