feat: add parse endpoint, refactor recurring tasks, and improve web task completion

Extract CreateRecurringTask into engine package for reuse by both CLI
and API. Add POST /tasks/parse endpoint for CLI-style input parsing.
Remove FK constraint on change_log to preserve history after task
deletion. Update web frontend to filter completed tasks from view and
add mock mode support for development.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 22:39:11 +01:00
parent 0352c22b4f
commit 78881e1b07
15 changed files with 2118 additions and 128 deletions
+2 -2
View File
@@ -173,13 +173,13 @@ func runMigrations() error {
);
-- Change log (key:value format like edit command)
-- No FK on task_uuid: change logs must survive task deletion
CREATE TABLE change_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_uuid TEXT NOT NULL,
change_type TEXT NOT NULL,
changed_at INTEGER NOT NULL,
data TEXT NOT NULL,
FOREIGN KEY (task_uuid) REFERENCES tasks(uuid) ON DELETE CASCADE
data TEXT NOT NULL
);
CREATE INDEX idx_change_log_timestamp ON change_log(changed_at);
+5 -2
View File
@@ -150,13 +150,16 @@ func TestFilterToSQL(t *testing.T) {
}
// Test tag filter
// Note: without a status filter, ToSQL adds an implicit template exclusion
// condition, so args will contain [StatusRecurring, "urgent"]
filter = &Filter{
IncludeTags: []string{"urgent"},
Attributes: map[string]string{},
}
where, args = filter.ToSQL()
if len(args) != 1 || args[0] != "urgent" {
t.Errorf("Expected tag 'urgent' in args, got %v", args)
if len(args) != 2 || args[1] != "urgent" {
t.Errorf("Expected tag 'urgent' as second arg (after template exclusion), got %v", args)
}
}
+88
View File
@@ -100,6 +100,94 @@ func CalculateNextDue(currentDue time.Time, recurrence time.Duration) time.Time
return currentDue.Add(recurrence)
}
// CreateRecurringTask creates a recurring task template and its first instance.
// It validates the recurrence pattern and due date, creates the template with
// StatusRecurring, then creates the first pending instance linked to it.
// Returns the first instance task.
func CreateRecurringTask(description string, mod *Modifier) (*Task, error) {
recurPattern := mod.SetAttributes["recur"]
if recurPattern == nil {
return nil, fmt.Errorf("no recurrence pattern specified")
}
if mod.SetAttributes["due"] == nil {
return nil, fmt.Errorf("recurring tasks require a due date (use due:YYYY-MM-DD or due:monday)")
}
duration, err := ParseRecurrencePattern(*recurPattern)
if err != nil {
return nil, fmt.Errorf("invalid recurrence pattern: %w", err)
}
now := time.Now()
template := &Task{
UUID: uuid.New(),
Status: StatusRecurring,
Description: description,
Priority: PriorityDefault,
Created: now,
Modified: now,
RecurrenceDuration: &duration,
Tags: []string{},
}
// Build modifier without the recur attribute
tempMod := &Modifier{
SetAttributes: make(map[string]*string),
AttributeOrder: []string{},
AddTags: mod.AddTags,
RemoveTags: mod.RemoveTags,
}
for _, key := range mod.AttributeOrder {
if key != "recur" {
tempMod.SetAttributes[key] = mod.SetAttributes[key]
tempMod.AttributeOrder = append(tempMod.AttributeOrder, key)
}
}
if err := tempMod.ApplyToNew(template); err != nil {
return nil, fmt.Errorf("failed to apply modifiers to template: %w", err)
}
if err := template.Save(); err != nil {
return nil, fmt.Errorf("failed to save template: %w", err)
}
for _, tag := range mod.AddTags {
if err := template.AddTag(tag); err != nil {
return nil, fmt.Errorf("failed to add tag to template: %w", err)
}
}
// Create first instance
instance := &Task{
UUID: uuid.New(),
Status: StatusPending,
Description: description,
Priority: template.Priority,
Created: now,
Modified: now,
ParentUUID: &template.UUID,
Due: template.Due,
Wait: template.Wait,
Scheduled: template.Scheduled,
Project: template.Project,
Tags: []string{},
}
if err := instance.Save(); err != nil {
return nil, fmt.Errorf("failed to save first instance: %w", err)
}
for _, tag := range template.Tags {
if err := instance.AddTag(tag); err != nil {
return nil, fmt.Errorf("failed to add tag to instance: %w", err)
}
}
return instance, nil
}
// SpawnNextInstance creates a new task instance from completed recurring task
func SpawnNextInstance(completedInstance *Task) error {
if completedInstance.ParentUUID == nil {