musora-content-services 2.95.0 → 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.
@@ -0,0 +1,18 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(find:*)",
5
+ "Bash(docker exec:*)",
6
+ "Bash(npm test:*)",
7
+ "WebSearch",
8
+ "WebFetch(domain:watermelondb.dev)",
9
+ "WebFetch(domain:github.com)",
10
+ "Bash(git checkout:*)",
11
+ "Bash(npm run doc:*)",
12
+ "Bash(cat:*)",
13
+ "Bash(tr:*)"
14
+ ],
15
+ "deny": [],
16
+ "ask": []
17
+ }
18
+ }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ### [2.95.1](https://github.com/railroadmedia/musora-content-services/compare/v2.95.0...v2.95.1) (2025-12-08)
6
+
5
7
  ## [2.95.0](https://github.com/railroadmedia/musora-content-services/compare/v2.94.8...v2.95.0) (2025-12-08)
6
8
 
7
9
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.95.0",
3
+ "version": "2.95.1",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -12,27 +12,50 @@ let progressUpdateCallback = null
12
12
  * @param {AwardCallbackFunction} callback - Function called with award data when an award is earned
13
13
  * @returns {UnregisterFunction} Cleanup function to unregister this callback
14
14
  *
15
- * @example // Display award notification
16
- * const cleanup = registerAwardCallback((award) => {
17
- * showNotification({
18
- * title: award.name,
19
- * message: award.completion_data.message,
20
- * image: award.badge
21
- * })
22
- * })
15
+ * @description
16
+ * Registers a callback to be notified when the user earns a new award. Only one
17
+ * callback can be registered at a time - registering a new one replaces the previous.
18
+ * Always call the returned cleanup function when your component unmounts.
19
+ *
20
+ * The callback receives an award object with:
21
+ * - `awardId` - Unique Sanity award ID
22
+ * - `name` - Display name of the award
23
+ * - `badge` - URL to badge image
24
+ * - `completed_at` - ISO timestamp
25
+ * - `completion_data.message` - Pre-generated congratulations message
26
+ * - `completion_data.practice_minutes` - Total practice time
27
+ * - `completion_data.days_user_practiced` - Days spent practicing
28
+ * - `completion_data.content_title` - Title of completed content
29
+ *
30
+ * @example // React Native - Show award celebration modal
31
+ * function useAwardNotification() {
32
+ * const [award, setAward] = useState(null)
23
33
  *
24
- * // Later, when component unmounts:
25
- * cleanup()
34
+ * useEffect(() => {
35
+ * return registerAwardCallback((awardData) => {
36
+ * setAward({
37
+ * title: awardData.name,
38
+ * badge: awardData.badge,
39
+ * message: awardData.completion_data.message,
40
+ * practiceMinutes: awardData.completion_data.practice_minutes
41
+ * })
42
+ * })
43
+ * }, [])
26
44
  *
27
- * @example // Track award analytics
28
- * registerAwardCallback((award) => {
29
- * analytics.track('Award Earned', {
30
- * awardId: award.awardId,
31
- * awardName: award.name,
32
- * practiceMinutes: award.completion_data.practice_minutes,
33
- * completedAt: award.completed_at
45
+ * return { award, dismissAward: () => setAward(null) }
46
+ * }
47
+ *
48
+ * @example // Track award in analytics
49
+ * useEffect(() => {
50
+ * return registerAwardCallback((award) => {
51
+ * analytics.track('Award Earned', {
52
+ * awardId: award.awardId,
53
+ * awardName: award.name,
54
+ * practiceMinutes: award.completion_data.practice_minutes,
55
+ * contentTitle: award.completion_data.content_title
56
+ * })
34
57
  * })
35
- * })
58
+ * }, [])
36
59
  */
37
60
  export function registerAwardCallback(callback) {
38
61
  if (typeof callback !== 'function') {
@@ -77,25 +100,41 @@ function unregisterAwardCallback() {
77
100
  * @param {ProgressCallbackFunction} callback - Function called with progress data when award progress changes
78
101
  * @returns {UnregisterFunction} Cleanup function to unregister this callback
79
102
  *
80
- * @example // Update progress bar
81
- * const cleanup = registerProgressCallback(({ awardId, progressPercentage }) => {
82
- * const progressBar = document.getElementById(`award-${awardId}`)
83
- * if (progressBar) {
84
- * progressBar.style.width = `${progressPercentage}%`
85
- * progressBar.textContent = `${progressPercentage}% Complete`
86
- * }
87
- * })
103
+ * @description
104
+ * Registers a callback to be notified when award progress changes (but award is not
105
+ * yet complete). Only one callback can be registered at a time. Use this to update
106
+ * progress bars or show "almost there" encouragement.
107
+ *
108
+ * The callback receives:
109
+ * - `awardId` - Unique Sanity award ID
110
+ * - `progressPercentage` - Current completion percentage (0-99)
111
+ *
112
+ * Note: When an award reaches 100%, `registerAwardCallback` fires instead.
113
+ *
114
+ * @example // React Native - Update progress in learning path screen
115
+ * function LearningPathScreen({ learningPathId }) {
116
+ * const [awardProgress, setAwardProgress] = useState({})
117
+ *
118
+ * useEffect(() => {
119
+ * return registerProgressCallback(({ awardId, progressPercentage }) => {
120
+ * setAwardProgress(prev => ({
121
+ * ...prev,
122
+ * [awardId]: progressPercentage
123
+ * }))
124
+ * })
125
+ * }, [])
88
126
  *
89
- * // Cleanup on unmount
90
- * return () => cleanup()
127
+ * // Use awardProgress to update UI
128
+ * }
91
129
  *
92
- * @example // React state update
130
+ * @example // Show encouragement toast at milestones
93
131
  * useEffect(() => {
94
132
  * return registerProgressCallback(({ awardId, progressPercentage }) => {
95
- * setAwardProgress(prev => ({
96
- * ...prev,
97
- * [awardId]: progressPercentage
98
- * }))
133
+ * if (progressPercentage === 50) {
134
+ * showToast('Halfway to your award!')
135
+ * } else if (progressPercentage >= 90) {
136
+ * showToast('Almost there! Just a few more lessons.')
137
+ * }
99
138
  * })
100
139
  * }, [])
101
140
  */
@@ -1,5 +1,54 @@
1
1
  /**
2
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)
3
52
  */
4
53
 
5
54
  import './types.js'
@@ -18,31 +67,48 @@ function enhanceCompletionData(completionData) {
18
67
  }
19
68
 
20
69
  /**
21
- * @param {number} contentId - Railcontent ID of the content item
70
+ * @param {number} contentId - Railcontent ID of the content item (lesson, course, or learning path)
22
71
  * @returns {Promise<ContentAwardsResponse>} Status object with award information
23
72
  *
24
- * @example // Check if content has awards
25
- * const { hasAwards, awards } = await getContentAwards(234567)
26
- * if (hasAwards) {
27
- * awards.forEach(award => {
28
- * console.log(`${award.awardTitle}: ${award.progressPercentage}%`)
29
- * })
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
+ * )
30
104
  * }
31
105
  *
32
- * @example // Display award progress on course page
33
- * const { awards } = await getContentAwards(courseId)
34
- * return (
35
- * <div>
36
- * {awards.map(award => (
37
- * <AwardProgressBar
38
- * key={award.awardId}
39
- * title={award.awardTitle}
40
- * progress={award.progressPercentage}
41
- * badge={award.isCompleted ? award.badge : null}
42
- * />
43
- * ))}
44
- * </div>
45
- * )
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
+ * }
46
112
  */
47
113
  export async function getContentAwards(contentId) {
48
114
  try {
@@ -93,35 +159,70 @@ export async function getContentAwards(contentId) {
93
159
  /**
94
160
  * @param {string} [brand=null] - Brand to filter by (drumeo, pianote, guitareo, singeo), or null for all brands
95
161
  * @param {AwardPaginationOptions} [options={}] - Optional pagination and filtering
96
- * @returns {Promise<AwardInfo[]>} Array of completed award objects sorted by completion date
162
+ * @returns {Promise<AwardInfo[]>} Array of completed award objects sorted by completion date (newest first)
97
163
  *
98
- * @example // Display completed awards gallery
99
- * const awards = await getCompletedAwards()
100
- * return (
101
- * <AwardsGallery>
102
- * <h2>My Achievements ({awards.length})</h2>
103
- * {awards.map(award => (
104
- * <Badge
105
- * key={award.awardId}
106
- * image={award.badge}
107
- * title={award.awardTitle}
108
- * date={new Date(award.completedAt).toLocaleDateString()}
109
- * />
110
- * ))}
111
- * </AwardsGallery>
112
- * )
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.
113
180
  *
114
- * @example // Paginated awards list
115
- * const [page, setPage] = useState(0)
116
- * const pageSize = 12
117
- * const awards = await getCompletedAwards('drumeo', {
118
- * limit: pageSize,
119
- * offset: page * pageSize
120
- * })
121
- *
122
- * @example // Filter by brand
123
- * const pianoAwards = await getCompletedAwards('pianote')
124
- * console.log(`You've earned ${pianoAwards.length} piano awards!`)
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
+ * }
125
226
  */
126
227
  export async function getCompletedAwards(brand = null, options = {}) {
127
228
  try {
@@ -179,42 +280,78 @@ export async function getCompletedAwards(brand = null, options = {}) {
179
280
  /**
180
281
  * @param {string} [brand=null] - Brand to filter by (drumeo, pianote, guitareo, singeo), or null for all brands
181
282
  * @param {AwardPaginationOptions} [options={}] - Optional pagination options
182
- * @returns {Promise<AwardInfo[]>} Array of in-progress award objects sorted by progress
283
+ * @returns {Promise<AwardInfo[]>} Array of in-progress award objects sorted by progress percentage (highest first)
183
284
  *
184
- * @example // Display in-progress awards dashboard
185
- * const inProgress = await getInProgressAwards()
186
- * return (
187
- * <div>
188
- * <h2>Keep Going! ({inProgress.length})</h2>
189
- * {inProgress.map(award => (
190
- * <ProgressCard
191
- * key={award.awardId}
192
- * title={award.awardTitle}
193
- * progress={award.progressPercentage}
194
- * badge={award.badge}
195
- * />
196
- * ))}
197
- * </div>
198
- * )
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
199
307
  *
200
- * @example // Show closest to completion
201
- * const inProgress = await getInProgressAwards(null, { limit: 3 })
202
- * console.log('Awards closest to completion:')
203
- * inProgress.forEach(award => {
204
- * console.log(`${award.awardTitle}: ${award.progressPercentage}% complete`)
205
- * })
206
- *
207
- * @example // Filter by brand with pagination
208
- * const guitarAwards = await getInProgressAwards('guitareo', {
209
- * limit: 10,
210
- * offset: 0
211
- * })
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
+ * }
212
349
  */
213
350
  export async function getInProgressAwards(brand = null, options = {}) {
214
351
  try {
215
352
  const allProgress = await db.userAwardProgress.getAll()
216
353
  const inProgress = allProgress.data.filter(p =>
217
- p.progress_percentage > 0 && (p.progress_percentage < 100 || p.completed_at === null)
354
+ p.progress_percentage >= 0 && (p.progress_percentage < 100 || p.completed_at === null)
218
355
  )
219
356
 
220
357
  let awards = await Promise.all(
@@ -266,20 +403,51 @@ export async function getInProgressAwards(brand = null, options = {}) {
266
403
  * @param {string} [brand=null] - Brand to filter by (drumeo, pianote, guitareo, singeo), or null for all brands
267
404
  * @returns {Promise<AwardStatistics>} Statistics object with award counts and completion percentage
268
405
  *
269
- * @example // Display stats widget
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
270
444
  * const stats = await getAwardStatistics('drumeo')
271
445
  * return (
272
- * <StatsWidget>
273
- * <Stat label="Earned" value={stats.completed} />
274
- * <Stat label="In Progress" value={stats.inProgress} />
275
- * <Stat label="Completion" value={`${stats.completionPercentage}%`} />
276
- * </StatsWidget>
446
+ * <View>
447
+ * <Text>{stats.completed} of {stats.totalAvailable} awards earned</Text>
448
+ * <ProgressBar progress={stats.completionPercentage / 100} />
449
+ * </View>
277
450
  * )
278
- *
279
- * @example // Progress bar
280
- * const stats = await getAwardStatistics()
281
- * console.log(`${stats.completed}/${stats.totalAvailable} awards earned`)
282
- * console.log(`${stats.completionPercentage}% complete`)
283
451
  */
284
452
  export async function getAwardStatistics(brand = null) {
285
453
  try {
@@ -299,7 +467,7 @@ export async function getAwardStatistics(brand = null) {
299
467
  ).length
300
468
 
301
469
  const inProgressCount = filteredProgress.filter(p =>
302
- p.progress_percentage > 0 && (p.progress_percentage < 100 || p.completed_at === null)
470
+ p.progress_percentage >= 0 && (p.progress_percentage < 100 || p.completed_at === null)
303
471
  ).length
304
472
 
305
473
  const totalAvailable = allDefinitions.length
@@ -6,14 +6,14 @@
6
6
  * @property {string|null} logo - URL to logo image
7
7
  * @property {string} badge - URL to badge image
8
8
  * @property {string} award - URL to award image
9
- * @property {number} content_id - Railcontent ID of the parent content
10
- * @property {string} content_type - Type of content (guided-course, learning-path-v2, etc.)
9
+ * @property {number} content_id - Railcontent ID of the parent content (e.g., the learning path ID)
10
+ * @property {string} content_type - Type of parent content ('learning-path-v2', 'guided-course', etc.). Used with content_id to determine collection context for award evaluation.
11
11
  * @property {string} type - Sanity document type
12
12
  * @property {string} brand - Brand (drumeo, pianote, guitareo, singeo)
13
13
  * @property {string} content_title - Title of the associated content
14
14
  * @property {string|null} award_custom_text - Custom text for the award
15
15
  * @property {string|null} instructor_name - Name of the instructor
16
- * @property {number[]} child_ids - Array of child content IDs required for completion
16
+ * @property {number[]} child_ids - Array of child content IDs (lessons) that must be completed to earn this award
17
17
  */
18
18
 
19
19
  /**
@@ -41,7 +41,7 @@
41
41
  * @property {string} award - URL to award image
42
42
  * @property {string} brand - Brand (drumeo, pianote, guitareo, singeo)
43
43
  * @property {string} instructorName - Name of the instructor
44
- * @property {number} progressPercentage - Completion percentage (0-100)
44
+ * @property {number} progressPercentage - Completion percentage (0-100). Progress is tracked per collection context for learning paths.
45
45
  * @property {boolean} isCompleted - Whether the award is fully completed
46
46
  * @property {string|null} completedAt - ISO timestamp of completion, or null if not completed
47
47
  * @property {AwardCompletionData|null} completionData - Practice statistics, or null if not started
@@ -50,7 +50,7 @@
50
50
  /**
51
51
  * @typedef {Object} ContentAwardsResponse
52
52
  * @property {boolean} hasAwards - Whether the content has any associated awards
53
- * @property {AwardInfo[]} awards - Array of award objects with progress information
53
+ * @property {AwardInfo[]} awards - Array of award objects with progress information. For learning paths, progress is scoped to the specific learning path context.
54
54
  */
55
55
 
56
56
  /**
@@ -15,7 +15,7 @@ export default class UserAwardProgressRepository extends SyncRepository<UserAwar
15
15
  }
16
16
 
17
17
  static isInProgress(progress: AwardProgressData): boolean {
18
- return progress.progress_percentage > 0 && !UserAwardProgressRepository.isCompleted(progress)
18
+ return progress.progress_percentage >= 0 && !UserAwardProgressRepository.isCompleted(progress)
19
19
  }
20
20
 
21
21
  static completedAtDate(progress: { completed_at: number | null }): Date | null {