musora-content-services 2.7.3 → 2.8.0

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 (48) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/docs/ContentOrganization.html +2 -2
  3. package/docs/Gamification.html +2 -2
  4. package/docs/UserManagementSystem.html +2 -2
  5. package/docs/api_types.js.html +2 -2
  6. package/docs/config.js.html +2 -2
  7. package/docs/content-org_content-org.js.html +2 -2
  8. package/docs/content-org_playlists-types.js.html +2 -2
  9. package/docs/content-org_playlists.js.html +2 -4
  10. package/docs/content.js.html +2 -2
  11. package/docs/gamification_awards.js.html +2 -2
  12. package/docs/gamification_gamification.js.html +2 -2
  13. package/docs/gamification_types.js.html +2 -2
  14. package/docs/global.html +2 -2
  15. package/docs/index.html +2 -2
  16. package/docs/module-Awards.html +2 -2
  17. package/docs/module-Config.html +2 -2
  18. package/docs/module-Content-Services-V2.html +2 -2
  19. package/docs/module-Interests.html +2 -2
  20. package/docs/module-Permissions.html +2 -2
  21. package/docs/module-Playlists.html +13 -13
  22. package/docs/module-Railcontent-Services.html +2 -2
  23. package/docs/module-Sanity-Services.html +2 -2
  24. package/docs/module-Sessions.html +2 -2
  25. package/docs/module-User-Activity.html +7 -7
  26. package/docs/module-UserManagement.html +448 -23
  27. package/docs/module-UserProfile.html +2 -2
  28. package/docs/railcontent.js.html +2 -2
  29. package/docs/sanity.js.html +2 -2
  30. package/docs/userActivity.js.html +2 -12
  31. package/docs/user_interests.js.html +2 -2
  32. package/docs/user_management.js.html +85 -12
  33. package/docs/user_permissions.js.html +2 -2
  34. package/docs/user_profile.js.html +2 -2
  35. package/docs/user_sessions.js.html +2 -2
  36. package/docs/user_types.js.html +2 -2
  37. package/docs/user_user-management-system.js.html +2 -2
  38. package/link_mcs.sh +0 -0
  39. package/package.json +1 -1
  40. package/src/contentMetaData.js +2 -2
  41. package/src/contentTypeConfig.js +10 -5
  42. package/src/index.d.ts +9 -1
  43. package/src/index.js +9 -1
  44. package/src/lib/httpHelper.js +60 -20
  45. package/src/services/dateUtils.js +23 -0
  46. package/src/services/user/management.js +83 -10
  47. package/src/services/userActivity.js +183 -37
  48. package/test/userActivity.test.js +0 -0
package/src/index.d.ts CHANGED
@@ -68,6 +68,7 @@ import {
68
68
  import {
69
69
  convertToTimeZone,
70
70
  getMonday,
71
+ getTimeRemainingUntilLocal,
71
72
  getWeekNumber,
72
73
  isNextDay,
73
74
  isSameDate
@@ -221,7 +222,10 @@ import {
221
222
 
222
223
  import {
223
224
  blockUser,
224
- unblockUser
225
+ deletePicture,
226
+ unblockUser,
227
+ uploadPicture,
228
+ uploadPictureFromS3
225
229
  } from './services/user/management.js';
226
230
 
227
231
  import {
@@ -282,6 +286,7 @@ declare module 'musora-content-services' {
282
286
  createPracticeNotes,
283
287
  deleteComment,
284
288
  deleteItemsFromPlaylist,
289
+ deletePicture,
285
290
  deletePlaylist,
286
291
  deletePracticeSession,
287
292
  deleteUserActivity,
@@ -396,6 +401,7 @@ declare module 'musora-content-services' {
396
401
  getScheduleContentRows,
397
402
  getSortOrder,
398
403
  getTabResults,
404
+ getTimeRemainingUntilLocal,
399
405
  getUserMonthlyStats,
400
406
  getUserPractices,
401
407
  getUserWeeklyStats,
@@ -459,6 +465,8 @@ declare module 'musora-content-services' {
459
465
  updatePlaylist,
460
466
  updatePracticeNotes,
461
467
  updateUserPractice,
468
+ uploadPicture,
469
+ uploadPictureFromS3,
462
470
  verifyImageSRC,
463
471
  verifyLocalDataContext,
464
472
  }
package/src/index.js CHANGED
@@ -68,6 +68,7 @@ import {
68
68
  import {
69
69
  convertToTimeZone,
70
70
  getMonday,
71
+ getTimeRemainingUntilLocal,
71
72
  getWeekNumber,
72
73
  isNextDay,
73
74
  isSameDate
@@ -221,7 +222,10 @@ import {
221
222
 
222
223
  import {
223
224
  blockUser,
224
- unblockUser
225
+ deletePicture,
226
+ unblockUser,
227
+ uploadPicture,
228
+ uploadPictureFromS3
225
229
  } from './services/user/management.js';
226
230
 
227
231
  import {
@@ -281,6 +285,7 @@ export {
281
285
  createPracticeNotes,
282
286
  deleteComment,
283
287
  deleteItemsFromPlaylist,
288
+ deletePicture,
284
289
  deletePlaylist,
285
290
  deletePracticeSession,
286
291
  deleteUserActivity,
@@ -395,6 +400,7 @@ export {
395
400
  getScheduleContentRows,
396
401
  getSortOrder,
397
402
  getTabResults,
403
+ getTimeRemainingUntilLocal,
398
404
  getUserMonthlyStats,
399
405
  getUserPractices,
400
406
  getUserWeeklyStats,
@@ -458,6 +464,8 @@ export {
458
464
  updatePlaylist,
459
465
  updatePracticeNotes,
460
466
  updateUserPractice,
467
+ uploadPicture,
468
+ uploadPictureFromS3,
461
469
  verifyImageSRC,
462
470
  verifyLocalDataContext,
463
471
  };
@@ -1,44 +1,84 @@
1
1
  import { globalConfig } from '../services/config.js'
2
2
 
3
- export async function fetchJSONHandler(url, token, baseUrl, method = 'get', dataVersion = null, body = null) {
4
- let headers = {
3
+ export async function fetchJSONHandler(
4
+ url,
5
+ token,
6
+ baseUrl,
7
+ method = 'get',
8
+ dataVersion = null,
9
+ body = null
10
+ ) {
11
+ const headers = {
5
12
  'Content-Type': 'application/json',
6
13
  Accept: 'application/json',
7
14
  'X-CSRF-TOKEN': token,
8
15
  }
9
16
 
17
+ if (body) {
18
+ body = JSON.stringify(body)
19
+ }
20
+
21
+ try {
22
+ const response = await fetchHandler(url, token, baseUrl, method, headers, dataVersion, body)
23
+
24
+ if (response.ok) {
25
+ const contentType = response.headers.get('content-type')
26
+ if (
27
+ contentType &&
28
+ contentType.indexOf('application/json') !== -1 &&
29
+ response.status !== 204
30
+ ) {
31
+ return await response.json()
32
+ } else {
33
+ return await response.text()
34
+ }
35
+ } else {
36
+ console.error(`Fetch error: ${method} ${url} ${response.status} ${response.statusText}`)
37
+ console.log(response)
38
+ }
39
+ } catch (error) {
40
+ console.error('Fetch error:', error)
41
+ }
42
+ return null
43
+ }
44
+
45
+ export async function fetchHandler(
46
+ url,
47
+ token,
48
+ baseUrl,
49
+ method = 'get',
50
+ headers = {},
51
+ dataVersion = null,
52
+ body = null
53
+ ) {
54
+ let reqHeaders = {
55
+ ...headers,
56
+ Accept: 'application/json',
57
+ 'X-CSRF-TOKEN': token,
58
+ }
59
+
10
60
  if (!globalConfig.isMA) {
11
61
  const params = new URLSearchParams(window.location.search)
12
62
  if (params.get('testNow')) {
13
- headers['testNow'] = params.get('testNow')
63
+ reqHeaders['testNow'] = params.get('testNow')
14
64
  }
15
65
  if (params.get('timezone')) {
16
- headers['M-Client-Timezone'] = params.get('timezone')
66
+ reqHeaders['M-Client-Timezone'] = params.get('timezone')
17
67
  }
18
68
  }
19
69
 
20
- if (globalConfig.localTimezoneString) headers['M-Client-Timezone'] = globalConfig.localTimezoneString
21
- if (dataVersion) headers['Data-Version'] = dataVersion
70
+ if (globalConfig.localTimezoneString)
71
+ reqHeaders['M-Client-Timezone'] = globalConfig.localTimezoneString
72
+ if (dataVersion) reqHeaders['Data-Version'] = dataVersion
22
73
  const options = {
23
74
  method,
24
- headers,
75
+ headers: reqHeaders,
25
76
  }
26
- if (body) options.body = JSON.stringify(body)
77
+ if (body) options.body = body
27
78
  if (token) options.headers['Authorization'] = `Bearer ${token}`
28
79
  if (baseUrl && url.startsWith('/')) url = baseUrl + url
29
80
  try {
30
- const response = await fetch(url, options)
31
- if (response.ok) {
32
- const contentType = response.headers.get("content-type");
33
- if (contentType && contentType.indexOf("application/json") !== -1) {
34
- return await response.json()
35
- } else {
36
- return await response.text()
37
- }
38
- } else {
39
- console.error(`Fetch error: ${method} ${url} ${response.status} ${response.statusText}`)
40
- console.log(response)
41
- }
81
+ return fetch(url, options)
42
82
  } catch (error) {
43
83
  console.error('Fetch error:', error)
44
84
  }
@@ -53,3 +53,26 @@ export function isNextDay(prev, current) {
53
53
  );
54
54
  }
55
55
 
56
+ export function getTimeRemainingUntilLocal(targetUtcIsoString) {
57
+ const targetUTC = new Date(targetUtcIsoString);
58
+ if (isNaN(targetUTC.getTime())) {
59
+ return "00:00:00";
60
+ }
61
+
62
+ const now = new Date();
63
+ const diff = targetUTC.getTime() - now.getTime();
64
+
65
+ if (diff <= 0) {
66
+ return "00:00:00";
67
+ }
68
+
69
+ const totalSeconds = Math.floor(diff / 1000);
70
+ const hours = String(Math.floor(totalSeconds / 3600)).padStart(2, '0');
71
+ const minutes = String(Math.floor((totalSeconds % 3600) / 60)).padStart(2, '0');
72
+ const seconds = String(totalSeconds % 60).padStart(2, '0');
73
+
74
+ return `${hours}:${minutes}:${seconds}`;
75
+ }
76
+
77
+
78
+
@@ -1,14 +1,9 @@
1
1
  /**
2
2
  * @module UserManagement
3
3
  */
4
- import { fetchHandler } from '../railcontent.js'
5
-
6
- /**
7
- * Exported functions that are excluded from index generation.
8
- *
9
- * @type {string[]}
10
- */
11
- const excludeFromGeneratedIndex = []
4
+ import { fetchHandler as railcontentFetchHandler } from '../railcontent.js'
5
+ import { fetchHandler, fetchJSONHandler } from '../../lib/httpHelper.js'
6
+ import { globalConfig } from '../config.js'
12
7
 
13
8
  const baseUrl = `/api/user-management-system`
14
9
 
@@ -19,7 +14,7 @@ const baseUrl = `/api/user-management-system`
19
14
  */
20
15
  export async function blockUser(userId) {
21
16
  const url = `${baseUrl}/v1/block/${userId}`
22
- return fetchHandler(url, 'post')
17
+ return railcontentFetchHandler(url, 'post')
23
18
  }
24
19
 
25
20
  /**
@@ -29,5 +24,83 @@ export async function blockUser(userId) {
29
24
  */
30
25
  export async function unblockUser(userId) {
31
26
  const url = `${baseUrl}/v1/unblock/${userId}`
32
- return fetchHandler(url, 'post')
27
+ return railcontentFetchHandler(url, 'post')
28
+ }
29
+
30
+ /**
31
+ * Upload a picture to the server
32
+ * @param {string} fieldKey
33
+ * @param {File} file
34
+ * @returns {Promise<any|string|null>}
35
+ */
36
+ export async function uploadPicture(fieldKey, file) {
37
+ const formData = new FormData()
38
+ formData.append('file', file)
39
+ formData.append('fieldKey', fieldKey)
40
+ const apiUrl = `${baseUrl}/v1/picture`
41
+
42
+ const response = await fetchHandler(
43
+ apiUrl,
44
+ globalConfig.sessionConfig.token,
45
+ globalConfig.baseUrl,
46
+ 'POST',
47
+ null,
48
+ null,
49
+ formData
50
+ )
51
+
52
+ if (!response.ok) {
53
+ const problemDetails = await response.json()
54
+ console.log('Error uploading picture:', problemDetails.detail)
55
+ throw new Error(`Upload failed: ${problemDetails.detail}`)
56
+ }
57
+
58
+ const { url } = await response.json()
59
+ console.log('Picture uploaded successfully:', url)
60
+
61
+ return url
62
+ }
63
+
64
+ /**
65
+ * Saves a picture uploaded to S3
66
+ * @param {string} fieldKey
67
+ * @param {string} s3_bucket_path
68
+ * @returns {Promise<any|string|null>}
69
+ */
70
+ export async function uploadPictureFromS3(fieldKey, s3_bucket_path) {
71
+ const apiUrl = `${baseUrl}/v1/picture/s3`
72
+
73
+ const response = await fetchJSONHandler(
74
+ apiUrl,
75
+ globalConfig.sessionConfig.token,
76
+ globalConfig.baseUrl,
77
+ 'POST',
78
+ null,
79
+ {
80
+ fieldKey,
81
+ s3_bucket_path,
82
+ }
83
+ )
84
+
85
+ if (!response.ok) {
86
+ const problemDetails = await response.json()
87
+ console.log('Error uploading picture:', problemDetails.detail)
88
+ throw new Error(`Upload failed: ${problemDetails.detail}`)
89
+ }
90
+
91
+ const { url } = await response.json()
92
+
93
+ return url
94
+ }
95
+
96
+ /**
97
+ * @param {string} pictureUrl
98
+ * @returns {Promise<any>}
99
+ */
100
+ export async function deletePicture(pictureUrl) {
101
+ const apiUrl = `${baseUrl}/v1/picture`
102
+
103
+ fetchJSONHandler(apiUrl, globalConfig.sessionConfig.token, globalConfig.baseUrl, 'DELETE', null, {
104
+ picture_url: pictureUrl,
105
+ })
33
106
  }
@@ -8,14 +8,14 @@ import {
8
8
  fetchUserPracticeMeta,
9
9
  fetchUserPracticeNotes,
10
10
  fetchHandler,
11
- fetchRecentUserActivities,
11
+ fetchRecentUserActivities, fetchChallengeLessonData
12
12
  } from './railcontent'
13
13
  import { DataContext, UserActivityVersionKey } from './dataContext.js'
14
14
  import { fetchByRailContentIds, fetchShows } from './sanity'
15
- import {fetchUserPlaylists} from "./content-org/playlists";
16
- import { convertToTimeZone, getMonday, getWeekNumber, isSameDate, isNextDay } from './dateUtils.js'
15
+ import {fetchPlaylist, fetchUserPlaylists} from "./content-org/playlists";
16
+ import {convertToTimeZone, getMonday, getWeekNumber, isSameDate, isNextDay, getTimeRemainingUntilLocal} from './dateUtils.js'
17
17
  import { globalConfig } from './config'
18
- import {collectionLessonTypes, lessonTypesMapping, progressTypesMapping} from "../contentTypeConfig";
18
+ import {collectionLessonTypes, lessonTypesMapping, progressTypesMapping, showsLessonTypes, songs} from "../contentTypeConfig";
19
19
  import {getAllStartedOrCompleted, getProgressStateByIds} from "./contentProgress";
20
20
  import {TabResponseType} from "../contentMetaData";
21
21
 
@@ -830,7 +830,7 @@ async function formatPracticeMeta(practices) {
830
830
  title: practice.content_id ? content.title : practice.title,
831
831
  category_id: practice.category_id,
832
832
  instrument_id: practice.instrument_id,
833
- content_type: getFormattedType(content.type || ''),
833
+ content_type: getFormattedType(content.type || '', content.brand),
834
834
  content_id: practice.content_id || null,
835
835
  content_brand: content.brand || null,
836
836
  created_at: convertToTimeZone(utcDate, userTimeZone),
@@ -911,6 +911,7 @@ export async function getProgressRows({ brand = null, limit = 8 } = {}) {
911
911
  const playlistEngagedOnContents = eligiblePlaylistItems.map(item => item.last_engaged_on);
912
912
  const playlistsContents = await fetchByRailContentIds(playlistEngagedOnContents, 'progress-tracker');
913
913
  const excludedParents = new Set();
914
+ const existingShows = new Set();
914
915
  for (const item of playlistsContents) {
915
916
  const contentId = item.id ?? item.railcontent_id;
916
917
  excludedParents.add(contentId)
@@ -960,20 +961,44 @@ export async function getProgressRows({ brand = null, limit = 8 } = {}) {
960
961
  }
961
962
  // Handle standalone parents
962
963
  if (!progressMap.has(id)) {
963
- progressMap.set(id, {
964
- id,
965
- raw: content,
966
- state: progress.status,
967
- percent: progress.progress,
968
- progressTimestamp: progress.last_update * 1000
969
- });
964
+ if(!existingShows.has(content.type)){
965
+ progressMap.set(id, {
966
+ id,
967
+ raw: content,
968
+ state: progress.status,
969
+ percent: progress.progress,
970
+ progressTimestamp: progress.last_update * 1000
971
+ });
972
+ }
973
+ if(showsLessonTypes.includes(content.type)) {
974
+ existingShows.add(content.type)
975
+ }
970
976
  }
971
977
  }
972
- const progressList = Array.from(progressMap.values());
978
+ const pinnedItem = await extractPinnedItem({
979
+ brand,
980
+ progressMap,
981
+ playlistItems: eligiblePlaylistItems,
982
+ })
983
+ const progressList = Array.from(progressMap.values())
984
+ if (pinnedItem) {
985
+ pinnedItem.pinned = true
986
+ }
987
+
988
+ const pinnedId = pinnedItem?.id
989
+ const filteredProgressList = pinnedId
990
+ ? progressList.filter(item => item.id !== pinnedId)
991
+ : progressList;
992
+ const filteredPlaylists = pinnedId
993
+ ? eligiblePlaylistItems.filter(item => item.id !== pinnedId)
994
+ : eligiblePlaylistItems;
995
+ const combinedBase = [...filteredProgressList, ...filteredPlaylists]
996
+ const combined = pinnedItem ? [pinnedItem, ...combinedBase] : combinedBase
997
+
998
+ const finalCombined = mergeAndSortItems(combined, limit)
973
999
 
974
- const combined = mergeAndSortItems([...progressList, ...eligiblePlaylistItems], limit);
975
1000
  const results = await Promise.all(
976
- combined.slice(0, limit).map(item =>
1001
+ finalCombined.slice(0, limit).map(item =>
977
1002
  item.type === 'playlist'
978
1003
  ? processPlaylistItem(item)
979
1004
  : processContentItem(item)
@@ -988,22 +1013,25 @@ export async function getProgressRows({ brand = null, limit = 8 } = {}) {
988
1013
 
989
1014
  async function processContentItem(item) {
990
1015
  let data = item.raw;
991
- const contentType = getFormattedType(data.type);
1016
+ const contentType = getFormattedType(data.type, data.brand);
992
1017
  const status = item.state;
993
1018
 
994
1019
  let ctaText = 'Continue';
995
1020
  if (contentType === 'transcription' || contentType === 'play-along' || contentType === 'jam-track') ctaText = 'Replay Song';
996
1021
  if (contentType === 'lesson') ctaText = status === 'completed' ? 'Revisit Lesson' : 'Continue';
997
- if ((contentType === 'pack' || contentType === 'song tutorial' || collectionLessonTypes.includes(contentType)) && status === 'completed') {
1022
+ if ((contentType === 'guided course' || contentType === 'song tutorial' || collectionLessonTypes.includes(contentType)) && status === 'completed') ctaText = 'Revisit Lessons' ;
1023
+ if (contentType === 'pack' && status === 'completed') {
998
1024
  ctaText = 'View Lessons';
999
1025
  }
1000
1026
 
1001
1027
  if (data.lesson_count > 0) {
1028
+ const lessonIds = extractLessonIds(item);
1029
+ const progressOnItems = await getProgressStateByIds(lessonIds);
1030
+ let completedCount = Object.values(progressOnItems).filter(value => value === 'completed').length;
1031
+ data.completed_children = completedCount;
1032
+
1002
1033
  if (item.childIndex) {
1003
1034
  let nextId = item.childIndex
1004
- const lessonIds = extractLessonIds(item);
1005
- const progressOnItems = await getProgressStateByIds(lessonIds);
1006
- const completedCount = Object.values(progressOnItems).filter(value => value === 'completed').length;
1007
1035
  const nextByProgress = findIncompleteLesson(progressOnItems, item.childIndex, item.raw.type);
1008
1036
  nextId = nextByProgress ? nextByProgress : nextId;
1009
1037
 
@@ -1025,28 +1053,46 @@ async function processContentItem(item) {
1025
1053
  const nextLesson = lessons.find(lesson => lesson.id === nextId);
1026
1054
  data.first_incomplete_child = nextLesson?.parent ?? nextLesson;
1027
1055
  data.second_incomplete_child = (nextLesson?.parent) ? nextLesson : null;
1028
- data.completed_children = completedCount;
1056
+ if(data.type === 'challenge' && nextByProgress !== undefined ){
1057
+ const challenge = await fetchChallengeLessonData(nextByProgress)
1058
+ if(challenge.lesson.is_locked) {
1059
+ data.is_locked = true;
1060
+ ctaText = 'Next lesson in ' + getTimeRemainingUntilLocal(challenge.lesson.unlock_date);
1061
+ }
1062
+ }
1029
1063
  }
1030
1064
  }
1031
1065
 
1032
- if(contentType == 'show' && status == 'completed'){
1066
+ if(contentType == 'show'){
1033
1067
  const shows = await fetchShows(data.brand, data.type)
1034
1068
  const showIds = shows.map(item => item.id);
1035
1069
  const progressOnItems = await getProgressStateByIds(showIds);
1036
- const nextByProgress = findIncompleteLesson(progressOnItems, data.id, data.type);
1037
- data = shows.find(lesson => lesson.id === nextByProgress);
1070
+ const completedCount = Object.values(progressOnItems).filter(value => value === 'completed').length;
1071
+ if(status == 'completed') {
1072
+ const nextByProgress = findIncompleteLesson(progressOnItems, data.id, data.type);
1073
+ data = shows.find(lesson => lesson.id === nextByProgress);
1074
+ }
1075
+ data.completed_children = completedCount;
1076
+ data.child_count = shows.length;
1077
+ item.percent = Math.round((completedCount / shows.length) * 100);
1078
+ if(completedCount == shows.length) {
1079
+ ctaText = 'Revisit Lessons';
1080
+ }
1038
1081
  }
1039
1082
 
1040
1083
  return {
1041
1084
  id: item.id,
1042
1085
  progressType: 'content',
1043
1086
  header: contentType,
1087
+ pinned: item.pinned ?? false,
1044
1088
  body: {
1045
1089
  progressPercent: item.percent,
1046
1090
  thumbnail: data.thumbnail,
1047
1091
  title: data.title,
1092
+ badge: data.badge ?? null,
1093
+ isLocked: data.is_locked ?? false,
1048
1094
  subtitle: !data.child_count || data.lesson_count === 1
1049
- ? `${data.difficulty_string} • ${data.artist_name}`
1095
+ ? (contentType === 'lesson') ? `${item.percent}% Complete`: `${data.difficulty_string} • ${data.artist_name}`
1050
1096
  : `${data.completed_children} of ${data.lesson_count ?? data.child_count} Lessons Complete`
1051
1097
  },
1052
1098
  cta: {
@@ -1101,10 +1147,12 @@ async function processPlaylistItem(item) {
1101
1147
  id: playlist.id,
1102
1148
  progressType: 'playlist',
1103
1149
  header: 'playlist',
1150
+ pinned: item.pinned ?? false,
1104
1151
  body: {
1105
1152
  first_items_thumbnail_url: playlist.first_items_thumbnail_url,
1106
1153
  title: playlist.name,
1107
- subtitle: `${playlist.duration_formated} • ${playlist.total_items} items • ${playlist.likes} likes • ${playlist.user.display_name}`
1154
+ subtitle: `${playlist.duration_formated} • ${playlist.total_items} items • ${playlist.likes} likes • ${playlist.user.display_name}`,
1155
+ total_items: playlist.total_items,
1108
1156
  },
1109
1157
  progressTimestamp: item.progressTimestamp,
1110
1158
  cta: {
@@ -1119,12 +1167,13 @@ async function processPlaylistItem(item) {
1119
1167
  }
1120
1168
  }
1121
1169
 
1122
- const getFormattedType = type => {
1170
+ const getFormattedType = (type, brand) => {
1123
1171
  for (const [key, values] of Object.entries(progressTypesMapping)) {
1124
1172
  if (values.includes(type)) {
1125
- return key;
1173
+ return key === 'songs' ? songs[brand] : key;
1126
1174
  }
1127
1175
  }
1176
+
1128
1177
  return null;
1129
1178
  };
1130
1179
 
@@ -1132,11 +1181,10 @@ function extractLessonIds(data) {
1132
1181
  const ids = [];
1133
1182
  function traverse(lessons) {
1134
1183
  for (const item of lessons) {
1135
- if (item.id) {
1136
- ids.push(item.id);
1137
- }
1138
1184
  if (item.lessons) {
1139
1185
  traverse(item.lessons); // Recursively handle nested lessons
1186
+ }else if (item.id) {
1187
+ ids.push(item.id);
1140
1188
  }
1141
1189
  }
1142
1190
  }
@@ -1165,9 +1213,24 @@ async function getEligiblePlaylistItems(playlists) {
1165
1213
  }
1166
1214
 
1167
1215
  function mergeAndSortItems(items, limit) {
1168
- return items
1216
+ const seen = new Set();
1217
+ const deduped = [];
1218
+
1219
+ for (const item of items) {
1220
+ const key = `${item.id}-${item.type || item.raw?.type}`;
1221
+ if (!seen.has(key)) {
1222
+ seen.add(key);
1223
+ deduped.push(item);
1224
+ }
1225
+ }
1226
+
1227
+ return deduped
1169
1228
  .filter(item => typeof item.progressTimestamp === 'number' && item.progressTimestamp > 0)
1170
- .sort((a, b) => b.progressTimestamp - a.progressTimestamp)
1229
+ .sort((a, b) => {
1230
+ if (a.pinned && !b.pinned) return -1;
1231
+ if (!a.pinned && b.pinned) return 1;
1232
+ return b.progressTimestamp - a.progressTimestamp;
1233
+ })
1171
1234
  .slice(0, limit + 5);
1172
1235
  }
1173
1236
 
@@ -1175,7 +1238,7 @@ function findIncompleteLesson(progressOnItems, currentContentId, contentType) {
1175
1238
  const ids = Object.keys(progressOnItems).map(Number);
1176
1239
  if (contentType === 'challenge') {
1177
1240
  // Return first incomplete lesson
1178
- return ids.find(id => progressOnItems[id] !== 'completed') || null;
1241
+ return ids.find(id => progressOnItems[id] !== 'completed') || ids.at(0);
1179
1242
  }
1180
1243
 
1181
1244
  // For other types, find next incomplete after current
@@ -1207,7 +1270,15 @@ function findIncompleteLesson(progressOnItems, currentContentId, contentType) {
1207
1270
  */
1208
1271
  export async function pinProgressRow(brand, id, progressType) {
1209
1272
  const url = `/api/user-management-system/v1/progress/pin?brand=${brand}&id=${id}&progressType=${progressType}`;
1210
- return await fetchHandler(url, 'PUT', null)
1273
+ const response = await fetchHandler(url, 'PUT', null)
1274
+ if (response && !response.error) {
1275
+ updatePinnedProgressRow(brand, {
1276
+ id,
1277
+ progressType,
1278
+ pinnedAt: new Date().toISOString(),
1279
+ });
1280
+ }
1281
+ return response;
1211
1282
  }
1212
1283
  /**
1213
1284
  * Unpins the current pinned progress row for a user, scoped by brand.
@@ -1221,7 +1292,82 @@ export async function pinProgressRow(brand, id, progressType) {
1221
1292
  * .catch(error => console.error(error));
1222
1293
  */
1223
1294
  export async function unpinProgressRow(brand) {
1224
- const url = `/api/user-management-system/v1/progress/unpin?brand=${brand}`;
1225
- return await fetchHandler(url, 'PUT', null)
1295
+ const url = `/api/user-management-system/v1/progress/unpin?brand=${brand}`
1296
+ const response = await fetchHandler(url, 'PUT', null)
1297
+ if (response && !response.error) {
1298
+ updatePinnedProgressRow(brand, null)
1299
+ }
1300
+ return response
1226
1301
  }
1227
1302
 
1303
+ function updatePinnedProgressRow(brand, pinnedData) {
1304
+ const user = JSON.parse(globalConfig.localStorage.getItem('user')) || {}
1305
+ user.brand_pinned_progress = user.brand_pinned_progress || {}
1306
+ user.brand_pinned_progress[brand] = pinnedData
1307
+ globalConfig.localStorage.setItem('user', JSON.stringify(user))
1308
+ }
1309
+
1310
+ async function extractPinnedItem({brand, progressMap, playlistItems}) {
1311
+ const user = JSON.parse(globalConfig.localStorage.getItem('user')) || {}
1312
+ user.brand_pinned_progress = user.brand_pinned_progress || {}
1313
+
1314
+ const pinned = user.brand_pinned_progress[brand]
1315
+ if (!pinned) return null
1316
+
1317
+ const {id, progressType, pinnedAt} = pinned
1318
+
1319
+ if (progressType === 'content') {
1320
+ const pinnedId = parseInt(id)
1321
+ if (progressMap.has(pinnedId)) {
1322
+ const item = progressMap.get(pinnedId)
1323
+ progressMap.delete(pinnedId)
1324
+ return item
1325
+ } else {
1326
+ const content = await fetchByRailContentIds([`${pinnedId}`], 'progress-tracker')
1327
+ const firstLessonId = getFirstLeafLessonId(content[0])
1328
+ return {
1329
+ id: pinnedId,
1330
+ state: 'started',
1331
+ percent: 0,
1332
+ raw: content[0],
1333
+ progressTimestamp: new Date(pinnedAt).getTime(),
1334
+ childIndex: firstLessonId
1335
+ }
1336
+ }
1337
+ }
1338
+ if (progressType === 'playlist') {
1339
+ const pinnedPlaylist = playlistItems.find(p => p.raw.id === id)
1340
+ if (pinnedPlaylist) {
1341
+ return pinnedPlaylist
1342
+ }else{
1343
+ const playlist = await fetchPlaylist(id)
1344
+ return {
1345
+ id: id,
1346
+ raw: playlist,
1347
+ progressTimestamp: new Date(pinnedAt).getTime(),
1348
+ type: 'playlist',
1349
+ last_engaged_on: playlist.items[0],
1350
+ }
1351
+ }
1352
+ }
1353
+
1354
+ return null
1355
+ }
1356
+
1357
+ function getFirstLeafLessonId(data) {
1358
+ function findFirstLeaf(lessons) {
1359
+ for (const item of lessons) {
1360
+ if (!item.lessons || item.lessons.length === 0) {
1361
+ return item.id || null
1362
+ }
1363
+ const found = findFirstLeaf(item.lessons)
1364
+ if (found) return found
1365
+ }
1366
+ return null
1367
+ }
1368
+
1369
+ return data.lessons ? findFirstLeaf(data.lessons) : null
1370
+ }
1371
+
1372
+
1373
+
File without changes