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.
- package/CHANGELOG.md +9 -0
- package/docs/Content-Organization.html +2 -2
- package/docs/Gamification.html +245 -0
- package/docs/api_types.js.html +97 -0
- package/docs/config.js.html +2 -2
- package/docs/content-org_playlists-types.js.html +3 -5
- package/docs/content-org_playlists.js.html +8 -9
- package/docs/content.js.html +2 -2
- package/docs/gamification_awards.js.html +664 -0
- package/docs/gamification_gamification.js.html +76 -0
- package/docs/gamification_types.js.html +98 -0
- package/docs/global.html +1441 -153
- package/docs/index.html +2 -2
- package/docs/module-Awards.html +354 -0
- package/docs/module-Config.html +2 -2
- package/docs/module-Content-Services-V2.html +2 -2
- package/docs/module-Playlists.html +6 -6
- package/docs/module-Railcontent-Services.html +33 -194
- package/docs/module-Sanity-Services.html +885 -90
- package/docs/module-Session-Management.html +2 -2
- package/docs/module-User-Permissions.html +2 -2
- package/docs/railcontent.js.html +13 -25
- package/docs/sanity.js.html +76 -5
- package/docs/user_permissions.js.html +3 -3
- package/docs/user_sessions.js.html +4 -4
- package/docs/user_types.js.html +2 -2
- package/jsdoc.json +4 -2
- package/package.json +1 -1
- package/src/contentTypeConfig.js +1 -0
- package/src/index.d.ts +40 -3
- package/src/index.js +40 -2
- package/src/services/api/types.js +25 -0
- package/src/services/dataContext.js +15 -2
- package/src/services/gamification/awards.js +592 -0
- package/src/services/gamification/gamification.js +4 -0
- package/src/services/gamification/types.js +26 -0
- package/src/services/railcontent.js +60 -0
- package/src/services/sanity.js +22 -21
- package/src/services/userActivity.js +377 -23
- package/test/contentLikes.test.js +2 -0
- package/test/mockData/mockData_fetchByRailContentIds_one_content.json +35 -0
- package/test/userActivity.test.js +118 -0
|
@@ -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,
|
package/src/services/sanity.js
CHANGED
|
@@ -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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
|
1368
|
-
let filterSongTypesWithSameLicense = await new FilterBuilder(
|
|
1369
|
-
|
|
1370
|
-
|
|
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, "
|
|
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, '
|
|
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, '
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 () => {
|