Commit Graph
1797 Commits
Author SHA1 Message Date
Marc-André Bombeck 8e9a7891ce docs(planning): W-INFRA v1.4.40 phase report 2026-05-21 12:27:49 +02:00
Marc-André Bombeck cbf5546122 chore(ci): wire knip workflow to fail on unused exports
Installs knip 6.14.1 as a devDependency, ships a curated knip.json
covering every Next.js App Router special file plus the worker / script
/ test entry points, and adds a workflow that runs on every push to
main and every PR targeting main.

The gate trips on:
- unused files (a new orphan module ships zero callers)
- unused dependencies (a production dep with no consumer left behind)
- unused devDependencies (same, in dev land)
- unlisted binaries (a package.json script reaches for a CLI without
  declaring it)

Unused-exports + unused-types stay informational this release because
the v1.4.40 W-GHOSTS sweep cleared 1 177 lines of dead code but the
historic exported-surface backlog still sits at ~ 487 exports + 52
types. Cleaning that incrementally avoids a single mega-PR risk.

Baseline carve-outs in knip.json:

ignore — three files W-GHOSTS missed in v1.4.40 (kept as known-zero
callers until a follow-up wave can verify they're safe to delete with
their dedicated test removed in the same commit):
- e2e/setup/test-helpers.ts (9-line barrel; future-multi-user hook)
- src/components/charts/compliance-line-chart.tsx (chart module only
  referenced by a touch-action contract test)
- src/lib/logging/index.ts (21-line barrel re-export)

