All checks were successful
Deploy / deploy (push) Successful in 3m25s
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.
104 lines
4.0 KiB
TypeScript
104 lines
4.0 KiB
TypeScript
export const dynamic = 'force-dynamic'
|
|
export const runtime = 'nodejs'
|
|
|
|
import { NextResponse } from 'next/server'
|
|
import { voiceBus } from '@/lib/voice-bus'
|
|
import { addTimer, removeTimer, listActive, findByLabel, adjustTimer, Timer } from '@/lib/timers'
|
|
|
|
// Допускаем либо bearer (Python-скрипт), либо auth_token cookie (браузер планшета).
|
|
// Cookie ↔ middleware нас bypass'ит для этого пути, проверяем вручную присутствие.
|
|
function authorized(req: Request): boolean {
|
|
const expected = process.env.VOICE_API_KEY
|
|
const auth = req.headers.get('authorization') || ''
|
|
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
|
|
}
|
|
|
|
function emit(event: string, payload: Record<string, any>) {
|
|
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 (!authorized(req)) {
|
|
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
const body = await req.json().catch(() => null)
|
|
if (!body || typeof body.action !== 'string') {
|
|
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) : 'Таймер'
|
|
const agent = body.agent === 'lusya' ? 'lusya' : 'cosmo'
|
|
if (!Number.isFinite(seconds) || seconds < 1 || seconds > 24 * 3600) {
|
|
return NextResponse.json({ error: 'seconds must be 1..86400' }, { status: 400 })
|
|
}
|
|
const endsAt = new Date(Date.now() + seconds * 1000).toISOString()
|
|
const t = addTimer({ label, endsAt, agent })
|
|
emit('timer_start', { timer: t })
|
|
return NextResponse.json({ timer: t })
|
|
}
|
|
|
|
// ─────────── cancel ───────────
|
|
if (body.action === 'cancel') {
|
|
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 })
|
|
}
|
|
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 })
|
|
}
|