Skip to content
This repository was archived by the owner on Mar 12, 2026. It is now read-only.

feat: add schedules & calendar support#124

Merged
brigleb merged 3 commits intomainfrom
claude/add-schedules-calendar-4z4J9
Jan 19, 2026
Merged

feat: add schedules & calendar support#124
brigleb merged 3 commits intomainfrom
claude/add-schedules-calendar-4z4J9

Conversation

@brigleb
Copy link
Copy Markdown
Member

@brigleb brigleb commented Jan 19, 2026

Implement comprehensive schedule (calendar) functionality:

Commands:

  • bc4 schedule list - List all schedules in a project
  • bc4 schedule view [id] - View a specific schedule
  • bc4 schedule entry list - List schedule entries (calendar events)
  • bc4 schedule entry view - View entry details
  • bc4 schedule entry create - Create new calendar event
  • bc4 schedule entry edit - Edit calendar event
  • bc4 schedule entry delete - Delete calendar event

Features:

  • Filter entries with --upcoming, --past, or --range
  • Support for all-day events (--all-day flag)
  • Support for participants (--participant flag)
  • Notification option (--notify flag)
  • Date parsing supports ISO 8601 and relative dates (today, tomorrow, next-monday)
  • JSON output support for all commands

Implement comprehensive schedule (calendar) functionality:

Commands:
- bc4 schedule list - List all schedules in a project
- bc4 schedule view [id] - View a specific schedule
- bc4 schedule entry list - List schedule entries (calendar events)
- bc4 schedule entry view <id> - View entry details
- bc4 schedule entry create - Create new calendar event
- bc4 schedule entry edit <id> - Edit calendar event
- bc4 schedule entry delete <id> - Delete calendar event

Features:
- Filter entries with --upcoming, --past, or --range <start> <end>
- Support for all-day events (--all-day flag)
- Support for participants (--participant flag)
- Notification option (--notify flag)
- Date parsing supports ISO 8601 and relative dates (today, tomorrow, next-monday)
- JSON output support for all commands
Copilot AI review requested due to automatic review settings January 19, 2026 01:31
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request adds comprehensive schedule (calendar) functionality to the bc4 CLI tool, following the established patterns from other commands like card, todo, and message management.

Changes:

  • Implements complete schedule/calendar management with 9 new command files and 3 API layer files
  • Adds filtering capabilities (upcoming, past, date range) for schedule entries
  • Includes support for all-day events, participants, and notifications with flexible date parsing

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
internal/api/schedule.go Defines Schedule and ScheduleEntry models with full CRUD operations for calendar functionality
internal/api/modular.go Adds ScheduleOperations interface following the existing modular client pattern
internal/api/interface.go Extends APIClient interface with schedule method signatures
internal/api/activity.go Adds AppURL field to Parent struct for enhanced URL support
internal/api/mock/client.go Implements mock methods for all schedule operations to support testing
cmd/schedule/schedule.go Root schedule command with aliases (cal, calendar) and subcommand registration
cmd/schedule/list.go Lists project schedules with JSON and table output formats
cmd/schedule/view.go Views detailed schedule information by ID or URL
cmd/schedule/entry.go Entry subcommand root with aliases (entries, event, events)
cmd/schedule/entry_list.go Lists calendar entries with filtering options (upcoming, past, range)
cmd/schedule/entry_view.go Displays detailed entry information including recurrence patterns
cmd/schedule/entry_create.go Creates new calendar events with participant resolution and date parsing
cmd/schedule/entry_edit.go Updates existing entries with partial update support
cmd/schedule/entry_delete.go Deletes (trashes) calendar entries
cmd/root.go Registers the schedule command with the root CLI

Comment on lines +129 to +148
// Determine if this is going to be an all-day event for date parsing
isAllDay := opts.allDay != nil && *opts.allDay

if opts.startsAt != "" {
startsAt, err := parseDateTime(opts.startsAt, isAllDay)
if err != nil {
return fmt.Errorf("invalid start date/time: %w", err)
}
req.StartsAt = &startsAt
hasChanges = true
}

if opts.endsAt != "" {
endsAt, err := parseDateTime(opts.endsAt, isAllDay)
if err != nil {
return fmt.Errorf("invalid end date/time: %w", err)
}
req.EndsAt = &endsAt
hasChanges = true
}
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic for determining if an event is all-day when parsing dates may be incorrect. At line 130, isAllDay is set based on whether the user is changing the event TO all-day. However, if the user is updating the start/end times without changing the all-day flag, the parseDateTime function will use isAllDay=false even if the existing event is already an all-day event. This could lead to incorrectly formatted dates when updating times on an existing all-day event. Consider fetching the existing entry first to determine its current all-day status, or require the user to explicitly specify the all-day flag when updating times.

