tenseleyflow/rcal / 9cb416e

Browse files

Add local event deletion

Authored by espadonne
SHA
9cb416effa450c59abfbbede48aa1a7b3325653c
Parents
24cd0c7
Tree
61ef0a0

5 changed files

StatusFile+-
M README.md 4 4
M src/agenda.rs 232 0
M src/app.rs 358 2
M src/cli.rs 36 5
M src/tui.rs 104 1
README.mdmodified
@@ -50,6 +50,7 @@ access.
5050
 
5151
 - Arrow keys move the selected date.
5252
 - `+` opens the Create event modal.
53
+- In day view, `d` opens the Delete confirmation for the selected local event.
5354
 - `Enter` opens the focused day view.
5455
 - `Esc` returns from day view to month view.
5556
 - `q` exits.
@@ -64,9 +65,9 @@ access.
6465
   day view.
6566
 
6667
 Created events are stored locally as JSON and are shown immediately in month,
67
-week, and day views. The create modal supports timed events, single-day all-day
68
-events, location, notes, and multiple reminder offsets; reminder notifications
69
-are not delivered yet.
68
+week, and day views. The create/edit modal supports timed events, single-day
69
+all-day events, recurrence, location, notes, and multiple reminder offsets.
70
+Reminder notifications are not delivered yet.
7071
 
7172
 ## Layout
7273
 
@@ -78,7 +79,6 @@ back to a focused day summary.
7879
 
7980
 - Real account integrations for Outlook, Google Calendar, Exchange, and similar
8081
   providers are not implemented yet.
81
-- Editing or deleting existing events is not implemented yet.
8282
 - Reminder offsets are stored but do not trigger notifications yet.
8383
 - Packaging is currently source-based through Cargo.
8484
 
src/agenda.rsmodified
@@ -264,6 +264,7 @@ pub struct Event {
264264
     pub recurrence: Option<RecurrenceRule>,
265265
     pub occurrence: Option<OccurrenceMetadata>,
266266
     pub occurrence_overrides: Vec<OccurrenceOverride>,
267
+    pub deleted_occurrences: Vec<OccurrenceAnchor>,
267268
 }
268269
 
269270
 impl Event {
@@ -284,6 +285,7 @@ impl Event {
284285
             recurrence: None,
285286
             occurrence: None,
286287
             occurrence_overrides: Vec::new(),
288
+            deleted_occurrences: Vec::new(),
287289
         }
288290
     }
289291
 
@@ -309,6 +311,7 @@ impl Event {
309311
             recurrence: None,
310312
             occurrence: None,
311313
             occurrence_overrides: Vec::new(),
314
+            deleted_occurrences: Vec::new(),
312315
         })
313316
     }
314317
 
