musora-content-services 2.1.1 → 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 (42) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/docs/Content-Organization.html +2 -2
  3. package/docs/Gamification.html +245 -0
  4. package/docs/api_types.js.html +97 -0
  5. package/docs/config.js.html +2 -2
  6. package/docs/content-org_playlists-types.js.html +3 -5
  7. package/docs/content-org_playlists.js.html +8 -9
  8. package/docs/content.js.html +2 -2
  9. package/docs/gamification_awards.js.html +664 -0
  10. package/docs/gamification_gamification.js.html +76 -0
  11. package/docs/gamification_types.js.html +98 -0
  12. package/docs/global.html +1441 -153
  13. package/docs/index.html +2 -2
  14. package/docs/module-Awards.html +354 -0
  15. package/docs/module-Config.html +2 -2
  16. package/docs/module-Content-Services-V2.html +2 -2
  17. package/docs/module-Playlists.html +6 -6
  18. package/docs/module-Railcontent-Services.html +33 -194
  19. package/docs/module-Sanity-Services.html +885 -90
  20. package/docs/module-Session-Management.html +2 -2
  21. package/docs/module-User-Permissions.html +2 -2
  22. package/docs/railcontent.js.html +13 -25
  23. package/docs/sanity.js.html +76 -5
  24. package/docs/user_permissions.js.html +3 -3
  25. package/docs/user_sessions.js.html +4 -4
  26. package/docs/user_types.js.html +2 -2
  27. package/jsdoc.json +4 -2
  28. package/package.json +1 -1
  29. package/src/contentTypeConfig.js +1 -0
  30. package/src/index.d.ts +40 -3
  31. package/src/index.js +40 -2
  32. package/src/services/api/types.js +25 -0
  33. package/src/services/dataContext.js +15 -2
  34. package/src/services/gamification/awards.js +592 -0
  35. package/src/services/gamification/gamification.js +4 -0
  36. package/src/services/gamification/types.js +26 -0
  37. package/src/services/railcontent.js +60 -0
  38. package/src/services/sanity.js +22 -21
  39. package/src/services/userActivity.js +377 -23
  40. package/test/contentLikes.test.js +2 -0
  41. package/test/mockData/mockData_fetchByRailContentIds_one_content.json +35 -0
  42. package/test/userActivity.test.js +118 -0
@@ -0,0 +1,4 @@
1
+ /**
2
+ * @namespace Gamification
3
+ * @property {module:Awards} Awards
4
+ */
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @typedef {Object} Award
3
+ * @property {string} username - The username of the user.
4
+ * @property {number} streak - The name of the award.
5
+ * @property {number} minutes_practiced - The name of the award.
6
+ * @property {Date} date_completed - Date of completion
7
+ * @property {string} challenge_title - The name of the challenge completed.
8
+ * @property {string} award_text - Award description
9
+ * @property {string} tier - Award tier [bronze, silver, gold]
10
+ * @property {string} award - Award image URL
11
+ * @property {string} award_64 - Award image in base64
12
+ * @property {string} instructor_signature - Instructor signature image URL
13
+ * @property {string} instructor_signature_64 - Instructor signature image in base64
14
+ * @property {string} musora_logo - Musora logo image URL
15
+ * @property {string} musora_logo_64 - Musora logo image in base64
16
+ * @property {string} brand_logo - Brand logo image URL
17
+ * @property {string} brand_logo_64 - Brand logo image in base64
18
+ * @property {string} ribbon_image - Ribbon image URL
19
+ * @property {string} ribbon_image_64 - Ribbon image in base64
20
+ * @property {number} id - ID of the challenge completed
21
+ * @property {string} artist_name - Name of the artist featured in the challenge
22
+ * @property {string} dark_mode_logo_url - Dark mode logo image URL
23
+ * @property {string} light_mode_logo_url - Light mode logo image URL
24
+ * @property {string} logo_image_url - Name of the artist featured in the challenge
25
+ * @property {string} web_url_path - URL path for the challenge
26
+ */
@@ -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,
@@ -15,7 +15,7 @@ import {
15
15
  getNewReleasesTypes,
16
16
  coachLessonsTypes,
17
17
  getChildFieldsForContentType,
18
- SONG_TYPES
18
+ SONG_TYPES,
19
19
  } from '../contentTypeConfig.js'
20
20
  import { fetchSimilarItems } from './recommendations.js'
21
21
  import { processMetadata, typeWithSortOrder } from '../contentMetaData.js'