Copilot uses AI. Check for mistakes.
Comment on lines +12 to +86
type entryDeleteOptions struct {
force bool
}

func newEntryDeleteCmd(f *factory.Factory) *cobra.Command {
opts := &entryDeleteOptions{}

cmd := &cobra.Command{
Use: "delete <entry-id|URL>",
Short: "Delete a schedule entry (calendar event)",
Long: `Delete (trash) a calendar event from the project's schedule.

The event will be moved to trash and can be recovered from the Basecamp web interface.

You can specify the entry using either:
- A numeric ID (e.g., "12345")
- A Basecamp URL`,
Example: ` # Delete an entry by ID
bc4 schedule entry delete 12345

# Delete without confirmation (if prompts are added in future)
bc4 schedule entry delete 12345 --force`,
Aliases: []string{"rm", "remove"},
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runEntryDelete(f, opts, args)
},
}

cmd.Flags().BoolVarP(&opts.force, "force", "f", false, "Skip confirmation prompt")

return cmd
}

func runEntryDelete(f *factory.Factory, opts *entryDeleteOptions, args []string) error {
// Parse entry ID (could be numeric ID or URL)
entryID, parsedURL, err := parser.ParseArgument(args[0])
if err != nil {
return fmt.Errorf("invalid entry ID or URL: %s", args[0])
}

// Apply URL overrides if provided
if parsedURL != nil {
if parsedURL.AccountID > 0 {
f = f.WithAccount(strconv.FormatInt(parsedURL.AccountID, 10))
}
if parsedURL.ProjectID > 0 {
f = f.WithProject(strconv.FormatInt(parsedURL.ProjectID, 10))
}
}

// Get API client from factory
client, err := f.ApiClient()
if err != nil {
return err
}
scheduleOps := client.Schedules()

// Get resolved project ID
projectID, err := f.ProjectID()
if err != nil {
return err
}

// Delete the entry
err = scheduleOps.DeleteScheduleEntry(f.Context(), projectID, entryID)
if err != nil {
return fmt.Errorf("failed to delete schedule entry: %w", err)
}

// Output confirmation
fmt.Printf("#%d deleted\n", entryID)

return nil
}
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The force flag is defined and exposed in the command but never used in the implementation. Either implement confirmation prompting with force flag support, or remove the flag if it's not needed yet. The example in line 32-33 suggests this is for future use, but having an unused flag could confuse users.

