managarten/packages/shared-stores/src/reminder-scheduler.ts
Till JS 4fa096147c feat(shared-stores,shared-ui): add shared reminder system
Add notificationService (Browser Notification API wrapper),
createReminderScheduler (30s poller with source pattern for checking
due reminders), and ReminderPicker UI component.

Todo module gets todoReminderSource (checks task dueDate - minutesBefore)
and ReminderSelector now delegates to shared ReminderPicker.

Scheduler supports multiple sources (todo, calendar, planta, etc.),
tag-based dedup, graceful error handling, and runtime source addition.
22 new tests (8 notification + 8 scheduler + 6 ReminderPicker).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:54:15 +02:00

117 lines
2.8 KiB
TypeScript

/**
* Reminder Scheduler
*
* Central polling service that checks all registered reminder sources
* and fires browser notifications for due reminders.
*
* @example
* ```typescript
* import { createReminderScheduler } from '@manacore/shared-stores';
* import { todoReminderSource } from '$lib/modules/todo/reminder-source';
*
* const scheduler = createReminderScheduler({
* sources: [todoReminderSource],
* });
*
* // Start polling (typically in +layout.svelte onMount)
* scheduler.start();
*
* // Stop on destroy
* scheduler.stop();
* ```
*/
import { notificationService } from './notifications';
export interface DueReminder {
/** Unique reminder ID */
id: string;
/** Notification title */
title: string;
/** Notification body */
body?: string;
/** Tag for deduplication (same tag replaces previous) */
tag: string;
}
export interface ReminderSource {
/** Source identifier (e.g. 'todo', 'calendar', 'planta') */
id: string;
/** Returns reminders that are currently due */
checkDue: () => Promise<DueReminder[]>;
/** Mark a reminder as sent (so it won't fire again) */
markSent: (reminderId: string) => Promise<void>;
}
export interface ReminderSchedulerConfig {
/** Check interval in ms (default: 30000 = 30s) */
intervalMs?: number;
/** Registered reminder sources */
sources: ReminderSource[];
/** Override notification service (for testing) */
notifier?: {
hasPermission(): boolean;
send(title: string, options?: { body?: string; tag?: string }): void;
};
}
export interface ReminderScheduler {
/** Start the polling loop */
start(): void;
/** Stop the polling loop */
stop(): void;
/** Manually check all sources now */
checkNow(): Promise<void>;
/** Add a source at runtime */
addSource(source: ReminderSource): void;
}
export function createReminderScheduler(config: ReminderSchedulerConfig): ReminderScheduler {
const intervalMs = config.intervalMs ?? 30_000;
const sources = [...config.sources];
const notifier = config.notifier ?? notificationService;
let timer: ReturnType<typeof setInterval> | null = null;
async function check() {
if (!notifier.hasPermission()) return;
for (const source of sources) {
try {
const due = await source.checkDue();
for (const reminder of due) {
notifier.send(reminder.title, {
body: reminder.body,
tag: reminder.tag,
});
await source.markSent(reminder.id);
}
} catch (e) {
console.error(`[ReminderScheduler] Error checking source "${source.id}":`, e);
}
}
}
return {
start() {
if (timer) return;
// Initial check after short delay (let app settle)
setTimeout(() => check(), 2000);
timer = setInterval(check, intervalMs);
},
stop() {
if (timer) {
clearInterval(timer);
timer = null;
}
},
async checkNow() {
await check();
},
addSource(source: ReminderSource) {
sources.push(source);
},
};
}