| 11 |
11 |
|
//! Events, Timer, and Contacts. Email and the review views are deliberately
|
| 12 |
12 |
|
//! absent, the desktop and web clients are the better surface for those.
|
| 13 |
13 |
|
//! `Tab`/`Shift+Tab` cycle groups, number keys and the arrows pick a pane
|
| 14 |
|
- |
//! within the active group, `j/k` scroll, `r` refreshes.
|
|
14 |
+ |
//! within the active group, `j/k` scroll, `r` refreshes, `a` narrows the Tasks
|
|
15 |
+ |
//! pane to what can be started right now.
|
| 15 |
16 |
|
//!
|
| 16 |
17 |
|
//! Usage: `got [--db <path>]`
|
| 17 |
18 |
|
|
| 26 |
27 |
|
use chrono::{Datelike, Duration, Local, NaiveDate, TimeZone, Utc};
|
| 27 |
28 |
|
use goingson_core::{
|
| 28 |
29 |
|
Contact, ContactRepository, Event, EventRepository, Priority, Project, ProjectRepository,
|
| 29 |
|
- |
ProjectStatus, ProjectType, Task, TaskCrud, TaskStatus, TaskTimeTracking, TimeSession,
|
| 30 |
|
- |
TimeSummaryProject, TimeTrackingSummary, UserId, roll_up_time_summary,
|
|
30 |
+ |
ProjectStatus, ProjectType, Task, TaskCrud, TaskDependencies, TaskStatus, TaskTimeTracking,
|
|
31 |
+ |
TimeSession, TimeSummaryProject, TimeTrackingSummary, UserId, roll_up_time_summary,
|
| 31 |
32 |
|
};
|
| 32 |
33 |
|
use goingson_db_sqlite::Db;
|
| 33 |
34 |
|
use goingson_db_sqlite::{
|
| 234 |
235 |
|
timer: TimerView,
|
| 235 |
236 |
|
contacts: Vec<Contact>,
|
| 236 |
237 |
|
contact_state: ListState,
|
|
238 |
+ |
/// Whether the Tasks pane is narrowed to what can be started right now.
|
|
239 |
+ |
ready_only: bool,
|
| 237 |
240 |
|
status: String,
|
| 238 |
241 |
|
theme: Theme,
|
| 239 |
242 |
|
}
|
| 240 |
243 |
|
|
|
244 |
+ |
/// How far past the ready set the `a` filter looks. Zero is startable now,
|
|
245 |
+ |
/// which is the only depth got offers: the pane answers "what can I pick up",
|
|
246 |
+ |
/// and `/threads --depth` is where planning further out belongs.
|
|
247 |
+ |
const READY_DEPTH: u32 = 0;
|
|
248 |
+ |
|
| 241 |
249 |
|
impl App {
|
| 242 |
250 |
|
fn new(db_path: PathBuf, theme: Theme) -> Self {
|
| 243 |
251 |
|
Self {
|
| 254 |
262 |
|
timer: TimerView::default(),
|
| 255 |
263 |
|
contacts: Vec::new(),
|
| 256 |
264 |
|
contact_state: ListState::default(),
|
|
265 |
+ |
ready_only: false,
|
| 257 |
266 |
|
status: String::new(),
|
| 258 |
267 |
|
}
|
| 259 |
268 |
|
}
|
| 286 |
295 |
|
let Loaded {
|
| 287 |
296 |
|
projects,
|
| 288 |
297 |
|
tasks,
|
|
298 |
+ |
ready,
|
| 289 |
299 |
|
events,
|
| 290 |
300 |
|
active_timer,
|
| 291 |
301 |
|
time_summary,
|
| 292 |
302 |
|
sessions,
|
| 293 |
303 |
|
contacts,
|
| 294 |
|
- |
} = load(db)?;
|
|
304 |
+ |
} = load(db, self.ready_only)?;
|
| 295 |
305 |
|
self.projects = build_projects(&projects, &tasks);
|
| 296 |
306 |
|
self.events = build_events(events);
|
| 297 |
307 |
|
self.timer = build_timer(active_timer, time_summary, sessions, &tasks);
|
| 298 |
308 |
|
self.contacts = build_contacts(contacts);
|
| 299 |
|
- |
self.rows = build_board(projects, tasks);
|
| 300 |
|
- |
self.status = format!(
|
| 301 |
|
- |
"{} open task(s) from {}",
|
| 302 |
|
- |
self.rows
|
| 303 |
|
- |
.iter()
|
| 304 |
|
- |
.filter(|r| matches!(r, Row::Task(_)))
|
| 305 |
|
- |
.count(),
|
| 306 |
|
- |
self.db_path.display()
|
| 307 |
|
- |
);
|
|
309 |
+ |
self.rows = build_board(projects, ready.as_deref().unwrap_or(&tasks));
|
|
310 |
+ |
let open = self
|
|
311 |
+ |
.rows
|
|
312 |
+ |
.iter()
|
|
313 |
+ |
.filter(|r| matches!(r, Row::Task(_)))
|
|
314 |
+ |
.count();
|
|
315 |
+ |
self.status = if self.ready_only {
|
|
316 |
+ |
format!("{open} ready task(s) from {}", self.db_path.display())
|
|
317 |
+ |
} else {
|
|
318 |
+ |
format!("{open} open task(s) from {}", self.db_path.display())
|
|
319 |
+ |
};
|
| 308 |
320 |
|
clamp_selection(&mut self.state, self.rows.len());
|
| 309 |
321 |
|
clamp_selection(&mut self.proj_state, self.projects.len());
|
| 310 |
322 |
|
clamp_selection(&mut self.event_state, self.events.len());
|
| 343 |
355 |
|
struct Loaded {
|
| 344 |
356 |
|
projects: Vec<Project>,
|
| 345 |
357 |
|
tasks: Vec<Task>,
|
|
358 |
+ |
/// The startable subset, present only while the ready-only filter is on.
|
|
359 |
+ |
///
|
|
360 |
+ |
/// A separate read rather than a filter over `tasks`, because readiness is
|
|
361 |
+ |
/// more than `block_depth == 0`: the repository query also drops snoozed
|
|
362 |
+ |
/// tasks and tasks on a cycle. Filtering here would quietly disagree with
|
|
363 |
+ |
/// the GUI and with `/threads`, which both run that query.
|
|
364 |
+ |
ready: Option<Vec<Task>>,
|
| 346 |
365 |
|
events: Vec<Event>,
|
| 347 |
366 |
|
active_timer: Option<(TimeSession, String)>,
|
| 348 |
367 |
|
time_summary: Vec<TimeTrackingSummary>,
|
| 351 |
370 |
|
}
|
| 352 |
371 |
|
|
| 353 |
372 |
|
/// Read every pane's data for the desktop user in one pass.
|
| 354 |
|
- |
fn load(db: &Db) -> Result<Loaded> {
|
|
373 |
+ |
///
|
|
374 |
+ |
/// `tasks` is always the whole set: the Projects pane counts open work per
|
|
375 |
+ |
/// project and the Timer pane resolves session ids back to descriptions, and
|
|
376 |
+ |
/// neither question is about readiness. `ready_only` adds the startable set
|
|
377 |
+ |
/// alongside it for the Tasks pane.
|
|
378 |
+ |
fn load(db: &Db, ready_only: bool) -> Result<Loaded> {
|
| 355 |
379 |
|
let projects = SqliteProjectRepository::new(db.clone()).list_all(DESKTOP_USER_ID)?;
|
| 356 |
380 |
|
let tasks = SqliteTaskRepository::new(db.clone()).list_all(DESKTOP_USER_ID)?;
|
|
381 |
+ |
let ready = ready_only
|
|
382 |
+ |
.then(|| {
|
|
383 |
+ |
SqliteTaskRepository::new(db.clone()).list_ready(
|
|
384 |
+ |
DESKTOP_USER_ID,
|
|
385 |
+ |
None,
|
|
386 |
+ |
READY_DEPTH,
|
|
387 |
+ |
None,
|
|
388 |
+ |
)
|
|
389 |
+ |
})
|
|
390 |
+ |
.transpose()?;
|
| 357 |
391 |
|
let events =
|
| 358 |
392 |
|
SqliteEventRepository::new(db.clone()).get_upcoming(DESKTOP_USER_ID, EVENT_HORIZON_DAYS)?;
|
| 359 |
393 |
|
|
| 376 |
410 |
|
Ok(Loaded {
|
| 377 |
411 |
|
projects,
|
| 378 |
412 |
|
tasks,
|
|
413 |
+ |
ready,
|
| 379 |
414 |
|
events,
|
| 380 |
415 |
|
active_timer,
|
| 381 |
416 |
|
time_summary,
|
| 400 |
435 |
|
}
|
| 401 |
436 |
|
|
| 402 |
437 |
|
/// Group open tasks under their project, projects with work first, and flatten.
|
| 403 |
|
- |
fn build_board(projects: Vec<Project>, tasks: Vec<Task>) -> Vec<Row> {
|
| 404 |
|
- |
let mut open: Vec<Task> = tasks.into_iter().filter(is_open).collect();
|
|
438 |
+ |
fn build_board(projects: Vec<Project>, tasks: &[Task]) -> Vec<Row> {
|
|
439 |
+ |
let mut open: Vec<Task> = tasks.iter().filter(|t| is_open(t)).cloned().collect();
|
|
440 |
+ |
// `effective_urgency`, not the stored `urgency` column: the stored one is a
|
|
441 |
+ |
// pure function of the task's own fields and knows nothing about the graph,
|
|
442 |
+ |
// so sorting by it would put got's order at odds with both the GUI and
|
|
443 |
+ |
// /threads. The graph term is what demotes a blocked task and promotes one
|
|
444 |
+ |
// that frees others.
|
| 405 |
445 |
|
open.sort_by(|a, b| {
|
| 406 |
|
- |
b.urgency
|
| 407 |
|
- |
.partial_cmp(&a.urgency)
|
|
446 |
+ |
b.effective_urgency()
|
|
447 |
+ |
.partial_cmp(&a.effective_urgency())
|
| 408 |
448 |
|
.unwrap_or(std::cmp::Ordering::Equal)
|
| 409 |
449 |
|
});
|
| 410 |
450 |
|
|
| 614 |
654 |
|
KeyCode::Char('j') | KeyCode::Down => app.move_selection(1),
|
| 615 |
655 |
|
KeyCode::Char('k') | KeyCode::Up => app.move_selection(-1),
|
| 616 |
656 |
|
KeyCode::Char('r') => app.reload(db)?,
|
|
657 |
+ |
// The filter is a different read, not a view over what is
|
|
658 |
+ |
// already loaded, so toggling it refreshes.
|
|
659 |
+ |
KeyCode::Char('a') => {
|
|
660 |
+ |
app.ready_only = !app.ready_only;
|
|
661 |
+ |
app.reload(db)?;
|
|
662 |
+ |
}
|
| 617 |
663 |
|
_ => {}
|
| 618 |
664 |
|
}
|
| 619 |
665 |
|
}
|
| 651 |
697 |
|
|
| 652 |
698 |
|
draw_pane(f, app, p, body[2]);
|
| 653 |
699 |
|
|
|
700 |
+ |
// The `a` hint states which way the toggle goes, so the footer says what
|
|
701 |
+ |
// pressing it does rather than which state the pane is in.
|
|
702 |
+ |
let ready_hint = if app.ready_only {
|
|
703 |
+ |
hint("a", "all tasks")
|
|
704 |
+ |
} else {
|
|
705 |
+ |
hint("a", "ready only")
|
|
706 |
+ |
};
|
| 654 |
707 |
|
f.render_widget(
|
| 655 |
708 |
|
StatusBar::new(
|
| 656 |
709 |
|
&p,
|
| 658 |
711 |
|
hint("Tab", "group"),
|
| 659 |
712 |
|
hint("1-9 / ←→", "pane"),
|
| 660 |
713 |
|
hint("j/k", "move"),
|
|
714 |
+ |
ready_hint,
|
| 661 |
715 |
|
hint("r", "refresh"),
|
| 662 |
716 |
|
hint("q", "quit"),
|
| 663 |
717 |
|
],
|
| 797 |
851 |
|
Span::styled(mark, Style::default().fg(mark_color)),
|
| 798 |
852 |
|
Span::styled(t.description.clone(), Style::default().fg(prio_color)),
|
| 799 |
853 |
|
];
|
|
854 |
+ |
spans.extend(graph_marker(p, t));
|
| 800 |
855 |
|
if let Some(due) = t.due {
|
| 801 |
856 |
|
spans.push(Span::styled(
|
| 802 |
857 |
|
format!(" due {}", due.format("%Y-%m-%d")),
|
| 808 |
863 |
|
}
|
| 809 |
864 |
|
}
|
| 810 |
865 |
|
|
|
866 |
+ |
/// The task row's blocking-graph marker, mirroring the GUI's two badges.
|
|
867 |
+ |
///
|
|
868 |
+ |
/// Only an unavailable task is marked. Readiness is the ordinary case and the
|
|
869 |
+ |
/// unmarked default, so badging it would put one on nearly every row and say
|
|
870 |
+ |
/// nothing. A task on a cycle gets its own marker instead of reading as merely
|
|
871 |
+ |
/// blocked, because it can never open: it wants repair, not patience.
|
|
872 |
+ |
///
|
|
873 |
+ |
/// `unblocks N` is the mirror image, shown only on a task that is startable
|
|
874 |
+ |
/// AND frees something. On a blocked task the count is true but not actionable,
|
|
875 |
+ |
/// so it would compete with the marker that is.
|
|
876 |
+ |
fn graph_marker(p: Theme, t: &Task) -> Vec<Span<'static>> {
|
|
877 |
+ |
if t.graph.in_cycle {
|
|
878 |
+ |
return vec![Span::styled(
|
|
879 |
+ |
" [cycle]",
|
|
880 |
+ |
Style::default()
|
|
881 |
+ |
.fg(p.status_danger)
|
|
882 |
+ |
.add_modifier(Modifier::BOLD),
|
|
883 |
+ |
)];
|
|
884 |
+ |
}
|
|
885 |
+ |
if t.is_blocked() {
|
|
886 |
+ |
let depth = t.graph.block_depth;
|
|
887 |
+ |
let label = if depth == 1 {
|
|
888 |
+ |
" [blocked]".to_string()
|
|
889 |
+ |
} else {
|
|
890 |
+ |
format!(" [blocked {depth}]")
|
|
891 |
+ |
};
|
|
892 |
+ |
return vec![Span::styled(label, Style::default().fg(p.status_warning))];
|
|
893 |
+ |
}
|
|
894 |
+ |
match t.unblocks_count() {
|
|
895 |
+ |
0 => Vec::new(),
|
|
896 |
+ |
n => vec![Span::styled(
|
|
897 |
+ |
format!(" unblocks {n}"),
|
|
898 |
+ |
Style::default().fg(p.status_info),
|
|
899 |
+ |
)],
|
|
900 |
+ |
}
|
|
901 |
+ |
}
|
|
902 |
+ |
|
| 811 |
903 |
|
fn project_item<'a>(p: Theme, row: &'a ProjectRow) -> ListItem<'a> {
|
| 812 |
904 |
|
let status_color = match row.status {
|
| 813 |
905 |
|
ProjectStatus::Active => p.status_success,
|