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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -133,10 +133,14 @@ 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 === Infinity
|
||||
? '—'
|
||||
: stats.nextMs <= 0
|
||||
? t('dashboard.stat.next.now')
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user