musora-content-services 2.2.0 → 2.2.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 (52) hide show
  1. package/.github/workflows/node.js.yml +0 -0
  2. package/.prettierignore +0 -0
  3. package/.prettierrc +0 -0
  4. package/CHANGELOG.md +2 -0
  5. package/README.md +0 -0
  6. package/babel.config.cjs +0 -0
  7. package/docs/fonts/Montserrat/Montserrat-Bold.eot +0 -0
  8. package/docs/fonts/Montserrat/Montserrat-Bold.ttf +0 -0
  9. package/docs/fonts/Montserrat/Montserrat-Bold.woff +0 -0
  10. package/docs/fonts/Montserrat/Montserrat-Bold.woff2 +0 -0
  11. package/docs/fonts/Montserrat/Montserrat-Regular.eot +0 -0
  12. package/docs/fonts/Montserrat/Montserrat-Regular.ttf +0 -0
  13. package/docs/fonts/Montserrat/Montserrat-Regular.woff +0 -0
  14. package/docs/fonts/Montserrat/Montserrat-Regular.woff2 +0 -0
  15. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.eot +0 -0
  16. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.svg +0 -0
  17. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.ttf +0 -0
  18. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff +0 -0
  19. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff2 +0 -0
  20. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.eot +0 -0
  21. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.svg +0 -0
  22. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.ttf +0 -0
  23. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff +0 -0
  24. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff2 +0 -0
  25. package/docs/scripts/collapse.js +0 -0
  26. package/docs/scripts/commonNav.js +0 -0
  27. package/docs/scripts/linenumber.js +0 -0
  28. package/docs/scripts/nav.js +0 -0
  29. package/docs/scripts/polyfill.js +0 -0
  30. package/docs/scripts/prettify/Apache-License-2.0.txt +0 -0
  31. package/docs/scripts/prettify/lang-css.js +0 -0
  32. package/docs/scripts/prettify/prettify.js +0 -0
  33. package/docs/scripts/search.js +0 -0
  34. package/docs/styles/jsdoc.css +0 -0
  35. package/docs/styles/prettify.css +0 -0
  36. package/jest.config.js +0 -0
  37. package/package.json +1 -1
  38. package/src/contentTypeConfig.js +1 -0
  39. package/src/index.d.ts +35 -2
  40. package/src/index.js +35 -2
  41. package/src/services/contentLikes.js +0 -0
  42. package/src/services/dataContext.js +15 -2
  43. package/src/services/railcontent.js +60 -0
  44. package/src/services/sanity.js +10 -7
  45. package/src/services/userActivity.js +377 -23
  46. package/test/contentLikes.test.js +2 -0
  47. package/test/live/contentProgressLive.test.js +0 -0
  48. package/test/live/railcontentLive.test.js +0 -0
  49. package/test/localStorageMock.js +0 -0
  50. package/test/log.js +0 -0
  51. package/test/mockData/mockData_fetchByRailContentIds_one_content.json +35 -0
  52. package/test/userActivity.test.js +118 -0
