Add timestamp editor

This commit is contained in:
Martin Pander
2026-02-02 10:55:47 +01:00
parent 7032d0fa54
commit fc8e9481c3
19 changed files with 922 additions and 113 deletions

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

View File

@ -65,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

@ -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

@ -13,7 +13,7 @@ type Item struct {
text string 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) Title() string { return i.text }
func (i Item) Description() string { return "" } func (i Item) Description() string { return "" }
func (i Item) FilterValue() string { return i.text } func (i Item) FilterValue() string { return i.text }

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

@ -39,7 +39,8 @@ func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.common.SetSize(msg.Width, msg.Height) m.common.SetSize(msg.Width, msg.Height)
case tea.KeyMsg: 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 { if m.activePage == m.taskPage {
m.activePage = m.timePage m.activePage = m.timePage
} else { } else {

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
} }

View File

@ -8,6 +8,7 @@ import (
"time" "time"
"tasksquire/components/input" "tasksquire/components/input"
"tasksquire/components/timestampeditor"
"tasksquire/taskwarrior" "tasksquire/taskwarrior"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
@ -676,45 +677,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
@ -725,12 +737,21 @@ func (t *timeEdit) GetName() string {
} }
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 +760,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
@ -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) != "" { 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"),

View File

@ -4,61 +4,65 @@ import (
"log/slog" "log/slog"
"strings" "strings"
"tasksquire/common" "tasksquire/common"
"tasksquire/components/timestampeditor"
"tasksquire/timewarrior" "tasksquire/timewarrior"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss"
) )
type TimeEditorPage struct { type TimeEditorPage struct {
common *common.Common common *common.Common
interval *timewarrior.Interval interval *timewarrior.Interval
form *huh.Form
startStr string // Fields
endStr string startEditor *timestampeditor.TimestampEditor
tagsStr string endEditor *timestampeditor.TimestampEditor
tagsInput textinput.Model
adjust bool
// State
currentField int
totalFields int
} }
func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *TimeEditorPage { func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *TimeEditorPage {
p := &TimeEditorPage{ // Create start timestamp editor
common: com, startEditor := timestampeditor.New(com).
interval: interval, Title("Start").
startStr: interval.Start, ValueFromString(interval.Start)
endStr: interval.End,
tagsStr: formatTags(interval.Tags),
}
p.form = huh.NewForm( // Create end timestamp editor
huh.NewGroup( endEditor := timestampeditor.New(com).
huh.NewInput(). Title("End").
Title("Start"). ValueFromString(interval.End)
Value(&p.startStr).
Validate(func(s string) error { // Create tags input
return timewarrior.ValidateDate(s) tagsInput := textinput.New()
}), tagsInput.Placeholder = "Space separated, use \"\" for tags with spaces"
huh.NewInput(). tagsInput.SetValue(formatTags(interval.Tags))
Title("End"). tagsInput.Width = 50
Value(&p.endStr).
Validate(func(s string) error { p := &TimeEditorPage{
if s == "" { common: com,
return nil // End can be empty (active) interval: interval,
} startEditor: startEditor,
return timewarrior.ValidateDate(s) endEditor: endEditor,
}), tagsInput: tagsInput,
huh.NewInput(). adjust: true, // Enable :adjust by default
Title("Tags"). currentField: 0,
Value(&p.tagsStr). totalFields: 4, // Updated to include adjust field
Description("Space separated, use \"\" for tags with spaces"), }
),
).WithTheme(com.Styles.Form)
return p return p
} }
func (p *TimeEditorPage) Init() tea.Cmd { 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) { 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) { switch msg := msg.(type) {
case tea.KeyMsg: 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() 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, BackCmd 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: case tea.WindowSizeMsg:
p.SetSize(msg.Width, msg.Height) p.SetSize(msg.Width, msg.Height)
} }
form, cmd := p.form.Update(msg) // Update the currently focused field
if f, ok := form.(*huh.Form); ok { var cmd tea.Cmd
p.form = f 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) 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...) 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 { 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) { func (p *TimeEditorPage) SetSize(width, height int) {
@ -117,13 +245,13 @@ func (p *TimeEditorPage) saveInterval() {
} }
} }
p.interval.Start = p.startStr p.interval.Start = p.startEditor.GetValueString()
p.interval.End = p.endStr p.interval.End = p.endEditor.GetValueString()
// Parse tags // 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 { if err != nil {
slog.Error("Failed to modify interval", "err", err) slog.Error("Failed to modify interval", "err", err)
} }
@ -164,5 +292,3 @@ func formatTags(tags []string) string {
} }
return strings.Join(formatted, " ") return strings.Join(formatted, " ")
} }

Binary file not shown.

Binary file not shown.

View File

View File

@ -30,7 +30,7 @@ type TimeWarrior interface {
ContinueInterval(id int) error ContinueInterval(id int) error
CancelTracking() error CancelTracking() error
DeleteInterval(id int) error DeleteInterval(id int) error
ModifyInterval(interval *Interval) error ModifyInterval(interval *Interval, adjust bool) error
GetSummary(filter ...string) string GetSummary(filter ...string) string
GetActive() *Interval GetActive() *Interval
@ -218,7 +218,7 @@ func (ts *TimeSquire) DeleteInterval(id int) error {
return nil return nil
} }
func (ts *TimeSquire) ModifyInterval(interval *Interval) error { func (ts *TimeSquire) ModifyInterval(interval *Interval, adjust bool) error {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
@ -229,8 +229,14 @@ func (ts *TimeSquire) ModifyInterval(interval *Interval) error {
return 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 // Import the modified interval
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"import"}...)...) cmd := exec.Command(twBinary, args...)
cmd.Stdin = bytes.NewBuffer(intervals) cmd.Stdin = bytes.NewBuffer(intervals)
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
if err != nil { if err != nil {