diff --git a/CHANGELOG.md b/CHANGELOG.md index db0ad6449..f5de0a194 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,308 @@ # Changelog +## [1.4.15] — 2026-05-09 + +### Added + +- **Backups: vollständig.** Die Sicherung am Sonntag früh ist jetzt + nur noch der Anfang. `/admin/backups` lässt jeden Snapshot als + `.json` herunterladen, neue Snapshots hochladen und — hinter einer + dreifachen Bestätigung („RESTORE“ tippen + Dialog + Confirm) — + einen Snapshot direkt in die Datenbank zurückspielen. Jede + Operation (Run, Download, Upload, Restore inkl. Start/Failure) + landet im Audit-Log mit Akteur und Ziel-Snapshot. + _Backups become a full lifecycle. The Sunday-morning snapshot is + no longer the end of the road. `/admin/backups` lets you download + any snapshot as `.json`, upload a new one, and — behind a + triple-confirm gate (type “RESTORE” + dialog + confirm) — restore + a snapshot straight into the database. Every operation (run, + download, upload, restore including start/failure) is recorded in + the audit log with actor and target snapshot._ +- **Erfolge-Seite (`/achievements`) und Dashboard-Karte.** Dedizierte + Seite zeigt freigeschaltete und gesperrte Erfolge inklusive + Fortschrittsbalken (`{aktuell} / {ziel}`), gruppiert nach + Kategorie (Medikation, Vitalwerte, Sicherheit, Engagement). Auf + dem Dashboard sitzt eine zuschaltbare „Letzte Erfolge“-Karte; die + Sichtbarkeit lässt sich in den Layout-Einstellungen umschalten. + _New `/achievements` page lists locked + unlocked achievements + with progress bars (`{current} / {target}`), grouped by category + (medication, vitals, security, engagement). The dashboard gains a + toggleable “Recent achievements” card; visibility lives in the + Layout settings._ +- **Onboarding-Tour für Erstnutzer.** Beim ersten Dashboard-Besuch + läuft ein Spotlight-Walk-Through über die Tile-Strip, das + Quick-Add-Menü, Insights, Integrationen und Erfolge. Skippable mit + Esc, Tastatur-navigierbar, `prefers-reduced-motion` respektiert. + Erneut anstoßen jederzeit unter Settings → Account → „Tour + wiederholen“. + _New onboarding tour for first-run users. A spotlight walk-through + on first dashboard load points out the tile strip, quick-add menu, + insights, integrations, and achievements. Skippable with Esc, + fully keyboard-navigable, honors `prefers-reduced-motion`. Replay + the tour any time from Settings → Account._ +- **Doctor-Report v2.** Vor dem PDF-Generieren öffnet sich ein + Dialog mit konfigurierbarem Zeitraum (Voreinstellungen 90 Tage / 6 + Monate / 12 Monate, manuell editierbar, max. 2 Jahre) und einem + optionalen Praxis-Namen, der auf der Titelseite des PDFs + erscheint. Der Praxis-Name wird als Nutzervorgabe gespeichert. + _Doctor-report v2. Before the PDF is generated a dialog asks for + the date range (presets 90 days / 6 months / 12 months, manually + editable, max 2 years) and an optional practice name that appears + on the PDF cover page. The practice name is persisted as a user + preference._ +- **Auto-Deploy nach GHCR-Push.** Sobald die Docker-Publish-Action + ein neues `:latest`-Image hochgeladen hat, ruft sie den + Coolify-Deploy-Webhook auf. Coolify pingt anschließend einen + internen Endpoint mit dem Ergebnis; Erfolg/Fehler/Unbekannt + landen im Audit-Log, persistente Failures triggern eine + Telegram-Benachrichtigung an alle Admins. Kein manuelles + Force-Pull mehr nötig. + _Auto-deploy on GHCR push. Once the Docker publish action ships a + new `:latest`, it calls the Coolify deploy webhook. Coolify in + turn pings an internal endpoint with the result; success / failure + / unknown all land in the audit log; persistent failures send a + Telegram alert to every admin. No more manual force-pull on the + host._ +- **Empty-States überall.** Brandneue Accounts und leere Listen + bekommen jetzt überall einen sinnvollen Empty-State (Icon + + Beschreibung + Call-to-Action), wo vorher nur weiße Fläche stand. + Betroffen: Admin-Tabellen (Users, Backups, Login-Overview, + API-Tokens, Feedback, Audit-Preview), Messungs-, Stimmungs-, + Medikamenten- und Erfolge-Listen, Insights-Top-Level + BMI-ohne- + Größe, Dashboard für komplett leere Accounts. + _Empty states everywhere. Brand-new accounts and empty lists now + always show a sensible empty state (icon + description + CTA) + where there used to be just white space. Coverage: admin tables + (users, backups, login-overview, api-tokens, feedback, + audit-preview), measurement / mood / medication / achievement + lists, the insights top-level + BMI-without-height view, and the + dashboard for fully empty accounts._ +- **Status-Karten für Notification-Channels.** Settings → + Benachrichtigungen zeigt für jeden Kanal (Telegram, ntfy, Web + Push) den aktuellen Status (verbunden / fehlerhaft / deaktiviert), + letzten Erfolg, letzten Fehler, Counter aufeinanderfolgender + Fehlversuche und ggf. Deaktivierungsgrund. Buttons: „Wieder + aktivieren“ (nur wenn auto-deaktiviert) + „Test-Nachricht + senden“. + _Notification channel status UI. Settings → Notifications now + shows per-channel (Telegram, ntfy, Web Push) the current state + (connected / error / disabled), last success, last failure, + consecutive-failure counter, and disable reason if any. Buttons + for “Re-enable” (only when auto-disabled) and “Send test”._ +- **Status-Karten für Withings + moodLog.** Settings → Integrationen + zeigt für beide Provider Verbindungsstatus, letzten Sync, letzten + Fehler und einen Reauth-Hinweis, sobald ein Refresh-Token + abgelehnt wurde. Auf persistente Fehler (≥ 3 in Folge) bekommen + Admins eine Telegram-Benachrichtigung, sofern der Kanal aktiviert + ist. + _Withings + moodLog integration status UI. Settings → Integrations + shows per-provider the connection state, last sync, last error, + and a reauth hint as soon as a refresh-token was rejected. After + persistent failures (≥ 3 in a row) admins receive a Telegram alert + if the channel is enabled._ +- **Top-Tiles unabhängig wählbar.** In den Layout-Einstellungen lässt + sich für jede Metrik die Sichtbarkeit der Trend-Kachel oben und + des Charts unten getrennt steuern. Bestehende Layouts behalten ihr + bisheriges Verhalten (eine Schaltfläche steuert beide), bis du + explizit den neuen Schalter umlegst. + _Top dashboard tiles selectable. The layout settings now expose a + separate toggle for each metric’s upper-row tile and lower-row + chart. Existing saved layouts keep their previous behaviour (one + switch controls both surfaces) until you explicitly flip the new + toggle._ +- **7-Tage-Trend statt 7-Tage-Schnitt.** Auf jeder Tile-Kachel zeigt + ein farbiger Delta-Indikator `(±N.N)` neben dem Wert die + metrik-bewusste Veränderung der letzten 7 Tage gegenüber den + davor. Label umbenannt von „7-Tage-Schnitt“ auf „7-Tage-Trend“. + _7-day-trend instead of 7-day-average. Each tile gains a coloured + delta indicator `(±N.N)` next to the value, showing the + metric-aware change of the last 7 days vs. the prior 7. Label + renamed from “7d average” to “7d trend”._ + +### Changed + +- **`/admin`-Übersicht neu gestaltet.** Statt einer Section-Grid + zeigt die Übersicht jetzt eine Audit-Log-Vorschau (letzte + Einträge, Akteur, Zielressource) und einen + System-Status-Snapshot. Die Section-Grid wandert in die Sidebar. + _The `/admin` overview replaces the section grid with an + audit-log preview (recent entries, actor, target resource) and a + system-status snapshot. The section grid moved into the sidebar._ +- **Sidebar-Admin-Submenu kontextbewusst.** Die Admin-Untereinträge + klappen nur noch auf, wenn man tatsächlich auf einer + `/admin/*`-Route ist. Auf allen anderen Seiten bleibt die + Admin-Gruppe eingeklappt. + _The sidebar Admin sub-items only expand when you are actually on + an `/admin/*` route. Everywhere else the Admin group stays + collapsed._ +- **Stimmung-Chart aggregiert wie die anderen Metriken.** Über 90 + Tage wechselt das Stimmung-Diagramm automatisch auf wöchentliche + Aggregation, über 730 Tage auf monatliche — analog zu Gewicht und + Blutdruck. Im Header zeigt ein Chip die aktive Aggregationsstufe. + _The mood chart now auto-aggregates: weekly past 90 days, monthly + past 730 days — same thresholds as weight and BP. A chip in the + chart header shows the active aggregation level._ +- **KI-Insights mit harten Anti-Halluzinations-Leitplanken.** Die + Provider-Antwort wird gegen ein striktes Zod-Schema geprüft + (Zusammenfassung, Empfehlungen mit Pflichtfeld `metricSource`, + Zitate, Warnungen). Empfehlungen, die Datenpunkte zitieren, die + nicht in der Snapshot-Liste stehen, werden abgewiesen. Der + Wrapper retried genau einmal mit korrigierender Systemnachricht; + scheitert auch der zweite Versuch, kommt 422 zurück statt + unsauberen Ausgaben. Slug-Drift im Codex-Backend (z. B. Wechsel + `gpt-5-codex` → `gpt-5.3-codex`) ist jetzt durch eine + konfigurierbare Fallback-Kette + 1-Stunden-Cache abgefedert; alle + Slugs durch → strukturierter 503 mit `attempted[]`-Liste. + _AI insights gain hard anti-hallucination guardrails. Provider + responses are validated against a strict Zod schema (summary, + recommendations with mandatory `metricSource`, citations, + warnings). Recommendations citing data points not present in the + snapshot are rejected. The wrapper retries exactly once with a + corrective system message; if the second attempt also fails, it + returns 422 instead of unsanitised output. Codex-backend slug + drift (e.g. `gpt-5-codex` → `gpt-5.3-codex`) is now cushioned by + a configurable fallback chain + 1-hour positive cache; if every + slug fails the route returns a structured 503 with the + `attempted[]` list._ +- **`/admin/api-tokens`-Tabelle mobil.** Auf schmalen Viewports + blenden Spalten gestaffelt aus (zuletzt benutzt, erstellt am, + Owner) und der Owner-Username wandert inline neben den Token- + Namen, damit beim Ausblenden keine Information verloren geht. + _The `/admin/api-tokens` table is responsive on mobile. Narrow + viewports gate columns behind breakpoints (last-used, created-at, + owner) and the owner username falls back inline next to the token + name so hiding the column never drops data._ +- **Stimmung-Kachel mobil.** Auf schmalen Viewports zeigt die + Mood-Karte oben jetzt nur noch die große Score-Zahl + Label statt + der doppelten Darstellung von vorher. + _Mood tile on mobile shows only the large score number + label, + no more doubled rendering._ +- **Quick-Add-Submenu eindeutiger.** Die beiden Einträge im + Quick-Add-Menü heißen jetzt „Messung erfassen“ und „Stimmung + erfassen“ (DE) bzw. „Log measurement“ und „Log mood“ (EN) — + vorher beide schlicht „Hinzufügen“. + _Quick-add submenu disambiguated. The two entries are now called + “Messung erfassen” / “Stimmung erfassen” (DE) and “Log + measurement” / “Log mood” (EN) — previously both simply “Add”._ + +### Fixed + +- **Blutdruck-Zielbereich-Prozent.** Die Zielbereich-Kachel zählte + Sys/Dia-Paare nicht korrekt, sobald durch Importe die Sys/Dia- + Zeitstempel weiter als 5 Minuten auseinander lagen — Ergebnis + konnte 0 % anzeigen, obwohl die meisten Werte im Zielbereich + waren. Fix: Tagesschlüssel-Fallback (gleicher Berliner Tag) + + Paaranzahl als Nenner. + _Blood-pressure target-range percentage. The target-range tile + miscounted sys/dia pairs once import drift pushed sys/dia + timestamps more than 5 minutes apart — the tile could show 0 % + even though most readings were inside the target range. Fix: a + same-Berlin-day key fallback + pair-count as denominator._ +- **Onboarding-Flackern.** Auf bereits abgeschlossenen Accounts + rendert die Onboarding-Karte nicht mehr für ~500 ms, bevor sie + wieder verschwindet. Sie wird erst gemountet, sobald der + Analytics-Status geladen ist, und öffnet sich nicht mehr + automatisch, wenn alles erledigt ist. + _Onboarding flicker. On accounts where onboarding is already + complete, the onboarding card no longer renders for ~500 ms + before vanishing. It mounts only after the analytics status has + loaded, and refuses to auto-open when nothing is left to do._ +- **Skip-Link blockiert Logo nicht.** Der „Zum Inhalt + springen“-Skip-Link liegt zwar weiterhin am Anfang des Tabbing- + Reihenfolge, blockiert aber nicht mehr Klicks aufs Logo. + _The “Skip to content” skip-link still leads the tab order but + no longer blocks clicks on the logo._ +- **Bug-Report-Eintrag richtet sich nach dem Admin-Toggle.** Wenn + der Admin den Bug-Report deaktiviert hat, verschwindet der + Eintrag aus Sidebar, Bottom-Nav, Topbar und der Fehler-Detail- + „Bug melden“-Schaltfläche. + _The “Report a bug” entry follows the admin feature flag. With + bug reporting disabled, the entry vanishes from the sidebar, + bottom-nav, topbar, and the error-detail “Report bug” button._ +- **Feedback-Link richtet sich nach dem Admin-Toggle.** Wenn die + Feedback-Sammlung deaktiviert ist, ist auch der UI-Einstieg + weg. + _The Feedback link follows its admin feature flag. With feedback + disabled, the UI entry point disappears too._ +- **Notification-Channels deaktivieren sich auf harte Reject- + Fehler.** Antworten Telegram / ntfy / Web Push wiederholt mit + 410 oder anderen permanenten Reject-Codes, deaktiviert sich der + Kanal automatisch und schreibt einen Audit-Log-Eintrag. Status + - Re-Enable-Button finden sich in Settings → Benachrichtigungen. + _Notification channels auto-disable on persistent hard rejects. + If Telegram / ntfy / Web Push respond repeatedly with 410 or + other permanent reject codes, the channel disables itself and + writes an audit-log entry. Status + re-enable button live in + Settings → Notifications._ +- **Refresh-Token-Fehler markiert Integration als reauth-pflichtig.** + Bricht der Withings- oder moodLog-Refresh-Token-Tausch ab + (typischerweise nach 90 Tagen ohne Re-Auth), wechselt die + Integration in `error_reauth` und der Settings-Eintrag bittet um + Neu-Verbindung — statt jeden Sync-Tick weiter gegen den Provider + zu hämmern. + _Refresh-token failures flip the integration to “needs reauth”. + When the Withings or moodLog refresh-token exchange fails + (typically after 90 days without re-auth), the integration + switches to `error_reauth` and the Settings entry asks for + re-connect — instead of hammering the upstream every sync + tick._ +- **Charts blockieren das Wischen nicht mehr.** Auf Touch-Geräten + fingen die Recharts-Wrapper vertikales Wischen ab; das fühlte + sich auf langsamen Geräten wie Hänger an. Fix: `touch-action: +pan-y` auf den Chart-Containern, vertikaler Scroll geht jetzt + durch. + _Mobile chart containers no longer eat vertical scroll. On touch + devices the Recharts wrappers swallowed vertical pans; on slower + devices that felt like a scroll lockup. Fix: `touch-action: +pan-y` on the chart wrappers, vertical scroll passes through + again._ +- **`/admin/users` mobil ohne Overflow.** Auf schmalen Viewports + rendert die User-Tabelle als Card-List statt einer + horizontal-scrollenden Tabelle. + _`/admin/users` on mobile renders as a card list instead of a + horizontally-scrolling table._ +- **A11y-Verbesserungen.** Chart-Range-Buttons, Medikamenten- + Primary-Buttons, Mood-List-Mobile-Icon-Buttons, Login-CTAs, + Settings → Account-Passkey-Tabelle, Onboarding-Tour-Focus-Trap + + DE-Overflow + Backdrop-Ring — alles auf 44 px Mindest- + Tap-Target bzw. mit `aria-label` / Keyboard-Bedienbarkeit + versehen. + _Accessibility round of fixes: chart range buttons, medication + primary buttons, mood-list mobile icon buttons, login CTAs, + Settings → Account passkey table, onboarding-tour focus trap + + DE-locale overflow + backdrop ring — all aligned to 44 px tap + targets and labelled / keyboard-navigable._ + +### Security + +- **AI-Provider-Antworten gegen striktes Schema validiert.** Der + Insight-Wrapper akzeptiert nur Antworten, die das strikte + `aiInsightResponseSchema` erfüllen, und verweigert Empfehlungen, + die Datenpunkte zitieren, die nicht im übergebenen Snapshot + stehen. Schemafehler triggern einen einmaligen Retry mit + korrigierender Systemnachricht; ein zweiter Fehler liefert 422 + statt halluzinierter Ausgaben. + _AI provider responses are validated against a strict Zod + schema. The insight wrapper only accepts responses that satisfy + the strict `aiInsightResponseSchema`, and rejects recommendations + citing data points absent from the supplied snapshot. Schema + failures trigger a single retry with a corrective system message; + a second failure returns 422 instead of hallucinated output._ +- **Backup-Operationen vollständig auditiert.** Run, Download, + Upload und Restore — inklusive Start-Marker, Denial-Reasons und + Failures — schreiben jeweils einen Audit-Log-Eintrag mit Akteur + und Snapshot-ID. Restore wird zusätzlich durch fünf unabhängige + Gates abgesichert (Cookie-Auth-only-Admin, `confirm: "RESTORE"`- + Body, getypte UI-Bestätigung, Idempotency-Key, Pre-Tx-Enum- + Validation). + _Backup operations are fully audited. Run, download, upload, and + restore — including start markers, denial reasons, and failures + — each write an audit-log entry with actor and snapshot ID. + Restore is additionally protected by five independent gates + (cookie-only admin auth, `confirm: "RESTORE"` body, typed UI + confirmation, idempotency-key wrap, pre-transaction enum + validation)._ + ## [1.4.14] — 2026-05-09 ### Added @@ -94,15 +397,15 @@ Cross-Metric-Tagespaarung in den Insight-Buckets rechnete früher in reinem UTC und verlor an den Berliner DST-Grenzen einen Tag. Die neue Helper `dayOffsetToBerlinDayKey()` arbeitet DST-immun (Anker - über Berliner Y-M-D, dann Subtraktion in 86_400_000-ms-Schritten); + über Berliner Y-M-D, dann Subtraktion in 86*400_000-ms-Schritten); die Tagespaarung über Blutdruck, Gewicht und Stimmung ist jetzt über DST-Wechsel hinweg konsistent. - _DST transitions no longer shift daily values. The cross-metric + \_DST transitions no longer shift daily values. The cross-metric daily bucket pairing previously did naive UTC math and dropped a day at the Berlin DST boundary. A new `dayOffsetToBerlinDayKey()` helper anchors at Berlin Y-M-D and subtracts in 86_400_000-ms steps; bucket pairing across blood pressure, weight, and mood is - now DST-safe._ + now DST-safe.* - **KI-Provider-Fehler werden korrekt klassifiziert.** `/api/insights/generate` antwortet auf 401/403-Fehler des Providers jetzt mit 422 („KI-Anbieter hat den Request abgelehnt — bitte @@ -112,8 +415,7 @@ _AI provider errors are now correctly classified by `/api/insights/generate`: provider 401/403 → 422 (“AI provider rejected the request — check your API key in Settings → AI”), - provider 5xx → 503 (“AI provider temporarily unavailable”), 429 → - 429. Previously everything returned 500. Full error context still + provider 5xx → 503 (“AI provider temporarily unavailable”), 429 → 429. Previously everything returned 500. Full error context still lands in the structured logs._ - **Logging-Redactor schluckt keine harmlosen Wörter mehr.** Der Secret-Redactor maskierte zuvor jedes Vorkommen von `sk-…` — auch diff --git a/docs/audit/v1414-summary.md b/docs/audit/v1414-summary.md index 47666d308..7ae4bfec9 100644 --- a/docs/audit/v1414-summary.md +++ b/docs/audit/v1414-summary.md @@ -53,21 +53,21 @@ Reset einplanen. ### Release -| SHA | Summary | -| --------- | ------------------------------------ | -| `e5fae9b` | chore(release): v1.4.14 | +| SHA | Summary | +| --------- | --------------------------------------------------------------- | +| `e5fae9b` | chore(release): v1.4.14 | | `f0d53fb` | chore(release): rebrand artifacts as v1.4.14 (patch, not minor) | ### Codex-OAuth verification (Phase 1) -| SHA | Summary | -| --------- | ---------------------------------------------------------------------- | +| SHA | Summary | +| --------- | ------------------------------------------------------------------------ | | `5df74f7` | fix(ai): default Codex model to `gpt-5.3-codex` for ChatGPT-account auth | ### v1.4.6 deferred bugfix backlog (Phase 2) -| SHA | Summary | -| --------- | -------------------------------------------------------------------- | +| SHA | Summary | +| --------- | -------------------------------------------------------------------------------------------- | | `512a6a6` | fix(admin): wipe-all-data clears notification channels, push subs, telegram deletions (T2.1) | | `cb6a59a` | fix(insights): correct DST handling in cross-metric daily bucket pairing (T2.2) | | `5403821` | fix(insights): map AI provider errors to 422/503 instead of 500 (T2.3) | @@ -75,17 +75,17 @@ Reset einplanen. ### Admin Panel refactor (Phase 4b — folds T2.6 + T2.7) -| SHA | Summary | -| --------- | ------------------------------------------------------------------------ | +| SHA | Summary | +| --------- | ----------------------------------------------------------------------------------- | | `d8b71b3` | feat(admin): scaffold per-section dynamic routes (admin-shell + [section]/page.tsx) | -| `12b280b` | refactor(admin): move sections to dynamic-route pattern + reduce overview | -| `69c5225` | feat(admin): backups view + manual backup trigger (T2.6 deferred) | -| `5957e18` | feat(admin): users management with filters + force-logout action (T2.7 deferred) | -| `a34cd8c` | refactor(i18n): namespace admin keys under `admin.section..*` | -| `73965cd` | feat(admin): expandable sidebar nav for admin sections | -| `961e2a9` | fix(admin): status-card CTAs use real routes (not `#anchor`) | -| `7a47532` | feat(admin): redirect legacy section-anchor paths to dynamic routes | -| `1a7e3d6` | test(admin): tighten status-card-grid + add per-section render smoke | +| `12b280b` | refactor(admin): move sections to dynamic-route pattern + reduce overview | +| `69c5225` | feat(admin): backups view + manual backup trigger (T2.6 deferred) | +| `5957e18` | feat(admin): users management with filters + force-logout action (T2.7 deferred) | +| `a34cd8c` | refactor(i18n): namespace admin keys under `admin.section..*` | +| `73965cd` | feat(admin): expandable sidebar nav for admin sections | +| `961e2a9` | fix(admin): status-card CTAs use real routes (not `#anchor`) | +| `7a47532` | feat(admin): redirect legacy section-anchor paths to dynamic routes | +| `1a7e3d6` | test(admin): tighten status-card-grid + add per-section render smoke | ### UX polish (Phase 5) @@ -100,44 +100,44 @@ Reset einplanen. ### Multi-agent QA reconcile (Phase 6) -| SHA | Summary | -| --------- | ------------------------------------------------------------------------ | -| `88c8db1` | refactor: apply simplify-review safe-to-apply suggestions (11 of 11) | -| `b3f282a` | fix(security): block force-logout from targeting the admin's own session | -| `8e17f22` | fix(admin): rate-limit `/api/admin/backups/run` to 3/min per admin | -| `f0cfd26` | fix(security): redact standalone `hlk_` / `hlr_` native API tokens in logs | -| `bd24f13` | fix(a11y): drop duplicate h2 inside single-section `/admin/` routes | +| SHA | Summary | +| --------- | ---------------------------------------------------------------------------------- | +| `88c8db1` | refactor: apply simplify-review safe-to-apply suggestions (11 of 11) | +| `b3f282a` | fix(security): block force-logout from targeting the admin's own session | +| `8e17f22` | fix(admin): rate-limit `/api/admin/backups/run` to 3/min per admin | +| `f0cfd26` | fix(security): redact standalone `hlk_` / `hlr_` native API tokens in logs | +| `bd24f13` | fix(a11y): drop duplicate h2 inside single-section `/admin/` routes | | `499cfad` | fix(a11y): bump `/admin/users` + `/admin/login-overview` tap targets to WCAG 2.5.5 | -| `ef96e87` | fix(admin): default api-tokens + login-overview routes to expanded | +| `ef96e87` | fix(admin): default api-tokens + login-overview routes to expanded | ### End-to-end test coverage (Phase 3) -| SHA | Summary | -| --------- | -------------------------------------------------------- | -| `535465c` | test(e2e): seeded test user + auth storageState fixture | -| `3999ea7` | test(e2e): authenticated dashboard + add-measurement flow | -| `2b6f959` | test(e2e): mocked Codex device-flow + insights regenerate specs | -| `a106547` | test(e2e): doctor-report PDF + mobile-viewport smoke | +| SHA | Summary | +| --------- | --------------------------------------------------------------------- | +| `535465c` | test(e2e): seeded test user + auth storageState fixture | +| `3999ea7` | test(e2e): authenticated dashboard + add-measurement flow | +| `2b6f959` | test(e2e): mocked Codex device-flow + insights regenerate specs | +| `a106547` | test(e2e): doctor-report PDF + mobile-viewport smoke | | `ca3c599` | test(e2e): extend axe-core to authenticated surfaces; fix login regex | -| `7af1f59` | fix(a11y): close violations the new e2e axe-core sweep surfaced | +| `7af1f59` | fix(a11y): close violations the new e2e axe-core sweep surfaced | ### Performance (Phase 4) -| SHA | Summary | -| --------- | -------------------------------------------------------- | -| `bb2b1de` | perf(insights): defer Recharts ScatterChart imports via `next/dynamic` | +| SHA | Summary | +| --------- | ----------------------------------------------------------------------- | +| `bb2b1de` | perf(insights): defer Recharts ScatterChart imports via `next/dynamic` | | `519e36e` | perf(dashboard): skip checklist API fetches once onboarding is complete | -| `1a85da0` | fix(insights): preserve chart visual parity in deferred bundle | -| `41fa203` | docs(audit): v1.4.14 performance audit report | +| `1a85da0` | fix(insights): preserve chart visual parity in deferred bundle | +| `41fa203` | docs(audit): v1.4.14 performance audit report | ### CI / housekeeping -| SHA | Summary | -| --------- | -------------------------------------------------------- | +| SHA | Summary | +| --------- | ----------------------------------------------------------------- | | `3cacdf2` | fix(tests): share next/headers cookieJar across integration suite | -| `c847b80` | chore(format): trend-card test file prettier sweep | -| `badd380` | style(admin): apply prettier to phase-6 reconcile changes | -| `6b88e56` | chore(format): prettier sweep over phase-2 touched files | +| `c847b80` | chore(format): trend-card test file prettier sweep | +| `badd380` | style(admin): apply prettier to phase-6 reconcile changes | +| `6b88e56` | chore(format): prettier sweep over phase-2 touched files | --- diff --git a/docs/audit/v1415-auto-deploy.md b/docs/audit/v1415-auto-deploy.md index dcdc2325e..851b38950 100644 --- a/docs/audit/v1415-auto-deploy.md +++ b/docs/audit/v1415-auto-deploy.md @@ -42,18 +42,18 @@ The current application config (UUID `pg8wggwogo8c4gc4ks0kk4ss`, mode for `dockercompose`-typed apps. - `manual_webhook_secret_github: null` — no webhook secret configured today. -What Coolify *does* support natively (per the Coolify docs): +What Coolify _does_ support natively (per the Coolify docs): 1. **GitHub App auto-deploy**: pushes to `main` trigger an immediate redeploy. - For us this would deploy from a SHA *before* GHCR finishes building, since + For us this would deploy from a SHA _before_ GHCR finishes building, since GHCR runs in the same push event in parallel. Race-prone. 2. **Deploy webhook URL**: every Coolify resource exposes `GET https:///api/v1/deploy?uuid=` with - Bearer-token auth. This is a *trigger-only* surface — it queues a + Bearer-token auth. This is a _trigger-only_ surface — it queues a redeploy. Coolify treats this as an authenticated remote-control command; the recipe in the docs is "GitHub Actions builds the image, pushes to GHCR, then `curl --request GET '${{ secrets.COOLIFY_WEBHOOK }}' - --header 'Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}'`." +--header 'Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}'`." 3. **Notification webhook (outgoing)**: Coolify can POST a deployment-event payload to a configured URL on success and failure. This is how we get a notification back into the app for audit logging + admin alerts. @@ -86,8 +86,8 @@ proves unreliable):** Watchtower with a per-image label step, add a final `Trigger Coolify deploy` step that runs only on the publish path (push to `main` or to a `v*` tag — never on PRs). It uses `secrets.COOLIFY_WEBHOOK` (the full Bearer-protected URL with `?uuid=…`) - + `secrets.COOLIFY_TOKEN`. Failure of the deploy trigger does **not** - fail the build (the image is already published; deploy is a follow-up). + - `secrets.COOLIFY_TOKEN`. Failure of the deploy trigger does **not** + fail the build (the image is already published; deploy is a follow-up). - Document required secrets in `.env.example`. 2. **`feat(deploy): admin notification + audit log on deploy success/failure`** diff --git a/docs/audit/v1415-ci-reliability.md b/docs/audit/v1415-ci-reliability.md index 8aa18c063..2946f3d78 100644 --- a/docs/audit/v1415-ci-reliability.md +++ b/docs/audit/v1415-ci-reliability.md @@ -24,12 +24,12 @@ more than coverage breadth. ## Per-workflow pass-rate -| Workflow | Total | Success | Failure | Cancelled | Completed pass-rate | -| ------------------------------ | ----: | ------: | ------: | --------: | ------------------: | -| Integration tests | 47 | 47 | 0 | 0 | 100 % | -| Security & Quality | 46 | 43 | 3 | 0 | 93 % | -| Build & Publish Docker Image | 60 | 30 | 3 | 26 | 91 % | -| e2e | 47 | 0 | 34 | 13 | 0 % | +| Workflow | Total | Success | Failure | Cancelled | Completed pass-rate | +| ---------------------------- | ----: | ------: | ------: | --------: | ------------------: | +| Integration tests | 47 | 47 | 0 | 0 | 100 % | +| Security & Quality | 46 | 43 | 3 | 0 | 93 % | +| Build & Publish Docker Image | 60 | 30 | 3 | 26 | 91 % | +| e2e | 47 | 0 | 34 | 13 | 0 % | `Completed pass-rate = success / (success + failure)` — strips out the "cancelled" entries that come from the `concurrency.cancel-in-progress` @@ -89,6 +89,7 @@ in 17 minutes. Both runs wrote to the same gha cache scope (`buildkit` default) with `mode=max`, deadlocking the exporter. **Fix shipped (`249c42b`)**: + - `cache-to: type=gha,mode=max,scope=build-${{ github.ref_name }}` - `cache-from:` reads BOTH `build-${ref}` AND `build-main` so tag/PR runs warm-start off main without contending on writes. @@ -117,12 +118,12 @@ fragility. No action needed beyond what's already in the workflow. - [x] **e2e dark colorScheme**: `41945b2`. Unblocks the 0 % gate. - [x] **docker-publish per-ref cache + timeout**: `249c42b`. Bounds - deadlocks at 30 min and prevents the v1.4.14 hang from recurring. + deadlocks at 30 min and prevents the v1.4.14 hang from recurring. - [x] **post-publish-verify workflow**: `ffa4aac`. Pulls the just- - published image, boots it against an ephemeral Postgres, probes - `/api/version` + `/api/health`. Informational (no `continue-on-error` - blocking), surfaces "manifest pushed but image won't boot" failures - the build step alone can't see. + published image, boots it against an ephemeral Postgres, probes + `/api/version` + `/api/health`. Informational (no `continue-on-error` + blocking), surfaces "manifest pushed but image won't boot" failures + the build step alone can't see. ### Defer to v1.4.16 (out of C3 scope, owned elsewhere) diff --git a/docs/audit/v1415-empty-states.md b/docs/audit/v1415-empty-states.md index b4fb5c526..cbfa03dfc 100644 --- a/docs/audit/v1415-empty-states.md +++ b/docs/audit/v1415-empty-states.md @@ -12,21 +12,21 @@ not a single grey sentence floating in a card. ## Findings -| Route / surface | Component | State before | What was wrong | What changed | -| --- | --- | --- | --- | --- | -| `/admin/users` | `UserManagementSection` | Loading-only — when `users` resolved to `[]` the table simply rendered an empty `` with the header still visible. | Empty case never specified; on a fresh deployment with one admin self-row it never reproduced for Marc, but the `admin/user` filter did regularly. | New `` mounts inside the card when `filteredUsers.length === 0`, with `Users` icon, filter-aware copy, and a "Reset filter" CTA when a filter is active. | -| `/admin/backups` | `BackupsSection` | Plain `

