musora-content-services 2.28.6 → 2.30.3

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 (158) hide show
  1. package/.coderabbit.yaml +0 -0
  2. package/.editorconfig +0 -0
  3. package/.github/pull_request_template.md +0 -0
  4. package/.github/workflows/conventional-commits.yaml +0 -0
  5. package/.github/workflows/docs.js.yml +0 -0
  6. package/.github/workflows/node.js.yml +0 -0
  7. package/.prettierignore +0 -0
  8. package/.prettierrc +0 -0
  9. package/CHANGELOG.md +49 -0
  10. package/README.md +0 -0
  11. package/babel.config.cjs +0 -0
  12. package/docs/ContentOrganization.html +0 -0
  13. package/docs/Gamification.html +0 -0
  14. package/docs/UserManagementSystem.html +0 -0
  15. package/docs/api_types.js.html +0 -0
  16. package/docs/config.js.html +0 -0
  17. package/docs/content-org_content-org.js.html +0 -0
  18. package/docs/content-org_playlists-types.js.html +0 -0
  19. package/docs/content-org_playlists.js.html +0 -0
  20. package/docs/content.js.html +0 -0
  21. package/docs/fonts/Montserrat/Montserrat-Bold.eot +0 -0
  22. package/docs/fonts/Montserrat/Montserrat-Bold.ttf +0 -0
  23. package/docs/fonts/Montserrat/Montserrat-Bold.woff +0 -0
  24. package/docs/fonts/Montserrat/Montserrat-Bold.woff2 +0 -0
  25. package/docs/fonts/Montserrat/Montserrat-Regular.eot +0 -0
  26. package/docs/fonts/Montserrat/Montserrat-Regular.ttf +0 -0
  27. package/docs/fonts/Montserrat/Montserrat-Regular.woff +0 -0
  28. package/docs/fonts/Montserrat/Montserrat-Regular.woff2 +0 -0
  29. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.eot +0 -0
  30. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.svg +0 -0
  31. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.ttf +0 -0
  32. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff +0 -0
  33. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff2 +0 -0
  34. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.eot +0 -0
  35. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.svg +0 -0
  36. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.ttf +0 -0
  37. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff +0 -0
  38. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff2 +0 -0
  39. package/docs/gamification_awards.js.html +0 -0
  40. package/docs/gamification_gamification.js.html +0 -0
  41. package/docs/gamification_types.js.html +0 -0
  42. package/docs/global.html +0 -0
  43. package/docs/index.html +0 -0
  44. package/docs/module-Awards.html +0 -0
  45. package/docs/module-Config.html +0 -0
  46. package/docs/module-Content-Services-V2.html +0 -0
  47. package/docs/module-Interests.html +0 -0
  48. package/docs/module-Permissions.html +0 -0
  49. package/docs/module-Playlists.html +0 -0
  50. package/docs/module-Railcontent-Services.html +0 -0
  51. package/docs/module-Sanity-Services.html +0 -0
  52. package/docs/module-Sessions.html +0 -0
  53. package/docs/module-UserActivity.html +0 -0
  54. package/docs/module-UserChat.html +0 -0
  55. package/docs/module-UserManagement.html +0 -0
  56. package/docs/module-UserNotifications.html +0 -0
  57. package/docs/module-UserProfile.html +0 -0
  58. package/docs/railcontent.js.html +0 -0
  59. package/docs/sanity.js.html +0 -0
  60. package/docs/scripts/collapse.js +0 -0
  61. package/docs/scripts/commonNav.js +0 -0
  62. package/docs/scripts/linenumber.js +0 -0
  63. package/docs/scripts/nav.js +0 -0
  64. package/docs/scripts/polyfill.js +0 -0
  65. package/docs/scripts/prettify/Apache-License-2.0.txt +0 -0
  66. package/docs/scripts/prettify/lang-css.js +0 -0
  67. package/docs/scripts/prettify/prettify.js +0 -0
  68. package/docs/scripts/search.js +0 -0
  69. package/docs/styles/jsdoc.css +0 -0
  70. package/docs/styles/prettify.css +0 -0
  71. package/docs/userActivity.js.html +0 -0
  72. package/docs/user_chat.js.html +0 -0
  73. package/docs/user_interests.js.html +0 -0
  74. package/docs/user_management.js.html +0 -0
  75. package/docs/user_notifications.js.html +0 -0
  76. package/docs/user_permissions.js.html +0 -0
  77. package/docs/user_profile.js.html +0 -0
  78. package/docs/user_sessions.js.html +0 -0
  79. package/docs/user_types.js.html +0 -0
  80. package/docs/user_user-management-system.js.html +0 -0
  81. package/jest.config.js +0 -0
  82. package/jsdoc.json +0 -0
  83. package/link_mcs.sh +0 -0
  84. package/package.json +1 -1
  85. package/src/contentMetaData.js +0 -0
  86. package/src/contentTypeConfig.js +69 -50
  87. package/src/filterBuilder.js +0 -0
  88. package/src/index.d.ts +0 -0
  89. package/src/index.js +0 -0
  90. package/src/infrastructure/http/HttpClient.ts +0 -0
  91. package/src/infrastructure/http/executors/FetchRequestExecutor.ts +0 -0
  92. package/src/infrastructure/http/index.ts +0 -0
  93. package/src/infrastructure/http/interfaces/HeaderProvider.ts +0 -0
  94. package/src/infrastructure/http/interfaces/HttpError.ts +0 -0
  95. package/src/infrastructure/http/interfaces/NetworkError.ts +0 -0
  96. package/src/infrastructure/http/interfaces/RequestExecutor.ts +0 -0
  97. package/src/infrastructure/http/interfaces/RequestOptions.ts +0 -0
  98. package/src/infrastructure/http/providers/DefaultHeaderProvider.ts +0 -0
  99. package/src/lib/httpHelper.js +0 -0
  100. package/src/lib/lastUpdated.js +0 -0
  101. package/src/services/api/types.js +0 -0
  102. package/src/services/config.js +0 -6
  103. package/src/services/content-org/content-org.js +0 -0
  104. package/src/services/content-org/guided-courses.ts +0 -0
  105. package/src/services/content-org/playlists-types.js +10 -0
  106. package/src/services/content-org/playlists.js +14 -14
  107. package/src/services/content.js +8 -9
  108. package/src/services/contentAggregator.js +45 -7
  109. package/src/services/contentLikes.js +0 -0
  110. package/src/services/contentProgress.js +122 -26
  111. package/src/services/dataContext.js +0 -0
  112. package/src/services/dateUtils.js +0 -0
  113. package/src/services/forum.js +0 -0
  114. package/src/services/gamification/awards.js +52 -33
  115. package/src/services/gamification/gamification.js +0 -0
  116. package/src/services/gamification/types.js +5 -23
  117. package/src/services/imageSRCBuilder.js +0 -0
  118. package/src/services/imageSRCVerify.js +0 -0
  119. package/src/services/railcontent.js +25 -36
  120. package/src/services/recommendations.js +20 -18
  121. package/src/services/sanity.js +27 -57
  122. package/src/services/types.js +0 -8
  123. package/src/services/user/account.ts +0 -0
  124. package/src/services/user/chat.js +0 -0
  125. package/src/services/user/interests.js +0 -0
  126. package/src/services/user/management.js +0 -0
  127. package/src/services/user/notifications.js +0 -0
  128. package/src/services/user/permissions.js +0 -0
  129. package/src/services/user/profile.js +0 -0
  130. package/src/services/user/sessions.js +0 -0
  131. package/src/services/user/types.js +0 -0
  132. package/src/services/user/user-management-system.js +0 -0
  133. package/src/services/userActivity.js +340 -419
  134. package/test/HttpClient.test.js +0 -0
  135. package/test/content.test.js +0 -0
  136. package/test/contentLikes.test.js +0 -0
  137. package/test/contentProgress.test.js +0 -0
  138. package/test/dataContext.test.js +0 -0
  139. package/test/forum.test.js +0 -0
  140. package/test/imageSRCBuilder.test.js +0 -0
  141. package/test/imageSRCVerify.test.js +0 -0
  142. package/test/initializeTests.js +0 -4
  143. package/test/lib/lastUpdated.test.js +0 -0
  144. package/test/live/contentProgressLive.test.js +0 -0
  145. package/test/live/railcontentLive.test.js +0 -0
  146. package/test/localStorageMock.js +0 -0
  147. package/test/log.js +0 -0
  148. package/test/mockData/mockData_fetchByRailContentIds_one_content.json +0 -0
  149. package/test/mockData/mockData_progress_content.json +0 -0
  150. package/test/mockData/mockData_sanity_progress_content.json +0 -0
  151. package/test/mockData/mockData_user_practices.json +0 -0
  152. package/test/notifications.test.js +0 -0
  153. package/test/progressRows.test.js +0 -0
  154. package/test/sanityQueryService.test.js +0 -0
  155. package/test/streakMessage.test.js +0 -0
  156. package/test/user/permissions.test.js +0 -0
  157. package/test/userActivity.test.js +0 -0
  158. package/tools/generate-index.cjs +0 -0
