Skip to content

Commit 5df6c07

Browse files
authored
Merge pull request #3 from bpauli/feat/workouts-schedule-subcommands
feat: restructure `workouts schedule` into add/list/remove subcommands
2 parents aa1f6df + 341a6dc commit 5df6c07

File tree

7 files changed

+546
-18
lines changed

7 files changed

+546
-18
lines changed

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Fast, script-friendly CLI for Garmin Connect. Access activities, health data, bo
1111
- **Activities** — list, search, view details, download (FIT/GPX/TCX/KML/CSV), upload, create manual entries, rename, retype, delete
1212
- **Health Data** — daily summaries, steps, heart rate, resting HR, sleep, stress, HRV, SpO2, respiration, body battery, floors, training readiness/status, VO2max, fitness age, race predictions, endurance/hill scores, intensity minutes, lactate threshold, cycling FTP
1313
- **Body Composition** — weight tracking, body fat, muscle mass, blood pressure, FIT file encoding for composition uploads
14-
- **Workouts** — list, view, download as FIT, upload from JSON, create with sport types and targets (pace/HR/power/cadence), schedule, delete
14+
- **Workouts** — list, view, download as FIT, upload from JSON, create with sport types and targets (pace/HR/power/cadence), schedule (add/list/remove), delete
1515
- **Devices** — list registered devices, view settings, solar data, alarms, primary/last-used device
1616
- **Gear** — list gear, usage stats, linked activities, defaults per activity type, link/unlink to activities
1717
- **Goals & Badges** — active goals, earned/available/in-progress badges, challenges, personal records
@@ -351,7 +351,10 @@ gccli workouts list --limit 20
351351
gccli workouts detail <id>
352352
gccli workouts download <id> --output workout.fit
353353
gccli workouts upload ./workout.json # See JSON structure below
354-
gccli workouts schedule <id> <YYYY-MM-DD>
354+
gccli workouts schedule add <id> <YYYY-MM-DD>
355+
gccli workouts schedule list <YYYY-MM-DD>
356+
gccli workouts schedule remove <schedule-id>
357+
gccli workouts schedule remove <schedule-id> --force
355358
gccli workouts delete <id>
356359

357360
# Create a running workout with pace targets

docs/index.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -556,7 +556,9 @@ <h2 class="section-title">Commands</h2>
556556
<div class="cmd-row"><div class="cmd">gccli workouts download &lt;id&gt; --output workout.fit</div><div class="desc">Download workout as FIT</div></div>
557557
<div class="cmd-row"><div class="cmd">gccli workouts upload ./workout.json</div><div class="desc">Upload workout from JSON</div></div>
558558
<div class="cmd-row"><div class="cmd">gccli workouts create "Name" --type run --step "warmup:5m" ...</div><div class="desc">Create with pace/HR/power targets</div></div>
559-
<div class="cmd-row"><div class="cmd">gccli workouts schedule &lt;id&gt; &lt;YYYY-MM-DD&gt;</div><div class="desc">Schedule workout on a date</div></div>
559+
<div class="cmd-row"><div class="cmd">gccli workouts schedule add &lt;id&gt; &lt;YYYY-MM-DD&gt;</div><div class="desc">Schedule workout on a date</div></div>
560+
<div class="cmd-row"><div class="cmd">gccli workouts schedule list &lt;YYYY-MM-DD&gt;</div><div class="desc">List scheduled workouts for a date</div></div>
561+
<div class="cmd-row"><div class="cmd">gccli workouts schedule remove &lt;schedule-id&gt; [-f]</div><div class="desc">Remove a scheduled workout</div></div>
560562
<div class="cmd-row"><div class="cmd">gccli workouts delete &lt;id&gt;</div><div class="desc">Delete a workout</div></div>
561563
</div>
562564
</div>

internal/cmd/workouts.go

Lines changed: 132 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ type WorkoutsCmd struct {
1616
Download WorkoutDownloadCmd `cmd:"" help:"Download workout as FIT file."`
1717
Upload WorkoutUploadCmd `cmd:"" help:"Upload workout from JSON file."`
1818
Create WorkoutCreateCmd `cmd:"" help:"Create a workout with sport type and optional targets."`
19-
Schedule WorkoutScheduleCmd `cmd:"" help:"Schedule a workout on a date."`
19+
Schedule WorkoutScheduleCmd `cmd:"" help:"Manage scheduled workouts."`
2020
Delete WorkoutDeleteCmd `cmd:"" help:"Delete a workout."`
2121
}
2222

@@ -138,15 +138,22 @@ func (c *WorkoutUploadCmd) Run(g *Globals) error {
138138
return nil
139139
}
140140

141-
// WorkoutScheduleCmd schedules a workout on a calendar date.
141+
// WorkoutScheduleCmd groups schedule subcommands.
142142
type WorkoutScheduleCmd struct {
143-
ID string `arg:"" help:"Workout ID."`
144-
Date string `arg:"" help:"Date to schedule (YYYY-MM-DD)."`
143+
Add WorkoutScheduleAddCmd `cmd:"" help:"Add a workout to the schedule."`
144+
List WorkoutScheduleListCmd `cmd:"" help:"List scheduled workouts for a date."`
145+
Remove WorkoutScheduleRemoveCmd `cmd:"" help:"Remove a scheduled workout from the calendar."`
145146
}
146147

147148
var dateRegexp = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
148149

