@forwardimpact/pathway 0.25.12 → 0.25.20

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.
@@ -7,11 +7,6 @@ import { render, div, h1, h2, p, a, label, section } from "../lib/render.js";
7
7
  import { getState } from "../lib/state.js";
8
8
  import { createBackLink } from "../components/nav.js";
9
9
  import { createStatCard } from "../components/card.js";
10
- import {
11
- createComparisonSkillRadar,
12
- createComparisonBehaviourRadar,
13
- } from "../components/comparison-radar.js";
14
- import { createProgressionTable } from "../components/progression-table.js";
15
10
  import { renderError } from "../components/error-page.js";
16
11
  import {
17
12
  createSelectWithValue,
@@ -23,6 +18,7 @@ import {
23
18
  getDefaultTargetLevel,
24
19
  isValidCombination,
25
20
  } from "../formatters/progress/shared.js";
21
+ import { buildComparisonResult } from "./progress-comparison.js";
26
22
 
27
23
  /**
28
24
  * Render career progress detail page
@@ -223,10 +219,8 @@ function createComparisonSelectorsSection({
223
219
  * Update the comparison results based on current selections
224
220
  */
225
221
  function updateComparison() {
226
- // Clear previous results
227
222
  comparisonResultsContainer.innerHTML = "";
228
223
 
229
- // Track can be empty string for generalist, but discipline and level are required
230
224
  if (!selectedDisciplineId || !selectedLevelId) {
231
225
  comparisonResultsContainer.appendChild(
232
226
  div(
@@ -244,16 +238,12 @@ function createComparisonSelectorsSection({
244
238
  (d) => d.id === selectedDisciplineId,
245
239
  );
246
240
  const targetLevel = data.levels.find((g) => g.id === selectedLevelId);
247
- // selectedTrackId can be empty string for generalist
248
241
  const targetTrack = selectedTrackId
249
242
  ? data.tracks.find((t) => t.id === selectedTrackId)
250
243
  : null;
251
244
 
252
- if (!targetDiscipline || !targetLevel) {
253
- return;
254
- }
245
+ if (!targetDiscipline || !targetLevel) return;
255
246
 
256
- // Check if comparing to same role
257
247
  if (
258
248
  targetDiscipline.id === discipline.id &&
259
249
  targetLevel.id === currentLevel.id &&
@@ -271,7 +261,6 @@ function createComparisonSelectorsSection({
271
261
  return;
272
262
  }
273
263
 
274
- // Use formatter shared module to analyze the progression
275
264
  const progressionView = prepareCustomProgression({
276
265
  discipline,
277
266
  currentLevel,
@@ -293,102 +282,17 @@ function createComparisonSelectorsSection({
293
282
  return;
294
283
  }
295
284
 
296
- const { skillChanges, behaviourChanges, summary, target } = progressionView;
297
-
298
- // Build flat comparison result sections
299
- const result = div(
300
- { className: "comparison-result" },
301
-
302
- // Summary stats
303
- div(
304
- { className: "grid grid-6" },
305
- summary.skillsGained > 0
306
- ? createStatCard({ value: summary.skillsGained, label: "New Skills" })
307
- : null,
308
- createStatCard({
309
- value: summary.skillsUp,
310
- label: "Skills to Grow",
311
- }),
312
- summary.skillsDown > 0
313
- ? createStatCard({
314
- value: summary.skillsDown,
315
- label: "Skills Decrease",
316
- })
317
- : null,
318
- summary.skillsLost > 0
319
- ? createStatCard({
320
- value: summary.skillsLost,
321
- label: "Skills Removed",
322
- })
323
- : null,
324
- createStatCard({
325
- value: summary.behavioursUp,
326
- label: "Behaviours to Mature",
327
- }),
328
- summary.behavioursDown > 0
329
- ? createStatCard({
330
- value: summary.behavioursDown,
331
- label: "Behaviours Decrease",
332
- })
333
- : null,
334
- ),
335
-
336
- // Comparison radars
337
- div(
338
- { className: "section auto-grid-lg" },
339
- createComparisonSkillRadar(
340
- currentJobView.skillMatrix,
341
- target.skillMatrix,
342
- {
343
- title: "Skills Comparison",
344
- currentLabel: `Current (${currentLevel.id})`,
345
- targetLabel: `Target (${targetLevel.id})`,
346
- size: 400,
347
- capabilities: data.capabilities,
348
- },
349
- ),
350
- createComparisonBehaviourRadar(
351
- currentJobView.behaviourProfile,
352
- target.behaviourProfile,
353
- {
354
- title: "Behaviours Comparison",
355
- currentLabel: `Current (${currentLevel.id})`,
356
- targetLabel: `Target (${targetLevel.id})`,
357
- size: 400,
358
- },
359
- ),
360
- ),
361
-
362
- // Skill changes section
363
- section(
364
- { className: "section section-detail" },
365
- h2({ className: "section-title" }, "Skill Changes"),
366
- createProgressionTable(skillChanges, "skill"),
367
- ),
368
-
369
- // Behaviour changes section
370
- section(
371
- { className: "section section-detail" },
372
- h2({ className: "section-title" }, "Behaviour Changes"),
373
- createProgressionTable(behaviourChanges, "behaviour"),
374
- ),
375
-
376
- // Link to target job
377
- div(
378
- { className: "page-actions" },
379
- a(
380
- {
381
- href: targetTrack
382
- ? `#/job/${targetDiscipline.id}/${targetLevel.id}/${targetTrack.id}`
383
- : `#/job/${targetDiscipline.id}/${targetLevel.id}`,
384
- className: "btn btn-secondary",
385
- },
386
- `View ${targetLevel.id}${targetTrack ? ` ${targetTrack.name}` : ""} Job Definition →`,
387
- ),
285
+ comparisonResultsContainer.appendChild(
286
+ buildComparisonResult(
287
+ progressionView,
288
+ currentJobView,
289
+ currentLevel,
290
+ targetLevel,
291
+ targetTrack,
292
+ targetDiscipline,
293
+ data,
388
294
  ),
389
295
  );
390
-
391
- comparisonResultsContainer.appendChild(result);
392
296
  }
393
297
 
394
298
  // Get initial available options
@@ -453,7 +357,6 @@ function createComparisonSelectorsSection({
453
357
  trackSelectEl.appendChild(opt);
454
358
  }
455
359
 
456
- // Try to keep current selection if valid
457
360
  const hasValidTrack = availableOptions.tracks.find(
458
361
  (t) => t.id === selectedTrackId,
459
362
  );
@@ -461,9 +364,6 @@ function createComparisonSelectorsSection({
461
364
  selectedTrackId === "" && availableOptions.allowsTrackless;
462
365
  if (hasValidTrack || isValidGeneralist) {
463
366
  trackSelectEl.value = selectedTrackId;
464
- } else if (availableOptions.allowsTrackless) {
465
- selectedTrackId = "";
466
- trackSelectEl.value = "";
467
367
  } else {
468
368
  selectedTrackId = "";
469
369
  trackSelectEl.value = "";
@@ -0,0 +1,494 @@
1
+ /**
2
+ * Self-assessment wizard step renderers
3
+ */
4
+
5
+ import { div, h2, h3, h4, p, span, button, a } from "../lib/render.js";
6
+ import { createBadge } from "../components/card.js";
7
+ import { createDisciplineSelect } from "../lib/form-controls.js";
8
+ import {
9
+ SKILL_PROFICIENCY_ORDER,
10
+ BEHAVIOUR_MATURITY_ORDER,
11
+ getConceptEmoji,
12
+ } from "@forwardimpact/map/levels";
13
+ import { formatLevel } from "../lib/render.js";
14
+
15
+ /**
16
+ * Create a tip card
17
+ * @param {string} icon
18
+ * @param {string} title
19
+ * @param {string} text
20
+ * @returns {HTMLElement}
21
+ */
22
+ function createTipCard(icon, title, text) {
23
+ return div(
24
+ { className: "tip-card" },
25
+ span({ className: "tip-icon" }, icon),
26
+ h4({}, title),
27
+ p({}, text),
28
+ );
29
+ }
30
+
31
+ /**
32
+ * Create a level selection button
33
+ * @param {Object} item - Skill or behaviour
34
+ * @param {string} level - Level value
35
+ * @param {number} index - Level index
36
+ * @param {string} type - 'skill' or 'behaviour'
37
+ * @param {Object} assessmentState - Assessment state ref
38
+ * @param {Function} rerender - Function to trigger re-render
39
+ * @returns {HTMLElement}
40
+ */
41
+ export function createLevelButton(
42
+ item,
43
+ level,
44
+ index,
45
+ type,
46
+ assessmentState,
47
+ rerender,
48
+ ) {
49
+ const stateKey = type === "skill" ? "skills" : "behaviours";
50
+ const currentLevel = assessmentState[stateKey][item.id];
51
+ const isSelected = currentLevel === level;
52
+ const proficiencyDescriptions =
53
+ type === "skill" ? item.proficiencyDescriptions : item.maturityDescriptions;
54
+ const description = proficiencyDescriptions?.[level] || "";
55
+
56
+ return button(
57
+ {
58
+ className: `level-btn level-${index + 1} ${isSelected ? "selected" : ""}`,
59
+ title: `${formatLevel(level)}: ${description}`,
60
+ onClick: () => {
61
+ assessmentState[stateKey][item.id] = level;
62
+ rerender();
63
+ },
64
+ },
65
+ span({ className: "level-btn-number" }, String(index + 1)),
66
+ span({ className: "level-btn-name" }, formatLevel(level)),
67
+ );
68
+ }
69
+
70
+ /**
71
+ * Render introduction step
72
+ * @param {Object} data - App data
73
+ * @param {Object} assessmentState - Assessment state
74
+ * @returns {HTMLElement}
75
+ */
76
+ export function renderIntroStep(data, assessmentState) {
77
+ return div(
78
+ { className: "assessment-step assessment-intro" },
79
+ div(
80
+ { className: "intro-card" },
81
+ h2({}, "Welcome to the Self-Assessment"),
82
+ p(
83
+ {},
84
+ "This assessment helps you understand your current skill proficiencies and behaviours, " +
85
+ "then matches you with suitable roles in the organization.",
86
+ ),
87
+
88
+ div(
89
+ { className: "intro-info" },
90
+ div(
91
+ { className: "info-item" },
92
+ span(
93
+ { className: "info-icon" },
94
+ getConceptEmoji(data.framework, "skill"),
95
+ ),
96
+ div(
97
+ {},
98
+ h4({}, `${data.skills.length} Skills`),
99
+ p({}, "Across " + data.capabilities.length + " capabilities"),
100
+ ),
101
+ ),
102
+ div(
103
+ { className: "info-item" },
104
+ span(
105
+ { className: "info-icon" },
106
+ getConceptEmoji(data.framework, "behaviour"),
107
+ ),
108
+ div(
109
+ {},
110
+ h4({}, `${data.behaviours.length} Behaviours`),
111
+ p({}, "Key mindsets and ways of working"),
112
+ ),
113
+ ),
114
+ div(
115
+ { className: "info-item" },
116
+ span({ className: "info-icon" }, "⏱️"),
117
+ div({}, h4({}, "10-15 Minutes"), p({}, "Complete at your own pace")),
118
+ ),
119
+ ),
120
+
121
+ div(
122
+ { className: "discipline-filter" },
123
+ h3({}, "Optional: Focus on a Discipline"),
124
+ p(
125
+ { className: "text-muted" },
126
+ "Select a discipline to highlight which skills are most relevant for that role. " +
127
+ "You can still assess all skills.",
128
+ ),
129
+ createDisciplineSelect({
130
+ id: "discipline-filter-select",
131
+ disciplines: data.disciplines,
132
+ initialValue: assessmentState.discipline || "",
133
+ placeholder: "Select discipline",
134
+ onChange: (value) => {
135
+ assessmentState.discipline = value || null;
136
+ },
137
+ getDisplayName: (d) => d.specialization,
138
+ }),
139
+ ),
140
+
141
+ div(
142
+ { className: "intro-tips" },
143
+ h3({}, "Tips for Accurate Self-Assessment"),
144
+ div(
145
+ { className: "auto-grid-sm" },
146
+ createTipCard(
147
+ "🎯",
148
+ "Be Honest",
149
+ "Rate yourself where you genuinely are, not where you aspire to be.",
150
+ ),
151
+ createTipCard(
152
+ "📚",
153
+ "Read Descriptions",
154
+ "Hover over levels to see detailed descriptions for each.",
155
+ ),
156
+ createTipCard(
157
+ "⏭️",
158
+ "Skip if Unsure",
159
+ "You can leave items unrated and come back later.",
160
+ ),
161
+ createTipCard(
162
+ "💾",
163
+ "Auto-Saved",
164
+ "Your progress is kept while you navigate between steps.",
165
+ ),
166
+ ),
167
+ ),
168
+ ),
169
+ );
170
+ }
171
+
172
+ /**
173
+ * Create a skill assessment item
174
+ * @param {Object} skill - Skill data
175
+ * @param {string|null} relevance - Skill relevance for selected discipline
176
+ * @param {Object} assessmentState
177
+ * @param {Function} rerender
178
+ * @returns {HTMLElement}
179
+ */
180
+ function createSkillAssessmentItem(
181
+ skill,
182
+ relevance,
183
+ assessmentState,
184
+ rerender,
185
+ ) {
186
+ const currentLevel = assessmentState.skills[skill.id];
187
+
188
+ return div(
189
+ {
190
+ className: `assessment-item ${currentLevel ? "assessed" : ""} ${relevance ? `relevance-${relevance}` : ""}`,
191
+ },
192
+ div(
193
+ { className: "assessment-item-header" },
194
+ div(
195
+ { className: "assessment-item-title" },
196
+ a({ href: `#/skill/${skill.id}` }, skill.name),
197
+ relevance && createBadge(relevance, relevance),
198
+ ),
199
+ currentLevel &&
200
+ span({ className: "current-level-badge" }, formatLevel(currentLevel)),
201
+ ),
202
+
203
+ p({ className: "assessment-item-description" }, skill.description),
204
+
205
+ div(
206
+ { className: "level-selector" },
207
+ ...SKILL_PROFICIENCY_ORDER.map((level, index) =>
208
+ createLevelButton(
209
+ skill,
210
+ level,
211
+ index,
212
+ "skill",
213
+ assessmentState,
214
+ rerender,
215
+ ),
216
+ ),
217
+ button(
218
+ {
219
+ className: "level-clear-btn",
220
+ title: "Clear selection",
221
+ onClick: () => {
222
+ delete assessmentState.skills[skill.id];
223
+ rerender();
224
+ },
225
+ },
226
+ "✕",
227
+ ),
228
+ ),
229
+ );
230
+ }
231
+
232
+ /**
233
+ * Render skills assessment step
234
+ * @param {Object} step - Step configuration with capability and items
235
+ * @param {Object} data - App data
236
+ * @param {Object} assessmentState
237
+ * @param {Function} rerender
238
+ * @param {Function} formatCapability
239
+ * @returns {HTMLElement}
240
+ */
241
+ export function renderSkillsStep(
242
+ step,
243
+ data,
244
+ assessmentState,
245
+ rerender,
246
+ formatCapability,
247
+ ) {
248
+ const { capability, items } = step;
249
+ const selectedDiscipline = assessmentState.discipline
250
+ ? data.disciplines.find((d) => d.id === assessmentState.discipline)
251
+ : null;
252
+
253
+ const getSkillRelevance = (skill) => {
254
+ if (!selectedDiscipline) return null;
255
+ if (selectedDiscipline.coreSkills?.includes(skill.id)) return "primary";
256
+ if (selectedDiscipline.supportingSkills?.includes(skill.id))
257
+ return "secondary";
258
+ if (selectedDiscipline.broadSkills?.includes(skill.id)) return "broad";
259
+ return null;
260
+ };
261
+
262
+ const sortedItems = [...items].sort((a, b) => {
263
+ const relevanceA = getSkillRelevance(a);
264
+ const relevanceB = getSkillRelevance(b);
265
+ const order = { primary: 0, secondary: 1, broad: 2 };
266
+
267
+ if (relevanceA && !relevanceB) return -1;
268
+ if (!relevanceA && relevanceB) return 1;
269
+ if (relevanceA && relevanceB) {
270
+ return (order[relevanceA] ?? 3) - (order[relevanceB] ?? 3);
271
+ }
272
+ return a.name.localeCompare(b.name);
273
+ });
274
+
275
+ const assessedCount = items.filter(
276
+ (item) => assessmentState.skills[item.id],
277
+ ).length;
278
+
279
+ return div(
280
+ { className: "assessment-step" },
281
+ div(
282
+ { className: "step-header" },
283
+ h2(
284
+ {},
285
+ span({ className: "step-header-icon" }, step.icon),
286
+ ` ${formatCapability(capability, data.capabilities)} Skills`,
287
+ ),
288
+ span(
289
+ { className: "step-progress" },
290
+ `${assessedCount}/${items.length} rated`,
291
+ ),
292
+ ),
293
+
294
+ selectedDiscipline &&
295
+ div(
296
+ { className: "discipline-context" },
297
+ span({}, `Showing relevance for: `),
298
+ span(
299
+ { className: "discipline-name" },
300
+ selectedDiscipline.specialization,
301
+ ),
302
+ ),
303
+
304
+ div(
305
+ { className: "assessment-items" },
306
+ ...sortedItems.map((skill) =>
307
+ createSkillAssessmentItem(
308
+ skill,
309
+ getSkillRelevance(skill),
310
+ assessmentState,
311
+ rerender,
312
+ ),
313
+ ),
314
+ ),
315
+ );
316
+ }
317
+
318
+ /**
319
+ * Create a behaviour assessment item
320
+ * @param {Object} behaviour - Behaviour data
321
+ * @param {Object} assessmentState
322
+ * @param {Function} rerender
323
+ * @returns {HTMLElement}
324
+ */
325
+ function createBehaviourAssessmentItem(behaviour, assessmentState, rerender) {
326
+ const currentLevel = assessmentState.behaviours[behaviour.id];
327
+
328
+ return div(
329
+ { className: `assessment-item ${currentLevel ? "assessed" : ""}` },
330
+ div(
331
+ { className: "assessment-item-header" },
332
+ div(
333
+ { className: "assessment-item-title" },
334
+ a({ href: `#/behaviour/${behaviour.id}` }, behaviour.name),
335
+ ),
336
+ currentLevel &&
337
+ span({ className: "current-level-badge" }, formatLevel(currentLevel)),
338
+ ),
339
+
340
+ p({ className: "assessment-item-description" }, behaviour.description),
341
+
342
+ div(
343
+ { className: "level-selector" },
344
+ ...BEHAVIOUR_MATURITY_ORDER.map((level, index) =>
345
+ createLevelButton(
346
+ behaviour,
347
+ level,
348
+ index,
349
+ "behaviour",
350
+ assessmentState,
351
+ rerender,
352
+ ),
353
+ ),
354
+ button(
355
+ {
356
+ className: "level-clear-btn",
357
+ title: "Clear selection",
358
+ onClick: () => {
359
+ delete assessmentState.behaviours[behaviour.id];
360
+ rerender();
361
+ },
362
+ },
363
+ "✕",
364
+ ),
365
+ ),
366
+ );
367
+ }
368
+
369
+ /**
370
+ * Render behaviours assessment step
371
+ * @param {Object} step - Step configuration
372
+ * @param {Object} data - App data
373
+ * @param {Object} assessmentState
374
+ * @param {Function} rerender
375
+ * @returns {HTMLElement}
376
+ */
377
+ export function renderBehavioursStep(step, data, assessmentState, rerender) {
378
+ const { items } = step;
379
+ const assessedCount = items.filter(
380
+ (item) => assessmentState.behaviours[item.id],
381
+ ).length;
382
+
383
+ return div(
384
+ { className: "assessment-step" },
385
+ div(
386
+ { className: "step-header" },
387
+ h2(
388
+ {},
389
+ span(
390
+ { className: "step-header-icon" },
391
+ getConceptEmoji(data.framework, "behaviour"),
392
+ ),
393
+ " Behaviours",
394
+ ),
395
+ span(
396
+ { className: "step-progress" },
397
+ `${assessedCount}/${items.length} rated`,
398
+ ),
399
+ ),
400
+
401
+ p(
402
+ { className: "step-intro" },
403
+ "Behaviours describe how you approach work—your mindsets and ways of working. " +
404
+ "These are equally important as technical skills.",
405
+ ),
406
+
407
+ div(
408
+ { className: "assessment-items" },
409
+ ...items.map((behaviour) =>
410
+ createBehaviourAssessmentItem(behaviour, assessmentState, rerender),
411
+ ),
412
+ ),
413
+ );
414
+ }
415
+
416
+ /**
417
+ * Render results preview before navigating to full results
418
+ * @param {Object} data - App data
419
+ * @param {Object} assessmentState
420
+ * @param {Function} calculateProgress
421
+ * @returns {HTMLElement}
422
+ */
423
+ export function renderResultsPreview(data, assessmentState, calculateProgress) {
424
+ const progress = calculateProgress(data);
425
+ const skillCount = Object.keys(assessmentState.skills).length;
426
+ const behaviourCount = Object.keys(assessmentState.behaviours).length;
427
+
428
+ if (progress < 20) {
429
+ return div(
430
+ { className: "assessment-step results-preview" },
431
+ div(
432
+ { className: "results-incomplete" },
433
+ h2({}, "Complete More of the Assessment"),
434
+ p(
435
+ {},
436
+ "Please complete at least 20% of the assessment to see job matches. " +
437
+ `You've currently assessed ${skillCount} skills and ${behaviourCount} behaviours.`,
438
+ ),
439
+ div(
440
+ { className: "progress-summary" },
441
+ div(
442
+ { className: "progress-bar large" },
443
+ div({
444
+ className: "progress-bar-fill",
445
+ style: `width: ${progress}%`,
446
+ }),
447
+ ),
448
+ span({}, `${progress}% complete`),
449
+ ),
450
+ ),
451
+ );
452
+ }
453
+
454
+ return div(
455
+ { className: "assessment-step results-preview" },
456
+ div(
457
+ { className: "results-ready" },
458
+ h2({}, "🎉 Assessment Complete!"),
459
+ p({}, "Great work! You're ready to see your job matches."),
460
+
461
+ div(
462
+ { className: "results-summary" },
463
+ div(
464
+ { className: "summary-stat" },
465
+ span({ className: "summary-value" }, String(skillCount)),
466
+ span({ className: "summary-label" }, "Skills Assessed"),
467
+ ),
468
+ div(
469
+ { className: "summary-stat" },
470
+ span({ className: "summary-value" }, String(behaviourCount)),
471
+ span({ className: "summary-label" }, "Behaviours Assessed"),
472
+ ),
473
+ div(
474
+ { className: "summary-stat" },
475
+ span({ className: "summary-value" }, `${progress}%`),
476
+ span({ className: "summary-label" }, "Complete"),
477
+ ),
478
+ ),
479
+
480
+ div(
481
+ { className: "results-actions" },
482
+ button(
483
+ {
484
+ className: "btn btn-primary btn-lg",
485
+ onClick: () => {
486
+ window.location.hash = "/self-assessment/results";
487
+ },
488
+ },
489
+ "View My Job Matches →",
490
+ ),
491
+ ),
492
+ ),
493
+ );
494
+ }