@@ -9,26 +9,33 @@ import {
9
9
  fetchUserPracticeNotes,
10
10
  fetchHandler,
11
11
  fetchRecentUserActivities,
12
- fetchLastInteractedChild,
13
12
  } from './railcontent'
14
13
  import { DataContext, UserActivityVersionKey } from './dataContext.js'
15
14
  import { fetchByRailContentIds, fetchShows } from './sanity'
16
- import {fetchPlaylist, fetchUserPlaylists} from "./content-org/playlists"
17
- import {pinnedGuidedCourses} from "./content-org/guided-courses"
18
- import {convertToTimeZone, getMonday, getWeekNumber, isSameDate, isNextDay, getTimeRemainingUntilLocal, toDayjs} from './dateUtils.js'
15
+ import { fetchPlaylist, fetchUserPlaylists } from './content-org/playlists'
16
+ import { pinnedGuidedCourses } from './content-org/guided-courses'
17
+ import {
18
+ getMonday,
19
+ getWeekNumber,
20
+ isSameDate,
21
+ isNextDay,
22
+ getTimeRemainingUntilLocal,
23
+ toDayjs,
24
+ } from './dateUtils.js'
19
25
  import { globalConfig } from './config'
20
- import {collectionLessonTypes, lessonTypesMapping, progressTypesMapping, recentTypes, showsLessonTypes, songs} from "../contentTypeConfig";
21
26
  import {
22
- getAllStartedOrCompleted,
23
- getProgressPercentageByIds,
24
- getProgressStateByIds,
25
- getResumeTimeSecondsByIds
26
- } from "./contentProgress";
27
- import {TabResponseType} from "../contentMetaData";
28
- import {isContentLikedByIds} from "./contentLikes.js";
27
+ collectionLessonTypes,
28
+ progressTypesMapping,
29
+ recentTypes,
30
+ showsLessonTypes,
31
+ songs,
32
+ } from '../contentTypeConfig'
33
+ import { getAllStartedOrCompleted, getProgressStateByIds } from './contentProgress'
34
+ import { TabResponseType } from '../contentMetaData'
29
35
  import dayjs from 'dayjs'
30
36
  import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
31
37
  import weekOfYear from 'dayjs/plugin/weekOfYear'
38
+ import {addContextToContent} from "./contentAggregator.js";
32
39
 
33
40
  const DATA_KEY_PRACTICES = 'practices'
34
41
  const DATA_KEY_LAST_UPDATED_TIME = 'u'