No backups recorded yet.

`. | No icon, no CTA, no signal that the "Run backup now" button at the top is what to click. | Replaced with `` (`Database` icon) that includes a primary "Backup now" button — duplicates the header CTA but inside the empty card so the new admin doesn't need to look up. | -| `/admin/login-overview` | `LoginOverviewSection` | Plain `

No entries found.

` (filter-dependent). | When "Failed only" is selected on a healthy installation the user sees nothing — looked like a bug. | `` with `ScrollText` icon, filter-aware description, and a "Show all events" CTA when the failed-only filter is active. | -| `/admin/api-tokens` | `ApiTokenOverviewSection` | Plain `

No API tokens found.

`. | Doesn't tell admins that tokens are issued at login from native clients — they wonder if they need to create them somewhere. | `` with `Key` icon and a description pointing to native sign-in / Settings → Account API tokens. | -| `/admin/feedback` | `FeedbackInboxSection` | Plain `

No feedback in this category.

` per tab. | OK as text but the tab strip already shows `0` badges; the inner panel deserves the same compactness as other admin sections. | `` with `Inbox` icon. Per-tab description. | -| `/admin` overview | `RecentAuditPreview` | Plain `

No recent audit entries.

`. | Compact but icon-less — visually inconsistent with the audit-table fixes. | `` with `ScrollText` icon. | -| `/measurements` | `MeasurementList` | `
{t("measurements.noMeasurements")}
`. | No CTA and the top-level "Add measurement" button is the only way out. | `` with `Activity` icon, filter-aware description, "Add your first measurement" CTA. | -| `/mood` | `MoodList` | `
{t("mood.noEntries")}
`. | No CTA. | `` with `Smile` icon, filter-aware description, "Log your first mood" CTA. | -| `/medications` | `MedicationsPage` | Custom inline icon + button block. | Already friendly but didn't share the new primitive — diverging styles. | Refactored to `` with `Pill` icon, "Add first medication" CTA. Visual parity with the rest. | -| `/insights` (top-level) | `InsightsPage` | Plain `
{t("common.noData")}
` when `data` is undefined. | Looked like a hung page. | `` with `TrendingUp` icon, "Log a measurement" CTA → `/measurements`. | -| `/insights` (BMI no-height) | Inline `

` | "no data" when `user.heightCm == null`. | The user might wonder why the BMI section is empty. | `` with `Ruler` icon, copy points to `/settings/account` and a "Set height" CTA. | -| `/achievements` | `AchievementsPage` (no-grouped state) | Plain `

No achievements unlocked yet.

` inside a card. | Icon-less. The card after summary tiles needs the same visual cue we use elsewhere. | `` with `Trophy` icon — keeps the same `noneUnlockedYet` string to avoid C4 i18n churn. | -| Dashboard tile-strip / charts | `DashboardPage` | Tile strip + chart row both hidden when every metric has zero data; only `` plus the welcome banner show. The strip itself stays a 0-px row. | Brand-new users see a near-blank page below the welcome banner — feels broken even though the checklist is technically present. | New `` block rendered when `trendCards.length === 0 && charts.length === 0`. Routed to "Add your first measurement" via the existing quick-entry dialog (no nav). Does not race the checklist (which has its own self-gating). | +| Route / surface | Component | State before | What was wrong | What changed | +| ----------------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/admin/users` | `UserManagementSection` | Loading-only — when `users` resolved to `[]` the table simply rendered an empty `` with the header still visible. | Empty case never specified; on a fresh deployment with one admin self-row it never reproduced for Marc, but the `admin/user` filter did regularly. | New `` mounts inside the card when `filteredUsers.length === 0`, with `Users` icon, filter-aware copy, and a "Reset filter" CTA when a filter is active. | +| `/admin/backups` | `BackupsSection` | Plain `

No backups recorded yet.

`. | No icon, no CTA, no signal that the "Run backup now" button at the top is what to click. | Replaced with `` (`Database` icon) that includes a primary "Backup now" button — duplicates the header CTA but inside the empty card so the new admin doesn't need to look up. | +| `/admin/login-overview` | `LoginOverviewSection` | Plain `

