Commit Graph
1344 Commits
Author SHA1 Message Date
Marc-André Bombeck ebe4d50824 Merge remote-tracking branch 'origin/main' into develop
# Conflicts:
#	CHANGELOG.md
#	package.json
#	src/components/insights/insights-layout-shell.tsx
2026-05-16 15:58:55 +02:00
Marc-André BombeckandClaude Opus 4.7 15077482b1 chore(release): v1.4.32
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:57:49 +02:00
Marc-André Bombeck a1d5270070 docs(planning): v1.4.32 implementation handoff before release pipeline
Records the six implementation commits + the full-suite-green
state so a resumer can pick up the CHANGELOG / version bump /
release pipeline without re-discovering the diff. Iceberg notes
cover the latent Prisma-field-name bug fixed in Commit 1, the
default-visible `recentWorkouts` widget, the four metrics
intentionally without an empty-state CTA, and the SVG-polyline
route-preview decision deferring MapLibre / Leaflet to v1.5.x.
2026-05-16 15:55:37 +02:00
Marc-André Bombeck 3da0b38e0f fix(i18n): drop placeholder cta keys on the wave-A empty-state blocks
The four metric sub-pages without a manual-entry CTA
(`hrv`, `restingHr`, `oxygenSaturation`, `activeEnergy`) shipped
with `"cta": ""` placeholders that broke the locale-integrity
sentinel. The template never renders those keys when
`emptyStateCtaType` is null — drop the empty values so every
remaining key carries non-empty text.
2026-05-16 15:54:42 +02:00
Marc-André Bombeck 1f01b941ae feat(insights): chart cards for HRV, resting HR, SpO2, body temperature, active energy
Adds five sub-pages for the wave-A HealthKit metrics the server has
been storing since v1.4.23 / v1.4.25 but never surfaced on the web:

  - /insights/hrv               → HEART_RATE_VARIABILITY
  - /insights/ruhepuls          → RESTING_HEART_RATE
  - /insights/sauerstoff        → OXYGEN_SATURATION
  - /insights/koerpertemperatur → BODY_TEMPERATURE
  - /insights/aktive-energie    → ACTIVE_ENERGY_BURNED

Each page composes the shared `<HealthKitMetricPage>` scaffold —
`SubPageShell` + `HealthChartDynamic` mounted on its own
`ChartOverlayKey` slot + `CoachLaunchButton` + an empty-state CTA
that points at the existing `/measurements?add=<TYPE>` quick-entry
where a manual entry makes sense, or an Apple-Health-onboarding cue
otherwise. The five new chart-overlay-prefs slots (`hrv`,
`restingHr`, `oxygenSaturation`, `bodyTemperature`, `activeEnergy`)
let the chart-cog popover persist per metric.

Insights tab strip picks up the five new pills via the existing
`SUB_PAGE_METRIC` + `InsightMetric` enums. The wave-A categorisation
overlay realigns HRV + resting HR to the `cardiovascular` bucket
per the iOS handoff brief (PULSE stays in `vitals`; spot-pulse is a
point-in-time signal, derived heart-rate variants are the cardiac
risk surface). Six locales updated with the new sub-page titles,
descriptions, chart titles, and empty-state copy.
2026-05-16 15:53:18 +02:00
Marc-André Bombeck 3f5816112b feat(dashboard): recent workouts tile
Adds `<RecentWorkoutsTile>` as a default-visible dashboard widget
(`recentWorkouts`). The tile reads the top three canonical workouts
through `useWorkouts({ limit: 3 })`, renders each as a deep-link
row with the sport icon + the day + the duration, and falls back
to an Apple-Health-onboarding cue when the workout list is empty.

`DASHBOARD_WIDGET_IDS` + `DEFAULT_DASHBOARD_LAYOUT.widgets` carry
the new id; `widgetIdEnum` on `/api/dashboard/widgets` picks it up
from the same source-of-truth array. The Settings → Dashboard
layout editor surfaces it under the existing widget toggle list so
users can opt out without touching the layout JSON. Six locales
updated.
2026-05-16 15:47:44 +02:00
Marc-André Bombeck 61969fb025 feat(insights): workout detail page with HR + route + stats
Adds `/insights/workouts/[id]` on top of the v1.4.32
`GET /api/workouts/{id}` endpoint. The page mounts a header strip
(sport icon + source + date range + duration summary), a stats card
that surfaces every aggregate field present on the row (duration,
distance, active energy, average / max / min HR, steps, elevation,
pace for foot- and ride-paced sports), an SVG-rendered route preview
when the workout carries `WorkoutRoute` GeoJSON LineString
geometry, and an HR-chart placeholder that exposes the aggregates
inline until the per-second sample series wires up in v1.5.x.

