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. +

+ 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 +

+ **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. +

+ 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 +

+ --- ## 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. + +

+ 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 +

+ ``` 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. +

+ 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 +

+ --- ## 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 →

-

- Dashboard with Health Score ring, medication compliance, vital-sign tiles - Insights tab with Frag-den-Coach CTA, BMI and blood-pressure reference ranges - Medication detail with 30-day compliance ring and 14-day intake-status glyph track - Mood entry sheet with five mood faces -

- 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: