Conversation
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
There was a problem hiding this comment.
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 |
| // 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 | ||
| } |
There was a problem hiding this comment.
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.
| 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 | ||
| } |
There was a problem hiding this comment.
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.
| endsAt = startsAt | ||
| } else { | ||
| // Default to 1 hour duration | ||
| startTime, _ := time.Parse(time.RFC3339, startsAt) |
There was a problem hiding this comment.
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.
| 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) | |
| } |
| 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)") |
There was a problem hiding this comment.
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.
| 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) | ||
| } |
There was a problem hiding this comment.
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.
| // 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 | ||
| } |
There was a problem hiding this comment.
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.
| 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 | ||
| } |
There was a problem hiding this comment.
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.
- 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>
Implement comprehensive schedule (calendar) functionality:
Commands:
Features: