Initial commit: TUI Watson time tracker implementation

This commit is contained in:
Ian Keane 2025-11-16 09:57:01 -05:00
commit 2f0cc79631
7 changed files with 1389 additions and 0 deletions

128
src/app.rs Normal file
View file

@ -0,0 +1,128 @@
use std::process::Command;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use crate::state::{AppState, TimeItem};
pub struct App {
pub state: AppState,
}
impl App {
pub fn new() -> anyhow::Result<Self> {
Ok(Self {
state: AppState::load()?,
})
}
pub fn handle_event(&mut self, event: Event) -> anyhow::Result<bool> {
match event {
Event::Key(KeyEvent {
code,
modifiers,
..
}) => match (code, modifiers) {
(KeyCode::Char('q'), _) => return Ok(true),
(KeyCode::Char('j'), _) | (KeyCode::Down, _) => self.move_selection(1),
(KeyCode::Char('k'), _) | (KeyCode::Up, _) => self.move_selection(-1),
(KeyCode::Char('h'), _) | (KeyCode::Left, _) => self.change_pane(-1),
(KeyCode::Char('l'), _) | (KeyCode::Right, _) => self.change_pane(1),
(KeyCode::Enter, _) => self.toggle_current_item()?,
(KeyCode::Char('e'), KeyModifiers::CONTROL) => self.edit_config()?,
_ => {}
},
_ => {}
}
Ok(false)
}
fn move_selection(&mut self, delta: i32) {
let items_len = match self.state.current_pane {
0 => self.state.permanent_items.len(),
1 => self.state.recurring_items.len(),
2 => self.state.recent_items.len(),
_ => return,
};
if items_len == 0 {
return;
}
let current = self.state.selected_indices[self.state.current_pane] as i32;
let new_index = (current + delta).rem_euclid(items_len as i32) as usize;
self.state.selected_indices[self.state.current_pane] = new_index;
}
fn change_pane(&mut self, delta: i32) {
self.state.current_pane = ((self.state.current_pane as i32 + delta).rem_euclid(3)) as usize;
}
fn get_current_item(&self) -> Option<TimeItem> {
let items = match self.state.current_pane {
0 => &self.state.permanent_items,
1 => &self.state.recurring_items,
2 => &self.state.recent_items,
_ => return None,
};
let index = self.state.selected_indices[self.state.current_pane];
items.get(index).cloned()
}
fn toggle_current_item(&mut self) -> anyhow::Result<()> {
if let Some(item) = self.get_current_item() {
if self.state.active_timer.as_ref().map(|(active, _)| active.name == item.name).unwrap_or(false) {
self.state.stop_timer()?;
} else {
self.state.start_timer(item)?;
}
self.state.save()?;
}
Ok(())
}
fn edit_config(&self) -> anyhow::Result<()> {
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
let config_path = AppState::config_file()?;
if !config_path.exists() {
std::fs::write(&config_path, "# WAT Configuration\n# Add your permanent items here\n\npermanent_items:\n - name: Daily Standup\n tags: [daily, meeting]\n")?;
}
Command::new(editor)
.arg(&config_path)
.spawn()?
.wait()?;
// Reload configuration
if config_path.exists() {
let contents = std::fs::read_to_string(config_path)?;
let config: serde_yaml::Value = serde_yaml::from_str(&contents)?;
if let Some(items) = config["permanent_items"].as_sequence() {
// Clear existing permanent items
self.state.permanent_items.clear();
// Add new items from config
for item in items {
if let (Some(name), Some(tags)) = (
item["name"].as_str(),
item["tags"].as_sequence()
) {
let tags = tags
.iter()
.filter_map(|t| t.as_str())
.map(String::from)
.collect();
self.state.permanent_items.push(TimeItem {
name: name.to_string(),
tags,
last_used: None,
});
}
}
}
}
Ok(())
}
}

52
src/main.rs Normal file
View file

@ -0,0 +1,52 @@
mod app;
mod state;
mod ui;
use anyhow::Result;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::prelude::*;
use std::io;
fn main() -> Result<()> {
// Setup terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// Create app and run it
let mut app = app::App::new()?;
let res = run_app(&mut terminal, &mut app);
// Restore terminal
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err)
}
Ok(())
}
fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut app::App) -> Result<()> {
loop {
terminal.draw(|f| ui::render(f, &app.state))?;
if event::poll(std::time::Duration::from_millis(50))? {
if app.handle_event(event::read()?)? {
return Ok(());
}
}
}
}

118
src/state.rs Normal file
View file

