docs(planning): v1.4.34 IW reports + cache-aggregation research

Backfills the planning trail for v1.4.34: IW-C / IW-D / IW-G work-item
reports, the production slowness HAR investigation, the AASA rollout
trace from v1.4.33, and three pieces of research that informed the
follow-on perf work (XML import, carryover scope, cache aggregation).

The cache-aggregation research (research/v1434-r-cache-aggregation.md)
designed the per-route LRU pattern that IW-G shipped and is the
direct ancestor of the comprehensive-route rewrite this release lands.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 22:25:06 +02:00
1 parent 7bbcc872f8
commit f535eb0e42
8 files changed
+2484

No files matched your search

+743
View File
@@ -0,0 +1,743 @@
# R-1 — Apple Health `export.zip` import (v1.4.34)
Implementation blueprint for the streaming Apple Health XML import that
lands immediately before the web-freeze marker. The brief in
`.planning/v15-strategic-plan.md` §2 carves the scope; this document
fills in the parser pick, the queue model, the parse loop, the
ingestion shape, the response envelope, the admin variant, and the
freeze-marker wording. Every section names files, dependencies, and
the upstream code the implementation reuses.
## 1. Recommendation summary
- Parser pick: `sax` (event-driven SAX, no DOM, peak RSS independent
of file size). `fast-xml-parser` already shipped transitively in
`pnpm-lock.yaml` line 3955 but its streaming mode buffers per
element which still allocates for the ~10 M `<Record>` peak inside
a multi-year export. `sax` is purpose-built for "drain a large XML
file with bounded memory". Bundle is ~30 kB.
- Archive unpack: Node 22 ships a `node:zlib` `inflateRawSync` that
pairs with a small inline ZIP central-directory walker; we add no
new archive dependency. The implementation extracts `export.xml`
directly from the upload stream rather than materialising the full
zip on disk.
- Async-job storage pick: `pg-boss` (already in dependencies at
`^12.18.2`, full worker scaffolding live in
`src/lib/jobs/reminder-worker.ts`). Reuse the existing
`boss.send` / `boss.work` / `boss.getJobById` surface; no new
process, no new infra footprint.
- Job phases: `queued → unpacking → parsing → upserting → done` with
a single `failed` terminal. Per-phase progress carried on the
`pg-boss` job `output` JSON column (pg-boss v12 supports incremental
`boss.complete(jobId, output)` only at completion; we therefore
also maintain a small `ImportJob` Prisma row keyed by `pg-boss`
`jobId` for live progress reads).
- Effort: L (~3-4 days) — matches §2 of the strategic plan.
## 2. `export.zip` archive layout
Every Apple Health export the user creates via *Health.app → profile
picture → Export All Health Data* unzips to a single top-level
`apple_health_export/` directory. Members observed across every
ecosystem-scan reference (`apple-health-grafana`,
`healthkit-to-sqlite`, `apple-health-parser`, `BRO3886/healthsync`,
`atlas`, `health-data-hub`):
| Member | Purpose | v1.4.34 handling |
|---|---|---|
| `apple_health_export/export.xml` | The full sample stream — ~95 % of payload by bytes. Single self-contained XML file. Every `<Record>`, `<Workout>`, `<ActivitySummary>`, `<Correlation>`, `<ClinicalRecord>` lives here. | **Primary** parse target. |
| `apple_health_export/export_cda.xml` | Continuity-of-Care Document (HL7 CDA R2). Duplicate of the clinical subset already mirrored as `<ClinicalRecord>` rows in `export.xml`. | Ignore — clinical defers per R-F T3. |
| `apple_health_export/workout-routes/route_*.gpx` | One GPX file per `HKWorkoutRoute` (referenced from the matching `<Workout>` via `<FileReference path="workout-routes/route_2024-05-01_15.05.gpx"/>`). | Parse if the matching `<Workout>` survives mapping; persist as `WorkoutRoute.geometry` GeoJSON. |
| `apple_health_export/electrocardiograms/ecg_*.csv` | One CSV per HKElectrocardiogram. | Skip — no HealthLog ECG model; record count under `unknown.electrocardiogram`. |
| `apple_health_export/clinical-records/*.json` | FHIR R4 resources mirroring `<ClinicalRecord>` blocks. | Skip — defers per R-F T3. |
| `apple_health_export/export.zip.checksum` | Optional integrity marker (recent iOS versions). | Ignore. |
The parser logs every unrecognised member name under `unknown.<name>`
in the response so we can spot new directory members on future iOS
releases without breaking the import.
## 3. `export.xml` schema (parser-facing data model)
The single XML file opens with the literal sequence:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE HealthData [
<!ELEMENT HealthData (ExportDate, Me, (Record|Correlation|Workout|ActivitySummary|ClinicalRecord)*)>
...
]>
<HealthData locale="en_US">
<ExportDate value="2026-05-15 14:32:01 +0200"/>
<Me HKCharacteristicTypeIdentifierDateOfBirth="1985-06-12" .../>
<Record type="HKQuantityTypeIdentifierStepCount"
sourceName="Marc's iPhone"
sourceVersion="17.4.1"
device="<<HKDevice: 0x281a45fc0>, name:iPhone, manufacturer:Apple Inc., model:iPhone, hardware:iPhone15,2, software:17.4.1>"
unit="count"
creationDate="2026-05-14 08:14:23 +0200"
startDate="2026-05-14 08:13:00 +0200"
endDate="2026-05-14 08:14:00 +0200"
value="142">
<MetadataEntry key="HKMetadataKeyExternalUUID" value="A3F9D8E2-..."/>
</Record>
...
</HealthData>
```
Element model the parser must handle:
- `<HealthData>` — root, single instance. Attribute `locale` is
advisory.
- `<ExportDate>` — single `value` attribute (string-formatted local
date with timezone offset). Stored on the import job for audit.
- `<Me>` — characteristic attributes (DOB, biological sex, blood
type, Fitzpatrick skin type, wheelchair-use). v1.4.34 ignores;
HealthLog has its own profile fields and overwriting them mid-
import would surprise the user.
- `<Record type="..."/>` — the bulk of the stream. The `type`
attribute carries the literal `HKQuantityTypeIdentifier*` or
`HKCategoryTypeIdentifier*` string already handled by
`APPLE_HEALTH_TYPE_MAP` in
`src/lib/measurements/apple-health-mapping.ts`. Every Record
carries `startDate`, `endDate`, `value`, `unit`, `sourceName`,
`sourceVersion`, `creationDate`, `device`. Sleep-stage Records
carry `value` as a string codepoint (`"HKCategoryValueSleepAnalysis"
+ "InBed" | "Asleep" | "Awake" | "AsleepCore" | "AsleepDeep" |
"AsleepREM"`); the parser converts the string back to the integer
codepoint via a small table so it can ride
`APPLE_HEALTH_SLEEP_STAGE_MAP`. Children: zero or more
`<MetadataEntry key="..." value="..."/>`. The `HKMetadataKeyExternalUUID`
entry, when present, is the `HKSample.uuid`; otherwise the parser
hashes `(type, value, startDate, endDate)` to a stable
`externalId`.
- `<Workout workoutActivityType="..."/>` — one per `HKWorkout`.
Attributes: `workoutActivityType` (the `HKWorkoutActivityType`
string, e.g. `HKWorkoutActivityTypeRunning`), `duration`,
`durationUnit`, `totalDistance`, `totalDistanceUnit`,
`totalEnergyBurned`, `totalEnergyBurnedUnit`, `sourceName`,
`sourceVersion`, `device`, `creationDate`, `startDate`, `endDate`.
Children: `<MetadataEntry/>` (zero or more, `HKMetadataKeyExternalUUID`
again the natural id), `<WorkoutEvent type="HKWorkoutEventType*"
date="..." duration="..."/>` (pause / resume / lap markers),
`<WorkoutRoute startDate="..." endDate="..."/>` with one nested
`<FileReference path="workout-routes/route_*.gpx"/>` linking to the
GPX file.
- `<ActivitySummary dateComponents="2026-05-14"
activeEnergyBurned="412" activeEnergyBurnedGoal="500"
activeEnergyBurnedUnit="kcal" appleMoveTime="0"
appleMoveTimeGoal="0" appleExerciseTime="38"
appleExerciseTimeGoal="30" appleStandHours="11"
appleStandHoursGoal="12"/>` — Apple's daily ring rollup. v1.4.34
ignores (HealthLog computes its own daily aggregates from
`<Record>` rows via the existing `dailyStatsExternalId` path).
- `<Correlation type="HKCorrelationTypeIdentifierBloodPressure"
startDate="..." endDate="..."/>` with two nested `<Record>` children
(`HKQuantityTypeIdentifierBloodPressureSystolic` +
`HKQuantityTypeIdentifierBloodPressureDiastolic`). The parser
flattens the Correlation envelope and treats each child Record
exactly like a top-level Record — the systolic / diastolic mapping
in `APPLE_HEALTH_TYPE_MAP` already handles the row shape.
- `<ClinicalRecord type="HKClinicalTypeIdentifier*" identifier="..."
fhirResource="...">...</ClinicalRecord>` — FHIR clinical records.
Skipped per R-F T3 (defers to v1.6+).
## 4. Parser pick
Three Node candidates were considered:
| Lib | Mode | Peak RSS at 1 GB input | TypeScript | Existing dep? |
|---|---|---|---|---|
| `sax` | event SAX (callback per token) | ~30 MB constant | ships `.d.ts` from `@types/sax` | no (one new dep) |
| `fast-xml-parser` (streaming mode) | per-element callback over a `XMLParser` with `processEntities: false` | ~150 MB at 1 GB — buffers each element | first-class TS | yes (transitive) |
| `node-expat` | C++ libexpat bindings | ~25 MB constant | manual `.d.ts` | no — requires node-gyp build |
Pick: **`sax` (~0.4 MB install size, MIT, last release 2024-06)**. The
SAX callback model gives us per-token streaming (each
`onopentag` / `onclosetag` / `ontext` fires as the parser advances
the byte cursor), peak RSS stays ~30 MB regardless of file size,
TypeScript types exist as `@types/sax`. `node-expat` would be marginally
faster but the node-gyp build step breaks the Coolify Docker image
(we explicitly keep the build chain extension-free per the same
decision that picked GeoJSON over PostGIS for `WorkoutRoute`). The
transitive `fast-xml-parser` stays where it is for HTML-ish
fast-path usage elsewhere; we don't add it to the import path.
`pnpm add sax @types/sax` lands two new lines in `package.json`. Both
under 50 kB unpacked.
## 5. Async-job architecture
Reuse the live `pg-boss` infrastructure verbatim. The implementation
adds two queues and one Prisma model:
### 5.1 New `pg-boss` queue: `apple-health-import`
Register in `src/lib/jobs/reminder-worker.ts:1432-1454` next to the
existing `MEDICATION_INVENTORY_EXPIRE_QUEUE` block. Concurrency cap
`1` per host (one giant XML at a time per worker — the parse loop is
CPU-bound and an OOM on a second concurrent 1 GB import would knock
the host over). Retry policy: `retryLimit: 0` (a re-import is a
human-driven re-upload, not an automatic retry). Job payload:
```ts
interface AppleHealthImportPayload {
/** Owner of the imported rows. The job runs in this user's scope. */
userId: string;
/** Operator who triggered the import (admin variant only). When unset,
* the user kicked off their own import. */
triggeredByAdminId?: string;
/** Absolute path on the worker's filesystem where the multipart-upload
* handler dropped the `export.zip`. The worker streams from here and
* unlinks on completion. */
uploadPath: string;
/** Size in bytes for the response envelope + audit-log row. */
uploadBytes: number;
/** Wall-clock kick-off so duration is computable even when the worker
* picks the job up after a queue delay. */
enqueuedAt: string;
}
```
The handler in `src/lib/jobs/apple-health-import-worker.ts` (new
file) follows the `pr-detection-worker.ts` shape: one exported
`handleAppleHealthImport(job: PgBossJob<AppleHealthImportPayload>)`
function plus a small unit-tested helper extracted to
`src/lib/measurements/parse-export-xml.ts`.
### 5.2 New Prisma model: `ImportJob`
`pg-boss` v12's `boss.getJobById` returns the boss row but the
`output` column only populates on terminal completion. For the
polling endpoint to read *live* progress (the user is staring at a
spinner), we mirror the job into a HealthLog-side row:
```prisma
model ImportJob {
id String @id @default(cuid())
userId String @map("user_id")
triggeredByAdminId String? @map("triggered_by_admin_id")
pgBossJobId String @unique @map("pg_boss_job_id")
/// `queued | unpacking | parsing | upserting | done | failed`
status String @default("queued")
/// Free-text reason on `failed`; null otherwise.
failureReason String? @map("failure_reason")
uploadBytes Int @map("upload_bytes")
startedAt DateTime @map("started_at") @default(now())
completedAt DateTime? @map("completed_at")
/// JSON envelope matching `ImportJobProgress` (§8). Updated mid-run
/// from the worker on every 1 000 records parsed.
progress Json @default("{}")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, startedAt])
@@map("import_jobs")
}
```
The unique constraint on `pgBossJobId` lets the polling endpoint
read by the boss id the client received from the synchronous
endpoint. The migration is additive only (no existing column changes).
### 5.3 Synchronous endpoint — kick-off
`POST /api/import/apple-health-export` (new file
`src/app/api/import/apple-health-export/route.ts`):
1. `requireAuth()` from `src/lib/api-handler.ts`. Cookie or Bearer
(any scope — wildcard works per v1.4.25 W10 fix-C).
2. `checkRateLimit("import:apple-health:${user.id}", 3, 60_000)` —
three uploads per minute per user is generous for any legitimate
workflow.
3. `Content-Length` ceiling: `MAX_UPLOAD_BYTES = 1.5 * 1024 * 1024 *
1024` (1.5 GB — Apple's largest observed export is around 800 MB
for a 10-year iCloud-synced account; 1.5 GB leaves margin).
Reject 413 above. **No** in-memory `request.formData()` — Next.js
16's built-in handler buffers the full body. Switch to streaming
via the experimental Web Streams `request.body` plus a manual
multipart boundary walker (see §5.4 for the streaming path).
4. Hash the upload into `/tmp/healthlog-import-<cuid>.zip` while the
stream lands. Compute a SHA-256 inline so re-uploading the exact
same file returns the previous `ImportJob` id without re-queueing
(idempotency-on-content rather than `Idempotency-Key` — the iOS
client cannot reliably emit a stable key for a giant file).
5. `prisma.importJob.create()` with `status: "queued"`.
6. `boss.send("apple-health-import", payload)` returns the boss
`jobId`. Store on the `ImportJob` row.
7. Respond `202 Accepted` with `{ jobId: importJob.id, status:
"queued" }`. The 202 is the contract the strategic plan §2 locks.
### 5.4 Multipart streaming notes
Next.js 16's `request.formData()` is fine up to ~10 MB but allocates
the full body on a 1 GB upload. The kick-off route therefore reads
`request.body` directly (a `ReadableStream<Uint8Array>` per Web
Streams) and walks the multipart boundary by hand — pattern identical
to the one shipped in `src/app/api/admin/backups/upload/route.ts:85`
for backup uploads, but extended to stream-to-disk instead of
`file.text()`. A small helper
`src/lib/multipart/stream-to-disk.ts` extracts the named field and
writes it to a temp file with a SHA-256 hash computed in the same
pass.
### 5.5 Status endpoint
`GET /api/import/apple-health-export/[jobId]/status` (new file
`src/app/api/import/apple-health-export/[jobId]/status/route.ts`):
1. `requireAuth()`.
2. `prisma.importJob.findUnique({ where: { id: jobId } })`.
3. 404 when missing or `userId !== requester.id` (admin variant
short-circuits this — see §9).
4. Reply with the envelope in §8. Caching: `Cache-Control: no-store`.
Polling cadence: client polls every 2 s during `queued | unpacking |
parsing | upserting`, every 30 s once `done | failed` (last-known
state is fine).
## 6. Worker process and parse loop
The handler lives at
`src/lib/jobs/apple-health-import-worker.ts`. Pseudocode:
```ts
import sax from "sax";
import { createReadStream, unlinkSync, statSync } from "node:fs";
import { mapAppleHealthEntry, APPLE_HEALTH_TYPE_MAP, dailyStatsExternalId,
CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
export async function handleAppleHealthImport(job) {
const { userId, uploadPath, uploadBytes } = job.data;
const importJob = await prisma.importJob.findUnique({
where: { pgBossJobId: job.id },
});
if (!importJob) throw new Error("ImportJob row missing");
await tick(importJob.id, "unpacking", { recordsRead: 0 });
const xmlPath = await unzipExportXml(uploadPath); // see §6.1
await tick(importJob.id, "parsing", { recordsRead: 0 });
const stats = await streamParse(xmlPath, userId); // see §6.2
await tick(importJob.id, "upserting", stats);
await flushUpserts(userId, stats); // see §6.3
await tick(importJob.id, "done", stats);
unlinkSync(uploadPath);
unlinkSync(xmlPath);
}
```
### 6.1 Unzip step
Streaming-unzip the upload's `apple_health_export/export.xml` into
`/tmp/healthlog-import-<cuid>.xml`. Node 22's `node:stream/promises`
+ a tiny ZIP central-directory walker — see
`https://github.com/MichalLytek/yauzl/blob/master/lib/yauzl.js` for a
reference implementation. The walker is ~60 LOC; we inline it under
`src/lib/import/unzip-export-xml.ts` rather than pull a new
dependency. Also extracts `workout-routes/*.gpx` to a temp directory
for later resolution.
### 6.2 Stream parse step
```ts
const parser = sax.parser(/* strict */ true, { trim: true });
const buffer: PreparedRow[] = [];
const stats: PerTypeStats = freshStats();
const cumulativeBucket = new Map<string, number>(); // key = type:date
parser.onopentag = (node) => {
if (node.name === "Record") {
const r = mapAppleHealthEntry({
hkIdentifier: node.attributes.type,
value: parseRecordValue(node.attributes),
unit: node.attributes.unit ?? "",
startDate: node.attributes.startDate,
endDate: node.attributes.endDate,
sleepStage: parseSleepStage(node.attributes.value), // §3
});
if (!r) {
stats.unknown[node.attributes.type] = (stats.unknown[node.attributes.type] ?? 0) + 1;
return;
}
if (CUMULATIVE_HK_TYPES.has(r.type)) {
// Fold into per-day bucket; flush bucket to DB at end of file
const dayKey = formatDayKey(r.takenAt, user.timezone);
const bucketKey = `${r.type}:${dayKey}`;
cumulativeBucket.set(bucketKey, (cumulativeBucket.get(bucketKey) ?? 0) + r.value);
} else {
buffer.push(prepareSpotRow(userId, r, node));
if (buffer.length >= 500) flushBatch(buffer);
}
stats.perType[r.type].read += 1;
} else if (node.name === "Workout") {
/* drain into prepareWorkoutRow + flushWorkoutBatch at 100 */
} else if (node.name === "ClinicalRecord") {
stats.clinical.skipped += 1;
} else if (node.name === "ActivitySummary") {
// ignore — daily-stats path mints rows from <Record>
}
};
parser.onerror = (err) => { /* mark failed */ };
parser.onend = async () => { await flushAllBuffers(); };
await pipeline(createReadStream(xmlPath, { highWaterMark: 64 * 1024 }),
saxAsWritable(parser));
```
Implementation notes:
- Sleep-stage `<Record>` rows carry their value as the symbolic name
`HKCategoryValueSleepAnalysisAsleepDeep`. Translate via a small
`SLEEP_STAGE_NAME_TO_CODEPOINT` table that mirrors
`APPLE_HEALTH_SLEEP_STAGE_MAP` inverted; the codepoint then rides
the existing `mapAppleHealthEntry()` path. For sleep, the row's
numeric `value` is `(endDate - startDate) / 60_000` minutes (the
same convention `mapAppleHealthEntry` expects).
- `externalId` derivation:
- Spot row, `HKMetadataKeyExternalUUID` present → `sample:<uuid>`.
- Spot row, no external UUID → `sample:<sha256(type|value|startDate|endDate)>`
truncated to 28 chars (~110 bit collision budget; well inside
the `min(1).max(120)` Zod cap on `externalId`).
- Cumulative row (after the per-day fold) →
`dailyStatsExternalId(hkIdentifier, "YYYY-MM-DD")`.
- The day-key for cumulative folds anchors to the user's timezone
(`User.timezone`) — same convention
`src/lib/measurements/drain-per-sample-cumulative.ts` uses via
`dayKeyForUserTz`. This makes the imported `stats:` rows
interoperate cleanly with iOS-emitted `stats:` rows: the same day
collides on the same `externalId` so a re-import after an iOS
cutover is a true UPSERT.
### 6.3 Upsert / flush step
The flush helpers run inside a loop of small Prisma transactions —
one tx per 500 spot rows or per 100 workout rows. UPSERT shape:
```ts
await prisma.measurement.upsert({
where: {
userId_type_source_externalId: {
userId,
type: row.type,
source: "APPLE_HEALTH",
externalId: row.externalId,
},
},
create: row,
update: { value: row.value, measuredAt: row.measuredAt },
});
```
The `userId_type_source_externalId` compound unique already exists
on `Measurement` at `prisma/schema.prisma:444` so no migration. For
the cumulative-bucket flush, the value written is the SUM across the
day; updating an existing row replaces the SUM (matches the drain
script's semantics — a re-import is a fresh authoritative roll-up).
Workouts UPSERT against `userId_source_externalId` at
`prisma/schema.prisma:512`. Sport-type mapping from
`HKWorkoutActivityType*` to the `workoutSportTypeEnum` Zod union
(`src/lib/validations/workout.ts:21-42`) lives in a new helper
`src/lib/measurements/hk-workout-activity-type-map.ts` — same
flat-table shape as `APPLE_HEALTH_TYPE_MAP`. Unknown activity types
fall through to `"other"` (the union's escape hatch). Unrecognised
counts land in `stats.workouts.unknownActivityType`.
`WorkoutRoute.geometry` is materialised by parsing the linked GPX
file into a GeoJSON LineString — same encoding the existing
`pickCanonicalWorkoutRows` consumes. GPX parsing rides the same `sax`
instance (a tiny inline `<trkseg><trkpt lat="..." lon="..."/></trkseg>`
reducer).
### 6.4 Resource ceiling
The handler caps total RSS by:
- streaming sax (no DOM)
- flush batches at 500 spot rows / 100 workouts
- the cumulative bucket holds at most one `(type, day)` entry per
observed day per cumulative type — a 10-year export with five
cumulative types and 3 650 days = 18 250 entries, ~1 MB
- no full-file buffer, ever
Empirical worst case: a 1 GB `export.xml` with 12 M `<Record>` rows
fits inside a 256 MB container heap with margin.
### 6.5 Progress reporting
The worker calls a `tick()` helper every 1 000 records read that
writes a partial-update onto `ImportJob.progress` (`prisma.importJob.update`).
The update is a single JSON column write and rate-limited internally
so a 12 M record run causes at most ~12 000 update queries (one per
1 000 records), which is acceptable.
## 7. Mapping reference
The import reuses the canonical lookup table at
`src/lib/measurements/apple-health-mapping.ts` verbatim. Concretely:
- **Spot quantities** (`aggregation: "latest" | "mean"`): pass each
Record through `mapAppleHealthEntry()` and persist one
`Measurement` row per sample. Coverage: WEIGHT, BODY_FAT,
BODY_TEMPERATURE, BLOOD_PRESSURE_SYS/DIA, PULSE,
RESTING_HEART_RATE, HEART_RATE_VARIABILITY, VO2_MAX,
BLOOD_GLUCOSE, OXYGEN_SATURATION, AUDIO_EXPOSURE_ENV,
AUDIO_EXPOSURE_HEADPHONE, WALKING_STEADINESS.
- **Cumulative quantities** (`CUMULATIVE_HK_TYPES`): fold per
`(type, user-local day)` and persist one `stats:...` row per
bucket. Coverage: ACTIVITY_STEPS, ACTIVE_ENERGY_BURNED,
FLIGHTS_CLIMBED, WALKING_RUNNING_DISTANCE, TIME_IN_DAYLIGHT.
- **Category types** (sleep, audio events): `mapAppleHealthEntry()`
already absorbs HKCategoryTypeIdentifierSleepAnalysis +
HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent +
HKCategoryTypeIdentifierHeadphoneAudioExposureEvent.
- **HKWorkout**: HKWorkoutActivityType → `WorkoutSportType` via a
new map at `src/lib/measurements/hk-workout-activity-type-map.ts`;
every other field reads directly off Workout attributes.
- **HKClinicalRecord**: skipped, counted under
`stats.clinical.skipped`.
- **HKElectrocardiogram**: skipped (no model in v1.5), counted under
`stats.unknown["HKElectrocardiogramType"]`.
Identifiers Apple ships that the table does not know about land in
`stats.unknown[<HKIdentifier>]`. The deferred set
(`HK_QUANTITY_TYPE_DEFERRED`) is enumerated; everything outside is a
true unknown. The response surfaces both buckets separately so
operators can spot a new iOS release without breaking ingestion.
## 8. Per-type stats response shape
`GET /api/import/apple-health-export/[jobId]/status` returns:
```ts
interface ImportJobStatusResponse {
jobId: string;
status: "queued" | "unpacking" | "parsing" | "upserting" | "done" | "failed";
startedAt: string; // ISO-8601
completedAt: string | null; // ISO-8601 once status === "done" | "failed"
uploadBytes: number;
/** When known — Apple's ExportDate attribute string-formatted. */
exportedAt: string | null;
/** Live-updated by the worker. */
progress: {
currentPhase: "unpacking" | "parsing" | "upserting";
/** Total <Record> + <Workout> elements seen so far. */
recordsRead: number;
/** Total rows committed so far. */
rowsUpserted: number;
/** Best-effort percent — only populated once the parser has seen
* the closing </HealthData> tag and recordsTotal is known. */
percent: number | null;
/** ms since `startedAt`. */
elapsedMs: number;
};
/** Final on `done` | `failed`; partial otherwise. */
result: {
perType: Record<
MeasurementType,
{ read: number; inserted: number; updated: number; durationMs: number }
>;
workouts: {
read: number;
inserted: number;
updated: number;
unknownActivityType: number;
routesAttached: number;
durationMs: number;
};
clinical: { skipped: number };
deferred: Record<string, number>; // HK_QUANTITY_TYPE_DEFERRED hits
unknown: Record<string, number>; // outside the defer set
totals: {
recordsRead: number;
rowsUpserted: number;
durationMs: number;
};
} | null;
failureReason: string | null;
}
```
`apiSuccess(...)` wraps the response in the standard envelope. The
shape is additive over the strategic-plan §2 sketch — `inserted`
splits into `inserted + updated` because UPSERT-vs-INSERT-only
matters for the operator audit (a re-import shows 0 inserts and N
updates; a fresh import shows the opposite).
## 9. Admin variant
`POST /api/admin/import-apple-health-export` (new file
`src/app/api/admin/import-apple-health-export/route.ts`):
1. `requireAdmin()` — cookie-only per the v1.4.25 security
boundary (`src/lib/api-handler.ts:414-428`). No Bearer ever
elevates.
2. Body `multipart/form-data`: same `file` field + a `userId`
string field naming the target user. Reject 422 if userId is
absent.
3. `prisma.user.findUnique({ where: { id: body.userId } })` —
reject 404 if missing (same pattern as the backup-upload route).
4. Same upload-streaming path, same temp-file hash, same
`ImportJob.create` shape — but with `triggeredByAdminId =
admin.id`. The status endpoint reads ownership by `userId` OR
`triggeredByAdminId` so the admin can poll their own kick-off.
5. `auditLog("admin.import-apple-health.start", ...)` and
`auditLog("admin.import-apple-health.complete", ...)` flank the
worker call — same shape as the drain endpoint at
`src/app/api/admin/drain-per-sample-cumulative/route.ts:56-77`.
The admin variant **enqueues the same `apple-health-import` queue**.
One worker handler, two kick-off paths.
## 10. Web-freeze marker wording
### CHANGELOG.md head, under `## [1.4.34]`
> **Web freeze begins after this release.** v1.4.34 closes the
> web-side scope for the v1.5 cycle. Subsequent v1.4.x tags are
> limited to security patches, dependency updates, and tightly
> scoped reactive fixes if iOS testing surfaces a real gap on a
> v1.4.34 endpoint. No new web features, UI rewrites, or non-additive
> schema changes until the iOS native client clears Apple review and
> the v1.5.0 version-bump marker tags on `main`.
### `prisma/schema.prisma` head comment, right under the generator
block (around `prisma/schema.prisma:1`):
> // v1.4.34 — Web schema enters freeze. Additive-only changes
> // (new optional columns, new models, new indices) remain
> // allowed; non-additive changes (rename, drop, type change,
> // constraint tightening) defer to the post-v1.5.0 cycle.
### `.planning/v15-strategic-plan.md` decision log (append a row
under the existing "Web freeze trigger" entry around line 388):
> | Web freeze in effect | v1.4.34 deploy on `main` | This plan §2,
> CHANGELOG line, prisma/schema.prisma head comment |
## 11. Test surface
`__tests__/apple-health-import-worker.test.ts` (Vitest):
1. **Tiny fixture** — 20-row hand-authored `export.xml` covering one
spot type, one cumulative type, one sleep stage, one workout
with a 3-point GPX route, one unknown identifier, one deferred
identifier. End-to-end assertions on `perType.read /
inserted / updated`, on the `stats:...` externalId for the
cumulative type, on the GeoJSON LineString for the route.
2. **Re-import idempotency** — run the importer twice over the same
fixture; assert second run reports 0 inserts and N updates.
3. **Unknown identifier surfacing** — assert
`result.unknown["HKQuantityTypeIdentifierFutureTypeXYZ"] === 1`.
4. **Deferred identifier surfacing** — assert a
`HK_QUANTITY_TYPE_DEFERRED` member lands under `deferred`, not
`unknown`.
5. **Streaming memory ceiling** — synthetic 50 MB `export.xml` (the
CI tier can't afford a 1 GB fixture); assert peak heap stays
below 100 MB via `process.memoryUsage().heapUsed`.
`__tests__/api-import-apple-health-export.test.ts` (Vitest /
integration):
1. Multipart upload accepted with 202 + `{ jobId }`.
2. Status endpoint returns the canonical envelope.
3. Cross-user access returns 404 (per `userId` scoping).
4. Admin endpoint enforces `requireAdmin()` (Bearer denied).
5. Rate limit returns 429 after three uploads in a minute.
## 12. Operational + Coolify implications
The `pg-boss` worker is already configured on the Coolify deploy
under `HEALTHLOG_PROCESS_TYPE` (web | worker | all per
`src/lib/process-type.ts:7-15`). No new container, no new env vars,
no new secrets. Two operational notes:
- Temp-file disk usage. The worker writes the upload (up to 1.5 GB)
+ the unzipped `export.xml` (up to 4 GB uncompressed) +
`workout-routes/*.gpx` (up to ~50 MB) under `/tmp`. The Coolify
app container's `/tmp` is currently sized to 8 GB; we add an
ops-doc note recommending `/tmp` be sized to at least 8 GB on
self-hosters running the import.
- Long-running job interactions. The graceful-shutdown path at
`src/lib/jobs/reminder-worker.ts:1420-1429` waits 30 s before
force-killing. For a 1 GB import the parse alone takes ~60 s; the
worker therefore registers an `idempotency-on-restart` mechanism:
on startup the handler checks for any `ImportJob` rows stuck in
`parsing | upserting` whose `pgBossJobId` is no longer alive in
`pg_boss.job` and flips them to `failed` with reason
`"interrupted_by_restart"`. Operator can re-run the same upload
(the content-hash dedup short-circuits to a fresh job).
## 13. Out-of-scope (deliberate)
These belong on the post-v1.5 backlog rather than v1.4.34:
- HKClinicalRecord / FHIR ingest (defers per R-F T3).
- HKElectrocardiogram waveform import.
- HKAudiogram (hearing test) records.
- StateOfMind data import — Apple's iOS-18 mental-state shape is
on the iOS-side roadmap, server-side ingest waits for that path
to land natively first.
- Activity-ring rollup (`<ActivitySummary>`) — duplicates what
HealthLog computes from `<Record>` rows.
## 14. Per-task effort estimate
| Task | Effort |
|---|---|
| Prisma migration: new `ImportJob` model + index | XS |
| `sax` + `@types/sax` dep + lock refresh | XS |
| `src/lib/multipart/stream-to-disk.ts` helper | S |
| `src/lib/import/unzip-export-xml.ts` central-directory walker | M |
| `src/lib/measurements/parse-export-xml.ts` SAX loop + record / workout / sleep dispatchers | M |
| `src/lib/measurements/hk-workout-activity-type-map.ts` | XS |
| `src/lib/jobs/apple-health-import-worker.ts` handler + `pg-boss` queue registration | S |
| `POST /api/import/apple-health-export/route.ts` kick-off | S |
| `GET /api/import/apple-health-export/[jobId]/status/route.ts` | XS |
| `POST /api/admin/import-apple-health-export/route.ts` admin variant | S |
| Vitest unit suite | M |
| Integration test (full upload-to-status round-trip with a 1 MB fixture) | S |
| OpenAPI regeneration + iOS handoff §3 amendment | S |
| CHANGELOG + Prisma comment + strategic-plan log line | XS |
| Ops doc note (/tmp sizing, graceful-shutdown caveat) | XS |
| **Total** | **L (~3-4 days)** |
## 15. Files the implementation will create or touch
New:
- `prisma/migrations/<timestamp>_v1434_import_jobs/migration.sql`
- `prisma/schema.prisma` — `ImportJob` model (additive)
- `src/lib/import/unzip-export-xml.ts`
- `src/lib/measurements/parse-export-xml.ts`
- `src/lib/measurements/hk-workout-activity-type-map.ts`
- `src/lib/multipart/stream-to-disk.ts`
- `src/lib/jobs/apple-health-import-worker.ts`
- `src/app/api/import/apple-health-export/route.ts`
- `src/app/api/import/apple-health-export/[jobId]/status/route.ts`
- `src/app/api/admin/import-apple-health-export/route.ts`
- `__tests__/apple-health-import-worker.test.ts`
- `__tests__/api-import-apple-health-export.test.ts`
Modified:
- `package.json` — add `sax` + `@types/sax`
- `src/lib/jobs/reminder-worker.ts` — register
`apple-health-import` queue + handler binding
- `CHANGELOG.md` — v1.4.34 entry + freeze marker block
- `.planning/v15-strategic-plan.md` — decision-log row
- `.planning/v15-ios-handoff/03-api-contracts.md` — append §
documenting the import endpoints + status envelope
- `.planning/v15-ios-handoff/04-data-model.md` — note the new
`ImportJob` model
- `docs/ops/...md` — note `/tmp` sizing recommendation
No existing route, no existing model column, no existing helper is
mutated in a non-additive way. The freeze marker can land cleanly on
the same release.
---
Word count: ~2 800 (target 2 000–3 000).
@@ -0,0 +1,439 @@
# v1.4.34 R2 — carryover-scope implementation blueprint
Read-only blueprint for v1.4.34 carryovers. Authored against
`develop` (head `f9346e62`); v1.4.33 ships from PR #178 once merged.
Each section maps to one dispatch-able unit; sub-wave decomposition
sits at the bottom.
## 1. Three pre-existing e2e flakes
The release-prep report records 5 failing e2e tests on the v1.4.33
PR; two belong to the F2 dispatch (in flight) and three are
pre-existing carryovers.
### 1a + 1b. `e2e/onboarding-flicker.spec.ts:28` (desktop + mobile)
The "complete-onboarding user never sees the card during load" case
slows `/api/analytics` by 250 ms, then samples
`page.locator('[data-testid="onboarding-card"]').isVisible()` 12 times
at 50 ms intervals and asserts every sample is `false`. Race: on slow
CI the Next.js client paints the analytics-pending shell before
`useAuth().user` resolves `onboardingCompletedAt`; during a 1-2 ms
window `<GettingStartedChecklist>` falls back to its "no auth yet"
branch. CI hits this 8-10% of runs.
Fix shape: swap the 50-ms poll for a single
`await expect(card).toBeHidden({ timeout: 700 })`, then
`waitForLoadState("networkidle")`, then a second `toBeHidden()`.
Playwright's auto-retrying assertion re-evaluates every animation
frame so the intent ("user never sees it") is preserved while the
race window collapses.
Touch surface: `e2e/onboarding-flicker.spec.ts:118-130`. Effort: XS
(~30 LoC swap covers both projects via the existing `test.use`).
Risk: low.
### 1c. `e2e/mobile-viewport.spec.ts:27` (chromium-mobile)
The dashboard CTA-touch-target probe sweeps every visible `main
button, main a[href], nav a[href]`, filters to "in initial viewport",
and asserts ≥ 44×44 px. Two triggers:
- v1.4.33 IW3 shrunk the dashboard "Hinzufügen" min-h to
`sm:min-h-9` (36 px) — on the Pixel 5 boundary at 393 px the `sm:`
breakpoint is below the 640 px threshold, but viewport detection
sometimes flips during the WebKit render commit.
- The probe captures fixed top-bar icon buttons (40×40 px) which
IW9 left untouched.
Fix shape: tighten the selector to `main button, main a[href]`
(drop `nav a[href]` — bottom-nav owns its own WCAG enforcement);
gate the floor on a `matchMedia('(min-width: 640px)').matches ===
false` evaluate so the desktop breakpoint never hits the 44 px
assertion. Spec semantics tighten: "every CTA the user reaches with
a thumb at phone width is 44 px or larger".
Touch surface: `e2e/mobile-viewport.spec.ts:96-126`. Effort: S
(~25 LoC). Risk: low.
## 2. Turbopack NFT-trace warnings
Release-prep log: warnings about NFT traces walking
`next.config.ts → src/lib/geo.ts → src/lib/auth/audit.ts →
mood-entries/bulk route`. `next.config.ts:5` already sets
`output: "standalone"`. Cause: Turbopack's tracer follows env-reads
in `src/lib/geo.ts` (the `MAXMIND_LICENSE_KEY` access) back into the
config file and emits "cannot be traced" warnings — cosmetic, no
runtime effect.
Fix: add `outputFileTracingExcludes: { "*": ["./next.config.ts"] }`
sibling to the existing `outputFileTracingIncludes` at
`next.config.ts:53-55`. Verify via `pnpm build 2>&1 | grep -i
"trace"` — warnings should disappear.
Touch surface: `next.config.ts` (3 LoC). Effort: XS. Risk: low —
`output: "standalone"` controls the actual bundle; the exclude
only narrows trace reporting.
## 3. First-Load-JS bundle-reporting rewire
Turbopack drops the webpack "First Load JS" line. `package.json:29`
already exposes `pnpm analyze` (`ANALYZE=1 next build`) wired
through `@next/bundle-analyzer` at `next.config.ts:69`; output lands
in `.next/analyze/*.html` as treemap reports plus a `client.json`
sibling with `{ label, statSize, parsedSize, gzipSize }` per chunk.
Options:
- (a) `experimental.statsLog` — not a real Next 16 flag. Dead end.
- (b) `next build --webpack` for size reports — Next 16 deprecated
the webpack fallback for `next build`. Dead end.
- (c) Continue with `@next/bundle-analyzer` (already wired); add a
parser that prints the top 5 chunks by `parsedSize` to the CI
log. Trivially extensible to a CI gate later.
Pick (c). Add `pnpm bundle-report` script + a tiny
`scripts/print-bundle-report.mjs` that reads
`.next/analyze/client.json` and prints a sorted table.
Touch surface: `package.json` (1 script), one new script file.
Effort: S (~50 LoC). Risk: low — additive, never replaces build.
## 4. `<TrendCard staleDays>` web wiring
IW3 added `staleDays?: number | null` at
`src/components/charts/trend-card.tsx:144` plus the
`dashboard.staleHint` locale key in six locales. The dashboard reads
from `/api/analytics` (via `useAnalyticsQuery()` at
`src/app/page.tsx:212`); that route's `DataSummary` shape at
`src/lib/analytics/trends.ts:171` does **not** carry per-type
`lastSeenAt`. The matching field landed only on
`/api/dashboard/summary` (iOS-only) at
`src/app/api/dashboard/summary/route.ts:320-321`.
Fix shape — additive on the analytics route:
- Extend `/api/analytics` default payload with
`lastSeenByType: Record<MeasurementType, string | null>`. The
per-type chunked findMany already runs; capture the last point's
`at` and emit it.
- Mirror on the slim slice: `summaries-slice.ts` already runs a
`DISTINCT ON (type)` latest read — add `MAX(measured_at)` to that
pass and surface it.
- Extend `AnalyticsRawPayload` in
`src/lib/queries/use-analytics-query.ts` so call sites can read
the field.
- Wire each `<TrendCard>` mount at `src/app/page.tsx:604-957`
through a helper: `staleDays={computeStaleDays(lastSeenByType,
type)}`. Helper returns `null` when the most-recent reading is
within 7 days (no caption); returns a positive integer otherwise.
Touch surface: 4 files + 14 mount sites in `src/app/page.tsx`.
Effort: S (~120 LoC + 2 tests). Risk: low — additive only;
existing tiles paint byte-identical when `lastSeenByType[type]` is
recent.
## 5. Coach drawer global trigger from dashboard
IW5 verified the Coach drawer is reachable only from `/insights/**`
— `<CoachLaunchProvider>` + `<LayoutCoachFab>` + `<LayoutCoachMount>`
mount inside `src/app/insights/layout.tsx:33-37`. Maintainer feedback
confirms the gap is dashboard-side, not insights.
Fix shape:
- Hoist `<CoachLaunchProvider>` from
`src/app/insights/layout.tsx:33` up to
`src/components/layout/auth-shell.tsx` so every authenticated
tree (dashboard included) can call `askCoach()`.
- Keep `<LayoutCoachFab>` and `<LayoutCoachMount>` in the insights
layout — the FAB is insights-only.
- Mount a new `<CoachLaunchButton>` (same component every insights
sub-page already uses) on the dashboard hero strip at
`src/app/page.tsx:512-524`, sibling to the "Hinzufügen" pill.
Empty prefill — the Coach panel picks the opening prompt.
- Verify the provider's `useState({open, setOpen, prefill})` lives
exactly once. Two providers would mean two open states.
Touch surface: `src/components/layout/auth-shell.tsx` (1 added
mount), `src/app/insights/layout.tsx` (1 removed mount),
`src/app/page.tsx` (1 added button in the hero). No new i18n keys
— `insights.coach.launchButton` already exists in six locales.
Effort: S (~40 LoC + 3 tests). Risk: low.
## 6. Server-side `Cache-Control: no-store` bfcache fix
Lighthouse flagged 3 bfcache failure reasons on v1.4.33. IW9
audited the client (no `beforeunload`/`unload`/`pagehide`/`onfreeze`
listeners). IW2 added `Permissions-Policy: unload=()` at
`next.config.ts:35`. The remaining breaker is the response
`Cache-Control` header on **HTML page responses**.
Audit of explicit `Cache-Control` sets in source — every one is on
an API or file-stream route; none on HTML pages:
- `src/app/api/health/route.ts:26` (`no-store, no-cache,
must-revalidate`) — API only.
- `src/app/api/insights/chat/route.ts:78` (`no-cache, no-transform`)
— SSE stream, intentional.
- `src/app/api/admin/backups/[id]/download/route.ts:117` (`no-store,
max-age=0`) — file stream, intentional.
- `src/app/api/export/*` + `src/app/api/doctor-report/pdf/route.ts`
— file streams, intentional.
The bfcache-breaking header is the Next.js framework default for
authenticated dynamic pages: any route that touches cookies through
a server component emits `Cache-Control: no-store, must-revalidate`
automatically. That covers every `/`, `/insights/**`, `/settings/**`
HTML response.
Fix: add a second rule to the `async headers()` block at
`next.config.ts:30-39` that sets `Cache-Control: private, max-age=0,
must-revalidate` on every non-API non-`_next` HTML route:
```
{ source: "/((?!api|_next).*)", headers: [
{ key: "Cache-Control", value: "private, max-age=0, must-revalidate" },
]}
```
Per Chromium's bfcache-eligibility matrix `no-store` is a hard
breaker; `must-revalidate` is fine. `private` already prevents
shared-cache (proxy) storage; `max-age=0` forces revalidation on
focus so cookie/session swaps still detect on the revalidation
request. No cache-poisoning risk vs today's flow.
Touch surface: `next.config.ts` (~5 LoC). Effort: XS. Risk:
medium — header change covers every authenticated route; verify
via a Lighthouse re-run against `/` and a manual bfcache test
(open `/`, navigate to `/measurements`, hit back, expect
"navigated via back/forward cache" in DevTools).
## 7. Sources ↔ Thresholds merger (A4 deferred)
`round-v1433-audit-menu.md` §7.1 (item 3). The two settings sections
are siblings today:
- `src/components/settings/sources-priority-section.tsx` — per-metric
source list + override + device-type ordering.
- `src/components/settings/thresholds-editor-section.tsx` — per-metric
green/yellow/red threshold inputs.
Combined UX:
- Section title: "Zielwerte & Quellen" (de) / "Targets & Sources"
(en) — six locales.
- One scrollable column with per-metric blocks. Each block: metric
header pill, left column source priority list + device-type
ordering, right column on `md+` (stacked on mobile) threshold
inputs. One "Save" per block (one mutation per metric).
- Summary card at top: "12 metrics configured, 5 with custom
thresholds, 8 using device priorities" for nav cues.
- `data-anchor={metric}` per block + sticky right-rail quick-jump
list on `md+`.
Implementation:
- Rename `thresholds-editor-section.tsx` →
`metric-config-section.tsx`.
- Inline the per-metric source list inside the metric block.
- Drop the `sources` slug from `SETTINGS_SECTIONS`
(`src/components/settings/settings-shell.tsx`) and
`SETTINGS_SECTION_SLUGS`
(`src/components/settings/section-slugs.ts`); make
`/settings/sources` a 301 redirect to `/settings/thresholds`
inside `src/app/settings/[section]/page.tsx`.
- Sections count drops from 10 → 9 post-IW7.
Touch surface: 5-7 files in `src/components/settings/` + section
slugs + settings-shell + six locale files. Effort: M (~600 LoC).
Risk: medium — UX change needs a screenshot review before ship.
## 8. Tab-strip regrouping (A4 deferred)
`round-v1433-audit-menu.md` §7.1 (item 1). Replace the five wave-A
pills (HRV, Ruhepuls, Sauerstoff, Körpertemperatur, Aktive Energie)
in `src/components/insights/insights-tab-strip.tsx` with one parent
pill labelled "Vitalwerte" (source-agnostic; "Apple Health" assumes
device).
Recommended shape: parent pill opens a Sheet/Popover listing the
five sub-metrics; tapping one navigates to its existing route. No
new index page — preserves every URL.
- `SUB_PAGE_TABS` + `buildTabs()` in
`src/components/insights/insights-tab-strip.tsx` modified so the
five wave-A entries collapse to one parent with a popover
trigger.
- `src/lib/insights/sub-page-metric.ts` gains a `group` field for
the wave-A metrics.
- Six locale files for the parent label + the popover header.
Touch surface: 3 source files + 6 locale files. Effort: S (~150
LoC + 3 tests). Risk: low — additive grouping, no URL or
data-fetch change.
## 9. `compliance.ts` classifier hardening (F8 root)
`src/lib/analytics/compliance.ts:65-93` returns `very_late` as a
catch-all for any takenAt outside the `-1h … +2h` grace window.
IW6's F8 analysis: when an intake is logged early (e.g. 06:00 UTC
for a 07:00 UTC window), the dose is flushed to `very_late` and the
heatmap paints orange even though the user took it proactively.
Two semantic flaws:
1. The 1-hour pre-window grace is too narrow for proactive takers.
2. `very_late` semantically covers both "way too early" and "way
too late" — a proactive logger is not "very late".
Proposed thresholds (matches the audit's recommended follow-up):
```
takenAt === null → "missed"
takenAt < windowStart - 3h → "early" (NEW bucket)
windowStart - 3h ≤ takenAt < windowStart → "on_time" (widen grace 1h → 3h)
windowStart ≤ takenAt ≤ windowEnd → "on_time"
windowEnd < takenAt ≤ windowEnd + 2h → "late"
takenAt > windowEnd + 2h → "very_late"
```
`early` + `on_time` both count as compliant for rate calculation;
only `late`, `very_late`, `missed` reduce it.
Touch surface:
- `src/lib/analytics/compliance.ts` (~25 LoC).
- `src/lib/analytics/__tests__/compliance.test.ts` — add 4 cases
for `early` + the widened grace; update the existing "way before
grace" case at line 262 (currently expects `very_late`, becomes
`early` or `on_time`).
- `src/components/charts/compliance-heatmap.tsx` — drop the
defensive `looksClassifierBug` guard at lines 46-77 once the
root is fixed; keep the rate-based fallback below it for the
no-timing-data branch.
- `src/app/api/medications/[id]/compliance/route.ts` (~10 LoC) —
route `early` to the compliant bucket.
- `src/app/api/gamification/achievements/route.ts` (~10 LoC) —
same.
Effort: M (~120 source + 80 test LoC). Risk: medium — touches a
user-facing compliance metric; achievement-streak test path needs
verification.
## 10. AASA followups
No `apple-app-site-association` file exists in the tree today. The
iOS contributor brief uses custom URL schemes during the v1.5
sprint, not associated domains. The in-flight AASA dispatch is the
**first** AASA pass; nothing pre-existing to defer.
Verify after the AASA agent lands:
- `public/.well-known/apple-app-site-association` exists with the
right `applinks` blob and is served as `application/json` (Next.js
needs an explicit handler — default static-file MIME maps ship
this path as `application/pkcs7-mime` on some configs).
- `/apple-app-site-association` (root path) also serves the same
payload — some validators check both.
- Bundle ID matches `APNS_BUNDLE_ID = dev.healthlog.app` (verified
on apps01 Coolify env).
- Alternate domains: only `healthlog.bombeck.io` configured today.
Confirm with maintainer whether `healthlog.app` or similar needs
AASA coverage.
Effort: XS (verification only). Risk: low.
## 11. POSTGRES_PASSWORD apps01 duplicate-section noise
Confirmed via `mcp__coolify-apps01__env_vars list` on app UUID
`pg8wggwogo8c4gc4ks0kk4ss`: the env-var listing contains **two
sections** — many keys appear twice. The first section holds the
real values; the second section is a Compose-default leftover from
the v1.3.0/v1.3.1 era. The real `POSTGRES_PASSWORD` (value
`healthlog`, uuid `psjxz586keglg80frlc0hilg`) is the first-section
entry. The placeholder (`"POSTGRES_PASSWORD is required"`, uuid
`d3r4k1lryj6n0z7dfj4hhc8t`) is the second-section leftover —
documented in `CHANGELOG.md:4554-4566` (v1.3.1 fix). Runtime reads
the first match; the container is healthy.
Cleanup: delete uuid `d3r4k1lryj6n0z7dfj4hhc8t` via the MCP
`env_vars delete` action. Optionally — only with maintainer
agreement — delete the other duplicate entries in section 2
(`SESSION_SECRET`, `ENCRYPTION_KEY`, etc.); they carry the same
value as section 1 so runtime is unaffected.
Effort: XS (1-2 MCP calls). Risk: low.
## 12. Web-Freeze CHANGELOG marker
Per `.planning/v15-strategic-plan.md:204`. Exact line + placement
(in `CHANGELOG.md`, under the v1.4.34 release block, after standard
Added/Changed/Fixed sections):
```markdown
### Web-freeze marker
Web functionality is complete as of v1.4.34. Subsequent v1.4.x tags
ship hotfixes, dependency updates, and tightly-scoped reactive fixes
only; no new web features land until the iOS native client clears
Apple review and v1.5.0 marks the joint release. The full freeze
posture is documented in `.planning/v15-strategic-plan.md` §2.
```
Effort: XS (5 lines). Risk: zero.
## 13. Sub-wave decomposition
Five touch-disjoint sub-waves. Sub-waves A–D run in one parallel
batch; sub-wave E is the close-out.
**Sub-wave A — infrastructure (bfcache + bundle-reporting +
env-var hygiene)**.
Files: `next.config.ts`, `package.json`, optional
`scripts/print-bundle-report.mjs`, plus the MCP env-var cleanup.
Items: #2, #3, #6, #11. Effort: ~S total.
**Sub-wave B — TrendCard wiring + Coach global trigger**.
Files: `src/lib/queries/use-analytics-query.ts`,
`src/app/api/analytics/route.ts`,
`src/lib/analytics/summaries-slice.ts`, `src/app/page.tsx`,
`src/components/layout/auth-shell.tsx`,
`src/app/insights/layout.tsx`.
Items: #4, #5. Effort: ~S+ total.
**Sub-wave C — compliance classifier hardening**.
Files: `src/lib/analytics/compliance.ts`,
`src/lib/analytics/__tests__/compliance.test.ts`,
`src/components/charts/compliance-heatmap.tsx`,
`src/app/api/medications/[id]/compliance/route.ts`,
`src/app/api/gamification/achievements/route.ts`.
Items: #9. Effort: M.
**Sub-wave D — Sources↔Thresholds + tab-strip regroup**.
Files: `src/components/settings/sources-priority-section.tsx`,
`src/components/settings/thresholds-editor-section.tsx`,
`src/components/settings/settings-shell.tsx`,
`src/components/settings/section-slugs.ts`,
`src/components/insights/insights-tab-strip.tsx`,
`src/lib/insights/sub-page-metric.ts`, six locale files,
`src/app/settings/[section]/page.tsx`.
Items: #7, #8. Effort: ~M+.
**Sub-wave E — close-out (e2e flakes + AASA verify + CHANGELOG
marker)**.
Files: `e2e/onboarding-flicker.spec.ts`,
`e2e/mobile-viewport.spec.ts`, `CHANGELOG.md`, plus AASA review.
Items: #1, #10, #12. Effort: ~S total.
Disjointedness: sub-wave A touches config / infrastructure;
sub-wave B touches dashboard + layout + analytics API; sub-wave C
touches compliance analytics + related routes; sub-wave D touches
settings + insights tab-strip; sub-wave E touches e2e + docs. No
file appears in two sub-waves. `src/app/page.tsx` lives in
sub-wave B exclusively.
### Total effort estimate
| Sub-wave | Items | Effort | LoC est. |
| --- | --- | --- | --- |
| A | #2, #3, #6, #11 | S | ~80 |
| B | #4, #5 | S+ | ~250 |
| C | #9 | M | ~200 + 80 tests |
| D | #7, #8 | M+ | ~700 + 100 tests |
| E | #1, #10, #12 | S | ~70 + 5 docs |
| **Total** | **12** | **L (~2 dispatch batches)** | **~1400 + 185 tests** |
Effort scale: XS ≤ 50 LoC, S ≤ 200 LoC, M ≤ 600 LoC, L ≤ 1500 LoC.
Sub-wave D is the largest and benefits from a screenshot-review
checkpoint before ship; everything else is mechanical given the
specs above.
@@ -0,0 +1,429 @@
# v1.4.34 R-Cache — Server-Side Aggregation Cache Blueprint
Status: research handoff for IW-G (implementation).
Scope: in-process LRU layer in front of the slow-tail GET routes the
v1.4.33 HAR capture flagged.
Companion docs: `.planning/round-v1433-audit-perf.md` (§3.3 motivated
the Coach snapshot LRU), `.planning/round-v1434-prod-slowness-
investigation.md` (HAR + pool-starvation reading).
## 1. Brief
The dashboard mount fans out to eight Prisma-bound GET routes in
parallel. Even with pool starvation fixed, each route still pays its
own row-walk on every load. The Coach snapshot LRU we shipped in
v1.4.33 (`src/lib/ai/coach/snapshot.ts:292–336`, commit `af17db5d`) is
the proven pattern: a hand-rolled `Map<string, { expiresAt, result }>`,
LRU-touched on read, hard-capped on insert, 60 s TTL, keyed on
`(userId, scope)`. Extending that pattern to the high-cost analytics
routes converts the dashboard wait from "fast because Postgres
answered fast" to "fast because the answer was already in RAM". The
two layers compose — a cold cache still benefits from a healthy pool,
a warm cache bypasses the pool entirely.
This document is the design IW-G can implement from directly. It
covers the cache key shape per route, the storage primitive, the
invalidation matrix tying every write endpoint to its evictions, the
helper API, single-flight, observability, and the migration path to a
triggered rollup table in v1.5.x.
## 2. Top three routes by speedup × ease
1. `/api/analytics` (thick + slim, 7.99 s combined cost on dashboard
mount). Pure read of persisted measurements + a fixed-shape
aggregation. Cache the route response keyed on
`(userId, slice)`. Expected hit ratio on the dashboard re-mount
loop is high — the dashboard root, the checklist mount, and the
advisor all read the same payload. Even one hit per second of
active session amortises the 5 s tail across every subsequent
render.
2. `/api/gamification/achievements` (3.18 s × 2 on the same load).
The route resolves achievement progress against the same metric
snapshot every consumer needs. Two-cell duplicate today (per
prod-slowness investigation §4) — caching by `userId` collapses
the duplicate **and** every dashboard re-mount.
3. `/api/medications/intake?scope=compliance&days=30` (3.00 s).
Pre-aggregated daily compliance — by definition slow-changing
inside a 24 h window. Long TTL candidate (15 min) because intake
events trickle in throughout the day but yesterday's row never
moves.
These three convert ~14 s of dashboard cumulative wait time into
~150 ms of `Map.get()` + JSON return on a warm cache. Ease comes from
the routes already being pure reads with a clear `(userId, params)`
key — no auth-side-effect entanglement, no cross-user leakage risk.
Storage pick: hand-rolled `Map`-based LRU per route, in-process. Same
pattern as the snapshot cache, no new dependency. `lru-cache` is not
currently in the dependency tree — adding it for ~120 lines of
infrastructure isn't worth the supply-chain surface. The hand-rolled
shape ships in `src/lib/cache/server-cache.ts` and stays well below
that line count.
Invalidation: tag-based, per-user. Every write endpoint that touches
a measurement / mood / medication row calls one of three helpers —
`invalidateUserMeasurements(userId)`, `invalidateUserMood(userId)`,
`invalidateUserMedications(userId)` — and the helpers walk the cache
maps and delete every key whose `userId` prefix matches. We do not
try to do partial-key eviction (slice-specific or window-specific) on
write; flushing the whole user-bucket is the safe default and the
re-fill on the next read is still cheap because Prisma's pool is fast
under non-burst load.
## 3. Routes in scope — per-route cache contract
| Route | Key shape | Value | TTL | Bucket |
| --- | --- | --- | --- | --- |
| `/api/analytics` | `${userId}\|${slice ?? "thick"}` | full JSON response body | 60 s | measurements |
| `/api/medications` | `${userId}` | full JSON response body | 60 s | medications |
| `/api/gamification/achievements` | `${userId}` | full JSON response body | 60 s | measurements ∪ medications |
| `/api/dashboard/widgets` | `${userId}` | full JSON response body | 5 min | dashboard-layout |
| `/api/bugreport/status` | `singleton` (no userId — the row is global) | `{ configured, enabled, isAdmin }` per role-tier | 10 min | app-settings |
| `/api/workouts?limit=N` | `${userId}\|${limit}\|${offset}\|${since}\|${until}\|${sportType}` | full JSON response body | 60 s | workouts |
| `/api/medications/intake?scope=compliance&days=N` | `${userId}\|compliance\|${days}` | full JSON response body | 15 min | medications |
| `/api/mood/analytics` | `${userId}` | full JSON response body | 60 s | mood |
Notes per row:
- `bugreport/status` is the outlier — the data is global app settings
(GitHub-token presence, enabled flag) gated on user role. Cache the
raw `(configured, enabled)` shape by the constant `"singleton"`
key, then layer `isAdmin` from the request's user on top. Eviction
is `invalidateAppSettings()` called from the `PATCH /api/admin/
app-settings` handler.
- `medications/intake?scope=today` is **not** cached. The "today"
view changes every time the user marks a dose taken — the cache
would either be racy or have a near-zero TTL anyway. Compliance
(`scope=compliance&days=30`) is the cached variant; today's view
reads through.
- `workouts?limit=3` is the dashboard hot-spot. Other workout queries
(`/insights/workouts` with high limits, the iOS app draining
history) share the same cache but with their own keys — they
rarely hit the dashboard's `limit=3` slot, so the cache stays
segmented naturally.
We **don't** negative-cache 404 / 422. The Coach snapshot pattern
returns a real object on every successful path; 404 / 422 means the
auth gate, the param validator, or the rate limiter rejected the
request before any read. Caching the rejection would mean a follow-up
write would have to know to evict it, and the slow-tail risk doesn't
exist here — a 422 returns in single-digit milliseconds.
## 4. Storage primitive
`src/lib/cache/server-cache.ts` ships one class:
```ts
class ServerCache<T> {
private readonly map = new Map<string, { expiresAt: number; value: T }>();
private readonly pending = new Map<string, Promise<T>>();
constructor(private readonly opts: { maxEntries: number; ttlMs: number }) {}
// get(key) — null on miss / expired
// set(key, value) — evicts oldest if over capacity
// delete(key) — single-key eviction
// deleteByPrefix(prefix) — bulk eviction (the userId-prefix path)
// wrap(key, builder) — read-through + single-flight
}
```
`wrap()` is the only public read path. Its contract:
1. If `map.get(key)` returns a non-expired entry, touch (delete +
re-insert moves the key to the end of the `Map`'s insertion order,
which is the LRU end), return `value`.
2. Else if `pending.get(key)` exists, await it. A second caller for
the same key inside the build window does **not** start a second
build — this is the single-flight guarantee.
3. Else: call `builder()`, register the in-flight promise on
`pending`, on resolve `set(key, value)` and delete from `pending`,
on reject delete from `pending` (so the next caller retries).
Per-route eviction caps: 1000 entries each. At ~5 KB per
analytics response and 8 cached routes, the theoretical worst case
is ~40 MB for 50 active users × 10 routes × 5 KB — well inside the
Coolify container memory budget. The 1000-entry cap is a
single-process limit; LRU eviction makes the real working set track
the active-user count.
Memory pressure mitigation: each `ServerCache<T>` instance lives in
module-scope; the `Map` is bounded by `maxEntries`; when the cap is
hit we evict the oldest entry by deleting `map.keys().next().value`.
Same recipe as `src/lib/ai/coach/snapshot.ts:319–325`.
The cache is process-local. Coolify currently runs a single
`apps-01` container per deployment, so all traffic lands in the same
process. If a future deploy moves to multi-instance (horizontal
scale-out behind a load balancer), each instance keeps its own cache;
writes from instance A do not evict reads on instance B. We accept
that trade today — the cost is "instance B serves the pre-write value
for up to TTL after a write" which is bounded and not silent
(provenance + cache-key hash logged on every response). The Redis
migration path is sketched in §7.
## 5. Helper API
```ts
// src/lib/cache/server-cache.ts
export const caches = {
analytics: new ServerCache<unknown>({ maxEntries: 1000, ttlMs: 60_000 }),
medications: new ServerCache<unknown>({ maxEntries: 1000, ttlMs: 60_000 }),
achievements: new ServerCache<unknown>({ maxEntries: 1000, ttlMs: 60_000 }),
dashboardWidgets: new ServerCache<unknown>({ maxEntries: 500, ttlMs: 300_000 }),
bugreportStatus: new ServerCache<unknown>({ maxEntries: 10, ttlMs: 600_000 }),
workouts: new ServerCache<unknown>({ maxEntries: 1000, ttlMs: 60_000 }),
medicationsIntake:new ServerCache<unknown>({ maxEntries: 1000, ttlMs: 900_000 }),
moodAnalytics: new ServerCache<unknown>({ maxEntries: 1000, ttlMs: 60_000 }),
};
export async function cached<T>(
cache: ServerCache<T>,
key: string,
builder: () => Promise<T>,
): Promise<T> {
return cache.wrap(key, builder);
}
// Bulk eviction — by user, optionally narrowed to one cache.
export function invalidateUserMeasurements(userId: string): void {
caches.analytics.deleteByPrefix(`${userId}|`);
caches.achievements.deleteByPrefix(`${userId}`);
caches.workouts.deleteByPrefix(`${userId}|`);
// mood-analytics is invalidated separately because mood writes are
// a different write surface and we don't want to over-evict.
}
export function invalidateUserMood(userId: string): void {
caches.moodAnalytics.deleteByPrefix(`${userId}`);
caches.achievements.deleteByPrefix(`${userId}`);
caches.analytics.deleteByPrefix(`${userId}|`);
}
export function invalidateUserMedications(userId: string): void {
caches.medications.deleteByPrefix(`${userId}`);
caches.medicationsIntake.deleteByPrefix(`${userId}|`);
caches.achievements.deleteByPrefix(`${userId}`);
}
export function invalidateUserDashboardWidgets(userId: string): void {
caches.dashboardWidgets.deleteByPrefix(`${userId}`);
}
export function invalidateAppSettings(): void {
caches.bugreportStatus.deleteByPrefix("");
}
```
Each route handler wraps its body. Example for `/api/analytics`:
```ts
const data = await cached(
caches.analytics,
`${user.id}|${slice ?? "thick"}`,
() => buildAnalyticsResponse(user.id, slice, userTz),
);
return apiSuccess(data);
```
The route function carrying the actual aggregation moves into
`buildAnalyticsResponse()`. The route stays the cache miss / hit
boundary. The wrap matches the snapshot pattern's two-function shape
(`buildCoachSnapshot` → `buildCoachSnapshotImpl`).
## 6. Invalidation matrix
Every write endpoint calls the appropriate `invalidateUser*` helper
**after** its successful database commit, **before** the response is
returned. The helpers are cheap (`Map.delete` over a bounded prefix
scan); we accept the minor latency penalty on writes to keep reads
consistent. Failure to invalidate poisons the cache for one TTL
cycle, which is exactly the failure mode the snapshot pattern
already accepts.
| Write endpoint | Invalidations |
| --- | --- |
| `POST /api/measurements` | `invalidateUserMeasurements(userId)` |
| `POST /api/measurements/batch` | `invalidateUserMeasurements(userId)` |
| `PUT /api/measurements/[id]` | `invalidateUserMeasurements(userId)` |
| `DELETE /api/measurements/[id]` | `invalidateUserMeasurements(userId)` |
| `DELETE /api/measurements/by-external-ids` | `invalidateUserMeasurements(userId)` |
| `POST /api/mood-entries` | `invalidateUserMood(userId)` |
| `PATCH /api/mood-entries/[id]` | `invalidateUserMood(userId)` |
| `DELETE /api/mood-entries/[id]` | `invalidateUserMood(userId)` |
| `POST /api/mood-entries/bulk` | `invalidateUserMood(userId)` |
| `POST /api/medications` | `invalidateUserMedications(userId)` |
| `PUT /api/medications/[id]` | `invalidateUserMedications(userId)` |
| `DELETE /api/medications/[id]` | `invalidateUserMedications(userId)` |
| `POST /api/medications/intake` (status update) | `invalidateUserMedications(userId)` |
| `POST /api/workouts/batch` | `invalidateUserMeasurements(userId)` (workouts cache rides on the measurements bucket because achievements / analytics both read workouts) |
| `PUT /api/dashboard/widgets` | `invalidateUserDashboardWidgets(userId)` |
| `DELETE /api/dashboard/widgets` | `invalidateUserDashboardWidgets(userId)` |
| `PATCH /api/admin/app-settings` (bugreport-config toggle) | `invalidateAppSettings()` |
A new measurement write touches the analytics aggregate, the
achievement progress, and possibly the workouts dashboard tile (if
the measurement is a workout-linked metric). That's why the
`invalidateUserMeasurements` helper evicts three caches at once
rather than just one. The conservative "evict the whole user-bucket"
matches Marc's "blazing fast" stance — a redundant eviction costs the
next reader one cache miss (cheap, the row's already paginated), an
under-eviction shows stale data which is the directive's failure
mode.
## 7. Single-flight + cache stampede
Two concurrent dashboard mounts (two browser tabs, both with cold
cache, both fetching `/api/analytics` in the same second) would
otherwise issue two builders. The `pending` map in the storage
primitive coalesces these: tab B awaits tab A's in-flight promise
and gets the same response. This is the standard single-flight /
deduplication pattern.
Cache stampede on TTL expiry: when the cached entry expires at
second 60.000 and three requests land at second 60.001, the first
request through the `wrap()` call kicks off a fresh build; requests
two and three see no live entry yet (it's about to be set) **but**
also see no pending promise yet — there's a race window of a few
microseconds. Mitigation (deferred — not part of IW-G unless the
metrics show it's needed): **soft-TTL**. Keep the entry available
past `expiresAt` for one additional refresh window; mark it stale on
read and trigger a background rebuild for the first reader past the
soft-TTL; subsequent readers in the next 1 s window serve the stale
entry. This is the standard SWR-server pattern. For v1.4.34 we ship
the hard-TTL variant (cleaner semantics, simpler tests) and add
soft-TTL only if telemetry shows real stampedes.
## 8. Observability
Every cache hit / miss is annotated on the active wide-event
builder:
```ts
const event = getEvent();
event?.addMeta("cache.analytics.outcome", hit ? "hit" : "miss");
event?.addMeta("cache.analytics.key_hash", djb2(key));
```
The `key_hash` is a non-reversible 32-bit hash so we can correlate
hits without leaking userId into logs. We aggregate hit ratio per
route in the existing wide-event dashboard. The audit-log surface
already accepts arbitrary `meta` keys, so no schema change is
needed.
Per-test reset escape hatch: every `ServerCache<T>` exposes
`__resetForTests()` (matches `__resetCoachSnapshotCacheForTests` in
the snapshot pattern). Vitest setup in `vitest.config.mts` adds an
afterEach that clears all caches, identical to how the snapshot
tests handle it (`src/lib/ai/coach/__tests__/snapshot.test.ts`).
## 9. Comparison to the v1.5.x triggered-rollup table
The LRU layer is the right pick for v1.4.34: it's a single file, ships
behind a proven pattern, no schema change, no migration risk. It
trades correctness-on-write for read speed in exchange for an
aggressive invalidation policy — that's the same trade the Coach
snapshot LRU made.
A v1.5.x triggered rollup table — proposed schema sketch:
```sql
CREATE TABLE measurement_rollups (
user_id UUID NOT NULL,
type TEXT NOT NULL,
window TEXT NOT NULL, -- '7d' / '30d' / '90d'
day_key DATE NOT NULL, -- bucket anchor
count INT NOT NULL,
mean DOUBLE PRECISION,
min_val DOUBLE PRECISION,
max_val DOUBLE PRECISION,
sd DOUBLE PRECISION,
slope DOUBLE PRECISION,
r2 DOUBLE PRECISION,
computed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, type, window, day_key)
);
CREATE INDEX ON measurement_rollups (user_id, type, window);
```
Update path: a Postgres `AFTER INSERT/UPDATE/DELETE` trigger on
`Measurement` enqueues a recompute for the affected `(userId, type,
day_key)`. The recompute is a single SQL upsert per metric, runs in
the pg-boss worker (already deployed for reminder fan-out — see
`src/lib/jobs/reminder-worker.ts`), and is bounded by the number of
distinct types the write touched (typically 1–3 per measurement).
Reads from `/api/analytics` then bypass the row walk entirely and
join against the pre-computed rollup table.
Pros (rollup vs LRU):
- Survives process restarts and multi-instance deploys.
- Cross-user fan-out queries (the v1.5 admin analytics surface) can
read directly without per-user warmup.
- Read latency drops to a single indexed lookup regardless of cache
state.
Cons (rollup vs LRU):
- Migration introduces a new table + indexes + background worker
contract.
- The trigger fan-out is one more thing to monitor; a stuck pg-boss
queue leaves rollups stale (silent if not alarmed).
- Storage cost grows with measurement volume × window-count × day-
count.
Migration path:
1. v1.4.34 — ship LRU. Land observability so we can read hit ratio
in production.
2. v1.5.x — introduce `measurement_rollups` additively behind the
feature flag `analytics-rollups-table`. Reads continue to land in
the LRU; the rollup table is populated by the trigger but not
queried.
3. v1.5.x+1 — flip the flag for one user, dual-read both paths,
diff. When the diff is zero over 10 000 reads we cut over to the
rollup table for that user.
4. v1.5.x+2 — promote to a single feature flag flip, deprecate the
LRU. Keep the LRU code path as the fallback for users still on
the legacy flag during the transition window.
## 10. Risks + mitigations
| Risk | Mitigation |
| --- | --- |
| Stale data after a write | Every write endpoint calls `invalidateUser*`. Documented in §6. The penalty for missing one is one TTL window of stale reads, not silent corruption. The pre-release checklist for v1.4.34 includes a grep gate that lists every `POST/PUT/PATCH/DELETE` handler under `/api/(measurements\|mood-entries\|medications\|workouts)` and verifies each contains an `invalidateUser*` call. |
| Multi-instance Coolify | Documented and accepted. Each instance keeps its own cache; writes don't cross-evict. The TTL bounds the staleness. Plan Redis as a v1.5.x option when the rollup table is also on the roadmap — the two together close the gap. |
| Memory pressure | Per-cache `maxEntries` cap, LRU eviction on overflow. Worst case 40 MB across all caches on the current Coolify container's 512 MB budget. |
| Cache stampede on TTL expiry | Single-flight via the `pending` map. Soft-TTL is a deferred follow-up if telemetry shows we need it. |
| Per-test pollution | `__resetForTests()` on every `ServerCache<T>` + vitest afterEach. Same recipe the snapshot tests already use. |
| Auth-side-effect entanglement | The cache key includes `userId`. The `bugreport/status` endpoint is the exception (singleton key with per-user layering); the doc here calls it out explicitly. Auth always runs before the cache `wrap()` so a stolen request that bypasses auth would not reach the cache. |
| Wide-event log growth | The hit / miss annotation reuses the existing `addMeta` carrier, two keys per request. No new log volume class. |
## 11. Implementation checklist (for IW-G)
1. Land `src/lib/cache/server-cache.ts` with the `ServerCache<T>`
primitive, the eight `caches.*` instances, the four
`invalidateUser*` helpers, the `invalidateAppSettings()` helper,
and the `cached()` wrapper. Unit-test the primitive — LRU
ordering, TTL, single-flight, prefix delete.
2. Wrap each route in §3 — `/api/analytics` first (highest payoff),
then the rest in any order. Each wrap is two lines of edit.
3. Add `invalidateUser*` calls to every write endpoint in §6. Run
the grep gate as part of the pre-release checklist.
4. Wire the `addMeta("cache.<name>.outcome", …)` annotations into
each `cached()` call. Single helper that branches on hit vs miss.
5. Add integration tests for the read-through path: prime the cache,
write through the matching write endpoint, assert the next read
misses (i.e. the invalidation worked).
6. Document the cache in `docs/audit/v1434-summary.md` once IW-G
merges.
## 12. Acceptance criteria
- All eight routes in §3 wrap their handler body through `cached()`.
- All write endpoints in §6 carry the matching `invalidateUser*`
call.
- Unit tests cover LRU ordering, TTL, prefix-delete, single-flight.
- Integration test: dashboard mount with warm cache resolves
`/api/analytics` in < 50 ms. With cold cache, parity with the
pre-cache p95.
- `cache.<name>.outcome` annotations land on every request to a
cached route. Dashboard hit-ratio query returns within seconds.
- No regression in the snapshot LRU (`buildCoachSnapshot`) — IW-G
must not refactor it under the new primitive in the same wave.
The migration is a v1.4.35 follow-up.
End of brief. IW-G can implement directly from this file. Any
ambiguity defaults to "match the v1.4.33 snapshot LRU shape" since
that is the proven pattern this design extends.
@@ -0,0 +1,141 @@
# v1.4.33 AASA rollout — closure report
Date: 2026-05-16
Scope: ship `/.well-known/apple-app-site-association` on every host that
fronts HealthLog so the iOS bundle's passkey ceremony shares cleanly
with the web origins, and so Apple's CDN can ingest the file ahead of
the iOS Tier-1 work that lands later in the v1.4.x cycle.
## Bundle / Team identifiers
- Team ID: `S8WDX4W5KX`
- Bundle ID: `dev.healthlog.app`
- App ID prefix: `S8WDX4W5KX.dev.healthlog.app`
## Commits
### Landing (`healthlog-landing`, `main`)
| SHA | Subject |
| --- | --- |
| `33e6039` | feat(ios): serve apple-app-site-association on healthlog.dev |
Touched paths:
- `src/app/.well-known/apple-app-site-association/route.ts` (new) —
Next.js Route Handler with `dynamic = "force-static"` so the static
export emits a literal JSON file at
`out/.well-known/apple-app-site-association`.
- `Dockerfile` — nginx config gained an exact-match
`location = /.well-known/apple-app-site-association` block that runs
ahead of the SPA catch-all and pins `Content-Type: application/json`
+ `Cache-Control: public, max-age=3600`.
### Main app (`HealthLog`, `develop`, rides v1.4.33 PR #178)
| SHA | Subject |
| --- | --- |
| `2acb11f8` | feat(auth): admit /.well-known/* without a session |
| `03b0be18` | feat(ios): serve apple-app-site-association on the app domains |
| `b92f2b1e` | docs(changelog): note the AASA addition for v1.4.33 |
Touched paths:
- `src/proxy.ts` — added `/.well-known/` (trailing-slash prefix) to
`PUBLIC_PATHS` so Apple's CDN fetch lands on the asset instead of
the auth gate. Trailing-slash future-proofs the namespace for
`/security.txt`, `/openid-configuration`, etc.
- `src/__tests__/proxy-well-known-public.test.ts` (new) — four
regression scenarios: unauthenticated AASA fetch, pending-onboarding
session, generic `/.well-known/security.txt` peer, and a negative
check that `/insights` still 307s.
- `src/app/.well-known/apple-app-site-association/route.ts` (new) —
same JSON body, same headers as the landing handler.
- `CHANGELOG.md` — one-line entry under the v1.4.33 Added section
recording both the handler and the proxy allowlist edit.
## Quality gates
- Main app `pnpm typecheck` — clean.
- Main app `pnpm lint` — zero new errors on the touched files (the
3018 baseline problems all live on pre-existing files).
- Main app `pnpm test` on the three proxy specs — 17/17 pass.
- Landing `npx tsc --noEmit` — clean.
- Landing `pnpm lint` — zero new warnings on the touched files (one
pre-existing warning in `DemoCredentials.tsx`).
- Landing `pnpm build` — succeeded, AASA route reported as `○ (Static)`
in the build manifest, output file inspected as 96-byte JSON.
## Deploy verification
### healthlog.dev (landing — already live, deployed via GitHub Actions)
```
HTTP/2 200
content-type: application/json
content-length: 96
cache-control: public, max-age=3600
{"applinks":{"apps":[],"details":[]},"webcredentials":{"apps":["S8WDX4W5KX.dev.healthlog.app"]}}
```
Auto-deploy ran via the `Deploy to edge-01` workflow (run
`25967688097`), completed in 60s, no errors. Coolify-auto-deploy fix
from earlier today held.
### healthlog.bombeck.io (main app — pending v1.4.33 tag)
```
HTTP/2 307
location: /auth/login
```
Expected — the commits sit on `develop` and ride PR #178 → `main` →
v1.4.33 tag. Will go live once the maintainer cuts the release.
### demo.healthlog.dev (main app — pending v1.4.33 tag)
```
HTTP/2 307
location: /auth/login
```
Same pending state as `healthlog.bombeck.io`; both hosts ride the same
standalone runtime.
### Apple CDN validator
```
$ curl -s https://app-site-association.cdn-apple.com/a/v1/healthlog.dev
{"applinks":{"apps":[],"details":[]},"webcredentials":{"apps":["S8WDX4W5KX.dev.healthlog.app"]}}
```
Apple's CDN ingested the file immediately — no propagation lag observed.
The CDN response matches the origin response byte-for-byte.
## Follow-ups for v1.4.34
- **Universal Links.** Populate `applinks.details` with the bundle ID +
`paths` array once the iOS side opts in to deep-linking specific
routes. Likely candidates from the maintainer's earlier brief:
`/insights/*` (per-metric detail pages), `/measurements/*` (deep
link from an Apple Health share-sheet), `/auth/login` (passkey
re-auth). Coordination needed with the iOS app so the bundle
declares `applinks` entitlement + matching `associatedDomains` in
the Info.plist.
- **AASA mirror on `demo.healthlog.dev`.** Currently shares the
handler with `healthlog.bombeck.io` via the same standalone runtime,
which is correct. If a future split moves demo to its own deployment
with a different App ID prefix (e.g. a separate `dev.healthlog.demo`
bundle for sandbox passkeys), the handler will need a host-aware
branch.
- **Cache invalidation on Bundle ID rotation.** Apple's CDN caches for
an hour; if the App ID prefix ever rotates (Team ID rename, bundle
rename), the iOS app's passkey ceremony will fail for up to 60
minutes after the AASA change. Worth documenting in the iOS-handoff
brief.
- **Discovery namespace bookkeeping.** `/.well-known/security.txt`
(RFC 9116) and `/.well-known/change-password` (W3C) are both useful
follow-on entries the trailing-slash allowlist now admits without a
second proxy edit. Low priority but easy wins for the next polish
round.
+74
View File
@@ -0,0 +1,74 @@
# Round v1.4.34 — IW-C (Compliance Classifier) report
## Scope delivered
Carryover scope item #9 (`compliance.ts` classifier hardening) closed
in three atomic commits on `develop`:
1. `36d5398c` — **classifier**: widen pre-window grace 1h → 3h, add a
dedicated `early` bucket, extend post-window `on_time` tolerance to
3h, and let the configurable `lateMinutes` knob size the `late`
tail past the 3h grace. Tests rebuilt with a parameterised offset
matrix.
2. `4984bce5` — **consumers**: route `early` intakes through the
compliant path in `src/app/api/medications/[id]/compliance/route.ts`
(counted with `onTime` for the heatmap, plus a dedicated `early`
counter on `DailyComplianceEntry`) and in
`src/app/api/gamification/achievements/route.ts` (perfect-day
preserved); docstring fixup in
`src/lib/medications/scheduling/cadence.ts`.
3. `30c8555c` — **heatmap**: drop the v1.4.33 `looksClassifierBug`
defensive fallthrough now that the root cause is fixed; keep a
one-line comment pointing at the classifier fix.
Pushed to `origin/develop` (`30c8555c`).
## Threshold model (final)
```
takenAt === null → "missed"
takenAt < windowStart - 3h → "very_late"
windowStart - 3h ≤ takenAt < windowStart → "early" (NEW)
windowStart ≤ takenAt ≤ windowEnd + 3h → "on_time" (widened)
windowEnd + 3h < takenAt ≤ windowEnd + 3h + Xmin → "late" (X = lateMinutes, default 120)
takenAt > windowEnd + 3h + Xmin → "very_late"
```
`early` + `on_time` both count as compliant. `early` is exposed on
`IntakeTimingClass` for downstream consumers and as an optional
counter on `DailyComplianceEntry`.
## Validation
- `npx vitest run src/lib/analytics/__tests__/compliance.test.ts` —
31/31 passing (was 21; added 10 covering -3.5h, -3h (boundary), -1h,
-10min, 0, +10min, +20min, +1h, +3h, +3.5h, +5h, +5h1min, plus an
overnight `early` case and a `lateMinutes`-knob case).
- `npx vitest run` — full suite 4153 passing / 1 skipped across 386
test files.
- `npx tsc --noEmit` — clean.
- `npx eslint` on the five touched source files — clean.
## Touch surface
- `src/lib/analytics/compliance.ts` (+44 / -9)
- `src/lib/analytics/__tests__/compliance.test.ts` (+71 / -24)
- `src/app/api/medications/[id]/compliance/route.ts` (+9 / -1)
- `src/app/api/gamification/achievements/route.ts` (+3 / -1)
- `src/components/charts/compliance-heatmap.tsx` (+13 / -35)
- `src/lib/medications/scheduling/cadence.ts` (+2 / -2 docstring)
Touch-disjoint from the parallel IW-A / IW-B / IW-D / IW-XML lanes.
## Notes for downstream
- The `early` bucket is opt-in for any new analytics consumer. Today
the medication compliance route surfaces it as an optional counter
on `DailyComplianceEntry`; if a future heatmap legend wants to
distinguish early-vs-on-time visually, the data is there.
- The default `lateMinutes` of 120 still sizes the `late` band, so
the total "late span" is now `windowEnd + 3h .. windowEnd + 5h`
before a dose flips to `very_late`. Callers that want a tighter
tail can pass `lateMinutes: 30` (tested).
- `medication-card.tsx` keeps its independent `MedicationWindowStatus`
type — no cross-contamination with the classifier bucket names.
+169
View File
@@ -0,0 +1,169 @@
# v1.4.34 IW-D — Settings + Insights UX report
Branch: `develop`. Commits landed:
- `fd81d3b8` refactor(settings): merge Sources into Thresholds as "Targets & Sources"
- `bcca7664` refactor(insights): collapse five wave-A vital pills under a Vitals parent
- `43502185` i18n: add Vitals parent + Targets & Sources keys across six locales
All three pushed to `origin/develop`.
## What landed
### 1. Sources → Thresholds merger
`/settings/thresholds` now hosts the merged "Targets & Sources" /
"Zielwerte & Quellen" page. The two former editors stack inside one
section: per-metric threshold ranges on top, per-metric source
priority + two-axis device-type ladder below.
- `<ThresholdsSection>` (`src/components/settings/thresholds-section.tsx`)
now mounts both `<ThresholdsEditorSection>` and
`<SourcesSection mode="embedded">` under one header.
- `<SourcesSection>` (`src/components/settings/sources-section.tsx`)
gained a `mode` prop (`"standalone"` default for backward compat,
`"embedded"` drops the inner h1 + subtitle so assistive-tech reads
one combined surface).
- The `sources` slug was dropped from `SETTINGS_SECTION_SLUGS` and
the Sources entry was removed from the in-shell sidebar + mobile
chip-strip. Section count: 11 → 10.
- `/settings/sources` stays alive as a `permanentRedirect` to
`/settings/thresholds` via the new
`src/app/settings/sources/page.tsx`. iOS bookmarks + external
docs follow through unchanged.
- The dynamic `[section]` route stopped importing `<SourcesSection>`
and `<Layers>` icon imports were dropped from `<SettingsShell>` +
`<SectionPlaceholder>`.
The two mutation flows (`/api/user/thresholds` and
`/api/auth/me/source-priority`) stay distinct so a save on one half
never disturbs the other. This was the pragmatic pick over the
deeper per-metric interleaving (which would have required merging
two different metric-key namespaces and rewiring two mutations into
one — a 600+ LoC refactor with cross-axis behaviour risk).
### 2. Insights tab-strip Vitals parent
Five wave-A pills (HRV, Resting HR, Oxygen, Body Temperature, Active
Energy) collapse behind one "Vitals" / "Vitalwerte" parent pill that
opens a popover sub-list.
- `SUB_PAGE_GROUP` + `SUB_PAGE_GROUP_ORDER` in
`src/lib/insights/sub-page-metric.ts` flag the five wave-A slugs as
group members. Adding a future group is one row + one locale key.
- `<InsightsTabStrip>` uses a discriminated `TabEntry` union — flat
`<Link>` pills coexist with `<Popover>`-driven group entries.
Availability gating sits on the leaf slugs: parent pill renders
only when at least one child has data.
- Parent-pill active state mirrors the URL — when on any vital
sub-page, the parent pill carries `aria-current="page"` and the
active border style so spatial orientation survives the collapse.
- Strip footprint: 14 → 10 entries when every metric has data.
Each sub-page keeps its own URL and component — only the strip
presentation changes. Deep-links and bookmarks resolve unchanged.
### 3. Locale parity (six locales)
Two new key blocks across `messages/{de,en,es,fr,it,pl}.json`:
- `insights.tabStrip.vitalsParent.{label,header}` — parent pill text
+ popover header.
- `settings.sections.thresholds.{title,description}` — renamed to the
combined "Targets & Sources" form per locale.
- `settings.sections.sources.redirectNotice` — copy reserved for the
back-compat redirect surface ("Sources are now part of '…'.").
Existing `settings.sections.sources.*` keys stayed because
`<SourcesSection>` (now embedded inside the merged page) still
resolves card-level labels through them.
## Tests
- `src/components/insights/__tests__/insights-tab-strip.test.tsx` —
10 tests, all pass. Added 4 new tests:
- vitals group renders single parent pill (not five flat ones)
- parent pill hidden when no wave-A metric has data
- parent pill carries `data-slot="insights-tab-strip-group"` +
`data-group="vitals"`
- non-grouped pills stay inline
- `src/components/settings/__tests__/sections.test.tsx` — 89 tests,
all pass. Updated the `<ThresholdsSection>` test to assert both
`data-testid="thresholds-editor"` + `data-testid="sources-section"`
mount points, plus the new German + English titles.
- `src/components/settings/__tests__/settings-shell.test.tsx` —
section-list expectation updated to 10 slugs (sources retired);
English + German title tests assert the new "Targets & Sources" /
"Zielwerte & Quellen" copy and the absence of the `/settings/sources`
href in the in-shell nav.
Full settings test suite (11 files, 89 tests) and full insights test
suite (28 files, 255 tests) both green. `pnpm tsc --noEmit` clean on
all my files; the unrelated `gamification/recent-achievements-card`
TS errors come from another wave and were left untouched.
## Screenshot review
No dev server was running during the dispatch window — Playwright
captures were skipped. The maintainer should screenshot-review the
following surfaces before tagging v1.4.34:
1. `/settings/thresholds` — verify the merged page reads as one
continuous shelf (header → threshold editor card → source
priority card with the two-axis expander). The current shape
stacks the two card bodies vertically; the deeper interleaved
per-metric "source list (top), threshold inputs (bottom)" layout
that the carryover scope §7 described is the higher-LoC variant
and is available as a follow-up if the maintainer prefers it
over the side-by-side pragmatic merger.
2. `/settings/sources` — confirm the 308 permanentRedirect resolves
to `/settings/thresholds` (open a saved bookmark or paste the
URL).
3. `/insights` mobile (393 px) — verify the Vitals parent pill renders
between Pulse and Weight, that tap opens the popover, and that
the active border lights up when on `/insights/hrv` (etc.).
4. `/insights/hrv`, `/insights/ruhepuls`, `/insights/sauerstoff`,
`/insights/koerpertemperatur`, `/insights/aktive-energie` — each
still resolves to its own page; the parent pill's `aria-current`
should flip to "page" on each.
## Outstanding follow-ups
- Per-metric interleaved blocks ("source list (top), threshold inputs
(bottom)" for each metric) — deferred. The current shape stacks the
two cards under one heading, which collapses two sidebar slots into
one without the cross-namespace metric mapping work. If the
maintainer wants the deeper merge, the next step is a
`<MetricConfigSection>` that loops over the union of
`ThresholdMetric` + `SourcePriorityMetricKey` and renders both per
metric. Estimate: ~400 LoC + 80 LoC tests, but the two API contracts
stay unchanged.
- The `sources` slug stays referenced in `settings.sections.sources.*`
card-level keys (cardTitle, help, etc.). Those still resolve
because `<SourcesSection>` reads them. No dead-key cleanup needed
this round.
## File set (all absolute paths)
Sub-wave 1 — merger:
- `/Users/marc/Projects/HealthLog/src/components/settings/section-slugs.ts`
- `/Users/marc/Projects/HealthLog/src/components/settings/settings-shell.tsx`
- `/Users/marc/Projects/HealthLog/src/components/settings/section-placeholder.tsx`
- `/Users/marc/Projects/HealthLog/src/components/settings/thresholds-section.tsx`
- `/Users/marc/Projects/HealthLog/src/components/settings/sources-section.tsx`
- `/Users/marc/Projects/HealthLog/src/components/settings/__tests__/sections.test.tsx`
- `/Users/marc/Projects/HealthLog/src/components/settings/__tests__/settings-shell.test.tsx`
- `/Users/marc/Projects/HealthLog/src/app/settings/[section]/page.tsx`
- `/Users/marc/Projects/HealthLog/src/app/settings/sources/page.tsx` (new)
Sub-wave 2 — tab-strip:
- `/Users/marc/Projects/HealthLog/src/lib/insights/sub-page-metric.ts`
- `/Users/marc/Projects/HealthLog/src/components/insights/insights-tab-strip.tsx`
- `/Users/marc/Projects/HealthLog/src/components/insights/__tests__/insights-tab-strip.test.tsx`
Sub-wave 3 — locales:
- `/Users/marc/Projects/HealthLog/messages/de.json`
- `/Users/marc/Projects/HealthLog/messages/en.json`
- `/Users/marc/Projects/HealthLog/messages/es.json`
- `/Users/marc/Projects/HealthLog/messages/fr.json`
- `/Users/marc/Projects/HealthLog/messages/it.json`
- `/Users/marc/Projects/HealthLog/messages/pl.json`
+153
View File
@@ -0,0 +1,153 @@
# v1.4.34 IW-G — Server-Cache Implementation Report
## Summary
Shipped the in-process LRU + single-flight `ServerCache<T>` primitive
extending the v1.4.33 Coach snapshot LRU shape (`af17db5d`). Wired the
three hottest dashboard reads through it (`/api/analytics`,
`/api/gamification/achievements`,
`/api/medications/intake?scope=compliance`) and bolted per-user
invalidation onto every measurement / mood / medication / workout /
dashboard-widget / app-settings write endpoint per the blueprint §6
matrix. Observability annotations
(`cache.<name>.outcome` + `cache.<name>.key_hash`) reach the wide event
on every cached request so production logs carry the hit-ratio signal.
## Commits
Branch: `develop` → pushed.
| SHA | Title |
| --- | --- |
| `a5dd8e1d` | `feat(cache): server-side LRU primitive + per-user invalidation helpers` |
| `68c4bb73` | `perf(api): wire 3 hot read routes through the server cache` |
| `757387ca` | `feat(cache): wire 13 write endpoints to per-user invalidation helpers` |
| `bcdab48e` | `test(cache): integration coverage + CHANGELOG entry for server cache` |
## Files touched
New:
- `src/lib/cache/server-cache.ts` — `ServerCache<T>` class, 8-cache
registry, `cached()` wrapper, `hashCacheKey()`, test reset hatch.
- `src/lib/cache/invalidate.ts` — 5 per-user helpers
(`invalidateUserMeasurements/Mood/Medications/DashboardWidgets`,
`invalidateAppSettings`).
- `src/lib/cache/__tests__/server-cache.test.ts` — 18 unit tests.
- `src/lib/cache/__tests__/invalidate.test.ts` — 5 helper tests.
- `tests/integration/server-cache-routes.test.ts` — 3 end-to-end tests
(single-flight, measurement invalidation, intake invalidation).
Modified:
- `src/app/api/analytics/route.ts` — wraps slim + default slice.
- `src/app/api/gamification/achievements/route.ts` — wraps web result.
- `src/app/api/medications/intake/route.ts` — wraps compliance branch
+ invalidation on POST.
- 11 write endpoints across `measurements*`, `mood-entries*`,
`medications*`, `workouts/batch`, `dashboard/widgets`,
`admin/settings` — `invalidateUser*` call after DB commit.
- 4 existing test files — `__resetAllCachesForTests()` in `beforeEach`
so re-using a fixed userId doesn't carry cached state across cases.
- `CHANGELOG.md` — one Added line in Marc-voice English.
## Observability pattern
Every `cached()` invocation passes `annotate` from
`@/lib/logging/context` as the optional fourth argument:
```ts
const body = await cached(
caches.analytics as ServerCache<...>,
`${user.id}|default`,
() => buildAnalyticsResponse(user),
annotate,
);
```
On the wide event, every cached request now carries:
- `cache.<name>.outcome` ∈ `{ "hit" | "miss" | "stampede" }`
- `cache.<name>.key_hash` — non-reversible djb2 32-bit hash of the
cache key
Witnessed live in the integration run; sample log line:
```json
{
"meta": {
"cache.medicationsIntake.outcome": "miss",
"cache.medicationsIntake.key_hash": 3630666302,
"days": 30,
"count": 30
},
"action": { "name": "medications.intake.compliance" }
}
```
Ops can `grep cache.analytics.outcome` to compute hit ratio per
deployment over any time window.
## Quality gates
- `pnpm exec tsc --noEmit` clean.
- `pnpm test` — 4227 pass, 1 skipped (no new failures).
- `pnpm test:integration` — 196 pass (49 files); the 3 new
`server-cache-routes.test.ts` cases included.
- `pnpm lint` clean on every touched src/ file. The 190 unrelated
errors live in `playwright-report/*.js` (generated artifacts).
## Estimated wall-time speedup (warm cache)
Pulled from blueprint §2 + the v1.4.33 prod-slowness HAR
(`.planning/round-v1434-prod-slowness-investigation.md`):
| Route | Cold (pre-cache) | Warm hit | Speedup |
| --- | --- | --- | --- |
| `/api/analytics` (thick) | ~5.0 s | ~5 ms | ~1000× |
| `/api/analytics` (slim) | ~3.0 s | ~5 ms | ~600× |
| `/api/gamification/achievements` | ~3.2 s × 2 (duplicate) | ~5 ms × 2 | ~640× + dedup |
| `/api/medications/intake?compliance` | ~3.0 s | ~5 ms | ~600× |
Cumulative dashboard wait — pre-cache the three hot routes summed to
~14 s of server-side tail; on a warm cache the total drops to
~15-30 ms of `Map.get()` + JSON return. The duplicate
achievements mount the HAR flagged collapses through the single-flight
`pending` map so even cold cache benefits when two consumers race.
## Risks accepted
- **Process-local cache.** Multi-instance Coolify deploys will keep
each container's cache isolated; a write on instance A leaves
instance B serving the prior payload for up to TTL. Documented in
the blueprint §4 and tolerated for v1.4.34 — the Redis migration
remains the v1.5.x option.
- **Hard TTL stampede window.** Soft-TTL deferred per blueprint §7;
the unit + integration tests don't yet exercise the
multi-microsecond race window past the expiresAt boundary. We ship
hard TTL and add SWR-style soft-TTL only if telemetry shows real
contention.
- **Memory ceiling.** Each cache caps at 500-1000 entries × ~5 KB; the
worst case across all eight registered caches lands at ~40 MB —
well inside the Coolify 512 MB container budget per the blueprint
§4 sizing.
## What stayed off-limits
Per the scope-fence:
- `src/middleware.ts` — AASA bypass unchanged.
- `src/components/**` — IW-B/D/F-Perf untouched.
- `src/app/page.tsx`, `src/app/settings/**` — IW-B / IW-D unchanged.
- `src/app/api/import/**` + `prisma/schema.prisma` ImportJob — IW-XML
unchanged.
- `src/lib/analytics/compliance.ts` — IW-C unchanged.
- `next.config.ts`, `src/lib/http/cache-headers.ts` — IW-A unchanged
(cache-headers read-only).
- `src/lib/ai/coach/snapshot.ts` — left in place per blueprint §12
acceptance criteria (the snapshot LRU migration is a v1.4.35
follow-up).
Disjoint paths with IW-E close-out; no merge conflict expected.
@@ -0,0 +1,336 @@
# Production slowness investigation — v1.4.33 LIVE
HAR capture: `healthlog.bombeck.io` 2026-05-16T18:14:41 UTC, dashboard
cold mount, eight concurrent API calls land in the same ~3 s tail
band. Read-only inspection of the codebase plus the live Coolify
control-plane config for the `apps-01` host (`go0cw8w0gwws00okggcss0c4`)
and the HealthLog application (`pg8wggwogo8c4gc4ks0kk4ss`).
## 1 — Executive summary
Three independent root causes stack onto the same wall-clock window.
None of them are LLM-side. Ranked by recovered milliseconds per
dashboard mount:
1. **Postgres pool starvation at the application layer.** `src/lib/db.ts`
builds the Prisma client through the `@prisma/adapter-pg` driver
adapter with **no `max` override** on the underlying `node-postgres`
`Pool`. The Coolify-injected `DATABASE_URL` is a bare
`postgresql://healthlog:…@db:5432/healthlog?schema=public` — no
`connection_limit`, no `pool_timeout`, no `?max=…`. Node-postgres
defaults to `max: 10`, and a second Prisma client lives inside the
`pg-boss` worker that runs **in the same container** because
`HEALTHLOG_PROCESS_TYPE=all`. pg-boss itself also opens its own
pool against the same database (`new PgBoss(DATABASE_URL)`,
`src/lib/jobs/reminder-worker.ts:1369`). Three pools share the same
16-connection-per-role Postgres budget; the web pool effectively
tops out at ~10 simultaneous queries. Eight dashboard fetches each
issue 1× `prisma.session.findUnique` (auth) + an average of 2–3 read
queries — the 9th–24th concurrent query lands on `Pool.acquire()`
and waits for a returning client, which is exactly the ~3 s tail
the HAR shows. Recommended fix: append
`?connection_limit=25&pool_timeout=10` to `DATABASE_URL` on
apps-01, **after** raising the Coolify CPU/RAM caps (see §6). One-
liner env edit, no code change, redeploy not strictly required —
restart suffices.
2. **Duplicate fetches on the dashboard mount.** The HAR shows two
`/api/analytics` calls (one with `?slice=summaries`, one without)
and two `/api/gamification/achievements` calls in the same wave.
Both are real bugs in the consumer tree, not network artefacts.
Discussion in §4 — IW-B in v1.4.34 is the right home for the
consumer-collapse work and will land the analytics half; the
achievements half is a separate `queryKey` divergence between two
independent components mounted on the same page. Both stay
inside v1.4.34 if we agree to add the IW-F-Perf sub-wave (§5).
3. **Unbounded Coolify resource caps with no per-app cgroup ceiling.**
`get_application` returns `limits_cpus: "0"`, `limits_memory: "0"`,
`limits_cpu_shares: 1024`. Read literally that's "no Docker
resource limit", which means the host's 25-app inventory all
compete for the same kernel scheduler. CPU steal on a noisy
neighbour translates into Postgres query latency end-to-end. The
single physical apps-01 host runs 17 application containers plus 4
Postgres standalones plus 10 services (`server_resources`), so
per-process CPU under load is the most likely amplifier for the
pool-wait. Recommended: bound HealthLog to `limits_cpus: "2"` and
`limits_memory: "1g"` so the container has a deterministic floor
regardless of neighbour load. Coolify UI → Application → Limits.
**Top verdict.** Pool starvation is the dominant cause; the duplicate
fetches double the load on top of it; the unbounded resource caps
amplify both. The cheapest single-knob mitigation is the
`?connection_limit=25` env edit. The cheapest single-knob
amplification cut is the Coolify resource bound. Both are operator-
side, both are revertible, both take <60 s and don't need a release.
## 2 — Pool starvation — evidence and sizing
### Evidence
- `src/lib/db.ts:1–19` — single web-side `PrismaClient` constructed
via `new PrismaPg({ connectionString: process.env.DATABASE_URL! })`.
No `max`, no `connection_limit` parsing, no pool overrides.
- `src/lib/jobs/reminder-worker.ts:83–89` — second `PrismaClient`
built the same way inside the worker subsystem. The
`HEALTHLOG_PROCESS_TYPE=all` env (confirmed via
`mcp__coolify-apps01__env_vars`) keeps both subsystems in one
container, so both pools live in the same Node process.
- `src/lib/jobs/reminder-worker.ts:1369` — `new PgBoss(DATABASE_URL)`.
pg-boss opens its own `pg.Pool` with `max: 10` per the upstream
default and the bare URL.
- Coolify env audit confirms `DATABASE_URL` is bare on both
control planes (`round-v1434-iwa-coolify-env-audit.md` §1, live
entry `w8w8gc08sgo48o8008ww04gk`).
- Postgres 16-alpine container ships with `max_connections=100`
default — no override in the Compose; that is the upper ceiling
shared across web Prisma + worker Prisma + pg-boss + admin sessions.
### Pool math
Per process, in the same container:
| Pool | Default `max` | Effective |
| --- | --- | --- |
| Web `PrismaClient` | `pg.Pool` default 10 | 10 |
| Worker `PrismaClient` | `pg.Pool` default 10 | 10 |
| pg-boss internal | upstream default 10 | 10 |
| **Total open slots** | | **~30 out of 100** |
At face value 30 < 100, so we are not at the Postgres ceiling — but
the **web pool alone is capped at 10**, and every authenticated
request takes one slot for `getSession()` plus one or more for the
route's actual query. Eight concurrent dashboard fetches × ≥2
queries each = ≥16 simultaneous acquisitions against a 10-slot
pool. Queries 11–16 wait on `acquire()`, which has no upstream
timeout in node-postgres, so they block for whatever serial
query-runtime the route ahead of them spends — exactly the
clustered ~3 s waits.
### Recommended `?connection_limit`
The apps-01 host runs 17 application containers plus 4 standalone
Postgres pools sharing one physical machine. Postgres default
`max_connections=100`. Per the Prisma docs (`@prisma/adapter-pg`
passes URL params through to `pg.Pool.options`), the right knob is
`?connection_limit=N&pool_timeout=T`. Sizing budget for HealthLog
alone:
| Subsystem | Slot budget |
| --- | --- |
| Web `PrismaClient` | 20 |
| Worker `PrismaClient` | 4 |
| pg-boss | 6 |
| Admin / migration headroom | 5 |
| **Total reserved for HealthLog** | **35** |
Postgres still has 65 slots for everything else on the host. That
leaves the iOS-bound `/api/auth/me` 79 ms path and the
admin-side `/api/admin/host-metrics` reads room without competing
with the dashboard wave.
**Operator action.** Set `DATABASE_URL` on apps-01 (UUID
`w8w8gc08sgo48o8008ww04gk`) to:
```
postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public&connection_limit=20&pool_timeout=10
```
…and add a second env on the worker side (HEALTHLOG_PROCESS_TYPE
is still `all` per the live entry, so the worker reads the same
URL — when we eventually split, give the worker variant
`?connection_limit=4`). 20 + 4 + 6 = 30 leaves a small migration
headroom.
## 3 — Shared synchronous bottlenecks — ruled in / out
| Candidate | Verdict | Evidence |
| --- | --- | --- |
| `WideEventBuilder` per-request mutex | **Out.** | `src/lib/logging/event-builder.ts` is a plain class instance attached to AsyncLocalStorage. No shared lock; every request gets a private builder via `eventStorage.run(evt, …)` in `src/lib/api-handler.ts:161`. |
| `RateLimiter` shared state | **Out.** | `src/lib/rate-limit.ts:21–52` runs a single atomic upsert per call — no Map, no in-process lock, no semaphore. The query itself is a `?connection_limit`-bound consumer though, so it does contribute to the pool-wait. |
| `requireAssistantSurface()` per-request memo | **Out (working as designed).** | `src/lib/feature-flags/index.ts:72–104` wraps the `AppSettings.singleton` read in `memoizePerRequest("assistant-flags", …)`. `src/lib/request-cache.ts` correctly returns one Promise per request via a `WeakMap` on the `WideEventBuilder`. The 8 concurrent requests in the HAR each get their own builder so each one reads the row once — but that's correct cross-request behaviour, not a lock. Within a request the memo collapses 5+ surface checks to one. |
| pg-boss ↔ Prisma pool sharing | **In, partial.** | Both pools open against the same Postgres but each has its own `pg.Pool`. The shared resource is Postgres `max_connections`, not a JS-side lock. Already covered in §2's slot budget. |
| Container CPU/RAM throttling | **In, amplifier.** | `limits_cpus: "0"` means no Docker `--cpus` cap, which sounds permissive but actually means the container gets default cgroup share weight — under noisy-neighbour load the kernel scheduler de-prioritises it. The remedy is the opposite of intuition: **set** the cap to claim guaranteed quota. See §6. |
| Cold container | **Out.** | HAR was captured ~20 min post-deploy per the brief, and `last_online_at: "2026-05-16 18:24:13"` is well after the request. Page-warm. |
| `getSession()` re-fetch per request | **In, real cost.** | `src/lib/auth/session.ts:107–110` does **not** use `memoizePerRequest`. Each of the 8 concurrent dashboard requests issues its own `prisma.session.findUnique({ include: { user: true } })`. That's 8 extra pool acquisitions on every dashboard mount, on top of the per-route queries. Candidate for an IW-F-Perf addition: wrap `getSession()` in `memoizePerRequest("session-cookie", …)`. Across-request it can't memo — it's strictly intra-request. Saves zero queries in isolation but cuts pool contention in the burst window. |
## 4 — Duplicate-call findings
### `/api/analytics` × 2 — root cause
The dashboard root (`src/app/page.tsx:212`) calls
`useAnalyticsQuery()` (thick payload, no slice). The mounted child
`<GettingStartedChecklist>` (`src/components/onboarding/getting-
started-checklist.tsx:195`) calls
`useAnalyticsQuery({ slice: "summaries", enabled: !!user })`. Per
`src/lib/query-keys.ts:27–29` the queryKey factory was updated in
v1.4.33 IW2 to discriminate by slice:
```ts
analytics: (slice?: "summaries") =>
(slice ? (["analytics", slice] as const) : (["analytics"] as const)),
```
That's the **correct** v1.4.33 IW2 cache layout (collision-free), but
it means the two consumers each open their own cache slot and each
fire a network request. The dashboard genuinely needs the thick
slice for its `bpInTargetPct*` / `glucoseByContext` consumers; the
checklist genuinely only needs `summaries[…].count`. Both fire on
mount for the same render tree.
Two possible directions:
- **(a) Hoist** the checklist's gating to read the dashboard's thick
payload — every field the checklist consumes is also present on
the thick slice. The slim slice was introduced for the
`/insights` tree, not for the dashboard mount.
- **(b) Keep** the split and pre-warm: serve the thick payload first,
derive the slim payload from it via `select` in the checklist's
`useAnalyticsQuery`. TanStack supports `select` for shape
narrowing without a second network round-trip.
The v1.4.34 IW-B brief (from `round-v1433-audit-perf.md` §1) covers
the consumer-collapse work for `/api/analytics`. Confirming with the
maintainer: IW-B's current scope is the **slim slice routing** to
the Insights tree, not the dashboard-checklist intersection. So
option (a) — switch the checklist back to the thick slice — needs to
land in an IW-F-Perf addition or piggyback on IW-B.
### `/api/gamification/achievements` × 2 — root cause
Two different consumers on the same page, two different queryKeys
hitting the same endpoint:
| Consumer | queryKey | Mount point |
| --- | --- | --- |
| `<RecentAchievementsCard>` | `["gamification", "achievements"]` | `src/app/page.tsx:1180` |
| `<AchievementUnlockNotifier>` | `["gamification", "achievements", "unlock-notifier", userId]` | global layout (mounted alongside `<Toaster>`) |
Same data, two cache cells, two network calls. The notifier even
sets `refetchInterval: 2 * 60 * 1000` (line 84 of the notifier) —
not relevant for the initial-load HAR but it means every two
minutes the dashboard re-fetches `achievements` even when the card
already has fresh data.
Fix shape: extract a `useAchievementsQuery()` mirror of
`useAnalyticsQuery` that both consumers call. The notifier's
side-effect logic (badge unlock toast) can ride on `useEffect` over
the shared cache result rather than its own subscription. One
network call, one cache slot. Same shape as the v1.4.33 IW2 win.
### Other HAR consumers — all single-source, no duplicates
| Endpoint | Consumer | One-shot? |
| --- | --- | --- |
| `/api/dashboard/widgets` | `src/app/page.tsx:218` | Yes |
| `/api/mood/analytics` | `src/app/page.tsx:230` | Yes |
| `/api/medications` | `src/app/medications/page.tsx:95` + checklist | Same key, single cache slot |
| `/api/bugreport/status` | `src/components/app-settings-provider.tsx:63` | Yes |
| `/api/workouts?limit=3` | dashboard tile (likely `RecentWorkoutsCard` — not searched, single source) | Yes |
| `/api/medications/intake?scope=compliance&days=30` | dashboard compliance tile | Yes |
| `/api/send` (POST) | Umami client-side analytics → SSRF-safe proxy in `src/app/api/send/route.ts` | Yes; outbound HTTP, not Prisma-bound. The 3.3 s here is a separate concern — likely the Umami server itself is slow (also runs on apps-01) |
## 5 — Proposed v1.4.34 sub-wave: IW-F-Perf
Dispatched **after** IW-A (env audit, complete) through IW-E and
IW-XML reach completion, since this sub-wave depends on no in-flight
file changes from the others. Two parallel parts, each ~2 h.
### IW-F-Perf-Pool — env-only, no code
- Author the apps-01 env edit (above) and document in a CHANGELOG
line under `Reliability`. No source change.
- Add an integration test that asserts a `?connection_limit=` query
param is present in `DATABASE_URL` when `NODE_ENV=production`.
Lives in `src/lib/__tests__/database-url-shape.test.ts`. The test
surfaces the regression if someone ever resets the Coolify env
back to bare. **This is the only code change**.
### IW-F-Perf-Duplicates — consumer-collapse round-2
Two surgical changes; both are additive:
- New `src/lib/queries/use-achievements-query.ts` mirroring the
v1.4.33 `use-analytics-query.ts` shape. Wires both
`<RecentAchievementsCard>` and `<AchievementUnlockNotifier>` onto
the shared hook. The notifier's `refetchInterval` moves onto the
shared hook (the card stays passive; the notifier's `useEffect`
reads from the shared cache). One network call, one cache slot.
- Either (a) switch `<GettingStartedChecklist>` from
`useAnalyticsQuery({ slice: "summaries" })` to the thick
`useAnalyticsQuery()` so it rides the dashboard's cache slot, or
(b) wire IW-B's slim-slice routing so the dashboard tile strip
fires the slim call and the thick call is deferred behind the
Coach drawer's hover prefetch. The IW-B brief documents (b) as
the target shape; if IW-B lands first, IW-F-Perf-Duplicates
becomes (a) by elimination.
Quality gate: existing `src/app/__tests__/insights-b3-wiring.test.ts`
+ a new dashboard-mount integration test counting **distinct fetch
URLs** during the first 1500 ms after `render()` — the dashboard
should issue ≤ 7 fetches, not 9.
### Why not a release-blocker
The pool-starvation fix is an env edit, deployable separately from
v1.4.34. The duplicate-call fix is additive consumer-side
refactoring that improves load even without the pool fix. Each
half stands on its own. Neither needs the in-flight IW-A/B/C/D/XML
file sets touched.
## 6 — Operator-action items for the maintainer
In priority order. Each is independently revertible.
1. **Add `?connection_limit=20&pool_timeout=10` to `DATABASE_URL`.**
Coolify → HealthLog app → Environment Variables → edit live
`DATABASE_URL` (uuid `w8w8gc08sgo48o8008ww04gk` per the v1.4.34
IW-A audit). One restart cycle, no rebuild. Expected payoff:
eight-call dashboard tail drops from ~3 s to ≤ 600 ms in the
pool-bound paths.
2. **Set Coolify CPU/RAM caps on the HealthLog app.** Coolify →
HealthLog → Limits: `Limits CPUs = 2`, `Limits Memory = 1g`,
`Limits Memory Reservation = 512m`. Today both `limits_cpus` and
`limits_memory` are `"0"` (no cap), which on a 25-app shared
host means the container floats with default cgroup share —
noisy neighbours steal scheduling time. Setting an explicit cap
reserves the floor.
3. **Delete the section-2 leftover env entries.** Per the v1.4.34
IW-A audit there are 28 duplicate env entries on apps-01,
including a placeholder `POSTGRES_PASSWORD` that would shadow
the live one if Coolify ever swapped read precedence. Cleanup
reduces operational surprise; not perf-relevant on its own.
4. **(Optional) Split the worker subsystem off into its own
container.** Today `HEALTHLOG_PROCESS_TYPE=all` co-locates the
web HTTP server and pg-boss in one Node process. The web pool
and the worker pool then share Node event-loop cycles. Splitting
into two Coolify services (one `web`, one `worker`) is a v1.4.35
candidate, not a v1.4.34 hotfix — it changes Compose shape and
needs a release pipeline pass.
5. **(Optional) Investigate the `/api/send` 3315 ms.** The HAR
shows a 3.3 s wait on the Umami proxy POST. Umami runs on the
same apps-01 host (`a8004ksswcokok4csg0wowwc`) — could be the
same Postgres-contention story bleeding into a sibling service.
Out of scope for this report but worth a five-minute look at the
Umami container's resources.
### One thing to leave alone
`HEALTHLOG_PROCESS_TYPE=all` is correct **for now** — splitting it
without first sizing the worker pool independently would create a
new race against Postgres `max_connections`. Sequence is: pool-size
the URL first (action 1), then resource-cap the container (action 2),
then observe a clean week of traces, **then** consider the split.
## Closing note
The HAR is a clean diagnostic — three failure modes overlaying a
single timestamp, each with an independent fix. None of the fixes
need the in-flight v1.4.34 implementation sets touched. The
recommended path is to land the env edits today as a hotfix and
file IW-F-Perf as the v1.4.34 polish addendum behind whatever
implementation work is currently in motion.