import type { DailyStartOutcome, DailyStartSettings } from "../shared/types"; interface DailyStartSnapshot { settings: DailyStartSettings; session: { running: boolean; paused: boolean; items: Record; }; canStart: boolean; } interface DailyStartController { getSnapshot(): DailyStartSnapshot; updateSettings(partial: Partial): unknown; start(): Promise; } interface LocalDateParts { year: number; month: number; day: number; } function parseLocalDate(value: string): LocalDateParts | null { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); if (!match) { return null; } const year = Number(match[1]); const month = Number(match[2]); const day = Number(match[3]); const date = new Date(year, month - 1, day, 12, 0, 0, 0); if (year < 1000 || date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) { return null; } return { year, month, day }; } function formatLocalDate(date: Date): string { return `${date.getFullYear().toString().padStart(4, "0")}-${(date.getMonth() + 1).toString().padStart(2, "0")}-${date.getDate().toString().padStart(2, "0")}`; } function addLocalDays(value: string, days: number): string { const parts = parseLocalDate(value); if (!parts) { return ""; } return formatLocalDate(new Date(parts.year, parts.month - 1, parts.day + days, 12, 0, 0, 0)); } function localTargetEpochMs(value: string, minuteOfDay: number): number { const parts = parseLocalDate(value); if (!parts) { return 0; } const minute = Math.max(0, Math.min(1_439, Math.floor(minuteOfDay))); return new Date(parts.year, parts.month - 1, parts.day, Math.floor(minute / 60), minute % 60, 0, 0).getTime(); } export function isValidLocalDate(value: string): boolean { return parseLocalDate(value) !== null; } export function nextDailyStartEpochMs(settings: DailyStartSettings, nowEpochMs = Date.now()): number { if (!settings.dailyStartEnabled || !Number.isFinite(settings.dailyStartMinuteOfDay)) { return 0; } const firstDate = parseLocalDate(settings.dailyStartFirstLocalDate); if (!firstDate) { return 0; } const now = new Date(nowEpochMs); const today = formatLocalDate(now); let candidate = settings.dailyStartFirstLocalDate > today ? settings.dailyStartFirstLocalDate : today; const handled = isValidLocalDate(settings.dailyStartLastHandledLocalDate) ? settings.dailyStartLastHandledLocalDate : ""; if (handled && candidate <= handled) { candidate = addLocalDays(handled, 1); } return localTargetEpochMs(candidate, settings.dailyStartMinuteOfDay); } export function hasDailyStartRulePatch(partial: object): boolean { const value = partial as Record; return ["dailyStartEnabled", "dailyStartMinuteOfDay", "dailyStartFirstLocalDate"] .some((key) => Object.prototype.hasOwnProperty.call(value, key)); } export function prepareDailyStartSettingsPatch(partial: T): T & { scheduledStartEpochMs?: number } { return hasDailyStartRulePatch(partial) ? { ...partial, scheduledStartEpochMs: 0 } : { ...partial }; } export function shouldDeferAutoResumeToDailyStart( settings: DailyStartSettings, wasRunning: boolean, nowEpochMs = Date.now() ): boolean { return !wasRunning && nextDailyStartEpochMs(settings, nowEpochMs) > nowEpochMs; } export class DailyStartScheduler { private reconcileInFlight: Promise | null = null; private reconcileTimer: ReturnType | null = null; public constructor( private readonly controller: DailyStartController, private readonly now: () => number = Date.now ) {} public reconcile(): Promise { if (this.reconcileInFlight) { return this.reconcileInFlight; } const operation = this.reconcileOnce(); this.reconcileInFlight = operation; void operation.finally(() => { if (this.reconcileInFlight === operation) { this.reconcileInFlight = null; } }).catch(() => {}); return operation; } public begin(onError: (error: unknown) => void = () => {}): void { this.end(); void this.reconcile().catch(onError); this.reconcileTimer = setInterval(() => { void this.reconcile().catch(onError); }, 60_000); this.reconcileTimer.unref?.(); } public end(): void { if (this.reconcileTimer !== null) { clearInterval(this.reconcileTimer); this.reconcileTimer = null; } } private finish(localDate: string, outcome: DailyStartOutcome): DailyStartOutcome { this.controller.updateSettings({ dailyStartLastHandledLocalDate: localDate, dailyStartPendingLocalDate: "", dailyStartLastOutcome: outcome }); return outcome; } private async reconcileOnce(): Promise { const nowEpochMs = this.now(); let settings = this.controller.getSnapshot().settings; if (!settings.dailyStartEnabled || !isValidLocalDate(settings.dailyStartFirstLocalDate)) { return null; } const today = formatLocalDate(new Date(nowEpochMs)); let missed: DailyStartOutcome | null = null; if (isValidLocalDate(settings.dailyStartPendingLocalDate) && settings.dailyStartPendingLocalDate < today) { const pendingDate = settings.dailyStartPendingLocalDate; this.controller.updateSettings({ dailyStartLastHandledLocalDate: pendingDate, dailyStartPendingLocalDate: "", dailyStartLastOutcome: "missed" }); settings = { ...settings, dailyStartLastHandledLocalDate: pendingDate, dailyStartPendingLocalDate: "", dailyStartLastOutcome: "missed" }; missed = "missed"; } if (today < settings.dailyStartFirstLocalDate || settings.dailyStartLastHandledLocalDate === today) { return missed; } const targetEpochMs = localTargetEpochMs(today, settings.dailyStartMinuteOfDay); if (!targetEpochMs || targetEpochMs > nowEpochMs) { return missed; } if (settings.dailyStartPendingLocalDate !== today) { this.controller.updateSettings({ dailyStartPendingLocalDate: today, dailyStartLastOutcome: "" }); } const snapshot = this.controller.getSnapshot(); if (snapshot.session.running || snapshot.session.paused) { return this.finish(today, "already_active"); } const hasQueuedItems = Object.values(snapshot.session.items) .some((item) => item.status === "queued" || item.status === "reconnect_wait"); if (!hasQueuedItems) { return this.finish(today, "empty_queue"); } if (!snapshot.canStart) { this.controller.updateSettings({ dailyStartLastOutcome: "missing_account" }); return "missing_account"; } try { await this.controller.start(); } catch (error) { this.controller.updateSettings({ dailyStartLastOutcome: "start_failed" }); throw error; } return this.finish(today, "started"); } }