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';