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:
1 parent
1c96a3ca7b
commit
893eb392d0
7 files changed
+322
-38
No files matched your search
+25
-2
@@ -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
@@ -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()
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
+2
-1
@@ -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.
|
||||
Reference in new issue
Block a user