Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -14775,6 +14775,7 @@ FLAG basecamp timeline --agent type=bool
FLAG basecamp timeline --all type=bool
FLAG basecamp timeline --cache-dir type=string
FLAG basecamp timeline --count type=bool
FLAG basecamp timeline --date type=string
FLAG basecamp timeline --help type=bool
FLAG basecamp timeline --hints type=bool
FLAG basecamp timeline --ids-only type=bool
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ require (
charm.land/bubbles/v2 v2.2.1
charm.land/bubbletea/v2 v2.1.0
charm.land/lipgloss/v2 v2.0.6
github.com/basecamp/basecamp-sdk/go v0.27.1-0.20261022230746-63115e7978c9
github.com/basecamp/basecamp-sdk/go v0.27.2-0.20261023175748-ab6fefd08980
github.com/basecamp/cli v0.2.2-0.20261023171439-c9bce8206df9
github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d
github.com/basecamp/surfguard/go v0.1.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/basecamp/actioncable-go v1.2.0 h1:t0MxlfMTcmaGXZ5ztgKRs7ZiY1MwXtBRwSSSAh1ofVg=
github.com/basecamp/actioncable-go v1.2.0/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs=
github.com/basecamp/basecamp-sdk/go v0.27.1-0.20261022230746-63115e7978c9 h1:ZcW7c/cnQTgFkNHMcnkxPqHIko2d0v2MrOgyykBrOj0=
github.com/basecamp/basecamp-sdk/go v0.27.1-0.20261022230746-63115e7978c9/go.mod h1:mUm/MQGMyg1E1cddyxdG7tQHiq0NsYee2zUeAUT5VjU=
github.com/basecamp/basecamp-sdk/go v0.27.2-0.20261023175748-ab6fefd08980 h1:UWxSoySz1Ojy4LTjiYmZl1J1zvmDu/pZOQ+7hHpTq6Q=
github.com/basecamp/basecamp-sdk/go v0.27.2-0.20261023175748-ab6fefd08980/go.mod h1:mUm/MQGMyg1E1cddyxdG7tQHiq0NsYee2zUeAUT5VjU=
github.com/basecamp/cli v0.2.2-0.20261023171439-c9bce8206df9 h1:3NfGs+zx23Tp6cf+97+UewrJHlzZMLzmvEXOmOJyQAk=
github.com/basecamp/cli v0.2.2-0.20261023171439-c9bce8206df9/go.mod h1:iTBTaWvsPEFIcZfkxQHEfISyJ6sZ7036K6bNx0RY3EE=
github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d h1:zEQVGq1x1nhKMZ2TudFAcSJ32CHT8richI1vQakIKz4=
Expand Down
55 changes: 49 additions & 6 deletions internal/commands/timeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ package commands
import (
"fmt"
"strconv"
"time"

"github.com/basecamp/basecamp-sdk/go/pkg/basecamp"
"github.com/spf13/cobra"

"github.com/basecamp/basecamp-cli/internal/appctx"
"github.com/basecamp/basecamp-cli/internal/dateparse"
"github.com/basecamp/basecamp-cli/internal/output"
)

Expand All @@ -18,20 +20,22 @@ func NewTimelineCmd() *cobra.Command {
var limit int
var page int
var all bool
var date string

cmd := &cobra.Command{
Use: "timeline [me]",
Short: "View activity timeline",
Long: `View activity timelines for the account, a project, or a person.

By default, shows the account-wide activity feed (recent activity across all projects).
By default, shows today's account-wide activity feed (activity across all projects).
Basecamp serves that feed one day at a time: use --date to read another day.

Use --in to view a specific project's timeline.
Use "me" or --person to view a person's activity timeline.`,
Annotations: map[string]string{"agent_notes": "Timeline shows activity feed — account-wide by default, or scoped with --in <project> or --person <id>"},
Annotations: map[string]string{"agent_notes": "Timeline shows activity feed — account-wide by default, one day per call (--date YYYY-MM-DD for an earlier day), or scoped with --in <project> or --person <id>"},
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runTimeline(cmd, args, project, person, limit, page, all)
return runTimeline(cmd, args, project, person, date, limit, page, all)
},
}

Expand All @@ -41,6 +45,7 @@ Use "me" or --person to view a person's activity timeline.`,
cmd.Flags().IntVarP(&limit, "limit", "n", 0, "Maximum number of events to fetch (0 = default 100)")
cmd.Flags().BoolVar(&all, "all", false, "Fetch all events (no limit)")
cmd.Flags().IntVar(&page, "page", 0, "Fetch a single page (use --all for everything)")
cmd.Flags().StringVar(&date, "date", "", "Day of the account-wide feed to read (YYYY-MM-DD, today, yesterday)")

return cmd
}
Expand Down Expand Up @@ -71,10 +76,34 @@ func timelineListOpts(limit, page int, all bool) *basecamp.TimelineListOptions {
return opts
}

