mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
146 files changed
+2088
-1155
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"');
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
@@ -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" },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in new issue
Block a user