These tests passed in Europe/Berlin but failed under UTC (or against the
shipped engine) because they leant on the host clock:
- intake route test: the mocked user had no timezone, so slot resolution
fell back to the host TZ and the 07:00 schedule slot only matched in
Berlin. Pin the user to Europe/Berlin.
- health-score: 30 daily takes seeded at an unaligned now-i*DAY shifted
their Berlin hour with the runner TZ, dropping them out of the
08:00-10:00 on-time band. Anchor each take to a fixed 07:30 UTC.
- sleep-stages: the night sat on the trailing-30-day window edge for a
fixed calendar instant. Anchor it to now-5d.
- weekly-compliance: not a timezone issue. The dose-window engine treats
a weekly dose inside the 4-day overdue tail as still-takeable, not
missed, so skipping the most-recent Monday no longer lowers the rate.
Skip an older, settled-miss Monday instead; the gap assertion stands.
The dose-window editor pulled DOSE_WINDOW_DEFAULTS from
@/lib/analytics/compliance, dragging the server-only scheduling graph
(which imports node:async_hooks) into the client bundle. Turbopack
rejected the client chunk; tsc does not model the boundary so the type
check stayed green.
Move the constant into a leaf module with no server imports and
re-export it from compliance so the server path is unchanged. The
client-reachable editor now imports the leaf directly.
Replace the Zeitplan tab's read-only summary placeholder with an inline
editor for the everyday schedule levers: the dose times-of-day and each
dose's on-time window via the shared <DoseWindowEditor>. Frequency and
cadence-kind stay structural — the tab links out to the wizard for those.
<ScheduleTimesEditor> self-saves via PUT /api/medications/[id] with the
full schedules array, preserving every cadence field from the snapshot
verbatim and changing only the times and windows; a window whose dose
time was removed is dropped before the write. Reminder grace is a
read-only echo with a jump to the Reminders tab, its single owner.
Wire the per-dose window into the creation/edit wizard so a new
medication and a structural edit set windows with the same semantics the
schedule tab uses. Step 7 renders the shared <DoseWindowEditor> below the
times picker; the payload carries doseWindows on each schedule draft, the
create body emits only the explicit ranges that still name a live dose
time and differ from the default band, and edit-hydrate round-trips the
persisted windows back onto the active draft.
The step root data attrs stay stable for e2e.
Introduce <DoseWindowEditor>, the configurable on-time window control
shared by the schedule tab and the wizard. Per dose time the user keeps
the precise time (the symmetric +/-1h default band) or sets an explicit
range such as 07:00-09:00; the row shows the resulting on-time band plus
the cadence-aware late tail. The component is controlled over the
persisted doseWindows contract ([{timeOfDay,start,end}]) and validates
HH:mm, start<=end, and timeOfDay membership to mirror the server schema.
Pure window math lives in dose-window.ts (band derivation, late tail,
default-vs-explicit detection, serialise) so it is unit-tested without a
render. Adds the medications.detail.zeitplan namespace across six
locales.
Replace the Verlauf tab's flat intake table with the per-slot dose-history
ledger the shared band engine mints. The tab now renders EVERY expected slot
with its status — on-time, late, skipped, missed, upcoming — even when the
dose was never taken, and tags every off-schedule intake as a standalone
ad-hoc row at its real time. Rows group by day, most-recent first.
Genommen / Übersprungen sit on the dose row and recompute instantly: the
cached ledger and the headline adherence are mutated optimistically (the
engines are pure, so the client mirrors their accounting) before the write
fires, then the dependent-key invalidation reconciles; a failed write rolls
the optimistic snapshot back. A header add dialog records a dose — including a
backdated or off-schedule one — and an off-window take surfaces the "diesem
Slot zuordnen?" nudge, pinning it onto a chosen slot via forceSlotInstant
rather than orphaning it. A compact took/skipped bar sits at the top of the
tab; the card body still never tints by status.
Adherence is derived from the same ledger the user is reading (taken over
taken + missed; skip, ad-hoc, upcoming excluded), so the history view and the
percentage cannot contradict. New status + dialog copy in all six locales.
The medications-list card kebab no longer opens a modal editor or the
stacked advanced-settings sheet. Edit navigates to the detail page's
Zeitplan tab, History to Verlauf, Advanced to Erweitert. The list-page
wizard becomes create-only; editing an existing medication goes through
the detail hero's "Vollständig bearbeiten".
Dissolve the advanced-settings sheet entirely: its five stacked groups
(Lifecycle / External API / Data / Danger zone) now live as inset-grouped
sections in the Erweitert tab. Drop the orphaned modal component, the
standalone SettingsSection wrapper, and the lazy intake-count query the
sheet's danger zone needed (the tab fetches it on demand). Remove the two
tests that only exercised the retired surfaces.
Make /medications/[id] the canonical full-page home for one medication:
a Radix tab strip (Übersicht · Zeitplan · Erinnerung · Bestand · Verlauf ·
Injektion · Erweitert) with ?tab= URL state, a read-only hero, and a
"Vollständig bearbeiten" jump into the wizard for structural edits.
Everyday levers move inline into the tabs.
Übersicht surfaces the adherence bars, Erinnerung owns notifications +
reminder grace, Bestand reads the pen/vial inventory (all medications,
calm empty state), Verlauf renders the editable intake history, and
Injektion (injectable routes only) carries the existing GLP-1 charts +
side-effect log. The ledger, the per-dose window editor and the
injection body-map land in the following waves.
The legacy /medications/[id]/history route now redirects to ?tab=verlauf
for bookmarks and deep links. Tab labels are i18n across six locales.
Swap the hand-rolled per-group slot substitution for the same pure,
total-order rebuildTilesWithReorderedVitals helper the inline edit mode
uses, so the pill-sort control renumbers tile order identically and stays
in lockstep with the overview arrange surface.
Tapping the top-right regenerate button POSTs an inline ~1500-token warm
generation that routinely runs past the 8 s read budget. The force branch
shared that 8 s AbortController, so a slow-but-successful generation was
discarded: the abort returned null, the mutation only invalidated (re-
reading the OLD cache), and the falling-edge toast claimed success while
the text never changed — even though the server had often written the
fresh briefing.
Give the force branch its own 45 s budget (matching the out-of-band warm
job) so a slow success is kept, not dropped. Tag every fetch outcome
(fresh / empty / timeout / no-provider) and surface the last regenerate
outcome through the tab strip, which now toasts "refreshed" only on a
genuinely fresh result, shows a "still working, try again" hint on a
timeout, and stays silent when no provider is configured. The read path
keeps its fast 8 s cap.
Customising the overview and sorting the nav pills now live only in
Settings → Insights, reached via the top-right cog. The in-page edit
mode and its toggle are gone — a single entry point, no duplicate. The
"every section hidden" empty-state now links to the Insights settings
section instead of reopening an in-page editor.
The tab strip gains a settings cog immediately left of the regenerate
button, linking to the new Insights settings section (overview arrange +
pill sort). It mirrors the Dashboard cog idiom and travels with the
regenerate affordance so the two top-right controls sit together.
The Insights overview customise surface now has a home in Settings,
analogous to the Dashboard section. It hosts two blocks:
- "Arrange the overview" embeds the inline section show/hide + order
editor, persisting to insightsLayoutJson.sections.
- "Sort the navigation pills" is a dedicated drag-reorder control for
the tab-strip pills, persisting to insightsLayoutJson.tiles[].order —
a field already separate from the section order in layout v2, so the
two concerns stay independent. Both write through the existing
/api/insights/layout contract the tab strip and overview share.
Wire the new slug into the settings registry, the section router, the
mobile/desktop nav, and the placeholder icon map. Adds the section copy
and the pill-sort + overview-arrange strings across all six locales.
IN_BED is the TOTAL time in bed (≈ CORE + DEEP + REM + AWAKE), not a sleep
phase, but it was a stacked Bar segment alongside the phases. Stacking the
total on top of its own constituents doubled every bar — a 7 h night drew as
~14 h dominated by the in-bed segment — and inflated the tooltip's per-night
total, which sums the stacked series. Drop IN_BED from STAGE_ORDER so the bar
height and the tooltip total are the real night. The STAGE_COLORS.IN_BED
token and the inBed label stay for the last-night hypnogram, which still
renders the in-bed span. STAGE_ORDER is now exported so the test pins that
IN_BED is not a stack series and the total equals CORE + DEEP + REM + AWAKE.
The v1.15.9 take-window highlight washed the whole medication card green
(ring + tinted background) when a dose was in its on-time window. A card
turning green reads as an imperative "take it now" and drowns the rest of
the page once several doses fall due. Drop the surface tint entirely so the
card stays a constant neutral surface; the take-now / overdue / very-overdue
status remains, expressed only through the discreet top status line / pill
(text + glyph), never as a background or border colour. Tests assert the
neutral surface across every dose status plus the surviving status pill.
Accept an optional doseWindows array on the schedule create/update routes:
each entry pins an explicit on-time range [start,end] to one of the
schedule's times-of-day, defaulting to the symmetric grace band when
absent. Reject malformed ranges (bad HH:mm, a timeOfDay outside the
schedule, an inverted span) with a 422 so the persisted window can never
feed the band minter a degenerate range.
Regenerates the OpenAPI contract for the additive field (iOS-safe).
Thread the persisted `doseWindows` through the shared band minter so the
on-time band for a dose time uses its explicit `[start, end]` range
(minted DST-correctly on the slot's local day) instead of the symmetric
±1h default; the late tail stays cadence-derived. A `normaliseDoseWindows`
helper coerces the stored JSON into a clean entry list, dropping anything
malformed so the read/write paths never defend against an arbitrary shape.
Every band consumer reads the same field: the dose-history endpoint, the
compliance %, the cadence timeline (card last/next dose), and the
write/edit slot attribution all resolve through one minter, so the
history view and the rate can never disagree. The write/edit and
health-score schedule selects now pull the column.
Add `doseWindows` (Json?) to MedicationSchedule: a nullable array of
`{ timeOfDay, start, end }` HH:mm triples naming an explicit on-time
range for a dose time (e.g. 07:00–09:00). A time with no entry keeps the
symmetric ±1h default; a NULL column leaves every slot on the default
derivation, so the migration is additive and changes no existing
behaviour. Migration 0137 adds the column with no backfill.
Convert the bulk-delete hard deleteMany to a soft-delete updateMany that sets
deletedAt and bumps syncVersion, matching the single-event DELETE handler so a
multi-select removal surfaces each row as a tombstone on the sync delta feed
to clients offline at delete time. The where clause now scopes deletedAt:null
so a re-post of already-removed ids counts zero.
GET /api/medications/[id]/dose-history?from=&to= returns the full per-slot
ledger over the window: every expected slot with a status (taken on-time / late
/ skipped / missed / upcoming) plus every off-schedule intake tagged ad-hoc,
built from the same band minter the compliance % consumes so the history view
and the rate can never disagree. Ownership-scoped, rate-limited, reads only
non-tombstoned rows, instants serialised as ISO strings. Additive — iOS-safe.
This is what the medication Verlauf tab renders.
Editing a takenAt or a status toggle now re-runs window-band attribution and
sets scheduledFor to the matched slot (the take's own time on a miss), instead
of leaving the slot stale. A slot move tombstones the source row and routes the
corrected dose through the shared canonical-slot upsert so the write is
P2002-safe rather than bare-updating into an occupied slot. Both edit surfaces
(the per-event PUT and the status-toggle POST) accept forceSlotInstant to pin
an off-window edit onto a named real slot, rejecting a non-slot with 422.
The per-medication intake POST resolves a taken dose's slot through the band
attributor instead of the ±6h snap: a take inside a slot's configurable dose
window snaps to that slot (canonical upsert), a take outside every window
records as a standalone ad-hoc row, and a PRN medication always inserts
standalone. A skip still binds to its slot by the canonical scheduledFor snap.
Add an optional forceSlotInstant to the intake body so the UI's late-take
"diesem Slot zuordnen?" nudge can pin an off-window take onto a chosen slot;
the server validates the instant is a real slot of the medication on its day
and rejects a non-slot with 422. The dose-safety future-slot guard stays.
Add a shared write-side attributor that runs the SAME cadence-aware band
minter the read ledger and the compliance % consume, so a take is bound to a
slot identically wherever the decision is made. `attributeTakenToSlot` maps a
takenAt onto a slot by dose-window membership (on-time / late) or returns null
for an off-window ad-hoc take; `resolveForcedSlotInstant` validates a
client-pinned slot against the real band anchors. The DB-loading resolvers
(`resolveSlotForWriteByBand`, `resolveForcedSlotForWrite`) load the
medication's schedules + rolling anchors and run the pure core.
This retires the wide ±6h nearest-snap (`snapToleranceMs`) that pulled an
11:29 take onto the 07:00 slot and a 13:02 take onto the 19:00 slot.
The medication compliance percentage and the history view drew their
per-dose attribution from four different matchers — the ±12h pairDoses
proximity used by calculateCompliance, the deriveDoseStatus bands (open
dose only), the 3h+120min classifyIntakeTiming heatmap heuristic, and a
raw takenAt-IS-NOT-NULL per-day rollup. They could and did disagree: a
dose could read "taken late" in the rate while the history called it an
off-schedule ad-hoc take.
Route the rate through the shared cadence-aware band minter +
reconstructDoseHistory so the percentage is a tally over the SAME ledger
the history view renders. The same intake can no longer be taken in the %
and ad-hoc in the ledger.
- calculateCompliance: when a medication context is supplied, the
count fields (taken / skipped / missed / rate) come from
tallyComplianceFromLedger. Numerator = taken_on_time + taken_late (a
late dose still counts as taken — the taking-vs-timing split is kept
available); denominator = taken + missed; skipped, ad-hoc, upcoming and
whole PRN groups are excluded; rate capped at 100. The streak still
walks the cadence timeline. Context-less pure-math callers keep the
legacy timeline tally byte-stable.
- complianceChips + missedDoses: adherence rate + missed count come from
the same ledger when an engine context is threaded.
- /api/medications/[id]/compliance heatmap: the per-day timing split and
missed marks are bucketed from the one ledger instead of the per-day
classifyIntakeTiming + expectedSlotCountForDay walk, so the cells agree
with the headline rate.
- medication_compliance_rollups stays a raw cross-medication daily
coverage count, documented as distinct from the band-attributed % (the
band minter is per-cadence and cannot run inside a single SQL tally).
Downstream readers (health score, insight compliance status) keep calling
calculateCompliance; their numbers shift to the honest band-attributed
values. Some currently-counted off-time takes (e.g. an 11:29 take of a
07:00 dose) now read ad-hoc with the slot missed rather than taken-late —
the configurable per-dose window is the lever to widen the on-time band.
Tests: the irregular twice-daily, single-time-late heatmap, and legacy
parity fixtures are updated with rationale for the intended number
changes; new tally tests pin the taking/timing split, the skip / ad-hoc /
upcoming / PRN exclusions, auto-missed=missed, the 100% cap, rolling
retrospective families, and the cross-consistency invariant.
Self-review: a rolling interval of 1 day is effectively daily, so a ±1-day
day-scale on-time window would over-widen and let one take claim adjacent
slots. Classify rolling by its interval — ≥2 days is day-scale (the canonical
weekly+ injection), ≤1 day stays minute-scale. Two tests pin the distinction.
Add the keystone that builds the correct SlotBand[] for every medication
cadence, so the dose-history read-model, the compliance rate, and the
write/edit attribution all consume one source of slot windows and can never
contradict each other.
Per cadence:
- daily / multi-time: minute-scale on-time (±60min default) + daily late tail;
- fixed weekdays / every-N-weeks: family derived from the realised inter-slot
gap, not the field-shape heuristic that mislabels legacy daysOfWeek meds as
daily; day-scale on-time + 4-day tail;
- rolling (GLP-1): retrospective bands anchored at each logged intake, not the
forward-only single slot that would orphan every past shot to ad-hoc;
- cyclic: off-week slots already dropped by the engine; band the survivors;
- one-shot: a single whole-day on-time band, never auto-missed;
- PRN / empty / malformed rrule: no bands, hasExpectedSlots=false;
- DST day-scale bounds minted via localHmAsUtc on calendar days, not ±N·DAY_MS;
- multi-schedule: bands per schedule so a weekly tail is never clipped by a
daily anchor.
Pure and synchronous; the rolling intake instants are pre-fetched and threaded
in like resolve-slot-instant. 25 tests covering each cadence plus DST
summer/winter, the realised-gap family, and the per-schedule no-clip invariant.
Reconstructs a complete, attributable ledger from the expected-slot bands +
the actual intakes: every expected slot carries a status (taken_on_time /
taken_late / skipped / missed / upcoming) even when never taken, and a taken
intake that matches no band is emitted as a tagged ad-hoc row at its real
time instead of vanishing.
A taken intake attributes by its real takenAt (not the stored, possibly
mis-snapped scheduledFor), so corrected bands converge forward over legacy
rows with no data migration. Skipped / auto-missed / pending rows bind to
their slot by anchor. Pure and instant-based. 9 tests.
Pure band-membership attribution replacing the wide nearest-neighbour
matchers that mis-assigned irregular intakes (a 13:02 take snapped onto
the 19:00 slot, an 15:46 take onto the 07:00 slot). A take pairs to a
slot only inside that slot's dose window plus a capped late tail:
intake in [onTimeStart, onTimeEnd] -> on_time
intake in (onTimeEnd, overdueEnd] -> late
otherwise -> ad-hoc (standalone row)
The on-time band is the per-dose window; overdueEnd is capped at the next
slot's on-time start so adjacent slots never both claim one intake. Pure
and instant-based; callers mint the bounds DST-correctly via localHmAsUtc.
13 tests pin summer/winter, on-time/late/ad-hoc, the cap, and a widened
window.
Next 16 truncated any request body over ~10MB passing through middleware
(src/proxy.ts) before the route handler saw it, so a multi-MB export.zip lost
its ZIP end-of-central-directory and failed to parse (GitHub #281). Set
middlewareClientMaxBodySize so real exports arrive intact.
The DEMO_MODE block 403s every non-GET /api/* request outside a narrow
allowlist, which also caught the above-chart display toggles — the
chart-overlay switches and the comparison-baseline selector — even
though they only write the caller's own dashboard-prefs blob.
Admit PUT /api/dashboard/chart-overlay-prefs and PUT
/api/dashboard/widgets in demo mode. The allowlist now pins method per
path, so the widgets PUT does not drag in the layout-reset DELETE. The
block only runs under DEMO_MODE=true, so production is unaffected.
Parameterised via env (user id, protected id, hosts, container patterns) so it
carries no account or infrastructure identifiers. Per-table demo-scoped COPY
with pre-flight + post-load isolation guards; strips secrets and unloadable
encrypted columns on the way through.
The wellness detail card carried overflow:hidden (v1.15.12) to keep the
subtle bottom-leaning gradient inside the rounded corners. But the score
ring's drop-shadow bloom is a child of that card, so overflow:hidden also
clipped the glow at the card edge.
The gradient is the card's own background, which is already clipped to
border-radius without overflow:hidden — overflow only clips child boxes,
never a painted background. Drop overflow:hidden: the gradient stays
corner-clipped, the ring glow now renders uncut on all four sides.
Warm the insight generator (briefing + key findings + insight prose) and
the Coach to the same premium-motivating advisor register already shipped
for the per-metric assessments. The intro and role framing drop the cold
clinical-summariser voice for a warm advisor who reads the user's data
against their own history, names earned wins, and frames an unfavourable
finding as one doable step.
Every safety and quality guard is preserved: no diagnosis, no
prescription, no platitudes, no bare number-echoing, honest about
unfavourable values, the banned positivity openers, the doctor-consult
call-to-action, and the JSON output schema + keyFindings tone enum stay
byte-identical. PROMPT_VERSION 4.27.0 -> 4.28.0 (also covers the Coach,
which imports it). Native FR/ES/IT/PL personas warmed to match.
The default insights layout shipped a curated visible subset (v1.15.11),
authored for the overview Vitals/Mobility grid. Once the tab strip began
gating its nav pills on the same layout, that subset dropped ~20 nav pills
for a fresh account with data in Sleep, Steps, Active-Energy, body
composition, Walking/mobility, Audio, Daylight, etc. — pills that show
today — and caused a flash-then-vanish on first paint.
The grid renders a fixed mapped subset regardless of tile visibility, so
defaulting every tile visible does not bloat it; what it restores is the
nav's everything-with-data default. The user hides what they don't want.
Also:
- mood distribution + weekday charts: switch axis tick fill from the raw
--dracula-fg (white-on-white on the light-mode card) to the shared
--muted-foreground axis-text token; dark mode unchanged.
- drop the orphaned insights.editMode.manageTiles key from all six locales
(replaced by manageSubPages) and add a manageSubPagesHint noting that
hiding a detail page also removes its top-nav pill.
- pin the all-visible default with a layout test and a tab-strip
default-nav regression test.
The Anpassen tile manager only listed the ~14 vitals overview-grid
tiles, so the rest of the routed sub-pages had no toggle anywhere. Now
that the tab strip reads the same per-tile visibility, the manager
becomes the one place to show, hide, and reorder every sub-page.
Broaden the Kacheln-verwalten disclosure to the full sub-page universe,
grouped by category (vitals, body, activity, sleep, cardiovascular,
hearing, environment, metabolic, mood, events) the same way the strip
groups them. Each group is its own drag context so a reorder never
crosses a group boundary; rows reuse the strip's labels so a manager row
reads like the nav pill it governs. The single tiles layout this writes
now drives the overview vitals grid, the tab-strip pills, and this
manager. Relabel the disclosure to Detailseiten verwalten / Manage
detail pages across the six locales.
The top insights navigation was driven purely by data availability: a
sub-page pill appeared whenever its metric had readings in the 90-day
window, with no connection to the per-tile visibility the customize
panel writes. Hiding a sub-page in Anpassen therefore never removed its
nav pill, and the two surfaces drifted apart.
Thread the resolved insights layout into the tab strip via the layout
shell. A pill now shows iff its metric has data AND its slug is visible
in the saved layout; data availability stays the floor, so a
layout-visible metric with no readings still gets no pill. Pills and
group children order by the saved layout order, falling back to the
natural slug order. While the layout query is in flight the strip keeps
the data-only gate, so first paint is unchanged and a slow or failed
layout fetch never suppresses a pill the user has data for.
Add a total slug to manager-group mapping in sub-page-metric so every
routed sub-page resolves to a category, a superset of the tab-strip nav
grouping that also covers the flat pills and the sleep / mood / events
categories the strip never collapses.
Both charts derived their height off the full card width via aspect-[3/2],
which ballooned them on a wide viewport and let them dominate the mood
subpage. Bound each to a fixed 150px band so they read as tidy sibling
cards, and align the deferred-load skeletons to the same band.
Soften the saturated full-bleed mood hues to a muted tint (fillOpacity
0.55) so the bars sit calmly over the card while the per-level hue still
distinguishes the bands. On the weekday chart, drop the bright-green 3.5
target guide to a quiet comment-grey dashed line. The shared mood-level
tokens used by the heatmap and legend are untouched.