Panics · P31 Project: stale drops and drag targets move nothing, timelines and roadmaps hold at jiff's ends, gone assignees show nobody

This commit is contained in:
ZacharyZhang-NY committed 2026-10-07 04:29:32 -04:00
1 parent 1c96a3ca7b
commit 893eb392d0
7 files changed
+322 -38

No files matched your search

+25 -2
View File
@@ -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<Pixels>| (bounds.center().x - pointer.x).abs();
distance(a)
@@ -430,7 +432,7 @@ impl RenderOnce for KanbanBoard {
let heights: Vec<Pixels> = 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;
}
+37 -9
View File
@@ -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<Initiative> = 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()
}
};
+6 -1
View File
@@ -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<Choice> = std::iter::once(nobody)
+7 -3
View File
@@ -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()
+23 -22
View File
@@ -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",
+222
View File
@@ -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<Desk>) -> 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<Desk>) -> 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<Desk>) -> 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<gpui::Pixels>, 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<Desk>) -> 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<Desk>) -> 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"]);
}