Compare commits
13 Commits
72a5c57faa
...
feat/task
| Author | SHA1 | Date | |
|---|---|---|---|
| 2baf3859fd | |||
| 2940711b26 | |||
| f5d297e6ab | |||
| 938ed177f1 | |||
| 81b9d87935 | |||
| 9940316ace | |||
| fc8e9481c3 | |||
| 7032d0fa54 | |||
| 681ed7e635 | |||
| effd95f6c1 | |||
| 4767a6cd91 | |||
| ce193c336c | |||
| f19767fb10 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,3 +2,4 @@
|
|||||||
app.log
|
app.log
|
||||||
test/taskchampion.sqlite3
|
test/taskchampion.sqlite3
|
||||||
tasksquire
|
tasksquire
|
||||||
|
test/*.sqlite3*
|
||||||
|
|||||||
207
AGENTS.md
Normal file
207
AGENTS.md
Normal 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
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@ -28,7 +28,9 @@ type Keymap struct {
|
|||||||
Insert key.Binding
|
Insert key.Binding
|
||||||
Tag key.Binding
|
Tag key.Binding
|
||||||
Undo key.Binding
|
Undo key.Binding
|
||||||
|
Fill key.Binding
|
||||||
StartStop key.Binding
|
StartStop key.Binding
|
||||||
|
Join key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: use config values for key bindings
|
// TODO: use config values for key bindings
|
||||||
@ -145,9 +147,19 @@ func NewKeymap() *Keymap {
|
|||||||
key.WithHelp("undo", "Undo"),
|
key.WithHelp("undo", "Undo"),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
Fill: key.NewBinding(
|
||||||
|
key.WithKeys("f"),
|
||||||
|
key.WithHelp("fill", "Fill gaps"),
|
||||||
|
),
|
||||||
|
|
||||||
StartStop: key.NewBinding(
|
StartStop: key.NewBinding(
|
||||||
key.WithKeys("s"),
|
key.WithKeys("s"),
|
||||||
key.WithHelp("start/stop", "Start/Stop"),
|
key.WithHelp("start/stop", "Start/Stop"),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
Join: key.NewBinding(
|
||||||
|
key.WithKeys("J"),
|
||||||
|
key.WithHelp("J", "Join with previous"),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -27,6 +27,10 @@ type Styles struct {
|
|||||||
Form *huh.Theme
|
Form *huh.Theme
|
||||||
TableStyle TableStyle
|
TableStyle TableStyle
|
||||||
|
|
||||||
|
Tab lipgloss.Style
|
||||||
|
ActiveTab lipgloss.Style
|
||||||
|
TabBar lipgloss.Style
|
||||||
|
|
||||||
ColumnFocused lipgloss.Style
|
ColumnFocused lipgloss.Style
|
||||||
ColumnBlurred lipgloss.Style
|
ColumnBlurred lipgloss.Style
|
||||||
ColumnInsert lipgloss.Style
|
ColumnInsert lipgloss.Style
|
||||||
@ -71,6 +75,19 @@ func NewStyles(config *taskwarrior.TWConfig) *Styles {
|
|||||||
|
|
||||||
styles.Form = formTheme
|
styles.Form = formTheme
|
||||||
|
|
||||||
|
styles.Tab = lipgloss.NewStyle().
|
||||||
|
Padding(0, 1).
|
||||||
|
Foreground(lipgloss.Color("240"))
|
||||||
|
|
||||||
|
styles.ActiveTab = styles.Tab.
|
||||||
|
Foreground(lipgloss.Color("252")).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
styles.TabBar = lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||||
|
BorderForeground(lipgloss.Color("240")).
|
||||||
|
MarginBottom(1)
|
||||||
|
|
||||||
styles.ColumnFocused = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1)
|
styles.ColumnFocused = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1)
|
||||||
styles.ColumnBlurred = lipgloss.NewStyle().Border(lipgloss.HiddenBorder(), true).Padding(1)
|
styles.ColumnBlurred = lipgloss.NewStyle().Border(lipgloss.HiddenBorder(), true).Padding(1)
|
||||||
styles.ColumnInsert = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1)
|
styles.ColumnInsert = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1)
|
||||||
|
|||||||
282
components/autocomplete/autocomplete.go
Normal file
282
components/autocomplete/autocomplete.go
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
package autocomplete
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/sahilm/fuzzy"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Autocomplete struct {
|
||||||
|
input textinput.Model
|
||||||
|
allSuggestions []string // All available suggestions (newest first)
|
||||||
|
filteredSuggestions []string // Currently matching suggestions
|
||||||
|
matchedIndexes [][]int // Matched character positions for each suggestion
|
||||||
|
selectedIndex int // -1 = input focused, 0+ = suggestion selected
|
||||||
|
showSuggestions bool // Whether to display suggestion box
|
||||||
|
maxVisible int // Max suggestions to show
|
||||||
|
minChars int // Min chars before showing suggestions
|
||||||
|
focused bool
|
||||||
|
width int
|
||||||
|
placeholder string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new autocomplete component
|
||||||
|
func New(suggestions []string, minChars int, maxVisible int) *Autocomplete {
|
||||||
|
ti := textinput.New()
|
||||||
|
ti.Width = 50
|
||||||
|
|
||||||
|
return &Autocomplete{
|
||||||
|
input: ti,
|
||||||
|
allSuggestions: suggestions,
|
||||||
|
selectedIndex: -1,
|
||||||
|
maxVisible: maxVisible,
|
||||||
|
minChars: minChars,
|
||||||
|
width: 50,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetValue sets the input value
|
||||||
|
func (a *Autocomplete) SetValue(value string) {
|
||||||
|
a.input.SetValue(value)
|
||||||
|
a.updateFilteredSuggestions()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetValue returns the current input value
|
||||||
|
func (a *Autocomplete) GetValue() string {
|
||||||
|
return a.input.Value()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus focuses the autocomplete input
|
||||||
|
func (a *Autocomplete) Focus() {
|
||||||
|
a.focused = true
|
||||||
|
a.input.Focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blur blurs the autocomplete input
|
||||||
|
func (a *Autocomplete) Blur() {
|
||||||
|
a.focused = false
|
||||||
|
a.input.Blur()
|
||||||
|
a.showSuggestions = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPlaceholder sets the placeholder text
|
||||||
|
func (a *Autocomplete) SetPlaceholder(placeholder string) {
|
||||||
|
a.placeholder = placeholder
|
||||||
|
a.input.Placeholder = placeholder
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWidth sets the width of the autocomplete
|
||||||
|
func (a *Autocomplete) SetWidth(width int) {
|
||||||
|
a.width = width
|
||||||
|
a.input.Width = width
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMaxVisible sets the maximum number of visible suggestions
|
||||||
|
func (a *Autocomplete) SetMaxVisible(max int) {
|
||||||
|
a.maxVisible = max
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMinChars sets the minimum characters required before showing suggestions
|
||||||
|
func (a *Autocomplete) SetMinChars(min int) {
|
||||||
|
a.minChars = min
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the autocomplete
|
||||||
|
func (a *Autocomplete) Init() tea.Cmd {
|
||||||
|
return textinput.Blink
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles messages for the autocomplete
|
||||||
|
func (a *Autocomplete) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if !a.focused {
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd tea.Cmd
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, key.NewBinding(key.WithKeys("down"))):
|
||||||
|
if a.showSuggestions && len(a.filteredSuggestions) > 0 {
|
||||||
|
a.selectedIndex++
|
||||||
|
if a.selectedIndex >= len(a.filteredSuggestions) {
|
||||||
|
a.selectedIndex = 0
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
case key.Matches(msg, key.NewBinding(key.WithKeys("up"))):
|
||||||
|
if a.showSuggestions && len(a.filteredSuggestions) > 0 {
|
||||||
|
a.selectedIndex--
|
||||||
|
if a.selectedIndex < 0 {
|
||||||
|
a.selectedIndex = len(a.filteredSuggestions) - 1
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
|
||||||
|
if a.showSuggestions && a.selectedIndex >= 0 && a.selectedIndex < len(a.filteredSuggestions) {
|
||||||
|
// Accept selected suggestion
|
||||||
|
a.input.SetValue(a.filteredSuggestions[a.selectedIndex])
|
||||||
|
a.showSuggestions = false
|
||||||
|
a.selectedIndex = -1
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
|
||||||
|
if a.showSuggestions && len(a.filteredSuggestions) > 0 {
|
||||||
|
// Accept first or selected suggestion
|
||||||
|
if a.selectedIndex >= 0 && a.selectedIndex < len(a.filteredSuggestions) {
|
||||||
|
a.input.SetValue(a.filteredSuggestions[a.selectedIndex])
|
||||||
|
} else {
|
||||||
|
a.input.SetValue(a.filteredSuggestions[0])
|
||||||
|
}
|
||||||
|
a.showSuggestions = false
|
||||||
|
a.selectedIndex = -1
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
|
||||||
|
if a.showSuggestions {
|
||||||
|
a.showSuggestions = false
|
||||||
|
a.selectedIndex = -1
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Handle regular text input
|
||||||
|
prevValue := a.input.Value()
|
||||||
|
a.input, cmd = a.input.Update(msg)
|
||||||
|
|
||||||
|
// Update suggestions if value changed
|
||||||
|
if a.input.Value() != prevValue {
|
||||||
|
a.updateFilteredSuggestions()
|
||||||
|
}
|
||||||
|
|
||||||
|
return a, cmd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.input, cmd = a.input.Update(msg)
|
||||||
|
return a, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the autocomplete
|
||||||
|
func (a *Autocomplete) View() string {
|
||||||
|
// Input field
|
||||||
|
inputView := a.input.View()
|
||||||
|
|
||||||
|
if !a.showSuggestions || len(a.filteredSuggestions) == 0 {
|
||||||
|
return inputView
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suggestion box
|
||||||
|
var suggestionViews []string
|
||||||
|
for i, suggestion := range a.filteredSuggestions {
|
||||||
|
if i >= a.maxVisible {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := " "
|
||||||
|
baseStyle := lipgloss.NewStyle().Padding(0, 1)
|
||||||
|
|
||||||
|
if i == a.selectedIndex {
|
||||||
|
// Highlight selected suggestion
|
||||||
|
baseStyle = baseStyle.Bold(true).Foreground(lipgloss.Color("12"))
|
||||||
|
prefix = "→ "
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build suggestion with highlighted matched characters
|
||||||
|
var rendered string
|
||||||
|
if i < len(a.matchedIndexes) {
|
||||||
|
rendered = a.renderWithHighlights(suggestion, a.matchedIndexes[i], i == a.selectedIndex)
|
||||||
|
} else {
|
||||||
|
rendered = suggestion
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestionViews = append(suggestionViews, baseStyle.Render(prefix+rendered))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Box style
|
||||||
|
boxStyle := lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("8")).
|
||||||
|
Width(a.width)
|
||||||
|
|
||||||
|
suggestionsBox := boxStyle.Render(
|
||||||
|
lipgloss.JoinVertical(lipgloss.Left, suggestionViews...),
|
||||||
|
)
|
||||||
|
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left, inputView, suggestionsBox)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderWithHighlights renders a suggestion with matched characters highlighted
|
||||||
|
func (a *Autocomplete) renderWithHighlights(str string, matchedIndexes []int, isSelected bool) string {
|
||||||
|
if len(matchedIndexes) == 0 {
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map for quick lookup
|
||||||
|
matchedMap := make(map[int]bool)
|
||||||
|
for _, idx := range matchedIndexes {
|
||||||
|
matchedMap[idx] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Choose highlight style based on selection state
|
||||||
|
var highlightStyle lipgloss.Style
|
||||||
|
if isSelected {
|
||||||
|
// When selected, use underline to distinguish from selection bold
|
||||||
|
highlightStyle = lipgloss.NewStyle().Underline(true)
|
||||||
|
} else {
|
||||||
|
// When not selected, use bold and accent color
|
||||||
|
highlightStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the string with highlights
|
||||||
|
var result string
|
||||||
|
runes := []rune(str)
|
||||||
|
for i, r := range runes {
|
||||||
|
if matchedMap[i] {
|
||||||
|
result += highlightStyle.Render(string(r))
|
||||||
|
} else {
|
||||||
|
result += string(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateFilteredSuggestions filters suggestions based on current input
|
||||||
|
func (a *Autocomplete) updateFilteredSuggestions() {
|
||||||
|
value := a.input.Value()
|
||||||
|
|
||||||
|
// Only show if >= minChars
|
||||||
|
if len(value) < a.minChars {
|
||||||
|
a.showSuggestions = false
|
||||||
|
a.filteredSuggestions = nil
|
||||||
|
a.matchedIndexes = nil
|
||||||
|
a.selectedIndex = -1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fuzzy match using sahilm/fuzzy
|
||||||
|
matches := fuzzy.Find(value, a.allSuggestions)
|
||||||
|
|
||||||
|
var filtered []string
|
||||||
|
var indexes [][]int
|
||||||
|
for _, match := range matches {
|
||||||
|
filtered = append(filtered, match.Str)
|
||||||
|
indexes = append(indexes, match.MatchedIndexes)
|
||||||
|
if len(filtered) >= a.maxVisible {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.filteredSuggestions = filtered
|
||||||
|
a.matchedIndexes = indexes
|
||||||
|
a.showSuggestions = len(filtered) > 0 && a.focused
|
||||||
|
a.selectedIndex = -1 // Reset to input
|
||||||
|
}
|
||||||
@ -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 }
|
||||||
@ -206,4 +206,4 @@ func (p *Picker) SelectItemByFilterValue(filterValue string) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
409
components/timestampeditor/timestampeditor.go
Normal file
409
components/timestampeditor/timestampeditor.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -149,7 +149,7 @@ func (m *Model) parseRowStyles(rows timewarrior.Intervals) []lipgloss.Style {
|
|||||||
for i := range rows {
|
for i := range rows {
|
||||||
// Default style
|
// Default style
|
||||||
styles[i] = m.common.Styles.Base.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
|
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 active, maybe highlight?
|
||||||
if rows[i].IsActive() {
|
if rows[i].IsActive() {
|
||||||
if c, ok := m.common.Styles.Colors["active"]; ok && c != nil {
|
if c, ok := m.common.Styles.Colors["active"]; ok && c != nil {
|
||||||
@ -330,11 +330,17 @@ func (m *Model) UpdateViewport() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SelectedRow returns the selected row.
|
// SelectedRow returns the selected row.
|
||||||
|
// Returns nil if cursor is on a gap row or out of bounds.
|
||||||
func (m Model) SelectedRow() Row {
|
func (m Model) SelectedRow() Row {
|
||||||
if m.cursor < 0 || m.cursor >= len(m.rows) {
|
if m.cursor < 0 || m.cursor >= len(m.rows) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Don't return gap rows as selected
|
||||||
|
if m.rows[m.cursor].IsGap {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
return m.rows[m.cursor]
|
return m.rows[m.cursor]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -388,15 +394,61 @@ func (m Model) Cursor() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetCursor sets the cursor position in the table.
|
// SetCursor sets the cursor position in the table.
|
||||||
|
// Skips gap rows by moving to the nearest non-gap row.
|
||||||
func (m *Model) SetCursor(n int) {
|
func (m *Model) SetCursor(n int) {
|
||||||
m.cursor = clamp(n, 0, len(m.rows)-1)
|
m.cursor = clamp(n, 0, len(m.rows)-1)
|
||||||
|
|
||||||
|
// Skip gap rows - try moving down first, then up
|
||||||
|
if m.cursor < len(m.rows) && m.rows[m.cursor].IsGap {
|
||||||
|
// Try moving down to find non-gap
|
||||||
|
found := false
|
||||||
|
for i := m.cursor; i < len(m.rows); i++ {
|
||||||
|
if !m.rows[i].IsGap {
|
||||||
|
m.cursor = i
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If not found down, try moving up
|
||||||
|
if !found {
|
||||||
|
for i := m.cursor; i >= 0; i-- {
|
||||||
|
if !m.rows[i].IsGap {
|
||||||
|
m.cursor = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
m.UpdateViewport()
|
m.UpdateViewport()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveUp moves the selection up by any number of rows.
|
// MoveUp moves the selection up by any number of rows.
|
||||||
// It can not go above the first row.
|
// It can not go above the first row. Skips gap rows.
|
||||||
func (m *Model) MoveUp(n int) {
|
func (m *Model) MoveUp(n int) {
|
||||||
|
originalCursor := m.cursor
|
||||||
m.cursor = clamp(m.cursor-n, 0, len(m.rows)-1)
|
m.cursor = clamp(m.cursor-n, 0, len(m.rows)-1)
|
||||||
|
|
||||||
|
// Skip gap rows
|
||||||
|
for m.cursor >= 0 && m.cursor < len(m.rows) && m.rows[m.cursor].IsGap {
|
||||||
|
m.cursor--
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we went past the beginning, find the first non-gap row
|
||||||
|
if m.cursor < 0 {
|
||||||
|
for i := 0; i < len(m.rows); i++ {
|
||||||
|
if !m.rows[i].IsGap {
|
||||||
|
m.cursor = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no non-gap row found, restore original cursor
|
||||||
|
if m.cursor < 0 || (m.cursor < len(m.rows) && m.rows[m.cursor].IsGap) {
|
||||||
|
m.cursor = originalCursor
|
||||||
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case m.start == 0:
|
case m.start == 0:
|
||||||
m.viewport.SetYOffset(clamp(m.viewport.YOffset, 0, m.cursor))
|
m.viewport.SetYOffset(clamp(m.viewport.YOffset, 0, m.cursor))
|
||||||
@ -409,9 +461,31 @@ func (m *Model) MoveUp(n int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MoveDown moves the selection down by any number of rows.
|
// MoveDown moves the selection down by any number of rows.
|
||||||
// It can not go below the last row.
|
// It can not go below the last row. Skips gap rows.
|
||||||
func (m *Model) MoveDown(n int) {
|
func (m *Model) MoveDown(n int) {
|
||||||
|
originalCursor := m.cursor
|
||||||
m.cursor = clamp(m.cursor+n, 0, len(m.rows)-1)
|
m.cursor = clamp(m.cursor+n, 0, len(m.rows)-1)
|
||||||
|
|
||||||
|
// Skip gap rows
|
||||||
|
for m.cursor >= 0 && m.cursor < len(m.rows) && m.rows[m.cursor].IsGap {
|
||||||
|
m.cursor++
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we went past the end, find the last non-gap row
|
||||||
|
if m.cursor >= len(m.rows) {
|
||||||
|
for i := len(m.rows) - 1; i >= 0; i-- {
|
||||||
|
if !m.rows[i].IsGap {
|
||||||
|
m.cursor = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no non-gap row found, restore original cursor
|
||||||
|
if m.cursor >= len(m.rows) || (m.cursor >= 0 && m.rows[m.cursor].IsGap) {
|
||||||
|
m.cursor = originalCursor
|
||||||
|
}
|
||||||
|
|
||||||
m.UpdateViewport()
|
m.UpdateViewport()
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
@ -452,6 +526,16 @@ func (m Model) headersView() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderRow(r int) string {
|
func (m *Model) renderRow(r int) string {
|
||||||
|
// Special rendering for gap rows
|
||||||
|
if m.rows[r].IsGap {
|
||||||
|
gapText := m.rows[r].GetString("gap_display")
|
||||||
|
gapStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("240")).
|
||||||
|
Align(lipgloss.Center).
|
||||||
|
Width(m.Width())
|
||||||
|
return gapStyle.Render(gapText)
|
||||||
|
}
|
||||||
|
|
||||||
var s = make([]string, 0, len(m.cols))
|
var s = make([]string, 0, len(m.cols))
|
||||||
for i, col := range m.cols {
|
for i, col := range m.cols {
|
||||||
if m.cols[i].Width <= 0 {
|
if m.cols[i].Width <= 0 {
|
||||||
|
|||||||
@ -26,7 +26,7 @@ func NewContextPickerPage(common *common.Common) *ContextPickerPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
selected := common.TW.GetActiveContext().Name
|
selected := common.TW.GetActiveContext().Name
|
||||||
|
|
||||||
itemProvider := func() []list.Item {
|
itemProvider := func() []list.Item {
|
||||||
contexts := common.TW.GetContexts()
|
contexts := common.TW.GetContexts()
|
||||||
options := make([]string, 0)
|
options := make([]string, 0)
|
||||||
@ -141,4 +141,4 @@ func (p *ContextPickerPage) View() string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateContextMsg *taskwarrior.Context
|
type UpdateContextMsg *taskwarrior.Context
|
||||||
|
|||||||
@ -5,14 +5,18 @@ import (
|
|||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
"github.com/charmbracelet/bubbles/key"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MainPage struct {
|
type MainPage struct {
|
||||||
common *common.Common
|
common *common.Common
|
||||||
activePage common.Component
|
activePage common.Component
|
||||||
|
|
||||||
taskPage common.Component
|
taskPage common.Component
|
||||||
timePage common.Component
|
timePage common.Component
|
||||||
|
currentTab int
|
||||||
|
width int
|
||||||
|
height int
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMainPage(common *common.Common) *MainPage {
|
func NewMainPage(common *common.Common) *MainPage {
|
||||||
@ -22,8 +26,9 @@ func NewMainPage(common *common.Common) *MainPage {
|
|||||||
|
|
||||||
m.taskPage = NewReportPage(common, common.TW.GetReport(common.TW.GetConfig().Get("uda.tasksquire.report.default")))
|
m.taskPage = NewReportPage(common, common.TW.GetReport(common.TW.GetConfig().Get("uda.tasksquire.report.default")))
|
||||||
m.timePage = NewTimePage(common)
|
m.timePage = NewTimePage(common)
|
||||||
|
|
||||||
m.activePage = m.taskPage
|
m.activePage = m.taskPage
|
||||||
|
m.currentTab = 0
|
||||||
|
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
@ -37,16 +42,39 @@ func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
|
m.width = msg.Width
|
||||||
|
m.height = msg.Height
|
||||||
m.common.SetSize(msg.Width, msg.Height)
|
m.common.SetSize(msg.Width, msg.Height)
|
||||||
|
|
||||||
|
tabHeight := lipgloss.Height(m.renderTabBar())
|
||||||
|
contentHeight := msg.Height - tabHeight
|
||||||
|
if contentHeight < 0 {
|
||||||
|
contentHeight = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
newMsg := tea.WindowSizeMsg{Width: msg.Width, Height: contentHeight}
|
||||||
|
activePage, cmd := m.activePage.Update(newMsg)
|
||||||
|
m.activePage = activePage.(common.Component)
|
||||||
|
return m, cmd
|
||||||
|
|
||||||
case tea.KeyMsg:
|
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
|
||||||
|
m.currentTab = 1
|
||||||
} else {
|
} else {
|
||||||
m.activePage = m.taskPage
|
m.activePage = m.taskPage
|
||||||
|
m.currentTab = 0
|
||||||
}
|
}
|
||||||
// Re-size the new active page just in case
|
|
||||||
m.activePage.SetSize(m.common.Width(), m.common.Height())
|
tabHeight := lipgloss.Height(m.renderTabBar())
|
||||||
|
contentHeight := m.height - tabHeight
|
||||||
|
if contentHeight < 0 {
|
||||||
|
contentHeight = 0
|
||||||
|
}
|
||||||
|
m.activePage.SetSize(m.width, contentHeight)
|
||||||
|
|
||||||
// Trigger a refresh/init on switch? Maybe not needed if we keep state.
|
// Trigger a refresh/init on switch? Maybe not needed if we keep state.
|
||||||
// But we might want to refresh data.
|
// But we might want to refresh data.
|
||||||
return m, m.activePage.Init()
|
return m, m.activePage.Init()
|
||||||
@ -59,6 +87,22 @@ func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MainPage) View() string {
|
func (m *MainPage) renderTabBar() string {
|
||||||
return m.activePage.View()
|
var tabs []string
|
||||||
|
headers := []string{"Tasks", "Time"}
|
||||||
|
|
||||||
|
for i, header := range headers {
|
||||||
|
style := m.common.Styles.Tab
|
||||||
|
if m.currentTab == i {
|
||||||
|
style = m.common.Styles.ActiveTab
|
||||||
|
}
|
||||||
|
tabs = append(tabs, style.Render(header))
|
||||||
|
}
|
||||||
|
|
||||||
|
row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
|
||||||
|
return m.common.Styles.TabBar.Width(m.common.Width()).Render(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MainPage) View() string {
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left, m.renderTabBar(), m.activePage.View())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -133,4 +133,4 @@ func (p *ProjectPickerPage) updateProjectCmd() tea.Msg {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateProjectMsg string
|
type UpdateProjectMsg string
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -129,4 +129,4 @@ func (p *ReportPickerPage) View() string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateReportMsg *taskwarrior.Report
|
type UpdateReportMsg *taskwarrior.Report
|
||||||
|
|||||||
@ -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"
|
||||||
@ -40,6 +41,8 @@ type TaskEditorPage struct {
|
|||||||
area int
|
area int
|
||||||
areaPicker *areaPicker
|
areaPicker *areaPicker
|
||||||
areas []area
|
areas []area
|
||||||
|
|
||||||
|
infoViewport viewport.Model
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPage {
|
func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPage {
|
||||||
@ -67,6 +70,11 @@ func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPag
|
|||||||
|
|
||||||
p.areaPicker = NewAreaPicker(com, []string{"Task", "Tags", "Dates"})
|
p.areaPicker = NewAreaPicker(com, []string{"Task", "Tags", "Dates"})
|
||||||
|
|
||||||
|
p.infoViewport = viewport.New(0, 0)
|
||||||
|
if p.task.Uuid != "" {
|
||||||
|
p.infoViewport.SetContent(p.common.TW.GetInformation(&p.task))
|
||||||
|
}
|
||||||
|
|
||||||
p.columnCursor = 1
|
p.columnCursor = 1
|
||||||
if p.task.Uuid == "" {
|
if p.task.Uuid == "" {
|
||||||
p.mode = modeInsert
|
p.mode = modeInsert
|
||||||
@ -93,6 +101,12 @@ func (p *TaskEditorPage) SetSize(width, height int) {
|
|||||||
} else {
|
} else {
|
||||||
p.colHeight = height - p.common.Styles.ColumnFocused.GetVerticalFrameSize()
|
p.colHeight = height - p.common.Styles.ColumnFocused.GetVerticalFrameSize()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p.infoViewport.Width = width - p.colWidth - p.common.Styles.ColumnFocused.GetHorizontalFrameSize()*2 - 5
|
||||||
|
if p.infoViewport.Width < 0 {
|
||||||
|
p.infoViewport.Width = 0
|
||||||
|
}
|
||||||
|
p.infoViewport.Height = p.colHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *TaskEditorPage) Init() tea.Cmd {
|
func (p *TaskEditorPage) Init() tea.Cmd {
|
||||||
@ -109,12 +123,20 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
p.mode = mode(msg)
|
p.mode = mode(msg)
|
||||||
case prevColumnMsg:
|
case prevColumnMsg:
|
||||||
p.columnCursor--
|
p.columnCursor--
|
||||||
|
maxCols := 2
|
||||||
|
if p.task.Uuid != "" {
|
||||||
|
maxCols = 3
|
||||||
|
}
|
||||||
if p.columnCursor < 0 {
|
if p.columnCursor < 0 {
|
||||||
p.columnCursor = len(p.areas) - 1
|
p.columnCursor = maxCols - 1
|
||||||
}
|
}
|
||||||
case nextColumnMsg:
|
case nextColumnMsg:
|
||||||
p.columnCursor++
|
p.columnCursor++
|
||||||
if p.columnCursor > len(p.areas)-1 {
|
maxCols := 2
|
||||||
|
if p.task.Uuid != "" {
|
||||||
|
maxCols = 3
|
||||||
|
}
|
||||||
|
if p.columnCursor >= maxCols {
|
||||||
p.columnCursor = 0
|
p.columnCursor = 0
|
||||||
}
|
}
|
||||||
case prevAreaMsg:
|
case prevAreaMsg:
|
||||||
@ -165,20 +187,26 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
picker, cmd := p.areaPicker.Update(msg)
|
picker, cmd := p.areaPicker.Update(msg)
|
||||||
p.areaPicker = picker.(*areaPicker)
|
p.areaPicker = picker.(*areaPicker)
|
||||||
return p, cmd
|
return p, cmd
|
||||||
} else {
|
} else if p.columnCursor == 1 {
|
||||||
model, cmd := p.areas[p.area].Update(prevFieldMsg{})
|
model, cmd := p.areas[p.area].Update(prevFieldMsg{})
|
||||||
p.areas[p.area] = model.(area)
|
p.areas[p.area] = model.(area)
|
||||||
return p, cmd
|
return p, cmd
|
||||||
|
} else if p.columnCursor == 2 {
|
||||||
|
p.infoViewport.LineUp(1)
|
||||||
|
return p, nil
|
||||||
}
|
}
|
||||||
case key.Matches(msg, p.common.Keymap.Down):
|
case key.Matches(msg, p.common.Keymap.Down):
|
||||||
if p.columnCursor == 0 {
|
if p.columnCursor == 0 {
|
||||||
picker, cmd := p.areaPicker.Update(msg)
|
picker, cmd := p.areaPicker.Update(msg)
|
||||||
p.areaPicker = picker.(*areaPicker)
|
p.areaPicker = picker.(*areaPicker)
|
||||||
return p, cmd
|
return p, cmd
|
||||||
} else {
|
} else if p.columnCursor == 1 {
|
||||||
model, cmd := p.areas[p.area].Update(nextFieldMsg{})
|
model, cmd := p.areas[p.area].Update(nextFieldMsg{})
|
||||||
p.areas[p.area] = model.(area)
|
p.areas[p.area] = model.(area)
|
||||||
return p, cmd
|
return p, cmd
|
||||||
|
} else if p.columnCursor == 2 {
|
||||||
|
p.infoViewport.LineDown(1)
|
||||||
|
return p, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -211,21 +239,23 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
picker, cmd := p.areaPicker.Update(msg)
|
picker, cmd := p.areaPicker.Update(msg)
|
||||||
p.areaPicker = picker.(*areaPicker)
|
p.areaPicker = picker.(*areaPicker)
|
||||||
return p, cmd
|
return p, cmd
|
||||||
} else {
|
} else if p.columnCursor == 1 {
|
||||||
model, cmd := p.areas[p.area].Update(prevFieldMsg{})
|
model, cmd := p.areas[p.area].Update(prevFieldMsg{})
|
||||||
p.areas[p.area] = model.(area)
|
p.areas[p.area] = model.(area)
|
||||||
return p, cmd
|
return p, cmd
|
||||||
}
|
}
|
||||||
|
return p, nil
|
||||||
case key.Matches(msg, p.common.Keymap.Next):
|
case key.Matches(msg, p.common.Keymap.Next):
|
||||||
if p.columnCursor == 0 {
|
if p.columnCursor == 0 {
|
||||||
picker, cmd := p.areaPicker.Update(msg)
|
picker, cmd := p.areaPicker.Update(msg)
|
||||||
p.areaPicker = picker.(*areaPicker)
|
p.areaPicker = picker.(*areaPicker)
|
||||||
return p, cmd
|
return p, cmd
|
||||||
} else {
|
} else if p.columnCursor == 1 {
|
||||||
model, cmd := p.areas[p.area].Update(nextFieldMsg{})
|
model, cmd := p.areas[p.area].Update(nextFieldMsg{})
|
||||||
p.areas[p.area] = model.(area)
|
p.areas[p.area] = model.(area)
|
||||||
return p, cmd
|
return p, cmd
|
||||||
}
|
}
|
||||||
|
return p, nil
|
||||||
case key.Matches(msg, p.common.Keymap.Ok):
|
case key.Matches(msg, p.common.Keymap.Ok):
|
||||||
model, cmd := p.areas[p.area].Update(msg)
|
model, cmd := p.areas[p.area].Update(msg)
|
||||||
if p.area != 3 {
|
if p.area != 3 {
|
||||||
@ -240,6 +270,10 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
picker, cmd := p.areaPicker.Update(msg)
|
picker, cmd := p.areaPicker.Update(msg)
|
||||||
p.areaPicker = picker.(*areaPicker)
|
p.areaPicker = picker.(*areaPicker)
|
||||||
return p, cmd
|
return p, cmd
|
||||||
|
} else if p.columnCursor == 2 {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
p.infoViewport, cmd = p.infoViewport.Update(msg)
|
||||||
|
return p, cmd
|
||||||
} else {
|
} else {
|
||||||
model, cmd := p.areas[p.area].Update(msg)
|
model, cmd := p.areas[p.area].Update(msg)
|
||||||
p.areas[p.area] = model.(area)
|
p.areas[p.area] = model.(area)
|
||||||
@ -252,29 +286,31 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
func (p *TaskEditorPage) View() string {
|
func (p *TaskEditorPage) View() string {
|
||||||
var focusedStyle, blurredStyle lipgloss.Style
|
var focusedStyle, blurredStyle lipgloss.Style
|
||||||
if p.mode == modeInsert {
|
if p.mode == modeInsert {
|
||||||
focusedStyle = p.common.Styles.ColumnInsert.Width(p.colWidth).Height(p.colHeight)
|
focusedStyle = p.common.Styles.ColumnInsert
|
||||||
} else {
|
} else {
|
||||||
focusedStyle = p.common.Styles.ColumnFocused.Width(p.colWidth).Height(p.colHeight)
|
focusedStyle = p.common.Styles.ColumnFocused
|
||||||
}
|
}
|
||||||
blurredStyle = p.common.Styles.ColumnBlurred.Width(p.colWidth).Height(p.colHeight)
|
blurredStyle = p.common.Styles.ColumnBlurred
|
||||||
// var picker, area string
|
|
||||||
var area string
|
|
||||||
if p.columnCursor == 0 {
|
|
||||||
// picker = focusedStyle.Render(p.areaPicker.View())
|
|
||||||
area = blurredStyle.Render(p.areas[p.area].View())
|
|
||||||
} else {
|
|
||||||
// picker = blurredStyle.Render(p.areaPicker.View())
|
|
||||||
area = focusedStyle.Render(p.areas[p.area].View())
|
|
||||||
|
|
||||||
|
var area string
|
||||||
|
if p.columnCursor == 1 {
|
||||||
|
area = focusedStyle.Copy().Width(p.colWidth).Height(p.colHeight).Render(p.areas[p.area].View())
|
||||||
|
} else {
|
||||||
|
area = blurredStyle.Copy().Width(p.colWidth).Height(p.colHeight).Render(p.areas[p.area].View())
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.task.Uuid != "" {
|
if p.task.Uuid != "" {
|
||||||
|
var infoView string
|
||||||
|
if p.columnCursor == 2 {
|
||||||
|
infoView = focusedStyle.Copy().Width(p.infoViewport.Width).Height(p.infoViewport.Height).Render(p.infoViewport.View())
|
||||||
|
} else {
|
||||||
|
infoView = blurredStyle.Copy().Width(p.infoViewport.Width).Height(p.infoViewport.Height).Render(p.infoViewport.View())
|
||||||
|
}
|
||||||
area = lipgloss.JoinHorizontal(
|
area = lipgloss.JoinHorizontal(
|
||||||
lipgloss.Top,
|
lipgloss.Top,
|
||||||
area,
|
area,
|
||||||
p.common.Styles.ColumnFocused.Render(p.common.TW.GetInformation(&p.task)),
|
infoView,
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tabs := ""
|
tabs := ""
|
||||||
@ -676,45 +712,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 +772,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 +795,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
|
||||||
@ -794,11 +879,12 @@ func NewDetailsEdit(com *common.Common, task *taskwarrior.Task) *detailsEdit {
|
|||||||
// return nil
|
// return nil
|
||||||
// }
|
// }
|
||||||
|
|
||||||
vp := viewport.New(40, 30)
|
vp := viewport.New(com.Width(), 40-com.Styles.ColumnFocused.GetVerticalFrameSize())
|
||||||
ta := textarea.New()
|
ta := textarea.New()
|
||||||
ta.SetWidth(40)
|
ta.SetWidth(70)
|
||||||
ta.SetHeight(30)
|
ta.SetHeight(40 - com.Styles.ColumnFocused.GetVerticalFrameSize() - 2)
|
||||||
ta.ShowLineNumbers = false
|
ta.ShowLineNumbers = false
|
||||||
|
ta.FocusedStyle.CursorLine = lipgloss.NewStyle()
|
||||||
ta.Focus()
|
ta.Focus()
|
||||||
if task.Udas["details"] != nil {
|
if task.Udas["details"] != nil {
|
||||||
ta.SetValue(task.Udas["details"].(string))
|
ta.SetValue(task.Udas["details"].(string))
|
||||||
@ -1008,6 +1094,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"),
|
||||||
|
|||||||
300
pages/timeEditor.go
Normal file
300
pages/timeEditor.go
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"tasksquire/common"
|
||||||
|
"tasksquire/components/autocomplete"
|
||||||
|
"tasksquire/components/timestampeditor"
|
||||||
|
"tasksquire/timewarrior"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TimeEditorPage struct {
|
||||||
|
common *common.Common
|
||||||
|
interval *timewarrior.Interval
|
||||||
|
|
||||||
|
// Fields
|
||||||
|
startEditor *timestampeditor.TimestampEditor
|
||||||
|
endEditor *timestampeditor.TimestampEditor
|
||||||
|
tagsInput *autocomplete.Autocomplete
|
||||||
|
adjust bool
|
||||||
|
|
||||||
|
// State
|
||||||
|
currentField int
|
||||||
|
totalFields int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *TimeEditorPage {
|
||||||
|
// Create start timestamp editor
|
||||||
|
startEditor := timestampeditor.New(com).
|
||||||
|
Title("Start").
|
||||||
|
ValueFromString(interval.Start)
|
||||||
|
|
||||||
|
// Create end timestamp editor
|
||||||
|
endEditor := timestampeditor.New(com).
|
||||||
|
Title("End").
|
||||||
|
ValueFromString(interval.End)
|
||||||
|
|
||||||
|
// Create tags autocomplete with combinations from past intervals
|
||||||
|
tagCombinations := com.TimeW.GetTagCombinations()
|
||||||
|
tagsInput := autocomplete.New(tagCombinations, 3, 10)
|
||||||
|
tagsInput.SetPlaceholder("Space separated, use \"\" for tags with spaces")
|
||||||
|
tagsInput.SetValue(formatTags(interval.Tags))
|
||||||
|
tagsInput.SetWidth(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 {
|
||||||
|
// Focus the first field (tags)
|
||||||
|
p.currentField = 0
|
||||||
|
p.tagsInput.Focus()
|
||||||
|
return p.tagsInput.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, p.common.Keymap.Back):
|
||||||
|
model, err := p.common.PopPage()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("page stack empty")
|
||||||
|
return nil, tea.Quit
|
||||||
|
}
|
||||||
|
return model, BackCmd
|
||||||
|
|
||||||
|
case key.Matches(msg, p.common.Keymap.Ok):
|
||||||
|
// Save and exit
|
||||||
|
p.saveInterval()
|
||||||
|
model, err := p.common.PopPage()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("page stack empty")
|
||||||
|
return nil, tea.Quit
|
||||||
|
}
|
||||||
|
return model, tea.Batch(tea.Batch(cmds...), refreshIntervals)
|
||||||
|
|
||||||
|
case key.Matches(msg, p.common.Keymap.Next):
|
||||||
|
// Move to next field
|
||||||
|
p.blurCurrentField()
|
||||||
|
p.currentField = (p.currentField + 1) % p.totalFields
|
||||||
|
cmds = append(cmds, p.focusCurrentField())
|
||||||
|
|
||||||
|
case key.Matches(msg, p.common.Keymap.Prev):
|
||||||
|
// Move to previous field
|
||||||
|
p.blurCurrentField()
|
||||||
|
p.currentField = (p.currentField - 1 + p.totalFields) % p.totalFields
|
||||||
|
cmds = append(cmds, p.focusCurrentField())
|
||||||
|
}
|
||||||
|
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
p.SetSize(msg.Width, msg.Height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the currently focused field
|
||||||
|
var cmd tea.Cmd
|
||||||
|
switch p.currentField {
|
||||||
|
case 0:
|
||||||
|
var model tea.Model
|
||||||
|
model, cmd = p.tagsInput.Update(msg)
|
||||||
|
if ac, ok := model.(*autocomplete.Autocomplete); ok {
|
||||||
|
p.tagsInput = ac
|
||||||
|
}
|
||||||
|
case 1:
|
||||||
|
var model tea.Model
|
||||||
|
model, cmd = p.startEditor.Update(msg)
|
||||||
|
if editor, ok := model.(*timestampeditor.TimestampEditor); ok {
|
||||||
|
p.startEditor = editor
|
||||||
|
}
|
||||||
|
case 2:
|
||||||
|
var model tea.Model
|
||||||
|
model, cmd = p.endEditor.Update(msg)
|
||||||
|
if editor, ok := model.(*timestampeditor.TimestampEditor); ok {
|
||||||
|
p.endEditor = editor
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
|
||||||
|
return p, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TimeEditorPage) focusCurrentField() tea.Cmd {
|
||||||
|
switch p.currentField {
|
||||||
|
case 0:
|
||||||
|
p.tagsInput.Focus()
|
||||||
|
return p.tagsInput.Init()
|
||||||
|
case 1:
|
||||||
|
return p.startEditor.Focus()
|
||||||
|
case 2:
|
||||||
|
return p.endEditor.Focus()
|
||||||
|
case 3:
|
||||||
|
// Adjust checkbox doesn't need focus action
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TimeEditorPage) blurCurrentField() {
|
||||||
|
switch p.currentField {
|
||||||
|
case 0:
|
||||||
|
p.tagsInput.Blur()
|
||||||
|
case 1:
|
||||||
|
p.startEditor.Blur()
|
||||||
|
case 2:
|
||||||
|
p.endEditor.Blur()
|
||||||
|
case 3:
|
||||||
|
// Adjust checkbox doesn't need blur action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TimeEditorPage) View() string {
|
||||||
|
var sections []string
|
||||||
|
|
||||||
|
// Title
|
||||||
|
titleStyle := p.common.Styles.Form.Focused.Title
|
||||||
|
sections = append(sections, titleStyle.Render("Edit Time Interval"))
|
||||||
|
sections = append(sections, "")
|
||||||
|
|
||||||
|
// Tags input (now first)
|
||||||
|
tagsLabelStyle := p.common.Styles.Form.Focused.Title
|
||||||
|
tagsLabel := tagsLabelStyle.Render("Tags")
|
||||||
|
if p.currentField == 0 {
|
||||||
|
sections = append(sections, tagsLabel)
|
||||||
|
sections = append(sections, p.tagsInput.View())
|
||||||
|
descStyle := p.common.Styles.Form.Focused.Description
|
||||||
|
sections = append(sections, descStyle.Render("Space separated, use \"\" for tags with spaces"))
|
||||||
|
} else {
|
||||||
|
blurredLabelStyle := p.common.Styles.Form.Blurred.Title
|
||||||
|
sections = append(sections, blurredLabelStyle.Render("Tags"))
|
||||||
|
sections = append(sections, lipgloss.NewStyle().Faint(true).Render(p.tagsInput.GetValue()))
|
||||||
|
}
|
||||||
|
|
||||||
|
sections = append(sections, "")
|
||||||
|
sections = append(sections, "")
|
||||||
|
|
||||||
|
// Start editor
|
||||||
|
sections = append(sections, p.startEditor.View())
|
||||||
|
sections = append(sections, "")
|
||||||
|
|
||||||
|
// End editor
|
||||||
|
sections = append(sections, p.endEditor.View())
|
||||||
|
sections = append(sections, "")
|
||||||
|
|
||||||
|
// Adjust checkbox
|
||||||
|
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) {
|
||||||
|
p.common.SetSize(width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TimeEditorPage) saveInterval() {
|
||||||
|
// If it's an existing interval (has ID), delete it first so we can replace it with the modified version
|
||||||
|
if p.interval.ID != 0 {
|
||||||
|
err := p.common.TimeW.DeleteInterval(p.interval.ID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to delete old interval during edit", "err", err)
|
||||||
|
// Proceeding to import anyway, attempting to save user data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.interval.Start = p.startEditor.GetValueString()
|
||||||
|
p.interval.End = p.endEditor.GetValueString()
|
||||||
|
|
||||||
|
// Parse tags
|
||||||
|
p.interval.Tags = parseTags(p.tagsInput.GetValue())
|
||||||
|
|
||||||
|
err := p.common.TimeW.ModifyInterval(p.interval, p.adjust)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to modify interval", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTags(tagsStr string) []string {
|
||||||
|
var tags []string
|
||||||
|
var current strings.Builder
|
||||||
|
inQuotes := false
|
||||||
|
|
||||||
|
for _, r := range tagsStr {
|
||||||
|
switch {
|
||||||
|
case r == '"':
|
||||||
|
inQuotes = !inQuotes
|
||||||
|
case r == ' ' && !inQuotes:
|
||||||
|
if current.Len() > 0 {
|
||||||
|
tags = append(tags, current.String())
|
||||||
|
current.Reset()
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
current.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current.Len() > 0 {
|
||||||
|
tags = append(tags, current.String())
|
||||||
|
}
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTags(tags []string) string {
|
||||||
|
var formatted []string
|
||||||
|
for _, t := range tags {
|
||||||
|
if strings.Contains(t, " ") {
|
||||||
|
formatted = append(formatted, "\""+t+"\"")
|
||||||
|
} else {
|
||||||
|
formatted = append(formatted, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(formatted, " ")
|
||||||
|
}
|
||||||
@ -1,12 +1,17 @@
|
|||||||
package pages
|
package pages
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
"tasksquire/common"
|
"tasksquire/common"
|
||||||
"tasksquire/components/timetable"
|
"tasksquire/components/timetable"
|
||||||
"tasksquire/timewarrior"
|
"tasksquire/timewarrior"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
"github.com/charmbracelet/bubbles/key"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TimePage struct {
|
type TimePage struct {
|
||||||
@ -16,29 +21,150 @@ type TimePage struct {
|
|||||||
data timewarrior.Intervals
|
data timewarrior.Intervals
|
||||||
|
|
||||||
shouldSelectActive bool
|
shouldSelectActive bool
|
||||||
|
|
||||||
|
selectedTimespan string
|
||||||
|
subpage common.Component
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTimePage(com *common.Common) *TimePage {
|
func NewTimePage(com *common.Common) *TimePage {
|
||||||
p := &TimePage{
|
p := &TimePage{
|
||||||
common: com,
|
common: com,
|
||||||
|
selectedTimespan: ":day",
|
||||||
}
|
}
|
||||||
|
|
||||||
p.populateTable(timewarrior.Intervals{})
|
p.populateTable(timewarrior.Intervals{})
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *TimePage) isMultiDayTimespan() bool {
|
||||||
|
switch p.selectedTimespan {
|
||||||
|
case ":day", ":yesterday":
|
||||||
|
return false
|
||||||
|
case ":week", ":lastweek", ":month", ":lastmonth", ":year":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TimePage) getTimespanLabel() string {
|
||||||
|
switch p.selectedTimespan {
|
||||||
|
case ":day":
|
||||||
|
return "Today"
|
||||||
|
case ":yesterday":
|
||||||
|
return "Yesterday"
|
||||||
|
case ":week":
|
||||||
|
return "Week"
|
||||||
|
case ":lastweek":
|
||||||
|
return "Last Week"
|
||||||
|
case ":month":
|
||||||
|
return "Month"
|
||||||
|
case ":lastmonth":
|
||||||
|
return "Last Month"
|
||||||
|
case ":year":
|
||||||
|
return "Year"
|
||||||
|
default:
|
||||||
|
return p.selectedTimespan
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TimePage) getTimespanDateRange() (start, end time.Time) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
switch p.selectedTimespan {
|
||||||
|
case ":day":
|
||||||
|
start = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||||
|
end = start.AddDate(0, 0, 1)
|
||||||
|
case ":yesterday":
|
||||||
|
start = time.Date(now.Year(), now.Month(), now.Day()-1, 0, 0, 0, 0, now.Location())
|
||||||
|
end = start.AddDate(0, 0, 1)
|
||||||
|
case ":week":
|
||||||
|
// Find the start of the week (Monday)
|
||||||
|
offset := int(time.Monday - now.Weekday())
|
||||||
|
if offset > 0 {
|
||||||
|
offset = -6
|
||||||
|
}
|
||||||
|
start = time.Date(now.Year(), now.Month(), now.Day()+offset, 0, 0, 0, 0, now.Location())
|
||||||
|
end = start.AddDate(0, 0, 7)
|
||||||
|
case ":lastweek":
|
||||||
|
// Find the start of last week
|
||||||
|
offset := int(time.Monday - now.Weekday())
|
||||||
|
if offset > 0 {
|
||||||
|
offset = -6
|
||||||
|
}
|
||||||
|
start = time.Date(now.Year(), now.Month(), now.Day()+offset-7, 0, 0, 0, 0, now.Location())
|
||||||
|
end = start.AddDate(0, 0, 7)
|
||||||
|
case ":month":
|
||||||
|
start = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||||
|
end = start.AddDate(0, 1, 0)
|
||||||
|
case ":lastmonth":
|
||||||
|
start = time.Date(now.Year(), now.Month()-1, 1, 0, 0, 0, 0, now.Location())
|
||||||
|
end = start.AddDate(0, 1, 0)
|
||||||
|
case ":year":
|
||||||
|
start = time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location())
|
||||||
|
end = start.AddDate(1, 0, 0)
|
||||||
|
default:
|
||||||
|
start = now
|
||||||
|
end = now
|
||||||
|
}
|
||||||
|
|
||||||
|
return start, end
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TimePage) renderHeader() string {
|
||||||
|
label := p.getTimespanLabel()
|
||||||
|
start, end := p.getTimespanDateRange()
|
||||||
|
|
||||||
|
var headerText string
|
||||||
|
if p.isMultiDayTimespan() {
|
||||||
|
// Multi-day format: "Week: Feb 02 - Feb 08, 2026"
|
||||||
|
if start.Year() == end.AddDate(0, 0, -1).Year() {
|
||||||
|
headerText = fmt.Sprintf("%s: %s - %s, %d",
|
||||||
|
label,
|
||||||
|
start.Format("Jan 02"),
|
||||||
|
end.AddDate(0, 0, -1).Format("Jan 02"),
|
||||||
|
start.Year())
|
||||||
|
} else {
|
||||||
|
headerText = fmt.Sprintf("%s: %s, %d - %s, %d",
|
||||||
|
label,
|
||||||
|
start.Format("Jan 02"),
|
||||||
|
start.Year(),
|
||||||
|
end.AddDate(0, 0, -1).Format("Jan 02"),
|
||||||
|
end.AddDate(0, 0, -1).Year())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single-day format: "Today (Mon, Feb 02, 2026)"
|
||||||
|
headerText = fmt.Sprintf("%s (%s, %s, %d)",
|
||||||
|
label,
|
||||||
|
start.Format("Mon"),
|
||||||
|
start.Format("Jan 02"),
|
||||||
|
start.Year())
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Rendering time page header", "text", headerText, "timespan", p.selectedTimespan)
|
||||||
|
// Make header bold and prominent
|
||||||
|
headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6"))
|
||||||
|
return headerStyle.Render(headerText)
|
||||||
|
}
|
||||||
|
|
||||||
func (p *TimePage) Init() tea.Cmd {
|
func (p *TimePage) Init() tea.Cmd {
|
||||||
return tea.Batch(p.getIntervals(), doTick())
|
return tea.Batch(p.getIntervals(), doTick())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
p.SetSize(msg.Width, msg.Height)
|
p.SetSize(msg.Width, msg.Height)
|
||||||
|
case UpdateTimespanMsg:
|
||||||
|
p.selectedTimespan = string(msg)
|
||||||
|
cmds = append(cmds, p.getIntervals())
|
||||||
case intervalsMsg:
|
case intervalsMsg:
|
||||||
p.data = timewarrior.Intervals(msg)
|
p.data = timewarrior.Intervals(msg)
|
||||||
p.populateTable(p.data)
|
p.populateTable(p.data)
|
||||||
|
case RefreshIntervalsMsg:
|
||||||
|
cmds = append(cmds, p.getIntervals())
|
||||||
case tickMsg:
|
case tickMsg:
|
||||||
cmds = append(cmds, p.getIntervals())
|
cmds = append(cmds, p.getIntervals())
|
||||||
cmds = append(cmds, doTick())
|
cmds = append(cmds, doTick())
|
||||||
@ -46,6 +172,12 @@ func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
switch {
|
switch {
|
||||||
case key.Matches(msg, p.common.Keymap.Quit):
|
case key.Matches(msg, p.common.Keymap.Quit):
|
||||||
return p, tea.Quit
|
return p, tea.Quit
|
||||||
|
case key.Matches(msg, p.common.Keymap.SetReport):
|
||||||
|
// Use 'r' key to show timespan picker
|
||||||
|
p.subpage = NewTimespanPickerPage(p.common, p.selectedTimespan)
|
||||||
|
cmd := p.subpage.Init()
|
||||||
|
p.common.PushPage(p)
|
||||||
|
return p.subpage, cmd
|
||||||
case key.Matches(msg, p.common.Keymap.StartStop):
|
case key.Matches(msg, p.common.Keymap.StartStop):
|
||||||
row := p.intervals.SelectedRow()
|
row := p.intervals.SelectedRow()
|
||||||
if row != nil {
|
if row != nil {
|
||||||
@ -65,6 +197,40 @@ func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
p.common.TimeW.DeleteInterval(interval.ID)
|
p.common.TimeW.DeleteInterval(interval.ID)
|
||||||
return p, tea.Batch(p.getIntervals(), doTick())
|
return p, tea.Batch(p.getIntervals(), doTick())
|
||||||
}
|
}
|
||||||
|
case key.Matches(msg, p.common.Keymap.Edit):
|
||||||
|
row := p.intervals.SelectedRow()
|
||||||
|
if row != nil {
|
||||||
|
interval := (*timewarrior.Interval)(row)
|
||||||
|
editor := NewTimeEditorPage(p.common, interval)
|
||||||
|
p.common.PushPage(p)
|
||||||
|
return editor, editor.Init()
|
||||||
|
}
|
||||||
|
case key.Matches(msg, p.common.Keymap.Add):
|
||||||
|
interval := timewarrior.NewInterval()
|
||||||
|
interval.Start = time.Now().UTC().Format("20060102T150405Z")
|
||||||
|
editor := NewTimeEditorPage(p.common, interval)
|
||||||
|
p.common.PushPage(p)
|
||||||
|
return editor, editor.Init()
|
||||||
|
case key.Matches(msg, p.common.Keymap.Fill):
|
||||||
|
row := p.intervals.SelectedRow()
|
||||||
|
if row != nil {
|
||||||
|
interval := (*timewarrior.Interval)(row)
|
||||||
|
p.common.TimeW.FillInterval(interval.ID)
|
||||||
|
return p, tea.Batch(p.getIntervals(), doTick())
|
||||||
|
}
|
||||||
|
case key.Matches(msg, p.common.Keymap.Undo):
|
||||||
|
p.common.TimeW.Undo()
|
||||||
|
return p, tea.Batch(p.getIntervals(), doTick())
|
||||||
|
case key.Matches(msg, p.common.Keymap.Join):
|
||||||
|
row := p.intervals.SelectedRow()
|
||||||
|
if row != nil {
|
||||||
|
interval := (*timewarrior.Interval)(row)
|
||||||
|
// Don't join if this is the last (oldest) interval
|
||||||
|
if interval.ID < len(p.data) {
|
||||||
|
p.common.TimeW.JoinInterval(interval.ID)
|
||||||
|
return p, tea.Batch(p.getIntervals(), doTick())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,50 +241,139 @@ func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return p, tea.Batch(cmds...)
|
return p, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RefreshIntervalsMsg struct{}
|
||||||
|
|
||||||
|
func refreshIntervals() tea.Msg {
|
||||||
|
return RefreshIntervalsMsg{}
|
||||||
|
}
|
||||||
|
|
||||||
func (p *TimePage) View() string {
|
func (p *TimePage) View() string {
|
||||||
|
header := p.renderHeader()
|
||||||
if len(p.data) == 0 {
|
if len(p.data) == 0 {
|
||||||
return p.common.Styles.Base.Render("No intervals found for today")
|
noDataMsg := p.common.Styles.Base.Render("No intervals found")
|
||||||
|
content := header + "\n\n" + noDataMsg
|
||||||
|
return lipgloss.Place(
|
||||||
|
p.common.Width(),
|
||||||
|
p.common.Height(),
|
||||||
|
lipgloss.Left,
|
||||||
|
lipgloss.Top,
|
||||||
|
content,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return p.intervals.View()
|
|
||||||
|
tableView := p.intervals.View()
|
||||||
|
content := header + "\n\n" + tableView
|
||||||
|
|
||||||
|
contentHeight := lipgloss.Height(content)
|
||||||
|
tableHeight := lipgloss.Height(tableView)
|
||||||
|
headerHeight := lipgloss.Height(header)
|
||||||
|
|
||||||
|
slog.Info("TimePage View rendered",
|
||||||
|
"headerLen", len(header),
|
||||||
|
"dataCount", len(p.data),
|
||||||
|
"headerHeight", headerHeight,
|
||||||
|
"tableHeight", tableHeight,
|
||||||
|
"contentHeight", contentHeight,
|
||||||
|
"termHeight", p.common.Height())
|
||||||
|
|
||||||
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *TimePage) SetSize(width int, height int) {
|
func (p *TimePage) SetSize(width int, height int) {
|
||||||
p.common.SetSize(width, height)
|
p.common.SetSize(width, height)
|
||||||
|
frameSize := p.common.Styles.Base.GetVerticalFrameSize()
|
||||||
|
tableHeight := height - frameSize - 3
|
||||||
|
slog.Info("TimePage SetSize", "totalHeight", height, "frameSize", frameSize, "tableHeight", tableHeight)
|
||||||
p.intervals.SetWidth(width - p.common.Styles.Base.GetHorizontalFrameSize())
|
p.intervals.SetWidth(width - p.common.Styles.Base.GetHorizontalFrameSize())
|
||||||
p.intervals.SetHeight(height - p.common.Styles.Base.GetVerticalFrameSize())
|
// Subtract 3: 1 for header line, 1 for empty line, 1 for safety margin
|
||||||
|
p.intervals.SetHeight(tableHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertGaps inserts gap intervals between actual intervals where there is untracked time.
|
||||||
|
// Gaps are not inserted before the first interval or after the last interval.
|
||||||
|
// Note: intervals are in reverse chronological order (newest first), so we need to account for that.
|
||||||
|
func insertGaps(intervals timewarrior.Intervals) timewarrior.Intervals {
|
||||||
|
if len(intervals) <= 1 {
|
||||||
|
return intervals
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(timewarrior.Intervals, 0, len(intervals)*2)
|
||||||
|
|
||||||
|
for i := 0; i < len(intervals); i++ {
|
||||||
|
result = append(result, intervals[i])
|
||||||
|
|
||||||
|
// Don't add gap after the last interval
|
||||||
|
if i < len(intervals)-1 {
|
||||||
|
// Since intervals are reversed (newest first), the gap is between
|
||||||
|
// the end of the NEXT interval and the start of the CURRENT interval
|
||||||
|
currentStart := intervals[i].GetStartTime()
|
||||||
|
nextEnd := intervals[i+1].GetEndTime()
|
||||||
|
|
||||||
|
// Calculate gap duration
|
||||||
|
gap := currentStart.Sub(nextEnd)
|
||||||
|
|
||||||
|
// Only insert gap if there is untracked time
|
||||||
|
if gap > 0 {
|
||||||
|
gapInterval := timewarrior.NewGapInterval(nextEnd, currentStart)
|
||||||
|
result = append(result, gapInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *TimePage) populateTable(intervals timewarrior.Intervals) {
|
func (p *TimePage) populateTable(intervals timewarrior.Intervals) {
|
||||||
var selectedStart string
|
var selectedStart string
|
||||||
currentIdx := p.intervals.Cursor()
|
|
||||||
if row := p.intervals.SelectedRow(); row != nil {
|
if row := p.intervals.SelectedRow(); row != nil {
|
||||||
selectedStart = row.Start
|
selectedStart = row.Start
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Insert gap intervals between actual intervals
|
||||||
|
intervalsWithGaps := insertGaps(intervals)
|
||||||
|
|
||||||
|
// Determine column configuration based on timespan
|
||||||
|
var startEndWidth int
|
||||||
|
var startField, endField string
|
||||||
|
if p.isMultiDayTimespan() {
|
||||||
|
startEndWidth = 16 // "2006-01-02 15:04"
|
||||||
|
startField = "start"
|
||||||
|
endField = "end"
|
||||||
|
} else {
|
||||||
|
startEndWidth = 5 // "15:04"
|
||||||
|
startField = "start_time"
|
||||||
|
endField = "end_time"
|
||||||
|
}
|
||||||
|
|
||||||
columns := []timetable.Column{
|
columns := []timetable.Column{
|
||||||
{Title: "ID", Name: "id", Width: 4},
|
{Title: "ID", Name: "id", Width: 4},
|
||||||
{Title: "Start", Name: "start", Width: 16},
|
{Title: "Weekday", Name: "weekday", Width: 9},
|
||||||
{Title: "End", Name: "end", Width: 16},
|
{Title: "Start", Name: startField, Width: startEndWidth},
|
||||||
|
{Title: "End", Name: endField, Width: startEndWidth},
|
||||||
{Title: "Duration", Name: "duration", Width: 10},
|
{Title: "Duration", Name: "duration", Width: 10},
|
||||||
{Title: "Tags", Name: "tags", Width: 0}, // flexible width
|
{Title: "Tags", Name: "tags", Width: 0}, // flexible width
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate table height: total height - header (1 line) - blank line (1) - safety (1)
|
||||||
|
frameSize := p.common.Styles.Base.GetVerticalFrameSize()
|
||||||
|
tableHeight := p.common.Height() - frameSize - 3
|
||||||
|
|
||||||
p.intervals = timetable.New(
|
p.intervals = timetable.New(
|
||||||
p.common,
|
p.common,
|
||||||
timetable.WithColumns(columns),
|
timetable.WithColumns(columns),
|
||||||
timetable.WithIntervals(intervals),
|
timetable.WithIntervals(intervalsWithGaps),
|
||||||
timetable.WithFocused(true),
|
timetable.WithFocused(true),
|
||||||
timetable.WithWidth(p.common.Width()-p.common.Styles.Base.GetVerticalFrameSize()),
|
timetable.WithWidth(p.common.Width()-p.common.Styles.Base.GetHorizontalFrameSize()),
|
||||||
timetable.WithHeight(p.common.Height()-p.common.Styles.Base.GetHorizontalFrameSize()),
|
timetable.WithHeight(tableHeight),
|
||||||
timetable.WithStyles(p.common.Styles.TableStyle),
|
timetable.WithStyles(p.common.Styles.TableStyle),
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(intervals) > 0 {
|
if len(intervalsWithGaps) > 0 {
|
||||||
newIdx := -1
|
newIdx := -1
|
||||||
|
|
||||||
if p.shouldSelectActive {
|
if p.shouldSelectActive {
|
||||||
for i, interval := range intervals {
|
for i, interval := range intervalsWithGaps {
|
||||||
if interval.IsActive() {
|
if !interval.IsGap && interval.IsActive() {
|
||||||
newIdx = i
|
newIdx = i
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@ -127,8 +382,8 @@ func (p *TimePage) populateTable(intervals timewarrior.Intervals) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if newIdx == -1 && selectedStart != "" {
|
if newIdx == -1 && selectedStart != "" {
|
||||||
for i, interval := range intervals {
|
for i, interval := range intervalsWithGaps {
|
||||||
if interval.Start == selectedStart {
|
if !interval.IsGap && interval.Start == selectedStart {
|
||||||
newIdx = i
|
newIdx = i
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@ -136,11 +391,17 @@ func (p *TimePage) populateTable(intervals timewarrior.Intervals) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if newIdx == -1 {
|
if newIdx == -1 {
|
||||||
newIdx = currentIdx
|
// Default to first non-gap interval
|
||||||
|
for i, interval := range intervalsWithGaps {
|
||||||
|
if !interval.IsGap {
|
||||||
|
newIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if newIdx >= len(intervals) {
|
if newIdx >= len(intervalsWithGaps) {
|
||||||
newIdx = len(intervals) - 1
|
newIdx = len(intervalsWithGaps) - 1
|
||||||
}
|
}
|
||||||
if newIdx < 0 {
|
if newIdx < 0 {
|
||||||
newIdx = 0
|
newIdx = 0
|
||||||
@ -154,8 +415,7 @@ type intervalsMsg timewarrior.Intervals
|
|||||||
|
|
||||||
func (p *TimePage) getIntervals() tea.Cmd {
|
func (p *TimePage) getIntervals() tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
// ":day" is a timewarrior hint for "today"
|
intervals := p.common.TimeW.GetIntervals(p.selectedTimespan)
|
||||||
intervals := p.common.TimeW.GetIntervals(":day")
|
|
||||||
return intervalsMsg(intervals)
|
return intervalsMsg(intervals)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
146
pages/timePage_test.go
Normal file
146
pages/timePage_test.go
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"tasksquire/timewarrior"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInsertGaps(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
intervals timewarrior.Intervals
|
||||||
|
expectedCount int
|
||||||
|
expectedGaps int
|
||||||
|
description string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty intervals",
|
||||||
|
intervals: timewarrior.Intervals{},
|
||||||
|
expectedCount: 0,
|
||||||
|
expectedGaps: 0,
|
||||||
|
description: "Should return empty list for empty input",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single interval",
|
||||||
|
intervals: timewarrior.Intervals{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Start: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||||
|
End: time.Now().UTC().Format("20060102T150405Z"),
|
||||||
|
Tags: []string{"test"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedCount: 1,
|
||||||
|
expectedGaps: 0,
|
||||||
|
description: "Should return single interval without gaps",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "two intervals with gap (reverse chronological)",
|
||||||
|
intervals: timewarrior.Intervals{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Start: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||||
|
End: time.Now().UTC().Format("20060102T150405Z"),
|
||||||
|
Tags: []string{"test2"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 2,
|
||||||
|
Start: time.Now().Add(-3 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||||
|
End: time.Now().Add(-2 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||||
|
Tags: []string{"test1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedCount: 3,
|
||||||
|
expectedGaps: 1,
|
||||||
|
description: "Should insert one gap between two intervals (newest first order)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "three intervals with two gaps (reverse chronological)",
|
||||||
|
intervals: timewarrior.Intervals{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Start: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||||
|
End: time.Now().UTC().Format("20060102T150405Z"),
|
||||||
|
Tags: []string{"test3"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 2,
|
||||||
|
Start: time.Now().Add(-3 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||||
|
End: time.Now().Add(-2 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||||
|
Tags: []string{"test2"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 3,
|
||||||
|
Start: time.Now().Add(-5 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||||
|
End: time.Now().Add(-4 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||||
|
Tags: []string{"test1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedCount: 5,
|
||||||
|
expectedGaps: 2,
|
||||||
|
description: "Should insert two gaps between three intervals (newest first order)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "consecutive intervals with no gap (reverse chronological)",
|
||||||
|
intervals: timewarrior.Intervals{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Start: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||||
|
End: time.Now().UTC().Format("20060102T150405Z"),
|
||||||
|
Tags: []string{"test2"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 2,
|
||||||
|
Start: time.Now().Add(-2 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||||
|
End: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"),
|
||||||
|
Tags: []string{"test1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedCount: 2,
|
||||||
|
expectedGaps: 0,
|
||||||
|
description: "Should not insert gap when intervals are consecutive (newest first order)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := insertGaps(tt.intervals)
|
||||||
|
|
||||||
|
if len(result) != tt.expectedCount {
|
||||||
|
t.Errorf("insertGaps() returned %d intervals, expected %d. %s",
|
||||||
|
len(result), tt.expectedCount, tt.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
gapCount := 0
|
||||||
|
for _, interval := range result {
|
||||||
|
if interval.IsGap {
|
||||||
|
gapCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if gapCount != tt.expectedGaps {
|
||||||
|
t.Errorf("insertGaps() created %d gaps, expected %d. %s",
|
||||||
|
gapCount, tt.expectedGaps, tt.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify gaps are properly interleaved with intervals
|
||||||
|
for i := 0; i < len(result)-1; i++ {
|
||||||
|
if result[i].IsGap && result[i+1].IsGap {
|
||||||
|
t.Errorf("insertGaps() created consecutive gap rows at indices %d and %d", i, i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify first and last items are never gaps
|
||||||
|
if len(result) > 0 {
|
||||||
|
if result[0].IsGap {
|
||||||
|
t.Errorf("insertGaps() created gap as first item")
|
||||||
|
}
|
||||||
|
if result[len(result)-1].IsGap {
|
||||||
|
t.Errorf("insertGaps() created gap as last item")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
128
pages/timespanPicker.go
Normal file
128
pages/timespanPicker.go
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"tasksquire/common"
|
||||||
|
"tasksquire/components/picker"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
"github.com/charmbracelet/bubbles/list"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TimespanPickerPage struct {
|
||||||
|
common *common.Common
|
||||||
|
picker *picker.Picker
|
||||||
|
selectedTimespan string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTimespanPickerPage(common *common.Common, currentTimespan string) *TimespanPickerPage {
|
||||||
|
p := &TimespanPickerPage{
|
||||||
|
common: common,
|
||||||
|
selectedTimespan: currentTimespan,
|
||||||
|
}
|
||||||
|
|
||||||
|
timespanOptions := []list.Item{
|
||||||
|
picker.NewItem(":day"),
|
||||||
|
picker.NewItem(":yesterday"),
|
||||||
|
picker.NewItem(":week"),
|
||||||
|
picker.NewItem(":lastweek"),
|
||||||
|
picker.NewItem(":month"),
|
||||||
|
picker.NewItem(":lastmonth"),
|
||||||
|
picker.NewItem(":year"),
|
||||||
|
}
|
||||||
|
|
||||||
|
itemProvider := func() []list.Item {
|
||||||
|
return timespanOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelect := func(item list.Item) tea.Cmd {
|
||||||
|
return func() tea.Msg { return timespanSelectedMsg{item: item} }
|
||||||
|
}
|
||||||
|
|
||||||
|
p.picker = picker.New(common, "Select Timespan", itemProvider, onSelect)
|
||||||
|
|
||||||
|
// Select the current timespan in the picker
|
||||||
|
p.picker.SelectItemByFilterValue(currentTimespan)
|
||||||
|
|
||||||
|
p.SetSize(common.Width(), common.Height())
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TimespanPickerPage) SetSize(width, height int) {
|
||||||
|
p.common.SetSize(width, height)
|
||||||
|
|
||||||
|
// Set list size with some padding/limits to look like a picker
|
||||||
|
listWidth := width - 4
|
||||||
|
if listWidth > 40 {
|
||||||
|
listWidth = 40
|
||||||
|
}
|
||||||
|
listHeight := height - 6
|
||||||
|
if listHeight > 20 {
|
||||||
|
listHeight = 20
|
||||||
|
}
|
||||||
|
p.picker.SetSize(listWidth, listHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TimespanPickerPage) Init() tea.Cmd {
|
||||||
|
return p.picker.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
type timespanSelectedMsg struct {
|
||||||
|
item list.Item
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TimespanPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
p.SetSize(msg.Width, msg.Height)
|
||||||
|
case timespanSelectedMsg:
|
||||||
|
timespan := msg.item.FilterValue()
|
||||||
|
|
||||||
|
model, err := p.common.PopPage()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("page stack empty")
|
||||||
|
return nil, tea.Quit
|
||||||
|
}
|
||||||
|
return model, func() tea.Msg { return UpdateTimespanMsg(timespan) }
|
||||||
|
case tea.KeyMsg:
|
||||||
|
if !p.picker.IsFiltering() {
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, p.common.Keymap.Back):
|
||||||
|
model, err := p.common.PopPage()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("page stack empty")
|
||||||
|
return nil, tea.Quit
|
||||||
|
}
|
||||||
|
return model, BackCmd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, cmd = p.picker.Update(msg)
|
||||||
|
return p, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TimespanPickerPage) View() string {
|
||||||
|
width := p.common.Width() - 4
|
||||||
|
if width > 40 {
|
||||||
|
width = 40
|
||||||
|
}
|
||||||
|
|
||||||
|
content := p.picker.View()
|
||||||
|
styledContent := lipgloss.NewStyle().Width(width).Render(content)
|
||||||
|
|
||||||
|
return lipgloss.Place(
|
||||||
|
p.common.Width(),
|
||||||
|
p.common.Height(),
|
||||||
|
lipgloss.Center,
|
||||||
|
lipgloss.Center,
|
||||||
|
p.common.Styles.Base.Render(styledContent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateTimespanMsg string
|
||||||
@ -120,19 +120,25 @@ func (t *Task) GetString(fieldWFormat string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(t.Annotations) == 0 {
|
if t.Udas["details"] != nil && t.Udas["details"] != "" {
|
||||||
return t.Description
|
return fmt.Sprintf("%s [D]", t.Description)
|
||||||
} else {
|
} else {
|
||||||
// var annotations []string
|
return t.Description
|
||||||
// for _, a := range t.Annotations {
|
|
||||||
// annotations = append(annotations, a.String())
|
|
||||||
// }
|
|
||||||
// return fmt.Sprintf("%s\n%s", t.Description, strings.Join(annotations, "\n"))
|
|
||||||
|
|
||||||
// TODO enable support for multiline in table
|
|
||||||
return fmt.Sprintf("%s [%d]", t.Description, len(t.Annotations))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if len(t.Annotations) == 0 {
|
||||||
|
// return t.Description
|
||||||
|
// } else {
|
||||||
|
// // var annotations []string
|
||||||
|
// // for _, a := range t.Annotations {
|
||||||
|
// // annotations = append(annotations, a.String())
|
||||||
|
// // }
|
||||||
|
// // return fmt.Sprintf("%s\n%s", t.Description, strings.Join(annotations, "\n"))
|
||||||
|
|
||||||
|
// // TODO enable support for multiline in table
|
||||||
|
// return fmt.Sprintf("%s [%d]", t.Description, len(t.Annotations))
|
||||||
|
// }
|
||||||
|
|
||||||
case "project":
|
case "project":
|
||||||
switch format {
|
switch format {
|
||||||
case "parent":
|
case "parent":
|
||||||
|
|||||||
Binary file not shown.
@ -20,6 +20,7 @@ type Interval struct {
|
|||||||
Start string `json:"start,omitempty"`
|
Start string `json:"start,omitempty"`
|
||||||
End string `json:"end,omitempty"`
|
End string `json:"end,omitempty"`
|
||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
IsGap bool `json:"-"` // True if this represents an untracked time gap
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewInterval() *Interval {
|
func NewInterval() *Interval {
|
||||||
@ -28,7 +29,31 @@ func NewInterval() *Interval {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewGapInterval creates a new gap interval representing untracked time.
|
||||||
|
// start and end are the times between which the gap occurred.
|
||||||
|
func NewGapInterval(start, end time.Time) *Interval {
|
||||||
|
return &Interval{
|
||||||
|
ID: -1, // Gap intervals have no real ID
|
||||||
|
Start: start.UTC().Format(dtformat),
|
||||||
|
End: end.UTC().Format(dtformat),
|
||||||
|
Tags: make([]string, 0),
|
||||||
|
IsGap: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (i *Interval) GetString(field string) string {
|
func (i *Interval) GetString(field string) string {
|
||||||
|
// Special handling for gap intervals
|
||||||
|
if i.IsGap {
|
||||||
|
switch field {
|
||||||
|
case "duration":
|
||||||
|
return i.GetDuration()
|
||||||
|
case "gap_display":
|
||||||
|
return fmt.Sprintf("--- Untracked: %s ---", i.GetDuration())
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch field {
|
switch field {
|
||||||
case "id":
|
case "id":
|
||||||
return strconv.Itoa(i.ID)
|
return strconv.Itoa(i.ID)
|
||||||
@ -36,12 +61,24 @@ func (i *Interval) GetString(field string) string {
|
|||||||
case "start":
|
case "start":
|
||||||
return formatDate(i.Start, "formatted")
|
return formatDate(i.Start, "formatted")
|
||||||
|
|
||||||
|
case "start_time":
|
||||||
|
return formatDate(i.Start, "time")
|
||||||
|
|
||||||
case "end":
|
case "end":
|
||||||
if i.End == "" {
|
if i.End == "" {
|
||||||
return "now"
|
return "now"
|
||||||
}
|
}
|
||||||
return formatDate(i.End, "formatted")
|
return formatDate(i.End, "formatted")
|
||||||
|
|
||||||
|
case "end_time":
|
||||||
|
if i.End == "" {
|
||||||
|
return "now"
|
||||||
|
}
|
||||||
|
return formatDate(i.End, "time")
|
||||||
|
|
||||||
|
case "weekday":
|
||||||
|
return formatDate(i.Start, "weekday")
|
||||||
|
|
||||||
case "tags":
|
case "tags":
|
||||||
if len(i.Tags) == 0 {
|
if len(i.Tags) == 0 {
|
||||||
return ""
|
return ""
|
||||||
@ -145,6 +182,8 @@ func formatDate(date string, format string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dt = dt.Local()
|
||||||
|
|
||||||
switch format {
|
switch format {
|
||||||
case "formatted", "":
|
case "formatted", "":
|
||||||
return dt.Format("2006-01-02 15:04")
|
return dt.Format("2006-01-02 15:04")
|
||||||
@ -152,6 +191,8 @@ func formatDate(date string, format string) string {
|
|||||||
return dt.Format("15:04")
|
return dt.Format("15:04")
|
||||||
case "date":
|
case "date":
|
||||||
return dt.Format("2006-01-02")
|
return dt.Format("2006-01-02")
|
||||||
|
case "weekday":
|
||||||
|
return dt.Format("Mon")
|
||||||
case "iso":
|
case "iso":
|
||||||
return dt.Format("2006-01-02T150405Z")
|
return dt.Format("2006-01-02T150405Z")
|
||||||
case "epoch":
|
case "epoch":
|
||||||
@ -171,10 +212,7 @@ func formatDuration(d time.Duration) string {
|
|||||||
minutes := int(d.Minutes()) % 60
|
minutes := int(d.Minutes()) % 60
|
||||||
seconds := int(d.Seconds()) % 60
|
seconds := int(d.Seconds()) % 60
|
||||||
|
|
||||||
if hours > 0 {
|
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
|
||||||
return fmt.Sprintf("%d:%02d:%02d", hours, minutes, seconds)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%d:%02d", minutes, seconds)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseDurationVague(d time.Duration) string {
|
func parseDurationVague(d time.Duration) string {
|
||||||
|
|||||||
@ -22,6 +22,7 @@ type TimeWarrior interface {
|
|||||||
GetConfig() *TWConfig
|
GetConfig() *TWConfig
|
||||||
|
|
||||||
GetTags() []string
|
GetTags() []string
|
||||||
|
GetTagCombinations() []string
|
||||||
|
|
||||||
GetIntervals(filter ...string) Intervals
|
GetIntervals(filter ...string) Intervals
|
||||||
StartTracking(tags []string) error
|
StartTracking(tags []string) error
|
||||||
@ -30,7 +31,9 @@ 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
|
FillInterval(id int) error
|
||||||
|
JoinInterval(id int) error
|
||||||
|
ModifyInterval(interval *Interval, adjust bool) error
|
||||||
GetSummary(filter ...string) string
|
GetSummary(filter ...string) string
|
||||||
GetActive() *Interval
|
GetActive() *Interval
|
||||||
|
|
||||||
@ -99,6 +102,46 @@ func (ts *TimeSquire) GetTags() []string {
|
|||||||
return tags
|
return tags
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTagCombinations returns unique tag combinations from intervals,
|
||||||
|
// ordered newest first (most recent intervals' tags appear first).
|
||||||
|
// Returns formatted strings like "dev client-work meeting".
|
||||||
|
func (ts *TimeSquire) GetTagCombinations() []string {
|
||||||
|
intervals := ts.GetIntervals() // Already sorted newest first
|
||||||
|
|
||||||
|
// Track unique combinations while preserving order
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var combinations []string
|
||||||
|
|
||||||
|
for _, interval := range intervals {
|
||||||
|
if len(interval.Tags) == 0 {
|
||||||
|
continue // Skip intervals with no tags
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format tags (handles spaces with quotes)
|
||||||
|
combo := formatTagsForCombination(interval.Tags)
|
||||||
|
|
||||||
|
if !seen[combo] {
|
||||||
|
seen[combo] = true
|
||||||
|
combinations = append(combinations, combo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return combinations
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatTagsForCombination formats tags consistently for display
|
||||||
|
func formatTagsForCombination(tags []string) string {
|
||||||
|
var formatted []string
|
||||||
|
for _, t := range tags {
|
||||||
|
if strings.Contains(t, " ") {
|
||||||
|
formatted = append(formatted, "\""+t+"\"")
|
||||||
|
} else {
|
||||||
|
formatted = append(formatted, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(formatted, " ")
|
||||||
|
}
|
||||||
|
|
||||||
func (ts *TimeSquire) GetIntervals(filter ...string) Intervals {
|
func (ts *TimeSquire) GetIntervals(filter ...string) Intervals {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
@ -218,7 +261,35 @@ func (ts *TimeSquire) DeleteInterval(id int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ts *TimeSquire) ModifyInterval(interval *Interval) error {
|
func (ts *TimeSquire) FillInterval(id int) error {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"fill", fmt.Sprintf("@%d", id)}...)...)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
slog.Error("Failed filling interval:", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) JoinInterval(id int) error {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
// Join the current interval with the previous one
|
||||||
|
// The previous interval has id+1 (since intervals are ordered newest first)
|
||||||
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"join", fmt.Sprintf("@%d", id+1), fmt.Sprintf("@%d", id)}...)...)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
slog.Error("Failed joining interval:", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) ModifyInterval(interval *Interval, adjust bool) error {
|
||||||
ts.mutex.Lock()
|
ts.mutex.Lock()
|
||||||
defer ts.mutex.Unlock()
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
@ -229,8 +300,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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user