@@ -88,7 +95,7 @@ export let userActivityContext = new DataContext(UserActivityVersionKey, fetchUs
88
95
  * .catch(error => console.error(error));
89
96
  */
90
97
  export async function getUserWeeklyStats() {
91
- const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
98
+ const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
92
99
  let data = await userActivityContext.getData()
93
100
  let practices = data?.[DATA_KEY_PRACTICES] ?? {}
94
101
  let sortedPracticeDays = Object.keys(practices)
@@ -99,10 +106,19 @@ export async function getUserWeeklyStats() {
99
106
  let dailyStats = []
100
107
  for (let i = 0; i < 7; i++) {
101
108
  const day = startOfWeek.add(i, 'day')
102
- let hasPractice = sortedPracticeDays.some((practiceDate) => isSameDate(practiceDate, day.format('YYYY-MM-DD')))
103
- let isActive = isSameDate(today, day)
109
+ let hasPractice = sortedPracticeDays.some((practiceDate) =>
110
+ isSameDate(practiceDate, day.format('YYYY-MM-DD'))
111
+ )
112
+ let isActive = isSameDate(today.format(), day.format())
104
113
  let type = hasPractice ? 'tracked' : isActive ? 'active' : 'none'
105
- dailyStats.push({ key: i, label: DAYS[i], isActive, inStreak: hasPractice, type, day: day.format('YYYY-MM-DD') })
114
+ dailyStats.push({
115
+ key: i,
116
+ label: DAYS[i],
117
+ isActive,
118
+ inStreak: hasPractice,
119
+ type,
120
+ day: day.format('YYYY-MM-DD'),
121
+ })
106
122
  }
107
123
 
108
124
  let { streakMessage } = getStreaksAndMessage(practices)
@@ -169,7 +185,7 @@ export async function getUserMonthlyStats(params = {}) {
169
185
  }
170
186
  }
171
187
 
172
- // let endOfMonth = new Date(year, month + 1, 0)
188
+ // let endOfMonth = new Date(year, month + 1, 0)
173
189
  let endOfGrid = endOfMonth.clone()
174
190
  while (endOfGrid.day() !== 0) {
175
191
  endOfGrid = endOfGrid.add(1, 'day')
@@ -221,7 +237,7 @@ export async function getUserMonthlyStats(params = {}) {
221
237
 
222
238
  // Filter past practices only
223
239
  let filteredPractices = Object.entries(practices)
224
- .filter(([date]) => dayjs.tz(date, timeZone).isSameOrBefore(endOfMonth))
240
+ .filter(([date]) => dayjs(date).isSameOrBefore(endOfMonth))
225
241
  .reduce((acc, [date, val]) => {
226
242
  acc[date] = val
227
243
  return acc
@@ -275,6 +291,7 @@ export async function getUserMonthlyStats(params = {}) {
275
291
  */
276
292
  export async function recordUserPractice(practiceDetails) {
277
293
  practiceDetails.auto = 0
294
+ practiceDetails.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
278
295
  if (practiceDetails.content_id) {
279
296
  practiceDetails.auto = 1
280
297
  }
@@ -390,13 +407,11 @@ export async function restoreUserPractice(id) {
390
407
  const restoredPractice = response.data.find((p) => p.id === id)
391
408
  if (restoredPractice) {
392
409
  await userActivityContext.updateLocal(async function (localContext) {
393
- const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
394
- const date = convertToTimeZone(restoredPractice.day, userTimeZone)
395
- if (localContext.data[DATA_KEY_PRACTICES][date]) {
396
- localContext.data[DATA_KEY_PRACTICES][date] = []
410
+ if (localContext.data[DATA_KEY_PRACTICES][restoredPractice.day]) {
411
+ localContext.data[DATA_KEY_PRACTICES][restoredPractice.day] = []
397
412
  }
398
413
  response.data.forEach((restoredPractice) => {
399
- localContext.data[DATA_KEY_PRACTICES][date].push({
414
+ localContext.data[DATA_KEY_PRACTICES][restoredPractice.day].push({
400
415
  id: restoredPractice.id,
401
416
  duration_seconds: restoredPractice.duration_seconds,
402
417
  })
@@ -645,7 +660,7 @@ function calculateStreaks(practices, includeStreakMessage = false) {
645
660
  let lastActiveDay = null
646
661
  let streakMessage = ''
647
662
 
648
- const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
663
+ const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
649
664
  let sortedPracticeDays = Object.keys(practices)
650
665
  .map((dateStr) => {
651
666
  const [year, month, day] = dateStr.split('-').map(Number)
@@ -828,8 +843,6 @@ async function formatPracticeMeta(practices) {
828
843
  const contentIds = practices.map((p) => p.content_id).filter((id) => id !== null)
829
844
  const contents = await fetchByRailContentIds(contentIds)
830
845
 
831
- const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
832
-
833
846
  return practices.map((practice) => {
834
847
  const content = contents.find((c) => c.id === practice.content_id) || {}
835
848
 
@@ -847,14 +860,13 @@ async function formatPracticeMeta(practices) {
847
860
  content_type: getFormattedType(content.type || '', content.brand),
848
861
  content_id: practice.content_id || null,
849
862
  content_brand: content.brand || null,
850
- created_at: convertToTimeZone(dayjs(practice.created_at), userTimeZone),
863
+ created_at: dayjs(practice.created_at),
851
864
  sanity_type: content.type || null,
852
865
  content_slug: content.slug || null,
853
866
  }
854
867
  })
855
868
  }
856
869
 
857
-
858
870
  /**
859
871
  * Records a new user activity in the system.
860
872
  *
@@ -898,6 +910,86 @@ export async function deleteUserActivity(id) {
898
910
  const url = `/api/user-management-system/v1/activities/${id}`
899
911
  return await fetchHandler(url, 'DELETE')
900
912
  }
913
+
914
+ async function extractPinnedItemsAndSortAllItems(userPinnedItem, contentsMap, eligiblePlaylistItems, pinnedGuidedCourse, limit) {
915
+ let pinnedItem = await popPinnedItemFromContentsOrPlaylistMap(
916
+ userPinnedItem,
917
+ contentsMap,
918
+ eligiblePlaylistItems,
919
+ )
920
+
921
+ const guidedCourseID = pinnedGuidedCourse?.content_id
922
+ let combined = [];
923
+ if (pinnedGuidedCourse) {
924
+ const guidedCourseContent = contentsMap.get(guidedCourseID) ?? await addContextToContent(fetchByRailContentId, guidedCourseID, 'guided-course',
925
+ {
926
+ addNextLesson: true,
927
+ addNavigateTo: true,
928
+ addProgressStatus: true,
929
+ addProgressPercentage: true,
930
+ addProgressTimestamp: true,
931
+ })
932
+ contentsMap = popContentAndRemoveChildrenFromContentsMap(guidedCourseContent, contentsMap)
933
+ guidedCourseContent.pinned = true
934
+ combined.push(guidedCourseContent)
935
+ }
936
+ if (pinnedItem) {
937
+ pinnedItem.pinned = true
938
+ combined.push(pinnedItem)
939
+ }
940
+
941
+ const progressList = Array.from(contentsMap.values())
942
+ combined = [...combined, ...progressList, ...eligiblePlaylistItems]
943
+ return mergeAndSortItems(combined, limit)
944
+ }
945
+
946
+ function generateContentsMap(contents, playlistsContents) {
947
+ const excludedTypes = new Set([
948
+ 'pack-bundle',
949
+ 'learning-path-course',
950
+ 'learning-path-level',
951
+ 'guided-course-part',
952
+ ]);
953
+ const existingShows = new Set();
954
+ const contentsMap = new Map();
955
+ const childToParentMap = {};
956
+ contents.forEach(content => {
957
+ if (Array.isArray(content.parent_content_data) && content.parent_content_data.length > 0) {
958
+ childToParentMap[content.id] = content.parent_content_data[content.parent_content_data.length - 1];
959
+ }
960
+ });
961
+
962
+ const allRecentTypeSet = new Set(
963
+ Object.values(recentTypes).flat()
964
+ )
965
+ contents.forEach(content => {
966
+ const id = content.id
967
+ const type = content.type
968
+ if (excludedTypes.has(type) || (!allRecentTypeSet.has(type) && !showsLessonTypes.includes(type)) ) return;
969
+ if (!childToParentMap[id]) {
970
+ // Shows don't have a parent to link them, but need to be handled as if they're a set of children
971
+ if (!existingShows.has(type)) {
972
+ contentsMap.set(id, content)
973
+ }
974
+ if (showsLessonTypes.includes(type)) {
975
+ existingShows.add(type)
976
+ }
977
+ }
978
+ })
979
+
980
+ // TODO this doesn't work for guided courses as the GC card takes precedence over the playlist card
981
+ // https://musora.atlassian.net/browse/BEH-812
982
+ if (playlistsContents) {
983
+ for (const item of playlistsContents) {
984
+ const contentId = item.id
985
+ contentsMap.delete(contentId)
986
+ const parentIds = item.parent_content_data || [];
987
+ parentIds.forEach(id => contentsMap.delete(id));
988
+ }
989
+ }
990
+ return contentsMap;
991
+ }
992
+
901
993
  /**
902
994
  * Fetches and combines recent user progress rows and playlists, excluding certain types and parents.
903
995
  *
@@ -912,11 +1004,7 @@ export async function deleteUserActivity(id) {
912
1004
  * .catch(error => console.error(error));
913
1005
  */
914
1006
  export async function getProgressRows({ brand = null, limit = 8 } = {}) {
915
- const excludedTypes = new Set([
916
- 'pack-bundle',
917
- 'learning-path-course',
918
- 'learning-path-level'
919
- ]);
1007
+
920
1008
  // TODO slice progress to a reasonable number, say 100
921
1009
  const [recentPlaylists, progressContents, allPinnedGuidedCourse, userPinnedItem ] = await Promise.all([
922
1010
  fetchUserPlaylists(brand, { sort: '-last_progress', limit: limit}),
@@ -924,321 +1012,187 @@ export async function getProgressRows({ brand = null, limit = 8 } = {}) {
924
1012
  pinnedGuidedCourses(brand),
925
1013
  getUserPinnedItem(brand),
926
1014
  ])
927
-
928
1015
  let pinnedGuidedCourse = allPinnedGuidedCourse?.[0] ?? null
929
1016
 
930
1017
  const playlists = recentPlaylists?.data || [];
931
1018
  const eligiblePlaylistItems = await getEligiblePlaylistItems(playlists);
932
- const playlistEngagedOnContents = eligiblePlaylistItems.map(item => item.last_engaged_on);
1019
+ const playlistEngagedOnContents = eligiblePlaylistItems.map(item => item.playlist.last_engaged_on);
933
1020
 
934
1021
  const nonPlaylistContentIds = Object.keys(progressContents)
935
1022
  if (pinnedGuidedCourse) {
936
- nonPlaylistContentIds.push(pinnedGuidedCourse.content_id);
1023
+ nonPlaylistContentIds.push(pinnedGuidedCourse.content_id)
937
1024
  }
938
1025
  if (userPinnedItem?.progressType === 'content') {
939
1026
  nonPlaylistContentIds.push(userPinnedItem.id)
940
1027
  }
941
1028
  const [ playlistsContents, contents ] = await Promise.all([
942
- fetchByRailContentIds(playlistEngagedOnContents, 'progress-tracker'),
943
- fetchByRailContentIds(nonPlaylistContentIds, 'progress-tracker', brand),
1029
+ addContextToContent(fetchByRailContentIds, playlistEngagedOnContents, 'progress-tracker', {
1030
+ addNextLesson: true,
1031
+ addNavigateTo: true,
1032
+ addProgressStatus: true,
1033
+ addProgressPercentage: true,
1034
+ addProgressTimestamp: true,
1035
+ }),
1036
+ addContextToContent(fetchByRailContentIds, nonPlaylistContentIds, 'progress-tracker', brand, {
1037
+ addNextLesson: true,
1038
+ addNavigateTo: true,
1039
+ addProgressStatus: true,
1040
+ addProgressPercentage: true,
1041
+ addProgressTimestamp: true,
1042
+ })
944
1043
  ]);
945
-
946
- const excludedParents = new Set();
947
- const existingShows = new Set();
948
- // TODO this doesn't work for guided courses as the GC card takes precedence over the playlist card
949
- // https://musora.atlassian.net/browse/BEH-812
950
- for (const item of playlistsContents) {
951
-
952
- const contentId = item.id ?? item.railcontent_id;
953
- delete progressContents[contentId]
954
- const parentIds = item.parent_content_data || [];
955
- parentIds.forEach(id => delete progressContents[id] );
956
- }
957
-
958
-
959
- const contentsMap = {};
960
- contents.forEach(content => {
961
- contentsMap[content.railcontent_id] = content;
962
- });
963
- const childToParentMap = {};
964
- Object.values(contentsMap).forEach(content => {
965
- if (Array.isArray(content.parent_content_data) && content.parent_content_data.length > 0) {
966
- childToParentMap[content.id] = content.parent_content_data[content.parent_content_data.length - 1];
967
- }
968
- });
969
-
970
- const allRecentTypeSet = new Set(
971
- Object.values(recentTypes).flat()
972
- )
973
- const progressMap = new Map();
974
- for (const [idStr, progress] of Object.entries(progressContents)) {
975
- const id = parseInt(idStr);
976
- const content = contentsMap[id];
977
- if (!content || excludedTypes.has(content.type) || !allRecentTypeSet.has(content.type) ) continue;
978
- const parentId = childToParentMap[id];
979
- // Handle children with parents
980
- if (parentId) {
981
- const parentContent = contentsMap[parentId];
982
- if (!parentContent || excludedTypes.has(parentContent.type)) continue;
983
- const existing = progressMap.get(parentId);
984
- if (existing) {
985
- // If childIndex isn't already set, set it
986
- if (existing.childIndex === undefined) {
987
- existing.childIndex = id;
988
- }
989
- } else {
990
- progressMap.set(parentId, {
991
- id: parentId,
992
- raw: parentContent,
993
- state: progress.status,
994
- percent: progress.progress,
995
- progressTimestamp: progress.last_update * 1000,
996
- childIndex: id
997
- });
998
- }
999
- continue;
1000
- }
1001
- // Handle standalone parents
1002
- if (!progressMap.has(id)) {
1003
- if(!existingShows.has(content.type)){
1004
- progressMap.set(id, {
1005
- id,
1006
- raw: content,
1007
- state: progress.status,
1008
- percent: progress.progress,
1009
- progressTimestamp: progress.last_update * 1000
1010
- });
1011
- }
1012
- if(showsLessonTypes.includes(content.type)) {
1013
- existingShows.add(content.type)
1014
- }
1015
- }
1016
- }
1017
- const pinnedItem = userPinnedItem ? await extractPinnedItem(
1018
- userPinnedItem,
1019
- progressMap,
1020
- eligiblePlaylistItems,
1021
- ) : null
1022
-
1023
- const pinnedId = pinnedItem?.id
1024
- const guidedCourseID = pinnedGuidedCourse?.content_id
1025
- let combined = [];
1026
- if (pinnedGuidedCourse) {
1027
- const guidedCourseContent = contentsMap[guidedCourseID]
1028
- if (guidedCourseContent) {
1029
- const temp = await extractPinnedGuidedCourseItem(guidedCourseContent, progressMap)
1030
- temp.pinned = true
1031
- combined.push(temp)
1032
- }
1033
- }
1034
- if (pinnedItem) {
1035
- pinnedItem.pinned = true
1036
- combined.push(pinnedItem)
1037
- }
1038
- const progressList = Array.from(progressMap.values())
1039
-
1040
- const filteredProgressList = pinnedId
1041
- ? progressList.filter(item => !(item.id === pinnedId || item.id === guidedCourseID))
1042
- : progressList;
1043
- const filteredPlaylists = pinnedId
1044
- ? (eligiblePlaylistItems.filter(item => !(item.id === pinnedId || item.id === guidedCourseID)))
1045
- : eligiblePlaylistItems;
1046
-
1047
- combined = [...combined, ...filteredProgressList, ...filteredPlaylists]
1048
- const finalCombined = mergeAndSortItems(combined, limit)
1044
+ const contentsMap = generateContentsMap(contents, playlistsContents);
1045
+ let combined = await extractPinnedItemsAndSortAllItems(userPinnedItem, contentsMap, eligiblePlaylistItems, pinnedGuidedCourse, limit);
1049
1046
  const results = await Promise.all(
1050
- finalCombined.slice(0, limit).map(item =>
1047
+ combined.slice(0, limit).map(item =>
1051
1048
  item.type === 'playlist'
1052
1049
  ? processPlaylistItem(item)
1053
1050
  : processContentItem(item)
1054
1051
  )
1055
1052
  );
1056
-
1053
+ console.log('HomePageProgressRows results: remove before merge', results)
1057
1054
  return {
1058
1055
  type: TabResponseType.PROGRESS_ROWS,
1059
1056
  displayBrowseAll: combined.length > limit,
1060
- data: results
1061
- };
1057
+ data: results,
1058
+ }
1062
1059
  }
1063
1060
 
1064
1061
  async function getUserPinnedItem(brand) {
1065
- const userRaw = await globalConfig.localStorage.getItem('user');
1066
- const user = userRaw ? JSON.parse(userRaw) : {};
1062
+ const userRaw = await globalConfig.localStorage.getItem('user')
1063
+ const user = userRaw ? JSON.parse(userRaw) : {}
1067
1064
  user.brand_pinned_progress = user.brand_pinned_progress || {}
1068
1065
  return user.brand_pinned_progress[brand] ?? null
1069
1066
  }
1070
1067
 
1071
- async function processContentItem(item) {
1072
- let data = item.raw;
1073
- const contentType = getFormattedType(data.type, data.brand);
1074
- const status = item.state;
1075
- const isLive = data.isLive ?? false
1076
- let ctaText = 'Continue';
1077
- if (contentType === 'transcription' || contentType === 'play-along' || contentType === 'jam-track') ctaText = 'Replay Song';
1078
- if (contentType === 'lesson') ctaText = status === 'completed' ? 'Revisit Lesson' : 'Continue';
1079
- if ((contentType === 'song-tutorial' || collectionLessonTypes.includes(contentType)) && status === 'completed') ctaText = 'Revisit Lessons' ;
1080
- if (contentType === 'pack' && status === 'completed') {
1081
- ctaText = 'View Lessons';
1082
- }
1083
-
1084
- if (data.lesson_count > 0) {
1085
- const lessonIds = extractLessonIds(item);
1086
- const progressOnItems = await getProgressStateByIds(lessonIds);
1087
- let completedCount = Object.values(progressOnItems).filter(value => value === 'completed').length;
1088
- data.completed_children = completedCount;
1089
-
1090
- if (item.childIndex) {
1091
- let nextId = item.childIndex
1092
- const nextByProgress = findIncompleteLesson(progressOnItems, item.childIndex, item.raw.type)
1093
- nextId = nextByProgress ? nextByProgress : nextId
1094
-
1095
- const nestedLessons = data.lessons
1096
- .filter(item => Array.isArray(item.lessons))
1097
- .flatMap(parent =>
1098
- parent.lessons.map(lesson => ({
1099
- ...lesson,
1100
- parent: {
1101
- id: parent.id,
1102
- slug: parent.slug,
1103
- title: parent.title,
1104
- type: parent.type
1105
- }
1106
- }))
1107
- );
1108
-
1109
- const lessons = (nestedLessons.length === 0) ? data.lessons : nestedLessons
1110
- const nextLesson = lessons.find(lesson => lesson.id === nextId)
1111
- data.first_incomplete_child = nextLesson?.parent ?? nextLesson
1112
- data.second_incomplete_child = (nextLesson?.parent) ? nextLesson : null
1113
- if(data.type === 'guided-course'){
1114
- let isLocked = new Date(nextLesson.published_on) > new Date()
1115
- data.thumbnail = nextLesson.thumbnail
1116
- // USHP-4 completed
1117
- if (status === 'completed') {
1118
- // duplicated code to above, but here for clarity
1119
- ctaText = 'Revisit Lessons'
1120
- // USHP-1 if lesson locked show unlock in X time
1121
- } else if (isLocked) {
1122
- data.is_locked = true
1123
- const timeRemaining = getTimeRemainingUntilLocal(nextLesson.published_on, {withTotalSeconds: true})
1124
- data.time_remaining_seconds = timeRemaining.totalSeconds
1125
- ctaText = 'Next lesson in ' + timeRemaining.formatted
1126
- }
1127
- // USHP-2 start course if not started
1128
- else if (status === 'not-started') {
1129
- ctaText = "Start Course"
1130
- }
1131
- // USHP-3 in progress for lesson
1132
- else {
1133
- ctaText = "Continue"
1134
- }
1135
- }
1068
+ async function processContentItem(content) {
1069
+ const contentType = getFormattedType(content.type, content.brand);
1070
+ const isLive = content.isLive ?? false
1071
+ let ctaText = getDefaultCTATextForContent(content, contentType)
1072
+
1073
+ content.completed_children = await getCompletedChildren(content, contentType)
1074
+
1075
+ if (content.type === 'guided-course') {
1076
+ const nextLessonPublishedOn = content.children.find(child => child.id === content.navigateTo.id)?.published_on
1077
+ let isLocked = new Date(nextLessonPublishedOn) > new Date()
1078
+ if (isLocked) {
1079
+ content.is_locked = true
1080
+ const timeRemaining = getTimeRemainingUntilLocal(nextLessonPublishedOn, {withTotalSeconds: true})
1081
+ content.time_remaining_seconds = timeRemaining.totalSeconds
1082
+ ctaText = 'Next lesson in ' + timeRemaining.formatted
1083
+ } else if (!content.progressStatus || content.progressStatus === 'not-started' ) {
1084
+ ctaText = "Start Course"
1136
1085
  }
1137
1086
  }
1138
1087
 
1139
- if(contentType == 'show'){
1140
- const shows = await fetchShows(data.brand, data.type)
1088
+ if (contentType === 'show'){
1089
+ const shows = await fetchShows(content.brand, content.type)
1141
1090
  const showIds = shows.map(item => item.id);
1142
1091
  const progressOnItems = await getProgressStateByIds(showIds);
1143
- const completedCount = Object.values(progressOnItems).filter(value => value === 'completed').length;
1144
- if(status == 'completed') {
1145
- const nextByProgress = findIncompleteLesson(progressOnItems, data.id, data.type);
1146
- data = shows.find(lesson => lesson.id === nextByProgress);
1092
+ const completedShows = content.completed_children
1093
+ const progressTimestamp = content.progressTimestamp
1094
+ const wasPinned = content.pinned ?? false
1095
+ if (content.progressStatus === 'completed') {
1096
+ // this could be handled more gracefully if their was a parent content type for shows
1097
+ const nextByProgress = findIncompleteLesson(progressOnItems, content.id, content.type);
1098
+ content = shows.find(lesson => lesson.id === nextByProgress);
1099
+ content.completed_children = completedShows
1100
+ content.progressTimestamp = progressTimestamp
1101
+ content.pinned = wasPinned
1147
1102
  }
1148
- data.completed_children = completedCount;
1149
- data.child_count = shows.length;
1150
- item.percent = Math.round((completedCount / shows.length) * 100);
1151
- if(completedCount === shows.length) {
1152
- ctaText = 'Revisit Lessons';
1103
+ content.child_count = shows.length;
1104
+ content.progressPercentage = Math.round((completedShows / shows.length) * 100);
1105
+ if (completedShows === shows.length) {
1106
+ ctaText = 'Revisit Show';
1153
1107
  }
1154
1108
  }
1155
1109
 
1156
1110
  return {
1157
- id: item.id,
1111
+ id: content.id,
1158
1112
  progressType: 'content',
1159
1113
  header: contentType,
1160
- pinned: item.pinned ?? false,
1114
+ pinned: content.pinned ?? false,
1115
+ content: content,
1161
1116
  body: {
1162
- progressPercent: isLive ? undefined: item.percent,
1163
- thumbnail: data.thumbnail,
1164
- title: data.title,
1117
+ progressPercent: isLive ? undefined: content.progressPercentage,
1118
+ thumbnail: content.thumbnail,
1119
+ title: content.title,
1165
1120
  isLive: isLive,
1166
- badge: data.badge ?? null,
1167
- isLocked: data.is_locked ?? false,
1168
- subtitle: !data.child_count || data.lesson_count === 1
1169
- ? (contentType === 'lesson' && isLive === false) ? `${item.percent}% Complete`: `${data.difficulty_string} • ${data.artist_name}`
1170
- : `${data.completed_children} of ${data.lesson_count ?? data.child_count} Lessons Complete`
1121
+ badge: content.badge ?? null,
1122
+ isLocked: content.is_locked ?? false,
1123
+ subtitle: !content.child_count || content.lesson_count === 1
1124
+ ? (contentType === 'lesson' && isLive === false) ? `${content.progressPercentage}% Complete`: `${content.difficulty_string} • ${content.artist_name}`
1125
+ : `${content.completed_children} of ${content.lesson_count ?? content.child_count} Lessons Complete`
1171
1126
  },
1172
1127
  cta: {
1173
1128
  text: ctaText,
1174
- timeRemainingToUnlockSeconds: data.time_remaining_seconds ?? null,
1129
+ timeRemainingToUnlockSeconds: content.time_remaining_seconds ?? null,
1175
1130
  action: {
1176
- type: data.type,
1177
- brand: data.brand,
1178
- id: data.id,
1179
- slug: data.slug,
1180
- child: data.first_incomplete_child
1181
- ? {
1182
- id: data.first_incomplete_child.id,
1183
- type: data.first_incomplete_child.type,
1184
- brand: data.first_incomplete_child.brand,
1185
- slug: data.first_incomplete_child.slug,
1186
- child: data.second_incomplete_child
1187
- ? {
1188
- id: data.second_incomplete_child.id,
1189
- type: data.second_incomplete_child.type,
1190
- brand: data.second_incomplete_child.brand,
1191
- slug: data.second_incomplete_child.slug
1192
- }
1193
- : null
1194
- }
1195
- : null
1131
+ type: content.type,
1132
+ brand: content.brand,
1133
+ id: content.id,
1134
+ slug: content.slug,
1135
+ child: content.navigateTo,
1196
1136
  }
1197
1137
  },
1198
- progressTimestamp: item.progressTimestamp
1138
+ // *1000 is to match playlists which are saved in millisecond accuracy
1139
+ progressTimestamp: content.progressTimestamp * 1000
1199
1140
  };
1200
1141
  }
1201
1142
 
1202
- async function processPlaylistItem(item) {
1203
- const playlist = item.raw;
1204
- const progressOnItems = await getProgressStateByIds(playlist.items.map(a => a.content_id));
1205
- const allItemsCompleted = item.raw.items.every(i => {
1206
- const itemId = i.content_id;
1207
- const progress = progressOnItems[itemId];
1208
- return progress && progress === 'completed';
1209
- });
1210
- let nextItem = playlist.items[0] ?? null;
1211
- if (!allItemsCompleted) {
1212
- const lastItemProgress = progressOnItems[playlist.last_engaged_on];
1213
- const index = playlist.items.findIndex(i => i.content_id === playlist.last_engaged_on);
1214
- if (lastItemProgress === 'completed') {
1215
- nextItem = playlist.items[index + 1] ?? nextItem;
1216
- } else {
1217
- nextItem = playlist.items[index] ?? nextItem;
1218
- }
1143
+ function getDefaultCTATextForContent(content, contentType)
1144
+ {
1145
+ let ctaText = 'Continue';
1146
+ if (content.progressStatus === 'completed')
1147
+ {
1148
+ if (contentType === songs[content.brand] || contentType === 'play along' || contentType === 'jam track') ctaText = 'Replay Song';
1149
+ if (contentType === 'lesson') ctaText = 'Revisit Lesson';
1150
+ if (contentType === 'song tutorial' || collectionLessonTypes.includes(contentType)) ctaText = 'Revisit Lessons' ;
1151
+ if (contentType === 'pack') ctaText = 'View Lessons'
1219
1152
  }
1153
+ return ctaText
1154
+ }
1155
+
1156
+ async function getCompletedChildren(content, contentType)
1157
+ {
1158
+ let completedChildren = null
1159
+ if (contentType === 'show') {
1160
+ const shows = await addContextToContent(fetchShows, content.brand, content.type, {
1161
+ addProgressStatus: true,
1162
+ })
1163
+ completedChildren = Object.values(shows).filter(show => show.progressStatus === 'completed').length;
1164
+ } else if (content.lesson_count > 0) {
1165
+ const lessonIds = getLeafNodes(content);
1166
+ const progressOnItems = await getProgressStateByIds(lessonIds);
1167
+ completedChildren = Object.values(progressOnItems).filter(value => value === 'completed').length;
1168
+ }
1169
+ return completedChildren
1170
+ }
1220
1171
 
1172
+ async function processPlaylistItem(item) {
1173
+ const playlist = item.playlist;
1221
1174
  return {
1222
1175
  id: playlist.id,
1223
1176
  progressType: 'playlist',
1224
1177
  header: 'playlist',
1225
1178
  pinned: item.pinned ?? false,
1179
+ playlist: playlist,
1226
1180
  body: {
1227
1181
  first_items_thumbnail_url: playlist.first_items_thumbnail_url,
1228
- title: playlist.name,
1229
- subtitle: `${playlist.duration_formated} • ${playlist.total_items} items • ${playlist.likes} likes • ${playlist.user.display_name}`,
1230
- total_items: playlist.total_items,
1182
+ title: playlist.name,
1183
+ subtitle: `${playlist.duration_formated} • ${playlist.total_items} items • ${playlist.likes} likes • ${playlist.user.display_name}`,
1184
+ total_items: playlist.total_items,
1231
1185
  },
1232
1186
  progressTimestamp: item.progressTimestamp,
1233
- cta: {
1234
- text: 'Continue',
1187
+ cta: {
1188
+ text: 'Continue',
1235
1189
  action: {
1236
1190
  brand: playlist.brand,
1237
- id: playlist.id,
1238
- itemId: nextItem.id,
1239
- lastEngagedOn: playlist.last_engaged_on,
1240
- lastEngagedOnItem: playlist.last_engaged_on_item,
1191
+ item_id: playlist.navigateTo.id ?? null,
1192
+ content_id: playlist.navigateTo.content_id ?? null,
1241
1193
  type: 'playlists',
1194
+ // TODO depreciated, maintained to avoid breaking changes
1195
+ id: playlist.id,
1242
1196
  }
1243
1197
  }
1244
1198
  }
@@ -1247,90 +1201,144 @@ async function processPlaylistItem(item) {
1247
1201
  const getFormattedType = (type, brand) => {
1248
1202
  for (const [key, values] of Object.entries(progressTypesMapping)) {
1249
1203
  if (values.includes(type)) {
1250
- return key === 'songs' ? songs[brand] : key;
1204
+ return key === 'songs' ? songs[brand] : key
1251
1205
  }
1252
1206
  }
1253
1207
 
1254
- return null;
1255
- };
1208
+ return null
1209
+ }
1256
1210
 
1257
- function extractLessonIds(data) {
1211
+ function getLeafNodes(content) {
1258
1212
  const ids = [];
1259
- function traverse(lessons) {
1260
- for (const item of lessons) {
1261
- if (item.lessons) {
1262
- traverse(item.lessons); // Recursively handle nested lessons
1263
- }else if (item.id) {
1213
+ function traverse(children) {
1214
+ for (const item of children) {
1215
+ if (item.children) {
1216
+ traverse(item.children); // Recursively handle nested lessons
1217
+ } else if (item.id) {
1264
1218
  ids.push(item.id);
1265
1219
  }
1266
1220
  }
1267
1221
  }
1268
- if (data.raw && Array.isArray(data.raw.lessons)) {
1269
- traverse(data.raw.lessons);
1222
+ if (content && Array.isArray(content.children)) {
1223
+ traverse(content.children);
1270
1224
  }
1271
-
1272
- return ids;
1225
+ return ids
1273
1226
  }
1274
1227
 
1275
-
1276
1228
  async function getEligiblePlaylistItems(playlists) {
1277
- const eligible = playlists.filter(p => p.last_progress && p.last_engaged_on);
1229
+ const eligible = playlists.filter((p) => p.last_progress && p.last_engaged_on)
1278
1230
  return Promise.all(
1279
- eligible.map(async p => {
1280
- const utcDate = new Date(p.last_progress.replace(' ', 'T') + 'Z');
1281
- const timestamp = utcDate.getTime();
1231
+ eligible.map(async (p) => {
1232
+ const utcDate = new Date(p.last_progress.replace(' ', 'T') + 'Z')
1233
+ const timestamp = utcDate.getTime()
1282
1234
  return {
1283
1235
  type: 'playlist',
1284
- progressTimestamp: timestamp,
1285
- last_engaged_on: p.last_engaged_on,
1286
- raw: p
1236
+ // Content timestamps are millisecond accurate so for comparison we bring this to the same resolution
1237
+ progressTimestamp: timestamp / 1000,
1238
+ playlist: p,
1239
+ id: p.id,
1287
1240
  };
1288
1241
  })
1289
- );
1242
+ )
1290
1243
  }
1291
1244
 
1292
1245
  function mergeAndSortItems(items, limit) {
1293
- const seen = new Set();
1294
- const deduped = [];
1246
+ const seen = new Set()
1247
+ const deduped = []
1295
1248
 
1296
1249
  for (const item of items) {
1297
- const key = `${item.id}-${item.type || item.raw?.type}`;
1250
+ const key = `${item.id}-${item.type}`;
1298
1251
  if (!seen.has(key)) {
1299
- seen.add(key);
1300
- deduped.push(item);
1252
+ seen.add(key)
1253
+ deduped.push(item)
1301
1254
  }
1302
1255
  }
1303
1256
 
1304
1257
  return deduped
1305
- .filter(item => typeof item.progressTimestamp === 'number' && item.progressTimestamp > 0)
1258
+ .filter(item => typeof item.progressTimestamp === 'number' && item.progressTimestamp >= 0)
1306
1259
  .sort((a, b) => {
1307
1260
  if (a.pinned && !b.pinned) return -1;
1308
1261
  if (!a.pinned && b.pinned) return 1;
1309
- // TODO guided course should always be before user pinned item
1262
+ // TODO pinned guided course should always be before user pinned item
1310
1263
  return b.progressTimestamp - a.progressTimestamp;
1311
1264
  })
1312
- .slice(0, limit + 5);
1265
+ .slice(0, limit + 5)
1313
1266
  }
1314
1267
 
1315
1268
  export function findIncompleteLesson(progressOnItems, currentContentId, contentType) {
1316
- const ids = Object.keys(progressOnItems).map(Number);
1269
+ const ids = Object.keys(progressOnItems).map(Number)
1317
1270
  if (contentType === 'guided-course') {
1318
1271
  // Return first incomplete lesson
1319
- return ids.find(id => progressOnItems[id] !== 'completed') || ids.at(0);
1272
+ return ids.find((id) => progressOnItems[id] !== 'completed') || ids.at(0)
1320
1273
  }
1321
1274
 
1322
1275
  // For other types, find next incomplete after current
1323
- const currentIndex = ids.indexOf(Number(currentContentId));
1324
- if (currentIndex === -1) return null;
1276
+ const currentIndex = ids.indexOf(Number(currentContentId))
1277
+ if (currentIndex === -1) return null
1325
1278
 
1326
1279
  for (let i = currentIndex + 1; i < ids.length; i++) {
1327
- const id = ids[i];
1280
+ const id = ids[i]
1328
1281
  if (progressOnItems[id] !== 'completed') {
1329
- return id;
1282
+ return id
1283
+ }
1284
+ }
1285
+
1286
+ return ids[0]
1287
+ }
1288
+
1289
+ async function popPinnedItemFromContentsOrPlaylistMap(pinned, contentsMap, playlistItems) {
1290
+ if (!pinned) return null
1291
+ const {id, progressType, pinnedAt} = pinned
1292
+ let item = null
1293
+ if (progressType === 'content') {
1294
+ const pinnedId = parseInt(id)
1295
+ if (contentsMap.has(pinnedId)) {
1296
+ item = contentsMap.get(pinnedId)
1297
+ contentsMap.delete(pinnedId)
1298
+
1299
+ } else {
1300
+ // we use fetchByRailContentIds so that we don't have the _type restriction in the query
1301
+ let data = await fetchByRailContentIds([id], 'progress-tracker')
1302
+ item = await addContextToContent(() => data[0] ?? null,
1303
+ {
1304
+ addNextLesson: true,
1305
+ addNavigateTo: true,
1306
+ addProgressStatus: true,
1307
+ addProgressPercentage: true,
1308
+ addProgressTimestamp: true
1309
+ }
1310
+ )
1311
+ }
1312
+ }
1313
+ if (progressType === 'playlist') {
1314
+ const pinnedPlaylist = playlistItems.find(p => p.playlist.id === id)
1315
+ if (pinnedPlaylist) {
1316
+ playlistItems = playlistItems.filter(p => p.playlist.id !== id)
1317
+ item = pinnedPlaylist
1318
+ } else {
1319
+ const playlist = await fetchPlaylist(id)
1320
+ item = {
1321
+ id: id,
1322
+ playlist: playlist,
1323
+ type: 'playlist',
1324
+ progressTimestamp: new Date(pinnedAt).getTime(),
1325
+ }
1330
1326
  }
1331
1327
  }
1328
+ return item
1329
+ }
1332
1330
 
1333
- return ids[0];
1331
+ function popContentAndRemoveChildrenFromContentsMap(content, contentsMap) {
1332
+ const children = content.children.map(child => child.id)
1333
+ if (contentsMap.has(content.id)) {
1334
+ contentsMap.delete(content.id)
1335
+ }
1336
+ children.forEach(child => {
1337
+ if (contentsMap.has(child)) {
1338
+ contentsMap.delete(child)
1339
+ }
1340
+ })
1341
+ return contentsMap
1334
1342
  }
1335
1343
 
1336
1344
  /**
@@ -1347,16 +1355,16 @@ export function findIncompleteLesson(progressOnItems, currentContentId, contentT
1347
1355
  * .catch(error => console.error(error));
1348
1356
  */
1349
1357
  export async function pinProgressRow(brand, id, progressType) {
1350
- const url = `/api/user-management-system/v1/progress/pin?brand=${brand}&id=${id}&progressType=${progressType}`;
1358
+ const url = `/api/user-management-system/v1/progress/pin?brand=${brand}&id=${id}&progressType=${progressType}`
1351
1359
  const response = await fetchHandler(url, 'PUT', null)
1352
1360
  if (response && !response.error && response['action'] === 'update_user_pin') {
1353
1361
  await updateUserPinnedProgressRow(brand, {
1354
1362
  id,
1355
1363
  progressType,
1356
1364
  pinnedAt: new Date().toISOString(),
1357
- });
1365
+ })
1358
1366
  }
1359
- return response;
1367
+ return response
1360
1368
  }
1361
1369
  /**
1362
1370
  * Unpins the current pinned progress row for a user, scoped by brand.
@@ -1381,113 +1389,26 @@ export async function unpinProgressRow(brand, id) {
1381
1389
  }
1382
1390
 
1383
1391
  async function updateUserPinnedProgressRow(brand, pinnedData) {
1384
- const userRaw = await globalConfig.localStorage.getItem('user');
1385
- const user = userRaw ? JSON.parse(userRaw) : {};
1392
+ const userRaw = await globalConfig.localStorage.getItem('user')
1393
+ const user = userRaw ? JSON.parse(userRaw) : {}
1386
1394
  user.brand_pinned_progress = user.brand_pinned_progress || {}
1387
1395
  user.brand_pinned_progress[brand] = pinnedData
1388
1396
  await globalConfig.localStorage.setItem('user', JSON.stringify(user))
1389
1397
  }
1390
1398
 
1391
- async function extractPinnedItem(pinned, progressMap, playlistItems) {
1392
- const {id, progressType, pinnedAt} = pinned
1393
-
1394
- if (progressType === 'content') {
1395
- const pinnedId = parseInt(id)
1396
- if (progressMap.has(pinnedId)) {
1397
- const item = progressMap.get(pinnedId)
1398
- progressMap.delete(pinnedId)
1399
- return item
1400
- } else {
1401
- const content = await fetchByRailContentIds([`${pinnedId}`], 'progress-tracker')
1402
- const firstLessonId = getFirstLeafLessonId(content[0])
1403
- return {
1404
- id: pinnedId,
1405
- state: 'started',
1406
- percent: 0,
1407
- raw: content[0],
1408
- progressTimestamp: new Date(pinnedAt).getTime(),
1409
- childIndex: firstLessonId
1410
- }
1411
- }
1412
- }
1413
- if (progressType === 'playlist') {
1414
- const pinnedPlaylist = playlistItems.find(p => p.raw.id === id)
1415
- if (pinnedPlaylist) {
1416
- return pinnedPlaylist
1417
- }else{
1418
- const playlist = await fetchPlaylist(id)
1419
- return {
1420
- id: id,
1421
- raw: playlist,
1422
- progressTimestamp: new Date(pinnedAt).getTime(),
1423
- type: 'playlist',
1424
- last_engaged_on: playlist.last_engaged_on,
1425
- }
1426
- }
1427
- }
1428
-
1429
- return null
1430
- }
1431
-
1432
- async function extractPinnedGuidedCourseItem(guidedCourse, progressMap) {
1433
- const children = guidedCourse.lessons.map(child => child.id)
1434
- let existingGuidedCourseProgress = null
1435
- if (progressMap.has(guidedCourse.id)) {
1436
- existingGuidedCourseProgress = progressMap.get(guidedCourse.id)
1437
- progressMap.delete(guidedCourse.id)
1438
- }
1439
- let lastChild = null
1440
- children.forEach(child => {
1441
- if (progressMap.has(child)) {
1442
- let childProgress = progressMap.get(child)
1443
- if (!lastChild && childProgress.state !== 'completed') {
1444
- lastChild = childProgress
1445
- lastChild.id = child
1446
- }
1447
- progressMap.delete(child)
1448
- }
1449
- })
1450
- return existingGuidedCourseProgress ?? {
1451
- id: guidedCourse.id,
1452
- state: 'not-started',
1453
- percent: 0,
1454
- raw: guidedCourse,
1455
- pinned: true,
1456
- progressTimestamp: new Date().getTime(),
1457
- childIndex: guidedCourse.id,
1458
- }
1459
- }
1460
-
1461
- function getFirstLeafLessonId(data) {
1462
- function findFirstLeaf(lessons) {
1463
- for (const item of lessons) {
1464
- if (!item.lessons || item.lessons.length === 0) {
1465
- return item.id || null
1466
- }
1467
- const found = findFirstLeaf(item.lessons)
1468
- if (found) return found
1469
- }
1470
- return null
1471
- }
1472
-
1473
- return data.lessons ? findFirstLeaf(data.lessons) : null
1474
- }
1475
-
1476
1399
  export async function fetchRecentActivitiesActiveTabs() {
1477
1400
  const url = `/api/user-management-system/v1/activities/tabs`
1478
1401
  try {
1479
- const tabs = await fetchHandler(url, 'GET');
1480
- const activitiesTabs = [];
1402
+ const tabs = await fetchHandler(url, 'GET')
1403
+ const activitiesTabs = []
1481
1404
 
1482
- tabs.forEach(tab => {
1483
- activitiesTabs.push({ name: tab.label, short_name:tab.label });
1484
- });
1405
+ tabs.forEach((tab) => {
1406
+ activitiesTabs.push({ name: tab.label, short_name: tab.label })
1407
+ })
1485
1408
 
1486
- return activitiesTabs;
1409
+ return activitiesTabs
1487
1410
  } catch (error) {
1488
- console.error('Error fetching activity tabs:', error);
1489
- return [];
1411
+ console.error('Error fetching activity tabs:', error)
1412
+ return []
1490
1413
  }
1491
1414
  }
1492
-
1493
-