Merge pull request #397 from MBombeck/release/v1.26.0

Release v1.26.0 — light theme and a consistent interface
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-02 00:43:22 +02:00
commit a2123fb9b3
146 files changed
+2088 -1155

No files matched your search

+22
View File
@@ -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
+1 -11
View File
@@ -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
+6 -3
View File
@@ -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,
},
};
@@ -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)-<palette>-<step>
* where <palette> is one of the Tailwind stock hues and <step> is a
* numeric 50…950 shade. The `-<step>` 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;
+7
View File
@@ -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",
},
},
]);
+59 -59
View File
@@ -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",
+18 -18
View File
@@ -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",
+1 -1
View File
@@ -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",
@@ -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"');
});
+12 -19
View File
@@ -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 (
<div className="space-y-6">
<h1 className="text-2xl font-bold tracking-tight">
{t("achievements.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("achievements.loginRequired")}
</p>
<PageHeader
title={t("achievements.title")}
description={t("achievements.loginRequired")}
/>
</div>
);
}
@@ -313,20 +312,14 @@ export default function AchievementsPage() {
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1
data-tour-id="achievements-hero"
className="text-2xl font-bold tracking-tight"
>
<PageHeader
title={
<span data-tour-id="achievements-hero">
{t("achievements.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("achievements.subtitle")}
</p>
</div>
<div className="flex shrink-0 items-center gap-2"></div>
</div>
</span>
}
description={t("achievements.subtitle")}
/>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-5">
+8 -1
View File
@@ -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 },
+6 -162
View File
@@ -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<ComplianceBucket[]>,
caches.medicationsIntake as ServerCache<ScheduleAnchoredComplianceBucket[]>,
`${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<ComplianceBucket[]> {
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<string, { start: Date; end: Date }>();
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<string, { scheduled: number; taken: number }>();
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();
+14 -5
View File
@@ -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(
+8 -1
View File
@@ -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 },
+8 -2
View File
@@ -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({
+6 -2
View File
@@ -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:
+8 -7
View File
@@ -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 (
<div className="mx-auto flex h-full min-h-0 w-full max-w-screen-xl flex-col gap-4 px-4 pt-6 pb-4 md:px-6">
<header className="flex flex-col gap-3">
<h1
data-slot="coach-conversations-heading"
className="text-foreground flex items-center gap-2 text-lg font-semibold"
>
<MessagesSquare className="size-5" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</h1>
<PageHeader
title={
<span data-slot="coach-conversations-heading">
{t("insights.coach.historyTitle")}
</span>
}
/>
<div className="relative">
<Search
aria-hidden="true"
+38 -28
View File
@@ -288,25 +288,34 @@
/* ── Light mode (Alucard — official Dracula light) ── */
:root.light {
--background: #f5f5f5;
--foreground: #1f1f1f;
--card: #ffffff;
--card-foreground: #1f1f1f;
--popover: #ffffff;
--popover-foreground: #1f1f1f;
/*
* Neutral spine (retuned v1.26.0). Softened off the pure-white / pure-black
* high-contrast set — a faint mauve tint (hue ~295–300) runs through every
* neutral so the light theme reads gentle rather than harsh. Foreground is a
* tinted near-black (not #000), dropping fg-on-card from ~16.5:1 to a
* comfortable ~13:1. Accent + semantic tokens below stay as the Alucard
* AA-tuned set. oklch throughout so lightness steps are perceptually even.
*/
--background: oklch(0.964 0.004 300);
--foreground: oklch(0.3 0.012 295);
--card: oklch(0.988 0.003 300);
--card-foreground: oklch(0.3 0.012 295);
--popover: oklch(0.992 0.002 300);
--popover-foreground: oklch(0.3 0.012 295);
--primary: #644ac9;
--primary-foreground: #ffffff;
--secondary: #dcdeef;
--secondary-foreground: #1f1f1f;
--muted: #dcdeef;
--muted-foreground: #5b6273;
/* Inactive tab-trigger text — the light muted-foreground already clears
* AA (4.58:1) on the light muted strip, so the token mirrors it. */
--tab-inactive: #5b6273;
/* Light-theme counterpart of the NativeSelect chevron (see dark block). */
--native-select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%235b6273' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
--accent: #dcdeef;
--accent-foreground: #1f1f1f;
--primary-foreground: oklch(0.99 0.002 300);
--secondary: oklch(0.95 0.008 300);
--secondary-foreground: oklch(0.3 0.012 295);
--muted: oklch(0.955 0.006 300);
--muted-foreground: oklch(0.515 0.02 295);
/* Inactive tab-trigger text — the light muted-foreground clears AA on the
* light muted strip, so the token mirrors it. */
--tab-inactive: oklch(0.515 0.02 295);
/* Light-theme counterpart of the NativeSelect chevron (see dark block).
* Stroke tracks the muted-foreground hex (#686572). */
--native-select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23686572' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
--accent: oklch(0.95 0.008 300);
--accent-foreground: oklch(0.3 0.012 295);
--destructive: #cb3a2a;
/*
* Alucard light-mode overrides for the semantic tokens — straight Dracula
@@ -318,22 +327,23 @@
--info: #036a96;
/* White glyph on the darker light-mode green — ~6.1:1 on `#14720a`. */
--success-foreground: #ffffff;
--border: #cfcfde;
--input: #cfcfde;
--ring: #644ac9;
--border: oklch(0.885 0.008 300);
--input: oklch(0.87 0.009 300);
--ring: oklch(0.6 0.13 292);
--chart-1: #644ac9;
--chart-2: #14720a;
--chart-3: #a3134d;
--chart-4: #036a96;
--chart-5: #a34d14;
--sidebar: #ffffff;
--sidebar-foreground: #1f1f1f;
--dracula-yellow: #846e15;
--sidebar: oklch(0.988 0.003 300);
--sidebar-foreground: oklch(0.3 0.012 295);
--sidebar-primary: #644ac9;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #dcdeef;
--sidebar-accent-foreground: #1f1f1f;
--sidebar-border: #cfcfde;
--sidebar-ring: #644ac9;
--sidebar-primary-foreground: oklch(0.99 0.002 300);
--sidebar-accent: oklch(0.95 0.008 300);
--sidebar-accent-foreground: oklch(0.3 0.012 295);
--sidebar-border: oklch(0.885 0.008 300);
--sidebar-ring: oklch(0.6 0.13 292);
/*
* v1.18.7 — wellness-ring per-metric arc colours (Alucard light), CALM pass.
+1 -1
View File
@@ -22,7 +22,7 @@ export default function InsightsActiveEnergyPage() {
chartKey="activeEnergy"
i18nPrefix="insights.activeEnergy"
explainerMetric="activeEnergy"
color="#ffb86c"
color="var(--warning)"
unit="kcal"
yAxisUnit="kcal"
emptyStateIcon={<Flame className="size-6" />}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsLaermereignissePage() {
chartKey="audioExposureEvent"
i18nPrefix="insights.audioExposureEvent"
explainerMetric="audioEvents"
color="#ff79c6"
color="var(--chart-3)"
unit=""
yAxisUnit=""
emptyStateIcon={<Volume2 className="size-6" />}
+1 -1
View File
@@ -51,7 +51,7 @@ export default function InsightsBlutzuckerPage() {
chartKey="bloodGlucose"
i18nPrefix="insights.bloodGlucose"
explainerMetric="bloodGlucose"
color="#ff79c6"
color="var(--chart-3)"
unit={glucoseUnit}
yAxisUnit={glucoseUnit}
// mmol/L = mg/dL ÷ 18.0182; the chart's valueScale multiplies, so pass
+5 -5
View File
@@ -88,19 +88,19 @@ export default function InsightsBlutdruckPage() {
{
min: bpTargets.sysLow,
max: bpTargets.sysHigh,
color: "#ff79c6",
color: "var(--chart-3)",
opacity: 0.21,
label: t("charts.systolic"),
textColor: "#ff79c6",
textColor: "var(--chart-3)",
lineOpacity: 0.24,
},
{
min: bpTargets.diaLow,
max: bpTargets.diaHigh,
color: "#8be9fd",
color: "var(--info)",
opacity: 0.21,
label: t("charts.diastolic"),
textColor: "#8be9fd",
textColor: "var(--info)",
lineOpacity: 0.24,
},
]
@@ -164,7 +164,7 @@ export default function InsightsBlutdruckPage() {
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
title={t("charts.bloodPressure")}
titleIcon={HeartPulse}
colors={["#ff79c6", "#8be9fd"]}
colors={["var(--chart-3)", "var(--info)"]}
unit="mmHg"
yAxisUnit="mmHg"
targetZones={bpTargetZones}
+6 -6
View File
@@ -31,11 +31,11 @@ import { SubPageShell } from "@/components/insights/sub-page-shell";
* the CTA targets `/settings/account`, not the measurement onboarding.
*/
const BMI_BANDS = [
{ min: 0, max: 17, color: "#ff5555", opacity: 0.16 },
{ min: 17, max: 18.5, color: "#ffb86c", opacity: 0.18 },
{ min: 18.5, max: 24.9, color: "#50fa7b", opacity: 0.2 },
{ min: 24.9, max: 29.9, color: "#ffb86c", opacity: 0.18 },
{ min: 29.9, max: 120, color: "#ff5555", opacity: 0.16 },
{ min: 0, max: 17, color: "var(--destructive)", opacity: 0.16 },
{ min: 17, max: 18.5, color: "var(--warning)", opacity: 0.18 },
{ min: 18.5, max: 24.9, color: "var(--success)", opacity: 0.2 },
{ min: 24.9, max: 29.9, color: "var(--warning)", opacity: 0.18 },
{ min: 29.9, max: 120, color: "var(--destructive)", opacity: 0.16 },
];
export default function InsightsBmiPage() {
@@ -117,7 +117,7 @@ export default function InsightsBmiPage() {
chartKey="bmi"
types={["WEIGHT"]}
title={t("targets.bmi")}
colors={["#f1fa8c"]}
colors={["var(--dracula-yellow)"]}
unit="kg/m²"
valueMode="bmi"
valueBands={BMI_BANDS}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsBodyTemperaturePage() {
chartKey="bodyTemperature"
i18nPrefix="insights.bodyTemperature"
explainerMetric="bodyTemperature"
color="#ff79c6"
color="var(--chart-3)"
unit="°C"
yAxisUnit="°C"
emptyStateIcon={<Thermometer className="size-6" />}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsKoerperwasserPage() {
chartKey="totalBodyWater"
i18nPrefix="insights.totalBodyWater"
explainerMetric="bodyWater"
color="#8be9fd"
color="var(--info)"
unit="kg"
yAxisUnit="kg"
emptyStateIcon={<Droplet className="size-6" />}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsKnochenmassePage() {
chartKey="boneMass"
i18nPrefix="insights.boneMass"
explainerMetric="boneMass"
color="#ffb86c"
color="var(--warning)"
unit="kg"
yAxisUnit="kg"
emptyStateIcon={<Bone className="size-6" />}
@@ -20,7 +20,7 @@ export default function InsightsBreathingDisturbancesPage() {
chartKey="breathingDisturbances"
i18nPrefix="insights.breathingDisturbances"
explainerMetric="breathingDisturbances"
color="#8be9fd"
color="var(--info)"
unit="count"
yAxisUnit="count"
emptyStateIcon={<Wind className="size-6" />}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsCardioFitnessPage() {
chartKey="vo2Max"
i18nPrefix="insights.cardioFitness"
explainerMetric="cardioFitness"
color="#bd93f9"
color="var(--chart-1)"
unit="mL/(kg·min)"
yAxisUnit="mL/(kg·min)"
statIcon={Gauge}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsCardioRecoveryPage() {
chartKey="cardioRecovery"
i18nPrefix="insights.cardioRecovery"
explainerMetric="cardioRecovery"
color="#ff79c6"
color="var(--chart-3)"
unit="bpm"
yAxisUnit="bpm"
emptyStateIcon={<HeartPulse className="size-6" />}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsTageslichtPage() {
chartKey="timeInDaylight"
i18nPrefix="insights.timeInDaylight"
explainerMetric="daylight"
color="#50fa7b"
color="var(--success)"
unit="min"
yAxisUnit="min"
emptyStateIcon={<Sun className="size-6" />}
@@ -21,7 +21,7 @@ export default function InsightsDoppelstandphasePage() {
chartKey="walkingDoubleSupport"
i18nPrefix="insights.walkingDoubleSupport"
explainerMetric="doubleSupportTime"
color="#50fa7b"
color="var(--success)"
unit="%"
yAxisUnit="%"
emptyStateIcon={<Footprints className="size-6" />}
@@ -21,7 +21,7 @@ export default function InsightsLaermbelastungPage() {
chartKey="audioExposureEnv"
i18nPrefix="insights.audioExposureEnv"
explainerMetric="environmentalAudio"
color="#ff79c6"
color="var(--chart-3)"
unit="dBA"
yAxisUnit="dBA"
emptyStateIcon={<Volume2 className="size-6" />}
+1 -1
View File
@@ -19,7 +19,7 @@ export default function InsightsFallsPage() {
chartKey="fallCount"
i18nPrefix="insights.falls"
explainerMetric="falls"
color="#ff5555"
color="var(--destructive)"
unit="falls"
yAxisUnit="falls"
emptyStateIcon={<PersonStanding className="size-6" />}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsFettfreieMassePage() {
chartKey="fatFreeMass"
i18nPrefix="insights.fatFreeMass"
explainerMetric="fatFreeMass"
color="#bd93f9"
color="var(--chart-1)"
unit="kg"
yAxisUnit="kg"
emptyStateIcon={<Scale className="size-6" />}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsFettmassePage() {
chartKey="fatMass"
i18nPrefix="insights.fatMass"
explainerMetric="fatMass"
color="#ffb86c"
color="var(--warning)"
unit="kg"
yAxisUnit="kg"
emptyStateIcon={<Droplets className="size-6" />}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsStockwerkePage() {
chartKey="flightsClimbed"
i18nPrefix="insights.flightsClimbed"
explainerMetric="flightsClimbed"
color="#50fa7b"
color="var(--success)"
unit="flights"
yAxisUnit="flights"
emptyStateIcon={<TrendingUp className="size-6" />}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsKopfhoererpegelPage() {
chartKey="audioExposureHeadphone"
i18nPrefix="insights.audioExposureHeadphone"
explainerMetric="headphoneAudio"
color="#ff79c6"
color="var(--chart-3)"
unit="dBA"
yAxisUnit="dBA"
emptyStateIcon={<Headphones className="size-6" />}
+1 -1
View File
@@ -27,7 +27,7 @@ export default function InsightsHrvPage() {
chartKey="hrv"
i18nPrefix="insights.hrv"
explainerMetric="hrv"
color="#bd93f9"
color="var(--chart-1)"
unit="ms"
yAxisUnit="ms"
emptyStateIcon={<Activity className="size-6" />}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsMagermassePage() {
chartKey="leanBodyMass"
i18nPrefix="insights.leanBodyMass"
explainerMetric="leanBodyMass"
color="#bd93f9"
color="var(--chart-1)"
unit="kg"
yAxisUnit="kg"
emptyStateIcon={<Scale className="size-6" />}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsMuskelmassePage() {
chartKey="muscleMass"
i18nPrefix="insights.muscleMass"
explainerMetric="muscleMass"
color="#bd93f9"
color="var(--chart-1)"
unit="kg"
yAxisUnit="kg"
emptyStateIcon={<Dumbbell className="size-6" />}
+4 -4
View File
@@ -23,16 +23,16 @@ export default function InsightsOxygenSaturationPage() {
chartKey="oxygenSaturation"
i18nPrefix="insights.oxygenSaturation"
explainerMetric="oxygenSaturation"
color="#8be9fd"
color="var(--info)"
unit="%"
yAxisUnit="%"
valueBands={[
// < 90 % — clinical hypoxia floor, paint the danger band.
{ min: 80, max: 90, color: "#ff5555", opacity: 0.18 },
{ min: 80, max: 90, color: "var(--destructive)", opacity: 0.18 },
// 90 .. 95 % — watch zone.
{ min: 90, max: 95, color: "#ffb86c", opacity: 0.18 },
{ min: 90, max: 95, color: "var(--warning)", opacity: 0.18 },
// 95 .. 100 % — clinical normal.
{ min: 95, max: 100, color: "#50fa7b", opacity: 0.18 },
{ min: 95, max: 100, color: "var(--success)", opacity: 0.18 },
]}
emptyStateIcon={<Wind className="size-6" />}
emptyStateCtaType={null}
@@ -21,7 +21,7 @@ export default function InsightsPulswellengeschwindigkeitPage() {
chartKey="pulseWaveVelocity"
i18nPrefix="insights.pulseWaveVelocity"
explainerMetric="pulseWaveVelocity"
color="#ff5555"
color="var(--destructive)"
unit="m/s"
yAxisUnit="m/s"
emptyStateIcon={<Activity className="size-6" />}
+16 -6
View File
@@ -98,26 +98,36 @@ export default function InsightsPulsPage() {
(user?.gender as "MALE" | "FEMALE" | null | undefined) ?? null,
);
const pulseBands = [
{ min: 30, max: pulseTarget.orangeMin, color: "#ff5555", opacity: 0.16 },
{
min: 30,
max: pulseTarget.orangeMin,
color: "var(--destructive)",
opacity: 0.16,
},
{
min: pulseTarget.orangeMin,
max: pulseTarget.greenMin,
color: "#ffb86c",
color: "var(--warning)",
opacity: 0.18,
},
{
min: pulseTarget.greenMin,
max: pulseTarget.greenMax,
color: "#50fa7b",
color: "var(--success)",
opacity: 0.2,
},
{
min: pulseTarget.greenMax,
max: pulseTarget.orangeMax,
color: "#ffb86c",
color: "var(--warning)",
opacity: 0.18,
},
{ min: pulseTarget.orangeMax, max: 220, color: "#ff5555", opacity: 0.16 },
{
min: pulseTarget.orangeMax,
max: 220,
color: "var(--destructive)",
opacity: 0.16,
},
].filter((band) => band.max > band.min);
return (
@@ -156,7 +166,7 @@ export default function InsightsPulsPage() {
types={hasRestingHr ? ["RESTING_HEART_RATE"] : ["PULSE"]}
title={t("charts.pulse")}
titleIcon={Heart}
colors={["#50fa7b"]}
colors={["var(--success)"]}
unit="bpm"
valueBands={hasRestingHr ? pulseBands : undefined}
compareBaseline={compareBaseline}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsAtemfrequenzPage() {
chartKey="respiratoryRate"
i18nPrefix="insights.respiratoryRate"
explainerMetric="respiratoryRate"
color="#8be9fd"
color="var(--info)"
unit="breaths/min"
yAxisUnit="breaths/min"
emptyStateIcon={<Wind className="size-6" />}
+1 -1
View File
@@ -24,7 +24,7 @@ export default function InsightsRestingHrPage() {
chartKey="restingHr"
i18nPrefix="insights.restingHr"
explainerMetric="restingHr"
color="#ff5555"
color="var(--destructive)"
unit="bpm"
yAxisUnit="bpm"
emptyStateIcon={<Heart className="size-6" />}
+1 -1
View File
@@ -20,7 +20,7 @@ export default function InsightsSixMinuteWalkPage() {
chartKey="sixMinuteWalkDistance"
i18nPrefix="insights.sixMinuteWalk"
explainerMetric="sixMinuteWalk"
color="#50fa7b"
color="var(--success)"
unit="m"
yAxisUnit="m"
emptyStateIcon={<Footprints className="size-6" />}
+2 -2
View File
@@ -29,7 +29,7 @@ export default function InsightsHauttemperaturPage() {
chartKey="skinTemperature"
i18nPrefix="insights.skinTemperature"
explainerMetric="skinTemperature"
color="#ffb86c"
color="var(--warning)"
unit="°C"
yAxisUnit="°C"
emptyStateIcon={<Thermometer className="size-6" />}
@@ -40,7 +40,7 @@ export default function InsightsHauttemperaturPage() {
type="BODY_TEMPERATURE_DEVIATION"
title={t("measurements.typeBodyTemperatureDeviation")}
icon={ThermometerSnowflake}
color="#8be9fd"
color="var(--info)"
unit="°C"
fractionDigits={2}
sectionTitle={t("insights.bodyTempDeviation.title")}
+1 -1
View File
@@ -20,7 +20,7 @@ export default function InsightsStairAscentSpeedPage() {
chartKey="stairAscentSpeed"
i18nPrefix="insights.stairAscentSpeed"
explainerMetric="stairAscentSpeed"
color="#50fa7b"
color="var(--success)"
unit="m/s"
yAxisUnit="m/s"
emptyStateIcon={<Gauge className="size-6" />}
@@ -20,7 +20,7 @@ export default function InsightsStairDescentSpeedPage() {
chartKey="stairDescentSpeed"
i18nPrefix="insights.stairDescentSpeed"
explainerMetric="stairDescentSpeed"
color="#50fa7b"
color="var(--success)"
unit="m/s"
yAxisUnit="m/s"
emptyStateIcon={<Gauge className="size-6" />}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsSchrittlaengePage() {
chartKey="walkingStepLength"
i18nPrefix="insights.walkingStepLength"
explainerMetric="stepLength"
color="#50fa7b"
color="var(--success)"
unit="m"
yAxisUnit="m"
emptyStateIcon={<Footprints className="size-6" />}
+1 -1
View File
@@ -26,7 +26,7 @@ export default function InsightsStepsPage() {
i18nPrefix="insights.steps"
explainerMetric="steps"
statIcon={Footprints}
color="#50fa7b"
color="var(--success)"
unit="steps"
yAxisUnit="steps"
emptyStateIcon={<Footprints className="size-6" />}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsGefaessalterPage() {
chartKey="vascularAge"
i18nPrefix="insights.vascularAge"
explainerMetric="vascularAge"
color="#ff79c6"
color="var(--chart-3)"
unit="years"
yAxisUnit="years"
emptyStateIcon={<HeartPulse className="size-6" />}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsViszeralfettPage() {
chartKey="visceralFat"
i18nPrefix="insights.visceralFat"
explainerMetric="visceralFat"
color="#ffb86c"
color="var(--warning)"
unit=""
yAxisUnit=""
emptyStateIcon={<Droplets className="size-6" />}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsGangasymmetriePage() {
chartKey="walkingAsymmetry"
i18nPrefix="insights.walkingAsymmetry"
explainerMetric="walkingAsymmetry"
color="#50fa7b"
color="var(--success)"
unit="%"
yAxisUnit="%"
emptyStateIcon={<Footprints className="size-6" />}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsGehstreckePage() {
chartKey="walkingRunningDistance"
i18nPrefix="insights.walkingRunningDistance"
explainerMetric="walkingDistance"
color="#50fa7b"
color="var(--success)"
unit="km"
yAxisUnit="km"
// Stored in metres; display daily totals in km (1 m = 0.001 km).
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsGehpulsPage() {
chartKey="walkingHeartRateAverage"
i18nPrefix="insights.walkingHeartRateAverage"
explainerMetric="walkingHeartRate"
color="#ff5555"
color="var(--destructive)"
unit="bpm"
yAxisUnit="bpm"
emptyStateIcon={<HeartPulse className="size-6" />}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsGehgeschwindigkeitPage() {
chartKey="walkingSpeed"
i18nPrefix="insights.walkingSpeed"
explainerMetric="walkingSpeed"
color="#50fa7b"
color="var(--success)"
unit="km/h"
yAxisUnit="km/h"
valueScale={3.6}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsGangstabilitaetPage() {
chartKey="walkingSteadiness"
i18nPrefix="insights.walkingSteadiness"
explainerMetric="walkingSteadiness"
color="#50fa7b"
color="var(--success)"
unit="%"
yAxisUnit="%"
emptyStateIcon={<Gauge className="size-6" />}
+1 -1
View File
@@ -104,7 +104,7 @@ export default function InsightsGewichtPage() {
types={["WEIGHT"]}
title={t("charts.weight")}
titleIcon={Scale}
colors={["#bd93f9"]}
colors={["var(--chart-1)"]}
unit="kg"
valueBands={weightBands}
compareBaseline={compareBaseline}
+1 -1
View File
@@ -21,7 +21,7 @@ export default function InsightsWristTemperaturePage() {
chartKey="wristTemperature"
i18nPrefix="insights.wristTemperature"
explainerMetric="wristTemperature"
color="#ffb86c"
color="var(--warning)"
unit="°C"
yAxisUnit="°C"
emptyStateIcon={<Thermometer className="size-6" />}
+58 -64
View File
@@ -17,6 +17,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { PageHeader } from "@/components/ui/page-header";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useAuth } from "@/hooks/use-auth";
@@ -66,73 +67,66 @@ export default function LabsPage() {
return (
<div className="space-y-6">
<PullToRefreshIndicator {...pull} />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1
data-tour-id="labs-hero"
className="text-2xl font-bold tracking-tight"
>
{t("labs.title")}
</h1>
<p className="text-muted-foreground text-xs sm:text-sm">
{t("labs.subtitle")}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{/* v1.18.6 (MOD-01) — the wrench is the module's customize entry
point, left of the primary Add and linking to the Labs settings
page (view, sort order, biomarker CRUD + reorder). Mirrors the
medication page header's wrench glyph + slot + 44px tap floor. */}
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href="/settings/layout/labs"
aria-label={t("labs.customize")}
title={t("labs.customize")}
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
{/* v1.18.10 — the "Add" action is a CHOICE when scanning is
available: scan a document (OCR) or add a value by hand. The scan
option only appears when the capability probe reports a usable
mode (vision, or local OCR opted-in for text-only providers), so
the surface stays simple for everyone else. When scanning is
unavailable, Add opens the manual form directly. */}
{ocrCapability.data?.available ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="min-h-11 sm:min-h-9">
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setScanOpen(true)}>
<ScanLine className="h-4 w-4" aria-hidden="true" />
{t("labs.ocr.addScan")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setDialogOpen(true)}>
<Pencil className="h-4 w-4" aria-hidden="true" />
{t("labs.ocr.addManual")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<PageHeader
title={<span data-tour-id="labs-hero">{t("labs.title")}</span>}
description={t("labs.subtitle")}
actions={
<>
{/* v1.18.6 (MOD-01) — the wrench is the module's customize entry
point, left of the primary Add and linking to the Labs settings
page (view, sort order, biomarker CRUD + reorder). Mirrors the
medication page header's wrench glyph + slot + 44px tap floor. */}
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Plus className="h-4 w-4" />
{t("common.add")}
<Link
href="/settings/layout/labs"
aria-label={t("labs.customize")}
title={t("labs.customize")}
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
)}
</div>
</div>
{/* v1.18.10 — the "Add" action is a CHOICE when scanning is
available: scan a document (OCR) or add a value by hand. The scan
option only appears when the capability probe reports a usable
mode (vision, or local OCR opted-in for text-only providers), so
the surface stays simple for everyone else. When scanning is
unavailable, Add opens the manual form directly. */}
{ocrCapability.data?.available ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="min-h-11 sm:min-h-9">
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setScanOpen(true)}>
<ScanLine className="h-4 w-4" aria-hidden="true" />
{t("labs.ocr.addScan")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setDialogOpen(true)}>
<Pencil className="h-4 w-4" aria-hidden="true" />
{t("labs.ocr.addManual")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
)}
</>
}
/>
<ResponsiveSheet
open={dialogOpen}
+1 -1
View File
@@ -88,7 +88,7 @@ export const viewport: Viewport = {
// `--background` from `app/globals.css` for each theme so the bar
// edge never seams against the page on cold paint.
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: light)", color: "#f3f2f5" },
{ media: "(prefers-color-scheme: dark)", color: "#282a36" },
],
};
+10 -17
View File
@@ -13,6 +13,7 @@ import { MeasurementList } from "@/components/measurements/measurement-list";
import { CustomMetricList } from "@/components/custom-metrics/custom-metric-list";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/ui/page-header";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Plus, Loader2 } from "lucide-react";
@@ -125,22 +126,14 @@ export default function MeasurementsPage() {
return (
<div className="space-y-6">
<PullToRefreshIndicator {...pull} />
<div className="flex items-center justify-between">
<div className="min-w-0">
<h1
data-tour-id="measurements-hero"
className="text-2xl font-bold tracking-tight"
>
<PageHeader
title={
<span data-tour-id="measurements-hero">
{t("measurements.title")}
</h1>
{/* v1.4.34 IW-G — subtitle now stays visible on mobile so
the H1 isn't an unframed label. `text-xs sm:text-sm`
preserves the desktop hierarchy. */}
<p className="text-muted-foreground truncate text-xs sm:text-sm">
{t("measurements.subtitle")}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
</span>
}
description={t("measurements.subtitle")}
actions={
<Button
className="min-h-11 sm:min-h-9"
onClick={() => setDialogOpen(true)}
@@ -148,8 +141,8 @@ export default function MeasurementsPage() {
<Plus className="h-4 w-4" />
{t("measurements.addMeasurement")}
</Button>
</div>
</div>
}
/>
<ResponsiveSheet
open={dialogOpen}
+34 -42
View File
@@ -9,6 +9,7 @@ import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { MoodForm } from "@/components/mood/mood-form";
import { MoodList } from "@/components/mood/mood-list";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/ui/page-header";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Plus, Loader2, Wrench } from "lucide-react";
@@ -63,49 +64,40 @@ export default function MoodPage() {
return (
<div className="space-y-6">
<PullToRefreshIndicator {...pull} />
<div className="flex items-center justify-between">
<div className="min-w-0">
<h1
data-tour-id="mood-hero"
className="text-2xl font-bold tracking-tight"
>
{t("mood.title")}
</h1>
{/* v1.4.34 IW-G — subtitle stays visible on mobile so the
H1 isn't an unframed label. */}
<p className="text-muted-foreground truncate text-xs sm:text-sm">
{t("mood.subtitle")}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{/* v1.17 — the wrench is the one customize entry point: it
links to /settings/mood, which owns the tag groups, custom
tags, visibility, and picker order. Same glyph, slot (left
of the add button) and responsive 44-px mobile tap floor
as the medications header. */}
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href="/settings/layout/mood"
aria-label={t("mood.customize")}
title={t("mood.customize")}
<PageHeader
title={<span data-tour-id="mood-hero">{t("mood.title")}</span>}
description={t("mood.subtitle")}
actions={
<>
{/* v1.17 — the wrench is the one customize entry point: it
links to /settings/mood, which owns the tag groups, custom
tags, visibility, and picker order. Same glyph, slot (left
of the add button) and responsive 44-px mobile tap floor
as the medications header. */}
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("mood.addEntry")}
</Button>
</div>
</div>
<Link
href="/settings/layout/mood"
aria-label={t("mood.customize")}
title={t("mood.customize")}
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("mood.addEntry")}
</Button>
</>
}
/>
<ResponsiveSheet
open={dialogOpen}
+17 -30
View File
@@ -6,6 +6,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/ui/page-header";
import Link from "next/link";
import { Bell, Loader2, Settings, AlertCircle } from "lucide-react";
import {
@@ -129,12 +130,10 @@ export default function NotificationsPage() {
if (!isAuthenticated) {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold tracking-tight">
{t("notifications.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("notifications.loginRequired")}
</p>
<PageHeader
title={t("notifications.title")}
description={t("notifications.loginRequired")}
/>
</div>
);
}
@@ -146,14 +145,10 @@ export default function NotificationsPage() {
if (isError) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("notifications.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("notifications.subtitle")}
</p>
</div>
<PageHeader
title={t("notifications.title")}
description={t("notifications.subtitle")}
/>
<div
role="alert"
className="border-destructive/40 bg-destructive/10 text-destructive rounded-lg border p-4 text-sm"
@@ -168,14 +163,10 @@ export default function NotificationsPage() {
if (activeChannels.length === 0) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("notifications.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("notifications.subtitle")}
</p>
</div>
<PageHeader
title={t("notifications.title")}
description={t("notifications.subtitle")}
/>
<div className="bg-card border-border max-w-2xl rounded-xl border p-6">
<div className="flex flex-col items-center gap-4 py-8 text-center">
<Bell className="text-muted-foreground h-12 w-12" />
@@ -196,14 +187,10 @@ export default function NotificationsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("notifications.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("notifications.subtitle")}
</p>
</div>
<PageHeader
title={t("notifications.title")}
description={t("notifications.subtitle")}
/>
{/* Desktop: table layout */}
<div className="bg-card border-border hidden overflow-hidden rounded-xl border md:block">
+41 -16
View File
@@ -561,19 +561,19 @@ export default function DashboardPage() {
{
min: bpTargets.sysLow,
max: bpTargets.sysHigh,
color: "#ff79c6",
color: "var(--chart-3)",
opacity: 0.21,
label: t("charts.systolic"),
textColor: "#ff79c6",
textColor: "var(--chart-3)",
lineOpacity: 0.24,
},
{
min: bpTargets.diaLow,
max: bpTargets.diaHigh,
color: "#8be9fd",
color: "var(--chart-4)",
opacity: 0.21,
label: t("charts.diastolic"),
textColor: "#8be9fd",
textColor: "var(--chart-4)",
lineOpacity: 0.24,
},
]
@@ -1174,7 +1174,7 @@ export default function DashboardPage() {
chartKey="weight"
types={["WEIGHT"]}
title={t("dashboard.weight")}
colors={["#bd93f9"]}
colors={["var(--chart-1)"]}
unit="kg"
valueBands={weightBands}
compareBaseline={compareBaseline}
@@ -1196,15 +1196,40 @@ export default function DashboardPage() {
chartKey="bmi"
types={["WEIGHT"]}
title={t("targets.bmi")}
colors={["#f1fa8c"]}
colors={["var(--dracula-yellow)"]}
unit="kg/m²"
valueMode="bmi"
valueBands={[
{ min: 0, max: 17, color: "#ff5555", opacity: 0.16 },
{ min: 17, max: 18.5, color: "#ffb86c", opacity: 0.18 },
{ min: 18.5, max: 24.9, color: "#50fa7b", opacity: 0.2 },
{ min: 24.9, max: 29.9, color: "#ffb86c", opacity: 0.18 },
{ min: 29.9, max: 120, color: "#ff5555", opacity: 0.16 },
{
min: 0,
max: 17,
color: "var(--destructive)",
opacity: 0.16,
},
{
min: 17,
max: 18.5,
color: "var(--warning)",
opacity: 0.18,
},
{
min: 18.5,
max: 24.9,
color: "var(--success)",
opacity: 0.2,
},
{
min: 24.9,
max: 29.9,
color: "var(--warning)",
opacity: 0.18,
},
{
min: 29.9,
max: 120,
color: "var(--destructive)",
opacity: 0.16,
},
]}
compareBaseline={compareBaseline}
/>
@@ -1227,7 +1252,7 @@ export default function DashboardPage() {
chartKey="bp"
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
title={t("dashboard.bloodPressure")}
colors={["#ff79c6", "#8be9fd"]}
colors={["var(--chart-3)", "var(--chart-4)"]}
unit="mmHg"
yAxisUnit="Hg"
targetZones={bpTargetZones}
@@ -1256,7 +1281,7 @@ export default function DashboardPage() {
// expected-high workout HR as "outside target").
types={hasRestingHr ? ["RESTING_HEART_RATE"] : ["PULSE"]}
title={t("dashboard.pulse")}
colors={["#50fa7b"]}
colors={["var(--success)"]}
unit="bpm"
valueBands={hasRestingHr ? pulseBands : undefined}
compareBaseline={compareBaseline}
@@ -1280,7 +1305,7 @@ export default function DashboardPage() {
chartKey="bodyFat"
types={["BODY_FAT"]}
title={t("dashboard.bodyFat")}
colors={["#ffb86c"]}
colors={["var(--warning)"]}
unit="%"
valueBands={bodyFatBands}
compareBaseline={compareBaseline}
@@ -1319,7 +1344,7 @@ export default function DashboardPage() {
chartKey="sleep"
types={["SLEEP_DURATION"]}
title={t("dashboard.sleep") ?? "Sleep"}
colors={["#8be9fd"]}
colors={["var(--chart-4)"]}
unit="h"
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
@@ -1342,7 +1367,7 @@ export default function DashboardPage() {
chartKey="steps"
types={["ACTIVITY_STEPS"]}
title={t("dashboard.steps") ?? "Steps"}
colors={["#50fa7b"]}
colors={["var(--success)"]}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
@@ -0,0 +1,92 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
/**
* v1.26.0 — design-consistency guardrails.
*
* These pin the invariants the design-consistency wave established so a
* later edit cannot silently undo them:
* 1. the authenticated shell's scroll container keeps its
* scrollbar-gutter reservation and single centred max-width box (the
* width-stability contract — a 4th width-shift regression must not
* recur);
* 2. the canonical `<PageHeader>` renders exactly one, correctly-styled
* `<h1>` (one-H1-per-page vocabulary);
* 3. `<QueryErrorCard>` renders its retry affordance so a failed read
* never falls through to an honest-empty state.
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("width stability — auth-shell scroll container", () => {
const source = readFileSync(
join(process.cwd(), "src", "components", "layout", "auth-shell.tsx"),
"utf8",
);
it("keeps [scrollbar-gutter:stable] on the scroll <main>", () => {
// The reserved gutter is what stops the whole viewport widening by a
// scrollbar's width when a short page (e.g. Integrations) hides the
// scroll. Without it the centred container shifts on every route swap.
expect(source).toContain("[scrollbar-gutter:stable]");
});
it("keeps the single centred `mx-auto max-w-screen-xl` container", () => {
// Every authenticated surface shares ONE normalised content box. The
// literal ordering `mx-auto max-w-screen-xl` is the class the shell
// ships; a duplicate or differently-capped box would reintroduce the
// per-route width drift this guard exists to prevent.
expect(source).toContain("mx-auto max-w-screen-xl");
const occurrences = source.split("mx-auto max-w-screen-xl").length - 1;
expect(occurrences).toBe(1);
});
});
describe("<PageHeader> — one canonical H1", () => {
it("renders exactly one <h1> with the canonical type scale", () => {
const html = render(
<PageHeader title="Dashboard" description="Your day at a glance" />,
);
const h1Count = (html.match(/<h1\b/g) ?? []).length;
expect(h1Count).toBe(1);
// The H1 carries the fixed header type scale used app-wide.
expect(html).toMatch(
/<h1[^>]*class="[^"]*text-2xl font-bold tracking-tight[^"]*"/,
);
expect(html).toContain("Dashboard");
});
it("renders no second heading when only a title is supplied", () => {
const html = render(<PageHeader title="Settings" />);
expect((html.match(/<h1\b/g) ?? []).length).toBe(1);
expect(html).not.toContain("<h2");
});
});
describe("<QueryErrorCard> — retry affordance", () => {
it("renders a retry button when onRetry is provided", () => {
const html = render(<QueryErrorCard onRetry={() => {}} />);
expect(html).toContain('data-slot="query-error-card"');
expect(html).toContain('role="alert"');
// The retry affordance is a real <button> so a failed read is
// recoverable in-place.
expect(html).toContain("<button");
expect(html).toContain("Retry");
});
it("omits the retry button when no onRetry is supplied", () => {
const html = render(<QueryErrorCard />);
expect(html).toContain('data-slot="query-error-card"');
expect(html).not.toContain("<button");
});
});
@@ -65,6 +65,67 @@ describe("motion-reduce coverage — every animate-spin pairs with motion-reduce
});
});
/**
* v1.26.0 — indefinite-animation reduced-motion sweep.
*
* `animate-pulse` / `animate-bounce` / `animate-ping` are Tailwind's
* INDEFINITE keyframe utilities: unlike a one-shot entrance, they loop
* until the element unmounts. Any such site that runs for an unbounded
* time (a typing indicator, a "listening" mic pulse, a live-status dot)
* must pair with `motion-reduce:animate-none` so a motion-sensitive user
* sees a static element instead of a perpetual throb.
*
* Allowlisted: load-terminating skeletons. A `<Skeleton>` / chart
* placeholder animates only while data is in flight and then unmounts, so
* its pulse is transient rather than perpetual; those files own their own
* reduced-motion story (and today still carry the modifier anyway). The
* allowlist is the escape hatch for that one legitimate class of use — an
* indefinite site elsewhere still has to carry the modifier.
*
* The check is textual and skips comment lines, mirroring the
* `animate-spin` sweep above.
*/
describe("motion-reduce coverage — indefinite pulse/bounce/ping pairs with motion-reduce:animate-none", () => {
const files = collectTsxFiles(ROOT);
// Load-terminating skeleton surfaces: their animation is bounded by the
// data-load lifecycle, so they are exempt from the perpetual-animation
// rule. Path-suffix matched against the absolute filename.
const SKELETON_ALLOWLIST = [
"src/components/ui/skeleton.tsx",
"src/components/charts/chart-skeleton.tsx",
];
const INDEFINITE_RE = /animate-(pulse|bounce|ping)/;
it("every indefinite animation site declares motion-reduce:animate-none", () => {
const violations: string[] = [];
for (const file of files) {
const posix = file.replace(/\\/g, "/");
if (SKELETON_ALLOWLIST.some((f) => posix.includes(f))) continue;
const src = readFileSync(file, "utf8");
const lines = src.split("\n");
lines.forEach((line, idx) => {
if (!INDEFINITE_RE.test(line)) return;
// Skip comment / JSDoc prose that merely names the utility.
const trimmed = line.trim();
if (trimmed.startsWith("//") || trimmed.startsWith("*")) return;
if (!line.includes("motion-reduce:animate-none")) {
violations.push(`${file}:${idx + 1}: ${trimmed}`);
}
});
}
expect(
violations,
`The following indefinite animate-pulse/bounce/ping sites are missing ` +
`motion-reduce:animate-none. Append the modifier so motion-sensitive ` +
`users see a static element (or allowlist the file if it is a ` +
`load-terminating skeleton):\n` +
violations.join("\n"),
).toEqual([]);
});
});
/**
* v1.11.3 — `.animate-insight-in` reduced-motion guard.
*
@@ -115,11 +115,12 @@ describe("ApiTokenOverviewSection — responsive", () => {
expect(html).toContain("All");
});
it("uses a smaller card padding on mobile (p-4 sm:p-6)", () => {
it("uses a smaller card padding on mobile (p-4 md:p-6)", () => {
const html = render();
// The card root carries the responsive padding to leave more room
// for content within a 393 CSS-px viewport.
expect(html).toMatch(/class="[^"]*\bp-4\b[^"]*sm:p-6/);
// The card root carries the shared SettingsCard padding contract
// (`p-4 md:p-6`) to leave more room for content within a 393 CSS-px
// viewport while stepping up only from `md`, not `sm`.
expect(html).toMatch(/class="[^"]*\bp-4\b[^"]*md:p-6/);
});
it("desktop date cells drop whitespace-nowrap (v1.4.22 C2 5th-attempt fix)", () => {
@@ -115,9 +115,9 @@ describe("UserManagementSection — mobile responsive", () => {
expect(mobileSlice).toContain("Force logout");
});
it("uses smaller card padding on mobile (p-4 sm:p-6)", () => {
it("uses smaller card padding on mobile (p-4 md:p-6)", () => {
const html = render();
expect(html).toMatch(/class="[^"]*\bp-4\b[^"]*sm:p-6/);
expect(html).toMatch(/class="[^"]*\bp-4\b[^"]*md:p-6/);
});
it("wraps the filter pills below the title on narrow viewports", () => {
+3 -2
View File
@@ -21,6 +21,7 @@
import { useQuery } from "@tanstack/react-query";
import { Sparkles, Loader2 } 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";
@@ -75,7 +76,7 @@ export function AiQualitySection() {
// resolves. Mirrors <CoachFeedbackSection> (header outside the
// fetch-state branch).
return (
<div className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6">
<SettingsCard className="space-y-4">
<SettingsCardHeader
icon={Sparkles}
title={t("admin.aiQuality.title")}
@@ -178,6 +179,6 @@ export function AiQualitySection() {
</table>
</div>
)}
</div>
</SettingsCard>
);
}
@@ -9,6 +9,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";
@@ -92,10 +93,7 @@ export function AiServerKeySection() {
}
return (
<div
data-slot="admin-ai-server-key"
className="bg-card border-border rounded-xl border p-4 sm:p-6"
>
<SettingsCard data-slot="admin-ai-server-key">
<SettingsCardHeader
icon={KeyRound}
title={t("admin.aiServerKey.title")}
@@ -183,6 +181,6 @@ export function AiServerKeySection() {
)}
</div>
</div>
</div>
</SettingsCard>
);
}
@@ -11,7 +11,9 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { formatDate, formatDateTime } 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 ApiTokenInfo } from "./_shared";
@@ -157,7 +159,7 @@ export function ApiTokenOverviewSection() {
});
return (
<div className="bg-card border-border overflow-hidden rounded-xl border p-4 sm:p-6">
<SettingsCard className="overflow-hidden">
<SettingsCardHeader
icon={Key}
title={t("admin.apiTokens")}
@@ -293,55 +295,60 @@ export function ApiTokenOverviewSection() {
>
{tokens.map((token) => {
return (
<li
<ListRow
asChild
key={token.id}
className="bg-muted/30 border-border overflow-hidden rounded-lg border p-3"
className="bg-muted/30 border-border overflow-hidden"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<li>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<TruncatedCell
value={formatTokenName(token.name)}
className="min-w-0 flex-1 font-medium"
/>
<TokenStatusBadge token={token} size="xs" />
</div>
<TruncatedCell
value={formatTokenName(token.name)}
className="min-w-0 flex-1 font-medium"
value={token.user.username}
className="text-muted-foreground text-xs"
/>
<TokenStatusBadge token={token} size="xs" />
</div>
<TruncatedCell
value={token.user.username}
className="text-muted-foreground text-xs"
/>
</div>
</div>
<div className="mt-2 flex flex-wrap gap-1">
{token.permissions.map((p) => (
<Badge
key={p}
variant="secondary"
className="max-w-full truncate text-[10px]"
title={
p === "*" ? t("admin.tokenPermissionAllTooltip") : p
}
>
{p === "*" ? t("admin.tokenPermissionAll") : p}
</Badge>
))}
</div>
<p className="text-muted-foreground mt-2 text-[11px]">
{t("admin.tokenLastUsed")}:{" "}
{token.lastUsedAt
? formatDateTime(token.lastUsedAt)
: t("admin.tokenNeverUsed")}
</p>
<p className="text-muted-foreground text-[11px]">
{t("admin.tokenCreated")}: {formatDate(token.createdAt)}
</p>
</li>
<div className="mt-2 flex flex-wrap gap-1">
{token.permissions.map((p) => (
<Badge
key={p}
variant="secondary"
className="max-w-full truncate text-[10px]"
title={
p === "*"
? t("admin.tokenPermissionAllTooltip")
: p
}
>
{p === "*" ? t("admin.tokenPermissionAll") : p}
</Badge>
))}
</div>
<p className="text-muted-foreground mt-2 text-[11px]">
{t("admin.tokenLastUsed")}:{" "}
{token.lastUsedAt
? formatDateTime(token.lastUsedAt)
: t("admin.tokenNeverUsed")}
</p>
<p className="text-muted-foreground text-[11px]">
{t("admin.tokenCreated")}: {formatDate(token.createdAt)}
</p>
</li>
</ListRow>
);
})}
</ul>
</>
)}
</div>
</div>
</SettingsCard>
);
}
@@ -46,6 +46,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
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";
@@ -115,7 +116,7 @@ export function AppLogPreviewSection() {
const events = data?.events ?? [];
return (
<div className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6">
<SettingsCard className="space-y-4">
<SettingsCardHeader
icon={FileText}
title={t("admin.section.app-logs.title")}
@@ -303,6 +304,6 @@ export function AppLogPreviewSection() {
</DialogClose>
</DialogContent>
</Dialog>
</div>
</SettingsCard>
);
}
+3 -2
View File
@@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Sparkles } 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";
@@ -99,7 +100,7 @@ export function AssistantSection() {
const disabledSubs = !masterOn || mutation.isPending;
return (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCard>
<SettingsCardHeader
icon={Sparkles}
title={t("admin.assistant.title")}
@@ -166,6 +167,6 @@ export function AssistantSection() {
/>
</div>
</div>
</div>
</SettingsCard>
);
}
+47 -42
View File
@@ -39,7 +39,9 @@ import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
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 { ListRow } from "@/components/ui/list-row";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { BackupRow, BackupsList } from "@/types/backups";
@@ -332,7 +334,7 @@ export function BackupsSection() {
}
return (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCard>
{/* v1.18.1 E3 — the snapshot count leads (numbers first), the
"Run now" button follows, and the explainer + docs link move
into the header's description slot. Previously the description
@@ -550,48 +552,51 @@ export function BackupsSection() {
data-testid="admin-backups-mobile-list"
>
{rows.map((row) => (
<li
<ListRow
asChild
key={row.id}
className="bg-muted/30 border-border rounded-lg border p-3"
className="bg-muted/30 border-border"
>
<div className="flex items-center gap-2">
<span className="truncate font-medium">{row.username}</span>
<Badge variant="secondary" className="text-[10px]">
{formatBackupType(row.type, t)}
</Badge>
</div>
<p className="text-muted-foreground text-[11px]">
{t("admin.section.backups.colSize")}:{" "}
{formatBytes(row.sizeBytes, fmt)} ·{" "}
{fmt.dateTime(row.createdAt)}
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={downloadingId === row.id}
onClick={() => handleDownload(row)}
aria-label={t("admin.section.backups.downloadAria", {
username: row.username,
})}
className="min-h-11"
>
{downloadingId === row.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Download className="h-3.5 w-3.5" />
)}
{t("admin.section.backups.download")}
</Button>
<RestoreRowDialog
row={row}
pending={
restore.isPending && restore.variables?.id === row.id
}
onConfirm={() => restore.mutate(row)}
/>
</div>
</li>
<li>
<div className="flex items-center gap-2">
<span className="truncate font-medium">{row.username}</span>
<Badge variant="secondary" className="text-[10px]">
{formatBackupType(row.type, t)}
</Badge>
</div>
<p className="text-muted-foreground text-[11px]">
{t("admin.section.backups.colSize")}:{" "}
{formatBytes(row.sizeBytes, fmt)} ·{" "}
{fmt.dateTime(row.createdAt)}
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={downloadingId === row.id}
onClick={() => handleDownload(row)}
aria-label={t("admin.section.backups.downloadAria", {
username: row.username,
})}
className="min-h-11"
>
{downloadingId === row.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Download className="h-3.5 w-3.5" />
)}
{t("admin.section.backups.download")}
</Button>
<RestoreRowDialog
row={row}
pending={
restore.isPending && restore.variables?.id === row.id
}
onConfirm={() => restore.mutate(row)}
/>
</div>
</li>
</ListRow>
))}
</ul>
@@ -602,6 +607,6 @@ export function BackupsSection() {
</p>
</div>
)}
</div>
</SettingsCard>
);
}
@@ -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
// <SystemStatusSection> (header outside the fetch-state branch).
return (
<div className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6">
<SettingsCard className="space-y-4">
<SettingsCardHeader
icon={Sparkles}
title={t("admin.coachFeedback.title")}
@@ -167,6 +168,6 @@ export function CoachFeedbackSection() {
</table>
</div>
)}
</div>
</SettingsCard>
);
}
@@ -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 (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCard>
<SettingsCardHeader
icon={Settings}
title={t("admin.appSettings")}
@@ -117,6 +118,6 @@ export function GeneralSettingsSection() {
</NativeSelect>
</div>
</div>
</div>
</SettingsCard>
);
}
+3 -2
View File
@@ -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 (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCard>
<SettingsCardHeader
icon={AlertTriangle}
title={t("admin.glitchtipTitle")}
@@ -152,6 +153,6 @@ export function GlitchtipSection() {
{t("common.save")}
</Button>
</div>
</div>
</SettingsCard>
);
}
+9 -20
View File
@@ -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 (
<section
aria-labelledby="admin-host-metrics-heading"
className="bg-card border-border rounded-xl border p-4 md:p-6"
>
<SettingsCard as="section" aria-labelledby="admin-host-metrics-heading">
<div className="mb-3 flex items-center justify-between">
<h2 id="admin-host-metrics-heading" className="text-sm font-semibold">
{t("admin.hostMetrics.title")}
@@ -169,16 +167,13 @@ export function HostMetricsChart() {
</span>
</div>
<Skeleton className="bg-muted/40 h-[180px] w-full" />
</section>
</SettingsCard>
);
}
if (isError) {
return (
<section
aria-labelledby="admin-host-metrics-heading"
className="bg-card border-border rounded-xl border p-4 md:p-6"
>
<SettingsCard as="section" aria-labelledby="admin-host-metrics-heading">
<h2 id="admin-host-metrics-heading" className="text-sm font-semibold">
{t("admin.hostMetrics.title")}
</h2>
@@ -188,7 +183,7 @@ export function HostMetricsChart() {
>
{t("admin.hostMetrics.loadError")}
</p>
</section>
</SettingsCard>
);
}
@@ -197,10 +192,7 @@ export function HostMetricsChart() {
// instead of an empty plot.
if (rows.length === 0) {
return (
<section
aria-labelledby="admin-host-metrics-heading"
className="bg-card border-border rounded-xl border p-4 md:p-6"
>
<SettingsCard as="section" aria-labelledby="admin-host-metrics-heading">
<div className="mb-3 flex items-center justify-between">
<h2 id="admin-host-metrics-heading" className="text-sm font-semibold">
{t("admin.hostMetrics.title")}
@@ -212,15 +204,12 @@ export function HostMetricsChart() {
<p className="text-muted-foreground text-xs">
{t("admin.hostMetrics.empty")}
</p>
</section>
</SettingsCard>
);
}
return (
<section
aria-labelledby="admin-host-metrics-heading"
className="bg-card border-border rounded-xl border p-4 md:p-6"
>
<SettingsCard as="section" aria-labelledby="admin-host-metrics-heading">
<div className="mb-3 flex items-center justify-between">
<h2 id="admin-host-metrics-heading" className="text-sm font-semibold">
{t("admin.hostMetrics.title")}
@@ -354,6 +343,6 @@ export function HostMetricsChart() {
</LineChart>
</ResponsiveContainer>
</div>
</section>
</SettingsCard>
);
}
@@ -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 (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCard>
<SettingsCardHeader
icon={ScrollText}
title={t("admin.loginOverview")}
@@ -580,6 +581,6 @@ export function LoginOverviewSection() {
</>
)}
</div>
</div>
</SettingsCard>
);
}
@@ -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 (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCard>
<SettingsCardHeader
icon={ToggleLeft}
title={t("admin.modules.title")}
@@ -119,6 +120,6 @@ export function ModuleAvailabilitySection() {
);
})}
</div>
</div>
</SettingsCard>
);
}
@@ -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 (
<section
aria-labelledby="admin-overview-audit-heading"
className="bg-card border-border rounded-xl border p-4 sm:p-6"
>
<SettingsCard as="section" aria-labelledby="admin-overview-audit-heading">
<SettingsCardHeader
icon={ScrollText}
titleId="admin-overview-audit-heading"
@@ -145,6 +143,6 @@ export function RecentAuditPreview() {
</ul>
)}
</div>
</section>
</SettingsCard>
);
}
+13 -12
View File
@@ -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 (
<div className="bg-card border-border mt-6 rounded-xl border p-4 sm:p-6">
<SettingsCard className="mt-6">
<SettingsCardHeader
icon={HeartPulse}
title={t("admin.notificationHealth.title")}
@@ -80,10 +81,10 @@ function NotificationHealthPanel() {
>
<span className="font-medium">{c.channel}</span>
<span className="flex items-center gap-3 text-xs">
<span className="text-success dark:text-green-400">
<span className="text-success">
{t("admin.notificationHealth.ok")}: {c.ok}
</span>
<span className="text-destructive dark:text-red-400">
<span className="text-destructive">
{t("admin.notificationHealth.error")}: {c.error}
</span>
<span className="text-muted-foreground">
@@ -110,7 +111,7 @@ function NotificationHealthPanel() {
</div>
)}
</div>
</div>
</SettingsCard>
);
}
@@ -194,7 +195,7 @@ export function RemindersSection() {
return (
<>
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCard>
{/* 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) => (
<div key={i} className="flex items-center gap-1.5 text-xs">
{r.success ? (
<CheckCircle2 className="text-success h-3.5 w-3.5 shrink-0 dark:text-green-400" />
<CheckCircle2 className="text-success h-3.5 w-3.5 shrink-0" />
) : (
<XCircle className="text-destructive h-3.5 w-3.5 shrink-0 dark:text-red-400" />
<XCircle className="text-destructive h-3.5 w-3.5 shrink-0" />
)}
<span className="font-medium">{r.channel}</span>
{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() {
</span>
<span className={statusColor}>{sched.label}</span>
{sched.notificationSent && (
<span className="text-success flex shrink-0 items-center gap-0.5 dark:text-green-400">
<span className="text-success flex shrink-0 items-center gap-0.5">
<Bell className="h-3 w-3" />
{t("admin.reminderCheckNotifSent")}
</span>
@@ -404,7 +405,7 @@ export function RemindersSection() {
</div>
</div>
)}
</div>
</SettingsCard>
<NotificationHealthPanel />
</>
);
+3 -2
View File
@@ -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 (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCard>
<SettingsCardHeader
icon={Globe}
title={t("admin.servicesGlobal")}
@@ -69,6 +70,6 @@ export function ServicesSection() {
disabled={updateSettings.isPending}
/>
</div>
</div>
</SettingsCard>
);
}
@@ -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 (
<div className="space-y-6">
<HostMetricsChart />
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCard>
<SettingsCardHeader
icon={Server}
title={t("admin.systemStatus")}
@@ -205,7 +206,7 @@ export function SystemStatusSection() {
{isFetching ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RotateCw className="h-3.5 w-3.5" />
<RefreshCw className="h-3.5 w-3.5" />
)}
{t("admin.systemStatusRetry")}
</Button>
@@ -218,7 +219,7 @@ export function SystemStatusSection() {
</span>
</div>
)}
</div>
</SettingsCard>
</div>
);
}
@@ -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 (
<section
<SettingsCard
as="section"
aria-labelledby="admin-overview-snapshot-heading"
className="bg-card border-border rounded-xl border p-4 sm:p-6"
>
<SettingsCardHeader
icon={Server}
@@ -136,6 +137,6 @@ export function SystemStatusSummary() {
</span>
</div>
)}
</section>
</SettingsCard>
);
}
+3 -2
View File
@@ -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 (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCard>
<SettingsCardHeader
icon={Activity}
title={t("admin.umamiTitle")}
@@ -153,6 +154,6 @@ export function UmamiSection() {
{t("common.save")}
</Button>
</div>
</div>
</SettingsCard>
);
}
@@ -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 (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCard>
<SettingsCardHeader
icon={Users}
title={t("admin.userManagement")}
@@ -385,36 +387,41 @@ export function UserManagementSection() {
data-testid="admin-users-mobile-list"
>
{filteredUsers.map((u) => (
<li
<ListRow
asChild
key={u.id}
className="bg-muted/30 border-border rounded-lg border p-3"
className="bg-muted/30 border-border"
>
<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>
<li>
<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>
<div className="mt-2 flex flex-wrap items-center gap-1">
{renderUserActions(u)}
</div>
</li>
</ListRow>
))}
</ul>
</>
@@ -594,6 +601,6 @@ export function UserManagementSection() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</SettingsCard>
);
}
@@ -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 (
<section
aria-labelledby="admin-overview-version-heading"
className="bg-card border-border rounded-xl border p-4 sm:p-6"
>
<SettingsCard as="section" aria-labelledby="admin-overview-version-heading">
<SettingsCardHeader
icon={Tag}
titleId="admin-overview-version-heading"
@@ -133,6 +131,6 @@ export function VersionTileSection() {
value={version?.builtAt ? formatDateTime(version.builtAt) : "—"}
/>
</div>
</section>
</SettingsCard>
);
}
@@ -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 (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCard>
<SettingsCardHeader
icon={BellRing}
title={t("admin.webPushVapidTitle")}
@@ -242,6 +243,6 @@ export function WebPushVapidSection() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</SettingsCard>
);
}
@@ -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();
});
+3 -3
View File
@@ -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",
+1 -1
View File
@@ -205,7 +205,7 @@ export function ClinicianView({
{/* ── Fenced wellness card (descriptive, NOT clinical) ────── */}
{wellness.length > 0 ? (
<section className="rounded-lg border border-dashed border-amber-500/50 bg-amber-500/5 p-5">
<section className="border-warning/50 bg-warning/5 rounded-lg border border-dashed p-5">
<h2 className="text-muted-foreground mb-1 text-base font-semibold">
{t("clinicianView.wellness.title")}
</h2>
+7 -15
View File
@@ -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 (
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1
data-tour-id="cycle-hero"
className="text-2xl font-bold tracking-tight"
>
{t("cycle.title")}
</h1>
<p className="text-muted-foreground text-xs sm:text-sm">
{t("cycle.subtitle")}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<PageHeader
title={<span data-tour-id="cycle-hero">{t("cycle.title")}</span>}
description={t("cycle.subtitle")}
actions={
<Button
onClick={() => openSheet(today)}
className="min-h-11 sm:min-h-9"
@@ -145,8 +137,8 @@ export function CycleView() {
<Plus className="h-4 w-4" />
{t("cycle.logToday")}
</Button>
</div>
</div>
}
/>
{/* Desktop: ring (left) + tabs (right). Single column below lg. */}
<div className="grid gap-6 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:items-start">
+89 -93
View File
@@ -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. */
<div
className={cn(
"flex items-center justify-between gap-4",
showGreeting && "sm:items-start",
)}
>
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">
{t("dashboard.title")}
</h1>
{showGreeting ? (
<PageHeader
title={t("dashboard.title")}
description={
showGreeting ? (
/* `min-h-5` reserves the greeting's line box from the SSR
pass on: the text personalises (name appended) on the first
client re-render after hydration, and the reserved line
keeps the header from collapsing/growing around that swap. */
<p
<span
data-slot="dashboard-header-greeting"
className="text-muted-foreground mt-1 min-h-5 truncate text-sm"
className="block min-h-5 truncate"
>
{welcomeText}
</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
{/* 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+. */}
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
data-testid="dashboard-customize-shortcut"
>
<Link
href="/settings/layout/dashboard"
aria-label={t("dashboard.customizeDashboard")}
title={t("dashboard.customizeDashboard")}
</span>
) : undefined
}
actions={
<>
{/* 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+. */}
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
data-testid="dashboard-customize-shortcut"
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* 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. */}
<Button
size="default"
className="min-h-11 sm:min-h-9"
data-tour-id="dashboard-quick-add"
<Link
href="/settings/layout/dashboard"
aria-label={t("dashboard.customizeDashboard")}
title={t("dashboard.customizeDashboard")}
>
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-w-[calc(100vw-2rem)]">
{/* 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`. */}
<DropdownMenuItem onClick={() => onQuickEntry("measurement")}>
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMeasurement")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onQuickEntry("mood")}>
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMood")}
</DropdownMenuItem>
{/* 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). */}
<DropdownMenuItem onClick={() => onQuickEntry("medicationIntake")}>
<Pill className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMedicationIntake")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* 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. */}
<Button
size="default"
className="min-h-11 sm:min-h-9"
data-tour-id="dashboard-quick-add"
>
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="max-w-[calc(100vw-2rem)]"
>
{/* 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`. */}
<DropdownMenuItem onClick={() => onQuickEntry("measurement")}>
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMeasurement")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onQuickEntry("mood")}>
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMood")}
</DropdownMenuItem>
{/* 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). */}
<DropdownMenuItem
onClick={() => onQuickEntry("medicationIntake")}
>
<Pill className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMedicationIntake")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
}
/>
);
}
@@ -62,6 +62,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
@@ -237,7 +238,9 @@ export function InboundDocumentsView() {
<header className="flex items-center gap-3">
<FileScan className="text-primary h-6 w-6" aria-hidden />
<div>
<h1 className="text-xl font-semibold">{t("documents.title")}</h1>
<h1 className="text-2xl font-bold tracking-tight">
{t("documents.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("documents.subtitle")}
</p>
@@ -309,11 +312,10 @@ export function InboundDocumentsView() {
<Label htmlFor="doc-date">
{t("documents.upload.dateLabel")}
</Label>
<Input
<DateField
id="doc-date"
type="date"
value={uploadDate}
onChange={(e) => setUploadDate(e.target.value)}
onChange={setUploadDate}
disabled={upload.isPending}
/>
</div>
@@ -398,22 +400,20 @@ export function InboundDocumentsView() {
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="doc-from">{t("documents.toolbar.fromLabel")}</Label>
<Input
<DateField
id="doc-from"
type="date"
value={fromDate}
max={toDate || undefined}
onChange={(e) => setFromDate(e.target.value)}
onChange={setFromDate}
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="doc-to">{t("documents.toolbar.toLabel")}</Label>
<Input
<DateField
id="doc-to"
type="date"
value={toDate}
min={fromDate || undefined}
onChange={(e) => setToDate(e.target.value)}
onChange={setToDate}
/>
</div>
<div className="flex flex-col gap-1">
@@ -920,7 +920,7 @@ function InlineNote({ slot, children }: { slot: string; children: string }) {
<div
role="status"
aria-live="polite"
className="flex items-start gap-2 rounded-md border border-sky-300 bg-sky-50 p-3 text-sm text-sky-900 dark:border-sky-900/60 dark:bg-sky-950/30 dark:text-sky-200"
className="border-info/30 bg-info/10 text-info flex items-start gap-2 rounded-md border p-3 text-sm"
data-slot={slot}
>
<Info className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
@@ -1007,12 +1007,7 @@ export function DocumentMetaEditForm({
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="edit-doc-date">{t("documents.edit.dateLabel")}</Label>
<Input
id="edit-doc-date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
<DateField id="edit-doc-date" value={date} onChange={setDate} />
</div>
<DialogFooter>
<Button variant="ghost" onClick={onCancel} disabled={save.isPending}>
@@ -1281,11 +1276,10 @@ function EditFields({
<Label htmlFor={`edit-${idPrefix}-date`} className="text-xs">
{t("documents.fields.date")}
</Label>
<Input
<DateField
id={`edit-${idPrefix}-date`}
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
onChange={setDate}
/>
</div>
<Button
+41 -63
View File
@@ -17,13 +17,7 @@
*/
import { useId, useMemo, useState } from "react";
import Link from "next/link";
import {
ChevronDown,
Plus,
RefreshCw,
Stethoscope,
Wrench,
} from "lucide-react";
import { ChevronDown, Plus, Stethoscope, Wrench } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -35,6 +29,8 @@ import {
CardTitle,
} from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
@@ -379,46 +375,39 @@ export function IllnessView() {
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1
data-tour-id="illness-hero"
className="text-2xl font-bold tracking-tight"
>
{t("illness.title")}
</h1>
<p className="text-muted-foreground text-xs sm:text-sm">
{t("illness.subtitle")}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{/* v1.18.6 (MOD-01) — wrench left of the primary Add, linking to the
Illness settings page (view + reorder). */}
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href="/settings/layout/illness"
aria-label={t("illness.customize")}
title={t("illness.customize")}
<PageHeader
title={<span data-tour-id="illness-hero">{t("illness.title")}</span>}
description={t("illness.subtitle")}
actions={
<>
{/* v1.18.6 (MOD-01) — wrench left of the primary Add, linking to the
Illness settings page (view + reorder). */}
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
{/* v1.18.6 (MOD-02) — the add button reads "hinzufügen" like every
other module, not the bespoke "neue Episode". */}
<Button
onClick={() => setNewOpen(true)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
</div>
</div>
<Link
href="/settings/layout/illness"
aria-label={t("illness.customize")}
title={t("illness.customize")}
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
{/* v1.18.6 (MOD-02) — the add button reads "hinzufügen" like every
other module, not the bespoke "neue Episode". */}
<Button
onClick={() => setNewOpen(true)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
</>
}
/>
{/* v1.18.6 (DISC-01) — the per-page medical disclaimer line is removed;
the one-time acknowledgment now lives at onboarding and the legal
@@ -430,23 +419,12 @@ export function IllnessView() {
<Skeleton className="h-32 w-full" />
</div>
) : isError ? (
<div
role="alert"
data-slot="illness-list-error"
className="text-muted-foreground flex flex-col items-start gap-3 py-8 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<span>{t("illness.listLoadError")}</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void refetch()}
className="gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("common.retry")}</span>
</Button>
</div>
// A read failure is NOT an empty journal — surface the error + Retry so
// an outage never reads as "no episodes yet".
<QueryErrorCard
title={t("illness.listLoadError")}
onRetry={() => void refetch()}
/>
) : hasEpisodes ? (
<>
{/* Retrospective summary sits above the list so a long history
@@ -16,11 +16,11 @@ import type { TimeInRangeDistribution } from "@/lib/analytics/glucose-metrics";
*/
const BAND_COLORS = {
veryLow: "#ff5555", // dracula-red — urgent low
low: "#ffb86c", // dracula-orange — low
inRange: "#50fa7b", // dracula-green — target
high: "#f1fa8c", // dracula-yellow — high
veryHigh: "#ff79c6", // dracula-pink — urgent high
veryLow: "var(--destructive)", // dracula-red — urgent low
low: "var(--warning)", // dracula-orange — low
inRange: "var(--success)", // dracula-green — target
high: "var(--dracula-yellow)", // dracula-yellow — high
veryHigh: "var(--chart-3)", // dracula-pink — urgent high
} as const;
type BandKey = keyof typeof BAND_COLORS;
@@ -89,7 +89,7 @@ export function RecoverySection() {
title: t("measurements.typeAnsCharge"),
explainer: t("insights.recovery.block.ansChargeExplainer"),
icon: Battery,
color: "#50fa7b",
color: "var(--success)",
unit: unitScore,
fractionDigits: 1,
},
@@ -105,7 +105,7 @@ export function RecoverySection() {
title: t("measurements.typeDayStrain"),
explainer: t("insights.recovery.block.dayStrainExplainer"),
icon: Gauge,
color: "#ffb86c",
color: "var(--warning)",
unit: unitScore,
fractionDigits: 1,
},
@@ -116,7 +116,7 @@ export function RecoverySection() {
title: t("measurements.typeWorkoutStrain"),
explainer: t("insights.recovery.block.workoutStrainExplainer"),
icon: Activity,
color: "#ff79c6",
color: "var(--chart-3)",
unit: unitScore,
fractionDigits: 1,
},
@@ -127,7 +127,7 @@ export function RecoverySection() {
title: t("measurements.typeCardioLoad"),
explainer: t("insights.recovery.block.cardioLoadExplainer"),
icon: TrendingUp,
color: "#bd93f9",
color: "var(--chart-1)",
unit: unitScore,
},
],
@@ -142,7 +142,7 @@ export function RecoverySection() {
title: t("measurements.typeAverageHeartRate"),
explainer: t("insights.recovery.block.averageHeartRateExplainer"),
icon: Heart,
color: "#8be9fd",
color: "var(--info)",
unit: unitBpm,
},
{
@@ -152,7 +152,7 @@ export function RecoverySection() {
title: t("measurements.typeMaxHeartRate"),
explainer: t("insights.recovery.block.maxHeartRateExplainer"),
icon: HeartPulse,
color: "#ff5555",
color: "var(--destructive)",
unit: unitBpm,
},
{
@@ -162,7 +162,7 @@ export function RecoverySection() {
title: t("measurements.typeEnergyExpenditureKj"),
explainer: t("insights.recovery.block.energyExpenditureKjExplainer"),
icon: Flame,
color: "#f1fa8c",
color: "var(--dracula-yellow)",
unit: unitKj,
},
],
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff. Show more