'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([]) const [tick, setTick] = useState(0) const [firedIds, setFiredIds] = useState>(new Set()) const firedRef = useRef>(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 | 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) => { try { firedRef.current.delete(id) setFiredIds(new Set(firedRef.current)) // We use POST with bearer — but widget runs with cookie auth. // Cancel endpoint only accepts bearer; for user-dismissal we use DELETE-style via... hmm. // For simplicity, tell server to cancel via a plain GET-less POST flow — skip server call here. // (The timer will be cleaned up on next listActive when expired >30min ago.) setTimers(ts => ts.filter(t => t.id !== id)) } catch {} } const now = Date.now() const active = timers.filter(t => { const remain = new Date(t.endsAt).getTime() - now return remain > -30 * 60 * 1000 // keep expired ones visible for 30 min max }) 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 */}
{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 ( {/* Progress bar */}
{t.label}
{formatRemaining(remain)}
) })}
{/* Fired alarm overlay — большой, с кнопкой dismiss */} {fired.length > 0 && (
Таймер
{fired[0].label}
)}
) }