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:
1 parent
943862caad
commit
f70726476a
19 files changed
+1294
-23
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 | |
|
||||
@@ -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 |
@@ -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()
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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`
|
||||
|
||||
|
||||
Reference in new issue
Block a user