Skip to content
Merged
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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/basecamp/bcq
go 1.25.6

require (
github.com/basecamp/basecamp-sdk/go v0.0.0-20260128012932-9f42351682f4
github.com/basecamp/basecamp-sdk/go v0.0.0-20260128054241-c856b2d2b56e
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/huh v0.8.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/basecamp/basecamp-sdk/go v0.0.0-20260128012932-9f42351682f4 h1:7JW/iVzC+2OTijVxK8mEeMNlxjLIjf1nA4pIg25rv8M=
github.com/basecamp/basecamp-sdk/go v0.0.0-20260128012932-9f42351682f4/go.mod h1:9DVbzvhPE5xRSFeYyxm7dCw5/7LuCiRLh7MMS/GZp0Q=
github.com/basecamp/basecamp-sdk/go v0.0.0-20260128054241-c856b2d2b56e h1:xk+phvIXB7l1fyw84OExvzeVKh9bOZ9us2wgSZygzAA=
github.com/basecamp/basecamp-sdk/go v0.0.0-20260128054241-c856b2d2b56e/go.mod h1:9DVbzvhPE5xRSFeYyxm7dCw5/7LuCiRLh7MMS/GZp0Q=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
Expand Down
2 changes: 2 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ func Execute() {
cmd.AddCommand(commands.NewTodolistgroupsCmd())
cmd.AddCommand(commands.NewMCPCmd())
cmd.AddCommand(commands.NewCommandsCmd())
cmd.AddCommand(commands.NewTimelineCmd())
cmd.AddCommand(commands.NewReportsCmd())

if err := cmd.Execute(); err != nil {
// Transform Cobra errors to match Bash CLI error format
Expand Down
2 changes: 2 additions & 0 deletions internal/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ func commandCategories() []CommandCategory {
{Name: "schedule", Category: "scheduling", Description: "Manage schedule entries", Actions: []string{"show", "entries", "create", "update"}},
{Name: "timesheet", Category: "scheduling", Description: "View time tracking reports", Actions: []string{"report", "project", "recording"}},
{Name: "checkins", Category: "scheduling", Description: "View automatic check-ins", Actions: []string{"questions", "question", "answers", "answer"}},
{Name: "timeline", Category: "scheduling", Description: "View activity timelines", Actions: []string{}},
{Name: "reports", Category: "scheduling", Description: "View reports", Actions: []string{"assignable", "assigned", "overdue", "schedule"}},
},
},
{
Expand Down
2 changes: 2 additions & 0 deletions internal/commands/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ func TestCatalogMatchesRegisteredCommands(t *testing.T) {
root.AddCommand(commands.NewTodolistgroupsCmd())
root.AddCommand(commands.NewMCPCmd())
root.AddCommand(commands.NewCommandsCmd())
root.AddCommand(commands.NewTimelineCmd())
root.AddCommand(commands.NewReportsCmd())

// Trigger Cobra's auto-addition of help subcommand
root.InitDefaultHelpCmd()
Expand Down
299 changes: 299 additions & 0 deletions internal/commands/reports.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
package commands

import (
"fmt"
"strconv"

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

"github.com/basecamp/bcq/internal/appctx"
"github.com/basecamp/bcq/internal/dateparse"
"github.com/basecamp/bcq/internal/output"
)

// NewReportsCmd creates the reports command for viewing various reports.
func NewReportsCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "reports",
Short: "View reports",
Long: `View various reports including assignable people, assigned todos, overdue todos, and upcoming schedule.

Reports provide cross-project views of assignments and schedules.`,
}

cmd.AddCommand(
newReportsAssignableCmd(),
newReportsAssignedCmd(),
newReportsOverdueCmd(),
newReportsScheduleCmd(),
)

return cmd
}

func newReportsAssignableCmd() *cobra.Command {
return &cobra.Command{
Use: "assignable",
Short: "List people who can be assigned todos",
Long: "List all people in the account who can be assigned to todos.",
RunE: func(cmd *cobra.Command, args []string) error {
app := appctx.FromContext(cmd.Context())

people, err := app.SDK.Reports().AssignablePeople(cmd.Context())
if err != nil {
return convertSDKError(err)
}

summary := fmt.Sprintf("%d assignable people", len(people))

return app.Output.OK(people,
output.WithSummary(summary),
output.WithBreadcrumbs(
output.Breadcrumb{
Action: "assigned",
Cmd: "bcq reports assigned <person>",
Description: "View todos assigned to a person",
},
output.Breadcrumb{
Action: "people",
Cmd: "bcq people list",
Description: "List all people",
},
),
)
},
}
}

func newReportsAssignedCmd() *cobra.Command {
var groupBy string

cmd := &cobra.Command{
Use: "assigned [person]",
Short: "View todos assigned to a person",
Long: `View todos assigned to a specific person.

If no person is specified, defaults to "me" (the current user).
Results can be grouped by bucket (project) or date.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
app := appctx.FromContext(cmd.Context())

// Default to "me" if no person specified
person := "me"
if len(args) > 0 {
person = args[0]
}

// Resolve person name/ID
personIDStr, personName, err := app.Names.ResolvePerson(cmd.Context(), person)
if err != nil {
return err
}

personID, err := strconv.ParseInt(personIDStr, 10, 64)
if err != nil {
return output.ErrUsage("Invalid person ID")
}

// Build options
var opts *basecamp.AssignedTodosOptions
if groupBy != "" {
if groupBy != "bucket" && groupBy != "date" {
return output.ErrUsage("--group-by must be 'bucket' or 'date'")
}
opts = &basecamp.AssignedTodosOptions{GroupBy: groupBy}
}

result, err := app.SDK.Reports().AssignedTodos(cmd.Context(), personID, opts)
if err != nil {
return convertSDKError(err)
}

// Build summary
todoCount := len(result.Todos)
displayName := personName
if displayName == "" && result.Person != nil {
displayName = result.Person.Name
}
if displayName == "" {
displayName = personIDStr
}

summary := fmt.Sprintf("%d todos assigned to %s", todoCount, displayName)
if result.GroupedBy != "" {
summary += fmt.Sprintf(" (grouped by %s)", result.GroupedBy)
}

return app.Output.OK(result,
output.WithSummary(summary),
output.WithBreadcrumbs(
output.Breadcrumb{
Action: "overdue",
Cmd: "bcq reports overdue",
Description: "View overdue todos",
},
output.Breadcrumb{
Action: "todos",
Cmd: "bcq todos --in <project> --assignee " + personIDStr,
Description: "List todos in a specific project",
},
),
)
},
}

cmd.Flags().StringVar(&groupBy, "group-by", "", "Group results by 'bucket' or 'date'")

return cmd
}

func newReportsOverdueCmd() *cobra.Command {
return &cobra.Command{
Use: "overdue",
Short: "View overdue todos grouped by lateness",
Long: `View all overdue todos grouped by how late they are.

Todos are grouped into categories:
- Under a week late
- Over a week late
- Over a month late
- Over three months late`,
RunE: func(cmd *cobra.Command, args []string) error {
app := appctx.FromContext(cmd.Context())

result, err := app.SDK.Reports().OverdueTodos(cmd.Context())
if err != nil {
return convertSDKError(err)
}

// Count total overdue todos
total := len(result.UnderAWeekLate) +
len(result.OverAWeekLate) +
len(result.OverAMonthLate) +
len(result.OverThreeMonthsLate)

summary := fmt.Sprintf("%d overdue todos", total)
if total > 0 {
var parts []string
if n := len(result.UnderAWeekLate); n > 0 {
parts = append(parts, fmt.Sprintf("%d <1 week", n))
}
if n := len(result.OverAWeekLate); n > 0 {
parts = append(parts, fmt.Sprintf("%d >1 week", n))
}
if n := len(result.OverAMonthLate); n > 0 {
parts = append(parts, fmt.Sprintf("%d >1 month", n))
}
if n := len(result.OverThreeMonthsLate); n > 0 {
parts = append(parts, fmt.Sprintf("%d >3 months", n))
}
if len(parts) > 0 {
summary += " ("
for i, p := range parts {
if i > 0 {
summary += ", "
}
summary += p
}
summary += ")"
}
}

return app.Output.OK(result,
output.WithSummary(summary),
output.WithBreadcrumbs(
output.Breadcrumb{
Action: "assigned",
Cmd: "bcq reports assigned",
Description: "View your assigned todos",
},
output.Breadcrumb{
Action: "schedule",
Cmd: "bcq reports schedule",
Description: "View upcoming schedule",
},
),
)
},
}
}

func newReportsScheduleCmd() *cobra.Command {
var startDate string
var endDate string

cmd := &cobra.Command{
Use: "schedule",
Short: "View upcoming schedule entries",
Long: `View upcoming schedule entries and assignables within a date window.

By default shows the upcoming schedule. Use --start and --end to specify a date range.
Dates can be natural language (e.g., "today", "next week", "+7") or YYYY-MM-DD format.`,
RunE: func(cmd *cobra.Command, args []string) error {
app := appctx.FromContext(cmd.Context())

// Parse dates if provided (dateparse handles natural language like "today", "+7")
// Unrecognized formats are normalized (trimmed/lowercased) and passed through for the API to validate
parsedStart := dateparse.Parse(startDate)
parsedEnd := dateparse.Parse(endDate)

result, err := app.SDK.Reports().UpcomingSchedule(cmd.Context(), parsedStart, parsedEnd)
if err != nil {
return convertSDKError(err)
}

// Count items
entryCount := len(result.ScheduleEntries)
recurringCount := len(result.RecurringOccurrences)
assignableCount := len(result.Assignables)
total := entryCount + recurringCount + assignableCount

// Build summary
var parts []string
if entryCount > 0 {
parts = append(parts, fmt.Sprintf("%d entries", entryCount))
}
if recurringCount > 0 {
parts = append(parts, fmt.Sprintf("%d recurring", recurringCount))
}
if assignableCount > 0 {
parts = append(parts, fmt.Sprintf("%d assignables", assignableCount))
}

summary := fmt.Sprintf("%d upcoming items", total)
if len(parts) > 0 {
summary += " ("
for i, p := range parts {
if i > 0 {
summary += ", "
}
summary += p
}
summary += ")"
}

return app.Output.OK(result,
output.WithSummary(summary),
output.WithBreadcrumbs(
output.Breadcrumb{
Action: "overdue",
Cmd: "bcq reports overdue",
Description: "View overdue todos",
},
output.Breadcrumb{
Action: "schedule",
Cmd: "bcq schedule entries --project <project>",
Description: "View project schedule",
},
),
)
},
}

cmd.Flags().StringVar(&startDate, "start", "", "Start date (e.g., today, next week, 2024-01-15)")
cmd.Flags().StringVar(&endDate, "end", "", "End date (e.g., +30, eom, 2024-02-15)")

return cmd
}
Loading
Loading