From e5b958cd86a7ade8fbb6b76f9e71defba5a9008a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 22:55:05 +0200 Subject: [PATCH 1/5] fix(backup): carry the account's own cycle symptoms into the payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The section builder returned them, the wire schema declared them, and the restore has read them since v1.33.1 — including the throw that replaced the silent drop. The payload assembly copied its keys out one by one and was never extended, so every backup written carried an empty list. An account with a custom symptom did not get the silent drop that release removed; it got a restore that failed outright on a key nothing had written back. Spread the cycle and records sections instead of hand-picking their keys, so a field added to either section interface reaches the wire by construction. Declare labelEncrypted on the cycle section, which the builder already emitted. --- src/lib/cycle/backup.ts | 10 +++++++ src/lib/export/full-backup-payload.ts | 43 +++++++++++++++++---------- 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts index 691a65875..ce511a02c 100644 --- a/src/lib/cycle/backup.ts +++ b/src/lib/cycle/backup.ts @@ -108,6 +108,16 @@ export interface CycleBackupSection { icon: string | null; sortOrder: number; isActive: boolean; + /** + * The user's own words for the symptom, carried as ciphertext verbatim. + * + * The builder has always emitted it and this interface did not declare it, + * which held while the payload was assembled key by key and stopped holding + * the moment the section is spread onto the wire: an undeclared field is a + * field the wire type says is not there. Declared, so the type describes + * the file. + */ + labelEncrypted: string | null; }>; } diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 20a5af75c..fe7b4707e 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -15,11 +15,15 @@ import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import { readNote } from "@/lib/crypto/note-cipher"; import { iterateMeasurementPages } from "@/lib/export/paged-measurements"; import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup"; -import { buildCycleBackupSection } from "@/lib/cycle/backup"; +import { + buildCycleBackupSection, + type CycleBackupSection, +} from "@/lib/cycle/backup"; import { buildRecordsBackupSection, countRecordsBackupSection, type RecordsBackupCounts, + type RecordsBackupSection, } from "@/lib/export/records-backup"; export interface FullBackupCounts extends RecordsBackupCounts { @@ -210,6 +214,11 @@ export async function buildFullBackupPayload( }), ]); + // Pinned to the section interfaces so the spreads below cannot quietly widen + // (or narrow) what reaches the wire. + const cycleSection: CycleBackupSection = cycle; + const recordsSection: RecordsBackupSection = records; + const payload = { schemaVersion: BACKUP_SCHEMA_VERSION, exportedAt: (options.exportedAt ?? new Date()).toISOString(), @@ -335,20 +344,24 @@ export async function buildFullBackupPayload( scaleMax: tag.scaleMax, inverse: tag.inverse, })), - cycleProfile: cycle.cycleProfile, - cycles: cycle.cycles, - cycleDayLogs: cycle.cycleDayLogs, - // v1.28 backup-completeness — the domains the pre-existing shape never - // covered. `manifest` discloses the two deliberate exclusions (document - // binaries, workout GPS/sample time series) inline in the file itself, - // not just in the export UI copy. - labResults: records.labResults, - biomarkers: records.biomarkers, - illnessEpisodes: records.illnessEpisodes, - allergies: records.allergies, - familyHistory: records.familyHistory, - workouts: records.workouts, - documents: records.documents, + // Both sections are SPREAD, not hand-picked key by key. + // + // Hand-picking is what lost the custom cycle symptoms. `buildCycleBackupSection` + // returned them, the wire schema declared them, `restoreCycleData` read them + // and — since v1.33.1 — THREW on a key it could not resolve. The list of keys + // copied out here was simply never extended, so every backup written carried + // `customSymptoms: []`. An account with one custom symptom did not get the + // silent drop that release removed; it got a restore that failed outright on + // a key nothing had written back, and the fix was inert in production. + // + // Spreading makes the section interface the single declaration of what rides + // the wire: a field added to `CycleBackupSection` / `RecordsBackupSection` + // reaches the payload by construction rather than by someone remembering. + // `manifest` (from the records section) discloses the two deliberate + // exclusions — document binaries, workout GPS/sample series — in the file + // itself, not just in the export UI copy. + ...cycleSection, + ...recordsSection, nutrientDays: nutrientDays.map((n) => ({ day: n.day, nutrient: n.nutrient, From ddbce306be5ddd35b4aa94bd3a5ce8d5f5b1aa20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 28 Jul 2026 00:09:10 +0200 Subject: [PATCH 2/5] fix(backup): drop the manifest key the records spread already carries The assembly picked `manifest` out of the records section by hand, one line under the comment explaining that both sections are spread precisely so nobody has to pick their keys. It resolved to the same value the spread had already placed, at the same position, so the emitted file is byte-identical either way. Left in, it is the pattern that lost the custom cycle symptoms sitting next to the note about how that happened. --- src/lib/export/full-backup-payload.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index fe7b4707e..f8bbe25a9 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -369,7 +369,6 @@ export async function buildFullBackupPayload( unit: n.unit, source: n.source, })), - manifest: records.manifest, }; return { From dd3a7915d6d0c2e33772d4be8628114410b7faaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 28 Jul 2026 00:09:22 +0200 Subject: [PATCH 3/5] test(backup): cover the assembly a custom cycle symptom has to cross Both ends of this defect had passing tests for the whole time it was broken. The section builder returned the symptoms, the wire schema declared them, the restore read them, and each of those three was covered on its own. Nothing covered the object literal in between, which copied section keys out one at a time and never listed `customSymptoms`. So the test spans the seam and asserts on rows. It populates an account with a symptom of its own on a day-log, builds the file through the real `buildFullBackupPayload`, serialises it, parses it back through the real `parseBackupPayload`, and drives the real admin restore. Asserting that the payload carries the field would only re-test the writer that was already green. Deleting the symptom row before the restore is the case the field exists for, not a contrivance: `restoreCycleData` clears day-logs, cycles, and the profile but never the symptom catalogue, so a restore onto the same instance still finds the row sitting there. That is why the older round-trip test stayed green through the whole thing. Mutation check: hand-pick the cycle section's keys again and the restore answers 500 on `Unknown cycle symptom keys: custom:sacroiliac-ache`, which is the production failure verbatim. --- ...ackup-custom-cycle-symptom-restore.test.ts | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 tests/integration/backup-custom-cycle-symptom-restore.test.ts diff --git a/tests/integration/backup-custom-cycle-symptom-restore.test.ts b/tests/integration/backup-custom-cycle-symptom-restore.test.ts new file mode 100644 index 000000000..be4709966 --- /dev/null +++ b/tests/integration/backup-custom-cycle-symptom-restore.test.ts @@ -0,0 +1,214 @@ +/** + * The seam between the backup writer and the backup reader. + * + * Custom cycle symptoms had a green test on every end and shipped broken + * anyway. `buildCycleBackupSection` returned them, `backupPayloadSchema` + * declared them, `restoreCycleData` read them, and each of those three was + * covered. What nothing covered was the assembly in between: the payload was + * built as an object literal that copied section keys out one at a time, and + * `customSymptoms` was never on that list. Every backup written carried an + * empty array, so an account whose day-log referenced a symptom it had created + * itself could not be restored at all — the restore threw on a key nothing had + * written back and the route answered 500. + * + * So this test asserts on ROWS, never on the payload object. Asserting the + * payload carries `customSymptoms` would re-test the writer, which was already + * passing while production was broken. The account is populated, the REAL + * `buildFullBackupPayload` produces the file, the file is serialised and parsed + * back through the REAL `parseBackupPayload`, the account's own symptom row is + * removed so the file is the only place it survives, and the REAL admin restore + * has to put the row and its day-log link back. + * + * Removing the symptom row before restoring is the point, not a contrivance: + * `restoreCycleData` wipes day-logs, cycles, and the profile, but never the + * symptom catalogue. Restore onto the same instance and the row is still + * sitting there to resolve against, which is exactly why the older round-trip + * test stayed green. The file has to carry the symptom for the case the + * `customSymptoms` field exists for: a restore onto an instance that never had + * it. + * + * Mutation check: hand-pick the cycle section's keys in + * `buildFullBackupPayload` instead of spreading it, and this test reproduces + * the production failure — restore answers 500 on + * `Unknown cycle symptom keys: custom:sacroiliac-ache`. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { decrypt, encrypt } from "@/lib/crypto"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { parseBackupPayload } from "@/lib/validations/backup"; +import { POST } from "@/app/api/admin/backups/[id]/restore/route"; +import { invalidateUserData } from "@/lib/cache/invalidate"; + +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), +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserData: vi.fn(), +})); + +/** The user's own words for the symptom, as they are held at rest. */ +const SYMPTOM_LABEL = "Sacroiliac ache"; +const SYMPTOM_KEY = "custom:sacroiliac-ache"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + vi.mocked(invalidateUserData).mockClear(); +}); + +function makeRequest(id: string) { + return new Request(`http://localhost/api/admin/backups/${id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }); +} + +describe("a backup of an account with its own cycle symptom", () => { + it("restores the symptom and its day-log link onto an instance without it", async () => { + const prisma = getPrismaClient(); + + // The restore writes into the backup's owner, and the route refuses a + // payload whose declared owner differs from the backup row. Owner and + // operator are the same account here so the fixture stays about the + // symptom rather than about the ownership guard. + const owner = await prisma.user.create({ + data: { + username: "cycle-symptom-owner", + email: "cycle-symptom-owner@example.test", + role: "ADMIN", + }, + }); + const session = await prisma.session.create({ + data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + + const category = await prisma.cycleSymptomCategory.findFirstOrThrow(); + const symptom = await prisma.cycleSymptom.create({ + data: { + userId: owner.id, + categoryId: category.id, + key: SYMPTOM_KEY, + labelKey: "cycle.symptom.custom", + labelEncrypted: encrypt(SYMPTOM_LABEL), + sortOrder: 3, + }, + }); + await prisma.cycleProfile.create({ + data: { + userId: owner.id, + goal: "GENERAL_HEALTH", + cycleTrackingEnabled: true, + typicalCycleLength: 29, + }, + }); + const cycle = await prisma.menstrualCycle.create({ + data: { + userId: owner.id, + startDate: "2026-07-01", + periodEndDate: "2026-07-05", + tz: "Europe/Berlin", + }, + }); + const dayLog = await prisma.cycleDayLog.create({ + data: { + userId: owner.id, + cycleId: cycle.id, + date: "2026-07-03", + flow: "MEDIUM", + source: "MANUAL", + tz: "Europe/Berlin", + symptomLinks: { create: { symptomId: symptom.id } }, + }, + }); + + // The real writer, the real wire format, the real reader. Nothing is + // asserted about `built` — the writer was green through the whole defect. + const { payload: built } = await buildFullBackupPayload(prisma, owner.id, { + purpose: "disaster-recovery", + }); + const parsed = parseBackupPayload(JSON.stringify(built)); + const backup = await prisma.dataBackup.create({ + data: { + userId: owner.id, + type: "CUSTOM_CYCLE_SYMPTOM_ROUND_TRIP", + data: encrypt(JSON.stringify(parsed)), + }, + }); + + // Model the instance the `customSymptoms` field exists for: one that never + // had this account's own symptom. The restore clears day-logs, cycles, and + // the profile itself, but never the symptom catalogue, so the row has to go + // before the restore runs or the file is not what resolves the link. + await prisma.cycleDayLog.deleteMany({ where: { userId: owner.id } }); + await prisma.menstrualCycle.deleteMany({ where: { userId: owner.id } }); + await prisma.cycleProfile.deleteMany({ where: { userId: owner.id } }); + await prisma.cycleSymptom.deleteMany({ where: { userId: owner.id } }); + expect( + await prisma.cycleSymptom.count({ where: { key: SYMPTOM_KEY } }), + ).toBe(0); + + const response = await POST( + makeRequest(backup.id) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + + expect(response.status).toBe(200); + + // Rows, not payload keys. + const restoredSymptom = await prisma.cycleSymptom.findUniqueOrThrow({ + where: { key: SYMPTOM_KEY }, + }); + expect(restoredSymptom).toEqual( + expect.objectContaining({ + userId: owner.id, + categoryId: category.id, + labelKey: "cycle.symptom.custom", + sortOrder: 3, + isActive: true, + }), + ); + // The label is ciphertext on the wire and stays readable after the trip: + // a restored symptom whose label came back empty is the same lost data in + // a quieter form. + expect(decrypt(restoredSymptom.labelEncrypted!)).toBe(SYMPTOM_LABEL); + + const restoredDayLog = await prisma.cycleDayLog.findUniqueOrThrow({ + where: { id: dayLog.id }, + }); + expect(restoredDayLog.date).toBe("2026-07-03"); + expect( + await prisma.cycleSymptomLink.findMany({ + where: { dayLogId: restoredDayLog.id }, + select: { symptomId: true }, + }), + ).toEqual([{ symptomId: restoredSymptom.id }]); + }); +}); From 4d459dbb687b0fece8eab7c432c9e39feea7d0d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 28 Jul 2026 00:09:29 +0200 Subject: [PATCH 4/5] =?UTF-8?q?chore(release):=20v1.33.2=20=E2=80=94=20the?= =?UTF-8?q?=20cycle-symptom=20fix=20that=20did=20not=20reach=20the=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.33.1 claimed custom cycle symptoms were carried in backups. They were not, and the same release turned the old silent drop into a hard failure, so the accounts the fix was written for went from losing one symptom link to being unable to restore at all. --- CHANGELOG.md | 34 ++++++++++++++++++++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index addeeb34e..d0fda05cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ ## [Unreleased] +## [1.33.2] — 2026-07-28 + +### The cycle-symptom fix in v1.33.1 did not work + +v1.33.1 said it had fixed custom cycle symptoms in backups. It had not. The +fix was real in three places and missing from the one that decides what ends +up in the file. The code that reads your cycle data returned the symptoms you +created yourself. The file format declared a field to hold them. The restore +knew how to put them back. But the step that assembles the file copies its +contents across one field at a time, and that list was never extended, so +every backup written since carried an empty list of custom symptoms. + +The same release also replaced a silent drop with an error. Before it, a +backup referencing a symptom the restoring instance did not have lost that +one link and finished. After it, the restore stops and reports a failure. +Together with the empty list, that took an account with a symptom of its own +from losing a single link to being unable to restore at all. That is the +group the fix was written for. + +Nothing in your stored data was damaged. But a backup written by v1.33.1 or +earlier does not hold the symptom definitions you created, and restoring one +of those files on this version will fail at the first day-log referencing +one. Take a fresh backup after updating and keep it alongside the older +files. + +The assembly step no longer lists fields by hand. It copies each section +whole, so a field added to a section reaches the file because of the way the +code is written rather than because somebody remembered. Both ends of this +were covered by tests that passed for the whole time it was broken, and +nothing covered the join between them, so there is now a test that writes a +real backup for an account with a symptom of its own, removes that symptom +from the database, restores the file, and checks the row and its day-log link +came back. + ## [1.33.1] — 2026-07-27 A patch release about the same thing as the one before it: places where the diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 58dc9d0be..994139e02 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.33.1 + version: 1.33.2 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 6cca5848c..5115fa8d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.33.1", + "version": "1.33.2", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 8d6d19c22..e97634d40 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.33.1"; + /* @sw-version-fallback */ "v1.33.2"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` From 2058321007083296a4a275a098e4822c005c2f37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 28 Jul 2026 11:10:24 +0200 Subject: [PATCH 5/5] docs(agents): point the agent entry file at the manual instead of copying it AGENTS.md duplicated the project description, version, stack table and a tree of the source layout, then went untouched for about thirty releases. It announced v1.4.0 as current and called the UI Dracula-themed, both false for a long time. An instruction file that is confidently wrong is worse than none. Replace the copies with a pointer to CLAUDE.md and keep only what is genuinely specific to running as an agent here: the generated-client read ban, the gate including TZ=UTC, reading output rather than exit codes, where working notes live, and how the worktrees are used. --- AGENTS.md | 279 +++++++++--------------------------------------------- 1 file changed, 47 insertions(+), 232 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ceaec3adc..4c2b55b87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,244 +1,59 @@ # AGENTS.md -Instructions for AI coding agents (OpenAI Codex, Claude Code, Cursor, etc.) working on this repository. +Entry point for coding agents working on this repository, whatever harness they run in. -## Project +## Read CLAUDE.md first. It is the operating manual. -**HealthLog** — a personal health-tracking web app (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, Dracula-themed UI, mobile-first PWA design. +`CLAUDE.md` carries the architecture walk, the code conventions, the security patterns, the +branch and release model, the self-hosting gotchas and the DO-NOTs. It is maintained. This file +is not a summary of it and must never become one. -**Status**: v1.4.0 (released) → v1.4.1 in progress. v1.4.0 shipped -the UI guidelines + Skeleton/EmptyState primitives, medical-citations -consolidation, the per-route `/settings/[section]` split, the -status-first admin grid + aggregator endpoint, five new "Test -connection" buttons, AI insights with inline charts via allowlisted -metric tokens, off-host encrypted S3 backup, encryption-key -versioning + rotation CLI, optional `HEALTHLOG_PROCESS_TYPE` worker / -web split, and short-lived 24h access tokens with refresh-token -rotation for native API clients. v1.4.1 follows up with per-section -admin component extraction (the inner monolith into one file per -panel), a Postgres testcontainers integration test suite, and a -Playwright + axe-core E2E foundation. See GitHub Releases + -CHANGELOG.md for the full feature timeline. +That is deliberate, and the reason is written in scar tissue. The previous version of this file +duplicated the project description, the version, the stack table, the command list and a tree of +the source layout. It then sat untouched for roughly thirty releases. It announced "v1.4.0 +released, v1.4.1 in progress" long after the version had passed 1.33, and described the UI as +Dracula-themed long after the palette had been replaced by tokens with a light theme beside it. +Anything that duplicates a moving fact will eventually assert a false one, and a confidently +wrong instruction file is worse than none. So this one points rather than copies. -## Tech Stack +## What is genuinely specific to working here as an agent -| Layer | Technology | Version | Notes | -| --------------- | -------------------- | ------- | ----------------------------------------------------------------------- | -| Framework | Next.js (App Router) | 16 | TypeScript strict, RSC default, `"use client"` only for interactivity | -| ORM | Prisma | 7.8 | Uses `PrismaPg` adapter, **not** `url` in schema — see gotchas below | -| Database | PostgreSQL | 16 | Docker Compose service, port 5432, user `healthlog` | -| UI | shadcn/ui (new-york) | latest | Components in `src/components/ui/` | -| Theme | Dracula | — | CSS variables in `globals.css`, dark mode default, `--dracula-*` tokens | -| CSS | Tailwind | 4 | CSS-first config (`@import "tailwindcss"` syntax) | -| Data fetching | TanStack Query | 5 | Provider in `src/components/providers.tsx` | -| Validation | Zod | v4 | Import as `zod/v4` (not `zod`) | -| Testing | Vitest | latest | Config in `vitest.config.mts` | -| Package manager | pnpm | latest | **Not** npm or yarn | -| Node | 20.x | via nvm | | -| Job queue | pg-boss | 12 | Named import `{ PgBoss }`, see gotchas | -| Auth | SimpleWebAuthn | 13 | Passkeys primary, password fallback | -| i18n | Custom context-based | — | `useTranslations()` hook, `messages/de.json` + `messages/en.json` | -| PDF | jsPDF + autotable | latest | Client-side doctor report generation | +- **Never read anything under `src/generated/`.** It is the generated Prisma client, roughly + 9 MB across 83 files including a 224 KB inline schema. Reading or grepping it exhausts a + context window and stalls the session. Regenerate it with `pnpm prisma generate`; never open + it. Treat `pnpm-lock.yaml`, `CHANGELOG.md`, `messages/*.json` and `docs/api/openapi.yaml` the + same way: grep them narrowly, do not read them whole. +- **The gate before anything is called finished:** + `pnpm typecheck && pnpm lint && pnpm format:check && TZ=UTC pnpm test && pnpm build`. + `TZ=UTC` is load-bearing, because CI runs in UTC and a timezone-fragile date test passes + locally and fails there. `pnpm test:integration` needs Docker. `pnpm e2e` is heavy and worth + running locally before pushing a large branch; skipping it has cost five CI rounds before. +- **Read the output, not the exit code.** `tsc` colours its output, so counting matches for an + error string can return zero while errors are open. A shell chain of two commands can report + success for the second while the first failed. +- **Working notes live in `.planning/`**, which is gitignored. Build plans, audits, design + documents and reconcile notes are there. Read them before planning anything, because they + usually already contain the measurement about to be redone. +- **Parallel work happens in git worktrees off `origin/main`**, one stream per worktree, + file-disjoint. Commit early: a worktree can break and uncommitted work is lost work. Each + worktree carries its own Prisma client, so regenerate in the main tree after merging. -## Commands +## Hard rules for every committed artefact -```bash -# Development -pnpm dev # Start dev server (http://localhost:3000) -pnpm build # Production build -pnpm lint # ESLint -pnpm format # Prettier format -pnpm format:check # Prettier check -pnpm typecheck # TypeScript strict check -pnpm openapi:generate # Regenerate docs/api/openapi.yaml from Zod schemas -pnpm openapi:check # Verify the spec is in sync (CI gate) +Stated in full in `CLAUDE.md` under "Voice and privacy". Repeated here because they are the ones +most often broken by an agent that skipped the manual. -# Git hooks (one-time per clone) -bash scripts/install-hooks.sh # Activates .githooks/, including the OpenAPI pre-commit check +- No tooling or assistant vocabulary in anything that reaches GitHub: not in commit messages, + branch names, PR bodies, code comments or release notes. No `Co-Authored-By` trailer. +- No personal names, no live health figures, no account identifiers. +- English, in the maintainer's voice: present-tense imperative for commits, factual for prose. +- Never `--no-verify`. Never force-push `main` or a release branch. Never `--no-gpg-sign` unless + asked. -# Tests -pnpm test # Run all tests (vitest) -pnpm test:watch # Watch mode +## When a claim and the code disagree -# Database -pnpm db:generate # Generate Prisma client -pnpm db:migrate # Create & apply migration (dev) -pnpm db:migrate:deploy # Apply migrations (production) -pnpm db:push # Push schema without migration (prototyping) -pnpm db:studio # Prisma Studio GUI - -# Docker -docker compose up -d # Start app + postgres -docker compose logs -f app # Tail app logs - -# Verification (run before completing tasks) -pnpm typecheck && pnpm lint && pnpm test && pnpm format:check && pnpm build -``` - -## File Layout - -``` -src/ -├── app/ # Pages and API routes (App Router) -│ ├── layout.tsx # Root layout (viewport-fit: cover for PWA) -│ ├── page.tsx # Dashboard (/) with quick entry dropdown -│ ├── globals.css # Dracula theme CSS variables -│ ├── admin/page.tsx # Admin shell — 77 LOC, mounts the per-section components from src/components/admin/ -│ ├── auth/login/page.tsx # Login -│ ├── auth/register/page.tsx # Registration -│ ├── achievements/page.tsx # Gamification achievements -│ ├── bugreport/page.tsx # Bug report → GitHub issue -│ ├── insights/page.tsx # Health insights + correlations + AI (7 sections with sticky nav) -│ ├── measurements/page.tsx # Measurements CRUD table -│ ├── medications/page.tsx # Medications management -│ ├── notifications/page.tsx # Notification preferences matrix -│ ├── onboarding/page.tsx # 4-step guided onboarding -│ ├── settings/page.tsx # 308-redirects /settings → /settings/account -│ ├── settings/[section]/page.tsx # Per-section route shell (account, integrations, notifications, dashboard, ai, api, advanced, about) -│ ├── mood/page.tsx # Mood tracking -│ ├── targets/page.tsx # Target values dashboard -│ └── api/ # 100+ API route files (admin, auth, measurements, medications, mood, insights, integrations, ingest, dashboard, feedback, tokens, notifications, monitoring, …) -│ ├── mood-entries/ # Mood CRUD -│ ├── import/ # JSON data import -│ ├── doctor-report/ # Doctor report data aggregation -│ ├── gamification/ # Achievements (persistent) -│ └── ... -├── components/ -│ ├── ui/ # shadcn/ui primitives -│ ├── layout/ # Shell: sidebar-nav, topbar, mobile-nav, bottom-nav -│ ├── medications/ # Medication form, card, timeline -│ ├── measurements/ # Measurement form, list -│ ├── mood/ # Mood form, mood list -│ ├── charts/ # Recharts wrappers, compliance charts -│ ├── insights/ # AI insights cards (status, advisor) -│ ├── gamification/ # Achievement cards, progress -│ ├── settings/ # Per-route Settings section components (one per /settings/[section]) -│ ├── admin/ # Per-route Admin section components (system-status, integrations, monitoring, reminders, users, audit, danger-zone, feedback) -│ └── monitoring/ # Umami, GlitchTip bootstrap -├── lib/ -│ ├── db.ts # Prisma client singleton -│ ├── crypto.ts # AES-256-GCM encryption -│ ├── gravatar.ts # Gravatar URL from email (SHA-256) -│ ├── api-response.ts # { data, error, meta } envelope helpers -│ ├── rate-limit.ts # In-memory sliding window rate limiter -│ ├── export.ts # CSV/JSON export formatting (incl. mood) -│ ├── doctor-report-pdf.ts # Client-side PDF generation (jsPDF) -│ ├── auth/session.ts # getSession() → { session, user } | null -│ ├── analytics/ # Trend calculations, compliance, correlations -│ ├── i18n/ # I18n context + config -│ ├── notifications/ # Dispatcher, types, senders (telegram, ntfy, web-push) -│ ├── jobs/ # pg-boss worker (reminders, insights, backups) -│ ├── validations/ # Zod schemas (shared between API + client) -│ ├── ai/ # Multi-provider client (OpenAI, Anthropic, local OpenAI-compat) -│ ├── feedback/ # In-app feedback + optional GitHub escalation -│ ├── gamification/ # Achievement definitions + progress -│ ├── insights/ # Insight pipeline + prompts -│ ├── logging/ # Wide Events: builder, context, sampler, transports, background -│ ├── monitoring/ # Umami / GlitchTip server-side hooks -│ └── withings/ # Withings OAuth client + sync service -├── hooks/ -│ └── use-auth.ts # useAuth(), useLogout() hooks -├── generated/prisma/client/ # Generated Prisma client (DO NOT EDIT) -messages/ -├── de.json # German translations (primary UI language) -└── en.json # English translations -prisma/ -├── schema.prisma # Database schema (26 models) -└── migrations/ # Migration files (0001–0025; latest: refresh_tokens + user_locale_drift_fix) -prisma.config.ts # Prisma config (DB URL lives here, NOT in schema) -public/ -├── sw.js # Service worker (Web Push + offline caching) -├── manifest.json # PWA manifest -└── ... # Static assets -docs.healthlog.dev # External documentation site -``` - -## Key Conventions - -- **Default locale is English**. All code-level strings in English. UI text via i18n (`t()` calls) with German + English translations. -- **i18n**: Use `useTranslations()` hook → `t("section.key")`. Supports parameter interpolation: `t("key", { count: 5 })`. Messages in `messages/de.json` + `messages/en.json`. -- **API response format**: Always `{ data, error, meta }` via `apiSuccess(data)` / `apiError(message, status)` from `src/lib/api-response.ts`. -- **Auth check pattern**: `const session = await getSession(); if (!session) return apiError("Not authenticated", 401);` -- **Timezone**: `Europe/Berlin` for display, UTC in database. -- **Encryption**: Sensitive data (Withings tokens, API keys, VAPID private keys) encrypted with AES-256-GCM via `src/lib/crypto.ts`. -- **Dracula colors**: Use CSS variables `var(--dracula-purple)`, `var(--dracula-cyan)`, `var(--dracula-green)`, `var(--dracula-orange)`, `var(--dracula-pink)`, `var(--dracula-red)`, `var(--dracula-yellow)`, `var(--dracula-fg)`, `var(--dracula-comment)` for chart/graph elements. -- **Proxy route protection**: `src/proxy.ts` checks `healthlog_session` cookie on all non-public paths. Unauthenticated page requests → redirect `/auth/login`. API routes keep their own `getSession()` auth. Public paths defined in `PUBLIC_PATHS` array. -- **PWA safe area**: Root layout uses `viewportFit: "cover"`. Bottom-nav has `pb-[env(safe-area-inset-bottom)]`. Auth-shell uses `pb-[calc(5rem+env(safe-area-inset-bottom,0px))]`. -- **PDF generation**: Client-side via `src/lib/doctor-report-pdf.ts` using jsPDF. Data fetched from `POST /api/doctor-report`. European medical format with German terminology. -- **Data backup**: pg-boss weekly job (`data-backup` queue, Sundays 03:00), stores compressed JSON in `DataBackup` model. -- **Achievements**: Persistent in `UserAchievement` table. API at `/api/gamification/achievements` computes current state, compares with DB, persists new unlocks. `unlockedAt` timestamp is stable once set. - -## Critical Gotchas - -These are hard-won lessons. Ignoring them will cause errors: - -### Prisma 7 - -- **No `url` in schema.prisma** — The database URL is configured in `prisma.config.ts`, not in the `datasource` block. -- **Import path**: `import { ... } from "@/generated/prisma/client"` (with `/client` suffix, not `@/generated/prisma`). -- **Adapter required**: PrismaClient needs `PrismaPg` adapter from `@prisma/adapter-pg`. See `src/lib/db.ts`. -- **No `earlyAccess`** in defineConfig — causes TypeScript errors. - -### Next.js 16 - -- **Proxy, not middleware**: Next.js 16 renamed middleware to proxy. The file is `src/proxy.ts` (not `middleware.ts`). Having both causes a build error: "Both middleware file and proxy file detected". - -### Libraries - -- **SimpleWebAuthn v13**: No `@simplewebauthn/server/script/deps` — define Transport type inline. -- **zxcvbn-typescript**: Default export only. `zxcvbnAsync` and `zxcvbnOptions` do not exist as named exports. -- **pg-boss v12**: `{ PgBoss }` named import (not default), use `localConcurrency` (not `teamSize`), handler receives `Job[]` array. -- **Zod v4**: Import from `"zod/v4"`, not `"zod"`. -- **jsPDF**: Client-side only. Import dynamically in browser context. Used with `jspdf-autotable` plugin. - -### Settings & Admin (per-route layout, v1.4) - -- **Settings**: 8 routes under `/settings/[section]` — `account`, `integrations`, `notifications`, `dashboard`, `ai`, `api`, `advanced`, `about`. The legacy `/settings` 308-redirects to `/settings/account`. Sidebar deep-links and the `` patterns from 1.3 still resolve via the redirect. -- **Admin**: `src/app/admin/page.tsx` is now a 77-LOC shell that mounts the per-section components in `src/components/admin/`. Status-card grid lives in `status-card-grid.tsx`. The aggregator endpoint `/api/admin/status-overview` returns the six-card summaries in one batched query. -- ESLint `react-hooks/set-state-in-effect` is **strict** now (was non-blocking when the settings monolith carried inline-effect state-setters). Use lazy `useState(() => …)` for localStorage reads, TanStack Query for data fetches. - -### Insights Page - -- Large file (~1750 lines), 7 content sections with AI summaries. -- Sticky horizontal chip navigation (`InsightsSectionNav` component) with IntersectionObserver for active state tracking. -- Section IDs: `general`, `bp`, `weight`, `pulse`, `mood`, `meds`, `bmi`. -- Each section has `scroll-mt-28` for header offset on scroll. - -### Sidebar - -- Collapsible (icons-only mode, `w-16`). State persisted in localStorage (`healthlog-sidebar-collapsed`). -- User section at bottom: Avatar (Gravatar if email, initials fallback) + username + three-dot MoreVertical menu (right side). -- Three-dot menu: Admin link, Notifications, Theme picker, Logout. -- ESLint enforces `react-hooks/set-state-in-effect` — use lazy `useState(() => ...)` for localStorage reads, NOT `useEffect` + `setState`. - -### Gravatar - -- `src/lib/gravatar.ts` generates Gravatar URL server-side (SHA-256 hash of email, `?d=404` fallback). -- Returned in `/api/auth/me` response as `gravatarUrl` field. Used by `AvatarImage` component with `AvatarFallback` for initials. - -### Notification System - -- **Channels**: `telegram`, `ntfy`, `web_push` — stored as plain `String` on `NotificationChannel.type` (no Prisma enum; the canonical list lives in `src/lib/notifications/types.ts`). -- **Event types**: `medication_reminder`, `measurement_anomaly`, `compliance_low`, `withings_sync_failed`, `system_alert` — also `String` on `NotificationPreference.eventType`. Source of truth is the discriminated union in `src/lib/notifications/types.ts`. -- **Opt-out model**: Preferences default to ON (enabled) when no `NotificationPreference` row exists. -- **Dispatcher**: `src/lib/notifications/dispatcher.ts` checks channel enabled + preference per event type. -- **Telegram**: Inline buttons (Take/Skip/Snooze 1h/Snooze 3h), pre-end window reminders (30 min before), message auto-cleanup after 24h. - -### Onboarding - -- 4-step guided flow: Profile → Medications → Notifications teaser → Target values. -- Optional steps 2-4 have "Skip" buttons. -- Progress indicator bar at top. -- Located at `/onboarding`, linked from proxy as public path. - -## Database Models (Prisma) - -26 models: `User`, `Passkey`, `Session`, `AuthChallenge`, `Measurement`, `Medication`, `MedicationSchedule`, `MedicationIntakeEvent`, `ReminderPhaseConfig`, `TelegramReminderMessage`, `TelegramScheduledDeletion`, `ApiToken`, `RefreshToken`, `WithingsConnection`, `MoodEntry`, `AppSettings`, `Feedback`, `AuditLog`, `NotificationChannel`, `NotificationPreference`, `PushSubscription`, `DataBackup`, `UserAchievement`, `RateLimit`, `Device`, `IdempotencyKey`. (`RefreshToken` added in v1.4.0 alongside the native-client 24h access-token / refresh-token rotation flow.) - -## When Making Changes - -1. **Read before modifying** — understand the existing code patterns. -2. **Run verification** after changes: `pnpm typecheck && pnpm lint && pnpm test && pnpm format:check && pnpm build` -3. **German UI text** — all user-facing strings go through `t("key")` with translations in both `messages/de.json` and `messages/en.json`. -4. **Don't over-engineer** — keep changes focused. No speculative abstractions. -5. **Update `docs/STATUS.md`** when completing tasks. +Fix the claim. A comment, a docblock or a release note asserting behaviour the code does not +have is treated as a defect here, not as documentation drift. More than one real failure in this +project began as prose that nothing enforced, including one that shipped a fix which did +nothing while its notes said otherwise. If it cannot be fixed inside the change being made, say +so plainly in the same file rather than leaving the false sentence standing.