diff --git a/internal/commands/checkins.go b/internal/commands/checkins.go index 45a9c242f..266e5130c 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" @@ -13,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 @@ -774,6 +777,10 @@ func newCheckinsAnswerCreateCmd(project *string) *cobra.Command { if err != nil { return output.ErrUsage("Invalid question ID") } + effectiveGroupOn := groupOn + if effectiveGroupOn == "" { + effectiveGroupOn = checkinsNow().Format("2006-01-02") + } html := richtext.MarkdownToHTML(content) @@ -794,7 +801,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) @@ -825,7 +832,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..7194e7a22 --- /dev/null +++ b/internal/commands/checkins_test.go @@ -0,0 +1,103 @@ +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 + 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: 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) { + 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" + + 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, "2026-03-25", 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/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()) 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