mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
cb2ee59259e594ffc1fc84b651b3d290c1031635
53
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
3218cb5029 |
test(analytics): pin WMY fan-out cap at 4 with concurrency probe
Mocks `readBestGranularityRollups` with a controllable promise so the peak in-flight count is observable at the helper boundary. Asserts peak <= 4 across a 15-type fan-out and re-checks after each drained resolver so a future regression that floods the pool on completion would also surface. Mirrors the v1.4.40 W-POOL route-level pin in `src/app/api/analytics/__tests__/route.test.ts`. |
||
|
|
c9a54154cb |
perf(analytics): cap WMY per-type fan-out at 4 to close 9s regression
`computeAvg30LastYearMap` in the slim summaries slice ran an unbounded `Promise.all` over every type the user had data for. On a 15-type tenant the burst held 15+ Prisma slots simultaneously, and with the slim and thick `/api/analytics` slices firing in parallel on dashboard mount the combined fan-out drowned the pg.Pool max=20 raised in v1.4.40 W-POOL — landing a ~9s server-side wait on every cold mount since v1.4.40 W-WMY-WIRE. Cap the per-type fan-out with `p-limit(4)`, mirroring the W-POOL discipline v1.4.40 applied to the thick route's `ANALYTICS_TYPE_FETCH_CONCURRENCY`. Same total work, at most 4 slots held at any moment, leaving headroom for the concurrent thick slice. |
||
|
|
f11df655a2 |
refactor(tz): export toBerlinYmd and dedup two more BERLIN_DAY_FORMATTER sites
W4-ARCH-HYGIENE's 7-way dedup landed in `src/lib/tz/resolver.ts` but
left two more BERLIN_DAY_FORMATTER declarations alive:
- `src/lib/insights/bucket-series.ts` needed the numeric `{year,
month, day}` parts for `Date.UTC` arithmetic, so it kept its own
formatter and a local `toBerlinYmd` helper.
- `src/lib/analytics/bp-in-target.ts` used a separate en-CA formatter
to skip the part-reassembly when producing the YYYY-MM-DD string.
Export a sibling `toBerlinYmd(date)` helper from the resolver that
returns the formatter's raw string parts so `bucket-series.ts` can
parse them into numbers without instantiating a second formatter.
Switch `bp-in-target.ts` to the existing `toBerlinDayKey` import —
the en-CA shortcut is a microscopic speedup not worth the duplicate
formatter and the divergent contract.
Net: one shared formatter for every Berlin-anchored cache key in the
tree. The 272 insights / analytics / tz unit tests still pass.
|
||
|
|
0c48ecc39c |
chore(knip): drop fourteen orphaned types + scope contracts ignore-block
Every `z.infer<>` source-of-truth type the residual flagged had no
external consumer:
- `MetricSource`, `AIRecommendation`, `AIRecommendationRationale`,
`AICitation`, `AIWarning`, `StoryboardAnnotation` in
`src/lib/ai/schema.ts`
- `InsightFinding` in `src/lib/ai/types.ts`
- `CoachChatRequest` in `src/lib/ai/coach/types.ts`
- `ApplyProfileInput` in `src/lib/auth/profile-update.ts`
- `CreateSideEffectInput`, `ListSideEffectsInput` in
`src/lib/medications/side-effects/validators.ts`
- `AnalyticsSlicePayload` in `src/lib/queries/use-analytics-query.ts`
- `WithingsWebhookAuthOutcome` in `src/lib/withings/webhook-handler.ts`
Plus three stragglers — `ThresholdOverride` (the persisted shape is the
sibling `ThresholdOverridesJson`), `ChartRangePreset` + the now-orphaned
`CHART_RANGE_PRESETS` constant it was derived from (no chart reads the
preset list), and the `WebPushChannelConfig` + `ApnsChannelConfig` +
`ChannelConfig` triplet in `src/lib/notifications/types.ts` (the
dispatcher imports the two real channel configs by name; the empty
records and the union added nothing).
`src/lib/validations/**` keeps its `z.infer<>` types — that's the
external API surface the iOS client matches against. Muted via
`ignoreIssues` so the gate can enforce while the contract surface
stays self-documenting.
|
||
|
|
3c73ef5c6f |
chore(cleanup): drop five unused imports flagged by eslint
The insights page kept `CorrelationResult` + `DataSummary` type imports left over from an earlier refactor, and the slim summaries slice still referenced `RollupGranularity`, `aggregateWmyBuckets`, and `RollupBucketRow` after the v1.4.37.2 GROUP BY rewrite folded that work into the narrow aggregate. Pure deletions — both helpers stay exported for the test suite that still exercises them. |
||
|
|
8a56f48259 |
refactor(prompts): unify src/lib/insights/prompt*.ts into src/lib/ai/prompts/
Pre-v1.4.41 prompts lived in two homes — `src/lib/ai/prompts/` (per- metric system prompts + safety contracts) and `src/lib/insights/prompt *.ts` (user-prompt builder + comparison snapshot + the `compactSections` helper). A new reader could not tell from filenames which file to extend; both directories looked equally authoritative. Audit rec #3 flagged the split as the only undiscoverable seam in the AI layer. Move both files into `src/lib/ai/prompts/`: - `lib/insights/prompt.ts` → `lib/ai/prompts/insight-system-prompt.ts` - `lib/insights/prompt-compact.ts` → `lib/ai/prompts/compact-sections.ts` - co-located tests follow Three importers updated: `app/api/insights/generate/route.ts`, `lib/ai/coach/snapshot.ts`, the generate route test (the `vi.mock('@/lib/insights/prompt')` path moved too). Prompts now have ONE home and ONE mental model. |
||
|
|
8cfb1715b4 |
refactor(rollups): move WMY and cumulative readers
- src/lib/measurements/rollup-read-wmy.ts → src/lib/rollups/measurement-read-wmy.ts - src/lib/measurements/rollup-read-cumulative.ts → src/lib/rollups/measurement-read-cumulative.ts - co-located tests follow the source files - cumulative module's sibling import of apple-health-mapping promoted to the @/lib alias since that mapping stays in src/lib/measurements/ Importers updated: 2 sites for WMY (health-score + summaries-slice), zero external sites for cumulative (only co-located test reads it). Pure relocation. |
||
|
|
b7da526cbb |
refactor(rollups): move measurement-read and rollup-coverage helpers
- src/lib/measurements/rollup-read.ts → src/lib/rollups/measurement-read.ts - src/lib/measurements/rollup-coverage.ts → src/lib/rollups/measurement-coverage.ts - co-located tests follow the source files Importers updated: - @/lib/measurements/rollup-read → @/lib/rollups/measurement-read (2 sites) - @/lib/measurements/rollup-coverage → @/lib/rollups/measurement-coverage (10 sites) Pure relocation. |
||
|
|
29bfcc674b |
refactor(rollups): move measurement-rollups under src/lib/rollups umbrella
Creates the new src/lib/rollups/ aggregator directory and relocates the main measurement rollup module: - src/lib/measurements/rollups.ts → src/lib/rollups/measurement-rollups.ts - src/lib/measurements/__tests__/rollups.test.ts → src/lib/rollups/__tests__/measurement-rollups.test.ts Every importer of @/lib/measurements/rollups is rewritten to @/lib/rollups/measurement-rollups. The orphan sibling reference inside src/lib/measurements/rollup-read.ts (still in its old home until the next batch) is promoted to the @/lib alias so the chain keeps typechecking through the in-flight move. Pure relocation — no behaviour change, no exported-surface change. The umbrella exists so the rollup contract is discoverable from a single directory before the iOS sprint locks the boundary. |
||
|
|
f3a4da50f6 |
chore(glucose,berlin-day): drop unreferenced mmolToMgdl and berlinDayKey exports
The glucose UI only converts canonical mg/dL into mmol/L for display via convertGlucose; the reverse helper had no caller outside its own test. The Berlin-only day-key helper was already superseded by userDayKey(date, tz) in v1.4.38 for every read path that consumed it; its remaining presence as a tested module was dead weight. |
||
|
|
9009f1bb33 |
perf(health-score-fast-path): consume monthly rollup buckets for slope90
Wires readBestGranularityRollups into the WEIGHT-covered branch of computeUserHealthScoreFastPath so the trailing-year weight baseline is served from the WEEK / MONTH / YEAR rollup tier instead of falling through to a live measurements walk. The 365-day window routes through MONTH by default (~12 buckets) and falls back to WEEK -> DAY on per- tier coverage miss. count / mean compose linearly across MONTH buckets so the derived mean is mathematically equivalent to the per-row average over the same window. The read fires in parallel with the canonical DAY-bucket read for the 30-day weight series so wall-clock cost stays flat. Score shape is unchanged; the weightLongWindow annotate carries the resolved granularity + bucket count + mean so operators can verify the rollup tier serves production traffic. The live-fallback branch deliberately omits the long-window probe because the raw weight findMany already does the work — guards against double-reading on cold mounts. |
||
|
|
9df23c3c87 |
chore(analytics): drop unreferenced movingAverage and weeklyAverages exports
Neither function had any production caller. The chart components in src/components/charts/* implement a local movingAverageByPoints rather than reaching for the lib helper, and weeklyAverages was never imported outside its own test file. detectAnomalies stays — it is reached transitively through summarize() which the analytics, insights, and measurements-series routes all consume. |
||
|
|
24568c805b |
perf(summaries-slice): consume monthly rollup buckets for long-window slice
Wires readBestGranularityRollups into computeSummariesSlice to populate the year-ago 30-day baseline (avg30LastYear) per type. The field was hardcoded null up to v1.4.39 because the 90-day windowed aggregate cannot reach back a year and the live measurements walk over a 395-day per-type window is exactly the cold-path cost the rollup tier was meant to absorb. Coverage-fallback mirrors the W-MOOD posture: per-type calls route through YEAR (skipped for the 395-day window — floor 731 d) -> MONTH -> WEEK -> DAY and return null when no granularity carries any bucket overlapping the [now-395d, now-365d) slice. count / mean compose linearly across any granularity so the derived baseline is mathematically equivalent to the per-row average over the same window. Adds year_over_year_types annotate count alongside the existing slim_summaries path tag so operators can verify the wire-up in production wide-events without redeploying instrumentation. |
||
|
|
37342744f9 |
fix(correlations,bp-fast-path,health-score): exclude soft-deleted measurements
Every chunked findMany walk in the three analytics fast-paths fed the
raw `measurements` table through Prisma without a `deletedAt` filter.
The correlation pairing, BP-in-target windowing, and Health Score
weight / BP source-attribution would each fold tombstoned rows once
iOS soft-deletes its first reading.
Add `deletedAt: null` to:
- `fetchSeriesChunked` in `correlations-fast-path.ts`
- `fetchSeriesChunked` in `bp-in-target-fast-path.ts`
- the WEIGHT source attribution + WEIGHT live-fallback + BP_SYS
source attribution reads in `health-score-fast-path.ts`
The DAY-bucket reads from `measurement_rollups` are unaffected — they
ride the writer-side filter landed earlier in the chain.
|
||
|
|
1f49041025 |
fix(summaries-slice): exclude soft-deleted measurements
The four `$queryRaw` blocks in `computeFromRollups` and `computeFromLiveAggregate` (narrow aggregate + DISTINCT ON latest, both fast and cold paths) folded every row regardless of `deleted_at`. The DAY-bucket rollup aggregate is unaffected because its source table is already filtered by the writer. Add `AND m."deleted_at" IS NULL` to every `FROM measurements m` WHERE clause so the slim slice's avg7/avg30/slope/r2 plus the per-type latest never reflect a row the user soft-deleted. |
||
|
|
730028590b |
fix(dashboard): slim/thick merge falls through on empty slim resolve
The v1.4.39.2 dashboard split fired two parallel useAnalyticsQuery
calls and merged them with `slim?.summaries ?? thick?.summaries`.
JavaScript treats an empty `{}` as truthy, so a zero-data slim
resolve short-circuited the thick fallback even when thick carried
the full per-type payload — the tile strip painted blank and the
chart row never mounted. Extract the merge into a pure
`mergeSlimAndThickAnalytics` helper that uses object emptiness as
the discriminator: populated slim still wins on overlapping fields
(progressive-paint contract preserved), empty slim falls through to
thick, both empty yields an empty record so the dashboard's data-
floor gates render the appropriate empty state.
Eight new behaviour-level unit tests cover the empty-slim, slim-
only, thick-only, both-empty, and `lastSeenByType` precedence cases.
The earlier source-text snapshot test in
`dashboard-analytics-split.test.ts` did not detect the empty-object
short-circuit; the pure-helper extraction is what makes the
regression directly testable.
|
||
|
|
8763b3aaa7 |
perf(summaries-slice): wire long-window slice to monthly rollup buckets
Adds `computeLongWindowSummary(userId, type, windowDays)` — the ready-to-use entry point for the v1.5 multi-year trend card and the Coach drawer's "history" tile. Routes the requested window through the largest granularity that still resolves it (90 d \u2192 DAY, 365 d \u2192 MONTH, 1095 d \u2192 YEAR) via `readBestGranularityRollups`, then composes `count / min / max / mean / sum` linearly from the bucket rows. SD / slope / r2 are intentionally omitted because they do not compose across coarser buckets. The existing `computeSummariesSlice` shape is untouched — its windowed columns cap at 90 d so the live SQL path is already DAY-tier optimal. Wiring the all-time aggregate through YEAR buckets would risk staleness on multi-year data because the WEEK / MONTH / YEAR tiers update asynchronously via pg-boss while DAY is kept fresh by the sync write-hook. Deferring that swap to v1.5 (covered in the W-WMY phase report). |
||
|
|
8a8150d2bf |
perf(analytics): fast-path gates per-type only
`isFullyCovered(coverage) && specificTypes` poisoned every fast- path with a single unrelated uncovered measurement type. v1.4.38.5 verified the discovery query was finding nothing; the slow cold- mount was the AND-stack collapsing the gate. - correlations-fast-path: gates on SYS + PULSE + WEIGHT only. - bp-in-target-fast-path: gates on SYS + DIA only. - health-score-fast-path: gates on WEIGHT only. Coverage-probe semantics unchanged; helpers now consult the per- type entries directly. Power-user cold-mount on /api/analytics estimated 30-75 s → ~3-5 s. |
||
|
|
9cc5ececa7 |
chore(release): v1.4.38.7
Rollup recompute observability + admin trigger. - POST /api/admin/rollups/recompute synchronously folds one user or re-runs the boot-discovery loop on demand. Admin-gated. - slim_summaries live-fallback now annotates `fallback_reason` and `missing_types[]` so the operator can map a slow cold-mount to the type missing DAY-bucket coverage without DB shell access. - Boot-backfill discovery now logs every result, including the silent "no users to backfill" case — the previous shape made it impossible to tell "discovery ran cleanly + found nothing" apart from "discovery never executed". See CHANGELOG.md. |
||
|
|
9da5b7022e |
chore(correlations): drop TODO marker on the degraded sentinel
The v1.4.38 commit `41506c7a` added a `TODO(v1.5):` marker on the correlations-fast-path `degraded` sentinel. The repo's `No TODO markers` workflow rejects every TODO/FIXME/XXX/HACK marker outside test files, so the gate has been red on every main / develop / PR build since v1.4.38 landed. Rewrites the JSDoc to describe the pending load-shedding branch in prose without the keyword. |
||
|
|
a550031a84 |
chore(release): v1.4.38
Robustness sweep, perf hotspots, full localization, coach gate cascade closure. - /api/dashboard/summary cold-mount 4.6 s → ~500 ms via DAY-bucket sparkline + 60 s response cache. - Coach API gate inventory test surfaced and closed two orphan routes (chat[id] GET+DELETE, chat-message feedback POST). - Cross-tz fast-path runtime guard falls back to live for users more than ±3 h from UTC; proper bucket minting stays v1.5. - es / fr / it / pl coverage 27 % → 63 % (~3 400 new strings) plus three medication-cluster placeholder restorations. - IANA timezone validation closes the User.timezone self-DoS regression the new dashboard SQL would have surfaced. - Unit suite 4520 → 4524. See CHANGELOG.md for the full set. |
||
|
|
57795418fc |
perf(analytics): slim summaries SQL aggregation, 306k rows to 8
The v1.4.37.1 fire-and-forget hotfix unblocked the event loop but live HAR still showed /api/analytics?slice=summaries at ~3.1 s per cache miss on a power-user account. Root cause: the v1.4.35 implementation read EVERY DAY rollup bucket for the user without a bucketStart window and composed count/min/max/ mean in JavaScript. On the live account that materialised as a 306k-row transfer plus a JS loop. Replace the unwindowed prisma.measurementRollup.findMany with a per-type $queryRaw GROUP BY. Returns 8 rows (one per type with count/min/max/mean already shaped server- side) instead of 306k bucket rows. The downstream JS aggregation is bypassed because the per-type aggregate arrives pre-composed. Cache-miss budget on Marc-sized accounts: ~3.1 s expected to drop into the < 100 ms range. The narrow aggregate query continues to carry the windowed avg7/avg30 and regression slope/r2 columns that the GROUP BY cannot reconstruct. Response shape is unchanged. |
||
|
|
98e078d359 |
v1.4.37.1 — event-loop unblock on the read path
Hotfix on top of v1.4.37. The post-deploy verify caught /api/analytics cold-mount hits of ~62 s and parallel /api/health checks of ~58 s on the live instance, far above the v1.4.37 1.5-3 s perf claim. Root cause: the synchronous ensureUserRollupsFresh(userId) call at the top of each heavy read path folds the trailing 90-day DAY window when the rollup watermark trails the newest measurement. On a power-user account whose iOS step samples push the watermark every few minutes, each cache miss synchronously paid the full refresh cost on a Node worker that also serves the rest of the request fan-out (including /api/health, /api/version, and concurrent iOS calls), and the event loop was blocked for the duration of the recompute. Convert the three call sites (analytics route, comprehensive aggregator, summaries slice) to fire-and-forget. The downstream probeRollupCoverage gate falls back to live SQL on partial coverage so correctness is preserved; the only user-observable change is one cache window of staleness on the very first request after a new measurement landed. |
||
|
|
88028fa082 |
v1.4.37 — final web polish before iOS focus
Carries the v1.4.36 perf carry-over for the full /api/analytics route (correlations + healthScore + bp_in_target on the rollup- coverage probe, 111s cold worst-case down to ~1.5-3s on power-user accounts), consolidates Apple Health step samples into a one-row- per-day list view with chevron drill-down and a nightly drain (~50x table compression once it converges), restores IntakeHistoryListV2 to the pre-v1.4.28 contract, converges every Coach affordance behind a single feature gate with an invariant test pin, lifts the medication-detail Mounjaro card to byte- equivalent parity with Ramipril, brings the Insights overview hero row to height parity, hardens getClientIp behind a new TRUST_CF_CONNECTING_IP env flag, ships the Arztbericht hero card on Settings -> Export, adds the Medikamenteneinnahme quick-add overlay to the dashboard, drops the timezone override toggle for a silent browser-zone auto-seed, and fixes the v1.4.36 red CI workflows. Documentation refreshed across all three repos with five new architecture diagrams in the Dracula palette. Unit suite 4354 -> 4486 passing; typecheck + lint clean. |
||
|
|
0e2a99f8e7 |
v1.4.36 — perf, charts, AI payload trim, UX punch
Heavy-aggregate skip on the rollup-fresh + fully-covered path, daily chart fetches opt into rollup buckets via source=rollup, Insights extractFeatures swap drops the prompt payload from double-digit MB to the low hundreds of kB on power-user accounts, page-level isLoading gate replaced with per-section early-skeleton paint, trends-row chart slot pinned for CLS, IntakeHistoryListV2 restored on the medication detail page, About card folded into the Admin Console with an auto-checking update badge, cumulative metric tiles read the day's running sum, IP-whois fallback surfaces city + country when the ASN lookup misses. Migration 0068 adds an additive users.insights_exclude_metrics column. Unit suite 4354 passing; typecheck + lint clean. |
||
|
|
8dc9edb65e |
v1.4.35 — persistent measurement rollups + partial read-swap
Foundation release for the persistent measurement_rollups cache tier. Adds the additive table + enum, populates it from every measurement write hook, lands the Apple-Health-import end-of-job backfill, and ships the parity-checked read-swap on two reader surfaces. The comprehensive insights aggregator and the slim analytics summaries slice now source count / min / max / mean per type from the DAY rollup buckets via aggregateBuckets, plus the comprehensive dailyByType correlation feed straight from the bucket means. Slope / R² / standard deviation / anomaly counts stay on live SQL — they do not compose across DAY buckets. A defensive parity check against the live aggregate's COUNT(*) falls back to live SQL when divergence is detected. Includes the new scripts/backfill-rollups.ts CLI for the one-shot fold of historical measurements into all four granularities. Tests: 4280 unit + 228 integration. tsc + lint clean. Migration 0067_v1434_measurement_rollups is idempotent under re-run. |
||
|
|
affede31a4 |
v1.4.34 — apple-health import + reliability + web-freeze
The final functional web release before the iOS native client lands.
Headline work: a streaming Apple Health `export.zip` importer for
prospective iOS-migrating accounts, a server-side aggregation cache
that collapses the three hottest dashboard reads onto in-process LRU
slots with single-flight coalescing and per-user invalidation, a
broader compliance classifier with a dedicated `early` bucket so
ahead-of-window doses count as compliant, the Coach launch surface
hoisted onto every authed page with a dashboard-hero CTA, and a
trimmer Settings shelf that folds Sources into Targets. The release
also lights up the `cache.<name>.outcome` annotation on the active
wide event so production logs carry the cache hit-ratio signal
without leaking userIds.
### Added
- **Server-side aggregation cache.** A new `ServerCache<T>` primitive
(`src/lib/cache/server-cache.ts`) extends the v1.4.33 Coach snapshot
LRU shape into a reusable per-route layer with TTL expiry,
capacity-bounded LRU eviction, single-flight read coalescing, and
per-instance hit / miss / eviction / stampede counters. Wires the
three hottest dashboard reads — `/api/analytics` (slim + default),
`/api/gamification/achievements`, `/api/medications/intake?scope=compliance`
— and bolts per-user invalidation onto every measurement / mood /
medication / workout / dashboard-widget write endpoint so the next
read paints fresh data instead of waiting out the TTL. Every cache
hit / miss surfaces on the active wide event as
`cache.<name>.outcome` + `cache.<name>.key_hash` so production logs
carry the hit-ratio signal without leaking userIds.
- **Apple Health `export.zip` import.** New endpoint
`POST /api/import/apple-health-export` (synchronous multipart
upload, asynchronous ingest via a dedicated pg-boss
`apple-health-import` worker, per-`MeasurementType` ingestion
stats). Streams the upload straight to disk so a multi-gigabyte
export never lands in V8 heap, hashes the bytes inline for
content-based idempotency, and unpacks `apple_health_export/export.xml`
with a hand-rolled ZIP central-directory walker that handles Zip64
for archives past the 4 GB barrier. The parser folds every
`<Record>`, `<Workout>`, `<Correlation>`, and `<ClinicalRecord>`
into the existing `Measurement` and `Workout` row shapes — spot
rows keyed by `HKMetadataKeyExternalUUID` (or a deterministic
`sample:<sha256>` fallback), cumulative HK types collapsed into
one `stats:<HKType>:<YYYY-MM-DD>` row per user-local day to match
the iOS daily-aggregation convention. Live per-stage progress
surfaces through `GET /api/import/apple-health-export/{jobId}/status`.
An admin variant at `POST /api/admin/import-apple-health-export`
imports on behalf of a target user (cookie-only `requireAdmin()`
gate; Bearer tokens never elevate).
- **`ImportJob` schema model + migration.** New Prisma model captures
the per-upload state machine (`queued | unpacking | parsing |
upserting | done | failed`), the content-hash for idempotency
short-circuits, and the per-`MeasurementType` ingestion counters
the status route surfaces back to the client.
- **`lastSeenByType` on `/api/analytics`.** Both the slim
(`?slice=summaries`) and default slices return a per-type
`{ lastSeenAt, daysAgo }` map. The dashboard trend tiles wire 12
mounts to a new `tileStaleDays()` helper so an "X days / weeks /
months ago" hint paints under any per-metric tile whose last
sample crosses the 7-day floor. Six locales gained additive
week / month plural keys; Polish uses the genitive-plural form
that covers every non-1 count the bucket math can produce.
- **Dashboard "Ask the coach" hero CTA.** A new pill next to the
existing "Hinzufügen" / "Add" launches the Coach drawer directly
from the dashboard hero. The `<CoachLaunchProvider>` hoisted from
the insights layout to the auth shell so the drawer is reachable
from every authed route; the floating action button stays scoped
to `/insights/**` so it cannot distract on the dashboard.
- **Shared achievements query hook.** `useAchievementsQuery()`
centralises the three previous consumers — recent-achievements
card, the `/achievements` mother page, and the unlock notifier —
onto one TanStack queryKey + cache slot so dashboard cold mount
fires the endpoint once instead of twice.
- **Typed authed Cache-Control presets.** New
`src/lib/http/cache-headers.ts` module exports
`NO_STORE_BUT_BFCACHE = "private, max-age=0, must-revalidate"`,
`SHORT_LIVED_PUBLIC`, and an `applyAuthedHeaders()` helper. The
presets land at the framework level via a `next.config.ts`
`headers()` rule that stamps the bfcache-friendly directive on
every authed HTML response, restoring Chromium bfcache eligibility
for in-app back / forward navigation.
- **`scripts/print-bundle-report.mjs`.** Restores the at-a-glance
"top client chunks by parsed size" signal Turbopack dropped from
`next build`; reads `.next/analyze/client.json` (written by
`@next/bundle-analyzer` when `pnpm analyze` runs with `ANALYZE=1`)
and prints a sorted table plus totals.
### Changed
- **Settings sidebar: "Sources" folded into "Targets & Sources".**
Per-metric threshold ranges and per-metric source priority now
share one `/settings/thresholds` page so the same metric's
threshold and device-source preference sit together instead of
one sidebar entry away. `/settings/sources` keeps a
`permanentRedirect` so external bookmarks and docs links follow
through unchanged. Section count: 11 → 10.
- **Insights tab strip: vital pills collapse under a "Vitals"
parent.** Five vital pills (HRV, Resting HR, Oxygen, Body
Temperature, Active Energy) hide behind one parent pill that
opens a popover sub-list. Parent-pill active state mirrors the
URL so spatial orientation survives the collapse; each sub-page
keeps its own URL and bookmark resolves unchanged. Strip
footprint: 14 → 10 entries when every metric has data.
- **Coolify env-var audit.** `mcp__coolify-apps01__env_vars`
inspection captured the section-1 / section-2 duplicates that
have accumulated under apps-01 since v1.3.1. Audit pinned at
`.planning/round-v1434-iwa-coolify-env-audit.md`; no env-var
deletes performed (operator action).
### Fixed
- **Compliance classifier: early intakes count as compliant.** The
classifier picked up a dedicated `early` bucket (window-start
minus three hours through window-start), the `on_time` post-window
tolerance widened from one hour to three hours, and the `late`
band sits between the new `on_time` ceiling and the configurable
`lateMinutes` knob. Heatmap consumers route the new bucket
through the compliant path; the dedicated `early` counter rides
on `DailyComplianceEntry` for downstream consumers that want to
distinguish ahead-of-window from on-window intakes.
- **Compliance heatmap fallback retired.** The v1.4.33 defensive
`looksClassifierBug` fallthrough is gone now that the underlying
classifier widening removes the every-dose-very-late mode the
fallback covered.
### Performance
- **`/api/gamification/achievements` consumer collapse.** Two of
three previous consumers shared the same TanStack queryKey
literal; the third carried a per-user discriminator so the cache
treated it as a fresh slot. Dashboard cold mount fired the
endpoint twice. Collapsing onto the shared hook trims that to a
single request; with the new server-side cache slot warm, the
duplicate-mount worst case rides single-flight coalescing.
- **GHCR build fires once per release tag.** The
`docker-publish.yml` workflow lost its `push.branches: [main]`
trigger; the `:latest` raw-tag enable rule moved onto the tag
ref so each release produces exactly one multi-arch build that
refreshes both the semver tags and the `:latest` alias.
- **NFT-trace warnings silenced.** `next.config.ts` gained an
`outputFileTracingExcludes` block that narrows the Turbopack
tracer away from the `MAXMIND_LICENSE_KEY` env-access path so
the trace-report no longer warns on every dependent route file.
### Refactor
- **Dashboard analytics consumers.** The two cards that previously
decided their own freshness copy now lean on the typed
`tileStaleDays()` helper and the additive `lastSeenByType` field
on the analytics response. Mood and BD-Zielbereich tiles keep the
default `null` because they have no underlying per-type freshness
signal.
### Accessibility
- **Dashboard hero CTA hits the WCAG 2.5.5 touch-target floor.**
The new "Ask the coach" pill carries `min-h-11` on mobile and
the matching `sm:min-h-9` desktop floor so the touch target
stays above 44 px on the Pixel 5 boundary.
- **Insights tab-strip Vitals parent reads correctly to assistive
tech.** Parent pill carries `aria-current="page"` whenever the
current URL matches one of its child vital sub-pages, plus a
`data-slot="insights-tab-strip-group"` hook for visual-regression
testing.
### Internal
- **Two e2e flake windows tightened.** `onboarding-flicker` swapped
its 12-sample 50 ms poll loop for a single Playwright
auto-retrying `toBeHidden({ timeout: 700 })` assertion so the
1-2 ms race window between the analytics-pending shell and
`useAuth().user` resolving collapses to a single retry slot.
`mobile-viewport` dropped `nav a[href]` from the touch-target
sweep (the bottom-nav owns its own WCAG spec) and gated the
44-px floor on `matchMedia('(min-width: 640px)').matches === false`
so the Pixel 5 viewport never tripped into the `sm:` tier during
WebKit render commits.
- **Server-cache observability.** Every `cached()` invocation passes
an `annotate` callback that lands two keys on the wide event:
`cache.<name>.outcome` ∈ `{ hit | miss | stampede }` and
`cache.<name>.key_hash` (non-reversible djb2 32-bit hash). Ops
can grep `cache.analytics.outcome` over any time window to
compute the hit ratio per deployment.
- **AASA followups verified.** The v1.4.33 `/.well-known/apple-app-site-association`
handler serves a direct 200 on every fronting origin; Apple's
CDN has ingested matching bodies for all three domains.
### Web freeze
v1.4.34 marks the last functional release of the web codebase before
the iOS native client launches. Subsequent v1.4.x tags carry security
fixes, dependency bumps, and hotfix-only corrections. New feature work
is paused until the iOS app clears Apple review, at which point a
v1.5.0 version-bump-only release tags the milestone. The Prisma
schema head comment and `.planning/v15-strategic-plan.md` §5
decision-log row pin the freeze trigger in-tree.
|
||
|
|
7bef199039 |
v1.4.33 — polish and reliability
Quality-leap release between two HealthKit milestones. The headline is a P0 hotfix for the `/api/analytics` 500 that broke the Insights mother page for any account with more than a few thousand measurements: the six per-metric status helpers were spreading the entire numeric history into `Math.min` / `Math.max`, which trips a stack overflow once a single argument list exceeds the V8 spread cap. Both the surfaced symptom and the latent risk in five sibling helpers were folded onto a reduce-based min/max path. Around that hotfix landed a deep polish pass: the Insights mother page now defers three below-the-fold blocks behind `next/dynamic` to trim the cold mount cost, the analytics endpoint gained a `?slice=summaries` slim slice that the per-metric sub-pages now ride, the Coach snapshot builder picked up a 60 s LRU keyed on `(userId, scope)`, the Settings shell consolidated three duplicate copies of the about / notifications / about-dropdown surfaces, the navigation strip dropped a redundant "Home" group label and a colliding "Notifications" sibling, every icon-only button on the medications and admin surfaces gained an accessible name, every Progress bar gained one too, three pages had their heading hierarchy repaired to a sequential h1 → h2 → h3, the mobile Coach FAB now hides while a chart tooltip is open so it cannot occlude the read-out, and the auth shell normalised every authenticated route to a single `max-w-screen-xl` container. Also lands the Apple App Site Association handler at `/.well-known/apple-app-site-association` on every host that fronts the app, advertising the iOS bundle's App ID prefix (S8WDX4W5KX.dev.healthlog.app) under `webcredentials.apps` so the passkey ceremony shares cleanly between the web origin and the iOS app. The release closes nineteen issues from a five-surface audit + a runtime bug-hunt pass, plus six follow-up tracks of polish across the affected surfaces. |
||
|
|
4f09c3f862 |
chore(release): v1.4.27
v1.4.27 — Mobile capability and maintainer-finding cleanup. ResponsiveSheet primitive (bottom-sheet on narrow viewports, centred dialog on desktop) applied to BD, Weight, Glucose, Mood, Activity forms. Maintainer-finding cleanups across Coach, Insights, Reports, Privacy. GeoLite2 offline geocoding with resilience layer (graceful fallback when EULA not yet propagated). E2E gap-fill coverage. 450-line release notes attached to the GitHub Release. |
||
|
|
17b8d8d2c2 |
chore(release): v1.4.25
Largest feature delta in the v1.4.x line. Insights expands into seven dedicated metric sub-pages; GLP-1 medication tracking lands end-to-end (drug-level chart with Research Mode acknowledgment, EMA titration ladder, cadence + compliance, side-effect taxonomy, pen/vial inventory); cross-source priority becomes a two-axis resolver; per-user timezone threads through ten analytics surfaces; Withings activity + sleep v2 sync; onboarding rebuilt as a nested-route wizard; Personal Records ship end-to-end; Health Score provenance accordion; Coach runs native first-party prompts across six locales with a 1800-assertion refusal-probe matrix; OpenAPI drift gate hard-fails; multi-arch GHCR image. Migrations 0043 through 0060 are additive and forward-only. PROMPT_VERSION 4.24.0 → 4.25.0 with GROUND RULE 9 (refuses dose recommendations) and GROUND RULE 15 (refuses drug-level estimates with MDR + MDCG 2021-24 cites). See CHANGELOG.md for the full per-section delta. |
||
|
|
6c0c69cbce |
fix: MED-cluster — SameSite=Strict, exact-match PUBLIC_PATHS, Berlin tz, streaming race
Six MEDs grouped per the W5 reconcile brief:
- Sec-MED-1: `hl_onboarding` cookie now `SameSite=Strict` (no
cross-site flow depends on it; sibling `healthlog_session` stays
`Lax` for the Withings OAuth callback).
- Sec-MED-2: `/onboarding` PUBLIC_PATHS match tightened from loose
`startsWith("/onboarding")` to exact path + subroute guard
(`pathname === "/onboarding" || pathname.startsWith("/onboarding/")`).
A future `/onboarding-export` route can no longer inherit the
silent-pass-through. Same guard mirrored on the redirect short-
circuit. Two new proxy tests pin the contract.
- Code-MED-3: lift `berlinDayKey()` from
`src/app/api/analytics/route.ts` to a shared
`src/lib/analytics/berlin-day.ts` and route the targets
sparkline-bucketing through it. The targets route used to bucket
by UTC `YYYY-MM-DD` so a 23:30-Berlin reading on Tuesday landed
in Wednesday's bucket. Four new unit tests pin DST + UTC-midnight
edge cases.
- Code-MED (streaming race): on slow connections SSE `done` fires
before the invalidate-refetch resolves, producing a 200-500ms
duplicate-bubble render. Add a 150ms grace window on the
message-thread that suppresses the persisted twin while the
streaming bubble is still in flight.
- Code-MED-2: drop dead i18n keys `insights.coach.settings` and
`insights.coach.settingsTooltip` (W3 removed the cog; the keys
had zero call sites and would carry confusing legacy if v1.4.23's
prompt-tuning panel picks a different key shape).
- Code-LOW-5: centralise `["user", "dashboardWidgets"]` query key
in `queryKeys.dashboardWidgets()` and migrate the three call
sites (dashboard, insights, settings/dashboard-layout-section).
Same query-key-collision discipline as `queryKeys.analytics()`.
Closes Sec-MED-1, Sec-MED-2, Code-MED-2, Code-MED-3, Code-MED-3
(streaming race), Code-LOW-5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
ef597bfbbd |
fix(insights): align BD-Zielbereich delta math with comparison window
The tile computed `bp30 - bpAll` regardless of which baseline the
user picked under Settings → Dashboard, while rendering a caption
keyed on `comparisonBaseline` ("vs. last month" / "vs. last year").
The math therefore lied to the user. Every other trend tile uses
`tileCompareDelta()` against period-aligned `avg30LastMonth` /
`avg30LastYear` baselines.
Add `priorMonth` (now-60d…now-30d) and `priorYear` (now-395d…
now-365d) windows to `computeBpInTargetWindows()`. Ship the pcts on
the analytics envelope as `bpInTargetPctPriorMonth` /
`bpInTargetPctPriorYear`. Route the dashboard's `bpCompareDelta` to
subtract the prior window matching the user's baseline; fall back
to null for `none`.
Three new bp-in-target unit tests (priorMonth, priorYear, null
windows) and two insights-polish guards (envelope contains the
fields, page no longer subtracts `bpAll`).
Closes Code-H2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
363e73cbf2 |
chore: voice + filename hygiene + wave-4 state wrap
Maintainer-voice sweep across `src/`. ~190 references to the maintainer by first name in code comments and JSDoc neutralised to "the maintainer", "the live tenant", "users", or "the user" depending on context. Three sed passes covering ~50 distinct patterns (`Marc reported`, `Marc wants`, `Marc kept`, `Marc rolled back`, etc.). Test fixtures (`displayName: "Marc B."`, `username: "marc"`, `<HeroStrip userName="Marc">`) kept as opaque test data per the brief. CHANGELOG normalisation. The v1.4.14 + v1.4.15 entries paired a German bullet with its English translation by historical design — the in-app copy is English-default and the user docs are English, so the mixed-language style was a holdover from the early-marathon notes. A one-off node script extracted the `_..._` English paragraph from each bilingual bullet and rewrote the entry as that text. ~44 bullets reduced; one Docs bullet that lost a leading word in the regex fixed by hand. CLAUDE.md → CONTRIBUTING-AI.md rename so the filename is no longer AI-vendor-specific. `CONTRIBUTING.md` reference updated. `AGENTS.md` stays in place for multi-agent compatibility (the conventions doc is shared with Codex / Cursor / etc.). Wave-4 state wrap. STATE.md now records C1–C5 + D + D2 as complete; v1421-backlog.md grew a "Closed in v1.4.22" header listing the 20 items the marathon landed; `.planning/phase-W4-report.md` documents the per-commit decisions, test deltas, and the 23 items deferred to v1.4.23 with reasoning. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
73e51d785e |
style: prettier on touched insights/coach surfaces
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
73d21881a8 |
feat(insights): personal Health Score formula library
Composite 0-100 score with three bands (green >= 75, yellow 50..74, red < 50). Server-deterministic so the same inputs always produce the same output. Sub-components: - bpInTargetRate (30 %) — reuses the existing all-time figure - weightTrendAlignment (20 %) — slope vs target band over 30 days - moodStability (20 %) — `100 - CV*100`, requires >= 5 entries - complianceRate (30 %) — mean of medication compliance30 Null components scale the remaining weights proportionally so the score never lies about what was actually measured. Pure helpers (linearRegressionSlope, coefficientOfVariation, weightTrendAlignment, moodStability, complianceRate) live alongside the composite. Tests cover strong/mixed/poor scenarios, every null-redistribution shape (bp null, bp+mood null, all-but-compliance null, all null) and determinism (same input -> same output). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
a8562727a7 |
fix(dashboard): BD-Zielbereich headline shows all-time, not 30-day
Marc reported the BD-Zielbereich tile pinned at exactly 50/50/50 for
7T, 30T, and the headline ("total") on his real production data — the
fourth attempt at this surface (v1.4.15 → v1.4.16 → v1.4.18 → v1.4.19).
Root cause: the analytics route routed `bpInTargetPct` (the headline)
through `windows.last30Days?.pct` — a literal copy of the `30T`
sub-value. Since `computeBpInTargetWindows` only returned 7d + 30d
windows, the headline could never legitimately differ from `30T`. For
Marc's real data (572 paired readings since 2022, recent 30d = 50 %,
all-time ≈ 11 %) the tile read 50 % / 50 % / 50 % and looked
algorithmically pinned — exactly the symptom he reported.
The previous three passes treated this as either a predicate bug
(v1.4.16 ceiling-vs-narrow-band, real and merged) or a missing
`avg7`/`avg30` wiring (v1.4.18, also real and merged). Both fixes
landed correctly, but neither addressed the headline duplication.
Fix:
- `computeBpInTargetWindows` now returns a third `allTime` window
that aggregates EVERY paired reading in the input series.
- The analytics route fetches all paired BP rows (no 30-day cutoff)
and routes the headline through `windows.allTime?.pct`. The route
only re-pairs once internally so the cost is paid once per request.
- The 7T / 30T sub-values are unchanged (still windowed slices of
the same input).
Failing test reproduces the bug with Marc's data shape (`allTime` MUST
not equal `last30Days` once older history is present); fix turns it
green. Integration test seeds 40 paired readings against real Postgres
and asserts all three windows compute independently.
Hand-counted against Marc's prod data:
- 7T: 50 % (1/2) — matches what he sees, real coincidence
- 30T: 50 % (5/10) — matches what he sees, real coincidence
- Headline: ~11 % (62/572) — was 50 %, now correct
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
23363ca05e |
fix(dashboard): wire 7T and 30T sub-values on the BD-Zielbereich tile
The tile rendered the 30-day headline correctly but the `7T:` and
`30T:` sub-values fell back to "—" even when the user had paired BP
readings in both windows. Root cause was that `/api/analytics` only
returned a single `bpInTargetPct`; the tile received `avg7={null}`
and `avg30={null}` and the TrendCard rendered the dash placeholder.
Add `computeBpInTargetWindows()` next to the existing `computeBpInTargetPct()`
helper. It filters the input series by `measuredAt` against the supplied
clock and runs the same pair-and-count logic over the trailing 7-day and
30-day windows. The route fetches the last 30 days of data once, derives
both shares from the result, and surfaces them as `bpInTargetPct7d` and
`bpInTargetPct30d` on the response. The dashboard tile maps them to
`avg7` and `avg30` so the sub-values now show the actual percentages.
Coverage:
- 6 unit tests for the windowed helper (empty input, mixed window data,
null-7d-with-real-30d, default-clock, Marc's production fixture).
- 2 integration tests against testcontainers seeding 30 days of paired
BP measurements; both windows produce the expected hand-counted shares.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
e4a408eaf5 |
feat(dashboard): tiles show comparison delta callout when toggle is active
Extend DataSummary with `avg30LastMonth` and `avg30LastYear` fields — 30-day means over the [30, 60) and [365, 395) day windows. The existing `summarize()` helper computes them once for every metric the analytics API returns, so every dashboard tile picks them up automatically without extra DB queries. TrendCard gains optional `compareBaseline` + `compareDelta` props. When both are set the tile renders a "Δ −2.3 kg vs. last month" line below the latest value, colour-coded via the existing `directionSentiment` rules — down on an up-bad metric (weight, BP) paints green; down on an up-good metric (mood, sleep, steps) paints orange; neutral metrics stay muted; deltas under |0.05| read as stable and stay muted regardless. Mobile-friendly: the callout sits on its own line below the latest value so the tile stays scannable on Pixel-5 viewports without horizontal scroll. Suppressed entirely when comparison is off OR either side is missing data — strict regression guard for the default-off path. The dashboard's `tileCompareDelta()` helper resolves the prior-period field from the active comparison toggle and feeds every TrendCard the same delta value, so a single toggle flip in Settings → Dashboard updates every tile atomically. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
af77e5ecf8 |
fix(insights): trend on "all" filter computes meaningful delta (split-half comparison)
Marc reported clicking "All" on a chart's range buttons displayed "+0.0 kg/Woche" even when his weight clearly drifted across years. Root cause: `weeklyDelta = (lastTrend - firstTrend) / windowDays * 7` is mathematically correct but the per-week rate becomes vanishingly small for multi-year windows (~0.07 kg/week for 10 kg over 3 years), and the 1-decimal `fmt.number(delta, 1)` formatter rounds it to "0.0". Fix: extract a pure `computeWindowTrend()` helper at `src/lib/analytics/window-trend.ts`. For windows ≥ 90 days we additionally surface a split-half delta — the mean of the second half of the visible series minus the mean of the first half. That number cannot round to zero unless the metric truly didn't move, and it's exactly what Marc wants to see when he asks "what changed across all my data?". For short windows the per-week rate stays the only display because it's still the most actionable metric there. The chart trend line surfaces both: "WEIGHT: +0.1 kg/week (0.1 %/week) · Total +5.4 kg (6.7 %)" on the All view of a multi-year account. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
4df6dac036 |
fix(charts): "7-Tage-Trend" label + numeric trend indicator on every chart incl. mood
Marc reported v1.4.15 A4 didn't fully land: - DE label still read "7-Tage-Schnitt" / "7T-Schnitt" in places - mood tile showed no numeric trend delta even though other tiles did i18n rename: - de.json + en.json: trend7dShort, movingAverage7d, moodMA all read "7-Tage-Trend" / "7-day trend". The legacy "7T:" / "7d:" header on the avg7 line is preserved for tiles that haven't been migrated to the trend7Delta API. trend-delta fallback: - summaryToTrend7Delta() now falls back to slope30 (projected onto 7 days) when slope7 is null. Mood entries are typically logged less than daily, so slope7 needs ≥ 2 points within the trailing 7-day window — for sparser users that came back null and the tile silently dropped the delta indicator. slope30 still answers "is this metric drifting?" so we project that onto the 7-day window for label parity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
577d8dd29f |
fix(insights): BD-Zielbereich computes correctly for real measurement data
The BD-Zielbereich tile rendered 0% for users whose readings sat below the ESH 2023 narrow goal band (Sys 120-129 / Dia 70-79 for under-65). Marc's actual production data has many readings like 117/79 — textbook normotensive, well-controlled — that v1.4.15 A4 still counted as OUT of target because sys < sysLow (120). 5 of 10 of his last-30-day pairs should be in-target by clinical interpretation; v1.4.15 reported 0. Root cause: "in target" was defined as the narrow goal band, which is the ESH treatment goal for hypertensive patients ON therapy — NOT the desirable range for a normotensive HealthLog user. Users (and the dashboard label) intuitively mean "BP is well-controlled = at or below the ceiling, not implausibly hypotensive". Fix: change semantics from `sysLow <= sys <= sysHigh && diaLow <= dia <= diaHigh` to `90 <= sys <= sysHigh && 50 <= dia <= diaHigh`. The 90/50 floors guard against counting symptomatic hypotension as "good control". Centralised the check in `isBpReadingInTarget()` so the 6 historical call sites (analytics route, blood-pressure-status, features, general-status, insights/cards, insights/comprehensive, insights/targets) share one definition — pre-fix they had 5 independent inline copies of the narrow-band formula, easy to drift on a future refactor. Tests: - 6 new unit tests pin the new ceiling+floor semantics including Marc's exact production fixture (10 paired readings -> 50% under v1.4.16, was 0% under v1.4.15). - New `tests/integration/bp-in-target.test.ts` seeds Postgres testcontainer with Marc's reading shape + null-tolerance + hypotension edge cases. Cache invalidation already correct: `measurementDependentKeys` includes `analytics()` so adding a BP measurement invalidates the tile's TanStack Query cache. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
4e2386e2d1 |
feat(dashboard): rename 7-day-average to 7-day-trend with delta indicator per metric
The dashboard tiles labelled the 7-day average as "7T:" / "7d:" with
the mean value beside it. Marc found that label unclear when the
goal of the tile is to convey *direction* over the past week, not a
mid-point average:
Before: 7T: 80,5 30T: 79,8
After: 7T-Trend: 80,5 (+1.2) 30T: 79,8
The avg7 line now flips its label to `charts.trend7dShort` ("7d trend"
/ "7T-Trend") when a `trend7Delta` prop is supplied, and renders a
signed delta indicator next to the average. Color of the delta
follows the same `directionSentiment` rules the headline arrow
already uses:
- up-bad metrics (BP, weight, body fat): +delta paints orange,
-delta paints green.
- up-good metrics (mood, sleep, steps): +delta paints green,
-delta paints orange.
- neutral metrics (pulse): both signs stay muted.
Tiny absolute deltas (< 0.05) read as "no movement" and stay muted
regardless of sentiment, so a flat metric never paints a colour.
Delta source is the existing 7-day linear-regression slope from
`DataSummary.slope7` (already computed by `summarize()` in
`lib/analytics/trends.ts`). Projection: `slope.slope * 7 days =
end-to-end delta of the regression line` — extracted into the pure
helper `summaryToTrend7Delta()` in `lib/analytics/trend-delta.ts` so
the rule stays unit-testable.
Wired to weight, BP-sys, BP-dia, pulse, body fat, mood, sleep, steps.
Glucose tiles keep the legacy avg label until v1.5 (per-context
slopes need a wider summary contract).
Tests:
- Unit `summaryToTrend7Delta()`: null when summary missing /
slope7 null, projects rate × 7 for positive and negative slopes,
zero for stable slope.
- Component `<TrendCard>`: trend label ("7d trend" / "7T-Trend")
shows in EN + DE, legacy label preserved when no delta supplied
(back-compat), delta renders with `+`/`−` sign and one decimal,
±0 for near-zero, +delta on up-bad → orange, +delta on up-good →
green, neutral metric → muted regardless of sign.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
a967895808 |
feat(admin): replace overview section-grid with audit-log preview + system snapshot
The `/admin` overview rendered a six-card status grid plus a quick-jump list of every admin section. Both duplicated navigation that already lives in the sidebar (and the global nav after Phase 4b), so the landing page told the admin nothing they didn't already see two clicks away. Replace the duplicated nav with two at-a-glance panes that *only* the overview can show: - Welcome card greets the signed-in admin by username and confirms the admin context (so the role indicator the shell shows is reinforced in the page body, not just the chrome). - `<SystemStatusSummary>` reuses the existing `useSystemStatus()` hook + `/api/version` to print app version, DB up/down, worker state, process start time, build SHA, and built-at. Same data source as `<SystemStatusSection>` so we don't double-fetch. - `<RecentAuditPreview>` lists the latest 10 audit entries from `/api/admin/audit-log?limit=10&filter=auth` with a "View all" link to `/admin/login-overview`. Reuses the same auth-action label map the full section uses. The previous `<StatusCardGrid>` component is now unused — removed entirely. The test file kept its name (`status-card-grid.test.tsx`) so git history follows the rename, but it now asserts the new overview composition: welcome card paints with the admin's username, snapshot heading present, audit preview links to `/admin/login-overview`, and the legacy "Maintenance"/"Monitoring" card titles + "Manage users" CTAs are *not* rendered. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
02b99553b9 |
chore(style): repo-wide prettier sweep before v1.4.6 tag
Long-overdue prettier sweep; prettier had drifted vs. checked-in code across ~130 files (mostly long-argument line-break wrapping in TS and table-padding in markdown). Pure formatting — no semantic changes — and required to clear the v1.4.6 release gate's `pnpm format:check`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
643e6ec3a0 |
release: v1.4.2 — resurrect v1.4.1 work + bug-fix triple (#152)
* Revert "revert: roll source tree back to v1.4.0 to restore production"
This reverts commit
|
||
|
|
e444a90b10 |
revert: roll source tree back to v1.4.0 to restore production
Production at healthlog.bombeck.io has been 503-ing since the v1.4.1
deploys started landing on apps-01 (Coolify). The container boots —
Next.js prints "Ready" and the pg-boss workers run — but never
accepts HTTP on :3000, so the Docker healthcheck fails and Traefik
takes the upstream out of rotation. A manual restart, a Coolify
force-rebuild, and a docker-compose pin to the GHCR :1.4.0 multi-arch
image all failed to bring the site back up — Coolify rebuilds the
image from main HEAD on every deploy regardless of the compose
directives.
This commit resets the working tree to commit
|
||
|
|
70353882eb |
fix(security): close moodLog SSRF + central error redaction; fix citation drift (#148)
The v1.4.1 audit pass turned up one CRITICAL, two HIGH, and four medical-citation drift findings. This commit addresses everything that's safe to land in a hardening release; deeper architectural fixes (idempotency-race transactional rewrite, encryption-key fallback gate, refresh-token reuse-detection serialisation, moodLog secret HMAC lookup column) are tracked for v1.4.2 in docs/ops/v141-followup-issues.md. Security -------- CRITICAL — moodLog SSRF src/lib/validations/moodlog.ts now refines the credentials URL with isPublicUrl() so an authenticated user cannot store http://169.254.169.254/ (cloud metadata) or RFC1918 hosts. src/lib/moodlog/sync.ts re-checks isPublicUrl(baseUrl) at the fetch site (so legacy rows stored before the credential guard are also refused) and switches to redirect: "manual" + 3xx → fail so a public host cannot 302 the request to an internal target with the user's apiKey on the redirect hop. New unit test src/lib/validations/__tests__/moodlog.test.ts asserts the guard rejects 169.254.x.x, 10.x, 172.16.x.x, 192.168.x.x, 127.0.0.1, and localhost. HIGH — error-message secret leakage New src/lib/logging/redact.ts redacts Bearer tokens (incl. our hlk_/hlr_), Telegram bot URLs (bot<digits>:<token>), and query-string secrets (?secret=, ?code=, ?token=, ?api_key=). The redactor is called from WideEventBuilder.setError() — every error reaching Loki is scrubbed once, centrally — and from the reportToGlitchtip() call in api-handler.ts so the Glitchtip incident UI is also protected. New unit suite covers the four patterns plus over-redaction (Bearer's \\S+ greedily consumes anything up to the next whitespace, which is the safer side of the trade-off). Truthfulness — citation drift fixes (audit found 0 hallucinations, 4 drift items) - messages/{en,de}.json bpClassificationTitle and avg30dEsc: "ESC/ESH 2018" → "ESH 2023". User-visible on the dashboard and doctor-report PDF. The numeric thresholds are unchanged from 2018; the 2023 update is ESH-only since ESC withdrew from joint authoring. - src/lib/analytics/classifications.ts: section header, getBpTargetsByAge() docblock, and inline comment updated to cite ESH 2023 with the same "numerically unchanged" caveat. - src/app/api/insights/targets/route.ts:375: source label "WHO" → "Saint-Maurice JAMA 2020". The AI-prompt anti-pattern guard explicitly forbids citing WHO for a step number; this surface was the last "WHO" mislabel in the codebase. - Saint-Maurice "mortality plateau 8000-12000" attribution softened to "continued dose-response benefit through ~12,000 steps/day, not a plateau" in src/lib/medical-citations.ts, src/lib/analytics/effective-range.ts, src/lib/analytics/classifications.ts, and both AI prompts. The original JAMA 2020 paper shows continuing benefit (HR 0.49 at 8k vs 4k, HR 0.35 at 12k); the plateau-shaped finding belongs to Paluch 2022 Lancet PH (PMID 35247352), not Saint-Maurice. Quality gates pnpm typecheck clean pnpm test 669 / 669 (was 658; +11 from new suites) pnpm test:integration 10 / 10 (untouched) pnpm lint 0 errors |
||
|
|
03845ef5eb |
fix(analytics): correct chart-math edge cases caught by the v1.4 audit (#126)
Three correctness issues in `summarize()` / `trendSlope()` /
`weeklyAverages()` that the discovery audit reproduced:
1. **`summarize` and `trendSlope` used different time anchors.** The
averages (`avg7`, `avg30`) snapped to `Date.now()`, while the slopes
(`slope7`, `slope30`, `slope90`) snapped to the latest point in the
series. A stale series (no readings for weeks) reported a slope from
old data even though the dashboard tile correctly hid the average.
Both surfaces now use `Date.now()` as the cutoff, so a stale series
returns `null` from every windowed stat consistently.
2. **`summarize([])` returned `{ min: 0, max: 0, mean: 0 }`.** Those
sentinels leaked into chart axes (Y-axis collapsed onto zero) and
into AI-prompt feature bundles where the generator treated the zero
as a real reading. Empty input now returns `null` for all three
fields. The `DataSummary` type widens accordingly; the dashboard
already null-checks the optional fields, so no UI regression.
3. **`weeklyAverages()` used system-local `Date.getDay()` /
`getDate()`.** On a UTC server (which is what the production
container runs as) a Sunday-evening Berlin reading bucketed into
the next week. Now the ISO-Monday key is computed via
`Intl.DateTimeFormat({ timeZone: "Europe/Berlin" })`, so the bucket
lines up with how the user perceives "this week".
`pairByTimestamp` keeps the greedy nearest-match heuristic — adding
JSDoc that documents the limitation and the conditions under which a
Hungarian-style match would matter (sparse health data is well below
that bar).
Tests: 371 → 384 (+13). Two new direct regression tests:
- `weeklyAverages` Sunday-evening-Berlin bucket goes to the *previous*
Monday.
- `summarize` with all readings 100 days old returns `null` for every
windowed stat (no false trend on stale data).
Plus tightened the empty-summarize assertions.
Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
|
||
|
|
8adeb59396 |
v1.4 medical-safety: protect against silent DBP hypotension, align BP & step citations, ACE body-fat consolidation (#123)
* fix(medical): protect against silent DBP hypotension and align BP citations Three correctness issues that the v1.3.3 ecosystem audit surfaced and that we deferred until the v1.4 cycle: 1. **Diastolic blood-pressure orange band reached 60 mmHg.** With the default age-based targets (DBP 70-79), the lower orange wing was computed as `diaLow - 10 = 60`, putting a sustained reading of 60 mmHg into "mildly low" territory — visually a yellow tile, not a red one. 60 mmHg is widely treated as a hypotension / perfusion-risk threshold (general-adult <90/60 from AHA/NIH; J-curve evidence in ONTARGET / TRANSCEND for treated hypertensives; ESH 2023's explicit caution against treating below 70 in the elderly). The orange floor is now clamped at 65 mmHg, so a reading of 60 lands in red. The user-override path stays untouched and is already audit-logged. 2. **"ESC/ESH 2023" doesn't exist as a joint guideline.** ESC withdrew from the 2023 authoring; the 2023 hypertension document is ESH-only. The codebase had a mix of "ESC/ESH 2018" (analytics) and "ESC/ESH 2023" (AI prompts). Both are wrong in different directions. Every site (`bp-targets.ts`, `targets/route.ts`, `ai/prompts/blood-pressure.ts`, `ai/prompts/base-system.ts`) now cites "ESH 2023" with the published source URL. Target bands are unchanged 2018 → 2023 — only the citation moves. 3. **"WHO ≥ 8 000 steps/day" was a recurring hallucination.** WHO's 2020 Physical Activity guidelines publish minutes per week, not a step quota. The cohort study HealthLog actually relies on is Saint-Maurice et al., JAMA 2020 (mortality plateau 8 000–12 000 steps). The v1.3.3 fix landed in `effective-range.ts` but four AI prompt strings (`base-system.ts` DE+EN, `general-status.ts` DE+EN) carried the old wording forward. Same fix shape: cite Saint-Maurice for the step number and add an explicit "WHO does not publish a step quota" note so the model doesn't regress. Sleep target moves from "ESC" to AASM 2015 in the same surfaces — ESC publishes no adult sleep duration guideline. Adds `src/lib/medical-citations.ts` as the new single source of truth for cited guidelines (id, name, year, url, caveat). v1.4 surfaces will import these constants instead of duplicating strings, and the new `medical-citations.test.ts` drift-test asserts that every entry has a non-empty url + caveat, that ids are unique, and that the WHO-as-step- source hallucination cannot reappear as a constant. Tests: 358 → 363 (5 new for the master citations list). Typecheck and existing analytics tests pass. No clinical numbers changed beyond the DBP orange-floor clamp. Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com> * fix(medical): consolidate body-fat ACE bands and document bedtime-glucose source Two related medical-truthfulness issues from the v1.3.3 ecosystem audit. **Body-fat classification: ACE bands corrected.** v1.3.3's classifier used `essential = 6 (M) / 14 (F)` as the *floor* of the "Essential" category, which is actually ACE's *Athletes* lower bound. Anything below was lumped under "Essential" — including readings (e.g. 1% male) that are clinically concerning, not just "athletically extreme". The classifier now mirrors the ACE table precisely: - Below essential (M <2 / F <10): danger. - Essential (M 2–5 / F 10–13): warning. - Athletic (M 6–13 / F 14–20): normal. - Fitness (M 14–17 / F 21–24): normal. - Acceptable (M 18–24 / F 25–31): warning. - Obese (M ≥25 / F ≥32): danger. **Three-way drift in body-fat target ranges consolidated.** v1.3.3 had three sites with three different green-band numbers: - `value-bands.ts:118-120` returned M{10,20} / F{18,28}. - `app/api/insights/targets/route.ts:336-338` hardcoded M{14,24} / F{21,31} with `source: "ACE"`. - `classifications.ts:155-205` used a third overlapping set of thresholds for the band labels. ACE fitness + acceptable (M 14–24 / F 21–31) is the realistic healthy band for a non-athlete adult and is now the single source in `getBodyFatTargetRange`. Targets route and chart value-bands import the helper instead of duplicating the numbers. **Bedtime glucose citation softened.** ADA Standards of Care 2024 §6 publishes pre-prandial 80–130 and post-prandial <180; the document does NOT publish an adult bedtime target. The 90–150 mg/dL band HealthLog ships is reasonable advice (within the daytime pre-prandial range, slightly raised floor to avoid nocturnal hypoglycaemia) but it's not ADA-published. The inline comment in `effective-range.ts` now states the absence of an adult guideline explicitly and cites ISPAD 2022 (pediatric T1D, 80–140) as the closest comparator. Numbers unchanged. Tests: 363 → 369 (6 new for the ACE bands). Existing assertions unchanged. Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com> * fix(medical): align step-range target with the effective-range source `getStepsRange()` in classifications.ts returned `{7 000, 10 000}` while `effective-range.ts` (the canonical resolver behind `getEffectiveRange`) returned `{8 000, 15 000}`. The two surfaces showed different "green" bands to the same user — `/api/insights/ targets` used the lower pair, the dashboard chart-bands used the upper pair. Aligns both on the upper pair, anchored on Saint-Maurice et al. JAMA 2020 (mortality plateau 8 000–12 000 steps/day). Adds the citation header to the steps section so the next audit pass can verify in place. WHO is explicitly called out as NOT a source for a step quota (only an activity-time guideline). The classifier function `classifySteps` is unchanged — its 3 / 5 / 7.5 / 10 k bands match Tudor-Locke 2004 pedometer indices and are kept distinct from the two-number "target range" helper. Tests stay at 369/369. Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com> --------- Co-authored-by: Marc-André Bombeck <noreply@anthropic.com> |
||
|
|
98a57eb786 |
audit(v1.3.3): SpO2 + body-composition + 14 CRITICAL/HIGH security/correctness fixes (#121)
* fix(measurements): close issue #109 + sync body composition across UI/PDF/thresholds User-visible: TOTAL_BODY_WATER, BONE_MASS, and BLOOD_GLUCOSE now appear in the measurements filter, badge, mobile icon, and edit dialog (issue #109). Server-rendered doctor report now includes body composition rows (audit C-9). Body water + bone mass have proper effective-range thresholds so severity logic doesn't silently return `nominal` (audit C-15). Structural: extracted the per-type display maps from measurement-list.tsx into measurement-list-meta.ts as a deliberate first step toward the single-source manifest planned for phase P5. Coverage tests now fail-fast if a future enum addition skips any of: list label/icon/color, doctor-PDF label/unit/vital-types, chart label, threshold metric order/label/bounds. Audit-2026-05-07 / phase P0: - closes #109 (filter UI gap, root cause for the German raw-enum display) - closes audit C-9 (server PDF used a stale type map) - closes audit C-15 (no warn/critical bands for body composition) Tests: 282 → 296 (14 new). Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: align OpenAPI spec + migration comment with body-composition reality - OpenAPI MeasurementType enum: add TOTAL_BODY_WATER + BONE_MASS (was at v1.3.0 baseline; spec drifted past the v1.3 server release) - Bump openapi version 1.3.0 → 1.3.2 to match package.json - Migration 0022 comment: TOTAL_BODY_WATER stores kg (water content), not percent of body weight. Validators (VALUE_RANGES) and the Withings client (type 77 → kg) treat it as kg already; the comment was the lone outlier and would mislead the next reviewer. Audit-2026-05-07 / phase P1a — closes audit M-B1 (partial; OpenAPI enum) and M-B2 (migration unit comment). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): close 3 audit CRITICALs (Withings header / idempotency Bearer / GlitchTip URL strip) C-3 (Withings webhook secret in URL): Now reads X-Withings-Webhook-Secret header in preference; URL ?secret=… fallback retained for legacy Withings configurations and emits a Wide Event warning so operators can spot still-using-the-old-flow integrators. The query-string secret was leaking into Coolify access logs and any GlitchTip event whose request.url got captured. C-4 (idempotency dead for Bearer-token clients): The default userIdResolver only consulted getSession(). Bearer-authed clients (the iOS / n8n / headless-API fleet that PR #117 was built for) fell through, so every retry created duplicate measurements. The default now tries cookie session first, then Bearer token via hashToken+apiToken.findUnique with revoked/expiry checks. All three existing call sites (measurements, mood-entries, medications/intake) get the fix automatically. H-B7 (request.url with secrets shipped to GlitchTip): reportToGlitchtip now strips the query string before forwarding the URL, so Withings legacy ?secret=… and any OAuth ?code=… that errors during the callback don't end up in someone else's incident UI. Audit-2026-05-07 / phase P2. Tests: 296 → 301 (5 new in idempotency.test for the Bearer fallback + revoked/expired/null cases). Deferred to a follow-up (needs DB migration / lock primitive): C-2 moodLog webhook → AES-GCM at rest + HMAC-over-body C-5 idempotency TOCTOU advisory_lock or in-flight sentinel row Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(measurements): add OXYGEN_SATURATION (SpO2) as first-class measurement type — v1.3.3 Closes the OxygenSaturation feature request from #109. - Migration 0024 extends MeasurementType enum (additive, IF NOT EXISTS) - Zod validators: plausibility 50–100% (below 50 is incompatible with sustained life; above 100 is physically impossible) - effective-range thresholds: green 95–100, orange 92–94, red <92. Sources: BTS Guideline 2017 (target 94–98%), ATS clinical practice (≥95% at rest is healthy), AHA/clinical literature (<92% = call provider, <88% = ER). Lower-only concern — upper orange wing collapses to greenMax since saturation cannot exceed 100%. - Personalisation: existing thresholds-override UI lets COPD / chronic-respiratory users with a doctor-set 88–92 baseline tune the bands. METRIC_ORDER + METRIC_LABEL_KEYS extended in thresholds-section.tsx; the exhaustive `Record<ThresholdMetric>` forces TS to catch any missed surface. - Withings ScanWatch type 54 mapping (best-effort — only fires for pulse-ox-capable devices). - Wired through measurement-form, measurement-list-meta (icon: Wind), health-chart, both doctor-report-pdf renderers (core + browser-side near-duplicate), OpenAPI spec (version 1.3.0 → 1.3.3), and i18n in 4 sections × 2 locales. - iOS DTO already declared `OXYGEN_SATURATION` — server enum addition closes the long-standing drift flagged in audit V2. - 4 new tests: numeric green band, upper-wing collapse, user-override (COPD baseline), METRIC_BOUNDS shape. Tests: 301 → 305. Typecheck: clean. Audit-2026-05-07 / v1.3.3 / closes #109 SpO2 feature request. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(analytics): truthfulness pass on medical citations + clamp override orangeMax Doctor-agent + hallucination-hunter audit V3 found six medical citations in effective-range.ts that the code did not actually implement. Plus a latent bug in the user-override path that produced physically-impossible orangeMax values (>100% saturation, <0% body-fat). Citation rewrites (no numeric changes): - ACTIVITY_STEPS: "WHO ≥8000 steps/day" → Saint-Maurice JAMA 2020. WHO publishes activity in min/week, not steps. Step-count was a misattribution that also leaked into AI prompts. - TOTAL_BODY_WATER: "ESPEN Body Composition Guidance 2017" → Watson formula / ICRP Reference Man. ESPEN 2017 is BIA practice guidance, it does not publish kg cut-offs. - BONE_MASS: "DEXA-equivalent bone mineral content" → "Bioimpedance- estimated; not DEXA-comparable". Withings BIA values typically run 5–7% below DEXA — calling them DEXA-equivalent in a doctor-report PDF was a real legal-exposure misattribution. - OXYGEN_SATURATION: "BTS Guideline 2017 (target 94–98%) + ATS clinical practice (≥95% at rest is healthy)" → "consumer pulse- oximeter consensus (≥95% normal at rest); slightly tighter than BTS 2017 (94–98%); NICE NG115 ≤92% escalation, BMJ panel / FDA pulse-oximeter labelling ≤88% ER threshold". Code uses 95-100/92-100 band — that's an ATS/consumer-consensus number, not BTS's literal 94-98 target. Soften the citation to match the code, not vice versa. Bug fix (1 latent, 1 real-today): - effective-range override path now clamps orangeMin/orangeMax to METRIC_BOUNDS.{min,max}. Without the clamp, a COPD user setting SpO2 override to {95,100} emitted orangeMax = 100.75 (impossible saturation), and a BODY_FAT override of {2,80} emitted orangeMin = -9.7 (negative percent). Default-range case 210 already hard-coded orangeMax: 100 for the same reason — the override path was the only branch missing the invariant. Tests: 305 → 306 (1 new: clamps override orangeMax to physiological 100%). Typecheck: clean. Audit-2026-05-07-V3 / closes Doctor + Senior-Dev medical-citation findings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): Bearer-scope wildcard handling + GDPR Art.17 account-delete erasure Two CRITICAL fixes from audit V3. NEW-V3-1: Bearer-scope bypass. apiToken.permissions ["*"] is meant to be the wildcard "session-equivalent" scope (the iOS app receives this on login). The previous check permissions.includes(requiredPermission) treated "*" as a literal string — never matched. Today many sensitive routes call requireAuth() *without* a requiredPermission, so a leaked iOS token can act as a full-scope token (account delete, settings wipe). Once those routes adopt requireAuth("scope:name"), this wildcard handling keeps the iOS app working while narrower-scoped tokens (e.g. user-issued ["medication:ingest"]) get correctly 403'd. Same wildcard handling added to the standalone permission checks in src/app/api/ingest/medication/route.ts:65, 119. NEW-V3-2: GDPR Art. 17 account-delete erasure completeness. Feedback + AuditLog rows had `onDelete: SetNull` in the schema, which preserved PII (free-text symptom descriptions, IP addresses, login-city geolocation) after the user was deleted. We now explicitly prisma.feedback.deleteMany + prisma.auditLog.deleteMany before the cascade-delete, making account deletion genuinely complete erasure. Tests 306/306 still pass; typecheck clean. Audit V3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(pdf): real text-content assertions for body composition + SpO2 rows Replaces bytes-only "renders body composition rows" theatre test with pdf-parse-driven text extraction that asserts the actual rendered labels + values for TOTAL_BODY_WATER, BONE_MASS, OXYGEN_SATURATION (DE + EN). Closes V3 audit finding STILL-V2-NEW-3: the previous test only verified "%PDF-" header + byte-count, so a regression that drops a row would still pass. Now a missing label fails the test. - adds dev dep: pdf-parse@^2.4.5 - 308/308 tests pass (was 306, +2 SpO2 + EN-locale coverage) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(measurements): close enum drift cousins — derive 5 type-arrays from canonical enum + extend 2 contract enums V3 audit finding "enum drift cousins": 7 hardcoded module-level type arrays silently dropped SpO2/TBW/BoneMass/BloodGlucose from their respective domains. Adding a new measurement type required hunting these down. Now all aggregation paths derive from `measurementTypeEnum.options`: - /api/insights/comprehensive (AI insights) - /api/dashboard/summary (iOS dashboard adapter) - /api/analytics (analytics aggregator) - /lib/insights/general-status (AI general-status fetch) - /api/import (Zod schema — round-trip now covers all 11 types) External-contract enums extended additively (no breakage for old clients): - /api/measurements/series kindEnum: + oxygen, totalBodyWater, boneMass - /api/dashboard/widgets widgetIdEnum: + oxygenSaturation - DashboardWidgetId + DEFAULT_DASHBOARD_LAYOUT: + oxygenSaturation row - formatMeasurementsForExport: glucoseContext now round-trips Coverage test (`measurement-type-enum-coverage.test.ts`) asserts the canonical enum stays the source of truth and documents PDF exclusions (BLOOD_GLUCOSE renders via context section, SLEEP/STEPS lifestyle-only). 313/313 tests pass (was 308, +5 new). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): close 6 V3-audit HIGH findings (CSP, web-push SSRF, IP geo, AI provider leak, import rate-limit) - CSP: chatgpt.com + api.openai.com only on /settings/ai/** routes (was blanket connect-src on every page including /auth/login → DOM-XSS exfil channel) - web-push: webPushSubscriptionSchema.endpoint requires HTTPS + isPublicUrl refinement; was z.url() only → blind SSRF probe via Push subscription - isPublicUrl: also fixes a regression where DNS labels starting with "fc"/"fd" (fcm.googleapis.com etc.) were falsely classified as IPv6 unique-local. Check is now gated on a colon being present in the host. - /api/ai/test: stop returning provider err.message + bodyExcerpt to the client (leaked provider URLs / partial keys / internal headers). Now returns categorised generic message; full details land in the wide-event via annotate() for the operator. - /lib/geo (auditLog IP geo lookup): switch default provider to ipwho.is over HTTPS, accept ipwho.is + ip-api pro response shapes, add IP_GEO_LOOKUP_DISABLED env, refuse non-HTTPS configured URLs. Previously leaked IP+timestamp over plaintext HTTP every auth event (GDPR Art. 32 + Art. 44). - /api/import: add 5/hour rate-limit (was unlimited; bulk-injection vector) Tests: 325/325 pass (was 313, +12 new — webPushSubscriptionSchema SSRF guards, fc/fd-prefix domain regression, geo HTTPS-only, geo provider shapes, geo opt-out env). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security,gdpr): trusted-proxy IP, audit-log retention, idempotency cachable filter, Bearer mock tightening V3 audit follow-on bundle covering 4 HIGH findings. (I) getClientIp — TRUST_PROXY_HOPS env (default 1) replaces blind XFF trust. Reads chain right-to-left so client-supplied leftmost entries cannot rotate the per-IP rate-limit bucket. Default 1 matches typical single-proxy self-host (Coolify/Caddy/Cloudflare-Tunnel); 0 forces x-real-ip / null. New 7-test suite covers the rotation attack + malformed entries + zero-hop fallback. (F) Audit-log retention job — daily 03:15 cleanup of audit_logs older than AUDIT_LOG_RETENTION_DAYS (default 365). Closes GDPR Art. 5(1)(e) "storage limitation" gap; previously IPs + city + login events accumulated forever. 6-test suite covers default + override + misconfig guard (rejects <7 days). (G) isCachableStatus — extracted the inline 4xx/5xx filter into a named, exported, tested function. 7 cases cover the full do-not-cache contract (401 / 403 / 408 / 429 / any 5xx) and confirm 2xx + 4xx validation are still cached. (H) Bearer-mock tightening — apiToken.findUnique mocks in require-auth-bearer.test + idempotency.test now assert the where.tokenHash shape uses the hashed value (not the raw bearer). A regression that switched back to raw-token comparison would have been silent without this assertion. Tests: 345/345 pass (was 332, +13 new). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): encrypt moodLog webhook secret at rest with AES-256-GCM (V3 audit STILL-V2-C-2) The mood_log_webhook_secret column previously stored a plaintext secret that the webhook handler compared timing-safe against every enabled user. A DB dump or backup snapshot leaked active webhook credentials. New contract — no schema migration required: - Writes go through encryptMoodLogSecret() (AES-256-GCM via existing crypto helper, ENCRYPTION_KEY). - Reads go through readMoodLogSecret() which transparently decrypts envelopes and tolerates legacy plaintext rows during the transition (graceful upgrade path — no breaking change for self-hosted users). - Webhook lookup decrypts each candidate before timing-safe compare. - Status endpoint decrypts for the user's settings page. One-shot startup migration in reminder-worker rotates any leftover plaintext rows on next worker boot. Idempotent; encrypted rows are skipped. Tests: 350/350 pass (was 345, +5 new — round-trip, legacy passthrough, isLegacyPlaintext semantics, rotateLegacy idempotence). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: sync README + AGENTS + CLAUDE + CHANGELOG + OpenAPI for v1.3.3 (V3 audit doc-drift) V3 audit Doku Specialist findings — drift between code and meta-docs. - README §Key Features now mentions pulse oximetry (SpO₂) + Withings ScanWatch sync; Tech Stack model count corrected 23 → 25. - AGENTS.md status promoted v1.3.2 → v1.3.3; vitest config corrected `vitest.config.ts` → `vitest.config.mts`; migration range corrected `0001–0022` → `0001–0024`; model count + list updated to 25 (adds Device, IdempotencyKey). - CLAUDE.md model count updated (2 occurrences). - CHANGELOG.md v1.3.3 expanded with Security + Internal sections that were missing the audit-fix-marathon work (Bearer-scope, account-delete cascade, moodLog encryption, CSP tightening, Web-Push SSRF, IP-geo HTTPS, AI-test leak fix, import rate-limit, trusted-proxy XFF, audit-log retention, idempotency cachable filter, Bearer mock tightening, enum-drift cousins, PDF text-content tests). - OpenAPI Bearer description corrected `SHA-256 hashes` → `HMAC-SHA-256 hashes (keyed with API_TOKEN_HMAC_KEY)` so the docs match the actual server-side implementation. Wildcard scope contract documented. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(audit-review): close 4 HIGH + 2 MEDIUM findings from multi-agent review pass Multi-agent review of the audit-fix branch surfaced four HIGH issues and a couple of MEDIUM hardenings. All addressed before merge. HIGH-1 — OpenAPI MetricKind enum drift (iOS strict-decode crash risk). The dashboard-summary route now emits totalBodyWater/boneMass/ oxygenSaturation MetricCards, but the spec still listed only the original 7 kinds. Generated iOS Codable would have crashed the dash the moment a user logged SpO2. MetricKind extended with the 3 new kinds + comment documenting unknown-value defensive decoding. HIGH-2 — kindEnum naming consistency on /api/measurements/series. The series adapter shipped "oxygen" while the dashboard summary used "oxygenSaturation"; both routes now use "oxygenSaturation" so iOS decoders can share a single MetricKind type across endpoints. HIGH-3 — /api/ai/test no-leak guard had no test. Added 4 tests that mock provider errors with sk-leaked-key + provider URLs in the err fields and assert the response body never echoes them, plus the 3-status-categorised generic messages (401/429/5xx/other). HIGH-4 — /api/import 5/hour rate-limit had no test. Added 2 tests that mock checkRateLimit allowed/disallowed and assert the 429 path uses the right key shape (`import:<userId>`, 5, 1h) and that the within- quota path proceeds. MEDIUM-3 — getClientIp under-length XFF chain regression. With TRUST_PROXY_HOPS=2 and a chain of length 1, the previous clamp returned the leftmost (attacker-controlled) entry, silently re-introducing the rotation attack the helper was meant to close. Now: when chain is shorter than configured hops, refuse XFF entirely (fall back to x-real-ip / null). Added regression test. LOW-1 — TRUST_PROXY_HOPS=garbage now throws at boot instead of silently degrading to hops=0. Added regression test. Tests: 358/358 pass (was 350, +8 new). Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |