diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f36d844b..5d2bd0b42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ ## [Unreleased] +## [1.26.0] — 2026-07-02 — Light theme and a consistent interface + +### Changed + +- The light theme is calmer. Text now sits on a soft off-white instead of pure white, the near-black body colour is lifted to a dark tinted grey, cards separate from the background by a gentle step rather than a hard edge, and charts and status colours draw from the theme's own palette — so nothing glares and light mode reads as one harmonious whole. The dark theme is unchanged. +- Every page shares one header now: the same title size, the same one-line description beneath it, and the same back control. Admin sections, cards and list rows follow the same spacing everywhere, so the app feels of a piece as you move between modules. +- The remaining forms that still opened the device's own date picker (allergies, environment, documents) now use the in-app calendar, matching the rest of the app. +- Wording is consistent across the interface — one word per action and one term per concept throughout. +- Mental Wellbeing now shows a visible page title, like every other module. + +### Fixed + +- When a list can't load, it shows a clear "couldn't load — retry" card instead of looking like there is no data; lists also show a placeholder skeleton while loading rather than a blank spinner. +- The Coach's medication-adherence storyline now reports the same figure as the dashboard tile. An account that only logged doses it took could previously read as fully on track and hide a real decline. +- Charts over a custom historic date range now return the range you asked for; a range far in the past could come back showing the most recent stretch instead. +- Logging out now always clears the session on this device, even if the server hits a transient database error while doing so. + +### Security + +- Error reports sent to an operator's monitoring service no longer carry the webhook path secret. This only applies if outbound error reporting is enabled. +- The workout import endpoint no longer accepts a device-owned source from a client, so a synced-device label cannot be forged. + ## [1.25.13] — 2026-07-01 — Briefing and medication fixes ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 73ee36bbd..7192ae660 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.25.13 + version: 1.26.0 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -2664,17 +2664,7 @@ paths: type: string enum: - MANUAL - - WITHINGS - - IMPORT - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP externalId: type: string maxLength: 128 diff --git a/eslint-plugins/healthlog/index.js b/eslint-plugins/healthlog/index.js index d5849ae5f..1119c7d7b 100644 --- a/eslint-plugins/healthlog/index.js +++ b/eslint-plugins/healthlog/index.js @@ -2,9 +2,10 @@ * @fileoverview Aggregates the project-local `healthlog/*` ESLint rules * into a single plugin object for the flat config. * - * - `queryKey-factory` — bare-array queryKey / mutationKey bypass guard. - * - `safe-fetch-required` — outbound fetch must route through safeFetch. - * - `api-fetch-required` — client /api/ calls must route through apiFetch. + * - `queryKey-factory` — bare-array queryKey / mutationKey bypass guard. + * - `safe-fetch-required` — outbound fetch must route through safeFetch. + * - `api-fetch-required` — client /api/ calls must route through apiFetch. + * - `no-raw-palette-color` — ban raw Tailwind palette utilities in app UI. */ "use strict"; @@ -12,11 +13,13 @@ const queryKeyFactory = require("./queryKey-factory.js"); const safeFetchRequired = require("./safe-fetch-required.js"); const apiFetchRequired = require("./api-fetch-required.js"); +const noRawPaletteColor = require("./no-raw-palette-color.js"); module.exports = { rules: { "queryKey-factory": queryKeyFactory.rules["queryKey-factory"], "safe-fetch-required": safeFetchRequired, "api-fetch-required": apiFetchRequired, + "no-raw-palette-color": noRawPaletteColor, }, }; diff --git a/eslint-plugins/healthlog/no-raw-palette-color.js b/eslint-plugins/healthlog/no-raw-palette-color.js new file mode 100644 index 000000000..6fe38790a --- /dev/null +++ b/eslint-plugins/healthlog/no-raw-palette-color.js @@ -0,0 +1,122 @@ +/** + * @fileoverview ESLint rule — raw Tailwind palette color utilities are + * banned in app UI; route every tone through a semantic token. + * + * v1.26.0 — the design-consistency wave collapsed the app's colour + * vocabulary onto semantic tokens (`text-warning` / `text-info` / + * `text-success` / `text-destructive` and their `bg-*` / `border-*` / + * opacity forms, plus `stroke-[var(--warning)]` for SVG accents). A raw + * `text-amber-500` / `bg-green-500` / `dark:text-red-400` re-introduces a + * hard-coded Tailwind stock tone that drifts off the token system, breaks + * light/dark parity, and often fails AA against the card surface. This + * rule flags those utilities at authoring time and in CI so the tokenised + * palette cannot regress. + * + * Match: a className fragment + * (text|bg|border|ring|stroke|fill|from|to|via)-- + * where is one of the Tailwind stock hues and is a + * numeric 50…950 shade. The `-` suffix is what pins it to the raw + * palette — bare `bg-white` / `text-black` (QR quiet-zone) and semantic + * tokens (`text-warning`, `bg-success/10`) never carry a numeric shade and + * so never match. + * + * Scope: `src/components/**` + `src/app/**`. The check inspects string + * literals and template-literal chunks (covering `className="…"`, + * `cn("…")`, cva variant maps, and `clsx` calls alike) — a syntactic + * match, mirroring the queryKey-factory / safe-fetch-required rules. + * + * Exempt files (narrow, path-suffix matched): + * - `src/app/global-error.tsx` — the App-Router global error boundary + * renders WITHOUT the token stylesheet loaded, so a future edit there + * legitimately needs raw palette / inline colour. + * Test files (`*.test.ts(x)`, `__tests__/`, `__mocks__/`) are exempt too. + * + * @see src/app/globals.css — the semantic token definitions. + */ + +"use strict"; + +// Files exempt from the rule. Posix-style suffix match against the +// absolute filename via `String#includes`, mirroring the sibling rules. +const EXEMPT_FILES = [ + // Runs without the token stylesheet — see the header note. + "src/app/global-error.tsx", +]; + +// Only enforce inside the app UI roots. +const ENFORCED_ROOTS = ["src/components/", "src/app/"]; + +// Raw Tailwind palette utility: a color property prefix, a stock hue, and +// a numeric 50…950 shade. The `\b` bounds let a `dark:` / `hover:` / group +// modifier prefix and a `/10` opacity suffix ride along while still +// pinning the numeric shade that distinguishes raw palette from tokens. +const RAW_PALETTE_RE = + /\b(?:text|bg|border|ring|stroke|fill|from|to|via)-(?:amber|sky|red|green|emerald|blue|orange|yellow|pink|purple|violet|zinc|gray|slate|neutral|stone|rose|lime|teal|cyan|indigo|fuchsia)-(?:50|100|200|300|400|500|600|700|800|900|950)\b/; + +function toPosix(filename) { + return filename.replace(/\\/g, "/"); +} + +function isTestFile(posix) { + return ( + /\.test\.[cm]?[jt]sx?$/.test(posix) || + /\.spec\.[cm]?[jt]sx?$/.test(posix) || + posix.includes("/__tests__/") || + posix.includes("/__mocks__/") + ); +} + +function isEnforced(filename) { + const posix = toPosix(filename); + if (!ENFORCED_ROOTS.some((root) => posix.includes(root))) return false; + if (EXEMPT_FILES.some((f) => posix.includes(f))) return false; + if (isTestFile(posix)) return false; + return true; +} + +/** @type {import("eslint").Rule.RuleModule} */ +const noRawPaletteColorRule = { + meta: { + type: "problem", + docs: { + description: + "Ban raw Tailwind palette color utilities in app UI; route every tone through a semantic token (text-warning / text-info / text-success / text-destructive and their bg / border / opacity forms).", + }, + schema: [], + messages: { + rawPalette: + 'Raw Tailwind palette utility "{{match}}" bypasses the semantic token system (drifts off the palette, breaks light/dark parity, often fails AA). Use a token: caution → text-warning / border-warning / bg-warning/10, info → text-info, success → text-success, destructive → text-destructive; SVG accents → stroke-[var(--warning)].', + }, + }, + create(context) { + const filename = context.filename ?? context.getFilename?.(); + if (!filename || !isEnforced(filename)) { + return {}; + } + + function checkText(node, text) { + if (typeof text !== "string") return; + const m = RAW_PALETTE_RE.exec(text); + if (m) { + context.report({ + node, + messageId: "rawPalette", + data: { match: m[0] }, + }); + } + } + + return { + Literal(node) { + if (typeof node.value === "string") { + checkText(node, node.value); + } + }, + TemplateElement(node) { + checkText(node, node.value.cooked ?? node.value.raw); + }, + }; + }, +}; + +module.exports = noRawPaletteColorRule; diff --git a/eslint.config.mjs b/eslint.config.mjs index 560d86560..3662faa09 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -44,6 +44,13 @@ const eslintConfig = defineConfig([ // envelope unwrap, one ApiError shape. Wrapper internals and test // files are exempt; see the rule file for the allowlist. "healthlog/api-fetch-required": "error", + // v1.26.0 — the design-consistency wave locked the app's colour + // vocabulary onto semantic tokens. Raw Tailwind palette utilities + // (`text-amber-500`, `bg-green-500`, `dark:text-red-400`, …) under + // src/components + src/app are banned so the tokenised palette can't + // regress. `src/app/global-error.tsx` (renders without the token + // stylesheet) and test files are exempt; see the rule file. + "healthlog/no-raw-palette-color": "error", }, }, ]); diff --git a/messages/de.json b/messages/de.json index 38556d3ae..d8c57323b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8,11 +8,11 @@ "moreOptions": "Weitere Optionen", "create": "Erstellen", "close": "Schließen", - "loading": "Laden...", + "loading": "Laden…", "loadFailed": "Konnte nicht geladen werden", "error": "Fehler", "saved": "Gespeichert", - "pullToRefresh": "Wird aktualisiert …", + "pullToRefresh": "Wird aktualisiert…", "no": "Nein", "noData": "Keine Daten vorhanden", "optional": "Optional", @@ -220,7 +220,7 @@ "themeDark": "Dunkel", "themeLight": "Hell", "collapseSidebar": "Einklappen", - "userMenu": "Benutzermenü", + "userMenu": "Nutzermenü", "accountSettings": "Kontoeinstellungen", "mood": "Stimmung", "coach": "Coach", @@ -252,11 +252,11 @@ "auth": { "login": "Anmelden", "register": "Registrieren", - "username": "Benutzername", + "username": "Nutzername", "password": "Passwort", "email": "E-Mail-Adresse", "emailPlaceholder": "name@beispiel.de", - "emailOrUsername": "E-Mail-Adresse oder Benutzername", + "emailOrUsername": "E-Mail-Adresse oder Nutzername", "emailOrUsernamePlaceholder": "name@beispiel.de oder benutzername", "loginWithPasskey": "Mit Passkey anmelden", "loginWithPassword": "Mit Passwort anmelden", @@ -306,7 +306,7 @@ "hero": { "scoreLabel": "Health Score", "scoreProvisional": "Noch nicht genug Daten", - "scoreComputing": "Score wird berechnet …", + "scoreComputing": "Score wird berechnet…", "doses": { "summary": "{taken} von {scheduled} Dosen genommen", "nextAt": "Nächste um {time}", @@ -378,7 +378,7 @@ "saveError": "Einnahme konnte nicht gespeichert werden.", "emptyTitle": "Noch keine Medikamente angelegt", "emptyDescription": "Lege zuerst ein Medikament mit Zeitfenster an, damit du die Einnahme protokollieren kannst.", - "emptyCta": "Medikament anlegen" + "emptyCta": "Medikament hinzufügen" }, "quickEntryDiscard": { "title": "Eintrag verwerfen?", @@ -772,10 +772,10 @@ }, "medications": { "title": "Medikamente", - "subtitle": "Medikation, Zeitpläne & Einnahmen", + "subtitle": "Medikamente, Zeitpläne & Einnahmen", "loginRequired": "Bitte anmelden, um Medikamente zu verwalten.", "addMedication": "Hinzufügen", - "firstMedication": "Erstes Medikament anlegen", + "firstMedication": "Erstes Medikament hinzufügen", "addChoice": { "logIntake": "Einnahme erfassen", "newMedication": "Neues Medikament" @@ -817,7 +817,7 @@ "importIntakes": "Einnahmen importieren", "importDescription": "JSON-Array mit Einnahme-Daten einfügen. Erwartetes Format:", "importUploadFile": "JSON-Datei hochladen", - "importPaste": "JSON hier einfügen...", + "importPaste": "JSON hier einfügen…", "importSelected": "Ausgewählt: {name}", "importFileLoaded": "Datei \"{name}\" geladen", "importInvalidJson": "Datei enthält kein gültiges JSON", @@ -949,7 +949,7 @@ "description": "Beschreibe das Medikament in eigenen Worten — das Formular wird automatisch vorausgefüllt.", "placeholder": "z. B. Mounjaro 5 mg wöchentlich am Mittwochmorgen ab nächstem Montag", "submit": "Extrahieren", - "submitBusy": "Wird verarbeitet …", + "submitBusy": "Wird verarbeitet…", "cancel": "Abbrechen", "examples": { "label": "Eines dieser Beispiele übernehmen", @@ -1412,7 +1412,7 @@ } }, "verlauf": { - "loading": "Verlauf wird geladen …", + "loading": "Verlauf wird geladen…", "loadError": "Verlauf konnte nicht geladen werden.", "empty": "Noch keine Einnahmen in diesem Zeitraum.", "summaryEmpty": "Noch keine auswertbaren Dosen.", @@ -1682,7 +1682,7 @@ "zeitplan": { "title": "Zeitplan", "timesTitle": "Einnahmezeiten", - "timesNote": "Passe die Uhrzeiten und das Zeitfenster jeder Dosis an. Die Häufigkeit (täglich, wöchentlich …) änderst du im Editor unter Erweitert.", + "timesNote": "Passe die Uhrzeiten und das Zeitfenster jeder Dosis an. Die Häufigkeit (täglich, wöchentlich…) änderst du im Editor unter Erweitert.", "scheduleLabel": "Zeitplan {index}", "fullEditNote": "Häufigkeit, Wirkstoffklasse und Kursfenster bearbeitest du im Editor unter Erweitert → Lebenszyklus.", "graceEcho": "Erinnerungsfenster: {minutes} Minuten", @@ -2152,7 +2152,7 @@ "send": "Senden", "stop": "Stopp", "thinking": "Denke nach…", - "composerPlaceholder": "Frag mich etwas …", + "composerPlaceholder": "Frag mich etwas…", "nudgeBubbleLabel": "Neue Nachricht vom Coach — Chat öffnen", "fabLabel": "Coach öffnen", "readAloud": "Vorlesen", @@ -2393,7 +2393,7 @@ "hrv": "Was sagt mir meine Herzfrequenzvariabilität zuletzt über Erholung und Stress?", "sleep": "Geh mit mir meinen letzten Schlaf durch — Dauer, Regelmäßigkeit und was sich zu ändern lohnt.", "mood": "Welche Muster siehst du in meiner Stimmung über die letzten Wochen?", - "medications": "Wie sieht meine Medikamentenadhärenz aus, und passt sie dazu, wie ich mich gefühlt habe?", + "medications": "Wie sieht meine Therapietreue aus, und passt sie dazu, wie ich mich gefühlt habe?", "steps": "Wie aktiv war ich zuletzt gemessen an meinen Schritten, und wie ist das im Vergleich zu einem gesunden Richtwert?", "activeEnergy": "Was sagt mein Aktivenergie-Verlauf über mein Aktivitätsniveau zuletzt?", "cardioFitness": "Was bedeutet mein Cardio-Fitness-Verlauf (VO₂max), und wie bewege ich ihn in die richtige Richtung?", @@ -2406,7 +2406,7 @@ "healthScore": "Warum liegt mein Gesundheits-Score bei {score} von 100, und was würde ihn am stärksten bewegen?", "periodWeek": "Geh mit mir durch, wie meine letzte Woche über meine Gesundheitswerte hinweg lief — was sticht heraus?", "periodMonth": "Geh mit mir durch, wie mein letzter Monat über meine Gesundheitswerte hinweg lief — was sticht heraus?", - "correlationBpCompliance": "Wie hängt meine Medikamentenadhärenz mit meinem Blutdruck zusammen? Geh mit mir durch, was dieser Zusammenhang für mich bedeutet.", + "correlationBpCompliance": "Wie hängt meine Therapietreue mit meinem Blutdruck zusammen? Geh mit mir durch, was dieser Zusammenhang für mich bedeutet.", "correlationMoodPulse": "Gibt es einen echten Zusammenhang zwischen meiner Stimmung und meinem Puls? Hilf mir, diesen Zusammenhang zu lesen.", "correlationWeightWeekday": "Folgt mein Gewicht einem Wochenmuster? Was sagt mir dieser Wochentags-Zusammenhang?", "recommendation": "Erzähl mir mehr zu dieser Empfehlung: \"{text}\" — warum trifft sie auf mich zu, und was sollte ich tun?" @@ -2534,7 +2534,7 @@ "stimmungEmptyAction": "Stimmungs-Tagebuch öffnen", "medikamenteDescription": "Einnahme-Kalender pro Medikament plus eine textbasierte Einschätzung.", "medikamenteEmptyTitle": "Erstes Medikament hinzufügen", - "medikamenteEmptyDescription": "Adhärenz-Insights brauchen mindestens ein erfasstes Medikament.", + "medikamenteEmptyDescription": "Therapietreue-Insights brauchen mindestens ein erfasstes Medikament.", "medikamenteEmptyAction": "Medikamente öffnen", "bmiDescription": "BMI aus deinem Gewicht und deiner Größe abgeleitet, mit Einschätzung.", "target": { @@ -3820,7 +3820,7 @@ }, "medications": { "title": "Medikamente & Einnahmen", - "body": "Medikamente verwaltet Zeitpläne, Einnahmen, Bestand und Titration. Versteckt: wiederkehrende Pläne (RRULE/rollierend) und Bestandswarnungen. Deine Einnahmequote speist die Compliance-Säule im Dashboard und den Kontext der KI." + "body": "Medikamente verwaltet Zeitpläne, Einnahmen, Bestand und Titration. Versteckt: wiederkehrende Pläne (RRULE/rollierend) und Bestandswarnungen. Deine Einnahmequote speist die Therapietreue-Säule im Dashboard und den Kontext der KI." }, "labs": { "title": "Laborwerte", @@ -3832,7 +3832,7 @@ }, "vorsorge": { "title": "Vorsorge", - "body": "Vorsorge erinnert an Untersuchungen und Impfungen mit eigenem Intervall. Tipp: „Erledigt“ kann direkt eine Messung oder ein Laborergebnis anlegen. Fällige Vorsorgen erscheinen als Reminder und im Arztbericht." + "body": "Vorsorge erinnert an Untersuchungen und Impfungen mit eigenem Intervall. Tipp: „Erledigt“ kann direkt eine Messung oder ein Laborergebnis hinzufügen. Fällige Vorsorgen erscheinen als Reminder und im Arztbericht." }, "cycle": { "title": "Zyklus", @@ -3994,7 +3994,7 @@ "profileTitle": "Profil einrichten", "profileDescription": "Größe, Geburtsdatum und Geschlecht schalten personalisierte Zielwerte frei.", "profileCta": "Profil öffnen", - "measurementTitle": "Erste Messung anlegen", + "measurementTitle": "Erste Messung hinzufügen", "measurementDescription": "Ein Messpunkt reicht, um die Kachel zu aktivieren — der Trend zeichnet sich ab dem 5. Eintrag.", "measurementCta": "Messung hinzufügen", "medicationTitle": "Medikament hinzufügen", @@ -4421,7 +4421,7 @@ "saved": "Datumsformat gespeichert.", "saveError": "Speichern des Datumsformats fehlgeschlagen." }, - "username": "Benutzername", + "username": "Nutzername", "height": "Körpergröße (cm)", "dateOfBirth": "Geburtsdatum", "dateOfBirthHint": "Wird für die automatische Berechnung der Blutdruck-Zielwerte benötigt.", @@ -4673,7 +4673,7 @@ "aboutMe": { "title": "Über mich", "description": "Persönlicher Kontext, den Coach und Tagesbriefing zusätzlich zu deinen Daten lesen.", - "placeholder": "Z. B. Schichtarbeit, vegetarische Ernährung, Knieverletzung, Training für einen Halbmarathon …", + "placeholder": "Z. B. Schichtarbeit, vegetarische Ernährung, Knieverletzung, Training für einen Halbmarathon…", "charCount": "{used} / {max} Zeichen", "clear": "Leeren", "save": "Speichern", @@ -4685,11 +4685,11 @@ "profileHint": "Alter und Geschlecht stammen aus deinem Profil und werden automatisch mitgegeben.", "freeTextLabel": "Über mich (Freitext)", "conditionsLabel": "Chronische Erkrankungen", - "conditionsPlaceholder": "Z. B. Bluthochdruck, Typ-2-Diabetes …", + "conditionsPlaceholder": "Z. B. Bluthochdruck, Typ-2-Diabetes…", "allergiesLabel": "Allergien & Unverträglichkeiten", - "allergiesPlaceholder": "Z. B. Laktoseintoleranz, Penicillin-Allergie …", + "allergiesPlaceholder": "Z. B. Laktoseintoleranz, Penicillin-Allergie…", "focusLabel": "Worauf soll der Coach achten?", - "focusPlaceholder": "Z. B. behalte meinen Blutdruck im Blick, motiviere mich zu mehr Bewegung …", + "focusPlaceholder": "Z. B. behalte meinen Blutdruck im Blick, motiviere mich zu mehr Bewegung…", "completeness": "{answered} von {total} beantwortet", "questionsTitle": "Der Coach hat Rückfragen", "questionsOpenCoach": "Im Coach beantworten", @@ -4892,9 +4892,9 @@ "expand": "Ausklappen", "dangerZone": "Alle Daten löschen", "dangerZoneTitle": "Gefahrenzone", - "dangerZoneDescription": "Löscht alle deine Gesundheitsdaten und Integrationen. Dein Benutzerkonto bleibt erhalten.", + "dangerZoneDescription": "Löscht alle deine Gesundheitsdaten und Integrationen. Dein Nutzerkonto bleibt erhalten.", "dangerZoneConfirm": "Alle Daten löschen?", - "dangerZoneConfirmDescription": "Dieser Vorgang löscht alle deine Gesundheitsdaten und Integrationen unwiderruflich. Dein Benutzerkonto bleibt erhalten.", + "dangerZoneConfirmDescription": "Dieser Vorgang löscht alle deine Gesundheitsdaten und Integrationen unwiderruflich. Dein Nutzerkonto bleibt erhalten.", "dangerZoneSuccess": "Alle persönlichen Daten wurden gelöscht", "dangerZoneDeleteFailed": "Löschen fehlgeschlagen", "finalDelete": "Endgültig löschen", @@ -5343,7 +5343,7 @@ }, "admin": { "title": "Administration", - "subtitle": "Systemstatus, Benutzer und Einstellungen", + "subtitle": "Systemstatus, Nutzer und Einstellungen", "settingsSaveError": "Einstellungen konnten nicht gespeichert werden", "systemStatus": "Systemstatus", "systemStatusDescription": "Laufzeitdaten der Instanz: Datenbank, Worker und Bestandszähler.", @@ -5351,11 +5351,11 @@ "databaseConnected": "Verbunden", "databaseError": "Fehler", "startedAt": "Gestartet", - "users": "Benutzer", + "users": "Nutzer", "measurementsCount": "Messwerte", "activeTokens": "Aktive API-Tokens", "activeSessions": "Aktive Sessions", - "loadingStatus": "Lade Status...", + "loadingStatus": "Status wird geladen…", "systemStatusLoadError": "System-Status konnte nicht geladen werden.", "systemStatusRetry": "Erneut versuchen", "adminSettingsLoadError": "Admin-Einstellungen konnten nicht geladen werden. Schalter unten zeigen evtl. nicht den Live-Zustand — Seite neu laden.", @@ -5387,7 +5387,7 @@ }, "aiQuality": { "title": "Auswertungs-Qualität", - "loading": "Feedback-Zusammenfassung wird geladen...", + "loading": "Feedback-Zusammenfassung wird geladen…", "loadError": "Qualitätszusammenfassung konnte nicht geladen werden. Bitte die Seite neu laden.", "empty": "Noch kein Feedback. Der Aggregator läuft täglich um 04:00 — sobald Nutzer Empfehlungen bewerten, erscheinen hier die Hilfreich-Quoten.", "windowLabel": "Letzte {days} Tage", @@ -5402,7 +5402,7 @@ }, "coachFeedback": { "title": "Coach-Feedback", - "loading": "Coach-Feedback-Zusammenfassung wird geladen...", + "loading": "Coach-Feedback-Zusammenfassung wird geladen…", "loadError": "Coach-Feedback-Zusammenfassung konnte nicht geladen werden. Bitte die Seite neu laden.", "empty": "Noch kein Coach-Feedback. Der Aggregator läuft täglich um 04:00 — sobald Nutzer Coach-Antworten bewerten, erscheinen hier die Hilfreich-Quoten.", "windowLabel": "Letzte {days} Tage", @@ -5422,14 +5422,14 @@ "appSettings": "Anwendungseinstellungen", "appSettingsDescription": "Globale Standardwerte für Registrierung, Sprache und Zeitzone neuer Konten.", "registrationEnabled": "Registrierung erlauben", - "registrationEnabledDescription": "Wenn deaktiviert, können sich keine neuen Benutzer registrieren.", + "registrationEnabledDescription": "Wenn deaktiviert, können sich keine neuen Nutzer registrieren.", "defaultLanguage": "Standard-Sprache", - "defaultLanguageDescription": "Wird für neue Benutzer und als Fallback verwendet.", + "defaultLanguageDescription": "Wird für neue Nutzer und als Fallback verwendet.", "defaultUserTimezone": "Standard-Zeitzone für neue Konten", - "defaultUserTimezoneDescription": "Wird für neue Konten ohne erkannte Browser-Zeitzone verwendet. Bestehende Benutzer sind nicht betroffen.", + "defaultUserTimezoneDescription": "Wird für neue Konten ohne erkannte Browser-Zeitzone verwendet. Bestehende Nutzer sind nicht betroffen.", "defaultUserTimezoneFallback": "Europe/Berlin (Standard)", "servicesGlobal": "Dienste (global)", - "servicesGlobalDescription": "Deaktivierte Dienste stehen keinem Benutzer zur Verfügung.", + "servicesGlobalDescription": "Deaktivierte Dienste stehen keinem Nutzer zur Verfügung.", "telegramGlobal": "Telegram-Benachrichtigungen erlauben.", "ntfyGlobal": "ntfy-Benachrichtigungen erlauben.", "webPushGlobal": "Browser-Push-Benachrichtigungen erlauben.", @@ -5553,7 +5553,7 @@ "revokedToast": "Einladung widerrufen.", "revokeError": "Einladung konnte nicht widerrufen werden." }, - "userManagement": "Benutzerverwaltung", + "userManagement": "Nutzerverwaltung", "userManagementDescription": "Konten verwalten: Rollen, Passwort-Resets und erzwungene Abmeldungen.", "userName": "Name", "userEmail": "E-Mail", @@ -5561,11 +5561,11 @@ "userPasskeys": "Passkeys", "userCreated": "Erstellt", "userActions": "Aktionen", - "editUser": "Benutzer bearbeiten", - "editUserTitle": "Benutzer bearbeiten: {name}", - "demoteToUser": "Zu User degradieren", + "editUser": "Nutzer bearbeiten", + "editUserTitle": "Nutzer bearbeiten: {name}", + "demoteToUser": "Zu Nutzer degradieren", "promoteToAdmin": "Zum Admin befördern", - "toUser": "Zum Benutzer", + "toUser": "Zum Nutzer", "toAdmin": "Zum Admin", "ownRoleUnchangeable": "Eigene Rolle nicht änderbar", "resetPassword": "Passwort zurücksetzen", @@ -5574,10 +5574,10 @@ "newPasswordPlaceholder": "Mindestens 12 Zeichen", "passwordReset": "Passwort wurde zurückgesetzt", "reset": "Zurücksetzen", - "loadingUsers": "Lade Benutzer...", + "loadingUsers": "Nutzer werden geladen…", "apiTokens": "API-Tokens", "apiTokensDescription": "Alle aktiven und widerrufenen API-Tokens über alle Konten hinweg.", - "tokenUser": "Benutzer", + "tokenUser": "Nutzer", "tokenName": "Name", "tokenPermissions": "Berechtigungen", "tokenPermissionAll": "Alle", @@ -5613,9 +5613,9 @@ "outcomeFailed": "Fehlgeschlagen", "auditDetails": "Details", "deleteAllData": "Alle Daten global löschen", - "deleteAllDescription": "Löscht alle Mess-, Medikamenten-, Integrations- und Protokoll-Daten aller Benutzer. Benutzerkonten bleiben bestehen.", + "deleteAllDescription": "Löscht alle Mess-, Medikamenten-, Integrations- und Protokoll-Daten aller Nutzer. Nutzerkonten bleiben bestehen.", "deleteAllConfirm": "Alle Daten wirklich löschen?", - "deleteAllConfirmDescription": "Dieser Vorgang löscht alle Messwerte, Medikamente, Einnahmen, API-Tokens, Withings-Verbindungen, offene Authentifizierungs-Challenges, Benachrichtigungskanäle (Telegram, ntfy, Web Push), Push-Abonnements und in der Warteschlange befindliche Telegram-Nachrichtenlöschungen aller Benutzer unwiderruflich. Benutzerkonten, das Audit-Log und eingereichtes Feedback bleiben erhalten, damit der Löschvorgang nachvollziehbar bleibt und Bug-Reports weiter triagierbar sind.", + "deleteAllConfirmDescription": "Dieser Vorgang löscht alle Messwerte, Medikamente, Einnahmen, API-Tokens, Withings-Verbindungen, offene Authentifizierungs-Challenges, Benachrichtigungskanäle (Telegram, ntfy, Web Push), Push-Abonnements und in der Warteschlange befindliche Telegram-Nachrichtenlöschungen aller Nutzer unwiderruflich. Nutzerkonten, das Audit-Log und eingereichtes Feedback bleiben erhalten, damit der Löschvorgang nachvollziehbar bleibt und Bug-Reports weiter triagierbar sind.", "deleteButton": "Alle Daten löschen", "deleteAllPromptLabel": "Tippe {token} zum Fortfahren", "finalDelete": "Endgültig löschen", @@ -5681,11 +5681,11 @@ "versionTileUpdateAvailable": "Update verfügbar: {tag}", "versionTileUpToDate": "Aktuell", "snapshotLoadError": "System-Snapshot konnte nicht geladen werden.", - "snapshotLoading": "Snapshot wird geladen...", + "snapshotLoading": "Snapshot wird geladen…", "auditTitle": "Letzte Aktivitäten", "auditEmpty": "Keine aktuellen Audit-Einträge.", "auditLoadError": "Letzte Aktivitäten konnten nicht geladen werden.", - "auditLoading": "Aktivitäten werden geladen...", + "auditLoading": "Aktivitäten werden geladen…", "auditViewAll": "Alle anzeigen" }, "section": { @@ -5714,7 +5714,7 @@ "subtitle": "Schwellwerte für Medikamentenerinnerungen anpassen." }, "users": { - "title": "Benutzer", + "title": "Nutzer", "subtitle": "Konten, Rollen und aktive Sitzungen verwalten.", "filter": { "all": "Alle", @@ -5723,7 +5723,7 @@ }, "forceLogout": "Abmeldung erzwingen", "forceLogoutConfirmTitle": "{name} abmelden?", - "forceLogoutConfirmBody": "Alle aktiven Sitzungen dieses Benutzers werden widerrufen. Auf allen Geräten ist eine erneute Anmeldung erforderlich.", + "forceLogoutConfirmBody": "Alle aktiven Sitzungen dieses Nutzers werden widerrufen. Auf allen Geräten ist eine erneute Anmeldung erforderlich.", "forceLogoutSuccess": "{name} aus {count} Sitzung(en) abgemeldet", "forceLogoutFailed": "Abmeldung fehlgeschlagen", "cannotLogoutSelf": "Eigene Sitzungen können nicht beendet werden", @@ -5744,7 +5744,7 @@ "subtitle": "Audit-Spur für Authentifizierungs- und Admin-Ereignisse." }, "auditLog": { - "filterActor": "Akteur (Benutzer-ID oder -Name)", + "filterActor": "Akteur (Nutzer-ID oder -Name)", "filterAction": "Aktion", "filterActionAll": "Alle Aktionen", "filterTarget": "Ziel (ID oder Pfad)", @@ -5792,7 +5792,7 @@ "runNow": "Jetzt sichern", "runEnqueued": "Backup-Job eingeplant", "runFailed": "Backup konnte nicht eingeplant werden", - "loading": "Backups werden geladen...", + "loading": "Backups werden geladen…", "loadError": "Backups konnten nicht geladen werden.", "emptyTitle": "Noch keine Backups", "emptyDescription": "Backups laufen automatisch jeden Sonntag um 03:00 Uhr — oder erstelle jetzt einen ersten Snapshot.", @@ -5820,7 +5820,7 @@ "restoreDescription": "Dies ersetzt ALLE aktuellen Daten von {username} mit dem Snapshot von {when}. Vorhandene Messungen, Medikamente, Einnahmen, Stimmungseinträge, Benachrichtigungs-Kanäle, Push-Abos und Telegram-Lösch-Aufträge werden entfernt.", "restorePromptLabel": "Tippe RESTORE zum Fortfahren", "restoreConfirm": "Jetzt wiederherstellen", - "restoreInProgress": "Wiederherstellung läuft...", + "restoreInProgress": "Wiederherstellung läuft…", "restoreSuccess": "Wiederherstellung abgeschlossen", "restoreFailed": "Wiederherstellung fehlgeschlagen", "docsLink": "Dokumentation" @@ -5840,7 +5840,7 @@ "encryption": { "title": "Verschlüsselung", "subtitle": "Verschlüsselungsabdeckung und Schlüsselrotations-Status für ruhende Daten.", - "loading": "Verschlüsselungsstatus wird geladen...", + "loading": "Verschlüsselungsstatus wird geladen…", "loadError": "Verschlüsselungsstatus konnte nicht geladen werden.", "coverageTitle": "Abdeckung", "coverageDescription": "Wie viele verschlüsselte Zeilen unter welcher Schlüssel-ID liegen. Es werden nur Schlüssel-IDs und Anzahlen gezeigt — niemals Schlüsselmaterial.", @@ -7068,7 +7068,7 @@ "saveError": "Wert konnte nicht gespeichert werden", "biomarker": "Biomarker", "biomarkerPlaceholder": "Biomarker auswählen", - "defineNew": "+ Neuen Biomarker anlegen", + "defineNew": "+ Neuen Biomarker hinzufügen", "noBiomarkersHint": "Lege zuerst einen Biomarker an, um einen Wert zu erfassen.", "pickBiomarkerError": "Bitte zuerst einen Biomarker auswählen.", "resultType": "Ergebnistyp", @@ -7114,9 +7114,9 @@ "biomarker": { "manageTitle": "Biomarker", "managerDescription": "Marker einmal definieren — Einheit und Bereich gelten für jede Messung.", - "define": "Anlegen", - "defineFirst": "Ersten Biomarker anlegen", - "defineTitle": "Biomarker anlegen", + "define": "Hinzufügen", + "defineFirst": "Ersten Biomarker hinzufügen", + "defineTitle": "Biomarker hinzufügen", "defineDescription": "Name, Einheit und Referenzbereich einmalig festlegen. Beim Erfassen wählst du nur den Marker.", "editTitle": "Biomarker bearbeiten", "edit": "Bearbeiten", @@ -7332,7 +7332,7 @@ }, "medications": { "label": "Medikamente", - "description": "Pläne, Einnahmen und Compliance." + "description": "Pläne, Einnahmen und Therapietreue." }, "illness": { "label": "Krankheitstagebuch", @@ -7893,7 +7893,7 @@ "review": { "title": "Erkannte Angaben prüfen", "confirm": "Bestätigte Angaben speichern", - "confirming": "Wird gespeichert …", + "confirming": "Wird gespeichert…", "needsReview": "Prüfung nötig", "approve": "Übernehmen", "reject": "Ablehnen", diff --git a/messages/en.json b/messages/en.json index b2da6e5f8..468dcbe75 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8,7 +8,7 @@ "moreOptions": "More options", "create": "Create", "close": "Close", - "loading": "Loading...", + "loading": "Loading…", "loadFailed": "Could not load", "error": "Error", "saved": "Saved", @@ -306,7 +306,7 @@ "hero": { "scoreLabel": "Health Score", "scoreProvisional": "Not enough data yet", - "scoreComputing": "Calculating score …", + "scoreComputing": "Calculating score…", "doses": { "summary": "{taken} of {scheduled} doses taken", "nextAt": "Next at {time}", @@ -817,7 +817,7 @@ "importIntakes": "Import intakes", "importDescription": "Paste a JSON array with intake data. Expected format:", "importUploadFile": "Upload JSON file", - "importPaste": "Paste JSON here...", + "importPaste": "Paste JSON here…", "importSelected": "Selected: {name}", "importFileLoaded": "File \"{name}\" loaded", "importInvalidJson": "File does not contain valid JSON", @@ -1412,7 +1412,7 @@ } }, "verlauf": { - "loading": "Loading history …", + "loading": "Loading history…", "loadError": "Could not load the history.", "empty": "No intakes in this period yet.", "summaryEmpty": "No doses to score yet.", @@ -1682,7 +1682,7 @@ "zeitplan": { "title": "Schedule", "timesTitle": "Dose times", - "timesNote": "Adjust each dose's time and on-time window. The frequency (daily, weekly …) is edited in the editor under Advanced.", + "timesNote": "Adjust each dose's time and on-time window. The frequency (daily, weekly…) is edited in the editor under Advanced.", "scheduleLabel": "Schedule {index}", "fullEditNote": "Frequency, treatment class and course window are edited in the editor under Advanced → Lifecycle.", "graceEcho": "Reminder window: {minutes} minutes", @@ -2152,7 +2152,7 @@ "send": "Send", "stop": "Stop", "thinking": "Thinking…", - "composerPlaceholder": "Ask me anything …", + "composerPlaceholder": "Ask me anything…", "nudgeBubbleLabel": "New message from the Coach — open chat", "fabLabel": "Open the Coach", "readAloud": "Read aloud", @@ -2534,7 +2534,7 @@ "stimmungEmptyAction": "Open mood journal", "medikamenteDescription": "Per-medication adherence calendars and a written assessment.", "medikamenteEmptyTitle": "Add your first medication", - "medikamenteEmptyDescription": "Compliance insights need at least one tracked medication.", + "medikamenteEmptyDescription": "Adherence insights need at least one tracked medication.", "medikamenteEmptyAction": "Open medications", "bmiDescription": "BMI derived from your weight and height, with a written assessment.", "target": { @@ -3820,7 +3820,7 @@ }, "medications": { "title": "Medications & intakes", - "body": "Medications manages schedules, intakes, stock, and titration. Hidden: recurring plans (RRULE/rolling) and low-stock warnings. Your intake rate feeds the compliance pillar on the dashboard and the AI’s context." + "body": "Medications manages schedules, intakes, stock, and titration. Hidden: recurring plans (RRULE/rolling) and low-stock warnings. Your intake rate feeds the adherence pillar on the dashboard and the AI’s context." }, "labs": { "title": "Lab values", @@ -5355,7 +5355,7 @@ "measurementsCount": "Measurements", "activeTokens": "Active API Tokens", "activeSessions": "Active Sessions", - "loadingStatus": "Loading status...", + "loadingStatus": "Loading status…", "systemStatusLoadError": "Failed to load system status.", "systemStatusRetry": "Retry", "adminSettingsLoadError": "Failed to load admin settings. Toggles below may not reflect the live state — refresh the page.", @@ -5387,7 +5387,7 @@ }, "aiQuality": { "title": "Insights Quality", - "loading": "Loading feedback summary...", + "loading": "Loading feedback summary…", "loadError": "Failed to load Insights quality summary. Try refreshing the page.", "empty": "No feedback yet. The aggregator runs daily at 04:00 — once users start rating recommendations, helpful-rate bands appear here.", "windowLabel": "Last {days} days", @@ -5402,7 +5402,7 @@ }, "coachFeedback": { "title": "Coach Feedback", - "loading": "Loading Coach feedback summary...", + "loading": "Loading Coach feedback summary…", "loadError": "Failed to load Coach feedback summary. Try refreshing the page.", "empty": "No Coach feedback yet. The aggregator runs daily at 04:00 — once users rate Coach replies, helpful-rate bands appear here.", "windowLabel": "Last {days} days", @@ -5574,7 +5574,7 @@ "newPasswordPlaceholder": "At least 12 characters", "passwordReset": "Password has been reset", "reset": "Reset", - "loadingUsers": "Loading users...", + "loadingUsers": "Loading users…", "apiTokens": "API Tokens", "apiTokensDescription": "All active and revoked API tokens across all accounts.", "tokenUser": "User", @@ -5681,11 +5681,11 @@ "versionTileUpdateAvailable": "Update available: {tag}", "versionTileUpToDate": "Up to date", "snapshotLoadError": "Failed to load system snapshot.", - "snapshotLoading": "Loading snapshot...", + "snapshotLoading": "Loading snapshot…", "auditTitle": "Recent activity", "auditEmpty": "No recent audit entries.", "auditLoadError": "Failed to load recent activity.", - "auditLoading": "Loading activity...", + "auditLoading": "Loading activity…", "auditViewAll": "View all" }, "section": { @@ -5792,7 +5792,7 @@ "runNow": "Backup now", "runEnqueued": "Backup job enqueued", "runFailed": "Failed to enqueue backup", - "loading": "Loading backups...", + "loading": "Loading backups…", "loadError": "Failed to load backups.", "emptyTitle": "No backups yet", "emptyDescription": "Backups run automatically every Sunday at 03:00 — or trigger one now to create the first snapshot.", @@ -5820,7 +5820,7 @@ "restoreDescription": "This will replace ALL current data for {username} with the snapshot from {when}. Existing measurements, medications, intake events, mood entries, notification channels, push subscriptions and queued Telegram deletions will be deleted.", "restorePromptLabel": "Type RESTORE to continue", "restoreConfirm": "Restore now", - "restoreInProgress": "Restoring...", + "restoreInProgress": "Restoring…", "restoreSuccess": "Restore complete", "restoreFailed": "Restore failed", "docsLink": "Documentation" @@ -5840,7 +5840,7 @@ "encryption": { "title": "Encryption", "subtitle": "Encryption coverage and key-rotation status for data at rest.", - "loading": "Loading encryption status...", + "loading": "Loading encryption status…", "loadError": "Failed to load encryption status.", "coverageTitle": "Coverage", "coverageDescription": "How many encrypted rows sit under each key id. Only key ids and counts are shown — never key material.", @@ -7332,7 +7332,7 @@ }, "medications": { "label": "Medications", - "description": "Schedules, intakes, and compliance." + "description": "Schedules, intakes, and adherence." }, "illness": { "label": "Illness journal", diff --git a/package.json b/package.json index 4493b1b45..0a27d02e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.25.13", + "version": "1.26.0", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/src/app/__tests__/dashboard-hero-wiring.test.ts b/src/app/__tests__/dashboard-hero-wiring.test.ts index 898830436..c5f562cd0 100644 --- a/src/app/__tests__/dashboard-hero-wiring.test.ts +++ b/src/app/__tests__/dashboard-hero-wiring.test.ts @@ -74,7 +74,7 @@ describe("dashboard hero — header handoff", () => { // prop gates the paragraph, and the `min-h-5` line-box reservation // keeps the header stable through the post-hydration name swap. expect(header).toContain("showGreeting"); - expect(header).toMatch(/\{showGreeting\s*\?/); + expect(header).toMatch(/\{\s*showGreeting\s*\?/); expect(header).toContain("min-h-5"); expect(header).toContain('data-slot="dashboard-header-greeting"'); }); diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx index 40a60c63f..6e4c2fae1 100644 --- a/src/app/achievements/page.tsx +++ b/src/app/achievements/page.tsx @@ -32,6 +32,7 @@ import { import { Progress } from "@/components/ui/progress"; import { Badge } from "@/components/ui/badge"; import { EmptyState } from "@/components/ui/empty-state"; +import { PageHeader } from "@/components/ui/page-header"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { formatDate } from "@/lib/format"; @@ -293,12 +294,10 @@ export default function AchievementsPage() { if (!isAuthenticated) { return (
-

