summaryrefslogtreecommitdiff
path: root/internal/store/native_tasks.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-09-03 02:34:25 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-09-03 02:34:25 +0000
commit1477ee531045388de9e7e797200e5cd14e4a6d0e (patch)
treeb5369768ed0da759d492fda7957ab25833759d78 /internal/store/native_tasks.go
parent699e31989e2c36513e02861f49672f8984ffe336 (diff)
Make the day's list a pull of committed work, not a push of everything unblocked
due_date had been carrying two incompatible meanings: "this has a real deadline" (Bills, meds, a signup cutoff) and "this is when I intend to work on it." Chains leaned entirely on the second -- every step was stamped due_date = now the moment it unlocked -- so a five-minute call and a six-month certification both arrived looking overdue, and the only way to quiet them was to hand-reschedule. The list therefore always contained work that could not be finished that day, which made ending a day at zero impossible by construction rather than by effort. Splits the two apart: - due_date keeps only its real meaning: an externally-imposed deadline. - committed_date (new, migration 031) means "I chose this for today." Chain steps no longer get a due date on unlock (CreateChain, advanceChain, and the delete-promotion path), and are excluded from the undated list so they don't simply fall through to it instead. They reach the day only by being committed, via a "+ today" control on the chain card. Bucket-active items get the same control. BuildTimeline pulls in committed tasks, deduped against tasks already present for a different reason (committed and due-today are two reasons for one row, not two rows). Migration 031 also clears the auto-stamped due dates from open chain steps; completed steps keep theirs as historical record. Two supporting changes made while in here: - Extracted the native_tasks SELECT list into nativeTaskColumns. It had been copy-pasted into nine queries that must each agree exactly with scanNativeTasks' field order; adding committed_date meant editing all nine correctly or silently breaking whichever was missed. - Extracted templateFuncMap() and added TestTemplates_AllParse. New() only logs a warning when template parsing fails, so a malformed template shipped silently and surfaced as a broken page at runtime. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'internal/store/native_tasks.go')
-rw-r--r--internal/store/native_tasks.go98
1 files changed, 69 insertions, 29 deletions
diff --git a/internal/store/native_tasks.go b/internal/store/native_tasks.go
index 955b7ac..5dd3e52 100644
--- a/internal/store/native_tasks.go
+++ b/internal/store/native_tasks.go
@@ -21,6 +21,16 @@ import (
// completion: the HTTP response was 200, but nothing in the database changed.
var ErrNativeTaskNotFound = errors.New("native task not found")
+// nativeTaskColumns is the shared SELECT list for every query that scans into
+// models.Task via scanNativeTasks. Kept in one place because the list and the
+// scan order have to agree exactly, and it had previously been copy-pasted into
+// nine separate queries -- adding a column meant editing all nine correctly or
+// silently breaking whichever was missed.
+const nativeTaskColumns = `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, committed_date`
+
// visibleChainTaskClause is the single source of truth for "which chain tasks
// belong on the list." Every task query that hides locked chain work must use
// this rather than spelling the predicate out inline -- see
@@ -38,10 +48,7 @@ const visibleChainTaskClause = `(t.chain_id = '' OR (t.chain_unlocked = 1 AND EX
// GetNativeTasks returns all non-completed native tasks.
func (s *Store) GetNativeTasks() ([]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
+ SELECT ` + nativeTaskColumns + `
FROM native_tasks
WHERE completed = 0 AND abandoned = 0
ORDER BY CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date ASC, priority DESC
@@ -59,10 +66,7 @@ func (s *Store) GetNativeTasks() ([]models.Task, error) {
// without double-counting against that separate fetch.
func (s *Store) GetNativeTasksByDateRange(start, end time.Time) ([]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
+ SELECT ` + nativeTaskColumns + `
FROM native_tasks t
WHERE completed = 0 AND abandoned = 0 AND due_date IS NOT NULL AND due_date >= ? AND due_date < ?
AND `+visibleChainTaskClause+`
@@ -83,10 +87,7 @@ func (s *Store) GetNativeTasksByDateRange(start, end time.Time) ([]models.Task,
// ComputeDaySection to be marked IsOverdue.
func (s *Store) GetOverdueNativeTasks(before time.Time) ([]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
+ SELECT ` + nativeTaskColumns + `
FROM native_tasks t
WHERE completed = 0 AND abandoned = 0 AND due_date IS NOT NULL AND due_date < ?
AND `+visibleChainTaskClause+`
@@ -102,13 +103,10 @@ func (s *Store) GetOverdueNativeTasks(before time.Time) ([]models.Task, error) {
// GetUndatedNativeTasks returns non-completed native tasks with no due date.
func (s *Store) GetUndatedNativeTasks() ([]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
+ SELECT ` + nativeTaskColumns + `
FROM native_tasks t
WHERE completed = 0 AND abandoned = 0 AND due_date IS NULL
- AND `+visibleChainTaskClause+`
+ AND t.chain_id = ''
AND bucket_state != 'dormant'
ORDER BY priority DESC, created_at ASC
`)
@@ -122,10 +120,7 @@ func (s *Store) GetUndatedNativeTasks() ([]models.Task, error) {
// GetNativeTaskByID returns a single native task by id, or ErrNativeTaskNotFound.
func (s *Store) GetNativeTaskByID(id 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
+ SELECT ` + nativeTaskColumns + `
FROM native_tasks
WHERE id = ?
`, id)
@@ -214,11 +209,10 @@ func (s *Store) DeleteNativeTask(id string) error {
}
if task.ChainUnlocked {
- now := config.Now()
result, err := tx.Exec(`
- UPDATE native_tasks SET chain_unlocked = 1, due_date = ?, updated_at = ?
+ UPDATE native_tasks SET chain_unlocked = 1, updated_at = ?
WHERE chain_id = ? AND chain_position = ?
- `, now, now, task.ChainID, task.ChainPosition)
+ `, config.Now(), task.ChainID, task.ChainPosition)
if err != nil {
return err
}
@@ -460,10 +454,7 @@ func (s *Store) SetNextOccurrenceOverride(id string, date time.Time) error {
// ignored recurring task doesn't block its successor from appearing.
func (s *Store) GetSeriesNeedingNextIteration(now time.Time) ([]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
+ SELECT ` + nativeTaskColumns + `
FROM native_tasks t1
WHERE recurrence_series_id != ''
AND due_date IS NOT NULL AND due_date <= ?
@@ -537,17 +528,23 @@ func scanNativeTasks(rows interface {
var weekdaysStr string
var nextOverrideStr string
var bucketLastActiveAt sql.NullTime
+ var committedDate sql.NullString
if err := rows.Scan(
&t.ID, &t.Content, &t.Description, &t.ProjectName, &t.ProjectID, &dueDateStr, &t.Priority, &t.Completed, &labelsJSON, &t.CreatedAt,
&t.RecurrenceFreq, &t.RecurrenceInterval, &weekdaysStr, &t.RecurrenceSeriesID, &nextOverrideStr, &t.EstimatedMinutes,
&t.ChainID, &t.ChainPosition, &t.ChainUnlocked,
- &t.BucketID, &t.BucketState, &bucketLastActiveAt,
+ &t.BucketID, &t.BucketState, &bucketLastActiveAt, &committedDate,
); err != nil {
return nil, err
}
if bucketLastActiveAt.Valid {
t.BucketLastActiveAt = &bucketLastActiveAt.Time
}
+ if committedDate.Valid && committedDate.String != "" {
+ if parsed, err := time.Parse("2006-01-02", committedDate.String); err == nil {
+ t.CommittedDate = &parsed
+ }
+ }
if dueDateStr != nil {
if parsed, err := time.Parse(time.RFC3339, *dueDateStr); err == nil {
t.DueDate = &parsed
@@ -598,3 +595,46 @@ func formatWeekdays(weekdays []int) string {
}
return strings.Join(strs, ",")
}
+
+// SetTaskCommitted marks a task as chosen for a given day. This is the "I'll do
+// this today" signal, deliberately separate from due_date, which now means only
+// an externally-imposed deadline -- see migration 031. Returns
+// ErrNativeTaskNotFound if id doesn't match any row.
+func (s *Store) SetTaskCommitted(id string, day time.Time) error {
+ result, err := s.db.Exec(`
+ UPDATE native_tasks SET committed_date = ?, updated_at = ? WHERE id = ?
+ `, day.Format("2006-01-02"), config.Now(), id)
+ if err != nil {
+ return err
+ }
+ return checkRowsAffected(result)
+}
+
+// ClearTaskCommitted removes a task's commitment, dropping it back to the
+// backlog without completing it.
+func (s *Store) ClearTaskCommitted(id string) error {
+ result, err := s.db.Exec(`
+ UPDATE native_tasks SET committed_date = NULL, updated_at = ? WHERE id = ?
+ `, config.Now(), id)
+ if err != nil {
+ return err
+ }
+ return checkRowsAffected(result)
+}
+
+// GetCommittedTasks returns the open tasks committed to a given day. Chain
+// steps are deliberately NOT filtered out here: committing is exactly how chain
+// work is meant to reach the day's list.
+func (s *Store) GetCommittedTasks(day time.Time) ([]models.Task, error) {
+ rows, err := s.db.Query(`
+ SELECT ` + nativeTaskColumns + `
+ FROM native_tasks t
+ WHERE completed = 0 AND abandoned = 0 AND committed_date = ?
+ ORDER BY priority DESC, created_at ASC
+ `, day.Format("2006-01-02"))
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+ return scanNativeTasks(rows)
+}