- MoodReminderCard auto-clear parity with DisableCoachCard (3 s
setTimeout + useRef cleanup) — closes the v1.4.48 docstring
parity gap
- mergeReorderIntoLayout dev-only console.warn on silent orphan
drop — defence-in-depth for the upcoming per-tile Suspense
refactor
- buildPayloadDiagnostic helper extracted from widget + series
routes — single source of truth for the iOS payload-diff
wide-event meta
- disableCoachBody + disableCoachData collapsed in OpenAPI routes
- IntegrationStatusNullProbeResult export dropped (local-only type)
- newerAvailable + linksHeading native translations for es / fr /
it / pl
Privacy-leaning users want the Coach affordance gone even when the
operator has enabled the global `assistant.coach` feature flag. The
new per-user `user.disable_coach` boolean is a hard client-side gate
that sits BELOW the existing operator flag — both must agree to
render the affordance.
The toggle ships as a Switch in Settings → Insights ("Hide Coach" /
"Coach ausblenden") and persists via `PATCH /api/auth/me/disable-coach`.
Every Coach mount point on the web (`<LayoutCoachFab>`,
`<LayoutCoachMount>`, the inline `<CoachLaunchButton>` pill, the
`<SuggestedPrompts>` chip strip, the `<HeroStrip>` action row, the
`/targets` page CTAs) reads the new value through a small
SSR-safe `useDisableCoach` hook so the long-tail snapshot tests
keep rendering without a QueryClientProvider.
Default is `false` — the upgrade from v1.4.46 is invisible to anyone
who hasn't toggled the new switch. The /api/auth/me payload tolerates
an undefined field for partial-deploy rollback safety. A dedicated
invariant fixture pins every Coach surface against both the
operator-level and the per-user gates; the existing cascade test
gains a `generated/` skip so Prisma's `inlineSchema` doc-comment
copy doesn't trip the orphan check.
Bilingual + es/fr/it/pl translations land in the same commit.
WelcomeCarousel and TourLauncher both auto-launched when their gating
flags were null, stacking ~90 seconds of forced onboarding on top of a
brand-new user before they could touch the dashboard. The tour now
auto-launches only ≥ 24 h after the wizard finishes (new pure
`shouldAutoLaunchTour` helper); first-day visits belong to the welcome
carousel alone. Users who *want* the tour on day one can trigger it
from a new "Replay the tour" / "Tour ansehen" button in Settings →
About — same persistence + window-event flow as the existing Settings
→ Account restart button. A per-user `tour-force-launch` sessionStorage
marker carries the manual intent across the navigation back to the
dashboard so the 24 h gate stays out of the way of an explicit click.
The dashboard layout section already let users reorder widgets via ↑/↓
arrow buttons; the v1.4.43 QoL audit (M1) flagged that modern parity
with Apple Health and Withings web expects drag-to-reorder as the
mouse/touch surface. Adds a GripVertical drag handle on each row wired
to @dnd-kit/sortable. The keyboard fallback stays — arrow buttons are
unchanged and the new handle is also keyboard-operable via dnd-kit's
KeyboardSensor (Tab + Space + arrows).
Both surfaces persist through the same draft → PUT path the arrow
buttons already used, so save / cancel / reset semantics carry over
without changes. Pure reorder logic is extracted into a shared
`reorderWidgets` helper so the mutation payload contract is pinned by
unit tests without spinning up a DndContext.
Bundle delta: ~29 KB gzipped (core 22 + sortable 5 + utilities 2),
well within the 50 KB budget called out in the audit.
All six locales gain `dragHandle` (button label) and `dragHandleHint`
(shared `aria-describedby` paragraph) keys.
W4's pluralised relativeMinutesAgoOne/Other + relativeHoursAgoOne/Other + relativeDaysAgoOne/Other keys were silently removed across all 6 locales when W12's i18n tighten (commit 2390438e) re-applied the legacy singular keys. The formatRelativeTime helper still reads the *One/*Other keys, so the Insights hero, Daily Briefing, and Coach history rail all paint raw key strings to users.
v1.4.43 W10 critical-reconcile — code-reviewer C-1, design C-1, simplifier C-1.
W11's noDataInRangeTitle/Description and W2's needMoreDistinctDaysTitle/Description were both keyed under 'charts' and were swept out when W12's i18n tighten on the same root branch was applied without preserving sibling-wave additions. Restoring the four keys with their translations across de/en/es/fr/it/pl.
W14 added warningServerError, parkedReconnect, resumeCta, resumeSuccess, resumeError to the integrationPill block across all six locales. W12's i18n tighten on the same block reverted to a pre-W4/W14 baseline. Restoring the missing keys with their translations.
Pill UI gains a fifth state — 'parked' — for the v1.4.43 W14 24h
persistent-failure park. Copy reads 'Pausiert — manuell wieder
verbinden' (DE) / 'Paused — reconnect manually' (EN); chip uses the
dracula-orange tokens (heavier than warning yellow, lighter than the
red reconnect pill).
The Withings card renders a 'Wieder verbinden' / 'Reconnect' banner
under the pill when the state is parked. Clicking POSTs to the new
/api/integrations/withings/resume endpoint (rate-limited 5/min per
user, same envelope as the test-connection endpoint). The endpoint
calls resumeIntegrationFromPark which flips state back to connected
and zeroes every per-kind bucket; the per-card status query
invalidates so the pill flips back without a page refresh.
Locale parity across all six locales (en/de/es/fr/it/pl); end-to-end
integration test against real Postgres pins both the resume flow and
the per-kind bucket increment contract.
`health-chart.tsx` returned `null` when the loaded payload was
empty in the selected range, which stripped the entire widget
(header + tabs + chart). On the dashboard the user saw a missing
tile with no explanation. Keep the card chrome mounted and paint a
`<ChartEmptyState>` with distinct copy from the < 3-points
"Mehr Messtage erforderlich" branch so the two situations don't
blur. Adds `charts.noDataInRange*` strings across all six locales.
formatRelativeTime always read the *Ago key with count=1, so a German user reading the Insights hero saw 'vor 1 Minuten' / 'vor 1 Stunden'. Splits each bucket into One / Other variants across all locales and branches on count === 1 in the helper, matching the dashboard.staleHintWeeksOne / Other pattern already in the bundle.
The branded 404 splash hard-coded its three strings in English, ignoring the cookie/Accept-Language locale resolution the rest of the app uses. Switches to getServerTranslator + resolveServerLocale and adds an errors.notFound.{title,body,backToDashboard} block to every locale bundle.
The pill previously collapsed both error_transient and error_reauth onto the same red 'reconnect' chip. The v1.4.42 persistent failure kind (Withings 293 / contract mismatch) maps to error_transient but reauth won't fix it. Adds a fourth pill state — warning — with an amber dracula-yellow chip and locale-aware copy ('Verbunden, aber Serverfehler' / 'Connected, server error'). Wiring at the call-site is deferred so the persistent kind can flow through the API payload first.
Reads 'Anbieter' instead of 'Provider' throughout the German coach + settings + raw-data copy, and softens the rate-limit toast from 'rate-limited' to 'gedrosselt'. Confines the term 'Provider' to the technical context where it still appears under-the-hood, never in user-facing strings.
The per-day drill-down used measurements.saveError in its fetch-error branch, which surfaced 'Fehler beim Speichern' on a read failure. Adds a measurements.loadError key in every locale and points the GET error path at it.
Pre-existing empty-state copy ("Erfasse mindestens 3 Einträge, um die
Trendlinie freizuschalten") misleads when a user has logged many
measurements on too few calendar days. Add a second pair of strings
keyed on `charts.needMoreDistinctDays{Title,Description}` so the
upcoming chart-gate split can paint the right call-to-action when raw
count is sufficient but distinct-day count is not.
The runtime never reads the format namespace — every locale-aware
formatter routes through Intl.DateTimeFormat directly via
src/lib/format-locale.ts. The keys were only verified by their own
ordering smoke test, which is also removed.
v0.5.4 PR #190 shipped the server cron, ledger, and APNs scaffolding
for the daily mood reminder but no surface for the user to opt in,
so the feature was dead code regardless of the locale, dispatcher,
or APNs fixes. This commit adds:
- `MoodReminderCard` in `/settings/notifications` — a single Switch
row bound to `users.mood_reminder_enabled`, optimistic on toggle,
with a localised toast on save success and an error message on
failure.
- `applyProfileUpdate` accepts and persists `moodReminderEnabled`
through the existing `/api/auth/profile` PUT and `/api/user/profile`
PATCH paths so both web and iOS clients share the same validation
+ audit envelope.
- `GET /api/user/profile` now surfaces the current value so the
Switch can render the right initial state without a separate
endpoint.
- Five-locale strings (de/en/es/fr/it/pl) under
`notifications.moodReminder.*` for the card title, description,
Switch aria label, on/off status text, and toast / error copy.
`Comment vous sentez-vous aujourdhui ?` ships from v0.5.4 PR #190
without the apostrophe; every other FR string uses `aujourd'hui`.
The push body is read aloud on iOS by VoiceOver, so the typo is
audible on top of being visible on the lockscreen.
* feat(notifications): set APNs category for med-reminders
Unblocks iOS HealthLog v0.5.3 which registers a UNNotificationCategory
named MEDICATION_REMINDER with three actions (Take / Snooze 15 min /
Skip). Without aps.category on the push payload iOS renders the
notification as a plain alert, so the action-button row never
materialises on the lock-screen.
- ApnsPayload gains optional `category` + `mutableContent` fields.
The sender writes `category` through to node-apn's setter (a
forward-compat cast wraps the d.ts gap in node-apn 8.1).
- sendViaApns automatically forwards `payload.eventType` as the
category so every event-type that lands here surfaces as a
matching iOS category identifier without further plumbing. Future
event-types that the iOS app doesn't register fall back to a plain
alert — iOS silently ignores unknown categories.
- mutableContent=1 is set by default so an iOS Notification Service
Extension can hook the payload later without a server-side change.
- New unit tests pin aps.category=MEDICATION_REMINDER on the dispatcher
path, aps.category=MOOD_REMINDER for the new event-type, and the
explicit-category override on the lower-level sendApnsPush entry.
* feat(prisma): add moodReminderEnabled + MoodReminderDispatch ledger
Schema foundation for the v0.5.4 daily mood-reminder push:
- `users.mood_reminder_enabled` (Boolean, default false): per-user
opt-in flag. Default off because mood capture is an emotionally
loaded surface — a fresh-install user must explicitly toggle the
preference in Settings before the server starts nudging them.
- `mood_reminder_dispatches` (new table, unique on userId+date):
idempotency ledger that records exactly one nudge per user per
local date. Doubles as the race-guard for the every-15-min cron
re-ticking inside the same 22:00 window — the unique constraint
makes parallel workers safe even though the worker boots at
localConcurrency=1.
Migration is additive: existing users stay on the opt-out path
byte-identical to v1.4.38 behaviour. Uses IF NOT EXISTS guards
matching the idempotent pattern from 0061 / 0068.
* feat(notifications): add MOOD_REMINDER event type with daily 22:00 cron
Second half of the v0.5.4 ios-coord patch. Now that the schema can
remember "user opted in" and "we already nudged today", wire the
worker side:
- types.ts: extend EVENT_TYPES with MOOD_REMINDER. Default off on
the per-event policy as a defence-in-depth gate behind the
per-user moodReminderEnabled flag.
- mood-reminder.ts: pure dispatcher module with three pinned-down
entry points so the unit tests don't have to spin up pg-boss:
* `evaluateMoodReminderWindow` — boundary contract for
"21:59 → skip, 22:00 → fire, 23:00 → skip" across any IANA tz.
* `buildMoodReminderPayload` — locale-aware title/body
("Stimmung erfassen" / "Wie geht es dir heute?" for DE,
"Log your mood" / "How are you feeling today?" for EN; falls
back to the app default for null/unknown locales).
* `runMoodReminderTick` — orchestrator that pulls opted-in
users, applies the window + already-logged + already-
dispatched filters, reserves the dedup row, and only then
calls the dispatcher. The P2002 unique-constraint violation
is caught and reclassified as "already dispatched" so a
lost race doesn't pollute the wide-event error stream.
- reminder-worker.ts: registers the `mood-reminder-check` queue
with a 15-min cron, mirroring the medication-reminder cadence so
any user's 22:00 boundary is caught within 15 minutes of wall-
clock without one-cron-per-zone. localConcurrency=1 keeps a
worker from racing itself. Also enriches the existing med-
reminder dispatch with a `scheduledAt` ISO string so the iOS
notification handler can pin "snooze 15 min" against the actual
schedule slot rather than the wall-clock moment APNs delivered.
- messages/{de,en,es,fr,it,pl}.json: new `moodReminders.dailyTitle`
+ `moodReminders.dailyBody` keys for all six locales.
- mood-reminder.test.ts: 13 unit tests covering opt-in gating,
window boundaries (21:59 / 22:00 / 23:00 + non-Berlin DST),
logged-today skip, ledger-already-exists skip, lost-race P2002
handling, and the multi-user multi-locale fan-out.
Robustness sweep, perf hotspots, full localization, coach gate
cascade closure.
- /api/dashboard/summary cold-mount 4.6 s → ~500 ms via DAY-bucket
sparkline + 60 s response cache.
- Coach API gate inventory test surfaced and closed two orphan
routes (chat[id] GET+DELETE, chat-message feedback POST).
- Cross-tz fast-path runtime guard falls back to live for users
more than ±3 h from UTC; proper bucket minting stays v1.5.
- es / fr / it / pl coverage 27 % → 63 % (~3 400 new strings) plus
three medication-cluster placeholder restorations.
- IANA timezone validation closes the User.timezone self-DoS
regression the new dashboard SQL would have surfaced.
- Unit suite 4520 → 4524.
See CHANGELOG.md for the full set.
Carries the v1.4.36 perf carry-over for the full /api/analytics
route (correlations + healthScore + bp_in_target on the rollup-
coverage probe, 111s cold worst-case down to ~1.5-3s on power-user
accounts), consolidates Apple Health step samples into a one-row-
per-day list view with chevron drill-down and a nightly drain
(~50x table compression once it converges), restores
IntakeHistoryListV2 to the pre-v1.4.28 contract, converges every
Coach affordance behind a single feature gate with an invariant
test pin, lifts the medication-detail Mounjaro card to byte-
equivalent parity with Ramipril, brings the Insights overview
hero row to height parity, hardens getClientIp behind a new
TRUST_CF_CONNECTING_IP env flag, ships the Arztbericht hero card
on Settings -> Export, adds the Medikamenteneinnahme quick-add
overlay to the dashboard, drops the timezone override toggle for
a silent browser-zone auto-seed, and fixes the v1.4.36 red CI
workflows. Documentation refreshed across all three repos with
five new architecture diagrams in the Dracula palette. Unit suite
4354 -> 4486 passing; typecheck + lint clean.
Heavy-aggregate skip on the rollup-fresh + fully-covered path,
daily chart fetches opt into rollup buckets via source=rollup,
Insights extractFeatures swap drops the prompt payload from
double-digit MB to the low hundreds of kB on power-user accounts,
page-level isLoading gate replaced with per-section early-skeleton
paint, trends-row chart slot pinned for CLS, IntakeHistoryListV2
restored on the medication detail page, About card folded into the
Admin Console with an auto-checking update badge, cumulative metric
tiles read the day's running sum, IP-whois fallback surfaces city
+ country when the ASN lookup misses. Migration 0068 adds an
additive users.insights_exclude_metrics column. Unit suite 4354
passing; typecheck + lint clean.
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.
The final functional web release before the iOS native client lands.
Headline work: a streaming Apple Health `export.zip` importer for
prospective iOS-migrating accounts, a server-side aggregation cache
that collapses the three hottest dashboard reads onto in-process LRU
slots with single-flight coalescing and per-user invalidation, a
broader compliance classifier with a dedicated `early` bucket so
ahead-of-window doses count as compliant, the Coach launch surface
hoisted onto every authed page with a dashboard-hero CTA, and a
trimmer Settings shelf that folds Sources into Targets. The release
also lights up the `cache.<name>.outcome` annotation on the active
wide event so production logs carry the cache hit-ratio signal
without leaking userIds.
### Added
- **Server-side aggregation cache.** A new `ServerCache<T>` primitive
(`src/lib/cache/server-cache.ts`) extends the v1.4.33 Coach snapshot
LRU shape into a reusable per-route layer with TTL expiry,
capacity-bounded LRU eviction, single-flight read coalescing, and
per-instance hit / miss / eviction / stampede counters. Wires the
three hottest dashboard reads — `/api/analytics` (slim + default),
`/api/gamification/achievements`, `/api/medications/intake?scope=compliance`
— and bolts per-user invalidation onto every measurement / mood /
medication / workout / dashboard-widget write endpoint so the next
read paints fresh data instead of waiting out the TTL. Every cache
hit / miss surfaces on the active wide event as
`cache.<name>.outcome` + `cache.<name>.key_hash` so production logs
carry the hit-ratio signal without leaking userIds.
- **Apple Health `export.zip` import.** New endpoint
`POST /api/import/apple-health-export` (synchronous multipart
upload, asynchronous ingest via a dedicated pg-boss
`apple-health-import` worker, per-`MeasurementType` ingestion
stats). Streams the upload straight to disk so a multi-gigabyte
export never lands in V8 heap, hashes the bytes inline for
content-based idempotency, and unpacks `apple_health_export/export.xml`
with a hand-rolled ZIP central-directory walker that handles Zip64
for archives past the 4 GB barrier. The parser folds every
`<Record>`, `<Workout>`, `<Correlation>`, and `<ClinicalRecord>`
into the existing `Measurement` and `Workout` row shapes — spot
rows keyed by `HKMetadataKeyExternalUUID` (or a deterministic
`sample:<sha256>` fallback), cumulative HK types collapsed into
one `stats:<HKType>:<YYYY-MM-DD>` row per user-local day to match
the iOS daily-aggregation convention. Live per-stage progress
surfaces through `GET /api/import/apple-health-export/{jobId}/status`.
An admin variant at `POST /api/admin/import-apple-health-export`
imports on behalf of a target user (cookie-only `requireAdmin()`
gate; Bearer tokens never elevate).
- **`ImportJob` schema model + migration.** New Prisma model captures
the per-upload state machine (`queued | unpacking | parsing |
upserting | done | failed`), the content-hash for idempotency
short-circuits, and the per-`MeasurementType` ingestion counters
the status route surfaces back to the client.
- **`lastSeenByType` on `/api/analytics`.** Both the slim
(`?slice=summaries`) and default slices return a per-type
`{ lastSeenAt, daysAgo }` map. The dashboard trend tiles wire 12
mounts to a new `tileStaleDays()` helper so an "X days / weeks /
months ago" hint paints under any per-metric tile whose last
sample crosses the 7-day floor. Six locales gained additive
week / month plural keys; Polish uses the genitive-plural form
that covers every non-1 count the bucket math can produce.
- **Dashboard "Ask the coach" hero CTA.** A new pill next to the
existing "Hinzufügen" / "Add" launches the Coach drawer directly
from the dashboard hero. The `<CoachLaunchProvider>` hoisted from
the insights layout to the auth shell so the drawer is reachable
from every authed route; the floating action button stays scoped
to `/insights/**` so it cannot distract on the dashboard.
- **Shared achievements query hook.** `useAchievementsQuery()`
centralises the three previous consumers — recent-achievements
card, the `/achievements` mother page, and the unlock notifier —
onto one TanStack queryKey + cache slot so dashboard cold mount
fires the endpoint once instead of twice.
- **Typed authed Cache-Control presets.** New
`src/lib/http/cache-headers.ts` module exports
`NO_STORE_BUT_BFCACHE = "private, max-age=0, must-revalidate"`,
`SHORT_LIVED_PUBLIC`, and an `applyAuthedHeaders()` helper. The
presets land at the framework level via a `next.config.ts`
`headers()` rule that stamps the bfcache-friendly directive on
every authed HTML response, restoring Chromium bfcache eligibility
for in-app back / forward navigation.
- **`scripts/print-bundle-report.mjs`.** Restores the at-a-glance
"top client chunks by parsed size" signal Turbopack dropped from
`next build`; reads `.next/analyze/client.json` (written by
`@next/bundle-analyzer` when `pnpm analyze` runs with `ANALYZE=1`)
and prints a sorted table plus totals.
### Changed
- **Settings sidebar: "Sources" folded into "Targets & Sources".**
Per-metric threshold ranges and per-metric source priority now
share one `/settings/thresholds` page so the same metric's
threshold and device-source preference sit together instead of
one sidebar entry away. `/settings/sources` keeps a
`permanentRedirect` so external bookmarks and docs links follow
through unchanged. Section count: 11 → 10.
- **Insights tab strip: vital pills collapse under a "Vitals"
parent.** Five vital pills (HRV, Resting HR, Oxygen, Body
Temperature, Active Energy) hide behind one parent pill that
opens a popover sub-list. Parent-pill active state mirrors the
URL so spatial orientation survives the collapse; each sub-page
keeps its own URL and bookmark resolves unchanged. Strip
footprint: 14 → 10 entries when every metric has data.
- **Coolify env-var audit.** `mcp__coolify-apps01__env_vars`
inspection captured the section-1 / section-2 duplicates that
have accumulated under apps-01 since v1.3.1. Audit pinned at
`.planning/round-v1434-iwa-coolify-env-audit.md`; no env-var
deletes performed (operator action).
### Fixed
- **Compliance classifier: early intakes count as compliant.** The
classifier picked up a dedicated `early` bucket (window-start
minus three hours through window-start), the `on_time` post-window
tolerance widened from one hour to three hours, and the `late`
band sits between the new `on_time` ceiling and the configurable
`lateMinutes` knob. Heatmap consumers route the new bucket
through the compliant path; the dedicated `early` counter rides
on `DailyComplianceEntry` for downstream consumers that want to
distinguish ahead-of-window from on-window intakes.
- **Compliance heatmap fallback retired.** The v1.4.33 defensive
`looksClassifierBug` fallthrough is gone now that the underlying
classifier widening removes the every-dose-very-late mode the
fallback covered.
### Performance
- **`/api/gamification/achievements` consumer collapse.** Two of
three previous consumers shared the same TanStack queryKey
literal; the third carried a per-user discriminator so the cache
treated it as a fresh slot. Dashboard cold mount fired the
endpoint twice. Collapsing onto the shared hook trims that to a
single request; with the new server-side cache slot warm, the
duplicate-mount worst case rides single-flight coalescing.
- **GHCR build fires once per release tag.** The
`docker-publish.yml` workflow lost its `push.branches: [main]`
trigger; the `:latest` raw-tag enable rule moved onto the tag
ref so each release produces exactly one multi-arch build that
refreshes both the semver tags and the `:latest` alias.
- **NFT-trace warnings silenced.** `next.config.ts` gained an
`outputFileTracingExcludes` block that narrows the Turbopack
tracer away from the `MAXMIND_LICENSE_KEY` env-access path so
the trace-report no longer warns on every dependent route file.
### Refactor
- **Dashboard analytics consumers.** The two cards that previously
decided their own freshness copy now lean on the typed
`tileStaleDays()` helper and the additive `lastSeenByType` field
on the analytics response. Mood and BD-Zielbereich tiles keep the
default `null` because they have no underlying per-type freshness
signal.
### Accessibility
- **Dashboard hero CTA hits the WCAG 2.5.5 touch-target floor.**
The new "Ask the coach" pill carries `min-h-11` on mobile and
the matching `sm:min-h-9` desktop floor so the touch target
stays above 44 px on the Pixel 5 boundary.
- **Insights tab-strip Vitals parent reads correctly to assistive
tech.** Parent pill carries `aria-current="page"` whenever the
current URL matches one of its child vital sub-pages, plus a
`data-slot="insights-tab-strip-group"` hook for visual-regression
testing.
### Internal
- **Two e2e flake windows tightened.** `onboarding-flicker` swapped
its 12-sample 50 ms poll loop for a single Playwright
auto-retrying `toBeHidden({ timeout: 700 })` assertion so the
1-2 ms race window between the analytics-pending shell and
`useAuth().user` resolving collapses to a single retry slot.
`mobile-viewport` dropped `nav a[href]` from the touch-target
sweep (the bottom-nav owns its own WCAG spec) and gated the
44-px floor on `matchMedia('(min-width: 640px)').matches === false`
so the Pixel 5 viewport never tripped into the `sm:` tier during
WebKit render commits.
- **Server-cache observability.** Every `cached()` invocation passes
an `annotate` callback that lands two keys on the wide event:
`cache.<name>.outcome` ∈ `{ hit | miss | stampede }` and
`cache.<name>.key_hash` (non-reversible djb2 32-bit hash). Ops
can grep `cache.analytics.outcome` over any time window to
compute the hit ratio per deployment.
- **AASA followups verified.** The v1.4.33 `/.well-known/apple-app-site-association`
handler serves a direct 200 on every fronting origin; Apple's
CDN has ingested matching bodies for all three domains.
### Web freeze
v1.4.34 marks the last functional release of the web codebase before
the iOS native client launches. Subsequent v1.4.x tags carry security
fixes, dependency bumps, and hotfix-only corrections. New feature work
is paused until the iOS app clears Apple review, at which point a
v1.5.0 version-bump-only release tags the milestone. The Prisma
schema head comment and `.planning/v15-strategic-plan.md` §5
decision-log row pin the freeze trigger in-tree.
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.
Operator-side assistant feature-flag matrix, /insights tab-strip blocking fix on mobile, and the GHCR-propagation-race Coolify auto-deploy fix. Full notes in CHANGELOG.
Server-side prep for the next iOS TestFlight build. Five surfaces land together so the iOS engineer can pick them up in one cut-over: a locked externalId shape for daily-aggregated cumulative HealthKit rows, the SyncMode foundation columns + handshake + bulk-backfill endpoints, a first-class MoodEntry.note column that replaces the legacy tag-based workaround, a cross-source workout dedup helper, and two new MeasurementType enums plus the shared category overlay that drives the iOS permission picker and the future Insights nav.
Bug-fix and consistency follow-through after the v1.4.27 mobile sweep.
The headline is a tighter dashboard and insights surface: six widgets
retire from code entirely (GLP-1 tile, dashboard `<DrugLevelChart>`
mount, GLP-1 detail-page intake history + inventory, the
`<InsightAdvisorCard>` block and its regeneration affordance, the
weekly-report route), four broken edges close (workout-edit duplicate-
timestamp 409, BD-Zielbereich tile rebuilt on the shared
`<TrendCard>` primitive, `/insights/puls` chart timeout fallback,
sticky tab-strip scroll lock), and one consistency contract lands per
maintainer directive: every medication-list row, every medication-
detail section header, every trend tile, every Coach launch glyph,
every chart-height contract reads on one shape. The HealthScore card
grows to fill its column, gains an accessible `?` tooltip explaining
the delta, and drops the placeholder "Wochenbericht erstellen"
button. iOS contracts are intact; the only `/api/*` evolution is
additive (the `aggregate=monthly` grain on `/api/measurements` and an
internal-only `/api/internal/web-vitals` beacon route).
Public privacy-policy page at /privacy enumerates every Apple
HealthKit identifier the iOS app reads, names every third-party
sub-processor, and restates the EU MDR + GDPR boundaries. Unblocks
Apple BLOCKER 2 of the iOS App Store Connect submission by giving
the registered Privacy URL a real page to point at.
Largest feature delta in the v1.4.x line. Insights expands into seven dedicated metric sub-pages; GLP-1 medication tracking lands end-to-end (drug-level chart with Research Mode acknowledgment, EMA titration ladder, cadence + compliance, side-effect taxonomy, pen/vial inventory); cross-source priority becomes a two-axis resolver; per-user timezone threads through ten analytics surfaces; Withings activity + sleep v2 sync; onboarding rebuilt as a nested-route wizard; Personal Records ship end-to-end; Health Score provenance accordion; Coach runs native first-party prompts across six locales with a 1800-assertion refusal-probe matrix; OpenAPI drift gate hard-fails; multi-arch GHCR image. Migrations 0043 through 0060 are additive and forward-only. PROMPT_VERSION 4.24.0 → 4.25.0 with GROUND RULE 9 (refuses dose recommendations) and GROUND RULE 15 (refuses drug-level estimates with MDR + MDCG 2021-24 cites).
See CHANGELOG.md for the full per-section delta.
The v1.4.22 prose rewrite shipped without a way for users to signal
whether the new tone is landing. v1.4.23 H7 closes that loop with a
light-touch thumbs row + an admin first-week dashboard.
Schema: extend `RecommendationFeedback` with a polymorphic
`target_type` column (default "recommendation" so legacy rows stay
representative) plus a nullable `reason` field. New index on
(target_type, created_at) so the aggregator can scan per-surface
buckets without a full sweep. Migration `0040_recommendation_feedback_target_type`.
Route: POST /api/insights/chat/messages/:id/feedback. Body
{ rating: "helpful" | "unhelpful", reason?: string }. Verifies
ownership (404 on cross-account messages — no existence leak),
decrypts the message body for the dedup key, and writes the row with
target_type="coach" + a metric-source string encoding the user's
active prefs (`coach:tone=warm:verbosity=default`). Per-user 60/h rate
limit. 409 on a P2002 (already_rated) so a double-click is a no-op.
Aggregator: new `buildCoachFeedbackBuckets()` slices Coach rows by
(promptVersion, tone, verbosity). The summary blob the worker writes
to AppSettings.adminAiInsightsFeedbackSummary now carries both
`buckets` (pre-existing) and `coachBuckets` (H7 addition). Targets
the same singleton row so no second worker handler is needed.
Admin view: `/admin/coach-feedback` (new section slug) renders the
Coach buckets as a small DataTable (prompt version × tone × verbosity
× helpful × not-helpful × n × helpful-rate). Helpful-rate column
tinted via Dracula tokens (green ≥80%, yellow ≥50%, orange below).
First useful question this answers: "Is the v1.4.22 prose rewrite
landing well, or did the warm tone overshoot?" If the helpful-rate
for PROMPT_VERSION 4.23.x drops below 50% within the first 100
ratings, v1.4.24 walks the persona back.
UI: `<ThumbsUp>` / `<ThumbsDown>` buttons (lucide icons, no emoji)
under each persisted assistant bubble. Streaming bubbles + refusals
+ error bubbles skip the row so users can't rate before the message
lands on disk. Localised "Thanks for the signal." after submit.
OpenAPI: spec carries the new POST route + the request schema.
Coverage now includes the H4 + H7 routes — three additions over
v1.4.22 baseline.
Tests +9 unit (aggregator Coach-bucket slicing, admin section
render-paths) plus an integration round-trip for the route.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v1.4.22 B5 audit removed the placeholder cog from the Coach
drawer header because it had no real wiring. v1.4.23 H4 returns
the cog with an actual settings surface backed by a per-user
preferences row.
Schema: nullable JSONB column `users.coach_prefs_json` (migration
0038_coach_prefs). Null = legacy v1.4.22 defaults; non-null carries
`{ tone, verbosity, excludeMetrics, showEvidenceByDefault }` validated
by the Zod schema in `src/lib/validations/coach-prefs.ts`.
Routes: GET + PUT `/api/auth/me/coach-prefs`. PUT validates the body,
persists the canonical defaulted form, and emits an
`auth.me.coach-prefs.put` annotation with the four-knob meta. OpenAPI
registry covers both verbs; the spec regenerates without drift.
Coach prompt builder: `getCoachSystemPrompt(locale, prefs)` prepends a
per-user OVERRIDE paragraph for non-default tone (neutral / concise)
and verbosity (brief / detailed). Concise tone forces brief verbosity
regardless of the explicit selection — concise == short. Default
prefs leave the prompt unchanged so the v1.4.22 train stays in place.
Snapshot builder: reads the prefs row BEFORE any measurement query
and intersects `excludeMetrics` with the resolved scope. The model
never sees data the user opted out of — that's the load-bearing
order: filter first, query second.
UI: settings cog in the drawer header opens a right-edge `<Sheet>`
with three Select pickers (tone, verbosity) and one Switch column
(per-metric exclusions + showEvidenceByDefault). Save calls the PUT
route and invalidates the `["coach-prefs"]` query so the next reply
honours the new defaults without a reload. EN + DE i18n keys for
every label and hint.
Message-thread reads the same `["coach-prefs"]` cache and toggles the
`open` attribute on the evidence `<details>` element when
`showEvidenceByDefault` is true.
Tests +24 unit (coach-prefs schema, system-prompt prefix, snapshot
mock for new prefs row). Integration round-trip seeds a session,
PUTs a non-default prefs shape, asserts the GET reflects the saved
values + the DB row carries the canonical defaulted form.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the strict insight schema, Coach snapshot pipeline, and
provenance envelope with the seven Apple Health categories that landed
in Wave 2 (HRV, sleep, resting HR, steps, active energy, flights,
distance, VO2 max, body temperature). All fields are additive and
optional — web-only accounts pay zero extra SQL and the prompt's new
GROUND RULE 12 instructs the model to stay silent when the snapshot
doesn't carry the HealthKit blocks.
Ratchets PROMPT_VERSION 4.22.0 → 4.23.0 and refreshes the
sourceMetric / trendAnnotations enums in both EN + DE. Two new test
files pin the prompt body and the snapshot fan-out.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The measurements list now paints `APPLE_HEALTH` rows with a Dracula-
pink chip whose accent matches the iOS app's brand. The localised
label ships in both English and German via the new
`measurements.sourceAppleHealth` key; the helper falls back to the
SCREAMING_SNAKE value for unknown sources so a missing key would
surface immediately instead of silently rendering blank.
Admin backup restore guards (`MEASUREMENT_TYPES`,
`MEASUREMENT_SOURCES`) gain the seven new HealthKit-shaped types and
the new source value so a restore from a backup that contains Apple
Health rows doesn't get rejected as malformed.
A new badge regression test mirrors the existing BP-badge guard:
asserts the localised label paints, the Dracula-pink utility classes
land, and the bilingual locale still renders the same brand spelling.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extend MeasurementType with the seven HealthKit-shaped values the iOS
batch ingest needs (HRV, resting HR, active energy, flights climbed,
distance, VO2 max, body temperature) and add APPLE_HEALTH to the
MeasurementSource enum. Sleep duration unit shifts from hours to
minutes so per-stage HKCategorySample rows persist without precision
loss; the new SleepStage enum mirrors the existing GlucoseContext
column shape and a CHECK constraint scopes it to SLEEP_DURATION rows.
The composite unique index `(user_id, type, source, external_id)`
gives the new batch endpoint an idempotent dedup key while the legacy
`(user_id, type, measured_at, source)` index continues to back the
manual UI's no-duplicate-at-the-same-wall-clock guard. NULL externalId
rows stay distinct under Postgres semantics so manual entries never
collide.
Migration is strictly additive: new enum values append, new columns
are nullable, no rows mutate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six MEDs grouped per the W5 reconcile brief:
- Sec-MED-1: `hl_onboarding` cookie now `SameSite=Strict` (no
cross-site flow depends on it; sibling `healthlog_session` stays
`Lax` for the Withings OAuth callback).
- Sec-MED-2: `/onboarding` PUBLIC_PATHS match tightened from loose
`startsWith("/onboarding")` to exact path + subroute guard
(`pathname === "/onboarding" || pathname.startsWith("/onboarding/")`).
A future `/onboarding-export` route can no longer inherit the
silent-pass-through. Same guard mirrored on the redirect short-
circuit. Two new proxy tests pin the contract.
- Code-MED-3: lift `berlinDayKey()` from
`src/app/api/analytics/route.ts` to a shared
`src/lib/analytics/berlin-day.ts` and route the targets
sparkline-bucketing through it. The targets route used to bucket
by UTC `YYYY-MM-DD` so a 23:30-Berlin reading on Tuesday landed
in Wednesday's bucket. Four new unit tests pin DST + UTC-midnight
edge cases.
- Code-MED (streaming race): on slow connections SSE `done` fires
before the invalidate-refetch resolves, producing a 200-500ms
duplicate-bubble render. Add a 150ms grace window on the
message-thread that suppresses the persisted twin while the
streaming bubble is still in flight.
- Code-MED-2: drop dead i18n keys `insights.coach.settings` and
`insights.coach.settingsTooltip` (W3 removed the cog; the keys
had zero call sites and would carry confusing legacy if v1.4.23's
prompt-tuning panel picks a different key shape).
- Code-LOW-5: centralise `["user", "dashboardWidgets"]` query key
in `queryKeys.dashboardWidgets()` and migrate the three call
sites (dashboard, insights, settings/dashboard-layout-section).
Same query-key-collision discipline as `queryKeys.analytics()`.
Closes Sec-MED-1, Sec-MED-2, Code-MED-2, Code-MED-3, Code-MED-3
(streaming race), Code-LOW-5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Multiple accessibility + visual defects on `<InsightsSectionNav>`:
- No `aria-current` on the active tab — screen readers had no signal
the active section had changed.
- No `:focus-visible` ring — `transition-colors` only animated the
background, leaving keyboard focus invisible against `bg-primary/10`.
- Unconditional `scroll-smooth` ignored `prefers-reduced-motion`.
- `bg-background/80` let the hero gradient bleed through.
- `-mx-4` carried the bar past the safe horizontal-scroll boundary
on 280 px Galaxy Fold; combined with the inner `overflow-x-auto`
it produced a ghost scrollbar.
- 112 px `scroll-mt-28` was ~2.5× the actual sticky-strip height
(~40-44 px), pushing target headings well below the viewport top.
- IntersectionObserver picked the last entry in the batch instead of
the highest intersectionRatio, so the active pill jumped during
fast scrolls.
Add `aria-current="location"`, focus-visible ring, motion-reduce
gate around `scrollIntoView`, swap `bg-background/80` for `/95
backdrop-blur`, drop the negative margin (the parent handles
horizontal padding), hide the inner scrollbar, ratio-sort the
observer entries, and tune `scroll-mt-28` → `scroll-mt-16` on all
seven sections.
Add `insights.navAriaLabel` (EN: "Skip to section", DE: "Zu
Abschnitt springen").
Closes Design-H1, Design-H3, Code-MED-4, Design-LOW-4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cluster of the v1.4.21 carry-over items that each landed in ≤5 LOC.
- F-32 — drop the duplicate help paragraph beneath the dashboard
settings header; the page-level description already covers it
- F-33 / F-34 — replace `&` with "and"/"und" in the measurements and
mood subtitles
- F-35 — unify the test-notification button label
(notificationTestSend) with the user-facing notificationStatus.sendTest
- F-44 — drop the `LOKI_ENDPOINT` env-var name from the user-facing
monitoring help text; reads as "Loki integration" instead
- F-45 — admin/users table column reads "Name" instead of repeating
the page title "Users"
- F-47 — /notifications event-matrix first column reads "Event" instead
of repeating the page title "Notifications" — new notifications.eventColumn key
- F-50 — soften every DE error-toast "Bitte erneut versuchen" to
"Bitte versuche es erneut", matching the friendlier tone we already
use in the Coach error path
- F-58 — admin/reminders section header swaps Clock for Bell so the
card icon matches the admin-shell nav tile
- F-62 — DE dashboard `greetingSalutation` reads "Hallo, {name}"
instead of the English "Hi, {name}"
- D-CR-M-03 — formatTokenName regex broadens to non-Z ISO offsets so a
`+02:00` timestamp suffix renders as the locale-aware chunk like a
`Z` suffix already does
- D-DSGN-M-04 — moodLog "Copy" button uses common.copy instead of
stripping the `!` off common.copied
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>