Files
Ely-GPUI-Components/src/project/milestone.rs
T
ZacharyZhang-NYandClaude Opus 5.5 75f00883ac 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>
2026-09-27 05:42:03 -04:00

240 lines
7.0 KiB
Rust

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"
]
);
}
}