func runTimeline(cmd *cobra.Command, args []string, project, person string, limit, page int, all bool) error {
// timelineDay parses --date. Basecamp reads a day it can't parse as today,
// so anything that isn't a date is refused here, as is a future day, which
// has no activity; dateparse reads a weekday name as the next one.
func timelineDay(date string, now time.Time) (basecamp.Date, error) {
if date == "" {
return basecamp.Date{}, nil
}
day, err := basecamp.ParseDate(dateparse.ParseFrom(date, now))
if err != nil || day.IsZero() {
return basecamp.Date{}, output.ErrUsageHint(fmt.Sprintf("invalid --date %q", date), "Use YYYY-MM-DD, today or yesterday.")
}
if day.After(basecamp.DateOf(now)) {
return basecamp.Date{}, output.ErrUsageHint(fmt.Sprintf("--date %s is in the future", day), "The activity feed has nothing after today.")
}
return day, nil
}

func runTimeline(cmd *cobra.Command, args []string, project, person, date string, limit, page int, all bool) error {
if err := validateTimelinePagination(limit, page, all); err != nil {
return err
}
if date != "" && (project != "" || person != "" || len(args) > 0) {
return output.ErrUsage("--date reads the account-wide feed; Basecamp doesn't filter project or person timelines by day")
}
day, err := timelineDay(date, time.Now())
if err != nil {
return err
}

app := appctx.FromContext(cmd.Context())

Expand Down Expand Up @@ -116,14 +145,28 @@ func runTimeline(cmd *cobra.Command, args []string, project, person string, limi
}

// Default: account-wide activity feed
result, err := app.Account().Timeline().Progress(cmd.Context(), opts)
result, err := app.Account().Timeline().Progress(cmd.Context(), &basecamp.ProgressOptions{Limit: opts.Limit, Page: opts.Page, Date: day})
if err != nil {
return convertSDKError(err)
}

// Undated, the day is today in the person's Basecamp time zone, which
// this machine's clock can't name, so neither can the day before it.
summary := fmt.Sprintf("%d events today", len(result.Events))
earlier := output.Breadcrumb{Action: "earlier", Cmd: "basecamp timeline --date <YYYY-MM-DD>", Description: "View an earlier day"}
if !day.IsZero() {
summary = fmt.Sprintf("%d events on %s", len(result.Events), day)
earlier = output.Breadcrumb{
Action: "previous",
Cmd: fmt.Sprintf("basecamp timeline --date %s", day.AddDays(-1)),
Description: "View the day before",
}
}

respOpts := []output.ResponseOption{
output.WithSummary(fmt.Sprintf("%d recent events", len(result.Events))),
output.WithSummary(summary),
output.WithBreadcrumbs(
earlier,
output.Breadcrumb{
Action: "project",
Cmd: "basecamp timeline --in <project>",
Expand Down
110 changes: 110 additions & 0 deletions internal/commands/timeline_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package commands

import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strings"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/basecamp/basecamp-cli/internal/output"
)

// progressTransport records the query of each progress.json request.
type progressTransport struct {
queries []url.Values
}

func (p *progressTransport) RoundTrip(req *http.Request) (*http.Response, error) {
header := http.Header{"Content-Type": []string{"application/json"}}
if req.URL.Path != "/99999/reports/progress.json" {
return &http.Response{StatusCode: http.StatusNotFound, Header: header, Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
}
p.queries = append(p.queries, req.URL.Query())
return &http.Response{StatusCode: http.StatusOK, Header: header, Body: io.NopCloser(strings.NewReader(`[]`)), Request: req}, nil
}

func firstBreadcrumb(t *testing.T, buf *bytes.Buffer) string {
t.Helper()
var envelope struct {
Breadcrumbs []struct {
Cmd string `json:"cmd"`
} `json:"breadcrumbs"`
}
require.NoError(t, json.Unmarshal(buf.Bytes(), &envelope))
require.NotEmpty(t, envelope.Breadcrumbs)
return envelope.Breadcrumbs[0].Cmd
}

func TestTimelineDateReadsThatDay(t *testing.T) {
transport := &progressTransport{}
app, buf := newTestAppWithTransport(t, transport)
app.Flags.Hints = true

require.NoError(t, executeCommand(NewTimelineCmd(), app, "--date", "2026-08-30"))

require.Len(t, transport.queries, 1)
assert.Equal(t, "2026-08-30", transport.queries[0].Get("date"))
assert.Equal(t, "basecamp timeline --date 2026-08-29", firstBreadcrumb(t, buf))
}

func TestTimelineWithoutDateReadsToday(t *testing.T) {
transport := &progressTransport{}
app, buf := newTestAppWithTransport(t, transport)
app.Flags.Hints = true

require.NoError(t, executeCommand(NewTimelineCmd(), app))

require.Len(t, transport.queries, 1)
assert.False(t, transport.queries[0].Has("date"))
// Undated, the day is Basecamp's today, which this machine's clock can't
// name, so the breadcrumb can't name the day before it either.
assert.Equal(t, "basecamp timeline --date <YYYY-MM-DD>", firstBreadcrumb(t, buf))
}

func TestTimelineDateRefusals(t *testing.T) {
for name, args := range map[string][]string{
"not a date": {"--date", "someday"},
"blank": {"--date", " "},
"in future": {"--date", "tomorrow"},
"for project": {"--date", "2026-08-30", "--in", "123"},
"for person": {"--date", "2026-08-30", "me"},
} {
t.Run(name, func(t *testing.T) {
transport := &progressTransport{}
app, _ := newTestAppWithTransport(t, transport)

err := executeCommand(NewTimelineCmd(), app, args...)

var e *output.Error
require.True(t, errors.As(err, &e), "expected *output.Error, got %T: %v", err, err)
assert.Equal(t, output.CodeUsage, e.Code)
assert.Empty(t, transport.queries)
})
}
}

// Chile starts daylight saving at midnight on 2026-09-06, so that day has no
// midnight and local-midnight arithmetic lands a day early.
func TestTimelineDateBreadcrumbAcrossAMidnightClockChange(t *testing.T) {
santiago, err := time.LoadLocation("America/Santiago")
require.NoError(t, err)
originalLocal := time.Local
time.Local = santiago
t.Cleanup(func() { time.Local = originalLocal })

transport := &progressTransport{}
app, buf := newTestAppWithTransport(t, transport)
app.Flags.Hints = true

require.NoError(t, executeCommand(NewTimelineCmd(), app, "--date", "2026-09-06"))

assert.Equal(t, "basecamp timeline --date 2026-09-05", firstBreadcrumb(t, buf))
}
23 changes: 23 additions & 0 deletions internal/mcpserver/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -337,3 +337,26 @@ func TestCatalogRefusesAnUndecidedPaginationStyle(t *testing.T) {
assert.Contains(t, err.Error(), `"offset"`)
assert.Empty(t, cat.Domains[0].Operations[0].Params, "nothing may be synthesized for an undecided style")
}

// TestCatalogKeepsTheActivityFilterOffTheProgressReport pins a policy the
// model sync applies. bc3 reads project_ids[] and people_ids[] on the
// progress report, but a list naming no ID the caller can see clears the
// caller's saved web activity filter. That makes a GET change state, which a
// read-only server must not do, so neither list is served; the day is.
func TestCatalogKeepsTheActivityFilterOffTheProgressReport(t *testing.T) {
cat := loadForTest(t)
var query []string
for _, d := range cat.Domains {
for _, op := range d.Operations {
if op.ID != "GetProgressReport" {
continue
}
for _, p := range op.Params {
if p.In == "query" {
query = append(query, p.Name)
}
}
}
}
assert.ElementsMatch(t, []string{"date", "page"}, query)
}
4 changes: 2 additions & 2 deletions internal/mcpserver/model/PROVENANCE.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"source": "github.com/basecamp/basecamp-sdk",
"commit": "63115e7978c989a271220cee8b025135315db882",
"ref": "v0.27.1-3-g63115e79",
"commit": "ab6fefd0898055b78f397f269e53e378bc6ffa6f",
"ref": "v0.27.1-6-gab6fefd0",
"files": ["behavior-model.json", "openapi.json"],
"synced_by": "scripts/sync-mcp-model.sh",
"patches": "binary-upload operations dropped because the toolkit refuses their non-JSON bodies (EXCLUDED_OPERATIONS) and the stream-ticket mint dropped by policy (POLICY_EXCLUDED_OPERATIONS); no tag patches applied (PATCHED_TAGS is empty — the export tags every operation) — see the sync script"
Expand Down
9 changes: 9 additions & 0 deletions internal/mcpserver/model/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -21515,6 +21515,15 @@
},
"required": true
},
{
"name": "date",
"in": "query",
"description": "The day to read (YYYY-MM-DD). The feed serves one day per request and defaults to today, so step this back a day at a time to read history; `page` pages within the day. The server reads an unparseable date as today.",
"schema": {
"type": "string",
"description": "The day to read (YYYY-MM-DD). The feed serves one day per request and defaults to today, so step this back a day at a time to read history; `page` pages within the day. The server reads an unparseable date as today."
}
},
{
"name": "page",
"in": "query",
Expand Down
43 changes: 36 additions & 7 deletions internal/tui/workspace/data/hub_global.go
Original file line number Diff line number Diff line change
Expand Up @@ -592,8 +592,8 @@ func (h *Hub) Timeline() *Pool[[]TimelineEventInfo] {
sort.Slice(all, func(i, j int) bool {
return all[i].CreatedAtTS > all[j].CreatedAtTS
})
if len(all) > 80 {
all = all[:80]
if len(all) > timelineFeedSize {
all = all[:timelineFeedSize]
}
return all, nil
})
Expand All @@ -605,14 +605,43 @@ func (h *Hub) Timeline() *Pool[[]TimelineEventInfo] {

// -- Shared fetch helpers

// timelineFeedSize is how many events the activity feed shows.
const timelineFeedSize = 80

// timelineLookbackDays is how many days, today included, a quiet account's
// feed reads.
const timelineLookbackDays = 7

// fetchTimelineEvents fetches timeline events from a single account.
// Basecamp serves the activity feed one day per request, so this asks for
// today undated, which is today in the person's Basecamp time zone, then
// steps back a day at a time from today on this machine until the feed is
// full. Starting the dated walk at this machine's today, not yesterday, keeps
// a day from being skipped when Basecamp's date is ahead; when the dates
// agree that day comes back twice, so an event already seen is skipped.
func fetchTimelineEvents(ctx context.Context, client *basecamp.AccountClient, acct AccountInfo) ([]TimelineEventInfo, error) {
result, err := client.Timeline().Progress(ctx, nil)
if err != nil {
return nil, err
var events []basecamp.TimelineEvent
seen := make(map[int64]bool)
today := basecamp.DateOf(hubNow())
for back := -1; back < timelineLookbackDays && len(events) < timelineFeedSize; back++ {
opts := &basecamp.ProgressOptions{Limit: timelineFeedSize - len(events)}
if back >= 0 {
opts.Date = today.AddDays(-back)
}
result, err := client.Timeline().Progress(ctx, opts)
if err != nil {
return nil, err
}
for _, e := range result.Events {
if !seen[e.ID] {
seen[e.ID] = true
events = append(events, e)
}
}
}
infos := make([]TimelineEventInfo, 0, len(result.Events))
for _, e := range result.Events {

infos := make([]TimelineEventInfo, 0, len(events))
for _, e := range events {
project := ""
var projectID int64
if e.Bucket != nil {
Expand Down
Loading
Loading