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:
Executable
+7
@@ -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
|
||||||
@@ -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)
|
- Drag-and-drop rescheduling (week/day time snap, month date move)
|
||||||
- iCalendar (.ics) file import with event preview and bulk create
|
- iCalendar (.ics) file import with event preview and bulk create
|
||||||
- Real-time updates via JMAP push notifications
|
- 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)
|
- Keyboard shortcuts: m/w/d/a (views), t (today), n (new event), arrows (navigate)
|
||||||
|
|
||||||
### Email Filters
|
### Email Filters
|
||||||
|
|||||||
+5
-1
@@ -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] i18n support with ICU pluralization (all 8 languages)
|
||||||
- [x] Drag-and-drop event rescheduling (week/day time snap, month date move)
|
- [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] 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
|
### Email Filters
|
||||||
- [x] JMAP Sieve Scripts (RFC 9661) with capability detection
|
- [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] JMAP client method tests (identity: 20, contacts: 41)
|
||||||
- [x] Unit tests for Sieve generator (50 tests)
|
- [x] Unit tests for Sieve generator (50 tests)
|
||||||
- [x] Unit tests for Sieve parser (14 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] XSS attack vector testing
|
||||||
- [x] Playwright E2E framework setup
|
- [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
|
- [ ] Participant scheduling with iTIP invitations
|
||||||
- [ ] Free/busy queries (Principal/getAvailability)
|
- [ ] Free/busy queries (Principal/getAvailability)
|
||||||
- [ ] Calendar sharing UI (JMAP Sharing RFC 9670)
|
- [ ] Calendar sharing UI (JMAP Sharing RFC 9670)
|
||||||
- [ ] Calendar event notifications display
|
|
||||||
- [ ] Email templates
|
- [ ] Email templates
|
||||||
- [ ] Email encryption (PGP/GPG)
|
- [ ] Email encryption (PGP/GPG)
|
||||||
- [ ] OAuth2/OIDC authentication (opt-in, Basic Auth remains default)
|
- [ ] OAuth2/OIDC authentication (opt-in, Basic Auth remains default)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { IntlProvider } from "@/components/providers/intl-provider";
|
import { IntlProvider } from "@/components/providers/intl-provider";
|
||||||
import { ThemeProvider } from "@/components/providers/theme-provider";
|
import { ThemeProvider } from "@/components/providers/theme-provider";
|
||||||
|
import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider";
|
||||||
import { locales } from "@/i18n/routing";
|
import { locales } from "@/i18n/routing";
|
||||||
|
|
||||||
export default async function LocaleLayout({
|
export default async function LocaleLayout({
|
||||||
@@ -24,7 +25,9 @@ export default async function LocaleLayout({
|
|||||||
return (
|
return (
|
||||||
<IntlProvider locale={locale} messages={messages}>
|
<IntlProvider locale={locale} messages={messages}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
{children}
|
<CalendarAlertProvider>
|
||||||
|
{children}
|
||||||
|
</CalendarAlertProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-22
@@ -18,6 +18,7 @@ import { useUIStore } from "@/stores/ui-store";
|
|||||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
|
import { playNotificationSound } from "@/lib/notification-sound";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
ErrorBoundary,
|
ErrorBoundary,
|
||||||
@@ -87,28 +88,6 @@ export default function Home() {
|
|||||||
advancedSearch,
|
advancedSearch,
|
||||||
} = useEmailStore();
|
} = 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
|
// Keyboard shortcuts handlers
|
||||||
const keyboardHandlers = useMemo(() => ({
|
const keyboardHandlers = useMemo(() => ({
|
||||||
onNextEmail: () => {
|
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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useCalendarStore, CalendarViewMode } from '@/stores/calendar-store';
|
import { useCalendarStore, CalendarViewMode } from '@/stores/calendar-store';
|
||||||
import { useSettingsStore } from '@/stores/settings-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() {
|
export function CalendarSettings() {
|
||||||
const t = useTranslations('calendar.settings');
|
const t = useTranslations('calendar.settings');
|
||||||
@@ -11,7 +11,7 @@ export function CalendarSettings() {
|
|||||||
const tDays = useTranslations('calendar.days');
|
const tDays = useTranslations('calendar.days');
|
||||||
|
|
||||||
const { viewMode, setViewMode } = useCalendarStore();
|
const { viewMode, setViewMode } = useCalendarStore();
|
||||||
const { timeFormat, firstDayOfWeek, updateSetting } = useSettingsStore();
|
const { timeFormat, firstDayOfWeek, calendarNotificationsEnabled, calendarNotificationSound, updateSetting } = useSettingsStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection title={t('title')}>
|
<SettingsSection title={t('title')}>
|
||||||
@@ -50,6 +50,27 @@ export function CalendarSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</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>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface Toast {
|
|||||||
message?: string;
|
message?: string;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
icon?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToastProps {
|
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">
|
<div className="flex-1">
|
||||||
<h4 className="font-medium">{toast.title}</h4>
|
<h4 className="font-medium">{toast.title}</h4>
|
||||||
{toast.message && (
|
{toast.message && (
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1097,7 +1097,11 @@
|
|||||||
"default_calendar": "Standardkalender",
|
"default_calendar": "Standardkalender",
|
||||||
"default_reminder": "Standarderinnerung",
|
"default_reminder": "Standarderinnerung",
|
||||||
"time_format_12h": "12-Stunden",
|
"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": {
|
"days": {
|
||||||
"monday": "Montag",
|
"monday": "Montag",
|
||||||
@@ -1121,7 +1125,10 @@
|
|||||||
"event_deleted": "Termin gelöscht",
|
"event_deleted": "Termin gelöscht",
|
||||||
"calendar_created": "Kalender erstellt",
|
"calendar_created": "Kalender erstellt",
|
||||||
"calendar_deleted": "Kalender gelöscht",
|
"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": {
|
"status": {
|
||||||
"loading_calendars": "Kalender werden geladen...",
|
"loading_calendars": "Kalender werden geladen...",
|
||||||
|
|||||||
@@ -1097,7 +1097,11 @@
|
|||||||
"default_calendar": "Default calendar",
|
"default_calendar": "Default calendar",
|
||||||
"default_reminder": "Default reminder",
|
"default_reminder": "Default reminder",
|
||||||
"time_format_12h": "12-hour",
|
"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": {
|
"days": {
|
||||||
"monday": "Monday",
|
"monday": "Monday",
|
||||||
@@ -1121,7 +1125,10 @@
|
|||||||
"event_deleted": "Event deleted",
|
"event_deleted": "Event deleted",
|
||||||
"calendar_created": "Calendar created",
|
"calendar_created": "Calendar created",
|
||||||
"calendar_deleted": "Calendar deleted",
|
"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": {
|
"status": {
|
||||||
"loading_calendars": "Loading calendars...",
|
"loading_calendars": "Loading calendars...",
|
||||||
|
|||||||
@@ -1097,7 +1097,11 @@
|
|||||||
"default_calendar": "Calendario predeterminado",
|
"default_calendar": "Calendario predeterminado",
|
||||||
"default_reminder": "Recordatorio predeterminado",
|
"default_reminder": "Recordatorio predeterminado",
|
||||||
"time_format_12h": "12 horas",
|
"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": {
|
"days": {
|
||||||
"monday": "Lunes",
|
"monday": "Lunes",
|
||||||
@@ -1121,7 +1125,10 @@
|
|||||||
"event_deleted": "Evento eliminado",
|
"event_deleted": "Evento eliminado",
|
||||||
"calendar_created": "Calendario creado",
|
"calendar_created": "Calendario creado",
|
||||||
"calendar_deleted": "Calendario eliminado",
|
"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": {
|
"status": {
|
||||||
"loading_calendars": "Cargando calendarios...",
|
"loading_calendars": "Cargando calendarios...",
|
||||||
|
|||||||
@@ -1097,7 +1097,11 @@
|
|||||||
"default_calendar": "Calendrier par défaut",
|
"default_calendar": "Calendrier par défaut",
|
||||||
"default_reminder": "Rappel par défaut",
|
"default_reminder": "Rappel par défaut",
|
||||||
"time_format_12h": "12 heures",
|
"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": {
|
"days": {
|
||||||
"monday": "Lundi",
|
"monday": "Lundi",
|
||||||
@@ -1121,7 +1125,10 @@
|
|||||||
"event_deleted": "Événement supprimé",
|
"event_deleted": "Événement supprimé",
|
||||||
"calendar_created": "Calendrier créé",
|
"calendar_created": "Calendrier créé",
|
||||||
"calendar_deleted": "Calendrier supprimé",
|
"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": {
|
"status": {
|
||||||
"loading_calendars": "Chargement des calendriers...",
|
"loading_calendars": "Chargement des calendriers...",
|
||||||
|
|||||||
@@ -1097,7 +1097,11 @@
|
|||||||
"default_calendar": "Calendario predefinito",
|
"default_calendar": "Calendario predefinito",
|
||||||
"default_reminder": "Promemoria predefinito",
|
"default_reminder": "Promemoria predefinito",
|
||||||
"time_format_12h": "12 ore",
|
"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": {
|
"days": {
|
||||||
"monday": "Lunedì",
|
"monday": "Lunedì",
|
||||||
@@ -1121,7 +1125,10 @@
|
|||||||
"event_deleted": "Evento eliminato",
|
"event_deleted": "Evento eliminato",
|
||||||
"calendar_created": "Calendario creato",
|
"calendar_created": "Calendario creato",
|
||||||
"calendar_deleted": "Calendario eliminato",
|
"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": {
|
"status": {
|
||||||
"loading_calendars": "Caricamento calendari...",
|
"loading_calendars": "Caricamento calendari...",
|
||||||
|
|||||||
@@ -1097,7 +1097,11 @@
|
|||||||
"default_calendar": "デフォルトのカレンダー",
|
"default_calendar": "デフォルトのカレンダー",
|
||||||
"default_reminder": "デフォルトのリマインダー",
|
"default_reminder": "デフォルトのリマインダー",
|
||||||
"time_format_12h": "12時間制",
|
"time_format_12h": "12時間制",
|
||||||
"time_format_24h": "24時間制"
|
"time_format_24h": "24時間制",
|
||||||
|
"notifications_enabled": "イベント通知",
|
||||||
|
"notifications_enabled_desc": "予定のイベントのアラートを表示する",
|
||||||
|
"notification_sound": "通知音",
|
||||||
|
"notification_sound_desc": "カレンダーアラートの音を鳴らす"
|
||||||
},
|
},
|
||||||
"days": {
|
"days": {
|
||||||
"monday": "月曜日",
|
"monday": "月曜日",
|
||||||
@@ -1121,7 +1125,10 @@
|
|||||||
"event_deleted": "予定を削除しました",
|
"event_deleted": "予定を削除しました",
|
||||||
"calendar_created": "カレンダーを作成しました",
|
"calendar_created": "カレンダーを作成しました",
|
||||||
"calendar_deleted": "カレンダーを削除しました",
|
"calendar_deleted": "カレンダーを削除しました",
|
||||||
"event_move_error": "イベントの移動に失敗しました"
|
"event_move_error": "イベントの移動に失敗しました",
|
||||||
|
"alert_title": "予定のイベント",
|
||||||
|
"alert_now": "まもなく開始",
|
||||||
|
"alert_in_minutes": "{count}分後"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading_calendars": "カレンダーを読み込み中...",
|
"loading_calendars": "カレンダーを読み込み中...",
|
||||||
|
|||||||
@@ -1097,7 +1097,11 @@
|
|||||||
"default_calendar": "Standaardagenda",
|
"default_calendar": "Standaardagenda",
|
||||||
"default_reminder": "Standaardherinnering",
|
"default_reminder": "Standaardherinnering",
|
||||||
"time_format_12h": "12-uurs",
|
"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": {
|
"days": {
|
||||||
"monday": "Maandag",
|
"monday": "Maandag",
|
||||||
@@ -1121,7 +1125,10 @@
|
|||||||
"event_deleted": "Evenement verwijderd",
|
"event_deleted": "Evenement verwijderd",
|
||||||
"calendar_created": "Agenda aangemaakt",
|
"calendar_created": "Agenda aangemaakt",
|
||||||
"calendar_deleted": "Agenda verwijderd",
|
"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": {
|
"status": {
|
||||||
"loading_calendars": "Agenda's laden...",
|
"loading_calendars": "Agenda's laden...",
|
||||||
|
|||||||
@@ -1097,7 +1097,11 @@
|
|||||||
"default_calendar": "Calendário padrão",
|
"default_calendar": "Calendário padrão",
|
||||||
"default_reminder": "Lembrete padrão",
|
"default_reminder": "Lembrete padrão",
|
||||||
"time_format_12h": "12 horas",
|
"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": {
|
"days": {
|
||||||
"monday": "Segunda-feira",
|
"monday": "Segunda-feira",
|
||||||
@@ -1121,7 +1125,10 @@
|
|||||||
"event_deleted": "Evento excluído",
|
"event_deleted": "Evento excluído",
|
||||||
"calendar_created": "Calendário criado",
|
"calendar_created": "Calendário criado",
|
||||||
"calendar_deleted": "Calendário excluído",
|
"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": {
|
"status": {
|
||||||
"loading_calendars": "Carregando calendários...",
|
"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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -162,10 +162,15 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
|
|
||||||
setSelectedEventId: (id) => set({ selectedEventId: id }),
|
setSelectedEventId: (id) => set({ selectedEventId: id }),
|
||||||
|
|
||||||
clearState: () => set({
|
clearState: () => {
|
||||||
...initialState,
|
set({
|
||||||
selectedDate: new Date(),
|
...initialState,
|
||||||
}),
|
selectedDate: new Date(),
|
||||||
|
});
|
||||||
|
import('./calendar-notification-store').then(({ useCalendarNotificationStore }) => {
|
||||||
|
useCalendarNotificationStore.getState().clearAll();
|
||||||
|
}).catch(() => {});
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'calendar-storage',
|
name: 'calendar-storage',
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ interface SettingsState {
|
|||||||
sessionTimeout: number; // minutes (0 = never)
|
sessionTimeout: number; // minutes (0 = never)
|
||||||
trustedSenders: string[]; // Email addresses that can load external content
|
trustedSenders: string[]; // Email addresses that can load external content
|
||||||
|
|
||||||
|
// Calendar Notifications
|
||||||
|
calendarNotificationsEnabled: boolean;
|
||||||
|
calendarNotificationSound: boolean;
|
||||||
|
|
||||||
// Advanced
|
// Advanced
|
||||||
debugMode: boolean;
|
debugMode: boolean;
|
||||||
|
|
||||||
@@ -82,6 +86,10 @@ const DEFAULT_SETTINGS = {
|
|||||||
sessionTimeout: 0, // Never
|
sessionTimeout: 0, // Never
|
||||||
trustedSenders: [] as string[],
|
trustedSenders: [] as string[],
|
||||||
|
|
||||||
|
// Calendar Notifications
|
||||||
|
calendarNotificationsEnabled: true,
|
||||||
|
calendarNotificationSound: true,
|
||||||
|
|
||||||
// Advanced
|
// Advanced
|
||||||
debugMode: false,
|
debugMode: false,
|
||||||
};
|
};
|
||||||
@@ -136,6 +144,8 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
sendConfirmation: state.sendConfirmation,
|
sendConfirmation: state.sendConfirmation,
|
||||||
defaultReplyMode: state.defaultReplyMode,
|
defaultReplyMode: state.defaultReplyMode,
|
||||||
sessionTimeout: state.sessionTimeout,
|
sessionTimeout: state.sessionTimeout,
|
||||||
|
calendarNotificationsEnabled: state.calendarNotificationsEnabled,
|
||||||
|
calendarNotificationSound: state.calendarNotificationSound,
|
||||||
debugMode: state.debugMode,
|
debugMode: state.debugMode,
|
||||||
};
|
};
|
||||||
return JSON.stringify(settings, null, 2);
|
return JSON.stringify(settings, null, 2);
|
||||||
|
|||||||
Reference in New Issue
Block a user