The projection's central claim was carried by a comment. It is now walked
before it is returned: any non-numeric leaf at any depth throws, and the only
two string leaves are allowlisted by shape — the sport type against the schema
enum itself, and the day key against its format.
This matters because sport_type is a TEXT column whose API write path is
enum-constrained but whose backup-restore path accepts any non-empty string, so
a restored row can carry arbitrary text. It is re-asserted at the projection
boundary rather than trusted from the column. The heart-rate provenance string
is dropped rather than allowlisted, and route geometry stays consumed-not-carried
— it yields one number, and location history may not ride a prompt.
Own-history rows now collapse cross-source twins through the canonical picker,
as the detail page's comparison already did; a session recorded by two paired
watches was inflating the sample size and biasing every median.
One test per gate, each asserting the refusal reason AND that the provider
entry was never reached — a gate that refuses but still resolves a provider
would pass a reason-only assertion while costing what the gate exists to
prevent. The cap and the hash are asserted independently of each other, since
the singleton queue key that normally collapses a burst is best-effort.
The backfill fixture walks the real chain: 750 historical sessions plus 40
re-synced ones plus one fresh, through the live classifier and the live spine
worker, yielding exactly one generation.
The peak detector changed under test: on a flat ride the ninetieth percentile
sits a beat above the mean, so a bpm of sampling noise read as two efforts the
rider did not do. Peaks now need an absolute prominence over the session mean
as well.
The detail surface at /insights/workouts/[id] gains a real heart-rate
curve, effort zones, an aspect-true GPS route, and per-kilometre splits
— all read from data already stored, no new integration, no migration.
- HR curve: one server path with strict provenance — stored
WorkoutSamples series first, a raw-PULSE window reconstruction second
(quality-gated so sparse data hides rather than lies), nothing third.
Adaptive bucketing keeps on-screen density constant across a 20-minute
HIIT and a 6-hour ride; a min/max envelope surfaces intervals.
- Effort zones: five %HRmax bands via the Tanaka HRmax, with a device's
own reported time-in-zone winning when present. Descriptive only.
- GPS route: self-rendered inline SVG with an equirectangular
cos(midLat) longitude correction (fixes the ~40% horizontal stretch),
aspect-clamped viewBox, RDP downsampling, start/end markers, and an
optional elevation profile. Nothing leaves the origin — no map tiles.
- Per-km splits and a GPX export, both derived client/server-side from
the geometry already on the payload.
- The endpoint extends additively (hrSeries, zones, splits, sportContext,
a reserved insight seam) behind an opt-in compact=1 param that drops
the raw sample/timestamp blobs for the web client; without the param
the response is unchanged.
- Every data-less section now hides instead of rendering an empty card;
the growing detail component is split into a workout-detail/ directory.
- List rows flag which sessions open into a rich detail.
Strava was the second workout integration, after WHOOP, that wrote its
raw vendor sport label ("Run", "Ride", "VirtualRide", ...) straight into
Workout.sportType instead of a canonical bucket. Every Strava row read
as generic, and the read-time picker treats two generic rows as
sport-compatible — a same-source brick session (Strava ride ending
~15:00, Strava run starting 15:03) could cluster into one and silently
drop a leg.
Add mapStravaSportType() (54 documented Strava sport_type values,
mirroring the WHOOP sport-map shape) and apply it at write time in
mapActivity(); the raw label stays in metadata.stravaType for
provenance. Backfill existing Strava rows via a hand-written migration
generated from the same table. Extend the cross-provider sport-map
parity test to cover Strava, and add a picker-level test proving a
back-to-back Ride+Run pair now survives as two rows instead of one.
WHOOP was the only workout integration that never normalised its sport,
unlike the Fitbit and Google Health mappers — it wrote WHOOP's raw
sport_name or a literal whoop_sport_<id> placeholder straight into
Workout.sportType. Confirmed in prod: a user's WHOOP-tracked cycling
workouts never rendered as cycling on /insights/workouts because nothing
in the UI could map the non-canonical string to an icon or label.
sport-map.ts adds a sport_id/sport_name table sourced from WHOOP's public
Workout-object reference and mirrors the Fitbit/Google mapper shape:
sport_id is preferred (retired past 2025-09-01 but still exact when
present), sport_name is normalised before the fallback lookup, and
anything unrecognised resolves to "other" — never a placeholder. The raw
WHOOP fields stay in Workout.metadata for traceability.
Adds a shared table-driven test that checks Fitbit, Google Health, and
WHOOP sport mappers together so the three integrations can't silently
drift out of parity on common sports.
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
A typed apiFetch wrapper owns the envelope unwrap, the ok-check and the
error read for every client call — two hundred call sites across the
app migrate onto it and a lint rule keeps raw api fetches out for good.
The legacy timezone helpers fold into the tz module and test fixtures
drop personal names.
iOS-operator-blocking hotfix for REG-11 — Home dashboard tile renders
neither chart nor latest value for BP / Puls / Körperfett when the
most recent reading is older than 7 days. Root cause was in
`/api/dashboard/summary` SQL gates; `latestIn7d` + `sparkBuckets` both
required `measured_at >= sevenDaysAgo`, which returned empty for
sparse accounts, leaving the iOS tile with `latestValue: null` +
`sparkline: []` to render from. Detail-screen
(`/api/measurements/series?kind=…`) escaped because of its broader
window.
Server-side changes:
- `latestIn7d` → `latestEver` (drops 7-day filter; DISTINCT ON returns
latest reading EVER per type, bounded at N_metrics rows).
- `sparkBuckets` rewritten via ROW_NUMBER() OVER (PARTITION BY type
ORDER BY bucket_start DESC) so the trailing N daily buckets per
type render regardless of age, bounded at SPARK_DAYS × N_metrics.
- BP + Pulse metrics.push gated on `latest || allTimeCount > 0` like
BodyFat already is — accounts with zero readings ever no longer get
empty placeholder tiles.
- 4 new regression tests in summary/__tests__/route.test.ts cover
sparse accounts, zero-data accounts, within-7d guards.
Other develop work bundled in this release:
- W4-QoL — copy nits.
- W5-WORKOUTS — user source priority threaded through write-time
picker + batch route.
- W7-WITHINGS-RELIABILITY — response classifier + sync hardening.
- W8-OPS — ops phase docs.
- B11 — NEXT_PUBLIC_APP_VERSION build-arg routing so /api/version
cannot drift from CI tag.
- env-check workflow — manifest / example lockstep enforcement.
Resolves PR #194 (squash-merge produced via local -X theirs strategy
because GitHub's UI couldn't auto-resolve develop's post-v1.4.42
contiguous work against the v1.4.42 squash on main; resulting tree is
byte-identical to develop's HEAD).
Six touch-disjoint waves landed on develop before this release:
- W1-KNIP — exports/types tiers flipped to enforcing-mode on main;
35 unused exports + 52 unused types triaged to 0/0; shadcn surface
+ zod-infer contract types scoped via knip.json ignore-block.
- W2-ZOD-MULTI-ISSUE — `returnAllZodIssues` shared helper +
`/api/dashboard/widgets` 422 now returns every Zod issue + worker
audit-ledger breadcrumb. 41 sibling routes documented for v1.4.43.
- W3-QUERYKEY-LONGTAIL — 40 settings/medications/admin/hooks files
migrated to the queryKeys factory; 9 new factory entries; ESLint
rule + test-guard scope extended in lockstep.
- W4-ARCH-HYGIENE — BERLIN_DAY_FORMATTER consolidated across nine
sites (seven status helpers + bucket-series + bp-in-target);
Suspense double-comment consolidated; doctor-report sanitiser regex
rewritten to escape-form so diffs are reviewable; pr-detection-
worker soft-delete filter; offhost-backup DR-intent comment.
- W5-IOS-WORKOUTS — `dedupeWorkoutBatch` write-time cross-source
dedup helper for the v1.5 iOS HealthKit observer ingest; dashboard
tile-strip placeholder parity with the live TrendCard chrome.
- W6-WITHINGS-OFF + ENV-CHECK — Withings off-response classifier
(transient/reauth/persistent) + WithingsApiError typed throw + the
`pnpm check-env` CLI. APNs `allOrNone: true` closes the AP-2
silent-disable gap the wave was conceived to close.
No migration, no env-var change, no API contract break for iOS v0.5.4.
The new `/api/dashboard/widgets` 422 envelope is additively extended.
pnpm test --run green at 4815 passing / 1 skipped.
pnpm typecheck, pnpm lint, pnpm knip (no --include flag) green.