fix(ux): touch targets, a11y on phase config, admin label i18n

UX/a11y residuals from 2026-04-26 audit:

- bottom-nav: touch targets sized to WCAG 2.5.5 minimum (44x44 CSS px)
  via min-h-11 min-w-11 on each Link. Nav height bumped to h-16 to
  accommodate. Visual icon stays at 20px so design doesn't shift.
- phase-config-dialog: decorative coloured dot now aria-hidden because
  the redundant text label ("Green"/"Yellow"/...) already conveys the
  phase. Screen readers no longer announce a meaningless 'image' node.
- admin: 'Web Push' and 'Bug Report' labels were hard-coded English in
  StatusItem and SettingsToggle calls; now go through t() with new
  admin.integrationWebPush / admin.integrationBugReport keys (en + de).
- medication-card: false-positive en-CA / en-US locale audit findings
  documented inline — those are intentional locale-independent helpers
  (en-CA gives YYYY-MM-DD for day-comparison, en-US gives a parseable
  string for the toBerlinDate timezone hack), not user-facing display.
This commit is contained in:
Marc-André Bombeck authored and Marc-André Bombeck committed 2026-04-27 00:28:28 +02:00
1 parent 908532be73
commit 3de2e37ed5
6 files changed
+106 -40

No files matched your search

