Skip to main content

max / goingson

36.7 KB · 1109 lines History Blame Raw
1 //! got, a read-first terminal cockpit over GoingsOn's local `goingson.db`.
2 //!
3 //! First consumer of `alloy_tui` (Alloy's themed-ratatui layer). Opens the same
4 //! SQLite file the desktop app uses as a peer reader, exactly like `go-mcp`
5 //! (see `crates/go-mcp`): links the domain crates directly, resolves the same
6 //! default DB path, and reads through the normal repository layer.
7 //!
8 //! The shell mirrors the desktop app's two-level pill-nav: three top-level
9 //! groups (Work / Time / Messages) each holding a set of panes. It is a slimmed
10 //! down GoingsOn: Tasks and Projects (the pathways `go-mcp` also covers) plus
11 //! Events, Timer, and Contacts. Email and the review views are deliberately
12 //! absent, the desktop and web clients are the better surface for those.
13 //! `Tab`/`Shift+Tab` cycle groups, number keys and the arrows pick a pane
14 //! within the active group, `j/k` scroll, `r` refreshes.
15 //!
16 //! Usage: `got [--db <path>]`
17
18 use std::collections::HashMap;
19 use std::io::{self, Stdout};
20 use std::path::PathBuf;
21
22 use alloy_tui::{AlloyStatusBar, Theme, hint};
23 use anyhow::{Context, Result};
24 use chrono::{Datelike, Duration, Local, NaiveDate, TimeZone, Utc};
25 use goingson_core::{
26 Contact, ContactRepository, Event, EventRepository, Priority, Project, ProjectRepository,
27 ProjectStatus, ProjectType, Task, TaskCrud, TaskStatus, TaskTimeTracking, TimeSession,
28 TimeSummaryProject, TimeTrackingSummary, UserId, roll_up_time_summary,
29 };
30 use goingson_db_sqlite::{
31 SqliteContactRepository, SqliteEventRepository, SqliteProjectRepository, SqliteTaskRepository,
32 init_pool,
33 };
34 use ratatui::Terminal;
35 use ratatui::crossterm::event::{self, Event as CtEvent, KeyCode, KeyEventKind};
36 use ratatui::crossterm::{execute, terminal};
37 use ratatui::layout::{Alignment, Constraint, Direction, Layout};
38 use ratatui::prelude::CrosstermBackend;
39 use ratatui::style::{Modifier, Style};
40 use ratatui::text::{Line, Span};
41 use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
42 use sqlx::SqlitePool;
43 use uuid::Uuid;
44
45 /// Fixed single-user desktop id. MUST match `DESKTOP_USER_ID` in `go-mcp`
46 /// (`crates/go-mcp/src/context.rs`) and the Tauri app (`src-tauri/src/state.rs`);
47 /// tasks are keyed on it, so a mismatch would read an empty set.
48 const DESKTOP_USER_ID: UserId = UserId::from_uuid(Uuid::from_u128(1));
49
50 /// The theme `got` renders in.
51 ///
52 /// `alloy_tui` ships no built-in palette on purpose: a missing or malformed
53 /// theme is an error the user sees, not something papered over by rendering in
54 /// colors that exist nowhere in the theme files. So load one. A theme dropped
55 /// into `~/.config/goingson/themes/` wins; otherwise use the set `makeover`
56 /// bundles, which includes the app's own `goingson` skin.
57 fn load_theme() -> Result<Theme> {
58 let mut dirs = Vec::new();
59 if let Some(home) = std::env::var_os("HOME") {
60 let custom = PathBuf::from(home)
61 .join(".config")
62 .join("goingson")
63 .join("themes");
64 if custom.is_dir() {
65 dirs.push((custom, true));
66 }
67 }
68 dirs.push((
69 makeover::bundled_themes_dir().context("makeover ships no themes directory")?,
70 false,
71 ));
72
73 let id = std::env::var("GOT_THEME").unwrap_or_else(|_| DEFAULT_THEME.to_string());
74 let colors = makeover::load_theme(&dirs, &id)
75 .map_err(anyhow::Error::msg)
76 .with_context(|| format!("loading theme `{id}`"))?;
77 Theme::from_theme(&colors)
78 .map_err(|e| anyhow::anyhow!("{e:?}"))
79 .with_context(|| format!("theme `{id}` is incomplete"))
80 }
81
82 /// The app's own skin, shared with the desktop build.
83 const DEFAULT_THEME: &str = "goingson";
84
85 fn main() -> Result<()> {
86 let db_arg = parse_db_arg()?;
87
88 // We own the runtime rather than using `#[tokio::main]` so the synchronous
89 // event loop can `block_on` a reload whenever the user asks for one.
90 let rt = tokio::runtime::Runtime::new()?;
91
92 let db_path = db_arg
93 .or_else(default_db_path)
94 .context("could not resolve a default goingson.db path; pass --db <path>")?;
95 if !db_path.exists() {
96 anyhow::bail!(
97 "database not found at {}. Run GoingsOn once to create it, or pass --db.",
98 db_path.display()
99 );
100 }
101
102 let pool = rt.block_on(init_pool(Some(&db_path.to_string_lossy())))?;
103
104 let mut app = App::new(db_path, load_theme()?);
105 app.reload(&rt, &pool)?;
106
107 let mut terminal = setup_terminal()?;
108 let result = run(&mut terminal, &mut app, &rt, &pool);
109 restore_terminal(&mut terminal)?;
110 result
111 }
112
113 /// The top-level pill-nav groups, mirroring the desktop app. Email lives under
114 /// the app's "Messages" group but is intentionally not surfaced here.
115 #[derive(Clone, Copy, PartialEq)]
116 enum Group {
117 Work,
118 Time,
119 Messages,
120 }
121
122 impl Group {
123 const ALL: [Group; 3] = [Group::Work, Group::Time, Group::Messages];
124
125 fn label(self) -> &'static str {
126 match self {
127 Group::Work => "Work",
128 Group::Time => "Time",
129 Group::Messages => "Messages",
130 }
131 }
132
133 /// The panes shown within this group, in tab order.
134 fn panes(self) -> &'static [Pane] {
135 match self {
136 Group::Work => &[Pane::Tasks, Pane::Projects],
137 Group::Time => &[Pane::Events, Pane::Timer],
138 Group::Messages => &[Pane::Contacts],
139 }
140 }
141
142 fn index(self) -> usize {
143 Self::ALL.iter().position(|&g| g == self).unwrap_or(0)
144 }
145
146 fn step(self, delta: isize) -> Group {
147 let n = Self::ALL.len() as isize;
148 Self::ALL[(self.index() as isize + delta).rem_euclid(n) as usize]
149 }
150 }
151
152 /// A single pane within a group, each backed by a live read of the local DB.
153 #[derive(Clone, Copy, PartialEq)]
154 enum Pane {
155 Tasks,
156 Projects,
157 Events,
158 Timer,
159 Contacts,
160 }
161
162 impl Pane {
163 fn label(self) -> &'static str {
164 match self {
165 Pane::Tasks => "Tasks",
166 Pane::Projects => "Projects",
167 Pane::Events => "Events",
168 Pane::Timer => "Timer",
169 Pane::Contacts => "Contacts",
170 }
171 }
172 }
173
174 /// A flattened board row: either a project header or one of its open tasks.
175 enum Row {
176 Header { name: String, open: usize },
177 Task(Box<Task>),
178 }
179
180 /// One line on the Projects pane: a project plus its open-task count.
181 struct ProjectRow {
182 name: String,
183 status: ProjectStatus,
184 project_type: ProjectType,
185 open: usize,
186 }
187
188 /// How far ahead the Events pane looks.
189 const EVENT_HORIZON_DAYS: i64 = 30;
190
191 /// A flattened Events-pane row: a day header or one event under it.
192 enum EventRow {
193 DayHeader { label: String, count: usize },
194 Event(Box<Event>),
195 }
196
197 /// How many recent sessions the Timer pane lists.
198 const RECENT_SESSION_LIMIT: usize = 10;
199
200 /// The Timer pane's view: the running timer (if any), this week's tracked
201 /// breakdown (matching the desktop Day-view panel), and recent sessions.
202 #[derive(Default)]
203 struct TimerView {
204 active: Option<ActiveTimer>,
205 today_minutes: i32,
206 week: Vec<TimeSummaryProject>,
207 recent: Vec<RecentSession>,
208 }
209
210 /// The currently running timer, resolved to its task and elapsed minutes.
211 struct ActiveTimer {
212 task: String,
213 elapsed_min: i32,
214 }
215
216 /// One finished session in the Timer pane's recent list.
217 struct RecentSession {
218 day: String,
219 task: String,
220 project: Option<String>,
221 minutes: i32,
222 }
223
224 struct App {
225 db_path: PathBuf,
226 group: Group,
227 /// Index into `group.panes()` of the active pane.
228 pane_idx: usize,
229 rows: Vec<Row>,
230 state: ListState,
231 projects: Vec<ProjectRow>,
232 proj_state: ListState,
233 events: Vec<EventRow>,
234 event_state: ListState,
235 timer: TimerView,
236 contacts: Vec<Contact>,
237 contact_state: ListState,
238 status: String,
239 theme: Theme,
240 }
241
242 impl App {
243 fn new(db_path: PathBuf, theme: Theme) -> Self {
244 Self {
245 db_path,
246 theme,
247 group: Group::Work,
248 pane_idx: 0,
249 rows: Vec::new(),
250 state: ListState::default(),
251 projects: Vec::new(),
252 proj_state: ListState::default(),
253 events: Vec::new(),
254 event_state: ListState::default(),
255 timer: TimerView::default(),
256 contacts: Vec::new(),
257 contact_state: ListState::default(),
258 status: String::new(),
259 }
260 }
261
262 fn current_pane(&self) -> Pane {
263 self.group.panes()[self.pane_idx]
264 }
265
266 /// Switch groups, landing on the new group's first pane.
267 fn cycle_group(&mut self, delta: isize) {
268 self.group = self.group.step(delta);
269 self.pane_idx = 0;
270 }
271
272 /// Move to another pane within the active group, wrapping.
273 fn move_pane(&mut self, delta: isize) {
274 let n = self.group.panes().len() as isize;
275 self.pane_idx = (self.pane_idx as isize + delta).rem_euclid(n) as usize;
276 }
277
278 /// Jump straight to the pane at `idx` (0-based) if the group has one there.
279 fn select_pane(&mut self, idx: usize) {
280 if idx < self.group.panes().len() {
281 self.pane_idx = idx;
282 }
283 }
284
285 /// Fetch projects + open tasks and rebuild every pane's view.
286 fn reload(&mut self, rt: &tokio::runtime::Runtime, pool: &SqlitePool) -> Result<()> {
287 let Loaded {
288 projects,
289 tasks,
290 events,
291 active_timer,
292 time_summary,
293 sessions,
294 contacts,
295 } = rt.block_on(load(pool))?;
296 self.projects = build_projects(&projects, &tasks);
297 self.events = build_events(events);
298 self.timer = build_timer(active_timer, time_summary, sessions, &tasks);
299 self.contacts = build_contacts(contacts);
300 self.rows = build_board(projects, tasks);
301 self.status = format!(
302 "{} open task(s) from {}",
303 self.rows
304 .iter()
305 .filter(|r| matches!(r, Row::Task(_)))
306 .count(),
307 self.db_path.display()
308 );
309 clamp_selection(&mut self.state, self.rows.len());
310 clamp_selection(&mut self.proj_state, self.projects.len());
311 clamp_selection(&mut self.event_state, self.events.len());
312 clamp_selection(&mut self.contact_state, self.contacts.len());
313 Ok(())
314 }
315
316 /// Scroll the active pane's list, wrapping. No-op on panes without a list.
317 fn move_selection(&mut self, delta: isize) {
318 let (state, len) = match self.current_pane() {
319 Pane::Tasks => (&mut self.state, self.rows.len()),
320 Pane::Projects => (&mut self.proj_state, self.projects.len()),
321 Pane::Events => (&mut self.event_state, self.events.len()),
322 Pane::Contacts => (&mut self.contact_state, self.contacts.len()),
323 _ => return,
324 };
325 if len == 0 {
326 return;
327 }
328 let cur = state.selected().unwrap_or(0) as isize;
329 let next = (cur + delta).rem_euclid(len as isize);
330 state.select(Some(next as usize));
331 }
332 }
333
334 /// Keep a list's selection in range after its contents change.
335 fn clamp_selection(state: &mut ListState, len: usize) {
336 if len == 0 {
337 state.select(None);
338 } else {
339 state.select(Some(state.selected().unwrap_or(0).min(len - 1)));
340 }
341 }
342
343 /// Everything the board reads in one refresh.
344 struct Loaded {
345 projects: Vec<Project>,
346 tasks: Vec<Task>,
347 events: Vec<Event>,
348 active_timer: Option<(TimeSession, String)>,
349 time_summary: Vec<TimeTrackingSummary>,
350 sessions: Vec<TimeSession>,
351 contacts: Vec<Contact>,
352 }
353
354 /// Read every pane's data for the desktop user in a single async pass.
355 async fn load(pool: &SqlitePool) -> Result<Loaded> {
356 let projects = SqliteProjectRepository::new(pool.clone())
357 .list_all(DESKTOP_USER_ID)
358 .await?;
359 let tasks = SqliteTaskRepository::new(pool.clone())
360 .list_all(DESKTOP_USER_ID)
361 .await?;
362 let events = SqliteEventRepository::new(pool.clone())
363 .get_upcoming(DESKTOP_USER_ID, EVENT_HORIZON_DAYS)
364 .await?;
365
366 // Time tracking. The week window matches the desktop Day-view panel: Monday
367 // 00:00 local through the next Monday, so our totals agree with the app's.
368 let task_repo = SqliteTaskRepository::new(pool.clone());
369 let active_timer = task_repo.get_active_timer(DESKTOP_USER_ID).await?;
370 let now = Local::now();
371 let week_start =
372 now.date_naive() - Duration::days(i64::from(now.weekday().num_days_from_monday()));
373 let (start, end) = (
374 local_midnight_utc(week_start),
375 local_midnight_utc(week_start + Duration::days(7)),
376 );
377 let time_summary = task_repo
378 .get_time_summary(DESKTOP_USER_ID, start, end)
379 .await?;
380 let sessions = task_repo.list_all_time_sessions(DESKTOP_USER_ID).await?;
381
382 let contacts = SqliteContactRepository::new(pool.clone())
383 .list_all(DESKTOP_USER_ID)
384 .await?;
385
386 Ok(Loaded {
387 projects,
388 tasks,
389 events,
390 active_timer,
391 time_summary,
392 sessions,
393 contacts,
394 })
395 }
396
397 /// Local midnight on `date`, expressed in UTC. Mirrors the desktop app's helper
398 /// so the Timer pane buckets time exactly as the Day view does.
399 fn local_midnight_utc(date: NaiveDate) -> chrono::DateTime<Utc> {
400 let naive = date.and_hms_opt(0, 0, 0).expect("midnight is always valid");
401 Local
402 .from_local_datetime(&naive)
403 .earliest()
404 .map(|dt| dt.with_timezone(&Utc))
405 .unwrap_or_else(|| Utc.from_utc_datetime(&naive))
406 }
407
408 fn is_open(t: &Task) -> bool {
409 matches!(t.status, TaskStatus::Pending | TaskStatus::Started)
410 }
411
412 /// Group open tasks under their project, projects with work first, and flatten.
413 fn build_board(projects: Vec<Project>, tasks: Vec<Task>) -> Vec<Row> {
414 let mut open: Vec<Task> = tasks.into_iter().filter(is_open).collect();
415 open.sort_by(|a, b| {
416 b.urgency
417 .partial_cmp(&a.urgency)
418 .unwrap_or(std::cmp::Ordering::Equal)
419 });
420
421 // Preserve one bucket per project name plus a trailing "(no project)".
422 let mut order: Vec<String> = projects.into_iter().map(|p| p.name).collect();
423 order.sort();
424 const NO_PROJECT: &str = "(no project)";
425 order.push(NO_PROJECT.to_string());
426
427 let mut rows = Vec::new();
428 for name in order {
429 let bucket: Vec<Task> = open
430 .iter()
431 .filter(|t| {
432 let pn = t.project_name.as_deref().unwrap_or(NO_PROJECT);
433 pn == name
434 })
435 .cloned()
436 .collect();
437 if bucket.is_empty() {
438 continue;
439 }
440 rows.push(Row::Header {
441 name,
442 open: bucket.len(),
443 });
444 rows.extend(bucket.into_iter().map(|t| Row::Task(Box::new(t))));
445 }
446 rows
447 }
448
449 /// Build the Projects pane: every project with its open-task count, sorted so
450 /// active work floats to the top and ties break alphabetically.
451 fn build_projects(projects: &[Project], tasks: &[Task]) -> Vec<ProjectRow> {
452 let mut open_counts: HashMap<&str, usize> = HashMap::new();
453 for t in tasks.iter().filter(|t| is_open(t)) {
454 if let Some(name) = t.project_name.as_deref() {
455 *open_counts.entry(name).or_default() += 1;
456 }
457 }
458
459 let mut rows: Vec<ProjectRow> = projects
460 .iter()
461 .map(|p| ProjectRow {
462 name: p.name.clone(),
463 status: p.status.clone(),
464 project_type: p.project_type.clone(),
465 open: open_counts.get(p.name.as_str()).copied().unwrap_or(0),
466 })
467 .collect();
468 rows.sort_by(|a, b| {
469 status_rank(&a.status)
470 .cmp(&status_rank(&b.status))
471 .then_with(|| a.name.cmp(&b.name))
472 });
473 rows
474 }
475
476 /// Sort key for project status: live work first, dormant last.
477 fn status_rank(status: &ProjectStatus) -> u8 {
478 match status {
479 ProjectStatus::Active => 0,
480 ProjectStatus::OnHold => 1,
481 ProjectStatus::Completed => 2,
482 ProjectStatus::Archived => 3,
483 }
484 }
485
486 /// Group upcoming events (already sorted ascending) by local calendar day,
487 /// emitting a day header before each day's events.
488 fn build_events(events: Vec<Event>) -> Vec<EventRow> {
489 let today = Local::now().date_naive();
490
491 let mut groups: Vec<(NaiveDate, Vec<Event>)> = Vec::new();
492 for ev in events {
493 let day = ev.start_time.with_timezone(&Local).date_naive();
494 match groups.last_mut() {
495 Some((d, bucket)) if *d == day => bucket.push(ev),
496 _ => groups.push((day, vec![ev])),
497 }
498 }
499
500 let mut rows = Vec::new();
501 for (day, bucket) in groups {
502 rows.push(EventRow::DayHeader {
503 label: day_label(day, today),
504 count: bucket.len(),
505 });
506 rows.extend(bucket.into_iter().map(|e| EventRow::Event(Box::new(e))));
507 }
508 rows
509 }
510
511 /// A friendly day header: "Today" / "Tomorrow" near now, else "Mon Jul 21".
512 fn day_label(day: NaiveDate, today: NaiveDate) -> String {
513 match (day - today).num_days() {
514 0 => "Today".to_string(),
515 1 => "Tomorrow".to_string(),
516 _ => day.format("%a %b %-d").to_string(),
517 }
518 }
519
520 /// Assemble the Timer pane from the raw timer reads. `tasks` resolves each
521 /// session's task id back to its description and project for display.
522 fn build_timer(
523 active: Option<(TimeSession, String)>,
524 summary: Vec<TimeTrackingSummary>,
525 sessions: Vec<TimeSession>,
526 tasks: &[Task],
527 ) -> TimerView {
528 let active = active.map(|(session, task)| ActiveTimer {
529 task,
530 elapsed_min: session.elapsed_minutes(),
531 });
532
533 // Today's total + the week's per-project breakdown, aggregated exactly as
534 // the desktop Day view does. "Today" is matched on the UTC date the query
535 // buckets by, same as the app.
536 let today = Utc::now().format("%Y-%m-%d").to_string();
537 let panel = roll_up_time_summary(&summary, &today);
538
539 // Most recent finished sessions, newest first.
540 let mut finished: Vec<TimeSession> = sessions
541 .into_iter()
542 .filter(|s| s.ended_at.is_some())
543 .collect();
544 finished.sort_by_key(|s| std::cmp::Reverse(s.started_at));
545 let recent = finished
546 .into_iter()
547 .take(RECENT_SESSION_LIMIT)
548 .map(|s| {
549 let task = tasks.iter().find(|t| t.id == s.task_id);
550 RecentSession {
551 day: s
552 .started_at
553 .with_timezone(&Local)
554 .format("%b %-d")
555 .to_string(),
556 task: task
557 .map(|t| t.description.clone())
558 .unwrap_or_else(|| "(unknown task)".to_string()),
559 project: task.and_then(|t| t.project_name.clone()),
560 minutes: s.duration_minutes.unwrap_or_else(|| s.elapsed_minutes()),
561 }
562 })
563 .collect();
564
565 TimerView {
566 active,
567 today_minutes: panel.today_minutes,
568 week: panel.projects,
569 recent,
570 }
571 }
572
573 /// Sort contacts for the Contacts pane: alphabetical by display name,
574 /// case-insensitive.
575 fn build_contacts(mut contacts: Vec<Contact>) -> Vec<Contact> {
576 contacts.sort_by_key(|c| c.display_name.to_lowercase());
577 contacts
578 }
579
580 /// Format a minute count as "2h 05m" or "45m".
581 fn fmt_minutes(min: i32) -> String {
582 let (h, m) = (min / 60, min % 60);
583 if h > 0 {
584 format!("{h}h {m:02}m")
585 } else {
586 format!("{m}m")
587 }
588 }
589
590 /// A fixed-width bar of block characters for `percent` (0-100) of `width`.
591 fn bar(percent: i32, width: usize) -> String {
592 let filled = ((percent.clamp(0, 100) as usize * width) + 50) / 100;
593 let filled = filled.min(width);
594 "".repeat(filled) + &"".repeat(width - filled)
595 }
596
597 /// Truncate to `max` chars with an ellipsis, so summary columns stay aligned.
598 fn truncate(s: &str, max: usize) -> String {
599 if s.chars().count() > max {
600 format!(
601 "{}",
602 s.chars().take(max.saturating_sub(1)).collect::<String>()
603 )
604 } else {
605 s.to_string()
606 }
607 }
608
609 fn run(
610 terminal: &mut Terminal<CrosstermBackend<Stdout>>,
611 app: &mut App,
612 rt: &tokio::runtime::Runtime,
613 pool: &SqlitePool,
614 ) -> Result<()> {
615 loop {
616 terminal.draw(|f| draw(f, app))?;
617
618 if let CtEvent::Key(key) = event::read()? {
619 if key.kind != KeyEventKind::Press {
620 continue;
621 }
622 match key.code {
623 KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
624 KeyCode::Tab => app.cycle_group(1),
625 KeyCode::BackTab => app.cycle_group(-1),
626 KeyCode::Left | KeyCode::Char('h') => app.move_pane(-1),
627 KeyCode::Right | KeyCode::Char('l') => app.move_pane(1),
628 KeyCode::Char(c @ '1'..='9') => app.select_pane(c as usize - '1' as usize),
629 KeyCode::Char('j') | KeyCode::Down => app.move_selection(1),
630 KeyCode::Char('k') | KeyCode::Up => app.move_selection(-1),
631 KeyCode::Char('r') => app.reload(rt, pool)?,
632 _ => {}
633 }
634 }
635 }
636 }
637
638 fn draw(f: &mut ratatui::Frame, app: &mut App) {
639 let p = app.theme;
640 let chunks = Layout::default()
641 .direction(Direction::Vertical)
642 .constraints([Constraint::Min(1), Constraint::Length(1)])
643 .split(f.area());
644
645 // The bordered shell carries the top-level group tabs in its title bar.
646 let block = Block::default()
647 .borders(Borders::ALL)
648 .border_style(Style::default().fg(p.line_border))
649 .title(group_tabs(p, app.group));
650 let inner = block.inner(chunks[0]);
651 f.render_widget(block, chunks[0]);
652
653 // Inside: the pane sub-tabs, then the active pane's body.
654 let body = Layout::default()
655 .direction(Direction::Vertical)
656 .constraints([
657 Constraint::Length(1),
658 Constraint::Length(1),
659 Constraint::Min(1),
660 ])
661 .split(inner);
662 f.render_widget(
663 Paragraph::new(pane_tabs(p, app.group, app.pane_idx)),
664 body[0],
665 );
666
667 draw_pane(f, app, p, body[2]);
668
669 f.render_widget(
670 AlloyStatusBar::new(
671 &p,
672 [
673 hint("Tab", "group"),
674 hint("1-9 / ←→", "pane"),
675 hint("j/k", "move"),
676 hint("r", "refresh"),
677 hint("q", "quit"),
678 ],
679 ),
680 chunks[1],
681 );
682 }
683
684 /// The Work / Time / Messages tab strip, active group in accent.
685 fn group_tabs(p: alloy_tui::Theme, active: Group) -> Line<'static> {
686 let mut spans = Vec::new();
687 for (i, g) in Group::ALL.iter().enumerate() {
688 if i > 0 {
689 spans.push(Span::styled("", Style::default().fg(p.line_border)));
690 }
691 let style = if *g == active {
692 Style::default()
693 .fg(p.status_info)
694 .add_modifier(Modifier::BOLD)
695 } else {
696 Style::default().fg(p.content_muted)
697 };
698 spans.push(Span::styled(g.label(), style));
699 }
700 Line::from(spans)
701 }
702
703 /// The sub-tab strip of panes within a group; the active pane is bracketed.
704 fn pane_tabs(p: alloy_tui::Theme, group: Group, active: usize) -> Line<'static> {
705 let mut spans = Vec::new();
706 for (i, pane) in group.panes().iter().enumerate() {
707 if i > 0 {
708 spans.push(Span::raw(" "));
709 }
710 if i == active {
711 spans.push(Span::styled(
712 format!("[{}]", pane.label()),
713 Style::default()
714 .fg(p.content_primary)
715 .add_modifier(Modifier::BOLD),
716 ));
717 } else {
718 spans.push(Span::styled(
719 format!(" {} ", pane.label()),
720 Style::default().fg(p.content_muted),
721 ));
722 }
723 }
724 Line::from(spans)
725 }
726
727 /// Render the active pane's body into `area`.
728 fn draw_pane(
729 f: &mut ratatui::Frame,
730 app: &mut App,
731 p: alloy_tui::Theme,
732 area: ratatui::layout::Rect,
733 ) {
734 match app.current_pane() {
735 Pane::Tasks => {
736 let items: Vec<ListItem> = app.rows.iter().map(|r| row_item(p, r)).collect();
737 let list = List::new(items)
738 .highlight_style(Style::default().bg(p.surface_raised).fg(p.content_primary))
739 .highlight_symbol("");
740 f.render_stateful_widget(list, area, &mut app.state);
741 }
742 Pane::Projects => {
743 let items: Vec<ListItem> = app.projects.iter().map(|r| project_item(p, r)).collect();
744 let list = List::new(items)
745 .highlight_style(Style::default().bg(p.surface_raised).fg(p.content_primary))
746 .highlight_symbol("");
747 f.render_stateful_widget(list, area, &mut app.proj_state);
748 }
749 Pane::Events if app.events.is_empty() => {
750 let empty = Paragraph::new(vec![
751 Line::from(""),
752 Line::from(Span::styled(
753 format!("No events in the next {EVENT_HORIZON_DAYS} days"),
754 Style::default().fg(p.content_muted),
755 )),
756 ])
757 .alignment(Alignment::Center);
758 f.render_widget(empty, area);
759 }
760 Pane::Events => {
761 let items: Vec<ListItem> = app.events.iter().map(|r| event_item(p, r)).collect();
762 let list = List::new(items)
763 .highlight_style(Style::default().bg(p.surface_raised).fg(p.content_primary))
764 .highlight_symbol("");
765 f.render_stateful_widget(list, area, &mut app.event_state);
766 }
767 Pane::Timer => {
768 f.render_widget(Paragraph::new(timer_lines(p, &app.timer)), area);
769 }
770 Pane::Contacts if app.contacts.is_empty() => {
771 let empty = Paragraph::new(vec![
772 Line::from(""),
773 Line::from(Span::styled(
774 "No contacts",
775 Style::default().fg(p.content_muted),
776 )),
777 ])
778 .alignment(Alignment::Center);
779 f.render_widget(empty, area);
780 }
781 Pane::Contacts => {
782 let items: Vec<ListItem> = app.contacts.iter().map(|c| contact_item(p, c)).collect();
783 let list = List::new(items)
784 .highlight_style(Style::default().bg(p.surface_raised).fg(p.content_primary))
785 .highlight_symbol("");
786 f.render_stateful_widget(list, area, &mut app.contact_state);
787 }
788 }
789 }
790
791 fn row_item<'a>(p: alloy_tui::Theme, row: &'a Row) -> ListItem<'a> {
792 match row {
793 Row::Header { name, open } => {
794 let line = Line::from(vec![
795 Span::styled(
796 name.clone(),
797 Style::default()
798 .fg(p.content_primary)
799 .add_modifier(Modifier::BOLD),
800 ),
801 Span::styled(format!(" {open}"), Style::default().fg(p.content_muted)),
802 ]);
803 ListItem::new(line)
804 }
805 Row::Task(t) => {
806 let (mark, mark_color) = match t.status {
807 TaskStatus::Started => ("", p.status_info),
808 _ => ("", p.content_muted),
809 };
810 let prio_color = match t.priority {
811 Priority::High => p.status_warning,
812 Priority::Medium => p.content_secondary,
813 Priority::Low => p.content_muted,
814 };
815 let mut spans = vec![
816 Span::raw(" "),
817 Span::styled(mark, Style::default().fg(mark_color)),
818 Span::styled(t.description.clone(), Style::default().fg(prio_color)),
819 ];
820 if let Some(due) = t.due {
821 spans.push(Span::styled(
822 format!(" due {}", due.format("%Y-%m-%d")),
823 Style::default().fg(p.content_muted),
824 ));
825 }
826 ListItem::new(Line::from(spans))
827 }
828 }
829 }
830
831 fn project_item<'a>(p: alloy_tui::Theme, row: &'a ProjectRow) -> ListItem<'a> {
832 let status_color = match row.status {
833 ProjectStatus::Active => p.status_success,
834 ProjectStatus::OnHold => p.status_warning,
835 ProjectStatus::Completed | ProjectStatus::Archived => p.content_muted,
836 };
837 let count = if row.open > 0 {
838 Span::styled(
839 format!(" {} open", row.open),
840 Style::default().fg(p.status_info),
841 )
842 } else {
843 Span::styled(" 0 open".to_string(), Style::default().fg(p.content_muted))
844 };
845 let line = Line::from(vec![
846 Span::raw(" "),
847 Span::styled(
848 row.name.clone(),
849 Style::default()
850 .fg(p.content_primary)
851 .add_modifier(Modifier::BOLD),
852 ),
853 count,
854 Span::styled(
855 format!(" · {}", row.status.as_str()),
856 Style::default().fg(status_color),
857 ),
858 Span::styled(
859 format!(" · {}", row.project_type.as_str()),
860 Style::default().fg(p.content_muted),
861 ),
862 ]);
863 ListItem::new(line)
864 }
865
866 fn event_item<'a>(p: alloy_tui::Theme, row: &'a EventRow) -> ListItem<'a> {
867 match row {
868 EventRow::DayHeader { label, count } => {
869 let line = Line::from(vec![
870 Span::styled(
871 label.clone(),
872 Style::default()
873 .fg(p.content_primary)
874 .add_modifier(Modifier::BOLD),
875 ),
876 Span::styled(format!(" {count}"), Style::default().fg(p.content_muted)),
877 ]);
878 ListItem::new(line)
879 }
880 EventRow::Event(e) => {
881 let start = e.start_time.with_timezone(&Local);
882 let time = match e.end_time {
883 Some(end) => format!(
884 "{}{}",
885 start.format("%H:%M"),
886 end.with_timezone(&Local).format("%H:%M")
887 ),
888 None => start.format("%H:%M").to_string(),
889 };
890 let mut spans = vec![
891 Span::raw(" "),
892 Span::styled(format!("{time:<11} "), Style::default().fg(p.status_info)),
893 Span::styled(e.title.clone(), Style::default().fg(p.content_primary)),
894 ];
895 if let Some(ctx) = e.project_name.as_deref().or(e.contact_name.as_deref()) {
896 spans.push(Span::styled(
897 format!(" {ctx}"),
898 Style::default().fg(p.content_secondary),
899 ));
900 }
901 if let Some(loc) = e.location.as_deref().filter(|l| !l.is_empty()) {
902 spans.push(Span::styled(
903 format!(" @ {loc}"),
904 Style::default().fg(p.content_muted),
905 ));
906 }
907 ListItem::new(Line::from(spans))
908 }
909 }
910 }
911
912 fn contact_item<'a>(p: alloy_tui::Theme, c: &'a Contact) -> ListItem<'a> {
913 let mut spans = vec![
914 Span::raw(" "),
915 Span::styled(
916 c.display_name.clone(),
917 Style::default()
918 .fg(p.content_primary)
919 .add_modifier(Modifier::BOLD),
920 ),
921 ];
922 let role = match (c.title.as_deref(), c.company.as_deref()) {
923 (Some(t), Some(co)) => Some(format!("{t}, {co}")),
924 (Some(t), None) => Some(t.to_string()),
925 (None, Some(co)) => Some(co.to_string()),
926 (None, None) => None,
927 };
928 if let Some(role) = role {
929 spans.push(Span::styled(
930 format!(" {role}"),
931 Style::default().fg(p.content_secondary),
932 ));
933 }
934 if let Some(email) = c.primary_email() {
935 spans.push(Span::styled(
936 format!(" {email}"),
937 Style::default().fg(p.content_muted),
938 ));
939 }
940 ListItem::new(Line::from(spans))
941 }
942
943 /// A bold section header line for the Timer pane.
944 fn section(p: alloy_tui::Theme, title: &str) -> Line<'static> {
945 Line::from(Span::styled(
946 format!(" {title}"),
947 Style::default()
948 .fg(p.content_secondary)
949 .add_modifier(Modifier::BOLD),
950 ))
951 }
952
953 /// Render the Timer pane as a compact dashboard: active timer, this week's
954 /// tracked breakdown, and recent sessions.
955 fn timer_lines(p: alloy_tui::Theme, tv: &TimerView) -> Vec<Line<'static>> {
956 let mut lines = Vec::new();
957
958 match &tv.active {
959 Some(a) => lines.push(Line::from(vec![
960 Span::styled(
961 "",
962 Style::default()
963 .fg(p.status_success)
964 .add_modifier(Modifier::BOLD),
965 ),
966 Span::styled(
967 a.task.clone(),
968 Style::default()
969 .fg(p.content_primary)
970 .add_modifier(Modifier::BOLD),
971 ),
972 Span::styled(
973 format!(" {} elapsed", fmt_minutes(a.elapsed_min)),
974 Style::default().fg(p.status_info),
975 ),
976 ])),
977 None => lines.push(Line::from(Span::styled(
978 " No active timer",
979 Style::default().fg(p.content_muted),
980 ))),
981 }
982
983 lines.push(Line::from(""));
984 lines.push(section(p, "This week"));
985 lines.push(Line::from(vec![
986 Span::raw(" "),
987 Span::styled("Today", Style::default().fg(p.content_secondary)),
988 Span::styled(
989 format!(" {} tracked", fmt_minutes(tv.today_minutes)),
990 Style::default().fg(p.content_primary),
991 ),
992 ]));
993 if tv.week.is_empty() {
994 lines.push(Line::from(Span::styled(
995 " no sessions this week",
996 Style::default().fg(p.content_muted),
997 )));
998 } else {
999 for proj in &tv.week {
1000 lines.push(Line::from(vec![
1001 Span::raw(" "),
1002 Span::styled(
1003 format!("{:<16}", truncate(&proj.name, 16)),
1004 Style::default().fg(p.content_secondary),
1005 ),
1006 Span::raw(" "),
1007 Span::styled(
1008 bar(proj.bar_percent, 12),
1009 Style::default().fg(p.status_info),
1010 ),
1011 Span::styled(
1012 format!(" {:>7}", fmt_minutes(proj.total_minutes)),
1013 Style::default().fg(p.content_primary),
1014 ),
1015 Span::styled(
1016 format!(" {}%", proj.percent),
1017 Style::default().fg(p.content_muted),
1018 ),
1019 ]));
1020 }
1021 }
1022
1023 lines.push(Line::from(""));
1024 lines.push(section(p, "Recent"));
1025 if tv.recent.is_empty() {
1026 lines.push(Line::from(Span::styled(
1027 " no recent sessions",
1028 Style::default().fg(p.content_muted),
1029 )));
1030 } else {
1031 for s in &tv.recent {
1032 let mut spans = vec![
1033 Span::raw(" "),
1034 Span::styled(
1035 format!("{:<7}", s.day),
1036 Style::default().fg(p.content_muted),
1037 ),
1038 Span::styled(
1039 format!("{:>7} ", fmt_minutes(s.minutes)),
1040 Style::default().fg(p.status_info),
1041 ),
1042 Span::styled(s.task.clone(), Style::default().fg(p.content_primary)),
1043 ];
1044 if let Some(proj) = &s.project {
1045 spans.push(Span::styled(
1046 format!(" {proj}"),
1047 Style::default().fg(p.content_secondary),
1048 ));
1049 }
1050 lines.push(Line::from(spans));
1051 }
1052 }
1053
1054 lines
1055 }
1056
1057 // --- CLI + path resolution -------------------------------------------------
1058
1059 fn parse_db_arg() -> Result<Option<PathBuf>> {
1060 let mut db = None;
1061 let mut it = std::env::args().skip(1);
1062 while let Some(arg) = it.next() {
1063 match arg.as_str() {
1064 "--db" => {
1065 let v = it.next().context("--db requires a value")?;
1066 db = Some(PathBuf::from(v));
1067 }
1068 "-h" | "--help" => {
1069 println!("got: read-first terminal board for GoingsOn\n\nUsage: got [--db <path>]");
1070 std::process::exit(0);
1071 }
1072 other => anyhow::bail!("unknown argument: {other}"),
1073 }
1074 }
1075 Ok(db)
1076 }
1077
1078 /// Default location of `goingson.db`, matching Tauri's `app_data_dir` for the
1079 /// `com.goingson.app` bundle, the same resolution `go-mcp` uses.
1080 fn default_db_path() -> Option<PathBuf> {
1081 let home = PathBuf::from(std::env::var_os("HOME")?);
1082
1083 #[cfg(target_os = "macos")]
1084 let base = home.join("Library/Application Support");
1085
1086 #[cfg(not(target_os = "macos"))]
1087 let base = std::env::var_os("XDG_DATA_HOME")
1088 .map(PathBuf::from)
1089 .unwrap_or_else(|| home.join(".local/share"));
1090
1091 Some(base.join("com.goingson.app").join("goingson.db"))
1092 }
1093
1094 // --- Terminal setup / teardown ---------------------------------------------
1095
1096 fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
1097 terminal::enable_raw_mode()?;
1098 let mut stdout = io::stdout();
1099 execute!(stdout, terminal::EnterAlternateScreen)?;
1100 Ok(Terminal::new(CrosstermBackend::new(stdout))?)
1101 }
1102
1103 fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
1104 terminal::disable_raw_mode()?;
1105 execute!(terminal.backend_mut(), terminal::LeaveAlternateScreen)?;
1106 terminal.show_cursor()?;
1107 Ok(())
1108 }
1109