//! got, a read-first terminal cockpit over GoingsOn's local `goingson.db`. //! //! First consumer of `alloy_tui` (Alloy's themed-ratatui layer). Opens the same //! SQLite file the desktop app uses as a peer reader, exactly like `go-mcp` //! (see `crates/go-mcp`): links the domain crates directly, resolves the same //! default DB path, and reads through the normal repository layer. //! //! The shell mirrors the desktop app's two-level pill-nav: three top-level //! groups (Work / Time / Messages) each holding a set of panes. It is a slimmed //! down GoingsOn: Tasks and Projects (the pathways `go-mcp` also covers) plus //! Events, Timer, and Contacts. Email and the review views are deliberately //! absent, the desktop and web clients are the better surface for those. //! `Tab`/`Shift+Tab` cycle groups, number keys and the arrows pick a pane //! within the active group, `j/k` scroll, `r` refreshes. //! //! Usage: `got [--db ]` use std::collections::HashMap; use std::io::{self, Stdout}; use std::path::PathBuf; use alloy_tui::{AlloyStatusBar, Theme, hint}; use anyhow::{Context, Result}; use chrono::{Datelike, Duration, Local, NaiveDate, TimeZone, Utc}; use goingson_core::{ Contact, ContactRepository, Event, EventRepository, Priority, Project, ProjectRepository, ProjectStatus, ProjectType, Task, TaskCrud, TaskStatus, TaskTimeTracking, TimeSession, TimeSummaryProject, TimeTrackingSummary, UserId, roll_up_time_summary, }; use goingson_db_sqlite::{ SqliteContactRepository, SqliteEventRepository, SqliteProjectRepository, SqliteTaskRepository, init_pool, }; use ratatui::Terminal; use ratatui::crossterm::event::{self, Event as CtEvent, KeyCode, KeyEventKind}; use ratatui::crossterm::{execute, terminal}; use ratatui::layout::{Alignment, Constraint, Direction, Layout}; use ratatui::prelude::CrosstermBackend; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; use sqlx::SqlitePool; use uuid::Uuid; /// Fixed single-user desktop id. MUST match `DESKTOP_USER_ID` in `go-mcp` /// (`crates/go-mcp/src/context.rs`) and the Tauri app (`src-tauri/src/state.rs`); /// tasks are keyed on it, so a mismatch would read an empty set. const DESKTOP_USER_ID: UserId = UserId::from_uuid(Uuid::from_u128(1)); /// The theme `got` renders in. /// /// `alloy_tui` ships no built-in palette on purpose: a missing or malformed /// theme is an error the user sees, not something papered over by rendering in /// colors that exist nowhere in the theme files. So load one. A theme dropped /// into `~/.config/goingson/themes/` wins; otherwise use the set `makeover` /// bundles, which includes the app's own `goingson` skin. fn load_theme() -> Result { let mut dirs = Vec::new(); if let Some(home) = std::env::var_os("HOME") { let custom = PathBuf::from(home) .join(".config") .join("goingson") .join("themes"); if custom.is_dir() { dirs.push((custom, true)); } } dirs.push(( makeover::bundled_themes_dir().context("makeover ships no themes directory")?, false, )); let id = std::env::var("GOT_THEME").unwrap_or_else(|_| DEFAULT_THEME.to_string()); let colors = makeover::load_theme(&dirs, &id) .map_err(anyhow::Error::msg) .with_context(|| format!("loading theme `{id}`"))?; Theme::from_theme(&colors) .map_err(|e| anyhow::anyhow!("{e:?}")) .with_context(|| format!("theme `{id}` is incomplete")) } /// The app's own skin, shared with the desktop build. const DEFAULT_THEME: &str = "goingson"; fn main() -> Result<()> { let db_arg = parse_db_arg()?; // We own the runtime rather than using `#[tokio::main]` so the synchronous // event loop can `block_on` a reload whenever the user asks for one. let rt = tokio::runtime::Runtime::new()?; let db_path = db_arg .or_else(default_db_path) .context("could not resolve a default goingson.db path; pass --db ")?; if !db_path.exists() { anyhow::bail!( "database not found at {}. Run GoingsOn once to create it, or pass --db.", db_path.display() ); } let pool = rt.block_on(init_pool(Some(&db_path.to_string_lossy())))?; let mut app = App::new(db_path, load_theme()?); app.reload(&rt, &pool)?; let mut terminal = setup_terminal()?; let result = run(&mut terminal, &mut app, &rt, &pool); restore_terminal(&mut terminal)?; result } /// The top-level pill-nav groups, mirroring the desktop app. Email lives under /// the app's "Messages" group but is intentionally not surfaced here. #[derive(Clone, Copy, PartialEq)] enum Group { Work, Time, Messages, } impl Group { const ALL: [Group; 3] = [Group::Work, Group::Time, Group::Messages]; fn label(self) -> &'static str { match self { Group::Work => "Work", Group::Time => "Time", Group::Messages => "Messages", } } /// The panes shown within this group, in tab order. fn panes(self) -> &'static [Pane] { match self { Group::Work => &[Pane::Tasks, Pane::Projects], Group::Time => &[Pane::Events, Pane::Timer], Group::Messages => &[Pane::Contacts], } } fn index(self) -> usize { Self::ALL.iter().position(|&g| g == self).unwrap_or(0) } fn step(self, delta: isize) -> Group { let n = Self::ALL.len() as isize; Self::ALL[(self.index() as isize + delta).rem_euclid(n) as usize] } } /// A single pane within a group, each backed by a live read of the local DB. #[derive(Clone, Copy, PartialEq)] enum Pane { Tasks, Projects, Events, Timer, Contacts, } impl Pane { fn label(self) -> &'static str { match self { Pane::Tasks => "Tasks", Pane::Projects => "Projects", Pane::Events => "Events", Pane::Timer => "Timer", Pane::Contacts => "Contacts", } } } /// A flattened board row: either a project header or one of its open tasks. enum Row { Header { name: String, open: usize }, Task(Box), } /// One line on the Projects pane: a project plus its open-task count. struct ProjectRow { name: String, status: ProjectStatus, project_type: ProjectType, open: usize, } /// How far ahead the Events pane looks. const EVENT_HORIZON_DAYS: i64 = 30; /// A flattened Events-pane row: a day header or one event under it. enum EventRow { DayHeader { label: String, count: usize }, Event(Box), } /// How many recent sessions the Timer pane lists. const RECENT_SESSION_LIMIT: usize = 10; /// The Timer pane's view: the running timer (if any), this week's tracked /// breakdown (matching the desktop Day-view panel), and recent sessions. #[derive(Default)] struct TimerView { active: Option, today_minutes: i32, week: Vec, recent: Vec, } /// The currently running timer, resolved to its task and elapsed minutes. struct ActiveTimer { task: String, elapsed_min: i32, } /// One finished session in the Timer pane's recent list. struct RecentSession { day: String, task: String, project: Option, minutes: i32, } struct App { db_path: PathBuf, group: Group, /// Index into `group.panes()` of the active pane. pane_idx: usize, rows: Vec, state: ListState, projects: Vec, proj_state: ListState, events: Vec, event_state: ListState, timer: TimerView, contacts: Vec, contact_state: ListState, status: String, theme: Theme, } impl App { fn new(db_path: PathBuf, theme: Theme) -> Self { Self { db_path, theme, group: Group::Work, pane_idx: 0, rows: Vec::new(), state: ListState::default(), projects: Vec::new(), proj_state: ListState::default(), events: Vec::new(), event_state: ListState::default(), timer: TimerView::default(), contacts: Vec::new(), contact_state: ListState::default(), status: String::new(), } } fn current_pane(&self) -> Pane { self.group.panes()[self.pane_idx] } /// Switch groups, landing on the new group's first pane. fn cycle_group(&mut self, delta: isize) { self.group = self.group.step(delta); self.pane_idx = 0; } /// Move to another pane within the active group, wrapping. fn move_pane(&mut self, delta: isize) { let n = self.group.panes().len() as isize; self.pane_idx = (self.pane_idx as isize + delta).rem_euclid(n) as usize; } /// Jump straight to the pane at `idx` (0-based) if the group has one there. fn select_pane(&mut self, idx: usize) { if idx < self.group.panes().len() { self.pane_idx = idx; } } /// Fetch projects + open tasks and rebuild every pane's view. fn reload(&mut self, rt: &tokio::runtime::Runtime, pool: &SqlitePool) -> Result<()> { let Loaded { projects, tasks, events, active_timer, time_summary, sessions, contacts, } = rt.block_on(load(pool))?; self.projects = build_projects(&projects, &tasks); self.events = build_events(events); self.timer = build_timer(active_timer, time_summary, sessions, &tasks); self.contacts = build_contacts(contacts); self.rows = build_board(projects, tasks); self.status = format!( "{} open task(s) from {}", self.rows .iter() .filter(|r| matches!(r, Row::Task(_))) .count(), self.db_path.display() ); clamp_selection(&mut self.state, self.rows.len()); clamp_selection(&mut self.proj_state, self.projects.len()); clamp_selection(&mut self.event_state, self.events.len()); clamp_selection(&mut self.contact_state, self.contacts.len()); Ok(()) } /// Scroll the active pane's list, wrapping. No-op on panes without a list. fn move_selection(&mut self, delta: isize) { let (state, len) = match self.current_pane() { Pane::Tasks => (&mut self.state, self.rows.len()), Pane::Projects => (&mut self.proj_state, self.projects.len()), Pane::Events => (&mut self.event_state, self.events.len()), Pane::Contacts => (&mut self.contact_state, self.contacts.len()), _ => return, }; if len == 0 { return; } let cur = state.selected().unwrap_or(0) as isize; let next = (cur + delta).rem_euclid(len as isize); state.select(Some(next as usize)); } } /// Keep a list's selection in range after its contents change. fn clamp_selection(state: &mut ListState, len: usize) { if len == 0 { state.select(None); } else { state.select(Some(state.selected().unwrap_or(0).min(len - 1))); } } /// Everything the board reads in one refresh. struct Loaded { projects: Vec, tasks: Vec, events: Vec, active_timer: Option<(TimeSession, String)>, time_summary: Vec, sessions: Vec, contacts: Vec, } /// Read every pane's data for the desktop user in a single async pass. async fn load(pool: &SqlitePool) -> Result { let projects = SqliteProjectRepository::new(pool.clone()) .list_all(DESKTOP_USER_ID) .await?; let tasks = SqliteTaskRepository::new(pool.clone()) .list_all(DESKTOP_USER_ID) .await?; let events = SqliteEventRepository::new(pool.clone()) .get_upcoming(DESKTOP_USER_ID, EVENT_HORIZON_DAYS) .await?; // Time tracking. The week window matches the desktop Day-view panel: Monday // 00:00 local through the next Monday, so our totals agree with the app's. let task_repo = SqliteTaskRepository::new(pool.clone()); let active_timer = task_repo.get_active_timer(DESKTOP_USER_ID).await?; let now = Local::now(); let week_start = now.date_naive() - Duration::days(i64::from(now.weekday().num_days_from_monday())); let (start, end) = ( local_midnight_utc(week_start), local_midnight_utc(week_start + Duration::days(7)), ); let time_summary = task_repo .get_time_summary(DESKTOP_USER_ID, start, end) .await?; let sessions = task_repo.list_all_time_sessions(DESKTOP_USER_ID).await?; let contacts = SqliteContactRepository::new(pool.clone()) .list_all(DESKTOP_USER_ID) .await?; Ok(Loaded { projects, tasks, events, active_timer, time_summary, sessions, contacts, }) } /// Local midnight on `date`, expressed in UTC. Mirrors the desktop app's helper /// so the Timer pane buckets time exactly as the Day view does. fn local_midnight_utc(date: NaiveDate) -> chrono::DateTime { let naive = date.and_hms_opt(0, 0, 0).expect("midnight is always valid"); Local .from_local_datetime(&naive) .earliest() .map(|dt| dt.with_timezone(&Utc)) .unwrap_or_else(|| Utc.from_utc_datetime(&naive)) } fn is_open(t: &Task) -> bool { matches!(t.status, TaskStatus::Pending | TaskStatus::Started) } /// Group open tasks under their project, projects with work first, and flatten. fn build_board(projects: Vec, tasks: Vec) -> Vec { let mut open: Vec = tasks.into_iter().filter(is_open).collect(); open.sort_by(|a, b| { b.urgency .partial_cmp(&a.urgency) .unwrap_or(std::cmp::Ordering::Equal) }); // Preserve one bucket per project name plus a trailing "(no project)". let mut order: Vec = projects.into_iter().map(|p| p.name).collect(); order.sort(); const NO_PROJECT: &str = "(no project)"; order.push(NO_PROJECT.to_string()); let mut rows = Vec::new(); for name in order { let bucket: Vec = open .iter() .filter(|t| { let pn = t.project_name.as_deref().unwrap_or(NO_PROJECT); pn == name }) .cloned() .collect(); if bucket.is_empty() { continue; } rows.push(Row::Header { name, open: bucket.len(), }); rows.extend(bucket.into_iter().map(|t| Row::Task(Box::new(t)))); } rows } /// Build the Projects pane: every project with its open-task count, sorted so /// active work floats to the top and ties break alphabetically. fn build_projects(projects: &[Project], tasks: &[Task]) -> Vec { let mut open_counts: HashMap<&str, usize> = HashMap::new(); for t in tasks.iter().filter(|t| is_open(t)) { if let Some(name) = t.project_name.as_deref() { *open_counts.entry(name).or_default() += 1; } } let mut rows: Vec = projects .iter() .map(|p| ProjectRow { name: p.name.clone(), status: p.status.clone(), project_type: p.project_type.clone(), open: open_counts.get(p.name.as_str()).copied().unwrap_or(0), }) .collect(); rows.sort_by(|a, b| { status_rank(&a.status) .cmp(&status_rank(&b.status)) .then_with(|| a.name.cmp(&b.name)) }); rows } /// Sort key for project status: live work first, dormant last. fn status_rank(status: &ProjectStatus) -> u8 { match status { ProjectStatus::Active => 0, ProjectStatus::OnHold => 1, ProjectStatus::Completed => 2, ProjectStatus::Archived => 3, } } /// Group upcoming events (already sorted ascending) by local calendar day, /// emitting a day header before each day's events. fn build_events(events: Vec) -> Vec { let today = Local::now().date_naive(); let mut groups: Vec<(NaiveDate, Vec)> = Vec::new(); for ev in events { let day = ev.start_time.with_timezone(&Local).date_naive(); match groups.last_mut() { Some((d, bucket)) if *d == day => bucket.push(ev), _ => groups.push((day, vec![ev])), } } let mut rows = Vec::new(); for (day, bucket) in groups { rows.push(EventRow::DayHeader { label: day_label(day, today), count: bucket.len(), }); rows.extend(bucket.into_iter().map(|e| EventRow::Event(Box::new(e)))); } rows } /// A friendly day header: "Today" / "Tomorrow" near now, else "Mon Jul 21". fn day_label(day: NaiveDate, today: NaiveDate) -> String { match (day - today).num_days() { 0 => "Today".to_string(), 1 => "Tomorrow".to_string(), _ => day.format("%a %b %-d").to_string(), } } /// Assemble the Timer pane from the raw timer reads. `tasks` resolves each /// session's task id back to its description and project for display. fn build_timer( active: Option<(TimeSession, String)>, summary: Vec, sessions: Vec, tasks: &[Task], ) -> TimerView { let active = active.map(|(session, task)| ActiveTimer { task, elapsed_min: session.elapsed_minutes(), }); // Today's total + the week's per-project breakdown, aggregated exactly as // the desktop Day view does. "Today" is matched on the UTC date the query // buckets by, same as the app. let today = Utc::now().format("%Y-%m-%d").to_string(); let panel = roll_up_time_summary(&summary, &today); // Most recent finished sessions, newest first. let mut finished: Vec = sessions .into_iter() .filter(|s| s.ended_at.is_some()) .collect(); finished.sort_by_key(|s| std::cmp::Reverse(s.started_at)); let recent = finished .into_iter() .take(RECENT_SESSION_LIMIT) .map(|s| { let task = tasks.iter().find(|t| t.id == s.task_id); RecentSession { day: s .started_at .with_timezone(&Local) .format("%b %-d") .to_string(), task: task .map(|t| t.description.clone()) .unwrap_or_else(|| "(unknown task)".to_string()), project: task.and_then(|t| t.project_name.clone()), minutes: s.duration_minutes.unwrap_or_else(|| s.elapsed_minutes()), } }) .collect(); TimerView { active, today_minutes: panel.today_minutes, week: panel.projects, recent, } } /// Sort contacts for the Contacts pane: alphabetical by display name, /// case-insensitive. fn build_contacts(mut contacts: Vec) -> Vec { contacts.sort_by_key(|c| c.display_name.to_lowercase()); contacts } /// Format a minute count as "2h 05m" or "45m". fn fmt_minutes(min: i32) -> String { let (h, m) = (min / 60, min % 60); if h > 0 { format!("{h}h {m:02}m") } else { format!("{m}m") } } /// A fixed-width bar of block characters for `percent` (0-100) of `width`. fn bar(percent: i32, width: usize) -> String { let filled = ((percent.clamp(0, 100) as usize * width) + 50) / 100; let filled = filled.min(width); "█".repeat(filled) + &"░".repeat(width - filled) } /// Truncate to `max` chars with an ellipsis, so summary columns stay aligned. fn truncate(s: &str, max: usize) -> String { if s.chars().count() > max { format!( "{}…", s.chars().take(max.saturating_sub(1)).collect::() ) } else { s.to_string() } } fn run( terminal: &mut Terminal>, app: &mut App, rt: &tokio::runtime::Runtime, pool: &SqlitePool, ) -> Result<()> { loop { terminal.draw(|f| draw(f, app))?; if let CtEvent::Key(key) = event::read()? { if key.kind != KeyEventKind::Press { continue; } match key.code { KeyCode::Char('q') | KeyCode::Esc => return Ok(()), KeyCode::Tab => app.cycle_group(1), KeyCode::BackTab => app.cycle_group(-1), KeyCode::Left | KeyCode::Char('h') => app.move_pane(-1), KeyCode::Right | KeyCode::Char('l') => app.move_pane(1), KeyCode::Char(c @ '1'..='9') => app.select_pane(c as usize - '1' as usize), KeyCode::Char('j') | KeyCode::Down => app.move_selection(1), KeyCode::Char('k') | KeyCode::Up => app.move_selection(-1), KeyCode::Char('r') => app.reload(rt, pool)?, _ => {} } } } } fn draw(f: &mut ratatui::Frame, app: &mut App) { let p = app.theme; let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(1), Constraint::Length(1)]) .split(f.area()); // The bordered shell carries the top-level group tabs in its title bar. let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(p.line_border)) .title(group_tabs(p, app.group)); let inner = block.inner(chunks[0]); f.render_widget(block, chunks[0]); // Inside: the pane sub-tabs, then the active pane's body. let body = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), Constraint::Length(1), Constraint::Min(1), ]) .split(inner); f.render_widget( Paragraph::new(pane_tabs(p, app.group, app.pane_idx)), body[0], ); draw_pane(f, app, p, body[2]); f.render_widget( AlloyStatusBar::new( &p, [ hint("Tab", "group"), hint("1-9 / ←→", "pane"), hint("j/k", "move"), hint("r", "refresh"), hint("q", "quit"), ], ), chunks[1], ); } /// The Work / Time / Messages tab strip, active group in accent. fn group_tabs(p: alloy_tui::Theme, active: Group) -> Line<'static> { let mut spans = Vec::new(); for (i, g) in Group::ALL.iter().enumerate() { if i > 0 { spans.push(Span::styled(" │ ", Style::default().fg(p.line_border))); } let style = if *g == active { Style::default() .fg(p.status_info) .add_modifier(Modifier::BOLD) } else { Style::default().fg(p.content_muted) }; spans.push(Span::styled(g.label(), style)); } Line::from(spans) } /// The sub-tab strip of panes within a group; the active pane is bracketed. fn pane_tabs(p: alloy_tui::Theme, group: Group, active: usize) -> Line<'static> { let mut spans = Vec::new(); for (i, pane) in group.panes().iter().enumerate() { if i > 0 { spans.push(Span::raw(" ")); } if i == active { spans.push(Span::styled( format!("[{}]", pane.label()), Style::default() .fg(p.content_primary) .add_modifier(Modifier::BOLD), )); } else { spans.push(Span::styled( format!(" {} ", pane.label()), Style::default().fg(p.content_muted), )); } } Line::from(spans) } /// Render the active pane's body into `area`. fn draw_pane( f: &mut ratatui::Frame, app: &mut App, p: alloy_tui::Theme, area: ratatui::layout::Rect, ) { match app.current_pane() { Pane::Tasks => { let items: Vec = app.rows.iter().map(|r| row_item(p, r)).collect(); let list = List::new(items) .highlight_style(Style::default().bg(p.surface_raised).fg(p.content_primary)) .highlight_symbol("▌"); f.render_stateful_widget(list, area, &mut app.state); } Pane::Projects => { let items: Vec = app.projects.iter().map(|r| project_item(p, r)).collect(); let list = List::new(items) .highlight_style(Style::default().bg(p.surface_raised).fg(p.content_primary)) .highlight_symbol("▌"); f.render_stateful_widget(list, area, &mut app.proj_state); } Pane::Events if app.events.is_empty() => { let empty = Paragraph::new(vec![ Line::from(""), Line::from(Span::styled( format!("No events in the next {EVENT_HORIZON_DAYS} days"), Style::default().fg(p.content_muted), )), ]) .alignment(Alignment::Center); f.render_widget(empty, area); } Pane::Events => { let items: Vec = app.events.iter().map(|r| event_item(p, r)).collect(); let list = List::new(items) .highlight_style(Style::default().bg(p.surface_raised).fg(p.content_primary)) .highlight_symbol("▌"); f.render_stateful_widget(list, area, &mut app.event_state); } Pane::Timer => { f.render_widget(Paragraph::new(timer_lines(p, &app.timer)), area); } Pane::Contacts if app.contacts.is_empty() => { let empty = Paragraph::new(vec![ Line::from(""), Line::from(Span::styled( "No contacts", Style::default().fg(p.content_muted), )), ]) .alignment(Alignment::Center); f.render_widget(empty, area); } Pane::Contacts => { let items: Vec = app.contacts.iter().map(|c| contact_item(p, c)).collect(); let list = List::new(items) .highlight_style(Style::default().bg(p.surface_raised).fg(p.content_primary)) .highlight_symbol("▌"); f.render_stateful_widget(list, area, &mut app.contact_state); } } } fn row_item<'a>(p: alloy_tui::Theme, row: &'a Row) -> ListItem<'a> { match row { Row::Header { name, open } => { let line = Line::from(vec![ Span::styled( name.clone(), Style::default() .fg(p.content_primary) .add_modifier(Modifier::BOLD), ), Span::styled(format!(" {open}"), Style::default().fg(p.content_muted)), ]); ListItem::new(line) } Row::Task(t) => { let (mark, mark_color) = match t.status { TaskStatus::Started => ("◐ ", p.status_info), _ => ("○ ", p.content_muted), }; let prio_color = match t.priority { Priority::High => p.status_warning, Priority::Medium => p.content_secondary, Priority::Low => p.content_muted, }; let mut spans = vec![ Span::raw(" "), Span::styled(mark, Style::default().fg(mark_color)), Span::styled(t.description.clone(), Style::default().fg(prio_color)), ]; if let Some(due) = t.due { spans.push(Span::styled( format!(" due {}", due.format("%Y-%m-%d")), Style::default().fg(p.content_muted), )); } ListItem::new(Line::from(spans)) } } } fn project_item<'a>(p: alloy_tui::Theme, row: &'a ProjectRow) -> ListItem<'a> { let status_color = match row.status { ProjectStatus::Active => p.status_success, ProjectStatus::OnHold => p.status_warning, ProjectStatus::Completed | ProjectStatus::Archived => p.content_muted, }; let count = if row.open > 0 { Span::styled( format!(" {} open", row.open), Style::default().fg(p.status_info), ) } else { Span::styled(" 0 open".to_string(), Style::default().fg(p.content_muted)) }; let line = Line::from(vec![ Span::raw(" "), Span::styled( row.name.clone(), Style::default() .fg(p.content_primary) .add_modifier(Modifier::BOLD), ), count, Span::styled( format!(" · {}", row.status.as_str()), Style::default().fg(status_color), ), Span::styled( format!(" · {}", row.project_type.as_str()), Style::default().fg(p.content_muted), ), ]); ListItem::new(line) } fn event_item<'a>(p: alloy_tui::Theme, row: &'a EventRow) -> ListItem<'a> { match row { EventRow::DayHeader { label, count } => { let line = Line::from(vec![ Span::styled( label.clone(), Style::default() .fg(p.content_primary) .add_modifier(Modifier::BOLD), ), Span::styled(format!(" {count}"), Style::default().fg(p.content_muted)), ]); ListItem::new(line) } EventRow::Event(e) => { let start = e.start_time.with_timezone(&Local); let time = match e.end_time { Some(end) => format!( "{}–{}", start.format("%H:%M"), end.with_timezone(&Local).format("%H:%M") ), None => start.format("%H:%M").to_string(), }; let mut spans = vec![ Span::raw(" "), Span::styled(format!("{time:<11} "), Style::default().fg(p.status_info)), Span::styled(e.title.clone(), Style::default().fg(p.content_primary)), ]; if let Some(ctx) = e.project_name.as_deref().or(e.contact_name.as_deref()) { spans.push(Span::styled( format!(" {ctx}"), Style::default().fg(p.content_secondary), )); } if let Some(loc) = e.location.as_deref().filter(|l| !l.is_empty()) { spans.push(Span::styled( format!(" @ {loc}"), Style::default().fg(p.content_muted), )); } ListItem::new(Line::from(spans)) } } } fn contact_item<'a>(p: alloy_tui::Theme, c: &'a Contact) -> ListItem<'a> { let mut spans = vec![ Span::raw(" "), Span::styled( c.display_name.clone(), Style::default() .fg(p.content_primary) .add_modifier(Modifier::BOLD), ), ]; let role = match (c.title.as_deref(), c.company.as_deref()) { (Some(t), Some(co)) => Some(format!("{t}, {co}")), (Some(t), None) => Some(t.to_string()), (None, Some(co)) => Some(co.to_string()), (None, None) => None, }; if let Some(role) = role { spans.push(Span::styled( format!(" {role}"), Style::default().fg(p.content_secondary), )); } if let Some(email) = c.primary_email() { spans.push(Span::styled( format!(" {email}"), Style::default().fg(p.content_muted), )); } ListItem::new(Line::from(spans)) } /// A bold section header line for the Timer pane. fn section(p: alloy_tui::Theme, title: &str) -> Line<'static> { Line::from(Span::styled( format!(" {title}"), Style::default() .fg(p.content_secondary) .add_modifier(Modifier::BOLD), )) } /// Render the Timer pane as a compact dashboard: active timer, this week's /// tracked breakdown, and recent sessions. fn timer_lines(p: alloy_tui::Theme, tv: &TimerView) -> Vec> { let mut lines = Vec::new(); match &tv.active { Some(a) => lines.push(Line::from(vec![ Span::styled( " ▶ ", Style::default() .fg(p.status_success) .add_modifier(Modifier::BOLD), ), Span::styled( a.task.clone(), Style::default() .fg(p.content_primary) .add_modifier(Modifier::BOLD), ), Span::styled( format!(" {} elapsed", fmt_minutes(a.elapsed_min)), Style::default().fg(p.status_info), ), ])), None => lines.push(Line::from(Span::styled( " No active timer", Style::default().fg(p.content_muted), ))), } lines.push(Line::from("")); lines.push(section(p, "This week")); lines.push(Line::from(vec![ Span::raw(" "), Span::styled("Today", Style::default().fg(p.content_secondary)), Span::styled( format!(" {} tracked", fmt_minutes(tv.today_minutes)), Style::default().fg(p.content_primary), ), ])); if tv.week.is_empty() { lines.push(Line::from(Span::styled( " no sessions this week", Style::default().fg(p.content_muted), ))); } else { for proj in &tv.week { lines.push(Line::from(vec![ Span::raw(" "), Span::styled( format!("{:<16}", truncate(&proj.name, 16)), Style::default().fg(p.content_secondary), ), Span::raw(" "), Span::styled( bar(proj.bar_percent, 12), Style::default().fg(p.status_info), ), Span::styled( format!(" {:>7}", fmt_minutes(proj.total_minutes)), Style::default().fg(p.content_primary), ), Span::styled( format!(" {}%", proj.percent), Style::default().fg(p.content_muted), ), ])); } } lines.push(Line::from("")); lines.push(section(p, "Recent")); if tv.recent.is_empty() { lines.push(Line::from(Span::styled( " no recent sessions", Style::default().fg(p.content_muted), ))); } else { for s in &tv.recent { let mut spans = vec![ Span::raw(" "), Span::styled( format!("{:<7}", s.day), Style::default().fg(p.content_muted), ), Span::styled( format!("{:>7} ", fmt_minutes(s.minutes)), Style::default().fg(p.status_info), ), Span::styled(s.task.clone(), Style::default().fg(p.content_primary)), ]; if let Some(proj) = &s.project { spans.push(Span::styled( format!(" {proj}"), Style::default().fg(p.content_secondary), )); } lines.push(Line::from(spans)); } } lines } // --- CLI + path resolution ------------------------------------------------- fn parse_db_arg() -> Result> { let mut db = None; let mut it = std::env::args().skip(1); while let Some(arg) = it.next() { match arg.as_str() { "--db" => { let v = it.next().context("--db requires a value")?; db = Some(PathBuf::from(v)); } "-h" | "--help" => { println!("got: read-first terminal board for GoingsOn\n\nUsage: got [--db ]"); std::process::exit(0); } other => anyhow::bail!("unknown argument: {other}"), } } Ok(db) } /// Default location of `goingson.db`, matching Tauri's `app_data_dir` for the /// `com.goingson.app` bundle, the same resolution `go-mcp` uses. fn default_db_path() -> Option { let home = PathBuf::from(std::env::var_os("HOME")?); #[cfg(target_os = "macos")] let base = home.join("Library/Application Support"); #[cfg(not(target_os = "macos"))] let base = std::env::var_os("XDG_DATA_HOME") .map(PathBuf::from) .unwrap_or_else(|| home.join(".local/share")); Some(base.join("com.goingson.app").join("goingson.db")) } // --- Terminal setup / teardown --------------------------------------------- fn setup_terminal() -> Result>> { terminal::enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, terminal::EnterAlternateScreen)?; Ok(Terminal::new(CrosstermBackend::new(stdout))?) } fn restore_terminal(terminal: &mut Terminal>) -> Result<()> { terminal::disable_raw_mode()?; execute!(terminal.backend_mut(), terminal::LeaveAlternateScreen)?; terminal.show_cursor()?; Ok(()) }