musora-content-services 2.19.2 → 2.21.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.
@@ -0,0 +1,8 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(rg:*)"
5
+ ],
6
+ "deny": []
7
+ }
8
+ }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
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.21.0](https://github.com/railroadmedia/musora-content-services/compare/v2.20.0...v2.21.0) (2025-07-12)
6
+
7
+
8
+ ### Features
9
+
10
+ * **BEH-572:** ai recommender row categories ([#337](https://github.com/railroadmedia/musora-content-services/issues/337)) ([d1984d2](https://github.com/railroadmedia/musora-content-services/commit/d1984d28371fa2d4f22d8ddb50fdcfd952399b46))
11
+
12
+ ## [2.20.0](https://github.com/railroadmedia/musora-content-services/compare/v2.19.2...v2.20.0) (2025-07-11)
13
+
14
+
15
+ ### Features
16
+
17
+ * **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))
18
+ * **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))
19
+
5
20
  ### [2.19.2](https://github.com/railroadmedia/musora-content-services/compare/v2.19.1...v2.19.2) (2025-07-09)
6
21
 
7
22
  ### [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.21.0",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -24,7 +24,6 @@ export const DEFAULT_FIELDS = [
24
24
  "'url': web_url_path",
25
25
  'published_on',
26
26
  "'type': _type",
27
- 'progress_percent',
28
27
  "'length_in_seconds' : coalesce(length_in_seconds, soundslice[0].soundslice_length_in_second)",
29
28
  'brand',
30
29
  "'genre': genre[]->name",
@@ -139,7 +138,7 @@ export const singleLessonTypes = ['quick-tips', 'rudiment', 'coach-lessons'];
139
138
  export const practiceAlongsLessonTypes = ['workout', 'boot-camp','challenges'];
140
139
  export const performancesLessonTypes = ['performance','solo','drum-fest-international-2022'];
141
140
  export const documentariesLessonTypes = ['tama','sonor','history-of-electronic-drums','paiste-cymbals'];
142
- export const liveArchivesLessonTypes = ['podcast', 'coach-stream', 'question-and-answer', 'live-streams'];
141
+ export const liveArchivesLessonTypes = ['podcast', 'coach-stream', 'question-and-answer', 'live-streams', 'live'];
143
142
  export const studentArchivesLessonTypes = ['student-review', 'student-focus','student-collaboration'];
144
143
  export const tutorialsLessonTypes = ['song-tutorial'];
145
144
  export const transcriptionsLessonTypes = ['song'];
@@ -180,6 +179,8 @@ export const lessonTypesMapping = {
180
179
  'jam tracks': ['jam-track'],
181
180
  };
182
181
 
