@sunsteel/contracts 0.86.0 → 0.88.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",
@@ -232,7 +234,11 @@ var REPORT_SUBJECT_KINDS = [
232
234
  // first one the TRUST-04 queue is likely to see in volume: everything above
233
235
  // is content its owner created for themselves and shared, where a comment is
234
236
  // aimed at somebody else.
235
- "COMMENT"
237
+ "COMMENT",
238
+ // MSG-09. A message has no page of its own and is not shared with anyone but
239
+ // the other participant, so its report captures what that participant could
240
+ // see -- the message and up to five before it -- for the moderator to read.
241
+ "MESSAGE"
236
242
  ];
237
243
  var REPORT_REASONS = [
238
244
  "SPAM",
@@ -249,7 +255,12 @@ var MODERATION_ACTION_KINDS = [
249
255
  "VIEW_SUBJECT",
250
256
  "DISMISS_REPORT",
251
257
  "HIDE_SUBJECT",
252
- "RESTORE_SUBJECT"
258
+ "RESTORE_SUBJECT",
259
+ // MSG-09. A power over an account rather than over one thing it made: it
260
+ // stops the account sending and starting conversations until it is lifted.
261
+ // Recorded against the member, from the report it was taken on.
262
+ "RESTRICT_MESSAGING",
263
+ "LIFT_MESSAGING_RESTRICTION"
253
264
  ];
254
265
  var MODERATION_NOTE_MAX_LENGTH = 500;
255
266
  var MODERATION_QUEUE_PAGE_SIZE = 20;
@@ -593,6 +604,224 @@ function requiredToFinish(kind) {
593
604
  return k === "WORKING" || k === "DROP";
594
605
  }
595
606
 
607
+ // src/linear-periodization.ts
608
+ var LP_BLOCK_STEPS = 8;
609
+ var LP_WORKING_SETS = 3;
610
+ var LP_REST_SECONDS = 180;
611
+ var LP_REFERENCE_MAX_KG = 1e3;
612
+ var EASY = { rirMin: 2, rirMax: 3, amrap: false };
613
+ var AMRAP = { rirMin: 0, rirMax: 0, amrap: true };
614
+ var HARD = { rirMin: 1, rirMax: 2, amrap: false };
615
+ var LP_STEPS = [
616
+ { step: 1, percentage: 0.63, sets: [EASY, EASY, AMRAP] },
617
+ { step: 2, percentage: 0.66, sets: [EASY, EASY, AMRAP] },
618
+ { step: 3, percentage: 0.69, sets: [EASY, EASY, AMRAP] },
619
+ { step: 4, percentage: 0.72, sets: [EASY, EASY, AMRAP] },
620
+ { step: 5, percentage: 0.75, sets: [HARD, HARD, HARD] },
621
+ { step: 6, percentage: 0.78, sets: [HARD, HARD, HARD] },
622
+ { step: 7, percentage: 0.81, sets: [HARD, HARD, HARD] },
623
+ { step: 8, percentage: 0.85, sets: [HARD, HARD, HARD] }
624
+ ];
625
+ function lpStep(step) {
626
+ const index = Math.min(Math.max(Math.trunc(step) || 1, 1), LP_BLOCK_STEPS);
627
+ return LP_STEPS[index - 1];
628
+ }
629
+ var LP_RECOVERY = { percentage: 0.7, sets: 2, reps: 5 };
630
+ var tidy = (value) => Math.round(value * 1e6) / 1e6;
631
+ function roundToIncrement(value, increment) {
632
+ if (!(increment > 0)) return tidy(value);
633
+ const steps = value / increment;
634
+ const floor = Math.floor(steps + 1e-9);
635
+ const fraction = steps - floor;
636
+ return tidy((fraction > 0.5 + 1e-9 ? floor + 1 : floor) * increment);
637
+ }
638
+ function roundDownToIncrement(value, increment) {
639
+ if (!(increment > 0)) return tidy(value);
640
+ return tidy(Math.floor(value / increment + 1e-9) * increment);
641
+ }
642
+ function lpLoadKg(referenceMaxKg, percentage, incrementKg) {
643
+ return roundToIncrement(referenceMaxKg * percentage, incrementKg);
644
+ }
645
+ var LP_PHASES = ["BLOCK", "RECOVERY", "FINISHED"];
646
+ function startLinearPeriodization(referenceMaxKg, cycle = 1) {
647
+ return {
648
+ referenceMaxKg,
649
+ phase: "BLOCK",
650
+ step: 1,
651
+ cycle,
652
+ samples: [],
653
+ finishedAt: null,
654
+ estimatedMaxKg: null,
655
+ nextReferenceMaxKg: null
656
+ };
657
+ }
658
+ function linearPeriodizationProblem(state) {
659
+ if (!Number.isFinite(state.referenceMaxKg) || state.referenceMaxKg <= 0 || state.referenceMaxKg > LP_REFERENCE_MAX_KG) {
660
+ return "The reference max must be above 0";
661
+ }
662
+ if (!LP_PHASES.includes(state.phase)) {
663
+ return "Unknown block phase";
664
+ }
665
+ if (!Number.isInteger(state.step) || state.step < 1 || state.step > LP_BLOCK_STEPS) {
666
+ return "The block step must be between 1 and 8";
667
+ }
668
+ if (!Number.isInteger(state.cycle) || state.cycle < 1) {
669
+ return "The block number must be 1 or more";
670
+ }
671
+ if (state.phase === "RECOVERY" && !((state.nextReferenceMaxKg ?? 0) > 0)) {
672
+ return "A recovery step needs the next block's reference max";
673
+ }
674
+ return null;
675
+ }
676
+ function lpPrescription(state, incrementKg) {
677
+ if (state.phase === "FINISHED") return [];
678
+ if (state.phase === "RECOVERY") {
679
+ const loadKg2 = lpLoadKg(
680
+ state.referenceMaxKg,
681
+ LP_RECOVERY.percentage,
682
+ incrementKg
683
+ );
684
+ return Array.from({ length: LP_RECOVERY.sets }, () => ({
685
+ loadKg: loadKg2,
686
+ target: null,
687
+ reps: LP_RECOVERY.reps
688
+ }));
689
+ }
690
+ const step = lpStep(state.step);
691
+ const loadKg = lpLoadKg(state.referenceMaxKg, step.percentage, incrementKg);
692
+ return step.sets.map((target) => ({ loadKg, target, reps: null }));
693
+ }
694
+ function lpWorkingSets(sets) {
695
+ return sets.filter((set) => setKindOf(set) !== "WARMUP");
696
+ }
697
+ var LP_ESTIMATE_STEPS = [7, 8];
698
+ var LP_ESTIMATE_MAX_REPS_TO_FAILURE = 10;
699
+ var LP_ESTIMATE_MIN_SETS = 3;
700
+ function lpRirMidpoint(target) {
701
+ return (target.rirMin + target.rirMax) / 2;
702
+ }
703
+ function lpSampleRir(sample) {
704
+ const target = lpStep(sample.step).sets[sample.set - 1] ?? HARD;
705
+ return lpRirMidpoint(target);
706
+ }
707
+ function lpSetEstimateKg(loadKg, reps, rir) {
708
+ return loadKg * (1 + (reps + rir) / 30);
709
+ }
710
+ function lpEstimate(samples, incrementKg) {
711
+ const sets = samples.filter((sample) => LP_ESTIMATE_STEPS.includes(sample.step)).map((sample) => ({ ...sample, rir: lpSampleRir(sample) })).filter(
712
+ (sample) => sample.reps >= 1 && sample.loadKg > 0 && sample.reps + sample.rir <= LP_ESTIMATE_MAX_REPS_TO_FAILURE
713
+ ).map((sample) => ({
714
+ ...sample,
715
+ estimateKg: tidy(lpSetEstimateKg(sample.loadKg, sample.reps, sample.rir))
716
+ })).sort((a, b) => a.step - b.step || a.set - b.set);
717
+ if (sets.length < LP_ESTIMATE_MIN_SETS) {
718
+ return { estimatedMaxKg: null, sets };
719
+ }
720
+ const values = sets.map((set) => set.estimateKg).sort((a, b) => a - b);
721
+ const middle = Math.floor(values.length / 2);
722
+ const median = values.length % 2 ? values[middle] : (values[middle - 1] + values[middle]) / 2;
723
+ return { estimatedMaxKg: roundDownToIncrement(median, incrementKg), sets };
724
+ }
725
+ var LP_PROGRESS_THRESHOLD = 0.025;
726
+ var LP_PROGRESS_CAP = 0.05;
727
+ var LP_NEXT_BLOCK_KINDS = ["PROGRESS", "REPEAT", "RESET"];
728
+ function lpRecommendation(referenceMaxKg, estimatedMaxKg, incrementKg) {
729
+ if (!(estimatedMaxKg && estimatedMaxKg > 0) || !(referenceMaxKg > 0)) {
730
+ return { kind: "REPEAT", nextReferenceMaxKg: referenceMaxKg, change: null };
731
+ }
732
+ const change = estimatedMaxKg / referenceMaxKg - 1;
733
+ if (change >= LP_PROGRESS_THRESHOLD - 1e-9) {
734
+ const capped = Math.min(
735
+ estimatedMaxKg,
736
+ referenceMaxKg * (1 + LP_PROGRESS_CAP)
737
+ );
738
+ const next = roundDownToIncrement(capped, incrementKg);
739
+ return {
740
+ kind: "PROGRESS",
741
+ nextReferenceMaxKg: next > referenceMaxKg ? next : referenceMaxKg,
742
+ change
743
+ };
744
+ }
745
+ if (change < -LP_PROGRESS_THRESHOLD - 1e-9) {
746
+ return {
747
+ kind: "RESET",
748
+ nextReferenceMaxKg: roundDownToIncrement(estimatedMaxKg, incrementKg),
749
+ change
750
+ };
751
+ }
752
+ return { kind: "REPEAT", nextReferenceMaxKg: referenceMaxKg, change };
753
+ }
754
+ function sameLpPosition(a, b) {
755
+ if (!a || !b) return !a && !b;
756
+ return a.phase === b.phase && a.step === b.step && a.cycle === b.cycle && Math.abs(a.referenceMaxKg - b.referenceMaxKg) < 1e-6;
757
+ }
758
+ function advanceLinearPeriodization(state, logged, context) {
759
+ if (state.phase === "FINISHED") return null;
760
+ const prescribed = lpPrescription(state, context.incrementKg).length;
761
+ for (let set = 1; set <= prescribed; set += 1) {
762
+ if (!logged.some((entry) => entry.set === set && entry.completed)) {
763
+ return null;
764
+ }
765
+ }
766
+ if (state.phase === "RECOVERY") {
767
+ return startLinearPeriodization(
768
+ state.nextReferenceMaxKg ?? state.referenceMaxKg,
769
+ state.cycle + 1
770
+ );
771
+ }
772
+ const samples = LP_ESTIMATE_STEPS.includes(state.step) ? [
773
+ ...state.samples.filter((sample) => sample.step !== state.step),
774
+ ...logged.filter((entry) => entry.completed && entry.set <= prescribed).map((entry) => ({
775
+ sessionId: context.sessionId,
776
+ step: state.step,
777
+ set: entry.set,
778
+ loadKg: entry.loadKg,
779
+ reps: entry.reps
780
+ }))
781
+ ] : state.samples;
782
+ if (state.step < LP_BLOCK_STEPS) {
783
+ return { ...state, step: state.step + 1, samples };
784
+ }
785
+ return {
786
+ ...state,
787
+ phase: "FINISHED",
788
+ samples,
789
+ finishedAt: context.finishedAt,
790
+ estimatedMaxKg: lpEstimate(samples, context.incrementKg).estimatedMaxKg,
791
+ nextReferenceMaxKg: null
792
+ };
793
+ }
794
+ function continueLinearPeriodization(state, choice) {
795
+ if (state.phase !== "FINISHED") return null;
796
+ if (choice.recovery) {
797
+ return {
798
+ ...state,
799
+ phase: "RECOVERY",
800
+ nextReferenceMaxKg: choice.referenceMaxKg
801
+ };
802
+ }
803
+ return startLinearPeriodization(choice.referenceMaxKg, state.cycle + 1);
804
+ }
805
+ function lpStoredSets(state, incrementKg) {
806
+ if (!state) {
807
+ return Array.from({ length: LP_WORKING_SETS }, () => ({
808
+ repType: "FIXED",
809
+ reps: null,
810
+ weight: null,
811
+ rir: null,
812
+ kind: "WORKING"
813
+ }));
814
+ }
815
+ const prescription = state.phase === "FINISHED" ? lpPrescription({ ...state, phase: "BLOCK", step: LP_BLOCK_STEPS }, incrementKg) : lpPrescription(state, incrementKg);
816
+ return prescription.map((set) => ({
817
+ repType: "FIXED",
818
+ reps: set.reps,
819
+ weight: set.loadKg,
820
+ rir: set.target ? set.target.rirMin : null,
821
+ kind: "WORKING"
822
+ }));
823
+ }
824
+
596
825
  // src/plate-loading.ts
597
826
  var CANONICAL_SCALE = 1e4;
598
827
  var DENSE_CAPACITY_LIMIT = 2e5;
@@ -1225,7 +1454,9 @@ var NOTIFICATION_KINDS = [
1225
1454
  "TRAINING_PARTNER_ENCOURAGEMENT",
1226
1455
  // NOTIF-07: selected activity from a partner who currently shares it.
1227
1456
  "TRAINING_PARTNER_SESSION",
1228
- "TRAINING_PARTNER_ACHIEVEMENT"
1457
+ "TRAINING_PARTNER_ACHIEVEMENT",
1458
+ // ROUT-18: an LP exercise finished its 8-step block.
1459
+ "LINEAR_BLOCK_FINISHED"
1229
1460
  ];
1230
1461
  var NOTIFICATIONS_LOOKBACK_DAYS = 30;
1231
1462
  var NOTIFICATIONS_RETENTION_DAYS = 90;
@@ -1412,6 +1643,14 @@ var API_ERROR_MESSAGES = {
1412
1643
  SWAP_SESSION_NOT_ACTIVE: "Exercises can only be swapped in an active session",
1413
1644
  SWAP_SETS_COMPLETED: "This exercise already has completed sets. Swap it before completing any.",
1414
1645
  NOTES_DISCARDED_SESSION: "A discarded workout cannot take notes",
1646
+ // ── Linear periodization (ROUT-17 to ROUT-19) ──────────────────────────
1647
+ LINEAR_BLOCK_INVALID: "This exercise's block is not valid: {reason}",
1648
+ LINEAR_BLOCK_EXTRA_SET: "An exercise on an 8-week block takes no extra sets",
1649
+ LINEAR_BLOCK_KIND_FIXED: "The working sets of an 8-week block cannot change kind",
1650
+ LINEAR_BLOCK_LOAD_FIXED: "The load of an 8-week block is prescribed and cannot be changed",
1651
+ LINEAR_BLOCK_SWAP_ROUTINE: "An exercise on an 8-week block can be swapped for this workout only",
1652
+ LINEAR_BLOCK_NOT_LINEAR: "This exercise is not on an 8-week block",
1653
+ LINEAR_BLOCK_NOT_FINISHED: "This exercise's block has not finished, so there is nothing to choose yet",
1415
1654
  // ── Corrections (LIVE-17) ──────────────────────────────────────────────
1416
1655
  CORRECTION_NOT_COMPLETED: "Only a completed workout can be corrected",
1417
1656
  CORRECTION_NOT_LATEST: "Only your most recent workout can be corrected",
@@ -1465,6 +1704,11 @@ var API_ERROR_MESSAGES = {
1465
1704
  MESSAGE_NOT_ADMITTED: "This member is not taking messages from you.",
1466
1705
  CONVERSATION_CLOSED: "This conversation can no longer receive messages.",
1467
1706
  MESSAGING_UNAVAILABLE: "Messaging is not available for this account.",
1707
+ // ── Message moderation (MSG-09) ────────────────────────────────────────
1708
+ MESSAGING_RESTRICTED: "Moderation has restricted your messaging. You can still read and delete messages.",
1709
+ REPORT_OWN_MESSAGE: "You cannot report your own message",
1710
+ MESSAGING_ALREADY_RESTRICTED: "This member's messaging is already restricted",
1711
+ MESSAGING_NOT_RESTRICTED: "This member's messaging is not restricted",
1468
1712
  // ── Account and settings ───────────────────────────────────────────────
1469
1713
  ACCOUNT_CONFLICT: "Account conflict for this email",
1470
1714
  ACCOUNT_DELETE_CONFIRMATION: "Type your username exactly as it appears to confirm.",
@@ -1561,6 +1805,7 @@ function normalizeMessageBody(body) {
1561
1805
  }
1562
1806
  return text;
1563
1807
  }
1808
+ var MESSAGE_REPORT_CONTEXT_BEFORE = 5;
1564
1809
  function isMessagePermission(value) {
1565
1810
  return MESSAGE_PERMISSIONS.includes(value);
1566
1811
  }
@@ -1641,6 +1886,19 @@ export {
1641
1886
  INCHES_PER_FOOT,
1642
1887
  LENGTH_UNITS,
1643
1888
  LIMITED_SHORTFALL_KG,
1889
+ LP_BLOCK_STEPS,
1890
+ LP_ESTIMATE_MAX_REPS_TO_FAILURE,
1891
+ LP_ESTIMATE_MIN_SETS,
1892
+ LP_ESTIMATE_STEPS,
1893
+ LP_NEXT_BLOCK_KINDS,
1894
+ LP_PHASES,
1895
+ LP_PROGRESS_CAP,
1896
+ LP_PROGRESS_THRESHOLD,
1897
+ LP_RECOVERY,
1898
+ LP_REFERENCE_MAX_KG,
1899
+ LP_REST_SECONDS,
1900
+ LP_STEPS,
1901
+ LP_WORKING_SETS,
1644
1902
  MAX_SETS_PER_EXERCISE,
1645
1903
  MEASURABLE_GOALS_MAX,
1646
1904
  MEASURABLE_GOAL_DIRECTIONS,
@@ -1649,6 +1907,7 @@ export {
1649
1907
  MESSAGES_PER_MINUTE_MAX,
1650
1908
  MESSAGE_BODY_MAX,
1651
1909
  MESSAGE_PERMISSIONS,
1910
+ MESSAGE_REPORT_CONTEXT_BEFORE,
1652
1911
  MINUTES_IN_DAY,
1653
1912
  MODERATION_ACTION_KINDS,
1654
1913
  MODERATION_HISTORY_PAGE_SIZE,
@@ -1782,6 +2041,7 @@ export {
1782
2041
  WEIGHT_UNITS,
1783
2042
  WORKOUT_SESSION_STATUSES,
1784
2043
  accountExportFileName,
2044
+ advanceLinearPeriodization,
1785
2045
  apiError,
1786
2046
  apiErrorMessage,
1787
2047
  applyDeload,
@@ -1792,6 +2052,7 @@ export {
1792
2052
  canLinkToNext,
1793
2053
  cmToFeetInches,
1794
2054
  cmToInches,
2055
+ continueLinearPeriodization,
1795
2056
  countsAsWork,
1796
2057
  countsForProgression,
1797
2058
  customExerciseProblem,
@@ -1821,6 +2082,17 @@ export {
1821
2082
  isWithinQuietHours,
1822
2083
  latestBodyWeight,
1823
2084
  leadWorkingWeight,
2085
+ linearPeriodizationProblem,
2086
+ lpEstimate,
2087
+ lpLoadKg,
2088
+ lpPrescription,
2089
+ lpRecommendation,
2090
+ lpRirMidpoint,
2091
+ lpSampleRir,
2092
+ lpSetEstimateKg,
2093
+ lpStep,
2094
+ lpStoredSets,
2095
+ lpWorkingSets,
1824
2096
  matchesDeletionConfirmation,
1825
2097
  messageBodyLength,
1826
2098
  normalizeDashboardLayout,
@@ -1833,10 +2105,14 @@ export {
1833
2105
  requiredToFinish,
1834
2106
  resolveActiveTrainingBlock,
1835
2107
  resolveRoutinePlan,
2108
+ roundDownToIncrement,
2109
+ roundToIncrement,
1836
2110
  routineDayLabel,
2111
+ sameLpPosition,
1837
2112
  sessionCorrectionWindow,
1838
2113
  setKindOf,
1839
2114
  setLogValuesDiffer,
2115
+ startLinearPeriodization,
1840
2116
  summarizeBodyProgress,
1841
2117
  trainingBlockCovers,
1842
2118
  warmUpLoad,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sunsteel/contracts",
3
- "version": "0.86.0",
3
+ "version": "0.88.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/ezee969/sunsteel-contracts.git"