Files
smart-home-tablet/components/TimerHomeWidget.tsx
Cosmo f78daffd5b
All checks were successful
Deploy / deploy (push) Successful in 3m23s
feat(timers): tap-to-open modal with big touch targets + create via UI
Tablet touch targets were cramped (30px +/- buttons). Swap to modal UX:

- New TimerModal component handles both 'control existing' and
  'create new'. 84px countdown, 56px min button height, 3×2 adjust
  grid (-5m, -1m, -10s, +10s, +1m, +5m), big destructive cancel/stop.
  Create view has 7 duration presets (1..60 min), label input with
  shortcut chips (Чайник, Паста, Яйца…), gradient 'Запустить' CTA.
- TimerHomeWidget cards are now full tap targets — open control modal
  on press. + button in header opens create modal. Inline buttons
  removed. Subtle hint text 'тап чтобы изменить'.
- Ticking countdown inside modal stays fresh via shared timers state:
  while modal is open we look up the current timer in active[] by id
  and re-pass to modal on every tick. When timer disappears
  (cancelled / expired >30s), modal auto-closes.
2026-04-23 16:34:51 +00:00

253 lines
9.4 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.
'use client'
import { useEffect, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { AlarmClock, Plus, Timer as TimerIcon } from 'lucide-react'
import TimerModal, { type TimerModalProps } from './TimerModal'
interface Timer {
id: string
label: string
startedAt: string
endsAt: string
agent?: 'cosmo' | 'lusya'
}
function formatRemaining(ms: number): { big: string; small?: string } {
if (ms <= 0) return { big: '0:00' }
const total = Math.ceil(ms / 1000)
const h = Math.floor(total / 3600)
const m = Math.floor((total % 3600) / 60)
const s = total % 60
if (h > 0) {
return { big: `${h}:${m.toString().padStart(2, '0')}`, small: `${s.toString().padStart(2, '0')}` }
}
return { big: `${m}:${s.toString().padStart(2, '0')}` }
}
export default function TimerHomeWidget() {
const [timers, setTimers] = useState<Timer[]>([])
const [, setTick] = useState(0)
const [modal, setModal] = useState<TimerModalProps['mode']>(null)
const fetchTimers = async () => {
try {
const r = await fetch('/api/voice/timer')
if (!r.ok) return
const d = await r.json()
setTimers(d.timers || [])
} catch {}
}
useEffect(() => {
let es: EventSource | null = null
let retry: ReturnType<typeof setTimeout> | null = null
let closed = false
const connect = () => {
es = new EventSource('/api/voice/stream')
es.onmessage = (e) => {
try {
const evt = JSON.parse(e.data)
if (evt.event === 'timer_start' || evt.event === 'timer_cancel') {
fetchTimers()
}
} catch {}
}
es.onerror = () => {
if (closed) return
es?.close()
retry = setTimeout(connect, 3000)
}
}
fetchTimers()
connect()
return () => {
closed = true
if (retry) clearTimeout(retry)
es?.close()
}
}, [])
useEffect(() => {
const t = setInterval(() => setTick(x => x + 1), 500)
return () => clearInterval(t)
}, [])
const now = Date.now()
const active = timers
.filter(t => new Date(t.endsAt).getTime() > now - 30_000)
.sort((a, b) => new Date(a.endsAt).getTime() - new Date(b.endsAt).getTime())
// Keep reference to the currently-open timer updated as it ticks down
// (so modal shows fresh countdown without reopening)
const modalTimer =
modal?.type === 'control' ? active.find(t => t.id === modal.timer.id) || modal.timer : null
const resolvedMode: TimerModalProps['mode'] = modal
? modal.type === 'create'
? modal
: modalTimer
? { type: 'control', timer: modalTimer }
: null // timer исчез (отменили / прозвенел >30с) — закроем
: null
useEffect(() => {
// Если таймер пропал из active, автоматом закрыть модалку
if (modal?.type === 'control' && !modalTimer) {
setModal(null)
}
}, [modal, modalTimer])
return (
<>
<div className="card" style={{
padding: '18px 20px',
display: 'flex', flexDirection: 'column', gap: 14,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<TimerIcon size={13} color="var(--text-secondary)" />
<span style={{
fontSize: 10, color: 'var(--text-secondary)',
textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 700,
}}>
Таймеры
</span>
{active.length > 0 && (
<span style={{
fontSize: 10, color: 'var(--text-tertiary)',
background: 'var(--surface-2)', padding: '2px 7px', borderRadius: 8,
fontWeight: 700, fontVariantNumeric: 'tabular-nums',
}}>
{active.length}
</span>
)}
</div>
<button
onClick={() => setModal({ type: 'create' })}
aria-label="Новый таймер"
style={{
width: 32, height: 32, borderRadius: 10,
background: 'color-mix(in srgb, #818cf8 12%, var(--surface-2))',
border: '1px solid color-mix(in srgb, #818cf8 30%, var(--border-subtle))',
color: '#a5b4fc',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer',
}}
>
<Plus size={15} />
</button>
</div>
{active.length === 0 ? (
<button
onClick={() => setModal({ type: 'create' })}
style={{
flex: 1, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
padding: '28px 0 20px', gap: 10,
color: 'var(--text-tertiary)',
background: 'transparent', border: 'none', cursor: 'pointer',
}}
>
<AlarmClock size={30} style={{ opacity: 0.4 }} />
<div style={{ fontSize: 13 }}>Нет активных таймеров</div>
<div style={{ fontSize: 11, opacity: 0.7, textAlign: 'center', lineHeight: 1.5 }}>
Скажи «поставь таймер на 5 минут»<br />или нажми +
</div>
</button>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<AnimatePresence mode="popLayout">
{active.map(t => {
const end = new Date(t.endsAt).getTime()
const start = new Date(t.startedAt).getTime()
const total = Math.max(1, end - start)
const remain = Math.max(0, end - now)
const progress = Math.max(0, Math.min(1, 1 - remain / total))
const imminent = remain > 0 && remain < 10_000
const expired = remain <= 0
const time = formatRemaining(remain)
const accent = expired ? '#f87171' : imminent ? '#fb923c' : '#818cf8'
const accentBg = expired
? 'linear-gradient(135deg, rgba(239,68,68,0.14), rgba(239,68,68,0.06))'
: imminent
? 'linear-gradient(135deg, rgba(251,146,60,0.14), rgba(251,146,60,0.06))'
: 'var(--surface-2)'
return (
<motion.button
key={t.id}
layout
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: 40 }}
transition={{ duration: 0.25 }}
onClick={() => setModal({ type: 'control', timer: t })}
style={{
position: 'relative', overflow: 'hidden',
padding: '16px 18px', borderRadius: 16,
background: accentBg,
border: `1px solid ${expired || imminent ? accent + '55' : 'var(--border-subtle)'}`,
display: 'flex', alignItems: 'center', gap: 14,
cursor: 'pointer', textAlign: 'left',
minHeight: 68,
width: '100%',
}}
>
<div style={{
position: 'absolute', bottom: 0, left: 0,
height: 3, width: `${progress * 100}%`,
background: accent,
transition: 'width 0.5s linear',
opacity: expired ? 0.5 : 1,
}} />
{/* Big countdown */}
<div style={{ display: 'flex', alignItems: 'baseline', gap: 2, minWidth: 108 }}>
<div style={{
fontSize: 34, fontWeight: 800,
color: expired ? accent : 'var(--text-primary)',
letterSpacing: '-1.5px', lineHeight: 1,
fontVariantNumeric: 'tabular-nums',
}}>
{time.big}
</div>
{time.small && (
<div style={{
fontSize: 16, color: 'var(--text-secondary)',
fontWeight: 600, fontVariantNumeric: 'tabular-nums',
}}>
:{time.small}
</div>
)}
</div>
{/* Label */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 14, fontWeight: 700,
color: 'var(--text-primary)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{t.label}
</div>
<div style={{
fontSize: 11, color: 'var(--text-tertiary)',
marginTop: 2,
}}>
{expired ? 'прозвенел — тап чтобы закрыть' : 'тап чтобы изменить'}
</div>
</div>
</motion.button>
)
})}
</AnimatePresence>
</div>
)}
</div>
<TimerModal mode={resolvedMode} onClose={() => setModal(null)} />
</>
)
}