All checks were successful
Deploy / deploy (push) Successful in 3m8s
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>
253 lines
9.4 KiB
TypeScript
253 lines
9.4 KiB
TypeScript
'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)} />
|
||
</>
|
||
)
|
||
}
|