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.
- package/.claude/settings.local.json +18 -0
- package/CHANGELOG.md +18 -0
- package/CLAUDE.md +408 -0
- package/babel.config.cjs +10 -0
- package/jsdoc.json +2 -1
- package/package.json +2 -2
- package/src/constants/award-assets.js +35 -0
- package/src/filterBuilder.js +7 -2
- package/src/index.d.ts +26 -5
- package/src/index.js +26 -5
- package/src/services/awards/award-callbacks.js +165 -0
- package/src/services/awards/award-query.js +495 -0
- package/src/services/awards/internal/.indexignore +1 -0
- package/src/services/awards/internal/award-definitions.js +239 -0
- package/src/services/awards/internal/award-events.js +102 -0
- package/src/services/awards/internal/award-manager.js +162 -0
- package/src/services/awards/internal/certificate-builder.js +66 -0
- package/src/services/awards/internal/completion-data-generator.js +84 -0
- package/src/services/awards/internal/content-progress-observer.js +137 -0
- package/src/services/awards/internal/image-utils.js +62 -0
- package/src/services/awards/internal/message-generator.js +17 -0
- package/src/services/awards/internal/types.js +5 -0
- package/src/services/awards/types.d.ts +79 -0
- package/src/services/awards/types.js +101 -0
- package/src/services/config.js +24 -4
- package/src/services/content-org/learning-paths.ts +19 -15
- package/src/services/gamification/awards.ts +114 -83
- package/src/services/progress-events.js +58 -0
- package/src/services/progress-row/method-card.js +20 -5
- package/src/services/sanity.js +1 -1
- package/src/services/sync/fetch.ts +10 -2
- package/src/services/sync/manager.ts +6 -0
- package/src/services/sync/models/ContentProgress.ts +5 -6
- package/src/services/sync/models/UserAwardProgress.ts +55 -0
- package/src/services/sync/models/index.ts +1 -0
- package/src/services/sync/repositories/content-progress.ts +47 -25
- package/src/services/sync/repositories/index.ts +1 -0
- package/src/services/sync/repositories/practices.ts +16 -1
- package/src/services/sync/repositories/user-award-progress.ts +133 -0
- package/src/services/sync/repository-proxy.ts +6 -0
- package/src/services/sync/retry.ts +12 -11
- package/src/services/sync/schema/index.ts +18 -3
- package/src/services/sync/store/index.ts +53 -8
- package/src/services/sync/store/push-coalescer.ts +3 -3
- package/src/services/sync/store-configs.ts +7 -1
- package/src/services/userActivity.js +0 -1
- package/test/HttpClient.test.js +6 -6
- package/test/awards/award-alacarte-observer.test.js +196 -0
- package/test/awards/award-auto-refresh.test.js +83 -0
- package/test/awards/award-calculations.test.js +33 -0
- package/test/awards/award-certificate-display.test.js +328 -0
- package/test/awards/award-collection-edge-cases.test.js +210 -0
- package/test/awards/award-collection-filtering.test.js +285 -0
- package/test/awards/award-completion-flow.test.js +213 -0
- package/test/awards/award-exclusion-handling.test.js +273 -0
- package/test/awards/award-multi-lesson.test.js +241 -0
- package/test/awards/award-observer-integration.test.js +325 -0
- package/test/awards/award-query-messages.test.js +438 -0
- package/test/awards/award-user-collection.test.js +412 -0
- package/test/awards/duplicate-prevention.test.js +118 -0
- package/test/awards/helpers/completion-mock.js +54 -0
- package/test/awards/helpers/index.js +3 -0
- package/test/awards/helpers/mock-setup.js +69 -0
- package/test/awards/helpers/progress-emitter.js +39 -0
- package/test/awards/message-generator.test.js +162 -0
- package/test/initializeTests.js +6 -0
- package/test/mockData/award-definitions.js +171 -0
- package/test/sync/models/award-database-integration.test.js +519 -0
- 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
|