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
checked={exercise.enabled}
onChange={onToggle}
aria-label={t('btn.done')}
aria-label={t('exercise.aria.toggle', { name: exercise.name })}
/>
</div>
</div>

View File

@@ -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 (
<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="text-[14px] text-text/75 font-semibold">
{lang === 'en'
? 'Activity, last 12 weeks'
: 'Активность за 12 недель'}
{t('heatmap.title')}
</div>
</div>
@@ -136,7 +160,7 @@ export function HistoryHeatmap({
return (
<div
key={di}
title={`${dateFmt.format(c.date)} · ${c.reps} ${lang === 'en' ? 'reps' : 'повторов'}`}
title={`${dateFmt.format(c.date)} · ${repsLabel(c.reps)}`}
className={[
'w-[12px] h-[12px] rounded-[3px] transition-colors',
tone
@@ -152,7 +176,7 @@ export function HistoryHeatmap({
{/* Legend */}
<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) => (
<div
key={b}
@@ -170,7 +194,7 @@ export function HistoryHeatmap({
].join(' ')}
/>
))}
<span>{lang === 'en' ? 'More' : 'Больше'}</span>
<span>{t('heatmap.legend.more')}</span>
</div>
</div>
)

View File

@@ -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<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 (
<>
<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 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
aria-hidden="true"
/>
<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"
initial={{ x: '-100%' }}
animate={{ x: 0 }}

View File

@@ -206,6 +206,30 @@ export const ru: Dict = {
'reminder.partial': 'Засчитаем {actual} из {planned}',
'reminder.aria.decrement': 'Уменьшить количество повторов',
'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': 'Готово',
'match.title.won': 'Победа',
'match.title.lost': 'Поражение',
@@ -426,6 +450,26 @@ export const en: Dict = {
'reminder.partial': "We'll log {actual} of {planned}",
'reminder.aria.decrement': 'Decrease 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',
'match.title.won': 'Victory',
'match.title.lost': 'Defeat',

View File

@@ -133,14 +133,18 @@ export default function Dashboard(): JSX.Element {
icon={<Flame size={14} strokeWidth={2.6} />}
/>
<HeroStat
tone="info"
tone={paused ? 'muted' : 'info'}
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={
stats.nextMs === Infinity
paused
? '—'
: stats.nextMs <= 0
? t('dashboard.stat.next.now')
: formatCountdown(stats.nextMs, lang)
: stats.nextMs === Infinity
? '—'
: stats.nextMs <= 0
? t('dashboard.stat.next.now')
: formatCountdown(stats.nextMs, lang)
}
subvalue={
paused
@@ -175,11 +179,7 @@ export default function Dashboard(): JSX.Element {
{history.length > 0 && (
<div className="mb-8">
<HistoryHeatmap
history={history}
exercises={exercises}
lang={lang}
/>
<HistoryHeatmap history={history} exercises={exercises} />
</div>
)}

View File

@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react'
import { useAppStore } from '../store/appStore'
import { Switch } from '../components/ui/Switch'
import { Card, Row, SectionHeader } from '../components/ui/Card'
@@ -204,6 +205,30 @@ function QuietTimesRow({
last?: boolean
}): JSX.Element {
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 (
<Row last={last} className={disabled ? 'opacity-50' : ''}>
<div className="flex-1 min-w-0">
@@ -217,17 +242,19 @@ function QuietTimesRow({
<div className="flex items-center gap-2">
<input
type="time"
value={qh.from}
value={from}
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"
/>
<span className="text-text/45 text-[14px]"></span>
<input
type="time"
value={qh.to}
value={to}
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"
/>
</div>
@@ -246,17 +273,19 @@ function QuietDaysRow({
disabled?: boolean
last?: boolean
}): JSX.Element {
const { t, lang } = useT()
const labels =
lang === 'en'
? ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
: ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб']
const { t } = useT()
// Indices match Date.getDay() (0 = Sunday) — same convention as
// src/shared/types.ts QuietHours.days values.
const labels = [0, 1, 2, 3, 4, 5, 6].map((i) => t(`weekday.short.${i}`))
function toggle(d: number): void {
const set = new Set(qh.days)
if (set.has(d)) set.delete(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 (