@@ -616,6 +619,11 @@ impl ConfiguredAgendaSource {
616619
             .into_iter()
617620
             .filter(|override_record| event_generates_anchor(&event, override_record.anchor))
618621
             .collect();
622
+        let existing_deleted_occurrences = std::mem::take(&mut events[index].deleted_occurrences);
623
+        event.deleted_occurrences = existing_deleted_occurrences
624
+            .into_iter()
625
+            .filter(|anchor| event_generates_anchor(&event, *anchor))
626
+            .collect();
619627
         events[index] = event.clone();
620628
 
621629
         if let Some(path) = &self.events_file {
@@ -662,6 +670,9 @@ impl ConfiguredAgendaSource {
662670
         } else {
663671
             events[index].occurrence_overrides.push(override_record);
664672
         }
673
+        events[index]
674
+            .deleted_occurrences
675
+            .retain(|deleted_anchor| *deleted_anchor != anchor);
665676
 
666677
         let event = occurrence_override_event(&events[index], anchor).ok_or_else(|| {
667678
             LocalEventStoreError::OccurrenceNotFound {
@@ -677,6 +688,60 @@ impl ConfiguredAgendaSource {
677688
         Ok(event)
678689
     }
679690
 
691
+    pub fn delete_event(&mut self, id: &str) -> Result<Event, LocalEventStoreError> {
692
+        let mut events = self.events.events().to_vec();
693
+        let Some(index) = events.iter().position(|event| event.id == id) else {
694
+            return Err(LocalEventStoreError::EventNotFound { id: id.to_string() });
695
+        };
696
+        if !events[index].is_local() {
697
+            return Err(LocalEventStoreError::EventNotEditable { id: id.to_string() });
698
+        }
699
+
700
+        let deleted = events.remove(index);
701
+        if let Some(path) = &self.events_file {
702
+            write_events_file(path, &events)?;
703
+        }
704
+        self.events.events = events;
705
+        Ok(deleted)
706
+    }
707
+
708
+    pub fn delete_occurrence(
709
+        &mut self,
710
+        series_id: &str,
711
+        anchor: OccurrenceAnchor,
712
+    ) -> Result<(), LocalEventStoreError> {
713
+        let mut events = self.events.events().to_vec();
714
+        let Some(index) = events.iter().position(|event| event.id == series_id) else {
715
+            return Err(LocalEventStoreError::EventNotFound {
716
+                id: series_id.to_string(),
717
+            });
718
+        };
719
+        if !events[index].is_local() || !events[index].is_recurring_series() {
720
+            return Err(LocalEventStoreError::EventNotEditable {
721
+                id: series_id.to_string(),
722
+            });
723
+        }
724
+        if !event_generates_anchor(&events[index], anchor) {
725
+            return Err(LocalEventStoreError::OccurrenceNotFound {
726
+                id: series_id.to_string(),
727
+                anchor: anchor.storage_key(),
728
+            });
729
+        }
730
+
731
+        events[index]
732
+            .occurrence_overrides
733
+            .retain(|override_record| override_record.anchor != anchor);
734
+        if !events[index].deleted_occurrences.contains(&anchor) {
735
+            events[index].deleted_occurrences.push(anchor);
736
+        }
737
+
738
+        if let Some(path) = &self.events_file {
739
+            write_events_file(path, &events)?;
740
+        }
741
+        self.events.events = events;
742
+        Ok(())
743
+    }
744
+
680745
     fn next_local_event_id(&self, title: &str) -> String {
681746
         let now = SystemTime::now()
682747
             .duration_since(UNIX_EPOCH)
@@ -1039,6 +1104,10 @@ fn expand_recurring_event(event: &Event, range: DateRange) -> Vec<Event> {
10391104
             }
10401105
 
10411106
             let anchor = occurrence_anchor_for_date(event, date);
1107
+            if event.deleted_occurrences.contains(&anchor) {
1108
+                date = date.add_days(1);
1109
+                continue;
1110
+            }
10421111
             let instance = occurrence_override_event(event, anchor)
10431112
                 .unwrap_or_else(|| generated_occurrence_event(event, anchor));
10441113
             events.push(instance);
@@ -1110,6 +1179,7 @@ fn generated_occurrence_event(series: &Event, anchor: OccurrenceAnchor) -> Event
11101179
     });
11111180
     event.recurrence = None;
11121181
     event.occurrence_overrides = Vec::new();
1182
+    event.deleted_occurrences = Vec::new();
11131183
     event
11141184
 }
11151185
 
@@ -1511,6 +1581,8 @@ enum LocalEventRecord {
15111581
         recurrence: Option<LocalRecurrenceRecord>,
15121582
         #[serde(default, skip_serializing_if = "Vec::is_empty")]
15131583
         overrides: Vec<LocalOccurrenceOverrideRecord>,
1584
+        #[serde(default, skip_serializing_if = "Vec::is_empty")]
1585
+        deleted_occurrences: Vec<LocalOccurrenceAnchorRecord>,
15141586
     },
15151587
     AllDay {
15161588
         id: String,
@@ -1526,6 +1598,8 @@ enum LocalEventRecord {
15261598
         recurrence: Option<LocalRecurrenceRecord>,
15271599
         #[serde(default, skip_serializing_if = "Vec::is_empty")]
15281600
         overrides: Vec<LocalOccurrenceOverrideRecord>,
1601
+        #[serde(default, skip_serializing_if = "Vec::is_empty")]
1602
+        deleted_occurrences: Vec<LocalOccurrenceAnchorRecord>,
15291603
     },
15301604
 }
15311605
 
@@ -1545,6 +1619,12 @@ impl LocalEventRecord {
15451619
             .iter()
15461620
             .map(LocalOccurrenceOverrideRecord::from_override)
15471621
             .collect::<Vec<_>>();
1622
+        let deleted_occurrences = event
1623
+            .deleted_occurrences
1624
+            .iter()
1625
+            .copied()
1626
+            .map(LocalOccurrenceAnchorRecord::from_anchor)
1627
+            .collect::<Vec<_>>();
15481628
 
15491629
         match event.timing {
15501630
             EventTiming::AllDay { date } => Self::AllDay {
@@ -1556,6 +1636,7 @@ impl LocalEventRecord {
15561636
                 reminders_minutes_before,
15571637
                 recurrence,
15581638
                 overrides,
1639
+                deleted_occurrences,
15591640
             },
15601641
             EventTiming::Timed { start, end } => Self::Timed {
15611642
                 id: event.id.clone(),
@@ -1569,6 +1650,7 @@ impl LocalEventRecord {
15691650
                 reminders_minutes_before,
15701651
                 recurrence,
15711652
                 overrides,
1653
+                deleted_occurrences,
15721654
             },
15731655
         }
15741656
     }
@@ -1587,6 +1669,7 @@ impl LocalEventRecord {
15871669
                 reminders_minutes_before,
15881670
                 recurrence,
15891671
                 overrides,
1672
+                deleted_occurrences,
15901673
             } => {
15911674
                 let start = EventDateTime::new(
15921675
                     parse_local_date(&start_date, path)?,
@@ -1617,6 +1700,10 @@ impl LocalEventRecord {
16171700
                     .into_iter()
16181701
                     .map(|override_record| override_record.into_override(path))
16191702
                     .collect::<Result<Vec<_>, _>>()?;
1703
+                event.deleted_occurrences = deleted_occurrences
1704
+                    .into_iter()
1705
+                    .map(|anchor| anchor.into_anchor(path))
1706
+                    .collect::<Result<Vec<_>, _>>()?;
16201707
                 Ok(event)
16211708
             }
16221709
             Self::AllDay {
@@ -1628,6 +1715,7 @@ impl LocalEventRecord {
16281715
                 reminders_minutes_before,
16291716
                 recurrence,
16301717
                 overrides,
1718
+                deleted_occurrences,
16311719
             } => {
16321720
                 let mut event = Event::all_day(
16331721
                     id.clone(),
@@ -1645,6 +1733,10 @@ impl LocalEventRecord {
16451733
                     .into_iter()
16461734
                     .map(|override_record| override_record.into_override(path))
16471735
                     .collect::<Result<Vec<_>, _>>()?;
1736
+                event.deleted_occurrences = deleted_occurrences
1737
+                    .into_iter()
1738
+                    .map(|anchor| anchor.into_anchor(path))
1739
+                    .collect::<Result<Vec<_>, _>>()?;
16481740
                 Ok(event)
16491741
             }
16501742
         }
@@ -3182,6 +3274,146 @@ mod tests {
31823274
         );
31833275
     }
31843276
 
3277
+    #[test]
3278
+    fn local_event_store_deletes_single_event_and_persists() {
3279
+        let path = temp_events_path("delete-event");
3280
+        let _ = std::fs::remove_dir_all(path.parent().expect("path has parent"));
3281
+        let day = date(23);
3282
+        let mut source = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3283
+            .expect("missing event file is empty");
3284
+        let event = source
3285
+            .create_event(CreateEventDraft {
3286
+                title: "Planning".to_string(),
3287
+                timing: CreateEventTiming::Timed {
3288
+                    start: at(day, 9, 0),
3289
+                    end: at(day, 10, 0),
3290
+                },
3291
+                location: None,
3292
+                notes: None,
3293
+                reminders: Vec::new(),
3294
+                recurrence: None,
3295
+            })
3296
+            .expect("event saves");
3297
+
3298
+        let deleted = source.delete_event(&event.id).expect("event deletes");
3299
+        let reloaded = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3300
+            .expect("saved file reloads");
3301
+        let agenda = DayAgenda::from_source(day, &reloaded);
3302
+
3303
+        let _ = std::fs::remove_dir_all(path.parent().expect("test dir exists"));
3304
+
3305
+        assert_eq!(deleted.id, event.id);
3306
+        assert!(agenda.is_empty());
3307
+    }
3308
+
3309
+    #[test]
3310
+    fn local_event_store_deletes_one_recurring_occurrence_and_persists() {
3311
+        let path = temp_events_path("delete-occurrence");
3312
+        let _ = std::fs::remove_dir_all(path.parent().expect("path has parent"));
3313
+        let day = date(23);
3314
+        let mut source = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3315
+            .expect("missing event file is empty");
3316
+        let event = source
3317
+            .create_event(CreateEventDraft {
3318
+                title: "Standup".to_string(),
3319
+                timing: CreateEventTiming::Timed {
3320
+                    start: at(day, 9, 0),
3321
+                    end: at(day, 9, 30),
3322
+                },
3323
+                location: None,
3324
+                notes: None,
3325
+                reminders: Vec::new(),
3326
+                recurrence: Some(RecurrenceRule {
3327
+                    frequency: RecurrenceFrequency::Daily,
3328
+                    interval: 1,
3329
+                    end: RecurrenceEnd::Count(3),
3330
+                    weekdays: Vec::new(),
3331
+                    monthly: None,
3332
+                    yearly: None,
3333
+                }),
3334
+            })
3335
+            .expect("recurring event saves");
3336
+        let deleted_anchor = OccurrenceAnchor::Timed {
3337
+            start: at(day.add_days(1), 9, 0),
3338
+        };
3339
+
3340
+        source
3341
+            .update_occurrence(
3342
+                &event.id,
3343
+                deleted_anchor,
3344
+                CreateEventDraft {
3345
+                    title: "Moved".to_string(),
3346
+                    timing: CreateEventTiming::Timed {
3347
+                        start: at(day.add_days(1), 10, 0),
3348
+                        end: at(day.add_days(1), 10, 30),
3349
+                    },
3350
+                    location: None,
3351
+                    notes: None,
3352
+                    reminders: Vec::new(),
3353
+                    recurrence: None,
3354
+                },
3355
+            )
3356
+            .expect("override saves before delete");
3357
+        source
3358
+            .delete_occurrence(&event.id, deleted_anchor)
3359
+            .expect("occurrence deletes");
3360
+        let reloaded = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3361
+            .expect("saved file reloads");
3362
+
3363
+        let first = DayAgenda::from_source(day, &reloaded);
3364
+        let second = DayAgenda::from_source(day.add_days(1), &reloaded);
3365
+        let third = DayAgenda::from_source(day.add_days(2), &reloaded);
3366
+
3367
+        let _ = std::fs::remove_dir_all(path.parent().expect("test dir exists"));
3368
+
3369
+        assert_eq!(first.timed_events.len(), 1);
3370
+        assert!(second.timed_events.is_empty());
3371
+        assert_eq!(third.timed_events.len(), 1);
3372
+        let stored = reloaded
3373
+            .local_event_by_id(&event.id)
3374
+            .expect("series exists");
3375
+        assert_eq!(stored.deleted_occurrences, vec![deleted_anchor]);
3376
+        assert!(stored.occurrence_overrides.is_empty());
3377
+    }
3378
+
3379
+    #[test]
3380
+    fn local_event_store_delete_series_removes_all_occurrences() {
3381
+        let path = temp_events_path("delete-series");
3382
+        let _ = std::fs::remove_dir_all(path.parent().expect("path has parent"));
3383
+        let day = date(23);
3384
+        let mut source = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3385
+            .expect("missing event file is empty");
3386
+        let event = source
3387
+            .create_event(CreateEventDraft {
3388
+                title: "Standup".to_string(),
3389
+                timing: CreateEventTiming::Timed {
3390
+                    start: at(day, 9, 0),
3391
+                    end: at(day, 9, 30),
3392
+                },
3393
+                location: None,
3394
+                notes: None,
3395
+                reminders: Vec::new(),
3396
+                recurrence: Some(RecurrenceRule {
3397
+                    frequency: RecurrenceFrequency::Daily,
3398
+                    interval: 1,
3399
+                    end: RecurrenceEnd::Count(3),
3400
+                    weekdays: Vec::new(),
3401
+                    monthly: None,
3402
+                    yearly: None,
3403
+                }),
3404
+            })
3405
+            .expect("recurring event saves");
3406
+
3407
+        source.delete_event(&event.id).expect("series deletes");
3408
+        let reloaded = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3409
+            .expect("saved file reloads");
3410
+        let range = DateRange::new(day, day.add_days(4)).expect("valid range");
3411
+
3412
+        let _ = std::fs::remove_dir_all(path.parent().expect("test dir exists"));
3413
+
3414
+        assert!(reloaded.events_intersecting(range).is_empty());
3415
+    }
3416
+
31853417
     #[test]
31863418
     fn series_edits_drop_overrides_whose_anchor_no_longer_generates() {
31873419
         let path = temp_events_path("series-edit-overrides");
src/app.rsmodified
@@ -25,6 +25,7 @@ pub struct AppState {
2525
     view_mode: ViewMode,
2626
     create_form: Option<CreateEventForm>,
2727
     recurrence_choice: Option<RecurrenceEditChoice>,
28
+    delete_choice: Option<EventDeleteChoice>,
2829
     selected_day_event_id: Option<String>,
2930
     should_quit: bool,
3031
 }
@@ -41,6 +42,7 @@ impl AppState {
4142
             view_mode: ViewMode::Month,
4243
             create_form: None,
4344
             recurrence_choice: None,
45
+            delete_choice: None,
4446
             selected_day_event_id: None,
4547
             should_quit: false,
4648
         }
@@ -70,6 +72,10 @@ impl AppState {
7072
         self.recurrence_choice.as_ref()
7173
     }
7274
 
75
+    pub const fn delete_choice(&self) -> Option<&EventDeleteChoice> {
76
+        self.delete_choice.as_ref()
77
+    }
78
+
7379
     pub fn selected_day_event_id(&self) -> Option<&str> {
7480
         self.selected_day_event_id.as_deref()
7581
     }
@@ -82,6 +88,10 @@ impl AppState {
8288
         self.recurrence_choice.is_some()
8389
     }
8490
 
91
+    pub const fn is_confirming_delete(&self) -> bool {
92
+        self.delete_choice.is_some()
93
+    }
94
+
8595
     pub fn close_create_form(&mut self) {
8696
         self.create_form = None;
8797
     }
@@ -90,6 +100,16 @@ impl AppState {
90100
         self.recurrence_choice = None;
91101
     }
92102
 
103
+    pub fn close_delete_choice(&mut self) {
104
+        self.delete_choice = None;
105
+    }
106
+
107
+    pub fn set_delete_error(&mut self, message: impl Into<String>) {
108
+        if let Some(choice) = &mut self.delete_choice {
109
+            choice.error = Some(message.into());
110
+        }
111
+    }
112
+
93113
     pub fn set_create_form_error(&mut self, message: impl Into<String>) {
94114
         if let Some(form) = &mut self.create_form {
95115
             form.error = Some(message.into());
@@ -161,6 +181,54 @@ impl AppState {
161181
         }
162182
     }
163183
 
184
+    pub fn handle_delete_choice_key(&mut self, key: KeyEvent) -> EventDeleteInputResult {
185
+        if key.kind == KeyEventKind::Release {
186
+            return EventDeleteInputResult::Continue;
187
+        }
188
+
189
+        let Some(choice) = &mut self.delete_choice else {
190
+            return EventDeleteInputResult::Continue;
191
+        };
192
+
193
+        match key.code {
194
+            KeyCode::Esc => EventDeleteInputResult::Cancel,
195
+            KeyCode::Up => {
196
+                choice.select_previous();
197
+                EventDeleteInputResult::Continue
198
+            }
199
+            KeyCode::Down => {
200
+                choice.select_next();
201
+                EventDeleteInputResult::Continue
202
+            }
203
+            KeyCode::Enter => match choice.selected_action() {
204
+                EventDeleteChoiceAction::Cancel => EventDeleteInputResult::Cancel,
205
+                EventDeleteChoiceAction::DeleteEvent => {
206
+                    EventDeleteInputResult::Submit(EventDeleteSubmission::Event {
207
+                        event_id: choice.event_id().to_string(),
208
+                    })
209
+                }
210
+                EventDeleteChoiceAction::DeleteThisOccurrence => {
211
+                    let EventDeleteTarget::Occurrence { series_id, anchor } = &choice.target else {
212
+                        return EventDeleteInputResult::Continue;
213
+                    };
214
+                    EventDeleteInputResult::Submit(EventDeleteSubmission::Occurrence {
215
+                        series_id: series_id.clone(),
216
+                        anchor: *anchor,
217
+                    })
218
+                }
219
+                EventDeleteChoiceAction::DeleteSeries => {
220
+                    let EventDeleteTarget::Occurrence { series_id, .. } = &choice.target else {
221
+                        return EventDeleteInputResult::Continue;
222
+                    };
223
+                    EventDeleteInputResult::Submit(EventDeleteSubmission::Series {
224
+                        series_id: series_id.clone(),
225
+                    })
226
+                }
227
+            },
228
+            _ => EventDeleteInputResult::Continue,
229
+        }
230
+    }
231
+
164232
     pub fn calendar_month(&self) -> CalendarMonth {
165233
         CalendarMonth::from_dates(self.selected_date, self.today)
166234
     }
@@ -215,9 +283,13 @@ impl AppState {
215283
                 self.view_mode = ViewMode::Month;
216284
                 self.selected_day_event_id = None;
217285
                 self.recurrence_choice = None;
286
+                self.delete_choice = None;
218287
             }
219288
             AppAction::OpenCreate => {
220
-                if self.create_form.is_none() && self.recurrence_choice.is_none() {
289
+                if self.create_form.is_none()
290
+                    && self.recurrence_choice.is_none()
291
+                    && self.delete_choice.is_none()
292
+                {
221293
                     let context = match self.view_mode {
222294
                         ViewMode::Month => CreateEventContext::EditableDate,
223295
                         ViewMode::Day => CreateEventContext::FixedDate,
@@ -225,6 +297,15 @@ impl AppState {
225297
                     self.create_form = Some(CreateEventForm::new(self.selected_date, context));
226298
                 }
227299
             }
300
+            AppAction::OpenDelete if self.view_mode == ViewMode::Day => {
301
+                if self.create_form.is_none()
302
+                    && self.recurrence_choice.is_none()
303
+                    && self.delete_choice.is_none()
304
+                    && let Some(source) = source
305
+                {
306
+                    self.open_selected_event_for_delete(source);
307
+                }
308
+            }
228309
             AppAction::MoveDays(days) if self.view_mode == ViewMode::Month => {
229310
                 self.selected_date = self.selected_date.add_days(days);
230311
             }
@@ -264,7 +345,8 @@ impl AppState {
264345
             AppAction::MoveDays(_)
265346
             | AppAction::SelectDate(_)
266347
             | AppAction::JumpToDay(_)
267
-            | AppAction::JumpToWeekday(_) => {}
348
+            | AppAction::JumpToWeekday(_)
349
+            | AppAction::OpenDelete => {}
268350
         }
269351
     }
270352
 
@@ -309,6 +391,19 @@ impl AppState {
309391
         }
310392
     }
311393
 
394
+    fn open_selected_event_for_delete(&mut self, source: &dyn AgendaSource) {
395
+        self.reconcile_day_event_selection(source);
396
+        let Some(selected_id) = self.selected_day_event_id.as_deref() else {
397
+            return;
398
+        };
399
+        if let Some(event) = selectable_day_events(self.selected_date, source)
400
+            .into_iter()
401
+            .find(|event| event.id == selected_id)
402
+        {
403
+            self.delete_choice = Some(EventDeleteChoice::for_event(&event));
404
+        }
405
+    }
406
+
312407
     fn weekday_in_selected_week(&self, weekday: Weekday) -> Option<CalendarDate> {
313408
         let month = self.calendar_month();
314409
         let selected = month.selected_cell()?;
@@ -332,6 +427,7 @@ pub enum AppAction {
332427
     OpenDay,
333428
     CloseDay,
334429
     OpenCreate,
430
+    OpenDelete,
335431
     Quit,
336432
 }
337433
 
@@ -432,6 +528,157 @@ pub enum RecurrenceChoiceInputResult {
432528
     Cancel,
433529
 }
434530
 
531
+#[derive(Debug, Clone, PartialEq, Eq)]
532
+pub struct EventDeleteChoice {
533
+    target: EventDeleteTarget,
534
+    selected: usize,
535
+    error: Option<String>,
536
+}
537
+
538
+impl EventDeleteChoice {
539
+    fn for_event(event: &Event) -> Self {
540
+        let target = if let Some(occurrence) = event.occurrence() {
541
+            EventDeleteTarget::Occurrence {
542
+                series_id: occurrence.series_id.clone(),
543
+                anchor: occurrence.anchor,
544
+            }
545
+        } else {
546
+            EventDeleteTarget::Event {
547
+                event_id: event.id.clone(),
548
+            }
549
+        };
550
+
551
+        Self {
552
+            target,
553
+            selected: 0,
554
+            error: None,
555
+        }
556
+    }
557
+
558
+    pub fn heading(&self) -> &'static str {
559
+        "Delete"
560
+    }
561
+
562
+    pub fn error(&self) -> Option<&str> {
563
+        self.error.as_deref()
564
+    }
565
+
566
+    pub fn rows(&self) -> Vec<EventDeleteChoiceRow> {
567
+        self.actions()
568
+            .into_iter()
569
+            .enumerate()
570
+            .map(|(index, action)| EventDeleteChoiceRow {
571
+                label: action.label(),
572
+                selected: index == self.selected,
573
+                dangerous: action.is_dangerous(),
574
+            })
575
+            .collect()
576
+    }
577
+
578
+    fn actions(&self) -> Vec<EventDeleteChoiceAction> {
579
+        match self.target {
580
+            EventDeleteTarget::Event { .. } => vec![
581
+                EventDeleteChoiceAction::DeleteEvent,
582
+                EventDeleteChoiceAction::Cancel,
583
+            ],
584
+            EventDeleteTarget::Occurrence { .. } => vec![
585
+                EventDeleteChoiceAction::DeleteThisOccurrence,
586
+                EventDeleteChoiceAction::DeleteSeries,
587
+                EventDeleteChoiceAction::Cancel,
588
+            ],
589
+        }
590
+    }
591
+
592
+    fn selected_action(&self) -> EventDeleteChoiceAction {
593
+        self.actions()[self.selected]
594
+    }
595
+
596
+    fn select_next(&mut self) {
597
+        let len = self.actions().len();
598
+        self.selected = (self.selected + 1) % len;
599
+        self.error = None;
600
+    }
601
+
602
+    fn select_previous(&mut self) {
603
+        let len = self.actions().len();
604
+        self.selected = if self.selected == 0 {
605
+            len - 1
606
+        } else {
607
+            self.selected - 1
608
+        };
609
+        self.error = None;
610
+    }
611
+
612
+    fn event_id(&self) -> &str {
613
+        match &self.target {
614
+            EventDeleteTarget::Event { event_id } => event_id,
615
+            EventDeleteTarget::Occurrence { series_id, .. } => series_id,
616
+        }
617
+    }
618
+}
619
+
620
+#[derive(Debug, Clone, PartialEq, Eq)]
621
+enum EventDeleteTarget {
622
+    Event {
623
+        event_id: String,
624
+    },
625
+    Occurrence {
626
+        series_id: String,
627
+        anchor: OccurrenceAnchor,
628
+    },
629
+}
630
+
631
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
632
+pub struct EventDeleteChoiceRow {
633
+    pub label: &'static str,
634
+    pub selected: bool,
635
+    pub dangerous: bool,
636
+}
637
+
638
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
639
+enum EventDeleteChoiceAction {
640
+    DeleteEvent,
641
+    DeleteThisOccurrence,
642
+    DeleteSeries,
643
+    Cancel,
644
+}
645
+
646
+impl EventDeleteChoiceAction {
647
+    const fn label(self) -> &'static str {
648
+        match self {
649
+            Self::DeleteEvent => "Delete event",
650
+            Self::DeleteThisOccurrence => "Delete this occurrence",
651
+            Self::DeleteSeries => "Delete series",
652
+            Self::Cancel => "Cancel",
653
+        }
654
+    }
655
+
656
+    const fn is_dangerous(self) -> bool {
657
+        !matches!(self, Self::Cancel)
658
+    }
659
+}
660
+
661
+#[derive(Debug, Clone, PartialEq, Eq)]
662
+pub enum EventDeleteSubmission {
663
+    Event {
664
+        event_id: String,
665
+    },
666
+    Occurrence {
667
+        series_id: String,
668
+        anchor: OccurrenceAnchor,
669
+    },
670
+    Series {
671
+        series_id: String,
672
+    },
673
+}
674
+
675
+#[derive(Debug, Clone, PartialEq, Eq)]
676
+pub enum EventDeleteInputResult {
677
+    Continue,
678
+    Cancel,
679
+    Submit(EventDeleteSubmission),
680
+}
681
+
435682
 #[derive(Debug, Clone, PartialEq, Eq)]
436683
 pub struct CreateEventForm {
437684
     mode: EventFormMode,
@@ -1431,6 +1678,11 @@ impl KeyboardInput {
14311678
             return AppAction::OpenCreate;
14321679
         }
14331680
 
1681
+        if value.eq_ignore_ascii_case(&'d') {
1682
+            self.clear();
1683
+            return AppAction::OpenDelete;
1684
+        }
1685
+
14341686
         if value.is_ascii_digit() {
14351687
             return self.translate_digit(value);
14361688
         }
@@ -2004,6 +2256,110 @@ mod tests {
20042256
         );
20052257
     }
20062258
 
2259
+    #[test]
2260
+    fn day_view_d_opens_delete_choice_for_selected_local_event() {
2261
+        let day = date(2026, Month::April, 23);
2262
+        let source = InMemoryAgendaSource::with_events_and_holidays(
2263
+            vec![local_timed_event(
2264
+                "local-time",
2265
+                "Standup",
2266
+                at(day, 9, 0),
2267
+                at(day, 9, 30),
2268
+            )],
2269
+            Vec::new(),
2270
+        );
2271
+        let mut app = AppState::new(day);
2272
+        let mut input = KeyboardInput::default();
2273
+
2274
+        apply_keys_with_source(
2275
+            &mut app,
2276
+            &mut input,
2277
+            &source,
2278
+            [key(KeyCode::Enter), char_key('d')],
2279
+        );
2280
+
2281
+        let choice = app.delete_choice().expect("delete modal opens");
2282
+        assert_eq!(choice.rows()[0].label, "Delete event");
2283
+        assert_eq!(
2284
+            app.handle_delete_choice_key(key(KeyCode::Enter)),
2285
+            EventDeleteInputResult::Submit(EventDeleteSubmission::Event {
2286
+                event_id: "local-time".to_string()
2287
+            })
2288
+        );
2289
+    }
2290
+
2291
+    #[test]
2292
+    fn day_view_d_opens_recurring_delete_choices() {
2293
+        let day = date(2026, Month::April, 23);
2294
+        let event = local_timed_event("series", "Standup", at(day, 9, 0), at(day, 9, 30))
2295
+            .with_recurrence(RecurrenceRule {
2296
+                frequency: RecurrenceFrequency::Daily,
2297
+                interval: 1,
2298
+                end: RecurrenceEnd::Count(2),
2299
+                weekdays: Vec::new(),
2300
+                monthly: None,
2301
+                yearly: None,
2302
+            });
2303
+        let source = InMemoryAgendaSource::with_events_and_holidays(vec![event], Vec::new());
2304
+        let mut app = AppState::new(day);
2305
+        let mut input = KeyboardInput::default();
2306
+
2307
+        apply_keys_with_source(
2308
+            &mut app,
2309
+            &mut input,
2310
+            &source,
2311
+            [key(KeyCode::Enter), char_key('d')],
2312
+        );
2313
+
2314
+        let choice = app.delete_choice().expect("delete modal opens");
2315
+        let rows = choice.rows();
2316
+        assert_eq!(rows[0].label, "Delete this occurrence");
2317
+        assert_eq!(rows[1].label, "Delete series");
2318
+        assert_eq!(
2319
+            app.handle_delete_choice_key(key(KeyCode::Enter)),
2320
+            EventDeleteInputResult::Submit(EventDeleteSubmission::Occurrence {
2321
+                series_id: "series".to_string(),
2322
+                anchor: OccurrenceAnchor::Timed {
2323
+                    start: at(day, 9, 0)
2324
+                },
2325
+            })
2326
+        );
2327
+    }
2328
+
2329
+    #[test]
2330
+    fn delete_choice_up_and_down_do_not_change_day_event_selection() {
2331
+        let day = date(2026, Month::April, 23);
2332
+        let source = InMemoryAgendaSource::with_events_and_holidays(
2333
+            vec![local_timed_event(
2334
+                "local-time",
2335
+                "Standup",
2336
+                at(day, 9, 0),
2337
+                at(day, 9, 30),
2338
+            )],
2339
+            Vec::new(),
2340
+        );
2341
+        let mut app = AppState::new(day);
2342
+        let mut input = KeyboardInput::default();
2343
+        apply_keys_with_source(
2344
+            &mut app,
2345
+            &mut input,
2346
+            &source,
2347
+            [key(KeyCode::Enter), char_key('d')],
2348
+        );
2349
+
2350
+        assert_eq!(
2351
+            app.handle_delete_choice_key(key(KeyCode::Down)),
2352
+            EventDeleteInputResult::Continue
2353
+        );
2354
+        assert_eq!(
2355
+            app.handle_delete_choice_key(key(KeyCode::Up)),
2356
+            EventDeleteInputResult::Continue
2357
+        );
2358
+
2359
+        assert_eq!(app.selected_day_event_id(), Some("local-time"));
2360
+        assert!(app.delete_choice().expect("choice stays open").rows()[0].selected);
2361
+    }
2362
+
20072363
     #[test]
20082364
     fn day_view_reconciles_selection_when_event_leaves_day() {
20092365
         let day = date(2026, Month::April, 23);
src/cli.rsmodified
@@ -17,8 +17,8 @@ use time::{Date, OffsetDateTime, format_description};
1717
 use crate::{
1818
     agenda::{ConfiguredAgendaSource, HolidayProvider, LocalEventStoreError, default_events_file},
1919
     app::{
20
-        AppState, CreateEventInputResult, EventFormMode, KeyboardInput, MouseInput,
21
-        RecurrenceChoiceInputResult,
20
+        AppState, CreateEventInputResult, EventDeleteInputResult, EventDeleteSubmission,
21
+        EventFormMode, KeyboardInput, MouseInput, RecurrenceChoiceInputResult,
2222
     },
2323
     calendar::CalendarDate,
2424
     tui::{
@@ -44,13 +44,14 @@ const HELP: &str = concat!(
4444
     "Keys:\n",
4545
     "  Arrow keys move selection; Enter opens day view; Esc returns to month; q exits.\n",
4646
     "  + opens the Create event modal.\n",
47
+    "  In day view, d opens the Delete confirmation for the selected local event.\n",
4748
     "  In day view, Left/Right move to the previous or next day.\n",
4849
     "  Digits jump immediately; a quick second digit refines the selected day.\n",
4950
     "  Weekday initials jump within the selected week.\n\n",
5051
     "Mouse:\n",
5152
     "  Left click selects a visible date; left click the selected date again to open day view.\n\n",
5253
     "Notes:\n",
53
-    "  Real calendar-account integration, editing, deletion, and reminder notifications are not in this milestone.\n",
54
+    "  Real calendar-account integration and reminder notifications are not in this milestone.\n",
5455
 );
5556
 
5657
 const VERSION: &str = concat!(env!("CARGO_PKG_NAME"), " ", env!("CARGO_PKG_VERSION"), "\n");
@@ -444,6 +445,7 @@ where
444445
 
445446
         let event = if !app.is_creating_event()
446447
             && !app.is_choosing_recurring_edit()
448
+            && !app.is_confirming_delete()
447449
             && keyboard.is_waiting_for_digit()
448450
         {
449451
             if event::poll(DIGIT_JUMP_TIMEOUT)? {
@@ -459,7 +461,33 @@ where
459461
         match event {
460462
             Event::Key(key) => {
461463
                 mouse.clear();
462
-                if app.is_choosing_recurring_edit() {
464
+                if app.is_confirming_delete() {
465
+                    match app.handle_delete_choice_key(key) {
466
+                        EventDeleteInputResult::Continue => {}
467
+                        EventDeleteInputResult::Cancel => app.close_delete_choice(),
468
+                        EventDeleteInputResult::Submit(submission) => match submission {
469
+                            EventDeleteSubmission::Event { event_id }
470
+                            | EventDeleteSubmission::Series {
471
+                                series_id: event_id,
472
+                            } => match agenda_source.delete_event(&event_id) {
473
+                                Ok(_) => {
474
+                                    app.close_delete_choice();
475
+                                    app.reconcile_day_event_selection(&agenda_source);
476
+                                }
477
+                                Err(err) => app.set_delete_error(err.to_string()),
478
+                            },
479
+                            EventDeleteSubmission::Occurrence { series_id, anchor } => {
480
+                                match agenda_source.delete_occurrence(&series_id, anchor) {
481
+                                    Ok(()) => {
482
+                                        app.close_delete_choice();
483
+                                        app.reconcile_day_event_selection(&agenda_source);
484
+                                    }
485
+                                    Err(err) => app.set_delete_error(err.to_string()),
486
+                                }
487
+                            }
488
+                        },
489
+                    }
490
+                } else if app.is_choosing_recurring_edit() {
463491
                     match app.handle_recurrence_choice_key(key, &agenda_source) {
464492
                         RecurrenceChoiceInputResult::Continue => {}
465493
                         RecurrenceChoiceInputResult::Cancel => app.close_recurrence_choice(),
@@ -511,7 +539,10 @@ where
511539
                 }
512540
             }
513541
             Event::Mouse(mouse_event) => {
514
-                if app.is_creating_event() || app.is_choosing_recurring_edit() {
542
+                if app.is_creating_event()
543
+                    || app.is_choosing_recurring_edit()
544
+                    || app.is_confirming_delete()
545
+                {
515546
                     continue;
516547
                 }
517548
                 keyboard.clear();
src/tui.rsmodified
@@ -12,7 +12,10 @@ use crate::{
1212
     agenda::{
1313
         AgendaSource, DayAgenda, DayMinute, EmptyAgendaSource, Event, EventTiming, TimedAgendaEvent,
1414
     },
15
-    app::{AppState, CreateEventForm, CreateEventFormRowKind, RecurrenceEditChoice, ViewMode},
15
+    app::{
16
+        AppState, CreateEventForm, CreateEventFormRowKind, EventDeleteChoice, RecurrenceEditChoice,
17
+        ViewMode,
18
+    },
1619
     calendar::{
1720
         CalendarCell, CalendarDate, CalendarMonth, CalendarWeek, DAYS_PER_WEEK, MONTH_GRID_WEEKS,
1821
     },
@@ -96,6 +99,9 @@ impl Widget for AppView<'_> {
9699
         if let Some(choice) = self.app.recurrence_choice() {
97100
             render_recurrence_choice_modal(choice, area, buf, CreateModalStyles::new());
98101
         }
102
+        if let Some(choice) = self.app.delete_choice() {
103
+            render_delete_choice_modal(choice, area, buf, CreateModalStyles::new());
104
+        }
99105
     }
100106
 }
101107
 
@@ -966,6 +972,80 @@ fn render_recurrence_choice_modal(
966972
     );
967973
 }
968974
 
975
+fn render_delete_choice_modal(
976
+    choice: &EventDeleteChoice,
977
+    area: Rect,
978
+    buf: &mut Buffer,
979
+    styles: CreateModalStyles,
980
+) {
981
+    if area.width == 0 || area.height == 0 {
982
+        return;
983
+    }
984
+
985
+    let modal = recurrence_choice_modal_area(area);
986
+    fill_rect(buf, modal, styles.panel);
987
+    draw_border(buf, modal, styles.border, BorderCharacters::normal());
988
+
989
+    let content = inset_rect(modal);
990
+    if content.width == 0 || content.height == 0 {
991
+        return;
992
+    }
993
+
994
+    write_centered(
995
+        buf,
996
+        content.y,
997
+        content.x,
998
+        content.width,
999
+        choice.heading(),
1000
+        styles.error,
1001
+    );
1002
+
1003
+    let mut y = content.y.saturating_add(2);
1004
+    for row in choice.rows() {
1005
+        if y >= content.bottom().saturating_sub(2) {
1006
+            break;
1007
+        }
1008
+        let marker = if row.selected { ">" } else { " " };
1009
+        let row_style = if row.selected && row.dangerous {
1010
+            styles.error
1011
+        } else if row.selected {
1012
+            styles.title
1013
+        } else {
1014
+            styles.value
1015
+        };
1016
+        write_padded_left(buf, y, content.x, 1, marker, styles.label);
1017
+        write_left(
1018
+            buf,
1019
+            y,
1020
+            content.x.saturating_add(2),
1021
+            content.width.saturating_sub(2),
1022
+            row.label,
1023
+            row_style,
1024
+        );
1025
+        y = y.saturating_add(1);
1026
+    }
1027
+
1028
+    if let Some(error) = choice.error() {
1029
+        write_left(
1030
+            buf,
1031
+            content.bottom().saturating_sub(2),
1032
+            content.x,
1033
+            content.width,
1034
+            error,
1035
+            styles.error,
1036
+        );
1037
+    }
1038
+
1039
+    write_centered(
1040
+        buf,
1041
+        content.bottom().saturating_sub(1),
1042
+        content.x,
1043
+        content.width,
1044
+        "Enter select | Esc cancel",
1045
+        styles.footer,
1046
+    );
1047
+}
1048
+
9691049
 fn create_modal_area(area: Rect, form: &CreateEventForm) -> Rect {
9701050
     if area.width < 52 || area.height < 16 {
9711051
         return area;
@@ -2251,6 +2331,29 @@ mod tests {
22512331
         assert!(rendered.contains("Enter select"));
22522332
     }
22532333
 
2334
+    #[test]
2335
+    fn delete_choice_modal_renders_over_day_view() {
2336
+        let day = date(2026, Month::April, 23);
2337
+        let source = agenda_source(
2338
+            vec![local_timed_event(
2339
+                "planning",
2340
+                "Planning",
2341
+                at(day, 9, 0),
2342
+                at(day, 10, 0),
2343
+            )],
2344
+            Vec::new(),
2345
+        );
2346
+        let mut app = AppState::new(day);
2347
+        app.apply_with_agenda_source(AppAction::OpenDay, &source);
2348
+        app.apply_with_agenda_source(AppAction::OpenDelete, &source);
2349
+
2350
+        let rendered = render_app_to_string_with_agenda_source(&app, 84, 26, &source);
2351
+
2352
+        assert!(rendered.contains("Delete"));
2353
+        assert!(rendered.contains("Delete event"));
2354
+        assert!(rendered.contains("Enter select"));
2355
+    }
2356
+
22542357
     #[test]
22552358
     fn recurring_instances_render_without_repeat_marker() {
22562359
         let day = date(2026, Month::April, 23);