From 0cd683d5dfaeeff09562aa3978652f2768321c0d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 16:29:28 +0400 Subject: [PATCH] feat(core): due, stale-sent and claimable selection Refs #1 Co-Authored-By: Claude Opus 5.5 --- src/core/schedule.ts | 38 ++++++++++++++++++++++ tests/schedule.test.ts | 74 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 src/core/schedule.ts create mode 100644 tests/schedule.test.ts diff --git a/src/core/schedule.ts b/src/core/schedule.ts new file mode 100644 index 0000000..4e51ab2 --- /dev/null +++ b/src/core/schedule.ts @@ -0,0 +1,38 @@ +import { SENT_STALE_MS, type VoiceRecord } from './record'; + +export interface Located { + path: string; + record: VoiceRecord; +} + +const time = (iso?: string) => (iso ? Date.parse(iso) : NaN); + +// Отсутствующее или испорченное время — не повод зависнуть навсегда. +export function isStaleSent(r: VoiceRecord, now: Date): boolean { + if (r.status !== 'sent') return false; + const t = time(r.sentAt); + return Number.isNaN(t) || now.getTime() - t > SENT_STALE_MS; +} + +export function due(items: readonly Located[], now: Date, deviceId: string): Located[] { + return items + .filter(({ record: r }) => { + if (r.device !== deviceId) return false; + if (r.status === 'saved') return true; + if (r.status !== 'retry') return false; + const t = time(r.nextAttempt); + return Number.isNaN(t) || t <= now.getTime(); + }) + .sort((a, b) => (a.record.id < b.record.id ? -1 : a.record.id > b.record.id ? 1 : 0)); +} + +export function staleOwn(items: readonly Located[], now: Date, deviceId: string): Located[] { + return items.filter(({ record: r }) => r.device === deviceId && isStaleSent(r, now)); +} + +export function claimable(items: readonly Located[], now: Date, deviceId: string): Located[] { + return items.filter( + ({ record: r }) => + r.device !== deviceId && (r.status === 'saved' || r.status === 'retry' || isStaleSent(r, now)), + ); +} diff --git a/tests/schedule.test.ts b/tests/schedule.test.ts new file mode 100644 index 0000000..74cea4b --- /dev/null +++ b/tests/schedule.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import type { VoiceRecord } from '../src/core/record'; +import { claimable, due, isStaleSent, staleOwn, type Located } from '../src/core/schedule'; + +const NOW = new Date('2026-09-27T11:15:03.000Z'); +const ago = (ms: number) => new Date(NOW.getTime() - ms).toISOString(); +const later = (ms: number) => new Date(NOW.getTime() + ms).toISOString(); + +function item(id: string, over: Partial = {}): Located { + return { + path: `voice/inbox/${id}.md`, + record: { id, status: 'saved', device: 'me', audio: `voice/source/${id}.m4a`, mime: 'audio/mp4', attempts: 0, ...over }, + }; +} +const ids = (xs: Located[]) => xs.map((x) => x.record.id); + +describe('due', () => { + it('takes own saved and retry whose time has come, oldest id first', () => { + const items = [ + item('c', { status: 'retry', nextAttempt: NOW.toISOString() }), + item('a'), + item('b', { status: 'retry', nextAttempt: later(1) }), + item('d', { device: 'other' }), + item('e', { status: 'sent', sentAt: ago(0) }), + item('f', { status: 'failed' }), + item('g', { status: 'inserted' }), + ]; + expect(ids(due(items, NOW, 'me'))).toEqual(['a', 'c']); + }); + + it('unparsable nextAttempt is due', () => { + expect(ids(due([item('a', { status: 'retry', nextAttempt: 'завтра' })], NOW, 'me'))).toEqual(['a']); + }); +}); + +describe('isStaleSent', () => { + it.each([ + ['exactly 10 min', { status: 'sent' as const, sentAt: ago(600_000) }, false], + ['10 min + 1 ms', { status: 'sent' as const, sentAt: ago(600_001) }, true], + ['2 min', { status: 'sent' as const, sentAt: ago(120_000) }, false], + ['no sentAt', { status: 'sent' as const }, true], + ['garbage sentAt', { status: 'sent' as const, sentAt: 'x' }, true], + ['not sent', { status: 'retry' as const, sentAt: ago(3_600_000) }, false], + ])('%s → %s', (_name, over, expected) => { + expect(isStaleSent(item('a', over).record, NOW)).toBe(expected); + }); +}); + +describe('staleOwn', () => { + it('only own stale sent', () => { + const items = [ + item('a', { status: 'sent', sentAt: ago(700_000) }), + item('b', { status: 'sent', sentAt: ago(700_000), device: 'other' }), + item('c', { status: 'sent', sentAt: ago(1_000) }), + ]; + expect(ids(staleOwn(items, NOW, 'me'))).toEqual(['a']); + }); +}); + +describe('claimable', () => { + it("others' saved, retry and stale sent only", () => { + const o = { device: 'other' }; + const items = [ + item('a', o), + item('b', { ...o, status: 'retry', nextAttempt: later(1_200_000) }), + item('c', { ...o, status: 'sent', sentAt: ago(700_000) }), + item('d', { ...o, status: 'sent', sentAt: ago(1_000) }), + item('e', { ...o, status: 'failed' }), + item('f', { ...o, status: 'inserted' }), + item('g'), + ]; + expect(ids(claimable(items, NOW, 'me'))).toEqual(['a', 'b', 'c']); + }); +});