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>
This commit is contained in:
Till JS 2026-04-02 16:54:15 +02:00
parent b995d52146
commit 4fa096147c
11 changed files with 624 additions and 26 deletions

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { Bell } from '@manacore/shared-icons';
import { ReminderPicker } from '@manacore/shared-ui';
interface Props {
value: number | null;
@ -8,30 +8,6 @@
}
let { value, onChange, disabled = false }: Props = $props();
const options = [
{ label: 'Keine', value: null },
{ label: '5 Min', value: 5 },
{ label: '15 Min', value: 15 },
{ label: '30 Min', value: 30 },
{ label: '1 Std', value: 60 },
{ label: '1 Tag', value: 1440 },
];
</script>
<div class="flex items-center gap-1.5">
<Bell size={14} class="text-muted-foreground" />
<select
{disabled}
value={value ?? ''}
onchange={(e) => {
const v = e.currentTarget.value;
onChange(v === '' ? null : Number(v));
}}
class="rounded-md border border-border bg-transparent px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none disabled:opacity-40"
>
{#each options as opt}
<option value={opt.value ?? ''}>{opt.label}</option>
{/each}
</select>
</div>
<ReminderPicker {value} {onChange} {disabled} />

View file

@ -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<DueReminder[]> {
const reminders = await db.table<LocalReminder>('reminders').toArray();
const pending = reminders.filter((r) => r.status === 'pending' && !r.deletedAt);
if (pending.length === 0) return [];
const tasks = await db.table<LocalTask>('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<void> {
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)`;
}

View file

@ -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,

View file

@ -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<string, unknown>).Notification = MockNotification;
});
afterEach(() => {
(globalThis as Record<string, unknown>).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',
})
);
});
});
});

View file

@ -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<boolean> {
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();
};
}
},
};

View file

@ -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();
});
});

View file

@ -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<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);
},
};
}

View file

@ -15,6 +15,7 @@ export {
COLORS_16,
DEFAULT_COLOR,
getRandomColor,
ReminderPicker,
} from './molecules';
export type { SelectOption, FilterDropdownOption } from './molecules';

View file

@ -0,0 +1,68 @@
<script lang="ts">
import { Bell, BellSlash } from '@manacore/shared-icons';
/**
* Reusable reminder time picker dropdown.
* Lets user select "X minutes before" for reminders on tasks, events, etc.
*/
interface ReminderOption {
value: number | null;
label: string;
}
interface Props {
/** Selected value in minutes (null = no reminder) */
value: number | null;
/** Called when selection changes */
onChange: (minutes: number | null) => void;
/** Custom options (defaults to standard set) */
options?: ReminderOption[];
/** Disable the picker */
disabled?: boolean;
}
const DEFAULT_OPTIONS: ReminderOption[] = [
{ value: null, label: 'Keine Erinnerung' },
{ value: 5, label: '5 Minuten vorher' },
{ value: 15, label: '15 Minuten vorher' },
{ value: 30, label: '30 Minuten vorher' },
{ value: 60, label: '1 Stunde vorher' },
{ value: 1440, label: '1 Tag vorher' },
];
let { value, onChange, options = DEFAULT_OPTIONS, disabled = false }: Props = $props();
function handleChange(e: Event) {
const target = e.target as HTMLSelectElement;
const raw = target.value;
onChange(raw === '' ? null : parseInt(raw, 10));
}
const displayLabel = $derived(
options.find((o) => o.value === value)?.label ?? 'Keine Erinnerung'
);
const hasReminder = $derived(value !== null);
</script>
<div class="inline-flex items-center gap-1.5">
{#if hasReminder}
<Bell size={14} weight="fill" class="text-primary flex-shrink-0" />
{:else}
<BellSlash size={14} class="text-muted-foreground flex-shrink-0" />
{/if}
<select
class="appearance-none bg-transparent text-xs cursor-pointer
{hasReminder ? 'text-primary font-medium' : 'text-muted-foreground'}
focus:outline-none"
{disabled}
onchange={handleChange}
>
{#each options as option}
<option value={option.value ?? ''} selected={option.value === value}>
{option.label}
</option>
{/each}
</select>
</div>

View file

@ -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);
});
});

View file

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