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