Files
laude/src/renderer/src/components/ExerciseCard.tsx
AnRil 46b3d59b66 feat(robustness+ui): отказоустойчивость main, тесты, a11y-полировка, лицензия
Надёжность main-процесса:
- глобальные uncaughtException/unhandledRejection (лог + flushNow)
- safeHandle/safeOn вокруг всех IPC-хендлеров (не падаем молча, generic-ошибка наружу)
- таймаут 4s на tasklist, Atomics.wait вместо busy-spin на exit-записи
- единый log.error для фоновых сбоев вместо console.error/тишины

Тесты (178 -> 203): meeting-detect, scheduler-gating, store (миграции/карантин/cap).

UI/UX:
- prefers-reduced-motion через MotionConfig + CSS media-блок
- Spinner/Skeleton примитивы, loading-состояния вместо пустых заглушек
- aria-live анонсы достижений и выполнения (useAnnounce)
- оформленные пустые состояния, клавиатура в меню ExerciseCard

Лицензия: проприетарный LICENSE + правка README/CLAUDE.md, счётчик тестов.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 13:23:53 +07:00

294 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { motion } from 'framer-motion'
import { Check, MoreHorizontal, Brain, CheckCircle2 } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import type { Exercise, Tick } from '@shared/types'
import { Icon } from '../lib/icon'
import { formatCountdown } from '../lib/format'
import { Switch } from './ui/Switch'
import { useT } from '../i18n'
import { useAnnounce } from '../lib/useAnnounce'
type Props = {
exercise: Exercise
tick?: Tick
/** Сделано повторений сегодня (для daily-goal индикатора). */
doneToday?: number
onEdit: () => void
onDelete: () => void
onToggle: (enabled: boolean) => void
onMarkDone: () => void
}
/**
* iOS-flavoured exercise card. White surface, soft shadow, big readable
* countdown. A subtle ring around the icon shows interval progress —
* Apple Fitness ring spirit but minimalist.
*/
export function ExerciseCard({
exercise,
tick,
doneToday,
onEdit,
onDelete,
onToggle,
onMarkDone
}: Props): JSX.Element {
const ms = tick?.msUntilFire ?? exercise.nextFireAt - Date.now()
const total = exercise.intervalMinutes * 60_000
const remaining = Math.max(0, Math.min(total, ms))
const elapsedPct = total > 0 ? 1 - remaining / total : 0
const goalReached =
exercise.dailyGoal !== undefined &&
exercise.dailyGoal > 0 &&
(doneToday ?? 0) >= exercise.dailyGoal
// Если цель закрыта — упражнение «отдыхает» до завтра, isDue не считаем.
const isDue = ms <= 0 && exercise.enabled && !goalReached
const [menuOpen, setMenuOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
// При открытии меню переводим фокус на первый пункт — клавиатурный
// пользователь сразу внутри, без слепого Tab'а.
useEffect(() => {
if (!menuOpen) return
menuRef.current
?.querySelector<HTMLButtonElement>('[role="menuitem"]')
?.focus()
}, [menuOpen])
// Esc закрывает и возвращает фокус на триггер; стрелки/Home/End — навигация.
const onMenuKeyDown = (e: React.KeyboardEvent<HTMLDivElement>): void => {
const items = Array.from(
menuRef.current?.querySelectorAll<HTMLButtonElement>(
'[role="menuitem"]'
) ?? []
)
if (items.length === 0) return
const idx = items.indexOf(document.activeElement as HTMLButtonElement)
if (e.key === 'Escape') {
e.preventDefault()
setMenuOpen(false)
triggerRef.current?.focus()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
items[(idx + 1) % items.length]?.focus()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
items[(idx - 1 + items.length) % items.length]?.focus()
} else if (e.key === 'Home') {
e.preventDefault()
items[0]?.focus()
} else if (e.key === 'End') {
e.preventDefault()
items[items.length - 1]?.focus()
}
}
// Дедуп rapid double-click на «Готово». Между кликом и обновлением
// nextFireAt (через broadcastState) есть окно ~1 сек, в которое можно
// вызвать markDone повторно и записать лишний entry в историю.
const markDoneInFlightRef = useRef(false)
const { t, lang } = useT()
const announce = useAnnounce()
const handleMarkDone = (): void => {
if (markDoneInFlightRef.current) return
markDoneInFlightRef.current = true
onMarkDone()
// Озвучиваем для screen-reader'ов — кнопка после засчёта исчезает,
// визуальный feedback незрячему недоступен.
announce(`${t('btn.done')}: ${exercise.name}`)
// К моменту окончания таймаута isDue уже false (после store-tick), кнопка
// не рендерится — флаг чистим на всякий случай для будущих кейсов.
setTimeout(() => {
markDoneInFlightRef.current = false
}, 1000)
}
// Ring math
const R = 22
const C = 2 * Math.PI * R
const dashOffset = C * (1 - elapsedPct)
return (
<motion.div
layout
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97 }}
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
className="relative bg-surface rounded-3xl p-5 shadow-card dark:ring-0.5 dark:ring-hairline/30"
>
<div className="flex items-start gap-4">
{/* Icon + progress ring */}
<div className="relative w-14 h-14 shrink-0">
<svg
className="absolute inset-0 -rotate-90"
viewBox="0 0 56 56"
width="56"
height="56"
>
<circle
cx="28"
cy="28"
r={R}
fill="none"
strokeWidth="2.5"
className="stroke-hairline/15 dark:stroke-hairline/30"
/>
{exercise.enabled && (
<circle
cx="28"
cy="28"
r={R}
fill="none"
strokeWidth="2.5"
strokeLinecap="round"
strokeDasharray={C}
strokeDashoffset={dashOffset}
className={isDue ? 'stroke-accent' : 'stroke-accent/85'}
style={{ transition: 'stroke-dashoffset 0.5s linear' }}
/>
)}
</svg>
<div
className={[
'absolute inset-[8px] rounded-full grid place-items-center',
exercise.enabled
? 'bg-accent/10 text-accent'
: 'bg-surface-2 text-text/40'
].join(' ')}
>
<Icon name={exercise.icon} size={20} />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<h3 className="font-display text-[18px] font-bold leading-tight truncate">
{exercise.name}
</h3>
<div className="relative">
<button
ref={triggerRef}
onClick={() => setMenuOpen((v) => !v)}
className="w-7 h-7 grid place-items-center rounded-full text-text/45 hover:bg-surface-2 active:scale-90 transition-all"
aria-label={t('titlebar.menu_aria')}
aria-haspopup="menu"
aria-expanded={menuOpen}
>
<MoreHorizontal size={16} />
</button>
{menuOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setMenuOpen(false)}
/>
<div
ref={menuRef}
role="menu"
aria-label={exercise.name}
onKeyDown={onMenuKeyDown}
className="absolute right-0 top-8 z-20 min-w-[140px] bg-surface rounded-xl shadow-sheet ring-0.5 ring-hairline/30 py-1 overflow-hidden"
>
<button
role="menuitem"
onClick={() => {
setMenuOpen(false)
onEdit()
}}
className="w-full text-left px-3 py-2 text-[13px] hover:bg-surface-2 active:bg-hairline/25"
>
{t('btn.edit')}
</button>
<button
role="menuitem"
onClick={() => {
setMenuOpen(false)
onDelete()
}}
className="w-full text-left px-3 py-2 text-[13px] text-destructive hover:bg-destructive/10 active:bg-destructive/15"
>
{t('btn.delete')}
</button>
</div>
</>
)}
</div>
</div>
<div className="text-[14px] text-text/65 mt-1 font-medium">
{t('editor.exercise.preview.meta', {
reps: exercise.reps,
min: exercise.intervalMinutes
})}
</div>
{/* Countdown + switch */}
<div className="flex items-end justify-between mt-3.5">
<div>
<div className="text-[12px] text-text/60 uppercase tracking-wider font-semibold inline-flex items-center gap-1">
{goalReached ? (
<>
<CheckCircle2
size={11}
strokeWidth={2.5}
className="text-success"
/>
{t('exercise.goal_reached.kicker')}
</>
) : isDue ? (
t('dashboard.stat.next.now')
) : (
t('fmt.through')
)}
{exercise.adaptive && !goalReached && (
<Brain
size={11}
strokeWidth={2.5}
className="text-info ml-0.5"
aria-label={t('exercise.adaptive.badge')}
/>
)}
</div>
<div
className={[
'font-mono-num text-[24px] font-bold leading-none mt-1 tracking-tight',
goalReached
? 'text-success'
: isDue
? 'text-accent'
: 'text-text'
].join(' ')}
>
{!exercise.enabled
? t('fmt.paused')
: goalReached
? t('exercise.goal_reached.value', {
done: doneToday ?? 0,
goal: exercise.dailyGoal ?? 0
})
: formatCountdown(ms, lang)}
</div>
</div>
<Switch
checked={exercise.enabled}
onChange={onToggle}
aria-label={t('exercise.aria.toggle', { name: exercise.name })}
/>
</div>
</div>
</div>
{isDue && (
<motion.button
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
onClick={handleMarkDone}
className="mt-4 w-full h-11 rounded-xl bg-accent text-white text-[15px] font-bold inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
>
<Check size={15} strokeWidth={2.5} /> {t('btn.done')}
</motion.button>
)}
</motion.div>
)
}