182
+ export const getNextLessonLessonParentTypes = ['course', 'guided-course', 'pack-bundle'];
183
+
183
184
  export const progressTypesMapping = {
184
185
  'lesson': [...singleLessonTypes,...practiceAlongsLessonTypes, ...liveArchivesLessonTypes, ...performancesLessonTypes, ...studentArchivesLessonTypes, ...documentariesLessonTypes, 'live'],
185
186
  'course': ['course'],
@@ -207,12 +208,33 @@ export const filterTypes = {
207
208
  export const recentTypes = {
208
209
  lessons: [...individualLessonsTypes],
209
210
  songs: [...tutorialsLessonTypes, ...transcriptionsLessonTypes, ...playAlongLessonTypes],
210
- home: [...individualLessonsTypes, ...tutorialsLessonTypes, ...transcriptionsLessonTypes, ...playAlongLessonTypes]
211
+ home: [...individualLessonsTypes, ...tutorialsLessonTypes, ...transcriptionsLessonTypes, ...playAlongLessonTypes,
212
+ 'guided-course', 'learning-path', 'live']
211
213
  }
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,
@@ -187,6 +189,7 @@ import {
187
189
  fetchCoachLessons,
188
190
  fetchComingSoon,
189
191
  fetchCommentModContentData,
192
+ fetchContentRows,
190
193
  fetchFoundation,
191
194
  fetchGenreLessons,
192
195
  fetchHierarchy,
@@ -284,6 +287,7 @@ import {
284
287
  createPracticeNotes,
285
288
  deletePracticeSession,
286
289
  deleteUserActivity,
290
+ findIncompleteLesson,
287
291
  getPracticeNotes,
288
292
  getPracticeSessions,
289
293
  getProgressRows,
@@ -361,6 +365,7 @@ declare module 'musora-content-services' {
361
365
  fetchContentInProgress,
362
366
  fetchContentPageUserData,
363
367
  fetchContentProgress,
368
+ fetchContentRows,
364
369
  fetchEnrollmentPageMetadata,
365
370
  fetchFoundation,
366
371
  fetchGenreLessons,
@@ -422,14 +427,17 @@ declare module 'musora-content-services' {
422
427
  fetchUserPracticeMeta,
423
428
  fetchUserPracticeNotes,
424
429
  fetchUserPractices,
430
+ findIncompleteLesson,
425
431
  getActiveDiscussions,
426
432
  getAllCompleted,
427
433
  getAllStarted,
428
434
  getAllStartedOrCompleted,
429
435
  getContentRows,
436
+ getLastInteractedOf,
430
437
  getLessonContentRows,
431
438
  getMonday,
432
439
  getNewAndUpcoming,
440
+ getNextLesson,
433
441
  getPracticeNotes,
434
442
  getPracticeSessions,
435
443
  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,
@@ -187,6 +189,7 @@ import {
187
189
  fetchCoachLessons,
188
190
  fetchComingSoon,
189
191
  fetchCommentModContentData,
192
+ fetchContentRows,
190
193
  fetchFoundation,
191
194
  fetchGenreLessons,
192
195
  fetchHierarchy,
@@ -284,6 +287,7 @@ import {
284
287
  createPracticeNotes,
285
288
  deletePracticeSession,
286
289
  deleteUserActivity,
290
+ findIncompleteLesson,
287
291
  getPracticeNotes,
288
292
  getPracticeSessions,
289
293
  getProgressRows,
@@ -360,6 +364,7 @@ export {
360
364
  fetchContentInProgress,
361
365
  fetchContentPageUserData,
362
366
  fetchContentProgress,
367
+ fetchContentRows,
363
368
  fetchEnrollmentPageMetadata,
364
369
  fetchFoundation,
365
370
  fetchGenreLessons,
@@ -421,14 +426,17 @@ export {
421
426
  fetchUserPracticeMeta,
422
427
  fetchUserPracticeNotes,
423
428
  fetchUserPractices,
429
+ findIncompleteLesson,
424
430
  getActiveDiscussions,
425
431
  getAllCompleted,
426
432
  getAllStarted,
427
433
  getAllStartedOrCompleted,
428
434
  getContentRows,
435
+ getLastInteractedOf,
429
436
  getLessonContentRows,
430
437
  getMonday,
431
438
  getNewAndUpcoming,
439
+ getNextLesson,
432
440
  getPracticeNotes,
433
441
  getPracticeSessions,
434
442
  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
  }
@@ -11,11 +11,12 @@ import {
11
11
  fetchUpcomingEvents,
12
12
  fetchScheduledReleases,
13
13
  fetchReturning,
14
- fetchLeaving, fetchScheduledAndNewReleases
14
+ fetchLeaving, fetchScheduledAndNewReleases, fetchContentRows
15
15
  } from './sanity.js'
16
16
  import {TabResponseType, Tabs, capitalizeFirstLetter} from '../contentMetaData.js'
17
17
  import {fetchHandler} from "./railcontent";
18
- import {recommendations} from "./recommendations";
18
+ import {recommendations, rankCategories} from "./recommendations";
19
+ import {addContextToContent} from "./contentAggregator.js";
19
20
 
20
21
 
21
22
  export async function getLessonContentRows (brand='drumeo', pageName = 'lessons') {
@@ -149,11 +150,11 @@ export async function getRecent(brand, pageName, tabName = 'all', {
149
150
  }
150
151
 
151
152
  /**
152
- * Fetches content rows for a given brand and page with optional filtering by content row id.
153
+ * Fetches content rows for a given brand and page with optional filtering by content row slug.
153
154
  *
154
155
  * @param {string} brand - The brand for which to fetch content rows.
155
156
  * @param {string} pageName - The page name (e.g., 'lessons', 'songs', 'challenges').
156
- * @param {string} [contentRowId] - The specific content row ID to fetch.
157
+ * @param {string|null} contentRowSlug - The specific content row ID to fetch.
157
158
  * @param {Object} params - Parameters for pagination.
158
159
  * @param {number} [params.page=1] - The page number for pagination.
159
160
  * @param {number} [params.limit=10] - The maximum number of content items per row.
@@ -168,32 +169,48 @@ export async function getRecent(brand, pageName, tabName = 'all', {
168
169
  * .then(content => console.log(content))
169
170
  * .catch(error => console.error(error));
170
171
  */
171
- export async function getContentRows(brand, pageName, contentRowId , {
172
+ export async function getContentRows(brand, pageName, contentRowSlug = null, {
172
173
  page = 1,
173
- limit = 10,
174
+ limit = 10
174
175
  } = {}) {
175
- const contentRow = contentRowId ? `&content_row_id=${contentRowId}` : ''
176
- const url = `/api/content/v1/rows?brand=${brand}&page_name=${pageName}${contentRow}&page=${page}&limit=${limit}`;
177
- const contentRows = await fetchHandler(url, 'get', null) || [];
178
- const results = await Promise.all(
179
- contentRows.map(async (row) => {
180
- if (row.content.length === 0){
181
- return { id: row.id, title: row.title, items: [] }
182
- }
183
- const data = await fetchByRailContentIds(row.content)
184
- return { id: row.id, title: row.title, items: data }
185
- })
186
- )
176
+ const sanityData = await addContextToContent(fetchContentRows, brand, pageName, contentRowSlug, {
177
+ dataField: 'content',
178
+ iterateDataFieldOnEachArrayElement: true,
179
+ addProgressStatus: true,
180
+ addProgressPercentage: true,
181
+ addNextLesson: true
182
+ })
183
+ if (!sanityData) {
184
+ return []
185
+ }
186
+ let contentMap = {}
187
+ let recData = {}
188
+ let slugNameMap = {}
189
+ for (const category of sanityData) {
190
+ recData[category.slug] = category.content.map(item => item.railcontent_id)
191
+ for (const content of category.content) {
192
+ contentMap[content.railcontent_id] = content
193
+ }
194
+ slugNameMap[category.slug] = category.name
195
+ }
196
+ const start = (page - 1) * limit
197
+ const end = start + limit
198
+ const sortedData = await rankCategories(brand, recData)
199
+ let finalData = []
200
+ for (const category of sortedData) {
201
+ finalData.push( {
202
+ id: category.slug,
203
+ title: slugNameMap[category.slug],
204
+ items: category.items.slice(start, end).map(id => contentMap[id])})
205
+ }
187
206
 
188
- if (contentRowId) {
189
- return {
207
+ return contentRowSlug ?
208
+ {
190
209
  type: TabResponseType.CATALOG,
191
- data: results[0].items,
210
+ data: finalData[0].items,
192
211
  meta: {}
193
- };
194
- }
195
-
196
- return results
212
+ }
213
+ : finalData
197
214
  }
198
215
 
199
216
  /**
@@ -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)
@@ -11,12 +16,15 @@ export async function addContextToContent(dataPromise, ...dataArgs)
11
16
 
12
17
  const {
13
18
  dataField = null,
19
+ iterateDataFieldOnEachArrayElement = false,
14
20
  addProgressPercentage = false,
15
21
  addIsLiked = false,
16
22
  addLikeCount = false,
17
23
  addProgressStatus = false,
18
24
  addResumeTimeSeconds = false,
19
25
  addLastInteractedChild = false,
26
+ addNextLesson = false,
27
+ addLastInteractedParent = false,
20
28
  } = options
21
29
 
22
30
  const dataParam = lastArg === options ? dataArgs.slice(0, -1) : dataArgs
@@ -24,26 +32,49 @@ export async function addContextToContent(dataPromise, ...dataArgs)
24
32
  const data = await dataPromise(...dataParam)
25
33
  if(!data) return false
26
34
 
27
- let ids = []
35
+ let items = []
36
+ let dataMap = []
28
37
 
29
- if (dataField && data?.[dataField]) {
30
- ids = data[dataField].map(item => item?.id).filter(Boolean);
38
+ if (dataField && (data?.[dataField] || iterateDataFieldOnEachArrayElement)) {
39
+ if (iterateDataFieldOnEachArrayElement && Array.isArray(data)) {
40
+ for(const parent of data) {
41
+ ids = [...ids, ...parent[dataField].map(item => item?.id).filter(Boolean)]
42
+ }
43
+ } else {
44
+ ids = data[dataField].map(item => item?.id).filter(Boolean);
45
+ }
31
46
  } else if (Array.isArray(data)) {
32
- ids = data.map(item => item?.id).filter(Boolean);
47
+ items = data;
33
48
  } else if (data?.id) {
34
- ids = [data.id]
49
+ items = [data]
50
+ }
51
+
52
+ const ids = items.map(item => item?.id).filter(Boolean)
53
+
54
+ //create data structure for common use by functions
55
+ if (addNextLesson) {
56
+ items.forEach((item) => {
57
+ if (item?.id) {
58
+ dataMap.push({
59
+ 'children': item.children?.map(child => child.id) ?? [],
60
+ 'type': item.type,
61
+ 'id': item.id,
62
+ })
63
+ }
64
+ })
35
65
  }
36
66
 
37
67
  if(ids.length === 0) return false
38
68
 
39
- const [progressPercentageData, progressStatusData, isLikedData, resumeTimeData, lastInteractedChildData] = await Promise.all([
69
+ const [progressPercentageData, progressStatusData, isLikedData, resumeTimeData, lastInteractedChildData, nextLessonData] = await Promise.all([
40
70
  addProgressPercentage ? getProgressPercentageByIds(ids) : Promise.resolve(null),
41
71
  addProgressStatus ? getProgressStateByIds(ids) : Promise.resolve(null),
42
72
  addIsLiked ? isContentLikedByIds(ids) : Promise.resolve(null),
43
73
  addResumeTimeSeconds ? getResumeTimeSecondsByIds(ids) : Promise.resolve(null),
44
74
  addLastInteractedChild ? fetchLastInteractedChild(ids) : Promise.resolve(null),
75
+ (addNextLesson || addLastInteractedParent) ? getNextLesson(dataMap) : Promise.resolve(null),
45
76
  ])
46
-
77
+
47
78
  const addContext = async (item) => ({
48
79
  ...item,
49
80
  ...(addProgressPercentage ? { progressPercentage: progressPercentageData?.[item.id] } : {}),
@@ -52,12 +83,26 @@ export async function addContextToContent(dataPromise, ...dataArgs)
52
83
  ...(addLikeCount && ids.length === 1 ? { likeCount: await fetchLikeCount(item.id) } : {}),
53
84
  ...(addResumeTimeSeconds ? { resumeTime: resumeTimeData?.[item.id] } : {}),
54
85
  ...(addLastInteractedChild ? { lastInteractedChild: lastInteractedChildData?.[item.id] } : {}),
86
+ ...(addNextLesson ? { nextLesson: nextLessonData?.[item.id] } : {}),
55
87
  })
56
-
88
+
89
+ if (addLastInteractedParent) {
90
+ const parentId = await getLastInteractedOf(ids);
91
+ data['nextLesson'] = nextLessonData[parentId];
92
+ }
93
+
57
94
  if (dataField) {
58
- data[dataField] = Array.isArray(data[dataField])
95
+ if (iterateDataFieldOnEachArrayElement) {
96
+ for(let parent of data) {
97
+ parent[dataField] = Array.isArray(parent[dataField])
98
+ ? await Promise.all(parent[dataField].map(addContext))
99
+ : await addContext(parent[dataField])
100
+ }
101
+ } else {
102
+ data[dataField] = Array.isArray(data[dataField])
59
103
  ? await Promise.all(data[dataField].map(addContext))
60
104
  : await addContext(data[dataField])
105
+ }
61
106
  return data
62
107
  } else {
63
108
  return Array.isArray(data)
@@ -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 = {}
@@ -80,11 +80,14 @@ export async function rankCategories(brand, categories) {
80
80
  globalConfig.recommendationsConfig.token
81
81
  )
82
82
  const response = await httpClient.post(url, data)
83
- let rankedCategories = {}
84
- response['ranked_playlists'].forEach(
85
- (category) =>
86
- (rankedCategories[category['playlist_id']] = categories[category['playlist_id']])
87
- )
83
+ let rankedCategories = []
84
+
85
+ for (const rankedPlaylist of response['ranked_playlists']) {
86
+ rankedCategories.push({
87
+ 'slug': rankedPlaylist.playlist_id,
88
+ 'items': rankedPlaylist.ranked_items
89
+ })
90
+ }
88
91
  return rankedCategories
89
92
  } catch (error) {
90
93
  console.error('Fetch error:', error)
@@ -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(',')
@@ -540,6 +540,21 @@ export async function fetchByRailContentIds(ids, contentType = undefined, brand
540
540
  return sortedResults
541
541
  }
542
542
 
543
+ export async function fetchContentRows(brand, pageName, contentRowSlug)
544
+ {
545
+ if (pageName === 'lessons') pageName = 'lesson'
546
+ if (pageName === 'songs') pageName = 'song'
547
+ const rowString = contentRowSlug ? ` && slug.current == "${contentRowSlug.toLowerCase()}"` : ''
548
+ return fetchSanity(`*[_type == 'recommended-content-row' && brand == '${brand}' && type == '${pageName}'${rowString}]{
549
+ brand,
550
+ name,
551
+ 'slug': slug.current,
552
+ 'content': content[]->{ ${getFieldsForContentType()} }
553
+ }`, true)
554
+ }
555
+
556
+
557
+
543
558
  /**
544
559
  * Fetch all content for a specific brand and type with pagination, search, and grouping options.
545
560
  * @param {string} brand - The brand for which to fetch content.
@@ -2356,9 +2371,11 @@ export async function fetchTabData(
2356
2371
 
2357
2372
  filter = `brand == "${brand}" ${includedFieldsFilter} ${progressFilter}`
2358
2373
  const childrenFilter = await new FilterBuilder(``, { isChildrenFilter: true }).buildFilter()
2359
- entityFieldsString = ` ${fieldsString},
2360
- 'lesson_count': coalesce(count(child[${childrenFilter}]->), 0) ,
2361
- 'length_in_seconds': coalesce(
2374
+ entityFieldsString =
2375
+ ` ${fieldsString},
2376
+ 'children': child[${childrenFilter}]->{'id': railcontent_id},
2377
+ 'lesson_count': coalesce(count(child[${childrenFilter}]->), 0),
2378
+ 'length_in_seconds': coalesce(
2362
2379
  math::sum(
2363
2380
  select(
2364
2381
  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
-