@forwardimpact/pathway 0.1.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.
Files changed (227) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +104 -0
  3. package/app/commands/agent.js +430 -0
  4. package/app/commands/behaviour.js +61 -0
  5. package/app/commands/command-factory.js +211 -0
  6. package/app/commands/discipline.js +58 -0
  7. package/app/commands/driver.js +94 -0
  8. package/app/commands/grade.js +60 -0
  9. package/app/commands/index.js +20 -0
  10. package/app/commands/init.js +67 -0
  11. package/app/commands/interview.js +68 -0
  12. package/app/commands/job.js +157 -0
  13. package/app/commands/progress.js +77 -0
  14. package/app/commands/questions.js +179 -0
  15. package/app/commands/serve.js +143 -0
  16. package/app/commands/site.js +121 -0
  17. package/app/commands/skill.js +76 -0
  18. package/app/commands/stage.js +129 -0
  19. package/app/commands/track.js +70 -0
  20. package/app/components/action-buttons.js +66 -0
  21. package/app/components/behaviour-profile.js +53 -0
  22. package/app/components/builder.js +341 -0
  23. package/app/components/card.js +98 -0
  24. package/app/components/checklist.js +145 -0
  25. package/app/components/comparison-radar.js +237 -0
  26. package/app/components/detail.js +230 -0
  27. package/app/components/error-page.js +72 -0
  28. package/app/components/grid.js +109 -0
  29. package/app/components/list.js +120 -0
  30. package/app/components/modifier-table.js +142 -0
  31. package/app/components/nav.js +64 -0
  32. package/app/components/progression-table.js +320 -0
  33. package/app/components/radar-chart.js +102 -0
  34. package/app/components/skill-matrix.js +97 -0
  35. package/app/css/base.css +56 -0
  36. package/app/css/bundles/app.css +40 -0
  37. package/app/css/bundles/handout.css +43 -0
  38. package/app/css/bundles/slides.css +40 -0
  39. package/app/css/components/badges.css +215 -0
  40. package/app/css/components/buttons.css +101 -0
  41. package/app/css/components/forms.css +105 -0
  42. package/app/css/components/layout.css +209 -0
  43. package/app/css/components/nav.css +166 -0
  44. package/app/css/components/progress.css +166 -0
  45. package/app/css/components/states.css +82 -0
  46. package/app/css/components/surfaces.css +243 -0
  47. package/app/css/components/tables.css +362 -0
  48. package/app/css/components/typography.css +122 -0
  49. package/app/css/components/utilities.css +41 -0
  50. package/app/css/pages/agent-builder.css +391 -0
  51. package/app/css/pages/assessment-results.css +453 -0
  52. package/app/css/pages/detail.css +59 -0
  53. package/app/css/pages/interview-builder.css +148 -0
  54. package/app/css/pages/job-builder.css +134 -0
  55. package/app/css/pages/landing.css +92 -0
  56. package/app/css/pages/lifecycle.css +118 -0
  57. package/app/css/pages/progress-builder.css +274 -0
  58. package/app/css/pages/self-assessment.css +502 -0
  59. package/app/css/reset.css +50 -0
  60. package/app/css/tokens.css +153 -0
  61. package/app/css/views/handout.css +30 -0
  62. package/app/css/views/print.css +608 -0
  63. package/app/css/views/slide-animations.css +113 -0
  64. package/app/css/views/slide-base.css +330 -0
  65. package/app/css/views/slide-sections.css +597 -0
  66. package/app/css/views/slide-tables.css +275 -0
  67. package/app/formatters/agent/dom.js +540 -0
  68. package/app/formatters/agent/profile.js +133 -0
  69. package/app/formatters/agent/skill.js +58 -0
  70. package/app/formatters/behaviour/dom.js +91 -0
  71. package/app/formatters/behaviour/markdown.js +54 -0
  72. package/app/formatters/behaviour/shared.js +64 -0
  73. package/app/formatters/discipline/dom.js +187 -0
  74. package/app/formatters/discipline/markdown.js +87 -0
  75. package/app/formatters/discipline/shared.js +131 -0
  76. package/app/formatters/driver/dom.js +103 -0
  77. package/app/formatters/driver/shared.js +92 -0
  78. package/app/formatters/grade/dom.js +208 -0
  79. package/app/formatters/grade/markdown.js +94 -0
  80. package/app/formatters/grade/shared.js +86 -0
  81. package/app/formatters/index.js +50 -0
  82. package/app/formatters/interview/dom.js +97 -0
  83. package/app/formatters/interview/markdown.js +66 -0
  84. package/app/formatters/interview/shared.js +332 -0
  85. package/app/formatters/job/description.js +176 -0
  86. package/app/formatters/job/dom.js +411 -0
  87. package/app/formatters/job/markdown.js +102 -0
  88. package/app/formatters/progress/dom.js +135 -0
  89. package/app/formatters/progress/markdown.js +86 -0
  90. package/app/formatters/progress/shared.js +339 -0
  91. package/app/formatters/questions/json.js +43 -0
  92. package/app/formatters/questions/markdown.js +303 -0
  93. package/app/formatters/questions/shared.js +274 -0
  94. package/app/formatters/questions/yaml.js +76 -0
  95. package/app/formatters/shared.js +71 -0
  96. package/app/formatters/skill/dom.js +168 -0
  97. package/app/formatters/skill/markdown.js +109 -0
  98. package/app/formatters/skill/shared.js +125 -0
  99. package/app/formatters/stage/dom.js +135 -0
  100. package/app/formatters/stage/index.js +12 -0
  101. package/app/formatters/stage/shared.js +111 -0
  102. package/app/formatters/track/dom.js +128 -0
  103. package/app/formatters/track/markdown.js +105 -0
  104. package/app/formatters/track/shared.js +181 -0
  105. package/app/handout-main.js +421 -0
  106. package/app/handout.html +21 -0
  107. package/app/index.html +59 -0
  108. package/app/lib/card-mappers.js +173 -0
  109. package/app/lib/cli-output.js +270 -0
  110. package/app/lib/error-boundary.js +70 -0
  111. package/app/lib/errors.js +49 -0
  112. package/app/lib/form-controls.js +47 -0
  113. package/app/lib/job-cache.js +86 -0
  114. package/app/lib/markdown.js +114 -0
  115. package/app/lib/radar.js +866 -0
  116. package/app/lib/reactive.js +77 -0
  117. package/app/lib/render.js +212 -0
  118. package/app/lib/router-core.js +160 -0
  119. package/app/lib/router-pages.js +16 -0
  120. package/app/lib/router-slides.js +202 -0
  121. package/app/lib/state.js +148 -0
  122. package/app/lib/utils.js +14 -0
  123. package/app/lib/yaml-loader.js +327 -0
  124. package/app/main.js +213 -0
  125. package/app/model/agent.js +702 -0
  126. package/app/model/checklist.js +137 -0
  127. package/app/model/derivation.js +699 -0
  128. package/app/model/index-generator.js +71 -0
  129. package/app/model/interview.js +539 -0
  130. package/app/model/job.js +222 -0
  131. package/app/model/levels.js +591 -0
  132. package/app/model/loader.js +564 -0
  133. package/app/model/matching.js +858 -0
  134. package/app/model/modifiers.js +158 -0
  135. package/app/model/profile.js +266 -0
  136. package/app/model/progression.js +507 -0
  137. package/app/model/validation.js +1385 -0
  138. package/app/pages/agent-builder.js +823 -0
  139. package/app/pages/assessment-results.js +507 -0
  140. package/app/pages/behaviour.js +70 -0
  141. package/app/pages/discipline.js +71 -0
  142. package/app/pages/driver.js +106 -0
  143. package/app/pages/grade.js +117 -0
  144. package/app/pages/interview-builder.js +50 -0
  145. package/app/pages/interview.js +304 -0
  146. package/app/pages/job-builder.js +50 -0
  147. package/app/pages/job.js +58 -0
  148. package/app/pages/landing.js +305 -0
  149. package/app/pages/progress-builder.js +58 -0
  150. package/app/pages/progress.js +495 -0
  151. package/app/pages/self-assessment.js +729 -0
  152. package/app/pages/skill.js +113 -0
  153. package/app/pages/stage.js +231 -0
  154. package/app/pages/track.js +69 -0
  155. package/app/slide-main.js +360 -0
  156. package/app/slides/behaviour.js +38 -0
  157. package/app/slides/chapter.js +82 -0
  158. package/app/slides/discipline.js +40 -0
  159. package/app/slides/driver.js +39 -0
  160. package/app/slides/grade.js +32 -0
  161. package/app/slides/index.js +198 -0
  162. package/app/slides/interview.js +58 -0
  163. package/app/slides/job.js +55 -0
  164. package/app/slides/overview.js +126 -0
  165. package/app/slides/progress.js +83 -0
  166. package/app/slides/skill.js +40 -0
  167. package/app/slides/track.js +39 -0
  168. package/app/slides.html +56 -0
  169. package/app/types.js +147 -0
  170. package/bin/pathway.js +489 -0
  171. package/examples/agents/.claude/skills/architecture-design/SKILL.md +88 -0
  172. package/examples/agents/.claude/skills/cloud-platforms/SKILL.md +90 -0
  173. package/examples/agents/.claude/skills/code-quality-review/SKILL.md +67 -0
  174. package/examples/agents/.claude/skills/data-modeling/SKILL.md +99 -0
  175. package/examples/agents/.claude/skills/developer-experience/SKILL.md +99 -0
  176. package/examples/agents/.claude/skills/devops-cicd/SKILL.md +96 -0
  177. package/examples/agents/.claude/skills/full-stack-development/SKILL.md +90 -0
  178. package/examples/agents/.claude/skills/knowledge-management/SKILL.md +100 -0
  179. package/examples/agents/.claude/skills/pattern-generalization/SKILL.md +102 -0
  180. package/examples/agents/.claude/skills/sre-practices/SKILL.md +117 -0
  181. package/examples/agents/.claude/skills/technical-debt-management/SKILL.md +123 -0
  182. package/examples/agents/.claude/skills/technical-writing/SKILL.md +129 -0
  183. package/examples/agents/.github/agents/se-platform-code.agent.md +181 -0
  184. package/examples/agents/.github/agents/se-platform-plan.agent.md +178 -0
  185. package/examples/agents/.github/agents/se-platform-review.agent.md +113 -0
  186. package/examples/agents/.vscode/settings.json +8 -0
  187. package/examples/behaviours/_index.yaml +8 -0
  188. package/examples/behaviours/outcome_ownership.yaml +44 -0
  189. package/examples/behaviours/polymathic_knowledge.yaml +42 -0
  190. package/examples/behaviours/precise_communication.yaml +40 -0
  191. package/examples/behaviours/relentless_curiosity.yaml +38 -0
  192. package/examples/behaviours/systems_thinking.yaml +41 -0
  193. package/examples/capabilities/_index.yaml +8 -0
  194. package/examples/capabilities/business.yaml +251 -0
  195. package/examples/capabilities/delivery.yaml +352 -0
  196. package/examples/capabilities/people.yaml +100 -0
  197. package/examples/capabilities/reliability.yaml +318 -0
  198. package/examples/capabilities/scale.yaml +394 -0
  199. package/examples/disciplines/_index.yaml +5 -0
  200. package/examples/disciplines/data_engineering.yaml +76 -0
  201. package/examples/disciplines/software_engineering.yaml +76 -0
  202. package/examples/drivers.yaml +205 -0
  203. package/examples/framework.yaml +58 -0
  204. package/examples/grades.yaml +118 -0
  205. package/examples/questions/behaviours/outcome_ownership.yaml +52 -0
  206. package/examples/questions/behaviours/polymathic_knowledge.yaml +48 -0
  207. package/examples/questions/behaviours/precise_communication.yaml +55 -0
  208. package/examples/questions/behaviours/relentless_curiosity.yaml +51 -0
  209. package/examples/questions/behaviours/systems_thinking.yaml +53 -0
  210. package/examples/questions/skills/architecture_design.yaml +54 -0
  211. package/examples/questions/skills/cloud_platforms.yaml +48 -0
  212. package/examples/questions/skills/code_quality.yaml +49 -0
  213. package/examples/questions/skills/data_modeling.yaml +46 -0
  214. package/examples/questions/skills/devops.yaml +47 -0
  215. package/examples/questions/skills/full_stack_development.yaml +48 -0
  216. package/examples/questions/skills/sre_practices.yaml +44 -0
  217. package/examples/questions/skills/stakeholder_management.yaml +49 -0
  218. package/examples/questions/skills/team_collaboration.yaml +43 -0
  219. package/examples/questions/skills/technical_writing.yaml +43 -0
  220. package/examples/self-assessments.yaml +66 -0
  221. package/examples/stages.yaml +76 -0
  222. package/examples/tracks/_index.yaml +6 -0
  223. package/examples/tracks/manager.yaml +53 -0
  224. package/examples/tracks/platform.yaml +54 -0
  225. package/examples/tracks/sre.yaml +58 -0
  226. package/examples/vscode-settings.yaml +22 -0
  227. package/package.json +68 -0
@@ -0,0 +1,507 @@
1
+ /**
2
+ * Career Progression Functions
3
+ *
4
+ * This module provides pure functions for calculating skill and behaviour
5
+ * changes between job definitions, supporting both grade progression and
6
+ * track comparison scenarios.
7
+ */
8
+
9
+ import { getSkillLevelIndex, getBehaviourMaturityIndex } from "./levels.js";
10
+ import { deriveJob, isValidJobCombination } from "./derivation.js";
11
+
12
+ /**
13
+ * @typedef {Object} SkillChange
14
+ * @property {string} id - Skill ID
15
+ * @property {string} name - Skill name
16
+ * @property {string} capability - Skill capability
17
+ * @property {string} type - Skill type (primary/secondary/broad)
18
+ * @property {string|null} currentLevel - Current skill level (null if skill is gained)
19
+ * @property {string|null} targetLevel - Target skill level (null if skill is lost)
20
+ * @property {number} currentIndex - Current level index (0-4, or -1 if not present)
21
+ * @property {number} targetIndex - Target level index (0-4, or -1 if not present)
22
+ * @property {number} change - Difference between target and current index
23
+ * @property {string|null} currentDescription - Description at current level
24
+ * @property {string|null} targetDescription - Description at target level
25
+ * @property {boolean} [isGained] - True if skill is new in target (not in current)
26
+ * @property {boolean} [isLost] - True if skill is removed in target (not in target)
27
+ */
28
+
29
+ /**
30
+ * @typedef {Object} BehaviourChange
31
+ * @property {string} id - Behaviour ID
32
+ * @property {string} name - Behaviour name
33
+ * @property {string} currentLevel - Current maturity level
34
+ * @property {string} targetLevel - Target maturity level
35
+ * @property {number} currentIndex - Current level index (0-4)
36
+ * @property {number} targetIndex - Target level index (0-4)
37
+ * @property {number} change - Difference between target and current index
38
+
39
+ * @property {string} currentDescription - Description at current level
40
+ * @property {string} targetDescription - Description at target level
41
+ */
42
+
43
+ /**
44
+ * @typedef {Object} ProgressionAnalysis
45
+ * @property {Object} current - Current job definition
46
+ * @property {Object} target - Target job definition
47
+ * @property {SkillChange[]} skillChanges - All skill changes
48
+ * @property {BehaviourChange[]} behaviourChanges - All behaviour changes
49
+ * @property {Object} summary - Summary statistics
50
+ */
51
+
52
+ /**
53
+ * Calculate skill level changes between two skill matrices
54
+ * Handles cross-discipline comparisons by including gained and lost skills
55
+ * @param {Array} currentMatrix - Current skill matrix entries
56
+ * @param {Array} targetMatrix - Target skill matrix entries
57
+ * @returns {SkillChange[]} Array of skill changes, sorted by change magnitude
58
+ */
59
+ export function calculateSkillChanges(currentMatrix, targetMatrix) {
60
+ const changes = [];
61
+ const processedSkillIds = new Set();
62
+
63
+ // Process skills in current matrix
64
+ for (const current of currentMatrix) {
65
+ processedSkillIds.add(current.skillId);
66
+ const target = targetMatrix.find((t) => t.skillId === current.skillId);
67
+
68
+ if (target) {
69
+ // Skill exists in both - calculate level change
70
+ const currentIndex = getSkillLevelIndex(current.level);
71
+ const targetIndex = getSkillLevelIndex(target.level);
72
+ const change = targetIndex - currentIndex;
73
+
74
+ changes.push({
75
+ id: current.skillId,
76
+ name: current.skillName,
77
+ capability: current.capability,
78
+ type: current.type,
79
+ currentLevel: current.level,
80
+ targetLevel: target.level,
81
+ currentIndex,
82
+ targetIndex,
83
+ change,
84
+ currentDescription: current.levelDescription,
85
+ targetDescription: target.levelDescription,
86
+ });
87
+ } else {
88
+ // Skill is lost (in current but not in target)
89
+ const currentIndex = getSkillLevelIndex(current.level);
90
+ changes.push({
91
+ id: current.skillId,
92
+ name: current.skillName,
93
+ capability: current.capability,
94
+ type: current.type,
95
+ currentLevel: current.level,
96
+ targetLevel: null,
97
+ currentIndex,
98
+ targetIndex: -1,
99
+ change: -(currentIndex + 1), // Negative change representing loss
100
+ currentDescription: current.levelDescription,
101
+ targetDescription: null,
102
+ isLost: true,
103
+ });
104
+ }
105
+ }
106
+
107
+ // Process skills only in target matrix (gained skills)
108
+ for (const target of targetMatrix) {
109
+ if (!processedSkillIds.has(target.skillId)) {
110
+ const targetIndex = getSkillLevelIndex(target.level);
111
+ changes.push({
112
+ id: target.skillId,
113
+ name: target.skillName,
114
+ capability: target.capability,
115
+ type: target.type,
116
+ currentLevel: null,
117
+ targetLevel: target.level,
118
+ currentIndex: -1,
119
+ targetIndex,
120
+ change: targetIndex + 1, // Positive change representing gain
121
+ currentDescription: null,
122
+ targetDescription: target.levelDescription,
123
+ isGained: true,
124
+ });
125
+ }
126
+ }
127
+
128
+ // Sort by change (largest first), then by type, then by name
129
+ const typeOrder = { primary: 0, secondary: 1, broad: 2 };
130
+ changes.sort((a, b) => {
131
+ if (b.change !== a.change) return b.change - a.change;
132
+ if (typeOrder[a.type] !== typeOrder[b.type])
133
+ return typeOrder[a.type] - typeOrder[b.type];
134
+ return a.name.localeCompare(b.name);
135
+ });
136
+
137
+ return changes;
138
+ }
139
+
140
+ /**
141
+ * Calculate behaviour maturity changes between two profiles
142
+ * @param {Array} currentProfile - Current behaviour profile entries
143
+ * @param {Array} targetProfile - Target behaviour profile entries
144
+ * @returns {BehaviourChange[]} Array of behaviour changes, sorted by change magnitude
145
+ */
146
+ export function calculateBehaviourChanges(currentProfile, targetProfile) {
147
+ const changes = [];
148
+
149
+ for (const current of currentProfile) {
150
+ const target = targetProfile.find(
151
+ (t) => t.behaviourId === current.behaviourId,
152
+ );
153
+ if (target) {
154
+ const currentIndex = getBehaviourMaturityIndex(current.maturity);
155
+ const targetIndex = getBehaviourMaturityIndex(target.maturity);
156
+ const change = targetIndex - currentIndex;
157
+
158
+ changes.push({
159
+ id: current.behaviourId,
160
+ name: current.behaviourName,
161
+ currentLevel: current.maturity,
162
+ targetLevel: target.maturity,
163
+ currentIndex,
164
+ targetIndex,
165
+ change,
166
+ currentDescription: current.maturityDescription,
167
+ targetDescription: target.maturityDescription,
168
+ });
169
+ }
170
+ }
171
+
172
+ // Sort by change (largest first), then by name
173
+ changes.sort((a, b) => {
174
+ if (b.change !== a.change) return b.change - a.change;
175
+ return a.name.localeCompare(b.name);
176
+ });
177
+
178
+ return changes;
179
+ }
180
+
181
+ /**
182
+ * Analyze progression between two job definitions
183
+ * @param {Object} currentJob - Current job definition
184
+ * @param {Object} targetJob - Target job definition
185
+ * @returns {ProgressionAnalysis} Complete progression analysis
186
+ */
187
+ export function analyzeProgression(currentJob, targetJob) {
188
+ const skillChanges = calculateSkillChanges(
189
+ currentJob.skillMatrix,
190
+ targetJob.skillMatrix,
191
+ );
192
+ const behaviourChanges = calculateBehaviourChanges(
193
+ currentJob.behaviourProfile,
194
+ targetJob.behaviourProfile,
195
+ );
196
+
197
+ const skillsUp = skillChanges.filter(
198
+ (s) => s.change > 0 && !s.isGained,
199
+ ).length;
200
+ const skillsDown = skillChanges.filter(
201
+ (s) => s.change < 0 && !s.isLost,
202
+ ).length;
203
+ const skillsSame = skillChanges.filter((s) => s.change === 0).length;
204
+ const skillsGained = skillChanges.filter((s) => s.isGained).length;
205
+ const skillsLost = skillChanges.filter((s) => s.isLost).length;
206
+
207
+ const behavioursUp = behaviourChanges.filter((b) => b.change > 0).length;
208
+ const behavioursDown = behaviourChanges.filter((b) => b.change < 0).length;
209
+ const behavioursSame = behaviourChanges.filter((b) => b.change === 0).length;
210
+
211
+ return {
212
+ current: currentJob,
213
+ target: targetJob,
214
+ skillChanges,
215
+ behaviourChanges,
216
+ summary: {
217
+ skillsUp,
218
+ skillsDown,
219
+ skillsSame,
220
+ skillsGained,
221
+ skillsLost,
222
+ totalSkillChange: skillChanges.reduce((sum, s) => sum + s.change, 0),
223
+ behavioursUp,
224
+ behavioursDown,
225
+ behavioursSame,
226
+ totalBehaviourChange: behaviourChanges.reduce(
227
+ (sum, b) => sum + b.change,
228
+ 0,
229
+ ),
230
+ },
231
+ };
232
+ }
233
+
234
+ /**
235
+ * Analyze grade progression for a role
236
+ * @param {Object} params
237
+ * @param {Object} params.discipline - The discipline
238
+ * @param {Object} params.grade - Current grade
239
+ * @param {Object} params.track - The track
240
+ * @param {Object} params.nextGrade - Target grade (optional, will find next if not provided)
241
+ * @param {Array} params.grades - All grades (needed if nextGrade not provided)
242
+ * @param {Array} params.skills - All skills
243
+ * @param {Array} params.behaviours - All behaviours
244
+ * @returns {ProgressionAnalysis|null} Progression analysis or null if no next grade
245
+ */
246
+ export function analyzeGradeProgression({
247
+ discipline,
248
+ grade,
249
+ track,
250
+ nextGrade,
251
+ grades,
252
+ skills,
253
+ behaviours,
254
+ }) {
255
+ // Find next grade if not provided
256
+ let targetGrade = nextGrade;
257
+ if (!targetGrade && grades) {
258
+ const sortedGrades = [...grades].sort(
259
+ (a, b) => a.ordinalRank - b.ordinalRank,
260
+ );
261
+ const currentIndex = sortedGrades.findIndex((g) => g.id === grade.id);
262
+ targetGrade = sortedGrades[currentIndex + 1];
263
+ }
264
+
265
+ if (!targetGrade) {
266
+ return null;
267
+ }
268
+
269
+ // Create job definitions
270
+ const currentJob = deriveJob({
271
+ discipline,
272
+ grade,
273
+ track,
274
+ skills,
275
+ behaviours,
276
+ });
277
+
278
+ const targetJob = deriveJob({
279
+ discipline,
280
+ grade: targetGrade,
281
+ track,
282
+ skills,
283
+ behaviours,
284
+ });
285
+
286
+ if (!currentJob || !targetJob) {
287
+ return null;
288
+ }
289
+
290
+ return analyzeProgression(currentJob, targetJob);
291
+ }
292
+
293
+ /**
294
+ * Analyze track comparison at the same grade
295
+ * @param {Object} params
296
+ * @param {Object} params.discipline - The discipline
297
+ * @param {Object} params.grade - The grade
298
+ * @param {Object} params.currentTrack - Current track
299
+ * @param {Object} params.targetTrack - Target track to compare
300
+ * @param {Array} params.skills - All skills
301
+ * @param {Array} params.behaviours - All behaviours
302
+ * @param {Array} params.grades - All grades (for validation)
303
+ * @returns {ProgressionAnalysis|null} Progression analysis or null if invalid combination
304
+ */
305
+ export function analyzeTrackComparison({
306
+ discipline,
307
+ grade,
308
+ currentTrack,
309
+ targetTrack,
310
+ skills,
311
+ behaviours,
312
+ grades,
313
+ }) {
314
+ // Check if target track is valid for this discipline
315
+ if (
316
+ !isValidJobCombination({ discipline, grade, track: targetTrack, grades })
317
+ ) {
318
+ return null;
319
+ }
320
+
321
+ // Create job definitions
322
+ const currentJob = deriveJob({
323
+ discipline,
324
+ grade,
325
+ track: currentTrack,
326
+ skills,
327
+ behaviours,
328
+ });
329
+
330
+ const targetJob = deriveJob({
331
+ discipline,
332
+ grade,
333
+ track: targetTrack,
334
+ skills,
335
+ behaviours,
336
+ });
337
+
338
+ if (!currentJob || !targetJob) {
339
+ return null;
340
+ }
341
+
342
+ return analyzeProgression(currentJob, targetJob);
343
+ }
344
+
345
+ /**
346
+ * Get all valid tracks for comparison given a discipline and grade
347
+ * @param {Object} params
348
+ * @param {Object} params.discipline - The discipline
349
+ * @param {Object} params.grade - The grade
350
+ * @param {Object} params.currentTrack - Current track (will be excluded from results)
351
+ * @param {Array} params.tracks - All available tracks
352
+ * @param {Array} params.grades - All grades (for validation)
353
+ * @returns {Array} Valid tracks for comparison
354
+ */
355
+ export function getValidTracksForComparison({
356
+ discipline,
357
+ grade,
358
+ currentTrack,
359
+ tracks,
360
+ grades,
361
+ }) {
362
+ return tracks.filter(
363
+ (t) =>
364
+ t.id !== currentTrack.id &&
365
+ isValidJobCombination({ discipline, grade, track: t, grades }),
366
+ );
367
+ }
368
+
369
+ /**
370
+ * Get the next grade in the progression
371
+ * @param {Object} grade - Current grade
372
+ * @param {Array} grades - All grades
373
+ * @returns {Object|null} Next grade or null if at highest
374
+ */
375
+ export function getNextGrade(grade, grades) {
376
+ const sortedGrades = [...grades].sort(
377
+ (a, b) => a.ordinalRank - b.ordinalRank,
378
+ );
379
+ const currentIndex = sortedGrades.findIndex((g) => g.id === grade.id);
380
+ return sortedGrades[currentIndex + 1] || null;
381
+ }
382
+
383
+ /**
384
+ * Get the previous grade in the progression
385
+ * @param {Object} grade - Current grade
386
+ * @param {Array} grades - All grades
387
+ * @returns {Object|null} Previous grade or null if at lowest
388
+ */
389
+ export function getPreviousGrade(grade, grades) {
390
+ const sortedGrades = [...grades].sort(
391
+ (a, b) => a.ordinalRank - b.ordinalRank,
392
+ );
393
+ const currentIndex = sortedGrades.findIndex((g) => g.id === grade.id);
394
+ return currentIndex > 0 ? sortedGrades[currentIndex - 1] : null;
395
+ }
396
+
397
+ /**
398
+ * Analyze custom progression from current role to any target discipline × grade × track combination
399
+ * This is the main abstraction for comparing arbitrary role combinations.
400
+ *
401
+ * @param {Object} params
402
+ * @param {Object} params.discipline - Current discipline
403
+ * @param {Object} params.currentGrade - Current grade
404
+ * @param {Object} params.currentTrack - Current track
405
+ * @param {Object} [params.targetDiscipline] - Target discipline (defaults to current discipline)
406
+ * @param {Object} params.targetGrade - Target grade for comparison
407
+ * @param {Object} params.targetTrack - Target track for comparison
408
+ * @param {Array} params.skills - All skills
409
+ * @param {Array} params.behaviours - All behaviours
410
+ * @param {Array} params.grades - All grades (for validation)
411
+ * @returns {ProgressionAnalysis|null} Progression analysis or null if invalid combination
412
+ */
413
+ export function analyzeCustomProgression({
414
+ discipline,
415
+ currentGrade,
416
+ currentTrack,
417
+ targetDiscipline,
418
+ targetGrade,
419
+ targetTrack,
420
+ skills,
421
+ behaviours,
422
+ grades,
423
+ }) {
424
+ // Use current discipline if target not specified
425
+ const targetDisc = targetDiscipline || discipline;
426
+
427
+ // Validate target combination is valid
428
+ if (
429
+ !isValidJobCombination({
430
+ discipline: targetDisc,
431
+ grade: targetGrade,
432
+ track: targetTrack,
433
+ grades,
434
+ })
435
+ ) {
436
+ return null;
437
+ }
438
+
439
+ // Create current job definition
440
+ const currentJob = deriveJob({
441
+ discipline,
442
+ grade: currentGrade,
443
+ track: currentTrack,
444
+ skills,
445
+ behaviours,
446
+ });
447
+
448
+ // Create target job definition
449
+ const targetJob = deriveJob({
450
+ discipline: targetDisc,
451
+ grade: targetGrade,
452
+ track: targetTrack,
453
+ skills,
454
+ behaviours,
455
+ });
456
+
457
+ if (!currentJob || !targetJob) {
458
+ return null;
459
+ }
460
+
461
+ return analyzeProgression(currentJob, targetJob);
462
+ }
463
+
464
+ /**
465
+ * Get all valid grade × track combinations for a discipline
466
+ * Useful for populating dropdowns in the UI
467
+ *
468
+ * @param {Object} params
469
+ * @param {Object} params.discipline - The discipline
470
+ * @param {Array} params.grades - All grades
471
+ * @param {Array} params.tracks - All tracks
472
+ * @param {Object} [params.excludeGrade] - Optional grade to exclude
473
+ * @param {Object} [params.excludeTrack] - Optional track to exclude
474
+ * @returns {Array<{grade: Object, track: Object}>} Valid combinations
475
+ */
476
+ export function getValidGradeTrackCombinations({
477
+ discipline,
478
+ grades,
479
+ tracks,
480
+ excludeGrade,
481
+ excludeTrack,
482
+ }) {
483
+ const combinations = [];
484
+
485
+ for (const grade of grades) {
486
+ for (const track of tracks) {
487
+ // Skip if this is the excluded combination
488
+ if (excludeGrade?.id === grade.id && excludeTrack?.id === track.id) {
489
+ continue;
490
+ }
491
+
492
+ if (isValidJobCombination({ discipline, grade, track, grades })) {
493
+ combinations.push({ grade, track });
494
+ }
495
+ }
496
+ }
497
+
498
+ // Sort by grade level, then by track name
499
+ combinations.sort((a, b) => {
500
+ if (a.grade.ordinalRank !== b.grade.ordinalRank) {
501
+ return a.grade.ordinalRank - b.grade.ordinalRank;
502
+ }
503
+ return a.track.name.localeCompare(b.track.name);
504
+ });
505
+
506
+ return combinations;
507
+ }