QA Specialist-H1 (v1.4.39): the wave-report noon-anchored parity tests
miss the DST-boundary case where the UTC-anchored rollup day-key and the
user's local wall-clock day-key diverge. Pinned with a route-parity test
that logs at `2025-10-25T23:30:00Z` (Berlin local `2025-10-26` 00:30,
post fall-back) and asserts the rollup tier emits `2025-10-25` —
documents the UTC-anchor trade-off the route's header comment now spells
out. v1.5 per-user-tz bucketing closes the gap; until then this is the
contract.
QA UX-H1 (v1.4.39): the rollup fast-path feeds `summarize()` one DataPoint
per calendar day (the rollup's `mean`); the live-fallback used to pass every
raw entry, which silently shifted `summary.count / latest / min / max / mean
/ avg7 / avg30 / slope30` on multi-entry days. Pre-aggregating the
live-fallback through `aggregateDailyAverages` before `summarize()` keeps
both branches byte-identical on multi-entry days too. Pinned with a
two-entries-on-the-same-day parity test that compares the live-fallback
and rollup-tier summary fields directly.
QA F-H-02 (v1.4.39): the read-aggregate-then-upsert pattern in the mood
and medication-compliance DAY recomputes could interleave concurrent
writes for the same (user, day) as A-SELECT -> B-SELECT -> B-UPSERT
(correct N) -> A-UPSERT (stale N-1), leaving a stale row until the next
write. Replaced both with a single SQL INSERT ... SELECT ... ON CONFLICT
DO UPDATE that re-aggregates inside the upsert subquery so each statement
commits with a snapshot taken after the prior commit released its row
lock. A paired DELETE statement handles the "all entries removed" case
in the same flow, gated on NOT EXISTS so it cannot clobber a row the
INSERT just wrote. Pinned both helpers with a Promise.all race test that
asserts both callers go through the atomic path and never fall back to
the legacy read-aggregate-upsert.
QA F-H-01 (v1.4.39): the previous "any row exists" probe would short-circuit
to the rollup path while only the first few days of a multi-medication
account had been backfilled, exposing zero-filled tiles for the un-rolled
days until the boot fold completed. The fix compares the count of DISTINCT
rolled days against the count of DISTINCT days that hold an intake event in
the trailing window via a single SQL aggregate; partial coverage now falls
through to the legacy aggregator until the fold catches up. Pinned with a
new unit test covering the partial-coverage case alongside the zero-events
and full-coverage cases.
Registers `medication-compliance-full-backfill` on pg-boss alongside
the existing `rollup-full-backfill` and `mood-rollup-full-backfill`
queues. The worker boot fires a discovery query that enqueues one
full-fold per user with intake events but no rollup coverage —
idempotent across reboots and singleton-keyed so a fast restart
while a backfill is queued doesn't double up. The reminder worker's
RED-phase mint path also wires the per-write rollup hook so freshly
minted `takenAt:null` rows bump the per-day `scheduled` count before
any read sees the user's compliance tile. Legacy accounts pre-dating
the v1.4.39 deploy auto-converge on the persistent tier without
operator action.
Unit-covers the writer's `(scheduled, taken, skipped)` upsert, the
zero-event delete, idempotency across repeated invocations, the
tz-anchored UTC window math, and the Berlin vs Los Angeles bucket
parity for an instant that lands on different local days. Reader
tests pin the trailing-window zero-fill plus the per-medication
fold-to-day. Boot-backfill discovery tests pin the singleton-keyed
enqueue. Route tests extend the existing harness with the new
coverage probe — rollup-tier read on coverage, live fallback on
miss — without touching the byte-stable response shape.
Swaps `/api/medications/intake?scope=compliance` onto the rollup
reader behind a coverage probe — a user with intake events but no
rollup rows still gets a correct response from the legacy live
aggregator and triggers the boot backfill in the background so the
next request lands on the rollup path. Every write site that mutates
`MedicationIntakeEvent` (POST + PUT + DELETE + per-medication import,
bulk batch, telegram callbacks, external API ingest, admin restore)
now fires `recomputeMedicationComplianceForEvent` for the affected
user-day. Bulk + import collapse the per-row hooks to one recompute
per distinct `(medicationId, dayKey)` so a 500-row ingest still costs
a bounded fold. Purge wipes the rollup partition alongside the
intake-event deleteMany. Eliminates the 3.2 s cold mount the
v1.4.38 perf analysis attributed to the unbounded event findMany.
Materialises the persistent medication-compliance rollup tier behind
`MedicationComplianceRollup`. Per-`(userId, medicationId, day)` row
where `day` is `YYYY-MM-DD` anchored to the user's IANA timezone.
`recomputeMedicationComplianceForDay` upserts the row from the events
inside the local-day UTC window; `readMedicationCompliance` returns a
zero-filled trailing-window bucket array matching the legacy
`buildComplianceBuckets` shape so consumer swaps are byte-stable.
`hasMedicationComplianceCoverage` powers the route's coverage probe
and `recomputeUserMedicationCompliance` is the full-fold the worker
boot backfill calls.
Unit tests pin the per-write hook contract (DAY upsert + WEEK/MONTH/
YEAR enqueue + delete-on-empty + idempotency under re-run), the
warm-up helper (no-op on fresh / dedup on concurrent / annotate on
populator failure), and the boot-time discovery enqueue. Route
tests pin the parity contract: identical entries + summary shape
between the rollup fast path and the live-fallback walk, plus the
LRU warm-read invariant.
Registers mood-rollup-recompute (per-bucket WEEK/MONTH/YEAR fold)
and mood-rollup-full-backfill (boot discovery) onto pg-boss. The
worker boot now fires a discovery query that enqueues one full-fold
job per user with mood entries but zero mood_entry_rollups rows —
idempotent across reboots and singleton-keyed so a fast restart
while a backfill is queued doesn't double up. Legacy accounts
pre-dating the v1.4.39 deploy auto-converge on the persistent tier
without operator action.
Captures the cumulative-metric sum_value rollup work — writer +
boot-backfill discovery widening + consumer swaps in
`/api/measurements?groupBy=day` and `/api/dashboard/summary` +
new `rollup-read-cumulative.ts` helpers. Documents the
`/api/analytics` A2 skip-cumulative deferral to v1.4.40 (file
owned by W-SINCE in this phase) and the per-source W8c deferral
to v1.5. Surfaces the cross-agent commit-message drift recurrence
for the marathon retrospective.
The /api/mood/analytics cold mount walked every MoodEntry the user
had ever written into Node only to bucket-average in JS. Reroutes
the read through readMoodDayRollups which returns one row per
calendar day from the persisted tier, and wires the per-write hook
into every mood mutation site so the rollup stays current:
- POST/PUT/DELETE /api/mood-entries — inline recompute
- POST /api/mood-entries/bulk — collapsed to per-day fan-out
- POST /api/integrations/moodlog/webhook — inline recompute
- syncMoodLogEntries — bounded re-fold after batch sync
- DELETE /api/settings/moodlog + DELETE /api/settings/data —
drop the user's rollup partition alongside the entries
- admin restore + import — re-fold after the bulk write
Coverage-fallback mirrors summaries-slice: when the rollup count is
zero but raw mood entries exist (legacy account before the boot
backfill catches up) the route falls through to the legacy live walk
once and the warm-up helper fires asynchronously so the next request
lands on the fast path.
The per-type loop inside `buildAnalyticsResponse` calls
`fetchMeasurementSeriesChunked` without a `since` filter, so a single
default-slice request pulls every measurement ever for the user across
15 chunked rounds. The v1.4.38.8 per-type fast-path gate made this
unreachable in the common case, but a regression on those gates would
re-trigger Marc's 74.6 s cold full-slice mount.
- Add `ANALYTICS_LIVE_WINDOW_DAYS = 90` + `liveSince` constant.
- Thread `since: liveSince` into every per-type chunked read.
- Annotate `meta.analytics.bp_aggregate.live_since` (ISO) so the next
perf-verify can prove the cap is in effect on a live request.
Defense-in-depth only — the rollup fast-path is unchanged because it
never reaches the per-type loop. Worst-case bound drops from ~347 k
rows to ~5 k rows on Marc's tenant (matching the
comprehensive-aggregator + summaries-slice 90-day windows).
Trade-off: `summarize().avg30LastYear` returns null on the fall-through
path; the dashboard's year-over-year overlay shows "no prior data"
until the rollup tier warms. Acceptable on a degraded path that
v1.4.38.8 already made rare.
Tests: new `src/__tests__/api/analytics/since-cap.test.ts` pins the
90-day floor on every per-type findMany, pins the `live_since`
annotate, and pins that the slim slice is untouched. Existing chunked
paging integration test reseeded with timestamps inside the 90-day
window so it still exercises the two-chunk boundary.
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).
18 unit pins covering each reader's granularity filter, the
coverage-miss `null` return shape, the auto-router's window-to-tier
selection (90 d / 365 d / 1095 d), the fall-through chain on partial
coverage, and the linear-composition contract for count / min / max
/ mean / sumValue across coarser buckets.
Adds bounded findMany helpers for the WEEK / MONTH / YEAR rollup
buckets the writer has been minting since v1.5.0 but no reader
consults. `readBestGranularityRollups` auto-routes a window to the
coarsest granularity that still resolves it (90 d \u2192 DAY, 365 d
\u2192 MONTH, 1095 d \u2192 YEAR) with a fall-through to finer tiers
on coverage miss. `aggregateWmyBuckets` mirrors the DAY-side
compositional contract for count / min / max / mean and folds the
new `sumValue` column for cumulative-metric callers.
The sparkline aggregate row now carries `sum_value` (+ `count`)
alongside the bucket mean. Cumulative tiles — ACTIVITY_STEPS today,
every CUMULATIVE_HK_TYPES member tomorrow — render the daily SUM
rather than the daily MEAN, which previously understated the visual
trend signal by the per-bucket sample count.
Spot tiles stay on `mean`. Cumulative tiles fall back to
`mean * count` when the legacy NULL surfaces (pre-v1.4.39 buckets
waiting for the boot-time backfill to converge). The two pre-v1.4.39
$queryRaw select-list changes are additive and the cache wrap stays
unchanged.
`GET /api/measurements?source=rollup&aggregate=daily` previously
reconstructed cumulative daily totals from `mean * count`. The
v1.4.39 writer populates the new `sum_value` column on every fold,
so the read path consumes it directly. The legacy `mean * count`
arithmetic stays as the fallback for boot-backfill convergence
where existing rows still carry `sumValue = null`.
Algebraically equivalent (AVG = SUM / COUNT for a single-source
day), so this is a no-op for byte parity until the cumulative
single-source assumption breaks — at which point the bucket-level
SUM is the canonical reference rather than the reconstructed value.
The v1.4.39 schema scaffolding added `measurement_rollups.sum_value`
for the cumulative-metric daily totals (steps, flights, distance,
daylight, active-energy). Extends the rollup aggregator to also `SUM`
each bucket and persist the column for every (user, type,
granularity, bucket) row regardless of metric kind — spot metrics
carry the column for free because the SUM rides on the same
$queryRaw aggregate as AVG / MIN / MAX / STDDEV_POP / REGR_*.
Boot-time backfill discovery now unions in users whose existing DAY
rollup rows carry `sum_value IS NULL`. Re-folding converges those
rows because `persistRollupRows` always writes the new column on
upsert, and the union runs as one indexed pass alongside the per-
type missing-coverage discovery.
Adds `src/lib/measurements/rollup-read-cumulative.ts` with the new
`readCumulativeDaySums` / `readCumulativeDaySumsBatch` helpers
consumers use to swap off the per-type chunked findMany loop.
Helpers fall back to `mean * count` when the legacy NULL surfaces so
the chart never paints a hole during the boot-backfill convergence
window.
Audit anchor: .planning/round-v1438-perf-analysis.md §3 (cumulative
daily sums) + §5 P3.
Mirrors the measurement-rollups tier for the mood domain. Adds the
write-path hook (synchronous DAY upsert + async WEEK/MONTH/YEAR enqueue),
the bounded DAY reader, the warm-up helper, and the boot-time
backfill discovery. Closes the §2.3 finding in the v1.4.38 perf
analysis where /api/mood/analytics walked every MoodEntry on every
cold mount.
Three additive Prisma additions land in one commit so the v1.4.39 wave
agents (W-MOOD, W-MED, W-SUM) build against a stable generated client:
- `mood_entry_rollups`: per-(user, granularity, bucket) mood stats
(count / mean / min / max / sd). Reuses the existing
`measurement_rollup_granularity` enum.
- `medication_compliance_rollups`: per-(user, medication, day)
scheduled / taken / skipped triple. `day` is the user-tz-anchored
YYYY-MM-DD string so multi-instance reads do not re-derive boundaries.
- `measurement_rollups.sum_value`: nullable column. Cumulative-metric
read paths consume it directly; boot backfill folds NULLs on first
reach.
Migrations 0070 / 0071 / 0072 use IF NOT EXISTS + DO $$ EXCEPTION WHEN
duplicate_object guards (mirror 0061 / 0067 / 0069). No ALTER on
existing tables that downgrade row contracts, no DROP. Backward-safe
read paths land in the wave agents that follow.
Includes `.planning/round-v1438-perf-analysis.md` — the read-only audit
that anchors the wave plan.
`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.
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.
v1.4.38.5 referenced `r."id" IS NULL` on the LEFT JOIN — but
`measurement_rollups` has a composite primary key
`(user_id, type, granularity, bucket_start)` and no surrogate `id`
column. Postgres rejected the query with `column r.id does not
exist` (SQL state 42703); the worker boot swallowed the error per
the helper's best-effort contract and the queue stayed empty.
Net effect: v1.4.38.5 deployed cleanly but delivered none of its
promised fast-path recovery. Marc's account stayed on the live
aggregator.
The fix uses `r."bucket_start" IS NULL` — any column from the
unmatched right side of the LEFT JOIN serves the "no matching row"
filter, and `bucket_start` is part of the composite PK so the
planner already has the column. Bumps to v1.4.38.6.
Analytics fast-path restored on long-running accounts. The boot-time
rollup-backfill discovery query was matching only ZERO-rollup users;
any account that ever folded silently stranded the moment a brand-
new measurement type landed. Discovery now widens to per-type
missing coverage so the boot-backfill queue picks these accounts up
on the next deploy and folds them back into the fast-path.
Analytics fast-path restored. The boot-time rollup-backfill
discovery query was only matching users with ZERO rollup rows;
long-running accounts silently stranded the moment a brand-new
measurement type landed because their existing rollup buckets kept
them invisible to discovery while the new type's empty partition
kept `isFullyCovered(coverage)` at `false`. Every `/api/analytics`,
dashboard summary, and mood-analytics read then fanned out to the
live aggregator across the full measurement table — 30-75 s on
power-user accounts after every container restart.
v1.4.38.5 widens discovery to per-type missing coverage so the
boot-backfill queue picks up these accounts on the next deploy and
folds them.
See CHANGELOG.md.
Marc's account hit the slow-path live SQL aggregator on every read
after every deploy: `/api/analytics` cold-mounted in ~75 s,
`/api/mood/analytics` in ~13 s, `slim_summaries` annotated
`path:"live", row_count:347114`. Root cause traced to
`enqueueBootTimeRollupBackfill`'s discovery query:
WHERE NOT EXISTS (
SELECT 1 FROM measurement_rollups r WHERE r."user_id" = u."id"
)
The clause only matched users with ZERO rollup rows. Any account
that had ever been folded (every long-running user) silently
stranded once a brand-new measurement type landed: the existing
WEIGHT bucket kept the user invisible to discovery, but the new
type's empty DAY partition kept `isFullyCovered(coverage)` at
`false` forever. Every analytics / dashboard read then fanned out
to the live aggregator across the full measurement table.
Discovery now joins `(user, distinct type)` against
`measurement_rollups` on the `(user, type, granularity=DAY)`
partition and surfaces users where any partition is unmatched. The
v1.4.35.1 zero-rollup case is still covered (the LEFT JOIN of an
empty rollup table emits a row for every type the user has logged).
Integration test pins the partial-coverage regression: a user with
WEIGHT rolled up + a brand-new PULSE row now lands on the queue.
Pro-active stale-shell self-heal — Marc reported the live site
"nicht erreichbar" on his pre-v1.4.38.3 PWA shell, even though the
server returned 200 on every endpoint. The v1.4.38.3 reactive
reload only fires after the running app trips `ChunkLoadError`;
users sitting on a page with no lazy-load trigger never recovered.
v1.4.38.4 closes that gap with `<VersionPoller>` + SW
`CACHE_VERSION` per-release + `NEXT_PUBLIC_APP_VERSION` injection,
so every future deploy heals itself within 60 s of the user
opening the app.
Mirrors the v1.4.38.4 tag (the previous release commit only carried
the CHANGELOG entry because the editor lost the version-line edit on
a merge race). `NEXT_PUBLIC_APP_VERSION` derives from this string at
build time, so the running shell's self-heal poll would otherwise
keep firing against itself.
Self-healing stale-shell after every deploy. New `<VersionPoller>`
client component proactively polls `/api/version` and triggers an
SW-unregister + cache wipe + hard reload when the server moves
ahead of the running shell. SW `CACHE_VERSION` now keys to the
release tag so the precached chunk graph from the previous deploy
is dropped on next SW install.
See CHANGELOG.md.
The v1.4.38.3 `AppError` boundary reloads once when a stale shell
trips `ChunkLoadError`, but the reload only fires AFTER the user
hits a navigation that lazy-loads a missing chunk. Users sitting on
the dashboard or insights overview ride out the staleness until
their next click; some never click and the cached shell silently
drifts further out of date with every deploy.
Three layered fixes so the running app heals itself instead:
- `public/sw.js` now keys both `STATIC_CACHE` and `PAGE_CACHE` to
the app release tag (`v1.4.38.4`). The `activate` step already
evicts any cache name outside the current pair, so bumping the
string at release time guarantees the precached root HTML and the
precached `/_next/static/*` chunks from the previous deploy are
dropped on the next SW install.
- `next.config.ts` injects `NEXT_PUBLIC_APP_VERSION` from
`package.json` into the client bundle so the running shell knows
which release it was built against.
- New `<VersionPoller>` client component mounts inside `<Providers>`
and polls `/api/version` every 60 s. If the live version moves
ahead of the shell-baked version it unregisters every active SW,
wipes CacheStorage, and `window.location.reload()`s.
`sessionStorage` gates the reload to once per session so a
mid-deploy webhook race that briefly returns a stale image cannot
loop the page.
- New `V054-SR-merge-deploy-report.md` walks the run end-to-end:
admin-merge of PR #190, six-axis QA findings + convergence,
v1.4.38.2 hotfix bundle (12 fixes), v1.4.38.3 CI green-up + the
AppError chunk-load auto-reload.
- Marathon closure table extended through v1.4.38.3 — twelve
consecutive releases between 2026-05-17 and 2026-05-18 capped by
the iOS-coord chain.
Trivial conflicts on package.json (version bump) and CHANGELOG.md
(new release block); both resolve cleanly by taking the main side.
Keeps develop and main aligned for the next feature cycle.
CI green-up + chunk-load auto-recover. Closes the three pre-existing
red checks that had been failing on every push since v1.4.38 landed
(TODO marker in product code, rollup integration test racing the
fire-and-forget rollup populator, e2e drift after the v1.4.37
doctor-report hero refactor + the dashboard touch-target gate),
plus a new `AppError` branch that auto-reloads the SPA when the
cached shell hits a stale chunk after a deploy.
See CHANGELOG.md.
After a deploy the cached SPA shell still references the old chunk
filenames. Next.js fetches them, the new server 404s the request,
and the user sees the generic error surface — typically with no
context other than "Failed to load chunk". The fix on the user's
side is a hard reload; before this commit they had to discover it
themselves.
`AppError` now detects the chunk-load family of errors
(`ChunkLoadError`, `Loading chunk`, `Failed to load chunk`,
`Failed to fetch dynamically imported module`) and triggers a single
`window.location.reload()`. `sessionStorage` gates the auto-reload
to once per session so an unrecoverable error doesn't loop into a
reload storm. Browsers in strict-privacy mode fall through to the
error UI cleanly.
The `RecentAchievementsCard` "View all" link in the dashboard card
header was 46×16 px. The mobile-viewport e2e check fails at the
WCAG-recommended ≥44 px touch-target floor and the existing project
contract pins the same number. `inline-flex min-h-11 items-center`
keeps the visual styling identical while satisfying the floor.
`measurement-flow.spec.ts` mocks `/api/measurements` and echoes the
just-posted body back as the list response. The `Measurement` shape
the list page consumes requires `unit` and `source`; the mock
omitted both, the list render crashed before painting the row, and
the test's `expect.poll(...)` for "78.4" timed out.
v1.4.37 lifted the doctor-report card from the export-tile grid to a
hero block on the export page; the test-ids renamed from
`export-card-doctor-report` / `export-action-doctor-report` to
`export-hero-doctor-report` / `export-hero-doctor-report-action`.
The Playwright suite still targeted the old ids and timed out on
every run since. Updates the two affected specs.
The v1.4.37.1 read-path hotfix changed `ensureUserRollupsFresh` from
an awaited populator to a fire-and-forget. On a cold testcontainer
the integration test inserts measurements and immediately reads
through `buildComprehensiveAggregate`, which now no longer waits for
the rollup-write path to land — the coverage probe sees no buckets
and the `dailyByType` branch returns an empty object.
Calls `recomputeUserRollups(user.id)` explicitly in the test setup
so the rollup-driven branch is exercised deterministically. Matches
the production warm-cache contract (the test was always asserting
the rollup-fed shape, just no longer racing against it).
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.
Mood-reminder hotfix bundle on top of v1.4.38.1 plus the missing
Settings toggle. Six-axis review of the v0.5.4 iOS-coord patch
surfaced enough blockers that the feature could not have been used
safely as shipped:
- Locale resolver demoted 4 of 6 locales (es/fr/it/pl) to English.
- Dedup ledger committed before delivery — a transient APNs blip
silently nuked the day's nudge and blocked retries.
- DST-unsafe arithmetic drifted the local-22:00 baseline by an hour
twice a year for both the mood-reminder push and the medication
iOS-snooze action.
- APNs payload forwarded the Telegram `replyMarkup` inline-keyboard
object and other channel-specific extras to Apple's servers and
the iOS notification handler.
- No Settings UI for the per-user opt-in flag — the feature was
unreachable for end users regardless of the other fixes.
v1.4.38.2 closes every item above plus adds the missing
`MoodReminderCard` Switch, a 90-day retention sweep for the dispatch
ledger, and a per-user `try` wrapper so one bad row cannot abort the
22:00 tick.
See CHANGELOG.md.
Mood-reminder hotfix bundle on top of v1.4.38.1 plus the missing
Settings toggle. Six-axis review of the v0.5.4 iOS-coord patch
surfaced enough blockers that the feature could not have been used
safely as shipped: locale resolver demoted 4 of 6 locales to
English, ledger committed before delivery, daily-22:00 timestamp
drifted by an hour on DST days, APNs payload forwarded Telegram
keyboard structures to Apple, no Settings surface for opt-in. All
of the above are closed in v1.4.38.2.
See CHANGELOG.md.
Captures the paper trail for the iOS-coordination PR #190 admin
merge plus the parallel review pass that surfaced the v1.4.38.2
hotfix bundle:
- Senior-dev / security / UX / product-lead / specialist /
simplifier finding reports.
- V054-server-apns-mood report carried over from the iOS-coord
agent's working notes.
v0.5.4 PR #190 shipped the server cron, ledger, and APNs scaffolding
for the daily mood reminder but no surface for the user to opt in,
so the feature was dead code regardless of the locale, dispatcher,
or APNs fixes. This commit adds:
- `MoodReminderCard` in `/settings/notifications` — a single Switch
row bound to `users.mood_reminder_enabled`, optimistic on toggle,
with a localised toast on save success and an error message on
failure.
- `applyProfileUpdate` accepts and persists `moodReminderEnabled`
through the existing `/api/auth/profile` PUT and `/api/user/profile`
PATCH paths so both web and iOS clients share the same validation
+ audit envelope.
- `GET /api/user/profile` now surfaces the current value so the
Switch can render the right initial state without a separate
endpoint.
- Five-locale strings (de/en/es/fr/it/pl) under
`notifications.moodReminder.*` for the card title, description,
Switch aria label, on/off status text, and toast / error copy.