From fc8e9481c30d61236dc6b4e7721859653fb334c5 Mon Sep 17 00:00:00 2001 From: Martin Pander Date: Mon, 2 Feb 2026 10:55:47 +0100 Subject: [PATCH] Add timestamp editor --- AGENTS.md | 207 +++++++++ common/common.go | 4 + common/stack.go | 7 + components/picker/picker.go | 4 +- components/timestampeditor/timestampeditor.go | 409 ++++++++++++++++++ components/timetable/table.go | 2 +- pages/contextPicker.go | 4 +- pages/main.go | 7 +- pages/projectPicker.go | 2 +- pages/report.go | 4 - pages/reportPicker.go | 2 +- pages/taskEditor.go | 123 ++++-- pages/timeEditor.go | 244 ++++++++--- pages/timePage.go | 2 +- test/taskchampion.sqlite3 | Bin 278528 -> 385024 bytes test/taskchampion.sqlite3-shm | Bin 0 -> 32768 bytes test/taskchampion.sqlite3-wal | 0 timewarrior/models.go | 2 +- timewarrior/timewarrior.go | 12 +- 19 files changed, 922 insertions(+), 113 deletions(-) create mode 100644 AGENTS.md create mode 100644 components/timestampeditor/timestampeditor.go create mode 100644 test/taskchampion.sqlite3-shm create mode 100644 test/taskchampion.sqlite3-wal 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 e588c6e855c460ceef8c4c8dcfe31a2dd3a982fa..bd4b6e8fde18a823e9ef77d725bdb74333467e9a 100644 GIT binary patch delta 43765 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 diff --git a/test/taskchampion.sqlite3-shm b/test/taskchampion.sqlite3-shm new file mode 100644 index 0000000000000000000000000000000000000000..fe9ac2845eca6fe6da8a63cd096d9cf9e24ece10 GIT binary patch literal 32768 zcmeIuAr62r3