Loading state mounts skeletons; the not-found branch surfaces a
neutral notice rather than a 404 page so deep-links from the list
page degrade gracefully when the workout has been deleted upstream.
2026-05-16 15:43:17 +02:00
Marc-André Bombeck beaee5791a feat(insights): workouts list page at /insights/workouts
Adds the user-visible surface for the workout rows the iOS client
already syncs through `POST /api/workouts/batch`. The page lives at
`/insights/workouts`, consumes the v1.4.32 list endpoint through a
new `useWorkouts()` hook, and renders each row as a deep-link into
the detail page with the sport icon, the date, the duration, and
the optional distance + active-energy chips.

The insights tab strip gains a `workouts` pill driven by an
event-style `hasWorkouts` availability signal — the layout shell
probes the list endpoint with `limit=1` so the pill self-gates on
zero workouts. Empty state mounts the shared `MetricEmptyState`
primitive with a Coach prefill that steers the conversation toward
the Apple Health sync onboarding cue. Six locales updated.
2026-05-16 15:38:07 +02:00
Marc-André Bombeck 8d514ed127 feat(api): expose workouts list + detail endpoints
Swap the v1.4.27 cluster picker on `GET /api/workouts` for the
source-priority-aware `pickCanonicalWorkoutRows()` (v1.4.30) so the
per-user ladder governs the canonical pick, fix the latent
Prisma-field-name bug (`distanceMeters` / `energyKcal` → the actual
`totalDistanceM` / `totalEnergyKcal` columns), and align the wire
shape with the iOS handoff contract (`distanceM`,
`activeEnergyKcal`, `avgHr`, `maxHr`).

Add `GET /api/workouts/{id}` for the workout-detail surface — owns
the optional `WorkoutRoute` GeoJSON geometry, exposes `canonicalId`
so deep-link redirects can land on the cluster winner, and seals
the existence channel with a cross-user 404 guard.

Register both routes in the OpenAPI registry so the pre-commit
drift hook catches the new shape going forward.
2026-05-16 15:29:33 +02:00
Marc-André Bombeck da15885dd4 docs(planning): v1.4.31 release closure 2026-05-16 14:36:33 +02:00
Marc-André Bombeck 5ea813e20c chore(release): v1.4.31
Operator-side assistant feature-flag matrix, /insights tab-strip blocking fix on mobile, and the GHCR-propagation-race Coolify auto-deploy fix. Full notes in CHANGELOG.
v1.4.31
2026-05-16 14:19:57 +02:00
Marc-André Bombeck 4876182a21 chore(merge): reconcile main into develop for v1.4.31 release 2026-05-16 14:14:21 +02:00
Marc-André Bombeck 00a6b67675 chore(release): v1.4.31 2026-05-16 14:11:35 +02:00
Marc-André Bombeck 8f0da1b476 docs(handoff): lock the assistant feature-flag scope across server-routed and on-device surfaces
`08-locked-contracts.md` gains §14 — "Assistant feature-flag
scope (locked v1.4.31)". Carries the full
`GET /api/feature-flags` response shape, the master-kill rule,
the cache posture, and the gated server endpoint matrix.

