feat(calendar): add event notifications with alert evaluation

Add client-side calendar event notification system that evaluates JMAP
CalendarEventAlert triggers and displays toast notifications when alert
times are reached. Includes configurable notification sound, acknowledged
alert persistence, and proactive event fetching for background alerts.
Also mounts ToastContainer globally to fix silent toast failures.
This commit is contained in:
Matthieu MALVACHE
2026-02-16 23:49:35 +01:00
committed by Matthieu MALVACHE
parent 73612313c0
commit fea21d5bcd
24 changed files with 949 additions and 47 deletions
+7
View File
@@ -0,0 +1,7 @@
# Check for AI attribution in commit message
if grep -qi "co-authored-by.*claude\|co-authored-by.*anthropic\|claude code\|claude sonnet\|claude opus" "$1"; then
echo "❌ ERROR: Commit message contains AI attribution (Claude/Anthropic)"
echo " This violates project policy in CLAUDE.md"
echo " Remove 'Co-Authored-By: Claude' and similar references"
exit 1
fi
+2
View File
@@ -68,6 +68,8 @@ This webmail client is designed to work seamlessly with [**Stalwart Mail Server*
- Drag-and-drop rescheduling (week/day time snap, month date move)
- iCalendar (.ics) file import with event preview and bulk create
- Real-time updates via JMAP push notifications
- Event notifications with client-side alert evaluation and toast display
- Configurable notification sound and enable/disable toggles
- Keyboard shortcuts: m/w/d/a (views), t (today), n (new event), arrows (navigate)
### Email Filters
+5 -1
View File
@@ -143,6 +143,9 @@ This document tracks the development status and planned features for JMAP Webmai
- [x] i18n support with ICU pluralization (all 8 languages)
- [x] Drag-and-drop event rescheduling (week/day time snap, month date move)
- [x] iCalendar (.ics) file import via CalendarEvent/parse with preview and bulk create
- [x] Event notifications with client-side alert evaluation and toast display
- [x] Notification sound, acknowledged alert persistence (localStorage), proactive 24h event fetch
- [x] Configurable notification settings (enable/disable, sound toggle)
### Email Filters
- [x] JMAP Sieve Scripts (RFC 9661) with capability detection
@@ -173,6 +176,8 @@ This document tracks the development status and planned features for JMAP Webmai
- [x] JMAP client method tests (identity: 20, contacts: 41)
- [x] Unit tests for Sieve generator (50 tests)
- [x] Unit tests for Sieve parser (14 tests)
- [x] Unit tests for calendar alerts (36 tests)
- [x] Unit tests for calendar notification store (8 tests)
- [x] XSS attack vector testing
- [x] Playwright E2E framework setup
@@ -188,7 +193,6 @@ This document tracks the development status and planned features for JMAP Webmai
- [ ] Participant scheduling with iTIP invitations
- [ ] Free/busy queries (Principal/getAvailability)
- [ ] Calendar sharing UI (JMAP Sharing RFC 9670)
- [ ] Calendar event notifications display
- [ ] Email templates
- [ ] Email encryption (PGP/GPG)
- [ ] OAuth2/OIDC authentication (opt-in, Basic Auth remains default)
+4 -1
View File
@@ -1,6 +1,7 @@
import { notFound } from "next/navigation";
import { IntlProvider } from "@/components/providers/intl-provider";
import { ThemeProvider } from "@/components/providers/theme-provider";
import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider";
import { locales } from "@/i18n/routing";
export default async function LocaleLayout({
@@ -24,7 +25,9 @@ export default async function LocaleLayout({
return (
<IntlProvider locale={locale} messages={messages}>
<ThemeProvider>
{children}
<CalendarAlertProvider>
{children}
</CalendarAlertProvider>
</ThemeProvider>
</IntlProvider>
);
+1 -22
View File
@@ -18,6 +18,7 @@ import { useUIStore } from "@/stores/ui-store";
import { useDeviceDetection } from "@/hooks/use-media-query";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { debug } from "@/lib/debug";
import { playNotificationSound } from "@/lib/notification-sound";
import { cn } from "@/lib/utils";
import {
ErrorBoundary,
@@ -87,28 +88,6 @@ export default function Home() {
advancedSearch,
} = useEmailStore();
// Play notification sound for new emails
const playNotificationSound = () => {
try {
// Use Web Audio API for a simple notification beep
const audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = 800; // Hz
oscillator.type = 'sine';
gainNode.gain.value = 0.1; // Low volume
oscillator.start();
oscillator.stop(audioContext.currentTime + 0.15); // Short beep
} catch (e) {
debug.log('Could not play notification sound:', e);
}
};
// Keyboard shortcuts handlers
const keyboardHandlers = useMemo(() => ({
onNextEmail: () => {
@@ -0,0 +1,19 @@
"use client";
import { useCalendarAlerts } from '@/hooks/use-calendar-alerts';
import { ToastContainer } from '@/components/ui/toast';
import { useToastStore } from '@/stores/toast-store';
export function CalendarAlertProvider({ children }: { children: React.ReactNode }) {
useCalendarAlerts();
const toasts = useToastStore((s) => s.toasts);
const removeToast = useToastStore((s) => s.removeToast);
return (
<>
{children}
<ToastContainer toasts={toasts} onClose={removeToast} />
</>
);
}
+23 -2
View File
@@ -3,7 +3,7 @@
import { useTranslations } from 'next-intl';
import { useCalendarStore, CalendarViewMode } from '@/stores/calendar-store';
import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, Select, RadioGroup } from './settings-section';
import { SettingsSection, SettingItem, Select, RadioGroup, ToggleSwitch } from './settings-section';
export function CalendarSettings() {
const t = useTranslations('calendar.settings');
@@ -11,7 +11,7 @@ export function CalendarSettings() {
const tDays = useTranslations('calendar.days');
const { viewMode, setViewMode } = useCalendarStore();
const { timeFormat, firstDayOfWeek, updateSetting } = useSettingsStore();
const { timeFormat, firstDayOfWeek, calendarNotificationsEnabled, calendarNotificationSound, updateSetting } = useSettingsStore();
return (
<SettingsSection title={t('title')}>
@@ -50,6 +50,27 @@ export function CalendarSettings() {
/>
</SettingItem>
<SettingItem
label={t('notifications_enabled')}
description={t('notifications_enabled_desc')}
>
<ToggleSwitch
checked={calendarNotificationsEnabled}
onChange={(checked) => updateSetting('calendarNotificationsEnabled', checked)}
/>
</SettingItem>
<SettingItem
label={t('notification_sound')}
description={t('notification_sound_desc')}
>
<ToggleSwitch
checked={calendarNotificationSound}
onChange={(checked) => updateSetting('calendarNotificationSound', checked)}
disabled={!calendarNotificationsEnabled}
/>
</SettingItem>
</SettingsSection>
);
}
+2 -1
View File
@@ -13,6 +13,7 @@ export interface Toast {
message?: string;
duration?: number;
onClick?: () => void;
icon?: React.ReactNode;
}
interface ToastProps {
@@ -60,7 +61,7 @@ export function ToastItem({ toast, onClose }: ToastProps) {
}
}}
>
<Icon className="w-5 h-5 flex-shrink-0 mt-0.5" />
{toast.icon !== undefined ? toast.icon : <Icon className="w-5 h-5 flex-shrink-0 mt-0.5" />}
<div className="flex-1">
<h4 className="font-medium">{toast.title}</h4>
{toast.message && (
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { useEffect, useRef, useCallback } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { useAuthStore } from '@/stores/auth-store';
import { useCalendarStore } from '@/stores/calendar-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useCalendarNotificationStore } from '@/stores/calendar-notification-store';
import { useToastStore } from '@/stores/toast-store';
import { getPendingAlerts, buildAlertKey } from '@/lib/calendar-alerts';
import { playNotificationSound } from '@/lib/notification-sound';
import type { CalendarEvent } from '@/lib/jmap/types';
const CHECK_INTERVAL_MS = 60 * 1000;
const PROACTIVE_FETCH_HOURS = 24;
const PROACTIVE_THROTTLE_MS = CHECK_INTERVAL_MS * 5;
export function useCalendarAlerts() {
const { isAuthenticated, client } = useAuthStore();
const { events, calendars, supportsCalendar } = useCalendarStore();
const { calendarNotificationsEnabled, calendarNotificationSound } = useSettingsStore();
const { acknowledgedAlerts, acknowledgeAlert, cleanupStaleAlerts } = useCalendarNotificationStore();
const addToast = useToastStore((s) => s.addToast);
const t = useTranslations('calendar.notifications');
const locale = useLocale();
const lastProactiveFetchRef = useRef<number>(0);
const proactiveEventsRef = useRef<CalendarEvent[]>([]);
const shownKeysRef = useRef<Set<string>>(new Set());
const checkAlerts = useCallback(() => {
if (!calendarNotificationsEnabled || !isAuthenticated) return;
try {
const now = Date.now();
const acknowledgedKeys = new Set(Object.keys(acknowledgedAlerts));
const allEvents = [...events, ...proactiveEventsRef.current];
const pending = getPendingAlerts(allEvents, calendars, acknowledgedKeys, now);
for (const alert of pending) {
const key = buildAlertKey(alert.eventId, alert.alertId, alert.fireTimeMs);
if (shownKeysRef.current.has(key)) continue;
shownKeysRef.current.add(key);
acknowledgeAlert(key, alert.fireTimeMs);
if (calendarNotificationSound) {
playNotificationSound();
}
const diffMs = new Date(alert.event.utcStart || alert.event.start).getTime() - now;
const diffMin = Math.round(diffMs / 60000);
const timeLabel = diffMin <= 0
? t('alert_now')
: t('alert_in_minutes', { count: diffMin });
const message = alert.calendarName
? `${timeLabel} · ${alert.calendarName}`
: timeLabel;
addToast({
type: 'info',
title: alert.event.title || t('alert_title'),
message,
duration: 15000,
onClick: () => {
window.location.href = `/${locale}/calendar`;
},
});
}
} catch {
// Silently ignore alert evaluation errors
}
}, [
calendarNotificationsEnabled, calendarNotificationSound,
isAuthenticated, events, calendars, acknowledgedAlerts,
acknowledgeAlert, addToast, t, locale,
]);
const proactiveFetch = useCallback(async () => {
if (!client || !supportsCalendar || !calendarNotificationsEnabled || !isAuthenticated) return;
const now = Date.now();
if (now - lastProactiveFetchRef.current < PROACTIVE_THROTTLE_MS) return;
try {
const start = new Date(now - 10 * 60 * 1000).toISOString();
const end = new Date(now + PROACTIVE_FETCH_HOURS * 60 * 60 * 1000).toISOString();
const fetched = await client.queryCalendarEvents({ after: start, before: end });
proactiveEventsRef.current = fetched;
lastProactiveFetchRef.current = Date.now();
} catch {
// Silently ignore proactive fetch errors
}
}, [client, supportsCalendar, calendarNotificationsEnabled, isAuthenticated]);
useEffect(() => {
if (!calendarNotificationsEnabled || !isAuthenticated) return;
cleanupStaleAlerts();
proactiveFetch();
const timer = setTimeout(() => checkAlerts(), 500);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated, calendarNotificationsEnabled]);
useEffect(() => {
if (!calendarNotificationsEnabled || !isAuthenticated) return;
const interval = setInterval(() => {
proactiveFetch();
checkAlerts();
}, CHECK_INTERVAL_MS);
return () => clearInterval(interval);
}, [calendarNotificationsEnabled, isAuthenticated, checkAlerts, proactiveFetch]);
useEffect(() => {
if (!calendarNotificationsEnabled || !isAuthenticated) return;
checkAlerts();
}, [events, calendarNotificationsEnabled, isAuthenticated, checkAlerts]);
}
+409
View File
@@ -0,0 +1,409 @@
import { describe, it, expect } from 'vitest';
import {
parseAlertOffset,
computeFireTime,
getEffectiveAlerts,
buildAlertKey,
getPendingAlerts,
} from '../calendar-alerts';
import type {
CalendarEvent,
CalendarEventAlert,
Calendar,
} from '@/lib/jmap/types';
function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
return {
id: 'evt-1',
calendarIds: { 'cal-1': true },
isDraft: false,
isOrigin: true,
utcStart: '2026-03-01T10:00:00Z',
utcEnd: '2026-03-01T11:00:00Z',
'@type': 'Event',
uid: 'uid-1',
title: 'Test Event',
description: '',
descriptionContentType: 'text/plain',
created: null,
updated: '2026-03-01T09:00:00Z',
sequence: 0,
start: '2026-03-01T10:00:00',
duration: 'PT1H',
timeZone: 'UTC',
showWithoutTime: false,
status: 'confirmed',
freeBusyStatus: 'busy',
privacy: 'public',
color: null,
keywords: null,
categories: null,
locale: null,
replyTo: null,
participants: null,
mayInviteSelf: false,
mayInviteOthers: false,
hideAttendees: false,
recurrenceId: null,
recurrenceIdTimeZone: null,
recurrenceRules: null,
recurrenceOverrides: null,
excludedRecurrenceRules: null,
useDefaultAlerts: false,
alerts: null,
locations: null,
virtualLocations: null,
links: null,
relatedTo: null,
...overrides,
};
}
function makeCalendar(overrides: Partial<Calendar> = {}): Calendar {
return {
id: 'cal-1',
name: 'Work',
description: null,
color: '#0000ff',
sortOrder: 1,
isSubscribed: true,
isVisible: true,
isDefault: true,
includeInAvailability: 'all',
defaultAlertsWithTime: null,
defaultAlertsWithoutTime: null,
timeZone: null,
shareWith: null,
myRights: {
mayReadFreeBusy: true,
mayReadItems: true,
mayWriteAll: true,
mayWriteOwn: true,
mayUpdatePrivate: true,
mayRSVP: true,
mayAdmin: false,
mayDelete: false,
},
...overrides,
};
}
function makeAlert(overrides: Partial<CalendarEventAlert> = {}): CalendarEventAlert {
return {
'@type': 'Alert',
trigger: {
'@type': 'OffsetTrigger',
offset: '-PT5M',
relativeTo: 'start',
},
action: 'display',
acknowledged: null,
relatedTo: null,
...overrides,
};
}
describe('parseAlertOffset', () => {
it('parses negative minutes', () => {
expect(parseAlertOffset('-PT5M')).toBe(-5 * 60 * 1000);
});
it('parses positive minutes', () => {
expect(parseAlertOffset('PT10M')).toBe(10 * 60 * 1000);
});
it('parses zero duration', () => {
expect(parseAlertOffset('PT0S')).toBe(0);
});
it('parses hours', () => {
expect(parseAlertOffset('-PT1H')).toBe(-60 * 60 * 1000);
});
it('parses days', () => {
expect(parseAlertOffset('-P1D')).toBe(-24 * 60 * 60 * 1000);
});
it('parses complex duration', () => {
expect(parseAlertOffset('-P1DT2H30M')).toBe(-(24 * 60 * 60 + 2 * 60 * 60 + 30 * 60) * 1000);
});
it('returns null for invalid format', () => {
expect(parseAlertOffset('invalid')).toBeNull();
});
it('parses positive day duration', () => {
expect(parseAlertOffset('P2D')).toBe(2 * 24 * 60 * 60 * 1000);
});
it('parses seconds only', () => {
expect(parseAlertOffset('PT30S')).toBe(30 * 1000);
});
});
describe('computeFireTime', () => {
it('computes offset from utcStart', () => {
const event = makeEvent({ utcStart: '2026-03-01T10:00:00Z' });
const trigger = { '@type': 'OffsetTrigger' as const, offset: '-PT5M', relativeTo: 'start' as const };
const expected = new Date('2026-03-01T10:00:00Z').getTime() - 5 * 60 * 1000;
expect(computeFireTime(event, trigger)).toBe(expected);
});
it('falls back to start when utcStart is null', () => {
const event = makeEvent({ utcStart: null, start: '2026-03-01T10:00:00' });
const trigger = { '@type': 'OffsetTrigger' as const, offset: '-PT10M', relativeTo: 'start' as const };
const expected = new Date('2026-03-01T10:00:00').getTime() - 10 * 60 * 1000;
expect(computeFireTime(event, trigger)).toBe(expected);
});
it('handles absolute trigger', () => {
const event = makeEvent();
const trigger = { '@type': 'AbsoluteTrigger' as const, when: '2026-03-01T09:55:00Z' };
expect(computeFireTime(event, trigger)).toBe(new Date('2026-03-01T09:55:00Z').getTime());
});
it('handles zero offset (at time of event)', () => {
const event = makeEvent({ utcStart: '2026-03-01T10:00:00Z' });
const trigger = { '@type': 'OffsetTrigger' as const, offset: 'PT0S', relativeTo: 'start' as const };
expect(computeFireTime(event, trigger)).toBe(new Date('2026-03-01T10:00:00Z').getTime());
});
it('handles positive offset (after start)', () => {
const event = makeEvent({ utcStart: '2026-03-01T10:00:00Z' });
const trigger = { '@type': 'OffsetTrigger' as const, offset: 'PT15M', relativeTo: 'start' as const };
const expected = new Date('2026-03-01T10:00:00Z').getTime() + 15 * 60 * 1000;
expect(computeFireTime(event, trigger)).toBe(expected);
});
it('computes offset from utcEnd when relativeTo is end', () => {
const event = makeEvent({ utcStart: '2026-03-01T10:00:00Z', utcEnd: '2026-03-01T11:00:00Z' });
const trigger = { '@type': 'OffsetTrigger' as const, offset: 'PT5M', relativeTo: 'end' as const };
const expected = new Date('2026-03-01T11:00:00Z').getTime() + 5 * 60 * 1000;
expect(computeFireTime(event, trigger)).toBe(expected);
});
it('returns null for invalid offset', () => {
const event = makeEvent();
const trigger = { '@type': 'OffsetTrigger' as const, offset: 'garbage', relativeTo: 'start' as const };
expect(computeFireTime(event, trigger)).toBeNull();
});
it('returns null for invalid absolute trigger date', () => {
const event = makeEvent();
const trigger = { '@type': 'AbsoluteTrigger' as const, when: 'not-a-date' };
expect(computeFireTime(event, trigger)).toBeNull();
});
});
describe('getEffectiveAlerts', () => {
it('returns event alerts when useDefaultAlerts is false', () => {
const alerts = { 'a1': makeAlert() };
const event = makeEvent({ useDefaultAlerts: false, alerts });
const calendars = [makeCalendar()];
expect(getEffectiveAlerts(event, calendars)).toBe(alerts);
});
it('returns null when event has no alerts and useDefaultAlerts is false', () => {
const event = makeEvent({ useDefaultAlerts: false, alerts: null });
const calendars = [makeCalendar()];
expect(getEffectiveAlerts(event, calendars)).toBeNull();
});
it('returns calendar defaultAlertsWithTime for timed events', () => {
const defaultAlerts = { 'd1': makeAlert() };
const event = makeEvent({ useDefaultAlerts: true, showWithoutTime: false });
const calendars = [makeCalendar({ defaultAlertsWithTime: defaultAlerts })];
expect(getEffectiveAlerts(event, calendars)).toBe(defaultAlerts);
});
it('returns calendar defaultAlertsWithoutTime for all-day events', () => {
const defaultAlerts = { 'd1': makeAlert() };
const event = makeEvent({ useDefaultAlerts: true, showWithoutTime: true });
const calendars = [makeCalendar({ defaultAlertsWithoutTime: defaultAlerts })];
expect(getEffectiveAlerts(event, calendars)).toBe(defaultAlerts);
});
it('returns null when calendar not found', () => {
const event = makeEvent({ useDefaultAlerts: true, calendarIds: { 'missing': true } });
const calendars = [makeCalendar()];
expect(getEffectiveAlerts(event, calendars)).toBeNull();
});
it('returns null when calendarIds is empty', () => {
const event = makeEvent({ useDefaultAlerts: true, calendarIds: {} });
const calendars = [makeCalendar()];
expect(getEffectiveAlerts(event, calendars)).toBeNull();
});
});
describe('buildAlertKey', () => {
it('builds deterministic key', () => {
const key = buildAlertKey('evt-1', 'alert-1', 12345);
expect(key).toBe('evt-1:alert-1:12345');
});
});
describe('getPendingAlerts', () => {
const eventStart = new Date('2026-03-01T10:00:00Z').getTime();
const fiveMinBefore = eventStart - 5 * 60 * 1000;
it('returns pending display alerts', () => {
const event = makeEvent({
alerts: { 'a1': makeAlert() },
});
const calendars = [makeCalendar()];
const now = fiveMinBefore + 1000;
const result = getPendingAlerts([event], calendars, new Set(), now);
expect(result).toHaveLength(1);
expect(result[0].eventId).toBe('evt-1');
expect(result[0].alertId).toBe('a1');
expect(result[0].calendarName).toBe('Work');
});
it('skips alerts not yet due', () => {
const event = makeEvent({
alerts: { 'a1': makeAlert() },
});
const calendars = [makeCalendar()];
const now = fiveMinBefore - 10000;
const result = getPendingAlerts([event], calendars, new Set(), now);
expect(result).toHaveLength(0);
});
it('skips stale alerts older than 10 minutes', () => {
const event = makeEvent({
alerts: { 'a1': makeAlert() },
});
const calendars = [makeCalendar()];
const now = fiveMinBefore + 11 * 60 * 1000;
const result = getPendingAlerts([event], calendars, new Set(), now);
expect(result).toHaveLength(0);
});
it('skips alerts at exactly the stale threshold', () => {
const event = makeEvent({
alerts: { 'a1': makeAlert() },
});
const calendars = [makeCalendar()];
const now = fiveMinBefore + 10 * 60 * 1000;
const result = getPendingAlerts([event], calendars, new Set(), now);
expect(result).toHaveLength(0);
});
it('skips acknowledged alerts', () => {
const event = makeEvent({
alerts: { 'a1': makeAlert() },
});
const calendars = [makeCalendar()];
const now = fiveMinBefore + 1000;
const key = buildAlertKey('evt-1', 'a1', fiveMinBefore);
const result = getPendingAlerts([event], calendars, new Set([key]), now);
expect(result).toHaveLength(0);
});
it('skips email action alerts', () => {
const event = makeEvent({
alerts: { 'a1': makeAlert({ action: 'email' }) },
});
const calendars = [makeCalendar()];
const now = fiveMinBefore + 1000;
const result = getPendingAlerts([event], calendars, new Set(), now);
expect(result).toHaveLength(0);
});
it('skips server-acknowledged alerts', () => {
const event = makeEvent({
alerts: { 'a1': makeAlert({ acknowledged: '2026-03-01T09:55:00Z' }) },
});
const calendars = [makeCalendar()];
const now = fiveMinBefore + 1000;
const result = getPendingAlerts([event], calendars, new Set(), now);
expect(result).toHaveLength(0);
});
it('resolves useDefaultAlerts from calendar defaults', () => {
const defaultAlerts = {
'd1': makeAlert({
trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' },
}),
};
const event = makeEvent({
useDefaultAlerts: true,
alerts: null,
});
const calendars = [makeCalendar({ defaultAlertsWithTime: defaultAlerts })];
const fifteenMinBefore = eventStart - 15 * 60 * 1000;
const now = fifteenMinBefore + 1000;
const result = getPendingAlerts([event], calendars, new Set(), now);
expect(result).toHaveLength(1);
expect(result[0].alertId).toBe('d1');
});
it('returns multiple alerts from same event independently', () => {
const tenMinBefore = eventStart - 10 * 60 * 1000;
const event = makeEvent({
alerts: {
'a1': makeAlert({ trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' } }),
'a2': makeAlert({ trigger: { '@type': 'OffsetTrigger', offset: '-PT10M', relativeTo: 'start' } }),
},
});
const calendars = [makeCalendar()];
const now = tenMinBefore + 1000;
const result = getPendingAlerts([event], calendars, new Set(), now);
expect(result).toHaveLength(1);
expect(result[0].alertId).toBe('a2');
const nowLater = fiveMinBefore + 1000;
const result2 = getPendingAlerts([event], calendars, new Set(), nowLater);
expect(result2).toHaveLength(2);
const alertIds = result2.map(r => r.alertId).sort();
expect(alertIds).toEqual(['a1', 'a2']);
});
it('handles multiple events with mixed alert states', () => {
const evt1 = makeEvent({
id: 'evt-1',
alerts: { 'a1': makeAlert() },
});
const evt2 = makeEvent({
id: 'evt-2',
alerts: { 'a1': makeAlert({ acknowledged: '2026-03-01T09:55:00Z' }) },
});
const evt3 = makeEvent({
id: 'evt-3',
alerts: { 'a1': makeAlert() },
});
const calendars = [makeCalendar()];
const now = fiveMinBefore + 1000;
const key1 = buildAlertKey('evt-1', 'a1', fiveMinBefore);
const result = getPendingAlerts([evt1, evt2, evt3], calendars, new Set([key1]), now);
expect(result).toHaveLength(1);
expect(result[0].eventId).toBe('evt-3');
});
it('skips alerts with unparseable offsets', () => {
const event = makeEvent({
alerts: {
'a1': makeAlert({
trigger: { '@type': 'OffsetTrigger', offset: 'invalid', relativeTo: 'start' },
}),
},
});
const calendars = [makeCalendar()];
const now = fiveMinBefore + 1000;
const result = getPendingAlerts([event], calendars, new Set(), now);
expect(result).toHaveLength(0);
});
it('returns calendarName as null when calendar not found', () => {
const event = makeEvent({
calendarIds: { 'missing': true },
alerts: { 'a1': makeAlert() },
});
const calendars = [makeCalendar()];
const now = fiveMinBefore + 1000;
const result = getPendingAlerts([event], calendars, new Set(), now);
expect(result).toHaveLength(1);
expect(result[0].calendarName).toBeNull();
});
});
+123
View File
@@ -0,0 +1,123 @@
import type {
CalendarEvent,
CalendarEventAlert,
CalendarOffsetTrigger,
CalendarAbsoluteTrigger,
Calendar,
} from '@/lib/jmap/types';
export interface PendingAlert {
eventId: string;
alertId: string;
fireTimeMs: number;
event: CalendarEvent;
calendarName: string | null;
}
const STALE_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
const DURATION_RE = /^(-?)P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
export function parseAlertOffset(offset: string): number | null {
const match = DURATION_RE.exec(offset);
if (!match) return null;
const negative = match[1] === '-';
const days = parseInt(match[2] || '0', 10);
const hours = parseInt(match[3] || '0', 10);
const minutes = parseInt(match[4] || '0', 10);
const seconds = parseInt(match[5] || '0', 10);
const ms = ((days * 24 * 60 * 60) + (hours * 60 * 60) + (minutes * 60) + seconds) * 1000;
return negative ? -ms : ms;
}
export function computeFireTime(
event: CalendarEvent,
trigger: CalendarOffsetTrigger | CalendarAbsoluteTrigger
): number | null {
if (trigger['@type'] === 'AbsoluteTrigger') {
const t = new Date(trigger.when).getTime();
return Number.isNaN(t) ? null : t;
}
const offsetMs = parseAlertOffset(trigger.offset);
if (offsetMs === null) return null;
let baseTime: number;
if (trigger.relativeTo === 'end') {
baseTime = event.utcEnd
? new Date(event.utcEnd).getTime()
: new Date(event.start).getTime();
} else {
baseTime = event.utcStart
? new Date(event.utcStart).getTime()
: new Date(event.start).getTime();
}
if (Number.isNaN(baseTime)) return null;
return baseTime + offsetMs;
}
export function getEffectiveAlerts(
event: CalendarEvent,
calendars: Calendar[]
): Record<string, CalendarEventAlert> | null {
if (!event.useDefaultAlerts) {
return event.alerts;
}
const calendarId = Object.keys(event.calendarIds)[0];
if (!calendarId) return null;
const calendar = calendars.find(c => c.id === calendarId);
if (!calendar) return null;
if (event.showWithoutTime) {
return calendar.defaultAlertsWithoutTime;
}
return calendar.defaultAlertsWithTime;
}
export function buildAlertKey(eventId: string, alertId: string, fireTimeMs: number): string {
return `${eventId}:${alertId}:${fireTimeMs}`;
}
export function getPendingAlerts(
events: CalendarEvent[],
calendars: Calendar[],
acknowledgedKeys: Set<string>,
now: number
): PendingAlert[] {
const pending: PendingAlert[] = [];
for (const event of events) {
const alerts = getEffectiveAlerts(event, calendars);
if (!alerts) continue;
const calendar = calendars.find(c => c.id === Object.keys(event.calendarIds)[0]) ?? null;
for (const [alertId, alert] of Object.entries(alerts)) {
if (alert.action !== 'display') continue;
if (alert.acknowledged) continue;
const fireTimeMs = computeFireTime(event, alert.trigger);
if (fireTimeMs === null) continue;
if (fireTimeMs > now) continue;
if (fireTimeMs <= now - STALE_THRESHOLD_MS) continue;
const key = buildAlertKey(event.id, alertId, fireTimeMs);
if (acknowledgedKeys.has(key)) continue;
pending.push({
eventId: event.id,
alertId,
fireTimeMs,
event,
calendarName: calendar?.name ?? null,
});
}
}
return pending;
}
+22
View File
@@ -0,0 +1,22 @@
import { debug } from '@/lib/debug';
export function playNotificationSound() {
try {
const audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = 800;
oscillator.type = 'sine';
gainNode.gain.value = 0.1;
oscillator.start();
oscillator.stop(audioContext.currentTime + 0.15);
oscillator.onended = () => audioContext.close();
} catch (e) {
debug.log('Could not play notification sound:', e);
}
}
+9 -2
View File
@@ -1097,7 +1097,11 @@
"default_calendar": "Standardkalender",
"default_reminder": "Standarderinnerung",
"time_format_12h": "12-Stunden",
"time_format_24h": "24-Stunden"
"time_format_24h": "24-Stunden",
"notifications_enabled": "Ereignisbenachrichtigungen",
"notifications_enabled_desc": "Benachrichtigungen für bevorstehende Termine anzeigen",
"notification_sound": "Benachrichtigungston",
"notification_sound_desc": "Ton für Kalenderbenachrichtigungen abspielen"
},
"days": {
"monday": "Montag",
@@ -1121,7 +1125,10 @@
"event_deleted": "Termin gelöscht",
"calendar_created": "Kalender erstellt",
"calendar_deleted": "Kalender gelöscht",
"event_move_error": "Termin konnte nicht verschoben werden"
"event_move_error": "Termin konnte nicht verschoben werden",
"alert_title": "Bevorstehender Termin",
"alert_now": "Beginnt jetzt",
"alert_in_minutes": "In {count} Min."
},
"status": {
"loading_calendars": "Kalender werden geladen...",
+9 -2
View File
@@ -1097,7 +1097,11 @@
"default_calendar": "Default calendar",
"default_reminder": "Default reminder",
"time_format_12h": "12-hour",
"time_format_24h": "24-hour"
"time_format_24h": "24-hour",
"notifications_enabled": "Event notifications",
"notifications_enabled_desc": "Show alerts for upcoming calendar events",
"notification_sound": "Notification sound",
"notification_sound_desc": "Play a sound for calendar alerts"
},
"days": {
"monday": "Monday",
@@ -1121,7 +1125,10 @@
"event_deleted": "Event deleted",
"calendar_created": "Calendar created",
"calendar_deleted": "Calendar deleted",
"event_move_error": "Failed to move event"
"event_move_error": "Failed to move event",
"alert_title": "Upcoming event",
"alert_now": "Starting now",
"alert_in_minutes": "In {count} min"
},
"status": {
"loading_calendars": "Loading calendars...",
+9 -2
View File
@@ -1097,7 +1097,11 @@
"default_calendar": "Calendario predeterminado",
"default_reminder": "Recordatorio predeterminado",
"time_format_12h": "12 horas",
"time_format_24h": "24 horas"
"time_format_24h": "24 horas",
"notifications_enabled": "Notificaciones de eventos",
"notifications_enabled_desc": "Mostrar alertas para eventos próximos",
"notification_sound": "Sonido de notificación",
"notification_sound_desc": "Reproducir un sonido para las alertas del calendario"
},
"days": {
"monday": "Lunes",
@@ -1121,7 +1125,10 @@
"event_deleted": "Evento eliminado",
"calendar_created": "Calendario creado",
"calendar_deleted": "Calendario eliminado",
"event_move_error": "Error al mover el evento"
"event_move_error": "Error al mover el evento",
"alert_title": "Evento próximo",
"alert_now": "Comienza ahora",
"alert_in_minutes": "En {count} min"
},
"status": {
"loading_calendars": "Cargando calendarios...",
+9 -2
View File
@@ -1097,7 +1097,11 @@
"default_calendar": "Calendrier par défaut",
"default_reminder": "Rappel par défaut",
"time_format_12h": "12 heures",
"time_format_24h": "24 heures"
"time_format_24h": "24 heures",
"notifications_enabled": "Notifications d'événements",
"notifications_enabled_desc": "Afficher les alertes pour les événements à venir",
"notification_sound": "Son de notification",
"notification_sound_desc": "Jouer un son pour les alertes de calendrier"
},
"days": {
"monday": "Lundi",
@@ -1121,7 +1125,10 @@
"event_deleted": "Événement supprimé",
"calendar_created": "Calendrier créé",
"calendar_deleted": "Calendrier supprimé",
"event_move_error": "Échec du déplacement de l'événement"
"event_move_error": "Échec du déplacement de l'événement",
"alert_title": "Événement à venir",
"alert_now": "Commence maintenant",
"alert_in_minutes": "Dans {count} min"
},
"status": {
"loading_calendars": "Chargement des calendriers...",
+9 -2
View File
@@ -1097,7 +1097,11 @@
"default_calendar": "Calendario predefinito",
"default_reminder": "Promemoria predefinito",
"time_format_12h": "12 ore",
"time_format_24h": "24 ore"
"time_format_24h": "24 ore",
"notifications_enabled": "Notifiche eventi",
"notifications_enabled_desc": "Mostra avvisi per gli eventi in arrivo",
"notification_sound": "Suono di notifica",
"notification_sound_desc": "Riproduci un suono per gli avvisi del calendario"
},
"days": {
"monday": "Lunedì",
@@ -1121,7 +1125,10 @@
"event_deleted": "Evento eliminato",
"calendar_created": "Calendario creato",
"calendar_deleted": "Calendario eliminato",
"event_move_error": "Spostamento dell'evento non riuscito"
"event_move_error": "Spostamento dell'evento non riuscito",
"alert_title": "Evento in arrivo",
"alert_now": "Inizia ora",
"alert_in_minutes": "Tra {count} min"
},
"status": {
"loading_calendars": "Caricamento calendari...",
+9 -2
View File
@@ -1097,7 +1097,11 @@
"default_calendar": "デフォルトのカレンダー",
"default_reminder": "デフォルトのリマインダー",
"time_format_12h": "12時間制",
"time_format_24h": "24時間制"
"time_format_24h": "24時間制",
"notifications_enabled": "イベント通知",
"notifications_enabled_desc": "予定のイベントのアラートを表示する",
"notification_sound": "通知音",
"notification_sound_desc": "カレンダーアラートの音を鳴らす"
},
"days": {
"monday": "月曜日",
@@ -1121,7 +1125,10 @@
"event_deleted": "予定を削除しました",
"calendar_created": "カレンダーを作成しました",
"calendar_deleted": "カレンダーを削除しました",
"event_move_error": "イベントの移動に失敗しました"
"event_move_error": "イベントの移動に失敗しました",
"alert_title": "予定のイベント",
"alert_now": "まもなく開始",
"alert_in_minutes": "{count}分後"
},
"status": {
"loading_calendars": "カレンダーを読み込み中...",
+9 -2
View File
@@ -1097,7 +1097,11 @@
"default_calendar": "Standaardagenda",
"default_reminder": "Standaardherinnering",
"time_format_12h": "12-uurs",
"time_format_24h": "24-uurs"
"time_format_24h": "24-uurs",
"notifications_enabled": "Evenementmeldingen",
"notifications_enabled_desc": "Meldingen weergeven voor aankomende evenementen",
"notification_sound": "Meldingsgeluid",
"notification_sound_desc": "Geluid afspelen voor agendameldingen"
},
"days": {
"monday": "Maandag",
@@ -1121,7 +1125,10 @@
"event_deleted": "Evenement verwijderd",
"calendar_created": "Agenda aangemaakt",
"calendar_deleted": "Agenda verwijderd",
"event_move_error": "Evenement verplaatsen mislukt"
"event_move_error": "Evenement verplaatsen mislukt",
"alert_title": "Aankomend evenement",
"alert_now": "Begint nu",
"alert_in_minutes": "Over {count} min"
},
"status": {
"loading_calendars": "Agenda's laden...",
+9 -2
View File
@@ -1097,7 +1097,11 @@
"default_calendar": "Calendário padrão",
"default_reminder": "Lembrete padrão",
"time_format_12h": "12 horas",
"time_format_24h": "24 horas"
"time_format_24h": "24 horas",
"notifications_enabled": "Notificações de eventos",
"notifications_enabled_desc": "Mostrar alertas para eventos próximos",
"notification_sound": "Som de notificação",
"notification_sound_desc": "Reproduzir um som para alertas do calendário"
},
"days": {
"monday": "Segunda-feira",
@@ -1121,7 +1125,10 @@
"event_deleted": "Evento excluído",
"calendar_created": "Calendário criado",
"calendar_deleted": "Calendário excluído",
"event_move_error": "Falha ao mover o evento"
"event_move_error": "Falha ao mover o evento",
"alert_title": "Evento próximo",
"alert_now": "Começa agora",
"alert_in_minutes": "Em {count} min"
},
"status": {
"loading_calendars": "Carregando calendários...",
@@ -0,0 +1,68 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useCalendarNotificationStore } from '../calendar-notification-store';
const RETENTION_THRESHOLD_MS = 24 * 60 * 60 * 1000;
function getStore() {
return useCalendarNotificationStore.getState();
}
describe('calendar-notification-store', () => {
beforeEach(() => {
getStore().clearAll();
});
it('starts with empty acknowledged alerts', () => {
expect(getStore().acknowledgedAlerts).toEqual({});
});
it('acknowledges an alert with key and fireTimeMs', () => {
getStore().acknowledgeAlert('evt-1:a1:1000', 1000);
expect(getStore().acknowledgedAlerts).toEqual({ 'evt-1:a1:1000': 1000 });
});
it('acknowledges multiple alerts', () => {
getStore().acknowledgeAlert('key1', 1000);
getStore().acknowledgeAlert('key2', 2000);
expect(Object.keys(getStore().acknowledgedAlerts)).toHaveLength(2);
});
it('isAcknowledged returns true for acknowledged keys', () => {
getStore().acknowledgeAlert('key1', 1000);
expect(getStore().isAcknowledged('key1')).toBe(true);
});
it('isAcknowledged returns false for unknown keys', () => {
expect(getStore().isAcknowledged('unknown')).toBe(false);
});
it('clearAll empties the map', () => {
getStore().acknowledgeAlert('key1', 1000);
getStore().acknowledgeAlert('key2', 2000);
getStore().clearAll();
expect(getStore().acknowledgedAlerts).toEqual({});
});
it('cleanupStaleAlerts removes entries older than 24 hours', () => {
const now = Date.now();
const old = now - RETENTION_THRESHOLD_MS - 1000;
const recent = now - 1000;
getStore().acknowledgeAlert('old', old);
getStore().acknowledgeAlert('recent', recent);
getStore().cleanupStaleAlerts();
expect(getStore().isAcknowledged('old')).toBe(false);
expect(getStore().isAcknowledged('recent')).toBe(true);
});
it('cleanupStaleAlerts keeps entries at exactly the threshold', () => {
const now = Date.now();
const atThreshold = now - RETENTION_THRESHOLD_MS + 100;
getStore().acknowledgeAlert('boundary', atThreshold);
getStore().cleanupStaleAlerts();
expect(getStore().isAcknowledged('boundary')).toBe(true);
});
});
+49
View File
@@ -0,0 +1,49 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const RETENTION_THRESHOLD_MS = 24 * 60 * 60 * 1000;
interface CalendarNotificationStore {
acknowledgedAlerts: Record<string, number>;
acknowledgeAlert: (key: string, fireTimeMs: number) => void;
isAcknowledged: (key: string) => boolean;
cleanupStaleAlerts: () => void;
clearAll: () => void;
}
export const useCalendarNotificationStore = create<CalendarNotificationStore>()(
persist(
(set, get) => ({
acknowledgedAlerts: {},
acknowledgeAlert: (key, fireTimeMs) => {
set((state) => ({
acknowledgedAlerts: { ...state.acknowledgedAlerts, [key]: fireTimeMs },
}));
},
isAcknowledged: (key) => {
return key in get().acknowledgedAlerts;
},
cleanupStaleAlerts: () => {
const now = Date.now();
const cleaned = Object.fromEntries(
Object.entries(get().acknowledgedAlerts)
.filter(([, fireTimeMs]) => now - fireTimeMs < RETENTION_THRESHOLD_MS)
);
set({ acknowledgedAlerts: cleaned });
},
clearAll: () => {
set({ acknowledgedAlerts: {} });
},
}),
{
name: 'calendar-notification-storage',
partialize: (state) => ({
acknowledgedAlerts: state.acknowledgedAlerts,
}),
}
)
);
+9 -4
View File
@@ -162,10 +162,15 @@ export const useCalendarStore = create<CalendarStore>()(
setSelectedEventId: (id) => set({ selectedEventId: id }),
clearState: () => set({
...initialState,
selectedDate: new Date(),
}),
clearState: () => {
set({
...initialState,
selectedDate: new Date(),
});
import('./calendar-notification-store').then(({ useCalendarNotificationStore }) => {
useCalendarNotificationStore.getState().clearAll();
}).catch(() => {});
},
}),
{
name: 'calendar-storage',
+10
View File
@@ -37,6 +37,10 @@ interface SettingsState {
sessionTimeout: number; // minutes (0 = never)
trustedSenders: string[]; // Email addresses that can load external content
// Calendar Notifications
calendarNotificationsEnabled: boolean;
calendarNotificationSound: boolean;
// Advanced
debugMode: boolean;
@@ -82,6 +86,10 @@ const DEFAULT_SETTINGS = {
sessionTimeout: 0, // Never
trustedSenders: [] as string[],
// Calendar Notifications
calendarNotificationsEnabled: true,
calendarNotificationSound: true,
// Advanced
debugMode: false,
};
@@ -136,6 +144,8 @@ export const useSettingsStore = create<SettingsState>()(
sendConfirmation: state.sendConfirmation,
defaultReplyMode: state.defaultReplyMode,
sessionTimeout: state.sessionTimeout,
calendarNotificationsEnabled: state.calendarNotificationsEnabled,
calendarNotificationSound: state.calendarNotificationSound,
debugMode: state.debugMode,
};
return JSON.stringify(settings, null, 2);