package pages import ( "fmt" "log/slog" "strings" "tasksquire/common" "tasksquire/components/autocomplete" "tasksquire/components/picker" "tasksquire/components/timestampeditor" "tasksquire/taskwarrior" "tasksquire/timewarrior" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) type TimeEditorPage struct { common *common.Common interval *timewarrior.Interval // Fields projectPicker *picker.Picker taskPicker *picker.Picker startEditor *timestampeditor.TimestampEditor endEditor *timestampeditor.TimestampEditor tagsInput *autocomplete.Autocomplete adjust bool // State selectedProject string selectedTask *taskwarrior.Task currentField int totalFields int uuid string // Preserved UUID tag track string // Preserved track tag (if present) } type timeEditorProjectSelectedMsg struct { project string } type timeEditorTaskSelectedMsg struct { task *taskwarrior.Task } // createTaskPickerForProject creates a picker showing tasks with +track tag for the given project func createTaskPickerForProject(com *common.Common, project string, defaultTask string) *picker.Picker { // Build filters for tasks with +track tag filters := []string{"+track", "status:pending"} if project != "" { filters = append(filters, "project:"+project) } taskItemProvider := func() []list.Item { tasks := com.TW.GetTasks(nil, filters...) // Add "(none)" as first option, then all tasks items := make([]list.Item, 0, len(tasks)+1) items = append(items, picker.NewItem("(none)")) for i := range tasks { items = append(items, picker.NewItem(tasks[i].Description)) } return items } taskOnSelect := func(item list.Item) tea.Cmd { return func() tea.Msg { // Handle "(none)" selection if item.FilterValue() == "(none)" { return timeEditorTaskSelectedMsg{task: nil} } // Find the task by description tasks := com.TW.GetTasks(nil, filters...) for _, task := range tasks { if task.Description == item.FilterValue() { return timeEditorTaskSelectedMsg{task: task} } } return nil } } title := "Task" if project != "" { title = fmt.Sprintf("Task (%s)", project) } opts := []picker.PickerOption{ picker.WithFilterByDefault(false), // Start in list mode, not filter mode } // Pre-select task if provided, otherwise default to "(none)" if defaultTask != "" { opts = append(opts, picker.WithDefaultValue(defaultTask)) } else { opts = append(opts, picker.WithDefaultValue("(none)")) } taskPicker := picker.New( com, title, taskItemProvider, taskOnSelect, opts..., ) taskPicker.SetSize(50, 10) return taskPicker } func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *TimeEditorPage { // Extract special tags (uuid, project, track) and display tags uuid, selectedProject, track, displayTags := extractSpecialTags(interval.Tags) // Track selected task for pre-selection var selectedTask *taskwarrior.Task var defaultTaskDescription string // If UUID exists, fetch the task and add its title to display tags if uuid != "" { tasks := com.TW.GetTasks(nil, "uuid:"+uuid) if len(tasks) > 0 { selectedTask = tasks[0] defaultTaskDescription = selectedTask.Description // Add to display tags if not already present // Note: formatTags() will handle quoting for display, so we store the raw title displayTags = ensureTagPresent(displayTags, defaultTaskDescription) } } // Create project picker with onCreate support for new projects projectItemProvider := func() []list.Item { projects := com.TW.GetProjects() items := make([]list.Item, len(projects)) for i, proj := range projects { items[i] = picker.NewItem(proj) } return items } projectOnSelect := func(item list.Item) tea.Cmd { return func() tea.Msg { return timeEditorProjectSelectedMsg{project: item.FilterValue()} } } projectOnCreate := func(name string) tea.Cmd { return func() tea.Msg { return timeEditorProjectSelectedMsg{project: name} } } 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, opts..., ) projectPicker.SetSize(50, 10) // Compact size for inline use // Create task picker (only if project is selected) var taskPicker *picker.Picker if selectedProject != "" { taskPicker = createTaskPickerForProject(com, selectedProject, defaultTaskDescription) } // Create start timestamp editor startEditor := timestampeditor.New(com). Title("Start"). ValueFromString(interval.Start) // Create end timestamp editor endEditor := timestampeditor.New(com). Title("End"). ValueFromString(interval.End) // Get tag combinations filtered by selected project tagCombinations := filterTagCombinationsByProject( com.TimeW.GetTagCombinations(), selectedProject, ) tagsInput := autocomplete.New(tagCombinations, 3, 10) tagsInput.SetPlaceholder("Space separated, use \"\" for tags with spaces") tagsInput.SetValue(formatTags(displayTags)) // Use display tags only (no uuid, project, track) tagsInput.SetWidth(50) p := &TimeEditorPage{ common: com, interval: interval, projectPicker: projectPicker, taskPicker: taskPicker, startEditor: startEditor, endEditor: endEditor, tagsInput: tagsInput, adjust: true, // Enable :adjust by default selectedProject: selectedProject, selectedTask: selectedTask, currentField: 0, totalFields: 6, // 6 fields: project, task, tags, start, end, adjust uuid: uuid, track: track, } return p } func (p *TimeEditorPage) Init() tea.Cmd { // Focus the first field (project picker) p.currentField = 0 return p.projectPicker.Init() } func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case timeEditorProjectSelectedMsg: // Project selection happens on Enter - advance to task picker // (Auto-selection of project already happened in Update() switch) p.blurCurrentField() p.currentField = 1 cmds = append(cmds, p.focusCurrentField()) return p, tea.Batch(cmds...) case timeEditorTaskSelectedMsg: // Task selection happens on Enter - advance to tags field // (Auto-selection of task already happened in Update() switch) p.blurCurrentField() p.currentField = 2 cmds = append(cmds, p.focusCurrentField()) return p, tea.Batch(cmds...) case tea.KeyMsg: 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 case key.Matches(msg, p.common.Keymap.Ok): // Handle Enter based on current field if p.currentField == 0 { // Project picker - let it handle Enter (will trigger projectSelectedMsg) break } if p.currentField == 1 { // Task picker - let it handle Enter (will trigger taskSelectedMsg) break } if p.currentField == 2 { // Tags field if p.tagsInput.HasSuggestions() { // Let autocomplete handle suggestion selection break } // Tags confirmed without suggestions - advance to start timestamp p.blurCurrentField() p.currentField = 3 cmds = append(cmds, p.focusCurrentField()) return p, tea.Batch(cmds...) } // For all other fields (3-5: 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 p.blurCurrentField() p.currentField = (p.currentField + 1) % p.totalFields cmds = append(cmds, p.focusCurrentField()) case key.Matches(msg, p.common.Keymap.Prev): // Move to previous field p.blurCurrentField() p.currentField = (p.currentField - 1 + p.totalFields) % p.totalFields cmds = append(cmds, p.focusCurrentField()) } case tea.WindowSizeMsg: p.SetSize(msg.Width, msg.Height) } // Update the currently focused field var cmd tea.Cmd switch p.currentField { case 0: // Project picker // Track the previous project selection previousProject := p.selectedProject var model tea.Model model, cmd = p.projectPicker.Update(msg) if pk, ok := model.(*picker.Picker); ok { p.projectPicker = pk } // Check if the highlighted project changed (auto-selection) currentProject := p.projectPicker.GetValue() if currentProject != previousProject && currentProject != "" { // Update the selected project and refresh task picker p.selectedProject = currentProject // Clear task selection when project changes p.selectedTask = nil p.uuid = "" // Create/update task picker for the new project p.taskPicker = createTaskPickerForProject(p.common, currentProject, "") // Refresh tag autocomplete with filtered combinations cmds = append(cmds, p.updateTagSuggestions()) } case 1: // Task picker if p.taskPicker != nil { // Track the previous task selection var previousTaskDesc string if p.selectedTask != nil { previousTaskDesc = p.selectedTask.Description } var model tea.Model model, cmd = p.taskPicker.Update(msg) if pk, ok := model.(*picker.Picker); ok { p.taskPicker = pk } // Check if the highlighted task changed (auto-selection) currentTaskDesc := p.taskPicker.GetValue() if currentTaskDesc != previousTaskDesc && currentTaskDesc != "" { // Handle "(none)" selection - clear task state if currentTaskDesc == "(none)" { p.selectedTask = nil p.uuid = "" p.track = "" // Don't clear tags - user might still want manual tags // Refresh tag suggestions cmds = append(cmds, p.updateTagSuggestions()) } else { // Find and update the selected task filters := []string{"+track", "status:pending"} if p.selectedProject != "" { filters = append(filters, "project:"+p.selectedProject) } tasks := p.common.TW.GetTasks(nil, filters...) for _, task := range tasks { if task.Description == currentTaskDesc { // Update selected task p.selectedTask = task p.uuid = task.Uuid // Build tags from task tags := []string{} // Add task description if task.Description != "" { tags = append(tags, task.Description) } // Add task tags (excluding "track" tag since it's preserved separately) for _, tag := range task.Tags { if tag != "track" { tags = append(tags, tag) } } // Store track tag if present if task.HasTag("track") { p.track = "track" } // Update tags input p.tagsInput.SetValue(formatTags(tags)) // Refresh tag suggestions cmds = append(cmds, p.updateTagSuggestions()) break } } } } } case 2: // Tags var model tea.Model model, cmd = p.tagsInput.Update(msg) if ac, ok := model.(*autocomplete.Autocomplete); ok { p.tagsInput = ac } case 3: // Start var model tea.Model model, cmd = p.startEditor.Update(msg) if editor, ok := model.(*timestampeditor.TimestampEditor); ok { p.startEditor = editor } case 4: // End var model tea.Model model, cmd = p.endEditor.Update(msg) if editor, ok := model.(*timestampeditor.TimestampEditor); ok { p.endEditor = editor } case 5: // Adjust // Handle adjust toggle with space/enter if msg, ok := msg.(tea.KeyMsg); ok { if msg.String() == " " || msg.String() == "enter" { p.adjust = !p.adjust } } } cmds = append(cmds, cmd) return p, tea.Batch(cmds...) } func (p *TimeEditorPage) focusCurrentField() tea.Cmd { switch p.currentField { case 0: return p.projectPicker.Init() // Picker doesn't have explicit Focus() case 1: if p.taskPicker != nil { return p.taskPicker.Init() } return nil case 2: p.tagsInput.Focus() return p.tagsInput.Init() case 3: return p.startEditor.Focus() case 4: return p.endEditor.Focus() case 5: // Adjust checkbox doesn't need focus action return nil } return nil } func (p *TimeEditorPage) blurCurrentField() { switch p.currentField { case 0: // Project picker doesn't have explicit Blur(), state handled by Update case 1: // Task picker doesn't have explicit Blur(), state handled by Update case 2: p.tagsInput.Blur() case 3: p.startEditor.Blur() case 4: p.endEditor.Blur() case 5: // Adjust checkbox doesn't need blur action } } func (p *TimeEditorPage) View() string { var sections []string // Title titleStyle := p.common.Styles.Form.Focused.Title sections = append(sections, titleStyle.Render("Edit Time Interval")) sections = append(sections, "") // Project picker (field 0) if p.currentField == 0 { sections = append(sections, p.projectPicker.View()) } else { blurredLabelStyle := p.common.Styles.Form.Blurred.Title sections = append(sections, blurredLabelStyle.Render("Project")) sections = append(sections, lipgloss.NewStyle().Faint(true).Render(p.selectedProject)) } sections = append(sections, "") sections = append(sections, "") // Task picker (field 1) if p.currentField == 1 { if p.taskPicker != nil { sections = append(sections, p.taskPicker.View()) } else { // No project selected yet blurredLabelStyle := p.common.Styles.Form.Blurred.Title sections = append(sections, blurredLabelStyle.Render("Task")) sections = append(sections, lipgloss.NewStyle().Faint(true).Render("(select a project first)")) } } else { blurredLabelStyle := p.common.Styles.Form.Blurred.Title sections = append(sections, blurredLabelStyle.Render("Task")) if p.selectedTask != nil { sections = append(sections, lipgloss.NewStyle().Faint(true).Render(p.selectedTask.Description)) } else { sections = append(sections, lipgloss.NewStyle().Faint(true).Render("(none)")) } } sections = append(sections, "") sections = append(sections, "") // Tags input (field 2) tagsLabelStyle := p.common.Styles.Form.Focused.Title tagsLabel := tagsLabelStyle.Render("Tags") if p.currentField == 2 { sections = append(sections, tagsLabel) sections = append(sections, p.tagsInput.View()) descStyle := p.common.Styles.Form.Focused.Description sections = append(sections, descStyle.Render("Space separated, use \"\" for tags with spaces")) } else { blurredLabelStyle := p.common.Styles.Form.Blurred.Title sections = append(sections, blurredLabelStyle.Render("Tags")) sections = append(sections, lipgloss.NewStyle().Faint(true).Render(p.tagsInput.GetValue())) } sections = append(sections, "") sections = append(sections, "") // Start editor (field 3) sections = append(sections, p.startEditor.View()) sections = append(sections, "") // End editor (field 4) sections = append(sections, p.endEditor.View()) sections = append(sections, "") // Adjust checkbox (field 5) adjustLabelStyle := p.common.Styles.Form.Focused.Title adjustLabel := adjustLabelStyle.Render("Adjust overlaps") var checkbox string if p.adjust { checkbox = "[X]" } else { checkbox = "[ ]" } if p.currentField == 5 { focusedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) sections = append(sections, adjustLabel) sections = append(sections, focusedStyle.Render(checkbox+" Auto-adjust overlapping intervals")) descStyle := p.common.Styles.Form.Focused.Description sections = append(sections, descStyle.Render("Press space to toggle")) } else { blurredLabelStyle := p.common.Styles.Form.Blurred.Title sections = append(sections, blurredLabelStyle.Render("Adjust overlaps")) sections = append(sections, lipgloss.NewStyle().Faint(true).Render(checkbox+" Auto-adjust overlapping intervals")) } sections = append(sections, "") sections = append(sections, "") // Help text helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) sections = append(sections, helpStyle.Render("tab/shift+tab: navigate • enter: select/save • esc: cancel")) content := lipgloss.JoinVertical(lipgloss.Left, sections...) return lipgloss.Place( p.common.Width(), p.common.Height(), lipgloss.Left, lipgloss.Top, content, ) } func (p *TimeEditorPage) SetSize(width, height int) { p.common.SetSize(width, height) } func (p *TimeEditorPage) saveInterval() { // If it's an existing interval (has ID), delete it first so we can replace it with the modified version if p.interval.ID != 0 { err := p.common.TimeW.DeleteInterval(p.interval.ID) if err != nil { slog.Error("Failed to delete old interval during edit", "err", err) // Proceeding to import anyway, attempting to save user data } } p.interval.Start = p.startEditor.GetValueString() p.interval.End = p.endEditor.GetValueString() // Parse display tags from input displayTags := parseTags(p.tagsInput.GetValue()) // Reconstruct full tags array by combining special tags and display tags var tags []string // Add preserved special tags first if p.uuid != "" { tags = append(tags, "uuid:"+p.uuid) } if p.track != "" { tags = append(tags, p.track) } // Add project tag if p.selectedProject != "" { tags = append(tags, "project:"+p.selectedProject) } // Add display tags (user-entered tags from the input field) tags = append(tags, displayTags...) p.interval.Tags = tags err := p.common.TimeW.ModifyInterval(p.interval, p.adjust) if err != nil { slog.Error("Failed to modify interval", "err", err) } } func parseTags(tagsStr string) []string { var tags []string var current strings.Builder inQuotes := false for _, r := range tagsStr { switch { case r == '"': inQuotes = !inQuotes case r == ' ' && !inQuotes: if current.Len() > 0 { tags = append(tags, current.String()) current.Reset() } default: current.WriteRune(r) } } if current.Len() > 0 { tags = append(tags, current.String()) } return tags } func formatTags(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, " ") } // extractSpecialTags separates special tags (uuid, project, track) from display tags // Returns uuid, project, track as separate strings, and displayTags for user editing func extractSpecialTags(tags []string) (uuid, project, track string, displayTags []string) { for _, tag := range tags { if strings.HasPrefix(tag, "uuid:") { uuid = strings.TrimPrefix(tag, "uuid:") } else if strings.HasPrefix(tag, "project:") { project = strings.TrimPrefix(tag, "project:") } else if tag == "track" { track = tag } else { displayTags = append(displayTags, tag) } } return } // extractProjectFromTags finds and removes the first tag that matches a known project // Returns the found project (or empty string) and the remaining tags // This is kept for backward compatibility but now uses extractSpecialTags internally func extractProjectFromTags(tags []string, projects []string) (string, []string) { _, project, _, remaining := extractSpecialTags(tags) return project, remaining } // ensureTagPresent adds a tag to the list if not already present func ensureTagPresent(tags []string, tag string) []string { for _, t := range tags { if t == tag { return tags // Already present } } return append(tags, tag) } // filterTagCombinationsByProject filters tag combinations to only show those // containing the exact project tag, and removes the project from the displayed combination func filterTagCombinationsByProject(combinations []string, project string) []string { if project == "" { return combinations } projectTag := "project:" + project var filtered []string for _, combo := range combinations { // Parse the combination into individual tags tags := parseTags(combo) // Check if project exists in this combination for _, tag := range tags { if tag == projectTag { // Found the project - now remove it from display var displayTags []string for _, t := range tags { if t != projectTag { displayTags = append(displayTags, t) } } if len(displayTags) > 0 { filtered = append(filtered, formatTags(displayTags)) } break } } } return filtered } // updateTagSuggestions refreshes the tag autocomplete with filtered combinations func (p *TimeEditorPage) updateTagSuggestions() tea.Cmd { combinations := filterTagCombinationsByProject( p.common.TimeW.GetTagCombinations(), p.selectedProject, ) // Update autocomplete suggestions currentValue := p.tagsInput.GetValue() p.tagsInput.SetSuggestions(combinations) p.tagsInput.SetValue(currentValue) // If tags field is focused, refocus it if p.currentField == 2 { p.tagsInput.Focus() return p.tagsInput.Init() } return nil }