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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fc0c3e564d
commit
75f00883ac
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