@forwardimpact/model 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.
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Job Cache
3
+ *
4
+ * Centralized caching for generated job definitions.
5
+ * Provides consistent key generation and get-or-create pattern.
6
+ */
7
+
8
+ import { deriveJob } from "./derivation.js";
9
+
10
+ /** @type {Map<string, Object>} */
11
+ const cache = new Map();
12
+
13
+ /**
14
+ * Create a consistent cache key from job parameters
15
+ * @param {string} disciplineId
16
+ * @param {string} gradeId
17
+ * @param {string} [trackId] - Optional track ID
18
+ * @returns {string}
19
+ */
20
+ export function makeJobKey(disciplineId, gradeId, trackId = null) {
21
+ if (trackId) {
22
+ return `${disciplineId}_${gradeId}_${trackId}`;
23
+ }
24
+ return `${disciplineId}_${gradeId}`;
25
+ }
26
+
27
+ /**
28
+ * Get or create a cached job definition
29
+ * @param {Object} params
30
+ * @param {Object} params.discipline
31
+ * @param {Object} params.grade
32
+ * @param {Object} [params.track] - Optional track
33
+ * @param {Array} params.skills
34
+ * @param {Array} params.behaviours
35
+ * @param {Array} [params.capabilities]
36
+ * @returns {Object|null}
37
+ */
38
+ export function getOrCreateJob({
39
+ discipline,
40
+ grade,
41
+ track = null,
42
+ skills,
43
+ behaviours,
44
+ capabilities,
45
+ }) {
46
+ const key = makeJobKey(discipline.id, grade.id, track?.id);
47
+
48
+ if (!cache.has(key)) {
49
+ const job = deriveJob({
50
+ discipline,
51
+ grade,
52
+ track,
53
+ skills,
54
+ behaviours,
55
+ capabilities,
56
+ });
57
+ if (job) {
58
+ cache.set(key, job);
59
+ }
60
+ return job;
61
+ }
62
+
63
+ return cache.get(key);
64
+ }
65
+
66
+ /**
67
+ * Clear all cached jobs
68
+ */
69
+ export function clearJobCache() {
70
+ cache.clear();
71
+ }
72
+
73
+ /**
74
+ * Invalidate a specific job from the cache
75
+ * @param {string} disciplineId
76
+ * @param {string} gradeId
77
+ * @param {string} [trackId] - Optional track ID
78
+ */
79
+ export function invalidateJob(disciplineId, gradeId, trackId = null) {
80
+ cache.delete(makeJobKey(disciplineId, gradeId, trackId));
81
+ }
82
+
83
+ /**
84
+ * Get the number of cached jobs (for testing/debugging)
85
+ * @returns {number}
86
+ */
87
+ export function getCacheSize() {
88
+ return cache.size;
89
+ }
package/lib/job.js ADDED
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Job Model
3
+ *
4
+ * Pure functions for preparing job data for display.
5
+ * Parallels model/agent.js in structure.
6
+ */
7
+
8
+ import {
9
+ calculateDriverCoverage,
10
+ generateJobTitle,
11
+ isValidJobCombination,
12
+ getDisciplineSkillIds,
13
+ } from "./derivation.js";
14
+ import { deriveChecklist } from "./checklist.js";
15
+ import { getOrCreateJob } from "./job-cache.js";
16
+
17
+ /**
18
+ * @typedef {Object} JobDetailView
19
+ * @property {string} title
20
+ * @property {string} disciplineId
21
+ * @property {string} disciplineName
22
+ * @property {string} gradeId
23
+ * @property {string} gradeName
24
+ * @property {string} trackId
25
+ * @property {string} trackName
26
+ * @property {Object} expectations
27
+ * @property {Array} skillMatrix
28
+ * @property {Array} behaviourProfile
29
+ * @property {Array} derivedResponsibilities
30
+ * @property {Array} driverCoverage
31
+ * @property {Object} checklists - Handoff checklists keyed by handoff type
32
+ */
33
+
34
+ /**
35
+ * Prepare a job for detail view
36
+ * @param {Object} params
37
+ * @param {Object} params.discipline
38
+ * @param {Object} params.grade
39
+ * @param {Object} params.track
40
+ * @param {Array} params.skills
41
+ * @param {Array} params.behaviours
42
+ * @param {Array} params.drivers
43
+ * @param {Array} [params.capabilities]
44
+ * @returns {JobDetailView|null}
45
+ */
46
+ export function prepareJobDetail({
47
+ discipline,
48
+ grade,
49
+ track,
50
+ skills,
51
+ behaviours,
52
+ drivers,
53
+ capabilities,
54
+ }) {
55
+ // Track is optional (null = generalist)
56
+ if (!discipline || !grade) return null;
57
+
58
+ const job = getOrCreateJob({
59
+ discipline,
60
+ grade,
61
+ track,
62
+ skills,
63
+ behaviours,
64
+ capabilities,
65
+ });
66
+
67
+ if (!job) return null;
68
+
69
+ const driverCoverage = calculateDriverCoverage({
70
+ job,
71
+ drivers,
72
+ });
73
+
74
+ // Derive checklists for each stage
75
+ const checklists = {};
76
+ if (capabilities) {
77
+ const stageIds = ["plan", "code"];
78
+ for (const stageId of stageIds) {
79
+ checklists[stageId] = deriveChecklist({
80
+ stageId,
81
+ skillMatrix: job.skillMatrix,
82
+ skills,
83
+ capabilities,
84
+ });
85
+ }
86
+ }
87
+
88
+ return {
89
+ title: job.title,
90
+ disciplineId: discipline.id,
91
+ disciplineName: discipline.specialization || discipline.name,
92
+ gradeId: grade.id,
93
+ gradeName: grade.professionalTitle || grade.id,
94
+ trackId: track?.id || null,
95
+ trackName: track?.name || null,
96
+ expectations: job.expectations || {},
97
+ // Raw model data for components that need the original shape
98
+ skillMatrix: job.skillMatrix,
99
+ behaviourProfile: job.behaviourProfile,
100
+ derivedResponsibilities: job.derivedResponsibilities || [],
101
+ // Transformed driver coverage for display
102
+ driverCoverage: driverCoverage.map((d) => ({
103
+ id: d.driverId,
104
+ name: d.driverName,
105
+ coverage: d.overallScore,
106
+ skillsCovered: d.coveredSkills?.length || 0,
107
+ skillsTotal:
108
+ (d.coveredSkills?.length || 0) + (d.missingSkills?.length || 0),
109
+ behavioursCovered: d.coveredBehaviours?.length || 0,
110
+ behavioursTotal:
111
+ (d.coveredBehaviours?.length || 0) + (d.missingBehaviours?.length || 0),
112
+ })),
113
+ // Derived checklists by handoff type
114
+ checklists,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Prepare a job for list view (summary only)
120
+ * @param {Object} params
121
+ * @param {Object} params.discipline
122
+ * @param {Object} params.grade
123
+ * @param {Object} params.track
124
+ * @param {Array} params.skills
125
+ * @param {Array} params.behaviours
126
+ * @returns {Object|null}
127
+ */
128
+ export function prepareJobSummary({
129
+ discipline,
130
+ grade,
131
+ track,
132
+ skills,
133
+ behaviours,
134
+ }) {
135
+ if (!discipline || !grade) return null;
136
+
137
+ const job = getOrCreateJob({
138
+ discipline,
139
+ grade,
140
+ track,
141
+ skills,
142
+ behaviours,
143
+ });
144
+
145
+ if (!job) return null;
146
+
147
+ return {
148
+ title: job.title,
149
+ disciplineId: discipline.id,
150
+ disciplineName: discipline.specialization || discipline.name,
151
+ gradeId: grade.id,
152
+ trackId: track?.id || null,
153
+ trackName: track?.name || null,
154
+ skillCount: job.skillMatrix.length,
155
+ behaviourCount: job.behaviourProfile.length,
156
+ primarySkillCount: job.skillMatrix.filter((s) => s.type === "primary")
157
+ .length,
158
+ };
159
+ }
160
+
161
+ /**
162
+ * @typedef {Object} JobBuilderPreview
163
+ * @property {boolean} isValid
164
+ * @property {string|null} title
165
+ * @property {number} totalSkills
166
+ * @property {number} totalBehaviours
167
+ * @property {string|null} invalidReason
168
+ */
169
+
170
+ /**
171
+ * Prepare job builder preview for form validation
172
+ * @param {Object} params
173
+ * @param {Object|null} params.discipline
174
+ * @param {Object|null} params.grade
175
+ * @param {Object|null} params.track
176
+ * @param {number} params.behaviourCount - Total behaviours in the system
177
+ * @param {Array} [params.grades] - All grades for validation
178
+ * @returns {JobBuilderPreview}
179
+ */
180
+ export function prepareJobBuilderPreview({
181
+ discipline,
182
+ grade,
183
+ track,
184
+ behaviourCount,
185
+ grades,
186
+ }) {
187
+ // Track is optional (null = generalist)
188
+ if (!discipline || !grade) {
189
+ return {
190
+ isValid: false,
191
+ title: null,
192
+ totalSkills: 0,
193
+ totalBehaviours: 0,
194
+ invalidReason: null,
195
+ };
196
+ }
197
+
198
+ const validCombination = isValidJobCombination({
199
+ discipline,
200
+ grade,
201
+ track,
202
+ grades,
203
+ });
204
+
205
+ if (!validCombination) {
206
+ const reason = track
207
+ ? `The ${track.name} track is not available for ${discipline.specialization}.`
208
+ : `${discipline.specialization} requires a track specialization.`;
209
+ return {
210
+ isValid: false,
211
+ title: null,
212
+ totalSkills: 0,
213
+ totalBehaviours: 0,
214
+ invalidReason: reason,
215
+ };
216
+ }
217
+
218
+ const title = generateJobTitle(discipline, grade, track);
219
+ const totalSkills = getDisciplineSkillIds(discipline).length;
220
+
221
+ return {
222
+ isValid: true,
223
+ title,
224
+ totalSkills,
225
+ totalBehaviours: behaviourCount,
226
+ invalidReason: null,
227
+ };
228
+ }