From 4fa096147cbefc235ad4bbf5aa3a399de1a2d184 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 2 Apr 2026 16:54:15 +0200 Subject: [PATCH] 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) --- .../components/form/ReminderSelector.svelte | 28 +--- .../src/lib/modules/todo/reminder-source.ts | 56 +++++++ packages/shared-stores/src/index.ts | 8 + .../shared-stores/src/notifications.test.ts | 88 ++++++++++ packages/shared-stores/src/notifications.ts | 68 ++++++++ .../src/reminder-scheduler.test.ts | 158 ++++++++++++++++++ .../shared-stores/src/reminder-scheduler.ts | 117 +++++++++++++ packages/shared-ui/src/index.ts | 1 + .../src/molecules/ReminderPicker.svelte | 68 ++++++++ .../src/molecules/ReminderPicker.test.ts | 57 +++++++ packages/shared-ui/src/molecules/index.ts | 1 + 11 files changed, 624 insertions(+), 26 deletions(-) create mode 100644 apps/manacore/apps/web/src/lib/modules/todo/reminder-source.ts create mode 100644 packages/shared-stores/src/notifications.test.ts create mode 100644 packages/shared-stores/src/notifications.ts create mode 100644 packages/shared-stores/src/reminder-scheduler.test.ts create mode 100644 packages/shared-stores/src/reminder-scheduler.ts create mode 100644 packages/shared-ui/src/molecules/ReminderPicker.svelte create mode 100644 packages/shared-ui/src/molecules/ReminderPicker.test.ts diff --git a/apps/manacore/apps/web/src/lib/modules/todo/components/form/ReminderSelector.svelte b/apps/manacore/apps/web/src/lib/modules/todo/components/form/ReminderSelector.svelte index 8926b4443..b5f9b009e 100644 --- a/apps/manacore/apps/web/src/lib/modules/todo/components/form/ReminderSelector.svelte +++ b/apps/manacore/apps/web/src/lib/modules/todo/components/form/ReminderSelector.svelte @@ -1,5 +1,5 @@ -
- - -
+ diff --git a/apps/manacore/apps/web/src/lib/modules/todo/reminder-source.ts b/apps/manacore/apps/web/src/lib/modules/todo/reminder-source.ts new file mode 100644 index 000000000..65b67d1f1 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/todo/reminder-source.ts @@ -0,0 +1,56 @@ +/** + * Todo Reminder Source — provides due reminders to the shared scheduler. + * + * Checks all pending reminders against their task's dueDate. + * A reminder is due when: dueDate - minutesBefore <= now. + */ + +import { db } from '$lib/data/database'; +import type { ReminderSource, DueReminder } from '@manacore/shared-stores'; +import type { LocalTask, LocalReminder } from './types'; + +export const todoReminderSource: ReminderSource = { + id: 'todo', + + async checkDue(): Promise { + const reminders = await db.table('reminders').toArray(); + const pending = reminders.filter((r) => r.status === 'pending' && !r.deletedAt); + if (pending.length === 0) return []; + + const tasks = await db.table('tasks').toArray(); + const taskMap = new Map(tasks.map((t) => [t.id, t])); + const now = Date.now(); + const due: DueReminder[] = []; + + for (const r of pending) { + const task = taskMap.get(r.taskId); + if (!task?.dueDate || task.isCompleted) continue; + + const triggerAt = new Date(task.dueDate).getTime() - r.minutesBefore * 60_000; + if (triggerAt <= now) { + due.push({ + id: r.id, + title: task.title || 'Aufgabe fällig', + body: r.minutesBefore > 0 ? `In ${formatMinutes(r.minutesBefore)}` : 'Jetzt fällig', + tag: `todo-${r.id}`, + }); + } + } + + return due; + }, + + async markSent(reminderId: string): Promise { + await db.table('reminders').update(reminderId, { + status: 'sent', + updatedAt: new Date().toISOString(), + }); + }, +}; + +function formatMinutes(minutes: number): string { + if (minutes < 60) return `${minutes} Minuten`; + if (minutes === 60) return '1 Stunde'; + if (minutes < 1440) return `${Math.round(minutes / 60)} Stunden`; + return `${Math.round(minutes / 1440)} Tag(e)`; +} diff --git a/packages/shared-stores/src/index.ts b/packages/shared-stores/src/index.ts index f463c5e30..80d886f8a 100644 --- a/packages/shared-stores/src/index.ts +++ b/packages/shared-stores/src/index.ts @@ -56,6 +56,14 @@ export { type ArchiveOps, type ArchiveOpsConfig, } from './archive'; +export { notificationService, type NotificationOptions } from './notifications'; +export { + createReminderScheduler, + type ReminderScheduler, + type ReminderSchedulerConfig, + type ReminderSource, + type DueReminder, +} from './reminder-scheduler'; export { createGuestMode, diff --git a/packages/shared-stores/src/notifications.test.ts b/packages/shared-stores/src/notifications.test.ts new file mode 100644 index 000000000..e3b9ddbf0 --- /dev/null +++ b/packages/shared-stores/src/notifications.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { notificationService } from './notifications'; + +describe('notificationService', () => { + const originalNotification = globalThis.Notification; + + beforeEach(() => { + // Mock Notification constructor + const MockNotification = vi.fn() as unknown as typeof Notification; + Object.defineProperty(MockNotification, 'permission', { + get: () => 'granted', + configurable: true, + }); + MockNotification.requestPermission = vi.fn().mockResolvedValue('granted'); + (globalThis as Record).Notification = MockNotification; + }); + + afterEach(() => { + (globalThis as Record).Notification = originalNotification; + }); + + describe('isSupported', () => { + it('returns true when Notification is available', () => { + expect(notificationService.isSupported()).toBe(true); + }); + }); + + describe('hasPermission', () => { + it('returns true when permission is granted', () => { + expect(notificationService.hasPermission()).toBe(true); + }); + + it('returns false when permission is denied', () => { + Object.defineProperty(Notification, 'permission', { + get: () => 'denied', + configurable: true, + }); + expect(notificationService.hasPermission()).toBe(false); + }); + }); + + describe('requestPermission', () => { + it('returns true when permission granted', async () => { + const result = await notificationService.requestPermission(); + expect(result).toBe(true); + }); + + it('returns false when permission denied', async () => { + Object.defineProperty(Notification, 'permission', { + get: () => 'denied', + configurable: true, + }); + const result = await notificationService.requestPermission(); + expect(result).toBe(false); + }); + }); + + describe('send', () => { + it('creates a Notification with title and body', () => { + notificationService.send('Test Title', { body: 'Test Body' }); + expect(Notification).toHaveBeenCalledWith( + 'Test Title', + expect.objectContaining({ + body: 'Test Body', + }) + ); + }); + + it('does nothing without permission', () => { + Object.defineProperty(Notification, 'permission', { + get: () => 'denied', + configurable: true, + }); + notificationService.send('Test'); + expect(Notification).not.toHaveBeenCalled(); + }); + + it('passes tag for deduplication', () => { + notificationService.send('Test', { tag: 'my-tag' }); + expect(Notification).toHaveBeenCalledWith( + 'Test', + expect.objectContaining({ + tag: 'my-tag', + }) + ); + }); + }); +}); diff --git a/packages/shared-stores/src/notifications.ts b/packages/shared-stores/src/notifications.ts new file mode 100644 index 000000000..424df7296 --- /dev/null +++ b/packages/shared-stores/src/notifications.ts @@ -0,0 +1,68 @@ +/** + * Browser Notification Service + * + * Centralized wrapper for the Browser Notification API. + * Used by the reminder scheduler to fire local notifications. + * + * @example + * ```typescript + * import { notificationService } from '@manacore/shared-stores'; + * + * if (await notificationService.requestPermission()) { + * notificationService.send('Task fällig', { body: 'Einkaufen gehen' }); + * } + * ``` + */ + +export interface NotificationOptions { + /** Notification body text */ + body?: string; + /** Icon URL */ + icon?: string; + /** Tag for deduplication (same tag replaces previous notification) */ + tag?: string; + /** Called when user clicks the notification */ + onClick?: () => void; +} + +export const notificationService = { + /** Check if browser supports Notification API */ + isSupported(): boolean { + return typeof window !== 'undefined' && 'Notification' in window; + }, + + /** Check if permission is already granted */ + hasPermission(): boolean { + if (!this.isSupported()) return false; + return Notification.permission === 'granted'; + }, + + /** Request notification permission. Returns true if granted. */ + async requestPermission(): Promise { + if (!this.isSupported()) return false; + if (Notification.permission === 'granted') return true; + if (Notification.permission === 'denied') return false; + + const result = await Notification.requestPermission(); + return result === 'granted'; + }, + + /** Send a browser notification. No-op if permission not granted. */ + send(title: string, options?: NotificationOptions): void { + if (!this.hasPermission()) return; + + const notification = new Notification(title, { + body: options?.body, + icon: options?.icon ?? '/favicon.png', + tag: options?.tag, + }); + + if (options?.onClick) { + notification.onclick = () => { + window.focus(); + options.onClick!(); + notification.close(); + }; + } + }, +}; diff --git a/packages/shared-stores/src/reminder-scheduler.test.ts b/packages/shared-stores/src/reminder-scheduler.test.ts new file mode 100644 index 000000000..fdf32c4ef --- /dev/null +++ b/packages/shared-stores/src/reminder-scheduler.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createReminderScheduler } from './reminder-scheduler'; + +function createMockNotifier(hasPermission = true) { + return { + hasPermission: vi.fn(() => hasPermission), + send: vi.fn(), + }; +} + +describe('createReminderScheduler', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('calls checkDue on each source during checkNow', async () => { + const notifier = createMockNotifier(); + const source = { + id: 'test', + checkDue: vi.fn().mockResolvedValue([]), + markSent: vi.fn(), + }; + + const scheduler = createReminderScheduler({ sources: [source], notifier }); + await scheduler.checkNow(); + expect(source.checkDue).toHaveBeenCalledOnce(); + }); + + it('sends notification for due reminders', async () => { + const notifier = createMockNotifier(); + const source = { + id: 'test', + checkDue: vi + .fn() + .mockResolvedValue([ + { id: 'r1', title: 'Task fällig', body: 'In 5 Minuten', tag: 'test-r1' }, + ]), + markSent: vi.fn(), + }; + + const scheduler = createReminderScheduler({ sources: [source], notifier }); + await scheduler.checkNow(); + + expect(notifier.send).toHaveBeenCalledWith('Task fällig', { + body: 'In 5 Minuten', + tag: 'test-r1', + }); + }); + + it('calls markSent after sending notification', async () => { + const notifier = createMockNotifier(); + const source = { + id: 'test', + checkDue: vi.fn().mockResolvedValue([{ id: 'r1', title: 'Test', tag: 'test-r1' }]), + markSent: vi.fn(), + }; + + const scheduler = createReminderScheduler({ sources: [source], notifier }); + await scheduler.checkNow(); + + expect(source.markSent).toHaveBeenCalledWith('r1'); + }); + + it('skips check if no permission', async () => { + const notifier = createMockNotifier(false); + const source = { + id: 'test', + checkDue: vi.fn().mockResolvedValue([]), + markSent: vi.fn(), + }; + + const scheduler = createReminderScheduler({ sources: [source], notifier }); + await scheduler.checkNow(); + + expect(source.checkDue).not.toHaveBeenCalled(); + }); + + it('handles errors gracefully', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const notifier = createMockNotifier(); + const source = { + id: 'broken', + checkDue: vi.fn().mockRejectedValue(new Error('DB error')), + markSent: vi.fn(), + }; + + const scheduler = createReminderScheduler({ sources: [source], notifier }); + await scheduler.checkNow(); + + expect(consoleError).toHaveBeenCalledWith(expect.stringContaining('broken'), expect.any(Error)); + consoleError.mockRestore(); + }); + + it('checks multiple sources', async () => { + const notifier = createMockNotifier(); + const source1 = { + id: 'todo', + checkDue: vi.fn().mockResolvedValue([{ id: 'r1', title: 'Task', tag: 'todo-r1' }]), + markSent: vi.fn(), + }; + const source2 = { + id: 'calendar', + checkDue: vi.fn().mockResolvedValue([{ id: 'r2', title: 'Event', tag: 'cal-r2' }]), + markSent: vi.fn(), + }; + + const scheduler = createReminderScheduler({ sources: [source1, source2], notifier }); + await scheduler.checkNow(); + + expect(notifier.send).toHaveBeenCalledTimes(2); + expect(source1.markSent).toHaveBeenCalledWith('r1'); + expect(source2.markSent).toHaveBeenCalledWith('r2'); + }); + + it('addSource adds a new source at runtime', async () => { + const notifier = createMockNotifier(); + const scheduler = createReminderScheduler({ sources: [], notifier }); + const source = { + id: 'late', + checkDue: vi.fn().mockResolvedValue([]), + markSent: vi.fn(), + }; + + scheduler.addSource(source); + await scheduler.checkNow(); + expect(source.checkDue).toHaveBeenCalledOnce(); + }); + + it('start/stop controls the interval', async () => { + const notifier = createMockNotifier(); + const source = { + id: 'test', + checkDue: vi.fn().mockResolvedValue([]), + markSent: vi.fn(), + }; + + const scheduler = createReminderScheduler({ sources: [source], notifier, intervalMs: 1000 }); + scheduler.start(); + + // Initial delay check (2s) + await vi.advanceTimersByTimeAsync(2100); + expect(source.checkDue).toHaveBeenCalled(); + + // Interval check + source.checkDue.mockClear(); + await vi.advanceTimersByTimeAsync(1000); + expect(source.checkDue).toHaveBeenCalled(); + + scheduler.stop(); + source.checkDue.mockClear(); + await vi.advanceTimersByTimeAsync(5000); + expect(source.checkDue).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared-stores/src/reminder-scheduler.ts b/packages/shared-stores/src/reminder-scheduler.ts new file mode 100644 index 000000000..ceafe7b49 --- /dev/null +++ b/packages/shared-stores/src/reminder-scheduler.ts @@ -0,0 +1,117 @@ +/** + * 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; + /** Mark a reminder as sent (so it won't fire again) */ + markSent: (reminderId: string) => Promise; +} + +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; + /** 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 | 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); + }, + }; +} diff --git a/packages/shared-ui/src/index.ts b/packages/shared-ui/src/index.ts index f4b7ecbba..d6ed98e77 100644 --- a/packages/shared-ui/src/index.ts +++ b/packages/shared-ui/src/index.ts @@ -15,6 +15,7 @@ export { COLORS_16, DEFAULT_COLOR, getRandomColor, + ReminderPicker, } from './molecules'; export type { SelectOption, FilterDropdownOption } from './molecules'; diff --git a/packages/shared-ui/src/molecules/ReminderPicker.svelte b/packages/shared-ui/src/molecules/ReminderPicker.svelte new file mode 100644 index 000000000..aa82c6eb5 --- /dev/null +++ b/packages/shared-ui/src/molecules/ReminderPicker.svelte @@ -0,0 +1,68 @@ + + +
+ {#if hasReminder} + + {:else} + + {/if} + +
diff --git a/packages/shared-ui/src/molecules/ReminderPicker.test.ts b/packages/shared-ui/src/molecules/ReminderPicker.test.ts new file mode 100644 index 000000000..fa5ab926c --- /dev/null +++ b/packages/shared-ui/src/molecules/ReminderPicker.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, fireEvent } from '@testing-library/svelte'; +import ReminderPicker from './ReminderPicker.svelte'; + +describe('ReminderPicker', () => { + it('renders a select element', () => { + const { container } = render(ReminderPicker, { + props: { value: null, onChange: vi.fn() }, + }); + expect(container.querySelector('select')).toBeInTheDocument(); + }); + + it('renders default options', () => { + const { container } = render(ReminderPicker, { + props: { value: null, onChange: vi.fn() }, + }); + const options = container.querySelectorAll('option'); + expect(options.length).toBeGreaterThanOrEqual(5); + }); + + it('shows bell icon when reminder is set', () => { + const { container } = render(ReminderPicker, { + props: { value: 15, onChange: vi.fn() }, + }); + // Bell icon should be present (not BellSlash) + const svgs = container.querySelectorAll('svg'); + expect(svgs.length).toBeGreaterThan(0); + }); + + it('calls onChange when selection changes', async () => { + const onChange = vi.fn(); + const { container } = render(ReminderPicker, { + props: { value: null, onChange }, + }); + const select = container.querySelector('select')!; + await fireEvent.change(select, { target: { value: '30' } }); + expect(onChange).toHaveBeenCalledWith(30); + }); + + it('calls onChange with null for "no reminder"', async () => { + const onChange = vi.fn(); + const { container } = render(ReminderPicker, { + props: { value: 15, onChange }, + }); + const select = container.querySelector('select')!; + await fireEvent.change(select, { target: { value: '' } }); + expect(onChange).toHaveBeenCalledWith(null); + }); + + it('supports disabled state', () => { + const { container } = render(ReminderPicker, { + props: { value: null, onChange: vi.fn(), disabled: true }, + }); + const select = container.querySelector('select')!; + expect(select.disabled).toBe(true); + }); +}); diff --git a/packages/shared-ui/src/molecules/index.ts b/packages/shared-ui/src/molecules/index.ts index 15e36a774..578699a67 100644 --- a/packages/shared-ui/src/molecules/index.ts +++ b/packages/shared-ui/src/molecules/index.ts @@ -7,6 +7,7 @@ export { default as FilterDropdown } from './FilterDropdown.svelte'; export { default as FavoriteButton } from './FavoriteButton.svelte'; export { default as ColorPicker } from './ColorPicker.svelte'; export { COLORS_12, COLORS_16, DEFAULT_COLOR, getRandomColor } from './ColorPicker.constants'; +export { default as ReminderPicker } from './ReminderPicker.svelte'; export type { SelectOption } from './Select.types'; export type { FilterDropdownOption } from './FilterDropdown.types';