Merge pull request #28 from LINUXexpert-org/calendar-recurring-warning

Stop calling every event a series
This commit is contained in:
LINUXexpert.org
2026-08-25 07:53:00 -07:00
committed by GitHub
5 changed files with 47 additions and 6 deletions
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { isRecurring } from "@/store/calendar";
import type { CalendarEvent } from "@/jmap/types";
/**
* `CalendarEvent/query` runs with `expandRecurrences`, and Stalwart puts a
* `baseEventId` on everything it returns that way — one-off events included.
* Treating that as proof of a series told people editing a plain event that
* their changes applied to the whole series, and offered to delete "all
* occurrences" of an event that has exactly one.
*/
const ev = (p: Partial<CalendarEvent>): CalendarEvent => ({ id: "ev1", "@type": "Event", uid: "u1", calendarIds: { c1: true }, start: "2026-08-25T09:00:00", duration: "PT30M", ...p } as CalendarEvent);
describe("isRecurring", () => {
it("does not call a one-off event a series just because it has a baseEventId", () => {
expect(isRecurring(ev({ baseEventId: "ev1" }))).toBe(false);
expect(isRecurring(ev({}))).toBe(false);
});
it("still recognises an occurrence whose base is another event", () => {
expect(isRecurring(ev({ id: "ev1_2", baseEventId: "ev1" }))).toBe(true);
});
it("recognises a series by its recurrence rules", () => {
expect(isRecurring(ev({ recurrenceRules: [{ "@type": "RecurrenceRule", frequency: "weekly" }] }))).toBe(true);
expect(isRecurring(ev({ baseEventId: "ev1", recurrenceRules: [{ "@type": "RecurrenceRule", frequency: "daily" }] }))).toBe(true);
expect(isRecurring(ev({ excludedRecurrenceRules: [{ "@type": "RecurrenceRule", frequency: "monthly" }] }))).toBe(true);
});
});
+14
View File
@@ -297,6 +297,20 @@ export const useCalendar = create<CalendarState>((set, get) => ({
},
}));
/**
* Whether an event is part of a series.
*
* Not the same question as "does it have a baseEventId": `CalendarEvent/query`
* runs with `expandRecurrences`, and Stalwart sets `baseEventId` on every event
* it returns that way — a one-off event included, pointing at itself. Recurrence
* rules are what make a series, so those are the question; a base that is some
* *other* event means this is one occurrence of one, whether or not the expanded
* instance carried the rules along with it.
*/
export function isRecurring(ev: CalendarEvent): boolean {
return Boolean(ev.recurrenceRules?.length || ev.excludedRecurrenceRules?.length || (ev.baseEventId && ev.baseEventId !== ev.id));
}
export function toInstance(e: CalendarEvent, calendars: Record<Id, Calendar>): EventInstance | null {
const allDay = Boolean(e.showWithoutTime);
let start: Date;
@@ -1,7 +1,7 @@
import { Calendar as CalIcon, CalendarDays, Copy, ExternalLink, Palette, Pencil, Plus, Tag, Trash2, X } from "lucide-react";
import { useLocation } from "wouter";
import type { CalendarEvent } from "@/jmap/types";
import { useCalendar, type EventInstance } from "@/store/calendar";
import { useCalendar, isRecurring, type EventInstance } from "@/store/calendar";
import { useSettings } from "@/store/settings";
import { formatDayMonth } from "@/lib/datetime";
import { MenuItem, MenuSep, MenuTitle, Popover, type Anchor } from "@/ui/popover";
@@ -89,7 +89,7 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
};
const del = async () => {
onClose();
const recurring = Boolean(ev.recurrenceRules?.length || ev.baseEventId);
const recurring = isRecurring(ev);
if (!(await confirmDialog({ title: recurring ? "Delete all occurrences?" : "Delete this event?", confirmLabel: "Delete", danger: true }))) return;
try {
await cal.destroyEvent(baseId, participants > 1);
+2 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { Plus, Trash2, Users } from "lucide-react";
import type { BusyPeriod, CalendarEvent, EmailAddress, JSCalendarAlert, JSCalendarParticipant, JSCalendarRecurrenceRule, JSCalendarNDay } from "@/jmap/types";
import { useCalendar, myParticipantKeys } from "@/store/calendar";
import { useCalendar, myParticipantKeys, isRecurring } from "@/store/calendar";
import { useSettings } from "@/store/settings";
import { useSession } from "@/store/session";
import { useContacts } from "@/store/contacts";
@@ -202,7 +202,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
return (
<Dialog open onClose={onClose} title={editing ? "Edit event" : "New event"} size="lg" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : editing ? "Save" : attendees.length && sendInvites ? "Send invites" : "Create"}</button></>}>
<div className="event-form">
{init.event?.baseEventId && <div className="info-box mb-16">This is a recurring event changes apply to the whole series.</div>}
{ev && isRecurring(ev) && <div className="info-box mb-16">This is a recurring event changes apply to the whole series.</div>}
<div className="field"><input className="input" style={{ fontSize: "1.1em", height: 44 }} placeholder="Add title" autoFocus value={title} onChange={(e) => setTitle(e.target.value)} /></div>
<div className="time-row mb-8">
{allDay ? (
+2 -2
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { AlignLeft, Bell, Calendar as CalIcon, Check, Clock, HelpCircle, Link2, MapPin, Pencil, Repeat, Trash2, Users, X, Mail } from "lucide-react";
import { useCalendar, myParticipantKeys, type EventInstance } from "@/store/calendar";
import { useCalendar, myParticipantKeys, isRecurring, type EventInstance } from "@/store/calendar";
import { Popover, type Anchor } from "@/ui/popover";
import { confirmDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
@@ -29,7 +29,7 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
const openCompose = useCompose((s) => s.open);
const del = async () => {
const recurring = Boolean(ev.recurrenceRules?.length || ev.baseEventId);
const recurring = isRecurring(ev);
const ok = await confirmDialog({ title: recurring ? "Delete all occurrences?" : "Delete this event?", message: recurring ? "This will delete the entire series." : undefined, confirmLabel: "Delete", danger: true });
if (!ok) return;
setBusy(true);