Panics · P30 Calendar: views and recurrences stay inside jiff's range, the editor refuses edits past it
This commit is contained in:
1 parent
a284c71e92
commit
8a0344d2c4
11 files changed
+238
-82
No files matched your search
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
Reference in new issue
Block a user