fix(a11y+i18n): heatmap/weekdays via dict, Sidebar focus trap, debounce time-picker

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 <noreply@anthropic.com>
This commit is contained in:
AnRil
2026-05-19 13:23:41 +07:00
parent f0dc5b2cc3
commit 85897aa7dc
6 changed files with 197 additions and 47 deletions

View File

@@ -163,7 +163,7 @@ export function ExerciseCard({
<Switch <Switch
checked={exercise.enabled} checked={exercise.enabled}
onChange={onToggle} onChange={onToggle}
aria-label={t('btn.done')} aria-label={t('exercise.aria.toggle', { name: exercise.name })}
/> />
</div> </div>
</div> </div>

View File

@@ -1,43 +1,57 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { dailyRepsRange } from '../lib/history' 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 = { type Props = {
history: HistoryEntry[] history: HistoryEntry[]
exercises: Exercise[] exercises: Exercise[]
days?: number days?: number
lang: Language
} }
/** /**
* GitHub-style contribution grid: weeks as columns, days-of-week as rows. * 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({ export function HistoryHeatmap({
history, history,
exercises, exercises,
days = 84, // 12 weeks days = 84 // 12 weeks
lang
}: Props): JSX.Element { }: Props): JSX.Element {
const { t, lang } = useT()
const cells = useMemo( const cells = useMemo(
() => dailyRepsRange(history, exercises, days), () => dailyRepsRange(history, exercises, days),
[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 { function bucket(n: number): number {
if (n === 0) return 0 if (n === 0 || !thresholds) return 0
if (max === 0) return 0 if (n <= thresholds.p25) return 1
const ratio = n / max if (n <= thresholds.p50) return 2
if (ratio < 0.25) return 1 if (n <= thresholds.p85) return 3
if (ratio < 0.5) return 2
if (ratio < 0.85) return 3
return 4 return 4
} }
// Group cells into columns (weeks). Pad start so first column aligns to // Group cells into columns (weeks). Pad start so the first column aligns
// its actual week (Mon-first). Memoised so monthLabels' deps are stable. // to its actual weekday (Mon-first).
const weeks = useMemo(() => { const weeks = useMemo(() => {
const firstDay = cells[0]?.date ?? new Date() const firstDay = cells[0]?.date ?? new Date()
const firstWeekday = (firstDay.getDay() + 6) % 7 // 0 = Mon const firstWeekday = (firstDay.getDay() + 6) % 7 // 0 = Mon
@@ -53,10 +67,17 @@ export function HistoryHeatmap({
return out return out
}, [cells]) }, [cells])
const dayLabels = // Day labels along the Y axis. Mon-first, only label every other day to
lang === 'en' // keep the column narrow. Pulled from the i18n dict (index = Date.getDay()).
? ['Mon', '', 'Wed', '', 'Fri', '', 'Sun'] 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 monthLabels = useMemo(() => {
const fmt = new Intl.DateTimeFormat(lang === 'en' ? 'en-US' : 'ru-RU', { const fmt = new Intl.DateTimeFormat(lang === 'en' ? 'en-US' : 'ru-RU', {
@@ -68,7 +89,7 @@ export function HistoryHeatmap({
}) })
}, [weeks, lang]) }, [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) => const monthLabelsCompressed = monthLabels.map((label, i) =>
label && label !== monthLabels[i - 1] ? label : '' label && label !== monthLabels[i - 1] ? label : ''
) )
@@ -82,13 +103,16 @@ export function HistoryHeatmap({
[lang] [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 ( return (
<div className="bg-surface rounded-2xl p-5 shadow-card dark:ring-0.5 dark:ring-hairline/30"> <div className="bg-surface rounded-2xl p-5 shadow-card dark:ring-0.5 dark:ring-hairline/30">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<div className="text-[14px] text-text/75 font-semibold"> <div className="text-[14px] text-text/75 font-semibold">
{lang === 'en' {t('heatmap.title')}
? 'Activity, last 12 weeks'
: 'Активность за 12 недель'}
</div> </div>
</div> </div>
@@ -136,7 +160,7 @@ export function HistoryHeatmap({
return ( return (
<div <div
key={di} key={di}
title={`${dateFmt.format(c.date)} · ${c.reps} ${lang === 'en' ? 'reps' : 'повторов'}`} title={`${dateFmt.format(c.date)} · ${repsLabel(c.reps)}`}
className={[ className={[
'w-[12px] h-[12px] rounded-[3px] transition-colors', 'w-[12px] h-[12px] rounded-[3px] transition-colors',
tone tone
@@ -152,7 +176,7 @@ export function HistoryHeatmap({
{/* Legend */} {/* Legend */}
<div className="flex items-center justify-end gap-1.5 mt-3 text-[10px] text-text/45 font-medium"> <div className="flex items-center justify-end gap-1.5 mt-3 text-[10px] text-text/45 font-medium">
<span>{lang === 'en' ? 'Less' : 'Меньше'}</span> <span>{t('heatmap.legend.less')}</span>
{[0, 1, 2, 3, 4].map((b) => ( {[0, 1, 2, 3, 4].map((b) => (
<div <div
key={b} key={b}
@@ -170,7 +194,7 @@ export function HistoryHeatmap({
].join(' ')} ].join(' ')}
/> />
))} ))}
<span>{lang === 'en' ? 'More' : 'Больше'}</span> <span>{t('heatmap.legend.more')}</span>
</div> </div>
</div> </div>
) )

