@lssm/example.learning-journey-registry 0.0.0-canary-20251212004227
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/.turbo/turbo-build.log +35 -0
- package/CHANGELOG.md +13 -0
- package/README.md +22 -0
- package/dist/api-types.js +0 -0
- package/dist/api.js +1 -0
- package/dist/docs/index.js +1 -0
- package/dist/docs/learning-journey-registry.docblock.js +1 -0
- package/dist/index.js +1 -0
- package/dist/libs/contracts/src/docs/PUBLISHING.docblock.js +76 -0
- package/dist/libs/contracts/src/docs/accessibility_wcag_compliance_specs.docblock.js +350 -0
- package/dist/libs/contracts/src/docs/index.js +1 -0
- package/dist/libs/contracts/src/docs/presentations.js +1 -0
- package/dist/libs/contracts/src/docs/registry.js +1 -0
- package/dist/libs/contracts/src/docs/tech/PHASE_1_QUICKSTART.docblock.js +383 -0
- package/dist/libs/contracts/src/docs/tech/PHASE_2_AI_NATIVE_OPERATIONS.docblock.js +68 -0
- package/dist/libs/contracts/src/docs/tech/PHASE_3_AUTO_EVOLUTION.docblock.js +140 -0
- package/dist/libs/contracts/src/docs/tech/PHASE_4_PERSONALIZATION_ENGINE.docblock.js +86 -0
- package/dist/libs/contracts/src/docs/tech/PHASE_5_ZERO_TOUCH_OPERATIONS.docblock.js +1 -0
- package/dist/libs/contracts/src/docs/tech/lifecycle-stage-system.docblock.js +213 -0
- package/dist/libs/contracts/src/docs/tech/mcp-endpoints.docblock.js +1 -0
- package/dist/libs/contracts/src/docs/tech/presentation-runtime.docblock.js +1 -0
- package/dist/libs/contracts/src/docs/tech/schema/README.docblock.js +262 -0
- package/dist/libs/contracts/src/docs/tech/templates/runtime.docblock.js +1 -0
- package/dist/libs/contracts/src/docs/tech/workflows/overview.docblock.js +1 -0
- package/dist/presentations/index.js +1 -0
- package/dist/progress-store.js +1 -0
- package/dist/tracks.js +1 -0
- package/package.json +59 -0
- package/src/api-types.ts +43 -0
- package/src/api.test.ts +46 -0
- package/src/api.ts +301 -0
- package/src/docs/index.ts +1 -0
- package/src/docs/learning-journey-registry.docblock.ts +36 -0
- package/src/index.ts +5 -0
- package/src/presentations/index.ts +61 -0
- package/src/progress-store.ts +39 -0
- package/src/tracks.ts +91 -0
- package/tsconfig.json +9 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/tsdown.config.js +7 -0
package/src/api.test.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { recordEvent, getProgress, listTracks } from './api';
|
|
4
|
+
import { learningJourneyTracks } from './tracks';
|
|
5
|
+
|
|
6
|
+
const learnerId = 'learner-1';
|
|
7
|
+
|
|
8
|
+
describe('learning journey registry api', () => {
|
|
9
|
+
it('completes studio track and applies streak bonus', () => {
|
|
10
|
+
const track = learningJourneyTracks.find(
|
|
11
|
+
(t) => t.id === 'studio_getting_started'
|
|
12
|
+
);
|
|
13
|
+
expect(track).toBeDefined();
|
|
14
|
+
|
|
15
|
+
const events = [
|
|
16
|
+
{ name: 'studio.template.instantiated' },
|
|
17
|
+
{ name: 'spec.changed', payload: { scope: 'sandbox' } },
|
|
18
|
+
{ name: 'regeneration.completed' },
|
|
19
|
+
{ name: 'playground.session.started' },
|
|
20
|
+
{ name: 'studio.evolution.applied' },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
events.forEach((evt) =>
|
|
24
|
+
recordEvent({
|
|
25
|
+
...evt,
|
|
26
|
+
learnerId,
|
|
27
|
+
occurredAt: new Date(),
|
|
28
|
+
})
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const progress = getProgress('studio_getting_started', learnerId);
|
|
32
|
+
expect(progress).toBeDefined();
|
|
33
|
+
if (!progress) return;
|
|
34
|
+
|
|
35
|
+
expect(progress.isCompleted).toBeTrue();
|
|
36
|
+
expect(progress.progress).toBe(100);
|
|
37
|
+
// base xp: track totalXp (110) + completion bonus (25) + streak bonus (25)
|
|
38
|
+
expect(progress.xpEarned).toBeGreaterThanOrEqual(160);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('lists tracks with empty progress for new learner', () => {
|
|
42
|
+
const result = listTracks('new-learner');
|
|
43
|
+
expect(result.tracks.length).toBeGreaterThan(0);
|
|
44
|
+
expect(result.progress.length).toBe(0);
|
|
45
|
+
});
|
|
46
|
+
});
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { learningJourneyTracks } from './tracks';
|
|
2
|
+
import type {
|
|
3
|
+
LearningJourneyTrackSpec,
|
|
4
|
+
StepAvailabilitySpec,
|
|
5
|
+
StepCompletionConditionSpec,
|
|
6
|
+
} from '@lssm/module.learning-journey/track-spec';
|
|
7
|
+
import type { LearningEvent, StepProgress, TrackProgress } from './api-types';
|
|
8
|
+
import {
|
|
9
|
+
getLearnerTracks,
|
|
10
|
+
getTrackResolver,
|
|
11
|
+
initProgress,
|
|
12
|
+
} from './progress-store';
|
|
13
|
+
|
|
14
|
+
const getTrack = getTrackResolver(learningJourneyTracks);
|
|
15
|
+
|
|
16
|
+
const matchesFilter = (
|
|
17
|
+
filter: Record<string, unknown> | undefined,
|
|
18
|
+
payload: Record<string, unknown> | undefined
|
|
19
|
+
): boolean => {
|
|
20
|
+
if (!filter) return true;
|
|
21
|
+
if (!payload) return false;
|
|
22
|
+
return Object.entries(filter).every(([key, value]) => payload[key] === value);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const matchesBaseEvent = (
|
|
26
|
+
condition: {
|
|
27
|
+
eventName: string;
|
|
28
|
+
eventVersion?: number;
|
|
29
|
+
sourceModule?: string;
|
|
30
|
+
payloadFilter?: Record<string, unknown>;
|
|
31
|
+
},
|
|
32
|
+
event: LearningEvent
|
|
33
|
+
): boolean => {
|
|
34
|
+
if (condition.eventName !== event.name) return false;
|
|
35
|
+
if (condition.eventVersion !== undefined && event.version !== undefined) {
|
|
36
|
+
if (condition.eventVersion !== event.version) return false;
|
|
37
|
+
}
|
|
38
|
+
if (
|
|
39
|
+
condition.sourceModule &&
|
|
40
|
+
event.sourceModule &&
|
|
41
|
+
condition.sourceModule !== event.sourceModule
|
|
42
|
+
) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
return matchesFilter(
|
|
46
|
+
condition.payloadFilter,
|
|
47
|
+
event.payload as Record<string, unknown> | undefined
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const matchesCondition = (
|
|
52
|
+
condition: StepCompletionConditionSpec,
|
|
53
|
+
event: LearningEvent,
|
|
54
|
+
step: StepProgress,
|
|
55
|
+
trackStartedAt: Date | undefined
|
|
56
|
+
): {
|
|
57
|
+
matched: boolean;
|
|
58
|
+
occurrences?: number;
|
|
59
|
+
masteryCount?: number;
|
|
60
|
+
} => {
|
|
61
|
+
if (condition.kind === 'count') {
|
|
62
|
+
if (!matchesBaseEvent(condition, event)) return { matched: false };
|
|
63
|
+
const occurrences = (step.occurrences ?? 0) + 1;
|
|
64
|
+
const within =
|
|
65
|
+
condition.withinHours === undefined ||
|
|
66
|
+
Boolean(
|
|
67
|
+
trackStartedAt &&
|
|
68
|
+
event.occurredAt &&
|
|
69
|
+
(event.occurredAt.getTime() - trackStartedAt.getTime()) /
|
|
70
|
+
(1000 * 60 * 60) <=
|
|
71
|
+
condition.withinHours
|
|
72
|
+
);
|
|
73
|
+
return { matched: within && occurrences >= condition.atLeast, occurrences };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (condition.kind === 'time_window') {
|
|
77
|
+
if (!matchesBaseEvent(condition, event)) return { matched: false };
|
|
78
|
+
if (
|
|
79
|
+
condition.withinHoursOfStart !== undefined &&
|
|
80
|
+
trackStartedAt &&
|
|
81
|
+
event.occurredAt
|
|
82
|
+
) {
|
|
83
|
+
const hoursSinceStart =
|
|
84
|
+
(event.occurredAt.getTime() - trackStartedAt.getTime()) /
|
|
85
|
+
(1000 * 60 * 60);
|
|
86
|
+
if (hoursSinceStart > condition.withinHoursOfStart) {
|
|
87
|
+
return { matched: false };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { matched: true };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (condition.kind === 'srs_mastery') {
|
|
94
|
+
if (event.name !== condition.eventName) return { matched: false };
|
|
95
|
+
const payload = event.payload as Record<string, unknown> | undefined;
|
|
96
|
+
if (!matchesFilter(condition.payloadFilter, payload)) {
|
|
97
|
+
return { matched: false };
|
|
98
|
+
}
|
|
99
|
+
const skillKey = condition.skillIdField ?? 'skillId';
|
|
100
|
+
const masteryKey = condition.masteryField ?? 'mastery';
|
|
101
|
+
const skillId = payload?.[skillKey];
|
|
102
|
+
const masteryValue = payload?.[masteryKey];
|
|
103
|
+
if (skillId === undefined || masteryValue === undefined) {
|
|
104
|
+
return { matched: false };
|
|
105
|
+
}
|
|
106
|
+
if (typeof masteryValue !== 'number') return { matched: false };
|
|
107
|
+
if (masteryValue < condition.minimumMastery) return { matched: false };
|
|
108
|
+
const masteryCount = (step.masteryCount ?? 0) + 1;
|
|
109
|
+
const required = condition.requiredCount ?? 1;
|
|
110
|
+
return { matched: masteryCount >= required, masteryCount };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { matched: matchesBaseEvent(condition, event) };
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const getAvailability = (
|
|
117
|
+
availability: StepAvailabilitySpec | undefined,
|
|
118
|
+
startedAt: Date | undefined
|
|
119
|
+
): { availableAt?: Date; dueAt?: Date } => {
|
|
120
|
+
if (!availability || !startedAt) return {};
|
|
121
|
+
|
|
122
|
+
const baseTime = startedAt.getTime();
|
|
123
|
+
let unlockTime = baseTime;
|
|
124
|
+
|
|
125
|
+
if (availability.unlockOnDay !== undefined) {
|
|
126
|
+
unlockTime =
|
|
127
|
+
baseTime + (availability.unlockOnDay - 1) * 24 * 60 * 60 * 1000;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (availability.unlockAfterHours !== undefined) {
|
|
131
|
+
unlockTime = baseTime + availability.unlockAfterHours * 60 * 60 * 1000;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const availableAt = new Date(unlockTime);
|
|
135
|
+
const dueAt =
|
|
136
|
+
availability.dueWithinHours !== undefined
|
|
137
|
+
? new Date(
|
|
138
|
+
availableAt.getTime() + availability.dueWithinHours * 60 * 60 * 1000
|
|
139
|
+
)
|
|
140
|
+
: undefined;
|
|
141
|
+
|
|
142
|
+
return { availableAt, dueAt };
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const computeProgressPercent = (steps: StepProgress[]): number => {
|
|
146
|
+
const total = steps.length || 1;
|
|
147
|
+
const done = steps.filter((s) => s.status === 'COMPLETED').length;
|
|
148
|
+
return Math.round((done / total) * 100);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const applyTrackCompletionBonuses = (
|
|
152
|
+
track: LearningJourneyTrackSpec,
|
|
153
|
+
progress: TrackProgress
|
|
154
|
+
) => {
|
|
155
|
+
if (progress.isCompleted) return progress;
|
|
156
|
+
|
|
157
|
+
const completedAt = new Date();
|
|
158
|
+
const startedAt = progress.startedAt ?? completedAt;
|
|
159
|
+
const hoursElapsed =
|
|
160
|
+
(completedAt.getTime() - startedAt.getTime()) / (1000 * 60 * 60);
|
|
161
|
+
|
|
162
|
+
let xpEarned = progress.xpEarned;
|
|
163
|
+
const { completionRewards, streakRule } = track;
|
|
164
|
+
|
|
165
|
+
if (completionRewards?.xpBonus) {
|
|
166
|
+
xpEarned += completionRewards.xpBonus;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (
|
|
170
|
+
streakRule?.hoursWindow !== undefined &&
|
|
171
|
+
hoursElapsed <= streakRule.hoursWindow &&
|
|
172
|
+
streakRule.bonusXp
|
|
173
|
+
) {
|
|
174
|
+
xpEarned += streakRule.bonusXp;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
...progress,
|
|
179
|
+
xpEarned,
|
|
180
|
+
isCompleted: true,
|
|
181
|
+
completedAt,
|
|
182
|
+
lastActivityAt: completedAt,
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
export const listTracks = (learnerId?: string) => {
|
|
187
|
+
const progressMap = learnerId ? getLearnerTracks(learnerId) : undefined;
|
|
188
|
+
const progress =
|
|
189
|
+
learnerId && progressMap
|
|
190
|
+
? Array.from(progressMap.values())
|
|
191
|
+
: ([] as TrackProgress[]);
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
tracks: learningJourneyTracks,
|
|
195
|
+
progress,
|
|
196
|
+
};
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export const getProgress = (trackId: string, learnerId: string) => {
|
|
200
|
+
const track = getTrack(trackId);
|
|
201
|
+
if (!track) return undefined;
|
|
202
|
+
|
|
203
|
+
const map = getLearnerTracks(learnerId);
|
|
204
|
+
const existing = map.get(trackId) ?? initProgress(learnerId, track);
|
|
205
|
+
map.set(trackId, existing);
|
|
206
|
+
return existing;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export const recordEvent = (event: LearningEvent) => {
|
|
210
|
+
const targets =
|
|
211
|
+
event.trackId !== undefined
|
|
212
|
+
? learningJourneyTracks.filter((t) => t.id === event.trackId)
|
|
213
|
+
: learningJourneyTracks;
|
|
214
|
+
|
|
215
|
+
const updated: TrackProgress[] = [];
|
|
216
|
+
const eventTime = event.occurredAt ?? new Date();
|
|
217
|
+
|
|
218
|
+
for (const track of targets) {
|
|
219
|
+
const map = getLearnerTracks(event.learnerId);
|
|
220
|
+
const current = map.get(track.id) ?? initProgress(event.learnerId, track);
|
|
221
|
+
const startedAt = current.startedAt ?? eventTime;
|
|
222
|
+
|
|
223
|
+
let changed = current.startedAt === undefined;
|
|
224
|
+
const steps: StepProgress[] = current.steps.map((step) => {
|
|
225
|
+
if (step.status === 'COMPLETED') return step;
|
|
226
|
+
|
|
227
|
+
const spec = track.steps.find((s) => s.id === step.id);
|
|
228
|
+
if (!spec) return step;
|
|
229
|
+
|
|
230
|
+
const { availableAt, dueAt } = getAvailability(
|
|
231
|
+
spec.availability,
|
|
232
|
+
startedAt
|
|
233
|
+
);
|
|
234
|
+
if (availableAt && eventTime < availableAt) {
|
|
235
|
+
return { ...step, availableAt, dueAt };
|
|
236
|
+
}
|
|
237
|
+
if (dueAt && eventTime > dueAt) {
|
|
238
|
+
// keep pending but note deadlines
|
|
239
|
+
return { ...step, availableAt, dueAt };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const result = matchesCondition(spec.completion, event, step, startedAt);
|
|
243
|
+
|
|
244
|
+
if (result.matched) {
|
|
245
|
+
changed = true;
|
|
246
|
+
return {
|
|
247
|
+
...step,
|
|
248
|
+
status: 'COMPLETED',
|
|
249
|
+
xpEarned: spec.xpReward ?? 0,
|
|
250
|
+
completedAt: eventTime,
|
|
251
|
+
triggeringEvent: event.name,
|
|
252
|
+
eventPayload: event.payload,
|
|
253
|
+
occurrences: result.occurrences ?? step.occurrences,
|
|
254
|
+
masteryCount: result.masteryCount ?? step.masteryCount,
|
|
255
|
+
availableAt,
|
|
256
|
+
dueAt,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (
|
|
261
|
+
result.occurrences !== undefined ||
|
|
262
|
+
result.masteryCount !== undefined
|
|
263
|
+
) {
|
|
264
|
+
changed = true;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
...step,
|
|
269
|
+
occurrences: result.occurrences ?? step.occurrences,
|
|
270
|
+
masteryCount: result.masteryCount ?? step.masteryCount,
|
|
271
|
+
availableAt,
|
|
272
|
+
dueAt,
|
|
273
|
+
};
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
if (!changed) {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const xpEarned =
|
|
281
|
+
steps.reduce((sum, s) => sum + s.xpEarned, 0) + (track.totalXp ?? 0);
|
|
282
|
+
let progress: TrackProgress = {
|
|
283
|
+
...current,
|
|
284
|
+
steps,
|
|
285
|
+
xpEarned,
|
|
286
|
+
startedAt,
|
|
287
|
+
lastActivityAt: eventTime,
|
|
288
|
+
progress: computeProgressPercent(steps),
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const allDone = steps.every((s) => s.status === 'COMPLETED');
|
|
292
|
+
if (allDone) {
|
|
293
|
+
progress = applyTrackCompletionBonuses(track, progress);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
map.set(track.id, progress);
|
|
297
|
+
updated.push(progress);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return updated;
|
|
301
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './learning-journey-registry.docblock';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { DocBlock } from '@lssm/lib.contracts/docs';
|
|
2
|
+
import { registerDocBlocks } from '@lssm/lib.contracts/docs';
|
|
3
|
+
|
|
4
|
+
const registryDocBlocks: DocBlock[] = [
|
|
5
|
+
{
|
|
6
|
+
id: 'docs.learning-journey.registry',
|
|
7
|
+
title: 'Learning Journey — Example Track Registry',
|
|
8
|
+
summary:
|
|
9
|
+
'Aggregates learning journey example tracks (Studio onboarding, Platform tour, CRM first win, Drills, Ambient Coach, Quest challenges).',
|
|
10
|
+
kind: 'usage',
|
|
11
|
+
visibility: 'public',
|
|
12
|
+
route: '/docs/learning-journey/registry',
|
|
13
|
+
tags: ['learning', 'registry', 'onboarding'],
|
|
14
|
+
body: `## Tracks
|
|
15
|
+
- \`studio_getting_started\` (Studio onboarding)
|
|
16
|
+
- \`platform_primitives_tour\` (Platform primitives)
|
|
17
|
+
- \`crm_first_win\` (CRM pipeline onboarding)
|
|
18
|
+
- \`drills_language_basics\` (Drills & SRS)
|
|
19
|
+
- \`money_ambient_coach\`, \`coliving_ambient_coach\` (Ambient tips)
|
|
20
|
+
- \`money_reset_7day\` (Quest/challenge)
|
|
21
|
+
|
|
22
|
+
## Exports
|
|
23
|
+
- \`learningJourneyTracks\` — raw specs
|
|
24
|
+
- \`onboardingTrackCatalog\` — DTOs aligned with onboarding API
|
|
25
|
+
- \`mapTrackSpecToDto\` — helper to map individual tracks
|
|
26
|
+
|
|
27
|
+
## Wiring
|
|
28
|
+
- Use with onboarding API contracts:
|
|
29
|
+
- \`learning.onboarding.listTracks\`
|
|
30
|
+
- \`learning.onboarding.getProgress\`
|
|
31
|
+
- \`learning.onboarding.recordEvent\`
|
|
32
|
+
- Intended for registry/adapters in Studio UI or services that surface tracks.`,
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
registerDocBlocks(registryDocBlocks);
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PresentationDescriptorV2,
|
|
3
|
+
PresentationV2Meta,
|
|
4
|
+
} from '@lssm/lib.contracts';
|
|
5
|
+
const baseMeta: Pick<PresentationV2Meta, 'domain' | 'owners' | 'tags'> = {
|
|
6
|
+
domain: 'learning-journey',
|
|
7
|
+
owners: ['learning-team'] as string[],
|
|
8
|
+
tags: ['learning', 'journey', 'onboarding'] as string[],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const LearningTrackListPresentation: PresentationDescriptorV2 = {
|
|
12
|
+
meta: {
|
|
13
|
+
name: 'learning.journey.track_list',
|
|
14
|
+
version: 1,
|
|
15
|
+
description: 'List of learning journeys available to the learner.',
|
|
16
|
+
...baseMeta,
|
|
17
|
+
},
|
|
18
|
+
source: {
|
|
19
|
+
type: 'component',
|
|
20
|
+
framework: 'react',
|
|
21
|
+
componentKey: 'LearningTrackList',
|
|
22
|
+
},
|
|
23
|
+
targets: ['react', 'markdown'],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const LearningTrackDetailPresentation: PresentationDescriptorV2 = {
|
|
27
|
+
meta: {
|
|
28
|
+
name: 'learning.journey.track_detail',
|
|
29
|
+
version: 1,
|
|
30
|
+
description: 'Track detail with steps and progress state.',
|
|
31
|
+
...baseMeta,
|
|
32
|
+
},
|
|
33
|
+
source: {
|
|
34
|
+
type: 'component',
|
|
35
|
+
framework: 'react',
|
|
36
|
+
componentKey: 'LearningTrackDetail',
|
|
37
|
+
},
|
|
38
|
+
targets: ['react', 'markdown', 'application/json'],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const LearningTrackProgressWidgetPresentation: PresentationDescriptorV2 =
|
|
42
|
+
{
|
|
43
|
+
meta: {
|
|
44
|
+
name: 'learning.journey.progress_widget',
|
|
45
|
+
version: 1,
|
|
46
|
+
description: 'Compact widget showing progress for active track.',
|
|
47
|
+
...baseMeta,
|
|
48
|
+
},
|
|
49
|
+
source: {
|
|
50
|
+
type: 'component',
|
|
51
|
+
framework: 'react',
|
|
52
|
+
componentKey: 'LearningTrackProgressWidget',
|
|
53
|
+
},
|
|
54
|
+
targets: ['react'],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const learningJourneyPresentations = [
|
|
58
|
+
LearningTrackListPresentation,
|
|
59
|
+
LearningTrackDetailPresentation,
|
|
60
|
+
LearningTrackProgressWidgetPresentation,
|
|
61
|
+
];
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { LearningJourneyTrackSpec } from '@lssm/module.learning-journey/track-spec';
|
|
2
|
+
|
|
3
|
+
import type { TrackProgress } from './api-types';
|
|
4
|
+
|
|
5
|
+
export const progressStore = new Map<string, Map<string, TrackProgress>>();
|
|
6
|
+
|
|
7
|
+
export const getTrackResolver =
|
|
8
|
+
(tracks: LearningJourneyTrackSpec[]) =>
|
|
9
|
+
(trackId: string): LearningJourneyTrackSpec | undefined =>
|
|
10
|
+
tracks.find((t) => t.id === trackId);
|
|
11
|
+
|
|
12
|
+
export const getLearnerTracks = (learnerId: string) => {
|
|
13
|
+
const existing = progressStore.get(learnerId);
|
|
14
|
+
if (existing) return existing;
|
|
15
|
+
const map = new Map<string, TrackProgress>();
|
|
16
|
+
progressStore.set(learnerId, map);
|
|
17
|
+
return map;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const initProgress = (
|
|
21
|
+
learnerId: string,
|
|
22
|
+
track: LearningJourneyTrackSpec
|
|
23
|
+
): TrackProgress => ({
|
|
24
|
+
learnerId,
|
|
25
|
+
trackId: track.id,
|
|
26
|
+
progress: 0,
|
|
27
|
+
isCompleted: false,
|
|
28
|
+
xpEarned: 0,
|
|
29
|
+
steps: track.steps.map((step) => ({
|
|
30
|
+
id: step.id,
|
|
31
|
+
status: 'PENDING',
|
|
32
|
+
xpEarned: 0,
|
|
33
|
+
occurrences: 0,
|
|
34
|
+
masteryCount: 0,
|
|
35
|
+
})),
|
|
36
|
+
startedAt: undefined,
|
|
37
|
+
completedAt: undefined,
|
|
38
|
+
lastActivityAt: undefined,
|
|
39
|
+
});
|
package/src/tracks.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
LearningJourneyStepSpec,
|
|
3
|
+
LearningJourneyTrackSpec,
|
|
4
|
+
StepAvailabilitySpec,
|
|
5
|
+
StepCompletionConditionSpec,
|
|
6
|
+
} from '@lssm/module.learning-journey/track-spec';
|
|
7
|
+
import { crmLearningTracks } from '@lssm/example.learning-journey.crm-onboarding/track';
|
|
8
|
+
import { drillTracks } from '@lssm/example.learning-journey.duo-drills/track';
|
|
9
|
+
import { ambientCoachTracks } from '@lssm/example.learning-journey.ambient-coach/track';
|
|
10
|
+
import { questTracks } from '@lssm/example.learning-journey.quest-challenges/track';
|
|
11
|
+
import { platformLearningTracks } from '@lssm/example.learning-journey.platform-tour/track';
|
|
12
|
+
import { studioLearningTracks } from '@lssm/example.learning-journey.studio-onboarding/track';
|
|
13
|
+
|
|
14
|
+
export interface OnboardingStepDto {
|
|
15
|
+
id: string;
|
|
16
|
+
title: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
completionEvent: string;
|
|
19
|
+
completionCondition?: StepCompletionConditionSpec;
|
|
20
|
+
xpReward?: number;
|
|
21
|
+
isRequired?: boolean;
|
|
22
|
+
canSkip?: boolean;
|
|
23
|
+
actionUrl?: string;
|
|
24
|
+
actionLabel?: string;
|
|
25
|
+
availability?: StepAvailabilitySpec;
|
|
26
|
+
metadata?: Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface OnboardingTrackDto {
|
|
30
|
+
id: string;
|
|
31
|
+
name: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
productId?: string;
|
|
34
|
+
targetUserSegment?: string;
|
|
35
|
+
targetRole?: string;
|
|
36
|
+
totalXp?: number;
|
|
37
|
+
streakRule?: LearningJourneyTrackSpec['streakRule'];
|
|
38
|
+
completionRewards?: LearningJourneyTrackSpec['completionRewards'];
|
|
39
|
+
steps: OnboardingStepDto[];
|
|
40
|
+
metadata?: Record<string, unknown>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const mapStep = (step: LearningJourneyStepSpec): OnboardingStepDto => ({
|
|
44
|
+
id: step.id,
|
|
45
|
+
title: step.title,
|
|
46
|
+
description: step.description,
|
|
47
|
+
completionEvent: step.completion.eventName,
|
|
48
|
+
completionCondition: step.completion,
|
|
49
|
+
xpReward: step.xpReward,
|
|
50
|
+
isRequired: step.isRequired,
|
|
51
|
+
canSkip: step.canSkip,
|
|
52
|
+
actionUrl: step.actionUrl,
|
|
53
|
+
actionLabel: step.actionLabel,
|
|
54
|
+
availability: step.availability,
|
|
55
|
+
metadata: step.metadata,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export const mapTrackSpecToDto = (
|
|
59
|
+
track: LearningJourneyTrackSpec
|
|
60
|
+
): OnboardingTrackDto => ({
|
|
61
|
+
id: track.id,
|
|
62
|
+
name: track.name,
|
|
63
|
+
description: track.description,
|
|
64
|
+
productId: track.productId,
|
|
65
|
+
targetUserSegment: track.targetUserSegment,
|
|
66
|
+
targetRole: track.targetRole,
|
|
67
|
+
totalXp: track.totalXp,
|
|
68
|
+
streakRule: track.streakRule,
|
|
69
|
+
completionRewards: track.completionRewards,
|
|
70
|
+
steps: track.steps.map(mapStep),
|
|
71
|
+
metadata: track.metadata,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
export const learningJourneyTracks: LearningJourneyTrackSpec[] = [
|
|
75
|
+
...studioLearningTracks,
|
|
76
|
+
...platformLearningTracks,
|
|
77
|
+
...crmLearningTracks,
|
|
78
|
+
...drillTracks,
|
|
79
|
+
...ambientCoachTracks,
|
|
80
|
+
...questTracks,
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
export const onboardingTrackCatalog: OnboardingTrackDto[] =
|
|
84
|
+
learningJourneyTracks.map(mapTrackSpecToDto);
|
|
85
|
+
|
|
86
|
+
export {
|
|
87
|
+
studioLearningTracks,
|
|
88
|
+
platformLearningTracks,
|
|
89
|
+
crmLearningTracks,
|
|
90
|
+
mapStep,
|
|
91
|
+
};
|