The Health Score weight pillar was gated on a derived weight target, and
the only target source is the BMI-22 fallback from the profile height. A
profile with no height yields a null target, so the pillar came back
empty ({value:null, weight:0, source:"none"}) however much weight data
the user had logged — the weight trend never factored into the score.
Decouple the pillar from requiring a target: when no target is derivable
but the series has at least two readings, score the bare weight trend on
its own. A flat trend anchors at 75 (maintenance is a solid result), a
decline lifts toward 100, and a sustained rise drops toward 25 — the one
direction that is broadly adverse independent of any individual goal. The
target-aware scoring is unchanged when a height is set. No fabricated
target: the trend is scored honestly, never against a pretend goal.
The series.length < 2 null guard stays — genuinely too little data still
returns null.
The card's next dose did not move on after a dose was logged: the server's
next-due and the open-cycle descriptor both picked the next slot purely by
clock time, ignoring whether that slot was already taken, skipped, or missed.
For a twice-daily med whose remaining slots today were all logged, the card
kept surfacing a present/past resolved slot instead of advancing to the next
genuinely-open one.
Skip resolved slots in both paths: computeNextDueAt and buildCurrentCycle now
walk occurrences forward and return the first slot no intake has resolved
(taken / skipped / auto-missed), so a med whose slots today are all logged
advances to tomorrow's first open slot. The list and detail routes read the
acted-on slots near now and thread them in; both walks are bounded so a
logged-ahead history cannot spin. The card's compliance/next-dose queries
already ride the ["medications"] invalidation prefix, so logging a dose
refreshes the card promptly once the server value advances.
The dose-to-slot pairing matched a logged intake against the slot's window
*centre*. For a wide intraday window (a twice-daily 07:00–19:00 med spans 12h,
so its 07:00 slot centres at 13:00 and its 19:00 slot at 01:00 the next day)
the centre sits between the real dose times, so an off-time take paired to the
wrong slot: a morning dose logged late could land on the evening slot, a
deliberate user-skip could read as taken, and a late evening take could read
as missed — distorting the compliance rate for an irregular twice-daily taker.
The intake write path already snaps each dose's scheduledFor to its canonical
slot instant, which identifies the slot unambiguously. Add an exact-anchor
pass that claims the slot whose windowStart matches the intake's scheduledFor
(±1 min) before the proximity fallback. Rows that don't line up on a slot
instant (legacy un-snapped data, sparse off-day takes) keep the existing
proximity match, so every prior cadence fixture is byte-stable.
A next-dose / status line whose window start equals its end rendered a
degenerate range — "07:00 bis 07:00 Uhr" (DE) / "07:00 – 07:00" (EN) — for a
single target time or a zero-span window. Reserve the "bis" / "–" separator
for a genuine range and render one time when start == end (the DE branch
keeps its "Uhr" suffix on the single time).
Render the user's profile photo in the mobile header user button when
one is set, falling back to the initials avatar otherwise — matching
the sidebar user section. Also surface the admin-console link in the
header user menu for admins, mirroring the desktop sidebar entry.
With no stored preference the app now resolves to dark instead of
tracking the OS. An explicit choice still wins: light/dark persist as
before, and "system" is now persisted verbatim rather than as a
cleared key, so an explicit OS-tracking choice stays distinguishable
from a never-chosen state and survives a reload. The first-paint
bootstrap script mirrors the same resolution so a fresh visitor sees
no flash to light.
Shorten the notification-area label and inbox title from the longer
"Notification Center" form to plain "Notifications" across all six
locales (nav entry, page title, breadcrumb). Refresh the stale inbox
references in the settings-shell and sections tests.
- Cycle ring: the fixed 120 px shrink-0 ring overflowed grid cells on
360 px Android widths. It now caps at its target size but is free to
shrink to fill its cell, so it never pushes the page wider than the
viewport.
- Cycle tab strip: the four long German labels overflowed the equal-width
flex-1 cells and forced a horizontal scroll at 375 px. Below sm the
triggers size to their content (text-xs, no flex-1) and scroll cleanly;
from sm up they reclaim the equal-width layout.
- Calendar day cells: bump the 40 px min-height to 44 px to clear the
mobile tap-target floor.
- Add a Pixel-5 spec asserting no horizontal scroll on /insights and
/cycle plus the calendar tap-target floor.
The cycle view also now feeds the profile's typical lengths into the
wheel so a low-data tracker gets the canonical four-phase ring.
When only a few cycle days are logged, the labelled forward run was short
enough that normalising it filled the whole ring with a single phase. For
a tracker below seven labelled days, derive the four-phase proportions
from the profile's typical lengths instead (period / luteal / cycle
length, textbook 28/5/14 defaults), so the dial shows the familiar
MENSTRUAL/FOLLICULAR/OVULATORY/LUTEAL arcs. The current phase and the
day-of-cycle marker stay driven by the observed labels.
The ring tile reads the shared, 5-min-cached cycle profile to source the
typical lengths (no extra request). Well-populated cycles keep the
observed-share spans unchanged.
The overview is a vertical stack on a single space-y-6 rhythm, but two
seams read wrong: Trends to period-in-review felt too wide while the
cycle summary to signals-of-the-day seam had no gap at all. The missing
gap traced to the cycle-summary card wrapping itself in a display:contents
box, which collapsed its margin so space-y skipped it.
- Give the cycle summary a real section wrapper (heading above the card,
matching every other section) so the inter-section margin applies.
- Document space-y-6 as the single gap, top to bottom; every section is
a real section or renders null, never a contents wrapper that strands
a phantom margin.
Every overview section now leads with a single SectionHeading above its
card: a Lucide icon in the foreground colour and an h2 title at one size
and weight. Previously the overview mixed four heading dialects — a
purple Sparkles glyph on the briefing, no icon on Trends, a white-icon
tile header on the score and vitals strips, and in-card titles buried
inside the period-in-review, cycle-summary and signals cards.
- Add SectionHeading (icon + title + optional trailing action).
- Briefing: purple icon to white; heading moves through SectionHeading.
- Trends: add a TrendingUp icon (was icon-less); subtitle becomes the
trailing action.
- Health scores + vitals: migrate the section headers off TileHeader.
- Period in review, signals of the day, device notifications: lift the
in-card title out to a heading above the card.
- Signals card min-height drops to match its slimmer body now that the
title row left the card.
Remove the per-row dose count (the '· 12 / 12' caption) from the
compliance bars — the percentage, bar and window-days label remain. The
auto-miss engine now makes the two windows' percentages genuinely
diverge, so the counts no longer earn the row noise; the engine still
carries the counts on the wire for other consumers.
Remove the injection-site display from the overview card, including the
last-injection-site shown on the last-intake line. Site tracking and
logging are unchanged elsewhere (the post-dose picker, the detail
history); only the card surface drops it.
Extract a single MedicationCardBody that owns the entire card shell,
header, status line, next/last slot, compliance bars, cycle line and
action row. Both the generic and the GLP-1 card render it and pass only
the value content of their next/last lines, so structure, section order,
spacing tokens and labels are identical by construction and cannot drift
again. This also pins the two cards to the same CardContent spacing
(flex h-full flex-col space-y-3.5) and the same mt-auto action wrapper,
removing the bottom-spacing mismatch between the two cards.
Drive the card's state from the new dose status: highlight the card green
when the dose is in its take-window, and show a top status line that
escalates from 'overdue' to 'very overdue' as a dose passes its miss
cutoff. Upcoming and taken states stay calm.
Derive a single DoseStatus for the open cycle in buildComplianceDisplay
and surface it as complianceDisplay.currentDose, so the medication card
renders its take-window / overdue presentation from one server authority
instead of re-spelling the window math client-side. Add the field to the
OpenAPI ComplianceDisplay schema and regenerate the spec.
The dashboard compliance tile set scheduled = COUNT(*) of logged intake
rows, so its rate (taken / scheduled) sat at ~100% across every window
regardless of real adherence — every logged row was both numerator and
denominator, and the 7/30/90 windows never diverged.
Anchor scheduled to the canonical recurrence engine's expected-dose count
per day, summed across active medications, so days the schedule expected a
dose the user missed pull the rate down and the windows genuinely diverge
with partial adherence. taken is capped per day at the expected count so a
duplicate log can't push a day above 100%. The response shape stays
{date, scheduled, taken} for the existing chart consumer.
Regenerate the OpenAPI contract for the new expected/missed count fields.
Thread the auto_missed flag through the cadence timeline so a forgotten
dose pairs to a missed slot (against the rate) rather than a neutral skip;
a deliberate user skip stays excluded. This unifies the skip semantics
across every compliance surface: user skip = excluded, forgotten = missed.
Add a per-dose grace/miss window model the card will render:
deriveDoseStatus derives on_time_window / overdue / missed / taken_on_time
/ taken_late / skipped / upcoming from the target instant, cadence family,
and now. Daily/intraday uses minute-scale windows (on-time +/-60min,
overdue to +240min, capped at the next dose); weekly/rolling uses the
clinical 4-day rule (on-time +/-1 day, overdue to +4 days). Defaults are
centralised named constants for later per-medication configurability.
Surface taken / expected / missed counts on each compliance-display row so
the card can show the trustworthy 'taken / expected . rate%' triple, and
pin a single now through the per-medication compliance route so the two
windows can't straddle a day boundary on a slow request.
A never-acted dose past its 24h cutoff was flipped to skipped=true by the
hourly cron. The compliance engine excludes skipped doses from the
denominator (a deliberate user pause), so a forgotten dose then counted
neither as taken nor missed — silently inflating adherence.
Add a dedicated auto_missed terminal state. The cron now sets auto_missed
(not skipped) on a never-acted pending row, so the engine counts it as a
real miss against the rate, while a user-initiated skip stays excluded.
The two are mutually exclusive on a live row by construction.
The hand-rolled multipart parser consumed the opening boundary marker and
then inspected the next two bytes inline to classify the suffix ("\r\n" for
another part, "--" for the closing boundary). When a stream chunk ended
exactly on that marker, fewer than two bytes were left in the buffer, so the
branch returned while still in the "preamble" state. The next read re-entered
preamble, searched for the marker again — which was already consumed — and
treated the part headers and the entire file body as preamble to discard,
matching only at the closing boundary. The captured file landed on disk
truncated or empty.
A tiny single-chunk upload never tripped it; a multi-megabyte binary arrives
in hundreds of chunks, so a chunk boundary routinely fell in the dead window
and the saved file came up short. The Apple Health export ingest then failed
its ZIP read with "Could not locate ZIP End-Of-Central-Directory record".
Add a dedicated "after-boundary" parser state that buffers until the two-byte
suffix is present instead of falling back to a marker search, route the
post-body separator through the same state, guard the epilogue state against
the body scanner, and tolerate RFC 2046 transport padding after the marker.
The preamble retain window now keeps boundaryMarker.length - 1 bytes so a
marker split across chunks can still complete. Streaming and the size cap are
unchanged — no part of the upload is buffered whole.
Cover the regression with byte-exact round-trip tests over adversarial
chunkings (1/3/7-byte feeds, chunks ending on the marker and inside the
separator, every single and adjacent split offset) plus an end-to-end check
that a streamed export.zip stays a parseable archive.
Two identical compliance percentages with no context read as a stuck
display, even when the math is correct (a rolling weekly med on cadence is
~100% on every window by construction; a daily med with full adherence
rounds to ~100% on both 7d and 30d). Render the taken-of-expected count
after each rate (e.g. 100% with 4 / 4 and 100% with 52 / 52) so the two rows
stay distinguishable and trustworthy. Falls back to the bare expected count
when only the denominator is on the wire, and to nothing when neither count
is available.
Thread a per-window taken count onto ComplianceDisplay (short.taken /
long.taken) alongside the existing expectedShort / expectedLong, wire both
through to the shared bars component, and regen the OpenAPI schema.
Add unit tests asserting the windows do not structurally collapse to one
value: a daily med with misses outside the 7-day window scores 7d above 30d,
and a rolling weekly med with a partial-adherence history yields a short rate
above the long rate. Add card-part tests for the count / fallback / no-count
render branches.
The GLP-1 card overrode the shared next/last slot with appointment-phrased
labels (Next / Last, "Nächster Termin" / "Letzter Termin") while the generic
card used Next intake / Last intake. Drop the override so both cards name the
concept identically; the GLP-1 relative-day value phrasing (in N days / today)
is unchanged. Remove the now-dead label override props from the shared slot
and the orphaned glp1NextLabel / glp1LastLabel strings from all locales.
The v1.8.2 slot-collapse cases mint a 07:00 pending slot and replay a
taken-write onto it, but a taken-write must never snap onto a future
slot — so the cases failed whenever the suite ran before 07:00 local.
Pin Date (only) to today's local noon for those cases and give the
shared test session a long expiry so it survives the time jump.
Add docs/integrations/data-import.md covering the Apple Health
export.zip path, the generic JSON import schema with the full
MeasurementType enum and canonical units, a worked example, and a
CSV-to-JSON conversion how-to. Fix the Apple Health doc to point at
Settings -> Export & Import instead of a non-existent import page. Extend
the export e2e spec with the import surfaces and the example-download
happy path.
Relabel the Export section to Export & Import (heading + description,
all six locales; slug unchanged so existing links keep working) and
mount the import area in it. Add a cycle export tile that reuses the
health-record FHIR export with the reproductive section opted in, gated
on cycle tracking being enabled so a non-cycle account never sees it.
Compress the health-record export hero — format and date range share a
row, the practice-name field tightens, and the FHIR note rides the
footer with the generate action — so the card opens denser without
losing any controls. Add the import + cycle i18n keys across all
locales.
The Apple Health export.zip and generic JSON import routes shipped
without a web surface (issue #281). Add an import area with two
controls: an Apple Health export.zip drop / picker that streams the
upload and polls the job status to completion, and a generic JSON
control that takes a pasted body or an uploaded .json file, validates it
client-side, and reports the imported / skipped counts. A Download
example button mints a valid example payload as a Blob. Both controls
surface rate-limit and error states and announce progress via aria-live.
Adds the importJobStatus query key to the factory.
Drop the obsolete doctor-report dialog spec (the interactive export-page
dialog was removed; the report now lives under the health-record export),
remove the doctor-report testid from the export smoke, and update the
integrations layout spec to three cards after the moodLog removal.
Both select primitives parked the trailing chevron visibly farther from
the right edge than the value text sits from the left. The shadcn trigger
gets a -2 px optical nudge to offset the chevron glyph's internal
padding. The native select drops the OS-drawn arrow (`appearance-none`)
and paints a chevron background at a 12 px right inset mirroring the left
`px-3` padding, kept on the single `<select>` element so call-site width
and margin classes still compose unchanged.
The open-cycle status line repeated the upcoming-dose timing already
shown in the card's next-intake slot; the calm on-track "next dose in N
days" phrasing is now suppressed, leaving only the actionable due /
overdue tiers.
Remove the "recommended next site" nudge from the GLP-1 card — it is
noise on the at-a-glance surface. Injection-site tracking stays: the last
site still reads on the last-injection line and the post-dose picker
still captures the next site.
Make "Period in review" the period-narrative card heading (a proper
title, not a faint uppercase label) and drop the possessive across all
locales.
Render the today-signal card only when there is an actual signal: an
all-clear day (every vital inside its personal range) or an account still
building its baselines now produces no card, rather than an empty header.
The single-status-display contract now expects three integration cards
(Withings, WHOOP, Google Health) instead of four after the moodLog card
removal.
Clicking the avatar (and the name beside it) in the sidebar user menu
now routes straight to the account settings. On the per-medication API
token row, drop the separate "Copy URL" button in favour of an inline
read-only endpoint field with a copy icon, and switch the generate-token
button to the standard outline variant so it no longer renders as the
lone primary-coloured control on the row.
Right-align the health-record export generate button. Remove the
duplicate doctor-report card from the export page (the doctor report is
reached through the health-record export now) and drop the now-unused
ArztberichtHeroCard. Remove the clinician share-link ("sharing") page,
its nav entry, slug, and section mapping; the backing model, routes, and
public clinician view stay in place for a later dedicated removal.
Remove the moodLog card, its render, and the moodLog-only UI plumbing
from the integrations settings — the integration is no longer offered.
The backing columns, routes, and status aggregator entry stay in place
for a later dedicated removal. Also shorten the integration save button
from "Save credentials" to "Save" across all locales, and add the
nav account-settings label.
The notifications settings panel white-screened with "x.map is not a
function" when a status payload reached the card as the whole
{ channels, events } object instead of the channels array (events is an
object map, not an array). Guard the channel map with an Array.isArray
check, default the mood reminder-hour read to optional chaining, and
add a regression test that renders the empty state instead of crashing
on a malformed payload.
When cycle tracking is on, the cycle ring takes the Strain slot in the
health-scores strip rather than adding a sixth tile: the wellness strip
gains a hideStrain flag the insights page sets from the cycle gate, so
Strain is hidden for cycle accounts and shown unchanged for everyone else.
Fix the oversized vertical gap on the insights overview. The cycle summary
card set data-revealed on the same node that carried wellness-tile-rise,
but the entrance keyframe selector is a descendant combinator, so the
animation never matched and the card stayed at the rise rule's opacity: 0 —
invisible yet still occupying its box. Wrap the card so data-revealed sits
on an ancestor, restoring the entrance and removing the phantom gap.
Move the gated cycle entry directly after Medications (before Insights) in
the sidebar nav instead of appending it at the end, spliced by the
Medications index so the position survives a list reorder.
Add an operator-only password-reset CLI. It runs under plain node in the
production standalone image (which strips tsx) via the in-image pg and
@node-rs/argon2, reusing the app's Argon2id parameters through a shared
.mjs module so a reset hash matches one the app would mint. It matches a
single user by username or email, reads the new password without echoing,
never prints it back, and exits non-zero on zero or multiple matches.
Documented in docs/ops/password-reset.md.