The §14 scope-rule sub-section locks verbatim from
`.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5: the matrix
gates BOTH server-routed AND iOS on-device assistant surfaces.
Apple Foundation Models on-device Coach + Briefing surfaces in
the v0.6.0 iOS path honour the same flag matrix as the
server-routed surfaces; the operator's intent is "no model-driven
surface in this app on this server" regardless of where the
inference runs.

The "What is NOT in this file" stub renumbers from §14 to §15.
2026-05-16 14:09:45 +02:00
Marc-André Bombeck 8e432b3172 chore(ci): catch OpenAPI drift via a pre-commit hook
v1.4.30 PR push failed CI on first attempt because the two new
MeasurementType enums weren't reflected in `docs/api/openapi.yaml`.
A separate `chore(openapi): regenerate the public spec` commit
was needed before the checks turned green. This hook catches the
drift at commit time locally so the contributor sees the
regeneration prompt before pushing.

New `.githooks/pre-commit`:
- Skips when no relevant files (`src/lib/openapi/`,
  `src/lib/validations/`, `src/app/api/`, the generator script
  itself) are staged.
- Runs `pnpm openapi:check` quietly; exits fast on match.
- On drift, runs `pnpm openapi:generate`, re-stages the
  regenerated `docs/api/openapi.yaml`, and continues the commit.
- Skips in CI so the existing `pnpm openapi:check` job in
  `.github/workflows/security.yml` stays the server-side
  authoritative gate.

`scripts/install-hooks.sh` flips `git config core.hooksPath` to
`.githooks/` — idempotent, one-shot per fresh clone. AGENTS.md
adds the install command to the contributor workflow section.
2026-05-16 14:08:54 +02:00
Marc-André Bombeck 477e57f1f1 ci(image): close the Coolify auto-deploy race against GHCR cache propagation
Five releases in a row (v1.4.27 → v1.4.30.1) the publish workflow
returned a 2xx from the Coolify webhook but the running container
on apps01 still carried the prior `:latest` digest. Host-side SSH
fallback patched it every time at ~5 minutes per release.

Root cause: GHCR's CDN edge layer takes 30-90 s to propagate a
fresh manifest write to consumer pull endpoints. The
`docker buildx imagetools create` + `inspect` steps confirm the
manifest at registry-write time, but the webhook fires
immediately afterwards. Coolify's pull lands inside the
edge-propagation window, gets the prior digest, declares no-op,
and returns success. The webhook itself works; the spacing
assumption between push and pull was wrong.

Fix: add `sleep 90` immediately after the auth + skip-mode
guards in the `Trigger Coolify deploy` step. Diagnostic line
prints the resolved image version (`steps.meta.outputs.version`)
before the sleep so the workflow log surfaces the exact tag the
operator can sanity-check.

Cost: ~30% on the publish-job duration, but only after the image
is fully on GHCR. The release-flow walk-through clock is
unaffected; the host-side SSH fallback remains available as a
backstop.

Full root-cause + hypothesis matrix + escalation plan documented
in `.planning/round-coolify-auto-deploy-fix-2026-05-16.md`.
2026-05-16 14:07:22 +02:00
Marc-André Bombeck 88bd9dddb7 fix(insights): unblock the tab strip during initial mobile load
Three client-side fixes that collapse the worst-case tap-block
window from the LLM-completion tail (~30 s on a cache-miss) down
to the bounded parallel-fetch window (~400-600 ms). The strip
itself stays interactive in the DOM throughout; the previous
freeze symptom traces to main-thread contention starving WebKit's
gesture-recognition timeout. Per
`.planning/research/v15-insights-blocking-bug.md`.

1. `fetchAdvisor` (`use-insights-advisor.ts`) gets an 8-second
   `AbortController` and converts `AbortError` into the existing
   graceful-null return path so the consuming UI surfaces the
   regen CTA instead of pinning the query in `isFetching: true`
   for the LLM's full completion latency.

2. `<InsightsTabStrip>` is wrapped in `React.memo`; the inner
   `buildTabs(availability)` is `useMemo`'d on `availability`; the
   layout shell `useMemo`'s the `availability` prop on its three
   leaf inputs. Together they break the
   "shell re-renders -> strip re-renders -> 8 pills re-render"
   cascade on every cache-write of analytics or comprehensive.

3. `<CoachDrawer>` is now lazy-loaded via `next/dynamic({ ssr: false })`
   inside `<LayoutCoachMount>` so the SSE machinery, the chat
   reader, the suggested-prompts chip rail, and the persistent
   settings sheet don't run their initialisers on every cold
   /insights mount. The drawer renders nothing until the user
   actually opens it.

Tests pin the memo contract on the strip and the import-cleanness
of the abort path. The wider fix-5 (analytics SQL aggregation per
R-B C1) stays scoped to v1.5.
2026-05-16 14:05:43 +02:00
Marc-André Bombeck e778835c9c i18n: localise the operator-disabled assistant notices
Four user-facing keys land in all six locale files so the
`<AssistantDisabledNotice>` component reads natively localised
copy:

  insights.coach.disabledByOperator
  insights.briefingDisabledByOperator
  insights.statusDisabledByOperator
  insights.correlationsDisabledByOperator

The Coach key nests under the existing `insights.coach.*` object;
the other three sit at the top of `insights.*` to avoid colliding
with the existing string-valued `insights.correlations` key.

The Health-Score explainer has no notice — its suppression is
silent on purpose so the bare delta digit still reads as a clean
KPI.

DE carries the full native phrasing; the established convention
for the other five locales (FR/ES/IT/PL/EN) keeps the user-facing
strings localised even where admin strings still default to
English.
2026-05-16 14:02:22 +02:00
Marc-André Bombeck 3b56bac59a feat(admin): assistant feature-flag toggle panel
New admin slug `assistant` between `ai-quality` (provider config)
and `coach-feedback` (in-app feedback inbox). The panel renders six
`<SettingsToggle>` rows — master plus five sub-flags — and writes
through a dedicated `PUT /api/admin/settings/assistant-flags`
endpoint. Optimistic UI flips on click; failure surfaces a toast
and re-invalidates the read query.

The dedicated endpoint (separate from the generic `/api/admin/settings`
PUT) lets the panel scope its audit trail to a single
`admin.settings.assistant-flags.update` action and lets the
optimistic UI bind 1:1 to a focused request/response shape. The
PUT handler echoes both the raw column values and the resolved
shape so the panel can render the master vs sub-flag distinction
visually.

Sub-toggles grey out when the master is off (UX-wise) so the
operator can see which sub-flags are individually flipped before
unmuting the master. Writes invalidate the runtime
`/api/feature-flags` cache so the operator sees the toggled
surface react within the same session.

i18n: `admin.section.assistant.{title,subtitle}` and
`admin.assistant.*` keys land in all six locales (DE proper, the
five English-leaning admin locales mirror the English copy as is
the established convention for admin strings).
2026-05-16 14:00:10 +02:00
Marc-André Bombeck 03d0b64bb8 feat(insights): gate assistant surfaces on operator feature flags
New client hook `useFeatureFlags()` reads the matrix from
`/api/feature-flags` once per session and feeds every gated mount
on the Insights surface. The hook fails open — any network error
or missing `<QueryClientProvider>` (the legacy presentational test
posture) returns the all-on default so the user never loses an
affordance to an instrumentation gap.

Gated surfaces:

  <LayoutCoachFab>, <LayoutCoachMount>, <CoachLaunchButton>
                                                    → coach
  <DailyBriefing> on /insights/page.tsx             → briefing
  <CorrelationRow> on /insights/page.tsx            → correlations
  <InsightStatusCard> × 6 sub-pages                 → insightStatus
  <HealthScoreDeltaExplainer> ? glyph               → healthScoreExplainer

Master `assistant.enabled = false` forces every sub-flag false in
the resolver, so flipping the master collapses every surface at
once. Each component returns `null` when its flag is off; the
delta digit on the Health-Score card stays visible because the
parent reads the number directly.

New `<AssistantDisabledNotice>` component prepares the empty-state
copy for callers that detect a 403 + `errorCode:
"assistant.disabled.<surface>"` response. The localised strings
land in the dedicated i18n commit.
2026-05-16 13:54:00 +02:00
Marc-André Bombeck aeff47afde feat(api): gate assistant endpoints on the feature-flag matrix
Every LLM-driven endpoint now reads the operator flag set near the
top of the handler and throws `AssistantDisabledError` when the
relevant surface is off. The api-handler catches the typed error
and returns the 403 envelope:

  { data: null, error: "...", meta: { errorCode: "assistant.disabled.<surface>" } }

Gates per the v1.4.31 brief:

  /api/insights/chat            → coach            (POST + GET rail)
  /api/insights/generate        → coach
  /api/insights/comprehensive   → coach
  /api/insights/cards           → insightStatus
  /api/insights/correlations    → correlations
  /api/insights/{weight,bmi,blood-pressure,pulse,mood,
                 medication-compliance}-status → insightStatus

Master `assistantEnabled = false` forces every sub-flag false in
the resolver, so flipping the master 403s every endpoint at once.

Older iOS clients see a generic 403 (their existing 403-handler
covers the case); v1.4.31+ clients read the `errorCode` to render
the `<AssistantDisabledNotice>` empty state. Locked per
`.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5.
2026-05-16 13:46:17 +02:00
Marc-André Bombeck 61c9a1e792 feat(api): expose the assistant feature-flag matrix
`GET /api/feature-flags` projects the six `AppSettings.assistant*`
booleans over HTTP as a single typed envelope:

  { data: { assistant: { enabled, coach, briefing, insightStatus,
                          correlations, healthScoreExplainer } } }

