Merge pull request #266 from MBombeck/release/v1.12.9

release/v1.12.9 — tighter mood catalogue, clearer cards, fixed retrospective
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-05 15:47:08 +02:00
commit 7affbd0a11
28 files changed
+502 -134

No files matched your search

+35
View File
@@ -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
+3 -3
View File
@@ -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.
+13 -16
View File
@@ -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
+37 -8
View File
@@ -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.
<p align="center">
<img src="docs/diagrams/02-coach-pipeline.svg" alt="Coach pipeline: a user question through the snapshot builder and prompt assembler, into the provider chain (ChatGPT OAuth, OpenAI, Anthropic, local, admin-shared), through the response parser, out to a cited reply with mini-charts" width="900" />
</p>
**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.
<p align="center">
<img src="docs/diagrams/05-security-model.svg" alt="Security model: concentric layers — auth perimeter (passkey plus password fallback), session perimeter (HttpOnly cookie plus Postgres sessions), encrypted core (AES-256-GCM versioned keys), with side rails for the rate limiter, audit log, HMAC tokens, CSP/HSTS, and SSRF guard" width="900" />
</p>
---
## 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.
<p align="center">
<img src="docs/diagrams/01-data-flow.svg" alt="Data flow: sources (Withings, Apple Health export, iPhone HealthKit, manual, moodLog) into ingest endpoints, into Measurement plus MeasurementRollup, out to dashboard, insights, Coach, and doctor PDF" width="900" />
</p>
```
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.
<p align="center">
<img src="docs/diagrams/04-source-priority.svg" alt="Source priority: three sources for the same metric on the same day, the resolver picking one canonical row per cumulative-versus-point ladder while the losing rows stay in the audit trail" width="900" />
</p>
---
## 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).
<a href="https://testflight.apple.com/join/bucuTBpa"><strong>Join the TestFlight public beta &rarr;</strong></a>
</p>
<p align="center">
<img src="docs/ios/ios-dashboard.png" alt="Dashboard with Health Score ring, medication compliance, vital-sign tiles" width="220" />
<img src="docs/ios/ios-insights.png" alt="Insights tab with Frag-den-Coach CTA, BMI and blood-pressure reference ranges" width="220" />
<img src="docs/ios/ios-medication-detail.png" alt="Medication detail with 30-day compliance ring and 14-day intake-status glyph track" width="220" />
<img src="docs/ios/ios-mood-entry.png" alt="Mood entry sheet with five mood faces" width="220" />
</p>
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
+1 -1
View File
@@ -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.
+21
View File
@@ -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).
+9 -6
View File
@@ -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",
+9 -6
View File
@@ -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",
+9 -6
View File
@@ -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",
+9 -6
View File
@@ -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",
+9 -6
View File
@@ -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",
+9 -6
View File
@@ -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",
+1 -1
View File
@@ -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",
+28
View File
@@ -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 <short_snake_case_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).
@@ -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'
);
+31
View File
@@ -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/<file>.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/).
+26
View File
@@ -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>/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`.
+25
View File
@@ -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).
+24
View File
@@ -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__/).
@@ -320,13 +320,13 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
client,
);
// The with-site copy reads "Last: <label> · <site>"; both halves
// must show up in the SSR string.
// The last-line renders the "Last:" label in its own left column and
// the value (label · site) flush right; both halves show in the SSR.
expect(html).toContain("Last:");
expect(html).toContain("Abdomen, lower left");
// The next-injection helper produces one of three strings — today,
// tomorrow, or "in N days". All three contain the localised "Next:"
// prefix from `glp1NextInjection*` keys.
// The next-line carries the "Next:" label in its left column,
// independent of which of the three value variants (today / tomorrow /
// "in N days") the helper produced.
expect(html).toMatch(/Next:/);
});
@@ -448,11 +448,19 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
expect(html).not.toContain("text-foreground/85");
// No literal em-dash placeholder for an absent next-injection.
expect(html).not.toContain(">—<");
// Both lines present (next + last), so two muted <p> in the slot.
// Both lines present (next + last). Each line is a two-column
// label-left / value-right row (`flex … justify-between`) so the
// label and the time read as distinct columns rather than crammed
// onto one run of text. Two such rows in the slot.
const slotStart = html.indexOf("min-h-[2.75rem]");
const slot = html.slice(slotStart, slotStart + 800);
const lines = slot.match(/<p class="text-muted-foreground">/g) ?? [];
expect(lines.length).toBe(2);
const slot = html.slice(slotStart, slotStart + 1200);
const rows =
slot.match(
/<div class="text-muted-foreground flex items-baseline justify-between gap-3">/g,
) ?? [];
expect(rows.length).toBe(2);
// The value is pinned flush-right in its own column.
expect(slot).toContain('<span class="text-right">');
}
});
@@ -21,10 +21,17 @@ import { useTranslations } from "@/lib/i18n/context";
* whose last-dose line is absent the same vertical footprint as a sibling
* that renders both lines, so the dose rows line up across the grid.
*
* Each card still builds the line *content* itself (the generic card emits a
* day-label + window-range; the GLP-1 card emits a weekly-cadence prose
* string), because the two cadences read differently — but the wrapper,
* order, colour, and gating are now guaranteed identical.
* Each card still builds the value *content* itself (the generic card emits a
* day-label + window-range; the GLP-1 card emits a weekly-cadence string),
* because the two cadences read differently — but the wrapper, order, colour,
* label/value layout, and gating are now guaranteed identical. The GLP-1 card
* overrides the left-column label with its appointment-phrased wording.
*
* Layout: when `labelled`, each line is a `flex justify-between` row with the
* bold label pinned left and the value flush right (`text-right`), so the
* label and the time read as two distinct columns rather than crammed onto
* one run of text. The label truncates first on a narrow viewport; the value
* (the time / cadence) never truncates.
*/
interface MedicationNextLastSlotProps {
/**
@@ -36,45 +43,56 @@ interface MedicationNextLastSlotProps {
/** The last-intake line content, or null when the med has never been taken. */
last: React.ReactNode | null;
/**
* When true (the generic card) the part prepends the bold
* "Next intake" / "Last intake" labels. The GLP-1 card passes its own
* site-aware prose (whose i18n strings already read "Next injection …" /
* "Last injection …"), so it opts out and supplies the full line content.
* Either way the wrapper, order, colour, spacing, gating and reserved
* min-height are identical.
* When true the part renders each line as a two-column row: a bold label
* pinned left and the value flush right. The generic card uses the default
* oral "Next intake" / "Last intake" labels; the GLP-1 card overrides them
* with its appointment-phrased labels (see `nextLabel` / `lastLabel`).
* When false the line renders as a single full-width run of text.
*/
labelled?: boolean;
/**
* Override for the left-column label on the next-line. Defaults to the
* oral `medications.nextIntake` string. The GLP-1 card passes its own
* "next appointment" wording so the value column carries only the time.
*/
nextLabel?: React.ReactNode;
/** Override for the left-column label on the last-line. */
lastLabel?: React.ReactNode;
}
export function MedicationNextLastSlot({
next,
last,
labelled = true,
nextLabel,
lastLabel,
}: MedicationNextLastSlotProps) {
const { t } = useTranslations();
return (
<div className="min-h-[2.75rem] space-y-3.5 text-sm">
{next && (
<p className="text-muted-foreground">
{labelled && (
<>
<span className="font-medium">{t("medications.nextIntake")}</span>{" "}
</>
)}
{next}
</p>
)}
{last && (
<p className="text-muted-foreground">
{labelled && (
<>
<span className="font-medium">{t("medications.lastIntake")}</span>{" "}
</>
)}
{last}
</p>
)}
{next &&
(labelled ? (
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
<span className="min-w-0 flex-shrink truncate font-medium">
{nextLabel ?? t("medications.nextIntake")}
</span>
<span className="text-right">{next}</span>
</div>
) : (
<p className="text-muted-foreground">{next}</p>
))}
{last &&
(labelled ? (
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
<span className="min-w-0 flex-shrink truncate font-medium">
{lastLabel ?? t("medications.lastIntake")}
</span>
<span className="text-right">{last}</span>
</div>
) : (
<p className="text-muted-foreground">{last}</p>
))}
</div>
);
}
@@ -410,13 +410,14 @@ export function Glp1MedicationCard({
{/* Injection state — next + last, rendered through the SAME shared
<MedicationNextLastSlot> the generic card uses, so the order
(next-then-last), colour, spacing, gating and reserved
min-height are identical across the two card types. The GLP-1
phrasing carries its own "Next / Last injection" wording (and
the rotation nudge), so it opts out of the part's bold label
prefix and supplies the full line content. */}
(next-then-last), colour, spacing, gating, reserved min-height
and the label-left / value-right two-column layout are identical
across the two card types. The GLP-1 card overrides only the
left-column label with its appointment-phrased wording; the value
column carries the time (plus the optional rotation nudge). */}
<MedicationNextLastSlot
labelled={false}
nextLabel={t("medications.glp1NextLabel")}
lastLabel={t("medications.glp1LastLabel")}
next={
next && currentWindowStatus.status !== "in_window" ? (
<>
+2
View File
@@ -10,6 +10,7 @@ import {
CheckCircle,
Clock,
CloudMoon,
CloudRain,
CloudSun,
Dumbbell,
Film,
@@ -66,6 +67,7 @@ const MOOD_TAG_ICONS: Record<string, LucideIcon> = {
CheckCircle,
Clock,
CloudMoon,
CloudRain,
CloudSun,
Dumbbell,
Film,
+33
View File
@@ -0,0 +1,33 @@
# `src/lib/`
The server and runtime toolkit. Everything that is not a page, component, or route handler lives here: the API kit, auth, crypto, the rollup tier, AI providers, notifications, background jobs, and the integration clients. Route handlers and RSC pages compose these modules; the modules never reach back into `app/`.
## Core request kit
- **`api-handler.ts`** — the `apiHandler` wrapper every route uses. Auth resolver (cookie OR Bearer), `requireAuth` / `requireAdmin` (admin is cookie-only by construction), idempotency plumbing, Wide-Event builder, GlitchTip forwarder.
- **`api-response.ts`** — the `{ data, error, meta? }` envelope: `apiSuccess` / `apiError`, `safeJson`, trusted-proxy IP resolver, `returnAllZodIssues` + `sanitiseZodIssues`.
- **`rate-limit.ts`** — Postgres-backed sliding-window limiter (single atomic upsert); multi-instance correct.
- **`idempotency.ts`** — user-scoped idempotency keys that refuse to cache secret-shaped responses.
- **`query-keys.ts`** — the only legal source of TanStack Query `queryKey` / `mutationKey` arrays (enforced by an ESLint rule).
- **`safe-fetch.ts`** — the one documented outbound-egress entry (`redirect: "manual"` + timeout; optional connect-time DNS-rebinding pin). Raw `fetch(` under `lib/` + `app/` is banned by ESLint.
- **`db.ts`** — the Prisma client singleton.
- **`crypto.ts`** — AES-256-GCM at rest with versioned key ids, fail-closed loader, rotation primitives.
## Subsystems
- **`auth/`** — Postgres sessions, secure-cookie flag policy, passkey (WebAuthn) + Argon2id password, per-device refresh-token rotation, HMAC Bearer tokens.
- **`ai/`** — multi-provider insight + Coach engine: `provider-chain.ts`, hand-rolled `{openai,anthropic,local,codex,mock}-client.ts`, `coach/`, `prompts/`, citation + confidence helpers.
- **`rollups/`** — DAY/WEEK/MONTH/YEAR pre-aggregations for measurements, mood, and medication compliance. Read-swap pattern: probe the rollup, fall back to live SQL on a coverage miss.
- **`notifications/`** — the dispatcher cascade (APNs → Telegram → ntfy → Web Push), per-channel `senders/`, retry policy, VAPID config.
- **`jobs/`** — pg-boss workers: reminders, insight pre-generation, backups, retention sweeps, integration backfills. Recurring work belongs here, not in `scripts/`.
- **`integrations/`**, **`withings/`**, **`whoop/`**, **`fitbit/`** — OAuth clients and sync services for the device providers; `sync/` holds the shared delta-feed plumbing.
- **`analytics/`** — trend calculations, cadence-aware medication `compliance.ts`, correlations.
- **`insights/`**, **`fhir/`**, **`doctor-report-*.ts`** — insight pipeline, FHIR R4 export, client-side doctor-report PDF.
- **`openapi/`** — Zod registry that is the source of truth for `docs/api/openapi.yaml`.
- **`logging/`**, **`observability/`**, **`monitoring/`** — Wide-Event transports and the central redaction denylists (redact at the egress boundary, not the call site).
- **`validations/`** — shared Zod schemas, including the SSRF `isPublicUrl` floor.
- **`i18n/`**, **`tz/`** + **`timezone.ts`**, **`format*.ts`** — translations, timezone helpers (Berlin for display, UTC in DB), locale formatting.
## Conventions
Kebab-case filenames. See [`../../CLAUDE.md`](../../CLAUDE.md) "Code conventions" and "Security-relevant patterns"; the critical-files map there cites the exact entry points. Most modules carry tests in a sibling `__tests__/`.
@@ -481,7 +481,7 @@ export async function buildPeriodNarrativeContext(
...DELTA_METRICS.map((m) => m.type),
...VITALS_BASELINE_TYPES,
...DISCOVERY_BEHAVIOURS.filter((k) => k !== "MOOD"),
...DISCOVERY_OUTCOMES,
...DISCOVERY_OUTCOMES.filter((k) => k !== "MOOD"),
]),
) as MeasurementType[];
+3 -2
View File
@@ -109,8 +109,9 @@ const structuredTagKeys = z.array(z.string().max(60)).max(30);
// The Zod `rating` bound here is the OUTER envelope (1..5 covers every
// seeded factor's scale). The REAL gate is per-tag: after resolving each
// key to its `MoodTag`, the server rejects a rating outside the tag's own
// `scaleMin..scaleMax` (e.g. 1..2 for `factor_conflict`). See
// `resolveRatedFactors` in `src/lib/mood/tag-links.ts`.
// `scaleMin..scaleMax` (every seeded factor is 1..5 today, but a factor
// with a tighter scale is rejected here too). See `resolveRatedFactors`
// in `src/lib/mood/tag-links.ts`.
const ratedFactor = z.object({
key: z.string().max(60),
rating: z.number().int().min(1).max(5),
+20 -21
View File
@@ -161,10 +161,10 @@ describe("POST /api/mood-entries/bulk (real Postgres)", () => {
{
mood: "GUT",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
// Two valid catalog keys (one from the new hobbies category,
// one nutrition-detail key) plus one unknown key that must be
// dropped silently.
tagKeys: ["movies", "fast_food", "not_a_real_tag"],
// Two valid catalog keys (one from the hobbies category, one
// health key) plus one unknown key that must be dropped
// silently.
tagKeys: ["movies", "alcohol", "not_a_real_tag"],
},
],
}),
@@ -182,7 +182,7 @@ describe("POST /api/mood-entries/bulk (real Postgres)", () => {
});
const keys = links.map((l) => l.moodTag.key).sort();
// The unknown key is dropped; the two catalog keys round-trip.
expect(keys).toEqual(["fast_food", "movies"]);
expect(keys).toEqual(["alcohol", "movies"]);
});
it("persists a rated factor with its score on the link (v1.12.0)", async () => {
@@ -195,7 +195,7 @@ describe("POST /api/mood-entries/bulk (real Postgres)", () => {
moodLoggedAt: "2026-05-16T08:00:00.000Z",
ratedFactors: [
{ key: "factor_work", rating: 4 },
{ key: "factor_conflict", rating: 2 },
{ key: "factor_sleep_quality", rating: 2 },
],
},
],
@@ -220,12 +220,18 @@ describe("POST /api/mood-entries/bulk (real Postgres)", () => {
rating: l.rating,
})),
).toEqual([
{ key: "factor_conflict", kind: "RATED", rating: 2 },
{ key: "factor_sleep_quality", kind: "RATED", rating: 2 },
{ key: "factor_work", kind: "RATED", rating: 4 },
]);
});
it("marks the entry skipped when a rated factor is out of scale (v1.12.0)", async () => {
it("rejects the batch when a rated factor falls outside the 1..5 envelope (v1.12.0)", async () => {
// Every seeded RATED factor uses the full 1..5 slider scale, so the
// outer Zod envelope (1..5) is the binding out-of-range gate — a
// rating above it fails parse and 422s the whole batch before any
// row lands. The narrower per-tag-scale path (a factor with a tighter
// `scaleMin..scaleMax`) is covered by the `resolveRatedFactors` unit
// tests.
const { POST } = await import("@/app/api/mood-entries/bulk/route");
const res = await POST(
makeRequest({
@@ -233,26 +239,19 @@ describe("POST /api/mood-entries/bulk (real Postgres)", () => {
{
mood: "GUT",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
// factor_conflict scale is 1..2; rating 5 is out of range.
ratedFactors: [{ key: "factor_conflict", rating: 5 }],
// factor_sleep_quality scale is 1..5; rating 9 is out of range.
ratedFactors: [{ key: "factor_sleep_quality", rating: 9 }],
},
],
}),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: { entries: Array<{ status: string }> };
};
expect(json.data.entries[0].status).toBe("skipped");
expect(res.status).toBe(422);
// The mood row upserted, but no rated link landed.
const entry = await getPrismaClient().moodEntry.findFirstOrThrow({
// Nothing persisted — the batch was rejected at parse time.
const entryCount = await getPrismaClient().moodEntry.count({
where: { userId: TEST_USER_ID },
});
const ratedCount = await getPrismaClient().moodEntryTagLink.count({
where: { moodEntryId: entry.id, rating: { not: null } },
});
expect(ratedCount).toBe(0);
expect(entryCount).toBe(0);
});
it("ignores tagKeys that resolve to no catalog rows", async () => {