- {t("achievements.title")} -

-

- {t("achievements.loginRequired")} -

+
); } @@ -313,20 +312,14 @@ export default function AchievementsPage() { return (
-
-
-

+ {t("achievements.title")} -

-

- {t("achievements.subtitle")} -

-
-
-
+ + } + description={t("achievements.subtitle")} + />
diff --git a/src/app/api/fitbit/credentials/route.ts b/src/app/api/fitbit/credentials/route.ts index 9ecb39a6e..bef303a15 100644 --- a/src/app/api/fitbit/credentials/route.ts +++ b/src/app/api/fitbit/credentials/route.ts @@ -1,5 +1,6 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { isP2025 } from "@/lib/prisma-errors"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; @@ -71,9 +72,15 @@ export const DELETE = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "fitbit.credentials.delete" } }); + // A missing connection row (P2025) is a benign "already disconnected" + // no-op; any other failure must propagate rather than be swallowed — + // otherwise the encrypted OAuth tokens can be left orphaned while the user + // is told the integration was disconnected. await prisma.fitbitConnection .delete({ where: { userId: user.id } }) - .catch(() => {}); + .catch((err) => { + if (!isP2025(err)) throw err; + }); await prisma.user.update({ where: { id: user.id }, diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index d50e5766c..fb7f2bd4c 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -25,16 +25,15 @@ import { import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; -import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; import { - buildComplianceMedicationContext, - expectedSlotCountForDay, - lastNonSkippedTakenAt, -} from "@/lib/analytics/compliance"; -import { getUserTodayBounds, startOfLocalDayInTz } from "@/lib/tz/local-day"; + buildScheduleAnchoredComplianceBuckets, + type ScheduleAnchoredComplianceBucket, +} from "@/lib/analytics/schedule-anchored-compliance"; +import { startOfLocalDayInTz } from "@/lib/tz/local-day"; import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes"; import { applyCanonicalSlotWrite, @@ -204,7 +203,7 @@ export const GET = apiHandler(async (request: NextRequest) => { // makes the rate genuinely reflect taken-of-expected and lets the 7/30/90 // windows diverge with partial adherence. const result = await cached( - caches.medicationsIntake as ServerCache, + caches.medicationsIntake as ServerCache, `${user.id}|compliance|${days}|${userTz}`, () => buildScheduleAnchoredComplianceBuckets(user.id, days, userTz), annotate, @@ -218,161 +217,6 @@ export const GET = apiHandler(async (request: NextRequest) => { return apiSuccess(result); }); -interface ComplianceBucket { - date: string; - scheduled: number; - taken: number; -} - -/** - * v1.15.9 — schedule-anchored per-day compliance buckets for the dashboard - * tile. Replaces the rollup/legacy path whose `scheduled` was the count of - * logged intake rows (BUG #1: rate `taken / scheduled` ≈ 100% across every - * window because every logged row was both numerator and denominator). - * - * `scheduled` is now the canonical recurrence engine's expected-dose count - * for the day, summed across the user's active medications, so days the - * schedule expected a dose that the user missed pull the rate down — and the - * 7/30/90 windows genuinely diverge with partial adherence. `taken` stays the - * count of taken (non-skipped, non-auto-missed) doses that day, capped at the - * day's expected count so a duplicate log can't push a day above 100%. - * - * Pure-ish over a single pinned `now` so a 15-minute cached row is internally - * consistent. Bounded: one active-medications query + one window-events query, - * then per-medication-per-day engine expansion over the trailing window. - */ -async function buildScheduleAnchoredComplianceBuckets( - userId: string, - days: number, - userTz: string, -): Promise { - const now = new Date(); - const nowMs = now.getTime(); - const start = new Date(nowMs - days * 86_400_000); - - const medications = await prisma.medication.findMany({ - where: { userId, active: true }, - include: { - schedules: true, - // v1.16.3 — archived schedule eras for era-aware expected counts. - scheduleRevisions: { orderBy: { validFrom: "asc" } }, - // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. - pauseEras: { select: { pausedAt: true, resumedAt: true } }, - }, - }); - - const events = await prisma.medicationIntakeEvent.findMany({ - // v1.7.0 sync — exclude tombstoned rows from the compliance buckets. - where: { userId, deletedAt: null, scheduledFor: { gte: start } }, - select: { - medicationId: true, - scheduledFor: true, - takenAt: true, - skipped: true, - autoMissed: true, - }, - }); - - // Pre-compute each local day's [start, end) bounds + key, oldest → newest. - // Each day's representative instant anchors on the user's LOCAL NOON of - // the current day and steps back in 24-hour increments: noon stays inside - // the intended local day across DST shifts (23/25-hour days), and — - // unlike the previous `now − 12 h` anchor — it cannot slip into - // yesterday when the user's local time is before noon, which silently - // dropped TODAY from the buckets for every pre-noon read. - const { start: todayStart } = getUserTodayBounds(now, userTz); - const todayNoonMs = todayStart.getTime() + 12 * 60 * 60 * 1000; - const dayKeys: string[] = []; - const dayBounds = new Map(); - for (let i = days - 1; i >= 0; i--) { - const representative = new Date(todayNoonMs - i * 86_400_000); - const { start: dayStart, end: dayEndInclusive } = getUserTodayBounds( - representative, - userTz, - ); - const key = userDayKey(dayStart, userTz); - if (dayBounds.has(key)) continue; - dayKeys.push(key); - dayBounds.set(key, { - start: dayStart, - end: new Date(dayEndInclusive.getTime() + 1), // half-open [start, end) - }); - } - - const totals = new Map(); - for (const key of dayKeys) totals.set(key, { scheduled: 0, taken: 0 }); - - // Group events by medication so each med's engine context is built once. - const eventsByMed = new Map< - string, - { - scheduledFor: Date; - takenAt: Date | null; - skipped: boolean; - autoMissed: boolean; - }[] - >(); - for (const e of events) { - const list = eventsByMed.get(e.medicationId) ?? []; - list.push({ - scheduledFor: e.scheduledFor, - takenAt: e.takenAt, - skipped: e.skipped, - autoMissed: e.autoMissed, - }); - eventsByMed.set(e.medicationId, list); - } - - for (const med of medications) { - if (med.schedules.length === 0) continue; - const medEvents = eventsByMed.get(med.id) ?? []; - const ctx = buildComplianceMedicationContext( - med, - lastNonSkippedTakenAt(medEvents), - userTz, - ); - - for (const key of dayKeys) { - const bounds = dayBounds.get(key)!; - // Skip days before the medication existed so a young med doesn't paint - // missed-denominator days it could not have been dosed on. - if (bounds.end <= med.createdAt) continue; - - const scheduled = expectedSlotCountForDay( - med.schedules, - bounds.start, - bounds.end, - ctx, - medEvents, - ); - if (scheduled === 0) continue; - - // Taken doses that landed in this day's window (non-skipped, non-auto- - // missed), capped at the expected count so a duplicate log can't push a - // single day above 100%. - const takenThisDay = medEvents.filter( - (e) => - e.takenAt !== null && - !e.skipped && - !e.autoMissed && - e.scheduledFor >= bounds.start && - e.scheduledFor < bounds.end, - ).length; - - const bucket = totals.get(key)!; - bucket.scheduled += scheduled; - bucket.taken += Math.min(takenThisDay, scheduled); - } - } - - return dayKeys - .map((date) => { - const v = totals.get(date)!; - return { date, scheduled: v.scheduled, taken: v.taken }; - }) - .sort((a, b) => a.date.localeCompare(b.date)); -} - export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); diff --git a/src/app/api/telegram/webhook/route.ts b/src/app/api/telegram/webhook/route.ts index 7de67e696..d6e9f1555 100644 --- a/src/app/api/telegram/webhook/route.ts +++ b/src/app/api/telegram/webhook/route.ts @@ -1,5 +1,6 @@ import { timingSafeEqual } from "node:crypto"; import { prisma } from "@/lib/db"; +import { isP2025 } from "@/lib/prisma-errors"; import { decrypt } from "@/lib/crypto"; import { answerTelegramCallbackQuery, @@ -945,11 +946,19 @@ async function handleTextMessage(update: TelegramUpdate) { context.userId === user.id && (context.kind === "mood_note" || context.kind === "measure_value") ) { - // Consume the context row regardless of outcome so a reply can't be - // replayed onto the entry twice. - await prisma.telegramPromptContext - .delete({ where: { id: context.id } }) - .catch(() => {}); + // The delete IS the replay guard: consuming the context row is what + // stops a redelivered reply from being applied to the entry twice. Make + // it authoritative — if the delete fails for any reason OTHER than the + // row already being gone (P2025), do NOT apply the reply; abort and let + // Telegram redeliver against a still-present row. P2025 means the row + // was already consumed, which is the guard succeeding, so proceed. + try { + await prisma.telegramPromptContext.delete({ + where: { id: context.id }, + }); + } catch (err) { + if (!isP2025(err)) return; + } const userMsgId = message?.message_id; if (context.expiresAt.getTime() < Date.now()) { const resp = await sendTelegramMessage( diff --git a/src/app/api/whoop/credentials/route.ts b/src/app/api/whoop/credentials/route.ts index 6675fa11e..ffc7e9183 100644 --- a/src/app/api/whoop/credentials/route.ts +++ b/src/app/api/whoop/credentials/route.ts @@ -1,5 +1,6 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { isP2025 } from "@/lib/prisma-errors"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; @@ -69,9 +70,15 @@ export const DELETE = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "whoop.credentials.delete" } }); + // A missing connection row (P2025) is a benign "already disconnected" + // no-op; any other failure must propagate rather than be swallowed — + // otherwise the encrypted OAuth tokens can be left orphaned while the user + // is told the integration was disconnected. await prisma.whoopConnection .delete({ where: { userId: user.id } }) - .catch(() => {}); + .catch((err) => { + if (!isP2025(err)) throw err; + }); await prisma.user.update({ where: { id: user.id }, diff --git a/src/app/api/withings/credentials/route.ts b/src/app/api/withings/credentials/route.ts index 0bd669f86..429514f95 100644 --- a/src/app/api/withings/credentials/route.ts +++ b/src/app/api/withings/credentials/route.ts @@ -1,6 +1,7 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { isP2025 } from "@/lib/prisma-errors"; import { annotate } from "@/lib/logging/context"; import { encrypt } from "@/lib/crypto"; import { withingsCredentialsSchema } from "@/lib/validations/withings"; @@ -64,10 +65,15 @@ export const DELETE = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "withings.credentials.delete" } }); - // Remove connection first + // Remove connection first. A missing connection row (P2025) is a benign + // "already disconnected" no-op; any other failure must propagate rather + // than be swallowed — otherwise the encrypted OAuth tokens can be left + // orphaned while the user is told the integration was disconnected. await prisma.withingsConnection .delete({ where: { userId: user.id } }) - .catch(() => {}); + .catch((err) => { + if (!isP2025(err)) throw err; + }); // Remove credentials await prisma.user.update({ diff --git a/src/app/api/workouts/batch/route.ts b/src/app/api/workouts/batch/route.ts index a490b2e3d..05fe3c853 100644 --- a/src/app/api/workouts/batch/route.ts +++ b/src/app/api/workouts/batch/route.ts @@ -7,10 +7,14 @@ * shape locked by `createBatchWorkoutSchema` in * `src/lib/validations/workout.ts`. * 2. The Withings activity sync (W17b, deferred) — server-to-server - * ingest with `source: "WITHINGS"` and `externalId` formatted as + * ingest with `externalId` formatted as * `"${withings.id}:${withings.model_id ?? "0"}:${withings.startdate}"`. * Withings ships no route geometry, so the nested `route` is - * always absent for WITHINGS-sourced entries. + * always absent for WITHINGS-sourced entries. NOTE: the request + * schema's `source` is narrowed to the client-writable set + * (MANUAL / APPLE_HEALTH), so this server path must write its + * `WITHINGS`-sourced rows directly via `prisma.workout.upsert` + * (as the WHOOP / Fitbit syncs already do), not through this route. * * Cross-cutting concerns mirror `/api/measurements/batch` so the iOS * sync engine can re-use the same retry / cursor plumbing: diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx index dfa955015..c2677c2d7 100644 --- a/src/app/coach/conversations/page.tsx +++ b/src/app/coach/conversations/page.tsx @@ -6,6 +6,7 @@ import { MessagesSquare, Search, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { PageHeader } from "@/components/ui/page-header"; import { COACH_SCROLLBAR } from "@/components/insights/coach-panel/message-thread"; import { useCoachConversations, @@ -122,13 +123,13 @@ function CoachConversationsBody() { return (
-

