Add fuzzy matching for time tags
This commit is contained in:
224
components/autocomplete/autocomplete.go
Normal file
224
components/autocomplete/autocomplete.go
Normal 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
|
||||||
|
}
|
||||||
@ -4,11 +4,11 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"tasksquire/common"
|
"tasksquire/common"
|
||||||
|
"tasksquire/components/autocomplete"
|
||||||
"tasksquire/components/timestampeditor"
|
"tasksquire/components/timestampeditor"
|
||||||
"tasksquire/timewarrior"
|
"tasksquire/timewarrior"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
"github.com/charmbracelet/bubbles/key"
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
@ -20,7 +20,7 @@ type TimeEditorPage struct {
|
|||||||
// Fields
|
// Fields
|
||||||
startEditor *timestampeditor.TimestampEditor
|
startEditor *timestampeditor.TimestampEditor
|
||||||
endEditor *timestampeditor.TimestampEditor
|
endEditor *timestampeditor.TimestampEditor
|
||||||
tagsInput textinput.Model
|
tagsInput *autocomplete.Autocomplete
|
||||||
adjust bool
|
adjust bool
|
||||||
|
|
||||||
// State
|
// State
|
||||||
@ -39,11 +39,12 @@ func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *Time
|
|||||||
Title("End").
|
Title("End").
|
||||||
ValueFromString(interval.End)
|
ValueFromString(interval.End)
|
||||||
|
|
||||||
// Create tags input
|
// Create tags autocomplete with combinations from past intervals
|
||||||
tagsInput := textinput.New()
|
tagCombinations := com.TimeW.GetTagCombinations()
|
||||||
tagsInput.Placeholder = "Space separated, use \"\" for tags with spaces"
|
tagsInput := autocomplete.New(tagCombinations, 3)
|
||||||
|
tagsInput.SetPlaceholder("Space separated, use \"\" for tags with spaces")
|
||||||
tagsInput.SetValue(formatTags(interval.Tags))
|
tagsInput.SetValue(formatTags(interval.Tags))
|
||||||
tagsInput.Width = 50
|
tagsInput.SetWidth(50)
|
||||||
|
|
||||||
p := &TimeEditorPage{
|
p := &TimeEditorPage{
|
||||||
common: com,
|
common: com,
|
||||||
@ -60,9 +61,10 @@ func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *Time
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *TimeEditorPage) Init() tea.Cmd {
|
func (p *TimeEditorPage) Init() tea.Cmd {
|
||||||
// Focus the first field
|
// Focus the first field (tags)
|
||||||
p.currentField = 0
|
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) {
|
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
|
var cmd tea.Cmd
|
||||||
switch p.currentField {
|
switch p.currentField {
|
||||||
case 0:
|
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
|
var model tea.Model
|
||||||
model, cmd = p.startEditor.Update(msg)
|
model, cmd = p.startEditor.Update(msg)
|
||||||
if editor, ok := model.(*timestampeditor.TimestampEditor); ok {
|
if editor, ok := model.(*timestampeditor.TimestampEditor); ok {
|
||||||
p.startEditor = editor
|
p.startEditor = editor
|
||||||
}
|
}
|
||||||
case 1:
|
case 2:
|
||||||
var model tea.Model
|
var model tea.Model
|
||||||
model, cmd = p.endEditor.Update(msg)
|
model, cmd = p.endEditor.Update(msg)
|
||||||
if editor, ok := model.(*timestampeditor.TimestampEditor); ok {
|
if editor, ok := model.(*timestampeditor.TimestampEditor); ok {
|
||||||
p.endEditor = editor
|
p.endEditor = editor
|
||||||
}
|
}
|
||||||
case 2:
|
|
||||||
p.tagsInput, cmd = p.tagsInput.Update(msg)
|
|
||||||
case 3:
|
case 3:
|
||||||
// Handle adjust toggle with space/enter
|
// Handle adjust toggle with space/enter
|
||||||
if msg, ok := msg.(tea.KeyMsg); ok {
|
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 {
|
func (p *TimeEditorPage) focusCurrentField() tea.Cmd {
|
||||||
switch p.currentField {
|
switch p.currentField {
|
||||||
case 0:
|
case 0:
|
||||||
return p.startEditor.Focus()
|
|
||||||
case 1:
|
|
||||||
return p.endEditor.Focus()
|
|
||||||
case 2:
|
|
||||||
p.tagsInput.Focus()
|
p.tagsInput.Focus()
|
||||||
return nil
|
return p.tagsInput.Init()
|
||||||
|
case 1:
|
||||||
|
return p.startEditor.Focus()
|
||||||
|
case 2:
|
||||||
|
return p.endEditor.Focus()
|
||||||
case 3:
|
case 3:
|
||||||
// Adjust checkbox doesn't need focus action
|
// Adjust checkbox doesn't need focus action
|
||||||
return nil
|
return nil
|
||||||
@ -155,11 +161,11 @@ func (p *TimeEditorPage) focusCurrentField() tea.Cmd {
|
|||||||
func (p *TimeEditorPage) blurCurrentField() {
|
func (p *TimeEditorPage) blurCurrentField() {
|
||||||
switch p.currentField {
|
switch p.currentField {
|
||||||
case 0:
|
case 0:
|
||||||
p.startEditor.Blur()
|
|
||||||
case 1:
|
|
||||||
p.endEditor.Blur()
|
|
||||||
case 2:
|
|
||||||
p.tagsInput.Blur()
|
p.tagsInput.Blur()
|
||||||
|
case 1:
|
||||||
|
p.startEditor.Blur()
|
||||||
|
case 2:
|
||||||
|
p.endEditor.Blur()
|
||||||
case 3:
|
case 3:
|
||||||
// Adjust checkbox doesn't need blur action
|
// 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, titleStyle.Render("Edit Time Interval"))
|
||||||
sections = append(sections, "")
|
sections = append(sections, "")
|
||||||
|
|
||||||
// Start editor
|
// Tags input (now first)
|
||||||
sections = append(sections, p.startEditor.View())
|
|
||||||
sections = append(sections, "")
|
|
||||||
|
|
||||||
// End editor
|
|
||||||
sections = append(sections, p.endEditor.View())
|
|
||||||
sections = append(sections, "")
|
|
||||||
|
|
||||||
// Tags input
|
|
||||||
tagsLabelStyle := p.common.Styles.Form.Focused.Title
|
tagsLabelStyle := p.common.Styles.Form.Focused.Title
|
||||||
tagsLabel := tagsLabelStyle.Render("Tags")
|
tagsLabel := tagsLabelStyle.Render("Tags")
|
||||||
if p.currentField == 2 {
|
if p.currentField == 0 {
|
||||||
sections = append(sections, tagsLabel)
|
sections = append(sections, tagsLabel)
|
||||||
sections = append(sections, p.tagsInput.View())
|
sections = append(sections, p.tagsInput.View())
|
||||||
descStyle := p.common.Styles.Form.Focused.Description
|
descStyle := p.common.Styles.Form.Focused.Description
|
||||||
@ -192,12 +190,20 @@ func (p *TimeEditorPage) View() string {
|
|||||||
} else {
|
} else {
|
||||||
blurredLabelStyle := p.common.Styles.Form.Blurred.Title
|
blurredLabelStyle := p.common.Styles.Form.Blurred.Title
|
||||||
sections = append(sections, blurredLabelStyle.Render("Tags"))
|
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, "")
|
||||||
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
|
// Adjust checkbox
|
||||||
adjustLabelStyle := p.common.Styles.Form.Focused.Title
|
adjustLabelStyle := p.common.Styles.Form.Focused.Title
|
||||||
adjustLabel := adjustLabelStyle.Render("Adjust overlaps")
|
adjustLabel := adjustLabelStyle.Render("Adjust overlaps")
|
||||||
@ -249,7 +255,7 @@ func (p *TimeEditorPage) saveInterval() {
|
|||||||
p.interval.End = p.endEditor.GetValueString()
|
p.interval.End = p.endEditor.GetValueString()
|
||||||
|
|
||||||
// Parse tags
|
// 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)
|
err := p.common.TimeW.ModifyInterval(p.interval, p.adjust)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
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
|
||||||
@ -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
|
||||||
@ -101,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()
|
||||||
|
|||||||
Reference in New Issue
Block a user