musora-content-services 2.94.8 → 2.95.1

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 (69) hide show
  1. package/.claude/settings.local.json +18 -0
  2. package/CHANGELOG.md +18 -0
  3. package/CLAUDE.md +408 -0
  4. package/babel.config.cjs +10 -0
  5. package/jsdoc.json +2 -1
  6. package/package.json +2 -2
  7. package/src/constants/award-assets.js +35 -0
  8. package/src/filterBuilder.js +7 -2
  9. package/src/index.d.ts +26 -5
  10. package/src/index.js +26 -5
  11. package/src/services/awards/award-callbacks.js +165 -0
  12. package/src/services/awards/award-query.js +495 -0
  13. package/src/services/awards/internal/.indexignore +1 -0
  14. package/src/services/awards/internal/award-definitions.js +239 -0
  15. package/src/services/awards/internal/award-events.js +102 -0
  16. package/src/services/awards/internal/award-manager.js +162 -0
  17. package/src/services/awards/internal/certificate-builder.js +66 -0
  18. package/src/services/awards/internal/completion-data-generator.js +84 -0
  19. package/src/services/awards/internal/content-progress-observer.js +137 -0
  20. package/src/services/awards/internal/image-utils.js +62 -0
  21. package/src/services/awards/internal/message-generator.js +17 -0
  22. package/src/services/awards/internal/types.js +5 -0
  23. package/src/services/awards/types.d.ts +79 -0
  24. package/src/services/awards/types.js +101 -0
  25. package/src/services/config.js +24 -4
  26. package/src/services/content-org/learning-paths.ts +19 -15
  27. package/src/services/gamification/awards.ts +114 -83
  28. package/src/services/progress-events.js +58 -0
  29. package/src/services/progress-row/method-card.js +20 -5
  30. package/src/services/sanity.js +1 -1
  31. package/src/services/sync/fetch.ts +10 -2
  32. package/src/services/sync/manager.ts +6 -0
  33. package/src/services/sync/models/ContentProgress.ts +5 -6
  34. package/src/services/sync/models/UserAwardProgress.ts +55 -0
  35. package/src/services/sync/models/index.ts +1 -0
  36. package/src/services/sync/repositories/content-progress.ts +47 -25
  37. package/src/services/sync/repositories/index.ts +1 -0
  38. package/src/services/sync/repositories/practices.ts +16 -1
  39. package/src/services/sync/repositories/user-award-progress.ts +133 -0
  40. package/src/services/sync/repository-proxy.ts +6 -0
  41. package/src/services/sync/retry.ts +12 -11
  42. package/src/services/sync/schema/index.ts +18 -3
  43. package/src/services/sync/store/index.ts +53 -8
  44. package/src/services/sync/store/push-coalescer.ts +3 -3
  45. package/src/services/sync/store-configs.ts +7 -1
  46. package/src/services/userActivity.js +0 -1
  47. package/test/HttpClient.test.js +6 -6
  48. package/test/awards/award-alacarte-observer.test.js +196 -0
  49. package/test/awards/award-auto-refresh.test.js +83 -0
  50. package/test/awards/award-calculations.test.js +33 -0
  51. package/test/awards/award-certificate-display.test.js +328 -0
  52. package/test/awards/award-collection-edge-cases.test.js +210 -0
  53. package/test/awards/award-collection-filtering.test.js +285 -0
  54. package/test/awards/award-completion-flow.test.js +213 -0
  55. package/test/awards/award-exclusion-handling.test.js +273 -0
  56. package/test/awards/award-multi-lesson.test.js +241 -0
  57. package/test/awards/award-observer-integration.test.js +325 -0
  58. package/test/awards/award-query-messages.test.js +438 -0
  59. package/test/awards/award-user-collection.test.js +412 -0
  60. package/test/awards/duplicate-prevention.test.js +118 -0
  61. package/test/awards/helpers/completion-mock.js +54 -0
  62. package/test/awards/helpers/index.js +3 -0
  63. package/test/awards/helpers/mock-setup.js +69 -0
  64. package/test/awards/helpers/progress-emitter.js +39 -0
  65. package/test/awards/message-generator.test.js +162 -0
  66. package/test/initializeTests.js +6 -0
  67. package/test/mockData/award-definitions.js +171 -0
  68. package/test/sync/models/award-database-integration.test.js +519 -0
  69. package/tools/generate-index.cjs +9 -0
@@ -0,0 +1,495 @@
1
+ /**
2
+ * @module Awards
3
+ *
4
+ * @description
5
+ * Query award progress, listen for award events, and generate certificates.
6
+ *
7
+ * **Query Functions** (read-only):
8
+ * - `getContentAwards(contentId)` - Get awards for a learning path/course
9
+ * - `getCompletedAwards(brand)` - Get user's earned awards
10
+ * - `getInProgressAwards(brand)` - Get awards user is working toward
11
+ * - `getAwardStatistics(brand)` - Get aggregate award stats
12
+ *
13
+ * **Event Callbacks**:
14
+ * - `registerAwardCallback(fn)` - Listen for new awards earned
15
+ * - `registerProgressCallback(fn)` - Listen for progress updates
16
+ *
17
+ * **Certificates**:
18
+ * - `fetchCertificate(awardId)` - Generate certificate (Web only)
19
+ *
20
+ * @example Quick Start
21
+ * import {
22
+ * getContentAwards,
23
+ * getCompletedAwards,
24
+ * registerAwardCallback
25
+ * } from 'musora-content-services'
26
+ *
27
+ * // Show awards for a learning path
28
+ * const { hasAwards, awards } = await getContentAwards(learningPathId)
29
+ *
30
+ * // Get user's completed awards
31
+ * const completed = await getCompletedAwards('drumeo')
32
+ *
33
+ * // Listen for new awards (show notification)
34
+ * useEffect(() => {
35
+ * return registerAwardCallback((award) => {
36
+ * showCelebration(award.name, award.badge, award.completion_data.message)
37
+ * })
38
+ * }, [])
39
+ *
40
+ * @example How Awards Update (Collection Context)
41
+ * // Award progress updates automatically when you save content progress.
42
+ * // CRITICAL: Pass collection context when inside a learning path!
43
+ *
44
+ * import { contentStatusCompleted } from 'musora-content-services'
45
+ *
46
+ * // Correct - passes collection context
47
+ * const collection = { type: 'learning-path-v2', id: learningPathId }
48
+ * await contentStatusCompleted(lessonId, collection)
49
+ *
50
+ * // Wrong - no collection context (affects wrong awards!)
51
+ * await contentStatusCompleted(lessonId, null)
52
+ */
53
+
54
+ import './types.js'
55
+ import { awardDefinitions } from './internal/award-definitions'
56
+ import { AwardMessageGenerator } from './internal/message-generator'
57
+ import db from '../sync/repository-proxy'
58
+ import UserAwardProgressRepository from '../sync/repositories/user-award-progress'
59
+
60
+ function enhanceCompletionData(completionData) {
61
+ if (!completionData) return null
62
+
63
+ return {
64
+ ...completionData,
65
+ message: AwardMessageGenerator.generatePopupMessage(completionData)
66
+ }
67
+ }
68
+
69
+ /**
70
+ * @param {number} contentId - Railcontent ID of the content item (lesson, course, or learning path)
71
+ * @returns {Promise<ContentAwardsResponse>} Status object with award information
72
+ *
73
+ * @description
74
+ * Returns awards associated with a content item and the user's progress toward each.
75
+ * Use this to display award progress on learning path or course detail pages.
76
+ *
77
+ * - Pass a **learning path ID** to get awards for that learning path
78
+ * - Pass a **course ID** to get awards for that course
79
+ * - Pass a **lesson ID** to get all awards that include that lesson in their requirements
80
+ *
81
+ * Returns `{ hasAwards: false, awards: [] }` on error (never throws).
82
+ *
83
+ * @example // Display award card on learning path detail page
84
+ * function LearningPathAwardCard({ learningPathId }) {
85
+ * const [awardData, setAwardData] = useState({ hasAwards: false, awards: [] })
86
+ *
87
+ * useEffect(() => {
88
+ * getContentAwards(learningPathId).then(setAwardData)
89
+ * }, [learningPathId])
90
+ *
91
+ * if (!awardData.hasAwards) return null
92
+ *
93
+ * const award = awardData.awards[0] // Learning paths typically have one award
94
+ * return (
95
+ * <AwardCard
96
+ * badge={award.badge}
97
+ * title={award.awardTitle}
98
+ * progress={award.progressPercentage}
99
+ * isCompleted={award.isCompleted}
100
+ * completedAt={award.completedAt}
101
+ * instructorName={award.instructorName}
102
+ * />
103
+ * )
104
+ * }
105
+ *
106
+ * @example // Check award status before showing certificate button
107
+ * const { hasAwards, awards } = await getContentAwards(learningPathId)
108
+ * const completedAward = awards.find(a => a.isCompleted)
109
+ * if (completedAward) {
110
+ * showCertificateButton(completedAward.awardId)
111
+ * }
112
+ */
113
+ export async function getContentAwards(contentId) {
114
+ try {
115
+ const hasAwards = await awardDefinitions.hasAwards(contentId)
116
+
117
+ if (!hasAwards) {
118
+ return {
119
+ hasAwards: false,
120
+ awards: []
121
+ }
122
+ }
123
+
124
+ const { definitions, progress } = await db.userAwardProgress.getAwardsForContent(contentId)
125
+
126
+ const awards = definitions.map(def => {
127
+ const userProgress = progress.get(def._id)
128
+ const completionData = enhanceCompletionData(userProgress?.completion_data)
129
+
130
+ return {
131
+ awardId: def._id,
132
+ awardTitle: def.name,
133
+ badge: def.badge,
134
+ award: def.award,
135
+ brand: def.brand,
136
+ instructorName: def.instructor_name,
137
+ progressPercentage: userProgress?.progress_percentage ?? 0,
138
+ isCompleted: userProgress ? UserAwardProgressRepository.isCompleted(userProgress) : false,
139
+ completedAt: userProgress?.completed_at
140
+ ? new Date(userProgress.completed_at).toISOString()
141
+ : null,
142
+ completionData
143
+ }
144
+ })
145
+
146
+ return {
147
+ hasAwards: true,
148
+ awards
149
+ }
150
+ } catch (error) {
151
+ console.error(`Failed to get award status for content ${contentId}:`, error)
152
+ return {
153
+ hasAwards: false,
154
+ awards: []
155
+ }
156
+ }
157
+ }
158
+
159
+ /**
160
+ * @param {string} [brand=null] - Brand to filter by (drumeo, pianote, guitareo, singeo), or null for all brands
161
+ * @param {AwardPaginationOptions} [options={}] - Optional pagination and filtering
162
+ * @returns {Promise<AwardInfo[]>} Array of completed award objects sorted by completion date (newest first)
163
+ *
164
+ * @description
165
+ * Returns all awards the user has completed. Use this for "My Achievements" or
166
+ * profile award gallery screens. Each award includes:
167
+ *
168
+ * - Badge and award images for display
169
+ * - Completion date for "Earned on X" display
170
+ * - `completionData.message` - Pre-generated congratulations text
171
+ * - `completionData.practice_minutes` - Total practice time for this award
172
+ * - `completionData.days_user_practiced` - Days spent earning this award
173
+ *
174
+ * Returns empty array `[]` on error (never throws).
175
+ *
176
+ * @example // Awards gallery screen
177
+ * function AwardsGalleryScreen() {
178
+ * const [awards, setAwards] = useState([])
179
+ * const brand = useBrand() // 'drumeo', 'pianote', etc.
180
+ *
181
+ * useEffect(() => {
182
+ * getCompletedAwards(brand).then(setAwards)
183
+ * }, [brand])
184
+ *
185
+ * return (
186
+ * <FlatList
187
+ * data={awards}
188
+ * keyExtractor={item => item.awardId}
189
+ * numColumns={2}
190
+ * renderItem={({ item }) => (
191
+ * <AwardBadge
192
+ * badge={item.badge}
193
+ * title={item.awardTitle}
194
+ * earnedDate={new Date(item.completedAt).toLocaleDateString()}
195
+ * onPress={() => showAwardDetail(item)}
196
+ * />
197
+ * )}
198
+ * />
199
+ * )
200
+ * }
201
+ *
202
+ * @example // Show award detail with practice stats
203
+ * function AwardDetailModal({ award }) {
204
+ * return (
205
+ * <Modal>
206
+ * <Image source={{ uri: award.award }} />
207
+ * <Text>{award.awardTitle}</Text>
208
+ * <Text>Instructor: {award.instructorName}</Text>
209
+ * <Text>Completed: {new Date(award.completedAt).toLocaleDateString()}</Text>
210
+ * <Text>Practice time: {award.completionData.practice_minutes} minutes</Text>
211
+ * <Text>Days practiced: {award.completionData.days_user_practiced}</Text>
212
+ * <Text>{award.completionData.message}</Text>
213
+ * </Modal>
214
+ * )
215
+ * }
216
+ *
217
+ * @example // Paginated loading
218
+ * const PAGE_SIZE = 12
219
+ * const loadMore = async (page) => {
220
+ * const newAwards = await getCompletedAwards(brand, {
221
+ * limit: PAGE_SIZE,
222
+ * offset: page * PAGE_SIZE
223
+ * })
224
+ * setAwards(prev => [...prev, ...newAwards])
225
+ * }
226
+ */
227
+ export async function getCompletedAwards(brand = null, options = {}) {
228
+ try {
229
+ const allProgress = await db.userAwardProgress.getAll()
230
+
231
+ const completed = allProgress.data.filter(p =>
232
+ p.progress_percentage === 100 && p.completed_at !== null
233
+ )
234
+
235
+ let awards = await Promise.all(
236
+ completed.map(async (progress) => {
237
+ const definition = await awardDefinitions.getById(progress.award_id)
238
+
239
+ if (!definition) {
240
+ return null
241
+ }
242
+
243
+ if (brand && definition.brand !== brand) {
244
+ return null
245
+ }
246
+
247
+ const completionData = enhanceCompletionData(progress.completion_data)
248
+
249
+ return {
250
+ awardId: progress.award_id,
251
+ awardTitle: definition.name,
252
+ badge: definition.badge,
253
+ award: definition.award,
254
+ brand: definition.brand,
255
+ instructorName: definition.instructor_name,
256
+ progressPercentage: progress.progress_percentage,
257
+ isCompleted: true,
258
+ completedAt: new Date(progress.completed_at * 1000).toISOString(),
259
+ completionData
260
+ }
261
+ })
262
+ )
263
+
264
+ awards = awards.filter(award => award !== null)
265
+
266
+ awards.sort((a, b) => new Date(b.completedAt).getTime() - new Date(a.completedAt).getTime())
267
+
268
+ if (options.limit) {
269
+ const offset = options.offset || 0
270
+ awards = awards.slice(offset, offset + options.limit)
271
+ }
272
+
273
+ return awards
274
+ } catch (error) {
275
+ console.error('Failed to get completed awards:', error)
276
+ return []
277
+ }
278
+ }
279
+
280
+ /**
281
+ * @param {string} [brand=null] - Brand to filter by (drumeo, pianote, guitareo, singeo), or null for all brands
282
+ * @param {AwardPaginationOptions} [options={}] - Optional pagination options
283
+ * @returns {Promise<AwardInfo[]>} Array of in-progress award objects sorted by progress percentage (highest first)
284
+ *
285
+ * @description
286
+ * Returns awards the user has started but not yet completed. Sorted by progress
287
+ * percentage (highest first) so awards closest to completion appear first.
288
+ * Use this for "Continue Learning" or dashboard widgets.
289
+ *
290
+ * Progress is calculated based on lessons completed within the correct collection
291
+ * context. For learning paths, only lessons completed within that specific learning
292
+ * path count toward its award.
293
+ *
294
+ * Returns empty array `[]` on error (never throws).
295
+ *
296
+ * @example // "Almost There" widget on home screen
297
+ * function AlmostThereWidget() {
298
+ * const [topAwards, setTopAwards] = useState([])
299
+ * const brand = useBrand()
300
+ *
301
+ * useEffect(() => {
302
+ * // Get top 3 closest to completion
303
+ * getInProgressAwards(brand, { limit: 3 }).then(setTopAwards)
304
+ * }, [brand])
305
+ *
306
+ * if (topAwards.length === 0) return null
307
+ *
308
+ * return (
309
+ * <View>
310
+ * <Text>Almost There!</Text>
311
+ * {topAwards.map(award => (
312
+ * <TouchableOpacity
313
+ * key={award.awardId}
314
+ * onPress={() => navigateToLearningPath(award)}
315
+ * >
316
+ * <Image source={{ uri: award.badge }} />
317
+ * <Text>{award.awardTitle}</Text>
318
+ * <ProgressBar progress={award.progressPercentage / 100} />
319
+ * <Text>{award.progressPercentage}% complete</Text>
320
+ * </TouchableOpacity>
321
+ * ))}
322
+ * </View>
323
+ * )
324
+ * }
325
+ *
326
+ * @example // Full in-progress awards list
327
+ * function InProgressAwardsScreen() {
328
+ * const [awards, setAwards] = useState([])
329
+ *
330
+ * useEffect(() => {
331
+ * getInProgressAwards().then(setAwards)
332
+ * }, [])
333
+ *
334
+ * return (
335
+ * <FlatList
336
+ * data={awards}
337
+ * keyExtractor={item => item.awardId}
338
+ * renderItem={({ item }) => (
339
+ * <AwardProgressCard
340
+ * badge={item.badge}
341
+ * title={item.awardTitle}
342
+ * progress={item.progressPercentage}
343
+ * brand={item.brand}
344
+ * />
345
+ * )}
346
+ * />
347
+ * )
348
+ * }
349
+ */
350
+ export async function getInProgressAwards(brand = null, options = {}) {
351
+ try {
352
+ const allProgress = await db.userAwardProgress.getAll()
353
+ const inProgress = allProgress.data.filter(p =>
354
+ p.progress_percentage >= 0 && (p.progress_percentage < 100 || p.completed_at === null)
355
+ )
356
+
357
+ let awards = await Promise.all(
358
+ inProgress.map(async (progress) => {
359
+ const definition = await awardDefinitions.getById(progress.award_id)
360
+
361
+ if (!definition) {
362
+ return null
363
+ }
364
+
365
+ if (brand && definition.brand !== brand) {
366
+ return null
367
+ }
368
+
369
+ const completionData = enhanceCompletionData(progress.completion_data)
370
+
371
+ return {
372
+ awardId: progress.award_id,
373
+ awardTitle: definition.name,
374
+ badge: definition.badge,
375
+ award: definition.award,
376
+ brand: definition.brand,
377
+ instructorName: definition.instructor_name,
378
+ progressPercentage: progress.progress_percentage,
379
+ isCompleted: false,
380
+ completedAt: null,
381
+ completionData
382
+ }
383
+ })
384
+ )
385
+
386
+ awards = awards.filter(award => award !== null)
387
+
388
+ awards.sort((a, b) => b.progressPercentage - a.progressPercentage)
389
+
390
+ if (options.limit) {
391
+ const offset = options.offset || 0
392
+ awards = awards.slice(offset, offset + options.limit)
393
+ }
394
+
395
+ return awards
396
+ } catch (error) {
397
+ console.error('Failed to get in-progress awards:', error)
398
+ return []
399
+ }
400
+ }
401
+
402
+ /**
403
+ * @param {string} [brand=null] - Brand to filter by (drumeo, pianote, guitareo, singeo), or null for all brands
404
+ * @returns {Promise<AwardStatistics>} Statistics object with award counts and completion percentage
405
+ *
406
+ * @description
407
+ * Returns aggregate statistics about the user's award progress. Use this for
408
+ * profile stats, gamification dashboards, or achievement summaries.
409
+ *
410
+ * Returns an object with:
411
+ * - `totalAvailable` - Total awards that can be earned
412
+ * - `completed` - Number of awards earned
413
+ * - `inProgress` - Number of awards started but not completed
414
+ * - `notStarted` - Number of awards not yet started
415
+ * - `completionPercentage` - Overall completion % (0-100, one decimal)
416
+ *
417
+ * Returns all zeros on error (never throws).
418
+ *
419
+ * @example // Profile stats card
420
+ * function ProfileStatsCard() {
421
+ * const [stats, setStats] = useState(null)
422
+ * const brand = useBrand()
423
+ *
424
+ * useEffect(() => {
425
+ * getAwardStatistics(brand).then(setStats)
426
+ * }, [brand])
427
+ *
428
+ * if (!stats) return <LoadingSpinner />
429
+ *
430
+ * return (
431
+ * <View style={styles.statsCard}>
432
+ * <StatItem label="Awards Earned" value={stats.completed} />
433
+ * <StatItem label="In Progress" value={stats.inProgress} />
434
+ * <StatItem label="Available" value={stats.totalAvailable} />
435
+ * <ProgressRing
436
+ * progress={stats.completionPercentage / 100}
437
+ * label={`${stats.completionPercentage}%`}
438
+ * />
439
+ * </View>
440
+ * )
441
+ * }
442
+ *
443
+ * @example // Achievement progress bar
444
+ * const stats = await getAwardStatistics('drumeo')
445
+ * return (
446
+ * <View>
447
+ * <Text>{stats.completed} of {stats.totalAvailable} awards earned</Text>
448
+ * <ProgressBar progress={stats.completionPercentage / 100} />
449
+ * </View>
450
+ * )
451
+ */
452
+ export async function getAwardStatistics(brand = null) {
453
+ try {
454
+ let allDefinitions = await awardDefinitions.getAll()
455
+
456
+ if (brand) {
457
+ allDefinitions = allDefinitions.filter(def => def.brand === brand)
458
+ }
459
+
460
+ const definitionIds = new Set(allDefinitions.map(def => def._id))
461
+
462
+ const allProgress = await db.userAwardProgress.getAll()
463
+ const filteredProgress = allProgress.data.filter(p => definitionIds.has(p.award_id))
464
+
465
+ const completedCount = filteredProgress.filter(p =>
466
+ p.progress_percentage === 100 && p.completed_at !== null
467
+ ).length
468
+
469
+ const inProgressCount = filteredProgress.filter(p =>
470
+ p.progress_percentage >= 0 && (p.progress_percentage < 100 || p.completed_at === null)
471
+ ).length
472
+
473
+ const totalAvailable = allDefinitions.length
474
+ const notStarted = totalAvailable - completedCount - inProgressCount
475
+
476
+ return {
477
+ totalAvailable,
478
+ completed: completedCount,
479
+ inProgress: inProgressCount,
480
+ notStarted: notStarted > 0 ? notStarted : 0,
481
+ completionPercentage: totalAvailable > 0
482
+ ? Math.round((completedCount / totalAvailable) * 100 * 10) / 10
483
+ : 0
484
+ }
485
+ } catch (error) {
486
+ console.error('Failed to get award statistics:', error)
487
+ return {
488
+ totalAvailable: 0,
489
+ completed: 0,
490
+ inProgress: 0,
491
+ notStarted: 0,
492
+ completionPercentage: 0
493
+ }
494
+ }
495
+ }
@@ -0,0 +1 @@
1
+ .indexignore