8 Commits

Author SHA1 Message Date
474bb3dc07 Add project tracking picker 2026-02-03 20:59:47 +01:00
1ffcf42773 Fix bugs 2026-02-03 20:13:09 +01:00
44ddbc0f47 Add syncing 2026-02-03 16:04:47 +01:00
2e33893e29 Merge branch 'feat/taskedit' into feat/time 2026-02-03 07:40:11 +01:00
46ce91196a Merge branch 'feat/task' into feat/time 2026-02-03 07:39:59 +01:00
70b6ee9bc7 Add picker to task edit 2026-02-02 20:43:08 +01:00
2baf3859fd Add tab bar 2026-02-02 19:47:18 +01:00
2940711b26 Make task details scrollable 2026-02-02 19:39:02 +01:00
19 changed files with 892 additions and 109 deletions

View File

@ -24,6 +24,7 @@ type Keymap struct {
SetReport key.Binding
SetContext key.Binding
SetProject key.Binding
PickProjectTask key.Binding
Select key.Binding
Insert key.Binding
Tag key.Binding
@ -127,6 +128,11 @@ func NewKeymap() *Keymap {
key.WithHelp("p", "Set project"),
),
PickProjectTask: key.NewBinding(
key.WithKeys("P"),
key.WithHelp("P", "Pick project task"),
),
Select: key.NewBinding(
key.WithKeys(" "),
key.WithHelp("space", "Select"),

View File

@ -27,6 +27,10 @@ type Styles struct {
Form *huh.Theme
TableStyle TableStyle
Tab lipgloss.Style
ActiveTab lipgloss.Style
TabBar lipgloss.Style
ColumnFocused lipgloss.Style
ColumnBlurred lipgloss.Style
ColumnInsert lipgloss.Style
@ -71,6 +75,19 @@ func NewStyles(config *taskwarrior.TWConfig) *Styles {
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.ColumnBlurred = lipgloss.NewStyle().Border(lipgloss.HiddenBorder(), true).Padding(1)
styles.ColumnInsert = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1)

85
common/sync.go Normal file
View File

@ -0,0 +1,85 @@
package common
import (
"log/slog"
"tasksquire/taskwarrior"
"tasksquire/timewarrior"
)
// FindTaskByUUID queries Taskwarrior for a task with the given UUID.
// Returns nil if not found.
func FindTaskByUUID(tw taskwarrior.TaskWarrior, uuid string) *taskwarrior.Task {
if uuid == "" {
return nil
}
// Use empty report to query by UUID filter
report := &taskwarrior.Report{Name: ""}
tasks := tw.GetTasks(report, "uuid:"+uuid)
if len(tasks) > 0 {
return tasks[0]
}
return nil
}
// SyncIntervalToTask synchronizes a Timewarrior interval's state to the corresponding Taskwarrior task.
// Action should be "start" or "stop".
// This function is idempotent and handles edge cases gracefully.
func SyncIntervalToTask(interval *timewarrior.Interval, tw taskwarrior.TaskWarrior, action string) {
if interval == nil {
return
}
// Extract UUID from interval tags
uuid := timewarrior.ExtractUUID(interval.Tags)
if uuid == "" {
slog.Debug("Interval has no UUID tag, skipping task sync",
"intervalID", interval.ID)
return
}
// Find corresponding task
task := FindTaskByUUID(tw, uuid)
if task == nil {
slog.Warn("Task not found for UUID, skipping sync",
"uuid", uuid)
return
}
// Perform sync action
switch action {
case "start":
// Start task if it's pending (idempotent - taskwarrior handles already-started tasks)
if task.Status == "pending" {
slog.Info("Starting Taskwarrior task from interval",
"uuid", uuid,
"description", task.Description,
"alreadyStarted", task.Start != "")
tw.StartTask(task)
} else {
slog.Debug("Task not pending, skipping start",
"uuid", uuid,
"status", task.Status)
}
case "stop":
// Only stop if task is pending and currently started
if task.Status == "pending" && task.Start != "" {
slog.Info("Stopping Taskwarrior task from interval",
"uuid", uuid,
"description", task.Description)
tw.StopTask(task)
} else {
slog.Debug("Task not started or not pending, skipping stop",
"uuid", uuid,
"status", task.Status,
"hasStart", task.Start != "")
}
default:
slog.Error("Unknown sync action", "action", action)
}
}

View File

@ -89,6 +89,11 @@ func (a *Autocomplete) SetSuggestions(suggestions []string) {
a.updateFilteredSuggestions()
}
// HasSuggestions returns true if the autocomplete is currently showing suggestions
func (a *Autocomplete) HasSuggestions() bool {
return a.showSuggestions && len(a.filteredSuggestions) > 0
}
// Init initializes the autocomplete
func (a *Autocomplete) Init() tea.Cmd {
return textinput.Blink

View File

@ -637,6 +637,11 @@ func (m *MultiSelect) GetValue() any {
return *m.value
}
// IsFiltering returns true if the multi-select is currently filtering.
func (m *MultiSelect) IsFiltering() bool {
return m.filtering
}
func min(a, b int) int {
if a < b {
return a

View File

@ -36,7 +36,9 @@ type Picker struct {
onCreate func(string) tea.Cmd
title string
filterByDefault bool
defaultValue string
baseItems []list.Item
focused bool
}
type PickerOption func(*Picker)
@ -53,6 +55,30 @@ func WithOnCreate(onCreate func(string) tea.Cmd) PickerOption {
}
}
func WithDefaultValue(value string) PickerOption {
return func(p *Picker) {
p.defaultValue = value
}
}
func (p *Picker) Focus() tea.Cmd {
p.focused = true
return nil
}
func (p *Picker) Blur() tea.Cmd {
p.focused = false
return nil
}
func (p *Picker) GetValue() string {
item := p.list.SelectedItem()
if item == nil {
return ""
}
return item.FilterValue()
}
func New(
c *common.Common,
title string,
@ -69,6 +95,7 @@ func New(
l.SetShowHelp(false)
l.SetShowStatusBar(false)
l.SetFilteringEnabled(true)
l.Filter = list.UnsortedFilter // Preserve item order, don't rank by match quality
// Custom key for filtering (insert mode)
l.KeyMap.Filter = key.NewBinding(
@ -82,6 +109,7 @@ func New(
itemProvider: itemProvider,
onSelect: onSelect,
title: title,
focused: true,
}
if c.TW.GetConfig().Get("uda.tasksquire.picker.filter_by_default") == "yes" {
@ -92,8 +120,24 @@ func New(
opt(p)
}
// If a default value is provided, don't start in filter mode
if p.defaultValue != "" {
p.filterByDefault = false
}
if p.filterByDefault {
// Manually trigger filter mode on the list so it doesn't require a global key press
p.list, _ = p.list.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}})
}
// Refresh items after entering filter mode to ensure they're visible
p.Refresh()
// If a default value is provided, select the corresponding item
if p.defaultValue != "" {
p.SelectItemByFilterValue(p.defaultValue)
}
return p
}
@ -103,18 +147,22 @@ func (p *Picker) Refresh() tea.Cmd {
}
func (p *Picker) updateListItems() tea.Cmd {
items := p.baseItems
filterVal := p.list.FilterValue()
return p.updateListItemsWithFilter(p.list.FilterValue())
}
func (p *Picker) updateListItemsWithFilter(filterVal string) tea.Cmd {
items := make([]list.Item, 0, len(p.baseItems)+1)
// First add all base items
items = append(items, p.baseItems...)
if p.onCreate != nil && filterVal != "" {
// Add the creation item at the end (bottom of the list)
newItem := creationItem{
text: "(new) " + filterVal,
filter: filterVal,
}
newItems := make([]list.Item, len(items)+1)
copy(newItems, items)
newItems[len(items)] = newItem
items = newItems
items = append(items, newItem)
}
return p.list.SetItems(items)
@ -134,27 +182,42 @@ func (p *Picker) SetSize(width, height int) {
}
func (p *Picker) Init() tea.Cmd {
if p.filterByDefault {
return func() tea.Msg {
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}}
}
}
return nil
// Trigger list item update to ensure items are properly displayed,
// especially when in filter mode with an empty filter
return p.updateListItems()
}
func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if !p.focused {
return p, nil
}
var cmd tea.Cmd
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
// If filtering, let the list handle keys (including Enter to stop filtering)
// If filtering, update items with predicted filter before list processes the key
if p.list.FilterState() == list.Filtering {
if key.Matches(msg, p.common.Keymap.Ok) {
items := p.list.VisibleItems()
if len(items) == 1 {
return p, p.handleSelect(items[0])
currentFilter := p.list.FilterValue()
predictedFilter := currentFilter
// Predict what the filter will be after this key
switch msg.Type {
case tea.KeyRunes:
predictedFilter = currentFilter + string(msg.Runes)
case tea.KeyBackspace:
if len(currentFilter) > 0 {
predictedFilter = currentFilter[:len(currentFilter)-1]
}
}
// Update items with predicted filter before list processes the message
if predictedFilter != currentFilter {
preCmd := p.updateListItemsWithFilter(predictedFilter)
cmds = append(cmds, preCmd)
}
break // Pass to list.Update
}
@ -168,15 +231,10 @@ func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
prevFilter := p.list.FilterValue()
p.list, cmd = p.list.Update(msg)
cmds = append(cmds, cmd)
if p.list.FilterValue() != prevFilter {
updateCmd := p.updateListItems()
return p, tea.Batch(cmd, updateCmd)
}
return p, cmd
return p, tea.Batch(cmds...)
}
func (p *Picker) handleSelect(item list.Item) tea.Cmd {
@ -189,7 +247,12 @@ func (p *Picker) handleSelect(item list.Item) tea.Cmd {
}
func (p *Picker) View() string {
title := p.common.Styles.Form.Focused.Title.Render(p.title)
var title string
if p.focused {
title = p.common.Styles.Form.Focused.Title.Render(p.title)
} else {
title = p.common.Styles.Form.Blurred.Title.Render(p.title)
}
return lipgloss.JoinVertical(lipgloss.Left, title, p.list.View())
}

118
on-modify.timewarrior Normal file
View File

@ -0,0 +1,118 @@
#!/usr/bin/env python3
###############################################################################
#
# Copyright 2016 - 2021, 2023, Gothenburg Bit Factory
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included
# in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# https://www.opensource.org/licenses/mit-license.php
#
###############################################################################
import json
import subprocess
import sys
# Hook should extract all the following for use as Timewarrior tags:
# UUID
# Project
# Tags
# Description
# UDAs
try:
input_stream = sys.stdin.buffer
except AttributeError:
input_stream = sys.stdin
def extract_tags_from(json_obj):
# Extract attributes for use as tags.
tags = [json_obj['description']]
# Add UUID with prefix for reliable task linking
if 'uuid' in json_obj:
tags.append('uuid:' + json_obj['uuid'])
# Add project with prefix for separate column display
if 'project' in json_obj:
tags.append('project:' + json_obj['project'])
if 'tags' in json_obj:
if type(json_obj['tags']) is str:
# Usage of tasklib (e.g. in taskpirate) converts the tag list into a string
# If this is the case, convert it back into a list first
# See https://github.com/tbabej/taskpirate/issues/11
task_tags = [tag for tag in json_obj['tags'].split(',') if tag != 'next']
tags.extend(task_tags)
else:
# Filter out the 'next' tag
task_tags = [tag for tag in json_obj['tags'] if tag != 'next']
tags.extend(task_tags)
return tags
def extract_annotation_from(json_obj):
if 'annotations' not in json_obj:
return '\'\''
return json_obj['annotations'][0]['description']
def main(old, new):
start_or_stop = ''
# Started task.
if 'start' in new and 'start' not in old:
start_or_stop = 'start'
# Stopped task.
elif ('start' not in new or 'end' in new) and 'start' in old:
start_or_stop = 'stop'
if start_or_stop:
tags = extract_tags_from(new)
subprocess.call(['timew', start_or_stop] + tags + [':yes'])
# Modifications to task other than start/stop
elif 'start' in new and 'start' in old:
old_tags = extract_tags_from(old)
new_tags = extract_tags_from(new)
if old_tags != new_tags:
subprocess.call(['timew', 'untag', '@1'] + old_tags + [':yes'])
subprocess.call(['timew', 'tag', '@1'] + new_tags + [':yes'])
old_annotation = extract_annotation_from(old)
new_annotation = extract_annotation_from(new)
if old_annotation != new_annotation:
subprocess.call(['timew', 'annotate', '@1', new_annotation])
if __name__ == "__main__":
old = json.loads(input_stream.readline().decode("utf-8", errors="replace"))
new = json.loads(input_stream.readline().decode("utf-8", errors="replace"))
print(json.dumps(new))
main(old, new)

View File

@ -5,6 +5,7 @@ import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type MainPage struct {
@ -13,6 +14,9 @@ type MainPage struct {
taskPage common.Component
timePage common.Component
currentTab int
width int
height int
}
func NewMainPage(common *common.Common) *MainPage {
@ -24,6 +28,7 @@ func NewMainPage(common *common.Common) *MainPage {
m.timePage = NewTimePage(common)
m.activePage = m.taskPage
m.currentTab = 0
return m
}
@ -37,17 +42,39 @@ func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = 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:
// 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 {
m.activePage = m.timePage
m.currentTab = 1
} else {
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.
// But we might want to refresh data.
return m, m.activePage.Init()
@ -60,6 +87,22 @@ func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd
}
func (m *MainPage) View() string {
return m.activePage.View()
func (m *MainPage) renderTabBar() string {
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())
}

View File

@ -82,6 +82,12 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case UpdateProjectMsg:
p.activeProject = string(msg)
cmds = append(cmds, p.getTasks())
case TaskPickedMsg:
if msg.Task != nil && msg.Task.Status == "pending" {
p.common.TW.StopActiveTasks()
p.common.TW.StartTask(msg.Task)
}
cmds = append(cmds, p.getTasks())
case UpdatedTasksMsg:
cmds = append(cmds, p.getTasks())
case tea.KeyMsg:
@ -119,6 +125,11 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmd := p.subpage.Init()
p.common.PushPage(p)
return p.subpage, cmd
case key.Matches(msg, p.common.Keymap.PickProjectTask):
p.subpage = NewProjectTaskPickerPage(p.common)
cmd := p.subpage.Init()
p.common.PushPage(p)
return p.subpage, cmd
case key.Matches(msg, p.common.Keymap.Tag):
if p.selectedTask != nil {
tag := p.common.TW.GetConfig().Get("uda.tasksquire.tag.default")
@ -137,6 +148,7 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, p.common.Keymap.StartStop):
if p.selectedTask != nil && p.selectedTask.Status == "pending" {
if p.selectedTask.Start == "" {
p.common.TW.StopActiveTasks()
p.common.TW.StartTask(p.selectedTask)
} else {
p.common.TW.StopTask(p.selectedTask)

View File

@ -8,6 +8,7 @@ import (
"time"
"tasksquire/components/input"
"tasksquire/components/picker"
"tasksquire/components/timestampeditor"
"tasksquire/taskwarrior"
@ -41,6 +42,8 @@ type TaskEditorPage struct {
area int
areaPicker *areaPicker
areas []area
infoViewport viewport.Model
}
func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPage {
@ -56,7 +59,7 @@ func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPag
tagOptions := p.common.TW.GetTags()
p.areas = []area{
NewTaskEdit(p.common, &p.task),
NewTaskEdit(p.common, &p.task, p.task.Uuid == ""),
NewTagEdit(p.common, &p.task.Tags, tagOptions),
NewTimeEdit(p.common, &p.task.Due, &p.task.Scheduled, &p.task.Wait, &p.task.Until),
NewDetailsEdit(p.common, &p.task),
@ -68,6 +71,11 @@ func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPag
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
if p.task.Uuid == "" {
p.mode = modeInsert
@ -94,10 +102,20 @@ func (p *TaskEditorPage) SetSize(width, height int) {
} else {
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 {
return nil
var cmds []tea.Cmd
for _, a := range p.areas {
cmds = append(cmds, a.Init())
}
return tea.Batch(cmds...)
}
func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@ -110,12 +128,20 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
p.mode = mode(msg)
case prevColumnMsg:
p.columnCursor--
maxCols := 2
if p.task.Uuid != "" {
maxCols = 3
}
if p.columnCursor < 0 {
p.columnCursor = len(p.areas) - 1
p.columnCursor = maxCols - 1
}
case nextColumnMsg:
p.columnCursor++
if p.columnCursor > len(p.areas)-1 {
maxCols := 2
if p.task.Uuid != "" {
maxCols = 3
}
if p.columnCursor >= maxCols {
p.columnCursor = 0
}
case prevAreaMsg:
@ -166,20 +192,26 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
picker, cmd := p.areaPicker.Update(msg)
p.areaPicker = picker.(*areaPicker)
return p, cmd
} else {
} else if p.columnCursor == 1 {
model, cmd := p.areas[p.area].Update(prevFieldMsg{})
p.areas[p.area] = model.(area)
return p, cmd
} else if p.columnCursor == 2 {
p.infoViewport.LineUp(1)
return p, nil
}
case key.Matches(msg, p.common.Keymap.Down):
if p.columnCursor == 0 {
picker, cmd := p.areaPicker.Update(msg)
p.areaPicker = picker.(*areaPicker)
return p, cmd
} else {
} else if p.columnCursor == 1 {
model, cmd := p.areas[p.area].Update(nextFieldMsg{})
p.areas[p.area] = model.(area)
return p, cmd
} else if p.columnCursor == 2 {
p.infoViewport.LineDown(1)
return p, nil
}
}
}
@ -212,25 +244,31 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
picker, cmd := p.areaPicker.Update(msg)
p.areaPicker = picker.(*areaPicker)
return p, cmd
} else {
} else if p.columnCursor == 1 {
model, cmd := p.areas[p.area].Update(prevFieldMsg{})
p.areas[p.area] = model.(area)
return p, cmd
}
return p, nil
case key.Matches(msg, p.common.Keymap.Next):
if p.columnCursor == 0 {
picker, cmd := p.areaPicker.Update(msg)
p.areaPicker = picker.(*areaPicker)
return p, cmd
} else {
} else if p.columnCursor == 1 {
model, cmd := p.areas[p.area].Update(nextFieldMsg{})
p.areas[p.area] = model.(area)
return p, cmd
}
return p, nil
case key.Matches(msg, p.common.Keymap.Ok):
isFiltering := p.areas[p.area].IsFiltering()
model, cmd := p.areas[p.area].Update(msg)
if p.area != 3 {
p.areas[p.area] = model.(area)
if isFiltering {
return p, cmd
}
return p, tea.Batch(cmd, nextField())
}
return p, cmd
@ -241,6 +279,10 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
picker, cmd := p.areaPicker.Update(msg)
p.areaPicker = picker.(*areaPicker)
return p, cmd
} else if p.columnCursor == 2 {
var cmd tea.Cmd
p.infoViewport, cmd = p.infoViewport.Update(msg)
return p, cmd
} else {
model, cmd := p.areas[p.area].Update(msg)
p.areas[p.area] = model.(area)
@ -253,29 +295,31 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (p *TaskEditorPage) View() string {
var focusedStyle, blurredStyle lipgloss.Style
if p.mode == modeInsert {
focusedStyle = p.common.Styles.ColumnInsert.Width(p.colWidth).Height(p.colHeight)
focusedStyle = p.common.Styles.ColumnInsert
} 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)
// 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())
blurredStyle = p.common.Styles.ColumnBlurred
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 != "" {
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(
lipgloss.Top,
area,
p.common.Styles.ColumnFocused.Render(p.common.TW.GetInformation(&p.task)),
infoView,
)
}
tabs := ""
@ -305,8 +349,11 @@ type area interface {
tea.Model
SetCursor(c int)
GetName() string
IsFiltering() bool
}
type focusMsg struct{}
type areaPicker struct {
common *common.Common
list list.Model
@ -378,26 +425,60 @@ func (a *areaPicker) View() string {
return a.list.View()
}
type EditableField interface {
tea.Model
Focus() tea.Cmd
Blur() tea.Cmd
}
type taskEdit struct {
common *common.Common
fields []huh.Field
fields []EditableField
cursor int
projectPicker *picker.Picker
// newProjectName *string
newAnnotation *string
udaValues map[string]*string
}
func NewTaskEdit(com *common.Common, task *taskwarrior.Task) *taskEdit {
func NewTaskEdit(com *common.Common, task *taskwarrior.Task, isNew bool) *taskEdit {
// newProject := ""
projectOptions := append([]string{"(none)"}, com.TW.GetProjects()...)
if task.Project == "" {
task.Project = "(none)"
}
itemProvider := func() []list.Item {
projects := com.TW.GetProjects()
items := []list.Item{picker.NewItem("(none)")}
for _, proj := range projects {
items = append(items, picker.NewItem(proj))
}
return items
}
onSelect := func(item list.Item) tea.Cmd {
return nil
}
onCreate := func(newProject string) tea.Cmd {
// The new project name will be used as the project value
// when the task is saved
return nil
}
opts := []picker.PickerOption{picker.WithOnCreate(onCreate)}
if isNew {
opts = append(opts, picker.WithFilterByDefault(true))
} else {
opts = append(opts, picker.WithDefaultValue(task.Project))
}
projPicker := picker.New(com, "Project", itemProvider, onSelect, opts...)
projPicker.SetSize(70, 8)
projPicker.Blur()
defaultKeymap := huh.NewDefaultKeyMap()
fields := []huh.Field{
fields := []EditableField{
huh.NewInput().
Title("Task").
Value(&task.Description).
@ -411,12 +492,7 @@ func NewTaskEdit(com *common.Common, task *taskwarrior.Task) *taskEdit {
Prompt(": ").
WithTheme(com.Styles.Form),
input.NewSelect(com).
Options(true, input.NewOptions(projectOptions...)...).
Title("Project").
Value(&task.Project).
WithKeyMap(defaultKeymap).
WithTheme(com.Styles.Form),
projPicker,
// huh.NewInput().
// Title("New Project").
@ -511,6 +587,7 @@ func NewTaskEdit(com *common.Common, task *taskwarrior.Task) *taskEdit {
t := taskEdit{
common: com,
fields: fields,
projectPicker: projPicker,
udaValues: udaValues,
@ -527,6 +604,13 @@ func (t *taskEdit) GetName() string {
return "Task"
}
func (t *taskEdit) IsFiltering() bool {
if f, ok := t.fields[t.cursor].(interface{ IsFiltering() bool }); ok {
return f.IsFiltering()
}
return false
}
func (t *taskEdit) SetCursor(c int) {
t.fields[t.cursor].Blur()
if c < 0 {
@ -538,11 +622,25 @@ func (t *taskEdit) SetCursor(c int) {
}
func (t *taskEdit) Init() tea.Cmd {
return nil
var cmds []tea.Cmd
// Ensure focus on the active field (especially for the first one)
if len(t.fields) > 0 {
cmds = append(cmds, func() tea.Msg {
return focusMsg{}
})
}
for _, f := range t.fields {
cmds = append(cmds, f.Init())
}
return tea.Batch(cmds...)
}
func (t *taskEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case focusMsg:
if len(t.fields) > 0 {
return t, t.fields[t.cursor].Focus()
}
case nextFieldMsg:
if t.cursor == len(t.fields)-1 {
t.fields[t.cursor].Blur()
@ -561,7 +659,7 @@ func (t *taskEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
t.fields[t.cursor].Focus()
default:
field, cmd := t.fields[t.cursor].Update(msg)
t.fields[t.cursor] = field.(huh.Field)
t.fields[t.cursor] = field.(EditableField)
return t, cmd
}
@ -624,6 +722,13 @@ func (t *tagEdit) GetName() string {
return "Tags"
}
func (t *tagEdit) IsFiltering() bool {
if f, ok := t.fields[t.cursor].(interface{ IsFiltering() bool }); ok {
return f.IsFiltering()
}
return false
}
func (t *tagEdit) SetCursor(c int) {
t.fields[t.cursor].Blur()
if c < 0 {
@ -736,6 +841,10 @@ func (t *timeEdit) GetName() string {
return "Dates"
}
func (t *timeEdit) IsFiltering() bool {
return false
}
func (t *timeEdit) SetCursor(c int) {
if len(t.fields) == 0 {
return
@ -868,6 +977,10 @@ func (d *detailsEdit) GetName() string {
return "Details"
}
func (d *detailsEdit) IsFiltering() bool {
return false
}
func (d *detailsEdit) SetCursor(c int) {
}
@ -1035,6 +1148,8 @@ func (d *detailsEdit) View() string {
// }
func (p *TaskEditorPage) updateTasksCmd() tea.Msg {
p.task.Project = p.areas[0].(*taskEdit).projectPicker.GetValue()
if p.task.Project == "(none)" {
p.task.Project = ""
}

View File

@ -40,9 +40,6 @@ func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *Time
// Extract project from tags if it exists
projects := com.TW.GetProjects()
selectedProject, remainingTags := extractProjectFromTags(interval.Tags, projects)
if selectedProject == "" && len(projects) > 0 {
selectedProject = projects[0] // Default to first project (required)
}
// Create project picker with onCreate support for new projects
projectItemProvider := func() []list.Item {
@ -66,18 +63,23 @@ func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *Time
}
}
opts := []picker.PickerOption{
picker.WithOnCreate(projectOnCreate),
}
if selectedProject != "" {
opts = append(opts, picker.WithDefaultValue(selectedProject))
} else {
opts = append(opts, picker.WithFilterByDefault(true))
}
projectPicker := picker.New(
com,
"Project",
projectItemProvider,
projectOnSelect,
picker.WithOnCreate(projectOnCreate),
picker.WithFilterByDefault(true),
opts...,
)
projectPicker.SetSize(50, 10) // Compact size for inline use
if selectedProject != "" {
projectPicker.SelectItemByFilterValue(selectedProject)
}
// Create start timestamp editor
startEditor := timestampeditor.New(com).
@ -129,8 +131,14 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case timeEditorProjectSelectedMsg:
// Update selected project
p.selectedProject = msg.project
// Blur current field (project picker)
p.blurCurrentField()
// Advance to tags field
p.currentField = 1
// Refresh tag autocomplete with filtered combinations
cmds = append(cmds, p.updateTagSuggestions())
// Focus tags input
cmds = append(cmds, p.focusCurrentField())
return p, tea.Batch(cmds...)
case tea.KeyMsg:
@ -144,9 +152,26 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return model, BackCmd
case key.Matches(msg, p.common.Keymap.Ok):
// Don't save if the project picker is focused - let it handle Enter
if p.currentField != 0 {
// Save and exit
// Handle Enter based on current field
if p.currentField == 0 {
// Project picker - let it handle Enter (will trigger projectSelectedMsg)
break
}
if p.currentField == 1 {
// Tags field
if p.tagsInput.HasSuggestions() {
// Let autocomplete handle suggestion selection
break
}
// Tags confirmed without suggestions - advance to start timestamp
p.blurCurrentField()
p.currentField = 2
cmds = append(cmds, p.focusCurrentField())
return p, tea.Batch(cmds...)
}
// For all other fields (2-4: start, end, adjust), save and exit
p.saveInterval()
model, err := p.common.PopPage()
if err != nil {
@ -154,8 +179,6 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return nil, tea.Quit
}
return model, tea.Batch(tea.Batch(cmds...), refreshIntervals)
}
// If picker is focused, let it handle the key below
case key.Matches(msg, p.common.Keymap.Next):
// Move to next field
@ -347,15 +370,16 @@ func (p *TimeEditorPage) saveInterval() {
// Add project to tags if not already present
if p.selectedProject != "" {
projectTag := "project:" + p.selectedProject
projectExists := false
for _, tag := range tags {
if tag == p.selectedProject {
if tag == projectTag {
projectExists = true
break
}
}
if !projectExists {
tags = append([]string{p.selectedProject}, tags...) // Prepend project
tags = append([]string{projectTag}, tags...) // Prepend project tag
}
}
@ -415,12 +439,16 @@ func extractProjectFromTags(tags []string, projects []string) (string, []string)
var remaining []string
for _, tag := range tags {
if foundProject == "" && projectSet[tag] {
foundProject = tag // First matching project
} else {
remaining = append(remaining, tag)
// Check if this tag is a project tag (format: "project:projectname")
if strings.HasPrefix(tag, "project:") {
projectName := strings.TrimPrefix(tag, "project:")
if foundProject == "" && projectSet[projectName] {
foundProject = projectName // First matching project
continue // Don't add to remaining tags
}
}
remaining = append(remaining, tag)
}
return foundProject, remaining
}
@ -432,6 +460,8 @@ func filterTagCombinationsByProject(combinations []string, project string) []str
return combinations
}
projectTag := "project:" + project
var filtered []string
for _, combo := range combinations {
// Parse the combination into individual tags
@ -439,11 +469,11 @@ func filterTagCombinationsByProject(combinations []string, project string) []str
// Check if project exists in this combination
for _, tag := range tags {
if tag == project {
if tag == projectTag {
// Found the project - now remove it from display
var displayTags []string
for _, t := range tags {
if t != project {
if t != projectTag {
displayTags = append(displayTags, t)
}
}

View File

@ -21,6 +21,7 @@ type TimePage struct {
data timewarrior.Intervals
shouldSelectActive bool
pendingSyncAction string // "start", "stop", or "" (empty means no pending action)
selectedTimespan string
subpage common.Component
@ -163,8 +164,27 @@ func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case intervalsMsg:
p.data = timewarrior.Intervals(msg)
p.populateTable(p.data)
// If we have a pending sync action (from continuing an interval),
// execute it now that intervals are refreshed
if p.pendingSyncAction != "" {
action := p.pendingSyncAction
p.pendingSyncAction = ""
cmds = append(cmds, p.syncActiveIntervalAfterRefresh(action))
}
case TaskPickedMsg:
if msg.Task != nil && msg.Task.Status == "pending" {
p.common.TW.StopActiveTasks()
p.common.TW.StartTask(msg.Task)
cmds = append(cmds, p.getIntervals())
cmds = append(cmds, doTick())
}
case RefreshIntervalsMsg:
cmds = append(cmds, p.getIntervals())
cmds = append(cmds, doTick())
case BackMsg:
// Restart tick loop when returning from subpage
cmds = append(cmds, doTick())
case tickMsg:
cmds = append(cmds, p.getIntervals())
cmds = append(cmds, doTick())
@ -178,17 +198,42 @@ func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmd := p.subpage.Init()
p.common.PushPage(p)
return p.subpage, cmd
case key.Matches(msg, p.common.Keymap.PickProjectTask):
p.subpage = NewProjectTaskPickerPage(p.common)
cmd := p.subpage.Init()
p.common.PushPage(p)
return p.subpage, cmd
case key.Matches(msg, p.common.Keymap.StartStop):
row := p.intervals.SelectedRow()
if row != nil {
interval := (*timewarrior.Interval)(row)
// Validate interval before proceeding
if interval.IsGap {
slog.Debug("Cannot start/stop gap interval")
return p, nil
}
if interval.IsActive() {
// Stop tracking
p.common.TimeW.StopTracking()
// Sync: stop corresponding Taskwarrior task immediately (interval has UUID)
common.SyncIntervalToTask(interval, p.common.TW, "stop")
} else {
// Continue tracking - creates a NEW interval
slog.Info("Continuing interval for task sync",
"intervalID", interval.ID,
"hasUUID", timewarrior.ExtractUUID(interval.Tags) != "",
"uuid", timewarrior.ExtractUUID(interval.Tags))
p.common.TimeW.ContinueInterval(interval.ID)
p.shouldSelectActive = true
// Set pending sync action instead of syncing immediately
// This ensures we sync AFTER intervals are refreshed
p.pendingSyncAction = "start"
}
return p, tea.Batch(p.getIntervals(), doTick())
cmds = append(cmds, p.getIntervals())
cmds = append(cmds, doTick())
return p, tea.Batch(cmds...)
}
case key.Matches(msg, p.common.Keymap.Delete):
row := p.intervals.SelectedRow()
@ -351,6 +396,7 @@ func (p *TimePage) populateTable(intervals timewarrior.Intervals) {
{Title: "Start", Name: startField, Width: startEndWidth},
{Title: "End", Name: endField, Width: startEndWidth},
{Title: "Duration", Name: "duration", Width: 10},
{Title: "Project", Name: "project", Width: 0}, // flexible width
{Title: "Tags", Name: "tags", Width: 0}, // flexible width
}
@ -419,3 +465,34 @@ func (p *TimePage) getIntervals() tea.Cmd {
return intervalsMsg(intervals)
}
}
// syncActiveInterval creates a command that syncs the currently active interval to Taskwarrior
func (p *TimePage) syncActiveInterval(action string) tea.Cmd {
return func() tea.Msg {
// Get the currently active interval
activeInterval := p.common.TimeW.GetActive()
if activeInterval != nil {
common.SyncIntervalToTask(activeInterval, p.common.TW, action)
}
return nil
}
}
// syncActiveIntervalAfterRefresh is called AFTER intervals have been refreshed
// to ensure we're working with current data
func (p *TimePage) syncActiveIntervalAfterRefresh(action string) tea.Cmd {
return func() tea.Msg {
// At this point, intervals have been refreshed, so GetActive() will work
activeInterval := p.common.TimeW.GetActive()
if activeInterval != nil {
slog.Info("Syncing active interval to task after refresh",
"action", action,
"intervalID", activeInterval.ID,
"hasUUID", timewarrior.ExtractUUID(activeInterval.Tags) != "")
common.SyncIntervalToTask(activeInterval, p.common.TW, action)
} else {
slog.Warn("No active interval found after refresh, cannot sync to task")
}
return nil
}
}

View File

@ -47,7 +47,7 @@ type Task struct {
Uuid string `json:"uuid,omitempty"`
Description string `json:"description,omitempty"`
Project string `json:"project"`
// Priority string `json:"priority"`
Priority string `json:"priority,omitempty"`
Status string `json:"status,omitempty"`
Tags []string `json:"tags"`
VirtualTags []string `json:"-"`
@ -149,8 +149,8 @@ func (t *Task) GetString(fieldWFormat string) string {
}
return t.Project
// case "priority":
// return t.Priority
case "priority":
return t.Priority
case "status":
return t.Status
@ -233,7 +233,33 @@ func (t *Task) GetString(fieldWFormat string) string {
return ""
}
func (t *Task) GetDate(dateString string) time.Time {
func (t *Task) GetDate(field string) time.Time {
var dateString string
switch field {
case "due":
dateString = t.Due
case "wait":
dateString = t.Wait
case "scheduled":
dateString = t.Scheduled
case "until":
dateString = t.Until
case "start":
dateString = t.Start
case "end":
dateString = t.End
case "entry":
dateString = t.Entry
case "modified":
dateString = t.Modified
default:
return time.Time{}
}
if dateString == "" {
return time.Time{}
}
dt, err := time.Parse(dtformat, dateString)
if err != nil {
slog.Error("Failed to parse time:", err)

View File

@ -0,0 +1,81 @@
package taskwarrior
import (
"testing"
"time"
)
func TestTask_GetString(t *testing.T) {
tests := []struct {
name string
task Task
fieldWFormat string
want string
}{
{
name: "Priority",
task: Task{
Priority: "H",
},
fieldWFormat: "priority",
want: "H",
},
{
name: "Description",
task: Task{
Description: "Buy milk",
},
fieldWFormat: "description.desc",
want: "Buy milk",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.task.GetString(tt.fieldWFormat); got != tt.want {
t.Errorf("Task.GetString() = %v, want %v", got, tt.want)
}
})
}
}
func TestTask_GetDate(t *testing.T) {
validDate := "20230101T120000Z"
parsedValid, _ := time.Parse("20060102T150405Z", validDate)
tests := []struct {
name string
task Task
field string
want time.Time
}{
{
name: "Due date valid",
task: Task{
Due: validDate,
},
field: "due",
want: parsedValid,
},
{
name: "Due date empty",
task: Task{},
field: "due",
want: time.Time{},
},
{
name: "Unknown field",
task: Task{Due: validDate},
field: "unknown",
want: time.Time{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.task.GetDate(tt.field); !got.Equal(tt.want) {
t.Errorf("Task.GetDate() = %v, want %v", got, tt.want)
}
})
}
}

View File

@ -98,6 +98,7 @@ type TaskWarrior interface {
DeleteTask(task *Task)
StartTask(task *Task)
StopTask(task *Task)
StopActiveTasks()
GetInformation(task *Task) string
AddTaskAnnotation(uuid string, annotation string)
@ -146,7 +147,7 @@ func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks {
args := ts.defaultArgs
if report.Context {
if report != nil && report.Context {
for _, context := range ts.contexts {
if context.Active && context.Name != "none" {
args = append(args, context.ReadFilter)
@ -159,7 +160,12 @@ func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks {
args = append(args, filter...)
}
cmd := exec.Command(twBinary, append(args, []string{"export", report.Name}...)...)
exportArgs := []string{"export"}
if report != nil && report.Name != "" {
exportArgs = append(exportArgs, report.Name)
}
cmd := exec.Command(twBinary, append(args, exportArgs...)...)
output, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed getting report:", err)
@ -490,6 +496,33 @@ func (ts *TaskSquire) StopTask(task *Task) {
}
}
func (ts *TaskSquire) StopActiveTasks() {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"+ACTIVE", "export"}...)...)
output, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed getting active tasks:", "error", err, "output", string(output))
return
}
tasks := make(Tasks, 0)
err = json.Unmarshal(output, &tasks)
if err != nil {
slog.Error("Failed unmarshalling active tasks:", err)
return
}
for _, task := range tasks {
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"stop", task.Uuid}...)...)
err := cmd.Run()
if err != nil {
slog.Error("Failed stopping task:", err)
}
}
}
func (ts *TaskSquire) GetInformation(task *Task) string {
ts.mutex.Lock()
defer ts.mutex.Unlock()

Binary file not shown.

View File

@ -83,7 +83,16 @@ func (i *Interval) GetString(field string) string {
if len(i.Tags) == 0 {
return ""
}
return strings.Join(i.Tags, " ")
// Extract and filter special tags (uuid:, project:)
_, _, displayTags := ExtractSpecialTags(i.Tags)
return strings.Join(displayTags, " ")
case "project":
project := ExtractProject(i.Tags)
if project == "" {
return "(none)"
}
return project
case "duration":
return i.GetDuration()

51
timewarrior/tags.go Normal file
View File

@ -0,0 +1,51 @@
package timewarrior
import (
"strings"
)
// Special tag prefixes for metadata
const (
UUIDPrefix = "uuid:"
ProjectPrefix = "project:"
)
// ExtractSpecialTags parses tags and separates special prefixed tags from display tags.
// Returns: uuid, project, and remaining display tags (description + user tags)
func ExtractSpecialTags(tags []string) (uuid string, project string, displayTags []string) {
displayTags = make([]string, 0, len(tags))
for _, tag := range tags {
switch {
case strings.HasPrefix(tag, UUIDPrefix):
uuid = strings.TrimPrefix(tag, UUIDPrefix)
case strings.HasPrefix(tag, ProjectPrefix):
project = strings.TrimPrefix(tag, ProjectPrefix)
default:
// Regular tag (description or user tag)
displayTags = append(displayTags, tag)
}
}
return uuid, project, displayTags
}
// ExtractUUID extracts just the UUID from tags (for sync operations)
func ExtractUUID(tags []string) string {
for _, tag := range tags {
if strings.HasPrefix(tag, UUIDPrefix) {
return strings.TrimPrefix(tag, UUIDPrefix)
}
}
return ""
}
// ExtractProject extracts just the project name from tags
func ExtractProject(tags []string) string {
for _, tag := range tags {
if strings.HasPrefix(tag, ProjectPrefix) {
return strings.TrimPrefix(tag, ProjectPrefix)
}
}
return ""
}

View File

@ -142,10 +142,9 @@ func formatTagsForCombination(tags []string) string {
return strings.Join(formatted, " ")
}
func (ts *TimeSquire) GetIntervals(filter ...string) Intervals {
ts.mutex.Lock()
defer ts.mutex.Unlock()
// getIntervalsUnlocked fetches intervals without acquiring mutex (for internal use only).
// Caller must hold ts.mutex.
func (ts *TimeSquire) getIntervalsUnlocked(filter ...string) Intervals {
args := append(ts.defaultArgs, "export")
if filter != nil {
args = append(args, filter...)
@ -176,6 +175,14 @@ func (ts *TimeSquire) GetIntervals(filter ...string) Intervals {
return intervals
}
// GetIntervals fetches intervals with the given filter, acquiring mutex lock.
func (ts *TimeSquire) GetIntervals(filter ...string) Intervals {
ts.mutex.Lock()
defer ts.mutex.Unlock()
return ts.getIntervalsUnlocked(filter...)
}
func (ts *TimeSquire) StartTracking(tags []string) error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
@ -347,8 +354,8 @@ func (ts *TimeSquire) GetActive() *Interval {
return nil
}
// Get the active interval
intervals := ts.GetIntervals()
// Get the active interval using unlocked version (we already hold the mutex)
intervals := ts.getIntervalsUnlocked()
for _, interval := range intervals {
if interval.End == "" {
return interval