mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 07:49:41 +02:00
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:
parent
b995d52146
commit
4fa096147c
11 changed files with 624 additions and 26 deletions
|
|
@ -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,
|
||||
|
|
|
|||
88
packages/shared-stores/src/notifications.test.ts
Normal file
88
packages/shared-stores/src/notifications.test.ts
Normal 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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
68
packages/shared-stores/src/notifications.ts
Normal file
68
packages/shared-stores/src/notifications.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
158
packages/shared-stores/src/reminder-scheduler.test.ts
Normal file
158
packages/shared-stores/src/reminder-scheduler.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
117
packages/shared-stores/src/reminder-scheduler.ts
Normal file
117
packages/shared-stores/src/reminder-scheduler.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue