@forwardimpact/pathway 0.25.15 → 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.
@@ -21,6 +21,213 @@ import { formatLevel } from "../lib/render.js";
21
21
  import { createLevelCell, createEmptyLevelCell } from "./detail.js";
22
22
  import { createBadge } from "./card.js";
23
23
 
24
+ /**
25
+ * Create a row for a gained (new) item
26
+ */
27
+ function createGainedRow(item, isSkill, maxLevels) {
28
+ const nameCell = td(
29
+ {},
30
+ a({ href: `#/${isSkill ? "skill" : "behaviour"}/${item.id}` }, item.name),
31
+ span({ className: "gained-badge" }, "NEW"),
32
+ );
33
+ const changeCell = td(
34
+ { className: "change-cell change-gained" },
35
+ span({ className: "change-indicator" }, "+"),
36
+ );
37
+ if (isSkill) {
38
+ return tr(
39
+ { className: "change-gained" },
40
+ nameCell,
41
+ td({}, createBadge(item.capability, item.capability)),
42
+ td({}, createBadge(formatLevel(item.type), item.type)),
43
+ createEmptyLevelCell(),
44
+ createLevelCell(item.targetIndex, maxLevels, item.targetLevel),
45
+ changeCell,
46
+ );
47
+ }
48
+ return tr(
49
+ { className: "change-gained" },
50
+ nameCell,
51
+ createEmptyLevelCell(),
52
+ createLevelCell(item.targetIndex, maxLevels, item.targetLevel),
53
+ changeCell,
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Create a row for a lost (removed) item
59
+ */
60
+ function createLostRow(item, isSkill, maxLevels) {
61
+ const nameCell = td(
62
+ {},
63
+ a({ href: `#/${isSkill ? "skill" : "behaviour"}/${item.id}` }, item.name),
64
+ span({ className: "lost-badge" }, "REMOVED"),
65
+ );
66
+ const changeCell = td(
67
+ { className: "change-cell change-lost" },
68
+ span({ className: "change-indicator" }, "−"),
69
+ );
70
+ if (isSkill) {
71
+ return tr(
72
+ { className: "change-lost" },
73
+ nameCell,
74
+ td({}, createBadge(item.capability, item.capability)),
75
+ td({}, createBadge(formatLevel(item.type), item.type)),
76
+ createLevelCell(item.currentIndex, maxLevels, item.currentLevel),
77
+ createEmptyLevelCell(),
78
+ changeCell,
79
+ );
80
+ }
81
+ return tr(
82
+ { className: "change-lost" },
83
+ nameCell,
84
+ createLevelCell(item.currentIndex, maxLevels, item.currentLevel),
85
+ createEmptyLevelCell(),
86
+ changeCell,
87
+ );
88
+ }
89
+
90
+ /**
91
+ * Create a row for a normal change item
92
+ */
93
+ function createChangeRow(item, isSkill, maxLevels) {
94
+ const changeClass =
95
+ item.change > 0
96
+ ? "change-up"
97
+ : item.change < 0
98
+ ? "change-down"
99
+ : "change-same";
100
+ const changeIcon = item.change > 0 ? "↑" : item.change < 0 ? "↓" : "—";
101
+ const changeText =
102
+ item.change !== 0 ? `${changeIcon} ${Math.abs(item.change)}` : "—";
103
+
104
+ if (isSkill) {
105
+ return tr(
106
+ { className: changeClass },
107
+ td({}, a({ href: `#/skill/${item.id}` }, item.name)),
108
+ td({}, createBadge(item.capability, item.capability)),
109
+ td({}, createBadge(formatLevel(item.type), item.type)),
110
+ createLevelCell(item.currentIndex, maxLevels, item.currentLevel),
111
+ createLevelCell(item.targetIndex, maxLevels, item.targetLevel),
112
+ td(
113
+ { className: `change-cell ${changeClass}` },
114
+ span({ className: "change-indicator" }, changeText),
115
+ ),
116
+ );
117
+ }
118
+ return tr(
119
+ { className: changeClass },
120
+ td({}, a({ href: `#/behaviour/${item.id}` }, item.name)),
121
+ createLevelCell(item.currentIndex, maxLevels, item.currentLevel),
122
+ createLevelCell(item.targetIndex, maxLevels, item.targetLevel),
123
+ td(
124
+ { className: `change-cell ${changeClass}` },
125
+ span({ className: "change-indicator" }, changeText),
126
+ ),
127
+ );
128
+ }
129
+
130
+ /**
131
+ * Pluralize a type name
132
+ * @param {string} type
133
+ * @param {number} count
134
+ * @returns {string}
135
+ */
136
+ function pluralize(type, count) {
137
+ return count > 1 ? `${type}s` : type;
138
+ }
139
+
140
+ /**
141
+ * Create a progression group with header and table
142
+ * @param {Object} params
143
+ * @param {Array} params.items
144
+ * @param {string} params.icon
145
+ * @param {string} params.label
146
+ * @param {string} [params.headerClass]
147
+ * @param {string} params.tableClass
148
+ * @param {HTMLElement} params.headers
149
+ * @param {Function} params.createRow
150
+ * @returns {HTMLElement}
151
+ */
152
+ function createProgressionGroup({
153
+ items,
154
+ icon,
155
+ label,
156
+ headerClass,
157
+ tableClass,
158
+ headers,
159
+ createRow,
160
+ }) {
161
+ return div(
162
+ { className: "progression-group" },
163
+ div(
164
+ { className: `progression-group-header ${headerClass || ""}`.trim() },
165
+ span({ className: "group-icon" }, icon),
166
+ span({}, label),
167
+ ),
168
+ div(
169
+ { className: "table-container" },
170
+ table(
171
+ { className: tableClass },
172
+ thead({}, headers),
173
+ tbody({}, ...items.map(createRow)),
174
+ ),
175
+ ),
176
+ );
177
+ }
178
+
179
+ /**
180
+ * Create a collapsible group for unchanged items
181
+ * @param {Object} params
182
+ * @param {Array} params.items
183
+ * @param {string} params.type
184
+ * @param {boolean} params.isSkill
185
+ * @param {string} params.tableClass
186
+ * @param {Function} params.createRow
187
+ * @returns {HTMLElement}
188
+ */
189
+ function createCollapsibleGroup({
190
+ items,
191
+ type,
192
+ isSkill,
193
+ tableClass,
194
+ createRow,
195
+ }) {
196
+ const noChangeHeaders = tr(
197
+ {},
198
+ th({}, isSkill ? "Skill" : "Behaviour"),
199
+ ...(isSkill ? [th({}, "Capability"), th({}, "Type")] : []),
200
+ th({}, "Current"),
201
+ th({}, "Target"),
202
+ th({}, "Change"),
203
+ );
204
+
205
+ const detailsId = `no-changes-${type}-${Date.now()}`;
206
+ return div(
207
+ { className: "progression-group no-change-group" },
208
+ createCollapsibleHeader(
209
+ detailsId,
210
+ `${items.length} ${pluralize(type, items.length)} unchanged`,
211
+ "✓",
212
+ ),
213
+ div(
214
+ {
215
+ className: "collapsible-content",
216
+ id: detailsId,
217
+ style: "display: none",
218
+ },
219
+ div(
220
+ { className: "table-container" },
221
+ table(
222
+ { className: tableClass },
223
+ thead({}, noChangeHeaders),
224
+ tbody({}, ...items.map(createRow)),
225
+ ),
226
+ ),
227
+ ),
228
+ );
229
+ }
230
+
24
231
  /**
25
232
  * Create a progression table showing changes
26
233
  * @param {SkillChangeItem[]|BehaviourChangeItem[]} changes - Array of change objects
@@ -44,116 +251,13 @@ export function createProgressionTable(changes, type = "skill") {
44
251
  const noChanges = changes.filter((c) => c.change === 0);
45
252
 
46
253
  const createRow = (item) => {
47
- // Handle gained skills (new in target role)
48
254
  if (item.isGained) {
49
- if (isSkill) {
50
- return tr(
51
- { className: "change-gained" },
52
- td(
53
- {},
54
- a({ href: `#/skill/${item.id}` }, item.name),
55
- span({ className: "gained-badge" }, "NEW"),
56
- ),
57
- td({}, createBadge(item.capability, item.capability)),
58
- td({}, createBadge(formatLevel(item.type), item.type)),
59
- createEmptyLevelCell(),
60
- createLevelCell(item.targetIndex, maxLevels, item.targetLevel),
61
- td(
62
- { className: "change-cell change-gained" },
63
- span({ className: "change-indicator" }, "+"),
64
- ),
65
- );
66
- } else {
67
- return tr(
68
- { className: "change-gained" },
69
- td(
70
- {},
71
- a({ href: `#/behaviour/${item.id}` }, item.name),
72
- span({ className: "gained-badge" }, "NEW"),
73
- ),
74
- createEmptyLevelCell(),
75
- createLevelCell(item.targetIndex, maxLevels, item.targetLevel),
76
- td(
77
- { className: "change-cell change-gained" },
78
- span({ className: "change-indicator" }, "+"),
79
- ),
80
- );
81
- }
255
+ return createGainedRow(item, isSkill, maxLevels);
82
256
  }
83
-
84
- // Handle lost skills (removed in target role)
85
257
  if (item.isLost) {
86
- if (isSkill) {
87
- return tr(
88
- { className: "change-lost" },
89
- td(
90
- {},
91
- a({ href: `#/skill/${item.id}` }, item.name),
92
- span({ className: "lost-badge" }, "REMOVED"),
93
- ),
94
- td({}, createBadge(item.capability, item.capability)),
95
- td({}, createBadge(formatLevel(item.type), item.type)),
96
- createLevelCell(item.currentIndex, maxLevels, item.currentLevel),
97
- createEmptyLevelCell(),
98
- td(
99
- { className: "change-cell change-lost" },
100
- span({ className: "change-indicator" }, "−"),
101
- ),
102
- );
103
- } else {
104
- return tr(
105
- { className: "change-lost" },
106
- td(
107
- {},
108
- a({ href: `#/behaviour/${item.id}` }, item.name),
109
- span({ className: "lost-badge" }, "REMOVED"),
110
- ),
111
- createLevelCell(item.currentIndex, maxLevels, item.currentLevel),
112
- createEmptyLevelCell(),
113
- td(
114
- { className: "change-cell change-lost" },
115
- span({ className: "change-indicator" }, "−"),
116
- ),
117
- );
118
- }
119
- }
120
-
121
- // Normal change (both current and target exist)
122
- const changeClass =
123
- item.change > 0
124
- ? "change-up"
125
- : item.change < 0
126
- ? "change-down"
127
- : "change-same";
128
- const changeIcon = item.change > 0 ? "↑" : item.change < 0 ? "↓" : "—";
129
- const changeText =
130
- item.change !== 0 ? `${changeIcon} ${Math.abs(item.change)}` : "—";
131
-
132
- if (isSkill) {
133
- return tr(
134
- { className: changeClass },
135
- td({}, a({ href: `#/skill/${item.id}` }, item.name)),
136
- td({}, createBadge(item.capability, item.capability)),
137
- td({}, createBadge(formatLevel(item.type), item.type)),
138
- createLevelCell(item.currentIndex, maxLevels, item.currentLevel),
139
- createLevelCell(item.targetIndex, maxLevels, item.targetLevel),
140
- td(
141
- { className: `change-cell ${changeClass}` },
142
- span({ className: "change-indicator" }, changeText),
143
- ),
144
- );
145
- } else {
146
- return tr(
147
- { className: changeClass },
148
- td({}, a({ href: `#/behaviour/${item.id}` }, item.name)),
149
- createLevelCell(item.currentIndex, maxLevels, item.currentLevel),
150
- createLevelCell(item.targetIndex, maxLevels, item.targetLevel),
151
- td(
152
- { className: `change-cell ${changeClass}` },
153
- span({ className: "change-indicator" }, changeText),
154
- ),
155
- );
258
+ return createLostRow(item, isSkill, maxLevels);
156
259
  }
260
+ return createChangeRow(item, isSkill, maxLevels);
157
261
  };
158
262
 
159
263
  const skillHeaders = tr(
@@ -165,127 +269,59 @@ export function createProgressionTable(changes, type = "skill") {
165
269
  th({}, "Change"),
166
270
  );
167
271
 
272
+ const tableClass = `table progression-table ${isSkill ? "skill-table" : "behaviour-table"}`;
168
273
  const content = [];
169
274
 
170
- // Show new skills first (in target role but not current)
171
275
  if (gained.length > 0) {
172
276
  content.push(
173
- div(
174
- { className: "progression-group" },
175
- div(
176
- { className: "progression-group-header gained-header" },
177
- span({ className: "group-icon" }, "➕"),
178
- span(
179
- {},
180
- `${gained.length} new ${type}${gained.length > 1 ? "s" : ""}`,
181
- ),
182
- ),
183
- div(
184
- { className: "table-container" },
185
- table(
186
- {
187
- className: `table progression-table ${isSkill ? "skill-table" : "behaviour-table"}`,
188
- },
189
- thead({}, skillHeaders),
190
- tbody({}, ...gained.map(createRow)),
191
- ),
192
- ),
193
- ),
277
+ createProgressionGroup({
278
+ items: gained,
279
+ icon: "➕",
280
+ label: `${gained.length} new ${pluralize(type, gained.length)}`,
281
+ headerClass: "gained-header",
282
+ tableClass,
283
+ headers: skillHeaders,
284
+ createRow,
285
+ }),
194
286
  );
195
287
  }
196
288
 
197
- // Show level changes
198
289
  if (changesRequired.length > 0) {
199
290
  content.push(
200
- div(
201
- { className: "progression-group" },
202
- div(
203
- { className: "progression-group-header" },
204
- span({ className: "group-icon" }, "📈"),
205
- span(
206
- {},
207
- `${changesRequired.length} ${type}${changesRequired.length > 1 ? "s" : ""} need growth`,
208
- ),
209
- ),
210
- div(
211
- { className: "table-container" },
212
- table(
213
- {
214
- className: `table progression-table ${isSkill ? "skill-table" : "behaviour-table"}`,
215
- },
216
- thead({}, skillHeaders),
217
- tbody({}, ...changesRequired.map(createRow)),
218
- ),
219
- ),
220
- ),
291
+ createProgressionGroup({
292
+ items: changesRequired,
293
+ icon: "📈",
294
+ label: `${changesRequired.length} ${pluralize(type, changesRequired.length)} need growth`,
295
+ tableClass,
296
+ headers: skillHeaders,
297
+ createRow,
298
+ }),
221
299
  );
222
300
  }
223
301
 
224
- // Show removed skills (in current role but not target)
225
302
  if (lost.length > 0) {
226
303
  content.push(
227
- div(
228
- { className: "progression-group" },
229
- div(
230
- { className: "progression-group-header lost-header" },
231
- span({ className: "group-icon" }, "➖"),
232
- span(
233
- {},
234
- `${lost.length} ${type}${lost.length > 1 ? "s" : ""} removed`,
235
- ),
236
- ),
237
- div(
238
- { className: "table-container" },
239
- table(
240
- {
241
- className: `table progression-table ${isSkill ? "skill-table" : "behaviour-table"}`,
242
- },
243
- thead({}, skillHeaders),
244
- tbody({}, ...lost.map(createRow)),
245
- ),
246
- ),
247
- ),
304
+ createProgressionGroup({
305
+ items: lost,
306
+ icon: "➖",
307
+ label: `${lost.length} ${pluralize(type, lost.length)} removed`,
308
+ headerClass: "lost-header",
309
+ tableClass,
310
+ headers: skillHeaders,
311
+ createRow,
312
+ }),
248
313
  );
249
314
  }
250
315
 
251
- // Show no changes (collapsed by default)
252
316
  if (noChanges.length > 0) {
253
- const noChangeHeaders = tr(
254
- {},
255
- th({}, isSkill ? "Skill" : "Behaviour"),
256
- ...(isSkill ? [th({}, "Capability"), th({}, "Type")] : []),
257
- th({}, "Current"),
258
- th({}, "Target"),
259
- th({}, "Change"),
260
- );
261
-
262
- const detailsId = `no-changes-${type}-${Date.now()}`;
263
317
  content.push(
264
- div(
265
- { className: "progression-group no-change-group" },
266
- createCollapsibleHeader(
267
- detailsId,
268
- `${noChanges.length} ${type}${noChanges.length > 1 ? "s" : ""} unchanged`,
269
- "✓",
270
- ),
271
- div(
272
- {
273
- className: "collapsible-content",
274
- id: detailsId,
275
- style: "display: none",
276
- },
277
- div(
278
- { className: "table-container" },
279
- table(
280
- {
281
- className: `table progression-table ${isSkill ? "skill-table" : "behaviour-table"}`,
282
- },
283
- thead({}, noChangeHeaders),
284
- tbody({}, ...noChanges.map(createRow)),
285
- ),
286
- ),
287
- ),
288
- ),
318
+ createCollapsibleGroup({
319
+ items: noChanges,
320
+ type,
321
+ isSkill,
322
+ tableClass,
323
+ createRow,
324
+ }),
289
325
  );
290
326
  }
291
327
 
@@ -5,6 +5,91 @@
5
5
  import { formatLevel } from "../../lib/render.js";
6
6
  import { getConceptEmoji } from "@forwardimpact/map/levels";
7
7
 
8
+ /**
9
+ * Append follow-ups to lines
10
+ * @param {string[]} lines
11
+ * @param {Object} q - Question object
12
+ */
13
+ function appendFollowUps(lines, q) {
14
+ if (q.followUps.length > 0) {
15
+ lines.push("", "**Follow-ups:**");
16
+ for (const followUp of q.followUps) {
17
+ lines.push(` → ${followUp}`);
18
+ }
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Append looking-for items to lines
24
+ * @param {string[]} lines
25
+ * @param {Object} q - Question object
26
+ */
27
+ function appendLookingFor(lines, q) {
28
+ if (q.lookingFor && q.lookingFor.length > 0) {
29
+ lines.push("", "**What to look for:**");
30
+ for (const item of q.lookingFor) {
31
+ lines.push(`- ${item}`);
32
+ }
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Format skill question sections
38
+ * @param {string[]} lines
39
+ * @param {Array} sections
40
+ * @param {string} emoji
41
+ */
42
+ function formatSkillSections(lines, sections, emoji) {
43
+ if (sections.length === 0) return;
44
+ lines.push(`## ${emoji} Skill Questions`, "");
45
+ for (const section of sections) {
46
+ lines.push(`### ${section.name} (${formatLevel(section.level)})`, "");
47
+ for (const q of section.questions) {
48
+ lines.push(`**Q**: ${q.question}`);
49
+ appendFollowUps(lines, q);
50
+ appendLookingFor(lines, q);
51
+ lines.push("");
52
+ }
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Format scenario-based question sections (capability or behaviour)
58
+ * @param {string[]} lines
59
+ * @param {Array} sections
60
+ * @param {string} heading
61
+ * @param {string} promptsKey - Key for guided prompts
62
+ * @param {string} promptsLabel - Display label for prompts
63
+ */
64
+ function formatScenarioSections(
65
+ lines,
66
+ sections,
67
+ heading,
68
+ promptsKey,
69
+ promptsLabel,
70
+ ) {
71
+ if (sections.length === 0) return;
72
+ lines.push(`## ${heading}`, "");
73
+ for (const section of sections) {
74
+ lines.push(`### ${section.name} (${formatLevel(section.level)})`, "");
75
+ for (const q of section.questions) {
76
+ lines.push(`**Scenario**: ${q.question}`);
77
+ if (q.context) {
78
+ lines.push(`> ${q.context}`);
79
+ }
80
+ if (q[promptsKey] && q[promptsKey].length > 0) {
81
+ lines.push("", `**${promptsLabel}:**`);
82
+ for (const prompt of q[promptsKey]) {
83
+ lines.push(`- ${prompt}`);
84
+ }
85
+ }
86
+ appendFollowUps(lines, q);
87
+ appendLookingFor(lines, q);
88
+ lines.push("");
89
+ }
90
+ }
91
+ }
92
+
8
93
  /**
9
94
  * Format interview detail as markdown
10
95
  * @param {Object} view - Interview detail view from presenter
@@ -24,102 +109,29 @@ export function interviewToMarkdown(view, { framework } = {}) {
24
109
  "",
25
110
  ];
26
111
 
27
- // Group sections by type
28
112
  const skillSections = view.sections.filter((s) => s.type === "skill");
29
113
  const behaviourSections = view.sections.filter((s) => s.type === "behaviour");
30
114
  const capabilitySections = view.sections.filter(
31
115
  (s) => s.type === "capability",
32
116
  );
33
117
 
34
- // Skill questions
35
- if (skillSections.length > 0) {
36
- lines.push(`## ${skillEmoji} Skill Questions`, "");
37
- for (const section of skillSections) {
38
- lines.push(`### ${section.name} (${formatLevel(section.level)})`, "");
39
- for (const q of section.questions) {
40
- lines.push(`**Q**: ${q.question}`);
41
- if (q.followUps.length > 0) {
42
- lines.push("", "**Follow-ups:**");
43
- for (const followUp of q.followUps) {
44
- lines.push(` → ${followUp}`);
45
- }
46
- }
47
- if (q.lookingFor && q.lookingFor.length > 0) {
48
- lines.push("", "**What to look for:**");
49
- for (const item of q.lookingFor) {
50
- lines.push(`- ${item}`);
51
- }
52
- }
53
- lines.push("");
54
- }
55
- }
56
- }
118
+ formatSkillSections(lines, skillSections, skillEmoji);
57
119
 
58
- // Capability decomposition questions
59
- if (capabilitySections.length > 0) {
60
- lines.push(`## 🧩 Decomposition Questions`, "");
61
- for (const section of capabilitySections) {
62
- lines.push(`### ${section.name} (${formatLevel(section.level)})`, "");
63
- for (const q of section.questions) {
64
- lines.push(`**Scenario**: ${q.question}`);
65
- if (q.context) {
66
- lines.push(`> ${q.context}`);
67
- }
68
- if (q.decompositionPrompts && q.decompositionPrompts.length > 0) {
69
- lines.push("", "**Guide the candidate through:**");
70
- for (const prompt of q.decompositionPrompts) {
71
- lines.push(`- ${prompt}`);
72
- }
73
- }
74
- if (q.followUps.length > 0) {
75
- lines.push("", "**Follow-ups:**");
76
- for (const followUp of q.followUps) {
77
- lines.push(` → ${followUp}`);
78
- }
79
- }
80
- if (q.lookingFor && q.lookingFor.length > 0) {
81
- lines.push("", "**What to look for:**");
82
- for (const item of q.lookingFor) {
83
- lines.push(`- ${item}`);
84
- }
85
- }
86
- lines.push("");
87
- }
88
- }
89
- }
120
+ formatScenarioSections(
121
+ lines,
122
+ capabilitySections,
123
+ "🧩 Decomposition Questions",
124
+ "decompositionPrompts",
125
+ "Guide the candidate through",
126
+ );
90
127
 
91
- // Behaviour stakeholder simulation questions
92
- if (behaviourSections.length > 0) {
93
- lines.push(`## ${behaviourEmoji} Stakeholder Simulation`, "");
94
- for (const section of behaviourSections) {
95
- lines.push(`### ${section.name} (${formatLevel(section.level)})`, "");
96
- for (const q of section.questions) {
97
- lines.push(`**Scenario**: ${q.question}`);
98
- if (q.context) {
99
- lines.push(`> ${q.context}`);
100
- }
101
- if (q.simulationPrompts && q.simulationPrompts.length > 0) {
102
- lines.push("", "**Steer the simulation:**");
103
- for (const prompt of q.simulationPrompts) {
104
- lines.push(`- ${prompt}`);
105
- }
106
- }
107
- if (q.followUps.length > 0) {
108
- lines.push("", "**Follow-ups:**");
109
- for (const followUp of q.followUps) {
110
- lines.push(` → ${followUp}`);
111
- }
112
- }
113
- if (q.lookingFor && q.lookingFor.length > 0) {
114
- lines.push("", "**What to look for:**");
115
- for (const item of q.lookingFor) {
116
- lines.push(`- ${item}`);
117
- }
118
- }
119
- lines.push("");
120
- }
121
- }
122
- }
128
+ formatScenarioSections(
129
+ lines,
130
+ behaviourSections,
131
+ `${behaviourEmoji} Stakeholder Simulation`,
132
+ "simulationPrompts",
133
+ "Steer the simulation",
134
+ );
123
135
 
124
136
  return lines.join("\n");
125
137
  }