feat(voice): hero TimerHomeWidget + timer cancel/adjust by label
All checks were successful
Deploy / deploy (push) Successful in 3m25s
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.
This commit is contained in:
@@ -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<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 (!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 })
|
||||
|
||||
63
app/page.tsx
63
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
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ───── Events + Notes row ───── */}
|
||||
{/* ───── Events + Timers row ───── */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: 14 }}>
|
||||
|
||||
{/* Events — today + tomorrow in one card */}
|
||||
@@ -705,64 +706,8 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="card" style={{ padding: '18px 20px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<StickyNote size={13} color="var(--text-secondary)" />
|
||||
<span style={{ fontSize: 10, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 700 }}>Заметки</span>
|
||||
</div>
|
||||
|
||||
{pinnedNotes.length === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', textAlign: 'center', color: 'var(--text-tertiary)' }}>
|
||||
<div>
|
||||
<StickyNote size={22} style={{ margin: '0 auto 6px', opacity: 0.4 }} />
|
||||
<div style={{ fontSize: 12 }}>Заметки появятся здесь</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{pinnedNotes.map(note => {
|
||||
const doneCount = note.items?.filter((i: any) => i.done).length || 0
|
||||
const totalCount = note.items?.length || 0
|
||||
return (
|
||||
<div key={note.id} style={{
|
||||
padding: '10px 12px', borderRadius: 12,
|
||||
background: 'var(--surface-2)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
borderLeft: `3px solid ${note.color}`,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
{note.type === 'shopping' ? <ShoppingCart size={12} color={note.color} /> : <FileText size={12} color={note.color} />}
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)', flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{note.title}</span>
|
||||
{note.type === 'shopping' && totalCount > 0 && (
|
||||
<span style={{ fontSize: 11, color: note.color, fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>{doneCount}/{totalCount}</span>
|
||||
)}
|
||||
</div>
|
||||
{note.type === 'shopping' ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{(note.items || []).filter((i: any) => !i.done).slice(0, 4).map((item: any) => (
|
||||
<div key={item.id} style={{ fontSize: 12, color: 'var(--text-secondary)', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{ width: 5, height: 5, borderRadius: 2, background: note.color, opacity: 0.6, flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.text}</span>
|
||||
</div>
|
||||
))}
|
||||
{(note.items || []).filter((i: any) => !i.done).length > 4 && (
|
||||
<div style={{ fontSize: 10, color: 'var(--text-tertiary)', marginLeft: 11 }}>
|
||||
+{(note.items || []).filter((i: any) => !i.done).length - 4} ещё
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)', lineHeight: 1.5, overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical' as any }}>
|
||||
{note.text || 'Пустая заметка'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Timers (replaces Notes on Home) */}
|
||||
<TimerHomeWidget />
|
||||
</div>
|
||||
|
||||
{/* Weather day detail modal */}
|
||||
|
||||
Reference in New Issue
Block a user