Add syncing

This commit is contained in:
Martin Pander
2026-02-03 16:04:47 +01:00
parent 2e33893e29
commit 44ddbc0f47
12 changed files with 480 additions and 72 deletions

View File

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

View File

@ -459,15 +459,21 @@ func NewTaskEdit(com *common.Common, task *taskwarrior.Task, isNew bool) *taskEd
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{}
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.SelectItemByFilterValue(task.Project)
projPicker.Blur()
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
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,18 +152,33 @@ 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
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)
// Handle Enter based on current field
if p.currentField == 0 {
// Project picker - let it handle Enter (will trigger projectSelectedMsg)
break
}
// 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):
// 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,11 +439,15 @@ 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,20 @@ 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 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())
@ -182,13 +195,34 @@ func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
row := p.intervals.SelectedRow()
if row != nil {
interval := (*timewarrior.Interval)(row)
if interval.IsActive() {
p.common.TimeW.StopTracking()
} else {
p.common.TimeW.ContinueInterval(interval.ID)
p.shouldSelectActive = true
// Validate interval before proceeding
if interval.IsGap {
slog.Debug("Cannot start/stop gap interval")
return p, nil
}
return p, tea.Batch(p.getIntervals(), doTick())
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)
common.SyncIntervalToTask(interval, p.common.TW, "start")
p.shouldSelectActive = true
// Set pending sync action instead of syncing immediately
// This ensures we sync AFTER intervals are refreshed
p.pendingSyncAction = "start"
}
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,7 +385,8 @@ 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: "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)
@ -419,3 +454,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
}
}