Files
smart-home-tablet/components/TimerHomeWidget.tsx
Cosmo e328055851
All checks were successful
Deploy / deploy (push) Successful in 3m8s
feat(design): FocusCard hero, CountdownCard, data-* palette, swipe, touch-targets
Big design pass across Home + tokens + components.

— globals.css: new data-* palette (cool/warm/hot/good/info/rose/violet/mood)
  with theme-aware variants, .grain overlay utility, .num-display
  typography helper, .hit-zone 44px wrapper, .eyebrow label, .focus-card
  base, focus-visible outline-offset 3px, space/touch scale vars.
— FocusCard.tsx: context engine — пять состояний (morning-outfit,
  tram-imminent, event-upcoming, countdown, bill-due, night, quiet).
  Auto-rotates by hour + live data. 96px display numbers, accent-mixed
  surfaces, grain overlay.
— CountdownCard.tsx + /api/countdowns: rotating 8s list, persistent
  /data/tablet-countdowns.json, full CRUD. Default seeded with Токио.
— HomeTab: replaced plain Weather hero with FocusCard, added Row 4
  with CountdownCard. Pulls trams + countdowns for the Focus context.
— Swipe between tabs: pointer-level detection on <main>, data-swipe-ignore
  bails out inside modals + note swipe-to-delete + voice overlay.
— Touch-target sweep: TopBar HA dot → 44px hit-zone, sensor chip 44px
  min-height, forecast day buttons 92px min, DeviceCard toggle 60x36,
  CalendarTab prev/next/close/list all 44x44, NotesTab buttons 44x44,
  TimerHomeWidget + 44x44, WeatherDayModal chevrons 48x48, close 48.
— Hardcoded hex → data-* tokens: TopBar sensors, TransportWidget routes
  (via color-mix), DeviceCard full rewrite (per-kind accent, glass
  removed in favor of color-mix surfaces + proper mock-state treatment),
  NotesTab palette refreshed to match dark theme.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:24:23 +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: 44, height: 44, borderRadius: 14,
background: 'color-mix(in srgb, var(--accent) 14%, var(--surface-2))',
border: '1px solid color-mix(in srgb, var(--accent) 30%, var(--border-subtle))',
color: 'var(--accent)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer',
}}
>
<Plus size={18} />
</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)} />
</>
)
}