19 Commits

Author SHA1 Message Date
474bb3dc07 Add project tracking picker 2026-02-03 20:59:47 +01:00
1ffcf42773 Fix bugs 2026-02-03 20:13:09 +01:00
44ddbc0f47 Add syncing 2026-02-03 16:04:47 +01:00
2e33893e29 Merge branch 'feat/taskedit' into feat/time 2026-02-03 07:40:11 +01:00
46ce91196a Merge branch 'feat/task' into feat/time 2026-02-03 07:39:59 +01:00
2b31d9bc2b Add autocompletion 2026-02-03 07:39:26 +01:00
70b6ee9bc7 Add picker to task edit 2026-02-02 20:43:08 +01:00
2baf3859fd Add tab bar 2026-02-02 19:47:18 +01:00
2940711b26 Make task details scrollable 2026-02-02 19:39:02 +01:00
f5d297e6ab Add proper fuzzy matching for time tags 2026-02-02 15:54:39 +01:00
938ed177f1 Add fuzzy matching for time tags 2026-02-02 15:41:53 +01:00
81b9d87935 Add niceties to time page 2026-02-02 12:44:12 +01:00
9940316ace Add time undo and fill 2026-02-02 11:12:09 +01:00
fc8e9481c3 Add timestamp editor 2026-02-02 10:55:47 +01:00
7032d0fa54 Add time editing 2026-02-02 10:04:54 +01:00
681ed7e635 Add time page 2026-02-02 10:04:54 +01:00
effd95f6c1 Refactor picker 2026-02-02 10:04:54 +01:00
4767a6cd91 Integrate timewarrior 2026-02-02 10:04:54 +01:00
ce193c336c Add README 2026-02-02 10:04:54 +01:00
35 changed files with 4957 additions and 283 deletions

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
.DS_Store .DS_Store
app.log app.log
test/taskchampion.sqlite3 test/taskchampion.sqlite3
tasksquire
test/*.sqlite3*

207
AGENTS.md Normal file
View File

@ -0,0 +1,207 @@
# Agent Development Guide for TaskSquire
This guide is for AI coding agents working on TaskSquire, a Go-based TUI (Terminal User Interface) for Taskwarrior.
## Project Overview
- **Language**: Go 1.22.2
- **Architecture**: Model-View-Update (MVU) pattern using Bubble Tea framework
- **Module**: `tasksquire`
- **Main Dependencies**: Bubble Tea, Lip Gloss, Huh, Bubbles (Charm ecosystem)
## Build, Test, and Lint Commands
### Building and Running
```bash
# Run directly
go run main.go
# Build binary
go build -o tasksquire main.go
# Run tests
go test ./...
# Run tests for a specific package
go test ./taskwarrior
# Run a single test
go test ./taskwarrior -run TestTaskSquire_GetContext
# Run tests with verbose output
go test -v ./taskwarrior
# Run tests with coverage
go test -cover ./...
```
### Linting and Formatting
```bash
# Format code (always run before committing)
go fmt ./...
# Lint with golangci-lint (available via nix-shell)
golangci-lint run
# Vet code for suspicious constructs
go vet ./...
# Tidy dependencies
go mod tidy
```
### Development Environment
```bash
# Enter Nix development shell (provides all tools)
nix develop
# Or use direnv (automatically loads .envrc)
direnv allow
```
## Project Structure
```
tasksquire/
├── main.go # Entry point: initializes TaskSquire, TimeSquire, and Bubble Tea
├── common/ # Shared state, components interface, keymaps, styles, utilities
├── pages/ # UI pages/views (report, taskEditor, timePage, pickers, etc.)
├── components/ # Reusable UI components (input, table, timetable, picker)
├── taskwarrior/ # Taskwarrior CLI wrapper, models, config
├── timewarrior/ # Timewarrior integration, models, config
└── test/ # Test fixtures and data
```
## Code Style Guidelines
### Imports
- **Standard Library First**: Group standard library imports, then third-party, then local
- **Local Import Pattern**: Use `tasksquire/<package>` for internal imports
```go
import (
"context"
"fmt"
"log/slog"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"tasksquire/common"
"tasksquire/taskwarrior"
)
```
### Naming Conventions
- **Exported Types**: PascalCase (e.g., `TaskSquire`, `ReportPage`, `Common`)
- **Unexported Fields**: camelCase (e.g., `configLocation`, `activeReport`, `pageStack`)
- **Interfaces**: Follow Go convention, often ending in 'er' (e.g., `TaskWarrior`, `TimeWarrior`, `Component`)
- **Constants**: PascalCase or SCREAMING_SNAKE_CASE for exported constants
- **Test Functions**: `TestFunctionName` or `TestType_Method`
### Types and Interfaces
- **Interface-Based Design**: Use interfaces for main abstractions (see `TaskWarrior`, `TimeWarrior`, `Component`)
- **Struct Composition**: Embed common state (e.g., pages embed or reference `*common.Common`)
- **Pointer Receivers**: Use pointer receivers for methods that modify state or for consistency
- **Generic Types**: Use generics where appropriate (e.g., `Stack[T]` in `common/stack.go`)
### Error Handling
- **Logging Over Panicking**: Use `log/slog` for structured logging, typically continue execution
- **Error Returns**: Return errors from functions, don't log and return
- **Context**: Errors are often logged with `slog.Error()` or `slog.Warn()` and execution continues
```go
// Typical pattern
if err != nil {
slog.Error("Failed to get tasks", "error", err)
return nil // or continue with default behavior
}
```
### Concurrency and Thread Safety
- **Mutex Protection**: Use `sync.Mutex` to protect shared state (see `TaskSquire.mu`)
- **Lock Pattern**: Lock before operations, defer unlock
```go
ts.mu.Lock()
defer ts.mu.Unlock()
```
### Configuration and Environment
- **Environment Variables**: Respect `TASKRC` and `TIMEWARRIORDB`
- **Fallback Paths**: Check standard locations (`~/.taskrc`, `~/.config/task/taskrc`)
- **Config Parsing**: Parse Taskwarrior config format manually (see `taskwarrior/config.go`)
### MVU Pattern (Bubble Tea)
- **Components Implement**: `Init() tea.Cmd`, `Update(tea.Msg) (tea.Model, tea.Cmd)`, `View() string`
- **Custom Messages**: Define custom message types for inter-component communication
- **Cmd Chaining**: Return commands from Init/Update to trigger async operations
```go
type MyMsg struct {
data string
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case MyMsg:
// Handle custom message
return m, nil
}
return m, nil
}
```
### Styling with Lip Gloss
- **Centralized Styles**: Define styles in `common/styles.go`
- **Theme Colors**: Parse colors from Taskwarrior config
- **Reusable Styles**: Create style functions, not inline styles
### Testing
- **Table-Driven Tests**: Use struct slices for test cases
- **Test Setup**: Create helper functions like `TaskWarriorTestSetup()`
- **Temp Directories**: Use `t.TempDir()` for isolated test environments
- **Prep Functions**: Include `prep func()` in test cases for setup
### Documentation
- **TODO Comments**: Mark future improvements with `// TODO: description`
- **Package Comments**: Document package purpose at the top of main files
- **Exported Functions**: Document exported functions, types, and methods
## Common Patterns
### Page Navigation
- Pages pushed onto stack via `common.PushPage()`
- Pop pages with `common.PopPage()`
- Check for subpages with `common.HasSubpages()`
### Task Operations
```go
// Get tasks for a report
tasks := ts.GetTasks(report, "filter", "args")
// Import/create task
ts.ImportTask(&task)
// Mark task done
ts.SetTaskDone(&task)
// Start/stop task
ts.StartTask(&task)
ts.StopTask(&task)
```
### JSON Handling
- Custom Marshal/Unmarshal for Task struct to handle UDAs (User Defined Attributes)
- Use `json.RawMessage` for flexible field handling
## Key Files to Reference
- `common/component.go` - Component interface definition
- `common/common.go` - Shared state container
- `taskwarrior/taskwarrior.go` - TaskWarrior interface and implementation
- `pages/main.go` - Main page router pattern
- `taskwarrior/models.go` - Data model examples
## Development Notes
- **Logging**: Application logs to `app.log` in current directory
- **Virtual Tags**: Filter out Taskwarrior virtual tags (see `virtualTags` map)
- **Color Parsing**: Custom color parsing from Taskwarrior config format
- **Debugging**: VSCode launch.json configured for remote debugging on port 43000

71
GEMINI.md Normal file
View File

@ -0,0 +1,71 @@
# Tasksquire
## Project Overview
Tasksquire is a Terminal User Interface (TUI) for [Taskwarrior](https://taskwarrior.org/), built using Go and the [Charm](https://charm.sh/) ecosystem (Bubble Tea, Lip Gloss, Huh). It provides a visual and interactive way to manage your tasks, contexts, and reports directly from the terminal.
The application functions as a wrapper around the `task` command-line tool, parsing its output (JSON, config) and executing commands to read and modify task data.
## Architecture
The project follows the standard [Bubble Tea](https://github.com/charmbracelet/bubbletea) Model-View-Update (MVU) architecture.
### Key Directories & Files
* **`main.go`**: The entry point of the application. It initializes the `TaskSquire` wrapper, sets up logging, and starts the Bubble Tea program with the `MainPage`.
* **`taskwarrior/`**: Contains the logic for interacting with the Taskwarrior CLI.
* `taskwarrior.go`: The core wrapper (`TaskSquire` struct) that executes `task` commands (`export`, `add`, `modify`, etc.) and parses results.
* `models.go`: Defines the Go structs matching Taskwarrior's data model (Tasks, Reports, Config).
* **`pages/`**: Contains the different views of the application.
* `main.go`: The top-level component (`MainPage`) that manages routing/switching between different pages.
* `report.go`: Displays lists of tasks (Taskwarrior reports).
* `taskEditor.go`: UI for creating or editing tasks.
* **`common/`**: Shared utilities, global state, and data structures used across the application.
* **`components/`**: Reusable UI components (e.g., inputs, tables).
* **`timewarrior/`**: Contains logic for integration with Timewarrior (likely in progress or planned).
## Building and Running
### Prerequisites
* **Go**: Version 1.22 or higher.
* **Taskwarrior**: The `task` binary must be installed and available in your system's `PATH`.
### Commands
To run the application directly:
```bash
go run main.go
```
To build a binary:
```bash
go build -o tasksquire main.go
```
### Nix Support
This project includes a `flake.nix` for users of the Nix package manager. You can enter a development shell with all dependencies (Go, tools) by running:
```bash
nix develop
```
## Configuration
Tasksquire respects your existing Taskwarrior configuration (`.taskrc`). It looks for the configuration file in the following order:
1. `TASKRC` environment variable.
2. `$HOME/.taskrc`
3. `$HOME/.config/task/taskrc`
Logging is written to `app.log` in the current working directory.
## Development Conventions
* **UI Framework**: Uses [Bubble Tea](https://github.com/charmbracelet/bubbletea) for the TUI loop.
* **Styling**: Uses [Lip Gloss](https://github.com/charmbracelet/lipgloss) for terminal styling.
* **Forms**: Uses [Huh](https://github.com/charmbracelet/huh) for form inputs.
* **Logging**: Uses `log/slog` for structured logging.

20
README.md Normal file
View File

@ -0,0 +1,20 @@
# TODO
- [>] Add default tags
- Default tags should be defined in the config and always displayed in the tag picker
- [ ] Add project manager
- [ ] Add projects that are always displayed in the project picker
- Saved in config
- [ ] Remove/archive projects
- [ ] Integrate timewarrior
- [ ] Add default timetracking items when addind projects
- [ ] Create interface for timewarrior input
- [ ] Create daily/weekly reports for HRM input
- Combine by project
- Combine by task names
- Combine by tags
- [ ] Add tag manager
- [ ] Edit default tags
- [ ] Update to bubbletea 2.0
# Done
- [x] Use JJ

View File

@ -6,6 +6,7 @@ import (
"os" "os"
"tasksquire/taskwarrior" "tasksquire/taskwarrior"
"tasksquire/timewarrior"
"golang.org/x/term" "golang.org/x/term"
) )
@ -13,6 +14,7 @@ import (
type Common struct { type Common struct {
Ctx context.Context Ctx context.Context
TW taskwarrior.TaskWarrior TW taskwarrior.TaskWarrior
TimeW timewarrior.TimeWarrior
Keymap *Keymap Keymap *Keymap
Styles *Styles Styles *Styles
Udas []taskwarrior.Uda Udas []taskwarrior.Uda
@ -22,10 +24,11 @@ type Common struct {
height int height int
} }
func NewCommon(ctx context.Context, tw taskwarrior.TaskWarrior) *Common { func NewCommon(ctx context.Context, tw taskwarrior.TaskWarrior, timeW timewarrior.TimeWarrior) *Common {
return &Common{ return &Common{
Ctx: ctx, Ctx: ctx,
TW: tw, TW: tw,
TimeW: timeW,
Keymap: NewKeymap(), Keymap: NewKeymap(),
Styles: NewStyles(tw.GetConfig()), Styles: NewStyles(tw.GetConfig()),
Udas: tw.GetUdas(), Udas: tw.GetUdas(),
@ -62,3 +65,7 @@ func (c *Common) PopPage() (Component, error) {
component.SetSize(c.width, c.height) component.SetSize(c.width, c.height)
return component, nil return component, nil
} }
func (c *Common) HasSubpages() bool {
return !c.pageStack.IsEmpty()
}

View File

@ -24,11 +24,14 @@ type Keymap struct {
SetReport key.Binding SetReport key.Binding
SetContext key.Binding SetContext key.Binding
SetProject key.Binding SetProject key.Binding
PickProjectTask key.Binding
Select key.Binding Select key.Binding
Insert key.Binding Insert key.Binding
Tag key.Binding Tag key.Binding
Undo key.Binding Undo key.Binding
Fill key.Binding
StartStop key.Binding StartStop key.Binding
Join key.Binding
} }
// TODO: use config values for key bindings // TODO: use config values for key bindings
@ -125,6 +128,11 @@ func NewKeymap() *Keymap {
key.WithHelp("p", "Set project"), key.WithHelp("p", "Set project"),
), ),
PickProjectTask: key.NewBinding(
key.WithKeys("P"),
key.WithHelp("P", "Pick project task"),
),
Select: key.NewBinding( Select: key.NewBinding(
key.WithKeys(" "), key.WithKeys(" "),
key.WithHelp("space", "Select"), key.WithHelp("space", "Select"),
@ -145,9 +153,19 @@ func NewKeymap() *Keymap {
key.WithHelp("undo", "Undo"), key.WithHelp("undo", "Undo"),
), ),
Fill: key.NewBinding(
key.WithKeys("f"),
key.WithHelp("fill", "Fill gaps"),
),
StartStop: key.NewBinding( StartStop: key.NewBinding(
key.WithKeys("s"), key.WithKeys("s"),
key.WithHelp("start/stop", "Start/Stop"), key.WithHelp("start/stop", "Start/Stop"),
), ),
Join: key.NewBinding(
key.WithKeys("J"),
key.WithHelp("J", "Join with previous"),
),
} }
} }

View File

@ -38,3 +38,10 @@ func (s *Stack[T]) Pop() (T, error) {
return item, nil return item, nil
} }
func (s *Stack[T]) IsEmpty() bool {
s.mutex.Lock()
defer s.mutex.Unlock()
return len(s.items) == 0
}

View File

@ -27,6 +27,10 @@ type Styles struct {
Form *huh.Theme Form *huh.Theme
TableStyle TableStyle TableStyle TableStyle
Tab lipgloss.Style
ActiveTab lipgloss.Style
TabBar lipgloss.Style
ColumnFocused lipgloss.Style ColumnFocused lipgloss.Style
ColumnBlurred lipgloss.Style ColumnBlurred lipgloss.Style
ColumnInsert lipgloss.Style ColumnInsert lipgloss.Style
@ -71,6 +75,19 @@ func NewStyles(config *taskwarrior.TWConfig) *Styles {
styles.Form = formTheme styles.Form = formTheme
styles.Tab = lipgloss.NewStyle().
Padding(0, 1).
Foreground(lipgloss.Color("240"))
styles.ActiveTab = styles.Tab.
Foreground(lipgloss.Color("252")).
Bold(true)
styles.TabBar = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, true, false).
BorderForeground(lipgloss.Color("240")).
MarginBottom(1)
styles.ColumnFocused = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1) styles.ColumnFocused = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1)
styles.ColumnBlurred = lipgloss.NewStyle().Border(lipgloss.HiddenBorder(), true).Padding(1) styles.ColumnBlurred = lipgloss.NewStyle().Border(lipgloss.HiddenBorder(), true).Padding(1)
styles.ColumnInsert = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1) styles.ColumnInsert = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1)

85
common/sync.go Normal file
View File

@ -0,0 +1,85 @@
package common
import (
"log/slog"
"tasksquire/taskwarrior"
"tasksquire/timewarrior"
)
// FindTaskByUUID queries Taskwarrior for a task with the given UUID.
// Returns nil if not found.
func FindTaskByUUID(tw taskwarrior.TaskWarrior, uuid string) *taskwarrior.Task {
if uuid == "" {
return nil
}
// Use empty report to query by UUID filter
report := &taskwarrior.Report{Name: ""}
tasks := tw.GetTasks(report, "uuid:"+uuid)
if len(tasks) > 0 {
return tasks[0]
}
return nil
}
// SyncIntervalToTask synchronizes a Timewarrior interval's state to the corresponding Taskwarrior task.
// Action should be "start" or "stop".
// This function is idempotent and handles edge cases gracefully.
func SyncIntervalToTask(interval *timewarrior.Interval, tw taskwarrior.TaskWarrior, action string) {
if interval == nil {
return
}
// Extract UUID from interval tags
uuid := timewarrior.ExtractUUID(interval.Tags)
if uuid == "" {
slog.Debug("Interval has no UUID tag, skipping task sync",
"intervalID", interval.ID)
return
}
// Find corresponding task
task := FindTaskByUUID(tw, uuid)
if task == nil {
slog.Warn("Task not found for UUID, skipping sync",
"uuid", uuid)
return
}
// Perform sync action
switch action {
case "start":
// Start task if it's pending (idempotent - taskwarrior handles already-started tasks)
if task.Status == "pending" {
slog.Info("Starting Taskwarrior task from interval",
"uuid", uuid,
"description", task.Description,
"alreadyStarted", task.Start != "")
tw.StartTask(task)
} else {
slog.Debug("Task not pending, skipping start",
"uuid", uuid,
"status", task.Status)
}
case "stop":
// Only stop if task is pending and currently started
if task.Status == "pending" && task.Start != "" {
slog.Info("Stopping Taskwarrior task from interval",
"uuid", uuid,
"description", task.Description)
tw.StopTask(task)
} else {
slog.Debug("Task not started or not pending, skipping stop",
"uuid", uuid,
"status", task.Status,
"hasStart", task.Start != "")
}
default:
slog.Error("Unknown sync action", "action", action)
}
}

View File

@ -0,0 +1,293 @@
package autocomplete
import (
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sahilm/fuzzy"
)
type Autocomplete struct {
input textinput.Model
allSuggestions []string // All available suggestions (newest first)
filteredSuggestions []string // Currently matching suggestions
matchedIndexes [][]int // Matched character positions for each suggestion
selectedIndex int // -1 = input focused, 0+ = suggestion selected
showSuggestions bool // Whether to display suggestion box
maxVisible int // Max suggestions to show
minChars int // Min chars before showing suggestions
focused bool
width int
placeholder string
}
// New creates a new autocomplete component
func New(suggestions []string, minChars int, maxVisible int) *Autocomplete {
ti := textinput.New()
ti.Width = 50
return &Autocomplete{
input: ti,
allSuggestions: suggestions,
selectedIndex: -1,
maxVisible: maxVisible,
minChars: minChars,
width: 50,
}
}
// SetValue sets the input value
func (a *Autocomplete) SetValue(value string) {
a.input.SetValue(value)
a.updateFilteredSuggestions()
}
// GetValue returns the current input value
func (a *Autocomplete) GetValue() string {
return a.input.Value()
}
// Focus focuses the autocomplete input
func (a *Autocomplete) Focus() {
a.focused = true
a.input.Focus()
}
// Blur blurs the autocomplete input
func (a *Autocomplete) Blur() {
a.focused = false
a.input.Blur()
a.showSuggestions = false
}
// SetPlaceholder sets the placeholder text
func (a *Autocomplete) SetPlaceholder(placeholder string) {
a.placeholder = placeholder
a.input.Placeholder = placeholder
}
// SetWidth sets the width of the autocomplete
func (a *Autocomplete) SetWidth(width int) {
a.width = width
a.input.Width = width
}
// SetMaxVisible sets the maximum number of visible suggestions
func (a *Autocomplete) SetMaxVisible(max int) {
a.maxVisible = max
}
// SetMinChars sets the minimum characters required before showing suggestions
func (a *Autocomplete) SetMinChars(min int) {
a.minChars = min
}
// SetSuggestions updates the available suggestions
func (a *Autocomplete) SetSuggestions(suggestions []string) {
a.allSuggestions = suggestions
a.updateFilteredSuggestions()
}
// HasSuggestions returns true if the autocomplete is currently showing suggestions
func (a *Autocomplete) HasSuggestions() bool {
return a.showSuggestions && len(a.filteredSuggestions) > 0
}
// Init initializes the autocomplete
func (a *Autocomplete) Init() tea.Cmd {
return textinput.Blink
}
// Update handles messages for the autocomplete
func (a *Autocomplete) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if !a.focused {
return a, nil
}
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, key.NewBinding(key.WithKeys("down"))):
if a.showSuggestions && len(a.filteredSuggestions) > 0 {
a.selectedIndex++
if a.selectedIndex >= len(a.filteredSuggestions) {
a.selectedIndex = 0
}
return a, nil
}
case key.Matches(msg, key.NewBinding(key.WithKeys("up"))):
if a.showSuggestions && len(a.filteredSuggestions) > 0 {
a.selectedIndex--
if a.selectedIndex < 0 {
a.selectedIndex = len(a.filteredSuggestions) - 1
}
return a, nil
}
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
if a.showSuggestions && a.selectedIndex >= 0 && a.selectedIndex < len(a.filteredSuggestions) {
// Accept selected suggestion
a.input.SetValue(a.filteredSuggestions[a.selectedIndex])
a.showSuggestions = false
a.selectedIndex = -1
return a, nil
}
case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
if a.showSuggestions && len(a.filteredSuggestions) > 0 {
// Accept first or selected suggestion
if a.selectedIndex >= 0 && a.selectedIndex < len(a.filteredSuggestions) {
a.input.SetValue(a.filteredSuggestions[a.selectedIndex])
} else {
a.input.SetValue(a.filteredSuggestions[0])
}
a.showSuggestions = false
a.selectedIndex = -1
return a, nil
}
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
if a.showSuggestions {
a.showSuggestions = false
a.selectedIndex = -1
return a, nil
}
default:
// Handle regular text input
prevValue := a.input.Value()
a.input, cmd = a.input.Update(msg)
// Update suggestions if value changed
if a.input.Value() != prevValue {
a.updateFilteredSuggestions()
}
return a, cmd
}
}
a.input, cmd = a.input.Update(msg)
return a, cmd
}
// View renders the autocomplete
func (a *Autocomplete) View() string {
// Input field
inputView := a.input.View()
if !a.showSuggestions || len(a.filteredSuggestions) == 0 {
return inputView
}
// Suggestion box
var suggestionViews []string
for i, suggestion := range a.filteredSuggestions {
if i >= a.maxVisible {
break
}
prefix := " "
baseStyle := lipgloss.NewStyle().Padding(0, 1)
if i == a.selectedIndex {
// Highlight selected suggestion
baseStyle = baseStyle.Bold(true).Foreground(lipgloss.Color("12"))
prefix = "→ "
}
// Build suggestion with highlighted matched characters
var rendered string
if i < len(a.matchedIndexes) {
rendered = a.renderWithHighlights(suggestion, a.matchedIndexes[i], i == a.selectedIndex)
} else {
rendered = suggestion
}
suggestionViews = append(suggestionViews, baseStyle.Render(prefix+rendered))
}
// Box style
boxStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("8")).
Width(a.width)
suggestionsBox := boxStyle.Render(
lipgloss.JoinVertical(lipgloss.Left, suggestionViews...),
)
return lipgloss.JoinVertical(lipgloss.Left, inputView, suggestionsBox)
}
// renderWithHighlights renders a suggestion with matched characters highlighted
func (a *Autocomplete) renderWithHighlights(str string, matchedIndexes []int, isSelected bool) string {
if len(matchedIndexes) == 0 {
return str
}
// Create a map for quick lookup
matchedMap := make(map[int]bool)
for _, idx := range matchedIndexes {
matchedMap[idx] = true
}
// Choose highlight style based on selection state
var highlightStyle lipgloss.Style
if isSelected {
// When selected, use underline to distinguish from selection bold
highlightStyle = lipgloss.NewStyle().Underline(true)
} else {
// When not selected, use bold and accent color
highlightStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
}
// Build the string with highlights
var result string
runes := []rune(str)
for i, r := range runes {
if matchedMap[i] {
result += highlightStyle.Render(string(r))
} else {
result += string(r)
}
}
return result
}
// updateFilteredSuggestions filters suggestions based on current input
func (a *Autocomplete) updateFilteredSuggestions() {
value := a.input.Value()
// Only show if >= minChars
if len(value) < a.minChars {
a.showSuggestions = false
a.filteredSuggestions = nil
a.matchedIndexes = nil
a.selectedIndex = -1
return
}
// Fuzzy match using sahilm/fuzzy
matches := fuzzy.Find(value, a.allSuggestions)
var filtered []string
var indexes [][]int
for _, match := range matches {
filtered = append(filtered, match.Str)
indexes = append(indexes, match.MatchedIndexes)
if len(filtered) >= a.maxVisible {
break
}
}
a.filteredSuggestions = filtered
a.matchedIndexes = indexes
a.showSuggestions = len(filtered) > 0 && a.focused
a.selectedIndex = -1 // Reset to input
}

View File

@ -637,6 +637,11 @@ func (m *MultiSelect) GetValue() any {
return *m.value return *m.value
} }
// IsFiltering returns true if the multi-select is currently filtering.
func (m *MultiSelect) IsFiltering() bool {
return m.filtering
}
func min(a, b int) int { func min(a, b int) int {
if a < b { if a < b {
return a return a

272
components/picker/picker.go Normal file
View File

@ -0,0 +1,272 @@
package picker
import (
"tasksquire/common"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type Item struct {
text string
}
func NewItem(text string) Item { return Item{text: text} }
func (i Item) Title() string { return i.text }
func (i Item) Description() string { return "" }
func (i Item) FilterValue() string { return i.text }
// creationItem is a special item for creating new entries
type creationItem struct {
text string
filter string
}
func (i creationItem) Title() string { return i.text }
func (i creationItem) Description() string { return "" }
func (i creationItem) FilterValue() string { return i.filter }
type Picker struct {
common *common.Common
list list.Model
itemProvider func() []list.Item
onSelect func(list.Item) tea.Cmd
onCreate func(string) tea.Cmd
title string
filterByDefault bool
defaultValue string
baseItems []list.Item
focused bool
}
type PickerOption func(*Picker)
func WithFilterByDefault(enabled bool) PickerOption {
return func(p *Picker) {
p.filterByDefault = enabled
}
}
func WithOnCreate(onCreate func(string) tea.Cmd) PickerOption {
return func(p *Picker) {
p.onCreate = onCreate
}
}
func WithDefaultValue(value string) PickerOption {
return func(p *Picker) {
p.defaultValue = value
}
}
func (p *Picker) Focus() tea.Cmd {
p.focused = true
return nil
}
func (p *Picker) Blur() tea.Cmd {
p.focused = false
return nil
}
func (p *Picker) GetValue() string {
item := p.list.SelectedItem()
if item == nil {
return ""
}
return item.FilterValue()
}
func New(
c *common.Common,
title string,
itemProvider func() []list.Item,
onSelect func(list.Item) tea.Cmd,
opts ...PickerOption,
) *Picker {
delegate := list.NewDefaultDelegate()
delegate.ShowDescription = false
delegate.SetSpacing(0)
l := list.New([]list.Item{}, delegate, 0, 0)
l.SetShowTitle(false)
l.SetShowHelp(false)
l.SetShowStatusBar(false)
l.SetFilteringEnabled(true)
l.Filter = list.UnsortedFilter // Preserve item order, don't rank by match quality
// Custom key for filtering (insert mode)
l.KeyMap.Filter = key.NewBinding(
key.WithKeys("i"),
key.WithHelp("i", "filter"),
)
p := &Picker{
common: c,
list: l,
itemProvider: itemProvider,
onSelect: onSelect,
title: title,
focused: true,
}
if c.TW.GetConfig().Get("uda.tasksquire.picker.filter_by_default") == "yes" {
p.filterByDefault = true
}
for _, opt := range opts {
opt(p)
}
// If a default value is provided, don't start in filter mode
if p.defaultValue != "" {
p.filterByDefault = false
}
if p.filterByDefault {
// Manually trigger filter mode on the list so it doesn't require a global key press
p.list, _ = p.list.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}})
}
// Refresh items after entering filter mode to ensure they're visible
p.Refresh()
// If a default value is provided, select the corresponding item
if p.defaultValue != "" {
p.SelectItemByFilterValue(p.defaultValue)
}
return p
}
func (p *Picker) Refresh() tea.Cmd {
p.baseItems = p.itemProvider()
return p.updateListItems()
}
func (p *Picker) updateListItems() tea.Cmd {
return p.updateListItemsWithFilter(p.list.FilterValue())
}
func (p *Picker) updateListItemsWithFilter(filterVal string) tea.Cmd {
items := make([]list.Item, 0, len(p.baseItems)+1)
// First add all base items
items = append(items, p.baseItems...)
if p.onCreate != nil && filterVal != "" {
// Add the creation item at the end (bottom of the list)
newItem := creationItem{
text: "(new) " + filterVal,
filter: filterVal,
}
items = append(items, newItem)
}
return p.list.SetItems(items)
}
func (p *Picker) SetSize(width, height int) {
// We do NOT set common.SetSize here, as we are a sub-component.
// Set list size. The parent is responsible for providing a reasonable size.
// If this component is intended to fill a page, width/height will be large.
// If it's a small embedded box, they will be small.
// We apply a small margin for the title if needed, but for now we just pass through
// minus a header gap if we render a title.
headerHeight := 2 // Title + gap
p.list.SetSize(width, height-headerHeight)
}
func (p *Picker) Init() tea.Cmd {
// Trigger list item update to ensure items are properly displayed,
// especially when in filter mode with an empty filter
return p.updateListItems()
}
func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if !p.focused {
return p, nil
}
var cmd tea.Cmd
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
// If filtering, update items with predicted filter before list processes the key
if p.list.FilterState() == list.Filtering {
currentFilter := p.list.FilterValue()
predictedFilter := currentFilter
// Predict what the filter will be after this key
switch msg.Type {
case tea.KeyRunes:
predictedFilter = currentFilter + string(msg.Runes)
case tea.KeyBackspace:
if len(currentFilter) > 0 {
predictedFilter = currentFilter[:len(currentFilter)-1]
}
}
// Update items with predicted filter before list processes the message
if predictedFilter != currentFilter {
preCmd := p.updateListItemsWithFilter(predictedFilter)
cmds = append(cmds, preCmd)
}
break // Pass to list.Update
}
switch {
case key.Matches(msg, p.common.Keymap.Ok):
selectedItem := p.list.SelectedItem()
if selectedItem == nil {
return p, nil
}
return p, p.handleSelect(selectedItem)
}
}
p.list, cmd = p.list.Update(msg)
cmds = append(cmds, cmd)
return p, tea.Batch(cmds...)
}
func (p *Picker) handleSelect(item list.Item) tea.Cmd {
if cItem, ok := item.(creationItem); ok {
if p.onCreate != nil {
return p.onCreate(cItem.filter)
}
}
return p.onSelect(item)
}
func (p *Picker) View() string {
var title string
if p.focused {
title = p.common.Styles.Form.Focused.Title.Render(p.title)
} else {
title = p.common.Styles.Form.Blurred.Title.Render(p.title)
}
return lipgloss.JoinVertical(lipgloss.Left, title, p.list.View())
}
func (p *Picker) IsFiltering() bool {
return p.list.FilterState() == list.Filtering
}
// SelectItemByFilterValue selects the item with the given filter value
func (p *Picker) SelectItemByFilterValue(filterValue string) {
items := p.list.Items()
for i, item := range items {
if item.FilterValue() == filterValue {
p.list.Select(i)
break
}
}
}

View File

@ -0,0 +1,409 @@
package timestampeditor
import (
"log/slog"
"strings"
"tasksquire/common"
"time"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const (
timeFormat = "20060102T150405Z" // Timewarrior format
)
// Field represents which field is currently focused
type Field int
const (
TimeField Field = iota
DateField
)
// TimestampEditor is a component for editing timestamps with separate time and date fields
type TimestampEditor struct {
common *common.Common
// Current timestamp value
timestamp time.Time
isEmpty bool // Track if timestamp is unset
// UI state
focused bool
currentField Field
// Dimensions
width int
height int
// Title and description
title string
description string
// Validation
validate func(time.Time) error
err error
}
// New creates a new TimestampEditor with no initial timestamp
func New(com *common.Common) *TimestampEditor {
return &TimestampEditor{
common: com,
timestamp: time.Time{}, // Zero time
isEmpty: true, // Start empty
focused: false,
currentField: TimeField,
validate: func(time.Time) error { return nil },
}
}
// Title sets the title of the timestamp editor
func (t *TimestampEditor) Title(title string) *TimestampEditor {
t.title = title
return t
}
// Description sets the description of the timestamp editor
func (t *TimestampEditor) Description(description string) *TimestampEditor {
t.description = description
return t
}
// Value sets the initial timestamp value
func (t *TimestampEditor) Value(timestamp time.Time) *TimestampEditor {
t.timestamp = timestamp
t.isEmpty = timestamp.IsZero()
return t
}
// ValueFromString sets the initial timestamp from a timewarrior format string
func (t *TimestampEditor) ValueFromString(s string) *TimestampEditor {
if s == "" {
t.timestamp = time.Time{}
t.isEmpty = true
return t
}
parsed, err := time.Parse(timeFormat, s)
if err != nil {
slog.Error("Failed to parse timestamp", "error", err)
t.timestamp = time.Time{}
t.isEmpty = true
return t
}
t.timestamp = parsed.Local()
t.isEmpty = false
return t
}
// GetValue returns the current timestamp
func (t *TimestampEditor) GetValue() time.Time {
return t.timestamp
}
// GetValueString returns the timestamp in timewarrior format, or empty string if unset
func (t *TimestampEditor) GetValueString() string {
if t.isEmpty {
return ""
}
return t.timestamp.UTC().Format(timeFormat)
}
// Validate sets the validation function
func (t *TimestampEditor) Validate(validate func(time.Time) error) *TimestampEditor {
t.validate = validate
return t
}
// Error returns the validation error
func (t *TimestampEditor) Error() error {
return t.err
}
// Focus focuses the timestamp editor
func (t *TimestampEditor) Focus() tea.Cmd {
t.focused = true
return nil
}
// Blur blurs the timestamp editor
func (t *TimestampEditor) Blur() tea.Cmd {
t.focused = false
t.err = t.validate(t.timestamp)
return nil
}
// SetSize sets the size of the timestamp editor
func (t *TimestampEditor) SetSize(width, height int) {
t.width = width
t.height = height
}
// Init initializes the timestamp editor
func (t *TimestampEditor) Init() tea.Cmd {
return nil
}
// Update handles messages for the timestamp editor
func (t *TimestampEditor) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if !t.focused {
return t, nil
}
switch msg := msg.(type) {
case tea.KeyMsg:
t.err = nil
switch msg.String() {
// Navigation between fields
case "h", "left":
t.currentField = TimeField
case "l", "right":
t.currentField = DateField
// Time field adjustments (lowercase - 5 minutes)
case "j":
// Set current time on first edit if empty
if t.isEmpty {
t.setCurrentTime()
}
if t.currentField == TimeField {
t.adjustTime(-5)
} else {
t.adjustDate(-1)
}
case "k":
// Set current time on first edit if empty
if t.isEmpty {
t.setCurrentTime()
}
if t.currentField == TimeField {
t.adjustTime(5)
} else {
t.adjustDate(1)
}
// Time field adjustments (uppercase - 30 minutes) or date adjustments (week)
case "J":
// Set current time on first edit if empty
if t.isEmpty {
t.setCurrentTime()
}
if t.currentField == TimeField {
t.adjustTime(-30)
} else {
t.adjustDate(-7)
}
case "K":
// Set current time on first edit if empty
if t.isEmpty {
t.setCurrentTime()
}
if t.currentField == TimeField {
t.adjustTime(30)
} else {
t.adjustDate(7)
}
// Remove timestamp
case "d":
t.timestamp = time.Time{}
t.isEmpty = true
}
}
return t, nil
}
// setCurrentTime sets the timestamp to the current time and marks it as not empty
func (t *TimestampEditor) setCurrentTime() {
now := time.Now()
// Snap to nearest 5 minutes
minute := now.Minute()
remainder := minute % 5
if remainder != 0 {
if remainder < 3 {
// Round down
now = now.Add(-time.Duration(remainder) * time.Minute)
} else {
// Round up
now = now.Add(time.Duration(5-remainder) * time.Minute)
}
}
// Zero out seconds and nanoseconds
t.timestamp = time.Date(
now.Year(),
now.Month(),
now.Day(),
now.Hour(),
now.Minute(),
0, 0,
now.Location(),
)
t.isEmpty = false
}
// adjustTime adjusts the time by the given number of minutes and snaps to nearest 5 minutes
func (t *TimestampEditor) adjustTime(minutes int) {
// Add the minutes
t.timestamp = t.timestamp.Add(time.Duration(minutes) * time.Minute)
// Snap to nearest 5 minutes
minute := t.timestamp.Minute()
remainder := minute % 5
if remainder != 0 {
if remainder < 3 {
// Round down
t.timestamp = t.timestamp.Add(-time.Duration(remainder) * time.Minute)
} else {
// Round up
t.timestamp = t.timestamp.Add(time.Duration(5-remainder) * time.Minute)
}
}
// Zero out seconds and nanoseconds
t.timestamp = time.Date(
t.timestamp.Year(),
t.timestamp.Month(),
t.timestamp.Day(),
t.timestamp.Hour(),
t.timestamp.Minute(),
0, 0,
t.timestamp.Location(),
)
}
// adjustDate adjusts the date by the given number of days
func (t *TimestampEditor) adjustDate(days int) {
t.timestamp = t.timestamp.AddDate(0, 0, days)
}
// View renders the timestamp editor
func (t *TimestampEditor) View() string {
var sb strings.Builder
styles := t.getStyles()
// Render title if present
if t.title != "" {
sb.WriteString(styles.title.Render(t.title))
if t.err != nil {
sb.WriteString(styles.errorIndicator.String())
}
sb.WriteString("\n")
}
// Render description if present
if t.description != "" {
sb.WriteString(styles.description.Render(t.description))
sb.WriteString("\n")
}
// Render the time and date fields side by side
var timeStr, dateStr string
if t.isEmpty {
timeStr = "--:--"
dateStr = "--- ----------"
} else {
timeStr = t.timestamp.Format("15:04")
dateStr = t.timestamp.Format("Mon 2006-01-02")
}
var timeField, dateField string
if t.currentField == TimeField {
timeField = styles.selectedField.Render(timeStr)
dateField = styles.unselectedField.Render(dateStr)
} else {
timeField = styles.unselectedField.Render(timeStr)
dateField = styles.selectedField.Render(dateStr)
}
fieldsRow := lipgloss.JoinHorizontal(lipgloss.Top, timeField, " ", dateField)
sb.WriteString(fieldsRow)
return styles.base.Render(sb.String())
}
// getHelpText returns the help text based on the current field
func (t *TimestampEditor) getHelpText() string {
if t.currentField == TimeField {
return "h/l: switch field • j/k: ±5min • J/K: ±30min • d: remove"
}
return "h/l: switch field • j/k: ±1day • J/K: ±1week • d: remove"
}
// Styles for the timestamp editor
type timestampEditorStyles struct {
base lipgloss.Style
title lipgloss.Style
description lipgloss.Style
errorIndicator lipgloss.Style
selectedField lipgloss.Style
unselectedField lipgloss.Style
help lipgloss.Style
}
// getStyles returns the styles for the timestamp editor
func (t *TimestampEditor) getStyles() timestampEditorStyles {
theme := t.common.Styles.Form
var styles timestampEditorStyles
if t.focused {
styles.base = lipgloss.NewStyle()
styles.title = theme.Focused.Title
styles.description = theme.Focused.Description
styles.errorIndicator = theme.Focused.ErrorIndicator
styles.selectedField = lipgloss.NewStyle().
Bold(true).
Padding(0, 2).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("12"))
styles.unselectedField = lipgloss.NewStyle().
Padding(0, 2).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("8"))
styles.help = lipgloss.NewStyle().
Foreground(lipgloss.Color("8")).
Italic(true)
} else {
styles.base = lipgloss.NewStyle()
styles.title = theme.Blurred.Title
styles.description = theme.Blurred.Description
styles.errorIndicator = theme.Blurred.ErrorIndicator
styles.selectedField = lipgloss.NewStyle().
Padding(0, 2).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("8"))
styles.unselectedField = lipgloss.NewStyle().
Padding(0, 2).
Border(lipgloss.HiddenBorder())
styles.help = lipgloss.NewStyle().
Foreground(lipgloss.Color("8"))
}
return styles
}
// Skip returns whether the timestamp editor should be skipped
func (t *TimestampEditor) Skip() bool {
return false
}
// Zoom returns whether the timestamp editor should be zoomed
func (t *TimestampEditor) Zoom() bool {
return false
}
// KeyBinds returns the key bindings for the timestamp editor
func (t *TimestampEditor) KeyBinds() []key.Binding {
return []key.Binding{
t.common.Keymap.Left,
t.common.Keymap.Right,
t.common.Keymap.Up,
t.common.Keymap.Down,
}
}

View File

@ -0,0 +1,582 @@
package timetable
import (
"strings"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
"tasksquire/common"
"tasksquire/timewarrior"
)
// Model defines a state for the table widget.
type Model struct {
common *common.Common
KeyMap KeyMap
cols []Column
rows timewarrior.Intervals
rowStyles []lipgloss.Style
cursor int
focus bool
styles common.TableStyle
styleFunc StyleFunc
viewport viewport.Model
start int
end int
}
// Row represents one line in the table.
type Row *timewarrior.Interval
// Column defines the table structure.
type Column struct {
Title string
Name string
Width int
MaxWidth int
ContentWidth int
}
// KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which
// is used to render the menu.
type KeyMap struct {
LineUp key.Binding
LineDown key.Binding
PageUp key.Binding
PageDown key.Binding
HalfPageUp key.Binding
HalfPageDown key.Binding
GotoTop key.Binding
GotoBottom key.Binding
}
// ShortHelp implements the KeyMap interface.
func (km KeyMap) ShortHelp() []key.Binding {
return []key.Binding{km.LineUp, km.LineDown}
}
// FullHelp implements the KeyMap interface.
func (km KeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{km.LineUp, km.LineDown, km.GotoTop, km.GotoBottom},
{km.PageUp, km.PageDown, km.HalfPageUp, km.HalfPageDown},
}
}
// DefaultKeyMap returns a default set of keybindings.
func DefaultKeyMap() KeyMap {
const spacebar = " "
return KeyMap{
LineUp: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑/k", "up"),
),
LineDown: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓/j", "down"),
),
PageUp: key.NewBinding(
key.WithKeys("b", "pgup"),
key.WithHelp("b/pgup", "page up"),
),
PageDown: key.NewBinding(
key.WithKeys("f", "pgdown", spacebar),
key.WithHelp("f/pgdn", "page down"),
),
HalfPageUp: key.NewBinding(
key.WithKeys("u", "ctrl+u"),
key.WithHelp("u", "½ page up"),
),
HalfPageDown: key.NewBinding(
key.WithKeys("d", "ctrl+d"),
key.WithHelp("d", "½ page down"),
),
GotoTop: key.NewBinding(
key.WithKeys("home", "g"),
key.WithHelp("g/home", "go to start"),
),
GotoBottom: key.NewBinding(
key.WithKeys("end", "G"),
key.WithHelp("G/end", "go to end"),
),
}
}
// SetStyles sets the table styles.
func (m *Model) SetStyles(s common.TableStyle) {
m.styles = s
m.UpdateViewport()
}
// Option is used to set options in New. For example:
//
// table := New(WithColumns([]Column{{Title: "ID", Width: 10}}))
type Option func(*Model)
// New creates a new model for the table widget.
func New(com *common.Common, opts ...Option) Model {
m := Model{
common: com,
cursor: 0,
viewport: viewport.New(0, 20),
KeyMap: DefaultKeyMap(),
}
for _, opt := range opts {
opt(&m)
}
m.cols = m.parseColumns(m.cols)
m.rowStyles = m.parseRowStyles(m.rows)
m.UpdateViewport()
return m
}
func (m *Model) parseRowStyles(rows timewarrior.Intervals) []lipgloss.Style {
styles := make([]lipgloss.Style, len(rows))
if len(rows) == 0 {
return styles
}
for i := range rows {
// Default style
styles[i] = m.common.Styles.Base.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
// If active, maybe highlight?
if rows[i].IsActive() {
if c, ok := m.common.Styles.Colors["active"]; ok && c != nil {
styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
}
}
}
return styles
}
func (m *Model) parseColumns(cols []Column) []Column {
if len(cols) == 0 {
return cols
}
for i, col := range cols {
for _, interval := range m.rows {
col.ContentWidth = max(col.ContentWidth, lipgloss.Width(interval.GetString(col.Name)))
}
cols[i] = col
}
combinedSize := 0
nonZeroWidths := 0
tagIndex := -1
for i, col := range cols {
if col.ContentWidth > 0 {
col.Width = max(col.ContentWidth, lipgloss.Width(col.Title))
nonZeroWidths++
}
if !strings.Contains(col.Name, "tags") {
combinedSize += col.Width
} else {
tagIndex = i
}
cols[i] = col
}
if tagIndex >= 0 {
cols[tagIndex].Width = m.Width() - combinedSize - nonZeroWidths
}
return cols
}
// WithColumns sets the table columns (headers).
func WithColumns(cols []Column) Option {
return func(m *Model) {
m.cols = cols
}
}
// WithRows sets the table rows (data).
func WithIntervals(rows timewarrior.Intervals) Option {
return func(m *Model) {
m.rows = rows
}
}
// WithHeight sets the height of the table.
func WithHeight(h int) Option {
return func(m *Model) {
m.viewport.Height = h - lipgloss.Height(m.headersView())
}
}
// WithWidth sets the width of the table.
func WithWidth(w int) Option {
return func(m *Model) {
m.viewport.Width = w
}
}
// WithFocused sets the focus state of the table.
func WithFocused(f bool) Option {
return func(m *Model) {
m.focus = f
}
}
// WithStyles sets the table styles.
func WithStyles(s common.TableStyle) Option {
return func(m *Model) {
m.styles = s
}
}
// WithStyleFunc sets the table style func which can determine a cell style per column, row, and selected state.
func WithStyleFunc(f StyleFunc) Option {
return func(m *Model) {
m.styleFunc = f
}
}
// WithKeyMap sets the key map.
func WithKeyMap(km KeyMap) Option {
return func(m *Model) {
m.KeyMap = km
}
}
// Update is the Bubble Tea update loop.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if !m.focus {
return m, nil
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.KeyMap.LineUp):
m.MoveUp(1)
case key.Matches(msg, m.KeyMap.LineDown):
m.MoveDown(1)
case key.Matches(msg, m.KeyMap.PageUp):
m.MoveUp(m.viewport.Height)
case key.Matches(msg, m.KeyMap.PageDown):
m.MoveDown(m.viewport.Height)
case key.Matches(msg, m.KeyMap.HalfPageUp):
m.MoveUp(m.viewport.Height / 2)
case key.Matches(msg, m.KeyMap.HalfPageDown):
m.MoveDown(m.viewport.Height / 2)
case key.Matches(msg, m.KeyMap.LineDown):
m.MoveDown(1)
case key.Matches(msg, m.KeyMap.GotoTop):
m.GotoTop()
case key.Matches(msg, m.KeyMap.GotoBottom):
m.GotoBottom()
}
}
return m, nil
}
// Focused returns the focus state of the table.
func (m Model) Focused() bool {
return m.focus
}
// Focus focuses the table, allowing the user to move around the rows and
// interact.
func (m *Model) Focus() {
m.focus = true
m.UpdateViewport()
}
// Blur blurs the table, preventing selection or movement.
func (m *Model) Blur() {
m.focus = false
m.UpdateViewport()
}
// View renders the component.
func (m Model) View() string {
return m.headersView() + "\n" + m.viewport.View()
}
// UpdateViewport updates the list content based on the previously defined
// columns and rows.
func (m *Model) UpdateViewport() {
renderedRows := make([]string, 0, len(m.rows))
if m.cursor >= 0 {
m.start = clamp(m.cursor-m.viewport.Height, 0, m.cursor)
} else {
m.start = 0
}
m.end = clamp(m.cursor+m.viewport.Height, m.cursor, len(m.rows))
for i := m.start; i < m.end; i++ {
renderedRows = append(renderedRows, m.renderRow(i))
}
m.viewport.SetContent(
lipgloss.JoinVertical(lipgloss.Left, renderedRows...),
)
}
// SelectedRow returns the selected row.
// Returns nil if cursor is on a gap row or out of bounds.
func (m Model) SelectedRow() Row {
if m.cursor < 0 || m.cursor >= len(m.rows) {
return nil
}
// Don't return gap rows as selected
if m.rows[m.cursor].IsGap {
return nil
}
return m.rows[m.cursor]
}
// Rows returns the current rows.
func (m Model) Rows() timewarrior.Intervals {
return m.rows
}
// Columns returns the current columns.
func (m Model) Columns() []Column {
return m.cols
}
// SetRows sets a new rows state.
func (m *Model) SetRows(r timewarrior.Intervals) {
m.rows = r
m.UpdateViewport()
}
// SetColumns sets a new columns state.
func (m *Model) SetColumns(c []Column) {
m.cols = c
m.UpdateViewport()
}
// SetWidth sets the width of the viewport of the table.
func (m *Model) SetWidth(w int) {
m.viewport.Width = w
m.UpdateViewport()
}
// SetHeight sets the height of the viewport of the table.
func (m *Model) SetHeight(h int) {
m.viewport.Height = h - lipgloss.Height(m.headersView())
m.UpdateViewport()
}
// Height returns the viewport height of the table.
func (m Model) Height() int {
return m.viewport.Height
}
// Width returns the viewport width of the table.
func (m Model) Width() int {
return m.viewport.Width
}
// Cursor returns the index of the selected row.
func (m Model) Cursor() int {
return m.cursor
}
// SetCursor sets the cursor position in the table.
// Skips gap rows by moving to the nearest non-gap row.
func (m *Model) SetCursor(n int) {
m.cursor = clamp(n, 0, len(m.rows)-1)
// Skip gap rows - try moving down first, then up
if m.cursor < len(m.rows) && m.rows[m.cursor].IsGap {
// Try moving down to find non-gap
found := false
for i := m.cursor; i < len(m.rows); i++ {
if !m.rows[i].IsGap {
m.cursor = i
found = true
break
}
}
// If not found down, try moving up
if !found {
for i := m.cursor; i >= 0; i-- {
if !m.rows[i].IsGap {
m.cursor = i
break
}
}
}
}
m.UpdateViewport()
}
// MoveUp moves the selection up by any number of rows.
// It can not go above the first row. Skips gap rows.
func (m *Model) MoveUp(n int) {
originalCursor := m.cursor
m.cursor = clamp(m.cursor-n, 0, len(m.rows)-1)
// Skip gap rows
for m.cursor >= 0 && m.cursor < len(m.rows) && m.rows[m.cursor].IsGap {
m.cursor--
}
// If we went past the beginning, find the first non-gap row
if m.cursor < 0 {
for i := 0; i < len(m.rows); i++ {
if !m.rows[i].IsGap {
m.cursor = i
break
}
}
}
// If no non-gap row found, restore original cursor
if m.cursor < 0 || (m.cursor < len(m.rows) && m.rows[m.cursor].IsGap) {
m.cursor = originalCursor
}
switch {
case m.start == 0:
m.viewport.SetYOffset(clamp(m.viewport.YOffset, 0, m.cursor))
case m.start < m.viewport.Height:
m.viewport.YOffset = (clamp(clamp(m.viewport.YOffset+n, 0, m.cursor), 0, m.viewport.Height))
case m.viewport.YOffset >= 1:
m.viewport.YOffset = clamp(m.viewport.YOffset+n, 1, m.viewport.Height)
}
m.UpdateViewport()
}
// MoveDown moves the selection down by any number of rows.
// It can not go below the last row. Skips gap rows.
func (m *Model) MoveDown(n int) {
originalCursor := m.cursor
m.cursor = clamp(m.cursor+n, 0, len(m.rows)-1)
// Skip gap rows
for m.cursor >= 0 && m.cursor < len(m.rows) && m.rows[m.cursor].IsGap {
m.cursor++
}
// If we went past the end, find the last non-gap row
if m.cursor >= len(m.rows) {
for i := len(m.rows) - 1; i >= 0; i-- {
if !m.rows[i].IsGap {
m.cursor = i
break
}
}
}
// If no non-gap row found, restore original cursor
if m.cursor >= len(m.rows) || (m.cursor >= 0 && m.rows[m.cursor].IsGap) {
m.cursor = originalCursor
}
m.UpdateViewport()
switch {
case m.end == len(m.rows) && m.viewport.YOffset > 0:
m.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.viewport.Height))
case m.cursor > (m.end-m.start)/2 && m.viewport.YOffset > 0:
m.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.cursor))
case m.viewport.YOffset > 1:
case m.cursor > m.viewport.YOffset+m.viewport.Height-1:
m.viewport.SetYOffset(clamp(m.viewport.YOffset+1, 0, 1))
}
}
// GotoTop moves the selection to the first row.
func (m *Model) GotoTop() {
m.MoveUp(m.cursor)
}
// GotoBottom moves the selection to the last row.
func (m *Model) GotoBottom() {
m.MoveDown(len(m.rows))
}
// StyleFunc is a function that can be used to customize the style of a table cell based on the row and column index.
type StyleFunc func(row, col int, value string) lipgloss.Style
func (m Model) headersView() string {
var s = make([]string, 0, len(m.cols))
for _, col := range m.cols {
if col.Width <= 0 {
continue
}
style := lipgloss.NewStyle().Width(col.Width).MaxWidth(col.Width).Inline(true)
renderedCell := style.Render(runewidth.Truncate(col.Title, col.Width, "…"))
s = append(s, m.styles.Header.Render(renderedCell))
}
return lipgloss.JoinHorizontal(lipgloss.Left, s...)
}
func (m *Model) renderRow(r int) string {
// Special rendering for gap rows
if m.rows[r].IsGap {
gapText := m.rows[r].GetString("gap_display")
gapStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
Align(lipgloss.Center).
Width(m.Width())
return gapStyle.Render(gapText)
}
var s = make([]string, 0, len(m.cols))
for i, col := range m.cols {
if m.cols[i].Width <= 0 {
continue
}
var cellStyle lipgloss.Style
cellStyle = m.rowStyles[r]
if r == m.cursor {
cellStyle = cellStyle.Inherit(m.styles.Selected)
}
style := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width).Inline(true)
renderedCell := cellStyle.Render(style.Render(runewidth.Truncate(m.rows[r].GetString(col.Name), m.cols[i].Width, "…")))
s = append(s, renderedCell)
}
row := lipgloss.JoinHorizontal(lipgloss.Left, s...)
if r == m.cursor {
return m.styles.Selected.Render(row)
}
return row
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func clamp(v, low, high int) int {
return min(max(v, low), high)
}

17
main.go
View File

@ -10,6 +10,7 @@ import (
"tasksquire/common" "tasksquire/common"
"tasksquire/pages" "tasksquire/pages"
"tasksquire/taskwarrior" "tasksquire/taskwarrior"
"tasksquire/timewarrior"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
@ -26,9 +27,23 @@ func main() {
log.Fatal("Unable to find taskrc file") log.Fatal("Unable to find taskrc file")
} }
var timewConfigPath string
if timewConfigEnv := os.Getenv("TIMEWARRIORDB"); timewConfigEnv != "" {
timewConfigPath = timewConfigEnv
} else if _, err := os.Stat(os.Getenv("HOME") + "/.timewarrior/timewarrior.cfg"); err == nil {
timewConfigPath = os.Getenv("HOME") + "/.timewarrior/timewarrior.cfg"
} else {
// Default to empty string if not found, let TimeSquire handle defaults or errors if necessary
// But TimeSquire seems to only take config location.
// Let's assume standard location if not found or pass empty if it auto-detects.
// Checking TimeSquire implementation: it calls `timew show` which usually works if timew is in path.
timewConfigPath = ""
}
ts := taskwarrior.NewTaskSquire(taskrcPath) ts := taskwarrior.NewTaskSquire(taskrcPath)
tws := timewarrior.NewTimeSquire(timewConfigPath)
ctx := context.Background() ctx := context.Background()
common := common.NewCommon(ctx, ts) common := common.NewCommon(ctx, ts, tws)
file, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) file, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil { if err != nil {

118
on-modify.timewarrior Normal file
View File

@ -0,0 +1,118 @@
#!/usr/bin/env python3
###############################################################################
#
# Copyright 2016 - 2021, 2023, Gothenburg Bit Factory
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included
# in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# https://www.opensource.org/licenses/mit-license.php
#
###############################################################################
import json
import subprocess
import sys
# Hook should extract all the following for use as Timewarrior tags:
# UUID
# Project
# Tags
# Description
# UDAs
try:
input_stream = sys.stdin.buffer
except AttributeError:
input_stream = sys.stdin
def extract_tags_from(json_obj):
# Extract attributes for use as tags.
tags = [json_obj['description']]
# Add UUID with prefix for reliable task linking
if 'uuid' in json_obj:
tags.append('uuid:' + json_obj['uuid'])
# Add project with prefix for separate column display
if 'project' in json_obj:
tags.append('project:' + json_obj['project'])
if 'tags' in json_obj:
if type(json_obj['tags']) is str:
# Usage of tasklib (e.g. in taskpirate) converts the tag list into a string
# If this is the case, convert it back into a list first
# See https://github.com/tbabej/taskpirate/issues/11
task_tags = [tag for tag in json_obj['tags'].split(',') if tag != 'next']
tags.extend(task_tags)
else:
# Filter out the 'next' tag
task_tags = [tag for tag in json_obj['tags'] if tag != 'next']
tags.extend(task_tags)
return tags
def extract_annotation_from(json_obj):
if 'annotations' not in json_obj:
return '\'\''
return json_obj['annotations'][0]['description']
def main(old, new):
start_or_stop = ''
# Started task.
if 'start' in new and 'start' not in old:
start_or_stop = 'start'
# Stopped task.
elif ('start' not in new or 'end' in new) and 'start' in old:
start_or_stop = 'stop'
if start_or_stop:
tags = extract_tags_from(new)
subprocess.call(['timew', start_or_stop] + tags + [':yes'])
# Modifications to task other than start/stop
elif 'start' in new and 'start' in old:
old_tags = extract_tags_from(old)
new_tags = extract_tags_from(new)
if old_tags != new_tags:
subprocess.call(['timew', 'untag', '@1'] + old_tags + [':yes'])
subprocess.call(['timew', 'tag', '@1'] + new_tags + [':yes'])
old_annotation = extract_annotation_from(old)
new_annotation = extract_annotation_from(new)
if old_annotation != new_annotation:
subprocess.call(['timew', 'annotate', '@1', new_annotation])
if __name__ == "__main__":
old = json.loads(input_stream.readline().decode("utf-8", errors="replace"))
new = json.loads(input_stream.readline().decode("utf-8", errors="replace"))
print(json.dumps(new))
main(old, new)

View File

@ -4,18 +4,19 @@ import (
"log/slog" "log/slog"
"slices" "slices"
"tasksquire/common" "tasksquire/common"
"tasksquire/components/picker"
"tasksquire/taskwarrior" "tasksquire/taskwarrior"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
type ContextPickerPage struct { type ContextPickerPage struct {
common *common.Common common *common.Common
contexts taskwarrior.Contexts contexts taskwarrior.Contexts
form *huh.Form picker *picker.Picker
} }
func NewContextPickerPage(common *common.Common) *ContextPickerPage { func NewContextPickerPage(common *common.Common) *ContextPickerPage {
@ -25,28 +26,36 @@ func NewContextPickerPage(common *common.Common) *ContextPickerPage {
} }
selected := common.TW.GetActiveContext().Name selected := common.TW.GetActiveContext().Name
options := make([]string, 0)
for _, c := range p.contexts {
if c.Name != "none" {
options = append(options, c.Name)
}
}
slices.Sort(options)
options = append([]string{"(none)"}, options...)
p.form = huh.NewForm( itemProvider := func() []list.Item {
huh.NewGroup( contexts := common.TW.GetContexts()
huh.NewSelect[string](). options := make([]string, 0)
Key("context"). for _, c := range contexts {
Options(huh.NewOptions(options...)...). if c.Name != "none" {
Title("Contexts"). options = append(options, c.Name)
Description("Choose a context"). }
Value(&selected). }
WithTheme(common.Styles.Form), slices.Sort(options)
), options = append([]string{"(none)"}, options...)
).
WithShowHelp(false). items := []list.Item{}
WithShowErrors(true) for _, opt := range options {
items = append(items, picker.NewItem(opt))
}
return items
}
onSelect := func(item list.Item) tea.Cmd {
return func() tea.Msg { return contextSelectedMsg{item: item} }
}
p.picker = picker.New(common, "Contexts", itemProvider, onSelect)
// Set active context
if selected == "" {
selected = "(none)"
}
p.picker.SelectItemByFilterValue(selected)
p.SetSize(common.Width(), common.Height()) p.SetSize(common.Width(), common.Height())
@ -56,76 +65,80 @@ func NewContextPickerPage(common *common.Common) *ContextPickerPage {
func (p *ContextPickerPage) SetSize(width, height int) { func (p *ContextPickerPage) SetSize(width, height int) {
p.common.SetSize(width, height) p.common.SetSize(width, height)
if width >= 20 { // Set list size with some padding/limits to look like a picker
p.form = p.form.WithWidth(20) listWidth := width - 4
} else { if listWidth > 40 {
p.form = p.form.WithWidth(width) listWidth = 40
} }
listHeight := height - 6
if height >= 30 { if listHeight > 20 {
p.form = p.form.WithHeight(30) listHeight = 20
} else {
p.form = p.form.WithHeight(height)
} }
p.picker.SetSize(listWidth, listHeight)
} }
func (p *ContextPickerPage) Init() tea.Cmd { func (p *ContextPickerPage) Init() tea.Cmd {
return p.form.Init() return p.picker.Init()
}
type contextSelectedMsg struct {
item list.Item
} }
func (p *ContextPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (p *ContextPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd var cmd tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
p.SetSize(msg.Width, msg.Height) p.SetSize(msg.Width, msg.Height)
case tea.KeyMsg: case contextSelectedMsg:
switch { name := msg.item.FilterValue() // Use FilterValue (which is the name/text)
case key.Matches(msg, p.common.Keymap.Back): if name == "(none)" {
model, err := p.common.PopPage() name = ""
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, BackCmd
} }
}
f, cmd := p.form.Update(msg) ctx := p.common.TW.GetContext(name)
if f, ok := f.(*huh.Form); ok {
p.form = f
cmds = append(cmds, cmd)
}
if p.form.State == huh.StateCompleted {
cmds = append(cmds, p.updateContextCmd)
model, err := p.common.PopPage() model, err := p.common.PopPage()
if err != nil { if err != nil {
slog.Error("page stack empty") slog.Error("page stack empty")
return nil, tea.Quit return nil, tea.Quit
} }
return model, tea.Batch(cmds...) return model, func() tea.Msg { return UpdateContextMsg(ctx) }
case tea.KeyMsg:
if !p.picker.IsFiltering() {
switch {
case key.Matches(msg, p.common.Keymap.Back):
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, BackCmd
}
}
} }
return p, tea.Batch(cmds...) _, cmd = p.picker.Update(msg)
return p, cmd
} }
func (p *ContextPickerPage) View() string { func (p *ContextPickerPage) View() string {
width := p.common.Width() - 4
if width > 40 {
width = 40
}
content := p.picker.View()
styledContent := lipgloss.NewStyle().Width(width).Render(content)
return lipgloss.Place( return lipgloss.Place(
p.common.Width(), p.common.Width(),
p.common.Height(), p.common.Height(),
lipgloss.Center, lipgloss.Center,
lipgloss.Center, lipgloss.Center,
p.common.Styles.Base.Render(p.form.View()), p.common.Styles.Base.Render(styledContent),
) )
} }
func (p *ContextPickerPage) updateContextCmd() tea.Msg {
context := p.form.GetString("context")
if context == "(none)" {
context = ""
}
return UpdateContextMsg(p.common.TW.GetContext(context))
}
type UpdateContextMsg *taskwarrior.Context type UpdateContextMsg *taskwarrior.Context

View File

@ -1,14 +1,22 @@
package pages package pages
import ( import (
tea "github.com/charmbracelet/bubbletea"
"tasksquire/common" "tasksquire/common"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
) )
type MainPage struct { type MainPage struct {
common *common.Common common *common.Common
activePage common.Component activePage common.Component
taskPage common.Component
timePage common.Component
currentTab int
width int
height int
} }
func NewMainPage(common *common.Common) *MainPage { func NewMainPage(common *common.Common) *MainPage {
@ -16,15 +24,17 @@ func NewMainPage(common *common.Common) *MainPage {
common: common, common: common,
} }
m.activePage = NewReportPage(common, common.TW.GetReport(common.TW.GetConfig().Get("uda.tasksquire.report.default"))) m.taskPage = NewReportPage(common, common.TW.GetReport(common.TW.GetConfig().Get("uda.tasksquire.report.default")))
// m.activePage = NewTaskEditorPage(common, taskwarrior.Task{}) m.timePage = NewTimePage(common)
m.activePage = m.taskPage
m.currentTab = 0
return m return m
} }
func (m *MainPage) Init() tea.Cmd { func (m *MainPage) Init() tea.Cmd {
return m.activePage.Init() return tea.Batch(m.taskPage.Init(), m.timePage.Init())
} }
func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@ -32,7 +42,43 @@ func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.common.SetSize(msg.Width, msg.Height) m.common.SetSize(msg.Width, msg.Height)
tabHeight := lipgloss.Height(m.renderTabBar())
contentHeight := msg.Height - tabHeight
if contentHeight < 0 {
contentHeight = 0
}
newMsg := tea.WindowSizeMsg{Width: msg.Width, Height: contentHeight}
activePage, cmd := m.activePage.Update(newMsg)
m.activePage = activePage.(common.Component)
return m, cmd
case tea.KeyMsg:
// Only handle tab key for page switching when at the top level (no subpages active)
if key.Matches(msg, m.common.Keymap.Next) && !m.common.HasSubpages() {
if m.activePage == m.taskPage {
m.activePage = m.timePage
m.currentTab = 1
} else {
m.activePage = m.taskPage
m.currentTab = 0
}
tabHeight := lipgloss.Height(m.renderTabBar())
contentHeight := m.height - tabHeight
if contentHeight < 0 {
contentHeight = 0
}
m.activePage.SetSize(m.width, contentHeight)
// Trigger a refresh/init on switch? Maybe not needed if we keep state.
// But we might want to refresh data.
return m, m.activePage.Init()
}
} }
activePage, cmd := m.activePage.Update(msg) activePage, cmd := m.activePage.Update(msg)
@ -41,6 +87,22 @@ func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd return m, cmd
} }
func (m *MainPage) View() string { func (m *MainPage) renderTabBar() string {
return m.activePage.View() var tabs []string
headers := []string{"Tasks", "Time"}
for i, header := range headers {
style := m.common.Styles.Tab
if m.currentTab == i {
style = m.common.Styles.ActiveTab
}
tabs = append(tabs, style.Render(header))
}
row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
return m.common.Styles.TabBar.Width(m.common.Width()).Render(row)
}
func (m *MainPage) View() string {
return lipgloss.JoinVertical(lipgloss.Left, m.renderTabBar(), m.activePage.View())
} }

View File

@ -3,16 +3,17 @@ package pages
import ( import (
"log/slog" "log/slog"
"tasksquire/common" "tasksquire/common"
"tasksquire/components/picker"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
type ProjectPickerPage struct { type ProjectPickerPage struct {
common *common.Common common *common.Common
form *huh.Form picker *picker.Picker
} }
func NewProjectPickerPage(common *common.Common, activeProject string) *ProjectPickerPage { func NewProjectPickerPage(common *common.Common, activeProject string) *ProjectPickerPage {
@ -20,30 +21,31 @@ func NewProjectPickerPage(common *common.Common, activeProject string) *ProjectP
common: common, common: common,
} }
var selected string itemProvider := func() []list.Item {
if activeProject == "" { projects := common.TW.GetProjects()
selected = "(none)" items := []list.Item{picker.NewItem("(none)")}
} else { for _, proj := range projects {
selected = activeProject items = append(items, picker.NewItem(proj))
}
return items
} }
projects := common.TW.GetProjects() onSelect := func(item list.Item) tea.Cmd {
options := []string{"(none)"} return func() tea.Msg { return projectSelectedMsg{item: item} }
options = append(options, projects...) }
p.form = huh.NewForm( // onCreate := func(name string) tea.Cmd {
huh.NewGroup( // return func() tea.Msg { return projectSelectedMsg{item: picker.NewItem(name)} }
huh.NewSelect[string](). // }
Key("project").
Options(huh.NewOptions(options...)...). // p.picker = picker.New(common, "Projects", itemProvider, onSelect, picker.WithOnCreate(onCreate))
Title("Projects"). p.picker = picker.New(common, "Projects", itemProvider, onSelect)
Description("Choose a project").
Value(&selected). // Set active project
WithTheme(common.Styles.Form), if activeProject == "" {
), activeProject = "(none)"
). }
WithShowHelp(false). p.picker.SelectItemByFilterValue(activeProject)
WithShowErrors(false)
p.SetSize(common.Width(), common.Height()) p.SetSize(common.Width(), common.Height())
@ -53,76 +55,82 @@ func NewProjectPickerPage(common *common.Common, activeProject string) *ProjectP
func (p *ProjectPickerPage) SetSize(width, height int) { func (p *ProjectPickerPage) SetSize(width, height int) {
p.common.SetSize(width, height) p.common.SetSize(width, height)
if width >= 20 { // Set list size with some padding/limits to look like a picker
p.form = p.form.WithWidth(20) listWidth := width - 4
} else { if listWidth > 40 {
p.form = p.form.WithWidth(width) listWidth = 40
} }
listHeight := height - 6
if height >= 30 { if listHeight > 20 {
p.form = p.form.WithHeight(30) listHeight = 20
} else {
p.form = p.form.WithHeight(height)
} }
p.picker.SetSize(listWidth, listHeight)
} }
func (p *ProjectPickerPage) Init() tea.Cmd { func (p *ProjectPickerPage) Init() tea.Cmd {
return p.form.Init() return p.picker.Init()
}
type projectSelectedMsg struct {
item list.Item
} }
func (p *ProjectPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (p *ProjectPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd var cmd tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
p.SetSize(msg.Width, msg.Height) p.SetSize(msg.Width, msg.Height)
case tea.KeyMsg: case projectSelectedMsg:
switch { proj := msg.item.FilterValue() // Use FilterValue (text)
case key.Matches(msg, p.common.Keymap.Back): if proj == "(none)" {
model, err := p.common.PopPage() proj = ""
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, BackCmd
} }
}
f, cmd := p.form.Update(msg)
if f, ok := f.(*huh.Form); ok {
p.form = f
cmds = append(cmds, cmd)
}
if p.form.State == huh.StateCompleted {
cmds = append(cmds, p.updateProjectCmd)
model, err := p.common.PopPage() model, err := p.common.PopPage()
if err != nil { if err != nil {
slog.Error("page stack empty") slog.Error("page stack empty")
return nil, tea.Quit return nil, tea.Quit
} }
return model, tea.Batch(cmds...) return model, func() tea.Msg { return UpdateProjectMsg(proj) }
case tea.KeyMsg:
if !p.picker.IsFiltering() {
switch {
case key.Matches(msg, p.common.Keymap.Back):
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, BackCmd
}
}
} }
return p, tea.Batch(cmds...) _, cmd = p.picker.Update(msg)
return p, cmd
} }
func (p *ProjectPickerPage) View() string { func (p *ProjectPickerPage) View() string {
width := p.common.Width() - 4
if width > 40 {
width = 40
}
content := p.picker.View()
styledContent := lipgloss.NewStyle().Width(width).Render(content)
return lipgloss.Place( return lipgloss.Place(
p.common.Width(), p.common.Width(),
p.common.Height(), p.common.Height(),
lipgloss.Center, lipgloss.Center,
lipgloss.Center, lipgloss.Center,
p.common.Styles.Base.Render(p.form.View()), p.common.Styles.Base.Render(styledContent),
) )
} }
func (p *ProjectPickerPage) updateProjectCmd() tea.Msg { func (p *ProjectPickerPage) updateProjectCmd() tea.Msg {
project := p.form.GetString("project") return nil
if project == "(none)" {
project = ""
}
return UpdateProjectMsg(project)
} }
type UpdateProjectMsg string type UpdateProjectMsg string

View File

@ -45,10 +45,6 @@ func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage {
taskTable: table.New(com), taskTable: table.New(com),
} }
p.subpage = NewTaskEditorPage(p.common, taskwarrior.Task{})
p.subpage.Init()
p.common.PushPage(p)
return p return p
} }
@ -86,6 +82,12 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case UpdateProjectMsg: case UpdateProjectMsg:
p.activeProject = string(msg) p.activeProject = string(msg)
cmds = append(cmds, p.getTasks()) cmds = append(cmds, p.getTasks())
case TaskPickedMsg:
if msg.Task != nil && msg.Task.Status == "pending" {
p.common.TW.StopActiveTasks()
p.common.TW.StartTask(msg.Task)
}
cmds = append(cmds, p.getTasks())
case UpdatedTasksMsg: case UpdatedTasksMsg:
cmds = append(cmds, p.getTasks()) cmds = append(cmds, p.getTasks())
case tea.KeyMsg: case tea.KeyMsg:
@ -94,24 +96,24 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return p, tea.Quit return p, tea.Quit
case key.Matches(msg, p.common.Keymap.SetReport): case key.Matches(msg, p.common.Keymap.SetReport):
p.subpage = NewReportPickerPage(p.common, p.activeReport) p.subpage = NewReportPickerPage(p.common, p.activeReport)
p.subpage.Init() cmd := p.subpage.Init()
p.common.PushPage(p) p.common.PushPage(p)
return p.subpage, nil return p.subpage, cmd
case key.Matches(msg, p.common.Keymap.SetContext): case key.Matches(msg, p.common.Keymap.SetContext):
p.subpage = NewContextPickerPage(p.common) p.subpage = NewContextPickerPage(p.common)
p.subpage.Init() cmd := p.subpage.Init()
p.common.PushPage(p) p.common.PushPage(p)
return p.subpage, nil return p.subpage, cmd
case key.Matches(msg, p.common.Keymap.Add): case key.Matches(msg, p.common.Keymap.Add):
p.subpage = NewTaskEditorPage(p.common, taskwarrior.NewTask()) p.subpage = NewTaskEditorPage(p.common, taskwarrior.NewTask())
p.subpage.Init() cmd := p.subpage.Init()
p.common.PushPage(p) p.common.PushPage(p)
return p.subpage, nil return p.subpage, cmd
case key.Matches(msg, p.common.Keymap.Edit): case key.Matches(msg, p.common.Keymap.Edit):
p.subpage = NewTaskEditorPage(p.common, *p.selectedTask) p.subpage = NewTaskEditorPage(p.common, *p.selectedTask)
p.subpage.Init() cmd := p.subpage.Init()
p.common.PushPage(p) p.common.PushPage(p)
return p.subpage, nil return p.subpage, cmd
case key.Matches(msg, p.common.Keymap.Ok): case key.Matches(msg, p.common.Keymap.Ok):
p.common.TW.SetTaskDone(p.selectedTask) p.common.TW.SetTaskDone(p.selectedTask)
return p, p.getTasks() return p, p.getTasks()
@ -120,9 +122,14 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return p, p.getTasks() return p, p.getTasks()
case key.Matches(msg, p.common.Keymap.SetProject): case key.Matches(msg, p.common.Keymap.SetProject):
p.subpage = NewProjectPickerPage(p.common, p.activeProject) p.subpage = NewProjectPickerPage(p.common, p.activeProject)
p.subpage.Init() cmd := p.subpage.Init()
p.common.PushPage(p) p.common.PushPage(p)
return p.subpage, nil return p.subpage, cmd
case key.Matches(msg, p.common.Keymap.PickProjectTask):
p.subpage = NewProjectTaskPickerPage(p.common)
cmd := p.subpage.Init()
p.common.PushPage(p)
return p.subpage, cmd
case key.Matches(msg, p.common.Keymap.Tag): case key.Matches(msg, p.common.Keymap.Tag):
if p.selectedTask != nil { if p.selectedTask != nil {
tag := p.common.TW.GetConfig().Get("uda.tasksquire.tag.default") tag := p.common.TW.GetConfig().Get("uda.tasksquire.tag.default")
@ -141,6 +148,7 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, p.common.Keymap.StartStop): case key.Matches(msg, p.common.Keymap.StartStop):
if p.selectedTask != nil && p.selectedTask.Status == "pending" { if p.selectedTask != nil && p.selectedTask.Status == "pending" {
if p.selectedTask.Start == "" { if p.selectedTask.Start == "" {
p.common.TW.StopActiveTasks()
p.common.TW.StartTask(p.selectedTask) p.common.TW.StartTask(p.selectedTask)
} else { } else {
p.common.TW.StopTask(p.selectedTask) p.common.TW.StopTask(p.selectedTask)

View File

@ -4,18 +4,19 @@ import (
"log/slog" "log/slog"
"slices" "slices"
"tasksquire/common" "tasksquire/common"
"tasksquire/components/picker"
"tasksquire/taskwarrior" "tasksquire/taskwarrior"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
type ReportPickerPage struct { type ReportPickerPage struct {
common *common.Common common *common.Common
reports taskwarrior.Reports reports taskwarrior.Reports
form *huh.Form picker *picker.Picker
} }
func NewReportPickerPage(common *common.Common, activeReport *taskwarrior.Report) *ReportPickerPage { func NewReportPickerPage(common *common.Common, activeReport *taskwarrior.Report) *ReportPickerPage {
@ -24,27 +25,29 @@ func NewReportPickerPage(common *common.Common, activeReport *taskwarrior.Report
reports: common.TW.GetReports(), reports: common.TW.GetReports(),
} }
selected := activeReport.Name itemProvider := func() []list.Item {
options := make([]string, 0)
for _, r := range p.reports {
options = append(options, r.Name)
}
slices.Sort(options)
options := make([]string, 0) items := []list.Item{}
for _, r := range p.reports { for _, opt := range options {
options = append(options, r.Name) items = append(items, picker.NewItem(opt))
}
return items
} }
slices.Sort(options)
p.form = huh.NewForm( onSelect := func(item list.Item) tea.Cmd {
huh.NewGroup( return func() tea.Msg { return reportSelectedMsg{item: item} }
huh.NewSelect[string](). }
Key("report").
Options(huh.NewOptions(options...)...). p.picker = picker.New(common, "Reports", itemProvider, onSelect)
Title("Reports").
Description("Choose a report"). if activeReport != nil {
Value(&selected). p.picker.SelectItemByFilterValue(activeReport.Name)
WithTheme(common.Styles.Form), }
),
).
WithShowHelp(false).
WithShowErrors(false)
p.SetSize(common.Width(), common.Height()) p.SetSize(common.Width(), common.Height())
@ -54,72 +57,76 @@ func NewReportPickerPage(common *common.Common, activeReport *taskwarrior.Report
func (p *ReportPickerPage) SetSize(width, height int) { func (p *ReportPickerPage) SetSize(width, height int) {
p.common.SetSize(width, height) p.common.SetSize(width, height)
if width >= 20 { // Set list size with some padding/limits to look like a picker
p.form = p.form.WithWidth(20) listWidth := width - 4
} else { if listWidth > 40 {
p.form = p.form.WithWidth(width) listWidth = 40
} }
listHeight := height - 6
if height >= 30 { if listHeight > 20 {
p.form = p.form.WithHeight(30) listHeight = 20
} else {
p.form = p.form.WithHeight(height)
} }
p.picker.SetSize(listWidth, listHeight)
} }
func (p *ReportPickerPage) Init() tea.Cmd { func (p *ReportPickerPage) Init() tea.Cmd {
return p.form.Init() return p.picker.Init()
}
type reportSelectedMsg struct {
item list.Item
} }
func (p *ReportPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (p *ReportPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd var cmd tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
p.SetSize(msg.Width, msg.Height) p.SetSize(msg.Width, msg.Height)
case tea.KeyMsg: case reportSelectedMsg:
switch { reportName := msg.item.FilterValue()
case key.Matches(msg, p.common.Keymap.Back): report := p.common.TW.GetReport(reportName)
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, BackCmd
}
}
f, cmd := p.form.Update(msg)
if f, ok := f.(*huh.Form); ok {
p.form = f
cmds = append(cmds, cmd)
}
if p.form.State == huh.StateCompleted {
cmds = append(cmds, []tea.Cmd{BackCmd, p.updateReportCmd}...)
model, err := p.common.PopPage() model, err := p.common.PopPage()
if err != nil { if err != nil {
slog.Error("page stack empty") slog.Error("page stack empty")
return nil, tea.Quit return nil, tea.Quit
} }
return model, tea.Batch(cmds...) return model, func() tea.Msg { return UpdateReportMsg(report) }
case tea.KeyMsg:
if !p.picker.IsFiltering() {
switch {
case key.Matches(msg, p.common.Keymap.Back):
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, BackCmd
}
}
} }
return p, tea.Batch(cmds...) _, cmd = p.picker.Update(msg)
return p, cmd
} }
func (p *ReportPickerPage) View() string { func (p *ReportPickerPage) View() string {
width := p.common.Width() - 4
if width > 40 {
width = 40
}
content := p.picker.View()
styledContent := lipgloss.NewStyle().Width(width).Render(content)
return lipgloss.Place( return lipgloss.Place(
p.common.Width(), p.common.Width(),
p.common.Height(), p.common.Height(),
lipgloss.Center, lipgloss.Center,
lipgloss.Center, lipgloss.Center,
p.common.Styles.Base.Render(p.form.View()), p.common.Styles.Base.Render(styledContent),
) )
} }
func (p *ReportPickerPage) updateReportCmd() tea.Msg {
return UpdateReportMsg(p.common.TW.GetReport(p.form.GetString("report")))
}
type UpdateReportMsg *taskwarrior.Report type UpdateReportMsg *taskwarrior.Report

View File

@ -8,6 +8,8 @@ import (
"time" "time"
"tasksquire/components/input" "tasksquire/components/input"
"tasksquire/components/picker"
"tasksquire/components/timestampeditor"
"tasksquire/taskwarrior" "tasksquire/taskwarrior"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
@ -40,6 +42,8 @@ type TaskEditorPage struct {
area int area int
areaPicker *areaPicker areaPicker *areaPicker
areas []area areas []area
infoViewport viewport.Model
} }
func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPage { func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPage {
@ -55,7 +59,7 @@ func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPag
tagOptions := p.common.TW.GetTags() tagOptions := p.common.TW.GetTags()
p.areas = []area{ p.areas = []area{
NewTaskEdit(p.common, &p.task), NewTaskEdit(p.common, &p.task, p.task.Uuid == ""),
NewTagEdit(p.common, &p.task.Tags, tagOptions), NewTagEdit(p.common, &p.task.Tags, tagOptions),
NewTimeEdit(p.common, &p.task.Due, &p.task.Scheduled, &p.task.Wait, &p.task.Until), NewTimeEdit(p.common, &p.task.Due, &p.task.Scheduled, &p.task.Wait, &p.task.Until),
NewDetailsEdit(p.common, &p.task), NewDetailsEdit(p.common, &p.task),
@ -67,6 +71,11 @@ func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPag
p.areaPicker = NewAreaPicker(com, []string{"Task", "Tags", "Dates"}) p.areaPicker = NewAreaPicker(com, []string{"Task", "Tags", "Dates"})
p.infoViewport = viewport.New(0, 0)
if p.task.Uuid != "" {
p.infoViewport.SetContent(p.common.TW.GetInformation(&p.task))
}
p.columnCursor = 1 p.columnCursor = 1
if p.task.Uuid == "" { if p.task.Uuid == "" {
p.mode = modeInsert p.mode = modeInsert
@ -93,10 +102,20 @@ func (p *TaskEditorPage) SetSize(width, height int) {
} else { } else {
p.colHeight = height - p.common.Styles.ColumnFocused.GetVerticalFrameSize() p.colHeight = height - p.common.Styles.ColumnFocused.GetVerticalFrameSize()
} }
p.infoViewport.Width = width - p.colWidth - p.common.Styles.ColumnFocused.GetHorizontalFrameSize()*2 - 5
if p.infoViewport.Width < 0 {
p.infoViewport.Width = 0
}
p.infoViewport.Height = p.colHeight
} }
func (p *TaskEditorPage) Init() tea.Cmd { func (p *TaskEditorPage) Init() tea.Cmd {
return nil var cmds []tea.Cmd
for _, a := range p.areas {
cmds = append(cmds, a.Init())
}
return tea.Batch(cmds...)
} }
func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@ -109,12 +128,20 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
p.mode = mode(msg) p.mode = mode(msg)
case prevColumnMsg: case prevColumnMsg:
p.columnCursor-- p.columnCursor--
maxCols := 2
if p.task.Uuid != "" {
maxCols = 3
}
if p.columnCursor < 0 { if p.columnCursor < 0 {
p.columnCursor = len(p.areas) - 1 p.columnCursor = maxCols - 1
} }
case nextColumnMsg: case nextColumnMsg:
p.columnCursor++ p.columnCursor++
if p.columnCursor > len(p.areas)-1 { maxCols := 2
if p.task.Uuid != "" {
maxCols = 3
}
if p.columnCursor >= maxCols {
p.columnCursor = 0 p.columnCursor = 0
} }
case prevAreaMsg: case prevAreaMsg:
@ -165,20 +192,26 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
picker, cmd := p.areaPicker.Update(msg) picker, cmd := p.areaPicker.Update(msg)
p.areaPicker = picker.(*areaPicker) p.areaPicker = picker.(*areaPicker)
return p, cmd return p, cmd
} else { } else if p.columnCursor == 1 {
model, cmd := p.areas[p.area].Update(prevFieldMsg{}) model, cmd := p.areas[p.area].Update(prevFieldMsg{})
p.areas[p.area] = model.(area) p.areas[p.area] = model.(area)
return p, cmd return p, cmd
} else if p.columnCursor == 2 {
p.infoViewport.LineUp(1)
return p, nil
} }
case key.Matches(msg, p.common.Keymap.Down): case key.Matches(msg, p.common.Keymap.Down):
if p.columnCursor == 0 { if p.columnCursor == 0 {
picker, cmd := p.areaPicker.Update(msg) picker, cmd := p.areaPicker.Update(msg)
p.areaPicker = picker.(*areaPicker) p.areaPicker = picker.(*areaPicker)
return p, cmd return p, cmd
} else { } else if p.columnCursor == 1 {
model, cmd := p.areas[p.area].Update(nextFieldMsg{}) model, cmd := p.areas[p.area].Update(nextFieldMsg{})
p.areas[p.area] = model.(area) p.areas[p.area] = model.(area)
return p, cmd return p, cmd
} else if p.columnCursor == 2 {
p.infoViewport.LineDown(1)
return p, nil
} }
} }
} }
@ -211,25 +244,31 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
picker, cmd := p.areaPicker.Update(msg) picker, cmd := p.areaPicker.Update(msg)
p.areaPicker = picker.(*areaPicker) p.areaPicker = picker.(*areaPicker)
return p, cmd return p, cmd
} else { } else if p.columnCursor == 1 {
model, cmd := p.areas[p.area].Update(prevFieldMsg{}) model, cmd := p.areas[p.area].Update(prevFieldMsg{})
p.areas[p.area] = model.(area) p.areas[p.area] = model.(area)
return p, cmd return p, cmd
} }
return p, nil
case key.Matches(msg, p.common.Keymap.Next): case key.Matches(msg, p.common.Keymap.Next):
if p.columnCursor == 0 { if p.columnCursor == 0 {
picker, cmd := p.areaPicker.Update(msg) picker, cmd := p.areaPicker.Update(msg)
p.areaPicker = picker.(*areaPicker) p.areaPicker = picker.(*areaPicker)
return p, cmd return p, cmd
} else { } else if p.columnCursor == 1 {
model, cmd := p.areas[p.area].Update(nextFieldMsg{}) model, cmd := p.areas[p.area].Update(nextFieldMsg{})
p.areas[p.area] = model.(area) p.areas[p.area] = model.(area)
return p, cmd return p, cmd
} }
return p, nil
case key.Matches(msg, p.common.Keymap.Ok): case key.Matches(msg, p.common.Keymap.Ok):
isFiltering := p.areas[p.area].IsFiltering()
model, cmd := p.areas[p.area].Update(msg) model, cmd := p.areas[p.area].Update(msg)
if p.area != 3 { if p.area != 3 {
p.areas[p.area] = model.(area) p.areas[p.area] = model.(area)
if isFiltering {
return p, cmd
}
return p, tea.Batch(cmd, nextField()) return p, tea.Batch(cmd, nextField())
} }
return p, cmd return p, cmd
@ -240,6 +279,10 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
picker, cmd := p.areaPicker.Update(msg) picker, cmd := p.areaPicker.Update(msg)
p.areaPicker = picker.(*areaPicker) p.areaPicker = picker.(*areaPicker)
return p, cmd return p, cmd
} else if p.columnCursor == 2 {
var cmd tea.Cmd
p.infoViewport, cmd = p.infoViewport.Update(msg)
return p, cmd
} else { } else {
model, cmd := p.areas[p.area].Update(msg) model, cmd := p.areas[p.area].Update(msg)
p.areas[p.area] = model.(area) p.areas[p.area] = model.(area)
@ -252,29 +295,31 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (p *TaskEditorPage) View() string { func (p *TaskEditorPage) View() string {
var focusedStyle, blurredStyle lipgloss.Style var focusedStyle, blurredStyle lipgloss.Style
if p.mode == modeInsert { if p.mode == modeInsert {
focusedStyle = p.common.Styles.ColumnInsert.Width(p.colWidth).Height(p.colHeight) focusedStyle = p.common.Styles.ColumnInsert
} else { } else {
focusedStyle = p.common.Styles.ColumnFocused.Width(p.colWidth).Height(p.colHeight) focusedStyle = p.common.Styles.ColumnFocused
} }
blurredStyle = p.common.Styles.ColumnBlurred.Width(p.colWidth).Height(p.colHeight) blurredStyle = p.common.Styles.ColumnBlurred
// var picker, area string
var area string
if p.columnCursor == 0 {
// picker = focusedStyle.Render(p.areaPicker.View())
area = blurredStyle.Render(p.areas[p.area].View())
} else {
// picker = blurredStyle.Render(p.areaPicker.View())
area = focusedStyle.Render(p.areas[p.area].View())
var area string
if p.columnCursor == 1 {
area = focusedStyle.Copy().Width(p.colWidth).Height(p.colHeight).Render(p.areas[p.area].View())
} else {
area = blurredStyle.Copy().Width(p.colWidth).Height(p.colHeight).Render(p.areas[p.area].View())
} }
if p.task.Uuid != "" { if p.task.Uuid != "" {
var infoView string
if p.columnCursor == 2 {
infoView = focusedStyle.Copy().Width(p.infoViewport.Width).Height(p.infoViewport.Height).Render(p.infoViewport.View())
} else {
infoView = blurredStyle.Copy().Width(p.infoViewport.Width).Height(p.infoViewport.Height).Render(p.infoViewport.View())
}
area = lipgloss.JoinHorizontal( area = lipgloss.JoinHorizontal(
lipgloss.Top, lipgloss.Top,
area, area,
p.common.Styles.ColumnFocused.Render(p.common.TW.GetInformation(&p.task)), infoView,
) )
} }
tabs := "" tabs := ""
@ -304,8 +349,11 @@ type area interface {
tea.Model tea.Model
SetCursor(c int) SetCursor(c int)
GetName() string GetName() string
IsFiltering() bool
} }
type focusMsg struct{}
type areaPicker struct { type areaPicker struct {
common *common.Common common *common.Common
list list.Model list list.Model
@ -377,26 +425,60 @@ func (a *areaPicker) View() string {
return a.list.View() return a.list.View()
} }
type EditableField interface {
tea.Model
Focus() tea.Cmd
Blur() tea.Cmd
}
type taskEdit struct { type taskEdit struct {
common *common.Common common *common.Common
fields []huh.Field fields []EditableField
cursor int cursor int
projectPicker *picker.Picker
// newProjectName *string // newProjectName *string
newAnnotation *string newAnnotation *string
udaValues map[string]*string udaValues map[string]*string
} }
func NewTaskEdit(com *common.Common, task *taskwarrior.Task) *taskEdit { func NewTaskEdit(com *common.Common, task *taskwarrior.Task, isNew bool) *taskEdit {
// newProject := "" // newProject := ""
projectOptions := append([]string{"(none)"}, com.TW.GetProjects()...)
if task.Project == "" { if task.Project == "" {
task.Project = "(none)" task.Project = "(none)"
} }
itemProvider := func() []list.Item {
projects := com.TW.GetProjects()
items := []list.Item{picker.NewItem("(none)")}
for _, proj := range projects {
items = append(items, picker.NewItem(proj))
}
return items
}
onSelect := func(item list.Item) tea.Cmd {
return nil
}
onCreate := func(newProject string) tea.Cmd {
// The new project name will be used as the project value
// when the task is saved
return nil
}
opts := []picker.PickerOption{picker.WithOnCreate(onCreate)}
if isNew {
opts = append(opts, picker.WithFilterByDefault(true))
} else {
opts = append(opts, picker.WithDefaultValue(task.Project))
}
projPicker := picker.New(com, "Project", itemProvider, onSelect, opts...)
projPicker.SetSize(70, 8)
projPicker.Blur()
defaultKeymap := huh.NewDefaultKeyMap() defaultKeymap := huh.NewDefaultKeyMap()
fields := []huh.Field{ fields := []EditableField{
huh.NewInput(). huh.NewInput().
Title("Task"). Title("Task").
Value(&task.Description). Value(&task.Description).
@ -410,12 +492,7 @@ func NewTaskEdit(com *common.Common, task *taskwarrior.Task) *taskEdit {
Prompt(": "). Prompt(": ").
WithTheme(com.Styles.Form), WithTheme(com.Styles.Form),
input.NewSelect(com). projPicker,
Options(true, input.NewOptions(projectOptions...)...).
Title("Project").
Value(&task.Project).
WithKeyMap(defaultKeymap).
WithTheme(com.Styles.Form),
// huh.NewInput(). // huh.NewInput().
// Title("New Project"). // Title("New Project").
@ -508,8 +585,9 @@ func NewTaskEdit(com *common.Common, task *taskwarrior.Task) *taskEdit {
WithTheme(com.Styles.Form)) WithTheme(com.Styles.Form))
t := taskEdit{ t := taskEdit{
common: com, common: com,
fields: fields, fields: fields,
projectPicker: projPicker,
udaValues: udaValues, udaValues: udaValues,
@ -526,6 +604,13 @@ func (t *taskEdit) GetName() string {
return "Task" return "Task"
} }
func (t *taskEdit) IsFiltering() bool {
if f, ok := t.fields[t.cursor].(interface{ IsFiltering() bool }); ok {
return f.IsFiltering()
}
return false
}
func (t *taskEdit) SetCursor(c int) { func (t *taskEdit) SetCursor(c int) {
t.fields[t.cursor].Blur() t.fields[t.cursor].Blur()
if c < 0 { if c < 0 {
@ -537,11 +622,25 @@ func (t *taskEdit) SetCursor(c int) {
} }
func (t *taskEdit) Init() tea.Cmd { func (t *taskEdit) Init() tea.Cmd {
return nil var cmds []tea.Cmd
// Ensure focus on the active field (especially for the first one)
if len(t.fields) > 0 {
cmds = append(cmds, func() tea.Msg {
return focusMsg{}
})
}
for _, f := range t.fields {
cmds = append(cmds, f.Init())
}
return tea.Batch(cmds...)
} }
func (t *taskEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (t *taskEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) { switch msg.(type) {
case focusMsg:
if len(t.fields) > 0 {
return t, t.fields[t.cursor].Focus()
}
case nextFieldMsg: case nextFieldMsg:
if t.cursor == len(t.fields)-1 { if t.cursor == len(t.fields)-1 {
t.fields[t.cursor].Blur() t.fields[t.cursor].Blur()
@ -560,7 +659,7 @@ func (t *taskEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
t.fields[t.cursor].Focus() t.fields[t.cursor].Focus()
default: default:
field, cmd := t.fields[t.cursor].Update(msg) field, cmd := t.fields[t.cursor].Update(msg)
t.fields[t.cursor] = field.(huh.Field) t.fields[t.cursor] = field.(EditableField)
return t, cmd return t, cmd
} }
@ -623,6 +722,13 @@ func (t *tagEdit) GetName() string {
return "Tags" return "Tags"
} }
func (t *tagEdit) IsFiltering() bool {
if f, ok := t.fields[t.cursor].(interface{ IsFiltering() bool }); ok {
return f.IsFiltering()
}
return false
}
func (t *tagEdit) SetCursor(c int) { func (t *tagEdit) SetCursor(c int) {
t.fields[t.cursor].Blur() t.fields[t.cursor].Blur()
if c < 0 { if c < 0 {
@ -676,45 +782,56 @@ func (t tagEdit) View() string {
type timeEdit struct { type timeEdit struct {
common *common.Common common *common.Common
fields []huh.Field fields []*timestampeditor.TimestampEditor
cursor int cursor int
// Store task field pointers to update them
due *string
scheduled *string
wait *string
until *string
} }
func NewTimeEdit(common *common.Common, due *string, scheduled *string, wait *string, until *string) *timeEdit { func NewTimeEdit(common *common.Common, due *string, scheduled *string, wait *string, until *string) *timeEdit {
// defaultKeymap := huh.NewDefaultKeyMap() // Create timestamp editors for each date field
dueEditor := timestampeditor.New(common).
Title("Due").
Description("When the task is due").
ValueFromString(*due)
scheduledEditor := timestampeditor.New(common).
Title("Scheduled").
Description("When to start working on the task").
ValueFromString(*scheduled)
waitEditor := timestampeditor.New(common).
Title("Wait").
Description("Hide task until this date").
ValueFromString(*wait)
untilEditor := timestampeditor.New(common).
Title("Until").
Description("Task expires after this date").
ValueFromString(*until)
t := timeEdit{ t := timeEdit{
common: common, common: common,
fields: []huh.Field{ fields: []*timestampeditor.TimestampEditor{
huh.NewInput(). dueEditor,
Title("Due"). scheduledEditor,
Value(due). waitEditor,
Validate(taskwarrior.ValidateDate). untilEditor,
Inline(true).
Prompt(": ").
WithTheme(common.Styles.Form),
huh.NewInput().
Title("Scheduled").
Value(scheduled).
Validate(taskwarrior.ValidateDate).
Inline(true).
Prompt(": ").
WithTheme(common.Styles.Form),
huh.NewInput().
Title("Wait").
Value(wait).
Validate(taskwarrior.ValidateDate).
Inline(true).
Prompt(": ").
WithTheme(common.Styles.Form),
huh.NewInput().
Title("Until").
Value(until).
Validate(taskwarrior.ValidateDate).
Inline(true).
Prompt(": ").
WithTheme(common.Styles.Form),
}, },
due: due,
scheduled: scheduled,
wait: wait,
until: until,
}
// Focus the first field
if len(t.fields) > 0 {
t.fields[0].Focus()
} }
return &t return &t
@ -724,13 +841,26 @@ func (t *timeEdit) GetName() string {
return "Dates" return "Dates"
} }
func (t *timeEdit) IsFiltering() bool {
return false
}
func (t *timeEdit) SetCursor(c int) { func (t *timeEdit) SetCursor(c int) {
if len(t.fields) == 0 {
return
}
// Blur the current field
t.fields[t.cursor].Blur() t.fields[t.cursor].Blur()
// Set new cursor position
if c < 0 { if c < 0 {
t.cursor = len(t.fields) - 1 t.cursor = len(t.fields) - 1
} else { } else {
t.cursor = c t.cursor = c
} }
// Focus the new field
t.fields[t.cursor].Focus() t.fields[t.cursor].Focus()
} }
@ -739,42 +869,71 @@ func (t *timeEdit) Init() tea.Cmd {
} }
func (t *timeEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (t *timeEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) { switch msg := msg.(type) {
case nextFieldMsg: case nextFieldMsg:
if t.cursor == len(t.fields)-1 { if t.cursor == len(t.fields)-1 {
// Update task field before moving to next area
t.syncToTaskFields()
t.fields[t.cursor].Blur() t.fields[t.cursor].Blur()
return t, nextArea() return t, nextArea()
} }
t.fields[t.cursor].Blur() t.fields[t.cursor].Blur()
t.cursor++ t.cursor++
t.fields[t.cursor].Focus() t.fields[t.cursor].Focus()
return t, nil
case prevFieldMsg: case prevFieldMsg:
if t.cursor == 0 { if t.cursor == 0 {
// Update task field before moving to previous area
t.syncToTaskFields()
t.fields[t.cursor].Blur() t.fields[t.cursor].Blur()
return t, prevArea() return t, prevArea()
} }
t.fields[t.cursor].Blur() t.fields[t.cursor].Blur()
t.cursor-- t.cursor--
t.fields[t.cursor].Focus() t.fields[t.cursor].Focus()
return t, nil
default: default:
field, cmd := t.fields[t.cursor].Update(msg) // Update the current timestamp editor
t.fields[t.cursor] = field.(huh.Field) model, cmd := t.fields[t.cursor].Update(msg)
t.fields[t.cursor] = model.(*timestampeditor.TimestampEditor)
return t, cmd return t, cmd
} }
return t, nil
} }
func (t *timeEdit) View() string { func (t *timeEdit) View() string {
views := make([]string, len(t.fields)) views := make([]string, len(t.fields))
for i, field := range t.fields { for i, field := range t.fields {
views[i] = field.View() views[i] = field.View()
if i < len(t.fields)-1 {
views[i] += "\n"
}
} }
return lipgloss.JoinVertical( return lipgloss.JoinVertical(
lipgloss.Left, lipgloss.Left,
views..., views...,
) )
} }
// syncToTaskFields converts the timestamp editor values back to task field strings
func (t *timeEdit) syncToTaskFields() {
// Update the task fields with values from the timestamp editors
// GetValueString() returns empty string for unset timestamps
if len(t.fields) > 0 {
*t.due = t.fields[0].GetValueString()
}
if len(t.fields) > 1 {
*t.scheduled = t.fields[1].GetValueString()
}
if len(t.fields) > 2 {
*t.wait = t.fields[2].GetValueString()
}
if len(t.fields) > 3 {
*t.until = t.fields[3].GetValueString()
}
}
type detailsEdit struct { type detailsEdit struct {
com *common.Common com *common.Common
vp viewport.Model vp viewport.Model
@ -818,6 +977,10 @@ func (d *detailsEdit) GetName() string {
return "Details" return "Details"
} }
func (d *detailsEdit) IsFiltering() bool {
return false
}
func (d *detailsEdit) SetCursor(c int) { func (d *detailsEdit) SetCursor(c int) {
} }
@ -985,6 +1148,8 @@ func (d *detailsEdit) View() string {
// } // }
func (p *TaskEditorPage) updateTasksCmd() tea.Msg { func (p *TaskEditorPage) updateTasksCmd() tea.Msg {
p.task.Project = p.areas[0].(*taskEdit).projectPicker.GetValue()
if p.task.Project == "(none)" { if p.task.Project == "(none)" {
p.task.Project = "" p.task.Project = ""
} }
@ -1009,6 +1174,9 @@ func (p *TaskEditorPage) updateTasksCmd() tea.Msg {
} }
} }
// Sync timestamp fields from the timeEdit area (area 2)
p.areas[2].(*timeEdit).syncToTaskFields()
if *(p.areas[0].(*taskEdit).newAnnotation) != "" { if *(p.areas[0].(*taskEdit).newAnnotation) != "" {
p.task.Annotations = append(p.task.Annotations, taskwarrior.Annotation{ p.task.Annotations = append(p.task.Annotations, taskwarrior.Annotation{
Entry: time.Now().Format("20060102T150405Z"), Entry: time.Now().Format("20060102T150405Z"),

510
pages/timeEditor.go Normal file
View File

@ -0,0 +1,510 @@
package pages
import (
"log/slog"
"strings"
"tasksquire/common"
"tasksquire/components/autocomplete"
"tasksquire/components/picker"
"tasksquire/components/timestampeditor"
"tasksquire/timewarrior"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type TimeEditorPage struct {
common *common.Common
interval *timewarrior.Interval
// Fields
projectPicker *picker.Picker
startEditor *timestampeditor.TimestampEditor
endEditor *timestampeditor.TimestampEditor
tagsInput *autocomplete.Autocomplete
adjust bool
// State
selectedProject string
currentField int
totalFields int
}
type timeEditorProjectSelectedMsg struct {
project string
}
func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *TimeEditorPage {
// Extract project from tags if it exists
projects := com.TW.GetProjects()
selectedProject, remainingTags := extractProjectFromTags(interval.Tags, projects)
// Create project picker with onCreate support for new projects
projectItemProvider := func() []list.Item {
projects := com.TW.GetProjects()
items := make([]list.Item, len(projects))
for i, proj := range projects {
items[i] = picker.NewItem(proj)
}
return items
}
projectOnSelect := func(item list.Item) tea.Cmd {
return func() tea.Msg {
return timeEditorProjectSelectedMsg{project: item.FilterValue()}
}
}
projectOnCreate := func(name string) tea.Cmd {
return func() tea.Msg {
return timeEditorProjectSelectedMsg{project: name}
}
}
opts := []picker.PickerOption{
picker.WithOnCreate(projectOnCreate),
}
if selectedProject != "" {
opts = append(opts, picker.WithDefaultValue(selectedProject))
} else {
opts = append(opts, picker.WithFilterByDefault(true))
}
projectPicker := picker.New(
com,
"Project",
projectItemProvider,
projectOnSelect,
opts...,
)
projectPicker.SetSize(50, 10) // Compact size for inline use
// Create start timestamp editor
startEditor := timestampeditor.New(com).
Title("Start").
ValueFromString(interval.Start)
// Create end timestamp editor
endEditor := timestampeditor.New(com).
Title("End").
ValueFromString(interval.End)
// Get tag combinations filtered by selected project
tagCombinations := filterTagCombinationsByProject(
com.TimeW.GetTagCombinations(),
selectedProject,
)
tagsInput := autocomplete.New(tagCombinations, 3, 10)
tagsInput.SetPlaceholder("Space separated, use \"\" for tags with spaces")
tagsInput.SetValue(formatTags(remainingTags)) // Use remaining tags (without project)
tagsInput.SetWidth(50)
p := &TimeEditorPage{
common: com,
interval: interval,
projectPicker: projectPicker,
startEditor: startEditor,
endEditor: endEditor,
tagsInput: tagsInput,
adjust: true, // Enable :adjust by default
selectedProject: selectedProject,
currentField: 0,
totalFields: 5, // Now 5 fields: project, tags, start, end, adjust
}
return p
}
func (p *TimeEditorPage) Init() tea.Cmd {
// Focus the first field (project picker)
p.currentField = 0
return p.projectPicker.Init()
}
func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case timeEditorProjectSelectedMsg:
// Update selected project
p.selectedProject = msg.project
// Blur current field (project picker)
p.blurCurrentField()
// Advance to tags field
p.currentField = 1
// Refresh tag autocomplete with filtered combinations
cmds = append(cmds, p.updateTagSuggestions())
// Focus tags input
cmds = append(cmds, p.focusCurrentField())
return p, tea.Batch(cmds...)
case tea.KeyMsg:
switch {
case key.Matches(msg, p.common.Keymap.Back):
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, BackCmd
case key.Matches(msg, p.common.Keymap.Ok):
// Handle Enter based on current field
if p.currentField == 0 {
// Project picker - let it handle Enter (will trigger projectSelectedMsg)
break
}
if p.currentField == 1 {
// Tags field
if p.tagsInput.HasSuggestions() {
// Let autocomplete handle suggestion selection
break
}
// Tags confirmed without suggestions - advance to start timestamp
p.blurCurrentField()
p.currentField = 2
cmds = append(cmds, p.focusCurrentField())
return p, tea.Batch(cmds...)
}
// For all other fields (2-4: start, end, adjust), save and exit
p.saveInterval()
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, tea.Batch(tea.Batch(cmds...), refreshIntervals)
case key.Matches(msg, p.common.Keymap.Next):
// Move to next field
p.blurCurrentField()
p.currentField = (p.currentField + 1) % p.totalFields
cmds = append(cmds, p.focusCurrentField())
case key.Matches(msg, p.common.Keymap.Prev):
// Move to previous field
p.blurCurrentField()
p.currentField = (p.currentField - 1 + p.totalFields) % p.totalFields
cmds = append(cmds, p.focusCurrentField())
}
case tea.WindowSizeMsg:
p.SetSize(msg.Width, msg.Height)
}
// Update the currently focused field
var cmd tea.Cmd
switch p.currentField {
case 0: // Project picker
var model tea.Model
model, cmd = p.projectPicker.Update(msg)
if pk, ok := model.(*picker.Picker); ok {
p.projectPicker = pk
}
case 1: // Tags (was 0)
var model tea.Model
model, cmd = p.tagsInput.Update(msg)
if ac, ok := model.(*autocomplete.Autocomplete); ok {
p.tagsInput = ac
}
case 2: // Start (was 1)
var model tea.Model
model, cmd = p.startEditor.Update(msg)
if editor, ok := model.(*timestampeditor.TimestampEditor); ok {
p.startEditor = editor
}
case 3: // End (was 2)
var model tea.Model
model, cmd = p.endEditor.Update(msg)
if editor, ok := model.(*timestampeditor.TimestampEditor); ok {
p.endEditor = editor
}
case 4: // Adjust (was 3)
// Handle adjust toggle with space/enter
if msg, ok := msg.(tea.KeyMsg); ok {
if msg.String() == " " || msg.String() == "enter" {
p.adjust = !p.adjust
}
}
}
cmds = append(cmds, cmd)
return p, tea.Batch(cmds...)
}
func (p *TimeEditorPage) focusCurrentField() tea.Cmd {
switch p.currentField {
case 0:
return p.projectPicker.Init() // Picker doesn't have explicit Focus()
case 1:
p.tagsInput.Focus()
return p.tagsInput.Init()
case 2:
return p.startEditor.Focus()
case 3:
return p.endEditor.Focus()
case 4:
// Adjust checkbox doesn't need focus action
return nil
}
return nil
}
func (p *TimeEditorPage) blurCurrentField() {
switch p.currentField {
case 0:
// Picker doesn't have explicit Blur(), state handled by Update
case 1:
p.tagsInput.Blur()
case 2:
p.startEditor.Blur()
case 3:
p.endEditor.Blur()
case 4:
// Adjust checkbox doesn't need blur action
}
}
func (p *TimeEditorPage) View() string {
var sections []string
// Title
titleStyle := p.common.Styles.Form.Focused.Title
sections = append(sections, titleStyle.Render("Edit Time Interval"))
sections = append(sections, "")
// Project picker (field 0)
if p.currentField == 0 {
sections = append(sections, p.projectPicker.View())
} else {
blurredLabelStyle := p.common.Styles.Form.Blurred.Title
sections = append(sections, blurredLabelStyle.Render("Project"))
sections = append(sections, lipgloss.NewStyle().Faint(true).Render(p.selectedProject))
}
sections = append(sections, "")
sections = append(sections, "")
// Tags input (now field 1, was first)
tagsLabelStyle := p.common.Styles.Form.Focused.Title
tagsLabel := tagsLabelStyle.Render("Tags")
if p.currentField == 1 { // Changed from 0
sections = append(sections, tagsLabel)
sections = append(sections, p.tagsInput.View())
descStyle := p.common.Styles.Form.Focused.Description
sections = append(sections, descStyle.Render("Space separated, use \"\" for tags with spaces"))
} else {
blurredLabelStyle := p.common.Styles.Form.Blurred.Title
sections = append(sections, blurredLabelStyle.Render("Tags"))
sections = append(sections, lipgloss.NewStyle().Faint(true).Render(p.tagsInput.GetValue()))
}
sections = append(sections, "")
sections = append(sections, "")
// Start editor
sections = append(sections, p.startEditor.View())
sections = append(sections, "")
// End editor
sections = append(sections, p.endEditor.View())
sections = append(sections, "")
// Adjust checkbox (now field 4, was 3)
adjustLabelStyle := p.common.Styles.Form.Focused.Title
adjustLabel := adjustLabelStyle.Render("Adjust overlaps")
var checkbox string
if p.adjust {
checkbox = "[X]"
} else {
checkbox = "[ ]"
}
if p.currentField == 4 { // Changed from 3
focusedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
sections = append(sections, adjustLabel)
sections = append(sections, focusedStyle.Render(checkbox+" Auto-adjust overlapping intervals"))
descStyle := p.common.Styles.Form.Focused.Description
sections = append(sections, descStyle.Render("Press space to toggle"))
} else {
blurredLabelStyle := p.common.Styles.Form.Blurred.Title
sections = append(sections, blurredLabelStyle.Render("Adjust overlaps"))
sections = append(sections, lipgloss.NewStyle().Faint(true).Render(checkbox+" Auto-adjust overlapping intervals"))
}
sections = append(sections, "")
sections = append(sections, "")
// Help text
helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
sections = append(sections, helpStyle.Render("tab/shift+tab: navigate • enter: save • esc: cancel"))
return lipgloss.JoinVertical(lipgloss.Left, sections...)
}
func (p *TimeEditorPage) SetSize(width, height int) {
p.common.SetSize(width, height)
}
func (p *TimeEditorPage) saveInterval() {
// If it's an existing interval (has ID), delete it first so we can replace it with the modified version
if p.interval.ID != 0 {
err := p.common.TimeW.DeleteInterval(p.interval.ID)
if err != nil {
slog.Error("Failed to delete old interval during edit", "err", err)
// Proceeding to import anyway, attempting to save user data
}
}
p.interval.Start = p.startEditor.GetValueString()
p.interval.End = p.endEditor.GetValueString()
// Parse tags from input
tags := parseTags(p.tagsInput.GetValue())
// Add project to tags if not already present
if p.selectedProject != "" {
projectTag := "project:" + p.selectedProject
projectExists := false
for _, tag := range tags {
if tag == projectTag {
projectExists = true
break
}
}
if !projectExists {
tags = append([]string{projectTag}, tags...) // Prepend project tag
}
}
p.interval.Tags = tags
err := p.common.TimeW.ModifyInterval(p.interval, p.adjust)
if err != nil {
slog.Error("Failed to modify interval", "err", err)
}
}
func parseTags(tagsStr string) []string {
var tags []string
var current strings.Builder
inQuotes := false
for _, r := range tagsStr {
switch {
case r == '"':
inQuotes = !inQuotes
case r == ' ' && !inQuotes:
if current.Len() > 0 {
tags = append(tags, current.String())
current.Reset()
}
default:
current.WriteRune(r)
}
}
if current.Len() > 0 {
tags = append(tags, current.String())
}
return tags
}
func formatTags(tags []string) string {
var formatted []string
for _, t := range tags {
if strings.Contains(t, " ") {
formatted = append(formatted, "\""+t+"\"")
} else {
formatted = append(formatted, t)
}
}
return strings.Join(formatted, " ")
}
// extractProjectFromTags finds and removes the first tag that matches a known project
// Returns the found project (or empty string) and the remaining tags
func extractProjectFromTags(tags []string, projects []string) (string, []string) {
projectSet := make(map[string]bool)
for _, p := range projects {
projectSet[p] = true
}
var foundProject string
var remaining []string
for _, tag := range tags {
// Check if this tag is a project tag (format: "project:projectname")
if strings.HasPrefix(tag, "project:") {
projectName := strings.TrimPrefix(tag, "project:")
if foundProject == "" && projectSet[projectName] {
foundProject = projectName // First matching project
continue // Don't add to remaining tags
}
}
remaining = append(remaining, tag)
}
return foundProject, remaining
}
// filterTagCombinationsByProject filters tag combinations to only show those
// containing the exact project tag, and removes the project from the displayed combination
func filterTagCombinationsByProject(combinations []string, project string) []string {
if project == "" {
return combinations
}
projectTag := "project:" + project
var filtered []string
for _, combo := range combinations {
// Parse the combination into individual tags
tags := parseTags(combo)
// Check if project exists in this combination
for _, tag := range tags {
if tag == projectTag {
// Found the project - now remove it from display
var displayTags []string
for _, t := range tags {
if t != projectTag {
displayTags = append(displayTags, t)
}
}
if len(displayTags) > 0 {
filtered = append(filtered, formatTags(displayTags))
}
break
}
}
}
return filtered
}
// updateTagSuggestions refreshes the tag autocomplete with filtered combinations
func (p *TimeEditorPage) updateTagSuggestions() tea.Cmd {
combinations := filterTagCombinationsByProject(
p.common.TimeW.GetTagCombinations(),
p.selectedProject,
)
// Update autocomplete suggestions
currentValue := p.tagsInput.GetValue()
p.tagsInput.SetSuggestions(combinations)
p.tagsInput.SetValue(currentValue)
// If tags field is focused, refocus it
if p.currentField == 1 {
p.tagsInput.Focus()
return p.tagsInput.Init()
}
return nil
}

498
pages/timePage.go Normal file
View File

@ -0,0 +1,498 @@
package pages
import (
"fmt"
"log/slog"
"time"
"tasksquire/common"
"tasksquire/components/timetable"
"tasksquire/timewarrior"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type TimePage struct {
common *common.Common
intervals timetable.Model
data timewarrior.Intervals
shouldSelectActive bool
pendingSyncAction string // "start", "stop", or "" (empty means no pending action)
selectedTimespan string
subpage common.Component
}
func NewTimePage(com *common.Common) *TimePage {
p := &TimePage{
common: com,
selectedTimespan: ":day",
}
p.populateTable(timewarrior.Intervals{})
return p
}
func (p *TimePage) isMultiDayTimespan() bool {
switch p.selectedTimespan {
case ":day", ":yesterday":
return false
case ":week", ":lastweek", ":month", ":lastmonth", ":year":
return true
default:
return true
}
}
func (p *TimePage) getTimespanLabel() string {
switch p.selectedTimespan {
case ":day":
return "Today"
case ":yesterday":
return "Yesterday"
case ":week":
return "Week"
case ":lastweek":
return "Last Week"
case ":month":
return "Month"
case ":lastmonth":
return "Last Month"
case ":year":
return "Year"
default:
return p.selectedTimespan
}
}
func (p *TimePage) getTimespanDateRange() (start, end time.Time) {
now := time.Now()
switch p.selectedTimespan {
case ":day":
start = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
end = start.AddDate(0, 0, 1)
case ":yesterday":
start = time.Date(now.Year(), now.Month(), now.Day()-1, 0, 0, 0, 0, now.Location())
end = start.AddDate(0, 0, 1)
case ":week":
// Find the start of the week (Monday)
offset := int(time.Monday - now.Weekday())
if offset > 0 {
offset = -6
}
start = time.Date(now.Year(), now.Month(), now.Day()+offset, 0, 0, 0, 0, now.Location())
end = start.AddDate(0, 0, 7)
case ":lastweek":
// Find the start of last week
offset := int(time.Monday - now.Weekday())
if offset > 0 {
offset = -6
}
start = time.Date(now.Year(), now.Month(), now.Day()+offset-7, 0, 0, 0, 0, now.Location())
end = start.AddDate(0, 0, 7)
case ":month":
start = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
end = start.AddDate(0, 1, 0)
case ":lastmonth":
start = time.Date(now.Year(), now.Month()-1, 1, 0, 0, 0, 0, now.Location())
end = start.AddDate(0, 1, 0)
case ":year":
start = time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location())
end = start.AddDate(1, 0, 0)
default:
start = now
end = now
}
return start, end
}
func (p *TimePage) renderHeader() string {
label := p.getTimespanLabel()
start, end := p.getTimespanDateRange()
var headerText string
if p.isMultiDayTimespan() {
// Multi-day format: "Week: Feb 02 - Feb 08, 2026"
if start.Year() == end.AddDate(0, 0, -1).Year() {
headerText = fmt.Sprintf("%s: %s - %s, %d",
label,
start.Format("Jan 02"),
end.AddDate(0, 0, -1).Format("Jan 02"),
start.Year())
} else {
headerText = fmt.Sprintf("%s: %s, %d - %s, %d",
label,
start.Format("Jan 02"),
start.Year(),
end.AddDate(0, 0, -1).Format("Jan 02"),
end.AddDate(0, 0, -1).Year())
}
} else {
// Single-day format: "Today (Mon, Feb 02, 2026)"
headerText = fmt.Sprintf("%s (%s, %s, %d)",
label,
start.Format("Mon"),
start.Format("Jan 02"),
start.Year())
}
slog.Info("Rendering time page header", "text", headerText, "timespan", p.selectedTimespan)
// Make header bold and prominent
headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6"))
return headerStyle.Render(headerText)
}
func (p *TimePage) Init() tea.Cmd {
return tea.Batch(p.getIntervals(), doTick())
}
func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
p.SetSize(msg.Width, msg.Height)
case UpdateTimespanMsg:
p.selectedTimespan = string(msg)
cmds = append(cmds, p.getIntervals())
case intervalsMsg:
p.data = timewarrior.Intervals(msg)
p.populateTable(p.data)
// If we have a pending sync action (from continuing an interval),
// execute it now that intervals are refreshed
if p.pendingSyncAction != "" {
action := p.pendingSyncAction
p.pendingSyncAction = ""
cmds = append(cmds, p.syncActiveIntervalAfterRefresh(action))
}
case TaskPickedMsg:
if msg.Task != nil && msg.Task.Status == "pending" {
p.common.TW.StopActiveTasks()
p.common.TW.StartTask(msg.Task)
cmds = append(cmds, p.getIntervals())
cmds = append(cmds, doTick())
}
case RefreshIntervalsMsg:
cmds = append(cmds, p.getIntervals())
cmds = append(cmds, doTick())
case BackMsg:
// Restart tick loop when returning from subpage
cmds = append(cmds, doTick())
case tickMsg:
cmds = append(cmds, p.getIntervals())
cmds = append(cmds, doTick())
case tea.KeyMsg:
switch {
case key.Matches(msg, p.common.Keymap.Quit):
return p, tea.Quit
case key.Matches(msg, p.common.Keymap.SetReport):
// Use 'r' key to show timespan picker
p.subpage = NewTimespanPickerPage(p.common, p.selectedTimespan)
cmd := p.subpage.Init()
p.common.PushPage(p)
return p.subpage, cmd
case key.Matches(msg, p.common.Keymap.PickProjectTask):
p.subpage = NewProjectTaskPickerPage(p.common)
cmd := p.subpage.Init()
p.common.PushPage(p)
return p.subpage, cmd
case key.Matches(msg, p.common.Keymap.StartStop):
row := p.intervals.SelectedRow()
if row != nil {
interval := (*timewarrior.Interval)(row)
// Validate interval before proceeding
if interval.IsGap {
slog.Debug("Cannot start/stop gap interval")
return p, nil
}
if interval.IsActive() {
// Stop tracking
p.common.TimeW.StopTracking()
// Sync: stop corresponding Taskwarrior task immediately (interval has UUID)
common.SyncIntervalToTask(interval, p.common.TW, "stop")
} else {
// Continue tracking - creates a NEW interval
slog.Info("Continuing interval for task sync",
"intervalID", interval.ID,
"hasUUID", timewarrior.ExtractUUID(interval.Tags) != "",
"uuid", timewarrior.ExtractUUID(interval.Tags))
p.common.TimeW.ContinueInterval(interval.ID)
p.shouldSelectActive = true
// Set pending sync action instead of syncing immediately
// This ensures we sync AFTER intervals are refreshed
p.pendingSyncAction = "start"
}
cmds = append(cmds, p.getIntervals())
cmds = append(cmds, doTick())
return p, tea.Batch(cmds...)
}
case key.Matches(msg, p.common.Keymap.Delete):
row := p.intervals.SelectedRow()
if row != nil {
interval := (*timewarrior.Interval)(row)
p.common.TimeW.DeleteInterval(interval.ID)
return p, tea.Batch(p.getIntervals(), doTick())
}
case key.Matches(msg, p.common.Keymap.Edit):
row := p.intervals.SelectedRow()
if row != nil {
interval := (*timewarrior.Interval)(row)
editor := NewTimeEditorPage(p.common, interval)
p.common.PushPage(p)
return editor, editor.Init()
}
case key.Matches(msg, p.common.Keymap.Add):
interval := timewarrior.NewInterval()
interval.Start = time.Now().UTC().Format("20060102T150405Z")
editor := NewTimeEditorPage(p.common, interval)
p.common.PushPage(p)
return editor, editor.Init()
case key.Matches(msg, p.common.Keymap.Fill):
row := p.intervals.SelectedRow()
if row != nil {
interval := (*timewarrior.Interval)(row)
p.common.TimeW.FillInterval(interval.ID)
return p, tea.Batch(p.getIntervals(), doTick())
}
case key.Matches(msg, p.common.Keymap.Undo):
p.common.TimeW.Undo()
return p, tea.Batch(p.getIntervals(), doTick())
case key.Matches(msg, p.common.Keymap.Join):
row := p.intervals.SelectedRow()
if row != nil {
interval := (*timewarrior.Interval)(row)
// Don't join if this is the last (oldest) interval
if interval.ID < len(p.data) {
p.common.TimeW.JoinInterval(interval.ID)
return p, tea.Batch(p.getIntervals(), doTick())
}
}
}
}
var cmd tea.Cmd
p.intervals, cmd = p.intervals.Update(msg)
cmds = append(cmds, cmd)
return p, tea.Batch(cmds...)
}
type RefreshIntervalsMsg struct{}
func refreshIntervals() tea.Msg {
return RefreshIntervalsMsg{}
}
func (p *TimePage) View() string {
header := p.renderHeader()
if len(p.data) == 0 {
noDataMsg := p.common.Styles.Base.Render("No intervals found")
content := header + "\n\n" + noDataMsg
return lipgloss.Place(
p.common.Width(),
p.common.Height(),
lipgloss.Left,
lipgloss.Top,
content,
)
}
tableView := p.intervals.View()
content := header + "\n\n" + tableView
contentHeight := lipgloss.Height(content)
tableHeight := lipgloss.Height(tableView)
headerHeight := lipgloss.Height(header)
slog.Info("TimePage View rendered",
"headerLen", len(header),
"dataCount", len(p.data),
"headerHeight", headerHeight,
"tableHeight", tableHeight,
"contentHeight", contentHeight,
"termHeight", p.common.Height())
return content
}
func (p *TimePage) SetSize(width int, height int) {
p.common.SetSize(width, height)
frameSize := p.common.Styles.Base.GetVerticalFrameSize()
tableHeight := height - frameSize - 3
slog.Info("TimePage SetSize", "totalHeight", height, "frameSize", frameSize, "tableHeight", tableHeight)
p.intervals.SetWidth(width - p.common.Styles.Base.GetHorizontalFrameSize())
// Subtract 3: 1 for header line, 1 for empty line, 1 for safety margin
p.intervals.SetHeight(tableHeight)
}
// insertGaps inserts gap intervals between actual intervals where there is untracked time.
// Gaps are not inserted before the first interval or after the last interval.
// Note: intervals are in reverse chronological order (newest first), so we need to account for that.
func insertGaps(intervals timewarrior.Intervals) timewarrior.Intervals {
if len(intervals) <= 1 {
return intervals
}
result := make(timewarrior.Intervals, 0, len(intervals)*2)
for i := 0; i < len(intervals); i++ {
result = append(result, intervals[i])
// Don't add gap after the last interval
if i < len(intervals)-1 {
// Since intervals are reversed (newest first), the gap is between
// the end of the NEXT interval and the start of the CURRENT interval
currentStart := intervals[i].GetStartTime()
nextEnd := intervals[i+1].GetEndTime()
// Calculate gap duration
gap := currentStart.Sub(nextEnd)
// Only insert gap if there is untracked time
if gap > 0 {
gapInterval := timewarrior.NewGapInterval(nextEnd, currentStart)
result = append(result, gapInterval)
}
}
}
return result
}
func (p *TimePage) populateTable(intervals timewarrior.Intervals) {
var selectedStart string
if row := p.intervals.SelectedRow(); row != nil {
selectedStart = row.Start
}
// Insert gap intervals between actual intervals
intervalsWithGaps := insertGaps(intervals)
// Determine column configuration based on timespan
var startEndWidth int
var startField, endField string
if p.isMultiDayTimespan() {
startEndWidth = 16 // "2006-01-02 15:04"
startField = "start"
endField = "end"
} else {
startEndWidth = 5 // "15:04"
startField = "start_time"
endField = "end_time"
}
columns := []timetable.Column{
{Title: "ID", Name: "id", Width: 4},
{Title: "Weekday", Name: "weekday", Width: 9},
{Title: "Start", Name: startField, Width: startEndWidth},
{Title: "End", Name: endField, Width: startEndWidth},
{Title: "Duration", Name: "duration", Width: 10},
{Title: "Project", Name: "project", Width: 0}, // flexible width
{Title: "Tags", Name: "tags", Width: 0}, // flexible width
}
// Calculate table height: total height - header (1 line) - blank line (1) - safety (1)
frameSize := p.common.Styles.Base.GetVerticalFrameSize()
tableHeight := p.common.Height() - frameSize - 3
p.intervals = timetable.New(
p.common,
timetable.WithColumns(columns),
timetable.WithIntervals(intervalsWithGaps),
timetable.WithFocused(true),
timetable.WithWidth(p.common.Width()-p.common.Styles.Base.GetHorizontalFrameSize()),
timetable.WithHeight(tableHeight),
timetable.WithStyles(p.common.Styles.TableStyle),
)
if len(intervalsWithGaps) > 0 {
newIdx := -1
if p.shouldSelectActive {
for i, interval := range intervalsWithGaps {
if !interval.IsGap && interval.IsActive() {
newIdx = i
break
}
}
p.shouldSelectActive = false
}
if newIdx == -1 && selectedStart != "" {
for i, interval := range intervalsWithGaps {
if !interval.IsGap && interval.Start == selectedStart {
newIdx = i
break
}
}
}
if newIdx == -1 {
// Default to first non-gap interval
for i, interval := range intervalsWithGaps {
if !interval.IsGap {
newIdx = i
break
}
}
}
if newIdx >= len(intervalsWithGaps) {
newIdx = len(intervalsWithGaps) - 1
}
if newIdx < 0 {
newIdx = 0
}
p.intervals.SetCursor(newIdx)
}
}
type intervalsMsg timewarrior.Intervals
func (p *TimePage) getIntervals() tea.Cmd {
return func() tea.Msg {
intervals := p.common.TimeW.GetIntervals(p.selectedTimespan)
return intervalsMsg(intervals)
}
}
// syncActiveInterval creates a command that syncs the currently active interval to Taskwarrior
func (p *TimePage) syncActiveInterval(action string) tea.Cmd {
return func() tea.Msg {
// Get the currently active interval
activeInterval := p.common.TimeW.GetActive()
if activeInterval != nil {
common.SyncIntervalToTask(activeInterval, p.common.TW, action)
}
return nil
}
}
// syncActiveIntervalAfterRefresh is called AFTER intervals have been refreshed
// to ensure we're working with current data
func (p *TimePage) syncActiveIntervalAfterRefresh(action string) tea.Cmd {
return func() tea.Msg {
// At this point, intervals have been refreshed, so GetActive() will work
activeInterval := p.common.TimeW.GetActive()
if activeInterval != nil {
slog.Info("Syncing active interval to task after refresh",
"action", action,
"intervalID", activeInterval.ID,
"hasUUID", timewarrior.ExtractUUID(activeInterval.Tags) != "")
common.SyncIntervalToTask(activeInterval, p.common.TW, action)
} else {
slog.Warn("No active interval found after refresh, cannot sync to task")
}
return nil
}
}

146
pages/timePage_test.go Normal file
View File

@ -0,0 +1,146 @@
package pages
import (
"testing"
"time"
"tasksquire/timewarrior"
)
func TestInsertGaps(t *testing.T) {
tests := []struct {
name string
intervals timewarrior.Intervals
expectedCount int
expectedGaps int
description string
}{
{
name: "empty intervals",
intervals: timewarrior.Intervals{},
expectedCount: 0,
expectedGaps: 0,
description: "Should return empty list for empty input",
},
{
name: "single interval",
intervals: timewarrior.Intervals{
{
ID: 1,
Start: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"),
End: time.Now().UTC().Format("20060102T150405Z"),
Tags: []string{"test"},
},
},
expectedCount: 1,
expectedGaps: 0,
description: "Should return single interval without gaps",
},
{
name: "two intervals with gap (reverse chronological)",
intervals: timewarrior.Intervals{
{
ID: 1,
Start: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"),
End: time.Now().UTC().Format("20060102T150405Z"),
Tags: []string{"test2"},
},
{
ID: 2,
Start: time.Now().Add(-3 * time.Hour).UTC().Format("20060102T150405Z"),
End: time.Now().Add(-2 * time.Hour).UTC().Format("20060102T150405Z"),
Tags: []string{"test1"},
},
},
expectedCount: 3,
expectedGaps: 1,
description: "Should insert one gap between two intervals (newest first order)",
},
{
name: "three intervals with two gaps (reverse chronological)",
intervals: timewarrior.Intervals{
{
ID: 1,
Start: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"),
End: time.Now().UTC().Format("20060102T150405Z"),
Tags: []string{"test3"},
},
{
ID: 2,
Start: time.Now().Add(-3 * time.Hour).UTC().Format("20060102T150405Z"),
End: time.Now().Add(-2 * time.Hour).UTC().Format("20060102T150405Z"),
Tags: []string{"test2"},
},
{
ID: 3,
Start: time.Now().Add(-5 * time.Hour).UTC().Format("20060102T150405Z"),
End: time.Now().Add(-4 * time.Hour).UTC().Format("20060102T150405Z"),
Tags: []string{"test1"},
},
},
expectedCount: 5,
expectedGaps: 2,
description: "Should insert two gaps between three intervals (newest first order)",
},
{
name: "consecutive intervals with no gap (reverse chronological)",
intervals: timewarrior.Intervals{
{
ID: 1,
Start: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"),
End: time.Now().UTC().Format("20060102T150405Z"),
Tags: []string{"test2"},
},
{
ID: 2,
Start: time.Now().Add(-2 * time.Hour).UTC().Format("20060102T150405Z"),
End: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"),
Tags: []string{"test1"},
},
},
expectedCount: 2,
expectedGaps: 0,
description: "Should not insert gap when intervals are consecutive (newest first order)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := insertGaps(tt.intervals)
if len(result) != tt.expectedCount {
t.Errorf("insertGaps() returned %d intervals, expected %d. %s",
len(result), tt.expectedCount, tt.description)
}
gapCount := 0
for _, interval := range result {
if interval.IsGap {
gapCount++
}
}
if gapCount != tt.expectedGaps {
t.Errorf("insertGaps() created %d gaps, expected %d. %s",
gapCount, tt.expectedGaps, tt.description)
}
// Verify gaps are properly interleaved with intervals
for i := 0; i < len(result)-1; i++ {
if result[i].IsGap && result[i+1].IsGap {
t.Errorf("insertGaps() created consecutive gap rows at indices %d and %d", i, i+1)
}
}
// Verify first and last items are never gaps
if len(result) > 0 {
if result[0].IsGap {
t.Errorf("insertGaps() created gap as first item")
}
if result[len(result)-1].IsGap {
t.Errorf("insertGaps() created gap as last item")
}
}
})
}
}

128
pages/timespanPicker.go Normal file
View File

@ -0,0 +1,128 @@
package pages
import (
"log/slog"
"tasksquire/common"
"tasksquire/components/picker"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type TimespanPickerPage struct {
common *common.Common
picker *picker.Picker
selectedTimespan string
}
func NewTimespanPickerPage(common *common.Common, currentTimespan string) *TimespanPickerPage {
p := &TimespanPickerPage{
common: common,
selectedTimespan: currentTimespan,
}
timespanOptions := []list.Item{
picker.NewItem(":day"),
picker.NewItem(":yesterday"),
picker.NewItem(":week"),
picker.NewItem(":lastweek"),
picker.NewItem(":month"),
picker.NewItem(":lastmonth"),
picker.NewItem(":year"),
}
itemProvider := func() []list.Item {
return timespanOptions
}
onSelect := func(item list.Item) tea.Cmd {
return func() tea.Msg { return timespanSelectedMsg{item: item} }
}
p.picker = picker.New(common, "Select Timespan", itemProvider, onSelect)
// Select the current timespan in the picker
p.picker.SelectItemByFilterValue(currentTimespan)
p.SetSize(common.Width(), common.Height())
return p
}
func (p *TimespanPickerPage) SetSize(width, height int) {
p.common.SetSize(width, height)
// Set list size with some padding/limits to look like a picker
listWidth := width - 4
if listWidth > 40 {
listWidth = 40
}
listHeight := height - 6
if listHeight > 20 {
listHeight = 20
}
p.picker.SetSize(listWidth, listHeight)
}
func (p *TimespanPickerPage) Init() tea.Cmd {
return p.picker.Init()
}
type timespanSelectedMsg struct {
item list.Item
}
func (p *TimespanPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
p.SetSize(msg.Width, msg.Height)
case timespanSelectedMsg:
timespan := msg.item.FilterValue()
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, func() tea.Msg { return UpdateTimespanMsg(timespan) }
case tea.KeyMsg:
if !p.picker.IsFiltering() {
switch {
case key.Matches(msg, p.common.Keymap.Back):
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, BackCmd
}
}
}
_, cmd = p.picker.Update(msg)
return p, cmd
}
func (p *TimespanPickerPage) View() string {
width := p.common.Width() - 4
if width > 40 {
width = 40
}
content := p.picker.View()
styledContent := lipgloss.NewStyle().Width(width).Render(content)
return lipgloss.Place(
p.common.Width(),
p.common.Height(),
lipgloss.Center,
lipgloss.Center,
p.common.Styles.Base.Render(styledContent),
)
}
type UpdateTimespanMsg string

View File

@ -12,9 +12,10 @@ type TWConfig struct {
var ( var (
defaultConfig = map[string]string{ defaultConfig = map[string]string{
"uda.tasksquire.report.default": "next", "uda.tasksquire.report.default": "next",
"uda.tasksquire.tag.default": "next", "uda.tasksquire.tag.default": "next",
"uda.tasksquire.tags.default": "low_energy,customer,delegate,code,communication,research", "uda.tasksquire.tags.default": "mngmnt,ops,low_energy,cust,delegate,code,comm,research",
"uda.tasksquire.picker.filter_by_default": "yes",
} }
) )

View File

@ -47,8 +47,8 @@ type Task struct {
Uuid string `json:"uuid,omitempty"` Uuid string `json:"uuid,omitempty"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Project string `json:"project"` Project string `json:"project"`
// Priority string `json:"priority"` Priority string `json:"priority,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
VirtualTags []string `json:"-"` VirtualTags []string `json:"-"`
Depends []string `json:"depends,omitempty"` Depends []string `json:"depends,omitempty"`
@ -149,8 +149,8 @@ func (t *Task) GetString(fieldWFormat string) string {
} }
return t.Project return t.Project
// case "priority": case "priority":
// return t.Priority return t.Priority
case "status": case "status":
return t.Status return t.Status
@ -233,7 +233,33 @@ func (t *Task) GetString(fieldWFormat string) string {
return "" return ""
} }
func (t *Task) GetDate(dateString string) time.Time { func (t *Task) GetDate(field string) time.Time {
var dateString string
switch field {
case "due":
dateString = t.Due
case "wait":
dateString = t.Wait
case "scheduled":
dateString = t.Scheduled
case "until":
dateString = t.Until
case "start":
dateString = t.Start
case "end":
dateString = t.End
case "entry":
dateString = t.Entry
case "modified":
dateString = t.Modified
default:
return time.Time{}
}
if dateString == "" {
return time.Time{}
}
dt, err := time.Parse(dtformat, dateString) dt, err := time.Parse(dtformat, dateString)
if err != nil { if err != nil {
slog.Error("Failed to parse time:", err) slog.Error("Failed to parse time:", err)

View File

@ -0,0 +1,81 @@
package taskwarrior
import (
"testing"
"time"
)
func TestTask_GetString(t *testing.T) {
tests := []struct {
name string
task Task
fieldWFormat string
want string
}{
{
name: "Priority",
task: Task{
Priority: "H",
},
fieldWFormat: "priority",
want: "H",
},
{
name: "Description",
task: Task{
Description: "Buy milk",
},
fieldWFormat: "description.desc",
want: "Buy milk",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.task.GetString(tt.fieldWFormat); got != tt.want {
t.Errorf("Task.GetString() = %v, want %v", got, tt.want)
}
})
}
}
func TestTask_GetDate(t *testing.T) {
validDate := "20230101T120000Z"
parsedValid, _ := time.Parse("20060102T150405Z", validDate)
tests := []struct {
name string
task Task
field string
want time.Time
}{
{
name: "Due date valid",
task: Task{
Due: validDate,
},
field: "due",
want: parsedValid,
},
{
name: "Due date empty",
task: Task{},
field: "due",
want: time.Time{},
},
{
name: "Unknown field",
task: Task{Due: validDate},
field: "unknown",
want: time.Time{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.task.GetDate(tt.field); !got.Equal(tt.want) {
t.Errorf("Task.GetDate() = %v, want %v", got, tt.want)
}
})
}
}

View File

@ -98,6 +98,7 @@ type TaskWarrior interface {
DeleteTask(task *Task) DeleteTask(task *Task)
StartTask(task *Task) StartTask(task *Task)
StopTask(task *Task) StopTask(task *Task)
StopActiveTasks()
GetInformation(task *Task) string GetInformation(task *Task) string
AddTaskAnnotation(uuid string, annotation string) AddTaskAnnotation(uuid string, annotation string)
@ -146,7 +147,7 @@ func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks {
args := ts.defaultArgs args := ts.defaultArgs
if report.Context { if report != nil && report.Context {
for _, context := range ts.contexts { for _, context := range ts.contexts {
if context.Active && context.Name != "none" { if context.Active && context.Name != "none" {
args = append(args, context.ReadFilter) args = append(args, context.ReadFilter)
@ -159,7 +160,12 @@ func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks {
args = append(args, filter...) args = append(args, filter...)
} }
cmd := exec.Command(twBinary, append(args, []string{"export", report.Name}...)...) exportArgs := []string{"export"}
if report != nil && report.Name != "" {
exportArgs = append(exportArgs, report.Name)
}
cmd := exec.Command(twBinary, append(args, exportArgs...)...)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
slog.Error("Failed getting report:", err) slog.Error("Failed getting report:", err)
@ -490,6 +496,33 @@ func (ts *TaskSquire) StopTask(task *Task) {
} }
} }
func (ts *TaskSquire) StopActiveTasks() {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"+ACTIVE", "export"}...)...)
output, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed getting active tasks:", "error", err, "output", string(output))
return
}
tasks := make(Tasks, 0)
err = json.Unmarshal(output, &tasks)
if err != nil {
slog.Error("Failed unmarshalling active tasks:", err)
return
}
for _, task := range tasks {
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"stop", task.Uuid}...)...)
err := cmd.Run()
if err != nil {
slog.Error("Failed stopping task:", err)
}
}
}
func (ts *TaskSquire) GetInformation(task *Task) string { func (ts *TaskSquire) GetInformation(task *Task) string {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()

Binary file not shown.

78
timewarrior/config.go Normal file
View File

@ -0,0 +1,78 @@
package timewarrior
import (
"fmt"
"log/slog"
"strings"
)
type TWConfig struct {
config map[string]string
}
var (
defaultConfig = map[string]string{
"uda.timesquire.default.tag": "",
}
)
func NewConfig(config []string) *TWConfig {
cfg := parseConfig(config)
for key, value := range defaultConfig {
if _, ok := cfg[key]; !ok {
cfg[key] = value
}
}
return &TWConfig{
config: cfg,
}
}
func (tc *TWConfig) GetConfig() map[string]string {
return tc.config
}
func (tc *TWConfig) Get(key string) string {
if _, ok := tc.config[key]; !ok {
slog.Debug(fmt.Sprintf("Key not found in config: %s", key))
return ""
}
return tc.config[key]
}
func parseConfig(config []string) map[string]string {
configMap := make(map[string]string)
for _, line := range config {
// Skip empty lines and comments
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// Timewarrior config format: key = value or key: value
var key, value string
if strings.Contains(line, "=") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
key = strings.TrimSpace(parts[0])
value = strings.TrimSpace(parts[1])
}
} else if strings.Contains(line, ":") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
key = strings.TrimSpace(parts[0])
value = strings.TrimSpace(parts[1])
}
}
if key != "" {
configMap[key] = value
}
}
return configMap
}

323
timewarrior/models.go Normal file
View File

@ -0,0 +1,323 @@
package timewarrior
import (
"fmt"
"log/slog"
"math"
"strconv"
"strings"
"time"
)
const (
dtformat = "20060102T150405Z"
)
type Intervals []*Interval
type Interval struct {
ID int `json:"-"`
Start string `json:"start,omitempty"`
End string `json:"end,omitempty"`
Tags []string `json:"tags,omitempty"`
IsGap bool `json:"-"` // True if this represents an untracked time gap
}
func NewInterval() *Interval {
return &Interval{
Tags: make([]string, 0),
}
}
// NewGapInterval creates a new gap interval representing untracked time.
// start and end are the times between which the gap occurred.
func NewGapInterval(start, end time.Time) *Interval {
return &Interval{
ID: -1, // Gap intervals have no real ID
Start: start.UTC().Format(dtformat),
End: end.UTC().Format(dtformat),
Tags: make([]string, 0),
IsGap: true,
}
}
func (i *Interval) GetString(field string) string {
// Special handling for gap intervals
if i.IsGap {
switch field {
case "duration":
return i.GetDuration()
case "gap_display":
return fmt.Sprintf("--- Untracked: %s ---", i.GetDuration())
default:
return ""
}
}
switch field {
case "id":
return strconv.Itoa(i.ID)
case "start":
return formatDate(i.Start, "formatted")
case "start_time":
return formatDate(i.Start, "time")
case "end":
if i.End == "" {
return "now"
}
return formatDate(i.End, "formatted")
case "end_time":
if i.End == "" {
return "now"
}
return formatDate(i.End, "time")
case "weekday":
return formatDate(i.Start, "weekday")
case "tags":
if len(i.Tags) == 0 {
return ""
}
// Extract and filter special tags (uuid:, project:)
_, _, displayTags := ExtractSpecialTags(i.Tags)
return strings.Join(displayTags, " ")
case "project":
project := ExtractProject(i.Tags)
if project == "" {
return "(none)"
}
return project
case "duration":
return i.GetDuration()
case "active":
if i.End == "" {
return "●"
}
return ""
default:
slog.Error(fmt.Sprintf("Field not implemented: %s", field))
return ""
}
}
func (i *Interval) GetDuration() string {
start, err := time.Parse(dtformat, i.Start)
if err != nil {
slog.Error("Failed to parse start time:", err)
return ""
}
var end time.Time
if i.End == "" {
end = time.Now()
} else {
end, err = time.Parse(dtformat, i.End)
if err != nil {
slog.Error("Failed to parse end time:", err)
return ""
}
}
duration := end.Sub(start)
return formatDuration(duration)
}
func (i *Interval) GetStartTime() time.Time {
dt, err := time.Parse(dtformat, i.Start)
if err != nil {
slog.Error("Failed to parse time:", err)
return time.Time{}
}
return dt
}
func (i *Interval) GetEndTime() time.Time {
if i.End == "" {
return time.Now()
}
dt, err := time.Parse(dtformat, i.End)
if err != nil {
slog.Error("Failed to parse time:", err)
return time.Time{}
}
return dt
}
func (i *Interval) HasTag(tag string) bool {
for _, t := range i.Tags {
if t == tag {
return true
}
}
return false
}
func (i *Interval) AddTag(tag string) {
if !i.HasTag(tag) {
i.Tags = append(i.Tags, tag)
}
}
func (i *Interval) RemoveTag(tag string) {
for idx, t := range i.Tags {
if t == tag {
i.Tags = append(i.Tags[:idx], i.Tags[idx+1:]...)
return
}
}
}
func (i *Interval) IsActive() bool {
return i.End == ""
}
func formatDate(date string, format string) string {
if date == "" {
return ""
}
dt, err := time.Parse(dtformat, date)
if err != nil {
slog.Error("Failed to parse time:", err)
return ""
}
dt = dt.Local()
switch format {
case "formatted", "":
return dt.Format("2006-01-02 15:04")
case "time":
return dt.Format("15:04")
case "date":
return dt.Format("2006-01-02")
case "weekday":
return dt.Format("Mon")
case "iso":
return dt.Format("2006-01-02T150405Z")
case "epoch":
return strconv.FormatInt(dt.Unix(), 10)
case "age":
return parseDurationVague(time.Since(dt))
case "relative":
return parseDurationVague(time.Until(dt))
default:
slog.Error(fmt.Sprintf("Date format not implemented: %s", format))
return ""
}
}
func formatDuration(d time.Duration) string {
hours := int(d.Hours())
minutes := int(d.Minutes()) % 60
seconds := int(d.Seconds()) % 60
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
}
func parseDurationVague(d time.Duration) string {
dur := d.Round(time.Second).Abs()
days := dur.Hours() / 24
var formatted string
if dur >= time.Hour*24*365 {
formatted = fmt.Sprintf("%.1fy", days/365)
} else if dur >= time.Hour*24*90 {
formatted = strconv.Itoa(int(math.Round(days/30))) + "mo"
} else if dur >= time.Hour*24*7 {
formatted = strconv.Itoa(int(math.Round(days/7))) + "w"
} else if dur >= time.Hour*24 {
formatted = strconv.Itoa(int(days)) + "d"
} else if dur >= time.Hour {
formatted = strconv.Itoa(int(dur.Round(time.Hour).Hours())) + "h"
} else if dur >= time.Minute {
formatted = strconv.Itoa(int(dur.Round(time.Minute).Minutes())) + "min"
} else if dur >= time.Second {
formatted = strconv.Itoa(int(dur.Round(time.Second).Seconds())) + "s"
}
if d < 0 {
formatted = "-" + formatted
}
return formatted
}
var (
dateFormats = []string{
"2006-01-02",
"2006-01-02T15:04",
"20060102T150405Z",
}
specialDateFormats = []string{
"",
"now",
"today",
"yesterday",
"tomorrow",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
"sun",
}
)
func ValidateDate(s string) error {
for _, f := range dateFormats {
if _, err := time.Parse(f, s); err == nil {
return nil
}
}
for _, f := range specialDateFormats {
if s == f {
return nil
}
}
return fmt.Errorf("invalid date")
}
func ValidateDuration(s string) error {
// TODO: implement proper duration validation
// Should accept formats like: 1h, 30m, 1h30m, etc.
return nil
}
// Summary represents time tracking summary data
type Summary struct {
Range string
TotalTime time.Duration
ByTag map[string]time.Duration
}
func (s *Summary) GetTotalString() string {
return formatDuration(s.TotalTime)
}
func (s *Summary) GetTagTime(tag string) string {
if duration, ok := s.ByTag[tag]; ok {
return formatDuration(duration)
}
return "0:00"
}

51
timewarrior/tags.go Normal file
View File

@ -0,0 +1,51 @@
package timewarrior
import (
"strings"
)
// Special tag prefixes for metadata
const (
UUIDPrefix = "uuid:"
ProjectPrefix = "project:"
)
// ExtractSpecialTags parses tags and separates special prefixed tags from display tags.
// Returns: uuid, project, and remaining display tags (description + user tags)
func ExtractSpecialTags(tags []string) (uuid string, project string, displayTags []string) {
displayTags = make([]string, 0, len(tags))
for _, tag := range tags {
switch {
case strings.HasPrefix(tag, UUIDPrefix):
uuid = strings.TrimPrefix(tag, UUIDPrefix)
case strings.HasPrefix(tag, ProjectPrefix):
project = strings.TrimPrefix(tag, ProjectPrefix)
default:
// Regular tag (description or user tag)
displayTags = append(displayTags, tag)
}
}
return uuid, project, displayTags
}
// ExtractUUID extracts just the UUID from tags (for sync operations)
func ExtractUUID(tags []string) string {
for _, tag := range tags {
if strings.HasPrefix(tag, UUIDPrefix) {
return strings.TrimPrefix(tag, UUIDPrefix)
}
}
return ""
}
// ExtractProject extracts just the project name from tags
func ExtractProject(tags []string) string {
for _, tag := range tags {
if strings.HasPrefix(tag, ProjectPrefix) {
return strings.TrimPrefix(tag, ProjectPrefix)
}
}
return ""
}

405
timewarrior/timewarrior.go Normal file
View File

@ -0,0 +1,405 @@
// TODO: error handling
// TODO: split combinedOutput and handle stderr differently
package timewarrior
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"os/exec"
"regexp"
"slices"
"strings"
"sync"
)
const (
twBinary = "timew"
)
type TimeWarrior interface {
GetConfig() *TWConfig
GetTags() []string
GetTagCombinations() []string
GetIntervals(filter ...string) Intervals
StartTracking(tags []string) error
StopTracking() error
ContinueTracking() error
ContinueInterval(id int) error
CancelTracking() error
DeleteInterval(id int) error
FillInterval(id int) error
JoinInterval(id int) error
ModifyInterval(interval *Interval, adjust bool) error
GetSummary(filter ...string) string
GetActive() *Interval
Undo()
}
type TimeSquire struct {
configLocation string
defaultArgs []string
config *TWConfig
mutex sync.Mutex
}
func NewTimeSquire(configLocation string) *TimeSquire {
if _, err := exec.LookPath(twBinary); err != nil {
slog.Error("Timewarrior not found")
return nil
}
ts := &TimeSquire{
configLocation: configLocation,
defaultArgs: []string{},
mutex: sync.Mutex{},
}
ts.config = ts.extractConfig()
return ts
}
func (ts *TimeSquire) GetConfig() *TWConfig {
ts.mutex.Lock()
defer ts.mutex.Unlock()
return ts.config
}
func (ts *TimeSquire) GetTags() []string {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"tags"}...)...)
output, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed getting tags:", err)
return nil
}
tags := make([]string, 0)
lines := strings.Split(string(output), "\n")
// Skip header lines and parse tag names
for i, line := range lines {
if i < 3 || line == "" {
continue
}
// Tags are space-separated, first column is the tag name
fields := strings.Fields(line)
if len(fields) > 0 {
tags = append(tags, fields[0])
}
}
slices.Sort(tags)
return tags
}
// GetTagCombinations returns unique tag combinations from intervals,
// ordered newest first (most recent intervals' tags appear first).
// Returns formatted strings like "dev client-work meeting".
func (ts *TimeSquire) GetTagCombinations() []string {
intervals := ts.GetIntervals() // Already sorted newest first
// Track unique combinations while preserving order
seen := make(map[string]bool)
var combinations []string
for _, interval := range intervals {
if len(interval.Tags) == 0 {
continue // Skip intervals with no tags
}
// Format tags (handles spaces with quotes)
combo := formatTagsForCombination(interval.Tags)
if !seen[combo] {
seen[combo] = true
combinations = append(combinations, combo)
}
}
return combinations
}
// formatTagsForCombination formats tags consistently for display
func formatTagsForCombination(tags []string) string {
var formatted []string
for _, t := range tags {
if strings.Contains(t, " ") {
formatted = append(formatted, "\""+t+"\"")
} else {
formatted = append(formatted, t)
}
}
return strings.Join(formatted, " ")
}
// getIntervalsUnlocked fetches intervals without acquiring mutex (for internal use only).
// Caller must hold ts.mutex.
func (ts *TimeSquire) getIntervalsUnlocked(filter ...string) Intervals {
args := append(ts.defaultArgs, "export")
if filter != nil {
args = append(args, filter...)
}
cmd := exec.Command(twBinary, args...)
output, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed getting intervals:", err)
return nil
}
intervals := make(Intervals, 0)
err = json.Unmarshal(output, &intervals)
if err != nil {
slog.Error("Failed unmarshalling intervals:", err)
return nil
}
// Reverse the intervals to show newest first
slices.Reverse(intervals)
// Assign IDs based on new order (newest is @1)
for i := range intervals {
intervals[i].ID = i + 1
}
return intervals
}
// GetIntervals fetches intervals with the given filter, acquiring mutex lock.
func (ts *TimeSquire) GetIntervals(filter ...string) Intervals {
ts.mutex.Lock()
defer ts.mutex.Unlock()
return ts.getIntervalsUnlocked(filter...)
}
func (ts *TimeSquire) StartTracking(tags []string) error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
if len(tags) == 0 {
return fmt.Errorf("at least one tag is required")
}
args := append(ts.defaultArgs, "start")
args = append(args, tags...)
cmd := exec.Command(twBinary, args...)
if err := cmd.Run(); err != nil {
slog.Error("Failed starting tracking:", err)
return err
}
return nil
}
func (ts *TimeSquire) StopTracking() error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, "stop")...)
if err := cmd.Run(); err != nil {
slog.Error("Failed stopping tracking:", err)
return err
}
return nil
}
func (ts *TimeSquire) ContinueTracking() error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, "continue")...)
if err := cmd.Run(); err != nil {
slog.Error("Failed continuing tracking:", err)
return err
}
return nil
}
func (ts *TimeSquire) ContinueInterval(id int) error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"continue", fmt.Sprintf("@%d", id)}...)...)
if err := cmd.Run(); err != nil {
slog.Error("Failed continuing interval:", err)
return err
}
return nil
}
func (ts *TimeSquire) CancelTracking() error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, "cancel")...)
if err := cmd.Run(); err != nil {
slog.Error("Failed canceling tracking:", err)
return err
}
return nil
}
func (ts *TimeSquire) DeleteInterval(id int) error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"delete", fmt.Sprintf("@%d", id)}...)...)
if err := cmd.Run(); err != nil {
slog.Error("Failed deleting interval:", err)
return err
}
return nil
}
func (ts *TimeSquire) FillInterval(id int) error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"fill", fmt.Sprintf("@%d", id)}...)...)
if err := cmd.Run(); err != nil {
slog.Error("Failed filling interval:", err)
return err
}
return nil
}
func (ts *TimeSquire) JoinInterval(id int) error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
// Join the current interval with the previous one
// The previous interval has id+1 (since intervals are ordered newest first)
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"join", fmt.Sprintf("@%d", id+1), fmt.Sprintf("@%d", id)}...)...)
if err := cmd.Run(); err != nil {
slog.Error("Failed joining interval:", err)
return err
}
return nil
}
func (ts *TimeSquire) ModifyInterval(interval *Interval, adjust bool) error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
// Export the modified interval
intervals, err := json.Marshal(Intervals{interval})
if err != nil {
slog.Error("Failed marshalling interval:", err)
return err
}
// Build import command with optional :adjust hint
args := append(ts.defaultArgs, "import")
if adjust {
args = append(args, ":adjust")
}
// Import the modified interval
cmd := exec.Command(twBinary, args...)
cmd.Stdin = bytes.NewBuffer(intervals)
out, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed modifying interval:", err, string(out))
return err
}
return nil
}
func (ts *TimeSquire) GetSummary(filter ...string) string {
ts.mutex.Lock()
defer ts.mutex.Unlock()
args := append(ts.defaultArgs, "summary")
if filter != nil {
args = append(args, filter...)
}
cmd := exec.Command(twBinary, args...)
output, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed getting summary:", err)
return ""
}
return string(output)
}
func (ts *TimeSquire) GetActive() *Interval {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"get", "dom.active"}...)...)
output, err := cmd.CombinedOutput()
if err != nil || string(output) == "0\n" {
return nil
}
// Get the active interval using unlocked version (we already hold the mutex)
intervals := ts.getIntervalsUnlocked()
for _, interval := range intervals {
if interval.End == "" {
return interval
}
}
return nil
}
func (ts *TimeSquire) Undo() {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"undo"}...)...)
err := cmd.Run()
if err != nil {
slog.Error("Failed undoing:", err)
}
}
func (ts *TimeSquire) extractConfig() *TWConfig {
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"show"}...)...)
output, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed getting config:", err)
return nil
}
return NewConfig(strings.Split(string(output), "\n"))
}
func extractTags(config string) []string {
re := regexp.MustCompile(`tag\.([^.]+)\.[^.]+`)
matches := re.FindAllStringSubmatch(config, -1)
uniques := make(map[string]struct{})
for _, match := range matches {
uniques[match[1]] = struct{}{}
}
var tags []string
for tag := range uniques {
tags = append(tags, tag)
}
slices.Sort(tags)
return tags
}