mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
908532be73
commit
3de2e37ed5
6 files changed
+106
-40
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user