The medication compliance percentage and the history view drew their per-dose attribution from four different matchers — the ±12h pairDoses proximity used by calculateCompliance, the deriveDoseStatus bands (open dose only), the 3h+120min classifyIntakeTiming heatmap heuristic, and a raw takenAt-IS-NOT-NULL per-day rollup. They could and did disagree: a dose could read "taken late" in the rate while the history called it an off-schedule ad-hoc take. Route the rate through the shared cadence-aware band minter + reconstructDoseHistory so the percentage is a tally over the SAME ledger the history view renders. The same intake can no longer be taken in the % and ad-hoc in the ledger. - calculateCompliance: when a medication context is supplied, the count fields (taken / skipped / missed / rate) come from tallyComplianceFromLedger. Numerator = taken_on_time + taken_late (a late dose still counts as taken — the taking-vs-timing split is kept available); denominator = taken + missed; skipped, ad-hoc, upcoming and whole PRN groups are excluded; rate capped at 100. The streak still walks the cadence timeline. Context-less pure-math callers keep the legacy timeline tally byte-stable. - complianceChips + missedDoses: adherence rate + missed count come from the same ledger when an engine context is threaded. - /api/medications/[id]/compliance heatmap: the per-day timing split and missed marks are bucketed from the one ledger instead of the per-day classifyIntakeTiming + expectedSlotCountForDay walk, so the cells agree with the headline rate. - medication_compliance_rollups stays a raw cross-medication daily coverage count, documented as distinct from the band-attributed % (the band minter is per-cadence and cannot run inside a single SQL tally). Downstream readers (health score, insight compliance status) keep calling calculateCompliance; their numbers shift to the honest band-attributed values. Some currently-counted off-time takes (e.g. an 11:29 take of a 07:00 dose) now read ad-hoc with the slot missed rather than taken-late — the configurable per-dose window is the lever to widen the on-time band. Tests: the irregular twice-daily, single-time-late heatmap, and legacy parity fixtures are updated with rationale for the intended number changes; new tally tests pin the taking/timing split, the skip / ad-hoc / upcoming / PRN exclusions, auto-missed=missed, the 100% cap, rolling retrospective families, and the cross-consistency invariant.
src/app/
The Next.js App Router tree: the page surfaces a logged-in user sees and the REST API under api/. Server components are the default; "use client" is added only where a hook, state, or browser API needs it. Routing is file-based — a page.tsx is a page, a route.ts is an API endpoint.
API routes (api/)
Every endpoint wraps its handler in apiHandler (@/lib/api-handler), parses any body with Zod safeParse, narrows userId from the session or Bearer token (never from the body), and returns the { data, error, meta? } envelope. requireAdmin() is cookie-only, so api/admin/ is unreachable by Bearer token. Notable groups:
auth/— login, register, password, passkey (WebAuthn), refresh, Codex device-OAuth.measurements/,mood-entries/,medications/,sleep/— the core health-data CRUD + batch/bulk ingest.ingest/— external Bearer-token ingest surfaces.insights/,analytics/,dashboard/— AI insights, the Coach SSE stream, trend summaries, the first-paint dashboard snapshot.withings/,whoop/,fitbit/,integrations/,sync/— OAuth connect/sync/webhook surfaces and the offline delta feed.fhir/,share-links/,doctor-report/,export/,import/— clinician-facing FHIR R4 read API, shareable records, report + data export.notifications/,devices/,send/,telegram/— channel registration, device tokens, dispatch, webhook intake.admin/— operator panel APIs (cookie-only).health/,version/,meta/,internal/— health check, build-version probe, runtime metadata, worker-internal routes.
Page surfaces
measurements/, medications/, mood/, insights/, achievements/, notifications/, settings/, onboarding/, admin/, plus the public about/, privacy/, bugreport/, the share-record route c/, and the root dashboard (page.tsx). Shared shell + chrome lives in layout.tsx; error.tsx / global-error.tsx / not-found.tsx / loading.tsx cover the framework boundaries. layout.tsx holds the one nonce-bound dangerouslySetInnerHTML (theme bootstrap).
Conventions
See ../../CLAUDE.md. The request-flow walk in .planning/codebase/arch.md annotates each layer with file:line citations. Related dirs: handlers compose ../lib/; pages render ../components/.