From 2509dde6aa372a505b186657706f4d21bd391807 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 12 Aug 2026 23:35:47 +0000 Subject: Add task title editing/deletion, timeline click-to-open, widget app launch Task-detail modal was description-only with no delete affordance; HandleUpdateTask now saves the title too and a Delete button hits a new DELETE /tasks/{id} route backed by store.DeleteNativeTask, which repairs chain_position/unlocks the successor when the deleted task belongs to a chain. Timeline tab task/card/gtask rows now open the same detail modal as the Tasks tab. Android widget's "TODAY" header is now a tap target that launches DashboardActivity, since nothing previously opened the full app from the widget. --- internal/store/chains_test.go | 114 ++++++++++++++++++++++++++++++++++++ internal/store/native_tasks.go | 61 +++++++++++++++++++ internal/store/native_tasks_test.go | 20 +++++++ 3 files changed, 195 insertions(+) (limited to 'internal/store') diff --git a/internal/store/chains_test.go b/internal/store/chains_test.go index 6359d11..bcfc09e 100644 --- a/internal/store/chains_test.go +++ b/internal/store/chains_test.go @@ -263,3 +263,117 @@ func TestGetChain_UnknownID_ReturnsErrNotFound(t *testing.T) { t.Errorf("err = %v, want ErrNativeTaskNotFound", err) } } + +// TestDeleteNativeTask_LockedChainTask_ClosesPositionGap proves deleting a +// not-yet-reached chain step doesn't strand the chain: positions after the +// deleted one shift down by one and stay a contiguous 0..N-1 sequence, since +// advanceChain's chain_position+1 lookup depends on that contiguity. +func TestDeleteNativeTask_LockedChainTask_ClosesPositionGap(t *testing.T) { + s := newNativeTasksTestStore(t) + + chain, err := s.CreateChain("Track", chainTasks("Step 1", "Step 2", "Step 3")) + if err != nil { + t.Fatal(err) + } + tasks, err := s.GetChainTasks(chain.ID) + if err != nil { + t.Fatal(err) + } + + if err := s.DeleteNativeTask(tasks[1].ID); err != nil { + t.Fatalf("DeleteNativeTask: %v", err) + } + + after, err := s.GetChainTasks(chain.ID) + if err != nil { + t.Fatal(err) + } + if len(after) != 2 { + t.Fatalf("len(after) = %d, want 2", len(after)) + } + if after[0].Content != "Step 1" || after[1].Content != "Step 3" { + t.Errorf("unexpected content order: %q, %q", after[0].Content, after[1].Content) + } + if after[1].ChainPosition != 1 { + t.Errorf("Step 3 chain_position = %d, want 1 (gap closed)", after[1].ChainPosition) + } + if !after[0].ChainUnlocked { + t.Errorf("position 0 should still be unlocked") + } + if after[1].ChainUnlocked { + t.Errorf("position 1 (formerly locked position 2) should still be locked") + } + + // Completing position 0 should now correctly advance to the + // renumbered position 1 (Step 3), proving advanceChain's + // chain_position+1 lookup still works post-deletion. + if err := s.CompleteNativeTask(after[0].ID); err != nil { + t.Fatalf("CompleteNativeTask: %v", err) + } + final, err := s.GetChainTasks(chain.ID) + if err != nil { + t.Fatal(err) + } + if !final[1].ChainUnlocked { + t.Errorf("Step 3 should be unlocked after completing Step 1") + } +} + +// TestDeleteNativeTask_UnlockedChainTask_PromotesSuccessor proves deleting +// the currently-actionable (unlocked) step unlocks whatever now sits at its +// position, rather than leaving the chain with nothing unlocked. +func TestDeleteNativeTask_UnlockedChainTask_PromotesSuccessor(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.DeleteNativeTask(tasks[0].ID); err != nil { + t.Fatalf("DeleteNativeTask: %v", err) + } + + after, err := s.GetChainTasks(chain.ID) + if err != nil { + t.Fatal(err) + } + 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) + } +} + +// TestDeleteNativeTask_LastRemainingChainTask_MarksChainCompleted proves +// deleting the sole unlocked task with nothing left to promote finishes the +// chain instead of leaving it active with zero unlocked tasks forever. +func TestDeleteNativeTask_LastRemainingChainTask_MarksChainCompleted(t *testing.T) { + s := newNativeTasksTestStore(t) + + chain, err := s.CreateChain("Track", chainTasks("Only step")) + if err != nil { + t.Fatal(err) + } + tasks, err := s.GetChainTasks(chain.ID) + if err != nil { + t.Fatal(err) + } + + if err := s.DeleteNativeTask(tasks[0].ID); err != nil { + t.Fatalf("DeleteNativeTask: %v", err) + } + + updatedChain, err := s.GetChain(chain.ID) + if err != nil { + t.Fatal(err) + } + if updatedChain.Status != "completed" { + t.Errorf("chain.Status = %q, want completed", updatedChain.Status) + } +} diff --git a/internal/store/native_tasks.go b/internal/store/native_tasks.go index 11a9197..9d9af2c 100644 --- a/internal/store/native_tasks.go +++ b/internal/store/native_tasks.go @@ -161,6 +161,67 @@ func (s *Store) UpdateNativeTaskDescription(id, description string) error { return err } +// DeleteNativeTask permanently removes a task. Returns ErrNativeTaskNotFound +// if id doesn't match any row. If the task belongs to a chain, this also +// closes the resulting gap in chain_position (advanceChain looks up +// chain_position+1, so a gap would either strand the chain mid-sequence or, +// if the deleted task was the unlocked one, silently stop it from ever +// advancing) and, if the deleted task was itself the unlocked position, +// unlocks whatever now occupies that position -- or marks the chain +// completed if nothing does (the deleted task was the last one left). +func (s *Store) DeleteNativeTask(id string) error { + task, err := s.GetNativeTaskByID(id) + if err != nil { + return err + } + + if task.ChainID == "" { + result, err := s.db.Exec(`DELETE FROM native_tasks WHERE id = ?`, id) + if err != nil { + return err + } + return checkRowsAffected(result) + } + + tx, err := s.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.Exec(`DELETE FROM native_tasks WHERE id = ?`, id); err != nil { + return err + } + if _, err := tx.Exec(` + UPDATE native_tasks SET chain_position = chain_position - 1 + WHERE chain_id = ? AND chain_position > ? + `, task.ChainID, task.ChainPosition); err != nil { + return err + } + + if task.ChainUnlocked { + now := config.Now() + result, err := tx.Exec(` + UPDATE native_tasks SET chain_unlocked = 1, due_date = ?, updated_at = ? + WHERE chain_id = ? AND chain_position = ? + `, now, now, task.ChainID, task.ChainPosition) + if err != nil { + return err + } + successorPromoted, err := result.RowsAffected() + if err != nil { + return err + } + if successorPromoted == 0 { + if _, err := tx.Exec(`UPDATE task_chains SET status = 'completed' WHERE id = ?`, task.ChainID); err != nil { + return err + } + } + } + + return tx.Commit() +} + // ErrChainTaskLocked is returned by CompleteNativeTask when the task // belongs to a chain but isn't the currently-unlocked position -- without // this guard, completing a locked task directly by id (bypassing the UI, diff --git a/internal/store/native_tasks_test.go b/internal/store/native_tasks_test.go index d82576e..9a7e55f 100644 --- a/internal/store/native_tasks_test.go +++ b/internal/store/native_tasks_test.go @@ -698,3 +698,23 @@ func TestCreateNextIteration_CarriesEstimatedMinutesForward(t *testing.T) { t.Errorf("EstimatedMinutes = %d, want 60 (carried forward)", next.EstimatedMinutes) } } + +func TestDeleteNativeTask_PlainTask_Removed(t *testing.T) { + s := newNativeTasksTestStore(t) + + if err := s.DeleteNativeTask("real-1"); err != nil { + t.Fatalf("DeleteNativeTask: %v", err) + } + + if _, err := s.GetNativeTaskByID("real-1"); !errors.Is(err, ErrNativeTaskNotFound) { + t.Errorf("err = %v, want ErrNativeTaskNotFound", err) + } +} + +func TestDeleteNativeTask_UnknownID_ReturnsErrNotFound(t *testing.T) { + s := newNativeTasksTestStore(t) + + if err := s.DeleteNativeTask("does-not-exist"); !errors.Is(err, ErrNativeTaskNotFound) { + t.Errorf("err = %v, want ErrNativeTaskNotFound", err) + } +} -- cgit v1.2.3