feat: fire calendar reminders from the local store, with native snooze and join buttons and one summary of what was missed

This commit is contained in:
Linus Rath
2026-11-03 12:31:58 +01:00
parent 4fd5c8c1aa
commit a802411cdd
55 changed files with 2555 additions and 70 deletions
@@ -0,0 +1,70 @@
"use client";
import { useLocale, useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
import { getPathPrefix } from "@/lib/browser-navigation";
import { reminderTimeZone, useMissedReminders } from "@/lib/calendar-reminders/state";
import { dismissReminders, joinMeeting } from "@/lib/desktop/reminder-notifications";
import { useSettingsStore } from "@/stores/settings-store";
/** What came due while the app was closed, the machine slept or notifications were paused. */
export function MissedRemindersDialog() {
const t = useTranslations("desktopReminders");
const tAlert = useTranslations("calendar.notifications");
const locale = useLocale();
const timeZone = useSettingsStore((s) => s.timeZone);
const account = useMissedReminders((s) => s.account);
const alerts = useMissedReminders((s) => s.alerts);
const close = useMissedReminders((s) => s.close);
if (!account || alerts.length === 0) return null;
const format = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short", timeZone: reminderTimeZone(timeZone) });
const finish = () => {
dismissReminders(account, alerts.map((alert) => alert.key));
close();
};
return (
<Dialog
isOpen
onClose={finish}
title={t("missed_dialog_title")}
description={t("missed_dialog_description")}
footer={(
<>
<Button
variant="outline"
onClick={() => {
finish();
window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
}}
>
{t("open_calendar")}
</Button>
<Button onClick={finish}>{t("done")}</Button>
</>
)}
>
<ul className="max-h-80 overflow-y-auto divide-y divide-border" data-testid="missed-reminders">
{alerts.map((alert) => (
<li key={alert.key} className="flex items-center gap-3 py-2">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">{alert.title || tAlert("alert_title")}</p>
<p className="truncate text-xs text-muted-foreground">
{format.format(new Date(alert.startMs ?? alert.fireTimeMs))}
{alert.calendarName ? ` · ${alert.calendarName}` : ""}
</p>
</div>
{alert.joinUrl && (
<Button variant="outline" size="sm" onClick={() => joinMeeting(alert.joinUrl!)}>
{t("join")}
</Button>
)}
</li>
))}
</ul>
</Dialog>
);
}
-19
View File
@@ -9,11 +9,8 @@ import { startOsSummary } from "@/lib/desktop/os-summary";
import { startPowerWatch } from "@/lib/desktop/power";
import { startWindowFiles } from "@/lib/desktop/window-files";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { calendarHooks } from "@/lib/plugin-hooks";
import type { CalendarAlertView } from "@/lib/plugin-types";
import { useAccountStore } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-store";
/**
* The main window's end of the desktop app's OS integration: new mail as native
@@ -25,7 +22,6 @@ export function DesktopOsHost() {
const t = useTranslations("desktopOs");
const tNotify = useTranslations("notifications");
const tPush = useTranslations("pwaNotify");
const tCalendar = useTranslations("calendar.notifications");
const locale = useLocale();
const archiveEnabled = useArchiveActionEnabled();
const clientKey = useAccountStore((s) => s.accounts.map((a) => a.id).join("\n"));
@@ -56,29 +52,14 @@ export function DesktopOsHost() {
summaryRef.current = summary;
const stopPower = startPowerWatch();
const stopFiles = startWindowFiles();
// Reminders' own native notification (snooze, join) comes with the reminders work.
// Until then a reminder must not fire into a hidden window unseen.
const reminders = calendarHooks.onCalendarAlert.register("desktop-os", (view: CalendarAlertView) => {
const { calendarNotificationsEnabled } = useSettingsStore.getState();
if (!calendarNotificationsEnabled) return;
const event = "eventId" in view;
void getDesktopBridge()?.os?.notify({
id: `reminder:${event ? view.eventId : view.taskId}:${view.id}:${view.triggerTime}`,
title: (event ? view.eventTitle : view.taskTitle) || tCalendar("alert_title"),
body: "",
context: "{}",
});
});
return () => {
mail.stop();
summary.stop();
stopPower();
stopFiles();
reminders.dispose();
mailRef.current = null;
summaryRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
@@ -1,12 +1,15 @@
"use client";
import { MissedRemindersDialog } from '@/components/calendar/missed-reminders-dialog';
import { useCalendarAlerts } from '@/hooks/use-calendar-alerts';
import { useStoreReminders } from '@/hooks/use-store-reminders';
import { ToastContainer } from '@/components/ui/toast';
import { LiveRegions } from '@/components/ui/live-regions';
import { useToastStore } from '@/stores/toast-store';
export function CalendarAlertProvider({ children }: { children: React.ReactNode }) {
useCalendarAlerts();
useStoreReminders();
const toasts = useToastStore((s) => s.toasts);
const removeToast = useToastStore((s) => s.removeToast);
@@ -15,6 +18,7 @@ export function CalendarAlertProvider({ children }: { children: React.ReactNode
<>
{children}
<ToastContainer toasts={toasts} onClose={removeToast} />
<MissedRemindersDialog />
<LiveRegions />
</>
);
@@ -0,0 +1,33 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { ToastItem } from '../toast';
describe('a toast with more than three actions', () => {
it('shows them in order, and each closes it', () => {
vi.useFakeTimers();
const clicked = vi.fn();
const onDismiss = vi.fn();
const action = (label: string) => ({ label, onClick: () => clicked(label) });
render(
<ToastItem
onClose={vi.fn()}
toast={{
id: 't',
type: 'info',
title: 'Standup',
duration: 0,
onDismiss,
tertiaryAction: action('five'),
secondaryAction: action('ten'),
action: action('fifteen'),
extraActions: [action('join'), action('open')],
}}
/>,
);
expect(screen.getAllByRole('button').map((button) => button.textContent).filter(Boolean)).toEqual(['five', 'ten', 'fifteen', 'join', 'open']);
fireEvent.click(screen.getByText('join'));
expect(clicked).toHaveBeenCalledWith('join');
expect(onDismiss).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
});
+3
View File
@@ -36,6 +36,8 @@ export interface Toast {
* that has one lays its actions out on a row of their own.
*/
tertiaryAction?: ToastAction;
/** More actions at the end of that row. Needs a tertiaryAction. */
extraActions?: ToastAction[];
/**
* Work in progress, e.g. an upload: 0..1, or null while the amount is
* unknown. The bottom line then shows progress instead of a countdown,
@@ -291,6 +293,7 @@ export function ToastItem({ toast, onClose, held = false, onFocusLost }: ToastPr
{actionButton(toast.tertiaryAction)}
{toast.secondaryAction && actionButton(toast.secondaryAction)}
{toast.action && actionButton(toast.action)}
{toast.extraActions?.map((action) => <span key={action.label} className="contents">{actionButton(action)}</span>)}
</div>
)}
</div>
@@ -76,6 +76,54 @@ afterEach(() => {
vi.restoreAllMocks();
});
describe('the 24-hour fetch', () => {
it('fires the occurrence of a series the server answered with its master', async () => {
const now = Date.now();
const occurrenceStart = new Date(now + 5 * 60_000);
const stamp = (date: Date) => {
const pad = (value: number) => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:00`;
};
// The same weekday and time, years ago: only the expansion finds today's.
const first = new Date(occurrenceStart.getTime() - 52 * 7 * 24 * 3_600_000);
const master = {
id: 'series',
uid: 'uid-series',
title: 'Weekly',
start: stamp(first),
duration: 'PT30M',
calendarIds: { c1: true },
useDefaultAlerts: false,
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' }],
alerts: {
a1: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT6M', relativeTo: 'start' } },
},
} as unknown as CalendarEvent;
useCalendarStore.setState({ events: [], calendars: [], supportsCalendar: true });
useAuthStore.setState({ isAuthenticated: true, client: { queryCalendarEvents: vi.fn(async () => [master]) } as never });
renderHook(() => useCalendarAlerts());
await waitFor(() => expect(useToastStore.getState().toasts.map((toast) => toast.title)).toEqual(['Weekly']));
});
});
describe('two tabs', () => {
it('rings in one, also when an id changed between the two', async () => {
const synthetic = { ...event, id: 'synthetic-2' } as CalendarEvent;
renderHook(() => useCalendarAlerts());
await waitFor(() => expect(useToastStore.getState().toasts).toHaveLength(1));
// The other tab has the same reminder under the id the server gives it now.
useCalendarStore.setState({ events: [synthetic] });
renderHook(() => useCalendarAlerts());
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 700));
});
expect(useToastStore.getState().toasts).toHaveLength(1);
});
});
describe('reminder hooks', () => {
it('reports a fired reminder once, and its dismissal when the user closes it', async () => {
const fired = vi.fn();
@@ -0,0 +1,380 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, renderHook, screen } from '@testing-library/react';
import { MissedRemindersDialog } from '@/components/calendar/missed-reminders-dialog';
import { ToastContainer } from '@/components/ui/toast';
import { useCalendarAlerts } from '../use-calendar-alerts';
import { useStoreReminders } from '../use-store-reminders';
import { useMissedReminders, useReminderSource } from '@/lib/calendar-reminders/state';
import { setAppLockCheck } from '@/lib/desktop/app-lock-state';
import type { BulwarkDesktopBridge, DesktopOsNotification, DesktopOsNotifyResult } from '@/lib/desktop/types';
import { calendarHooks, clearAllHooks } from '@/lib/plugin-hooks';
import { useOfflineStore } from '@/lib/offline/state';
import { emitStoreEvent } from '@/lib/offline/store-events';
import { CALENDAR_SESSION, FakeCalendarJmap } from '@/lib/store/__tests__/helpers/fake-calendar';
import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store';
import { setStoreBackend } from '@/lib/store/client/api';
import { useAuthStore } from '@/stores/auth-store';
import { useCalendarNotificationStore } from '@/stores/calendar-notification-store';
import { useCalendarStore } from '@/stores/calendar-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useToastStore } from '@/stores/toast-store';
const sounds = vi.hoisted(() => ({ play: vi.fn() }));
vi.mock('@/lib/notification-sound', () => ({ playNotificationSound: sounds.play }));
vi.mock('@/lib/title-alert', () => ({ startTitleAlert: vi.fn() }));
vi.mock('@/lib/offline/offline-mode', () => ({ offlineActive: () => true }));
const popout = vi.hoisted(() => ({ value: false }));
vi.mock('@/lib/popout/window-role', async (original) => ({
...await original<typeof import('@/lib/popout/window-role')>(),
isPopoutWindow: () => popout.value,
}));
const MIN = 60_000;
const ACCOUNT = 'acc';
const START = Date.UTC(2026, 10, 10, 9);
const FIRE = START - 15 * MIN;
const ALERT = { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } };
const LINK = { virtualLocations: { v: { '@type': 'VirtualLocation', uri: 'https://meet.jit.si/standup' } } };
function event(partial: Record<string, unknown> = {}) {
return { id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', duration: 'PT30M', alerts: { a: ALERT }, ...partial };
}
let store: InProcessStore;
let server: FakeCalendarJmap;
let notifyResult: DesktopOsNotifyResult;
const notify = vi.fn(async (_notification: DesktopOsNotification) => notifyResult);
const openExternal = vi.fn();
function desktop() {
window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { focus: vi.fn() }, shell: { openExternal }, os: { notify } } as unknown as BulwarkDesktopBridge;
}
function setVisibility(state: 'visible' | 'hidden') {
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state });
}
function Toasts() {
const toasts = useToastStore((s) => s.toasts);
const removeToast = useToastStore((s) => s.removeToast);
return <ToastContainer toasts={toasts} onClose={removeToast} />;
}
async function advance(ms: number) {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms);
});
}
async function syncedWith(...events: Record<string, unknown>[]) {
for (const partial of events) server.addEvent(partial);
await store.sync(ACCOUNT);
}
const toastTitles = () => useToastStore.getState().toasts.map((toast) => toast.title);
beforeEach(() => {
vi.useFakeTimers({ now: FIRE - 30 * MIN, toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] });
vi.clearAllMocks();
popout.value = false;
clearAllHooks();
localStorage.clear();
setAppLockCheck(null);
setVisibility('visible');
notifyResult = { shown: true, playSound: false };
useToastStore.setState({ toasts: [] });
useReminderSource.setState({ driven: {} });
useMissedReminders.getState().close();
useCalendarNotificationStore.getState().clearAll();
useOfflineStore.setState({ enabled: [ACCOUNT] });
useAuthStore.setState({ isAuthenticated: true, client: null, activeAccountId: ACCOUNT });
useCalendarStore.setState({ events: [], calendars: [] });
useSettingsStore.setState({
calendarNotificationsEnabled: true, calendarNotificationSound: true, enableCalendarTasks: true,
doNotDisturb: false, visualAlerts: false, timeZone: 'Europe/Berlin',
});
server = new FakeCalendarJmap('1.0');
store = new InProcessStore(server, CALENDAR_SESSION);
setStoreBackend(store);
});
afterEach(() => {
setStoreBackend(null);
window.bulwarkDesktop = undefined;
setAppLockCheck(null);
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe('in the desktop app', () => {
beforeEach(desktop);
it('fires a native notification with the window hidden, at the time, with the network gone', async () => {
await syncedWith(event(LINK));
await store.setOnline(false);
renderHook(() => useStoreReminders());
await advance(FIRE - Date.now() - 1000);
expect(notify).not.toHaveBeenCalled();
await advance(1000);
expect(notify).toHaveBeenCalledTimes(1);
const sent = notify.mock.calls[0][0];
expect(sent.title).toBe('Standup');
expect(sent.actions!.map((action) => action.id)).toEqual(['join', 'open', 'snooze-5', 'snooze-10', 'snooze-15']);
expect(toastTitles()).toEqual([]);
});
it('shows no Join for an event without a link', async () => {
await syncedWith(event());
renderHook(() => useStoreReminders());
await advance(FIRE - Date.now());
expect(notify.mock.calls[0][0].actions!.map((action) => action.id)).not.toContain('join');
});
it('plays the sound main asks for, and asks for none in Do not disturb', async () => {
await syncedWith(event());
notifyResult = { shown: true, playSound: true };
renderHook(() => useStoreReminders());
await advance(FIRE - Date.now());
expect(notify.mock.calls[0][0].sound).toBe(true);
expect(sounds.play).toHaveBeenCalledTimes(1);
useSettingsStore.setState({ doNotDisturb: true });
await syncedWith(event({ id: 'e2', uid: 'uid-2', start: '2026-11-10T10:01:00' }));
emitStoreEvent({ type: 'changed', account: ACCOUNT, collections: ['CalendarEvent'] });
await advance(MIN);
expect(notify.mock.calls[1][0].sound).toBe(false);
});
it('shows it in the app when the window is in front, with the same choices', async () => {
await syncedWith(event(LINK));
notifyResult = { shown: false, reason: 'focused', playSound: true };
render(<Toasts />);
renderHook(() => useStoreReminders());
await advance(FIRE - Date.now());
expect(toastTitles()).toEqual(['Standup']);
expect(sounds.play).toHaveBeenCalledTimes(1);
expect(screen.getAllByRole('button').map((button) => button.textContent).filter(Boolean)).toEqual(['snooze', 'snooze', 'snooze', 'join', 'open']);
fireEvent.click(screen.getByText('join'));
expect(openExternal).toHaveBeenCalledWith('https://meet.jit.si/standup');
});
it('snoozing from the in-app toast brings it back, and closing it does not', async () => {
await syncedWith(event());
notifyResult = { shown: false, reason: 'focused', playSound: false };
render(<Toasts />);
renderHook(() => useStoreReminders());
await advance(FIRE - Date.now());
fireEvent.click(screen.getAllByText('snooze')[0]);
await advance(5 * MIN);
expect(notify).toHaveBeenCalledTimes(2);
await advance(1000);
const again = useToastStore.getState().toasts.at(-1)!;
act(() => again.onDismiss!());
await advance(60 * MIN);
expect(notify).toHaveBeenCalledTimes(2);
});
it('holds what fires while paused or in quiet hours for the summary afterwards', async () => {
await syncedWith(event());
notifyResult = { shown: false, reason: 'quiet-hours', playSound: false };
renderHook(() => useStoreReminders());
await advance(FIRE - Date.now());
expect(notify).toHaveBeenCalledTimes(1);
expect(toastTitles()).toEqual([]);
// Still quiet: the summary is tried and put back.
await advance(5 * MIN);
const tried = notify.mock.calls.length;
expect(notify.mock.calls.at(-1)![0].id).toMatch(/^reminder-missed:/);
notifyResult = { shown: true, playSound: false };
await advance(5 * MIN);
expect(notify.mock.calls.length).toBe(tried + 1);
expect(notify.mock.calls.at(-1)![0].body).toBe('Standup');
await advance(30 * MIN);
expect(notify.mock.calls.length).toBe(tried + 1);
});
it('says nothing of the event while locked, and tells afterwards', async () => {
await syncedWith(event());
let locked = true;
setAppLockCheck(() => locked);
renderHook(() => useStoreReminders());
await advance(FIRE - Date.now());
expect(notify).toHaveBeenCalledTimes(1);
expect(JSON.stringify(notify.mock.calls[0][0])).not.toContain('Standup');
expect(notify.mock.calls[0][0].actions).toBeUndefined();
expect(toastTitles()).toEqual([]);
await advance(10 * MIN);
expect(notify).toHaveBeenCalledTimes(1);
locked = false;
await advance(5 * MIN);
expect(notify).toHaveBeenCalledTimes(2);
expect(notify.mock.calls[1][0].body).toBe('Standup');
});
it('summarises what was missed once, with a native notification, across a restart', async () => {
await syncedWith(event(), event({ id: 'e2', uid: 'uid-2', title: 'Review' }));
const before = renderHook(() => useStoreReminders());
await advance(0);
before.unmount();
vi.setSystemTime(FIRE + 3 * 60 * MIN);
const after = renderHook(() => useStoreReminders());
await advance(20_000);
expect(notify).toHaveBeenCalledTimes(1);
expect(notify.mock.calls[0][0]).toMatchObject({ title: 'missed_title', body: 'Standup, Review' });
after.unmount();
renderHook(() => useStoreReminders());
await advance(60 * MIN);
expect(notify).toHaveBeenCalledTimes(1);
});
it('summarises in the app when the window is in front, and the dialog lists them', async () => {
await syncedWith(event(LINK), event({ id: 'e2', uid: 'uid-2', title: 'Review' }));
notifyResult = { shown: false, reason: 'focused', playSound: false };
const before = renderHook(() => useStoreReminders());
await advance(0);
before.unmount();
vi.setSystemTime(FIRE + 3 * 60 * MIN);
render(<><Toasts /><MissedRemindersDialog /></>);
renderHook(() => useStoreReminders());
await advance(20_000);
expect(toastTitles()).toEqual(['missed_title']);
fireEvent.click(screen.getByText('missed_show'));
await advance(0);
const list = screen.getByTestId('missed-reminders');
expect(list.textContent).toContain('Standup');
expect(list.textContent).toContain('Review');
expect(screen.getAllByText('join')).toHaveLength(1);
fireEvent.click(screen.getByText('done'));
await advance(0);
expect(screen.queryByTestId('missed-reminders')).toBeNull();
});
it('tells plugins, and lets one take the reminder over', async () => {
await syncedWith(event());
const fired = vi.fn();
calendarHooks.onCalendarAlert.register('t', fired);
calendarHooks.onBeforeCalendarAlert.register('t', () => false);
renderHook(() => useStoreReminders());
await advance(FIRE - Date.now());
expect(fired).toHaveBeenCalledWith(expect.objectContaining({ id: 'a', eventId: 'e1', eventTitle: 'Standup' }));
expect(notify).not.toHaveBeenCalled();
});
function calendarStoreHas(uid: string, title: string) {
useCalendarStore.setState({
events: [{ id: 'e1', uid, title, start: '2026-11-10T10:00:00', utcStart: new Date(START).toISOString(), duration: 'PT30M', calendarIds: { c: true }, useDefaultAlerts: false, alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'AbsoluteTrigger', when: new Date(FIRE).toISOString() } } } }] as never,
});
}
it('makes the old path stand down once the store has this account', async () => {
await syncedWith(event());
// Not the same event as far as the claim can tell: only standing down keeps it quiet.
calendarStoreHas('another-uid', 'Old path');
renderHook(() => {
useCalendarAlerts();
useStoreReminders();
});
await advance(FIRE - Date.now() + 2 * MIN);
expect(notify).toHaveBeenCalledTimes(1);
expect(toastTitles()).toEqual([]);
});
it('lets the two paths tell each other what fired, for the moment the store takes over', async () => {
await syncedWith(event());
calendarStoreHas('uid-1', 'Standup');
const { claimKey, claimNow } = await import('@/lib/calendar-reminders/claims');
// The old path got there just before the store was ready.
expect(claimNow(claimKey(ACCOUNT, 'uid-1', 'a', FIRE), FIRE)).toBe(true);
renderHook(() => useStoreReminders());
await advance(FIRE - Date.now() + 2 * MIN);
expect(notify).not.toHaveBeenCalled();
});
});
describe('in a browser', () => {
function grantNotifications(showNotification = vi.fn(async () => {})) {
vi.stubGlobal('Notification', Object.assign(vi.fn(), { permission: 'granted' }));
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: { getRegistration: async () => ({ showNotification, getNotifications: async () => [] }) },
});
return showNotification;
}
afterEach(() => {
Reflect.deleteProperty(navigator, 'serviceWorker');
});
it('keeps the in-app toast, without desktop buttons', async () => {
await syncedWith(event(LINK));
render(<Toasts />);
renderHook(() => useStoreReminders());
await advance(FIRE - Date.now());
const toast = useToastStore.getState().toasts[0];
expect(toast.title).toBe('Standup');
expect(toast.action).toBeUndefined();
expect(toast.extraActions).toBeUndefined();
expect(sounds.play).toHaveBeenCalledTimes(1);
});
it('adds a system notification only where permission is granted and the tab is out of view', async () => {
await syncedWith(event());
const shown = grantNotifications();
setVisibility('hidden');
renderHook(() => useStoreReminders());
await advance(FIRE - Date.now() + 3000);
expect(toastTitles()).toEqual(['Standup']);
expect(shown).toHaveBeenCalledTimes(1);
expect(shown).toHaveBeenCalledWith('Standup', expect.objectContaining({ data: { kind: 'calendar-reminder' }, silent: false }));
});
it('asks for no permission and shows none when it was not granted', async () => {
await syncedWith(event());
const request = vi.fn();
vi.stubGlobal('Notification', Object.assign(vi.fn(), { permission: 'default', requestPermission: request }));
setVisibility('hidden');
renderHook(() => useStoreReminders());
await advance(FIRE - Date.now() + 3000);
expect(toastTitles()).toEqual(['Standup']);
expect(request).not.toHaveBeenCalled();
});
it('fires in one of two tabs', async () => {
await syncedWith(event());
renderHook(() => useStoreReminders());
renderHook(() => useStoreReminders());
await advance(FIRE - Date.now() + MIN);
expect(toastTitles()).toEqual(['Standup']);
});
it('closes the toast in this window when another acknowledges the reminder', async () => {
await syncedWith(event());
renderHook(() => useStoreReminders());
await advance(FIRE - Date.now());
expect(toastTitles()).toEqual(['Standup']);
const key = `${Object.keys(CALENDAR_SESSION.accounts)[0]}:uid-1:a:${FIRE}`;
const other = new BroadcastChannel('bulwark-calendar-reminders');
other.postMessage({ kind: 'acknowledged', keys: [key] });
other.close();
vi.useRealTimers();
await vi.waitFor(() => expect(toastTitles()).toEqual([]));
});
it('gives nothing to a pop-out', async () => {
popout.value = true;
await syncedWith(event());
const area = vi.spyOn(store, 'area');
renderHook(() => useStoreReminders());
await advance(FIRE - Date.now() + MIN);
expect(area).not.toHaveBeenCalled();
expect(toastTitles()).toEqual([]);
});
});
+85 -51
View File
@@ -2,7 +2,7 @@
import { useEffect, useRef, useCallback } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { parseISO } from 'date-fns';
import { format, parseISO } from 'date-fns';
import { isPopoutWindow } from '@/lib/popout/window-role';
import { useAuthStore } from '@/stores/auth-store';
import { useCalendarStore } from '@/stores/calendar-store';
@@ -11,10 +11,16 @@ import { useTaskStore } from '@/stores/task-store';
import { useCalendarNotificationStore } from '@/stores/calendar-notification-store';
import { useToastStore } from '@/stores/toast-store';
import { getPendingAlerts, getPendingTaskAlerts, getNextAlertFireTime, buildAlertKey } from '@/lib/calendar-alerts';
import { claimAlert, claimKey } from '@/lib/calendar-reminders/claims';
import { useReminderSource } from '@/lib/calendar-reminders/state';
import { showSystemReminder } from '@/lib/calendar-reminders/system-notification';
import { notifyWithoutStore } from '@/lib/desktop/reminder-notifications';
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
import { playNotificationSound } from '@/lib/notification-sound';
import { lastingToastDuration } from '@/lib/toast-duration';
import { startTitleAlert } from '@/lib/title-alert';
import { getPathPrefix } from '@/lib/browser-navigation';
import { getDesktopBridge } from '@/lib/desktop/bridge';
import type { CalendarEvent } from '@/lib/jmap/types';
import type { CalendarAlertView } from '@/lib/plugin-types';
import { calendarHooks } from '@/lib/plugin-hooks';
@@ -23,6 +29,8 @@ import { changesAnyType, subscribePushStateChanges } from '@/lib/push-events';
const CHECK_INTERVAL_MS = 60 * 1000;
const PROACTIVE_FETCH_HOURS = 24;
const PROACTIVE_THROTTLE_MS = CHECK_INTERVAL_MS * 5;
// Wall clocks: an occurrence's can be a day off the instant it's at.
const EXPAND_MARGIN_MS = 27 * 60 * 60 * 1000;
// Longest single wait for the next reminder; the periodic check covers the rest.
const MAX_WAKE_DELAY_MS = 6 * 60 * 60 * 1000;
/** How long a reminder stays at the shortest "Notification duration". */
@@ -42,7 +50,7 @@ export function reminderToastDuration(): number {
* Whether a reminder plays its sound. Do not disturb keeps reminders on
* screen (they were asked for) but silent.
*/
function reminderSoundOn(soundSetting: boolean): boolean {
export function reminderSoundOn(soundSetting: boolean): boolean {
return soundSetting && !useSettingsStore.getState().doNotDisturb;
}
@@ -51,7 +59,7 @@ function reminderSoundOn(soundSetting: boolean): boolean {
* is in the background. Reminders were asked for, so Do not disturb keeps
* this too.
*/
function reminderTitleAlert(text: string): void {
export function reminderTitleAlert(text: string): void {
if (useSettingsStore.getState().visualAlerts) startTitleAlert(text);
}
@@ -61,7 +69,7 @@ function reminderTitleAlert(text: string): void {
* itself does. onCalendarAlert fires either way. With no plugin asked, the
* reminder is shown at once.
*/
function remind(view: CalendarAlertView, show: () => void): void {
export function remind(view: CalendarAlertView, show: () => void): void {
void calendarHooks.onCalendarAlert.emit(view);
if (calendarHooks.onBeforeCalendarAlert.size === 0) {
show();
@@ -73,9 +81,11 @@ function remind(view: CalendarAlertView, show: () => void): void {
}
export function useCalendarAlerts() {
const { isAuthenticated: signedIn, client } = useAuthStore();
const { isAuthenticated: signedIn, client, activeAccountId } = useAuthStore();
// Reminders of an account with a local store come from there: useStoreReminders.
const fromStore = useReminderSource((s) => !!activeAccountId && !!s.driven[activeAccountId]);
// The main window rings; a pop-out would ring the same alert again.
const isAuthenticated = signedIn && !isPopoutWindow();
const isAuthenticated = signedIn && !isPopoutWindow() && !fromStore;
const { events, calendars, supportsCalendar } = useCalendarStore();
const { calendarNotificationsEnabled, calendarNotificationSound, enableCalendarTasks, notificationSoundChoice } = useSettingsStore();
const { tasks: storeTasks } = useTaskStore();
@@ -91,6 +101,15 @@ export function useCalendarAlerts() {
const wakeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const checkAlertsRef = useRef<() => void>(() => {});
// Beside the toast: the desktop app's native notification, a browser's system one.
const tellOutsideTheWindow = useCallback((key: string, title: string, body: string, silent: boolean) => {
if (getDesktopBridge()?.os) {
notifyWithoutStore(`reminder:${key}`, title, body, { generic: t('alert_title') }, activeAccountId);
return;
}
void showSystemReminder({ key, title, body, silent }, `${getPathPrefix(locale)}/${locale}/calendar`);
}, [t, locale, activeAccountId]);
const checkAlerts = useCallback(() => {
if (wakeTimerRef.current) {
clearTimeout(wakeTimerRef.current);
@@ -127,27 +146,33 @@ export function useCalendarAlerts() {
? `${timeLabel} · ${alert.calendarName}`
: timeLabel;
remind({
id: alert.alertId,
eventId: alert.eventId,
eventTitle: alert.event.title || '',
triggerTime: new Date(alert.fireTimeMs).toISOString(),
}, () => {
if (reminderSoundOn(calendarNotificationSound)) {
playNotificationSound(notificationSoundChoice);
}
reminderTitleAlert(t('title_alert', { title: alert.event.title || t('alert_title') }));
addToast({
type: 'info',
title: alert.event.title || t('alert_title'),
message,
duration: reminderToastDuration(),
onClick: () => {
window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
},
onDismiss: () => {
void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: alert.alertId, eventId: alert.eventId });
},
const uid = alert.event.uid || alert.event.originalId || alert.eventId;
claimAlert(claimKey(activeAccountId, uid, alert.alertId, alert.fireTimeMs), alert.fireTimeMs, () => {
remind({
id: alert.alertId,
eventId: alert.eventId,
eventTitle: alert.event.title || '',
triggerTime: new Date(alert.fireTimeMs).toISOString(),
}, () => {
const sound = reminderSoundOn(calendarNotificationSound);
if (sound) {
playNotificationSound(notificationSoundChoice);
}
const title = alert.event.title || t('alert_title');
reminderTitleAlert(t('title_alert', { title }));
addToast({
type: 'info',
title,
message,
duration: reminderToastDuration(),
onClick: () => {
window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
},
onDismiss: () => {
void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: alert.alertId, eventId: alert.eventId });
},
});
tellOutsideTheWindow(key, title, message, !sound);
});
});
}
@@ -166,27 +191,33 @@ export function useCalendarAlerts() {
? `${t('task_due')} · ${taskAlert.calendarName}`
: t('task_due');
remind({
id: taskAlert.alertId,
taskId: taskAlert.taskId,
taskTitle: taskAlert.task.title || '',
triggerTime: new Date(taskAlert.fireTimeMs).toISOString(),
}, () => {
if (reminderSoundOn(calendarNotificationSound)) {
playNotificationSound(notificationSoundChoice);
}
reminderTitleAlert(t('title_alert', { title: taskAlert.task.title || t('alert_title') }));
addToast({
type: 'info',
title: taskAlert.task.title || t('alert_title'),
message: taskMsg,
duration: reminderToastDuration(),
onClick: () => {
window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
},
onDismiss: () => {
void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: taskAlert.alertId, taskId: taskAlert.taskId });
},
const uid = taskAlert.task.uid || taskAlert.taskId;
claimAlert(claimKey(activeAccountId, uid, taskAlert.alertId, taskAlert.fireTimeMs), taskAlert.fireTimeMs, () => {
remind({
id: taskAlert.alertId,
taskId: taskAlert.taskId,
taskTitle: taskAlert.task.title || '',
triggerTime: new Date(taskAlert.fireTimeMs).toISOString(),
}, () => {
const sound = reminderSoundOn(calendarNotificationSound);
if (sound) {
playNotificationSound(notificationSoundChoice);
}
const title = taskAlert.task.title || t('alert_title');
reminderTitleAlert(t('title_alert', { title }));
addToast({
type: 'info',
title,
message: taskMsg,
duration: reminderToastDuration(),
onClick: () => {
window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
},
onDismiss: () => {
void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: taskAlert.alertId, taskId: taskAlert.taskId });
},
});
tellOutsideTheWindow(key, title, taskMsg, !sound);
});
});
}
@@ -196,8 +227,8 @@ export function useCalendarAlerts() {
}
}, [
calendarNotificationsEnabled, calendarNotificationSound, notificationSoundChoice,
isAuthenticated, events, calendars, acknowledgedAlerts,
acknowledgeAlert, addToast, t, locale, enableCalendarTasks, storeTasks,
isAuthenticated, activeAccountId, events, calendars, acknowledgedAlerts,
acknowledgeAlert, addToast, t, locale, enableCalendarTasks, storeTasks, tellOutsideTheWindow,
]);
checkAlertsRef.current = checkAlerts;
@@ -211,7 +242,10 @@ export function useCalendarAlerts() {
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;
// Without synthetic ids the server answers with a series' master, whose start can
// be years ago: only its occurrences have a fire time. Passes real occurrences through.
const stamp = (ms: number) => format(new Date(ms), "yyyy-MM-dd'T'HH:mm:ss");
proactiveEventsRef.current = expandRecurringEvents(fetched, stamp(now - EXPAND_MARGIN_MS), stamp(now + PROACTIVE_FETCH_HOURS * 60 * 60 * 1000 + EXPAND_MARGIN_MS));
lastProactiveFetchRef.current = Date.now();
checkAlertsRef.current();
} catch {
+278
View File
@@ -0,0 +1,278 @@
"use client";
import { useEffect, useRef } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { remind, reminderSoundOn, reminderTitleAlert, reminderToastDuration } from '@/hooks/use-calendar-alerts';
import { getPathPrefix } from '@/lib/browser-navigation';
import { onAcknowledged } from '@/lib/calendar-reminders/channel';
import { claimKey, claimNow } from '@/lib/calendar-reminders/claims';
import { createReminderRunner } from '@/lib/calendar-reminders/runner';
import { reminderTimeZone, setReminderPoke, useMissedReminders, useReminderSource } from '@/lib/calendar-reminders/state';
import { closeSystemReminders, showSystemReminder } from '@/lib/calendar-reminders/system-notification';
import { isAppLocked } from '@/lib/desktop/app-lock-state';
import { getDesktopBridge } from '@/lib/desktop/bridge';
import {
buildMissedNotification,
buildReminderNotification,
dismissReminders,
joinMeeting,
missedBody,
SNOOZE_MINUTES,
snoozeReminder,
type ReminderLabels,
} from '@/lib/desktop/reminder-notifications';
import { addWakeHandler } from '@/lib/desktop/system-events';
import { playNotificationSound } from '@/lib/notification-sound';
import { offlineActive } from '@/lib/offline/offline-mode';
import { visibleAccounts, useOfflineStore } from '@/lib/offline/state';
import { addStoreEventListener } from '@/lib/offline/store-events';
import { calendarHooks } from '@/lib/plugin-hooks';
import type { CalendarAlertView } from '@/lib/plugin-types';
import { isPopoutWindow } from '@/lib/popout/window-role';
import { getStore } from '@/lib/store/client/api';
import type { MissedBatch, ReminderAlert } from '@/lib/store/reminders/ledger';
import { useAuthStore } from '@/stores/auth-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useToastStore } from '@/stores/toast-store';
function viewOf(alert: ReminderAlert): CalendarAlertView {
const triggerTime = new Date(alert.fireTimeMs).toISOString();
return alert.kind === 'task'
? { id: alert.alertId, taskId: alert.baseEventId, taskTitle: alert.title, triggerTime }
: { id: alert.alertId, eventId: alert.baseEventId, eventTitle: alert.title, triggerTime };
}
function acknowledgeView(alert: ReminderAlert) {
return alert.kind === 'task'
? { alertId: alert.alertId, taskId: alert.baseEventId }
: { alertId: alert.alertId, eventId: alert.baseEventId };
}
/**
* Reminders from the local store: on time with no network, and in the desktop app with
* the window hidden. For accounts without a store useCalendarAlerts carries on as before.
*/
export function useStoreReminders(): void {
const signedIn = useAuthStore((s) => s.isAuthenticated);
const t = useTranslations('desktopReminders');
const tAlert = useTranslations('calendar.notifications');
const locale = useLocale();
const addToast = useToastStore((s) => s.addToast);
const removeToast = useToastStore((s) => s.removeToast);
const enabledKey = useOfflineStore((s) => s.enabled.join('\n'));
const labels: ReminderLabels = {
generic: t('generic_title'),
untitled: tAlert('alert_title'),
when: (alert) => {
const minutes = alert.startMs === null ? 0 : Math.round((alert.startMs - Date.now()) / 60_000);
const time = alert.kind === 'task'
? tAlert('task_due')
: minutes <= 0 ? tAlert('alert_now') : tAlert('alert_in_minutes', { count: minutes });
return alert.calendarName ? `${time} · ${alert.calendarName}` : time;
},
snooze: (minutes) => t('snooze', { count: minutes }),
snoozeShort: (minutes) => t('snooze_short', { count: minutes }),
open: t('open'),
join: t('join'),
missedTitle: (count) => t('missed_title', { count }),
missedMore: (count) => t('missed_more', { count }),
};
const labelsRef = useRef(labels);
labelsRef.current = labels;
const calendarUrl = `${getPathPrefix(locale)}/${locale}/calendar`;
const calendarUrlRef = useRef(calendarUrl);
calendarUrlRef.current = calendarUrl;
const runnerRef = useRef<ReturnType<typeof createReminderRunner> | null>(null);
useEffect(() => {
if (!signedIn || isPopoutWindow()) return;
const toastsByKey = new Map<string, string>();
const inAppReminder = (account: string, alert: ReminderAlert) => {
const current = labelsRef.current;
const desktop = !!getDesktopBridge()?.os;
// Set when a button says what happens next, so closing the toast doesn't undo it.
let handled = false;
const snoozeAction = (minutes: number) => ({
label: current.snooze(minutes),
onClick: () => {
handled = true;
void snoozeReminder(account, alert.key, minutes).catch(() => {});
},
});
const [five, ten, fifteen] = SNOOZE_MINUTES.map(snoozeAction);
const id = addToast({
type: 'info',
title: alert.title || current.untitled,
message: current.when(alert),
duration: reminderToastDuration(),
onClick: () => {
window.location.href = calendarUrlRef.current;
},
onDismiss: () => {
toastsByKey.delete(alert.key);
void calendarHooks.onCalendarAlertAcknowledge.emit(acknowledgeView(alert));
if (!handled) dismissReminders(account, [alert.key]);
},
...(desktop ? {
tertiaryAction: five,
secondaryAction: ten,
action: fifteen,
extraActions: [
...(alert.joinUrl ? [{ label: current.join, onClick: () => joinMeeting(alert.joinUrl!) }] : []),
{ label: current.open, onClick: () => { window.location.href = calendarUrlRef.current; } },
],
} : {}),
});
toastsByKey.set(alert.key, id);
};
const showReminder = async (account: string, alert: ReminderAlert) => {
const settings = useSettingsStore.getState();
const sound = reminderSoundOn(settings.calendarNotificationSound);
const os = getDesktopBridge()?.os;
const hold = () => getStore().area(account, 'reminders.hold', { keys: [alert.key] }).catch(() => {});
if (os) {
const locked = isAppLocked();
const result = await os.notify(await buildReminderNotification(account, alert, labelsRef.current, sound));
// Paused, quiet hours, or nothing may be said now: the summary says it later.
if (locked || result.reason === 'paused' || result.reason === 'quiet-hours') {
await hold();
return;
}
if (result.reason === 'duplicate') return;
if (result.playSound) playNotificationSound(settings.notificationSoundChoice);
if (result.shown) return;
inAppReminder(account, alert);
return;
}
if (sound) playNotificationSound(settings.notificationSoundChoice);
reminderTitleAlert(tAlert('title_alert', { title: alert.title || labelsRef.current.untitled }));
inAppReminder(account, alert);
void showSystemReminder({
key: alert.key,
title: alert.title || labelsRef.current.untitled,
body: labelsRef.current.when(alert),
silent: !sound,
}, calendarUrlRef.current);
};
const showMissed = async (account: string) => {
if (isAppLocked()) return;
const timeZone = reminderTimeZone(useSettingsStore.getState().timeZone);
const batch = await getStore().area<MissedBatch | null>(account, 'reminders.takeMissed', { timeZone });
if (!batch) return;
const current = labelsRef.current;
const os = getDesktopBridge()?.os;
if (os) {
const result = await os.notify(await buildMissedNotification(account, batch, current));
if (result.shown) return;
// Told nobody, so it has to wait for a time when it can.
if (result.reason === 'paused' || result.reason === 'quiet-hours' || result.reason === 'duplicate') {
await getStore().area(account, 'reminders.releaseMissed', { batch: batch.batch });
return;
}
}
const keys = batch.alerts.map((alert) => alert.key);
let opened = false;
const body = missedBody(batch.alerts, current);
const id = addToast({
type: 'info',
title: current.missedTitle(batch.alerts.length),
message: body,
duration: 0,
action: {
label: t('missed_show'),
onClick: () => {
opened = true;
useMissedReminders.getState().show(account, batch.alerts);
},
},
onDismiss: () => {
for (const key of keys) toastsByKey.delete(key);
if (!opened) dismissReminders(account, keys);
},
});
for (const key of keys) toastsByKey.set(key, id);
if (!os) {
void showSystemReminder({
key: `missed:${batch.batch}`,
title: current.missedTitle(batch.alerts.length),
body,
silent: true,
}, calendarUrlRef.current);
}
};
const runner = createReminderRunner({
accounts: () => visibleAccounts().filter((account) => offlineActive(account)),
store: () => getStore(),
params: () => {
const settings = useSettingsStore.getState();
return {
timeZone: reminderTimeZone(settings.timeZone),
tasks: settings.enableCalendarTasks,
muted: !settings.calendarNotificationsEnabled,
};
},
hidden: () => document.visibilityState === 'hidden',
offline: () => navigator.onLine === false,
onDue: (account, alerts) => {
for (const alert of alerts) {
// Whichever of the two paths got there first: one comes back from a snooze.
const key = claimKey(account, alert.uid, alert.alertId, alert.fireTimeMs);
if (!claimNow(key, alert.fireTimeMs) && !alert.snoozedUntil) continue;
remind(viewOf(alert), () => {
void showReminder(account, alert).catch((error) => {
console.error('Failed to show a reminder:', error);
});
});
}
},
onMissed: showMissed,
onSource: (account, driven) => useReminderSource.getState().setDriven(account, driven),
});
runnerRef.current = runner;
setReminderPoke(() => runner.poke());
runner.start();
const stopEvents = addStoreEventListener((event) => {
if (event.type === 'synced') runner.synced(event.account);
else if (event.type === 'changed' && event.collections.some((name) => name === 'CalendarEvent' || name === 'Calendar')) runner.poke();
});
const stopWake = addWakeHandler(() => runner.wake());
const onOnline = () => runner.wake();
const onVisible = () => {
if (document.visibilityState === 'visible') runner.poke();
};
window.addEventListener('online', onOnline);
document.addEventListener('visibilitychange', onVisible);
const stopAcks = onAcknowledged((keys) => {
for (const key of keys) {
const id = toastsByKey.get(key);
if (id) removeToast(id);
toastsByKey.delete(key);
}
void closeSystemReminders(keys);
});
return () => {
runner.stop();
runnerRef.current = null;
setReminderPoke(null);
stopEvents();
stopWake();
stopAcks();
window.removeEventListener('online', onOnline);
document.removeEventListener('visibilitychange', onVisible);
};
// Labels go through a ref: a language change mustn't restart the check.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [signedIn]);
// An account just turned on or off.
useEffect(() => {
runnerRef.current?.poke();
}, [enabledKey]);
}
@@ -0,0 +1,98 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { claimAlert, claimKey, claimNow, CLAIMS_STORAGE_KEY, HIDDEN_GRACE_MS } from '../claims';
const FIRE = Date.UTC(2026, 10, 10, 9);
function setVisibility(state: 'visible' | 'hidden') {
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state });
}
/** A Web Lock: one holder at a time, across "tabs". */
function fakeLocks() {
let tail: Promise<unknown> = Promise.resolve();
return {
request: vi.fn(<T,>(_name: string, callback: () => T | Promise<T>) => {
const run = tail.then(callback);
tail = run.catch(() => {});
return run;
}),
};
}
beforeEach(() => {
localStorage.clear();
setVisibility('visible');
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
Reflect.deleteProperty(navigator, 'locks');
});
describe('claimNow', () => {
it('says yes once', () => {
const key = claimKey('acc', 'uid-1', 'a', FIRE);
expect(claimNow(key, FIRE)).toBe(true);
expect(claimNow(key, FIRE)).toBe(false);
});
it('tells occurrences and accounts apart', () => {
expect(claimNow(claimKey('acc', 'uid-1', 'a', FIRE), FIRE)).toBe(true);
expect(claimNow(claimKey('acc', 'uid-1', 'a', FIRE + 1000), FIRE + 1000)).toBe(true);
expect(claimNow(claimKey('other', 'uid-1', 'a', FIRE), FIRE)).toBe(true);
});
it('forgets what is a day old', () => {
vi.useFakeTimers({ now: FIRE });
claimNow('old', FIRE - 25 * 3_600_000);
claimNow('new', FIRE);
expect(Object.keys(JSON.parse(localStorage.getItem(CLAIMS_STORAGE_KEY)!))).toEqual(['new']);
});
it('holds only the key, nothing of the event', () => {
claimNow(claimKey('acc', 'uid-1', 'a', FIRE), FIRE);
expect(localStorage.getItem(CLAIMS_STORAGE_KEY)).toBe(JSON.stringify({ [`acc:uid-1:a:${FIRE}`]: FIRE }));
});
});
describe('claimAlert', () => {
it('lets one of two tabs through when their timers run out together', async () => {
Object.defineProperty(navigator, 'locks', { configurable: true, value: fakeLocks() });
const won = vi.fn();
claimAlert('k', FIRE, () => won('tab 1'));
claimAlert('k', FIRE, () => won('tab 2'));
await vi.waitFor(() => expect(won).toHaveBeenCalled());
await new Promise((resolve) => setTimeout(resolve, 0));
expect(won.mock.calls).toEqual([['tab 1']]);
});
it('runs at once where there is no Web Lock', () => {
const won = vi.fn();
claimAlert('k', FIRE, won);
expect(won).toHaveBeenCalledTimes(1);
});
it('lets a tab in view go first', () => {
vi.useFakeTimers({ now: FIRE });
setVisibility('hidden');
const hidden = vi.fn();
claimAlert('k', FIRE, hidden);
setVisibility('visible');
const visible = vi.fn();
claimAlert('k', FIRE, visible);
expect(visible).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(HIDDEN_GRACE_MS);
expect(hidden).not.toHaveBeenCalled();
});
it('still fires in a hidden tab when no other took it', () => {
vi.useFakeTimers({ now: FIRE });
setVisibility('hidden');
const won = vi.fn();
claimAlert('k', FIRE, won);
expect(won).not.toHaveBeenCalled();
vi.advanceTimersByTime(HIDDEN_GRACE_MS);
expect(won).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,268 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CALENDAR_SESSION, FakeCalendarJmap } from '@/lib/store/__tests__/helpers/fake-calendar';
import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store';
import type { MissedBatch, ReminderAlert } from '@/lib/store/reminders/ledger';
import { HIDDEN_GRACE_MS } from '../claims';
import { createReminderRunner, SUMMARY_WAIT_MS, type ReminderRunner, type ReminderRunnerEnv } from '../runner';
const MIN = 60_000;
const ACCOUNT = 'acc';
const START = Date.UTC(2026, 10, 10, 9);
const FIRE = START - 15 * MIN;
const ALERT = { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } };
function event(partial: Record<string, unknown> = {}) {
return { id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', duration: 'PT30M', alerts: { a: ALERT }, ...partial };
}
interface Tab {
runner: ReminderRunner;
due: ReminderAlert[];
firedAt: number[];
missed: MissedBatch[];
source: boolean[];
env: ReminderRunnerEnv;
hidden: boolean;
offline: boolean;
muted: boolean;
}
let store: InProcessStore;
let server: FakeCalendarJmap;
const tabs: Tab[] = [];
function tab(options: Partial<Pick<Tab, 'hidden' | 'offline'>> = {}): Tab {
const made = { due: [], firedAt: [], missed: [], source: [], hidden: false, offline: false, muted: false, ...options } as unknown as Tab;
made.env = {
accounts: () => [ACCOUNT],
store: () => store,
params: () => ({ timeZone: 'Europe/Berlin', tasks: true, muted: made.muted }),
hidden: () => made.hidden,
offline: () => made.offline,
onDue: (_account, alerts) => {
made.due.push(...alerts);
made.firedAt.push(...alerts.map(() => Date.now()));
},
onMissed: async (account) => {
const batch = await store.area<MissedBatch | null>(account, 'reminders.takeMissed', { timeZone: 'Europe/Berlin' });
if (batch) made.missed.push(batch);
},
onSource: (_account, driven) => {
made.source.push(driven);
},
};
made.runner = createReminderRunner(made.env);
tabs.push(made);
return made;
}
async function syncedStore(...events: Record<string, unknown>[]): Promise<void> {
for (const partial of events) server.addEvent(partial);
await store.sync(ACCOUNT);
}
beforeEach(() => {
vi.useFakeTimers({ now: FIRE - 30 * MIN, toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] });
server = new FakeCalendarJmap('1.0');
store = new InProcessStore(server, CALENDAR_SESSION);
});
afterEach(() => {
for (const made of tabs.splice(0)) made.runner.stop();
vi.useRealTimers();
});
describe('firing from the store', () => {
it('fires at the moment it is due, with the network gone', async () => {
await syncedStore(event());
await store.setOnline(false);
server.request = (() => {
throw new Error('A reminder needs no server');
}) as unknown as FakeCalendarJmap['request'];
const one = tab();
one.runner.start();
await vi.advanceTimersByTimeAsync(FIRE - Date.now() - 1000);
expect(one.due).toEqual([]);
await vi.advanceTimersByTimeAsync(1000);
expect(one.due.map((alert) => alert.title)).toEqual(['Standup']);
expect(one.firedAt).toEqual([FIRE]);
});
it('takes over from the old path once the store has synced, and gives it back when the store fails', async () => {
const one = tab();
one.runner.start();
await vi.advanceTimersByTimeAsync(0);
expect(one.source.at(-1)).toBe(false);
await syncedStore(event());
one.runner.poke();
await vi.advanceTimersByTimeAsync(0);
expect(one.source.at(-1)).toBe(true);
store.failArea = () => true;
one.runner.poke();
await vi.advanceTimersByTimeAsync(0);
expect(one.source.at(-1)).toBe(false);
});
it('fires in one tab only, when both ask at the same moment', async () => {
await syncedStore(event());
const [one, two] = [tab(), tab()];
one.runner.start();
two.runner.start();
await vi.advanceTimersByTimeAsync(FIRE - Date.now() + MIN);
expect(one.due.length + two.due.length).toBe(1);
});
it('leaves it to the tab in view when another is hidden', async () => {
await syncedStore(event());
const hidden = tab({ hidden: true });
const visible = tab();
hidden.runner.start();
await vi.advanceTimersByTimeAsync(0);
visible.runner.start();
await vi.advanceTimersByTimeAsync(FIRE - Date.now() + HIDDEN_GRACE_MS + MIN);
expect(visible.due).toHaveLength(1);
expect(hidden.due).toHaveLength(0);
});
it('still fires when only a hidden tab is left', async () => {
await syncedStore(event());
const hidden = tab({ hidden: true });
hidden.runner.start();
await vi.advanceTimersByTimeAsync(FIRE - Date.now() + HIDDEN_GRACE_MS);
expect(hidden.due).toHaveLength(1);
});
it('notices an event that just arrived, and a snooze just set', async () => {
await syncedStore();
const one = tab();
one.runner.start();
await vi.advanceTimersByTimeAsync(FIRE - Date.now() - 2 * MIN);
await syncedStore(event());
one.runner.poke();
await vi.advanceTimersByTimeAsync(2 * MIN);
expect(one.firedAt).toEqual([FIRE]);
await store.area(ACCOUNT, 'reminders.snooze', { key: one.due[0].key, minutes: 5 });
one.runner.poke();
await vi.advanceTimersByTimeAsync(5 * MIN);
expect(one.firedAt).toEqual([FIRE, FIRE + 5 * MIN]);
});
it('brings a snoozed reminder back after a restart', async () => {
await syncedStore(event());
const before = tab();
before.runner.start();
await vi.advanceTimersByTimeAsync(FIRE - Date.now());
await store.area(ACCOUNT, 'reminders.snooze', { key: before.due[0].key, minutes: 10 });
before.runner.stop();
await vi.advanceTimersByTimeAsync(MIN);
const after = tab();
after.runner.start();
await vi.advanceTimersByTimeAsync(10 * MIN);
expect(before.due).toHaveLength(1);
expect(after.firedAt).toEqual([FIRE + 10 * MIN]);
expect(after.due[0].snoozedUntil).toBe(FIRE + 10 * MIN);
});
it('stays quiet while reminders are off', async () => {
await syncedStore(event());
const one = tab();
one.muted = true;
one.runner.start();
await vi.advanceTimersByTimeAsync(FIRE - Date.now() + MIN);
expect(one.due).toEqual([]);
});
});
describe('after being away', () => {
async function awayFromBothAlerts(): Promise<Tab> {
await syncedStore(event(), event({ id: 'e2', uid: 'uid-2', title: 'Review' }));
const before = tab();
before.runner.start();
await vi.advanceTimersByTimeAsync(0);
before.runner.stop();
vi.setSystemTime(FIRE + 60 * MIN);
return before;
}
it('waits a moment for the first sync, then summarises once, not as a burst', async () => {
await awayFromBothAlerts();
const one = tab();
one.runner.start();
await vi.advanceTimersByTimeAsync(SUMMARY_WAIT_MS - 1000);
expect(one.missed).toEqual([]);
await vi.advanceTimersByTimeAsync(1000);
expect(one.due).toEqual([]);
expect(one.missed.map((batch) => batch.alerts.map((alert) => alert.title))).toEqual([['Standup', 'Review']]);
await vi.advanceTimersByTimeAsync(30 * MIN);
one.runner.wake();
await vi.advanceTimersByTimeAsync(SUMMARY_WAIT_MS);
expect(one.missed).toHaveLength(1);
});
it('does not wait once the sync is done, and has nothing to wait for offline', async () => {
await awayFromBothAlerts();
const synced = tab();
synced.runner.start();
await vi.advanceTimersByTimeAsync(100);
synced.runner.synced(ACCOUNT);
await vi.advanceTimersByTimeAsync(0);
expect(synced.missed).toHaveLength(1);
});
it('gives the summary at once with no network', async () => {
await awayFromBothAlerts();
const offline = tab({ offline: true });
offline.runner.start();
await vi.advanceTimersByTimeAsync(0);
expect(offline.missed).toHaveLength(1);
});
it('gives the summary to one window of two, and neither again after a restart', async () => {
await awayFromBothAlerts();
const [one, two] = [tab({ offline: true }), tab({ offline: true })];
one.runner.start();
two.runner.start();
await vi.advanceTimersByTimeAsync(0);
expect(one.missed.length + two.missed.length).toBe(1);
one.runner.stop();
two.runner.stop();
await vi.advanceTimersByTimeAsync(MIN);
const next = tab({ offline: true });
next.runner.start();
await vi.advanceTimersByTimeAsync(SUMMARY_WAIT_MS);
expect(next.missed).toEqual([]);
});
it('sees a jump of the clock without being told, as after a sleep', async () => {
await syncedStore(event(), event({ id: 'e2', uid: 'uid-2', title: 'Review' }));
const one = tab();
one.runner.start();
await vi.advanceTimersByTimeAsync(MIN);
// Asleep: timers stood still and the clock did not.
vi.setSystemTime(FIRE + 60 * MIN);
await vi.advanceTimersByTimeAsync(MIN + SUMMARY_WAIT_MS);
expect(one.due).toEqual([]);
expect(one.missed.map((batch) => batch.alerts.length)).toEqual([1 + 1]);
});
it('tries a summary again that could not be shown', async () => {
await awayFromBothAlerts();
const one = tab({ offline: true });
let refuse = true;
const plain = one.env.onMissed;
one.env.onMissed = async (account) => {
if (!refuse) return plain(account);
const batch = await store.area<MissedBatch>(account, 'reminders.takeMissed', { timeZone: 'Europe/Berlin' });
await store.area(account, 'reminders.releaseMissed', { batch: batch.batch });
};
one.runner.start();
await vi.advanceTimersByTimeAsync(0);
expect(one.missed).toEqual([]);
refuse = false;
await vi.advanceTimersByTimeAsync(5 * MIN);
expect(one.missed).toHaveLength(1);
});
});
+32
View File
@@ -0,0 +1,32 @@
/**
* Telling the other windows that a reminder has been dealt with, so its toast and system
* notification go there too. What's done is recorded in the store; this only clears what's
* on screen. Only keys cross it, and they say nothing about the event but its uid.
*/
const CHANNEL_NAME = 'bulwark-calendar-reminders';
interface AcknowledgedMessage {
kind: 'acknowledged';
keys: string[];
}
function open(): BroadcastChannel | null {
return typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(CHANNEL_NAME);
}
export function announceAcknowledged(keys: readonly string[]): void {
const channel = open();
if (!channel) return;
channel.postMessage({ kind: 'acknowledged', keys: [...keys] } satisfies AcknowledgedMessage);
channel.close();
}
export function onAcknowledged(listener: (keys: string[]) => void): () => void {
const channel = open();
if (!channel) return () => {};
channel.onmessage = (event: MessageEvent<AcknowledgedMessage>) => {
if (event.data?.kind === 'acknowledged' && Array.isArray(event.data.keys)) listener(event.data.keys);
};
return () => channel.close();
}
+70
View File
@@ -0,0 +1,70 @@
/**
* One tab fires a reminder when there's no local store to decide it. The list sits in
* localStorage and holds no more than the event's uid, and the Web Lock makes reading
* and writing it one step across tabs: their timers run out at the same moment, so a
* plain read and write would let both through.
*
* Keys are the series' uid, never the id: synthetic ids reshuffle when an override
* changes, and a reminder fired again inside its ten minutes.
*/
const STORAGE_KEY = 'calendar-alert-claims';
const LOCK = 'bulwark-calendar-alert-claim';
const KEEP_MS = 24 * 60 * 60 * 1000;
/** A tab out of view waits, so the one in view is who shows the reminder. */
export const HIDDEN_GRACE_MS = 2000;
export function claimKey(account: string | null | undefined, uid: string, alertId: string, fireTimeMs: number): string {
return `${account ?? ''}:${uid}:${alertId}:${fireTimeMs}`;
}
function readClaims(): Record<string, number> {
try {
const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as unknown;
return parsed && typeof parsed === 'object' ? parsed as Record<string, number> : {};
} catch {
return {};
}
}
/** True for the first to ask. Also what the store path tells the other path with. */
export function claimNow(key: string, fireTimeMs: number): boolean {
const claims = readClaims();
if (key in claims) return false;
const now = Date.now();
const kept = Object.fromEntries(Object.entries(claims).filter(([, fire]) => now - fire < KEEP_MS));
kept[key] = fireTimeMs;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(kept));
} catch {
// Without storage each tab is on its own, as it always was.
}
return true;
}
/** Runs `won` in the one tab that gets the reminder. At once, when nothing makes it wait. */
export function claimAlert(key: string, fireTimeMs: number, won: () => void): void {
const attempt = () => {
const locks = typeof navigator === 'undefined' ? undefined : navigator.locks;
if (!locks) {
if (claimNow(key, fireTimeMs)) won();
return;
}
void locks.request(LOCK, () => claimNow(key, fireTimeMs)).then((claimed) => {
if (claimed) won();
});
};
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') setTimeout(attempt, HIDDEN_GRACE_MS);
else attempt();
}
/** A sign-out: the next person starts with a clean slate. */
export function clearClaims(): void {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// Nothing was kept, then.
}
}
export const CLAIMS_STORAGE_KEY = STORAGE_KEY;
+156
View File
@@ -0,0 +1,156 @@
/**
* Asks the local store what's due, at the moment it's due. Runs in the page: in the
* desktop app that's the main window, which keeps its timers while hidden in the tray,
* and the store's worker answers with no network. Every tab or window may run one: the
* store hands each alert to whichever asks first.
*
* Between checks it sleeps until the store's next fire time, and looks in every few
* minutes in case something changed with nobody saying so.
*/
import type { StoreBackend } from '@/lib/store/client/api';
import type { ReminderAlert, TickParams, TickResult } from '@/lib/store/reminders/ledger';
import { HIDDEN_GRACE_MS } from './claims';
const REFRESH_MS = 5 * 60_000;
/** Timers stand still while a machine sleeps. The clock doesn't. */
const CLOCK_WATCH_MS = 60_000;
const CLOCK_JUMP_MS = 2 * CLOCK_WATCH_MS;
/**
* After a start or a wake, how long a summary waits for the first sync, so it leaves out
* what was deleted elsewhere in the meantime. Reminders that are due don't wait.
*/
export const SUMMARY_WAIT_MS = 20_000;
export interface ReminderRunnerEnv {
/** The local accounts with a store right now. */
accounts(): string[];
store(): Pick<StoreBackend, 'area'>;
params(): TickParams;
/** Out of view: another tab in view should get to go first. */
hidden(): boolean;
offline(): boolean;
/** Runs in the one tab that got these. */
onDue(account: string, alerts: ReminderAlert[]): void;
/** There's a summary to give. Resolves when that's been tried. */
onMissed(account: string): Promise<void>;
/** Whether this account's reminders come from the store now, or the app's old path keeps them. */
onSource(account: string, driven: boolean): void;
}
export interface ReminderRunner {
start(): void;
stop(): void;
/** Check now: the calendar changed, a snooze was set. */
poke(): void;
/** Back from sleep, or back online. */
wake(): void;
/** The store finished a sync for this account. */
synced(account: string): void;
}
export function createReminderRunner(env: ReminderRunnerEnv): ReminderRunner {
let timer: ReturnType<typeof setTimeout> | null = null;
let clockWatch: ReturnType<typeof setInterval> | null = null;
let lastWatch = 0;
let running = false;
let again = false;
let stopped = true;
let summaryAt = 0;
const presenting = new Set<string>();
const known = new Set<string>();
const schedule = (at: number) => {
if (timer) clearTimeout(timer);
const wait = Math.max(at - Date.now(), 0) + (env.hidden() ? HIDDEN_GRACE_MS : 0);
timer = setTimeout(() => void run(), wait);
};
const check = async (account: string): Promise<number | null> => {
let result: TickResult;
try {
result = await env.store().area<TickResult>(account, 'reminders.tick', env.params());
} catch {
// The store is out of reach: the old path takes these over until it's back.
env.onSource(account, false);
return null;
}
env.onSource(account, result.ready);
if (result.due.length > 0) env.onDue(account, result.due);
if (result.missed === 0) return result.nextAt;
const wait = env.offline() ? 0 : summaryAt - Date.now();
if (wait > 0) return result.nextAt === null ? summaryAt : Math.min(result.nextAt, summaryAt);
if (!presenting.has(account)) {
presenting.add(account);
void env.onMissed(account)
.catch(() => {})
.finally(() => presenting.delete(account));
}
return result.nextAt;
};
const run = async () => {
if (stopped) return;
if (running) {
again = true;
return;
}
running = true;
let next = Date.now() + REFRESH_MS;
const accounts = env.accounts();
for (const gone of [...known]) {
if (!accounts.includes(gone)) {
known.delete(gone);
env.onSource(gone, false);
}
}
for (const account of accounts) {
known.add(account);
const at = await check(account);
if (at !== null) next = Math.min(next, at);
}
running = false;
if (stopped) return;
if (again) {
again = false;
void run();
return;
}
schedule(next);
};
return {
start() {
stopped = false;
summaryAt = Date.now() + SUMMARY_WAIT_MS;
lastWatch = Date.now();
clockWatch = setInterval(() => {
const now = Date.now();
const jumped = now - lastWatch > CLOCK_JUMP_MS;
lastWatch = now;
if (jumped) this.wake();
}, CLOCK_WATCH_MS);
void run();
},
stop() {
stopped = true;
if (timer) clearTimeout(timer);
if (clockWatch) clearInterval(clockWatch);
timer = null;
clockWatch = null;
for (const account of known) env.onSource(account, false);
known.clear();
},
poke() {
void run();
},
wake() {
summaryAt = Date.now() + SUMMARY_WAIT_MS;
void run();
},
synced() {
summaryAt = 0;
void run();
},
};
}
+55
View File
@@ -0,0 +1,55 @@
/**
* What the reminder code shares across the page, kept free of the rest of the app so
* useCalendarAlerts can read it without pulling in the local store.
*/
import { create } from 'zustand';
import type { ReminderAlert } from '@/lib/store/reminders/ledger';
interface ReminderSourceState {
/** Local accounts whose reminders come from the store: useCalendarAlerts leaves those alone. */
driven: Record<string, true>;
setDriven: (account: string, driven: boolean) => void;
}
export const useReminderSource = create<ReminderSourceState>()((set) => ({
driven: {},
setDriven: (account, driven) => set((state) => {
if (!!state.driven[account] === driven) return state;
const next = { ...state.driven };
if (driven) next[account] = true;
else delete next[account];
return { driven: next };
}),
}));
interface MissedRemindersState {
account: string | null;
alerts: ReminderAlert[];
show: (account: string, alerts: ReminderAlert[]) => void;
close: () => void;
}
/** The summary dialog's contents. Empty: it's closed. */
export const useMissedReminders = create<MissedRemindersState>()((set) => ({
account: null,
alerts: [],
show: (account, alerts) => set({ account, alerts }),
close: () => set({ account: null, alerts: [] }),
}));
let poke: (() => void) | null = null;
/** The running check, so something done from a toast takes effect at once. */
export function setReminderPoke(next: (() => void) | null): void {
poke = next;
}
export function pokeReminders(): void {
poke?.();
}
/** The zone that reads a floating event: the setting's, or the system's on `auto`. */
export function reminderTimeZone(setting: string | undefined): string {
return setting && setting !== 'auto' ? setting : Intl.DateTimeFormat().resolvedOptions().timeZone;
}
@@ -0,0 +1,70 @@
/**
* A browser's system notification for a reminder, where the user has already granted the
* permission. This never asks for it. Through the service worker where there is one, since
* Chrome on Android refuses `new Notification`. The tag keeps one per reminder, whichever
* tab asks.
*/
export interface SystemReminder {
key: string;
title: string;
body: string;
/** Do not disturb: on screen, but quiet. */
silent: boolean;
}
function tagOf(key: string): string {
return `calendar-reminder:${key}`;
}
function permitted(): boolean {
return typeof Notification !== 'undefined' && Notification.permission === 'granted';
}
async function registration(): Promise<ServiceWorkerRegistration | undefined> {
try {
return await navigator.serviceWorker?.getRegistration();
} catch {
return undefined;
}
}
/** In front of the user the in-app toast says it already. */
export function wantsSystemNotification(): boolean {
if (!permitted() || typeof document === 'undefined') return false;
return document.visibilityState === 'hidden' || !document.hasFocus();
}
export async function showSystemReminder(reminder: SystemReminder, openUrl: string): Promise<void> {
if (!wantsSystemNotification()) return;
const options = { body: reminder.body, tag: tagOf(reminder.key), silent: reminder.silent };
try {
const worker = await registration();
if (worker) {
await worker.showNotification(reminder.title, { ...options, data: { kind: 'calendar-reminder' } });
return;
}
const shown = new Notification(reminder.title, options);
shown.onclick = () => {
window.focus();
window.location.href = openUrl;
shown.close();
};
} catch (error) {
console.warn('A system notification could not be shown:', error);
}
}
/** Dealt with elsewhere: it shouldn't stay in the notification list. */
export async function closeSystemReminders(keys: readonly string[]): Promise<void> {
if (!permitted() || keys.length === 0) return;
const worker = await registration();
if (!worker) return;
try {
for (const key of keys) {
for (const shown of await worker.getNotifications({ tag: tagOf(key) })) shown.close();
}
} catch {
// Nothing that matters is left behind.
}
}
@@ -0,0 +1,239 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useMissedReminders } from '@/lib/calendar-reminders/state';
import { CALENDAR_SESSION, FakeCalendarJmap } from '@/lib/store/__tests__/helpers/fake-calendar';
import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store';
import { setStoreBackend } from '@/lib/store/client/api';
import type { ReminderAlert, TickResult } from '@/lib/store/reminders/ledger';
import { setAppLockCheck } from '../app-lock-state';
import {
buildMissedNotification,
buildReminderNotification,
handleReminderCommand,
missedBody,
notifyWithoutStore,
type ReminderLabels,
} from '../reminder-notifications';
import type { BulwarkDesktopBridge } from '../types';
const ACCOUNT = 'alice@one';
const accounts = [{ id: ACCOUNT, cookieSlot: 3, serverUrl: 'https://one.example', username: 'alice@example.org' }];
let activeAccountId: string | null = ACCOUNT;
vi.mock('@/stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts }) } }));
vi.mock('@/stores/auth-store', () => ({ useAuthStore: { getState: () => ({ activeAccountId }) } }));
const policy = vi.hoisted(() => ({ pushWithoutContent: false }));
vi.mock('@/lib/device-policy', () => ({ devicesForAccount: () => ({ pushWithoutContent: policy.pushWithoutContent }) }));
const MIN = 60_000;
const START = Date.UTC(2026, 10, 10, 9);
const FIRE = START - 15 * MIN;
const labels: ReminderLabels = {
generic: 'GENERIC',
when: () => 'In 15 min · Personal',
untitled: 'UNTITLED',
snooze: (minutes) => `Snooze ${minutes} min`,
snoozeShort: (minutes) => `${minutes} min`,
open: 'Open',
join: 'Join',
missedTitle: (count) => `${count} missed`,
missedMore: (count) => `and ${count} more`,
};
function alert(partial: Partial<ReminderAlert> = {}): ReminderAlert {
return {
key: `j1:uid-1:a:${FIRE}`, jmap: 'j1', uid: 'uid-1', kind: 'event', baseEventId: 'e1', recurrenceId: null, alertId: 'a', fireTimeMs: FIRE,
title: 'Secret board meeting', start: '2026-11-10T10:00:00', startMs: START, joinUrl: null, timeZone: 'Europe/Berlin', calendarId: 'cal1', calendarName: 'Personal',
...partial,
};
}
const focus = vi.fn();
const openExternal = vi.fn();
const notify = vi.fn(async () => ({ shown: true, playSound: false }));
function setPlatform(platform: string) {
window.bulwarkDesktop = { version: 1, platform, windows: { focus }, shell: { openExternal }, os: { notify } } as unknown as BulwarkDesktopBridge;
}
let store: InProcessStore;
let server: FakeCalendarJmap;
const env = { leaveFor: vi.fn(), push: vi.fn() };
beforeEach(() => {
vi.clearAllMocks();
policy.pushWithoutContent = false;
activeAccountId = ACCOUNT;
setAppLockCheck(null);
setPlatform('win32');
useMissedReminders.getState().close();
server = new FakeCalendarJmap('1.0');
store = new InProcessStore(server, CALENDAR_SESSION);
setStoreBackend(store);
});
afterEach(() => {
setStoreBackend(null);
window.bulwarkDesktop = undefined;
setAppLockCheck(null);
vi.useRealTimers();
});
async function contextFor(reminder: ReminderAlert): Promise<string> {
return (await buildReminderNotification(ACCOUNT, reminder, labels, false)).context;
}
describe('what a reminder says', () => {
it('offers Join only when the event has a link', async () => {
const without = await buildReminderNotification(ACCOUNT, alert(), labels, false);
expect(without.actions!.map((action) => action.id)).toEqual(['open', 'snooze-5', 'snooze-10', 'snooze-15']);
const withLink = await buildReminderNotification(ACCOUNT, alert({ joinUrl: 'https://meet.jit.si/x' }), labels, false);
expect(withLink.actions!.map((action) => action.id)).toEqual(['join', 'open', 'snooze-5', 'snooze-10', 'snooze-15']);
});
it('uses short snooze labels where five buttons share a row', async () => {
const windows = await buildReminderNotification(ACCOUNT, alert(), labels, false);
expect(windows.actions!.find((action) => action.id === 'snooze-5')!.label).toBe('5 min');
setPlatform('darwin');
const mac = await buildReminderNotification(ACCOUNT, alert(), labels, false);
expect(mac.actions!.find((action) => action.id === 'snooze-5')!.label).toBe('Snooze 5 min');
});
it('sends nothing of the event while the app is locked: no title, no time, no key, no buttons', async () => {
setAppLockCheck(() => true);
const locked = await buildReminderNotification(ACCOUNT, alert({ joinUrl: 'https://meet.jit.si/x' }), labels, true);
expect(locked).toMatchObject({ title: 'GENERIC', body: '', context: '', kind: 'reminder' });
expect(locked.actions).toBeUndefined();
expect(JSON.stringify(locked)).not.toContain('Secret');
expect(JSON.stringify(locked)).not.toContain('uid-1');
});
it('keeps the title off when the organisation says so, the buttons stay', async () => {
policy.pushWithoutContent = true;
const hidden = await buildReminderNotification(ACCOUNT, alert(), labels, false);
expect(hidden).toMatchObject({ title: 'GENERIC', body: '' });
expect(hidden.actions).toHaveLength(4);
});
it('gets a new id when it comes back from a snooze, since main drops a repeat', async () => {
const first = await buildReminderNotification(ACCOUNT, alert(), labels, false);
const back = await buildReminderNotification(ACCOUNT, alert({ snoozedUntil: FIRE + 5 * MIN }), labels, false);
expect(back.id).not.toBe(first.id);
});
it('falls back on a neutral title for an event without one', async () => {
expect((await buildReminderNotification(ACCOUNT, alert({ title: '' }), labels, false)).title).toBe('UNTITLED');
});
it('keeps titles out of a summary when locked or forbidden, and names three at most', async () => {
const alerts = ['A', 'B', 'C', 'D', 'E'].map((title) => alert({ title, key: title }));
expect(missedBody(alerts, labels)).toBe('A, B, C, and 2 more');
const open = await buildMissedNotification(ACCOUNT, { batch: 7, alerts }, labels);
expect(open).toMatchObject({ title: '5 missed', body: 'A, B, C, and 2 more' });
setAppLockCheck(() => true);
expect((await buildMissedNotification(ACCOUNT, { batch: 7, alerts }, labels)).body).toBe('');
});
it('without a store says what it can, and nothing when locked', () => {
notifyWithoutStore('id', 'Dentist', 'In 5 min', { generic: 'GENERIC' }, ACCOUNT);
expect(notify).toHaveBeenLastCalledWith(expect.objectContaining({ title: 'Dentist', body: 'In 5 min', context: '{"cal":true}' }));
setAppLockCheck(() => true);
notifyWithoutStore('id2', 'Dentist', 'In 5 min', { generic: 'GENERIC' }, ACCOUNT);
expect(notify).toHaveBeenLastCalledWith(expect.objectContaining({ title: 'GENERIC', body: '' }));
});
});
describe('what its buttons do', () => {
/** A reminder really on record in the store. */
async function fired(partial: Record<string, unknown> = {}): Promise<ReminderAlert> {
vi.useFakeTimers({ now: FIRE - MIN, toFake: ['Date'] });
server.addEvent({ id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } }, ...partial });
await store.sync(ACCOUNT);
const params = { timeZone: 'Europe/Berlin', tasks: true };
await store.area(ACCOUNT, 'reminders.tick', params);
vi.setSystemTime(FIRE);
return (await store.area<TickResult>(ACCOUNT, 'reminders.tick', params)).due[0];
}
it('snoozes from the store with the window left hidden', async () => {
const reminder = await fired();
expect(await handleReminderCommand('snooze-10', await contextFor(reminder), env)).toBe(true);
expect(focus).not.toHaveBeenCalled();
expect(env.leaveFor).not.toHaveBeenCalled();
const params = { timeZone: 'Europe/Berlin', tasks: true };
vi.setSystemTime(FIRE + 9 * MIN);
expect((await store.area<TickResult>(ACCOUNT, 'reminders.tick', params)).due).toEqual([]);
vi.setSystemTime(FIRE + 10 * MIN);
expect((await store.area<TickResult>(ACCOUNT, 'reminders.tick', params)).due).toHaveLength(1);
});
it('refuses a snooze length no button offers', async () => {
const reminder = await fired();
await handleReminderCommand('snooze-999', await contextFor(reminder), env);
expect(focus).toHaveBeenCalled();
const params = { timeZone: 'Europe/Berlin', tasks: true };
vi.setSystemTime(FIRE + 16 * MIN);
expect((await store.area<TickResult>(ACCOUNT, 'reminders.tick', params)).due).toEqual([]);
});
it('joins through the OS from what the store holds, with the window left hidden', async () => {
const reminder = await fired({ virtualLocations: { v: { '@type': 'VirtualLocation', uri: 'https://meet.jit.si/standup' } } });
await handleReminderCommand('join', await contextFor(reminder), env);
expect(openExternal).toHaveBeenCalledWith('https://meet.jit.si/standup');
expect(focus).not.toHaveBeenCalled();
});
it('opens the event in the account on screen', async () => {
const reminder = await fired();
await handleReminderCommand('open', await contextFor(reminder), env);
expect(focus).toHaveBeenCalled();
expect(env.leaveFor).toHaveBeenCalledWith('/calendar');
expect(env.push).toHaveBeenCalledWith('/calendar/event/e1?calendar=cal1');
});
it('opens only the calendar for an account that is not on screen', async () => {
const reminder = await fired();
const context = await contextFor(reminder);
activeAccountId = 'someone-else';
await handleReminderCommand('open', context, env);
expect(env.leaveFor).toHaveBeenCalledWith('/calendar');
expect(env.push).not.toHaveBeenCalled();
});
it('touches nothing when the slot has gone to another login since', async () => {
const reminder = await fired();
const context = JSON.parse(await contextFor(reminder)) as { login: string };
context.login = 'not-this-login';
await handleReminderCommand('snooze-5', JSON.stringify(context), env);
expect(env.leaveFor).toHaveBeenCalledWith('/calendar');
const params = { timeZone: 'Europe/Berlin', tasks: true };
vi.setSystemTime(FIRE + 5 * MIN);
expect((await store.area<TickResult>(ACCOUNT, 'reminders.tick', params)).due).toEqual([]);
});
it('opens the summary from a click on its toast, even after a restart', async () => {
vi.useFakeTimers({ now: FIRE - MIN, toFake: ['Date'] });
server.addEvent({ id: 'e1', uid: 'uid-1', title: 'One', alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } } });
server.addEvent({ id: 'e2', uid: 'uid-2', title: 'Two', alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } } });
await store.sync(ACCOUNT);
const params = { timeZone: 'Europe/Berlin', tasks: true };
await store.area(ACCOUNT, 'reminders.tick', params);
vi.setSystemTime(FIRE + 60 * MIN);
await store.area(ACCOUNT, 'reminders.tick', params);
const batch = await store.area<{ batch: number; alerts: ReminderAlert[] }>(ACCOUNT, 'reminders.takeMissed', params);
const toast = await buildMissedNotification(ACCOUNT, batch, labels);
await handleReminderCommand('open', toast.context, env);
expect(focus).toHaveBeenCalled();
expect(useMissedReminders.getState().alerts.map((a) => a.title)).toEqual(['One', 'Two']);
expect(useMissedReminders.getState().account).toBe(ACCOUNT);
});
it('goes to the calendar for a store-less reminder, and leaves mail toasts alone', async () => {
expect(await handleReminderCommand('open', JSON.stringify({ cal: true }), env)).toBe(true);
expect(env.leaveFor).toHaveBeenCalledWith('/calendar');
expect(await handleReminderCommand('open', JSON.stringify({ emailId: 'm1', slot: 3 }), env)).toBe(false);
expect(await handleReminderCommand('open', '', env)).toBe(false);
expect(await handleReminderCommand('open', '{broken', env)).toBe(false);
});
});
+2
View File
@@ -8,6 +8,7 @@
import { getDesktopBridge } from '@/lib/desktop/bridge';
import type { DesktopOsCommand } from '@/lib/desktop/types';
import { openMailLink } from '@/lib/desktop/mail-links';
import { handleReminderCommand } from '@/lib/desktop/reminder-notifications';
import type { MailNotificationContext } from '@/lib/desktop/mail-notifications';
import { handOffDraft, handOffFiles, handOffNewMessage, kindForFile } from '@/lib/launch-handoff';
import { parseMailto } from '@/lib/protocol-handlers/mailto';
@@ -89,6 +90,7 @@ function runAction(request: NotificationActionRequest): Promise<NotificationActi
}
async function handleNotification(action: string, rawContext: string, reply: string | undefined, env: OsCommandEnv): Promise<void> {
if (await handleReminderCommand(action, rawContext, env)) return;
const context = parseContext(rawContext);
if (action === 'reply') {
await openFromNotification(context, env, { text: reply ?? '' });
+218
View File
@@ -0,0 +1,218 @@
/**
* Calendar reminders as native notifications, and what their buttons do.
*
* A toast may be clicked a day later and after a restart, so what it hands back says
* which login and which reminder and no more: the rest comes from the store. Main keeps
* pause, quiet hours and the OS's Do Not Disturb. Nothing about the event goes to main at
* all while the app is locked, or when the organisation says so for that login
* (pushWithoutContent, the rule new mail follows).
*/
import { announceAcknowledged } from '@/lib/calendar-reminders/channel';
import { pokeReminders, useMissedReminders } from '@/lib/calendar-reminders/state';
import { getDesktopBridge } from '@/lib/desktop/bridge';
import { isAppLocked } from '@/lib/desktop/app-lock-state';
import type { DesktopOsNotification } from '@/lib/desktop/types';
import { devicesForAccount } from '@/lib/device-policy';
import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
import { getStore } from '@/lib/store/client/api';
import type { MissedBatch, ReminderAlert } from '@/lib/store/reminders/ledger';
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
export const SNOOZE_MINUTES = [5, 10, 15] as const;
const SNOOZE_PREFIX = 'snooze-';
const JOIN = 'join';
const OPEN = 'open';
/** How many titles a summary names. */
const SUMMARY_TITLES = 3;
export interface ReminderLabels {
/** Says nothing about the event. */
generic: string;
/** "In 10 min · Personal". */
when: (alert: ReminderAlert) => string;
untitled: string;
snooze: (minutes: number) => string;
/** Five buttons in a row on a Windows toast: three "Snooze..." would all cut off. */
snoozeShort: (minutes: number) => string;
open: string;
join: string;
missedTitle: (count: number) => string;
missedMore: (count: number) => string;
}
export interface ReminderToastContext {
/** The reminder. */
r?: string;
/** The summary. */
rb?: number;
/** No store to ask: the click only goes to the calendar. */
cal?: boolean;
slot?: number;
login?: string;
}
async function loginOf(account: string): Promise<Pick<ReminderToastContext, 'slot' | 'login'>> {
const entry = useAccountStore.getState().accounts.find((candidate) => candidate.id === account);
if (!entry) return {};
return { slot: entry.cookieSlot, login: await loginFingerprint(entry.serverUrl, entry.username) };
}
function hideContent(account: string): boolean {
if (isAppLocked()) return true;
try {
return devicesForAccount(account).pushWithoutContent;
} catch {
return true;
}
}
function reminderId(alert: ReminderAlert): string {
// Main drops an id it saw in the last few minutes, and a snooze can be shorter.
return `reminder:${alert.key}${alert.snoozedUntil ? `:${alert.snoozedUntil}` : ''}`;
}
export async function buildReminderNotification(account: string, alert: ReminderAlert, labels: ReminderLabels, sound: boolean): Promise<DesktopOsNotification> {
const base = { group: 'reminders', kind: 'reminder' as const, sound };
// Not even the uid. Two at the same moment are one "you have a reminder".
if (isAppLocked()) return { ...base, id: `reminder-locked:${alert.fireTimeMs}`, title: labels.generic, body: '', context: '' };
const context: ReminderToastContext = { r: alert.key, ...(await loginOf(account)) };
const short = getDesktopBridge()?.platform === 'win32';
const actions = [
...(alert.joinUrl ? [{ id: JOIN, label: labels.join }] : []),
{ id: OPEN, label: labels.open },
...SNOOZE_MINUTES.map((minutes) => ({ id: `${SNOOZE_PREFIX}${minutes}`, label: short ? labels.snoozeShort(minutes) : labels.snooze(minutes) })),
];
const hidden = hideContent(account);
return {
...base,
id: reminderId(alert),
title: hidden ? labels.generic : alert.title || labels.untitled,
body: hidden ? '' : labels.when(alert),
context: JSON.stringify(context),
actions,
};
}
/** The first few titles of a summary, for a place too small for all of them. */
export function missedBody(alerts: readonly ReminderAlert[], labels: Pick<ReminderLabels, 'untitled' | 'missedMore'>): string {
const named = alerts.slice(0, SUMMARY_TITLES).map((alert) => alert.title || labels.untitled).join(', ');
const more = alerts.length - SUMMARY_TITLES;
return more > 0 ? `${named}, ${labels.missedMore(more)}` : named;
}
export async function buildMissedNotification(account: string, batch: MissedBatch, labels: ReminderLabels): Promise<DesktopOsNotification> {
const context: ReminderToastContext = { rb: batch.batch, ...(await loginOf(account)) };
return {
id: `reminder-missed:${batch.batch}`,
group: 'reminders',
kind: 'reminder',
title: labels.missedTitle(batch.alerts.length),
body: hideContent(account) ? '' : missedBody(batch.alerts, labels),
context: JSON.stringify(context),
};
}
/** Without a store: says so, with nothing to snooze. A click goes to the calendar. */
export function notifyWithoutStore(id: string, title: string, body: string, labels: { generic: string }, account: string | null): void {
const hidden = isAppLocked() || !account || hideContent(account);
void getDesktopBridge()?.os?.notify({
id,
group: 'reminders',
kind: 'reminder',
title: hidden ? labels.generic : title,
body: hidden ? '' : body,
context: JSON.stringify({ cal: true } satisfies ReminderToastContext),
});
}
async function accountOf(context: ReminderToastContext): Promise<string | null> {
if (context.slot === undefined || !context.login) return null;
const entry = useAccountStore.getState().accounts.find((account) => account.cookieSlot === context.slot);
if (!entry || (await loginFingerprint(entry.serverUrl, entry.username)) !== context.login) return null;
return entry.id;
}
/** Goes to the event when it's in the account on screen, otherwise to the calendar. */
export function calendarPathFor(account: string, alert: ReminderAlert | null): string | null {
if (!alert || alert.kind !== 'event' || useAuthStore.getState().activeAccountId !== account) return null;
const query = alert.calendarId ? `?calendar=${encodeURIComponent(alert.calendarId)}` : '';
return `/calendar/event/${encodeURIComponent(alert.baseEventId)}${query}`;
}
export function joinMeeting(url: string): void {
const shell = getDesktopBridge()?.shell;
if (shell) shell.openExternal(url);
else window.open(url, '_blank', 'noopener,noreferrer');
}
export function dismissReminders(account: string, keys: string[]): void {
announceAcknowledged(keys);
void getStore().area(account, 'reminders.dismiss', { keys }).catch(() => {});
}
export async function snoozeReminder(account: string, key: string, minutes: number): Promise<void> {
announceAcknowledged([key]);
await getStore().area<boolean>(account, 'reminders.snooze', { key, minutes });
pokeReminders();
}
export interface ReminderCommandEnv {
leaveFor: (path: '/calendar') => void | Promise<void>;
push: (path: string) => void;
}
function parse(raw: string): ReminderToastContext | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as ReminderToastContext;
return parsed && typeof parsed === 'object' && (parsed.r !== undefined || parsed.rb !== undefined || parsed.cal) ? parsed : null;
} catch {
return null;
}
}
function snoozeMinutes(action: string): number | null {
if (!action.startsWith(SNOOZE_PREFIX)) return null;
const minutes = Number(action.slice(SNOOZE_PREFIX.length));
return (SNOOZE_MINUTES as readonly number[]).includes(minutes) ? minutes : null;
}
/** Whether this click was a reminder's. Buttons that only do something don't bring the window up. */
export async function handleReminderCommand(action: string, rawContext: string, env: ReminderCommandEnv): Promise<boolean> {
const context = parse(rawContext);
if (!context) return false;
const account = await accountOf(context);
const show = () => getDesktopBridge()?.windows?.focus?.();
if (context.rb !== undefined) {
show();
const alerts = account ? await getStore().area<ReminderAlert[]>(account, 'reminders.batch', { batch: context.rb }).catch(() => []) : [];
if (account && alerts.length > 0) useMissedReminders.getState().show(account, alerts);
else await env.leaveFor('/calendar');
return true;
}
const key = context.r;
const minutes = snoozeMinutes(action);
if (minutes !== null && account && key) {
await snoozeReminder(account, key, minutes);
return true;
}
const alert = account && key ? await getStore().area<ReminderAlert | null>(account, 'reminders.get', { key }).catch(() => null) : null;
if (action === JOIN && alert?.joinUrl) {
joinMeeting(alert.joinUrl);
dismissReminders(account!, [alert.key]);
return true;
}
// Open, a click on the toast, or a button that can't be carried out any more.
show();
await env.leaveFor('/calendar');
const path = account ? calendarPathFor(account, alert) : null;
if (path) env.push(path);
if (account && alert) dismissReminders(account, [alert.key]);
return true;
}
+2
View File
@@ -235,6 +235,8 @@ export interface DesktopOsNotification {
sound?: boolean;
/** New mail: flash the taskbar or bounce the dock if that's on. */
attention?: boolean;
/** What a locked app says instead: new mail unless this says otherwise. */
kind?: 'mail' | 'reminder';
}
export interface DesktopOsNotifyResult {
+2
View File
@@ -30,6 +30,7 @@ import { labelsPolicyFor } from '@/lib/outgoing/classification';
import { getSendGuardSource, guardState, guardsSending } from '@/lib/plugin-send-guards';
import { emitPushStateChange } from '@/lib/push-events';
import { requestOutboxSync } from '@/lib/pwa-notify/outbox-sync';
import { emitStoreEvent } from '@/lib/offline/store-events';
import { STORAGE_ERROR_AREA } from '@/lib/store/area';
import type { DraftError } from '@/lib/store/drafts/pending';
import { configureStore, getStore, type StorePageHooks } from '@/lib/store/client/api';
@@ -197,6 +198,7 @@ const storeHooks: StorePageHooks = {
notify({ kind: 'store-reset' });
},
onEvent(event) {
emitStoreEvent(event);
if (event.type === 'created' && event.collection === 'Email') {
const client = onlineClientFor(event.account);
if (client) reportStoreCreatedMail(client, event.jmap, event.ids);
+18
View File
@@ -0,0 +1,18 @@
/** The store's events, for the parts of the page that aren't offline mode itself. */
import type { StoreEvent } from '@/lib/store/area';
type Listener = (event: StoreEvent) => void;
const listeners = new Set<Listener>();
export function addStoreEventListener(listener: Listener): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function emitStoreEvent(event: StoreEvent): void {
for (const listener of [...listeners]) listener(event);
}
+1
View File
@@ -21,6 +21,7 @@ const SIGNED_OUT_STORAGE_KEYS = [
'contact-storage',
'calendar-storage',
'calendar-notification-storage',
'calendar-alert-claims',
// Open tabs name messages and their subjects.
'pro-tabs',
'search-history-storage',
+14
View File
@@ -6591,5 +6591,19 @@
"copy_as_file": "نسخ كملف",
"copied": "تم نسخ الملف. يمكنك لصقه في مجلد.",
"copy_failed": "تعذّر نسخ الملف."
},
"desktopReminders": {
"generic_title": "تذكير",
"snooze": "تأجيل {count} د",
"snooze_short": "{count} د",
"open": "فتح",
"join": "انضمام",
"missed_title": "{count, plural, zero {لا تذكيرات فائتة} one {تذكير فائت واحد} two {تذكيران فائتان} few {# تذكيرات فائتة} many {# تذكيرًا فائتًا} other {# تذكير فائت}}",
"missed_more": "و{count} أخرى",
"missed_show": "عرض",
"missed_dialog_title": "التذكيرات الفائتة",
"missed_dialog_description": "حان موعد هذه التذكيرات بينما كان التطبيق مغلقًا أو الكمبيوتر في وضع السكون أو الإشعارات متوقفة مؤقتًا.",
"open_calendar": "فتح التقويم",
"done": "تم"
}
}
+14
View File
@@ -6591,5 +6591,19 @@
"copy_as_file": "Copia com a fitxer",
"copied": "Fitxer copiat. El podeu enganxar en una carpeta.",
"copy_failed": "No s'ha pogut copiar el fitxer."
},
"desktopReminders": {
"generic_title": "Recordatori",
"snooze": "Posposa {count} min",
"snooze_short": "{count} min",
"open": "Obre",
"join": "Uneix-te",
"missed_title": "{count, plural, one {# recordatori perdut} other {# recordatoris perduts}}",
"missed_more": "i {count} més",
"missed_show": "Mostra",
"missed_dialog_title": "Recordatoris perduts",
"missed_dialog_description": "Van vèncer mentre l'aplicació estava tancada, l'ordinador en repòs o les notificacions en pausa.",
"open_calendar": "Obre el calendari",
"done": "Fet"
}
}
+14
View File
@@ -6591,5 +6591,19 @@
"copy_as_file": "Kopírovat jako soubor",
"copied": "Soubor zkopírován. Můžete ho vložit do složky.",
"copy_failed": "Soubor se nepodařilo zkopírovat."
},
"desktopReminders": {
"generic_title": "Připomenutí",
"snooze": "Odložit o {count} min",
"snooze_short": "{count} min",
"open": "Otevřít",
"join": "Připojit se",
"missed_title": "{count, plural, one {# zmeškané připomenutí} few {# zmeškaná připomenutí} many {# zmeškaného připomenutí} other {# zmeškaných připomenutí}}",
"missed_more": "a další ({count})",
"missed_show": "Zobrazit",
"missed_dialog_title": "Zmeškaná připomenutí",
"missed_dialog_description": "Nastala, když byla aplikace zavřená, počítač v režimu spánku nebo oznámení pozastavená.",
"open_calendar": "Otevřít kalendář",
"done": "Hotovo"
}
}
+14
View File
@@ -6591,5 +6591,19 @@
"copy_as_file": "Kopiér som fil",
"copied": "Filen er kopieret. Du kan indsætte den i en mappe.",
"copy_failed": "Filen kunne ikke kopieres."
},
"desktopReminders": {
"generic_title": "Påmindelse",
"snooze": "Udsæt {count} min.",
"snooze_short": "{count} min.",
"open": "Åbn",
"join": "Deltag",
"missed_title": "{count, plural, one {# overset påmindelse} other {# oversete påmindelser}}",
"missed_more": "og {count} mere",
"missed_show": "Vis",
"missed_dialog_title": "Oversete påmindelser",
"missed_dialog_description": "De forfaldt, mens appen var lukket, computeren sov, eller notifikationer var sat på pause.",
"open_calendar": "Åbn kalender",
"done": "Færdig"
}
}
+14
View File
@@ -6591,5 +6591,19 @@
"copy_as_file": "Als Datei kopieren",
"copied": "Datei kopiert. Sie können sie in einen Ordner einfügen.",
"copy_failed": "Die Datei konnte nicht kopiert werden."
},
"desktopReminders": {
"generic_title": "Erinnerung",
"snooze": "{count} Min. schlummern",
"snooze_short": "{count} Min.",
"open": "Öffnen",
"join": "Teilnehmen",
"missed_title": "{count, plural, one {# verpasste Erinnerung} other {# verpasste Erinnerungen}}",
"missed_more": "und {count} weitere",
"missed_show": "Anzeigen",
"missed_dialog_title": "Verpasste Erinnerungen",
"missed_dialog_description": "Diese wurden fällig, während die App geschlossen war, der Computer im Ruhezustand war oder Benachrichtigungen pausiert waren.",
"open_calendar": "Kalender öffnen",
"done": "Fertig"
}
}
+14
View File
@@ -6591,5 +6591,19 @@
"copy_as_file": "Copy as file",
"copied": "File copied. You can paste it into a folder.",
"copy_failed": "The file could not be copied."
},
"desktopReminders": {
"generic_title": "Reminder",
"snooze": "Snooze {count} min",
"snooze_short": "{count} min",
"open": "Open",
"join": "Join",
"missed_title": "{count, plural, one {# reminder you missed} other {# reminders you missed}}",
"missed_more": "and {count} more",
"missed_show": "Show",
"missed_dialog_title": "Reminders you missed",
"missed_dialog_description": "These came due while the app was closed, the computer was asleep or notifications were paused.",
"open_calendar": "Open calendar",
"done": "Done"
}
}
+14
View File
@@ -6591,5 +6591,19 @@
"copy_as_file": "Copiar como archivo",
"copied": "Archivo copiado. Puedes pegarlo en una carpeta.",
"copy_failed": "No se pudo copiar el archivo."
},
"desktopReminders": {
"generic_title": "Recordatorio",
"snooze": "Posponer {count} min",
"snooze_short": "{count} min",
"open": "Abrir",
"join": "Unirse",
"missed_title": "{count, plural, one {# recordatorio perdido} other {# recordatorios perdidos}}",
"missed_more": "y {count} más",
"missed_show": "Mostrar",
"missed_dialog_title": "Recordatorios perdidos",
"missed_dialog_description": "Vencieron mientras la aplicación estaba cerrada, el ordenador en reposo o las notificaciones en pausa.",
"open_calendar": "Abrir calendario",
"done": "Listo"
}
}
+14
View File
@@ -6591,5 +6591,19 @@
"copy_as_file": "Kopeeri failina",
"copied": "Fail on kopeeritud. Saad selle kausta kleepida.",
"copy_failed": "Faili ei saanud kopeerida."
},
"desktopReminders": {
"generic_title": "Meeldetuletus",
"snooze": "Lükka {count} min edasi",
"snooze_short": "{count} min",
"open": "Ava",
"join": "Liitu",
"missed_title": "{count, plural, one {# kasutamata meeldetuletus} other {# kasutamata meeldetuletust}}",
"missed_more": "ja veel {count}",
"missed_show": "Näita",
"missed_dialog_title": "Kasutamata meeldetuletused",
"missed_dialog_description": "Need saabusid, kui rakendus oli suletud, arvuti puhkas või teavitused olid peatatud.",
"open_calendar": "Ava kalender",
"done": "Valmis"
}
}
+14
View File
@@ -6591,5 +6591,19 @@
"copy_as_file": "رونوشت به‌صورت پرونده",
"copied": "پرونده رونوشت شد. می‌توانید آن را در یک پوشه بچسبانید.",
"copy_failed": "رونوشت پرونده ممکن نشد."
},
"desktopReminders": {
"generic_title": "یادآوری",
"snooze": "تعویق {count} دقیقه",
"snooze_short": "{count} دقیقه",
"open": "باز کردن",
"join": "پیوستن",
"missed_title": "{count, plural, one {# یادآوری از دست‌رفته} other {# یادآوری از دست‌رفته}}",
"missed_more": "و {count} مورد دیگر",
"missed_show": "نمایش",
"missed_dialog_title": "یادآوری‌های از دست‌رفته",
"missed_dialog_description": "این یادآوری‌ها زمانی رسیدند که برنامه بسته بود، رایانه در خواب بود یا اعلان‌ها متوقف بودند.",
"open_calendar": "باز کردن تقویم",
"done": "تمام"
}
}
+14
View File
@@ -6591,5 +6591,19 @@
"copy_as_file": "Copier en tant que fichier",
"copied": "Fichier copié. Vous pouvez le coller dans un dossier.",
"copy_failed": "Le fichier n'a pas pu être copié."
},
"desktopReminders": {
"generic_title": "Rappel",
"snooze": "Reporter de {count} min",
"snooze_short": "{count} min",
"open": "Ouvrir",
"join": "Rejoindre",
"missed_title": "{count, plural, one {# rappel manqué} other {# rappels manqués}}",
"missed_more": "et {count} de plus",
"missed_show": "Afficher",
"missed_dialog_title": "Rappels manqués",
"missed_dialog_description": "Ils sont arrivés à échéance alors que l'application était fermée, l'ordinateur en veille ou les notifications en pause.",
"open_calendar": "Ouvrir le calendrier",
"done": "Terminé"
}
}
+14
View File
@@ -6592,5 +6592,19 @@
"copy_as_file": "העתקה כקובץ",
"copied": "הקובץ הועתק. אפשר להדביק אותו בתיקייה.",
"copy_failed": "לא ניתן להעתיק את הקובץ."
},
"desktopReminders": {
"generic_title": "תזכורת",
"snooze": "דחייה ב-{count} דק'",
"snooze_short": "{count} דק'",
"open": "פתיחה",
"join": "הצטרפות",
"missed_title": "{count, plural, one {תזכורת אחת שהוחמצה} two {# תזכורות שהוחמצו} other {# תזכורות שהוחמצו}}",
"missed_more": "ועוד {count}",
"missed_show": "הצגה",
"missed_dialog_title": "תזכורות שהוחמצו",
"missed_dialog_description": "הן הגיעו כשהאפליקציה הייתה סגורה, המחשב היה במצב שינה או שההתראות הושהו.",
"open_calendar": "פתיחת היומן",
"done": "סיום"
}
}
+14
View File
@@ -6591,5 +6591,19 @@
"copy_as_file": "Másolás fájlként",
"copied": "Fájl másolva. Beillesztheti egy mappába.",
"copy_failed": "A fájlt nem sikerült másolni."
},
"desktopReminders": {
"generic_title": "Emlékeztető",
"snooze": "Halasztás {count} perccel",
"snooze_short": "{count} perc",
"open": "Megnyitás",
"join": "Csatlakozás",
"missed_title": "{count, plural, one {# elmulasztott emlékeztető} other {# elmulasztott emlékeztető}}",
"missed_more": "és még {count}",
"missed_show": "Megjelenítés",
"missed_dialog_title": "Elmulasztott emlékeztetők",
"missed_dialog_description": "Akkor jártak le, amikor az alkalmazás be volt zárva, a számítógép alvó állapotban volt, vagy az értesítések szüneteltek.",
"open_calendar": "Naptár megnyitása",
"done": "Kész"
}
}
+14
View File
@@ -6591,5 +6591,19 @@
"copy_as_file": "Copia come file",
"copied": "File copiato. Puoi incollarlo in una cartella.",
"copy_failed": "Impossibile copiare il file."
},
"desktopReminders": {
"generic_title": "Promemoria",
"snooze": "Rimanda di {count} min",
"snooze_short": "{count} min",
"open": "Apri",
"join": "Partecipa",
"missed_title": "{count, plural, one {# promemoria perso} other {# promemoria persi}}",
"missed_more": "e altri {count}",
"missed_show": "Mostra",
"missed_dialog_title": "Promemoria persi",
"missed_dialog_description": "Sono scattati mentre l'app era chiusa, il computer in sospensione o le notifiche in pausa.",
"open_calendar": "Apri calendario",
"done": "Fatto"
}
}
+14
View File
@@ -6591,5 +6591,19 @@
"copy_as_file": "ファイルとしてコピー",
"copied": "ファイルをコピーしました。フォルダーに貼り付けられます。",
"copy_failed": "ファイルをコピーできませんでした。"
},
"desktopReminders": {
"generic_title": "リマインダー",
"snooze": "{count} 分後に再通知",
"snooze_short": "{count} 分",
"open": "開く",
"join": "参加",
"missed_title": "{count, plural, other {見逃したリマインダー # 件}}",
"missed_more": "ほか {count} 件",
"missed_show": "表示",
"missed_dialog_title": "見逃したリマインダー",
"missed_dialog_description": "アプリが閉じていた間、コンピューターがスリープしていた間、または通知を一時停止していた間に期限が来たものです。",
"open_calendar": "カレンダーを開く",
"done": "完了"
}
}
+14
View File
@@ -6591,5 +6591,19 @@
"copy_as_file": "파일로 복사",
"copied": "파일을 복사했습니다. 폴더에 붙여넣을 수 있습니다.",
"copy_failed": "파일을 복사할 수 없습니다."
},
"desktopReminders": {
"generic_title": "알림",
"snooze": "{count}분 후 다시 알림",
"snooze_short": "{count}분",
"open": "열기",
"join": "참가",
"missed_title": "{count, plural, other {놓친 알림 #개}}",
"missed_more": "외 {count}개",
"missed_show": "보기",
"missed_dialog_title": "놓친 알림",
"missed_dialog_description": "앱이 닫혀 있거나 컴퓨터가 절전 상태이거나 알림이 일시 중지된 동안 시간이 된 알림입니다.",
"open_calendar": "캘린더 열기",
"done": "완료"
}
}
+14
View File
@@ -6591,5 +6591,19 @@
"copy_as_file": "Kopēt kā failu",
"copied": "Fails nokopēts. Varat to ielīmēt mapē.",
"copy_failed": "Failu neizdevās nokopēt."
},
"desktopReminders": {
"generic_title": "Atgādinājums",
"snooze": "Atlikt par {count} min",
"snooze_short": "{count} min",
"open": "Atvērt",
"join": "Pievienoties",
"missed_title": "{count, plural, zero {# nokavētu atgādinājumu} one {# nokavēts atgādinājums} other {# nokavēti atgādinājumi}}",
"missed_more": "un vēl {count}",
"missed_show": "Rādīt",
"missed_dialog_title": "Nokavētie atgādinājumi",
"missed_dialog_description": "Tie pienāca, kamēr lietotne bija aizvērta, dators bija miega režīmā vai paziņojumi bija apturēti.",
"open_calendar": "Atvērt kalendāru",
"done": "Gatavs"
}
}
+14
View File
@@ -6591,5 +6591,19 @@
"copy_as_file": "Файл болгож хуулах",
"copied": "Файлыг хууллаа. Хавтсанд буулгаж болно.",
"copy_failed": "Файлыг хуулж чадсангүй."
},
"desktopReminders": {
"generic_title": "Сануулга",
"snooze": "{count} минут хойшлуулах",
"snooze_short": "{count} мин",
"open": "Нээх",
"join": "Нэгдэх",
"missed_title": "{count, plural, one {Алдсан # сануулга} other {Алдсан # сануулга}}",
"missed_more": "бас {count}",
"missed_show": "Харуулах",
"missed_dialog_title": "Алдсан сануулгууд",
"missed_dialog_description": "Апп хаалттай, компьютер унтсан эсвэл мэдэгдэл түр зогссон үед хугацаа нь болсон.",
"open_calendar": "Хуанли нээх",
"done": "Болсон"
}
}
+14
View File
@@ -6591,5 +6591,19 @@
"copy_as_file": "Kopier som fil",
"copied": "Filen er kopiert. Du kan lime den inn i en mappe.",
"copy_failed": "Filen kunne ikke kopieres."
},
"desktopReminders": {
"generic_title": "Påminnelse",
"snooze": "Utsett {count} min",
"snooze_short": "{count} min",
"open": "Åpne",
"join": "Bli med",
"missed_title": "{count, plural, one {# tapt påminnelse} other {# tapte påminnelser}}",
"missed_more": "og {count} til",
"missed_show": "Vis",
"missed_dialog_title": "Tapte påminnelser",
"missed_dialog_description": "De forfalt mens appen var lukket, datamaskinen sov eller varsler var satt på pause.",
"open_calendar": "Åpne kalender",
"done": "Ferdig"
}
}
+14
View File
@@ -6591,5 +6591,19 @@
"copy_as_file": "Kopiëren als bestand",
"copied": "Bestand gekopieerd. Je kunt het in een map plakken.",
"copy_failed": "Het bestand kon niet worden gekopieerd."
},
"desktopReminders": {
"generic_title": "Herinnering",
"snooze": "{count} min uitstellen",
"snooze_short": "{count} min",
"open": "Openen",
"join": "Deelnemen",
"missed_title": "{count, plural, one {# gemiste herinnering} other {# gemiste herinneringen}}",
"missed_more": "en nog {count}",
"missed_show": "Tonen",
"missed_dialog_title": "Gemiste herinneringen",
"missed_dialog_description": "Deze kwamen op terwijl de app gesloten was, de computer sliep of meldingen gepauzeerd waren.",
"open_calendar": "Agenda openen",
"done": "Klaar"
}
}
+14
View File
@@ -6591,5 +6591,19 @@
"copy_as_file": "Kopiuj jako plik",
"copied": "Plik skopiowany. Możesz wkleić go do folderu.",
"copy_failed": "Nie udało się skopiować pliku."
},
"desktopReminders": {
"generic_title": "Przypomnienie",
"snooze": "Odłóż o {count} min",
"snooze_short": "{count} min",
"open": "Otwórz",
"join": "Dołącz",
"missed_title": "{count, plural, one {# pominięte przypomnienie} few {# pominięte przypomnienia} many {# pominiętych przypomnień} other {# pominiętego przypomnienia}}",
"missed_more": "i jeszcze {count}",
"missed_show": "Pokaż",
"missed_dialog_title": "Pominięte przypomnienia",
"missed_dialog_description": "Pojawiły się, gdy aplikacja była zamknięta, komputer był uśpiony lub powiadomienia były wstrzymane.",
"open_calendar": "Otwórz kalendarz",
"done": "Gotowe"
}
}
+14
View File
@@ -6591,5 +6591,19 @@
"copy_as_file": "Copiar como ficheiro",
"copied": "Ficheiro copiado. Pode colá-lo numa pasta.",
"copy_failed": "Não foi possível copiar o ficheiro."
},
"desktopReminders": {
"generic_title": "Lembrete",
"snooze": "Adiar {count} min",
"snooze_short": "{count} min",
"open": "Abrir",
"join": "Entrar",
"missed_title": "{count, plural, one {# lembrete perdido} other {# lembretes perdidos}}",
"missed_more": "e mais {count}",
"missed_show": "Mostrar",
"missed_dialog_title": "Lembretes perdidos",
"missed_dialog_description": "Venceram enquanto a aplicação estava fechada, o computador em suspensão ou as notificações em pausa.",
"open_calendar": "Abrir calendário",
"done": "Concluído"
}
}
+14
View File
@@ -6591,5 +6591,19 @@
"copy_as_file": "Copiază ca fișier",
"copied": "Fișier copiat. Îl poți lipi într-un dosar.",
"copy_failed": "Fișierul nu a putut fi copiat."
},
"desktopReminders": {
"generic_title": "Memento",
"snooze": "Amână {count} min",
"snooze_short": "{count} min",
"open": "Deschide",
"join": "Participă",
"missed_title": "{count, plural, one {# memento ratat} few {# mementouri ratate} other {# de mementouri ratate}}",
"missed_more": "și încă {count}",
"missed_show": "Afișează",
"missed_dialog_title": "Mementouri ratate",
"missed_dialog_description": "Au venit la scadență cât timp aplicația era închisă, computerul în repaus sau notificările în pauză.",
"open_calendar": "Deschide calendarul",
"done": "Gata"
}
}
+14
View File
@@ -6591,5 +6591,19 @@
"copy_as_file": "Копировать как файл",
"copied": "Файл скопирован. Его можно вставить в папку.",
"copy_failed": "Не удалось скопировать файл."
},
"desktopReminders": {
"generic_title": "Напоминание",
"snooze": "Отложить на {count} мин",
"snooze_short": "{count} мин",
"open": "Открыть",
"join": "Подключиться",
"missed_title": "{count, plural, one {# пропущенное напоминание} few {# пропущенных напоминания} many {# пропущенных напоминаний} other {# пропущенного напоминания}}",
"missed_more": "и ещё {count}",
"missed_show": "Показать",
"missed_dialog_title": "Пропущенные напоминания",
"missed_dialog_description": "Они сработали, пока приложение было закрыто, компьютер спал или уведомления были приостановлены.",
"open_calendar": "Открыть календарь",
"done": "Готово"
}
}
+14
View File
@@ -6591,5 +6591,19 @@
"copy_as_file": "Kopírovať ako súbor",
"copied": "Súbor skopírovaný. Môžete ho vložiť do priečinka.",
"copy_failed": "Súbor sa nepodarilo skopírovať."
},
"desktopReminders": {
"generic_title": "Pripomienka",
"snooze": "Odložiť o {count} min",
"snooze_short": "{count} min",
"open": "Otvoriť",
"join": "Pripojiť sa",
"missed_title": "{count, plural, one {# zmeškaná pripomienka} few {# zmeškané pripomienky} many {# zmeškanej pripomienky} other {# zmeškaných pripomienok}}",
"missed_more": "a ďalšie ({count})",
"missed_show": "Zobraziť",
"missed_dialog_title": "Zmeškané pripomienky",
"missed_dialog_description": "Nastali, keď bola aplikácia zatvorená, počítač v režime spánku alebo oznámenia pozastavené.",
"open_calendar": "Otvoriť kalendár",
"done": "Hotovo"
}
}
+14
View File
@@ -6599,5 +6599,19 @@
"copy_as_file": "คัดลอกเป็นไฟล์",
"copied": "คัดลอกไฟล์แล้ว คุณวางลงในโฟลเดอร์ได้",
"copy_failed": "คัดลอกไฟล์ไม่ได้"
},
"desktopReminders": {
"generic_title": "การแจ้งเตือน",
"snooze": "เลื่อน {count} นาที",
"snooze_short": "{count} นาที",
"open": "เปิด",
"join": "เข้าร่วม",
"missed_title": "{count, plural, other {การแจ้งเตือนที่พลาด # รายการ}}",
"missed_more": "และอีก {count} รายการ",
"missed_show": "แสดง",
"missed_dialog_title": "การแจ้งเตือนที่พลาด",
"missed_dialog_description": "ถึงเวลาแจ้งเตือนขณะที่แอปปิดอยู่ คอมพิวเตอร์อยู่ในโหมดพัก หรือหยุดการแจ้งเตือนชั่วคราว",
"open_calendar": "เปิดปฏิทิน",
"done": "เสร็จ"
}
}
+14
View File
@@ -6591,5 +6591,19 @@
"copy_as_file": "Dosya olarak kopyala",
"copied": "Dosya kopyalandı. Bir klasöre yapıştırabilirsiniz.",
"copy_failed": "Dosya kopyalanamadı."
},
"desktopReminders": {
"generic_title": "Anımsatıcı",
"snooze": "{count} dk ertele",
"snooze_short": "{count} dk",
"open": "Aç",
"join": "Katıl",
"missed_title": "{count, plural, one {Kaçırılan # anımsatıcı} other {Kaçırılan # anımsatıcı}}",
"missed_more": "ve {count} tane daha",
"missed_show": "Göster",
"missed_dialog_title": "Kaçırılan anımsatıcılar",
"missed_dialog_description": "Uygulama kapalıyken, bilgisayar uykudayken veya bildirimler duraklatılmışken zamanı geldi.",
"open_calendar": "Takvimi aç",
"done": "Bitti"
}
}
+14
View File
@@ -6591,5 +6591,19 @@
"copy_as_file": "Копіювати як файл",
"copied": "Файл скопійовано. Його можна вставити в папку.",
"copy_failed": "Не вдалося скопіювати файл."
},
"desktopReminders": {
"generic_title": "Нагадування",
"snooze": "Відкласти на {count} хв",
"snooze_short": "{count} хв",
"open": "Відкрити",
"join": "Приєднатися",
"missed_title": "{count, plural, one {# пропущене нагадування} few {# пропущені нагадування} many {# пропущених нагадувань} other {# пропущеного нагадування}}",
"missed_more": "і ще {count}",
"missed_show": "Показати",
"missed_dialog_title": "Пропущені нагадування",
"missed_dialog_description": "Вони спрацювали, коли застосунок було закрито, комп'ютер спав або сповіщення були призупинені.",
"open_calendar": "Відкрити календар",
"done": "Готово"
}
}
+14
View File
@@ -6603,5 +6603,19 @@
"copy_as_file": "Sao chép dưới dạng tệp",
"copied": "Đã sao chép tệp. Bạn có thể dán vào một thư mục.",
"copy_failed": "Không thể sao chép tệp."
},
"desktopReminders": {
"generic_title": "Lời nhắc",
"snooze": "Báo lại sau {count} phút",
"snooze_short": "{count} phút",
"open": "Mở",
"join": "Tham gia",
"missed_title": "{count, plural, other {# lời nhắc đã bỏ lỡ}}",
"missed_more": "và {count} lời nhắc khác",
"missed_show": "Hiển thị",
"missed_dialog_title": "Lời nhắc đã bỏ lỡ",
"missed_dialog_description": "Những lời nhắc này đến hạn khi ứng dụng đóng, máy tính ngủ hoặc thông báo bị tạm dừng.",
"open_calendar": "Mở lịch",
"done": "Xong"
}
}
+14
View File
@@ -6591,5 +6591,19 @@
"copy_as_file": "複製為檔案",
"copied": "檔案已複製。您可以將其貼到資料夾中。",
"copy_failed": "無法複製檔案。"
},
"desktopReminders": {
"generic_title": "提醒",
"snooze": "{count} 分鐘後再提醒",
"snooze_short": "{count} 分鐘",
"open": "開啟",
"join": "加入",
"missed_title": "{count, plural, other {# 則錯過的提醒}}",
"missed_more": "還有 {count} 則",
"missed_show": "顯示",
"missed_dialog_title": "錯過的提醒",
"missed_dialog_description": "這些提醒在應用程式關閉、電腦睡眠或通知暫停時到期。",
"open_calendar": "開啟日曆",
"done": "完成"
}
}
+14
View File
@@ -6591,5 +6591,19 @@
"copy_as_file": "复制为文件",
"copied": "文件已复制。你可以将其粘贴到文件夹中。",
"copy_failed": "无法复制文件。"
},
"desktopReminders": {
"generic_title": "提醒",
"snooze": "{count} 分钟后再提醒",
"snooze_short": "{count} 分钟",
"open": "打开",
"join": "加入",
"missed_title": "{count, plural, other {# 条错过的提醒}}",
"missed_more": "还有 {count} 条",
"missed_show": "显示",
"missed_dialog_title": "错过的提醒",
"missed_dialog_description": "这些提醒在应用关闭、电脑睡眠或通知暂停时到期。",
"open_calendar": "打开日历",
"done": "完成"
}
}
+1
View File
@@ -1096,6 +1096,7 @@ function getReusableClientScore(state) {
function buildClickUrl(data, reply) {
if (!data) return `${BASE_PATH}/`;
if (data.kind === "calendar-reminder") return `${BASE_PATH}/calendar`;
const params = new URLSearchParams();
if (Number.isInteger(data.slot)) params.set("slot", String(data.slot));
if (typeof data.accountId === "string" && data.accountId) params.set("owner", data.accountId);
+2
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { clearClaims } from '@/lib/calendar-reminders/claims';
const RETENTION_THRESHOLD_MS = 24 * 60 * 60 * 1000;
@@ -37,6 +38,7 @@ export const useCalendarNotificationStore = create<CalendarNotificationStore>()(
clearAll: () => {
set({ acknowledgedAlerts: {} });
clearClaims();
},
}),
{