149-
func (c *WorkoutScheduleCmd) Run(g *Globals) error {
150+
// WorkoutScheduleAddCmd schedules a workout on a calendar date.
151+
type WorkoutScheduleAddCmd struct {
152+
ID string `arg:"" help:"Workout ID."`
153+
Date string `arg:"" help:"Date to schedule (YYYY-MM-DD)."`
154+
}
155+
156+
func (c *WorkoutScheduleAddCmd) Run(g *Globals) error {
150157
if !dateRegexp.MatchString(c.Date) {
151158
return fmt.Errorf("invalid date format %q: expected YYYY-MM-DD", c.Date)
152159
}
@@ -169,6 +176,126 @@ func (c *WorkoutScheduleCmd) Run(g *Globals) error {
169176
return nil
170177
}
171178

179+
// WorkoutScheduleListCmd lists scheduled workouts for a date.
180+
type WorkoutScheduleListCmd struct {
181+
Date string `arg:"" help:"Date to list (YYYY-MM-DD)."`
182+
}
183+
184+
func (c *WorkoutScheduleListCmd) Run(g *Globals) error {
185+
if !dateRegexp.MatchString(c.Date) {
186+
return fmt.Errorf("invalid date format %q: expected YYYY-MM-DD", c.Date)
187+
}
188+
189+
client, err := resolveClient(g)
190+
if err != nil {
191+
return err
192+
}
193+
194+
data, err := client.GetCalendarWeek(g.Context, c.Date)
195+
if err != nil {
196+
return fmt.Errorf("list scheduled workouts: %w", err)
197+
}
198+
199+
items, err := filterCalendarWorkouts(data, c.Date)
200+
if err != nil {
201+
return err
202+
}
203+
204+
if outfmt.IsJSON(g.Context) {
205+
out, err := json.Marshal(items)
206+
if err != nil {
207+
return fmt.Errorf("marshal calendar items: %w", err)
208+
}
209+
return outfmt.WriteJSON(os.Stdout, json.RawMessage(out))
210+
}
211+
212+
rows := formatCalendarWorkoutRows(items)
213+
header := []string{"SCHEDULE ID", "WORKOUT ID", "TITLE", "SPORT", "DATE"}
214+
215+
if outfmt.IsPlain(g.Context) {
216+
return outfmt.WritePlain(os.Stdout, rows)
217+
}
218+
return outfmt.WriteTable(os.Stdout, header, rows)
219+
}
220+
221+
// filterCalendarWorkouts extracts workout items matching the given date from calendar data.
222+
func filterCalendarWorkouts(data json.RawMessage, date string) ([]map[string]any, error) {
223+
var calendar map[string]any
224+
if err := json.Unmarshal(data, &calendar); err != nil {
225+
return nil, fmt.Errorf("parse calendar: %w", err)
226+
}
227+
228+
rawItems, ok := calendar["calendarItems"]
229+
if !ok {
230+
return nil, nil
231+
}
232+
233+
items, ok := rawItems.([]any)
234+
if !ok {
235+
return nil, nil
236+
}
237+
238+
var workouts []map[string]any
239+
for _, item := range items {
240+
m, ok := item.(map[string]any)
241+
if !ok {
242+
continue
243+
}
244+
if jsonString(m, "itemType") != "workout" {
245+
continue
246+
}
247+
if jsonString(m, "date") != date {
248+
continue
249+
}
250+
workouts = append(workouts, m)
251+
}
252+
return workouts, nil
253+
}
254+
255+
// formatCalendarWorkoutRows extracts table rows from calendar workout items.
256+
func formatCalendarWorkoutRows(items []map[string]any) [][]string {
257+
rows := make([][]string, 0, len(items))
258+
for _, item := range items {
259+
rows = append(rows, []string{
260+
jsonString(item, "scheduleId"),
261+
jsonString(item, "workoutId"),
262+
jsonString(item, "title"),
263+
jsonString(item, "sportTypeKey"),
264+
jsonString(item, "date"),
265+
})
266+
}
267+
return rows
268+
}
269+
270+
// WorkoutScheduleRemoveCmd removes a scheduled workout from the calendar.
271+
type WorkoutScheduleRemoveCmd struct {
272+
ID string `arg:"" help:"Schedule ID (from 'workouts schedule list')."`
273+
Force bool `help:"Skip confirmation prompt." short:"f"`
274+
}
275+
276+
func (c *WorkoutScheduleRemoveCmd) Run(g *Globals) error {
277+
client, err := resolveClient(g)
278+
if err != nil {
279+
return err
280+
}
281+
282+
ok, err := confirm(os.Stderr, fmt.Sprintf("Remove scheduled workout %s?", c.ID), c.Force)
283+
if err != nil {
284+
return err
285+
}
286+
if !ok {
287+
g.UI.Infof("Cancelled")
288+
return nil
289+
}
290+
291+
if err := client.UnscheduleWorkout(g.Context, c.ID); err != nil {
292+
return fmt.Errorf("remove scheduled workout: %w", err)
293+
}
294+
295+
g.UI.Successf("Removed scheduled workout %s", c.ID)
296+
return nil
297+
}
298+
172299
// WorkoutDeleteCmd deletes a workout.
173300
type WorkoutDeleteCmd struct {
174301
ID string `arg:"" help:"Workout ID."`

0 commit comments

Comments
 (0)