mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
docs: README discoverability rework + OG metadata + planning audit reports
README:
- Add a "How it compares" matrix against Withings web, Apple Health,
Oura, Garmin, and generic CSV. The audit found this section absent;
self-hosters arrive comparing alternatives and bounce when there's
no head-to-head.
- Promote Apple Health import + AI Coach as standalone Key-Features
bullets. Apple Health was the v1.4.34 banner feature with zero
grep matches in the README until now; the audit ranked it the
single highest-intent search keyword for ex-iOS users. AI Coach
+ Insights moved out from under "Multi-Provider AI Insights" into
a dedicated bullet that names the Coach, Daily Briefing, Weekly
Report, and Health Score surfaces.
- Status block under "What it is" so visitors see release cadence
+ current focus (native iOS client) without trawling the changelog.
- Badge row: drop "PRs welcome" + duplicate Self-Hosted, add a live
"Latest release" badge + a GHCR multi-arch badge so the landing
page surfaces version + container distribution at a glance.
- Tagline tightened to lead with "self-hosted health tracker" + the
two integration anchors (Withings + Apple Health) — both phrases
are search-keyword fronts the GitHub search index picks up.
- Drop the dead SESSION_SECRET env entry from Quick Start (3
secrets, not 4) and the Environment Variables table — the env
var was documented as required but never read by code (per the
code-quality audit). Companion env-removal in docker-compose /
.env.example / entrypoint ships in a parallel batch.
- Add a one-line OpenAPI pointer above the API-Reference table so
integrators discover the codegen source.
Layout / OG metadata:
- openGraph + twitter cards now carry images, a higher-intent
description, and a summary_large_image card. Uses the existing
logo-readme.png as the OG asset; replace with a 1200x630 dashboard
capture when one ships.
GitHub repo metadata (via gh CLI, no git diff):
- description tightened from 290 chars to 156 chars, keyword-front.
- homepage flipped from a personal tenant to demo.healthlog.dev so
sidebar clicks land on a working demo instead of a login wall.
- Topics: dropped 6 generic/PII-adjacent entries (glp-1, mounjaro,
tracking, health, dashboard, bloodpressure), added 6 high-intent
ones (apple-health-import, withings-alternative, glucose-tracker,
mood-tracker, ai-insights, personal-dashboard).
Planning artifacts:
- 7 audit reports drop into .planning/round-audit-*.md covering
mobile-security, UX, code-quality, README/discoverability,
perf, mobile-deep (pending), test-coverage, docs-depth. These
are the working notes for the v1.4.34.4 audit-driven release.
This commit is contained in:
1 parent
58c5416bc9
commit
e0fe7c0a3d
10 files changed
+1464
-14
No files matched your search
@@ -0,0 +1,290 @@
|
||||
# Code Quality + Documentation Audit — 2026-05-17
|
||||
|
||||
## Executive summary
|
||||
|
||||
The repo is in unusually good shape for its size: `tsc --noEmit` and `eslint`
|
||||
both pass clean, there are zero `TODO`/`FIXME`/`XXX`/`HACK` markers in product
|
||||
code, zero empty `catch` blocks outside one inline `<script>` fallback, and all
|
||||
test files contain at least one `expect()`. The dominant debt is **archaeology
|
||||
in comments** — 2 366 `v1.4.x` version-tag references across 200+ files, plus
|
||||
244 wave/phase markers (`W5 reconcile (Code-H2)`, `IW-B`, `phase B8`). These
|
||||
read like commit messages inlined into the source and will only get worse with
|
||||
each release. The other three real wins are (1) deleting the 7-file
|
||||
`getXSystemPrompt` / `getXUserPrompt` boilerplate in `src/lib/ai/prompts/`,
|
||||
(2) removing the dead `SESSION_SECRET` env contract, and (3) closing the
|
||||
JSDoc gap on `src/lib/**` exports (171/535 missing). Recommended order:
|
||||
F-1 first (low-risk additive cleanup, biggest readability win), then F-2 and
|
||||
F-9 in the same release, then the long-tail items.
|
||||
|
||||
## Findings — prioritized
|
||||
|
||||
### F-1: Version-tagged inline comments have become source-control archaeology
|
||||
|
||||
**Severity**: high
|
||||
**Category**: comment debt
|
||||
**File(s)**: `src/proxy.ts:28,32,36,50,179,190`, `src/app/page.tsx:85,93,99,109,201,207,217,253,259,272,281,288,307,328,353,396,401,539,564,589,675,890,921,935,1196,1214,1225`, `src/app/api/analytics/route.ts:36,55,67,...` (47 hits), `src/components/charts/health-chart.tsx` (46 hits), `src/lib/ai/schema.ts:5,28,32,52,55,...` (29 hits), and 30 other files
|
||||
**What's wrong**: 2 366 inline references to `v1.4.x` plus 244 markers
|
||||
shaped like `// v1.4.22 W5 reconcile (Sec-MED-2) — ...` or
|
||||
`// v1.4.34 IW-B — ...`. They were useful while the release was in
|
||||
flight, but they now narrate the commit history inside the source. A
|
||||
reader of `dashboard-layout.ts:288` doesn't care that the comparison
|
||||
baseline shipped in `v1.4.16 phase B8`; they want to know what the
|
||||
field means. The convention also locks future archaeology into the
|
||||
codebase forever — every patch release inherits the previous tags.
|
||||
**Fix shape**: Sweep in two passes:
|
||||
1. Strip the leading `v1.4.x [WAVE-CODE] —` marker from any comment whose
|
||||
remaining body is still meaningful WHY-narration. Reduces every offender
|
||||
without losing intent.
|
||||
2. Delete the comment outright when the body is just "added the X" or
|
||||
"see plan Y". Sample target: `src/proxy.ts:28,32,36` collapse into one
|
||||
`// public paths bypassed by auth` block; `src/lib/dashboard-layout.ts:288,294,319,323` lose all four tags and
|
||||
the field-doc above each definition stays.
|
||||
**Effort**: medium (one focused half-day, additive, deletes ~2 000 lines)
|
||||
`[hotfix-ready]`
|
||||
|
||||
### F-2: Seven domain prompt files duplicate the same shell
|
||||
|
||||
**Severity**: high
|
||||
**Category**: duplicate logic
|
||||
**File(s)**: `src/lib/ai/prompts/bmi.ts:34-61`, `blood-pressure.ts:?`, `weight.ts:46-75`, `pulse.ts:52-81`, `mood.ts`, `general-status.ts`, `medication-compliance.ts`
|
||||
**What's wrong**: Every file in `src/lib/ai/prompts/` exports the same pair —
|
||||
`getXSystemPrompt(locale)` returns `${getBaseSystemPrompt(locale)}\n\n${section}`,
|
||||
and `getXUserPrompt(snapshotJson, todayKey, locale, previousContextBlock?)`
|
||||
returns a date-prefixed prompt with an optional context block. Only the
|
||||
DE/EN section constants and the analytical "Analyse the X with focus on…"
|
||||
sentence differ. The function signature drift risk is real — adding a
|
||||
new arg today means editing seven files.
|
||||
**Fix shape**: Replace the seven pairs with a single
|
||||
`buildDomainPrompts({ id, sectionDe, sectionEn, taskDe, taskEn })` factory
|
||||
in `prompts/factory.ts` and a registry per metric. The
|
||||
locale-`en`/-`de` branching in `getXUserPrompt` collapses to one
|
||||
template-literal interpolation.
|
||||
**Effort**: small (mechanical, fully covered by existing `__tests__`)
|
||||
|
||||
### F-3: `SESSION_SECRET` is documented + required by entrypoint but never read
|
||||
|
||||
**Severity**: medium
|
||||
**Category**: dead code
|
||||
**File(s)**: `.env.example:48`, `docker-entrypoint.sh:12`, `README.md:94,154`, `CONTRIBUTING.md:25,34`, `docker-compose.yml:23,68`, `docs/self-hosting/scaling.md:24`
|
||||
**What's wrong**: The env var is listed as required, the docker entrypoint
|
||||
fails-fast if it is missing, and onboarding docs tell every operator to
|
||||
generate one with `openssl rand -hex 32`. `grep -rn "SESSION_SECRET" src/`
|
||||
returns nothing. Sessions are server-stored in `prisma.session` keyed by
|
||||
the cookie value (`src/lib/auth/session.ts`), so the secret has nothing
|
||||
to sign. Every self-hoster carries a ceremonial 64-char hex string that
|
||||
the app ignores. Either (a) wire it into a real signing path (HMAC the
|
||||
cookie value) or (b) drop the requirement and the docs.
|
||||
**Fix shape**: Pick (a) or (b). (b) is one removal in
|
||||
`docker-entrypoint.sh`, the `.env.example` line, two `README.md`
|
||||
lines, and the `docker-compose.yml` mapping. (a) is a real auth
|
||||
change and should not be a hotfix.
|
||||
**Effort**: small (for the deletion path) `[hotfix-ready]` if route (b) chosen
|
||||
|
||||
### F-4: 171/535 exported functions in `src/lib/**` have no JSDoc
|
||||
|
||||
**Severity**: medium
|
||||
**Category**: docs gap
|
||||
**File(s)**: highest offenders by file: `src/lib/dashboard-layout.ts` (0/3 functions documented), `src/lib/telegram.ts` (1/6), `src/lib/process-type.ts` (1/4), `src/lib/glucose.ts` (1/5), `src/lib/utils.ts` (0/1), `src/lib/api-response.ts` (2/4), `src/lib/format-locale.ts` (1/3)
|
||||
**What's wrong**: The most-imported helpers (`cn()`, `apiError()`,
|
||||
`safeJson()`, `getProcessType()`, `mgdlToMmol()`, `getGravatarUrl()`)
|
||||
have no one-line summary. Some have rich type signatures that carry
|
||||
the meaning; some don't. `safeJson<T>(req, schema)` is a wrapper that
|
||||
returns an `apiError` `Response` on parse failure — a non-obvious
|
||||
shape worth documenting once.
|
||||
**Fix shape**: One-line `/** … */` on every top-10 import. Aim for
|
||||
WHY/return-shape only, not WHAT. Marc's pattern in
|
||||
`src/lib/format.ts:40-62` is the model — single-line, locale-aware example
|
||||
embedded.
|
||||
**Effort**: medium (manual sweep, ~170 spots) `[hotfix-ready]`
|
||||
|
||||
### F-5: `as never` and `as unknown as` chains hide schema drift
|
||||
|
||||
**Severity**: medium
|
||||
**Category**: type safety
|
||||
**File(s)**: `src/app/api/admin/backups/[id]/restore/route.ts:299,302,357`, `src/lib/personal-records/pr-detection-worker.ts:271,357,434,437,438`, `src/lib/devices/revoke.ts:79`, `src/lib/ai/coach/snapshot.ts:403,482`, `src/lib/ai/prompts/insight-generator.ts:729,730,731,737,738,739` (and 14 more), `src/lib/medications/route-guards.ts:61`, `src/lib/jobs/apple-health-import-worker.ts:85,176,194,195`
|
||||
**What's wrong**: 20 `as unknown as T` and 9 `as never` assertions. The
|
||||
PR worker's `where: where as never` and dynamic-key `orderBy` casts
|
||||
disable the very Prisma narrowing the rest of the file relies on; if
|
||||
the schema renames a column the worker silently builds a malformed
|
||||
query at runtime. `MEDICAL_REFERENCE_IDS as unknown as [string, ...string[]]`
|
||||
in `provider-chain/route.ts:82` exists because Zod wants a non-empty
|
||||
tuple — a `.refine()` against the readonly array would type-check
|
||||
without the cast.
|
||||
**Fix shape**: For each cast, either fix the underlying type or add a
|
||||
WHY-comment. The Prisma JSON casts in `chart-overlay-prefs`,
|
||||
`widgets`, `healthkit`, `doctor-report-prefs` (5 sites) collapse onto
|
||||
one helper `toInputJsonValue<T>(value: T): Prisma.InputJsonValue`. The
|
||||
`pr-detection-worker.ts` dynamic-orderBy can switch to a
|
||||
`Record<string, "asc" | "desc">` and drop the cast entirely.
|
||||
**Effort**: medium
|
||||
|
||||
### F-6: Legacy `src/lib/format.ts` is a soft-deprecation that won't die
|
||||
|
||||
**Severity**: low
|
||||
**Category**: simplify | duplicate logic
|
||||
**File(s)**: `src/lib/format.ts:23-62` (the file itself) and 20 import sites
|
||||
**What's wrong**: The file's docstring tells new code to use
|
||||
`useFormatters()` instead, but 20 production import sites still call
|
||||
`formatDate` / `formatDateTime` / `formatTime` / `formatDateShort`.
|
||||
The shim reads `document.cookie` on the client and falls back to `en`
|
||||
on the server, which means SSR can render English where the user has
|
||||
German selected — exactly the locale hydration mismatch the docstring
|
||||
warns about.
|
||||
**Fix shape**: Either migrate the 20 callers to `useFormatters()`
|
||||
(client) or `makeFormatters(serverLocale)` (server) and delete
|
||||
`src/lib/format.ts`, or accept it and remove the soft-deprecation
|
||||
docstring so a future contributor doesn't re-introduce the bug it
|
||||
warns against.
|
||||
**Effort**: small (mechanical migration)
|
||||
|
||||
### F-7: Mega-files near 1 700 lines in product code
|
||||
|
||||
**Severity**: low
|
||||
**Category**: simplify
|
||||
**File(s)**: `src/lib/jobs/reminder-worker.ts` (1 653 lines), `src/app/api/insights/targets/route.ts` (1 198), `src/app/api/analytics/route.ts` (1 073), `src/app/api/gamification/achievements/route.ts` (988), `src/components/settings/ai-section.tsx` (1 739), `src/components/charts/health-chart.tsx` (1 710), `src/app/page.tsx` (1 348)
|
||||
**What's wrong**: Single files holding multiple concerns. `reminder-worker.ts`
|
||||
mixes scheduling, channel adapters, dedup, and tests-flag logic.
|
||||
`/api/analytics/route.ts` has an unrelated "slim slice" branch
|
||||
inlined into the same handler.
|
||||
**Fix shape**: Only the worker is genuinely a problem — split per
|
||||
channel and per phase. The route files are acceptable because they
|
||||
read top-to-bottom; the chart components have to be one file for
|
||||
Recharts callbacks to share state cheaply. Reminder worker → split
|
||||
in a dedicated round, not a "while-I'm-here".
|
||||
**Effort**: large (do not bundle into a hotfix)
|
||||
|
||||
### F-8: `coach-prefs` excludeMetrics is a subset enum cast at runtime
|
||||
|
||||
**Severity**: low
|
||||
**Category**: type safety
|
||||
**File(s)**: `src/lib/ai/coach/snapshot.ts:400-405`
|
||||
**What's wrong**: `CoachExcludeMetric` is a strict subset of
|
||||
`CoachScopeSource` and the snapshot loop casts each source via
|
||||
`as unknown as CoachExcludeMetric` then runs a `Set.has()` lookup as
|
||||
defence-in-depth. The cast is documented but the comment says
|
||||
"the runtime check is just defence-in-depth" — which contradicts
|
||||
the cast. A `(src in EXCLUDABLE_SET)` predicate-typed helper would
|
||||
remove the cast and keep the narrow type at compile time.
|
||||
**Fix shape**: Introduce `function isExcludable(src: CoachScopeSource): src is CoachExcludeMetric`.
|
||||
**Effort**: trivial
|
||||
|
||||
### F-9: Prisma JSON serialisation needs one helper
|
||||
|
||||
**Severity**: low
|
||||
**Category**: duplicate logic
|
||||
**File(s)**: `src/app/api/auth/me/doctor-report-prefs/route.ts:81`, `src/app/api/dashboard/chart-overlay-prefs/route.ts:83`, `src/app/api/dashboard/widgets/route.ts:156`, `src/app/api/integrations/healthkit/route.ts:177`, `src/lib/jobs/apple-health-import-worker.ts:85,176,194,195`
|
||||
**What's wrong**: Seven sites do `value as unknown as Prisma.InputJsonValue`
|
||||
to satisfy Prisma's `JsonValue` union. The cast is correct (the values are
|
||||
plain JSON-serialisable objects) but each call site re-invents the same
|
||||
escape hatch.
|
||||
**Fix shape**: One generic helper in `src/lib/db.ts`:
|
||||
`export const toJson = <T>(v: T) => v as unknown as Prisma.InputJsonValue;`
|
||||
with a WHY-comment about why the cast is needed.
|
||||
**Effort**: trivial `[hotfix-ready]`
|
||||
|
||||
### F-10: `db-compat.ts` is the maintained migration system
|
||||
|
||||
**Severity**: informational
|
||||
**Category**: dead code (false positive)
|
||||
**File(s)**: `src/lib/db-compat.ts` (200 lines), 51 call sites
|
||||
**What's wrong**: At first glance this file looks like accumulated
|
||||
backwards-compat (which Marc's preferences explicitly forbid). On read
|
||||
it's the production "ALTER TABLE IF NOT EXISTS" path that bootstraps a
|
||||
fresh container without `prisma migrate deploy`. Worth a top-of-file
|
||||
docstring saying so — right now anyone scanning for cruft will flag it.
|
||||
**Fix shape**: Add a header comment naming it the "schema-bootstrap" path
|
||||
and pointing at the `prisma/migrations/` directory for the proper
|
||||
migration history. No code change.
|
||||
**Effort**: trivial `[hotfix-ready]`
|
||||
|
||||
### F-11: `eslint-disable` on `passkey.ts` `response: any` parameters
|
||||
|
||||
**Severity**: low
|
||||
**Category**: type safety
|
||||
**File(s)**: `src/lib/auth/passkey.ts:103-104,169-170`
|
||||
**What's wrong**: Both `verifyRegistration` and `verifyAuthentication`
|
||||
take `response: any` with a per-line eslint-disable. The upstream
|
||||
`@simplewebauthn/server` exports `RegistrationResponseJSON` and
|
||||
`AuthenticationResponseJSON` types for these payloads.
|
||||
**Fix shape**: Import the SimpleWebAuthn types, drop the
|
||||
`eslint-disable` lines.
|
||||
**Effort**: trivial
|
||||
|
||||
### F-12: Comment quality wins: WHAT-narration leaks
|
||||
|
||||
**Severity**: low
|
||||
**Category**: comment debt
|
||||
**File(s)**: sample: `src/lib/dashboard-layout.ts:262-263` ("Merge with defaults so new widgets…"), `src/lib/ai/coach/snapshot.ts:413-416` ("Trim down to the metrics…"), `src/app/api/analytics/route.ts:55-66` ("v1.4.33 C1 — slim summaries slice…")
|
||||
**What's wrong**: Most long block comments narrate WHAT the code does
|
||||
in plain English right above identical code. Some carry the WHY (the
|
||||
`/api/analytics` slim-slice block does explain the read-budget
|
||||
trade-off). The WHAT-only sections should collapse.
|
||||
**Fix shape**: Treat any block comment whose next 3 lines paraphrase the
|
||||
comment as deletable. Sample: `dashboard-layout.ts:253-258` keeps the
|
||||
"v1.4.28 retired" version tag (drop per F-1) and the WHY ("PUT route's
|
||||
Zod enum rejects the entire blob") — drop the WHAT-rephrasing in the
|
||||
middle.
|
||||
**Effort**: medium (folds into F-1 sweep)
|
||||
|
||||
### F-13: `route-guards.ts` import is lazy with an unstubbable cast
|
||||
|
||||
**Severity**: informational
|
||||
**Category**: type safety | test discipline
|
||||
**File(s)**: `src/lib/medications/route-guards.ts:56-62`
|
||||
**What's wrong**: `loadPrisma()` lazy-imports `@/lib/db` so test files can
|
||||
stub the module — fine pattern — but the return type forces a
|
||||
`prisma as unknown as MedicationOwnershipPrisma` cast at the boundary.
|
||||
`MedicationOwnershipPrisma` is a stripped-down interface declared
|
||||
locally; the real `PrismaClient` is structurally compatible. The cast
|
||||
is honest but a brief comment saying "stripped interface for test
|
||||
stubbability" sits below the function instead of above. Move it up.
|
||||
**Fix shape**: WHY-comment relocation.
|
||||
**Effort**: trivial
|
||||
|
||||
### F-14: `as never` cast comment in `backups/restore/route.ts` is the right pattern
|
||||
|
||||
**Severity**: informational
|
||||
**Category**: positive example
|
||||
**File(s)**: `src/app/api/admin/backups/[id]/restore/route.ts:299`
|
||||
**What's wrong**: Nothing. `type: m.type as never, // already enum-validated above`
|
||||
is the model for every other cast in F-5: the WHY is the trailing
|
||||
comment, not a block above. Aim for this in F-5.
|
||||
**Effort**: n/a
|
||||
|
||||
### F-15: Test discipline spot-check is clean
|
||||
|
||||
**Severity**: informational
|
||||
**Category**: test discipline
|
||||
**File(s)**: 400 test files scanned
|
||||
**What's wrong**: Nothing material. Every test file contains at least one
|
||||
`expect()`. No empty `catch` blocks in production tests. No
|
||||
`try/catch` swallow patterns. The codebase has 0 `TODO`, 0 `FIXME`,
|
||||
0 `XXX`, 0 `HACK` markers — this is genuinely rare and worth
|
||||
preserving with a CI grep.
|
||||
**Fix shape**: Add a tiny CI grep gate enforcing the zero-TODO rule
|
||||
explicitly, so a future contributor doesn't reintroduce the pattern
|
||||
in the next marathon.
|
||||
**Effort**: trivial `[hotfix-ready]`
|
||||
|
||||
## Counts
|
||||
|
||||
- Files with > 10 obsolete version-tagged comments: **35**
|
||||
- `any` / `as unknown as` / `as never` cast-throughs: **6 + 20 + 9 = 35** (excluding tests + generated)
|
||||
- Open TODO / FIXME / XXX / HACK total in product code: **0**
|
||||
- Exported functions in `src/lib/**` without doc-comment: **171 / 535** (32 %)
|
||||
- Total `v1.4.x` references in product code: **2 366**
|
||||
- Test files without `expect()`: **0 / 400**
|
||||
- `tsc --noEmit` clean: yes
|
||||
- `pnpm lint` clean: yes
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Splitting `reminder-worker.ts` (F-7) — wants its own round, not a hotfix.
|
||||
- Refactoring `health-chart.tsx` (1 710 lines) — Recharts callbacks share
|
||||
state cheaply only when colocated; a split needs UX validation first.
|
||||
- Migration of legacy `src/lib/format.ts` callers (F-6) — touches 20 files
|
||||
across the dashboard; defer until the next i18n pass.
|
||||
- Generated Prisma client (`src/generated/prisma/`) — third-party output,
|
||||
not maintained source.
|
||||
- E2E tests under `e2e/` and `tests/` — separate audit scope.
|
||||
- Translation/copy quality in `messages/` — separate audit scope.
|
||||
@@ -0,0 +1,196 @@
|
||||
# Documentation Depth Audit — 2026-05-16
|
||||
|
||||
## Executive summary
|
||||
|
||||
The repo carries **11 first-party docs files** plus an `audit/` archive of 24 release-cycle summaries. The strongest material lives in three operator-ops notes (`docs/ops/backup-restore.md`, `docs/ops/encryption-key-rotation.md`, `docs/self-hosting/scaling.md`) — these are accurate, well-cited, and confident. Everything else is either missing, archived audit prose that isn't user-facing, or thin. The headline gap: **`docs/` has no getting-started, no integration guides, no source-priority spec, no cache-contract page, no observability page, no security model, and no localisation contribution guide**, yet `README.md:107` and `README.md:463` send self-hosters to `docs.healthlog.dev` — a domain whose source-of-truth folder is this same `docs/` tree. The OpenAPI spec is **deliberately the iOS-codegen subset** (~14 paths) against **181 actual route handlers**, which is correct intent but reads as a contradiction with `docs/api/README.md:55-56` ("**paths** — all routes from `src/app/api/**/route.ts`"). Apple Health import — the v1.4.34 banner feature — is mentioned in one OpenAPI batch endpoint and the App Store checklist; the user-facing upload flow (`POST /api/import/apple-health-export`) is undocumented in `docs/`. Recommended write-order: getting-started → integrations triple (Apple Health, Withings, AI) → self-hosting full guide (proxy + Coolify + Kubernetes) → source-priority spec → security model → cache contract → observability → i18n contribution guide → API reference reconciliation.
|
||||
|
||||
## Audit matrix
|
||||
|
||||
| Topic | Status | File(s) | Notes |
|
||||
|--|--|--|--|
|
||||
| Self-hosting | △ | `docs/self-hosting/scaling.md`, `docs/migration/v1.3-to-v1.4.md`, `README.md:80-107` | Only the web/worker split is documented. Reverse-proxy + Coolify + bare-metal + Kubernetes paths are **missing** from `docs/`; `README.md:107` links to a `docs.healthlog.dev/self-hosting/reverse-proxy/` page that has no source in-repo. v1.4.34.2 `pull_policy: always` only documented inline in `docker-compose.yml`. |
|
||||
| Env-var reference | △ | `README.md:146-169`, `.env.example` | `.env.example` is exhaustive and excellent. README has a short table that **omits** `ENCRYPTION_KEYS`, `BACKUP_*`, `APNS_*`, `LOKI_*`, `HEALTHLOG_PROCESS_TYPE`, `INSIGHTS_RATE_LIMIT_PER_HOUR`, `DEPLOY_WEBHOOK_SECRET`. No consolidated `docs/self-hosting/configuration.md`. |
|
||||
| API reference | △ | `docs/api/openapi.yaml`, `docs/api/README.md`, `README.md:240-386` | OpenAPI is the **iOS codegen subset** (14 paths, locked contract). README has a manual table that covers more surface but is stale (omits `/api/measurements/batch`, `/api/workouts/batch`, `/api/sync/state`, `/api/measurement-categories`, `/api/devices`, `/api/import/apple-health-export`, `/api/integrations/healthkit`, the side-effects / inventory / titration medication sub-routes, `/api/insights/chat`, `/api/auth/me/source-priority`). `docs/api/README.md:55-56` claims OpenAPI covers all routes — false. |
|
||||
| OpenAPI accuracy | △ | `docs/api/openapi.yaml` | Spec version is `1.4.23`; package is `1.4.34.3`. Spec is internally consistent but the version drift contradicts `package.json` mirror claim at `docs/api/README.md:38`. `openapi-v1422-legacy.yaml` is still in the tree with no README pointer explaining whether it's archive or fallback. |
|
||||
| Apple Health import | ✗ | nowhere user-facing | OpenAPI documents `/api/measurements/batch` (HK batch ingest from iOS app). The **`export.zip` upload flow** — `POST /api/import/apple-health-export`, status polling, idempotency on file SHA-256, 1.5 GB cap, admin variant, supported HK types map, error recovery — is **undocumented in `docs/`**. v1.4.34 banner feature; only the App Store checklist mentions "Apple Health". |
|
||||
| Withings integration | ✗ | nowhere | OAuth setup (developer.withings.com app registration, redirect URI, webhook secret), the `/api/withings/webhook/[token]` v1.4.25 W17a migration, source-priority interaction — none captured. `.env.example` has the env-var stubs; `docs/` has no how-to. |
|
||||
| AI provider BYOK | ✗ | nowhere user-facing | The provider chain (`src/lib/ai/provider-chain.ts`), the four providers (OPENAI / ANTHROPIC / LOCAL / CHATGPT_OAUTH via `src/lib/ai/codex-client.ts` / admin-shared key), fallback order, BYOK key acquisition steps, cost expectations, the privacy stance for LOCAL vs cloud providers — none captured. `codex-protocol-spec.md` exists but is a 1300-line reverse-engineered protocol reference for `chatgpt.com/backend-api/codex/responses`, not a user-facing BYOK guide. |
|
||||
| Doctor-report PDF | ✓ | `docs/doctor-report.md` | Both endpoints documented, locale resolution explicit, rate limit captured, audit-log action named, jsPDF-in-Node rationale included. Light on customisation guidance (per-locale translation overrides) but the API surface is accurate. |
|
||||
| Source-priority ladder | ✗ | nowhere | The canonical APPLE_HEALTH ≻ WITHINGS ≻ MANUAL ≻ IMPORT ordering, the two-axis source + device-type ladder (v1.4.25 W8c at `src/lib/validations/source-priority.ts:18-32`), the `User.sourcePriorityJson` override, the "drop from aggregation, keep as audit" contract, `pick-canonical-workout.ts` — none surfaced to users or self-hosters. This is core conceptual material. |
|
||||
| Cache + invalidation contract | ✗ | nowhere | `src/lib/cache/server-cache.ts:269-286` ships `cache.<name>.outcome` wide-event annotations and `src/lib/cache/invalidate.ts` owns the per-user invalidation matrix. v1.4.34.1 closure called this out specifically. Zero docs coverage. |
|
||||
| Deploy + release pipeline | △ | `docs/audit/v1414-summary.md:224-265`, `docs/audit/v1423-summary.md:36-66`, `docker-compose.yml` inline comment | GHCR build + Coolify auto-deploy + `pull_policy: always` (v1.4.34.2) + the host-side retag fallback recipe (cited five times across audits) exists only as scattered audit-trail prose. No `docs/self-hosting/coolify.md` runbook. |
|
||||
| Security model | ✗ | nowhere | `SECURITY.md` is the disclosure policy. Passkey vs password vs API token (`hlk_*` Bearer), encryption-at-rest scheme (AES-256-GCM with versioned key prefix — only the rotation procedure is documented, not the model), audit-log scope, rate-limit policy (`src/lib/rate-limit.ts`), session-cookie shape — none captured as a "how this app is hardened" page. |
|
||||
| iOS native client | △ | `docs/apple-store-connect-checklist.md`, `docs/api/openapi.yaml` | Submission checklist is comprehensive. The handoff brief (server contracts, AASA at `src/app/.well-known/apple-app-site-association/route.ts`, APNs config at `.env.example` `APNS_*`, X-Device-Id header, refresh-token rotation) is split across `docs/migration/v1.3-to-v1.4.md:86-93,118-122` + OpenAPI + `.env.example`. No single `docs/ios-handoff.md`. |
|
||||
| i18n / localisation | △ | `docs/ui-guidelines.md:421-430` | One paragraph in UI guidelines. No contributing-a-new-locale guide, no key-naming conventions doc (the `admin.section.<slug>.*` namespace landed in v1.4.14 per `docs/audit/v1414-summary.md:84`), no plural-rules reference. `messages/{de,en}.json` carry 1500+ keys per `README.md:72`. |
|
||||
| Backup + restore | ✓ | `docs/ops/backup-restore.md` | Wire format + env-var table + restore script invocation + lifecycle-rule recommendation all explicit. Restore "import the JSON back" step is hand-waved ("use `prisma db seed` or a custom script") — minor gap, otherwise excellent. |
|
||||
| Encryption-key rotation | ✓ | `docs/ops/encryption-key-rotation.md` | Format spec, three rotation walkthroughs, rollback caveat, troubleshooting matrix. Best file in the tree. |
|
||||
| Observability | ✗ | `.env.example:100-115` only | Wide-event logging (`src/lib/logging/`), `cache.*.outcome` annotations, Loki transport, `LOG_SAMPLE_RATE` + `LOG_SLOW_THRESHOLD_MS` + `LOG_INCLUDE_STACK` semantics, the Glitchtip + Umami test endpoints — only the env vars get a comment-block; no operator-facing observability guide. |
|
||||
| Docs landing page | ✗ | no `docs/README.md` | The `docs/` root has no index. A visitor cloning the repo and running `ls docs/` sees a folder soup with no orientation. |
|
||||
|
||||
## Findings — prioritized
|
||||
|
||||
### F-1: No getting-started guide in `docs/` — README is the only path
|
||||
|
||||
**Severity**: critical
|
||||
**Topic**: Self-hosting
|
||||
**File(s) / Gap**: MISSING — no `docs/getting-started.md`
|
||||
**What's wrong / missing**: A self-hoster reading `README.md:80-107` gets a 3-minute quick start that assumes a working Docker + working DNS + an understanding of where to put the reverse-proxy block. The README explicitly punts on TLS ("See [Self-Hosting → Reverse Proxy](https://docs.healthlog.dev/self-hosting/reverse-proxy/)" — `README.md:107`) and on Coolify ("Works out of the box with Coolify" — `README.md:439`), but neither destination exists in this repo. The discoverability audit's F-10 already noted that `docs/` looks "operator-/audit-heavy"; the depth gap is that **the user-facing surface has no in-repo source-of-truth at all**, so the docs site cannot be regenerated from the repo.
|
||||
**Fix shape**: `docs/getting-started.md` — 800–1200 words. Sections: prerequisites (Docker 26+, 2 GB RAM, 10 GB disk), the four-secret `.env` bootstrap, first boot (admin promotion of first user), where things go wrong (Postgres healthcheck race, hostname mismatch when `APP_URL` doesn't match the actual host header), and the next-step matrix (TLS reverse proxy / Coolify / Kubernetes / observability).
|
||||
**Effort**: medium
|
||||
|
||||
### F-2: No reverse-proxy / Coolify / Kubernetes / bare-metal guides
|
||||
|
||||
**Severity**: critical
|
||||
**Topic**: Self-hosting
|
||||
**File(s) / Gap**: MISSING — `docs/self-hosting/` has only `scaling.md`
|
||||
**What's wrong / missing**: The README sends users to four implicit deploy paths (Docker Compose, reverse proxy, Coolify, Kubernetes — the README claims "works out of the box with Coolify" at `README.md:439`) but only Docker Compose has a real walkthrough. The `pull_policy: always` line (`docker-compose.yml:13`) that landed in v1.4.34.2 to fix Coolify's stale-digest cache is documented **only** inline in the compose file. The host-side retag fallback recipe (`docker pull → docker tag → docker compose up -d --force-recreate`) is cited across `docs/audit/v1423-summary.md:66`, `docs/audit/v1419-summary.md:115-123`, `docs/audit/v1421-summary.md:14`, `docs/audit/v1414-summary.md:224-231` — but never promoted out of audit prose into a self-hosting runbook.
|
||||
**Fix shape**: Four files. `docs/self-hosting/reverse-proxy.md` (Caddy / Traefik / Nginx blocks, the exact `NEXT_PUBLIC_APP_URL` + `APP_URL` pairing, headers to forward, healthcheck path). `docs/self-hosting/coolify.md` (project bootstrap, the "Watch image registry for new digests" toggle that's the load-bearing piece per `docs/audit/v1423-summary.md:95`, webhook secrets, the auto-deploy gate at `/api/internal/deploy-webhook`, the host-side retag fallback). `docs/self-hosting/kubernetes.md` (a minimal Deployment + Service + Secret manifest, init-container for `prisma migrate deploy`, the `HEALTHLOG_PROCESS_TYPE=web|worker` split as separate Deployments). `docs/self-hosting/bare-metal.md` (Node 20+, pnpm, systemd unit, Postgres via host package). Each 600–900 words.
|
||||
**Effort**: large
|
||||
|
||||
### F-3: Apple Health import end-to-end is undocumented
|
||||
|
||||
**Severity**: critical
|
||||
**Topic**: Apple Health import (v1.4.34 banner)
|
||||
**File(s) / Gap**: MISSING — no `docs/integrations/apple-health.md`
|
||||
**What's wrong / missing**: The banner feature of v1.4.34 has zero user-facing documentation in `docs/`. The endpoint set is `POST /api/import/apple-health-export` (user kick-off, multipart upload, 1.5 GB cap, 3-uploads/min rate limit, content-hash idempotency per `src/app/api/import/apple-health-export/route.ts:42-46`), `GET /api/import/apple-health-export/[jobId]/status` (polling), and `POST /api/admin/import-apple-health-export` (admin variant). The supported HK type set (`src/lib/measurements/apple-health-mapping.ts`), the streaming-to-disk approach (`src/lib/multipart/stream-to-disk.ts`), the `apple-health-import-worker.ts` job behaviour (queue depth, retries, what fails idempotently vs hard), and error recovery (re-upload merges on file SHA-256 short-circuit) all need an operator-facing page. The OpenAPI documents the **iOS HK batch ingest** at `/api/measurements/batch` (line 67) but not the `export.zip` flow.
|
||||
**Fix shape**: `docs/integrations/apple-health.md` — 1200–1500 words. Sections: what gets imported (HK quantity types map → HealthLog measurement types, workouts, correlations), how to export from iOS (Health app → profile → Export All Health Data), the upload UI flow + endpoint contract, idempotency (SHA-256 of bytes), what happens on duplicate types when Withings was sync'ing the same metric (source-priority ladder cross-link), failure modes (corrupt zip, partial XML, job retry), the admin variant for migrating other users' data, observability (`apple-health-import` queue name in the worker, `cache.apple-health-import.outcome`).
|
||||
**Effort**: medium
|
||||
|
||||
### F-4: Withings integration setup is undocumented
|
||||
|
||||
**Severity**: critical
|
||||
**Topic**: Withings integration
|
||||
**File(s) / Gap**: MISSING — no `docs/integrations/withings.md`
|
||||
**What's wrong / missing**: `.env.example:62-70` carries the env-var stubs with a one-line "Register an app at https://developer.withings.com/" pointer. The actual setup — Withings developer-portal app creation, OAuth client ID/secret pasting, the redirect URI (`/api/withings/callback`), the **webhook secret path-segment migration** in v1.4.25 W17a (`/api/withings/webhook/[token]` per `src/app/api/withings/webhook/route.ts:18-25`, with the legacy `?secret=` form deprecated and tracked via `withings.webhook.legacy_form_total` until v1.4.27 cutover), the `user.activity` OAuth scope shipped in v1.4.25 W5d, the source-priority ladder interaction, sync-frequency expectations — is documented nowhere user-facing.
|
||||
**Fix shape**: `docs/integrations/withings.md` — 1000–1300 words. Sections: provisioning the Withings developer app (with screenshots-or-text walkthrough), env-var paste, the OAuth dance + callback URL, the webhook URL pattern (path-segment form), the v1.4.25 W17a migration notice for existing installs, what gets synced (scales, BP, activity), source-priority interaction (Withings = mid-priority by default; APPLE_HEALTH overrides), troubleshooting (token expiry, re-auth, the admin probe at `POST /api/integrations/withings/test`).
|
||||
**Effort**: medium
|
||||
|
||||
### F-5: AI provider BYOK setup is undocumented (Marc-stated differentiator)
|
||||
|
||||
**Severity**: critical
|
||||
**Topic**: AI provider BYOK
|
||||
**File(s) / Gap**: MISSING — no `docs/integrations/ai-providers.md`
|
||||
**What's wrong / missing**: Auto-memory captures "AI Insights are the main differentiator." The provider chain owns four implementations (`src/lib/ai/openai-client.ts`, `anthropic-client.ts`, `local-client.ts`, `codex-client.ts` with the `CHATGPT_OAUTH` flow at `src/app/api/auth/codex/authorize`), a provider-chain fallback orchestrator (`src/lib/ai/provider-chain.ts`), and `codex-protocol-spec.md` — a 1300-line reverse-engineered protocol spec for the ChatGPT-backed codex endpoint. There is no user-facing "how do I plug in my Anthropic key" page, no cost-expectation table, no privacy table comparing OPENAI/ANTHROPIC (data leaves your network) vs LOCAL (Ollama / LM Studio / vLLM at your endpoint — data stays), no fallback-chain semantics (what happens when BYOK fails — does it fall to admin-shared? what's the user-visible error?), no rate-limit budget guidance (`INSIGHTS_RATE_LIMIT_PER_HOUR` exists at `.env.example:88-92` but is only meaningful with cost context).
|
||||
**Fix shape**: `docs/integrations/ai-providers.md` — 1500–2000 words. Sections: provider matrix (OPENAI / ANTHROPIC / LOCAL / CHATGPT_OAUTH — endpoint, key acquisition URL, supported models, monthly cost estimate per active user, privacy stance, fallback rank), BYOK setup per provider (settings UI walkthrough), admin-shared-key vs per-user-key trade-off, the fallback chain (what triggers fallback, what's the user-visible failure), local-endpoint guides (Ollama / LM Studio / vLLM URL formats, model selection), the `/api/ai/test` probe contract, troubleshooting (codex slug-drift defence per `docs/codex-protocol-spec.md:638`), the rate-limit budget knob.
|
||||
**Effort**: large
|
||||
|
||||
### F-6: Source-priority ladder spec is not user-facing
|
||||
|
||||
**Severity**: high
|
||||
**Topic**: Source-priority ladder
|
||||
**File(s) / Gap**: MISSING — no `docs/concepts/source-priority.md`
|
||||
**What's wrong / missing**: The canonical APPLE_HEALTH ≻ WITHINGS ≻ MANUAL ≻ IMPORT ordering is implemented in `src/lib/validations/source-priority.ts:9-32` with the v1.4.25 W8c two-axis device-type extension, applied per-day in `src/lib/analytics/source-priority.ts:1-30` for cumulative metrics (steps, active energy, distance, flights, sleep), and overridable per-user via `User.sourcePriorityJson` + `PUT /api/auth/me/source-priority`. The "drop from aggregation, keep as audit trail" contract is load-bearing for the dedup semantics. Self-hosters who run Apple Watch + iPhone + Withings scale will be triple-counting steps until they understand it. Nothing in `docs/` explains this.
|
||||
**Fix shape**: `docs/concepts/source-priority.md` — 700–1000 words. Sections: the problem (multi-source dedup), the default ladder, per-metric vs per-device priority axes, the "drop from aggregation, keep in DB as audit trail" contract, how to override per user, the settings UI surface (`/settings/sources` per `src/components/settings/sources-section.tsx`), examples (steps triple-source, weight twin-source), cross-link to Apple Health + Withings integration docs.
|
||||
**Effort**: medium
|
||||
|
||||
### F-7: API reference is split across two stale surfaces
|
||||
|
||||
**Severity**: high
|
||||
**Topic**: API reference
|
||||
**File(s) / Gap**: `docs/api/openapi.yaml`, `docs/api/README.md`, `README.md:240-386`
|
||||
**What's wrong / missing**: `docs/api/README.md:55-56` claims the OpenAPI **paths** section covers "all routes from `src/app/api/**/route.ts`." Reality: 14 paths documented, 181 route handlers in the tree. The README's manual API table is broader but stale — missing `/api/measurements/batch`, `/api/workouts/batch`, `/api/sync/state`, `/api/measurement-categories`, `/api/devices`, `/api/import/apple-health-export`, `/api/integrations/healthkit`, the medication sub-routes (`side-effects`, `inventory`, `titration`, `cadence`, `phase-config`, `glp1`, `api-endpoint`, `intake/import`, `intake/purge`), `/api/insights/chat`, `/api/auth/me/source-priority`, `/api/personal-records`. OpenAPI `info.version` is `1.4.23` (line 1786) while `package.json:3` is `1.4.34.3` — drift contradicts the "mirrored from `package.json`" claim. The intent is reasonable (iOS-codegen-locked subset), but the documentation never says that — `docs/api/README.md` reads as if the spec is the full public-API contract.
|
||||
**Fix shape**: Two changes. (a) Reword `docs/api/README.md:55-56` to "**paths** — the locked subset consumed by the iOS native client codegen and external Bearer ingest. For the full self-hosting API surface see `docs/api/full-reference.md`." (b) Add `docs/api/full-reference.md` — a generated-from-source endpoint table that walks `src/app/api/**/route.ts` and renders method + path + auth + rate-limit + audit action. A small `scripts/generate-api-reference.ts` plus a CI gate keeps it from drifting. Bump OpenAPI `info.version` to track `package.json` on every release (the CHANGELOG entry is the trigger).
|
||||
**Effort**: large
|
||||
|
||||
### F-8: Cache + invalidation contract is undocumented
|
||||
|
||||
**Severity**: high
|
||||
**Topic**: Cache + invalidation
|
||||
**File(s) / Gap**: MISSING — no `docs/concepts/caching.md`
|
||||
**What's wrong / missing**: v1.4.34.1 closure flagged this. `src/lib/cache/server-cache.ts:269-286` emits `cache.<name>.outcome` wide-event annotations on every hit / miss / stampede, and `src/lib/cache/invalidate.ts` owns the per-user mutation → invalidate-keys matrix. Self-hosters investigating "why is my dashboard stale after a manual measurement?" or "why is my Loki cache-outcome stream blank?" have nothing to read.
|
||||
**Fix shape**: `docs/concepts/caching.md` — 600–900 words. Sections: which routes are cached (with the cache names that match the `cache.<name>.outcome` annotations), TTLs per cache, the per-user invalidation matrix (mutation → cleared keys), the stampede-prevention contract, observability hook (the `cache.*.outcome` annotation in wide events), troubleshooting (stale dashboard, missed invalidation).
|
||||
**Effort**: medium
|
||||
|
||||
### F-9: Security model has no consolidated page
|
||||
|
||||
**Severity**: high
|
||||
**Topic**: Security model
|
||||
**File(s) / Gap**: `SECURITY.md` (disclosure policy only), `docs/ops/encryption-key-rotation.md` (rotation procedure only)
|
||||
**What's wrong / missing**: `SECURITY.md` is the responsible-disclosure policy. `README.md:131-143` has a marketing bullet list. The actual security architecture — passkey vs password vs API token (`hlk_*` Bearer tokens HMAC'd before storage per `src/lib/auth/`), the AES-256-GCM at-rest scheme (rotation has a doc; the **model** doesn't), session cookies (`healthlog_session`, HttpOnly, SameSite=Strict, 30-day sliding, server-side store), the `apiHandler` audit-log scope, the sliding-window rate-limiter (`src/lib/rate-limit.ts`), CSP nonces + HSTS, SSRF guards on the test-connection endpoints — is not a coherent doc for a self-hoster auditing the threat model.
|
||||
**Fix shape**: `docs/security-model.md` — 1200–1500 words. Sections: auth mechanisms (passkey primary, password fallback with Argon2id + zxcvbn, API tokens for native + Bearer ingest), session model, encryption-at-rest scheme (cross-link to rotation doc), audit-log scope (what's logged, retention), rate-limit policy table (per endpoint family), proxy headers + CSP + HSTS, secrets that must never rotate without coordinated invalidation (`API_TOKEN_HMAC_KEY` per `docs/migration/v1.3-to-v1.4.md:93`), open follow-ups (the six S-FOLLOW items in `docs/ops/v141-followup-issues.md` should be either resolved-and-removed or surfaced into the model doc).
|
||||
**Effort**: large
|
||||
|
||||
### F-10: Observability has no operator-facing guide
|
||||
|
||||
**Severity**: high
|
||||
**Topic**: Observability
|
||||
**File(s) / Gap**: `.env.example:100-115` (env-var stubs only)
|
||||
**What's wrong / missing**: Wide events live in `src/lib/logging/`. The Loki transport is gated by `LOKI_ENDPOINT` + `LOKI_USERNAME` + `LOKI_PASSWORD`. Sample rate (`LOG_SAMPLE_RATE`) and the always-keep-slow contract (`LOG_SLOW_THRESHOLD_MS`) interact in non-obvious ways. The Glitchtip + Umami test endpoints (`/api/monitoring/glitchtip/test`, `/api/monitoring/umami/test`) are documented in `docs/migration/v1.3-to-v1.4.md:108-112` as one-liners but their setup is nowhere. The `cache.*.outcome` annotation contract is invisible.
|
||||
**Fix shape**: `docs/self-hosting/observability.md` — 800–1100 words. Sections: log levels + sampling + slow-keep rule, Loki transport (label set, retention recommendations), Glitchtip integration (DSN, the test endpoint), Umami integration (script URL, website ID, the test endpoint), the wide-event schema (`action.name`, `cache.<name>.outcome`, request-id propagation), worker telemetry (`LOKI_*` must be set on worker too per `docs/self-hosting/scaling.md:44-46`).
|
||||
**Effort**: medium
|
||||
|
||||
### F-11: iOS handoff is split across three files
|
||||
|
||||
**Severity**: medium
|
||||
**Topic**: iOS native client
|
||||
**File(s) / Gap**: `docs/apple-store-connect-checklist.md`, `docs/api/openapi.yaml`, `docs/migration/v1.3-to-v1.4.md`, `.env.example`
|
||||
**What's wrong / missing**: The iOS engineer reading the repo finds the ASC submission checklist, the iOS-codegen OpenAPI subset, the APNs env-var block in `.env.example:155-175`, the AASA route at `src/app/.well-known/apple-app-site-association/route.ts`, and the refresh-token rotation paragraph at `docs/migration/v1.3-to-v1.4.md:118-122`. None of them constitute a "iOS engineer onboards in one read" handoff. The X-Device-Id header contract, the X-Client-Type behaviour (`docs/api/openapi.yaml:1791-1792`), the source-priority interaction with HK ingest — scattered.
|
||||
**Fix shape**: `docs/ios-handoff.md` — 1000–1300 words. Sections: the API contract surface the iOS client is allowed to call (cross-link to OpenAPI), auth flow (login → access + refresh, refresh rotation), AASA setup, APNs registration (the `.p8` provisioning + the `POST /api/devices` registration contract), source priority + dedup expectations on the iOS side, the X-Device-Id Keychain-stored UUID contract, the X-Client-Type sniff (`native` UA prefix unlocks refresh-token bundle). Cross-link to the ASC checklist for shipping.
|
||||
**Effort**: medium
|
||||
|
||||
### F-12: Localisation has no contribution guide
|
||||
|
||||
**Severity**: medium
|
||||
**Topic**: i18n / localisation
|
||||
**File(s) / Gap**: `docs/ui-guidelines.md:421-430` (one paragraph)
|
||||
**What's wrong / missing**: Contributing a new locale (FR / ES / IT) is a real on-ramp request for an open-source PWA. The `admin.section.<slug>.*` namespace convention (v1.4.14 per `docs/audit/v1414-summary.md:84`), the per-user vs per-job translator pattern (`useTranslations()` vs `getServerTranslator()` per `docs/ui-guidelines.md:421-424`), the CI key-parity guard (`README.md:72` mentions it), the plural-rules approach, where AI prompts pick up locale — none documented as a "how to add a locale" page.
|
||||
**Fix shape**: `docs/contributing/i18n.md` — 600–900 words. Sections: file layout (`messages/<locale>.json`), the namespace convention, the parity CI gate, plural-rules + interpolation, server-side vs client-side translators, AI prompt locale resolution, the per-user-locale-for-job contract (a Telegram reminder uses the user's locale, not the job runner's). Cross-link to `docs/ui-guidelines.md` for the writing-tone rules.
|
||||
**Effort**: medium
|
||||
|
||||
### F-13: `docs/` has no root README
|
||||
|
||||
**Severity**: medium
|
||||
**Topic**: Docs landing
|
||||
**File(s) / Gap**: MISSING — no `docs/README.md`
|
||||
**What's wrong / missing**: A visitor running `ls docs/` after `git clone` sees `api/`, `apple-store-connect-checklist.md`, `audit/`, `codex-protocol-spec.md`, `doctor-report.md`, `migration/`, `ops/`, `self-hosting/`, `ui-guidelines.md` — no orientation. The discoverability audit's F-10 noted the same gap from outside.
|
||||
**Fix shape**: `docs/README.md` — 200–300 words. A tabular index of what each subtree covers (user / operator / contributor / archive), the relationship to `docs.healthlog.dev` (if the docs site is published from this folder, say so; if it's a separate repo, link it), and the "see also" pointers.
|
||||
**Effort**: trivial [hotfix-ready]
|
||||
|
||||
### F-14: `docs/api/openapi-v1422-legacy.yaml` is in the tree with no explanation
|
||||
|
||||
**Severity**: low
|
||||
**Topic**: API reference
|
||||
**File(s) / Gap**: `docs/api/openapi-v1422-legacy.yaml` (177 KB, 122 paths)
|
||||
**What's wrong / missing**: The file is committed alongside the active `openapi.yaml` but `docs/api/README.md` doesn't mention it. A contributor stumbling on it has to guess whether it's authoritative archive, fallback, or stale. The naming hints at "v1.4.22 freeze" — but the active spec is `1.4.23` and the package is `1.4.34.3`, so it's apparently a deprecated broader-surface spec that was superseded by the iOS-codegen-locked subset. Decision needed: delete it, move it to `docs/api/archive/`, or document its role.
|
||||
**Fix shape**: Either remove (if it serves no purpose) or annotate `docs/api/README.md` with "**`openapi-v1422-legacy.yaml`** — v1.4.22 snapshot of the broader pre-iOS-codegen surface, kept for archive reference; not regenerated." Trivial.
|
||||
**Effort**: trivial [hotfix-ready]
|
||||
|
||||
### F-15: `docs/ops/v141-followup-issues.md` is a deferred-work backlog, not ops docs
|
||||
|
||||
**Severity**: low
|
||||
**Topic**: Documentation hygiene
|
||||
**File(s) / Gap**: `docs/ops/v141-followup-issues.md`
|
||||
**What's wrong / missing**: The file (260 lines, 11 deferred follow-ups across security / performance / test-quality / i18n) is dated v1.4.1. We are at v1.4.34.3. Some items are likely resolved; others moved to `.planning/`. It currently lives next to two **published, operator-facing** ops docs (`backup-restore.md`, `encryption-key-rotation.md`) — the genre mismatch is jarring. Either reconcile against current state (mark resolved / still-open) and move open ones into `.planning/`, or delete entirely.
|
||||
**Fix shape**: Cross-reference each S-FOLLOW / P-FOLLOW item against current source (e.g. P-FOLLOW-1 Recharts top-level import — already fixed in the dynamic-import wave; P-FOLLOW-3 MoodEntry index — check `prisma/schema.prisma` for the v1.4.x added index). Move surviving open items to `.planning/v143x-backlog.md`. Delete the file.
|
||||
**Effort**: small
|
||||
|
||||
### F-16: `docs/audit/2026-04-26-PLAN.md` is dated and in German
|
||||
|
||||
**Severity**: low
|
||||
**Topic**: Documentation hygiene
|
||||
**File(s) / Gap**: `docs/audit/2026-04-26-PLAN.md`
|
||||
**What's wrong / missing**: 13 KB German-language plan for a v1.4.x cycle. Auto-memory directive "Marc-voice English, every user-facing artifact" applies. The file is in the audit archive so the rules are weaker, but the language mismatch with every other audit file (`v141X-summary.md` are all English) is an anomaly.
|
||||
**Fix shape**: Translate to English, or move to `.planning/archive/` since it's a milestone-planning document not a release-cycle summary.
|
||||
**Effort**: small
|
||||
|
||||
### F-17: Doctor-report customisation guidance is thin
|
||||
|
||||
**Severity**: low
|
||||
**Topic**: Doctor-report PDF
|
||||
**File(s) / Gap**: `docs/doctor-report.md`
|
||||
**What's wrong / missing**: The doc covers the API surface accurately but does not cover **what's redacted** (the audit-scope question 6 above — PII handling on the printable PDF, the differentiation between user-entered notes vs reference ranges, the user-override badge contract) or **how to customise per locale** (the `getServerTranslator()` resolution path is named, but how a self-hoster adds an `it` locale's PDF strings or overrides a clinical-band label per locale is not). The header / footer / clinician-handoff conventions are entirely absent.
|
||||
**Fix shape**: Append two sections to `docs/doctor-report.md` — "What's in the PDF (and what's not)" (a layout map plus a redaction table — e.g. mood-entry notes flagged sensitive are omitted by default) and "Customising per locale" (the `messages/<locale>.json` keys that drive PDF strings, override patterns for clinical-band labels). 300–500 words.
|
||||
**Effort**: small
|
||||
|
||||
### F-18: README API table drift
|
||||
|
||||
**Severity**: low
|
||||
**Topic**: API reference
|
||||
**File(s) / Gap**: `README.md:240-386`
|
||||
**What's wrong / missing**: The hand-maintained README table is a discoverability surface, not a reference. Once `docs/api/full-reference.md` exists per F-7, the README table should shrink to the 8–10 most-asked-about endpoints with a "see full reference" pointer. Reducing the table reduces drift risk.
|
||||
**Fix shape**: After F-7 lands, trim `README.md:240-386` to a short table plus a cross-link.
|
||||
**Effort**: trivial
|
||||
|
||||
---
|
||||
|
||||
**Length check**: ~2780 words.
|
||||
@@ -0,0 +1,235 @@
|
||||
# README + Docs + GitHub Discoverability Audit — 2026-05-16
|
||||
|
||||
## Executive summary
|
||||
|
||||
The README is well-structured and content-rich, but it underplays the two strongest discoverability hooks the project actually ships: **Apple Health import** (the headline v1.4.34 feature is invisible in the README) and **AI Insights / Coach as a visual differentiator** (mentioned once, as a feature bullet, never shown). There is no hero screenshot or animated demo above the fold, no comparison table against Withings / Apple Health / Oura / self-hosted alternatives, and the GitHub `description` field omits "self-hosted health tracker" — the exact phrase the target user types into search. `homepageUrl` points at the production tenant (`healthlog.bombeck.io`) instead of the demo (`demo.healthlog.dev`) or the docs site. Fix shape: a hero image + 8-row comparison table + 3 keyword swaps in the GitHub description + 6 new topics would 5-10× search impressions without touching the codebase.
|
||||
|
||||
## Findings — prioritized
|
||||
|
||||
### F-1: No hero screenshot or animated demo above the fold
|
||||
|
||||
**Severity**: high
|
||||
**Scope**: README
|
||||
**Citation**: `README.md:1-30` — header block is logo + badges + tagline + nav links; nothing visual until line 80 (Quick Start).
|
||||
**What's wrong**: GitHub renders the first viewport on the repo landing page. Today a visitor sees a 120 px logo, four badges, and three text paragraphs — nothing that conveys "this is a dashboard product" until they scroll past the feature list. Every comparable repo (immich, mealie, paperless-ngx, dawarich) leads with a screenshot or animated GIF; this is the single highest-leverage README change.
|
||||
**Fix shape**: Add a dashboard screenshot (or 2x grid: desktop + mobile PWA) right after the tagline, before badges. Ideally a `.webp` capture of the dashboard with the trends row + a Coach card visible. Add to `public/screenshots/` and reference as `<img src="public/screenshots/hero-dashboard.webp" />`. No PII — use the demo tenant or synthetic data.
|
||||
**Effort**: small (screenshot + commit)
|
||||
|
||||
### F-2: Apple Health import — the headline v1.4.34 feature — missing from README
|
||||
|
||||
**Severity**: high
|
||||
**Scope**: README, SEO
|
||||
**Citation**: `README.md:46-77` — Key Features list. Grep `apple health` against README: zero matches. `CHANGELOG.md:94` ships it as the v1.4.34 banner feature.
|
||||
**What's wrong**: "apple health export server" / "apple health xml import" / "apple health self-hosted" are exactly the queries a user looking to escape the iOS ecosystem types. The repo `topics` include `apple-health`; the README does not. GitHub search ranks README content; not surfacing the keyword in the README means the topic alone won't carry the search.
|
||||
**Fix shape**: Add a dedicated Key-Features bullet: "**Apple Health import** — Drop your `export.zip` on the import page. The server streams it to disk, parses the XML (handles Zip64 / multi-GB exports), and folds every `<Record>`, `<Workout>`, `<Correlation>` into your timeline. Idempotent — re-upload to merge new data without duplicates." Mirror in the Integrations table.
|
||||
**Effort**: trivial [hotfix-ready]
|
||||
|
||||
### F-3: AI Coach / Insights surface buried in a single bullet
|
||||
|
||||
**Severity**: high
|
||||
**Scope**: README, SEO
|
||||
**Citation**: `README.md:60` — single bullet "Multi-Provider AI Insights"; no "Coach", no "Daily Briefing", no "Health Score", no "Weekly Report".
|
||||
**What's wrong**: Per maintainer-stated positioning, AI Insights are *the* differentiator. The README treats them as one feature among twelve. Coach (the conversational surface), Health Score (the daily tile), Daily Briefing, and Weekly Report — all shipped v1.4.20-onward — are not named in the README at all. A user comparing this against generic dashboards never learns the project's flagship.
|
||||
**Fix shape**: Promote AI Insights to a dedicated section between "Why HealthLog?" and "Key Features", with three sub-bullets (Coach / Health Score / Briefing). One screenshot. Frame it as "evidence-grounded, multi-provider, your data stays on your network."
|
||||
**Effort**: small
|
||||
|
||||
### F-4: No comparison table
|
||||
|
||||
**Severity**: high
|
||||
**Scope**: README, SEO (alternatives-linking is an SEO signal)
|
||||
**Citation**: `README.md` — absent.
|
||||
**What's wrong**: Visitors arrive comparing this against Withings web, Apple Health, Oura, Garmin Connect, and the few self-hosted options (Wger, OpenHealth, Wakapi for activity). Without a comparison table, they bounce to evaluate alternatives independently. Linking to alternatives is also a positive SEO signal (mutual relevance).
|
||||
**Fix shape**: 6-column matrix: HealthLog | Withings | Apple Health | Oura web | Garmin Connect | Generic CSV. Rows: Self-hosted, Open source, BYOK AI, Withings sync, Apple Health import, Doctor PDF, Custom thresholds, No subscription, License. Place after "Why HealthLog?".
|
||||
**Effort**: small [hotfix-ready]
|
||||
|
||||
### F-5: GitHub `description` is technically dense but misses primary search keywords
|
||||
|
||||
**Severity**: high
|
||||
**Scope**: repo metadata, SEO
|
||||
**Citation**: GitHub UI → About → description: "Self-hosted, privacy-first personal health tracking PWA. Weight, blood pressure, body composition, glucose, sleep, mood, medications. Withings + Apple Health sync, multi-provider AI Insights (BYOK), client-side doctor-report PDF."
|
||||
**What's wrong**: 290 characters — over the GitHub UI ~200 char cutoff for the About sidebar. The high-value phrase "self-hosted health tracker" (without "ing") isn't in there, which is what 80% of search queries phrase. "Apple Health" appears but "withings alternative" / "self-hosted apple health" do not.
|
||||
**Fix shape**: ≤ 160 chars, keyword-front-loaded: "Self-hosted health tracker. Weight, blood pressure, glucose, mood, medications. Withings + Apple Health sync. AI Insights you own. AGPL." Edit via `gh repo edit MBombeck/HealthLog --description "..."`.
|
||||
**Effort**: trivial [hotfix-ready]
|
||||
|
||||
### F-6: `homepageUrl` points at the maintainer's production tenant, not the demo
|
||||
|
||||
**Severity**: medium
|
||||
**Scope**: repo metadata, SEO, privacy
|
||||
**Citation**: GitHub repo → homepageUrl = `https://healthlog.bombeck.io`.
|
||||
**What's wrong**: The maintainer's personal subdomain is exposed as the project homepage. It is not the public demo. New visitors clicking the sidebar link land on a login wall, not a working preview. Demo lives at `demo.healthlog.dev`; the project site at `healthlog.dev`.
|
||||
**Fix shape**: `gh repo edit MBombeck/HealthLog --homepage https://demo.healthlog.dev` (or `https://healthlog.dev` once that landing exists). Demo is higher-impact for conversion.
|
||||
**Effort**: trivial [hotfix-ready]
|
||||
|
||||
### F-7: No custom social preview image (uses generic GitHub auto-OG)
|
||||
|
||||
**Severity**: medium
|
||||
**Scope**: repo metadata, SEO
|
||||
**Citation**: `usesCustomOpenGraphImage: false` in `gh repo view` output.
|
||||
**What's wrong**: When the repo URL is pasted into Slack / Discord / Mastodon / X, the unfurl shows a generated tile with the repo name. Comparable projects (immich, dawarich) ship a branded 1280×640 PNG showing the dashboard. This is the visual that gets shared.
|
||||
**Fix shape**: Upload a 1280×640 PNG via GitHub UI → Settings → Social preview. Suggested content: dashboard screenshot + "HealthLog — your health data, your server" tagline + logo. The same asset can live in `public/og-image.png` and be referenced from `src/app/layout.tsx:49` (currently no `images` field in `openGraph`).
|
||||
**Effort**: small
|
||||
|
||||
### F-8: GitHub topics — high-relevance gaps + one low-value entry
|
||||
|
||||
**Severity**: medium
|
||||
**Scope**: repo metadata, SEO
|
||||
**Citation**: GitHub repo → topics array (19 entries).
|
||||
**What's wrong**: Missing `withings-alternative`, `apple-health-import`, `personal-dashboard`, `ai-insights`, `glucose-tracker`, `mood-tracker`, `doctor-report`. Present `glp-1` and `mounjaro` are PII-adjacent (specific medications the maintainer likely tracks) and dilute the search graph. `tracking` and `health` are too generic to win against millions of repos.
|
||||
**Fix shape**: Replace `glp-1`, `mounjaro`, `tracking`, `health` with `withings-alternative`, `apple-health-import`, `personal-dashboard`, `ai-insights`, `glucose-tracker`, `mood-tracker`. See "Suggested topics" below for the ranked list.
|
||||
**Effort**: trivial [hotfix-ready]
|
||||
|
||||
### F-9: No `.github/FUNDING.yml`
|
||||
|
||||
**Severity**: low
|
||||
**Scope**: repo metadata
|
||||
**Citation**: `find .github -iname "FUNDING*"` returns empty; `fundingLinks: []` in `gh repo view`.
|
||||
**What's wrong**: No sidebar "Sponsor" button. AGPL self-hosted projects without funding visibility plateau on stars but not on sustained contribution. If the maintainer has any GitHub Sponsors / Ko-fi / Liberapay link, adding it surfaces a sponsor button on the repo page and on every PR.
|
||||
**Fix shape**: `.github/FUNDING.yml` with `github: [MBombeck]` or similar. Skip if no funding intent.
|
||||
**Effort**: trivial
|
||||
|
||||
### F-10: `docs/` tree is operator-/audit-heavy, user-facing guides live elsewhere
|
||||
|
||||
**Severity**: medium
|
||||
**Scope**: docs/
|
||||
**Citation**: `find docs -name "*.md"` — 27 of 31 files are `docs/audit/v14XX-summary.md` or ops/migration notes. No user-facing setup guide tree.
|
||||
**What's wrong**: A visitor following "see Self-Hosting → Reverse Proxy" (`README.md:107`) is bounced to `docs.healthlog.dev`, but the in-repo `docs/` tree appears to be the operator's working notes. There's no `docs/getting-started.md`, no `docs/integrations/apple-health.md`, no `docs/integrations/withings.md`, no `docs/ai-providers.md` in the repo. This is fine if `docs.healthlog.dev` is the canonical surface — but the canonical surface needs verifying, and `docs/` should signal "see docs.healthlog.dev" with a README, not look like a half-built docs tree.
|
||||
**Fix shape**: Add `docs/README.md` (top-level) saying "User docs live at docs.healthlog.dev. The files here are internal operator audits and migration notes." Or move `audit/` into `docs/audit/` ring-fenced. Verify the topics the README links to (`/self-hosting/reverse-proxy/`) exist on the public docs site.
|
||||
**Effort**: small
|
||||
|
||||
### F-11: No `docs/api/openapi.yaml` link in README
|
||||
|
||||
**Severity**: low
|
||||
**Scope**: README, docs/
|
||||
**Citation**: `docs/api/openapi.yaml` exists (`docs/api/README.md:1`) but the README's API Reference section (`README.md:240-386`) doesn't reference it.
|
||||
**What's wrong**: A developer evaluating integration potential doesn't see an OpenAPI spec is available. The README's API table is good for scanning; the OpenAPI spec is the codegen target. Both should be discoverable.
|
||||
**Fix shape**: Add one line above the API Reference collapsibles: "Machine-readable OpenAPI 3.1 spec: [`docs/api/openapi.yaml`](docs/api/openapi.yaml). Generates iOS DTO + any client codegen."
|
||||
**Effort**: trivial [hotfix-ready]
|
||||
|
||||
### F-12: README Tech Stack omits the iOS native client
|
||||
|
||||
**Severity**: low
|
||||
**Scope**: README
|
||||
**Citation**: `README.md:111-128` — Tech Stack table; no iOS / Swift / SwiftUI row.
|
||||
**What's wrong**: The repo carries `docs/apple-store-connect-checklist.md` and v1.4.23-onward CHANGELOG entries describing the native iOS app foundation. The README hides this; a visitor sees "web PWA only."
|
||||
**Fix shape**: Add row "iOS client | SwiftUI (separate repo, in development)" or call it out in a Roadmap section.
|
||||
**Effort**: trivial
|
||||
|
||||
### F-13: No Roadmap / Status section
|
||||
|
||||
**Severity**: low
|
||||
**Scope**: README
|
||||
**Citation**: `README.md` — absent.
|
||||
**What's wrong**: Visitors can't tell what's next, what's stable, or whether the project is alive. The CHANGELOG shows ~daily releases; that signal doesn't reach the landing.
|
||||
**Fix shape**: A small "Status" section: "Active development. ~weekly releases. Current focus: native iOS client (v1.5)." Plus one-line Roadmap link to a GitHub Project board or to the discussion forum.
|
||||
**Effort**: trivial
|
||||
|
||||
### F-14: `src/app/layout.tsx` openGraph has no `images` field
|
||||
|
||||
**Severity**: low
|
||||
**Scope**: SEO
|
||||
**Citation**: `src/app/layout.tsx:49-57` — `openGraph` block lacks `images:`.
|
||||
**What's wrong**: When `demo.healthlog.dev` is shared in chat, the unfurl has no image. Same problem as F-7 but for the live app, not the repo.
|
||||
**Fix shape**: Add `images: [{ url: "/og-image.png", width: 1200, height: 630 }]` and ship `public/og-image.png`. Same asset as F-7.
|
||||
**Effort**: trivial
|
||||
|
||||
### F-15: README has no badge for latest release or Docker pulls
|
||||
|
||||
**Severity**: low
|
||||
**Scope**: README, SEO
|
||||
**Citation**: `README.md:11-17` — 5 badges, none of which surface version or activity.
|
||||
**What's wrong**: A current-version badge tells visitors the project is alive without them scrolling. Docker pulls signal traction.
|
||||
**Fix shape**: Add `` and `` (GHCR doesn't have a pulls counter but the latest-tag badge serves the same role).
|
||||
**Effort**: trivial [hotfix-ready]
|
||||
|
||||
## Suggested topics
|
||||
|
||||
Ranked by search-volume × relevance. Aim for 15-18 active topics; drop `glp-1`, `mounjaro`, `tracking`, `health`, `dashboard` (too generic).
|
||||
|
||||
1. `self-hosted`
|
||||
2. `health-tracking`
|
||||
3. `pwa`
|
||||
4. `nextjs`
|
||||
5. `apple-health`
|
||||
6. `withings`
|
||||
7. `apple-health-import`
|
||||
8. `withings-alternative`
|
||||
9. `personal-health`
|
||||
10. `quantified-self`
|
||||
11. `privacy`
|
||||
12. `prisma`
|
||||
13. `medication-tracker`
|
||||
14. `glucose-tracker`
|
||||
15. `mood-tracker`
|
||||
16. `ai-insights`
|
||||
17. `docker`
|
||||
18. `agpl`
|
||||
|
||||
## Draft README revision
|
||||
|
||||
Not warranted as a full rewrite. The existing structure is sound; the top three changes (hero image + Apple Health bullet + comparison table) are additive edits to the current document. Suggested insertions to the existing README:
|
||||
|
||||
**Insert after line 22 (before the "Website / Live Demo / Documentation" link row):**
|
||||
|
||||
```markdown
|
||||
<p align="center">
|
||||
<img src="public/screenshots/hero-dashboard.webp" alt="HealthLog dashboard — trends, Coach card, Health Score" width="900" />
|
||||
</p>
|
||||
```
|
||||
|
||||
**Insert as a new section after "Why HealthLog?" (after line 44):**
|
||||
|
||||
```markdown
|
||||
## How it compares
|
||||
|
||||
| | HealthLog | Withings web | Apple Health | Oura web | Generic CSV |
|
||||
| ------------------------ | ------------------ | -------------- | ------------ | ---------- | ----------- |
|
||||
| Self-hosted | Yes | No | No | No | Yes |
|
||||
| Open source | AGPL-3.0 | No | No | No | n/a |
|
||||
| Withings device sync | Yes (OAuth2) | Yes (native) | Via shortcut | No | No |
|
||||
| Apple Health import | Yes (`export.zip`) | No | Native | No | Manual |
|
||||
| Custom clinician targets | Yes (audit-logged) | Limited | No | No | n/a |
|
||||
| Doctor-report PDF | Yes (client-side) | No | No | No | n/a |
|
||||
| AI Insights | Multi-provider BYOK| No | Limited | Subscription| n/a |
|
||||
| Subscription required | No | For some metrics| No | Yes | No |
|
||||
| Your data leaves device | Never | Withings cloud | Apple cloud | Oura cloud | Depends |
|
||||
```
|
||||
|
||||
**Insert as a new bullet inside Key Features (between line 58 and line 60):**
|
||||
|
||||
```markdown
|
||||
**Apple Health import** — Drop your iOS `export.zip` on the import page. Streaming parser handles multi-gigabyte archives (Zip64), folds every record / workout / correlation into your timeline, and stays idempotent on re-upload. Per-type ingestion stats and live progress on a status endpoint.
|
||||
```
|
||||
|
||||
**Insert as a new bullet inside Key Features, promoted to first position (replace current line 60 "Multi-Provider AI Insights"):**
|
||||
|
||||
```markdown
|
||||
**AI Coach + Insights** — A conversational Coach trained on your own data, a daily briefing, a weekly report, and a Health Score tile on the dashboard. Pick OpenAI, Anthropic Claude, or any OpenAI-compatible local endpoint (Ollama, LM Studio, vLLM). BYOK or admin-shared. Evidence-grounded — every claim links back to the measurements that produced it. Local endpoints keep all data on your network.
|
||||
```
|
||||
|
||||
**Add a one-line Status block after the "What it is" paragraph (after line 36):**
|
||||
|
||||
```markdown
|
||||
> **Status**: active. New releases roughly weekly — see [CHANGELOG](CHANGELOG.md). Current focus: native iOS client (v1.5).
|
||||
```
|
||||
|
||||
**Replace badges block (lines 11-17) with:**
|
||||
|
||||
```markdown
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg" alt="License: AGPL-3.0" /></a>
|
||||
<a href="https://github.com/MBombeck/HealthLog/releases"><img src="https://img.shields.io/github/v/release/MBombeck/HealthLog?sort=semver&color=success" alt="Latest release" /></a>
|
||||
<img src="https://img.shields.io/badge/Self--Hosted-yes-success" alt="Self-Hosted" />
|
||||
<img src="https://img.shields.io/badge/Next.js-16-black?logo=next.js" alt="Next.js 16" />
|
||||
<img src="https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&logoColor=white" alt="TypeScript" />
|
||||
<a href="https://github.com/MBombeck/HealthLog/pkgs/container/healthlog"><img src="https://img.shields.io/badge/GHCR-multi--arch-2496ED?logo=docker&logoColor=white" alt="GHCR" /></a>
|
||||
</p>
|
||||
```
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Rewriting `docs.healthlog.dev` content (separate audit; outside repo).
|
||||
- Producing the actual hero screenshot / OG image binary (asset creation, not code).
|
||||
- Editing the live GitHub repo metadata (`description`, `topics`, `homepageUrl`) — flagged as `[hotfix-ready]`, awaiting maintainer command.
|
||||
- CONTRIBUTING.md / SECURITY.md / CODE_OF_CONDUCT.md content review beyond presence check (all three present and structurally sound; no critical issues found).
|
||||
- Release-note quality audit per release (CHANGELOG is comprehensive; the v1.4.34 entry reads well).
|
||||
- E2E / integration test coverage of the docs links themselves.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Mobile Security Audit — 2026-05-16
|
||||
|
||||
## Executive summary
|
||||
|
||||
The mobile surface is in better shape than expected. Passkey, refresh-token rotation with reuse-detection, AES-GCM-at-rest with versioned keys, HMAC-hashed API tokens, and per-IP rate-limit envelopes on every auth path are already in place. The three sharp gaps I'd close before App Store goes live: (1) **the Apple Health import pipeline reads a 1.5 GB upload into memory with `readFileSync` and inflates the central XML with no max-output cap** — a malicious or accidental zip-bomb crashes the worker; (2) **password change does not revoke `ApiToken` rows nor `RefreshToken` family** — a stolen long-lived iOS token survives the user's most obvious self-remediation; (3) **the `/.well-known/` namespace is wide-open via `startsWith` in proxy.ts** — fine today, but the pattern admits any future `/.well-known/foo` route without auth, which is exactly how AASA siblings (Universal Links, OIDC discovery) tend to creep in. Order: F-1 before App Store ship (worker can be killed remotely), then F-2 hotfix, then everything else.
|
||||
|
||||
## Findings — prioritized
|
||||
|
||||
### F-1: Apple Health unzip loads entire upload to RAM + inflates with no output cap
|
||||
|
||||
**Severity**: high
|
||||
**Blast radius**: any authenticated user can crash the worker container (DoS the whole queue, including reminders + Withings activity sync + sleep sync) by uploading a crafted ZIP. With 1.5 GB upload allowed, even a benign large export OOMs a typical Coolify worker.
|
||||
**File(s)**: `src/lib/import/unzip-export-xml.ts:65` (`readFileSync(archivePath)` — whole archive in RAM), `:240` (`inflateRawSync(compressed)` — no `maxOutputLength` option), `src/app/api/import/apple-health-export/route.ts:41` (`MAX_UPLOAD_BYTES = 1.5 GB`)
|
||||
**What's wrong**: The streamed-to-disk multipart parser does its job (`stream-to-disk.ts` keeps memory bounded), but then `extractExportXml` undoes that by `readFileSync`ing the entire upload — peak RSS jumps to ~1.5 GB on the worker per concurrent import. Worse, `inflateRawSync` has no upper bound on output: a 1 MB ZIP with a 10:1 deflate ratio can inflate to many GB. Apple's real exports compress well, so an attacker only needs one crafted entry to OOM the worker. `sax.parser` itself is fine — defaults `maxEntityCount=512`, `maxEntityDepth=4` (sax.js source, lib/sax.js:70–71) so billion-laughs is covered.
|
||||
**Fix shape**: switch to a streaming ZIP reader (`yauzl` or a `createReadStream + node:zlib.createInflateRaw` pipe) that walks the central directory by seek and streams the chosen entry through `createInflateRaw({ maxOutputLength: 8 * 1024 * 1024 * 1024 })` (8 GB cap — Apple's largest realistic export is ~6 GB uncompressed). Or keep the current shape but pass an explicit `maxOutputLength` to the existing `inflateRawSync` call and `fs.statSync` + reject if `archive.size > 2 GB` before the `readFileSync`.
|
||||
**Effort**: medium (4 h) — needs a streaming path; tests already exercise the central-directory walker so refactoring is bounded.
|
||||
|
||||
### F-2: Password change does not revoke `ApiToken` or `RefreshToken` rows
|
||||
|
||||
**Severity**: high `[hotfix-ready]`
|
||||
**Blast radius**: a user who suspects compromise and changes their password (the obvious self-remediation) is told all sessions are killed, but their 90-day iOS Bearer token + 60-day refresh-token family keep working. iOS app continues operating; an attacker holding the leaked token also continues operating.
|
||||
**File(s)**: `src/app/api/auth/password/route.ts:81` (`destroyAllSessions(user.id)` is the only revocation call), `src/lib/auth/session.ts:159–161` (`destroyAllSessions` deletes Session rows only)
|
||||
**What's wrong**: `destroyAllSessions` only touches `prisma.session.deleteMany`. Native callers authenticate via `ApiToken` (Bearer) and rotate via `RefreshToken` — both tables are independent and survive the password change.
|
||||
**Fix shape**: in `password/route.ts` after `destroyAllSessions`, add `await prisma.apiToken.updateMany({ where: { userId: user.id, revoked: false }, data: { revoked: true } })` and `await prisma.refreshToken.updateMany({ where: { userId: user.id, revokedAt: null }, data: { revokedAt: new Date() } })`. Same fix belongs in the admin reset-password path (`src/app/api/admin/users/[id]/reset-password/route.ts`).
|
||||
**Effort**: trivial (< 15 min)
|
||||
|
||||
### F-3: AASA + `/.well-known/` proxy bypass admits any future namespace member without auth
|
||||
|
||||
**Severity**: medium `[hotfix-ready]`
|
||||
**Blast radius**: today, none — only `apple-app-site-association` lives under `/.well-known/` (`src/app/.well-known/apple-app-site-association/route.ts:36`). The risk is that the `startsWith("/.well-known/")` rule (`src/proxy.ts:43`) will silently make every future sibling route (OIDC discovery, security.txt, openid-federation, change-password) publicly readable, including ones a future developer wires to internal helpers. AASA itself is fine: it returns a static object with no user data.
|
||||
**File(s)**: `src/proxy.ts:43`, `src/app/.well-known/apple-app-site-association/route.ts:32` (`webcredentials.apps: ["S8WDX4W5KX.dev.healthlog.app"]` is correct shape)
|
||||
**What's wrong**: The proxy bypass is a `startsWith` over a tree, not an explicit allowlist. The maintainer comment at proxy.ts:38–42 even hints at intentionally pre-admitting `/security.txt`, `/openid-configuration`, etc. — but security.txt should live at `/.well-known/security.txt` (which would correctly bypass), and an OIDC discovery doc, if ever added, must not be public if it leaks internal endpoint shape.
|
||||
**Fix shape**: tighten to an explicit allowlist of `/.well-known/apple-app-site-association` and `/.well-known/security.txt`. Future namespace additions must be opted in explicitly. Drop the `/api/auth/codex/callback` line at proxy.ts:26 too — that route doesn't exist (verified, only `device-poll`, `device-start`, `disconnect`); it's just stale PUBLIC_PATHS clutter that erodes the reviewer's trust signal.
|
||||
**Effort**: trivial (< 15 min)
|
||||
|
||||
### F-4: Withings webhook uses one global shared secret across all users
|
||||
|
||||
**Severity**: medium
|
||||
**Blast radius**: a single leaked `WITHINGS_WEBHOOK_SECRET` allows an attacker to spoof Withings notifications for **any** connected user — they just need to know the target's `withingsUserId` (a 6–10 digit number, enumerable). The handler ignores unknown users (`webhook-handler.ts:140`) but happily fires `syncUserMeasurements` for any recognised id without any per-user authenticity check.
|
||||
**File(s)**: `src/app/api/withings/webhook/[token]/route.ts:32` (`process.env.WITHINGS_WEBHOOK_SECRET`), `src/lib/withings/webhook-handler.ts:127–148`
|
||||
**What's wrong**: Withings does not sign webhook bodies, so per-user authenticity is unachievable end-to-end. The mitigation today is that the secret moved from query (`?secret=`) to path segment (good — keeps it out of reverse-proxy access-log `query_string` columns). Still: one secret protects everyone, and the spoofed notification just retriggers `syncUserMeasurements`, so the practical blast radius is "attacker forces a sync against your real Withings account" — not data exfiltration, just rate-limit / pull-side abuse. Worth flagging because the iOS roll-out increases the user count under the same secret.
|
||||
**Fix shape**: rotate `WITHINGS_WEBHOOK_SECRET` quarterly via a `scripts/rotate-withings-webhook-secret.ts` that re-subscribes every active connection through `withings/connect` against the new secret. Operator change, no code change required to ship today, but it should be documented as a runbook rotation gate before the iOS launch grows the user base.
|
||||
**Effort**: medium (4 h) — operator runbook + a re-subscription helper script.
|
||||
|
||||
### F-5: HSTS missing `preload`; CSP allows `wbsapi.withings.net` on every page
|
||||
|
||||
**Severity**: low `[hotfix-ready]`
|
||||
**Blast radius**: HSTS without `preload` requires a fresh visitor to hit HTTPS once before the policy locks in; the first-visit window is exploitable on hostile networks (cafe Wi-Fi). The `wbsapi.withings.net` `connect-src` entry is broader than needed — that host should only be reachable from `/settings/integrations/withings/*`, similar to the AI-route gating already done at proxy.ts:235–238.
|
||||
**File(s)**: `src/proxy.ts:250–253` (HSTS), `src/proxy.ts:241` (CSP)
|
||||
**What's wrong**: `Strict-Transport-Security: max-age=31536000; includeSubDomains` — needs `; preload` for `healthlog.bombeck.io` to be eligible for the Chromium preload list. The CSP `wbsapi.withings.net` host is global; mirror the AI-only gating pattern instead.
|
||||
**Fix shape**: append `; preload` to the HSTS header. Gate Withings in `connect-src` to `pathname.startsWith("/settings/integrations/withings") || pathname.startsWith("/api/withings/")`. Submit the domain to `hstspreload.org` only after `; preload` ships on production.
|
||||
**Effort**: trivial (< 15 min)
|
||||
|
||||
### F-6: `getClientIp` returns `null` when `TRUST_PROXY_HOPS` is set but XFF chain is shorter than expected — IP-keyed rate limits fall open
|
||||
|
||||
**Severity**: medium `[hotfix-ready]`
|
||||
**Blast radius**: every IP-keyed rate-limit silently collapses to a single bucket. `auth:login:unknown`, `auth:register:unknown`, `withings-webhook:null`, `moodlog-webhook:unknown` — one shared bucket means 5 login attempts per fifteen minutes globally instead of per-IP. An attacker spraying weak passwords burns the same bucket as legitimate users.
|
||||
**File(s)**: `src/lib/api-response.ts:98–120`, callers e.g. `src/app/api/auth/login/route.ts:25–26` (`const ip = getClientIp(request) ?? "unknown"`)
|
||||
**What's wrong**: The current contract (refuse to read XFF when shorter than configured hops) is correct *security* behaviour — it stops the leftmost-IP rotation attack — but it does so by returning `null`, which every caller then collapses to a literal `"unknown"` string and rate-limits *all anonymous traffic together*. The downstream rate-limit therefore fails open from a noisy-neighbour perspective even though it fails closed from a credential-stuffing-from-one-IP perspective.
|
||||
**Fix shape**: when `getClientIp` returns `null` because the chain was too short, log a warning once per process (it's an operator misconfig signal) and apply a much tighter universal rate-limit on the affected route (e.g. 20/min globally for `auth:login:unknown`) so the collapsed bucket is itself a useful gate. Better: surface a sentinel like `"unknown-trust-violation"` so the operator alert fires on the dashboards. Best: add an admin-status indicator that flips red when `TRUST_PROXY_HOPS` and the XFF chain don't match.
|
||||
**Effort**: small (< 1 h)
|
||||
|
||||
### F-7: Service worker offline page reflects untranslated German; manifest scope is implicit; no `scope` set
|
||||
|
||||
**Severity**: low
|
||||
**Blast radius**: low. The PWA offline fallback at `public/sw.js:104` is a hard-coded German HTML blob — users on `en` locale see German strings on offline boot. The manifest has no `scope`, so the install scope defaults to `/`, which is fine for a single-route app but exposes the app to PWA-hijack if a future subpath ever ships a separate manifest. Push notification handler does not validate the `data.url` is same-origin before `client.navigate(url)` at sw.js:166 — a server-side injection into push payload could redirect the focused PWA window to an attacker URL.
|
||||
**File(s)**: `public/sw.js:104` (German-only offline fallback), `public/sw.js:155–172` (notification click), `public/manifest.json` (missing `scope`, `id`, `display_override`)
|
||||
**What's wrong**: `client.navigate(url)` trusts the push payload verbatim. Push payloads are server-authenticated by VAPID, but the push-server is our own — a server-side bug (or compromised admin issuing pushes) could ship an off-origin URL into `data.url`.
|
||||
**Fix shape**: validate `new URL(url, self.location.origin).origin === self.location.origin` before `client.navigate`. Localise the offline HTML via the same i18n message bundle the rest of the app uses (or strip the German and just render a generic icon). Add `"scope": "/"`, `"id": "/?source=pwa"`, `"display_override": ["standalone"]` to the manifest.
|
||||
**Effort**: small (< 1 h)
|
||||
|
||||
### F-8: Audit-log coverage gap on Bearer mutation paths + IdP-style ops
|
||||
|
||||
**Severity**: medium
|
||||
**Blast radius**: an attacker with a stolen native token can mutate measurements (`/api/measurements/[id]` PATCH), delete medications, change targets, etc. without any audit-log entry beyond the create paths. The audit table is the only forensic trail; missing-entries means "we knew it happened but not when by whom" in an incident.
|
||||
**File(s)**: `src/app/api/measurements/[id]/route.ts` (PATCH/DELETE — has `auditLog` in places, not uniformly), `src/app/api/medications/[id]/route.ts` (no audit calls — verified via the grep at the top of the audit), `src/app/api/auth/me/devices/[id]/route.ts` (DELETE — token-revoke flow, needs audit), `src/lib/auth/audit.ts:26` (geo enrichment is gated on `action.startsWith("auth.")` — non-auth events miss the carrier/location enrichment that is genuinely useful for incident response)
|
||||
**What's wrong**: The audit module exists and works, but coverage is patchy. Every mutation path that a stolen Bearer token can reach should log. Geo-enrichment is restricted to auth events; extending to any mutation costs nothing (the timeout race already caps the await window at 3 s) and gives the incident-response narrative.
|
||||
**Fix shape**: walk every `route.ts` under `/api/measurements`, `/api/medications`, `/api/auth/me`, `/api/integrations` and confirm `auditLog(...)` on every non-GET handler. Drop the `action.startsWith("auth.")` gate in `audit.ts:26` so geo enrichment runs for any event that carries an `ipAddress`. Add a regression test that walks the api tree and asserts every non-GET handler imports `auditLog`.
|
||||
**Effort**: medium (4 h) — sweep, add missing calls, regression test.
|
||||
|
||||
## Out of scope / accepted risks
|
||||
|
||||
- **`unsafe-inline` in style-src** (`src/proxy.ts:241`): Tailwind injects style attributes and Recharts renders inline `style="..."` for tooltips. Removing it breaks both. Risk is bounded — CSS injection is not a meaningful exfil path absent CSS-keylogger primitives, which our threat model does not include.
|
||||
- **Session cookie `SameSite: Lax`** (`src/lib/auth/session.ts:76`): required for the Withings OAuth top-level cross-site redirect to land with the cookie attached. The comment is accurate; CSRF for unsafe methods stays protected by `Lax`'s same-rule.
|
||||
- **`X-Frame-Options: DENY`** (`src/proxy.ts:222`) plus CSP `frame-ancestors 'none'` (proxy.ts:241): correctly double-belted. Good.
|
||||
- **No CSRF tokens** (`src/lib/api-handler.ts:33`): acceptable under `SameSite: Lax` + same-origin-only API design. Verified no public-internet POST mutations exist that read session cookies (Withings OAuth callback is GET).
|
||||
- **Argon2id password hashing**: not re-verified line-by-line, but the wiring in `src/lib/auth/password.ts` follows the well-known node-argon2 default settings; no findings during the sweep.
|
||||
- **No virus scan on uploads**: acceptable for a self-hosted personal-health-tracking app. Threat model is the maintainer's own iOS device, not a multi-tenant SaaS.
|
||||
- **`@simplewebauthn/server` passkey flow**: structurally correct (origin allowlist, RP ID derived from APP_URL, 5-min challenge TTL, counter advance, challenge invalidation in `finally`). No findings.
|
||||
|
||||
## What you didn't get to
|
||||
|
||||
- **Admin surface end-to-end audit** (`/api/admin/*` — 30+ routes). I sampled `admin/users/[id]/reset-password` for the F-2 follow-up. Worth a dedicated sweep before the iOS rollout doubles the admin attack surface for the maintainer's own account.
|
||||
- **APNs ingest path**. The roadmap mentions APNs scaffolding (v1.4.23) but I didn't find a `/api/notifications/apns/*` route in this audit. Once it exists, signature verification of the iOS provider response and idempotency on device-token rotation will need a finding pass.
|
||||
- **Withings OAuth state-token entropy and replay window**. The callback at `/api/withings/callback` accepts the state token and exchanges the code; I didn't verify the state is single-use or that the redirect_uri pin matches.
|
||||
- **`scripts/rotate-encryption-key.ts`** wiring + the contract for re-encrypting moodlog/Withings/Telegram/AI-provider secrets. Documented at `src/lib/crypto.ts:244–247` but I didn't read the rotation script itself.
|
||||
- **Telegram webhook bot-impersonation risk**: the secret-token verification at `src/app/api/telegram/webhook/route.ts:35–47` is sound, but the chat-ID-to-user mapping that the handler uses to route inbound messages was not re-verified.
|
||||
- **Rate-limit on `/api/measurements` POST + PATCH** (no `checkRateLimit` call confirmed via grep). A stolen native token can write at unbounded rate to those routes — the batch endpoint is capped (60/min) but the single-entry POST is not. Worth a follow-up.
|
||||
@@ -0,0 +1,105 @@
|
||||
# Performance Audit — 2026-05-16
|
||||
|
||||
## Executive summary
|
||||
|
||||
The web tree is in good shape on the obvious lanes — Recharts is gated behind a single `next/dynamic` boundary, the dashboard tile-strip cache hit rate sits near 100% inside the 60-second LRU, the public image surface is 130 KiB total, and the `Cache-Control` posture is bfcache-friendly. The remaining wins are all on the **server-cpu / TTFB** side: at least seven `/api/insights/*` and `/api/medications/*` routes still load unbounded `findMany` slabs into JS and aggregate in-memory, with no SQL-side rollup like the v1.4.34.1 comprehensive fix. The dashboard mother page also fires three independent `useQuery` calls from a single `"use client"` shell, which costs one cold paint plus two hydration ticks before tiles settle — INP and LCP both move when these are batched. Finally `/api/medications/intake?scope=today` is a per-request, hot-loop `findMany` with no cache wrap; this is the highest-frequency authed endpoint on the site after `/api/analytics`.
|
||||
|
||||
## Findings — prioritized
|
||||
|
||||
### F-1: Insights mother page fans out three sequential client queries
|
||||
**Severity**: high
|
||||
**Web-vital**: LCP, INP
|
||||
**File(s)**: `src/app/insights/page.tsx:161-175`, `src/app/insights/page.tsx:181`, `src/app/page.tsx:223-251`
|
||||
**What's wrong**: Both the dashboard and the Insights mother page are `"use client"` shells that fire `/api/insights/comprehensive`, `/api/analytics`, and (on dashboard) `/api/dashboard/widgets` + `/api/mood/analytics` from React mount. None are co-located in a Suspense boundary or a server-side `Promise.all`. Cold paint is hero strip → 3-5 spinners → tiles. The v1.4.34.1 cache wrap helps the second visitor; the first paid the wait.
|
||||
**Fix shape**: Make the mother pages server components that `Promise.all` the four endpoints inside a `loading.tsx` Suspense shell, hand the resolved data down to the existing `"use client"` widgets as initial state, and let TanStack hydrate from the dehydrated boundary. The cache wrap stays warm; the cold paint shaves one full network round-trip.
|
||||
**Effort**: medium
|
||||
|
||||
### F-2: `/api/medications/intake?scope=today` runs uncached on every render
|
||||
**Severity**: high
|
||||
**Web-vital**: TTFB, server-cpu
|
||||
**File(s)**: `src/app/api/medications/intake/route.ts:78-112`
|
||||
**What's wrong**: The `today` scope is rendered on every dashboard mount and every medications-page mount (and the iOS app polls it). It runs `findMany` with `include: { medication }` over the user's whole-day schedule slab with no `cached()` wrap, no `take`, and no SQL rollup. The compliance branch below it (line 119) is cached; the today branch is not. This is the hottest authed endpoint on the site after `/api/analytics`.
|
||||
**Fix shape**: Wrap in `cached(caches.medicationsIntake, ${userId}|today|${userTz}, …)` with a 30-second TTL (intake events trickle in but don't move sub-minute) and invalidate from the intake-mutation handler. Same pattern as the existing compliance branch directly below it.
|
||||
**Effort**: small `[hotfix-ready]`
|
||||
|
||||
### F-3: `/api/insights/targets` still loads full 30-day measurement slab into JS
|
||||
**Severity**: high
|
||||
**Web-vital**: server-cpu, TTFB
|
||||
**File(s)**: `src/app/api/insights/targets/route.ts:152-160`, `src/app/api/insights/targets/route.ts:1080-1090`
|
||||
**What's wrong**: The v1.4.34.1 SQL-aggregator landed on `/api/insights/comprehensive` only; the sibling `/api/insights/targets` route still issues an unbounded `findMany` over the last 30 days across seven measurement types (line 152) and a second one over all-time glucose (line 1080). For a power user with Apple Health backfill these are 10k-30k row pulls, then a JS reduce. The route is page-blocking for `/targets`.
|
||||
**Fix shape**: Mirror the `buildComprehensiveAggregate()` shape — `groupBy({ type }, { _avg, _count, _max })` plus a `$queryRaw` for the slope tuple. Most of `/targets`' downstream math is per-type averages and latest-values that Postgres already computes faster than V8.
|
||||
**Effort**: medium
|
||||
|
||||
### F-4: `/api/insights/cards` issues a wide measurement slab + a 30-day intake slab
|
||||
**Severity**: medium
|
||||
**Web-vital**: server-cpu, TTFB
|
||||
**File(s)**: `src/app/api/insights/cards/route.ts:73-88`, `src/app/api/insights/cards/route.ts:139-150`
|
||||
**What's wrong**: Same shape as F-3: 90-day measurement findMany across four types, then a 30-day intake event findMany across every active medication, both reduced in JS. Same SQL-aggregator treatment applies. Not cached either.
|
||||
**Fix shape**: Same as F-3. Bonus: wrap in `cached(caches.insightsCards, …)` with a 60-second TTL once the SQL aggregator is in place.
|
||||
**Effort**: medium
|
||||
|
||||
### F-5: Dashboard tiles fire two extra queries the analytics cache already serves
|
||||
**Severity**: medium
|
||||
**Web-vital**: INP, server-cpu
|
||||
**File(s)**: `src/app/page.tsx:226-251`
|
||||
**What's wrong**: `useQuery(["dashboardWidgets"])` and `useQuery(["moodAnalytics"])` both fire alongside `useAnalyticsQuery()` on every dashboard mount. The widget layout is a stable user preference (cache TTL could be 5 minutes, not 60s), and `/api/mood/analytics` payload is a subset of what `/api/analytics`' thick slice already returns through `summaries.MOOD` + correlations. Three round-trips where one would do.
|
||||
**Fix shape**: Move `dashboardWidgets` to a per-session-localStorage prefetch (it changes via the Settings page; invalidate on save). Fold `moodAnalytics` into `/api/analytics`' thick slice — the field is already there structurally; the consumer just needs to read it from `analyticsQuery.data.summaries.MOOD`.
|
||||
**Effort**: small
|
||||
|
||||
### F-6: Three `Recharts` consumers ship without a `next/dynamic` wrapper
|
||||
**Severity**: medium
|
||||
**Web-vital**: bundle-size, LCP
|
||||
**File(s)**: `src/components/admin/host-metrics-chart.tsx:33`, `src/components/insights/sleep-stage-stacked-bar.tsx:10`, `src/components/medications/DrugLevelChart.tsx:47`, `src/app/medications/[id]/history/page.tsx:8`
|
||||
**What's wrong**: `DrugLevelChart` ships in the eager bundle of `/medications/[id]/history` (no lazy wrapper). `SleepStageStackedBar` ships eagerly into the insights tree. Host-metrics is wrapped at the consumer per its file comment, but the file itself imports `recharts` so a static import chain into it pulls the whole 108 KiB Brotli vendor chunk. The `HealthChartDynamic` + `MoodChart` + scatter-card wrappers cover the dashboard + insights mother page but not these three.
|
||||
**Fix shape**: Wrap each at the consumer with the same `dynamic(() => import("…"), { ssr: false, loading: () => <ChartSkeleton /> })` shape `HealthChartDynamic` uses. Three identical lines.
|
||||
**Effort**: trivial `[hotfix-ready]`
|
||||
|
||||
### F-7: `ChartSkeleton` width-only fallback leaves loaders that don't match
|
||||
**Severity**: low
|
||||
**Web-vital**: CLS
|
||||
**File(s)**: `src/components/insights/correlation-card.tsx:34`, `src/components/admin/host-metrics-chart.tsx:166`, `src/components/charts/compliance-line-chart.tsx:110`
|
||||
**What's wrong**: Three `next/dynamic` loading slots paint a 180 px skeleton, but the actual `ResponsiveContainer` heights below are 180 px (compliance-line), 220 px (sleep-stages), and unset (host-metrics) — so the layout still snaps on hydration. The v1.4.34.1 scatter-card fix solved one of these; the rest are open. `DrugLevelChart` reserves 240 px via inline style which is fine, but its outer container is eager-imported (F-6) so it never paints a skeleton anyway.
|
||||
**Fix shape**: Either reuse `<ChartSkeleton>` (which already honours `--chart-height` / `--chart-height-md`) for every dynamic chart loader, or pin the loading-div height to the same value the chart itself uses.
|
||||
**Effort**: trivial `[hotfix-ready]`
|
||||
|
||||
### F-8: Service-worker page-cache is network-first with no SWR window
|
||||
**Severity**: low
|
||||
**Web-vital**: offline, LCP (return visits)
|
||||
**File(s)**: `public/sw.js:65-69`, `public/sw.js:50` (API exclusion)
|
||||
**What's wrong**: HTML pages run network-first with cache fallback (line 65). On a flaky connection the cache only fires once the network promise rejects; there's no `stale-while-revalidate` so the user sits on a spinner for the network timeout. `/api/*` is excluded from the SW entirely (line 50) so the in-memory LRU on the server is the only cache for repeat visits.
|
||||
**Fix shape**: Switch the HTML branch to a stale-while-revalidate strategy keyed off the same `PAGE_CACHE`. For `/api/analytics?slice=summaries` specifically, allow a SW cache with a 30-second max-age — the slim slice is already idempotent across same-window mounts.
|
||||
**Effort**: small
|
||||
|
||||
### F-9: `<head>` carries an inline pre-paint theme script on every page
|
||||
**Severity**: low
|
||||
**Web-vital**: TTFB, render-blocking
|
||||
**File(s)**: `src/app/layout.tsx:73-95`
|
||||
**What's wrong**: Every page response carries an inline `<script>` (line 74-95) reading localStorage and stamping a class onto `<html>`. The nonce is also computed via `headers()` on every render (line 82-84). The script is ~270 bytes which is fine; the header read isn't free — it pushes the layout shell to dynamic-rendering mode, which is already the case via cookies but the duplicate cost is per-route.
|
||||
**Fix shape**: Move the theme script into a static file served from `/_next/static/` with `Cache-Control: public, immutable`, then reference it via `<script src=… defer>`. Acceptable FOUC risk: the script needs to execute before first paint to avoid the flash, so the inline-with-nonce variant is actually the correct trade. Leave as informational unless a CSP-relaxation lets us move it static.
|
||||
**Effort**: small (informational — current shape is the right one)
|
||||
|
||||
### F-10: 16 `findMany` call sites across `/api/*` still have no `take`
|
||||
**Severity**: medium
|
||||
**Web-vital**: server-cpu, db
|
||||
**File(s)**: count = 16 routes flagged via grep. Hot ones: `src/app/api/insights/cards/route.ts:73,84,139`; `src/app/api/insights/comprehensive/route.ts:88,214,234`; `src/app/api/insights/targets/route.ts:152,166,816,824,958,1080`; `src/app/api/insights/glp1-timeline/route.ts:76,95`; `src/app/api/measurements/series/route.ts:82,91,122`; `src/app/api/personal-records/route.ts:38` (clamped via `limit` clamp at runtime); admin / export / backup routes.
|
||||
**What's wrong**: Most of these are scope-bounded by `where: { measuredAt: { gte: someDate } }` so they're not literally unbounded, but the date predicate isn't the same thing as a SQL ceiling. A user with a multi-year Apple Health backfill plus a 90-day window can still pull 10k+ rows per call. The PersonalRecord route is the only one with an explicit clamp.
|
||||
**Fix shape**: Audit each call site for the worst-case row count. The hot insights routes (F-3, F-4) deserve the SQL-aggregator treatment. Cold export/backup routes should grow a `take: 50_000` ceiling with a clear 413-response when the user exceeds it — protects the DB worker from a runaway query.
|
||||
**Effort**: medium (spread across routes)
|
||||
|
||||
## Numbers
|
||||
|
||||
- **App routes**: 35 total page.tsx files; 27 are `"use client"` shells → no server-side data fetch on those pages.
|
||||
- **Recharts importers**: 8 files (`grep`); 3 of them ship eager (F-6).
|
||||
- **`next/dynamic` boundaries in production code**: 14 (excluding tests).
|
||||
- **`/api/*` route handlers**: 80+ route.ts files.
|
||||
- **`findMany` call sites under `/api/*`**: 103 total; 23 route files contain at least one without a `take` in the surrounding 8 lines (rough heuristic — most are date-window-bounded but unbounded by row count).
|
||||
- **`cached()` wraps under `/api/*`**: 14 route call sites — v1.4.34.1's expansion. The remaining 80+ routes go to Postgres on every request.
|
||||
- **Public images**: 5 files, all PNG/SVG, largest is `logo-readme.png` at 102 KiB. `logo-512.png` is 23 KiB, `logo-192.png` is 8 KiB. No oversized hero image; no raw `<img>` tags in the app surface (only inside a `bugreport` sanitiser comment).
|
||||
- **`next/image` usage**: 0 — every image on the site is either a CSS background or one of the public icons referenced via `<link rel="icon">` / manifest. Acceptable today since the app surface has no inline raster imagery; would matter the moment Marc adds a logo or onboarding hero.
|
||||
- **`next/font`**: 1 family (Inter, 4 weights, latin subset). Self-hosted via `next/font/google`. No FOIT (next swap default). Looks correct.
|
||||
- **Service worker precache**: 4 URLs (`/`, `/logo-192.png`, `/logo-512.png`, `/favicon.svg`).
|
||||
- **Inline `<script>` in layout**: 1 (theme pre-paint, 270 bytes).
|
||||
- **Chart components with `min-h-[…]` reservation**: 4/8 (50%). The v1.4.34.1 scatter fix covered the worst CLS case; F-7 lists the others.
|
||||
- **Postgres index coverage for hot queries**: `Measurement(userId, type, measuredAt)` exists (`schema.prisma:452`) — covers every `/api/insights/*` measurement query. `MedicationIntakeEvent(userId, medicationId, scheduledFor)` exists (`schema.prisma:742`) — covers `/api/medications/intake` today scope. `PersonalRecord(userId, metricType, value)` exists (`schema.prisma:589`). No gap on the hot read paths the audit could spot.
|
||||
- **`force-dynamic` routes**: 5 in `/api/insights/*` (chat, chat-id, targets, cards, comprehensive). Correct posture for authed reads; no SSG opportunity here.
|
||||
- **bfcache posture**: `Cache-Control: private, max-age=0, must-revalidate` applied via `next.config.ts` for HTML and `applyAuthedHeaders()` for 10 API call sites. Looks right.
|
||||
@@ -0,0 +1,309 @@
|
||||
# Test Coverage Audit — 2026-05-16
|
||||
|
||||
## Executive summary
|
||||
|
||||
Coverage is thick on pure helpers and route-edge validation but thin where it
|
||||
matters most: every long-running irreversible mutation (login by password,
|
||||
password change with token wipe, password reset, Withings OAuth handshake,
|
||||
Withings full-sync orchestration, Apple Health worker, force-logout) is either
|
||||
unit-mocked or untested at the route level. Two known parallel-isolation
|
||||
flakes (`apns-dispatch`, `integration-status`) still rely on
|
||||
`fileParallelism: false`. There is no enforced perf assertion anywhere — the
|
||||
v1.4.34.1 100k-row regression that triggered this round has no test that would
|
||||
catch it tomorrow. No visual-regression suite exists; that gap is acceptable
|
||||
for an audit-grounded layout, but should be acknowledged.
|
||||
|
||||
## Findings — prioritized
|
||||
|
||||
### F-1: `POST /api/auth/password` has zero route-level tests despite the new token-wipe codepath
|
||||
|
||||
**Severity**: critical
|
||||
**Category**: critical-path gap
|
||||
**File(s)**: `src/app/api/auth/password/route.ts:22-97` (no `__tests__/` sibling)
|
||||
**What's wrong**: The password-change handler runs `destroyAllSessions(user.id)`
|
||||
which deletes sessions + revokes API tokens + revokes refresh tokens in one
|
||||
transaction (`src/lib/auth/session.ts:174-186`). This is the v1.4.34.3 fix.
|
||||
No test asserts the transaction is invoked, no test asserts the
|
||||
freshly-issued session survives the wipe, no test asserts the same-password
|
||||
guard returns 422, and no test asserts the rate-limit short-circuits at six
|
||||
attempts. The integration suite has nothing either — `auth-flow.test.ts`
|
||||
covers `createSession`/`destroySession` only.
|
||||
**Fix shape**: `tests/integration/auth-password-change.test.ts`
|
||||
- POST with correct current password wipes every sibling session (+ revokes refresh tokens, + revokes API tokens) and the response cookie carries the new session
|
||||
- POST with wrong current password leaves every session intact and returns 401
|
||||
- POST with identical current+new returns 422 without touching the password hash
|
||||
- Sixth call inside the 15-minute window returns 429
|
||||
- Strength-checker rejection (weak password) returns 422 + does NOT wipe sessions
|
||||
|
||||
**Effort**: small
|
||||
|
||||
### F-2: No route test for `passkey/register-options`, `passkey/register-verify`, `passkey/login-options`, `auth/register`, `auth/refresh`
|
||||
|
||||
**Severity**: critical
|
||||
**Category**: critical-path gap
|
||||
**File(s)**:
|
||||
- `src/app/api/auth/passkey/register-options/route.ts` (no test)
|
||||
- `src/app/api/auth/passkey/register-verify/route.ts` (no test)
|
||||
- `src/app/api/auth/passkey/login-options/route.ts` (no test)
|
||||
- `src/app/api/auth/register/route.ts` (no test)
|
||||
- `src/app/api/auth/refresh/route.ts` (no test — `src/lib/auth/__tests__/refresh-token.test.ts` covers only the helper)
|
||||
**What's wrong**: Only `auth/passkey/login-verify/__tests__/native-token.test.ts`
|
||||
and `auth/login/__tests__/native-token.test.ts` exist on the auth routes, and
|
||||
both target the native-Bearer branch — the web-cookie branch is untested. The
|
||||
WebAuthn ceremony is purely server-side; a regression in the challenge-store
|
||||
roundtrip ships silently.
|
||||
**Fix shape**: one integration file per route
|
||||
- register-options issues a challenge, persists it in `auth_challenges`, refuses concurrent challenges for the same user
|
||||
- register-verify rejects a stale challenge (>5 min), rejects an attestation that doesn't match the persisted challenge, persists the credential
|
||||
- login-options refuses to enumerate credentials for an unknown username
|
||||
- /api/auth/register: weak-password 422, duplicate-username 409, valid signup writes session cookie + emits audit
|
||||
- /api/auth/refresh: expired refresh returns 401 + revokes parent, valid rotates and returns new access+refresh pair
|
||||
|
||||
**Effort**: medium
|
||||
|
||||
### F-3: Withings OAuth `exchangeCode` + `refreshAccessToken` + `/api/withings/callback` have no test
|
||||
|
||||
**Severity**: critical
|
||||
**Category**: critical-path gap
|
||||
**File(s)**:
|
||||
- `src/lib/withings/client.ts:131-156` (refreshAccessToken — no test in `client.test.ts`)
|
||||
- `src/lib/withings/client.ts` (exchangeCode — same)
|
||||
- `src/app/api/withings/callback/route.ts` (no `__tests__/` sibling)
|
||||
- `src/app/api/withings/connect/route.ts` (no test)
|
||||
- `src/app/api/withings/sync/route.ts` (no test)
|
||||
- `src/app/api/withings/disconnect/route.ts` (no test)
|
||||
**What's wrong**: `client.test.ts` is 378 lines exclusively on `MEASURE_TYPE_MAP`
|
||||
+ scope helpers + `WITHINGS_NOTIFY_APPLIS`. The two functions that handle
|
||||
the actual OAuth credentials (the only thing standing between a healthy
|
||||
connection and a forever-broken integration) are not exercised. Webhook
|
||||
delivery is tested; the credential lifecycle is not. CSRF state-cookie
|
||||
comparison in `callback/route.ts:24-37` uses `timingSafeEqual` — that branch
|
||||
is reachable only by the test that doesn't exist.
|
||||
**Fix shape**: `src/lib/withings/__tests__/oauth.test.ts` + `tests/integration/withings-oauth.test.ts`
|
||||
- exchangeCode: happy POST encodes credentials, persists encrypted, calls `subscribeWebhook`
|
||||
- exchangeCode: Withings status≠0 throws + writes `recordSyncFailure`
|
||||
- refreshAccessToken: rotates encrypted tokens in place, refresh failure parks integration via `parkIntegrationAtReauth`
|
||||
- /api/withings/callback: state-mismatch returns 302 to `withings=error&reason=state`, valid code persists tokens
|
||||
- /api/withings/disconnect: revokes credentials and writes audit row
|
||||
|
||||
**Effort**: medium
|
||||
|
||||
### F-4: Apple Health import worker untested end-to-end; `partial-failure` recovery has no fixture
|
||||
|
||||
**Severity**: high
|
||||
**Category**: integration gap
|
||||
**File(s)**: `src/lib/jobs/apple-health-import-worker.ts` (no `__tests__/` sibling),
|
||||
`src/lib/measurements/__tests__/import-apple-health-export.test.ts:378` (memory ceiling but no kill-and-resume)
|
||||
**What's wrong**: The parse helpers are well tested at the unit level, but the
|
||||
pg-boss worker that wires extraction → parse → upsert into `ImportJob` rows
|
||||
has no test. A worker crash mid-batch (the `phases: queued → unpacking →
|
||||
parsing → upserting → done` state machine documented in
|
||||
`.planning/research/v1434-r-1-xml-import.md`) is the documented failure mode;
|
||||
the resume codepath was never exercised. The admin variant under
|
||||
`src/app/api/admin/import-apple-health-export/__tests__/` covers route
|
||||
validation but not the full pipe.
|
||||
**Fix shape**: `tests/integration/apple-health-import-worker.test.ts`
|
||||
- queue → unpacking → parsing → upserting → done with a 200-row fixture writes 200 measurement rows
|
||||
- worker throws mid-`upserting` → re-running the same job ID resumes at last committed phase, ends in `done`, total inserts unchanged
|
||||
- malformed XML transitions `ImportJob.status` to `failed` with diagnostic text, no half-written measurements remain
|
||||
|
||||
**Effort**: medium
|
||||
|
||||
### F-5: No perf assertion gate exists; 100k-row regression cannot be caught by the suite
|
||||
|
||||
**Severity**: high
|
||||
**Category**: perf-test gap
|
||||
**File(s)**: `tests/integration/insights-comprehensive-cache.test.ts`,
|
||||
`tests/integration/analytics-bp-aggregate-paged.test.ts` (both only assert correctness),
|
||||
`src/lib/insights/comprehensive-aggregator.ts` (the route under load)
|
||||
**What's wrong**: `.planning/round-v1434-prod-slowness-investigation.md` traces
|
||||
the original incident to pool starvation + per-route row-walks on dashboards
|
||||
with multi-year histories. The fix (server-cache + pool-size config) is now
|
||||
in place, but nothing in CI asserts the route stays under budget. A future
|
||||
N+1 regression on `/api/analytics` will resurface in production the same way.
|
||||
The memory-ceiling assertion in `import-apple-health-export.test.ts:378` is
|
||||
the only quantitative guard in the suite.
|
||||
**Fix shape**: `tests/integration/perf-budget.test.ts` (tagged `@slow`, opt-in via env)
|
||||
- seed 100k measurements across 30 types for one user, cold-cache `/api/analytics` finishes < 1 s (assert via `performance.now()` median over 5 runs)
|
||||
- warm-cache second hit < 50 ms (validates server-cache layer)
|
||||
- `/api/insights/comprehensive` cold under same fixture < 1.5 s
|
||||
- `/api/gamification/achievements` cold < 750 ms
|
||||
|
||||
**Effort**: medium. Mark `[hotfix-ready]` only for the warm-cache assertion (< 1h once seeder helper lands).
|
||||
|
||||
### F-6: Cache-invalidation coverage limited to two write surfaces; 16 more write endpoints unverified
|
||||
|
||||
**Severity**: high
|
||||
**Category**: integration gap
|
||||
**File(s)**: `tests/integration/server-cache-routes.test.ts` (covers analytics
|
||||
invalidation on measurement POST + medication-intake invalidation only),
|
||||
write sites that call `invalidateUser*` and have no integration assertion:
|
||||
- `src/app/api/measurements/[id]/route.ts` (PATCH + DELETE)
|
||||
- `src/app/api/measurements/batch/route.ts`
|
||||
- `src/app/api/measurements/by-external-ids/route.ts`
|
||||
- `src/app/api/mood-entries/route.ts` (POST + DELETE branches)
|
||||
- `src/app/api/mood-entries/bulk/route.ts`
|
||||
- `src/app/api/mood-entries/[id]/route.ts`
|
||||
- `src/app/api/medications/route.ts` (POST + PATCH)
|
||||
- `src/app/api/medications/[id]/route.ts`
|
||||
- `src/app/api/medications/[id]/intake/route.ts`
|
||||
- `src/app/api/medications/[id]/intake/[eventId]/route.ts`
|
||||
- `src/app/api/medications/intake/bulk/route.ts`
|
||||
- `src/app/api/workouts/batch/route.ts`
|
||||
- `src/app/api/dashboard/widgets/route.ts`
|
||||
- `src/app/api/dashboard/chart-overlay-prefs/route.ts`
|
||||
- `src/app/api/auth/me/timezone/route.ts` (covered by `timezone-per-user.test.ts`)
|
||||
- `src/app/api/insights/comprehensive/route.ts` (PUT — covered by `insights-comprehensive-cache.test.ts`)
|
||||
|
||||
**What's wrong**: A single missed `invalidateUserMeasurements(userId)` on any
|
||||
write path silently produces stale dashboard reads for up to the TTL (60 s).
|
||||
The blueprint in `.planning/research/v1434-r-cache-aggregation.md` §3 names
|
||||
every surface; the test suite covers two of them. The pattern is
|
||||
copy-pasteable.
|
||||
**Fix shape**: extend `server-cache-routes.test.ts` with one parametric block
|
||||
- for each `[route, mutation, evicts]` triple, prime cache → mutate → re-read → assert MISS
|
||||
- assert the cache key for a different user is NOT evicted (cross-user isolation)
|
||||
|
||||
**Effort**: small (one helper + 16 invocations)
|
||||
|
||||
### F-7: Provider-chain "no provider configured" fallback has no test
|
||||
|
||||
**Severity**: medium
|
||||
**Category**: critical-path gap
|
||||
**File(s)**: `src/lib/insights/no-key-fallbacks.ts:1-200` (no `__tests__/no-key-fallbacks.test.ts`)
|
||||
**What's wrong**: When the provider chain exhausts (`AllProvidersFailedError`)
|
||||
or a user has zero providers configured, the insight surface falls back to
|
||||
the static localised body in `no-key-fallbacks.ts`. The file ships six
|
||||
locales' worth of fallback text. The DE/EN router is one branch with two
|
||||
arms; the EN-arm is what every FR/ES/IT/PL user actually sees today. No test
|
||||
guards against a typo in a fallback string that would leak to production for
|
||||
non-DE users on the most common "no key configured" path.
|
||||
**Fix shape**: `src/lib/insights/__tests__/no-key-fallbacks.test.ts`
|
||||
- each `getNoKey*Text(locale)` returns the DE body iff `locale === "de"`, EN body otherwise
|
||||
- every exported helper returns a non-empty string for every shipped `Locale`
|
||||
- assertion that EN body length > 80 chars (prevents accidentally shipping the DE body to EN users — same as the umlaute-required guard but inverted)
|
||||
|
||||
**Effort**: trivial `[hotfix-ready]`
|
||||
|
||||
### F-8: Parallel-isolation flakes are masked by `fileParallelism: false`, not fixed
|
||||
|
||||
**Severity**: medium
|
||||
**Category**: flaky test
|
||||
**File(s)**:
|
||||
- `tests/integration/apns-dispatch.test.ts:144-260` (three cases share Device + NotificationChannel state)
|
||||
- `tests/integration/integration-status.test.ts:147-198` (the burst-dedup case shares an in-memory rate-limiter)
|
||||
- `vitest.integration.config.mts:23-25` (`fileParallelism: false, isolate: false`)
|
||||
**What's wrong**: The configured serial-fork run hides the underlying problem
|
||||
— both files mutate process-level state (the `apnsSendMock`/`apnsShutdownMock`
|
||||
factory and the alert-dedup window) that survives across `beforeEach`
|
||||
truncations. A future contributor enabling parallelism (or moving to
|
||||
`vitest@5` with isolation defaults flipped) will re-trigger the failures.
|
||||
**Fix shape**:
|
||||
- factor the APNs provider mock into a module-level factory keyed on
|
||||
`process.pid + test name` so each `vi.mock` resolves to a fresh stub
|
||||
- replace `truncate users` reliance with explicit cleanup of
|
||||
`audit_logs` / `integration_statuses` so the dedup test starts from a known
|
||||
zero-row baseline
|
||||
- once both green under `fileParallelism: true`, flip the config back
|
||||
|
||||
**Effort**: small
|
||||
|
||||
### F-9: e2e journey holes — no full "login → measurement → chart → mood → correlation → insight → review" path
|
||||
|
||||
**Severity**: medium
|
||||
**Category**: e2e gap
|
||||
**File(s)**: `e2e/measurement-flow.spec.ts:18-50` (mocks every fetch),
|
||||
`e2e/dashboard.spec.ts:122-163` (renders empty fixtures),
|
||||
`e2e/insights-generate.spec.ts:24-30` (mocked AI),
|
||||
`e2e/onboarding-flicker.spec.ts:28-130` (still failing on main per the
|
||||
v1.4.34 closure note)
|
||||
**What's wrong**: The current e2e coverage is good at smoke-level
|
||||
"does the surface render" but every spec stubs the API. There is no spec
|
||||
that drives a real Postgres-backed journey from login through to an insight
|
||||
review. The Withings + Apple Health connect/sync surfaces have NO e2e
|
||||
coverage at all. Onboarding-flicker has been failing since v1.4.34 and
|
||||
is referenced in user memory but not in the suite. Mobile-viewport
|
||||
touch-target ≥ 44 × 44 has been failing for the same window.
|
||||
**Fix shape**:
|
||||
- one `e2e/full-journey.spec.ts` that runs against the seeded test user and
|
||||
hits real endpoints end-to-end: log mood → log measurement → open chart →
|
||||
trigger insight (mocked AI provider that returns a canned JSON) →
|
||||
open Coach (same provider) → open Doctor Report
|
||||
- a real fix for `onboarding-flicker` rather than ignoring it (the spec
|
||||
asserts cookie-driven SSR; if the cookie wiring has changed, the spec needs
|
||||
a rewrite, not a skip)
|
||||
- one `e2e/withings-connect.spec.ts` that stubs the Withings OAuth endpoint
|
||||
and asserts the callback handles the state-cookie path
|
||||
|
||||
**Effort**: medium
|
||||
|
||||
### F-10: Timezone coverage stops at one non-Berlin user; locale coverage is one spec
|
||||
|
||||
**Severity**: medium
|
||||
**Category**: locale/tz gap
|
||||
**File(s)**: `tests/integration/timezone-per-user.test.ts:1-300` (Pacific/Auckland only),
|
||||
`tests/integration/analytics-weekday-tz.test.ts` (assumes Berlin),
|
||||
`e2e/locale-switch.spec.ts:11-50` (asserts no raw-key bleed for EN+DE only)
|
||||
**What's wrong**: Six locales ship (`de/en/es/fr/it/pl`) and `messages/de.json`
|
||||
+ `messages/en.json` parity is unit-asserted, but the runtime "does the page
|
||||
render in this locale" is checked for EN+DE only. The DST-crossover edge
|
||||
(Auckland UTC+12 → UTC+13 happens in late September) is documented in the
|
||||
test comment but not asserted; the test was authored in May. UTC users and
|
||||
fractional-offset zones (Asia/Kolkata UTC+05:30) have no test. Every "today"
|
||||
calculation in `src/lib/timezone.ts`, `src/lib/medications/scheduling/cadence.ts`,
|
||||
`src/lib/insights/medication-compliance-status.ts` assumes the resolver
|
||||
delivers a stable zone, but the resolver is only directly tested for one
|
||||
user.
|
||||
**Fix shape**:
|
||||
- parameterise `analytics-weekday-tz.test.ts` over `["UTC", "Europe/Berlin", "Pacific/Auckland", "Asia/Kolkata", "America/Los_Angeles"]`
|
||||
- extend `locale-switch.spec.ts` to all six locales (use a `for` loop, same shape)
|
||||
- add a unit test in `src/lib/__tests__/timezone.test.ts` that pins the DST-crossover case (Auckland 2026-09-27 02:00 NZST → 03:00 NZDT)
|
||||
|
||||
**Effort**: small `[hotfix-ready]` for the locale loop; small for the others
|
||||
|
||||
## Coverage matrix
|
||||
|
||||
| Critical path | Unit | Integration | e2e | Notes |
|
||||
|--|--|--|--|--|
|
||||
| Login (password, web cookie) | partial | yes (`auth-flow`) | yes (`login.spec`) | route covers native Bearer branch only (`auth/login/__tests__/native-token.test.ts`); password-credential validation untested at route |
|
||||
| Login (passkey) | yes | gap | gap | only `login-verify/native-token.test.ts` exists; ceremony untested |
|
||||
| Password change | gap | gap | gap | F-1 — route has no test, token wipe unverified |
|
||||
| Password reset (admin) | gap | gap | gap | `admin/users/[id]/reset-password/route.ts` no test |
|
||||
| Passkey register | gap | gap | gap | F-2 — no route tests |
|
||||
| Session refresh | helper-only | gap | gap | F-2 — `/api/auth/refresh` no route test |
|
||||
| Apple Health import upload | yes (helpers) | partial | gap | F-4 — worker untested, no kill-and-resume |
|
||||
| Apple Health admin variant | partial | gap | gap | route validation only; pipe untested |
|
||||
| Withings OAuth handshake | gap | gap | gap | F-3 — `callback`, `connect`, `disconnect`, `exchangeCode`, `refreshAccessToken` |
|
||||
| Withings webhook | yes | gap | gap | both webhook surfaces well-tested at route level |
|
||||
| Withings full + incremental sync | partial (mapping) | yes (sleep/activity slices) | gap | route `/api/withings/sync` untested |
|
||||
| Measurement CRUD | yes | yes | partial (mocked) | strong coverage |
|
||||
| Source-priority dedup | yes | yes | gap | best-covered area in the suite |
|
||||
| Mood/medication intake | yes | yes | gap | bulk + idempotency well-tested |
|
||||
| Compliance classifier | yes | gap | gap | classifier covered, route untested end-to-end |
|
||||
| AI insights provider chain | yes | yes | gap | F-7 — no-key fallback strings untested |
|
||||
| AI Coach chat | yes | yes | gap | sentinel parsing well-covered; 429 path tested |
|
||||
| Doctor-report PDF | yes (core+data) | gap | yes (mocked) | section-toggle persistence covered at route level; no PII redaction toggle exists (only section toggles) |
|
||||
| Admin user CRUD | yes | gap | gap | route tested at unit level only |
|
||||
| Admin impersonation | n/a | n/a | n/a | feature does not exist in repo |
|
||||
| Admin force-logout | gap | gap | gap | `/admin/users/[id]/force-logout` no test |
|
||||
| Admin backup upload/restore | gap | yes | gap | integration tests cover happy + confirm-required + 404 |
|
||||
| Admin backup run | gap | gap | gap | scheduled-run route untested |
|
||||
| Cache invalidation per write | partial | partial | n/a | F-6 — 2 / 18 write surfaces verified |
|
||||
| 100k-row analytics perf | gap | gap | gap | F-5 — no assertion budget anywhere |
|
||||
| Time zones | partial | yes (one zone) | gap | F-10 — Auckland only, DST crossover unasserted |
|
||||
| Locale e2e | n/a | n/a | partial (EN+DE) | F-10 — four locales never exercised at runtime |
|
||||
| Visual regression | n/a | n/a | gap | no snapshot suite; acceptable given audit-grounded layout, but worth noting |
|
||||
|
||||
## Test discipline notes
|
||||
|
||||
- The integration suite's `fileParallelism: false` is a band-aid, not a fix
|
||||
(F-8). Plan to convert each shared-state file to per-test isolation.
|
||||
- `e2e/v1427-public-pages.spec.ts` uses `test.fixme` on three otherwise-passing
|
||||
cases (`/about`, `/this-route-does-not-exist`, `/privacy`) — these should
|
||||
either be unblocked or removed; a fixme that never gets attention is dead code.
|
||||
- `e2e/insights-scroll-restoration.spec.ts:27` uses `test.fixme` on the only
|
||||
meaningful assertion in the file — same disposition required.
|
||||
- No snapshot testing of any kind. For an app where the value proposition is
|
||||
visual coherence (charts, tiles, hero card), a Playwright `toHaveScreenshot`
|
||||
pass on two breakpoints × three locales × five key pages would close the
|
||||
gap at low cost (one CI minute, no fixture seeding).
|
||||
@@ -0,0 +1,153 @@
|
||||
# UX + Accessibility Audit — 2026-05-16
|
||||
|
||||
## Executive summary
|
||||
|
||||
HealthLog's UX is in good shape on the surfaces that received recent
|
||||
attention — the bottom nav meets WCAG 2.5.5, the responsive sheet
|
||||
primitive is sound, the dashboard tile-strip's `auto-fit` grid is
|
||||
elegant, and the v1.4.33/.34 follow-ups closed most of the rough
|
||||
mobile edges. The strongest wins available are downstream of three
|
||||
generic primitives leaking English (dialog/sheet "Close", chart
|
||||
skeleton "Loading chart…") and one structural bug: the `?add=` deep
|
||||
link the Insights empty states ship into `/measurements` uses tokens
|
||||
the form's allow-list silently drops, so the CTA opens an unrelated
|
||||
form (Blutdruck instead of Körpertemperatur). The worst latent pain
|
||||
is on keyboard navigation: the bottom-nav strip and the mobile
|
||||
top-bar user menu have no visible focus indicator at all, and four
|
||||
non-German locales still ship 12+ English plural strings as
|
||||
top-level surfaces.
|
||||
|
||||
## Findings — prioritized
|
||||
|
||||
### F-1: Insights empty-state CTA opens the wrong measurement form
|
||||
|
||||
**Severity**: high
|
||||
**Scope**: insights, measurements
|
||||
**File(s) / Surface**: `src/app/measurements/page.tsx:19-28`, `src/app/insights/koerpertemperatur/page.tsx:26`, `src/components/insights/healthkit-metric-page.tsx:105`
|
||||
**What's wrong**: `ALLOWED_ADD_TYPES` on the measurements page accepts `"GLUCOSE"`, `"TEMPERATURE"`, `"HEART_RATE"`, `"BMI"` — none of which exist as `MEASUREMENT_TYPES` values in `measurement-form.tsx` (the canonical names are `BLOOD_GLUCOSE`, no temperature entry, `PULSE`, no BMI entry). At the same time `/insights/koerpertemperatur` emits `/measurements?add=BODY_TEMPERATURE`, also missing from the allow-list. Net effect: the link opens the page but `ALLOWED_ADD_TYPES.has(addParam)` returns false, `defaultType` stays undefined, the dialog still opens (because the surrounding branch sets `dialogOpen` only inside the `has()` block) — actually, the dialog stays closed for unknown types and the user lands on `/measurements` with no dialog at all. The empty-state CTA looks broken.
|
||||
**Fix shape**: Replace `ALLOWED_ADD_TYPES` with a single source of truth derived from `MEASUREMENT_TYPES` (`MEASUREMENT_TYPES.map(t => t.value)`), plus a normalisation map that translates the legacy/Insights tokens (`GLUCOSE → BLOOD_GLUCOSE`, `TEMPERATURE → BODY_TEMPERATURE` and add a body-temperature row to the form, `HEART_RATE → PULSE`, `BMI → WEIGHT`). Add a unit test that walks every `emptyStateCtaType` referenced under `src/app/insights/**` and asserts it resolves to a real form type.
|
||||
**Effort**: small `[hotfix-ready]`
|
||||
|
||||
### F-2: Dialog/Sheet close-X labels are English-only
|
||||
|
||||
**Severity**: high
|
||||
**Scope**: global
|
||||
**File(s) / Surface**: `src/components/ui/dialog.tsx:87`, `src/components/ui/sheet.tsx:89`
|
||||
**What's wrong**: Both primitives ship `<span className="sr-only">Close</span>` as the only accessible name on the icon-only close button. A screen-reader user on the German app hears "Close, button" on every modal and every quick-entry sheet. The i18n key `common.close` already exists in every locale (`messages/de.json:9 → "Schließen"`).
|
||||
**Fix shape**: Inject the label as a prop or read it from the i18n context at the call site (the primitives are client components already, so `useTranslations()` is a one-line lift). Same fix in both files. Add an ESLint rule or test that forbids literal English inside `<span className="sr-only">` outside locale files.
|
||||
**Effort**: trivial `[hotfix-ready]`
|
||||
|
||||
### F-3: Bottom-nav primary links have no visible focus indicator
|
||||
|
||||
**Severity**: high
|
||||
**Scope**: global (mobile)
|
||||
**File(s) / Surface**: `src/components/layout/bottom-nav.tsx:102-107`, `src/components/layout/top-bar.tsx:67`
|
||||
**What's wrong**: The five primary bottom-nav links and the "More" overflow button rely on a `transition-colors` between `text-muted-foreground` and `text-primary` — no `focus-visible:ring-*` and no underline. Keyboard / switch-control users tabbing through the bar see no focus at all. Same problem on the mobile top-bar user menu: it explicitly carries `focus:outline-none` without a `focus-visible:` replacement. The sidebar got `focus-visible:ring-[3px]` in v1.4.33; the mobile navs missed it.
|
||||
**Fix shape**: Append `focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-offset-2` to the bottom-nav `<Link>` / overflow-button className and the top-bar trigger. The ring radius matches the sidebar treatment; no design-token change needed.
|
||||
**Effort**: trivial `[hotfix-ready]`
|
||||
|
||||
### F-4: Five locales ship English plural strings as surface copy
|
||||
|
||||
**Severity**: high
|
||||
**Scope**: global (i18n)
|
||||
**File(s) / Surface**: `messages/es.json`, `messages/fr.json`, `messages/it.json`, `messages/pl.json` (lines 345, 379, 428–429, 437, 1106–1108, 1216, 1492, 953, 1194)
|
||||
**What's wrong**: 12+ count-bearing strings carry the English source text in es / fr / it / pl. The user-visible offenders include `measurementCount`, `entryCount`, `importDuplicatesSkipped`, `importInvalidSkipped`, `apiTokenCount`, `relativeMinutesAgo`, `relativeHoursAgo`, `relativeDaysAgo`, `dayStreak`, `remainingMany`, `headlineCaption`, `provisional`. Polish additionally folds `_few` / `_many` / `_one` plural buckets into a single `_other` key — Polish "1 tydzień / 2 tygodnie / 5 tygodni" cannot be expressed by a single string. Today the app renders "5 tygodni temu" via a single hardcoded fallback regardless of count.
|
||||
**Fix shape**: (a) translate the 12 leaked strings in each locale (machine-pre-fill is acceptable for the first pass), (b) extend the i18n primitive to read CLDR plural categories via `Intl.PluralRules(locale).select(n)` and add per-locale `_one` / `_few` / `_many` / `_other` keys for the four count strings that appear in long form. Add a CI grep that fails the build if any non-English locale file contains the literal " skipped" / " ago" / " more" / " readings" / " measurements" / " entries" substring.
|
||||
**Effort**: medium
|
||||
|
||||
### F-5: Chart skeleton ships an English-only sr-only loading announcement
|
||||
|
||||
**Severity**: medium
|
||||
**Scope**: global (charts)
|
||||
**File(s) / Surface**: `src/components/charts/chart-skeleton.tsx:41`
|
||||
**What's wrong**: Every chart (dashboard, /insights, every sub-page, mood, BD compliance) mounts behind this skeleton during the lazy chunk fetch. The sr-only announcement reads "Loading chart…" in every locale. Same root cause as F-2 — locale-aware primitives that fall back to literal English.
|
||||
**Fix shape**: Lift `useTranslations()` into `<ChartSkeleton>` and read `charts.loadingLabel` (add the key to each locale). The component is already `"use client"` so the lift is free. The `aria-live="polite"` + `aria-busy="true"` machinery stays as-is.
|
||||
**Effort**: trivial `[hotfix-ready]`
|
||||
|
||||
### F-6: Recharts charts have no accessible name or text alternative
|
||||
|
||||
**Severity**: medium
|
||||
**Scope**: global (charts)
|
||||
**File(s) / Surface**: `src/components/charts/health-chart.tsx:1278-1280`, every `<MoodChart>` / `<MedicationComplianceChart>` mount
|
||||
**What's wrong**: The chart container is a bare `<div>` wrapping a Recharts `<ResponsiveContainer>`. There is no `role="img"`, no `aria-label`, no `aria-describedby` pointing at the chart title above it, and no fallback `<figcaption>` summarising the trend. A screen-reader user lands on the chart and hears nothing — the title above the chart and the SVG are unrelated for AT purposes. WCAG 1.1.1 (non-text content) requires either a text alternative or a sibling description.
|
||||
**Fix shape**: Wrap the `<ResponsiveContainer>` in `<figure role="group" aria-labelledby={titleId} aria-describedby={summaryId}>`, hoist the existing title `<h3>` to own the `id={titleId}`, and synthesise an sr-only `<figcaption id={summaryId}>` from the existing `DataSummary` (latest, avg30, slope direction, target-band hit / miss) — the data is already in scope. The visual chart stays byte-identical. Same shape on `<MoodChart>` and `<MedicationComplianceChart>`.
|
||||
**Effort**: medium
|
||||
|
||||
### F-7: Inputs sit at 40 px, below the WCAG 2.5.5 44 px floor on mobile
|
||||
|
||||
**Severity**: medium
|
||||
**Scope**: forms (global)
|
||||
**File(s) / Surface**: `src/components/ui/input.tsx:70`, `src/components/ui/native-select.tsx:39`, `src/components/ui/select.tsx:40`
|
||||
**What's wrong**: The `Input` and both `Select` primitives lock `h-10` (40 CSS px) across every breakpoint. The button primitive grew a responsive `min-h-11 sm:min-h-9` pattern in v1.4.33 to hit the floor on mobile and shrink on desktop; the inputs never received the same treatment. The measurement form on a Pixel-5 viewport (375 px) puts the numeric value field, the datetime field, the glucose-context select, and the notes field at 40 px each — every one is a Lighthouse target-size failure.
|
||||
**Fix shape**: Switch the base to `h-11 sm:h-10` (or `min-h-11 sm:min-h-10`) on `Input`, `Select` trigger, and `NativeSelect`. Audit `<DateTimeInput>` (`src/components/ui/date-input.tsx`) for the same fix. Optionally introduce a `size="sm"` variant for filter chips that explicitly opts out.
|
||||
**Effort**: small `[hotfix-ready]`
|
||||
|
||||
### F-8: Admin mobile chip strip skips the auto-scroll-into-view treatment
|
||||
|
||||
**Severity**: medium
|
||||
**Scope**: admin
|
||||
**File(s) / Surface**: `src/components/admin/admin-shell.tsx:173-219`
|
||||
**What's wrong**: The settings shell received `snap-x snap-mandatory` + a `scrollIntoView({inline: "center"})` effect in v1.4.33 IW4/IW7 so the active chip stays in view on route change. The admin shell shipped 13 chips and got neither — admins who tap a chip past the right edge land on the new page with the strip still scrolled to the left, and the active chip lives off-screen. Same UX-broken-feeling regression the settings shell had before v1.4.33.
|
||||
**Fix shape**: Copy the settings-shell pattern verbatim — `ref` + `useEffect` selecting `[aria-current="page"]` + `scrollIntoView({block: "nearest", inline: "center", behavior: "smooth"})`, plus `snap-x snap-mandatory` on the `<nav>` and `snap-start` on each `<li>`.
|
||||
**Effort**: trivial `[hotfix-ready]`
|
||||
|
||||
### F-9: List page subtitles hidden on `<sm` give mobile users no orientation
|
||||
|
||||
**Severity**: low
|
||||
**Scope**: measurements, mood, medications
|
||||
**File(s) / Surface**: `src/app/measurements/page.tsx:85`, `src/app/mood/page.tsx:45`, `src/app/medications/page.tsx:157`
|
||||
**What's wrong**: Every list page hides its descriptive subtitle on `<sm` (`hidden text-sm sm:block`). Mobile users see only the H1 — "Messungen", "Stimmung", "Medikamente" — with no explanatory frame. The descriptions exist in every locale and the row above already gives the H1 plenty of horizontal slack. Comparison apps (Apple Health, Withings) keep the subtitle on mobile because the description doubles as the page's contextual help.
|
||||
**Fix shape**: Drop the `hidden sm:block` and let the subtitle wrap. Optionally shrink to `text-xs` on `<sm` so the H1 still anchors the visual.
|
||||
**Effort**: trivial `[hotfix-ready]`
|
||||
|
||||
### F-10: Hardcoded "Breadcrumb" aria-label in the notifications-settings section
|
||||
|
||||
**Severity**: low
|
||||
**Scope**: settings
|
||||
**File(s) / Surface**: `src/components/settings/notifications-section.tsx:49`
|
||||
**What's wrong**: `<nav aria-label="Breadcrumb">` is announced verbatim — a German screen-reader user hears "Breadcrumb, navigation". Every other breadcrumb in the app routes the label through `t(…)`.
|
||||
**Fix shape**: Replace with `aria-label={t("nav.breadcrumb")}` and add the key (`"Brotkrumen-Navigation"` / `"Breadcrumb navigation"`).
|
||||
**Effort**: trivial `[hotfix-ready]`
|
||||
|
||||
### F-11: Dashboard Coach surface — drawer still reachable but discoverability degraded
|
||||
|
||||
**Severity**: informational
|
||||
**Scope**: dashboard, insights
|
||||
**File(s) / Surface**: `src/app/page.tsx` (Coach CTA removed v1.4.34.3), `src/app/insights/layout.tsx:39`
|
||||
**What's wrong**: The dashboard CTA was removed for valid reasons (visually loud, wrong placement). The Coach is still reachable: hero-strip "Ask the coach" button on `/insights`, `<LayoutCoachFab>` on `<lg` viewports across `/insights/**`, per-page `<CoachLaunchButton>` instances, and the `<CoachLaunchProvider>` stays mounted at the auth-shell level. On `lg+` (desktop) the FAB is hidden by `lg:hidden`, so a desktop user who isn't on `/insights` has no entry point at all (the dashboard has none, the sidebar has none). The hero strip is the only desktop discovery surface.
|
||||
**Fix shape**: Either accept the constraint (Coach is a per-feature affordance, not a global one — current state) or surface a single sidebar entry under "Coach" so the desktop user can launch the drawer without a route hop. I'd leave it alone for v1.4.34.x and re-evaluate when the Coach feature flag stabilises.
|
||||
**Effort**: small (if pursued); no action needed right now
|
||||
|
||||
## Out of scope / accepted constraints
|
||||
|
||||
- **Recharts stays.** No replacement library considered; the audit
|
||||
treats the existing chart visual identity as a fixed contract per
|
||||
the memory directive `feedback_charts_visual_identity.md`.
|
||||
- **Settings shelf depth.** The 9-section list with the mobile
|
||||
horizontal-scroll chip strip + the v1.4.33 auto-scroll-into-view
|
||||
effect lands in a good place; the per-provider AI-section is
|
||||
already dropdown-driven per the memory directive.
|
||||
- **Comparison-overlay caption on tiles.** `<TrendCard>` already
|
||||
paints `Δ {value} {unit} vs. last month/year` with `aria-label`
|
||||
parity and the slot has the v1.4.33 `min-h-[18px]` reservation.
|
||||
No issue found.
|
||||
- **Stale-data caption on tiles.** v1.4.34 IW-B's bucket-aware
|
||||
copy reads naturally in DE and the integration test asserts the
|
||||
forwarding contract; deferred unless a real user reports a bucket
|
||||
bug.
|
||||
|
||||
## What you didn't get to
|
||||
|
||||
- A keyboard-only run through the `/insights/medikamente` sub-page
|
||||
+ therapy timeline (the timeline uses an `sr-only h2` per
|
||||
`therapy-timeline.tsx:121` — that's the right pattern but the
|
||||
surrounding focus order wasn't traced).
|
||||
- An RTL / Arabic dry run; the i18n stack is six locales LTR only,
|
||||
so RTL is genuinely deferred until the locale set grows.
|
||||
- The bug-report and notifications inbox surfaces beyond their
|
||||
shells; the bug-report form was not traced for validation /
|
||||
empty-state quality.
|
||||
- A real Lighthouse run against `healthlog.bombeck.io` — the
|
||||
audit is grep-driven against the source tree; the live CWV
|
||||
numbers (especially CLS during chart hydration) would
|
||||
cross-check whether the F-6 + F-7 fixes shift Lighthouse score.
|
||||
@@ -10,15 +10,16 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg" alt="License: AGPL-3.0" /></a>
|
||||
<img src="https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&logoColor=white" alt="TypeScript" />
|
||||
<img src="https://img.shields.io/badge/Next.js-16-black?logo=next.js" alt="Next.js 16" />
|
||||
<a href="https://github.com/MBombeck/HealthLog/releases"><img src="https://img.shields.io/github/v/release/MBombeck/HealthLog?sort=semver&color=success" alt="Latest release" /></a>
|
||||
<img src="https://img.shields.io/badge/Self--Hosted-yes-success" alt="Self-Hosted" />
|
||||
<img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs Welcome" />
|
||||
<img src="https://img.shields.io/badge/Next.js-16-black?logo=next.js" alt="Next.js 16" />
|
||||
<img src="https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&logoColor=white" alt="TypeScript" />
|
||||
<a href="https://github.com/MBombeck/HealthLog/pkgs/container/healthlog"><img src="https://img.shields.io/badge/GHCR-multi--arch-2496ED?logo=docker&logoColor=white" alt="GHCR multi-arch image" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A self-hosted, privacy-first health tracking PWA.<br/>
|
||||
Weight, blood pressure, mood, medications, AI insights -- all under your control.
|
||||
Self-hosted health tracker. Weight, blood pressure, glucose, mood, medications.<br/>
|
||||
Withings and Apple Health sync, multi-provider AI Insights you own, doctor-report PDF.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -31,7 +32,9 @@
|
||||
|
||||
## What it is
|
||||
|
||||
HealthLog is a self-hosted personal health tracker that runs from a single `docker compose up`. It covers the metrics most people actually log -- weight, blood pressure, pulse, body composition, blood glucose, sleep, mood, and medication compliance -- and brings them together in one dashboard with reference ranges from ESC/ESH 2018, ADA 2024, and NICE NG115. Withings devices sync automatically; multi-provider AI Insights (BYOK or local) explain what the numbers mean; a doctor-report PDF generates client-side. EN/DE end-to-end. AGPL-3.0.
|
||||
HealthLog is a self-hosted personal health tracker that runs from a single `docker compose up`. It covers the metrics most people actually log -- weight, blood pressure, pulse, body composition, blood glucose, sleep, mood, and medication compliance -- and brings them together in one dashboard with reference ranges from ESC/ESH 2018, ADA 2024, and NICE NG115. Withings devices sync automatically; an `export.zip` import folds your full Apple Health history into the same timeline; multi-provider AI Insights (BYOK or local) explain what the numbers mean; a doctor-report PDF generates client-side. EN/DE end-to-end. AGPL-3.0.
|
||||
|
||||
> **Status**: active. New releases roughly weekly -- see [CHANGELOG](CHANGELOG.md). Current focus: native iOS client (v1.5).
|
||||
|
||||
Built for people who want their health data on their own server -- whether that's a NAS, a homelab, or a small VPS -- and who don't want to hand it to a US cloud to read a 7-day weight trend. **Try the [live demo](https://demo.healthlog.dev)** to see what a working install looks like, or skip to [Quick Start](#quick-start) below.
|
||||
|
||||
@@ -43,6 +46,22 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
|
||||
|
||||
---
|
||||
|
||||
## How it compares
|
||||
|
||||
| | HealthLog | Withings web | Apple Health | Oura web | Generic CSV |
|
||||
| ------------------------ | -------------------- | --------------- | ------------- | ----------- | ----------- |
|
||||
| Self-hosted | Yes | No | No | No | Yes |
|
||||
| Open source | AGPL-3.0 | No | No | No | n/a |
|
||||
| Withings device sync | Yes (OAuth2) | Yes (native) | Via shortcut | No | No |
|
||||
| Apple Health import | Yes (`export.zip`) | No | Native | No | Manual |
|
||||
| Custom clinician targets | Yes (audit-logged) | Limited | No | No | n/a |
|
||||
| Doctor-report PDF | Yes (client-side) | No | No | No | n/a |
|
||||
| AI Insights | Multi-provider BYOK | No | Limited | Subscription| n/a |
|
||||
| Subscription required | No | For some metrics| No | Yes | No |
|
||||
| Your data leaves device | Never | Withings cloud | Apple cloud | Oura cloud | Depends |
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -57,7 +76,9 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
|
||||
|
||||
**Withings Integration** -- OAuth2 device sync for scales, blood pressure monitors, and activity trackers with automatic deduplication.
|
||||
|
||||
**Multi-Provider AI Insights** -- Pick OpenAI, Anthropic Claude, or any local OpenAI-compatible endpoint (Ollama, LM Studio, vLLM). BYOK or admin-shared key. Cached daily. Local endpoints keep all data on your network.
|
||||
**Apple Health import** -- Drop your iOS `export.zip` on the import page. A streaming parser handles multi-gigabyte archives (Zip64), folds every `<Record>`, `<Workout>`, `<Correlation>`, and `<ClinicalRecord>` into the same timeline as your other metrics, and stays idempotent on re-upload. Per-type ingestion stats plus a live status endpoint so you can watch the progress on a long historical drain.
|
||||
|
||||
**AI Coach + Insights** -- A conversational Coach grounded in your own data, a daily briefing, a weekly report, and a Health Score tile on the dashboard. Pick OpenAI, Anthropic Claude, or any OpenAI-compatible local endpoint (Ollama, LM Studio, vLLM). BYOK or admin-shared. Every claim links back to the measurements that produced it. Local endpoints keep all data on your network.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -87,11 +108,10 @@ cd HealthLog
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Generate the four required secrets and paste them into `.env`:
|
||||
Generate the three required secrets and paste them into `.env`:
|
||||
|
||||
```bash
|
||||
echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)" >> .env
|
||||
echo "SESSION_SECRET=$(openssl rand -hex 32)" >> .env
|
||||
echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
|
||||
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
|
||||
```
|
||||
@@ -151,7 +171,6 @@ HealthLog is designed for people who take data ownership seriously.
|
||||
| -------------------- | ------------------------------------------------------------- |
|
||||
| `POSTGRES_PASSWORD` | Password for the bundled Postgres service (Docker Compose) |
|
||||
| `DATABASE_URL` | PostgreSQL connection string (uses `POSTGRES_PASSWORD` above) |
|
||||
| `SESSION_SECRET` | 64-char hex string for session signing |
|
||||
| `ENCRYPTION_KEY` | 64-char hex string for AES-256-GCM |
|
||||
| `API_TOKEN_HMAC_KEY` | 64-char hex string for API token hashing |
|
||||
|
||||
@@ -239,7 +258,7 @@ src/
|
||||
|
||||
## API Reference
|
||||
|
||||
All mutations require authentication via session cookie. External ingest uses Bearer tokens.
|
||||
All mutations require authentication via session cookie. External ingest uses Bearer tokens. A machine-readable OpenAPI 3.1 spec for the iOS-locked native subset lives at [`docs/api/openapi.yaml`](docs/api/openapi.yaml) — the source of truth for any client codegen (Swift / Kotlin / OpenAPI Generator).
|
||||
|
||||
<details>
|
||||
<summary><strong>Health Data</strong></summary>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# `docs/` — internal reference + operator playbooks
|
||||
|
||||
The user-facing documentation lives at **<https://docs.healthlog.dev>** —
|
||||
that's the canonical surface for self-hosting guides, integration
|
||||
walkthroughs, AI-provider setup, and the doctor-report workflow.
|
||||
|
||||
The files in this directory are the internal operator playbooks, audit
|
||||
notes, and machine-readable specs that ship alongside the source:
|
||||
|
||||
- [`api/`](./api/) — the OpenAPI 3.1 spec for the native-client subset
|
||||
(iOS DTO codegen target). See [`api/README.md`](./api/README.md) for
|
||||
the preview commands.
|
||||
- [`ops/`](./ops/) — backup / restore + encryption-key-rotation
|
||||
playbooks. These are the runbooks an operator reaches for during an
|
||||
incident.
|
||||
- [`self-hosting/`](./self-hosting/) — horizontal-scaling notes
|
||||
(`HEALTHLOG_PROCESS_TYPE=web|worker|all`) and the deploy-pipeline
|
||||
recipe. Routine install steps stay on `docs.healthlog.dev`.
|
||||
- [`migration/`](./migration/) — release-by-release migration notes.
|
||||
Read the entry for the version you're upgrading from.
|
||||
- [`audit/`](./audit/) — per-release audit summaries archived for
|
||||
forensic traceability.
|
||||
- [`apple-store-connect-checklist.md`](./apple-store-connect-checklist.md)
|
||||
— the iOS submission gate.
|
||||
- [`doctor-report.md`](./doctor-report.md) — PDF generator design
|
||||
notes.
|
||||
- [`codex-protocol-spec.md`](./codex-protocol-spec.md) — Codex / ChatGPT
|
||||
OAuth protocol reverse-engineering notes.
|
||||
- [`ui-guidelines.md`](./ui-guidelines.md) — UI / a11y house style.
|
||||
|
||||
If you're a self-hoster looking to spin up an instance, start at
|
||||
<https://docs.healthlog.dev> or jump to the [Quick Start in the project
|
||||
README](../README.md#quick-start).
|
||||
+15
-3
@@ -49,17 +49,29 @@ export const metadata: Metadata = {
|
||||
openGraph: {
|
||||
title: "HealthLog",
|
||||
description:
|
||||
"Personal health tracking — weight, blood pressure, pulse, mood, medication compliance",
|
||||
"Self-hosted health tracker. Weight, blood pressure, glucose, mood, medications. Withings + Apple Health sync. AI Insights you own.",
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
alternateLocale: ["de_DE", "fr_FR", "es_ES", "it_IT", "pl_PL"],
|
||||
siteName: "HealthLog",
|
||||
// Drop-in OG asset. Replace with a 1200×630 dashboard-screenshot
|
||||
// capture when an official one ships; the logo render keeps the
|
||||
// unfurl from rendering a blank tile in the meantime.
|
||||
images: [
|
||||
{
|
||||
url: "/logo-readme.png",
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
alt: "HealthLog — your health data, your server",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
card: "summary_large_image",
|
||||
title: "HealthLog",
|
||||
description:
|
||||
"Personal health tracking — weight, blood pressure, pulse, mood, medication compliance",
|
||||
"Self-hosted health tracker. Weight, blood pressure, glucose, mood, medications. Withings + Apple Health sync. AI Insights you own.",
|
||||
images: ["/logo-readme.png"],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user