Fix recurrence edit/delete scope and simplify weekly UI

- Weekly recurrence no longer requires manual weekday selection;
  auto-derives from event start date
- EventForm now receives and forwards editScope prop to API
  (edit_scope in PUT body, scope query param in DELETE)
- CalendarPage passes scope through proper prop instead of _editScope hack
- Backend this_and_future: inherits parent's recurrence_rule when child
  has none, properly regenerates children after edit
- Backend: parent-level edits now delete+regenerate all children

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-22 01:08:00 +08:00
parent 232bdd3ef2
commit f826d05c60
3 changed files with 43 additions and 20 deletions

View File

@ -304,6 +304,13 @@ async def update_event(
this_original_start = event.original_start or event.start_datetime this_original_start = event.original_start or event.start_datetime
if parent_id is not None: if parent_id is not None:
# Fetch the parent's recurrence_rule before deleting siblings
parent_result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == parent_id)
)
parent_event = parent_result.scalar_one_or_none()
parent_rule = parent_event.recurrence_rule if parent_event else None
await db.execute( await db.execute(
delete(CalendarEvent).where( delete(CalendarEvent).where(
CalendarEvent.parent_event_id == parent_id, CalendarEvent.parent_event_id == parent_id,
@ -318,17 +325,33 @@ async def update_event(
event.is_recurring = True event.is_recurring = True
event.original_start = None event.original_start = None
# If a new recurrence_rule was provided, regenerate children from this point # Inherit parent's recurrence_rule if none was provided in update
if not event.recurrence_rule and parent_rule:
event.recurrence_rule = parent_rule
# Regenerate children from this point
if event.recurrence_rule: if event.recurrence_rule:
await db.flush() await db.flush()
children = generate_occurrences(event) children = generate_occurrences(event)
for child in children: for child in children:
db.add(child) db.add(child)
else: else:
# Not part of a series — plain update # This IS a parent — update it and regenerate all children
for key, value in update_data.items(): for key, value in update_data.items():
setattr(event, key, value) setattr(event, key, value)
# Delete all existing children and regenerate
if event.recurrence_rule:
await db.execute(
delete(CalendarEvent).where(
CalendarEvent.parent_event_id == event.id
)
)
await db.flush()
children = generate_occurrences(event)
for child in children:
db.add(child)
await db.commit() await db.commit()
else: else:

View File

@ -46,6 +46,7 @@ export default function CalendarPage() {
const [scopeDialogOpen, setScopeDialogOpen] = useState(false); const [scopeDialogOpen, setScopeDialogOpen] = useState(false);
const [scopeAction, setScopeAction] = useState<ScopeAction>('edit'); const [scopeAction, setScopeAction] = useState<ScopeAction>('edit');
const [scopeEvent, setScopeEvent] = useState<CalendarEvent | null>(null); const [scopeEvent, setScopeEvent] = useState<CalendarEvent | null>(null);
const [activeEditScope, setActiveEditScope] = useState<'this' | 'this_and_future' | null>(null);
const { data: calendars = [] } = useCalendars(); const { data: calendars = [] } = useCalendars();
@ -177,8 +178,8 @@ export default function CalendarPage() {
const handleScopeChoice = (scope: 'this' | 'this_and_future') => { const handleScopeChoice = (scope: 'this' | 'this_and_future') => {
if (!scopeEvent) return; if (!scopeEvent) return;
if (scopeAction === 'edit') { if (scopeAction === 'edit') {
// For edits, open form — the form will send scope on save setEditingEvent(scopeEvent);
setEditingEvent({ ...scopeEvent, _editScope: scope } as any); setActiveEditScope(scope);
setShowForm(true); setShowForm(true);
setScopeDialogOpen(false); setScopeDialogOpen(false);
} else if (scopeAction === 'delete') { } else if (scopeAction === 'delete') {
@ -235,6 +236,7 @@ export default function CalendarPage() {
calendarRef.current?.getApi().unselect(); calendarRef.current?.getApi().unselect();
setShowForm(false); setShowForm(false);
setEditingEvent(null); setEditingEvent(null);
setActiveEditScope(null);
setSelectedStart(null); setSelectedStart(null);
setSelectedEnd(null); setSelectedEnd(null);
setSelectedAllDay(false); setSelectedAllDay(false);
@ -323,6 +325,7 @@ export default function CalendarPage() {
initialStart={selectedStart} initialStart={selectedStart}
initialEnd={selectedEnd} initialEnd={selectedEnd}
initialAllDay={selectedAllDay} initialAllDay={selectedAllDay}
editScope={activeEditScope}
onClose={handleCloseForm} onClose={handleCloseForm}
/> />
)} )}

View File

@ -25,6 +25,7 @@ interface EventFormProps {
initialStart?: string | null; initialStart?: string | null;
initialEnd?: string | null; initialEnd?: string | null;
initialAllDay?: boolean; initialAllDay?: boolean;
editScope?: 'this' | 'this_and_future' | null;
onClose: () => void; onClose: () => void;
} }
@ -74,7 +75,7 @@ function parseRecurrenceRule(raw?: string): RecurrenceRule | null {
} }
} }
export default function EventForm({ event, initialStart, initialEnd, initialAllDay, onClose }: EventFormProps) { export default function EventForm({ event, initialStart, initialEnd, initialAllDay, editScope, onClose }: EventFormProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: calendars = [] } = useCalendars(); const { data: calendars = [] } = useCalendars();
const isAllDay = event?.all_day ?? initialAllDay ?? false; const isAllDay = event?.all_day ?? initialAllDay ?? false;
@ -125,7 +126,8 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
case 'every_n_days': case 'every_n_days':
return { type: 'every_n_days', interval: recurrenceInterval }; return { type: 'every_n_days', interval: recurrenceInterval };
case 'weekly': case 'weekly':
return { type: 'weekly', weekday: recurrenceWeekday }; // No weekday needed — backend derives it from the event's start date
return { type: 'weekly' };
case 'monthly_nth_weekday': case 'monthly_nth_weekday':
return { type: 'monthly_nth_weekday', week: recurrenceWeek, weekday: recurrenceWeekday }; return { type: 'monthly_nth_weekday', week: recurrenceWeek, weekday: recurrenceWeekday };
case 'monthly_date': case 'monthly_date':
@ -145,7 +147,7 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
endDt = adjustAllDayEndForSave(endDt); endDt = adjustAllDayEndForSave(endDt);
} }
const payload = { const payload: Record<string, any> = {
title: data.title, title: data.title,
description: data.description, description: data.description,
start_datetime: data.start_datetime, start_datetime: data.start_datetime,
@ -158,6 +160,9 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
}; };
if (event) { if (event) {
if (editScope) {
payload.edit_scope = editScope;
}
const response = await api.put(`/events/${event.id}`, payload); const response = await api.put(`/events/${event.id}`, payload);
return response.data; return response.data;
} else { } else {
@ -179,7 +184,8 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
await api.delete(`/events/${event?.id}`); const params = editScope ? `?scope=${editScope}` : '';
await api.delete(`/events/${event?.id}${params}`);
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
@ -346,18 +352,9 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
)} )}
{recurrenceType === 'weekly' && ( {recurrenceType === 'weekly' && (
<div className="space-y-2"> <p className="text-xs text-muted-foreground">
<Label htmlFor="weekday">Day of week</Label> Repeats every week on the same day as the start date.
<Select </p>
id="weekday"
value={recurrenceWeekday.toString()}
onChange={(e) => setRecurrenceWeekday(parseInt(e.target.value))}
>
{WEEKDAYS.map((name, i) => (
<option key={i} value={i}>{name}</option>
))}
</Select>
</div>
)} )}
{recurrenceType === 'monthly_nth_weekday' && ( {recurrenceType === 'monthly_nth_weekday' && (