`requireAuth()` gates the route — any logged-in user passes. The
resolver in `src/lib/feature-flags/index.ts` forces every sub-flag
false when the master is off, so the response is always
operator-effective and clients never have to compose
`master && sub`. `Cache-Control: private, max-age=60` caps the
read cost on the hot /insights mount path while still propagating
admin changes within a minute.

iOS reads this endpoint on cold start and gates every Coach,
Briefing, status-card, correlation and explainer surface against
the matrix — regardless of whether the surface ends up calling the
server or running on-device. Locked per
`.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5.
2026-05-16 13:40:02 +02:00
Marc-André Bombeck b68206eca4 feat(settings): add 6 assistant-surface feature flags to AppSettings
Operator-side kill matrix for every LLM-driven surface. Master flag
`assistantEnabled` plus five sub-flags carve the seven surfaces:
Coach + chat, Daily Briefing + advisor, per-metric status cards,
correlation narration, Health-Score delta explainer. Defaults are
`true` so existing installs upgrade without losing affordances.

New `src/lib/feature-flags/` module exposes `getAssistantFlags()`,
`resolveAssistantFlags()` and the `AssistantDisabledError` typed
error route handlers throw to surface the 403 +
`errorCode: "assistant.disabled.<surface>"` envelope. The resolver
forces every sub-flag false when the master is off so callers never
have to compose `master && sub`.

Locks per `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5:
the same matrix gates server-routed AND iOS on-device surfaces.

