diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx index 4c6173af1..73c1f10a6 100644 --- a/src/app/auth/register/page.tsx +++ b/src/app/auth/register/page.tsx @@ -194,7 +194,13 @@ export default function RegisterPage() {

{t("auth.hasAccount")}{" "} - + {t("auth.login")}

diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 1ec8bbb11..f63244557 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -467,74 +467,134 @@ export function BackupsSection() { /> ) : ( -
- - - - - - - - - - - - {rows.map((row, i) => ( - - - - - - +
+ {/* Wide layout: the canonical table. Hidden below `md`, where the + five columns push Download / Restore off-screen behind a + horizontal scroll; the card list below takes over there. */} +
+
- {t("admin.section.backups.colUser")} - - {t("admin.section.backups.colType")} - - {t("admin.section.backups.colSize")} - - {t("admin.section.backups.colCreatedAt")} - - {t("admin.section.backups.colActions")} -
{row.username} - - {formatBackupType(row.type, t)} - - - {formatBytes(row.sizeBytes, fmt)} - - {fmt.dateTime(row.createdAt)} - -
- - restore.mutate(row)} - /> -
-
+ + + + + + + - ))} - -
+ {t("admin.section.backups.colUser")} + + {t("admin.section.backups.colType")} + + {t("admin.section.backups.colSize")} + + {t("admin.section.backups.colCreatedAt")} + + {t("admin.section.backups.colActions")} +
+ + + {rows.map((row, i) => ( + + {row.username} + + + {formatBackupType(row.type, t)} + + + + {formatBytes(row.sizeBytes, fmt)} + + + {fmt.dateTime(row.createdAt)} + + +
+ + restore.mutate(row)} + /> +
+ + + ))} + + +
+ + {/* Narrow layout: each backup as a self-contained card with the + meta line + a flex-wrapping action row, so Download / Restore + stay reachable on a phone instead of scrolling off-screen. */} + +