@ -0,0 +1,118 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use chrono::{DateTime, Utc};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeItem {
pub name: String,
pub tags: Vec<String>,
pub last_used: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AppState {
pub permanent_items: Vec<TimeItem>,
pub recurring_items: Vec<TimeItem>,
pub recent_items: Vec<TimeItem>,
pub current_pane: usize,
pub selected_indices: [usize; 3],
pub active_timer: Option<(TimeItem, DateTime<Utc>)>,
}
impl Default for AppState {
fn default() -> Self {
Self {
permanent_items: Vec::new(),
recurring_items: Vec::new(),
recent_items: Vec::new(),
current_pane: 0,
selected_indices: [0; 3],
active_timer: None,
}
}
}
impl AppState {
pub fn config_dir() -> anyhow::Result<PathBuf> {
let mut path = dirs::config_dir()
.ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?;
path.push("wat");
std::fs::create_dir_all(&path)?;
Ok(path)
}
pub fn state_file() -> anyhow::Result<PathBuf> {
let mut path = Self::config_dir()?;
path.push("state.yaml");
Ok(path)
}
pub fn config_file() -> anyhow::Result<PathBuf> {
let mut path = Self::config_dir()?;
path.push("config.yaml");
Ok(path)
}
pub fn load() -> anyhow::Result<Self> {
let path = Self::state_file()?;
if !path.exists() {
return Ok(Self::default());
}
let contents = std::fs::read_to_string(path)?;
Ok(serde_yaml::from_str(&contents)?)
}
pub fn save(&self) -> anyhow::Result<()> {
let path = Self::state_file()?;
let contents = serde_yaml::to_string(&self)?;
std::fs::write(path, contents)?;
Ok(())
}
pub fn add_recent_item(&mut self, item: TimeItem) {
self.recent_items.insert(0, item);
if self.recent_items.len() > 20 {
self.recent_items.pop();
}
}
pub fn start_timer(&mut self, item: TimeItem) -> anyhow::Result<()> {
// Stop current timer if any
if let Some((ref current_item, start_time)) = self.active_timer {
self.stop_timer()?;
}
// Start new timer
std::process::Command::new("watson")
.args(["start", &item.name])
.args(item.tags.iter().map(|t| format!("-T {}", t)))
.spawn()?
.wait()?;
self.active_timer = Some((item.clone(), Utc::now()));
Ok(())
}
pub fn stop_timer(&mut self) -> anyhow::Result<()> {
if let Some((ref item, _)) = self.active_timer {
std::process::Command::new("watson")
.arg("stop")
.spawn()?
.wait()?;
// Update last_used timestamp
let update_last_used = |items: &mut Vec<TimeItem>| {
if let Some(target) = items.iter_mut().find(|i| i.name == item.name) {
target.last_used = Some(Utc::now());
}
};
update_last_used(&mut self.permanent_items);
update_last_used(&mut self.recurring_items);
update_last_used(&mut self.recent_items);
self.active_timer = None;
}
Ok(())
}
}

102
src/ui.rs Normal file
View file

@ -0,0 +1,102 @@
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame,
};
use crate::state::{AppState, TimeItem};
const ACTIVE_COLOR: Color = Color::Green;
const INACTIVE_COLOR: Color = Color::Yellow;
pub fn render(frame: &mut Frame, state: &AppState) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
])
.split(frame.size());
render_section(
frame,
chunks[0],
"Permanent Items",
&state.permanent_items,
state.current_pane == 0,
state.selected_indices[0],
state,
);
render_section(
frame,
chunks[1],
"Recurring Items",
&state.recurring_items,
state.current_pane == 1,
state.selected_indices[1],
state,
);
render_section(
frame,
chunks[2],
"Recent Items",
&state.recent_items,
state.current_pane == 2,
state.selected_indices[2],
state,
);
}
fn render_section(
frame: &mut Frame,
area: Rect,
title: &str,
items: &[TimeItem],
is_active: bool,
selected: usize,
state: &AppState,
) {
let border_color = if is_active { ACTIVE_COLOR } else { INACTIVE_COLOR };
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.style(Style::default().fg(border_color));
let items: Vec<ListItem> = items
.iter()
.enumerate()
.map(|(i, item)| {
let is_running = state
.active_timer
.as_ref()
.map(|(active, _)| active.name == item.name)
.unwrap_or(false);
let style = if is_running {
Style::default().fg(ACTIVE_COLOR).add_modifier(Modifier::BOLD)
} else if i == selected && is_active {
Style::default().fg(border_color).add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
let mut line = item.name.clone();
if !item.tags.is_empty() {
line.push_str(" [");
line.push_str(&item.tags.join(", "));
line.push(']');
}
ListItem::new(Line::from(vec![Span::styled(line, style)]))
})
.collect();
let list = List::new(items).block(block);
frame.render_widget(list, area);
}