From fbdf831cb006a8ffe6c0ceca727c489d6f4016cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 15:24:17 +0200 Subject: [PATCH 1/9] fix(insights): strip MOOD from the period-narrative measurement query MOOD is mood-entry backed, not a MeasurementType; it was filtered out of DISCOVERY_BEHAVIOURS but not DISCOVERY_OUTCOMES, so it leaked into the measurement.findMany type filter and threw PrismaClientValidationError, failing the period-narrative warm job. --- src/lib/insights/narrative/period-narrative.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/insights/narrative/period-narrative.ts b/src/lib/insights/narrative/period-narrative.ts index 7c12658e1..1641b2314 100644 --- a/src/lib/insights/narrative/period-narrative.ts +++ b/src/lib/insights/narrative/period-narrative.ts @@ -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[]; From e7c76e5bca53697e7c4ad0c0262817b8b85938fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 15:26:15 +0200 Subject: [PATCH 2/9] docs(changelog): restore the v1.12.7 section dropped when v1.12.8 was added --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a90e136bc..f8d3bde38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,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 From b3a626d83c9fbd4e0d2eabdb541ab00cd2e6c420 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 15:21:18 +0200 Subject: [PATCH 3/9] docs: add navigational READMEs to key code directories Add a README to src, src/lib, src/app, src/components, prisma, scripts, and e2e so a technical visitor browsing the tree gets oriented: what lives in each directory, the most important files, the local conventions, and pointers to related dirs and docs. Co-Authored-By: Claude Opus 4.8 (1M context) --- e2e/README.md | 21 +++++++++++++++++++++ prisma/README.md | 28 ++++++++++++++++++++++++++++ scripts/README.md | 31 +++++++++++++++++++++++++++++++ src/README.md | 26 ++++++++++++++++++++++++++ src/app/README.md | 25 +++++++++++++++++++++++++ src/components/README.md | 24 ++++++++++++++++++++++++ src/lib/README.md | 33 +++++++++++++++++++++++++++++++++ 7 files changed, 188 insertions(+) create mode 100644 e2e/README.md create mode 100644 prisma/README.md create mode 100644 scripts/README.md create mode 100644 src/README.md create mode 100644 src/app/README.md create mode 100644 src/components/README.md create mode 100644 src/lib/README.md 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/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/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/lib/README.md b/src/lib/README.md new file mode 100644 index 000000000..f46fde331 --- /dev/null +++ b/src/lib/README.md @@ -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__/`. From 46cecc812733047b71c11ed4c2808ab883e47ad0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 15:23:08 +0200 Subject: [PATCH 4/9] docs(readme): embed all five architecture diagrams, add docs pointer table Reference the data-flow, coach-pipeline, source-priority, and security-model SVGs alongside the self-hosting topology already present, so the architecture story is complete. Add a Documentation section with a pointer table into docs/ and the new code-directory READMEs. Drop the four iOS screenshot references that pointed at an uncommitted scratch directory. Correct the model count to 61. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 45 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 8 deletions(-) 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 From fac2ebeca1c3e2d6c34a9badc0385bb52346e514 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 15:23:53 +0200 Subject: [PATCH 5/9] docs: fix branch-model and schema-count drift Rewrite the CONTRIBUTING branch-model section from the retired develop->main model to the current trunk-based model: main is the single long-lived branch, short-lived release/vX.Y.Z branches merge to main, the tag triggers the GHCR build. Correct the schema model count in CLAUDE.md from 47 to 61 (the real count of model declarations in prisma/schema.prisma) and refresh the stale current-line marker. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 6 +++--- CONTRIBUTING.md | 29 +++++++++++++---------------- 2 files changed, 16 insertions(+), 19 deletions(-) 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 From a3e741c23c4e1f0de8f1f19d377eb0e47488f46e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 15:27:48 +0200 Subject: [PATCH 6/9] feat(mood): revise factor + tag catalog (migration 0126) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tighten the operator-finalised mood catalog to three RATED factors and a trimmed default BINARY tag set. - Seed `factor_sadness` (RATED, 1..5, inverse — higher = worse) under the `factors` category, with its label in all six locales and a CloudRain glyph. The RATED set is now work / sleep quality / sadness. - Retire `factor_conflict`, `factor_family`, `factor_social`, `factor_stress`: flip each to BINARY and deactivate. The catalog rows stay so historical entries still resolve their labels (the insights read joins by id without an active filter); they just leave the RATED set and the default picker. - Trim the default BINARY tag set roughly in half: deactivate the sleep-tag category (now covered by the sleep-quality slider), the mood level restatements, the nutrition long-tail, and low-signal day-type tags. Kept rows are the high-signal, correlatable tags. Nothing is deleted; inactive rows still resolve labels for existing entries. Reference-data only — no schema change, idempotent upsert + closed-key updates, no per-user data touched. Co-Authored-By: Claude Opus 4.8 (1M context) --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- .../migration.sql | 72 +++++++++++++++++++ src/components/mood/mood-tag-icons.ts | 2 + 8 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 prisma/migrations/0126_v1129_mood_catalog_revision/migration.sql diff --git a/messages/de.json b/messages/de.json index ef739e2c7..8cf0ff449 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": { diff --git a/messages/en.json b/messages/en.json index 0113f3ab5..84f091789 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": { diff --git a/messages/es.json b/messages/es.json index 85772d59c..fac9fef9d 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": { diff --git a/messages/fr.json b/messages/fr.json index 76a80ca9e..d976ba187 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": { diff --git a/messages/it.json b/messages/it.json index 75d2f312a..78e3b61fa 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": { diff --git a/messages/pl.json b/messages/pl.json index 4ce4a7ddc..02f28c961 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": { 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/src/components/mood/mood-tag-icons.ts b/src/components/mood/mood-tag-icons.ts index cf22b4508..0d6516ff9 100644 --- a/src/components/mood/mood-tag-icons.ts +++ b/src/components/mood/mood-tag-icons.ts @@ -10,6 +10,7 @@ import { CheckCircle, Clock, CloudMoon, + CloudRain, CloudSun, Dumbbell, Film, @@ -66,6 +67,7 @@ const MOOD_TAG_ICONS: Record = { CheckCircle, Clock, CloudMoon, + CloudRain, CloudSun, Dumbbell, Film, From c59290ddc6e3f6bce663c5cac040ea3d5243a7a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 15:27:53 +0200 Subject: [PATCH 7/9] test(mood): align bulk catalog tests with the revised factor set The retired RATED factors leave no seeded factor with a scale narrower than the 1..5 rating envelope, so the per-tag out-of-scale "skipped" path is no longer reachable through the catalog (it stays covered by the `resolveRatedFactors` unit tests). Update the bulk integration tests to use surviving factors and assert the envelope rejection, and swap a deactivated tag for an active one in the tag-key round-trip. Refresh the stale per-tag-scale comment in the rated-factor schema. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/validations/moodlog.ts | 5 ++- tests/integration/mood-entries-bulk.test.ts | 41 ++++++++++----------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/lib/validations/moodlog.ts b/src/lib/validations/moodlog.ts index 80dabb994..6597a3358 100644 --- a/src/lib/validations/moodlog.ts +++ b/src/lib/validations/moodlog.ts @@ -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), diff --git a/tests/integration/mood-entries-bulk.test.ts b/tests/integration/mood-entries-bulk.test.ts index ef3577926..c595c8566 100644 --- a/tests/integration/mood-entries-bulk.test.ts +++ b/tests/integration/mood-entries-bulk.test.ts @@ -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 () => { From 83cf4eff2ed607e613e80ee7f095fbe6b83e5cdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 15:34:13 +0200 Subject: [PATCH 8/9] fix(medications): split the card next/last dose lines into label-left, value-right rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The next-intake and last-intake lines on the medication cards ran the label and the time together on one run of text, reading as crammed. Lay each line out as a two-column row — bold label pinned left, value flush right — so the two rows read as distinct, aligned columns. The label truncates first on a narrow viewport; the time never truncates. The GLP-1 card routes through the same shared slot and now overrides only the left-column label with its appointment-phrased wording, so the value column carries the time and the optional rotation nudge. Its value strings drop the now-redundant leading label. Co-Authored-By: Claude Opus 4.8 (1M context) --- messages/de.json | 12 +-- messages/en.json | 12 +-- messages/es.json | 12 +-- messages/fr.json | 12 +-- messages/it.json | 12 +-- messages/pl.json | 12 +-- .../__tests__/medication-card-glp1.test.tsx | 10 +-- .../medication-card-symmetry.test.tsx | 16 +++- .../card-parts/medication-next-last-slot.tsx | 78 ++++++++++++------- .../medications/glp1-medication-card.tsx | 13 ++-- 10 files changed, 114 insertions(+), 75 deletions(-) diff --git a/messages/de.json b/messages/de.json index 8cf0ff449..d87fc7340 100644 --- a/messages/de.json +++ b/messages/de.json @@ -702,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 84f091789..34cd6f238 100644 --- a/messages/en.json +++ b/messages/en.json @@ -702,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 fac9fef9d..f77e0498f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -702,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 d976ba187..5cbdafd73 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -702,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 78e3b61fa..71b82ecfa 100644 --- a/messages/it.json +++ b/messages/it.json @@ -702,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 02f28c961..368af74df 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -702,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/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: