3 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
17 changed files with 651 additions and 74 deletions

View File

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

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() 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 // Init initializes the autocomplete
func (a *Autocomplete) Init() tea.Cmd { func (a *Autocomplete) Init() tea.Cmd {
return textinput.Blink return textinput.Blink

View File

@ -36,6 +36,7 @@ type Picker struct {
onCreate func(string) tea.Cmd onCreate func(string) tea.Cmd
title string title string
filterByDefault bool filterByDefault bool
defaultValue string
baseItems []list.Item baseItems []list.Item
focused bool focused bool
} }
@ -54,6 +55,12 @@ 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 { func (p *Picker) Focus() tea.Cmd {
p.focused = true p.focused = true
return nil return nil
@ -88,6 +95,7 @@ func New(
l.SetShowHelp(false) l.SetShowHelp(false)
l.SetShowStatusBar(false) l.SetShowStatusBar(false)
l.SetFilteringEnabled(true) l.SetFilteringEnabled(true)
l.Filter = list.UnsortedFilter // Preserve item order, don't rank by match quality
// Custom key for filtering (insert mode) // Custom key for filtering (insert mode)
l.KeyMap.Filter = key.NewBinding( l.KeyMap.Filter = key.NewBinding(
@ -112,16 +120,24 @@ func New(
opt(p) opt(p)
} }
if p.filterByDefault { // If a default value is provided, don't start in filter mode
// Manually trigger filter mode on the list so it doesn't require a global key press if p.defaultValue != "" {
var cmd tea.Cmd p.filterByDefault = false
p.list, cmd = p.list.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}})
// We can ignore the command here as it's likely just for blinking, which will happen on Init anyway
_ = cmd
} }
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() p.Refresh()
// If a default value is provided, select the corresponding item
if p.defaultValue != "" {
p.SelectItemByFilterValue(p.defaultValue)
}
return p return p
} }
@ -131,18 +147,22 @@ func (p *Picker) Refresh() tea.Cmd {
} }
func (p *Picker) updateListItems() tea.Cmd { func (p *Picker) updateListItems() tea.Cmd {
items := p.baseItems return p.updateListItemsWithFilter(p.list.FilterValue())
filterVal := 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 != "" { if p.onCreate != nil && filterVal != "" {
// Add the creation item at the end (bottom of the list)
newItem := creationItem{ newItem := creationItem{
text: "(new) " + filterVal, text: "(new) " + filterVal,
filter: filterVal, filter: filterVal,
} }
newItems := make([]list.Item, len(items)+1) items = append(items, newItem)
copy(newItems, items)
newItems[len(items)] = newItem
items = newItems
} }
return p.list.SetItems(items) return p.list.SetItems(items)
@ -162,7 +182,9 @@ func (p *Picker) SetSize(width, height int) {
} }
func (p *Picker) Init() tea.Cmd { func (p *Picker) Init() tea.Cmd {
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) { func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@ -171,17 +193,31 @@ func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
var cmd tea.Cmd var cmd tea.Cmd
var cmds []tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg: 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 p.list.FilterState() == list.Filtering {
// if key.Matches(msg, p.common.Keymap.Ok) { currentFilter := p.list.FilterValue()
// items := p.list.VisibleItems() predictedFilter := currentFilter
// if len(items) == 1 {
// return p, p.handleSelect(items[0]) // 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 break // Pass to list.Update
} }
@ -195,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) p.list, cmd = p.list.Update(msg)
cmds = append(cmds, cmd)
if p.list.FilterValue() != prevFilter { return p, tea.Batch(cmds...)
updateCmd := p.updateListItems()
return p, tea.Batch(cmd, updateCmd)
}
return p, cmd
} }
func (p *Picker) handleSelect(item list.Item) tea.Cmd { func (p *Picker) handleSelect(item list.Item) tea.Cmd {

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

@ -12,8 +12,8 @@ type MainPage struct {
common *common.Common common *common.Common
activePage common.Component activePage common.Component
taskPage common.Component taskPage common.Component
timePage common.Component timePage common.Component
currentTab int currentTab int
width int width int
height int height int

View File

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

View File

@ -459,15 +459,21 @@ func NewTaskEdit(com *common.Common, task *taskwarrior.Task, isNew bool) *taskEd
onSelect := func(item list.Item) tea.Cmd { onSelect := func(item list.Item) tea.Cmd {
return nil 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{} opts := []picker.PickerOption{picker.WithOnCreate(onCreate)}
if isNew { if isNew {
opts = append(opts, picker.WithFilterByDefault(true)) opts = append(opts, picker.WithFilterByDefault(true))
} else {
opts = append(opts, picker.WithDefaultValue(task.Project))
} }
projPicker := picker.New(com, "Project", itemProvider, onSelect, opts...) projPicker := picker.New(com, "Project", itemProvider, onSelect, opts...)
projPicker.SetSize(70, 8) projPicker.SetSize(70, 8)
projPicker.SelectItemByFilterValue(task.Project)
projPicker.Blur() projPicker.Blur()
defaultKeymap := huh.NewDefaultKeyMap() defaultKeymap := huh.NewDefaultKeyMap()

View File

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

View File

@ -21,6 +21,7 @@ type TimePage struct {
data timewarrior.Intervals data timewarrior.Intervals
shouldSelectActive bool shouldSelectActive bool
pendingSyncAction string // "start", "stop", or "" (empty means no pending action)
selectedTimespan string selectedTimespan string
subpage common.Component subpage common.Component
@ -163,8 +164,27 @@ func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case intervalsMsg: case intervalsMsg:
p.data = timewarrior.Intervals(msg) p.data = timewarrior.Intervals(msg)
p.populateTable(p.data) 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: case RefreshIntervalsMsg:
cmds = append(cmds, p.getIntervals()) cmds = append(cmds, p.getIntervals())
cmds = append(cmds, doTick())
case BackMsg:
// Restart tick loop when returning from subpage
cmds = append(cmds, doTick())
case tickMsg: case tickMsg:
cmds = append(cmds, p.getIntervals()) cmds = append(cmds, p.getIntervals())
cmds = append(cmds, doTick()) cmds = append(cmds, doTick())
@ -178,17 +198,42 @@ func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmd := p.subpage.Init() cmd := p.subpage.Init()
p.common.PushPage(p) p.common.PushPage(p)
return p.subpage, cmd 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): case key.Matches(msg, p.common.Keymap.StartStop):
row := p.intervals.SelectedRow() row := p.intervals.SelectedRow()
if row != nil { if row != nil {
interval := (*timewarrior.Interval)(row) interval := (*timewarrior.Interval)(row)
// Validate interval before proceeding
if interval.IsGap {
slog.Debug("Cannot start/stop gap interval")
return p, nil
}
if interval.IsActive() { if interval.IsActive() {
// Stop tracking
p.common.TimeW.StopTracking() p.common.TimeW.StopTracking()
// Sync: stop corresponding Taskwarrior task immediately (interval has UUID)
common.SyncIntervalToTask(interval, p.common.TW, "stop")
} else { } 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.common.TimeW.ContinueInterval(interval.ID)
p.shouldSelectActive = true 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): case key.Matches(msg, p.common.Keymap.Delete):
row := p.intervals.SelectedRow() row := p.intervals.SelectedRow()
@ -351,7 +396,8 @@ func (p *TimePage) populateTable(intervals timewarrior.Intervals) {
{Title: "Start", Name: startField, Width: startEndWidth}, {Title: "Start", Name: startField, Width: startEndWidth},
{Title: "End", Name: endField, Width: startEndWidth}, {Title: "End", Name: endField, Width: startEndWidth},
{Title: "Duration", Name: "duration", Width: 10}, {Title: "Duration", Name: "duration", Width: 10},
{Title: "Tags", Name: "tags", Width: 0}, // flexible width {Title: "Project", Name: "project", Width: 0}, // flexible width
{Title: "Tags", Name: "tags", Width: 0}, // flexible width
} }
// Calculate table height: total height - header (1 line) - blank line (1) - safety (1) // Calculate table height: total height - header (1 line) - blank line (1) - safety (1)
@ -419,3 +465,34 @@ func (p *TimePage) getIntervals() tea.Cmd {
return intervalsMsg(intervals) 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,8 +47,8 @@ type Task struct {
Uuid string `json:"uuid,omitempty"` Uuid string `json:"uuid,omitempty"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Project string `json:"project"` Project string `json:"project"`
// Priority string `json:"priority"` Priority string `json:"priority,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
VirtualTags []string `json:"-"` VirtualTags []string `json:"-"`
Depends []string `json:"depends,omitempty"` Depends []string `json:"depends,omitempty"`
@ -149,8 +149,8 @@ func (t *Task) GetString(fieldWFormat string) string {
} }
return t.Project return t.Project
// case "priority": case "priority":
// return t.Priority return t.Priority
case "status": case "status":
return t.Status return t.Status
@ -233,7 +233,33 @@ func (t *Task) GetString(fieldWFormat string) string {
return "" 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) dt, err := time.Parse(dtformat, dateString)
if err != nil { if err != nil {
slog.Error("Failed to parse time:", err) 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) DeleteTask(task *Task)
StartTask(task *Task) StartTask(task *Task)
StopTask(task *Task) StopTask(task *Task)
StopActiveTasks()
GetInformation(task *Task) string GetInformation(task *Task) string
AddTaskAnnotation(uuid string, annotation string) AddTaskAnnotation(uuid string, annotation string)
@ -146,7 +147,7 @@ func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks {
args := ts.defaultArgs args := ts.defaultArgs
if report.Context { if report != nil && report.Context {
for _, context := range ts.contexts { for _, context := range ts.contexts {
if context.Active && context.Name != "none" { if context.Active && context.Name != "none" {
args = append(args, context.ReadFilter) args = append(args, context.ReadFilter)
@ -159,7 +160,12 @@ func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks {
args = append(args, filter...) 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() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
slog.Error("Failed getting report:", err) 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 { func (ts *TaskSquire) GetInformation(task *Task) string {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() 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 { if len(i.Tags) == 0 {
return "" 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": case "duration":
return i.GetDuration() 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, " ") return strings.Join(formatted, " ")
} }
func (ts *TimeSquire) GetIntervals(filter ...string) Intervals { // getIntervalsUnlocked fetches intervals without acquiring mutex (for internal use only).
ts.mutex.Lock() // Caller must hold ts.mutex.
defer ts.mutex.Unlock() func (ts *TimeSquire) getIntervalsUnlocked(filter ...string) Intervals {
args := append(ts.defaultArgs, "export") args := append(ts.defaultArgs, "export")
if filter != nil { if filter != nil {
args = append(args, filter...) args = append(args, filter...)
@ -176,6 +175,14 @@ func (ts *TimeSquire) GetIntervals(filter ...string) Intervals {
return 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 { func (ts *TimeSquire) StartTracking(tags []string) error {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
@ -347,8 +354,8 @@ func (ts *TimeSquire) GetActive() *Interval {
return nil return nil
} }
// Get the active interval // Get the active interval using unlocked version (we already hold the mutex)
intervals := ts.GetIntervals() intervals := ts.getIntervalsUnlocked()
for _, interval := range intervals { for _, interval := range intervals {
if interval.End == "" { if interval.End == "" {
return interval return interval