Files
smart-home-tablet/components/TimerWidget.tsx
Cosmo fa583cd279
All checks were successful
Deploy / deploy (push) Successful in 3m10s
fix(timer): dismiss actually cancels on server + shorter retention
Bug: после перезагрузки страницы оверлей «Таймер прозвенел» открывался
снова и снова. Две причины:

- dismissTimer в TimerWidget удалял таймер только из локального
  useState, но /data/tablet-timers.json оставался нетронутым. После
  reload таймер возвращался в список и firedRef (которая пустая после
  reload) снова триггерила alarm.
- lib/timers.ts держал просроченные таймеры 30 минут, давая им шанс
  повторно сработать при каждом reload в этом окне.

Фикс:
- dismissTimer теперь POST /api/voice/timer {action:cancel, id} через
  cookie auth (endpoint с прошлого коммита принимает и cookie, и bearer).
- Retention в listActive снижена до 30 секунд — этого хватает чтобы
  клиент увидел свежий звонок; старше = самоудаление.
- TimerWidget клиентский фильтр тоже 30 секунд.
2026-04-23 13:58:53 +00:00

271 lines
9.3 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, Bell } from 'lucide-react'
interface Timer {
id: string
label: string
startedAt: string
endsAt: string
agent?: 'cosmo' | 'lusya'
}
function formatRemaining(ms: number): string {
if (ms <= 0) return '0:00'
const total = Math.round(ms / 1000)
const h = Math.floor(total / 3600)
const m = Math.floor((total % 3600) / 60)
const s = total % 60
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
return `${m}:${s.toString().padStart(2, '0')}`
}
function beep() {
try {
const AC = (window as any).AudioContext || (window as any).webkitAudioContext
if (!AC) return
const ctx = new AC()
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = 'sine'
osc.frequency.value = 880
gain.gain.value = 0.15
osc.connect(gain)
gain.connect(ctx.destination)
const t = ctx.currentTime
osc.start(t)
osc.frequency.setValueAtTime(880, t)
osc.frequency.setValueAtTime(660, t + 0.2)
osc.frequency.setValueAtTime(880, t + 0.4)
osc.stop(t + 0.6)
setTimeout(() => ctx.close(), 1000)
} catch {}
}
export default function TimerWidget() {
const [timers, setTimers] = useState<Timer[]>([])
const [tick, setTick] = useState(0)
const [firedIds, setFiredIds] = useState<Set<string>>(new Set())
const firedRef = useRef<Set<string>>(new Set())
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 subscription for real-time timer events
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()
}
}, [])
// Tick every 500ms for smooth countdown
useEffect(() => {
const t = setInterval(() => setTick(x => x + 1), 500)
return () => clearInterval(t)
}, [])
// Fire alarm when timer hits zero (once per timer)
useEffect(() => {
const now = Date.now()
for (const t of timers) {
const remain = new Date(t.endsAt).getTime() - now
if (remain <= 0 && !firedRef.current.has(t.id)) {
firedRef.current.add(t.id)
setFiredIds(new Set(firedRef.current))
beep()
// secondary beeps every 4s up to ~30s or until dismissed
let beeps = 0
const interval = setInterval(() => {
beeps++
if (beeps > 6 || !firedRef.current.has(t.id)) {
clearInterval(interval)
return
}
beep()
}, 4000)
}
}
}, [timers, tick])
const dismissTimer = async (id: string) => {
firedRef.current.delete(id)
setFiredIds(new Set(firedRef.current))
setTimers(ts => ts.filter(t => t.id !== id))
try {
// Fire-and-forget server cancel. API теперь принимает cookie auth, так что
// браузерный запрос проходит middleware.
await fetch('/api/voice/timer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'cancel', id }),
credentials: 'include',
})
} catch {}
}
const now = Date.now()
const active = timers.filter(t => {
const remain = new Date(t.endsAt).getTime() - now
// Keep expired for only 30 sec — иначе при перезагрузке давно прошедший
// таймер снова начнёт трезвонить.
return remain > -30 * 1000
})
if (active.length === 0) return null
// Separate fired (expired) timers — big alarm modal — from running timers (chips)
const fired = active.filter(t => firedIds.has(t.id))
const running = active.filter(t => !firedIds.has(t.id))
return (
<>
{/* Running timer chips — fixed bottom-right, stacked */}
<div style={{
position: 'fixed', bottom: 16, right: 16, zIndex: 180,
display: 'flex', flexDirection: 'column', gap: 8,
pointerEvents: 'none',
}}>
<AnimatePresence>
{running.map(t => {
const remain = new Date(t.endsAt).getTime() - now
const total = new Date(t.endsAt).getTime() - new Date(t.startedAt).getTime()
const progress = Math.max(0, Math.min(1, 1 - remain / total))
const imminent = remain < 10_000
return (
<motion.div
key={t.id}
initial={{ opacity: 0, x: 30 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 30 }}
layout
style={{
pointerEvents: 'auto',
background: imminent
? 'linear-gradient(135deg, rgba(251,146,60,0.22), rgba(239,68,68,0.18))'
: 'rgba(20, 20, 40, 0.88)',
border: imminent ? '1px solid rgba(251,146,60,0.4)' : '1px solid rgba(255,255,255,0.08)',
borderRadius: 16, padding: '10px 14px',
backdropFilter: 'blur(20px)',
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
display: 'flex', alignItems: 'center', gap: 10,
minWidth: 180, position: 'relative', overflow: 'hidden',
}}
>
{/* Progress bar */}
<div style={{
position: 'absolute', bottom: 0, left: 0,
height: 2, width: `${progress * 100}%`,
background: imminent ? '#fb923c' : '#818cf8',
transition: 'width 0.5s linear',
}} />
<AlarmClock size={16} color={imminent ? '#fb923c' : '#a5b4fc'} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 10, color: 'rgba(255,255,255,0.55)',
fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{t.label}
</div>
<div style={{
fontSize: 17, fontWeight: 800, color: 'white',
fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.5px',
lineHeight: 1.1,
}}>
{formatRemaining(remain)}
</div>
</div>
</motion.div>
)
})}
</AnimatePresence>
</div>
{/* Fired alarm overlay — большой, с кнопкой dismiss */}
<AnimatePresence>
{fired.length > 0 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{
position: 'fixed', inset: 0, zIndex: 310,
background: 'rgba(10, 5, 5, 0.82)',
backdropFilter: 'blur(20px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 40,
}}
>
<motion.div
animate={{ scale: [1, 1.05, 1] }}
transition={{ duration: 1, repeat: Infinity }}
style={{
background: 'linear-gradient(135deg, rgba(251,146,60,0.3), rgba(239,68,68,0.25))',
border: '2px solid rgba(251,146,60,0.5)',
borderRadius: 32, padding: '40px 48px',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 20,
maxWidth: 520, textAlign: 'center',
boxShadow: '0 24px 80px rgba(251,146,60,0.4)',
}}
>
<Bell size={64} color="#fb923c" />
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.7)', fontWeight: 700, letterSpacing: '0.2em', textTransform: 'uppercase' }}>
Таймер
</div>
<div style={{ fontSize: 32, fontWeight: 800, color: 'white', letterSpacing: '-0.5px' }}>
{fired[0].label}
</div>
<button
onClick={() => dismissTimer(fired[0].id)}
style={{
marginTop: 12, padding: '14px 36px', borderRadius: 16,
background: 'rgba(255,255,255,0.12)', border: '1px solid rgba(255,255,255,0.2)',
color: 'white', fontSize: 16, fontWeight: 700,
display: 'flex', alignItems: 'center', gap: 8,
cursor: 'pointer',
}}
>
<X size={18} /> Остановить
</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
)
}