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(null) const menuRef = useRef(null) // При открытии меню переводим фокус на первый пункт — клавиатурный // пользователь сразу внутри, без слепого Tab'а. useEffect(() => { if (!menuOpen) return menuRef.current ?.querySelector('[role="menuitem"]') ?.focus() }, [menuOpen]) // Esc закрывает и возвращает фокус на триггер; стрелки/Home/End — навигация. const onMenuKeyDown = (e: React.KeyboardEvent): void => { const items = Array.from( menuRef.current?.querySelectorAll( '[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 (
{/* Icon + progress ring */}
{exercise.enabled && ( )}

{exercise.name}

{menuOpen && ( <>
setMenuOpen(false)} />
)}
{t('editor.exercise.preview.meta', { reps: exercise.reps, min: exercise.intervalMinutes })}
{/* Countdown + switch */}
{goalReached ? ( <> {t('exercise.goal_reached.kicker')} ) : isDue ? ( t('dashboard.stat.next.now') ) : ( t('fmt.through') )} {exercise.adaptive && !goalReached && ( )}
{!exercise.enabled ? t('fmt.paused') : goalReached ? t('exercise.goal_reached.value', { done: doneToday ?? 0, goal: exercise.dailyGoal ?? 0 }) : formatCountdown(ms, lang)}
{isDue && ( {t('btn.done')} )} ) }