mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
v1.4.34.1 — insights cold-mount perf hotfix + scatter-card sizing
Hotfix on top of v1.4.34. The Insights page mount was paying ~29 s on
every cold load against accounts populated by the v1.4.34 Apple Health
importer. Root cause: `/api/insights/comprehensive` walked the unbounded
90-day measurement set into JS, then ran per-type `summarize()`, BMI,
blood-pressure classification, target adherence, and four Pearson
correlations from the same pile of rows. On a 100 000+ row account the
route consumed one of the 20 pool connections for the whole 29 s, which
cascaded sibling endpoints into Cloudflare "no available server" 503s.
The scatter-correlation card also surfaced a `width(-1)/height(-1)`
Recharts warning on first paint because its dynamic-mount lacked a
non-zero floor before aspect-ratio resolved.
Changed:
- /api/insights/comprehensive now reads through a SQL-side aggregator
(src/lib/insights/comprehensive-aggregator.ts) grouping by metric
type with Postgres-native AVG / MIN / MAX / REGR_SLOPE / REGR_R2 on
the measurements (user_id, type, measured_at) index path. Returns
the same DataSummary-shaped per-type bundle the legacy route
stitched together; the medication-compliance block keeps its
bounded Prisma reads. Wrapped through the server cache keyed on
`${userId}|comprehensive` (60 s TTL). Cold path drops from ~29 s
to a few hundred ms against fixture data matching the production
volume that triggered the bug; warm path resolves in single-digit
milliseconds.
- Cache-wrap the five remaining read routes the v1.4.34 server-cache
blueprint left staged but unwired: /api/mood/analytics,
/api/workouts, /api/bugreport/status, /api/medications,
/api/dashboard/widgets. The Cloudflare 503 cascade on
/api/measurements?aggregate=daily resolves as a downstream
consequence — once the comprehensive route stops monopolising the
pool, the origin accepts new connections fast enough that
Cloudflare's circuit-breaker no longer trips.
- /api/dashboard/chart-overlay-prefs PUT now invalidates the new
dashboard-widgets cache so the partial-update path doesn't leave
stale layout state in the cache for up to TTL.
Fixed:
- Scatter-correlation card no longer logs the Recharts
`width(-1)/height(-1)` warning on first mount. The wrapper now
carries a min-h-[180px] floor that matches the loading skeleton so
ResponsiveContainer always measures a non-zero parent before
aspect-ratio settles.
This commit is contained in:
1 parent
affede31a4
commit
d74a0efab9
24 files changed
+4590
-241
No files matched your search
@@ -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.
|
||||
@@ -0,0 +1,480 @@
|
||||
---
|
||||
file: .planning/round-v1434-closure-report.md
|
||||
purpose: v1.4.34 release closure — apple-health import + reliability + web freeze
|
||||
created: 2026-05-17
|
||||
tag: v1.4.34
|
||||
---
|
||||
|
||||
# v1.4.34 — release closure
|
||||
|
||||
Shipped 2026-05-17. The final functional web release before the iOS
|
||||
native client lands. Headline work: a streaming Apple Health
|
||||
`export.zip` importer, a server-side aggregation cache that collapses
|
||||
the three hottest dashboard reads onto in-process LRU slots with
|
||||
single-flight coalescing and per-user invalidation, a broader
|
||||
compliance classifier with a dedicated `early` bucket, the Coach
|
||||
launch surface hoisted onto every authed page with a dashboard-hero
|
||||
CTA, a trimmer Settings shelf that folds Sources into Targets, two
|
||||
e2e flake-window tightenings, and the web-freeze marker pinned across
|
||||
CHANGELOG + Prisma schema head comment + the v1.5 strategic plan
|
||||
decision log.
|
||||
|
||||
## Outcome
|
||||
|
||||
- GitHub Release:
|
||||
<https://github.com/MBombeck/HealthLog/releases/tag/v1.4.34>.
|
||||
- Tag `v1.4.34` →
|
||||
`affede31a408974d016f4f6b47a9cb0cd703d8d0` on `main`
|
||||
(PR #179 squash, merged at 2026-05-16T19:25:26Z).
|
||||
- Sister-repos:
|
||||
- `healthlog-docs@5391448` — image-pin bump in
|
||||
`src/content/docs/self-hosting/updates.mdx` from `:1.4.33`
|
||||
to `:1.4.34`.
|
||||
- `healthlog-landing@33e6039` — already on the expected HEAD
|
||||
from the v1.4.33 AASA-rollout round; no drift, no commit
|
||||
needed.
|
||||
- GHCR build conclusion: SUCCESS — single multi-arch
|
||||
(linux/amd64 + linux/arm64) build on tag push
|
||||
(`25970776818`), 4 m 47 s linux/amd64 + 4 m 16 s linux/arm64,
|
||||
manifest-merge step green; image digest
|
||||
`sha256:3f268a9e390cfccb22795bb1755c8786ea8cb276573f02744ee58954722b3166`.
|
||||
IW-A's `docker-publish.yml` workflow gate held — exactly one
|
||||
build fired per tag (vs the double-build pattern noted in
|
||||
the v1.4.33 iceberg).
|
||||
- Coolify auto-deploy: **fallback used on both hosts.**
|
||||
- apps-01: Coolify-API redeploy (MCP `deploy --force`)
|
||||
queued + finished at 2026-05-16T19:32:36Z but did not pull
|
||||
the new image because the compose file pins `:latest` and
|
||||
the local `:latest` was stale. Host-side retag fallback
|
||||
applied (`docker pull :1.4.34` + `docker tag :latest` +
|
||||
`docker compose up -d --force-recreate app`).
|
||||
- edge-01: Coolify MCP still unreachable (matches v1.4.33
|
||||
iceberg note). SSH host-side compose-pin bump applied:
|
||||
`sed -i 's|:1.4.33|:1.4.34|g' docker-compose.yaml` +
|
||||
`docker pull` + `docker compose up -d --force-recreate`.
|
||||
- Pre-deploy compose backups created at
|
||||
`docker-compose.yaml.pre-v1434.bak` on both hosts.
|
||||
|
||||
## Commits on develop since v1.4.33 closure
|
||||
|
||||
Forty-two commits between `b8f6b13a` (v1.4.33 release commit on
|
||||
develop) and the v1.4.34 release tag, including the trailing
|
||||
v1.4.33-finalisation tail (AASA + planning), the post-merge
|
||||
reconciliation merge commit, and the v1.4.34 work proper. The
|
||||
v1.4.34 work proper (after `72a72266 docs(planning): v1.4.33
|
||||
closure`) groups as:
|
||||
|
||||
### IW-A — Infrastructure carryovers
|
||||
|
||||
- `8e05f922` feat(http): centralise authed Cache-Control with
|
||||
bfcache-friendly preset
|
||||
- `42432832` chore(build): silence NFT-trace warnings and ship
|
||||
the bfcache header
|
||||
- `24b8d52f` ci(docker): fire the release build once per tag
|
||||
- `5daa78da` docs(planning): document apps-01 env-var duplicate
|
||||
sections
|
||||
- `aac2ab68` docs(planning): v1.4.34 IW-A close-out report
|
||||
|
||||
### IW-XML — Apple Health `export.zip` import
|
||||
|
||||
- `2a3fb0e9` chore(deps): add sax for streaming Apple Health
|
||||
XML import
|
||||
- `3d309bb9` feat(import): add ImportJob model for Apple Health
|
||||
export ingest
|
||||
- `3e2d2c46` feat(import): streaming Apple Health export.xml
|
||||
parser + mapper
|
||||
- `9c227894` feat(jobs): apple-health-import worker queue
|
||||
- `73cd44e0` feat(import): Apple Health export.zip endpoints +
|
||||
multipart streamer
|
||||
- `c6528b93` test(import): cover Apple Health export.zip parser
|
||||
+ endpoints
|
||||
- `cc3aae97` docs(changelog): note the Apple Health
|
||||
`export.zip` import for v1.4.34
|
||||
- `1413bc39` docs(planning): IW-XML close-out report
|
||||
|
||||
### IW-B — Dashboard + Coach + Cache-Control
|
||||
|
||||
- `9a8a9e9a` feat(analytics): expose per-type lastSeenByType +
|
||||
bfcache-friendly Cache-Control
|
||||
- `721e88df` feat(i18n): add stale-hint week/month plurals and
|
||||
dashboard coach CTA
|
||||
- `0729c202` feat(dashboard): hoist Coach launch provider and
|
||||
add hero CTA
|
||||
- `4d404542` docs(planning): v1.4.34 IW-B close-out report
|
||||
|
||||
### IW-C — Compliance classifier
|
||||
|
||||
- `36d5398c` fix(compliance): widen grace window and add
|
||||
`early` bucket
|
||||
- `4984bce5` fix(compliance): route `early` intakes through
|
||||
the compliant bucket
|
||||
- `30c8555c` refactor(charts): drop the v1.4.33 heatmap
|
||||
classifier-bug fallback
|
||||
|
||||
### IW-D — Settings + Insights UX
|
||||
|
||||
- `fd81d3b8` refactor(settings): merge Sources into Thresholds
|
||||
as "Targets & Sources"
|
||||
- `bcca7664` refactor(insights): collapse five vital pills under
|
||||
a Vitals parent
|
||||
- `43502185` i18n: add Vitals parent + Targets & Sources keys
|
||||
across six locales
|
||||
|
||||
### IW-F-Perf — Consumer collapse
|
||||
|
||||
- `ca7ca0e7` feat(queries): shared achievements query hook
|
||||
- `3b77b6a0` refactor(gamification): collapse achievement
|
||||
consumers onto shared hook
|
||||
- `c6345b71` test(queries): pin achievements hook collapse +
|
||||
audit analytics consumers
|
||||
- `6529073c` docs(planning): IW-F-Perf close-out report
|
||||
|
||||
### IW-G — Server-cache primitive + 3 hot routes + 13 invalidation hooks
|
||||
|
||||
- `a5dd8e1d` feat(cache): server-side LRU primitive + per-user
|
||||
invalidation helpers (also carries the IW-E web-freeze marker
|
||||
edits, see §"Pool-bump hot-fix + observability" below)
|
||||
- `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
|
||||
|
||||
### IW-E — Web-Freeze marker + e2e flake fixes
|
||||
|
||||
- `fef24a89` test(e2e): stabilise onboarding-flicker +
|
||||
mobile-viewport probes
|
||||
- `13b451a5` docs(planning): v1.4.34 IW-E close-out report
|
||||
- (web-freeze edits on `CHANGELOG.md` + `prisma/schema.prisma` +
|
||||
`.planning/v15-strategic-plan.md` rode along with `a5dd8e1d`
|
||||
through a concurrent-worker staging race — content is correct,
|
||||
attribution is the IW-G commit)
|
||||
|
||||
### Release commits
|
||||
|
||||
- `72b2506a` docs(changelog): clean v1.4.34 entry
|
||||
- `1ae4adfd` chore(release): v1.4.34
|
||||
- `fd1a4366` Merge remote-tracking branch 'origin/main' into
|
||||
develop (post-v1.4.33 reconcile)
|
||||
|
||||
## Verification
|
||||
|
||||
All checks performed at 2026-05-17T00:30 UTC after the host-side
|
||||
retag fallback completed on both hosts.
|
||||
|
||||
### apps-01 (`healthlog.bombeck.io`)
|
||||
|
||||
```
|
||||
$ curl -s https://healthlog.bombeck.io/api/version
|
||||
{"data":{"version":"1.4.34","buildSha":null,"builtAt":null,"license":"AGPL-3.0",
|
||||
"repository":"https://github.com/MBombeck/HealthLog",
|
||||
"changelog":"https://github.com/MBombeck/HealthLog/blob/main/CHANGELOG.md",
|
||||
"docs":"https://docs.healthlog.dev","offlineGeoEnabled":false},"error":null}
|
||||
|
||||
$ curl -sI https://healthlog.bombeck.io/privacy
|
||||
HTTP/2 200
|
||||
|
||||
$ curl -s https://healthlog.bombeck.io/.well-known/apple-app-site-association
|
||||
{"applinks":{"apps":[],"details":[]},"webcredentials":{"apps":["S8WDX4W5KX.dev.healthlog.app"]}}
|
||||
```
|
||||
|
||||
### edge-01 (`demo.healthlog.dev`)
|
||||
|
||||
```
|
||||
$ curl -s https://demo.healthlog.dev/api/version
|
||||
{"data":{"version":"1.4.34","buildSha":null,"builtAt":null,"license":"AGPL-3.0",
|
||||
"repository":"https://github.com/MBombeck/HealthLog",
|
||||
"changelog":"https://github.com/MBombeck/HealthLog/blob/main/CHANGELOG.md",
|
||||
"docs":"https://docs.healthlog.dev","offlineGeoEnabled":false},"error":null}
|
||||
|
||||
$ curl -sI https://demo.healthlog.dev/privacy
|
||||
HTTP/2 200
|
||||
|
||||
$ curl -s https://demo.healthlog.dev/.well-known/apple-app-site-association
|
||||
{"applinks":{"apps":[],"details":[]},"webcredentials":{"apps":["S8WDX4W5KX.dev.healthlog.app"]}}
|
||||
```
|
||||
|
||||
### Landing (`healthlog.dev`)
|
||||
|
||||
```
|
||||
$ curl -sI https://healthlog.dev/privacy
|
||||
HTTP/2 200
|
||||
$ curl -sI https://healthlog.dev/support
|
||||
HTTP/2 200
|
||||
$ curl -s https://healthlog.dev/.well-known/apple-app-site-association
|
||||
{"applinks":{"apps":[],"details":[]},"webcredentials":{"apps":["S8WDX4W5KX.dev.healthlog.app"]}}
|
||||
```
|
||||
|
||||
### Apple CDN re-probe
|
||||
|
||||
- `app-site-association.cdn-apple.com/a/v1/healthlog.dev` →
|
||||
200, matches origin byte-for-byte.
|
||||
- `app-site-association.cdn-apple.com/a/v1/demo.healthlog.dev`
|
||||
→ 200, matches origin byte-for-byte.
|
||||
- `app-site-association.cdn-apple.com/a/v1/healthlog.bombeck.io`
|
||||
→ 200, matches origin byte-for-byte. **Ingested since
|
||||
v1.4.33** (was 404 in the v1.4.33 closure-report; Apple's
|
||||
CDN walked the new origin after the first authenticated
|
||||
fetch).
|
||||
|
||||
### CI on PR #179
|
||||
|
||||
- 6/7 green: Build linux/amd64, Build linux/arm64, integration,
|
||||
Lint/Typecheck/Test, dependency audit, secret scanning.
|
||||
- e2e: 3 pre-existing v1.4.32-era flakes (onboarding-flicker
|
||||
desktop + mobile, mobile-viewport touch-targets) — IW-E's
|
||||
tightening landed on the spec selectors but the CI run still
|
||||
caught the same three windows. Accepted ship-with-flakes per
|
||||
the maintainer's standing decision.
|
||||
|
||||
## Pool-bump hot-fix + observability
|
||||
|
||||
### apps-01 `DATABASE_URL` pool-bump
|
||||
|
||||
Mid-session env-only change (no code, no release) per
|
||||
`.planning/round-v1434-prod-slowness-investigation.md` §2:
|
||||
`DATABASE_URL` was edited on apps-01 to append
|
||||
`?connection_limit=20&pool_timeout=10` so the prisma + pg-boss
|
||||
pools share a deterministic ceiling instead of starving each
|
||||
other under load. Followed by a Coolify redeploy to pick up the
|
||||
new env. **edge-01 still has the bare `DATABASE_URL`** because
|
||||
the edge-01 Coolify MCP daemon was unreachable during the
|
||||
session — operator action pending the MCP daemon restart.
|
||||
|
||||
Once edge-01 is reachable, the same edit applies:
|
||||
- env var: `DATABASE_URL`
|
||||
- append: `?connection_limit=20&pool_timeout=10`
|
||||
- redeploy (or container restart) to flush the prisma + pg-boss
|
||||
pool instances and pick up the new connection-string.
|
||||
|
||||
### Server-cache observability
|
||||
|
||||
`ServerCache<T>` from IW-G ships an `annotate` hook that lands
|
||||
two keys on every wide-event for a cached request:
|
||||
|
||||
- `cache.<name>.outcome` ∈ `{ hit | miss | stampede }`
|
||||
- `cache.<name>.key_hash` — non-reversible djb2 32-bit hash of
|
||||
the cache key (`userId|scope`)
|
||||
|
||||
Prod log grep recipes for hit-ratio:
|
||||
|
||||
```
|
||||
# All analytics outcomes in the last hour
|
||||
... | grep -E 'cache\.analytics\.outcome' | jq -r '.meta."cache.analytics.outcome"' | sort | uniq -c
|
||||
|
||||
# Achievements + intake + analytics hit-ratio in one pass
|
||||
... | grep -E 'cache\.(analytics|achievements|medicationsIntake)\.outcome' | jq -r '"\(.meta | to_entries | map(select(.key | endswith(".outcome"))) | .[0].key) \(.meta | to_entries | map(select(.key | endswith(".outcome"))) | .[0].value)"' | sort | uniq -c
|
||||
```
|
||||
|
||||
Eight registered caches sit behind the registry; capacity caps
|
||||
land each instance below 500-1000 entries × ~5 KB so worst-case
|
||||
memory across the eight is ~40 MB — comfortably inside the
|
||||
512 MB Coolify container budget. Process-local cache only;
|
||||
multi-instance Coolify deploys would diverge per container
|
||||
until the v1.5.x Redis migration.
|
||||
|
||||
## Apple Health import perf
|
||||
|
||||
The IW-XML synthetic 10 000-record case stresses the
|
||||
cumulative-fold path (every row collapses into one
|
||||
`stats:HKQuantityTypeIdentifierStepCount:YYYY-MM-DD` daily
|
||||
bucket) and lands at 312 ms wall-clock with a heap-delta ceiling
|
||||
under 100 MB. A bounded 1 GB synthetic-fixture test was
|
||||
deliberately deferred — the in-process heap-delta assertion
|
||||
already exercises the streaming property under test
|
||||
(per-token SAX, bounded buffers), and the integration-tier
|
||||
1 GB fixture would require a `testcontainers` Postgres + several
|
||||
minutes per run. Adding the 1 GB-fixture path to a nightly
|
||||
perf suite tracks under v1.4.35 backlog.
|
||||
|
||||
## Resource-limits Coolify resize
|
||||
|
||||
Per the v1.4.33 prod-slowness investigation §3 (and noted in
|
||||
the iceberg), the apps-01 HealthLog application still has
|
||||
`limits_cpus: "0"` + `limits_memory: "0"` in the Coolify
|
||||
control-plane config — i.e. no Docker resource limits at all,
|
||||
so the host's 25-app inventory shares the same kernel scheduler
|
||||
budget. The recommended UI-action is to bind HealthLog to
|
||||
`limits_cpus: "2"` + `limits_memory: "1g"` in the Coolify
|
||||
Application → Limits panel so the container has a deterministic
|
||||
floor regardless of neighbour load. This is operator-action
|
||||
only and pending the maintainer's UI access.
|
||||
|
||||
## Carry-overs to v1.4.x / v1.5.x
|
||||
|
||||
### Behind the web-freeze gate
|
||||
|
||||
The web-freeze marker now pins additive-only changes for the
|
||||
v1.4.x tail. Concrete items still on the runway:
|
||||
|
||||
- **Apple Health import: 1 GB synthetic-fixture perf test**
|
||||
under the nightly suite (IW-XML defer).
|
||||
- **Apple Health import: HKClinicalRecord / FHIR ingest**
|
||||
(IW-XML §13, post v1.5).
|
||||
- **Apple Health import: HKElectrocardiogram waveforms**
|
||||
(no HealthLog ECG model yet, deferred indefinitely).
|
||||
- **Apple Health import: workout routes (`workout-routes/*.gpx`)**
|
||||
— schema field exists, parser write-back deferred to a
|
||||
post-v1.4.34 follow-up.
|
||||
- **`getting-started-checklist.tsx` thick-slice swap** — drop
|
||||
the dashboard's duplicate slim-call once IW-B's
|
||||
`lastSeenByType` contract stabilises (IW-F-Perf §3).
|
||||
- **Per-metric interleaved Settings blocks** — IW-D's
|
||||
Targets+Sources merger stacks both card bodies vertically;
|
||||
the deeper "metric-by-metric, threshold on top, source list
|
||||
below" merge needs a ~400 LoC `<MetricConfigSection>` and
|
||||
cross-namespace metric mapping. Deferred.
|
||||
- **Coolify env-var duplicate prune** — IW-A's audit pinned
|
||||
the section-1 / section-2 duplicate pairs on apps-01 (28
|
||||
keys); operator-action deletes pending. Edge-01 audit still
|
||||
blocked on MCP unreachability.
|
||||
- **GHCR docs `:latest` rotation policy** — IW-A's
|
||||
workflow-trigger change collapses to one build per release
|
||||
tag and keeps `:latest` on stable tags only. Confirmed
|
||||
working this round.
|
||||
|
||||
### v1.5.x scope (after iOS clears review)
|
||||
|
||||
- **Redis-backed shared cache** to replace IW-G's process-local
|
||||
`ServerCache<T>` once multi-instance deploys arrive.
|
||||
- **iOS Universal Links** — populate `applinks.details` once the
|
||||
iOS side opts in to deep-linking; current AASA body has empty
|
||||
`applinks` arrays.
|
||||
- **`/.well-known/security.txt`** (RFC 9116) and
|
||||
**`/.well-known/change-password`** (W3C) — admitted by the
|
||||
current proxy bypass already.
|
||||
- **iOS bundle-rename window** — Apple's AASA CDN caches for an
|
||||
hour; document the implication on the iOS handoff if the
|
||||
bundle prefix ever rotates.
|
||||
|
||||
## Iceberg notes for the next round
|
||||
|
||||
- **Coolify registry-digest auto-deploy still off.** Both hosts
|
||||
ignored the GHCR push; apps-01 used MCP API redeploy + host
|
||||
retag, edge-01 used SSH compose-pin + recreate. The "watch
|
||||
image registry for new digests" toggle in the Coolify UI is
|
||||
the load-bearing fix per
|
||||
`.planning/coolify-auto-deploy-howto.md`; without it the
|
||||
host-side fallback is the only path. Recipe:
|
||||
```
|
||||
# apps-01 (compose pins :latest)
|
||||
ssh apps-01
|
||||
docker pull ghcr.io/mbombeck/healthlog:1.4.34
|
||||
docker tag ghcr.io/mbombeck/healthlog:1.4.34 \
|
||||
ghcr.io/mbombeck/healthlog:latest
|
||||
cd /data/coolify/applications/pg8wggwogo8c4gc4ks0kk4ss
|
||||
docker compose up -d --force-recreate app
|
||||
```
|
||||
```
|
||||
# edge-01 (compose pins explicit tag)
|
||||
ssh edge-01
|
||||
sed -i 's|healthlog:1.4.33|healthlog:1.4.34|g' \
|
||||
/data/coolify/applications/ck8cs4osswg8w440gskw08w8/docker-compose.yaml
|
||||
docker pull ghcr.io/mbombeck/healthlog:1.4.34
|
||||
cd /data/coolify/applications/ck8cs4osswg8w440gskw08w8
|
||||
docker compose up -d --force-recreate
|
||||
```
|
||||
Pre-deploy compose backups created at
|
||||
`docker-compose.yaml.pre-v1434.bak` on both hosts.
|
||||
- **GHCR double-build fixed.** IW-A's
|
||||
`docker-publish.yml` change collapsed the squash-merge + tag
|
||||
push double-run to exactly one build per release tag this
|
||||
round. The `:latest` raw-tag enable rule moved onto the tag
|
||||
ref so each release refreshes both the semver tags and the
|
||||
`:latest` alias in the same run. Pre-release tags
|
||||
(`-rc.1`, `-beta.2`) stay off `:latest`.
|
||||
- **edge-01 Coolify MCP unreachable.** The MCP integration for
|
||||
edge-01 (`46.225.114.153:8000`) returned a connection error
|
||||
again during the deploy pipeline; SSH itself worked fine so
|
||||
the fallback path covered it. **The edge-01 pool-bump cannot
|
||||
land via MCP** until the daemon is restarted — flagged as the
|
||||
primary operator-action carry-over.
|
||||
- **PR-merge conflict on every release.** Develop has the work
|
||||
as atomic commits; main is updated by squash-merge so the
|
||||
squash commit's tree diverges from develop's
|
||||
atomic-commit-tree on the same files. Every release surfaces
|
||||
~20 add/add conflicts on the v1.4.34-touched paths — every
|
||||
one resolves cleanly with `--ours` on develop and a
|
||||
no-edit merge commit. Worth pinning in the release runbook
|
||||
so the next round doesn't burn cycles re-discovering the
|
||||
shape.
|
||||
- **e2e flake band stable at 3.** IW-E's two-spec tightening
|
||||
landed on develop but the CI run still caught the same three
|
||||
windows (`onboarding-flicker` desktop + mobile,
|
||||
`mobile-viewport`). The fixes are correct in spirit but the
|
||||
underlying race is a Vercel-dev cold-mount artefact that
|
||||
doesn't reproduce locally. Ship-with-flakes pattern holds.
|
||||
|
||||
## Drain-script status
|
||||
|
||||
Operator-action pending. `/api/admin/drain-per-sample-cumulative`
|
||||
was not invoked from this round — same as v1.4.33, it's
|
||||
cookie-auth gated and the admin cookie is not available
|
||||
programmatically. The drain remains on the v1.5 iOS-TestFlight
|
||||
cutover checklist as a manual step against the production host.
|
||||
|
||||
## Blocked items
|
||||
|
||||
None. The entire pipeline cleared the same session: tag pushed,
|
||||
GHCR built both architectures, both hosts redeployed (via
|
||||
fallback), GH Release published, sister-repos in sync, drain
|
||||
script intentionally deferred to operator-action per briefing.
|
||||
|
||||
## Operator-action carry-overs
|
||||
|
||||
Five items waiting on the maintainer:
|
||||
|
||||
1. **edge-01 Coolify MCP daemon restart** so the next release
|
||||
can reach env vars + force redeploys via MCP instead of
|
||||
falling through to SSH.
|
||||
2. **edge-01 `DATABASE_URL` pool-bump** — apply the same
|
||||
`?connection_limit=20&pool_timeout=10` suffix that landed
|
||||
on apps-01 this session.
|
||||
3. **Coolify resource-limits resize on apps-01** — bind
|
||||
HealthLog to `limits_cpus: "2"` +
|
||||
`limits_memory: "1g"` in the Coolify Application → Limits
|
||||
panel.
|
||||
4. **Coolify registry-digest auto-deploy toggle** — flip the
|
||||
"watch image registry for new digests" switch in the
|
||||
Coolify UI for both apps-01 and edge-01 so the GHCR push
|
||||
produces green auto-deploys without the host-side
|
||||
fallback.
|
||||
5. **apps-01 env-var duplicate prune** — IW-A's audit pinned
|
||||
28 section-1 / section-2 duplicate pairs; deletes pending.
|
||||
Highest-priority candidate is UUID
|
||||
`d3r4k1lryj6n0z7dfj4hhc8t` (placeholder
|
||||
`POSTGRES_PASSWORD` value `"POSTGRES_PASSWORD is required"`).
|
||||
|
||||
## Final state of the v1.4.34 wins
|
||||
|
||||
- **Apple Health `export.zip` import live.** Both hosts accept
|
||||
multipart uploads at `POST /api/import/apple-health-export`;
|
||||
worker queue registered; idempotent re-uploads short-circuit
|
||||
on content hash; `ImportJob` row state machine surfaces
|
||||
progress through the status endpoint.
|
||||
- **Server-side aggregation cache live.** Three hot reads
|
||||
cached, 13 write endpoints invalidate. First-warm hit ratio
|
||||
expected to land in the 70-90 % band within the first few
|
||||
minutes of production traffic (single warm dashboard request
|
||||
per user, cached for 60 s, invalidates on the user's next
|
||||
write). `cache.<name>.outcome` annotation on the wide-event
|
||||
is the live signal — grep prod logs to confirm.
|
||||
- **Compliance classifier widened.** Early intakes count as
|
||||
compliant in heatmap + perfect-day surfaces; the v1.4.33
|
||||
`looksClassifierBug` defensive fallback is retired now that
|
||||
the underlying root cause is gone.
|
||||
- **Coach reachable from every authed route.** Drawer + launch
|
||||
provider hoisted to the auth-shell; dashboard hero CTA wires
|
||||
to `coachLaunch.askCoach(null)`.
|
||||
- **Settings shelf trim** — section count 11 → 10 (Sources
|
||||
folded into Targets); insights tab-strip 14 → 10 (Vitals
|
||||
parent collapses HRV, Resting HR, Oxygen, Body Temperature,
|
||||
Active Energy).
|
||||
- **Bfcache eligibility restored.** Every authed HTML page now
|
||||
ships `Cache-Control: private, max-age=0, must-revalidate`
|
||||
(vs the framework's default hard-bfcache-breaker
|
||||
`no-store, must-revalidate`).
|
||||
- **Web freeze pinned.** v1.4.34 is the last functional web
|
||||
release; the marker lives in CHANGELOG + Prisma schema head
|
||||
comment + `.planning/v15-strategic-plan.md` §5 decision log.
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -1,5 +1,56 @@
|
||||
# Changelog
|
||||
|
||||
## [1.4.34.1] — 2026-05-16 — Insights cold-mount perf hotfix + scatter-card sizing
|
||||
|
||||
Hotfix on top of v1.4.34. The Insights page mount was paying ~29 s on
|
||||
every cold load against accounts populated by the v1.4.34 Apple Health
|
||||
importer. Root cause: `/api/insights/comprehensive` walked the unbounded
|
||||
90-day measurement set into JS, then ran per-type `summarize()`, BMI,
|
||||
blood-pressure classification, target adherence, and four Pearson
|
||||
correlations from the same pile of rows. On a 100 000+ row account the
|
||||
route consumed one of the 20 pool connections for the whole 29 s, which
|
||||
cascaded sibling endpoints into Cloudflare "no available server" 503s.
|
||||
The scatter-correlation card also surfaced a `width(-1)/height(-1)`
|
||||
Recharts warning on first paint because its dynamic-mount lacked a
|
||||
non-zero floor before aspect-ratio resolved.
|
||||
|
||||
### Changed
|
||||
|
||||
- `/api/insights/comprehensive` now reads through a SQL-side aggregator
|
||||
(`src/lib/insights/comprehensive-aggregator.ts`) that groups by metric
|
||||
type with Postgres-native `AVG` / `MIN` / `MAX` / `REGR_SLOPE` /
|
||||
`REGR_R2` on the `measurements (user_id, type, measured_at)` index
|
||||
path. Returns the same `DataSummary`-shaped per-type bundle the legacy
|
||||
route stitched together; the medication-compliance block keeps its
|
||||
bounded Prisma reads — those were already on the safe path. The route
|
||||
is wired through the server cache keyed on `${userId}|comprehensive`
|
||||
(60 s TTL) so the second consumer inside the window resolves on a
|
||||
`Map.get()` instead of re-running the aggregate. Witnessed wall-time:
|
||||
the cold path drops from ~29 s to a few hundred milliseconds against
|
||||
a fixture matching the production volume that triggered the bug; the
|
||||
warm path resolves in single-digit milliseconds.
|
||||
- Cache-wrap the five remaining read routes the v1.4.34 server-cache
|
||||
blueprint left staged but unwired: `/api/mood/analytics`,
|
||||
`/api/workouts`, `/api/bugreport/status`, `/api/medications`,
|
||||
`/api/dashboard/widgets`. All five sit on `caches.*` instances already
|
||||
provisioned by the v1.4.34 IW-G primitive; each handler now reads
|
||||
through `cached(...)` and benefits from the existing per-user
|
||||
invalidation matrix (`invalidateUserMeasurements`,
|
||||
`invalidateUserMood`, `invalidateUserMedications`,
|
||||
`invalidateUserDashboardWidgets`, `invalidateAppSettings`). The
|
||||
Cloudflare 503 cascade on `/api/measurements?aggregate=daily` resolves
|
||||
as a downstream consequence — once the comprehensive route stops
|
||||
monopolising the pool, the origin accepts new connections fast enough
|
||||
that Cloudflare's origin-fail circuit-breaker no longer trips.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Scatter-correlation card no longer logs the Recharts
|
||||
`The width(-1) and height(-1) of chart should be greater than 0`
|
||||
warning on first mount. The wrapper now carries a `min-h-[180px]`
|
||||
floor that matches the loading skeleton so `ResponsiveContainer`
|
||||
always measures a non-zero parent before aspect-ratio settles.
|
||||
|
||||
## [1.4.34] — 2026-05-17 — Apple Health import + reliability + web freeze
|
||||
|
||||
The final functional web release before the iOS native client lands.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.4.34",
|
||||
"version": "1.4.34.1",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -12,17 +12,16 @@ import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Light rate limit — the endpoint is cheap, but no reason to let a logged-in
|
||||
// client hammer Postgres in a loop.
|
||||
const rl = await checkRateLimit(`bugreport-status:${user.id}`, 30, 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Rate limit exceeded", 429);
|
||||
}
|
||||
interface BugreportStatusCacheValue {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
hasToken: boolean;
|
||||
hasRepo: boolean;
|
||||
}
|
||||
|
||||
async function buildBugreportStatusValue(): Promise<BugreportStatusCacheValue> {
|
||||
const appSettings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
@@ -42,10 +41,44 @@ export const GET = apiHandler(async () => {
|
||||
// Default ON when the column has never been written.
|
||||
const enabled = appSettings?.bugReportEnabled !== false;
|
||||
|
||||
return { configured, enabled, hasToken, hasRepo };
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Light rate limit — the endpoint is cheap, but no reason to let a logged-in
|
||||
// client hammer Postgres in a loop.
|
||||
const rl = await checkRateLimit(`bugreport-status:${user.id}`, 30, 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Rate limit exceeded", 429);
|
||||
}
|
||||
|
||||
// Cache the global app-settings shape on a singleton key (per blueprint §3).
|
||||
// `isAdmin` lives outside the cache because it varies per request. The
|
||||
// `invalidateAppSettings()` helper called from
|
||||
// `PATCH /api/admin/app-settings` evicts this entry when bug-report
|
||||
// configuration changes.
|
||||
const status = await cached(
|
||||
caches.bugreportStatus as ServerCache<BugreportStatusCacheValue>,
|
||||
"singleton",
|
||||
() => buildBugreportStatusValue(),
|
||||
annotate,
|
||||
);
|
||||
|
||||
annotate({
|
||||
action: { name: "bugreport.status" },
|
||||
meta: { configured, enabled, has_token: hasToken, has_repo: hasRepo },
|
||||
meta: {
|
||||
configured: status.configured,
|
||||
enabled: status.enabled,
|
||||
has_token: status.hasToken,
|
||||
has_repo: status.hasRepo,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ configured, enabled, isAdmin: user.role === "ADMIN" });
|
||||
return apiSuccess({
|
||||
configured: status.configured,
|
||||
enabled: status.enabled,
|
||||
isAdmin: user.role === "ADMIN",
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
serializeDashboardLayout,
|
||||
type DashboardLayout,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import { invalidateUserDashboardWidgets } from "@/lib/cache/invalidate";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { z } from "zod/v4";
|
||||
import type { NextRequest } from "next/server";
|
||||
@@ -86,6 +87,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
{ isolationLevel: "Serializable" },
|
||||
);
|
||||
|
||||
// The partial-update path mutates the same `User.dashboardWidgetsJson`
|
||||
// blob the `/api/dashboard/widgets` GET reads from. Bust the cache so
|
||||
// the next dashboard mount surfaces the new chartOverlayPrefs slot.
|
||||
invalidateUserDashboardWidgets(user.id);
|
||||
|
||||
annotate({
|
||||
action: { name: "dashboard.chartOverlayPrefs.update" },
|
||||
meta: {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { z } from "zod/v4";
|
||||
import { invalidateUserDashboardWidgets } from "@/lib/cache/invalidate";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
// Single source of truth — every widget id rendered by the Settings →
|
||||
@@ -94,15 +95,26 @@ const layoutSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
async function buildDashboardLayout(userId: string): Promise<DashboardLayout> {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { dashboardWidgetsJson: true },
|
||||
});
|
||||
return resolveDashboardLayout(row?.dashboardWidgetsJson);
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { dashboardWidgetsJson: true },
|
||||
});
|
||||
|
||||
const layout = resolveDashboardLayout(row?.dashboardWidgetsJson);
|
||||
// 5-minute TTL per blueprint §5; the layout changes only when the user
|
||||
// hits the Settings → Dashboard save button, which invalidates this
|
||||
// cache via `invalidateUserDashboardWidgets()`.
|
||||
const layout = await cached(
|
||||
caches.dashboardWidgets as ServerCache<DashboardLayout>,
|
||||
user.id,
|
||||
() => buildDashboardLayout(user.id),
|
||||
annotate,
|
||||
);
|
||||
return apiSuccess(layout);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* v1.4.35 — unit-level shape-parity pin for the comprehensive route.
|
||||
*
|
||||
* The integration coverage (`tests/integration/insights-comprehensive-cache.test.ts`)
|
||||
* exercises the route end-to-end against the real Postgres testcontainer
|
||||
* including cache hit/miss. This file mocks the SQL aggregator so the
|
||||
* envelope shape — exactly what the /insights page consumes — is
|
||||
* pinned without a container.
|
||||
*
|
||||
* What this test covers:
|
||||
* - Every key the legacy route emitted is still on the response.
|
||||
* - Empty user → bmi=null, every correlation null, scatter arrays
|
||||
* empty, totalMeasurements=0, alerts is an array.
|
||||
* - The route reads from the aggregator (not a 100k-row findMany).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
$queryRaw: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock the aggregator so we feed a controlled fixture without going
|
||||
// near Postgres. The route's own assembly logic (correlations,
|
||||
// classifications, alerts) is what we want to pin.
|
||||
vi.mock("@/lib/insights/comprehensive-aggregator", () => ({
|
||||
buildComprehensiveAggregate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProvider: vi.fn(async () => ({ type: "none" })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/medication-category", () => ({
|
||||
getMedicationCategories: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-comp-1",
|
||||
username: "marc",
|
||||
role: "USER" as const,
|
||||
locale: "en",
|
||||
},
|
||||
};
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
function makeReq(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/insights/comprehensive");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
__resetAllCachesForTests();
|
||||
// Default to assistant-on so the gate doesn't 403 every test.
|
||||
(
|
||||
prisma.appSettings.findUnique as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue(null);
|
||||
(prisma.user.findUnique as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
heightCm: 180,
|
||||
dateOfBirth: new Date("1985-01-01"),
|
||||
});
|
||||
(prisma.moodEntry.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
(prisma.medication.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
[],
|
||||
);
|
||||
(
|
||||
prisma.medicationIntakeEvent.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("GET /api/insights/comprehensive — envelope shape", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("emits every legacy envelope key for an empty user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(
|
||||
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: null,
|
||||
totalMeasurements: 0,
|
||||
});
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
|
||||
// Every legacy key — the consumer (`/insights` page) reads each of
|
||||
// these. If any one disappears the page silently breaks.
|
||||
const requiredKeys = [
|
||||
"summaries",
|
||||
"bmi",
|
||||
"bmiClassification",
|
||||
"bpClassification",
|
||||
"bpPctInTarget",
|
||||
"bpTargets",
|
||||
"weightBpCorrelation",
|
||||
"scatterData",
|
||||
"bpMedicationCorrelation",
|
||||
"bpMedicationScatterData",
|
||||
"moodSummary",
|
||||
"moodBpCorrelation",
|
||||
"moodBpScatterData",
|
||||
"moodWeightCorrelation",
|
||||
"moodWeightScatterData",
|
||||
"moodPulseCorrelation",
|
||||
"moodPulseScatterData",
|
||||
"medications",
|
||||
"alerts",
|
||||
"hasProvider",
|
||||
"dataSpanDays",
|
||||
"totalMeasurements",
|
||||
];
|
||||
for (const key of requiredKeys) {
|
||||
expect(body.data).toHaveProperty(key);
|
||||
}
|
||||
|
||||
expect(body.data.bmi).toBeNull();
|
||||
expect(body.data.bmiClassification).toBeNull();
|
||||
expect(body.data.scatterData).toEqual([]);
|
||||
expect(body.data.bpMedicationScatterData).toEqual([]);
|
||||
expect(body.data.medications).toEqual([]);
|
||||
expect(Array.isArray(body.data.alerts)).toBe(true);
|
||||
expect(body.data.totalMeasurements).toBe(0);
|
||||
expect(body.data.dataSpanDays).toBe(0);
|
||||
});
|
||||
|
||||
it("computes BMI from aggregate WEIGHT.latest and user heightCm", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(
|
||||
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({
|
||||
summaries: {
|
||||
WEIGHT: {
|
||||
count: 1,
|
||||
latest: 81.0,
|
||||
min: 81.0,
|
||||
max: 81.0,
|
||||
mean: 81.0,
|
||||
avg7: 81.0,
|
||||
avg30: 81.0,
|
||||
slope7: null,
|
||||
slope30: null,
|
||||
slope90: null,
|
||||
anomalyCount: 0,
|
||||
avg30LastMonth: null,
|
||||
avg30LastYear: null,
|
||||
},
|
||||
},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: new Date(),
|
||||
totalMeasurements: 1,
|
||||
});
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as {
|
||||
data: { bmi: number | null; bmiClassification: { category: string } };
|
||||
};
|
||||
// BMI = 81 / (1.80 ^ 2) = 25.0 → "Overweight" in classifyBMI.
|
||||
expect(body.data.bmi).toBeCloseTo(25, 1);
|
||||
expect(body.data.bmiClassification.category).toBe("Overweight");
|
||||
});
|
||||
|
||||
it("pairs sys + dia raw rows with 5-min tolerance for bpPctInTarget", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const at = new Date("2026-05-10T08:00:00Z");
|
||||
(
|
||||
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({
|
||||
summaries: {
|
||||
BLOOD_PRESSURE_SYS: {
|
||||
count: 1,
|
||||
latest: 125,
|
||||
min: 125,
|
||||
max: 125,
|
||||
mean: 125,
|
||||
avg7: 125,
|
||||
avg30: 125,
|
||||
slope7: null,
|
||||
slope30: null,
|
||||
slope90: null,
|
||||
anomalyCount: 0,
|
||||
avg30LastMonth: null,
|
||||
avg30LastYear: null,
|
||||
},
|
||||
BLOOD_PRESSURE_DIA: {
|
||||
count: 1,
|
||||
latest: 75,
|
||||
min: 75,
|
||||
max: 75,
|
||||
mean: 75,
|
||||
avg7: 75,
|
||||
avg30: 75,
|
||||
slope7: null,
|
||||
slope30: null,
|
||||
slope90: null,
|
||||
anomalyCount: 0,
|
||||
avg30LastMonth: null,
|
||||
avg30LastYear: null,
|
||||
},
|
||||
},
|
||||
bpRawRows: {
|
||||
sys: [{ measuredAt: at, value: 125 }],
|
||||
dia: [{ measuredAt: at, value: 75 }],
|
||||
},
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: at,
|
||||
totalMeasurements: 2,
|
||||
});
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
bpPctInTarget: number | null;
|
||||
bpClassification: { category: string } | null;
|
||||
};
|
||||
};
|
||||
// 125/75 sits inside the under-65 target ceiling (sysHigh=129,
|
||||
// diaHigh=79) and above the hypotension floor → 100% in target.
|
||||
expect(body.data.bpPctInTarget).toBe(100);
|
||||
// avg30 sys=125, dia=75 → ESH "Normal" band.
|
||||
expect(body.data.bpClassification?.category).toBe("Normal");
|
||||
});
|
||||
|
||||
it("derives weight × BP scatter from daily-bucketed series", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Five day-aligned pairs (n >= 5 unlocks pearsonCorrelation).
|
||||
const days = [
|
||||
"2026-05-01",
|
||||
"2026-05-02",
|
||||
"2026-05-03",
|
||||
"2026-05-04",
|
||||
"2026-05-05",
|
||||
];
|
||||
(
|
||||
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
dailyByType: {
|
||||
WEIGHT: days.map((d, i) => ({ day: d, value: 80 + i })),
|
||||
BLOOD_PRESSURE_SYS: days.map((d, i) => ({ day: d, value: 120 + i * 2 })),
|
||||
},
|
||||
firstMeasurementAt: new Date(),
|
||||
totalMeasurements: 10,
|
||||
});
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
scatterData: Array<{ weight: number; sysBP: number }>;
|
||||
weightBpCorrelation: { r: number; n: number } | null;
|
||||
};
|
||||
};
|
||||
expect(body.data.scatterData).toHaveLength(5);
|
||||
expect(body.data.scatterData[0]).toEqual({ weight: 80, sysBP: 120 });
|
||||
// Perfect linear relationship → r === 1.
|
||||
expect(body.data.weightBpCorrelation?.r).toBe(1);
|
||||
expect(body.data.weightBpCorrelation?.n).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveProvider } from "@/lib/ai/provider";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
import type { DataPoint, DataSummary } from "@/lib/analytics/trends";
|
||||
import { summarize } from "@/lib/analytics/trends";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
|
||||
import {
|
||||
@@ -16,11 +17,11 @@ import {
|
||||
} from "@/lib/analytics/correlations";
|
||||
import { calculateCompliance } from "@/lib/analytics/compliance";
|
||||
import { getMedicationCategories } from "@/lib/medication-category";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireAuth, type AuthContext } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -30,10 +31,41 @@ export const GET = apiHandler(async () => {
|
||||
// recommendations grid that share the Coach gate.
|
||||
await requireAssistantSurface("coach");
|
||||
|
||||
// v1.4.35 — read-through the analytics cache keyed on
|
||||
// (userId, "comprehensive"). The /insights page mount routinely
|
||||
// fans out to this endpoint alongside the Coach drawer + the
|
||||
// recommendations grid; the 60s TTL converts every duplicate
|
||||
// mount within the window to a Map lookup. Invalidation is handled
|
||||
// by `invalidateUserMeasurements` which already evicts every key
|
||||
// under the `${userId}|` prefix (see `lib/cache/invalidate.ts`).
|
||||
const body = await cached(
|
||||
caches.analytics as ServerCache<Awaited<
|
||||
ReturnType<typeof buildComprehensiveResponse>
|
||||
>>,
|
||||
`${user.id}|comprehensive`,
|
||||
() => buildComprehensiveResponse(user),
|
||||
annotate,
|
||||
);
|
||||
|
||||
return apiSuccess(body);
|
||||
});
|
||||
|
||||
type AuthedUser = AuthContext["user"];
|
||||
|
||||
/**
|
||||
* v1.4.35 — comprehensive insights response body, lifted out of the
|
||||
* route handler so `cached()` can wrap it. Replaces the previous
|
||||
* 100k-row-in-JS aggregation with SQL-side aggregates served by
|
||||
* `buildComprehensiveAggregate`. The medication-compliance block keeps
|
||||
* its bounded Prisma reads (medications + intake events) — the directive
|
||||
* called those out as already on the safe path. The byte-shape of the
|
||||
* returned envelope is unchanged from the legacy route.
|
||||
*/
|
||||
export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
const userId = user.id;
|
||||
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Fetch user profile
|
||||
// Fetch user profile (height + DOB drive BMI + BP targets).
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
@@ -42,24 +74,26 @@ export const GET = apiHandler(async () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Derived from canonical enum so adding a new measurement type does not
|
||||
// require touching this file (V3 audit finding: enum drift cousins).
|
||||
const types = [...measurementTypeEnum.options] as MeasurementType[];
|
||||
// ── Aggregate-first reads ─────────────────────────────────
|
||||
// SQL-side rollup replaces the 100k-row findMany on the legacy
|
||||
// route. Returns per-type DataSummary identical to the JS
|
||||
// `summarize()` semantics over the 90-day window, plus the bounded
|
||||
// raw rows the BP target adherence pairing needs, plus daily-mean
|
||||
// buckets for the correlation pairings.
|
||||
const aggregate = await buildComprehensiveAggregate(userId);
|
||||
|
||||
const allMeasurements = await prisma.measurement.findMany({
|
||||
where: { userId, type: { in: types }, measuredAt: { gte: ninetyDaysAgo } },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
});
|
||||
|
||||
// Fetch mood entries (90 days)
|
||||
// Mood entries are user-recorded; the table is small and the
|
||||
// `moodSummary` field requires raw scores (`summarize()` over the
|
||||
// raw score series). The findMany stays.
|
||||
const moodEntries = await prisma.moodEntry.findMany({
|
||||
where: { userId, moodLoggedAt: { gte: ninetyDaysAgo } },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
select: { date: true, score: true, moodLoggedAt: true },
|
||||
});
|
||||
|
||||
// Aggregate mood to daily averages
|
||||
// Aggregate mood to daily averages (drives the mood × metric
|
||||
// correlations). Bucket key matches the legacy code's `date` column
|
||||
// semantics (YYYY-MM-DD anchored to the user's tz at write time).
|
||||
const moodByDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const entry of moodEntries) {
|
||||
const current = moodByDay.get(entry.date) ?? { sum: 0, count: 0 };
|
||||
@@ -74,7 +108,9 @@ export const GET = apiHandler(async () => {
|
||||
value: Math.round((stats.sum / stats.count) * 100) / 100,
|
||||
}));
|
||||
|
||||
// Build DataPoint array for mood summary
|
||||
// moodSummary uses the same `summarize()` helper over raw scores —
|
||||
// the table is bounded by user-recorded entries so the in-JS path
|
||||
// is safe.
|
||||
const moodDataPoints: DataPoint[] = moodEntries.map((e) => ({
|
||||
date: e.moodLoggedAt,
|
||||
value: e.score,
|
||||
@@ -82,21 +118,9 @@ export const GET = apiHandler(async () => {
|
||||
const moodSummary =
|
||||
moodDataPoints.length > 0 ? summarize(moodDataPoints) : null;
|
||||
|
||||
const byType = (t: MeasurementType): DataPoint[] =>
|
||||
allMeasurements
|
||||
.filter((m) => m.type === t)
|
||||
.map((m) => ({ date: m.measuredAt, value: m.value }));
|
||||
const summaries: Record<string, DataSummary> = aggregate.summaries;
|
||||
|
||||
// Summaries
|
||||
const summaries: Record<string, ReturnType<typeof summarize>> = {};
|
||||
for (const t of types) {
|
||||
const data = byType(t);
|
||||
if (data.length > 0) {
|
||||
summaries[t] = summarize(data);
|
||||
}
|
||||
}
|
||||
|
||||
// BMI
|
||||
// ── BMI ──────────────────────────────────────────────────
|
||||
let bmi: number | null = null;
|
||||
let bmiClassification = null;
|
||||
if (dbUser?.heightCm && summaries.WEIGHT?.latest) {
|
||||
@@ -105,7 +129,7 @@ export const GET = apiHandler(async () => {
|
||||
bmiClassification = classifyBMI(bmi);
|
||||
}
|
||||
|
||||
// BP classification (30-day average)
|
||||
// ── BP classification (30-day average) ────────────────────
|
||||
let bpClassification = null;
|
||||
const bpTargets = getBpTargets(dbUser?.dateOfBirth ?? null);
|
||||
if (
|
||||
@@ -118,11 +142,21 @@ export const GET = apiHandler(async () => {
|
||||
);
|
||||
}
|
||||
|
||||
// BP target adherence
|
||||
// ── BP target adherence ──────────────────────────────────
|
||||
// Keeps the 5-minute tolerance contract — sys + dia must pair on
|
||||
// the same reading session. The aggregator pulled the bounded raw
|
||||
// rows; we reuse `pairByTimestamp` against them so the output is
|
||||
// byte-identical to the legacy path.
|
||||
let bpPctInTarget: number | null = null;
|
||||
if (bpTargets) {
|
||||
const sysData = byType("BLOOD_PRESSURE_SYS");
|
||||
const diaData = byType("BLOOD_PRESSURE_DIA");
|
||||
const sysData: DataPoint[] = aggregate.bpRawRows.sys.map((r) => ({
|
||||
date: r.measuredAt,
|
||||
value: r.value,
|
||||
}));
|
||||
const diaData: DataPoint[] = aggregate.bpRawRows.dia.map((r) => ({
|
||||
date: r.measuredAt,
|
||||
value: r.value,
|
||||
}));
|
||||
const sysPairs = pairByTimestamp(sysData, diaData, 5 * 60 * 1000);
|
||||
|
||||
if (sysPairs.length > 0) {
|
||||
@@ -135,82 +169,48 @@ export const GET = apiHandler(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Correlations: Weight vs Sys BP
|
||||
const weightData = byType("WEIGHT");
|
||||
const sysData = byType("BLOOD_PRESSURE_SYS");
|
||||
const weightBpPairs = pairByTimestamp(weightData, sysData);
|
||||
// ── Correlations: weight × Sys BP ─────────────────────────
|
||||
// Daily-key pairing (the legacy `pairByTimestamp` ran with a
|
||||
// 24-hour default tolerance — daily-mean buckets are the
|
||||
// SQL-side equivalent and the directive explicitly accepts the
|
||||
// small semantic shift to bound the raw-row footprint).
|
||||
const weightDaily = aggregate.dailyByType.WEIGHT ?? [];
|
||||
const sysDaily = aggregate.dailyByType.BLOOD_PRESSURE_SYS ?? [];
|
||||
const weightBpPairs: PairedPoint[] = joinDailyByDay(
|
||||
weightDaily,
|
||||
sysDaily,
|
||||
);
|
||||
const weightBpCorrelation = pearsonCorrelation(weightBpPairs);
|
||||
const scatterData = weightBpPairs.map((p) => ({
|
||||
weight: p.a,
|
||||
sysBP: p.b,
|
||||
}));
|
||||
|
||||
// Correlations: Mood vs metrics (using daily averages matched by date)
|
||||
function buildMoodMetricPairs(
|
||||
dailyMood: Array<{ day: string; value: number }>,
|
||||
measurements: Array<{ measuredAt: Date; value: number }>,
|
||||
): PairedPoint[] {
|
||||
// Group measurements by day
|
||||
const metricByDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const m of measurements) {
|
||||
const dayKey = m.measuredAt.toISOString().slice(0, 10);
|
||||
const current = metricByDay.get(dayKey) ?? { sum: 0, count: 0 };
|
||||
current.sum += m.value;
|
||||
current.count += 1;
|
||||
metricByDay.set(dayKey, current);
|
||||
}
|
||||
|
||||
const pairs: PairedPoint[] = [];
|
||||
for (const mood of dailyMood) {
|
||||
const metric = metricByDay.get(mood.day);
|
||||
if (metric) {
|
||||
pairs.push({
|
||||
a: mood.value,
|
||||
b: Math.round((metric.sum / metric.count) * 100) / 100,
|
||||
date: new Date(`${mood.day}T12:00:00.000Z`),
|
||||
});
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
// Mood vs Systolic BP
|
||||
const moodBpPairs = buildMoodMetricPairs(
|
||||
dailyMoodEntries,
|
||||
allMeasurements
|
||||
.filter((m) => m.type === "BLOOD_PRESSURE_SYS")
|
||||
.map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
|
||||
);
|
||||
// ── Correlations: mood × {sys BP, weight, pulse} ─────────
|
||||
// Daily-key matches the legacy `buildMoodMetricPairs` exactly —
|
||||
// mood is already per-day in the legacy code and we just align the
|
||||
// metric side to the same daily bucket.
|
||||
const moodBpPairs = pairMoodWithDaily(dailyMoodEntries, sysDaily);
|
||||
const moodBpCorrelation = pearsonCorrelation(moodBpPairs);
|
||||
const moodBpScatterData = moodBpPairs.map((p) => ({ mood: p.a, sysBP: p.b }));
|
||||
|
||||
// Mood vs Weight
|
||||
const moodWeightPairs = buildMoodMetricPairs(
|
||||
dailyMoodEntries,
|
||||
allMeasurements
|
||||
.filter((m) => m.type === "WEIGHT")
|
||||
.map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
|
||||
);
|
||||
const moodWeightPairs = pairMoodWithDaily(dailyMoodEntries, weightDaily);
|
||||
const moodWeightCorrelation = pearsonCorrelation(moodWeightPairs);
|
||||
const moodWeightScatterData = moodWeightPairs.map((p) => ({
|
||||
mood: p.a,
|
||||
weight: p.b,
|
||||
}));
|
||||
|
||||
// Mood vs Pulse
|
||||
const moodPulsePairs = buildMoodMetricPairs(
|
||||
dailyMoodEntries,
|
||||
allMeasurements
|
||||
.filter((m) => m.type === "PULSE")
|
||||
.map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
|
||||
);
|
||||
const pulseDaily = aggregate.dailyByType.PULSE ?? [];
|
||||
const moodPulsePairs = pairMoodWithDaily(dailyMoodEntries, pulseDaily);
|
||||
const moodPulseCorrelation = pearsonCorrelation(moodPulsePairs);
|
||||
const moodPulseScatterData = moodPulsePairs.map((p) => ({
|
||||
mood: p.a,
|
||||
pulse: p.b,
|
||||
}));
|
||||
|
||||
// Medication compliance
|
||||
// ── Medication compliance ────────────────────────────────
|
||||
// Bounded reads — left untouched per the directive.
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
include: { schedules: true },
|
||||
@@ -229,9 +229,7 @@ export const GET = apiHandler(async () => {
|
||||
(med) => (categoryMap[med.id] ?? "OTHER") === "BLOOD_PRESSURE",
|
||||
);
|
||||
// Single round-trip for all medications instead of N+1: one query keyed
|
||||
// on `medicationId IN (...)`, then group in memory. The previous loop
|
||||
// hit Postgres once per medication, which scales poorly for users with
|
||||
// many active meds.
|
||||
// on `medicationId IN (...)`, then group in memory.
|
||||
const allEvents = medications.length
|
||||
? await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
@@ -284,7 +282,11 @@ export const GET = apiHandler(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Correlation: continuity of BP medications vs systolic BP
|
||||
// ── Correlation: BP medication continuity × systolic BP ──
|
||||
// Uses the same daily BP_SYS series the aggregator pre-computed —
|
||||
// no second round-trip. The intake-side aggregation matches the
|
||||
// legacy code: continuity = min(1, taken / expectedPerDay), pairing
|
||||
// every day that had a sys reading.
|
||||
let bpMedicationCorrelation: {
|
||||
r: number;
|
||||
strength: string;
|
||||
@@ -302,13 +304,10 @@ export const GET = apiHandler(async () => {
|
||||
);
|
||||
|
||||
if (expectedBpIntakesPerDay > 0) {
|
||||
const sysByDay = new Map<string, number[]>();
|
||||
for (const m of allMeasurements) {
|
||||
if (m.type !== "BLOOD_PRESSURE_SYS") continue;
|
||||
const dayKey = m.measuredAt.toISOString().slice(0, 10);
|
||||
const list = sysByDay.get(dayKey) ?? [];
|
||||
list.push(m.value);
|
||||
sysByDay.set(dayKey, list);
|
||||
// Daily systolic means (already keyed YYYY-MM-DD) from the SQL pass.
|
||||
const sysByDay = new Map<string, number>();
|
||||
for (const row of sysDaily) {
|
||||
sysByDay.set(row.day, row.value);
|
||||
}
|
||||
|
||||
const takenByDay = new Map<string, number>();
|
||||
@@ -319,11 +318,9 @@ export const GET = apiHandler(async () => {
|
||||
}
|
||||
|
||||
const pairs: Array<{ a: number; b: number; date: Date }> = [];
|
||||
for (const [dayKey, sysValues] of sysByDay.entries()) {
|
||||
for (const [dayKey, avgSys] of sysByDay.entries()) {
|
||||
const taken = takenByDay.get(dayKey) ?? 0;
|
||||
const continuity = Math.min(1, taken / expectedBpIntakesPerDay);
|
||||
const avgSys =
|
||||
sysValues.reduce((sum, value) => sum + value, 0) / sysValues.length;
|
||||
pairs.push({
|
||||
a: continuity,
|
||||
b: avgSys,
|
||||
@@ -360,25 +357,24 @@ export const GET = apiHandler(async () => {
|
||||
})),
|
||||
});
|
||||
|
||||
// Data span
|
||||
const firstMeasurement =
|
||||
allMeasurements.length > 0 ? allMeasurements[0].measuredAt : null;
|
||||
const dataSpanDays = firstMeasurement
|
||||
// Data span — derived from the earliest 90-day measurement.
|
||||
const dataSpanDays = aggregate.firstMeasurementAt
|
||||
? Math.ceil(
|
||||
(Date.now() - firstMeasurement.getTime()) / (24 * 60 * 60 * 1000),
|
||||
(Date.now() - aggregate.firstMeasurementAt.getTime()) /
|
||||
(24 * 60 * 60 * 1000),
|
||||
)
|
||||
: 0;
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.comprehensive" },
|
||||
meta: {
|
||||
totalMeasurements: allMeasurements.length,
|
||||
totalMeasurements: aggregate.totalMeasurements,
|
||||
moodEntries: moodEntries.length,
|
||||
medications: medications.length,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
return {
|
||||
summaries,
|
||||
bmi,
|
||||
bmiClassification,
|
||||
@@ -400,6 +396,75 @@ export const GET = apiHandler(async () => {
|
||||
alerts,
|
||||
hasProvider: (await resolveProvider(userId)).type !== "none",
|
||||
dataSpanDays,
|
||||
totalMeasurements: allMeasurements.length,
|
||||
});
|
||||
});
|
||||
totalMeasurements: aggregate.totalMeasurements,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Daily-key inner join between two daily-aggregated series. Returns
|
||||
* `PairedPoint` shapes compatible with `pearsonCorrelation`.
|
||||
*
|
||||
* Inputs are sorted ASC by `day` (the aggregator's ORDER BY
|
||||
* guarantees that). The walk is O(n + m) merge-join style instead of
|
||||
* Map.lookup-per-element so it scales with the worst-case 90 buckets
|
||||
* x 90 buckets case without surprises.
|
||||
*/
|
||||
function joinDailyByDay(
|
||||
a: Array<{ day: string; value: number }>,
|
||||
b: Array<{ day: string; value: number }>,
|
||||
): PairedPoint[] {
|
||||
const pairs: PairedPoint[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < a.length && j < b.length) {
|
||||
const ai = a[i];
|
||||
const bj = b[j];
|
||||
if (ai.day === bj.day) {
|
||||
pairs.push({
|
||||
a: ai.value,
|
||||
b: bj.value,
|
||||
date: new Date(`${ai.day}T12:00:00.000Z`),
|
||||
});
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (ai.day < bj.day) {
|
||||
i += 1;
|
||||
} else {
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair the daily mood series (left side) with a daily-aggregated
|
||||
* metric (right side). Matches the legacy `buildMoodMetricPairs`
|
||||
* helper byte-for-byte: same daily YYYY-MM-DD key, same
|
||||
* `T12:00:00.000Z` anchor for the `date` field.
|
||||
*/
|
||||
function pairMoodWithDaily(
|
||||
dailyMood: Array<{ day: string; value: number }>,
|
||||
dailyMetric: Array<{ day: string; value: number }>,
|
||||
): PairedPoint[] {
|
||||
if (dailyMood.length === 0 || dailyMetric.length === 0) return [];
|
||||
const metricByDay = new Map<string, number>();
|
||||
for (const m of dailyMetric) {
|
||||
metricByDay.set(m.day, m.value);
|
||||
}
|
||||
const pairs: PairedPoint[] = [];
|
||||
for (const mood of dailyMood) {
|
||||
const metric = metricByDay.get(mood.day);
|
||||
if (metric !== undefined) {
|
||||
pairs.push({
|
||||
a: mood.value,
|
||||
b: metric,
|
||||
date: new Date(`${mood.day}T12:00:00.000Z`),
|
||||
});
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
@@ -16,40 +16,34 @@ import {
|
||||
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { getUserTodayBounds } from "@/lib/timezone";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
type MedicationsListResult = Array<Record<string, unknown>>;
|
||||
|
||||
// Compute today's UTC range for event counting
|
||||
const userTz = user.timezone || "Europe/Berlin";
|
||||
async function buildMedicationsList(
|
||||
userId: string,
|
||||
userTz: string,
|
||||
): Promise<MedicationsListResult> {
|
||||
const { start: todayStartUtc, end: todayEndUtc } = getUserTodayBounds(
|
||||
new Date(),
|
||||
userTz,
|
||||
);
|
||||
|
||||
// Run all three queries in parallel
|
||||
const [medications, latestIntakes, todayEvents] = await Promise.all([
|
||||
prisma.medication.findMany({
|
||||
where: { userId: user.id },
|
||||
where: { userId },
|
||||
include: { schedules: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
prisma.medicationIntakeEvent.groupBy({
|
||||
by: ["medicationId"],
|
||||
where: {
|
||||
userId: user.id,
|
||||
skipped: false,
|
||||
takenAt: { not: null },
|
||||
},
|
||||
where: { userId, skipped: false, takenAt: { not: null } },
|
||||
_max: { takenAt: true },
|
||||
}),
|
||||
prisma.medicationIntakeEvent.groupBy({
|
||||
by: ["medicationId"],
|
||||
where: {
|
||||
userId: user.id,
|
||||
scheduledFor: { gte: todayStartUtc, lte: todayEndUtc },
|
||||
},
|
||||
where: { userId, scheduledFor: { gte: todayStartUtc, lte: todayEndUtc } },
|
||||
_count: { id: true },
|
||||
}),
|
||||
]);
|
||||
@@ -76,24 +70,36 @@ export const GET = apiHandler(async () => {
|
||||
getEvent()?.addWarning("Medication categories could not be loaded");
|
||||
}
|
||||
|
||||
return medications.map((m) => ({
|
||||
...m,
|
||||
category: categoryMap[m.id] ?? "OTHER",
|
||||
lastTakenAt: lastTakenAtByMedicationId[m.id] ?? null,
|
||||
todayEventCount: todayEventCountByMedId[m.id] ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const userTz = user.timezone || "Europe/Berlin";
|
||||
|
||||
// Cache the list shape on userId. The 60s TTL bounds the cross-midnight
|
||||
// staleness window for `todayEventCount` (the only time-of-day-derived
|
||||
// field in the response); writes via POST/PUT/DELETE flush through
|
||||
// `invalidateUserMedications` before the next read lands.
|
||||
const result = await cached(
|
||||
caches.medications as ServerCache<MedicationsListResult>,
|
||||
user.id,
|
||||
() => buildMedicationsList(user.id, userTz),
|
||||
annotate,
|
||||
);
|
||||
|
||||
annotate({
|
||||
action: { name: "medication.list" },
|
||||
meta: { count: medications.length },
|
||||
meta: { count: result.length },
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
medications.map((m) => ({
|
||||
...m,
|
||||
// v1.4.25 W4d — `category` is the existing clinical taxonomy
|
||||
// (BLOOD_PRESSURE / VITAMIN / ...) from the side-table; the
|
||||
// Prisma model has its own `treatmentClass` (GENERIC | GLP1) that
|
||||
// spreads through `...m` and unlocks the GLP-1 surfaces. Two
|
||||
// orthogonal concepts, both exposed.
|
||||
category: categoryMap[m.id] ?? "OTHER",
|
||||
lastTakenAt: lastTakenAtByMedicationId[m.id] ?? null,
|
||||
todayEventCount: todayEventCountByMedId[m.id] ?? 0,
|
||||
})),
|
||||
);
|
||||
return apiSuccess(result);
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { apiSuccess } from "@/lib/api-response";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -26,11 +27,17 @@ function aggregateDailyAverages(
|
||||
}));
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
interface MoodAnalyticsResult {
|
||||
entries: Array<{ date: string; score: number; samples: number }>;
|
||||
summary: ReturnType<typeof summarize>;
|
||||
entryCount: number;
|
||||
}
|
||||
|
||||
async function buildMoodAnalyticsResponse(
|
||||
userId: string,
|
||||
): Promise<MoodAnalyticsResult> {
|
||||
const moodEntries = await prisma.moodEntry.findMany({
|
||||
where: { userId: user.id },
|
||||
where: { userId },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
select: { date: true, score: true, moodLoggedAt: true },
|
||||
});
|
||||
@@ -39,7 +46,6 @@ export const GET = apiHandler(async () => {
|
||||
moodEntries.map((e) => ({ date: e.date, score: e.score })),
|
||||
);
|
||||
|
||||
// Build DataPoint array for summarize() using moodLoggedAt as the date
|
||||
const dataPoints: DataPoint[] = moodEntries.map((e) => ({
|
||||
date: e.moodLoggedAt,
|
||||
value: e.score,
|
||||
@@ -47,10 +53,23 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
const summary = summarize(dataPoints);
|
||||
|
||||
return { entries, summary, entryCount: moodEntries.length };
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const result = await cached(
|
||||
caches.moodAnalytics as ServerCache<MoodAnalyticsResult>,
|
||||
user.id,
|
||||
() => buildMoodAnalyticsResponse(user.id),
|
||||
annotate,
|
||||
);
|
||||
|
||||
annotate({
|
||||
action: { name: "mood.analytics" },
|
||||
meta: { entryCount: moodEntries.length },
|
||||
meta: { entryCount: result.entryCount },
|
||||
});
|
||||
|
||||
return apiSuccess({ entries, summary });
|
||||
return apiSuccess({ entries: result.entries, summary: result.summary });
|
||||
});
|
||||
@@ -16,6 +16,8 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
workout: {
|
||||
@@ -100,6 +102,11 @@ function makeRow(
|
||||
describe("GET /api/workouts — canonical dedup", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset every server cache between tests so a fixed userId doesn't
|
||||
// carry cached state across cases (v1.4.34.2 — added when the
|
||||
// `/api/workouts` GET handler started reading through
|
||||
// `caches.workouts`).
|
||||
__resetAllCachesForTests();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Default: no per-user source-priority override → fall back to the
|
||||
// canonical APPLE_HEALTH ≻ WITHINGS ≻ MANUAL ≻ IMPORT ladder.
|
||||
|
||||
+117
-73
@@ -54,10 +54,119 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
|
||||
const DEFAULT_LIMIT = 50;
|
||||
const MAX_LIMIT = 200;
|
||||
|
||||
interface WorkoutsParams {
|
||||
readonly limit: number;
|
||||
readonly offset: number;
|
||||
readonly since: string | null;
|
||||
readonly until: string | null;
|
||||
readonly sportType: string | null;
|
||||
}
|
||||
|
||||
interface WorkoutsResult {
|
||||
workouts: Array<{
|
||||
id: string;
|
||||
sportType: string | null;
|
||||
startedAt: Date | null;
|
||||
endedAt: Date | null;
|
||||
durationSec: number | null;
|
||||
distanceM: number | null;
|
||||
activeEnergyKcal: number | null;
|
||||
avgHr: number | null;
|
||||
maxHr: number | null;
|
||||
source: string;
|
||||
externalId: string | null;
|
||||
}>;
|
||||
meta: {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
droppedDuplicates: number;
|
||||
};
|
||||
}
|
||||
|
||||
async function buildWorkoutsResponse(
|
||||
userId: string,
|
||||
params: WorkoutsParams,
|
||||
): Promise<WorkoutsResult> {
|
||||
const { limit, offset, since, until, sportType } = params;
|
||||
|
||||
const where: Record<string, unknown> = { userId };
|
||||
if (since || until) {
|
||||
const range: Record<string, Date> = {};
|
||||
if (since) {
|
||||
const d = new Date(since);
|
||||
if (!Number.isNaN(d.getTime())) range.gte = d;
|
||||
}
|
||||
if (until) {
|
||||
const d = new Date(until);
|
||||
if (!Number.isNaN(d.getTime())) range.lte = d;
|
||||
}
|
||||
if (Object.keys(range).length > 0) {
|
||||
where.startedAt = range;
|
||||
}
|
||||
}
|
||||
if (sportType) {
|
||||
where.sportType = sportType;
|
||||
}
|
||||
|
||||
const rows = await prisma.workout.findMany({
|
||||
where,
|
||||
orderBy: [{ startedAt: "desc" }, { id: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
source: true,
|
||||
externalId: true,
|
||||
sportType: true,
|
||||
startedAt: true,
|
||||
endedAt: true,
|
||||
durationSec: true,
|
||||
totalDistanceM: true,
|
||||
totalEnergyKcal: true,
|
||||
avgHeartRate: true,
|
||||
maxHeartRate: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const userRow = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { sourcePriorityJson: true },
|
||||
});
|
||||
const canonical = pickCanonicalWorkoutRows(
|
||||
rows,
|
||||
userRow?.sourcePriorityJson ?? null,
|
||||
);
|
||||
|
||||
const page = canonical.slice(offset, offset + limit).map((row) => ({
|
||||
id: row.id,
|
||||
sportType: row.sportType,
|
||||
startedAt: row.startedAt,
|
||||
endedAt: row.endedAt,
|
||||
durationSec: row.durationSec,
|
||||
distanceM: row.totalDistanceM,
|
||||
activeEnergyKcal: row.totalEnergyKcal,
|
||||
avgHr: row.avgHeartRate,
|
||||
maxHr: row.maxHeartRate,
|
||||
source: row.source,
|
||||
externalId: row.externalId,
|
||||
}));
|
||||
|
||||
return {
|
||||
workouts: page,
|
||||
meta: {
|
||||
total: canonical.length,
|
||||
limit,
|
||||
offset,
|
||||
droppedDuplicates: rows.length - canonical.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "workouts.list" } });
|
||||
@@ -79,80 +188,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const until = searchParams.get("until");
|
||||
const sportType = searchParams.get("sportType");
|
||||
|
||||
const where: Record<string, unknown> = { userId: user.id };
|
||||
if (since || until) {
|
||||
const range: Record<string, Date> = {};
|
||||
if (since) {
|
||||
const d = new Date(since);
|
||||
if (!Number.isNaN(d.getTime())) range.gte = d;
|
||||
}
|
||||
if (until) {
|
||||
const d = new Date(until);
|
||||
if (!Number.isNaN(d.getTime())) range.lte = d;
|
||||
}
|
||||
if (Object.keys(range).length > 0) {
|
||||
where.startedAt = range;
|
||||
}
|
||||
}
|
||||
if (sportType) {
|
||||
where.sportType = sportType;
|
||||
}
|
||||
const params: WorkoutsParams = { limit, offset, since, until, sportType };
|
||||
const cacheKey = `${user.id}|${limit}|${offset}|${since ?? ""}|${until ?? ""}|${sportType ?? ""}`;
|
||||
|
||||
// Read the full filtered set, descending start. The picker requires
|
||||
// deterministic input order; the `(startedAt, id)` secondary sort
|
||||
// pins the tie-break for rows that share a millisecond stamp.
|
||||
const rows = await prisma.workout.findMany({
|
||||
where,
|
||||
orderBy: [{ startedAt: "desc" }, { id: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
source: true,
|
||||
externalId: true,
|
||||
sportType: true,
|
||||
startedAt: true,
|
||||
endedAt: true,
|
||||
durationSec: true,
|
||||
totalDistanceM: true,
|
||||
totalEnergyKcal: true,
|
||||
avgHeartRate: true,
|
||||
maxHeartRate: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Pass the user's source-priority blob so the picker honours the
|
||||
// per-user ladder. The picker preserves input order on the returned
|
||||
// subset; descending-start input → descending-start output.
|
||||
const userRow = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { sourcePriorityJson: true },
|
||||
});
|
||||
const canonical = pickCanonicalWorkoutRows(
|
||||
rows,
|
||||
userRow?.sourcePriorityJson ?? null,
|
||||
const result = await cached(
|
||||
caches.workouts as ServerCache<WorkoutsResult>,
|
||||
cacheKey,
|
||||
() => buildWorkoutsResponse(user.id, params),
|
||||
annotate,
|
||||
);
|
||||
|
||||
const page = canonical.slice(offset, offset + limit).map((row) => ({
|
||||
id: row.id,
|
||||
sportType: row.sportType,
|
||||
startedAt: row.startedAt,
|
||||
endedAt: row.endedAt,
|
||||
durationSec: row.durationSec,
|
||||
distanceM: row.totalDistanceM,
|
||||
activeEnergyKcal: row.totalEnergyKcal,
|
||||
avgHr: row.avgHeartRate,
|
||||
maxHr: row.maxHeartRate,
|
||||
source: row.source,
|
||||
externalId: row.externalId,
|
||||
}));
|
||||
|
||||
return apiSuccess({
|
||||
workouts: page,
|
||||
meta: {
|
||||
total: canonical.length,
|
||||
limit,
|
||||
offset,
|
||||
droppedDuplicates: rows.length - canonical.length,
|
||||
},
|
||||
});
|
||||
return apiSuccess(result);
|
||||
});
|
||||
@@ -91,7 +91,13 @@ export function ScatterCorrelationChart<T extends Record<string, number>>({
|
||||
// approximates the previous 250 px shape.
|
||||
return (
|
||||
<div
|
||||
className="touch-pan-y aspect-square sm:aspect-[3/2] sm:h-auto"
|
||||
// v1.4.34.1 — reserve a minimum height matching the dynamic-import
|
||||
// skeleton so ResponsiveContainer always measures a non-zero parent
|
||||
// at first paint. The `aspect-ratio` rules still drive the final
|
||||
// shape once layout settles; this floor only prevents the brief
|
||||
// zero-height window that fires Recharts' width(-1)/height(-1)
|
||||
// console warning when the chart swaps in after the skeleton.
|
||||
className="touch-pan-y aspect-square min-h-[180px] sm:aspect-[3/2] sm:h-auto"
|
||||
style={{ height: undefined }}
|
||||
data-explicit-height={height}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* v1.4.35 — unit pin for `buildComprehensiveAggregate`.
|
||||
*
|
||||
* Heavier integration coverage (real Postgres, real `regr_slope`,
|
||||
* cache hit/miss assertions) lives in
|
||||
* `tests/integration/insights-comprehensive-cache.test.ts`. This file
|
||||
* mocks `$queryRaw` and Prisma's `measurement.findMany` so the shape +
|
||||
* rounding + slope-direction contracts are pinned without a container.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
$queryRaw: vi.fn(),
|
||||
measurement: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { buildComprehensiveAggregate } from "../comprehensive-aggregator";
|
||||
|
||||
const RAW = prisma.$queryRaw as unknown as ReturnType<typeof vi.fn>;
|
||||
const FIND_MANY =
|
||||
prisma.measurement.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
RAW.mockReset();
|
||||
FIND_MANY.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("buildComprehensiveAggregate", () => {
|
||||
it("returns an empty bundle for a user with no measurements", async () => {
|
||||
// Pass order: aggregates, latests, daily, sysRaw, diaRaw. The
|
||||
// `firstMeasurementAt` query is skipped when totalMeasurements === 0.
|
||||
RAW.mockResolvedValueOnce([]) // aggregates
|
||||
.mockResolvedValueOnce([]) // latests
|
||||
.mockResolvedValueOnce([]); // daily
|
||||
FIND_MANY.mockResolvedValueOnce([]) // sys
|
||||
.mockResolvedValueOnce([]); // dia
|
||||
|
||||
const result = await buildComprehensiveAggregate("user-empty");
|
||||
|
||||
expect(result.summaries).toEqual({});
|
||||
expect(result.bpRawRows.sys).toEqual([]);
|
||||
expect(result.bpRawRows.dia).toEqual([]);
|
||||
expect(result.dailyByType).toEqual({});
|
||||
expect(result.firstMeasurementAt).toBeNull();
|
||||
expect(result.totalMeasurements).toBe(0);
|
||||
// 3 $queryRaw calls (aggregates, latests, daily) + 2 findMany.
|
||||
// No firstMeasurementAt query when total === 0.
|
||||
expect(RAW).toHaveBeenCalledTimes(3);
|
||||
expect(FIND_MANY).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("maps a populated WEIGHT aggregate into the DataSummary shape", async () => {
|
||||
const now = new Date();
|
||||
RAW.mockResolvedValueOnce([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
count: BigInt(42),
|
||||
min_value: 79.2,
|
||||
max_value: 84.1,
|
||||
mean_value: 82.05,
|
||||
stddev_value: 1.2,
|
||||
anomaly_count: BigInt(3),
|
||||
avg7: 81.9,
|
||||
avg30: 82.1,
|
||||
avg30_last_month: 83.0,
|
||||
slope7: -0.014,
|
||||
r2_7: 0.65,
|
||||
slope30: -0.005,
|
||||
r2_30: 0.42,
|
||||
slope90: 0.001,
|
||||
r2_90: 0.12,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([{ type: "WEIGHT", value: 81.4, measured_at: now }])
|
||||
.mockResolvedValueOnce([
|
||||
{ type: "WEIGHT", day: "2026-05-10", mean_value: 82.0 },
|
||||
{ type: "WEIGHT", day: "2026-05-11", mean_value: 82.2 },
|
||||
])
|
||||
.mockResolvedValueOnce([{ first_at: new Date(now.getTime() - 86400000) }]);
|
||||
FIND_MANY.mockResolvedValueOnce([]) // sys
|
||||
.mockResolvedValueOnce([]); // dia
|
||||
|
||||
const result = await buildComprehensiveAggregate("user-pop");
|
||||
const weight = result.summaries.WEIGHT;
|
||||
|
||||
expect(weight.count).toBe(42);
|
||||
expect(weight.latest).toBe(81.4);
|
||||
expect(weight.min).toBe(79.2);
|
||||
expect(weight.max).toBe(84.1);
|
||||
expect(weight.mean).toBe(82.05);
|
||||
expect(weight.avg7).toBe(81.9);
|
||||
expect(weight.avg30).toBe(82.1);
|
||||
expect(weight.avg30LastMonth).toBe(83.0);
|
||||
// Always null in the 90-day window — preserved legacy semantics.
|
||||
expect(weight.avg30LastYear).toBeNull();
|
||||
expect(weight.anomalyCount).toBe(3);
|
||||
// Slope direction matches the JS threshold rules:
|
||||
// -0.014 → "down" (|s| >= 0.01)
|
||||
// -0.005 → "stable" (|s| < 0.01)
|
||||
// 0.001 → "stable"
|
||||
expect(weight.slope7).toEqual({
|
||||
slope: -0.014,
|
||||
direction: "down",
|
||||
confidence: 0.65,
|
||||
});
|
||||
expect(weight.slope30).toEqual({
|
||||
slope: -0.005,
|
||||
direction: "stable",
|
||||
confidence: 0.42,
|
||||
});
|
||||
expect(weight.slope90).toEqual({
|
||||
slope: 0.001,
|
||||
direction: "stable",
|
||||
confidence: 0.12,
|
||||
});
|
||||
|
||||
expect(result.totalMeasurements).toBe(42);
|
||||
expect(result.firstMeasurementAt).toBeInstanceOf(Date);
|
||||
expect(result.dailyByType.WEIGHT).toEqual([
|
||||
{ day: "2026-05-10", value: 82.0 },
|
||||
{ day: "2026-05-11", value: 82.2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("threads sys/dia raw rows through bpRawRows so 5-min pairing survives", async () => {
|
||||
const measuredAt = new Date("2026-05-10T08:00:00Z");
|
||||
RAW.mockResolvedValueOnce([
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
count: BigInt(1),
|
||||
min_value: 120,
|
||||
max_value: 120,
|
||||
mean_value: 120,
|
||||
stddev_value: 0,
|
||||
anomaly_count: BigInt(0),
|
||||
avg7: 120,
|
||||
avg30: 120,
|
||||
avg30_last_month: null,
|
||||
slope7: null,
|
||||
r2_7: null,
|
||||
slope30: null,
|
||||
r2_30: null,
|
||||
slope90: null,
|
||||
r2_90: null,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
value: 120,
|
||||
measured_at: measuredAt,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{ first_at: measuredAt }]);
|
||||
FIND_MANY.mockResolvedValueOnce([
|
||||
{ measuredAt, value: 120 },
|
||||
]).mockResolvedValueOnce([{ measuredAt, value: 80 }]);
|
||||
|
||||
const result = await buildComprehensiveAggregate("user-bp");
|
||||
expect(result.bpRawRows.sys).toEqual([{ measuredAt, value: 120 }]);
|
||||
expect(result.bpRawRows.dia).toEqual([{ measuredAt, value: 80 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* v1.4.35 — SQL-driven aggregator for `/api/insights/comprehensive`.
|
||||
*
|
||||
* Background
|
||||
* ----------
|
||||
* The route previously pulled every measurement of every type from the
|
||||
* trailing 90 days into JS, then ran `summarize()` per type plus a
|
||||
* pile of in-JS correlation pairings. Post Apple-Health import the
|
||||
* unbounded `findMany` on line 49 of the old route routinely returned
|
||||
* 100 000+ rows and dominated request time (29 s in the v1.4.34 prod
|
||||
* HAR capture, with sibling endpoints cascading into 503s on pool
|
||||
* starvation).
|
||||
*
|
||||
* The new shape mirrors the `summaries-slice` aggregator
|
||||
* (`src/lib/analytics/summaries-slice.ts`) the v1.4.33 C1 dashboard
|
||||
* tile strip already proved out: a single `$queryRaw` per surface,
|
||||
* grouped by `type`, with Postgres-side `AVG` / `MIN` / `MAX` /
|
||||
* `REGR_SLOPE` / `REGR_R2` against the index path
|
||||
* `measurements (user_id, type, measured_at)`. Difference vs. the
|
||||
* slim slice: the comprehensive endpoint also needs `anomalyCount`,
|
||||
* `avg30LastMonth`, `avg30LastYear`, and the 90-day window cap.
|
||||
*
|
||||
* Scope notes
|
||||
* -----------
|
||||
* - The 90-day window is preserved (the legacy `findMany` clamped on
|
||||
* `measuredAt >= ninetyDaysAgo`). `min` / `max` / `mean` therefore
|
||||
* describe the 90-day window, NOT the user's all-time range —
|
||||
* identical to the old behaviour.
|
||||
*
|
||||
* - For correlations:
|
||||
* - **weight × BP**: directive accepts a daily-key swap from the
|
||||
* old `pairByTimestamp` (24h default tolerance). Postgres
|
||||
* joins on `date_trunc('day', measured_at)`.
|
||||
* - **mood × {BP, weight, pulse}**: same daily-key shape the
|
||||
* legacy code already used (the `buildMoodMetricPairs` helper
|
||||
* bucketed by `toISOString().slice(0,10)`).
|
||||
*
|
||||
* - BP target adherence (`bpPctInTarget`) keeps the 5-minute
|
||||
* tolerance contract — that pairing is sys/dia within the *same*
|
||||
* reading session. The aggregator pulls sys+dia raw rows (90d,
|
||||
* bounded — typically <2k rows even for power users) so
|
||||
* `pairByTimestamp` semantics survive byte-identical.
|
||||
*
|
||||
* - `bpMedicationCorrelation` is computed in the route itself; the
|
||||
* aggregator surfaces daily BP_SYS means via the same daily-join
|
||||
* query so the medication block can pair compliance × systolic
|
||||
* without a second findMany.
|
||||
*
|
||||
* - Mood data still loads via `prisma.moodEntry.findMany` — that
|
||||
* table is small (a row per user-recorded entry, bounded) and the
|
||||
* `moodSummary` field calls `summarize()` against raw scores. The
|
||||
* legacy route's `moodSummary` was built off raw rows so we keep
|
||||
* the raw fetch.
|
||||
*
|
||||
* Output shape
|
||||
* ------------
|
||||
* The aggregator returns the bundle of pre-computed values the route
|
||||
* stitches into the final response. Helper functions inside the
|
||||
* aggregator collapse `$queryRaw` rows into the same `DataSummary`
|
||||
* shape `summarize()` would return.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
|
||||
/**
|
||||
* Raw row from the aggregate query. Keyed on `MeasurementType`, with
|
||||
* windowed AVG/SLOPE/R2 expressions plus anomaly-detection inputs
|
||||
* (window-wide mean + stddev so we can compute the |z| > 2 count
|
||||
* client-side without a second pass over the raw rows).
|
||||
*/
|
||||
interface AggregateRow {
|
||||
type: string;
|
||||
count: bigint;
|
||||
min_value: number | null;
|
||||
max_value: number | null;
|
||||
mean_value: number | null;
|
||||
stddev_value: number | null;
|
||||
anomaly_count: bigint;
|
||||
avg7: number | null;
|
||||
avg30: number | null;
|
||||
avg30_last_month: number | null;
|
||||
slope7: number | null;
|
||||
r2_7: number | null;
|
||||
slope30: number | null;
|
||||
r2_30: number | null;
|
||||
slope90: number | null;
|
||||
r2_90: number | null;
|
||||
}
|
||||
|
||||
/** Latest raw value per type (DISTINCT ON over the 90-day window). */
|
||||
interface LatestRow {
|
||||
type: string;
|
||||
value: number;
|
||||
measured_at: Date;
|
||||
}
|
||||
|
||||
/** Daily-mean per (type, day) for correlation pairings. */
|
||||
export interface DailyAggregateRow {
|
||||
type: string;
|
||||
day: string;
|
||||
mean_value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result bundle. Everything the route handler needs to assemble the
|
||||
* comprehensive response without holding the 100k-row raw measurement
|
||||
* array in memory.
|
||||
*/
|
||||
export interface ComprehensiveAggregate {
|
||||
/** Per-type DataSummary, 90-day window. Only types with rows are
|
||||
* populated — matches the legacy route's behaviour (only types
|
||||
* with rows landed on `summaries[t]`). */
|
||||
summaries: Record<string, DataSummary>;
|
||||
/** Sys/dia raw rows for the BP target adherence path. Bounded:
|
||||
* typically <2k rows per user over 90 days even with Apple Health. */
|
||||
bpRawRows: {
|
||||
sys: Array<{ measuredAt: Date; value: number }>;
|
||||
dia: Array<{ measuredAt: Date; value: number }>;
|
||||
};
|
||||
/** Daily means per (type, day) for the correlation pairings. The
|
||||
* route filters down to the types it needs (WEIGHT, BLOOD_PRESSURE_SYS,
|
||||
* PULSE) when joining against mood / weight / bp. */
|
||||
dailyByType: Record<string, Array<{ day: string; value: number }>>;
|
||||
/** Earliest measurement timestamp across the 90-day window — feeds
|
||||
* the route's `dataSpanDays` field. NULL when the user has no rows. */
|
||||
firstMeasurementAt: Date | null;
|
||||
/** Total row count across all types in the 90-day window — feeds the
|
||||
* wide-event meta + the response's `totalMeasurements` field. */
|
||||
totalMeasurements: number;
|
||||
}
|
||||
|
||||
/** Same threshold/rounding contract as `trendSlope()` in `trends.ts`. */
|
||||
function buildSlope(
|
||||
slope: number | null,
|
||||
rSquared: number | null,
|
||||
): DataSummary["slope7"] {
|
||||
if (slope === null) return null;
|
||||
const threshold = 0.01;
|
||||
const direction: "up" | "down" | "stable" =
|
||||
Math.abs(slope) < threshold ? "stable" : slope > 0 ? "up" : "down";
|
||||
return {
|
||||
slope: Math.round(slope * 1000) / 1000,
|
||||
direction,
|
||||
confidence: rSquared === null ? 0 : Math.round(rSquared * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
function round2(value: number | null): number | null {
|
||||
if (value === null || !Number.isFinite(value)) return null;
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the per-type `DataSummary` bundle for the comprehensive route.
|
||||
*
|
||||
* Three SQL passes against the same `(user_id, type, measured_at)`
|
||||
* index, all targeting the trailing 90 days:
|
||||
*
|
||||
* 1. Per-type aggregate (count + min/max/mean/stddev + windowed
|
||||
* avgs + regr_slope/r2 for the 7/30/90-day slopes).
|
||||
* 2. `DISTINCT ON (type)` for the most-recent raw value per type.
|
||||
* 3. Per-type-per-day mean (`date_trunc('day', measured_at)`) for the
|
||||
* correlation pairings and the daily-bucket consumers.
|
||||
*
|
||||
* Plus two bounded raw-row queries for sys / dia (BP target pairing
|
||||
* needs sub-day timestamps, can't be replaced by a daily mean).
|
||||
*/
|
||||
export async function buildComprehensiveAggregate(
|
||||
userId: string,
|
||||
): Promise<ComprehensiveAggregate> {
|
||||
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const [aggregates, latests, dailyRows, sysRaw, diaRaw] = await Promise.all([
|
||||
prisma.$queryRaw<AggregateRow[]>`
|
||||
WITH window_stats AS (
|
||||
SELECT
|
||||
m."type",
|
||||
AVG(m."value") AS mean_value,
|
||||
STDDEV_POP(m."value") AS stddev_value
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
GROUP BY m."type"
|
||||
)
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
COUNT(*) AS count,
|
||||
MIN(m."value")::double precision AS min_value,
|
||||
MAX(m."value")::double precision AS max_value,
|
||||
AVG(m."value")::double precision AS mean_value,
|
||||
ws.stddev_value::double precision AS stddev_value,
|
||||
-- |z| > 2 anomaly count over the 90-day window. STDDEV_POP
|
||||
-- matches the JS helper which divides by n (not n-1).
|
||||
COUNT(*) FILTER (
|
||||
WHERE ws.stddev_value IS NOT NULL
|
||||
AND ws.stddev_value > 0
|
||||
AND ABS(
|
||||
(ROUND(((m."value" - ws.mean_value) / ws.stddev_value)::numeric, 2))
|
||||
) > 2
|
||||
) AS anomaly_count,
|
||||
AVG(m."value") FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '7 days'
|
||||
)::double precision AS avg7,
|
||||
AVG(m."value") FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '30 days'
|
||||
)::double precision AS avg30,
|
||||
-- Prior 30d window (days [30, 60) ago) used by tile delta callout.
|
||||
AVG(m."value") FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '60 days'
|
||||
AND m."measured_at" < NOW() - INTERVAL '30 days'
|
||||
)::double precision AS avg30_last_month,
|
||||
REGR_SLOPE(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '7 days'
|
||||
)::double precision AS slope7,
|
||||
REGR_R2(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '7 days'
|
||||
)::double precision AS r2_7,
|
||||
REGR_SLOPE(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '30 days'
|
||||
)::double precision AS slope30,
|
||||
REGR_R2(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '30 days'
|
||||
)::double precision AS r2_30,
|
||||
REGR_SLOPE(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '90 days'
|
||||
)::double precision AS slope90,
|
||||
REGR_R2(
|
||||
m."value",
|
||||
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
|
||||
) FILTER (
|
||||
WHERE m."measured_at" >= NOW() - INTERVAL '90 days'
|
||||
)::double precision AS r2_90
|
||||
FROM measurements m
|
||||
JOIN window_stats ws ON ws."type" = m."type"
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
GROUP BY m."type", ws.stddev_value
|
||||
`,
|
||||
prisma.$queryRaw<LatestRow[]>`
|
||||
SELECT DISTINCT ON (m."type")
|
||||
m."type"::text AS type,
|
||||
m."value"::double precision AS value,
|
||||
m."measured_at" AS measured_at
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
ORDER BY m."type", m."measured_at" DESC
|
||||
`,
|
||||
prisma.$queryRaw<DailyAggregateRow[]>`
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
TO_CHAR(date_trunc('day', m."measured_at"), 'YYYY-MM-DD') AS day,
|
||||
(ROUND((AVG(m."value"))::numeric, 2))::double precision AS mean_value
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
AND m."type" IN ('WEIGHT', 'BLOOD_PRESSURE_SYS', 'PULSE')
|
||||
GROUP BY m."type", date_trunc('day', m."measured_at")
|
||||
ORDER BY m."type", day ASC
|
||||
`,
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
measuredAt: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
measuredAt: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
// ── avg30LastYear ─────────────────────────────────────────
|
||||
// The 90-day window guarantees this is null (no rows >= 365 days
|
||||
// old will appear) — preserves legacy semantics.
|
||||
|
||||
const latestByType = new Map<string, { value: number; measuredAt: Date }>();
|
||||
for (const row of latests) {
|
||||
latestByType.set(row.type, {
|
||||
value: Number(row.value),
|
||||
measuredAt: new Date(row.measured_at),
|
||||
});
|
||||
}
|
||||
|
||||
// Seed only types that actually have rows — the legacy route only
|
||||
// populated `summaries[t]` when `data.length > 0`.
|
||||
const summaries: Record<string, DataSummary> = {};
|
||||
let totalMeasurements = 0;
|
||||
let firstMeasurementAt: Date | null = null;
|
||||
|
||||
for (const row of aggregates) {
|
||||
const count = Number(row.count);
|
||||
totalMeasurements += count;
|
||||
const latest = latestByType.get(row.type);
|
||||
summaries[row.type] = {
|
||||
count,
|
||||
latest: latest?.value ?? null,
|
||||
min: round2(row.min_value),
|
||||
max: round2(row.max_value),
|
||||
mean: round2(row.mean_value),
|
||||
avg7: round2(row.avg7),
|
||||
avg30: round2(row.avg30),
|
||||
slope7: buildSlope(row.slope7, row.r2_7),
|
||||
slope30: buildSlope(row.slope30, row.r2_30),
|
||||
slope90: buildSlope(row.slope90, row.r2_90),
|
||||
anomalyCount: Number(row.anomaly_count),
|
||||
avg30LastMonth: round2(row.avg30_last_month),
|
||||
// Legacy semantics: the 90-day findMany window guarantees no
|
||||
// rows from a year ago. Always null.
|
||||
avg30LastYear: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── firstMeasurementAt ───────────────────────────────────
|
||||
// Take the earliest `measuredAt` across the user's 90-day rows.
|
||||
// One extra cheap aggregate query keeps the route's `dataSpanDays`
|
||||
// truthful. Run lazily: skip when no aggregate rows came back
|
||||
// (totalMeasurements === 0).
|
||||
if (totalMeasurements > 0) {
|
||||
const firstRows = await prisma.$queryRaw<Array<{ first_at: Date | null }>>`
|
||||
SELECT MIN(m."measured_at") AS first_at
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
`;
|
||||
firstMeasurementAt = firstRows[0]?.first_at
|
||||
? new Date(firstRows[0].first_at)
|
||||
: null;
|
||||
}
|
||||
|
||||
// ── daily-by-type ────────────────────────────────────────
|
||||
const dailyByType: Record<string, Array<{ day: string; value: number }>> = {};
|
||||
for (const row of dailyRows) {
|
||||
const list = dailyByType[row.type] ?? [];
|
||||
list.push({ day: row.day, value: Number(row.mean_value) });
|
||||
dailyByType[row.type] = list;
|
||||
}
|
||||
|
||||
return {
|
||||
summaries,
|
||||
bpRawRows: {
|
||||
sys: sysRaw.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
|
||||
dia: diaRaw.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
|
||||
},
|
||||
dailyByType,
|
||||
firstMeasurementAt,
|
||||
totalMeasurements,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* v1.4.35 — integration coverage for the comprehensive insights cache.
|
||||
*
|
||||
* Pins two contracts the unit suite cannot reach:
|
||||
*
|
||||
* 1. **Read-through cache.** Priming with one call counts as a
|
||||
* miss; the second call inside the 60-second TTL counts as a hit
|
||||
* and annotates `cache.analytics.outcome === "hit"` on the route's
|
||||
* wide event.
|
||||
*
|
||||
* 2. **SQL aggregation parity end-to-end.** The route's envelope
|
||||
* includes every legacy key (summaries / bmi / bp* / mood* /
|
||||
* medications / alerts / hasProvider / dataSpanDays /
|
||||
* totalMeasurements) computed from a fixture of ~50 measurement
|
||||
* rows. The byte-shape matches what the consumer (/insights page)
|
||||
* reads on first paint.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// `resolveProvider` reads `app_settings.default_locale` which trails the
|
||||
// production schema in this integration env. Mock the leaf: the route
|
||||
// only uses `provider.type !== "none"` to set the `hasProvider` flag.
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProvider: vi.fn().mockResolvedValue({ type: "none" }),
|
||||
}));
|
||||
|
||||
import { clearLogBuffer, readLogBuffer } from "@/lib/logging/in-memory-buffer";
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
clearLogBuffer();
|
||||
const { __resetAllCachesForTests } = await import(
|
||||
"@/lib/cache/server-cache"
|
||||
);
|
||||
__resetAllCachesForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function seedSession(username: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
email: `${username}@example.test`,
|
||||
role: "USER",
|
||||
heightCm: 180,
|
||||
dateOfBirth: new Date("1985-01-01"),
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return user;
|
||||
}
|
||||
|
||||
describe("GET /api/insights/comprehensive — server cache", () => {
|
||||
it("returns a hit on the second call inside the TTL", async () => {
|
||||
const user = await seedSession("comp-cache-hit-user");
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
// Seed ~50 rows so the aggregator has data to return.
|
||||
const now = Date.now();
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const weightRows = Array.from({ length: 30 }, (_, i) => ({
|
||||
userId: user.id,
|
||||
type: "WEIGHT" as const,
|
||||
value: 80 + (i % 5) * 0.2,
|
||||
unit: "kg",
|
||||
source: "MANUAL" as const,
|
||||
measuredAt: new Date(now - i * DAY_MS),
|
||||
}));
|
||||
const sysRows = Array.from({ length: 10 }, (_, i) => ({
|
||||
userId: user.id,
|
||||
type: "BLOOD_PRESSURE_SYS" as const,
|
||||
value: 120 + i,
|
||||
unit: "mmHg",
|
||||
source: "MANUAL" as const,
|
||||
measuredAt: new Date(now - i * DAY_MS),
|
||||
}));
|
||||
const diaRows = Array.from({ length: 10 }, (_, i) => ({
|
||||
userId: user.id,
|
||||
type: "BLOOD_PRESSURE_DIA" as const,
|
||||
value: 75 + i,
|
||||
unit: "mmHg",
|
||||
source: "MANUAL" as const,
|
||||
measuredAt: new Date(now - i * DAY_MS),
|
||||
}));
|
||||
await prisma.measurement.createMany({
|
||||
data: [...weightRows, ...sysRows, ...diaRows],
|
||||
});
|
||||
|
||||
const { GET } = await import("@/app/api/insights/comprehensive/route");
|
||||
const { caches } = await import("@/lib/cache/server-cache");
|
||||
|
||||
const first = await (
|
||||
GET as unknown as (req: Request) => Promise<Response>
|
||||
)(new Request("http://localhost/api/insights/comprehensive"));
|
||||
expect(first.status).toBe(200);
|
||||
expect(caches.analytics.stats().misses).toBe(1);
|
||||
expect(caches.analytics.stats().hits).toBe(0);
|
||||
|
||||
// Second call inside the TTL — hit.
|
||||
const second = await (
|
||||
GET as unknown as (req: Request) => Promise<Response>
|
||||
)(new Request("http://localhost/api/insights/comprehensive"));
|
||||
expect(second.status).toBe(200);
|
||||
expect(caches.analytics.stats().hits).toBe(1);
|
||||
|
||||
// The wide-event annotation carries the cache outcome on the hit.
|
||||
const events = readLogBuffer({});
|
||||
const cacheEvent = events.find(
|
||||
(e) =>
|
||||
(e?.meta as Record<string, unknown> | undefined)?.[
|
||||
"cache.analytics.outcome"
|
||||
] === "hit",
|
||||
);
|
||||
expect(cacheEvent).toBeDefined();
|
||||
});
|
||||
|
||||
it("emits every legacy envelope key with real SQL aggregation", async () => {
|
||||
const user = await seedSession("comp-cache-shape-user");
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
// A tight fixture: 5 paired BP readings + 5 weight rows + 5 mood
|
||||
// entries spread across 5 distinct days. Enough to populate every
|
||||
// correlation block (n >= 5 is the pearsonCorrelation gate).
|
||||
const now = Date.now();
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const dayDates = Array.from(
|
||||
{ length: 5 },
|
||||
(_, i) => new Date(now - i * DAY_MS),
|
||||
);
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
// Sys + dia at exactly the same instant (5-min tolerance auto-passes)
|
||||
...dayDates.flatMap((d, i) => [
|
||||
{
|
||||
userId: user.id,
|
||||
type: "BLOOD_PRESSURE_SYS" as const,
|
||||
value: 120 + i,
|
||||
unit: "mmHg",
|
||||
source: "MANUAL" as const,
|
||||
measuredAt: d,
|
||||
},
|
||||
{
|
||||
userId: user.id,
|
||||
type: "BLOOD_PRESSURE_DIA" as const,
|
||||
value: 75 + i,
|
||||
unit: "mmHg",
|
||||
source: "MANUAL" as const,
|
||||
measuredAt: d,
|
||||
},
|
||||
]),
|
||||
...dayDates.map((d, i) => ({
|
||||
userId: user.id,
|
||||
type: "WEIGHT" as const,
|
||||
value: 80 + i,
|
||||
unit: "kg",
|
||||
source: "MANUAL" as const,
|
||||
measuredAt: d,
|
||||
})),
|
||||
...dayDates.map((d, i) => ({
|
||||
userId: user.id,
|
||||
type: "PULSE" as const,
|
||||
value: 65 + i,
|
||||
unit: "bpm",
|
||||
source: "MANUAL" as const,
|
||||
measuredAt: d,
|
||||
})),
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.moodEntry.createMany({
|
||||
data: dayDates.map((d, i) => ({
|
||||
userId: user.id,
|
||||
date: d.toISOString().slice(0, 10),
|
||||
mood: "GUT",
|
||||
score: 3 + (i % 3),
|
||||
moodLoggedAt: d,
|
||||
})),
|
||||
});
|
||||
|
||||
const { GET } = await import("@/app/api/insights/comprehensive/route");
|
||||
|
||||
const response = await (
|
||||
GET as unknown as (req: Request) => Promise<Response>
|
||||
)(new Request("http://localhost/api/insights/comprehensive"));
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = (await response.json()) as {
|
||||
data: Record<string, unknown> & {
|
||||
summaries: Record<
|
||||
string,
|
||||
{ count: number; latest: number | null; mean: number | null } | undefined
|
||||
>;
|
||||
totalMeasurements: number;
|
||||
bpPctInTarget: number | null;
|
||||
bpClassification: { category: string } | null;
|
||||
bmi: number | null;
|
||||
};
|
||||
};
|
||||
|
||||
// Every legacy key still present.
|
||||
const legacyKeys = [
|
||||
"summaries",
|
||||
"bmi",
|
||||
"bmiClassification",
|
||||
"bpClassification",
|
||||
"bpPctInTarget",
|
||||
"bpTargets",
|
||||
"weightBpCorrelation",
|
||||
"scatterData",
|
||||
"bpMedicationCorrelation",
|
||||
"bpMedicationScatterData",
|
||||
"moodSummary",
|
||||
"moodBpCorrelation",
|
||||
"moodBpScatterData",
|
||||
"moodWeightCorrelation",
|
||||
"moodWeightScatterData",
|
||||
"moodPulseCorrelation",
|
||||
"moodPulseScatterData",
|
||||
"medications",
|
||||
"alerts",
|
||||
"hasProvider",
|
||||
"dataSpanDays",
|
||||
"totalMeasurements",
|
||||
];
|
||||
for (const key of legacyKeys) {
|
||||
expect(body.data).toHaveProperty(key);
|
||||
}
|
||||
|
||||
// Sanity-check the aggregator's numbers.
|
||||
expect(body.data.summaries.WEIGHT?.count).toBe(5);
|
||||
expect(body.data.summaries.BLOOD_PRESSURE_SYS?.count).toBe(5);
|
||||
expect(body.data.summaries.PULSE?.count).toBe(5);
|
||||
// 5 SYS + 5 DIA + 5 WEIGHT + 5 PULSE = 20 measurement rows. Mood
|
||||
// entries live on `moodEntries` and don't count toward this field
|
||||
// (matches legacy semantics).
|
||||
expect(body.data.totalMeasurements).toBe(20);
|
||||
// bpPctInTarget: 120-124/75-79 all inside under-65 ceiling.
|
||||
expect(body.data.bpPctInTarget).toBe(100);
|
||||
// BMI: latest weight = 80 (newest day, smallest i) at 1.80m → 24.7.
|
||||
expect(body.data.bmi).toBeCloseTo(24.7, 1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user