diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-09-03 02:34:25 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-09-03 02:34:25 +0000 |
| commit | 1477ee531045388de9e7e797200e5cd14e4a6d0e (patch) | |
| tree | b5369768ed0da759d492fda7957ab25833759d78 /internal | |
| parent | 699e31989e2c36513e02861f49672f8984ffe336 (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.go | 69 | ||||
| -rw-r--r-- | internal/handlers/handlers.go | 177 | ||||
| -rw-r--r-- | internal/handlers/templates_test.go | 24 | ||||
| -rw-r--r-- | internal/handlers/timeline_logic.go | 38 | ||||
| -rw-r--r-- | internal/models/types.go | 7 | ||||
| -rw-r--r-- | internal/store/buckets.go | 5 | ||||
| -rw-r--r-- | internal/store/chains.go | 28 | ||||
| -rw-r--r-- | internal/store/chains_test.go | 103 | ||||
| -rw-r--r-- | internal/store/native_tasks.go | 98 | ||||
| -rw-r--r-- | internal/store/native_tasks_test.go | 47 | ||||
| -rw-r--r-- | internal/store/sqlite_test.go | 3 |
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 |
