Add a local-calendar daily start scheduler with pending receipts, per-day deduplication, DST-safe target calculation, missed-day recovery, and retryable account/start failure outcomes. Persist and validate the daily rule, expose its next target to the renderer, preserve legacy one-time schedules until a daily rule is saved, and gate boot auto-resume on recorded active-run evidence. Wire boot, settings, account, suspend, resume, interval, and shutdown lifecycle handling with focused RED/GREEN coverage for calendar, persistence, renderer, and controller boundaries.
216 lines
6.9 KiB
TypeScript
216 lines
6.9 KiB
TypeScript
import type { DailyStartOutcome, DailyStartSettings } from "../shared/types";
|
|
|
|
interface DailyStartSnapshot {
|
|
settings: DailyStartSettings;
|
|
session: {
|
|
running: boolean;
|
|
paused: boolean;
|
|
items: Record<string, { status: string }>;
|
|
};
|
|
canStart: boolean;
|
|
}
|
|
|
|
interface DailyStartController {
|
|
getSnapshot(): DailyStartSnapshot;
|
|
updateSettings(partial: Partial<DailyStartSettings>): unknown;
|
|
start(): Promise<void>;
|
|
}
|
|
|
|
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<string, unknown>;
|
|
return ["dailyStartEnabled", "dailyStartMinuteOfDay", "dailyStartFirstLocalDate"]
|
|
.some((key) => Object.prototype.hasOwnProperty.call(value, key));
|
|
}
|
|
|
|
export function prepareDailyStartSettingsPatch<T extends object>(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<DailyStartOutcome | null> | null = null;
|
|
private reconcileTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
public constructor(
|
|
private readonly controller: DailyStartController,
|
|
private readonly now: () => number = Date.now
|
|
) {}
|
|
|
|
public reconcile(): Promise<DailyStartOutcome | null> {
|
|
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<DailyStartOutcome | null> {
|
|
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");
|
|
}
|
|
}
|