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
@@ -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| {
|
||||
|
||||
Reference in new issue
Block a user