diff --git a/components/autocomplete/autocomplete.go b/components/autocomplete/autocomplete.go new file mode 100644 index 0000000..2e98307 --- /dev/null +++ b/components/autocomplete/autocomplete.go @@ -0,0 +1,224 @@ +package autocomplete + +import ( + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type Autocomplete struct { + input textinput.Model + allSuggestions []string // All available suggestions (newest first) + filteredSuggestions []string // Currently matching suggestions + 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) *Autocomplete { + ti := textinput.New() + ti.Width = 50 + + return &Autocomplete{ + input: ti, + allSuggestions: suggestions, + selectedIndex: -1, + maxVisible: 5, + 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 +} + +// 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 := " " + style := lipgloss.NewStyle().Padding(0, 1) + + if i == a.selectedIndex { + // Highlight selected suggestion + style = style.Bold(true).Foreground(lipgloss.Color("12")) + prefix = "→ " + } + + suggestionViews = append(suggestionViews, style.Render(prefix+suggestion)) + } + + // 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) +} + +// updateFilteredSuggestions filters suggestions based on current input +func (a *Autocomplete) updateFilteredSuggestions() { + value := strings.ToLower(a.input.Value()) + + // Only show if >= minChars + if len(value) < a.minChars { + a.showSuggestions = false + a.filteredSuggestions = nil + a.selectedIndex = -1 + return + } + + // Substring match (case-insensitive) + var filtered []string + for _, suggestion := range a.allSuggestions { + if strings.Contains(strings.ToLower(suggestion), value) { + filtered = append(filtered, suggestion) + if len(filtered) >= a.maxVisible { + break + } + } + } + + a.filteredSuggestions = filtered + a.showSuggestions = len(filtered) > 0 && a.focused + a.selectedIndex = -1 // Reset to input +} diff --git a/pages/timeEditor.go b/pages/timeEditor.go index 5c6545b..e88ed68 100644 --- a/pages/timeEditor.go +++ b/pages/timeEditor.go @@ -4,11 +4,11 @@ import ( "log/slog" "strings" "tasksquire/common" + "tasksquire/components/autocomplete" "tasksquire/components/timestampeditor" "tasksquire/timewarrior" "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) @@ -20,7 +20,7 @@ type TimeEditorPage struct { // Fields startEditor *timestampeditor.TimestampEditor endEditor *timestampeditor.TimestampEditor - tagsInput textinput.Model + tagsInput *autocomplete.Autocomplete adjust bool // State @@ -39,11 +39,12 @@ func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *Time Title("End"). ValueFromString(interval.End) - // Create tags input - tagsInput := textinput.New() - tagsInput.Placeholder = "Space separated, use \"\" for tags with spaces" + // Create tags autocomplete with combinations from past intervals + tagCombinations := com.TimeW.GetTagCombinations() + tagsInput := autocomplete.New(tagCombinations, 3) + tagsInput.SetPlaceholder("Space separated, use \"\" for tags with spaces") tagsInput.SetValue(formatTags(interval.Tags)) - tagsInput.Width = 50 + tagsInput.SetWidth(50) p := &TimeEditorPage{ common: com, @@ -60,9 +61,10 @@ func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *Time } func (p *TimeEditorPage) Init() tea.Cmd { - // Focus the first field + // Focus the first field (tags) p.currentField = 0 - return p.startEditor.Focus() + p.tagsInput.Focus() + return p.tagsInput.Init() } func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -110,19 +112,23 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 1: + case 2: var model tea.Model model, cmd = p.endEditor.Update(msg) if editor, ok := model.(*timestampeditor.TimestampEditor); ok { p.endEditor = editor } - case 2: - p.tagsInput, cmd = p.tagsInput.Update(msg) case 3: // Handle adjust toggle with space/enter if msg, ok := msg.(tea.KeyMsg); ok { @@ -139,12 +145,12 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (p *TimeEditorPage) focusCurrentField() tea.Cmd { switch p.currentField { case 0: - return p.startEditor.Focus() - case 1: - return p.endEditor.Focus() - case 2: p.tagsInput.Focus() - return nil + 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 @@ -155,11 +161,11 @@ func (p *TimeEditorPage) focusCurrentField() tea.Cmd { func (p *TimeEditorPage) blurCurrentField() { switch p.currentField { case 0: - p.startEditor.Blur() - case 1: - p.endEditor.Blur() - case 2: p.tagsInput.Blur() + case 1: + p.startEditor.Blur() + case 2: + p.endEditor.Blur() case 3: // Adjust checkbox doesn't need blur action } @@ -173,18 +179,10 @@ func (p *TimeEditorPage) View() string { sections = append(sections, titleStyle.Render("Edit Time Interval")) sections = append(sections, "") - // Start editor - sections = append(sections, p.startEditor.View()) - sections = append(sections, "") - - // End editor - sections = append(sections, p.endEditor.View()) - sections = append(sections, "") - - // Tags input + // Tags input (now first) tagsLabelStyle := p.common.Styles.Form.Focused.Title tagsLabel := tagsLabelStyle.Render("Tags") - if p.currentField == 2 { + if p.currentField == 0 { sections = append(sections, tagsLabel) sections = append(sections, p.tagsInput.View()) descStyle := p.common.Styles.Form.Focused.Description @@ -192,12 +190,20 @@ func (p *TimeEditorPage) View() string { } else { blurredLabelStyle := p.common.Styles.Form.Blurred.Title sections = append(sections, blurredLabelStyle.Render("Tags")) - sections = append(sections, lipgloss.NewStyle().Faint(true).Render(p.tagsInput.Value())) + sections = append(sections, 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") @@ -249,7 +255,7 @@ func (p *TimeEditorPage) saveInterval() { p.interval.End = p.endEditor.GetValueString() // Parse tags - p.interval.Tags = parseTags(p.tagsInput.Value()) + p.interval.Tags = parseTags(p.tagsInput.GetValue()) err := p.common.TimeW.ModifyInterval(p.interval, p.adjust) if err != nil { diff --git a/pages/timePage_test.go b/pages/timePage_test.go new file mode 100644 index 0000000..850368f --- /dev/null +++ b/pages/timePage_test.go @@ -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") + } + } + }) + } +} diff --git a/pages/timespanPicker.go b/pages/timespanPicker.go new file mode 100644 index 0000000..849a36a --- /dev/null +++ b/pages/timespanPicker.go @@ -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 diff --git a/timewarrior/timewarrior.go b/timewarrior/timewarrior.go index e470465..fcb5990 100644 --- a/timewarrior/timewarrior.go +++ b/timewarrior/timewarrior.go @@ -22,6 +22,7 @@ type TimeWarrior interface { GetConfig() *TWConfig GetTags() []string + GetTagCombinations() []string GetIntervals(filter ...string) Intervals StartTracking(tags []string) error @@ -101,6 +102,46 @@ func (ts *TimeSquire) GetTags() []string { 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 { ts.mutex.Lock() defer ts.mutex.Unlock()