tenseleyflow/rcal / b2c7109

Browse files

Add local event copy flow

Authored by espadonne
SHA
b2c7109935d5381f346ae77c5f6b2e960f57c980
Parents
04203a9
Tree
37e0ce7

5 changed files

StatusFile+-
M README.md 1 0
M src/agenda.rs 200 0
M src/app.rs 337 1
M src/cli.rs 30 2
M src/tui.rs 101 2
README.mdmodified
@@ -51,6 +51,7 @@ access.
5151
 - Arrow keys move the selected date.
5252
 - `?` opens contextual help.
5353
 - `+` opens the Create event modal.
54
+- In day view, `c` opens the Copy confirmation for the selected local event.
5455
 - In day view, `d` opens the Delete confirmation for the selected local event.
5556
 - `Enter` opens the focused day view.
5657
 - `Esc` returns from day view to month view.
src/agenda.rsmodified
@@ -379,6 +379,20 @@ pub struct CreateEventDraft {
379379
 }
380380
 
381381
 impl CreateEventDraft {
382
+    pub fn from_event(event: &Event) -> Self {
383
+        Self {
384
+            title: event.title.clone(),
385
+            timing: match event.timing {
386
+                EventTiming::AllDay { date } => CreateEventTiming::AllDay { date },
387
+                EventTiming::Timed { start, end } => CreateEventTiming::Timed { start, end },
388
+            },
389
+            location: event.location.clone(),
390
+            notes: event.notes.clone(),
391
+            reminders: event.reminders.clone(),
392
+            recurrence: event.recurrence.clone(),
393
+        }
394
+    }
395
+
382396
     pub fn into_event(self, id: String) -> Result<Event, AgendaError> {
383397
         let source = SourceMetadata::local().with_external_id(id.clone());
384398
         let mut event = match self.timing {
@@ -705,6 +719,42 @@ impl ConfiguredAgendaSource {
705719
         Ok(deleted)
706720
     }
707721
 
722
+    pub fn duplicate_event(&mut self, id: &str) -> Result<Event, LocalEventStoreError> {
723
+        let event = self
724
+            .events
725
+            .local_event_by_id(id)
726
+            .ok_or_else(|| LocalEventStoreError::EventNotFound { id: id.to_string() })?;
727
+        self.insert_event_copy(event)
728
+    }
729
+
730
+    pub fn duplicate_occurrence(
731
+        &mut self,
732
+        series_id: &str,
733
+        anchor: OccurrenceAnchor,
734
+    ) -> Result<Event, LocalEventStoreError> {
735
+        let series = self.events.local_event_by_id(series_id).ok_or_else(|| {
736
+            LocalEventStoreError::EventNotFound {
737
+                id: series_id.to_string(),
738
+            }
739
+        })?;
740
+        if !series.is_recurring_series() {
741
+            return Err(LocalEventStoreError::EventNotEditable {
742
+                id: series_id.to_string(),
743
+            });
744
+        }
745
+        if !event_generates_anchor(&series, anchor) {
746
+            return Err(LocalEventStoreError::OccurrenceNotFound {
747
+                id: series_id.to_string(),
748
+                anchor: anchor.storage_key(),
749
+            });
750
+        }
751
+
752
+        let occurrence = occurrence_override_event(&series, anchor)
753
+            .unwrap_or_else(|| generated_occurrence_event(&series, anchor));
754
+        let draft = CreateEventDraft::from_event(&occurrence).without_recurrence();
755
+        self.create_event(draft)
756
+    }
757
+
708758
     pub fn delete_occurrence(
709759
         &mut self,
710760
         series_id: &str,
@@ -742,6 +792,21 @@ impl ConfiguredAgendaSource {
742792
         Ok(())
743793
     }
744794
 
795
+    fn insert_event_copy(&mut self, mut event: Event) -> Result<Event, LocalEventStoreError> {
796
+        let id = self.next_local_event_id(&event.title);
797
+        event.id = id.clone();
798
+        event.source = SourceMetadata::local().with_external_id(id);
799
+        event.occurrence = None;
800
+
801
+        if let Some(path) = &self.events_file {
802
+            let mut events = self.events.events().to_vec();
803
+            events.push(event.clone());
804
+            write_events_file(path, &events)?;
805
+        }
806
+        self.events.push_event(event.clone());
807
+        Ok(event)
808
+    }
809
+
745810
     fn next_local_event_id(&self, title: &str) -> String {
746811
         let now = SystemTime::now()
747812
             .duration_since(UNIX_EPOCH)
@@ -3165,6 +3230,141 @@ mod tests {
31653230
         assert_eq!(agenda.all_day_events[0].location.as_deref(), Some("Room 2"));
31663231
     }
31673232
 
3233
+    #[test]
3234
+    fn local_event_store_duplicates_single_event_and_persists() {
3235
+        let path = temp_events_path("duplicate-event");
3236
+        let _ = std::fs::remove_dir_all(path.parent().expect("path has parent"));
3237
+        let day = date(23);
3238
+        let mut source = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3239
+            .expect("missing event file is empty");
3240
+        let event = source
3241
+            .create_event(CreateEventDraft {
3242
+                title: "Planning".to_string(),
3243
+                timing: CreateEventTiming::Timed {
3244
+                    start: at(day, 9, 0),
3245
+                    end: at(day, 10, 0),
3246
+                },
3247
+                location: Some("Room 1".to_string()),
3248
+                notes: Some("Bring notes".to_string()),
3249
+                reminders: vec![Reminder::minutes_before(15)],
3250
+                recurrence: None,
3251
+            })
3252
+            .expect("event saves");
3253
+
3254
+        let copied = source.duplicate_event(&event.id).expect("event copies");
3255
+        let reloaded = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3256
+            .expect("saved file reloads");
3257
+        let agenda = DayAgenda::from_source(day, &reloaded);
3258
+
3259
+        let _ = std::fs::remove_dir_all(path.parent().expect("test dir exists"));
3260
+
3261
+        assert_ne!(copied.id, event.id);
3262
+        assert!(copied.is_local());
3263
+        assert_eq!(copied.title, "Planning");
3264
+        assert_eq!(copied.location.as_deref(), Some("Room 1"));
3265
+        assert_eq!(agenda.timed_events.len(), 2);
3266
+    }
3267
+
3268
+    #[test]
3269
+    fn local_event_store_duplicates_occurrence_as_standalone_event() {
3270
+        let path = temp_events_path("duplicate-occurrence");
3271
+        let _ = std::fs::remove_dir_all(path.parent().expect("path has parent"));
3272
+        let day = date(23);
3273
+        let mut source = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3274
+            .expect("missing event file is empty");
3275
+        let event = source
3276
+            .create_event(CreateEventDraft {
3277
+                title: "Standup".to_string(),
3278
+                timing: CreateEventTiming::Timed {
3279
+                    start: at(day, 9, 0),
3280
+                    end: at(day, 9, 30),
3281
+                },
3282
+                location: None,
3283
+                notes: None,
3284
+                reminders: Vec::new(),
3285
+                recurrence: Some(RecurrenceRule {
3286
+                    frequency: RecurrenceFrequency::Daily,
3287
+                    interval: 1,
3288
+                    end: RecurrenceEnd::Count(2),
3289
+                    weekdays: Vec::new(),
3290
+                    monthly: None,
3291
+                    yearly: None,
3292
+                }),
3293
+            })
3294
+            .expect("recurring event saves");
3295
+        let anchor = OccurrenceAnchor::Timed {
3296
+            start: at(day.add_days(1), 9, 0),
3297
+        };
3298
+
3299
+        let copied = source
3300
+            .duplicate_occurrence(&event.id, anchor)
3301
+            .expect("occurrence copies");
3302
+        let reloaded = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3303
+            .expect("saved file reloads");
3304
+        let agenda = DayAgenda::from_source(day.add_days(1), &reloaded);
3305
+
3306
+        let _ = std::fs::remove_dir_all(path.parent().expect("test dir exists"));
3307
+
3308
+        assert_ne!(copied.id, event.id);
3309
+        assert!(copied.occurrence().is_none());
3310
+        assert!(copied.recurrence.is_none());
3311
+        assert_eq!(agenda.timed_events.len(), 2);
3312
+        assert!(
3313
+            agenda
3314
+                .timed_events
3315
+                .iter()
3316
+                .any(|agenda_event| agenda_event.event.id == copied.id)
3317
+        );
3318
+    }
3319
+
3320
+    #[test]
3321
+    fn local_event_store_duplicates_recurring_series() {
3322
+        let path = temp_events_path("duplicate-series");
3323
+        let _ = std::fs::remove_dir_all(path.parent().expect("path has parent"));
3324
+        let day = date(23);
3325
+        let mut source = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3326
+            .expect("missing event file is empty");
3327
+        let event = source
3328
+            .create_event(CreateEventDraft {
3329
+                title: "Standup".to_string(),
3330
+                timing: CreateEventTiming::Timed {
3331
+                    start: at(day, 9, 0),
3332
+                    end: at(day, 9, 30),
3333
+                },
3334
+                location: None,
3335
+                notes: None,
3336
+                reminders: Vec::new(),
3337
+                recurrence: Some(RecurrenceRule {
3338
+                    frequency: RecurrenceFrequency::Daily,
3339
+                    interval: 1,
3340
+                    end: RecurrenceEnd::Count(2),
3341
+                    weekdays: Vec::new(),
3342
+                    monthly: None,
3343
+                    yearly: None,
3344
+                }),
3345
+            })
3346
+            .expect("recurring event saves");
3347
+
3348
+        let copied = source
3349
+            .duplicate_event(&event.id)
3350
+            .expect("recurring series copies");
3351
+        let reloaded = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3352
+            .expect("saved file reloads");
3353
+        let agenda = DayAgenda::from_source(day.add_days(1), &reloaded);
3354
+
3355
+        let _ = std::fs::remove_dir_all(path.parent().expect("test dir exists"));
3356
+
3357
+        assert_ne!(copied.id, event.id);
3358
+        assert!(copied.recurrence.is_some());
3359
+        assert_eq!(agenda.timed_events.len(), 2);
3360
+        assert!(
3361
+            agenda
3362
+                .timed_events
3363
+                .iter()
3364
+                .any(|agenda_event| agenda_event.event.id.starts_with(&copied.id))
3365
+        );
3366
+    }
3367
+
31683368
     #[test]
31693369
     fn local_event_store_loads_version_one_and_rewrites_version_two_on_save() {
31703370
         let path = temp_events_path("version-one");
src/app.rsmodified
@@ -30,6 +30,7 @@ pub struct AppState {
3030
     create_form: Option<CreateEventForm>,
3131
     recurrence_choice: Option<RecurrenceEditChoice>,
3232
     delete_choice: Option<EventDeleteChoice>,
33
+    copy_choice: Option<EventCopyChoice>,
3334
     help_open: bool,
3435
     selected_day_event_id: Option<String>,
3536
     should_quit: bool,
@@ -48,6 +49,7 @@ impl AppState {
4849
             create_form: None,
4950
             recurrence_choice: None,
5051
             delete_choice: None,
52
+            copy_choice: None,
5153
             help_open: false,
5254
             selected_day_event_id: None,
5355
             should_quit: false,
@@ -82,6 +84,10 @@ impl AppState {
8284
         self.delete_choice.as_ref()
8385
     }
8486
 
87
+    pub const fn copy_choice(&self) -> Option<&EventCopyChoice> {
88
+        self.copy_choice.as_ref()
89
+    }
90
+
8591
     pub fn selected_day_event_id(&self) -> Option<&str> {
8692
         self.selected_day_event_id.as_deref()
8793
     }
@@ -98,6 +104,10 @@ impl AppState {
98104
         self.delete_choice.is_some()
99105
     }
100106
 
107
+    pub const fn is_copying_event(&self) -> bool {
108
+        self.copy_choice.is_some()
109
+    }
110
+
101111
     pub const fn is_showing_help(&self) -> bool {
102112
         self.help_open
103113
     }
@@ -114,6 +124,10 @@ impl AppState {
114124
         self.delete_choice = None;
115125
     }
116126
 
127
+    pub fn close_copy_choice(&mut self) {
128
+        self.copy_choice = None;
129
+    }
130
+
117131
     pub fn close_help(&mut self) {
118132
         self.help_open = false;
119133
     }
@@ -124,6 +138,12 @@ impl AppState {
124138
         }
125139
     }
126140
 
141
+    pub fn set_copy_error(&mut self, message: impl Into<String>) {
142
+        if let Some(choice) = &mut self.copy_choice {
143
+            choice.error = Some(message.into());
144
+        }
145
+    }
146
+
127147
     pub fn set_create_form_error(&mut self, message: impl Into<String>) {
128148
         if let Some(form) = &mut self.create_form {
129149
             form.error = Some(message.into());
@@ -243,6 +263,54 @@ impl AppState {
243263
         }
244264
     }
245265
 
266
+    pub fn handle_copy_choice_key(&mut self, key: KeyEvent) -> EventCopyInputResult {
267
+        if key.kind == KeyEventKind::Release {
268
+            return EventCopyInputResult::Continue;
269
+        }
270
+
271
+        let Some(choice) = &mut self.copy_choice else {
272
+            return EventCopyInputResult::Continue;
273
+        };
274
+
275
+        match key.code {
276
+            KeyCode::Esc => EventCopyInputResult::Cancel,
277
+            KeyCode::Up => {
278
+                choice.select_previous();
279
+                EventCopyInputResult::Continue
280
+            }
281
+            KeyCode::Down => {
282
+                choice.select_next();
283
+                EventCopyInputResult::Continue
284
+            }
285
+            KeyCode::Enter => match choice.selected_action() {
286
+                EventCopyChoiceAction::Cancel => EventCopyInputResult::Cancel,
287
+                EventCopyChoiceAction::CopyEvent => {
288
+                    EventCopyInputResult::Submit(EventCopySubmission::Event {
289
+                        event_id: choice.event_id().to_string(),
290
+                    })
291
+                }
292
+                EventCopyChoiceAction::CopyThisOccurrence => {
293
+                    let EventCopyTarget::Occurrence { series_id, anchor } = &choice.target else {
294
+                        return EventCopyInputResult::Continue;
295
+                    };
296
+                    EventCopyInputResult::Submit(EventCopySubmission::Occurrence {
297
+                        series_id: series_id.clone(),
298
+                        anchor: *anchor,
299
+                    })
300
+                }
301
+                EventCopyChoiceAction::CopySeries => {
302
+                    let EventCopyTarget::Occurrence { series_id, .. } = &choice.target else {
303
+                        return EventCopyInputResult::Continue;
304
+                    };
305
+                    EventCopyInputResult::Submit(EventCopySubmission::Series {
306
+                        series_id: series_id.clone(),
307
+                    })
308
+                }
309
+            },
310
+            _ => EventCopyInputResult::Continue,
311
+        }
312
+    }
313
+
246314
     pub fn handle_help_key(&mut self, key: KeyEvent) -> HelpInputResult {
247315
         if key.kind == KeyEventKind::Release {
248316
             return HelpInputResult::Continue;
@@ -307,7 +375,8 @@ impl AppState {
307375
             AppAction::OpenHelp
308376
                 if self.create_form.is_none()
309377
                     && self.recurrence_choice.is_none()
310
-                    && self.delete_choice.is_none() =>
378
+                    && self.delete_choice.is_none()
379
+                    && self.copy_choice.is_none() =>
311380
             {
312381
                 self.help_open = true;
313382
             }
@@ -328,12 +397,14 @@ impl AppState {
328397
                 self.selected_day_event_id = None;
329398
                 self.recurrence_choice = None;
330399
                 self.delete_choice = None;
400
+                self.copy_choice = None;
331401
                 self.help_open = false;
332402
             }
333403
             AppAction::OpenCreate => {
334404
                 if self.create_form.is_none()
335405
                     && self.recurrence_choice.is_none()
336406
                     && self.delete_choice.is_none()
407
+                    && self.copy_choice.is_none()
337408
                     && !self.help_open
338409
                 {
339410
                     let context = match self.view_mode {
@@ -347,12 +418,24 @@ impl AppState {
347418
                 if self.create_form.is_none()
348419
                     && self.recurrence_choice.is_none()
349420
                     && self.delete_choice.is_none()
421
+                    && self.copy_choice.is_none()
350422
                     && !self.help_open
351423
                     && let Some(source) = source
352424
                 {
353425
                     self.open_selected_event_for_delete(source);
354426
                 }
355427
             }
428
+            AppAction::OpenCopy if self.view_mode == ViewMode::Day => {
429
+                if self.create_form.is_none()
430
+                    && self.recurrence_choice.is_none()
431
+                    && self.delete_choice.is_none()
432
+                    && self.copy_choice.is_none()
433
+                    && !self.help_open
434
+                    && let Some(source) = source
435
+                {
436
+                    self.open_selected_event_for_copy(source);
437
+                }
438
+            }
356439
             AppAction::MoveDays(days) if self.view_mode == ViewMode::Month => {
357440
                 self.selected_date = self.selected_date.add_days(days);
358441
             }
@@ -394,6 +477,7 @@ impl AppState {
394477
             | AppAction::JumpToDay(_)
395478
             | AppAction::JumpToWeekday(_)
396479
             | AppAction::OpenDelete
480
+            | AppAction::OpenCopy
397481
             | AppAction::OpenHelp => {}
398482
         }
399483
     }
@@ -452,6 +536,25 @@ impl AppState {
452536
         }
453537
     }
454538
 
539
+    fn open_selected_event_for_copy(&mut self, source: &dyn AgendaSource) {
540
+        self.reconcile_day_event_selection(source);
541
+        let Some(selected_id) = self.selected_day_event_id.as_deref() else {
542
+            return;
543
+        };
544
+        if let Some(event) = selectable_day_events(self.selected_date, source)
545
+            .into_iter()
546
+            .find(|event| event.id == selected_id)
547
+        {
548
+            self.copy_choice = Some(EventCopyChoice::for_event(&event));
549
+        }
550
+    }
551
+
552
+    pub fn select_day_event_id(&mut self, event_id: impl Into<String>) {
553
+        if self.view_mode == ViewMode::Day {
554
+            self.selected_day_event_id = Some(event_id.into());
555
+        }
556
+    }
557
+
455558
     fn weekday_in_selected_week(&self, weekday: Weekday) -> Option<CalendarDate> {
456559
         let month = self.calendar_month();
457560
         let selected = month.selected_cell()?;
@@ -476,6 +579,7 @@ pub enum AppAction {
476579
     CloseDay,
477580
     OpenCreate,
478581
     OpenDelete,
582
+    OpenCopy,
479583
     OpenHelp,
480584
     Quit,
481585
 }
@@ -728,6 +832,153 @@ pub enum EventDeleteInputResult {
728832
     Submit(EventDeleteSubmission),
729833
 }
730834
 
835
+#[derive(Debug, Clone, PartialEq, Eq)]
836
+pub struct EventCopyChoice {
837
+    target: EventCopyTarget,
838
+    selected: usize,
839
+    error: Option<String>,
840
+}
841
+
842
+impl EventCopyChoice {
843
+    fn for_event(event: &Event) -> Self {
844
+        let target = if let Some(occurrence) = event.occurrence() {
845
+            EventCopyTarget::Occurrence {
846
+                series_id: occurrence.series_id.clone(),
847
+                anchor: occurrence.anchor,
848
+            }
849
+        } else {
850
+            EventCopyTarget::Event {
851
+                event_id: event.id.clone(),
852
+            }
853
+        };
854
+
855
+        Self {
856
+            target,
857
+            selected: 0,
858
+            error: None,
859
+        }
860
+    }
861
+
862
+    pub fn heading(&self) -> &'static str {
863
+        "Copy"
864
+    }
865
+
866
+    pub fn error(&self) -> Option<&str> {
867
+        self.error.as_deref()
868
+    }
869
+
870
+    pub fn rows(&self) -> Vec<EventCopyChoiceRow> {
871
+        self.actions()
872
+            .into_iter()
873
+            .enumerate()
874
+            .map(|(index, action)| EventCopyChoiceRow {
875
+                label: action.label(),
876
+                selected: index == self.selected,
877
+            })
878
+            .collect()
879
+    }
880
+
881
+    fn actions(&self) -> Vec<EventCopyChoiceAction> {
882
+        match self.target {
883
+            EventCopyTarget::Event { .. } => {
884
+                vec![
885
+                    EventCopyChoiceAction::CopyEvent,
886
+                    EventCopyChoiceAction::Cancel,
887
+                ]
888
+            }
889
+            EventCopyTarget::Occurrence { .. } => vec![
890
+                EventCopyChoiceAction::CopyThisOccurrence,
891
+                EventCopyChoiceAction::CopySeries,
892
+                EventCopyChoiceAction::Cancel,
893
+            ],
894
+        }
895
+    }
896
+
897
+    fn selected_action(&self) -> EventCopyChoiceAction {
898
+        self.actions()[self.selected]
899
+    }
900
+
901
+    fn select_next(&mut self) {
902
+        let len = self.actions().len();
903
+        self.selected = (self.selected + 1) % len;
904
+        self.error = None;
905
+    }
906
+
907
+    fn select_previous(&mut self) {
908
+        let len = self.actions().len();
909
+        self.selected = if self.selected == 0 {
910
+            len - 1
911
+        } else {
912
+            self.selected - 1
913
+        };
914
+        self.error = None;
915
+    }
916
+
917
+    fn event_id(&self) -> &str {
918
+        match &self.target {
919
+            EventCopyTarget::Event { event_id } => event_id,
920
+            EventCopyTarget::Occurrence { series_id, .. } => series_id,
921
+        }
922
+    }
923
+}
924
+
925
+#[derive(Debug, Clone, PartialEq, Eq)]
926
+enum EventCopyTarget {
927
+    Event {
928
+        event_id: String,
929
+    },
930
+    Occurrence {
931
+        series_id: String,
932
+        anchor: OccurrenceAnchor,
933
+    },
934
+}
935
+
936
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
937
+pub struct EventCopyChoiceRow {
938
+    pub label: &'static str,
939
+    pub selected: bool,
940
+}
941
+
942
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
943
+enum EventCopyChoiceAction {
944
+    CopyEvent,
945
+    CopyThisOccurrence,
946
+    CopySeries,
947
+    Cancel,
948
+}
949
+
950
+impl EventCopyChoiceAction {
951
+    const fn label(self) -> &'static str {
952
+        match self {
953
+            Self::CopyEvent => "Copy event",
954
+            Self::CopyThisOccurrence => "Copy this occurrence",
955
+            Self::CopySeries => "Copy series",
956
+            Self::Cancel => "Cancel",
957
+        }
958
+    }
959
+}
960
+
961
+#[derive(Debug, Clone, PartialEq, Eq)]
962
+pub enum EventCopySubmission {
963
+    Event {
964
+        event_id: String,
965
+    },
966
+    Occurrence {
967
+        series_id: String,
968
+        anchor: OccurrenceAnchor,
969
+    },
970
+    Series {
971
+        series_id: String,
972
+    },
973
+}
974
+
975
+#[derive(Debug, Clone, PartialEq, Eq)]
976
+pub enum EventCopyInputResult {
977
+    Continue,
978
+    Cancel,
979
+    Submit(EventCopySubmission),
980
+}
981
+
731982
 #[derive(Debug, Clone, PartialEq, Eq)]
732983
 pub enum HelpInputResult {
733984
     Continue,
@@ -1743,6 +1994,11 @@ impl KeyboardInput {
17431994
             return AppAction::OpenDelete;
17441995
         }
17451996
 
1997
+        if value.eq_ignore_ascii_case(&'c') {
1998
+            self.clear();
1999
+            return AppAction::OpenCopy;
2000
+        }
2001
+
17462002
         if value.is_ascii_digit() {
17472003
             return self.translate_digit(value);
17482004
         }
@@ -2415,6 +2671,86 @@ mod tests {
24152671
         );
24162672
     }
24172673
 
2674
+    #[test]
2675
+    fn day_view_c_opens_copy_choice_for_selected_local_event() {
2676
+        let day = date(2026, Month::April, 23);
2677
+        let source = InMemoryAgendaSource::with_events_and_holidays(
2678
+            vec![local_timed_event(
2679
+                "local-time",
2680
+                "Standup",
2681
+                at(day, 9, 0),
2682
+                at(day, 9, 30),
2683
+            )],
2684
+            Vec::new(),
2685
+        );
2686
+        let mut app = AppState::new(day);
2687
+        let mut input = KeyboardInput::default();
2688
+
2689
+        apply_keys_with_source(
2690
+            &mut app,
2691
+            &mut input,
2692
+            &source,
2693
+            [key(KeyCode::Enter), char_key('c')],
2694
+        );
2695
+
2696
+        let choice = app.copy_choice().expect("copy modal opens");
2697
+        assert_eq!(choice.rows()[0].label, "Copy event");
2698
+        assert_eq!(
2699
+            app.handle_copy_choice_key(key(KeyCode::Enter)),
2700
+            EventCopyInputResult::Submit(EventCopySubmission::Event {
2701
+                event_id: "local-time".to_string()
2702
+            })
2703
+        );
2704
+    }
2705
+
2706
+    #[test]
2707
+    fn day_view_c_opens_recurring_copy_choices() {
2708
+        let day = date(2026, Month::April, 23);
2709
+        let event = local_timed_event("series", "Standup", at(day, 9, 0), at(day, 9, 30))
2710
+            .with_recurrence(RecurrenceRule {
2711
+                frequency: RecurrenceFrequency::Daily,
2712
+                interval: 1,
2713
+                end: RecurrenceEnd::Count(2),
2714
+                weekdays: Vec::new(),
2715
+                monthly: None,
2716
+                yearly: None,
2717
+            });
2718
+        let source = InMemoryAgendaSource::with_events_and_holidays(vec![event], Vec::new());
2719
+        let mut app = AppState::new(day);
2720
+        let mut input = KeyboardInput::default();
2721
+
2722
+        apply_keys_with_source(
2723
+            &mut app,
2724
+            &mut input,
2725
+            &source,
2726
+            [key(KeyCode::Enter), char_key('c')],
2727
+        );
2728
+
2729
+        let choice = app.copy_choice().expect("copy modal opens");
2730
+        let rows = choice.rows();
2731
+        assert_eq!(rows[0].label, "Copy this occurrence");
2732
+        assert_eq!(rows[1].label, "Copy series");
2733
+        assert_eq!(
2734
+            app.handle_copy_choice_key(key(KeyCode::Enter)),
2735
+            EventCopyInputResult::Submit(EventCopySubmission::Occurrence {
2736
+                series_id: "series".to_string(),
2737
+                anchor: OccurrenceAnchor::Timed {
2738
+                    start: at(day, 9, 0)
2739
+                },
2740
+            })
2741
+        );
2742
+        assert_eq!(
2743
+            app.handle_copy_choice_key(key(KeyCode::Down)),
2744
+            EventCopyInputResult::Continue
2745
+        );
2746
+        assert_eq!(
2747
+            app.handle_copy_choice_key(key(KeyCode::Enter)),
2748
+            EventCopyInputResult::Submit(EventCopySubmission::Series {
2749
+                series_id: "series".to_string()
2750
+            })
2751
+        );
2752
+    }
2753
+
24182754
     #[test]
24192755
     fn delete_choice_up_and_down_do_not_change_day_event_selection() {
24202756
         let day = date(2026, Month::April, 23);
src/cli.rsmodified
@@ -17,8 +17,9 @@ use time::{Date, OffsetDateTime, format_description};
1717
 use crate::{
1818
     agenda::{ConfiguredAgendaSource, HolidayProvider, LocalEventStoreError, default_events_file},
1919
     app::{
20
-        AppState, CreateEventInputResult, EventDeleteInputResult, EventDeleteSubmission,
21
-        EventFormMode, HelpInputResult, KeyboardInput, MouseInput, RecurrenceChoiceInputResult,
20
+        AppState, CreateEventInputResult, EventCopyInputResult, EventCopySubmission,
21
+        EventDeleteInputResult, EventDeleteSubmission, EventFormMode, HelpInputResult,
22
+        KeyboardInput, MouseInput, RecurrenceChoiceInputResult,
2223
     },
2324
     calendar::CalendarDate,
2425
     tui::{
@@ -45,6 +46,7 @@ const HELP: &str = concat!(
4546
     "  Arrow keys move selection; Enter opens day view; Esc returns to month; q exits.\n",
4647
     "  ? opens contextual help.\n",
4748
     "  + opens the Create event modal.\n",
49
+    "  In day view, c opens the Copy confirmation for the selected local event.\n",
4850
     "  In day view, d opens the Delete confirmation for the selected local event.\n",
4951
     "  In day view, Left/Right move to the previous or next day.\n",
5052
     "  Digits jump immediately; a quick second digit refines the selected day.\n",
@@ -447,6 +449,7 @@ where
447449
         let event = if !app.is_creating_event()
448450
             && !app.is_choosing_recurring_edit()
449451
             && !app.is_confirming_delete()
452
+            && !app.is_copying_event()
450453
             && !app.is_showing_help()
451454
             && keyboard.is_waiting_for_digit()
452455
         {
@@ -493,6 +496,30 @@ where
493496
                             }
494497
                         },
495498
                     }
499
+                } else if app.is_copying_event() {
500
+                    match app.handle_copy_choice_key(key) {
501
+                        EventCopyInputResult::Continue => {}
502
+                        EventCopyInputResult::Cancel => app.close_copy_choice(),
503
+                        EventCopyInputResult::Submit(submission) => {
504
+                            let result = match submission {
505
+                                EventCopySubmission::Event { event_id }
506
+                                | EventCopySubmission::Series {
507
+                                    series_id: event_id,
508
+                                } => agenda_source.duplicate_event(&event_id),
509
+                                EventCopySubmission::Occurrence { series_id, anchor } => {
510
+                                    agenda_source.duplicate_occurrence(&series_id, anchor)
511
+                                }
512
+                            };
513
+                            match result {
514
+                                Ok(event) => {
515
+                                    app.close_copy_choice();
516
+                                    app.select_day_event_id(event.id);
517
+                                    app.reconcile_day_event_selection(&agenda_source);
518
+                                }
519
+                                Err(err) => app.set_copy_error(err.to_string()),
520
+                            }
521
+                        }
522
+                    }
496523
                 } else if app.is_choosing_recurring_edit() {
497524
                     match app.handle_recurrence_choice_key(key, &agenda_source) {
498525
                         RecurrenceChoiceInputResult::Continue => {}
@@ -548,6 +575,7 @@ where
548575
                 if app.is_creating_event()
549576
                     || app.is_choosing_recurring_edit()
550577
                     || app.is_confirming_delete()
578
+                    || app.is_copying_event()
551579
                     || app.is_showing_help()
552580
                 {
553581
                     continue;
src/tui.rsmodified
@@ -13,8 +13,8 @@ use crate::{
1313
         AgendaSource, DayAgenda, DayMinute, EmptyAgendaSource, Event, EventTiming, TimedAgendaEvent,
1414
     },
1515
     app::{
16
-        AppState, CreateEventForm, CreateEventFormRowKind, EventDeleteChoice, RecurrenceEditChoice,
17
-        ViewMode,
16
+        AppState, CreateEventForm, CreateEventFormRowKind, EventCopyChoice, EventDeleteChoice,
17
+        RecurrenceEditChoice, ViewMode,
1818
     },
1919
     calendar::{
2020
         CalendarCell, CalendarDate, CalendarMonth, CalendarWeek, DAYS_PER_WEEK, MONTH_GRID_WEEKS,
@@ -103,6 +103,9 @@ impl Widget for AppView<'_> {
103103
         if let Some(choice) = self.app.delete_choice() {
104104
             render_delete_choice_modal(choice, area, buf, CreateModalStyles::new());
105105
         }
106
+        if let Some(choice) = self.app.copy_choice() {
107
+            render_copy_choice_modal(choice, area, buf, CreateModalStyles::new());
108
+        }
106109
         if self.app.is_showing_help() {
107110
             render_help_modal(self.app.view_mode(), area, buf, CreateModalStyles::new());
108111
         }
@@ -1063,6 +1066,77 @@ fn render_delete_choice_modal(
10631066
     );
10641067
 }
10651068
 
1069
+fn render_copy_choice_modal(
1070
+    choice: &EventCopyChoice,
1071
+    area: Rect,
1072
+    buf: &mut Buffer,
1073
+    styles: CreateModalStyles,
1074
+) {
1075
+    if area.width == 0 || area.height == 0 {
1076
+        return;
1077
+    }
1078
+
1079
+    let modal = recurrence_choice_modal_area(area);
1080
+    fill_rect(buf, modal, styles.panel);
1081
+    draw_border(buf, modal, styles.border, BorderCharacters::normal());
1082
+
1083
+    let content = inset_rect(modal);
1084
+    if content.width == 0 || content.height == 0 {
1085
+        return;
1086
+    }
1087
+
1088
+    write_centered(
1089
+        buf,
1090
+        content.y,
1091
+        content.x,
1092
+        content.width,
1093
+        choice.heading(),
1094
+        styles.title,
1095
+    );
1096
+
1097
+    let mut y = content.y.saturating_add(2);
1098
+    for row in choice.rows() {
1099
+        if y >= content.bottom().saturating_sub(2) {
1100
+            break;
1101
+        }
1102
+        let marker = if row.selected { ">" } else { " " };
1103
+        write_padded_left(buf, y, content.x, 1, marker, styles.label);
1104
+        write_left(
1105
+            buf,
1106
+            y,
1107
+            content.x.saturating_add(2),
1108
+            content.width.saturating_sub(2),
1109
+            row.label,
1110
+            if row.selected {
1111
+                styles.title
1112
+            } else {
1113
+                styles.value
1114
+            },
1115
+        );
1116
+        y = y.saturating_add(1);
1117
+    }
1118
+
1119
+    if let Some(error) = choice.error() {
1120
+        write_left(
1121
+            buf,
1122
+            content.bottom().saturating_sub(2),
1123
+            content.x,
1124
+            content.width,
1125
+            error,
1126
+            styles.error,
1127
+        );
1128
+    }
1129
+
1130
+    write_centered(
1131
+        buf,
1132
+        content.bottom().saturating_sub(1),
1133
+        content.x,
1134
+        content.width,
1135
+        "Enter select | Esc cancel",
1136
+        styles.footer,
1137
+    );
1138
+}
1139
+
10661140
 fn render_help_modal(view_mode: ViewMode, area: Rect, buf: &mut Buffer, styles: CreateModalStyles) {
10671141
     if area.width == 0 || area.height == 0 {
10681142
         return;
@@ -1174,6 +1248,7 @@ fn help_rows(view_mode: ViewMode) -> &'static [(&'static str, &'static str)] {
11741248
             ("Left/Right", "Move to the previous or next day"),
11751249
             ("Up/Down", "Select a local event"),
11761250
             ("Enter", "Edit the selected local event"),
1251
+            ("c", "Copy the selected local event"),
11771252
             ("d", "Delete the selected local event"),
11781253
             ("+", "Create an event on this day"),
11791254
             ("Esc", "Return to month view"),
@@ -2521,6 +2596,29 @@ mod tests {
25212596
         assert!(rendered.contains("Enter select"));
25222597
     }
25232598
 
2599
+    #[test]
2600
+    fn copy_choice_modal_renders_over_day_view() {
2601
+        let day = date(2026, Month::April, 23);
2602
+        let source = agenda_source(
2603
+            vec![local_timed_event(
2604
+                "planning",
2605
+                "Planning",
2606
+                at(day, 9, 0),
2607
+                at(day, 10, 0),
2608
+            )],
2609
+            Vec::new(),
2610
+        );
2611
+        let mut app = AppState::new(day);
2612
+        app.apply_with_agenda_source(AppAction::OpenDay, &source);
2613
+        app.apply_with_agenda_source(AppAction::OpenCopy, &source);
2614
+
2615
+        let rendered = render_app_to_string_with_agenda_source(&app, 84, 26, &source);
2616
+
2617
+        assert!(rendered.contains("Copy"));
2618
+        assert!(rendered.contains("Copy event"));
2619
+        assert!(rendered.contains("Enter select"));
2620
+    }
2621
+
25242622
     #[test]
25252623
     fn help_modal_shows_month_week_keys_in_month_mode() {
25262624
         let mut app = AppState::new(date(2026, Month::April, 23));
@@ -2543,6 +2641,7 @@ mod tests {
25432641
         let rendered = render_app_to_string(&app, 84, 26);
25442642
 
25452643
         assert!(rendered.contains("Day keys"));
2644
+        assert!(rendered.contains("Copy the selected local event"));
25462645
         assert!(rendered.contains("Delete the selected local event"));
25472646
         assert!(rendered.contains("Move to the previous or next day"));
25482647
     }