{t("admin.section.backups.retentionLabel", { days: data!.retentionDays, diff --git a/src/components/admin/general-settings-section.tsx b/src/components/admin/general-settings-section.tsx index 8e1dc1011..62cb0d438 100644 --- a/src/components/admin/general-settings-section.tsx +++ b/src/components/admin/general-settings-section.tsx @@ -68,6 +68,7 @@ export function GeneralSettingsSection() {

updateSettings.mutate({ defaultLocale: e.target.value }) @@ -94,6 +95,7 @@ export function GeneralSettingsSection() {

updateSettings.mutate({ defaultUserTimezone: e.target.value }) diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx index 4db8ab460..a6559cc06 100644 --- a/src/components/illness/illness-view.tsx +++ b/src/components/illness/illness-view.tsx @@ -17,7 +17,13 @@ */ import { useId, useMemo, useState } from "react"; import Link from "next/link"; -import { ChevronDown, Plus, Stethoscope, Wrench } from "lucide-react"; +import { + ChevronDown, + Plus, + RefreshCw, + Stethoscope, + Wrench, +} from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -308,7 +314,12 @@ function EpisodeGroup({ export function IllnessView() { const { t } = useTranslations(); - const { data: episodes, isLoading } = useIllnessEpisodes(true); + const { + data: episodes, + isLoading, + isError, + refetch, + } = useIllnessEpisodes(true); const resolve = useResolveEpisode(); const { prefs } = useModuleListPrefs("illness"); @@ -418,6 +429,24 @@ export function IllnessView() { + ) : isError ? ( +
+ {t("illness.listLoadError")} + +
) : hasEpisodes ? ( <> {/* Retrospective summary sits above the list so a long history diff --git a/src/components/insights/glucose/glucose-advanced-disclosure.tsx b/src/components/insights/glucose/glucose-advanced-disclosure.tsx index 362dfcc29..4392173f2 100644 --- a/src/components/insights/glucose/glucose-advanced-disclosure.tsx +++ b/src/components/insights/glucose/glucose-advanced-disclosure.tsx @@ -3,6 +3,7 @@ import { useId, useState } from "react"; import { ChevronDown } from "lucide-react"; +import { InfoHint } from "@/components/ui/info-hint"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; @@ -146,8 +147,9 @@ function AdvancedStat({ }) { return (
-
+
{label} +
{value} diff --git a/src/components/insights/glucose/glucose-clinical-panel.tsx b/src/components/insights/glucose/glucose-clinical-panel.tsx index cbc1535fc..b27317673 100644 --- a/src/components/insights/glucose/glucose-clinical-panel.tsx +++ b/src/components/insights/glucose/glucose-clinical-panel.tsx @@ -5,6 +5,7 @@ import { Activity, Droplet, FlaskConical, TrendingUp } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { InfoHint } from "@/components/ui/info-hint"; import { LearningGate } from "@/components/ui/learning-gate"; import { LearnMoreLink } from "@/components/ui/learn-more-link"; import { Skeleton } from "@/components/ui/skeleton"; @@ -195,11 +196,9 @@ export function GlucoseClinicalPanel() { ) : null} {cv ? (
- + {t("insights.bloodGlucose.clinical.cv.label")} +
@@ -283,12 +282,10 @@ function Metric({ }) { return (
- + {icon} {label} + {help ? : null} {value} diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index 1f45b1518..e0c597f9d 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -165,7 +165,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { if (isError) { return ( -

+

{t("labs.loadError")}

); diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index 280702666..e43368c38 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -35,6 +35,7 @@ import { CalendarClock, CheckCircle2, Plus, + RefreshCw, Wrench, } from "lucide-react"; @@ -219,7 +220,12 @@ export function VorsorgeSection({ variant?: "settings" | "page"; }) { const { t } = useTranslations(); - const { data: reminders, isLoading } = useMeasurementReminders(enabled); + const { + data: reminders, + isLoading, + isError, + refetch, + } = useMeasurementReminders(enabled); const { create, update, remove, satisfy } = useMeasurementReminderMutations(); // v1.18.6 (MOD-03) — page view (cards/list) + manual order persist // client-side; the settings page writes them, the page reads them. @@ -662,7 +668,27 @@ export function VorsorgeSection({
)} - {!isLoading && (reminders?.length ?? 0) === 0 && ( + {!isLoading && isError && ( +
+ {t("measurementReminders.loadError")} + +
+ )} + + {!isLoading && !isError && (reminders?.length ?? 0) === 0 && ( } title={t("measurementReminders.empty.title")} diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index 772f902f6..d5a90d6f4 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -43,6 +43,7 @@ import { Loader2, Pencil, Plus, + RefreshCw, Smile, Trash2, ChevronLeft, @@ -219,7 +220,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { const toParam = toDay || undefined; const sourceParam = sourceFilter === "ALL" ? undefined : sourceFilter; - const { data, isLoading } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.moodEntriesList({ mood: moodFilter === "ALL" ? undefined : moodFilter, source: sourceParam, @@ -492,6 +493,24 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
+ ) : isError ? ( +
+ {t("mood.loadError")} + +
) : !data?.entries?.length ? ( // v1.4.15 phase-C5: replace bare-text empty rectangle with // EmptyState. Filter-aware copy splits "no mood entries yet" diff --git a/src/components/settings/ai/runtime-actions-row.tsx b/src/components/settings/ai/runtime-actions-row.tsx index c83a8f670..070ef3730 100644 --- a/src/components/settings/ai/runtime-actions-row.tsx +++ b/src/components/settings/ai/runtime-actions-row.tsx @@ -245,6 +245,7 @@ export function RuntimeActionsRow({

diff --git a/src/components/targets/target-edit-sheet.tsx b/src/components/targets/target-edit-sheet.tsx index 27df912ae..9edd6110a 100644 --- a/src/components/targets/target-edit-sheet.tsx +++ b/src/components/targets/target-edit-sheet.tsx @@ -307,6 +307,52 @@ function TargetEditSheetBody({ const canSave = !isDerivedMetric && primaryValid && secondaryValid && !busy; + // Per-field validity so the inputs can carry `aria-invalid` and the + // disabled Save button gets a spoken explanation instead of greying out + // silently. The order check (min < max) flags both ends of the pair. + const primaryOrderInvalid = + Number.isFinite(minNum) && Number.isFinite(maxNum) && minNum >= maxNum; + const primaryMinInvalid = + primaryBounds != null && + (!Number.isFinite(minNum) || + minNum < primaryBounds.min || + minNum > primaryBounds.max || + primaryOrderInvalid); + const primaryMaxInvalid = + primaryBounds != null && + (!Number.isFinite(maxNum) || + maxNum < primaryBounds.min || + maxNum > primaryBounds.max || + primaryOrderInvalid); + + const secondaryOrderInvalid = + isBp && + Number.isFinite(diaMinNum) && + Number.isFinite(diaMaxNum) && + diaMinNum >= diaMaxNum; + const secondaryMinInvalid = + isBp && + secondaryBounds != null && + (!Number.isFinite(diaMinNum) || + diaMinNum < secondaryBounds.min || + diaMinNum > secondaryBounds.max || + secondaryOrderInvalid); + const secondaryMaxInvalid = + isBp && + secondaryBounds != null && + (!Number.isFinite(diaMaxNum) || + diaMaxNum < secondaryBounds.min || + diaMaxNum > secondaryBounds.max || + secondaryOrderInvalid); + + let validationMessage: string | null = null; + if (!isDerivedMetric && primaryBounds && (!primaryValid || !secondaryValid)) { + validationMessage = + primaryOrderInvalid || secondaryOrderInvalid + ? t("targets.edit.validationOrder") + : t("targets.edit.validationBounds"); + } + const handleSave = () => { if (!canSave) return; if (isBp) { @@ -426,6 +472,7 @@ function TargetEditSheetBody({ value={displayMin} onChange={(e) => setMinStr(e.target.value)} disabled={busy} + aria-invalid={primaryMinInvalid} data-slot="target-edit-min-input" />
@@ -444,6 +491,7 @@ function TargetEditSheetBody({ value={displayMax} onChange={(e) => setMaxStr(e.target.value)} disabled={busy} + aria-invalid={primaryMaxInvalid} data-slot="target-edit-max-input" />
@@ -467,6 +515,7 @@ function TargetEditSheetBody({ value={displayDiaMin} onChange={(e) => setDiaMinStr(e.target.value)} disabled={busy} + aria-invalid={secondaryMinInvalid} data-slot="target-edit-dia-min-input" /> @@ -483,6 +532,7 @@ function TargetEditSheetBody({ value={displayDiaMax} onChange={(e) => setDiaMaxStr(e.target.value)} disabled={busy} + aria-invalid={secondaryMaxInvalid} data-slot="target-edit-dia-max-input" /> @@ -496,6 +546,16 @@ function TargetEditSheetBody({ unit: primaryBounds.unit, })}

+ + {validationMessage && ( +

+ {validationMessage} +

+ )} )} diff --git a/src/components/ui/info-hint.tsx b/src/components/ui/info-hint.tsx new file mode 100644 index 000000000..671b09cec --- /dev/null +++ b/src/components/ui/info-hint.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { Info } from "lucide-react"; + +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; + +/** + * A small, tap-reachable help affordance. Replaces the bare `title=` + * attribute, which never surfaces on touch. The trigger is a real button + * with an accessible name, so keyboard and screen-reader users reach the + * same hint a pointer user gets on hover; tapping the button focuses it, + * which opens the tooltip on touch devices. + */ +export function InfoHint({ + label, + className, +}: { + /** The hint text shown inside the tooltip. */ + label: string; + className?: string; +}) { + const { t } = useTranslations(); + return ( + + + + + + + {label} + + + + ); +} diff --git a/src/components/ui/password-strength.tsx b/src/components/ui/password-strength.tsx index 03a67e15c..32011560f 100644 --- a/src/components/ui/password-strength.tsx +++ b/src/components/ui/password-strength.tsx @@ -10,12 +10,16 @@ interface PasswordStrengthProps { minLength?: number; } +// The bar fill stays vivid (it's a non-text indicator), but the label text +// routes through the AA-tuned semantic tokens — the raw Tailwind 500 tones +// failed contrast against the white card. Weakest is destructive, the middle +// band warning, the strong band success. const SCORE_COLORS = [ - { color: "bg-red-500", textColor: "text-red-500" }, - { color: "bg-orange-500", textColor: "text-orange-500" }, - { color: "bg-yellow-500", textColor: "text-yellow-500" }, - { color: "bg-green-500", textColor: "text-green-500" }, - { color: "bg-emerald-600", textColor: "text-emerald-600" }, + { color: "bg-red-500", textColor: "text-destructive" }, + { color: "bg-orange-500", textColor: "text-destructive" }, + { color: "bg-yellow-500", textColor: "text-warning" }, + { color: "bg-green-500", textColor: "text-success" }, + { color: "bg-emerald-600", textColor: "text-success" }, ]; export function PasswordStrength({