@sunsteel/contracts 0.86.0 → 0.87.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.
package/dist/index.cjs CHANGED
@@ -96,6 +96,19 @@ __export(index_exports, {
96
96
  INCHES_PER_FOOT: () => INCHES_PER_FOOT,
97
97
  LENGTH_UNITS: () => LENGTH_UNITS,
98
98
  LIMITED_SHORTFALL_KG: () => LIMITED_SHORTFALL_KG,
99
+ LP_BLOCK_STEPS: () => LP_BLOCK_STEPS,
100
+ LP_ESTIMATE_MAX_REPS_TO_FAILURE: () => LP_ESTIMATE_MAX_REPS_TO_FAILURE,
101
+ LP_ESTIMATE_MIN_SETS: () => LP_ESTIMATE_MIN_SETS,
102
+ LP_ESTIMATE_STEPS: () => LP_ESTIMATE_STEPS,
103
+ LP_NEXT_BLOCK_KINDS: () => LP_NEXT_BLOCK_KINDS,
104
+ LP_PHASES: () => LP_PHASES,
105
+ LP_PROGRESS_CAP: () => LP_PROGRESS_CAP,
106
+ LP_PROGRESS_THRESHOLD: () => LP_PROGRESS_THRESHOLD,
107
+ LP_RECOVERY: () => LP_RECOVERY,
108
+ LP_REFERENCE_MAX_KG: () => LP_REFERENCE_MAX_KG,
109
+ LP_REST_SECONDS: () => LP_REST_SECONDS,
110
+ LP_STEPS: () => LP_STEPS,
111
+ LP_WORKING_SETS: () => LP_WORKING_SETS,
99
112
  MAX_SETS_PER_EXERCISE: () => MAX_SETS_PER_EXERCISE,
100
113
  MEASURABLE_GOALS_MAX: () => MEASURABLE_GOALS_MAX,
101
114
  MEASURABLE_GOAL_DIRECTIONS: () => MEASURABLE_GOAL_DIRECTIONS,
@@ -237,6 +250,7 @@ __export(index_exports, {
237
250
  WEIGHT_UNITS: () => WEIGHT_UNITS,
238
251
  WORKOUT_SESSION_STATUSES: () => WORKOUT_SESSION_STATUSES,
239
252
  accountExportFileName: () => accountExportFileName,
253
+ advanceLinearPeriodization: () => advanceLinearPeriodization,
240
254
  apiError: () => apiError,
241
255
  apiErrorMessage: () => apiErrorMessage,
242
256
  applyDeload: () => applyDeload,
@@ -247,6 +261,7 @@ __export(index_exports, {
247
261
  canLinkToNext: () => canLinkToNext,
248
262
  cmToFeetInches: () => cmToFeetInches,
249
263
  cmToInches: () => cmToInches,
264
+ continueLinearPeriodization: () => continueLinearPeriodization,
250
265
  countsAsWork: () => countsAsWork,
251
266
  countsForProgression: () => countsForProgression,
252
267
  customExerciseProblem: () => customExerciseProblem,
@@ -276,6 +291,17 @@ __export(index_exports, {
276
291
  isWithinQuietHours: () => isWithinQuietHours,
277
292
  latestBodyWeight: () => latestBodyWeight,
278
293
  leadWorkingWeight: () => leadWorkingWeight,
294
+ linearPeriodizationProblem: () => linearPeriodizationProblem,
295
+ lpEstimate: () => lpEstimate,
296
+ lpLoadKg: () => lpLoadKg,
297
+ lpPrescription: () => lpPrescription,
298
+ lpRecommendation: () => lpRecommendation,
299
+ lpRirMidpoint: () => lpRirMidpoint,
300
+ lpSampleRir: () => lpSampleRir,
301
+ lpSetEstimateKg: () => lpSetEstimateKg,
302
+ lpStep: () => lpStep,
303
+ lpStoredSets: () => lpStoredSets,
304
+ lpWorkingSets: () => lpWorkingSets,
279
305
  matchesDeletionConfirmation: () => matchesDeletionConfirmation,
280
306
  messageBodyLength: () => messageBodyLength,
281
307
  normalizeDashboardLayout: () => normalizeDashboardLayout,
@@ -288,10 +314,14 @@ __export(index_exports, {
288
314
  requiredToFinish: () => requiredToFinish,
289
315
  resolveActiveTrainingBlock: () => resolveActiveTrainingBlock,
290
316
  resolveRoutinePlan: () => resolveRoutinePlan,
317
+ roundDownToIncrement: () => roundDownToIncrement,
318
+ roundToIncrement: () => roundToIncrement,
291
319
  routineDayLabel: () => routineDayLabel,
320
+ sameLpPosition: () => sameLpPosition,
292
321
  sessionCorrectionWindow: () => sessionCorrectionWindow,
293
322
  setKindOf: () => setKindOf,
294
323
  setLogValuesDiffer: () => setLogValuesDiffer,
324
+ startLinearPeriodization: () => startLinearPeriodization,
295
325
  summarizeBodyProgress: () => summarizeBodyProgress,
296
326
  trainingBlockCovers: () => trainingBlockCovers,
297
327
  warmUpLoad: () => warmUpLoad,
@@ -311,7 +341,9 @@ var REP_TYPES = ["FIXED", "RANGE"];
311
341
  var PROGRESSION_SCHEMES = [
312
342
  "NONE",
313
343
  "DOUBLE_PROGRESSION",
314
- "DYNAMIC_DOUBLE_PROGRESSION"
344
+ "DYNAMIC_DOUBLE_PROGRESSION",
345
+ // ROUT-17: an 8-step block by percentage of a reference max and target RIR.
346
+ "LINEAR_PERIODIZATION"
315
347
  ];
316
348
  var MUSCLE_GROUPS = [
317
349
  "PECTORAL",
@@ -894,6 +926,224 @@ function requiredToFinish(kind) {
894
926
  return k === "WORKING" || k === "DROP";
895
927
  }
896
928
 
929
+ // src/linear-periodization.ts
930
+ var LP_BLOCK_STEPS = 8;
931
+ var LP_WORKING_SETS = 3;
932
+ var LP_REST_SECONDS = 180;
933
+ var LP_REFERENCE_MAX_KG = 1e3;
934
+ var EASY = { rirMin: 2, rirMax: 3, amrap: false };
935
+ var AMRAP = { rirMin: 0, rirMax: 0, amrap: true };
936
+ var HARD = { rirMin: 1, rirMax: 2, amrap: false };
937
+ var LP_STEPS = [
938
+ { step: 1, percentage: 0.63, sets: [EASY, EASY, AMRAP] },
939
+ { step: 2, percentage: 0.66, sets: [EASY, EASY, AMRAP] },
940
+ { step: 3, percentage: 0.69, sets: [EASY, EASY, AMRAP] },
941
+ { step: 4, percentage: 0.72, sets: [EASY, EASY, AMRAP] },
942
+ { step: 5, percentage: 0.75, sets: [HARD, HARD, HARD] },
943
+ { step: 6, percentage: 0.78, sets: [HARD, HARD, HARD] },
944
+ { step: 7, percentage: 0.81, sets: [HARD, HARD, HARD] },
945
+ { step: 8, percentage: 0.85, sets: [HARD, HARD, HARD] }
946
+ ];
947
+ function lpStep(step) {
948
+ const index = Math.min(Math.max(Math.trunc(step) || 1, 1), LP_BLOCK_STEPS);
949
+ return LP_STEPS[index - 1];
950
+ }
951
+ var LP_RECOVERY = { percentage: 0.7, sets: 2, reps: 5 };
952
+ var tidy = (value) => Math.round(value * 1e6) / 1e6;
953
+ function roundToIncrement(value, increment) {
954
+ if (!(increment > 0)) return tidy(value);
955
+ const steps = value / increment;
956
+ const floor = Math.floor(steps + 1e-9);
957
+ const fraction = steps - floor;
958
+ return tidy((fraction > 0.5 + 1e-9 ? floor + 1 : floor) * increment);
959
+ }
960
+ function roundDownToIncrement(value, increment) {
961
+ if (!(increment > 0)) return tidy(value);
962
+ return tidy(Math.floor(value / increment + 1e-9) * increment);
963
+ }
964
+ function lpLoadKg(referenceMaxKg, percentage, incrementKg) {
965
+ return roundToIncrement(referenceMaxKg * percentage, incrementKg);
966
+ }
967
+ var LP_PHASES = ["BLOCK", "RECOVERY", "FINISHED"];
968
+ function startLinearPeriodization(referenceMaxKg, cycle = 1) {
969
+ return {
970
+ referenceMaxKg,
971
+ phase: "BLOCK",
972
+ step: 1,
973
+ cycle,
974
+ samples: [],
975
+ finishedAt: null,
976
+ estimatedMaxKg: null,
977
+ nextReferenceMaxKg: null
978
+ };
979
+ }
980
+ function linearPeriodizationProblem(state) {
981
+ if (!Number.isFinite(state.referenceMaxKg) || state.referenceMaxKg <= 0 || state.referenceMaxKg > LP_REFERENCE_MAX_KG) {
982
+ return "The reference max must be above 0";
983
+ }
984
+ if (!LP_PHASES.includes(state.phase)) {
985
+ return "Unknown block phase";
986
+ }
987
+ if (!Number.isInteger(state.step) || state.step < 1 || state.step > LP_BLOCK_STEPS) {
988
+ return "The block step must be between 1 and 8";
989
+ }
990
+ if (!Number.isInteger(state.cycle) || state.cycle < 1) {
991
+ return "The block number must be 1 or more";
992
+ }
993
+ if (state.phase === "RECOVERY" && !((state.nextReferenceMaxKg ?? 0) > 0)) {
994
+ return "A recovery step needs the next block's reference max";
995
+ }
996
+ return null;
997
+ }
998
+ function lpPrescription(state, incrementKg) {
999
+ if (state.phase === "FINISHED") return [];
1000
+ if (state.phase === "RECOVERY") {
1001
+ const loadKg2 = lpLoadKg(
1002
+ state.referenceMaxKg,
1003
+ LP_RECOVERY.percentage,
1004
+ incrementKg
1005
+ );
1006
+ return Array.from({ length: LP_RECOVERY.sets }, () => ({
1007
+ loadKg: loadKg2,
1008
+ target: null,
1009
+ reps: LP_RECOVERY.reps
1010
+ }));
1011
+ }
1012
+ const step = lpStep(state.step);
1013
+ const loadKg = lpLoadKg(state.referenceMaxKg, step.percentage, incrementKg);
1014
+ return step.sets.map((target) => ({ loadKg, target, reps: null }));
1015
+ }
1016
+ function lpWorkingSets(sets) {
1017
+ return sets.filter((set) => setKindOf(set) !== "WARMUP");
1018
+ }
1019
+ var LP_ESTIMATE_STEPS = [7, 8];
1020
+ var LP_ESTIMATE_MAX_REPS_TO_FAILURE = 10;
1021
+ var LP_ESTIMATE_MIN_SETS = 3;
1022
+ function lpRirMidpoint(target) {
1023
+ return (target.rirMin + target.rirMax) / 2;
1024
+ }
1025
+ function lpSampleRir(sample) {
1026
+ const target = lpStep(sample.step).sets[sample.set - 1] ?? HARD;
1027
+ return lpRirMidpoint(target);
1028
+ }
1029
+ function lpSetEstimateKg(loadKg, reps, rir) {
1030
+ return loadKg * (1 + (reps + rir) / 30);
1031
+ }
1032
+ function lpEstimate(samples, incrementKg) {
1033
+ const sets = samples.filter((sample) => LP_ESTIMATE_STEPS.includes(sample.step)).map((sample) => ({ ...sample, rir: lpSampleRir(sample) })).filter(
1034
+ (sample) => sample.reps >= 1 && sample.loadKg > 0 && sample.reps + sample.rir <= LP_ESTIMATE_MAX_REPS_TO_FAILURE
1035
+ ).map((sample) => ({
1036
+ ...sample,
1037
+ estimateKg: tidy(lpSetEstimateKg(sample.loadKg, sample.reps, sample.rir))
1038
+ })).sort((a, b) => a.step - b.step || a.set - b.set);
1039
+ if (sets.length < LP_ESTIMATE_MIN_SETS) {
1040
+ return { estimatedMaxKg: null, sets };
1041
+ }
1042
+ const values = sets.map((set) => set.estimateKg).sort((a, b) => a - b);
1043
+ const middle = Math.floor(values.length / 2);
1044
+ const median = values.length % 2 ? values[middle] : (values[middle - 1] + values[middle]) / 2;
1045
+ return { estimatedMaxKg: roundDownToIncrement(median, incrementKg), sets };
1046
+ }
1047
+ var LP_PROGRESS_THRESHOLD = 0.025;
1048
+ var LP_PROGRESS_CAP = 0.05;
1049
+ var LP_NEXT_BLOCK_KINDS = ["PROGRESS", "REPEAT", "RESET"];
1050
+ function lpRecommendation(referenceMaxKg, estimatedMaxKg, incrementKg) {
1051
+ if (!(estimatedMaxKg && estimatedMaxKg > 0) || !(referenceMaxKg > 0)) {
1052
+ return { kind: "REPEAT", nextReferenceMaxKg: referenceMaxKg, change: null };
1053
+ }
1054
+ const change = estimatedMaxKg / referenceMaxKg - 1;
1055
+ if (change >= LP_PROGRESS_THRESHOLD - 1e-9) {
1056
+ const capped = Math.min(
1057
+ estimatedMaxKg,
1058
+ referenceMaxKg * (1 + LP_PROGRESS_CAP)
1059
+ );
1060
+ const next = roundDownToIncrement(capped, incrementKg);
1061
+ return {
1062
+ kind: "PROGRESS",
1063
+ nextReferenceMaxKg: next > referenceMaxKg ? next : referenceMaxKg,
1064
+ change
1065
+ };
1066
+ }
1067
+ if (change < -LP_PROGRESS_THRESHOLD - 1e-9) {
1068
+ return {
1069
+ kind: "RESET",
1070
+ nextReferenceMaxKg: roundDownToIncrement(estimatedMaxKg, incrementKg),
1071
+ change
1072
+ };
1073
+ }
1074
+ return { kind: "REPEAT", nextReferenceMaxKg: referenceMaxKg, change };
1075
+ }
1076
+ function sameLpPosition(a, b) {
1077
+ if (!a || !b) return !a && !b;
1078
+ return a.phase === b.phase && a.step === b.step && a.cycle === b.cycle && Math.abs(a.referenceMaxKg - b.referenceMaxKg) < 1e-6;
1079
+ }
1080
+ function advanceLinearPeriodization(state, logged, context) {
1081
+ if (state.phase === "FINISHED") return null;
1082
+ const prescribed = lpPrescription(state, context.incrementKg).length;
1083
+ for (let set = 1; set <= prescribed; set += 1) {
1084
+ if (!logged.some((entry) => entry.set === set && entry.completed)) {
1085
+ return null;
1086
+ }
1087
+ }
1088
+ if (state.phase === "RECOVERY") {
1089
+ return startLinearPeriodization(
1090
+ state.nextReferenceMaxKg ?? state.referenceMaxKg,
1091
+ state.cycle + 1
1092
+ );
1093
+ }
1094
+ const samples = LP_ESTIMATE_STEPS.includes(state.step) ? [
1095
+ ...state.samples.filter((sample) => sample.step !== state.step),
1096
+ ...logged.filter((entry) => entry.completed && entry.set <= prescribed).map((entry) => ({
1097
+ sessionId: context.sessionId,
1098
+ step: state.step,
1099
+ set: entry.set,
1100
+ loadKg: entry.loadKg,
1101
+ reps: entry.reps
1102
+ }))
1103
+ ] : state.samples;
1104
+ if (state.step < LP_BLOCK_STEPS) {
1105
+ return { ...state, step: state.step + 1, samples };
1106
+ }
1107
+ return {
1108
+ ...state,
1109
+ phase: "FINISHED",
1110
+ samples,
1111
+ finishedAt: context.finishedAt,
1112
+ estimatedMaxKg: lpEstimate(samples, context.incrementKg).estimatedMaxKg,
1113
+ nextReferenceMaxKg: null
1114
+ };
1115
+ }
1116
+ function continueLinearPeriodization(state, choice) {
1117
+ if (state.phase !== "FINISHED") return null;
1118
+ if (choice.recovery) {
1119
+ return {
1120
+ ...state,
1121
+ phase: "RECOVERY",
1122
+ nextReferenceMaxKg: choice.referenceMaxKg
1123
+ };
1124
+ }
1125
+ return startLinearPeriodization(choice.referenceMaxKg, state.cycle + 1);
1126
+ }
1127
+ function lpStoredSets(state, incrementKg) {
1128
+ if (!state) {
1129
+ return Array.from({ length: LP_WORKING_SETS }, () => ({
1130
+ repType: "FIXED",
1131
+ reps: null,
1132
+ weight: null,
1133
+ rir: null,
1134
+ kind: "WORKING"
1135
+ }));
1136
+ }
1137
+ const prescription = state.phase === "FINISHED" ? lpPrescription({ ...state, phase: "BLOCK", step: LP_BLOCK_STEPS }, incrementKg) : lpPrescription(state, incrementKg);
1138
+ return prescription.map((set) => ({
1139
+ repType: "FIXED",
1140
+ reps: set.reps,
1141
+ weight: set.loadKg,
1142
+ rir: set.target ? set.target.rirMin : null,
1143
+ kind: "WORKING"
1144
+ }));
1145
+ }
1146
+
897
1147
  // src/plate-loading.ts
898
1148
  var CANONICAL_SCALE = 1e4;
899
1149
  var DENSE_CAPACITY_LIMIT = 2e5;
@@ -1526,7 +1776,9 @@ var NOTIFICATION_KINDS = [
1526
1776
  "TRAINING_PARTNER_ENCOURAGEMENT",
1527
1777
  // NOTIF-07: selected activity from a partner who currently shares it.
1528
1778
  "TRAINING_PARTNER_SESSION",
1529
- "TRAINING_PARTNER_ACHIEVEMENT"
1779
+ "TRAINING_PARTNER_ACHIEVEMENT",
1780
+ // ROUT-18: an LP exercise finished its 8-step block.
1781
+ "LINEAR_BLOCK_FINISHED"
1530
1782
  ];
1531
1783
  var NOTIFICATIONS_LOOKBACK_DAYS = 30;
1532
1784
  var NOTIFICATIONS_RETENTION_DAYS = 90;
@@ -1713,6 +1965,14 @@ var API_ERROR_MESSAGES = {
1713
1965
  SWAP_SESSION_NOT_ACTIVE: "Exercises can only be swapped in an active session",
1714
1966
  SWAP_SETS_COMPLETED: "This exercise already has completed sets. Swap it before completing any.",
1715
1967
  NOTES_DISCARDED_SESSION: "A discarded workout cannot take notes",
1968
+ // ── Linear periodization (ROUT-17 to ROUT-19) ──────────────────────────
1969
+ LINEAR_BLOCK_INVALID: "This exercise's block is not valid: {reason}",
1970
+ LINEAR_BLOCK_EXTRA_SET: "An exercise on an 8-week block takes no extra sets",
1971
+ LINEAR_BLOCK_KIND_FIXED: "The working sets of an 8-week block cannot change kind",
1972
+ LINEAR_BLOCK_LOAD_FIXED: "The load of an 8-week block is prescribed and cannot be changed",
1973
+ LINEAR_BLOCK_SWAP_ROUTINE: "An exercise on an 8-week block can be swapped for this workout only",
1974
+ LINEAR_BLOCK_NOT_LINEAR: "This exercise is not on an 8-week block",
1975
+ LINEAR_BLOCK_NOT_FINISHED: "This exercise's block has not finished, so there is nothing to choose yet",
1716
1976
  // ── Corrections (LIVE-17) ──────────────────────────────────────────────
1717
1977
  CORRECTION_NOT_COMPLETED: "Only a completed workout can be corrected",
1718
1978
  CORRECTION_NOT_LATEST: "Only your most recent workout can be corrected",
@@ -1943,6 +2203,19 @@ function isMessagePermission(value) {
1943
2203
  INCHES_PER_FOOT,
1944
2204
  LENGTH_UNITS,
1945
2205
  LIMITED_SHORTFALL_KG,
2206
+ LP_BLOCK_STEPS,
2207
+ LP_ESTIMATE_MAX_REPS_TO_FAILURE,
2208
+ LP_ESTIMATE_MIN_SETS,
2209
+ LP_ESTIMATE_STEPS,
2210
+ LP_NEXT_BLOCK_KINDS,
2211
+ LP_PHASES,
2212
+ LP_PROGRESS_CAP,
2213
+ LP_PROGRESS_THRESHOLD,
2214
+ LP_RECOVERY,
2215
+ LP_REFERENCE_MAX_KG,
2216
+ LP_REST_SECONDS,
2217
+ LP_STEPS,
2218
+ LP_WORKING_SETS,
1946
2219
  MAX_SETS_PER_EXERCISE,
1947
2220
  MEASURABLE_GOALS_MAX,
1948
2221
  MEASURABLE_GOAL_DIRECTIONS,
@@ -2084,6 +2357,7 @@ function isMessagePermission(value) {
2084
2357
  WEIGHT_UNITS,
2085
2358
  WORKOUT_SESSION_STATUSES,
2086
2359
  accountExportFileName,
2360
+ advanceLinearPeriodization,
2087
2361
  apiError,
2088
2362
  apiErrorMessage,
2089
2363
  applyDeload,
@@ -2094,6 +2368,7 @@ function isMessagePermission(value) {
2094
2368
  canLinkToNext,
2095
2369
  cmToFeetInches,
2096
2370
  cmToInches,
2371
+ continueLinearPeriodization,
2097
2372
  countsAsWork,
2098
2373
  countsForProgression,
2099
2374
  customExerciseProblem,
@@ -2123,6 +2398,17 @@ function isMessagePermission(value) {
2123
2398
  isWithinQuietHours,
2124
2399
  latestBodyWeight,
2125
2400
  leadWorkingWeight,
2401
+ linearPeriodizationProblem,
2402
+ lpEstimate,
2403
+ lpLoadKg,
2404
+ lpPrescription,
2405
+ lpRecommendation,
2406
+ lpRirMidpoint,
2407
+ lpSampleRir,
2408
+ lpSetEstimateKg,
2409
+ lpStep,
2410
+ lpStoredSets,
2411
+ lpWorkingSets,
2126
2412
  matchesDeletionConfirmation,
2127
2413
  messageBodyLength,
2128
2414
  normalizeDashboardLayout,
@@ -2135,10 +2421,14 @@ function isMessagePermission(value) {
2135
2421
  requiredToFinish,
2136
2422
  resolveActiveTrainingBlock,
2137
2423
  resolveRoutinePlan,
2424
+ roundDownToIncrement,
2425
+ roundToIncrement,
2138
2426
  routineDayLabel,
2427
+ sameLpPosition,
2139
2428
  sessionCorrectionWindow,
2140
2429
  setKindOf,
2141
2430
  setLogValuesDiffer,
2431
+ startLinearPeriodization,
2142
2432
  summarizeBodyProgress,
2143
2433
  trainingBlockCovers,
2144
2434
  warmUpLoad,