File without changes
package/.prettierignore CHANGED
File without changes
package/.prettierrc CHANGED
File without changes
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.2.1](https://github.com/railroadmedia/musora-content-services/compare/v2.2.0...v2.2.1) (2025-03-28)
6
+
5
7
  ## [2.2.0](https://github.com/railroadmedia/musora-content-services/compare/v2.1.1...v2.2.0) (2025-03-27)
6
8
 
7
9
 
package/README.md CHANGED
File without changes
package/babel.config.cjs CHANGED
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
package/jest.config.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -521,6 +521,7 @@ export let contentTypeConfig = {
521
521
  returning: {
522
522
  fields: [
523
523
  `quarter_published`,
524
+ '"thumbnail": thumbnail.asset->url',
524
525
  ]
525
526
  }
526
527
  }
package/src/index.d.ts CHANGED
@@ -94,8 +94,11 @@ import {
94
94
  fetchUserChallengeProgress,
95
95
  fetchUserLikes,
96
96
  fetchUserPermissionsData,
97
+ fetchUserPracticeMeta,
98
+ fetchUserPractices,
97
99
  likeComment,
98
100
  likePlaylist,
101
+ logUserPractice,
99
102
  openComment,
100
103
  pinPlaylist,
101
104
  playback,
@@ -179,6 +182,11 @@ import {
179
182
  jumpToContinueContent
180
183
  } from './services/sanity.js';
181
184
 
185
+ import {
186
+ blockUser,
187
+ unblockUser
188
+ } from './services/user/management.js';
189
+
182
190
  import {
183
191
  fetchUserPermissions,
184
192
  reset
@@ -190,7 +198,17 @@ import {
190
198
  } from './services/user/sessions.js';
191
199
 
192
200
  import {
193
- getUserActivityStats
201
+ deletePracticeSession,
202
+ getPracticeSessions,
203
+ getRecentActivity,
204
+ getUserMonthlyStats,
205
+ getUserPractices,
206
+ getUserWeeklyStats,
207
+ recordUserPractice,
208
+ removeUserPractice,
209
+ restorePracticeSession,
210
+ restoreUserPractice,
211
+ updateUserPractice
194
212
  } from './services/userActivity.js';
195
213
 
196
214
  declare module 'musora-content-services' {
@@ -199,6 +217,7 @@ declare module 'musora-content-services' {
199
217
  assignModeratorToComment,
200
218
  assignmentStatusCompleted,
201
219
  assignmentStatusReset,
220
+ blockUser,
202
221
  closeComment,
203
222
  contentStatusCompleted,
204
223
  contentStatusReset,
@@ -209,6 +228,7 @@ declare module 'musora-content-services' {
209
228
  deletePlaylist,
210
229
  deletePlaylistItem,
211
230
  deletePlaylistLike,
231
+ deletePracticeSession,
212
232
  duplicatePlaylist,
213
233
  editComment,
214
234
  fetchAll,
@@ -288,6 +308,8 @@ declare module 'musora-content-services' {
288
308
  fetchUserPermissions,
289
309
  fetchUserPermissionsData,
290
310
  fetchUserPlaylists,
311
+ fetchUserPracticeMeta,
312
+ fetchUserPractices,
291
313
  getActiveDiscussions,
292
314
  getAllCompleted,
293
315
  getAllStarted,
@@ -295,17 +317,21 @@ declare module 'musora-content-services' {
295
317
  getContentRows,
296
318
  getLessonContentRows,
297
319
  getNewAndUpcoming,
320
+ getPracticeSessions,
298
321
  getProgressPercentage,
299
322
  getProgressPercentageByIds,
300
323
  getProgressState,
301
324
  getProgressStateByIds,
302
325
  getRecent,
326
+ getRecentActivity,
303
327
  getRecommendedForYou,
304
328
  getResumeTimeSeconds,
305
329
  getScheduleContentRows,
306
330
  getSortOrder,
307
331
  getTabResults,
308
- getUserActivityStats,
332
+ getUserMonthlyStats,
333
+ getUserPractices,
334
+ getUserWeeklyStats,
309
335
  globalConfig,
310
336
  initializeService,
311
337
  isContentLiked,
@@ -313,6 +339,7 @@ declare module 'musora-content-services' {
313
339
  likeComment,
314
340
  likeContent,
315
341
  likePlaylist,
342
+ logUserPractice,
316
343
  login,
317
344
  logout,
318
345
  openComment,
@@ -334,17 +361,23 @@ declare module 'musora-content-services' {
334
361
  rankCategories,
335
362
  rankItems,
336
363
  recommendations,
364
+ recordUserPractice,
337
365
  recordWatchSession,
366
+ removeUserPractice,
338
367
  replyToComment,
339
368
  reportPlaylist,
340
369
  reset,
370
+ restorePracticeSession,
371
+ restoreUserPractice,
341
372
  setStudentViewForUser,
342
373
  unassignModeratorToComment,
374
+ unblockUser,
343
375
  unlikeComment,
344
376
  unlikeContent,
345
377
  unpinPlaylist,
346
378
  updatePlaylist,
347
379
  updatePlaylistItem,
380
+ updateUserPractice,
348
381
  verifyLocalDataContext,
349
382
  }
350
383
  }
package/src/index.js CHANGED
@@ -94,8 +94,11 @@ import {
94
94
  fetchUserChallengeProgress,
95
95
  fetchUserLikes,
96
96
  fetchUserPermissionsData,
97
+ fetchUserPracticeMeta,
98
+ fetchUserPractices,
97
99
  likeComment,
98
100
  likePlaylist,
101
+ logUserPractice,
99
102
  openComment,
100
103
  pinPlaylist,
101
104
  playback,
@@ -179,6 +182,11 @@ import {
179
182
  jumpToContinueContent
180
183
  } from './services/sanity.js';
181
184
 
185
+ import {
186
+ blockUser,
187
+ unblockUser
188
+ } from './services/user/management.js';
189
+
182
190
  import {
183
191
  fetchUserPermissions,
184
192
  reset
@@ -190,7 +198,17 @@ import {
190
198
  } from './services/user/sessions.js';
191
199
 
192
200
  import {
193
- getUserActivityStats
201
+ deletePracticeSession,
202
+ getPracticeSessions,
203
+ getRecentActivity,
204
+ getUserMonthlyStats,
205
+ getUserPractices,
206
+ getUserWeeklyStats,
207
+ recordUserPractice,
208
+ removeUserPractice,
209
+ restorePracticeSession,
210
+ restoreUserPractice,
211
+ updateUserPractice
194
212
  } from './services/userActivity.js';
195
213
 
196
214
  export {
@@ -198,6 +216,7 @@ export {
198
216
  assignModeratorToComment,
199
217
  assignmentStatusCompleted,
200
218
  assignmentStatusReset,
219
+ blockUser,
201
220
  closeComment,
202
221
  contentStatusCompleted,
203
222
  contentStatusReset,
@@ -208,6 +227,7 @@ export {
208
227
  deletePlaylist,
209
228
  deletePlaylistItem,
210
229
  deletePlaylistLike,
230
+ deletePracticeSession,
211
231
  duplicatePlaylist,
212
232
  editComment,
213
233
  fetchAll,
@@ -287,6 +307,8 @@ export {
287
307
  fetchUserPermissions,
288
308
  fetchUserPermissionsData,
289
309
  fetchUserPlaylists,
310
+ fetchUserPracticeMeta,
311
+ fetchUserPractices,
290
312
  getActiveDiscussions,
291
313
  getAllCompleted,
292
314
  getAllStarted,
@@ -294,17 +316,21 @@ export {
294
316
  getContentRows,
295
317
  getLessonContentRows,
296
318
  getNewAndUpcoming,
319
+ getPracticeSessions,
297
320
  getProgressPercentage,
298
321
  getProgressPercentageByIds,
299
322
  getProgressState,
300
323
  getProgressStateByIds,
301
324
  getRecent,
325
+ getRecentActivity,
302
326
  getRecommendedForYou,
303
327
  getResumeTimeSeconds,
304
328
  getScheduleContentRows,
305
329
  getSortOrder,
306
330
  getTabResults,
307
- getUserActivityStats,
331
+ getUserMonthlyStats,
332
+ getUserPractices,
333
+ getUserWeeklyStats,
308
334
  globalConfig,
309
335
  initializeService,
310
336
  isContentLiked,
@@ -312,6 +338,7 @@ export {
312
338
  likeComment,
313
339
  likeContent,
314
340
  likePlaylist,
341
+ logUserPractice,
315
342
  login,
316
343
  logout,
317
344
  openComment,
@@ -333,16 +360,22 @@ export {
333
360
  rankCategories,
334
361
  rankItems,
335
362
  recommendations,
363
+ recordUserPractice,
336
364
  recordWatchSession,
365
+ removeUserPractice,
337
366
  replyToComment,
338
367
  reportPlaylist,
339
368
  reset,
369
+ restorePracticeSession,
370
+ restoreUserPractice,
340
371
  setStudentViewForUser,
341
372
  unassignModeratorToComment,
373
+ unblockUser,
342
374
  unlikeComment,
343
375
  unlikeContent,
344
376
  unpinPlaylist,
345
377
  updatePlaylist,
346
378
  updatePlaylistItem,
379
+ updateUserPractice,
347
380
  verifyLocalDataContext,
348
381
  };
File without changes
@@ -10,6 +10,7 @@ const excludeFromGeneratedIndex = []
10
10
  //These constants need to match MWP UserDataVersionKeyEnum enum
11
11
  export const ContentLikesVersionKey = 0
12
12
  export const ContentProgressVersionKey = 1
13
+ export const UserActivityVersionKey = 2
13
14
 
14
15
  let cache = null
15
16
 
@@ -101,8 +102,10 @@ export class DataContext {
101
102
 
102
103
  clearCache() {
103
104
  this.clearContext()
104
- cache.removeItem(this.localStorageKey)
105
- cache.removeItem(this.localStorageLastUpdatedKey)
105
+ if (cache) {
106
+ cache.removeItem(this.localStorageKey)
107
+ cache.removeItem(this.localStorageLastUpdatedKey)
108
+ }
106
109
  }
107
110
 
108
111
  clearContext() {
@@ -134,4 +137,14 @@ export class DataContext {
134
137
  version() {
135
138
  return this.context?.version ?? -1
136
139
  }
140
+ async updateLocal(localUpdateFunction) {
141
+ await this.ensureLocalContextLoaded()
142
+ if (this.context) {
143
+ const res = await localUpdateFunction(this.context)
144
+ if (this.context) this.context.version++
145
+ let data = JSON.stringify(this.context)
146
+ cache.setItem(this.localStorageKey, data)
147
+ this.setLastUpdatedTime()
148
+ }
149
+ }
137
150
  }
@@ -1176,6 +1176,66 @@ export async function editComment(commentId, comment) {
1176
1176
  return await patchDataHandler(url, data)
1177
1177
  }
1178
1178
 
1179
+ export async function fetchUserPractices(currentVersion) {
1180
+ const url = `/api/user/practices/v1/practices`
1181
+ const response = await fetchDataHandler(url, currentVersion)
1182
+ const { data, version } = response;
1183
+ const userPractices = data;
1184
+
1185
+ let formattedPractices = userPractices.reduce((acc, practice) => {
1186
+ // Initialize the array if the day does not exist
1187
+ if (!acc[practice.day]) {
1188
+ acc[practice.day] = [];
1189
+ }
1190
+
1191
+ // Push the practice entry into the array
1192
+ acc[practice.day].push({ id:practice.id, duration_seconds: practice.duration_seconds });
1193
+
1194
+ return acc;
1195
+ }, {});
1196
+
1197
+ let json = {
1198
+ data: {
1199
+ practices: formattedPractices
1200
+ },
1201
+ version: version
1202
+ };
1203
+
1204
+ return json;
1205
+ }
1206
+
1207
+ export async function logUserPractice(practiceDetails) {
1208
+ const url = `/api/user/practices/v1/practices`
1209
+ return await fetchHandler(url, 'POST', null, practiceDetails)
1210
+ }
1211
+ export async function fetchUserPracticeMeta(practiceIds) {
1212
+ if(practiceIds.length == 0)
1213
+ {
1214
+ return [];
1215
+ }
1216
+ let idsString = '';
1217
+ if (practiceIds && practiceIds.length > 0) {
1218
+ idsString = '?';
1219
+ practiceIds.forEach((id, index) => {
1220
+ idsString += `practice_ids[]=${id}${index < practiceIds.length - 1 ? '&' : ''}`;
1221
+ });
1222
+ }
1223
+ const url = `/api/user/practices/v1/practices${idsString}`
1224
+ return await fetchHandler(url, 'GET', null)
1225
+ }
1226
+
1227
+ function fetchAbsolute(url, params) {
1228
+ if (globalConfig.railcontentConfig.authToken) {
1229
+ params.headers['Authorization'] = `Bearer ${globalConfig.railcontentConfig.authToken}`
1230
+ }
1231
+
1232
+ if (globalConfig.railcontentConfig.baseUrl) {
1233
+ if (url.startsWith('/')) {
1234
+ return fetch(globalConfig.railcontentConfig.baseUrl + url, params)
1235
+ }
1236
+ }
1237
+ return fetch(url, params)
1238
+ }
1179
1239
  export async function fetchHandler(url, method = 'get', dataVersion = null, body = null) {
1180
1240
  return fetchJSONHandler(
1181
1241
  url,
@@ -353,6 +353,7 @@ export async function fetchNewReleases(
353
353
  "id": railcontent_id,
354
354
  title,
355
355
  "image": thumbnail.asset->url,
356
+ "thumbnail": thumbnail.asset->url,
356
357
  ${artistOrInstructorName()},
357
358
  "artists": instructor[]->name,
358
359
  difficulty,
@@ -396,6 +397,7 @@ export async function fetchUpcomingEvents(brand, { page = 1, limit = 10 } = {})
396
397
  "id": railcontent_id,
397
398
  title,
398
399
  "image": thumbnail.asset->url,
400
+ "thumbnail": thumbnail.asset->url,
399
401
  ${artistOrInstructorName()},
400
402
  "artists": instructor[]->name,
401
403
  difficulty,
@@ -444,6 +446,7 @@ export async function fetchScheduledReleases(brand, { page = 1, limit = 10 }) {
444
446
  "id": railcontent_id,
445
447
  title,
446
448
  "image": thumbnail.asset->url,
449
+ "thumbnail": thumbnail.asset->url,
447
450
  ${artistOrInstructorName()},
448
451
  "artists": instructor[]->name,
449
452
  difficulty,
@@ -1071,7 +1074,7 @@ export async function fetchMethod(brand, slug) {
1071
1074
  child_count,
1072
1075
  difficulty,
1073
1076
  difficulty_string,
1074
- "thumbnail_url": thumbnail.asset->url,
1077
+ "thumbnail": thumbnail.asset->url,
1075
1078
  "instructor": instructor[]->{name},
1076
1079
  title,
1077
1080
  "type": _type,
@@ -1097,7 +1100,7 @@ export async function fetchMethodChildren(railcontentId) {
1097
1100
  "child_count":coalesce(count(child[${childrenFilter}]->), 0),
1098
1101
  "id": railcontent_id,
1099
1102
  "description": ${descriptionField},
1100
- "thumbnail_url": thumbnail.asset->url,
1103
+ "thumbnail": thumbnail.asset->url,
1101
1104
  title,
1102
1105
  xp,
1103
1106
  total_xp,
@@ -1261,7 +1264,7 @@ export async function fetchLessonContent(railContentId) {
1261
1264
  railcontent_id,
1262
1265
  "id":railcontent_id,
1263
1266
  slug, artist->,
1264
- "thumbnail_url":thumbnail.asset->url,
1267
+ "thumbnail":thumbnail.asset->url,
1265
1268
  "url": web_url_path,
1266
1269
  soundslice_slug,
1267
1270
  "description": description[0].children[0].text,
@@ -1406,7 +1409,7 @@ export async function fetchRelatedLessons(railContentId, brand) {
1406
1409
  ).buildFilter()
1407
1410
  const filterNeighbouringSiblings = await new FilterBuilder(`references(^._id)`).buildFilter()
1408
1411
  const childrenFilter = await new FilterBuilder(``, { isChildrenFilter: true }).buildFilter()
1409
- const queryFields = `_id, "id":railcontent_id, published_on, "instructor": instructor[0]->name, title, "thumbnail_url":thumbnail.asset->url, length_in_seconds, web_url_path, "type": _type, difficulty, difficulty_string, railcontent_id, artist->,"permission_id": permission[]->railcontent_id,_type, "genre": genre[]->name`
1412
+ const queryFields = `_id, "id":railcontent_id, published_on, "instructor": instructor[0]->name, title, "thumbnail":thumbnail.asset->url, length_in_seconds, web_url_path, "type": _type, difficulty, difficulty_string, railcontent_id, artist->,"permission_id": permission[]->railcontent_id,_type, "genre": genre[]->name`
1410
1413
  const queryFieldsWithSort = queryFields + ', sort'
1411
1414
  const query = `*[railcontent_id == ${railContentId} && brand == "${brand}" && (!defined(permission) || references(*[_type=='permission']._id))]{
1412
1415
  _type, parent_type, railcontent_id,
@@ -1697,11 +1700,11 @@ export async function fetchArtistLessons(
1697
1700
  const query = `{
1698
1701
  "entity":
1699
1702
  *[_type == 'artist' && name == '${name}']
1700
- {'type': _type, name, 'thumbnail_url':thumbnail_url.asset->url,
1703
+ {'type': _type, name, 'thumbnail':thumbnail_url.asset->url,
1701
1704
  'lessons_count': count(*[${addType} brand == '${brand}' && references(^._id)]),
1702
1705
  'lessons': *[${addType} brand == '${brand}' && references(^._id) && (status in ['published'] || (status == 'scheduled' && defined(published_on) && published_on >= '${now}')) ${searchFilter} ${includedFieldsFilter} ${progressFilter}]{${fieldsString}}
1703
1706
  [${start}...${end}]}
1704
- |order(${sortOrder})
1707
+ |order(${sortOrder})f
1705
1708
  }`
1706
1709
  return fetchSanity(query, true)
1707
1710
  }
@@ -1751,7 +1754,7 @@ export async function fetchGenreLessons(
1751
1754
  const query = `{
1752
1755
  "entity":
1753
1756
  *[_type == 'genre' && name == '${name}']
1754
- {'type': _type, name, 'thumbnail_url':thumbnail_url.asset->url,
1757
+ {'type': _type, name, 'thumbnail':thumbnail_url.asset->url,
1755
1758
  'lessons_count': count(*[${addType} brand == '${brand}' && references(^._id)]),
1756
1759
  'lessons': *[${addType} brand == '${brand}' && references(^._id) && (status in ['published'] || (status == 'scheduled' && defined(published_on) && published_on >= '${now}')) ${searchFilter} ${includedFieldsFilter} ${progressFilter}]{${fieldsString}}
1757
1760
  [${start}...${end}]}
@@ -2,31 +2,385 @@
2
2
  * @module User-Activity
3
3
  */
4
4
 
5
- import {fetchHandler} from "./railcontent";
6
- const userActivityStats = {
7
- user: {
8
- id: 1,
9
- fullName: 'John Doe',
10
- profilePictureUrl: 'https://i.pravatar.cc/300',
5
+ import {fetchUserPractices, logUserPractice, fetchUserPracticeMeta, fetchHandler} from './railcontent'
6
+ import { DataContext, UserActivityVersionKey } from './dataContext.js'
7
+ import {fetchByRailContentIds} from "./sanity";
8
+ import {lessonTypesMapping} from "../contentTypeConfig";
9
+
10
+ const recentActivity = {
11
+ data: [
12
+ { id: 5,title: '3 Easy Classical Songs For Beginners', action: 'Comment', thumbnail: 'https://s3-alpha-sig.figma.com/img/22c3/1eb9/d819a2c6727b78deb2fcf051349a0667?Expires=1743984000&Key-Pair-Id=APKAQ4GOSFWCW27IBOMQ&Signature=RGusttOtnWP80iL68~l4XqFrQNe-kOTnUSLtMXQwGJNLfWNze6~fMZ15LsH4IYpz85mJKgILgCegi1sEPF6loBJpKYF9AH5HC2Zz1fenM1T3V387dgb4FifujKtR-DJuBpknPNFvZ9wC9ebCfoXhc1HLe7BJUDSr8gJssiqsimQPU-9TanAOJAFTaxOfvQ0-WEW1VIdCWLX0OOjn1qs~jZTeOGcxy3b~OD1CxiUmwp5tA3lBgqya18Mf8kmcfHjByNmYysd2FwV5tS19dCnmzbE9hwvLwMOnQ38SYOKhxCLsteDRBIxLNjTGJFDUm4wF~089Kkd1zA8pn8-kVfYtwg__', summary: 'Just completed the advanced groove lesson! I’m finally feeling more confident with my fills. Thanks for the clear explanations and practice tips! ', date: '2025-03-25 10:09:48' },
13
+ { id:4, title: 'Piano Man by Billy Joel', action: 'Play', thumbnail:'https://s3-alpha-sig.figma.com/img/fab0/fffe/3cb08e0256a02a18ac37b2db1c2b4a1f?Expires=1743984000&Key-Pair-Id=APKAQ4GOSFWCW27IBOMQ&Signature=smjF95zM9b469lFJ8F6mmjpRs4jPlDcBnflDXc9G~Go1ab87fnWpmg-megUoLmSkqu-Rf3s8P5buzqNP-YnqQl413g3grqNURTIwHRaI2HplN1OXL~OBLU9jHjgQxZmMI6VfSLs301W~cU9NHmMLYRr38r9mVQM6ippSMawj7MFSywiPhvHSvAIXt65o6HlNszhq1n5eZmxVdiL7tjifSja~fGVtHDsX0wuD3L-KAN5TIqywAgRzzFFMHw3yYxiOHajbRSi0s0LJNIHRF4iBJFFZWVXY5vdNX5YKmAmblnbfYIK3GrwJiaVEv6rGzOo~nN4Zh-FKJWvjyPd2oBmfbg__', date: '2025-03-25 10:04:48' },
14
+ { id:3, title: 'General Piano Discussion', action: 'Post', thumbnail: 'https://s3-alpha-sig.figma.com/img/22c3/1eb9/d819a2c6727b78deb2fcf051349a0667?Expires=1743984000&Key-Pair-Id=APKAQ4GOSFWCW27IBOMQ&Signature=RGusttOtnWP80iL68~l4XqFrQNe-kOTnUSLtMXQwGJNLfWNze6~fMZ15LsH4IYpz85mJKgILgCegi1sEPF6loBJpKYF9AH5HC2Zz1fenM1T3V387dgb4FifujKtR-DJuBpknPNFvZ9wC9ebCfoXhc1HLe7BJUDSr8gJssiqsimQPU-9TanAOJAFTaxOfvQ0-WEW1VIdCWLX0OOjn1qs~jZTeOGcxy3b~OD1CxiUmwp5tA3lBgqya18Mf8kmcfHjByNmYysd2FwV5tS19dCnmzbE9hwvLwMOnQ38SYOKhxCLsteDRBIxLNjTGJFDUm4wF~089Kkd1zA8pn8-kVfYtwg__', summary: 'Just completed the advanced groove lesson! I’m finally feeling more confident with my fills. Thanks for the clear explanations and practice tips! ', date: '2025-03-25 09:49:48' },
15
+ { id:2, title: 'Welcome To Guitareo', action: 'Complete', thumbnail: 'https://s3-alpha-sig.figma.com/img/22c3/1eb9/d819a2c6727b78deb2fcf051349a0667?Expires=1743984000&Key-Pair-Id=APKAQ4GOSFWCW27IBOMQ&Signature=RGusttOtnWP80iL68~l4XqFrQNe-kOTnUSLtMXQwGJNLfWNze6~fMZ15LsH4IYpz85mJKgILgCegi1sEPF6loBJpKYF9AH5HC2Zz1fenM1T3V387dgb4FifujKtR-DJuBpknPNFvZ9wC9ebCfoXhc1HLe7BJUDSr8gJssiqsimQPU-9TanAOJAFTaxOfvQ0-WEW1VIdCWLX0OOjn1qs~jZTeOGcxy3b~OD1CxiUmwp5tA3lBgqya18Mf8kmcfHjByNmYysd2FwV5tS19dCnmzbE9hwvLwMOnQ38SYOKhxCLsteDRBIxLNjTGJFDUm4wF~089Kkd1zA8pn8-kVfYtwg__',date: '2025-03-25 09:34:48' },
16
+ { id:1, title: 'Welcome To Guitareo', action: 'Start', thumbnail: 'https://s3-alpha-sig.figma.com/img/22c3/1eb9/d819a2c6727b78deb2fcf051349a0667?Expires=1743984000&Key-Pair-Id=APKAQ4GOSFWCW27IBOMQ&Signature=RGusttOtnWP80iL68~l4XqFrQNe-kOTnUSLtMXQwGJNLfWNze6~fMZ15LsH4IYpz85mJKgILgCegi1sEPF6loBJpKYF9AH5HC2Zz1fenM1T3V387dgb4FifujKtR-DJuBpknPNFvZ9wC9ebCfoXhc1HLe7BJUDSr8gJssiqsimQPU-9TanAOJAFTaxOfvQ0-WEW1VIdCWLX0OOjn1qs~jZTeOGcxy3b~OD1CxiUmwp5tA3lBgqya18Mf8kmcfHjByNmYysd2FwV5tS19dCnmzbE9hwvLwMOnQ38SYOKhxCLsteDRBIxLNjTGJFDUm4wF~089Kkd1zA8pn8-kVfYtwg__',date: '2025-03-25 09:04:48' },
17
+ ],
18
+ }
19
+
20
+ const DATA_KEY_PRACTICES = 'practices'
21
+ const DATA_KEY_LAST_UPDATED_TIME = 'u'
22
+
23
+ const DAYS = ['M', 'T', 'W', 'T', 'F', 'S', 'S']
24
+
25
+ export let userActivityContext = new DataContext(UserActivityVersionKey, fetchUserPractices)
26
+
27
+ // Get Weekly Stats
28
+ export async function getUserWeeklyStats() {
29
+ let data = await userActivityContext.getData()
30
+
31
+ let practices = data?.[DATA_KEY_PRACTICES] ?? {}
32
+
33
+ let today = new Date()
34
+ let startOfWeek = getMonday(today) // Get last Monday
35
+ let dailyStats = []
36
+ const todayKey = today.toISOString().split('T')[0]
37
+ for (let i = 0; i < 7; i++) {
38
+ let day = new Date(startOfWeek)
39
+ day.setDate(startOfWeek.getDate() + i)
40
+ let dayKey = day.toISOString().split('T')[0]
41
+
42
+ let dayActivity = practices[dayKey] ?? null
43
+ let isActive = dayKey === todayKey
44
+ let type = (dayActivity !== null ? 'tracked' : (isActive ? 'active' : 'none'))
45
+ dailyStats.push({ key: i, label: DAYS[i], isActive, inStreak: dayActivity !== null, type })
46
+ }
47
+
48
+ let { streakMessage } = getStreaksAndMessage(practices);
49
+
50
+ return { data: { dailyActiveStats: dailyStats, streakMessage } }
51
+ }
52
+
53
+ export async function getUserMonthlyStats(year = new Date().getFullYear(), month = new Date().getMonth(), day = 1) {
54
+ let data = await userActivityContext.getData()
55
+ let practices = data?.[DATA_KEY_PRACTICES] ?? {}
56
+ // Get the first day of the specified month and the number of days in that month
57
+ let firstDayOfMonth = new Date(year, month, 1)
58
+ let today = new Date()
59
+
60
+ let startOfMonth = getMonday(firstDayOfMonth)
61
+ let endOfMonth = new Date(year, month + 1, 0)
62
+ while (endOfMonth.getDay() !== 0) {
63
+ endOfMonth.setDate(endOfMonth.getDate() + 1)
64
+ }
65
+
66
+ let daysInMonth = Math.ceil((endOfMonth - startOfMonth) / (1000 * 60 * 60 * 24)) + 1;
67
+
68
+ let dailyStats = []
69
+ let practiceDuration = 0
70
+ let daysPracticed = 0
71
+ let weeklyStats = {}
72
+
73
+ for (let i = 0; i < daysInMonth; i++) {
74
+ let day = new Date(startOfMonth)
75
+ day.setDate(startOfMonth.getDate() + i)
76
+ let dayKey = day.toISOString().split('T')[0]
77
+
78
+ // Check if the user has activity for the day, default to 0 if undefined
79
+ let dayActivity = practices[dayKey] ?? null
80
+ let weekKey = getWeekNumber(day)
81
+
82
+ if (!weeklyStats[weekKey]) {
83
+ weeklyStats[weekKey] = { key: weekKey, inStreak: false };
84
+ }
85
+
86
+ if (dayActivity) {
87
+ practiceDuration += dayActivity.reduce((sum, entry) => sum + entry.duration_seconds, 0)
88
+ daysPracticed++;
89
+ }
90
+ let isActive = dayKey === today.toISOString().split('T')[0]
91
+ let type = (dayActivity !== null ? 'tracked' : (isActive ? 'active' : 'none'))
92
+
93
+ let isInStreak = dayActivity !== null;
94
+ if (isInStreak) {
95
+ weeklyStats[weekKey].inStreak = true;
96
+ }
97
+
98
+ dailyStats.push({
99
+ key: i,
100
+ label: dayKey,
101
+ isActive,
102
+ inStreak: dayActivity !== null,
103
+ type,
104
+ })
105
+ }
106
+
107
+ let filteredPractices = Object.keys(practices)
108
+ .filter((date) => new Date(date) <= endOfMonth)
109
+ .reduce((obj, key) => {
110
+ obj[key] = practices[key]
111
+ return obj
112
+ }, {})
113
+
114
+ let { currentDailyStreak, currentWeeklyStreak } = calculateStreaks(filteredPractices);
115
+
116
+ return { data: {
117
+ dailyActiveStats: dailyStats,
118
+ weeklyActiveStats: Object.values(weeklyStats),
119
+ practiceDuration,
120
+ currentDailyStreak,
121
+ currentWeeklyStreak,
122
+ daysPracticed,
123
+ }
124
+ }
125
+ }
126
+
127
+ export async function getUserPractices() {
128
+ let data = await userActivityContext.getData()
129
+ return data?.[DATA_KEY_PRACTICES] ?? []
130
+ }
131
+
132
+ export async function recordUserPractice(practiceDetails) {
133
+ practiceDetails.auto = 0;
134
+ if (practiceDetails.content_id) {
135
+ practiceDetails.auto = 1;
136
+ }
137
+
138
+ await userActivityContext.update(
139
+ async function (localContext) {
140
+ let userData = localContext.data ?? { [DATA_KEY_PRACTICES]: {} };
141
+ localContext.data = userData;
142
+ },
143
+ async function () {
144
+ const response = await logUserPractice(practiceDetails);
145
+ if (response) {
146
+ await userActivityContext.updateLocal(async function (localContext) {
147
+ const newPractices = response.data ?? []
148
+ newPractices.forEach(newPractice => {
149
+ const { date } = newPractice;
150
+ if (!localContext.data[DATA_KEY_PRACTICES][date]) {
151
+ localContext.data[DATA_KEY_PRACTICES][date] = [];
152
+ }
153
+ localContext.data[DATA_KEY_PRACTICES][date][DATA_KEY_LAST_UPDATED_TIME] = Math.round(new Date().getTime() / 1000)
154
+ localContext.data[DATA_KEY_PRACTICES][date].push({
155
+ id: newPractice.id,
156
+ duration_seconds: newPractice.duration_seconds // Add the new practice for this date
157
+ });
158
+ });
159
+ });
160
+ }
161
+ return response;
162
+ }
163
+ );
164
+ }
165
+
166
+ export async function updateUserPractice(id, practiceDetails) {
167
+ const url = `/api/user/practices/v1/practices/${id}`
168
+ return await fetchHandler(url, 'PUT', null, practiceDetails)
169
+ }
170
+
171
+ export async function removeUserPractice(id) {
172
+ let url = `/api/user/practices/v1/practices${buildQueryString([id])}`;
173
+ await userActivityContext.update(
174
+ async function (localContext) {
175
+ if (localContext.data?.[DATA_KEY_PRACTICES]) {
176
+ Object.keys(localContext.data[DATA_KEY_PRACTICES]).forEach(date => {
177
+ localContext.data[DATA_KEY_PRACTICES][date] = localContext.data[DATA_KEY_PRACTICES][date].filter(
178
+ practice => practice.id !== id
179
+ );
180
+ });
181
+ }
11
182
  },
12
- dailyActiveStats: [
13
- { label: 'M', isActive: false, inStreak: false, type: 'none' },
14
- { label: 'T', isActive: false, inStreak: false, type: 'none' },
15
- { label: 'W', isActive: true, inStreak: true, type: 'tracked' },
16
- { label: 'T', isActive: true, inStreak: true, type: 'tracked' },
17
- { label: 'F', isActive: false, inStreak: false, type: 'none' },
18
- { label: 'S', isActive: true, inStreak: false, type: 'active' },
19
- { label: 'S', isActive: false, inStreak: false, type: 'none' }
20
- ],
21
- currentDailyStreak: 3,
22
- currentWeeklyStreak: 2,
23
- streakMessage: "That's 8 weeks in a row! Way to keep your streak going.",
24
- };
25
-
26
- export async function getUserActivityStats(brand) {
27
- return userActivityStats;
28
- //return await fetchHandler(`/api/user-activity/v1/stats`);
183
+ async function () {
184
+ return await fetchHandler(url, 'delete');
185
+ }
186
+ );
187
+ }
188
+
189
+ export async function restoreUserPractice(id) {
190
+ let url = `/api/user/practices/v1/practices/restore${buildQueryString([id])}`;
191
+ const response = await fetchHandler(url, 'put');
192
+ if (response?.data) {
193
+ await userActivityContext.updateLocal(async function (localContext) {
194
+ const restoredPractice = response.data;
195
+ const { date } = restoredPractice;
196
+ if (!localContext.data[DATA_KEY_PRACTICES][date]) {
197
+ localContext.data[DATA_KEY_PRACTICES][date] = [];
198
+ }
199
+ localContext.data[DATA_KEY_PRACTICES][date].push({
200
+ id: restoredPractice.id,
201
+ duration_seconds: restoredPractice.duration_seconds,
202
+ });
203
+ });
204
+ }
205
+ return response;
29
206
  }
30
207
 
208
+ export async function deletePracticeSession(day) {
209
+ const userPracticesIds = await getUserPracticeIds(day);
210
+ if (!userPracticesIds.length) return [];
211
+
212
+ const url = `/api/user/practices/v1/practices${buildQueryString(userPracticesIds)}`;
213
+ return await fetchHandler(url, 'delete', null);
214
+ }
215
+
216
+ export async function restorePracticeSession(date) {
217
+ const url = `/api/user/practices/v1/practices/restore?date=${date}`;
218
+ return await fetchHandler(url, 'put', null);
219
+ }
220
+
221
+ export async function getPracticeSessions(day) {
222
+ const userPracticesIds = await getUserPracticeIds(day);
223
+ if (!userPracticesIds.length) return { data: { practices: [], practiceDuration: 0 } };
224
+
225
+ const meta = await fetchUserPracticeMeta(userPracticesIds);
226
+ const practiceDuration = meta.data.reduce((total, practice) => total + (practice.duration_seconds || 0), 0);
227
+ const contentIds = meta.data.map(practice => practice.content_id).filter(id => id !== null);
228
+
229
+ const contents = await fetchByRailContentIds(contentIds);
230
+ const getFormattedType = (type) => {
231
+ for (const [key, values] of Object.entries(lessonTypesMapping)) {
232
+ if (values.includes(type)) {
233
+ return key.replace(/\b\w/g, char => char.toUpperCase());
234
+ }
235
+ }
236
+ return null;
237
+ };
238
+
239
+ const formattedMeta = meta.data.map(practice => {
240
+ const content = contents.find(c => c.id === practice.content_id) || {};
241
+ return {
242
+ id: practice.id,
243
+ auto: practice.auto,
244
+ thumbnail: (practice.content_id)? content.thumbnail : '',
245
+ duration: practice.duration_seconds || 0,
246
+ content_url: content.url || null,
247
+ title: (practice.content_id)? content.title : practice.title,
248
+ category_id: practice.category_id || null,
249
+ instrument_id: practice.instrument_id || null,
250
+ content_type: getFormattedType(content.type || ''),
251
+ content_id: practice.content_id || null,
252
+ content_brand: content.brand || null,
253
+ };
254
+ });
255
+ return { data: { practices: formattedMeta, practiceDuration } };
256
+ }
257
+
258
+
259
+ export async function getRecentActivity() {
260
+ return { data: recentActivity };
261
+ }
262
+
263
+ function getStreaksAndMessage(practices)
264
+ {
265
+ let { currentDailyStreak, currentWeeklyStreak } = calculateStreaks(practices)
266
+
267
+ let streakMessage = currentWeeklyStreak > 1
268
+ ? `That's ${currentWeeklyStreak} weeks in a row! Keep going!`
269
+ : `Nice! You have a ${currentDailyStreak} day streak! Way to keep it going!`
270
+ return {
271
+ currentDailyStreak,
272
+ currentWeeklyStreak,
273
+ streakMessage,
274
+ }
275
+ }
276
+
277
+ async function getUserPracticeIds(day = new Date().toISOString().split('T')[0]) {
278
+ let data = await userActivityContext.getData();
279
+ let practices = data?.[DATA_KEY_PRACTICES] ?? {};
280
+ let userPracticesIds = [];
281
+ Object.keys(practices).forEach(date => {
282
+ if (date === day) {
283
+ practices[date].forEach(practice => userPracticesIds.push(practice.id));
284
+ }
285
+ });
286
+
287
+ return userPracticesIds;
288
+ }
289
+
290
+ function buildQueryString(ids, paramName = 'practice_ids') {
291
+ if (!ids.length) return '';
292
+ return '?' + ids.map(id => `${paramName}[]=${id}`).join('&');
293
+ }
294
+
295
+
296
+ // Helper: Get start of the week (Monday)
297
+ function getMonday(d) {
298
+ d = new Date(d)
299
+ var day = d.getDay(),
300
+ diff = d.getDate() - day + (day == 0 ? -6 : 1) // adjust when day is sunday
301
+ return new Date(d.setDate(diff))
302
+ }
303
+
304
+ // Helper: Get the week number
305
+ function getWeekNumber(date) {
306
+ let startOfYear = new Date(date.getFullYear(), 0, 1)
307
+ let diff = date - startOfYear
308
+ let oneWeekMs = 7 * 24 * 60 * 60 * 1000
309
+ return Math.ceil((diff / oneWeekMs) + startOfYear.getDay() / 7)
310
+ }
311
+
312
+ // Helper: function to check if two dates are consecutive days
313
+ function isNextDay(prevDateStr, currentDateStr) {
314
+ let prevDate = new Date(prevDateStr)
315
+ let currentDate = new Date(currentDateStr)
316
+ let diff = (currentDate - prevDate) / (1000 * 60 * 60 * 24)
317
+ return diff === 1
318
+ }
319
+
320
+ // Helper: Calculate streaks
321
+ function calculateStreaks(practices) {
322
+ let currentDailyStreak = 0
323
+ let currentWeeklyStreak = 0
324
+
325
+ let sortedPracticeDays = Object.keys(practices).sort() // Ensure dates are sorted in order
326
+
327
+ if (sortedPracticeDays.length === 0) {
328
+ return { currentDailyStreak: 1, currentWeeklyStreak: 1 }
329
+ }
330
+
331
+ let dailyStreak = 0
332
+ let longestDailyStreak = 0
333
+ let prevDay = null
334
+
335
+ sortedPracticeDays.forEach((dayKey) => {
336
+ if (prevDay === null || isNextDay(prevDay, dayKey)) {
337
+ dailyStreak++
338
+ longestDailyStreak = Math.max(longestDailyStreak, dailyStreak)
339
+ } else {
340
+ dailyStreak = 1 // Reset streak if there's a gap
341
+ }
342
+ prevDay = dayKey
343
+ })
344
+
345
+ currentDailyStreak = dailyStreak
346
+
347
+ // Calculate weekly streaks
348
+ let weeklyStreak = 0
349
+ let prevWeek = null
350
+ let currentWeekActivity = false
351
+
352
+ sortedPracticeDays.forEach((dayKey) => {
353
+ let date = new Date(dayKey)
354
+ let weekNumber = getWeekNumber(date)
355
+
356
+ if (prevWeek === null) {
357
+ prevWeek = weekNumber
358
+ currentWeekActivity = true
359
+ } else if (weekNumber !== prevWeek) {
360
+ // A new week has started
361
+ if (currentWeekActivity) {
362
+ weeklyStreak++
363
+ currentWeekActivity = false
364
+ }
365
+ prevWeek = weekNumber
366
+ }
367
+
368
+ if (practices[dayKey]) {
369
+ currentWeekActivity = true
370
+ }
371
+ })
372
+
373
+ // If the user has activity in the current week, count it
374
+ if (currentWeekActivity) {
375
+ weeklyStreak++
376
+ }
377
+
378
+ currentWeeklyStreak = weeklyStreak
379
+
380
+ return { currentDailyStreak, currentWeeklyStreak }
381
+ }
382
+
383
+
384
+
31
385
 
32
386
 
@@ -5,6 +5,7 @@ import {
5
5
  unlikeContent,
6
6
  } from '../src/services/contentLikes'
7
7
  import { initializeTestService } from './initializeTests'
8
+ import { userActivityContext } from '../src/services/userActivity.js'
8
9
 
9
10
  const railContentModule = require('../src/services/railcontent.js')
10
11
 
@@ -17,6 +18,7 @@ describe('contentLikesDataContext', function () {
17
18
  mock = jest.spyOn(dataContext, 'fetchData')
18
19
  var json = JSON.parse(`{"version":${testVersion},"data":[308516,308515,308514,308518]}`)
19
20
  mock.mockImplementation(() => json)
21
+ dataContext.ensureLocalContextLoaded()
20
22
  })
21
23
 
22
24
  test('contentLiked', async () => {
File without changes
File without changes
File without changes
package/test/log.js CHANGED
File without changes
@@ -0,0 +1,35 @@
1
+ [
2
+ {
3
+ "permission_id": [
4
+ 78,
5
+ 89,
6
+ 91,
7
+ 92,
8
+ 88,
9
+ 90
10
+ ],
11
+ "thumbnail": "https://cdn.sanity.io/images/4032r8py/staging/5f15b20b428c06263fd39599fc310ab00eb05fee-1920x1080.jpg",
12
+ "difficulty_string": "Beginner",
13
+ "url": "/drumeo/quick-tips/how-to-play-drums/415183",
14
+ "lesson_count": null,
15
+ "id": 415183,
16
+ "image": "https://cdn.sanity.io/images/4032r8py/staging/5f15b20b428c06263fd39599fc310ab00eb05fee-1920x1080.jpg",
17
+ "web_url_path": "/drumeo/quick-tips/how-to-play-drums/415183",
18
+ "type": "quick-tips",
19
+ "brand": "drumeo",
20
+ "genre": null,
21
+ "status": "published",
22
+ "xp": 100,
23
+ "railcontent_id": 415183,
24
+ "artist": null,
25
+ "progress_percent": null,
26
+ "child_count": null,
27
+ "sanity_id": "quick-tips_415183",
28
+ "artist_name": "Brandon Toews",
29
+ "title": "How To Play Drums",
30
+ "difficulty": 3,
31
+ "published_on": "2024-12-13T12:00:00.000000Z",
32
+ "length_in_seconds": 576,
33
+ "slug": "how-to-play-drums"
34
+ }
35
+ ]
@@ -0,0 +1,118 @@
1
+ import { initializeTestService } from './initializeTests.js'
2
+ import {getUserMonthlyStats, getUserWeeklyStats, userActivityContext, recordUserPractice, getUserPractices} from '../src/services/userActivity.js'
3
+ import { logUserPractice } from '../src/services/railcontent.js'
4
+ import {fetchByRailContentIds} from "../src";
5
+ import mockData_fetchByRailContentIds_one_content from './mockData/mockData_fetchByRailContentIds_one_content.json';
6
+
7
+ global.fetch = jest.fn()
8
+ let mock = null
9
+ const testVersion = 1
10
+ const DEBUG = true
11
+
12
+ jest.mock('../src/services/railcontent', () => ({
13
+ ...jest.requireActual('../src/services/railcontent'),
14
+ logUserPractice: jest.fn(() => Promise.resolve()),
15
+ fetchUserPermissionsData: jest.fn(() => ({ permissions: [78, 91, 92], isAdmin: false }))
16
+ }))
17
+
18
+ jest.mock('../src/services/sanity', () => ({
19
+ ...jest.requireActual('../src/services/sanity'),
20
+ fetchByRailContentIds: jest.fn(() => Promise.resolve(mockData_fetchByRailContentIds_one_content)),
21
+ }))
22
+ describe('User Activity API Tests', function () {
23
+ beforeEach(() => {
24
+ initializeTestService()
25
+ mock = jest.spyOn(userActivityContext, 'fetchData')
26
+ var json = JSON.parse(
27
+ `{
28
+ "version": ${testVersion},
29
+ "config": { "key": 1, "enabled": 1, "checkInterval": 1, "refreshInterval": 2 },
30
+ "data": {
31
+ "practices": {
32
+ "2025-02-10": [{ "duration_seconds": 190 }],
33
+ "2025-02-11": [{ "duration_seconds": 340 }],
34
+ "2025-02-19": [{ "duration_seconds": 340 }],
35
+ "2025-03-01": [{ "duration_seconds": 360 }],
36
+ "2025-03-03": [{ "duration_seconds": 360 }],
37
+ "2025-03-05": [{ "duration_seconds": 100 }],
38
+ "2025-03-11": [{ "duration_seconds": 190 }],
39
+ "2025-03-14": [{ "duration_seconds": 456 }],
40
+ "2025-03-15": [{ "duration_seconds": 124 }],
41
+ "2025-03-16": [{ "duration_seconds": 452 }, { "duration_seconds": 456 }],
42
+ "2025-03-17": [{ "duration_seconds": 122 }]
43
+ }
44
+ }
45
+ }`
46
+ )
47
+ mock.mockImplementation(() => json)
48
+ userActivityContext.ensureLocalContextLoaded()
49
+ })
50
+
51
+ test('fetches user practices successfully', async () => {
52
+ userActivityContext.clearCache()
53
+ const practices = await getUserMonthlyStats()
54
+ consoleLog(practices)
55
+ // Assert that dailyActiveStats contains correct data
56
+ const dailyStats = practices.dailyActiveStats
57
+ const currentDate = new Date()
58
+ const currentDateString = currentDate.toISOString().split('T')[0]
59
+ expect(dailyStats).toHaveLength(42)
60
+
61
+ // Verify current day's stats (e.g., March 17, 2025)
62
+ const current = dailyStats.find(stat => stat.label === currentDateString)
63
+ expect(current).toBeTruthy()
64
+ expect(current.isActive).toBe(true)
65
+ expect(current.type).toBe('active')
66
+ expect(current.inStreak).toBe(false)
67
+
68
+ // Ensure that mock was called as expected
69
+ expect(mock).toHaveBeenCalledTimes(1)
70
+ })
71
+
72
+ test('fetches user practices from past', async () => {
73
+ userActivityContext.clearCache()
74
+ const practices = await getUserMonthlyStats( 2025, 1)
75
+ consoleLog(practices)
76
+
77
+ // Assert that dailyActiveStats contains correct data
78
+ const dailyStats = practices.dailyActiveStats
79
+ const feb10 = dailyStats.find(stat => stat.label === '2025-02-10')
80
+ expect(feb10.inStreak).toBe(true)
81
+ expect(feb10.type).toBe('tracked')
82
+ expect(feb10.isActive).toBe(false)
83
+ })
84
+
85
+ test('fetches user practices for current week', async () => {
86
+ userActivityContext.clearCache()
87
+ const practices = await getUserWeeklyStats( )
88
+ consoleLog(practices)
89
+
90
+ const dailyStats = practices.dailyActiveStats
91
+ const monday = dailyStats.find(stat => stat.label === 'M')
92
+ expect(monday).toBeDefined
93
+ const tuesday = dailyStats.find(stat => stat.label === 'T')
94
+ expect(tuesday).toBeDefined
95
+ })
96
+
97
+ test('should add a new practice entry and call logUserPractice', async () => {
98
+ userActivityContext.clearCache()
99
+ const mockPractice = {
100
+ duration_seconds: 300,
101
+ content_id: 415183
102
+ }
103
+
104
+ jest.spyOn(userActivityContext, 'update').mockImplementation(async (callback) => {
105
+ await callback(userActivityContext)
106
+ })
107
+
108
+ await recordUserPractice(mockPractice)
109
+
110
+ expect(userActivityContext.update).toHaveBeenCalledTimes(1)
111
+ })
112
+
113
+ function consoleLog(message, object=null, debug=false) {
114
+ if (debug || DEBUG) {
115
+ console.log(message, object);
116
+ }
117
+ }
118
+ })