Migration 0065 is additive only; `app_settings` gains six boolean
columns, all default `true`, idempotent under `IF NOT EXISTS`.
2026-05-16 13:38:49 +02:00
Marc-André Bombeck 0aaf10d796 docs(planning): v1.4.30.1 closure 2026-05-16 13:31:34 +02:00
Marc-André Bombeck 175e3aea97 chore(release): v1.4.30.1
Categories endpoint + conflict-resolution lock. A two-item follow-up to v1.4.30: the categorisation overlay shipped yesterday as a TypeScript map; the new `GET /api/measurement-categories` endpoint projects it over HTTP so the iOS-side picker can fetch the canonical shape rather than carry a hard-coded mirror — the unblocker the iOS team flagged as the one missing piece. In parallel the SyncMode conflict-resolution policy that the v0.6.0 standalone-first track depends on lands as §13 of `08-locked-contracts.md`.
v1.4.30.1
2026-05-16 13:20:49 +02:00
Marc-André Bombeck b27ecbd4f1 chore(merge): reconcile main into develop for v1.4.30.1 release 2026-05-16 13:15:31 +02:00
Marc-André Bombeck 993dfae2e1 chore(release): v1.4.30.1
Categories endpoint + conflict-resolution lock. A two-item follow-up
to v1.4.30: the categorisation overlay shipped yesterday as a
TypeScript map; the new `GET /api/measurement-categories` endpoint
projects it over HTTP so the iOS-side picker can fetch the canonical
shape rather than carry a hard-coded mirror — the unblocker the iOS
team flagged as the one missing piece. In parallel the SyncMode
conflict-resolution policy that the v0.6.0 standalone-first track
depends on lands as §13 of `08-locked-contracts.md`.
2026-05-16 13:14:00 +02:00
Marc-André Bombeck b4fa223983 docs(planning): slot Apple Health XML import as v1.4.34 + reshuffle the freeze marker
§2 of the v1.5 strategic plan now ends v1.4.33 without the freeze
marker. A new v1.4.34 sub-section lands per
`.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R2: scope is
`POST /api/import/apple-health-export` (multipart) + streaming XML
parser + async job model (`{ jobId }` + `/status` polling) +
per-MeasurementType ingestion stats + idempotent UPSERT on
`externalId` + admin endpoint variant + mapping for
HKQuantityTypeSample, HKCategoryTypeSample and HKWorkout
(HKClinicalRecord defers per R-F T3). Effort L (~3-4 days).

The web-freeze marker moves to v1.4.34. The freeze posture, the
v1.5.0 version-bump shape, and the decision-log §5 rows
(web-freeze trigger, v1.5 release shape, web patch sequence, plus
a new "Apple Health XML import slot" row) all renumber. The
deferred §4 row and §6 open question #3 close out — the import is
now scheduled, not deferred.
2026-05-16 13:12:51 +02:00
Marc-André Bombeck 15cbc10278 docs(brief): decouple Coach SSE from the v1.5 ship gate per iOS-team pivot
§5b "Coach SSE" and §6 "Coach SSE drawer iOS implementation" both
reframed per `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R6.
The server endpoint `GET /api/insights/chat` stays live indefinitely
for PWA users, future iOS reevaluation post-MDR, and any other
client that adopts the SSE protocol. v1.5.0 iOS ships Apple
Foundation Models on-device Daily Briefing + Trend Observations as
the primary assistant surface; a native server-Coach drawer is
deferred pending MDR Class-IIa pre-review.
2026-05-16 13:11:26 +02:00
Marc-André Bombeck 0378e8e99d docs(brief): bump live state to v1.4.30 and add new endpoint listings
§3 of the iOS contributor brief now reflects the v1.4.30 production
state (both hosts on the iOS-coordinated foundation tag) and the
v1.4.30.1 follow-up. The recent-releases table gains two rows; the
"endpoints the iOS client may consume today" list gains the four
v1.4.30 surfaces (`/api/sync/state`, `/api/mood-entries/bulk`,
`/api/medications/intake/bulk`,
`/api/admin/drain-per-sample-cumulative`) plus the new v1.4.30.1
`/api/measurement-categories` projection.
2026-05-16 13:10:51 +02:00
Marc-André Bombeck 2fcc47ca1e docs(handoff): lock the SyncMode conflict-resolution policy
Appends §13 to `08-locked-contracts.md` codifying the hard-spec
delivered in `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R9.
Covers bulk-backfill, steady-state bidirectional sync, write
conflict (LWW by `updatedAt` with server-wins on the millisecond
tie), delete conflict (409 + canonical row vs 410 Gone), and the
`GET /api/sync/state` envelope shape.

iOS uses this as the contract for the v0.6.0 standalone-first track
once `SyncMode = paired` is the live state. The "What is NOT in
this file" stub renumbers to §14.
2026-05-16 13:10:12 +02:00
Marc-André Bombeck bd3a4470e5 feat(api): expose the measurement-categories map as a public endpoint
Adds `GET /api/measurement-categories`, a thin projection of the
v1.4.30 `MEASUREMENT_CATEGORIES` overlay at
`src/lib/measurements/categories.ts`. iOS reads the response on cold
start to drive the HealthKit permission picker grouping; the future
web Insights nav and the Coach evidence shelf consume the same shape.

Response envelope carries a `version: 1` marker, an ordered
`categories` list with `labelKey` translation keys, and the
`assignments` map keyed on `MeasurementType`. `requireAuth()` gates
the route — any logged-in user passes — and the response carries
`Cache-Control: public, max-age=600` so clients honour a 10-minute
edge cache.

Locked per `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R1.
2026-05-16 13:09:27 +02:00
Marc-André Bombeck eb4d97b4a4 docs(planning): v1.4.30 closure 2026-05-16 13:04:00 +02:00
Marc-André Bombeck 3ece95f192 chore(release): v1.4.30
Server-side prep for the next iOS TestFlight build. Five surfaces land together so the iOS engineer can pick them up in one cut-over: a locked externalId shape for daily-aggregated cumulative HealthKit rows, the SyncMode foundation columns + handshake + bulk-backfill endpoints, a first-class MoodEntry.note column that replaces the legacy tag-based workaround, a cross-source workout dedup helper, and two new MeasurementType enums plus the shared category overlay that drives the iOS permission picker and the future Insights nav.
v1.4.30
2026-05-16 12:37:55 +02:00
Marc-André Bombeck 27592fd9da docs(planning): response to iOS team — R1-R10 answered + strategic-pivot absorption
iOS team's 2026-05-16 response confirms two strategic pivots
(Apple Foundation Models on-device + standalone-first for v0.6.0)
and asks ten coordination items. Every R1-R10 answered with
hard-spec commitments. Two new items land in the v1.4.x roadmap:
GET /api/measurement-categories endpoint (R1, v1.4.30) and the
conflict-resolution policy lock (R9, v1.4.30). Apple Health XML
import (R2) slates to v1.4.34 alongside the freeze marker.
2026-05-16 12:33:55 +02:00
Marc-André Bombeck 8d153146f9 chore(openapi): regenerate the public spec after the v1.4.30 enum additions 2026-05-16 12:32:59 +02:00
Marc-André Bombeck 5206ae6074 chore(merge): reconcile main into develop for v1.4.30 release 2026-05-16 12:27:29 +02:00
Marc-André Bombeck e1e628da50 chore(release): v1.4.30 2026-05-16 12:25:20 +02:00
Marc-André Bombeck 0522652ae2 feat(measurements): wire walking-steadiness + audio-exposure-event through every registry
v1.4.30 commit 7 follow-up. The new MeasurementType values land in
every derived registry the canonical enum drives:

  - measurement-list-meta.ts label keys + icons + colors so the
    measurements list view renders both types (Gauge for steadiness,
    Volume2 for the loud-listening event)
  - 6 locale files (de/en/es/fr/it/pl) carry typeWalkingSteadiness +
    typeAudioExposureEvent keys; English-fallback copy for the four
    non-EN/DE locales matches the existing v1.4.25 W8d pattern
  - chart-tokens.ts ALLOWED_CHART_TOKENS list so the LLM can
    reference `metric:WALKING_STEADINESS` + `metric:AUDIO_EXPOSURE_EVENT`
    in prose; dedicated chart components ship alongside the iOS sync
  - the enum-coverage drift-guard test's EXPECTED_TYPES + PDF
    exclusion set so the canonical enum-extension lint stays green

Full unit suite passes (4035 specs).
2026-05-16 12:23:30 +02:00
Marc-André Bombeck a3b00f49ef test(db): expand the real-Postgres integration coverage
v1.4.30 backstop. Every new endpoint introduced earlier in the patch
run rides the existing real-Postgres container fixture (v1.4.29's
contribution): the SyncMode handshake, the mood-entries bulk
upsert, the medication-intake bulk insert with idempotency-key
collision, the mood-note column round-trip, and the drain helper
itself.

This commit adds the missing slot — the admin endpoint
POST /api/admin/drain-per-sample-cumulative — so the route's admin
gate, dryRun default, and commit path all run against real SQL
end-to-end. Mocked-prisma siblings would have hidden the v1.4.29
lesson (the date_trunc bound-parameter bug); the route's
transactional upsert + delete needs the same fence.

Coverage delta: 3 new integration specs, all green.
2026-05-16 12:18:25 +02:00
Marc-André Bombeck dd487cfddc feat(measurements): add walking-steadiness and audio-exposure-event types
R-F T1.4 + T1.5 close-out. Two new MeasurementType enum values
unlock the iOS Tier-1 surfaces Apple's own Health app shows
prominently.

Schema (migration 0064, additive):
  - measurement_type gains WALKING_STEADINESS + AUDIO_EXPOSURE_EVENT

WALKING_STEADINESS — iOS 15+ Mobility daily rollup. Apple ships a
0..1 fraction; HealthLog stores 0..100 percent (same convention as
oxygen saturation + body fat).

AUDIO_EXPOSURE_EVENT — iOS 13+ category-type that fires when the
rolling 7-day average crosses the WHO 80-dBA loud-listening
threshold. Both the environmental + headphone events map to the
same MeasurementType so chart-card consumers pick them up uniformly;
the notes field carries the source token at iOS write time.

Updates ride the same release:
  - Zod measurementTypeEnum gains both values
  - apple-health-mapping.ts maps three HK identifiers
    (AppleWalkingSteadiness, EnvironmentalAudioExposureEvent,
    HeadphoneAudioExposureEvent) and removes the same identifiers
    from HK_QUANTITY_TYPE_DEFERRED
  - categories.ts assigns WALKING_STEADINESS to activity and
    AUDIO_EXPOSURE_EVENT to hearing
  - pr-direction.ts pins WALKING_STEADINESS to MAX (higher steadiness
    is the achievement) and AUDIO_EXPOSURE_EVENT to no-PR (threshold-
    cross flag, not goal-axis)

