Add local event edit flow
- SHA
eb06be2af61f0259f5e0500416c894984ba75182- Parents
-
e19fcf6 - Tree
71327ba
eb06be2
eb06be2af61f0259f5e0500416c894984ba75182e19fcf6
71327ba| Status | File | + | - |
|---|---|---|---|
| M |
src/agenda.rs
|
125 | 0 |
| M |
src/app.rs
|
446 | 6 |
| M |
src/cli.rs
|
22 | 8 |
| M |
src/tui.rs
|
116 | 4 |
src/agenda.rsmodified@@ -215,6 +215,10 @@ impl Event { | |||
| 215 | self.timing.is_timed() | 215 | self.timing.is_timed() |
| 216 | } | 216 | } |
| 217 | 217 | ||
| 218 | + pub fn is_local(&self) -> bool { | ||
| 219 | + self.source.source_id == "local" | ||
| 220 | + } | ||
| 221 | + | ||
| 218 | pub fn intersects_range(&self, range: DateRange) -> bool { | 222 | pub fn intersects_range(&self, range: DateRange) -> bool { |
| 219 | match self.timing { | 223 | match self.timing { |
| 220 | EventTiming::AllDay { date } => range.contains_date(date), | 224 | EventTiming::AllDay { date } => range.contains_date(date), |
@@ -443,6 +447,35 @@ impl ConfiguredAgendaSource { | |||
| 443 | Ok(event) | 447 | Ok(event) |
| 444 | } | 448 | } |
| 445 | 449 | ||
| 450 | + pub fn update_event( | ||
| 451 | + &mut self, | ||
| 452 | + id: &str, | ||
| 453 | + draft: CreateEventDraft, | ||
| 454 | + ) -> Result<Event, LocalEventStoreError> { | ||
| 455 | + let mut events = self.events.events().to_vec(); | ||
| 456 | + let Some(index) = events.iter().position(|event| event.id == id) else { | ||
| 457 | + return Err(LocalEventStoreError::EventNotFound { id: id.to_string() }); | ||
| 458 | + }; | ||
| 459 | + if !events[index].is_local() { | ||
| 460 | + return Err(LocalEventStoreError::EventNotEditable { id: id.to_string() }); | ||
| 461 | + } | ||
| 462 | + | ||
| 463 | + let event = | ||
| 464 | + draft | ||
| 465 | + .into_event(id.to_string()) | ||
| 466 | + .map_err(|err| LocalEventStoreError::Encode { | ||
| 467 | + path: self.events_file.clone(), | ||
| 468 | + reason: err.to_string(), | ||
| 469 | + })?; | ||
| 470 | + events[index] = event.clone(); | ||
| 471 | + | ||
| 472 | + if let Some(path) = &self.events_file { | ||
| 473 | + write_events_file(path, &events)?; | ||
| 474 | + } | ||
| 475 | + self.events.events = events; | ||
| 476 | + Ok(event) | ||
| 477 | + } | ||
| 478 | + | ||
| 446 | fn next_local_event_id(&self, title: &str) -> String { | 479 | fn next_local_event_id(&self, title: &str) -> String { |
| 447 | let now = SystemTime::now() | 480 | let now = SystemTime::now() |
| 448 | .duration_since(UNIX_EPOCH) | 481 | .duration_since(UNIX_EPOCH) |
@@ -774,6 +807,12 @@ pub enum LocalEventStoreError { | |||
| 774 | path: PathBuf, | 807 | path: PathBuf, |
| 775 | version: u8, | 808 | version: u8, |
| 776 | }, | 809 | }, |
| 810 | + EventNotFound { | ||
| 811 | + id: String, | ||
| 812 | + }, | ||
| 813 | + EventNotEditable { | ||
| 814 | + id: String, | ||
| 815 | + }, | ||
| 777 | Encode { | 816 | Encode { |
| 778 | path: Option<PathBuf>, | 817 | path: Option<PathBuf>, |
| 779 | reason: String, | 818 | reason: String, |
@@ -798,6 +837,8 @@ impl fmt::Display for LocalEventStoreError { | |||
| 798 | "unsupported local events file version {version} in {}", | 837 | "unsupported local events file version {version} in {}", |
| 799 | path.display() | 838 | path.display() |
| 800 | ), | 839 | ), |
| 840 | + Self::EventNotFound { id } => write!(f, "local event '{id}' was not found"), | ||
| 841 | + Self::EventNotEditable { id } => write!(f, "event '{id}' is not editable locally"), | ||
| 801 | Self::Encode { path, reason } => { | 842 | Self::Encode { path, reason } => { |
| 802 | if let Some(path) = path { | 843 | if let Some(path) = path { |
| 803 | write!(f, "failed to encode {}: {reason}", path.display()) | 844 | write!(f, "failed to encode {}: {reason}", path.display()) |
@@ -1680,6 +1721,90 @@ mod tests { | |||
| 1680 | ); | 1721 | ); |
| 1681 | } | 1722 | } |
| 1682 | 1723 | ||
| 1724 | + #[test] | ||
| 1725 | + fn local_event_store_updates_existing_event_id_and_persists() { | ||
| 1726 | + let path = temp_events_path("update"); | ||
| 1727 | + let _ = std::fs::remove_dir_all(path.parent().expect("path has parent")); | ||
| 1728 | + let day = date(23); | ||
| 1729 | + let mut source = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off()) | ||
| 1730 | + .expect("missing event file is empty"); | ||
| 1731 | + let event = source | ||
| 1732 | + .create_event(CreateEventDraft { | ||
| 1733 | + title: "Planning".to_string(), | ||
| 1734 | + timing: CreateEventTiming::Timed { | ||
| 1735 | + start: at(day, 9, 0), | ||
| 1736 | + end: at(day, 10, 0), | ||
| 1737 | + }, | ||
| 1738 | + location: None, | ||
| 1739 | + notes: None, | ||
| 1740 | + reminders: Vec::new(), | ||
| 1741 | + }) | ||
| 1742 | + .expect("event saves"); | ||
| 1743 | + | ||
| 1744 | + let updated = source | ||
| 1745 | + .update_event( | ||
| 1746 | + &event.id, | ||
| 1747 | + CreateEventDraft { | ||
| 1748 | + title: "Updated planning".to_string(), | ||
| 1749 | + timing: CreateEventTiming::AllDay { date: day }, | ||
| 1750 | + location: Some("Room 2".to_string()), | ||
| 1751 | + notes: Some("Moved".to_string()), | ||
| 1752 | + reminders: vec![Reminder::minutes_before(5)], | ||
| 1753 | + }, | ||
| 1754 | + ) | ||
| 1755 | + .expect("event updates"); | ||
| 1756 | + | ||
| 1757 | + assert_eq!(updated.id, event.id); | ||
| 1758 | + assert!(updated.is_local()); | ||
| 1759 | + | ||
| 1760 | + let reloaded = ConfiguredAgendaSource::from_events_file(&path, HolidayProvider::off()) | ||
| 1761 | + .expect("saved file reloads"); | ||
| 1762 | + let agenda = DayAgenda::from_source(day, &reloaded); | ||
| 1763 | + | ||
| 1764 | + let _ = std::fs::remove_dir_all(path.parent().expect("test dir exists")); | ||
| 1765 | + | ||
| 1766 | + assert!(agenda.timed_events.is_empty()); | ||
| 1767 | + assert_eq!(agenda.all_day_events.len(), 1); | ||
| 1768 | + assert_eq!(agenda.all_day_events[0].id, event.id); | ||
| 1769 | + assert_eq!(agenda.all_day_events[0].title, "Updated planning"); | ||
| 1770 | + assert_eq!(agenda.all_day_events[0].location.as_deref(), Some("Room 2")); | ||
| 1771 | + } | ||
| 1772 | + | ||
| 1773 | + #[test] | ||
| 1774 | + fn local_event_store_rejects_missing_and_non_local_updates() { | ||
| 1775 | + let day = date(23); | ||
| 1776 | + let mut source = ConfiguredAgendaSource::new( | ||
| 1777 | + InMemoryAgendaSource::with_events_and_holidays( | ||
| 1778 | + vec![timed("fixture", "Fixture", at(day, 8, 0), at(day, 9, 0))], | ||
| 1779 | + Vec::new(), | ||
| 1780 | + ), | ||
| 1781 | + HolidayProvider::off(), | ||
| 1782 | + ); | ||
| 1783 | + let draft = CreateEventDraft { | ||
| 1784 | + title: "Updated".to_string(), | ||
| 1785 | + timing: CreateEventTiming::Timed { | ||
| 1786 | + start: at(day, 10, 0), | ||
| 1787 | + end: at(day, 11, 0), | ||
| 1788 | + }, | ||
| 1789 | + location: None, | ||
| 1790 | + notes: None, | ||
| 1791 | + reminders: Vec::new(), | ||
| 1792 | + }; | ||
| 1793 | + | ||
| 1794 | + assert!(matches!( | ||
| 1795 | + source | ||
| 1796 | + .update_event("missing", draft.clone()) | ||
| 1797 | + .expect_err("missing event fails"), | ||
| 1798 | + LocalEventStoreError::EventNotFound { .. } | ||
| 1799 | + )); | ||
| 1800 | + assert!(matches!( | ||
| 1801 | + source | ||
| 1802 | + .update_event("fixture", draft) | ||
| 1803 | + .expect_err("fixture event fails"), | ||
| 1804 | + LocalEventStoreError::EventNotEditable { .. } | ||
| 1805 | + )); | ||
| 1806 | + } | ||
| 1807 | + | ||
| 1683 | #[test] | 1808 | #[test] |
| 1684 | fn local_event_store_rejects_malformed_json() { | 1809 | fn local_event_store_rejects_malformed_json() { |
| 1685 | let path = temp_events_path("malformed"); | 1810 | let path = temp_events_path("malformed"); |
src/app.rsmodified@@ -5,7 +5,8 @@ use time::{Month, Time, Weekday}; | |||
| 5 | 5 | ||
| 6 | use crate::{ | 6 | use crate::{ |
| 7 | agenda::{ | 7 | agenda::{ |
| 8 | - AgendaSource, CreateEventDraft, CreateEventTiming, DayAgenda, EventDateTime, Reminder, | 8 | + AgendaSource, CreateEventDraft, CreateEventTiming, DayAgenda, Event, EventDateTime, |
| 9 | + EventTiming, Reminder, | ||
| 9 | }, | 10 | }, |
| 10 | calendar::{CalendarDate, CalendarMonth, DAYS_PER_WEEK}, | 11 | calendar::{CalendarDate, CalendarMonth, DAYS_PER_WEEK}, |
| 11 | }; | 12 | }; |
@@ -22,6 +23,7 @@ pub struct AppState { | |||
| 22 | today: CalendarDate, | 23 | today: CalendarDate, |
| 23 | view_mode: ViewMode, | 24 | view_mode: ViewMode, |
| 24 | create_form: Option<CreateEventForm>, | 25 | create_form: Option<CreateEventForm>, |
| 26 | + selected_day_event_id: Option<String>, | ||
| 25 | should_quit: bool, | 27 | should_quit: bool, |
| 26 | } | 28 | } |
| 27 | 29 | ||
@@ -36,6 +38,7 @@ impl AppState { | |||
| 36 | today, | 38 | today, |
| 37 | view_mode: ViewMode::Month, | 39 | view_mode: ViewMode::Month, |
| 38 | create_form: None, | 40 | create_form: None, |
| 41 | + selected_day_event_id: None, | ||
| 39 | should_quit: false, | 42 | should_quit: false, |
| 40 | } | 43 | } |
| 41 | } | 44 | } |
@@ -60,6 +63,10 @@ impl AppState { | |||
| 60 | self.create_form.as_ref() | 63 | self.create_form.as_ref() |
| 61 | } | 64 | } |
| 62 | 65 | ||
| 66 | + pub fn selected_day_event_id(&self) -> Option<&str> { | ||
| 67 | + self.selected_day_event_id.as_deref() | ||
| 68 | + } | ||
| 69 | + | ||
| 63 | pub const fn is_creating_event(&self) -> bool { | 70 | pub const fn is_creating_event(&self) -> bool { |
| 64 | self.create_form.is_some() | 71 | self.create_form.is_some() |
| 65 | } | 72 | } |
@@ -93,12 +100,49 @@ impl AppState { | |||
| 93 | DayAgenda::from_source(self.selected_date, source) | 100 | DayAgenda::from_source(self.selected_date, source) |
| 94 | } | 101 | } |
| 95 | 102 | ||
| 103 | + pub fn reconcile_day_event_selection(&mut self, source: &dyn AgendaSource) { | ||
| 104 | + if self.view_mode != ViewMode::Day { | ||
| 105 | + self.selected_day_event_id = None; | ||
| 106 | + return; | ||
| 107 | + } | ||
| 108 | + | ||
| 109 | + let events = selectable_day_events(self.selected_date, source); | ||
| 110 | + if let Some(selected_id) = &self.selected_day_event_id | ||
| 111 | + && events.iter().any(|event| &event.id == selected_id) | ||
| 112 | + { | ||
| 113 | + return; | ||
| 114 | + } | ||
| 115 | + | ||
| 116 | + self.selected_day_event_id = events.first().map(|event| event.id.clone()); | ||
| 117 | + } | ||
| 118 | + | ||
| 96 | pub fn apply(&mut self, action: AppAction) { | 119 | pub fn apply(&mut self, action: AppAction) { |
| 120 | + self.apply_resolved(action, None); | ||
| 121 | + } | ||
| 122 | + | ||
| 123 | + pub fn apply_with_agenda_source(&mut self, action: AppAction, source: &dyn AgendaSource) { | ||
| 124 | + self.apply_resolved(action, Some(source)); | ||
| 125 | + } | ||
| 126 | + | ||
| 127 | + fn apply_resolved(&mut self, action: AppAction, source: Option<&dyn AgendaSource>) { | ||
| 97 | match action { | 128 | match action { |
| 98 | AppAction::Noop => {} | 129 | AppAction::Noop => {} |
| 99 | AppAction::Quit => self.should_quit = true, | 130 | AppAction::Quit => self.should_quit = true, |
| 100 | - AppAction::OpenDay => self.view_mode = ViewMode::Day, | 131 | + AppAction::OpenDay if self.view_mode == ViewMode::Day => { |
| 101 | - AppAction::CloseDay => self.view_mode = ViewMode::Month, | 132 | + if let Some(source) = source { |
| 133 | + self.open_selected_event_for_edit(source); | ||
| 134 | + } | ||
| 135 | + } | ||
| 136 | + AppAction::OpenDay => { | ||
| 137 | + self.view_mode = ViewMode::Day; | ||
| 138 | + if let Some(source) = source { | ||
| 139 | + self.reconcile_day_event_selection(source); | ||
| 140 | + } | ||
| 141 | + } | ||
| 142 | + AppAction::CloseDay => { | ||
| 143 | + self.view_mode = ViewMode::Month; | ||
| 144 | + self.selected_day_event_id = None; | ||
| 145 | + } | ||
| 102 | AppAction::OpenCreate => { | 146 | AppAction::OpenCreate => { |
| 103 | if self.create_form.is_none() { | 147 | if self.create_form.is_none() { |
| 104 | let context = match self.view_mode { | 148 | let context = match self.view_mode { |
@@ -115,6 +159,21 @@ impl AppState { | |||
| 115 | if self.view_mode == ViewMode::Day && matches!(days, -1 | 1) => | 159 | if self.view_mode == ViewMode::Day && matches!(days, -1 | 1) => |
| 116 | { | 160 | { |
| 117 | self.selected_date = self.selected_date.add_days(days); | 161 | self.selected_date = self.selected_date.add_days(days); |
| 162 | + if let Some(source) = source { | ||
| 163 | + self.reconcile_day_event_selection(source); | ||
| 164 | + } else { | ||
| 165 | + self.selected_day_event_id = None; | ||
| 166 | + } | ||
| 167 | + } | ||
| 168 | + AppAction::MoveDays(-7) if self.view_mode == ViewMode::Day => { | ||
| 169 | + if let Some(source) = source { | ||
| 170 | + self.move_day_event_selection(source, -1); | ||
| 171 | + } | ||
| 172 | + } | ||
| 173 | + AppAction::MoveDays(7) if self.view_mode == ViewMode::Day => { | ||
| 174 | + if let Some(source) = source { | ||
| 175 | + self.move_day_event_selection(source, 1); | ||
| 176 | + } | ||
| 118 | } | 177 | } |
| 119 | AppAction::SelectDate(date) if self.view_mode == ViewMode::Month => { | 178 | AppAction::SelectDate(date) if self.view_mode == ViewMode::Month => { |
| 120 | self.selected_date = date; | 179 | self.selected_date = date; |
@@ -136,6 +195,40 @@ impl AppState { | |||
| 136 | } | 195 | } |
| 137 | } | 196 | } |
| 138 | 197 | ||
| 198 | + fn move_day_event_selection(&mut self, source: &dyn AgendaSource, delta: i32) { | ||
| 199 | + let events = selectable_day_events(self.selected_date, source); | ||
| 200 | + if events.is_empty() { | ||
| 201 | + self.selected_day_event_id = None; | ||
| 202 | + return; | ||
| 203 | + } | ||
| 204 | + | ||
| 205 | + let current_index = self | ||
| 206 | + .selected_day_event_id | ||
| 207 | + .as_ref() | ||
| 208 | + .and_then(|id| events.iter().position(|event| &event.id == id)) | ||
| 209 | + .unwrap_or(0); | ||
| 210 | + let len = events.len(); | ||
| 211 | + let next_index = if delta < 0 { | ||
| 212 | + (current_index + len - 1) % len | ||
| 213 | + } else { | ||
| 214 | + (current_index + 1) % len | ||
| 215 | + }; | ||
| 216 | + self.selected_day_event_id = Some(events[next_index].id.clone()); | ||
| 217 | + } | ||
| 218 | + | ||
| 219 | + fn open_selected_event_for_edit(&mut self, source: &dyn AgendaSource) { | ||
| 220 | + self.reconcile_day_event_selection(source); | ||
| 221 | + let Some(selected_id) = self.selected_day_event_id.as_deref() else { | ||
| 222 | + return; | ||
| 223 | + }; | ||
| 224 | + if let Some(event) = selectable_day_events(self.selected_date, source) | ||
| 225 | + .into_iter() | ||
| 226 | + .find(|event| event.id == selected_id) | ||
| 227 | + { | ||
| 228 | + self.create_form = Some(CreateEventForm::edit(&event)); | ||
| 229 | + } | ||
| 230 | + } | ||
| 231 | + | ||
| 139 | fn weekday_in_selected_week(&self, weekday: Weekday) -> Option<CalendarDate> { | 232 | fn weekday_in_selected_week(&self, weekday: Weekday) -> Option<CalendarDate> { |
| 140 | let month = self.calendar_month(); | 233 | let month = self.calendar_month(); |
| 141 | let selected = month.selected_cell()?; | 234 | let selected = month.selected_cell()?; |
@@ -168,8 +261,15 @@ pub enum CreateEventContext { | |||
| 168 | FixedDate, | 261 | FixedDate, |
| 169 | } | 262 | } |
| 170 | 263 | ||
| 264 | +#[derive(Debug, Clone, PartialEq, Eq)] | ||
| 265 | +pub enum EventFormMode { | ||
| 266 | + Create, | ||
| 267 | + Edit { event_id: String }, | ||
| 268 | +} | ||
| 269 | + | ||
| 171 | #[derive(Debug, Clone, PartialEq, Eq)] | 270 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 172 | pub struct CreateEventForm { | 271 | pub struct CreateEventForm { |
| 272 | + mode: EventFormMode, | ||
| 173 | context: CreateEventContext, | 273 | context: CreateEventContext, |
| 174 | selected_date: CalendarDate, | 274 | selected_date: CalendarDate, |
| 175 | title: String, | 275 | title: String, |
@@ -188,6 +288,7 @@ pub struct CreateEventForm { | |||
| 188 | impl CreateEventForm { | 288 | impl CreateEventForm { |
| 189 | pub fn new(selected_date: CalendarDate, context: CreateEventContext) -> Self { | 289 | pub fn new(selected_date: CalendarDate, context: CreateEventContext) -> Self { |
| 190 | Self { | 290 | Self { |
| 291 | + mode: EventFormMode::Create, | ||
| 191 | context, | 292 | context, |
| 192 | selected_date, | 293 | selected_date, |
| 193 | title: String::new(), | 294 | title: String::new(), |
@@ -204,6 +305,67 @@ impl CreateEventForm { | |||
| 204 | } | 305 | } |
| 205 | } | 306 | } |
| 206 | 307 | ||
| 308 | + pub fn edit(event: &Event) -> Self { | ||
| 309 | + let (all_day, start_date, start_time, end_date, end_time, selected_date) = | ||
| 310 | + match event.timing { | ||
| 311 | + EventTiming::AllDay { date } => ( | ||
| 312 | + true, | ||
| 313 | + date.to_string(), | ||
| 314 | + "09:00".to_string(), | ||
| 315 | + date.to_string(), | ||
| 316 | + "10:00".to_string(), | ||
| 317 | + date, | ||
| 318 | + ), | ||
| 319 | + EventTiming::Timed { start, end } => ( | ||
| 320 | + false, | ||
| 321 | + start.date.to_string(), | ||
| 322 | + format_time_field(start.time), | ||
| 323 | + end.date.to_string(), | ||
| 324 | + format_time_field(end.time), | ||
| 325 | + start.date, | ||
| 326 | + ), | ||
| 327 | + }; | ||
| 328 | + let mut reminders = [false; REMINDER_PRESETS.len()]; | ||
| 329 | + for reminder in &event.reminders { | ||
| 330 | + if let Some(index) = REMINDER_PRESETS | ||
| 331 | + .iter() | ||
| 332 | + .position(|preset| preset.minutes == reminder.minutes_before) | ||
| 333 | + { | ||
| 334 | + reminders[index] = true; | ||
| 335 | + } | ||
| 336 | + } | ||
| 337 | + | ||
| 338 | + Self { | ||
| 339 | + mode: EventFormMode::Edit { | ||
| 340 | + event_id: event.id.clone(), | ||
| 341 | + }, | ||
| 342 | + context: CreateEventContext::EditableDate, | ||
| 343 | + selected_date, | ||
| 344 | + title: event.title.clone(), | ||
| 345 | + all_day, | ||
| 346 | + start_date, | ||
| 347 | + start_time, | ||
| 348 | + end_date, | ||
| 349 | + end_time, | ||
| 350 | + location: event.location.clone().unwrap_or_default(), | ||
| 351 | + notes: event.notes.clone().unwrap_or_default(), | ||
| 352 | + reminders, | ||
| 353 | + focused: 0, | ||
| 354 | + error: None, | ||
| 355 | + } | ||
| 356 | + } | ||
| 357 | + | ||
| 358 | + pub fn mode(&self) -> &EventFormMode { | ||
| 359 | + &self.mode | ||
| 360 | + } | ||
| 361 | + | ||
| 362 | + pub fn heading(&self) -> &'static str { | ||
| 363 | + match &self.mode { | ||
| 364 | + EventFormMode::Create => "Create", | ||
| 365 | + EventFormMode::Edit { .. } => "Edit", | ||
| 366 | + } | ||
| 367 | + } | ||
| 368 | + | ||
| 207 | pub const fn context(&self) -> CreateEventContext { | 369 | pub const fn context(&self) -> CreateEventContext { |
| 208 | self.context | 370 | self.context |
| 209 | } | 371 | } |
@@ -232,7 +394,10 @@ impl CreateEventForm { | |||
| 232 | 394 | ||
| 233 | if ctrl_s(key) { | 395 | if ctrl_s(key) { |
| 234 | return match self.submit() { | 396 | return match self.submit() { |
| 235 | - Ok(draft) => CreateEventInputResult::Submit(draft), | 397 | + Ok(draft) => CreateEventInputResult::Submit(EventFormSubmission { |
| 398 | + mode: self.mode.clone(), | ||
| 399 | + draft, | ||
| 400 | + }), | ||
| 236 | Err(err) => { | 401 | Err(err) => { |
| 237 | self.error = Some(err.to_string()); | 402 | self.error = Some(err.to_string()); |
| 238 | CreateEventInputResult::Continue | 403 | CreateEventInputResult::Continue |
@@ -436,11 +601,17 @@ pub enum CreateEventFormRowKind { | |||
| 436 | Toggle, | 601 | Toggle, |
| 437 | } | 602 | } |
| 438 | 603 | ||
| 604 | +#[derive(Debug, Clone, PartialEq, Eq)] | ||
| 605 | +pub struct EventFormSubmission { | ||
| 606 | + pub mode: EventFormMode, | ||
| 607 | + pub draft: CreateEventDraft, | ||
| 608 | +} | ||
| 609 | + | ||
| 439 | #[derive(Debug, Clone, PartialEq, Eq)] | 610 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 440 | pub enum CreateEventInputResult { | 611 | pub enum CreateEventInputResult { |
| 441 | Continue, | 612 | Continue, |
| 442 | Cancel, | 613 | Cancel, |
| 443 | - Submit(CreateEventDraft), | 614 | + Submit(EventFormSubmission), |
| 444 | } | 615 | } |
| 445 | 616 | ||
| 446 | #[derive(Debug, Clone, PartialEq, Eq)] | 617 | #[derive(Debug, Clone, PartialEq, Eq)] |
@@ -614,6 +785,25 @@ fn parse_time_field(value: &str, field: &'static str) -> Result<Time, CreateEven | |||
| 614 | }) | 785 | }) |
| 615 | } | 786 | } |
| 616 | 787 | ||
| 788 | +fn format_time_field(time: Time) -> String { | ||
| 789 | + format!("{:02}:{:02}", time.hour(), time.minute()) | ||
| 790 | +} | ||
| 791 | + | ||
| 792 | +fn selectable_day_events(date: CalendarDate, source: &dyn AgendaSource) -> Vec<Event> { | ||
| 793 | + let agenda = DayAgenda::from_source(date, source); | ||
| 794 | + agenda | ||
| 795 | + .all_day_events | ||
| 796 | + .into_iter() | ||
| 797 | + .chain( | ||
| 798 | + agenda | ||
| 799 | + .timed_events | ||
| 800 | + .into_iter() | ||
| 801 | + .map(|agenda_event| agenda_event.event), | ||
| 802 | + ) | ||
| 803 | + .filter(Event::is_local) | ||
| 804 | + .collect() | ||
| 805 | +} | ||
| 806 | + | ||
| 617 | #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] | 807 | #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] |
| 618 | pub struct KeyboardInput { | 808 | pub struct KeyboardInput { |
| 619 | pending: PendingKey, | 809 | pending: PendingKey, |
@@ -822,7 +1012,7 @@ fn ctrl_s(key: KeyEvent) -> bool { | |||
| 822 | mod tests { | 1012 | mod tests { |
| 823 | use super::*; | 1013 | use super::*; |
| 824 | use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind}; | 1014 | use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind}; |
| 825 | - use time::Month; | 1015 | + use time::{Month, Time}; |
| 826 | 1016 | ||
| 827 | use crate::agenda::{Holiday, InMemoryAgendaSource, SourceMetadata}; | 1017 | use crate::agenda::{Holiday, InMemoryAgendaSource, SourceMetadata}; |
| 828 | 1018 | ||
@@ -830,6 +1020,32 @@ mod tests { | |||
| 830 | CalendarDate::from_ymd(year, month, day).expect("valid test date") | 1020 | CalendarDate::from_ymd(year, month, day).expect("valid test date") |
| 831 | } | 1021 | } |
| 832 | 1022 | ||
| 1023 | + fn at(date: CalendarDate, hour: u8, minute: u8) -> EventDateTime { | ||
| 1024 | + EventDateTime::new( | ||
| 1025 | + date, | ||
| 1026 | + Time::from_hms(hour, minute, 0).expect("valid test time"), | ||
| 1027 | + ) | ||
| 1028 | + } | ||
| 1029 | + | ||
| 1030 | + fn local_timed_event(id: &str, title: &str, start: EventDateTime, end: EventDateTime) -> Event { | ||
| 1031 | + Event::timed(id, title, start, end, SourceMetadata::local()) | ||
| 1032 | + .expect("valid local timed event") | ||
| 1033 | + } | ||
| 1034 | + | ||
| 1035 | + fn local_all_day_event(id: &str, title: &str, date: CalendarDate) -> Event { | ||
| 1036 | + Event::all_day(id, title, date, SourceMetadata::local()) | ||
| 1037 | + } | ||
| 1038 | + | ||
| 1039 | + fn fixture_timed_event( | ||
| 1040 | + id: &str, | ||
| 1041 | + title: &str, | ||
| 1042 | + start: EventDateTime, | ||
| 1043 | + end: EventDateTime, | ||
| 1044 | + ) -> Event { | ||
| 1045 | + Event::timed(id, title, start, end, SourceMetadata::fixture()) | ||
| 1046 | + .expect("valid fixture timed event") | ||
| 1047 | + } | ||
| 1048 | + | ||
| 833 | fn key(code: KeyCode) -> KeyEvent { | 1049 | fn key(code: KeyCode) -> KeyEvent { |
| 834 | KeyEvent::new(code, KeyModifiers::empty()) | 1050 | KeyEvent::new(code, KeyModifiers::empty()) |
| 835 | } | 1051 | } |
@@ -866,6 +1082,18 @@ mod tests { | |||
| 866 | } | 1082 | } |
| 867 | } | 1083 | } |
| 868 | 1084 | ||
| 1085 | + fn apply_keys_with_source( | ||
| 1086 | + app: &mut AppState, | ||
| 1087 | + input: &mut KeyboardInput, | ||
| 1088 | + source: &dyn AgendaSource, | ||
| 1089 | + keys: impl IntoIterator<Item = KeyEvent>, | ||
| 1090 | + ) { | ||
| 1091 | + for key in keys { | ||
| 1092 | + let action = input.translate(key); | ||
| 1093 | + app.apply_with_agenda_source(action, source); | ||
| 1094 | + } | ||
| 1095 | + } | ||
| 1096 | + | ||
| 869 | #[test] | 1097 | #[test] |
| 870 | fn arrow_keys_move_within_month() { | 1098 | fn arrow_keys_move_within_month() { |
| 871 | let mut app = AppState::new(date(2026, Month::April, 23)); | 1099 | let mut app = AppState::new(date(2026, Month::April, 23)); |
@@ -1055,6 +1283,152 @@ mod tests { | |||
| 1055 | assert_eq!(app.selected_date(), date(2026, Month::April, 23)); | 1283 | assert_eq!(app.selected_date(), date(2026, Month::April, 23)); |
| 1056 | } | 1284 | } |
| 1057 | 1285 | ||
| 1286 | + #[test] | ||
| 1287 | + fn day_view_selects_first_local_event_and_skips_non_local_items() { | ||
| 1288 | + let day = date(2026, Month::April, 23); | ||
| 1289 | + let source = InMemoryAgendaSource::with_events_and_holidays( | ||
| 1290 | + vec![ | ||
| 1291 | + Event::all_day("fixture-all", "A Fixture", day, SourceMetadata::fixture()), | ||
| 1292 | + local_all_day_event("local-all", "Release", day), | ||
| 1293 | + fixture_timed_event("fixture-time", "Fixture", at(day, 8, 0), at(day, 9, 0)), | ||
| 1294 | + local_timed_event("local-time", "Standup", at(day, 9, 0), at(day, 9, 30)), | ||
| 1295 | + ], | ||
| 1296 | + vec![Holiday::new( | ||
| 1297 | + "holiday", | ||
| 1298 | + "Holiday", | ||
| 1299 | + day, | ||
| 1300 | + SourceMetadata::fixture(), | ||
| 1301 | + )], | ||
| 1302 | + ); | ||
| 1303 | + let mut app = AppState::new(day); | ||
| 1304 | + let mut input = KeyboardInput::default(); | ||
| 1305 | + | ||
| 1306 | + apply_keys_with_source(&mut app, &mut input, &source, [key(KeyCode::Enter)]); | ||
| 1307 | + | ||
| 1308 | + assert_eq!(app.view_mode(), ViewMode::Day); | ||
| 1309 | + assert_eq!(app.selected_day_event_id(), Some("local-all")); | ||
| 1310 | + } | ||
| 1311 | + | ||
| 1312 | + #[test] | ||
| 1313 | + fn day_view_up_and_down_cycle_local_event_selection() { | ||
| 1314 | + let day = date(2026, Month::April, 23); | ||
| 1315 | + let source = InMemoryAgendaSource::with_events_and_holidays( | ||
| 1316 | + vec![ | ||
| 1317 | + local_all_day_event("local-all", "Release", day), | ||
| 1318 | + local_timed_event("local-time", "Standup", at(day, 9, 0), at(day, 9, 30)), | ||
| 1319 | + ], | ||
| 1320 | + Vec::new(), | ||
| 1321 | + ); | ||
| 1322 | + let mut app = AppState::new(day); | ||
| 1323 | + let mut input = KeyboardInput::default(); | ||
| 1324 | + | ||
| 1325 | + apply_keys_with_source( | ||
| 1326 | + &mut app, | ||
| 1327 | + &mut input, | ||
| 1328 | + &source, | ||
| 1329 | + [key(KeyCode::Enter), key(KeyCode::Down)], | ||
| 1330 | + ); | ||
| 1331 | + | ||
| 1332 | + assert_eq!(app.selected_date(), day); | ||
| 1333 | + assert_eq!(app.selected_day_event_id(), Some("local-time")); | ||
| 1334 | + | ||
| 1335 | + apply_keys_with_source(&mut app, &mut input, &source, [key(KeyCode::Down)]); | ||
| 1336 | + assert_eq!(app.selected_day_event_id(), Some("local-all")); | ||
| 1337 | + | ||
| 1338 | + apply_keys_with_source(&mut app, &mut input, &source, [key(KeyCode::Up)]); | ||
| 1339 | + assert_eq!(app.selected_day_event_id(), Some("local-time")); | ||
| 1340 | + } | ||
| 1341 | + | ||
| 1342 | + #[test] | ||
| 1343 | + fn day_view_enter_opens_edit_for_selected_local_event() { | ||
| 1344 | + let day = date(2026, Month::April, 23); | ||
| 1345 | + let source = InMemoryAgendaSource::with_events_and_holidays( | ||
| 1346 | + vec![local_timed_event( | ||
| 1347 | + "local-time", | ||
| 1348 | + "Standup", | ||
| 1349 | + at(day, 9, 0), | ||
| 1350 | + at(day, 9, 30), | ||
| 1351 | + )], | ||
| 1352 | + Vec::new(), | ||
| 1353 | + ); | ||
| 1354 | + let mut app = AppState::new(day); | ||
| 1355 | + let mut input = KeyboardInput::default(); | ||
| 1356 | + | ||
| 1357 | + apply_keys_with_source( | ||
| 1358 | + &mut app, | ||
| 1359 | + &mut input, | ||
| 1360 | + &source, | ||
| 1361 | + [key(KeyCode::Enter), key(KeyCode::Enter)], | ||
| 1362 | + ); | ||
| 1363 | + | ||
| 1364 | + let form = app.create_form().expect("edit form opens"); | ||
| 1365 | + assert_eq!( | ||
| 1366 | + form.mode(), | ||
| 1367 | + &EventFormMode::Edit { | ||
| 1368 | + event_id: "local-time".to_string() | ||
| 1369 | + } | ||
| 1370 | + ); | ||
| 1371 | + assert_eq!(form.rows()[0].value, "Standup"); | ||
| 1372 | + } | ||
| 1373 | + | ||
| 1374 | + #[test] | ||
| 1375 | + fn day_view_reconciles_selection_when_event_leaves_day() { | ||
| 1376 | + let day = date(2026, Month::April, 23); | ||
| 1377 | + let next_day = date(2026, Month::April, 24); | ||
| 1378 | + let source = InMemoryAgendaSource::with_events_and_holidays( | ||
| 1379 | + vec![ | ||
| 1380 | + local_timed_event("move", "Move", at(day, 8, 0), at(day, 9, 0)), | ||
| 1381 | + local_timed_event("stay", "Stay", at(day, 10, 0), at(day, 11, 0)), | ||
| 1382 | + ], | ||
| 1383 | + Vec::new(), | ||
| 1384 | + ); | ||
| 1385 | + let moved_source = InMemoryAgendaSource::with_events_and_holidays( | ||
| 1386 | + vec![ | ||
| 1387 | + local_timed_event("move", "Move", at(next_day, 8, 0), at(next_day, 9, 0)), | ||
| 1388 | + local_timed_event("stay", "Stay", at(day, 10, 0), at(day, 11, 0)), | ||
| 1389 | + ], | ||
| 1390 | + Vec::new(), | ||
| 1391 | + ); | ||
| 1392 | + let mut app = AppState::new(day); | ||
| 1393 | + app.apply_with_agenda_source(AppAction::OpenDay, &source); | ||
| 1394 | + | ||
| 1395 | + assert_eq!(app.selected_day_event_id(), Some("move")); | ||
| 1396 | + | ||
| 1397 | + app.reconcile_day_event_selection(&moved_source); | ||
| 1398 | + | ||
| 1399 | + assert_eq!(app.selected_date(), day); | ||
| 1400 | + assert_eq!(app.selected_day_event_id(), Some("stay")); | ||
| 1401 | + } | ||
| 1402 | + | ||
| 1403 | + #[test] | ||
| 1404 | + fn edit_form_up_and_down_keep_day_event_selection() { | ||
| 1405 | + let day = date(2026, Month::April, 23); | ||
| 1406 | + let source = InMemoryAgendaSource::with_events_and_holidays( | ||
| 1407 | + vec![local_timed_event( | ||
| 1408 | + "local-time", | ||
| 1409 | + "Standup", | ||
| 1410 | + at(day, 9, 0), | ||
| 1411 | + at(day, 9, 30), | ||
| 1412 | + )], | ||
| 1413 | + Vec::new(), | ||
| 1414 | + ); | ||
| 1415 | + let mut app = AppState::new(day); | ||
| 1416 | + let mut input = KeyboardInput::default(); | ||
| 1417 | + apply_keys_with_source( | ||
| 1418 | + &mut app, | ||
| 1419 | + &mut input, | ||
| 1420 | + &source, | ||
| 1421 | + [key(KeyCode::Enter), key(KeyCode::Enter)], | ||
| 1422 | + ); | ||
| 1423 | + | ||
| 1424 | + assert_eq!( | ||
| 1425 | + app.handle_create_key(key(KeyCode::Down)), | ||
| 1426 | + CreateEventInputResult::Continue | ||
| 1427 | + ); | ||
| 1428 | + assert_eq!(app.selected_day_event_id(), Some("local-time")); | ||
| 1429 | + assert!(app.create_form().expect("form stays open").rows()[1].focused); | ||
| 1430 | + } | ||
| 1431 | + | ||
| 1058 | #[test] | 1432 | #[test] |
| 1059 | fn quit_action_marks_app_done() { | 1433 | fn quit_action_marks_app_done() { |
| 1060 | let mut app = AppState::new(date(2026, Month::April, 23)); | 1434 | let mut app = AppState::new(date(2026, Month::April, 23)); |
@@ -1143,6 +1517,72 @@ mod tests { | |||
| 1143 | assert_eq!(form.error(), Some("title is required")); | 1517 | assert_eq!(form.error(), Some("title is required")); |
| 1144 | } | 1518 | } |
| 1145 | 1519 | ||
| 1520 | + #[test] | ||
| 1521 | + fn edit_form_preloads_timed_event_fields() { | ||
| 1522 | + let day = date(2026, Month::April, 23); | ||
| 1523 | + let event = local_timed_event("local-time", "Standup", at(day, 9, 0), at(day, 9, 30)) | ||
| 1524 | + .with_location("Room 1") | ||
| 1525 | + .with_notes("Bring notes") | ||
| 1526 | + .with_reminders(vec![ | ||
| 1527 | + Reminder::minutes_before(10), | ||
| 1528 | + Reminder::minutes_before(60), | ||
| 1529 | + ]); | ||
| 1530 | + | ||
| 1531 | + let form = CreateEventForm::edit(&event); | ||
| 1532 | + | ||
| 1533 | + assert_eq!( | ||
| 1534 | + form.mode(), | ||
| 1535 | + &EventFormMode::Edit { | ||
| 1536 | + event_id: "local-time".to_string() | ||
| 1537 | + } | ||
| 1538 | + ); | ||
| 1539 | + assert_eq!(form.title, "Standup"); | ||
| 1540 | + assert!(!form.all_day); | ||
| 1541 | + assert_eq!(form.start_date, "2026-04-23"); | ||
| 1542 | + assert_eq!(form.start_time, "09:00"); | ||
| 1543 | + assert_eq!(form.end_date, "2026-04-23"); | ||
| 1544 | + assert_eq!(form.end_time, "09:30"); | ||
| 1545 | + assert_eq!(form.location, "Room 1"); | ||
| 1546 | + assert_eq!(form.notes, "Bring notes"); | ||
| 1547 | + assert!(form.reminders[1]); | ||
| 1548 | + assert!(form.reminders[4]); | ||
| 1549 | + } | ||
| 1550 | + | ||
| 1551 | + #[test] | ||
| 1552 | + fn edit_form_preloads_all_day_event_and_can_switch_to_timed() { | ||
| 1553 | + let day = date(2026, Month::April, 23); | ||
| 1554 | + let event = local_all_day_event("local-all", "Release", day); | ||
| 1555 | + let mut form = CreateEventForm::edit(&event); | ||
| 1556 | + | ||
| 1557 | + assert!(form.all_day); | ||
| 1558 | + assert_eq!(form.start_date, "2026-04-23"); | ||
| 1559 | + assert_eq!(form.start_time, "09:00"); | ||
| 1560 | + assert_eq!(form.end_time, "10:00"); | ||
| 1561 | + | ||
| 1562 | + form.all_day = false; | ||
| 1563 | + let draft = form.submit().expect("all-day edit can become timed"); | ||
| 1564 | + | ||
| 1565 | + assert_eq!( | ||
| 1566 | + draft.timing, | ||
| 1567 | + CreateEventTiming::Timed { | ||
| 1568 | + start: EventDateTime::new(day, Time::from_hms(9, 0, 0).expect("valid time")), | ||
| 1569 | + end: EventDateTime::new(day, Time::from_hms(10, 0, 0).expect("valid time")), | ||
| 1570 | + } | ||
| 1571 | + ); | ||
| 1572 | + } | ||
| 1573 | + | ||
| 1574 | + #[test] | ||
| 1575 | + fn edit_form_can_switch_timed_event_to_all_day() { | ||
| 1576 | + let day = date(2026, Month::April, 23); | ||
| 1577 | + let event = local_timed_event("local-time", "Standup", at(day, 9, 0), at(day, 9, 30)); | ||
| 1578 | + let mut form = CreateEventForm::edit(&event); | ||
| 1579 | + | ||
| 1580 | + form.all_day = true; | ||
| 1581 | + let draft = form.submit().expect("timed edit can become all day"); | ||
| 1582 | + | ||
| 1583 | + assert_eq!(draft.timing, CreateEventTiming::AllDay { date: day }); | ||
| 1584 | + } | ||
| 1585 | + | ||
| 1146 | #[test] | 1586 | #[test] |
| 1147 | fn create_form_submits_day_view_cross_midnight_event() { | 1587 | fn create_form_submits_day_view_cross_midnight_event() { |
| 1148 | let day = date(2026, Month::April, 23); | 1588 | let day = date(2026, Month::April, 23); |
src/cli.rsmodified@@ -16,7 +16,7 @@ use time::{Date, OffsetDateTime, format_description}; | |||
| 16 | 16 | ||
| 17 | use crate::{ | 17 | use crate::{ |
| 18 | agenda::{ConfiguredAgendaSource, HolidayProvider, LocalEventStoreError, default_events_file}, | 18 | agenda::{ConfiguredAgendaSource, HolidayProvider, LocalEventStoreError, default_events_file}, |
| 19 | - app::{AppState, CreateEventInputResult, KeyboardInput, MouseInput}, | 19 | + app::{AppState, CreateEventInputResult, EventFormMode, KeyboardInput, MouseInput}, |
| 20 | calendar::CalendarDate, | 20 | calendar::CalendarDate, |
| 21 | tui::{ | 21 | tui::{ |
| 22 | AppView, DEFAULT_RENDER_HEIGHT, DEFAULT_RENDER_WIDTH, hit_test_app_date, | 22 | AppView, DEFAULT_RENDER_HEIGHT, DEFAULT_RENDER_WIDTH, hit_test_app_date, |
@@ -457,16 +457,30 @@ where | |||
| 457 | match app.handle_create_key(key) { | 457 | match app.handle_create_key(key) { |
| 458 | CreateEventInputResult::Continue => {} | 458 | CreateEventInputResult::Continue => {} |
| 459 | CreateEventInputResult::Cancel => app.close_create_form(), | 459 | CreateEventInputResult::Cancel => app.close_create_form(), |
| 460 | - CreateEventInputResult::Submit(draft) => { | 460 | + CreateEventInputResult::Submit(submission) => match submission.mode { |
| 461 | - match agenda_source.create_event(draft) { | 461 | + EventFormMode::Create => { |
| 462 | - Ok(_) => app.close_create_form(), | 462 | + match agenda_source.create_event(submission.draft) { |
| 463 | - Err(err) => app.set_create_form_error(err.to_string()), | 463 | + Ok(_) => { |
| 464 | + app.close_create_form(); | ||
| 465 | + app.reconcile_day_event_selection(&agenda_source); | ||
| 466 | + } | ||
| 467 | + Err(err) => app.set_create_form_error(err.to_string()), | ||
| 468 | + } | ||
| 464 | } | 469 | } |
| 465 | - } | 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); | ||
| 475 | + } | ||
| 476 | + Err(err) => app.set_create_form_error(err.to_string()), | ||
| 477 | + } | ||
| 478 | + } | ||
| 479 | + }, | ||
| 466 | } | 480 | } |
| 467 | } else { | 481 | } else { |
| 468 | let action = keyboard.translate(key); | 482 | let action = keyboard.translate(key); |
| 469 | - app.apply(action); | 483 | + app.apply_with_agenda_source(action, &agenda_source); |
| 470 | } | 484 | } |
| 471 | } | 485 | } |
| 472 | Event::Mouse(mouse_event) => { | 486 | Event::Mouse(mouse_event) => { |
@@ -479,7 +493,7 @@ where | |||
| 479 | let target_date = | 493 | let target_date = |
| 480 | hit_test_app_date(&app, area, mouse_event.column, mouse_event.row); | 494 | hit_test_app_date(&app, area, mouse_event.column, mouse_event.row); |
| 481 | let action = mouse.translate(mouse_event, target_date, app.selected_date()); | 495 | let action = mouse.translate(mouse_event, target_date, app.selected_date()); |
| 482 | - app.apply(action); | 496 | + app.apply_with_agenda_source(action, &agenda_source); |
| 483 | } | 497 | } |
| 484 | Event::Resize(_, _) => { | 498 | Event::Resize(_, _) => { |
| 485 | keyboard.clear(); | 499 | keyboard.clear(); |
src/tui.rsmodified@@ -452,6 +452,7 @@ struct DayViewStyles { | |||
| 452 | timeline_event: Style, | 452 | timeline_event: Style, |
| 453 | holiday: Style, | 453 | holiday: Style, |
| 454 | event: Style, | 454 | event: Style, |
| 455 | + selected_event: Style, | ||
| 455 | } | 456 | } |
| 456 | 457 | ||
| 457 | impl DayViewStyles { | 458 | impl DayViewStyles { |
@@ -467,6 +468,10 @@ impl DayViewStyles { | |||
| 467 | timeline_event: Style::new().fg(Color::White).bg(Color::Blue), | 468 | timeline_event: Style::new().fg(Color::White).bg(Color::Blue), |
| 468 | holiday: Style::new().fg(Color::Yellow), | 469 | holiday: Style::new().fg(Color::Yellow), |
| 469 | event: Style::new().fg(Color::White), | 470 | event: Style::new().fg(Color::White), |
| 471 | + selected_event: Style::new() | ||
| 472 | + .fg(Color::White) | ||
| 473 | + .bg(Color::Blue) | ||
| 474 | + .add_modifier(Modifier::BOLD), | ||
| 470 | } | 475 | } |
| 471 | } | 476 | } |
| 472 | } | 477 | } |
@@ -743,7 +748,13 @@ fn render_day_view( | |||
| 743 | match layout.mode { | 748 | match layout.mode { |
| 744 | DayViewLayoutMode::Split | DayViewLayoutMode::Stacked => { | 749 | DayViewLayoutMode::Split | DayViewLayoutMode::Stacked => { |
| 745 | if let Some(agenda_area) = layout.agenda_area { | 750 | if let Some(agenda_area) = layout.agenda_area { |
| 746 | - render_agenda_panel(&agenda, agenda_area, buf, styles); | 751 | + render_agenda_panel( |
| 752 | + &agenda, | ||
| 753 | + app.selected_day_event_id(), | ||
| 754 | + agenda_area, | ||
| 755 | + buf, | ||
| 756 | + styles, | ||
| 757 | + ); | ||
| 747 | } | 758 | } |
| 748 | 759 | ||
| 749 | if let Some(timeline_area) = layout.timeline_area { | 760 | if let Some(timeline_area) = layout.timeline_area { |
@@ -821,7 +832,7 @@ fn render_create_event_modal( | |||
| 821 | content.y, | 832 | content.y, |
| 822 | content.x, | 833 | content.x, |
| 823 | content.width, | 834 | content.width, |
| 824 | - "Create", | 835 | + form.heading(), |
| 825 | styles.title, | 836 | styles.title, |
| 826 | ); | 837 | ); |
| 827 | let label_width = 12.min(content.width.saturating_sub(1)); | 838 | let label_width = 12.min(content.width.saturating_sub(1)); |
@@ -942,7 +953,13 @@ fn create_modal_value_lines( | |||
| 942 | } | 953 | } |
| 943 | } | 954 | } |
| 944 | 955 | ||
| 945 | -fn render_agenda_panel(agenda: &DayAgenda, area: Rect, buf: &mut Buffer, styles: DayViewStyles) { | 956 | +fn render_agenda_panel( |
| 957 | + agenda: &DayAgenda, | ||
| 958 | + selected_event_id: Option<&str>, | ||
| 959 | + area: Rect, | ||
| 960 | + buf: &mut Buffer, | ||
| 961 | + styles: DayViewStyles, | ||
| 962 | +) { | ||
| 946 | render_panel(area, "Agenda", buf, styles); | 963 | render_panel(area, "Agenda", buf, styles); |
| 947 | 964 | ||
| 948 | let content = inset_rect(area); | 965 | let content = inset_rect(area); |
@@ -1001,6 +1018,7 @@ fn render_agenda_panel(agenda: &DayAgenda, area: Rect, buf: &mut Buffer, styles: | |||
| 1001 | y = render_event_detail_lines( | 1018 | y = render_event_detail_lines( |
| 1002 | event, | 1019 | event, |
| 1003 | &format!("- {}", event.title), | 1020 | &format!("- {}", event.title), |
| 1021 | + selected_event_id == Some(event.id.as_str()), | ||
| 1004 | content, | 1022 | content, |
| 1005 | y, | 1023 | y, |
| 1006 | buf, | 1024 | buf, |
@@ -1041,6 +1059,7 @@ fn render_agenda_panel(agenda: &DayAgenda, area: Rect, buf: &mut Buffer, styles: | |||
| 1041 | y = render_event_detail_lines( | 1059 | y = render_event_detail_lines( |
| 1042 | &agenda_event.event, | 1060 | &agenda_event.event, |
| 1043 | &agenda_event_line(agenda_event), | 1061 | &agenda_event_line(agenda_event), |
| 1062 | + selected_event_id == Some(agenda_event.event.id.as_str()), | ||
| 1044 | content, | 1063 | content, |
| 1045 | y, | 1064 | y, |
| 1046 | buf, | 1065 | buf, |
@@ -1054,12 +1073,23 @@ fn render_agenda_panel(agenda: &DayAgenda, area: Rect, buf: &mut Buffer, styles: | |||
| 1054 | fn render_event_detail_lines( | 1073 | fn render_event_detail_lines( |
| 1055 | event: &Event, | 1074 | event: &Event, |
| 1056 | first_line: &str, | 1075 | first_line: &str, |
| 1076 | + selected: bool, | ||
| 1057 | content: Rect, | 1077 | content: Rect, |
| 1058 | mut y: u16, | 1078 | mut y: u16, |
| 1059 | buf: &mut Buffer, | 1079 | buf: &mut Buffer, |
| 1060 | styles: DayViewStyles, | 1080 | styles: DayViewStyles, |
| 1061 | ) -> u16 { | 1081 | ) -> u16 { |
| 1062 | - write_left(buf, y, content.x, content.width, first_line, styles.event); | 1082 | + let first_line = if selected { |
| 1083 | + format!("> {first_line}") | ||
| 1084 | + } else { | ||
| 1085 | + format!(" {first_line}") | ||
| 1086 | + }; | ||
| 1087 | + let style = if selected { | ||
| 1088 | + styles.selected_event | ||
| 1089 | + } else { | ||
| 1090 | + styles.event | ||
| 1091 | + }; | ||
| 1092 | + write_left(buf, y, content.x, content.width, &first_line, style); | ||
| 1063 | y += 1; | 1093 | y += 1; |
| 1064 | 1094 | ||
| 1065 | if let Some(location) = &event.location { | 1095 | if let Some(location) = &event.location { |
@@ -1819,6 +1849,21 @@ mod tests { | |||
| 1819 | buffer | 1849 | buffer |
| 1820 | } | 1850 | } |
| 1821 | 1851 | ||
| 1852 | + fn render_app_buffer_with_agenda_source<S>( | ||
| 1853 | + app: &AppState, | ||
| 1854 | + width: u16, | ||
| 1855 | + height: u16, | ||
| 1856 | + agenda_source: &S, | ||
| 1857 | + ) -> Buffer | ||
| 1858 | + where | ||
| 1859 | + S: AgendaSource, | ||
| 1860 | + { | ||
| 1861 | + let area = Rect::new(0, 0, width, height); | ||
| 1862 | + let mut buffer = Buffer::empty(area); | ||
| 1863 | + AppView::with_agenda_source(app, agenda_source).render(area, &mut buffer); | ||
| 1864 | + buffer | ||
| 1865 | + } | ||
| 1866 | + | ||
| 1822 | fn buffer_lines(buffer: &Buffer) -> Vec<String> { | 1867 | fn buffer_lines(buffer: &Buffer) -> Vec<String> { |
| 1823 | buffer_to_string(buffer) | 1868 | buffer_to_string(buffer) |
| 1824 | .lines() | 1869 | .lines() |
@@ -1890,6 +1935,10 @@ mod tests { | |||
| 1890 | SourceMetadata::fixture() | 1935 | SourceMetadata::fixture() |
| 1891 | } | 1936 | } |
| 1892 | 1937 | ||
| 1938 | + fn local_source_metadata() -> SourceMetadata { | ||
| 1939 | + SourceMetadata::local() | ||
| 1940 | + } | ||
| 1941 | + | ||
| 1893 | fn at(date: CalendarDate, hour: u8, minute: u8) -> EventDateTime { | 1942 | fn at(date: CalendarDate, hour: u8, minute: u8) -> EventDateTime { |
| 1894 | EventDateTime::new( | 1943 | EventDateTime::new( |
| 1895 | date, | 1944 | date, |
@@ -1901,6 +1950,11 @@ mod tests { | |||
| 1901 | Event::timed(id, title, start, end, source_metadata()).expect("valid test event") | 1950 | Event::timed(id, title, start, end, source_metadata()).expect("valid test event") |
| 1902 | } | 1951 | } |
| 1903 | 1952 | ||
| 1953 | + fn local_timed_event(id: &str, title: &str, start: EventDateTime, end: EventDateTime) -> Event { | ||
| 1954 | + Event::timed(id, title, start, end, local_source_metadata()) | ||
| 1955 | + .expect("valid local timed event") | ||
| 1956 | + } | ||
| 1957 | + | ||
| 1904 | fn agenda_source(events: Vec<Event>, holidays: Vec<Holiday>) -> InMemoryAgendaSource { | 1958 | fn agenda_source(events: Vec<Event>, holidays: Vec<Holiday>) -> InMemoryAgendaSource { |
| 1905 | InMemoryAgendaSource::with_events_and_holidays(events, holidays) | 1959 | InMemoryAgendaSource::with_events_and_holidays(events, holidays) |
| 1906 | } | 1960 | } |
@@ -2070,6 +2124,28 @@ mod tests { | |||
| 2070 | assert!(rendered.contains("Ctrl-S save")); | 2124 | assert!(rendered.contains("Ctrl-S save")); |
| 2071 | } | 2125 | } |
| 2072 | 2126 | ||
| 2127 | + #[test] | ||
| 2128 | + fn edit_modal_uses_edit_title() { | ||
| 2129 | + let day = date(2026, Month::April, 23); | ||
| 2130 | + let source = agenda_source( | ||
| 2131 | + vec![local_timed_event( | ||
| 2132 | + "planning", | ||
| 2133 | + "Planning", | ||
| 2134 | + at(day, 9, 0), | ||
| 2135 | + at(day, 10, 0), | ||
| 2136 | + )], | ||
| 2137 | + Vec::new(), | ||
| 2138 | + ); | ||
| 2139 | + let mut app = AppState::new(day); | ||
| 2140 | + app.apply_with_agenda_source(AppAction::OpenDay, &source); | ||
| 2141 | + app.apply_with_agenda_source(AppAction::OpenDay, &source); | ||
| 2142 | + | ||
| 2143 | + let rendered = render_app_to_string_with_agenda_source(&app, 84, 26, &source); | ||
| 2144 | + | ||
| 2145 | + assert!(rendered.contains("Edit")); | ||
| 2146 | + assert!(rendered.contains("Planning")); | ||
| 2147 | + } | ||
| 2148 | + | ||
| 2073 | #[test] | 2149 | #[test] |
| 2074 | fn create_modal_clears_background_content() { | 2150 | fn create_modal_clears_background_content() { |
| 2075 | let selected = date(2026, Month::April, 23); | 2151 | let selected = date(2026, Month::April, 23); |
@@ -2419,6 +2495,42 @@ mod tests { | |||
| 2419 | assert!(rendered.contains("Bring notes")); | 2495 | assert!(rendered.contains("Bring notes")); |
| 2420 | } | 2496 | } |
| 2421 | 2497 | ||
| 2498 | + #[test] | ||
| 2499 | + fn day_view_highlights_selected_local_event() { | ||
| 2500 | + let day = date(2026, Month::April, 23); | ||
| 2501 | + let source = agenda_source( | ||
| 2502 | + vec![local_timed_event( | ||
| 2503 | + "planning", | ||
| 2504 | + "Planning", | ||
| 2505 | + at(day, 9, 0), | ||
| 2506 | + at(day, 10, 0), | ||
| 2507 | + )], | ||
| 2508 | + Vec::new(), | ||
| 2509 | + ); | ||
| 2510 | + let mut app = AppState::new(day); | ||
| 2511 | + app.apply_with_agenda_source(AppAction::OpenDay, &source); | ||
| 2512 | + | ||
| 2513 | + let buffer = render_app_buffer_with_agenda_source(&app, 84, 18, &source); | ||
| 2514 | + let rendered = buffer_to_string(&buffer); | ||
| 2515 | + let selected_position = rendered | ||
| 2516 | + .lines() | ||
| 2517 | + .enumerate() | ||
| 2518 | + .find_map(|(row, line)| { | ||
| 2519 | + line.find("> 09:00-10:00 Planning") | ||
| 2520 | + .map(|column| (column, row)) | ||
| 2521 | + }) | ||
| 2522 | + .expect("selected event is rendered"); | ||
| 2523 | + let selected_cell = buffer | ||
| 2524 | + .cell(( | ||
| 2525 | + u16::try_from(selected_position.0).expect("column fits"), | ||
| 2526 | + u16::try_from(selected_position.1).expect("row fits"), | ||
| 2527 | + )) | ||
| 2528 | + .expect("selected cell exists"); | ||
| 2529 | + | ||
| 2530 | + assert_eq!(selected_cell.bg, Color::Blue); | ||
| 2531 | + assert!(selected_cell.modifier.contains(Modifier::BOLD)); | ||
| 2532 | + } | ||
| 2533 | + | ||
| 2422 | #[test] | 2534 | #[test] |
| 2423 | fn overlapping_timeline_events_keep_distinct_labels() { | 2535 | fn overlapping_timeline_events_keep_distinct_labels() { |
| 2424 | let day = date(2026, Month::April, 23); | 2536 | let day = date(2026, Month::April, 23); |