Commit Graph
1334 Commits
Author SHA1 Message Date
Marc-André Bombeck da15885dd4 docs(planning): v1.4.31 release closure 2026-05-16 14:36:33 +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
Marc-André Bombeck 945b556664 chore(release): v1.4.29.1
Daily-step aggregation hotfix. The dashboard 7-day step chart now renders daily totals (sums in the thousands), not per-sample averages, by routing cumulative HealthKit types through the sum branch of the client-side daily aggregator.
v1.4.29.1
2026-05-16 11:29:46 +02:00
Marc-André Bombeck 2062d74336 chore(merge): reconcile main into develop for v1.4.29.1 hotfix
# Conflicts:
#	CHANGELOG.md
#	package.json
2026-05-16 11:23:27 +02:00
Marc-André Bombeck c2aa8888e0 chore(release): v1.4.29.1
Daily-step aggregation hotfix. The dashboard 7-day step chart now
renders daily totals (sums in the thousands), not per-sample
averages, by routing cumulative HealthKit types through the
sum branch of the client-side daily aggregator.
2026-05-16 11:21:45 +02:00
Marc-André Bombeck c265103b69 fix(charts): sum cumulative types in the client-side daily aggregator
The 7-day window on the dashboard chart still pulls raw rows from
`/api/measurements` (the server-side `aggregate=daily` path kicks in
only for windows over a week). The client-side daily aggregator then
reduced every per-day bucket with `sum / count`, which averages step
counts across the per-sample rows Apple Health emits instead of
summing them. Steps on a 7-day view rendered as the per-sample
average (hundreds), not the daily total.

The reduction now branches on `CUMULATIVE_HK_TYPES` (the same set
that drives the server-side SQL aggregator in v1.4.29): cumulative
types reduce with `sum`, spot metrics keep the `sum / count`
average.
2026-05-16 11:19:46 +02:00
Marc-André Bombeck 6835ea6f10 docs(planning): v1.4.29 closure and v1.4.30 seed research
v1.4.29 ships dashboard performance + chart polish on both hosts.
Two new findings surfaced during the window: an /insights tab-strip
blocking root cause and an assistant-optional operator toggle plan.
Both land as v1.4.30 scope add-ons alongside the iOS server-prep menu.
2026-05-16 11:11:22 +02:00
Marc-André Bombeck d76c0b56e7 chore(release): v1.4.29
A targeted performance + polish patch. The headline is a faster
dashboard on data-rich accounts: the pulse chart drops up to
~5 000 raw rows per range tab in favour of one row per day, the
duplicate `/api/dashboard/widgets` fetch on every mount collapses
to one, and three long-running analytics reads are bounded to
the trailing window the tile path actually needs. Two production
regressions close — the `aggregate=daily|weekly|monthly` path on
`/api/measurements` 500'd for every grain, and the same path
averaged cumulative step counts instead of summing.
v1.4.29
2026-05-16 10:59:42 +02:00
Marc-André Bombeck bb6a56dcda chore(merge): reconcile main into develop for v1.4.29 release
# Conflicts:
#	CHANGELOG.md
#	package.json
2026-05-16 10:53:10 +02:00
Marc-André Bombeck 59968eb1da chore(release): v1.4.29
A targeted performance + polish patch. The headline is a faster
dashboard on data-rich accounts: the pulse chart drops up to
~5 000 raw rows per range tab in favour of one row per day, the
duplicate `/api/dashboard/widgets` fetch on every mount collapses
to one, and three long-running analytics reads are bounded to
the trailing window the tile path actually needs. Two production
regressions close — the `aggregate=daily|weekly|monthly` path on
`/api/measurements` 500'd for every grain, and the same path
averaged cumulative step counts instead of summing.
2026-05-16 10:49:14 +02:00
Marc-André Bombeck c1a52c3268 perf(dashboard): bound glucose + bp-in-target windows and stale-time the inline queries
Three small fixes that compound on a power-user dashboard mount:

- **H2**: The per-context glucose summaries walked every persisted
  `BLOOD_GLUCOSE` row a multi-year user has written. The summaries
  only feed the trailing tile, so bound the read to the last 30
  days.
- **M1**: The BP-in-target chunked walk passed no `since` bound
  even though `computeBpInTargetWindows`'s longest sub-window is
  `priorYear`. Pass a 365-day floor through both
  `fetchMeasurementSeriesChunked` calls.
- **M5**: The three inline dashboard `useQuery` blocks shared no
  `staleTime` / `refetchOnWindowFocus` settings, so a tab-focus-
  and-return triggered an immediate refetch storm. Lift a shared
  `DASHBOARD_QUERY_OPTS` object so every query inherits the
  1-minute staleness window the chart queries already use.

iOS-neutral. No contract change; the analytics response shape
stays identical.
2026-05-16 10:46:49 +02:00
Marc-André Bombeck b012641804 fix(settings): tighten the dashboard-layout drag-list rows on mobile
Each widget row carried a vertical pair of 44-px arrow buttons —
116-px row, repeated across 15 widgets, ~1 740 px of scroll just
for the reorder list. Three times the height of every neighbour
on the Settings page.

Swap the vertical arrow stack for a horizontal pair on the
trailing edge. `size-11 sm:size-9` preserves the 44-px mobile
tap target and shrinks to 36-px on desktop where pointer accuracy
is high. Row container drops `p-3 gap-3` for `px-3 py-2 gap-2`
with `min-h-12` as the height floor — 48 px, 2.4× tighter.
Label gains `truncate` + `title` so long widget names stay on a
single line.

Column-header spacer widens to reserve the new trailing arrow
column so the Tile / Chart switches stay column-aligned.

The existing `dashboard-layout-section.test.tsx` reads `data-slot`
attributes (unchanged), so the four-test suite stays green.
2026-05-16 10:45:28 +02:00