Copilot uses AI. Check for mistakes.
endsAt = startsAt
} else {
// Default to 1 hour duration
startTime, _ := time.Parse(time.RFC3339, startsAt)
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error from time.Parse is being silently ignored on line 116. If startsAt cannot be parsed as RFC3339 (which should not happen since parseDateTime returns RFC3339 format), the startTime will be zero-valued and adding 1 hour will result in an incorrect timestamp. While this is unlikely given parseDateTime's implementation, it's better to handle the error explicitly or add a comment explaining why it's safe to ignore.

Suggested change
startTime, _ := time.Parse(time.RFC3339, startsAt)
startTime, err := time.Parse(time.RFC3339, startsAt)
if err != nil {
return fmt.Errorf("failed to parse start time as RFC3339: %w", err)
}

Copilot uses AI. Check for mistakes.
Comment on lines +47 to +70
bc4 schedule entry list --range 2025-01-01 2025-01-31

# Output as JSON
bc4 schedule entry list --json`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
opts.jsonOutput = viper.GetBool("json")

// Handle --range flag with two positional values
rangeArgs, _ := cmd.Flags().GetStringSlice("range")
if len(rangeArgs) == 2 {
opts.startDate = rangeArgs[0]
opts.endDate = rangeArgs[1]
} else if len(rangeArgs) == 1 {
return fmt.Errorf("--range requires both start and end dates (e.g., --range 2025-01-01,2025-01-31)")
}

return runEntryList(f, opts)
},
}

cmd.Flags().BoolVar(&opts.upcoming, "upcoming", false, "Show only upcoming events")
cmd.Flags().BoolVar(&opts.past, "past", false, "Show only past events")
cmd.Flags().StringSlice("range", nil, "Filter by date range: --range START_DATE,END_DATE (YYYY-MM-DD)")
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The --range flag documentation is inconsistent. Line 47 shows the usage as "--range 2025-01-01 2025-01-31" (space-separated), but line 70 defines it as a StringSlice flag with the description saying "--range START_DATE,END_DATE" (comma-separated), and line 61 also mentions comma-separated format. Users will expect space-separated based on the example, but the implementation expects comma-separated. Either update the example to show comma-separated format, or change the flag handling to accept space-separated values.

Copilot uses AI. Check for mistakes.
Comment on lines +98 to +106
if opts.upcoming {
entries, err = scheduleOps.GetUpcomingScheduleEntries(f.Context(), projectID, schedule.ID)
} else if opts.past {
entries, err = scheduleOps.GetPastScheduleEntries(f.Context(), projectID, schedule.ID)
} else if opts.startDate != "" && opts.endDate != "" {
entries, err = scheduleOps.GetScheduleEntriesInRange(f.Context(), projectID, schedule.ID, opts.startDate, opts.endDate)
} else {
entries, err = scheduleOps.GetScheduleEntries(f.Context(), projectID, schedule.ID)
}
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The command allows multiple conflicting filter flags to be specified simultaneously (--upcoming, --past, and --range). If a user specifies multiple flags, only the first one in the if-else chain will be used, which could be confusing. Consider adding validation to ensure only one filter type is specified at a time, or document the precedence order clearly in the help text.

Copilot uses AI. Check for mistakes.
Comment on lines +206 to +222
// formatParticipants formats participant names for display
func formatParticipants(participants []api.Person) string {
if len(participants) == 0 {
return "-"
}

names := make([]string, len(participants))
for i, p := range participants {
names[i] = p.Name
}

result := strings.Join(names, ", ")
if len(result) > 30 {
return fmt.Sprintf("%s... (+%d)", names[0], len(participants)-1)
}
return result
}
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The formatParticipants function is defined but never used in the code. Lines 158-163 display only the participant count, not formatted participant names. Either remove this unused function or use it in the table output instead of showing just the count.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +38
package schedule

import (
"github.com/needmore/bc4/internal/cmdutil"
"github.com/needmore/bc4/internal/factory"
"github.com/spf13/cobra"
)

// NewScheduleCmd creates the schedule command
func NewScheduleCmd(f *factory.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "schedule",
Short: "Work with Basecamp schedules - calendar events and entries",
Long: `Work with Basecamp schedules and calendar events.

Each Basecamp project can have a schedule (calendar) containing events.
Events can be single or recurring, all-day or timed, and can have participants.

Examples:
bc4 schedule list # List schedules in current project
bc4 schedule view 123 # View schedule details
bc4 schedule entry list # List calendar events
bc4 schedule entry list --upcoming # List upcoming events
bc4 schedule entry view 456 # View event details
bc4 schedule entry create "Meeting" # Create a new event`,
Aliases: []string{"schedules", "cal", "calendar"},
}

// Enable suggestions for subcommand typos
cmdutil.EnableSuggestions(cmd)

// Add subcommands
cmd.AddCommand(newListCmd(f))
cmd.AddCommand(newViewCmd(f))
cmd.AddCommand(newEntryCmd(f))

return cmd
}
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The schedule command implementation lacks test coverage. Other commands in the repository (card, message, todo, project, etc.) all have comprehensive test files, but no test files exist for any of the schedule commands. Add test files following the existing patterns, such as schedule_test.go, entry_list_test.go, entry_create_test.go, etc.

Copilot uses AI. Check for mistakes.
brigleb and others added 2 commits January 18, 2026 18:01
- Add tests for schedule command structure and subcommands
- Add tests for entry list command with flag validation
- Add tests for entry create command including parseDateTime function
- Add TODO comments for future recurring event support
- All tests pass successfully

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
The Basecamp API's dock.enabled field doesn't accurately reflect whether
a tool is accessible. Following the pattern used by todoset lookup, we
should not check the enabled field - if the tool is in the dock, it's
available for use.

This fixes the issue where schedules were incorrectly reported as
"not enabled" even when visible and functional in the Basecamp UI.

Tested with real project data - schedule entries can now be listed,
created, viewed, and deleted successfully.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@brigleb brigleb merged commit 1c8a832 into main Jan 19, 2026
1 of 2 checks passed
@brigleb brigleb deleted the claude/add-schedules-calendar-4z4J9 branch January 19, 2026 03:19
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants