From 207d4b01091947f2686ffe8860f30f3e0073444d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 27 Sep 2026 05:42:03 -0400 Subject: [PATCH] =?UTF-8?q?Project=20=C2=B7=20Plans:=20a=20roadmap,=20a=20?= =?UTF-8?q?burndown,=20milestones,=20workload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- TASKS.md | 3 +- examples/gallery/pages/project/mod.rs | 5 + examples/gallery/pages/project/plans.rs | 108 +++++++++ src/charts/burndown.rs | 268 ++++++++++++++++++++++ src/charts/mod.rs | 2 + src/charts/schedule.rs | 4 +- src/project/milestone.rs | 239 ++++++++++++++++++++ src/project/mod.rs | 6 + src/project/roadmap.rs | 283 ++++++++++++++++++++++++ src/project/tests/boards.rs | 38 ++++ src/project/workload.rs | 152 +++++++++++++ src/theme/project.rs | 3 + tasks/ch31-43.md | 10 +- 13 files changed, 1113 insertions(+), 8 deletions(-) create mode 100644 examples/gallery/pages/project/plans.rs create mode 100644 src/charts/burndown.rs create mode 100644 src/project/milestone.rs create mode 100644 src/project/roadmap.rs create mode 100644 src/project/workload.rs diff --git a/TASKS.md b/TASKS.md index 8918461..dc85d44 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 | | diff --git a/examples/gallery/pages/project/mod.rs b/examples/gallery/pages/project/mod.rs index f5311f2..7388d1b 100644 --- a/examples/gallery/pages/project/mod.rs +++ b/examples/gallery/pages/project/mod.rs @@ -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() } diff --git a/examples/gallery/pages/project/plans.rs b/examples/gallery/pages/project/plans.rs new file mode 100644 index 0000000..425c9ed --- /dev/null +++ b/examples/gallery/pages/project/plans.rs @@ -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")), + )) +} diff --git a/src/charts/burndown.rs b/src/charts/burndown.rs new file mode 100644 index 0000000..804f0e1 --- /dev/null +++ b/src/charts/burndown.rs @@ -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, + today: Option, +} + +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, 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) -> 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 = + 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
= burn + .ticks + .iter() + .map(|(y, value)| beside(*y, sizes.gutter, label(compact(*value)))) + .collect(); + let dates: Vec
= 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::>(), + [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}"); + } +} diff --git a/src/charts/mod.rs b/src/charts/mod.rs index 736a353..1643306 100644 --- a/src/charts/mod.rs +++ b/src/charts/mod.rs @@ -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}; diff --git a/src/charts/schedule.rs b/src/charts/schedule.rs index 9fa05c7..266c5eb 100644 --- a/src/charts/schedule.rs +++ b/src/charts/schedule.rs @@ -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| { diff --git a/src/project/milestone.rs b/src/project/milestone.rs new file mode 100644 index 0000000..01f9e87 --- /dev/null +++ b/src/project/milestone.rs @@ -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, +} + +impl MilestoneProgress { + /// Work from `start` meant to end by `target`, with `done` of `total` issues done. + pub fn new( + id: impl Into, + name: impl Into, + (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" + ] + ); + } +} diff --git a/src/project/mod.rs b/src/project/mod.rs index 7016503..c86a107 100644 --- a/src/project/mod.rs +++ b/src/project/mod.rs @@ -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}; diff --git a/src/project/roadmap.rs b/src/project/roadmap.rs new file mode 100644 index 0000000..4be552d --- /dev/null +++ b/src/project/roadmap.rs @@ -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, + name: impl Into, + 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)>, + today: Option, +} + +impl Roadmap { + /// `months` months from the one `first` falls in. + pub fn new(id: impl Into, 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, + initiatives: impl IntoIterator, + ) -> 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::>()) + }); + 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::>()) + .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); + } +} diff --git a/src/project/tests/boards.rs b/src/project/tests/boards.rs index b6fa5f8..5bd9d70 100644 --- a/src/project/tests/boards.rs +++ b/src/project/tests/boards.rs @@ -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) -> 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}" + ); +} diff --git a/src/project/workload.rs b/src/project/workload.rs new file mode 100644 index 0000000..cd414bc --- /dev/null +++ b/src/project/workload.rs @@ -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, + unit: SharedString, +} + +impl WorkloadView { + /// Loads counted in `unit`, as "pts" or "h". + pub fn new( + id: impl Into, + loads: impl IntoIterator, + unit: impl Into, + ) -> Self { + let loads: Vec = 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::>()) + } +} diff --git a/src/theme/project.rs b/src/theme/project.rs index ed9cb2d..ca230f9 100644 --- a/src/theme/project.rs +++ b/src/theme/project.rs @@ -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), } } } diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index f202a33..c498869 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -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