ignoreDependencies — runtime / build-time deps loaded outside the
import graph:
- @hookform/resolvers, react-hook-form: dynamic + framework conv.
- @prisma/client: re-exported via src/generated/ (which is ignored)
- next-themes: ThemeProvider mounts via dynamic import
- @vitejs/plugin-react: vitest.config consumer
- shadcn, tailwindcss, tw-animate-css: build pipeline
- testcontainers: integration-test bootstrap
- @types/* : ambient typings

ignoreBinaries — invoked via shell from integration helpers or
declared via :
- docker, docker-compose, pg_isready (integration setup)
- tsx (the openapi:generate + openapi:check scripts run via npx tsx)
2026-05-21 12:26:53 +02:00
Marc-André Bombeck cc08a43e37 refactor(rollups): move medication-compliance-rollups under the umbrella
- src/lib/medications/compliance-rollups.ts → src/lib/rollups/medication-compliance-rollups.ts
- co-located test follows the source file

10 importers updated covering the dashboard summary, every intake CRUD
route (single + bulk + import + Telegram webhook), the ingest endpoint,
the admin backups restore path, and the reminder worker job.

Closes the rollup umbrella restructure — every persistent-rollup
producer + reader now lives under src/lib/rollups/. Pure relocation
across all five batches: zero behaviour or exported-surface change.
2026-05-21 12:22:48 +02:00
Marc-André Bombeck 63e56f7a2d refactor(rollups): move mood-rollups under the umbrella
- src/lib/mood/rollups.ts → src/lib/rollups/mood-rollups.ts
- co-located test follows the source file

17 importers updated from @/lib/mood/rollups to @/lib/rollups/mood-rollups,
covering the mood-entries CRUD routes, the moodlog webhook, the import
restore path, the insights comprehensive + targets routes, the analytics
+ features paths, and the reminder worker + moodlog sync job consumers.

Pure relocation.
2026-05-21 12:22:12 +02:00
Marc-André Bombeck 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.
2026-05-21 12:21:40 +02:00
Marc-André Bombeck 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.
2026-05-21 12:20:34 +02:00
Marc-André Bombeck 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.
2026-05-21 12:19:45 +02:00
Marc-André Bombeck 8974e7730c fix(app-page): clear lint regression from per-tile Suspense scaffolding
The useMemo introduced for the timezone-aware greeting in the W-RSC
per-tile Suspense pass listed [user?.timezone] as its dep array. React
Compiler's preserve-manual-memoization rule rejected the literal
optional-chain expression because the inferred dep is the wider 'user'
object — a less-specific source than the declared property access — so
the compiler refused to optimize the surrounding 1 400-line component
body.

Lift userTimezone into a local before the memo so the inferred dep and
the declared dep agree. No behaviour change; the memo still recomputes
only when the user's timezone string changes.
2026-05-21 12:17:08 +02:00
Marc-André Bombeck fa36817040 fix(analytics-route): exclude soft-deleted measurements in chunked series fetch
The three Measurement reads in the analytics route — glucose-by-context,
sleep-stage breakdown, and the fetchMeasurementSeriesChunked paged
helper that backs the per-type summarize loop and the BP-Zielbereich
windowing — were still pulling soft-deleted rows. W-DELETED skipped this
file assuming the W-POOL pool / p-limit pass covered it, but that pass
only addressed concurrency, not the deletedAt predicate.

Adds deletedAt: null to all three findMany clauses so the route shares
the same soft-delete contract as every other analytics read path. iOS
soft-delete unblocked.
2026-05-21 12:16:13 +02:00
Marc-André Bombeck 6901b0791d docs(planning): W-GHOSTS v1.4.40 phase report 2026-05-21 12:14:22 +02:00
Marc-André Bombeck cd8277d367 refactor(tz): consolidate wallClockInTz into src/lib/tz/wall-clock.ts
The compliance-rollups module and the cadence scheduling module each
carried a private wallClockInTz definition. The cadence variant
returned the full {year,month,day,hour,minute,second,weekday} tuple;
the compliance variant returned only {year,month,day}. Promote one
canonical helper that returns every part — the compliance caller
destructures the three fields it needs — so the cross-tz ±3 h guard
stays in lock-step instead of drifting between sibling modules.
2026-05-21 12:12:40 +02:00
Marc-André Bombeck 79c86a0652 refactor(tz): consolidate startOfUtcDay into src/lib/tz/start-of-utc-day.ts
The mood-rollup and measurement-rollup writers carried byte-identical
copies of the helper that could drift independently. Promote one
canonical export under src/lib/tz/ so both writers — and any future
rollup table — import the same UTC-anchored truncation.
2026-05-21 12:10:42 +02:00
Marc-André Bombeck e26e27b5ff chore(i18n): drop dead format.dateShort timeShort dateTime keys across six locales
The runtime never reads the format namespace — every locale-aware
formatter routes through Intl.DateTimeFormat directly via
src/lib/format-locale.ts. The keys were only verified by their own
ordering smoke test, which is also removed.
2026-05-21 12:09:31 +02:00
Marc-André Bombeck e9a9e79edb docs(planning): W-WMY-WIRE v1.4.40 phase report 2026-05-21 12:07:24 +02:00
Marc-André Bombeck 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.
2026-05-21 12:07:16 +02:00
Marc-André Bombeck b1469c4626 test(rollup-read-wmy): parity and granularity-routing across consumers
Pins the cross-consumer routing contract the v1.4.40 wire-in relies
on. Both computeSummariesSlice (year-ago baseline) and
computeUserHealthScoreFastPath (weight long-window) now call
readBestGranularityRollups so the floor table is shared state. The
new tests prove:

- 90 d -> DAY (every coarser floor too high),
- 365 d -> MONTH (clears 181 d floor, short of 731 d YEAR floor),
- 1095 d -> YEAR (clears the 731 d YEAR floor).

Plus a parity assertion: a MONTH bucket consolidating 30 measurements
with mean 80 returns the same count-weighted mean as the two DAY
buckets (count 10 / mean 82 + count 20 / mean 79) that compose it.
Locks the linearly-composable contract count / mean carries across
granularities — any future floor adjustment forces both consumers to
re-pin in lock-step rather than silently diverging.
2026-05-21 12:06:15 +02:00
Marc-André Bombeck 75607e4c09 chore(measurements): drop unreferenced typesMissingCoverage and readCumulativeDaySumsBatch exports
The bucket-fresh read paths probe coverage per-type via
coverage.get(type) or rely on isFullyCovered for the all-or-nothing
fast path; the typesMissingCoverage helper had no production caller.
The cumulative-batch reader was only exercised by its own test —
the analytics A2 path took the chunked per-type findMany route in
production. Strip both and their tests.
2026-05-21 12:05:43 +02:00
Marc-André Bombeck 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.
2026-05-21 12:05:02 +02:00
Marc-André Bombeck 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.
2026-05-21 12:04:07 +02:00
Marc-André Bombeck 2bff80aaf9 chore(routes): remove duplicate /api/monitoring/{umami,glitchtip}/test endpoints
Both routes were superseded by their /api/admin/monitoring/{umami,glitchtip}-test
counterparts which are the variants the admin UI actually fetches.
The duplicated routes had no callers anywhere in the source tree.
2026-05-21 12:02:00 +02:00
Marc-André Bombeck 2b4e2177be chore(routes): remove orphan /api/medications/intake-summary endpoint
The GET handler had no fetch caller and the queryKeys.medicationIntakeSummary
entry was never consumed in any useQuery. Strip the route file, the
factory entry, the medicationDependentKeys reference, and an outdated
quick-add doc comment.
2026-05-21 12:01:11 +02:00
Marc-André Bombeck 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.
2026-05-21 12:00:56 +02:00
Marc-André Bombeck 0ec4a99322 chore(jobs): remove dead TELEGRAM_CLEANUP_QUEUE handler and registration
The queue had a handler, registration, and worker binding but no
producer anywhere in the codebase — zero boss.send calls and no cron
schedule. The doc comment claimed the Telegram sender schedules
cleanup but the sender never called boss.send. Strip the constant, the
interface, the handler, the allQueues entry, and the worker bind.
2026-05-21 11:58:36 +02:00
Marc-André Bombeck 317d56181b test(auth-registration-status): pin all four discovery branches
The route is read-only and its envelope shape (`registrationEnabled`
boolean) gates iOS sign-in + web onboarding. Four branches walked:
  1. singleton row exists with registrationEnabled: true → reports true
  2. singleton row exists with registrationEnabled: false → reports
     false (admin-disabled tenant, invite-only)
  3. singleton row missing → reports schema default true (fresh self-
     hosted instance, no-config baseline)
  4. findUnique throws → reports false (fail-closed; critical defense
     against a DB blip silently unlocking sign-up on a tenant that
     disabled registration)

No code change; the route's current behaviour is correct. Pin it
before someone refactors fail-closed → fail-open by accident.
2026-05-21 11:53:04 +02:00
Marc-André Bombeck 08cf854960 fix(dashboard-summary): recompute compliance rollup after bulk projection backfill
Mirrors the intake-route call site (v1.4.40 close of v1.4.39.4 hook
gap). `projectAndReadTodaysIntakes` runs the same idempotent
projection helper the intake route uses; without this hook the
compliance rollup stayed pre-projection on the second call site too.

Fires `recomputeMedicationComplianceForEvent` once per distinct
`(medication_id, dayKey)` after the bulk `createMany`, best-effort
inside the helper's try/catch so a populator hiccup doesn't surface
as a 5xx on the dashboard summary.
2026-05-21 11:52:54 +02:00
Marc-André Bombeck e8a0947638 docs(planning): W-RSC v1.4.40 phase report 2026-05-21 11:52:50 +02:00
Marc-André Bombeck de1e65d4a1 fix(medications-intake): recompute compliance rollup after bulk projection backfill
Closes the v1.4.39.4 hook gap surfaced by the QA reconcile pass. The
`scope=today` branch's bulk `createMany` mints fresh
`(medicationId, scheduledFor)` PENDING rows when the morning
projection lands; without this hook the compliance rollup for the
affected `(user, medication, day)` tuples stays at its pre-projection
`scheduled` count, inflating the apparent compliance % until the user
actually logs against the new row.

Fires `recomputeMedicationComplianceForEvent` once per distinct
`(medication_id, dayKey)` (coalesced by Set) so a 5-meds-3-times-a-
day projection drops 15 createMany rows down to ≤3 recompute round-
trips. Best-effort: errors stay caught inside the helper so a
populator hiccup doesn't surface as a 5xx on the intake request.
2026-05-21 11:52:42 +02:00
Marc-André Bombeck ca7d00ffbf perf(insights-generate): consume mood-rollup tier via features extractor
Audit Critical Finding #2 (round-v1439-arch-qa-infra-db). The mood
block inside `extractFeatures` ran an unbounded
`prisma.moodEntry.findMany({ where: { userId } })` and folded the
entire history into the AI feature payload for /api/insights/generate.
On power-user accounts the walk dominated the cold-mount budget.

Swap to `readMoodDayRollups(userId, fiveYearsAgo)` so the feature
payload reads one row per calendar day. The downstream consumers
(`avg7` / `avg30` / `trend30` / `coverage` / cross-metric
correlations) all operate at per-day resolution, matching the rollup
shape. `latest` score reads a single `findFirst` (bounded). Coverage-
fallback runs a bounded one-year live walk + fires
`ensureUserMoodRollupsFresh` so the next request lands on the rollup
tier (same posture as /api/mood/analytics post-v1.4.39).

Existing test fixture updated to mock `moodEntryRollup` + the
warm-up; six features tests stay green.
2026-05-21 11:52:32 +02:00
Marc-André Bombeck 594cd22ff5 docs(planning): W-DELETED v1.4.40 report — soft-delete invisibility across reader tiers
Inventory of touched files, test matrix, and commit map for the
v1.4.40 soft-delete-filter sweep that closes Senior Infra+DB audit
Critical Finding #3.
2026-05-21 11:52:29 +02:00
Marc-André Bombeck 45a8399822 perf(insights-comprehensive): consume mood-rollup tier
Audit Critical Finding #2 (round-v1439-arch-qa-infra-db). The 90-day
`prisma.moodEntry.findMany` block fed two downstream consumers:
`dailyMoodEntries` for mood × metric correlations, and `moodSummary`
for the dashboard mood block. Both read at a per-day resolution.

Swap to `readMoodDayRollups(userId, ninetyDaysAgo)` so the route reads
one row per calendar day instead of one per entry. Coverage-fallback
runs a bounded 90-day live walk + fires `ensureUserMoodRollupsFresh`
so the next request lands on the rollup tier (same posture as
/api/mood/analytics post-v1.4.39). `moodSummary` now feeds per-day
mean DataPoints into `summarize()` on both branches, matching the
rollup-tier convention shipped in v1.4.39 (QA UX-H1).

Two parity tests added to `__tests__/route.test.ts`: rollup fast path
skips the raw findMany, and the coverage-fallback runs the live walk
exactly once when rollups are empty.
2026-05-21 11:52:21 +02:00
Marc-André Bombeck f8de4b05fd perf(insights-targets): consume mood-rollup tier and floor distinct measurement scan
Audit Critical Finding #2 + High finding 3 (round-v1439-arch-qa-infra-db).
The mood block at the bottom of the route used to run an unbounded
`prisma.moodEntry.findMany({ where: { userId } })` on every mount —
pulling every mood the user had ever logged into Node only to bucket-
average them in JS. The `findMany({distinct:['type']})` for 'absolute
latest measurement per type' carried no `measuredAt >=` floor either,
so on a 347 k-row tenant Postgres still had to sort the full set
before the driver applied DISTINCT ON (Prisma's `distinct` doesn't
compile to PG's `DISTINCT ON`).

This commit:
  - swaps the mood findMany for `readMoodDayRollups(userId, since)`
    plus a single `findFirst` for the latest score; coverage-fallback
    runs a bounded 30-day raw walk + fires `ensureUserMoodRollupsFresh`
    so the next request lands on the rollup tier (same posture as
    /api/mood/analytics post-v1.4.39)
  - adds a 365-day floor to the latest-ever-per-type read so the
    driver-side distinct walk stays bounded
  - pins all three behaviours in `__tests__/route.test.ts` (4 new
    tests: rollup fast path, coverage-fallback bounded walk, no-mood
    branch, distinct floor present)
2026-05-21 11:52:09 +02:00
Marc-André Bombeck 691443e264 test(query-keys): pin factory enforcement and dedup behaviour
Three test suites land together:

1. queryKeys factory contracts for the four new v1.4.40 entries
   (`authMe`, `userThresholds`, `chartData`, `dashboardMedicationCompliance`)
   — pin the byte-stable shapes so a future rename of any literal
   lands a matching test failure rather than silently breaking cache
   layout.

2. `measurementDependentKeys` / `medicationDependentKeys` bundle
   membership — `["chart-data"]` and
   `["dashboard-medication-compliance"]` prefixes now ride the
   bundles so a mutation evicts every per-chart and per-compliance
   cache in one pass (audit-C2 + audit-L4).

3. Factory-bypass guard — walks `src/components/charts`,
   `src/app/page.tsx` and `src/hooks/use-auth.ts` and fails if a
   literal `queryKey: [...]` slipped past the factory. Acts as a
   lint-style enforcement check in the absence of a custom ESLint
   rule. The guarded directory list is intentionally scoped tight to
   the files this wave migrated; a follow-up wave can extend it as
   it routes the remaining admin/settings/medications surfaces
   through the factory.

Plus a dashboard Suspense-boundary structural test that pins the
per-tile `<Suspense fallback={...}>` wrappers, the module-scope
`DASHBOARD_QUERY_OPTS` hoist, and the `useMemo` around the hour-of-day
derivation. A refactor that drops any of those three structural
guarantees lands red CI rather than silently regressing the
streaming-composition contract.

All 16 + 5 new assertions green; tests 4565 → 4586.
2026-05-21 11:50:37 +02:00
Marc-André BombeckandClaude Opus 4.7 a3b0dc920b test(consent): pin endpoint behaviour and append-only invariant
Cases covered:

- POST 401 unauth, 400 invalid kind, 400 non-ISO signedAt, 400 oversize
  artefact (>64 KB), 200 inserts row + strips artefact from response.
- GET 401, 400 invalid kind, null when nothing granted, latest row by
  kind, full keyspace projection when ?kind is omitted.
- DELETE 401, flips revokedAt on the latest active row, idempotent
  no-op when nothing to revoke, master toggle revokes per-kind.

The headline test is the append-only invariant: a revoke + re-grant
cycle leaves the original row in place (with revokedAt set), mints a
fresh row, and a subsequent GET points at the re-grant — never at the
revoked predecessor. Prisma's `delete` is asserted to never be called
on a receipt, which is the legal-team guarantee the audit trail relies
on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:49:22 +02:00
Marc-André BombeckandClaude Opus 4.7 d6809e3d54 feat(consent): POST + GET + DELETE endpoints at /api/consent/ai
Wires the iOS onboarding + Settings "AI deaktivieren" flow to the
persistence layer landed in the previous commit:

- POST /api/consent/ai — accepts { kind, artefact, signedAt }, persists
  a fresh receipt, returns id + serialised row (artefact stripped from
  the response so the signed token doesn't echo over network paths it
  doesn't need to cross).

- GET /api/consent/ai/latest?kind=… — latest non-revoked receipt for
  the given kind. Omit ?kind to get the full three-kind keyspace
  keyed by kind, with null slots for kinds the user hasn't granted.

- DELETE /api/consent/ai/latest?kind=… — flips revokedAt on the
  latest active row. Idempotent: a no-op returns 200 with null receipt
  rather than 404 so the iOS toggle can hammer the endpoint without
  spurious error states. Omit ?kind for the master "deaktivieren alle
  KI-Funktionen" sweep across every kind.

Both grant + revoke write to auditLog so the trail is reconstructible
even if the receipts table is rebuilt from a backup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:49:11 +02:00
Marc-André BombeckandClaude Opus 4.7 8692a97e3f feat(consent): CRUD helper for AI consent receipts
Adds the persistence layer for the AI consent audit trail required by
App-Store Guideline 5.1.2(i) and GDPR Art. 7:

- src/lib/validations/consent.ts — Zod schemas with a three-kind
  discriminator (ai_full / ai_insights_only / ai_coach) and a 64 KB cap
  on the opaque artefact so a misbehaving client can't fill the audit
  table with multi-megabyte rows.

- src/lib/consent/receipts.ts — append-only helpers: createReceipt,
  latestActiveReceipt, latestActiveReceiptsByKind, revokeLatest.
  Revoke flips a single field on the existing row; a re-grant always
  mints a fresh row, keeping the historical chain intact for legal
  review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:48:56 +02:00
Marc-André Bombeck 55fefb1d72 test(integration): pin soft-delete invisibility across analytics, dashboard, rollup tiers
Pins the v1.4.40 W-DELETED contract end-to-end against the real
Postgres testcontainer. Three contracts:

  1. The slim summaries slice's narrow aggregate + DISTINCT ON latest
     reads exclude tombstoned rows. Fixture parks a 99.0 sentinel
     row that would explode min/max if leaked.
  2. The dashboard summary route's per-type groupBy + latestIn7d
     DISTINCT ON ignore the tombstone — the WEIGHT tile's
     `latestValue` shows the live row, not the tombstoned 99.0.
  3. `recomputeBucketsForMeasurement` produces a DAY bucket with
     count=2 (not 3) when one of three same-day rows is soft-
     deleted, and the bucket mean reflects the live two rows only.

Also pins the Coach snapshot's measurement read against the same
filter — tombstoned rows must never reach the AI prompt where they
would surface as live data the user thinks they removed.
2026-05-21 11:48:40 +02:00
Marc-André Bombeck 3cacfcf94c feat(dashboard): per-tile Suspense boundaries for progressive paint
Wrap every chart row cell and every tile-strip cell in a per-cell
`<Suspense>` boundary so the dashboard composition reads as a grid of
independently suspending islands rather than a flat tree that shares
loading semantics (audit-H2 + brief C1). The dynamic-loaded charts
(`HealthChartDynamic`, `MoodChart`, `MedicationComplianceChart`) already
mount their own `<ChartSkeleton>` while the JS chunk resolves; the
Suspense layer lifts the same skeleton to a streaming-compatible
boundary so a future migration to `useSuspenseQuery` automatically
buckets each chart's loading state to its own cell without a further
composition pass.

Bundled with two audit-cited polishes that share the same touch
surface: hoist `DASHBOARD_QUERY_OPTS` to module scope so the option
object reference stays stable across renders (audit-M2), and memoise
the `getHourForTimeZone` call so the `Intl.DateTimeFormat`
instantiation only re-runs when the user's timezone changes
(audit-H4). Both are micro-wins individually but the page renders
~720 lines of JSX per pass, so a stable per-render allocation budget
keeps Recharts re-render cost bounded.

No visible behaviour change in the current cold-mount waterfall —
every chart still suspends on its own client-fetch — but the
structural boundary is the prerequisite for the v1.4.41 RSC migration
that hoists slim-analytics fetching to the server.
2026-05-21 11:48:10 +02:00
Marc-André Bombeck 128d863763 test(privacy): render assertions for the GDPR-required sections
Track the v1.4.40 W-PRIVACY bilingual rewrite of /privacy. The test
suite now pins:

- The bilingual H1 ("Datenschutzerklaerung / Privacy Policy") so a
  redesign cannot silently regress to a monolingual title that fails
  the German DPA review.
- Eleven sections each carry a `data-slot="privacy-section-de"` body
  plus a collapsible `data-slot="privacy-section-en"` translation,
  with `lang="de"` / `lang="en"` boundaries so screen readers switch
  pronunciation correctly.
- The HealthKit data-flow boundary (POST /api/measurements over
  TLS 1.3).
- The AI off-device transit guarantees: off by default, never
  includes raw HealthKit identifiers, named providers (Anthropic,
  OpenAI).
- Consent-receipt endpoint GET /api/account/consents with the 5-year
  retention window.
- Encryption disclosure: TLS 1.3, HSTS, certificate pinning + the DE
  "Zertifikat-Pinning" mirror.
- Server-location + operator framing (Germany, Hetzner, "operator").
- Retention defaults: 5-year measurements, 90-day audit, 30-day APNs.
- GDPR Art. 17 deletion route names "Konto loeschen", "Delete
  account", "User.delete", "onDelete: Cascade" verbatim.
- Operator contact mailto:mbombeck@gmail.com + GitHub-issue fallback,
  with the v1.4.20 PII rule still enforced (no "Marc-Andre" or
  free-text "Bombeck <name>" anywhere in the body).

Bumps POLICY_VERSION constant to 1.4.40 + LAST_UPDATED to 2026-05-18.
2026-05-21 11:47:42 +02:00
Marc-André Bombeck c79d91736c feat(privacy): public privacy policy page at /privacy with DE + EN content
Convert the English-only v1.4.26 privacy page to a bilingual document
that an Apple US App-Store reviewer and a German DPA inspector can both
read without locale switching. Each numbered section opens with the
German body (visible by default) and ships a collapsible English
translation directly underneath. The H1 and TOC carry both languages.

GDPR / DSGVO content additions on top of the v1.4.26 spine:

- HealthKit data flow disclosed explicitly: HK sample to iOS app to
  POST /api/measurements over HTTPS, with no third-party transit.
- AI off-device transit boundary spelled out: TLS 1.3, off by default,
  never includes raw HealthKit identifiers, per-provider opt-in.
- Consent receipt persistence linked to GET /api/account/consents with
  the 5-year retention window.
- Encryption layer named explicitly: TLS 1.3, HSTS, certificate
  pinning on iOS (Zertifikat-Pinning in the DE body).
- Server location framed as operator in Germany on Hetzner; no
  third-party hosting beyond the AI providers listed in section 4.
- Retention defaults table: rolling 5-year measurement window, 90-day
  audit logs, 14-day server access logs, 30-day failed APNs.
- GDPR Art. 17 deletion route named: Settings -> Daten -> Konto
  loeschen with the User.delete + onDelete: Cascade contract.
- Operator contact via mbombeck@gmail.com with the GDPR subject-line
  convention plus the GitHub-issue fallback channel.
- DSGVO legal-basis citations attached per data category (Art. 6 (1)
  (b) and Art. 9 (2) (a) GDPR).

Bumped POLICY_VERSION to 1.4.40, LAST_UPDATED to 2026-05-18. Static
render kept (force-static, no revalidate) so the legal document does
not depend on JavaScript to display its non-default-locale text to a
reviewer.
2026-05-21 11:46:50 +02:00
Marc-André BombeckandClaude Opus 4.7 6ab3440336 test(apns,notifications-status): pin payload shape and aggregate behaviour
Pins the SB-5 (apns time-sensitive interruption-level) and SB-6
(/api/notifications/status events map) contracts.

Note: the SB-6 route + test changes landed inside an earlier
`fix(dashboard-summary)` commit (1bcaae47) due to a cross-wave
pre-commit-hook race that auto-folded my staged changes; this
commit ships the wave report + records the proper SB-5 + SB-6
intent in the log for the release-notes sweep to pick up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:45:47 +02:00
Marc-André Bombeck 97920f0f59 fix(measurements-route,series,timeline): exclude soft-deleted in list reads
`GET /api/measurements` has five distinct read shapes (per-day
drill-down, group-by-day, rollup-source coverage-fallback probe + fold
re-read, live `date_trunc` aggregate, default findMany + count). Each
relied on a shared `where` builder or a local `findMany` clause; none
filtered `deletedAt`. The `GET /api/measurements/series` adapter for
iOS read BP-SYS / BP-DIA / single-type series without the filter too.

Pin `deletedAt: null` on the shared `where` so the count + raw findMany
+ group-by-day mode all inherit it; add the filter to the dayKey
drill-down findMany, the coverage-fallback day-count probe, and the
live `date_trunc` aggregate; thread the filter through the three series
findMany calls. The `source=rollup` branch reads from
`measurement_rollups` so the writer-side filter covers it. No
`measurements/timeline` route exists yet — the file-set name was
prospective.
2026-05-21 11:45:26 +02:00
Marc-André Bombeck 1dd1a9a7a5 fix(mood-chart): align queryKey to the shared mood-analytics factory entry
Pre-fix the dashboard mounted two separate consumers against the same
`/api/mood/analytics` endpoint: `app/page.tsx:291` keyed on
`queryKeys.moodAnalytics()` (`["mood-analytics"]`) and the chart at
`mood-chart.tsx:316` keyed on a bare `["mood-chart-data"]` literal.
TanStack treats different keys as separate cache slots, so a cold
dashboard mount fired two requests against the same endpoint — wasting
one HTTP round trip, one pg pool slot, and ~22 KB of payload per cold
mount (empirical-trace #3). Re-key the chart to `queryKeys.moodAnalytics()`
so TanStack dedups the request; align the chart's `staleTime` to the
dashboard's 60 s window so a route swing back to the dashboard within
a minute stays a free cache hit.

The chart's own subscription becomes the cache writer for any insights
/ mood sub-page that mounts it without the dashboard parent having
fetched first. No payload-shape change — the chart already typed its
response as `MoodAnalyticsData` which matches the dashboard's
`{ entries, summary }` shape byte-for-byte.
2026-05-21 11:45:25 +02:00
Marc-André Bombeck 1bcaae4764 fix(dashboard-summary): exclude soft-deleted in sparkline and streak queries
`latestIn7d` and `streakDays` are raw SQL against `measurements`; the
`allTime` aggregate is `prisma.measurement.groupBy`. All three folded
tombstoned rows into the tile-strip's latest value, the all-time count
+ last-seen-at metadata, and the measurement-streak day count. The
`sparkBuckets` query reads from `measurement_rollups` so the writer-side
filter covers it.

Add `m."deleted_at" IS NULL` to the two raw queries and `deletedAt: null`
to the Prisma groupBy.
2026-05-21 11:44:00 +02:00
Marc-André BombeckandClaude Opus 4.7 8187d54969 feat(apns): time-sensitive interruption-level for medication reminders
Medication reminders are the one notification category the user has
explicitly opted in to. iOS 15+ supports `aps.interruption-level:
time-sensitive` to break through Focus modes per category, gated on
a per-app toggle the user grants on install. Wire it for
MEDICATION_REMINDER only — every other event (mood nudges, anomaly
alerts, PRs, system messages) stays on the iOS default `active`
level so Focus modes including Sleep keep silencing them as the user
expects.

Pairs with `apns-priority: 10` for time-sensitive deliveries per
Apple's push best-practices doc; without the priority pairing APNs
may downgrade delivery and silently lose the Focus-bypass intent.

Pinned by 8 new unit tests across `apns.test.ts` — one positive case
for MEDICATION_REMINDER and a parameterised negative covering all
six other event-types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:43:41 +02:00
Marc-André Bombeck c47b7dade3 fix(comprehensive-aggregator): exclude soft-deleted in raw aggregate
The route's central analytics aggregator runs eight `FROM measurements`
clauses (window_stats CTE + main aggregate + DISTINCT ON latest +
firstAt probe, mirrored across the rollup-fresh and cold-fallback
branches) plus one `findMany` for the BP raw rows. None filtered on
`deleted_at`, so the Coach/Insights snapshot would see tombstoned
readings in every windowed avg, slope, anomaly count, and latest
attribution.

Add `AND m."deleted_at" IS NULL` to the eight raw SQL filters and
`deletedAt: null` to `fetchBpRawRows`. The `measurement_rollups`
findMany branch already rides the writer-side filter.
2026-05-21 11:43:29 +02:00
Marc-André Bombeck 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.
2026-05-21 11:41:55 +02:00
Marc-André Bombeck 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.
2026-05-21 11:41:05 +02:00
Marc-André Bombeck f0a0551a80 fix(rollups): filter deletedAt in measurement-rollup readers
`probeRollupCoverage` discovers the set of types the user has logged
by selecting `DISTINCT "type" FROM measurements`. A type whose every
row has been tombstoned would still surface, force the read path to
demand rollup coverage for it, and silently push the request onto the
live aggregator branch.

Add `"deleted_at" IS NULL` so the discovery only returns types the
user can still see. The rollup-read modules (`rollup-read`,
`rollup-read-wmy`, `rollup-read-cumulative`) read from
`measurement_rollups` which is already populated from the filtered
writer in the previous commit, so they need no additional filter.
2026-05-21 11:40:34 +02:00
Marc-André Bombeck db2d864a8e fix(rollups): skip soft-deleted measurements in DAY recompute
The `runRollupAggregate` SQL folded every row in `measurements`
regardless of `deleted_at`. Once iOS soft-deletes its first reading
through the by-external-ids path, the tombstoned value would still
contribute to the bucket count / mean / min / max, leaving dashboards
and analytics showing data the user thinks they removed.

Add `AND m."deleted_at" IS NULL` to both branches of `runRollupAggregate`
plus the watermark probes in `isRollupFresh` and `ensureUserRollupsFreshImpl`
so a stale `updatedAt` on a tombstoned row no longer keeps the rollup
flagged as stale forever. The boot-time backfill discovery is widened
the same way so deleted-only `(user, type, day)` tuples never enqueue.

Defense-in-depth: every reader will pick up the filter in follow-up
commits — this lands the writer-side cut so newly-folded buckets are
already clean.
2026-05-21 11:40:08 +02:00
Marc-André Bombeck 433874e390 test(analytics,db): pin concurrency cap and pool ceiling
Two regression nets for the W-POOL changes shipped in the prior two
commits:

* `src/lib/__tests__/db.test.ts` — assert `getPoolMax()` returns 20 by
  default and honours `DATABASE_POOL_MAX`, with a final assertion that
  pins the floor at 20 so a future refactor that drops the explicit
  `max:` config can't silently revert to the library default of 10.
* `src/app/api/analytics/__tests__/route.test.ts` — instrument the
  per-type `findMany` mock with inflight counters and assert peak
  concurrency never exceeds `ANALYTICS_TYPE_FETCH_CONCURRENCY`. The
  wall-clock window also catches both an unbounded `Promise.all`
  regression (would finish too fast) and an accidental serialisation
  (would finish too slow).
2026-05-21 11:39:54 +02:00