No entries found.

` (filter-dependent). | When "Failed only" is selected on a healthy installation the user sees nothing — looked like a bug. | `` with `ScrollText` icon, filter-aware description, and a "Show all events" CTA when the failed-only filter is active. | +| `/admin/api-tokens` | `ApiTokenOverviewSection` | Plain `

No API tokens found.

`. | Doesn't tell admins that tokens are issued at login from native clients — they wonder if they need to create them somewhere. | `` with `Key` icon and a description pointing to native sign-in / Settings → Account API tokens. | +| `/admin/feedback` | `FeedbackInboxSection` | Plain `

No feedback in this category.

` per tab. | OK as text but the tab strip already shows `0` badges; the inner panel deserves the same compactness as other admin sections. | `` with `Inbox` icon. Per-tab description. | +| `/admin` overview | `RecentAuditPreview` | Plain `

No recent audit entries.

`. | Compact but icon-less — visually inconsistent with the audit-table fixes. | `` with `ScrollText` icon. | +| `/measurements` | `MeasurementList` | `
{t("measurements.noMeasurements")}
`. | No CTA and the top-level "Add measurement" button is the only way out. | `` with `Activity` icon, filter-aware description, "Add your first measurement" CTA. | +| `/mood` | `MoodList` | `
{t("mood.noEntries")}
`. | No CTA. | `` with `Smile` icon, filter-aware description, "Log your first mood" CTA. | +| `/medications` | `MedicationsPage` | Custom inline icon + button block. | Already friendly but didn't share the new primitive — diverging styles. | Refactored to `` with `Pill` icon, "Add first medication" CTA. Visual parity with the rest. | +| `/insights` (top-level) | `InsightsPage` | Plain `
{t("common.noData")}
` when `data` is undefined. | Looked like a hung page. | `` with `TrendingUp` icon, "Log a measurement" CTA → `/measurements`. | +| `/insights` (BMI no-height) | Inline `

