Project · Plans: a roadmap, a burndown, milestones, workload
Roadmap lays initiatives across months in lanes: each a bar from its first day through its last, filled as far as it has come, under the months' names and a line at today; hover a bar for its dates. Bars sit at fractions of the span, so nothing is measured, and a strip too narrow for its months scrolls sideways at a least width per month. GanttChart and TimelineView are the charts GanttChart. BurndownChart lives with the charts, on their scales, labels, tiles and paint: the ideal line from the scope down to nothing, what was left at each day's end so far, and a line at today; hover a day to read it. MilestoneProgress shows a milestone's issues done as a bar and a count, when it is due, and whether it keeps pace: on track, at risk when the work trails the time gone by more than a tenth, late past its day, or done. WorkloadView sets each person's points against what they can take, the bar turning red past it, in columns that line up.
This commit is contained in:
1 parent
e630e61a63
commit
207d4b0109
13 files changed
+1113
-8
No files matched your search
@@ -112,7 +112,7 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
- [x] T30c Calendar · Agenda and people
|
||||
- [x] T30d Calendar · Editing
|
||||
- [x] T31a Project · Tasks — `tasks/ch31-43.md`
|
||||
- [ ] T31b Project · Issues and boards
|
||||
- [x] T31b Project · Issues and boards
|
||||
- [ ] T31c Project · Plans
|
||||
- [ ] T31d Project · Time and databases
|
||||
- [ ] T32 Canvas & Design
|
||||
@@ -365,3 +365,4 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
| T30d | 2 | PASS | |
|
||||
| T31a | 1 | PASS | |
|
||||
| T31b | 1 | FAIL | a focused card past the board's fold stayed out of view; left and right steps that keep a card's place went untested |
|
||||
| T31b | 2 | PASS | |
|
||||
@@ -9,6 +9,7 @@ use gpui::{
|
||||
use jiff::{Timestamp, ToSpan, civil::Date, tz::TimeZone};
|
||||
|
||||
mod boards;
|
||||
mod plans;
|
||||
mod tasks;
|
||||
|
||||
use super::Page;
|
||||
@@ -147,5 +148,9 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
.child(boards::kanban(window, cx))
|
||||
.child(boards::panel(window, cx))
|
||||
.child(boards::labels(window, cx))
|
||||
.child(plans::roadmap(cx))
|
||||
.child(plans::burndown(cx))
|
||||
.child(plans::milestones(cx))
|
||||
.child(plans::workload(cx))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use ely_gpui_component::{
|
||||
charts::BurndownChart,
|
||||
project::{Initiative, Load, MilestoneProgress, Roadmap, WorkloadView},
|
||||
};
|
||||
use gpui::{App, IntoElement, ParentElement, Styled, div, px};
|
||||
use jiff::{ToSpan, civil::Date};
|
||||
|
||||
use super::{team, today};
|
||||
use crate::{probe::probe, ui::section};
|
||||
|
||||
/// A day `offset` days from `today`.
|
||||
fn day(today: Date, offset: i64) -> Date {
|
||||
today.checked_add(offset.days()).expect("a day nearby")
|
||||
}
|
||||
|
||||
pub fn roadmap(cx: &mut App) -> impl IntoElement + use<> {
|
||||
let today = today();
|
||||
let at = |offset: i64| day(today, offset);
|
||||
let map = Roadmap::new("project-roadmap", at(-40), 6)
|
||||
.today(today)
|
||||
.lane(
|
||||
"Design",
|
||||
[
|
||||
Initiative::new("concept", "Concept", at(-40), at(-12)).progress(1.0),
|
||||
Initiative::new("schematic", "Schematic design", at(-14), at(30)).progress(0.55),
|
||||
Initiative::new("details", "Detail drawings", at(28), at(95)),
|
||||
],
|
||||
)
|
||||
.lane(
|
||||
"Site",
|
||||
[
|
||||
Initiative::new("survey", "Land survey", at(-20), at(6)).progress(0.8),
|
||||
Initiative::new("permit", "Permit set", at(10), at(70)).progress(0.1),
|
||||
],
|
||||
)
|
||||
.lane(
|
||||
"Client",
|
||||
[Initiative::new("reviews", "Monthly reviews", at(-35), at(120)).progress(0.3)],
|
||||
);
|
||||
section(
|
||||
"GanttChart / TimelineView · Roadmap",
|
||||
"A roadmap lays initiatives across months in lanes, each bar filled as far as it has come, under a line at today; hover a bar for its dates. GanttChart and TimelineView are the charts GanttChart, for tasks by the day.",
|
||||
cx,
|
||||
)
|
||||
.child(probe("project-roadmap", div().w(px(720.)).child(map)))
|
||||
}
|
||||
|
||||
pub fn burndown(cx: &mut App) -> impl IntoElement + use<> {
|
||||
let today = today();
|
||||
let (first, last) = (day(today, -6), day(today, 7));
|
||||
let chart = BurndownChart::new("project-burndown", first, last, 40.0)
|
||||
.left([40.0, 38.0, 38.0, 33.0, 34.0, 29.0, 24.0])
|
||||
.today(today);
|
||||
section(
|
||||
"Burndown Chart",
|
||||
"Work left over a sprint: the ideal line from the scope down to nothing, what was left at each day's end so far, and a line at today. Work added mid-sprint lifts the line. Hover a day to read it. It lives with the charts as BurndownChart.",
|
||||
cx,
|
||||
)
|
||||
.child(probe("project-burndown", div().w(px(640.)).child(chart)))
|
||||
}
|
||||
|
||||
pub fn milestones(cx: &mut App) -> impl IntoElement + use<> {
|
||||
let today = today();
|
||||
let at = |offset: i64| day(today, offset);
|
||||
let rows = [
|
||||
MilestoneProgress::new("project-concept", "Concept", (at(-40), at(-12)), (14, 14)),
|
||||
MilestoneProgress::new(
|
||||
"project-schematic",
|
||||
"Schematic design",
|
||||
(at(-14), at(30)),
|
||||
(9, 20),
|
||||
),
|
||||
MilestoneProgress::new("project-permit", "Permit set", (at(-30), at(10)), (4, 16)),
|
||||
MilestoneProgress::new("project-survey", "Land survey", (at(-20), at(-2)), (7, 9)),
|
||||
];
|
||||
section(
|
||||
"MilestoneProgress",
|
||||
"A milestone's issues done as a bar and a count, when it is due, and whether it keeps pace: on track, at risk when the work trails the time gone, late past its day, or done.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"project-milestones",
|
||||
div()
|
||||
.w(px(420.))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_6()
|
||||
.children(rows.map(|row| row.today(today))),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn workload(cx: &mut App) -> impl IntoElement + use<> {
|
||||
let people = team();
|
||||
let loads = [(12.0, 10.0), (7.0, 10.0), (9.0, 8.0), (4.0, 10.0)];
|
||||
let rows = people
|
||||
.into_iter()
|
||||
.zip(loads)
|
||||
.map(|(person, (assigned, capacity))| Load::new(person, assigned, capacity));
|
||||
section(
|
||||
"WorkloadView",
|
||||
"Who carries how much this sprint: each person's points against what they can take, the bar turning red past it.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"project-workload",
|
||||
div().w(px(420.)).child(WorkloadView::new("project-workload", rows, "pts")),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
use gpui::{
|
||||
App, Bounds, Div, ElementId, Entity, IntoElement, ParentElement, PathBuilder, Pixels,
|
||||
Refineable, RenderOnce, StyleRefinement, Styled, Window, canvas, div, fill, size,
|
||||
};
|
||||
use jiff::{ToSpan, civil::Date};
|
||||
|
||||
use super::{
|
||||
axes::{anchored, below, beside},
|
||||
geometry::Rect,
|
||||
paint::{at, finish, measure, tint, trace},
|
||||
parts::ChartTooltip,
|
||||
scale::{Linear, compact, nice},
|
||||
schedule::{days, marks},
|
||||
tiles::{Tiles, tracked},
|
||||
};
|
||||
use crate::{
|
||||
theme::{ActiveTheme, TextSize},
|
||||
typography::tabular,
|
||||
};
|
||||
|
||||
/// Where a burndown's lines and levels fall in its frame.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(crate) struct Burn {
|
||||
pub ideal: [(f32, f32); 2],
|
||||
pub left: Vec<(f32, f32)>,
|
||||
pub ticks: Vec<(f32, f64)>,
|
||||
pub step: f32,
|
||||
}
|
||||
|
||||
/// Lays a sprint of `count` days, `scope` at the start and `left` at the end of each day so far, into `frame`.
|
||||
pub(crate) fn burn(count: usize, scope: f64, left: &[f64], frame: Rect) -> Burn {
|
||||
let high = left.iter().copied().fold(scope, f64::max);
|
||||
let ((low, high), ticks) = nice(0.0, high, 4);
|
||||
let y = Linear::new((low, high), (frame.y + frame.h, frame.y));
|
||||
let step = frame.w / (count - 1) as f32;
|
||||
let x = |ix: usize| frame.x + step * ix as f32;
|
||||
Burn {
|
||||
ideal: [(x(0), y.at(scope)), (x(count - 1), y.at(0.0))],
|
||||
left: left
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, value)| (x(ix), y.at(*value)))
|
||||
.collect(),
|
||||
ticks: ticks.iter().map(|tick| (y.at(*tick), *tick)).collect(),
|
||||
step,
|
||||
}
|
||||
}
|
||||
|
||||
/// Work left over a sprint, a day at a time: the ideal line from the scope down to nothing on the last day, what was left at each day's end so far, and a line at today. Hover a day to read it.
|
||||
#[derive(IntoElement)]
|
||||
pub struct BurndownChart {
|
||||
base: Div,
|
||||
id: ElementId,
|
||||
first: Date,
|
||||
last: Date,
|
||||
scope: f64,
|
||||
left: Vec<f64>,
|
||||
today: Option<Date>,
|
||||
}
|
||||
|
||||
impl BurndownChart {
|
||||
/// A sprint from its first day through its last, with `scope` to do, in points or issues.
|
||||
pub fn new(id: impl Into<ElementId>, first: Date, last: Date, scope: f64) -> Self {
|
||||
assert!(last > first, "a sprint ends after its first day");
|
||||
assert!(scope.is_finite() && scope > 0.0, "a sprint has work to do");
|
||||
Self {
|
||||
base: div(),
|
||||
id: id.into(),
|
||||
first,
|
||||
last,
|
||||
scope,
|
||||
left: Vec::new(),
|
||||
today: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What was left at the end of each day from the first, as far as the sprint has gone.
|
||||
pub fn left(mut self, left: impl IntoIterator<Item = f64>) -> Self {
|
||||
self.left = left.into_iter().collect();
|
||||
let count = days(self.first, self.last) as usize + 1;
|
||||
assert!(
|
||||
self.left.len() <= count,
|
||||
"more days left than the sprint has"
|
||||
);
|
||||
assert!(
|
||||
self.left
|
||||
.iter()
|
||||
.all(|value| value.is_finite() && *value >= 0.0),
|
||||
"work left is a count, zero or more"
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
/// Marks this day with a line.
|
||||
pub fn today(mut self, today: Date) -> Self {
|
||||
self.today = Some(today);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for BurndownChart {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for BurndownChart {
|
||||
fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let count = days(self.first, self.last) as usize + 1;
|
||||
let tiles: Entity<Tiles> =
|
||||
window.use_keyed_state((self.id.clone(), "days"), cx, |_, _| Tiles::default());
|
||||
let (bounds, hover) = (tiles.read(cx).bounds, tiles.read(cx).pointed(count));
|
||||
let theme = cx.theme();
|
||||
let (colors, sizes, rem) = (theme.colors.clone(), theme.chart(), window.rem_size());
|
||||
let pixels = |length: gpui::Rems| f32::from(length.to_pixels(rem));
|
||||
let (gutter, foot, inset) = (
|
||||
pixels(sizes.gutter),
|
||||
pixels(sizes.foot),
|
||||
pixels(sizes.inset),
|
||||
);
|
||||
let frame = Rect {
|
||||
x: gutter,
|
||||
y: inset,
|
||||
w: (f32::from(bounds.size.width) - gutter - inset).max(0.0),
|
||||
h: (pixels(sizes.height) - inset - foot).max(0.0),
|
||||
};
|
||||
let burn = burn(count, self.scope, &self.left, frame);
|
||||
let label = |text: String| {
|
||||
tabular(div())
|
||||
.whitespace_nowrap()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(text)
|
||||
};
|
||||
let levels: Vec<Div> = burn
|
||||
.ticks
|
||||
.iter()
|
||||
.map(|(y, value)| beside(*y, sizes.gutter, label(compact(*value))))
|
||||
.collect();
|
||||
let dates: Vec<Div> = marks(self.first, count as i32)
|
||||
.into_iter()
|
||||
.map(|(offset, text)| below(frame.x + burn.step * offset as f32, frame, label(text)))
|
||||
.collect();
|
||||
let x_of = |ix: usize| frame.x + burn.step * ix as f32;
|
||||
let today = self
|
||||
.today
|
||||
.map(|today| days(self.first, today))
|
||||
.filter(|offset| (0..count as i32).contains(offset))
|
||||
.map(|offset| x_of(offset as usize));
|
||||
let tooltip = hover.map(|ix| {
|
||||
let day = self
|
||||
.first
|
||||
.checked_add((ix as i64).days())
|
||||
.expect("a day of the sprint");
|
||||
let ideal = self.scope * (1.0 - ix as f64 / (count - 1) as f64);
|
||||
let mut card = ChartTooltip::new(day.strftime("%a %b %-d").to_string());
|
||||
if let Some(left) = self.left.get(ix) {
|
||||
card = card.row(Some(tint(&colors, 0)), "Left", compact(*left));
|
||||
}
|
||||
card = card.row(None, "Ideal", compact(ideal));
|
||||
let y = burn
|
||||
.left
|
||||
.get(ix)
|
||||
.map_or(frame.y + frame.h / 2.0, |point| point.1);
|
||||
anchored((x_of(ix), y), x_of(ix) > frame.x + frame.w * 0.6, card)
|
||||
});
|
||||
let (stroke, hairline, palette) =
|
||||
(sizes.stroke.to_pixels(rem), sizes.hairline, colors.clone());
|
||||
let points = burn.left.clone();
|
||||
let (ideal, levels_at) = (burn.ideal, burn.ticks.clone());
|
||||
let pointed = hover.map(x_of);
|
||||
let (step, first_x) = (burn.step, frame.x);
|
||||
let mut root = div().relative().h(sizes.height);
|
||||
root.style().refine(self.base.style());
|
||||
tracked(
|
||||
root,
|
||||
(self.id.clone(), "burndown").into(),
|
||||
&tiles,
|
||||
move |(x, _)| {
|
||||
let ix = ((x - first_x) / step).round();
|
||||
(ix >= 0.0 && (ix as usize) < count).then_some(ix as usize)
|
||||
},
|
||||
)
|
||||
.child(
|
||||
canvas(
|
||||
|_, _, _| {},
|
||||
move |bounds, _, window, _| {
|
||||
let origin = bounds.origin;
|
||||
for (y, _) in &levels_at {
|
||||
window.paint_quad(fill(
|
||||
Bounds::new(
|
||||
at(origin, (frame.x, *y)),
|
||||
size(Pixels::from(frame.w), hairline),
|
||||
),
|
||||
palette.border.opacity(0.5),
|
||||
));
|
||||
}
|
||||
for x in pointed.iter().chain(today.iter()) {
|
||||
let ink = match Some(*x) == today {
|
||||
true => tint(&palette, 3),
|
||||
false => palette.border_strong,
|
||||
};
|
||||
window.paint_quad(fill(
|
||||
Bounds::new(
|
||||
at(origin, (*x, frame.y)),
|
||||
size(stroke / 2.0, Pixels::from(frame.h)),
|
||||
),
|
||||
ink,
|
||||
));
|
||||
}
|
||||
let mut line = PathBuilder::stroke(stroke / 2.0);
|
||||
trace(&mut line, &ideal, false, origin);
|
||||
finish(line, palette.fg_subtle, window);
|
||||
if points.len() > 1 {
|
||||
let mut line = PathBuilder::stroke(stroke);
|
||||
trace(&mut line, &points, false, origin);
|
||||
finish(line, tint(&palette, 0), window);
|
||||
}
|
||||
},
|
||||
)
|
||||
.absolute()
|
||||
.inset_0(),
|
||||
)
|
||||
.children(levels)
|
||||
.children(dates)
|
||||
.children(tooltip)
|
||||
.child(measure(tiles, |tiles| &mut tiles.bounds))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Rect, burn};
|
||||
|
||||
#[test]
|
||||
fn the_ideal_runs_from_the_scope_to_nothing_and_the_days_step_evenly() {
|
||||
let frame = Rect {
|
||||
x: 40.0,
|
||||
y: 0.0,
|
||||
w: 90.0,
|
||||
h: 100.0,
|
||||
};
|
||||
let burn = burn(10, 20.0, &[20.0, 18.0, 18.0, 11.0], frame);
|
||||
assert_eq!(burn.step, 10.0);
|
||||
assert_eq!(burn.ideal, [(40.0, 0.0), (130.0, 100.0)]);
|
||||
assert_eq!(
|
||||
burn.left.iter().map(|point| point.0).collect::<Vec<_>>(),
|
||||
[40.0, 50.0, 60.0, 70.0]
|
||||
);
|
||||
assert_eq!(
|
||||
burn.left[3].1, 45.0,
|
||||
"11 of 20 left sits at 55 from the floor"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn work_that_grew_past_the_scope_raises_the_top() {
|
||||
let frame = Rect {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
w: 10.0,
|
||||
h: 100.0,
|
||||
};
|
||||
let burn = burn(2, 20.0, &[26.0], frame);
|
||||
let top = burn.ticks.last().expect("levels").1;
|
||||
assert!(top >= 26.0, "the scale reaches {top}");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod axes;
|
||||
mod burndown;
|
||||
mod calendar;
|
||||
mod cartesian;
|
||||
mod distribution;
|
||||
@@ -28,6 +29,7 @@ mod targets;
|
||||
mod tests;
|
||||
mod tiles;
|
||||
|
||||
pub use burndown::BurndownChart;
|
||||
pub use calendar::CalendarHeatmap;
|
||||
pub use cartesian::{AreaChart, BarChart, LineChart, ScatterChart};
|
||||
pub use distribution::{BoxPlot, Histogram, ViolinPlot, WaterfallChart};
|
||||
|
||||
@@ -57,14 +57,14 @@ impl Task {
|
||||
}
|
||||
|
||||
/// Whole days from `from` to `to`.
|
||||
fn days(from: Date, to: Date) -> i32 {
|
||||
pub(super) fn days(from: Date, to: Date) -> i32 {
|
||||
to.since(from)
|
||||
.expect("dates within jiff's range")
|
||||
.get_days()
|
||||
}
|
||||
|
||||
/// Days to mark along a span: each month's first day when the span is long, each Monday when it is short.
|
||||
fn marks(first: Date, count: i32) -> Vec<(i32, String)> {
|
||||
pub(super) fn marks(first: Date, count: i32) -> Vec<(i32, String)> {
|
||||
let long = count > 62;
|
||||
(0..count)
|
||||
.filter_map(|offset| {
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
use gpui::{
|
||||
App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
|
||||
Window, div,
|
||||
};
|
||||
use jiff::civil::Date;
|
||||
|
||||
use super::tasks::clock_today;
|
||||
use crate::{
|
||||
motion::ProgressBar,
|
||||
primitives::Severity,
|
||||
theme::{ActiveTheme, TextSize},
|
||||
typography::{Ellipsis, fresh, tabular},
|
||||
};
|
||||
|
||||
/// Whether a milestone keeps pace.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Standing {
|
||||
OnTrack,
|
||||
AtRisk,
|
||||
Late,
|
||||
Done,
|
||||
}
|
||||
|
||||
impl Standing {
|
||||
pub fn words(self) -> &'static str {
|
||||
match self {
|
||||
Standing::OnTrack => "On track",
|
||||
Standing::AtRisk => "At risk",
|
||||
Standing::Late => "Late",
|
||||
Standing::Done => "Done",
|
||||
}
|
||||
}
|
||||
|
||||
fn severity(self) -> Severity {
|
||||
match self {
|
||||
Standing::OnTrack | Standing::Done => Severity::Success,
|
||||
Standing::AtRisk => Severity::Warning,
|
||||
Standing::Late => Severity::Danger,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Days from `from` to `to`.
|
||||
fn days(from: Date, to: Date) -> i64 {
|
||||
i64::from(to.since(from).expect("days between two dates").get_days())
|
||||
}
|
||||
|
||||
/// Where `done` of `total` stands on `today`, from `start` to `target`: done when all are, late past the target, at risk when the share done trails the share of time gone by more than a tenth.
|
||||
pub(crate) fn standing(
|
||||
start: Date,
|
||||
target: Date,
|
||||
today: Date,
|
||||
done: usize,
|
||||
total: usize,
|
||||
) -> Standing {
|
||||
if done == total {
|
||||
return Standing::Done;
|
||||
}
|
||||
if today > target {
|
||||
return Standing::Late;
|
||||
}
|
||||
let gone = days(start, today).max(0) as f32 / days(start, target).max(1) as f32;
|
||||
let share = done as f32 / total as f32;
|
||||
match share + 0.1 < gone {
|
||||
true => Standing::AtRisk,
|
||||
false => Standing::OnTrack,
|
||||
}
|
||||
}
|
||||
|
||||
/// When a target falls from `today`, in words: due today, in so many days, or so many days late.
|
||||
fn due(target: Date, today: Date) -> String {
|
||||
match days(today, target) {
|
||||
0 => "Due today".into(),
|
||||
1 => "Due tomorrow".into(),
|
||||
left if left > 1 => format!("{left} days left"),
|
||||
-1 => "A day late".into(),
|
||||
late => format!("{} days late", -late),
|
||||
}
|
||||
}
|
||||
|
||||
/// A milestone on its way: its name, whether it keeps pace, how many of its issues are done as a bar and a count, and when it is due.
|
||||
#[derive(IntoElement)]
|
||||
pub struct MilestoneProgress {
|
||||
id: ElementId,
|
||||
name: SharedString,
|
||||
start: Date,
|
||||
target: Date,
|
||||
done: usize,
|
||||
total: usize,
|
||||
today: Option<Date>,
|
||||
}
|
||||
|
||||
impl MilestoneProgress {
|
||||
/// Work from `start` meant to end by `target`, with `done` of `total` issues done.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
name: impl Into<SharedString>,
|
||||
(start, target): (Date, Date),
|
||||
(done, total): (usize, usize),
|
||||
) -> Self {
|
||||
assert!(
|
||||
target >= start,
|
||||
"a milestone's target comes after its start"
|
||||
);
|
||||
assert!(total > 0 && done <= total, "{done} of {total} issues done");
|
||||
Self {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
start,
|
||||
target,
|
||||
done,
|
||||
total,
|
||||
today: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The day pace is read on; the clock's otherwise.
|
||||
pub fn today(mut self, day: Date) -> Self {
|
||||
self.today = Some(day);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for MilestoneProgress {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let today = match self.today {
|
||||
Some(day) => day,
|
||||
None => {
|
||||
fresh((self.id.clone(), "clock"), window, cx);
|
||||
clock_today("milestone progress")
|
||||
}
|
||||
};
|
||||
let stands = standing(self.start, self.target, today, self.done, self.total);
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let ink = stands.severity().color(colors);
|
||||
let quiet = |text: String| {
|
||||
tabular(div())
|
||||
.flex_none()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(colors.fg_muted)
|
||||
.child(text)
|
||||
};
|
||||
let share = self.done as f32 / self.total as f32;
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.child(Ellipsis::new(self.name)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(ink)
|
||||
.child(div().size(theme.status_dot()).rounded_full().bg(ink))
|
||||
.child(stands.words()),
|
||||
),
|
||||
)
|
||||
.child(ProgressBar::new((self.id.clone(), "done"), share))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.justify_between()
|
||||
.gap_x_3()
|
||||
.child(quiet(format!("{} of {} issues", self.done, self.total)))
|
||||
.child(quiet(match stands {
|
||||
Standing::Done => format!("Due {}", self.target.strftime("%b %-d")),
|
||||
_ => due(self.target, today),
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use jiff::civil::date;
|
||||
|
||||
use super::{Standing, due, standing};
|
||||
|
||||
#[test]
|
||||
fn a_milestone_keeps_pace_trails_or_runs_late() {
|
||||
let (start, target) = (date(2026, 9, 1), date(2026, 9, 21));
|
||||
let today = date(2026, 9, 11);
|
||||
assert_eq!(
|
||||
standing(start, target, today, 5, 10),
|
||||
Standing::OnTrack,
|
||||
"half the time, half the work"
|
||||
);
|
||||
assert_eq!(
|
||||
standing(start, target, today, 3, 10),
|
||||
Standing::AtRisk,
|
||||
"half the time, three tenths done"
|
||||
);
|
||||
assert_eq!(
|
||||
standing(start, target, today, 9, 20),
|
||||
Standing::OnTrack,
|
||||
"trailing by less than a tenth"
|
||||
);
|
||||
assert_eq!(
|
||||
standing(start, target, date(2026, 9, 22), 9, 10),
|
||||
Standing::Late
|
||||
);
|
||||
assert_eq!(
|
||||
standing(start, target, date(2026, 9, 30), 10, 10),
|
||||
Standing::Done
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_target_reads_as_days_left_or_late() {
|
||||
let target = date(2026, 9, 21);
|
||||
let words = [21, 20, 16, 22, 24].map(|day| due(target, date(2026, 9, day)));
|
||||
assert_eq!(
|
||||
words,
|
||||
[
|
||||
"Due today",
|
||||
"Due tomorrow",
|
||||
"5 days left",
|
||||
"A day late",
|
||||
"3 days late"
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,13 @@ mod board;
|
||||
mod card;
|
||||
mod labels;
|
||||
mod marks;
|
||||
mod milestone;
|
||||
mod moves;
|
||||
mod panel;
|
||||
mod roadmap;
|
||||
mod tasks;
|
||||
mod work;
|
||||
mod workload;
|
||||
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
mod tests;
|
||||
@@ -14,6 +17,9 @@ pub use board::{KanbanBoard, KanbanCard, KanbanColumn};
|
||||
pub use card::IssueCard;
|
||||
pub use labels::LabelManager;
|
||||
pub use marks::{AssigneePicker, IssueIdBadge, PriorityIndicator, StatusSelect};
|
||||
pub use milestone::{MilestoneProgress, Standing};
|
||||
pub use panel::TaskDetailPanel;
|
||||
pub use roadmap::{Initiative, Roadmap};
|
||||
pub use tasks::{TaskItem, TaskList};
|
||||
pub use work::{IssueId, Person, Priority, Status, Task};
|
||||
pub use workload::{Load, WorkloadView};
|
||||
@@ -0,0 +1,283 @@
|
||||
use gpui::{
|
||||
App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
|
||||
StatefulInteractiveElement, Styled, Window, div, relative,
|
||||
};
|
||||
use jiff::{ToSpan, civil::Date};
|
||||
|
||||
use crate::{
|
||||
layout::on_axis,
|
||||
primitives::Tooltip,
|
||||
theme::{ActiveTheme, Density, Radius, TextSize},
|
||||
typography::{Ellipsis, format},
|
||||
};
|
||||
|
||||
/// An initiative on a roadmap: its key, its name, its first and last day, and how far along it is.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Initiative {
|
||||
pub key: SharedString,
|
||||
pub name: SharedString,
|
||||
pub start: Date,
|
||||
pub end: Date,
|
||||
pub progress: f32,
|
||||
}
|
||||
|
||||
impl Initiative {
|
||||
pub fn new(
|
||||
key: impl Into<SharedString>,
|
||||
name: impl Into<SharedString>,
|
||||
start: Date,
|
||||
end: Date,
|
||||
) -> Self {
|
||||
let name = name.into();
|
||||
assert!(end >= start, "initiative {name} ends before it starts");
|
||||
Self {
|
||||
key: key.into(),
|
||||
name,
|
||||
start,
|
||||
end,
|
||||
progress: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// How far along it is, zero to one.
|
||||
pub fn progress(mut self, share: f32) -> Self {
|
||||
assert!(
|
||||
(0.0..=1.0).contains(&share),
|
||||
"progress is a share, zero to one"
|
||||
);
|
||||
self.progress = share;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Where `day` falls along the span from `first`, `span` days long, as a share clamped to the span.
|
||||
pub(crate) fn along(first: Date, span: i32, day: Date) -> f32 {
|
||||
let offset = day.since(first).expect("days between two dates").get_days();
|
||||
(offset as f32 / span as f32).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Initiatives across months in lanes, as teams or themes: each a bar from its first day through its last, filled as far as it has come, under the months' names, with a line at today. Hover a bar to read its dates; too narrow for its months, it scrolls sideways.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Roadmap {
|
||||
id: ElementId,
|
||||
first: Date,
|
||||
months: i8,
|
||||
lanes: Vec<(SharedString, Vec<Initiative>)>,
|
||||
today: Option<Date>,
|
||||
}
|
||||
|
||||
impl Roadmap {
|
||||
/// `months` months from the one `first` falls in.
|
||||
pub fn new(id: impl Into<ElementId>, first: Date, months: i8) -> Self {
|
||||
assert!(
|
||||
(1..=24).contains(&months),
|
||||
"a roadmap spans one to twenty-four months"
|
||||
);
|
||||
Self {
|
||||
id: id.into(),
|
||||
first: first.first_of_month(),
|
||||
months,
|
||||
lanes: Vec::new(),
|
||||
today: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A lane of initiatives under its name.
|
||||
pub fn lane(
|
||||
mut self,
|
||||
name: impl Into<SharedString>,
|
||||
initiatives: impl IntoIterator<Item = Initiative>,
|
||||
) -> Self {
|
||||
self.lanes
|
||||
.push((name.into(), initiatives.into_iter().collect()));
|
||||
self
|
||||
}
|
||||
|
||||
/// Marks this day with a line.
|
||||
pub fn today(mut self, today: Date) -> Self {
|
||||
self.today = Some(today);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
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 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)
|
||||
})
|
||||
.collect();
|
||||
let head = div()
|
||||
.relative()
|
||||
.h(row)
|
||||
.children(months.iter().map(|(x, day)| {
|
||||
let name = match day.month() == 1 || *day == self.first {
|
||||
true => day.strftime("%b %Y").to_string(),
|
||||
false => day.strftime("%b").to_string(),
|
||||
};
|
||||
div()
|
||||
.absolute()
|
||||
.left(relative(*x))
|
||||
.top_0()
|
||||
.h_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.pl_1p5()
|
||||
.whitespace_nowrap()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(name)
|
||||
}));
|
||||
let rules = months.iter().map(|(x, _)| {
|
||||
div()
|
||||
.absolute()
|
||||
.left(relative(*x))
|
||||
.top_0()
|
||||
.bottom_0()
|
||||
.border_l_1()
|
||||
.border_color(colors.border)
|
||||
});
|
||||
let today = self
|
||||
.today
|
||||
.filter(|today| (self.first..end).contains(today))
|
||||
.map(|today| {
|
||||
div()
|
||||
.absolute()
|
||||
.left(relative(along(self.first, span, today)))
|
||||
.top_0()
|
||||
.bottom_0()
|
||||
.border_l_1()
|
||||
.border_color(colors.hue(3, "roadmap today"))
|
||||
});
|
||||
let radius = theme.radius(Radius::Md);
|
||||
let lanes = self
|
||||
.lanes
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(lane_ix, (name, initiatives))| {
|
||||
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)
|
||||
.map(|each| {
|
||||
let (from, to) = (
|
||||
along(self.first, span, each.start),
|
||||
along(
|
||||
self.first,
|
||||
span,
|
||||
each.end.tomorrow().expect("a day after the end"),
|
||||
),
|
||||
);
|
||||
let dates = format!(
|
||||
"{}: {} – {}, {} done",
|
||||
each.name,
|
||||
each.start.strftime("%b %-d"),
|
||||
each.end.strftime("%b %-d"),
|
||||
format::percent(f64::from(each.progress), 0, false)
|
||||
);
|
||||
let named = each.key.clone();
|
||||
div().relative().h(row).child(
|
||||
div()
|
||||
.id((self.id.clone(), format!("initiative-{}", each.key)))
|
||||
.debug_selector(move || format!("initiative {named}"))
|
||||
.absolute()
|
||||
.left(relative(from))
|
||||
.w(relative(to - from))
|
||||
.top_1()
|
||||
.bottom_1()
|
||||
.rounded(radius)
|
||||
.bg(hue.opacity(0.16))
|
||||
.tooltip(Tooltip::text(dates))
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.left_0()
|
||||
.top_0()
|
||||
.bottom_0()
|
||||
.w(relative(each.progress))
|
||||
.rounded(radius)
|
||||
.bg(hue.opacity(0.32)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.relative()
|
||||
.h_full()
|
||||
.px_2()
|
||||
.flex()
|
||||
.items_center()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(colors.fg)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(Ellipsis::new(each.name)),
|
||||
),
|
||||
),
|
||||
)
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(
|
||||
div()
|
||||
.h(row)
|
||||
.flex()
|
||||
.items_center()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(colors.fg_muted)
|
||||
.child(name),
|
||||
)
|
||||
.children(bars.collect::<Vec<_>>())
|
||||
});
|
||||
let least = theme.project().month * self.months as f32;
|
||||
on_axis(
|
||||
div()
|
||||
.id((self.id.clone(), "scroll"))
|
||||
.debug_selector(|| "roadmap".into())
|
||||
.overflow_x_scroll(),
|
||||
)
|
||||
.child(
|
||||
div().min_w(least).flex().flex_col().child(head).child(
|
||||
div()
|
||||
.relative()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.children(rules)
|
||||
.children(lanes.collect::<Vec<_>>())
|
||||
.children(today),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use jiff::civil::date;
|
||||
|
||||
use super::along;
|
||||
|
||||
#[test]
|
||||
fn days_fall_along_the_span_and_stay_inside_it() {
|
||||
let first = date(2026, 9, 1);
|
||||
assert_eq!(along(first, 30, date(2026, 9, 16)), 0.5);
|
||||
assert_eq!(along(first, 30, date(2026, 8, 20)), 0.0);
|
||||
assert_eq!(along(first, 30, date(2026, 12, 1)), 1.0);
|
||||
}
|
||||
}
|
||||
@@ -186,3 +186,41 @@ fn the_panel_hands_on_the_task_after_each_edit(cx: &mut TestAppContext) {
|
||||
"a blank title is not taken"
|
||||
);
|
||||
}
|
||||
|
||||
fn roadmap(_: Entity<Desk>) -> AnyElement {
|
||||
crate::project::Roadmap::new("roadmap", date(2026, 9, 1), 2)
|
||||
.lane(
|
||||
"Design",
|
||||
[
|
||||
crate::project::Initiative::new(
|
||||
"early",
|
||||
"Early",
|
||||
date(2026, 7, 1),
|
||||
date(2026, 8, 30),
|
||||
),
|
||||
crate::project::Initiative::new(
|
||||
"half",
|
||||
"Half",
|
||||
date(2026, 10, 1),
|
||||
date(2026, 10, 31),
|
||||
),
|
||||
],
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_roadmap_draws_only_what_falls_in_its_months(cx: &mut TestAppContext) {
|
||||
let (_, cx) = desk(roadmap, cx);
|
||||
assert!(
|
||||
cx.debug_bounds("initiative early").is_none(),
|
||||
"it ended before the span"
|
||||
);
|
||||
let map = cx.debug_bounds("roadmap").expect("the roadmap draws");
|
||||
let half = cx.debug_bounds("initiative half").expect("October draws");
|
||||
let share = (half.left() - map.left()) / map.size.width;
|
||||
assert!(
|
||||
(share - 30.0 / 61.0).abs() < 0.01,
|
||||
"October starts 30 of 61 days in: {share}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
use gpui::{
|
||||
App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
|
||||
Styled, Window, div, relative,
|
||||
};
|
||||
|
||||
use super::work::Person;
|
||||
use crate::{
|
||||
data_display::Avatar,
|
||||
theme::{ActiveTheme, AvatarSize, TextSize},
|
||||
typography::{Ellipsis, tabular},
|
||||
};
|
||||
|
||||
/// Work someone has against what they can take, in one unit, as points.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Load {
|
||||
pub person: Person,
|
||||
pub assigned: f32,
|
||||
pub capacity: f32,
|
||||
}
|
||||
|
||||
impl Load {
|
||||
pub fn new(person: Person, assigned: f32, capacity: f32) -> Self {
|
||||
assert!(
|
||||
assigned.is_finite() && assigned >= 0.0,
|
||||
"{} has {assigned} assigned",
|
||||
person.name
|
||||
);
|
||||
assert!(
|
||||
capacity.is_finite() && capacity > 0.0,
|
||||
"{} can take {capacity}",
|
||||
person.name
|
||||
);
|
||||
Self {
|
||||
person,
|
||||
assigned,
|
||||
capacity,
|
||||
}
|
||||
}
|
||||
|
||||
/// Past what they can take.
|
||||
pub fn over(&self) -> bool {
|
||||
self.assigned > self.capacity
|
||||
}
|
||||
}
|
||||
|
||||
/// Who carries how much: each person with a bar of their work against what they can take, red past it, and the two counts.
|
||||
#[derive(IntoElement)]
|
||||
pub struct WorkloadView {
|
||||
id: ElementId,
|
||||
loads: Vec<Load>,
|
||||
unit: SharedString,
|
||||
}
|
||||
|
||||
impl WorkloadView {
|
||||
/// Loads counted in `unit`, as "pts" or "h".
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
loads: impl IntoIterator<Item = Load>,
|
||||
unit: impl Into<SharedString>,
|
||||
) -> Self {
|
||||
let loads: Vec<Load> = loads.into_iter().collect();
|
||||
for (ix, load) in loads.iter().enumerate() {
|
||||
let twice = loads[..ix]
|
||||
.iter()
|
||||
.any(|other| other.person.key == load.person.key);
|
||||
assert!(!twice, "workload: {} twice", load.person.key);
|
||||
}
|
||||
Self {
|
||||
id: id.into(),
|
||||
loads,
|
||||
unit: unit.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for WorkloadView {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let rows = self.loads.into_iter().map(|load| {
|
||||
let over = load.over();
|
||||
let fill = if over {
|
||||
colors.danger
|
||||
} else {
|
||||
colors.hue(0, "workload")
|
||||
};
|
||||
let share = (load.assigned / load.capacity).min(1.0);
|
||||
let count = format!("{:.0} / {:.0} {}", load.assigned, load.capacity, self.unit);
|
||||
let key = load.person.key.clone();
|
||||
div()
|
||||
.debug_selector(move || format!("load {key}"))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.w(relative(0.4))
|
||||
.flex_none()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
Avatar::new(
|
||||
(self.id.clone(), format!("who-{}", load.person.key)),
|
||||
load.person.name.clone(),
|
||||
)
|
||||
.size(AvatarSize::Xs),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(Ellipsis::new(load.person.name)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.h_1p5()
|
||||
.relative()
|
||||
.rounded_full()
|
||||
.bg(colors.sunken)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.left_0()
|
||||
.top_0()
|
||||
.bottom_0()
|
||||
.w(relative(share))
|
||||
.rounded_full()
|
||||
.bg(fill),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
tabular(div())
|
||||
.w_20()
|
||||
.flex_none()
|
||||
.flex()
|
||||
.justify_end()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(if over { colors.danger } else { colors.fg_muted })
|
||||
.child(count),
|
||||
)
|
||||
});
|
||||
div()
|
||||
.debug_selector(|| "workload".into())
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.children(rows.collect::<Vec<_>>())
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,15 @@ use super::{Theme, tokens::px_to_rems};
|
||||
pub struct ProjectSizes {
|
||||
/// A board's column, wide.
|
||||
pub column: Rems,
|
||||
/// A roadmap's month at least, wide.
|
||||
pub month: Rems,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
pub fn project(&self) -> ProjectSizes {
|
||||
ProjectSizes {
|
||||
column: px_to_rems(272.0),
|
||||
month: px_to_rems(72.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -21,11 +21,11 @@
|
||||
|
||||
## T31c Project · Plans
|
||||
|
||||
- [ ] GanttChart / TimelineView → `charts::GanttChart`
|
||||
- [ ] Roadmap
|
||||
- [ ] Burndown Chart
|
||||
- [ ] MilestoneProgress
|
||||
- [ ] WorkloadView
|
||||
- [x] GanttChart / TimelineView → `charts::GanttChart`
|
||||
- [x] Roadmap
|
||||
- [x] Burndown Chart → `charts::BurndownChart`, on the charts' scales and paint
|
||||
- [x] MilestoneProgress
|
||||
- [x] WorkloadView
|
||||
|
||||
## T31d Project · Time and databases
|
||||
|
||||
|
||||
Reference in new issue
Block a user