View File

@@ -1,3 +1,4 @@
import { useEffect, useRef } from 'react'
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { Sun, Dumbbell, Joystick, Flame, Settings2, X } from 'lucide-react' import { Sun, Dumbbell, Joystick, Flame, Settings2, X } from 'lucide-react'
@@ -44,6 +45,53 @@ export function Sidebar({
onMobileClose onMobileClose
}: Props): JSX.Element { }: Props): JSX.Element {
const { t } = useT() const { t } = useT()
const drawerRef = useRef<HTMLElement | null>(null)
const lastFocusedRef = useRef<HTMLElement | null>(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<HTMLElement>(
'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 ( return (
<> <>
<aside className="hidden md:flex w-64 shrink-0 vibrancy hairline-b border-r-0 flex-col"> <aside className="hidden md:flex w-64 shrink-0 vibrancy hairline-b border-r-0 flex-col">
@@ -65,8 +113,13 @@ export function Sidebar({
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
aria-hidden="true"
/> />
<motion.aside <motion.aside
ref={drawerRef}
role="dialog"
aria-modal="true"
aria-label={t('sidebar.aria.nav')}
className="relative w-72 max-w-[85vw] h-full vibrancy flex flex-col" className="relative w-72 max-w-[85vw] h-full vibrancy flex flex-col"
initial={{ x: '-100%' }} initial={{ x: '-100%' }}
animate={{ x: 0 }} animate={{ x: 0 }}

View File

@@ -206,6 +206,30 @@ export const ru: Dict = {
'reminder.partial': 'Засчитаем {actual} из {planned}', 'reminder.partial': 'Засчитаем {actual} из {planned}',
'reminder.aria.decrement': 'Уменьшить количество повторов', 'reminder.aria.decrement': 'Уменьшить количество повторов',
'reminder.aria.increment': 'Увеличить количество повторов', 'reminder.aria.increment': 'Увеличить количество повторов',
// Weekday short labels (Mon..Sun). Used by Settings days-of-week picker,
// HistoryHeatmap row axis, and Dashboard date headers. Index 0 = Sunday
// to match Date.getDay()'s convention so callers can use the value
// directly without re-mapping.
'weekday.short.0': 'Вс',
'weekday.short.1': 'Пн',
'weekday.short.2': 'Вт',
'weekday.short.3': 'Ср',
'weekday.short.4': 'Чт',
'weekday.short.5': 'Пт',
'weekday.short.6': 'Сб',
// History heatmap
'heatmap.title': 'Активность за 12 недель',
'heatmap.legend.less': 'Меньше',
'heatmap.legend.more': 'Больше',
'heatmap.tooltip.reps_one': '{n} повтор',
'heatmap.tooltip.reps_few': '{n} повтора',
'heatmap.tooltip.reps_many': '{n} повторов',
// Sidebar
'sidebar.aria.nav': 'Главная навигация',
'exercise.aria.toggle': 'Переключить упражнение «{name}»',
'reminder.btn.done': 'Готово', 'reminder.btn.done': 'Готово',
'match.title.won': 'Победа', 'match.title.won': 'Победа',
'match.title.lost': 'Поражение', 'match.title.lost': 'Поражение',
@@ -426,6 +450,26 @@ export const en: Dict = {
'reminder.partial': "We'll log {actual} of {planned}", 'reminder.partial': "We'll log {actual} of {planned}",
'reminder.aria.decrement': 'Decrease rep count', 'reminder.aria.decrement': 'Decrease rep count',
'reminder.aria.increment': 'Increase rep count', 'reminder.aria.increment': 'Increase rep count',
// Weekday short labels (Mon..Sun). Index 0 = Sunday.
'weekday.short.0': 'Sun',
'weekday.short.1': 'Mon',
'weekday.short.2': 'Tue',
'weekday.short.3': 'Wed',
'weekday.short.4': 'Thu',
'weekday.short.5': 'Fri',
'weekday.short.6': 'Sat',
// History heatmap
'heatmap.title': 'Activity, last 12 weeks',
'heatmap.legend.less': 'Less',
'heatmap.legend.more': 'More',
'heatmap.tooltip.reps_one': '{n} rep',
'heatmap.tooltip.reps_many': '{n} reps',
// Sidebar
'sidebar.aria.nav': 'Main navigation',
'exercise.aria.toggle': 'Toggle exercise "{name}"',
'reminder.btn.done': 'Done', 'reminder.btn.done': 'Done',
'match.title.won': 'Victory', 'match.title.won': 'Victory',
'match.title.lost': 'Defeat', 'match.title.lost': 'Defeat',

View File

@@ -133,14 +133,18 @@ export default function Dashboard(): JSX.Element {
icon={<Flame size={14} strokeWidth={2.6} />} icon={<Flame size={14} strokeWidth={2.6} />}
/> />
<HeroStat <HeroStat
tone="info" tone={paused ? 'muted' : 'info'}
label={t('dashboard.stat.next')} label={t('dashboard.stat.next')}
// When paused, the countdown freezes — show a dash instead of a
// number that keeps ticking down, which is misleading.
value={ value={
stats.nextMs === Infinity paused
? '—' ? '—'
: stats.nextMs <= 0 : stats.nextMs === Infinity
? t('dashboard.stat.next.now') ? '—'
: formatCountdown(stats.nextMs, lang) : stats.nextMs <= 0
? t('dashboard.stat.next.now')
: formatCountdown(stats.nextMs, lang)
} }
subvalue={ subvalue={
paused paused
@@ -175,11 +179,7 @@ export default function Dashboard(): JSX.Element {
{history.length > 0 && ( {history.length > 0 && (
<div className="mb-8"> <div className="mb-8">
<HistoryHeatmap <HistoryHeatmap history={history} exercises={exercises} />
history={history}
exercises={exercises}
lang={lang}
/>
</div> </div>
)} )}

View File

@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react'
import { useAppStore } from '../store/appStore' import { useAppStore } from '../store/appStore'
import { Switch } from '../components/ui/Switch' import { Switch } from '../components/ui/Switch'
import { Card, Row, SectionHeader } from '../components/ui/Card' import { Card, Row, SectionHeader } from '../components/ui/Card'
@@ -204,6 +205,30 @@ function QuietTimesRow({
last?: boolean last?: boolean
}): JSX.Element { }): JSX.Element {
const { t } = useT() const { t } = useT()
// Local mirror of from/to so typing doesn't fire an IPC + disk write per
// keystroke. We commit on blur (or when validation passes during typing).
// The HH:MM regex catches the moment the user has typed a full time.
const [from, setFrom] = useState(qh.from)
const [to, setTo] = useState(qh.to)
const HHMM = /^\d{1,2}:\d{2}$/
// Sync from props when an external state change happens (lang switch,
// pause toggle), but only if user isn't mid-edit.
useEffect(() => {
setFrom(qh.from)
}, [qh.from])
useEffect(() => {
setTo(qh.to)
}, [qh.to])
const commit = (next: { from?: string; to?: string }): void => {
const f = next.from ?? from
const tt = next.to ?? to
if (!HHMM.test(f) || !HHMM.test(tt)) return
if (f === qh.from && tt === qh.to) return
onChange({ ...qh, from: f, to: tt })
}
return ( return (
<Row last={last} className={disabled ? 'opacity-50' : ''}> <Row last={last} className={disabled ? 'opacity-50' : ''}>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -217,17 +242,19 @@ function QuietTimesRow({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="time" type="time"
value={qh.from} value={from}
disabled={disabled} disabled={disabled}
onChange={(e) => onChange({ ...qh, from: e.target.value })} onChange={(e) => setFrom(e.target.value)}
onBlur={() => commit({ from })}
className="h-9 px-3 rounded-xl bg-surface-2 text-[14px] outline-none focus:ring-2 focus:ring-accent/45 transition-all border-0 font-mono-num" className="h-9 px-3 rounded-xl bg-surface-2 text-[14px] outline-none focus:ring-2 focus:ring-accent/45 transition-all border-0 font-mono-num"
/> />
<span className="text-text/45 text-[14px]"></span> <span className="text-text/45 text-[14px]"></span>
<input <input
type="time" type="time"
value={qh.to} value={to}
disabled={disabled} disabled={disabled}
onChange={(e) => onChange({ ...qh, to: e.target.value })} onChange={(e) => setTo(e.target.value)}
onBlur={() => commit({ to })}
className="h-9 px-3 rounded-xl bg-surface-2 text-[14px] outline-none focus:ring-2 focus:ring-accent/45 transition-all border-0 font-mono-num" className="h-9 px-3 rounded-xl bg-surface-2 text-[14px] outline-none focus:ring-2 focus:ring-accent/45 transition-all border-0 font-mono-num"
/> />
</div> </div>
@@ -246,17 +273,19 @@ function QuietDaysRow({
disabled?: boolean disabled?: boolean
last?: boolean last?: boolean
}): JSX.Element { }): JSX.Element {
const { t, lang } = useT() const { t } = useT()
const labels = // Indices match Date.getDay() (0 = Sunday) — same convention as
lang === 'en' // src/shared/types.ts QuietHours.days values.
? ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] const labels = [0, 1, 2, 3, 4, 5, 6].map((i) => t(`weekday.short.${i}`))
: ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб']
function toggle(d: number): void { function toggle(d: number): void {
const set = new Set(qh.days) const set = new Set(qh.days)
if (set.has(d)) set.delete(d) if (set.has(d)) set.delete(d)
else set.add(d) else set.add(d)
onChange({ ...qh, days: Array.from(set).sort() }) // Numeric sort — default Array.sort() does lexical and would order
// [0,1,10,2] as [0,1,10,2]; even though days are single-digit today the
// explicit comparator survives future widening.
onChange({ ...qh, days: Array.from(set).sort((a, b) => a - b) })
} }
return ( return (