All checks were successful
Deploy / deploy (push) Successful in 3m10s
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 секунд.
271 lines
9.3 KiB
TypeScript
271 lines
9.3 KiB
TypeScript
'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>
|
||
</>
|
||
)
|
||
}
|