@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.js CHANGED
@@ -10,7 +10,9 @@ var REP_TYPES = ["FIXED", "RANGE"];
10
10
  var PROGRESSION_SCHEMES = [
11
11
  "NONE",
12
12
  "DOUBLE_PROGRESSION",
13
- "DYNAMIC_DOUBLE_PROGRESSION"
13
+ "DYNAMIC_DOUBLE_PROGRESSION",
14
+ // ROUT-17: an 8-step block by percentage of a reference max and target RIR.
15
+ "LINEAR_PERIODIZATION"
14
16
  ];
15
17
  var MUSCLE_GROUPS = [
16
18
  "PECTORAL",
@@ -593,6 +595,224 @@ function requiredToFinish(kind) {
593
595
  return k === "WORKING" || k === "DROP";
594
596
  }
595
597
 
598
+ // src/linear-periodization.ts
599
+ var LP_BLOCK_STEPS = 8;
600
+ var LP_WORKING_SETS = 3;
601
+ var LP_REST_SECONDS = 180;
602
+ var LP_REFERENCE_MAX_KG = 1e3;
603
+ var EASY = { rirMin: 2, rirMax: 3, amrap: false };
604
+ var AMRAP = { rirMin: 0, rirMax: 0, amrap: true };
605
+ var HARD = { rirMin: 1, rirMax: 2, amrap: false };
606
+ var LP_STEPS = [
607
+ { step: 1, percentage: 0.63, sets: [EASY, EASY, AMRAP] },
608
+ { step: 2, percentage: 0.66, sets: [EASY, EASY, AMRAP] },
609
+ { step: 3, percentage: 0.69, sets: [EASY, EASY, AMRAP] },
610
+ { step: 4, percentage: 0.72, sets: [EASY, EASY, AMRAP] },
611
+ { step: 5, percentage: 0.75, sets: [HARD, HARD, HARD] },
612
+ { step: 6, percentage: 0.78, sets: [HARD, HARD, HARD] },
613
+ { step: 7, percentage: 0.81, sets: [HARD, HARD, HARD] },
614
+ { step: 8, percentage: 0.85, sets: [HARD, HARD, HARD] }
615
+ ];
616
+ function lpStep(step) {
617
+ const index = Math.min(Math.max(Math.trunc(step) || 1, 1), LP_BLOCK_STEPS);
618
+ return LP_STEPS[index - 1];
619
+ }
620
+ var LP_RECOVERY = { percentage: 0.7, sets: 2, reps: 5 };
621
+ var tidy = (value) => Math.round(value * 1e6) / 1e6;
622
+ function roundToIncrement(value, increment) {
623
+ if (!(increment > 0)) return tidy(value);
624
+ const steps = value / increment;
625
+ const floor = Math.floor(steps + 1e-9);
626
+ const fraction = steps - floor;
627
+ return tidy((fraction > 0.5 + 1e-9 ? floor + 1 : floor) * increment);
628
+ }
629
+ function roundDownToIncrement(value, increment) {
630
+ if (!(increment > 0)) return tidy(value);
631
+ return tidy(Math.floor(value / increment + 1e-9) * increment);
632
+ }
633
+ function lpLoadKg(referenceMaxKg, percentage, incrementKg) {
634
+ return roundToIncrement(referenceMaxKg * percentage, incrementKg);
635
+ }
636
+ var LP_PHASES = ["BLOCK", "RECOVERY", "FINISHED"];
637
+ function startLinearPeriodization(referenceMaxKg, cycle = 1) {
638
+ return {
639
+ referenceMaxKg,
640
+ phase: "BLOCK",
641
+ step: 1,
642
+ cycle,
643
+ samples: [],
644
+ finishedAt: null,
645
+ estimatedMaxKg: null,
646
+ nextReferenceMaxKg: null
647
+ };
648
+ }
649
+ function linearPeriodizationProblem(state) {
650
+ if (!Number.isFinite(state.referenceMaxKg) || state.referenceMaxKg <= 0 || state.referenceMaxKg > LP_REFERENCE_MAX_KG) {
651
+ return "The reference max must be above 0";
652
+ }
653
+ if (!LP_PHASES.includes(state.phase)) {
654
+ return "Unknown block phase";
655
+ }
656
+ if (!Number.isInteger(state.step) || state.step < 1 || state.step > LP_BLOCK_STEPS) {
657
+ return "The block step must be between 1 and 8";
658
+ }
659
+ if (!Number.isInteger(state.cycle) || state.cycle < 1) {
660
+ return "The block number must be 1 or more";
661
+ }
662
+ if (state.phase === "RECOVERY" && !((state.nextReferenceMaxKg ?? 0) > 0)) {
663
+ return "A recovery step needs the next block's reference max";
664
+ }
665
+ return null;
666
+ }
667
+ function lpPrescription(state, incrementKg) {
668
+ if (state.phase === "FINISHED") return [];
669
+ if (state.phase === "RECOVERY") {
670
+ const loadKg2 = lpLoadKg(
671
+ state.referenceMaxKg,
672
+ LP_RECOVERY.percentage,
673
+ incrementKg
674
+ );
675
+ return Array.from({ length: LP_RECOVERY.sets }, () => ({
676
+ loadKg: loadKg2,
677
+ target: null,
678
+ reps: LP_RECOVERY.reps
679
+ }));
680
+ }
681
+ const step = lpStep(state.step);
682
+ const loadKg = lpLoadKg(state.referenceMaxKg, step.percentage, incrementKg);
683
+ return step.sets.map((target) => ({ loadKg, target, reps: null }));
684
+ }
685
+ function lpWorkingSets(sets) {
686
+ return sets.filter((set) => setKindOf(set) !== "WARMUP");
687
+ }
688
+ var LP_ESTIMATE_STEPS = [7, 8];
689
+ var LP_ESTIMATE_MAX_REPS_TO_FAILURE = 10;
690
+ var LP_ESTIMATE_MIN_SETS = 3;
691
+ function lpRirMidpoint(target) {
692
+ return (target.rirMin + target.rirMax) / 2;
693
+ }
694
+ function lpSampleRir(sample) {
695
+ const target = lpStep(sample.step).sets[sample.set - 1] ?? HARD;
696
+ return lpRirMidpoint(target);
697
+ }
698
+ function lpSetEstimateKg(loadKg, reps, rir) {
699
+ return loadKg * (1 + (reps + rir) / 30);
700
+ }
701
+ function lpEstimate(samples, incrementKg) {
702
+ const sets = samples.filter((sample) => LP_ESTIMATE_STEPS.includes(sample.step)).map((sample) => ({ ...sample, rir: lpSampleRir(sample) })).filter(
703
+ (sample) => sample.reps >= 1 && sample.loadKg > 0 && sample.reps + sample.rir <= LP_ESTIMATE_MAX_REPS_TO_FAILURE
704
+ ).map((sample) => ({
705
+ ...sample,
706
+ estimateKg: tidy(lpSetEstimateKg(sample.loadKg, sample.reps, sample.rir))
707
+ })).sort((a, b) => a.step - b.step || a.set - b.set);
708
+ if (sets.length < LP_ESTIMATE_MIN_SETS) {
709
+ return { estimatedMaxKg: null, sets };
710
+ }
711
+ const values = sets.map((set) => set.estimateKg).sort((a, b) => a - b);
712
+ const middle = Math.floor(values.length / 2);
713
+ const median = values.length % 2 ? values[middle] : (values[middle - 1] + values[middle]) / 2;
714
+ return { estimatedMaxKg: roundDownToIncrement(median, incrementKg), sets };
715
+ }
716
+ var LP_PROGRESS_THRESHOLD = 0.025;
717
+ var LP_PROGRESS_CAP = 0.05;
718
+ var LP_NEXT_BLOCK_KINDS = ["PROGRESS", "REPEAT", "RESET"];
719
+ function lpRecommendation(referenceMaxKg, estimatedMaxKg, incrementKg) {
720
+ if (!(estimatedMaxKg && estimatedMaxKg > 0) || !(referenceMaxKg > 0)) {
721
+ return { kind: "REPEAT", nextReferenceMaxKg: referenceMaxKg, change: null };
722
+ }
723
+ const change = estimatedMaxKg / referenceMaxKg - 1;
724
+ if (change >= LP_PROGRESS_THRESHOLD - 1e-9) {
725
+ const capped = Math.min(
726
+ estimatedMaxKg,
727
+ referenceMaxKg * (1 + LP_PROGRESS_CAP)
728
+ );
729
+ const next = roundDownToIncrement(capped, incrementKg);
730
+ return {
731
+ kind: "PROGRESS",
732
+ nextReferenceMaxKg: next > referenceMaxKg ? next : referenceMaxKg,
733
+ change
734
+ };
735
+ }
736
+ if (change < -LP_PROGRESS_THRESHOLD - 1e-9) {
737
+ return {
738
+ kind: "RESET",
739
+ nextReferenceMaxKg: roundDownToIncrement(estimatedMaxKg, incrementKg),
740
+ change
741
+ };
742
+ }
743
+ return { kind: "REPEAT", nextReferenceMaxKg: referenceMaxKg, change };
744
+ }
745
+ function sameLpPosition(a, b) {
746
+ if (!a || !b) return !a && !b;
747
+ return a.phase === b.phase && a.step === b.step && a.cycle === b.cycle && Math.abs(a.referenceMaxKg - b.referenceMaxKg) < 1e-6;
748
+ }
749
+ function advanceLinearPeriodization(state, logged, context) {
750
+ if (state.phase === "FINISHED") return null;
751
+ const prescribed = lpPrescription(state, context.incrementKg).length;
752
+ for (let set = 1; set <= prescribed; set += 1) {
753
+ if (!logged.some((entry) => entry.set === set && entry.completed)) {
754
+ return null;
755
+ }
756
+ }
757
+ if (state.phase === "RECOVERY") {
758
+ return startLinearPeriodization(
759
+ state.nextReferenceMaxKg ?? state.referenceMaxKg,
760
+ state.cycle + 1
761
+ );
762
+ }
763
+ const samples = LP_ESTIMATE_STEPS.includes(state.step) ? [
764
+ ...state.samples.filter((sample) => sample.step !== state.step),
765
+ ...logged.filter((entry) => entry.completed && entry.set <= prescribed).map((entry) => ({
766
+ sessionId: context.sessionId,
767
+ step: state.step,
768
+ set: entry.set,
769
+ loadKg: entry.loadKg,
770
+ reps: entry.reps
771
+ }))
772
+ ] : state.samples;
773
+ if (state.step < LP_BLOCK_STEPS) {
774
+ return { ...state, step: state.step + 1, samples };
775
+ }
776
+ return {
777
+ ...state,
778
+ phase: "FINISHED",
779
+ samples,
780
+ finishedAt: context.finishedAt,
781
+ estimatedMaxKg: lpEstimate(samples, context.incrementKg).estimatedMaxKg,
782
+ nextReferenceMaxKg: null
783
+ };
784
+ }
785
+ function continueLinearPeriodization(state, choice) {
786
+ if (state.phase !== "FINISHED") return null;
787
+ if (choice.recovery) {
788
+ return {
789
+ ...state,
790
+ phase: "RECOVERY",
791
+ nextReferenceMaxKg: choice.referenceMaxKg
792
+ };
793
+ }
794
+ return startLinearPeriodization(choice.referenceMaxKg, state.cycle + 1);
795
+ }
796
+ function lpStoredSets(state, incrementKg) {
797
+ if (!state) {
798
+ return Array.from({ length: LP_WORKING_SETS }, () => ({
799
+ repType: "FIXED",
800
+ reps: null,
801
+ weight: null,
802
+ rir: null,
803
+ kind: "WORKING"
804
+ }));
805
+ }
806
+ const prescription = state.phase === "FINISHED" ? lpPrescription({ ...state, phase: "BLOCK", step: LP_BLOCK_STEPS }, incrementKg) : lpPrescription(state, incrementKg);
807
+ return prescription.map((set) => ({
808
+ repType: "FIXED",
809
+ reps: set.reps,
810
+ weight: set.loadKg,
811
+ rir: set.target ? set.target.rirMin : null,
812
+ kind: "WORKING"
813
+ }));
814
+ }
815
+
596
816
  // src/plate-loading.ts
597
817
  var CANONICAL_SCALE = 1e4;
598
818
  var DENSE_CAPACITY_LIMIT = 2e5;
@@ -1225,7 +1445,9 @@ var NOTIFICATION_KINDS = [
1225
1445
  "TRAINING_PARTNER_ENCOURAGEMENT",
1226
1446
  // NOTIF-07: selected activity from a partner who currently shares it.
1227
1447
  "TRAINING_PARTNER_SESSION",
1228
- "TRAINING_PARTNER_ACHIEVEMENT"
1448
+ "TRAINING_PARTNER_ACHIEVEMENT",
1449
+ // ROUT-18: an LP exercise finished its 8-step block.
1450
+ "LINEAR_BLOCK_FINISHED"
1229
1451
  ];
1230
1452
  var NOTIFICATIONS_LOOKBACK_DAYS = 30;
1231
1453
  var NOTIFICATIONS_RETENTION_DAYS = 90;
@@ -1412,6 +1634,14 @@ var API_ERROR_MESSAGES = {
1412
1634
  SWAP_SESSION_NOT_ACTIVE: "Exercises can only be swapped in an active session",
1413
1635
  SWAP_SETS_COMPLETED: "This exercise already has completed sets. Swap it before completing any.",
1414
1636
  NOTES_DISCARDED_SESSION: "A discarded workout cannot take notes",
1637
+ // ── Linear periodization (ROUT-17 to ROUT-19) ──────────────────────────
1638
+ LINEAR_BLOCK_INVALID: "This exercise's block is not valid: {reason}",
1639
+ LINEAR_BLOCK_EXTRA_SET: "An exercise on an 8-week block takes no extra sets",
1640
+ LINEAR_BLOCK_KIND_FIXED: "The working sets of an 8-week block cannot change kind",
1641
+ LINEAR_BLOCK_LOAD_FIXED: "The load of an 8-week block is prescribed and cannot be changed",
1642
+ LINEAR_BLOCK_SWAP_ROUTINE: "An exercise on an 8-week block can be swapped for this workout only",
1643
+ LINEAR_BLOCK_NOT_LINEAR: "This exercise is not on an 8-week block",
1644
+ LINEAR_BLOCK_NOT_FINISHED: "This exercise's block has not finished, so there is nothing to choose yet",
1415
1645
  // ── Corrections (LIVE-17) ──────────────────────────────────────────────
1416
1646
  CORRECTION_NOT_COMPLETED: "Only a completed workout can be corrected",
1417
1647
  CORRECTION_NOT_LATEST: "Only your most recent workout can be corrected",
@@ -1641,6 +1871,19 @@ export {
1641
1871
  INCHES_PER_FOOT,
1642
1872
  LENGTH_UNITS,
1643
1873
  LIMITED_SHORTFALL_KG,
1874
+ LP_BLOCK_STEPS,
1875
+ LP_ESTIMATE_MAX_REPS_TO_FAILURE,
1876
+ LP_ESTIMATE_MIN_SETS,
1877
+ LP_ESTIMATE_STEPS,
1878
+ LP_NEXT_BLOCK_KINDS,
1879
+ LP_PHASES,
1880
+ LP_PROGRESS_CAP,
1881
+ LP_PROGRESS_THRESHOLD,
1882
+ LP_RECOVERY,
1883
+ LP_REFERENCE_MAX_KG,
1884
+ LP_REST_SECONDS,
1885
+ LP_STEPS,
1886
+ LP_WORKING_SETS,
1644
1887
  MAX_SETS_PER_EXERCISE,
1645
1888
  MEASURABLE_GOALS_MAX,
1646
1889
  MEASURABLE_GOAL_DIRECTIONS,
@@ -1782,6 +2025,7 @@ export {
1782
2025
  WEIGHT_UNITS,
1783
2026
  WORKOUT_SESSION_STATUSES,
1784
2027
  accountExportFileName,
2028
+ advanceLinearPeriodization,
1785
2029
  apiError,
1786
2030
  apiErrorMessage,
1787
2031
  applyDeload,
@@ -1792,6 +2036,7 @@ export {
1792
2036
  canLinkToNext,
1793
2037
  cmToFeetInches,
1794
2038
  cmToInches,
2039
+ continueLinearPeriodization,
1795
2040
  countsAsWork,
1796
2041
  countsForProgression,
1797
2042
  customExerciseProblem,
@@ -1821,6 +2066,17 @@ export {
1821
2066
  isWithinQuietHours,
1822
2067
  latestBodyWeight,
1823
2068
  leadWorkingWeight,
2069
+ linearPeriodizationProblem,
2070
+ lpEstimate,
2071
+ lpLoadKg,
2072
+ lpPrescription,
2073
+ lpRecommendation,
2074
+ lpRirMidpoint,
2075
+ lpSampleRir,
2076
+ lpSetEstimateKg,
2077
+ lpStep,
2078
+ lpStoredSets,
2079
+ lpWorkingSets,
1824
2080
  matchesDeletionConfirmation,
1825
2081
  messageBodyLength,
1826
2082
  normalizeDashboardLayout,
@@ -1833,10 +2089,14 @@ export {
1833
2089
  requiredToFinish,
1834
2090
  resolveActiveTrainingBlock,
1835
2091
  resolveRoutinePlan,
2092
+ roundDownToIncrement,
2093
+ roundToIncrement,
1836
2094
  routineDayLabel,
2095
+ sameLpPosition,
1837
2096
  sessionCorrectionWindow,
1838
2097
  setKindOf,
1839
2098
  setLogValuesDiffer,
2099
+ startLinearPeriodization,
1840
2100
  summarizeBodyProgress,
1841
2101
  trainingBlockCovers,
1842
2102
  warmUpLoad,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sunsteel/contracts",
3
- "version": "0.86.0",
3
+ "version": "0.87.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/ezee969/sunsteel-contracts.git"