mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
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.
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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
@@ -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';
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user