From b47f4be155ed25116a5c18c5e1ca63e8798bcadb Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 24 Mar 2026 21:21:55 -0400 Subject: [PATCH 1/3] Default checkins answer date to today --- internal/commands/checkins.go | 6 +- internal/commands/checkins_test.go | 92 ++++++++++++++++++++++++++++++ skills/basecamp/SKILL.md | 3 +- 3 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 internal/commands/checkins_test.go diff --git a/internal/commands/checkins.go b/internal/commands/checkins.go index 45a9c242f..4dcdd799c 100644 --- a/internal/commands/checkins.go +++ b/internal/commands/checkins.go @@ -4,6 +4,7 @@ import ( "fmt" "strconv" "strings" + "time" "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" "github.com/spf13/cobra" @@ -774,6 +775,9 @@ func newCheckinsAnswerCreateCmd(project *string) *cobra.Command { if err != nil { return output.ErrUsage("Invalid question ID") } + if groupOn == "" { + groupOn = time.Now().Format("2006-01-02") + } html := richtext.MarkdownToHTML(content) @@ -825,7 +829,7 @@ func newCheckinsAnswerCreateCmd(project *string) *cobra.Command { }, } - cmd.Flags().StringVar(&groupOn, "date", "", "Date to group answer (ISO 8601, e.g., 2024-01-22)") + cmd.Flags().StringVar(&groupOn, "date", "", "Date to group answer (ISO 8601, e.g., 2024-01-22; defaults to today)") cmd.Flags().StringArrayVar(&attachFiles, "attach", nil, "Attach file (repeatable)") return cmd diff --git a/internal/commands/checkins_test.go b/internal/commands/checkins_test.go new file mode 100644 index 000000000..4f35e6b52 --- /dev/null +++ b/internal/commands/checkins_test.go @@ -0,0 +1,92 @@ +package commands + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockCheckinsAnswerCreateTransport struct { + recordedPath string + recordedBody map[string]any +} + +func (m *mockCheckinsAnswerCreateTransport) RoundTrip(req *http.Request) (*http.Response, error) { + header := make(http.Header) + header.Set("Content-Type", "application/json") + + switch { + case req.Method == "GET" && strings.Contains(req.URL.Path, "/projects.json"): + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(`[{"id":123,"name":"Test Project"}]`)), + Header: header, + }, nil + case req.Method == "POST" && strings.Contains(req.URL.Path, "/questions/456/answers.json"): + m.recordedPath = req.URL.Path + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + if err := json.Unmarshal(body, &m.recordedBody); err != nil { + return nil, err + } + return &http.Response{ + StatusCode: 201, + Body: io.NopCloser(strings.NewReader(`{ + "id": 789, + "content": "

hello world

", + "group_on": "2026-03-25", + "creator": {"name": "Rob Zolkos"}, + "parent": {"id": 456, "title": "What did you work on today?", "type": "Question", "url": "https://example.test/questions/456", "app_url": "https://example.test/questions/456"}, + "bucket": {"id": 123, "name": "Test Project", "type": "Project"}, + "status": "active", + "type": "Question::Answer", + "title": "Answer" + }`)), + Header: header, + }, nil + default: + return &http.Response{ + StatusCode: 404, + Body: io.NopCloser(strings.NewReader(`{"error":"Not Found"}`)), + Header: header, + }, nil + } +} + +func TestCheckinsAnswerCreateDefaultsDateToToday(t *testing.T) { + transport := &mockCheckinsAnswerCreateTransport{} + app, _ := newTestAppWithTransport(t, transport) + app.Config.ProjectID = "123" + + project := "" + cmd := newCheckinsAnswerCreateCmd(&project) + + err := executeCommand(cmd, app, "456", "hello world") + require.NoError(t, err) + require.NotNil(t, transport.recordedBody) + assert.Equal(t, "/99999/questions/456/answers.json", transport.recordedPath) + assert.Equal(t, "

hello world

", transport.recordedBody["content"]) + assert.Equal(t, time.Now().Format("2006-01-02"), transport.recordedBody["group_on"]) +} + +func TestCheckinsAnswerCreatePreservesExplicitDate(t *testing.T) { + transport := &mockCheckinsAnswerCreateTransport{} + app, _ := newTestAppWithTransport(t, transport) + app.Config.ProjectID = "123" + + project := "" + cmd := newCheckinsAnswerCreateCmd(&project) + + err := executeCommand(cmd, app, "456", "hello world", "--date", "2026-03-25") + require.NoError(t, err) + require.NotNil(t, transport.recordedBody) + assert.Equal(t, "2026-03-25", transport.recordedBody["group_on"]) +} diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index d4b131a52..a528df86c 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -533,7 +533,7 @@ basecamp checkins answers --in # List answers basecamp checkins answer --in # Answer details basecamp checkins question create "What did you work on?" --in basecamp checkins question update "New question" --frequency every_week -basecamp checkins answer create "My answer" --in +basecamp checkins answer create "My answer" --in # Defaults to today basecamp checkins answer update "Updated" --in ``` @@ -758,6 +758,7 @@ cat ~/.config/basecamp/accounts.json # Check available accounts - `basecamp comment "Text"` (not a flag) - `basecamp webhooks create "https://..." --in ` (not `--url`) - `basecamp checkins answer create "content"` (not `--question`) +- `--date YYYY-MM-DD` is optional for `checkins answer create`; if omitted, it defaults to today **Missing argument errors (code: "usage"):** When a required positional argument is missing, the CLI returns a structured error naming From 6307a5e3e61da2ad448bef351055c9db8d88d573 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 24 Mar 2026 21:35:29 -0400 Subject: [PATCH 2/3] Address PR review feedback --- internal/commands/checkins.go | 7 ++++--- internal/commands/checkins_test.go | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/commands/checkins.go b/internal/commands/checkins.go index 4dcdd799c..b4e315cbc 100644 --- a/internal/commands/checkins.go +++ b/internal/commands/checkins.go @@ -775,8 +775,9 @@ func newCheckinsAnswerCreateCmd(project *string) *cobra.Command { if err != nil { return output.ErrUsage("Invalid question ID") } - if groupOn == "" { - groupOn = time.Now().Format("2006-01-02") + effectiveGroupOn := groupOn + if effectiveGroupOn == "" { + effectiveGroupOn = time.Now().Format("2006-01-02") } html := richtext.MarkdownToHTML(content) @@ -798,7 +799,7 @@ func newCheckinsAnswerCreateCmd(project *string) *cobra.Command { req := &basecamp.CreateAnswerRequest{ Content: html, - GroupOn: groupOn, + GroupOn: effectiveGroupOn, } answer, err := app.Account().Checkins().CreateAnswer(cmd.Context(), qID, req) diff --git a/internal/commands/checkins_test.go b/internal/commands/checkins_test.go index 4f35e6b52..901c9c792 100644 --- a/internal/commands/checkins_test.go +++ b/internal/commands/checkins_test.go @@ -62,6 +62,7 @@ func (m *mockCheckinsAnswerCreateTransport) RoundTrip(req *http.Request) (*http. } func TestCheckinsAnswerCreateDefaultsDateToToday(t *testing.T) { + expectedDate := time.Now().Format("2006-01-02") transport := &mockCheckinsAnswerCreateTransport{} app, _ := newTestAppWithTransport(t, transport) app.Config.ProjectID = "123" @@ -74,7 +75,7 @@ func TestCheckinsAnswerCreateDefaultsDateToToday(t *testing.T) { require.NotNil(t, transport.recordedBody) assert.Equal(t, "/99999/questions/456/answers.json", transport.recordedPath) assert.Equal(t, "

hello world

", transport.recordedBody["content"]) - assert.Equal(t, time.Now().Format("2006-01-02"), transport.recordedBody["group_on"]) + assert.Equal(t, expectedDate, transport.recordedBody["group_on"]) } func TestCheckinsAnswerCreatePreservesExplicitDate(t *testing.T) { From e5742c2f75cd7f8a3329b9bf4f99ccf164c93a47 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 24 Mar 2026 21:55:16 -0400 Subject: [PATCH 3/3] Fix check-ins answer date follow-ups --- internal/commands/checkins.go | 4 +- internal/commands/checkins_test.go | 14 ++++- internal/tui/workspace/data/hub.go | 7 ++- internal/tui/workspace/data/hub_test.go | 81 +++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 4 deletions(-) diff --git a/internal/commands/checkins.go b/internal/commands/checkins.go index b4e315cbc..266e5130c 100644 --- a/internal/commands/checkins.go +++ b/internal/commands/checkins.go @@ -14,6 +14,8 @@ import ( "github.com/basecamp/basecamp-cli/internal/richtext" ) +var checkinsNow = time.Now + // NewCheckinsCmd creates the checkins command group. func NewCheckinsCmd() *cobra.Command { var project string @@ -777,7 +779,7 @@ func newCheckinsAnswerCreateCmd(project *string) *cobra.Command { } effectiveGroupOn := groupOn if effectiveGroupOn == "" { - effectiveGroupOn = time.Now().Format("2006-01-02") + effectiveGroupOn = checkinsNow().Format("2006-01-02") } html := richtext.MarkdownToHTML(content) diff --git a/internal/commands/checkins_test.go b/internal/commands/checkins_test.go index 901c9c792..7194e7a22 100644 --- a/internal/commands/checkins_test.go +++ b/internal/commands/checkins_test.go @@ -30,6 +30,9 @@ func (m *mockCheckinsAnswerCreateTransport) RoundTrip(req *http.Request) (*http. }, nil case req.Method == "POST" && strings.Contains(req.URL.Path, "/questions/456/answers.json"): m.recordedPath = req.URL.Path + if req.Body != nil { + defer req.Body.Close() + } body, err := io.ReadAll(req.Body) if err != nil { return nil, err @@ -62,7 +65,14 @@ func (m *mockCheckinsAnswerCreateTransport) RoundTrip(req *http.Request) (*http. } func TestCheckinsAnswerCreateDefaultsDateToToday(t *testing.T) { - expectedDate := time.Now().Format("2006-01-02") + originalNow := checkinsNow + checkinsNow = func() time.Time { + return time.Date(2026, 3, 25, 9, 30, 0, 0, time.Local) + } + t.Cleanup(func() { + checkinsNow = originalNow + }) + transport := &mockCheckinsAnswerCreateTransport{} app, _ := newTestAppWithTransport(t, transport) app.Config.ProjectID = "123" @@ -75,7 +85,7 @@ func TestCheckinsAnswerCreateDefaultsDateToToday(t *testing.T) { require.NotNil(t, transport.recordedBody) assert.Equal(t, "/99999/questions/456/answers.json", transport.recordedPath) assert.Equal(t, "

hello world

", transport.recordedBody["content"]) - assert.Equal(t, expectedDate, transport.recordedBody["group_on"]) + assert.Equal(t, "2026-03-25", transport.recordedBody["group_on"]) } func TestCheckinsAnswerCreatePreservesExplicitDate(t *testing.T) { diff --git a/internal/tui/workspace/data/hub.go b/internal/tui/workspace/data/hub.go index a9e9c7ff9..400b75afb 100644 --- a/internal/tui/workspace/data/hub.go +++ b/internal/tui/workspace/data/hub.go @@ -10,6 +10,8 @@ import ( "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" ) +var hubNow = time.Now + // Hub is the central data coordinator providing typed, realm-scoped pool access. // // Hub manages three realm tiers: @@ -1160,7 +1162,10 @@ func (h *Hub) CreateCheckinAnswer(ctx context.Context, accountID string, project if client == nil { return fmt.Errorf("no client for account %s", accountID) } - _, err := client.Checkins().CreateAnswer(ctx, questionID, &basecamp.CreateAnswerRequest{Content: content}) + _, err := client.Checkins().CreateAnswer(ctx, questionID, &basecamp.CreateAnswerRequest{ + Content: content, + GroupOn: hubNow().Format("2006-01-02"), + }) return err } diff --git a/internal/tui/workspace/data/hub_test.go b/internal/tui/workspace/data/hub_test.go index 778afba15..4ac752520 100644 --- a/internal/tui/workspace/data/hub_test.go +++ b/internal/tui/workspace/data/hub_test.go @@ -1,6 +1,11 @@ package data import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" "testing" "time" @@ -9,6 +14,82 @@ import ( "github.com/stretchr/testify/require" ) +type hubCheckinsTestTokenProvider struct{} + +func (hubCheckinsTestTokenProvider) AccessToken(_ context.Context) (string, error) { + return "test-token", nil +} + +type mockHubCheckinsTransport struct { + recordedPath string + recordedBody map[string]any +} + +func (m *mockHubCheckinsTransport) RoundTrip(req *http.Request) (*http.Response, error) { + header := make(http.Header) + header.Set("Content-Type", "application/json") + + switch { + case req.Method == http.MethodPost && strings.Contains(req.URL.Path, "/questions/456/answers.json"): + m.recordedPath = req.URL.Path + if req.Body != nil { + defer req.Body.Close() + } + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + if err := json.Unmarshal(body, &m.recordedBody); err != nil { + return nil, err + } + return &http.Response{ + StatusCode: http.StatusCreated, + Body: io.NopCloser(strings.NewReader(`{ + "id": 789, + "content": "

hello world

", + "group_on": "2026-03-25", + "creator": {"name": "Rob Zolkos"}, + "parent": {"id": 456, "title": "What did you work on today?", "type": "Question", "url": "https://example.test/questions/456", "app_url": "https://example.test/questions/456"}, + "bucket": {"id": 123, "name": "Test Project", "type": "Project"}, + "status": "active", + "type": "Question::Answer", + "title": "Answer" + }`)), + Header: header, + }, nil + default: + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader(`{"error":"Not Found"}`)), + Header: header, + }, nil + } +} + +func TestHubCreateCheckinAnswerDefaultsDateToToday(t *testing.T) { + originalNow := hubNow + hubNow = func() time.Time { + return time.Date(2026, 3, 25, 9, 30, 0, 0, time.Local) + } + t.Cleanup(func() { + hubNow = originalNow + }) + + transport := &mockHubCheckinsTransport{} + sdk := basecamp.NewClient(&basecamp.Config{}, hubCheckinsTestTokenProvider{}, + basecamp.WithTransport(transport), + basecamp.WithMaxRetries(0), + ) + h := NewHub(NewMultiStore(sdk), "") + + err := h.CreateCheckinAnswer(context.Background(), "99999", 123, 456, "

hello world

") + require.NoError(t, err) + require.NotNil(t, transport.recordedBody) + assert.Equal(t, "/99999/questions/456/answers.json", transport.recordedPath) + assert.Equal(t, "

hello world

", transport.recordedBody["content"]) + assert.Equal(t, "2026-03-25", transport.recordedBody["group_on"]) +} + func TestHubNewHasGlobalRealm(t *testing.T) { h := NewHub(nil, "") require.NotNil(t, h.Global())