+2
View File
@@ -1107,6 +1107,8 @@
"telegramGlobal": "Telegram-Benachrichtigungen systemweit erlauben.",
"ntfyGlobal": "ntfy-Benachrichtigungen systemweit erlauben.",
"webPushGlobal": "Browser-Push-Benachrichtigungen systemweit erlauben.",
"integrationWebPush": "Web Push",
"integrationBugReport": "Bug-Reports",
"apiGlobal": "API-Endpunkte und API-Tokens systemweit erlauben.",
"moodLogGlobal": "moodLog-Integration systemweit erlauben.",
"sectionGeneral": "Allgemein",
+2
View File
@@ -1107,6 +1107,8 @@
"telegramGlobal": "Allow Telegram notifications system-wide.",
"ntfyGlobal": "Allow ntfy notifications system-wide.",
"webPushGlobal": "Allow browser push notifications system-wide.",
"integrationWebPush": "Web Push",
"integrationBugReport": "Bug Report",
"apiGlobal": "Allow API endpoints and API tokens system-wide.",
"moodLogGlobal": "Allow moodLog integration system-wide.",
"sectionGeneral": "General",
+31 -18
View File
@@ -378,7 +378,7 @@ function SystemStatusSection({ id }: { id: string }) {
{status.integrations.webPush && (
<StatusItem
icon={BellRing}
label="Web Push"
label={t("admin.integrationWebPush")}
value={t("admin.configured")}
className="text-dracula-green"
/>
@@ -386,7 +386,7 @@ function SystemStatusSection({ id }: { id: string }) {
{status.integrations.bugReport && (
<StatusItem
icon={Bug}
label="Bug Report"
label={t("admin.integrationBugReport")}
value={t("admin.configured")}
className="text-dracula-green"
/>
@@ -518,7 +518,7 @@ function ServicesSection({ id }: { id: string }) {
disabled={updateSettings.isPending}
/>
<SettingsToggle
label="Web Push"
label={t("admin.integrationWebPush")}
description={t("admin.webPushGlobal")}
icon={Globe}
checked={settings?.webPushGlobal ?? true}
@@ -2239,8 +2239,7 @@ function FeedbackInboxSection({ id }: { id: string }) {
});
const githubConfigured = Boolean(status?.integrations.bugReport?.configured);
const [activeStatus, setActiveStatus] =
useState<FeedbackStatusType>("OPEN");
const [activeStatus, setActiveStatus] = useState<FeedbackStatusType>("OPEN");
const [selected, setSelected] = useState<FeedbackItem | null>(null);
const { data, isLoading } = useQuery({
@@ -2281,7 +2280,11 @@ function FeedbackInboxSection({ id }: { id: string }) {
<TabsList>
{STATUS_TABS.map((s) => (
<TabsTrigger key={s} value={s}>
<span>{t(`admin.feedback.tab${s.charAt(0) + s.slice(1).toLowerCase()}`)}</span>
<span>
{t(
`admin.feedback.tab${s.charAt(0) + s.slice(1).toLowerCase()}`,
)}
</span>
<Badge variant="secondary" className="ml-1.5 text-xs">
{counts[s] ?? 0}
</Badge>
@@ -2328,7 +2331,7 @@ function FeedbackInboxSection({ id }: { id: string }) {
{data.items.map((item, i) => (
<tr
key={item.id}
className={`cursor-pointer hover:bg-muted/40 ${i % 2 === 0 ? "bg-muted/30" : ""}`}
className={`hover:bg-muted/40 cursor-pointer ${i % 2 === 0 ? "bg-muted/30" : ""}`}
onClick={() => setSelected(item)}
>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
@@ -2337,7 +2340,7 @@ function FeedbackInboxSection({ id }: { id: string }) {
<td className="px-3 py-2">
<FeedbackCategoryBadge category={item.category} />
</td>
<td className="px-3 py-2 font-medium truncate max-w-[28ch]">
<td className="max-w-[28ch] truncate px-3 py-2 font-medium">
{item.subject}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
@@ -2379,9 +2382,16 @@ function FeedbackInboxSection({ id }: { id: string }) {
);
}
function FeedbackCategoryBadge({ category }: { category: FeedbackCategoryType }) {
function FeedbackCategoryBadge({
category,
}: {
category: FeedbackCategoryType;
}) {
const { t } = useTranslations();
const map: Record<FeedbackCategoryType, { label: string; className: string }> = {
const map: Record<
FeedbackCategoryType,
{ label: string; className: string }
> = {
BUG: {
label: t("admin.feedback.categoryBug"),
className: "bg-red-500/15 text-red-400 border-red-500/30",
@@ -2400,7 +2410,9 @@ function FeedbackCategoryBadge({ category }: { category: FeedbackCategoryType })
},
};
const cfg = map[category];
return <Badge className={`text-xs border ${cfg.className}`}>{cfg.label}</Badge>;
return (
<Badge className={`border text-xs ${cfg.className}`}>{cfg.label}</Badge>
);
}
function FeedbackDetailDialog({
@@ -2487,11 +2499,12 @@ function FeedbackDetailDialog({
const url = typeof meta.url === "string" ? meta.url : null;
const locale = typeof meta.locale === "string" ? meta.locale : null;
const userAgent = typeof meta.userAgent === "string" ? meta.userAgent : null;
const appVersion = typeof meta.appVersion === "string" ? meta.appVersion : null;
const appVersion =
typeof meta.appVersion === "string" ? meta.appVersion : null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
@@ -2529,7 +2542,7 @@ function FeedbackDetailDialog({
{(url || locale || userAgent || appVersion) && (
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<h4 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
{t("admin.feedback.metadataHeading")}
</h4>
<dl className="mt-2 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-xs">
@@ -2571,14 +2584,14 @@ function FeedbackDetailDialog({
{item.screenshotBase64 && (
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
<h4 className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
{t("admin.feedback.screenshotHeading")}
</h4>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.screenshotBase64}
alt="Screenshot"
className="max-h-72 rounded-md border border-border"
className="border-border max-h-72 rounded-md border"
/>
</div>
)}
@@ -2611,7 +2624,7 @@ function FeedbackDetailDialog({
</div>
</div>
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-3">
<div className="border-border flex flex-wrap items-center gap-2 border-t pt-3">
<Button
size="sm"
variant="outline"
@@ -2674,7 +2687,7 @@ function FeedbackDetailDialog({
</Button>
)}
{issueUrl && (
<Badge className="ml-auto border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green ml-auto">
{t("admin.feedback.publishedToGithub")}
</Badge>
)}
+10 -4
View File
@@ -31,8 +31,11 @@ export function BottomNav() {
const items = navItems;
return (
<nav aria-label={t("nav.mobileNavigation")} className="bg-card/80 border-border fixed bottom-0 left-0 z-50 w-full border-t pb-[env(safe-area-inset-bottom)] backdrop-blur-md md:hidden">
<div className="mx-auto flex h-14 max-w-lg items-center justify-around px-2">
<nav
aria-label={t("nav.mobileNavigation")}
className="bg-card/80 border-border fixed bottom-0 left-0 z-50 w-full border-t pb-[env(safe-area-inset-bottom)] backdrop-blur-md md:hidden"
>
<div className="mx-auto flex h-16 max-w-lg items-stretch justify-around px-1">
{items.map((item) => {
const isActive =
item.href === "/"
@@ -44,8 +47,11 @@ export function BottomNav() {
href={item.href}
aria-label={t(item.tKey)}
aria-current={isActive ? "page" : undefined}
// Touch target sized to WCAG 2.5.5 minimum (44×44 CSS px). The
// outer min-h-11 min-w-11 is the actual hit area; the icon
// stays visually centered at 20px so the design doesn't shift.
className={cn(
"relative flex items-center justify-center rounded-lg p-2.5 transition-colors",
"relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors",
isActive
? "text-primary"
: "text-muted-foreground hover:text-foreground",
@@ -53,7 +59,7 @@ export function BottomNav() {
>
<item.icon className="h-5 w-5" />
{isActive && (
<span className="bg-primary absolute -bottom-1 h-1 w-1 rounded-full" />
<span className="bg-primary absolute bottom-1.5 h-1 w-1 rounded-full" />
)}
</Link>
);
+19 -2
View File
@@ -78,6 +78,10 @@ function parseTimeToMinutes(value: string): number {
}
function toBerlinDate(date: Date): Date {
// Intentionally en-US: this is not user-facing display — it produces a
// parseable string ("1/2/2026, 3:04:05 PM") that we feed back into Date
// to shift the *value* from UTC to Berlin-local for arithmetic. Display
// formatting goes through useFormatters() elsewhere.
return new Date(
date.toLocaleString("en-US", {
timeZone: "Europe/Berlin",
@@ -315,7 +319,11 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
// Don't show late/very_late if all overdue schedules are covered by events
if (status !== "in_window" && !hasUncoveredOverdue) return best;
// Don't show in_window if last intake is already within this window today
if (status === "in_window" && isLastIntakeInCurrentWindow(medication.lastTakenAt, s, nowBerlin)) return best;
if (
status === "in_window" &&
isLastIntakeInCurrentWindow(medication.lastTakenAt, s, nowBerlin)
)
return best;
const priority = { in_window: 3, late: 2, very_late: 1 };
if (!best.status || priority[status] > priority[best.status]) {
return { status, schedule: s };
@@ -327,6 +335,9 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
: { status: null, schedule: null };
function formatLastTakenAt(value: string): string {
// Intentionally en-CA: gives YYYY-MM-DD which is locale-independent and
// string-comparable for the today / yesterday / older bucketing below.
// The actual user-facing display goes through formatTime / formatDateTime.
const dayFormatter = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
@@ -374,7 +385,13 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
</div>
</div>
<div className="flex items-center gap-0.5">
<Button variant="ghost" size="icon" className="h-8 w-8" asChild aria-label={t("medications.intakeHistory")}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
asChild
aria-label={t("medications.intakeHistory")}
>
<Link href={`/medications/${medication.id}/history`}>
<History className="h-3.5 w-3.5" />
</Link>
@@ -51,9 +51,24 @@ const PHASES: {
dotClass: string;
direction: "before" | "after";
}[] = [
{ key: "green", color: "green", dotClass: "bg-green-500", direction: "before" },
{ key: "yellow", color: "yellow", dotClass: "bg-yellow-500", direction: "before" },
{ key: "orange", color: "orange", dotClass: "bg-orange-500", direction: "after" },
{
key: "green",
color: "green",
dotClass: "bg-green-500",
direction: "before",
},
{
key: "yellow",
color: "yellow",
dotClass: "bg-yellow-500",
direction: "before",
},
{
key: "orange",
color: "orange",
dotClass: "bg-orange-500",
direction: "after",
},
{ key: "red", color: "red", dotClass: "bg-red-500", direction: "after" },
];
@@ -92,7 +107,9 @@ export function PhaseConfigDialog({
},
onSuccess: () => {
setLocalForm(null);
queryClient.invalidateQueries({ queryKey: ["phase-config", medicationId] });
queryClient.invalidateQueries({
queryKey: ["phase-config", medicationId],
});
setStatusMessage(t("medications.phaseSaved"));
setTimeout(() => setStatusMessage(null), 2000);
},
@@ -108,7 +125,9 @@ export function PhaseConfigDialog({
},
onSuccess: () => {
setLocalForm(null);
queryClient.invalidateQueries({ queryKey: ["phase-config", medicationId] });
queryClient.invalidateQueries({
queryKey: ["phase-config", medicationId],
});
setStatusMessage(t("medications.phaseReset"));
setTimeout(() => setStatusMessage(null), 2000);
},
@@ -129,10 +148,14 @@ export function PhaseConfigDialog({
const phaseLabel = (key: PhaseKey): string => {
switch (key) {
case "green": return t("medications.phaseGreen");
case "yellow": return t("medications.phaseYellow");
case "orange": return t("medications.phaseOrange");
case "red": return t("medications.phaseRed");
case "green":
return t("medications.phaseGreen");
case "yellow":
return t("medications.phaseYellow");
case "orange":
return t("medications.phaseOrange");
case "red":
return t("medications.phaseRed");
}
};
@@ -160,8 +183,11 @@ export function PhaseConfigDialog({
return (
<div key={key} className="flex items-center gap-2">
<div className={`h-3 w-3 rounded-full ${dotClass} shrink-0`} />
<span className="w-14 text-sm font-medium shrink-0">
<div
className={`h-3 w-3 rounded-full ${dotClass} shrink-0`}
aria-hidden="true"
/>
<span className="w-14 shrink-0 text-sm font-medium">
{phaseLabel(key)}
</span>
<Input
@@ -172,20 +198,20 @@ export function PhaseConfigDialog({
onChange={(e) =>
updateValue(key, parseInt(e.target.value, 10) || 0)
}
className="w-20 h-8 text-sm"
className="h-8 w-20 text-sm"
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 w-12 text-xs px-0"
className="h-8 w-12 px-0 text-xs"
onClick={() => toggleMode(key)}
>
{mode === "MINUTES"
? t("medications.phaseMinutes")
: t("medications.phasePercent")}
</Button>
<span className="text-muted-foreground text-xs shrink-0">
<span className="text-muted-foreground shrink-0 text-xs">
{direction === "before"
? t("medications.phaseBeforeEnd")
: t("medications.phaseAfterEnd")}
@@ -197,12 +223,12 @@ export function PhaseConfigDialog({
)}
{statusMessage && (
<div className="text-sm text-green-500 text-center">
<div className="text-center text-sm text-green-500">
{statusMessage}
</div>
)}
<DialogFooter className="flex-row justify-between sm:justify-between gap-2">
<DialogFooter className="flex-row justify-between gap-2 sm:justify-between">
<Button
type="button"
variant="ghost"