feat(voice): hero TimerHomeWidget + timer cancel/adjust by label
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:
Cosmo
2026-04-23 13:51:25 +00:00
parent 7fb05181e6
commit 0c677df558
4 changed files with 378 additions and 95 deletions

View File

@@ -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, 'id' | 'startedAt'>): 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]
}