Files
smart-home-tablet/components/TimerHomeWidget.tsx
Cosmo 0c677df558
All checks were successful
Deploy / deploy (push) Successful in 3m25s
feat(voice): hero TimerHomeWidget + timer cancel/adjust by label
UI:
- Replace Notes column on Home bento with TimerHomeWidget. Shows all
  active timers as stacked cards with big 30px countdowns, per-timer
  +1/-1 minute buttons and cancel. Colors: indigo default, amber in
  last 10s, red when expired. Empty state suggests voice command.
- Existing chip TimerWidget (bottom-right) kept for ambient view on
  other tabs — redundant on Home, but harmless.

API:
- /api/voice/timer accepts cookie OR bearer (browser widget cancel
  works with user's auth_token cookie; Python script uses bearer).
- New action 'adjust' — shifts endsAt by delta_seconds. Clamps so
  endsAt never goes into the past.
- Cancel now supports {label} in addition to {id} (fuzzy substring
  match, most-recently-started wins). Emits timer_cancel with id+label
  so clients can refresh.
- findByLabel / adjustTimer helpers in lib/timers.ts.
2026-04-23 13:51:25 +00:00

264 lines
10 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, useRef, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { AlarmClock, X, Plus, Minus, Timer as TimerIcon } from 'lucide-react'
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')}` }
}
async function callTimer(action: string, body: Record<string, any>) {
// Cookie авторизация (браузер планшета уже залогинен под PIN)
return fetch('/api/voice/timer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, ...body }),
credentials: 'include',
})
}
export default function TimerHomeWidget() {
const [timers, setTimers] = useState<Timer[]>([])
const [, setTick] = useState(0)
const fetchTimers = async () => {
try {
const r = await fetch('/api/voice/timer')
if (!r.ok) return
const d = await r.json()
setTimers(d.timers || [])
} catch {}
}
// Подписка на SSE для live-обновлений
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()
}
}, [])
// Тик каждые 500мс для плавного отсчёта
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 - 60_000) // прячем просроченные > 1 мин
.sort((a, b) => new Date(a.endsAt).getTime() - new Date(b.endsAt).getTime())
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>
</div>
{active.length === 0 ? (
<div style={{
flex: 1, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
padding: '24px 0', gap: 10,
color: 'var(--text-tertiary)',
}}>
<AlarmClock size={28} style={{ opacity: 0.4 }} />
<div style={{ fontSize: 13 }}>Нет активных таймеров</div>
<div style={{ fontSize: 11, opacity: 0.7, textAlign: 'center', lineHeight: 1.5 }}>
Скажи «поставь таймер на&nbsp;5&nbsp;минут»
</div>
</div>
) : (
<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.12), rgba(239,68,68,0.06))'
: imminent
? 'linear-gradient(135deg, rgba(251,146,60,0.12), rgba(251,146,60,0.05))'
: 'var(--surface-2)'
return (
<motion.div
key={t.id}
layout
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: 40 }}
transition={{ duration: 0.25 }}
style={{
position: 'relative', overflow: 'hidden',
padding: '14px 16px', borderRadius: 16,
background: accentBg,
border: `1px solid ${expired || imminent ? accent + '55' : 'var(--border-subtle)'}`,
}}
>
{/* Progress fill as bottom bar */}
<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,
}} />
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
{/* Big countdown */}
<div style={{ display: 'flex', alignItems: 'baseline', gap: 2, minWidth: 92 }}>
<div style={{
fontSize: 30, fontWeight: 800,
color: expired ? accent : 'var(--text-primary)',
letterSpacing: '-1.5px', lineHeight: 1,
fontVariantNumeric: 'tabular-nums',
}}>
{time.big}
</div>
{time.small && (
<div style={{
fontSize: 14, color: 'var(--text-secondary)',
fontWeight: 600, fontVariantNumeric: 'tabular-nums',
}}>
:{time.small}
</div>
)}
</div>
{/* Label */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13, fontWeight: 700,
color: 'var(--text-primary)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{t.label}
</div>
<div style={{
fontSize: 10, color: 'var(--text-tertiary)',
marginTop: 2,
}}>
{expired ? 'прозвенел' : ''}
</div>
</div>
{/* Controls */}
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => callTimer('adjust', { id: t.id, delta_seconds: -60 })}
title="1 минута"
style={{
width: 30, height: 30, borderRadius: 10,
background: 'var(--surface-3)', border: '1px solid var(--border-subtle)',
color: 'var(--text-secondary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer',
}}
>
<Minus size={13} />
</button>
<button
onClick={() => callTimer('adjust', { id: t.id, delta_seconds: 60 })}
title="+1 минута"
style={{
width: 30, height: 30, borderRadius: 10,
background: 'var(--surface-3)', border: '1px solid var(--border-subtle)',
color: 'var(--text-secondary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer',
}}
>
<Plus size={13} />
</button>
<button
onClick={() => callTimer('cancel', { id: t.id })}
title="Отменить"
style={{
width: 30, height: 30, borderRadius: 10,
background: 'rgba(239,68,68,0.12)',
border: '1px solid rgba(239,68,68,0.25)',
color: '#f87171',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer',
}}
>
<X size={13} />
</button>
</div>
</div>
</motion.div>
)
})}
</AnimatePresence>
</div>
)}
</div>
)
}