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.
This commit is contained in:
1 parent
e630e61a63
commit
207d4b0109
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"
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
@@ -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<_>>())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user