diff --git a/app/api/voice/timer/route.ts b/app/api/voice/timer/route.ts index d7ccc2a..70be7ff 100644 --- a/app/api/voice/timer/route.ts +++ b/app/api/voice/timer/route.ts @@ -3,24 +3,36 @@ export const runtime = 'nodejs' import { NextResponse } from 'next/server' import { voiceBus } from '@/lib/voice-bus' -import { addTimer, removeTimer, listActive } from '@/lib/timers' +import { addTimer, removeTimer, listActive, findByLabel, adjustTimer, Timer } from '@/lib/timers' -function bearerOk(req: Request): boolean { +// Допускаем либо bearer (Python-скрипт), либо auth_token cookie (браузер планшета). +// Cookie ↔ middleware нас bypass'ит для этого пути, проверяем вручную присутствие. +function authorized(req: Request): boolean { const expected = process.env.VOICE_API_KEY - if (!expected) return false const auth = req.headers.get('authorization') || '' - const token = auth.replace(/^Bearer\s+/i, '').trim() - return token === expected + const bearer = auth.replace(/^Bearer\s+/i, '').trim() + if (expected && bearer === expected) return true + + const cookie = req.headers.get('cookie') || '' + if (/auth_token=[a-f0-9]{32,}/i.test(cookie)) return true + + return false } -export async function GET(req: Request) { - // Browser (cookie auth via middleware) will reach here — listing is public to logged-in user. - // Script with bearer can also GET it. +function emit(event: string, payload: Record) { + voiceBus.emit('voice', { + event, + ...payload, + timestamp: new Date().toISOString(), + }) +} + +export async function GET() { return NextResponse.json({ timers: listActive() }) } export async function POST(req: Request) { - if (!bearerOk(req)) { + if (!authorized(req)) { return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) } @@ -29,6 +41,7 @@ export async function POST(req: Request) { return NextResponse.json({ error: 'action required' }, { status: 400 }) } + // ─────────── start ─────────── if (body.action === 'start') { const seconds = Number(body.seconds) const label = typeof body.label === 'string' ? body.label.slice(0, 80) : 'Таймер' @@ -38,26 +51,52 @@ export async function POST(req: Request) { } const endsAt = new Date(Date.now() + seconds * 1000).toISOString() const t = addTimer({ label, endsAt, agent }) - voiceBus.emit('voice', { - event: 'timer_start', - timer: t, - timestamp: new Date().toISOString(), - }) + emit('timer_start', { timer: t }) return NextResponse.json({ timer: t }) } + // ─────────── cancel ─────────── if (body.action === 'cancel') { - const id = typeof body.id === 'string' ? body.id : '' - if (!id) return NextResponse.json({ error: 'id required' }, { status: 400 }) - const ok = removeTimer(id) - if (ok) { - voiceBus.emit('voice', { - event: 'timer_cancel', - id, - timestamp: new Date().toISOString(), - }) + let target: Timer | null = null + if (typeof body.id === 'string' && body.id) { + target = removeTimer(body.id) + } else if (typeof body.label === 'string' && body.label) { + const found = findByLabel(body.label) + if (found) target = removeTimer(found.id) + } else { + return NextResponse.json({ error: 'id or label required' }, { status: 400 }) } - return NextResponse.json({ cancelled: ok }) + if (!target) { + return NextResponse.json({ cancelled: false, error: 'timer_not_found' }, { status: 404 }) + } + emit('timer_cancel', { id: target.id, label: target.label }) + return NextResponse.json({ cancelled: true, timer: target }) + } + + // ─────────── adjust ─────────── + if (body.action === 'adjust') { + const delta = Number(body.delta_seconds) + if (!Number.isFinite(delta) || delta === 0) { + return NextResponse.json({ error: 'delta_seconds must be non-zero number' }, { status: 400 }) + } + let targetId: string | null = null + if (typeof body.id === 'string' && body.id) { + targetId = body.id + } else if (typeof body.label === 'string' && body.label) { + const found = findByLabel(body.label) + if (found) targetId = found.id + } else { + return NextResponse.json({ error: 'id or label required' }, { status: 400 }) + } + if (!targetId) { + return NextResponse.json({ adjusted: false, error: 'timer_not_found' }, { status: 404 }) + } + const updated = adjustTimer(targetId, delta) + if (!updated) { + return NextResponse.json({ adjusted: false, error: 'timer_not_found' }, { status: 404 }) + } + emit('timer_start', { timer: updated }) // переиспользуем event — виджеты перечитают + return NextResponse.json({ adjusted: true, timer: updated }) } return NextResponse.json({ error: `unknown action: ${body.action}` }, { status: 400 }) diff --git a/app/page.tsx b/app/page.tsx index 76fc370..b02f906 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -13,6 +13,7 @@ import TransportWidget from '@/components/TransportWidget' import WeatherAnimation from '@/components/WeatherAnimation' import VoiceOverlay from '@/components/VoiceOverlay' import TimerWidget from '@/components/TimerWidget' +import TimerHomeWidget from '@/components/TimerHomeWidget' type Tab = 'home' | 'devices' | 'calendar' | 'notes' | 'settings' @@ -623,7 +624,7 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S )} - {/* ───── Events + Notes row ───── */} + {/* ───── Events + Timers row ───── */}
{/* Events — today + tomorrow in one card */} @@ -705,64 +706,8 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S
- {/* Notes */} -
-
- - Заметки -
- - {pinnedNotes.length === 0 ? ( -
-
- -
Заметки появятся здесь
-
-
- ) : ( -
- {pinnedNotes.map(note => { - const doneCount = note.items?.filter((i: any) => i.done).length || 0 - const totalCount = note.items?.length || 0 - return ( -
-
- {note.type === 'shopping' ? : } - {note.title} - {note.type === 'shopping' && totalCount > 0 && ( - {doneCount}/{totalCount} - )} -
- {note.type === 'shopping' ? ( -
- {(note.items || []).filter((i: any) => !i.done).slice(0, 4).map((item: any) => ( -
-
- {item.text} -
- ))} - {(note.items || []).filter((i: any) => !i.done).length > 4 && ( -
- +{(note.items || []).filter((i: any) => !i.done).length - 4} ещё -
- )} -
- ) : ( -
- {note.text || 'Пустая заметка'} -
- )} -
- ) - })} -
- )} -
+ {/* Timers (replaces Notes on Home) */} +
{/* Weather day detail modal */} diff --git a/components/TimerHomeWidget.tsx b/components/TimerHomeWidget.tsx new file mode 100644 index 0000000..1b5f48d --- /dev/null +++ b/components/TimerHomeWidget.tsx @@ -0,0 +1,263 @@ +'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) { + // 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([]) + 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 | 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 ( +
+
+
+ + + Таймеры + + {active.length > 0 && ( + + {active.length} + + )} +
+
+ + {active.length === 0 ? ( +
+ +
Нет активных таймеров
+
+ Скажи «поставь таймер на 5 минут» +
+
+ ) : ( +
+ + {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 ( + + {/* Progress fill as bottom bar */} +
+ +
+ {/* Big countdown */} +
+
+ {time.big} +
+ {time.small && ( +
+ :{time.small} +
+ )} +
+ + {/* Label */} +
+
+ {t.label} +
+
+ {expired ? 'прозвенел' : ''} +
+
+ + {/* Controls */} +
+ + + +
+
+ + ) + })} + +
+ )} +
+ ) +} diff --git a/lib/timers.ts b/lib/timers.ts index 1027e8f..6392315 100644 --- a/lib/timers.ts +++ b/lib/timers.ts @@ -7,8 +7,8 @@ const TIMERS_PATH = path.join(DATA_DIR, 'tablet-timers.json') export interface Timer { id: string label: string - startedAt: string // ISO - endsAt: string // ISO + startedAt: string + endsAt: string agent?: 'cosmo' | 'lusya' } @@ -27,11 +27,14 @@ function save(list: Timer[]) { } catch {} } -// Mutative helpers, used by timer API route +function cleanup(list: Timer[]): Timer[] { + // Drop items expired more than 30 min ago + const cutoff = Date.now() - 30 * 60 * 1000 + return list.filter(t => new Date(t.endsAt).getTime() > cutoff) +} + export function listActive(): Timer[] { - const now = Date.now() - // Drop any that expired over 30 min ago — stale garbage - const list = load().filter(t => new Date(t.endsAt).getTime() > now - 30 * 60 * 1000) + const list = cleanup(load()) save(list) return list } @@ -42,16 +45,49 @@ export function addTimer(t: Omit): Timer { startedAt: new Date().toISOString(), ...t, } - const list = load() + const list = cleanup(load()) list.push(full) save(list) return full } -export function removeTimer(id: string): boolean { +export function removeTimer(id: string): Timer | null { const list = load() - const next = list.filter(t => t.id !== id) - if (next.length === list.length) return false - save(next) - return true + const found = list.find(t => t.id === id) + if (!found) return null + save(list.filter(t => t.id !== id)) + return found +} + +/** + * Fuzzy lookup by label (case-insensitive substring match). + * Returns most-recently-started matching active timer, or null. + */ +export function findByLabel(label: string): Timer | null { + const q = label.trim().toLowerCase() + if (!q) return null + const list = listActive() + const matches = list.filter(t => + t.label.toLowerCase().includes(q) || q.includes(t.label.toLowerCase()) + ) + if (matches.length === 0) return null + // Sort by startedAt desc — most recent wins + matches.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()) + return matches[0] +} + +/** + * Shift endsAt by deltaSeconds. Returns updated timer or null if not found. + * Clamps endsAt to no earlier than "now + 1s" (не даём в прошлое). + */ +export function adjustTimer(id: string, deltaSeconds: number): Timer | null { + const list = load() + const idx = list.findIndex(t => t.id === id) + if (idx === -1) return null + const current = new Date(list[idx].endsAt).getTime() + const proposed = current + deltaSeconds * 1000 + const minimum = Date.now() + 1000 + list[idx] = { ...list[idx], endsAt: new Date(Math.max(proposed, minimum)).toISOString() } + save(list) + return list[idx] }