tenseleyflow/rcal / 424b004

Browse files

Add local recurring events

Authored by espadonne
SHA
424b0042eb9202f4d2d34cd7f836a43c11a124c9
Parents
eb06be2
Tree
d774907

4 changed files

StatusFile+-
M src/agenda.rs 1475 31
M src/app.rs 708 13
M src/cli.rs 46 19
M src/tui.rs 130 2
src/agenda.rsmodified
1742 lines changed — click to load
@@ -40,7 +40,7 @@ impl DateRange {
4040
     }
4141
 }
4242
 
43
-#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
43
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
4444
 pub struct EventDateTime {
4545
     pub date: CalendarDate,
4646
     pub time: Time,
@@ -141,6 +141,117 @@ impl EventTiming {
141141
     }
142142
 }
143143
 
144
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145
+pub enum RecurrenceFrequency {
146
+    Daily,
147
+    Weekly,
148
+    Monthly,
149
+    Yearly,
150
+}
151
+
152
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153
+pub enum RecurrenceEnd {
154
+    Never,
155
+    Until(CalendarDate),
156
+    Count(u32),
157
+}
158
+
159
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160
+pub enum RecurrenceOrdinal {
161
+    Number(u8),
162
+    Last,
163
+}
164
+
165
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166
+pub enum RecurrenceMonthlyRule {
167
+    DayOfMonth(u8),
168
+    WeekdayOrdinal {
169
+        ordinal: RecurrenceOrdinal,
170
+        weekday: Weekday,
171
+    },
172
+}
173
+
174
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175
+pub enum RecurrenceYearlyRule {
176
+    Date {
177
+        month: Month,
178
+        day: u8,
179
+    },
180
+    WeekdayOrdinal {
181
+        month: Month,
182
+        ordinal: RecurrenceOrdinal,
183
+        weekday: Weekday,
184
+    },
185
+}
186
+
187
+#[derive(Debug, Clone, PartialEq, Eq)]
188
+pub struct RecurrenceRule {
189
+    pub frequency: RecurrenceFrequency,
190
+    pub interval: u16,
191
+    pub end: RecurrenceEnd,
192
+    pub weekdays: Vec<Weekday>,
193
+    pub monthly: Option<RecurrenceMonthlyRule>,
194
+    pub yearly: Option<RecurrenceYearlyRule>,
195
+}
196
+
197
+impl RecurrenceRule {
198
+    pub fn new(frequency: RecurrenceFrequency) -> Self {
199
+        Self {
200
+            frequency,
201
+            interval: 1,
202
+            end: RecurrenceEnd::Never,
203
+            weekdays: Vec::new(),
204
+            monthly: None,
205
+            yearly: None,
206
+        }
207
+    }
208
+
209
+    pub fn with_interval(mut self, interval: u16) -> Self {
210
+        self.interval = interval.max(1);
211
+        self
212
+    }
213
+
214
+    pub fn interval(&self) -> u16 {
215
+        self.interval.max(1)
216
+    }
217
+}
218
+
219
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
220
+pub enum OccurrenceAnchor {
221
+    AllDay { date: CalendarDate },
222
+    Timed { start: EventDateTime },
223
+}
224
+
225
+impl OccurrenceAnchor {
226
+    pub const fn date(self) -> CalendarDate {
227
+        match self {
228
+            Self::AllDay { date } => date,
229
+            Self::Timed { start } => start.date,
230
+        }
231
+    }
232
+
233
+    fn storage_key(self) -> String {
234
+        match self {
235
+            Self::AllDay { date } => format!("{date}"),
236
+            Self::Timed { start } => {
237
+                format!("{}T{}", start.date, format_time(start.time))
238
+            }
239
+        }
240
+    }
241
+}
242
+
243
+#[derive(Debug, Clone, PartialEq, Eq)]
244
+pub struct OccurrenceMetadata {
245
+    pub series_id: String,
246
+    pub anchor: OccurrenceAnchor,
247
+}
248
+
249
+#[derive(Debug, Clone, PartialEq, Eq)]
250
+pub struct OccurrenceOverride {
251
+    pub anchor: OccurrenceAnchor,
252
+    pub draft: CreateEventDraft,
253
+}
254
+
144255
 #[derive(Debug, Clone, PartialEq, Eq)]
145256
 pub struct Event {
146257
     pub id: String,
@@ -150,6 +261,9 @@ pub struct Event {
150261
     pub reminders: Vec<Reminder>,
151262
     pub source: SourceMetadata,
152263
     pub timing: EventTiming,
264
+    pub recurrence: Option<RecurrenceRule>,
265
+    pub occurrence: Option<OccurrenceMetadata>,
266
+    pub occurrence_overrides: Vec<OccurrenceOverride>,
153267
 }
154268
 
155269
 impl Event {
@@ -167,6 +281,9 @@ impl Event {
167281
             reminders: Vec::new(),
168282
             source,
169283
             timing: EventTiming::AllDay { date },
284
+            recurrence: None,
285
+            occurrence: None,
286
+            occurrence_overrides: Vec::new(),
170287
         }
171288
     }
172289
 
@@ -189,6 +306,9 @@ impl Event {
189306
             reminders: Vec::new(),
190307
             source,
191308
             timing: EventTiming::Timed { start, end },
309
+            recurrence: None,
310
+            occurrence: None,
311
+            occurrence_overrides: Vec::new(),
192312
         })
193313
     }
194314
 
@@ -207,6 +327,11 @@ impl Event {
207327
         self
208328
     }
209329
 
330
+    pub fn with_recurrence(mut self, recurrence: RecurrenceRule) -> Self {
331
+        self.recurrence = Some(recurrence);
332
+        self
333
+    }
334
+
210335
     pub const fn is_all_day(&self) -> bool {
211336
         self.timing.is_all_day()
212337
     }
@@ -219,6 +344,14 @@ impl Event {
219344
         self.source.source_id == "local"
220345
     }
221346
 