` | "no data" when `user.heightCm == null`. | The user might wonder why the BMI section is empty. | `` with `Ruler` icon, copy points to `/settings/account` and a "Set height" CTA. | +| `/achievements` | `AchievementsPage` (no-grouped state) | Plain `

No achievements unlocked yet.

` inside a card. | Icon-less. The card after summary tiles needs the same visual cue we use elsewhere. | `` with `Trophy` icon — keeps the same `noneUnlockedYet` string to avoid C4 i18n churn. | +| Dashboard tile-strip / charts | `DashboardPage` | Tile strip + chart row both hidden when every metric has zero data; only `` plus the welcome banner show. The strip itself stays a 0-px row. | Brand-new users see a near-blank page below the welcome banner — feels broken even though the checklist is technically present. | New `` block rendered when `trendCards.length === 0 && charts.length === 0`. Routed to "Add your first measurement" via the existing quick-entry dialog (no nav). Does not race the checklist (which has its own self-gating). | ## Out of scope (deferred) @@ -43,7 +43,7 @@ not a single grey sentence floating in a card. with height but no weight is intentionally minimal — the chart itself shows the empty axis. - Tab strip `0` badges on `/admin/feedback` use the same `` per panel; not duplicating in this audit. +size="compact">` per panel; not duplicating in this audit. ## Tests added diff --git a/docs/audit/v1415-i18n-coverage.md b/docs/audit/v1415-i18n-coverage.md index be1e8ee9a..4daad2ac2 100644 --- a/docs/audit/v1415-i18n-coverage.md +++ b/docs/audit/v1415-i18n-coverage.md @@ -59,12 +59,12 @@ These appeared in the grep but are deliberately excluded: 5 entries where both locales return the key's last segment unchanged: -| Path | Value | Reason | -| ------------------------------- | --------- | --------------------------------------------------- | -| `settings.ntfy` | `ntfy` | brand name, not translated | -| `classifications.bp.Optimal` | `Optimal` | identical word in EN and DE | -| `classifications.bp.Normal` | `Normal` | identical | -| `classifications.pulse.Normal` | `Normal` | identical | +| Path | Value | Reason | +| --------------------------------- | --------- | --------------------------------------------------- | +| `settings.ntfy` | `ntfy` | brand name, not translated | +| `classifications.bp.Optimal` | `Optimal` | identical word in EN and DE | +| `classifications.bp.Normal` | `Normal` | identical | +| `classifications.pulse.Normal` | `Normal` | identical | | `classifications.bodyFat.Fitness` | `Fitness` | identical (anglicism in DE medical/fitness context) | A further 25 entries are EN==key but DE has an actual translation @@ -73,16 +73,16 @@ fine. ### v1.4.15 new namespaces — coverage check -| Namespace | Origin | EN keys | DE keys | Drift | Empty | -| ------------------------------- | -------------- | ------: | ------: | ----: | ----: | -| `admin.section.backups.*` | B1 backups | 35 | 35 | 0 | 0 | -| `achievements.*` | B4 | 106 | 106 | 0 | 0 | -| `onboarding.tour.*` | B5 | 18 | 18 | 0 | 0 | -| `admin.overview.*` | A2 | 16 | 16 | 0 | 0 | -| `settings.notificationStatus.*` | B3 | 13 | 13 | 0 | 0 | -| `settings.integrationStatus.*` | B2 | 6 | 6 | 0 | 0 | -| `doctorReport.*` | B6 | 90 | 90 | 0 | 0 | -| `quickAdd.*` (`common.add` etc) | A3 | (`common`) | — | 0 | 0 | +| Namespace | Origin | EN keys | DE keys | Drift | Empty | +| ------------------------------- | ---------- | ---------: | ------: | ----: | ----: | +| `admin.section.backups.*` | B1 backups | 35 | 35 | 0 | 0 | +| `achievements.*` | B4 | 106 | 106 | 0 | 0 | +| `onboarding.tour.*` | B5 | 18 | 18 | 0 | 0 | +| `admin.overview.*` | A2 | 16 | 16 | 0 | 0 | +| `settings.notificationStatus.*` | B3 | 13 | 13 | 0 | 0 | +| `settings.integrationStatus.*` | B2 | 6 | 6 | 0 | 0 | +| `doctorReport.*` | B6 | 90 | 90 | 0 | 0 | +| `quickAdd.*` (`common.add` etc) | A3 | (`common`) | — | 0 | 0 | ### Naming consistency diff --git a/docs/audit/v1416-ai-roadmap.md b/docs/audit/v1416-ai-roadmap.md index fe51ed113..7272cb48a 100644 --- a/docs/audit/v1416-ai-roadmap.md +++ b/docs/audit/v1416-ai-roadmap.md @@ -34,7 +34,7 @@ NOT done in v1.4.15 (deliberately deferred): - The `/api/insights/generate` route does NOT yet adopt `generateInsight()` — the existing UI consumes the rich legacy shape (`{summary, classification, findings, correlations, - dataQuality, ...}`). Migrating the consumer is v1.4.16 work because +dataQuality, ...}`). Migrating the consumer is v1.4.16 work because it requires UI changes to `` and `` to render the new strict shape. - The scope-hardened prompt at `src/lib/ai/prompts/insight-generator.ts` @@ -61,9 +61,9 @@ guideline document, with a rendered footnote in the UI. plain language for the German UI. - DGE healthy-weight (BMI bands). - AASM 2015 sleep duration consensus. - Each excerpt: `{ id, source, citation, excerpt, locale, validity }`. - Curated by hand (NOT scraped) — no quote longer than 200 characters - to stay inside fair-use. + Each excerpt: `{ id, source, citation, excerpt, locale, validity }`. + Curated by hand (NOT scraped) — no quote longer than 200 characters + to stay inside fair-use. 2. **Inject relevant excerpts as system context per request.** A pre-flight step `selectRelevantReferences(features)` looks at the @@ -76,7 +76,7 @@ guideline document, with a rendered footnote in the UI. `citations[]` entry gains an optional `referenceId` field pointing into the bundle — the model uses an excerpt's `id` to back its citation. The UI then renders a footnote `[1] ESH/ESC 2024, - p. 17 — "..."` next to the recommendation. +p. 17 — "..."` next to the recommendation. 4. **Hallucination guard for references.** Schema rejects any `referenceId` not in the bundle. The model cannot fabricate a @@ -112,7 +112,7 @@ Goal: a single provider's outage no longer breaks insights generation. that). Per-request total wall-clock budget: 60 s. 4. **Telemetry.** Wide-Event `ai.cascade.attempts: ["codex","admin-key"]` - + `ai.cascade.outcome: "success" | "all-failed"`. + - `ai.cascade.outcome: "success" | "all-failed"`. ## v1.4.16 — Per-recommendation explainability diff --git a/docs/codex-protocol-spec.md b/docs/codex-protocol-spec.md index 2a8fa7880..bef215f99 100644 --- a/docs/codex-protocol-spec.md +++ b/docs/codex-protocol-spec.md @@ -646,9 +646,9 @@ To eliminate that risk class, HealthLog's `CodexClient` walks an ordered chain o ```ts const DEFAULT_SLUG_FALLBACK_CHAIN = [ "gpt-5.3-codex", // current allow-list entry (verified 2026-05-09) - "gpt-5-codex", // historical default (rejected as of 2026-05 but safe to retry — backend may flip) - "gpt-5", // bare slug — rejected on ChatGPT-auth as of 2026-05 but lives on api-key auth - "gpt-4o", // ladder-rung for last-ditch capability + "gpt-5-codex", // historical default (rejected as of 2026-05 but safe to retry — backend may flip) + "gpt-5", // bare slug — rejected on ChatGPT-auth as of 2026-05 but lives on api-key auth + "gpt-4o", // ladder-rung for last-ditch capability ]; ``` @@ -656,15 +656,15 @@ The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-sep **Trigger condition** (when to walk to the next slug): -| Status | Action | -| ------------------------------------------------------------------------------------- | ----------- | -| `400` body matches `/not supported when using Codex with a ChatGPT account/i` | walk | -| `400` body contains `model_not_found` | walk | -| `400` body contains `does not exist` AND mentions a model name | walk | -| `404` (any body) | walk | -| `401` first time | refresh and re-try same slug; do NOT walk | -| `401` after refresh, `403`, `429`, `5xx` | propagate (don't walk) | -| `200` with SSE `response.failed.error.code === "invalid_prompt"` | propagate (request shape is wrong, slug is fine) | +| Status | Action | +| ----------------------------------------------------------------------------- | ------------------------------------------------ | +| `400` body matches `/not supported when using Codex with a ChatGPT account/i` | walk | +| `400` body contains `model_not_found` | walk | +| `400` body contains `does not exist` AND mentions a model name | walk | +| `404` (any body) | walk | +| `401` first time | refresh and re-try same slug; do NOT walk | +| `401` after refresh, `403`, `429`, `5xx` | propagate (don't walk) | +| `200` with SSE `response.failed.error.code === "invalid_prompt"` | propagate (request shape is wrong, slug is fine) | **Positive cache** — when a slug is observed working (HTTP 200 with at least one `response.completed` event), record `(slug, timestamp)` in a process-local Map. On the next request, jump straight to the cached slug. TTL: 1 hour. Cache scope: per-process (no DB write). diff --git a/package.json b/package.json index b24fa6454..c1b545057 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.4.14", + "version": "1.4.15", "private": true, "packageManager": "pnpm@10.31.0", "scripts": { diff --git a/src/app/api/internal/deploy-webhook/__tests__/route.test.ts b/src/app/api/internal/deploy-webhook/__tests__/route.test.ts index 10819a415..4019b6323 100644 --- a/src/app/api/internal/deploy-webhook/__tests__/route.test.ts +++ b/src/app/api/internal/deploy-webhook/__tests__/route.test.ts @@ -252,10 +252,7 @@ describe("POST /api/internal/deploy-webhook", () => { it("tolerates malformed (non-string) status fields by treating them as unknown", async () => { const res = await POST( - jsonRequest( - { status: 42 }, - { "x-deploy-webhook-secret": "test-secret" }, - ), + jsonRequest({ status: 42 }, { "x-deploy-webhook-secret": "test-secret" }), ); expect(res.status).toBe(200); const body = (await res.json()) as { outcome: string }; @@ -291,18 +288,24 @@ describe("POST /api/internal/deploy-webhook", () => { describe("GET /api/internal/deploy-webhook (reachability check)", () => { it("returns 200 with valid secret header", async () => { - const req = new NextRequest("http://localhost/api/internal/deploy-webhook", { - method: "GET", - headers: { "x-deploy-webhook-secret": "test-secret" }, - }); + const req = new NextRequest( + "http://localhost/api/internal/deploy-webhook", + { + method: "GET", + headers: { "x-deploy-webhook-secret": "test-secret" }, + }, + ); const res = await GET(req); expect(res.status).toBe(200); }); it("returns 401 without a secret", async () => { - const req = new NextRequest("http://localhost/api/internal/deploy-webhook", { - method: "GET", - }); + const req = new NextRequest( + "http://localhost/api/internal/deploy-webhook", + { + method: "GET", + }, + ); const res = await GET(req); expect(res.status).toBe(401); }); diff --git a/src/app/api/internal/deploy-webhook/route.ts b/src/app/api/internal/deploy-webhook/route.ts index 712aa89de..75849d993 100644 --- a/src/app/api/internal/deploy-webhook/route.ts +++ b/src/app/api/internal/deploy-webhook/route.ts @@ -52,7 +52,10 @@ interface NormalizedEvent { raw: CoolifyDeployPayload; } -function asStringOr(value: unknown, fallback: T): string | T { +function asStringOr( + value: unknown, + fallback: T, +): string | T { return typeof value === "string" && value.length > 0 ? value : fallback; } @@ -63,7 +66,8 @@ function classifyOutcome(status: unknown): NormalizedEvent["outcome"] { // "failed" / "error" / "stopped" for unhappy. Anything else is // logged but doesn't trigger a Telegram page (avoids alert fatigue // on intermediate states like "queued" or "in_progress"). - if (s === "success" || s === "finished" || s === "succeeded") return "success"; + if (s === "success" || s === "finished" || s === "succeeded") + return "success"; if (s === "failed" || s === "error" || s === "stopped" || s === "failure") { return "failure"; } @@ -148,7 +152,11 @@ export const POST = apiHandler(async (request: NextRequest) => { // Coolify's per-event burst (typically 1-2 per deploy) but well below // a hostile actor flooding to fish for a working secret. const ip = getClientIp(request); - const rl = await checkRateLimit(`deploy-webhook:${ip ?? "unknown"}`, 60, 60_000); + const rl = await checkRateLimit( + `deploy-webhook:${ip ?? "unknown"}`, + 60, + 60_000, + ); if (!rl.allowed) { return NextResponse.json( { status: "rate_limited" }, @@ -204,7 +212,10 @@ export const POST = apiHandler(async (request: NextRequest) => { await notifyAdminsOfFailure(event); } - return NextResponse.json({ status: "ok", outcome: event.outcome }, { status: 200 }); + return NextResponse.json( + { status: "ok", outcome: event.outcome }, + { status: 200 }, + ); }); /** diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 584b87e6f..8aa2833f8 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -822,7 +822,9 @@ export default function InsightsPage() { description={t("insights.emptyDescription")} action={ } /> diff --git a/src/components/admin/__tests__/backups-section-empty.test.tsx b/src/components/admin/__tests__/backups-section-empty.test.tsx index 0d34070b3..fa8f81b3b 100644 --- a/src/components/admin/__tests__/backups-section-empty.test.tsx +++ b/src/components/admin/__tests__/backups-section-empty.test.tsx @@ -64,9 +64,7 @@ describe("BackupsSection — empty state", () => { it("includes the localized title and description", () => { const html = render(); expect(html).toContain("No backups yet"); - expect(html).toContain( - "Backups run automatically every Sunday at 03:00", - ); + expect(html).toContain("Backups run automatically every Sunday at 03:00"); }); it("exposes the Backup-now CTA inside the empty card", () => { diff --git a/src/components/admin/__tests__/user-management-empty.test.tsx b/src/components/admin/__tests__/user-management-empty.test.tsx index d948c7c18..e40f4c5cb 100644 --- a/src/components/admin/__tests__/user-management-empty.test.tsx +++ b/src/components/admin/__tests__/user-management-empty.test.tsx @@ -75,9 +75,7 @@ describe("UserManagementSection — empty state", () => { it("carries the localized title and description", () => { const html = render(); expect(html).toContain("No users in this view"); - expect(html).toContain( - "There are no users matching the current filter.", - ); + expect(html).toContain("There are no users matching the current filter."); }); it("does not render the reset CTA when the default filter is active", () => { diff --git a/src/components/admin/user-management-section.tsx b/src/components/admin/user-management-section.tsx index fed449083..f60bdda09 100644 --- a/src/components/admin/user-management-section.tsx +++ b/src/components/admin/user-management-section.tsx @@ -296,105 +296,109 @@ export function UserManagementSection() { /> ) : ( - <> - {/* Desktop table: kept verbatim — only the wrapping + <> + {/* Desktop table: kept verbatim — only the wrapping `md:block hidden` swaps it out for the card-list below at `< md`. Hiding the table-only wrapper (instead of just the cells) saves DOM weight on mobile too. */} -
- - - - - - - - - - - - - {filteredUsers.map((u, i) => ( - - - - - - - +
+
- {t("admin.users")} - - {t("admin.userEmail")} - - {t("admin.userRole")} - - {t("admin.userPasskeys")} - - {t("admin.userCreated")} - - {t("admin.userActions")} -
{u.username} - {u.email || "—"} - - - {u.role} - - {u.passkeyCount} - {formatDate(u.createdAt)} - -
- {renderUserActions(u)} -
-
+ + + + + + + + - ))} - -
+ {t("admin.users")} + + {t("admin.userEmail")} + + {t("admin.userRole")} + + {t("admin.userPasskeys")} + + {t("admin.userCreated")} + + {t("admin.userActions")} +
-
+ + + {filteredUsers.map((u, i) => ( + + {u.username} + + {u.email || "—"} + + + + {u.role} + + + + {u.passkeyCount} + + + {formatDate(u.createdAt)} + + +
+ {renderUserActions(u)} +
+ + + ))} + + + - {/* Mobile card list: each user renders as a self-contained + {/* Mobile card list: each user renders as a self-contained card with the meta line + a flex-wrapping action row. All four actions stay visible and tap-targetable; nothing is hidden behind a horizontal scroll. */} -
    - {filteredUsers.map((u) => ( -
  • -
    -
    -
    - {u.username} - - {u.role} - +
      + {filteredUsers.map((u) => ( +
    • +
      +
      +
      + + {u.username} + + + {u.role} + +
      +

      + {u.email || "—"} +

      +

      + {t("admin.userPasskeys")}: {u.passkeyCount} ·{" "} + {formatDate(u.createdAt)} +

      -

      - {u.email || "—"} -

      -

      - {t("admin.userPasskeys")}: {u.passkeyCount} ·{" "} - {formatDate(u.createdAt)} -

      -
    -
    - {renderUserActions(u)} -
    -
  • - ))} -
- +
+ {renderUserActions(u)} +
+ + ))} + + ) ) : (
diff --git a/src/components/charts/__tests__/mood-chart-aggregation.test.tsx b/src/components/charts/__tests__/mood-chart-aggregation.test.tsx index 171888405..24d7e871e 100644 --- a/src/components/charts/__tests__/mood-chart-aggregation.test.tsx +++ b/src/components/charts/__tests__/mood-chart-aggregation.test.tsx @@ -9,13 +9,13 @@ import { renderToStaticMarkup } from "react-dom/server"; * exported so the contract can be locked in unit tests. */ -import { - aggregateMoodEntries, - pickMoodBucket, - MoodChart, -} from "../mood-chart"; +import { aggregateMoodEntries, pickMoodBucket, MoodChart } from "../mood-chart"; -function dayEntries(start: string, count: number, score = 4): Array<{ +function dayEntries( + start: string, + count: number, + score = 4, +): Array<{ date: string; score: number; }> { diff --git a/src/components/charts/__tests__/trend-card-7d-trend.test.tsx b/src/components/charts/__tests__/trend-card-7d-trend.test.tsx index c2322e934..a9567c540 100644 --- a/src/components/charts/__tests__/trend-card-7d-trend.test.tsx +++ b/src/components/charts/__tests__/trend-card-7d-trend.test.tsx @@ -53,7 +53,7 @@ describe(" 7-day trend label", () => { expect(html).toContain("7d trend"); // Legacy avg7 short label must NOT appear in the trend variant — // this is the whole point of the rename. - expect(html).not.toContain('>7d:<'); + expect(html).not.toContain(">7d:<"); }); it("paints the trend-label in German", () => { diff --git a/src/components/doctor-report/__tests__/doctor-report-dialog.test.tsx b/src/components/doctor-report/__tests__/doctor-report-dialog.test.tsx index 1566bb1dd..5f50901a3 100644 --- a/src/components/doctor-report/__tests__/doctor-report-dialog.test.tsx +++ b/src/components/doctor-report/__tests__/doctor-report-dialog.test.tsx @@ -48,9 +48,15 @@ describe("doctor-report dialog — i18n contract", () => { it(`exposes every doctor-report dialog key in ${locale}.json`, () => { const messages = loadMessages(locale); const root = messages.doctorReport as Record | undefined; - expect(root, `doctorReport namespace missing in ${locale}.json`).toBeDefined(); + expect( + root, + `doctorReport namespace missing in ${locale}.json`, + ).toBeDefined(); const dialog = root!.dialog as Record | undefined; - expect(dialog, `doctorReport.dialog missing in ${locale}.json`).toBeDefined(); + expect( + dialog, + `doctorReport.dialog missing in ${locale}.json`, + ).toBeDefined(); for (const key of REQUIRED_KEYS) { expect( dialog![key], diff --git a/src/components/doctor-report/doctor-report-dialog.tsx b/src/components/doctor-report/doctor-report-dialog.tsx index 782023c44..acdbe654f 100644 --- a/src/components/doctor-report/doctor-report-dialog.tsx +++ b/src/components/doctor-report/doctor-report-dialog.tsx @@ -107,12 +107,18 @@ export function DoctorReportDialog({ const validation = useMemo(() => { if (!startDate || !endDate) { - return { ok: false as const, key: "doctorReport.dialog.errorInvalidDate" }; + return { + ok: false as const, + key: "doctorReport.dialog.errorInvalidDate", + }; } const start = new Date(startDate); const end = new Date(endDate); if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { - return { ok: false as const, key: "doctorReport.dialog.errorInvalidDate" }; + return { + ok: false as const, + key: "doctorReport.dialog.errorInvalidDate", + }; } if (end.getTime() < start.getTime()) { return { @@ -122,7 +128,10 @@ export function DoctorReportDialog({ } const spanDays = Math.ceil((end.getTime() - start.getTime()) / ONE_DAY_MS); if (spanDays > MAX_RANGE_DAYS) { - return { ok: false as const, key: "doctorReport.dialog.errorRangeTooLong" }; + return { + ok: false as const, + key: "doctorReport.dialog.errorRangeTooLong", + }; } return { ok: true as const }; }, [startDate, endDate]); diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index 97bb9cc41..4b1ceefb9 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -124,7 +124,7 @@ export function AuthShell({ children }: { children: React.ReactNode }) { */} {t("nav.skipToContent")} diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index 52260b43c..b7c5eba5b 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -74,7 +74,12 @@ const navItems = [ icon: Lightbulb, tourId: "nav-insights", }, - { href: "/targets", tKey: "nav.targets", icon: Target, tourId: "nav-targets" }, + { + href: "/targets", + tKey: "nav.targets", + icon: Target, + tourId: "nav-targets", + }, { href: "/achievements", tKey: "nav.achievements", diff --git a/src/components/settings/__tests__/dashboard-layout-section.test.tsx b/src/components/settings/__tests__/dashboard-layout-section.test.tsx index 835f452d3..708e281a8 100644 --- a/src/components/settings/__tests__/dashboard-layout-section.test.tsx +++ b/src/components/settings/__tests__/dashboard-layout-section.test.tsx @@ -47,10 +47,7 @@ describe(" — tile + chart split", () => { }); it("renders both column headers in German", () => { - const html = render( - , - "de", - ); + const html = render(, "de"); expect(html).toContain("Kachel"); expect(html).toContain("Chart"); }); diff --git a/src/lib/__tests__/dashboard-layout.test.ts b/src/lib/__tests__/dashboard-layout.test.ts index 64db2a241..ca37a22c5 100644 --- a/src/lib/__tests__/dashboard-layout.test.ts +++ b/src/lib/__tests__/dashboard-layout.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_DASHBOARD_LAYOUT, resolveDashboardLayout, serializeDashboardLayout, + type DashboardLayout, } from "@/lib/dashboard-layout"; /** @@ -73,15 +74,13 @@ describe("resolveDashboardLayout() — tileVisible upgrade", () => { it("defaults the layout when raw input is null / not an object", () => { expect(resolveDashboardLayout(null)).toEqual(DEFAULT_DASHBOARD_LAYOUT); - expect(resolveDashboardLayout(undefined)).toEqual( - DEFAULT_DASHBOARD_LAYOUT, - ); + expect(resolveDashboardLayout(undefined)).toEqual(DEFAULT_DASHBOARD_LAYOUT); }); }); describe("serializeDashboardLayout() — tileVisible persistence", () => { it("persists tileVisible explicitly so a re-read keeps the user's choice", () => { - const layout = { + const layout: DashboardLayout = { version: 1, widgets: [ // User toggled tileVisible to false but kept the chart visible. @@ -94,7 +93,7 @@ describe("serializeDashboardLayout() — tileVisible persistence", () => { }); it("derives tileVisible from visible when the field is missing on input", () => { - const layout = { + const layout: DashboardLayout = { version: 1, widgets: [ // Field omitted — serialize must not produce undefined; mirror. @@ -106,7 +105,7 @@ describe("serializeDashboardLayout() — tileVisible persistence", () => { }); it("normalizes order to 0-based dense", () => { - const layout = { + const layout: DashboardLayout = { version: 1, widgets: [ { id: "weight", visible: true, tileVisible: true, order: 5 }, diff --git a/src/lib/__tests__/i18n-locale-integrity.test.ts b/src/lib/__tests__/i18n-locale-integrity.test.ts index b11cb57dc..f96f12488 100644 --- a/src/lib/__tests__/i18n-locale-integrity.test.ts +++ b/src/lib/__tests__/i18n-locale-integrity.test.ts @@ -214,7 +214,11 @@ describe("i18n locale file integrity", () => { }); // Flat helper used by the value-quality checks below. - function flattenValues(obj: unknown, prefix: string, out: [string, string][]) { + function flattenValues( + obj: unknown, + prefix: string, + out: [string, string][], + ) { if (obj == null || typeof obj !== "object") return; for (const [k, v] of Object.entries(obj as Record)) { const key = prefix ? `${prefix}.${k}` : k; @@ -295,24 +299,21 @@ describe("i18n locale file integrity", () => { it.each([ ["en", EN_PATH], ["de", DE_PATH], - ])( - "%s locale has no TODO/FIXME placeholders in values", - (_locale, path) => { - const data = JSON.parse(readFileSync(path, "utf8")) as Record< - string, - unknown - >; - const flat: [string, string][] = []; - flattenValues(data, "", flat); - const todoRe = /\b(TODO|FIXME|XXX|TBD)\b/; - const todos = flat - .filter(([, v]) => todoRe.test(v)) - .map(([k, v]) => `${k} = ${JSON.stringify(v)}`); - expect( - todos, - `Found TODO/FIXME/XXX/TBD placeholders in translation values:\n` + - todos.map((s) => ` ${s}`).join("\n"), - ).toEqual([]); - }, - ); + ])("%s locale has no TODO/FIXME placeholders in values", (_locale, path) => { + const data = JSON.parse(readFileSync(path, "utf8")) as Record< + string, + unknown + >; + const flat: [string, string][] = []; + flattenValues(data, "", flat); + const todoRe = /\b(TODO|FIXME|XXX|TBD)\b/; + const todos = flat + .filter(([, v]) => todoRe.test(v)) + .map(([k, v]) => `${k} = ${JSON.stringify(v)}`); + expect( + todos, + `Found TODO/FIXME/XXX/TBD placeholders in translation values:\n` + + todos.map((s) => ` ${s}`).join("\n"), + ).toEqual([]); + }); }); diff --git a/src/lib/ai/__tests__/citation-enforcement.test.ts b/src/lib/ai/__tests__/citation-enforcement.test.ts index 50f27145c..f9a0cbf41 100644 --- a/src/lib/ai/__tests__/citation-enforcement.test.ts +++ b/src/lib/ai/__tests__/citation-enforcement.test.ts @@ -83,7 +83,11 @@ describe("citation-from-data — schema-level enforcement", () => { id: "rec-1", text: "Walk more.", severity: "suggestion", - metricSource: { type: "activity", timeRange: "last7days", summary: "" }, + metricSource: { + type: "activity", + timeRange: "last7days", + summary: "", + }, }, ], }; @@ -158,7 +162,10 @@ describe("citation-from-data — cross-check enforcement", () => { const missing = findUncitedRecommendations(orphan); expect(missing).toHaveLength(1); expect(missing[0].recommendationId).toBe("rec-orphan"); - expect(missing[0].missing).toEqual({ type: "diet", timeRange: "last7days" }); + expect(missing[0].missing).toEqual({ + type: "diet", + timeRange: "last7days", + }); }); it("partial-key match on type alone is NOT enough — timeRange must also match", () => { diff --git a/src/lib/ai/__tests__/generate-insight.test.ts b/src/lib/ai/__tests__/generate-insight.test.ts index f929ca57f..e6760651d 100644 --- a/src/lib/ai/__tests__/generate-insight.test.ts +++ b/src/lib/ai/__tests__/generate-insight.test.ts @@ -145,10 +145,7 @@ describe("generateInsight wrapper", () => { it("retries once with correction message and succeeds on second attempt", async () => { const provider = new MockAIProvider({ - responses: [ - "definitely not json", - JSON.stringify(validResponse), - ], + responses: ["definitely not json", JSON.stringify(validResponse)], }); const outcome = await generateInsight(provider, { systemPrompt: "sys", @@ -203,7 +200,10 @@ describe("generateInsight wrapper", () => { citations: [], // empty — fails cross-check }; const provider = new MockAIProvider({ - responses: [JSON.stringify(orphanResponse), JSON.stringify(validResponse)], + responses: [ + JSON.stringify(orphanResponse), + JSON.stringify(validResponse), + ], }); const outcome = await generateInsight(provider, { systemPrompt: "sys", diff --git a/src/lib/ai/__tests__/insight-generator-prompt.test.ts b/src/lib/ai/__tests__/insight-generator-prompt.test.ts index 57c00ad38..bcc95b21c 100644 --- a/src/lib/ai/__tests__/insight-generator-prompt.test.ts +++ b/src/lib/ai/__tests__/insight-generator-prompt.test.ts @@ -5,10 +5,7 @@ import { OUT_OF_SCOPE_REFUSAL_EN, OUT_OF_SCOPE_REFUSAL_DE, } from "../prompts/insight-generator"; -import { - aiInsightResponseSchema, - findUncitedRecommendations, -} from "../schema"; +import { aiInsightResponseSchema, findUncitedRecommendations } from "../schema"; import { generateInsight } from "../generate-insight"; import { MockAIProvider } from "../mock-client"; diff --git a/src/lib/ai/codex-client.ts b/src/lib/ai/codex-client.ts index c6dde12af..f05a8d615 100644 --- a/src/lib/ai/codex-client.ts +++ b/src/lib/ai/codex-client.ts @@ -252,7 +252,8 @@ export class CodexClient implements AIProvider { upstream: "codex", model: attempted[attempted.length - 1] ?? null, bodyExcerpt: lastSlugRejectionError - ? (lastSlugRejectionError as Error & { bodyExcerpt?: string }).bodyExcerpt + ? (lastSlugRejectionError as Error & { bodyExcerpt?: string }) + .bodyExcerpt : null, attempted, }); diff --git a/src/lib/ai/generate-insight.ts b/src/lib/ai/generate-insight.ts index 4383c1c0c..401780083 100644 --- a/src/lib/ai/generate-insight.ts +++ b/src/lib/ai/generate-insight.ts @@ -42,10 +42,7 @@ export interface GenerateInsightOutcome { * what went wrong on the first attempt. Includes the violated zod * issues verbatim (truncated to 1KB) so the model can self-correct. */ -function buildRetryCorrectionMessage( - reason: string, - details: string, -): string { +function buildRetryCorrectionMessage(reason: string, details: string): string { return ` Your previous response did not satisfy the required JSON schema. Reason: ${reason} @@ -144,7 +141,12 @@ export async function generateInsight( ): Promise { const first = await tryOnce(provider, params); if (first.ok && first.parsed) { - return { parsed: first.parsed, raw: first.raw, attempts: 1, retried: false }; + return { + parsed: first.parsed, + raw: first.raw, + attempts: 1, + retried: false, + }; } // Retry-once with corrective context appended to the user prompt. diff --git a/src/lib/ai/schema.ts b/src/lib/ai/schema.ts index 9e5c64f71..0498e069e 100644 --- a/src/lib/ai/schema.ts +++ b/src/lib/ai/schema.ts @@ -59,7 +59,9 @@ export const metricSourceSchema = z.object({ * 12 readings" or "compliance 0.71 / 30d". Empty value rejected so * the model cannot fake a citation. */ - summary: z.string().min(1, "metricSource.summary required (zero-hallucination)"), + summary: z + .string() + .min(1, "metricSource.summary required (zero-hallucination)"), /** Sample count behind the value, when known. */ n: z.number().int().nonnegative().optional(), }); @@ -156,9 +158,10 @@ export class InsightSchemaError extends Error { * Returns the list of missing-citation issues; an empty list means * the response is internally consistent. */ -export function findUncitedRecommendations( - parsed: AIInsightResponse, -): Array<{ recommendationId: string; missing: { type: string; timeRange: string } }> { +export function findUncitedRecommendations(parsed: AIInsightResponse): Array<{ + recommendationId: string; + missing: { type: string; timeRange: string }; +}> { const cited = new Set( parsed.citations.map((c) => `${c.type}::${c.timeRange}`), ); diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index 80b27bc8d..3d0a1efa6 100644 Binary files a/src/lib/doctor-report-data.ts and b/src/lib/doctor-report-data.ts differ diff --git a/src/lib/integrations/__tests__/admin-alert.test.ts b/src/lib/integrations/__tests__/admin-alert.test.ts index 861a11db2..8a19c5179 100644 --- a/src/lib/integrations/__tests__/admin-alert.test.ts +++ b/src/lib/integrations/__tests__/admin-alert.test.ts @@ -58,9 +58,7 @@ describe("formatAdminAlertPayload — moodLog transient", () => { subjectLabel: "marc@example.com", }); expect(out.title).toBe("moodLog sync failing for marc@example.com"); - expect(out.message).toContain( - "Action: investigate the upstream service.", - ); + expect(out.message).toContain("Action: investigate the upstream service."); expect(out.message).toContain("(http_502)"); }); });