From 32cc05a5460449992a4e8115f774ac59202a2edf Mon Sep 17 00:00:00 2001 From: Joakim Date: Thu, 19 Feb 2026 13:56:55 +0100 Subject: [PATCH] feat: add task history via log command and info integration Add engine/history.go with GetTaskHistory and diff-style FormatTaskHistory that compares consecutive change_log entries to show only what changed. Add cmd/log.go command for full task history. Integrate last 5 history entries into FormatTaskDetail (info view) as a "Recent Changes" section. Co-Authored-By: Claude Opus 4.6 --- opal-task/cmd/log.go | 75 +++++++++++ opal-task/cmd/root.go | 3 +- opal-task/internal/engine/display.go | 20 +++ opal-task/internal/engine/history.go | 180 +++++++++++++++++++++++++++ 4 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 opal-task/cmd/log.go create mode 100644 opal-task/internal/engine/history.go diff --git a/opal-task/cmd/log.go b/opal-task/cmd/log.go new file mode 100644 index 0000000..b85a51e --- /dev/null +++ b/opal-task/cmd/log.go @@ -0,0 +1,75 @@ +package cmd + +import ( + "fmt" + "os" + + "git.jnss.me/joakim/opal/internal/engine" + "github.com/spf13/cobra" +) + +var logCmd = &cobra.Command{ + Use: "log [filter]", + Short: "Show change history for a task", + Long: `Show the change history for a single task, pulled from the change log. + +Examples: + opal 2 log + opal log +bug`, + Run: func(cmd *cobra.Command, args []string) { + parsed := getParsedArgs(cmd) + if err := showTaskLog(parsed.Filters); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func showTaskLog(args []string) error { + if len(args) == 0 { + return fmt.Errorf("no task specified for log command") + } + + filter, err := engine.ParseFilter(args) + if err != nil { + return fmt.Errorf("failed to parse filter: %w", err) + } + + ws, err := engine.LoadWorkingSet() + if err != nil { + return fmt.Errorf("failed to load working set: %w", err) + } + + var task *engine.Task + + if len(filter.IDs) > 0 { + if len(filter.IDs) != 1 { + return fmt.Errorf("log requires exactly one task") + } + task, err = ws.GetTaskByDisplayID(filter.IDs[0]) + if err != nil { + return err + } + } else { + tasks, err := engine.GetTasks(filter) + if err != nil { + return fmt.Errorf("failed to get tasks: %w", err) + } + if len(tasks) == 0 { + return fmt.Errorf("no tasks matched filter") + } + if len(tasks) > 1 { + return fmt.Errorf("log requires exactly one task (filter matched %d)", len(tasks)) + } + task = tasks[0] + } + + entries, err := engine.GetTaskHistory(task.UUID) + if err != nil { + return err + } + + fmt.Printf("History for: %s\n\n", task.Description) + fmt.Print(engine.FormatTaskHistory(entries)) + return nil +} diff --git a/opal-task/cmd/root.go b/opal-task/cmd/root.go index 964824d..e49c349 100644 --- a/opal-task/cmd/root.go +++ b/opal-task/cmd/root.go @@ -34,7 +34,7 @@ var commandNames = []string{ "add", "done", "modify", "delete", "start", "stop", "count", "projects", "tags", "info", "edit", "server", "sync", "reports", "setup", - "version", "annotate", "denotate", "undo", + "version", "annotate", "denotate", "undo", "log", } // Report names (dynamically populated) @@ -239,6 +239,7 @@ func init() { rootCmd.AddCommand(annotateCmd) rootCmd.AddCommand(denotateCmd) rootCmd.AddCommand(undoCmd) + rootCmd.AddCommand(logCmd) // Enable --version flag on root command rootCmd.Version = Version diff --git a/opal-task/internal/engine/display.go b/opal-task/internal/engine/display.go index 6b2b156..f2ea32e 100644 --- a/opal-task/internal/engine/display.go +++ b/opal-task/internal/engine/display.go @@ -2,6 +2,7 @@ package engine import ( "fmt" + "strings" "time" "github.com/fatih/color" @@ -187,6 +188,25 @@ func FormatTaskDetail(task *Task) string { } } + // Recent changes from change_log (last 5) + if entries, err := GetTaskHistory(task.UUID); err == nil && len(entries) > 0 { + t.AppendSeparator() + // Show last 5 entries + start := 0 + if len(entries) > 5 { + start = len(entries) - 5 + } + historyStr := FormatTaskHistory(entries[start:]) + lines := strings.Split(strings.TrimSpace(historyStr), "\n") + for i, line := range lines { + label := "" + if i == 0 { + label = "History" + } + t.AppendRow(table.Row{label, line}) + } + } + return t.Render() } diff --git a/opal-task/internal/engine/history.go b/opal-task/internal/engine/history.go new file mode 100644 index 0000000..6098e2f --- /dev/null +++ b/opal-task/internal/engine/history.go @@ -0,0 +1,180 @@ +package engine + +import ( + "fmt" + "strings" + "time" + + "github.com/google/uuid" +) + +// HistoryEntry represents a change_log entry for display. +type HistoryEntry struct { + ID int + Timestamp time.Time + ChangeType string // "create", "update", "delete" + Data string // raw key:value data from change_log +} + +// GetTaskHistory returns change_log entries for a task UUID, ordered chronologically. +func GetTaskHistory(taskUUID uuid.UUID) ([]HistoryEntry, error) { + db := GetDB() + if db == nil { + return nil, fmt.Errorf("database not initialized") + } + + rows, err := db.Query( + "SELECT id, changed_at, change_type, data FROM change_log WHERE task_uuid = ? ORDER BY id ASC", + taskUUID.String(), + ) + if err != nil { + return nil, fmt.Errorf("failed to query change_log: %w", err) + } + defer rows.Close() + + var entries []HistoryEntry + for rows.Next() { + var e HistoryEntry + var changedAt int64 + if err := rows.Scan(&e.ID, &changedAt, &e.ChangeType, &e.Data); err != nil { + return nil, fmt.Errorf("failed to scan change_log entry: %w", err) + } + e.Timestamp = time.Unix(changedAt, 0) + entries = append(entries, e) + } + + return entries, nil +} + +// FormatTaskHistory returns a diff-style history display. +// Compares consecutive entries and shows only what changed. +func FormatTaskHistory(entries []HistoryEntry) string { + if len(entries) == 0 { + return "No history found.\n" + } + + var sb strings.Builder + var prevFields map[string]string + + for _, entry := range entries { + ts := entry.Timestamp.Format("2006-01-02 15:04") + currentFields := parseChangeData(entry.Data) + + if entry.ChangeType == "create" { + // Show creation summary + desc := currentFields["description"] + priority := currentFields["priority"] + tags := currentFields["tags"] + line := fmt.Sprintf("%s created \"%s\"", ts, desc) + if priority != "" && priority != "D" { + line += fmt.Sprintf(" priority:%s", priority) + } + if tags != "" { + for _, tag := range strings.Split(tags, ",") { + line += fmt.Sprintf(" +%s", strings.TrimSpace(tag)) + } + } + sb.WriteString(line + "\n") + } else if entry.ChangeType == "delete" { + sb.WriteString(fmt.Sprintf("%s deleted\n", ts)) + } else if entry.ChangeType == "update" { + if prevFields == nil { + // No previous entry to diff against, show as generic update + sb.WriteString(fmt.Sprintf("%s updated\n", ts)) + } else { + // Diff against previous + changes := diffFields(prevFields, currentFields) + if len(changes) == 0 { + sb.WriteString(fmt.Sprintf("%s updated (tags changed)\n", ts)) + } else { + for i, change := range changes { + if i == 0 { + sb.WriteString(fmt.Sprintf("%s modified %s\n", ts, change)) + } else { + sb.WriteString(fmt.Sprintf(" %s\n", change)) + } + } + } + } + } + + prevFields = currentFields + } + + return sb.String() +} + +// parseChangeData parses key:value lines from change_log data. +func parseChangeData(data string) map[string]string { + fields := make(map[string]string) + for _, line := range strings.Split(data, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.SplitN(line, ": ", 2) + if len(parts) == 2 { + fields[parts[0]] = parts[1] + } + } + return fields +} + +// diffFields compares two field maps and returns human-readable change descriptions. +func diffFields(prev, curr map[string]string) []string { + var changes []string + + // Skip internal/timestamp fields + skip := map[string]bool{ + "uuid": true, "created": true, "modified": true, + } + + // Check fields in current that differ from prev + for key, currVal := range curr { + if skip[key] { + continue + } + prevVal, existed := prev[key] + if !existed { + changes = append(changes, fmt.Sprintf("%s: (none) → %s", key, formatFieldValue(key, currVal))) + } else if prevVal != currVal { + changes = append(changes, fmt.Sprintf("%s: %s → %s", key, formatFieldValue(key, prevVal), formatFieldValue(key, currVal))) + } + } + + // Check fields removed (in prev but not in current) + for key, prevVal := range prev { + if skip[key] { + continue + } + if _, exists := curr[key]; !exists { + changes = append(changes, fmt.Sprintf("%s: %s → (none)", key, formatFieldValue(key, prevVal))) + } + } + + return changes +} + +// formatFieldValue formats a change_log field value for human display. +func formatFieldValue(key, value string) string { + // For timestamp fields, try to format as dates + switch key { + case "due", "scheduled", "wait", "until", "start", "end": + if t, err := parseUnixString(value); err == nil { + return t.Format("2006-01-02") + } + case "status": + return value // already human-readable + } + return value +} + +// parseUnixString parses a unix timestamp string. +func parseUnixString(s string) (time.Time, error) { + var ts int64 + _, err := fmt.Sscanf(s, "%d", &ts) + if err != nil { + return time.Time{}, err + } + return time.Unix(ts, 0), nil +}