[1.4.27] — 2026-05-15
Mobile capability + maintainer-finding cleanup. The headline is a new
<ResponsiveSheet> primitive that switches between a bottom-sheet on
narrow viewports and a centred dialog on desktop; every primary form
entry mounts through it with sticky-pinned Save / Cancel above the soft
keyboard. Tap targets across Button, Input, Select,
DropdownMenu and the new shared PasswordInput lift to the WCAG
2.5.5 floor, and inputMode / enterKeyHint / autoComplete /
aria-invalid / aria-describedby are wired across every form. The
Coach drawer mount moves up to the /insights layout so every
sub-page can launch the panel, and flips to a bottom-sheet branch on
narrow viewports. The dashboard GLP-1 tile gains a two-tab pane with a
range strip; the seven insights sub-pages gate on metric data
availability with empty-state CTAs that route into a self-opening
measurement form. Login overview surfaces an offline-resolved carrier
chip via bundled MaxMind GeoLite2-City + GeoLite2-ASN MMDBs. A new
<NativeSelect> primitive replaces five raw selects across settings
and admin. 26 of the 27 maintainer findings landed; the weekly-report
dead-click ask defers to v1.4.28 pending a screenshot. Migration 0061
is additive, IF NOT EXISTS-guarded, and forward-only.
Added
<ResponsiveSheet>primitive. New surface at
src/components/ui/responsive-sheet.tsxplus a typeduseIsMobile
hook atsrc/hooks/use-is-mobile.ts. Renders a Radix<Sheet>with
aside="bottom"branch belowmd(768 px), a centred<Dialog>
branch onmd+, and adata-variant="dialog" | "sheet"hook for
tests and downstream styling. The Sheet branch sticky-pins the
footer slot at the bottom edge with a backdrop-blur background; the
Dialog branch flows the footer normally. SSR-safe — seeded from
useSyncExternalStoreso the first client render reads
window.matchMediasynchronously rather than waiting an effect
tick.<NativeSelect>primitive atsrc/components/ui/native-select.tsx,
consumed byaccount-section.tsx,timezone-picker.tsx,
general-settings-section.tsxand the five remaining raw selects on
ai-section.tsx.<CoachLaunchProvider>andLayoutCoachMountfor/insights.
The drawer mount moves up tosrc/app/insights/layout.tsxso every
routed sub-page can calluseCoachLaunch()and open the panel.
Each of the seven sub-pages mounts a<CoachLaunchButton>— sticky
FAB on<lg, inline action onlg+.askCoach(prefill, scope)
accepts a reservedscopeargument for per-metric Coach narrowing
(parameter is wired through the context but the sources rail does
not yet pre-narrow; consumed in v1.4.28).- Dashboard GLP-1 tile — two-tab pane plus range strip. The tile
pairs a Weight tab with a new Drug-Level tab (default Drug-Level)
driven by<DrugLevelChart>in itscompactmode. A
7-day / 30-day / 90-day / all-time radiogroup picks the chart range.
Schedule dates promote to a header pill row; the previous green seam
is gone. - Insights sub-page availability gating. New helper
src/lib/insights/metric-availability.tsexposeshasMetricData()
as a single decision point.<InsightsTabStrip>,
<InsightsLayoutShell>and the seven sub-page routes gate the tab
pill, the layout mount and the sub-page body on whether the
underlying metric has data; missing metrics drop cleanly rather than
rendering an empty chart. - Insights empty-state CTAs. Each sub-page renders the shared
<EmptyState>with a metric-specific title, description and CTA.
BP / weight / pulse / BMI route into/measurements?add=<TYPE>,
which auto-opens the<ResponsiveSheet>add-form withdefaultType
set androuter.replaces the query so the back button returns to a
clean URL. Mood / medication / sleep route to their dedicated pages.
Every empty-state offers a Coach launch as a secondary path. - Offline geo-IP + ASN-to-carrier lookups. New
scripts/fetch-geolite2.shdownloadsGeoLite2-City.mmdb+
GeoLite2-ASN.mmdbintoassets/geolite2/at build time using the
MAXMIND_LICENSE_KEYrepo secret. The runtime resolver in
src/lib/geo.tsreadsGEOLITE2_DIR(defaults to
/opt/geolite2) and silently skips the offline tier when the files
are absent so local-dev workflows without the license key continue
to fall back toipwho.is. The MMDB files are not vendored in git;
.gitignoreexcludesassets/geolite2/*.mmdb. The fetch script
prints the SHA256 of each MMDB to stderr after download. The
GHCR build step fails fast with an::error::line when the secret
is unset. AuditLog.asn+AuditLog.carriercolumns. Migration0061
adds two additive,IF NOT EXISTS-guarded columns toAuditLog,
plus ageo-backfillpg-boss job that re-resolves carrier on
historical rows. The admin login overview surfaces the carrier as
a chip under the auth-provider column, with a case-insensitive
short-label heuristic that collapses verbose org strings
(e.g.Telefonica O2 Deutschland→O2)./aboutpage. New public route atsrc/app/about/page.tsx
carrying the MaxMind GeoLite2 CC BY-SA 4.0 attribution alongside the
existing project credits. Joinsproxy.tsandauth-shell.tsx
PUBLIC_PATHSso the page reaches an unauthenticated visitor with
its own edge-to-edge header and footer (same shape as/privacy).- Coach polish — info-icon popover for the composer hint. The
verbose hint span at the bottom of the composer collapses to an
Infoicon wrapped in a new shadcn<Popover>(mirroring the
existing<Tooltip>shape). The trigger carries anaria-label
with the same hint copy so screen readers still announce it on
focus; the popover body shows the long-form hint on tap or hover. - Soft-keyboard re-pin on the message thread.
src/components/insights/coach-panel/message-thread.tsxadds a
window.visualViewport.resizelistener with the samewasPinned
guard as the existing message-arrival effect. When the soft
keyboard slides in, the thread re-pins to the bottom so the tail
stays visible. parkIntegrationAtReauthhelper. New
src/lib/integrations/status.tsexport that flips an integration
row tostate=error_reauthwithout incrementing
consecutiveFailures, without callingrecordSyncFailure, and
without entering the 3-strike admin-alert ladder. Writes one
idempotentintegrations.reauth_requiredaudit row. Replaces the
scope-skip branch inwithings/sync-activity.tsand
withings/sync-sleep.ts; the defence-in-depth 403 catch block
stays onrecordSyncFailure.dispatchLocalisedNotificationhelper. New
src/lib/notifications/dispatch-localised.tsresolves the
recipient'sUser.locale, calls
getServerTranslator(locale).t(titleKey, params)/
.t(messageKey, params), and delegates to the base
dispatchNotificationwith composed strings. Wired into the deploy
webhook, the admin "test notification" button, the user "test
Telegram" button, and the admin reminder-check diagnostic. Nine
new notification keys land in all six locale bundles.- Locale-native date format ordering for FR / ES / IT / PL. A new
format.*namespace documentsdateShort,timeShort, and
dateTimeper locale — FR / ES / IT use{day}/{month}/{year}
slashes, PL uses{day}.{month}.{year}dots, DE keeps dots, EN
keeps slashes. The keys are forward-looking; runtime formatting
still routes throughIntl.DateTimeFormatvia
src/lib/format-locale.ts, but downstream surfaces that render
outside a React context (PDF, CSV, email) can read the ordering
hint without spinning upIntl. coachScopeWindowSchema.lastYear. The Coach snapshot window
enum gains alastYearvalue (365 days) betweenlast90daysand
allTime.<SourceChips>surfaces the year-in-review chip when
the resolved scope window matches.- Workouts read route. New
GET /api/workoutspaginated route
that runs every fetch throughpickCanonicalWorkout()with
DEFAULT_WORKOUT_SOURCE_PRIORITYand the 5-min cluster window.
Pagination corrects against canonical dedup — the route pulls the
full filtered set, dedupes once, then slices, someta.total
reports the deduped count rather than the per-window
canonical.length. - iOS handoff addendum. New
.planning/v15-ios-handoff/22-standalone-and-server-pairing.md
documents the standalone-then-pair pattern for the iOS native
client. Sibling of the existing22-offline-first-architecture.md;
same research input, neutral framing per the v1.4.27 convention
directive.
Changed
- Tap-target floor lifted across the primitives.
Buttondefault
h-9 → h-10,lgh-10 → h-11,iconsize-9 → size-10,
icon-lgsize-10 → size-11;Inputh-9 → h-10;Select
triggerh-9 → h-10;DropdownMenuItemgainsmin-h-11 py-2.
Dialogclose-X grew from 24 px tomin-h-9 min-w-9(36 px,
WCAG 2.5.8) — intentional compromise so the close affordance does
not crowd a dialog header. PasswordInputlifted to the shared UI layer. Moved from
src/components/settings/password-input.tsxto
src/components/ui/password-input.tsx; the toggle button grew to a
44 px hit area, the input getspr-12so user input never collides
with the toggle. Five settings + admin consumers re-import.- Coach drawer mount move. The drawer no longer mounts inline on
/insights/page.tsx; the layout owns it. Sub-pages call
useCoachLaunch()to open the panel. - Coach drawer bottom-sheet on
<sm. The drawer reads
useIsMobile("sm")and switchesside="right"→side="bottom"
below 640 px. The window-pill<Select>in the header hides on
phone viewports — the sources-rail picker covers the same override.
<SheetTitle>pinsmin-w-0 truncateso long titles always clip. - Coach evidence disclosure. The
<details>block on each Coach
reply is now controlled viauseStatewith an accurate
aria-expandeddriven from that state. TheshowEvidenceByDefault
Coach pref retires from the settings sheet (the persisted field
stays on the Zod schema for backward compatibility; v1.5 can drop
it via a forward-compat migration). - Coach sources-rail toggle. Swapped from a raw
<input type="checkbox">to the new shadcn<Checkbox>primitive
atsrc/components/ui/checkbox.tsx. Keyboard contract (Space
toggles, Tab moves), focus ring, touch-friendly hit target. The
existingdata-slot="coach-sources-checkbox"marker is preserved. - Coach settings-sheet close affordance. Retired the primitive's
absolutely-positioned close-X (showCloseButton={false}) in favour
of an inline<SheetClose>in the header, matching the coach-drawer
pattern and clearing 44 px. - Coach rail-tray triggers. The history and sources triggers lift
out of the absolute overlay into a sub-header strip
(xl:hidden/lg:hidden); both buttons sit atmin-h-11. The
per-row delete onhistory-rail.tsxdrops the
opacity-0 group-hover:opacity-100reveal and is always visible at
size-11. - Token-leak hardening at insight-status producers and consumer.
normalizeSummaryTexton the seven*-status.tshelpers
(pulse,weight,bmi,mood,blood-pressure,
medication-compliance,general) now callsstripChartTokens()
before whitespace collapse;<InsightStatusCard>wrapstextwith
the same call at the render site as defence-in-depth for cached
rows. The colon-form, capitalised-Metric and orphan-enum tokens
are now scrubbed at both layers. - Settings — date-of-birth paired with language in one grid row.
account-section.tsxcollapses the v1.4.19 split. The
TimezonePickerinner gap lifts togap-3so the select and the
detect button breathe at the same rhythm as the rest of the form. - Settings + admin shells reserve a minimum main-column height.
Both shells pick up
<main className="min-h-[calc(100dvh-12rem)] min-w-0">so short
sub-pages no longer trigger a click-to-shift as the layout
collapses inwards. - Thresholds + Sources skeleton rows replace the single spinner.
The two settings sections render a row-shaped placeholder list
while loading; the skeletons map over the same metric ordering as
the live UI so the layout stays put across the loading-to-loaded
transition. - Heading weight + card cadence + label-input gap. Standardised
tofont-semiboldacross every divergent<h2>/<h3>in
settings and admin sections; card-internal vertical rhythm pins to
space-y-4; the password-change dialog ataccount-section.tsx
liftsspace-y-1.5 → space-y-2across its three label-input pairs. - Health Score column rebalanced. The hero strip splits to a
tablet-friendlymd:flex-row; the Health Score card pins a
basis-width so the column does not stretch past its content.
The L2 disclaimer text bumps fromtext-[10px]totext-[11px]
to clear the 12 px mobile floor concern. The retired inline
ask-Coach button drops; theonAskCoachprop stays for backward
compatibility (destructure-and-ignore). - Daily Briefing trim. The duplicate paragraph slot drops; the
card renders a single insight line and the matching insights
sub-page link. /api/versioncarrier surface. The login overview CSV adopts
carrier as a column and the per-row chip surfaces under the
provider cell. Empty carrier renders as no chip rather than a
placeholder label.- CoachLaunch
scopeparameter. Reserved on the
useCoachLaunch().askCoach(prefill, scope)signature for v1.4.28's
per-metric narrowing; currently a no-op on the sources rail. - CSV / pagination chrome out of the admin scroll wrappers.
login-overview-section.tsxandapp-log-preview-section.tsxnow
render the pagination controls and the summary line as siblings of
theoverflow-x-autotable wrapper. The CSV export button was
already in the toolbar row above the table. measurement-list.tsxfilter row stacks on<sm. The
measurement-type filterSelectTriggerwidens tow-full sm:w-48
and stacks above the controls below the small breakpoint.- Chart-height as a CSS variable.
HealthChart,MoodChartand
MedicationComplianceChartexpose--chart-height/
--chart-height-mdso the height shifts onmd+without a
re-render.MoodChart280 → 240 to match the rest of the trend
strip; a sharedCHART_HEIGHT_PXconstant lives at
src/components/charts/constants.ts. - Compliance heatmap tap-pin + cell floor. The heatmap tooltip
pins on tap on touch surfaces (previously hover-only); each cell
pins a 14 px floor; the heatmap overflows the parent on<sm
rather than crushing to one row. - Withings sync — scope-skip path silences the admin alert.
Calls torecordSyncFailureinsync-activity.tsand
sync-sleep.tsswap toparkIntegrationAtReauthon the
deliberate scope-skip branch; the defence-in-depth 403 catch path
keeps the loudrecordSyncFailure. The false-positive 3-strike
admin Telegram for re-auth scope deltas is gone. - i18n bundles — 154 dead keys retired. A repository-wide scan
retires keys that no surface reads (the legacy insights status
trio, theaiInsights/generate*/noApiKeyset retired with
the briefing rebuild, theonboarding.v2.*stub set, ten dead
medication keys, four dead chart keys, theinsightsPreview/
bloodPressureDia/bloodPressureSysdashboard keys, two dead
notification keys, two dead admin-provider labels). 228 new
strings land across the same 38 unique paths for the GLP-1 tile,
the admin carrier chip, the insights empty states and the
notifications dispatcher. - Shared mood label module. New
src/lib/mood/labels.tsexposes
MOOD_ENUM_VALUES,MOOD_SCORE_BY_ENUM,MOOD_ENUM_BY_SCORE,
MOOD_LABEL_KEYS, andmoodLabelKeyForScore().mood-list.tsx
andmood-chart.tsximport the canonical key map from this
module; the five inlinet("charts.moodLabel${n}")calls retire
in favour of the canonicalmood.level*set. - Shared
allMessages+resolveKeyextract. Both
lib/i18n/context.tsxandlib/i18n/server-translator.tsnow
import from the newlib/i18n/shared-resolve.ts; the two
duplicate copies are gone (net 61 / -66 LOC). metricPriorityObjectSchemaderives from
SOURCE_PRIORITY_METRIC_KEYS. Adding a metric class is a
single-line constant edit instead of three parallel listings.- Workouts attach route collapses 1+N to a single
findMany.
POST /api/workouts/batchswaps the
Promise.all(withoutExternal.map(p => tx.workout.findFirst(...)))
loop for one batchedtx.workout.findManywith a per-entryOR
clause; per-batch round-trip count drops from 1+N to 2 for a
100-row batch. The createdAt-DESC tie-break is preserved via
in-memory grouping. - Form input attributes wired across the surface. Measurement,
medication, mood, settings, admin and auth forms pick up
inputMode/enterKeyHint/autoComplete/autoCapitalize/
aria-required/aria-invalid/aria-describedby. The Input
primitive derivesinputModefrom thetypeprop when the caller
does not pass one (number → decimal,tel → tel,
email → email,url → url,search → search). Integer-only
call sites still passinputMode="numeric"explicitly. - Schedule day-of-week grid widens on narrow viewports.
medication-form.tsxstacks the Daily pill above a fixed
grid grid-cols-7so every weekday keeps the 44 px tap-target
floor regardless of container width. - Public-page polish.
/aboutand/privacysticky headers
pick uppt-[env(safe-area-inset-top)]so the brand row clears
the iOS notch./privacymounts a default-closed<details>
Contents TOC above the body with anchor links to every numbered
section; the 19 HealthKit identifier<code>elements gain
break-allso the longest camelCase entries wrap.
Fixed
- Stray-brace typo at the insights-targets route. The trailing
comment block atsrc/app/api/insights/targets/route.ts:807
carried a stray}that the v1.4.25 polish pass missed; cleaned
up alongside the prompt-side audit. - Chart-tick timezone audit.
compliance-heatmap.tsxparsed the
day-key against local tz (new Date(dateStr + "T00:00:00")) and
readgetDay()/getMonth()(server-tz) while the dateKey was
UTC-anchored viatoISOString().slice(0, 10). Pinned to
T00:00:00Z+getUTC*accessors so the Monday-alignment and
month-marker placement stay correct under an SSR pass on a
non-Berlin host. The five sibling chart files
(sleep-stage-stacked-bar,mood-chart,health-chart,
medication-compliance-chart, plus the broader insights surface)
audit clean. /aboutreturned 401 to unauthenticated visitors./about
joinedproxy.tsPUBLIC_PATHSin B3 but the client-side
auth-shell.tsxPUBLIC_PATHSlist was missing the entry, so
the route surfaced the redirect-to-login screen instead of the
credits.isStandalonePublicPagealso matches/aboutso the
route renders edge-to-edge.- Insights empty-state CTAs hit a 404. The CTAs targeted
/measurements/new, which is not a route. Swapped to
/measurements?add=<TYPE>with aMEASUREMENT_TYPESallow-list
on the consumer side; the measurements page reads the query
param, opens the form withdefaultTypeset, then
router.replaces to a clean URL. /api/versionregister button below the tap floor. The
/auth/registersubmit button promotes tosize="lg" min-h-11 w-fullso the primary action stays finger-tap reachable on
narrow viewports.- Workouts pagination broken under canonical dedup. Pulling the
full filtered set and slicing post-dedup yields the correct
meta.totaland a no-overlap, no-gap descending order across
pages. New regression test paginates eight twin clusters across
two pages. useIsMobilefirst-paint desktop flash. The hook now reads
throughuseSyncExternalStorewithgetServerSnapshot() => false
andgetClientSnapshot()reading
window.matchMedia(query).matchessynchronously. SSR still
resolves tofalse; the first client render reads the live
media-query state without waiting an effect tick.Sheetclose-X tap target. Widened to match theDialog
primitive's 36 px floor./aboutand/privacyanchors occluded by the sticky header.
scroll-mtwidened so the section start lands below the sticky
header rather than behind it.MedicationComplianceChart.compareBaselineprop intent. The
prop was carried by 24 call sites uniformly but was never
consumed; explicitly destructured withvoid compareBaseline;so
the type contract is preserved and the dead-prop signal clears.- DrugLevelChart dead axis labels. Dropped the empty
<text>
child of<XAxis>(an invisible SVG node beneath the x-axis) and
the duplicate Rechartslabel={…}prop on<YAxis>that tried to
paint the unit-less caption inside a 1 px-wide axis where it
could never be read. The external<p>above the chart remains
the single source of truth. not-found.tsxmissing. New branded 404 page with the
<Logo>, the 404 eyebrow, the headline and a single
back-to-dashboard<Link>.min-h-dvhfollows the dynamic
viewport on iOS Safari;pt-[calc(env(safe-area-inset-top)+3rem)]
keeps the headline clear of the notch.
Removed
- Orphan
/api/audit-logroute. The route file (1 281 B) had no
callers, no test fixture and no DTO. Five admin / monitoring
endpoints (/api/admin/ai-settings,/api/admin/backup/test,
/api/admin/status-overview,/api/monitoring/glitchtip/test,
/api/monitoring/umami/test) defer to v1.4.28 because each is
referenced by README or CHANGELOG — a wire-or-remove decision the
maintainer owns. <InsightsCardPreview>surface. The standalone dashboard
preview retired alongside the layout-test contract flip;
dashboard-layout.test.tsnow guards against accidental
reintroduction.- 14 dead exports across
glp1-knowledge.ts,scheduling/cadence.ts
andglp1-snapshot.ts. Eight type symbols on
glp1-knowledge.tsdrop to internal types;ExpectedDosedrops
itsexportkeyword;__testables.WEEKDAY_KEYSretires with zero
callers.routeForBrandandGLP1_DRUG_IDSstay exported because
the test suites read them. BASE_SYSTEM_PROMPT+INSIGHTS_SYSTEM_PROMPTbare-symbol
exports. Verified clean — only locale-suffixed_DE/_EN
forms remain.- Stale legacy insights-prompt module. Pruned alongside the
v1.4.25 native-locale rebuild that displaced it. - Three dead Coach prefs.
showEvidenceByDefaultUI retires
from the Coach settings sheet; the persisted Zod field stays for
backward compatibility.
Infrastructure
MAXMIND_LICENSE_KEYwired into the GHCR build workflow. A
newFetch GeoLite2 databasesstep in
.github/workflows/docker-publish.ymlbetween the metadata-action
and the buildx build-push exports the secret from repo secrets.
Offline GeoLite2 is optional in this release: when the secret
is unset the workflow emits a::warning::, drops an.empty
marker intoassets/geolite2/, and continues so the Dockerfile
COPYstill has a non-empty source. The runtime resolver in
src/lib/geo.tsdetects the marker on first lookup, falls back
to the existingipwho.isprovider, and sends a one-shot admin
notification (notifications.admin.offlineGeoUnavailable*) with
a pointer to the GitHub Actions secrets page so the maintainer
hears about the gap from the running app. The/api/version
endpoint exposesofflineGeoEnabled: boolean; the/admin
overview snapshot and the full/admin/system-statuspage render
a green / yellow chip from that flag. Setting the secret and
redeploying lights the feature up without code changes.- Migration
0061_audit_log_carrier. Additive,
IF NOT EXISTS-guarded, forward-only. Adds two columns to
AuditLog:asn(bigint) +carrier(text). Safe to
re-apply on the demo server.
Tests
pnpm test --run— 4004 / 4005 passing, 1 skipped. Across
357 files. The skipped test is a pre-existing
visual-regression placeholder.- 6 new
responsive-sheet.test.tsxsmoke tests pin the
dialog-vs-sheet branch, thedata-varianthook, the footer-slot
contract and the SSRuseIsMobilefirst-paint. - 16 new
metric-availability.test.tscases cover each metric ×
{has data, no data, undefined summaries, missing summary entry, BMI-from-WEIGHT derivation, sys-vs-dia independence, mood/medication overrides}. - 6 new
insights-tab-strip.test.tsxcases assert
backward-compat withoutavailability, pill-drop when data is
missing, overview pill always renders, mood + medication
light-up, BMI-from-WEIGHT derivation. - Drift-guard test at
src/__tests__/i18n-drift-guard.test.ts
(16 cases) anchors the GLP-1 tile keys, the carrier keys, the
insights empty-state keys, the notification dispatcher keys and
the personal-record namespace across all six locales. - Locale-native date format test at
src/lib/i18n/__tests__/format-locale-order.test.ts(7 cases)
asserts ordering per locale. canonical-dedup.test.ts(4 cases) plus the new
pagination regression covers the workouts read route.- Auth + audit suites extended for the new ASN + carrier
columns and the carrier short-label heuristic (14 cases under
geo-asn.test.ts, 5 new cases underaudit.test.ts, 8 new
underlogin-overview-csv.test.ts). coach-launch-context.test.tsx(3 cases) pins the new
Coach launch context hook shape, the provider mount, and the
null fallback outside the provider.