All checks were successful
Deploy / deploy (push) Successful in 3m10s
Bug: после перезагрузки страницы оверлей «Таймер прозвенел» открывался
снова и снова. Две причины:
- dismissTimer в TimerWidget удалял таймер только из локального
useState, но /data/tablet-timers.json оставался нетронутым. После
reload таймер возвращался в список и firedRef (которая пустая после
reload) снова триггерила alarm.
- lib/timers.ts держал просроченные таймеры 30 минут, давая им шанс
повторно сработать при каждом reload в этом окне.
Фикс:
- dismissTimer теперь POST /api/voice/timer {action:cancel, id} через
cookie auth (endpoint с прошлого коммита принимает и cookie, и bearer).
- Retention в listActive снижена до 30 секунд — этого хватает чтобы
клиент увидел свежий звонок; старше = самоудаление.
- TimerWidget клиентский фильтр тоже 30 секунд.
96 lines
2.8 KiB
TypeScript
96 lines
2.8 KiB
TypeScript
import * as fs from 'fs'
|
|
import * as path from 'path'
|
|
|
|
const DATA_DIR = fs.existsSync('/data') ? '/data' : '/tmp'
|
|
const TIMERS_PATH = path.join(DATA_DIR, 'tablet-timers.json')
|
|
|
|
export interface Timer {
|
|
id: string
|
|
label: string
|
|
startedAt: string
|
|
endsAt: string
|
|
agent?: 'cosmo' | 'lusya'
|
|
}
|
|
|
|
function load(): Timer[] {
|
|
try {
|
|
if (fs.existsSync(TIMERS_PATH)) {
|
|
return JSON.parse(fs.readFileSync(TIMERS_PATH, 'utf-8'))
|
|
}
|
|
} catch {}
|
|
return []
|
|
}
|
|
|
|
function save(list: Timer[]) {
|
|
try {
|
|
fs.writeFileSync(TIMERS_PATH, JSON.stringify(list, null, 2))
|
|
} catch {}
|
|
}
|
|
|
|
function cleanup(list: Timer[]): Timer[] {
|
|
// Drop items expired more than 30 seconds ago. Достаточно чтобы показать
|
|
// «звенит» при активной странице, но не воскрешать alarm после перезагрузки
|
|
// через час.
|
|
const cutoff = Date.now() - 30 * 1000
|
|
return list.filter(t => new Date(t.endsAt).getTime() > cutoff)
|
|
}
|
|
|
|
export function listActive(): Timer[] {
|
|
const list = cleanup(load())
|
|
save(list)
|
|
return list
|
|
}
|
|
|
|
export function addTimer(t: Omit<Timer, 'id' | 'startedAt'>): Timer {
|
|
const full: Timer = {
|
|
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
|
startedAt: new Date().toISOString(),
|
|
...t,
|
|
}
|
|
const list = cleanup(load())
|
|
list.push(full)
|
|
save(list)
|
|
return full
|
|
}
|
|
|
|
export function removeTimer(id: string): Timer | null {
|
|
const list = load()
|
|
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]
|
|
}
|