Compare commits

..

No commits in common. "f14982abf48cc83058890ad33f949063fec9de77" and "f3dcd5acbc414fdb774351a9fc9113f5ab55eff0" have entirely different histories.

14 changed files with 314 additions and 441 deletions

121
README.md
View file

@ -1,92 +1,71 @@
# wat # wat
`wat` is a TUI interface for [Watson](https://tailordev.github.io/Watson/) `wat` is a terminal UI wrapper around [Watson](https://tailordev.github.io/Watson/) that lets you browse and launch timers via a keyboard-driven interface.
![wat in tarnation](./assets/wat.png "wat")
--- ---
## Overview and screenshots ## Goals & Workflow
- Never type `watson start ...` again—start/stop timers with `Enter`.
- Maintain three panes simultaneously:
1. **Permanent Items** curated recurring tasks defined in `state.yaml`.
2. **Recurring Items** semi-regular tasks you hop between.
3. **Ad-Hoc Items** last ~20 one-offs, automatically tracked.
- Highlight clearly when a timer is running (bold green) vs idle (yellow).
- Keep everything human-editable: YAML state/config under `~/.config/wat`.
### Main Interface ---
Wat's main screen consists of 3 groupings of tasks, each of which is optional and can be disabled via settings. The idea here is to provide an interface to toggle what task you are currently working on, with lots of options for editing, annotating, and reporting on that time spent later. When you track time on a task, it will show up in the `view` page (v), which is analogous to `watson log`. ## Key Features
- **Navigation**: `j/k` or arrows move within a pane; `h/l`, arrows, or `Ctrl+n/p` switch panes; `q` quits.
- **Timer control**: `Enter` toggles the selected item. Starting a different item automatically stops the previous timer.
- **New entries**: `n` launches a modal (project + optional tag). Item is added to the current pane.
- **Project reassignment**: `p` opens a modal to change the tags for the selected item.
- **Log viewing**: `v` opens Watson's log viewer with daily/weekly/monthly views. Use `d`/`w`/`m` to switch periods, `g` to toggle grouping by date/project, `j`/`k` to select entries, `e` to edit, `x` to delete, `c` to copy to clipboard.
- **Deletion**: `d` removes the selected entry; no noisy status message.
- **Config editing**:
- `Ctrl+e` edits task config (`state.yaml`).
- `c` edits app config (`config.yaml`).
- **Help overlays**: `?` for help, `c` inside help for config instructions.
![Toggling a task in the wat interface](./assets/0-track-time.webm "time-tracking") ---
The three available categories of task are `Permanent Items`, for tasks that will likely be part of your workflow for the forseeable future, `Recurring Items` for scheduled weekly meetings or tasks which come and go, and `Ad-Hoc Items` for things that will not recur. Eventually, I plan to make the `Ad-Hoc` section display items in reverse order, and remove ones beyond 10 items. This will not delete past time entries for those items. ## Files & Persistence
```
~/.config/wat/
├─ state.yaml # serialized AppState (items, selections, active timer)
└─ config.yaml # UI/app options (help hints, allowed projects, etc.)
```
- `state.yaml` and `config.yaml` use `serde_yaml` for readability.
### The 'View Entries' Interface ---
Pressing `v` on the main interface will take the user to the interface for viewing actual time entries, as created from the main interface. The default view is the 'day' view, which shows your entries for the current day. You can press `g` to `group` or `ungroup` your tasks by project. You can also navigate using `h,j,k,l` between entries, entries grouped by project, grouped by day, or all entries. You can press `y` to copy the selection to your clipboard.
![Navigating the daily view](./assets/2-daily-view-and-proj-selection.webm "daily-view")
### Weekly and Monthly View
When in the `view` interface, you can switch to a weekly or monthly view using `w` or `m`. Grouping works the same way in these views as in the `daily` view. You can also press 'r' to reverse the order of the days in the display.
![Navigating by week or month](./assets/3-weekly-view-and-day-selection.webm "week-and-month-view")
### Individual entry editing
When an entry is selected, you can press `e` to edit the full `watson edit` configuration for your selected entry in your chosen `$EDITOR`
![Editing a time entry](./assets/4-individual-editing.webm "editing-an-entry")
### Backfill gap and overlap correction
The `ungrouped` view can also be used as an error resolution and quick edit page using the `b` (backfill) key. Here, `backfill` roughly translates to 'take the current entry and make its start time equal to the stop time of the previous entry'. The `b` key will do nothing if the entry is the first entry of the day.
In the ungrouped view, entries are chronological. Entries with gaps betweent them are denoted by a blank line with three dashes (---). Entries which overlap with the previous entry (which is likely an error and should never happen) are colored yellow. In both cases, pressing `b` will resolve the issue. (You may want gaps in certain cases such as lunch breaks or short breaks, so don't do this without thinking about the context)
![Backfilling Overlapped or Disjointed Entries](./assets/5-backfill-gap-and-overlap-correction.webm "backfilling")
### Rounding
The time tracking software I use at work only accepts logging for tasks and projects in 15m intervals, so I included a hotkey `R` to automatically round tasks to the nearest 15m. This reduces a little burden when reporting time, but keep in mind that if you get unlucky and many entries are rounded down you may end up under-reporting. Keep this in mind and always sanity check your time.
![Rounding](./assets/6-rounding.webm "rounding")
### Annotation
Watson actually doesn't include any options for annotating time entries by default! However, it does allow you to technically assign multiple 'tags' to an entry. In `wat`, we've been treating the `tag` as the name of the time entry more-or-less. When an entry is completed, you can navigate to the view page and press `a` to add a second tag if you want to be more specific about that particular entry. In practice, it looks just fine in the final reporting.
## Building & Running ## Building & Running
``` ```
cargo build cargo build
cargo run cargo run
``` ```
Requires `watson` on your `PATH` with a configured workspace. The application will automatically create the necessary `~/.config/watson` directory structure on first run if it doesn't exist. Requires `watson` on your `PATH` with a configured workspace.
Otherwise, just download the latest release for a relatively-stable binary.
---
## Keybindings (Summary) ## Keybindings (Summary)
| Keys | Action | | Keys | Action |
|-------------------|-------------------------------------------------------------------| |------|--------|
| `j` / `k`, arrows | Move selection | | `j` / `k`, arrows | Move selection |
| `Enter` | Start/stop timer | | `h` / `l`, arrows, `Ctrl+n` / `Ctrl+p` | Switch panes |
| `n` | New entry (project + optional tag) | | `Enter` | Start/stop timer |
| `p` | Reassign project for selected item | | `n` | New entry (project + optional tag) |
| `v` | View Watson log (d/w/m period, g group, e edit, x delete, y copy) | | `p` | Reassign tag for selected item |
| `x` | Delete entry | | `v` | View Watson log (d/w/m period, g group, e edit, x delete, c copy) |
| `Ctrl+e` | Edit task config (`state.yaml`) | | `d` | Delete entry |
| `c` | Edit app config (`config.yaml`) or show config help | | `Ctrl+e` | Edit task config (`state.yaml`) |
| `?` | Help screen | | `c` | Edit app config (`config.yaml`) or show config help |
| `q` | Quit | | `?` | Help screen |
| `q` | Quit |
## TODO ---
### Architecture ## TODO / Follow-ups
Eventually I need to refactor this whole thing, which was mostly written by LLMs. I want to make the underlying data structure for the view display much more solidly defined so that it is more extensible. It should be stored in some kind of nested structure which is aware of ordering as well as grouping and can be dynamically (and optimistically) updated without refreshing the UI when appropriate. Once that is resolved, I can focus on the following upgrades: - Add inline editing of permanent/recurring items instead of dropping to `$EDITOR`.
- Consider status messaging for critical errors (currently unused after deleting the delete confirmation).
- Adding a `/` command to narrow down entries by fuzzy searching - Add tests/snapshots for UI components.
- Perhaps having a universal interface for entries going back more than a month
- An option for people who don't want to copy / paste their hours into another interface
- Actually ordering the ad-hoc section by most recently used one-offs, and implementing a good way to auto-prune that list
- Print the time totals at the project-level for each day
- Some kind of API for the real weirdos out there (sean)
### Terminal interface
Another big reason I need to drill down and rewrite this with human hands is the difference between the mac and linux terminal handling. I need to learn to use ratatui well enough to understand how to develop for both platforms at once. I originally coded this on a linux machine, and I had a much smoother experience which was largely broken on Mac. This lead to a lot of spaghettification supporting mac, and some of the things I had working on linux are still not working on mac.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1 +0,0 @@
ffmpeg -i add-task.mov -c:v libvpx-vp9 -b:v 0 -crf 30 -pix_fmt yuv420p add-task.webm

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

File diff suppressed because it is too large Load diff

View file

@ -33,10 +33,10 @@ fn render_main(frame: &mut Frame, app: &App) {
let has_active_timer = app.state.active_timer.is_some(); let has_active_timer = app.state.active_timer.is_some();
let has_status = app.status_message.is_some(); let has_status = app.status_message.is_some();
let show_help_hint = app.config.show_help_hint; let show_help_hint = app.config.show_help_hint;
// Show bottom bar if we have any of: timer, status, or help hint // Show bottom bar if we have any of: timer, status, or help hint
let show_bottom_bar = has_active_timer || has_status || show_help_hint; let show_bottom_bar = has_active_timer || has_status || show_help_hint;
let bottom_height = if show_bottom_bar { let bottom_height = if show_bottom_bar {
if has_status { if has_status {
2 // Need extra line for status 2 // Need extra line for status
@ -63,41 +63,41 @@ fn render_main(frame: &mut Frame, app: &App) {
.borders(Borders::ALL) .borders(Borders::ALL)
.title("WAT") .title("WAT")
.style(Style::default().fg(ACTIVE_COLOR)); .style(Style::default().fg(ACTIVE_COLOR));
let text = Paragraph::new("No sections enabled. Edit config (press 'c') to enable sections.") let text = Paragraph::new("No sections enabled. Edit config (press 'c') to enable sections.")
.block(block) .block(block)
.style(Style::default().fg(Color::Yellow)) .style(Style::default().fg(Color::Yellow))
.alignment(Alignment::Center); .alignment(Alignment::Center);
frame.render_widget(text, frame.size()); frame.render_widget(text, frame.size());
return; return;
} }
// Build constraints for enabled sections // Build constraints for enabled sections
let mut constraints = Vec::new(); let mut constraints = Vec::new();
if bottom_height > 0 { if bottom_height > 0 {
// Reserve space for bottom bar first, then split remainder among sections // Reserve space for bottom bar first, then split remainder among sections
constraints.push(Constraint::Min(0)); // Sections get remaining space constraints.push(Constraint::Min(0)); // Sections get remaining space
constraints.push(Constraint::Length(bottom_height)); // Bottom bar gets fixed height constraints.push(Constraint::Length(bottom_height)); // Bottom bar gets fixed height
let layout = Layout::default() let layout = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints(constraints) .constraints(constraints)
.split(frame.size()); .split(frame.size());
// Split the top area among enabled sections // Split the top area among enabled sections
let section_percentage = 100 / enabled_sections as u16; let section_percentage = 100 / enabled_sections as u16;
let section_constraints = vec![Constraint::Percentage(section_percentage); enabled_sections]; let section_constraints = vec![Constraint::Percentage(section_percentage); enabled_sections];
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints(section_constraints) .constraints(section_constraints)
.split(layout[0]); .split(layout[0]);
// Render enabled sections // Render enabled sections
let mut chunk_idx = 0; let mut chunk_idx = 0;
if app.config.show_permanent { if app.config.show_permanent {
render_section( render_section(
frame, frame,
@ -145,14 +145,14 @@ fn render_main(frame: &mut Frame, app: &App) {
// No bottom bar - just render sections // No bottom bar - just render sections
let section_percentage = 100 / enabled_sections as u16; let section_percentage = 100 / enabled_sections as u16;
let constraints = vec![Constraint::Percentage(section_percentage); enabled_sections]; let constraints = vec![Constraint::Percentage(section_percentage); enabled_sections];
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints(constraints) .constraints(constraints)
.split(frame.size()); .split(frame.size());
let mut chunk_idx = 0; let mut chunk_idx = 0;
if app.config.show_permanent { if app.config.show_permanent {
render_section( render_section(
frame, frame,
@ -679,7 +679,7 @@ fn render_log_view(frame: &mut Frame, app: &App) {
let period_str = match app.log_view_period { let period_str = match app.log_view_period {
LogViewPeriod::Day => "Day", LogViewPeriod::Day => "Day",
LogViewPeriod::Week => "Week", LogViewPeriod::Week => "Week",
LogViewPeriod::Month => "31 Days", LogViewPeriod::Month => "Month",
}; };
let grouping_str = match app.log_view_grouping { let grouping_str = match app.log_view_grouping {
@ -812,14 +812,14 @@ fn render_log_view(frame: &mut Frame, app: &App) {
// Third pass: render with highlighting and overlap detection // Third pass: render with highlighting and overlap detection
let is_by_date = matches!(app.log_view_grouping, LogViewGrouping::ByDate); let is_by_date = matches!(app.log_view_grouping, LogViewGrouping::ByDate);
// Pre-calculate overlaps for efficiency - check consecutive entries within same day // Pre-calculate overlaps for efficiency - check consecutive entries within same day
let mut overlap_entry_indices = std::collections::HashSet::new(); let mut overlap_entry_indices = std::collections::HashSet::new();
if is_by_date && app.log_view_frame_indices.len() > 1 { if is_by_date && app.log_view_frame_indices.len() > 1 {
// Track which day we're in to avoid comparing across days // Track which day we're in to avoid comparing across days
let mut current_day_start_entry = 0; let mut current_day_start_entry = 0;
let mut in_day = false; let mut in_day = false;
for (line_idx, line) in app.log_view_content.iter().enumerate() { for (line_idx, line) in app.log_view_content.iter().enumerate() {
// New day header // New day header
if !line.starts_with(" ") && !line.is_empty() && !line.starts_with(" ") { if !line.starts_with(" ") && !line.is_empty() && !line.starts_with(" ") {
@ -835,12 +835,12 @@ fn render_log_view(frame: &mut Frame, app: &App) {
.filter(|l| l.starts_with(" ") && !l.is_empty() && l.trim() != "---") .filter(|l| l.starts_with(" ") && !l.is_empty() && l.trim() != "---")
.count() .count()
.saturating_sub(1); .saturating_sub(1);
// Only check overlap if not the first entry of the day // Only check overlap if not the first entry of the day
if entry_idx > current_day_start_entry { if entry_idx > current_day_start_entry {
let current_frame_idx = app.log_view_frame_indices[entry_idx]; let current_frame_idx = app.log_view_frame_indices[entry_idx];
let prev_frame_idx = app.log_view_frame_indices[entry_idx - 1]; let prev_frame_idx = app.log_view_frame_indices[entry_idx - 1];
if let (Some(current), Some(prev)) = ( if let (Some(current), Some(prev)) = (
app.log_view_frames.get(current_frame_idx), app.log_view_frames.get(current_frame_idx),
app.log_view_frames.get(prev_frame_idx), app.log_view_frames.get(prev_frame_idx),
@ -859,16 +859,16 @@ fn render_log_view(frame: &mut Frame, app: &App) {
} }
} }
} }
app.log_view_content app.log_view_content
.iter() .iter()
.enumerate() .enumerate()
.map(|(idx, line)| { .map(|(idx, line)| {
let is_selected = idx >= selected_line_start && idx <= selected_line_end; let is_selected = idx >= selected_line_start && idx <= selected_line_end;
// Skip styling for separator lines // Skip styling for separator lines
let is_separator = line.trim() == "---"; let is_separator = line.trim() == "---";
// Check if this line corresponds to an overlapping entry // Check if this line corresponds to an overlapping entry
let has_overlap = if !is_separator && is_by_date && line.starts_with(" ") && !line.is_empty() { let has_overlap = if !is_separator && is_by_date && line.starts_with(" ") && !line.is_empty() {
// Count which entry this is (0-based in display order) // Count which entry this is (0-based in display order)
@ -878,7 +878,7 @@ fn render_log_view(frame: &mut Frame, app: &App) {
.filter(|l| l.starts_with(" ") && !l.is_empty() && l.trim() != "---") .filter(|l| l.starts_with(" ") && !l.is_empty() && l.trim() != "---")
.count() .count()
.saturating_sub(1); .saturating_sub(1);
overlap_entry_indices.contains(&entry_idx) overlap_entry_indices.contains(&entry_idx)
} else { } else {
false false
@ -901,12 +901,10 @@ fn render_log_view(frame: &mut Frame, app: &App) {
.collect() .collect()
}; };
// Use the scroll position calculated by the app
let paragraph = Paragraph::new(text_lines) let paragraph = Paragraph::new(text_lines)
.block(block) .block(block)
.wrap(ratatui::widgets::Wrap { trim: false }) .wrap(ratatui::widgets::Wrap { trim: false });
.scroll((app.log_view_scroll as u16, 0));
frame.render_widget(paragraph, chunks[0]); frame.render_widget(paragraph, chunks[0]);
// Render help hint at bottom if enabled // Render help hint at bottom if enabled
@ -930,7 +928,7 @@ fn render_log_view_help(frame: &mut Frame, app: &App) {
"Time Periods:", "Time Periods:",
"- d: Switch to Day view (current day)", "- d: Switch to Day view (current day)",
"- w: Switch to Week view (current week)", "- w: Switch to Week view (current week)",
"- m: Switch to Month view (last 31 days)", "- m: Switch to Month view (current month)",
"", "",
"Grouping:", "Grouping:",
"- g: Toggle between grouping by Date or by Project", "- g: Toggle between grouping by Date or by Project",