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:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-09-27 05:42:03 -04:00
1 parent fc0c3e564d
commit 75f00883ac
13 files changed
+1113 -8

No files matched your search

+2 -1
View File
@@ -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 | |
+5
View File
@@ -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()
}
+108
View File
@@ -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")),
))
}
+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| {
+239
View File
@@ -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"
]
);
}
}
+6
View File
@@ -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};
+283
View File
@@ -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);
}
}
+38
View File
@@ -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}"
);
}
+152
View File
@@ -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<_>>())
}
}
+3
View File
@@ -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
View File
@@ -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