Files
gems/opal-task/internal/engine/task.go
T
joakim b02c40f716 feat: improve CLI output with relative dates, rich feedback, and recurring task info
Add relative date formatting (today, tomorrow, in 3d, etc.) for list and
detail views. Add structured feedback helpers for add/complete/delete
operations showing display IDs and parsed modifiers. Change Complete() to
return spawned recurring instance so callers can display recurrence info.
Add AppendTask to working set for immediate display ID assignment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:44:56 +01:00

777 lines
18 KiB
Go

package engine
import (
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
)
type Status byte
const (
StatusPending Status = 'P'
StatusCompleted Status = 'C'
StatusDeleted Status = 'D'
StatusRecurring Status = 'R'
)
// MarshalJSON encodes Status as a single-character string (e.g. "P", "C").
func (s Status) MarshalJSON() ([]byte, error) {
return json.Marshal(string(s))
}
// UnmarshalJSON decodes a single-character string into a Status.
func (s *Status) UnmarshalJSON(data []byte) error {
var str string
if err := json.Unmarshal(data, &str); err != nil {
return err
}
if len(str) != 1 {
return fmt.Errorf("invalid status: %q", str)
}
*s = Status(str[0])
return nil
}
type Priority int
const (
PriorityLow Priority = 0
PriorityDefault Priority = 1
PriorityMedium Priority = 2
PriorityHigh Priority = 3
)
type Task struct {
// Identity
UUID uuid.UUID `json:"uuid"`
ID int `json:"id"`
// Core fields
Status Status `json:"status"`
Description string `json:"description"`
Project *string `json:"project"`
Priority Priority `json:"priority"`
// Timestamps
Created time.Time `json:"created"`
Modified time.Time `json:"modified"`
Start *time.Time `json:"start,omitempty"`
End *time.Time `json:"end,omitempty"`
Due *time.Time `json:"due,omitempty"`
Scheduled *time.Time `json:"scheduled,omitempty"`
Wait *time.Time `json:"wait,omitempty"`
Until *time.Time `json:"until,omitempty"`
// Recurrence (parent-child approach)
RecurrenceDuration *time.Duration `json:"recurrence_duration,omitempty"`
ParentUUID *uuid.UUID `json:"parent_uuid,omitempty"`
// Derived fields (not stored in DB)
Tags []string `json:"tags"`
Urgency float64 `json:"urgency"`
}
// MarshalJSON emits Task with unix timestamps (int64) instead of RFC3339 strings.
func (t Task) MarshalJSON() ([]byte, error) {
type taskJSON struct {
UUID uuid.UUID `json:"uuid"`
ID int `json:"id"`
Status Status `json:"status"`
Description string `json:"description"`
Project *string `json:"project"`
Priority Priority `json:"priority"`
Created int64 `json:"created"`
Modified int64 `json:"modified"`
Start *int64 `json:"start,omitempty"`
End *int64 `json:"end,omitempty"`
Due *int64 `json:"due,omitempty"`
Scheduled *int64 `json:"scheduled,omitempty"`
Wait *int64 `json:"wait,omitempty"`
Until *int64 `json:"until,omitempty"`
RecurrenceDuration *int64 `json:"recurrence_duration,omitempty"`
ParentUUID *uuid.UUID `json:"parent_uuid,omitempty"`
Tags []string `json:"tags"`
Urgency float64 `json:"urgency"`
}
toUnix := func(tp *time.Time) *int64 {
if tp == nil {
return nil
}
v := tp.Unix()
return &v
}
var recurDur *int64
if t.RecurrenceDuration != nil {
v := int64(*t.RecurrenceDuration / time.Second)
recurDur = &v
}
return json.Marshal(taskJSON{
UUID: t.UUID,
ID: t.ID,
Status: t.Status,
Description: t.Description,
Project: t.Project,
Priority: t.Priority,
Created: t.Created.Unix(),
Modified: t.Modified.Unix(),
Start: toUnix(t.Start),
End: toUnix(t.End),
Due: toUnix(t.Due),
Scheduled: toUnix(t.Scheduled),
Wait: toUnix(t.Wait),
Until: toUnix(t.Until),
RecurrenceDuration: recurDur,
ParentUUID: t.ParentUUID,
Tags: t.Tags,
Urgency: t.Urgency,
})
}
// UnmarshalJSON parses Task from JSON with unix timestamps (int64) and duration in seconds.
func (t *Task) UnmarshalJSON(data []byte) error {
type taskJSON struct {
UUID uuid.UUID `json:"uuid"`
ID int `json:"id"`
Status Status `json:"status"`
Description string `json:"description"`
Project *string `json:"project"`
Priority Priority `json:"priority"`
Created int64 `json:"created"`
Modified int64 `json:"modified"`
Start *int64 `json:"start,omitempty"`
End *int64 `json:"end,omitempty"`
Due *int64 `json:"due,omitempty"`
Scheduled *int64 `json:"scheduled,omitempty"`
Wait *int64 `json:"wait,omitempty"`
Until *int64 `json:"until,omitempty"`
RecurrenceDuration *int64 `json:"recurrence_duration,omitempty"`
ParentUUID *uuid.UUID `json:"parent_uuid,omitempty"`
Tags []string `json:"tags"`
Urgency float64 `json:"urgency"`
}
var raw taskJSON
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
fromUnix := func(v *int64) *time.Time {
if v == nil {
return nil
}
t := time.Unix(*v, 0)
return &t
}
t.UUID = raw.UUID
t.ID = raw.ID
t.Status = raw.Status
t.Description = raw.Description
t.Project = raw.Project
t.Priority = raw.Priority
t.Created = time.Unix(raw.Created, 0)
t.Modified = time.Unix(raw.Modified, 0)
t.Start = fromUnix(raw.Start)
t.End = fromUnix(raw.End)
t.Due = fromUnix(raw.Due)
t.Scheduled = fromUnix(raw.Scheduled)
t.Wait = fromUnix(raw.Wait)
t.Until = fromUnix(raw.Until)
t.ParentUUID = raw.ParentUUID
t.Tags = raw.Tags
t.Urgency = raw.Urgency
if raw.RecurrenceDuration != nil {
d := time.Duration(*raw.RecurrenceDuration) * time.Second
t.RecurrenceDuration = &d
}
return nil
}
// timeNow returns current time (allows mocking in tests)
var timeNow = time.Now
// Helper functions for SQL time conversion
func timeToSQL(t *time.Time) interface{} {
if t == nil {
return nil
}
return t.Unix()
}
func sqlToTime(v interface{}) *time.Time {
if v == nil {
return nil
}
unix, ok := v.(int64)
if !ok {
return nil
}
t := time.Unix(unix, 0)
return &t
}
func durationToSQL(d *time.Duration) interface{} {
if d == nil {
return nil
}
return int64(*d)
}
func sqlToDuration(v interface{}) *time.Duration {
if v == nil {
return nil
}
nanos, ok := v.(int64)
if !ok {
return nil
}
d := time.Duration(nanos)
return &d
}
func stringPtrToSQL(s *string) interface{} {
if s == nil {
return nil
}
return *s
}
func sqlToStringPtr(v interface{}) *string {
if v == nil {
return nil
}
str, ok := v.(string)
if !ok {
return nil
}
return &str
}
func uuidPtrToSQL(u *uuid.UUID) interface{} {
if u == nil {
return nil
}
return u.String()
}
func sqlToUUIDPtr(v interface{}) *uuid.UUID {
if v == nil {
return nil
}
str, ok := v.(string)
if !ok {
return nil
}
u, err := uuid.Parse(str)
if err != nil {
return nil
}
return &u
}
// CreateTask creates a new task with the given description
func CreateTask(description string) (*Task, error) {
return CreateTaskWithModifier(description, nil)
}
// CreateTaskWithModifier creates a new task with the given description and applies modifiers
func CreateTaskWithModifier(description string, mod *Modifier) (*Task, error) {
now := timeNow()
task := &Task{
UUID: uuid.New(),
Status: StatusPending,
Description: description,
Priority: PriorityDefault,
Created: now,
Modified: now,
Tags: []string{},
}
// Apply modifiers before saving (for attributes)
if mod != nil {
if err := mod.ApplyToNew(task); err != nil {
return nil, err
}
}
if err := task.Save(); err != nil {
return nil, err
}
// Apply tags after saving (requires task.ID)
if mod != nil {
for _, tag := range mod.AddTags {
if err := task.AddTag(tag); err != nil {
return nil, err
}
}
// Update task to trigger change log with tags
if len(mod.AddTags) > 0 {
task.Modified = timeNow()
if err := task.Save(); err != nil {
return nil, err
}
}
}
return task, nil
}
// GetTask retrieves a task by UUID
func GetTask(taskUUID uuid.UUID) (*Task, error) {
db := GetDB()
if db == nil {
return nil, fmt.Errorf("database not initialized")
}
query := `
SELECT id, uuid, status, description, project, priority,
created, modified, start, end, due, scheduled, wait, until_date,
recurrence_duration, parent_uuid
FROM tasks
WHERE uuid = ?
`
task := &Task{}
var (
uuidStr string
project interface{}
created int64
modified int64
start interface{}
end interface{}
due interface{}
scheduled interface{}
wait interface{}
until interface{}
recurDuration interface{}
parentUUIDStr interface{}
)
err := db.QueryRow(query, taskUUID.String()).Scan(
&task.ID,
&uuidStr,
&task.Status,
&task.Description,
&project,
&task.Priority,
&created,
&modified,
&start,
&end,
&due,
&scheduled,
&wait,
&until,
&recurDuration,
&parentUUIDStr,
)
if err != nil {
return nil, fmt.Errorf("failed to get task: %w", err)
}
// Parse UUID
task.UUID, err = uuid.Parse(uuidStr)
if err != nil {
return nil, fmt.Errorf("failed to parse UUID: %w", err)
}
// Convert timestamps
task.Created = time.Unix(created, 0)
task.Modified = time.Unix(modified, 0)
// Convert nullable fields
task.Project = sqlToStringPtr(project)
task.Start = sqlToTime(start)
task.End = sqlToTime(end)
task.Due = sqlToTime(due)
task.Scheduled = sqlToTime(scheduled)
task.Wait = sqlToTime(wait)
task.Until = sqlToTime(until)
task.RecurrenceDuration = sqlToDuration(recurDuration)
task.ParentUUID = sqlToUUIDPtr(parentUUIDStr)
// Load tags
tags, err := task.GetTags()
if err != nil {
return nil, fmt.Errorf("failed to load tags: %w", err)
}
task.Tags = tags
return task, nil
}
// GetTasks retrieves all tasks with optional filtering
func GetTasks(filter *Filter) ([]*Task, error) {
db := GetDB()
if db == nil {
return nil, fmt.Errorf("database not initialized")
}
// Build WHERE clause from filter
whereClause := "1=1"
var args []interface{}
if filter != nil {
whereClause, args = filter.ToSQL()
}
query := fmt.Sprintf(`
SELECT id, uuid, status, description, project, priority,
created, modified, start, end, due, scheduled, wait, until_date,
recurrence_duration, parent_uuid
FROM tasks
WHERE %s
ORDER BY
CASE WHEN due IS NULL THEN 1 ELSE 0 END,
due ASC,
priority DESC
`, whereClause)
rows, err := db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("failed to query tasks: %w", err)
}
defer rows.Close()
tasks := []*Task{}
for rows.Next() {
task := &Task{}
var (
uuidStr string
project interface{}
created int64
modified int64
start interface{}
end interface{}
due interface{}
scheduled interface{}
wait interface{}
until interface{}
recurDuration interface{}
parentUUIDStr interface{}
)
err := rows.Scan(
&task.ID,
&uuidStr,
&task.Status,
&task.Description,
&project,
&task.Priority,
&created,
&modified,
&start,
&end,
&due,
&scheduled,
&wait,
&until,
&recurDuration,
&parentUUIDStr,
)
if err != nil {
return nil, fmt.Errorf("failed to scan task: %w", err)
}
// Parse UUID
task.UUID, err = uuid.Parse(uuidStr)
if err != nil {
return nil, fmt.Errorf("failed to parse UUID: %w", err)
}
// Convert timestamps
task.Created = time.Unix(created, 0)
task.Modified = time.Unix(modified, 0)
// Convert nullable fields
task.Project = sqlToStringPtr(project)
task.Start = sqlToTime(start)
task.End = sqlToTime(end)
task.Due = sqlToTime(due)
task.Scheduled = sqlToTime(scheduled)
task.Wait = sqlToTime(wait)
task.Until = sqlToTime(until)
task.RecurrenceDuration = sqlToDuration(recurDuration)
task.ParentUUID = sqlToUUIDPtr(parentUUIDStr)
// Load tags
tags, err := task.GetTags()
if err != nil {
return nil, fmt.Errorf("failed to load tags: %w", err)
}
task.Tags = tags
tasks = append(tasks, task)
}
return tasks, nil
}
// Save inserts or updates the task in the database
func (t *Task) Save() error {
db := GetDB()
if db == nil {
return fmt.Errorf("database not initialized")
}
t.Modified = timeNow()
// Check if task exists
var exists bool
err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM tasks WHERE uuid = ?)", t.UUID.String()).Scan(&exists)
if err != nil {
return fmt.Errorf("failed to check if task exists: %w", err)
}
if exists {
// Update existing task
query := `
UPDATE tasks SET
status = ?, description = ?, project = ?, priority = ?,
modified = ?, start = ?, end = ?, due = ?,
scheduled = ?, wait = ?, until_date = ?,
recurrence_duration = ?, parent_uuid = ?
WHERE uuid = ?
`
_, err = db.Exec(query,
t.Status,
t.Description,
stringPtrToSQL(t.Project),
t.Priority,
t.Modified.Unix(),
timeToSQL(t.Start),
timeToSQL(t.End),
timeToSQL(t.Due),
timeToSQL(t.Scheduled),
timeToSQL(t.Wait),
timeToSQL(t.Until),
durationToSQL(t.RecurrenceDuration),
uuidPtrToSQL(t.ParentUUID),
t.UUID.String(),
)
if err != nil {
return fmt.Errorf("failed to update task: %w", err)
}
} else {
// Insert new task
query := `
INSERT INTO tasks (
uuid, status, description, project, priority,
created, modified, start, end, due, scheduled, wait, until_date,
recurrence_duration, parent_uuid
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
result, err := db.Exec(query,
t.UUID.String(),
t.Status,
t.Description,
stringPtrToSQL(t.Project),
t.Priority,
t.Created.Unix(),
t.Modified.Unix(),
timeToSQL(t.Start),
timeToSQL(t.End),
timeToSQL(t.Due),
timeToSQL(t.Scheduled),
timeToSQL(t.Wait),
timeToSQL(t.Until),
durationToSQL(t.RecurrenceDuration),
uuidPtrToSQL(t.ParentUUID),
)
if err != nil {
return fmt.Errorf("failed to insert task: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("failed to get last insert id: %w", err)
}
t.ID = int(id)
}
return nil
}
// AddTag adds a tag to the task
func (t *Task) AddTag(tag string) error {
db := GetDB()
if db == nil {
return fmt.Errorf("database not initialized")
}
// Ensure task has been saved and has an ID
if t.ID == 0 {
return fmt.Errorf("task must be saved before adding tags")
}
_, err := db.Exec("INSERT OR IGNORE INTO tags (task_id, tag) VALUES (?, ?)", t.ID, tag)
if err != nil {
return fmt.Errorf("failed to add tag: %w", err)
}
// Update in-memory tags
for _, existingTag := range t.Tags {
if existingTag == tag {
return nil // Already exists
}
}
t.Tags = append(t.Tags, tag)
return nil
}
// RemoveTag removes a tag from the task
func (t *Task) RemoveTag(tag string) error {
db := GetDB()
if db == nil {
return fmt.Errorf("database not initialized")
}
if t.ID == 0 {
return fmt.Errorf("task must be saved before removing tags")
}
_, err := db.Exec("DELETE FROM tags WHERE task_id = ? AND tag = ?", t.ID, tag)
if err != nil {
return fmt.Errorf("failed to remove tag: %w", err)
}
// Update in-memory tags
newTags := []string{}
for _, existingTag := range t.Tags {
if existingTag != tag {
newTags = append(newTags, existingTag)
}
}
t.Tags = newTags
return nil
}
// GetTags retrieves all tags for the task
func (t *Task) GetTags() ([]string, error) {
db := GetDB()
if db == nil {
return nil, fmt.Errorf("database not initialized")
}
if t.ID == 0 {
return []string{}, nil
}
rows, err := db.Query("SELECT tag FROM tags WHERE task_id = ? ORDER BY tag", t.ID)
if err != nil {
return nil, fmt.Errorf("failed to get tags: %w", err)
}
defer rows.Close()
tags := []string{}
for rows.Next() {
var tag string
if err := rows.Scan(&tag); err != nil {
return nil, fmt.Errorf("failed to scan tag: %w", err)
}
tags = append(tags, tag)
}
return tags, nil
}
// 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 nil, err
}
// If this is a recurring instance, spawn next instance
if t.ParentUUID != nil {
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, nil
}
// Delete marks a task as deleted (soft delete)
func (t *Task) Delete(permanent bool) error {
db := GetDB()
if db == nil {
return fmt.Errorf("database not initialized")
}
if permanent {
// Hard delete - remove from database
_, err := db.Exec("DELETE FROM tasks WHERE uuid = ?", t.UUID.String())
if err != nil {
return fmt.Errorf("failed to delete task: %w", err)
}
} else {
// Soft delete - mark as deleted
t.Status = StatusDeleted
now := timeNow()
t.End = &now
if err := t.Save(); err != nil {
return err
}
}
return nil
}
// StartTask sets the start time for a task
func (t *Task) StartTask() error {
now := timeNow()
t.Start = &now
return t.Save()
}
// StopTask clears the start time for a task
func (t *Task) StopTask() error {
t.Start = nil
return t.Save()
}
// IsRecurringTemplate returns true if task is a recurring template
func (t *Task) IsRecurringTemplate() bool {
return t.Status == StatusRecurring && t.RecurrenceDuration != nil && t.ParentUUID == nil
}
// IsRecurringInstance returns true if task is a recurring instance
func (t *Task) IsRecurringInstance() bool {
return t.ParentUUID != nil
}
// PopulateUrgency computes and sets the Urgency field on the given tasks.
func PopulateUrgency(tasks ...*Task) {
cfg, _ := GetConfig()
coeffs := BuildUrgencyCoefficients(cfg)
for _, t := range tasks {
t.Urgency = t.CalculateUrgency(coeffs)
}
}