Panics · P30 Calendar: views and recurrences stay inside jiff's range, the editor refuses edits past it

This commit is contained in:
ZacharyZhang-NY committed 2026-10-07 03:54:07 -04:00
1 parent a284c71e92
commit 8a0344d2c4
11 files changed
+238 -82

No files matched your search

+8 -3
View File
@@ -218,9 +218,14 @@ impl<const DAYS: usize> RenderOnce for CalendarDays<DAYS> {
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| {
+49 -30
View File
@@ -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<When> {
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<When> {
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<gpui::AnyElement> = 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)
}
+3
View File
@@ -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))
}
+18
View File
@@ -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<jiff::civil::Date> {
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
}
+7 -4
View File
@@ -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();
+7 -1
View File
@@ -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,
};
+61 -35
View File
@@ -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<Date> = 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<Vec<Date>> = 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<String> = [1, 2, 3, 4, 11, 12, 13, 21, 22, 23, 31]
+52 -2
View File
@@ -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));
}
+19
View File
@@ -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<Planning>) -> 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"]);
}
+12 -6
View File
@@ -29,11 +29,14 @@ fn marks(events: &[Event], year: i16, zone: &TimeZone) -> BTreeMap<Date, usize>
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)
+2 -1
View File
@@ -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.