diff --git a/AGENTS.md b/AGENTS.md
index e25fb74..d0a7d26 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -101,9 +101,9 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- Trackpad momentum arrives as `TouchPhase::Moved` after `Ended`, so a gesture acts only between `Started` and `Ended` (`lists::SwipeableListItem`).
- Quartz scroll events carry no window. The harness hands them to the window itself, placed in window points (`Step::Swipe`).
- A chevron that opens and closes is `primitives::Disclosure`.
-- `uniform_list` does not stretch its rows; each row sets `w_full`.
+- `uniform_list` does not stretch its rows; each row sets `w_full`, or `min_w_full` when the same row also sits in a flex column, where it stretches (`tables::DataTable`).
- Chapters import only earlier chapters, so a select of tree nodes lives in `lists` (`TreeSelect`) and reaches `forms::listing`.
-- Script clicks aim at a probe's corner, so a scripted target must fit in the window; a tall probe puts its top off screen.
+- Script clicks aim at a probe's corner, so a scripted target must fit in the window; a tall probe puts its top off screen. The log names each probe's bounds as the script aims, and a probe around content with no width of its own measures smaller than what it draws, so a scripted demo sets its width.
- `scripts/check.sh` fails by its exit status; a pipe into `tail` hides it.
- Reduced motion shortens animations to 1ms on the wall clock. Tests sleep past it between refreshes to see where one ends.
- The test platform never runs next-frame callbacks, and its text metrics are simplified: each character takes a fixed advance. Tests refresh the window in place of the display link, and click where layout does not hang on text, such as padding and slots.
diff --git a/TASKS.md b/TASKS.md
index 47741d8..e23191b 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -113,7 +113,7 @@ Per-component lines live in `tasks/`. Tags there:
- [x] T30d Calendar · Editing
- [x] T31a Project · Tasks — `tasks/ch31-43.md`
- [x] T31b Project · Issues and boards
-- [ ] T31c Project · Plans
+- [x] T31c Project · Plans
- [ ] T31d Project · Time and databases
- [ ] T32 Canvas & Design
- [ ] T33 DB & Dev Tools
@@ -367,3 +367,4 @@ Per-component lines live in `tasks/`. Tags there:
| 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 | |
| T31c | 1 | FAIL | a roadmap bar's end and the workload bar's cap went untested |
+| T31c | 2 | PASS | |
diff --git a/assets/icons/chart-gantt.svg b/assets/icons/chart-gantt.svg
new file mode 100644
index 0000000..1b0dd64
--- /dev/null
+++ b/assets/icons/chart-gantt.svg
@@ -0,0 +1,18 @@
+
+
diff --git a/examples/gallery/pages/project/mod.rs b/examples/gallery/pages/project/mod.rs
index 7388d1b..57c8c5c 100644
--- a/examples/gallery/pages/project/mod.rs
+++ b/examples/gallery/pages/project/mod.rs
@@ -11,6 +11,7 @@ use jiff::{Timestamp, ToSpan, civil::Date, tz::TimeZone};
mod boards;
mod plans;
mod tasks;
+mod time;
use super::Page;
use crate::{script::Step, ui::keep};
@@ -42,6 +43,14 @@ const SCRIPT: &[Step] = &[
Step::UpAt("project-board", 360.0, 110.0),
Step::Wait(300),
Step::Shot("board-dropped"),
+ Step::DownAt("project-database", 105.0, 17.0),
+ Step::UpAt("project-database", 105.0, 17.0),
+ Step::Wait(300),
+ Step::Shot("database-board"),
+ Step::DownAt("project-database", 349.0, 17.0),
+ Step::UpAt("project-database", 349.0, 17.0),
+ Step::Wait(300),
+ Step::Shot("database-timeline"),
];
/// What a demo says it did, under it.
@@ -100,12 +109,14 @@ fn work(today: Date) -> Vec {
.issue(IssueId::new("ARC", 41))
.status(Status::InProgress)
.priority(Priority::Urgent)
+ .starts(day(-9))
.due(day(-1))
.assignee(who("ana"))
.label(tag("client")),
Task::new("samples", "Order plaster samples")
.issue(IssueId::new("ARC", 44))
.priority(Priority::High)
+ .starts(day(-3))
.due(day(0))
.assignee(who("ben"))
.label(tag("budget")),
@@ -113,6 +124,7 @@ fn work(today: Date) -> Vec {
.issue(IssueId::new("ARC", 38))
.status(Status::InReview)
.priority(Priority::Medium)
+ .starts(day(-5))
.due(day(3))
.assignee(who("chloe"))
.label(tag("site"))
@@ -125,6 +137,7 @@ fn work(today: Date) -> Vec {
.issue(IssueId::new("ARC", 29))
.status(Status::Done)
.priority(Priority::Medium)
+ .starts(day(-16))
.due(day(-4))
.assignee(who("dev"))
.label(tag("site")),
@@ -152,5 +165,8 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
.child(plans::burndown(cx))
.child(plans::milestones(cx))
.child(plans::workload(cx))
+ .child(time::tracker(window, cx))
+ .child(time::pomodoro(window, cx))
+ .child(time::database(window, cx))
.into_any_element()
}
diff --git a/examples/gallery/pages/project/time.rs b/examples/gallery/pages/project/time.rs
new file mode 100644
index 0000000..2ac8a62
--- /dev/null
+++ b/examples/gallery/pages/project/time.rs
@@ -0,0 +1,110 @@
+use std::time::{Duration, Instant};
+
+use ely_gpui_component::project::{DatabaseView, PomodoroTimer, Status, TimeEntry, TimeTracker};
+use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
+
+use super::{board, quiet};
+use crate::{
+ probe::probe,
+ ui::{change, keep, section, set},
+};
+
+pub fn tracker(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ let entries = keep(
+ "project-entries",
+ || {
+ vec![
+ TimeEntry::new("e1", "Client brief", Duration::from_secs(95 * 60)),
+ TimeEntry::new("e2", "Site photos", Duration::from_secs(40 * 60)),
+ TimeEntry::new("e3", "Budget review", Duration::from_secs(25 * 60)),
+ ]
+ },
+ window,
+ cx,
+ );
+ let running = keep(
+ "project-running",
+ || None::<(SharedString, Instant)>,
+ window,
+ cx,
+ );
+ let (listed, now) = (entries.read(cx).clone(), running.read(cx).clone());
+ let (started, stopped, logged) = (running.clone(), running, entries);
+ let tracker = TimeTracker::new("project-tracker", listed)
+ .on_start(move |task, at, _, cx| set(&started, Some((task.clone(), at)), cx))
+ .on_stop(move |at, _, cx| {
+ let Some((task, since)) = stopped.update(cx, |running, cx| {
+ cx.notify();
+ running.take()
+ }) else {
+ return;
+ };
+ change(&logged, cx, |entries| {
+ let key = SharedString::from(format!("e{}", entries.len() + 1));
+ entries.insert(0, TimeEntry::new(key, task, at.duration_since(since)));
+ });
+ });
+ let tracker = match now {
+ Some((task, since)) => tracker.running(task, since),
+ None => tracker,
+ };
+ section(
+ "TimeTracker",
+ "Time against a task: name it, Start or Enter starts the clock, and Stop turns it into an entry. Today's entries sit under it with their total.",
+ cx,
+ )
+ .child(probe("project-tracker", div().w(px(420.)).child(tracker)))
+}
+
+pub fn pomodoro(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ let said = keep("project-pomodoro-said", || None::, window, cx);
+ let shown = said.read(cx).clone();
+ section(
+ "Pomodoro Timer",
+ "Focus for twenty-five minutes, rest for five, and after the fourth round for fifteen. The ring counts the phase down around the time left; a phase that ends waits for Start.",
+ cx,
+ )
+ .child(probe(
+ "project-pomodoro",
+ div()
+ .w(px(280.))
+ .flex()
+ .flex_col()
+ .items_center()
+ .gap_3()
+ .child(
+ PomodoroTimer::new("project-pomodoro")
+ .on_phase(move |phase, _, cx| set(&said, Some(format!("{} begins.", phase.words()).into()), cx)),
+ )
+ .children(shown.map(|shown| quiet(shown, cx))),
+ ))
+}
+
+pub fn database(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ let tasks = board(window, cx);
+ let listed = tasks.read(cx).clone();
+ let opened = keep(
+ "project-opened",
+ || Some(SharedString::from("brief")),
+ window,
+ cx,
+ );
+ let moved = tasks.clone();
+ let view = DatabaseView::new("project-database", listed)
+ .on_open(move |key, _, cx| set(&opened, Some(key.clone()), cx))
+ .on_status(move |key, status: Status, _, cx| {
+ change(&moved, cx, |tasks| {
+ let task = tasks
+ .iter_mut()
+ .find(|task| task.key == *key)
+ .expect("a listed task");
+ task.status = status;
+ })
+ });
+ section(
+ "DatabaseView",
+ "The studio's tasks five ways: a table, a board by status, a gallery of cards, a month calendar by due day, and a timeline from start to due. A card moved on the board takes its column's status, and a record opens in the panel above.",
+ cx,
+ )
+ .child(probe("project-database", div().w(px(860.)).child(view)))
+}
diff --git a/examples/gallery/script.rs b/examples/gallery/script.rs
index 7bf3a83..5404008 100644
--- a/examples/gallery/script.rs
+++ b/examples/gallery/script.rs
@@ -425,6 +425,9 @@ async fn target_bounds(
if window.update(cx, |gallery, _, cx| gallery.reveal(bounds, cx))? {
cx.background_executor().timer(FRAME).await;
}
- cx.update(|cx| Probes::get(key, cx))?
- .with_context(|| format!("probe {key} vanished after scrolling"))
+ let bounds = cx
+ .update(|cx| Probes::get(key, cx))?
+ .with_context(|| format!("probe {key} vanished after scrolling"))?;
+ log::info!("script: probe {key} at {bounds:?}");
+ Ok(bounds)
}
diff --git a/src/motion/progress.rs b/src/motion/progress.rs
index 18f6d3b..d6fdb7f 100644
--- a/src/motion/progress.rs
+++ b/src/motion/progress.rs
@@ -1,8 +1,8 @@
use std::f32::consts::{FRAC_PI_2, TAU};
use gpui::{
- Animation, AnimationExt, App, ElementId, IntoElement, ParentElement, RenderOnce, Styled,
- Window, canvas, div, prelude::*, relative,
+ Animation, AnimationExt, AnyElement, App, ElementId, IntoElement, ParentElement, Rems,
+ RenderOnce, Styled, Window, canvas, div, prelude::*, relative,
};
use super::{BASE, SLOW, duration, ease_in_out_cubic, ease_out_cubic, spinner::arc};
@@ -174,12 +174,14 @@ impl RenderOnce for ProgressBar {
}
}
-/// Progress as a ring that fills clockwise from the top and glides to each new value; the percent can sit inside.
+/// Progress as a ring that fills clockwise from the top and glides to each new value; the percent, or any content, can sit inside.
#[derive(IntoElement)]
pub struct ProgressRing {
id: ElementId,
value: f32,
percent: bool,
+ size: Option,
+ inside: Option,
}
impl ProgressRing {
@@ -188,6 +190,8 @@ impl ProgressRing {
id: id.into(),
value: checked(value, "progress"),
percent: false,
+ size: None,
+ inside: None,
}
}
@@ -196,6 +200,23 @@ impl ProgressRing {
self.percent = true;
self
}
+
+ /// A ring this wide; the theme's otherwise. Its stroke keeps the theme ring's weight.
+ pub fn size(mut self, size: Rems) -> Self {
+ self.size = Some(size);
+ self
+ }
+
+ /// What sits in the ring's middle.
+ pub fn inside(mut self, content: impl IntoElement) -> Self {
+ assert!(
+ !self.percent,
+ "progress ring {:?}: a percent or content inside, not both",
+ self.id
+ );
+ self.inside = Some(content.into_any_element());
+ self
+ }
}
impl RenderOnce for ProgressRing {
@@ -205,13 +226,13 @@ impl RenderOnce for ProgressRing {
let theme = cx.theme();
let (fill, track) = (theme.colors.fg, theme.colors.border);
let text = theme.text_size(TextSize::Xs);
- div()
- .relative()
+ let stroke = theme.progress_ring().to_pixels(window.rem_size()) / 12.0;
+ let glide = div()
+ .absolute()
+ .inset_0()
.flex()
- .flex_none()
.items_center()
.justify_center()
- .size(theme.progress_ring())
.with_animation(
(self.id, format!("glide-{turn}")),
Animation::new(duration(BASE, cx)).with_easing(ease_out_cubic),
@@ -226,7 +247,7 @@ impl RenderOnce for ProgressRing {
|_, _, _| {},
move |bounds, _, window, _| {
let side = bounds.size.width.min(bounds.size.height);
- let width = side / 12.0;
+ let width = stroke;
let (center, radius) = (bounds.center(), side / 2.0 - width);
arc(center, radius, 0.0, TAU, width, track, window);
if at > 0.0 {
@@ -245,6 +266,15 @@ impl RenderOnce for ProgressRing {
)
})
},
- )
+ );
+ div()
+ .relative()
+ .flex()
+ .flex_none()
+ .items_center()
+ .justify_center()
+ .size(self.size.unwrap_or(theme.progress_ring()))
+ .child(glide)
+ .children(self.inside)
}
}
diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs
index 5cc2830..031fc6c 100644
--- a/src/primitives/icon.rs
+++ b/src/primitives/icon.rs
@@ -82,6 +82,7 @@ icons! {
ChartLine => "chart-line",
ChartPie => "chart-pie",
CheckCheck => "check-check",
+ ChartGantt => "chart-gantt",
Check => "check",
ChevronDown => "chevron-down",
ChevronLeft => "chevron-left",
diff --git a/src/project/database.rs b/src/project/database.rs
new file mode 100644
index 0000000..25635d6
--- /dev/null
+++ b/src/project/database.rs
@@ -0,0 +1,345 @@
+use std::rc::Rc;
+
+use gpui::{
+ AnyElement, App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce,
+ SharedString, StatefulInteractiveElement, Styled, Window, div,
+};
+use jiff::civil::Date;
+
+use super::{
+ board::{KanbanBoard, KanbanCard, KanbanColumn},
+ card::IssueCard,
+ roadmap::{Initiative, Roadmap},
+ tasks::clock_today,
+ work::{Status, Task},
+};
+use crate::{
+ buttons::SegmentedControl,
+ calendar::{CalendarMonthView, Event},
+ data_display::Tone,
+ forms::OnValue,
+ layout::{on_axis, seeded::use_seeded},
+ primitives::IconName,
+ tables::{Cell, Column, DataTable, Row},
+ theme::{ActiveTheme, ControlSize, TextSize},
+ typography::fresh,
+};
+
+type OnStatus = Rc;
+
+/// How a database shows its records.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum DatabaseLayout {
+ Table,
+ Board,
+ Gallery,
+ Calendar,
+ Timeline,
+}
+
+impl DatabaseLayout {
+ pub const ALL: [DatabaseLayout; 5] = [
+ DatabaseLayout::Table,
+ DatabaseLayout::Board,
+ DatabaseLayout::Gallery,
+ DatabaseLayout::Calendar,
+ DatabaseLayout::Timeline,
+ ];
+
+ pub fn words(self) -> &'static str {
+ match self {
+ DatabaseLayout::Table => "Table",
+ DatabaseLayout::Board => "Board",
+ DatabaseLayout::Gallery => "Gallery",
+ DatabaseLayout::Calendar => "Calendar",
+ DatabaseLayout::Timeline => "Timeline",
+ }
+ }
+
+ fn icon(self) -> IconName {
+ match self {
+ DatabaseLayout::Table => IconName::Table,
+ DatabaseLayout::Board => IconName::Kanban,
+ DatabaseLayout::Gallery => IconName::LayoutGrid,
+ DatabaseLayout::Calendar => IconName::Calendar,
+ DatabaseLayout::Timeline => IconName::ChartGantt,
+ }
+ }
+
+ fn of_words(words: &str) -> DatabaseLayout {
+ DatabaseLayout::ALL
+ .into_iter()
+ .find(|layout| layout.words() == words)
+ .unwrap_or_else(|| panic!("no database layout {words}"))
+ }
+}
+
+/// A status's tone in a table's tag.
+fn tone(status: Status) -> Tone {
+ match status {
+ Status::Backlog | Status::Todo => Tone::Neutral,
+ Status::InProgress | Status::InReview => Tone::Info,
+ Status::Done => Tone::Success,
+ Status::Canceled => Tone::Neutral,
+ }
+}
+
+/// Records five ways: a table, a board by status, a gallery of cards, a month calendar by due day, and a timeline from start to due. A switch picks the way; a record opens from the board, the gallery and the calendar, and a card moved to another column takes its status.
+#[derive(IntoElement)]
+pub struct DatabaseView {
+ id: ElementId,
+ tasks: Vec,
+ layout: DatabaseLayout,
+ today: Option,
+ on_open: Option,
+ on_status: Option,
+}
+
+impl DatabaseView {
+ pub fn new(id: impl Into, tasks: impl IntoIterator- ) -> Self {
+ Self {
+ id: id.into(),
+ tasks: tasks.into_iter().collect(),
+ layout: DatabaseLayout::Table,
+ today: None,
+ on_open: None,
+ on_status: None,
+ }
+ }
+
+ /// The way it shows first; a new one from the owner shows at once.
+ pub fn layout(mut self, layout: DatabaseLayout) -> Self {
+ self.layout = layout;
+ self
+ }
+
+ /// The day due dates count from; the clock's otherwise.
+ pub fn today(mut self, day: Date) -> Self {
+ self.today = Some(day);
+ self
+ }
+
+ /// Gets the key of the record opened.
+ pub fn on_open(
+ mut self,
+ handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
+ ) -> Self {
+ self.on_open = Some(Rc::new(handler));
+ self
+ }
+
+ /// Gets a record's key and the status of the column it was moved to.
+ pub fn on_status(
+ mut self,
+ handler: impl Fn(&SharedString, Status, &mut Window, &mut App) + 'static,
+ ) -> Self {
+ self.on_status = Some(Rc::new(handler));
+ self
+ }
+}
+
+impl RenderOnce for DatabaseView {
+ fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let id = self.id.clone();
+ let today = match self.today {
+ Some(day) => day,
+ None => {
+ fresh((id.clone(), "clock"), window, cx);
+ clock_today("database view")
+ }
+ };
+ let layout = use_seeded((id.clone(), "layout"), self.layout, window, cx);
+ let shown = layout.read(cx).value;
+ let switch = DatabaseLayout::ALL
+ .into_iter()
+ .fold(
+ SegmentedControl::new((id.clone(), "layouts"), shown.words()),
+ |switch, each| switch.segment(each.words(), each.words(), Some(each.icon())),
+ )
+ .size(ControlSize::Sm)
+ .on_change({
+ let (id, layout) = (id.clone(), layout.clone());
+ move |words: &SharedString, _, cx| {
+ let next = DatabaseLayout::of_words(words);
+ log::info!("database view {id:?}: {}", next.words());
+ layout.update(cx, |layout, cx| {
+ layout.value = next;
+ cx.notify();
+ })
+ }
+ });
+ let open = self.on_open.clone();
+ let opener = move |key: SharedString| {
+ let open = open.clone();
+ move |window: &mut Window, cx: &mut App| {
+ if let Some(open) = &open {
+ open(&key, window, cx);
+ }
+ }
+ };
+ let theme = cx.theme();
+ let body: AnyElement = match shown {
+ DatabaseLayout::Table => {
+ let columns = [
+ Column::new("title", "Title").width(theme.project().column),
+ Column::new("status", "Status"),
+ Column::new("priority", "Priority"),
+ Column::new("assignee", "Assignee"),
+ Column::new("due", "Due"),
+ ];
+ let rows = self.tasks.iter().map(|task| {
+ let cells = [
+ Cell::Text(task.title.clone()),
+ Cell::Tag(task.status.words().into(), tone(task.status)),
+ Cell::Text(task.priority.words().into()),
+ task.assignee
+ .as_ref()
+ .map_or(Cell::Empty, |person| Cell::Person(person.name.clone())),
+ task.due.map_or(Cell::Empty, |due| {
+ Cell::Text(due.strftime("%b %-d").to_string().into())
+ }),
+ ];
+ Row::new(task.key.clone(), cells)
+ });
+ DataTable::new((id.clone(), "table"), columns)
+ .rows(rows.collect::>())
+ .into_any_element()
+ }
+ DatabaseLayout::Board => {
+ let on_status = self.on_status.clone();
+ Status::ALL
+ .into_iter()
+ .fold(KanbanBoard::new((id.clone(), "board")), |board, status| {
+ let cards =
+ self.tasks
+ .iter()
+ .filter(|task| task.status == status)
+ .map(|task| {
+ let face = IssueCard::new(
+ (id.clone(), format!("card-{}", task.key)),
+ task.clone(),
+ )
+ .today(today);
+ KanbanCard::new(task.key.clone(), face)
+ });
+ board.column(KanbanColumn::new(status.key(), status.words()).cards(cards))
+ })
+ .on_move(move |key, column, _, window, cx| {
+ let status = Status::of_key(column);
+ if let Some(on_status) = &on_status {
+ on_status(key, status, window, cx);
+ }
+ })
+ .on_open({
+ let opener = opener.clone();
+ move |key, window, cx| opener(key.clone())(window, cx)
+ })
+ .into_any_element()
+ }
+ DatabaseLayout::Gallery => div()
+ .flex()
+ .flex_wrap()
+ .gap_3()
+ .children(self.tasks.iter().map(|task| {
+ div().w(theme.project().column).child(
+ IssueCard::new((id.clone(), format!("tile-{}", task.key)), task.clone())
+ .today(today)
+ .on_open(opener(task.key.clone())),
+ )
+ }))
+ .into_any_element(),
+ DatabaseLayout::Calendar => {
+ let month = self
+ .tasks
+ .iter()
+ .filter_map(|task| task.due)
+ .min()
+ .unwrap_or(today);
+ let events = self.tasks.iter().filter_map(|task| {
+ task.due.map(|due| {
+ Event::all_day(task.key.clone(), task.title.clone(), due, due, 0)
+ })
+ });
+ let opener = opener.clone();
+ CalendarMonthView::new((id.clone(), "calendar"), month, events.collect::>())
+ .today(today)
+ .on_event(move |key, window, cx| opener(key.clone())(window, cx))
+ .into_any_element()
+ }
+ DatabaseLayout::Timeline => {
+ let dated: Vec<&Task> = self
+ .tasks
+ .iter()
+ .filter(|task| task.start.is_some() && task.due.is_some())
+ .collect();
+ let first = dated
+ .iter()
+ .filter_map(|task| task.start)
+ .min()
+ .unwrap_or(today);
+ let last = dated
+ .iter()
+ .filter_map(|task| task.due)
+ .max()
+ .unwrap_or(today);
+ let months =
+ ((last.year() - first.year()) * 12 + (last.month() - first.month()) as i16 + 1)
+ .clamp(1, 24) as i8;
+ let lanes = Status::ALL.into_iter().filter_map(|status| {
+ let bars: Vec = dated
+ .iter()
+ .filter(|task| task.status == status)
+ .map(|task| {
+ let (start, due) =
+ (task.start.expect("dated"), task.due.expect("dated"));
+ let done = if status == Status::Done { 1.0 } else { 0.0 };
+ Initiative::new(task.key.clone(), task.title.clone(), start, due)
+ .progress(done)
+ })
+ .collect();
+ (!bars.is_empty()).then_some((status.words(), bars))
+ });
+ let undated = self.tasks.len() - dated.len();
+ div()
+ .flex()
+ .flex_col()
+ .gap_3()
+ .child(lanes.fold(
+ Roadmap::new((id.clone(), "timeline"), first, months).today(today),
+ |map, (name, bars)| map.lane(name, bars),
+ ))
+ .children((undated > 0).then(|| {
+ div()
+ .text_size(theme.text_size(TextSize::Sm))
+ .text_color(theme.colors.fg_muted)
+ .child(format!("{undated} without a start and a due day"))
+ }))
+ .into_any_element()
+ }
+ };
+ div()
+ .flex()
+ .flex_col()
+ .gap_4()
+ .child(
+ div()
+ .flex()
+ .flex_wrap()
+ .items_center()
+ .justify_between()
+ .gap_2()
+ .child(
+ on_axis(div().id((id.clone(), "switch")).overflow_x_scroll())
+ .min_w_0()
+ .child(switch),
+ )
+ .child(
+ div()
+ .text_size(theme.text_size(TextSize::Sm))
+ .text_color(theme.colors.fg_muted)
+ .child(format!("{} records", self.tasks.len())),
+ ),
+ )
+ .child(body)
+ }
+}
diff --git a/src/project/mod.rs b/src/project/mod.rs
index c86a107..757eb81 100644
--- a/src/project/mod.rs
+++ b/src/project/mod.rs
@@ -1,12 +1,15 @@
mod board;
mod card;
+mod database;
mod labels;
mod marks;
mod milestone;
mod moves;
mod panel;
+mod pomodoro;
mod roadmap;
mod tasks;
+mod tracker;
mod work;
mod workload;
@@ -15,11 +18,14 @@ mod tests;
pub use board::{KanbanBoard, KanbanCard, KanbanColumn};
pub use card::IssueCard;
+pub use database::{DatabaseLayout, DatabaseView};
pub use labels::LabelManager;
pub use marks::{AssigneePicker, IssueIdBadge, PriorityIndicator, StatusSelect};
pub use milestone::{MilestoneProgress, Standing};
pub use panel::TaskDetailPanel;
+pub use pomodoro::{Phase, PomodoroTimer};
pub use roadmap::{Initiative, Roadmap};
pub use tasks::{TaskItem, TaskList};
+pub use tracker::{TimeEntry, TimeTracker};
pub use work::{IssueId, Person, Priority, Status, Task};
pub use workload::{Load, WorkloadView};
diff --git a/src/project/pomodoro.rs b/src/project/pomodoro.rs
new file mode 100644
index 0000000..49af305
--- /dev/null
+++ b/src/project/pomodoro.rs
@@ -0,0 +1,349 @@
+use std::{
+ rc::Rc,
+ time::{Duration, Instant},
+};
+
+use gpui::{
+ App, ElementId, Entity, IntoElement, ParentElement, RenderOnce, Styled, Task, Window, div,
+};
+
+use crate::{
+ buttons::{Button, ButtonVariant, IconButton},
+ motion::ProgressRing,
+ primitives::IconName,
+ theme::{ActiveTheme, ControlSize, TextSize},
+ typography::{format, tabular},
+};
+
+type OnPhase = Rc;
+
+/// What a pomodoro is doing.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Phase {
+ Focus,
+ ShortBreak,
+ LongBreak,
+}
+
+impl Phase {
+ pub fn words(self) -> &'static str {
+ match self {
+ Phase::Focus => "Focus",
+ Phase::ShortBreak => "Short break",
+ Phase::LongBreak => "Long break",
+ }
+ }
+}
+
+/// How long each phase runs, and the focus rounds before a long break.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub(crate) struct Lengths {
+ pub focus: Duration,
+ pub short: Duration,
+ pub long: Duration,
+ pub rounds: u8,
+}
+
+/// Where a pomodoro stands: its phase, the round it is on, time banked before a pause, and when it last started.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub(crate) struct Cycle {
+ pub phase: Phase,
+ pub round: u8,
+ pub banked: Duration,
+ pub since: Option,
+}
+
+impl Cycle {
+ const START: Cycle = Cycle {
+ phase: Phase::Focus,
+ round: 1,
+ banked: Duration::ZERO,
+ since: None,
+ };
+
+ pub(crate) fn length(&self, lengths: &Lengths) -> Duration {
+ match self.phase {
+ Phase::Focus => lengths.focus,
+ Phase::ShortBreak => lengths.short,
+ Phase::LongBreak => lengths.long,
+ }
+ }
+
+ /// Time gone in this phase by `now`.
+ pub(crate) fn gone(&self, now: Instant) -> Duration {
+ self.banked
+ + self
+ .since
+ .map_or(Duration::ZERO, |since| now.duration_since(since))
+ }
+
+ /// The phase after this one, waiting: a break after focus, long after the last round, and focus after a break.
+ pub(crate) fn next(self, lengths: &Lengths) -> Cycle {
+ let (phase, round) = match self.phase {
+ Phase::Focus if self.round >= lengths.rounds => (Phase::LongBreak, self.round),
+ Phase::Focus => (Phase::ShortBreak, self.round),
+ Phase::ShortBreak => (Phase::Focus, self.round + 1),
+ Phase::LongBreak => (Phase::Focus, 1),
+ };
+ Cycle {
+ phase,
+ round,
+ ..Cycle::START
+ }
+ }
+}
+
+/// A focus timer in pomodoros: focus for a spell, a short break, and after the last round a long one. A ring counts each phase down around the time left, dots count the rounds, and a phase that ends waits for Start.
+#[derive(IntoElement)]
+pub struct PomodoroTimer {
+ id: ElementId,
+ lengths: Lengths,
+ on_phase: Option,
+}
+
+impl PomodoroTimer {
+ /// Twenty-five minutes of focus, breaks of five and fifteen, four rounds.
+ pub fn new(id: impl Into) -> Self {
+ Self {
+ id: id.into(),
+ lengths: Lengths {
+ focus: Duration::from_secs(25 * 60),
+ short: Duration::from_secs(5 * 60),
+ long: Duration::from_secs(15 * 60),
+ rounds: 4,
+ },
+ on_phase: None,
+ }
+ }
+
+ /// Minutes of focus, of a short break and of a long one.
+ pub fn minutes(mut self, focus: u64, short: u64, long: u64) -> Self {
+ assert!(
+ focus > 0 && short > 0 && long > 0,
+ "each phase takes a minute or more"
+ );
+ self.lengths.focus = Duration::from_secs(focus * 60);
+ self.lengths.short = Duration::from_secs(short * 60);
+ self.lengths.long = Duration::from_secs(long * 60);
+ self
+ }
+
+ /// Focus rounds before a long break.
+ pub fn rounds(mut self, rounds: u8) -> Self {
+ assert!(rounds > 0, "a pomodoro needs a round");
+ self.lengths.rounds = rounds;
+ self
+ }
+
+ /// Gets each phase as it comes, whether it ran out or was skipped.
+ pub fn on_phase(mut self, handler: impl Fn(Phase, &mut Window, &mut App) + 'static) -> Self {
+ self.on_phase = Some(Rc::new(handler));
+ self
+ }
+}
+
+/// Moves `cycle` to its next phase and tells the owner once the frame is drawn.
+fn advance(
+ cycle: &Entity,
+ lengths: Lengths,
+ on_phase: Option,
+ window: &mut Window,
+ cx: &mut App,
+) {
+ let next = cycle.update(cx, |cycle, cx| {
+ *cycle = cycle.next(&lengths);
+ cx.notify();
+ *cycle
+ });
+ log::info!(
+ "pomodoro: {} begins, round {}",
+ next.phase.words(),
+ next.round
+ );
+ if let Some(on_phase) = on_phase {
+ window.defer(cx, move |window, cx| on_phase(next.phase, window, cx));
+ }
+}
+
+impl RenderOnce for PomodoroTimer {
+ fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let lengths = self.lengths;
+ let cycle = window.use_keyed_state((self.id.clone(), "cycle"), cx, |_, _| Cycle::START);
+ let now = cx.background_executor().now();
+ if cycle.read(cx).since.is_some()
+ && cycle.read(cx).gone(now) >= cycle.read(cx).length(&lengths)
+ {
+ advance(&cycle, lengths, self.on_phase.clone(), window, cx);
+ }
+ let current = *cycle.read(cx);
+ if current.since.is_some() {
+ window.use_keyed_state((self.id.clone(), "tick"), cx, |window, cx| -> Task<()> {
+ cx.spawn_in(window, async move |ticker, cx| {
+ loop {
+ cx.background_executor().timer(Duration::from_secs(1)).await;
+ if ticker.update(cx, |_, cx| cx.notify()).is_err() {
+ return;
+ }
+ }
+ })
+ });
+ }
+ let length = current.length(&lengths);
+ let gone = current.gone(now).min(length);
+ let left = (length - gone).as_millis().div_ceil(1000) as u64;
+ let theme = cx.theme();
+ let colors = &theme.colors;
+ let face = div()
+ .flex()
+ .flex_col()
+ .items_center()
+ .gap_0p5()
+ .child(
+ tabular(div())
+ .text_size(theme.text_size(TextSize::Xxl))
+ .child(format::duration(left, format::DurationStyle::Clock)),
+ )
+ .child(
+ div()
+ .text_size(theme.text_size(TextSize::Sm))
+ .text_color(colors.fg_muted)
+ .child(current.phase.words()),
+ );
+ let share = gone.as_secs_f32() / length.as_secs_f32();
+ let dots = (1..=lengths.rounds).map(|round| {
+ let done =
+ round < current.round || (round == current.round && current.phase != Phase::Focus);
+ div().size(theme.status_dot()).rounded_full().bg(if done {
+ colors.fg_muted
+ } else {
+ colors.border_strong
+ })
+ });
+ let running = current.since.is_some();
+ let toggle = {
+ let cycle = cycle.clone();
+ Button::new(
+ (self.id.clone(), "toggle"),
+ if running { "Pause" } else { "Start" },
+ )
+ .variant(ButtonVariant::Primary)
+ .icon(if running {
+ IconName::Pause
+ } else {
+ IconName::Play
+ })
+ .on_click(move |_, _, cx| {
+ let now = cx.background_executor().now();
+ cycle.update(cx, |cycle, cx| {
+ match cycle.since.take() {
+ Some(since) => cycle.banked += now.duration_since(since),
+ None => cycle.since = Some(now),
+ }
+ log::info!(
+ "pomodoro: {}",
+ if cycle.since.is_some() {
+ "running"
+ } else {
+ "paused"
+ }
+ );
+ cx.notify();
+ })
+ })
+ };
+ let skip = {
+ let (cycle, on_phase) = (cycle.clone(), self.on_phase.clone());
+ IconButton::new((self.id.clone(), "skip"), IconName::SkipForward)
+ .variant(ButtonVariant::Ghost)
+ .size(ControlSize::Sm)
+ .tooltip("Skip to the next phase")
+ .on_click(move |_, window, cx| {
+ advance(&cycle, lengths, on_phase.clone(), window, cx)
+ })
+ };
+ let reset = IconButton::new((self.id.clone(), "reset"), IconName::RotateCcw)
+ .variant(ButtonVariant::Ghost)
+ .size(ControlSize::Sm)
+ .tooltip("Start over")
+ .on_click(move |_, _, cx| {
+ log::info!("pomodoro: reset");
+ cycle.update(cx, |cycle, cx| {
+ *cycle = Cycle::START;
+ cx.notify();
+ })
+ });
+ div()
+ .flex()
+ .flex_col()
+ .items_center()
+ .gap_4()
+ .child(
+ ProgressRing::new((self.id.clone(), "ring"), share)
+ .size(theme.project().pomodoro)
+ .inside(face),
+ )
+ .child(div().flex().gap_1p5().children(dots))
+ .child(
+ div()
+ .flex()
+ .items_center()
+ .gap_2()
+ .child(reset)
+ .child(toggle)
+ .child(skip),
+ )
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::time::{Duration, Instant};
+
+ use super::{Cycle, Lengths, Phase};
+
+ const LENGTHS: Lengths = Lengths {
+ focus: Duration::from_secs(1500),
+ short: Duration::from_secs(300),
+ long: Duration::from_secs(900),
+ rounds: 2,
+ };
+
+ #[test]
+ fn focus_and_breaks_take_turns_and_the_last_round_rests_long() {
+ let mut cycle = Cycle::START;
+ let mut seen = Vec::new();
+ for _ in 0..5 {
+ cycle = cycle.next(&LENGTHS);
+ seen.push((cycle.phase, cycle.round));
+ }
+ assert_eq!(
+ seen,
+ [
+ (Phase::ShortBreak, 1),
+ (Phase::Focus, 2),
+ (Phase::LongBreak, 2),
+ (Phase::Focus, 1),
+ (Phase::ShortBreak, 1),
+ ]
+ );
+ }
+
+ #[test]
+ fn time_gone_adds_what_was_banked_to_the_run_since_start() {
+ let start = Instant::now();
+ let cycle = Cycle {
+ banked: Duration::from_secs(60),
+ since: Some(start),
+ ..Cycle::START
+ };
+ assert_eq!(
+ cycle.gone(start + Duration::from_secs(30)),
+ Duration::from_secs(90)
+ );
+ assert_eq!(
+ Cycle::START.gone(start),
+ Duration::ZERO,
+ "a stopped cycle has none"
+ );
+ }
+}
diff --git a/src/project/tests/mod.rs b/src/project/tests/mod.rs
index 6065d0f..1019f83 100644
--- a/src/project/tests/mod.rs
+++ b/src/project/tests/mod.rs
@@ -8,6 +8,7 @@ use super::{AssigneePicker, IssueId, Person, Priority, Status, StatusSelect, Tas
use crate::{documents::Checklist, forms::bind_keys, primitives::FocusNext, theme::Theme};
mod boards;
+mod time;
/// A view that shows one project part and keeps what it heard.
struct Desk {
diff --git a/src/project/tests/time.rs b/src/project/tests/time.rs
new file mode 100644
index 0000000..4bacc82
--- /dev/null
+++ b/src/project/tests/time.rs
@@ -0,0 +1,86 @@
+use std::time::Duration;
+
+use gpui::{AnyElement, Entity, IntoElement, TestAppContext};
+use jiff::civil::date;
+
+use super::{Desk, desk, heard, note, press, settle, tab_to};
+use crate::project::{DatabaseView, PomodoroTimer, Status, Task, TimeEntry, TimeTracker};
+
+fn pomodoro(owner: Entity) -> AnyElement {
+ PomodoroTimer::new("pomodoro")
+ .minutes(2, 1, 3)
+ .rounds(2)
+ .on_phase(move |phase, _, cx| note(&owner, phase.words().into(), cx))
+ .into_any_element()
+}
+
+#[gpui::test]
+fn a_paused_phase_keeps_its_time_and_one_run_out_hands_on_the_next(cx: &mut TestAppContext) {
+ let (host, cx) = desk(pomodoro, cx);
+ tab_to(2, cx);
+ press("space", cx);
+ cx.executor().advance_clock(Duration::from_secs(61));
+ settle(cx);
+ press("space", cx);
+ cx.executor().advance_clock(Duration::from_secs(600));
+ settle(cx);
+ assert!(
+ heard(&host, cx).is_empty(),
+ "a paused minute of two stays short"
+ );
+ press("space", cx);
+ cx.executor().advance_clock(Duration::from_secs(60));
+ settle(cx);
+ assert_eq!(heard(&host, cx), ["Short break"]);
+}
+
+fn tracker(owner: Entity) -> AnyElement {
+ let entry = TimeEntry::new("e1", "Brief", Duration::from_secs(600));
+ TimeTracker::new("tracker", [entry])
+ .on_start(move |task, _, _, cx| note(&owner, format!("start {task}"), cx))
+ .into_any_element()
+}
+
+#[gpui::test]
+fn a_named_task_starts_on_enter_and_a_blank_one_does_not(cx: &mut TestAppContext) {
+ let (host, cx) = desk(tracker, cx);
+ tab_to(1, cx);
+ press("enter", cx);
+ cx.simulate_input(" Site photos ");
+ press("enter", cx);
+ assert_eq!(heard(&host, cx), ["start Site photos"]);
+}
+
+fn database(owner: Entity) -> AnyElement {
+ let tasks = [
+ Task::new("a", "Alpha")
+ .starts(date(2026, 9, 20))
+ .due(date(2026, 9, 30)),
+ Task::new("b", "Beta").status(Status::Done),
+ ];
+ DatabaseView::new("db", tasks)
+ .today(date(2026, 9, 27))
+ .on_status(move |key, status, _, cx| note(&owner, format!("{key} {}", status.words()), cx))
+ .into_any_element()
+}
+
+#[gpui::test]
+fn the_switch_shows_each_way_and_the_board_moves_a_record(cx: &mut TestAppContext) {
+ let (host, cx) = desk(database, cx);
+ assert!(cx.debug_bounds("table-row a").is_some(), "a table first");
+ tab_to(5, cx);
+ press("space", cx);
+ assert!(
+ cx.debug_bounds("initiative a").is_some(),
+ "a dated record on the timeline"
+ );
+ assert!(
+ cx.debug_bounds("initiative b").is_none(),
+ "an undated one left off"
+ );
+ tab_to(2, cx);
+ press("space", cx);
+ tab_to(6, cx);
+ press("alt-right", cx);
+ assert_eq!(heard(&host, cx), ["a In progress"]);
+}
diff --git a/src/project/tracker.rs b/src/project/tracker.rs
new file mode 100644
index 0000000..3a801f1
--- /dev/null
+++ b/src/project/tracker.rs
@@ -0,0 +1,254 @@
+use std::{
+ rc::Rc,
+ time::{Duration, Instant},
+};
+
+use gpui::{
+ App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
+ Styled, Task, Window, div,
+};
+
+use crate::{
+ buttons::{Button, ButtonVariant},
+ forms::{Enter, Input, Run, TextInput},
+ primitives::IconName,
+ theme::{ActiveTheme, TextSize},
+ typography::{Ellipsis, format, tabular},
+};
+
+type OnStart = Rc;
+type OnStop = Rc;
+
+/// Time spent on a task.
+#[derive(Clone, Debug, PartialEq)]
+pub struct TimeEntry {
+ pub key: SharedString,
+ pub task: SharedString,
+ pub spent: Duration,
+}
+
+impl TimeEntry {
+ pub fn new(
+ key: impl Into,
+ task: impl Into,
+ spent: Duration,
+ ) -> Self {
+ Self {
+ key: key.into(),
+ task: task.into(),
+ spent,
+ }
+ }
+}
+
+/// Time spent as a tracker counts it, in hours and whole minutes: "1h 35m", "40m".
+pub(crate) fn spent(duration: Duration) -> String {
+ let minutes = duration.as_secs() / 60;
+ match minutes / 60 {
+ 0 => format!("{minutes}m"),
+ hours => format!("{hours}h {}m", minutes % 60),
+ }
+}
+
+/// A timer against a task: a field names it, Start or Enter starts it and Stop ends it, the running time counting up; under it, the entries made and their total. The owner keeps what runs and what was spent.
+#[derive(IntoElement)]
+pub struct TimeTracker {
+ id: ElementId,
+ entries: Vec,
+ running: Option<(SharedString, Instant)>,
+ on_start: Option,
+ on_stop: Option,
+}
+
+impl TimeTracker {
+ pub fn new(id: impl Into, entries: impl IntoIterator
- ) -> Self {
+ Self {
+ id: id.into(),
+ entries: entries.into_iter().collect(),
+ running: None,
+ on_start: None,
+ on_stop: None,
+ }
+ }
+
+ /// The task running and when it started, by the executor's clock.
+ pub fn running(mut self, task: impl Into, since: Instant) -> Self {
+ self.running = Some((task.into(), since));
+ self
+ }
+
+ /// Gets the task named and the moment it starts.
+ pub fn on_start(
+ mut self,
+ handler: impl Fn(&SharedString, Instant, &mut Window, &mut App) + 'static,
+ ) -> Self {
+ self.on_start = Some(Rc::new(handler));
+ self
+ }
+
+ /// Gets the moment the running task stops.
+ pub fn on_stop(mut self, handler: impl Fn(Instant, &mut Window, &mut App) + 'static) -> Self {
+ self.on_stop = Some(Rc::new(handler));
+ self
+ }
+}
+
+impl RenderOnce for TimeTracker {
+ fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let id = self.id.clone();
+ let field = window.use_keyed_state((id.clone(), "task"), cx, |window, cx| {
+ TextInput::new(window, cx).placeholder("What are you working on?")
+ });
+ if self.running.is_some() {
+ window.use_keyed_state((id.clone(), "tick"), cx, |window, cx| -> Task<()> {
+ cx.spawn_in(window, async move |ticker, cx| {
+ loop {
+ cx.background_executor().timer(Duration::from_secs(1)).await;
+ if ticker.update(cx, |_, cx| cx.notify()).is_err() {
+ return;
+ }
+ }
+ })
+ });
+ }
+ let now = cx.background_executor().now();
+ let elapsed = self
+ .running
+ .as_ref()
+ .map_or(Duration::ZERO, |(_, since)| now.duration_since(*since));
+ let start: Run = {
+ let (id, field, on_start) = (id.clone(), field.clone(), self.on_start);
+ Rc::new(move |window, cx| {
+ let task = SharedString::from(field.read(cx).text().trim().to_string());
+ if task.is_empty() {
+ log::info!("time tracker {id:?}: nothing named to time");
+ return;
+ }
+ let now = cx.background_executor().now();
+ log::info!("time tracker {id:?}: start {task}");
+ field.update(cx, |input, cx| input.set_text(String::new(), cx));
+ if let Some(on_start) = &on_start {
+ on_start(&task, now, window, cx);
+ }
+ })
+ };
+ let theme = cx.theme();
+ let colors = &theme.colors;
+ let (task, action) = match &self.running {
+ Some((task, _)) => {
+ let (id, on_stop) = (id.clone(), self.on_stop.clone());
+ (
+ div()
+ .flex_1()
+ .min_w_0()
+ .child(Ellipsis::new(task.clone()))
+ .into_any_element(),
+ Button::new((self.id.clone(), "stop"), "Stop")
+ .icon(IconName::Square)
+ .on_click(move |_, window, cx| {
+ let now = cx.background_executor().now();
+ log::info!("time tracker {id:?}: stop");
+ if let Some(on_stop) = &on_stop {
+ on_stop(now, window, cx);
+ }
+ })
+ .into_any_element(),
+ )
+ }
+ None => {
+ let (typed, pressed) = (start.clone(), start);
+ (
+ div()
+ .flex_1()
+ .min_w_0()
+ .capture_action(move |_: &Enter, window, cx| {
+ cx.stop_propagation();
+ typed(window, cx)
+ })
+ .child(Input::new(&field))
+ .into_any_element(),
+ Button::new((self.id.clone(), "start"), "Start")
+ .variant(ButtonVariant::Primary)
+ .icon(IconName::Play)
+ .on_click(move |_, window, cx| pressed(window, cx))
+ .into_any_element(),
+ )
+ }
+ };
+ let clock = tabular(div())
+ .flex_none()
+ .text_size(theme.text_size(TextSize::Lg))
+ .child(format::duration(
+ elapsed.as_secs(),
+ format::DurationStyle::Clock,
+ ));
+ let quiet = |text: String| {
+ tabular(div())
+ .flex_none()
+ .text_size(theme.text_size(TextSize::Sm))
+ .text_color(colors.fg_muted)
+ .child(text)
+ };
+ let total: Duration = self.entries.iter().map(|entry| entry.spent).sum();
+ let rows = self.entries.iter().map(|entry| {
+ div()
+ .flex()
+ .items_center()
+ .gap_3()
+ .child(
+ div()
+ .flex_1()
+ .min_w_0()
+ .child(Ellipsis::new(entry.task.clone())),
+ )
+ .child(quiet(spent(entry.spent)))
+ });
+ div()
+ .debug_selector(|| "time-tracker".into())
+ .flex()
+ .flex_col()
+ .gap_4()
+ .child(
+ div()
+ .flex()
+ .items_center()
+ .gap_3()
+ .child(task)
+ .child(clock)
+ .child(action),
+ )
+ .child(
+ div()
+ .flex()
+ .flex_col()
+ .gap_2()
+ .children(rows.collect::>()),
+ )
+ .child(
+ div()
+ .flex()
+ .items_center()
+ .justify_between()
+ .gap_3()
+ .pt_2()
+ .border_t_1()
+ .border_color(colors.border)
+ .child(quiet("Total".into()))
+ .child(quiet(spent(total))),
+ )
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::time::Duration;
+
+ use super::spent;
+
+ #[test]
+ fn time_spent_reads_in_hours_and_whole_minutes() {
+ let words =
+ [95 * 60, 40 * 60 + 59, 59, 3 * 3600].map(|secs| spent(Duration::from_secs(secs)));
+ assert_eq!(words, ["1h 35m", "40m", "0m", "3h 0m"]);
+ }
+}
diff --git a/src/project/work.rs b/src/project/work.rs
index 41664c8..82e10d5 100644
--- a/src/project/work.rs
+++ b/src/project/work.rs
@@ -172,7 +172,7 @@ impl Person {
}
}
-/// A piece of work: its key, its issue's name if it has one, what it is, where it stands, how urgent, when it is due, who has it, and its labels.
+/// A piece of work: its key, its issue's name if it has one, what it is, where it stands, how urgent, when it starts and is due, who has it, and its labels.
#[derive(Clone, Debug, PartialEq)]
pub struct Task {
pub key: SharedString,
@@ -180,6 +180,7 @@ pub struct Task {
pub title: SharedString,
pub status: Status,
pub priority: Priority,
+ pub start: Option,
pub due: Option,
pub assignee: Option,
pub labels: Vec