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