diff --git a/AGENTS.md b/AGENTS.md index eec64414..37ec69e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,8 +147,8 @@ CREATE TABLE tasks ( pr_url TEXT DEFAULT '', -- Pull request URL pr_number INTEGER DEFAULT 0, -- Pull request number scheduled_at DATETIME, -- When to next run - recurrence TEXT DEFAULT '', -- Recurrence pattern - last_run_at DATETIME, -- Last execution time + recurrence TEXT DEFAULT '', -- Deprecated recurrence pattern (kept for legacy display) + last_run_at DATETIME, -- Last scheduled execution time created_at DATETIME, updated_at DATETIME, started_at DATETIME, @@ -230,7 +230,7 @@ backlog → queued → processing → done ↘ blocked (needs input) blocked can return to processing via retry -done triggers memory extraction + recurring task reset +done triggers memory extraction ``` 1. **backlog** - Created but not yet started @@ -239,6 +239,8 @@ done triggers memory extraction + recurring task reset 4. **blocked** - Waiting for user input/clarification 5. **done** - Completed successfully +Recurring scheduling has been removed from TaskYou. Use external schedulers (cron, calendar apps, etc.) to invoke the CLI whenever a task should repeat. + ## Key Bindings (TUI) | Key | Action | @@ -286,7 +288,7 @@ The background executor (`internal/executor/executor.go`): - Supports real-time watching via subscriptions - Handles task suspension and resumption - Extracts memories from successful tasks -- Manages scheduled and recurring tasks +- Manages scheduled tasks (recurring runs must now be handled externally via CLI automation) ### Claude Code Integration diff --git a/cmd/task/main.go b/cmd/task/main.go index 5308020e..a1b94bb2 100644 --- a/cmd/task/main.go +++ b/cmd/task/main.go @@ -531,9 +531,6 @@ Examples: if t.ScheduledAt != nil { item["scheduled_at"] = t.ScheduledAt.Time.Format(time.RFC3339) } - if t.Recurrence != "" { - item["recurrence"] = t.Recurrence - } if t.LastRunAt != nil { item["last_run_at"] = t.LastRunAt.Time.Format(time.RFC3339) } @@ -623,10 +620,11 @@ Examples: } // Schedule indicator scheduleIndicator := "" - if t.IsRecurring() { - scheduleIndicator = lipgloss.NewStyle().Foreground(lipgloss.Color("#F59E0B")).Render("🔁 ") - } else if t.IsScheduled() { - scheduleIndicator = lipgloss.NewStyle().Foreground(lipgloss.Color("#F59E0B")).Render("⏰ ") + scheduleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#F59E0B")) + if t.IsScheduled() { + scheduleIndicator = scheduleStyle.Render("⏰ ") + } else if t.Recurrence != "" { + scheduleIndicator = scheduleStyle.Render("⚠ ") } prStatus := "" if showPR { @@ -721,9 +719,6 @@ Examples: if task.ScheduledAt != nil { output["scheduled_at"] = task.ScheduledAt.Time.Format(time.RFC3339) } - if task.Recurrence != "" { - output["recurrence"] = task.Recurrence - } if task.LastRunAt != nil { output["last_run_at"] = task.LastRunAt.Time.Format(time.RFC3339) } @@ -822,18 +817,19 @@ Examples: // Schedule info scheduleColor := lipgloss.Color("#F59E0B") // Orange for schedule - if task.IsRecurring() || task.IsScheduled() { + scheduleStyle := lipgloss.NewStyle().Foreground(scheduleColor) + if task.IsScheduled() || task.LastRunAt != nil || task.Recurrence != "" { fmt.Println() fmt.Println(boldStyle.Render("Schedule:")) - if task.Recurrence != "" { - recurrenceStyled := lipgloss.NewStyle().Foreground(scheduleColor).Render(task.Recurrence) - fmt.Printf(" Recurrence: %s\n", recurrenceStyled) - } if task.ScheduledAt != nil { - fmt.Printf(" Next run: %s\n", task.ScheduledAt.Time.Format("2006-01-02 15:04:05")) + fmt.Printf(" Next run: %s\n", scheduleStyle.Render(task.ScheduledAt.Time.Format("2006-01-02 15:04:05"))) } if task.LastRunAt != nil { - fmt.Printf(" Last run: %s\n", task.LastRunAt.Time.Format("2006-01-02 15:04:05")) + fmt.Printf(" Last run: %s\n", scheduleStyle.Render(task.LastRunAt.Time.Format("2006-01-02 15:04:05"))) + } + if task.Recurrence != "" { + note := fmt.Sprintf("Recurring schedules inside TaskYou were removed (legacy value: %s)", task.Recurrence) + fmt.Printf(" Note: %s\n", scheduleStyle.Render(note)) } } diff --git a/internal/db/sqlite.go b/internal/db/sqlite.go index 73b3befa..01681701 100644 --- a/internal/db/sqlite.go +++ b/internal/db/sqlite.go @@ -220,8 +220,8 @@ func (db *DB) migrate() error { `ALTER TABLE tasks ADD COLUMN port INTEGER DEFAULT 0`, // Scheduled task columns `ALTER TABLE tasks ADD COLUMN scheduled_at DATETIME`, // When to next run (null = not scheduled) - `ALTER TABLE tasks ADD COLUMN recurrence TEXT DEFAULT ''`, // Recurrence pattern (empty = one-time) - `ALTER TABLE tasks ADD COLUMN last_run_at DATETIME`, // When last executed (for recurring tasks) + `ALTER TABLE tasks ADD COLUMN recurrence TEXT DEFAULT ''`, // Deprecated recurrence pattern (empty = one-time) + `ALTER TABLE tasks ADD COLUMN last_run_at DATETIME`, // When last executed (for scheduled tasks) // Claude session tracking `ALTER TABLE tasks ADD COLUMN claude_session_id TEXT DEFAULT ''`, // Claude session ID for resuming conversations // Project color column diff --git a/internal/db/tasks.go b/internal/db/tasks.go index bf63ef7d..8c84bfb3 100644 --- a/internal/db/tasks.go +++ b/internal/db/tasks.go @@ -35,10 +35,10 @@ type Task struct { UpdatedAt LocalTime StartedAt *LocalTime CompletedAt *LocalTime - // Schedule fields for recurring/scheduled tasks + // Schedule fields for tasks with delayed execution ScheduledAt *LocalTime // When to next run (nil = not scheduled) - Recurrence string // Recurrence pattern: "", "hourly", "daily", "weekly", "monthly", or cron expression - LastRunAt *LocalTime // When last executed (for recurring tasks) + Recurrence string // Deprecated: no longer used (kept for backward compatibility) + LastRunAt *LocalTime // When last executed (for scheduled tasks) } // Task statuses @@ -75,25 +75,11 @@ func DefaultExecutor() string { return ExecutorClaude } -// Recurrence patterns for scheduled tasks -const ( - RecurrenceNone = "" // One-time scheduled task (or not scheduled) - RecurrenceHourly = "hourly" // Run every hour - RecurrenceDaily = "daily" // Run every day - RecurrenceWeekly = "weekly" // Run every week - RecurrenceMonthly = "monthly" // Run every month -) - // IsScheduled returns true if the task has a scheduled time set. func (t *Task) IsScheduled() bool { return t.ScheduledAt != nil && !t.ScheduledAt.Time.IsZero() } -// IsRecurring returns true if the task has a recurrence pattern. -func (t *Task) IsRecurring() bool { - return t.Recurrence != "" -} - // Port allocation constants const ( PortRangeStart = 3100 // First port in the allocation range @@ -696,7 +682,6 @@ func (db *DB) GetTasksWithBranches() ([]*Task, error) { // A task is due if: // - It has a scheduled_at time that is <= now // - It is not currently queued or processing (to avoid double-queueing) -// For recurring tasks, this allows blocked/done tasks to run again on schedule. func (db *DB) GetDueScheduledTasks() ([]*Task, error) { rows, err := db.Query(` SELECT id, title, body, status, type, project, COALESCE(executor, 'claude'), @@ -776,49 +761,8 @@ func (db *DB) GetScheduledTasks() ([]*Task, error) { return tasks, nil } -// CalculateNextRunTime calculates the next scheduled time based on recurrence pattern. -// Returns nil if recurrence is empty (one-time task). -func CalculateNextRunTime(recurrence string, fromTime time.Time) *LocalTime { - if recurrence == "" { - return nil - } - - var nextTime time.Time - switch recurrence { - case RecurrenceHourly: - nextTime = fromTime.Add(1 * time.Hour) - case RecurrenceDaily: - nextTime = fromTime.AddDate(0, 0, 1) - case RecurrenceWeekly: - nextTime = fromTime.AddDate(0, 0, 7) - case RecurrenceMonthly: - nextTime = fromTime.AddDate(0, 1, 0) - default: - // Unknown recurrence pattern - return nil - } - - return &LocalTime{Time: nextTime} -} - -// UpdateTaskSchedule updates only the schedule-related fields of a task. -func (db *DB) UpdateTaskSchedule(taskID int64, scheduledAt *LocalTime, recurrence string, lastRunAt *LocalTime) error { - _, err := db.Exec(` - UPDATE tasks SET - scheduled_at = ?, - recurrence = ?, - last_run_at = ?, - updated_at = CURRENT_TIMESTAMP - WHERE id = ? - `, scheduledAt, recurrence, lastRunAt, taskID) - if err != nil { - return fmt.Errorf("update task schedule: %w", err) - } - return nil -} - -// QueueScheduledTask queues a scheduled task and updates its schedule for recurring tasks. -// For recurring tasks, it sets the next scheduled_at time; for one-time tasks, it clears scheduled_at. +// QueueScheduledTask queues a scheduled task. +// The scheduled time is always cleared so the run happens once. func (db *DB) QueueScheduledTask(taskID int64) error { // Get the task first task, err := db.GetTask(taskID) @@ -831,15 +775,12 @@ func (db *DB) QueueScheduledTask(taskID int64) error { now := time.Now() - // Calculate next run time for recurring tasks - if task.IsRecurring() { - nextRun := CalculateNextRunTime(task.Recurrence, now) - task.ScheduledAt = nextRun - task.LastRunAt = &LocalTime{Time: now} - } else { - // One-time task - clear the schedule after queuing - task.ScheduledAt = nil - task.LastRunAt = &LocalTime{Time: now} + // Recurring tasks are no longer supported; treat everything as one-time + task.ScheduledAt = nil + task.LastRunAt = &LocalTime{Time: now} + if task.Recurrence != "" { + // Clear any legacy recurrence data so the UI no longer highlights it + task.Recurrence = "" } // Update status to queued diff --git a/internal/db/tasks_test.go b/internal/db/tasks_test.go index 02926f4a..4c2912b1 100644 --- a/internal/db/tasks_test.go +++ b/internal/db/tasks_test.go @@ -1155,7 +1155,6 @@ func TestScheduledTasks(t *testing.T) { Type: TypeCode, Project: "personal", ScheduledAt: &LocalTime{Time: scheduledTime}, - Recurrence: RecurrenceDaily, } if err := db.CreateTask(task); err != nil { t.Fatalf("failed to create task: %v", err) @@ -1169,11 +1168,8 @@ func TestScheduledTasks(t *testing.T) { if !retrieved.IsScheduled() { t.Error("expected task to be scheduled") } - if !retrieved.IsRecurring() { - t.Error("expected task to be recurring") - } - if retrieved.Recurrence != RecurrenceDaily { - t.Errorf("expected recurrence %q, got %q", RecurrenceDaily, retrieved.Recurrence) + if retrieved.Recurrence != "" { + t.Errorf("expected recurrence to remain empty, got %q", retrieved.Recurrence) } } @@ -1242,9 +1238,9 @@ func TestGetDueScheduledTasks(t *testing.T) { } } -func TestGetDueScheduledTasks_RecurringFromAnyStatus(t *testing.T) { - // Test that recurring tasks are picked up when due, regardless of current status - // (blocked, done, etc.) as long as they're not already queued/processing +func TestGetDueScheduledTasks_IgnoresStatus(t *testing.T) { + // Due tasks should be picked up regardless of status (blocked/done/etc.) + // as long as they're not already queued or processing tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "test.db") @@ -1257,40 +1253,37 @@ func TestGetDueScheduledTasks_RecurringFromAnyStatus(t *testing.T) { now := time.Now() - // Create a recurring task that's blocked and due + // Create a scheduled task that's blocked and due blockedTask := &Task{ - Title: "Blocked Recurring Task", + Title: "Blocked Scheduled Task", Status: StatusBlocked, Type: TypeCode, Project: "personal", ScheduledAt: &LocalTime{Time: now.Add(-1 * time.Hour)}, - Recurrence: RecurrenceDaily, } if err := db.CreateTask(blockedTask); err != nil { t.Fatalf("failed to create blocked task: %v", err) } - // Create a recurring task that's done and due + // Create a scheduled task that's done and due doneTask := &Task{ - Title: "Done Recurring Task", + Title: "Done Scheduled Task", Status: StatusDone, Type: TypeCode, Project: "personal", ScheduledAt: &LocalTime{Time: now.Add(-30 * time.Minute)}, - Recurrence: RecurrenceDaily, } if err := db.CreateTask(doneTask); err != nil { t.Fatalf("failed to create done task: %v", err) } - // Create a recurring task that's queued (should NOT be returned to avoid double-queue) + // Create a scheduled task that's queued (should NOT be returned to avoid double-queue) queuedTask := &Task{ - Title: "Queued Recurring Task", + Title: "Queued Scheduled Task", Status: StatusQueued, Type: TypeCode, Project: "personal", ScheduledAt: &LocalTime{Time: now.Add(-15 * time.Minute)}, - Recurrence: RecurrenceDaily, } if err := db.CreateTask(queuedTask); err != nil { t.Fatalf("failed to create queued task: %v", err) @@ -1323,10 +1316,10 @@ func TestGetDueScheduledTasks_RecurringFromAnyStatus(t *testing.T) { } if !foundBlocked { - t.Error("blocked recurring task should be returned when due") + t.Error("blocked scheduled task should be returned when due") } if !foundDone { - t.Error("done recurring task should be returned when due") + t.Error("done scheduled task should be returned when due") } } @@ -1351,7 +1344,6 @@ func TestQueueScheduledTask(t *testing.T) { Type: TypeCode, Project: "personal", ScheduledAt: &LocalTime{Time: now}, - Recurrence: RecurrenceNone, } if err := db.CreateTask(oneTimeTask); err != nil { t.Fatalf("failed to create one-time task: %v", err) @@ -1376,79 +1368,39 @@ func TestQueueScheduledTask(t *testing.T) { if retrieved.LastRunAt == nil { t.Error("expected last_run_at to be set") } + if retrieved.Recurrence != "" { + t.Errorf("expected recurrence to stay empty, got %q", retrieved.Recurrence) + } - // Test recurring scheduled task - recurringTask := &Task{ - Title: "Recurring Task", + // Legacy recurring tasks should be treated as one-time and cleared + legacyTask := &Task{ + Title: "Legacy Recurring Task", Status: StatusBacklog, Type: TypeCode, Project: "personal", ScheduledAt: &LocalTime{Time: now}, - Recurrence: RecurrenceHourly, + Recurrence: "daily", } - if err := db.CreateTask(recurringTask); err != nil { - t.Fatalf("failed to create recurring task: %v", err) + if err := db.CreateTask(legacyTask); err != nil { + t.Fatalf("failed to create legacy recurring task: %v", err) } - // Queue the recurring task - if err := db.QueueScheduledTask(recurringTask.ID); err != nil { - t.Fatalf("failed to queue recurring task: %v", err) + if err := db.QueueScheduledTask(legacyTask.ID); err != nil { + t.Fatalf("failed to queue legacy task: %v", err) } - // Verify it was queued and next schedule set - retrieved, err = db.GetTask(recurringTask.ID) + retrieved, err = db.GetTask(legacyTask.ID) if err != nil { - t.Fatalf("failed to get task: %v", err) + t.Fatalf("failed to get legacy task: %v", err) } - if retrieved.Status != StatusQueued { - t.Errorf("expected status %q, got %q", StatusQueued, retrieved.Status) + if retrieved.ScheduledAt != nil { + t.Error("expected scheduled_at to be cleared for legacy recurring task") } - if retrieved.ScheduledAt == nil { - t.Error("expected scheduled_at to be set for next run") - } else { - // Next run should be approximately 1 hour in the future - expectedNext := now.Add(1 * time.Hour) - diff := retrieved.ScheduledAt.Time.Sub(expectedNext) - if diff > 5*time.Second || diff < -5*time.Second { - t.Errorf("expected next schedule ~%v, got %v", expectedNext, retrieved.ScheduledAt.Time) - } + if retrieved.Recurrence != "" { + t.Errorf("expected recurrence to be cleared, got %q", retrieved.Recurrence) } if retrieved.LastRunAt == nil { - t.Error("expected last_run_at to be set") - } -} - -func TestCalculateNextRunTime(t *testing.T) { - now := time.Date(2024, 6, 15, 10, 30, 0, 0, time.Local) - - tests := []struct { - recurrence string - expected time.Time - nilResult bool - }{ - {RecurrenceNone, time.Time{}, true}, - {RecurrenceHourly, now.Add(1 * time.Hour), false}, - {RecurrenceDaily, now.AddDate(0, 0, 1), false}, - {RecurrenceWeekly, now.AddDate(0, 0, 7), false}, - {RecurrenceMonthly, now.AddDate(0, 1, 0), false}, - {"unknown", time.Time{}, true}, - } - - for _, tc := range tests { - t.Run(tc.recurrence, func(t *testing.T) { - result := CalculateNextRunTime(tc.recurrence, now) - if tc.nilResult { - if result != nil { - t.Errorf("expected nil, got %v", result) - } - } else { - if result == nil { - t.Error("expected non-nil result") - } else if !result.Time.Equal(tc.expected) { - t.Errorf("expected %v, got %v", tc.expected, result.Time) - } - } - }) + t.Error("expected last_run_at to be set for legacy task") } } diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 314ebeec..c6df404d 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -627,50 +627,6 @@ func (e *Executor) worker(ctx context.Context) { } } -// handleRecurringTaskCompletion resets a recurring task to backlog after it completes. -// This allows it to be picked up again at the next scheduled time. -func (e *Executor) handleRecurringTaskCompletion(task *db.Task) { - // Reload task to get current state - currentTask, err := e.db.GetTask(task.ID) - if err != nil || currentTask == nil { - return - } - - // Only handle recurring tasks - if !currentTask.IsRecurring() { - return - } - - // Calculate next run time if not already set - if currentTask.ScheduledAt == nil { - nextRun := db.CalculateNextRunTime(currentTask.Recurrence, time.Now()) - currentTask.ScheduledAt = nextRun - } - - // Reset to backlog so it can be queued again at next scheduled time - currentTask.Status = db.StatusBacklog - currentTask.LastRunAt = &db.LocalTime{Time: time.Now()} - - if err := e.db.UpdateTask(currentTask); err != nil { - e.logger.Error("Failed to reset recurring task", "id", task.ID, "error", err) - return - } - - e.logLine(task.ID, "system", "───────────────────────────────────────────────────────") - e.logLine(task.ID, "system", fmt.Sprintf("✅ RECURRING RUN COMPLETED - %s", time.Now().Format("Jan 2, 2006 3:04:05 PM"))) - e.logLine(task.ID, "system", fmt.Sprintf(" Next run scheduled for: %s (%s)", - currentTask.ScheduledAt.Format("Jan 2, 2006 3:04:05 PM"), - currentTask.Recurrence)) - e.logLine(task.ID, "system", "───────────────────────────────────────────────────────") - - // Broadcast the status change - e.broadcastTaskEvent(TaskEvent{ - Type: "status_changed", - Task: currentTask, - TaskID: task.ID, - }) -} - // queueDueScheduledTasks checks for scheduled tasks that are due and queues them. func (e *Executor) queueDueScheduledTasks() { tasks, err := e.db.GetDueScheduledTasks() @@ -685,14 +641,13 @@ func (e *Executor) queueDueScheduledTasks() { // Log the scheduled execution with a clear separator e.logLine(task.ID, "system", "") e.logLine(task.ID, "system", "═══════════════════════════════════════════════════════") - if task.IsRecurring() { - e.logLine(task.ID, "system", fmt.Sprintf("🔁 RECURRING RUN STARTED (%s) - %s", task.Recurrence, time.Now().Format("Jan 2, 2006 3:04:05 PM"))) - } else { - e.logLine(task.ID, "system", fmt.Sprintf("⏰ SCHEDULED RUN STARTED - %s", time.Now().Format("Jan 2, 2006 3:04:05 PM"))) + e.logLine(task.ID, "system", fmt.Sprintf("⏰ SCHEDULED RUN STARTED - %s", time.Now().Format("Jan 2, 2006 3:04:05 PM"))) + if task.Recurrence != "" { + e.logLine(task.ID, "system", "Recurring schedules are no longer supported inside TaskYou. This run will not repeat automatically.") } e.logLine(task.ID, "system", "═══════════════════════════════════════════════════════") - // Queue the task (this also updates the next run time for recurring tasks) + // Queue the task if err := e.db.QueueScheduledTask(task.ID); err != nil { e.logger.Error("Failed to queue scheduled task", "id", task.ID, "error", err) continue @@ -882,9 +837,6 @@ func (e *Executor) executeTask(ctx context.Context, task *db.Task) { retryFeedback, _ := e.db.GetRetryFeedback(task.ID) isRetry := retryFeedback != "" - // Check if this is a recurring task that has run before - isRecurringRun := task.IsRecurring() && task.LastRunAt != nil - // Build prompt based on task type prompt := e.buildPrompt(task, attachmentPaths) @@ -924,12 +876,6 @@ func (e *Executor) executeTask(ctx context.Context, task *db.Task) { e.logLine(task.ID, "system", fmt.Sprintf("Resuming previous session with feedback (executor: %s)", executorName)) execResult := taskExecutor.Resume(taskCtx, task, workDir, prompt, feedbackWithAttachments) result = execResult.toInternal() - } else if isRecurringRun { - // Resume session with recurring message that includes full task details - recurringPrompt := e.buildRecurringPrompt(task, attachmentPaths) - e.logLine(task.ID, "system", fmt.Sprintf("Recurring task (%s) - resuming session (executor: %s)", task.Recurrence, executorName)) - execResult := taskExecutor.Resume(taskCtx, task, workDir, prompt, recurringPrompt) - result = execResult.toInternal() } else { e.logLine(task.ID, "system", fmt.Sprintf("Starting new session (executor: %s)", executorName)) execResult := taskExecutor.Execute(taskCtx, task, workDir, prompt) @@ -965,9 +911,6 @@ func (e *Executor) executeTask(ctx context.Context, task *db.Task) { // NOTE: We intentionally do NOT kill the executor here - keep it running so user can // easily retry/resume the task. Old done task executors are cleaned up after 2h // by the cleanupOrphanedClaudes routine. - - // Handle recurring task: reset to backlog for next run - e.handleRecurringTaskCompletion(task) } else if result.Success { e.updateStatus(task.ID, db.StatusDone) e.logLine(task.ID, "system", "Task completed successfully") @@ -989,9 +932,6 @@ func (e *Executor) executeTask(ctx context.Context, task *db.Task) { e.logger.Error("Memory extraction failed", "task", task.ID, "error", err) } }() - - // Handle recurring task: reset to backlog for next run - e.handleRecurringTaskCompletion(task) } else if result.NeedsInput { e.updateStatus(task.ID, db.StatusBlocked) // Log the question with special type so UI can display it @@ -1204,26 +1144,6 @@ The task system will automatically detect your status. return prompt.String() } -// buildRecurringPrompt builds a message for recurring task runs. -// Includes full task details since the session history may be long. -func (e *Executor) buildRecurringPrompt(task *db.Task, attachmentPaths []string) string { - var sb strings.Builder - - sb.WriteString(fmt.Sprintf("=== Recurring Task Triggered (%s) ===\n\n", task.Recurrence)) - sb.WriteString(fmt.Sprintf("It's time for your %s task.\n", task.Recurrence)) - if task.LastRunAt != nil { - sb.WriteString(fmt.Sprintf("Last run: %s\n\n", task.LastRunAt.Format("2006-01-02 15:04"))) - } - - // Include full task details (since history may be long) - sb.WriteString("--- Task Details ---\n\n") - sb.WriteString(e.buildPrompt(task, attachmentPaths)) - - sb.WriteString("\n\nPlease work on this task now.") - - return sb.String() -} - // applyTemplateSubstitutions replaces template placeholders in task type instructions. func (e *Executor) applyTemplateSubstitutions(template string, task *db.Task, projectInstructions, memories, similarTasks, attachments, conversationHistory string) string { result := template diff --git a/internal/ui/detail.go b/internal/ui/detail.go index 182476dc..fcc5ed09 100644 --- a/internal/ui/detail.go +++ b/internal/ui/detail.go @@ -1979,8 +1979,8 @@ func (m *DetailModel) renderHeader() string { meta.WriteString(errorStyle.Render("⚠ " + m.paneError)) } - // Schedule info - show if scheduled OR recurring - if t.IsScheduled() || t.IsRecurring() { + // Schedule info - show if scheduled + if t.IsScheduled() { meta.WriteString(" ") var scheduleStyle lipgloss.Style if m.focused { @@ -1995,21 +1995,10 @@ func (m *DetailModel) renderHeader() string { Foreground(dimmedFg) } icon := "⏰" - if t.IsRecurring() { - icon = "🔁" - } - var scheduleText string - if t.IsScheduled() { - scheduleText = icon + " " + formatScheduleTime(t.ScheduledAt.Time) - } else { - scheduleText = icon - } - if t.IsRecurring() { - scheduleText += " (" + t.Recurrence + ")" - } + scheduleText := icon + " " + formatScheduleTime(t.ScheduledAt.Time) meta.WriteString(scheduleStyle.Render(scheduleText)) - // Show last run info for recurring tasks + // Show last run info when available if t.LastRunAt != nil { var lastRunStyle lipgloss.Style if m.focused { @@ -2022,6 +2011,22 @@ func (m *DetailModel) renderHeader() string { lastRunText := fmt.Sprintf(" Last: %s", t.LastRunAt.Time.Format("Jan 2 3:04pm")) meta.WriteString(lastRunStyle.Render(lastRunText)) } + } else if t.Recurrence != "" { + // Legacy recurring tasks: show a subtle warning so users know it won't repeat + meta.WriteString(" ") + var warnStyle lipgloss.Style + if m.focused { + warnStyle = lipgloss.NewStyle(). + Padding(0, 1). + Background(lipgloss.Color("214")). + Foreground(lipgloss.Color("#000000")) + } else { + warnStyle = lipgloss.NewStyle(). + Padding(0, 1). + Background(dimmedBg). + Foreground(dimmedFg) + } + meta.WriteString(warnStyle.Render("Recurring schedules removed")) } // PR link if available diff --git a/internal/ui/form.go b/internal/ui/form.go index 3f8a9b2d..e844d8b3 100644 --- a/internal/ui/form.go +++ b/internal/ui/form.go @@ -30,16 +30,16 @@ const ( FieldType FieldExecutor FieldSchedule - FieldRecurrence + FieldCount ) // FormModel represents the new task form. type FormModel struct { - db *db.DB - width int - height int - submitted bool - cancelled bool + db *db.DB + width int + height int + submitted bool + cancelled bool isEdit bool // true when editing an existing task originalProject string // original project when editing (to detect project changes) @@ -53,20 +53,17 @@ type FormModel struct { scheduleInput textinput.Model // For entering schedule time (e.g., "1h", "2h30m", "tomorrow 9am") // Select values - project string - projectIdx int - projects []string - taskType string - typeIdx int - types []string - executor string // "claude", "codex", "gemini" - executorIdx int - executors []string - queue bool - attachments []string // Parsed file paths - recurrence string // "", "hourly", "daily", "weekly", "monthly" - recurrenceIdx int - recurrences []string + project string + projectIdx int + projects []string + taskType string + typeIdx int + types []string + executor string // "claude", "codex", "gemini" + executorIdx int + executors []string + queue bool + attachments []string // Parsed file paths // Magic paste fields (populated when pasting URLs) prURL string // GitHub PR URL if pasted @@ -142,8 +139,6 @@ func NewEditFormModel(database *db.DB, task *db.Task, width, height int) *FormMo executor: executor, executors: []string{db.ExecutorClaude, db.ExecutorCodex, db.ExecutorGemini}, isEdit: true, - recurrence: task.Recurrence, - recurrences: []string{"", db.RecurrenceHourly, db.RecurrenceDaily, db.RecurrenceWeekly, db.RecurrenceMonthly}, prURL: task.PRURL, prNumber: task.PRNumber, autocompleteSvc: autocompleteSvc, @@ -230,14 +225,6 @@ func NewEditFormModel(database *db.DB, task *db.Task, width, height int) *FormMo m.scheduleInput.SetValue(task.ScheduledAt.Format("2006-01-02 15:04")) } - // Set recurrence index - for i, r := range m.recurrences { - if r == task.Recurrence { - m.recurrenceIdx = i - break - } - } - // Attachments input m.attachmentsInput = textinput.New() m.attachmentsInput.Placeholder = "Files (drag anywhere or type paths)" @@ -273,7 +260,6 @@ func NewFormModel(database *db.DB, width, height int, workingDir string) *FormMo focused: FieldProject, executor: db.DefaultExecutor(), executors: []string{db.ExecutorClaude, db.ExecutorCodex, db.ExecutorGemini}, - recurrences: []string{"", db.RecurrenceHourly, db.RecurrenceDaily, db.RecurrenceWeekly, db.RecurrenceMonthly}, autocompleteSvc: autocompleteSvc, autocompleteEnabled: autocompleteEnabled, taskRefAutocomplete: NewTaskRefAutocompleteModel(database, width-24), @@ -571,7 +557,7 @@ func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { break } // On last field, submit - if m.focused == FieldRecurrence { + if m.focused == FieldSchedule { m.parseAttachments() m.submitted = true return m, nil @@ -598,11 +584,6 @@ func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.executor = m.executors[m.executorIdx] return m, nil } - if m.focused == FieldRecurrence { - m.recurrenceIdx = (m.recurrenceIdx - 1 + len(m.recurrences)) % len(m.recurrences) - m.recurrence = m.recurrences[m.recurrenceIdx] - return m, nil - } case "right": if m.focused == FieldProject { @@ -622,11 +603,6 @@ func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.executor = m.executors[m.executorIdx] return m, nil } - if m.focused == FieldRecurrence { - m.recurrenceIdx = (m.recurrenceIdx + 1) % len(m.recurrences) - m.recurrence = m.recurrences[m.recurrenceIdx] - return m, nil - } // Alt+Arrow keys for word/paragraph movement in text fields case "alt+left": @@ -660,7 +636,7 @@ func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { default: // Type-to-select for selector fields - if m.focused == FieldProject || m.focused == FieldType || m.focused == FieldExecutor || m.focused == FieldRecurrence { + if m.focused == FieldProject || m.focused == FieldType || m.focused == FieldExecutor { key := msg.String() if len(key) == 1 && unicode.IsLetter(rune(key[0])) { m.selectByPrefix(strings.ToLower(key)) @@ -859,18 +835,6 @@ func (m *FormModel) selectByPrefix(prefix string) { return } } - case FieldRecurrence: - for i, r := range m.recurrences { - label := r - if label == "" { - label = "none" - } - if strings.HasPrefix(strings.ToLower(label), prefix) { - m.recurrenceIdx = i - m.recurrence = r - return - } - } } } @@ -919,14 +883,14 @@ func (m *FormModel) loadLastExecutorForProject() { func (m *FormModel) focusNext() { m.blurAll() m.cancelAutocomplete() - m.focused = (m.focused + 1) % (FieldRecurrence + 1) + m.focused = (m.focused + 1) % FieldCount m.focusCurrent() } func (m *FormModel) focusPrev() { m.blurAll() m.cancelAutocomplete() - m.focused = (m.focused - 1 + FieldRecurrence + 1) % (FieldRecurrence + 1) + m.focused = (m.focused - 1 + FieldCount) % FieldCount m.focusCurrent() } @@ -1258,23 +1222,6 @@ func (m *FormModel) View() string { b.WriteString(cursor + " " + labelStyle.Render("Schedule") + m.scheduleInput.View()) b.WriteString("\n\n") - // Recurrence selector - cursor = " " - if m.focused == FieldRecurrence { - cursor = cursorStyle.Render("▸") - } - // Build recurrence labels from m.recurrences (replace empty string with "none") - recurrenceLabels := make([]string, len(m.recurrences)) - for i, r := range m.recurrences { - if r == "" { - recurrenceLabels[i] = "none" - } else { - recurrenceLabels[i] = r - } - } - b.WriteString(cursor + " " + labelStyle.Render("Recurrence") + m.renderSelector(recurrenceLabels, m.recurrenceIdx, m.focused == FieldRecurrence, selectedStyle, optionStyle, dimStyle)) - b.WriteString("\n\n") - // Cancel confirmation message if m.showCancelConfirm { confirmStyle := lipgloss.NewStyle(). @@ -1336,15 +1283,14 @@ func (m *FormModel) GetDBTask() *db.Task { } task := &db.Task{ - Title: m.titleInput.Value(), - Body: m.bodyInput.Value(), - Status: status, - Type: m.taskType, - Project: m.project, - Executor: m.executor, - Recurrence: m.recurrence, - PRURL: m.prURL, - PRNumber: m.prNumber, + Title: m.titleInput.Value(), + Body: m.bodyInput.Value(), + Status: status, + Type: m.taskType, + Project: m.project, + Executor: m.executor, + PRURL: m.prURL, + PRNumber: m.prNumber, } // Parse schedule time @@ -1498,7 +1444,7 @@ func (m *FormModel) calculateBodyHeight() int { // Maximum height is 50% of screen height // Account for other form elements: header(2) + title(2) + body label(1) + project(2) + - // type(2) + schedule(2) + recurrence(2) + attachments(2) + help(1) + padding/borders(~6) = ~22 lines + // type(2) + schedule(2) + attachments(2) + help(1) + padding/borders(~6) = ~19 lines formOverhead := 22 maxHeight := (m.height - formOverhead) / 2 if maxHeight < minHeight { diff --git a/internal/ui/kanban.go b/internal/ui/kanban.go index 2c87e2ba..52227bfb 100644 --- a/internal/ui/kanban.go +++ b/internal/ui/kanban.go @@ -166,7 +166,7 @@ func (k *KanbanBoard) distributeTasksToColumns() { } } - // Sort each column to put recurring tasks at the bottom + // Sort each column so pinned tasks stay at the top for i := range k.columns { k.sortColumnTasks(i) } @@ -175,8 +175,8 @@ func (k *KanbanBoard) distributeTasksToColumns() { k.clampSelection() } -// sortColumnTasks sorts tasks within a column, putting recurring tasks at the bottom. -// Non-recurring tasks maintain their original order (by creation date from DB query). +// sortColumnTasks keeps pinned tasks at the top of a column while preserving +// the existing order for everything else. func (k *KanbanBoard) sortColumnTasks(colIdx int) { if colIdx < 0 || colIdx >= len(k.columns) { return @@ -186,24 +186,19 @@ func (k *KanbanBoard) sortColumnTasks(colIdx int) { return } - // Stable sort: pinned tasks stay at top, then non-recurring, then recurring - var pinned, nonRecurring, recurring []*db.Task + // Stable sort: pinned tasks stay at top, then everything else in original order + var pinned, rest []*db.Task for _, task := range tasks { if task.Pinned { pinned = append(pinned, task) continue } - if task.IsRecurring() { - recurring = append(recurring, task) - } else { - nonRecurring = append(nonRecurring, task) - } + rest = append(rest, task) } - // Reconstruct the slice with pinned first, then non-recurring, then recurring + // Reconstruct the slice with pinned first ordered := append([]*db.Task{}, pinned...) - ordered = append(ordered, nonRecurring...) - ordered = append(ordered, recurring...) + ordered = append(ordered, rest...) k.columns[colIdx].Tasks = ordered } @@ -865,19 +860,17 @@ func (k *KanbanBoard) renderTaskCard(task *db.Task, width int, isSelected bool) b.WriteString(processStyle.Render("●")) // Green dot for running process } - // Schedule indicator - show if scheduled OR recurring - if task.IsScheduled() || task.IsRecurring() { + // Schedule indicator - show if scheduled or warn about legacy recurrence + if task.IsScheduled() { scheduleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("214")) // Orange for schedule - var scheduleText string - if task.IsScheduled() { - scheduleText = formatScheduleTime(task.ScheduledAt.Time) - } + scheduleText := formatScheduleTime(task.ScheduledAt.Time) icon := "⏰" - if task.IsRecurring() { - icon = "🔁" // Use repeat icon to indicate recurring task - } b.WriteString(" ") b.WriteString(scheduleStyle.Render(icon + scheduleText)) + } else if task.Recurrence != "" { + warnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("214")) + b.WriteString(" ") + b.WriteString(warnStyle.Render("⚠")) } // Pin indicator @@ -906,8 +899,6 @@ func (k *KanbanBoard) renderTaskCard(task *db.Task, width int, isSelected bool) Padding(0, 1). MarginBottom(1) - // Recurring tasks are de-emphasized visually (dimmed) when not selected - isRecurring := task.IsRecurring() // Check if task has an active input notification needsInput := k.NeedsInput(task.ID) @@ -928,14 +919,6 @@ func (k *KanbanBoard) renderTaskCard(task *db.Task, width int, isSelected bool) BorderStyle(lipgloss.NormalBorder()). BorderForeground(ColorWarning). MarginBottom(0) - } else if isRecurring { - // Recurring tasks are dimmed to de-emphasize them - cardStyle = cardStyle. - Foreground(ColorMuted). - BorderBottom(true). - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(ColorMuted). - MarginBottom(0) } else { // Non-selected cards have a subtle bottom border for separation cardStyle = cardStyle. diff --git a/internal/ui/kanban_test.go b/internal/ui/kanban_test.go index 45a38da8..965b96c7 100644 --- a/internal/ui/kanban_test.go +++ b/internal/ui/kanban_test.go @@ -590,96 +590,6 @@ func TestPinnedTasksStayVisibleWhenScrolling(t *testing.T) { } } -func TestKanbanBoard_RecurringTasksAtBottom(t *testing.T) { - board := NewKanbanBoard(100, 50) - - // Mix of recurring and non-recurring tasks in the same column - tasks := []*db.Task{ - {ID: 1, Title: "Regular Task 1", Status: db.StatusBacklog, Recurrence: ""}, - {ID: 2, Title: "Recurring Task (daily)", Status: db.StatusBacklog, Recurrence: "daily"}, - {ID: 3, Title: "Regular Task 2", Status: db.StatusBacklog, Recurrence: ""}, - {ID: 4, Title: "Recurring Task (weekly)", Status: db.StatusBacklog, Recurrence: "weekly"}, - {ID: 5, Title: "Regular Task 3", Status: db.StatusBacklog, Recurrence: ""}, - } - board.SetTasks(tasks) - - // Get the backlog column (index 0) - col := board.columns[0] - - // Verify order: all non-recurring tasks should come before recurring tasks - // Expected order: 1, 3, 5 (non-recurring) then 2, 4 (recurring) - expectedOrder := []int64{1, 3, 5, 2, 4} - - if len(col.Tasks) != len(expectedOrder) { - t.Fatalf("Expected %d tasks in backlog, got %d", len(expectedOrder), len(col.Tasks)) - } - - for i, task := range col.Tasks { - if task.ID != expectedOrder[i] { - t.Errorf("Task at position %d: expected ID %d, got %d", i, expectedOrder[i], task.ID) - } - } - - // Verify that recurring tasks are at the end - nonRecurringCount := 0 - for _, task := range col.Tasks { - if !task.IsRecurring() { - nonRecurringCount++ - } - } - - // All non-recurring tasks should be in the first positions - for i := 0; i < nonRecurringCount; i++ { - if col.Tasks[i].IsRecurring() { - t.Errorf("Expected non-recurring task at position %d, but found recurring task (ID %d)", i, col.Tasks[i].ID) - } - } - - // All recurring tasks should be at the end - for i := nonRecurringCount; i < len(col.Tasks); i++ { - if !col.Tasks[i].IsRecurring() { - t.Errorf("Expected recurring task at position %d, but found non-recurring task (ID %d)", i, col.Tasks[i].ID) - } - } -} - -func TestKanbanBoard_RecurringTasksSortingAcrossColumns(t *testing.T) { - board := NewKanbanBoard(100, 50) - - // Tasks in multiple columns with recurring ones - tasks := []*db.Task{ - {ID: 1, Title: "Backlog Regular", Status: db.StatusBacklog, Recurrence: ""}, - {ID: 2, Title: "Backlog Recurring", Status: db.StatusBacklog, Recurrence: "daily"}, - {ID: 3, Title: "InProgress Regular", Status: db.StatusQueued, Recurrence: ""}, - {ID: 4, Title: "InProgress Recurring", Status: db.StatusQueued, Recurrence: "weekly"}, - } - board.SetTasks(tasks) - - // Check backlog column (index 0) - backlogCol := board.columns[0] - if len(backlogCol.Tasks) != 2 { - t.Fatalf("Expected 2 tasks in backlog, got %d", len(backlogCol.Tasks)) - } - if backlogCol.Tasks[0].ID != 1 { - t.Errorf("Backlog first task: expected ID 1, got %d", backlogCol.Tasks[0].ID) - } - if backlogCol.Tasks[1].ID != 2 { - t.Errorf("Backlog second task: expected ID 2, got %d", backlogCol.Tasks[1].ID) - } - - // Check in-progress column (index 1) - inProgressCol := board.columns[1] - if len(inProgressCol.Tasks) != 2 { - t.Fatalf("Expected 2 tasks in in-progress, got %d", len(inProgressCol.Tasks)) - } - if inProgressCol.Tasks[0].ID != 3 { - t.Errorf("InProgress first task: expected ID 3, got %d", inProgressCol.Tasks[0].ID) - } - if inProgressCol.Tasks[1].ID != 4 { - t.Errorf("InProgress second task: expected ID 4, got %d", inProgressCol.Tasks[1].ID) - } -} - // TestKanbanBoard_FirstTaskClickable is a regression test for the bug where // clicking on the first task in a kanban column did not work because the // click handler expected the task area to start at y=4 instead of y=2.