diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ca1a0ae --- /dev/null +++ b/AGENTS.md @@ -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/` 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 diff --git a/common/common.go b/common/common.go index 545a25d..18cfacb 100644 --- a/common/common.go +++ b/common/common.go @@ -65,3 +65,7 @@ func (c *Common) PopPage() (Component, error) { component.SetSize(c.width, c.height) return component, nil } + +func (c *Common) HasSubpages() bool { + return !c.pageStack.IsEmpty() +} diff --git a/common/stack.go b/common/stack.go index accb98c..a5513ae 100644 --- a/common/stack.go +++ b/common/stack.go @@ -38,3 +38,10 @@ func (s *Stack[T]) Pop() (T, error) { return item, nil } + +func (s *Stack[T]) IsEmpty() bool { + s.mutex.Lock() + defer s.mutex.Unlock() + + return len(s.items) == 0 +} diff --git a/components/picker/picker.go b/components/picker/picker.go index 9877cf6..71d820a 100644 --- a/components/picker/picker.go +++ b/components/picker/picker.go @@ -13,7 +13,7 @@ type Item struct { text string } -func NewItem(text string) Item { return Item{text: text} } +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 } @@ -206,4 +206,4 @@ func (p *Picker) SelectItemByFilterValue(filterValue string) { break } } -} \ No newline at end of file +} diff --git a/components/timestampeditor/timestampeditor.go b/components/timestampeditor/timestampeditor.go new file mode 100644 index 0000000..4b74361 --- /dev/null +++ b/components/timestampeditor/timestampeditor.go @@ -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, + } +} diff --git a/components/timetable/table.go b/components/timetable/table.go index b4d0a61..44ecbb6 100644 --- a/components/timetable/table.go +++ b/components/timetable/table.go @@ -149,7 +149,7 @@ func (m *Model) parseRowStyles(rows timewarrior.Intervals) []lipgloss.Style { 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 { diff --git a/pages/contextPicker.go b/pages/contextPicker.go index 43cd470..893e4fa 100644 --- a/pages/contextPicker.go +++ b/pages/contextPicker.go @@ -26,7 +26,7 @@ func NewContextPickerPage(common *common.Common) *ContextPickerPage { } selected := common.TW.GetActiveContext().Name - + itemProvider := func() []list.Item { contexts := common.TW.GetContexts() options := make([]string, 0) @@ -141,4 +141,4 @@ func (p *ContextPickerPage) View() string { ) } -type UpdateContextMsg *taskwarrior.Context \ No newline at end of file +type UpdateContextMsg *taskwarrior.Context diff --git a/pages/main.go b/pages/main.go index a396aed..f659718 100644 --- a/pages/main.go +++ b/pages/main.go @@ -10,7 +10,7 @@ import ( type MainPage struct { common *common.Common activePage common.Component - + taskPage common.Component timePage common.Component } @@ -22,7 +22,7 @@ func NewMainPage(common *common.Common) *MainPage { m.taskPage = NewReportPage(common, common.TW.GetReport(common.TW.GetConfig().Get("uda.tasksquire.report.default"))) m.timePage = NewTimePage(common) - + m.activePage = m.taskPage return m @@ -39,7 +39,8 @@ func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.common.SetSize(msg.Width, msg.Height) case tea.KeyMsg: - if key.Matches(msg, m.common.Keymap.Next) { + // 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 } else { diff --git a/pages/projectPicker.go b/pages/projectPicker.go index 1508611..f332b68 100644 --- a/pages/projectPicker.go +++ b/pages/projectPicker.go @@ -133,4 +133,4 @@ func (p *ProjectPickerPage) updateProjectCmd() tea.Msg { return nil } -type UpdateProjectMsg string \ No newline at end of file +type UpdateProjectMsg string diff --git a/pages/report.go b/pages/report.go index a12ef19..37c038a 100644 --- a/pages/report.go +++ b/pages/report.go @@ -45,10 +45,6 @@ func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage { taskTable: table.New(com), } - p.subpage = NewTaskEditorPage(p.common, taskwarrior.Task{}) - p.subpage.Init() - p.common.PushPage(p) - return p } diff --git a/pages/reportPicker.go b/pages/reportPicker.go index 0b179ce..323be01 100644 --- a/pages/reportPicker.go +++ b/pages/reportPicker.go @@ -129,4 +129,4 @@ func (p *ReportPickerPage) View() string { ) } -type UpdateReportMsg *taskwarrior.Report \ No newline at end of file +type UpdateReportMsg *taskwarrior.Report diff --git a/pages/taskEditor.go b/pages/taskEditor.go index 6ab68d4..7eeba70 100644 --- a/pages/taskEditor.go +++ b/pages/taskEditor.go @@ -8,6 +8,7 @@ import ( "time" "tasksquire/components/input" + "tasksquire/components/timestampeditor" "tasksquire/taskwarrior" "github.com/charmbracelet/bubbles/key" @@ -676,45 +677,56 @@ func (t tagEdit) View() string { type timeEdit struct { common *common.Common - fields []huh.Field + fields []*timestampeditor.TimestampEditor 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 { - // 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{ common: common, - fields: []huh.Field{ - huh.NewInput(). - Title("Due"). - Value(due). - Validate(taskwarrior.ValidateDate). - 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), + fields: []*timestampeditor.TimestampEditor{ + dueEditor, + scheduledEditor, + waitEditor, + untilEditor, }, + due: due, + scheduled: scheduled, + wait: wait, + until: until, + } + + // Focus the first field + if len(t.fields) > 0 { + t.fields[0].Focus() } return &t @@ -725,12 +737,21 @@ func (t *timeEdit) GetName() string { } func (t *timeEdit) SetCursor(c int) { + if len(t.fields) == 0 { + return + } + + // Blur the current field t.fields[t.cursor].Blur() + + // Set new cursor position if c < 0 { t.cursor = len(t.fields) - 1 } else { t.cursor = c } + + // Focus the new field t.fields[t.cursor].Focus() } @@ -739,42 +760,71 @@ func (t *timeEdit) Init() tea.Cmd { } func (t *timeEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg.(type) { + switch msg := msg.(type) { case nextFieldMsg: if t.cursor == len(t.fields)-1 { + // Update task field before moving to next area + t.syncToTaskFields() t.fields[t.cursor].Blur() return t, nextArea() } t.fields[t.cursor].Blur() t.cursor++ t.fields[t.cursor].Focus() + return t, nil case prevFieldMsg: if t.cursor == 0 { + // Update task field before moving to previous area + t.syncToTaskFields() t.fields[t.cursor].Blur() return t, prevArea() } t.fields[t.cursor].Blur() t.cursor-- t.fields[t.cursor].Focus() + return t, nil default: - field, cmd := t.fields[t.cursor].Update(msg) - t.fields[t.cursor] = field.(huh.Field) + // Update the current timestamp editor + model, cmd := t.fields[t.cursor].Update(msg) + t.fields[t.cursor] = model.(*timestampeditor.TimestampEditor) return t, cmd } - return t, nil } func (t *timeEdit) View() string { views := make([]string, len(t.fields)) + for i, field := range t.fields { views[i] = field.View() + if i < len(t.fields)-1 { + views[i] += "\n" + } } + return lipgloss.JoinVertical( lipgloss.Left, 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 { com *common.Common vp viewport.Model @@ -1009,6 +1059,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) != "" { p.task.Annotations = append(p.task.Annotations, taskwarrior.Annotation{ Entry: time.Now().Format("20060102T150405Z"), diff --git a/pages/timeEditor.go b/pages/timeEditor.go index 3837dd6..5c6545b 100644 --- a/pages/timeEditor.go +++ b/pages/timeEditor.go @@ -4,61 +4,65 @@ import ( "log/slog" "strings" "tasksquire/common" + "tasksquire/components/timestampeditor" "tasksquire/timewarrior" "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" ) type TimeEditorPage struct { common *common.Common interval *timewarrior.Interval - form *huh.Form - startStr string - endStr string - tagsStr string + // Fields + startEditor *timestampeditor.TimestampEditor + endEditor *timestampeditor.TimestampEditor + tagsInput textinput.Model + adjust bool + + // State + currentField int + totalFields int } func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *TimeEditorPage { - p := &TimeEditorPage{ - common: com, - interval: interval, - startStr: interval.Start, - endStr: interval.End, - tagsStr: formatTags(interval.Tags), - } + // Create start timestamp editor + startEditor := timestampeditor.New(com). + Title("Start"). + ValueFromString(interval.Start) - p.form = huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title("Start"). - Value(&p.startStr). - Validate(func(s string) error { - return timewarrior.ValidateDate(s) - }), - huh.NewInput(). - Title("End"). - Value(&p.endStr). - Validate(func(s string) error { - if s == "" { - return nil // End can be empty (active) - } - return timewarrior.ValidateDate(s) - }), - huh.NewInput(). - Title("Tags"). - Value(&p.tagsStr). - Description("Space separated, use \"\" for tags with spaces"), - ), - ).WithTheme(com.Styles.Form) + // Create end timestamp editor + endEditor := timestampeditor.New(com). + Title("End"). + ValueFromString(interval.End) + + // Create tags input + tagsInput := textinput.New() + tagsInput.Placeholder = "Space separated, use \"\" for tags with spaces" + tagsInput.SetValue(formatTags(interval.Tags)) + tagsInput.Width = 50 + + p := &TimeEditorPage{ + common: com, + interval: interval, + startEditor: startEditor, + endEditor: endEditor, + tagsInput: tagsInput, + adjust: true, // Enable :adjust by default + currentField: 0, + totalFields: 4, // Updated to include adjust field + } return p } func (p *TimeEditorPage) Init() tea.Cmd { - return p.form.Init() + // Focus the first field + p.currentField = 0 + return p.startEditor.Focus() } func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -66,41 +70,165 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: - if key.Matches(msg, p.common.Keymap.Back) { + 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): + // 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) } - form, cmd := p.form.Update(msg) - if f, ok := form.(*huh.Form); ok { - p.form = f + // Update the currently focused field + var cmd tea.Cmd + switch p.currentField { + case 0: + var model tea.Model + model, cmd = p.startEditor.Update(msg) + if editor, ok := model.(*timestampeditor.TimestampEditor); ok { + p.startEditor = editor + } + case 1: + var model tea.Model + model, cmd = p.endEditor.Update(msg) + if editor, ok := model.(*timestampeditor.TimestampEditor); ok { + p.endEditor = editor + } + case 2: + p.tagsInput, cmd = p.tagsInput.Update(msg) + case 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) - if p.form.State == huh.StateCompleted { - p.saveInterval() - - model, err := p.common.PopPage() - if err != nil { - slog.Error("page stack empty") - return nil, tea.Quit - } - // Return with a command to refresh the intervals - return model, tea.Batch(tea.Batch(cmds...), refreshIntervals) - } - return p, tea.Batch(cmds...) } +func (p *TimeEditorPage) focusCurrentField() tea.Cmd { + switch p.currentField { + case 0: + return p.startEditor.Focus() + case 1: + return p.endEditor.Focus() + case 2: + p.tagsInput.Focus() + return nil + case 3: + // Adjust checkbox doesn't need focus action + return nil + } + return nil +} + +func (p *TimeEditorPage) blurCurrentField() { + switch p.currentField { + case 0: + p.startEditor.Blur() + case 1: + p.endEditor.Blur() + case 2: + p.tagsInput.Blur() + case 3: + // Adjust checkbox doesn't need blur action + } +} + func (p *TimeEditorPage) View() string { - return p.form.View() + var sections []string + + // Title + titleStyle := p.common.Styles.Form.Focused.Title + sections = append(sections, titleStyle.Render("Edit Time Interval")) + 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, "") + + // Tags input + tagsLabelStyle := p.common.Styles.Form.Focused.Title + tagsLabel := tagsLabelStyle.Render("Tags") + if p.currentField == 2 { + 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.Value())) + } + + sections = append(sections, "") + sections = append(sections, "") + + // Adjust checkbox + 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 == 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) { @@ -117,13 +245,13 @@ func (p *TimeEditorPage) saveInterval() { } } - p.interval.Start = p.startStr - p.interval.End = p.endStr + p.interval.Start = p.startEditor.GetValueString() + p.interval.End = p.endEditor.GetValueString() // Parse tags - p.interval.Tags = parseTags(p.tagsStr) + p.interval.Tags = parseTags(p.tagsInput.Value()) - err := p.common.TimeW.ModifyInterval(p.interval) + err := p.common.TimeW.ModifyInterval(p.interval, p.adjust) if err != nil { slog.Error("Failed to modify interval", "err", err) } @@ -164,5 +292,3 @@ func formatTags(tags []string) string { } return strings.Join(formatted, " ") } - - \ No newline at end of file diff --git a/pages/timePage.go b/pages/timePage.go index 83a54c4..b04e37f 100644 --- a/pages/timePage.go +++ b/pages/timePage.go @@ -24,7 +24,7 @@ func NewTimePage(com *common.Common) *TimePage { p := &TimePage{ common: com, } - + p.populateTable(timewarrior.Intervals{}) return p } diff --git a/test/taskchampion.sqlite3 b/test/taskchampion.sqlite3 index e588c6e..bd4b6e8 100644 Binary files a/test/taskchampion.sqlite3 and b/test/taskchampion.sqlite3 differ diff --git a/test/taskchampion.sqlite3-shm b/test/taskchampion.sqlite3-shm new file mode 100644 index 0000000..fe9ac28 Binary files /dev/null and b/test/taskchampion.sqlite3-shm differ diff --git a/test/taskchampion.sqlite3-wal b/test/taskchampion.sqlite3-wal new file mode 100644 index 0000000..e69de29 diff --git a/timewarrior/models.go b/timewarrior/models.go index fb60db7..b448486 100644 --- a/timewarrior/models.go +++ b/timewarrior/models.go @@ -144,7 +144,7 @@ func formatDate(date string, format string) string { slog.Error("Failed to parse time:", err) return "" } - + dt = dt.Local() switch format { diff --git a/timewarrior/timewarrior.go b/timewarrior/timewarrior.go index daf52db..cd96656 100644 --- a/timewarrior/timewarrior.go +++ b/timewarrior/timewarrior.go @@ -30,7 +30,7 @@ type TimeWarrior interface { ContinueInterval(id int) error CancelTracking() error DeleteInterval(id int) error - ModifyInterval(interval *Interval) error + ModifyInterval(interval *Interval, adjust bool) error GetSummary(filter ...string) string GetActive() *Interval @@ -218,7 +218,7 @@ func (ts *TimeSquire) DeleteInterval(id int) error { return nil } -func (ts *TimeSquire) ModifyInterval(interval *Interval) error { +func (ts *TimeSquire) ModifyInterval(interval *Interval, adjust bool) error { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -229,8 +229,14 @@ func (ts *TimeSquire) ModifyInterval(interval *Interval) error { 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, append(ts.defaultArgs, []string{"import"}...)...) + cmd := exec.Command(twBinary, args...) cmd.Stdin = bytes.NewBuffer(intervals) out, err := cmd.CombinedOutput() if err != nil {