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