feat: add a send-invitations switch and custom reminder offsets, keeping alerts the picker cannot show

This commit is contained in:
Linus Rath
2026-08-29 18:53:58 +02:00
parent c705227611
commit 2abea9742e
8 changed files with 277 additions and 40 deletions
+2 -2
View File
@@ -83,7 +83,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
- What RN does: fixed preset list, multiple allowed, non-offset alerts are dropped from the UI but kept until save (`src/components/calendar/EventModal.tsx:299-312`, `src/lib/calendar-alerts.ts:51-70`). Note the "kept until save" comment is wrong: `remindersToAlerts` rebuilds the map from presets only, so an absolute-trigger alert is lost on the next save.
- Fix hint: add a custom value+unit row; when rebuilding alerts, carry over alerts whose trigger has no `offset`.
- [ ] **Duplicate event, export .ics, copy meeting link / title, add note** — `P3` — `missing`
- [ ] **Duplicate event, export .ics, copy meeting link / title, add note** — `P3` — `missing` — duplicate (+1 day, opens editor), share as .ics (`eventToICS` port + expo-sharing) and copy meeting link done in 5fe8e40; deferred: copy title, append-a-note
- What WEB does: popover/context-menu actions duplicate (+1 day, opens editor), export via `downloadEventICS`, copy meeting link, copy title, append a timestamped note to the description (ref `components/calendar/calendar-app.tsx:1017-1125`, `lib/calendar-ics-export.ts:96, 176`).
- What RN does: `EventDetailSheet` has an `onDuplicate` prop that `CalendarScreen` never passes (`src/components/calendar/EventDetailSheet.tsx:59, 336-342`); no export/copy/note.
- Fix hint: wire `onDuplicate` (clone like WEB `handleDuplicateFromDetail`), add "Share .ics" via `expo-sharing` + a port of `eventToICS`, add "Copy link" with `Clipboard`.
@@ -231,7 +231,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi
- What RN does: `queryEvents` ignores its `_after`/`_before` arguments citing "Stalwart rejects after/before filters" (outdated) and uses `limit: 1000` (`src/api/calendar.ts:188-208`); `fetchEvents` then re-fetches everything whenever `ensureRange` widens the union (`src/stores/calendar-store.ts:235-251`). Accounts with more than 1000 objects silently lose events; every navigation past the loaded range re-downloads all events.
- Fix hint: send `filter: { after, before, inCalendar... }` as WEB does (`{ operator: 'AND', conditions: [...] }` when combined), keep `timeZone`; then `loadedRange` can be the requested window rather than a growing union.
- [ ] **iCal import passes raw parsed objects to `CalendarEvent/set`; existing UIDs are skipped instead of linked (#113)** — `P2` — `partial`
- [x] **iCal import passes raw parsed objects to `CalendarEvent/set`; existing UIDs are skipped instead of linked (#113)** — `P2` — `partial` — fixed in 5fe8e40
- What WEB does: whitelists the JSCalendar properties it sends (drops server-computed `utcStart`/`utcEnd`/`isOrigin`/`created`/`updated`, rewrites participants to `calendarAddress`, normalises all-day duration/timeZone) and links UIDs that exist in another calendar by adding the target calendar to `calendarIds` (ref `stores/calendar-store.ts:822-958`, changelog 1.5.x #113).
- What RN does: `importEvents` skips existing UIDs and batch-creates `{ ...parsed, calendarIds }` untouched (`src/stores/calendar-store.ts:352-376`, `src/api/calendar.ts:278-297`). Whether Stalwart rejects computed properties on create depends on the parse output; WEB chose to whitelist after hitting failures ("Deduplicate UIDs during iCal import to prevent mass failures").
- Fix hint: port WEB's `prepared` mapping (`stores/calendar-store.ts:885-957`) and the link-by-`calendarIds` branch.
+2 -1
View File
@@ -2888,7 +2888,8 @@
"add": "Add reminder",
"remove": "Remove reminder",
"amount": "Reminder amount",
"unit": "Reminder unit"
"unit": "Reminder unit",
"unit_minutes_after": "minutes after"
},
"settings": {
"title": "Calendar settings",
+1 -1
View File
@@ -297,7 +297,7 @@ export function EventDetailSheet({
{reminders.length > 0 && (
<DetailRow
icon={<Bell size={16} color={c.textMuted} />}
text={reminders.map((r) => formatReminder(r.minutesBefore)).join(', ')}
text={reminders.map((r) => formatReminder(r.minutesBefore, t)).join(', ')}
/>
)}
{calendar && (
+114 -5
View File
@@ -27,6 +27,7 @@ import {
} from 'lucide-react-native';
import { addHours, addMinutes, format } from 'date-fns';
import type {
Alert,
Calendar,
CalendarEvent,
EventLocation,
@@ -51,9 +52,12 @@ import { useCalendarLocale } from '../../lib/calendar-locale';
import {
alertsToReminders,
remindersToAlerts,
preservedAlerts,
formatReminder,
REMINDER_PRESETS,
REMINDER_UNIT_MINUTES,
type Reminder,
type ReminderUnit,
} from '../../lib/calendar-alerts';
import { buildRecurrenceSummary, isSimpleRecurrenceRule } from '../../lib/recurrence';
import {
@@ -88,7 +92,11 @@ interface EventModalProps {
currentUserEmails?: string[];
// Client-side iCal subscriptions are read-only targets (#762).
isSubscriptionCalendar?: (calendarId: string) => boolean;
onSave: (data: Partial<CalendarEvent>, calendarId: string) => void | Promise<void>;
onSave: (
data: Partial<CalendarEvent>,
calendarId: string,
options?: { sendSchedulingMessages?: boolean },
) => void | Promise<void>;
onDelete?: (event: CalendarEvent) => void;
onClose: () => void;
}
@@ -201,6 +209,14 @@ export function EventModal({
const [customRule, setCustomRule] = React.useState<RecurrenceRule | null>(null);
const [recurrenceEditorOpen, setRecurrenceEditorOpen] = React.useState(false);
const [reminders, setReminders] = React.useState<Reminder[]>([]);
// Alerts the picker can't show (absolute / end-relative / non-display)
// ride along untouched so a save never drops them.
const [preserved, setPreserved] = React.useState<Record<string, Alert>>({});
const [customAmount, setCustomAmount] = React.useState('30');
const [customUnit, setCustomUnit] = React.useState<ReminderUnit>('minutes');
const [customReminderOpen, setCustomReminderOpen] = React.useState(false);
// "Send invitations" (webmail default: on) decides sendSchedulingMessages.
const [sendInvitations, setSendInvitations] = React.useState(true);
const [location, setLocation] = React.useState('');
const [videoUrl, setVideoUrl] = React.useState('');
const [recurrenceOpen, setRecurrenceOpen] = React.useState(false);
@@ -230,6 +246,9 @@ export function EventModal({
setCustomRule(detected === 'custom' ? event.recurrenceRules?.[0] ?? null : null);
setRecurrenceEditorOpen(false);
setReminders(alertsToReminders(event.alerts));
setPreserved(preservedAlerts(event.alerts));
setSendInvitations(true);
setCustomReminderOpen(false);
setLocation(detectLocation(event));
setVideoUrl(detectVideoUrl(event));
} else {
@@ -245,6 +264,9 @@ export function EventModal({
setCustomRule(null);
setRecurrenceEditorOpen(false);
setReminders([]);
setPreserved({});
setSendInvitations(true);
setCustomReminderOpen(false);
setLocation('');
setVideoUrl('');
}
@@ -313,7 +335,7 @@ export function EventModal({
// All-day events are date-only and carry no zone.
timeZone: allDay ? null : getEffectiveTimeZone(),
recurrenceRules: clearedOr(recurrenceRules, 'recurrenceRules'),
alerts: clearedOr(remindersToAlerts(reminders), 'alerts'),
alerts: clearedOr(remindersToAlerts(reminders, preserved), 'alerts'),
useDefaultAlerts: reminders.length > 0 ? false : undefined,
locations: clearedOr(buildLocations(location), 'locations'),
virtualLocations: clearedOr(buildVirtualLocations(videoUrl), 'virtualLocations'),
@@ -344,7 +366,9 @@ export function EventModal({
for (const key of Object.keys(data) as (keyof CalendarEvent)[]) {
if (data[key] === undefined) delete data[key];
}
await onSave(data, calendarId);
await onSave(data, calendarId, {
sendSchedulingMessages: attendees.length > 0 && sendInvitations,
});
onClose();
} finally {
setSaving(false);
@@ -588,7 +612,7 @@ export function EventModal({
<View style={{ gap: spacing.xs }}>
{reminders.map((r) => (
<View key={r.minutesBefore} style={styles.reminderRow}>
<Text style={styles.fieldText}>{formatReminder(r.minutesBefore)}</Text>
<Text style={styles.fieldText}>{formatReminder(r.minutesBefore, t)}</Text>
<Pressable
onPress={() => removeReminder(r.minutesBefore)}
hitSlop={8}
@@ -619,9 +643,51 @@ export function EventModal({
style={styles.popoverRow}
onPress={() => addReminder(preset)}
>
<Text style={styles.popoverRowText}>{formatReminder(preset)}</Text>
<Text style={styles.popoverRowText}>{formatReminder(preset, t)}</Text>
</Pressable>
))}
<Pressable
style={styles.popoverRow}
onPress={() => { setReminderPickerOpen(false); setCustomReminderOpen(true); }}
>
<Text style={styles.popoverRowText}>{t('calendar.recurrence.custom', 'Custom…')}</Text>
</Pressable>
</View>
)}
{customReminderOpen && (
<View style={styles.customReminderRow}>
<TextInput
value={customAmount}
onChangeText={(v) => setCustomAmount(v.replace(/[^0-9]/g, ''))}
keyboardType="number-pad"
style={styles.customAmountInput}
placeholder="30"
placeholderTextColor={c.textMuted}
/>
<View style={styles.customUnits}>
{(['minutes', 'hours', 'days', 'weeks'] as ReminderUnit[]).map((unit) => (
<Pressable
key={unit}
onPress={() => setCustomUnit(unit)}
style={[styles.unitChip, customUnit === unit && styles.unitChipActive]}
>
<Text style={[styles.unitChipText, customUnit === unit && styles.unitChipTextActive]}>
{t(`calendar.alerts.unit_${unit}_before`, `${unit} before`)}
</Text>
</Pressable>
))}
</View>
<Button
size="sm"
onPress={() => {
const amount = parseInt(customAmount, 10);
if (!Number.isFinite(amount) || amount < 0) return;
addReminder(amount * REMINDER_UNIT_MINUTES[customUnit]);
setCustomReminderOpen(false);
}}
>
{t('calendar.alerts.add', 'Add reminder')}
</Button>
</View>
)}
</Section>
@@ -674,6 +740,19 @@ export function EventModal({
setAttendees((prev) => prev.filter((a) => a.email.toLowerCase() !== email.toLowerCase()))
}
/>
{attendees.length > 0 && (
<View style={styles.allDayRow}>
<Text style={[styles.fieldText, { flex: 1 }]}>
{t('calendar.participants.send_invitations', 'Send invitations to participants')}
</Text>
<Switch
value={sendInvitations}
onValueChange={setSendInvitations}
thumbColor={sendInvitations ? c.primary : c.textMuted}
trackColor={{ false: c.surface, true: c.primaryBg }}
/>
</View>
)}
</Section>
{isEdit && onDelete && (
@@ -842,6 +921,36 @@ function makeStyles(c: ThemePalette) {
backgroundColor: c.surface,
},
reminderRemove: { padding: 2 },
customReminderRow: {
gap: spacing.sm,
padding: spacing.md,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.surface,
},
customAmountInput: {
...typography.body,
color: c.text,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: c.background,
},
customUnits: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs },
unitChip: {
paddingHorizontal: spacing.sm,
paddingVertical: 6,
borderRadius: radius.full,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.background,
},
unitChipActive: { backgroundColor: c.primary, borderColor: c.primary },
unitChipText: { ...typography.caption, color: c.text },
unitChipTextActive: { color: c.primaryForeground },
addRow: {
flexDirection: 'row',
alignItems: 'center',
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { formatReminder, preservedAlerts, remindersToAlerts } from '../calendar-alerts';
describe('preservedAlerts / remindersToAlerts', () => {
const alerts = {
a: { trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display' },
abs: { trigger: { '@type': 'AbsoluteTrigger', when: '2026-03-01T08:00:00Z' }, action: 'display' },
end: { trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'end' }, action: 'display' },
mail: { trigger: { '@type': 'OffsetTrigger', offset: '-PT1H' }, action: 'email' },
};
it('keeps alerts the picker cannot represent', () => {
expect(Object.keys(preservedAlerts(alerts)).sort()).toEqual(['abs', 'end', 'mail']);
expect(preservedAlerts(undefined)).toEqual({});
});
it('rebuilds the picker alerts on top of the preserved ones without key clashes', () => {
const rebuilt = remindersToAlerts([{ minutesBefore: 10 }, { minutesBefore: 0 }], preservedAlerts(alerts))!;
expect(Object.keys(rebuilt).sort()).toEqual(['abs', 'end', 'mail', 'reminder-1', 'reminder-2']);
expect(rebuilt['reminder-1'].trigger).toEqual({ '@type': 'OffsetTrigger', offset: '-PT10M', relativeTo: 'start' });
expect(rebuilt['reminder-2'].trigger.offset).toBe('PT0S');
expect(rebuilt.abs).toBe(alerts.abs);
});
it('returns undefined only when nothing is left', () => {
expect(remindersToAlerts([], {})).toBeUndefined();
expect(Object.keys(remindersToAlerts([], preservedAlerts(alerts))!)).toHaveLength(3);
});
});
describe('formatReminder', () => {
it('falls back to English plurals', () => {
expect(formatReminder(0)).toBe('At time of event');
expect(formatReminder(1)).toBe('1 minute before');
expect(formatReminder(45)).toBe('45 minutes before');
expect(formatReminder(120)).toBe('2 hours before');
expect(formatReminder(60 * 24)).toBe('1 day before');
expect(formatReminder(60 * 24 * 14)).toBe('2 weeks before');
});
it('uses a plain translated string and ignores ICU plural templates it cannot expand', () => {
const t = (key: string, fallback?: string) =>
key === 'calendar.alerts.hours_before' ? '# Stunden vorher'
: key === 'calendar.alerts.minutes_before' ? '{count, plural, one {# minute} other {# minutes}}'
: fallback ?? key;
expect(formatReminder(120, t)).toBe('2 Stunden vorher');
expect(formatReminder(5, t)).toBe('5 minutes before');
});
});
+65 -14
View File
@@ -69,34 +69,85 @@ export function alertsToReminders(alerts: CalendarEvent['alerts']): Reminder[] {
.sort((a, b) => b.minutesBefore - a.minutesBefore);
}
export function remindersToAlerts(reminders: Reminder[]): Record<string, Alert> | undefined {
if (reminders.length === 0) return undefined;
const alerts: Record<string, Alert> = {};
reminders.forEach((r, i) => {
alerts[`reminder-${i + 1}`] = {
trigger: { '@type': 'OffsetTrigger', offset: minutesBeforeToOffset(r.minutesBefore) },
// Alerts the picker can't represent (absolute triggers, end-relative offsets,
// non-display actions) — kept as-is so a save doesn't drop them.
export function preservedAlerts(alerts: CalendarEvent['alerts']): Record<string, Alert> {
const out: Record<string, Alert> = {};
if (!alerts) return out;
for (const [id, alert] of Object.entries(alerts)) {
const trigger = alert?.trigger;
if (!trigger) continue;
const representable =
trigger.offset !== undefined &&
(trigger.relativeTo === undefined || trigger.relativeTo === 'start') &&
(!alert.action || alert.action === 'display') &&
offsetToMinutesBefore(trigger.offset) !== null;
if (!representable) out[id] = alert;
}
return out;
}
export function remindersToAlerts(
reminders: Reminder[],
preserved: Record<string, Alert> = {},
): Record<string, Alert> | undefined {
if (reminders.length === 0 && Object.keys(preserved).length === 0) return undefined;
const alerts: Record<string, Alert> = { ...preserved };
let n = 0;
for (const r of reminders) {
let key = `reminder-${++n}`;
while (key in alerts) key = `reminder-${++n}`;
alerts[key] = {
'@type': 'Alert',
trigger: { '@type': 'OffsetTrigger', offset: minutesBeforeToOffset(r.minutesBefore), relativeTo: 'start' },
action: 'display',
};
});
}
return alerts;
}
export function formatReminder(minutesBefore: number): string {
if (minutesBefore === 0) return 'At time of event';
if (minutesBefore < 0) return `${Math.abs(minutesBefore)} minutes after`;
if (minutesBefore < 60) return `${minutesBefore} minute${minutesBefore === 1 ? '' : 's'} before`;
type Translate = (key: string, fallback?: string) => string;
const plain: Translate = (_key, fallback) => fallback ?? _key;
function plural(t: Translate, count: number, key: string, one: string, many: string): string {
// Locale files carry ICU plurals ("{count, plural, one {# minute before} other {# minutes before}}")
// that the mobile t() can't expand, so pick the English-style form and only
// use the translation when it is a plain string.
const raw = t(key, '');
if (raw && !raw.includes('{')) return raw.replace('#', String(count));
return (count === 1 ? one : many).replace('#', String(count));
}
export function formatReminder(minutesBefore: number, t: Translate = plain): string {
if (minutesBefore === 0) return t('calendar.alerts.at_time', 'At time of event');
if (minutesBefore < 0) return `${Math.abs(minutesBefore)} ${t('calendar.alerts.unit_minutes_after', 'minutes after')}`;
if (minutesBefore < 60) {
return plural(t, minutesBefore, 'calendar.alerts.minutes_before', '# minute before', '# minutes before');
}
if (minutesBefore < 60 * 24) {
const h = minutesBefore / 60;
return Number.isInteger(h) ? `${h} hour${h === 1 ? '' : 's'} before` : `${minutesBefore} minutes before`;
if (Number.isInteger(h)) return plural(t, h, 'calendar.alerts.hours_before', '# hour before', '# hours before');
return plural(t, minutesBefore, 'calendar.alerts.minutes_before', '# minute before', '# minutes before');
}
if (minutesBefore < 60 * 24 * 7) {
const d = minutesBefore / (60 * 24);
return Number.isInteger(d) ? `${d} day${d === 1 ? '' : 's'} before` : `${(minutesBefore / 60).toFixed(0)} hours before`;
if (Number.isInteger(d)) return plural(t, d, 'calendar.alerts.days_before', '# day before', '# days before');
return plural(t, Math.round(minutesBefore / 60), 'calendar.alerts.hours_before', '# hour before', '# hours before');
}
const w = minutesBefore / (60 * 24 * 7);
return Number.isInteger(w) ? `${w} week${w === 1 ? '' : 's'} before` : `${(minutesBefore / (60 * 24)).toFixed(0)} days before`;
if (Number.isInteger(w)) return plural(t, w, 'calendar.alerts.weeks_before', '# week before', '# weeks before');
return plural(t, Math.round(minutesBefore / (60 * 24)), 'calendar.alerts.days_before', '# day before', '# days before');
}
export type ReminderUnit = 'minutes' | 'hours' | 'days' | 'weeks';
export const REMINDER_UNIT_MINUTES: Record<ReminderUnit, number> = {
minutes: 1,
hours: 60,
days: 60 * 24,
weeks: 60 * 24 * 7,
};
// Presets offered by the reminder picker (minutes before start).
export const REMINDER_PRESETS: number[] = [
0, 5, 10, 15, 30, 60, 120, 60 * 24, 60 * 24 * 2, 60 * 24 * 7,
+21 -9
View File
@@ -88,7 +88,13 @@ import type { Calendar, CalendarEvent, RecurrenceRule } from '../api/types';
type ViewMode = 'month' | 'week' | 'agenda';
type PendingAction =
| { kind: 'edit'; event: CalendarEvent; updates: Partial<CalendarEvent>; calendarId: string }
| {
kind: 'edit';
event: CalendarEvent;
updates: Partial<CalendarEvent>;
calendarId: string;
sendScheduling?: boolean;
}
| { kind: 'delete'; event: CalendarEvent }
| null;
@@ -464,14 +470,15 @@ export default function CalendarScreen() {
try {
if (action.kind === 'edit') {
const { updates } = action;
const opts = { sendSchedulingMessages: action.sendScheduling };
switch (scope) {
case 'this': {
if (event.recurrenceId) {
// Client-side expanded occurrence: write a one-shot override
// on the master instead of touching the series.
await updateEvent(event.id, buildRecurrenceOverridePatch(updates, event.recurrenceId));
await updateEvent(event.id, buildRecurrenceOverridePatch(updates, event.recurrenceId), opts);
} else {
await updateEvent(event.id, updates);
await updateEvent(event.id, updates, opts);
}
break;
}
@@ -484,7 +491,7 @@ export default function CalendarScreen() {
const targetCalendarId =
action.calendarId || getPrimaryCalendarId(master) || '';
try {
await createEvent(newEventData, targetCalendarId);
await createEvent(newEventData, targetCalendarId, opts);
} catch (createError) {
// Roll back the truncation so the series isn't left cut short.
try {
@@ -499,7 +506,7 @@ export default function CalendarScreen() {
case 'all': {
const master = await getMasterEvent(event);
if (!master) throw new Error('Master event not found');
await updateEvent(master.id, buildAllScopeUpdates(updates, event, master));
await updateEvent(master.id, buildAllScopeUpdates(updates, event, master), opts);
break;
}
}
@@ -544,7 +551,12 @@ export default function CalendarScreen() {
);
const handleSave = React.useCallback(
async (data: Partial<CalendarEvent>, calendarId: string) => {
async (
data: Partial<CalendarEvent>,
calendarId: string,
options?: { sendSchedulingMessages?: boolean },
) => {
const sendScheduling = options?.sendSchedulingMessages;
if (modalEvent) {
const updates: Partial<CalendarEvent> = { ...data };
// Moving the event to another calendar: the store remaps the store id
@@ -555,12 +567,12 @@ export default function CalendarScreen() {
if (isRecurringSeriesMember(modalEvent)) {
// Ask which occurrences the edit applies to; the actual write
// happens in handleScopeSelect.
setPendingAction({ kind: 'edit', event: modalEvent, updates, calendarId });
setPendingAction({ kind: 'edit', event: modalEvent, updates, calendarId, sendScheduling });
return;
}
await updateEvent(modalEvent.id, updates);
await updateEvent(modalEvent.id, updates, { sendSchedulingMessages: sendScheduling });
} else {
await createEvent(data, calendarId);
await createEvent(data, calendarId, { sendSchedulingMessages: sendScheduling });
}
},
[modalEvent, createEvent, updateEvent],
+23 -8
View File
@@ -165,10 +165,20 @@ export interface CalendarState {
ensureRange: (after: string, before: string) => Promise<void>;
refresh: () => Promise<void>;
handleStateChange: (change: StateChange) => Promise<void>;
createEvent: (event: Partial<CalendarEvent>, calendarId: string) => Promise<CalendarEvent>;
// `options.sendSchedulingMessages` overrides the default (send iMIP when
// the event has participants) — the editor's "send invitations" switch.
createEvent: (
event: Partial<CalendarEvent>,
calendarId: string,
options?: { sendSchedulingMessages?: boolean },
) => Promise<CalendarEvent>;
// `changes` may be a plain partial or a JMAP patch with JSON-pointer keys
// (e.g. `recurrenceOverrides/<recurrenceId>`).
updateEvent: (id: string, changes: Partial<CalendarEvent> | Record<string, unknown>) => Promise<void>;
updateEvent: (
id: string,
changes: Partial<CalendarEvent> | Record<string, unknown>,
options?: { sendSchedulingMessages?: boolean },
) => Promise<void>;
deleteEvent: (id: string) => Promise<void>;
// Resolve a client-side expanded occurrence (or a master) to its master
// event, fetching it from the server when the expansion replaced it.
@@ -428,17 +438,19 @@ export const useCalendarStore = create<CalendarState>()(
}
},
createEvent: async (event, calendarId) => {
createEvent: async (event, calendarId, options) => {
// Shared calendars live in the owner's account — route the create there,
// and against the calendar's raw server id (calendarId is the namespaced
// store id for shared calendars).
const calendars = get().calendars;
const cal = calendars.find((c) => c.id === calendarId);
const accountId = cal?.accountId;
const schedule =
options?.sendSchedulingMessages ?? (hasSchedulingParticipants(event) ? true : undefined);
const created = await apiCreateEvent(
event,
cal?.originalId || calendarId,
hasSchedulingParticipants(event) ? true : undefined,
schedule,
accountId,
);
// The /set echo lacks server-computed properties (utcStart/utcEnd, the
@@ -465,7 +477,7 @@ export const useCalendarStore = create<CalendarState>()(
return full;
},
updateEvent: async (id, changes) => {
updateEvent: async (id, changes, options) => {
// Resolve client-side expanded occurrence IDs back to the master event ID.
const storeEvent = get().events.find((e) => e.id === id);
const realId = storeEvent?.originalId || id;
@@ -484,9 +496,12 @@ export const useCalendarStore = create<CalendarState>()(
// Notify attendees if either the stored event or the incoming changes
// carry participants.
const schedule =
hasSchedulingParticipants(changes as Partial<CalendarEvent>) ||
hasSchedulingParticipants(storeEvent);
await apiUpdateEvent(realId, patch, schedule ? true : undefined, storeEvent?.accountId);
options?.sendSchedulingMessages ??
(hasSchedulingParticipants(changes as Partial<CalendarEvent>) ||
hasSchedulingParticipants(storeEvent)
? true
: undefined);
await apiUpdateEvent(realId, patch, schedule, storeEvent?.accountId);
set({
events: get().events.map((e) => (e.id === id ? { ...e, ...(changes as Partial<CalendarEvent>) } : e)),
});