-

+ + {t("insights.coach.historyTitle")} + + } + />
+ ); } diff --git a/src/components/admin/coach-feedback-section.tsx b/src/components/admin/coach-feedback-section.tsx index c78619301..0308625c4 100644 --- a/src/components/admin/coach-feedback-section.tsx +++ b/src/components/admin/coach-feedback-section.tsx @@ -21,6 +21,7 @@ import { useQuery } from "@tanstack/react-query"; import { Loader2, Sparkles } from "lucide-react"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -70,7 +71,7 @@ export function CoachFeedbackSection() { // /admin/coach-feedback. Mirrors the canonical structure used by // (header outside the fetch-state branch). return ( -
+
)} -
+ ); } diff --git a/src/components/admin/general-settings-section.tsx b/src/components/admin/general-settings-section.tsx index 62cb0d438..008edf4fb 100644 --- a/src/components/admin/general-settings-section.tsx +++ b/src/components/admin/general-settings-section.tsx @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { Settings } from "lucide-react"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { NativeSelect } from "@/components/ui/native-select"; @@ -26,7 +27,7 @@ export function GeneralSettingsSection() { const currentDefaultTz = settings?.defaultUserTimezone ?? ""; return ( -
+
-
+ ); } diff --git a/src/components/admin/glitchtip-section.tsx b/src/components/admin/glitchtip-section.tsx index c14a18cc8..a608f876c 100644 --- a/src/components/admin/glitchtip-section.tsx +++ b/src/components/admin/glitchtip-section.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useMutation } from "@tanstack/react-query"; import { AlertTriangle, Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -69,7 +70,7 @@ export function GlitchtipSection() { } return ( -
+
-
+ ); } diff --git a/src/components/admin/host-metrics-chart.tsx b/src/components/admin/host-metrics-chart.tsx index 7188f90f3..0ef11da16 100644 --- a/src/components/admin/host-metrics-chart.tsx +++ b/src/components/admin/host-metrics-chart.tsx @@ -39,6 +39,7 @@ import { useMemo } from "react"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { Skeleton } from "@/components/ui/skeleton"; +import { SettingsCard } from "@/components/settings/settings-card"; import { apiGet } from "@/lib/api/api-fetch"; interface HostMetricsApiSample { @@ -156,10 +157,7 @@ export function HostMetricsChart() { // jump when data lands. Animated only when motion isn't reduced. if (isLoading) { return ( -
+

{t("admin.hostMetrics.title")} @@ -169,16 +167,13 @@ export function HostMetricsChart() {

-
+ ); } if (isError) { return ( -
+

{t("admin.hostMetrics.title")}

@@ -188,7 +183,7 @@ export function HostMetricsChart() { > {t("admin.hostMetrics.loadError")}

-
+ ); } @@ -197,10 +192,7 @@ export function HostMetricsChart() { // instead of an empty plot. if (rows.length === 0) { return ( -
+

{t("admin.hostMetrics.title")} @@ -212,15 +204,12 @@ export function HostMetricsChart() {

{t("admin.hostMetrics.empty")}

-

+ ); } return ( -
+

{t("admin.hostMetrics.title")} @@ -354,6 +343,6 @@ export function HostMetricsChart() {

-
+ ); } diff --git a/src/components/admin/login-overview-section.tsx b/src/components/admin/login-overview-section.tsx index 54a6059fc..601a67d80 100644 --- a/src/components/admin/login-overview-section.tsx +++ b/src/components/admin/login-overview-section.tsx @@ -25,6 +25,7 @@ import { SelectValue, } from "@/components/ui/select"; import { formatDateTime } from "@/lib/format"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -219,7 +220,7 @@ export function LoginOverviewSection() { } return ( -
+ )}
- + ); } diff --git a/src/components/admin/module-availability-section.tsx b/src/components/admin/module-availability-section.tsx index c552c0245..e578d451f 100644 --- a/src/components/admin/module-availability-section.tsx +++ b/src/components/admin/module-availability-section.tsx @@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { ToggleLeft } from "lucide-react"; import { toast } from "sonner"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -87,7 +88,7 @@ export function ModuleAvailabilitySection() { const availability = data?.availability; return ( -
+ -
+ ); } diff --git a/src/components/admin/recent-audit-preview.tsx b/src/components/admin/recent-audit-preview.tsx index 486afb127..6f112b24c 100644 --- a/src/components/admin/recent-audit-preview.tsx +++ b/src/components/admin/recent-audit-preview.tsx @@ -20,6 +20,7 @@ import { } from "lucide-react"; import { EmptyState } from "@/components/ui/empty-state"; import { formatDateTime } from "@/lib/format"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -53,10 +54,7 @@ export function RecentAuditPreview() { const entries = data?.entries ?? []; return ( -
+ )} -
+ ); } diff --git a/src/components/admin/reminders-section.tsx b/src/components/admin/reminders-section.tsx index 18dae6626..da51690c7 100644 --- a/src/components/admin/reminders-section.tsx +++ b/src/components/admin/reminders-section.tsx @@ -15,6 +15,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -53,7 +54,7 @@ function NotificationHealthPanel() { }); return ( -
+ {c.channel} - + {t("admin.notificationHealth.ok")}: {c.ok} - + {t("admin.notificationHealth.error")}: {c.error} @@ -110,7 +111,7 @@ function NotificationHealthPanel() {
)} - + ); } @@ -194,7 +195,7 @@ export function RemindersSection() { return ( <> -
+ {/* v1.4.22 D / F-58 — icon swap so the section header matches the admin-shell nav tile (Bell). Clock now lives on the timing chips inline instead of competing as the page icon. */} @@ -322,9 +323,9 @@ export function RemindersSection() { {testNotification.data.results.map((r, i) => (
{r.success ? ( - + ) : ( - + )} {r.channel} {r.error && ( @@ -370,11 +371,11 @@ export function RemindersSection() { {med.schedules.map((sched, j) => { const statusColor = sched.status === "open" - ? "text-success dark:text-green-400" + ? "text-success" : sched.status === "threshold" - ? "text-warning dark:text-yellow-400" + ? "text-warning" : sched.status === "missed" - ? "text-destructive dark:text-red-400" + ? "text-destructive" : sched.status === "skipped" ? "text-muted-foreground" : ""; @@ -391,7 +392,7 @@ export function RemindersSection() { {sched.label} {sched.notificationSent && ( - + {t("admin.reminderCheckNotifSent")} @@ -404,7 +405,7 @@ export function RemindersSection() {
)} - + ); diff --git a/src/components/admin/services-section.tsx b/src/components/admin/services-section.tsx index 17a3bd784..27ff8e02b 100644 --- a/src/components/admin/services-section.tsx +++ b/src/components/admin/services-section.tsx @@ -1,6 +1,7 @@ "use client"; import { Bell, Globe, Key, MessageCircle, Smile } from "lucide-react"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared"; @@ -11,7 +12,7 @@ export function ServicesSection() { const updateSettings = useUpdateSettings(); return ( -
+
- + ); } diff --git a/src/components/admin/system-status-section.tsx b/src/components/admin/system-status-section.tsx index e14543a84..244087d7c 100644 --- a/src/components/admin/system-status-section.tsx +++ b/src/components/admin/system-status-section.tsx @@ -13,13 +13,14 @@ import { Key, Loader2, Map, - RotateCw, + RefreshCw, Server, Users, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { formatDateTime } from "@/lib/format"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared"; @@ -51,7 +52,7 @@ export function SystemStatusSection() { return (
-
+ ) : ( - + )} {t("admin.systemStatusRetry")} @@ -218,7 +219,7 @@ export function SystemStatusSection() {
)} -
+ ); } diff --git a/src/components/admin/system-status-summary.tsx b/src/components/admin/system-status-summary.tsx index 72583f105..c21a0ed3e 100644 --- a/src/components/admin/system-status-summary.tsx +++ b/src/components/admin/system-status-summary.tsx @@ -23,6 +23,7 @@ import { Tag, } from "lucide-react"; import { formatDateTime } from "@/lib/format"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared"; @@ -33,9 +34,9 @@ export function SystemStatusSummary() { const { data: version } = usePublicVersion(); return ( -
)} -
+ ); } diff --git a/src/components/admin/umami-section.tsx b/src/components/admin/umami-section.tsx index 6a92dd0a8..b19d6327a 100644 --- a/src/components/admin/umami-section.tsx +++ b/src/components/admin/umami-section.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useMutation } from "@tanstack/react-query"; import { Activity, Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -72,7 +73,7 @@ export function UmamiSection() { } return ( -
+
- + ); } diff --git a/src/components/admin/user-management-section.tsx b/src/components/admin/user-management-section.tsx index 320618621..f9412c43b 100644 --- a/src/components/admin/user-management-section.tsx +++ b/src/components/admin/user-management-section.tsx @@ -30,7 +30,9 @@ import { } from "@/components/ui/alert-dialog"; import { useAuth } from "@/hooks/use-auth"; import { formatDate } from "@/lib/format"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { ListRow } from "@/components/ui/list-row"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { type AdminUser, PasswordInput } from "./_shared"; @@ -253,7 +255,7 @@ export function UserManagementSection() { ); return ( -
+ {filteredUsers.map((u) => ( -
  • -
    -
    -
    - - {u.username} - - - {u.role} - +
  • +
    +
    +
    + + {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)} +
    + + ))} @@ -594,6 +601,6 @@ export function UserManagementSection() { - + ); } diff --git a/src/components/admin/version-tile-section.tsx b/src/components/admin/version-tile-section.tsx index fd7e0eda3..7389d6c0b 100644 --- a/src/components/admin/version-tile-section.tsx +++ b/src/components/admin/version-tile-section.tsx @@ -25,6 +25,7 @@ import { } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { formatDateTime } from "@/lib/format"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -82,10 +83,7 @@ export function VersionTileSection() { const sha = shortSha(version?.buildSha ?? null); return ( -
    + -
    + ); } diff --git a/src/components/admin/web-push-vapid-section.tsx b/src/components/admin/web-push-vapid-section.tsx index 16092aa65..c3acb4213 100644 --- a/src/components/admin/web-push-vapid-section.tsx +++ b/src/components/admin/web-push-vapid-section.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { BellRing, KeyRound, Loader2 } from "lucide-react"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { Button } from "@/components/ui/button"; import { @@ -119,7 +120,7 @@ export function WebPushVapidSection() { } return ( -
    + -
    + ); } diff --git a/src/components/charts/__tests__/health-chart-vertical-markers.test.ts b/src/components/charts/__tests__/health-chart-vertical-markers.test.ts index 6babb1ac2..427d2813b 100644 --- a/src/components/charts/__tests__/health-chart-vertical-markers.test.ts +++ b/src/components/charts/__tests__/health-chart-vertical-markers.test.ts @@ -46,7 +46,7 @@ describe("resolveVerticalMarkerPositions", () => { ); expect(positions).toHaveLength(1); expect(positions[0].pointIndex).toBe(0); - expect(positions[0].color).toBe("#50fa7b"); + expect(positions[0].color).toBe("var(--success)"); expect(positions[0].label).toBeUndefined(); }); @@ -69,7 +69,7 @@ describe("resolveVerticalMarkerPositions", () => { expect(positions[0].color).toBe("#ff79c6"); expect(positions[0].label).toBe("Mounjaro 7.5"); // Default color preserved for the second marker. - expect(positions[1].color).toBe("#50fa7b"); + expect(positions[1].color).toBe("var(--success)"); expect(positions[1].label).toBeUndefined(); }); diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 7d667dc6b..a6541e8ac 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -190,7 +190,7 @@ interface HealthChartProps { * 2. A small filled dot sits at the x-axis intersection (mood- * chart style emoji-on-axis pattern repurposed) so a row of * green dots reads as "injection cadence" at a glance. - * 3. Default green (`#50fa7b`) to match the strip-tile palette + * 3. Default green (`var(--success)`) to match the strip-tile palette * for active medications; callers can override per marker. * * Off-window markers silently drop via `ifOverflow="discard"`, @@ -485,7 +485,7 @@ export function resolveVerticalMarkerPositions( if (idx === undefined) continue; out.push({ pointIndex: idx, - color: marker.color ?? "#50fa7b", + color: marker.color ?? "var(--success)", label: marker.label, }); } @@ -570,7 +570,7 @@ function buildTrendSeriesByTime(data: Array<{ date: Date; value: number }>): { export function HealthChart({ types, title, - colors = ["#bd93f9", "#ff79c6", "#8be9fd"], + colors = ["var(--chart-1)", "var(--chart-3)", "var(--chart-4)"], unit, yAxisUnit, valueMode = "raw", diff --git a/src/components/clinician/clinician-view.tsx b/src/components/clinician/clinician-view.tsx index 1850a2b64..9340fc763 100644 --- a/src/components/clinician/clinician-view.tsx +++ b/src/components/clinician/clinician-view.tsx @@ -205,7 +205,7 @@ export function ClinicianView({ {/* ── Fenced wellness card (descriptive, NOT clinical) ────── */} {wellness.length > 0 ? ( -
    +

    {t("clinicianView.wellness.title")}

    diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index 7489fd3c1..7016b61cd 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -6,6 +6,7 @@ import { Loader2, Plus, RefreshCw, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +import { PageHeader } from "@/components/ui/page-header"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; @@ -125,19 +126,10 @@ export function CycleView() { return (
    -
    -
    -

    - {t("cycle.title")} -

    -

    - {t("cycle.subtitle")} -

    -
    -
    + {t("cycle.title")}} + description={t("cycle.subtitle")} + actions={ -
    -
    + } + /> {/* Desktop: ring (left) + tabs (right). Single column below lg. */}
    diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index 72d0b7540..7d686cac7 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -22,11 +22,11 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { PageHeader } from "@/components/ui/page-header"; import { useTranslations } from "@/lib/i18n/context"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; import { getHourForTimeZone } from "@/components/dashboard/range-display"; -import { cn } from "@/lib/utils"; import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets"; export function DashboardHeader({ @@ -68,106 +68,102 @@ export function DashboardHeader({ : t("dashboard.welcomeBack", { greeting: timeGreeting }); return ( - /* With the greeting the title block is two lines on narrow - viewports: centre the buttons against it on mobile, top-align on - sm+ (the pre-hero posture). Without the greeting the single-line - centre alignment stands. */ -
    -
    -

    - {t("dashboard.title")} -

    - {showGreeting ? ( + {welcomeText} -

    - ) : null} -
    -
    - {/* Customize shortcut to the dashboard-customization settings - (the tile/layout editor at /settings/dashboard). Sits to - the left of the add button as a monochrome ghost icon, with - a `min-h-11 min-w-11` mobile floor so it meets the 44 px - touch-target contract the add button also honours, shrinking - back to the 40 px icon footprint on sm+. */} - - - - {/* v1.4.33 maintainer-item-7 — restore proportional sizing - across viewports. The v1.4.27 fix pinned a `size="sm" - min-h-11` combo to hit WCAG 2.5.5's 44 px touch-target - contract on mobile, but `size="sm"` is h-8 (32 px) and - the `min-h-11` override stretched the cap vertically - while keeping the small horizontal padding — the - button read as klobig on Pixel 5. Switch to - `size="default"` (h-10 = 40 px) on mobile with a - responsive `min-h-11 sm:min-h-9` so the button is - 44 px tall under finger pressure and shrinks back to - the desktop-friendly 36 px on `sm:` upwards. The icon - + label keep the same visual contract. */} - - - - {/* Menu items must each carry a self-contained verb-phrase - ("Log measurement", "Log mood") — the trigger above already - says "Add", and the icon is `aria-hidden`, so the visible - text is the only thing distinguishing the rows. v1.4.15 - phase-A3 fix #1 hardened this with a unit guard at - `src/app/__tests__/quick-add-labels.test.ts` — both labels - must differ from each other AND from `common.add`. */} - onQuickEntry("measurement")}> - - onQuickEntry("mood")}> - - {/* v1.4.37 W7b — third quick-add row: medication intake. - Same Sheet-on-mobile / Dialog-on-desktop primitive as - the other two; the menu label is a self-contained - verb-phrase so it doesn't collide with the trigger or - the sibling rows (cf. quick-add-labels.test.ts). */} - onQuickEntry("medicationIntake")}> - - - -
    -
    +
    - setFromDate(e.target.value)} + onChange={setFromDate} />
    - setToDate(e.target.value)} + onChange={setToDate} />
    @@ -920,7 +920,7 @@ function InlineNote({ slot, children }: { slot: string; children: string }) {
    @@ -1007,12 +1007,7 @@ export function DocumentMetaEditForm({
    - setDate(e.target.value)} - /> +
    - {/* v1.18.6 (MOD-02) — the add button reads "hinzufügen" like every - other module, not the bespoke "neue Episode". */} - -
    - + +