diff --git a/CHANGELOG.md b/CHANGELOG.md
index a90e136bc..35bef4f85 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,21 @@
## [Unreleased]
+## [1.12.9] — 2026-06-05 — a tighter mood catalogue, clearer cards, and a fixed retrospective
+
+### Changed
+
+- **The mood factors are down to three sliders** — work, sleep quality, and a new sadness slider — and the day-tag list is trimmed to a tighter, higher-signal set. Tags you've already used stay on your past entries; they just no longer clutter the picker.
+- **Medication cards read cleaner.** The next-dose and last-dose lines are now their own rows: the label on the left, the time flush to the right.
+
+### Fixed
+
+- **The period retrospective generates again.** A non-measurement metric was leaking into a measurement query and failing the background job; it's now filtered out.
+
+### Documentation
+
+- **Every code directory has a README**, the main README embeds the architecture diagrams and a documentation index, and the contributor guide reflects the current trunk-based release model.
+
## [1.12.8] — 2026-06-05 — fuller WHOOP data, charts that follow your selection
### Added
@@ -37,6 +52,26 @@
- **Metric pages and the Insights overview show a retry on error** instead of a blank or a false "no data yet".
- **Account profile:** the timezone selector fills its row, and the "globally excluded injection sites" card matches the other cards.
+## [1.12.7] — 2026-06-05 — Insights that react, read clean, and stay legible
+
+### Added
+
+- **The stats above a chart now follow your selection.** Brush a window on a metric chart and the min / max / median / average recompute for just that span; clear the selection to return to the full range. Works the same on every metric page, and blood pressure now shows systolic and diastolic in one card, side by side.
+
+### Changed
+
+- **The mood page reads top to bottom the way you'd walk it.** Summary, then the mood calendar, then the chart, then the target, then the assessment — followed by the breakdowns. The two "what stands out" sections are merged into one card, the statistical footnote is gone (the detail lives in a small info tooltip), and the mood colours are aligned to the rest of the charts.
+- **VO₂max opens its full chart.** The compact row on the pulse page now links to the dedicated cardio-fitness page instead of showing a chart-less summary.
+- **AI assessments vary more across your metrics.** The anti-repetition context now reaches the main cards (blood pressure, weight, pulse, mood, BMI, medication, overall), a step is only suggested when the finding implies one, and the default models were refreshed so the shared-key fallback isn't a quality drop.
+
+### Fixed
+
+- **Status colours stay legible on the light theme.** Connection states, recommendation badges, score provenance, and the settings success messages now use tokens that meet contrast in both themes.
+- **A late device sync no longer shrinks a day's step or energy total.** Trailing samples are folded into the existing daily total instead of replacing it.
+- **The medication streak is correct across time zones**, and the dashboard now serves its snapshot instantly after a sync while refreshing in the background.
+- **Metric pages and the Insights overview show a retry on error** instead of a blank or a false "no data yet".
+- **Account profile:** the timezone selector fills its row, and the "globally excluded injection sites" card matches the other cards.
+
## [1.12.6] — 2026-06-05 — one consistent Insights surface
### Changed
diff --git a/CLAUDE.md b/CLAUDE.md
index 4eded2583..349aca477 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,7 +4,7 @@ Working notes for anyone (human or otherwise) editing this repository. Skim befo
## What HealthLog is
-Self-hosted personal-health-tracking PWA: weight, blood pressure, pulse, body composition, glucose, sleep, mood, medication compliance. Withings + Apple Health sync, multi-provider AI Insights (BYOK or local), doctor-report PDF, native iOS client in public beta. Single `docker compose up`, Postgres-backed, AES-256-GCM at rest. AGPL-3.0. Current line: v1.5.x.
+Self-hosted personal-health-tracking PWA: weight, blood pressure, pulse, body composition, glucose, sleep, mood, medication compliance. Withings + Apple Health sync, multi-provider AI Insights (BYOK or local), doctor-report PDF, native iOS client in public beta. Single `docker compose up`, Postgres-backed, AES-256-GCM at rest. AGPL-3.0. Current line: v1.12.x.
## Voice and privacy
@@ -38,7 +38,7 @@ Prefer patch bumps. A release dominated by bug fixes plus additive features with
| Framework | Next.js 16.2.6 — App Router, `output: "standalone"` | SWC strips `console.*` in prod except `error/warn`. |
| Language | TypeScript 6, `strict: true`, `moduleResolution: "bundler"` | Path alias `@/*` → `./src/*`. `scripts/` excluded from typecheck. |
| UI | React 19.2.5 (exact), Tailwind 4, shadcn/ui (new-york style, zinc base), Radix, Lucide | Recharts 3 for charts — stays; replacement requires explicit approval. |
-| Persistence | PostgreSQL 16 + Prisma 7.8 — 47 models | Generated client at `src/generated/prisma`. Production image ships a separate `/opt/prisma-cli` install + `/opt/pg-boss` runtime install (see `Dockerfile:74-83`). |
+| Persistence | PostgreSQL 16 + Prisma 7.8 — 61 models | Generated client at `src/generated/prisma`. Production image ships a separate `/opt/prisma-cli` install + `/opt/pg-boss` runtime install (see `Dockerfile:74-83`). |
| Queue | pg-boss 12.18 | Cron + retry semantics live in Postgres tables; workers under `src/lib/jobs/`. |
| Auth | `@simplewebauthn/server` 13, `@node-rs/argon2` 2 | Passkey + Argon2id password, server-side sessions in Postgres. |
| Notifications | `@parse/node-apn` 8 (APNs), `web-push` 3 (VAPID), raw fetch for Telegram + ntfy | Per-channel `recordPushAttempt` row, hard-reject classification. |
@@ -124,7 +124,7 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file:
| Notification dispatcher | `src/lib/notifications/dispatcher.ts` + `src/lib/notifications/senders/` | APNs → Telegram → ntfy → Web Push cascade, hard-reject classification, `push_attempts` ledger with 90-day retention. |
| Coach | `src/app/api/insights/chat/route.ts` + `src/lib/ai/coach/` | SSE stream, budget gate + per-user rate gate, refusal detector, snapshot builder, message persistence with `encryptedContent` Bytes column. |
| OpenAPI | `src/lib/openapi/registry.ts` + `src/lib/openapi/routes.ts` | Source of truth for `docs/api/openapi.yaml`. |
-| Schema | `prisma/schema.prisma` (47 models) | `cuid()` PKs, `snake_case` columns via `@map`, encrypted columns mostly under `*Encrypted` (search the file). |
+| Schema | `prisma/schema.prisma` (61 models) | `cuid()` PKs, `snake_case` columns via `@map`, encrypted columns mostly under `*Encrypted` (search the file). |
| Compose | `docker-compose.yml` | `app` + `db`. Env-var whitelist under `environment:` — vars not listed never reach the container. `pull_policy: always` is load-bearing. |
`.planning/codebase/arch.md` carries the full annotated walk for every section.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index eea5b4d01..20eaafeb2 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -90,37 +90,34 @@ Always include the migration file in your PR.
## Branch Model
-HealthLog uses a long-lived `develop` branch for daily work and reserves `main` for releases.
+HealthLog is trunk-based: `main` is the single long-lived branch and is always releasable. There is no `develop` branch.
```
feature/* ──┐
- ├──► develop ──► (release-merge) ──► main ──► tag vX.Y.Z ──► GHCR build ──► Coolify deploy
- fix/* ──┘ │
- └──► hotfix/* ──► main + tag, then merge back to develop
+ fix/* ──┼──► PR ──► main ──► tag vX.Y.Z ──► GHCR build ──► deploy
+ │
+ release/vX.Y.Z ──► PR (merge-commit) ──► main ──► tag vX.Y.Z ──► GHCR build ──► deploy
```
-Two simple rules:
+The rules:
-- **`develop` is the daily target.** Open feature, fix, test, and docs PRs against `develop`. Builds do not run on `develop` pushes — the branch is free of release ceremony.
-- **`main` is release-only.** It receives a single release-merge per version, gets tagged (`v1.4.20`, etc.), and that tag is what triggers the Docker image build, the GHCR push, and the Coolify deploy. Pushes to `main` outside a release are reserved for hotfixes that cannot wait for the next cycle.
-
-If a critical bug needs a hotfix:
-
-1. Branch from `main`: `git checkout -b hotfix/something main`
-2. Land the fix; tag a patch release on `main`
-3. Merge `main` back into `develop` so the next release inherits the fix
+- **`main` always builds, passes CI, and is deployable.** Every commit on it is a release commit (`chore(release): vX.Y.Z`), a release-branch merge, or a maintenance commit (docs, gitignore, audit findings) applied directly.
+- **A release** is built on a short-lived `release/vX.Y.Z` branch cut off `main`, then opened as a PR `release/vX.Y.Z` → `main` with green CI, merged as a merge-commit, and tagged `vX.Y.Z`. The tag push triggers the GHCR multi-arch image build (and `:latest`). Deploy is manual. Delete the release branch after merge.
+- **Small standalone changes** (a single fix, a docs touch) go through a short-lived `fix/X` / `feat/X` branch + PR targeting `main`.
+- **Hotfixes** branch from `main` and merge straight back to `main`. There is no second long-lived line to forward-port to.
+- Never force-push `main` or any release branch.
End users: track the latest `v*` tag (or `:latest` on GHCR). The `main` branch always equals the latest release.
-Contributors: track `develop`. PRs target `develop`.
+Contributors: branch off `main`; PRs target `main`.
## Pull Requests
1. Fork the repository
-2. Create a feature branch off `develop` (`git checkout -b feature/your-feature develop`)
+2. Create a feature branch off `main` (`git checkout -b feat/your-feature main`)
3. Make your changes
4. Ensure all checks pass (`pnpm typecheck && pnpm lint && pnpm test && pnpm format:check && pnpm build`)
-5. Submit a PR against `develop` with a clear description of the changes
+5. Submit a PR against `main` with a clear description of the changes
## Translations
diff --git a/README.md b/README.md
index a3521685f..510606216 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,7 @@
- [Integrations](#integrations)
- [Local Development](#local-development)
- [Deployment](#deployment)
+- [Documentation](#documentation)
- [Roadmap](#roadmap)
- [Native iOS client](#native-ios-client)
- [Contributing](#contributing)
@@ -132,6 +133,10 @@ HealthLog is the right fit when you want your wearable and clinical numbers in o
**AI Coach + Insights** -- Multi-provider, evidence-grounded health insights: per-metric assessments that read each vital against its reference range, correlations across your metrics, a daily briefing, a weekly report, a Health Score tile, and a conversational Coach grounded in your own data. Pick OpenAI, Anthropic Claude, ChatGPT via Codex device-OAuth (no API key needed), or any OpenAI-compatible local endpoint (Ollama, LM Studio, vLLM). BYOK or admin-shared. Feed the Coach a chosen set of data clusters (cardiovascular, body composition, activity, workouts, sleep, mood, glucose, medication, mobility, environment) with a soft budget cap that degrades the lowest-signal clusters first. Every claim links back to the measurements that produced it. The Coach now reads your history longitudinally — a narrative of how a metric has moved and a per-metric trajectory framing — so an answer reflects the direction of travel rather than only the latest reading. Local endpoints keep all data on your network.
+
+
+
+
**Derived wellness metrics** -- A transparent metrics tier computed from your own measurements: a fitness-age band from VO₂max, a vascular-age delta, an HRV-balance band, a sleep score, a daily readiness and a stored recovery score, plus a coincident-deviation early-strain flag. Each one re-frames or blends signals you already recorded against age/sex norms or your personal baseline, cites the inputs and the published method behind it, and returns "not enough data" rather than a fabricated value when its minimum inputs are missing. The recovery score persists as a daily 0–100 series the dashboard, charts, and native client read without recomputing. These are descriptive wellness framings — not clinical or training-grade assessments.
**Doctor Report PDF Export** -- Generate professional medical reports client-side. Locale-aware (English/German), with vital sign summaries, BP/BMI/glucose classification, compliance rates, custom-threshold badges, and optional AI analysis.
@@ -194,7 +199,7 @@ Open **http://localhost:3000**. The first registered user becomes admin.
| ------------- | ------------------------------------------------- |
| Framework | Next.js 16 (App Router, React Server Components) |
| Language | TypeScript (strict mode) |
-| Database | PostgreSQL 16 + Prisma 7 (60 models) |
+| Database | PostgreSQL 16 + Prisma 7 (61 models) |
| Job Queue | pg-boss 12 (reminders, insights, backups) |
| UI | shadcn/ui, Tailwind CSS 4, Radix UI, Lucide Icons |
| Charts | Recharts 3 |
@@ -226,6 +231,10 @@ HealthLog is designed for people who take data ownership seriously.
- **Wide-event structured logging** -- Every API route emits a single envelope with `action`, latency, request id, sampled payload (with secret redaction), and an optional Loki transport for self-hosted log aggregation.
- **Audit logging** -- All sensitive operations tracked with IP addresses, dedup-windowed to keep the ledger compact under bursty writes.
+
+
+
+
---
## Environment Variables
@@ -259,6 +268,12 @@ Telegram bot token, ntfy settings, Web Push VAPID keys, Umami, and GlitchTip URL
## Architecture
+Data flows from every source — Withings, an Apple Health `export.zip`, the iPhone's live HealthKit, manual entry, moodLog — through the ingest endpoints into a single `Measurement` table and its pre-aggregated rollups, then back out to the dashboard, insights, Coach, and the doctor-report PDF.
+
+
+
+
+
```
src/
├── app/ # Next.js App Router pages & API routes
@@ -524,6 +539,10 @@ Clinician share-link lifecycle — create a scoped, time-limited link, hand a cl
When two providers report the same metric — a Withings scale and an Apple Watch both logging weight, or HealthKit and Fitbit both reporting steps — HealthLog keeps every row as an audit trail but applies a per-metric source priority to decide which reading is canonical. Cumulative metrics (steps, distance, active energy, floors) pick exactly one source per day so they are never double-counted; point measurements (weight, blood pressure, HRV) pick a display-preferred source. The default ladder reflects measurement reliability for each metric class: a dedicated scale outranks a wrist estimate for weight, a recovery strap leads the sleep and HRV ladders, and a cuff leads blood pressure. The ladder is per-user adjustable, and an optional device-type tie-break decides between two devices on the same source. The underlying idea is [data fusion](https://en.wikipedia.org/wiki/Data_fusion) — combining several imperfect sensors into a single, more reliable estimate rather than averaging them blindly.
+
+
+
+
---
## Local Development
@@ -570,6 +589,23 @@ For a single-process default the same container hosts both the web and worker (`
---
+## Documentation
+
+The full documentation set lives under [`docs/`](docs/) (also published at [docs.healthlog.dev](https://docs.healthlog.dev)). A technical visitor browsing the source will also find a navigational `README.md` in each key code directory (`src/`, `src/lib/`, `src/app/`, `src/components/`, `prisma/`, `scripts/`, `e2e/`).
+
+| Area | Where | What it covers |
+| ---- | ----- | -------------- |
+| Self-hosting | [`docs/self-hosting/`](docs/self-hosting/) | Getting started, reverse-proxy setup, horizontal scaling |
+| Operator runbooks | [`docs/ops/`](docs/ops/) | Deploy, encryption-key rotation, backup/restore, env check, migrations, TLS pinning |
+| API contract | [`docs/api/`](docs/api/) | OpenAPI 3.1 spec (`openapi.yaml`) for the native client subset |
+| Integrations | [`docs/integrations/`](docs/integrations/) | Withings, WHOOP, Google Health/Fitbit, Apple Health, AI providers |
+| Architecture diagrams | [`docs/diagrams/`](docs/diagrams/) | The five SVG diagrams embedded above |
+| Migration notes | [`docs/migration/`](docs/migration/) | Cross-version upgrade notes |
+| Design records | [`docs/adr/`](docs/adr/), [`docs/ui-guidelines.md`](docs/ui-guidelines.md) | Architecture decision records + UI conventions |
+| Contributor manual | [`CLAUDE.md`](CLAUDE.md), [`CONTRIBUTING.md`](CONTRIBUTING.md) | Conventions, branch/release model, the critical-files map |
+
+---
+
## Roadmap
| Release line | Focus |
@@ -593,13 +629,6 @@ The detailed changelog lives in [`CHANGELOG.md`](CHANGELOG.md).
Join the TestFlight public beta →
-
-
-
-
-
-
-
A SwiftUI iOS companion that lives on the same `/api` surfaces as the web client — same server, same data, same source of truth. The phone is a view + a write-ahead log; your server is the database. Code lives in a separate repo: [github.com/MBombeck/healthlog-iOS](https://github.com/MBombeck/healthlog-iOS).
### What the phone unlocks
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 95ec1e8af..0bca23ef1 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.12.8
+ version: 1.12.9
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
diff --git a/e2e/README.md b/e2e/README.md
new file mode 100644
index 000000000..fa2e35d96
--- /dev/null
+++ b/e2e/README.md
@@ -0,0 +1,21 @@
+# `e2e/`
+
+End-to-end browser tests. Playwright 1.60 drives a production build of the app against a real PostgreSQL, with `@axe-core/playwright` for accessibility assertions. This is the heaviest test tier; CI runs it on every PR, and locally it is worth running when a change touches a UI flow.
+
+## Running
+
+```bash
+pnpm e2e
+```
+
+The Playwright config (`../playwright.config.ts`) builds the app (`next build`) and brings up Postgres before the specs run; `setup/global-setup.ts` seeds the auth + fixture state. The suite is not part of the fast local gate (`typecheck` / `lint` / `test`) — it surfaces at PR CI, so expect responsive/wizard/nav reworks to flag here first.
+
+## Layout
+
+- **`setup/`** — `global-setup.ts` (auth + DB seeding), `test-helpers.ts` (shared login + navigation helpers).
+- **`utils/`** — fixtures such as `mock-dashboard-snapshot.ts`.
+- **`*.spec.ts`** — one spec per flow: auth/login/redirect, dashboard, charts, the medication-wizard cadences (`medications-wizard-*`), insights generation + scroll restoration, doctor report, settings, mobile-viewport consistency, locale switch, accessibility (`a11y.spec.ts`), and the `v1427-*` regression set.
+
+## Convention
+
+Assert against stable `data-*` attributes (`data-slot`, `data-display-step`, root display attrs), not viewport-dependent visible text. Responsive `sm:hidden` UI changes break `getByText` on desktop while the underlying element is still present — anchoring on data attributes keeps the specs stable across breakpoints. See [`../CLAUDE.md`](../CLAUDE.md).
diff --git a/messages/de.json b/messages/de.json
index ef739e2c7..d87fc7340 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -577,7 +577,8 @@
"factorSleepQuality": "Schlafqualität",
"factorStress": "Stress",
"factorConflict": "Konflikt",
- "factorFamily": "Familie"
+ "factorFamily": "Familie",
+ "factorSadness": "Traurigkeit"
}
},
"medications": {
@@ -701,11 +702,13 @@
"glp1DosesPerUnit": "Dosen pro Pen / Ampulle",
"glp1DosesPerUnitPlaceholder": "z. B. 4 für einen Mounjaro KwikPen",
"glp1DosesPerUnitHelp": "Optional — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy Einmal-Pen = 1. Leer lassen, wenn keine Dosis-pro-Pen-Verfolgung gewünscht ist.",
- "glp1LastInjection": "Letzter Termin: {label}",
- "glp1LastInjectionWithSite": "Letzter Termin: {label} · {site}",
- "glp1NextInjectionDays": "Nächster Termin: {label} (in {days} Tagen)",
- "glp1NextInjectionToday": "Nächster Termin: heute",
- "glp1NextInjectionTomorrow": "Nächster Termin: morgen",
+ "glp1NextLabel": "Nächster Termin:",
+ "glp1LastLabel": "Letzter Termin:",
+ "glp1LastInjection": "{label}",
+ "glp1LastInjectionWithSite": "{label} · {site}",
+ "glp1NextInjectionDays": "{label} (in {days} Tagen)",
+ "glp1NextInjectionToday": "heute",
+ "glp1NextInjectionTomorrow": "morgen",
"glp1RotationLast": "Letzte Stelle: {site}",
"glp1RotationSuggested": "Empfohlen: {site}",
"glp1LogSideEffect": "Nebenwirkung loggen",
diff --git a/messages/en.json b/messages/en.json
index 0113f3ab5..34cd6f238 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -577,7 +577,8 @@
"factorSleepQuality": "sleep quality",
"factorStress": "stress",
"factorConflict": "conflict",
- "factorFamily": "family"
+ "factorFamily": "family",
+ "factorSadness": "sadness"
}
},
"medications": {
@@ -701,11 +702,13 @@
"glp1DosesPerUnit": "Doses per pen / vial",
"glp1DosesPerUnitPlaceholder": "e.g. 4 for a Mounjaro KwikPen",
"glp1DosesPerUnitHelp": "Optional — Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose pen = 1. Leave blank if you don't track per-pen dosing.",
- "glp1LastInjection": "Last: {label}",
- "glp1LastInjectionWithSite": "Last: {label} · {site}",
- "glp1NextInjectionDays": "Next: {label} (in {days} days)",
- "glp1NextInjectionToday": "Next: today",
- "glp1NextInjectionTomorrow": "Next: tomorrow",
+ "glp1NextLabel": "Next:",
+ "glp1LastLabel": "Last:",
+ "glp1LastInjection": "{label}",
+ "glp1LastInjectionWithSite": "{label} · {site}",
+ "glp1NextInjectionDays": "{label} (in {days} days)",
+ "glp1NextInjectionToday": "today",
+ "glp1NextInjectionTomorrow": "tomorrow",
"glp1RotationLast": "Last site: {site}",
"glp1RotationSuggested": "Recommended next: {site}",
"glp1LogSideEffect": "Log side effect",
diff --git a/messages/es.json b/messages/es.json
index 85772d59c..f77e0498f 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -577,7 +577,8 @@
"factorSleepQuality": "calidad del sueño",
"factorStress": "estrés",
"factorConflict": "conflicto",
- "factorFamily": "familia"
+ "factorFamily": "familia",
+ "factorSadness": "tristeza"
}
},
"medications": {
@@ -701,11 +702,13 @@
"glp1DosesPerUnit": "Dosis por pluma / vial",
"glp1DosesPerUnitPlaceholder": "p. ej. 4 para una pluma Mounjaro KwikPen",
"glp1DosesPerUnitHelp": "Opcional — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy pluma de un solo uso = 1. Déjalo vacío si no quieres llevar el conteo de dosis por pluma.",
- "glp1LastInjection": "Última cita: {label}",
- "glp1LastInjectionWithSite": "Última cita: {label} · {site}",
- "glp1NextInjectionDays": "Siguiente: {label} (en {days} días)",
- "glp1NextInjectionToday": "Próxima cita: hoy",
- "glp1NextInjectionTomorrow": "Próxima cita: mañana",
+ "glp1NextLabel": "Próxima cita:",
+ "glp1LastLabel": "Última cita:",
+ "glp1LastInjection": "{label}",
+ "glp1LastInjectionWithSite": "{label} · {site}",
+ "glp1NextInjectionDays": "{label} (en {days} días)",
+ "glp1NextInjectionToday": "hoy",
+ "glp1NextInjectionTomorrow": "mañana",
"glp1RotationLast": "Último sitio: {site}",
"glp1RotationSuggested": "Sugerido: {site}",
"glp1LogSideEffect": "Registrar efecto secundario",
diff --git a/messages/fr.json b/messages/fr.json
index 76a80ca9e..5cbdafd73 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -577,7 +577,8 @@
"factorSleepQuality": "qualité du sommeil",
"factorStress": "stress",
"factorConflict": "conflit",
- "factorFamily": "famille"
+ "factorFamily": "famille",
+ "factorSadness": "tristesse"
}
},
"medications": {
@@ -701,11 +702,13 @@
"glp1DosesPerUnit": "Doses par stylo / flacon",
"glp1DosesPerUnitPlaceholder": "p. ex. 4 pour un stylo Mounjaro KwikPen",
"glp1DosesPerUnitHelp": "Optionnel — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy stylo à usage unique = 1. Laisse vide si tu ne veux pas suivre la dose par stylo.",
- "glp1LastInjection": "Dernier rendez-vous : {label}",
- "glp1LastInjectionWithSite": "Dernier rendez-vous : {label} · {site}",
- "glp1NextInjectionDays": "Suivant : {label} (dans {days} jours)",
- "glp1NextInjectionToday": "Prochain rendez-vous : aujourd’hui",
- "glp1NextInjectionTomorrow": "Prochain rendez-vous : demain",
+ "glp1NextLabel": "Prochain rendez-vous :",
+ "glp1LastLabel": "Dernier rendez-vous :",
+ "glp1LastInjection": "{label}",
+ "glp1LastInjectionWithSite": "{label} · {site}",
+ "glp1NextInjectionDays": "{label} (dans {days} jours)",
+ "glp1NextInjectionToday": "aujourd’hui",
+ "glp1NextInjectionTomorrow": "demain",
"glp1RotationLast": "Dernier site : {site}",
"glp1RotationSuggested": "Conseillé : {site}",
"glp1LogSideEffect": "Consigner un effet secondaire",
diff --git a/messages/it.json b/messages/it.json
index 75d2f312a..71b82ecfa 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -577,7 +577,8 @@
"factorSleepQuality": "qualità del sonno",
"factorStress": "stress",
"factorConflict": "conflitto",
- "factorFamily": "famiglia"
+ "factorFamily": "famiglia",
+ "factorSadness": "tristezza"
}
},
"medications": {
@@ -701,11 +702,13 @@
"glp1DosesPerUnit": "Dosi per penna / fiala",
"glp1DosesPerUnitPlaceholder": "es. 4 per una penna Mounjaro KwikPen",
"glp1DosesPerUnitHelp": "Opzionale — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy penna monouso = 1. Lascia vuoto se non vuoi monitorare le dosi per penna.",
- "glp1LastInjection": "Ultimo appuntamento: {label}",
- "glp1LastInjectionWithSite": "Ultimo appuntamento: {label} · {site}",
- "glp1NextInjectionDays": "Prossimo: {label} (tra {days} giorni)",
- "glp1NextInjectionToday": "Prossimo appuntamento: oggi",
- "glp1NextInjectionTomorrow": "Prossimo appuntamento: domani",
+ "glp1NextLabel": "Prossimo appuntamento:",
+ "glp1LastLabel": "Ultimo appuntamento:",
+ "glp1LastInjection": "{label}",
+ "glp1LastInjectionWithSite": "{label} · {site}",
+ "glp1NextInjectionDays": "{label} (tra {days} giorni)",
+ "glp1NextInjectionToday": "oggi",
+ "glp1NextInjectionTomorrow": "domani",
"glp1RotationLast": "Ultima sede: {site}",
"glp1RotationSuggested": "Suggerita: {site}",
"glp1LogSideEffect": "Registra effetto collaterale",
diff --git a/messages/pl.json b/messages/pl.json
index 4ce4a7ddc..368af74df 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -577,7 +577,8 @@
"factorSleepQuality": "jakość snu",
"factorStress": "stres",
"factorConflict": "konflikt",
- "factorFamily": "rodzina"
+ "factorFamily": "rodzina",
+ "factorSadness": "smutek"
}
},
"medications": {
@@ -701,11 +702,13 @@
"glp1DosesPerUnit": "Dawki na pen / fiolkę",
"glp1DosesPerUnitPlaceholder": "np. 4 dla pena Mounjaro KwikPen",
"glp1DosesPerUnitHelp": "Opcjonalne — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy pen jednorazowy = 1. Zostaw puste, jeśli nie chcesz śledzić dawek na pen.",
- "glp1LastInjection": "Ostatni termin: {label}",
- "glp1LastInjectionWithSite": "Ostatni termin: {label} · {site}",
- "glp1NextInjectionDays": "Następnie: {label} (za {days} dni)",
- "glp1NextInjectionToday": "Następny termin: dziś",
- "glp1NextInjectionTomorrow": "Następny termin: jutro",
+ "glp1NextLabel": "Następny termin:",
+ "glp1LastLabel": "Ostatni termin:",
+ "glp1LastInjection": "{label}",
+ "glp1LastInjectionWithSite": "{label} · {site}",
+ "glp1NextInjectionDays": "{label} (za {days} dni)",
+ "glp1NextInjectionToday": "dziś",
+ "glp1NextInjectionTomorrow": "jutro",
"glp1RotationLast": "Ostatnie miejsce: {site}",
"glp1RotationSuggested": "Sugerowane: {site}",
"glp1LogSideEffect": "Zapisz działanie niepożądane",
diff --git a/package.json b/package.json
index 3ff67435d..6e7386d5b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.12.8",
+ "version": "1.12.9",
"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",
diff --git a/prisma/README.md b/prisma/README.md
new file mode 100644
index 000000000..584bebcd6
--- /dev/null
+++ b/prisma/README.md
@@ -0,0 +1,28 @@
+# `prisma/`
+
+The database layer. HealthLog runs PostgreSQL 16 via Prisma 7. This directory holds the single schema file and the forward-only migration history; the generated client is emitted elsewhere.
+
+## Contents
+
+- **`schema.prisma`** — 61 models, one file. `cuid()` primary keys, `snake_case` columns via `@map(...)`, encrypted columns mostly suffixed `*Encrypted` (search the file; there are 24 encrypted column references). The `generator client` block emits to `../src/generated/prisma` (imported as `@/generated/prisma`).
+- **`migrations/`** — numbered, forward-only SQL migrations (`0001_…` upward). `migration_lock.toml` pins the provider to `postgresql`.
+
+## Migration workflow
+
+```bash
+# author a migration from a schema edit
+pnpm dlx prisma migrate dev --name
+
+# regenerate the client after any schema change
+pnpm dlx prisma generate
+```
+
+Number a new migration with the next free prefix. The production entrypoint runs `prisma migrate deploy` on boot, so every migration must apply cleanly forward — there is no down-migration path. Always include the migration file in the same change as the schema edit.
+
+## Conventions
+
+- Encryption is fail-closed: `*Encrypted` columns are written through `@/lib/crypto` (AES-256-GCM, versioned key ids); never store plaintext in an encrypted column. Key rotation is `scripts/rotate-encryption-key.ts` + `docs/ops/encryption-key-rotation.md`.
+- The generated client under `src/generated/prisma` is build output — do not edit it.
+- Schema-touching changes that affect the API surface must regenerate `docs/api/openapi.yaml` (see [`../CLAUDE.md`](../CLAUDE.md)).
+
+Related: [`../src/lib/README.md`](../src/lib/README.md) (`db.ts`, `rollups/`), [`../docs/ops/`](../docs/ops/) (migrations + backup runbooks).
diff --git a/prisma/migrations/0126_v1129_mood_catalog_revision/migration.sql b/prisma/migrations/0126_v1129_mood_catalog_revision/migration.sql
new file mode 100644
index 000000000..7e3c7a277
--- /dev/null
+++ b/prisma/migrations/0126_v1129_mood_catalog_revision/migration.sql
@@ -0,0 +1,72 @@
+-- v1.12.9 — mood-catalog revision.
+--
+-- Tightens the operator-finalised mood factor + tag catalog seeded by
+-- 0118 / 0119 / 0120. Three changes, all reference-data only — no schema
+-- change, no per-user data touched, no destructive delete:
+--
+-- 1. Seed a new RATED factor `factor_sadness` (scale 1..5, inverse —
+-- higher = a worse day), under the existing `factors` category.
+-- 2. Retire the four RATED factors the operator dropped
+-- (`factor_conflict`, `factor_family`, `factor_social`,
+-- `factor_stress`): flip each to `kind = 'BINARY'` AND set
+-- `is_active = false`. They leave the RATED set and the default
+-- picker, but the catalog rows stay so any historical
+-- `mood_entry_tag_links` row still resolves its label (the insights
+-- read joins the catalog row by id and does NOT filter `is_active`).
+-- The result: exactly THREE RATED factors remain
+-- (`factor_work`, `factor_sleep_quality`, `factor_sadness`).
+-- 3. Trim the default BINARY tag set roughly in half: mark the
+-- long-tail / slider-redundant tags `is_active = false`. The picker
+-- (`GET /api/mood/tags` filters `is_active = true`) stops offering
+-- them; existing entries that reference a now-inactive tag still
+-- resolve its label because the read joins by id without the active
+-- filter. Nothing is deleted.
+--
+-- Idempotent: the seed uses a deterministic id + ON CONFLICT upsert by
+-- `key` (mirrors 0118 / 0119 / 0120); the retire / trim updates key off a
+-- closed `key` list, so a re-run is a no-op.
+
+-- ─── 1. New RATED factor: sadness (inverse, 1..5) ────────────────────
+INSERT INTO "mood_tags"
+ ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active", "kind", "scale_min", "scale_max", "inverse")
+VALUES ('mt_factor_sadness', 'mtc_factors', 'factor_sadness', 'mood.tag.factorSadness', 'CloudRain', 2, true, 'RATED', 1, 5, true)
+ON CONFLICT ("key") DO UPDATE SET
+ "category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon",
+ "sort_order" = EXCLUDED."sort_order", "is_active" = EXCLUDED."is_active", "kind" = EXCLUDED."kind",
+ "scale_min" = EXCLUDED."scale_min", "scale_max" = EXCLUDED."scale_max", "inverse" = EXCLUDED."inverse";
+
+-- Re-seat the two surviving RATED factors at the top of the `factors`
+-- category so the final order reads work → sleep quality → sadness.
+UPDATE "mood_tags" SET "sort_order" = 0 WHERE "key" = 'factor_work';
+UPDATE "mood_tags" SET "sort_order" = 1 WHERE "key" = 'factor_sleep_quality';
+
+-- ─── 2. Retire the four dropped RATED factors (BINARY + inactive) ─────
+-- Flip kind so they leave the RATED set; deactivate so they leave the
+-- default picker. Historical RATED links keep their `rating` value and
+-- still resolve their label (read joins the catalog row by id, no active
+-- filter). Not deleted — references stay valid.
+UPDATE "mood_tags"
+SET "kind" = 'BINARY', "is_active" = false
+WHERE "key" IN ('factor_conflict', 'factor_family', 'factor_social', 'factor_stress');
+
+-- ─── 3. Trim the default BINARY tag set (~half) ──────────────────────
+-- Deactivate the long-tail / slider-redundant binary tags. Kept set is
+-- the high-signal, plausibly-correlatable tags; dropped set is sleep
+-- tags now covered by the Schlafqualität slider, mood-level restatements
+-- (the 1..5 face already carries them), and low-signal duplicates. Rows
+-- stay so historical entries still resolve their labels.
+UPDATE "mood_tags"
+SET "is_active" = false
+WHERE "key" IN (
+ -- feelings: drop tags that just restate the 5-face mood level / overlap
+ -- the surviving factor_sadness; keep a tight high-signal subset.
+ 'content', 'unsure', 'bored', 'tense', 'stressed', 'sad',
+ -- sleep: the Schlafqualität (factor_sleep_quality) slider supersedes
+ -- the whole sleep-tag category.
+ 'slept_well', 'slept_ok', 'slept_poorly', 'early_night',
+ -- health: drop the nutrition long-tail (kept: ate_well, alcohol,
+ -- worked_out, walked, hydrated).
+ 'fast_food', 'no_sweets', 'big_meal',
+ -- work: drop low-signal day-type tags (kept: productive, day_off).
+ 'overtime', 'travel', 'sick_day'
+);
diff --git a/scripts/README.md b/scripts/README.md
new file mode 100644
index 000000000..5fcadab54
--- /dev/null
+++ b/scripts/README.md
@@ -0,0 +1,31 @@
+# `scripts/`
+
+One-shot operator and build scripts. These are not part of the running application — they are run by hand during a deploy, a backfill, or a release, or wired into CI. Recurring work belongs on pg-boss (`src/lib/jobs/`), not here.
+
+## How to run them
+
+```bash
+pnpm dlx tsx scripts/.ts
+```
+
+Use `pnpm dlx tsx`, not bare `pnpm tsx`. The production standalone image strips `tsx`, so the bare invocation fails inside the container. `scripts/` is excluded from the project typecheck.
+
+## What lives here
+
+- **`generate-openapi.ts`** — emits `docs/api/openapi.yaml` from the registered Zod schemas (`pnpm openapi:generate`).
+- **`check-openapi.ts`** — compares a freshly generated spec against the committed YAML; CI fails on drift (`pnpm openapi:check`).
+- **`check-env.ts`** — pre-deploy env-var sanity check against `env-manifest.json` (`pnpm check-env`); wired into CI.
+- **`assert-deploy.ts`** — post-deploy `/api/version` assertion.
+- **`rotate-encryption-key.ts`** — AES-256-GCM key rotation; see `docs/ops/encryption-key-rotation.md`.
+- **`restore-backup.ts`** — restore an off-host AES-GCM backup from an S3-compatible bucket.
+- **`backfill-rollups.ts`**, **`drain-per-sample-cumulative.ts`**, **`backfill-mood-note-column.ts`** — historical data backfills (note: the boot-time `rollup-full-backfill` queue handles new accounts automatically; the CLI is the manual fallback).
+- **`seed-demo.ts`** + **`seed-demo-v15.sql`** — populate a demo tenant.
+- **`test-notifications.ts`** — exercise the notification dispatcher + reminder check.
+- **`fetch-geolite2.sh`** — download the MaxMind GeoLite2 MMDBs (needs a licence key) before `docker build`.
+- **`install-hooks.sh`** — install the local git hooks.
+- **`generate-sw-version.mjs`**, **`print-bundle-report.mjs`** — service-worker version stamp and bundle-size report.
+- **`i18n/`** — locale tooling; **`__tests__/`** — tests for the scripts above.
+
+## Conventions
+
+See [`../CLAUDE.md`](../CLAUDE.md) "Tests and commands" + "Self-hosting gotchas". Operator runbooks that drive these scripts live in [`../docs/ops/`](../docs/ops/).
diff --git a/src/README.md b/src/README.md
new file mode 100644
index 000000000..1f460f2e5
--- /dev/null
+++ b/src/README.md
@@ -0,0 +1,26 @@
+# `src/`
+
+Application source. HealthLog is a Next.js 16 App Router project (`output: "standalone"`, React 19, TypeScript strict). The path alias `@/*` resolves to this directory. Everything a request touches lives under here; the layout below follows the request flow rather than alphabetical order.
+
+## Request flow
+
+```
+client → src/proxy.ts → src/app//route.ts → src/lib/api-handler.ts → handler body → src/lib/api-response.ts → client
+```
+
+- **`proxy.ts`** — the HTTP edge (Next.js 16's renamed `middleware`). Public-path allowlist, demo-mode mutation block, server-side onboarding redirect, worker-only refusal, `x-request-id` + CSP nonce generation, every security header.
+- **`app/`** — App Router pages (RSC by default) and API routes. Every API route wraps its handler in `apiHandler`. See [`app/README.md`](app/README.md).
+- **`lib/`** — the server/runtime toolkit: `apiHandler`, auth, crypto, rollups, AI, notifications, pg-boss jobs, integrations, rate limiting. See [`lib/README.md`](lib/README.md).
+- **`components/`** — React components (shadcn/ui new-york, kebab-case files). See [`components/README.md`](components/README.md).
+
+## Other directories
+
+- **`hooks/`** — shared React hooks (`use-` prefix, kebab-case).
+- **`types/`** — shared TypeScript declarations.
+- **`generated/prisma/`** — the generated Prisma client (do not edit; regenerated by `prisma generate`). Imported as `@/generated/prisma`.
+- **`instrumentation.ts`** — Next.js startup hook; boots the pg-boss worker registry and boot-time backfill queues.
+- **`__tests__/`** — cross-cutting unit tests; most tests live in `__tests__/` folders next to the code they cover.
+
+## Conventions
+
+See [`../CLAUDE.md`](../CLAUDE.md) for the full set. The load-bearing ones: RSC by default (`"use client"` only when a hook/state/browser API needs it); every API route wraps `apiHandler` and parses its body with Zod `safeParse`; `userId` is always narrowed from session or Bearer, never accepted as a body field; TanStack Query keys come only from `src/lib/query-keys.ts`; outbound HTTP goes through `src/lib/safe-fetch.ts`.
diff --git a/src/app/README.md b/src/app/README.md
new file mode 100644
index 000000000..889bc66c4
--- /dev/null
+++ b/src/app/README.md
@@ -0,0 +1,25 @@
+# `src/app/`
+
+The Next.js App Router tree: the page surfaces a logged-in user sees and the REST API under `api/`. Server components are the default; `"use client"` is added only where a hook, state, or browser API needs it. Routing is file-based — a `page.tsx` is a page, a `route.ts` is an API endpoint.
+
+## API routes (`api/`)
+
+Every endpoint wraps its handler in `apiHandler` (`@/lib/api-handler`), parses any body with Zod `safeParse`, narrows `userId` from the session or Bearer token (never from the body), and returns the `{ data, error, meta? }` envelope. `requireAdmin()` is cookie-only, so `api/admin/` is unreachable by Bearer token. Notable groups:
+
+- **`auth/`** — login, register, password, passkey (WebAuthn), refresh, Codex device-OAuth.
+- **`measurements/`, `mood-entries/`, `medications/`, `sleep/`** — the core health-data CRUD + batch/bulk ingest.
+- **`ingest/`** — external Bearer-token ingest surfaces.
+- **`insights/`, `analytics/`, `dashboard/`** — AI insights, the Coach SSE stream, trend summaries, the first-paint dashboard snapshot.
+- **`withings/`, `whoop/`, `fitbit/`, `integrations/`, `sync/`** — OAuth connect/sync/webhook surfaces and the offline delta feed.
+- **`fhir/`, `share-links/`, `doctor-report/`, `export/`, `import/`** — clinician-facing FHIR R4 read API, shareable records, report + data export.
+- **`notifications/`, `devices/`, `send/`, `telegram/`** — channel registration, device tokens, dispatch, webhook intake.
+- **`admin/`** — operator panel APIs (cookie-only).
+- **`health/`, `version/`, `meta/`, `internal/`** — health check, build-version probe, runtime metadata, worker-internal routes.
+
+## Page surfaces
+
+`measurements/`, `medications/`, `mood/`, `insights/`, `achievements/`, `notifications/`, `settings/`, `onboarding/`, `admin/`, plus the public `about/`, `privacy/`, `bugreport/`, the share-record route `c/`, and the root dashboard (`page.tsx`). Shared shell + chrome lives in `layout.tsx`; `error.tsx` / `global-error.tsx` / `not-found.tsx` / `loading.tsx` cover the framework boundaries. `layout.tsx` holds the one nonce-bound `dangerouslySetInnerHTML` (theme bootstrap).
+
+## Conventions
+
+See [`../../CLAUDE.md`](../../CLAUDE.md). The request-flow walk in `.planning/codebase/arch.md` annotates each layer with file:line citations. Related dirs: handlers compose [`../lib/`](../lib/README.md); pages render [`../components/`](../components/README.md).
diff --git a/src/components/README.md b/src/components/README.md
new file mode 100644
index 000000000..6adf81477
--- /dev/null
+++ b/src/components/README.md
@@ -0,0 +1,24 @@
+# `src/components/`
+
+React components. The UI primitives are shadcn/ui (new-york style, zinc base) over Radix + Tailwind 4 + Lucide; everything above the primitives is feature-grouped by surface. Components are server components unless they need a hook, state, or browser API, in which case they open with `"use client"`. Charts use Recharts 3 — replacing the chart library requires explicit approval.
+
+## Primitives and shell
+
+- **`ui/`** — shadcn/ui primitives (button, dialog, sheet, table, …). Generated by the shadcn CLI; keep edits minimal and on-style.
+- **`layout/`** — the app shell: sidebar, topbar, bottom nav.
+- **`providers.tsx`, `app-settings-provider.tsx`, `version-poller.tsx`** — top-level context providers and the self-healing stale-shell poller.
+- **`i18n/`** — translation context + locale switcher.
+
+## Feature groups
+
+- **`insights/`** — insight status, assessment cards, Coach surface, advisor cards (the largest group).
+- **`medications/`** — medication cards, the schedule wizard, intake timeline, detail history.
+- **`settings/`** — the section components behind each settings page.
+- **`measurements/`, `mood/`, `dashboard/`** — entry forms, lists, dashboard tiles.
+- **`charts/`** — Recharts wrappers.
+- **`monitoring/`** — Umami / GlitchTip client bootstrap.
+- **`gamification/`, `targets/`, `comparison/`, `doctor-report/`, `clinician/`, `onboarding/`, `admin/`** — the remaining per-surface groups.
+
+## Conventions
+
+Kebab-case filenames; React component exports stay PascalCase regardless. (A handful of PascalCase-filename outliers under `medications/` + `onboarding/` are pre-existing drift, noted in [`../../CLAUDE.md`](../../CLAUDE.md).) Every `t()` call needs a matching key in `messages/en.json` — guarded by `i18n-call-site-coverage.test.ts`. Data reads go through TanStack Query with keys from `@/lib/query-keys` and unwrap `(await res.json()).data`. Tests live in [`__tests__/`](__tests__/).
diff --git a/src/components/medications/__tests__/medication-card-glp1.test.tsx b/src/components/medications/__tests__/medication-card-glp1.test.tsx
index 260b1d1d3..a7130b2f0 100644
--- a/src/components/medications/__tests__/medication-card-glp1.test.tsx
+++ b/src/components/medications/__tests__/medication-card-glp1.test.tsx
@@ -320,13 +320,13 @@ describe(" — GLP-1 variant rendering", () => {
client,
);
- // The with-site copy reads "Last: