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
|
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
Reference in New Issue
Block a user