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 { NextResponse } from 'next/server'
|
||||||
import { voiceBus } from '@/lib/voice-bus'
|
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
|
const expected = process.env.VOICE_API_KEY
|
||||||
if (!expected) return false
|
|
||||||
const auth = req.headers.get('authorization') || ''
|
const auth = req.headers.get('authorization') || ''
|
||||||
const token = auth.replace(/^Bearer\s+/i, '').trim()
|
const bearer = auth.replace(/^Bearer\s+/i, '').trim()
|
||||||
return token === expected
|
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) {
|
function emit(event: string, payload: Record<string, any>) {
|
||||||
// Browser (cookie auth via middleware) will reach here — listing is public to logged-in user.
|
voiceBus.emit('voice', {
|
||||||
// Script with bearer can also GET it.
|
event,
|
||||||
|
...payload,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
return NextResponse.json({ timers: listActive() })
|
return NextResponse.json({ timers: listActive() })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
if (!bearerOk(req)) {
|
if (!authorized(req)) {
|
||||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
|
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 })
|
return NextResponse.json({ error: 'action required' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────── start ───────────
|
||||||
if (body.action === 'start') {
|
if (body.action === 'start') {
|
||||||
const seconds = Number(body.seconds)
|
const seconds = Number(body.seconds)
|
||||||
const label = typeof body.label === 'string' ? body.label.slice(0, 80) : 'Таймер'
|
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 endsAt = new Date(Date.now() + seconds * 1000).toISOString()
|
||||||
const t = addTimer({ label, endsAt, agent })
|
const t = addTimer({ label, endsAt, agent })
|
||||||
voiceBus.emit('voice', {
|
emit('timer_start', { timer: t })
|
||||||
event: 'timer_start',
|
|
||||||
timer: t,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
return NextResponse.json({ timer: t })
|
return NextResponse.json({ timer: t })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────── cancel ───────────
|
||||||
if (body.action === 'cancel') {
|
if (body.action === 'cancel') {
|
||||||
const id = typeof body.id === 'string' ? body.id : ''
|
let target: Timer | null = null
|
||||||
if (!id) return NextResponse.json({ error: 'id required' }, { status: 400 })
|
if (typeof body.id === 'string' && body.id) {
|
||||||
const ok = removeTimer(id)
|
target = removeTimer(body.id)
|
||||||
if (ok) {
|
} else if (typeof body.label === 'string' && body.label) {
|
||||||
voiceBus.emit('voice', {
|
const found = findByLabel(body.label)
|
||||||
event: 'timer_cancel',
|
if (found) target = removeTimer(found.id)
|
||||||
id,
|
} else {
|
||||||
timestamp: new Date().toISOString(),
|
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 })
|
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 WeatherAnimation from '@/components/WeatherAnimation'
|
||||||
import VoiceOverlay from '@/components/VoiceOverlay'
|
import VoiceOverlay from '@/components/VoiceOverlay'
|
||||||
import TimerWidget from '@/components/TimerWidget'
|
import TimerWidget from '@/components/TimerWidget'
|
||||||
|
import TimerHomeWidget from '@/components/TimerHomeWidget'
|
||||||
|
|
||||||
type Tab = 'home' | 'devices' | 'calendar' | 'notes' | 'settings'
|
type Tab = 'home' | 'devices' | 'calendar' | 'notes' | 'settings'
|
||||||
|
|
||||||
@@ -623,7 +624,7 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ───── Events + Notes row ───── */}
|
{/* ───── Events + Timers row ───── */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: 14 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: 14 }}>
|
||||||
|
|
||||||
{/* Events — today + tomorrow in one card */}
|
{/* Events — today + tomorrow in one card */}
|
||||||
@@ -705,64 +706,8 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Timers (replaces Notes on Home) */}
|
||||||
<div className="card" style={{ padding: '18px 20px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<TimerHomeWidget />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Weather day detail modal */}
|
{/* Weather day detail modal */}
|
||||||
|
|||||||
263
components/TimerHomeWidget.tsx
Normal file
263
components/TimerHomeWidget.tsx
Normal file
@@ -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<string, any>) {
|
||||||
|
// 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<Timer[]>([])
|
||||||
|
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<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()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Тик каждые 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 (
|
||||||
|
<div className="card" style={{
|
||||||
|
padding: '18px 20px',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 14,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<TimerIcon size={13} color="var(--text-secondary)" />
|
||||||
|
<span style={{
|
||||||
|
fontSize: 10, color: 'var(--text-secondary)',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 700,
|
||||||
|
}}>
|
||||||
|
Таймеры
|
||||||
|
</span>
|
||||||
|
{active.length > 0 && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 10, color: 'var(--text-tertiary)',
|
||||||
|
background: 'var(--surface-2)', padding: '2px 7px', borderRadius: 8,
|
||||||
|
fontWeight: 700, fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}>
|
||||||
|
{active.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{active.length === 0 ? (
|
||||||
|
<div style={{
|
||||||
|
flex: 1, display: 'flex', flexDirection: 'column',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: '24px 0', gap: 10,
|
||||||
|
color: 'var(--text-tertiary)',
|
||||||
|
}}>
|
||||||
|
<AlarmClock size={28} style={{ opacity: 0.4 }} />
|
||||||
|
<div style={{ fontSize: 13 }}>Нет активных таймеров</div>
|
||||||
|
<div style={{ fontSize: 11, opacity: 0.7, textAlign: 'center', lineHeight: 1.5 }}>
|
||||||
|
Скажи «поставь таймер на 5 минут»
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{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 (
|
||||||
|
<motion.div
|
||||||
|
key={t.id}
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, x: 40 }}
|
||||||
|
transition={{ duration: 0.25 }}
|
||||||
|
style={{
|
||||||
|
position: 'relative', overflow: 'hidden',
|
||||||
|
padding: '14px 16px', borderRadius: 16,
|
||||||
|
background: accentBg,
|
||||||
|
border: `1px solid ${expired || imminent ? accent + '55' : 'var(--border-subtle)'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Progress fill as bottom bar */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: 0, left: 0,
|
||||||
|
height: 3, width: `${progress * 100}%`,
|
||||||
|
background: accent,
|
||||||
|
transition: 'width 0.5s linear',
|
||||||
|
opacity: expired ? 0.5 : 1,
|
||||||
|
}} />
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
|
{/* Big countdown */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 2, minWidth: 92 }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 30, fontWeight: 800,
|
||||||
|
color: expired ? accent : 'var(--text-primary)',
|
||||||
|
letterSpacing: '-1.5px', lineHeight: 1,
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}>
|
||||||
|
{time.big}
|
||||||
|
</div>
|
||||||
|
{time.small && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: 14, color: 'var(--text-secondary)',
|
||||||
|
fontWeight: 600, fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}>
|
||||||
|
:{time.small}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 13, fontWeight: 700,
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{t.label}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 10, color: 'var(--text-tertiary)',
|
||||||
|
marginTop: 2,
|
||||||
|
}}>
|
||||||
|
{expired ? 'прозвенел' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => callTimer('adjust', { id: t.id, delta_seconds: -60 })}
|
||||||
|
title="−1 минута"
|
||||||
|
style={{
|
||||||
|
width: 30, height: 30, borderRadius: 10,
|
||||||
|
background: 'var(--surface-3)', border: '1px solid var(--border-subtle)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Minus size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => callTimer('adjust', { id: t.id, delta_seconds: 60 })}
|
||||||
|
title="+1 минута"
|
||||||
|
style={{
|
||||||
|
width: 30, height: 30, borderRadius: 10,
|
||||||
|
background: 'var(--surface-3)', border: '1px solid var(--border-subtle)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => callTimer('cancel', { id: t.id })}
|
||||||
|
title="Отменить"
|
||||||
|
style={{
|
||||||
|
width: 30, height: 30, borderRadius: 10,
|
||||||
|
background: 'rgba(239,68,68,0.12)',
|
||||||
|
border: '1px solid rgba(239,68,68,0.25)',
|
||||||
|
color: '#f87171',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,8 +7,8 @@ const TIMERS_PATH = path.join(DATA_DIR, 'tablet-timers.json')
|
|||||||
export interface Timer {
|
export interface Timer {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
startedAt: string // ISO
|
startedAt: string
|
||||||
endsAt: string // ISO
|
endsAt: string
|
||||||
agent?: 'cosmo' | 'lusya'
|
agent?: 'cosmo' | 'lusya'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,11 +27,14 @@ function save(list: Timer[]) {
|
|||||||
} catch {}
|
} 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[] {
|
export function listActive(): Timer[] {
|
||||||
const now = Date.now()
|
const list = cleanup(load())
|
||||||
// Drop any that expired over 30 min ago — stale garbage
|
|
||||||
const list = load().filter(t => new Date(t.endsAt).getTime() > now - 30 * 60 * 1000)
|
|
||||||
save(list)
|
save(list)
|
||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
@@ -42,16 +45,49 @@ export function addTimer(t: Omit<Timer, 'id' | 'startedAt'>): Timer {
|
|||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
...t,
|
...t,
|
||||||
}
|
}
|
||||||
const list = load()
|
const list = cleanup(load())
|
||||||
list.push(full)
|
list.push(full)
|
||||||
save(list)
|
save(list)
|
||||||
return full
|
return full
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeTimer(id: string): boolean {
|
export function removeTimer(id: string): Timer | null {
|
||||||
const list = load()
|
const list = load()
|
||||||
const next = list.filter(t => t.id !== id)
|
const found = list.find(t => t.id === id)
|
||||||
if (next.length === list.length) return false
|
if (!found) return null
|
||||||
save(next)
|
save(list.filter(t => t.id !== id))
|
||||||
return true
|
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]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user