summaryrefslogtreecommitdiff
path: root/internal
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
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')
-rw-r--r--internal/handlers/chains_timeline_test.go69
-rw-r--r--internal/handlers/handlers.go177
-rw-r--r--internal/handlers/templates_test.go24
-rw-r--r--internal/handlers/timeline_logic.go38
-rw-r--r--internal/models/types.go7
-rw-r--r--internal/store/buckets.go5
-rw-r--r--internal/store/chains.go28
-rw-r--r--internal/store/chains_test.go103
-rw-r--r--internal/store/native_tasks.go98
-rw-r--r--internal/store/native_tasks_test.go47
-rw-r--r--internal/store/sqlite_test.go3
11 files changed, 470 insertions, 129 deletions
diff --git a/internal/handlers/chains_timeline_test.go b/internal/handlers/chains_timeline_test.go
index e9c8a3a..d4bc369 100644
--- a/internal/handlers/chains_timeline_test.go
+++ b/internal/handlers/chains_timeline_test.go
@@ -5,10 +5,15 @@ import (
"testing"
"time"
+ "task-dashboard/internal/config"
"task-dashboard/internal/models"
)
-func TestBuildTimeline_PopulatesChainBadgeForUnlockedTask(t *testing.T) {
+// Chain steps carry no due date and are kept out of the undated list, so they
+// stay off the day's timeline entirely until deliberately committed. This is the
+// whole point of the pull model: the day contains what was chosen plus real
+// deadlines, so finishing at zero is reachable.
+func TestBuildTimeline_UnlockedChainTaskAbsentUntilCommitted(t *testing.T) {
s, cleanup := setupTestDB(t)
defer cleanup()
@@ -28,6 +33,36 @@ func TestBuildTimeline_PopulatesChainBadgeForUnlockedTask(t *testing.T) {
if err != nil {
t.Fatalf("BuildTimeline: %v", err)
}
+ for _, item := range items {
+ if item.ID == tasks[0].ID {
+ t.Error("uncommitted chain step appeared on the timeline; chain work should only arrive by being committed")
+ }
+ }
+}
+
+func TestBuildTimeline_CommittedChainTaskAppearsWithBadge(t *testing.T) {
+ s, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ chain, err := s.CreateChain("Ham Radio Track", []models.ChainTaskInput{
+ {Content: "Study Technician"}, {Content: "Pass exam"}, {Content: "Study General"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ tasks, err := s.GetChainTasks(chain.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.SetTaskCommitted(tasks[0].ID, config.Now()); err != nil {
+ t.Fatal(err)
+ }
+
+ now := time.Now()
+ items, err := BuildTimeline(context.Background(), s, now.Add(-time.Hour), now.Add(24*time.Hour))
+ if err != nil {
+ t.Fatalf("BuildTimeline: %v", err)
+ }
var found bool
for _, item := range items {
@@ -42,6 +77,36 @@ func TestBuildTimeline_PopulatesChainBadgeForUnlockedTask(t *testing.T) {
}
}
if !found {
- t.Fatal("expected the unlocked chain task (position 0) to appear in the timeline")
+ t.Fatal("expected the committed chain step to appear on the timeline")
+ }
+}
+
+// A task that is both committed and due today must appear once, not twice.
+func TestBuildTimeline_CommittedAndDueTaskNotDuplicated(t *testing.T) {
+ s, cleanup := setupTestDB(t)
+ defer cleanup()
+
+ due := config.Now()
+ if err := s.CreateNativeTask(models.Task{ID: "dual", Content: "Bills", DueDate: &due}); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.SetTaskCommitted("dual", config.Now()); err != nil {
+ t.Fatal(err)
+ }
+
+ now := time.Now()
+ items, err := BuildTimeline(context.Background(), s, now.Add(-24*time.Hour), now.Add(24*time.Hour))
+ if err != nil {
+ t.Fatalf("BuildTimeline: %v", err)
+ }
+
+ var count int
+ for _, item := range items {
+ if item.ID == "dual" {
+ count++
+ }
+ }
+ if count != 1 {
+ t.Errorf("task appeared %d times, want exactly 1 (committed and due are two reasons for the same row)", count)
}
}
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index 69e1c65..e70650d 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -51,70 +51,7 @@ type Handler struct {
// New creates a new Handler instance
func New(s *store.Store, trello api.TrelloAPI, planToEat api.PlanToEatAPI, googleCalendar api.GoogleCalendarAPI, googleTasks api.GoogleTasksAPI, googleTasksOAuthConfig *oauth2.Config, claudomator api.ClaudomatorClient, cfg *config.Config, sessions *scs.SessionManager, buildVersion string, webAuthnEnabled bool) *Handler {
- // Template functions
- funcMap := template.FuncMap{
- "subtract": func(a, b int) int { return a - b },
- "add": func(a, b int) int { return a + b },
- // multiDayLabel returns the "(starts/ends HH:MM)" suffix for a
- // multi-day calendar event's row on the day it's rendered, or ""
- // for a normal item or a "spans" (pass-through) day, which shows
- // as a plain all-day row with no time label.
- "multiDayLabel": func(item TimelineItemView) string {
- switch item.MultiDayVariant {
- case "starts":
- if item.Time.Hour() == 0 && item.Time.Minute() == 0 {
- return ""
- }
- return " (starts " + item.Time.Format("3:04 PM") + ")"
- case "ends":
- if item.EndTime == nil || (item.EndTime.Hour() == 0 && item.EndTime.Minute() == 0) {
- return ""
- }
- return " (ends " + item.EndTime.Format("3:04 PM") + ")"
- default:
- return ""
- }
- },
- // hasInt reports whether n is present in a []int -- used to pre-check
- // the weekday checkboxes on a recurring task's edit form.
- "hasInt": func(list []int, n int) bool {
- for _, v := range list {
- if v == n {
- return true
- }
- }
- return false
- },
- // recurrenceUnit maps a recurrence freq to its singular noun, so the
- // task-detail template can say "every 2 weeks" instead of gluing an
- // "s" onto the adjective "weekly" ("every 2 weeklys").
- "recurrenceUnit": func(freq string) string {
- switch freq {
- case "daily":
- return "day"
- case "weekly":
- return "week"
- case "monthly":
- return "month"
- case "yearly":
- return "year"
- default:
- return freq
- }
- },
- // weekdayNames formats recurrence weekday indices (0=Sunday, matching
- // time.Weekday, see models/recurrence.go) as "Mon, Wed, Fri".
- "weekdayNames": func(weekdays []int) string {
- names := [7]string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}
- parts := make([]string, 0, len(weekdays))
- for _, d := range weekdays {
- if d >= 0 && d < 7 {
- parts = append(parts, names[d])
- }
- }
- return strings.Join(parts, ", ")
- },
- }
+ funcMap := templateFuncMap()
// Parse templates including partials
tmpl, err := template.New("").Funcs(funcMap).ParseGlob(filepath.Join(cfg.TemplateDir, "*.html"))
@@ -1263,3 +1200,115 @@ type ScheduledItem struct {
SourceIcon string
Priority int
}
+
+// HandleTaskCommit marks a doot-native task as chosen for today, which is how
+// chain steps and undated backlog work reach the day's list at all -- they carry
+// no due date by design, so nothing pulls them in automatically. Posting again
+// for an already-committed task is harmless (it just re-stamps today).
+func (h *Handler) HandleTaskCommit(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+ if id == "" {
+ JSONError(w, http.StatusBadRequest, "Missing id", nil)
+ return
+ }
+ if err := h.store.SetTaskCommitted(id, config.Now()); err != nil {
+ if errors.Is(err, store.ErrNativeTaskNotFound) {
+ JSONError(w, http.StatusNotFound, "task not found", err)
+ return
+ }
+ JSONError(w, http.StatusInternalServerError, "Failed to commit task", err)
+ return
+ }
+ w.Header().Set("HX-Trigger", "refresh-tasks")
+ w.WriteHeader(http.StatusOK)
+}
+
+// HandleTaskUncommit drops a task back to the backlog without completing it --
+// the honest way to walk back an over-committed day.
+func (h *Handler) HandleTaskUncommit(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+ if id == "" {
+ JSONError(w, http.StatusBadRequest, "Missing id", nil)
+ return
+ }
+ if err := h.store.ClearTaskCommitted(id); err != nil {
+ if errors.Is(err, store.ErrNativeTaskNotFound) {
+ JSONError(w, http.StatusNotFound, "task not found", err)
+ return
+ }
+ JSONError(w, http.StatusInternalServerError, "Failed to uncommit task", err)
+ return
+ }
+ w.Header().Set("HX-Trigger", "refresh-tasks")
+ w.WriteHeader(http.StatusOK)
+}
+
+// templateFuncMap returns the functions available to every template. Extracted
+// from New so the template set can be parse-checked in a test -- New only logs a
+// warning on a parse failure, so a malformed template would otherwise degrade
+// silently at runtime instead of failing anything.
+func templateFuncMap() template.FuncMap {
+ return template.FuncMap{
+ "subtract": func(a, b int) int { return a - b },
+ "add": func(a, b int) int { return a + b },
+ // multiDayLabel returns the "(starts/ends HH:MM)" suffix for a
+ // multi-day calendar event's row on the day it's rendered, or ""
+ // for a normal item or a "spans" (pass-through) day, which shows
+ // as a plain all-day row with no time label.
+ "multiDayLabel": func(item TimelineItemView) string {
+ switch item.MultiDayVariant {
+ case "starts":
+ if item.Time.Hour() == 0 && item.Time.Minute() == 0 {
+ return ""
+ }
+ return " (starts " + item.Time.Format("3:04 PM") + ")"
+ case "ends":
+ if item.EndTime == nil || (item.EndTime.Hour() == 0 && item.EndTime.Minute() == 0) {
+ return ""
+ }
+ return " (ends " + item.EndTime.Format("3:04 PM") + ")"
+ default:
+ return ""
+ }
+ },
+ // hasInt reports whether n is present in a []int -- used to pre-check
+ // the weekday checkboxes on a recurring task's edit form.
+ "hasInt": func(list []int, n int) bool {
+ for _, v := range list {
+ if v == n {
+ return true
+ }
+ }
+ return false
+ },
+ // recurrenceUnit maps a recurrence freq to its singular noun, so the
+ // task-detail template can say "every 2 weeks" instead of gluing an
+ // "s" onto the adjective "weekly" ("every 2 weeklys").
+ "recurrenceUnit": func(freq string) string {
+ switch freq {
+ case "daily":
+ return "day"
+ case "weekly":
+ return "week"
+ case "monthly":
+ return "month"
+ case "yearly":
+ return "year"
+ default:
+ return freq
+ }
+ },
+ // weekdayNames formats recurrence weekday indices (0=Sunday, matching
+ // time.Weekday, see models/recurrence.go) as "Mon, Wed, Fri".
+ "weekdayNames": func(weekdays []int) string {
+ names := [7]string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}
+ parts := make([]string, 0, len(weekdays))
+ for _, d := range weekdays {
+ if d >= 0 && d < 7 {
+ parts = append(parts, names[d])
+ }
+ }
+ return strings.Join(parts, ", ")
+ },
+ }
+}
diff --git a/internal/handlers/templates_test.go b/internal/handlers/templates_test.go
new file mode 100644
index 0000000..ffaf137
--- /dev/null
+++ b/internal/handlers/templates_test.go
@@ -0,0 +1,24 @@
+package handlers
+
+import (
+ "html/template"
+ "path/filepath"
+ "testing"
+)
+
+// New() only logs a warning when template parsing fails, so a malformed template
+// -- an unknown function, an unclosed action, a typo'd field pipeline -- ships
+// silently and only shows up as a broken page at runtime. This parses the real
+// template set with the real func map so that failure is a build-time failure
+// instead.
+func TestTemplates_AllParse(t *testing.T) {
+ dir := filepath.Join("..", "..", "web", "templates")
+
+ tmpl, err := template.New("").Funcs(templateFuncMap()).ParseGlob(filepath.Join(dir, "*.html"))
+ if err != nil {
+ t.Fatalf("parsing top-level templates: %v", err)
+ }
+ if _, err := tmpl.ParseGlob(filepath.Join(dir, "partials", "*.html")); err != nil {
+ t.Fatalf("parsing partials: %v", err)
+ }
+}
diff --git a/internal/handlers/timeline_logic.go b/internal/handlers/timeline_logic.go
index b25be1f..881ae78 100644
--- a/internal/handlers/timeline_logic.go
+++ b/internal/handlers/timeline_logic.go
@@ -245,6 +245,44 @@ func BuildTimeline(ctx context.Context, s *store.Store, start, end time.Time) ([
}
}
+ // Tasks explicitly committed to today. This is the only way chain steps and
+ // other undated backlog work reach the day at all -- they carry no due date
+ // by design (see migration 031), so nothing above picks them up.
+ //
+ // Deduped against everything already added: "committed" and "due today" are
+ // two independent reasons for the same row, and a task can have both.
+ seen := make(map[string]bool, len(items))
+ for _, item := range items {
+ seen[item.ID] = true
+ }
+ committed, err := s.GetCommittedTasks(now)
+ if err != nil {
+ log.Printf("Warning: failed to read committed tasks: %v", err)
+ } else {
+ for _, task := range committed {
+ if seen[task.ID] {
+ continue
+ }
+ item := models.TimelineItem{
+ ID: task.ID,
+ Type: models.TimelineItemTypeTask,
+ Title: task.Content,
+ Time: now,
+ Description: task.Description,
+ OriginalItem: task,
+ IsCompleted: task.Completed,
+ Source: "doot",
+ IsAllDay: true,
+ Undated: true,
+ ProjectColor: projectColors[task.ProjectID],
+ }
+ setChainBadge(s, &item, task)
+ item.BucketState = task.BucketState
+ item.ComputeDaySection(now)
+ items = append(items, item)
+ }
+ }
+
// Sort items by Time
sort.Slice(items, func(i, j int) bool {
return items[i].Time.Before(items[j].Time)
diff --git a/internal/models/types.go b/internal/models/types.go
index 41ba162..1decd93 100644
--- a/internal/models/types.go
+++ b/internal/models/types.go
@@ -29,6 +29,13 @@ type Task struct {
RecurrenceSeriesID string `json:"recurrence_series_id,omitempty"`
NextOccurrenceOverride *time.Time `json:"next_occurrence_override,omitempty"`
+ // CommittedDate is "I chose to do this on this day," deliberately distinct
+ // from DueDate, which after migration 031 means only an externally-imposed
+ // deadline. The day's list is a pull of committed work plus real deadlines,
+ // rather than a push of everything unblocked -- which is what makes ending a
+ // day at zero possible.
+ CommittedDate *time.Time `json:"committed_date,omitempty"`
+
// Chain membership (doot-native tasks only). ChainID != "" is the
// indicator that a task belongs to a linear task chain.
ChainID string `json:"chain_id,omitempty"`
diff --git a/internal/store/buckets.go b/internal/store/buckets.go
index 1e3ce9a..5517c66 100644
--- a/internal/store/buckets.go
+++ b/internal/store/buckets.go
@@ -93,10 +93,7 @@ func (s *Store) RemoveBucketItem(taskID string) error {
// both), active first, then by priority -- backs the bucket-management view.
func (s *Store) GetBucketItems(bucketID 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 bucket_id = ?
ORDER BY (bucket_state = 'active') DESC, priority DESC
diff --git a/internal/store/chains.go b/internal/store/chains.go
index f854678..423d7f3 100644
--- a/internal/store/chains.go
+++ b/internal/store/chains.go
@@ -3,7 +3,6 @@ package store
import (
"database/sql"
"fmt"
- "time"
"task-dashboard/internal/config"
"task-dashboard/internal/models"
@@ -17,7 +16,8 @@ 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
+// unlocked; the rest are locked. No step carries a due date -- a chain step is
+// a milestone, not a deadline (see migration 031). 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 {
@@ -46,18 +46,14 @@ func (s *Store) CreateChain(name string, tasks []models.ChainTaskInput) (*models
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 {
+ INSERT INTO native_tasks (id, content, description, project_name, project_id, priority, chain_id, chain_position, chain_unlocked, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `, newTaskID(), t.Content, t.Description, project.Name, project.ID, priority, chainID, i, unlocked, now, now); err != nil {
return nil, err
}
}
@@ -113,10 +109,7 @@ func (s *Store) GetChains() ([]models.Chain, error) {
// 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
+ SELECT `+nativeTaskColumns+`
FROM native_tasks
WHERE chain_id = ?
ORDER BY chain_position ASC
@@ -185,8 +178,8 @@ func (s *Store) maxCompletedChainPosition(chainID string) (*int, error) {
// 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
+// against a chain that was never actually stuck is a no-op rather than a
+// redundant 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
@@ -205,9 +198,8 @@ func (s *Store) advanceChain(chainID string, completedPosition int) error {
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)
+ UPDATE native_tasks SET chain_unlocked = 1, updated_at = ? WHERE id = ?
+ `, config.Now(), successorID)
return err
}
diff --git a/internal/store/chains_test.go b/internal/store/chains_test.go
index d21490c..007fe78 100644
--- a/internal/store/chains_test.go
+++ b/internal/store/chains_test.go
@@ -38,8 +38,10 @@ func TestCreateChain_SeedsPositionsCorrectly(t *testing.T) {
if len(tasks) != 3 {
t.Fatalf("len(tasks) = %d, want 3", len(tasks))
}
- if !tasks[0].ChainUnlocked || tasks[0].DueDate == nil {
- t.Errorf("position 0: ChainUnlocked=%v DueDate=%v, want unlocked with a due date", tasks[0].ChainUnlocked, tasks[0].DueDate)
+ // Unlocked but with no due date: a chain step is a milestone, not a
+ // deadline (migration 031). It reaches the day's list by being committed.
+ if !tasks[0].ChainUnlocked || tasks[0].DueDate != nil {
+ t.Errorf("position 0: ChainUnlocked=%v DueDate=%v, want unlocked with no due date", tasks[0].ChainUnlocked, tasks[0].DueDate)
}
for i := 1; i < 3; i++ {
if tasks[i].ChainUnlocked || tasks[i].DueDate != nil {
@@ -71,8 +73,8 @@ func TestCompleteNativeTask_AdvancesChain(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if !after[1].ChainUnlocked || after[1].DueDate == nil {
- t.Errorf("position 1 after completing position 0: ChainUnlocked=%v DueDate=%v, want unlocked with a due date", after[1].ChainUnlocked, after[1].DueDate)
+ if !after[1].ChainUnlocked || after[1].DueDate != nil {
+ t.Errorf("position 1 after completing position 0: ChainUnlocked=%v DueDate=%v, want unlocked with no due date", after[1].ChainUnlocked, after[1].DueDate)
}
updatedChain, err := s.GetChain(chain.ID)
@@ -187,7 +189,12 @@ func TestCompleteNativeTask_LockedChainTask_ReturnsErrChainTaskLocked(t *testing
}
}
-func TestSetChainStatus_ResumeWithNothingStuck_DoesNotResetDueDate(t *testing.T) {
+// Chain steps no longer carry due dates, so the original form of this test (that
+// resume must not reset the head's due date) no longer has a subject. The
+// underlying property it guarded still matters though: a resume catch-up against
+// a chain that was never stuck must be a no-op, not a re-unlock that disturbs
+// existing state.
+func TestSetChainStatus_ResumeWithNothingStuck_IsANoOp(t *testing.T) {
s := newNativeTasksTestStore(t)
chain, err := s.CreateChain("Track", chainTasks("Step 1", "Step 2"))
@@ -198,7 +205,7 @@ func TestSetChainStatus_ResumeWithNothingStuck_DoesNotResetDueDate(t *testing.T)
if err != nil {
t.Fatal(err)
}
- originalDueDate := *tasks[0].DueDate
+ originalHeadID := tasks[0].ID
// Pause and resume with nothing completed yet -- position 0 is already
// unlocked and should be left untouched by the resume catch-up.
@@ -213,8 +220,11 @@ func TestSetChainStatus_ResumeWithNothingStuck_DoesNotResetDueDate(t *testing.T)
if err != nil {
t.Fatal(err)
}
- if !after[0].DueDate.Equal(originalDueDate) {
- t.Errorf("DueDate = %v, want unchanged %v (resume catch-up should be a no-op when nothing was stuck)", after[0].DueDate, originalDueDate)
+ if after[0].ID != originalHeadID || !after[0].ChainUnlocked {
+ t.Errorf("head after resume = %+v, want the same unlocked head (resume catch-up should be a no-op when nothing was stuck)", after[0])
+ }
+ if after[1].ChainUnlocked {
+ t.Error("resume unlocked position 1 despite nothing being completed -- catch-up should not advance an unstuck chain")
}
}
@@ -349,8 +359,8 @@ func TestDeleteNativeTask_UnlockedChainTask_PromotesSuccessor(t *testing.T) {
if len(after) != 1 {
t.Fatalf("len(after) = %d, want 1", len(after))
}
- if !after[0].ChainUnlocked || after[0].DueDate == nil {
- t.Errorf("Step 2 should be promoted to unlocked with a due date, got ChainUnlocked=%v DueDate=%v", after[0].ChainUnlocked, after[0].DueDate)
+ if !after[0].ChainUnlocked || after[0].DueDate != nil {
+ t.Errorf("Step 2 should be promoted to unlocked with no due date, got ChainUnlocked=%v DueDate=%v", after[0].ChainUnlocked, after[0].DueDate)
}
}
@@ -484,3 +494,76 @@ func TestChainVisibilityPredicate_UsedEverywhereChainUnlockedIsFiltered(t *testi
t.Errorf("visibleChainTaskClause referenced %d time(s), want >= 4 (declaration + the task queries); a query that stopped using it has drifted off the shared predicate", refs)
}
}
+
+// A chain step is a milestone, not a deadline. Stamping due_date on unlock made
+// every step -- a five-minute call and a six-month certification alike -- arrive
+// looking due today, which is what drove the hand-rescheduling that made the
+// list untrustworthy. Steps now carry no due date; they reach the day's list
+// only by being committed.
+func TestCreateChain_HeadTaskHasNoDueDate(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ chain, err := s.CreateChain("Track", chainTasks("Step 1", "Step 2"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ tasks, err := s.GetChainTasks(chain.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !tasks[0].ChainUnlocked {
+ t.Fatal("expected position 0 to be unlocked")
+ }
+ if tasks[0].DueDate != nil {
+ t.Errorf("head task DueDate = %v, want nil -- a chain step is a milestone, not a deadline", tasks[0].DueDate)
+ }
+}
+
+func TestCompleteNativeTask_AdvancedStepHasNoDueDate(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ chain, err := s.CreateChain("Track", chainTasks("Step 1", "Step 2"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ tasks, err := s.GetChainTasks(chain.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.CompleteNativeTask(tasks[0].ID); err != nil {
+ t.Fatal(err)
+ }
+
+ after, err := s.GetChainTasks(chain.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !after[1].ChainUnlocked {
+ t.Fatal("expected position 1 to be unlocked after completing position 0")
+ }
+ if after[1].DueDate != nil {
+ t.Errorf("newly unlocked step DueDate = %v, want nil", after[1].DueDate)
+ }
+}
+
+// With no due date, an unlocked chain step would otherwise fall into the undated
+// bucket and show up on the timeline anyway -- defeating the point. Chain work
+// reaches the day only via commitment.
+func TestGetUndatedNativeTasks_ExcludesUnlockedChainSteps(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ chain, err := s.CreateChain("Track", chainTasks("Step 1", "Step 2"))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ undated, err := s.GetUndatedNativeTasks()
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, task := range undated {
+ if task.ChainID == chain.ID {
+ t.Errorf("chain step %q leaked into the undated list; chain work should surface via the chain summary and commitment only", task.Content)
+ }
+ }
+}
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)
+}
diff --git a/internal/store/native_tasks_test.go b/internal/store/native_tasks_test.go
index 9453c86..70b775e 100644
--- a/internal/store/native_tasks_test.go
+++ b/internal/store/native_tasks_test.go
@@ -50,7 +50,8 @@ func newNativeTasksTestStore(t *testing.T) *Store {
bucket_id TEXT DEFAULT '',
bucket_state TEXT DEFAULT '',
bucket_last_active_at DATETIME,
- abandoned BOOLEAN DEFAULT 0
+ abandoned BOOLEAN DEFAULT 0,
+ committed_date DATE
)
`); err != nil {
t.Fatal(err)
@@ -798,3 +799,47 @@ func TestCompleteNativeTask_DoesNotAbandonAnything(t *testing.T) {
t.Errorf("abandoned = %d, want 0 -- a completed occurrence was done, not skipped", abandoned)
}
}
+
+func TestSetTaskCommitted_RoundTrips(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+
+ today := config.Now()
+ if err := s.SetTaskCommitted("real-1", today); err != nil {
+ t.Fatalf("SetTaskCommitted: %v", err)
+ }
+
+ committed, err := s.GetCommittedTasks(today)
+ if err != nil {
+ t.Fatalf("GetCommittedTasks: %v", err)
+ }
+ if len(committed) != 1 || committed[0].ID != "real-1" {
+ t.Fatalf("GetCommittedTasks = %v, want exactly real-1", committed)
+ }
+
+ // A different day must not pick it up.
+ other, err := s.GetCommittedTasks(today.AddDate(0, 0, 1))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(other) != 0 {
+ t.Errorf("tomorrow's committed list = %d task(s), want 0", len(other))
+ }
+
+ if err := s.ClearTaskCommitted("real-1"); err != nil {
+ t.Fatalf("ClearTaskCommitted: %v", err)
+ }
+ after, err := s.GetCommittedTasks(today)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(after) != 0 {
+ t.Errorf("after clearing, committed list = %d task(s), want 0", len(after))
+ }
+}
+
+func TestSetTaskCommitted_UnknownID_ReturnsErrNotFound(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+ if err := s.SetTaskCommitted("nope", config.Now()); !errors.Is(err, ErrNativeTaskNotFound) {
+ t.Fatalf("err = %v, want ErrNativeTaskNotFound", err)
+ }
+}
diff --git a/internal/store/sqlite_test.go b/internal/store/sqlite_test.go
index 2110423..6890bc4 100644
--- a/internal/store/sqlite_test.go
+++ b/internal/store/sqlite_test.go
@@ -192,7 +192,8 @@ func setupTestStoreWithNativeTasks(t *testing.T) *Store {
bucket_id TEXT DEFAULT '',
bucket_state TEXT DEFAULT '',
bucket_last_active_at DATETIME,
- abandoned BOOLEAN DEFAULT 0
+ abandoned BOOLEAN DEFAULT 0,
+ committed_date DATE
);
-- The task queries join against this to check the owning chain's status
-- (see visibleChainTaskClause), so it has to exist even for tests that