mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge pull request #266 from MBombeck/release/v1.12.9
release/v1.12.9 — tighter mood catalogue, clearer cards, fixed retrospective
This commit is contained in:
28 files changed
+502
-134
No files matched your search
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@ Working notes for anyone (human or otherwise) editing this repository. Skim befo
|
||||
|
||||
## What HealthLog is
|
||||
|
||||
Self-hosted personal-health-tracking PWA: weight, blood pressure, pulse, body composition, glucose, sleep, mood, medication compliance. Withings + Apple Health sync, multi-provider AI Insights (BYOK or local), doctor-report PDF, native iOS client in public beta. Single `docker compose up`, Postgres-backed, AES-256-GCM at rest. AGPL-3.0. Current line: v1.5.x.
|
||||
Self-hosted personal-health-tracking PWA: weight, blood pressure, pulse, body composition, glucose, sleep, mood, medication compliance. Withings + Apple Health sync, multi-provider AI Insights (BYOK or local), doctor-report PDF, native iOS client in public beta. Single `docker compose up`, Postgres-backed, AES-256-GCM at rest. AGPL-3.0. Current line: v1.12.x.
|
||||
|
||||
## Voice and privacy
|
||||
|
||||
@@ -38,7 +38,7 @@ Prefer patch bumps. A release dominated by bug fixes plus additive features with
|
||||
| Framework | Next.js 16.2.6 — App Router, `output: "standalone"` | SWC strips `console.*` in prod except `error/warn`. |
|
||||
| Language | TypeScript 6, `strict: true`, `moduleResolution: "bundler"` | Path alias `@/*` → `./src/*`. `scripts/` excluded from typecheck. |
|
||||
| UI | React 19.2.5 (exact), Tailwind 4, shadcn/ui (new-york style, zinc base), Radix, Lucide | Recharts 3 for charts — stays; replacement requires explicit approval. |
|
||||
| Persistence | PostgreSQL 16 + Prisma 7.8 — 47 models | Generated client at `src/generated/prisma`. Production image ships a separate `/opt/prisma-cli` install + `/opt/pg-boss` runtime install (see `Dockerfile:74-83`). |
|
||||
| Persistence | PostgreSQL 16 + Prisma 7.8 — 61 models | Generated client at `src/generated/prisma`. Production image ships a separate `/opt/prisma-cli` install + `/opt/pg-boss` runtime install (see `Dockerfile:74-83`). |
|
||||
| Queue | pg-boss 12.18 | Cron + retry semantics live in Postgres tables; workers under `src/lib/jobs/`. |
|
||||
| Auth | `@simplewebauthn/server` 13, `@node-rs/argon2` 2 | Passkey + Argon2id password, server-side sessions in Postgres. |
|
||||
| Notifications | `@parse/node-apn` 8 (APNs), `web-push` 3 (VAPID), raw fetch for Telegram + ntfy | Per-channel `recordPushAttempt` row, hard-reject classification. |
|
||||
@@ -124,7 +124,7 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file:
|
||||
| Notification dispatcher | `src/lib/notifications/dispatcher.ts` + `src/lib/notifications/senders/` | APNs → Telegram → ntfy → Web Push cascade, hard-reject classification, `push_attempts` ledger with 90-day retention. |
|
||||
| Coach | `src/app/api/insights/chat/route.ts` + `src/lib/ai/coach/` | SSE stream, budget gate + per-user rate gate, refusal detector, snapshot builder, message persistence with `encryptedContent` Bytes column. |
|
||||
| OpenAPI | `src/lib/openapi/registry.ts` + `src/lib/openapi/routes.ts` | Source of truth for `docs/api/openapi.yaml`. |
|
||||
| Schema | `prisma/schema.prisma` (47 models) | `cuid()` PKs, `snake_case` columns via `@map`, encrypted columns mostly under `*Encrypted` (search the file). |
|
||||
| Schema | `prisma/schema.prisma` (61 models) | `cuid()` PKs, `snake_case` columns via `@map`, encrypted columns mostly under `*Encrypted` (search the file). |
|
||||
| Compose | `docker-compose.yml` | `app` + `db`. Env-var whitelist under `environment:` — vars not listed never reach the container. `pull_policy: always` is load-bearing. |
|
||||
|
||||
`.planning/codebase/arch.md` carries the full annotated walk for every section.
|
||||
|
||||
+13
-16
@@ -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
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
- [Integrations](#integrations)
|
||||
- [Local Development](#local-development)
|
||||
- [Deployment](#deployment)
|
||||
- [Documentation](#documentation)
|
||||
- [Roadmap](#roadmap)
|
||||
- [Native iOS client](#native-ios-client)
|
||||
- [Contributing](#contributing)
|
||||
@@ -132,6 +133,10 @@ HealthLog is the right fit when you want your wearable and clinical numbers in o
|
||||
|
||||
**AI Coach + Insights** -- Multi-provider, evidence-grounded health insights: per-metric assessments that read each vital against its reference range, correlations across your metrics, a daily briefing, a weekly report, a Health Score tile, and a conversational Coach grounded in your own data. Pick OpenAI, Anthropic Claude, ChatGPT via Codex device-OAuth (no API key needed), or any OpenAI-compatible local endpoint (Ollama, LM Studio, vLLM). BYOK or admin-shared. Feed the Coach a chosen set of data clusters (cardiovascular, body composition, activity, workouts, sleep, mood, glucose, medication, mobility, environment) with a soft budget cap that degrades the lowest-signal clusters first. Every claim links back to the measurements that produced it. The Coach now reads your history longitudinally — a narrative of how a metric has moved and a per-metric trajectory framing — so an answer reflects the direction of travel rather than only the latest reading. Local endpoints keep all data on your network.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/diagrams/02-coach-pipeline.svg" alt="Coach pipeline: a user question through the snapshot builder and prompt assembler, into the provider chain (ChatGPT OAuth, OpenAI, Anthropic, local, admin-shared), through the response parser, out to a cited reply with mini-charts" width="900" />
|
||||
</p>
|
||||
|
||||
**Derived wellness metrics** -- A transparent metrics tier computed from your own measurements: a fitness-age band from VO₂max, a vascular-age delta, an HRV-balance band, a sleep score, a daily readiness and a stored recovery score, plus a coincident-deviation early-strain flag. Each one re-frames or blends signals you already recorded against age/sex norms or your personal baseline, cites the inputs and the published method behind it, and returns "not enough data" rather than a fabricated value when its minimum inputs are missing. The recovery score persists as a daily 0–100 series the dashboard, charts, and native client read without recomputing. These are descriptive wellness framings — not clinical or training-grade assessments.
|
||||
|
||||
**Doctor Report PDF Export** -- Generate professional medical reports client-side. Locale-aware (English/German), with vital sign summaries, BP/BMI/glucose classification, compliance rates, custom-threshold badges, and optional AI analysis.
|
||||
@@ -194,7 +199,7 @@ Open **http://localhost:3000**. The first registered user becomes admin.
|
||||
| ------------- | ------------------------------------------------- |
|
||||
| Framework | Next.js 16 (App Router, React Server Components) |
|
||||
| Language | TypeScript (strict mode) |
|
||||
| Database | PostgreSQL 16 + Prisma 7 (60 models) |
|
||||
| Database | PostgreSQL 16 + Prisma 7 (61 models) |
|
||||
| Job Queue | pg-boss 12 (reminders, insights, backups) |
|
||||
| UI | shadcn/ui, Tailwind CSS 4, Radix UI, Lucide Icons |
|
||||
| Charts | Recharts 3 |
|
||||
@@ -226,6 +231,10 @@ HealthLog is designed for people who take data ownership seriously.
|
||||
- **Wide-event structured logging** -- Every API route emits a single envelope with `action`, latency, request id, sampled payload (with secret redaction), and an optional Loki transport for self-hosted log aggregation.
|
||||
- **Audit logging** -- All sensitive operations tracked with IP addresses, dedup-windowed to keep the ledger compact under bursty writes.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/diagrams/05-security-model.svg" alt="Security model: concentric layers — auth perimeter (passkey plus password fallback), session perimeter (HttpOnly cookie plus Postgres sessions), encrypted core (AES-256-GCM versioned keys), with side rails for the rate limiter, audit log, HMAC tokens, CSP/HSTS, and SSRF guard" width="900" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
@@ -259,6 +268,12 @@ Telegram bot token, ntfy settings, Web Push VAPID keys, Umami, and GlitchTip URL
|
||||
|
||||
## Architecture
|
||||
|
||||
Data flows from every source — Withings, an Apple Health `export.zip`, the iPhone's live HealthKit, manual entry, moodLog — through the ingest endpoints into a single `Measurement` table and its pre-aggregated rollups, then back out to the dashboard, insights, Coach, and the doctor-report PDF.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/diagrams/01-data-flow.svg" alt="Data flow: sources (Withings, Apple Health export, iPhone HealthKit, manual, moodLog) into ingest endpoints, into Measurement plus MeasurementRollup, out to dashboard, insights, Coach, and doctor PDF" width="900" />
|
||||
</p>
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js App Router pages & API routes
|
||||
@@ -524,6 +539,10 @@ Clinician share-link lifecycle — create a scoped, time-limited link, hand a cl
|
||||
|
||||
When two providers report the same metric — a Withings scale and an Apple Watch both logging weight, or HealthKit and Fitbit both reporting steps — HealthLog keeps every row as an audit trail but applies a per-metric source priority to decide which reading is canonical. Cumulative metrics (steps, distance, active energy, floors) pick exactly one source per day so they are never double-counted; point measurements (weight, blood pressure, HRV) pick a display-preferred source. The default ladder reflects measurement reliability for each metric class: a dedicated scale outranks a wrist estimate for weight, a recovery strap leads the sleep and HRV ladders, and a cuff leads blood pressure. The ladder is per-user adjustable, and an optional device-type tie-break decides between two devices on the same source. The underlying idea is [data fusion](https://en.wikipedia.org/wiki/Data_fusion) — combining several imperfect sensors into a single, more reliable estimate rather than averaging them blindly.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/diagrams/04-source-priority.svg" alt="Source priority: three sources for the same metric on the same day, the resolver picking one canonical row per cumulative-versus-point ladder while the losing rows stay in the audit trail" width="900" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Local Development
|
||||
@@ -570,6 +589,23 @@ For a single-process default the same container hosts both the web and worker (`
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
The full documentation set lives under [`docs/`](docs/) (also published at [docs.healthlog.dev](https://docs.healthlog.dev)). A technical visitor browsing the source will also find a navigational `README.md` in each key code directory (`src/`, `src/lib/`, `src/app/`, `src/components/`, `prisma/`, `scripts/`, `e2e/`).
|
||||
|
||||
| Area | Where | What it covers |
|
||||
| ---- | ----- | -------------- |
|
||||
| Self-hosting | [`docs/self-hosting/`](docs/self-hosting/) | Getting started, reverse-proxy setup, horizontal scaling |
|
||||
| Operator runbooks | [`docs/ops/`](docs/ops/) | Deploy, encryption-key rotation, backup/restore, env check, migrations, TLS pinning |
|
||||
| API contract | [`docs/api/`](docs/api/) | OpenAPI 3.1 spec (`openapi.yaml`) for the native client subset |
|
||||
| Integrations | [`docs/integrations/`](docs/integrations/) | Withings, WHOOP, Google Health/Fitbit, Apple Health, AI providers |
|
||||
| Architecture diagrams | [`docs/diagrams/`](docs/diagrams/) | The five SVG diagrams embedded above |
|
||||
| Migration notes | [`docs/migration/`](docs/migration/) | Cross-version upgrade notes |
|
||||
| Design records | [`docs/adr/`](docs/adr/), [`docs/ui-guidelines.md`](docs/ui-guidelines.md) | Architecture decision records + UI conventions |
|
||||
| Contributor manual | [`CLAUDE.md`](CLAUDE.md), [`CONTRIBUTING.md`](CONTRIBUTING.md) | Conventions, branch/release model, the critical-files map |
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
| Release line | Focus |
|
||||
@@ -593,13 +629,6 @@ The detailed changelog lives in [`CHANGELOG.md`](CHANGELOG.md).
|
||||
<a href="https://testflight.apple.com/join/bucuTBpa"><strong>Join the TestFlight public beta →</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/ios/ios-dashboard.png" alt="Dashboard with Health Score ring, medication compliance, vital-sign tiles" width="220" />
|
||||
<img src="docs/ios/ios-insights.png" alt="Insights tab with Frag-den-Coach CTA, BMI and blood-pressure reference ranges" width="220" />
|
||||
<img src="docs/ios/ios-medication-detail.png" alt="Medication detail with 30-day compliance ring and 14-day intake-status glyph track" width="220" />
|
||||
<img src="docs/ios/ios-mood-entry.png" alt="Mood entry sheet with five mood faces" width="220" />
|
||||
</p>
|
||||
|
||||
A SwiftUI iOS companion that lives on the same `/api` surfaces as the web client — same server, same data, same source of truth. The phone is a view + a write-ahead log; your server is the database. Code lives in a separate repo: [github.com/MBombeck/healthlog-iOS](https://github.com/MBombeck/healthlog-iOS).
|
||||
|
||||
### What the phone unlocks
|
||||
|
||||
@@ -1,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.
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# `e2e/`
|
||||
|
||||
End-to-end browser tests. Playwright 1.60 drives a production build of the app against a real PostgreSQL, with `@axe-core/playwright` for accessibility assertions. This is the heaviest test tier; CI runs it on every PR, and locally it is worth running when a change touches a UI flow.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
pnpm e2e
|
||||
```
|
||||
|
||||
The Playwright config (`../playwright.config.ts`) builds the app (`next build`) and brings up Postgres before the specs run; `setup/global-setup.ts` seeds the auth + fixture state. The suite is not part of the fast local gate (`typecheck` / `lint` / `test`) — it surfaces at PR CI, so expect responsive/wizard/nav reworks to flag here first.
|
||||
|
||||
## Layout
|
||||
|
||||
- **`setup/`** — `global-setup.ts` (auth + DB seeding), `test-helpers.ts` (shared login + navigation helpers).
|
||||
- **`utils/`** — fixtures such as `mock-dashboard-snapshot.ts`.
|
||||
- **`*.spec.ts`** — one spec per flow: auth/login/redirect, dashboard, charts, the medication-wizard cadences (`medications-wizard-*`), insights generation + scroll restoration, doctor report, settings, mobile-viewport consistency, locale switch, accessibility (`a11y.spec.ts`), and the `v1427-*` regression set.
|
||||
|
||||
## Convention
|
||||
|
||||
Assert against stable `data-*` attributes (`data-slot`, `data-display-step`, root display attrs), not viewport-dependent visible text. Responsive `sm:hidden` UI changes break `getByText` on desktop while the underlying element is still present — anchoring on data attributes keeps the specs stable across breakpoints. See [`../CLAUDE.md`](../CLAUDE.md).
|
||||
+9
-6
@@ -577,7 +577,8 @@
|
||||
"factorSleepQuality": "Schlafqualität",
|
||||
"factorStress": "Stress",
|
||||
"factorConflict": "Konflikt",
|
||||
"factorFamily": "Familie"
|
||||
"factorFamily": "Familie",
|
||||
"factorSadness": "Traurigkeit"
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
@@ -701,11 +702,13 @@
|
||||
"glp1DosesPerUnit": "Dosen pro Pen / Ampulle",
|
||||
"glp1DosesPerUnitPlaceholder": "z. B. 4 für einen Mounjaro KwikPen",
|
||||
"glp1DosesPerUnitHelp": "Optional — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy Einmal-Pen = 1. Leer lassen, wenn keine Dosis-pro-Pen-Verfolgung gewünscht ist.",
|
||||
"glp1LastInjection": "Letzter Termin: {label}",
|
||||
"glp1LastInjectionWithSite": "Letzter Termin: {label} · {site}",
|
||||
"glp1NextInjectionDays": "Nächster Termin: {label} (in {days} Tagen)",
|
||||
"glp1NextInjectionToday": "Nächster Termin: heute",
|
||||
"glp1NextInjectionTomorrow": "Nächster Termin: morgen",
|
||||
"glp1NextLabel": "Nächster Termin:",
|
||||
"glp1LastLabel": "Letzter Termin:",
|
||||
"glp1LastInjection": "{label}",
|
||||
"glp1LastInjectionWithSite": "{label} · {site}",
|
||||
"glp1NextInjectionDays": "{label} (in {days} Tagen)",
|
||||
"glp1NextInjectionToday": "heute",
|
||||
"glp1NextInjectionTomorrow": "morgen",
|
||||
"glp1RotationLast": "Letzte Stelle: {site}",
|
||||
"glp1RotationSuggested": "Empfohlen: {site}",
|
||||
"glp1LogSideEffect": "Nebenwirkung loggen",
|
||||
|
||||
+9
-6
@@ -577,7 +577,8 @@
|
||||
"factorSleepQuality": "sleep quality",
|
||||
"factorStress": "stress",
|
||||
"factorConflict": "conflict",
|
||||
"factorFamily": "family"
|
||||
"factorFamily": "family",
|
||||
"factorSadness": "sadness"
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
@@ -701,11 +702,13 @@
|
||||
"glp1DosesPerUnit": "Doses per pen / vial",
|
||||
"glp1DosesPerUnitPlaceholder": "e.g. 4 for a Mounjaro KwikPen",
|
||||
"glp1DosesPerUnitHelp": "Optional — Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose pen = 1. Leave blank if you don't track per-pen dosing.",
|
||||
"glp1LastInjection": "Last: {label}",
|
||||
"glp1LastInjectionWithSite": "Last: {label} · {site}",
|
||||
"glp1NextInjectionDays": "Next: {label} (in {days} days)",
|
||||
"glp1NextInjectionToday": "Next: today",
|
||||
"glp1NextInjectionTomorrow": "Next: tomorrow",
|
||||
"glp1NextLabel": "Next:",
|
||||
"glp1LastLabel": "Last:",
|
||||
"glp1LastInjection": "{label}",
|
||||
"glp1LastInjectionWithSite": "{label} · {site}",
|
||||
"glp1NextInjectionDays": "{label} (in {days} days)",
|
||||
"glp1NextInjectionToday": "today",
|
||||
"glp1NextInjectionTomorrow": "tomorrow",
|
||||
"glp1RotationLast": "Last site: {site}",
|
||||
"glp1RotationSuggested": "Recommended next: {site}",
|
||||
"glp1LogSideEffect": "Log side effect",
|
||||
|
||||
+9
-6
@@ -577,7 +577,8 @@
|
||||
"factorSleepQuality": "calidad del sueño",
|
||||
"factorStress": "estrés",
|
||||
"factorConflict": "conflicto",
|
||||
"factorFamily": "familia"
|
||||
"factorFamily": "familia",
|
||||
"factorSadness": "tristeza"
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
@@ -701,11 +702,13 @@
|
||||
"glp1DosesPerUnit": "Dosis por pluma / vial",
|
||||
"glp1DosesPerUnitPlaceholder": "p. ej. 4 para una pluma Mounjaro KwikPen",
|
||||
"glp1DosesPerUnitHelp": "Opcional — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy pluma de un solo uso = 1. Déjalo vacío si no quieres llevar el conteo de dosis por pluma.",
|
||||
"glp1LastInjection": "Última cita: {label}",
|
||||
"glp1LastInjectionWithSite": "Última cita: {label} · {site}",
|
||||
"glp1NextInjectionDays": "Siguiente: {label} (en {days} días)",
|
||||
"glp1NextInjectionToday": "Próxima cita: hoy",
|
||||
"glp1NextInjectionTomorrow": "Próxima cita: mañana",
|
||||
"glp1NextLabel": "Próxima cita:",
|
||||
"glp1LastLabel": "Última cita:",
|
||||
"glp1LastInjection": "{label}",
|
||||
"glp1LastInjectionWithSite": "{label} · {site}",
|
||||
"glp1NextInjectionDays": "{label} (en {days} días)",
|
||||
"glp1NextInjectionToday": "hoy",
|
||||
"glp1NextInjectionTomorrow": "mañana",
|
||||
"glp1RotationLast": "Último sitio: {site}",
|
||||
"glp1RotationSuggested": "Sugerido: {site}",
|
||||
"glp1LogSideEffect": "Registrar efecto secundario",
|
||||
|
||||
+9
-6
@@ -577,7 +577,8 @@
|
||||
"factorSleepQuality": "qualité du sommeil",
|
||||
"factorStress": "stress",
|
||||
"factorConflict": "conflit",
|
||||
"factorFamily": "famille"
|
||||
"factorFamily": "famille",
|
||||
"factorSadness": "tristesse"
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
@@ -701,11 +702,13 @@
|
||||
"glp1DosesPerUnit": "Doses par stylo / flacon",
|
||||
"glp1DosesPerUnitPlaceholder": "p. ex. 4 pour un stylo Mounjaro KwikPen",
|
||||
"glp1DosesPerUnitHelp": "Optionnel — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy stylo à usage unique = 1. Laisse vide si tu ne veux pas suivre la dose par stylo.",
|
||||
"glp1LastInjection": "Dernier rendez-vous : {label}",
|
||||
"glp1LastInjectionWithSite": "Dernier rendez-vous : {label} · {site}",
|
||||
"glp1NextInjectionDays": "Suivant : {label} (dans {days} jours)",
|
||||
"glp1NextInjectionToday": "Prochain rendez-vous : aujourd’hui",
|
||||
"glp1NextInjectionTomorrow": "Prochain rendez-vous : demain",
|
||||
"glp1NextLabel": "Prochain rendez-vous :",
|
||||
"glp1LastLabel": "Dernier rendez-vous :",
|
||||
"glp1LastInjection": "{label}",
|
||||
"glp1LastInjectionWithSite": "{label} · {site}",
|
||||
"glp1NextInjectionDays": "{label} (dans {days} jours)",
|
||||
"glp1NextInjectionToday": "aujourd’hui",
|
||||
"glp1NextInjectionTomorrow": "demain",
|
||||
"glp1RotationLast": "Dernier site : {site}",
|
||||
"glp1RotationSuggested": "Conseillé : {site}",
|
||||
"glp1LogSideEffect": "Consigner un effet secondaire",
|
||||
|
||||
+9
-6
@@ -577,7 +577,8 @@
|
||||
"factorSleepQuality": "qualità del sonno",
|
||||
"factorStress": "stress",
|
||||
"factorConflict": "conflitto",
|
||||
"factorFamily": "famiglia"
|
||||
"factorFamily": "famiglia",
|
||||
"factorSadness": "tristezza"
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
@@ -701,11 +702,13 @@
|
||||
"glp1DosesPerUnit": "Dosi per penna / fiala",
|
||||
"glp1DosesPerUnitPlaceholder": "es. 4 per una penna Mounjaro KwikPen",
|
||||
"glp1DosesPerUnitHelp": "Opzionale — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy penna monouso = 1. Lascia vuoto se non vuoi monitorare le dosi per penna.",
|
||||
"glp1LastInjection": "Ultimo appuntamento: {label}",
|
||||
"glp1LastInjectionWithSite": "Ultimo appuntamento: {label} · {site}",
|
||||
"glp1NextInjectionDays": "Prossimo: {label} (tra {days} giorni)",
|
||||
"glp1NextInjectionToday": "Prossimo appuntamento: oggi",
|
||||
"glp1NextInjectionTomorrow": "Prossimo appuntamento: domani",
|
||||
"glp1NextLabel": "Prossimo appuntamento:",
|
||||
"glp1LastLabel": "Ultimo appuntamento:",
|
||||
"glp1LastInjection": "{label}",
|
||||
"glp1LastInjectionWithSite": "{label} · {site}",
|
||||
"glp1NextInjectionDays": "{label} (tra {days} giorni)",
|
||||
"glp1NextInjectionToday": "oggi",
|
||||
"glp1NextInjectionTomorrow": "domani",
|
||||
"glp1RotationLast": "Ultima sede: {site}",
|
||||
"glp1RotationSuggested": "Suggerita: {site}",
|
||||
"glp1LogSideEffect": "Registra effetto collaterale",
|
||||
|
||||
+9
-6
@@ -577,7 +577,8 @@
|
||||
"factorSleepQuality": "jakość snu",
|
||||
"factorStress": "stres",
|
||||
"factorConflict": "konflikt",
|
||||
"factorFamily": "rodzina"
|
||||
"factorFamily": "rodzina",
|
||||
"factorSadness": "smutek"
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
@@ -701,11 +702,13 @@
|
||||
"glp1DosesPerUnit": "Dawki na pen / fiolkę",
|
||||
"glp1DosesPerUnitPlaceholder": "np. 4 dla pena Mounjaro KwikPen",
|
||||
"glp1DosesPerUnitHelp": "Opcjonalne — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy pen jednorazowy = 1. Zostaw puste, jeśli nie chcesz śledzić dawek na pen.",
|
||||
"glp1LastInjection": "Ostatni termin: {label}",
|
||||
"glp1LastInjectionWithSite": "Ostatni termin: {label} · {site}",
|
||||
"glp1NextInjectionDays": "Następnie: {label} (za {days} dni)",
|
||||
"glp1NextInjectionToday": "Następny termin: dziś",
|
||||
"glp1NextInjectionTomorrow": "Następny termin: jutro",
|
||||
"glp1NextLabel": "Następny termin:",
|
||||
"glp1LastLabel": "Ostatni termin:",
|
||||
"glp1LastInjection": "{label}",
|
||||
"glp1LastInjectionWithSite": "{label} · {site}",
|
||||
"glp1NextInjectionDays": "{label} (za {days} dni)",
|
||||
"glp1NextInjectionToday": "dziś",
|
||||
"glp1NextInjectionTomorrow": "jutro",
|
||||
"glp1RotationLast": "Ostatnie miejsce: {site}",
|
||||
"glp1RotationSuggested": "Sugerowane: {site}",
|
||||
"glp1LogSideEffect": "Zapisz działanie niepożądane",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# `prisma/`
|
||||
|
||||
The database layer. HealthLog runs PostgreSQL 16 via Prisma 7. This directory holds the single schema file and the forward-only migration history; the generated client is emitted elsewhere.
|
||||
|
||||
## Contents
|
||||
|
||||
- **`schema.prisma`** — 61 models, one file. `cuid()` primary keys, `snake_case` columns via `@map(...)`, encrypted columns mostly suffixed `*Encrypted` (search the file; there are 24 encrypted column references). The `generator client` block emits to `../src/generated/prisma` (imported as `@/generated/prisma`).
|
||||
- **`migrations/`** — numbered, forward-only SQL migrations (`0001_…` upward). `migration_lock.toml` pins the provider to `postgresql`.
|
||||
|
||||
## Migration workflow
|
||||
|
||||
```bash
|
||||
# author a migration from a schema edit
|
||||
pnpm dlx prisma migrate dev --name <short_snake_case_name>
|
||||
|
||||
# regenerate the client after any schema change
|
||||
pnpm dlx prisma generate
|
||||
```
|
||||
|
||||
Number a new migration with the next free prefix. The production entrypoint runs `prisma migrate deploy` on boot, so every migration must apply cleanly forward — there is no down-migration path. Always include the migration file in the same change as the schema edit.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Encryption is fail-closed: `*Encrypted` columns are written through `@/lib/crypto` (AES-256-GCM, versioned key ids); never store plaintext in an encrypted column. Key rotation is `scripts/rotate-encryption-key.ts` + `docs/ops/encryption-key-rotation.md`.
|
||||
- The generated client under `src/generated/prisma` is build output — do not edit it.
|
||||
- Schema-touching changes that affect the API surface must regenerate `docs/api/openapi.yaml` (see [`../CLAUDE.md`](../CLAUDE.md)).
|
||||
|
||||
Related: [`../src/lib/README.md`](../src/lib/README.md) (`db.ts`, `rollups/`), [`../docs/ops/`](../docs/ops/) (migrations + backup runbooks).
|
||||
@@ -0,0 +1,72 @@
|
||||
-- v1.12.9 — mood-catalog revision.
|
||||
--
|
||||
-- Tightens the operator-finalised mood factor + tag catalog seeded by
|
||||
-- 0118 / 0119 / 0120. Three changes, all reference-data only — no schema
|
||||
-- change, no per-user data touched, no destructive delete:
|
||||
--
|
||||
-- 1. Seed a new RATED factor `factor_sadness` (scale 1..5, inverse —
|
||||
-- higher = a worse day), under the existing `factors` category.
|
||||
-- 2. Retire the four RATED factors the operator dropped
|
||||
-- (`factor_conflict`, `factor_family`, `factor_social`,
|
||||
-- `factor_stress`): flip each to `kind = 'BINARY'` AND set
|
||||
-- `is_active = false`. They leave the RATED set and the default
|
||||
-- picker, but the catalog rows stay so any historical
|
||||
-- `mood_entry_tag_links` row still resolves its label (the insights
|
||||
-- read joins the catalog row by id and does NOT filter `is_active`).
|
||||
-- The result: exactly THREE RATED factors remain
|
||||
-- (`factor_work`, `factor_sleep_quality`, `factor_sadness`).
|
||||
-- 3. Trim the default BINARY tag set roughly in half: mark the
|
||||
-- long-tail / slider-redundant tags `is_active = false`. The picker
|
||||
-- (`GET /api/mood/tags` filters `is_active = true`) stops offering
|
||||
-- them; existing entries that reference a now-inactive tag still
|
||||
-- resolve its label because the read joins by id without the active
|
||||
-- filter. Nothing is deleted.
|
||||
--
|
||||
-- Idempotent: the seed uses a deterministic id + ON CONFLICT upsert by
|
||||
-- `key` (mirrors 0118 / 0119 / 0120); the retire / trim updates key off a
|
||||
-- closed `key` list, so a re-run is a no-op.
|
||||
|
||||
-- ─── 1. New RATED factor: sadness (inverse, 1..5) ────────────────────
|
||||
INSERT INTO "mood_tags"
|
||||
("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active", "kind", "scale_min", "scale_max", "inverse")
|
||||
VALUES ('mt_factor_sadness', 'mtc_factors', 'factor_sadness', 'mood.tag.factorSadness', 'CloudRain', 2, true, 'RATED', 1, 5, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon",
|
||||
"sort_order" = EXCLUDED."sort_order", "is_active" = EXCLUDED."is_active", "kind" = EXCLUDED."kind",
|
||||
"scale_min" = EXCLUDED."scale_min", "scale_max" = EXCLUDED."scale_max", "inverse" = EXCLUDED."inverse";
|
||||
|
||||
-- Re-seat the two surviving RATED factors at the top of the `factors`
|
||||
-- category so the final order reads work → sleep quality → sadness.
|
||||
UPDATE "mood_tags" SET "sort_order" = 0 WHERE "key" = 'factor_work';
|
||||
UPDATE "mood_tags" SET "sort_order" = 1 WHERE "key" = 'factor_sleep_quality';
|
||||
|
||||
-- ─── 2. Retire the four dropped RATED factors (BINARY + inactive) ─────
|
||||
-- Flip kind so they leave the RATED set; deactivate so they leave the
|
||||
-- default picker. Historical RATED links keep their `rating` value and
|
||||
-- still resolve their label (read joins the catalog row by id, no active
|
||||
-- filter). Not deleted — references stay valid.
|
||||
UPDATE "mood_tags"
|
||||
SET "kind" = 'BINARY', "is_active" = false
|
||||
WHERE "key" IN ('factor_conflict', 'factor_family', 'factor_social', 'factor_stress');
|
||||
|
||||
-- ─── 3. Trim the default BINARY tag set (~half) ──────────────────────
|
||||
-- Deactivate the long-tail / slider-redundant binary tags. Kept set is
|
||||
-- the high-signal, plausibly-correlatable tags; dropped set is sleep
|
||||
-- tags now covered by the Schlafqualität slider, mood-level restatements
|
||||
-- (the 1..5 face already carries them), and low-signal duplicates. Rows
|
||||
-- stay so historical entries still resolve their labels.
|
||||
UPDATE "mood_tags"
|
||||
SET "is_active" = false
|
||||
WHERE "key" IN (
|
||||
-- feelings: drop tags that just restate the 5-face mood level / overlap
|
||||
-- the surviving factor_sadness; keep a tight high-signal subset.
|
||||
'content', 'unsure', 'bored', 'tense', 'stressed', 'sad',
|
||||
-- sleep: the Schlafqualität (factor_sleep_quality) slider supersedes
|
||||
-- the whole sleep-tag category.
|
||||
'slept_well', 'slept_ok', 'slept_poorly', 'early_night',
|
||||
-- health: drop the nutrition long-tail (kept: ate_well, alcohol,
|
||||
-- worked_out, walked, hydrated).
|
||||
'fast_food', 'no_sweets', 'big_meal',
|
||||
-- work: drop low-signal day-type tags (kept: productive, day_off).
|
||||
'overtime', 'travel', 'sick_day'
|
||||
);
|
||||
@@ -0,0 +1,31 @@
|
||||
# `scripts/`
|
||||
|
||||
One-shot operator and build scripts. These are not part of the running application — they are run by hand during a deploy, a backfill, or a release, or wired into CI. Recurring work belongs on pg-boss (`src/lib/jobs/`), not here.
|
||||
|
||||
## How to run them
|
||||
|
||||
```bash
|
||||
pnpm dlx tsx scripts/<file>.ts
|
||||
```
|
||||
|
||||
Use `pnpm dlx tsx`, not bare `pnpm tsx`. The production standalone image strips `tsx`, so the bare invocation fails inside the container. `scripts/` is excluded from the project typecheck.
|
||||
|
||||
## What lives here
|
||||
|
||||
- **`generate-openapi.ts`** — emits `docs/api/openapi.yaml` from the registered Zod schemas (`pnpm openapi:generate`).
|
||||
- **`check-openapi.ts`** — compares a freshly generated spec against the committed YAML; CI fails on drift (`pnpm openapi:check`).
|
||||
- **`check-env.ts`** — pre-deploy env-var sanity check against `env-manifest.json` (`pnpm check-env`); wired into CI.
|
||||
- **`assert-deploy.ts`** — post-deploy `/api/version` assertion.
|
||||
- **`rotate-encryption-key.ts`** — AES-256-GCM key rotation; see `docs/ops/encryption-key-rotation.md`.
|
||||
- **`restore-backup.ts`** — restore an off-host AES-GCM backup from an S3-compatible bucket.
|
||||
- **`backfill-rollups.ts`**, **`drain-per-sample-cumulative.ts`**, **`backfill-mood-note-column.ts`** — historical data backfills (note: the boot-time `rollup-full-backfill` queue handles new accounts automatically; the CLI is the manual fallback).
|
||||
- **`seed-demo.ts`** + **`seed-demo-v15.sql`** — populate a demo tenant.
|
||||
- **`test-notifications.ts`** — exercise the notification dispatcher + reminder check.
|
||||
- **`fetch-geolite2.sh`** — download the MaxMind GeoLite2 MMDBs (needs a licence key) before `docker build`.
|
||||
- **`install-hooks.sh`** — install the local git hooks.
|
||||
- **`generate-sw-version.mjs`**, **`print-bundle-report.mjs`** — service-worker version stamp and bundle-size report.
|
||||
- **`i18n/`** — locale tooling; **`__tests__/`** — tests for the scripts above.
|
||||
|
||||
## Conventions
|
||||
|
||||
See [`../CLAUDE.md`](../CLAUDE.md) "Tests and commands" + "Self-hosting gotchas". Operator runbooks that drive these scripts live in [`../docs/ops/`](../docs/ops/).
|
||||
@@ -0,0 +1,26 @@
|
||||
# `src/`
|
||||
|
||||
Application source. HealthLog is a Next.js 16 App Router project (`output: "standalone"`, React 19, TypeScript strict). The path alias `@/*` resolves to this directory. Everything a request touches lives under here; the layout below follows the request flow rather than alphabetical order.
|
||||
|
||||
## Request flow
|
||||
|
||||
```
|
||||
client → src/proxy.ts → src/app/<route>/route.ts → src/lib/api-handler.ts → handler body → src/lib/api-response.ts → client
|
||||
```
|
||||
|
||||
- **`proxy.ts`** — the HTTP edge (Next.js 16's renamed `middleware`). Public-path allowlist, demo-mode mutation block, server-side onboarding redirect, worker-only refusal, `x-request-id` + CSP nonce generation, every security header.
|
||||
- **`app/`** — App Router pages (RSC by default) and API routes. Every API route wraps its handler in `apiHandler`. See [`app/README.md`](app/README.md).
|
||||
- **`lib/`** — the server/runtime toolkit: `apiHandler`, auth, crypto, rollups, AI, notifications, pg-boss jobs, integrations, rate limiting. See [`lib/README.md`](lib/README.md).
|
||||
- **`components/`** — React components (shadcn/ui new-york, kebab-case files). See [`components/README.md`](components/README.md).
|
||||
|
||||
## Other directories
|
||||
|
||||
- **`hooks/`** — shared React hooks (`use-` prefix, kebab-case).
|
||||
- **`types/`** — shared TypeScript declarations.
|
||||
- **`generated/prisma/`** — the generated Prisma client (do not edit; regenerated by `prisma generate`). Imported as `@/generated/prisma`.
|
||||
- **`instrumentation.ts`** — Next.js startup hook; boots the pg-boss worker registry and boot-time backfill queues.
|
||||
- **`__tests__/`** — cross-cutting unit tests; most tests live in `__tests__/` folders next to the code they cover.
|
||||
|
||||
## Conventions
|
||||
|
||||
See [`../CLAUDE.md`](../CLAUDE.md) for the full set. The load-bearing ones: RSC by default (`"use client"` only when a hook/state/browser API needs it); every API route wraps `apiHandler` and parses its body with Zod `safeParse`; `userId` is always narrowed from session or Bearer, never accepted as a body field; TanStack Query keys come only from `src/lib/query-keys.ts`; outbound HTTP goes through `src/lib/safe-fetch.ts`.
|
||||
@@ -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).
|
||||
@@ -0,0 +1,24 @@
|
||||
# `src/components/`
|
||||
|
||||
React components. The UI primitives are shadcn/ui (new-york style, zinc base) over Radix + Tailwind 4 + Lucide; everything above the primitives is feature-grouped by surface. Components are server components unless they need a hook, state, or browser API, in which case they open with `"use client"`. Charts use Recharts 3 — replacing the chart library requires explicit approval.
|
||||
|
||||
## Primitives and shell
|
||||
|
||||
- **`ui/`** — shadcn/ui primitives (button, dialog, sheet, table, …). Generated by the shadcn CLI; keep edits minimal and on-style.
|
||||
- **`layout/`** — the app shell: sidebar, topbar, bottom nav.
|
||||
- **`providers.tsx`, `app-settings-provider.tsx`, `version-poller.tsx`** — top-level context providers and the self-healing stale-shell poller.
|
||||
- **`i18n/`** — translation context + locale switcher.
|
||||
|
||||
## Feature groups
|
||||
|
||||
- **`insights/`** — insight status, assessment cards, Coach surface, advisor cards (the largest group).
|
||||
- **`medications/`** — medication cards, the schedule wizard, intake timeline, detail history.
|
||||
- **`settings/`** — the section components behind each settings page.
|
||||
- **`measurements/`, `mood/`, `dashboard/`** — entry forms, lists, dashboard tiles.
|
||||
- **`charts/`** — Recharts wrappers.
|
||||
- **`monitoring/`** — Umami / GlitchTip client bootstrap.
|
||||
- **`gamification/`, `targets/`, `comparison/`, `doctor-report/`, `clinician/`, `onboarding/`, `admin/`** — the remaining per-surface groups.
|
||||
|
||||
## Conventions
|
||||
|
||||
Kebab-case filenames; React component exports stay PascalCase regardless. (A handful of PascalCase-filename outliers under `medications/` + `onboarding/` are pre-existing drift, noted in [`../../CLAUDE.md`](../../CLAUDE.md).) Every `t()` call needs a matching key in `messages/en.json` — guarded by `i18n-call-site-coverage.test.ts`. Data reads go through TanStack Query with keys from `@/lib/query-keys` and unwrap `(await res.json()).data`. Tests live in [`__tests__/`](__tests__/).
|
||||
@@ -320,13 +320,13 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
|
||||
client,
|
||||
);
|
||||
|
||||
// The with-site copy reads "Last: <label> · <site>"; both halves
|
||||
// must show up in the SSR string.
|
||||
// The last-line renders the "Last:" label in its own left column and
|
||||
// the value (label · site) flush right; both halves show in the SSR.
|
||||
expect(html).toContain("Last:");
|
||||
expect(html).toContain("Abdomen, lower left");
|
||||
// The next-injection helper produces one of three strings — today,
|
||||
// tomorrow, or "in N days". All three contain the localised "Next:"
|
||||
// prefix from `glp1NextInjection*` keys.
|
||||
// The next-line carries the "Next:" label in its left column,
|
||||
// independent of which of the three value variants (today / tomorrow /
|
||||
// "in N days") the helper produced.
|
||||
expect(html).toMatch(/Next:/);
|
||||
});
|
||||
|
||||
|
||||
@@ -448,11 +448,19 @@ describe("medication card symmetry — Ramipril vs Mounjaro", () => {
|
||||
expect(html).not.toContain("text-foreground/85");
|
||||
// No literal em-dash placeholder for an absent next-injection.
|
||||
expect(html).not.toContain(">—<");
|
||||
// Both lines present (next + last), so two muted <p> in the slot.
|
||||
// Both lines present (next + last). Each line is a two-column
|
||||
// label-left / value-right row (`flex … justify-between`) so the
|
||||
// label and the time read as distinct columns rather than crammed
|
||||
// onto one run of text. Two such rows in the slot.
|
||||
const slotStart = html.indexOf("min-h-[2.75rem]");
|
||||
const slot = html.slice(slotStart, slotStart + 800);
|
||||
const lines = slot.match(/<p class="text-muted-foreground">/g) ?? [];
|
||||
expect(lines.length).toBe(2);
|
||||
const slot = html.slice(slotStart, slotStart + 1200);
|
||||
const rows =
|
||||
slot.match(
|
||||
/<div class="text-muted-foreground flex items-baseline justify-between gap-3">/g,
|
||||
) ?? [];
|
||||
expect(rows.length).toBe(2);
|
||||
// The value is pinned flush-right in its own column.
|
||||
expect(slot).toContain('<span class="text-right">');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -21,10 +21,17 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
* whose last-dose line is absent the same vertical footprint as a sibling
|
||||
* that renders both lines, so the dose rows line up across the grid.
|
||||
*
|
||||
* Each card still builds the line *content* itself (the generic card emits a
|
||||
* day-label + window-range; the GLP-1 card emits a weekly-cadence prose
|
||||
* string), because the two cadences read differently — but the wrapper,
|
||||
* order, colour, and gating are now guaranteed identical.
|
||||
* Each card still builds the value *content* itself (the generic card emits a
|
||||
* day-label + window-range; the GLP-1 card emits a weekly-cadence string),
|
||||
* because the two cadences read differently — but the wrapper, order, colour,
|
||||
* label/value layout, and gating are now guaranteed identical. The GLP-1 card
|
||||
* overrides the left-column label with its appointment-phrased wording.
|
||||
*
|
||||
* Layout: when `labelled`, each line is a `flex justify-between` row with the
|
||||
* bold label pinned left and the value flush right (`text-right`), so the
|
||||
* label and the time read as two distinct columns rather than crammed onto
|
||||
* one run of text. The label truncates first on a narrow viewport; the value
|
||||
* (the time / cadence) never truncates.
|
||||
*/
|
||||
interface MedicationNextLastSlotProps {
|
||||
/**
|
||||
@@ -36,45 +43,56 @@ interface MedicationNextLastSlotProps {
|
||||
/** The last-intake line content, or null when the med has never been taken. */
|
||||
last: React.ReactNode | null;
|
||||
/**
|
||||
* When true (the generic card) the part prepends the bold
|
||||
* "Next intake" / "Last intake" labels. The GLP-1 card passes its own
|
||||
* site-aware prose (whose i18n strings already read "Next injection …" /
|
||||
* "Last injection …"), so it opts out and supplies the full line content.
|
||||
* Either way the wrapper, order, colour, spacing, gating and reserved
|
||||
* min-height are identical.
|
||||
* When true the part renders each line as a two-column row: a bold label
|
||||
* pinned left and the value flush right. The generic card uses the default
|
||||
* oral "Next intake" / "Last intake" labels; the GLP-1 card overrides them
|
||||
* with its appointment-phrased labels (see `nextLabel` / `lastLabel`).
|
||||
* When false the line renders as a single full-width run of text.
|
||||
*/
|
||||
labelled?: boolean;
|
||||
/**
|
||||
* Override for the left-column label on the next-line. Defaults to the
|
||||
* oral `medications.nextIntake` string. The GLP-1 card passes its own
|
||||
* "next appointment" wording so the value column carries only the time.
|
||||
*/
|
||||
nextLabel?: React.ReactNode;
|
||||
/** Override for the left-column label on the last-line. */
|
||||
lastLabel?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MedicationNextLastSlot({
|
||||
next,
|
||||
last,
|
||||
labelled = true,
|
||||
nextLabel,
|
||||
lastLabel,
|
||||
}: MedicationNextLastSlotProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="min-h-[2.75rem] space-y-3.5 text-sm">
|
||||
{next && (
|
||||
<p className="text-muted-foreground">
|
||||
{labelled && (
|
||||
<>
|
||||
<span className="font-medium">{t("medications.nextIntake")}</span>{" "}
|
||||
</>
|
||||
)}
|
||||
{next}
|
||||
</p>
|
||||
)}
|
||||
{last && (
|
||||
<p className="text-muted-foreground">
|
||||
{labelled && (
|
||||
<>
|
||||
<span className="font-medium">{t("medications.lastIntake")}</span>{" "}
|
||||
</>
|
||||
)}
|
||||
{last}
|
||||
</p>
|
||||
)}
|
||||
{next &&
|
||||
(labelled ? (
|
||||
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
|
||||
<span className="min-w-0 flex-shrink truncate font-medium">
|
||||
{nextLabel ?? t("medications.nextIntake")}
|
||||
</span>
|
||||
<span className="text-right">{next}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">{next}</p>
|
||||
))}
|
||||
{last &&
|
||||
(labelled ? (
|
||||
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
|
||||
<span className="min-w-0 flex-shrink truncate font-medium">
|
||||
{lastLabel ?? t("medications.lastIntake")}
|
||||
</span>
|
||||
<span className="text-right">{last}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">{last}</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -410,13 +410,14 @@ export function Glp1MedicationCard({
|
||||
|
||||
{/* Injection state — next + last, rendered through the SAME shared
|
||||
<MedicationNextLastSlot> the generic card uses, so the order
|
||||
(next-then-last), colour, spacing, gating and reserved
|
||||
min-height are identical across the two card types. The GLP-1
|
||||
phrasing carries its own "Next / Last injection" wording (and
|
||||
the rotation nudge), so it opts out of the part's bold label
|
||||
prefix and supplies the full line content. */}
|
||||
(next-then-last), colour, spacing, gating, reserved min-height
|
||||
and the label-left / value-right two-column layout are identical
|
||||
across the two card types. The GLP-1 card overrides only the
|
||||
left-column label with its appointment-phrased wording; the value
|
||||
column carries the time (plus the optional rotation nudge). */}
|
||||
<MedicationNextLastSlot
|
||||
labelled={false}
|
||||
nextLabel={t("medications.glp1NextLabel")}
|
||||
lastLabel={t("medications.glp1LastLabel")}
|
||||
next={
|
||||
next && currentWindowStatus.status !== "in_window" ? (
|
||||
<>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CheckCircle,
|
||||
Clock,
|
||||
CloudMoon,
|
||||
CloudRain,
|
||||
CloudSun,
|
||||
Dumbbell,
|
||||
Film,
|
||||
@@ -66,6 +67,7 @@ const MOOD_TAG_ICONS: Record<string, LucideIcon> = {
|
||||
CheckCircle,
|
||||
Clock,
|
||||
CloudMoon,
|
||||
CloudRain,
|
||||
CloudSun,
|
||||
Dumbbell,
|
||||
Film,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# `src/lib/`
|
||||
|
||||
The server and runtime toolkit. Everything that is not a page, component, or route handler lives here: the API kit, auth, crypto, the rollup tier, AI providers, notifications, background jobs, and the integration clients. Route handlers and RSC pages compose these modules; the modules never reach back into `app/`.
|
||||
|
||||
## Core request kit
|
||||
|
||||
- **`api-handler.ts`** — the `apiHandler` wrapper every route uses. Auth resolver (cookie OR Bearer), `requireAuth` / `requireAdmin` (admin is cookie-only by construction), idempotency plumbing, Wide-Event builder, GlitchTip forwarder.
|
||||
- **`api-response.ts`** — the `{ data, error, meta? }` envelope: `apiSuccess` / `apiError`, `safeJson`, trusted-proxy IP resolver, `returnAllZodIssues` + `sanitiseZodIssues`.
|
||||
- **`rate-limit.ts`** — Postgres-backed sliding-window limiter (single atomic upsert); multi-instance correct.
|
||||
- **`idempotency.ts`** — user-scoped idempotency keys that refuse to cache secret-shaped responses.
|
||||
- **`query-keys.ts`** — the only legal source of TanStack Query `queryKey` / `mutationKey` arrays (enforced by an ESLint rule).
|
||||
- **`safe-fetch.ts`** — the one documented outbound-egress entry (`redirect: "manual"` + timeout; optional connect-time DNS-rebinding pin). Raw `fetch(` under `lib/` + `app/` is banned by ESLint.
|
||||
- **`db.ts`** — the Prisma client singleton.
|
||||
- **`crypto.ts`** — AES-256-GCM at rest with versioned key ids, fail-closed loader, rotation primitives.
|
||||
|
||||
## Subsystems
|
||||
|
||||
- **`auth/`** — Postgres sessions, secure-cookie flag policy, passkey (WebAuthn) + Argon2id password, per-device refresh-token rotation, HMAC Bearer tokens.
|
||||
- **`ai/`** — multi-provider insight + Coach engine: `provider-chain.ts`, hand-rolled `{openai,anthropic,local,codex,mock}-client.ts`, `coach/`, `prompts/`, citation + confidence helpers.
|
||||
- **`rollups/`** — DAY/WEEK/MONTH/YEAR pre-aggregations for measurements, mood, and medication compliance. Read-swap pattern: probe the rollup, fall back to live SQL on a coverage miss.
|
||||
- **`notifications/`** — the dispatcher cascade (APNs → Telegram → ntfy → Web Push), per-channel `senders/`, retry policy, VAPID config.
|
||||
- **`jobs/`** — pg-boss workers: reminders, insight pre-generation, backups, retention sweeps, integration backfills. Recurring work belongs here, not in `scripts/`.
|
||||
- **`integrations/`**, **`withings/`**, **`whoop/`**, **`fitbit/`** — OAuth clients and sync services for the device providers; `sync/` holds the shared delta-feed plumbing.
|
||||
- **`analytics/`** — trend calculations, cadence-aware medication `compliance.ts`, correlations.
|
||||
- **`insights/`**, **`fhir/`**, **`doctor-report-*.ts`** — insight pipeline, FHIR R4 export, client-side doctor-report PDF.
|
||||
- **`openapi/`** — Zod registry that is the source of truth for `docs/api/openapi.yaml`.
|
||||
- **`logging/`**, **`observability/`**, **`monitoring/`** — Wide-Event transports and the central redaction denylists (redact at the egress boundary, not the call site).
|
||||
- **`validations/`** — shared Zod schemas, including the SSRF `isPublicUrl` floor.
|
||||
- **`i18n/`**, **`tz/`** + **`timezone.ts`**, **`format*.ts`** — translations, timezone helpers (Berlin for display, UTC in DB), locale formatting.
|
||||
|
||||
## Conventions
|
||||
|
||||
Kebab-case filenames. See [`../../CLAUDE.md`](../../CLAUDE.md) "Code conventions" and "Security-relevant patterns"; the critical-files map there cites the exact entry points. Most modules carry tests in a sibling `__tests__/`.
|
||||
@@ -481,7 +481,7 @@ export async function buildPeriodNarrativeContext(
|
||||
...DELTA_METRICS.map((m) => m.type),
|
||||
...VITALS_BASELINE_TYPES,
|
||||
...DISCOVERY_BEHAVIOURS.filter((k) => k !== "MOOD"),
|
||||
...DISCOVERY_OUTCOMES,
|
||||
...DISCOVERY_OUTCOMES.filter((k) => k !== "MOOD"),
|
||||
]),
|
||||
) as MeasurementType[];
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user