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:
1 parent
fc0c3e564d
commit
75f00883ac
13 files changed
+1113
-8
No files matched your search
@@ -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"
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user