diff --git a/src/project/board.rs b/src/project/board.rs index cdec4fe..03c222d 100644 --- a/src/project/board.rs +++ b/src/project/board.rs @@ -395,6 +395,7 @@ impl RenderOnce for KanbanBoard { ) }); let (moves, drops, move_measures) = (hold.clone(), hold, measures); + let landings = orders.clone(); let on_move = self.on_move; on_axis( div() @@ -418,6 +419,7 @@ impl RenderOnce for KanbanBoard { let column = measures .bodies .iter() + .filter(|(key, _)| orders.contains_key(*key)) .min_by(|(_, a), (_, b)| { let distance = |bounds: &Bounds| (bounds.center().x - pointer.x).abs(); distance(a) @@ -430,7 +432,7 @@ impl RenderOnce for KanbanBoard { let heights: Vec = orders[&column] .iter() .filter(|key| **key != held.key) - .map(|key| measures.rows[key]) + .filter_map(|key| measures.rows.get(key).copied()) .collect(); let middle = pointer.y - held.grab.y + card.size.height / 2.0; let index = landing(&heights, body.origin.y, middle); @@ -453,7 +455,28 @@ impl RenderOnce for KanbanBoard { }) else { return; }; - if origins[&held.key] == (held.column.clone(), held.index) { + let room = landings + .get(&held.column) + .map(|order| order.iter().filter(|key| **key != held.key).count()); + let (Some(origin), Some(room)) = (origins.get(&held.key), room) else { + log::error!( + "board: {} or column {} left the board; the drop is dropped", + held.key, + held.column + ); + return; + }; + if held.index > room { + log::error!( + "board: {} at {} past {} in {}; the drop is dropped", + held.key, + held.index, + room, + held.column + ); + return; + } + if *origin == (held.column.clone(), held.index) { log::info!("board: {} dropped where it was", held.key); return; } diff --git a/src/project/database.rs b/src/project/database.rs index c62618b..891f1c6 100644 --- a/src/project/database.rs +++ b/src/project/database.rs @@ -15,7 +15,7 @@ use super::{ }; use crate::{ buttons::SegmentedControl, - calendar::{CalendarMonthView, Event}, + calendar::{CalendarMonthView, Event, within}, data_display::Tone, forms::OnValue, layout::seeded::use_seeded, @@ -249,13 +249,23 @@ impl RenderOnce for DatabaseView { })) .into_any_element(), DatabaseLayout::Calendar => { - let month = self - .tasks + let shown = |task: &&Task| match task.due { + Some(due) if !within(due) => { + log::error!( + "database view: {} is due past the calendar's days", + task.key + ); + false + } + due => due.is_some(), + }; + let due: Vec<&Task> = self.tasks.iter().filter(shown).collect(); + let month = due .iter() .filter_map(|task| task.due) .min() .unwrap_or(today); - let events = self.tasks.iter().filter_map(|task| { + let events = due.iter().filter_map(|task| { task.due.map(|due| { Event::all_day(task.key.clone(), task.title.clone(), due, due, 0) }) @@ -267,11 +277,22 @@ impl RenderOnce for DatabaseView { .into_any_element() } DatabaseLayout::Timeline => { - let dated: Vec<&Task> = self + let both: Vec<&Task> = self .tasks .iter() .filter(|task| task.start.is_some() && task.due.is_some()) .collect(); + let dated: Vec<&Task> = both + .iter() + .copied() + .filter(|task| task.start <= task.due) + .collect(); + let backward = both.len() - dated.len(); + if backward > 0 { + log::error!( + "database view: {backward} tasks are due before they start; left out" + ); + } let first = dated .iter() .filter_map(|task| task.start) @@ -282,9 +303,10 @@ impl RenderOnce for DatabaseView { .filter_map(|task| task.due) .max() .unwrap_or(today); - let months = - ((last.year() - first.year()) * 12 + (last.month() - first.month()) as i16 + 1) - .clamp(1, 24) as i8; + let months = ((i32::from(last.year()) - i32::from(first.year())) * 12 + + i32::from(last.month() - first.month()) + + 1) + .clamp(1, 24) as i8; let lanes = Status::ALL.into_iter().filter_map(|status| { let bars: Vec = dated .iter() @@ -299,7 +321,7 @@ impl RenderOnce for DatabaseView { .collect(); (!bars.is_empty()).then_some((status.words(), bars)) }); - let undated = self.tasks.len() - dated.len(); + let undated = self.tasks.len() - both.len(); div() .flex() .flex_col() @@ -314,6 +336,12 @@ impl RenderOnce for DatabaseView { .text_color(theme.colors.fg_muted) .child(format!("{undated} without a start and a due day")) })) + .children((backward > 0).then(|| { + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_muted) + .child(format!("{backward} due before they start")) + })) .into_any_element() } }; diff --git a/src/project/marks.rs b/src/project/marks.rs index d257f3e..4fd39d3 100644 --- a/src/project/marks.rs +++ b/src/project/marks.rs @@ -208,7 +208,12 @@ impl RenderOnce for AssigneePicker { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { if let Some(key) = &self.assignee { let known = self.people.iter().any(|person| person.key == *key); - assert!(known, "assignee picker {:?}: no person {key}", self.id); + if !known { + log::error!( + "assignee picker {:?}: no person {key}; nobody shown", + self.id + ); + } } let nobody = Choice::new("", "No assignee").icon(IconName::User); let choices: Vec = std::iter::once(nobody) diff --git a/src/project/milestone.rs b/src/project/milestone.rs index 01f9e87..446cee1 100644 --- a/src/project/milestone.rs +++ b/src/project/milestone.rs @@ -102,7 +102,10 @@ impl MilestoneProgress { target >= start, "a milestone's target comes after its start" ); - assert!(total > 0 && done <= total, "{done} of {total} issues done"); + assert!(total > 0, "{done} of no issues done"); + if done > total { + log::error!("milestone: {done} of {total} issues done; all count as done"); + } Self { id: id.into(), name: name.into(), @@ -130,7 +133,8 @@ impl RenderOnce for MilestoneProgress { clock_today("milestone progress") } }; - let stands = standing(self.start, self.target, today, self.done, self.total); + let done = self.done.min(self.total); + let stands = standing(self.start, self.target, today, done, self.total); let theme = cx.theme(); let colors = &theme.colors; let ink = stands.severity().color(colors); @@ -141,7 +145,7 @@ impl RenderOnce for MilestoneProgress { .text_color(colors.fg_muted) .child(text) }; - let share = self.done as f32 / self.total as f32; + let share = done as f32 / self.total as f32; div() .flex() .flex_col() diff --git a/src/project/roadmap.rs b/src/project/roadmap.rs index 4be552d..95e14db 100644 --- a/src/project/roadmap.rs +++ b/src/project/roadmap.rs @@ -102,25 +102,28 @@ impl Roadmap { impl RenderOnce for Roadmap { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - let end = self - .first - .checked_add((self.months as i64).months()) - .expect("a roadmap within jiff's range"); - let span = end - .since(self.first) - .expect("days of the roadmap") - .get_days(); + let (last, span) = match self.first.checked_add((self.months as i64).months()) { + Ok(end) => ( + end.yesterday().expect("a day before a later one"), + end.since(self.first) + .expect("days of the roadmap") + .get_days(), + ), + Err(error) => { + log::error!( + "roadmap {:?}: its months run past jiff's last day: {error}", + self.id + ); + let days = Date::MAX.since(self.first).expect("days of the roadmap"); + (Date::MAX, days.get_days() + 1) + } + }; let theme = cx.theme(); let colors = &theme.colors; let row = theme.table_row(Density::Compact); let months: Vec<(f32, Date)> = (0..self.months) - .map(|month| { - let day = self - .first - .checked_add((month as i64).months()) - .expect("a month of the roadmap"); - (along(self.first, span, day), day) - }) + .filter_map(|month| self.first.checked_add((month as i64).months()).ok()) + .map(|day| (along(self.first, span, day), day)) .collect(); let head = div() .relative() @@ -154,7 +157,7 @@ impl RenderOnce for Roadmap { }); let today = self .today - .filter(|today| (self.first..end).contains(today)) + .filter(|today| (self.first..=last).contains(today)) .map(|today| { div() .absolute() @@ -173,15 +176,13 @@ impl RenderOnce for Roadmap { let hue = colors.hue(lane_ix % colors.chart.len(), format_args!("lane {name}")); let bars = initiatives .into_iter() - .filter(|each| each.end >= self.first && each.start < end) + .filter(|each| each.end >= self.first && each.start <= last) .map(|each| { let (from, to) = ( along(self.first, span, each.start), - along( - self.first, - span, - each.end.tomorrow().expect("a day after the end"), - ), + each.end + .tomorrow() + .map_or(1.0, |after| along(self.first, span, after)), ); let dates = format!( "{}: {} – {}, {} done", diff --git a/src/project/tests/boards.rs b/src/project/tests/boards.rs index 4f9e914..6c7cb4b 100644 --- a/src/project/tests/boards.rs +++ b/src/project/tests/boards.rs @@ -229,3 +229,225 @@ fn a_roadmap_draws_only_what_falls_in_its_months(cx: &mut TestAppContext) { "October runs through its last day: {end}" ); } + +/// A roadmap at the end of jiff's range, a milestone past its count and an assignee who left. +fn far(_: &Desk, _: Entity) -> AnyElement { + let people = [crate::project::Person::new("ana", "Ana Lima")]; + gpui::div() + .w(gpui::px(640.0)) + .child( + crate::project::Roadmap::new("roadmap", date(9999, 11, 1), 3).lane( + "Design", + [crate::project::Initiative::new( + "last", + "Last", + date(9999, 12, 1), + date(9999, 12, 31), + )], + ), + ) + .child( + crate::project::MilestoneProgress::new( + "milestone", + "Launch", + (date(2026, 9, 1), date(2026, 10, 1)), + (6, 5), + ) + .today(date(2026, 9, 15)), + ) + .child(crate::project::AssigneePicker::new("assignee", people).assignee("gone")) + .into_any_element() +} + +#[gpui::test] +fn the_end_of_time_an_overcount_and_a_gone_assignee_draw(cx: &mut TestAppContext) { + let (_, cx) = desk(far, cx); + settle(cx); +} + +/// A roadmap past jiff's last month with a bar on its last day alone. +fn last_day(_: &Desk, _: Entity) -> AnyElement { + crate::project::Roadmap::new("last-day-map", date(9999, 11, 1), 3) + .lane( + "Last", + [crate::project::Initiative::new( + "only-last-day", + "Only last day", + date(9999, 12, 31), + date(9999, 12, 31), + )], + ) + .into_any_element() +} + +#[gpui::test] +fn the_last_day_jiff_holds_keeps_its_bar(cx: &mut TestAppContext) { + let (_, cx) = desk(last_day, cx); + let bar = cx + .debug_bounds("initiative only-last-day") + .expect("the last day belongs to December"); + assert!(bar.size.width > px(0.0), "one day has a width"); +} + +/// Tasks a database lays out: one due at jiff's last day, one due before it starts. +fn far_tasks(layout: crate::project::DatabaseLayout) -> AnyElement { + let tasks = [ + Task::new("a", "A") + .starts(date(2026, 9, 1)) + .due(date(9999, 12, 31)), + Task::new("r", "R") + .starts(date(2026, 9, 27)) + .due(date(2026, 8, 27)), + ]; + crate::project::DatabaseView::new("database", tasks) + .layout(layout) + .today(date(2026, 9, 1)) + .into_any_element() +} + +#[gpui::test] +fn a_timeline_caps_long_spans_and_leaves_out_backward_tasks(cx: &mut TestAppContext) { + let (_, cx) = desk( + |_, _| far_tasks(crate::project::DatabaseLayout::Timeline), + cx, + ); + assert!(cx.debug_bounds("initiative a").is_some()); + assert!(cx.debug_bounds("initiative r").is_none()); +} + +#[gpui::test] +fn a_calendar_leaves_out_days_past_its_own(cx: &mut TestAppContext) { + let _ = desk( + |_, _| { + let last = Task::new("a", "A").due(date(9999, 12, 31)); + crate::project::DatabaseView::new("database", [last]) + .layout(crate::project::DatabaseLayout::Calendar) + .today(date(2026, 9, 1)) + .into_any_element() + }, + cx, + ); +} + +/// A board whose second column leaves once anything is heard. +fn changing_board(desk: &Desk, _: Entity) -> AnyElement { + let board = KanbanBoard::new("changing-board") + .column(KanbanColumn::new("todo", "Todo").card(card("a"))); + let board = match desk.heard.is_empty() { + true => board.column(KanbanColumn::new("gone", "Gone").card(card("b"))), + false => board, + }; + board.into_any_element() +} + +#[gpui::test] +fn a_column_that_leaves_during_a_drag_takes_no_card(cx: &mut TestAppContext) { + let (host, cx) = desk(changing_board, cx); + let from = cx.debug_bounds("card a").expect("card a").center(); + let gone = cx + .debug_bounds("column gone") + .expect("column gone") + .center(); + cx.simulate_mouse_move(from, None, Modifiers::none()); + cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::none()); + for shift in [12.0, 16.0] { + let at = point(from.x + px(shift), from.y); + cx.simulate_mouse_move(at, Some(MouseButton::Left), Modifiers::none()); + settle(cx); + } + host.update(cx, |host, cx| { + host.heard.push("removed".into()); + cx.notify(); + }); + settle(cx); + assert!(cx.debug_bounds("column gone").is_none()); + cx.simulate_mouse_move(gone, Some(MouseButton::Left), Modifiers::none()); + settle(cx); + cx.simulate_mouse_up(gone, MouseButton::Left, Modifiers::none()); + settle(cx); +} + +/// Drags `card a` toward `to` in six moves, as a hand would. +fn drag_toward(to: gpui::Point, cx: &mut gpui::VisualTestContext) { + let from = cx.debug_bounds("card a").expect("card a").center(); + cx.simulate_mouse_move(from, None, Modifiers::none()); + cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::none()); + for step in 1..=6 { + let share = step as f32 / 6.0; + let at = point( + from.x + (to.x - from.x) * share, + from.y + (to.y - from.y) * share, + ); + cx.simulate_mouse_move(at, Some(MouseButton::Left), Modifiers::none()); + settle(cx); + } +} + +/// A board whose second column leaves, and the moves it asks. +fn leaving_column(host: &Desk, owner: Entity) -> AnyElement { + let board = + KanbanBoard::new("drop-board").column(KanbanColumn::new("todo", "Todo").card(card("a"))); + let board = match host.heard.is_empty() { + true => board.column(KanbanColumn::new("gone", "Gone").card(card("b"))), + false => board, + }; + board + .on_move(move |key, column, index, _, cx| { + note(&owner, format!("move {key} {column} {index}"), cx) + }) + .into_any_element() +} + +#[gpui::test] +fn a_drop_on_a_column_that_left_moves_nothing(cx: &mut TestAppContext) { + let (host, cx) = desk(leaving_column, cx); + let gone = cx + .debug_bounds("column gone") + .expect("column gone") + .center(); + drag_toward(gone, cx); + host.update(cx, |host, cx| { + host.heard.push("removed".into()); + cx.notify(); + }); + settle(cx); + cx.simulate_mouse_up(gone, MouseButton::Left, Modifiers::none()); + settle(cx); + assert_eq!(heard(&host, cx), ["removed"]); +} + +/// A target column that loses a card, and the moves it asks. +fn shrinking_target(host: &Desk, owner: Entity) -> AnyElement { + let target = KanbanColumn::new("target", "Target").card(card("b")); + let target = match host.heard.is_empty() { + true => target.card(card("c")), + false => target, + }; + let todo = KanbanColumn::new("todo", "Todo") + .card(card("a")) + .card(card("d")) + .card(card("e")); + KanbanBoard::new("shrinking-board") + .column(todo) + .column(target) + .on_move(move |key, column, index, _, cx| { + note(&owner, format!("move {key} {column} {index}"), cx) + }) + .into_any_element() +} + +#[gpui::test] +fn a_drop_past_a_column_that_lost_a_card_moves_nothing(cx: &mut TestAppContext) { + let (host, cx) = desk(shrinking_target, cx); + let target = cx.debug_bounds("column target").expect("column target"); + let to = point(target.center().x, target.bottom() - px(8.0)); + drag_toward(to, cx); + host.update(cx, |host, cx| { + host.heard.push("removed c".into()); + cx.notify(); + }); + settle(cx); + cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none()); + settle(cx); + assert_eq!(heard(&host, cx), ["removed c"]); +} diff --git a/tasks/panics.md b/tasks/panics.md index 64948ae..8caba71 100644 --- a/tasks/panics.md +++ b/tasks/panics.md @@ -45,7 +45,7 @@ Each item reads every site in its folders, fixes what the rules name, updates ca - [x] P28 Messaging — `src/messaging` - [x] P29 Mail — `src/mail` - [x] P30 Calendar — `src/calendar` -- [ ] P31 Project — `src/project` +- [x] P31 Project — `src/project` - [ ] P32 Canvas & Design — `src/canvas` - [ ] P33 DB & Dev Tools — `src/devtools` - [ ] P34 Dashboard — `src/dashboard` @@ -96,3 +96,4 @@ One line per item: what changed, what stayed and why, review 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. +- P31: a gone assignee logs and shows nobody; a milestone past its count fills, the text real; a roadmap past jiff's range ends inclusively on its last day; the database timeline leaves out and counts tasks due before they start and counts months in `i32`, and its calendar keeps to days `calendar::within` holds; the board's drag and drop take only columns, cards and places this frame holds. Kept: one list's uniqueness, an initiative's and a milestone's own order, shares, month counts, pomodoro lengths, issue keys, lookups over the components' own tables. Review: 3 rounds.