diff --git a/internal/commands/board.go b/internal/commands/board.go index a5a6d4c..8a851d1 100644 --- a/internal/commands/board.go +++ b/internal/commands/board.go @@ -516,6 +516,8 @@ var boardClosedCmd = &cobra.Command{ linkNext = parseSDKLinkNext(resp) } + hydrateCardColumns(items) + count := dataCount(items) summary := fmt.Sprintf("%d closed cards", count) if boardClosedAll { @@ -590,6 +592,8 @@ var boardPostponedCmd = &cobra.Command{ linkNext = parseSDKLinkNext(resp) } + hydrateCardColumns(items) + count := dataCount(items) summary := fmt.Sprintf("%d postponed cards", count) if boardPostponedAll { @@ -664,6 +668,8 @@ var boardStreamCmd = &cobra.Command{ linkNext = parseSDKLinkNext(resp) } + hydrateCardColumns(items) + count := dataCount(items) summary := fmt.Sprintf("%d stream cards", count) if boardStreamAll { diff --git a/internal/commands/card.go b/internal/commands/card.go index 45012bd..736295f 100644 --- a/internal/commands/card.go +++ b/internal/commands/card.go @@ -144,6 +144,8 @@ var cardListCmd = &cobra.Command{ linkNext = parseSDKLinkNext(resp) } + hydrateCardColumns(items) + // Build summary count := dataCount(items) summary := fmt.Sprintf("%d cards", count) @@ -192,6 +194,7 @@ var cardShowCmd = &cobra.Command{ } items := normalizeAny(data) + hydrateCardColumns(items) // Build summary summary := fmt.Sprintf("Card #%s", cardNumber) diff --git a/internal/commands/pseudocolumns.go b/internal/commands/pseudocolumns.go index 6be7349..0625059 100644 --- a/internal/commands/pseudocolumns.go +++ b/internal/commands/pseudocolumns.go @@ -39,3 +39,52 @@ func parsePseudoColumnID(id string) (pseudoColumn, bool) { return pseudoColumn{}, false } } + +// inferPseudoColumn determines which pseudo column a card belongs to when the +// API returns an empty column object (cards in Not Now, Done, or Maybe have no +// real column). The states are mutually exclusive upstream: closing a card +// destroys its not_now record, and postponing clears its column. Drafts belong +// to no lane, so they are left untouched. +func inferPseudoColumn(card map[string]any) (pseudoColumn, bool) { + if jsonBool(card["closed"]) { + return pseudoColumnDone, true + } + if jsonBool(card["postponed"]) { + return pseudoColumnNotNow, true + } + if status, _ := card["status"].(string); status == "published" { + return pseudoColumnMaybe, true + } + return pseudoColumn{}, false +} + +// hydrateCardColumns applies hydrateCardColumn to a card or a list of cards. +func hydrateCardColumns(items any) { + switch d := items.(type) { + case []map[string]any: + for _, m := range d { + hydrateCardColumn(m) + } + case map[string]any: + hydrateCardColumn(d) + } +} + +// hydrateCardColumn replaces an empty column object with the inferred pseudo +// column so consumers always see a usable column id/name. +func hydrateCardColumn(card map[string]any) { + column, _ := card["column"].(map[string]any) + if id, _ := column["id"].(string); id != "" { + return + } + pseudo, ok := inferPseudoColumn(card) + if !ok { + return + } + card["column"] = pseudoColumnObject(pseudo) +} + +func jsonBool(v any) bool { + b, ok := v.(bool) + return ok && b +} diff --git a/internal/commands/pseudocolumns_test.go b/internal/commands/pseudocolumns_test.go new file mode 100644 index 0000000..57001f5 --- /dev/null +++ b/internal/commands/pseudocolumns_test.go @@ -0,0 +1,183 @@ +package commands + +import ( + "testing" + + "github.com/basecamp/fizzy-cli/internal/client" +) + +func cardWithEmptyColumn(number int, extra map[string]any) map[string]any { + card := map[string]any{ + "id": "card-id", + "number": float64(number), + "title": "Test Card", + "status": "published", + "column": map[string]any{"id": "", "name": "", "created_at": ""}, + } + for k, v := range extra { + card[k] = v + } + return card +} + +func TestHydrateCardColumn(t *testing.T) { + t.Run("hydrates published card without column as maybe", func(t *testing.T) { + card := cardWithEmptyColumn(1, nil) + hydrateCardColumn(card) + + column, ok := card["column"].(map[string]any) + if !ok { + t.Fatalf("expected column map, got %T", card["column"]) + } + if column["id"] != "maybe" || column["name"] != "Maybe?" { + t.Errorf("expected maybe pseudo column, got %v", column) + } + if column["kind"] != "triage" || column["pseudo"] != true { + t.Errorf("expected pseudo triage marker, got %v", column) + } + }) + + t.Run("hydrates closed card as done", func(t *testing.T) { + card := cardWithEmptyColumn(2, map[string]any{"closed": true}) + hydrateCardColumn(card) + + column := card["column"].(map[string]any) + if column["id"] != "done" || column["name"] != "Done" { + t.Errorf("expected done pseudo column, got %v", column) + } + }) + + t.Run("hydrates postponed card as not-now", func(t *testing.T) { + card := cardWithEmptyColumn(3, map[string]any{"postponed": true}) + hydrateCardColumn(card) + + column := card["column"].(map[string]any) + if column["id"] != "not-now" || column["name"] != "Not Now" { + t.Errorf("expected not-now pseudo column, got %v", column) + } + }) + + t.Run("prefers closed over other flags", func(t *testing.T) { + card := cardWithEmptyColumn(4, map[string]any{"closed": true, "postponed": true}) + hydrateCardColumn(card) + + column := card["column"].(map[string]any) + if column["id"] != "done" { + t.Errorf("expected done pseudo column, got %v", column) + } + }) + + t.Run("leaves drafts untouched", func(t *testing.T) { + card := cardWithEmptyColumn(5, map[string]any{"status": "drafted"}) + hydrateCardColumn(card) + + column := card["column"].(map[string]any) + if column["id"] != "" { + t.Errorf("expected draft column to remain empty, got %v", column) + } + }) + + t.Run("leaves cards with a real column untouched", func(t *testing.T) { + card := cardWithEmptyColumn(6, map[string]any{ + "column": map[string]any{"id": "col-123", "name": "Development"}, + }) + hydrateCardColumn(card) + + column := card["column"].(map[string]any) + if column["id"] != "col-123" || column["name"] != "Development" { + t.Errorf("expected real column to remain untouched, got %v", column) + } + }) + + t.Run("leaves unknown payloads untouched", func(t *testing.T) { + card := map[string]any{"id": "x", "column": map[string]any{"id": ""}} + hydrateCardColumn(card) + + column := card["column"].(map[string]any) + if column["id"] != "" { + t.Errorf("expected column to remain empty, got %v", column) + } + }) +} + +func TestHydrateCardColumnsList(t *testing.T) { + items := []map[string]any{ + cardWithEmptyColumn(1, nil), + cardWithEmptyColumn(2, map[string]any{"closed": true}), + cardWithEmptyColumn(3, map[string]any{"postponed": true}), + } + hydrateCardColumns(items) + + expected := []string{"maybe", "done", "not-now"} + for i, want := range expected { + column := items[i]["column"].(map[string]any) + if column["id"] != want { + t.Errorf("card %d: expected column id %q, got %v", i+1, want, column["id"]) + } + } + + single := cardWithEmptyColumn(7, nil) + hydrateCardColumns(single) + if single["column"].(map[string]any)["id"] != "maybe" { + t.Errorf("expected single card hydration, got %v", single["column"]) + } +} + +func TestCardShowHydratesPseudoColumn(t *testing.T) { + mock := NewMockClient() + mock.GetResponse = &client.APIResponse{ + StatusCode: 200, + Data: cardWithEmptyColumn(216, nil), + } + + result := SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + err := cardShowCmd.RunE(cardShowCmd, []string{"216"}) + assertExitCode(t, err, 0) + + data, ok := result.Response.Data.(map[string]any) + if !ok { + t.Fatalf("expected map response data, got %T", result.Response.Data) + } + column, ok := data["column"].(map[string]any) + if !ok { + t.Fatalf("expected column map, got %T", data["column"]) + } + if column["id"] != "maybe" || column["name"] != "Maybe?" { + t.Errorf("expected hydrated maybe column, got %v", column) + } +} + +func TestCardListHydratesPseudoColumns(t *testing.T) { + mock := NewMockClient() + mock.GetWithPaginationResponse = &client.APIResponse{ + StatusCode: 200, + Data: []any{ + cardWithEmptyColumn(1, nil), + cardWithEmptyColumn(2, map[string]any{"closed": true}), + cardWithEmptyColumn(3, map[string]any{"postponed": true}), + }, + } + + result := SetTestModeWithSDK(mock) + SetTestConfig("token", "account", "https://api.example.com") + defer resetTest() + + err := cardListCmd.RunE(cardListCmd, []string{}) + assertExitCode(t, err, 0) + + arr, ok := result.Response.Data.([]any) + if !ok { + t.Fatalf("expected array response data, got %T", result.Response.Data) + } + expected := []string{"maybe", "done", "not-now"} + for i, want := range expected { + card := arr[i].(map[string]any) + column := card["column"].(map[string]any) + if column["id"] != want { + t.Errorf("card %d: expected column id %q, got %v", i+1, want, column["id"]) + } + } +} diff --git a/internal/commands/search.go b/internal/commands/search.go index 308656f..2686f79 100644 --- a/internal/commands/search.go +++ b/internal/commands/search.go @@ -32,6 +32,7 @@ use 'fizzy card list' with --search and the relevant filter flags.`, } items := normalizeAny(raw) + hydrateCardColumns(items) count := dataCount(items) summary := fmt.Sprintf("%d results for %q", count, query)