The health-record export panel sent no locale, so the server resolved the
report language from the browser's Accept-Language header. A German user on
an English-default browser got an English doctor report.
Send the active in-app locale from the panel, and on the server resolve
most-specific-first: explicit selection, then the healthlog-locale cookie
(what the user actually chose in the UI), then Accept-Language as the
weakest fallback.
Two small polish items on the insights category surface.
The desktop explainer popover body rendered at 11px while the mobile
sheet used the larger `text-sm`, an uncomfortable and asymmetric read.
Lift the popover body to `text-xs` (12px) so both surfaces sit on the
type scale.
The `MetricTargetSummary` docstring claimed both the range and the
in-target share are hidden when the route flags `insufficientData`. The
range stays visible — only the share is suppressed. Correct the comment
to match the code.
Two parts to the explainer surface, landed together because they share
the same i18n block and page props.
Coverage: every routed insights category page now passes an
`explainerMetric` prop, so the heading-side `?` glyph is present on all
37 pages instead of 13. The 24 newly covered metrics — body
composition (body water, bone/fat/muscle/lean mass, fat-free mass,
visceral fat), mobility (flights, walking distance/speed/steadiness,
asymmetry, double-support, step length, walking heart rate),
cardiovascular (pulse-wave velocity, vascular age), hearing
(environmental + headphone levels, loud-sound events), respiratory
rate, daylight, blood glucose, and skin temperature — each gain a one
to two sentence layperson definition grounded in standard clinical
references, shipped in all six locales.
Keys: the explainer key segments were a German/English mix
(`blutdruck`, `puls`, `gewicht`, `schlaf`, `stimmung`, `medikamente`
alongside English ones). Rename them to English to match the
naming-convention ADR — `bloodPressure`, `pulse`, `weight`, `sleep`,
`mood`, `medications` — across every locale, and update the
`explainerMetric` prop values that reference them. These are i18n key
segments, not route slugs, so the slug contract is untouched.
Rename every routed Insights sub-page slug from German to English
(`/insights/blutdruck` -> `/insights/blood-pressure`, `gewicht` ->
`weight`, `medikamente` -> `medications`, and the full v1.7.0 metric
tail). UI copy stays localised through `t()` keys; only the internal
slugs change. The slug registry in `src/lib/insights/sub-page-metric.ts`
is the single source of truth, so the tab strip, target summary, and
daily-briefing routing targets follow it.
`next.config.ts` `redirects()` emits a permanent redirect from every
legacy German slug to its English target, including a `:path*` variant
so nested routes survive. Bookmarks and cached PWA navigation keep
resolving.
Each insights category page now carries two additions placed beside the
chart and assessment:
- a target-range card (MetricTargetSummary) showing the numeric goal
band and the share of logged days within it, read from the shared
/api/insights/targets payload so the bands match the /targets editor
exactly. Blood pressure stitches the systolic and diastolic bands into
one S/D string. A discreet link routes to /targets to adjust the band.
- a question-mark explainer next to each heading (MetricExplainer in
SubPageShell) opening a static, layperson 'What is X?' read. Popover on
desktop, bottom-sheet on phones; keyboard- and touch-reachable with a
44px hit surface. Copy ships in all six locales.
The /targets page stays the canonical place to tune the bands.
The advanced sheet sits on one medication and its import already loads
that medication's intake history, but the co-located export streamed
every medication. Pass the medication id through to the export route,
which narrows both the medication row and the intake history by it
(always alongside userId, so a foreign id 404s rather than leaking).
The medication-card kebab menu comment implied the GLP-1 "Log side
effect" item is reachable from the card. It is not wired on the
medications list page — both card variants there receive only the
edit / history / advanced handlers, so the item never renders. Side-
effect logging lives on the detail page's SideEffectsSection. Correct
the comment to match.
Remove the scheduling i18n key subtrees orphaned when the schedule
section and cadence-summary row left the card: section, loading,
loadFailed, editCta, reminders, cadenceViz, compliance, oneShot,
legacyWindow, plus detail.cadence.title. Dropped in lockstep across all
six locales; the still-live naturalLanguage / cadence / timesOfDay /
courseWindow keys stay.
The generic medication card and the GLP-1 variant carried ~120 lines of
hand-synced JSX for the status pill, compliance bars, primary intake
actions, and state badges. Lift these into single-purpose presentational
components under card-parts/ consumed by both cards, so symmetry is
structural rather than maintained by hand.
Unify the day-streak flame onto the canonical text-dracula-orange
warning/streak token. The generic card had drifted onto Tailwind-stock
text-orange-400; both cards now render the same token.
No visual or behavioural change beyond the streak-token convergence.
The detail page carried an Edit / History / Advanced action row and a
'Rhythmus' cadence block that duplicated the card actions. Drop them:
editing and advanced settings are now reached from the card overflow
menu only. The detail page leads with the intake-history table, keeps
the GLP-1 drug-level / titration as a default-closed disclosure, and
renders a compact, read-only summary header (name, dose, status, plain-
language cadence line).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The overview cards (generic and GLP-1) carried four header icon-buttons
(open / edit / history / advanced). Collapse them into a single overflow
kebab and make the card header link to the detail page. The GLP-1 card
folds its side-effect quick-log into the same menu so both variants keep
one symmetric affordance. Menu items inherit the 44px tap-target floor
from the dropdown primitive.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The chat-side sources rail and the settings cog drove two different
data-source models — the rail hardcoded the legacy five sources and kept
its selection in ephemeral drawer state that reset on close, while the cog
edited the ten persisted clusters on User.coachPrefsJson. The prompt scope
followed the persisted clusters, so what the rail showed could differ from
what the model received.
Bind the rail to the same persisted prefs: it now reads useCoachPrefs and
writes the cluster toggles + analysis window through a shared
useSaveCoachPrefs mutation, so a toggle sticks and the cog reflects it (and
vice-versa). The chat request no longer sends a scope override, so the
route resolves the source set and window from coachPrefsJson and the rail
display cannot drift from the prompt. Drop the ephemeral scope state, the
duplicate header window pill, and the legacy five-source rail model.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Widen the medication advanced-settings dialog to the 4xl token and lay
the Data, Reminders and Lifecycle groups into a responsive two-column
grid on lg+ so a normal desktop viewport no longer forces a long
single-column scroll. The Danger zone keeps its destructive-tinted card
full-width at the bottom, separated from routine toggles.
Co-locate medications CSV export beside the intake import in the Data
group: a new DataPortabilityRow pairs the import button with an export
button that hits the existing /api/export/medications endpoint, with an
intake-history toggle mirroring the Settings export card. No new
endpoint.
Correct the import affordance copy to JSON — the dialog and API only
parse a JSON intake array, so the prior CSV/JSON label overstated the
accepted format.
Pure-history medication detail page, legacy step consolidation, and the
finish of the outbound-fetch migration.
- /medications/[id] becomes a read-only history surface; every setting
moves into a new advanced-settings sheet, and the edit trigger becomes
a two-option picker. The list page is unchanged.
- A pg-boss job folds pre-v1.5.0 granular step rows into one canonical
daily total per user per day and soft-deletes the originals; migration
0087 adds a partial index for the converging discovery scan.
- Every remaining server-side fetch routes through safeFetch (Withings,
Codex OAuth + SSE, bug-report, Umami, GlitchTip, Loki); a new
healthlog/safe-fetch-required ESLint rule guards the boundary.
- Fixes: intake-edit dialog seeded from real values, consolidation skips
a unique-constraint day instead of stranding it, avatar upload reads
the body once under a bounded cap, egress lint rejects absolute URLs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-row Bearbeiten and Löschen actions sit behind a kebab on each
intake row; multi-select gates a bulk-delete toolbar that wires
through to the v1.5.5 bulk-delete endpoint. Closes the two
displaced features from the v1.5.4 flat-form retirement.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A bare ["medications", id, "api-endpoint"] tuple escaped the
factory rule via useMemo; the new medicationApiEndpoint key in
the factory restores the single source of truth.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Split the two title ids so the Switch's aria-labelledby resolves
to the row label rather than the section heading. axe duplicate-id
gone; the announced name now matches the user's mental model.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The list-page medication cards previously pointed their History icon
straight at `/medications/{id}/history`. v1.5.5 promotes the detail
page as the canonical umbrella surface for everything the v1.5.4 form
retirement displaced, so the icon now opens `/medications/{id}` and
the detail page's intake-history preview links onward to the bulk-
delete sub-route in one extra tap.
Two focused tests:
- `medication-detail-header.test.tsx` pins the status-pill contract
(label always rendered, dot rides Dracula `--success` / `--warning`
tokens, dot is `aria-hidden`, DOM order is heading-then-edit).
- `detail-page-cascade.test.tsx` pins the per-section query-key +
invalidation cascade against the central factory. The §9.4
phase-config key lives outside the bundle so the settings section
must invalidate it explicitly; the test documents that contract
so a future refactor cannot drop it silently.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A new `/medications/[id]/page.tsx` surfaces every action the v1.5.4
form retirement buried — log today's dose, edit the plan, manage
notifications, manage API tokens, import CSV, configure GLP-1 phases
in a focused Sheet, walk a three-tier destructive cascade. The page
reads as a clinical document from header to destructive footer; one
phone-scroll reaches every restored feature.
Section order (recurring variant — 8 sections):
1. Header band — drug name + dose + status pill + edit pencil
2. Today's dose card — two buttons (`[Genommen]` + `[Übersprungen]`)
3. Cadence summary — plain-language line + Bearbeiten → wizard
Step 5 via the new `landingIntent: "cadence"` route
4. Dose ladder / Phasen — GLP-1 only, reuses the v1.4 components
5. Intake history preview — last 14 grouped + footer link to
`/history`; CSV-import trigger lives in the header
6. Notifications — `<Switch>` in `<label>` row + read-only chip
strip; replaced by an "iPhone steuert die Erinnerungen" chip
when `clientManaged === true`
7. Settings — Externe Integration / CSV stub / Phasen
(button → Sheet) / Grace minutes
8. Verwaltung & Gefahrenzone — Card A (Pausieren + Beenden) and
Card B (Verlauf + Medikament löschen) under one heading
One-shot variant collapses to 5 sections per D-3 §6. Paused variant
keeps the same structure with the header pill flipped and the
today's-dose buttons muted.
Every mutation cascades through `medicationDependentKeys` so the
inline compliance chart, dashboard chart and achievement reads
converge in one tick. Tier 1 Pausieren PUTs `{ active }` only;
the server derives `pausedAt`. Destructive CTAs ship `font-semibold`
so the WCAG large-text contrast band catches the destructive-button
white-on-#ff5555. Status pill text is always rendered; the dot is
`aria-hidden` and reads from Dracula `--success` / `--warning` tokens
so the colour stays in lockstep with the theme.
i18n covers DE + EN natively; ES/FR/IT/PL inherit the EN strings as
the locale-integrity guard's fallback contract specifies. New keys
live under `medications.detail.*`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The iOS team had gated both client-side waiting on the server map.
Both flow raw SI on the wire (metres and metres per second
respectively) — the server-side ×100 scaling the percent gait
metrics use does NOT apply here. The convention block at the top of
apple-health-mapping.ts now spells the split out so a future
gait-metric addition picks the right path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The list page was carrying two large dialogs (`IntakeImportDialog`
and `ApiEndpointDialog`) whose state slots had no list-card trigger
wired to them. The slots were only ever set from inside the dialogs
themselves, so they read as dead code on the list page even though
the dialog bodies themselves were healthy.
`IntakeImportDialog` lifts to `src/components/medications/intake-import-dialog.tsx`
as the canonical mount the detail-page intake-history header will
import. `ApiEndpointDialog` no longer mounts on the list page; its
final v1.5.5 home is the detail-page Settings sub-row (lands in a
later commit). The list page drops the two state slots, the inline
dialog bodies, and the now-unused imports.
`medicationDependentKeys` also gains the `compliance-chart-inline`
prefix. The per-medication inline compliance chart used to mount
under `queryKeys.medicationComplianceChart(id)` (which expands to
`["compliance-chart-inline", id]`) but the bundle did not match the
prefix, so an intake mutation left the chart up to 60 s stale. The
prefix now lands in the bundle so every detail-page mutation evicts
the tile in one tick.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six changes the v1.5.5 detail page leans on:
- Desktop shell widens from `sm:max-w-md` (448 px) to `sm:max-w-[560px]`
so the strip + body stay readable on the wider section icons the v1.5
shell ships. Mobile bottom-sheet keeps the 40 dvh floor so short
screens never collapse the wizard to a stub.
- Outer spacing scales to `p-4 pr-12 sm:p-6 sm:pr-14` so the close-X
has room to breathe and the strip + body share one column. The
fade-only `animate-in fade-in-0 duration-200 motion-reduce:animate-none`
on the step body keeps motion to one vocabulary — the slide proposed
earlier was stripped.
- `<Progress>` gets the explicit `transition-all motion-reduce:transition-none`
so width-only progress reads as the one source of motion on the
strip.
- `landingStepForEdit` takes an optional `intent` arg (`"cadence"` /
`"name"` / `"summary"`); the detail page's cadence-summary row uses
`"cadence"` so the user lands on Step 5 instead of Step 1. One-shot
payloads fall back to Step 8 because the cadence picker is not on
their path.
- The step `<h2>` carries `tabIndex={-1}` and receives focus on
intent-routed edit-opens so the screen reader announces the step
the user actually landed on, not the first input on it.
- `buildCreateBody` takes `forMode: "create" | "edit"` and omits
`notificationsEnabled` on edit. The detail-page notifications
switch is the single source of truth post-create; the wizard now
refuses to round-trip its default back to the API on edit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous /me payload carried a gravatarUrl built from
SHA-256(email) and pointed every authenticated page-load at
www.gravatar.com — Automattic could correlate the digest against
its known-email table on each request. This release moves the
avatar in-house: the user uploads through POST /api/user/avatar
(multipart, JPEG/PNG/WebP, 2 MiB / 2048×2048 max), the bytes ride
on the User row alongside a content-type and updatedAt timestamp,
and GET /api/user/avatar/{id} serves them from same-origin with
the cache-busting timestamp on the profile response.
The image-header parsing is hand-rolled in src/lib/avatar.ts so
the standalone image does not gain a native dep just to read width
and height. Validation rejects oversize / wrong content-type at
the multipart parse boundary; DELETE clears the row. The bytes
ride pg_dump alongside the rest of the row so the existing weekly
encrypted backup path already covers them.
The sidebar, the coach panel, the use-auth hook, and the disable-
coach card stop reading gravatarUrl; src/lib/gravatar.ts retires.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds RESPIRATORY_RATE, BODY_MASS_INDEX, and the four remaining
deferred quantity types to MeasurementType plus the Apple Health
mapping. The batch route already returns 200 with a per-entry skip
for unmappable identifiers; iOS reads that as success and advances
its sync anchor, so any sample with one of these identifiers used
to be dropped forever. The mapping now accepts them and the rows
land.
The walking-asymmetry and walking-double-support percent values
adopt the project's server-side scaling convention (raw HK reading
on the wire, server applies the ×100 at ingest), matching the
walking-steadiness path. iOS releases that still send the
pre-multiplied percent need a one-release shim and are tracked in
the iOS coord notes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 140 px slot matched the mini-chart's internal painting area but
not the mini-card shell's header + padding above it. The roughly
34 px the shell adds overflowed the slot and pushed the chart
envelope down into the TrendAnnotation row below — visible as text
sitting on top of the chart. Raise the wrapper to 180 px so the
full envelope (header + chart band) lives inside the slot and the
three tiles share one annotation baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five new payload-helper cases pin the multi-schedule encoder, the
hydration that opens on the list view, the add and remove guards,
and the schedule-id preservation on edit. A new Playwright spec
walks a two-schedule create flow end-to-end and verifies both
summary cards land before save.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A medication can now hold multiple parallel schedules, each with its
own cadence and times. Steps 5-7 of the wizard become per-schedule;
Step 8 lists every configured schedule with edit and remove actions
and surfaces a "Weiteren Zeitplan hinzufügen" card at the bottom.
The hydrator reads every schedule on edit and lands the user
directly on the list view when more than one is present.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Component tests pin the path table, the step encoding, the
treatment-class to category mapping, and the create-vs-edit prop
branching. POST /api/medications gains pin-point coverage for the
new DIABETES and ANTIBIOTIC clinical-category values. Six Playwright
specs walk the new dialog end-to-end via the German label surface,
one per cadence shape; helpers consolidate the dialog-open + step
indicator assertions so each cadence spec only carries its own
sub-control selectors. The OpenAPI yaml refreshes to surface the
two new enum values to the iOS client.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The legacy CreationWizard and the flat MedicationForm retire in
favour of MedicationWizardDialog. /medications/new keeps working
through a redirect that opens the dialog from the list page; the
"Bearbeiten" action on a card opens the same dialog with the
hydrated payload. The PhaseConfigDialog drops along with the flat
form — it was only reachable from the kebab menu and stays out of
the wizard's surface; a future release can resurface the per-phase
configuration once the wizard grows compose-mode for multi-schedule
medications.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each of the eight steps lands with its Lucide icon, title, subline
and a single focused field. The cadence radio rows carry the
plain-language description directly under each label (not in a
tooltip), and the rolling-cadence row gets the literal mental-model
line so the patient understands the counter resets on every log.
Step 2 surfaces ten taxonomy rows that each map onto a single
(treatmentClass, category) pair through a hard-coded table; Step 6
swaps its body on the cadence kind picked in Step 5; Step 7 wraps
the existing times-of-day chips primitive with a sunrise / sun /
sunset / moon preset row above it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A new MedicationWizardDialog routes through a Dialog on desktop and
a bottom Sheet on mobile via the existing ResponsiveSheet primitive,
with the wizard's own header inside the body and the footer carrying
Back / Next / Save. The dialog tracks an eight-step state machine
whose visible counter follows the path the user actually walks —
one-shot collapses to 5 steps, daily to 7, every other cadence to 8.
The pure helpers (path table, validation gate, request-body builder,
summary formatter, edit-path hydration) live alongside the dialog
shell so the next commits can plug step content in without touching
the wiring. Step content is empty in this commit; the next commits
fill the eight steps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes the 402 / 423 spare-code branch in the natural-language
extractor; the route surfaces budget exhaustion through the standard
error envelope, so the inline comment was an unused shim. The
recapCadence single-call wrapper was already retired alongside the
empty day-detail step.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step advance moves focus into the new step's first input so
keyboard and screen-reader users land where they need to type. The
cadence-picker rows now register clicks across the full 44 px row,
not just the radio glyph + label. The "describe it" trigger on
step 1 rises to 44 px so it clears the WCAG 2.5.5 floor the rest of
the codebase honours.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The flat form's oneShot switch now drives the picker's allowed kinds
directly; the picker can no longer encode a contradictory shape.
Multi-schedule medications surface a confirmation when the switch
flips on, since the existing logic drops every schedule but the
first.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The progress chip now matches the path the user actually walks (5
steps for one-shot, 6 for recurring after dropping the empty "day
detail" step). The cadence picker no longer offers a one-shot radio
once the mode picker has settled on recurring. The summary line on
the final step interpolates the actual weekdays / day-of-month /
yearly date rather than reading a category label.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lets the wizard and the edit form constrain the radio list so the
one-shot kind never appears alongside a medication-level switch
that already encodes it. The unfiltered shape is preserved for
callers that don't pass the prop.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Populates messages/{de,en}.json with native copy for every key the
new picker primitives, the wizard, the edit-form refactor, and the
plain-language summary consume. The remaining four locales ship the
English fallback for v1.5.0; native polish lands in a follow-up.
CadencePicker rolling-explainer key flipped to `kind.rollingExplainer`
so the i18n resolver can return both the rolling label and its
explainer (the original `kind.rolling.explainer` path collided with
the `kind.rolling` string leaf — JSON cannot host both shapes on the
same prefix). Adds the dose-unit SI tokens (mg / g / ml) to the
placeholder-allowlist in the locale-integrity guard since those
labels are intentionally identical across every locale.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an optional Coach-backed overlay that extracts a structured
medication payload from a free-text description so the wizard can
pre-fill its steps. The route is rate-limited and budget-gated; the
prompt carries a citation-coverage guard so the extraction cannot
hallucinate a name + dose pair the user did not write.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a step-driven create flow that composes the cadence picker, the
times-of-day chips, and the course-window row. Surfaces a placeholder
slot for the natural-language extraction overlay that lands separately.
Pure helpers (validateStep, buildCreateBody, summariseCadence) carry
the test surface; the interactive flow is covered by Playwright in a
later commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops the legacy weekday grid in favour of the cadence picker, the
times-of-day chips, and the course-window row. Pre-v1.5 medications
read-fall-back through inferCadenceFromLegacy; writes go to both
shapes so the v1.5.x dual-write window stays intact.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three components plus a shared `types.ts` module under
`src/components/medications/scheduling/`. The wizard + the form
refactor consume them; the picker emits a `CadenceValue`
discriminated union that the form maps onto the API request body.
### CadencePicker
A grouped flat radio list with 8 cadence kinds:
- Every day → `FREQ=DAILY`
- Certain weekdays (chip-toggle row) → `FREQ=WEEKLY;BYDAY=...`
- Every N weeks on certain days → `FREQ=WEEKLY;INTERVAL=N;BYDAY=...`
- Monthly on day → `FREQ=MONTHLY;BYMONTHDAY=N`
- Every N months on day → `FREQ=MONTHLY;INTERVAL=N;BYMONTHDAY=M`
- Yearly on date → `FREQ=YEARLY;BYMONTH=M;BYMONTHDAY=D`
- Rolling N days from last intake → `rollingIntervalDays: N`
- One-time dose → `oneShot: true`
The "Every N days flexible" option carries an explainer line so
the GLP-1 patient understands the rolling semantic. Defaults are
chosen so the encoded RRULE is always parseable (weekday set
defaults to Monday, yearly clamps to Jan 1) — the wizard / form
gates "continue" until the user picks the real day.
Sub-controls state is owned by the parent (the picker accepts
`subControls` + emits via the second `onChange` arg) so a wizard
that re-enters step 3 sees the user's previous picks.
### TimesOfDayChips
Multi-chip HH:mm input with morning / noon / evening / night
presets. Dedupes + sorts. `maxChips` prop honours the Zod cap of
8; when the parent passes `maxChips: 1` (one-shot context) the
component renders a single time picker instead of a chip row.
### CourseWindowRow
Two date pickers (`startsOn` + `endsOn`) with a "No end date"
toggle. `lockEndsToStart` prop forces the one-shot case where
both pickers share a date. Validation message rendered below the
row when `endsOn < startsOn`.
### Quality
- "use client" on every component file.
- shadcn/ui-only — no new deps, no shadcn Calendar primitive in
this repo so date pickers fall back to `<Input type="date">`.
- WCAG 2.5.5 tap targets (44 px) across every interactive
element; focus rings, aria-labels, aria-pressed on toggles,
role="group" on chip rows.
- 49 tests across the three components (encoder mapping, sort /
dedupe / preset toggle, range validation, SSR-only render
smoke tests). Pure helpers exported so the contract is pinned
at the unit-test level; user-event paths land in Playwright
e2e per the synthesis quality bar.
i18n keys listed at the top of each component file under
`medications.scheduling.{cadence,timesOfDay,courseWindow}.*`. The
follow-up i18n agent populates DE + EN.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Marc reported the BD (Sys) tile rendering `13… mmHg` — the
systolic / diastolic pair `131/85` outgrew the `text-3xl` footprint on
the narrowest grid columns and the value-row's `truncate` ellipsis
chopped the most important part of the tile (the number itself).
The other tiles in the strip (weight, pulse, glucose, …) carry a
single value that never threatens the column-width budget on its own,
so they keep `text-3xl`. Only paired-metric tiles step down to
`text-2xl`; `131/85 mmHg` at `text-2xl tabular-nums tracking-tight` is
~84 px wide vs ~140 px for the narrowest 2-column grid cell — fits
with the unit and trend arrow at every viewport. `truncate` stays as
a defence-in-depth safety net for outlier values.
`cn()` arg ordering tightened so `tailwind-merge` keeps the explicit
`leading-none`. Tailwind v4's `text-3xl` / `text-2xl` carry their own
default `line-height`, and the last-wins dedup rule otherwise dropped
`leading-none` and broke the W20a across-tile baseline alignment that
`trend-card-baseline-alignment.test.tsx` pins.
Also fixes a pre-existing date-boundary test flake:
`src/app/api/medications/intake/__tests__/route.test.ts`'s
`reads the rollup tier when coverage is present` computed `todayKey`
from UTC components, but the route's `readMedicationCompliance` uses
the user's timezone (Europe/Berlin in the test fixture). At every
22:00 — 24:00 UTC window — once the Berlin clock crossed midnight —
the two computations diverged by one calendar day, the mock returned
data for the wrong day, and `today.scheduled` came back zero instead
of three. The test now derives `todayKey` from the same Berlin zone
the route uses.
Tests: 5270 unit + integration pass, lint clean, typecheck clean.
Bundles the deferred v1.4.47 Mediums + Lows with the v1.4.48-deferred items
into a single release. Headline: server-side suppression of MEDICATION_REMINDER
APNs for iOS clients that own their local reminders (closes GH #206), a
push_attempts table backing /api/admin/notifications/diagnostic, and a sweep
of v1.4.48 QA forward-findings.
Performance (the user-visible win):
- /api/analytics cold-fallback path splits the heavy single-statement
aggregate into two parallel queries. The linearly composable columns
(count / min / max / mean) keep the full-partition scan; the windowed
7 / 30 / 90-day avg / slope / r² columns take a 90-day outer cap so
the planner does an index range scan on (user_id, type, measured_at)
instead of a full-partition sequential scan. Output is bit-identical;
cold slim slice drops from multi-second to sub-second on tenants with
large measurement partitions.
Server-side notifications:
- GET + PATCH /api/auth/me/notification-prefs with deep-merge semantics.
Initial category medication.clientManaged: boolean (default false). Cron
medicationReminderJob skips dose-due APNs when the flag is true,
emitting a medication_reminder.suppressed_client_managed wide-event
annotation per skip.
- push_attempts table backs the diagnostic endpoint. Every APNS /
Web-Push / Telegram / NTFY sender writes a fire-and-forget row.
Daily 03:35 Europe/Berlin cleanup prunes rows older than 90 days.
- /api/admin/notifications/diagnostic — admin-only endpoint surfacing
device APNs token presence (masked), channel config presence, and
recent push attempts. Registered in OpenAPI.
- Per-channel APNs "Send test" button now fires with
eventType: "MEDICATION_REMINDER" so the dispatcher's time-sensitive +
priority-10 branch runs. A successful self-test now exercises the
exact path production reminders take; an unsuccessful one points the
user at iOS Settings → Time Sensitive Notifications rather than
appearing to be a server-side bug.
Withings reliability:
- OAuth callback consumes the nonce atomically (delete-first with
P2025 catch for the replay branch). The previous read-then-delete
pattern allowed a race between two concurrent callbacks.
- ?reason=state redirect differentiated into csrf1 / replay / expired /
cross_user so operators can tell mismatch types apart without DB
shell access.
- connect endpoint rate-limited to 10/60s/user; row-create failures
redirect to a consistent error surface.
Observability hardening:
- redactSensitiveFields(body) denylist helper covers password / token /
secret / apiKey / authorization / csrfState / nonce, recursive,
case-insensitive. Caught: /api/devices invalid_format issues were
echoing apnsToken values verbatim into the audit log.
- sanitiseZodIssues(issues, { stripValuesFromMessage: true }) overload
applied to 14 audit-log sites.
Migrations: 0079 (notification_prefs), 0080 (push_attempts).
Tests: 4815 → 5272 unit + integration. Workout-batch integration suite
that had been red on main since v1.4.45 (three release commits shipped
through it) now green again — updated to match v1.4.42 cross-source
dedup semantics.
Resolves PR #207.
iOS-operator-blocking hotfix for REG-11 — Home dashboard tile renders
neither chart nor latest value for BP / Puls / Körperfett when the
most recent reading is older than 7 days. Root cause was in
`/api/dashboard/summary` SQL gates; `latestIn7d` + `sparkBuckets` both
required `measured_at >= sevenDaysAgo`, which returned empty for
sparse accounts, leaving the iOS tile with `latestValue: null` +
`sparkline: []` to render from. Detail-screen
(`/api/measurements/series?kind=…`) escaped because of its broader
window.
Server-side changes:
- `latestIn7d` → `latestEver` (drops 7-day filter; DISTINCT ON returns
latest reading EVER per type, bounded at N_metrics rows).
- `sparkBuckets` rewritten via ROW_NUMBER() OVER (PARTITION BY type
ORDER BY bucket_start DESC) so the trailing N daily buckets per
type render regardless of age, bounded at SPARK_DAYS × N_metrics.
- BP + Pulse metrics.push gated on `latest || allTimeCount > 0` like
BodyFat already is — accounts with zero readings ever no longer get
empty placeholder tiles.
- 4 new regression tests in summary/__tests__/route.test.ts cover
sparse accounts, zero-data accounts, within-7d guards.
Other develop work bundled in this release:
- W4-QoL — copy nits.
- W5-WORKOUTS — user source priority threaded through write-time
picker + batch route.
- W7-WITHINGS-RELIABILITY — response classifier + sync hardening.
- W8-OPS — ops phase docs.
- B11 — NEXT_PUBLIC_APP_VERSION build-arg routing so /api/version
cannot drift from CI tag.
- env-check workflow — manifest / example lockstep enforcement.
Resolves PR #194 (squash-merge produced via local -X theirs strategy
because GitHub's UI couldn't auto-resolve develop's post-v1.4.42
contiguous work against the v1.4.42 squash on main; resulting tree is
byte-identical to develop's HEAD).