trainerroad-cli 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,8 +2,21 @@ import {
2
2
  compactCurrentPlan,
3
3
  compactPlanPhase,
4
4
  compactPlanSummary,
5
+ deriveCurrentPlanFromPlans,
5
6
  toIsoDate,
6
7
  } from "../lib/planning-normalizers.mjs";
8
+ import { isHttpStatus } from "../trainerroad-client.mjs";
9
+ import { dateOnlyNowInTimeZone } from "../lib/timezone.mjs";
10
+
11
+ // current-custom-plan 404s for plan-builder plans; the web app no longer calls it either.
12
+ async function fetchCurrentCustomPlanOrNull(client, memberId, username) {
13
+ try {
14
+ return await client.getCurrentCustomPlan(memberId, username);
15
+ } catch (error) {
16
+ if (isHttpStatus(error, 404)) return null;
17
+ throw error;
18
+ }
19
+ }
7
20
 
8
21
  export async function commandPlan(flags, deps) {
9
22
  const {
@@ -25,11 +38,16 @@ export async function commandPlan(flags, deps) {
25
38
  throw new Error(`Invalid --view "${view}". Expected one of: current, phases, plans.`);
26
39
  }
27
40
 
28
- const [currentPlanRaw, allPlansRaw, phasesRaw] = await Promise.all([
29
- context.client.getCurrentCustomPlan(context.memberInfo.username),
30
- context.client.getAllUserPlans(context.memberInfo.username),
31
- context.client.getPlanPhases(context.memberInfo.username),
41
+ const { memberId, username } = context.memberInfo;
42
+ const [explicitCurrentPlanRaw, allPlansRaw, phasesRaw] = await Promise.all([
43
+ fetchCurrentCustomPlanOrNull(context.client, memberId, username),
44
+ context.client.getAllUserPlans(memberId, username),
45
+ context.client.getPlanPhases(memberId, username),
32
46
  ]);
47
+ const todayDateOnly = deps.todayDateOnly ?? dateOnlyNowInTimeZone(flags.tz ?? null);
48
+ const currentPlanRaw =
49
+ explicitCurrentPlanRaw ??
50
+ deriveCurrentPlanFromPlans(allPlansRaw, phasesRaw, todayDateOnly, { memberId });
33
51
  const currentPlan = compactCurrentPlan(currentPlanRaw);
34
52
  const plans = (Array.isArray(allPlansRaw) ? allPlansRaw : []).map((item) => compactPlanSummary(item));
35
53
  const phases = (Array.isArray(phasesRaw) ? phasesRaw : []).map((item) => compactPlanPhase(item));
@@ -50,7 +68,7 @@ export async function commandPlan(flags, deps) {
50
68
  phases: phases.length,
51
69
  currentPlan: currentPlan ? 1 : 0,
52
70
  },
53
- currentPlan: flags.full || view === "current" ? currentPlan : compactCurrentPlan(currentPlanRaw),
71
+ currentPlan,
54
72
  plans: flags.full || view === "plans" ? plans : undefined,
55
73
  phases: flags.full || view === "phases" ? phases : undefined,
56
74
  count: filteredRecords.length,
@@ -68,7 +68,8 @@ export async function commandPowerRecords(flags, deps) {
68
68
  const allRecords = Array.isArray(raw?.results?.[0]?.personalRecords)
69
69
  ? raw.results[0].personalRecords
70
70
  : [];
71
- const rankedByWatts = [...allRecords].sort((a, b) => (b?.Watts ?? 0) - (a?.Watts ?? 0));
71
+ const wattsOf = (item) => item?.watts ?? item?.Watts ?? 0;
72
+ const rankedByWatts = [...allRecords].sort((a, b) => wattsOf(b) - wattsOf(a));
72
73
  const selectedRaw = full ? allRecords : rankedByWatts.slice(0, limit);
73
74
  const records = full ? selectedRaw : selectedRaw.map((item) => compactPersonalRecord(item));
74
75
 
@@ -0,0 +1,99 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const FORMATS = new Set(["png", "svg"]);
5
+
6
+ async function requirePrivateMember(flags, deps) {
7
+ const { withClient } = deps;
8
+ const client = await withClient(flags);
9
+ try {
10
+ const memberInfo = await client.getMemberInfo();
11
+ return { client, memberInfo };
12
+ } catch {
13
+ throw new Error(
14
+ "This command requires private authenticated mode. Login first with trainerroad-cli login.",
15
+ );
16
+ }
17
+ }
18
+
19
+ async function rasterize(svg, { width, background }) {
20
+ let Resvg;
21
+ try {
22
+ ({ Resvg } = await import("@resvg/resvg-js"));
23
+ } catch {
24
+ throw new Error(
25
+ "PNG output needs the optional @resvg/resvg-js package. Install it (npm install @resvg/resvg-js) or use --format svg.",
26
+ );
27
+ }
28
+ const renderer = new Resvg(svg, {
29
+ fitTo: { mode: "width", value: width },
30
+ background,
31
+ });
32
+ const image = renderer.render();
33
+ return { bytes: image.asPng(), width: image.width, height: image.height };
34
+ }
35
+
36
+ export async function commandWorkoutImage(flags, deps) {
37
+ const { isJsonMode, requireFlag, requirePositiveInteger, writeOutput } = deps;
38
+ const workoutId = Number(requireFlag("workout-image", flags, "id"));
39
+ if (!Number.isFinite(workoutId)) {
40
+ throw new Error(`Invalid --id "${flags.id}". Expected a numeric workout ID.`);
41
+ }
42
+
43
+ const explicitFormat = flags.format ? String(flags.format).toLowerCase() : null;
44
+ if (explicitFormat && !FORMATS.has(explicitFormat)) {
45
+ throw new Error(`Invalid --format "${flags.format}". Expected png or svg.`);
46
+ }
47
+ const requestedFile = flags.file ? String(flags.file) : null;
48
+ const extension = requestedFile ? path.extname(requestedFile).slice(1).toLowerCase() : null;
49
+ const format = explicitFormat ?? (FORMATS.has(extension) ? extension : "png");
50
+ const file = requestedFile ?? `workout-${workoutId}.${format}`;
51
+ const width = requirePositiveInteger(flags.width, 1200);
52
+ const background = flags.background ? String(flags.background) : "#1c1c1c";
53
+
54
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
55
+ const summaryPayload = await client.getWorkoutSummary(workoutId, memberInfo.username);
56
+ const summary = summaryPayload?.summary ?? null;
57
+ const chartUrl = summary?.picUrl ?? null;
58
+ if (!chartUrl) {
59
+ throw new Error(`Workout ${workoutId} has no chart image (summary.picUrl was empty).`);
60
+ }
61
+
62
+ const svg = await client.fetchText(chartUrl);
63
+ let bytes;
64
+ let size = null;
65
+ if (format === "svg") {
66
+ bytes = Buffer.from(svg, "utf8");
67
+ } else {
68
+ const rendered = await rasterize(svg, { width, background });
69
+ bytes = rendered.bytes;
70
+ size = { width: rendered.width, height: rendered.height };
71
+ }
72
+ await fs.mkdir(path.dirname(path.resolve(file)), { recursive: true });
73
+ await fs.writeFile(file, bytes);
74
+
75
+ const payload = {
76
+ generatedAt: new Date().toISOString(),
77
+ command: "workout-image",
78
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
79
+ query: { workoutId, format, width: format === "png" ? width : undefined, background: format === "png" ? background : undefined },
80
+ workout: {
81
+ workoutId,
82
+ workoutName: summary?.workoutName ?? summary?.name ?? null,
83
+ durationMinutes: summary?.duration ?? null,
84
+ tss: summary?.tss ?? null,
85
+ intensityFactor: summary?.intensityFactor ?? null,
86
+ },
87
+ chartUrl,
88
+ file: path.resolve(file),
89
+ bytes: bytes.length,
90
+ size,
91
+ message: `Wrote ${format.toUpperCase()} chart for ${summary?.workoutName ?? `workout ${workoutId}`} to ${path.resolve(file)}.`,
92
+ };
93
+
94
+ if (!isJsonMode(flags)) {
95
+ await writeOutput(payload, flags, (value) => value.message);
96
+ return;
97
+ }
98
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
99
+ }
@@ -115,6 +115,7 @@ export function summarizeWorkout(workout) {
115
115
  firstPublishDate: workout.firstPublishDate ?? null,
116
116
  indoorAlternativeId: workout.indoorAlternativeId ?? null,
117
117
  energyKj: workout.kj ?? null,
118
+ chartUrl: workout.picUrl ?? null,
118
119
  goal: stripHtml(workout.goalDescription),
119
120
  description: stripHtml(workout.workoutDescription),
120
121
  };
@@ -1,3 +1,5 @@
1
+ import { isHttpStatus } from "../trainerroad-client.mjs";
2
+
1
3
  const ALTERNATE_CATEGORIES = new Set(["similar", "easier", "harder", "longer", "shorter"]);
2
4
  const SWITCH_MODES = new Set(["inside", "outside"]);
3
5
 
@@ -308,3 +310,61 @@ export async function commandSwitchWorkout(flags, deps) {
308
310
 
309
311
  await writeOutput(payload, { ...flags, json: !flags.jsonl });
310
312
  }
313
+
314
+ const ADAPTIVE_NOTE =
315
+ "Removing a planned workout lets TrainerRoad's Adaptive Training rebuild the upcoming plan around the gap. Re-read `future` afterwards.";
316
+
317
+ export async function commandRemoveWorkout(flags, deps) {
318
+ const { isJsonMode, requireFlag, toBoolean, writeOutput } = deps;
319
+ const dryRun = toBoolean(flags["dry-run"], false);
320
+ const plannedActivityId = String(requireFlag("remove-workout", flags, "id"));
321
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
322
+
323
+ let before = null;
324
+ try {
325
+ before = summarizePlannedActivity(await client.getPlannedActivity(plannedActivityId, memberInfo.username));
326
+ } catch (error) {
327
+ if (!isHttpStatus(error, 404)) throw error;
328
+ }
329
+ const noop = before === null;
330
+
331
+ const base = {
332
+ generatedAt: new Date().toISOString(),
333
+ command: "remove-workout",
334
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
335
+ query: { plannedActivityId },
336
+ before,
337
+ adaptiveTraining: ADAPTIVE_NOTE,
338
+ };
339
+
340
+ if (dryRun || noop) {
341
+ const payload = {
342
+ ...base,
343
+ dryRun,
344
+ noop,
345
+ message: noop
346
+ ? `No planned activity with id ${plannedActivityId} exists on the calendar.`
347
+ : `Would remove ${before.workoutName ?? "workout"} from ${before.date}.`,
348
+ };
349
+ if (!isJsonMode(flags)) {
350
+ await writeOutput(payload, flags, (value) => (value.noop ? value.message : `${value.message}\nNo changes made.`));
351
+ return;
352
+ }
353
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
354
+ return;
355
+ }
356
+
357
+ await client.deletePlannedActivity(plannedActivityId, memberInfo.username);
358
+ const payload = {
359
+ ...base,
360
+ dryRun: false,
361
+ noop: false,
362
+ message: `Removed ${before.workoutName ?? "workout"} from ${before.date} (plannedActivityId=${plannedActivityId}).`,
363
+ };
364
+
365
+ if (!isJsonMode(flags)) {
366
+ await writeOutput(payload, flags, (value) => `${value.message}\n${value.adaptiveTraining}`);
367
+ return;
368
+ }
369
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
370
+ }
@@ -31,6 +31,7 @@ function summarizeWorkout(workout) {
31
31
  firstPublishDate: workout.firstPublishDate ?? null,
32
32
  indoorAlternativeId: workout.indoorAlternativeId ?? null,
33
33
  energyKj: workout.kj ?? null,
34
+ chartUrl: workout.picUrl ?? null,
34
35
  goal: stripHtml(workout.goalDescription),
35
36
  description: stripHtml(workout.workoutDescription),
36
37
  };
@@ -264,6 +264,61 @@ export const COMMANDS = {
264
264
  "trainerroad-cli switch-workout --id 123456 --mode outside --json",
265
265
  ],
266
266
  },
267
+ "add-event": {
268
+ summary:
269
+ "Add a race or event to the calendar with discipline, priority, duration, and a TSS or intensity estimate. Remove it later with remove-workout (private mode).",
270
+ usage: [
271
+ "trainerroad-cli add-event --name <text> --date YYYY-MM-DD --discipline <name|id> --duration <minutes> (--tss <number> | --intensity <1-10>) [--priority A|B|C] [--notes <text>] [--dry-run] [--json|--jsonl]",
272
+ ],
273
+ examples: [
274
+ "trainerroad-cli add-event --name \"Black Fork\" --date 2027-05-01 --discipline gravel --priority A --duration 300 --tss 340 --dry-run",
275
+ "trainerroad-cli add-event --name \"Tuesday crit\" --date 2026-10-06 --discipline criterium --priority C --duration 60 --intensity 9 --json",
276
+ ],
277
+ },
278
+ "remove-workout": {
279
+ summary:
280
+ "Remove a planned workout or event from the calendar by planned-activity id. TrainerRoad may rebuild the plan around the gap (private mode).",
281
+ usage: ["trainerroad-cli remove-workout --id <planned-activity-id> [--dry-run] [--json|--jsonl]"],
282
+ examples: [
283
+ "trainerroad-cli remove-workout --id 05a68215-0fd5-431e-ba3f-b3bf01210c29 --dry-run",
284
+ "trainerroad-cli remove-workout --id 05a68215-0fd5-431e-ba3f-b3bf01210c29 --json",
285
+ ],
286
+ },
287
+ "workout-image": {
288
+ summary: "Save a workout's power-profile chart as PNG (default) or SVG (private mode).",
289
+ usage: [
290
+ "trainerroad-cli workout-image --id <workout-id> [--file <path>] [--format png|svg] [--width <px>] [--background <css-color>] [--json]",
291
+ ],
292
+ examples: [
293
+ "trainerroad-cli workout-image --id 1592808 --file fishers.png",
294
+ "trainerroad-cli workout-image --id 1592808 --format svg --file fishers.svg --json",
295
+ ],
296
+ },
297
+ "annotation-details": {
298
+ summary: "Fetch one calendar annotation with its title and notes (private mode).",
299
+ usage: ["trainerroad-cli annotation-details --id <annotation-id> [--full] [--json|--jsonl]"],
300
+ examples: ["trainerroad-cli annotation-details --id 1818fb12-7294-4a98-b494-b4b90160edc1 --json"],
301
+ },
302
+ "add-annotation": {
303
+ summary:
304
+ "Add a calendar annotation: time off, illness, injury, or a note. Multi-day via --days or --end-date. TrainerRoad may adapt nearby workouts (private mode).",
305
+ usage: [
306
+ "trainerroad-cli add-annotation --type time-off|illness|injury|note --date YYYY-MM-DD [--days <count> | --end-date YYYY-MM-DD] [--title <text>] [--notes <text>] [--dry-run] [--json|--jsonl]",
307
+ ],
308
+ examples: [
309
+ "trainerroad-cli add-annotation --type time-off --date 2026-09-21 --days 3 --title \"Travel\" --dry-run",
310
+ "trainerroad-cli add-annotation --type illness --date 2026-09-21 --end-date 2026-09-23 --notes \"Head cold\" --json",
311
+ "trainerroad-cli add-annotation --type note --date 2026-09-21 --title \"New saddle\" --json",
312
+ ],
313
+ },
314
+ "remove-annotation": {
315
+ summary: "Remove a calendar annotation by id. No-op if it is already gone (private mode).",
316
+ usage: ["trainerroad-cli remove-annotation --id <annotation-id> [--dry-run] [--json|--jsonl]"],
317
+ examples: [
318
+ "trainerroad-cli remove-annotation --id 1818fb12-7294-4a98-b494-b4b90160edc1 --dry-run",
319
+ "trainerroad-cli remove-annotation --id 1818fb12-7294-4a98-b494-b4b90160edc1 --json",
320
+ ],
321
+ },
267
322
  logout: {
268
323
  summary: "Clear local persisted session.",
269
324
  usage: ["trainerroad-cli logout"],
@@ -326,6 +381,12 @@ export const COMMAND_REQUIRED_FLAGS = {
326
381
  "move-workout": ["id", "to"],
327
382
  "replace-workout": ["id", "alternate-id"],
328
383
  "switch-workout": ["id", "mode"],
384
+ "add-event": ["name", "date", "discipline", "duration"],
385
+ "remove-workout": ["id"],
386
+ "workout-image": ["id"],
387
+ "annotation-details": ["id"],
388
+ "add-annotation": ["type", "date"],
389
+ "remove-annotation": ["id"],
329
390
  };
330
391
 
331
392
  export const FLAG_DETAILS = {
@@ -438,7 +499,23 @@ export const FLAG_DETAILS = {
438
499
  placeholder: "<count>",
439
500
  description: "Upstream workout library page size.",
440
501
  },
441
- id: { placeholder: "<id>", description: "Workout, planned activity, or record identifier." },
502
+ id: { placeholder: "<id>", description: "Workout, planned activity, annotation, or record identifier." },
503
+ name: { placeholder: "<text>", description: "Event name shown on the calendar." },
504
+ discipline: {
505
+ placeholder: "<name|id>",
506
+ description:
507
+ "Event discipline: gravel, criterium, time-trial, gran-fondo, climbing-road-race, rolling-road-race, cyclocross, xc-olympic, xc-marathon, short-track, gravity, enduro, or a triathlon type; numeric ids accepted.",
508
+ },
509
+ priority: { placeholder: "A|B|C", description: "Race priority. A drives the plan, C is a training race (default B)." },
510
+ tss: { placeholder: "<number>", description: "Expected TSS for the event. Use this or --intensity." },
511
+ intensity: { placeholder: "<1-10>", description: "Expected intensity on TrainerRoad's 1-10 scale. Use this or --tss." },
512
+ file: { placeholder: "<path>", description: "Destination file for the image (default workout-<id>.png)." },
513
+ format: { placeholder: "png|svg", description: "Image format. Inferred from --file extension when omitted." },
514
+ width: { placeholder: "<px>", description: "PNG width in pixels (default 1200)." },
515
+ background: { placeholder: "<css-color>", description: "PNG background colour (default #1c1c1c)." },
516
+ title: { placeholder: "<text>", description: "Annotation title shown on the calendar. Defaults to the type name." },
517
+ notes: { placeholder: "<text>", description: "Free-text notes stored with the annotation." },
518
+ "color-id": { placeholder: "<id>", description: "TrainerRoad annotation colour id (default 2)." },
442
519
  "include-chart": {
443
520
  placeholder: "true|false",
444
521
  description: "Include workout chart/sample data in workout-details.",
@@ -484,6 +561,38 @@ function mergeFlagGroups(...groups) {
484
561
  );
485
562
  }
486
563
 
564
+ // Per-command overrides for flags whose meaning differs from the shared FLAG_DETAILS entry.
565
+ export const COMMAND_FLAG_DETAILS = {
566
+ "add-annotation": {
567
+ type: {
568
+ placeholder: "time-off|illness|injury|note",
569
+ description: "Annotation type. Also accepts a numeric TrainerRoad typeId.",
570
+ },
571
+ date: { placeholder: "YYYY-MM-DD", description: "First day of the annotation." },
572
+ days: { placeholder: "<count>", description: "Number of days the annotation covers (default 1)." },
573
+ "end-date": { placeholder: "YYYY-MM-DD", description: "Last day of the annotation, inclusive. Overrides --days." },
574
+ },
575
+ "annotation-details": {
576
+ id: { placeholder: "<annotation-id>", description: "Annotation id from `annotations`." },
577
+ full: { description: "Include the raw upstream annotation payload." },
578
+ },
579
+ "workout-image": {
580
+ id: { placeholder: "<workout-id>", description: "Library workout id (from workout-library, workout-details, or future --details)." },
581
+ },
582
+ "remove-workout": {
583
+ id: { placeholder: "<planned-activity-id>", description: "Planned activity id from `future --details` or `events`." },
584
+ },
585
+ "add-event": {
586
+ date: { placeholder: "YYYY-MM-DD", description: "Event date." },
587
+ duration: { placeholder: "<minutes>", description: "Expected event duration in minutes." },
588
+ notes: { placeholder: "<text>", description: "Free-text description stored with the event." },
589
+ full: { description: "Include TrainerRoad's raw create response." },
590
+ },
591
+ "remove-annotation": {
592
+ id: { placeholder: "<annotation-id>", description: "Annotation id from `annotations`." },
593
+ },
594
+ };
595
+
487
596
  const SHARED_FLAGS = {
488
597
  help: ["help"],
489
598
  output: ["output"],
@@ -764,5 +873,57 @@ export const COMMAND_FLAG_ALLOWLIST = {
764
873
  SHARED_FLAGS.writeSafety,
765
874
  ["id", "mode"],
766
875
  ),
876
+ "add-event": mergeFlagGroups(
877
+ SHARED_FLAGS.help,
878
+ SHARED_FLAGS.output,
879
+ SHARED_FLAGS.jsonAndJsonl,
880
+ SHARED_FLAGS.session,
881
+ SHARED_FLAGS.credentials,
882
+ SHARED_FLAGS.writeSafety,
883
+ ["name", "date", "discipline", "priority", "duration", "tss", "intensity", "notes", "full"],
884
+ ),
885
+ "remove-workout": mergeFlagGroups(
886
+ SHARED_FLAGS.help,
887
+ SHARED_FLAGS.output,
888
+ SHARED_FLAGS.jsonAndJsonl,
889
+ SHARED_FLAGS.session,
890
+ SHARED_FLAGS.credentials,
891
+ SHARED_FLAGS.writeSafety,
892
+ ["id"],
893
+ ),
894
+ "workout-image": mergeFlagGroups(
895
+ SHARED_FLAGS.help,
896
+ SHARED_FLAGS.output,
897
+ SHARED_FLAGS.json,
898
+ SHARED_FLAGS.session,
899
+ SHARED_FLAGS.credentials,
900
+ ["id", "file", "format", "width", "background"],
901
+ ),
902
+ "annotation-details": mergeFlagGroups(
903
+ SHARED_FLAGS.help,
904
+ SHARED_FLAGS.output,
905
+ SHARED_FLAGS.jsonAndJsonl,
906
+ SHARED_FLAGS.session,
907
+ SHARED_FLAGS.credentials,
908
+ ["id", "full"],
909
+ ),
910
+ "add-annotation": mergeFlagGroups(
911
+ SHARED_FLAGS.help,
912
+ SHARED_FLAGS.output,
913
+ SHARED_FLAGS.jsonAndJsonl,
914
+ SHARED_FLAGS.session,
915
+ SHARED_FLAGS.credentials,
916
+ SHARED_FLAGS.writeSafety,
917
+ ["type", "date", "days", "end-date", "title", "notes", "color-id"],
918
+ ),
919
+ "remove-annotation": mergeFlagGroups(
920
+ SHARED_FLAGS.help,
921
+ SHARED_FLAGS.output,
922
+ SHARED_FLAGS.jsonAndJsonl,
923
+ SHARED_FLAGS.session,
924
+ SHARED_FLAGS.credentials,
925
+ SHARED_FLAGS.writeSafety,
926
+ ["id"],
927
+ ),
767
928
  logout: mergeFlagGroups(SHARED_FLAGS.help, SHARED_FLAGS.output, SHARED_FLAGS.session),
768
929
  };
@@ -9,14 +9,61 @@ const PROGRESSION_ZONE_META = {
9
9
  79: { zoneKey: "anaerobic", zoneLabel: "Anaerobic", sortOrder: 6 },
10
10
  };
11
11
 
12
- const ANNOTATION_TYPE_LABELS = {
12
+ // From the web app's enum, checked against real annotations on 2026-09-02 (typeId 2 "Wisdom Teeth",
13
+ // typeId 4 "Hiking Out West"). Earlier releases had 2 and 4 swapped.
14
+ export const ANNOTATION_TYPE_LABELS = {
13
15
  1: "note",
14
- 2: "time-off",
16
+ 2: "illness",
15
17
  3: "injury",
16
- 4: "illness",
17
- 9: "plan-marker",
18
+ 4: "time-off",
19
+ 5: "stage-race",
20
+ 6: "custom-plan-start",
21
+ 7: "custom-plan-week",
22
+ 8: "custom-plan-block",
23
+ 9: "plan-start",
24
+ 10: "plan-week",
18
25
  };
19
26
 
27
+ // Names an agent can pass to add-annotation --type. Only the four user-editable types.
28
+ export const ANNOTATION_TYPE_IDS = {
29
+ note: 1,
30
+ illness: 2,
31
+ sick: 2,
32
+ injury: 3,
33
+ "time-off": 4,
34
+ };
35
+
36
+ function endDateOnlyFrom(startDateOnly, durationSeconds) {
37
+ if (!startDateOnly || !Number.isFinite(Number(durationSeconds))) return startDateOnly ?? null;
38
+ const days = Math.max(1, Math.round(Number(durationSeconds) / 86_400));
39
+ const [year, month, day] = startDateOnly.split("-").map(Number);
40
+ return new Date(Date.UTC(year, month - 1, day + days - 1)).toISOString().slice(0, 10);
41
+ }
42
+
43
+ // Shape of GET /app/api/react-calendar/annotation/{id}: the timeline row plus title, text, colour.
44
+ export function compactAnnotationDetail(record) {
45
+ const dateOnly = toIsoDateFromCalendarDate(record?.date);
46
+ const durationSeconds = record?.duration ?? null;
47
+ const typeLabel = ANNOTATION_TYPE_LABELS[record?.typeId] ?? `type-${record?.typeId ?? "unknown"}`;
48
+ return {
49
+ id: record?.id ?? null,
50
+ type: typeLabel,
51
+ typeId: record?.typeId ?? null,
52
+ typeLabel,
53
+ title: record?.title ?? null,
54
+ text: record?.text ?? null,
55
+ date: record?.date ?? null,
56
+ dateOnly,
57
+ endDateOnly: endDateOnlyFrom(dateOnly, durationSeconds),
58
+ durationSeconds,
59
+ durationDays: Number.isFinite(Number(durationSeconds)) ? Math.round(Number(durationSeconds) / 86_400) : null,
60
+ timeOfDay: record?.timeOfDay ?? null,
61
+ colorId: record?.colorId ?? null,
62
+ colorHex: record?.colorHex ?? null,
63
+ plannedActivityGroupId: record?.plannedActivityGroupId ?? null,
64
+ };
65
+ }
66
+
20
67
  function toIsoDateFromPlanned(item) {
21
68
  return `${String(item.date.year).padStart(4, "0")}-${String(item.date.month).padStart(2, "0")}-${String(item.date.day).padStart(2, "0")}`;
22
69
  }
@@ -135,12 +182,71 @@ export function compactCurrentPlan(plan) {
135
182
  dateOnly: plan.start ? toIsoDate(plan.start) : null,
136
183
  canEdit: plan.canEdit ?? null,
137
184
  currentPhase: plan.currentPhase ?? null,
185
+ currentPhaseId: plan.currentPhaseId ?? null,
186
+ currentPhaseName: plan.currentPhaseName ?? null,
138
187
  currentPhaseStart: plan.currentPhaseStart ?? null,
139
188
  currentPhaseEnd: plan.currentPhaseEnd ?? null,
140
189
  plannedActivityGroupType: plan.plannedActivityGroupType ?? null,
141
190
  autoUpdateApplied: plan.autoUpdateApplied ?? null,
142
191
  phaseCount: Array.isArray(plan.phases) ? plan.phases.length : 0,
143
192
  phases: Array.isArray(plan.phases) ? plan.phases.map((phase) => compactPlanPhase(phase)) : [],
193
+ source: plan.source ?? "current-custom-plan",
194
+ };
195
+ }
196
+
197
+ function dateWindowContains(start, end, dateOnly) {
198
+ if (!dateOnly) return false;
199
+ const startDateOnly = start ? toIsoDate(start) : null;
200
+ const endDateOnly = end ? toIsoDate(end) : null;
201
+ if (!startDateOnly || !endDateOnly) return false;
202
+ return startDateOnly <= dateOnly && dateOnly <= endDateOnly;
203
+ }
204
+
205
+ // Phases carry the plan's id. When they don't, fall back to phases that sit inside the plan window.
206
+ function phaseBelongsToPlan(phase, plan) {
207
+ if (phase?.customPlanId != null && plan?.id != null) {
208
+ return String(phase.customPlanId) === String(plan.id);
209
+ }
210
+ const phaseStart = phase?.start ? toIsoDate(phase.start) : null;
211
+ const phaseEnd = phase?.end ? toIsoDate(phase.end) : null;
212
+ return dateWindowContains(plan?.start, plan?.end, phaseStart) && dateWindowContains(plan?.start, plan?.end, phaseEnd);
213
+ }
214
+
215
+ // Replacement for the retired current-custom-plan endpoint: the plan whose window contains today,
216
+ // with its phases attached. Returns a raw-shaped plan for compactCurrentPlan, or null.
217
+ export function deriveCurrentPlanFromPlans(plans, phases, todayDateOnly, { memberId = null } = {}) {
218
+ const planList = Array.isArray(plans) ? plans : [];
219
+ const phaseList = Array.isArray(phases) ? phases : [];
220
+ const activePlans = planList
221
+ .filter((plan) => dateWindowContains(plan?.start, plan?.end, todayDateOnly))
222
+ .sort((a, b) => toIsoDate(b.start).localeCompare(toIsoDate(a.start)));
223
+ const plan = activePlans[0];
224
+ if (!plan) return null;
225
+
226
+ const planPhases = phaseList
227
+ .filter((phase) => phaseBelongsToPlan(phase, plan))
228
+ .sort((a, b) => (a?.start && b?.start ? toIsoDate(a.start).localeCompare(toIsoDate(b.start)) : 0));
229
+ const currentPhase =
230
+ planPhases.find((phase) => dateWindowContains(phase?.start, phase?.end, todayDateOnly)) ?? null;
231
+
232
+ return {
233
+ id: plan.id ?? null,
234
+ name: plan.name ?? null,
235
+ memberId: plan.memberId ?? memberId,
236
+ discipline: plan.discipline ?? null,
237
+ volume: plan.volume ?? null,
238
+ start: plan.start ?? null,
239
+ end: plan.end ?? null,
240
+ canEdit: plan.canEdit ?? null,
241
+ currentPhase: currentPhase?.type ?? plan.phase ?? null,
242
+ currentPhaseId: currentPhase?.id ?? null,
243
+ currentPhaseName: currentPhase?.planName ?? null,
244
+ currentPhaseStart: currentPhase?.start ?? null,
245
+ currentPhaseEnd: currentPhase?.end ?? null,
246
+ plannedActivityGroupType: plan.plannedActivityGroupType ?? null,
247
+ autoUpdateApplied: plan.autoUpdateApplied ?? null,
248
+ phases: planPhases,
249
+ source: "all-user-plans",
144
250
  };
145
251
  }
146
252