From 85897aa7dc8553711176756b0b044d55b8fc579e Mon Sep 17 00:00:00 2001 From: AnRil Date: Tue, 19 May 2026 13:23:41 +0700 Subject: [PATCH] fix(a11y+i18n): heatmap/weekdays via dict, Sidebar focus trap, debounce time-picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third pass through the audit list. Tests still 53 passing, typecheck and ESLint clean. i18n — finish removing hardcoded localised strings from components - Add 7 weekday short labels (weekday.short.0..6, index = Date.getDay()). - Settings QuietDaysRow + HistoryHeatmap now pull weekday labels from the dict instead of inline ru/en arrays. - Heatmap title, legend (Less/More), and per-cell rep tooltip are now i18n keys; the tooltip uses translateN with proper Russian plurals (1 повтор / 2 повтора / 5 повторов). - New aria labels: sidebar.aria.nav, exercise.aria.toggle. - HistoryHeatmap no longer takes a `lang` prop — pulls language from useT() like every other component. Heatmap intensity scaling - Bucket thresholds now percentile-based (p25/p50/p85 over non-zero days) rather than a flat ratio against the single max. A 200-rep "catch up" day no longer collapses every normal 10-rep day into the lowest bucket. Sidebar mobile drawer - Esc closes the drawer. - Tab/Shift-Tab trap inside the drawer. - Focus restores to the hamburger button on close. - Drawer gets role="dialog" + aria-modal="true" + aria-label. - Backdrop gets aria-hidden so screen readers skip the scrim. Settings — stop IPC chatter on time picker - QuietTimesRow mirrors `from`/`to` into local state and only emits an updateSettings IPC on blur (or when the local value matches HH:MM and differs from the current setting). Was firing ~5 IPCs while the user scrubbed time inputs, each rewriting app-state.json. - QuietDaysRow uses a numeric sort comparator instead of default lexical. Dashboard polish - "Until next reminder" hero stat now shows "—" when paused instead of continuing to tick down a misleading countdown. ExerciseCard - Switch aria-label was t('btn.done') ("Готово") — wrong semantics. Now reads "Toggle exercise X" via new i18n key. Co-Authored-By: Claude Opus 4.7 --- src/renderer/src/components/ExerciseCard.tsx | 2 +- .../src/components/HistoryHeatmap.tsx | 76 ++++++++++++------- src/renderer/src/components/Sidebar.tsx | 53 +++++++++++++ src/renderer/src/i18n/dict.ts | 44 +++++++++++ src/renderer/src/pages/Dashboard.tsx | 20 ++--- src/renderer/src/pages/Settings.tsx | 49 +++++++++--- 6 files changed, 197 insertions(+), 47 deletions(-) diff --git a/src/renderer/src/components/ExerciseCard.tsx b/src/renderer/src/components/ExerciseCard.tsx index 68783f5..701ada9 100644 --- a/src/renderer/src/components/ExerciseCard.tsx +++ b/src/renderer/src/components/ExerciseCard.tsx @@ -163,7 +163,7 @@ export function ExerciseCard({ diff --git a/src/renderer/src/components/HistoryHeatmap.tsx b/src/renderer/src/components/HistoryHeatmap.tsx index 48c9322..9f92b8f 100644 --- a/src/renderer/src/components/HistoryHeatmap.tsx +++ b/src/renderer/src/components/HistoryHeatmap.tsx @@ -1,43 +1,57 @@ import { useMemo } from 'react' import { dailyRepsRange } from '../lib/history' -import type { Exercise, HistoryEntry, Language } from '@shared/types' +import type { Exercise, HistoryEntry } from '@shared/types' +import { translateN, useT } from '../i18n' type Props = { history: HistoryEntry[] exercises: Exercise[] days?: number - lang: Language } /** * GitHub-style contribution grid: weeks as columns, days-of-week as rows. - * Intensity bucket from 0 to 4 based on relative reps within the window. + * + * Intensity bucket uses percentile-based thresholds (over non-zero days) + * rather than a flat ratio against the single max — so one outlier day + * doesn't blot out every normal day into the lowest bucket. */ export function HistoryHeatmap({ history, exercises, - days = 84, // 12 weeks - lang + days = 84 // 12 weeks }: Props): JSX.Element { + const { t, lang } = useT() + const cells = useMemo( () => dailyRepsRange(history, exercises, days), [history, exercises, days] ) - const max = cells.reduce((m, c) => Math.max(m, c.reps), 0) - // Bucket function — 0 for zero, 1-4 for low/med/high/peak. + // Percentile-based bucket thresholds over non-zero days. Stable when the + // user has one outlier (e.g. a 200-rep "catch up" day) — normal 10-rep + // days still spread across buckets 1..4 instead of all collapsing to 1. + const thresholds = useMemo(() => { + const nz = cells + .map((c) => c.reps) + .filter((n) => n > 0) + .sort((a, b) => a - b) + if (nz.length === 0) return null + const p = (q: number): number => + nz[Math.min(nz.length - 1, Math.floor(q * nz.length))] + return { p25: p(0.25), p50: p(0.5), p85: p(0.85) } + }, [cells]) + function bucket(n: number): number { - if (n === 0) return 0 - if (max === 0) return 0 - const ratio = n / max - if (ratio < 0.25) return 1 - if (ratio < 0.5) return 2 - if (ratio < 0.85) return 3 + if (n === 0 || !thresholds) return 0 + if (n <= thresholds.p25) return 1 + if (n <= thresholds.p50) return 2 + if (n <= thresholds.p85) return 3 return 4 } - // Group cells into columns (weeks). Pad start so first column aligns to - // its actual week (Mon-first). Memoised so monthLabels' deps are stable. + // Group cells into columns (weeks). Pad start so the first column aligns + // to its actual weekday (Mon-first). const weeks = useMemo(() => { const firstDay = cells[0]?.date ?? new Date() const firstWeekday = (firstDay.getDay() + 6) % 7 // 0 = Mon @@ -53,10 +67,17 @@ export function HistoryHeatmap({ return out }, [cells]) - const dayLabels = - lang === 'en' - ? ['Mon', '', 'Wed', '', 'Fri', '', 'Sun'] - : ['Пн', '', 'Ср', '', 'Пт', '', 'Вс'] + // Day labels along the Y axis. Mon-first, only label every other day to + // keep the column narrow. Pulled from the i18n dict (index = Date.getDay()). + const dayLabels = [ + t('weekday.short.1'), // Mon + '', + t('weekday.short.3'), // Wed + '', + t('weekday.short.5'), // Fri + '', + t('weekday.short.0') // Sun + ] const monthLabels = useMemo(() => { const fmt = new Intl.DateTimeFormat(lang === 'en' ? 'en-US' : 'ru-RU', { @@ -68,7 +89,7 @@ export function HistoryHeatmap({ }) }, [weeks, lang]) - // Compress repeated month labels (only show on first week of the month) + // Show a month label only on the first week that lands inside it. const monthLabelsCompressed = monthLabels.map((label, i) => label && label !== monthLabels[i - 1] ? label : '' ) @@ -82,13 +103,16 @@ export function HistoryHeatmap({ [lang] ) + // Pluralised "{n} reps" / "{n} повторов" for the cell tooltip. + // Outside React state — needed inside the cell-render closure. + const repsLabel = (n: number): string => + translateN(lang, 'heatmap.tooltip.reps', n) + return (
- {lang === 'en' - ? 'Activity, last 12 weeks' - : 'Активность за 12 недель'} + {t('heatmap.title')}
@@ -136,7 +160,7 @@ export function HistoryHeatmap({ return (
- {lang === 'en' ? 'Less' : 'Меньше'} + {t('heatmap.legend.less')} {[0, 1, 2, 3, 4].map((b) => (
))} - {lang === 'en' ? 'More' : 'Больше'} + {t('heatmap.legend.more')}
) diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index bf3fea9..5260c54 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef } from 'react' import { NavLink } from 'react-router-dom' import { AnimatePresence, motion } from 'framer-motion' import { Sun, Dumbbell, Joystick, Flame, Settings2, X } from 'lucide-react' @@ -44,6 +45,53 @@ export function Sidebar({ onMobileClose }: Props): JSX.Element { const { t } = useT() + const drawerRef = useRef(null) + const lastFocusedRef = useRef(null) + + // Esc closes + focus trap while the mobile drawer is open. Mirrors the + // pattern used in Modal.tsx. + useEffect(() => { + if (!mobileOpen) return undefined + lastFocusedRef.current = document.activeElement as HTMLElement | null + + const onKeyDown = (e: KeyboardEvent): void => { + if (e.key === 'Escape') { + e.preventDefault() + onMobileClose?.() + return + } + if (e.key !== 'Tab') return + const root = drawerRef.current + if (!root) return + const focusables = root.querySelectorAll( + 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])' + ) + if (focusables.length === 0) return + const first = focusables[0] + const last = focusables[focusables.length - 1] + const active = document.activeElement as HTMLElement | null + if (e.shiftKey) { + if (active === first || !root.contains(active)) { + e.preventDefault() + last.focus() + } + } else { + if (active === last || !root.contains(active)) { + e.preventDefault() + first.focus() + } + } + } + document.addEventListener('keydown', onKeyDown, true) + return () => { + document.removeEventListener('keydown', onKeyDown, true) + // Return focus to the trigger (Titlebar's hamburger) so keyboard users + // pick up where they left off. + const target = lastFocusedRef.current + if (target && document.body.contains(target)) target.focus() + } + }, [mobileOpen, onMobileClose]) + return ( <>