Project · Time and databases: a time tracker, a pomodoro, a database

TimeTracker times a task: a field names it, Start or Enter starts the
clock and Stop ends it, the running time counting up by the executor's
clock; the entries sit under it in hours and minutes with their total.
The owner keeps what runs and what was spent, and a blank name starts
nothing.

PomodoroTimer runs focus, 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, Pause banks the time gone, and a phase that runs out
moves on, waits for Start, and reaches the owner through window.defer.
motion::ProgressRing gains a size and content inside, its stroke kept
at the theme ring's weight, the content outside the per-turn glide.

DatabaseView shows tasks five ways, switched from a strip that scrolls
when narrow: a table, a board by status whose moves hand on the new
status, a gallery of issue cards, a month calendar by due day, and a
timeline from start to due, which counts the records without dates.
Task gains a start day.

Along the way: table rows, groups and details fill with min_w_full, so
they stretch in the table's column and fill a virtual list alike;
before, a table in a measured column drew rows at their words' width.
The gallery script logs each probe it aims at.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-27 06:11:38 -04:00
1 parent 943862caad
commit f70726476a
19 files changed
+1294 -23

No files matched your search

+2 -2
View File
@@ -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.
+2 -1
View File
@@ -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 | |
+18
View File
@@ -0,0 +1,18 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-chart-gantt"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 6h8" />
<path d="M12 16h6" />
<path d="M3 3v16a2 2 0 0 0 2 2h16" />
<path d="M8 11h7" />
</svg>

After

Width:  |  Height:  |  Size: 400 B

+16
View File
@@ -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<Task> {
.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<Task> {
.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<Task> {
.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()
}
+110
View File
@@ -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::<SharedString>, 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)))
}
+5 -2
View File
@@ -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)
}
+39 -9
View File
@@ -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<Rems>,
inside: Option<AnyElement>,
}
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)
}
}
+1
View File
@@ -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",
+345
View File
@@ -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<dyn Fn(&SharedString, Status, &mut Window, &mut App)>;
/// 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<Task>,
layout: DatabaseLayout,
today: Option<Date>,
on_open: Option<OnValue>,
on_status: Option<OnStatus>,
}
impl DatabaseView {
pub fn new(id: impl Into<ElementId>, tasks: impl IntoIterator<Item = Task>) -> 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::<Vec<_>>())
.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::<Vec<_>>())
.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<Initiative> = 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)
}
}
+6
View File
@@ -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};
+349
View File
@@ -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<dyn Fn(Phase, &mut Window, &mut App)>;
/// 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<Instant>,
}
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<OnPhase>,
}
impl PomodoroTimer {
/// Twenty-five minutes of focus, breaks of five and fifteen, four rounds.
pub fn new(id: impl Into<ElementId>) -> 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<Cycle>,
lengths: Lengths,
on_phase: Option<OnPhase>,
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"
);
}
}
+1
View File
@@ -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 {
+86
View File
@@ -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<Desk>) -> 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<Desk>) -> 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<Desk>) -> 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"]);
}
+254
View File
@@ -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<dyn Fn(&SharedString, Instant, &mut Window, &mut App)>;
type OnStop = Rc<dyn Fn(Instant, &mut Window, &mut App)>;
/// 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<SharedString>,
task: impl Into<SharedString>,
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<TimeEntry>,
running: Option<(SharedString, Instant)>,
on_start: Option<OnStart>,
on_stop: Option<OnStop>,
}
impl TimeTracker {
pub fn new(id: impl Into<ElementId>, entries: impl IntoIterator<Item = TimeEntry>) -> 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<SharedString>, 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::<Vec<_>>()),
)
.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"]);
}
}
+9 -1
View File
@@ -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<Date>,
pub due: Option<Date>,
pub assignee: Option<Person>,
pub labels: Vec<Label>,
@@ -193,6 +194,7 @@ impl Task {
title: title.into(),
status: Status::Todo,
priority: Priority::None,
start: None,
due: None,
assignee: None,
labels: Vec::new(),
@@ -214,6 +216,12 @@ impl Task {
self
}
/// The day work begins, for a timeline.
pub fn starts(mut self, day: Date) -> Self {
self.start = Some(day);
self
}
pub fn due(mut self, day: Date) -> Self {
self.due = Some(day);
self
+5 -3
View File
@@ -127,7 +127,7 @@ impl Body {
let content = detail(&key, window, cx);
let colors = &cx.theme().colors;
div()
.w_full()
.min_w_full()
.pl(self.lead_width())
.pr_3()
.py_3()
@@ -152,7 +152,7 @@ impl Body {
let colors = &theme.colors;
let row = div()
.id((self.id.clone(), format!("group-{name}-{lead}")))
.w_full()
.min_w_full()
.h(self.height)
.flex()
.items_center()
@@ -204,11 +204,13 @@ impl Body {
let colors = &theme.colors;
let press = self.select.clone();
let open = self.open.contains(&key);
let named = key.clone();
div()
.id((self.id.clone(), format!("row-{key}-{lead}")))
.debug_selector(move || format!("table-row {named}"))
.flex()
.items_center()
.w_full()
.min_w_full()
.h(self.height)
.border_b_1()
.border_color(colors.border)
+40 -2
View File
@@ -1,8 +1,8 @@
use std::{cell::RefCell, rc::Rc};
use gpui::{
Context, IntoElement, KeyUpEvent, Keystroke, Modifiers, Render, Styled, TestAppContext, Window,
point, px,
Context, IntoElement, KeyUpEvent, Keystroke, Modifiers, ParentElement, Render, Styled,
TestAppContext, Window, div, point, px,
};
use super::{edit_at, settle};
@@ -151,3 +151,41 @@ fn a_focused_header_past_the_edge_scrolls_into_view(cx: &mut TestAppContext) {
"the last header's column sits at the box's end"
);
}
/// A table in a column beside a long line, in a box its row measures by content.
struct Exposed;
impl Render for Exposed {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let table = DataTable::new(
"exposed",
[Column::new("name", "Name"), Column::new("note", "Note")],
)
.rows(vec![
Row::new("a", ["A".into(), "".into()]),
Row::new("b", ["A much longer name".into(), "Note".into()]),
]);
let line = "A line long enough to wrap in this box, so the column fills it, and more.";
div()
.flex()
.child(div().child(div().flex().flex_col().child(table).child(line)))
}
}
#[gpui::test]
fn rows_span_the_table_whatever_their_words(cx: &mut TestAppContext) {
cx.update(Theme::init);
let (_, cx) = cx.add_window_view(|_, _| Exposed);
cx.run_until_parked();
let short = cx
.debug_bounds("table-row a")
.expect("row a draws")
.size
.width;
let long = cx
.debug_bounds("table-row b")
.expect("row b draws")
.size
.width;
assert_eq!(short, long, "a short row keeps the long row's width");
}
+3
View File
@@ -9,6 +9,8 @@ pub struct ProjectSizes {
pub column: Rems,
/// A roadmap's month at least, wide.
pub month: Rems,
/// A pomodoro's ring, wide.
pub pomodoro: Rems,
}
impl Theme {
@@ -16,6 +18,7 @@ impl Theme {
ProjectSizes {
column: px_to_rems(272.0),
month: px_to_rems(72.0),
pomodoro: px_to_rems(176.0),
}
}
}
+3 -3
View File
@@ -29,9 +29,9 @@
## T31d Project · Time and databases
- [ ] TimeTracker
- [ ] Pomodoro Timer
- [ ] DatabaseView (from T21c) — table, board, gallery, calendar, timeline
- [x] TimeTracker
- [x] Pomodoro Timer
- [x] DatabaseView (from T21c) — table, board, gallery, calendar, timeline
## T32 Canvas & Design — `src/canvas`