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

+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<_>>())
}
}