diff --git a/README.md b/README.md index d6187e135..0b028bbf0 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,24 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a --- +## How it works + +

+ Five-stage data pipeline: sources to ingest to Postgres + rollups to reads to surfaces +

+ +Every reading walks the same five-stage pipeline. **Sources** (Withings webhook, an Apple Health `export.zip`, the iPhone HealthKit batch from the v1.5 native client, manual entry, the moodLog.app webhook) reach a small set of **ingest** endpoints, which dedup against the source-priority resolver before persisting into Postgres. **Storage** keeps two shapes — a `Measurement` row per sample plus pre-aggregated DAY / WEEK / MONTH `MeasurementRollup` buckets. **Reads** probe the rollups first and fall back to live SQL when the ask is too slope-heavy or the bucket is cold. **Surfaces** — dashboard tiles, Insights cards, the AI Coach, the doctor-report PDF — all consume the same read path, so a number on a tile is always the same number the Coach cites. + +Three more diagrams cover the pieces that matter most when you're evaluating the project: + +- [`02-coach-pipeline.svg`](docs/diagrams/02-coach-pipeline.svg) — how the AI Coach grounds every reply in your own data via the snapshot builder + provider chain + Zod-validated parser. +- [`04-source-priority.svg`](docs/diagrams/04-source-priority.svg) — why steps logged simultaneously by Apple Watch, a Withings ScanWatch and the iPhone don't triple-count. +- [`05-security-model.svg`](docs/diagrams/05-security-model.svg) — the three concentric perimeters (auth, session, encrypted core) plus rate-limiter, audit-log and SSRF rails. + +The deployment topology lives further down under [Deployment](#deployment). + +--- + ## Key Features **Health Metrics** -- Track weight, blood pressure, pulse, body fat, sleep, steps, blood glucose (fasting/postprandial/random/bedtime, mg/dL ↔ mmol/L), total body water, bone mass, and pulse oximetry (SpO₂) with interactive trend charts, moving averages, and traffic-light ranges based on ESC/ESH 2018, ADA 2024, and consensus pulse-oximeter guidance (NICE NG115). Body-composition + SpO₂ metrics sync automatically from Withings Body+ scales and ScanWatch devices. @@ -145,6 +163,7 @@ Open **http://localhost:3000**. The first registered user becomes admin. | PDF | jsPDF (client-side generation) | | Testing | Vitest 4 | | Deployment | Docker (multi-stage Alpine) | +| Native client | SwiftUI iOS app (separate repo, active for v1.5) | --- @@ -457,6 +476,24 @@ The included `docker-compose.yml` runs the app and PostgreSQL. The entrypoint au The app listens on port **3000**. Place it behind Nginx, Caddy, or Traefik for TLS termination. Works out of the box with [Coolify](https://coolify.io/). +

+ Self-hosting topology: internet → reverse proxy → Next.js app + pg-boss worker → PostgreSQL, with GHCR image pull and optional Coolify autodeploy and S3 backup +

+ +For a single-process default the same container hosts both the web and worker (`HEALTHLOG_PROCESS_TYPE=all`, the default). Split them via `HEALTHLOG_PROCESS_TYPE=web` and `HEALTHLOG_PROCESS_TYPE=worker` for horizontal scale. Off-host AES-GCM weekly backups to any S3-compatible bucket (R2, B2, MinIO, AWS) are opt-in via the admin panel. See [`docs/self-hosting/`](docs/self-hosting/) and [`docs/ops/`](docs/ops/) for the full operator manual. + +--- + +## Roadmap + +| Release line | Focus | +| ------------ | ----- | +| **v1.4.x** (current) | Web maturity — Apple Health import, AI Coach, persistent rollup tier, multi-provider AI, doctor PDF, encryption-key rotation, Coolify autodeploy. Roughly weekly cadence. | +| **v1.5** (in active development) | Native iOS client (SwiftUI). The backend contract is already locked in [`docs/api/openapi.yaml`](docs/api/openapi.yaml); the iOS app lives in a separate repository and ingests via the same `/api/measurements/batch` and `/api/auth/refresh` surfaces the web uses. | +| **v2.x** (planned) | Multi-tenant hardening, expanded device passthrough (Garmin / Polar), opt-in cross-user aggregate research mode (off by default; never enabled without explicit consent). | + +The detailed changelog lives in [`CHANGELOG.md`](CHANGELOG.md); per-release audit summaries live under [`docs/audit/`](docs/audit/). + --- ## Contributing diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 803b4cfd9..8e9102cb7 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2498,6 +2498,17 @@ paths: enum: - rollup type: string + - in: query + name: groupBy + schema: + enum: + - day + type: string + - in: query + name: dayKey + schema: + pattern: ^\d{4}-\d{2}-\d{2}$ + type: string responses: "200": content: diff --git a/src/app/api/medications/[id]/intake/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/__tests__/route.test.ts index a46c65432..e5818c69d 100644 --- a/src/app/api/medications/[id]/intake/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/route.test.ts @@ -134,10 +134,7 @@ describe("GET /api/medications/[id]/intake — status filter", () => { expect(where).toEqual({ medicationId: "med-1", userId: "user-1", - OR: [ - { takenAt: { not: null }, skipped: false }, - { skipped: true }, - ], + OR: [{ takenAt: { not: null }, skipped: false }, { skipped: true }], }); }); @@ -154,10 +151,7 @@ describe("GET /api/medications/[id]/intake — status filter", () => { expect(countArgs?.where).toEqual({ medicationId: "med-1", userId: "user-1", - OR: [ - { takenAt: { not: null }, skipped: false }, - { skipped: true }, - ], + OR: [{ takenAt: { not: null }, skipped: false }, { skipped: true }], }); }); }); diff --git a/src/components/medications/__tests__/intake-history-list-v2.test.tsx b/src/components/medications/__tests__/intake-history-list-v2.test.tsx index f1843953e..315bb11fd 100644 --- a/src/components/medications/__tests__/intake-history-list-v2.test.tsx +++ b/src/components/medications/__tests__/intake-history-list-v2.test.tsx @@ -96,10 +96,7 @@ describe(" — empty state", () => { it("renders the empty-state title and CTA in English", () => { const client = makeClient(); seedIntake(client, "med-1", []); - const html = render( - , - client, - ); + const html = render(, client); expect(html).toContain("No intakes recorded yet"); expect(html).toContain("Open daily intake"); expect(html).toContain('href="/medications"'); @@ -141,10 +138,7 @@ describe(" — populated rows", () => { createdAt: "2026-05-14T20:00:00.000Z", }, ]); - const html = render( - , - client, - ); + const html = render(, client); expect(html).toContain("Taken"); expect(html).toContain("Skipped"); expect(html).toContain("Website"); @@ -238,10 +232,7 @@ describe(" — populated rows", () => { it("renders the section title across both locales", () => { const client = makeClient(); seedIntake(client, "med-1", []); - const enHtml = render( - , - client, - ); + const enHtml = render(, client); expect(enHtml).toContain("Intake history"); const deClient = makeClient(); @@ -269,10 +260,7 @@ describe(" — sort indicators", () => { createdAt: "2026-05-15T08:02:00.000Z", }, ]); - const html = render( - , - client, - ); + const html = render(, client); // Default sort = takenAt desc, so the "taken" header carries the // arrow-down icon and the "scheduled" header carries the neutral // dual-arrow icon. @@ -322,10 +310,7 @@ describe(" — sort indicators", () => { ], 50, ); - const html = render( - , - client, - ); + const html = render(, client); expect(html).toContain("Previous"); expect(html).toContain("Next"); // a11y: pagination buttons clear the 44 px mobile touch floor and @@ -352,10 +337,7 @@ describe(" — sort indicators", () => { ], 1, ); - const html = render( - , - client, - ); + const html = render(, client); expect(html).not.toContain(">Previous<"); expect(html).not.toContain(">Next<"); }); diff --git a/src/components/medications/intake-history-list-v2.tsx b/src/components/medications/intake-history-list-v2.tsx index 851793bb3..15070d4fc 100644 --- a/src/components/medications/intake-history-list-v2.tsx +++ b/src/components/medications/intake-history-list-v2.tsx @@ -17,12 +17,7 @@ import { useAuth } from "@/hooks/use-auth"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@/components/ui/card"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { EmptyState } from "@/components/ui/empty-state"; import { Table, @@ -233,8 +228,12 @@ export function IntakeHistoryListV2({ {sortIndicator("scheduledFor")} - {t("medications.intakeHistoryColStatus")} - {t("medications.intakeHistoryColSource")} + + {t("medications.intakeHistoryColStatus")} + + + {t("medications.intakeHistoryColSource")} + @@ -264,10 +263,7 @@ export function IntakeHistoryListV2({ variant="secondary" className="gap-1 bg-green-500/20 text-xs text-green-400" > -