Skip to main content

max / goingson

got says whether a task is available The board mirrored the Work tab and said nothing about the blocking graph, so a blocked task read there as work anyone could pick up. Same two markers the GUI carries, on the same terms: only an unavailable task is marked, since readiness is the ordinary case and badging it would put one on nearly every row; a cycle gets its own marker because it can never open; and unblocks N appears only where it is actionable, on a task that is startable and frees something. Sorting moves to effective_urgency. The stored urgency column knows nothing about the graph, so ordering by it disagreed with both the GUI and /threads about what to do next. `a` narrows the pane to the ready set. That is a list_ready read rather than a filter over the loaded tasks: readiness is more than depth zero, the query also drops snoozed tasks and cycles, and restating the rule here is how got would come to disagree again.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-10 13:46 UTC
Signed with PGP, not checked
Commit: d997a1ca8bfacd7fb5582bd3edba22073709f6be
Parent: 5e16cc1
2 files changed, +114 insertions, -18 deletions
@@ -17,6 +17,10 @@
17 17 blockers, showing both what a task waits on and what waits on it.
18 18 - Dependency cycles are refused when drawn and reported when they arrive
19 19 through sync, since nothing on a cycle can ever become available.
20 + - `got`, the terminal board, follows the same rules: a task row is marked when
21 + it is blocked or on a cycle, a startable task says how much finishing it
22 + frees, `a` narrows the Tasks pane to what can be started right now, and the
23 + list is ordered by the same score the desktop app sorts by.
20 24
21 25 ## [0.5.0] — 2026-07-16
22 26
@@ -11,7 +11,8 @@
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,8 +27,8 @@
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,10 +235,17 @@
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,6 +262,7 @@
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,25 +295,28 @@
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,6 +355,13 @@
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,9 +370,24 @@
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,6 +410,7 @@
376 410 Ok(Loaded {
377 411 projects,
378 412 tasks,
413 + ready,
379 414 events,
380 415 active_timer,
381 416 time_summary,
@@ -400,11 +435,16 @@
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,6 +654,12 @@
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,6 +697,13 @@
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,6 +711,7 @@
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,6 +851,7 @@
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,6 +863,43 @@
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,