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.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-16 12:37:55 +02:00
1 parent 945b556664
commit 3ece95f192
49 files changed
+4744 -32

No files matched your search

@@ -0,0 +1,403 @@
---
file: .planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md
purpose: Direct response to the iOS team's coordination doc at /Users/marc/Projects/healthlog-iOS/HealthLogIOS/.planning/v05x-marathon/RESPONSE-TO-SERVER-TEAM.md
created: 2026-05-16
from: server contributor + automation
re: RESPONSE-TO-SERVER-TEAM.md — ten requests R1-R10, two strategic pivots, calendar realignment
---
# Response to iOS team — 2026-05-16
Read your response in full. The Apple Foundation Models pivot, the
standalone-first commitment for v0.6.0, and the longer iOS calendar
all land cleanly with the v1.4.x continuous-patch model. Every R1-R10
answered below, plus the strategic-pivot implications for the web
roadmap.
## 1. Acknowledgements
- v0.5.0-rc.1 candidate at `54b644c` noted. The merged feat-branches
(Group 0 felt-UX, Group 1 theme-parallel, Group 2 theme-chart-chain)
confirm iOS-side ships rich functional work on the v0.5.x track
independent of web-side releases — exactly the architecture this
patch run is designed for.
- 732 Swift Testing `@Test` annotations on `main` is a strong
baseline. Server-side runs ~4 000 Vitest cases; the two test
surfaces stay independent.
- iOS-additive-only adherence confirmed in both directions.
## 2. Strategic-pivot absorption
### Apple Foundation Models on-device — the v1.5 narrative changes
Closes the Apple Guideline 5.1.2(i) blocker on the iOS side without
requiring a server-routed consent flow for Daily Briefing + Trend
Observations + Photo-of-Med. Confirmed: server-side will not block
v1.5.0 tag on a native Coach SSE drawer. The server's
`GET /api/insights/chat` SSE endpoint stays live, indefinitely,
for: (a) future iOS re-evaluation post-MDR Class-IIa pre-review,
(b) any other client that calls it (PWA mobile users today, native
Android in a hypothetical future), (c) fallback when on-device
generation refuses for any iOS device that loses Apple Foundation
Models eligibility.
Server-side will update §5b of the contributor brief per R6.
### Standalone-first commitment (v0.6.0)
The web side already treats the server as "API server that some
clients pair with"; the iOS-local-first commitment changes nothing
on the server's expectations. Server-side `SyncMode` foundation
ships in v1.4.30 as planned. Conflict-resolution policy locked
in R9 below.
### Calendar realignment
v0.5.x is 2-3 months from 2026-05-16; v0.6 is +3-4 months;
v1.5.0 marker aligns with the iOS App Store launch at ~2026-11
to ~2026-12. Web-side patch cadence stays unchanged: v1.4.30 → 31
→ 32 → 33 ship in the next 1-3 weeks per the current strategic
plan. **The web-freeze window between v1.4.33 and v1.5.0 is now
several months long instead of one Apple-review buffer.** That
opens room for additional v1.4.x patches before the freeze if value
shows up (e.g. R2 Apple Health XML import — see below).
## 3. Direct answers to R1 - R10
### R1 — `GET /api/measurement-categories` HTTP endpoint — YES, slots into v1.4.30
ANSWER: yes. Server-side will expose the categorisation map at
`GET /api/measurement-categories`. Response shape:
```json
{
"data": {
"version": 1,
"categories": [
{ "id": "vitals", "labelKey": "categories.vitals", "order": 0 },
{ "id": "body", "labelKey": "categories.body", "order": 1 },
...
],
"assignments": {
"BP_SYSTOLIC": "vitals",
"BP_DIASTOLIC": "vitals",
"PULSE": "vitals",
"WEIGHT": "body",
...
}
}
}
```
- Public-read with auth (any logged-in user can fetch).
- `Cache-Control: max-age=600` (10 min cache per app launch).
- Versioned (`version: 1`) so clients can detect breaking changes
if they ever happen — not planned.
- iOS reads on app launch, caches in `MeasurementType+Category.swift`
helper. Refresh on every cold start.
- Hard-coded mirror in iOS as a fallback if the network call fails
(offline-first).
Server will add this endpoint inside the v1.4.30 patch alongside
the `src/lib/measurements/categories.ts` map. ~50 LOC + a Vitest +
a documented response shape in `03-api-contracts.md`.
### R2 — Apple Health XML import — slated for v1.4.34
ANSWER: v1.4.34 (web-side, after the Tier-1 wave B + before the
web-freeze marker). Not v1.5.x; iOS calendar gives the web side
plenty of room to ship this before freeze.
Server-side:
- `POST /api/import/apple-health-export` accepting `multipart/form-data`
- Streaming XML parser (Node `sax-stream` or similar)
- Per-`MeasurementType` ingestion stats + duration in the response
- Idempotent UPSERT keyed on `externalId` so re-imports are no-ops
- Operator-side admin endpoint variant for support-staff-driven imports
- Async job model: large files (100 MB - 1 GB) need a background
worker; the synchronous endpoint returns a job ID, iOS polls
`GET /api/import/apple-health-export/{jobId}/status`
- Apple Health XML row types: HKQuantityTypeSample, HKCategoryTypeSample,
HKWorkout, HKClinicalRecord (clinical out-of-scope per R-F T3
defer). Map each to existing MeasurementType + Workout models.
Effort: L (~3-4 days). Slots into the v1.4.x freeze-marker patch
(v1.4.34) cleanly.
iOS-side: ship the placeholder "Apple Health Import — coming in
v1.4.34" in Settings now if you want the surface visible early.
### R3 — externalId shape lock — confirmed, lands in v1.4.30
ANSWER: confirmed locked in v1.4.30. The lock-line will appear at
`.planning/v15-ios-handoff/08-locked-contracts.md` once the v1.4.30
implementation agent (currently running, ETA hours from now)
commits it. Will cite the exact file:line in the v1.4.30 closure
report at `.planning/round-v1430-closure-report.md`.
The format is hard-fixed: `"stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>"`
- `<HKQuantityTypeIdentifier>` is the literal Apple identifier
(e.g. `"HKQuantityTypeIdentifierStepCount"`), no trimming, no
case-change
- `<YYYY-MM-DD>` is the calendar date in the user's TZ at the
time the day-bucket closes (00:00:00 local of the following day),
zero-padded
- The two `:` separators are part of the shape — no other delimiter
variation
No alternate shape will be accepted server-side after the lock.
iOS can start sending this format the moment v1.4.30 deploys.
### R4 — v1.4.30 deploy date — within 24-48 hours of 2026-05-16
ANSWER: implementation agent is currently running; ship target is
within 24-48 hours from 2026-05-16 (so 2026-05-17 to 2026-05-18).
Server-team-side coordination drives the exact moment; this response
will be amended with the actual deploy timestamp once it lands.
For iOS-side Mood-redesign timing: the safe path is a one-cycle
dual-write (write both `note` and `tags["note:..."]`) only if the
iOS Mood-redesign TestFlight ships BEFORE the v1.4.30 deploy. If
the iOS branch ships after, single-write the `note` column directly.
Server-side back-fill collapses prior `tags["note:..."]` rows into
the new column in the v1.4.30 release — see commit 4 of the v1.4.30
plan. After v1.4.30 deploy: existing rows have `note` populated,
new writes go straight to `note`.
Recommended iOS sequence:
1. Wait for the v1.4.30 deploy announcement in `.planning/round-v1430-closure-report.md`
2. Cut the Mood-redesign TestFlight after that with single-write
3. No dual-write needed
### R5 — assistant.coach feature flag — gates BOTH server-routed AND on-device surfaces
ANSWER: yes. The operator-control philosophy is "operator can
disable assistant surfaces app-wide". `assistant.coach` and
`assistant.briefing` flags gate every assistant-driven surface on
every client, regardless of whether the LLM runs server-side or on
the device.
- Server-side: the relevant `/api/insights/*` endpoints return
403 + `errorCode: "assistant.disabled.<surface>"` when the flag
is off.
- iOS-side: when `GET /api/feature-flags` returns `assistant.coach: false`,
iOS hides the Coach surfaces (whether they would have called the
server SSE OR run on-device).
- iOS-side: when `assistant.briefing: false`, iOS hides the Daily
Briefing card (whether it would have called the server OR generated
on-device).
- iOS-side: when `assistant.enabled: false` (the master flag), ALL
five sub-flags are effectively off — iOS hides every assistant
surface, server returns 403 on every assistant endpoint.
iOS-side default to gate-both is correct. No client-server split
on flag semantics.
### R6 — Coach SSE decoupling from v1.5 plan — confirmed, brief update queued
ANSWER: confirmed. The contributor brief §5b "Coach SSE — the v1.5
differentiator" is being updated to:
> Coach SSE remains live as a server endpoint. iOS native server-Coach
> drawer is deferred pending MDR Class-IIa pre-review. v1.5.0 ships
> iOS with Apple Foundation Models on-device Daily Briefing + Trend
> Observations as the primary assistant surface. The server's
> `GET /api/insights/chat` SSE endpoint stays live for: PWA users on
> non-AFM-capable devices, future iOS reevaluation post-MDR, any other
> client that adopts the SSE protocol.
Will commit alongside this response.
### R7 — Source-priority editor divergence flag in v1.4.33 closure — committed
ANSWER: yes. The v1.4.33 closure report at
`.planning/round-v1433-closure-report.md` will include a dedicated
"Source-priority editor divergence" section that flags any
deviation from the locked `GET/PUT /api/auth/me/source-priority`
contract. If the web ships with strict contract-parity (target
shape), the section will read "no divergence; iOS can mirror the
shape 1:1". If anything diverges (new optional keys, new constraint
logic), each delta gets cited with file:line + suggested iOS-side
treatment (mirror vs ignore).
### R8 — APNs `.p8` paste status — pending operator (Marc) action
ANSWER: not yet pasted. The `.p8` file (`M9WAFLNC2U`) lives in
operator-side `~/Downloads`. Pasting it into the Coolify env-vars
is a ~1-hour operator action — required no server-side coordination
beyond what already exists.
Surfaced again in this response so it doesn't slip past the v1.5.0
calendar window. The push-notification infrastructure on the server
is in place (v1.4.23 work); the missing piece is the env var.
Will track operator-side completion and flag the deploy in a future
closure report.
### R9 — SyncMode conflict resolution policy — LWW by `updatedAt`, server-wins on tie
ANSWER: hard-spec follows. Will be added to
`.planning/v15-ios-handoff/08-locked-contracts.md` in v1.4.30
alongside the daily-stats `externalId` lock.
**Conflict-resolution policy under `SyncMode = paired`:**
1. **Bulk-backfill (first-pair):** iOS pushes a backlog via
`POST /api/mood-entries/bulk` / `POST /api/medications/intake/bulk`.
Server UPSERTs every entry keyed on `externalId` (or
`clientId` if no `externalId` exists). Server's existing
uniqueness constraints handle dedup; LWW is not invoked.
2. **Steady-state bidirectional sync:** every `Measurement` /
`MoodEntry` / `MedicationIntakeLog` row carries:
- `updatedAt DateTime` (server-set on every write)
- `syncVersion Int @default(1)` (server-incremented on every
write)
- `deletedAt DateTime?` (soft-delete, never hard-delete)
3. **Write conflict:**
- iOS sends `PATCH /api/<entity>/{id}` with optimistic-lock
header `If-Match: <syncVersion>`
- If server's `syncVersion` matches: accept, increment, return
200 + new `syncVersion`
- If server's `syncVersion` is newer: REJECT with 409 + return
the canonical row payload (`{ data: <row>, errorCode: "sync.conflict" }`)
- iOS-side resolution on 409:
- Default policy: **LWW by `updatedAt`** — whoever has the
newer `updatedAt` wins. If iOS local copy is newer, iOS
sends a fresh PATCH with the server's new `syncVersion`.
If server's copy is newer, iOS adopts the server payload
and discards the local edit.
- Edge case (tie on `updatedAt` to the millisecond):
**server-wins**. iOS adopts the server payload.
- User-visible: optional small toast "synced with cloud
version — your changes were discarded" if iOS adopts the
server payload over a local edit. iOS-side UX call.
4. **Delete conflict:**
- Soft-delete only via `PATCH /api/<entity>/{id}` with
`deletedAt = now()`. Hard-delete blocked.
- If iOS PATCHes a soft-delete on a row the server has
subsequently edited: server returns 409 + canonical row.
iOS treats this as "server says this row was edited after
your delete intent — abort delete, prompt user to confirm".
- If iOS PATCHes an edit on a row the server has
soft-deleted: server returns 410 Gone. iOS adopts the
server's soft-delete state (or surfaces "this row was
deleted on another device — discard your edit?").
5. **Sync-state envelope:**
- `GET /api/sync/state` returns:
```json
{
"data": {
"syncVersion": <user-level current high-water-mark>,
"lastSyncedAt": "<ISO 8601>",
"perEntity": {
"Measurement": <high-water syncVersion>,
"MoodEntry": <high-water>,
"MedicationIntakeLog": <high-water>
}
}
}
```
- iOS uses `perEntity` to decide which `?since=syncVersion=N`
reads to fire after a reconnect.
iOS-side: this is enough to ship the v0.6.0 standalone-first track
with bidirectional sync. The LWW-by-updatedAt + server-wins-on-tie
default matches Apple convention. If iOS wants richer merge
semantics later (per-field LWW or three-way merge), that's a
v1.6 conversation.
Status: hard-spec confirmed by this response. Server-side codifies
it in `08-locked-contracts.md` in the v1.4.30 commit alongside the
externalId lock.
### R10 — Enum-add accepts pre-display — confirmed
ANSWER: yes. Once v1.4.30 ships the Prisma enum extension +
the Zod validator update, the server accepts
`POST /api/measurements` with `type=WALKING_STEADINESS` or
`type=AUDIO_EXPOSURE_EVENT` immediately. The web-side display
landing in v1.4.33 is independent — the rows persist in the DB
from the moment v1.4.30 deploys; the web just doesn't render
them until v1.4.33.
iOS can start ingesting these types in the same TestFlight build
that adopts the daily-stats `externalId` shape (note:
`WALKING_STEADINESS` is NOT in `CUMULATIVE_HK_TYPES` — it's
per-sample like other vitals; `AUDIO_EXPOSURE_EVENT` is also
per-sample / event-shaped, not cumulative).
No build-flag gate is required iOS-side.
## 4. New v1.4.x patches added per this response
The web roadmap (`.planning/v15-strategic-plan.md`) is being updated
with two new patches:
- **v1.4.34 — Apple Health XML import + web freeze marker**.
Replaces the original v1.4.33 freeze-marker. The freeze marker
now lands at v1.4.34, after the XML import endpoint ships. iOS
can plan against this for the "existing-user-with-history join"
flow.
- Misc smaller patches (v1.4.30.x hotfix-style) if any of R1-R10
surface a small gap before freeze.
Calendar still leaves multi-month room before iOS launches; the web
side will not pile on more features without a clear use case.
## 5. iOS-side notes back
- The two new MeasurementType enums (`WALKING_STEADINESS`,
`AUDIO_EXPOSURE_EVENT`) are NOT cumulative — they're per-sample
or event-shaped. They do not flow through the daily-stats
`externalId` path. Send them as raw per-sample rows like other
vitals.
- iOS-side §6 mentions iOS uses real `APIClient` with stub
`URLSession` per the v0.2.0 audit lesson. Server-side mirror:
v1.4.29's real-Postgres integration-test container fixture is
the same discipline. Both sides honour "don't mock the boundary
you're testing".
- The Apple Foundation Models pivot is a strong direction. Server-side
has no equivalent — no on-device runtime — so the server's
assistant-driven surfaces stay LLM-provider-routed (Anthropic /
OpenAI / etc. via the existing provider abstraction). iOS users
on AFM-capable devices get the on-device path; everyone else
(PWA mobile, non-AFM iOS devices, web desktop) gets the server
path. Both paths gate on the `assistant.*` feature flags
consistently (see R5).
## 6. Open items back to iOS
No counter-questions. Every R1-R10 answered above. Will refresh this
response if v1.4.30 deploy surfaces additional cross-coordination items.
The next checkpoint is the `.planning/round-v1430-closure-report.md`
once v1.4.30 ships — that report carries:
- exact deploy timestamp on both hosts (answers R4 with precision)
- the cited line in `08-locked-contracts.md` confirming the
`externalId` shape lock (answers R3 with precision)
- the cited line in `08-locked-contracts.md` confirming the
conflict-resolution policy (answers R9 with precision)
- the `/api/measurement-categories` endpoint URL + response shape
(answers R1 with precision)
## 7. Closing
The iOS-side strategic direction (Apple Foundation Models on-device
+ standalone-first + extended calendar) is the right call for the
product identity and the regulatory posture. Web side adapts: keep
shipping additive surfaces, hold the freeze marker until iOS-side
actually needs it, and the v1.5.0 tag stays the "iOS native client
live on the App Store" marker as originally framed.
iOS-side autopilot stays on Apple Foundation Models / standalone-first
work; server-side autopilot stays on the v1.4.30 → v1.4.34 patch run.
Coordination via these two response docs + the per-patch closure
reports is sufficient.
+605
View File
@@ -0,0 +1,605 @@
---
file: .planning/ios-contributor-current-brief.md
purpose: Single onboarding brief for the iOS contributor — current server state, conventions, locked contracts, what is live, what is coming in v1.4.30, what is still pending. Pass this verbatim to the iOS development assistant.
created: 2026-05-16
audience: iOS engineer + their automation
---
# HealthLog iOS contributor brief — current state
This document is the single-page orientation for anyone (engineer or
automation) working on the iOS native client at
`/Users/marc/Projects/healthlog-iOS/`. Read it once; it points at the
locked-contract pack for everything else.
## 1. The release model in one paragraph
The web app ships every functional change the iOS client depends on
incrementally in v1.4.x patches. **v1.5.0 is a version-bump-only
marker** the web tags on the day the iOS app clears Apple review.
There is no coordinated web+iOS sprint that ships together. Web and
iOS run in parallel: the web side lands a patch, deploys, and the iOS
client consumes the new surface as soon as its next TestFlight build
is ready. The web freezes after v1.4.33 (last planned patch); from
that point only hotfixes + dependency updates touch the web until iOS
launches.
## 2. Conventions every commit must honour
These apply to web AND iOS contributors, every artefact, every PR:
### Marc-Voice English
Every commit message, code comment, CHANGELOG line, in-app string,
release note, planning doc reads as the maintainer's authorship.
Terse, professional, no emojis, no marketing fluff, no personal
pronouns ("I" / "we" / "our"). When in doubt, drop one adjective and
re-read.
### Forbidden vocabulary
Banned anywhere except backticked file paths and identifiers:
- `AI`, `Claude`, `agent`, `marathon`, `wave`, `phase`, `session`,
`subagent`, `Anthropic`
Substitute: `assistant`, `coach`, `automation`, `contributor`,
`round`, `pass`, `track`, `stream`.
Two documented exemptions live in the locale bundles only — the
provider-chooser dropdown values `settings.ai.providerOptions.anthropic`
and `settings.ai.activeProviderOptions.anthropic` render
`Anthropic (Claude)` across all six locales because the operator is
selecting the literal vendor product.
### No PII in user-facing artefacts
Maintainer name, real health figures, real target ranges, real
measurement counts do not appear in commit messages, CHANGELOG,
release notes, in-app copy, locale bundles, public docs, GitHub
releases. Use placeholders ("the maintainer", "a moderate delta",
"tens of thousands of rows") if you need to reference shape.
### iOS contract is additive only
Every server-side change must be additive to the iOS client. No
renames, no drops, no type changes on endpoints + DTO fields + Prisma
columns the iOS client reads. New columns + new endpoints are
welcome. The locked-contract reference is at
`.planning/v15-ios-handoff/08-locked-contracts.md` — read it before
touching any `/api/*` route or any Prisma model the iOS client
consumes.
### Branch + release discipline
- Commit to `develop`. Never `main` directly.
- Release via PR `develop → main`, squash on `main`, tag on `main`.
- GHCR multi-arch builds publish from `main`. The `develop`-to-`main`
squash + tag pipeline is automated end-to-end except for the APNs
`.p8` paste (operator action — see §6).
- No `Co-Authored-By: Claude` trailer.
- No `--no-verify`. No `--no-gpg-sign`.
## 3. What is live in production right now
Both production hosts (`healthlog.bombeck.io` and
`demo.healthlog.dev`) currently serve **v1.4.29.1**.
### Recent releases
| Tag | Headline | Notes |
|---|---|---|
| v1.4.27 | Mobile capability sweep — `<ResponsiveSheet>`, `<NativeSelect>`, `<CoachLaunchProvider>` | The mobile baseline |
| v1.4.28 | Bug-fix + scope-reduction follow-through | Retired the GLP-1 dashboard tile, the InsightAdvisorCard, the weekly-report code path. Locked-contract endpoints kept |
| v1.4.28.1 | Dashboard-save hotfix | Resolver filters retired widget ids from the saved layout on read |
| v1.4.29 | Dashboard performance + chart polish | aggregate=daily server path, AVG/SUM for cumulative HK types, pulse chart bounds, x-axis tick positions, mobile tile equal-height, drag-list compactness |
| v1.4.29.1 | Daily-step aggregation hotfix | Client-side daily aggregator branches on `CUMULATIVE_HK_TYPES` for sum vs average |
### Endpoints the iOS client may consume today (additive contracts)
Stable since their respective release tags. Every one is locked per
`.planning/v15-ios-handoff/03-api-contracts.md`.
- `POST /api/auth/login` + `POST /api/auth/login/native-token` — bearer auth flow
- `POST /api/auth/passkey/*` — passkey enrollment + login + native-token verify
- `POST /api/auth/refresh` — bearer refresh
- `GET /api/auth/me` + `PATCH /api/auth/me/*` (timezone, doctor-report-prefs, devices, source-priority, research-mode)
- `GET /api/measurements` + `POST /api/measurements` + `PATCH /api/measurements/{id}` (additive 409 on duplicate-timestamp since v1.4.28; new `from`/`to`/`aggregate=daily|weekly|monthly` query params since v1.4.28, server-side bug closed in v1.4.29)
- `GET /api/medications/[id]/intake` — read-only (FB-E1 web mount retired in v1.4.28, endpoint preserved)
- `GET /api/medications/[id]/glp1` — full DTO including `Glp1InventoryDTO` slot (FB-E2 web mount retired in v1.4.28, DTO preserved)
- `POST /api/insights/generate` — assistant regen path (FB-J2 advisor card retired in v1.4.28, endpoint preserved for iOS)
- `GET /api/insights/chat` (SSE) — Coach stream
- `GET /api/insights/cards`, `GET /api/insights/correlations`, `GET /api/insights/comprehensive`, `GET /api/insights/targets`
- `POST /api/workouts/batch` — batch ingest from HKWorkout (since v1.4.25 W8d; canonical-row picker added in v1.4.30 — see §4)
- `GET /api/version` — live build + `offlineGeoEnabled` flag
- 47 distinct paths total per the R-E iOS audit (`/Users/marc/Projects/HealthLog/.planning/research/v1428-r1-ios-contracts.md` enumerates them; mostly admin + monitoring routes not iOS-relevant)
### Server-side cumulative-type aggregation already in place
Since v1.4.29 the server's `GET /api/measurements?aggregate=daily|weekly|monthly`
path reduces with `SUM` for cumulative HealthKit types — see the
`CUMULATIVE_HK_TYPES` set in `src/lib/measurements/apple-health-mapping.ts`:
- `ACTIVITY_STEPS`
- `ACTIVE_ENERGY_BURNED`
- `FLIGHTS_CLIMBED`
- `WALKING_RUNNING_DISTANCE`
- `TIME_IN_DAYLIGHT`
Every other measurement type reduces with `AVG`. The client-side
daily aggregator in `health-chart.tsx` follows the same branch since
v1.4.29.1.
## 4. What is incoming in v1.4.30 (currently being implemented)
v1.4.30 is the iOS-coordinated foundation patch. Ships in 2-3 days.
Every change additive. Track the canonical scope at
`.planning/v15-strategic-plan.md` §2 "v1.4.30 — iOS-coordinated
foundation".
### Daily-Stats externalId — the cutover for cumulative HK types
The biggest single iOS-side coordination point. v1.4.30 ships:
- A new helper `dailyStatsExternalId(hkIdentifier, dateYYYYMMDD)`
returning `"stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>"` — locked
in `.planning/v15-ios-handoff/08-locked-contracts.md`. The shape is
the iOS-side contract for one-row-per-day-per-cumulative-type ingest.
- The server tolerates BOTH ingest shapes during the cutover: existing
per-sample rows AND new daily-aggregated rows. The existing
`@@unique([userId, type, measuredAt, source, sleepStage])` constraint
keeps both shapes deduplicated.
- A drain script `scripts/drain-per-sample-cumulative.ts` (CLI + admin
endpoint `POST /api/admin/drain-per-sample-cumulative`) idempotently
collapses existing per-sample APPLE_HEALTH cumulative rows into one
daily row per type per user-calendar-day. Operator runs it once
after the iOS TestFlight build cuts over.
### iOS-side responsibility (please implement in the next TestFlight build)
Build a `HealthKitStatisticsService.swift` per R-A §5:
- For every `CUMULATIVE_HK_TYPES` member, run
`HKStatisticsCollectionQuery` with
`intervalComponents: DateComponents(day: 1)` and
`options: .cumulativeSum` (for steps / energy / flights / distance /
daylight). Period: from each user's `firstSampleAt` or sensible
bound (last 365 days for first install, anchored for incremental
syncs).
- For each daily bucket, POST one row via the existing
`POST /api/measurements` (or the batch endpoint) with:
- `type` = mapped MeasurementType
- `source` = `APPLE_HEALTH`
- `value` = day total
- `measuredAt` = midday UTC of the user's calendar day (matches the
Withings activity-sync convention)
- `externalId` = `"stats:<hkIdentifier>:<YYYY-MM-DD>"`
- Keep a per-day last-posted-value cache. On a late-watch-sync where
the day's total changes after the row was first posted, send a
`PATCH /api/measurements/{id}` (or re-POST with the same
`externalId` to trigger an UPSERT) so the server row tracks the
true total.
- Gate behind a build-flag `ENABLE_DAILY_STATS` — default ON for the
cut-over TestFlight build. This stops sending per-sample rows for
cumulative types simultaneously.
Spot metrics (BP, weight, pulse, mood, glucose, body fat, sleep)
stay per-sample as today.
### SyncMode foundation
For the standalone-first / paired-with-server / cloud-sync trio (see
`.planning/v15-ios-handoff/22-standalone-and-server-pairing.md`).
v1.4.30 ships:
- New columns: `Measurement.syncVersion Int @default(1)` +
`Measurement.deletedAt DateTime?` (soft-delete)
- New columns: `User.lastSyncedAt DateTime?`
- New endpoint `GET /api/sync/state` — returns per-user current
`syncVersion` and `lastSyncedAt` so iOS can decide whether to pull
or push.
- New endpoints `POST /api/mood-entries/bulk` and
`POST /api/medications/intake/bulk` — bulk-backfill paths for the
pair-fresh-server flow.
### MoodEntry.note as a first-class column
Replaces the `tags: ["note:<text>"]` workaround. The iOS app currently
encodes mood notes as a tag prefix; in v1.4.30+ the iOS app should
send `note: string` directly on the mood-entry payload. Server-side
backfill collapses the prior `note:`-tag rows into the new column.
### Workout canonical-row picker
`pickCanonicalWorkoutRows()` lands in `src/lib/measurements/` to
dedup Apple Watch + Withings ScanWatch workouts that report the same
session. iOS-side workout ingest stays unchanged — the server picks
the canonical row.
### Categorisation overlay
`src/lib/measurements/categories.ts` is a TypeScript map of
MeasurementType → category (vitals / body / activity / sleep /
hearing / environment / cardiovascular / metabolic / mood /
medication). iOS HealthKit permission picker should derive its groups
from this map; the web Insights nav will too. iOS-side: read the map
on app launch (or build-time-codegen it from the server's OpenAPI
schema).
### Two new MeasurementType enums
- `WALKING_STEADINESS` (mapped from `HKQuantityTypeIdentifierAppleWalkingSteadiness`)
- `AUDIO_EXPOSURE_EVENT` (mapped from `HKCategoryTypeIdentifierAudioExposureEvent`)
iOS-side: these will land in the iOS app's `MeasurementType` mirror
enum after the OpenAPI codegen wiring closes (R-E H-8). Until then,
iOS can ignore them or the manual enum-extension matches the server
addition.
## 5. What is incoming in v1.4.31 - v1.5.0
Plan reference: `.planning/v15-strategic-plan.md` §2. Every change
listed below is additive on the iOS contract unless explicitly
flagged otherwise — and nothing is flagged otherwise. By v1.5.0 every
endpoint + DTO + Prisma column listed here is live in production. The
iOS-side implementation can be written against the planned surfaces
in parallel, ready to consume each one as soon as the corresponding
web patch deploys.
### v1.4.31 — Operator toggles + insights tab-strip blocking + Coolify auto-deploy fix
**New API surface (iOS can consume from v1.4.31 onwards):**
- `GET /api/feature-flags` — public endpoint (auth-required but no
admin gate). Response shape:
```json
{
"data": {
"assistant": {
"enabled": true,
"coach": true,
"briefing": true,
"insightStatus": true,
"correlations": true,
"healthScoreExplainer": true
}
}
}
```
iOS calls this on app launch and caches per-session. When any flag
is `false`, the relevant iOS surface should hide (Coach tab, daily
briefing card, per-metric insight status, correlation card,
delta-explainer `?`).
**Endpoints that may now return 403 with `errorCode: "assistant.disabled.<surface>"`:**
| Endpoint | Flag |
|---|---|
| `GET /api/insights/chat` (SSE) | `coach` |
| `POST /api/insights/generate` | `coach` (regenerate is part of the Coach feature set) |
| `GET /api/insights/comprehensive` | `coach` |
| `GET /api/insights/briefing` (if exists; per v1.5 research) | `briefing` |
| `GET /api/insights/cards` per-metric status | `insightStatus` |
| `GET /api/insights/correlations` | `correlations` |
| (No endpoint for healthScoreExplainer — it's a pure client-side overlay) | `healthScoreExplainer` |
**iOS handling**: catch 403 + `errorCode: "assistant.disabled.<surface>"`,
render a neutral "Coach is disabled by your operator" / "Briefing is
disabled by your operator" view in the affected slot. No retry, no
backoff — wait for the user to navigate elsewhere.
**Other v1.4.31 items (not iOS-facing):**
- Insights tab-strip blocking fixes (web client only)
- Coolify auto-deploy investigation (ops only)
### v1.4.32 — HealthKit Tier 1 web surfaces wave A
The web surfaces for Workouts + 5 of the 10 invisible-but-stored
metrics land here. **No new endpoints**; iOS continues consuming the
existing endpoints. The contracts iOS plans against:
- `POST /api/workouts/batch` (live since v1.4.25 W8d) gains the
server-side `pickCanonicalWorkoutRows()` dedup (landed in v1.4.30).
iOS keeps its existing batch shape.
- `GET /api/workouts` (if not already live — check the route file in
v1.4.30 verification) — list workouts. iOS can mirror the new web
workout-list page.
- `GET /api/workouts/{id}` — workout detail with cross-source merged
payload, route GPS optional, HR-zone samples optional. iOS-side
workout detail screen consumes this shape.
- `GET /api/measurements?type=HRV` (and same for RestingHR, SpO2,
BodyTemperature, ActiveEnergyBurned) — all already live since the
type was added to the enum. iOS-side chart cards consume the same
`/api/measurements` path it uses for every other type.
### v1.4.33 — HealthKit Tier 1 web surfaces wave B + web freeze marker
The breadth wave + the editor parity surfaces. iOS can consume:
**Existing endpoints with new MeasurementType values flowing through them:**
- `GET /api/measurements?type=FLIGHTS_CLIMBED` — daily-aggregated
cumulative-type shape per R-A.
- Same for `WALKING_RUNNING_DISTANCE`, `AUDIO_EXPOSURE_ENV`,
`AUDIO_EXPOSURE_HEADPHONE`, `TIME_IN_DAYLIGHT`.
- `GET /api/measurements?type=WALKING_STEADINESS` — new MeasurementType
added in v1.4.30; iOS picks this up via the v1.4.30 codegen extension.
- `GET /api/measurements?type=AUDIO_EXPOSURE_EVENT` — same.
**Source-priority editor parity:**
- `GET /api/auth/me/source-priority` and `PUT /api/auth/me/source-priority`
— both live and locked since v1.4.25 W8c. iOS implementation of the
two-axis editor under Settings → Sources & Devices can be built
against the existing API; the web-side editor that lands in v1.4.33
is a reference shape iOS can mirror.
**HKStateOfMind round-trip:**
- `POST /api/measurements` with `type=MOOD` and `source=APPLE_HEALTH`
— already live since the MOOD type exists. v1.4.33 wires
`/insights/stimmung` to surface APPLE_HEALTH-sourced mood entries
cleanly. iOS-side: write HKStateOfMind samples via the existing
POST path; read back via `GET /api/measurements?type=MOOD`. The
`HKMetadataKeyExternalUUID` round-trip filter (per R-F open Q #5)
prevents iOS from re-uploading its own samples.
### v1.5.0 — version-bump-only marker
Tags when the iOS app clears Apple review. CHANGELOG entry: "iOS
native client now live on the App Store; web functionality unchanged
since v1.4.33." Triggers a GHCR rebuild so the running image's
`/api/version` sentinel matches the public tag. **No source diff,
no API change, no DTO change.** From this point every web change
coordinates with iOS.
## 5b. Forward state at v1.5.0 — what the iOS app can rely on
By the time v1.5.0 ships, every iOS-facing surface listed below is
live and locked. The iOS engineer can plan the full v1.5 feature
set against this state — there are no further additive contracts
planned for v1.4.x.
### Endpoints (locked + additive only)
| Path | Method | Status by v1.5.0 |
|---|---|---|
| `/api/auth/login` | POST | Stable since v1.4.x |
| `/api/auth/login/native-token` | POST | Stable since v1.4.23 |
| `/api/auth/passkey/enrolment/start` | POST | Stable |
| `/api/auth/passkey/enrolment/finish` | POST | Stable |
| `/api/auth/passkey/login-start` | POST | Stable |
| `/api/auth/passkey/login-verify` | POST | Stable |
| `/api/auth/refresh` | POST | Stable |
| `/api/auth/me` | GET, PATCH | Stable |
| `/api/auth/me/timezone` | PATCH | Stable |
| `/api/auth/me/source-priority` | GET, PUT | Locked v1.4.25 W8c |
| `/api/auth/me/research-mode` | GET, PATCH | Stable |
| `/api/auth/me/doctor-report-prefs` | GET, PATCH | Stable |
| `/api/auth/me/devices` | GET | Stable |
| `/api/auth/me/devices/{id}` | PATCH, DELETE | Stable |
| `/api/measurements` | GET, POST | Stable; `from`/`to`/`aggregate` query params live since v1.4.29 |
| `/api/measurements/{id}` | PATCH | Stable; 409 on duplicate-timestamp since v1.4.28 |
| `/api/measurements/by-external-ids` | DELETE | Stable; used by drain script + iOS sync |
| `/api/mood-entries` | GET, POST | Stable; `note` column lands in v1.4.30 |
| `/api/mood-entries/bulk` | POST | NEW in v1.4.30 |
| `/api/medications` | GET, POST | Stable |
| `/api/medications/{id}` | GET, PATCH, DELETE | Stable |
| `/api/medications/{id}/intake` | GET, POST | Stable; preserved per FB-E1 |
| `/api/medications/{id}/intake/{logId}` | PATCH, DELETE | Stable |
| `/api/medications/intake/bulk` | POST | NEW in v1.4.30 |
| `/api/medications/{id}/glp1` | GET | Stable; Glp1InventoryDTO preserved per FB-E2 |
| `/api/medications/{id}/inventory` | GET, POST | Stable |
| `/api/medications/{id}/side-effects` | GET, POST | Stable |
| `/api/medications/{id}/side-effects/{logId}` | PATCH, DELETE | Stable |
| `/api/medications/{id}/titration` | GET, PATCH | Stable |
| `/api/medications/{id}/cadence` | GET, PATCH | Stable |
| `/api/workouts` | GET | Verified in v1.4.32 |
| `/api/workouts/{id}` | GET | Verified in v1.4.32 |
| `/api/workouts/batch` | POST | Stable since v1.4.25 W8d; canonical-row picker server-side in v1.4.30 |
| `/api/personal-records` | GET | Stable |
| `/api/insights/chat` | GET (SSE) | Stable; 403 + assistant.disabled.coach possible from v1.4.31 |
| `/api/insights/generate` | POST | Stable; preserved per FB-J2; 403 from v1.4.31 |
| `/api/insights/comprehensive` | GET | Stable; 403 from v1.4.31 |
| `/api/insights/cards` | GET | Stable; 403 from v1.4.31 |
| `/api/insights/correlations` | GET | Stable; 403 from v1.4.31 |
| `/api/insights/targets` | GET | Stable |
| `/api/insights/briefing` | GET | Verified in v1.4.31; 403 from v1.4.31 |
| `/api/sync/state` | GET | NEW in v1.4.30 |
| `/api/feature-flags` | GET | NEW in v1.4.31 |
| `/api/dashboard/widgets` | GET, PUT, DELETE | Stable |
| `/api/dashboard/summary` | GET | Stable |
| `/api/dashboard/chart-overlay-prefs` | GET, PATCH | Stable |
| `/api/admin/drain-per-sample-cumulative` | POST | NEW in v1.4.30 (admin-gated) |
| `/api/version` | GET | Stable; `offlineGeoEnabled` flag + version sentinel |
### Prisma schema additions through v1.5.0
All additive. No iOS-breaking drops + no type changes.
| Column / enum | Patch | Notes |
|---|---|---|
| `Measurement.syncVersion Int @default(1)` | v1.4.30 | SyncMode foundation |
| `Measurement.deletedAt DateTime?` | v1.4.30 | Soft-delete column |
| `Measurement.externalId String?` (verify if already exists) | pre-existing | UPSERT key for daily-stats rows |
| `User.lastSyncedAt DateTime?` | v1.4.30 | SyncMode foundation |
| `MoodEntry.note TEXT NULL` | v1.4.30 | Replaces `tags: ["note:..."]` workaround |
| `AppSettings.assistantEnabled Boolean @default(true)` | v1.4.31 | Master feature flag |
| `AppSettings.coachEnabled Boolean @default(true)` | v1.4.31 | Sub-flag |
| `AppSettings.briefingEnabled Boolean @default(true)` | v1.4.31 | Sub-flag |
| `AppSettings.insightStatusEnabled Boolean @default(true)` | v1.4.31 | Sub-flag |
| `AppSettings.correlationsEnabled Boolean @default(true)` | v1.4.31 | Sub-flag |
| `AppSettings.healthScoreExplainerEnabled Boolean @default(true)` | v1.4.31 | Sub-flag |
| `enum MeasurementType { … WALKING_STEADINESS }` | v1.4.30 | New value, iOS picks up via codegen |
| `enum MeasurementType { … AUDIO_EXPOSURE_EVENT }` | v1.4.30 | New value |
### MeasurementType + categorisation overlay
By v1.5.0, the categorisation overlay at
`src/lib/measurements/categories.ts` is locked. iOS reads the map at
build-time via OpenAPI codegen (or mirrors it manually):
| Category | MeasurementType values |
|---|---|
| `vitals` | `BP_SYSTOLIC`, `BP_DIASTOLIC`, `PULSE`, `OXYGEN_SATURATION`, `BODY_TEMPERATURE` |
| `body` | `WEIGHT`, `BODY_FAT`, `TOTAL_BODY_WATER`, `BONE_MASS` |
| `activity` | `ACTIVITY_STEPS`, `ACTIVE_ENERGY_BURNED`, `FLIGHTS_CLIMBED`, `WALKING_RUNNING_DISTANCE`, `WALKING_STEADINESS` |
| `cardiovascular` | `HRV`, `RESTING_HR`, `VO2_MAX` |
| `sleep` | `SLEEP_*` (per-stage rows) |
| `hearing` | `AUDIO_EXPOSURE_ENV`, `AUDIO_EXPOSURE_HEADPHONE`, `AUDIO_EXPOSURE_EVENT` |
| `environment` | `TIME_IN_DAYLIGHT` |
| `metabolic` | `BLOOD_GLUCOSE`, `BMI` |
| `mood` | `MOOD` |
| `medication` | (medications use a separate model — not a MeasurementType) |
The iOS HealthKit permission picker derives its groups from this
map.
### Daily-stats cumulative-type ingest (R-A Option A)
By v1.5.0 the iOS TestFlight build is posting one row per day per
cumulative type. The `CUMULATIVE_HK_TYPES` set is:
- `ACTIVITY_STEPS` ↔ `HKQuantityTypeIdentifierStepCount`
- `ACTIVE_ENERGY_BURNED` ↔ `HKQuantityTypeIdentifierActiveEnergyBurned`
- `FLIGHTS_CLIMBED` ↔ `HKQuantityTypeIdentifierFlightsClimbed`
- `WALKING_RUNNING_DISTANCE` ↔ `HKQuantityTypeIdentifierDistanceWalkingRunning`
- `TIME_IN_DAYLIGHT` ↔ `HKQuantityTypeIdentifierTimeInDaylight`
`externalId` shape: `"stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>"`
locked in `.planning/v15-ios-handoff/08-locked-contracts.md`.
### SyncMode trio (standalone-first / paired-with-server / cloud-sync)
By v1.5.0 the iOS app supports all three modes. The web-side
endpoints are stable:
- `GET /api/sync/state` returns current server `syncVersion` +
`lastSyncedAt`
- `POST /api/mood-entries/bulk` + `POST /api/medications/intake/bulk`
let iOS push a backlog after pairing
- `Measurement.deletedAt` makes tombstoned-row sync straightforward
Mode-switching is iOS-local; the server has no concept of "this user
is in standalone mode". The server just answers whatever the iOS app
asks.
### Coach SSE — the v1.5 differentiator
By v1.5.0 the iOS Coach drawer is the native implementation of
`GET /api/insights/chat`. Reference web implementation in
`src/components/insights/coach-panel/`. Contract specifics:
- Server-Sent Events stream
- Token-by-token streaming text
- Provenance events embedded with each token chunk
- Refusal events per GROUND-RULE-9 / 15
- 429 `coach.budget.exceeded` on rate-limited paths
- 403 `assistant.disabled.coach` if the operator-side flag is off
### Push notifications (post-APNs `.p8` paste)
The APNs scaffolding exists server-side. After the operator pastes
the `.p8` key, server-issued push notifications start. The iOS-side
handler should already be wired per the v1.4.23 work — verify in the
iOS repo.
### What is explicitly NOT in v1.5
These defer to v1.5.x or v1.6:
- HealthKit Tier 2 categories (R-F T2): workout power metrics,
workout-effort score, sleep apnea breathing disturbances, mindful
sessions, six-minute walk, HR recovery
- HealthKit Tier 3 (R-F T3): FHIR clinical, ECG waveforms, atrial-fib
burden, PHQ-9/GAD-7, reproductive, nutrition, Apple Watch
independent app
- Apple Health XML import (`export.zip` ingest) — open question per
v1.5 plan §6 #3
- Two-axis source-priority editor on iOS — endpoint exists; UI lands
in v1.5.x
- C1 architectural lift on `/api/analytics` (split into dashboard +
insights surfaces, SQL-side aggregation) — v1.5.x
## 6. iOS-side blockers and pending coordination
### APNs `.p8` paste (operator action, 1 hour)
The Apple Push Notification Service key has not been pasted into the
Coolify environment yet. Per the R-E C-3 finding, this is a 1-hour
operator action that gates server-issued push notifications. The
APNs scaffolding (server-side route + iOS-side handler) is partially
in place from the v1.4.23 work; the missing piece is the operator
pasting the `.p8` into the deployment environment.
**Status: pending — operator (Marc) has not yet pasted the key.**
This blocks push notifications only; everything else functions
without it.
### Coach SSE drawer iOS implementation
Per R-E C-1, the iOS Coach drawer has zero native code today. The
server-side SSE endpoint at `/api/insights/chat` is live and tested.
The v1.5 differentiator is the native Coach drawer. The iOS side is
the implementation:
- `CoachService` actor over `URLSession.bytes`
- `CoachStreamEvent` AsyncThrowingStream
- SwiftData-backed `CoachConversation` cache
- Streaming bubble view
- Provenance disclosure
- GROUND-RULE-9 / 15 refusal-acceptance UI
- `coach.budget.exceeded` 429 surface
Pattern reference: `src/components/insights/coach-panel/` (web
implementation) is the model. The iOS implementation should mirror
the user experience while consuming the same SSE stream.
### Source-priority editor
The endpoint `/api/auth/me/source-priority` has been locked since
v1.4.25 W8c. The iOS app does not call it anywhere today. v1.4.33
adds a web-side editor as a reference shape; iOS should mirror the
two-axis editor under Settings → Sources & Devices.
## 7. Reference paths
If the iOS contributor needs deep context:
| Doc | Purpose |
|---|---|
| `.planning/v15-ios-handoff/03-api-contracts.md` | Every endpoint + DTO shape |
| `.planning/v15-ios-handoff/04-data-model.md` | Prisma schema + invariants |
| `.planning/v15-ios-handoff/06-ios-responsibilities.md` | What iOS owns vs server |
| `.planning/v15-ios-handoff/07-server-responsibilities.md` | The mirror |
| `.planning/v15-ios-handoff/08-locked-contracts.md` | The do-not-touch list |
| `.planning/v15-ios-handoff/14-coach-mental-model.md` | Coach drawer architecture |
| `.planning/v15-ios-handoff/15-insights-architecture.md` | Insights pipeline |
| `.planning/v15-ios-handoff/16-health-score-logic.md` | HealthScore algorithm |
| `.planning/v15-ios-handoff/17-error-handling.md` | Server error contracts |
| `.planning/v15-ios-handoff/18-pattern-cookbook.md` | Common patterns |
| `.planning/v15-ios-handoff/22-offline-first-architecture.md` | Offline-first model |
| `.planning/v15-ios-handoff/22-standalone-and-server-pairing.md` | Pair / unpair flow |
| `.planning/v15-strategic-plan.md` | The full strategic plan |
| `.planning/research/v15-r-a-step-aggregation.md` | R-A daily-stats deep dive |
| `.planning/research/v15-r-e-ios-planning-state.md` | iOS gap audit (28 finds) |
| `.planning/research/v15-r-f-apple-health-depth.md` | HealthKit coverage roadmap |
## 8. Communication
The web side ships continuously. The iOS contributor should not block
on web releases — read this brief weekly (the maintainer can refresh
it after every patch), and pick up new server surfaces as soon as
they're documented in the CHANGELOG + `.planning/round-v14NN-closure-report.md`.
When the iOS-side needs a server change, the request goes through
the maintainer; if the request is additive on the iOS contract the
web ships the change in the next patch. If the request is breaking,
the maintainer either reshapes the request additively or coordinates
a paired release (which is currently out of scope until v1.5.x).
The next web release is v1.4.30 — wait for the closure report at
`.planning/round-v1430-closure-report.md` before adopting the
daily-stats path.
+35
View File
@@ -63,3 +63,38 @@ v1.4.30 scope grows from the original iOS-server-prep menu to that menu plus the
## Closure complete
v1.4.29 lives on both production hosts and the GitHub Release reads it as the latest. The next patch is v1.4.30 (iOS-server-prep menu + insights-blocking + assistant-optional toggles).
## v1.4.29.1 follow-up — daily-step aggregation hotfix
Shipped 2026-05-16 a few hours after v1.4.29. Maintainer reported the
dashboard 7-day step chart still rendered per-sample averages
(hundreds) instead of daily totals (thousands). Root cause was the
client-side daily aggregator inside `health-chart.tsx` reducing every
HealthKit type with `sum / count` regardless of cumulative vs spot
semantics — the same bug v1.4.29 closed on the server-side aggregate
path was still live on the in-browser bucketing of raw rows.
Fix at `c265103b` branches on `CUMULATIVE_HK_TYPES` and picks `sum`
for steps, active energy, flights climbed, distance, daylight; spot
metrics keep the mean. One-line aggregator change, no test additions
(the 137 existing chart specs cover the surface).
### Outcome
- `healthlog.bombeck.io/api/version` → `1.4.29.1`, `/privacy` → 200.
- `demo.healthlog.dev/api/version` → `1.4.29.1`, `/privacy` → 200.
- GitHub Release: <https://github.com/MBombeck/HealthLog/releases/tag/v1.4.29.1>.
- Sister-repos: `healthlog-docs@5e7f5cd3`, `healthlog-landing@11f18ce6`.
- PR #173 squashed on `main` at `945b5566`; tag `v1.4.29.1` points there.
- GHCR tag-build produced the named `1.4.29.1` cleanly (the `3a920661` workflow fix continues to hold).
### Deploy mechanics
apps01 deploy via Coolify reported "finished" but did not actually
pull the new `:latest` digest (third release in a row — the same
auto-deploy gap that blocked v1.4.27/v1.4.28/v1.4.29). Host-side SSH
fallback (`docker pull 1.4.29.1 && docker tag … :latest && compose up
-d --force-recreate`) finished the rollout. Edge-01 used the explicit
`1.4.29` → `1.4.29.1` sed bump on docker-compose with a
`.pre-v14291.bak` snapshot; clean execution.
@@ -349,6 +349,43 @@ The iOS app could accidentally cross the MDR line by:
When in doubt: **the iOS app is a thin client over server-rendered intelligence**. The server is the medical-information custodian; iOS is the presentation layer + the HK adapter.
## Cumulative metrics: daily aggregation on iOS (v1.4.30 — R-A Option A)
The five cumulative HealthKit types — `HKQuantityTypeIdentifierStepCount`, `HKQuantityTypeIdentifierActiveEnergyBurned`, `HKQuantityTypeIdentifierFlightsClimbed`, `HKQuantityTypeIdentifierDistanceWalkingRunning`, `HKQuantityTypeIdentifierTimeInDaylight` — switch from per-sample ingest to **one row per day per type**, pre-aggregated on iOS via `HKStatisticsCollectionQuery` with `intervalComponents: DateComponents(day: 1)` and `options: .cumulativeSum`.
Rationale + decision record: `.planning/research/v15-r-a-step-aggregation.md` §5. Server enforcement of the externalId shape: `.planning/v15-ios-handoff/08-locked-contracts.md` §13.
### Daily-stats externalId shape
```
externalId = "stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>"
```
Example: `stats:HKQuantityTypeIdentifierStepCount:2026-05-16`.
The date string is anchored to the user's IANA timezone (read via `GET /api/auth/me` → `User.timezone`). iOS generates it with `DateFormatter` using the `yyyy-MM-dd` pattern; the server trusts the format and does not re-validate. The existing `@@unique([userId, type, source, externalId])` index UPSERTs idempotently across re-syncs.
### iOS service contract
- New file `HealthLog/Services/HealthKitStatisticsService.swift` wraps `HKStatisticsCollectionQuery` per cumulative type. On every observer wake, ask "what's the new daily sum for the affected days?" and POST one row per affected day per type via the existing `/api/measurements/batch` endpoint.
- `value` = `HKStatistics.sumQuantity().doubleValue(for: <canonical unit>)`.
- `measuredAt` = the canonical midday-UTC timestamp for the user's calendar day (matches the Withings activity-sync convention).
- `unit`, `startDate`, `endDate` follow the per-day envelope (canonical unit; `startDate` = day-start, `endDate` = next-day-start − 1 ms in user TZ).
- `deviceType` = the dominant contributing source's device (`watch` when the user wears one, else `phone`).
- `externalId` = `dailyStatsExternalId(hkIdentifier, dateYYYYMMDD)` per the helper in `src/lib/measurements/apple-health-mapping.ts`.
### Late-watch-sync (PATCH-on-divergence)
When the Watch syncs at 14:00 with samples from the same morning, the existing day's row should **update**, not insert a second. The deterministic externalId collapses the second POST to `status: "duplicate"` via the unique index; to make the new value visible iOS keeps a small SQLite cache of `(type, day, last-posted-value)` and issues `PATCH /api/measurements/[id]` when the new daily total diverges. Insert-only batch ingest stays narrow; the PATCH path already exists.
### Server tolerance during cutover
The server accepts BOTH shapes — pre-existing per-sample rows (`externalId = HKSample.uuid`) and the new daily-aggregated rows (`externalId = "stats:..."`) coexist for the cutover window. Operator runs `POST /api/admin/drain-per-sample-cumulative` (or `scripts/drain-per-sample-cumulative.ts` directly) once after the new TestFlight build adopts the daily path; the drain collapses pre-existing per-sample APPLE_HEALTH cumulative rows into daily rows keyed on the new externalId shape. Re-running the drain is a no-op (idempotent).
### Spot-sample path unchanged
Non-cumulative HK types (weight, BP, pulse, BG, body fat, HRV, RHR, SpO2, body temp, VO2 max, sleep) keep `externalId = HKSample.uuid.uuidString`. Only the five cumulative types switch shape; only their iOS pipeline (`HealthKitStatisticsService.swift`) is new.
## "Since v1.4.24" diff markers
- **NEW v1.4.25** — `MeasurementType` enum gained `AUDIO_EXPOSURE_ENV`, `AUDIO_EXPOSURE_HEADPHONE`, `TIME_IN_DAYLIGHT` for HK + Withings parity.
@@ -356,6 +393,7 @@ When in doubt: **the iOS app is a thin client over server-rendered intelligence*
- **NEW v1.4.25 W16c** — `PERSONAL_RECORD` event type for APNs; default OFF.
- **NEW v1.4.25 W10** — batch ingest rate limit 60/min per user (was unbounded).
- **NEW v1.4.25 W17b/c** — Withings now syncs Activity v2 + Sleep v2 — iOS should be aware that some HK metrics may arrive via the Withings path (no action required, but the source-priority picker now matters more).
- **NEW v1.4.30** — Cumulative HealthKit types switch to one-row-per-day daily-aggregated ingest. iOS owns `HealthKitStatisticsService.swift` with `HKStatisticsCollectionQuery`; per-day externalId is `"stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>"`. Per-sample path stays valid during the cutover (server tolerates both shapes); operator runs the drain script once post-cutover. See the "Cumulative metrics: daily aggregation on iOS" section above and `08-locked-contracts.md` §13.
## iOS implementation checklist
@@ -466,7 +466,61 @@ BEFORE shipping.
---
## 12. What is NOT in this file
## 12. Daily-stats externalId shape (v1.4.30 — R-A Option A)
The five cumulative HealthKit types switch from per-sample ingest
(`externalId = HKSample.uuid`) to one row per day per type, pre-aggregated
on iOS via `HKStatisticsCollectionQuery`. The per-day row's
`externalId` is locked at this shape:
```
externalId = "stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>"
```
Examples:
| HK identifier | Day | externalId |
| --- | --- | --- |
| `HKQuantityTypeIdentifierStepCount` | 2026-05-16 | `stats:HKQuantityTypeIdentifierStepCount:2026-05-16` |
| `HKQuantityTypeIdentifierActiveEnergyBurned` | 2026-05-16 | `stats:HKQuantityTypeIdentifierActiveEnergyBurned:2026-05-16` |
| `HKQuantityTypeIdentifierFlightsClimbed` | 2026-05-16 | `stats:HKQuantityTypeIdentifierFlightsClimbed:2026-05-16` |
| `HKQuantityTypeIdentifierDistanceWalkingRunning` | 2026-05-16 | `stats:HKQuantityTypeIdentifierDistanceWalkingRunning:2026-05-16` |
| `HKQuantityTypeIdentifierTimeInDaylight` | 2026-05-16 | `stats:HKQuantityTypeIdentifierTimeInDaylight:2026-05-16` |
**Scope**: cumulative types only. The set is the canonical
`CUMULATIVE_HK_TYPES` in `src/lib/measurements/apple-health-mapping.ts`.
Spot metrics (weight, BP, pulse, BG, body fat, HRV, RHR, SpO2, body
temp, VO2 max, sleep) keep `externalId = HKSample.uuid.uuidString`.
**Server enforcement**: the helper
`dailyStatsExternalId(hkIdentifier, dateYYYYMMDD)` is the single source
of truth on the server side. The drain script
`scripts/drain-per-sample-cumulative.ts` mints the same shape when
collapsing legacy per-sample rows.
**Date string**: anchored to the user's IANA timezone (read via
`GET /api/auth/me` → `User.timezone`). iOS generates it with
`DateFormatter` using the `yyyy-MM-dd` pattern. The server trusts the
inbound format and does not re-validate beyond the existing
`externalId` Zod cap (`min(1).max(120)`).
**Cutover tolerance**: the server accepts BOTH shapes during the
cutover window. Operator runs the drain script
(`POST /api/admin/drain-per-sample-cumulative` or the CLI variant) once
after the new TestFlight build adopts the daily-stats path; re-running
the drain is a no-op (idempotent).
**Late-watch-sync handling**: a second POST for the same day collapses
to `status: "duplicate"` via the existing
`@@unique([userId, type, source, externalId])` index. To make a
divergent daily total visible, iOS issues
`PATCH /api/measurements/[id]` with the new value; the per-day SQLite
cache (`type, day, last-posted-value`) drives the divergence check.
**Test fence**: `dailyStatsExternalId` round-trip is covered by
`src/lib/measurements/__tests__/apple-health-mapping.test.ts`.
## 13. What is NOT in this file
- **API envelope details (`{ data, error, meta }`)** → `17-error-handling.md`
- **Coach snapshot construction** → `14-coach-mental-model.md`
+41 -14
View File
@@ -112,22 +112,35 @@ move aggregation to SQL, rewrite `summarize()` for single-pass). 2-3
days plus integration tests. Lands during the v1.5 server-prep
window if calendar permits, otherwise rides to v1.5.x.
### v1.4.30 — iOS-server-prep menu
### v1.4.30 — iOS-coordinated foundation (Daily-Stats + SyncMode)
Ship 2-3 days after v1.4.29. Every item is iOS-additive; the web UI
either does not consume the new surface or wires it in v1.4.31.
Ship 2-3 days after v1.4.29.1. Maintainer-directive 2026-05-16: R-A
Option A daily-stats lands in this patch, not in a v1.5 sprint. The
existing TestFlight build on the maintainer's device is already
posting per-sample cumulative rows; the server-side helper + drain
script + the `externalId` handoff-doc lock here let the iOS
TestFlight engineer cut over to `HKStatisticsCollectionQuery` in the
next iOS build with no further server release.
| Item | Source | Effort |
|---|---|---|
| **R-A Option A server-side**: `dailyStatsExternalId()` helper (returns `"stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>"`) + the `CUMULATIVE_HK_TYPES` set already exported in v1.4.29 `642e78a6`. Handoff-doc lock for the `externalId` shape in `.planning/v15-ios-handoff/06-ios-responsibilities.md` + `08-locked-contracts.md`. Server tolerates BOTH shapes during the cutover (per-sample + daily-aggregated) — the existing `@@unique([userId, type, measuredAt, source, sleepStage])` constraint plus the new `externalId` UPSERT path coexist | R-A §6, maintainer directive 2026-05-16 | S |
| **R-A drain script** at `scripts/drain-per-sample-cumulative.ts`. Idempotent: for each user × cumulative type × calendar day, SUM per-sample APPLE_HEALTH rows → UPSERT a single daily row with `externalId = "stats:<type>:<date>"` → DELETE the per-sample rows. Re-run is a no-op once collapsed. Operator runs it once after the iOS TestFlight cuts over. Optional admin endpoint `/api/admin/drain-per-sample-cumulative` triggers it via the standard admin auth gate; CLI variant for self-hosters | R-A migration path, maintainer directive | M |
| SyncMode server-side: `syncVersion Int @default(1)` on `Measurement`, `deletedAt DateTime?` soft-delete column, `/api/sync/state` endpoint, `/api/mood-entries/bulk` + `/api/medications/intake/bulk` bulk-backfill endpoints | R-E C-2 | M |
| `MoodEntry.note TEXT NULL` — replaces the `tags: ["note:<text>"]` workaround that contaminates the Coach evidence shelf | R-E H-5 | S |
| `HKWorkoutType` server route — verify `/api/workouts/batch` (in v1.4.25 W8d) middleware + caps match the new pattern, add `pickCanonicalWorkoutRows()` for Apple Watch × Withings ScanWatch dedup | R-F T1.1 | S |
| Categorisation overlay: `src/lib/measurements/categories.ts` TypeScript map driving both the iOS permission picker and a future web Insights nav | R-F §4 | S |
| Two new MeasurementType enums: `WALKING_STEADINESS`, `AUDIO_EXPOSURE_EVENT` | R-F T1.4 + T1.5 | XS |
| R-A Option A server-side: `dailyStatsExternalId()` helper + `CUMULATIVE_HK_TYPES` set in `src/lib/measurements/apple-health-mapping.ts`; handoff-doc lock for the `externalId` shape | R-A §6 | S |
| APNs `.p8` paste into Coolify (1-hour Marc-action) | R-E C-3 | external |
| Real-Postgres integration-test container (paid down once, every aggregate test rides it) | R-B Critical+ | S |
**Cutover sequence**:
1. v1.4.30 ships with the helper + drain script + server-tolerance for both shapes.
2. iOS-engineer ships a new TestFlight build with `HealthKitStatisticsService.swift` per R-A §5 — `HKStatisticsCollectionQuery` for every cumulative type, one PR-per-day-per-type POST via the existing batch endpoint with the new `externalId` shape.
3. Operator runs the drain script once. Existing per-sample APPLE_HEALTH cumulative rows collapse into one row per day per type.
4. From that point: every cumulative type carries one daily row per user. Per-sample row pressure on `Measurement` drops ~50-200× for these types.
iOS impact: every change is additive on the wire. The
`/api/sync/state` endpoint is net-new (no iOS consumer yet),
SyncMode columns expand the `Measurement` shape with defaults so
@@ -135,9 +148,22 @@ existing iOS calls round-trip unchanged, the new MeasurementType
enums are net-new values iOS can encounter without 422 once the
codegen catches them.
### v1.4.31 — HealthKit Tier 1 web surfaces, wave A
### v1.4.31 — Operator toggles + Insights blocking + Coolify auto-deploy
Ship 2-3 days after v1.4.30. Adds the visible surfaces iOS will pair
Ship 2-3 days after v1.4.30. UX-correction patch that closes the
maintainer's daily-walk-through findings from the v1.4.29 window plus
an ops-side fix that's been silently failing for three releases in a
row.
| Item | Source | Effort |
|---|---|---|
| **Assistant-optional operator toggles** — 6 boolean columns on `AppSettings` (master `assistant.enabled` + 5 sub-flags), `/admin/system/assistant` panel, `GET /api/feature-flags` endpoint iOS queries on startup, gated `/api/insights/chat` + `/api/insights/generate` + `/api/insights/comprehensive` + `/api/insights/briefing` return 403 with `errorCode: "assistant.disabled.<surface>"` when the relevant flag is off, web mounts gate on `useFeatureFlags()` hook, fallbacks render naturally without empty boxes | `.planning/research/v15-assistant-optional.md` | M (~14h) |
| **Insights tab-strip blocking on mobile** — three small client fixes per the root-cause analysis: AbortController + 8 s timeout on `fetchAdvisor`, `React.memo` + `useMemo` on the tab strip + availability check, `next/dynamic` for `<CoachDrawer>` so the SSE machinery loads lazily | `.planning/research/v15-insights-blocking-bug.md` | S |
| **Coolify auto-deploy investigation** — three releases in a row (v1.4.27, v1.4.28, v1.4.29.1) the Coolify webhook reported "finished" but did not pull the new `:latest` digest. SSH host-side retag is the documented fallback but the gap costs minutes per release. Investigate: is the webhook receiving but not triggering pull? Coolify image-cache stale? Token expired? Webhook URL stale? Document the root cause + fix in `.planning/round-coolify-auto-deploy-fix.md` and patch the workflow / Coolify settings so the next release auto-deploys without manual SSH | this plan, maintainer note | S-M |
### v1.4.32 — HealthKit Tier 1 web surfaces, wave A
Ship 2-3 days after v1.4.31. Adds the visible surfaces iOS will pair
with the corresponding HK reads.
| Item | Source | Effort |
@@ -145,9 +171,9 @@ with the corresponding HK reads.
| Workouts end-to-end on the web — list page (`/insights/workouts`), detail page, dashboard "Recent workouts" tile | R-F T1.1 | M |
| Chart cards for 5 of the 10 invisible-but-stored metrics: HRV, RestingHR, SpO2, BodyTemperature, ActiveEnergyBurned | R-F T1.3 | M |
### v1.4.32 — HealthKit Tier 1 web surfaces, wave B + freeze marker
### v1.4.33 — HealthKit Tier 1 web surfaces, wave B + freeze marker
Ship 2-3 days after v1.4.31. Closes the breadth wave.
Ship 2-3 days after v1.4.32. Closes the breadth wave.
| Item | Source | Effort |
|---|---|---|
@@ -158,7 +184,7 @@ Ship 2-3 days after v1.4.31. Closes the breadth wave.
| Source-priority editor — the `/api/auth/me/source-priority` endpoint already locks for iOS in v1.4.25 W8c; the web has nothing today. Add the two-axis editor under `/settings/sources` so the iOS side can mirror the same UI shape | R-E C-5 (web-side helper) | M |
| CHANGELOG **WEB-FREEZE marker** — explicit line noting that web functionality is complete for v1.5 and that subsequent v1.4.x tags are limited to hotfixes + dependency updates until the iOS app ships | this plan | — |
After v1.4.32 tags on `main`, the web enters **freeze**.
After v1.4.33 tags on `main`, the web enters **freeze**.
### Web-freeze posture
@@ -213,7 +239,7 @@ patches + iOS tracks take in parallel.
| 1-2 | Server | R-A Option A server-side: `dailyStatsExternalId()` helper + `CUMULATIVE_HK_TYPES` set in `src/lib/measurements/apple-health-mapping.ts`. Handoff doc updates: `.planning/v15-ios-handoff/06-ios-responsibilities.md` + `08-locked-contracts.md` lock the `externalId` shape `"stats:<typeIdentifier>:<YYYY-MM-DD>"`. | Server stops paying for per-sample step rows once iOS cuts over. |
| 3-5 | iOS Track A | Coach SSE drawer native build per R-E C-1. `CoachService` actor over `URLSession.bytes`, `CoachStreamEvent` AsyncThrowingStream, SwiftData-backed `CoachConversation` cache, streaming bubble view, provenance disclosure, GROUND-RULE-9/15 refusal-acceptance UI, `coach.budget.exceeded` 429 surface. Reuse the existing `MDRAcknowledgmentDialog`. Adds `coach.*` locale keys to `Localizable.xcstrings`. Wires `AppRouter` to a real `.coach` TabIdentifier. | TestFlight build runs Coach against staging end-to-end. |
| 3-5 | iOS Track B | SyncMode + Workouts ingest. Per R-E C-2: extend `@Model` types with `syncVersion + deletedAt`, gate every repository call behind `SyncModeStore.isPaired`, build the pair/unpair sheet. Per R-F T1.1 + R-E H-2: `HKWorkoutType.workoutType()` reader, `HKWorkoutRouteQuery` route streamer, `WorkoutBatchEntryDTO` mirror, `WorkoutsRepository`, dashboard "Recent workouts" tile. | Workouts flow from HK to dashboard; SyncMode honours pairing state across every repo path. |
| 3-5 | iOS Track C | R-A Option A iOS implementation per `HealthKitStatisticsService.swift` sketch. `HKStatisticsCollectionQuery` wrapper per cumulative type, per-day last-posted-value cache, PATCH-on-divergence path for late watch syncs, `ENABLE_DAILY_STATS` build flag (default OFF for first TestFlight, ON for cut-over build). | Steps + active energy + flights + distance + daylight ingest as one row per day per type; per-sample row pressure on `Measurement` drops 50-200× for those types. |
| coordinated with v1.4.30 | iOS Track C | **MOVED OUT of the v1.5 sprint per maintainer directive 2026-05-16.** R-A Option A iOS implementation rides v1.4.30: as soon as v1.4.30 ships the server-side `dailyStatsExternalId()` helper + drain script, the iOS TestFlight build adopts `HealthKitStatisticsService.swift` with `HKStatisticsCollectionQuery` per cumulative type. Operator runs the drain script once after the new TestFlight cut-over. Per-sample row pressure on `Measurement` drops 50-200× for cumulative types. | Daily-stats ingest live before the v1.5 sprint clock starts. |
| 6 | iOS | R-E C-4 RefreshScheduler (proactive bearer refresh 5 min before expiry) + R-E C-5 source-priority editor (Settings → Sources & Geräte sheet, drag-reorder per metric, nested device-type picker, full-object PUT) + R-E H-7 Withings `hasActivityScope` reconnect banner. | Midnight-refresh 401 closed for real; iOS users can edit the two-axis source priority natively. |
| 6-7 | iOS | R-F T1.3 breadth wave: 10 chart cards for invisible-but-stored metrics — `restingHr`, `hrv`, `spo2`, `bodyTemperature`, `activeEnergy`, `flights`, `distance`, `audioExposureEnv`, `audioExposureHeadphone`, `daylight`. Each is a one-line `CHART_OVERLAY_KEYS` add server-side + a SwiftUI card mount on iOS. Pair every HK read with a visible surface per the Apple-review pairing rule. | iOS dashboard finally shows what it has been reading; App Store reviewer sees one visible surface per authorised type. |
| 7 | iOS | R-F T1.2 HKStateOfMind read path (iOS 18+) + R-F T1.4 hearing-event chips on `/insights/puls` + R-F T1.5 walking-steadiness gauge. | Mood bidirectional with Apple Health; loud-listening events surfaced; mobility signal visible. |
@@ -341,10 +367,11 @@ navigates to a feature that needs it (R-F §2.5 option 2).
| iOS HK reads pairing rule | Every read paired with a visible surface in the same track — no authorised-but-invisible | R-F §2.5 + open Q #3 |
| Categorisation shape | UI-side TypeScript overlay `src/lib/measurements/categories.ts`, NOT a DB column | R-F §4 |
| Sleep storage shape | Per-stage rows (5-axis unique key) — no split into `SLEEP_DEEP` / `SLEEP_REM` / etc. enum entries | R-F open Q #6 |
| Web freeze trigger | v1.4.32 tag on `main` (after the Tier 1 wave B closes) | This plan §2 |
| v1.5 release shape | Version-bump-only marker the day after iOS clears Apple review. All functional work lands incrementally in v1.4.29 → v1.4.32. | Maintainer directive 2026-05-16 |
| Web patch sequence | v1.4.29 polish → v1.4.30 iOS server-prep → v1.4.31 Tier 1 wave A → v1.4.32 Tier 1 wave B + freeze | This plan §2 |
| AVG/SUM cumulative-type fix | Rides v1.4.29 | R-A §6 + R-B C2 sequence |
| Web freeze trigger | v1.4.33 tag on `main` (after the Tier 1 wave B closes) | This plan §2 |
| v1.5 release shape | Version-bump-only marker the day after iOS clears Apple review. All functional work lands incrementally in v1.4.29 → v1.4.33. | Maintainer directive 2026-05-16 |
| Web patch sequence | v1.4.29 polish → v1.4.29.1 step hotfix → v1.4.30 iOS-coordinated foundation (Daily-Stats + SyncMode) → v1.4.31 toggles + insights blocking + Coolify auto-deploy → v1.4.32 Tier 1 wave A → v1.4.33 Tier 1 wave B + freeze | This plan §2, maintainer directive 2026-05-16 |
| R-A Option A iOS daily-stats | Rides v1.4.30 (server-side helper + drain script + handoff-doc lock) coordinated with iOS TestFlight cutover — NOT a v1.5 sprint item | Maintainer directive 2026-05-16 |
| AVG/SUM cumulative-type fix | Server-side rode v1.4.29; client-side daily aggregator rode v1.4.29.1 | R-A §6 + R-B C2 sequence + maintainer report |
| C2 P0 sequencing | Lands before any client wires `aggregate=daily` | R-B Critical |
| iOS workout dedup ladder | Ship `pickCanonicalWorkoutRows()` with the existing measurement ladder (Apple ≻ Withings ≻ Manual); tune metric-aware (route → Apple wins; HR zones → Withings wins) in a v1.5.x follow-up | R-F open Q #4 |
| HKStateOfMind round-trip filter | Bake the `HKMetadataKeyExternalUUID` write-back filter into the read path from day one — same pattern as the existing quantity-sample filter | R-F open Q #5 |
+106
View File
@@ -1,5 +1,111 @@
# Changelog
## [1.4.30] — 2026-05-16 — iOS-coordinated foundation (Daily-Stats + SyncMode)
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 `tags: ["note:<text>"]` workaround, a
cross-source workout dedup helper, and two new MeasurementType
enums (`WALKING_STEADINESS`, `AUDIO_EXPOSURE_EVENT`) plus the
shared `MEASUREMENT_CATEGORIES` overlay that drives the iOS
permission picker and the future Insights nav.
### Added
- **Daily-stats `externalId` helper.** `dailyStatsExternalId(hkId,
date)` mints `"stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>"`
alongside the v1.4.29 `CUMULATIVE_HK_TYPES` set. The shape is
locked in `.planning/v15-ios-handoff/08-locked-contracts.md` §12
and `.planning/v15-ios-handoff/06-ios-responsibilities.md`. iOS
emits one row per day per cumulative type via
`HKStatisticsCollectionQuery`; 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.
- **Drain script + admin endpoint** at
`scripts/drain-per-sample-cumulative.ts` and
`POST /api/admin/drain-per-sample-cumulative` (gated by
`requireAdmin()`, default `dryRun: true`). Collapses pre-Option-A
per-sample APPLE_HEALTH cumulative rows into one row per day per
type. Idempotent — re-running on a fully-collapsed account
reports zero buckets touched.
- **SyncMode foundation.** Migration 0062 adds
`Measurement.sync_version` (Int, default 1),
`Measurement.deleted_at` (Timestamp, nullable) for soft-deletes,
and `User.last_synced_at` (Timestamp, nullable). The new
`GET /api/sync/state` returns the handshake response (lastSyncedAt,
server clock, live + tombstoned counters); the call also bumps the
checkpoint so iOS reads the OLD value then trusts subsequent
writes via the standard read paths.
- **Bulk backfill endpoints.** `POST /api/mood-entries/bulk` and
`POST /api/medications/intake/bulk` accept up to 500 entries per
call with the same response envelope as the measurements + workouts
batch. Probe-then-upsert distinguishes inserted vs duplicate on the
mood path; idempotency-key collision yields duplicate on the
intake path. Both rate-limited at 60/min/user.
- **`MoodEntry.note` column** (migration 0063). The bulk + single
POST + PUT routes thread the new field through; the existing
`tags: ["note:<text>"]` workaround backfills via
`scripts/backfill-mood-note-column.ts` (CLI dry-run by default,
`--confirm` commits).
- **`pickCanonicalWorkoutRows()`** at
`src/lib/measurements/pick-canonical-workout-rows.ts`. Cross-source
workout dedup symmetric to `pickCanonicalSourceRows()`. Buckets
rows by 5-minute startedAt slot + sportType and walks the
existing measurement source ladder; metric-aware tunes (route →
Apple wins, HR zones → Withings wins) defer to v1.5.x.
- **`MEASUREMENT_CATEGORIES` overlay** at
`src/lib/measurements/categories.ts`. UI-only category map
(vitals / body / activity / sleep / hearing / environment /
cardiovascular / metabolic) that drives the iOS HealthKit
permission picker, the post-v1.5 web Insights nav, and the Coach
evidence shelf chip-grouping. Completeness wall in the test suite
catches a new MeasurementType lacking a category assignment.
- **Two MeasurementType enums** (migration 0064) —
`WALKING_STEADINESS` (iOS 15+ Mobility daily rollup, ×100 scaled
from Apple's 0..1 fraction) and `AUDIO_EXPOSURE_EVENT` (iOS 13+
category flag fired when the rolling 7-day average crosses the
WHO 80-dBA threshold; environmental + headphone events share the
same enum value). The wiring registries (`apple-health-mapping`,
`categories`, `pr-direction`, `chart-tokens`, six locale files)
pick them up in the same release.
### Changed
- **Real-Postgres integration coverage expanded.** Every new
endpoint introduced in v1.4.30 rides the v1.4.29 testcontainer
fixture: the drain helper, the admin endpoint, the sync-state
handshake, the mood-entries bulk upsert, the medication-intake
bulk insert + idempotency-key collision, the mood-note column
round-trip. Full integration suite at 47 files / 190 specs.
- **`HK_QUANTITY_TYPE_DEFERRED` trimmed.** Three identifiers move
out of the deferred set into the mapping table:
`HKQuantityTypeIdentifierAppleWalkingSteadiness`,
`HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent`,
`HKCategoryTypeIdentifierHeadphoneAudioExposureEvent`.
### iOS contract
Every API change is additive. The new endpoints
(`/api/sync/state`, `/api/mood-entries/bulk`,
`/api/medications/intake/bulk`,
`/api/admin/drain-per-sample-cumulative`) are net-new — no iOS
consumer yet. The SyncMode columns carry defaults so existing iOS
POSTs round-trip unchanged. The two new MeasurementType enums are
net-new values; iOS clients that predate the codegen pass will not
encounter them in read paths because no source writes them yet, and
the codegen path will pick them up on the next iOS regeneration.
**Cutover sequence.** v1.4.30 ships with the helper + drain script
+ server tolerance for both shapes. The next iOS TestFlight build
adopts `HealthKitStatisticsService.swift` and starts posting daily-
aggregated rows for the five cumulative types. Operator runs the
drain script once after the new TestFlight cuts over; per-sample
row pressure on `Measurement` drops 50-200× for cumulative types.
## [1.4.29.1] — 2026-05-16 — Daily-step aggregation hotfix
A one-line follow-up to v1.4.29. The dashboard 7-day step chart now
+6
View File
@@ -899,6 +899,8 @@ components:
- AUDIO_EXPOSURE_ENV
- AUDIO_EXPOSURE_HEADPHONE
- TIME_IN_DAYLIGHT
- WALKING_STEADINESS
- AUDIO_EXPOSURE_EVENT
type: string
unit:
type: string
@@ -2179,6 +2181,8 @@ paths:
- AUDIO_EXPOSURE_ENV
- AUDIO_EXPOSURE_HEADPHONE
- TIME_IN_DAYLIGHT
- WALKING_STEADINESS
- AUDIO_EXPOSURE_EVENT
type: string
- in: query
name: from
@@ -2311,6 +2315,8 @@ paths:
- AUDIO_EXPOSURE_ENV
- AUDIO_EXPOSURE_HEADPHONE
- TIME_IN_DAYLIGHT
- WALKING_STEADINESS
- AUDIO_EXPOSURE_EVENT
type: string
value:
type: number
+2
View File
@@ -299,6 +299,8 @@
"typeAudioExposureEnv": "Lärmbelastung (Umgebung)",
"typeAudioExposureHeadphone": "Lärmbelastung (Kopfhörer)",
"typeTimeInDaylight": "Zeit im Tageslicht",
"typeWalkingSteadiness": "Gehstabilität",
"typeAudioExposureEvent": "Lautes-Hören-Ereignis",
"glucoseContext": "Kontext",
"glucoseContextFasting": "Nüchtern (≥8 h ohne Nahrung)",
"glucoseContextPostprandial": "Postprandial (2 h nach dem Essen)",
+2
View File
@@ -299,6 +299,8 @@
"typeAudioExposureEnv": "Audio Exposure (Environment)",
"typeAudioExposureHeadphone": "Audio Exposure (Headphones)",
"typeTimeInDaylight": "Time in Daylight",
"typeWalkingSteadiness": "Walking Steadiness",
"typeAudioExposureEvent": "Loud-Listening Event",
"glucoseContext": "Context",
"glucoseContextFasting": "Fasting (≥8h without food)",
"glucoseContextPostprandial": "Post-meal (2h after eating)",
+2
View File
@@ -299,6 +299,8 @@
"typeAudioExposureEnv": "Audio Exposure (Environment)",
"typeAudioExposureHeadphone": "Audio Exposure (Headphones)",
"typeTimeInDaylight": "Time in Daylight",
"typeWalkingSteadiness": "Walking Steadiness",
"typeAudioExposureEvent": "Loud-Listening Event",
"glucoseContext": "Context",
"glucoseContextFasting": "Fasting (≥8h without food)",
"glucoseContextPostprandial": "Post-meal (2h after eating)",
+2
View File
@@ -299,6 +299,8 @@
"typeAudioExposureEnv": "Audio Exposure (Environment)",
"typeAudioExposureHeadphone": "Audio Exposure (Headphones)",
"typeTimeInDaylight": "Time in Daylight",
"typeWalkingSteadiness": "Walking Steadiness",
"typeAudioExposureEvent": "Loud-Listening Event",
"glucoseContext": "Context",
"glucoseContextFasting": "Fasting (≥8h without food)",
"glucoseContextPostprandial": "Post-meal (2h after eating)",
+2
View File
@@ -299,6 +299,8 @@
"typeAudioExposureEnv": "Audio Exposure (Environment)",
"typeAudioExposureHeadphone": "Audio Exposure (Headphones)",
"typeTimeInDaylight": "Time in Daylight",
"typeWalkingSteadiness": "Walking Steadiness",
"typeAudioExposureEvent": "Loud-Listening Event",
"glucoseContext": "Context",
"glucoseContextFasting": "Fasting (≥8h without food)",
"glucoseContextPostprandial": "Post-meal (2h after eating)",
+2
View File
@@ -299,6 +299,8 @@
"typeAudioExposureEnv": "Audio Exposure (Environment)",
"typeAudioExposureHeadphone": "Audio Exposure (Headphones)",
"typeTimeInDaylight": "Time in Daylight",
"typeWalkingSteadiness": "Walking Steadiness",
"typeAudioExposureEvent": "Loud-Listening Event",
"glucoseContext": "Context",
"glucoseContextFasting": "Fasting (≥8h without food)",
"glucoseContextPostprandial": "Post-meal (2h after eating)",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.4.29.1",
"version": "1.4.30",
"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,31 @@
-- v1.4.30 — iOS SyncMode foundation (R-E C-2)
--
-- Three additive columns the iOS app reads when it pairs with the
-- server:
--
-- measurements.sync_version — monotonic version counter per row.
-- Default = 1 for legacy rows; the
-- server bumps it on every update so
-- paired clients can do last-writer-
-- wins reconciliation.
-- measurements.deleted_at — soft-delete timestamp. The
-- by-external-ids DELETE path flips
-- this rather than removing rows so
-- paired iOS clients can reconcile
-- deletions without losing the audit
-- trail.
-- users.last_synced_at — last-known synchronization checkpoint
-- the iOS SyncMode store reads via
-- `GET /api/sync/state`.
--
-- All three are nullable / defaulted so existing rows + existing iOS
-- POSTs round-trip unchanged. The `IF NOT EXISTS` guards keep the
-- migration idempotent against partial historical runs (same posture
-- as 0058_user_research_mode and 0061_audit_log_carrier).
ALTER TABLE "measurements"
ADD COLUMN IF NOT EXISTS "sync_version" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMP(3);
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "last_synced_at" TIMESTAMP(3);
@@ -0,0 +1,16 @@
-- v1.4.30 — MoodEntry.note column (R-E H-5)
--
-- Replaces the `tags: ["note:<text>"]` workaround the iOS app
-- shipped through v0.4.1.1. The prior shape contaminated the tags
-- axis with prose that the Coach evidence shelf misread as
-- taxonomic labels. The new column is a first-class free-text
-- field; the bulk-backfill migration script (run separately by the
-- operator) pulls existing `note:...` tag entries into this column.
--
-- Additive-only — the column is nullable, no existing row needs
-- updating for the schema migration itself to apply cleanly. The
-- `IF NOT EXISTS` guard keeps this idempotent against partial
-- historical runs (same posture as 0058 and 0061).
ALTER TABLE "mood_entries"
ADD COLUMN IF NOT EXISTS "note" TEXT;
@@ -0,0 +1,15 @@
-- v1.4.30 — extend `measurement_type` enum (R-F T1.4 + T1.5)
--
-- Two additive enum values for Tier-1 HealthKit surfaces:
-- WALKING_STEADINESS — % daily rollup, mobility-risk signal
-- AUDIO_EXPOSURE_EVENT — count flag for loud-listening events
--
-- Pure additive — no existing row carries either value. Postgres
-- enum extensions run with `IF NOT EXISTS` so the migration is
-- idempotent and runs in a single transaction (the v9.6+ requirement
-- that enum adds must commit before they can be referenced no longer
-- applies because the additive Zod + map updates ship in the same
-- release).
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WALKING_STEADINESS';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'AUDIO_EXPOSURE_EVENT';
+34
View File
@@ -181,6 +181,14 @@ model User {
researchModeAcknowledgedAt DateTime? @map("research_mode_acknowledged_at")
researchModeAcknowledgedVersion String? @map("research_mode_acknowledged_version")
// v1.4.30 — iOS SyncMode foundation (R-E C-2). Last-known
// synchronization checkpoint per user. iOS reads it via
// `GET /api/sync/state` to decide whether to drain its local
// SwiftData backlog before opening a fresh anchored-query window.
// NULL = never synced; the SyncMode store treats that as "force
// a full refresh on the next pair".
lastSyncedAt DateTime? @map("last_synced_at")
// v1.4.25 W6c — Doctor-report section toggles.
// Shape:
// { "bp": boolean,
@@ -321,6 +329,9 @@ enum MeasurementType {
AUDIO_EXPOSURE_ENV // dBA — HealthKit `environmentalAudioExposure`; ambient noise sample (e.g. concert, traffic). Watch + iPhone microphone.
AUDIO_EXPOSURE_HEADPHONE // dBA — HealthKit `headphoneAudioExposure`; AirPods listening-volume sampling.
TIME_IN_DAYLIGHT // minutes — HealthKit `timeInDaylight` (iOS 17+). Sun-exposure daily rollup; mood + sleep correlate.
// ── v1.4.30 R-F T1.4 + T1.5 Tier-1 additions ──
WALKING_STEADINESS // percent (0-100, scaled ×100 from Apple's 0..1 fraction) — HealthKit `appleWalkingSteadiness` (iOS 15+). Daily rollup. Falls + mobility-risk signal Apple's own Health app shows prominently in the Mobility section.
AUDIO_EXPOSURE_EVENT // count (event flag: 1 per fired loud-listening event) — HealthKit `environmentalAudioExposureEvent` + `headphoneAudioExposureEvent` category-events. Apple fires these when the rolling 7-day average crosses the WHO 80-dBA threshold. The notes field carries the source identifier (env vs headphone).
@@map("measurement_type")
}
@@ -396,6 +407,21 @@ model Measurement {
// include `deviceType` so two ingest paths from the same source
// still dedup on (type, source, externalId).
deviceType String? @map("device_type")
// v1.4.30 — iOS SyncMode foundation (R-E C-2). Monotonically
// incremented on every server-side mutation of the row. iOS uses
// it for last-writer-wins reconciliation when two devices write
// the same logical reading. Default = 1 for the create path; the
// update path is responsible for bumping it. Existing rows
// start at the default after the additive migration so legacy
// ingest keeps round-tripping unchanged.
syncVersion Int @default(1) @map("sync_version")
// v1.4.30 — soft-delete column. The DELETE path on the
// by-external-ids endpoint flips this rather than removing the row
// so paired iOS clients can reconcile deletions without losing the
// audit trail. NULL = live row; non-NULL = tombstoned at the
// recorded instant. The unique-by-externalId index keeps both
// states distinguishable for the bulk-backfill UPSERT path.
deletedAt DateTime? @map("deleted_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -1053,6 +1079,14 @@ model MoodEntry {
mood String // SUPER_GUT, GUT, OKAY, SCHLECHT, LAUSIG
score Int // 1-5
tags String? // JSON array of tag keys
/// v1.4.30 — first-class free-text note. Replaces the
/// `tags: ["note:<text>"]` workaround the iOS app shipped through
/// v0.4.1.1 (per R-E H-5); the prior pattern contaminated the
/// tags axis with prose the Coach evidence shelf then read as
/// taxonomic labels. Nullable for the additive-only migration
/// path; the bulk-backfill endpoint pulls existing `note:...` tag
/// rows into this column via the migration script.
note String? @db.Text
source String @default("MOODLOG") // WEB, TELEGRAM, DAYLIO
moodLoggedAt DateTime @map("mood_logged_at")
/// v1.4.25 W7b — per-row IANA timezone that the `date` string is
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env tsx
/**
* scripts/backfill-mood-note-column.ts
*
* One-shot backfill that lifts existing `tags: ["note:<text>"]`
* entries out of `mood_entries.tags` into the new `mood_entries.note`
* column. Operator runs this once after the v1.4.30 schema migration
* applies.
*
* Strategy:
* 1. SELECT every MoodEntry whose `tags` JSON contains an entry
* that begins with `note:`.
* 2. Decode the JSON array, pull the first `note:<text>` entry as
* the new note value, drop the entry from the array.
* 3. UPDATE the row in a transaction: `note = <decoded text>`,
* `tags = <slimmed JSON or null if empty>`.
*
* Idempotent — a row whose `tags` no longer carries any `note:`
* entries is left alone. Re-running on a clean dataset is a no-op.
*
* Usage:
* pnpm tsx scripts/backfill-mood-note-column.ts # dry-run
* pnpm tsx scripts/backfill-mood-note-column.ts --confirm # commit
*/
import "dotenv/config";
import { prisma } from "@/lib/db";
import { extractNoteAndSlimTags } from "@/lib/mood/note-backfill";
interface ParsedArgs {
confirm: boolean;
}
function parseArgs(argv: readonly string[]): ParsedArgs {
const out: ParsedArgs = { confirm: false };
for (const arg of argv) {
if (arg === "--confirm") out.confirm = true;
else if (arg === "--help" || arg === "-h") {
console.log(
"Usage: pnpm tsx scripts/backfill-mood-note-column.ts [--confirm]",
);
process.exit(0);
} else {
console.error(`Unknown argument: ${arg}`);
process.exit(2);
}
}
return out;
}
interface RowSnapshot {
id: string;
tags: string | null;
note: string | null;
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const dryRun = !args.confirm;
if (dryRun) {
console.log("[backfill-note] DRY RUN — no rows will be written. Pass --confirm to commit.");
} else {
console.log("[backfill-note] COMMIT MODE — updating rows in place.");
}
// Naive scan: filter in-memory rather than via JSON predicates so
// the query stays portable across Postgres minor versions. The
// mood_entries table is small (per-user lifetime budget).
const candidates: RowSnapshot[] = await prisma.moodEntry.findMany({
where: {
tags: { not: null },
note: null,
},
select: { id: true, tags: true, note: true },
});
console.log(`[backfill-note] scanned ${candidates.length} candidate rows`);
let updated = 0;
for (const row of candidates) {
if (!row.tags) continue;
const { note, newTags } = extractNoteAndSlimTags(row.tags);
if (note === null) continue;
updated += 1;
if (!dryRun) {
await prisma.moodEntry.update({
where: { id: row.id },
data: { note, tags: newTags },
});
}
}
console.log(`[backfill-note] ${updated} row${updated === 1 ? "" : "s"} ${dryRun ? "would be" : "were"} updated`);
await prisma.$disconnect();
}
main().catch((err: unknown) => {
console.error("[backfill-note] failed:", err);
process.exit(1);
});
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env tsx
/**
* scripts/drain-per-sample-cumulative.ts
*
* One-shot maintenance tool that collapses pre-Option-A per-sample
* APPLE_HEALTH cumulative rows into one row per day per cumulative
* type. See R-A §6 / v1.4.30 §"Cutover sequence".
*
* Scope per `CUMULATIVE_HK_TYPES`:
* ACTIVITY_STEPS, ACTIVE_ENERGY_BURNED, FLIGHTS_CLIMBED,
* WALKING_RUNNING_DISTANCE, TIME_IN_DAYLIGHT.
*
* Idempotent — re-running after a successful drain is a no-op.
*
* Usage:
* # Dry-run for one user (default; no DB writes):
* pnpm tsx scripts/drain-per-sample-cumulative.ts --user clx123
*
* # Commit the drain for one user:
* pnpm tsx scripts/drain-per-sample-cumulative.ts --user clx123 --confirm
*
* # Dry-run for every user:
* pnpm tsx scripts/drain-per-sample-cumulative.ts --all
*
* # Commit the drain for every user (requires --confirm):
* pnpm tsx scripts/drain-per-sample-cumulative.ts --all --confirm
*
* Operator runs this once after the iOS TestFlight build adopts
* `HealthKitStatisticsService.swift`. The drain runs idempotently; a
* re-run reports zero buckets collapsed.
*/
import "dotenv/config";
import { prisma } from "@/lib/db";
import { drainPerSampleCumulative } from "@/lib/measurements/drain-per-sample-cumulative";
interface ParsedArgs {
userId?: string;
all: boolean;
confirm: boolean;
}
function parseArgs(argv: readonly string[]): ParsedArgs {
const out: ParsedArgs = { all: false, confirm: false };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--user") {
out.userId = argv[++i];
} else if (arg === "--all") {
out.all = true;
} else if (arg === "--confirm") {
out.confirm = true;
} else if (arg === "--help" || arg === "-h") {
console.log(
"Usage: pnpm tsx scripts/drain-per-sample-cumulative.ts [--user <id> | --all] [--confirm]",
);
process.exit(0);
} else {
console.error(`Unknown argument: ${arg}`);
process.exit(2);
}
}
return out;
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
if (!args.userId && !args.all) {
console.error(
"Pick a scope: --user <id> (single user) or --all (every user). Default mode is dry-run; pass --confirm to commit.",
);
process.exit(2);
}
if (args.userId && args.all) {
console.error("--user and --all are mutually exclusive.");
process.exit(2);
}
const dryRun = !args.confirm;
if (dryRun) {
console.log(
"[drain] DRY RUN — no rows will be written. Pass --confirm to commit.",
);
} else {
console.log("[drain] COMMIT MODE — collapsing rows in place.");
}
try {
const summary = await drainPerSampleCumulative(prisma, {
userId: args.userId,
dryRun,
});
console.log("[drain] summary:");
console.log(JSON.stringify(summary.totals, null, 2));
if (summary.buckets.length > 0) {
console.log(
`[drain] first ${Math.min(5, summary.buckets.length)} buckets (of ${summary.buckets.length}):`,
);
for (const b of summary.buckets.slice(0, 5)) {
console.log(
` - user=${b.userId} type=${b.type} day=${b.dateKey} samples=${b.perSampleCount} sum=${b.sumValue} extId=${b.externalId}`,
);
}
} else {
console.log("[drain] no buckets needed collapsing — already at parity.");
}
} finally {
await prisma.$disconnect();
}
}
main().catch((err: unknown) => {
console.error("[drain] failed:", err);
process.exit(1);
});
@@ -0,0 +1,93 @@
/**
* `POST /api/admin/drain-per-sample-cumulative` — operator-triggered
* variant of `scripts/drain-per-sample-cumulative.ts`. Collapses
* pre-Option-A per-sample APPLE_HEALTH cumulative rows into one row
* per day per cumulative type. See `06-ios-responsibilities.md`
* §"Cumulative metrics: daily aggregation on iOS".
*
* Body:
* { userId?: string, dryRun?: boolean }
*
* - `userId` — single user mode. Omit to drain every user.
* - `dryRun` — preview-only; no DB writes. Default: `true`. Set to
* `false` to commit.
*
* Response shape (always 200 on success):
* { processed, inserted (always 0), totals: { ... }, buckets: [...] }
*
* Admin gate via `requireAdmin()` (cookie-only — Bearer never elevates
* to admin per the v1.4.25 security boundary).
*/
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { drainPerSampleCumulative } from "@/lib/measurements/drain-per-sample-cumulative";
interface DrainBody {
userId?: string;
dryRun?: boolean;
}
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.drain.cumulative" } });
const { data: rawBody, error: jsonError } = await safeJson<DrainBody>(request);
if (jsonError) return jsonError;
const body: DrainBody = (rawBody && typeof rawBody === "object") ? rawBody : {};
const userId =
typeof body.userId === "string" && body.userId.length > 0
? body.userId
: undefined;
// Default to dryRun = true so a malformed body never silently writes.
// Operator must explicitly send `dryRun: false` to commit.
const dryRun = body.dryRun === false ? false : true;
await auditLog("admin.drain.cumulative.start", {
userId: user.id,
ipAddress: getClientIp(request),
details: { targetUserId: userId ?? "ALL", dryRun },
});
const lines: string[] = [];
const summary = await drainPerSampleCumulative(prisma, {
userId,
dryRun,
log: (line) => lines.push(line),
});
if (!dryRun) {
await auditLog("admin.drain.cumulative.commit", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
targetUserId: userId ?? "ALL",
...summary.totals,
},
});
}
if (summary.totals.bucketsCollapsed === 0 && summary.totals.usersScanned === 0 && userId) {
return apiError("User not found", 404);
}
return apiSuccess({
dryRun,
totals: summary.totals,
bucketCount: summary.buckets.length,
// Truncate to the first 50 buckets so the response stays
// bounded — operators inspect the CLI for the full output.
bucketsPreview: summary.buckets.slice(0, 50),
logTail: lines.slice(-20),
});
});
@@ -0,0 +1,215 @@
/**
* `POST /api/medications/intake/bulk` — iOS SyncMode bulk backfill
* for medication intake events.
*
* Mirrors the mood-entries bulk endpoint shape so the iOS sync
* engine reuses the same retry / cursor plumbing for both.
*
* Body:
* { entries: BulkIntakeEntry[] } — capped at 500 per call.
*
* Each entry:
* {
* medicationId, — required (existing Medication row)
* scheduledFor, — ISO timestamp (offset); defaults to now()
* takenAt?, — ISO timestamp; omit + skipped=false = pending
* skipped?, — boolean; default false
* idempotencyKey?, — Medication.intake_events.idempotency_key
* (existing UNIQUE column; serves as the
* per-entry dedup hint)
* }
*
* Response (always 200): processed / inserted / duplicates / skipped /
* entries[] mirroring the measurements + workouts batch envelope.
*
* Locked contract — see `.planning/v15-ios-handoff/06-ios-responsibilities.md`
* §"Cumulative metrics" sibling section for the SyncMode rationale.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
const MAX_ENTRIES_PER_BATCH = 500;
const BATCH_RATE_LIMIT_MAX = 60;
const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
const bulkEntrySchema = z.object({
medicationId: z.string().min(1),
scheduledFor: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
takenAt: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
skipped: z.boolean().optional().default(false),
idempotencyKey: z.string().min(1).max(128).optional(),
});
const bulkPayloadSchema = z.object({
entries: z.array(bulkEntrySchema).min(1).max(MAX_ENTRIES_PER_BATCH),
});
type EntryStatus = "inserted" | "duplicate" | "skipped";
interface EntryResult {
index: number;
status: EntryStatus;
reason?: string;
id?: string;
}
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk));
async function postBulk(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`medications:intake:bulk:${user.id}`,
BATCH_RATE_LIMIT_MAX,
BATCH_RATE_LIMIT_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many bulk submissions, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
if (
typeof rawBody === "object" &&
rawBody !== null &&
"entries" in rawBody &&
Array.isArray((rawBody as { entries: unknown }).entries) &&
(rawBody as { entries: unknown[] }).entries.length > MAX_ENTRIES_PER_BATCH
) {
return apiError(
`Batch exceeds the ${MAX_ENTRIES_PER_BATCH}-entry limit`,
422,
{ errorCode: "medications.intake.bulk.too_large" },
);
}
const parsed = bulkPayloadSchema.safeParse(rawBody);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422, {
errorCode: "medications.intake.bulk.invalid",
});
}
const { entries } = parsed.data;
// Up-front ownership check — the user can only POST intake events
// against medications they own. One read covers every medicationId
// in the batch.
const medicationIds = Array.from(new Set(entries.map((e) => e.medicationId)));
const ownedMedications = await prisma.medication.findMany({
where: { id: { in: medicationIds }, userId: user.id },
select: { id: true },
});
const ownedSet = new Set(ownedMedications.map((m) => m.id));
const results: EntryResult[] = [];
let inserted = 0;
let duplicates = 0;
const skipped: Array<{ index: number; reason: string }> = [];
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (!ownedSet.has(entry.medicationId)) {
const reason = "medication_not_found";
skipped.push({ index: i, reason });
results.push({ index: i, status: "skipped", reason });
continue;
}
try {
const scheduledFor = entry.scheduledFor ?? new Date();
// The idempotencyKey, when supplied, has a UNIQUE index. A
// re-submission of the same key returns the existing row via
// P2002 → status: "duplicate".
const row = await prisma.medicationIntakeEvent.create({
data: {
userId: user.id,
medicationId: entry.medicationId,
scheduledFor,
takenAt: entry.takenAt ?? null,
skipped: entry.skipped,
source: "API",
idempotencyKey: entry.idempotencyKey ?? null,
},
});
inserted += 1;
results.push({ index: i, status: "inserted", id: row.id });
} catch (err: unknown) {
// P2002 = unique-constraint violation; the idempotencyKey
// already exists → "duplicate", not an error.
if (
typeof err === "object" &&
err !== null &&
"code" in err &&
(err as { code: string }).code === "P2002"
) {
const existing = entry.idempotencyKey
? await prisma.medicationIntakeEvent.findUnique({
where: { idempotencyKey: entry.idempotencyKey },
select: { id: true },
})
: null;
duplicates += 1;
results.push({
index: i,
status: "duplicate",
id: existing?.id,
});
continue;
}
const reason =
err instanceof Error ? err.message.slice(0, 120) : "create_failed";
skipped.push({ index: i, reason });
results.push({ index: i, status: "skipped", reason });
}
}
await auditLog("medications.intake.bulk.ingest", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
processed: entries.length,
inserted,
duplicates,
skipped: skipped.length,
},
});
annotate({
action: { name: "medications.intake.bulk.ingest" },
meta: {
processed: entries.length,
inserted,
duplicates,
skipped: skipped.length,
},
});
return apiSuccess({
processed: entries.length,
inserted,
duplicates,
skipped,
entries: results,
});
}
+3
View File
@@ -88,6 +88,9 @@ export const PUT = apiHandler(
if (data.tags !== undefined) {
updateData.tags = data.tags ? JSON.stringify(data.tags) : null;
}
if (data.note !== undefined) {
updateData.note = data.note;
}
const entry = await prisma.moodEntry.update({
where: { id },
+220
View File
@@ -0,0 +1,220 @@
/**
* `POST /api/mood-entries/bulk` — iOS SyncMode bulk backfill.
*
* When iOS pairs a fresh device with the server, it drains its local
* SwiftData mood log via this endpoint in one shot. Per-entry UPSERT
* semantics keyed by `externalId` so re-runs are idempotent.
*
* Body:
* { entries: BulkMoodEntry[] } — capped at 500 per call.
*
* Response (always 200):
* {
* processed,
* inserted,
* duplicates,
* skipped: [{ index, reason }, ...],
* entries: [{ index, status, id? }, ...]
* }
*
* Locked contract — see `.planning/v15-ios-handoff/06-ios-responsibilities.md`
* §"Cumulative metrics" sibling section for the SyncMode rationale,
* and `08-locked-contracts.md` §2 for the batch envelope shape.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import {
getScoreForMood,
moodLevelEnum,
moodSourceEnum,
} from "@/lib/validations/moodlog";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
const MAX_ENTRIES_PER_BATCH = 500;
const BATCH_RATE_LIMIT_MAX = 60;
const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
const bulkEntrySchema = z.object({
mood: moodLevelEnum,
tags: z.array(z.string().max(50)).max(20).optional(),
note: z.string().max(500).optional(),
moodLoggedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
source: moodSourceEnum.optional().default("MANUAL"),
/**
* Optional iOS-side identifier (e.g. SwiftData row UUID) that lets
* the bulk endpoint dedup idempotently when iOS retries the same
* batch after a network hiccup. Mirrors the `externalId` posture on
* the measurements batch endpoint. NULL = no dedup hint; the
* existing `(userId, date, moodLoggedAt)` unique index still
* protects against straight-up duplicates.
*/
externalId: z.string().min(1).max(120).optional(),
});
const bulkPayloadSchema = z.object({
entries: z.array(bulkEntrySchema).min(1).max(MAX_ENTRIES_PER_BATCH),
});
type EntryStatus = "inserted" | "duplicate" | "skipped";
interface EntryResult {
index: number;
status: EntryStatus;
reason?: string;
id?: string;
}
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk));
async function postBulk(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`mood-entries:bulk:${user.id}`,
BATCH_RATE_LIMIT_MAX,
BATCH_RATE_LIMIT_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many bulk submissions, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
if (
typeof rawBody === "object" &&
rawBody !== null &&
"entries" in rawBody &&
Array.isArray((rawBody as { entries: unknown }).entries) &&
(rawBody as { entries: unknown[] }).entries.length > MAX_ENTRIES_PER_BATCH
) {
return apiError(
`Batch exceeds the ${MAX_ENTRIES_PER_BATCH}-entry limit`,
422,
{ errorCode: "mood.bulk.too_large" },
);
}
const parsed = bulkPayloadSchema.safeParse(rawBody);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422, {
errorCode: "mood.bulk.invalid",
});
}
const { entries } = parsed.data;
const tz = user.timezone ?? DEFAULT_TIMEZONE;
const results: EntryResult[] = [];
let inserted = 0;
let duplicates = 0;
const skipped: Array<{ index: number; reason: string }> = [];
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const date = moodDateKey(entry.moodLoggedAt, tz);
const score = getScoreForMood(entry.mood);
try {
// Probe-then-upsert so the response reliably distinguishes
// "inserted" from "duplicate". Two round-trips per entry is
// acceptable given the 500-entry cap; a more cache-friendly
// shape (batched probe) is a v1.4.31 optimisation if the cap
// grows.
const existing = await prisma.moodEntry.findUnique({
where: {
userId_date_moodLoggedAt: {
userId: user.id,
date,
moodLoggedAt: entry.moodLoggedAt,
},
},
select: { id: true },
});
const result = await prisma.moodEntry.upsert({
where: {
userId_date_moodLoggedAt: {
userId: user.id,
date,
moodLoggedAt: entry.moodLoggedAt,
},
},
create: {
userId: user.id,
date,
tz,
mood: entry.mood,
score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
note: entry.note ?? null,
source: entry.source,
moodLoggedAt: entry.moodLoggedAt,
},
update: {
// Last-writer-wins on the mood + tags + note triple. The
// iOS client only re-posts an existing entry when it has
// new data; the server trusts that decision.
mood: entry.mood,
score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
note: entry.note ?? null,
},
});
if (existing) {
duplicates += 1;
results.push({ index: i, status: "duplicate", id: result.id });
} else {
inserted += 1;
results.push({ index: i, status: "inserted", id: result.id });
}
} catch (err: unknown) {
const reason =
err instanceof Error ? err.message.slice(0, 120) : "upsert_failed";
skipped.push({ index: i, reason });
results.push({ index: i, status: "skipped", reason });
}
}
await auditLog("mood.bulk.ingest", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
processed: entries.length,
inserted,
duplicates,
skipped: skipped.length,
},
});
annotate({
action: { name: "mood.bulk.ingest" },
meta: {
processed: entries.length,
inserted,
duplicates,
skipped: skipped.length,
},
});
return apiSuccess({
processed: entries.length,
inserted,
duplicates,
skipped,
entries: results,
});
}
+2 -1
View File
@@ -90,7 +90,7 @@ async function postMoodEntry(request: NextRequest) {
return apiError(parsed.error.issues[0].message, 422);
}
const { mood, tags, moodLoggedAt, source } = parsed.data;
const { mood, tags, note, moodLoggedAt, source } = parsed.data;
// v1.4.25 W7b (Decision A) — anchor the `date` string to the user's
// current displayTimezone and store the resolved zone on the row.
// Legacy rows with `tz IS NULL` continue to read as Europe/Berlin
@@ -108,6 +108,7 @@ async function postMoodEntry(request: NextRequest) {
mood,
score,
tags: tags ? JSON.stringify(tags) : null,
note: note ?? null,
source: source ?? "MANUAL",
moodLoggedAt,
},
+93
View File
@@ -0,0 +1,93 @@
/**
* `GET /api/sync/state` — iOS SyncMode handshake.
*
* The iOS SyncMode store reads this endpoint on pair / unpair, on
* background-to-foreground, and before opening a fresh anchored
* HealthKit query window. The response is a compact JSON shape iOS
* compares against its local SwiftData state to decide whether to
* (a) drain a pending outbox, (b) request a full refresh, or (c) sit
* quiet.
*
* Response shape:
* {
* userId,
* timezone,
* lastSyncedAt, — User.lastSyncedAt (ISO string or null)
* serverNow, — `new Date().toISOString()` for clock skew
* measurements: {
* lastUpdatedAt, — MAX(updated_at) for live rows
* liveCount, — COUNT(*) WHERE deleted_at IS NULL
* tombstonedCount, — COUNT(*) WHERE deleted_at IS NOT NULL
* }
* }
*
* No body. requireAuth() — cookie + Bearer both accepted (iOS uses
* Bearer per `05-auth-flows.md`).
*
* Locked contract: see `.planning/v15-ios-handoff/08-locked-contracts.md`
* §13 cross-reference. The shape is additive going forward.
*/
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const GET = apiHandler(async (_request: NextRequest) => {
const { user } = await requireAuth();
// Two aggregate reads + one user-side bump. The aggregate reads run
// against indexed columns (userId, deleted_at) so they stay
// O(rows-for-user) and not O(rows-across-tenants).
const [latest, liveCount, tombstonedCount, currentUser] = await Promise.all([
prisma.measurement.findFirst({
where: { userId: user.id, deletedAt: null },
orderBy: { updatedAt: "desc" },
select: { updatedAt: true },
}),
prisma.measurement.count({
where: { userId: user.id, deletedAt: null },
}),
prisma.measurement.count({
where: { userId: user.id, deletedAt: { not: null } },
}),
prisma.user.findUnique({
where: { id: user.id },
select: { lastSyncedAt: true, timezone: true },
}),
]);
// The handshake also bumps the user's lastSyncedAt — the call IS
// the handshake. iOS reads the OLD lastSyncedAt from the response
// and then trusts that subsequent server writes after the new
// checkpoint will round-trip via the standard read paths.
const previous = currentUser?.lastSyncedAt ?? null;
const nextCheckpoint = new Date();
await prisma.user.update({
where: { id: user.id },
data: { lastSyncedAt: nextCheckpoint },
});
annotate({
action: { name: "sync.state" },
meta: {
liveCount,
tombstonedCount,
lastSyncedAtBefore: previous?.toISOString() ?? null,
},
});
return apiSuccess({
userId: user.id,
timezone: currentUser?.timezone ?? "Europe/Berlin",
lastSyncedAt: previous?.toISOString() ?? null,
serverNow: nextCheckpoint.toISOString(),
measurements: {
lastUpdatedAt: latest?.updatedAt?.toISOString() ?? null,
liveCount,
tombstonedCount,
},
});
});
@@ -68,6 +68,9 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
AUDIO_EXPOSURE_ENV: "measurements.typeAudioExposureEnv",
AUDIO_EXPOSURE_HEADPHONE: "measurements.typeAudioExposureHeadphone",
TIME_IN_DAYLIGHT: "measurements.typeTimeInDaylight",
// ── v1.4.30 R-F T1.4 + T1.5 ──
WALKING_STEADINESS: "measurements.typeWalkingSteadiness",
AUDIO_EXPOSURE_EVENT: "measurements.typeAudioExposureEvent",
};
export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
@@ -110,6 +113,12 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
AUDIO_EXPOSURE_ENV: Volume2,
AUDIO_EXPOSURE_HEADPHONE: Headphones,
TIME_IN_DAYLIGHT: Sun,
// ── v1.4.30 R-F T1.4 + T1.5 ──
// Activity/Gauge carries the mobility-steadiness signal (same
// family as VO2_MAX). Volume2 is reused for the loud-listening
// event flag — the event is a louder-cousin of the env quantity.
WALKING_STEADINESS: Gauge,
AUDIO_EXPOSURE_EVENT: Volume2,
};
export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
@@ -149,4 +158,7 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
AUDIO_EXPOSURE_ENV: "bg-chart-5/20 text-chart-5",
AUDIO_EXPOSURE_HEADPHONE: "bg-chart-5/20 text-chart-5",
TIME_IN_DAYLIGHT: "bg-chart-2/20 text-chart-2",
// ── v1.4.30 R-F T1.4 + T1.5 ──
WALKING_STEADINESS: "bg-chart-2/20 text-chart-2",
AUDIO_EXPOSURE_EVENT: "bg-chart-5/20 text-chart-5",
};
@@ -46,10 +46,13 @@ const EXPECTED_TYPES = [
"AUDIO_EXPOSURE_ENV",
"AUDIO_EXPOSURE_HEADPHONE",
"TIME_IN_DAYLIGHT",
// ── v1.4.30 R-F T1.4 + T1.5 ──
"WALKING_STEADINESS",
"AUDIO_EXPOSURE_EVENT",
] as const;
describe("measurementTypeEnum coverage", () => {
it("exposes the 28 canonical measurement types", () => {
it("exposes the 30 canonical measurement types", () => {
expect([...measurementTypeEnum.options].sort()).toEqual(
[...EXPECTED_TYPES].sort(),
);
@@ -96,6 +99,12 @@ describe("measurementTypeEnum coverage", () => {
"AUDIO_EXPOSURE_ENV",
"AUDIO_EXPOSURE_HEADPHONE",
"TIME_IN_DAYLIGHT",
// v1.4.30 R-F T1.4 + T1.5 additions held under the same v1.5 gate.
// Walking-steadiness pairs with a mobility chip; audio-exposure
// events surface on the Insights audio sub-page. Neither belongs
// in the clinical PDF until the iOS-app sync lands.
"WALKING_STEADINESS",
"AUDIO_EXPOSURE_EVENT",
]);
it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => {
+5
View File
@@ -60,6 +60,11 @@ export const ALLOWED_CHART_TOKENS = [
"metric:AUDIO_EXPOSURE_ENV",
"metric:AUDIO_EXPOSURE_HEADPHONE",
"metric:TIME_IN_DAYLIGHT",
// v1.4.30 — R-F T1.4 + T1.5 Tier-1 additions. Same posture: the
// LLM can reference the tokens in prose; the chart components land
// alongside the iOS-app sync.
"metric:WALKING_STEADINESS",
"metric:AUDIO_EXPOSURE_EVENT",
] as const;
export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number];
@@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest";
import {
APPLE_HEALTH_SLEEP_STAGE_MAP,
APPLE_HEALTH_TYPE_MAP,
CUMULATIVE_HK_TYPES,
HK_QUANTITY_TYPE_DEFERRED,
dailyStatsExternalId,
mapAppleHealthEntry,
} from "../apple-health-mapping";
import { measurementTypeEnum } from "@/lib/validations/measurement";
@@ -129,8 +131,8 @@ describe("HK_QUANTITY_TYPE_DEFERRED", () => {
// Cardiovascular / clinical
"HKQuantityTypeIdentifierAtrialFibrillationBurden",
"HKQuantityTypeIdentifierPeripheralPerfusionIndex",
// Mobility
"HKQuantityTypeIdentifierAppleWalkingSteadiness",
// Mobility — `AppleWalkingSteadiness` moved into the mapped
// set in v1.4.30. The remaining identifiers stay deferred.
"HKQuantityTypeIdentifierNumberOfTimesFallen",
"HKCategoryTypeIdentifierAppleWalkingSteadinessEvent",
// Respiratory / pulmonary
@@ -149,9 +151,9 @@ describe("HK_QUANTITY_TYPE_DEFERRED", () => {
"HKCategoryTypeIdentifierHighHeartRateEvent",
"HKCategoryTypeIdentifierIrregularHeartRhythmEvent",
"HKCategoryTypeIdentifierLowCardioFitnessEvent",
// Audio-exposure events
"HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent",
"HKCategoryTypeIdentifierHeadphoneAudioExposureEvent",
// Audio-exposure events — environmental + headphone moved into
// the mapped set in v1.4.30 (AUDIO_EXPOSURE_EVENT). The general
// sound-reduction flag stays deferred.
"HKCategoryTypeIdentifierEnvironmentalSoundReduction",
// Behavioural / habit
"HKCategoryTypeIdentifierHandwashingEvent",
@@ -343,3 +345,99 @@ describe("mapAppleHealthEntry", () => {
expect(out).toBeNull();
});
});
describe("v1.4.30 Tier-1 additions (R-F T1.4 + T1.5)", () => {
it("maps appleWalkingSteadiness to WALKING_STEADINESS with × 100 scaling", () => {
const mapping =
APPLE_HEALTH_TYPE_MAP.HKQuantityTypeIdentifierAppleWalkingSteadiness;
expect(mapping).toBeDefined();
expect(mapping.measurementType).toBe("WALKING_STEADINESS");
expect(mapping.convertToDbUnit(0.85)).toBeCloseTo(85);
expect(mapping.dbUnit).toBe("%");
});
it("maps environmental + headphone audio-exposure events to AUDIO_EXPOSURE_EVENT (count 1)", () => {
const envMapping =
APPLE_HEALTH_TYPE_MAP.HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent;
expect(envMapping).toBeDefined();
expect(envMapping.measurementType).toBe("AUDIO_EXPOSURE_EVENT");
expect(envMapping.convertToDbUnit(0)).toBe(1);
expect(envMapping.convertToDbUnit(99)).toBe(1);
const hpMapping =
APPLE_HEALTH_TYPE_MAP.HKCategoryTypeIdentifierHeadphoneAudioExposureEvent;
expect(hpMapping).toBeDefined();
expect(hpMapping.measurementType).toBe("AUDIO_EXPOSURE_EVENT");
expect(hpMapping.convertToDbUnit(0)).toBe(1);
});
it("does not double-book the new identifiers as deferred AND mapped", () => {
expect(
HK_QUANTITY_TYPE_DEFERRED.has(
"HKQuantityTypeIdentifierAppleWalkingSteadiness",
),
).toBe(false);
expect(
HK_QUANTITY_TYPE_DEFERRED.has(
"HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent",
),
).toBe(false);
expect(
HK_QUANTITY_TYPE_DEFERRED.has(
"HKCategoryTypeIdentifierHeadphoneAudioExposureEvent",
),
).toBe(false);
});
});
describe("dailyStatsExternalId (v1.4.30 — R-A Option A handoff lock)", () => {
it("produces the canonical stats:<type>:<date> shape for stepCount", () => {
expect(
dailyStatsExternalId("HKQuantityTypeIdentifierStepCount", "2026-05-16"),
).toBe("stats:HKQuantityTypeIdentifierStepCount:2026-05-16");
});
it("produces the same shape for every cumulative-type identifier", () => {
const cases: Array<[string, string]> = [
["HKQuantityTypeIdentifierStepCount", "2026-01-01"],
["HKQuantityTypeIdentifierActiveEnergyBurned", "2026-02-29"],
["HKQuantityTypeIdentifierFlightsClimbed", "2025-12-31"],
["HKQuantityTypeIdentifierDistanceWalkingRunning", "2024-02-29"],
["HKQuantityTypeIdentifierTimeInDaylight", "2026-05-16"],
];
for (const [id, day] of cases) {
expect(dailyStatsExternalId(id, day)).toBe(`stats:${id}:${day}`);
}
});
it("accepts the date string as-is — iOS owns the format", () => {
// Per R-A §5, iOS generates the date string from the user's IANA
// timezone via DateFormatter with the `yyyy-MM-dd` pattern. The
// server trusts the inbound shape rather than re-validating; the
// receiving Zod schema already caps `externalId` at 120 chars.
expect(
dailyStatsExternalId("HKQuantityTypeIdentifierStepCount", " 2026-05-16 "),
).toBe("stats:HKQuantityTypeIdentifierStepCount: 2026-05-16 ");
expect(
dailyStatsExternalId("HKQuantityTypeIdentifierStepCount", ""),
).toBe("stats:HKQuantityTypeIdentifierStepCount:");
});
it("covers every CUMULATIVE_HK_TYPES MeasurementType via a known HK identifier", () => {
// Sanity wall: every cumulative MeasurementType has a forward-map
// entry in APPLE_HEALTH_TYPE_MAP so the iOS-side daily-stats
// service can mint the externalId without a server round-trip.
const cumulativeIdentifiers = Object.values(APPLE_HEALTH_TYPE_MAP)
.filter((m) => CUMULATIVE_HK_TYPES.has(m.measurementType))
.map((m) => m.hkIdentifier);
expect(cumulativeIdentifiers.sort()).toEqual(
[
"HKQuantityTypeIdentifierStepCount",
"HKQuantityTypeIdentifierActiveEnergyBurned",
"HKQuantityTypeIdentifierFlightsClimbed",
"HKQuantityTypeIdentifierDistanceWalkingRunning",
"HKQuantityTypeIdentifierTimeInDaylight",
].sort(),
);
});
});
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import {
MEASUREMENT_CATEGORIES,
getMeasurementCategory,
measurementTypesForCategory,
} from "../categories";
describe("MEASUREMENT_CATEGORIES — completeness wall", () => {
it("assigns a category to every MeasurementType in the canonical enum", () => {
const enumValues = measurementTypeEnum.options;
for (const type of enumValues) {
expect(
MEASUREMENT_CATEGORIES.has(type),
`MeasurementType ${type} has no category assignment`,
).toBe(true);
}
});
it("does not assign a category to a type the canonical enum does not know", () => {
const enumValues = new Set<string>(measurementTypeEnum.options);
for (const type of MEASUREMENT_CATEGORIES.keys()) {
expect(
enumValues.has(type),
`MeasurementType ${type} is mapped but not in the canonical enum`,
).toBe(true);
}
});
it("groups blood-pressure systolic and diastolic into the same category", () => {
expect(MEASUREMENT_CATEGORIES.get("BLOOD_PRESSURE_SYS")).toBe(
MEASUREMENT_CATEGORIES.get("BLOOD_PRESSURE_DIA"),
);
});
});
describe("getMeasurementCategory", () => {
it("returns the category for a known type", () => {
expect(getMeasurementCategory("WEIGHT")).toBe("body");
expect(getMeasurementCategory("BLOOD_GLUCOSE")).toBe("metabolic");
expect(getMeasurementCategory("ACTIVITY_STEPS")).toBe("activity");
expect(getMeasurementCategory("AUDIO_EXPOSURE_HEADPHONE")).toBe("hearing");
});
});
describe("measurementTypesForCategory", () => {
it("lists every type assigned to a category", () => {
expect(measurementTypesForCategory("hearing").sort()).toEqual([
"AUDIO_EXPOSURE_ENV",
"AUDIO_EXPOSURE_EVENT",
"AUDIO_EXPOSURE_HEADPHONE",
]);
});
it("returns an empty list for a category with no members today", () => {
// None of the current 27 enum values surface a mood-specific
// MeasurementType — mood lives in MoodEntry — so the category
// can be queried without surprise (mostly a sanity wall for the
// R-F §4.4 synthetic-token decision).
const allCategories: ReadonlyArray<string> = Array.from(
new Set(Array.from(MEASUREMENT_CATEGORIES.values())),
);
for (const cat of allCategories) {
const members = measurementTypesForCategory(cat as never);
expect(members.length, `${cat} should not be empty`).toBeGreaterThan(0);
}
});
it("includes the v1.4.25 W8d hearing + environment additions", () => {
// Regression sentinel — v1.4.25 W8d added AUDIO_EXPOSURE_ENV /
// AUDIO_EXPOSURE_HEADPHONE / TIME_IN_DAYLIGHT. The categorisation
// overlay must pick them up so the iOS picker surfaces them under
// Hearing + Environment rather than dropping them into the flat
// 27-line view that the overlay exists to avoid.
expect(measurementTypesForCategory("hearing")).toContain(
"AUDIO_EXPOSURE_ENV",
);
expect(measurementTypesForCategory("environment")).toContain(
"TIME_IN_DAYLIGHT",
);
});
});
@@ -0,0 +1,154 @@
import { describe, expect, it } from "vitest";
import {
bucketRowsByUserDay,
canonicalDailyTimestamp,
dayKeyForUserTz,
sumBucketValues,
} from "../drain-per-sample-cumulative";
describe("dayKeyForUserTz", () => {
it("anchors the calendar day to the user's IANA zone", () => {
// 23:45 NZST on 2026-05-16 is still 11:45 UTC on the same day, but
// the user's calendar day in Europe/Berlin would be the previous day
// because Berlin is 10-11h behind NZST.
const instant = new Date("2026-05-16T11:45:00.000Z");
expect(dayKeyForUserTz(instant, "Pacific/Auckland")).toBe("2026-05-16");
// Europe/Berlin: same instant resolves to local 13:45 — still 2026-05-16.
expect(dayKeyForUserTz(instant, "Europe/Berlin")).toBe("2026-05-16");
// America/Los_Angeles (UTC-7 in DST): same instant resolves to 04:45 — 2026-05-16.
expect(dayKeyForUserTz(instant, "America/Los_Angeles")).toBe("2026-05-16");
});
it("uses sv-SE Intl formatting so output is ISO-shaped", () => {
const instant = new Date("2026-01-01T00:00:00.000Z");
expect(dayKeyForUserTz(instant, "UTC")).toBe("2026-01-01");
// The Pacific/Auckland clock is 13h ahead → already 2026-01-01 mid-afternoon at UTC midnight.
expect(dayKeyForUserTz(instant, "Pacific/Auckland")).toBe("2026-01-01");
// Europe/Berlin (UTC+1 in standard time): same instant → 01:00 local → still 2026-01-01.
expect(dayKeyForUserTz(instant, "Europe/Berlin")).toBe("2026-01-01");
});
});
describe("canonicalDailyTimestamp", () => {
it("anchors a Berlin day-key to the local-noon instant", () => {
// 12:00 local in Berlin during DST (CEST, UTC+2) is 10:00 UTC.
const ts = canonicalDailyTimestamp("2026-05-16", "Europe/Berlin");
expect(ts.toISOString()).toBe("2026-05-16T10:00:00.000Z");
});
it("anchors a UTC day-key to plain 12:00 UTC", () => {
const ts = canonicalDailyTimestamp("2026-05-16", "UTC");
expect(ts.toISOString()).toBe("2026-05-16T12:00:00.000Z");
});
it("anchors a Pacific/Auckland day-key to the local-noon instant", () => {
// 12:00 NZST (UTC+12) → 00:00 UTC on the same day.
const ts = canonicalDailyTimestamp("2026-05-16", "Pacific/Auckland");
expect(ts.toISOString()).toBe("2026-05-16T00:00:00.000Z");
});
it("handles half-hour-offset zones (India IST = UTC+5:30)", () => {
// 12:00 IST → 06:30 UTC on the same day.
const ts = canonicalDailyTimestamp("2026-05-16", "Asia/Kolkata");
expect(ts.toISOString()).toBe("2026-05-16T06:30:00.000Z");
});
});
describe("bucketRowsByUserDay", () => {
it("groups per-sample rows by the user's calendar day", () => {
const rows = [
{
id: "m-1",
type: "ACTIVITY_STEPS" as const,
value: 1200,
measuredAt: new Date("2026-05-16T08:00:00.000Z"), // 10:00 Berlin
externalId: "hk-uuid-1",
},
{
id: "m-2",
type: "ACTIVITY_STEPS" as const,
value: 3400,
measuredAt: new Date("2026-05-16T14:00:00.000Z"), // 16:00 Berlin
externalId: "hk-uuid-2",
},
{
id: "m-3",
type: "ACTIVITY_STEPS" as const,
value: 800,
measuredAt: new Date("2026-05-17T06:00:00.000Z"), // 08:00 Berlin, next day
externalId: "hk-uuid-3",
},
];
const { byDay } = bucketRowsByUserDay(rows, "Europe/Berlin");
expect(byDay.size).toBe(2);
expect(byDay.get("2026-05-16")?.length).toBe(2);
expect(byDay.get("2026-05-17")?.length).toBe(1);
});
it("skips rows whose externalId is already in stats:... shape (idempotent re-run)", () => {
const rows = [
{
id: "m-1",
type: "ACTIVITY_STEPS" as const,
value: 5000,
measuredAt: new Date("2026-05-16T10:00:00.000Z"),
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-05-16",
},
{
id: "m-2",
type: "ACTIVITY_STEPS" as const,
value: 1200,
measuredAt: new Date("2026-05-16T08:00:00.000Z"),
externalId: "hk-uuid-pre-drain",
},
];
const { byDay } = bucketRowsByUserDay(rows, "Europe/Berlin");
// The already-collapsed row is skipped; only the per-sample row
// remains in the bucket.
expect(byDay.get("2026-05-16")?.length).toBe(1);
expect(byDay.get("2026-05-16")?.[0]?.id).toBe("m-2");
});
it("groups rows with NULL externalId (manual entries) like any other per-sample row", () => {
const rows = [
{
id: "m-1",
type: "ACTIVITY_STEPS" as const,
value: 1200,
measuredAt: new Date("2026-05-16T08:00:00.000Z"),
externalId: null,
},
];
const { byDay } = bucketRowsByUserDay(rows, "Europe/Berlin");
expect(byDay.get("2026-05-16")?.length).toBe(1);
});
it("returns an empty map when the input is empty", () => {
const { byDay } = bucketRowsByUserDay([], "Europe/Berlin");
expect(byDay.size).toBe(0);
});
});
describe("sumBucketValues", () => {
it("sums a non-empty bucket", () => {
const rows = [
{ id: "1", type: "ACTIVITY_STEPS" as const, value: 1200, measuredAt: new Date(), externalId: null },
{ id: "2", type: "ACTIVITY_STEPS" as const, value: 3400, measuredAt: new Date(), externalId: null },
{ id: "3", type: "ACTIVITY_STEPS" as const, value: 800, measuredAt: new Date(), externalId: null },
];
expect(sumBucketValues(rows)).toBe(5400);
});
it("returns 0 for an empty bucket", () => {
expect(sumBucketValues([])).toBe(0);
});
it("handles fractional values (e.g. ACTIVE_ENERGY_BURNED kcal)", () => {
const rows = [
{ id: "1", type: "ACTIVE_ENERGY_BURNED" as const, value: 12.4, measuredAt: new Date(), externalId: null },
{ id: "2", type: "ACTIVE_ENERGY_BURNED" as const, value: 7.6, measuredAt: new Date(), externalId: null },
];
expect(sumBucketValues(rows)).toBeCloseTo(20.0);
});
});
@@ -0,0 +1,180 @@
import { describe, expect, it } from "vitest";
import { pickCanonicalWorkoutRows } from "../pick-canonical-workout-rows";
interface RowFixture {
id: string;
startedAt: Date;
sportType: string;
source: "APPLE_HEALTH" | "WITHINGS" | "MANUAL" | "IMPORT";
}
describe("pickCanonicalWorkoutRows", () => {
it("returns the empty list unchanged for an empty input", () => {
expect(pickCanonicalWorkoutRows([])).toEqual([]);
});
it("keeps a single workout regardless of source", () => {
const rows: RowFixture[] = [
{
id: "w-1",
startedAt: new Date("2026-05-16T08:00:00Z"),
sportType: "running",
source: "WITHINGS",
},
];
expect(pickCanonicalWorkoutRows(rows).map((r) => r.id)).toEqual(["w-1"]);
});
it("prefers APPLE_HEALTH over WITHINGS when both source the same workout", () => {
const rows: RowFixture[] = [
{
id: "apple",
startedAt: new Date("2026-05-16T08:00:00Z"),
sportType: "running",
source: "APPLE_HEALTH",
},
{
id: "withings",
startedAt: new Date("2026-05-16T08:01:30Z"), // 90 s apart — same 5-min slot
sportType: "running",
source: "WITHINGS",
},
];
expect(pickCanonicalWorkoutRows(rows).map((r) => r.id)).toEqual(["apple"]);
});
it("does NOT collapse two distinct runs whose starts are > 5 min apart", () => {
const rows: RowFixture[] = [
{
id: "first",
startedAt: new Date("2026-05-16T08:00:00Z"),
sportType: "running",
source: "APPLE_HEALTH",
},
{
id: "second",
startedAt: new Date("2026-05-16T08:06:00Z"), // 6 min after start — separate bucket
sportType: "running",
source: "APPLE_HEALTH",
},
];
expect(pickCanonicalWorkoutRows(rows).map((r) => r.id)).toEqual([
"first",
"second",
]);
});
it("keeps workouts of different sports that start in the same minute", () => {
const rows: RowFixture[] = [
{
id: "run",
startedAt: new Date("2026-05-16T08:00:00Z"),
sportType: "running",
source: "APPLE_HEALTH",
},
{
id: "cycle",
startedAt: new Date("2026-05-16T08:01:00Z"),
sportType: "cycling",
source: "APPLE_HEALTH",
},
];
expect(pickCanonicalWorkoutRows(rows).map((r) => r.id).sort()).toEqual([
"cycle",
"run",
]);
});
it("preserves input order on the output", () => {
const rows: RowFixture[] = [
{
id: "morning",
startedAt: new Date("2026-05-16T06:00:00Z"),
sportType: "running",
source: "APPLE_HEALTH",
},
{
id: "evening",
startedAt: new Date("2026-05-16T18:00:00Z"),
sportType: "cycling",
source: "APPLE_HEALTH",
},
];
expect(pickCanonicalWorkoutRows(rows).map((r) => r.id)).toEqual([
"morning",
"evening",
]);
});
it("honours a user-supplied ladder that promotes WITHINGS over APPLE_HEALTH", () => {
const rows: RowFixture[] = [
{
id: "apple",
startedAt: new Date("2026-05-16T08:00:00Z"),
sportType: "running",
source: "APPLE_HEALTH",
},
{
id: "withings",
startedAt: new Date("2026-05-16T08:01:00Z"),
sportType: "running",
source: "WITHINGS",
},
];
const userPriority = {
metricPriority: {
steps: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "IMPORT"],
},
};
expect(
pickCanonicalWorkoutRows(rows, userPriority).map((r) => r.id),
).toEqual(["withings"]);
});
it("keeps the manual entry when no priority-ladder source claims the slot", () => {
// Edge case: a user with both APPLE_HEALTH and WITHINGS still
// manually recorded an "elliptical" workout the integrations
// don't track. Manual wins by default (only entry in bucket).
const rows: RowFixture[] = [
{
id: "manual",
startedAt: new Date("2026-05-16T08:00:00Z"),
sportType: "elliptical",
source: "MANUAL",
},
];
expect(pickCanonicalWorkoutRows(rows).map((r) => r.id)).toEqual(["manual"]);
});
it("falls through to keeping every row when sources aren't on the ladder at all", () => {
// Theoretical safety net — no real source emits a value outside
// the canonical enum, but the picker MUST NEVER drop signal it
// can't classify. Forcing a ladder of a single source the bucket
// doesn't carry exercises the fallback path.
const rows: RowFixture[] = [
{
id: "a",
startedAt: new Date("2026-05-16T08:00:00Z"),
sportType: "running",
source: "APPLE_HEALTH",
},
{
id: "b",
startedAt: new Date("2026-05-16T08:01:00Z"),
sportType: "running",
source: "WITHINGS",
},
];
const oddPriority = {
metricPriority: {
steps: ["IMPORT"],
},
};
expect(
pickCanonicalWorkoutRows(rows, oddPriority)
.map((r) => r.id)
.sort(),
).toEqual(["a", "b"]);
});
});
+101 -9
View File
@@ -285,6 +285,43 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
convertToDbUnit: (v) => v,
aggregation: "sum",
},
// ── v1.4.30 R-F T1.4 + T1.5 Tier-1 additions ────────────────
// 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).
HKQuantityTypeIdentifierAppleWalkingSteadiness: {
hkIdentifier: "HKQuantityTypeIdentifierAppleWalkingSteadiness",
measurementType: "WALKING_STEADINESS",
hkUnit: "%",
dbUnit: "%",
// Apple ships 0..1 fraction; HealthLog stores 0..100.
convertToDbUnit: (v) => v * 100,
aggregation: "latest",
},
// Environmental audio-exposure event — iOS 13+ category-type that
// fires when the rolling 7-day average crosses the WHO 80-dBA loud-
// listening threshold. Stored as a 1.0 count per fired event; the
// `notes` field carries the source token ("env" vs "headphone").
HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent: {
hkIdentifier: "HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent",
measurementType: "AUDIO_EXPOSURE_EVENT",
hkUnit: "count",
dbUnit: "count",
convertToDbUnit: () => 1,
aggregation: "sum",
},
// Headphone audio-exposure event — same shape as the environmental
// sibling; both share the AUDIO_EXPOSURE_EVENT MeasurementType so
// chart-card consumers can pick them up uniformly.
HKCategoryTypeIdentifierHeadphoneAudioExposureEvent: {
hkIdentifier: "HKCategoryTypeIdentifierHeadphoneAudioExposureEvent",
measurementType: "AUDIO_EXPOSURE_EVENT",
hkUnit: "count",
dbUnit: "count",
convertToDbUnit: () => 1,
aggregation: "sum",
},
};
/**
@@ -310,6 +347,62 @@ export const CUMULATIVE_HK_TYPES: ReadonlySet<MeasurementType> = new Set<Measure
"TIME_IN_DAYLIGHT",
]);
/**
* v1.4.30 — externalId shape for daily-aggregated cumulative
* HealthKit rows. iOS emits one row per day per cumulative type via
* `HKStatisticsCollectionQuery` per R-A Option A; the externalId
* UPSERTs the matching server row idempotently across re-syncs.
*
* Format: `stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>`.
*
* Cumulative HK types only — the spot-sample path keeps using
* `HKSample.uuid` as `externalId`. The shape is intentionally
* stable across the cutover: iOS clients still posting per-sample
* rows round-trip through the existing `(userId, type, source,
* externalId)` unique index; iOS clients on the daily-stats path
* collide on the deterministic `"stats:..."` key for idempotent
* UPSERTs.
*
* Locked contract — see
* `.planning/v15-ios-handoff/08-locked-contracts.md` §13 and
* `.planning/v15-ios-handoff/06-ios-responsibilities.md` Domain 1
* "Cumulative metrics: daily aggregation on iOS".
*
* The helper accepts the date string as-is: iOS generates it from
* the user's IANA timezone via `DateFormatter` with the
* `yyyy-MM-dd` pattern; the server trusts that format rather than
* re-validating per ingest because the iOS handoff doc locks the
* shape and the receiving Zod schema already caps `externalId` at
* 120 characters.
*/
export function dailyStatsExternalId(
hkIdentifier: string,
dateYYYYMMDD: string,
): string {
return `stats:${hkIdentifier}:${dateYYYYMMDD}`;
}
/**
* v1.4.30 — reverse lookup from a HealthLog `MeasurementType` to the
* canonical HealthKit identifier for that type. Used by the drain
* script when minting a `dailyStatsExternalId` from a row whose
* `hkIdentifier` is not carried on the table (the per-sample ingest
* stores only the resolved `MeasurementType`).
*
* Returns `null` when the type has no HealthKit counterpart (Withings-
* only metrics). Callers in the cumulative-drain path can assume the
* lookup succeeds because `CUMULATIVE_HK_TYPES` is a subset of the
* HK-mapped types.
*/
export function hkIdentifierForType(
type: MeasurementType,
): string | null {
for (const mapping of Object.values(APPLE_HEALTH_TYPE_MAP)) {
if (mapping.measurementType === type) return mapping.hkIdentifier;
}
return null;
}
/**
* HK identifiers the iOS app may emit that HealthLog deliberately does
* NOT map yet. Listing them here means the batch route can log a
@@ -411,9 +504,9 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set<string>([
// opt-in than the existing Health-share prompt.
"HKQuantityTypeIdentifierAtrialFibrillationBurden",
"HKQuantityTypeIdentifierPeripheralPerfusionIndex",
// Mobility (iOS 15+) — surface as a wellness signal in v1.5 once the
// Insights cardio sub-page has room for a steadiness gauge.
"HKQuantityTypeIdentifierAppleWalkingSteadiness",
// Mobility (iOS 15+) — `AppleWalkingSteadiness` moved into the
// mapping table in v1.4.30 (R-F T1.5). The remaining identifiers
// stay deferred until a wellness sub-page surface lands.
"HKQuantityTypeIdentifierNumberOfTimesFallen",
"HKCategoryTypeIdentifierAppleWalkingSteadinessEvent",
// Respiratory / pulmonary clinical (iOS 17) — pair with FHIR clinical
@@ -435,12 +528,11 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set<string>([
"HKCategoryTypeIdentifierHighHeartRateEvent",
"HKCategoryTypeIdentifierIrregularHeartRhythmEvent",
"HKCategoryTypeIdentifierLowCardioFitnessEvent",
// Audio-exposure events (iOS 13+) — the continuous AUDIO_EXPOSURE_*
// quantity identifiers ARE mapped above; these are the
// "loud-event-fired" flags that pair with them. Defer until we
// surface event chips in the Insights audio sub-page.
"HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent",
"HKCategoryTypeIdentifierHeadphoneAudioExposureEvent",
// Audio-exposure events (iOS 13+) — Environmental + Headphone
// moved into the mapping table in v1.4.30 (R-F T1.4) as
// AUDIO_EXPOSURE_EVENT. The general "sound reduction" flag stays
// deferred until we surface event chips in the Insights audio
// sub-page.
"HKCategoryTypeIdentifierEnvironmentalSoundReduction",
// Behavioural / habit category-types — not in HealthLog scope yet.
"HKCategoryTypeIdentifierHandwashingEvent",
+134
View File
@@ -0,0 +1,134 @@
/**
* v1.4.30 — UI-side categorisation overlay for `MeasurementType`.
*
* The flat 27-value enum is the correct storage shape (per R-F §4.1)
* — Postgres enums are cheap; a per-category sub-table would explode
* the read joins. The overlay below is presentation-only and drives:
*
* - The iOS HealthKit permission picker — grouped by `category` so
* the user sees one consent screen per logical area (vitals,
* activity, sleep, …) rather than a flat 27-line list. The iOS
* side mirrors this map verbatim via the swift-openapi-generator
* codegen path (R-E H-8); a new MeasurementType is one change in
* this file + the Prisma enum + the Zod validator + this overlay.
*
* - The web Insights nav (post-v1.5) — the sub-page strip stays
* curated at 7 pages, but the "All metrics" overview can group
* cards by category cheaply from this map.
*
* - The Coach evidence shelf — chip-grouping by category makes the
* prose more navigable than a flat list.
*
* Locked: see `.planning/research/v15-r-f-apple-health-depth.md` §4.
*/
import type { MeasurementType } from "@/generated/prisma/client";
/**
* Apple-Health-flavoured category set. Collapses Apple's
* Heart + Vitals into one `vitals` bucket and omits the ones we
* deliberately don't track (Cycle, Nutrition, Respiratory clinical,
* Symptoms).
*
* `cardiovascular` is distinct from `vitals` — vitals are point-in-
* time signals (BP, pulse, HRV, body temp, SpO2); cardiovascular
* holds derived risk markers that arrive less frequently (pulse-wave
* velocity, vascular age).
*/
export type MeasurementCategory =
| "vitals"
| "body"
| "activity"
| "sleep"
| "hearing"
| "environment"
| "cardiovascular"
| "metabolic";
/**
* Per-MeasurementType → category mapping. The map is exhaustive over
* the current enum surface — adding a new MeasurementType is a
* compile-time error in this file until a category is assigned.
*
* The map is intentionally `ReadonlyMap` rather than a plain object
* literal so consumers cannot mutate it accidentally; a flat
* `Record<MeasurementType, MeasurementCategory>` would be equally
* valid but provides no immutability guard.
*/
export const MEASUREMENT_CATEGORIES: ReadonlyMap<
MeasurementType,
MeasurementCategory
> = new Map<MeasurementType, MeasurementCategory>([
// ── Vitals (point-in-time signals) ──
["BLOOD_PRESSURE_SYS", "vitals"],
["BLOOD_PRESSURE_DIA", "vitals"],
["PULSE", "vitals"],
["RESTING_HEART_RATE", "vitals"],
["HEART_RATE_VARIABILITY", "vitals"],
["OXYGEN_SATURATION", "vitals"],
["BODY_TEMPERATURE", "vitals"],
// ── Body composition ──
["WEIGHT", "body"],
["BODY_FAT", "body"],
["FAT_FREE_MASS", "body"],
["FAT_MASS", "body"],
["MUSCLE_MASS", "body"],
["TOTAL_BODY_WATER", "body"],
["BONE_MASS", "body"],
["VISCERAL_FAT", "body"],
// ── Activity (cumulative + fitness + mobility) ──
["ACTIVITY_STEPS", "activity"],
["ACTIVE_ENERGY_BURNED", "activity"],
["FLIGHTS_CLIMBED", "activity"],
["WALKING_RUNNING_DISTANCE", "activity"],
["VO2_MAX", "activity"],
["WALKING_STEADINESS", "activity"],
// ── Sleep ──
["SLEEP_DURATION", "sleep"],
// ── Hearing (audio exposure quantities + events) ──
["AUDIO_EXPOSURE_ENV", "hearing"],
["AUDIO_EXPOSURE_HEADPHONE", "hearing"],
["AUDIO_EXPOSURE_EVENT", "hearing"],
// ── Environment ──
["TIME_IN_DAYLIGHT", "environment"],
// ── Cardiovascular (derived risk markers) ──
["PULSE_WAVE_VELOCITY", "cardiovascular"],
["VASCULAR_AGE", "cardiovascular"],
// ── Metabolic ──
["BLOOD_GLUCOSE", "metabolic"],
["SKIN_TEMPERATURE", "metabolic"],
]);
/**
* Lookup the category for a `MeasurementType`. Returns `undefined`
* only if a new enum value lands in the schema without a sibling
* entry in the map — the completeness test in
* `__tests__/categories.test.ts` is the regression sentinel.
*/
export function getMeasurementCategory(
type: MeasurementType,
): MeasurementCategory | undefined {
return MEASUREMENT_CATEGORIES.get(type);
}
/**
* List every `MeasurementType` belonging to a given category. Useful
* for the iOS picker fan-out and the web Insights nav grouping. The
* return order is the insertion order in `MEASUREMENT_CATEGORIES`
* above — kept deterministic so the picker UX has a stable layout.
*/
export function measurementTypesForCategory(
category: MeasurementCategory,
): MeasurementType[] {
const out: MeasurementType[] = [];
for (const [type, cat] of MEASUREMENT_CATEGORIES) {
if (cat === category) out.push(type);
}
return out;
}
@@ -0,0 +1,344 @@
/**
* v1.4.30 — drain pre-Option-A per-sample APPLE_HEALTH cumulative rows
* into one row per day per type, keyed by the locked `dailyStatsExternalId`
* shape. Idempotent: re-running collapses zero rows once every cumulative
* bucket already holds a single `stats:...` row.
*
* Scope per `CUMULATIVE_HK_TYPES`:
* ACTIVITY_STEPS, ACTIVE_ENERGY_BURNED, FLIGHTS_CLIMBED,
* WALKING_RUNNING_DISTANCE, TIME_IN_DAYLIGHT
*
* Per user × type × calendar day (anchored to `User.timezone`):
* 1. SELECT all `Measurement` rows with `source = 'APPLE_HEALTH'` and
* `type = <cumulative type>` and `measuredAt` within that user's
* calendar day boundary and `externalId NOT LIKE 'stats:%'`.
* 2. If 0 rows → continue.
* 3. If 1 row whose externalId already follows the `stats:...`
* shape → continue (already collapsed).
* 4. SUM the values; pick canonical timestamp = midday UTC of the
* user's calendar day (matches the Withings activity-sync
* convention per R-A §5 / W17b).
* 5. UPSERT a row with `externalId = dailyStatsExternalId(...)`,
* `value = sumValue`, `measuredAt = canonicalTimestamp`.
* 6. DELETE the original per-sample rows in the same transaction.
*
* Designed to be invoked by both:
* - the CLI at `scripts/drain-per-sample-cumulative.ts`
* - the admin endpoint at
* `POST /api/admin/drain-per-sample-cumulative`
*
* The dry-run path emits the same per-user-day summary without
* touching the DB so the operator can inspect what would change before
* committing.
*/
import { Prisma } from "@/generated/prisma/client";
import type { MeasurementType, PrismaClient } from "@/generated/prisma/client";
import {
CUMULATIVE_HK_TYPES,
dailyStatsExternalId,
hkIdentifierForType,
} from "./apple-health-mapping";
/** Per-(user, type, day) action summary. */
export interface DrainBucket {
userId: string;
type: MeasurementType;
/** Calendar-day key in the user's timezone (`YYYY-MM-DD`). */
dateKey: string;
/** Number of per-sample rows scanned for this bucket. */
perSampleCount: number;
/** SUM of per-sample values for this bucket (canonical-unit). */
sumValue: number;
/** ISO-8601 canonical timestamp (midday UTC of the user's calendar day). */
canonicalTimestamp: string;
/** Resulting `externalId` of the daily-aggregated row. */
externalId: string;
}
export interface DrainSummary {
/** Did the run actually write to the DB (false for `dryRun`). */
dryRun: boolean;
/** Per-user-day buckets the drain rewrote (or would rewrite). */
buckets: DrainBucket[];
/** Aggregate counts across the run. */
totals: {
usersScanned: number;
bucketsCollapsed: number;
perSampleRowsDeleted: number;
dailyRowsUpserted: number;
};
}
export interface DrainOptions {
/** Limit the drain to a single user. Default = every user. */
userId?: string;
/** Preview-only mode — no DB writes, no transaction commits. */
dryRun?: boolean;
/** Logger sink — defaults to `console.log`. */
log?: (line: string) => void;
}
/**
* Resolve the user's calendar-day key (`YYYY-MM-DD`) for a given
* timestamp + timezone. Reuses the same `sv-SE` Intl formatting choice
* the mood-entries path locked in v1.4.25 W7b so iOS-side and
* server-side day-keys round-trip byte-identically.
*/
export function dayKeyForUserTz(date: Date, tz: string): string {
return new Intl.DateTimeFormat("sv-SE", { timeZone: tz }).format(date);
}
/**
* Compute the canonical timestamp for a calendar-day key in the user's
* timezone. Returns the JS-Date instant at the user's local 12:00 noon
* — matches the Withings activity sync convention (one daily row per
* type, anchored to midday so the row sorts cleanly between same-day
* spot samples). The string returned by `toISOString()` is UTC.
*/
export function canonicalDailyTimestamp(
dateKey: string,
tz: string,
): Date {
// Compute the UTC offset for noon-local of the given day. We don't
// have a lightweight TZ-math library on the server, so the trick is:
// build "12:00 UTC of the day", read what wall-clock that shows in
// the target zone, then shift by the resulting offset.
const utcNoon = new Date(`${dateKey}T12:00:00.000Z`);
const fmt = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
hour12: false,
hour: "2-digit",
minute: "2-digit",
timeZoneName: "shortOffset",
});
// The shortOffset entry looks like "GMT+2", "GMT-5:30", or "GMT".
const parts = fmt.formatToParts(utcNoon);
const tzPart = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT";
const match = tzPart.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/);
if (!match) {
// UTC — noon UTC is noon local.
return utcNoon;
}
const sign = match[1] === "+" ? 1 : -1;
const hours = Number.parseInt(match[2], 10);
const minutes = match[3] ? Number.parseInt(match[3], 10) : 0;
const offsetMinutes = sign * (hours * 60 + minutes);
// utcNoon represents 12:00 UTC. The user's local clock reads
// 12:00 + offsetMinutes at that instant. To anchor at local 12:00,
// subtract the offset.
return new Date(utcNoon.getTime() - offsetMinutes * 60 * 1000);
}
/**
* Group an array of per-sample rows into per-day buckets keyed by the
* user's timezone. Exposed for unit testing the bucketing semantics
* without booting Prisma.
*/
export interface PerSampleRow {
id: string;
type: MeasurementType;
value: number;
measuredAt: Date;
externalId: string | null;
}
export interface BucketedRows {
/** Map keyed by `YYYY-MM-DD` (user TZ). */
byDay: Map<string, PerSampleRow[]>;
}
export function bucketRowsByUserDay(
rows: readonly PerSampleRow[],
tz: string,
): BucketedRows {
const byDay = new Map<string, PerSampleRow[]>();
for (const row of rows) {
// Skip rows already in the daily-stats shape — re-running the
// drain on a previously-collapsed bucket is a no-op.
if (row.externalId !== null && row.externalId.startsWith("stats:")) {
continue;
}
const key = dayKeyForUserTz(row.measuredAt, tz);
const slot = byDay.get(key) ?? [];
slot.push(row);
byDay.set(key, slot);
}
return { byDay };
}
/**
* Sum the values in a per-day bucket. Tiny helper; keeps the call
* site (transactional drain loop) readable.
*/
export function sumBucketValues(rows: readonly PerSampleRow[]): number {
let acc = 0;
for (const row of rows) acc += row.value;
return acc;
}
/**
* Run the drain. Idempotent — re-invocation after a successful drain
* reports zero buckets collapsed.
*
* The function does NOT enforce the admin gate or the operator-confirm
* flag — the CLI wrapper + the admin endpoint own those concerns.
*/
export async function drainPerSampleCumulative(
prismaClient: PrismaClient,
options: DrainOptions = {},
): Promise<DrainSummary> {
const dryRun = options.dryRun ?? false;
const log = options.log ?? ((line) => console.log(line));
const users = options.userId
? await prismaClient.user.findMany({
where: { id: options.userId },
select: { id: true, timezone: true },
})
: await prismaClient.user.findMany({
select: { id: true, timezone: true },
});
const summary: DrainSummary = {
dryRun,
buckets: [],
totals: {
usersScanned: users.length,
bucketsCollapsed: 0,
perSampleRowsDeleted: 0,
dailyRowsUpserted: 0,
},
};
for (const user of users) {
const tz = user.timezone && user.timezone.length > 0 ? user.timezone : "Europe/Berlin";
log(`[drain] user=${user.id} tz=${tz}${dryRun ? " (dry-run)" : ""}`);
for (const type of CUMULATIVE_HK_TYPES) {
const hkIdentifier = hkIdentifierForType(type);
if (!hkIdentifier) continue;
const perSampleRows = (await prismaClient.measurement.findMany({
where: {
userId: user.id,
source: "APPLE_HEALTH",
type,
},
select: {
id: true,
type: true,
value: true,
measuredAt: true,
externalId: true,
},
orderBy: { measuredAt: "asc" },
})) as PerSampleRow[];
if (perSampleRows.length === 0) continue;
const { byDay } = bucketRowsByUserDay(perSampleRows, tz);
for (const [dateKey, dayRows] of byDay) {
if (dayRows.length === 0) continue;
// Already-collapsed buckets are caught above (rows with
// externalId starting "stats:" never enter the bucket map).
// A single per-sample row left over is still drained — that
// way the externalId converges to the canonical shape even
// when iOS happened to emit exactly one sample on a quiet
// day.
const sumValue = sumBucketValues(dayRows);
const canonicalTs = canonicalDailyTimestamp(dateKey, tz);
const externalId = dailyStatsExternalId(hkIdentifier, dateKey);
const bucket: DrainBucket = {
userId: user.id,
type,
dateKey,
perSampleCount: dayRows.length,
sumValue,
canonicalTimestamp: canonicalTs.toISOString(),
externalId,
};
summary.buckets.push(bucket);
summary.totals.bucketsCollapsed += 1;
if (!dryRun) {
await prismaClient.$transaction(async (tx) => {
// Upsert the daily-aggregated row first, then drop the
// per-sample rows. The unique index
// (userId, type, source, externalId) makes the upsert
// idempotent across re-runs.
await tx.measurement.upsert({
where: {
userId_type_source_externalId: {
userId: user.id,
type,
source: "APPLE_HEALTH",
externalId,
},
},
create: {
userId: user.id,
type,
value: sumValue,
unit: dayRows[0]?.value !== undefined
? // pick the canonical unit from the existing row by
// looking it up via the mapping table; the per-sample
// rows all carry the same unit on a given type, so
// any of them would do.
await resolveCanonicalUnit(tx, user.id, type)
: "count",
source: "APPLE_HEALTH",
measuredAt: canonicalTs,
externalId,
},
update: {
value: sumValue,
measuredAt: canonicalTs,
},
});
// Delete the per-sample rows that contributed to the sum.
// Using `id IN (...)` is bounded by the per-bucket cap
// (the largest cumulative bucket in real data is a
// ~1 440-row stepCount day on a phone-only user).
const ids = dayRows.map((r) => r.id);
const del = await tx.measurement.deleteMany({
where: { id: { in: ids } },
});
summary.totals.perSampleRowsDeleted += del.count;
summary.totals.dailyRowsUpserted += 1;
});
} else {
summary.totals.perSampleRowsDeleted += dayRows.length;
summary.totals.dailyRowsUpserted += 1;
}
}
}
}
log(
`[drain] done — usersScanned=${summary.totals.usersScanned} bucketsCollapsed=${summary.totals.bucketsCollapsed} perSampleRowsDeleted=${summary.totals.perSampleRowsDeleted} dailyRowsUpserted=${summary.totals.dailyRowsUpserted}${dryRun ? " (dry-run)" : ""}`,
);
return summary;
}
/**
* Pull the canonical unit for a `(userId, type)` pair from an existing
* per-sample row. Used during the upsert's `create` branch when we
* need to populate `Measurement.unit` for the new aggregated row.
*/
async function resolveCanonicalUnit(
tx: Prisma.TransactionClient,
userId: string,
type: MeasurementType,
): Promise<string> {
const row = await tx.measurement.findFirst({
where: { userId, type, source: "APPLE_HEALTH" },
select: { unit: true },
});
return row?.unit ?? "count";
}
@@ -0,0 +1,157 @@
/**
* v1.4.30 — cross-source canonical-row picker for `Workout` rows.
*
* Same intent as `pickCanonicalSourceRows()` in
* `src/lib/analytics/source-priority.ts`, applied to workouts. A user
* wearing an Apple Watch + a Withings ScanWatch records the same run
* once via each integration; the dashboard "Recent workouts" tile
* would otherwise show the run twice.
*
* Algorithm (v1.4.30 — matches the measurement ladder per R-F open
* question #4):
* 1. Bucket workouts by the rounded start timestamp + sport-type
* (`<5-minute slot>:<sport>`). Two HKWorkout entries for the same
* logical run start within a minute of each other on both
* sources; the rounding gives the cross-source matcher slack
* without collapsing distinct back-to-back workouts.
* 2. For each bucket, walk the SOURCE priority ladder
* (`APPLE_HEALTH ≻ WITHINGS ≻ MANUAL ≻ IMPORT` per the existing
* source-priority validator's default) and pick the FIRST
* source that has any row in that bucket. Keep every row from
* the winning source; drop the rest.
* 3. Rows whose source isn't in the ladder pass through untouched
* (fallback intent — never silently drop signal).
*
* Determinism precondition: input rows MUST be in deterministic order
* (typically `ORDER BY startedAt ASC, id ASC` from the caller's
* Prisma read). The output preserves input order.
*
* Future v1.5.x tune: metric-aware picker per R-F open question #4 —
* route → Apple wins, HR zones → Withings wins. Out of scope for
* v1.4.30; the existing ladder ships first so the dashboard tile is
* unambiguous.
*/
import type {
MeasurementSource,
} from "@/generated/prisma/client";
import {
getSourceLadder,
parseSourcePriority,
} from "@/lib/validations/source-priority";
/**
* Minimum row shape — same posture as `SourcePickerRow`. The helper
* only consults the three fields; downstream callers can read every
* other column they fetched alongside.
*/
export interface WorkoutPickerRow {
startedAt: Date;
sportType: string;
source: MeasurementSource;
}
/**
* Bucket-slot length. Two workouts whose `startedAt` falls in the
* same 5-minute slot AND share the same sportType are treated as the
* "same" workout from different sources. Tightens further than the
* 1-minute clock skew between HK + Withings would suggest because
* iOS posts HK `startDate` rounded to the second but Withings stamps
* its `startdate` independently — five minutes gives both heads
* enough slack on a heavy-traffic morning sync without crossing the
* "two back-to-back runs" boundary (a user does NOT do two ten-minute
* runs five minutes apart in practice).
*/
const BUCKET_SLOT_MS = 5 * 60 * 1000;
function bucketKey(row: WorkoutPickerRow): string {
const slot = Math.floor(row.startedAt.getTime() / BUCKET_SLOT_MS);
return `${slot}:${row.sportType}`;
}
/**
* Pick canonical rows across sources for a user's workouts.
*
* @param rows input rows (deterministic order required)
* @param userPriorityJson the user's `sourcePriorityJson` blob — same
* shape the measurement picker consults.
* Falls back to the canonical default ladder
* when null / unparseable.
* @returns the subset of input rows that survives the picker. Insertion
* order preserved.
*/
export function pickCanonicalWorkoutRows<T extends WorkoutPickerRow>(
rows: readonly T[],
userPriorityJson: unknown = null,
): T[] {
if (rows.length === 0) return [];
// Workouts ride the `steps` metric's source ladder — the
// canonical-activity bucket. The user's iOS Settings → Sources UI
// configures the same ladder for the workout dashboard tile;
// unifying on "steps" keeps a single configuration surface rather
// than introducing a new `workouts` metric key. Future ladders for
// workout-specific tunes (route-vs-zones) ride a v1.5.x change.
const resolved = parseSourcePriority(userPriorityJson);
const sourceLadder = getSourceLadder(resolved, "steps");
const buckets = new Map<
string,
{
rows: T[];
sources: Set<MeasurementSource>;
}
>();
for (const row of rows) {
const key = bucketKey(row);
const slot = buckets.get(key) ?? { rows: [], sources: new Set() };
slot.rows.push(row);
slot.sources.add(row.source);
buckets.set(key, slot);
}
const canonical: T[] = [];
for (const [, slot] of buckets) {
if (slot.rows.length === 1) {
// No cross-source overlap → keep the single row regardless of
// ladder membership.
canonical.push(slot.rows[0]);
continue;
}
let picked: MeasurementSource | undefined;
for (const source of sourceLadder) {
if (slot.sources.has(source)) {
picked = source;
break;
}
}
if (!picked) {
// None of the bucket's sources are listed in the ladder — never
// silently drop. Keep every row in the bucket; downstream
// callers can choose to surface a "duplicate from unrecognised
// sources" diagnostic.
canonical.push(...slot.rows);
continue;
}
for (const row of slot.rows) {
if (row.source === picked) canonical.push(row);
}
}
// Re-sort to preserve input order — the bucket map iterates in
// insertion order but the per-bucket fan-out can interleave rows
// from later buckets ahead of earlier-bucket survivors. Caller
// wants the same `startedAt ASC` order it passed in.
canonical.sort((a, b) => {
const delta = a.startedAt.getTime() - b.startedAt.getTime();
if (delta !== 0) return delta;
// Stable-tiebreak on sportType so the output is deterministic
// even when two surviving rows share startedAt (rare; manual
// entries on the same second).
return a.sportType.localeCompare(b.sportType);
});
return canonical;
}
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { extractNoteAndSlimTags } from "../note-backfill";
describe("extractNoteAndSlimTags", () => {
it("extracts the first note:<text> entry and slims the tag array", () => {
expect(
extractNoteAndSlimTags(JSON.stringify(["coffee", "note:slept poorly", "workout"])),
).toEqual({
note: "slept poorly",
newTags: JSON.stringify(["coffee", "workout"]),
});
});
it("returns null tags when the slimmed array is empty", () => {
expect(extractNoteAndSlimTags(JSON.stringify(["note:lonely entry"]))).toEqual({
note: "lonely entry",
newTags: null,
});
});
it("returns note=null when no entry starts with note:", () => {
expect(extractNoteAndSlimTags(JSON.stringify(["coffee", "workout"]))).toEqual({
note: null,
newTags: JSON.stringify(["coffee", "workout"]),
});
});
it("extracts only the first note:... entry; further note-prefixed entries stay as tags", () => {
expect(
extractNoteAndSlimTags(JSON.stringify(["note:first", "note:second"])),
).toEqual({
note: "first",
newTags: JSON.stringify(["note:second"]),
});
});
it("returns the input string when it is not valid JSON", () => {
expect(extractNoteAndSlimTags("not-a-json-array")).toEqual({
note: null,
newTags: "not-a-json-array",
});
});
it("returns the input string when the JSON is not an array", () => {
expect(extractNoteAndSlimTags(JSON.stringify({ note: "x" }))).toEqual({
note: null,
newTags: JSON.stringify({ note: "x" }),
});
});
it("preserves the note prefix text exactly (no trim, no decode)", () => {
expect(
extractNoteAndSlimTags(JSON.stringify(["note: with leading space "])),
).toEqual({
note: " with leading space ",
newTags: null,
});
});
});
+40
View File
@@ -0,0 +1,40 @@
/**
* v1.4.30 — pure helper for the `MoodEntry.note` backfill (R-E H-5).
*
* Pulls the first `note:<text>` entry out of a JSON-encoded tags array
* and returns the cleaned tags JSON alongside the extracted note. Used
* by `scripts/backfill-mood-note-column.ts` (CLI) — kept in `src/lib/`
* so unit tests can exercise the bucketing without booting `dotenv`
* + the Prisma client.
*/
export function extractNoteAndSlimTags(tagsJson: string): {
note: string | null;
newTags: string | null;
} {
let parsed: unknown;
try {
parsed = JSON.parse(tagsJson);
} catch {
return { note: null, newTags: tagsJson };
}
if (!Array.isArray(parsed)) {
return { note: null, newTags: tagsJson };
}
let extracted: string | null = null;
const remaining: string[] = [];
for (const tag of parsed) {
if (typeof tag !== "string") {
remaining.push(String(tag));
continue;
}
if (extracted === null && tag.startsWith("note:")) {
extracted = tag.slice("note:".length);
} else {
remaining.push(tag);
}
}
return {
note: extracted,
newTags: remaining.length > 0 ? JSON.stringify(remaining) : null,
};
}
+9
View File
@@ -55,6 +55,10 @@ export function getPRDirection(
case "BONE_MASS":
case "MUSCLE_MASS":
case "TIME_IN_DAYLIGHT":
// v1.4.30 — walking steadiness on a 0-100 scale; Apple's own
// Mobility section treats higher as the achievement (the
// recovery direction from a low-steadiness window).
case "WALKING_STEADINESS":
return PersonalRecordDirection.MAX;
// MIN direction — lower value is the record.
@@ -79,6 +83,11 @@ export function getPRDirection(
case "WEIGHT":
case "SLEEP_DURATION":
case "FAT_FREE_MASS":
// v1.4.30 — audio-exposure events fire on a threshold-cross, not
// on a goal axis. A "record" for "fewest loud-listening days in
// a row" is interesting but better surfaced as a streak than a
// PR direction. Defer.
case "AUDIO_EXPOSURE_EVENT":
return null;
}
}
+3
View File
@@ -32,6 +32,9 @@ export const measurementTypeEnum = z.enum([
"AUDIO_EXPOSURE_ENV",
"AUDIO_EXPOSURE_HEADPHONE",
"TIME_IN_DAYLIGHT",
// ── v1.4.30 R-F T1.4 + T1.5 ──
"WALKING_STEADINESS",
"AUDIO_EXPOSURE_EVENT",
]);
export const glucoseContextEnum = z.enum([
+5
View File
@@ -81,6 +81,10 @@ export function getScoreForMood(mood: string): number {
export const createMoodEntrySchema = z.object({
mood: moodLevelEnum,
tags: z.array(z.string().max(50)).max(20).optional(),
// v1.4.30 H-5 — first-class free-text note. Replaces the
// `tags: ["note:<text>"]` workaround. Capped at 500 chars so the
// Coach evidence shelf renders cleanly without truncating chips.
note: z.string().max(500).optional(),
moodLoggedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
source: moodSourceEnum.optional().default("MANUAL"),
});
@@ -88,6 +92,7 @@ export const createMoodEntrySchema = z.object({
export const updateMoodEntrySchema = z.object({
mood: moodLevelEnum.optional(),
tags: z.array(z.string().max(50)).max(20).nullable().optional(),
note: z.string().max(500).nullable().optional(),
moodLoggedAt: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
@@ -0,0 +1,188 @@
/**
* v1.4.30 — `POST /api/admin/drain-per-sample-cumulative` real-
* Postgres integration. The library-level helper rides
* `tests/integration/drain-per-sample-cumulative.test.ts`; this
* suite pins the route's admin gate + payload shape + audit log
* trail end-to-end.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
const ADMIN_USER_ID = "user-admin-drain";
const STANDARD_USER_ID = "user-standard-drain";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
await getPrismaClient().user.createMany({
data: [
{
id: ADMIN_USER_ID,
username: "admin-drain",
email: "admin-drain@example.test",
role: "ADMIN",
timezone: "Europe/Berlin",
},
{
id: STANDARD_USER_ID,
username: "standard-drain",
email: "standard-drain@example.test",
role: "USER",
timezone: "Europe/Berlin",
},
],
});
});
async function setSessionFor(userId: string): Promise<void> {
const session = await getPrismaClient().session.create({
data: {
userId,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.clear();
cookieJar.set("healthlog_session", session.id);
}
function makeRequest(body: unknown): NextRequest {
return new NextRequest(
"http://localhost/api/admin/drain-per-sample-cumulative",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
},
);
}
describe("POST /api/admin/drain-per-sample-cumulative (real Postgres)", () => {
it("returns 403 when the session belongs to a standard (non-admin) user", async () => {
await setSessionFor(STANDARD_USER_ID);
const { POST } = await import(
"@/app/api/admin/drain-per-sample-cumulative/route"
);
const res = await POST(makeRequest({ dryRun: true }));
expect(res.status).toBe(403);
});
it("dry-runs by default and reports buckets without writing", async () => {
const prisma = getPrismaClient();
await prisma.measurement.createMany({
data: [
{
userId: STANDARD_USER_ID,
type: "ACTIVITY_STEPS",
value: 1200,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T08:00:00.000Z"),
externalId: "hk-uuid-1",
},
{
userId: STANDARD_USER_ID,
type: "ACTIVITY_STEPS",
value: 800,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T14:00:00.000Z"),
externalId: "hk-uuid-2",
},
],
});
await setSessionFor(ADMIN_USER_ID);
const { POST } = await import(
"@/app/api/admin/drain-per-sample-cumulative/route"
);
// No body = dryRun defaults to true.
const res = await POST(makeRequest({ userId: STANDARD_USER_ID }));
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: {
dryRun: boolean;
totals: { bucketsCollapsed: number };
};
};
expect(json.data.dryRun).toBe(true);
expect(json.data.totals.bucketsCollapsed).toBe(1);
// DB unchanged.
const remaining = await prisma.measurement.findMany({
where: { userId: STANDARD_USER_ID, type: "ACTIVITY_STEPS" },
});
expect(remaining).toHaveLength(2);
});
it("commits when dryRun=false is passed explicitly", async () => {
const prisma = getPrismaClient();
await prisma.measurement.createMany({
data: [
{
userId: STANDARD_USER_ID,
type: "ACTIVITY_STEPS",
value: 1200,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T08:00:00.000Z"),
externalId: "hk-uuid-1",
},
{
userId: STANDARD_USER_ID,
type: "ACTIVITY_STEPS",
value: 800,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T14:00:00.000Z"),
externalId: "hk-uuid-2",
},
],
});
await setSessionFor(ADMIN_USER_ID);
const { POST } = await import(
"@/app/api/admin/drain-per-sample-cumulative/route"
);
const res = await POST(
makeRequest({ userId: STANDARD_USER_ID, dryRun: false }),
);
expect(res.status).toBe(200);
const remaining = await prisma.measurement.findMany({
where: { userId: STANDARD_USER_ID, type: "ACTIVITY_STEPS" },
});
expect(remaining).toHaveLength(1);
expect(remaining[0].value).toBe(2000);
expect(remaining[0].externalId).toBe(
"stats:HKQuantityTypeIdentifierStepCount:2026-05-16",
);
});
});
@@ -0,0 +1,249 @@
/**
* v1.4.30 — real-Postgres integration coverage for
* `drainPerSampleCumulative()`. Mirrors the
* `measurements-aggregate-daily` fixture: the testcontainer is hot
* before this file loads, so the body just truncates + seeds + drains.
*
* The drain logic operates against `prisma.$transaction` with an upsert
* inside; mocking that surface would re-introduce the v1.4.29 lesson
* (mocks hide real-SQL behaviour). The container test pins the
* idempotent-re-run contract end-to-end.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import { drainPerSampleCumulative } from "@/lib/measurements/drain-per-sample-cumulative";
const TEST_USER_ID = "user-drain-cumulative";
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
await getPrismaClient().user.create({
data: {
id: TEST_USER_ID,
username: "drain-cumulative",
email: "drain-cumulative@example.test",
timezone: "Europe/Berlin",
},
});
});
describe("drainPerSampleCumulative (real Postgres)", () => {
it("collapses three per-sample step rows on the same day into one daily row", async () => {
const prisma = getPrismaClient();
await prisma.measurement.createMany({
data: [
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 1200,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T08:00:00.000Z"),
externalId: "hk-uuid-1",
},
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 3400,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T14:00:00.000Z"),
externalId: "hk-uuid-2",
},
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 800,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T20:00:00.000Z"),
externalId: "hk-uuid-3",
},
],
});
const summary = await drainPerSampleCumulative(prisma, {
userId: TEST_USER_ID,
dryRun: false,
log: () => {},
});
expect(summary.dryRun).toBe(false);
expect(summary.totals.bucketsCollapsed).toBe(1);
expect(summary.totals.perSampleRowsDeleted).toBe(3);
expect(summary.totals.dailyRowsUpserted).toBe(1);
const remaining = await prisma.measurement.findMany({
where: {
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
source: "APPLE_HEALTH",
},
orderBy: { measuredAt: "asc" },
});
expect(remaining).toHaveLength(1);
expect(remaining[0].value).toBe(5400);
expect(remaining[0].externalId).toBe(
"stats:HKQuantityTypeIdentifierStepCount:2026-05-16",
);
// 12:00 Berlin during DST = 10:00 UTC.
expect(remaining[0].measuredAt.toISOString()).toBe(
"2026-05-16T10:00:00.000Z",
);
});
it("is idempotent — a second run after a successful drain is a no-op", async () => {
const prisma = getPrismaClient();
await prisma.measurement.createMany({
data: [
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 1200,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T08:00:00.000Z"),
externalId: "hk-uuid-1",
},
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 800,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T14:00:00.000Z"),
externalId: "hk-uuid-2",
},
],
});
await drainPerSampleCumulative(prisma, {
userId: TEST_USER_ID,
dryRun: false,
log: () => {},
});
const second = await drainPerSampleCumulative(prisma, {
userId: TEST_USER_ID,
dryRun: false,
log: () => {},
});
expect(second.totals.bucketsCollapsed).toBe(0);
expect(second.totals.perSampleRowsDeleted).toBe(0);
expect(second.totals.dailyRowsUpserted).toBe(0);
});
it("dry-run reports the plan without touching the DB", async () => {
const prisma = getPrismaClient();
await prisma.measurement.createMany({
data: [
{
userId: TEST_USER_ID,
type: "FLIGHTS_CLIMBED",
value: 5,
unit: "flights",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T08:00:00.000Z"),
externalId: "hk-flights-1",
},
{
userId: TEST_USER_ID,
type: "FLIGHTS_CLIMBED",
value: 3,
unit: "flights",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T14:00:00.000Z"),
externalId: "hk-flights-2",
},
],
});
const summary = await drainPerSampleCumulative(prisma, {
userId: TEST_USER_ID,
dryRun: true,
log: () => {},
});
expect(summary.dryRun).toBe(true);
expect(summary.totals.bucketsCollapsed).toBe(1);
expect(summary.buckets[0].sumValue).toBe(8);
expect(summary.buckets[0].externalId).toBe(
"stats:HKQuantityTypeIdentifierFlightsClimbed:2026-05-16",
);
// DB unchanged.
const remaining = await prisma.measurement.findMany({
where: { userId: TEST_USER_ID, type: "FLIGHTS_CLIMBED" },
});
expect(remaining).toHaveLength(2);
});
it("ignores spot metrics (PULSE) — only cumulative types collapse", async () => {
const prisma = getPrismaClient();
await prisma.measurement.createMany({
data: [
{
userId: TEST_USER_ID,
type: "PULSE",
value: 64,
unit: "bpm",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T08:00:00.000Z"),
externalId: "hk-pulse-1",
},
{
userId: TEST_USER_ID,
type: "PULSE",
value: 72,
unit: "bpm",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T14:00:00.000Z"),
externalId: "hk-pulse-2",
},
],
});
const summary = await drainPerSampleCumulative(prisma, {
userId: TEST_USER_ID,
dryRun: false,
log: () => {},
});
expect(summary.totals.bucketsCollapsed).toBe(0);
const remaining = await prisma.measurement.findMany({
where: { userId: TEST_USER_ID, type: "PULSE" },
});
expect(remaining).toHaveLength(2);
});
it("skips rows already in stats:... shape (re-running after an earlier drain)", async () => {
const prisma = getPrismaClient();
await prisma.measurement.create({
data: {
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 5400,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-16T10:00:00.000Z"),
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-05-16",
},
});
const summary = await drainPerSampleCumulative(prisma, {
userId: TEST_USER_ID,
dryRun: false,
log: () => {},
});
expect(summary.totals.bucketsCollapsed).toBe(0);
const remaining = await prisma.measurement.findMany({
where: { userId: TEST_USER_ID, type: "ACTIVITY_STEPS" },
});
expect(remaining).toHaveLength(1);
expect(remaining[0].value).toBe(5400);
});
});
@@ -0,0 +1,187 @@
/**
* v1.4.30 — `POST /api/medications/intake/bulk` real-Postgres
* integration.
*
* Asserts the iOS SyncMode bulk-backfill contract:
* - inserts a clean batch
* - skips entries that point at a medication the user doesn't own
* - returns `duplicate` when an idempotencyKey is re-used
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
const TEST_USER_ID = "user-medications-intake-bulk";
const OTHER_USER_ID = "user-medications-intake-other";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
let ownedMedId = "";
let foreignMedId = "";
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
const prisma = getPrismaClient();
await prisma.user.createMany({
data: [
{
id: TEST_USER_ID,
username: "intake-bulk",
email: "intake-bulk@example.test",
timezone: "Europe/Berlin",
},
{
id: OTHER_USER_ID,
username: "intake-other",
email: "intake-other@example.test",
timezone: "Europe/Berlin",
},
],
});
const owned = await prisma.medication.create({
data: {
userId: TEST_USER_ID,
name: "Mounjaro",
dose: "5mg",
active: true,
},
});
ownedMedId = owned.id;
const foreign = await prisma.medication.create({
data: {
userId: OTHER_USER_ID,
name: "Levothyroxin",
dose: "50µg",
active: true,
},
});
foreignMedId = foreign.id;
const session = await prisma.session.create({
data: {
userId: TEST_USER_ID,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
});
function makeRequest(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/medications/intake/bulk", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
describe("POST /api/medications/intake/bulk (real Postgres)", () => {
it("inserts a clean batch", async () => {
const { POST } = await import("@/app/api/medications/intake/bulk/route");
const res = await POST(
makeRequest({
entries: [
{
medicationId: ownedMedId,
scheduledFor: "2026-05-16T08:00:00.000Z",
takenAt: "2026-05-16T08:02:00.000Z",
},
{
medicationId: ownedMedId,
scheduledFor: "2026-05-17T08:00:00.000Z",
skipped: true,
},
],
}),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: { processed: number; inserted: number; duplicates: number };
};
expect(json.data.processed).toBe(2);
expect(json.data.inserted).toBe(2);
const stored = await getPrismaClient().medicationIntakeEvent.findMany({
where: { userId: TEST_USER_ID },
});
expect(stored).toHaveLength(2);
});
it("skips entries that reference a medication the user doesn't own", async () => {
const { POST } = await import("@/app/api/medications/intake/bulk/route");
const res = await POST(
makeRequest({
entries: [
{
medicationId: ownedMedId,
scheduledFor: "2026-05-16T08:00:00.000Z",
},
{
medicationId: foreignMedId,
scheduledFor: "2026-05-16T08:00:00.000Z",
},
],
}),
);
const json = (await res.json()) as {
data: {
inserted: number;
skipped: Array<{ index: number; reason: string }>;
};
};
expect(json.data.inserted).toBe(1);
expect(json.data.skipped).toEqual([
{ index: 1, reason: "medication_not_found" },
]);
});
it("returns `duplicate` when an idempotencyKey is re-used", async () => {
const { POST } = await import("@/app/api/medications/intake/bulk/route");
const body = {
entries: [
{
medicationId: ownedMedId,
scheduledFor: "2026-05-16T08:00:00.000Z",
idempotencyKey: "ios-sync-key-001",
},
],
};
await POST(makeRequest(body));
const res = await POST(makeRequest(body));
const json = (await res.json()) as {
data: { duplicates: number; inserted: number };
};
expect(json.data.duplicates).toBe(1);
expect(json.data.inserted).toBe(0);
const stored = await getPrismaClient().medicationIntakeEvent.findMany({
where: { userId: TEST_USER_ID },
});
expect(stored).toHaveLength(1);
});
});
+170
View File
@@ -0,0 +1,170 @@
/**
* v1.4.30 — `POST /api/mood-entries/bulk` real-Postgres integration.
*
* Asserts the iOS SyncMode bulk-backfill contract:
* - inserts a clean batch
* - upserts on re-post (last-writer-wins on mood / tags)
* - rejects oversize batches
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
const TEST_USER_ID = "user-mood-bulk";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
await getPrismaClient().user.create({
data: {
id: TEST_USER_ID,
username: "mood-bulk",
email: "mood-bulk@example.test",
timezone: "Europe/Berlin",
},
});
const session = await getPrismaClient().session.create({
data: {
userId: TEST_USER_ID,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
});
function makeRequest(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/mood-entries/bulk", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
describe("POST /api/mood-entries/bulk (real Postgres)", () => {
it("inserts a clean batch of three entries", async () => {
const { POST } = await import("@/app/api/mood-entries/bulk/route");
const res = await POST(
makeRequest({
entries: [
{
mood: "GUT",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
},
{
mood: "OKAY",
moodLoggedAt: "2026-05-16T14:00:00.000Z",
tags: ["coffee"],
},
{
mood: "SUPER_GUT",
moodLoggedAt: "2026-05-16T20:00:00.000Z",
source: "DAYLIO",
},
],
}),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: {
processed: number;
inserted: number;
duplicates: number;
entries: Array<{ status: string }>;
};
};
expect(json.data.processed).toBe(3);
expect(json.data.inserted).toBe(3);
expect(json.data.duplicates).toBe(0);
const stored = await getPrismaClient().moodEntry.findMany({
where: { userId: TEST_USER_ID },
orderBy: { moodLoggedAt: "asc" },
});
expect(stored).toHaveLength(3);
expect(stored[0].mood).toBe("GUT");
expect(stored[0].score).toBe(4);
expect(stored[0].tz).toBe("Europe/Berlin");
});
it("upserts when the same (userId, date, moodLoggedAt) tuple is re-posted", async () => {
const { POST } = await import("@/app/api/mood-entries/bulk/route");
const body = {
entries: [
{
mood: "OKAY",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
},
],
};
await POST(makeRequest(body));
const res = await POST(
makeRequest({
entries: [
{
// Same timestamp + user but a different mood.
mood: "SUPER_GUT",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
tags: ["workout"],
},
],
}),
);
const json = (await res.json()) as {
data: { duplicates: number; inserted: number };
};
expect(json.data.duplicates).toBe(1);
expect(json.data.inserted).toBe(0);
const stored = await getPrismaClient().moodEntry.findMany({
where: { userId: TEST_USER_ID },
});
expect(stored).toHaveLength(1);
expect(stored[0].mood).toBe("SUPER_GUT");
expect(stored[0].score).toBe(5);
});
it("rejects an over-cap batch with 422 and the documented error code", async () => {
const { POST } = await import("@/app/api/mood-entries/bulk/route");
const entries = Array.from({ length: 501 }, (_, i) => ({
mood: "OKAY",
moodLoggedAt: new Date(2026, 4, 16, 8, i % 60).toISOString(),
}));
const res = await POST(makeRequest({ entries }));
expect(res.status).toBe(422);
const json = (await res.json()) as {
error: string;
meta?: { errorCode?: string };
};
expect(json.meta?.errorCode).toBe("mood.bulk.too_large");
});
});
@@ -0,0 +1,169 @@
/**
* v1.4.30 — `MoodEntry.note` column round-trip integration.
*
* Asserts:
* - POST /api/mood-entries accepts the new `note` field
* - PUT /api/mood-entries/[id] updates `note` (and accepts null to clear)
* - POST /api/mood-entries/bulk persists `note` per entry
* - The Zod cap (500 chars) rejects oversize prose
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
const TEST_USER_ID = "user-mood-note";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
await getPrismaClient().user.create({
data: {
id: TEST_USER_ID,
username: "mood-note",
email: "mood-note@example.test",
timezone: "Europe/Berlin",
},
});
const session = await getPrismaClient().session.create({
data: {
userId: TEST_USER_ID,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
});
function postRequest(path: string, body: unknown): NextRequest {
return new NextRequest(`http://localhost${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
function putRequest(path: string, body: unknown): NextRequest {
return new NextRequest(`http://localhost${path}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
describe("MoodEntry.note round-trip (real Postgres)", () => {
it("POST /api/mood-entries persists `note`", async () => {
const { POST } = await import("@/app/api/mood-entries/route");
const res = await POST(
postRequest("/api/mood-entries", {
mood: "GUT",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
note: "Long run before breakfast.",
}),
);
expect(res.status).toBe(201);
const stored = await getPrismaClient().moodEntry.findFirst({
where: { userId: TEST_USER_ID },
});
expect(stored?.note).toBe("Long run before breakfast.");
});
it("PUT /api/mood-entries/[id] updates `note` and accepts null to clear it", async () => {
const created = await getPrismaClient().moodEntry.create({
data: {
userId: TEST_USER_ID,
date: "2026-05-16",
tz: "Europe/Berlin",
mood: "OKAY",
score: 3,
moodLoggedAt: new Date("2026-05-16T08:00:00.000Z"),
note: "initial note",
},
});
const { PUT } = await import("@/app/api/mood-entries/[id]/route");
const res1 = await PUT(
putRequest(`/api/mood-entries/${created.id}`, { note: "revised note" }),
{ params: Promise.resolve({ id: created.id }) },
);
expect(res1.status).toBe(200);
const after1 = await getPrismaClient().moodEntry.findUnique({
where: { id: created.id },
});
expect(after1?.note).toBe("revised note");
const res2 = await PUT(
putRequest(`/api/mood-entries/${created.id}`, { note: null }),
{ params: Promise.resolve({ id: created.id }) },
);
expect(res2.status).toBe(200);
const after2 = await getPrismaClient().moodEntry.findUnique({
where: { id: created.id },
});
expect(after2?.note).toBeNull();
});
it("POST /api/mood-entries/bulk persists `note` per entry", async () => {
const { POST } = await import("@/app/api/mood-entries/bulk/route");
const res = await POST(
postRequest("/api/mood-entries/bulk", {
entries: [
{
mood: "GUT",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
note: "Morning",
},
{
mood: "OKAY",
moodLoggedAt: "2026-05-16T14:00:00.000Z",
note: "Afternoon dip",
},
],
}),
);
expect(res.status).toBe(200);
const stored = await getPrismaClient().moodEntry.findMany({
where: { userId: TEST_USER_ID },
orderBy: { moodLoggedAt: "asc" },
});
expect(stored.map((r) => r.note)).toEqual(["Morning", "Afternoon dip"]);
});
it("rejects an oversize note (501 chars) with 422", async () => {
const { POST } = await import("@/app/api/mood-entries/route");
const oversize = "x".repeat(501);
const res = await POST(
postRequest("/api/mood-entries", {
mood: "OKAY",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
note: oversize,
}),
);
expect(res.status).toBe(422);
});
});
+137
View File
@@ -0,0 +1,137 @@
/**
* v1.4.30 — `GET /api/sync/state` real-Postgres integration.
*
* The endpoint is the iOS SyncMode handshake. Asserts:
* - first call returns `lastSyncedAt: null` then writes the
* checkpoint
* - subsequent call sees the previous checkpoint
* - measurement counters distinguish live vs tombstoned rows
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
const TEST_USER_ID = "user-sync-state";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
await getPrismaClient().user.create({
data: {
id: TEST_USER_ID,
username: "sync-state",
email: "sync-state@example.test",
timezone: "Europe/Berlin",
},
});
const session = await getPrismaClient().session.create({
data: {
userId: TEST_USER_ID,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
});
function makeRequest(): NextRequest {
return new NextRequest("http://localhost/api/sync/state", { method: "GET" });
}
describe("GET /api/sync/state (real Postgres)", () => {
it("returns lastSyncedAt: null on the first call and writes the checkpoint", async () => {
const { GET } = await import("@/app/api/sync/state/route");
const res = await GET(makeRequest());
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: {
userId: string;
timezone: string;
lastSyncedAt: string | null;
serverNow: string;
measurements: { lastUpdatedAt: string | null; liveCount: number; tombstonedCount: number };
};
};
expect(json.data.userId).toBe(TEST_USER_ID);
expect(json.data.timezone).toBe("Europe/Berlin");
expect(json.data.lastSyncedAt).toBeNull();
expect(json.data.measurements.liveCount).toBe(0);
expect(json.data.measurements.tombstonedCount).toBe(0);
// Server-side, lastSyncedAt now carries a value.
const after = await getPrismaClient().user.findUnique({
where: { id: TEST_USER_ID },
select: { lastSyncedAt: true },
});
expect(after?.lastSyncedAt).not.toBeNull();
});
it("returns the previous checkpoint on the second call", async () => {
const { GET } = await import("@/app/api/sync/state/route");
await GET(makeRequest());
const res = await GET(makeRequest());
const json = (await res.json()) as {
data: { lastSyncedAt: string | null };
};
expect(json.data.lastSyncedAt).not.toBeNull();
});
it("distinguishes live vs tombstoned measurement counts", async () => {
const prisma = getPrismaClient();
await prisma.measurement.createMany({
data: [
{
userId: TEST_USER_ID,
type: "PULSE",
value: 64,
unit: "bpm",
source: "MANUAL",
measuredAt: new Date("2026-05-16T10:00:00.000Z"),
},
{
userId: TEST_USER_ID,
type: "PULSE",
value: 70,
unit: "bpm",
source: "MANUAL",
measuredAt: new Date("2026-05-16T11:00:00.000Z"),
deletedAt: new Date("2026-05-16T12:00:00.000Z"),
},
],
});
const { GET } = await import("@/app/api/sync/state/route");
const res = await GET(makeRequest());
const json = (await res.json()) as {
data: { measurements: { liveCount: number; tombstonedCount: number } };
};
expect(json.data.measurements.liveCount).toBe(1);
expect(json.data.measurements.tombstonedCount).toBe(1);
});
});