package store import ( "database/sql" "fmt" "time" "task-dashboard/internal/config" "task-dashboard/internal/models" ) // defaultChainProjectColor is used for the project a chain auto-creates for // itself, since the chain-creation API takes no color -- unlike // HandleWidgetProjectsCreate, where the client always supplies one. const defaultChainProjectColor = "#8B5CF6" // CreateChain creates a backing project (per the design's "a chain is // effectively a project with strict sequential unlocking"), a task_chains // row, and one native_tasks row per entry in tasks. Position 0 is created // unlocked with due_date = now; the rest are locked with no due date. All // in one transaction so a partial chain never exists. func (s *Store) CreateChain(name string, tasks []models.ChainTaskInput) (*models.Chain, error) { if len(tasks) == 0 { return nil, fmt.Errorf("chain must have at least one task") } project, err := s.CreateProject(name, defaultChainProjectColor) if err != nil { return nil, err } chainID := newTaskID() now := config.Now() tx, err := s.db.Begin() if err != nil { return nil, err } defer func() { _ = tx.Rollback() }() if _, err := tx.Exec(` INSERT INTO task_chains (id, project_id, status, created_at) VALUES (?, ?, 'active', ?) `, chainID, project.ID, now); err != nil { return nil, err } for i, t := range tasks { unlocked := i == 0 var dueDate *time.Time if unlocked { dueDate = &now } priority := t.Priority if priority == 0 { priority = 1 } if _, err := tx.Exec(` INSERT INTO native_tasks (id, content, description, project_name, project_id, priority, due_date, chain_id, chain_position, chain_unlocked, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, newTaskID(), t.Content, t.Description, project.Name, project.ID, priority, dueDate, chainID, i, unlocked, now, now); err != nil { return nil, err } } if err := tx.Commit(); err != nil { return nil, err } return &models.Chain{ID: chainID, ProjectID: project.ID, Status: "active", CreatedAt: now}, nil } // GetChain returns a single chain by id, or ErrNativeTaskNotFound. func (s *Store) GetChain(id string) (*models.Chain, error) { var c models.Chain err := s.db.QueryRow(` SELECT id, project_id, status, created_at FROM task_chains WHERE id = ? `, id).Scan(&c.ID, &c.ProjectID, &c.Status, &c.CreatedAt) if err == sql.ErrNoRows { return nil, ErrNativeTaskNotFound } if err != nil { return nil, err } return &c, nil } // GetChains returns every active or paused chain, oldest first. Completed // and abandoned chains are excluded -- they're done, not part of "current // work" surfaces like the Tasks tab. func (s *Store) GetChains() ([]models.Chain, error) { rows, err := s.db.Query(` SELECT id, project_id, status, created_at FROM task_chains WHERE status IN ('active', 'paused') ORDER BY created_at ASC `) if err != nil { return nil, err } defer func() { _ = rows.Close() }() var chains []models.Chain for rows.Next() { var c models.Chain if err := rows.Scan(&c.ID, &c.ProjectID, &c.Status, &c.CreatedAt); err != nil { return nil, err } chains = append(chains, c) } return chains, rows.Err() } // GetChainTasks returns every task in the chain, locked and unlocked both, // ordered by chain_position -- the full checklist view. func (s *Store) GetChainTasks(chainID string) ([]models.Task, error) { rows, err := s.db.Query(` SELECT id, content, description, project_name, project_id, due_date, priority, completed, labels, created_at, recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes, chain_id, chain_position, chain_unlocked, bucket_id, bucket_state, bucket_last_active_at FROM native_tasks WHERE chain_id = ? ORDER BY chain_position ASC `, chainID) if err != nil { return nil, err } defer func() { _ = rows.Close() }() return scanNativeTasks(rows) } // SetChainStatus updates a chain's status (active/paused/abandoned/completed). // Resuming a paused chain (status "active" from a current status of // "paused") catches up any advancement that was deferred while paused -- // per the design, "the chain must be explicitly resumed for the next task // to unlock," i.e. resuming itself performs the unlock, not merely // re-arming future completions to do so. Without this, a chain paused // immediately after a completion (before its successor could unlock) would // stay stuck forever: no task is ever unlocked, so no future completion // could trigger advanceChain either. Returns ErrNativeTaskNotFound if id // doesn't match any row. func (s *Store) SetChainStatus(id, status string) error { if status == "active" { current, err := s.GetChain(id) if err != nil { return err } if current.Status == "paused" { maxCompleted, err := s.maxCompletedChainPosition(id) if err != nil { return err } if maxCompleted != nil { if err := s.advanceChain(id, *maxCompleted); err != nil { return err } } } } result, err := s.db.Exec(`UPDATE task_chains SET status = ? WHERE id = ?`, status, id) if err != nil { return err } return checkRowsAffected(result) } // maxCompletedChainPosition returns the highest chain_position among // completed tasks in the chain, or nil if none are completed yet. func (s *Store) maxCompletedChainPosition(chainID string) (*int, error) { var pos sql.NullInt64 err := s.db.QueryRow(` SELECT MAX(chain_position) FROM native_tasks WHERE chain_id = ? AND completed = 1 `, chainID).Scan(&pos) if err != nil { return nil, err } if !pos.Valid { return nil, nil } p := int(pos.Int64) return &p, nil } // advanceChain is called from CompleteNativeTask (when the just-completed // task has a chain_id set and the chain isn't paused) and from // SetChainStatus's resume catch-up. Idempotent: if the successor position // is already unlocked, it's left untouched (so a resume catch-up run // against a chain that was never actually stuck is a no-op, not a // due_date-resetting re-unlock). Completing/catching-up-to the last // position marks the chain completed instead of unlocking a successor. func (s *Store) advanceChain(chainID string, completedPosition int) error { var successorID string var successorUnlocked bool err := s.db.QueryRow(` SELECT id, chain_unlocked FROM native_tasks WHERE chain_id = ? AND chain_position = ? `, chainID, completedPosition+1).Scan(&successorID, &successorUnlocked) if err == sql.ErrNoRows { // No next position -- the completed task was the last in the chain. return s.SetChainStatus(chainID, "completed") } if err != nil { return err } if successorUnlocked { return nil } now := config.Now() _, err = s.db.Exec(` UPDATE native_tasks SET chain_unlocked = 1, due_date = ?, updated_at = ? WHERE id = ? `, now, now, successorID) return err }