diff --git a/.husky/commit-msg b/.husky/commit-msg
new file mode 100755
index 00000000..94864ce0
--- /dev/null
+++ b/.husky/commit-msg
@@ -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
diff --git a/README.md b/README.md
index 7a54894e..25c4a828 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/ROADMAP.md b/ROADMAP.md
index edb2613e..15287fa4 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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)
diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx
index a5e0f944..a2d66fd2 100644
--- a/app/[locale]/layout.tsx
+++ b/app/[locale]/layout.tsx
@@ -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 (
- {children}
+
+ {children}
+
);
diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx
index eab3d8a0..df69ad49 100644
--- a/app/[locale]/page.tsx
+++ b/app/[locale]/page.tsx
@@ -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: () => {
diff --git a/components/providers/calendar-alert-provider.tsx b/components/providers/calendar-alert-provider.tsx
new file mode 100644
index 00000000..3e87045c
--- /dev/null
+++ b/components/providers/calendar-alert-provider.tsx
@@ -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}
+
+ >
+ );
+}
diff --git a/components/settings/calendar-settings.tsx b/components/settings/calendar-settings.tsx
index 99909161..19b4a587 100644
--- a/components/settings/calendar-settings.tsx
+++ b/components/settings/calendar-settings.tsx
@@ -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 (
@@ -50,6 +50,27 @@ export function CalendarSettings() {
/>
+
+ updateSetting('calendarNotificationsEnabled', checked)}
+ />
+
+
+
+ updateSetting('calendarNotificationSound', checked)}
+ disabled={!calendarNotificationsEnabled}
+ />
+
+
);
}
diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx
index 2b4b5650..f9191d87 100644
--- a/components/ui/toast.tsx
+++ b/components/ui/toast.tsx
@@ -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) {
}
}}
>
-
+ {toast.icon !== undefined ? toast.icon : }
{toast.title}
{toast.message && (
diff --git a/hooks/use-calendar-alerts.ts b/hooks/use-calendar-alerts.ts
new file mode 100644
index 00000000..9dc9c6b1
--- /dev/null
+++ b/hooks/use-calendar-alerts.ts
@@ -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(0);
+ const proactiveEventsRef = useRef([]);
+ const shownKeysRef = useRef>(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]);
+}
diff --git a/lib/__tests__/calendar-alerts.test.ts b/lib/__tests__/calendar-alerts.test.ts
new file mode 100644
index 00000000..9cea5b66
--- /dev/null
+++ b/lib/__tests__/calendar-alerts.test.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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();
+ });
+});
diff --git a/lib/calendar-alerts.ts b/lib/calendar-alerts.ts
new file mode 100644
index 00000000..f904ee65
--- /dev/null
+++ b/lib/calendar-alerts.ts
@@ -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 | 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,
+ 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;
+}
diff --git a/lib/notification-sound.ts b/lib/notification-sound.ts
new file mode 100644
index 00000000..f444a0aa
--- /dev/null
+++ b/lib/notification-sound.ts
@@ -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);
+ }
+}
diff --git a/locales/de/common.json b/locales/de/common.json
index 57b7407d..e65f817e 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -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...",
diff --git a/locales/en/common.json b/locales/en/common.json
index 18b63043..23930882 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -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...",
diff --git a/locales/es/common.json b/locales/es/common.json
index 9988c20f..a7b3ac79 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -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...",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 7a861799..9b0f79f7 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -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...",
diff --git a/locales/it/common.json b/locales/it/common.json
index df4fbf50..bc21c3a3 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -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...",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index be15f1fc..82e28585 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -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": "カレンダーを読み込み中...",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 93d3d7f0..c2e877c9 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -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...",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index a9757dce..50fe5e55 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -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...",
diff --git a/stores/__tests__/calendar-notification-store.test.ts b/stores/__tests__/calendar-notification-store.test.ts
new file mode 100644
index 00000000..2b8284d0
--- /dev/null
+++ b/stores/__tests__/calendar-notification-store.test.ts
@@ -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);
+ });
+});
diff --git a/stores/calendar-notification-store.ts b/stores/calendar-notification-store.ts
new file mode 100644
index 00000000..da0a266e
--- /dev/null
+++ b/stores/calendar-notification-store.ts
@@ -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;
+ acknowledgeAlert: (key: string, fireTimeMs: number) => void;
+ isAcknowledged: (key: string) => boolean;
+ cleanupStaleAlerts: () => void;
+ clearAll: () => void;
+}
+
+export const useCalendarNotificationStore = create()(
+ 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,
+ }),
+ }
+ )
+);
diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts
index 79634a3e..b3998c68 100644
--- a/stores/calendar-store.ts
+++ b/stores/calendar-store.ts
@@ -162,10 +162,15 @@ export const useCalendarStore = create()(
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',
diff --git a/stores/settings-store.ts b/stores/settings-store.ts
index 255fc125..70a530e5 100644
--- a/stores/settings-store.ts
+++ b/stores/settings-store.ts
@@ -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()(
sendConfirmation: state.sendConfirmation,
defaultReplyMode: state.defaultReplyMode,
sessionTimeout: state.sessionTimeout,
+ calendarNotificationsEnabled: state.calendarNotificationsEnabled,
+ calendarNotificationSound: state.calendarNotificationSound,
debugMode: state.debugMode,
};
return JSON.stringify(settings, null, 2);