iOS contract: the two new enum values are net-new — iOS clients that
predate the codegen pass will not encounter them in read paths
because no source writes them yet. New iOS code paths can post the
new HK identifiers via the existing batch endpoint without further
server changes.
2026-05-16 12:16:39 +02:00
Marc-André Bombeck 479e0aed48 feat(measurements): shared category map for the iOS picker and the web Insights nav
R-F §4 close-out. The flat 27-value MeasurementType enum stays as the
storage shape; this overlay is presentation-only and drives both
consumers:

  - iOS HealthKit permission picker — grouped by category so the user
    sees one consent screen per logical area (vitals / activity /
    sleep / hearing / environment / cardiovascular / metabolic / body)
    rather than a flat 27-line list
  - web Insights nav (post-v1.5) — the existing 7-page strip stays
    curated; an "All metrics" overview can group cards by category
    cheaply from this map
  - Coach evidence shelf — chip-grouping by category makes the prose
    more navigable than a flat list

The map is ReadonlyMap<MeasurementType, MeasurementCategory> so
consumers cannot mutate it accidentally. Adding a new MeasurementType
without a sibling entry is caught at test time by the completeness
wall.

iOS contract: presentation-only — no API surface change. The iOS
client mirrors this map verbatim once the swift-openapi-generator
wiring (R-E H-8) lands; new types stay invisible to clients that
predate the codegen pass.
2026-05-16 12:12:52 +02:00
Marc-André Bombeck 57abfbfb90 feat(workouts): pick canonical rows across Apple Watch and Withings sources
R-F T1.1 close-out for the dashboard "Recent workouts" tile. Same
intent as pickCanonicalSourceRows() for measurements, applied to
workouts. A user wearing an Apple Watch + a Withings ScanWatch
records the same run via each integration; without the picker the
dashboard would show the run twice.

Bucket key = (5-min-rounded startedAt, sportType). The 5-minute slot
absorbs the clock skew between HK and Withings without collapsing
two back-to-back runs into one bucket (a user does not run twice in
a five-minute window in practice).

Source-priority ladder ride the existing measurement "steps" key —
unifying on one Settings UI rather than introducing a new "workouts"
metric key. Default ladder is APPLE_HEALTH > WITHINGS > MANUAL >
IMPORT per the source-priority validator's canonical default.
Metric-aware tunes (route → Apple wins, HR zones → Withings wins)
defer to v1.5.x per R-F open question #4.

iOS contract: helper is read-side only — no API surface change. The
workout batch ingest route (verified in v1.4.25 W8d) keeps the
existing rate-limit + idempotency + 5 MB body cap + 100-workout
batch cap. /api/workouts/batch is the iOS endpoint going into v1.5.

Picker exercises empty-input, single-row, cross-source overlap,
sport-discriminator-keeps-distinct, > 5-minute-distinct, user-
supplied ladder honoured, manual-only fallback, and an unrecognised
ladder fallthrough.
2026-05-16 12:11:20 +02:00
Marc-André Bombeck 11325e31a0 feat(mood): add a first-class note column replacing the tag-based workaround
R-E H-5. Up to v0.4.1.1 the iOS app stored mood-entry prose as
`tags: ["note:<text>"]` because the server had no dedicated column.
The workaround contaminated the tags axis with prose the Coach
evidence shelf misread as taxonomic labels.

Schema (migration 0063, additive):
  - mood_entries.note (TEXT, nullable)

Validation + routes:
  - createMoodEntrySchema gains optional note (max 500 chars)
  - updateMoodEntrySchema accepts note + null to clear
  - POST /api/mood-entries persists note alongside tags
  - PUT /api/mood-entries/[id] honours partial note updates
  - POST /api/mood-entries/bulk carries note through the bulk path

Backfill script at scripts/backfill-mood-note-column.ts lifts the
legacy note:<text> tag entries into the new column. Idempotent
dry-run mode is the default; --confirm commits. Helper logic lives
in src/lib/mood/note-backfill.ts for tight unit coverage.

iOS contract: additive. Old iOS payloads (no note field) round-trip
unchanged. New iOS code paths can read or write note without
touching tags.
2026-05-16 12:08:53 +02:00
Marc-André Bombeck 0ccc00b774 feat(api): SyncMode foundation — syncVersion column, sync-state endpoint, bulk backfill routes
R-E C-2 server-side. Three additive columns + three new endpoints
let the iOS SyncMode store pair, drain, and reconcile against a
fresh paired-server.

Schema (migration 0062, additive):
  - Measurement.sync_version (Int, default 1) for last-writer-wins
  - Measurement.deleted_at (Timestamp, nullable) for soft-deletes
  - User.last_synced_at (Timestamp, nullable) for the handshake
    checkpoint

Endpoints:
  - GET /api/sync/state — handshake response (lastSyncedAt, server
    clock, live + tombstoned measurement counters); call also bumps
    the checkpoint so iOS reads the OLD value then trusts subsequent
    writes
  - POST /api/mood-entries/bulk — UPSERT per entry keyed by
    (userId, date, moodLoggedAt); probe-then-upsert so the response
    reliably reports inserted vs duplicate; rate-limited at 60/min
  - POST /api/medications/intake/bulk — INSERT per entry with
    P2002-on-idempotencyKey → duplicate; up-front user-owns-medication
    check skips foreign rows with a documented reason

iOS contract: every change is additive. Existing per-sample POSTs
keep round-tripping (sync_version defaults to 1 server-side;
deleted_at stays NULL on every existing read path; last_synced_at is
opt-in). The new endpoints are net-new — no iOS consumer yet.

Tests cover the handshake checkpoint advance, live-vs-tombstoned
counters, clean batch ingest, re-post upsert semantics, ownership
filtering, idempotency-key collision, and over-cap rejection — all
against the real-Postgres container.
2026-05-16 12:04:45 +02:00
Marc-André Bombeck 0e92f96e72 feat(scripts): drain per-sample cumulative HealthKit rows into daily-aggregated rows
Idempotent maintenance tool that collapses pre-Option-A per-sample
APPLE_HEALTH cumulative rows (steps, active energy, flights climbed,
walking + running distance, time in daylight) into one row per day
per type, keyed by the locked dailyStatsExternalId shape. Per
user × type × calendar-day (anchored to User.timezone): SUM the values,
UPSERT a daily row at canonical midday-local (matching the Withings
activity-sync convention), DELETE the per-sample rows.

Three entry points share src/lib/measurements/drain-per-sample-cumulative.ts:
  - Library helper for unit + integration tests
  - CLI at scripts/drain-per-sample-cumulative.ts with --user / --all
    + --confirm flags (default dry-run)
  - Admin endpoint POST /api/admin/drain-per-sample-cumulative gated
    by requireAdmin() with dryRun defaulting to true

Operator runs the drain once after the iOS TestFlight build adopts
HealthKitStatisticsService.swift. Re-running on a fully-collapsed
account reports zero buckets touched.

Tests cover bucketing semantics, the canonical-timestamp math across
DST + half-hour zones, idempotent re-runs, dry-run preview, spot-metric
exclusion, and already-collapsed-row skip via real-Postgres.
2026-05-16 11:56:39 +02:00
Marc-André Bombeck 6e6bc61800 feat(api): lock the daily-stats externalId shape for cumulative HealthKit types
R-A Option A handoff prep. Adds dailyStatsExternalId() helper alongside
the v1.4.29 CUMULATIVE_HK_TYPES set; the helper mints
"stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>" so iOS can UPSERT one
row per day per cumulative type via the existing batch endpoint, the
unique index collapses re-syncs idempotently, and a future PATCH-on-
divergence call updates a late-watch-sync revision without inserting a
second row. Handoff docs (06 + 08) lock the shape for the iOS client.

Cumulative-type set unchanged from v1.4.29; spot metrics keep the
per-sample uuid externalId. Server tolerates both shapes during the
cutover window — the drain script (next commit) collapses pre-existing
per-sample rows post-cutover.

Tests cover the helper round-trip, the cumulative-type identifier wall,
and the iOS-trusted date-string passthrough.
2026-05-16 11:51:25 +02:00
Marc-André Bombeck 1a2447295d docs(planning): iOS contributor brief — current state + full v1.4.x → v1.5.0 forward roadmap
Single onboarding doc the iOS contributor (or their automation) reads
once and then references continuously. Covers: the release model
(v1.5.0 is version-bump-only marker), every convention every commit
must honour, what's live in production today (v1.4.29.1), what
incoming patches deliver through to web freeze, and the locked
forward state at v1.5.0 — every endpoint + DTO + Prisma column + new
MeasurementType the iOS app can rely on consuming by ship date.
2026-05-16 11:48:08 +02:00
Marc-André Bombeck 185afb9b8c docs(planning): move R-A daily-stats into v1.4.30 + reshuffle the patch run
Maintainer directive 2026-05-16: the iOS TestFlight build is already
posting per-sample cumulative rows; the daily-stats cutover cannot
wait for a v1.5 sprint. v1.4.30 now ships the R-A server-side helper
plus a one-shot drain script so the iOS TestFlight build can adopt
`HKStatisticsCollectionQuery` immediately. The remaining web patches
slide one slot — v1.4.31 picks up the assistant toggles + insights
blocking + Coolify auto-deploy fix, v1.4.32 + v1.4.33 carry the
HealthKit Tier 1 surfaces.
2026-05-16 11:42:34 +02:00
Marc-André Bombeck e76716935f docs(planning): v1.4.29.1 hotfix closure
Daily-step aggregation hotfix shipped same day as v1.4.29; closure
stanza records PR #173, squash SHA 945b5566, tag, and the SSH
host-side fallback that finished apps01 rollout after Coolify
auto-deploy stalled for the third release in a row.
2026-05-16 11:39:46 +02:00