pg-boss already coalesces concurrent cron ticks across worker
containers via the shared queue lease, but a single container that
takes longer than the cron interval can pick up two jobs back-to-back
when the second tick fires while the first pass is still running.
A module-local geoBackfillRunning flag fans the second invocation
out as a no-op log line instead of stacking two concurrent passes
inside the same Node process. The next cron tick after the in-flight
pass completes catches up the work the skipped pass would have done.
Replaces 14 English-fallback strings in dashboard.medicationIntakeQuickAdd
and dashboard.quickAddMedicationIntake (plus the W7c step-consolidation
captions in measurements.dailyTotalCaption/expandDay/collapseDay) with
native Spanish, French, Italian, and Polish copy. Closes the v1.4.37
W10 i18n-findings gap on the four secondary locales for the medication
intake and measurement-grouping surfaces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The rollup path keys rows on d.toISOString().slice(0, 10) (UTC slice)
and compares them against now - N*DAY_MS boundaries. For a user
inside the +-3h band around UTC (Berlin +1/+2) the day-key on the
rollup row lines up with the local calendar day; outside that band
the rollup row addresses the previous-or-next local day relative to
the window boundary and the pair count drifts across the cut.
Runtime guard: when isNearUtc(userTz, now) is false, force the live
fallback (which keys per-event rows by their own measuredAt and is
therefore self-consistent regardless of zone). The analytics route
now threads its existing userTz value into the helper; legacy
callers that omit it default to near-UTC for backwards-compat.
Both annotate dicts carry a new tz_guard field
("near-utc" | "non-utc-live-fallback") so ops logs can attribute a
live-fallback to coverage gaps vs the tz guard. A v1.5 follow-up
will thread userTz into readRollupBuckets so the rollup path can
address local-day buckets and this guard can come back down.
Each row spends up to 3 s on the lookupIpLocation online fallback;
5000 rows × 3 s let a single backfill burst block for ~4 h and starve
the next hourly cron. Tighten the cap to 500 so the worst-case pass
fits well inside the hour-long schedule slot. The helper is
idempotent, so a tenant with a large null-location backlog drains
over multiple hourly passes instead of one mega-pass.
Thread aria-controls from the cumulative-type expand/collapse chevron
to the per-day drill-down container so screen readers can jump from
the disclosure button to the disclosed content. Uses dayKey-derived
stable ids on both desktop (TableRow) and mobile (wrapper div)
layouts; falls back to row id when ungrouped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The fast-path keys rollup rows on UTC-midnight bucketStart via
userDayKey(b.bucketStart, userTz), then pairs those per-day means
with per-event mood + intake rows that the loop below also re-keys
in user-tz. For Berlin (+1/+2) the two keying schemes line up on
the same calendar day; for Honolulu (-10) the rollup-derived per-day
mean addresses the previous local day relative to the per-event
stream, and the Pearson / ANOVA inputs miscorrelate silently.
Runtime guard: when isNearUtc(userTz, now) is false, force the
live SQL path (which re-keys SYS / PULSE / WEIGHT via the same
userDayKey helper as mood / intake) so day-key parity is
guaranteed. The meta annotate carries a new tz_guard breadcrumb
("near-utc" | "non-utc-live-fallback") so prod logs make the
decision visible. A v1.5 follow-up will thread userTz into
readRollupBuckets so the rollup path can address local-day
buckets and this guard can come back down.
Pure helper returns true when the supplied IANA zone is within +-3h
of UTC at the supplied instant. Exposed from both lib/tz/format and
lib/tz/resolver so client and server callers share the same gate.
Honours DST via the existing tzOffsetMinutes path.
Used by the v1.4.38 analytics fast-path runtime guards
(bp-in-target, correlations) to fall back to live SQL for users far
from UTC, where the rollup table's UTC-midnight bucketStart slips a
calendar day relative to the per-event mood / intake stream.
The v1.4.37.2 slim-summaries SQL aggregation landed directly on
main during incident response; this merge brings develop back into
parity and seeds the v1.4.38 marathon kickoff document.
The hotfix landed in 57795418; package.json was missed in that
commit because the squash-merge attempt collided with the
working tree. Pure version bump so docker-publish keys on
v1.4.37.2.
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.
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.
Hotfix on top of v1.4.37. ensureUserRollupsFresh fire-and-forget
on /api/analytics, comprehensive aggregator, summaries slice.
The synchronous variant was blocking the Node event loop for
~60 s per cache miss on power-user accounts whose iOS step
samples kept the 90-day rollup window slightly stale, which
starved /api/health, /api/version and concurrent iOS requests.
The downstream coverage probe preserves correctness via the
live-SQL fallback; the only user-observable change is one cache
window of staleness on the very first request after a new
measurement lands.
Adds the v1.4.37 row to the audit-marathon closure ledger (now
six releases on 2026-05-17) with the headline numbers and the
post-deploy perf-QA write-up. Live verify confirms the deploy is
healthy (tag 1.4.37, build 88028fa0, reminder worker up in 772 ms,
no pending migrations). The W10-1 Critical fix (DRAIN_CUMULATIVE_QUEUE
registration in allQueues) is verified by clean worker boot --
absent the fix, the boot would have failed on the boss.schedule
call. The W2 perf claim (full /api/analytics route 111 s cold
worst-case down to 1.5-3 s) needs a real authenticated dashboard
mount to surface in the logs; framework + verification path
captured for when the numbers land.
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.
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 (hides planned and
skipped rows by default), 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.
Capture the W10 reconcile-apply outcome (10 commits closing 1
Critical + 3 High + 6 Mediums-or-P0s from the six W10 reviewer
reports) and seed the v1.4.38 backlog with the deferred items so
the next round inherits a clean triage surface.
The existing .env.example block warned that an attacker could forge
the geo lookup if the flag was enabled on a non-Cloudflare deployment,
but did not name the two additional downstream consumers of
`getClientIp`: the IP-rate-limit bucket key for login/chat/coach and
the audit_logs.ipAddress column for every privileged write. An
operator who deploys behind nginx / Caddy / direct and copy-pastes
the flag from a forum thread would let any caller defeat the
rate-limiter AND forge the IP recorded against their audit trail.
Rewrite the block to list every consumer explicitly and re-emphasise
that the flag is fail-closed (default off, strict "=== '1'" gate, no
server-side topology detection — the operator owns the decision).
The dose field on the medication-intake quick-add sheet was rendered
editable but the POST body has no `dose` slot — any user edit was
silently dropped, so a user typing "5 mg" for a half-pill day saw the
catalogue dose recorded instead. The previous hint copy did not warn
about the constraint, leaving the user's mental model out of sync
with the persisted data.
Render the field read-only with a muted background, drop the tab
order on the input, and rewrite the hint copy across all six locales
so the user knows that variable doses are coming in a later version
and the standard dose is recorded for now. The setDoseOverride state
holder stays so the reset-on-medication-switch keeps working when the
v1.4.38 doseOverride schema lands.
W4a item 2 promoted DropdownMenuItem to whitespace-nowrap globally and
bumped the sidebar user menu to w-60 so the German "Benachrichtigungs-
Center" label stayed on a single line. The top-bar user menu kept its
w-48 (192 px) width — the same label plus a 16 px icon + 8 px gap + 16
px padding measures ~220 px and overflowed under the global nowrap +
overflow-x-hidden, clipping without an ellipsis.
Bump the top-bar user menu to w-60 so it matches the sidebar's symmetry.
W-CI raised the onboarding checklist toggle and the intake-history
pager to the 44 px mobile touch floor (WCAG 2.5.5). The new
medication-intake quick-add sheet sibling shipped with an empty-state
CTA at h-8 (size="sm" default) and footer Save/Cancel buttons at h-10,
both below the project-wide floor and visibly inconsistent with the
sibling MoodForm / MeasurementForm + the rest of the v1.4.37 surfaces.
Apply the established min-h-11 sm:min-h-9 override to the empty-state
CTA, the footer Save/Cancel, and the empty-state Close button. Extend
the SSR contract test to count three min-h-11 floors instead of two so
a future regression that drops one of the buttons back below 44 px
trips before the release.
The W7c drill-down resolved the day window via
canonicalDailyTimestamp(dayKey, tz) ± 12 h. The shape worked for any
non-DST day because the noon instant carries the zone's offset, but
on the two transition days per year the true local day is 23 or 25
hours long while the window always spanned exactly 24 hours. On
Berlin spring-forward (Mar 30, 2025) the window leaked the previous
day's last hour into the drill-down; on fall-back (Oct 26, 2025) it
hid the first hour of `today`. Same shape applies to every IANA zone
that observes DST.
Add a localDayWindow(dayKey, tz) helper that anchors dayStart and
dayEnd at the user's local 00:00 of the requested day and the next
day. The next-day anchor pattern picks up the DST shift automatically
because each day's offset is read at its own UTC midnight (EU/US DST
transitions happen at local 02:00 / 03:00, so the offset at 00:00
UTC is unambiguous). Half-hour zones (Asia/Kolkata UTC+5:30,
Pacific/Chatham UTC+12:45) keep their minute component.
The dayKey validator regex matched any YYYY-MM-DD string — including
impossible dates like 2026-02-30 or 2026-13-01. `new Date()` silently
overflows those values into the following month, so the drill-down
returned rows from a different calendar day than the user asked for.
No 422 was emitted; the response was syntactically well-formed and
pointed at the wrong day. The same helper feeds the admin drain
route, so a malformed CLI invocation had the same blast radius.
Add a refine that round-trips the parsed Date back to ISO and
rejects when the slice no longer matches the input.
The groupBy=day collapsed branch runs the per-user-day bucketing AFTER
the per-sample findMany, so it can't honour a non-zero offset without
real Postgres-side date_trunc grouping plus a distinct count — both
v1.4.38 backlog items. The dayKey drill-down has the same shape: it
returns a single bounded page, not a cursor.
Both branches previously accepted offset silently and hard-coded
offset:0 in the response meta, so a consumer threading the v1
pagination contract (admin tools, any future iOS list view) would
paint "showing 1-25 of N" with the wrong N. Reject the combination
at the validator instead with a clear 422.
The analytics route carried an inline MeasurementType →
SourcePriorityMetricKey switch next to the cumulative-day-sum
bucket-and-sum. Adding a new cumulative metric required updates in
three places — the SourcePriorityMetricKey enum, the route-level
switch, and CUMULATIVE_HK_TYPES — with no shared test to pin the
drift. Same shape as the H-2 / W10-4 H3 finding the previous commit
closed for the cumulative-type set.
Move the lookup next to CUMULATIVE_DAY_SUM_TYPES in
src/lib/measurements/cumulative-day-sum.ts and add vitest assertions:
every CUMULATIVE_HK_TYPES member resolves either to a real
SourcePriorityMetricKey or to null (the picker's pass-through escape
hatch for types like TIME_IN_DAYLIGHT). The route now imports the
helper instead of duplicating the switch.
CUMULATIVE_HK_TYPES (apple-health-mapping.ts) and CUMULATIVE_DAY_SUM_TYPES
(cumulative-day-sum.ts) enumerated the same five Apple Health cumulative
types in two literal blocks. A contributor adding a sixth type (e.g.
swimming strokes) only had to update one list and the system would
silently misreport for that type — either the list-view collapse would
not fire or the analytics pickCumulativeDaySum would skip it, depending
on which file the contributor touched.
Derive CUMULATIVE_DAY_SUM_TYPES from CUMULATIVE_HK_TYPES at module load
(sorted alphabetically for stable iteration) and add a vitest parity
assertion so the membership stays pinned. Both exports remain so every
existing import keeps working.
pg-boss v12 requires explicit createQueue before scheduling. The
nightly drain schedule registered without a matching createQueue call,
which silently no-ops the cron — the per-sample Apple Health rows
never collapse into one row per day. The list view still works via
the on-demand groupBy=day branch, but the underlying table stays
bloated forever.
Add the queue to the allQueues array next to ROLLUP_FULL_BACKFILL_QUEUE
so the boot loop creates it before the schedule registration runs.
Extend the existing scheduler test to grep the allQueues block so a
future regression flips red instead of slipping through.
The two helpers carried byte-equivalent CF / XFF / x-real-ip ladders
since the F-6 audit landed `getClientIpOrTrustWarning` alongside
`getClientIp`. Project `.ip` through the tagged helper so the rotation
guard, Cloudflare opt-in and one-shot trust-violation warning live in
one place; every existing call site keeps the same return value.
The v1.4.34.5 tap-target sweep covered the dashboard tile and
per-row chevron but missed the two CTAs the
`<GettingStartedChecklist>` header renders on the onboarded-user
dashboard: the expand/collapse toggle (32 px tall) and the
dismiss-all CTA (40 px via shadcn's default Button, which has not
yet learned the `h-11 sm:h-10` mobile lift the input primitives
follow). Both flagged in the `mobile-viewport` touch-target probe.
Pin `min-h-11 sm:min-h-10` on each so the desktop rhythm is
unchanged.
While here, two e2e specs needed updates to match the same wave:
- `mobile-viewport.spec.ts` enforced `width < 44 || height < 44`,
which flagged the chart-range pill row (`7T` / `30T` / `90T`,
each 44 px tall but 30-40 px wide) as a WCAG violation even
though 2.5.5 honours adjacent-target spacing for horizontal
groups. Drop the width branch; the height floor is the hard
contract every solo CTA must clear.
- `onboarding-flicker.spec.ts` route-stubbed `**/api/analytics`,
which is exact-match against the path — the IW1 slim-slice
variant `/api/analytics?slice=summaries` (which the checklist
hook now uses) bypassed the stub and hit the real route, so the
onboarded-user fixture flipped to `measurementCount = 0` and
the card rendered. Swap both stubs to a regex that matches the
base path AND any query-string variant.
The v1.4.34.5 iOS-textarea-zoom sweep lifted the mobile input
height floor from 40 px (`h-10`) to 44 px (`h-11`) on `Input`,
`Select` trigger, and the native `<select>` primitives — the
desktop tier still resolves to 40 px via `sm:h-10`. The
settings-mobile-consistency spec still pinned every form input,
the Compare-to trigger, and every `/settings/ai` native select to
40 px, so it went red on the v1.4.36 tag while the implementation
was correct.
Bump the three height assertions to 44 px and refresh the header
docblock so a future maintainer reads the contract as "44 on
mobile, 40 on >=sm" instead of stumbling on a stale baseline.
The integration suite used to share a single module graph across
files (`pool: forks`, `fileParallelism: false`, `isolate: false`).
That kept the container boot amortised but meant a `vi.mock()`
declaration in one file lost to whichever earlier file had already
pulled the real module into the graph. `apns-dispatch.test.ts` and
`integration-status.test.ts` both fall through to
`@/lib/notifications/dispatcher` (and its `@parse/node-apn` /
telegram sender deps); when a sibling test mounted the real
dispatcher first, the mock factories registered later silently
no-op'd and the in-suite run reported the mock as called zero
times. Each file still passed in isolation, which is the
fingerprint the v1.4.35.1 closure already flagged as pre-existing.
Flip the integration config to `isolate: true`. Per-file module
isolation costs ~10 s of import rebuild across the 56-file run;
the Postgres testcontainer + migrations live in `globalSetup`, so
the slow part (container boot, ~5-15 s) is unaffected. The two
`withings-*-sync.test.ts` files were previously dependent on a
sibling setting `process.env.ENCRYPTION_KEY` before them — pin the
deterministic test key in each file so they remain self-contained
under per-file isolation. Locally `pnpm test:integration` now
shows the four target failures green; the remaining two
bp-in-target flakes are pre-existing local-only data drift that
passed on CI under the old isolation flag and continue to be
ordering-independent.
The features-payload-too-large guard constructs a ~5.6 MB rollup
payload and runs the whole serialiser pipeline to confirm the
ceiling fires. Local hardware completes the assertion well inside
the default 5 s budget, but the GitHub-Actions Ubuntu runners
crossed it and timed out on the v1.4.36 push. Pin a 30 s budget on
this single case (Vitest 4's two-arg `it(name, opts, fn)` form,
the deprecated three-arg form was removed) and leave the rest of
the suite on the default; the runtime is dominated by the giant
allocation, so the wider window only matters for the slowest
runners.
Two findings from the post-implementation self-review:
- Empty-state CTA: swap the plain anchor for next/link so the
/medications jump stays inside the SPA router instead of triggering
a full page reload.
- JSDoc accuracy: the original module-level comment claimed a
non-default dose 'surfaces as a sonner toast hint' — neither the
toast nor a notes-style append actually exists. Rewrite the dose
paragraph to honestly describe the contract: the dose input is
pre-filled for visual confirmation, editable, but the POST body
never carries the dose override. The intake event always
references the medication's catalogue dose; a half/double dose
still routes through the medication edit on /medications.
Add the SSR-level suite for the new quick-add: empty-state markup, the
populated form's field testids plus 44 px touch floors, the pure
default-selection heuristic across the four ranked branches
(empty / single-active / due-now / alphabetical fallback), and the
no-raw-key locale check for English and German.
Extend the existing quick-add-labels guard so it covers the third
DropdownMenu row alongside the original two — the icon is decorative
and the trigger label sits above the menu, so the three visible
labels must stay distinct from each other and from the trigger.
Add the menu-item label (dashboard.quickAddMedicationIntake) and the
dashboard.medicationIntakeQuickAdd block (sheet title, sheet
description, field labels, hint, error message, and empty-state copy)
to all six locales. The German bundle ships Marc-Voice copy with full
UTF-8 umlauts; the other locales mirror the English fallback already
in use for the other two quick-add rows until the localisation pass.
Add the third row to the dashboard quick-add DropdownMenu so a
medication-intake event can be logged without leaving the page. The new
ResponsiveSheet shares the same footer-portal contract as the existing
Measurement and Mood sheets so the Save and Cancel buttons sticky-pin
above the mobile soft keyboard, and the menu label is a self-contained
verb-phrase ('Medikamenteneinnahme erfassen' / 'Log medication intake')
that doesn't collide with the trigger or the sibling rows.
Mirror the ResponsiveSheet primitive used by the Measurement and Mood
quick-adds for medication intake. The sheet auto-selects the active
medication whose schedule window is currently open (or the only active
medication when the user has just one), pre-fills the dose from the
catalogue entry, and defaults the time taken to now. On submit the
form invalidates medicationDependentKeys plus the inline per-medication
compliance chart cache so the detail tile, the dashboard compliance
chart, the today list, and the achievements card all refresh in lockstep
with the API. An empty-state branch suppresses the form and surfaces a
CTA to /medications when the user hasn't yet created a medication.
The `runGeoBackfill` helper has shipped since v1.4.27 but was never
wired into the worker boot — it only ever fired through a test path,
so the admin sign-in overview's "Standort" column kept rendering
"—" on every historical row where the fire-and-forget IP resolver
missed at write time. Marc's report against v1.4.36 made the
problem visible: even after the v1.4.36 fallback fix, the backfill
was missing entirely.
Wire the helper into the existing pg-boss schedule list at :40
every hour. The queue name + cron expression live in the helper
module so the worker boot pulls them by import; a co-located unit
test pins both shapes (queue name + cron slot) plus a source-text
probe that confirms the worker actually imports and registers the
runner. The helper itself is unchanged — it stays idempotent, capped
at 5 000 rows per pass, and scoped to the trailing 30-day window
that the operational triage cares about.
The :40 slot was chosen to dodge the existing :00 (Withings activity
+ measure crons), :15 (Withings sleep), :30 (moodlog sync), and
:25 (medication compliance status) crowd at the top of the hour;
the helper's per-call cap keeps the audit-log write budget under
control even if a future ingest path enqueues a hot run.
Per-commit summary, file ownership, tests delta, self-review findings
and applied resolutions, visual diff between Ramipril and Mounjaro
post-fix, and the GLP-1-specific rotation-hint prominence judgement.
Self-review polish on top of the v1.4.37 W7c step-consolidation
landing:
- The collapsed daily view returns up to 5000 synthesised rows in
one shot, so the legacy 25-row pagination would have re-fired the
same scan with offset=0 returning the same slice. Disable
pagination on the day-grouped path; per-sample lists keep their
PAGE_SIZE=25 behaviour.
- Bump the per-page row scan from 1250 to the server max of 5000 so
even chatty pre-drain Apple-Watch accounts (~200 chunks/day on
busy days) get a useful number of daily rows on the first paint.
Post-drain the scan is trivially short (each day reduces to one
stats: row).
- Reset offset=0 explicitly on the grouped path so a sort-direction
flip never paginates into an empty server slice.
- Clarify the day-boundary comment in the dayKey drill-down branch
— the ±12h around canonical noon shift works for every IANA
offset, including sub-half-hour zones (Asia/Kathmandu +5:45,
Pacific/Chatham +12:45).
The Coolify-fronted HealthLog stack sits behind Cloudflare, which
strips and re-sets `cf-connecting-ip` on every request that lands
on its edge. Without consulting this header, `getClientIp` reads
the Caddy loopback IP off the `X-Forwarded-For` chain and the
admin sign-in geo lookup ends up with a null IP for every visitor
— Marc's "Standort" column rendered a literal "—" on every row of
the sign-in overview even after v1.4.36 closed the missing-ASN
fallback.
The new branch prefers `cf-connecting-ip` ahead of the XFF / x-real-ip
chain, but only when the operator opts in via `TRUST_CF_CONNECTING_IP=1`.
A self-hosted deployment without Cloudflare in front MUST leave the
flag off — an attacker can set the header directly on the public
internet, and the downstream geo resolver would happily report a
forged location.
The trust-violation accounting in `getClientIpOrTrustWarning` skips
when the CF header satisfies the lookup because Cloudflare re-sets
the value on every edge request — there is no chain to violate. The
existing XFF + x-real-ip semantics are unchanged for every
deployment that leaves the flag unset.
`.env.example` documents the flag in a dedicated "Reverse-proxy
trust" block right above the process-split section so the operator
finds it next to the existing infra-trust controls.
Side-by-side contract suite for the v1.4.37 W4b symmetry fixes from
Marc's UX audit (item 11). Asserts the load-bearing structural seams
both medication-card variants must share:
* History + Pencil header buttons render on both cards.
* No kebab on either card when no overflow prop is wired (Mounjaro
matches Ramipril in the common case).
* Kebab only mounts on Mounjaro when `onLogSideEffect` is supplied;
no equivalent surface on Ramipril.
* Category-label badge uses the shared category-map lookup on both
cards (Mounjaro no longer hard-codes the treatment-class label).
* Take-now coloured pill renders on both cards when inside the
current schedule window.
* Primary-actions row carries the canonical two-button shape — the
GLP-1-specific Stethoscope no longer leaks into this row.
* Purple `text-purple-400` accent on the upcoming schedule dose
surfaces uniformly across both card kinds.
Four new test files cover the v1.4.37 W7c surface:
* group-by-day.test.ts (route) — collapses per-sample step rows into
one row per user-TZ day with sampleCount; gates on
CUMULATIVE_HK_TYPES so non-cumulative types keep the legacy
findMany behaviour; resolves the dayKey drill-down window via
measuredAt: { gte, lt }; rejects malformed dayKey at the
validation layer.
* measurement-list-step-grouping.test.tsx (UI) — renders the
collapsed daily total with the localised sample-count caption;
exposes a chevron-expand button with aria-expanded; suppresses
the edit/delete actions on the synthesised daily row.
* drain-per-sample-cumulative.test.ts (helper) — passes a
measuredAt cutoff into the per-sample scan when cutoffHours is
set; falls back to the all-rows behaviour for CLI/admin callers
that omit the option.
* drain-cumulative-queue.test.ts (scheduler) — pins the queue name,
cron cadence (03:45 Europe/Berlin), 36 h cutoff constant, and
pg-boss handler wiring.
Self-review polish on top of the W2 fast-path landings:
- Rename the misleading `ninetyFiveDaysAgo` local in
bp-in-target-fast-path to `readSince` so the 396-day BP rollup
read window does not read like a 95-day shortcut.
- Document the rollup-bucket-vs-display-tz day-key shift in
correlations-fast-path. The DAY bucket anchors at UTC midnight; for
the Berlin-centric production tenant that lands on the same local
calendar day, so the rollup-fast-path's per-day pair shape matches
the live path within the n >= 20 surface gate. A v1.5 follow-up can
mint per-user-tz buckets if a non-Berlin account needs strict
parity.
The GLP-1 card carried a third Stethoscope button in the primary
actions row (Eingenommen / Übersprungen / Nebenwirkung), which broke
the two-button symmetry the generic medication card establishes.
Marc reported the asymmetry during the v1.4.37 UX audit (audit
item 11): "ich möchte natürlich, dass das genau gleich ist".
Move the side-effect quick-log into a kebab-style overflow in the
header `actions` slot. The kebab only mounts when the parent wires
`onLogSideEffect`, so Mounjaro and Ramipril cards stay byte-equivalent
in the common case (history + edit, no kebab). When the side-effect
hand-off is wired, Mounjaro gains the kebab while the primary actions
row stays the canonical two-button shape.
The localised label re-uses the existing `common.moreOptions` key for
the trigger aria-label and the existing `medications.glp1LogSideEffect`
key for the menu item, so no new i18n strings are required.
Marc directive: when the operator turns the global Coach flag off,
every Coach affordance on every surface must vanish — no grey-out,
no error, no inert button, no DOM trace. The recent v1.4.37 W5
hotfixes closed three holes the UX audit caught (HeroStrip action
button, SuggestedPrompts chip strip, /targets CoachDrawer mount); a
follow-up audit item asked for an automated invariant so the gate
contract can't regress.
Walk every Coach-bearing web surface (HeroStrip action row,
SuggestedPrompts chips, CoachLaunchButton inline pill, LayoutCoachFab
mobile sticky, LayoutCoachMount drawer subtree) under a QueryClient
pre-seeded with `coach: false` and assert each one paints nothing
measurable in the SSR output. The fixture maintains a known surface
list; a trailing count check forces contributors to revisit the
fixture when a new Coach-bearing component lands.