347
+    pub const fn is_recurring_series(&self) -> bool {
348
+        self.recurrence.is_some()
349
+    }
350
+
351
+    pub const fn occurrence(&self) -> Option<&OccurrenceMetadata> {
352
+        self.occurrence.as_ref()
353
+    }
354
+
222355
     pub fn intersects_range(&self, range: DateRange) -> bool {
223356
         match self.timing {
224357
             EventTiming::AllDay { date } => range.contains_date(date),
@@ -239,6 +372,7 @@ pub struct CreateEventDraft {
239372
     pub location: Option<String>,
240373
     pub notes: Option<String>,
241374
     pub reminders: Vec<Reminder>,
375
+    pub recurrence: Option<RecurrenceRule>,
242376
 }
243377
 
244378
 impl CreateEventDraft {
@@ -254,8 +388,14 @@ impl CreateEventDraft {
254388
         event.location = self.location;
255389
         event.notes = self.notes;
256390
         event.reminders = self.reminders;
391
+        event.recurrence = self.recurrence;
257392
         Ok(event)
258393
     }
394
+
395
+    fn without_recurrence(mut self) -> Self {
396
+        self.recurrence = None;
397
+        self
398
+    }
259399
 }
260400
 
261401
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -395,6 +535,10 @@ pub trait AgendaSource {
395535
     fn events_intersecting(&self, range: DateRange) -> Vec<Event>;
396536
 
397537
     fn holidays_in(&self, range: DateRange) -> Vec<Holiday>;
538
+
539
+    fn local_event_by_id(&self, _id: &str) -> Option<Event> {
540
+        None
541
+    }
398542
 }
399543
 
400544
 #[derive(Debug)]
@@ -460,13 +604,18 @@ impl ConfiguredAgendaSource {
460604
             return Err(LocalEventStoreError::EventNotEditable { id: id.to_string() });
461605
         }
462606
 
463
-        let event =
607
+        let mut event =
464608
             draft
465609
                 .into_event(id.to_string())
466610
                 .map_err(|err| LocalEventStoreError::Encode {
467611
                     path: self.events_file.clone(),
468612
                     reason: err.to_string(),
469613
                 })?;
614
+        let existing_overrides = std::mem::take(&mut events[index].occurrence_overrides);
615
+        event.occurrence_overrides = existing_overrides
616
+            .into_iter()
617
+            .filter(|override_record| event_generates_anchor(&event, override_record.anchor))
618
+            .collect();
470619
         events[index] = event.clone();
471620
 
472621
         if let Some(path) = &self.events_file {
@@ -476,6 +625,58 @@ impl ConfiguredAgendaSource {
476625
         Ok(event)
477626
     }
478627
 
628
+    pub fn update_occurrence(
629
+        &mut self,
630
+        series_id: &str,
631
+        anchor: OccurrenceAnchor,
632
+        draft: CreateEventDraft,
633
+    ) -> Result<Event, LocalEventStoreError> {
634
+        let mut events = self.events.events().to_vec();
635
+        let Some(index) = events.iter().position(|event| event.id == series_id) else {
636
+            return Err(LocalEventStoreError::EventNotFound {
637
+                id: series_id.to_string(),
638
+            });
639
+        };
640
+        if !events[index].is_local() || !events[index].is_recurring_series() {
641
+            return Err(LocalEventStoreError::EventNotEditable {
642
+                id: series_id.to_string(),
643
+            });
644
+        }
645
+        if !event_generates_anchor(&events[index], anchor) {
646
+            return Err(LocalEventStoreError::OccurrenceNotFound {
647
+                id: series_id.to_string(),
648
+                anchor: anchor.storage_key(),
649
+            });
650
+        }
651
+
652
+        let override_record = OccurrenceOverride {
653
+            anchor,
654
+            draft: draft.without_recurrence(),
655
+        };
656
+        if let Some(existing) = events[index]
657
+            .occurrence_overrides
658
+            .iter_mut()
659
+            .find(|existing| existing.anchor == anchor)
660
+        {
661
+            *existing = override_record;
662
+        } else {
663
+            events[index].occurrence_overrides.push(override_record);
664
+        }
665
+
666
+        let event = occurrence_override_event(&events[index], anchor).ok_or_else(|| {
667
+            LocalEventStoreError::OccurrenceNotFound {
668
+                id: series_id.to_string(),
669
+                anchor: anchor.storage_key(),
670
+            }
671
+        })?;
672
+
673
+        if let Some(path) = &self.events_file {
674
+            write_events_file(path, &events)?;
675
+        }
676
+        self.events.events = events;
677
+        Ok(event)
678
+    }
679
+
479680
     fn next_local_event_id(&self, title: &str) -> String {
480681
         let now = SystemTime::now()
481682
             .duration_since(UNIX_EPOCH)
@@ -499,6 +700,10 @@ impl AgendaSource for ConfiguredAgendaSource {
499700
     fn holidays_in(&self, range: DateRange) -> Vec<Holiday> {
500701
         self.holidays.holidays_in(range)
501702
     }
703
+
704
+    fn local_event_by_id(&self, id: &str) -> Option<Event> {
705
+        self.events.local_event_by_id(id)
706
+    }
502707
 }
503708
 
504709
 #[derive(Debug)]
@@ -761,11 +966,17 @@ impl InMemoryAgendaSource {
761966
 
762967
 impl AgendaSource for InMemoryAgendaSource {
763968
     fn events_intersecting(&self, range: DateRange) -> Vec<Event> {
764
-        self.events
969
+        let mut events = self
970
+            .events
765971
             .iter()
766
-            .filter(|event| event.intersects_range(range))
767
-            .cloned()
768
-            .collect()
972
+            .flat_map(|event| events_intersecting_range(event, range))
973
+            .collect::<Vec<_>>();
974
+        events.sort_by(|left, right| {
975
+            event_sort_key(left)
976
+                .cmp(&event_sort_key(right))
977
+                .then(left.id.cmp(&right.id))
978
+        });
979
+        events
769980
     }
770981
 
771982
     fn holidays_in(&self, range: DateRange) -> Vec<Holiday> {
@@ -775,6 +986,331 @@ impl AgendaSource for InMemoryAgendaSource {
775986
             .cloned()
776987
             .collect()
777988
     }
989
+
990
+    fn local_event_by_id(&self, id: &str) -> Option<Event> {
991
+        self.events
992
+            .iter()
993
+            .find(|event| event.id == id && event.is_local())
994
+            .cloned()
995
+    }
996
+}
997
+
998
+fn events_intersecting_range(event: &Event, range: DateRange) -> Vec<Event> {
999
+    if event.recurrence.is_none() {
1000
+        return event
1001
+            .intersects_range(range)
1002
+            .then(|| event.clone())
1003
+            .into_iter()
1004
+            .collect();
1005
+    }
1006
+
1007
+    expand_recurring_event(event, range)
1008
+        .into_iter()
1009
+        .filter(|event| event.intersects_range(range))
1010
+        .collect()
1011
+}
1012
+
1013
+fn expand_recurring_event(event: &Event, range: DateRange) -> Vec<Event> {
1014
+    let Some(recurrence) = &event.recurrence else {
1015
+        return Vec::new();
1016
+    };
1017
+    let Some(start_date) = event_start_date(event) else {
1018
+        return Vec::new();
1019
+    };
1020
+
1021
+    let mut events = Vec::new();
1022
+    let final_date = range.end.add_days(-1);
1023
+    let mut date = start_date;
1024
+    let mut generated_count = 0_u32;
1025
+
1026
+    while date <= final_date {
1027
+        if let RecurrenceEnd::Until(until) = recurrence.end
1028
+            && date > until
1029
+        {
1030
+            break;
1031
+        }
1032
+
1033
+        if recurs_on_date(date, start_date, recurrence) {
1034
+            generated_count = generated_count.saturating_add(1);
1035
+            if let RecurrenceEnd::Count(max_count) = recurrence.end
1036
+                && generated_count > max_count
1037
+            {
1038
+                break;
1039
+            }
1040
+
1041
+            let anchor = occurrence_anchor_for_date(event, date);
1042
+            let instance = occurrence_override_event(event, anchor)
1043
+                .unwrap_or_else(|| generated_occurrence_event(event, anchor));
1044
+            events.push(instance);
1045
+        }
1046
+
1047
+        date = date.add_days(1);
1048
+    }
1049
+
1050
+    events
1051
+}
1052
+
1053
+fn event_generates_anchor(event: &Event, anchor: OccurrenceAnchor) -> bool {
1054
+    let Some(recurrence) = &event.recurrence else {
1055
+        return false;
1056
+    };
1057
+    let Some(start_date) = event_start_date(event) else {
1058
+        return false;
1059
+    };
1060
+    if anchor.date() < start_date || !recurs_on_date(anchor.date(), start_date, recurrence) {
1061
+        return false;
1062
+    }
1063
+    if !anchor_is_within_recurrence_end(anchor.date(), start_date, recurrence) {
1064
+        return false;
1065
+    }
1066
+    occurrence_anchor_for_date(event, anchor.date()) == anchor
1067
+}
1068
+
1069
+fn anchor_is_within_recurrence_end(
1070
+    anchor_date: CalendarDate,
1071
+    start_date: CalendarDate,
1072
+    recurrence: &RecurrenceRule,
1073
+) -> bool {
1074
+    if let RecurrenceEnd::Until(until) = recurrence.end
1075
+        && anchor_date > until
1076
+    {
1077
+        return false;
1078
+    }
1079
+
1080
+    if let RecurrenceEnd::Count(max_count) = recurrence.end {
1081
+        let mut count = 0_u32;
1082
+        let mut date = start_date;
1083
+        while date <= anchor_date {
1084
+            if recurs_on_date(date, start_date, recurrence) {
1085
+                count = count.saturating_add(1);
1086
+            }
1087
+            date = date.add_days(1);
1088
+        }
1089
+        return count <= max_count;
1090
+    }
1091
+
1092
+    true
1093
+}
1094
+
1095
+fn occurrence_override_event(series: &Event, anchor: OccurrenceAnchor) -> Option<Event> {
1096
+    let override_record = series
1097
+        .occurrence_overrides
1098
+        .iter()
1099
+        .find(|override_record| override_record.anchor == anchor)?;
1100
+    occurrence_event_from_draft(series, anchor, override_record.draft.clone()).ok()
1101
+}
1102
+
1103
+fn generated_occurrence_event(series: &Event, anchor: OccurrenceAnchor) -> Event {
1104
+    let mut event = series.clone();
1105
+    event.id = occurrence_event_id(series, anchor);
1106
+    event.timing = occurrence_timing(series, anchor);
1107
+    event.occurrence = Some(OccurrenceMetadata {
1108
+        series_id: series.id.clone(),
1109
+        anchor,
1110
+    });
1111
+    event.recurrence = None;
1112
+    event.occurrence_overrides = Vec::new();
1113
+    event
1114
+}
1115
+
1116
+fn occurrence_event_from_draft(
1117
+    series: &Event,
1118
+    anchor: OccurrenceAnchor,
1119
+    draft: CreateEventDraft,
1120
+) -> Result<Event, AgendaError> {
1121
+    let mut event = draft
1122
+        .without_recurrence()
1123
+        .into_event(occurrence_event_id(series, anchor))?;
1124
+    event.source = series.source.clone();
1125
+    event.occurrence = Some(OccurrenceMetadata {
1126
+        series_id: series.id.clone(),
1127
+        anchor,
1128
+    });
1129
+    Ok(event)
1130
+}
1131
+
1132
+fn occurrence_event_id(series: &Event, anchor: OccurrenceAnchor) -> String {
1133
+    format!("{}#{}", series.id, anchor.storage_key())
1134
+}
1135
+
1136
+fn occurrence_anchor_for_date(event: &Event, date: CalendarDate) -> OccurrenceAnchor {
1137
+    match event.timing {
1138
+        EventTiming::AllDay { .. } => OccurrenceAnchor::AllDay { date },
1139
+        EventTiming::Timed { start, .. } => OccurrenceAnchor::Timed {
1140
+            start: EventDateTime::new(date, start.time),
1141
+        },
1142
+    }
1143
+}
1144
+
1145
+fn occurrence_timing(event: &Event, anchor: OccurrenceAnchor) -> EventTiming {
1146
+    match (event.timing, anchor) {
1147
+        (EventTiming::AllDay { .. }, OccurrenceAnchor::AllDay { date }) => {
1148
+            EventTiming::AllDay { date }
1149
+        }
1150
+        (
1151
+            EventTiming::Timed { start, end },
1152
+            OccurrenceAnchor::Timed {
1153
+                start: anchor_start,
1154
+            },
1155
+        ) => {
1156
+            let duration_minutes = datetime_distance_minutes(start, end);
1157
+            EventTiming::Timed {
1158
+                start: anchor_start,
1159
+                end: add_minutes(anchor_start, duration_minutes),
1160
+            }
1161
+        }
1162
+        _ => event.timing,
1163
+    }
1164
+}
1165
+
1166
+fn event_start_date(event: &Event) -> Option<CalendarDate> {
1167
+    match event.timing {
1168
+        EventTiming::AllDay { date } => Some(date),
1169
+        EventTiming::Timed { start, .. } => Some(start.date),
1170
+    }
1171
+}
1172
+
1173
+fn recurs_on_date(date: CalendarDate, start_date: CalendarDate, rule: &RecurrenceRule) -> bool {
1174
+    if date < start_date {
1175
+        return false;
1176
+    }
1177
+
1178
+    match rule.frequency {
1179
+        RecurrenceFrequency::Daily => {
1180
+            days_between(start_date, date) % i32::from(rule.interval()) == 0
1181
+        }
1182
+        RecurrenceFrequency::Weekly => {
1183
+            let days = days_between(start_date, date);
1184
+            let week_index = days / 7;
1185
+            let weekdays = recurrence_weekdays(rule, start_date);
1186
+            week_index % i32::from(rule.interval()) == 0 && weekdays.contains(&date.weekday())
1187
+        }
1188
+        RecurrenceFrequency::Monthly => {
1189
+            let months = months_between(start_date, date);
1190
+            if months < 0 || months % i32::from(rule.interval()) != 0 {
1191
+                return false;
1192
+            }
1193
+            let monthly = rule
1194
+                .monthly
1195
+                .unwrap_or(RecurrenceMonthlyRule::DayOfMonth(start_date.day()));
1196
+            match monthly {
1197
+                RecurrenceMonthlyRule::DayOfMonth(day) => {
1198
+                    CalendarDate::from_ymd(date.year(), date.month(), day).ok() == Some(date)
1199
+                }
1200
+                RecurrenceMonthlyRule::WeekdayOrdinal { ordinal, weekday } => {
1201
+                    weekday_ordinal_date(date.year(), date.month(), ordinal, weekday) == Some(date)
1202
+                }
1203
+            }
1204
+        }
1205
+        RecurrenceFrequency::Yearly => {
1206
+            let years = date.year() - start_date.year();
1207
+            if years < 0 || years % i32::from(rule.interval()) != 0 {
1208
+                return false;
1209
+            }
1210
+            let yearly = rule.yearly.unwrap_or(RecurrenceYearlyRule::Date {
1211
+                month: start_date.month(),
1212
+                day: start_date.day(),
1213
+            });
1214
+            match yearly {
1215
+                RecurrenceYearlyRule::Date { month, day } => {
1216
+                    CalendarDate::from_ymd(date.year(), month, day).ok() == Some(date)
1217
+                }
1218
+                RecurrenceYearlyRule::WeekdayOrdinal {
1219
+                    month,
1220
+                    ordinal,
1221
+                    weekday,
1222
+                } => {
1223
+                    date.month() == month
1224
+                        && weekday_ordinal_date(date.year(), month, ordinal, weekday) == Some(date)
1225
+                }
1226
+            }
1227
+        }
1228
+    }
1229
+}
1230
+
1231
+fn recurrence_weekdays(rule: &RecurrenceRule, start_date: CalendarDate) -> Vec<Weekday> {
1232
+    if rule.weekdays.is_empty() {
1233
+        vec![start_date.weekday()]
1234
+    } else {
1235
+        rule.weekdays.clone()
1236
+    }
1237
+}
1238
+
1239
+fn weekday_ordinal_date(
1240
+    year: i32,
1241
+    month: Month,
1242
+    ordinal: RecurrenceOrdinal,
1243
+    weekday: Weekday,
1244
+) -> Option<CalendarDate> {
1245
+    match ordinal {
1246
+        RecurrenceOrdinal::Number(number) if (1..=4).contains(&number) => {
1247
+            let first = CalendarDate::from_ymd(year, month, 1).ok()?;
1248
+            let first_weekday = first.weekday().number_days_from_sunday();
1249
+            let target_weekday = weekday.number_days_from_sunday();
1250
+            let offset = (target_weekday + 7 - first_weekday) % 7;
1251
+            let day = 1 + offset + (number - 1) * 7;
1252
+            CalendarDate::from_ymd(year, month, day).ok()
1253
+        }
1254
+        RecurrenceOrdinal::Last => {
1255
+            let mut date = CalendarDate::from_ymd(year, month, month.length(year)).ok()?;
1256
+            while date.weekday() != weekday {
1257
+                date = date.add_days(-1);
1258
+            }
1259
+            Some(date)
1260
+        }
1261
+        _ => None,
1262
+    }
1263
+}
1264
+
1265
+pub fn recurrence_ordinal_for_date(date: CalendarDate) -> RecurrenceOrdinal {
1266
+    if date.day().saturating_add(7) > date.month().length(date.year()) {
1267
+        RecurrenceOrdinal::Last
1268
+    } else {
1269
+        RecurrenceOrdinal::Number(((date.day() - 1) / 7) + 1)
1270
+    }
1271
+}
1272
+
1273
+fn days_between(start: CalendarDate, end: CalendarDate) -> i32 {
1274
+    end.inner().to_julian_day() - start.inner().to_julian_day()
1275
+}
1276
+
1277
+fn months_between(start: CalendarDate, end: CalendarDate) -> i32 {
1278
+    (end.year() - start.year()) * 12 + i32::from(u8::from(end.month()))
1279
+        - i32::from(u8::from(start.month()))
1280
+}
1281
+
1282
+fn datetime_distance_minutes(start: EventDateTime, end: EventDateTime) -> i32 {
1283
+    days_between(start.date, end.date) * 24 * 60 + time_minutes(end.time) - time_minutes(start.time)
1284
+}
1285
+
1286
+fn add_minutes(start: EventDateTime, duration_minutes: i32) -> EventDateTime {
1287
+    let absolute_minutes = time_minutes(start.time) + duration_minutes;
1288
+    let day_offset = absolute_minutes.div_euclid(24 * 60);
1289
+    let minute_of_day = absolute_minutes.rem_euclid(24 * 60);
1290
+    EventDateTime::new(
1291
+        start.date.add_days(day_offset),
1292
+        Time::from_hms(
1293
+            u8::try_from(minute_of_day / 60).expect("hour stays in range"),
1294
+            u8::try_from(minute_of_day % 60).expect("minute stays in range"),
1295
+            0,
1296
+        )
1297
+        .expect("computed time is valid"),
1298
+    )
1299
+}
1300
+
1301
+fn time_minutes(time: Time) -> i32 {
1302
+    i32::from(time.hour()) * 60 + i32::from(time.minute())
1303
+}
1304
+
1305
+fn event_sort_key(event: &Event) -> (CalendarDate, DayMinute, String) {
1306
+    match event.timing {
1307
+        EventTiming::AllDay { date } => (date, DayMinute::START, event.title.clone()),
1308
+        EventTiming::Timed { start, .. } => (
1309
+            start.date,
1310
+            DayMinute::from_time(start.time),
1311
+            event.title.clone(),
1312
+        ),
1313
+    }
7781314
 }
7791315
 
7801316
 pub fn default_events_file() -> PathBuf {
@@ -810,6 +1346,10 @@ pub enum LocalEventStoreError {
8101346
     EventNotFound {
8111347
         id: String,
8121348
     },
1349
+    OccurrenceNotFound {
1350
+        id: String,
1351
+        anchor: String,
1352
+    },
8131353
     EventNotEditable {
8141354
         id: String,
8151355
     },
@@ -838,6 +1378,12 @@ impl fmt::Display for LocalEventStoreError {
8381378
                 path.display()
8391379
             ),
8401380
             Self::EventNotFound { id } => write!(f, "local event '{id}' was not found"),
1381
+            Self::OccurrenceNotFound { id, anchor } => {
1382
+                write!(
1383
+                    f,
1384
+                    "recurring occurrence '{anchor}' was not found for local event '{id}'"
1385
+                )
1386
+            }
8411387
             Self::EventNotEditable { id } => write!(f, "event '{id}' is not editable locally"),
8421388
             Self::Encode { path, reason } => {
8431389
                 if let Some(path) = path {
@@ -876,7 +1422,7 @@ fn load_events_file(path: &Path) -> Result<InMemoryAgendaSource, LocalEventStore
8761422
         }
8771423
     })?;
8781424
 
879
-    if file.version != LOCAL_EVENTS_VERSION {
1425
+    if !matches!(file.version, 1 | LOCAL_EVENTS_VERSION) {
8801426
         return Err(LocalEventStoreError::UnsupportedVersion {
8811427
             path: path.to_path_buf(),
8821428
             version: file.version,
@@ -927,7 +1473,7 @@ fn write_events_file(path: &Path, events: &[Event]) -> Result<(), LocalEventStor
9271473
     })
9281474
 }
9291475
 
930
-const LOCAL_EVENTS_VERSION: u8 = 1;
1476
+const LOCAL_EVENTS_VERSION: u8 = 2;
9311477
 
9321478
 #[derive(Debug, Serialize, Deserialize)]
9331479
 struct LocalEventsFile {
@@ -952,6 +1498,10 @@ enum LocalEventRecord {
9521498
         notes: Option<String>,
9531499
         #[serde(default, skip_serializing_if = "Vec::is_empty")]
9541500
         reminders_minutes_before: Vec<u16>,
1501
+        #[serde(default, skip_serializing_if = "Option::is_none")]
1502
+        recurrence: Option<LocalRecurrenceRecord>,
1503
+        #[serde(default, skip_serializing_if = "Vec::is_empty")]
1504
+        overrides: Vec<LocalOccurrenceOverrideRecord>,
9551505
     },
9561506
     AllDay {
9571507
         id: String,
@@ -963,6 +1513,10 @@ enum LocalEventRecord {
9631513
         notes: Option<String>,
9641514
         #[serde(default, skip_serializing_if = "Vec::is_empty")]
9651515
         reminders_minutes_before: Vec<u16>,
1516
+        #[serde(default, skip_serializing_if = "Option::is_none")]
1517
+        recurrence: Option<LocalRecurrenceRecord>,
1518
+        #[serde(default, skip_serializing_if = "Vec::is_empty")]
1519
+        overrides: Vec<LocalOccurrenceOverrideRecord>,
9661520
     },
9671521
 }
9681522
 
@@ -973,6 +1527,15 @@ impl LocalEventRecord {
9731527
             .iter()
9741528
             .map(|reminder| reminder.minutes_before)
9751529
             .collect::<Vec<_>>();
1530
+        let recurrence = event
1531
+            .recurrence
1532
+            .as_ref()
1533
+            .map(LocalRecurrenceRecord::from_rule);
1534
+        let overrides = event
1535
+            .occurrence_overrides
1536
+            .iter()
1537
+            .map(LocalOccurrenceOverrideRecord::from_override)
1538
+            .collect::<Vec<_>>();
9761539
 
9771540
         match event.timing {
9781541
             EventTiming::AllDay { date } => Self::AllDay {
@@ -982,6 +1545,8 @@ impl LocalEventRecord {
9821545
                 location: event.location.clone(),
9831546
                 notes: event.notes.clone(),
9841547
                 reminders_minutes_before,
1548
+                recurrence,
1549
+                overrides,
9851550
             },
9861551
             EventTiming::Timed { start, end } => Self::Timed {
9871552
                 id: event.id.clone(),
@@ -993,6 +1558,8 @@ impl LocalEventRecord {
9931558
                 location: event.location.clone(),
9941559
                 notes: event.notes.clone(),
9951560
                 reminders_minutes_before,
1561
+                recurrence,
1562
+                overrides,
9961563
             },
9971564
         }
9981565
     }
@@ -1009,6 +1576,8 @@ impl LocalEventRecord {
10091576
                 location,
10101577
                 notes,
10111578
                 reminders_minutes_before,
1579
+                recurrence,
1580
+                overrides,
10121581
             } => {
10131582
                 let start = EventDateTime::new(
10141583
                     parse_local_date(&start_date, path)?,
@@ -1032,6 +1601,13 @@ impl LocalEventRecord {
10321601
                 event.location = empty_to_none(location);
10331602
                 event.notes = empty_to_none(notes);
10341603
                 event.reminders = reminders_from_minutes(reminders_minutes_before);
1604
+                event.recurrence = recurrence
1605
+                    .map(|recurrence| recurrence.into_rule(path))
1606
+                    .transpose()?;
1607
+                event.occurrence_overrides = overrides
1608
+                    .into_iter()
1609
+                    .map(|override_record| override_record.into_override(path))
1610
+                    .collect::<Result<Vec<_>, _>>()?;
10351611
                 Ok(event)
10361612
             }
10371613
             Self::AllDay {
@@ -1041,6 +1617,8 @@ impl LocalEventRecord {
10411617
                 location,
10421618
                 notes,
10431619
                 reminders_minutes_before,
1620
+                recurrence,
1621
+                overrides,
10441622
             } => {
10451623
                 let mut event = Event::all_day(
10461624
                     id.clone(),
@@ -1051,41 +1629,510 @@ impl LocalEventRecord {
10511629
                 event.location = empty_to_none(location);
10521630
                 event.notes = empty_to_none(notes);
10531631
                 event.reminders = reminders_from_minutes(reminders_minutes_before);
1632
+                event.recurrence = recurrence
1633
+                    .map(|recurrence| recurrence.into_rule(path))
1634
+                    .transpose()?;
1635
+                event.occurrence_overrides = overrides
1636
+                    .into_iter()
1637
+                    .map(|override_record| override_record.into_override(path))
1638
+                    .collect::<Result<Vec<_>, _>>()?;
10541639
                 Ok(event)
10551640
             }
10561641
         }
10571642
     }
10581643
 }
10591644
 
1060
-fn reminders_from_minutes(minutes: Vec<u16>) -> Vec<Reminder> {
1061
-    let mut reminders = minutes
1062
-        .into_iter()
1063
-        .map(Reminder::minutes_before)
1064
-        .collect::<Vec<_>>();
1065
-    reminders.sort();
1066
-    reminders.dedup();
1067
-    reminders
1645
+#[derive(Debug, Clone, Serialize, Deserialize)]
1646
+struct LocalRecurrenceRecord {
1647
+    frequency: String,
1648
+    interval: u16,
1649
+    #[serde(default)]
1650
+    weekdays: Vec<String>,
1651
+    #[serde(default, skip_serializing_if = "Option::is_none")]
1652
+    monthly: Option<LocalRecurrenceMonthlyRecord>,
1653
+    #[serde(default, skip_serializing_if = "Option::is_none")]
1654
+    yearly: Option<LocalRecurrenceYearlyRecord>,
1655
+    end: LocalRecurrenceEndRecord,
10681656
 }
10691657
 
1070
-fn empty_to_none(value: Option<String>) -> Option<String> {
1071
-    value.and_then(|value| {
1072
-        let trimmed = value.trim();
1073
-        if trimmed.is_empty() {
1074
-            None
1075
-        } else {
1076
-            Some(trimmed.to_string())
1658
+impl LocalRecurrenceRecord {
1659
+    fn from_rule(rule: &RecurrenceRule) -> Self {
1660
+        Self {
1661
+            frequency: match rule.frequency {
1662
+                RecurrenceFrequency::Daily => "daily",
1663
+                RecurrenceFrequency::Weekly => "weekly",
1664
+                RecurrenceFrequency::Monthly => "monthly",
1665
+                RecurrenceFrequency::Yearly => "yearly",
1666
+            }
1667
+            .to_string(),
1668
+            interval: rule.interval(),
1669
+            weekdays: rule
1670
+                .weekdays
1671
+                .iter()
1672
+                .map(|weekday| weekday_name(*weekday))
1673
+                .collect(),
1674
+            monthly: rule.monthly.map(LocalRecurrenceMonthlyRecord::from_rule),
1675
+            yearly: rule.yearly.map(LocalRecurrenceYearlyRecord::from_rule),
1676
+            end: LocalRecurrenceEndRecord::from_rule(rule.end),
10771677
         }
1078
-    })
1678
+    }
1679
+
1680
+    fn into_rule(self, path: &Path) -> Result<RecurrenceRule, LocalEventStoreError> {
1681
+        let frequency = match self.frequency.as_str() {
1682
+            "daily" => RecurrenceFrequency::Daily,
1683
+            "weekly" => RecurrenceFrequency::Weekly,
1684
+            "monthly" => RecurrenceFrequency::Monthly,
1685
+            "yearly" => RecurrenceFrequency::Yearly,
1686
+            value => {
1687
+                return Err(LocalEventStoreError::Parse {
1688
+                    path: path.to_path_buf(),
1689
+                    reason: format!("invalid recurrence frequency '{value}'"),
1690
+                });
1691
+            }
1692
+        };
1693
+        let weekdays = self
1694
+            .weekdays
1695
+            .into_iter()
1696
+            .map(|weekday| parse_weekday_record(&weekday, path))
1697
+            .collect::<Result<Vec<_>, _>>()?;
1698
+
1699
+        Ok(RecurrenceRule {
1700
+            frequency,
1701
+            interval: self.interval.max(1),
1702
+            end: self.end.into_rule(path)?,
1703
+            weekdays,
1704
+            monthly: self
1705
+                .monthly
1706
+                .map(|monthly| monthly.into_rule(path))
1707
+                .transpose()?,
1708
+            yearly: self
1709
+                .yearly
1710
+                .map(|yearly| yearly.into_rule(path))
1711
+                .transpose()?,
1712
+        })
1713
+    }
10791714
 }
10801715
 
1081
-fn parse_local_date(value: &str, path: &Path) -> Result<CalendarDate, LocalEventStoreError> {
1082
-    parse_iso_date(value).ok_or_else(|| LocalEventStoreError::Parse {
1083
-        path: path.to_path_buf(),
1084
-        reason: format!("invalid date '{value}'"),
1085
-    })
1716
+#[derive(Debug, Clone, Serialize, Deserialize)]
1717
+#[serde(tag = "mode", rename_all = "snake_case")]
1718
+enum LocalRecurrenceEndRecord {
1719
+    Never,
1720
+    Until { date: String },
1721
+    Count { count: u32 },
10861722
 }
10871723
 
1088
-fn parse_local_time(value: &str, path: &Path) -> Result<Time, LocalEventStoreError> {
1724
+impl LocalRecurrenceEndRecord {
1725
+    fn from_rule(end: RecurrenceEnd) -> Self {
1726
+        match end {
1727
+            RecurrenceEnd::Never => Self::Never,
1728
+            RecurrenceEnd::Until(date) => Self::Until {
1729
+                date: date.to_string(),
1730
+            },
1731
+            RecurrenceEnd::Count(count) => Self::Count { count },
1732
+        }
1733
+    }
1734
+
1735
+    fn into_rule(self, path: &Path) -> Result<RecurrenceEnd, LocalEventStoreError> {
1736
+        match self {
1737
+            Self::Never => Ok(RecurrenceEnd::Never),
1738
+            Self::Until { date } => Ok(RecurrenceEnd::Until(parse_local_date(&date, path)?)),
1739
+            Self::Count { count } => Ok(RecurrenceEnd::Count(count.max(1))),
1740
+        }
1741
+    }
1742
+}
1743
+
1744
+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1745
+#[serde(tag = "mode", rename_all = "snake_case")]
1746
+enum LocalRecurrenceMonthlyRecord {
1747
+    DayOfMonth {
1748
+        day: u8,
1749
+    },
1750
+    WeekdayOrdinal {
1751
+        ordinal: LocalRecurrenceOrdinalRecord,
1752
+        weekday: LocalWeekdayRecord,
1753
+    },
1754
+}
1755
+
1756
+impl LocalRecurrenceMonthlyRecord {
1757
+    fn from_rule(rule: RecurrenceMonthlyRule) -> Self {
1758
+        match rule {
1759
+            RecurrenceMonthlyRule::DayOfMonth(day) => Self::DayOfMonth { day },
1760
+            RecurrenceMonthlyRule::WeekdayOrdinal { ordinal, weekday } => Self::WeekdayOrdinal {
1761
+                ordinal: LocalRecurrenceOrdinalRecord::from_rule(ordinal),
1762
+                weekday: LocalWeekdayRecord::from_weekday(weekday),
1763
+            },
1764
+        }
1765
+    }
1766
+
1767
+    fn into_rule(self, _path: &Path) -> Result<RecurrenceMonthlyRule, LocalEventStoreError> {
1768
+        Ok(match self {
1769
+            Self::DayOfMonth { day } => RecurrenceMonthlyRule::DayOfMonth(day),
1770
+            Self::WeekdayOrdinal { ordinal, weekday } => RecurrenceMonthlyRule::WeekdayOrdinal {
1771
+                ordinal: ordinal.into_rule(),
1772
+                weekday: weekday.into_weekday(),
1773
+            },
1774
+        })
1775
+    }
1776
+}
1777
+
1778
+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1779
+#[serde(tag = "mode", rename_all = "snake_case")]
1780
+enum LocalRecurrenceYearlyRecord {
1781
+    Date {
1782
+        month: u8,
1783
+        day: u8,
1784
+    },
1785
+    WeekdayOrdinal {
1786
+        month: u8,
1787
+        ordinal: LocalRecurrenceOrdinalRecord,
1788
+        weekday: LocalWeekdayRecord,
1789
+    },
1790
+}
1791
+
1792
+impl LocalRecurrenceYearlyRecord {
1793
+    fn from_rule(rule: RecurrenceYearlyRule) -> Self {
1794
+        match rule {
1795
+            RecurrenceYearlyRule::Date { month, day } => Self::Date {
1796
+                month: u8::from(month),
1797
+                day,
1798
+            },
1799
+            RecurrenceYearlyRule::WeekdayOrdinal {
1800
+                month,
1801
+                ordinal,
1802
+                weekday,
1803
+            } => Self::WeekdayOrdinal {
1804
+                month: u8::from(month),
1805
+                ordinal: LocalRecurrenceOrdinalRecord::from_rule(ordinal),
1806
+                weekday: LocalWeekdayRecord::from_weekday(weekday),
1807
+            },
1808
+        }
1809
+    }
1810
+
1811
+    fn into_rule(self, path: &Path) -> Result<RecurrenceYearlyRule, LocalEventStoreError> {
1812
+        Ok(match self {
1813
+            Self::Date { month, day } => RecurrenceYearlyRule::Date {
1814
+                month: parse_month_record(month, path)?,
1815
+                day,
1816
+            },
1817
+            Self::WeekdayOrdinal {
1818
+                month,
1819
+                ordinal,
1820
+                weekday,
1821
+            } => RecurrenceYearlyRule::WeekdayOrdinal {
1822
+                month: parse_month_record(month, path)?,
1823
+                ordinal: ordinal.into_rule(),
1824
+                weekday: weekday.into_weekday(),
1825
+            },
1826
+        })
1827
+    }
1828
+}
1829
+
1830
+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1831
+#[serde(rename_all = "snake_case")]
1832
+enum LocalRecurrenceOrdinalRecord {
1833
+    First,
1834
+    Second,
1835
+    Third,
1836
+    Fourth,
1837
+    Last,
1838
+}
1839
+
1840
+impl LocalRecurrenceOrdinalRecord {
1841
+    fn from_rule(ordinal: RecurrenceOrdinal) -> Self {
1842
+        match ordinal {
1843
+            RecurrenceOrdinal::Number(1) => Self::First,
1844
+            RecurrenceOrdinal::Number(2) => Self::Second,
1845
+            RecurrenceOrdinal::Number(3) => Self::Third,
1846
+            RecurrenceOrdinal::Number(4) => Self::Fourth,
1847
+            RecurrenceOrdinal::Last | RecurrenceOrdinal::Number(_) => Self::Last,
1848
+        }
1849
+    }
1850
+
1851
+    const fn into_rule(self) -> RecurrenceOrdinal {
1852
+        match self {
1853
+            Self::First => RecurrenceOrdinal::Number(1),
1854
+            Self::Second => RecurrenceOrdinal::Number(2),
1855
+            Self::Third => RecurrenceOrdinal::Number(3),
1856
+            Self::Fourth => RecurrenceOrdinal::Number(4),
1857
+            Self::Last => RecurrenceOrdinal::Last,
1858
+        }
1859
+    }
1860
+}
1861
+
1862
+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1863
+#[serde(rename_all = "snake_case")]
1864
+enum LocalWeekdayRecord {
1865
+    Sunday,
1866
+    Monday,
1867
+    Tuesday,
1868
+    Wednesday,
1869
+    Thursday,
1870
+    Friday,
1871
+    Saturday,
1872
+}
1873
+
1874
+impl LocalWeekdayRecord {
1875
+    const fn from_weekday(weekday: Weekday) -> Self {
1876
+        match weekday {
1877
+            Weekday::Sunday => Self::Sunday,
1878
+            Weekday::Monday => Self::Monday,
1879
+            Weekday::Tuesday => Self::Tuesday,
1880
+            Weekday::Wednesday => Self::Wednesday,
1881
+            Weekday::Thursday => Self::Thursday,
1882
+            Weekday::Friday => Self::Friday,
1883
+            Weekday::Saturday => Self::Saturday,
1884
+        }
1885
+    }
1886
+
1887
+    const fn into_weekday(self) -> Weekday {
1888
+        match self {
1889
+            Self::Sunday => Weekday::Sunday,
1890
+            Self::Monday => Weekday::Monday,
1891
+            Self::Tuesday => Weekday::Tuesday,
1892
+            Self::Wednesday => Weekday::Wednesday,
1893
+            Self::Thursday => Weekday::Thursday,
1894
+            Self::Friday => Weekday::Friday,
1895
+            Self::Saturday => Weekday::Saturday,
1896
+        }
1897
+    }
1898
+}
1899
+
1900
+#[derive(Debug, Clone, Serialize, Deserialize)]
1901
+struct LocalOccurrenceOverrideRecord {
1902
+    anchor: LocalOccurrenceAnchorRecord,
1903
+    event: LocalEventDraftRecord,
1904
+}
1905
+
1906
+impl LocalOccurrenceOverrideRecord {
1907
+    fn from_override(override_record: &OccurrenceOverride) -> Self {
1908
+        Self {
1909
+            anchor: LocalOccurrenceAnchorRecord::from_anchor(override_record.anchor),
1910
+            event: LocalEventDraftRecord::from_draft(&override_record.draft),
1911
+        }
1912
+    }
1913
+
1914
+    fn into_override(self, path: &Path) -> Result<OccurrenceOverride, LocalEventStoreError> {
1915
+        Ok(OccurrenceOverride {
1916
+            anchor: self.anchor.into_anchor(path)?,
1917
+            draft: self.event.into_draft(path)?,
1918
+        })
1919
+    }
1920
+}
1921
+
1922
+#[derive(Debug, Clone, Serialize, Deserialize)]
1923
+#[serde(tag = "kind", rename_all = "snake_case")]
1924
+enum LocalOccurrenceAnchorRecord {
1925
+    AllDay { date: String },
1926
+    Timed { date: String, time: String },
1927
+}
1928
+
1929
+impl LocalOccurrenceAnchorRecord {
1930
+    fn from_anchor(anchor: OccurrenceAnchor) -> Self {
1931
+        match anchor {
1932
+            OccurrenceAnchor::AllDay { date } => Self::AllDay {
1933
+                date: date.to_string(),
1934
+            },
1935
+            OccurrenceAnchor::Timed { start } => Self::Timed {
1936
+                date: start.date.to_string(),
1937
+                time: format_time(start.time),
1938
+            },
1939
+        }
1940
+    }
1941
+
1942
+    fn into_anchor(self, path: &Path) -> Result<OccurrenceAnchor, LocalEventStoreError> {
1943
+        Ok(match self {
1944
+            Self::AllDay { date } => OccurrenceAnchor::AllDay {
1945
+                date: parse_local_date(&date, path)?,
1946
+            },
1947
+            Self::Timed { date, time } => OccurrenceAnchor::Timed {
1948
+                start: EventDateTime::new(
1949
+                    parse_local_date(&date, path)?,
1950
+                    parse_local_time(&time, path)?,
1951
+                ),
1952
+            },
1953
+        })
1954
+    }
1955
+}
1956
+
1957
+#[derive(Debug, Clone, Serialize, Deserialize)]
1958
+#[serde(tag = "timing", rename_all = "snake_case")]
1959
+enum LocalEventDraftRecord {
1960
+    Timed {
1961
+        title: String,
1962
+        start_date: String,
1963
+        start_time: String,
1964
+        end_date: String,
1965
+        end_time: String,
1966
+        #[serde(default, skip_serializing_if = "Option::is_none")]
1967
+        location: Option<String>,
1968
+        #[serde(default, skip_serializing_if = "Option::is_none")]
1969
+        notes: Option<String>,
1970
+        #[serde(default, skip_serializing_if = "Vec::is_empty")]
1971
+        reminders_minutes_before: Vec<u16>,
1972
+    },
1973
+    AllDay {
1974
+        title: String,
1975
+        date: String,
1976
+        #[serde(default, skip_serializing_if = "Option::is_none")]
1977
+        location: Option<String>,
1978
+        #[serde(default, skip_serializing_if = "Option::is_none")]
1979
+        notes: Option<String>,
1980
+        #[serde(default, skip_serializing_if = "Vec::is_empty")]
1981
+        reminders_minutes_before: Vec<u16>,
1982
+    },
1983
+}
1984
+
1985
+impl LocalEventDraftRecord {
1986
+    fn from_draft(draft: &CreateEventDraft) -> Self {
1987
+        let reminders_minutes_before = draft
1988
+            .reminders
1989
+            .iter()
1990
+            .map(|reminder| reminder.minutes_before)
1991
+            .collect::<Vec<_>>();
1992
+        match draft.timing {
1993
+            CreateEventTiming::Timed { start, end } => Self::Timed {
1994
+                title: draft.title.clone(),
1995
+                start_date: start.date.to_string(),
1996
+                start_time: format_time(start.time),
1997
+                end_date: end.date.to_string(),
1998
+                end_time: format_time(end.time),
1999
+                location: draft.location.clone(),
2000
+                notes: draft.notes.clone(),
2001
+                reminders_minutes_before,
2002
+            },
2003
+            CreateEventTiming::AllDay { date } => Self::AllDay {
2004
+                title: draft.title.clone(),
2005
+                date: date.to_string(),
2006
+                location: draft.location.clone(),
2007
+                notes: draft.notes.clone(),
2008
+                reminders_minutes_before,
2009
+            },
2010
+        }
2011
+    }
2012
+
2013
+    fn into_draft(self, path: &Path) -> Result<CreateEventDraft, LocalEventStoreError> {
2014
+        Ok(match self {
2015
+            Self::Timed {
2016
+                title,
2017
+                start_date,
2018
+                start_time,
2019
+                end_date,
2020
+                end_time,
2021
+                location,
2022
+                notes,
2023
+                reminders_minutes_before,
2024
+            } => {
2025
+                let start = EventDateTime::new(
2026
+                    parse_local_date(&start_date, path)?,
2027
+                    parse_local_time(&start_time, path)?,
2028
+                );
2029
+                let end = EventDateTime::new(
2030
+                    parse_local_date(&end_date, path)?,
2031
+                    parse_local_time(&end_time, path)?,
2032
+                );
2033
+                if start >= end {
2034
+                    return Err(LocalEventStoreError::Parse {
2035
+                        path: path.to_path_buf(),
2036
+                        reason: format!(
2037
+                            "invalid override range: start {start:?} must be before end {end:?}"
2038
+                        ),
2039
+                    });
2040
+                }
2041
+
2042
+                CreateEventDraft {
2043
+                    title,
2044
+                    timing: CreateEventTiming::Timed { start, end },
2045
+                    location: empty_to_none(location),
2046
+                    notes: empty_to_none(notes),
2047
+                    reminders: reminders_from_minutes(reminders_minutes_before),
2048
+                    recurrence: None,
2049
+                }
2050
+            }
2051
+            Self::AllDay {
2052
+                title,
2053
+                date,
2054
+                location,
2055
+                notes,
2056
+                reminders_minutes_before,
2057
+            } => CreateEventDraft {
2058
+                title,
2059
+                timing: CreateEventTiming::AllDay {
2060
+                    date: parse_local_date(&date, path)?,
2061
+                },
2062
+                location: empty_to_none(location),
2063
+                notes: empty_to_none(notes),
2064
+                reminders: reminders_from_minutes(reminders_minutes_before),
2065
+                recurrence: None,
2066
+            },
2067
+        })
2068
+    }
2069
+}
2070
+
2071
+fn reminders_from_minutes(minutes: Vec<u16>) -> Vec<Reminder> {
2072
+    let mut reminders = minutes
2073
+        .into_iter()
2074
+        .map(Reminder::minutes_before)
2075
+        .collect::<Vec<_>>();
2076
+    reminders.sort();
2077
+    reminders.dedup();
2078
+    reminders
2079
+}
2080
+
2081
+fn empty_to_none(value: Option<String>) -> Option<String> {
2082
+    value.and_then(|value| {
2083
+        let trimmed = value.trim();
2084
+        if trimmed.is_empty() {
2085
+            None
2086
+        } else {
2087
+            Some(trimmed.to_string())
2088
+        }
2089
+    })
2090
+}
2091
+
2092
+fn weekday_name(weekday: Weekday) -> String {
2093
+    match weekday {
2094
+        Weekday::Sunday => "sunday",
2095
+        Weekday::Monday => "monday",
2096
+        Weekday::Tuesday => "tuesday",
2097
+        Weekday::Wednesday => "wednesday",
2098
+        Weekday::Thursday => "thursday",
2099
+        Weekday::Friday => "friday",
2100
+        Weekday::Saturday => "saturday",
2101
+    }
2102
+    .to_string()
2103
+}
2104
+
2105
+fn parse_weekday_record(value: &str, path: &Path) -> Result<Weekday, LocalEventStoreError> {
2106
+    match value {
2107
+        "sunday" => Ok(Weekday::Sunday),
2108
+        "monday" => Ok(Weekday::Monday),
2109
+        "tuesday" => Ok(Weekday::Tuesday),
2110
+        "wednesday" => Ok(Weekday::Wednesday),
2111
+        "thursday" => Ok(Weekday::Thursday),
2112
+        "friday" => Ok(Weekday::Friday),
2113
+        "saturday" => Ok(Weekday::Saturday),
2114
+        _ => Err(LocalEventStoreError::Parse {
2115
+            path: path.to_path_buf(),
2116
+            reason: format!("invalid weekday '{value}'"),
2117
+        }),
2118
+    }
2119
+}
2120
+
2121
+fn parse_month_record(value: u8, path: &Path) -> Result<Month, LocalEventStoreError> {
2122
+    Month::try_from(value).map_err(|_| LocalEventStoreError::Parse {
2123
+        path: path.to_path_buf(),
2124
+        reason: format!("invalid month '{value}'"),
2125
+    })
2126
+}
2127
+
2128
+fn parse_local_date(value: &str, path: &Path) -> Result<CalendarDate, LocalEventStoreError> {
2129
+    parse_iso_date(value).ok_or_else(|| LocalEventStoreError::Parse {
2130
+        path: path.to_path_buf(),
2131
+        reason: format!("invalid date '{value}'"),
2132
+    })
2133
+}
2134
+
2135
+fn parse_local_time(value: &str, path: &Path) -> Result<Time, LocalEventStoreError> {
10892136
     parse_hhmm_time(value).ok_or_else(|| LocalEventStoreError::Parse {
10902137
         path: path.to_path_buf(),
10912138
         reason: format!("invalid time '{value}'"),
@@ -1639,6 +2686,212 @@ mod tests {
16392686
         assert!(!range.contains_date(day.add_days(1)));
16402687
     }
16412688
 
2689
+    #[test]
2690
+    fn daily_recurrence_expands_with_interval_and_count() {
2691
+        let start = date_ymd(2026, Month::April, 1);
2692
+        let event = Event::all_day("daily", "Every other day", start, source()).with_recurrence(
2693
+            RecurrenceRule {
2694
+                frequency: RecurrenceFrequency::Daily,
2695
+                interval: 2,
2696
+                end: RecurrenceEnd::Count(3),
2697
+                weekdays: Vec::new(),
2698
+                monthly: None,
2699
+                yearly: None,
2700
+            },
2701
+        );
2702
+        let source = InMemoryAgendaSource::with_events_and_holidays(vec![event], Vec::new());
2703
+        let range = DateRange::new(start, date_ymd(2026, Month::April, 10)).expect("valid range");
2704
+
2705
+        let dates = source
2706
+            .events_intersecting(range)
2707
+            .into_iter()
2708
+            .filter_map(|event| event.timing.date())
2709
+            .collect::<Vec<_>>();
2710
+
2711
+        assert_eq!(dates, [date_ymd(2026, Month::April, 1), date(3), date(5)]);
2712
+    }
2713
+
2714
+    #[test]
2715
+    fn weekly_recurrence_supports_multiple_days_and_interval() {
2716
+        let start = date_ymd(2026, Month::April, 5);
2717
+        let event =
2718
+            Event::all_day("weekly", "Workout", start, source()).with_recurrence(RecurrenceRule {
2719
+                frequency: RecurrenceFrequency::Weekly,
2720
+                interval: 2,
2721
+                end: RecurrenceEnd::Never,
2722
+                weekdays: vec![Weekday::Sunday, Weekday::Tuesday],
2723
+                monthly: None,
2724
+                yearly: None,
2725
+            });
2726
+        let source = InMemoryAgendaSource::with_events_and_holidays(vec![event], Vec::new());
2727
+        let range = DateRange::new(start, date_ymd(2026, Month::April, 23)).expect("valid range");
2728
+
2729
+        let dates = source
2730
+            .events_intersecting(range)
2731
+            .into_iter()
2732
+            .filter_map(|event| event.timing.date())
2733
+            .collect::<Vec<_>>();
2734
+
2735
+        assert_eq!(dates, [date(5), date(7), date(19), date(21)]);
2736
+    }
2737
+
2738
+    #[test]
2739
+    fn monthly_recurrence_skips_invalid_day_of_month_dates() {
2740
+        let start = date_ymd(2026, Month::January, 31);
2741
+        let event = Event::all_day("month-day", "Month end", start, source()).with_recurrence(
2742
+            RecurrenceRule {
2743
+                frequency: RecurrenceFrequency::Monthly,
2744
+                interval: 1,
2745
+                end: RecurrenceEnd::Never,
2746
+                weekdays: Vec::new(),
2747
+                monthly: Some(RecurrenceMonthlyRule::DayOfMonth(31)),
2748
+                yearly: None,
2749
+            },
2750
+        );
2751
+        let source = InMemoryAgendaSource::with_events_and_holidays(vec![event], Vec::new());
2752
+        let range = DateRange::new(start, date_ymd(2026, Month::April, 1)).expect("valid range");
2753
+
2754
+        let dates = source
2755
+            .events_intersecting(range)
2756
+            .into_iter()
2757
+            .filter_map(|event| event.timing.date())
2758
+            .collect::<Vec<_>>();
2759
+
2760
+        assert_eq!(dates, [start, date_ymd(2026, Month::March, 31)]);
2761
+    }
2762
+
2763
+    #[test]
2764
+    fn monthly_recurrence_supports_last_weekday_rules() {
2765
+        let start = date_ymd(2026, Month::April, 30);
2766
+        let event = Event::all_day("last-thursday", "Review", start, source()).with_recurrence(
2767
+            RecurrenceRule {
2768
+                frequency: RecurrenceFrequency::Monthly,
2769
+                interval: 1,
2770
+                end: RecurrenceEnd::Count(3),
2771
+                weekdays: Vec::new(),
2772
+                monthly: Some(RecurrenceMonthlyRule::WeekdayOrdinal {
2773
+                    ordinal: RecurrenceOrdinal::Last,
2774
+                    weekday: Weekday::Thursday,
2775
+                }),
2776
+                yearly: None,
2777
+            },
2778
+        );
2779
+        let source = InMemoryAgendaSource::with_events_and_holidays(vec![event], Vec::new());
2780
+        let range = DateRange::new(start, date_ymd(2026, Month::July, 1)).expect("valid range");
2781
+
2782
+        let dates = source
2783
+            .events_intersecting(range)
2784
+            .into_iter()
2785
+            .filter_map(|event| event.timing.date())
2786
+            .collect::<Vec<_>>();
2787
+
2788
+        assert_eq!(
2789
+            dates,
2790
+            [
2791
+                date_ymd(2026, Month::April, 30),
2792
+                date_ymd(2026, Month::May, 28),
2793
+                date_ymd(2026, Month::June, 25)
2794
+            ]
2795
+        );
2796
+    }
2797
+
2798
+    #[test]
2799
+    fn yearly_recurrence_skips_invalid_dates_and_supports_weekday_ordinal() {
2800
+        let leap_day = date_ymd(2024, Month::February, 29);
2801
+        let leap =
2802
+            Event::all_day("leap", "Leap", leap_day, source()).with_recurrence(RecurrenceRule {
2803
+                frequency: RecurrenceFrequency::Yearly,
2804
+                interval: 1,
2805
+                end: RecurrenceEnd::Never,
2806
+                weekdays: Vec::new(),
2807
+                monthly: None,
2808
+                yearly: Some(RecurrenceYearlyRule::Date {
2809
+                    month: Month::February,
2810
+                    day: 29,
2811
+                }),
2812
+            });
2813
+        let thanksgiving = Event::all_day(
2814
+            "thanksgiving",
2815
+            "Thanksgiving",
2816
+            date_ymd(2026, Month::November, 26),
2817
+            source(),
2818
+        )
2819
+        .with_recurrence(RecurrenceRule {
2820
+            frequency: RecurrenceFrequency::Yearly,
2821
+            interval: 1,
2822
+            end: RecurrenceEnd::Count(2),
2823
+            weekdays: Vec::new(),
2824
+            monthly: None,
2825
+            yearly: Some(RecurrenceYearlyRule::WeekdayOrdinal {
2826
+                month: Month::November,
2827
+                ordinal: RecurrenceOrdinal::Number(4),
2828
+                weekday: Weekday::Thursday,
2829
+            }),
2830
+        });
2831
+        let source =
2832
+            InMemoryAgendaSource::with_events_and_holidays(vec![leap, thanksgiving], Vec::new());
2833
+        let range = DateRange::new(
2834
+            date_ymd(2024, Month::January, 1),
2835
+            date_ymd(2029, Month::January, 1),
2836
+        )
2837
+        .expect("valid range");
2838
+
2839
+        let ids_and_dates = source
2840
+            .events_intersecting(range)
2841
+            .into_iter()
2842
+            .map(|event| (event.id, event.timing.date().expect("all-day date")))
2843
+            .collect::<Vec<_>>();
2844
+
2845
+        assert!(ids_and_dates.contains(&("leap#2024-02-29".to_string(), leap_day)));
2846
+        assert!(ids_and_dates.contains(&(
2847
+            "leap#2028-02-29".to_string(),
2848
+            date_ymd(2028, Month::February, 29)
2849
+        )));
2850
+        assert!(
2851
+            !ids_and_dates
2852
+                .iter()
2853
+                .any(|(_, date)| *date == date_ymd(2025, Month::February, 28))
2854
+        );
2855
+        assert!(ids_and_dates.contains(&(
2856
+            "thanksgiving#2026-11-26".to_string(),
2857
+            date_ymd(2026, Month::November, 26)
2858
+        )));
2859
+        assert!(ids_and_dates.contains(&(
2860
+            "thanksgiving#2027-11-25".to_string(),
2861
+            date_ymd(2027, Month::November, 25)
2862
+        )));
2863
+    }
2864
+
2865
+    #[test]
2866
+    fn recurring_cross_midnight_events_intersect_each_visible_day() {
2867
+        let start = date(23);
2868
+        let event = Event::timed(
2869
+            "late",
2870
+            "Late shift",
2871
+            at(start, 23, 0),
2872
+            at(start.add_days(1), 1, 0),
2873
+            source(),
2874
+        )
2875
+        .expect("valid recurring event")
2876
+        .with_recurrence(RecurrenceRule {
2877
+            frequency: RecurrenceFrequency::Daily,
2878
+            interval: 1,
2879
+            end: RecurrenceEnd::Count(2),
2880
+            weekdays: Vec::new(),
2881
+            monthly: None,
2882
+            yearly: None,
2883
+        });
2884
+        let source = InMemoryAgendaSource::with_events_and_holidays(vec![event], Vec::new());
2885
+
2886
+        let agenda = DayAgenda::from_source(start.add_days(1), &source);
2887
+
2888
+        assert_eq!(agenda.timed_events.len(), 2);
2889
+        assert!(agenda.timed_events[0].starts_before_day);
2890
+        assert_eq!(agenda.timed_events[0].visible_end.as_minutes(), 60);
2891
+        assert_eq!(agenda.timed_events[1].visible_start.as_minutes(), 23 * 60);
2892
+        assert!(agenda.timed_events[1].ends_after_day);
2893
+    }
2894
+
16422895
     #[test]
16432896
     fn invalid_ranges_are_rejected() {
16442897
         let day = date(23);
@@ -1680,6 +2933,7 @@ mod tests {
16802933
                 location: Some("War room".to_string()),
16812934
                 notes: Some("Bring notes".to_string()),
16822935
                 reminders: vec![Reminder::minutes_before(10), Reminder::minutes_before(60)],
2936
+                recurrence: None,
16832937
             })
16842938
             .expect("timed event saves");
16852939
         source
@@ -1689,11 +2943,12 @@ mod tests {
16892943
                 location: None,
16902944
                 notes: None,
16912945
                 reminders: vec![Reminder::minutes_before(24 * 60)],
2946
+                recurrence: None,
16922947
             })
16932948
             .expect("all-day event saves");
16942949
 
16952950
         let body = std::fs::read_to_string(&path).expect("event file exists");
1696
-        assert!(body.contains(r#""version": 1"#));
2951
+        assert!(body.contains(r#""version": 2"#));
16972952
         assert!(body.contains(r#""reminders_minutes_before""#));
16982953
 
16992954
         let reloaded = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
@@ -1738,6 +2993,7 @@ mod tests {
17382993
                 location: None,
17392994
                 notes: None,
17402995
                 reminders: Vec::new(),
2996
+                recurrence: None,
17412997
             })
17422998
             .expect("event saves");
17432999
 
@@ -1750,6 +3006,7 @@ mod tests {
17503006
                     location: Some("Room 2".to_string()),
17513007
                     notes: Some("Moved".to_string()),
17523008
                     reminders: vec![Reminder::minutes_before(5)],
3009
+                    recurrence: None,
17533010
                 },
17543011
             )
17553012
             .expect("event updates");
@@ -1770,6 +3027,192 @@ mod tests {
17703027
         assert_eq!(agenda.all_day_events[0].location.as_deref(), Some("Room 2"));
17713028
     }
17723029
 
3030
+    #[test]
3031
+    fn local_event_store_loads_version_one_and_rewrites_version_two_on_save() {
3032
+        let path = temp_events_path("version-one");
3033
+        let _ = std::fs::remove_dir_all(path.parent().expect("path has parent"));
3034
+        std::fs::create_dir_all(path.parent().expect("path has parent"))
3035
+            .expect("parent can be created");
3036
+        std::fs::write(
3037
+            &path,
3038
+            r#"{
3039
+  "version": 1,
3040
+  "events": [
3041
+    {
3042
+      "id": "old",
3043
+      "title": "Old file",
3044
+      "date": "2026-04-23"
3045
+    }
3046
+  ]
3047
+}"#,
3048
+        )
3049
+        .expect("file can be written");
3050
+        let mut source = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3051
+            .expect("version one file loads");
3052
+
3053
+        source
3054
+            .update_event(
3055
+                "old",
3056
+                CreateEventDraft {
3057
+                    title: "Rewritten".to_string(),
3058
+                    timing: CreateEventTiming::AllDay { date: date(23) },
3059
+                    location: None,
3060
+                    notes: None,
3061
+                    reminders: Vec::new(),
3062
+                    recurrence: None,
3063
+                },
3064
+            )
3065
+            .expect("event update saves");
3066
+
3067
+        let body = std::fs::read_to_string(&path).expect("event file exists");
3068
+        let _ = std::fs::remove_dir_all(path.parent().expect("test dir exists"));
3069
+
3070
+        assert!(body.contains(r#""version": 2"#));
3071
+        assert!(body.contains("Rewritten"));
3072
+    }
3073
+
3074
+    #[test]
3075
+    fn local_event_store_saves_recurring_series_and_occurrence_overrides() {
3076
+        let path = temp_events_path("recurring-overrides");
3077
+        let _ = std::fs::remove_dir_all(path.parent().expect("path has parent"));
3078
+        let day = date(23);
3079
+        let mut source = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3080
+            .expect("missing event file is empty");
3081
+        let event = source
3082
+            .create_event(CreateEventDraft {
3083
+                title: "Standup".to_string(),
3084
+                timing: CreateEventTiming::Timed {
3085
+                    start: at(day, 9, 0),
3086
+                    end: at(day, 9, 30),
3087
+                },
3088
+                location: None,
3089
+                notes: None,
3090
+                reminders: Vec::new(),
3091
+                recurrence: Some(RecurrenceRule {
3092
+                    frequency: RecurrenceFrequency::Daily,
3093
+                    interval: 1,
3094
+                    end: RecurrenceEnd::Count(3),
3095
+                    weekdays: Vec::new(),
3096
+                    monthly: None,
3097
+                    yearly: None,
3098
+                }),
3099
+            })
3100
+            .expect("recurring event saves");
3101
+        let anchor = OccurrenceAnchor::Timed {
3102
+            start: at(day.add_days(1), 9, 0),
3103
+        };
3104
+
3105
+        source
3106
+            .update_occurrence(
3107
+                &event.id,
3108
+                anchor,
3109
+                CreateEventDraft {
3110
+                    title: "Moved standup".to_string(),
3111
+                    timing: CreateEventTiming::Timed {
3112
+                        start: at(day.add_days(1), 10, 0),
3113
+                        end: at(day.add_days(1), 10, 30),
3114
+                    },
3115
+                    location: Some("Room 2".to_string()),
3116
+                    notes: None,
3117
+                    reminders: vec![Reminder::minutes_before(5)],
3118
+                    recurrence: None,
3119
+                },
3120
+            )
3121
+            .expect("occurrence override saves");
3122
+
3123
+        let reloaded = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3124
+            .expect("saved file reloads");
3125
+        let agenda = DayAgenda::from_source(day.add_days(1), &reloaded);
3126
+
3127
+        let _ = std::fs::remove_dir_all(path.parent().expect("test dir exists"));
3128
+
3129
+        assert_eq!(agenda.timed_events.len(), 1);
3130
+        let overridden = &agenda.timed_events[0].event;
3131
+        assert_eq!(overridden.title, "Moved standup");
3132
+        assert_eq!(overridden.location.as_deref(), Some("Room 2"));
3133
+        assert_eq!(
3134
+            overridden.occurrence().map(|occurrence| occurrence.anchor),
3135
+            Some(anchor)
3136
+        );
3137
+    }
3138
+
3139
+    #[test]
3140
+    fn series_edits_drop_overrides_whose_anchor_no_longer_generates() {
3141
+        let path = temp_events_path("series-edit-overrides");
3142
+        let _ = std::fs::remove_dir_all(path.parent().expect("path has parent"));
3143
+        let day = date(23);
3144
+        let mut source = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off())
3145
+            .expect("missing event file is empty");
3146
+        let event = source
3147
+            .create_event(CreateEventDraft {
3148
+                title: "Standup".to_string(),
3149
+                timing: CreateEventTiming::Timed {
3150
+                    start: at(day, 9, 0),
3151
+                    end: at(day, 9, 30),
3152
+                },
3153
+                location: None,
3154
+                notes: None,
3155
+                reminders: Vec::new(),
3156
+                recurrence: Some(RecurrenceRule {
3157
+                    frequency: RecurrenceFrequency::Daily,
3158
+                    interval: 1,
3159
+                    end: RecurrenceEnd::Count(3),
3160
+                    weekdays: Vec::new(),
3161
+                    monthly: None,
3162
+                    yearly: None,
3163
+                }),
3164
+            })
3165
+            .expect("recurring event saves");
3166
+        let anchor = OccurrenceAnchor::Timed {
3167
+            start: at(day.add_days(1), 9, 0),
3168
+        };
3169
+        source
3170
+            .update_occurrence(
3171
+                &event.id,
3172
+                anchor,
3173
+                CreateEventDraft {
3174
+                    title: "Override".to_string(),
3175
+                    timing: CreateEventTiming::Timed {
3176
+                        start: at(day.add_days(1), 10, 0),
3177
+                        end: at(day.add_days(1), 10, 30),
3178
+                    },
3179
+                    location: None,
3180
+                    notes: None,
3181
+                    reminders: Vec::new(),
3182
+                    recurrence: None,
3183
+                },
3184
+            )
3185
+            .expect("override saves");
3186
+
3187
+        let updated = source
3188
+            .update_event(
3189
+                &event.id,
3190
+                CreateEventDraft {
3191
+                    title: "Standup".to_string(),
3192
+                    timing: CreateEventTiming::Timed {
3193
+                        start: at(day, 9, 0),
3194
+                        end: at(day, 9, 30),
3195
+                    },
3196
+                    location: None,
3197
+                    notes: None,
3198
+                    reminders: Vec::new(),
3199
+                    recurrence: Some(RecurrenceRule {
3200
+                        frequency: RecurrenceFrequency::Weekly,
3201
+                        interval: 1,
3202
+                        end: RecurrenceEnd::Never,
3203
+                        weekdays: vec![day.weekday()],
3204
+                        monthly: None,
3205
+                        yearly: None,
3206
+                    }),
3207
+                },
3208
+            )
3209
+            .expect("series update saves");
3210
+
3211
+        let _ = std::fs::remove_dir_all(path.parent().expect("test dir exists"));
3212
+
3213
+        assert!(updated.occurrence_overrides.is_empty());
3214
+    }
3215
+
17733216
     #[test]
17743217
     fn local_event_store_rejects_missing_and_non_local_updates() {
17753218
         let day = date(23);
@@ -1789,6 +3232,7 @@ mod tests {
17893232
             location: None,
17903233
             notes: None,
17913234
             reminders: Vec::new(),
3235
+            recurrence: None,
17923236
         };
17933237
 
17943238
         assert!(matches!(
src/app.rsmodified
@@ -6,7 +6,8 @@ use time::{Month, Time, Weekday};
66
 use crate::{
77
     agenda::{
88
         AgendaSource, CreateEventDraft, CreateEventTiming, DayAgenda, Event, EventDateTime,
9
-        EventTiming, Reminder,
9
+        EventTiming, OccurrenceAnchor, RecurrenceEnd, RecurrenceFrequency, RecurrenceMonthlyRule,
10
+        RecurrenceRule, RecurrenceYearlyRule, Reminder, recurrence_ordinal_for_date,
1011
     },
1112
     calendar::{CalendarDate, CalendarMonth, DAYS_PER_WEEK},
1213
 };
@@ -23,6 +24,7 @@ pub struct AppState {
2324
     today: CalendarDate,
2425
     view_mode: ViewMode,
2526
     create_form: Option<CreateEventForm>,
27
+    recurrence_choice: Option<RecurrenceEditChoice>,
2628
     selected_day_event_id: Option<String>,
2729
     should_quit: bool,
2830
 }
@@ -38,6 +40,7 @@ impl AppState {
3840
             today,
3941
             view_mode: ViewMode::Month,
4042
             create_form: None,
43
+            recurrence_choice: None,
4144
             selected_day_event_id: None,
4245
             should_quit: false,
4346
         }
@@ -63,6 +66,10 @@ impl AppState {
6366
         self.create_form.as_ref()
6467
     }
6568
 
69
+    pub const fn recurrence_choice(&self) -> Option<&RecurrenceEditChoice> {
70
+        self.recurrence_choice.as_ref()
71
+    }
72
+
6673
     pub fn selected_day_event_id(&self) -> Option<&str> {
6774
         self.selected_day_event_id.as_deref()
6875
     }
@@ -71,10 +78,18 @@ impl AppState {
7178
         self.create_form.is_some()
7279
     }
7380
 
81
+    pub const fn is_choosing_recurring_edit(&self) -> bool {
82
+        self.recurrence_choice.is_some()
83
+    }
84
+
7485
     pub fn close_create_form(&mut self) {
7586
         self.create_form = None;
7687
     }
7788
 
89
+    pub fn close_recurrence_choice(&mut self) {
90
+        self.recurrence_choice = None;
91
+    }
92
+
7893
     pub fn set_create_form_error(&mut self, message: impl Into<String>) {
7994
         if let Some(form) = &mut self.create_form {
8095
             form.error = Some(message.into());
@@ -89,6 +104,63 @@ impl AppState {
89104
         form.handle_key(key)
90105
     }
91106
 
107
+    pub fn handle_recurrence_choice_key(
108
+        &mut self,
109
+        key: KeyEvent,
110
+        source: &dyn AgendaSource,
111
+    ) -> RecurrenceChoiceInputResult {
112
+        if key.kind == KeyEventKind::Release {
113
+            return RecurrenceChoiceInputResult::Continue;
114
+        }
115
+
116
+        let Some(choice) = &mut self.recurrence_choice else {
117
+            return RecurrenceChoiceInputResult::Continue;
118
+        };
119
+
120
+        match key.code {
121
+            KeyCode::Esc => RecurrenceChoiceInputResult::Cancel,
122
+            KeyCode::Up => {
123
+                choice.select_previous();
124
+                RecurrenceChoiceInputResult::Continue
125
+            }
126
+            KeyCode::Down => {
127
+                choice.select_next();
128
+                RecurrenceChoiceInputResult::Continue
129
+            }
130
+            KeyCode::Enter => match choice.selected_action() {
131
+                RecurrenceEditChoiceAction::ThisOccurrence => {
132
+                    let series_id = choice.series_id.clone();
133
+                    let anchor = choice.anchor;
134
+                    self.recurrence_choice = None;
135
+                    if let Some(event) = selectable_day_events(self.selected_date, source)
136
+                        .into_iter()
137
+                        .find(|event| {
138
+                            event
139
+                                .occurrence()
140
+                                .map(|occurrence| {
141
+                                    occurrence.series_id == series_id && occurrence.anchor == anchor
142
+                                })
143
+                                .unwrap_or(false)
144
+                        })
145
+                    {
146
+                        self.create_form = Some(CreateEventForm::edit_occurrence(&event));
147
+                    }
148
+                    RecurrenceChoiceInputResult::Continue
149
+                }
150
+                RecurrenceEditChoiceAction::Series => {
151
+                    let series_id = choice.series_id.clone();
152
+                    self.recurrence_choice = None;
153
+                    if let Some(event) = source.local_event_by_id(&series_id) {
154
+                        self.create_form = Some(CreateEventForm::edit(&event));
155
+                    }
156
+                    RecurrenceChoiceInputResult::Continue
157
+                }
158
+                RecurrenceEditChoiceAction::Cancel => RecurrenceChoiceInputResult::Cancel,
159
+            },
160
+            _ => RecurrenceChoiceInputResult::Continue,
161
+        }
162
+    }
163
+
92164
     pub fn calendar_month(&self) -> CalendarMonth {
93165
         CalendarMonth::from_dates(self.selected_date, self.today)
94166
     }
@@ -142,9 +214,10 @@ impl AppState {
142214
             AppAction::CloseDay => {
143215
                 self.view_mode = ViewMode::Month;
144216
                 self.selected_day_event_id = None;
217
+                self.recurrence_choice = None;
145218
             }
146219
             AppAction::OpenCreate => {
147
-                if self.create_form.is_none() {
220
+                if self.create_form.is_none() && self.recurrence_choice.is_none() {
148221
                     let context = match self.view_mode {
149222
                         ViewMode::Month => CreateEventContext::EditableDate,
150223
                         ViewMode::Day => CreateEventContext::FixedDate,
@@ -225,7 +298,14 @@ impl AppState {
225298
             .into_iter()
226299
             .find(|event| event.id == selected_id)
227300
         {
228
-            self.create_form = Some(CreateEventForm::edit(&event));
301
+            if let Some(occurrence) = event.occurrence() {
302
+                self.recurrence_choice = Some(RecurrenceEditChoice::new(
303
+                    occurrence.series_id.clone(),
304
+                    occurrence.anchor,
305
+                ));
306
+            } else {
307
+                self.create_form = Some(CreateEventForm::edit(&event));
308
+            }
229309
         }
230310
     }
231311
 
@@ -264,7 +344,92 @@ pub enum CreateEventContext {
264344
 #[derive(Debug, Clone, PartialEq, Eq)]
265345
 pub enum EventFormMode {
266346
     Create,
267
-    Edit { event_id: String },
347
+    Edit {
348
+        event_id: String,
349
+    },
350
+    EditOccurrence {
351
+        series_id: String,
352
+        anchor: OccurrenceAnchor,
353
+    },
354
+}
355
+
356
+#[derive(Debug, Clone, PartialEq, Eq)]
357
+pub struct RecurrenceEditChoice {
358
+    series_id: String,
359
+    anchor: OccurrenceAnchor,
360
+    selected: usize,
361
+}
362
+
363
+impl RecurrenceEditChoice {
364
+    const OPTIONS: [RecurrenceEditChoiceAction; 3] = [
365
+        RecurrenceEditChoiceAction::ThisOccurrence,
366
+        RecurrenceEditChoiceAction::Series,
367
+        RecurrenceEditChoiceAction::Cancel,
368
+    ];
369
+
370
+    fn new(series_id: String, anchor: OccurrenceAnchor) -> Self {
371
+        Self {
372
+            series_id,
373
+            anchor,
374
+            selected: 0,
375
+        }
376
+    }
377
+
378
+    pub fn rows(&self) -> Vec<RecurrenceEditChoiceRow> {
379
+        Self::OPTIONS
380
+            .iter()
381
+            .enumerate()
382
+            .map(|(index, action)| RecurrenceEditChoiceRow {
383
+                label: action.label(),
384
+                selected: index == self.selected,
385
+            })
386
+            .collect()
387
+    }
388
+
389
+    fn selected_action(&self) -> RecurrenceEditChoiceAction {
390
+        Self::OPTIONS[self.selected]
391
+    }
392
+
393
+    fn select_next(&mut self) {
394
+        self.selected = (self.selected + 1) % Self::OPTIONS.len();
395
+    }
396
+
397
+    fn select_previous(&mut self) {
398
+        self.selected = if self.selected == 0 {
399
+            Self::OPTIONS.len() - 1
400
+        } else {
401
+            self.selected - 1
402
+        };
403
+    }
404
+}
405
+
406
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
407
+pub struct RecurrenceEditChoiceRow {
408
+    pub label: &'static str,
409
+    pub selected: bool,
410
+}
411
+
412
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
413
+enum RecurrenceEditChoiceAction {
414
+    ThisOccurrence,
415
+    Series,
416
+    Cancel,
417
+}
418
+
419
+impl RecurrenceEditChoiceAction {
420
+    const fn label(self) -> &'static str {
421
+        match self {
422
+            Self::ThisOccurrence => "Edit this occurrence",
423
+            Self::Series => "Edit series",
424
+            Self::Cancel => "Cancel",
425
+        }
426
+    }
427
+}
428
+
429
+#[derive(Debug, Clone, PartialEq, Eq)]
430
+pub enum RecurrenceChoiceInputResult {
431
+    Continue,
432
+    Cancel,
268433
 }
269434
 
270435
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -281,6 +446,14 @@ pub struct CreateEventForm {
281446
     location: String,
282447
     notes: String,
283448
     reminders: [bool; REMINDER_PRESETS.len()],
449
+    repeat: RepeatFrequency,
450
+    recurrence_interval: String,
451
+    weekly_days: [bool; DAYS_PER_WEEK],
452
+    monthly_mode: RecurrenceMonthlyFormMode,
453
+    yearly_mode: RecurrenceYearlyFormMode,
454
+    recurrence_end: RecurrenceEndFormMode,
455
+    recurrence_until_date: String,
456
+    recurrence_count: String,
284457
     focused: usize,
285458
     error: Option<String>,
286459
 }
@@ -300,6 +473,14 @@ impl CreateEventForm {
300473
             location: String::new(),
301474
             notes: String::new(),
302475
             reminders: [false; REMINDER_PRESETS.len()],
476
+            repeat: RepeatFrequency::None,
477
+            recurrence_interval: "1".to_string(),
478
+            weekly_days: weekly_days_for(selected_date.weekday()),
479
+            monthly_mode: RecurrenceMonthlyFormMode::DayOfMonth,
480
+            yearly_mode: RecurrenceYearlyFormMode::Date,
481
+            recurrence_end: RecurrenceEndFormMode::Never,
482
+            recurrence_until_date: selected_date.to_string(),
483
+            recurrence_count: "10".to_string(),
303484
             focused: 0,
304485
             error: None,
305486
         }
@@ -335,7 +516,7 @@ impl CreateEventForm {
335516
             }
336517
         }
337518
 
338
-        Self {
519
+        let mut form = Self {
339520
             mode: EventFormMode::Edit {
340521
                 event_id: event.id.clone(),
341522
             },
@@ -350,9 +531,32 @@ impl CreateEventForm {
350531
             location: event.location.clone().unwrap_or_default(),
351532
             notes: event.notes.clone().unwrap_or_default(),
352533
             reminders,
534
+            repeat: RepeatFrequency::None,
535
+            recurrence_interval: "1".to_string(),
536
+            weekly_days: weekly_days_for(selected_date.weekday()),
537
+            monthly_mode: RecurrenceMonthlyFormMode::DayOfMonth,
538
+            yearly_mode: RecurrenceYearlyFormMode::Date,
539
+            recurrence_end: RecurrenceEndFormMode::Never,
540
+            recurrence_until_date: selected_date.to_string(),
541
+            recurrence_count: "10".to_string(),
353542
             focused: 0,
354543
             error: None,
355
-        }
544
+        };
545
+        form.load_recurrence(event.recurrence.as_ref());
546
+        form
547
+    }
548
+
549
+    pub fn edit_occurrence(event: &Event) -> Self {
550
+        let Some(occurrence) = event.occurrence() else {
551
+            return Self::edit(event);
552
+        };
553
+        let mut form = Self::edit(event);
554
+        form.mode = EventFormMode::EditOccurrence {
555
+            series_id: occurrence.series_id.clone(),
556
+            anchor: occurrence.anchor,
557
+        };
558
+        form.repeat = RepeatFrequency::None;
559
+        form
356560
     }
357561
 
358562
     pub fn mode(&self) -> &EventFormMode {
@@ -362,7 +566,7 @@ impl CreateEventForm {
362566
     pub fn heading(&self) -> &'static str {
363567
         match &self.mode {
364568
             EventFormMode::Create => "Create",
365
-            EventFormMode::Edit { .. } => "Edit",
569
+            EventFormMode::Edit { .. } | EventFormMode::EditOccurrence { .. } => "Edit",
366570
         }
367571
     }
368572
 
@@ -394,10 +598,10 @@ impl CreateEventForm {
394598
 
395599
         if ctrl_s(key) {
396600
             return match self.submit() {
397
-                Ok(draft) => CreateEventInputResult::Submit(EventFormSubmission {
601
+                Ok(draft) => CreateEventInputResult::Submit(Box::new(EventFormSubmission {
398602
                     mode: self.mode.clone(),
399603
                     draft,
400
-                }),
604
+                })),
401605
                 Err(err) => {
402606
                     self.error = Some(err.to_string());
403607
                     CreateEventInputResult::Continue
@@ -458,12 +662,14 @@ impl CreateEventForm {
458662
 
459663
         if self.all_day {
460664
             let date = self.start_date()?;
665
+            let recurrence = self.recurrence_rule(date)?;
461666
             return Ok(CreateEventDraft {
462667
                 title,
463668
                 timing: CreateEventTiming::AllDay { date },
464669
                 location,
465670
                 notes,
466671
                 reminders,
672
+                recurrence,
467673
             });
468674
         }
469675
 
@@ -480,6 +686,7 @@ impl CreateEventForm {
480686
         if start >= end {
481687
             return Err(CreateEventFormError::InvalidRange);
482688
         }
689
+        let recurrence = self.recurrence_rule(start_date)?;
483690
 
484691
         Ok(CreateEventDraft {
485692
             title,
@@ -487,6 +694,7 @@ impl CreateEventForm {
487694
             location,
488695
             notes,
489696
             reminders,
697
+            recurrence,
490698
         })
491699
     }
492700
 
@@ -501,6 +709,117 @@ impl CreateEventForm {
501709
         parse_date_field(&self.end_date, "end date")
502710
     }
503711
 
712
+    fn load_recurrence(&mut self, recurrence: Option<&RecurrenceRule>) {
713
+        let Some(recurrence) = recurrence else {
714
+            return;
715
+        };
716
+        self.repeat = RepeatFrequency::from_rule(recurrence.frequency);
717
+        self.recurrence_interval = recurrence.interval().to_string();
718
+        if !recurrence.weekdays.is_empty() {
719
+            self.weekly_days = [false; DAYS_PER_WEEK];
720
+            for weekday in &recurrence.weekdays {
721
+                self.weekly_days[usize::from(weekday.number_days_from_sunday())] = true;
722
+            }
723
+        }
724
+        self.recurrence_end = match recurrence.end {
725
+            RecurrenceEnd::Never => RecurrenceEndFormMode::Never,
726
+            RecurrenceEnd::Until(date) => {
727
+                self.recurrence_until_date = date.to_string();
728
+                RecurrenceEndFormMode::Until
729
+            }
730
+            RecurrenceEnd::Count(count) => {
731
+                self.recurrence_count = count.to_string();
732
+                RecurrenceEndFormMode::Count
733
+            }
734
+        };
735
+        if let Some(monthly) = recurrence.monthly {
736
+            self.monthly_mode = match monthly {
737
+                RecurrenceMonthlyRule::DayOfMonth(_) => RecurrenceMonthlyFormMode::DayOfMonth,
738
+                RecurrenceMonthlyRule::WeekdayOrdinal { .. } => {
739
+                    RecurrenceMonthlyFormMode::WeekdayOrdinal
740
+                }
741
+            };
742
+        }
743
+        if let Some(yearly) = recurrence.yearly {
744
+            self.yearly_mode = match yearly {
745
+                RecurrenceYearlyRule::Date { .. } => RecurrenceYearlyFormMode::Date,
746
+                RecurrenceYearlyRule::WeekdayOrdinal { .. } => {
747
+                    RecurrenceYearlyFormMode::WeekdayOrdinal
748
+                }
749
+            };
750
+        }
751
+    }
752
+
753
+    fn recurrence_rule(
754
+        &self,
755
+        start_date: CalendarDate,
756
+    ) -> Result<Option<RecurrenceRule>, CreateEventFormError> {
757
+        if matches!(self.mode, EventFormMode::EditOccurrence { .. })
758
+            || self.repeat == RepeatFrequency::None
759
+        {
760
+            return Ok(None);
761
+        }
762
+
763
+        let interval = parse_positive_u16(&self.recurrence_interval, "interval")?;
764
+        let end = match self.recurrence_end {
765
+            RecurrenceEndFormMode::Never => RecurrenceEnd::Never,
766
+            RecurrenceEndFormMode::Until => {
767
+                RecurrenceEnd::Until(parse_date_field(&self.recurrence_until_date, "until date")?)
768
+            }
769
+            RecurrenceEndFormMode::Count => {
770
+                RecurrenceEnd::Count(parse_positive_u32(&self.recurrence_count, "count")?)
771
+            }
772
+        };
773
+        let frequency = self.repeat.frequency().expect("repeat is not none");
774
+        let mut rule = RecurrenceRule::new(frequency).with_interval(interval);
775
+        rule.end = end;
776
+
777
+        match self.repeat {
778
+            RepeatFrequency::Weekly => {
779
+                rule.weekdays = self
780
+                    .weekly_days
781
+                    .iter()
782
+                    .enumerate()
783
+                    .filter_map(|(index, enabled)| enabled.then_some(weekday_at(index)))
784
+                    .collect();
785
+                if rule.weekdays.is_empty() {
786
+                    rule.weekdays.push(start_date.weekday());
787
+                }
788
+            }
789
+            RepeatFrequency::Monthly => {
790
+                rule.monthly = Some(match self.monthly_mode {
791
+                    RecurrenceMonthlyFormMode::DayOfMonth => {
792
+                        RecurrenceMonthlyRule::DayOfMonth(start_date.day())
793
+                    }
794
+                    RecurrenceMonthlyFormMode::WeekdayOrdinal => {
795
+                        RecurrenceMonthlyRule::WeekdayOrdinal {
796
+                            ordinal: recurrence_ordinal_for_date(start_date),
797
+                            weekday: start_date.weekday(),
798
+                        }
799
+                    }
800
+                });
801
+            }
802
+            RepeatFrequency::Yearly => {
803
+                rule.yearly = Some(match self.yearly_mode {
804
+                    RecurrenceYearlyFormMode::Date => RecurrenceYearlyRule::Date {
805
+                        month: start_date.month(),
806
+                        day: start_date.day(),
807
+                    },
808
+                    RecurrenceYearlyFormMode::WeekdayOrdinal => {
809
+                        RecurrenceYearlyRule::WeekdayOrdinal {
810
+                            month: start_date.month(),
811
+                            ordinal: recurrence_ordinal_for_date(start_date),
812
+                            weekday: start_date.weekday(),
813
+                        }
814
+                    }
815
+                });
816
+            }
817
+            RepeatFrequency::Daily | RepeatFrequency::None => {}
818
+        }
819
+
820
+        Ok(Some(rule))
821
+    }
822
+
504823
     fn visible_fields(&self) -> Vec<CreateEventField> {
505824
         let mut fields = vec![CreateEventField::Title, CreateEventField::AllDay];
506825
         if self.context == CreateEventContext::EditableDate {
@@ -516,6 +835,26 @@ impl CreateEventForm {
516835
             CreateEventField::Notes,
517836
         ]);
518837
         fields.extend((0..REMINDER_PRESETS.len()).map(CreateEventField::Reminder));
838
+        if !matches!(self.mode, EventFormMode::EditOccurrence { .. }) {
839
+            fields.push(CreateEventField::Repeat);
840
+            if self.repeat != RepeatFrequency::None {
841
+                fields.push(CreateEventField::RecurrenceInterval);
842
+                match self.repeat {
843
+                    RepeatFrequency::Weekly => {
844
+                        fields.extend((0..DAYS_PER_WEEK).map(CreateEventField::WeeklyDay));
845
+                    }
846
+                    RepeatFrequency::Monthly => fields.push(CreateEventField::MonthlyMode),
847
+                    RepeatFrequency::Yearly => fields.push(CreateEventField::YearlyMode),
848
+                    RepeatFrequency::Daily | RepeatFrequency::None => {}
849
+                }
850
+                fields.push(CreateEventField::RecurrenceEnd);
851
+                match self.recurrence_end {
852
+                    RecurrenceEndFormMode::Until => fields.push(CreateEventField::UntilDate),
853
+                    RecurrenceEndFormMode::Count => fields.push(CreateEventField::OccurrenceCount),
854
+                    RecurrenceEndFormMode::Never => {}
855
+                }
856
+            }
857
+        }
519858
         fields
520859
     }
521860
 
@@ -533,6 +872,20 @@ impl CreateEventForm {
533872
                 let preset = REMINDER_PRESETS[index];
534873
                 format!("{} {}", checkbox(self.reminders[index]), preset.label)
535874
             }
875
+            CreateEventField::Repeat => self.repeat.label().to_string(),
876
+            CreateEventField::RecurrenceInterval => self.recurrence_interval.clone(),
877
+            CreateEventField::WeeklyDay(index) => {
878
+                format!(
879
+                    "{} {}",
880
+                    checkbox(self.weekly_days[index]),
881
+                    weekday_short_label(weekday_at(index))
882
+                )
883
+            }
884
+            CreateEventField::MonthlyMode => self.monthly_mode.label().to_string(),
885
+            CreateEventField::YearlyMode => self.yearly_mode.label().to_string(),
886
+            CreateEventField::RecurrenceEnd => self.recurrence_end.label().to_string(),
887
+            CreateEventField::UntilDate => self.recurrence_until_date.clone(),
888
+            CreateEventField::OccurrenceCount => self.recurrence_count.clone(),
536889
         }
537890
     }
538891
 
@@ -561,6 +914,13 @@ impl CreateEventForm {
561914
             CreateEventField::AllDay => self.all_day = !self.all_day,
562915
             CreateEventField::Reminder(index) => self.reminders[index] = !self.reminders[index],
563916
             CreateEventField::Notes => self.notes.push('\n'),
917
+            CreateEventField::Repeat => self.repeat = self.repeat.next(),
918
+            CreateEventField::WeeklyDay(index) => {
919
+                self.weekly_days[index] = !self.weekly_days[index]
920
+            }
921
+            CreateEventField::MonthlyMode => self.monthly_mode = self.monthly_mode.next(),
922
+            CreateEventField::YearlyMode => self.yearly_mode = self.yearly_mode.next(),
923
+            CreateEventField::RecurrenceEnd => self.recurrence_end = self.recurrence_end.next(),
564924
             _ => {}
565925
         }
566926
         self.error = None;
@@ -576,7 +936,16 @@ impl CreateEventForm {
576936
             CreateEventField::EndTime => Some(&mut self.end_time),
577937
             CreateEventField::Location => Some(&mut self.location),
578938
             CreateEventField::Notes => Some(&mut self.notes),
579
-            CreateEventField::AllDay | CreateEventField::Reminder(_) => None,
939
+            CreateEventField::RecurrenceInterval => Some(&mut self.recurrence_interval),
940
+            CreateEventField::UntilDate => Some(&mut self.recurrence_until_date),
941
+            CreateEventField::OccurrenceCount => Some(&mut self.recurrence_count),
942
+            CreateEventField::AllDay
943
+            | CreateEventField::Reminder(_)
944
+            | CreateEventField::Repeat
945
+            | CreateEventField::WeeklyDay(_)
946
+            | CreateEventField::MonthlyMode
947
+            | CreateEventField::YearlyMode
948
+            | CreateEventField::RecurrenceEnd => None,
580949
         };
581950
 
582951
         if let Some(target) = target {
@@ -611,7 +980,7 @@ pub struct EventFormSubmission {
611980
 pub enum CreateEventInputResult {
612981
     Continue,
613982
     Cancel,
614
-    Submit(EventFormSubmission),
983
+    Submit(Box<EventFormSubmission>),
615984
 }
616985
 
617986
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -619,6 +988,7 @@ pub enum CreateEventFormError {
619988
     RequiredField(&'static str),
620989
     InvalidDate { field: &'static str, value: String },
621990
     InvalidTime { field: &'static str, value: String },
991
+    InvalidNumber { field: &'static str, value: String },
622992
     InvalidRange,
623993
 }
624994
 
@@ -628,6 +998,9 @@ impl std::fmt::Display for CreateEventFormError {
628998
             Self::RequiredField(field) => write!(f, "{field} is required"),
629999
             Self::InvalidDate { field, value } => write!(f, "{field} '{value}' must be YYYY-MM-DD"),
6301000
             Self::InvalidTime { field, value } => write!(f, "{field} '{value}' must be HH:MM"),
1001
+            Self::InvalidNumber { field, value } => {
1002
+                write!(f, "{field} '{value}' must be a positive number")
1003
+            }
6311004
             Self::InvalidRange => write!(f, "end must be after start"),
6321005
         }
6331006
     }
@@ -666,6 +1039,125 @@ const REMINDER_PRESETS: [ReminderPreset; 6] = [
6661039
     },
6671040
 ];
6681041
 
1042
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1043
+enum RepeatFrequency {
1044
+    None,
1045
+    Daily,
1046
+    Weekly,
1047
+    Monthly,
1048
+    Yearly,
1049
+}
1050
+
1051
+impl RepeatFrequency {
1052
+    const fn label(self) -> &'static str {
1053
+        match self {
1054
+            Self::None => "None",
1055
+            Self::Daily => "Daily",
1056
+            Self::Weekly => "Weekly",
1057
+            Self::Monthly => "Monthly",
1058
+            Self::Yearly => "Yearly",
1059
+        }
1060
+    }
1061
+
1062
+    const fn next(self) -> Self {
1063
+        match self {
1064
+            Self::None => Self::Daily,
1065
+            Self::Daily => Self::Weekly,
1066
+            Self::Weekly => Self::Monthly,
1067
+            Self::Monthly => Self::Yearly,
1068
+            Self::Yearly => Self::None,
1069
+        }
1070
+    }
1071
+
1072
+    const fn frequency(self) -> Option<RecurrenceFrequency> {
1073
+        match self {
1074
+            Self::None => None,
1075
+            Self::Daily => Some(RecurrenceFrequency::Daily),
1076
+            Self::Weekly => Some(RecurrenceFrequency::Weekly),
1077
+            Self::Monthly => Some(RecurrenceFrequency::Monthly),
1078
+            Self::Yearly => Some(RecurrenceFrequency::Yearly),
1079
+        }
1080
+    }
1081
+
1082
+    const fn from_rule(frequency: RecurrenceFrequency) -> Self {
1083
+        match frequency {
1084
+            RecurrenceFrequency::Daily => Self::Daily,
1085
+            RecurrenceFrequency::Weekly => Self::Weekly,
1086
+            RecurrenceFrequency::Monthly => Self::Monthly,
1087
+            RecurrenceFrequency::Yearly => Self::Yearly,
1088
+        }
1089
+    }
1090
+}
1091
+
1092
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1093
+enum RecurrenceMonthlyFormMode {
1094
+    DayOfMonth,
1095
+    WeekdayOrdinal,
1096
+}
1097
+
1098
+impl RecurrenceMonthlyFormMode {
1099
+    const fn label(self) -> &'static str {
1100
+        match self {
1101
+            Self::DayOfMonth => "Day of month",
1102
+            Self::WeekdayOrdinal => "Nth weekday",
1103
+        }
1104
+    }
1105
+
1106
+    const fn next(self) -> Self {
1107
+        match self {
1108
+            Self::DayOfMonth => Self::WeekdayOrdinal,
1109
+            Self::WeekdayOrdinal => Self::DayOfMonth,
1110
+        }
1111
+    }
1112
+}
1113
+
1114
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1115
+enum RecurrenceYearlyFormMode {
1116
+    Date,
1117
+    WeekdayOrdinal,
1118
+}
1119
+
1120
+impl RecurrenceYearlyFormMode {
1121
+    const fn label(self) -> &'static str {
1122
+        match self {
1123
+            Self::Date => "Date",
1124
+            Self::WeekdayOrdinal => "Nth weekday",
1125
+        }
1126
+    }
1127
+
1128
+    const fn next(self) -> Self {
1129
+        match self {
1130
+            Self::Date => Self::WeekdayOrdinal,
1131
+            Self::WeekdayOrdinal => Self::Date,
1132
+        }
1133
+    }
1134
+}
1135
+
1136
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1137
+enum RecurrenceEndFormMode {
1138
+    Never,
1139
+    Until,
1140
+    Count,
1141
+}
1142
+
1143
+impl RecurrenceEndFormMode {
1144
+    const fn label(self) -> &'static str {
1145
+        match self {
1146
+            Self::Never => "Never",
1147
+            Self::Until => "Until date",
1148
+            Self::Count => "Count",
1149
+        }
1150
+    }
1151
+
1152
+    const fn next(self) -> Self {
1153
+        match self {
1154
+            Self::Never => Self::Until,
1155
+            Self::Until => Self::Count,
1156
+            Self::Count => Self::Never,
1157
+        }
1158
+    }
1159
+}
1160
+
6691161
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
6701162
 enum CreateEventField {
6711163
     Title,
@@ -677,6 +1169,14 @@ enum CreateEventField {
6771169
     Location,
6781170
     Notes,
6791171
     Reminder(usize),
1172
+    Repeat,
1173
+    RecurrenceInterval,
1174
+    WeeklyDay(usize),
1175
+    MonthlyMode,
1176
+    YearlyMode,
1177
+    RecurrenceEnd,
1178
+    UntilDate,
1179
+    OccurrenceCount,
6801180
 }
6811181
 
6821182
 impl CreateEventField {
@@ -691,13 +1191,21 @@ impl CreateEventField {
6911191
             Self::Location => "Location",
6921192
             Self::Notes => "Notes",
6931193
             Self::Reminder(_) => "Reminder",
1194
+            Self::Repeat => "Repeat",
1195
+            Self::RecurrenceInterval => "Interval",
1196
+            Self::WeeklyDay(_) => "Weekly",
1197
+            Self::MonthlyMode => "Monthly",
1198
+            Self::YearlyMode => "Yearly",
1199
+            Self::RecurrenceEnd => "Ends",
1200
+            Self::UntilDate => "Until",
1201
+            Self::OccurrenceCount => "Count",
6941202
         }
6951203
     }
6961204
 
6971205
     const fn kind(self) -> CreateEventFormRowKind {
6981206
         match self {
6991207
             Self::Notes => CreateEventFormRowKind::Multiline,
700
-            Self::AllDay | Self::Reminder(_) => CreateEventFormRowKind::Toggle,
1208
+            Self::AllDay | Self::Reminder(_) | Self::WeeklyDay(_) => CreateEventFormRowKind::Toggle,
7011209
             _ => CreateEventFormRowKind::Text,
7021210
         }
7031211
     }
@@ -707,6 +1215,37 @@ fn checkbox(enabled: bool) -> &'static str {
7071215
     if enabled { "[x]" } else { "[ ]" }
7081216
 }
7091217
 
1218
+fn weekly_days_for(weekday: Weekday) -> [bool; DAYS_PER_WEEK] {
1219
+    let mut days = [false; DAYS_PER_WEEK];
1220
+    days[usize::from(weekday.number_days_from_sunday())] = true;
1221
+    days
1222
+}
1223
+
1224
+fn weekday_at(index: usize) -> Weekday {
1225
+    match index {
1226
+        0 => Weekday::Sunday,
1227
+        1 => Weekday::Monday,
1228
+        2 => Weekday::Tuesday,
1229
+        3 => Weekday::Wednesday,
1230
+        4 => Weekday::Thursday,
1231
+        5 => Weekday::Friday,
1232
+        6 => Weekday::Saturday,
1233
+        _ => unreachable!("weekday index stays in range"),
1234
+    }
1235
+}
1236
+
1237
+fn weekday_short_label(weekday: Weekday) -> &'static str {
1238
+    match weekday {
1239
+        Weekday::Sunday => "Sun",
1240
+        Weekday::Monday => "Mon",
1241
+        Weekday::Tuesday => "Tue",
1242
+        Weekday::Wednesday => "Wed",
1243
+        Weekday::Thursday => "Thu",
1244
+        Weekday::Friday => "Fri",
1245
+        Weekday::Saturday => "Sat",
1246
+    }
1247
+}
1248
+
7101249
 fn normalize_required(value: &str, field: &'static str) -> Result<String, CreateEventFormError> {
7111250
     let trimmed = value.trim();
7121251
     if trimmed.is_empty() {
@@ -721,6 +1260,22 @@ fn normalize_optional(value: &str) -> Option<String> {
7211260
     (!trimmed.is_empty()).then(|| trimmed.to_string())
7221261
 }
7231262
 
1263
+fn parse_positive_u16(value: &str, field: &'static str) -> Result<u16, CreateEventFormError> {
1264
+    let parsed = value.trim().parse::<u16>().ok().filter(|value| *value > 0);
1265
+    parsed.ok_or_else(|| CreateEventFormError::InvalidNumber {
1266
+        field,
1267
+        value: value.to_string(),
1268
+    })
1269
+}
1270
+
1271
+fn parse_positive_u32(value: &str, field: &'static str) -> Result<u32, CreateEventFormError> {
1272
+    let parsed = value.trim().parse::<u32>().ok().filter(|value| *value > 0);
1273
+    parsed.ok_or_else(|| CreateEventFormError::InvalidNumber {
1274
+        field,
1275
+        value: value.to_string(),
1276
+    })
1277
+}
1278
+
7241279
 fn parse_date_field(
7251280
     value: &str,
7261281
     field: &'static str,
@@ -1014,7 +1569,7 @@ mod tests {
10141569
     use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
10151570
     use time::{Month, Time};
10161571
 
1017
-    use crate::agenda::{Holiday, InMemoryAgendaSource, SourceMetadata};
1572
+    use crate::agenda::{Holiday, InMemoryAgendaSource, RecurrenceOrdinal, SourceMetadata};
10181573
 
10191574
     fn date(year: i32, month: Month, day: u8) -> CalendarDate {
10201575
         CalendarDate::from_ymd(year, month, day).expect("valid test date")
@@ -1371,6 +1926,84 @@ mod tests {
13711926
         assert_eq!(form.rows()[0].value, "Standup");
13721927
     }
13731928
 
1929
+    #[test]
1930
+    fn day_view_enter_on_recurring_event_opens_edit_choice() {
1931
+        let day = date(2026, Month::April, 23);
1932
+        let event = local_timed_event("series", "Standup", at(day, 9, 0), at(day, 9, 30))
1933
+            .with_recurrence(RecurrenceRule {
1934
+                frequency: RecurrenceFrequency::Daily,
1935
+                interval: 1,
1936
+                end: RecurrenceEnd::Count(2),
1937
+                weekdays: Vec::new(),
1938
+                monthly: None,
1939
+                yearly: None,
1940
+            });
1941
+        let source = InMemoryAgendaSource::with_events_and_holidays(vec![event], Vec::new());
1942
+        let mut app = AppState::new(day);
1943
+        let mut input = KeyboardInput::default();
1944
+
1945
+        apply_keys_with_source(
1946
+            &mut app,
1947
+            &mut input,
1948
+            &source,
1949
+            [key(KeyCode::Enter), key(KeyCode::Enter)],
1950
+        );
1951
+
1952
+        let choice = app.recurrence_choice().expect("choice modal opens");
1953
+        assert_eq!(choice.rows()[0].label, "Edit this occurrence");
1954
+        assert_eq!(app.selected_day_event_id(), Some("series#2026-04-23T09:00"));
1955
+    }
1956
+
1957
+    #[test]
1958
+    fn recurring_edit_choice_can_open_occurrence_or_series_edit() {
1959
+        let day = date(2026, Month::April, 23);
1960
+        let event = local_timed_event("series", "Standup", at(day, 9, 0), at(day, 9, 30))
1961
+            .with_recurrence(RecurrenceRule {
1962
+                frequency: RecurrenceFrequency::Daily,
1963
+                interval: 1,
1964
+                end: RecurrenceEnd::Count(2),
1965
+                weekdays: Vec::new(),
1966
+                monthly: None,
1967
+                yearly: None,
1968
+            });
1969
+        let source = InMemoryAgendaSource::with_events_and_holidays(vec![event], Vec::new());
1970
+
1971
+        let mut occurrence_app = AppState::new(day);
1972
+        occurrence_app.apply_with_agenda_source(AppAction::OpenDay, &source);
1973
+        occurrence_app.apply_with_agenda_source(AppAction::OpenDay, &source);
1974
+        assert_eq!(
1975
+            occurrence_app.handle_recurrence_choice_key(key(KeyCode::Enter), &source),
1976
+            RecurrenceChoiceInputResult::Continue
1977
+        );
1978
+        assert_eq!(
1979
+            occurrence_app.create_form().expect("form opens").mode(),
1980
+            &EventFormMode::EditOccurrence {
1981
+                series_id: "series".to_string(),
1982
+                anchor: OccurrenceAnchor::Timed {
1983
+                    start: at(day, 9, 0)
1984
+                },
1985
+            }
1986
+        );
1987
+
1988
+        let mut series_app = AppState::new(day);
1989
+        series_app.apply_with_agenda_source(AppAction::OpenDay, &source);
1990
+        series_app.apply_with_agenda_source(AppAction::OpenDay, &source);
1991
+        assert_eq!(
1992
+            series_app.handle_recurrence_choice_key(key(KeyCode::Down), &source),
1993
+            RecurrenceChoiceInputResult::Continue
1994
+        );
1995
+        assert_eq!(
1996
+            series_app.handle_recurrence_choice_key(key(KeyCode::Enter), &source),
1997
+            RecurrenceChoiceInputResult::Continue
1998
+        );
1999
+        assert_eq!(
2000
+            series_app.create_form().expect("series form opens").mode(),
2001
+            &EventFormMode::Edit {
2002
+                event_id: "series".to_string()
2003
+            }
2004
+        );
2005
+    }
2006
+
13742007
     #[test]
13752008
     fn day_view_reconciles_selection_when_event_leaves_day() {
13762009
         let day = date(2026, Month::April, 23);
@@ -1634,6 +2267,68 @@ mod tests {
16342267
         assert_eq!(draft.reminders[0].minutes_before, 24 * 60);
16352268
     }
16362269
 
2270
+    #[test]
2271
+    fn create_form_submits_weekly_recurrence() {
2272
+        let day = date(2026, Month::April, 23);
2273
+        let mut form = CreateEventForm::new(day, CreateEventContext::EditableDate);
2274
+        form.title = "Practice".to_string();
2275
+        form.repeat = RepeatFrequency::Weekly;
2276
+        form.recurrence_interval = "2".to_string();
2277
+        form.weekly_days = [false; DAYS_PER_WEEK];
2278
+        form.weekly_days[usize::from(Weekday::Tuesday.number_days_from_sunday())] = true;
2279
+        form.weekly_days[usize::from(Weekday::Thursday.number_days_from_sunday())] = true;
2280
+        form.recurrence_end = RecurrenceEndFormMode::Count;
2281
+        form.recurrence_count = "4".to_string();
2282
+
2283
+        let draft = form.submit().expect("form submits");
2284
+        let recurrence = draft.recurrence.expect("recurrence submitted");
2285
+
2286
+        assert_eq!(recurrence.frequency, RecurrenceFrequency::Weekly);
2287
+        assert_eq!(recurrence.interval, 2);
2288
+        assert_eq!(recurrence.weekdays, [Weekday::Tuesday, Weekday::Thursday]);
2289
+        assert_eq!(recurrence.end, RecurrenceEnd::Count(4));
2290
+    }
2291
+
2292
+    #[test]
2293
+    fn edit_series_form_preloads_recurrence_and_occurrence_form_hides_it() {
2294
+        let day = date(2026, Month::April, 23);
2295
+        let event = local_timed_event("series", "Standup", at(day, 9, 0), at(day, 9, 30))
2296
+            .with_recurrence(RecurrenceRule {
2297
+                frequency: RecurrenceFrequency::Monthly,
2298
+                interval: 1,
2299
+                end: RecurrenceEnd::Until(day.add_days(60)),
2300
+                weekdays: Vec::new(),
2301
+                monthly: Some(RecurrenceMonthlyRule::WeekdayOrdinal {
2302
+                    ordinal: RecurrenceOrdinal::Last,
2303
+                    weekday: day.weekday(),
2304
+                }),
2305
+                yearly: None,
2306
+            });
2307
+
2308
+        let form = CreateEventForm::edit(&event);
2309
+
2310
+        assert_eq!(form.repeat, RepeatFrequency::Monthly);
2311
+        assert_eq!(form.monthly_mode, RecurrenceMonthlyFormMode::WeekdayOrdinal);
2312
+        assert_eq!(form.recurrence_end, RecurrenceEndFormMode::Until);
2313
+
2314
+        let mut occurrence = event.clone();
2315
+        occurrence.id = "series#2026-04-23T09:00".to_string();
2316
+        occurrence.occurrence = Some(crate::agenda::OccurrenceMetadata {
2317
+            series_id: "series".to_string(),
2318
+            anchor: OccurrenceAnchor::Timed {
2319
+                start: at(day, 9, 0),
2320
+            },
2321
+        });
2322
+        let occurrence_form = CreateEventForm::edit_occurrence(&occurrence);
2323
+
2324
+        assert!(
2325
+            !occurrence_form
2326
+                .rows()
2327
+                .iter()
2328
+                .any(|row| row.label == "Repeat")
2329
+        );
2330
+    }
2331
+
16372332
     #[test]
16382333
     fn select_date_action_can_pick_adjacent_month_cells() {
16392334
         let mut app = AppState::new(date(2026, Month::April, 23));
src/cli.rsmodified
@@ -16,7 +16,10 @@ use time::{Date, OffsetDateTime, format_description};
1616
 
1717
 use crate::{
1818
     agenda::{ConfiguredAgendaSource, HolidayProvider, LocalEventStoreError, default_events_file},
19
-    app::{AppState, CreateEventInputResult, EventFormMode, KeyboardInput, MouseInput},
19
+    app::{
20
+        AppState, CreateEventInputResult, EventFormMode, KeyboardInput, MouseInput,
21
+        RecurrenceChoiceInputResult,
22
+    },
2023
     calendar::CalendarDate,
2124
     tui::{
2225
         AppView, DEFAULT_RENDER_HEIGHT, DEFAULT_RENDER_WIDTH, hit_test_app_date,
@@ -439,7 +442,10 @@ where
439442
             return Ok(());
440443
         }
441444
 
442
-        let event = if !app.is_creating_event() && keyboard.is_waiting_for_digit() {
445
+        let event = if !app.is_creating_event()
446
+            && !app.is_choosing_recurring_edit()
447
+            && keyboard.is_waiting_for_digit()
448
+        {
443449
             if event::poll(DIGIT_JUMP_TIMEOUT)? {
444450
                 event::read()?
445451
             } else {
@@ -453,30 +459,51 @@ where
453459
         match event {
454460
             Event::Key(key) => {
455461
                 mouse.clear();
456
-                if app.is_creating_event() {
462
+                if app.is_choosing_recurring_edit() {
463
+                    match app.handle_recurrence_choice_key(key, &agenda_source) {
464
+                        RecurrenceChoiceInputResult::Continue => {}
465
+                        RecurrenceChoiceInputResult::Cancel => app.close_recurrence_choice(),
466
+                    }
467
+                } else if app.is_creating_event() {
457468
                     match app.handle_create_key(key) {
458469
                         CreateEventInputResult::Continue => {}
459470
                         CreateEventInputResult::Cancel => app.close_create_form(),
460
-                        CreateEventInputResult::Submit(submission) => match submission.mode {
461
-                            EventFormMode::Create => {
462
-                                match agenda_source.create_event(submission.draft) {
463
-                                    Ok(_) => {
464
-                                        app.close_create_form();
465
-                                        app.reconcile_day_event_selection(&agenda_source);
471
+                        CreateEventInputResult::Submit(submission) => {
472
+                            let submission = *submission;
473
+                            match submission.mode {
474
+                                EventFormMode::Create => {
475
+                                    match agenda_source.create_event(submission.draft) {
476
+                                        Ok(_) => {
477
+                                            app.close_create_form();
478
+                                            app.reconcile_day_event_selection(&agenda_source);
479
+                                        }
480
+                                        Err(err) => app.set_create_form_error(err.to_string()),
466481
                                     }
467
-                                    Err(err) => app.set_create_form_error(err.to_string()),
468482
                                 }
469
-                            }
470
-                            EventFormMode::Edit { event_id } => {
471
-                                match agenda_source.update_event(&event_id, submission.draft) {
472
-                                    Ok(_) => {
473
-                                        app.close_create_form();
474
-                                        app.reconcile_day_event_selection(&agenda_source);
483
+                                EventFormMode::Edit { event_id } => {
484
+                                    match agenda_source.update_event(&event_id, submission.draft) {
485
+                                        Ok(_) => {
486
+                                            app.close_create_form();
487
+                                            app.reconcile_day_event_selection(&agenda_source);
488
+                                        }
489
+                                        Err(err) => app.set_create_form_error(err.to_string()),
490
+                                    }
491
+                                }
492
+                                EventFormMode::EditOccurrence { series_id, anchor } => {
493
+                                    match agenda_source.update_occurrence(
494
+                                        &series_id,
495
+                                        anchor,
496
+                                        submission.draft,
497
+                                    ) {
498
+                                        Ok(_) => {
499
+                                            app.close_create_form();
500
+                                            app.reconcile_day_event_selection(&agenda_source);
501
+                                        }
502
+                                        Err(err) => app.set_create_form_error(err.to_string()),
475503
                                     }
476
-                                    Err(err) => app.set_create_form_error(err.to_string()),
477504
                                 }
478505
                             }
479
-                        },
506
+                        }
480507
                     }
481508
                 } else {
482509
                     let action = keyboard.translate(key);
@@ -484,7 +511,7 @@ where
484511
                 }
485512
             }
486513
             Event::Mouse(mouse_event) => {
487
-                if app.is_creating_event() {
514
+                if app.is_creating_event() || app.is_choosing_recurring_edit() {
488515
                     continue;
489516
                 }
490517
                 keyboard.clear();
src/tui.rsmodified
@@ -12,7 +12,7 @@ use crate::{
1212
     agenda::{
1313
         AgendaSource, DayAgenda, DayMinute, EmptyAgendaSource, Event, EventTiming, TimedAgendaEvent,
1414
     },
15
-    app::{AppState, CreateEventForm, CreateEventFormRowKind, ViewMode},
15
+    app::{AppState, CreateEventForm, CreateEventFormRowKind, RecurrenceEditChoice, ViewMode},
1616
     calendar::{
1717
         CalendarCell, CalendarDate, CalendarMonth, CalendarWeek, DAYS_PER_WEEK, MONTH_GRID_WEEKS,
1818
     },
@@ -93,6 +93,9 @@ impl Widget for AppView<'_> {
9393
         if let Some(form) = self.app.create_form() {
9494
             render_create_event_modal(form, area, buf, CreateModalStyles::new());
9595
         }
96
+        if let Some(choice) = self.app.recurrence_choice() {
97
+            render_recurrence_choice_modal(choice, area, buf, CreateModalStyles::new());
98
+        }
9699
     }
97100
 }
98101
 
@@ -903,6 +906,66 @@ fn render_create_event_modal(
903906
     );
904907
 }
905908
 
909
+fn render_recurrence_choice_modal(
910
+    choice: &RecurrenceEditChoice,
911
+    area: Rect,
912
+    buf: &mut Buffer,
913
+    styles: CreateModalStyles,
914
+) {
915
+    if area.width == 0 || area.height == 0 {
916
+        return;
917
+    }
918
+
919
+    let modal = recurrence_choice_modal_area(area);
920
+    fill_rect(buf, modal, styles.panel);
921
+    draw_border(buf, modal, styles.border, BorderCharacters::normal());
922
+
923
+    let content = inset_rect(modal);
924
+    if content.width == 0 || content.height == 0 {
925
+        return;
926
+    }
927
+
928
+    write_centered(
929
+        buf,
930
+        content.y,
931
+        content.x,
932
+        content.width,
933
+        "Edit",
934
+        styles.title,
935
+    );
936
+
937
+    let mut y = content.y.saturating_add(2);
938
+    for row in choice.rows() {
939
+        if y >= content.bottom().saturating_sub(1) {
940
+            break;
941
+        }
942
+        let marker = if row.selected { ">" } else { " " };
943
+        write_padded_left(buf, y, content.x, 1, marker, styles.label);
944
+        write_left(
945
+            buf,
946
+            y,
947
+            content.x.saturating_add(2),
948
+            content.width.saturating_sub(2),
949
+            row.label,
950
+            if row.selected {
951
+                styles.title
952
+            } else {
953
+                styles.value
954
+            },
955
+        );
956
+        y = y.saturating_add(1);
957
+    }
958
+
959
+    write_centered(
960
+        buf,
961
+        content.bottom().saturating_sub(1),
962
+        content.x,
963
+        content.width,
964
+        "Enter select | Esc cancel",
965
+        styles.footer,
966
+    );
967
+}
968
+
906969
 fn create_modal_area(area: Rect, form: &CreateEventForm) -> Rect {
907970
     if area.width < 52 || area.height < 16 {
908971
         return area;
@@ -919,6 +982,21 @@ fn create_modal_area(area: Rect, form: &CreateEventForm) -> Rect {
919982
     )
920983
 }
921984
 
985
+fn recurrence_choice_modal_area(area: Rect) -> Rect {
986
+    if area.width < 36 || area.height < 9 {
987
+        return area;
988
+    }
989
+
990
+    let width = area.width.saturating_sub(4).min(36);
991
+    let height = 9.min(area.height.saturating_sub(4));
992
+    Rect::new(
993
+        area.x + area.width.saturating_sub(width) / 2,
994
+        area.y + area.height.saturating_sub(height) / 2,
995
+        width,
996
+        height,
997
+    )
998
+}
999
+
9221000
 fn desired_create_modal_height(form: &CreateEventForm, modal_width: u16) -> u16 {
9231001
     let content_width = modal_width.saturating_sub(2);
9241002
     let value_width = create_modal_value_width(content_width);
@@ -1812,7 +1890,10 @@ mod tests {
18121890
     use time::{Month, Time};
18131891
 
18141892
     use crate::{
1815
-        agenda::{Event, EventDateTime, Holiday, InMemoryAgendaSource, Reminder, SourceMetadata},
1893
+        agenda::{
1894
+            Event, EventDateTime, Holiday, InMemoryAgendaSource, RecurrenceEnd,
1895
+            RecurrenceFrequency, RecurrenceRule, Reminder, SourceMetadata,
1896
+        },
18161897
         app::AppAction,
18171898
         calendar::CalendarDate,
18181899
     };
@@ -2146,6 +2227,53 @@ mod tests {
21462227
         assert!(rendered.contains("Planning"));
21472228
     }
21482229
 
2230
+    #[test]
2231
+    fn recurring_edit_choice_modal_renders_over_day_view() {
2232
+        let day = date(2026, Month::April, 23);
2233
+        let recurring = local_timed_event("series", "Standup", at(day, 9, 0), at(day, 10, 0))
2234
+            .with_recurrence(RecurrenceRule {
2235
+                frequency: RecurrenceFrequency::Daily,
2236
+                interval: 1,
2237
+                end: RecurrenceEnd::Count(2),
2238
+                weekdays: Vec::new(),
2239
+                monthly: None,
2240
+                yearly: None,
2241
+            });
2242
+        let source = agenda_source(vec![recurring], Vec::new());
2243
+        let mut app = AppState::new(day);
2244
+        app.apply_with_agenda_source(AppAction::OpenDay, &source);
2245
+        app.apply_with_agenda_source(AppAction::OpenDay, &source);
2246
+
2247
+        let rendered = render_app_to_string_with_agenda_source(&app, 84, 26, &source);
2248
+
2249
+        assert!(rendered.contains("Edit this occurrence"));
2250
+        assert!(rendered.contains("Edit series"));
2251
+        assert!(rendered.contains("Enter select"));
2252
+    }
2253
+
2254
+    #[test]
2255
+    fn recurring_instances_render_without_repeat_marker() {
2256
+        let day = date(2026, Month::April, 23);
2257
+        let recurring = local_timed_event("series", "Standup", at(day, 9, 0), at(day, 10, 0))
2258
+            .with_recurrence(RecurrenceRule {
2259
+                frequency: RecurrenceFrequency::Daily,
2260
+                interval: 1,
2261
+                end: RecurrenceEnd::Count(2),
2262
+                weekdays: Vec::new(),
2263
+                monthly: None,
2264
+                yearly: None,
2265
+            });
2266
+        let source = agenda_source(vec![recurring], Vec::new());
2267
+        let mut app = AppState::new(day.add_days(1));
2268
+        app.apply(AppAction::OpenDay);
2269
+
2270
+        let rendered = render_app_to_string_with_agenda_source(&app, 84, 26, &source);
2271
+
2272
+        assert!(rendered.contains("09:00-10:00 Standup"));
2273
+        assert!(!rendered.contains("repeat"));
2274
+        assert!(!rendered.contains("recurring"));
2275
+    }
2276
+
21492277
     #[test]
21502278
     fn create_modal_clears_background_content() {
21512279
         let selected = date(2026, Month::April, 23);