diff --git a/internal/workspace/most_recent.go b/internal/workspace/most_recent.go new file mode 100644 index 0000000..b170500 --- /dev/null +++ b/internal/workspace/most_recent.go @@ -0,0 +1,30 @@ +package workspace + +// MostRecentActive returns the active workspace with the latest UpdatedAt +// timestamp under root, considering both tasks and projects. +// +// Returns (nil, nil) if no active workspaces exist (this is not an error +// condition; callers decide how to surface "nothing to do"). +// +// Legacy v0.2 workspaces (no Type field) are included as tasks via +// EffectiveType. Archived workspaces are always excluded. +func MostRecentActive(root string) (*QueryResult, error) { + results, err := ListWorkspaces(root, ListOpts{ + IncludeArchived: false, + Limit: 0, + Type: TypeAny, + }) + if err != nil { + return nil, err + } + if len(results) == 0 { + return nil, nil + } + best := results[0] + for _, r := range results[1:] { + if r.Meta.UpdatedAt.After(best.Meta.UpdatedAt) { + best = r + } + } + return &best, nil +} diff --git a/internal/workspace/most_recent_test.go b/internal/workspace/most_recent_test.go new file mode 100644 index 0000000..9e5ec99 --- /dev/null +++ b/internal/workspace/most_recent_test.go @@ -0,0 +1,138 @@ +package workspace + +import ( + "testing" + "time" +) + +// All test timestamps are anchored to a fixed base date so the assertions +// don't depend on wall-clock time. +var baseT = time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC) + +func TestMostRecentActiveTasksOnly(t *testing.T) { + root := t.TempDir() + createTestWorkspaceFull(t, root, "general", "2026-04-01_old", "active", "task", baseT) + createTestWorkspaceFull(t, root, "general", "2026-04-02_new", "active", "task", baseT.Add(2*time.Hour)) + + got, err := MostRecentActive(root) + if err != nil { + t.Fatalf("MostRecentActive: %v", err) + } + if got == nil { + t.Fatal("expected a result, got nil") + } + if got.Meta.Slug != "new" { + t.Errorf("slug: got %q, want \"new\"", got.Meta.Slug) + } +} + +func TestMostRecentActiveProjectsOnly(t *testing.T) { + root := t.TempDir() + createTestWorkspaceFull(t, root, "projects", "2026-04-01_old-proj", "active", "project", baseT) + createTestWorkspaceFull(t, root, "projects", "2026-04-02_new-proj", "active", "project", baseT.Add(2*time.Hour)) + + got, err := MostRecentActive(root) + if err != nil { + t.Fatalf("MostRecentActive: %v", err) + } + if got == nil { + t.Fatal("expected a result, got nil") + } + if got.Meta.Slug != "new-proj" { + t.Errorf("slug: got %q, want \"new-proj\"", got.Meta.Slug) + } +} + +func TestMostRecentActiveProjectWinsOverOlderTask(t *testing.T) { + root := t.TempDir() + createTestWorkspaceFull(t, root, "general", "2026-04-01_old-task", "active", "task", baseT) + createTestWorkspaceFull(t, root, "projects", "2026-04-02_new-proj", "active", "project", baseT.Add(2*time.Hour)) + + got, err := MostRecentActive(root) + if err != nil { + t.Fatalf("MostRecentActive: %v", err) + } + if got == nil || got.Meta.Slug != "new-proj" { + t.Fatalf("expected new-proj, got %+v", got) + } + if EffectiveType(got.Meta) != "project" { + t.Errorf("EffectiveType: got %q, want \"project\"", EffectiveType(got.Meta)) + } +} + +func TestMostRecentActiveTaskWinsOverOlderProject(t *testing.T) { + root := t.TempDir() + createTestWorkspaceFull(t, root, "projects", "2026-04-01_old-proj", "active", "project", baseT) + createTestWorkspaceFull(t, root, "general", "2026-04-02_new-task", "active", "task", baseT.Add(2*time.Hour)) + + got, err := MostRecentActive(root) + if err != nil { + t.Fatalf("MostRecentActive: %v", err) + } + if got == nil || got.Meta.Slug != "new-task" { + t.Fatalf("expected new-task, got %+v", got) + } + if EffectiveType(got.Meta) != "task" { + t.Errorf("EffectiveType: got %q, want \"task\"", EffectiveType(got.Meta)) + } +} + +func TestMostRecentActiveLegacyWorkspaceCounts(t *testing.T) { + root := t.TempDir() + // Most recent has no Type field at all (v0.2 era). + createTestWorkspaceFull(t, root, "projects", "2026-04-01_old-proj", "active", "project", baseT) + createTestWorkspaceFull(t, root, "general", "2026-04-02_legacy", "active", "", baseT.Add(2*time.Hour)) + + got, err := MostRecentActive(root) + if err != nil { + t.Fatalf("MostRecentActive: %v", err) + } + if got == nil || got.Meta.Slug != "legacy" { + t.Fatalf("expected legacy, got %+v", got) + } + // EffectiveType should fall back to "task". + if EffectiveType(got.Meta) != "task" { + t.Errorf("legacy EffectiveType: got %q, want \"task\"", EffectiveType(got.Meta)) + } +} + +func TestMostRecentActiveExcludesArchived(t *testing.T) { + root := t.TempDir() + // Newest is archived; should be skipped in favor of the older active project. + createTestWorkspaceFull(t, root, "general", "2026-04-03_newest-archived", "archived", "task", baseT.Add(3*time.Hour)) + createTestWorkspaceFull(t, root, "projects", "2026-04-02_active-proj", "active", "project", baseT.Add(2*time.Hour)) + createTestWorkspaceFull(t, root, "general", "2026-04-01_old-task", "active", "task", baseT) + + got, err := MostRecentActive(root) + if err != nil { + t.Fatalf("MostRecentActive: %v", err) + } + if got == nil || got.Meta.Slug != "active-proj" { + t.Fatalf("expected active-proj (newest non-archived), got %+v", got) + } +} + +func TestMostRecentActiveReturnsNilWhenEmpty(t *testing.T) { + root := t.TempDir() + got, err := MostRecentActive(root) + if err != nil { + t.Fatalf("MostRecentActive: %v", err) + } + if got != nil { + t.Errorf("expected nil for empty root, got %+v", got) + } +} + +func TestMostRecentActiveReturnsNilWhenAllArchived(t *testing.T) { + root := t.TempDir() + createTestWorkspaceFull(t, root, "general", "2026-04-01_arch-task", "archived", "task", baseT) + createTestWorkspaceFull(t, root, "projects", "2026-04-02_arch-proj", "archived", "project", baseT.Add(time.Hour)) + + got, err := MostRecentActive(root) + if err != nil { + t.Fatalf("MostRecentActive: %v", err) + } + if got != nil { + t.Errorf("expected nil when all workspaces archived, got %+v", got) + } +} diff --git a/internal/workspace/query_test.go b/internal/workspace/query_test.go index cb7bec7..54b4c98 100644 --- a/internal/workspace/query_test.go +++ b/internal/workspace/query_test.go @@ -16,18 +16,25 @@ func createTestWorkspace(t *testing.T, root, category, dirName string, status st // createTestWorkspaceTyped creates a minimal workspace of an explicit type // (use "" to simulate a v0.2 workspace with no type field). func createTestWorkspaceTyped(t *testing.T, root, category, dirName, status, taskType string) { + t.Helper() + now := time.Now().UTC().Truncate(time.Second) + createTestWorkspaceFull(t, root, category, dirName, status, taskType, now) +} + +// createTestWorkspaceFull creates a workspace with an explicit UpdatedAt +// timestamp -- used by tests that exercise "most recently updated" logic. +func createTestWorkspaceFull(t *testing.T, root, category, dirName, status, taskType string, updatedAt time.Time) { t.Helper() dir := filepath.Join(root, category, dirName) os.MkdirAll(dir, 0755) - now := time.Now().UTC().Truncate(time.Second) // Extract slug from dirName (skip "YYYY-MM-DD_") slug := dirName[11:] meta := &TaskMeta{ ID: "test", Slug: slug, Title: slug, - CreatedAt: now, - UpdatedAt: now, + CreatedAt: updatedAt, + UpdatedAt: updatedAt, Status: status, Category: category, Type: taskType,