Надёжность 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>
294 lines
11 KiB
TypeScript
294 lines
11 KiB
TypeScript
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>
|
||
)
|
||
}
|