Add fuzzy matching for time tags

This commit is contained in:
Martin Pander
2026-02-02 15:41:53 +01:00
parent 81b9d87935
commit 938ed177f1
5 changed files with 577 additions and 32 deletions

View File

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

View File

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

146
pages/timePage_test.go Normal file
View 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
View 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

View File

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