Three controls carry most of the app's mutation surface between them. The row
delete is spelled once for measurements, lab results, biomarkers, allergies,
family history, custom metrics and medication supply; the selection bar is
the only bulk delete; the capture picker is the fastest path to four entry
forms on a phone and bypasses every page header.
Deleting belongs to the owner at every grant level, so the first two are
absent inside somebody else's record rather than greyed out: a disabled bin
still claims the row is the delegate's to remove. The picker keeps the two
kinds a delegation admits and drops a mood entry and a glass of water, which
the server refuses under a switch; when nothing survives, the bar's plus
button goes with them instead of opening an empty sheet.
Switching is three acts in a fixed order, and the order is the safety
argument. Ask the server, so a refused switch leaves the browser where it was.
Empty the caches a reload cannot reach, awaited rather than fired, because a
wipe racing the navigation is a wipe that sometimes does not happen. Then
reload the document, which is what discards the in-memory cache, the rendered
React trees, and every request already in flight against the old record.
The banner is the feature, not decoration. Someone who forgets they are
switched will log their own blood pressure into another person's record, and
there is no undo for a reading that was never theirs. So it states whose
record is open, that it is read-only, and carries the way out — in the one
visual register reserved for "something about this screen is not normal", at
the top of the shell above every other strip. A subtle tint is what people
stop seeing on the third switch.
The switcher lives in one component mounted in both user menus. A way in on
the laptop and no way out on the phone is how the two menus have drifted
before.
Nav entries that sharing does not cover drop out, and a page reached by
bookmark says so instead of painting a row of refusals. Both are paint: the
server refuses those routes on its own, from a frozen list this code cannot
reach. Settings, connected services, notifications and the AI surfaces are the
account around a record rather than the record itself.
A grant can end while a browser is inside the record. Revocation clears the
delegate's sessions in the same transaction; expiry does not, and leaves a
session stamped with an account it may no longer act on — correct refusals
under a screen that lies. One cache subscriber notices the stable errorCode
and leaves, rather than asking two hundred call sites to remember.
The resolved per-user module map rides the client-only `/api/auth/me`
query, so it is still undefined on SSR and the first client paint. The
nav model's module gate fails open (`modules?.[key] !== false`), so every
module-gated entry — cycle, mood, labs, coach, insights … — rendered for
one frame before the query resolved and filtered it out. A user without a
module saw its entry flash into the sidebar and the mobile bar, then
vanish.
Gate the module-dependent filtering behind `useMounted()` in both bars.
SSR and the first client paint now render only the core (non-module-gated)
destinations; the real module map applies once mounted. Before mount a
gated entry is treated as hidden — fail-closed — so SSR and first paint are
identical (no hydration mismatch) and a disabled module can never flicker
in. The pure nav-model helpers keep their fail-open default for callers
that pass a settled map; the bars thread the mounted flag in.
Rework the sidebar and bottom-nav SSR tests to assert the fail-closed
first-paint contract (core-only, gated entries absent regardless of the
map); the resolved post-mount set stays covered by the pure-helper tests
in nav-model.test.ts and by e2e.
Drop the in-app bug-report surface end to end: the user submission page,
the feedback intake and admin inbox, the optional GitHub-issue
escalation, the operator config, and the dedicated achievement badges.
- delete the /bugreport page and the bugreport / feedback / admin-feedback API routes
- drop the Feedback model, its enums, and the AppSettings GitHub-issue and enable columns (migration 0205)
- remove the admin Feedback section, the Bug Report integration card, and the system-status row
- drop the bug-report nav entry and the app-settings provider that only carried its flag
- remove the bug-hunter achievement badges and their metric
- prune the related i18n keys across every locale and the encrypted-column and key-rotation registries
The long single-word labels (de "Benachrichtigungen") clipped their last
character at the tile edge on a 320 px phone. Trim the grid gutters and the
tile padding/gap to hand each label more horizontal room, and cap the bottom
sheet at max-w-md, so the label wraps cleanly onto a second line instead of
running off the tile. Tile height stays uniform.
Size the full-page Coach surface to clear the mobile BottomNav band plus the
iOS safe area so the docked composer and Stop control never sit under the nav;
desktop full-bleed height is unchanged (the nav is mobile-only).
Stop truncating the More-hub labels: shrink the label type and allow a
two-line clamp so long words (German Benachrichtigungen, and the long es/fr/it
labels) read in full instead of ellipsising.
Raise the lab reading-history edit/delete controls to 44px on touch
(compact on desktop), and drop the lab-detail Add button to icon-only on the
narrowest phones.
Trim the longest label ('Benachrichtigungen') with truncate (nowrap +
ellipsis) instead of break-words so every More-hub tile reads on a
single line on a phone, matching its neighbours. The full label stays
discoverable via the title attribute; the desktop layout is untouched.
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
Mark the top-level `/insights` nav destination `requiresModule: "insights"`
so it drops from both bars when the account has the insights module off,
mirroring mood / labs / coach / achievements. The bottom-nav pinned
`/insights` as a fixed primary slot regardless of the module map; it now
filters that slot through the same per-user map the More hub honours
(fail-open on a missing key / unloaded /me payload). A disabled insights
module is hidden everywhere — the primary-slot filter keeps it out of the
More hub too, so it is hidden, not relocated.
Extend the nav-model + bottom-nav tests with insights on/off coverage.
Generalise the per-destination cycle gate into a requiresModule marker
on each nav destination and read it from the account's resolved module
map (the modules field on GET /api/auth/me). Fold the bespoke cycle
boolean into requiresModule: "cycle" so there is one gating mechanism;
cycle and coach are delegated keys already reflected in the map, so
reading them here is not a re-derivation.
Tag mood, cycle, labs, coach and achievements with their module keys;
core destinations (measurements, medications, vorsorge, insights, the
dashboard) carry none and always render. The sidebar, the mobile More
hub, and the Insights tab strip all filter on the same map.
Insights pills: a metric pill belonging to a toggleable module (mood,
sleep, glucose, workouts) hides when its module is disabled, on top of
the existing data + saved-layout gates; the Recovery pill gates on the
recovery module. Overview and core-metric pills always show. Every gate
fails open — a missing key or an absent map keeps the entry, matching
the gate's default-on contract, so a stale /me payload never blanks the
nav.
The mobile "More" overflow sheet renders a two-column grid of icon-plus-label
tiles. The label span carried min-w-0 but no break handling, so a long
single-word label such as the German "Benachrichtigungen" — which has no
natural break opportunity — pushed past its grid track and overflowed the
tile box.
Add break-words to the label so an unbroken long word breaks inside the tile;
combined with the existing min-w-0 shrink and the auto-height tile (min-h-14),
the second line is absorbed cleanly. The fix is layout-level, so every label
in all six locales fits without special-casing the longest one.
Vorsorge (preventive-care) was a top-level tracking surface reachable only
three taps deep through Settings → Reminders, while peer surfaces (Labs,
Recovery) already had a top-level nav home. Add it to the one shared
destination model in the clinical spine, so it renders on the desktop
sidebar and the mobile More hub like every other feature, with a
nav.vorsorge key across all six locales.
Bug Report, Settings and Notifications were still a second hand-curated
list on each bar — the exact drift the one-model contract set out to kill,
just pushed down a level. Promote them to a shared NAV_UTILITY_DESTINATIONS
list both bars consume: the sidebar footer + avatar menu and the bottom-nav
More-hub tail now read from one source and can no longer disagree. The
More hub itself is now computed by mobileMoreHubDestinations() so the
headline invariant (hub == feature list minus primary slots, plus the
utility tail) is a tested model function rather than inline bar logic.
Add a sidebar utility-parity guard and a mobile More-hub invariant test.
The desktop sidebar and the mobile bottom-nav each hand-curated their
own destination list, and the two had drifted: the sidebar listed
Measurements and Mood inline but hid Workouts; the bottom bar buried
Measurements and Mood in More but promoted Workouts. A user who learned
the product on one platform re-learned it on the other.
Extract one ordered destination model both bars consume. The sidebar
renders every entry in order; the bottom bar keeps its 5-slot shape and
derives its More hub from the same list, so the two surfaces tell one
story. Workouts is now first-class on both. A shared active-resolver
prefers the most-specific sibling so a sub-route no longer lights up its
parent.
A dedicated /labs page hosts manual entry and a per-analyte view: the
four essentials (biomarker, value, unit, date) stay visible, the
reference range, panel grouping and note sit under an optional section.
Results group by analyte with a small inline trend once an analyte has
two or more readings. The reference-range badge is deliberately neutral
and calm — an out-of-range value reads as a quiet directional marker, not
an alarming red.
Adds the labs section toggle to the health-record export panel, the /labs
nav entries, the /api/labs OpenAPI routes, and the labs copy across all
six locales.
The More hub grid tile label was a flex child without min-w-0, so
long compound labels (e.g. "Benachrichtigungen") painted past the
tile's rounded border instead of wrapping. Give the span min-w-0
and tightened leading so it wraps cleanly within the tile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The insights read path served a cold cache by generating inline — up to
ten seconds on first visit — and the nightly warm pass structurally
excluded evening visitors: their visit stamped the cache, the half-four
run skipped it as fresh, and it expired exactly at the next evening's
visit. Stale content now serves immediately with its timestamp while
one background job revalidates, the warm threshold drops to an hour,
forced warming fills both message locales, the analytics caches serve
stale-while-revalidate, the profile endpoint parallelises its reads,
the bug-report status drops a write per call, the umami proxy degrades
to a no-op script instead of an error, the insights route sheds its
heaviest static imports, and the medication page prefetches on intent.
The Withings OAuth callback finally reports its outcome — connected or
a readable reason — instead of dropping the user on the settings page
without a word. The adherence number carries one name everywhere and an
explanation right where it is shown. Demo mode announces itself with a
banner instead of failing silently in English. Bug reports become
reachable from the mobile menu, the about section returns to the
settings navigation, charts link to the target settings they draw,
integrations and source priority point at each other, and the
medication wizard warns when reminders are switched on without a
configured channel. The longest explainer texts shrink to two
sentences.
Build the web UI for the cycle-tracking category against the existing
`/api/cycle/*` routes: a month calendar + circular phase wheel, a fast
log-day sheet, a predictions panel, phase-insight cards, and a single-page
settings form, all under a gated `/cycle` tab.
- Calendar + wheel: a hand-rolled SVG phase ring (0 KB runtime, matching the
wellness-ring aesthetic with calm per-phase hues) showing the current
phase + day-of-cycle, alongside a month grid where logged period days show
a filled pip, the predicted next period renders as a ranged band (never a
single date), the fertile window shows only when the goal allows it
(the API suppresses it server-side otherwise), and ovulation + per-day
symptoms carry markers. Reads `GET /api/cycle/calendar`.
- Log-day sheet: one-tap period start, flow / symptom / ovulation-test /
mucus chips, basal-temperature, intercourse + protection, and a free-text
note. Optimistic write to the day-log + period routes; invalidates the
cycle query keys.
- Predictions panel: next-period range with a confidence pill, goal-gated
fertile window, a still-learning state for fewer than three cycles or
raw-chart mode, cycle-history stats, and the fixed non-medical disclaimer
(reusing the drug-level-estimate disclaimer pattern).
- Insights cards render whatever phase-correlation cards the API surfaces,
with a graceful empty-state until the feed lands.
- Settings: a single dynamic form where the inclusive goal selector drives
the copy (conception framing only for trying-to-conceive), plus typical-
length priors, raw-chart mode, sensitive-category encryption, discreet
notifications, and export / delete. PATCHes `/api/auth/me/cycle-prefs`.
- Nav: a Cycle entry in the sidebar and the mobile More hub, gated on
`cycleTrackingEnabled` from `/api/auth/me` and hidden otherwise.
Add the cycle query keys + dependent-key bundle to the factory, full copy
across all six locales, and dedicated per-theme phase hues. Plain
second-person voice, WCAG 2.1 AA, no new dependencies.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lift the bottom-bar center capture action into a proper floating-action
button: a 56px target raised out of the bar with a card collar ring and
a stronger shadow, so it reads as the primary CTA rather than a fifth
flush tab. Desktop sidebar nav is unaffected.
Unify the selectable controls across the mood logging surface onto one
design language: the 5 mood faces, the tag tiles, the rated-factor steps
and the GLP-1 chips now share a single selected-state treatment
(border-primary, bg-primary/15, primary text, single border) and a
consistent rounding family. Match the More-hub row height to the capture
tiles so equivalent tappable rows share one min-height at the same tier.
Give the demoted doctor-report export card a primary-tinted icon to match
its peer cards — the demotion comes from position and size, not a muted
icon. Drop the doubled heading atop the mood sheet by demoting the hero
question to a field label so the sheet title is the single primary
heading. Reword the capture mood option description to describe the
action instead of echoing the hero question.
(cherry picked from commit 1296f151f1c9a7c95ff917e3d69a5c0d369ba210)
Reshape the mobile bottom bar to the five-slot shape the native client
uses: Home · Meds · Log · Insights · More. The center "Log" slot is a
capture action rather than a destination — it opens a picker that routes
to the existing measurement, medication-intake and mood quick-entry
sheets (those forms already share a mount contract, so the picker reuses
them verbatim). Expand the former overflow into a real "More" hub that
holds the rest of the top-level destinations: Measurements, Mood,
Workouts, Achievements, Notifications and Settings.
The change is additive — Measurements and Mood leave the always-visible
strip but stay reachable through the More hub and the capture picker, so
nothing loses its route. On desktop the sidebar still lists every
destination.
Move the metric/imperial unit system from its standalone card into a
dropdown in the Profile form beside the timezone picker — it is a
display preference like language and timezone. Persistence is unchanged:
the dropdown PATCHes /api/auth/me/unit-preference on change and
invalidates the same query keys, so chart display transforms re-render
without a reload. Retire the now-unused card.
The standalone /targets (Zielwerte) page is retired. Target editing
moved inline onto the Insights metric pages in v1.8.6 — each metric's
reference panel mounts the editor and writes the same
PUT /api/user/thresholds, reading the same /api/insights/targets
payload. The page carried a deprecation notice and had already dropped
out of the navigation, so removing it is a UI-only change: no data
model, API route, or inline editor moves.
Remove the page and its tests, the components reachable only from it
(target card, per-card coach button, summary header, target prompt /
scope helpers, coach-handoff hook), the now-dead /zielwerte→/targets
proxy redirect, and the orphaned targets.* i18n keys across all six
locales. Repoint the handful of code comments that pointed readers at
the page to the inline Insights panel, and prune the two coach
gate-inventory allowlists plus the queryKey factory guard root that
named the removed files.
The reference panel keeps the range bar, consistency strip, status
pill, edit sheet, and source-link helpers — those are the live editing
surface now.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 7f842c5620087127e980fe22a56133135b7c0531)
Target editing now lives inline on the insights pages, so the standalone
targets page is no longer a navigation destination. Remove its sidebar
and bottom-nav (overflow) entries and add a top deprecation banner that
points the user at the insights pages and notes the page will be removed
in a future release.
The page stays routable: the /zielwerte to /targets redirect remains and
the route still renders. Removal later is UI-only because the insights
panel consumes the same targets read and threshold-override write. New
deprecation strings land across all six locales.
Consolidated hotfix landing the findings from a six-axis audit
covering mobile security, UX + accessibility, code quality + docs,
README + discoverability, web performance, and mobile-deep responsive
behaviour. None of the changes break the public API or the existing
data shape; every fix is additive and SAFE under the v1.4.34.x web
freeze. Full audit reports live in `.planning/round-audit-*.md`.
Security
- Password rotation now wipes API tokens + refresh tokens in the
same `$transaction` as session revocation. A stolen iOS Bearer no
longer survives a user-initiated password change or an admin reset.
- `/.well-known/` proxy bypass tightened to an explicit-allowlist
`Set`. Stale `/api/auth/codex/callback` PUBLIC_PATHS entry dropped.
- HSTS gains `preload`. Withings host removed from the global CSP
and gated to the integration's own routes.
- `getClientIp` warns once per process on `TRUST_PROXY_HOPS`/XFF
mismatch + exposes a tagged helper for tighter universal buckets.
- Service worker rejects off-origin notification-click URLs. Offline
fallback body switched to a locale-neutral icon. Manifest gains
`scope`, `id`, `display_override`.
UX + accessibility
- i18n leaks closed on Dialog/Sheet close-X, ChartSkeleton's loading
announcement, and the notifications-section breadcrumb.
- Focus rings on bottom-nav, the More overflow button, and the
mobile top-bar user menu.
- Tap-target floor: Input / NativeSelect / Select / DateTimeInput
switch to `h-11 sm:h-10`.
- Admin chip-strip auto-scroll-into-view (matches v1.4.33 settings).
- List-page subtitles visible on mobile.
- Insights empty-state CTA vocabulary normalised so the link opens
the right measurement form (was silently failing for
BODY_TEMPERATURE / BLOOD_GLUCOSE / PULSE).
Mobile-deep
- `viewport.themeColor` reacts to light/dark mode.
- TopBar + Coach FAB reserve safe-area-inset-top / -bottom for
notched iPhones.
- Sonner toaster switches to `theme="system"`.
Code quality
- `SESSION_SECRET` dropped — the env var was documented as required
but never read by code. Three secrets, not four. Self-hosters
upgrading can delete the existing line.
- `toJson<T>()` helper consolidates seven Prisma JSON-cast sites.
- `db-compat.ts` gains a header docstring naming it the
schema-bootstrap path.
- Passkey response types adopted from `@simplewebauthn/server`;
`any` + eslint-disable pragmas drop out.
- Zero-TODO CI gate added at `.github/workflows/no-todo-markers.yml`.
Documentation + discoverability
- README rework: How-it-compares matrix, Apple Health + AI Coach
promoted to standalone Key-Features bullets, status block, latest
release + GHCR badges, OpenAPI pointer, three-secret Quick Start.
- openGraph + twitter cards now carry `images` and a
`summary_large_image` Twitter card.
- GitHub repo metadata: description tightened from 290 to 156
chars + keyword-front, `homepageUrl` flipped from a personal
tenant to the demo site, six generic / PII-adjacent topics
swapped for six high-intent ones.
- Five new user-facing docs: `docs/self-hosting/getting-started.md`,
`docs/self-hosting/reverse-proxy.md`,
`docs/integrations/apple-health.md`,
`docs/integrations/withings.md`,
`docs/integrations/ai-providers.md`.
- `docs/README.md` clarifies the docs tree as internal playbooks
vs the user-facing `docs.healthlog.dev` site.
Quality-leap release between two HealthKit milestones. The headline is a P0 hotfix for the `/api/analytics` 500 that broke the Insights mother page for any account with more than a few thousand measurements: the six per-metric status helpers were spreading the entire numeric history into `Math.min` / `Math.max`, which trips a stack overflow once a single argument list exceeds the V8 spread cap. Both the surfaced symptom and the latent risk in five sibling helpers were folded onto a reduce-based min/max path.
Around that hotfix landed a deep polish pass: the Insights mother page now defers three below-the-fold blocks behind `next/dynamic` to trim the cold mount cost, the analytics endpoint gained a `?slice=summaries` slim slice that the per-metric sub-pages now ride, the Coach snapshot builder picked up a 60 s LRU keyed on `(userId, scope)`, the Settings shell consolidated three duplicate copies of the about / notifications / about-dropdown surfaces, the navigation strip dropped a redundant "Home" group label and a colliding "Notifications" sibling, every icon-only button on the medications and admin surfaces gained an accessible name, every Progress bar gained one too, three pages had their heading hierarchy repaired to a sequential h1 → h2 → h3, the mobile Coach FAB now hides while a chart tooltip is open so it cannot occlude the read-out, and the auth shell normalised every authenticated route to a single `max-w-screen-xl` container.
Also lands the Apple App Site Association handler at `/.well-known/apple-app-site-association` on every host that fronts the app, advertising the iOS bundle's App ID prefix (S8WDX4W5KX.dev.healthlog.app) under `webcredentials.apps` so the passkey ceremony shares cleanly between the web origin and the iOS app.
The release closes nineteen issues from a five-surface audit + a runtime bug-hunt pass, plus six follow-up tracks of polish across the affected surfaces.
The 7-item bottom nav collapsed each touch target to ~50 px wide on
a 375 px viewport — only just-passing WCAG 2.5.5's 44 px floor and
visibly cramped on iPhone-13-mini-class screens. The mobile audit
(`.planning/phase-A5-mobile-findings.md`) recommended 5+More.
Primary strip keeps the four most-used surfaces plus Insights:
Home, Measurements, Mood, Medications, Insights. Targets and
Achievements move into a Radix `<Sheet>` triggered by a sixth
"More" entry. Order matches the v1.4.15 layout for the four core
items so muscle memory holds.
The More button mirrors the same `min-h-11 min-w-11` hit area;
`aria-haspopup="dialog"` + `aria-expanded` keeps screen-reader
behaviour tight; the sheet renders bottom-anchored with safe-area
padding so the inner links stay reachable above the iPhone home
indicator. Active styling lights the More entry whenever any of
its overflow children is the current route, so the user always
knows which strip-level link "owns" their position.
UX/a11y residuals from 2026-04-26 audit:
- bottom-nav: touch targets sized to WCAG 2.5.5 minimum (44x44 CSS px)
via min-h-11 min-w-11 on each Link. Nav height bumped to h-16 to
accommodate. Visual icon stays at 20px so design doesn't shift.
- phase-config-dialog: decorative coloured dot now aria-hidden because
the redundant text label ("Green"/"Yellow"/...) already conveys the
phase. Screen readers no longer announce a meaningless 'image' node.
- admin: 'Web Push' and 'Bug Report' labels were hard-coded English in
StatusItem and SettingsToggle calls; now go through t() with new
admin.integrationWebPush / admin.integrationBugReport keys (en + de).
- medication-card: false-positive en-CA / en-US locale audit findings
documented inline — those are intentional locale-independent helpers
(en-CA gives YYYY-MM-DD for day-comparison, en-US gives a parseable
string for the toBerlinDate timezone hack), not user-facing display.
Switch defaultLocale from "de" to "en" and make English the code-level
language throughout. All ~145 hardcoded German strings translated:
- API error messages (52 routes) now return English
- Zod validation messages in English
- Classification/analytics functions return English categories
- Notification type labels in English
- Component strings use i18n t() calls (heatmap, mood chart,
medication form, intake history, password strength, insights card)
- ~50 new i18n keys in both de.json and en.json
Route migration:
- /stimmung → /mood, /zielwerte → /targets (new pages)
- 301 redirects in proxy.ts for legacy URLs
- Sidebar + bottom nav updated
Bump version to 1.0.1.
HealthLog — personal health-tracking PWA with weight, blood pressure,
pulse, mood tracking, medication compliance, Withings integration,
doctor report PDF export, and Dracula-themed UI.