mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
chore(release): v1.4.15
Bumps version to 1.4.15 and adds the corresponding CHANGELOG entry covering backup completeness, achievements UI, onboarding tour, doctor-report v2, auto-deploy on GHCR push, empty-states sweep, notification + integration status UI, AI hallucination guardrails, mobile UX fixes, and the BD-Zielbereich percentage fix. Includes the formatting drift left over by the v1.4.15 development phases (Prettier sweep on touched files) and a typecheck fix in `dashboard-layout.test.ts` so `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm test`, and `pnpm test:integration` all pass green on the release tag. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
1b1214bcb3
commit
4dcaa08b42
32 files changed
+639
-295
No files matched your search
+307
-5
@@ -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
|
||||
|
||||
+42
-42
@@ -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.<slug>.*` |
|
||||
| `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.<slug>.*` |
|
||||
| `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/<slug>` 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/<slug>` 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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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://<coolify-fqdn>/api/v1/deploy?uuid=<resource-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`**
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 `<tbody>` 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 `<EmptyState>` 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 `<p>No backups recorded yet.</p>`. | No icon, no CTA, no signal that the "Run backup now" button at the top is what to click. | Replaced with `<EmptyState>` (`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 `<p>No entries found.</p>` (filter-dependent). | When "Failed only" is selected on a healthy installation the user sees nothing — looked like a bug. | `<EmptyState>` with `ScrollText` icon, filter-aware description, and a "Show all events" CTA when the failed-only filter is active. |
|
||||
| `/admin/api-tokens` | `ApiTokenOverviewSection` | Plain `<p>No API tokens found.</p>`. | Doesn't tell admins that tokens are issued at login from native clients — they wonder if they need to create them somewhere. | `<EmptyState>` with `Key` icon and a description pointing to native sign-in / Settings → Account API tokens. |
|
||||
| `/admin/feedback` | `FeedbackInboxSection` | Plain `<p>No feedback in this category.</p>` per tab. | OK as text but the tab strip already shows `0` badges; the inner panel deserves the same compactness as other admin sections. | `<EmptyState size="compact">` with `Inbox` icon. Per-tab description. |
|
||||
| `/admin` overview | `RecentAuditPreview` | Plain `<p>No recent audit entries.</p>`. | Compact but icon-less — visually inconsistent with the audit-table fixes. | `<EmptyState size="compact" variant="plain">` with `ScrollText` icon. |
|
||||
| `/measurements` | `MeasurementList` | `<div class="border-dashed">{t("measurements.noMeasurements")}</div>`. | No CTA and the top-level "Add measurement" button is the only way out. | `<EmptyState>` with `Activity` icon, filter-aware description, "Add your first measurement" CTA. |
|
||||
| `/mood` | `MoodList` | `<div class="border-dashed">{t("mood.noEntries")}</div>`. | No CTA. | `<EmptyState>` 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 `<EmptyState>` with `Pill` icon, "Add first medication" CTA. Visual parity with the rest. |
|
||||
| `/insights` (top-level) | `InsightsPage` | Plain `<div class="text-muted-foreground py-20 text-center">{t("common.noData")}</div>` when `data` is undefined. | Looked like a hung page. | `<EmptyState>` with `TrendingUp` icon, "Log a measurement" CTA → `/measurements`. |
|
||||
| `/insights` (BMI no-height) | Inline `<p>` | "no data" when `user.heightCm == null`. | The user might wonder why the BMI section is empty. | `<EmptyState size="compact" variant="plain">` with `Ruler` icon, copy points to `/settings/account` and a "Set height" CTA. |
|
||||
| `/achievements` | `AchievementsPage` (no-grouped state) | Plain `<p>No achievements unlocked yet.</p>` inside a card. | Icon-less. The card after summary tiles needs the same visual cue we use elsewhere. | `<EmptyState>` 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 `<GettingStartedChecklist>` 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 `<EmptyState>` 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 `<tbody>` 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 `<EmptyState>` 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 `<p>No backups recorded yet.</p>`. | No icon, no CTA, no signal that the "Run backup now" button at the top is what to click. | Replaced with `<EmptyState>` (`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 `<p>No entries found.</p>` (filter-dependent). | When "Failed only" is selected on a healthy installation the user sees nothing — looked like a bug. | `<EmptyState>` with `ScrollText` icon, filter-aware description, and a "Show all events" CTA when the failed-only filter is active. |
|
||||
| `/admin/api-tokens` | `ApiTokenOverviewSection` | Plain `<p>No API tokens found.</p>`. | Doesn't tell admins that tokens are issued at login from native clients — they wonder if they need to create them somewhere. | `<EmptyState>` with `Key` icon and a description pointing to native sign-in / Settings → Account API tokens. |
|
||||
| `/admin/feedback` | `FeedbackInboxSection` | Plain `<p>No feedback in this category.</p>` per tab. | OK as text but the tab strip already shows `0` badges; the inner panel deserves the same compactness as other admin sections. | `<EmptyState size="compact">` with `Inbox` icon. Per-tab description. |
|
||||
| `/admin` overview | `RecentAuditPreview` | Plain `<p>No recent audit entries.</p>`. | Compact but icon-less — visually inconsistent with the audit-table fixes. | `<EmptyState size="compact" variant="plain">` with `ScrollText` icon. |
|
||||
| `/measurements` | `MeasurementList` | `<div class="border-dashed">{t("measurements.noMeasurements")}</div>`. | No CTA and the top-level "Add measurement" button is the only way out. | `<EmptyState>` with `Activity` icon, filter-aware description, "Add your first measurement" CTA. |
|
||||
| `/mood` | `MoodList` | `<div class="border-dashed">{t("mood.noEntries")}</div>`. | No CTA. | `<EmptyState>` 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 `<EmptyState>` with `Pill` icon, "Add first medication" CTA. Visual parity with the rest. |
|
||||
| `/insights` (top-level) | `InsightsPage` | Plain `<div class="text-muted-foreground py-20 text-center">{t("common.noData")}</div>` when `data` is undefined. | Looked like a hung page. | `<EmptyState>` with `TrendingUp` icon, "Log a measurement" CTA → `/measurements`. |
|
||||
| `/insights` (BMI no-height) | Inline `<p>` | "no data" when `user.heightCm == null`. | The user might wonder why the BMI section is empty. | `<EmptyState size="compact" variant="plain">` with `Ruler` icon, copy points to `/settings/account` and a "Set height" CTA. |
|
||||
| `/achievements` | `AchievementsPage` (no-grouped state) | Plain `<p>No achievements unlocked yet.</p>` inside a card. | Icon-less. The card after summary tiles needs the same visual cue we use elsewhere. | `<EmptyState>` 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 `<GettingStartedChecklist>` 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 `<EmptyState>` 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 `<EmptyState
|
||||
size="compact">` per panel; not duplicating in this audit.
|
||||
size="compact">` per panel; not duplicating in this audit.
|
||||
|
||||
## Tests added
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 `<InsightAdvisorCard />` and
|
||||
`<InsightsCard />` 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
|
||||
|
||||
|
||||
+12
-12
@@ -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).
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.4.14",
|
||||
"version": "1.4.15",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"scripts": {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -52,7 +52,10 @@ interface NormalizedEvent {
|
||||
raw: CoolifyDeployPayload;
|
||||
}
|
||||
|
||||
function asStringOr<T extends string | null>(value: unknown, fallback: T): string | T {
|
||||
function asStringOr<T extends string | null>(
|
||||
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 },
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -822,7 +822,9 @@ export default function InsightsPage() {
|
||||
description={t("insights.emptyDescription")}
|
||||
action={
|
||||
<Button size="sm" asChild>
|
||||
<Link href="/measurements">{t("insights.emptyAddMeasurement")}</Link>
|
||||
<Link href="/measurements">
|
||||
{t("insights.emptyAddMeasurement")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -296,105 +296,109 @@ export function UserManagementSection() {
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 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. */}
|
||||
<div className="mt-4 hidden overflow-x-auto md:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.users")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.userEmail")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center font-medium">
|
||||
{t("admin.userRole")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center font-medium">
|
||||
{t("admin.userPasskeys")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.userCreated")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.userActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{filteredUsers.map((u, i) => (
|
||||
<tr key={u.id} className={i % 2 === 0 ? "bg-muted/30" : ""}>
|
||||
<td className="px-3 py-2 font-medium">{u.username}</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{u.email || "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<Badge
|
||||
variant={u.role === "ADMIN" ? "default" : "secondary"}
|
||||
className="text-xs"
|
||||
>
|
||||
{u.role}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">{u.passkeyCount}</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
|
||||
{formatDate(u.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{renderUserActions(u)}
|
||||
</div>
|
||||
</td>
|
||||
<div className="mt-4 hidden overflow-x-auto md:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.users")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.userEmail")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center font-medium">
|
||||
{t("admin.userRole")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center font-medium">
|
||||
{t("admin.userPasskeys")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.userCreated")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.userActions")}
|
||||
</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{filteredUsers.map((u, i) => (
|
||||
<tr key={u.id} className={i % 2 === 0 ? "bg-muted/30" : ""}>
|
||||
<td className="px-3 py-2 font-medium">{u.username}</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{u.email || "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<Badge
|
||||
variant={u.role === "ADMIN" ? "default" : "secondary"}
|
||||
className="text-xs"
|
||||
>
|
||||
{u.role}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{u.passkeyCount}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
|
||||
{formatDate(u.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{renderUserActions(u)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<ul
|
||||
className="mt-4 space-y-2 md:hidden"
|
||||
data-testid="admin-users-mobile-list"
|
||||
>
|
||||
{filteredUsers.map((u) => (
|
||||
<li
|
||||
key={u.id}
|
||||
className="bg-muted/30 border-border rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">{u.username}</span>
|
||||
<Badge
|
||||
variant={u.role === "ADMIN" ? "default" : "secondary"}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{u.role}
|
||||
</Badge>
|
||||
<ul
|
||||
className="mt-4 space-y-2 md:hidden"
|
||||
data-testid="admin-users-mobile-list"
|
||||
>
|
||||
{filteredUsers.map((u) => (
|
||||
<li
|
||||
key={u.id}
|
||||
className="bg-muted/30 border-border rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">
|
||||
{u.username}
|
||||
</span>
|
||||
<Badge
|
||||
variant={u.role === "ADMIN" ? "default" : "secondary"}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{u.role}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{u.email || "—"}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("admin.userPasskeys")}: {u.passkeyCount} ·{" "}
|
||||
{formatDate(u.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{u.email || "—"}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("admin.userPasskeys")}: {u.passkeyCount} ·{" "}
|
||||
{formatDate(u.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1">
|
||||
{renderUserActions(u)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1">
|
||||
{renderUserActions(u)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
|
||||
@@ -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;
|
||||
}> {
|
||||
|
||||
@@ -53,7 +53,7 @@ describe("<TrendCard> 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", () => {
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown> | 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],
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -124,7 +124,7 @@ export function AuthShell({ children }: { children: React.ReactNode }) {
|
||||
*/}
|
||||
<a
|
||||
href="#main-content"
|
||||
className="bg-primary text-primary-foreground fixed top-2 left-2 z-[100] -translate-y-full rounded-md px-4 py-2 text-sm font-medium opacity-0 transition-transform pointer-events-none focus:translate-y-0 focus:opacity-100 focus:pointer-events-auto"
|
||||
className="bg-primary text-primary-foreground pointer-events-none fixed top-2 left-2 z-[100] -translate-y-full rounded-md px-4 py-2 text-sm font-medium opacity-0 transition-transform focus:pointer-events-auto focus:translate-y-0 focus:opacity-100"
|
||||
>
|
||||
{t("nav.skipToContent")}
|
||||
</a>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -47,10 +47,7 @@ describe("<DashboardLayoutSection> — tile + chart split", () => {
|
||||
});
|
||||
|
||||
it("renders both column headers in German", () => {
|
||||
const html = render(
|
||||
<DashboardLayoutSection id="dashboard-layout" />,
|
||||
"de",
|
||||
);
|
||||
const html = render(<DashboardLayoutSection id="dashboard-layout" />, "de");
|
||||
expect(html).toContain("Kachel");
|
||||
expect(html).toContain("Chart");
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<string, unknown>)) {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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<GenerateInsightOutcome> {
|
||||
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.
|
||||
|
||||
@@ -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}`),
|
||||
);
|
||||
|
||||
Binary file not shown.
@@ -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)");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user