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:
ZacharyZhang-NY committed 2026-09-27 05:42:03 -04:00
1 parent e630e61a63
commit 207d4b0109
13 files changed
+1113 -8

No files matched your search

+268
View File
@@ -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}");
}
}
+2
View File
@@ -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};
+2 -2
View File
@@ -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| {