diff --git a/opal-task/cmd/add.go b/opal-task/cmd/add.go index 25d8ee3..4de2356 100644 --- a/opal-task/cmd/add.go +++ b/opal-task/cmd/add.go @@ -63,11 +63,14 @@ func addTask(args []string) error { return fmt.Errorf("failed to create task: %w", err) } - fmt.Printf("Created task %s\n", task.UUID) - if len(task.Tags) > 0 { - fmt.Printf("Tags: %s\n", strings.Join(task.Tags, ", ")) + displayID, err := engine.AppendTask(task) + if err != nil { + // Non-fatal: task was created, just can't assign display ID + fmt.Printf("Created task %s\n", task.UUID) + return nil } + fmt.Print(engine.FormatAddFeedback(task, displayID)) return nil } @@ -110,8 +113,13 @@ func addRecurringTask(description string, mod *engine.Modifier) error { return err } - fmt.Printf("Created recurring task %s\n", *instance.ParentUUID) - fmt.Printf("First instance: %s\n", instance.UUID) + displayID, err := engine.AppendTask(instance) + if err != nil { + fmt.Printf("Created recurring task %s\n", *instance.ParentUUID) + fmt.Printf("First instance: %s\n", instance.UUID) + return nil + } + fmt.Print(engine.FormatRecurringAddFeedback(instance, displayID)) return nil } diff --git a/opal-task/cmd/delete.go b/opal-task/cmd/delete.go index d08130a..a5d3584 100644 --- a/opal-task/cmd/delete.go +++ b/opal-task/cmd/delete.go @@ -53,17 +53,25 @@ func deleteTasks(args []string) error { return fmt.Errorf("no tasks matched filter") } - fmt.Printf("Delete %d task(s)? (y/N): ", len(tasks)) - var confirm string - fmt.Scanln(&confirm) - if confirm != "y" && confirm != "Y" { - return nil + if len(tasks) > 1 { + fmt.Print(engine.FormatTaskConfirmList("delete", tasks, ws)) + fmt.Printf("Proceed? (y/N): ") + var confirm string + fmt.Scanln(&confirm) + if confirm != "y" && confirm != "Y" { + fmt.Println("Cancelled.") + return nil + } } for _, task := range tasks { task.Delete(false) // Soft delete } - fmt.Printf("Deleted %d task(s).\n", len(tasks)) + if len(tasks) == 1 { + fmt.Printf("Deleted task %s\n", engine.FormatTaskSummary(tasks[0], ws)) + } else { + fmt.Printf("Deleted %d task(s).\n", len(tasks)) + } return nil } diff --git a/opal-task/cmd/done.go b/opal-task/cmd/done.go index beb251e..e301b21 100644 --- a/opal-task/cmd/done.go +++ b/opal-task/cmd/done.go @@ -66,9 +66,9 @@ func completeTasks(args []string) error { return fmt.Errorf("no tasks matched filter") } - // Confirm if multiple tasks if len(tasks) > 1 { - fmt.Printf("About to complete %d tasks. Proceed? (y/N): ", len(tasks)) + fmt.Print(engine.FormatTaskConfirmList("complete", tasks, ws)) + fmt.Printf("Proceed? (y/N): ") var confirm string fmt.Scanln(&confirm) if confirm != "y" && confirm != "Y" { @@ -80,14 +80,18 @@ func completeTasks(args []string) error { // Complete tasks completed := 0 for _, task := range tasks { - if err := task.Complete(); err != nil { + if _, err := task.Complete(); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to complete task %s: %v\n", task.UUID, err) } else { completed++ } } - fmt.Printf("Completed %d task(s).\n", completed) + if len(tasks) == 1 { + fmt.Printf("Completed task %s\n", engine.FormatTaskSummary(tasks[0], ws)) + } else { + fmt.Printf("Completed %d task(s).\n", completed) + } return nil } diff --git a/opal-task/cmd/edit.go b/opal-task/cmd/edit.go index bb99654..d71f506 100644 --- a/opal-task/cmd/edit.go +++ b/opal-task/cmd/edit.go @@ -240,7 +240,8 @@ func applyEditedFields(task *engine.Task, fields map[string]string) error { return err } // Then complete (which saves automatically) - return task.Complete() + _, err := task.Complete() + return err } // If changing to deleted, use Delete() method diff --git a/opal-task/cmd/modify.go b/opal-task/cmd/modify.go index 6ed1a7c..65e218f 100644 --- a/opal-task/cmd/modify.go +++ b/opal-task/cmd/modify.go @@ -87,7 +87,8 @@ func modifyTasks(filterArgs, modifierArgs []string) error { // Confirm if multiple tasks or no filters specified if len(tasks) > 1 || len(filterArgs) == 0 { - fmt.Printf("About to modify %d task(s). Proceed? (y/N): ", len(tasks)) + fmt.Print(engine.FormatTaskConfirmList("modify", tasks, ws)) + fmt.Printf("Proceed? (y/N): ") var confirm string fmt.Scanln(&confirm) if confirm != "y" && confirm != "Y" { diff --git a/opal-task/internal/api/handlers/tasks.go b/opal-task/internal/api/handlers/tasks.go index 82aed63..b2830f3 100644 --- a/opal-task/internal/api/handlers/tasks.go +++ b/opal-task/internal/api/handlers/tasks.go @@ -83,6 +83,11 @@ func ListTasks(w http.ResponseWriter, r *http.Request) { filter.IncludeTags = tags } + // Exclude tag filters + if excludeTags := query["exclude_tag"]; len(excludeTags) > 0 { + filter.ExcludeTags = excludeTags + } + // Get tasks tasks, err := engine.GetTasks(filter) if err != nil { @@ -324,7 +329,7 @@ func CompleteTask(w http.ResponseWriter, r *http.Request) { return } - if err := task.Complete(); err != nil { + if _, err := task.Complete(); err != nil { errorResponse(w, http.StatusInternalServerError, err.Error()) return } diff --git a/opal-task/internal/engine/display.go b/opal-task/internal/engine/display.go index e902257..334cf31 100644 --- a/opal-task/internal/engine/display.go +++ b/opal-task/internal/engine/display.go @@ -143,19 +143,23 @@ func FormatTaskDetail(task *Task) string { } if task.Due != nil { - t.AppendRow(table.Row{"Due", formatTimeWithColor(*task.Due)}) + dueStr := FormatDateWithRelative(*task.Due) + if task.Due.Before(timeNow()) { + dueStr = color.RedString(dueStr) + } + t.AppendRow(table.Row{"Due", dueStr}) } if task.Scheduled != nil { - t.AppendRow(table.Row{"Scheduled", formatTime(*task.Scheduled)}) + t.AppendRow(table.Row{"Scheduled", FormatDateWithRelative(*task.Scheduled)}) } if task.Wait != nil { - t.AppendRow(table.Row{"Wait", formatTime(*task.Wait)}) + t.AppendRow(table.Row{"Wait", FormatDateWithRelative(*task.Wait)}) } if task.Until != nil { - t.AppendRow(table.Row{"Until", formatTime(*task.Until)}) + t.AppendRow(table.Row{"Until", FormatDateWithRelative(*task.Until)}) } if task.RecurrenceDuration != nil { @@ -310,16 +314,20 @@ func formatDue(due *time.Time) string { return "" } - now := time.Now() + rel := FormatRelativeDate(*due) + + now := timeNow() if due.Before(now) { - return color.RedString(due.Format("2006-01-02")) + return color.RedString(rel) } - if due.Before(now.Add(24 * time.Hour)) { - return color.YellowString(due.Format("2006-01-02")) + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + tomorrow := today.Add(24 * time.Hour) + if due.Before(tomorrow) { + return color.YellowString(rel) } - return due.Format("2006-01-02") + return rel } func formatTimeWithColor(t time.Time) string { diff --git a/opal-task/internal/engine/feedback.go b/opal-task/internal/engine/feedback.go new file mode 100644 index 0000000..6f5db5e --- /dev/null +++ b/opal-task/internal/engine/feedback.go @@ -0,0 +1,163 @@ +package engine + +import ( + "fmt" + "strings" + + "github.com/fatih/color" +) + +// FormatTaskSummary returns a one-line summary for action feedback. +// Example: `3 "Buy groceries" due:tomorrow +errand` +func FormatTaskSummary(task *Task, ws *WorkingSet) string { + displayID := resolveDisplayID(task, ws) + + parts := []string{fmt.Sprintf("%d — %q", displayID, task.Description)} + + if task.Due != nil { + parts = append(parts, fmt.Sprintf("due:%s", FormatRelativeDate(*task.Due))) + } + if task.Project != nil { + parts = append(parts, fmt.Sprintf("project:%s", *task.Project)) + } + if len(task.Tags) > 0 { + for _, tag := range task.Tags { + parts = append(parts, color.CyanString("+"+tag)) + } + } + + return strings.Join(parts, " ") +} + +// FormatTaskConfirmList returns the multi-task confirmation block. +// Shows up to 10 tasks, then "...and N more". +func FormatTaskConfirmList(action string, tasks []*Task, ws *WorkingSet) string { + var b strings.Builder + + limit := 10 + if len(tasks) < limit { + limit = len(tasks) + } + + fmt.Fprintf(&b, "About to %s %d task(s):\n", action, len(tasks)) + for i := 0; i < limit; i++ { + task := tasks[i] + displayID := resolveDisplayID(task, ws) + line := fmt.Sprintf(" %3d %-40s", displayID, truncate(task.Description, 40)) + + if task.Due != nil { + line += fmt.Sprintf(" due:%-10s", FormatRelativeDate(*task.Due)) + } + if len(task.Tags) > 0 { + tags := make([]string, len(task.Tags)) + for j, tag := range task.Tags { + tags[j] = "+" + tag + } + line += " " + strings.Join(tags, " ") + } + fmt.Fprintln(&b, line) + } + + if len(tasks) > 10 { + fmt.Fprintf(&b, " ...and %d more\n", len(tasks)-10) + } + + return b.String() +} + +// FormatAddFeedback returns the detailed post-add feedback block. +func FormatAddFeedback(task *Task, displayID int) string { + var b strings.Builder + + fmt.Fprintf(&b, "Created task %d — %q\n", displayID, task.Description) + + if task.Due != nil { + fmt.Fprintf(&b, " Due: %s\n", FormatDateWithRelative(*task.Due)) + } + if task.Project != nil { + fmt.Fprintf(&b, " Project: %s\n", *task.Project) + } + if task.Priority != PriorityDefault { + fmt.Fprintf(&b, " Priority: %s\n", priorityIntToString(task.Priority)) + } + if task.Scheduled != nil { + fmt.Fprintf(&b, " Scheduled: %s\n", FormatDateWithRelative(*task.Scheduled)) + } + if task.Wait != nil { + fmt.Fprintf(&b, " Wait: %s\n", FormatDateWithRelative(*task.Wait)) + } + if len(task.Tags) > 0 { + tags := make([]string, len(task.Tags)) + for i, tag := range task.Tags { + tags[i] = "+" + tag + } + fmt.Fprintf(&b, " Tags: %s\n", strings.Join(tags, " ")) + } + + return b.String() +} + +// FormatRecurringAddFeedback returns feedback for a newly created recurring task. +func FormatRecurringAddFeedback(instance *Task, displayID int) string { + var b strings.Builder + + fmt.Fprintf(&b, "Created recurring task %d — %q\n", displayID, instance.Description) + + if instance.RecurrenceDuration != nil { + fmt.Fprintf(&b, " Recurrence: %s\n", FormatRecurrenceDuration(*instance.RecurrenceDuration)) + } else if instance.ParentUUID != nil { + // Instance: get recurrence from parent + parent, err := GetTask(*instance.ParentUUID) + if err == nil && parent.RecurrenceDuration != nil { + fmt.Fprintf(&b, " Recurrence: %s\n", FormatRecurrenceDuration(*parent.RecurrenceDuration)) + } + } + if instance.Due != nil { + fmt.Fprintf(&b, " Due: %s\n", FormatDateWithRelative(*instance.Due)) + } + if len(instance.Tags) > 0 { + tags := make([]string, len(instance.Tags)) + for i, tag := range instance.Tags { + tags[i] = "+" + tag + } + fmt.Fprintf(&b, " Tags: %s\n", strings.Join(tags, " ")) + } + + return b.String() +} + +// FormatCompletionFeedback returns completion feedback with recurrence info. +func FormatCompletionFeedback(task *Task, displayID int, nextInstance *Task) string { + var b strings.Builder + + fmt.Fprintf(&b, "Completed task %d — %q\n", displayID, task.Description) + + if nextInstance != nil { + if nextInstance.Due != nil { + fmt.Fprintf(&b, "Next instance created — due: %s\n", FormatDateWithRelative(*nextInstance.Due)) + } else { + fmt.Fprintf(&b, "Next instance created\n") + } + } + + return b.String() +} + +func resolveDisplayID(task *Task, ws *WorkingSet) int { + if ws == nil { + return 0 + } + for id, uuid := range ws.byID { + if uuid == task.UUID { + return id + } + } + return 0 +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max-1] + "…" +} diff --git a/opal-task/internal/engine/recurrence.go b/opal-task/internal/engine/recurrence.go index 8665b95..ddfc01d 100644 --- a/opal-task/internal/engine/recurrence.go +++ b/opal-task/internal/engine/recurrence.go @@ -188,20 +188,21 @@ func CreateRecurringTask(description string, mod *Modifier) (*Task, error) { return instance, nil } -// SpawnNextInstance creates a new task instance from completed recurring task -func SpawnNextInstance(completedInstance *Task) error { +// SpawnNextInstance creates a new task instance from completed recurring task. +// Returns the newly created instance, or nil if recurrence has expired. +func SpawnNextInstance(completedInstance *Task) (*Task, error) { if completedInstance.ParentUUID == nil { - return fmt.Errorf("task is not a recurring instance") + return nil, fmt.Errorf("task is not a recurring instance") } // Load template template, err := GetTask(*completedInstance.ParentUUID) if err != nil { - return fmt.Errorf("failed to load template: %w", err) + return nil, fmt.Errorf("failed to load template: %w", err) } if template.RecurrenceDuration == nil { - return fmt.Errorf("template has no recurrence duration") + return nil, fmt.Errorf("template has no recurrence duration") } // Calculate next due date @@ -212,7 +213,7 @@ func SpawnNextInstance(completedInstance *Task) error { } else if completedInstance.Due != nil { baseDate = *completedInstance.Due } else { - return fmt.Errorf("recurring instance has no due or end date") + return nil, fmt.Errorf("recurring instance has no due or end date") } next := CalculateNextDue(baseDate, *template.RecurrenceDuration) @@ -221,7 +222,7 @@ func SpawnNextInstance(completedInstance *Task) error { // Check if we're past 'until' date if template.Until != nil && nextDue != nil && nextDue.After(*template.Until) { // Don't spawn, recurrence has expired - return nil + return nil, nil } // Create new instance @@ -243,20 +244,20 @@ func SpawnNextInstance(completedInstance *Task) error { } if err := newInstance.Save(); err != nil { - return fmt.Errorf("failed to save new instance: %w", err) + return nil, fmt.Errorf("failed to save new instance: %w", err) } // Copy tags from template templateTags, err := template.GetTags() if err != nil { - return fmt.Errorf("failed to get template tags: %w", err) + return nil, fmt.Errorf("failed to get template tags: %w", err) } for _, tag := range templateTags { if err := newInstance.AddTag(tag); err != nil { - return fmt.Errorf("failed to add tag: %w", err) + return nil, fmt.Errorf("failed to add tag: %w", err) } } - return nil + return newInstance, nil } diff --git a/opal-task/internal/engine/recurrence_test.go b/opal-task/internal/engine/recurrence_test.go index 551eac0..5471ef6 100644 --- a/opal-task/internal/engine/recurrence_test.go +++ b/opal-task/internal/engine/recurrence_test.go @@ -196,7 +196,7 @@ func TestSpawnNextInstance(t *testing.T) { } // Complete the instance (should spawn next) - if err := instance1.Complete(); err != nil { + if _, err := instance1.Complete(); err != nil { t.Fatalf("Failed to complete instance: %v", err) } @@ -306,7 +306,7 @@ func TestRecurrenceWithUntilDate(t *testing.T) { } // Complete instance - should NOT spawn new one (past until date) - if err := instance.Complete(); err != nil { + if _, err := instance.Complete(); err != nil { t.Fatalf("Failed to complete instance: %v", err) } diff --git a/opal-task/internal/engine/reldate.go b/opal-task/internal/engine/reldate.go new file mode 100644 index 0000000..5fdaf2a --- /dev/null +++ b/opal-task/internal/engine/reldate.go @@ -0,0 +1,45 @@ +package engine + +import ( + "fmt" + "math" + "time" +) + +// FormatRelativeDate returns a human-readable relative date string. +// Within 14 days: "today", "tomorrow", "yesterday", "in 3d", "2d ago" +// Beyond 14 days: "Feb 28", "Mar 15" +// Cross-year: "Feb 28 2027" +func FormatRelativeDate(t time.Time) string { + now := timeNow() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + target := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) + + days := int(math.Round(target.Sub(today).Hours() / 24)) + + switch { + case days == 0: + return "today" + case days == 1: + return "tomorrow" + case days == -1: + return "yesterday" + case days > 1 && days <= 14: + return fmt.Sprintf("in %dd", days) + case days < -1 && days >= -14: + return fmt.Sprintf("%dd ago", -days) + default: + if t.Year() != now.Year() { + return t.Format("Jan 2 2006") + } + return t.Format("Jan 2") + } +} + +// FormatDateWithRelative returns "2026-02-20 (in 2 days)" style. +// Used in info/detail views where both absolute and relative are useful. +func FormatDateWithRelative(t time.Time) string { + absolute := t.Format("2006-01-02 15:04") + relative := FormatRelativeDate(t) + return fmt.Sprintf("%s (%s)", absolute, relative) +} diff --git a/opal-task/internal/engine/task.go b/opal-task/internal/engine/task.go index 965f258..98a198a 100644 --- a/opal-task/internal/engine/task.go +++ b/opal-task/internal/engine/task.go @@ -694,25 +694,27 @@ func (t *Task) GetTags() ([]string, error) { return tags, nil } -// Complete marks a task as completed -func (t *Task) Complete() error { +// Complete marks a task as completed. +// Returns the next recurring instance if one was spawned, or nil. +func (t *Task) Complete() (*Task, error) { t.Status = StatusCompleted now := timeNow() t.End = &now if err := t.Save(); err != nil { - return err + return nil, err } // If this is a recurring instance, spawn next instance if t.ParentUUID != nil { - if err := SpawnNextInstance(t); err != nil { - // Log error but don't fail the completion - return fmt.Errorf("completed task but failed to spawn next instance: %w", err) + next, err := SpawnNextInstance(t) + if err != nil { + return nil, fmt.Errorf("completed task but failed to spawn next instance: %w", err) } + return next, nil } - return nil + return nil, nil } // Delete marks a task as deleted (soft delete) diff --git a/opal-task/internal/engine/task_test.go b/opal-task/internal/engine/task_test.go index 5141e4b..bcee203 100644 --- a/opal-task/internal/engine/task_test.go +++ b/opal-task/internal/engine/task_test.go @@ -165,7 +165,7 @@ func TestTaskComplete(t *testing.T) { t.Fatalf("Failed to create task: %v", err) } - if err := task.Complete(); err != nil { + if _, err := task.Complete(); err != nil { t.Fatalf("Failed to complete task: %v", err) } diff --git a/opal-task/internal/engine/ws.go b/opal-task/internal/engine/ws.go index 20a25a8..c90df71 100644 --- a/opal-task/internal/engine/ws.go +++ b/opal-task/internal/engine/ws.go @@ -142,3 +142,32 @@ func (ws *WorkingSet) GetTasks() []*Task { func (ws *WorkingSet) Size() int { return len(ws.byID) } + +// ByID returns the display_id -> UUID mapping. +func (ws *WorkingSet) ByID() map[int]uuid.UUID { + return ws.byID +} + +// AppendTask inserts a task into the working set at MAX(display_id) + 1. +// Returns the assigned display ID. The ID is valid until the next report +// render, at which point the entire working set is rebuilt. +func AppendTask(task *Task) (int, error) { + db := GetDB() + if db == nil { + return 0, fmt.Errorf("database not initialized") + } + + var maxID int + err := db.QueryRow("SELECT COALESCE(MAX(display_id), 0) FROM working_set").Scan(&maxID) + if err != nil { + return 0, fmt.Errorf("failed to query max display_id: %w", err) + } + + newID := maxID + 1 + _, err = db.Exec("INSERT INTO working_set (display_id, task_uuid) VALUES (?, ?)", newID, task.UUID.String()) + if err != nil { + return 0, fmt.Errorf("failed to append to working set: %w", err) + } + + return newID, nil +}