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