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

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