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.
- package/.claude/settings.local.json +18 -0
- package/CHANGELOG.md +2 -0
- package/package.json +1 -1
- package/src/services/awards/award-callbacks.js +72 -33
- package/src/services/awards/award-query.js +257 -89
- package/src/services/awards/types.js +5 -5
- package/src/services/sync/repositories/user-award-progress.ts +1 -1
|
@@ -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
|
@@ -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
|
-
* @
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
*
|
|
25
|
-
*
|
|
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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
* @
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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
|
-
*
|
|
90
|
-
*
|
|
127
|
+
* // Use awardProgress to update UI
|
|
128
|
+
* }
|
|
91
129
|
*
|
|
92
|
-
* @example //
|
|
130
|
+
* @example // Show encouragement toast at milestones
|
|
93
131
|
* useEffect(() => {
|
|
94
132
|
* return registerProgressCallback(({ awardId, progressPercentage }) => {
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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
|
-
* @
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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 //
|
|
33
|
-
* const { awards } = await getContentAwards(
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
* @
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
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
|
-
* @
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
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
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
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
|
|
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
|
-
* @
|
|
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
|
-
* <
|
|
273
|
-
* <
|
|
274
|
-
* <
|
|
275
|
-
*
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
18
|
+
return progress.progress_percentage >= 0 && !UserAwardProgressRepository.isCompleted(progress)
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
static completedAtDate(progress: { completed_at: number | null }): Date | null {
|