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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
28973fe1d2
commit
6377f926c8
19 files changed
+1294
-23
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user