musora-content-services 2.19.2 → 2.20.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ## [2.20.0](https://github.com/railroadmedia/musora-content-services/compare/v2.19.2...v2.20.0) (2025-07-11)
6
+
7
+
8
+ ### Features
9
+
10
+ * **BEH-662:** add pinned guided course to homepage ([#332](https://github.com/railroadmedia/musora-content-services/issues/332)) ([6a06612](https://github.com/railroadmedia/musora-content-services/commit/6a066121684cbab1610119906568e4320c4df08c))
11
+ * **BEH-706:** add continue behaviour to lessons collection page and index page ([#326](https://github.com/railroadmedia/musora-content-services/issues/326)) ([6add054](https://github.com/railroadmedia/musora-content-services/commit/6add054841688ea0cd550f11ad21e047de63f235))
12
+
5
13
  ### [2.19.2](https://github.com/railroadmedia/musora-content-services/compare/v2.19.1...v2.19.2) (2025-07-09)
6
14
 
7
15
  ### [2.19.1](https://github.com/railroadmedia/musora-content-services/compare/v2.19.0...v2.19.1) (2025-07-09)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.19.2",
3
+ "version": "2.20.0",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -180,6 +180,8 @@ export const lessonTypesMapping = {
180
180
  'jam tracks': ['jam-track'],
181
181
  };
182
182
 
183
+ export const getNextLessonLessonParentTypes = ['course', 'guided-course', 'pack-bundle'];
184
+
183
185
  export const progressTypesMapping = {
184
186
  'lesson': [...singleLessonTypes,...practiceAlongsLessonTypes, ...liveArchivesLessonTypes, ...performancesLessonTypes, ...studentArchivesLessonTypes, ...documentariesLessonTypes, 'live'],
185
187
  'course': ['course'],
@@ -212,7 +214,27 @@ export const recentTypes = {
212
214
 
213
215
  export let contentTypeConfig = {
214
216
  'progress-tracker': {
215
- fields: ['"parent_content_data": parent_content_data[].id','"badge" : badge.asset->url','"lessons": child[]->{"id": railcontent_id, "slug":slug.current, "brand":brand, "type": _type, "lessons": child[]->{"id":railcontent_id, "slug":slug.current, "type": _type,"brand":brand}}'],
217
+ fields: ['"parent_content_data": parent_content_data[].id',
218
+ '"badge" : badge.asset->url',
219
+ '"lessons": child[]->{' +
220
+ '"id": railcontent_id,' +
221
+ '"slug":slug.current,' +
222
+ '"brand":brand,' +
223
+ '"type": _type,' +
224
+ '"thumbnail": thumbnail.asset->url,' +
225
+ 'published_on,' +
226
+ '"lessons": child[]->{' +
227
+ '"id":railcontent_id,' +
228
+ '"slug":slug.current,' +
229
+ '"type": _type,' +
230
+ '"brand":brand},' +
231
+ '"thumbnail": thumbnail.asset->url,' +
232
+ 'published_on,' +
233
+ '}'
234
+ ],
235
+
236
+
237
+
216
238
  },
217
239
  song: {
218
240
  fields: ['album', 'soundslice', 'instrumentless', `"resources": ${resourcesField}`],
@@ -420,10 +442,14 @@ export let contentTypeConfig = {
420
442
  '"logo_image_url": logo_image_url.asset->url',
421
443
  'total_xp',
422
444
  `"children": child[]->{
423
- "description": ${descriptionField},
424
- "lesson_count": child_count,
425
- ${getFieldsForContentType()}
426
- }`,
445
+ "description": ${descriptionField},
446
+ "lesson_count": child_count,
447
+ "children": child[]->{
448
+ "description": ${descriptionField},
449
+ ${getFieldsForContentType()}
450
+ },
451
+ ${getFieldsForContentType()}
452
+ }`,
427
453
  `"resources": ${resourcesField}`,
428
454
  '"thumbnail": thumbnail.asset->url',
429
455
  '"light_mode_logo": light_mode_logo_url.asset->url',
package/src/index.d.ts CHANGED
@@ -62,6 +62,8 @@ import {
62
62
  getAllCompleted,
63
63
  getAllStarted,
64
64
  getAllStartedOrCompleted,
65
+ getLastInteractedOf,
66
+ getNextLesson,
65
67
  getProgressDateByIds,
66
68
  getProgressPercentage,
67
69
  getProgressPercentageByIds,
@@ -284,6 +286,7 @@ import {
284
286
  createPracticeNotes,
285
287
  deletePracticeSession,
286
288
  deleteUserActivity,
289
+ findIncompleteLesson,
287
290
  getPracticeNotes,
288
291
  getPracticeSessions,
289
292
  getProgressRows,
@@ -422,14 +425,17 @@ declare module 'musora-content-services' {
422
425
  fetchUserPracticeMeta,
423
426
  fetchUserPracticeNotes,
424
427
  fetchUserPractices,
428
+ findIncompleteLesson,
425
429
  getActiveDiscussions,
426
430
  getAllCompleted,
427
431
  getAllStarted,
428
432
  getAllStartedOrCompleted,
429
433
  getContentRows,
434
+ getLastInteractedOf,
430
435
  getLessonContentRows,
431
436
  getMonday,
432
437
  getNewAndUpcoming,
438
+ getNextLesson,
433
439
  getPracticeNotes,
434
440
  getPracticeSessions,
435
441
  getProgressDateByIds,
package/src/index.js CHANGED
@@ -62,6 +62,8 @@ import {
62
62
  getAllCompleted,
63
63
  getAllStarted,
64
64
  getAllStartedOrCompleted,
65
+ getLastInteractedOf,
66
+ getNextLesson,
65
67
  getProgressDateByIds,
66
68
  getProgressPercentage,
67
69
  getProgressPercentageByIds,
@@ -284,6 +286,7 @@ import {
284
286
  createPracticeNotes,
285
287
  deletePracticeSession,
286
288
  deleteUserActivity,
289
+ findIncompleteLesson,
287
290
  getPracticeNotes,
288
291
  getPracticeSessions,
289
292
  getProgressRows,
@@ -421,14 +424,17 @@ export {
421
424
  fetchUserPracticeMeta,
422
425
  fetchUserPracticeNotes,
423
426
  fetchUserPractices,
427
+ findIncompleteLesson,
424
428
  getActiveDiscussions,
425
429
  getAllCompleted,
426
430
  getAllStarted,
427
431
  getAllStartedOrCompleted,
428
432
  getContentRows,
433
+ getLastInteractedOf,
429
434
  getLessonContentRows,
430
435
  getMonday,
431
436
  getNewAndUpcoming,
437
+ getNextLesson,
432
438
  getPracticeNotes,
433
439
  getPracticeSessions,
434
440
  getProgressDateByIds,
@@ -40,7 +40,7 @@ export async function guidedCourses() {
40
40
  return await fetchHandler(url, 'GET')
41
41
  }
42
42
 
43
- export async function pinnedGuidedCourses() {
44
- const url: string = `${BASE_PATH}/v1/user/guided-courses/pinned`
43
+ export async function pinnedGuidedCourses(brand) {
44
+ const url: string = `${BASE_PATH}/v1/user/guided-courses/pinned?brand=${brand}`
45
45
  return await fetchHandler(url, 'GET')
46
46
  }
@@ -1,7 +1,12 @@
1
- import { getProgressStateByIds, getProgressPercentageByIds, getResumeTimeSecondsByIds } from "./contentProgress"
2
- import { isContentLikedByIds } from "./contentLikes"
3
- import { fetchLikeCount, fetchLastInteractedChild } from "./railcontent"
4
-
1
+ import {
2
+ getLastInteractedOf,
3
+ getNextLesson,
4
+ getProgressPercentageByIds,
5
+ getProgressStateByIds,
6
+ getResumeTimeSecondsByIds
7
+ } from "./contentProgress"
8
+ import {isContentLikedByIds} from "./contentLikes"
9
+ import {fetchLastInteractedChild, fetchLikeCount} from "./railcontent"
5
10
 
6
11
 
7
12
  export async function addContextToContent(dataPromise, ...dataArgs)
@@ -17,6 +22,8 @@ export async function addContextToContent(dataPromise, ...dataArgs)
17
22
  addProgressStatus = false,
18
23
  addResumeTimeSeconds = false,
19
24
  addLastInteractedChild = false,
25
+ addNextLesson = false,
26
+ addLastInteractedParent = false,
20
27
  } = options
21
28
 
22
29
  const dataParam = lastArg === options ? dataArgs.slice(0, -1) : dataArgs
@@ -24,26 +31,43 @@ export async function addContextToContent(dataPromise, ...dataArgs)
24
31
  const data = await dataPromise(...dataParam)
25
32
  if(!data) return false
26
33
 
27
- let ids = []
34
+ let items = []
35
+ let dataMap = []
28
36
 
29
37
  if (dataField && data?.[dataField]) {
30
- ids = data[dataField].map(item => item?.id).filter(Boolean);
38
+ items = data[dataField];
31
39
  } else if (Array.isArray(data)) {
32
- ids = data.map(item => item?.id).filter(Boolean);
40
+ items = data;
33
41
  } else if (data?.id) {
34
- ids = [data.id]
42
+ items = [data]
43
+ }
44
+
45
+ const ids = items.map(item => item?.id).filter(Boolean)
46
+
47
+ //create data structure for common use by functions
48
+ if (addNextLesson) {
49
+ items.forEach((item) => {
50
+ if (item?.id) {
51
+ dataMap.push({
52
+ 'children': item.children?.map(child => child.id) ?? [],
53
+ 'type': item.type,
54
+ 'id': item.id,
55
+ })
56
+ }
57
+ })
35
58
  }
36
59
 
37
60
  if(ids.length === 0) return false
38
61
 
39
- const [progressPercentageData, progressStatusData, isLikedData, resumeTimeData, lastInteractedChildData] = await Promise.all([
62
+ const [progressPercentageData, progressStatusData, isLikedData, resumeTimeData, lastInteractedChildData, nextLessonData] = await Promise.all([
40
63
  addProgressPercentage ? getProgressPercentageByIds(ids) : Promise.resolve(null),
41
64
  addProgressStatus ? getProgressStateByIds(ids) : Promise.resolve(null),
42
65
  addIsLiked ? isContentLikedByIds(ids) : Promise.resolve(null),
43
66
  addResumeTimeSeconds ? getResumeTimeSecondsByIds(ids) : Promise.resolve(null),
44
67
  addLastInteractedChild ? fetchLastInteractedChild(ids) : Promise.resolve(null),
68
+ (addNextLesson || addLastInteractedParent) ? getNextLesson(dataMap) : Promise.resolve(null),
45
69
  ])
46
-
70
+
47
71
  const addContext = async (item) => ({
48
72
  ...item,
49
73
  ...(addProgressPercentage ? { progressPercentage: progressPercentageData?.[item.id] } : {}),
@@ -52,8 +76,14 @@ export async function addContextToContent(dataPromise, ...dataArgs)
52
76
  ...(addLikeCount && ids.length === 1 ? { likeCount: await fetchLikeCount(item.id) } : {}),
53
77
  ...(addResumeTimeSeconds ? { resumeTime: resumeTimeData?.[item.id] } : {}),
54
78
  ...(addLastInteractedChild ? { lastInteractedChild: lastInteractedChildData?.[item.id] } : {}),
79
+ ...(addNextLesson ? { nextLesson: nextLessonData?.[item.id] } : {}),
55
80
  })
56
-
81
+
82
+ if (addLastInteractedParent) {
83
+ const parentId = await getLastInteractedOf(ids);
84
+ data['nextLesson'] = nextLessonData[parentId];
85
+ }
86
+
57
87
  if (dataField) {
58
88
  data[dataField] = Array.isArray(data[dataField])
59
89
  ? await Promise.all(data[dataField].map(addContext))
@@ -5,8 +5,9 @@ import {
5
5
  postRecordWatchSession,
6
6
  } from './railcontent.js'
7
7
  import { DataContext, ContentProgressVersionKey } from './dataContext.js'
8
- import { fetchHierarchy } from './sanity.js'
9
- import {recordUserPractice} from "./userActivity";
8
+ import {fetchHierarchy} from './sanity.js'
9
+ import {recordUserPractice, findIncompleteLesson} from "./userActivity";
10
+ import {getNextLessonLessonParentTypes} from "../contentTypeConfig.js";
10
11
 
11
12
  const STATE_STARTED = 'started'
12
13
  const STATE_COMPLETED = 'completed'
@@ -14,10 +15,11 @@ const DATA_KEY_STATUS = 's'
14
15
  const DATA_KEY_PROGRESS = 'p'
15
16
  const DATA_KEY_RESUME_TIME = 't'
16
17
  const DATA_KEY_LAST_UPDATED_TIME = 'u'
18
+
17
19
  export let dataContext = new DataContext(ContentProgressVersionKey, fetchContentProgress)
18
20
 
19
21
  export async function getProgressPercentage(contentId) {
20
- return getById(contentIds, DATA_KEY_PROGRESS, 0)
22
+ return getById(contentId, DATA_KEY_PROGRESS, 0)
21
23
  }
22
24
 
23
25
  export async function getProgressPercentageByIds(contentIds) {
@@ -40,6 +42,70 @@ export async function getResumeTimeSecondsByIds(contentIds) {
40
42
  return getByIds(contentIds, DATA_KEY_RESUME_TIME, 0)
41
43
  }
42
44
 
45
+ export async function getNextLesson(dataMap)
46
+ {
47
+ let nextLessonData = {}
48
+
49
+ for (const content of dataMap) {
50
+
51
+ //only calculate nextLesson if needed, based on content type
52
+ if (!getNextLessonLessonParentTypes.includes(content.type)) {
53
+ nextLessonData[content.id] = null
54
+
55
+ } else {
56
+ //return first child if parent-content is complete or no progress
57
+ const contentState = await getProgressState(content.id)
58
+ if (contentState !== STATE_STARTED) {
59
+ nextLessonData[content.id] = content.children[0]
60
+
61
+ } else {
62
+ //if content in progress
63
+
64
+ const childrenStates = await getProgressStateByIds(content.children)
65
+
66
+ //calculate last_engaged
67
+ const lastInteracted = await getLastInteractedOf(content.children)
68
+ const lastInteractedStatus = childrenStates[lastInteracted]
69
+
70
+ //different nextLesson behaviour for different content types
71
+ if (content.type === 'course' || content.type === 'pack-bundle') {
72
+ if (lastInteractedStatus === STATE_STARTED) {
73
+ nextLessonData[content.id] = lastInteracted
74
+ } else {
75
+ nextLessonData[content.id] = findIncompleteLesson(childrenStates, lastInteracted, content.type)
76
+ }
77
+
78
+ } else if (content.type === 'guided-course') {
79
+ nextLessonData[content.id] = findIncompleteLesson(childrenStates, lastInteracted, content.type)
80
+ }
81
+ }
82
+ }
83
+ }
84
+ return nextLessonData
85
+ }
86
+
87
+ /**
88
+ * filter through contents, only keeping the most recent
89
+ * @param {array} contentIds
90
+ * @returns {Promise<number>}
91
+ */
92
+ export async function getLastInteractedOf(contentIds) {
93
+ const data = await getByIds(contentIds, DATA_KEY_LAST_UPDATED_TIME, 0)
94
+ const sorted = Object.keys(data)
95
+ .map(function (key) {
96
+ return parseInt(key)
97
+ })
98
+ .sort(function (a, b) {
99
+ let v1 = data[a]
100
+ let v2 = data[b]
101
+ if (v1 > v2) return -1
102
+ else if (v1 < v2) return 1
103
+ return 0
104
+ })
105
+
106
+ return sorted[0]
107
+ }
108
+
43
109
  export async function getProgressDateByIds(contentIds) {
44
110
  let data = await dataContext.getData()
45
111
  let progress = {}
File without changes
@@ -513,7 +513,7 @@ export async function fetchByRailContentId(id, contentType) {
513
513
  * .catch(error => console.error(error));
514
514
  */
515
515
  export async function fetchByRailContentIds(ids, contentType = undefined, brand = undefined) {
516
- if (!ids) {
516
+ if (!ids?.length) {
517
517
  return []
518
518
  }
519
519
  const idsString = ids.join(',')
@@ -2356,9 +2356,11 @@ export async function fetchTabData(
2356
2356
 
2357
2357
  filter = `brand == "${brand}" ${includedFieldsFilter} ${progressFilter}`
2358
2358
  const childrenFilter = await new FilterBuilder(``, { isChildrenFilter: true }).buildFilter()
2359
- entityFieldsString = ` ${fieldsString},
2360
- 'lesson_count': coalesce(count(child[${childrenFilter}]->), 0) ,
2361
- 'length_in_seconds': coalesce(
2359
+ entityFieldsString =
2360
+ ` ${fieldsString},
2361
+ 'children': child[${childrenFilter}]->{'id': railcontent_id},
2362
+ 'lesson_count': coalesce(count(child[${childrenFilter}]->), 0),
2363
+ 'length_in_seconds': coalesce(
2362
2364
  math::sum(
2363
2365
  select(
2364
2366
  child[${childrenFilter}]->length_in_seconds
@@ -8,16 +8,25 @@ import {
8
8
  fetchUserPracticeMeta,
9
9
  fetchUserPracticeNotes,
10
10
  fetchHandler,
11
- fetchRecentUserActivities, fetchChallengeLessonData
11
+ fetchRecentUserActivities,
12
+ fetchChallengeLessonData,
13
+ fetchLastInteractedChild,
12
14
  } from './railcontent'
13
15
  import { DataContext, UserActivityVersionKey } from './dataContext.js'
14
16
  import { fetchByRailContentIds, fetchShows } from './sanity'
15
- import {fetchPlaylist, fetchUserPlaylists} from "./content-org/playlists";
17
+ import {fetchPlaylist, fetchUserPlaylists} from "./content-org/playlists"
18
+ import {pinnedGuidedCourses} from "./content-org/guided-courses"
16
19
  import {convertToTimeZone, getMonday, getWeekNumber, isSameDate, isNextDay, getTimeRemainingUntilLocal} from './dateUtils.js'
17
20
  import { globalConfig } from './config'
18
21
  import {collectionLessonTypes, lessonTypesMapping, progressTypesMapping, showsLessonTypes, songs} from "../contentTypeConfig";
19
- import {getAllStartedOrCompleted, getProgressStateByIds} from "./contentProgress";
22
+ import {
23
+ getAllStartedOrCompleted,
24
+ getProgressPercentageByIds,
25
+ getProgressStateByIds,
26
+ getResumeTimeSecondsByIds
27
+ } from "./contentProgress";
20
28
  import {TabResponseType} from "../contentMetaData";
29
+ import {isContentLikedByIds} from "./contentLikes.js";
21
30
 
22
31
  const DATA_KEY_PRACTICES = 'practices'
23
32
  const DATA_KEY_LAST_UPDATED_TIME = 'u'
@@ -351,7 +360,7 @@ export async function removeUserPractice(id) {
351
360
  Object.keys(localContext.data[DATA_KEY_PRACTICES]).forEach((date) => {
352
361
  localContext.data[DATA_KEY_PRACTICES][date] = localContext.data[DATA_KEY_PRACTICES][
353
362
  date
354
- ].filter((practice) => practice.id !== id)
363
+ ].filter((practice) => practice.id !== id)
355
364
  })
356
365
  }
357
366
  },
@@ -911,26 +920,45 @@ export async function getProgressRows({ brand = null, limit = 8 } = {}) {
911
920
  'learning-path-course',
912
921
  'learning-path-level'
913
922
  ]);
923
+ // TODO slice progress to a reasonable number, say 100
924
+ const [recentPlaylists, progressContents, allPinnedGuidedCourse, userPinnedItem ] = await Promise.all([
925
+ fetchUserPlaylists(brand, { sort: '-last_progress', limit: limit}),
926
+ getAllStartedOrCompleted({onlyIds: false, brand: brand }),
927
+ pinnedGuidedCourses(brand),
928
+ getUserPinnedItem(brand),
929
+ ])
930
+
931
+ let pinnedGuidedCourse = allPinnedGuidedCourse?.[0] ?? null
914
932
 
915
- const recentPlaylists = await fetchUserPlaylists(brand, {
916
- sort: '-last_progress',
917
- limit: limit,
918
- });
919
933
  const playlists = recentPlaylists?.data || [];
920
934
  const eligiblePlaylistItems = await getEligiblePlaylistItems(playlists);
921
935
  const playlistEngagedOnContents = eligiblePlaylistItems.map(item => item.last_engaged_on);
922
- const playlistsContents = await fetchByRailContentIds(playlistEngagedOnContents, 'progress-tracker');
936
+
937
+ const nonPlaylistContentIds = Object.keys(progressContents)
938
+ if (pinnedGuidedCourse) {
939
+ nonPlaylistContentIds.push(pinnedGuidedCourse.content_id);
940
+ }
941
+ if (userPinnedItem?.progressType === 'content') {
942
+ nonPlaylistContentIds.push(userPinnedItem.id)
943
+ }
944
+ const [ playlistsContents, contents ] = await Promise.all([
945
+ fetchByRailContentIds(playlistEngagedOnContents, 'progress-tracker'),
946
+ fetchByRailContentIds(nonPlaylistContentIds, 'progress-tracker', brand),
947
+ ]);
948
+
923
949
  const excludedParents = new Set();
924
950
  const existingShows = new Set();
951
+ // TODO this doesn't work for guided courses as the GC card takes precedence over the playlist card
952
+ // https://musora.atlassian.net/browse/BEH-812
925
953
  for (const item of playlistsContents) {
954
+
926
955
  const contentId = item.id ?? item.railcontent_id;
927
- excludedParents.add(contentId)
956
+ delete progressContents[contentId]
928
957
  const parentIds = item.parent_content_data || [];
929
- parentIds.forEach(id => excludedParents.add(id));
958
+ parentIds.forEach(id => delete progressContents[id] );
930
959
  }
931
960
 
932
- const progressContents = await getAllStartedOrCompleted({onlyIds: false, brand: brand, excludedIds: Array.from(excludedParents) });
933
- const contents = await fetchByRailContentIds(Object.keys(progressContents), 'progress-tracker', brand);
961
+
934
962
  const contentsMap = {};
935
963
  contents.forEach(content => {
936
964
  contentsMap[content.railcontent_id] = content;
@@ -985,28 +1013,38 @@ export async function getProgressRows({ brand = null, limit = 8 } = {}) {
985
1013
  }
986
1014
  }
987
1015
  }
988
- const pinnedItem = await extractPinnedItem({
989
- brand,
1016
+ const pinnedItem = userPinnedItem ? await extractPinnedItem(
1017
+ userPinnedItem,
990
1018
  progressMap,
991
- playlistItems: eligiblePlaylistItems,
992
- })
993
- const progressList = Array.from(progressMap.values())
1019
+ eligiblePlaylistItems,
1020
+ ) : null
1021
+
1022
+ const pinnedId = pinnedItem?.id
1023
+ const guidedCourseID = pinnedGuidedCourse?.content_id
1024
+ let combined = [];
1025
+ if (pinnedGuidedCourse) {
1026
+ const guidedCourseContent = contentsMap[guidedCourseID]
1027
+ if (guidedCourseContent) {
1028
+ const temp = await extractPinnedGuidedCourseItem(guidedCourseContent, progressMap)
1029
+ temp.pinned = true
1030
+ combined.push(temp)
1031
+ }
1032
+ }
994
1033
  if (pinnedItem) {
995
1034
  pinnedItem.pinned = true
1035
+ combined.push(pinnedItem)
996
1036
  }
1037
+ const progressList = Array.from(progressMap.values())
997
1038
 
998
- const pinnedId = pinnedItem?.id
999
1039
  const filteredProgressList = pinnedId
1000
- ? progressList.filter(item => item.id !== pinnedId)
1040
+ ? progressList.filter(item => !(item.id === pinnedId || item.id === guidedCourseID))
1001
1041
  : progressList;
1002
1042
  const filteredPlaylists = pinnedId
1003
- ? eligiblePlaylistItems.filter(item => item.id !== pinnedId)
1043
+ ? (eligiblePlaylistItems.filter(item => !(item.id === pinnedId || item.id === guidedCourseID)))
1004
1044
  : eligiblePlaylistItems;
1005
- const combinedBase = [...filteredProgressList, ...filteredPlaylists]
1006
- const combined = pinnedItem ? [pinnedItem, ...combinedBase] : combinedBase
1007
1045
 
1046
+ combined = [...combined, ...filteredProgressList, ...filteredPlaylists]
1008
1047
  const finalCombined = mergeAndSortItems(combined, limit)
1009
-
1010
1048
  const results = await Promise.all(
1011
1049
  finalCombined.slice(0, limit).map(item =>
1012
1050
  item.type === 'playlist'
@@ -1022,11 +1060,17 @@ export async function getProgressRows({ brand = null, limit = 8 } = {}) {
1022
1060
  };
1023
1061
  }
1024
1062
 
1063
+ async function getUserPinnedItem(brand) {
1064
+ const userRaw = await globalConfig.localStorage.getItem('user');
1065
+ const user = userRaw ? JSON.parse(userRaw) : {};
1066
+ user.brand_pinned_progress = user.brand_pinned_progress || {}
1067
+ return user.brand_pinned_progress[brand] ?? null
1068
+ }
1069
+
1025
1070
  async function processContentItem(item) {
1026
1071
  let data = item.raw;
1027
1072
  const contentType = getFormattedType(data.type, data.brand);
1028
1073
  const status = item.state;
1029
-
1030
1074
  let ctaText = 'Continue';
1031
1075
  if (contentType === 'transcription' || contentType === 'play-along' || contentType === 'jam-track') ctaText = 'Replay Song';
1032
1076
  if (contentType === 'lesson') ctaText = status === 'completed' ? 'Revisit Lesson' : 'Continue';
@@ -1064,6 +1108,29 @@ async function processContentItem(item) {
1064
1108
  const nextLesson = lessons.find(lesson => lesson.id === nextId)
1065
1109
  data.first_incomplete_child = nextLesson?.parent ?? nextLesson
1066
1110
  data.second_incomplete_child = (nextLesson?.parent) ? nextLesson : null
1111
+ if(data.type === 'guided-course'){
1112
+ let isLocked = new Date(nextLesson.published_on) > new Date()
1113
+ data.thumbnail = nextLesson.thumbnail
1114
+ // USHP-4 completed
1115
+ if (status === 'completed') {
1116
+ // duplicated code to above, but here for clarity
1117
+ ctaText = 'Revisit Lessons'
1118
+ // USHP-1 if lesson locked show unlock in X time
1119
+ } else if (isLocked) {
1120
+ data.is_locked = true
1121
+ const timeRemaining = getTimeRemainingUntilLocal(nextLesson.published_on, {withTotalSeconds: true})
1122
+ data.time_remaining_seconds = timeRemaining.totalSeconds
1123
+ ctaText = 'Next lesson in ' + timeRemaining.formatted
1124
+ }
1125
+ // USHP-2 start course if not started
1126
+ else if (status === 'not-started') {
1127
+ ctaText = "Start Course"
1128
+ }
1129
+ // USHP-3 in progress for lesson
1130
+ else {
1131
+ ctaText = "Continue"
1132
+ }
1133
+ }
1067
1134
  }
1068
1135
  }
1069
1136
 
@@ -1079,7 +1146,7 @@ async function processContentItem(item) {
1079
1146
  data.completed_children = completedCount;
1080
1147
  data.child_count = shows.length;
1081
1148
  item.percent = Math.round((completedCount / shows.length) * 100);
1082
- if(completedCount == shows.length) {
1149
+ if(completedCount === shows.length) {
1083
1150
  ctaText = 'Revisit Lessons';
1084
1151
  }
1085
1152
  }
@@ -1096,8 +1163,8 @@ async function processContentItem(item) {
1096
1163
  badge: data.badge ?? null,
1097
1164
  isLocked: data.is_locked ?? false,
1098
1165
  subtitle: !data.child_count || data.lesson_count === 1
1099
- ? (contentType === 'lesson') ? `${item.percent}% Complete`: `${data.difficulty_string} • ${data.artist_name}`
1100
- : `${data.completed_children} of ${data.lesson_count ?? data.child_count} Lessons Complete`
1166
+ ? (contentType === 'lesson') ? `${item.percent}% Complete`: `${data.difficulty_string} • ${data.artist_name}`
1167
+ : `${data.completed_children} of ${data.lesson_count ?? data.child_count} Lessons Complete`
1101
1168
  },
1102
1169
  cta: {
1103
1170
  text: ctaText,
@@ -1108,21 +1175,21 @@ async function processContentItem(item) {
1108
1175
  id: data.id,
1109
1176
  slug: data.slug,
1110
1177
  child: data.first_incomplete_child
1111
- ? {
1178
+ ? {
1112
1179
  id: data.first_incomplete_child.id,
1113
1180
  type: data.first_incomplete_child.type,
1114
1181
  brand: data.first_incomplete_child.brand,
1115
1182
  slug: data.first_incomplete_child.slug,
1116
1183
  child: data.second_incomplete_child
1117
- ? {
1184
+ ? {
1118
1185
  id: data.second_incomplete_child.id,
1119
1186
  type: data.second_incomplete_child.type,
1120
1187
  brand: data.second_incomplete_child.brand,
1121
1188
  slug: data.second_incomplete_child.slug
1122
1189
  }
1123
- : null
1190
+ : null
1124
1191
  }
1125
- : null
1192
+ : null
1126
1193
  }
1127
1194
  },
1128
1195
  progressTimestamp: item.progressTimestamp
@@ -1234,12 +1301,13 @@ function mergeAndSortItems(items, limit) {
1234
1301
  .sort((a, b) => {
1235
1302
  if (a.pinned && !b.pinned) return -1;
1236
1303
  if (!a.pinned && b.pinned) return 1;
1304
+ // TODO guided course should always be before user pinned item
1237
1305
  return b.progressTimestamp - a.progressTimestamp;
1238
1306
  })
1239
1307
  .slice(0, limit + 5);
1240
1308
  }
1241
1309
 
1242
- function findIncompleteLesson(progressOnItems, currentContentId, contentType) {
1310
+ export function findIncompleteLesson(progressOnItems, currentContentId, contentType) {
1243
1311
  const ids = Object.keys(progressOnItems).map(Number);
1244
1312
  if (contentType === 'guided-course') {
1245
1313
  // Return first incomplete lesson
@@ -1274,11 +1342,10 @@ function findIncompleteLesson(progressOnItems, currentContentId, contentType) {
1274
1342
  * .catch(error => console.error(error));
1275
1343
  */
1276
1344
  export async function pinProgressRow(brand, id, progressType) {
1277
- if (!(brand && id && progressType)) throw new Error(`undefined parameter progressType: ${progressType} brand: ${brand} or id: ${id}`)
1278
1345
  const url = `/api/user-management-system/v1/progress/pin?brand=${brand}&id=${id}&progressType=${progressType}`;
1279
1346
  const response = await fetchHandler(url, 'PUT', null)
1280
- if (response && !response.error && response['action'] === 'update_user_pin') {
1281
- await updateUserPinnedProgressRow(brand, {
1347
+ if (response && !response.error) {
1348
+ await updatePinnedProgressRow(brand, {
1282
1349
  id,
1283
1350
  progressType,
1284
1351
  pinnedAt: new Date().toISOString(),
@@ -1290,25 +1357,23 @@ export async function pinProgressRow(brand, id, progressType) {
1290
1357
  * Unpins the current pinned progress row for a user, scoped by brand.
1291
1358
  *
1292
1359
  * @param {string} brand - The brand context for the unpin action.
1293
- * @param {string} id - The content or playlist id to unpin.
1294
1360
  * @returns {Promise<Object>} - A promise resolving to the response from the unpin API.
1295
1361
  *
1296
1362
  * @example
1297
- * unpinProgressRow('drumeo', 123456)
1363
+ * unpinProgressRow('drumeo')
1298
1364
  * .then(response => console.log(response))
1299
1365
  * .catch(error => console.error(error));
1300
1366
  */
1301
- export async function unpinProgressRow(brand, id) {
1302
- if (!(brand && id)) throw new Error(`undefined parameter brand: ${brand} or id: ${id}`)
1303
- const url = `/api/user-management-system/v1/progress/unpin?brand=${brand}&id=${id}`
1367
+ export async function unpinProgressRow(brand) {
1368
+ const url = `/api/user-management-system/v1/progress/unpin?brand=${brand}`
1304
1369
  const response = await fetchHandler(url, 'PUT', null)
1305
- if (response && !response.error && response['action'] === 'clear_user_pin') {
1306
- await updateUserPinnedProgressRow(brand, null)
1370
+ if (response && !response.error) {
1371
+ await updatePinnedProgressRow(brand, null)
1307
1372
  }
1308
1373
  return response
1309
1374
  }
1310
1375
 
1311
- async function updateUserPinnedProgressRow(brand, pinnedData) {
1376
+ async function updatePinnedProgressRow(brand, pinnedData) {
1312
1377
  const userRaw = await globalConfig.localStorage.getItem('user');
1313
1378
  const user = userRaw ? JSON.parse(userRaw) : {};
1314
1379
  user.brand_pinned_progress = user.brand_pinned_progress || {}
@@ -1316,14 +1381,7 @@ async function updateUserPinnedProgressRow(brand, pinnedData) {
1316
1381
  await globalConfig.localStorage.setItem('user', JSON.stringify(user))
1317
1382
  }
1318
1383
 
1319
- async function extractPinnedItem({brand, progressMap, playlistItems}) {
1320
- const userRaw = await globalConfig.localStorage.getItem('user');
1321
- const user = userRaw ? JSON.parse(userRaw) : {};
1322
- user.brand_pinned_progress = user.brand_pinned_progress || {}
1323
-
1324
- const pinned = user.brand_pinned_progress[brand]
1325
- if (!pinned) return null
1326
-
1384
+ async function extractPinnedItem(pinned, progressMap, playlistItems) {
1327
1385
  const {id, progressType, pinnedAt} = pinned
1328
1386
 
1329
1387
  if (progressType === 'content') {
@@ -1356,7 +1414,7 @@ async function extractPinnedItem({brand, progressMap, playlistItems}) {
1356
1414
  raw: playlist,
1357
1415
  progressTimestamp: new Date(pinnedAt).getTime(),
1358
1416
  type: 'playlist',
1359
- last_engaged_on: playlist.items[0],
1417
+ last_engaged_on: playlist.last_engaged_on,
1360
1418
  }
1361
1419
  }
1362
1420
  }
@@ -1364,6 +1422,35 @@ async function extractPinnedItem({brand, progressMap, playlistItems}) {
1364
1422
  return null
1365
1423
  }
1366
1424
 
1425
+ async function extractPinnedGuidedCourseItem(guidedCourse, progressMap) {
1426
+ const children = guidedCourse.lessons.map(child => child.id)
1427
+ let existingGuidedCourseProgress = null
1428
+ if (progressMap.has(guidedCourse.id)) {
1429
+ existingGuidedCourseProgress = progressMap.get(guidedCourse.id)
1430
+ progressMap.delete(guidedCourse.id)
1431
+ }
1432
+ let lastChild = null
1433
+ children.forEach(child => {
1434
+ if (progressMap.has(child)) {
1435
+ let childProgress = progressMap.get(child)
1436
+ if (!lastChild && childProgress.state !== 'completed') {
1437
+ lastChild = childProgress
1438
+ lastChild.id = child
1439
+ }
1440
+ progressMap.delete(child)
1441
+ }
1442
+ })
1443
+ return existingGuidedCourseProgress ?? {
1444
+ id: guidedCourse.id,
1445
+ state: 'not-started',
1446
+ percent: 0,
1447
+ raw: guidedCourse,
1448
+ pinned: true,
1449
+ progressTimestamp: new Date().getTime(),
1450
+ childIndex: guidedCourse.id,
1451
+ }
1452
+ }
1453
+
1367
1454
  function getFirstLeafLessonId(data) {
1368
1455
  function findFirstLeaf(lessons) {
1369
1456
  for (const item of lessons) {
@@ -1380,4 +1467,3 @@ function getFirstLeafLessonId(data) {
1380
1467
  }
1381
1468
 
1382
1469
 
1383
-