@@ -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,
@@ -1321,7 +1324,7 @@ export async function fetchLessonContent(railContentId) {
1321
1324
  * @param count
1322
1325
  * @returns {Promise<Array<Object>|null>}
1323
1326
  */
1324
- export async function fetchRelatedRecommendedContent(railContentId, brand, count=10) {
1327
+ export async function fetchRelatedRecommendedContent(railContentId, brand, count = 10) {
1325
1328
  const recommendedItems = await fetchSimilarItems(railContentId, brand, count)
1326
1329
  return fetchByRailContentIds(recommendedItems)
1327
1330
  }
@@ -1333,9 +1336,9 @@ export async function fetchRelatedRecommendedContent(railContentId, brand, count
1333
1336
  * @param railcontentId
1334
1337
  * @param brand
1335
1338
  * @param count
1336
- * @returns {Promise<*>}
1339
+ * @returns {Promise<Array<Object>>}
1337
1340
  */
1338
- export async function fetchOtherSongVersions(railcontentId, brand, count=3){
1341
+ export async function fetchOtherSongVersions(railcontentId, brand, count = 3) {
1339
1342
  return fetchRelatedByLicense(railcontentId, brand, true, count)
1340
1343
  }
1341
1344
 
@@ -1346,9 +1349,9 @@ export async function fetchOtherSongVersions(railcontentId, brand, count=3){
1346
1349
  * @param {integer} railcontentId
1347
1350
  * @param {string} brand
1348
1351
  * @param {integer:3} count
1349
- * @returns {Promise<*>}
1352
+ * @returns {Promise<Array<Object>>}
1350
1353
  */
1351
- export async function fetchLessonsFeaturingThisContent(railcontentId, brand, count=3){
1354
+ export async function fetchLessonsFeaturingThisContent(railcontentId, brand, count = 3) {
1352
1355
  return fetchRelatedByLicense(railcontentId, brand, false, count)
1353
1356
  }
1354
1357
 
@@ -1359,16 +1362,15 @@ export async function fetchLessonsFeaturingThisContent(railcontentId, brand, cou
1359
1362
  * @param {string} brand
1360
1363
  * @param {boolean} onlyUseSongTypes - if true, only return the song type documents. If false, return everything except those
1361
1364
  * @param {integer:3} count
1362
- * @returns {Promise<*[]>}
1365
+ * @returns {Promise<Array<Object>>}
1363
1366
  */
1364
1367
  async function fetchRelatedByLicense(railcontentId, brand, onlyUseSongTypes, count) {
1365
1368
  const typeCheck = `@->_type in [${arrayJoinWithQuotes(SONG_TYPES)}]`
1366
1369
  const typeCheckString = onlyUseSongTypes ? `${typeCheck}` : `!(${typeCheck})`
1367
- const contentFromLicenseFilter =`_type == 'license' && references(^._id)].content[${typeCheckString} && @->railcontent_id != ${railcontentId}`
1368
- let filterSongTypesWithSameLicense = await new FilterBuilder(
1369
- contentFromLicenseFilter,
1370
- {isChildrenFilter: true},
1371
- ).buildFilter()
1370
+ const contentFromLicenseFilter = `_type == 'license' && references(^._id)].content[${typeCheckString} && @->railcontent_id != ${railcontentId}`
1371
+ let filterSongTypesWithSameLicense = await new FilterBuilder(contentFromLicenseFilter, {
1372
+ isChildrenFilter: true,
1373
+ }).buildFilter()
1372
1374
  let queryFields = getFieldsForContentType()
1373
1375
  const baseParentQuery = `railcontent_id == ${railcontentId}`
1374
1376
  let parentQuery = await new FilterBuilder(baseParentQuery).buildFilter()
@@ -1383,7 +1385,7 @@ async function fetchRelatedByLicense(railcontentId, brand, onlyUseSongTypes, cou
1383
1385
  }[0...1]`
1384
1386
 
1385
1387
  const results = await fetchSanity(query, false)
1386
- return results['related_by_license'] ?? [];
1388
+ return results['related_by_license'] ?? []
1387
1389
  }
1388
1390
 
1389
1391
  /**
@@ -1407,7 +1409,7 @@ export async function fetchRelatedLessons(railContentId, brand) {
1407
1409
  ).buildFilter()
1408
1410
  const filterNeighbouringSiblings = await new FilterBuilder(`references(^._id)`).buildFilter()
1409
1411
  const childrenFilter = await new FilterBuilder(``, { isChildrenFilter: true }).buildFilter()
1410
- 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`
1411
1413
  const queryFieldsWithSort = queryFields + ', sort'
1412
1414
  const query = `*[railcontent_id == ${railContentId} && brand == "${brand}" && (!defined(permission) || references(*[_type=='permission']._id))]{
1413
1415
  _type, parent_type, railcontent_id,
@@ -1698,11 +1700,11 @@ export async function fetchArtistLessons(
1698
1700
  const query = `{
1699
1701
  "entity":
1700
1702
  *[_type == 'artist' && name == '${name}']
1701
- {'type': _type, name, 'thumbnail_url':thumbnail_url.asset->url,
1703
+ {'type': _type, name, 'thumbnail':thumbnail_url.asset->url,
1702
1704
  'lessons_count': count(*[${addType} brand == '${brand}' && references(^._id)]),
1703
1705
  'lessons': *[${addType} brand == '${brand}' && references(^._id) && (status in ['published'] || (status == 'scheduled' && defined(published_on) && published_on >= '${now}')) ${searchFilter} ${includedFieldsFilter} ${progressFilter}]{${fieldsString}}
1704
1706
  [${start}...${end}]}
1705
- |order(${sortOrder})
1707
+ |order(${sortOrder})f
1706
1708
  }`
1707
1709
  return fetchSanity(query, true)
1708
1710
  }
@@ -1752,7 +1754,7 @@ export async function fetchGenreLessons(
1752
1754
  const query = `{
1753
1755
  "entity":
1754
1756
  *[_type == 'genre' && name == '${name}']
1755
- {'type': _type, name, 'thumbnail_url':thumbnail_url.asset->url,
1757
+ {'type': _type, name, 'thumbnail':thumbnail_url.asset->url,
1756
1758
  'lessons_count': count(*[${addType} brand == '${brand}' && references(^._id)]),
1757
1759
  'lessons': *[${addType} brand == '${brand}' && references(^._id) && (status in ['published'] || (status == 'scheduled' && defined(published_on) && published_on >= '${now}')) ${searchFilter} ${includedFieldsFilter} ${progressFilter}]{${fieldsString}}
1758
1760
  [${start}...${end}]}
@@ -2350,4 +2352,3 @@ export async function fetchScheduledAndNewReleases(
2350
2352
 
2351
2353
  return fetchSanity(query, true)
2352
2354
  }
2353
-
@@ -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 () => {