From 8a0344d2c4b04118e5491379ab160c717184bbb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 7 Oct 2026 03:54:07 -0400 Subject: [PATCH] =?UTF-8?q?Panics=20=C2=B7=20P30=20Calendar:=20views=20and?= =?UTF-8?q?=20recurrences=20stay=20inside=20jiff's=20range,=20the=20editor?= =?UTF-8?q?=20refuses=20edits=20past=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/calendar/days.rs | 11 ++-- src/calendar/editor.rs | 79 +++++++++++++++++----------- src/calendar/event.rs | 3 ++ src/calendar/mod.rs | 18 +++++++ src/calendar/month.rs | 11 ++-- src/calendar/recurrence.rs | 8 ++- src/calendar/repeat.rs | 96 ++++++++++++++++++++++------------- src/calendar/tests/editing.rs | 54 +++++++++++++++++++- src/calendar/tests/mod.rs | 19 +++++++ src/calendar/year.rs | 18 ++++--- tasks/panics.md | 3 +- 11 files changed, 238 insertions(+), 82 deletions(-) diff --git a/src/calendar/days.rs b/src/calendar/days.rs index eca84fa..c83c032 100644 --- a/src/calendar/days.rs +++ b/src/calendar/days.rs @@ -218,9 +218,14 @@ impl RenderOnce for CalendarDays { Rc::new(move |by, window, cx| { let next = match by { 0 => Self::first(today), - by => shown - .checked_add((i64::from(by) * DAYS as i64).days()) - .expect("days on either side"), + by => match super::stepped( + shown, + (i64::from(by) * DAYS as i64).days(), + "calendar days", + ) { + Some(day) => day, + None => return, + }, }; log::info!("calendar days {id:?}: from {next}"); first.update(cx, |first, cx| { diff --git a/src/calendar/editor.rs b/src/calendar/editor.rs index 39ecf42..86c9b4a 100644 --- a/src/calendar/editor.rs +++ b/src/calendar/editor.rs @@ -63,39 +63,43 @@ impl EventDraft { } } -/// `when` as whole days, or as hours from nine to ten on its first day. -fn toggled(when: When, zone: &TimeZone) -> When { +/// `when` as whole days, or as hours from nine to ten on its first day; none when the zone holds no such hours. +pub(super) fn toggled(when: When, zone: &TimeZone) -> Option { match when { When::Timed { start, end } => { let first = start.to_zoned(zone.clone()).date(); let last = end.to_zoned(zone.clone()).date().max(first); - When::AllDay { first, last } + Some(When::AllDay { first, last }) } When::AllDay { first, .. } => { - let at = |hour| { - first - .at(hour, 0, 0, 0) - .to_zoned(zone.clone()) - .expect("the zone holds the hour") - .timestamp() + let at = |hour| match first.at(hour, 0, 0, 0).to_zoned(zone.clone()) { + Ok(at) => Some(at.timestamp()), + Err(error) => { + log::error!("event editor: {first} at {hour}:00 has no moment: {error}"); + None + } }; - When::Timed { - start: at(9), - end: at(10), - } + Some(When::Timed { + start: at(9)?, + end: at(10)?, + }) } } } -/// `when` with its start moved to `start`, its length kept. -fn moved(when: When, start: Timestamp) -> When { +/// `when` with its start moved to `start`, its length kept; none for an all-day event or an end past what jiff holds. +pub(super) fn moved(when: When, start: Timestamp) -> Option { let When::Timed { start: was, end } = when else { - panic!("only timed events move by the hour"); + log::error!("event editor: an all-day event does not move by the hour"); + return None; }; let length: SignedDuration = end.duration_since(was); - When::Timed { - start, - end: start + length, + match start.checked_add(length) { + Ok(end) => Some(When::Timed { start, end }), + Err(error) => { + log::error!("event editor: no end {length} after {start}: {error}"); + None + } } } @@ -244,17 +248,23 @@ impl RenderOnce for EventEditor { let civil = |at: Timestamp| at.to_zoned(zone.clone()).datetime(); let exact = { let zone = zone.clone(); - move |at: DateTime| { - at.to_zoned(zone.clone()) - .expect("the zone holds the time") - .timestamp() + move |at: DateTime| match at.to_zoned(zone.clone()) { + Ok(at) => Some(at.timestamp()), + Err(error) => { + log::error!("event editor: {at} has no moment in its zone: {error}"); + None + } } }; let whole = matches!(draft.when, When::AllDay { .. }); let toggle = { let zone = zone.clone(); let when = draft.when; - edit(Rc::new(move |draft| draft.when = toggled(when, &zone))) + edit(Rc::new(move |draft| { + if let Some(when) = toggled(when, &zone) { + draft.when = when; + } + })) }; let span: Vec = match draft.when { When::Timed { start, end } => { @@ -264,10 +274,14 @@ impl RenderOnce for EventEditor { .on_change({ let edit = edit.clone(); move |picked, _, cx| { - let start = at(picked); - edit(Rc::new(move |draft| draft.when = moved(draft.when, start)))( - cx, - ) + let Some(start) = at(picked) else { + return; + }; + edit(Rc::new(move |draft| { + if let Some(when) = moved(draft.when, start) { + draft.when = when; + } + }))(cx) } }) .into_any_element(), @@ -275,7 +289,9 @@ impl RenderOnce for EventEditor { .on_change({ let edit = edit.clone(); move |picked, _, cx| { - let end = until(picked); + let Some(end) = until(picked) else { + return; + }; edit(Rc::new(move |draft| { if let When::Timed { start, .. } = draft.when { draft.when = When::Timed { start, end }; @@ -292,8 +308,11 @@ impl RenderOnce for EventEditor { let edit = edit.clone(); move |day, _, cx| { let length = last - first; + let Ok(last) = day.checked_add(length) else { + log::error!("event editor: no day {length} after {day}"); + return; + }; edit(Rc::new(move |draft| { - let last = day.checked_add(length).expect("a day ahead"); draft.when = When::AllDay { first: day, last }; }))(cx) } diff --git a/src/calendar/event.rs b/src/calendar/event.rs index 7bdeed2..507758b 100644 --- a/src/calendar/event.rs +++ b/src/calendar/event.rs @@ -73,6 +73,9 @@ impl Event { When::AllDay { first, last } => (first, last), When::Timed { start, end } => { let first = start.to_zoned(zone.clone()).date(); + if end <= start { + return (first, first); + } let before_end = end - SignedDuration::from_nanos(1); (first, before_end.to_zoned(zone.clone()).date().max(first)) } diff --git a/src/calendar/mod.rs b/src/calendar/mod.rs index 33b9a95..c954ef7 100644 --- a/src/calendar/mod.rs +++ b/src/calendar/mod.rs @@ -39,3 +39,21 @@ pub use recurrence::RecurrenceEditor; pub use reminders::ReminderPicker; pub use repeat::{Ends, Frequency, Recurrence}; pub use year::YearView; + +/// The days a calendar shows: jiff's range less a year at each end, so the weeks around any of them fit. +pub(crate) fn within(day: jiff::civil::Date) -> bool { + (jiff::civil::date(-9998, 1, 1)..=jiff::civil::date(9998, 12, 31)).contains(&day) +} + +/// `at` moved `by` while the calendar holds it; a step past its ends logs and goes nowhere. +pub(crate) fn stepped( + at: jiff::civil::Date, + by: jiff::Span, + owner: &str, +) -> Option { + let next = at.checked_add(by).ok().filter(|day| within(*day)); + if next.is_none() { + log::warn!("{owner}: {at} moved {by} leaves the calendar; it stays"); + } + next +} diff --git a/src/calendar/month.rs b/src/calendar/month.rs index 1f2c7bf..586751a 100644 --- a/src/calendar/month.rs +++ b/src/calendar/month.rs @@ -354,9 +354,10 @@ impl RenderOnce for CalendarMonthView { Rc::new(move |by, window, cx| { let day = match by { 0 => today, - by => at - .checked_add(by.months()) - .expect("a month lies on either side"), + by => match super::stepped(at, by.months(), "calendar month view") { + Some(day) => day, + None => return, + }, }; pick(day, window, cx) }) @@ -381,7 +382,9 @@ impl RenderOnce for CalendarMonthView { _ => return, }; cx.stop_propagation(); - pick(at.checked_add(by).expect("a day nearby"), window, cx) + if let Some(day) = super::stepped(at, by, "calendar month view") { + pick(day, window, cx) + } } }; let theme = cx.theme(); diff --git a/src/calendar/recurrence.rs b/src/calendar/recurrence.rs index fe310bb..be90ac4 100644 --- a/src/calendar/recurrence.rs +++ b/src/calendar/recurrence.rs @@ -191,7 +191,13 @@ impl RenderOnce for RecurrenceEditor { .selected(key) .on_change(move |key, window, cx| { let ends = match key.as_ref() { - "on" => Ends::On(first.checked_add(1.month()).expect("a month ahead")), + "on" => match first.checked_add(1.month()) { + Ok(last) => Ends::On(last), + Err(error) => { + log::error!("recurrence: no day a month after {first}: {error}"); + return; + } + }, "after" => Ends::After(10), _ => Ends::Never, }; diff --git a/src/calendar/repeat.rs b/src/calendar/repeat.rs index 4e69417..a738b08 100644 --- a/src/calendar/repeat.rs +++ b/src/calendar/repeat.rs @@ -120,41 +120,49 @@ impl Recurrence { let mut days = Vec::with_capacity(cap); let mut round: i64 = 0; while days.len() < cap { - let candidates: Vec = match self.frequency { - Frequency::Daily => vec![ - first - .checked_add((round * step).days()) - .expect("a day ahead"), - ], - Frequency::Weekly => { - let monday = week_start(first) - .checked_add((round * step).weeks()) - .expect("a week ahead"); - self.on(first) - .into_iter() - .map(|day| { - monday - .checked_add(i64::from(day.to_monday_zero_offset()).days()) - .expect("a day of the week") - }) - .filter(|day| *day >= first) - .collect() - } - Frequency::Monthly => { - let month = first - .first_of_month() - .checked_add((round * step).months()) - .expect("a month ahead"); - Date::new(month.year(), month.month(), first.day()) - .into_iter() - .collect() - } - Frequency::Yearly => { - let year = first.year() + (round * step) as i16; - Date::new(year, first.month(), first.day()) - .into_iter() - .collect() - } + let candidates: Option> = match self.frequency { + Frequency::Daily => first + .checked_add((round * step).days()) + .ok() + .map(|day| vec![day]), + Frequency::Weekly => week_start(first) + .checked_add((round * step).weeks()) + .ok() + .map(|monday| { + self.on(first) + .into_iter() + .filter_map(|day| { + monday + .checked_add(i64::from(day.to_monday_zero_offset()).days()) + .ok() + }) + .filter(|day| *day >= first) + .collect() + }), + Frequency::Monthly => first + .first_of_month() + .checked_add((round * step).months()) + .ok() + .map(|month| { + Date::new(month.year(), month.month(), first.day()) + .into_iter() + .collect() + }), + Frequency::Yearly => i16::try_from(i64::from(first.year()) + round * step) + .ok() + .filter(|year| *year <= Date::MAX.year()) + .map(|year| { + Date::new(year, first.month(), first.day()) + .into_iter() + .collect() + }), + }; + let Some(candidates) = candidates else { + log::warn!( + "recurrence: it runs past the last day jiff holds after {} days", + days.len() + ); + return days; }; for day in candidates { if !within(day) || days.len() == cap { @@ -181,6 +189,24 @@ mod tests { } } + #[test] + fn a_rule_stops_where_the_calendar_ends() { + let yearly = every(Frequency::Yearly, 99).days(date(9900, 2, 28), 5); + assert_eq!(yearly, [date(9900, 2, 28), date(9999, 2, 28)]); + let monthly = every(Frequency::Monthly, 99).days(date(9990, 1, 15), 5); + assert_eq!(monthly, [date(9990, 1, 15), date(9998, 4, 15)]); + assert_eq!( + every(Frequency::Daily, 99).days(date(9999, 12, 1), 5).len(), + 1 + ); + assert_eq!( + every(Frequency::Weekly, 99) + .days(date(9999, 12, 1), 5) + .len(), + 1 + ); + } + #[test] fn ordinals_read_as_spoken() { let said: Vec = [1, 2, 3, 4, 11, 12, 13, 21, 22, 23, 31] diff --git a/src/calendar/tests/editing.rs b/src/calendar/tests/editing.rs index ad3f0cd..768d65f 100644 --- a/src/calendar/tests/editing.rs +++ b/src/calendar/tests/editing.rs @@ -1,9 +1,10 @@ use gpui::{Entity, IntoElement, Modifiers, TestAppContext, VisualTestContext, point, px}; -use jiff::{civil::date, tz::TimeZone}; +use jiff::{SignedDuration, Timestamp, civil::date, tz::TimeZone}; use super::{Planning, at, heard, note, planning, press, settle, tab_to}; use crate::calendar::{ - AvailabilityPicker, EventDraft, EventEditor, RecurrenceEditor, ReminderPicker, When, + AvailabilityPicker, Event, EventDraft, EventEditor, RecurrenceEditor, ReminderPicker, When, + editor::{moved, toggled}, }; fn draft(title: &str, from: i8, to: i8) -> EventDraft { @@ -192,3 +193,52 @@ fn new_days_from_the_owner_start_the_choice_over(cx: &mut TestAppContext) { "the chosen day of the old run is gone; the first open day shows" ); } + +#[test] +fn only_a_timed_event_moves_and_only_while_its_end_exists() { + let all_day = When::AllDay { + first: date(2026, 9, 1), + last: date(2026, 9, 2), + }; + assert_eq!(moved(all_day, Timestamp::UNIX_EPOCH), None); + let start = Timestamp::UNIX_EPOCH; + let hour = When::Timed { + start, + end: start + SignedDuration::from_hours(1), + }; + assert_eq!( + moved(hour, Timestamp::MAX), + None, + "no end past jiff's last moment" + ); + let later = start + SignedDuration::from_hours(5); + let expected = When::Timed { + start: later, + end: later + SignedDuration::from_hours(1), + }; + assert_eq!(moved(hour, later), Some(expected)); +} + +#[test] +fn a_day_at_jiffs_end_stays_all_day_and_an_end_at_its_start_has_days() { + let last = When::AllDay { + first: date(9999, 12, 31), + last: date(9999, 12, 31), + }; + let west = TimeZone::fixed(jiff::tz::offset(-12)); + assert_eq!(toggled(last, &west), None); + let start = Timestamp::MIN + SignedDuration::from_hours(1); + let mut backward = Event::timed( + "e", + "Backward", + start, + start + SignedDuration::from_hours(1), + 0, + ); + backward.when = When::Timed { + start, + end: Timestamp::MIN, + }; + let first = start.to_zoned(TimeZone::UTC).date(); + assert_eq!(backward.days(&TimeZone::UTC), (first, first)); +} diff --git a/src/calendar/tests/mod.rs b/src/calendar/tests/mod.rs index 13339b0..0e7fe60 100644 --- a/src/calendar/tests/mod.rs +++ b/src/calendar/tests/mod.rs @@ -269,3 +269,22 @@ fn the_owners_echo_of_a_year_keeps_the_day(cx: &mut TestAppContext) { } assert_eq!(heard(&host, cx), ["open 2027-01-16"]); } + +/// A month view at the last month the calendar shows. +fn last_month(_: &Planning, owner: Entity) -> gpui::AnyElement { + CalendarMonthView::new("month", date(9998, 12, 1), Vec::new()) + .zone(TimeZone::UTC) + .today(date(9998, 12, 31)) + .on_day(move |day, _, cx| note(&owner, format!("open {day}"), cx)) + .into_any_element() +} + +#[gpui::test] +fn the_calendar_ends_a_year_short_of_jiffs_last_day(cx: &mut TestAppContext) { + let (host, cx) = planning(last_month, cx); + tab_to(4, cx); + for key in ["right", "pagedown", "down", "enter"] { + press(key, cx); + } + assert_eq!(heard(&host, cx), ["open 9998-12-31"]); +} diff --git a/src/calendar/year.rs b/src/calendar/year.rs index 6f0f806..22cda5c 100644 --- a/src/calendar/year.rs +++ b/src/calendar/year.rs @@ -29,11 +29,14 @@ fn marks(events: &[Event], year: i16, zone: &TimeZone) -> BTreeMap for (ix, event) in events.iter().enumerate() { let (first, last) = event.days(zone); let mut day = first; - while day <= last { + loop { if day.year() == year { marks.entry(day).or_insert(ix); } - day = day.tomorrow().expect("a day follows"); + if day >= last { + break; + } + day = day.tomorrow().expect("a day before the last has one after"); } } marks @@ -251,9 +254,10 @@ impl RenderOnce for YearView { Rc::new(move |by, window, cx| { let day = match by { 0 => today, - by => at - .checked_add(by.years()) - .expect("a year lies on either side"), + by => match super::stepped(at, by.years(), "year view") { + Some(day) => day, + None => return, + }, }; pick(day, window, cx) }) @@ -278,7 +282,9 @@ impl RenderOnce for YearView { _ => return, }; cx.stop_propagation(); - pick(at.checked_add(by).expect("a day nearby"), window, cx) + if let Some(day) = super::stepped(at, by, "year view") { + pick(day, window, cx) + } } }; let tints = marks(&self.events, shown, &zone) diff --git a/tasks/panics.md b/tasks/panics.md index 5920aa8..64948ae 100644 --- a/tasks/panics.md +++ b/tasks/panics.md @@ -44,7 +44,7 @@ Each item reads every site in its folders, fixes what the rules name, updates ca - [x] P27 Files — `src/files` - [x] P28 Messaging — `src/messaging` - [x] P29 Mail — `src/mail` -- [ ] P30 Calendar — `src/calendar` +- [x] P30 Calendar — `src/calendar` - [ ] P31 Project — `src/project` - [ ] P32 Canvas & Design — `src/canvas` - [ ] P33 DB & Dev Tools — `src/devtools` @@ -95,3 +95,4 @@ One line per item: what changed, what stayed and why, review rounds. - P27: an owner's pick gone from a grid, an explorer folder or a directory listing logs and is kept as given, so it replaces a local pick and marks none (P13's listing now does the same); the columns mark a path name only while its parent holds it as a folder; moved bytes past a total log, the bar already capped; duplicate marks on no copy, or on every copy of a group, are dropped, so Remove never takes a group's last copy. Kept: one list's uniqueness, a snapshot's path and levels, items and downloads, lookups by construction, `datetime` with a constant pattern. Review: 3 rounds. - P28: an open chat or selected member no longer listed logs and marks none. Kept: one list's uniqueness and non-emptiness, one message's receipt and replies, constant `datetime` patterns, picks from lists without disabled rows, keys from the same render. Review: 1 round. - P29: an open mailbox, ticked label or selected mail no longer listed logs and marks none; a picked moment its zone cannot hold logs and reads as none, so the time dialog's action stays off. Kept: one list's uniqueness, day arithmetic from the owner's now, constant `datetime` patterns, the dialog's action behind its disabled button. Review: 1 round. +- P30: calendar views step only within jiff's range less a year at each end (`calendar::within`, `stepped`), so every grid around a day fits, and a step past logs and stays; recurrences end where jiff's range ends; the year view's marks stop at an event's last day; the event editor refuses moves, ends, all-day starts and switches to timed that leave jiff's range or a stale painted kind, each with a log; a backward timed draft's days are its first day. Kept: owner dates at jiff's far edges, one list's uniqueness, an event's own order, one organizer, working hours, slots, reminder offsets, constant patterns, picks that always name a value. Review: 2 rounds.