musora-content-services 2.111.0 → 2.111.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(rg:*)",
5
+ "Bash(npm run lint:*)"
6
+ ],
7
+ "deny": []
8
+ }
9
+ }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
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.111.1](https://github.com/railroadmedia/musora-content-services/compare/v2.111.0...v2.111.1) (2026-01-06)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * fixes small bug with aggregator & adds safety for saveContentProgress ([#681](https://github.com/railroadmedia/musora-content-services/issues/681)) ([b3fd0a7](https://github.com/railroadmedia/musora-content-services/commit/b3fd0a7acf1fe01e2aff567148554fb291bac8a7))
11
+
5
12
  ## [2.111.0](https://github.com/railroadmedia/musora-content-services/compare/v2.110.3...v2.111.0) (2026-01-05)
6
13
 
7
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.111.0",
3
+ "version": "2.111.1",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -964,3 +964,13 @@ function groupFilters(filters) {
964
964
  return acc
965
965
  }, {})
966
966
  }
967
+
968
+ export const getFormattedType = (type, brand) => {
969
+ for (const [key, values] of Object.entries(progressTypesMapping)) {
970
+ if (values.includes(type)) {
971
+ return key === 'songs' ? songs[brand] : key
972
+ }
973
+ }
974
+
975
+ return null
976
+ }
package/src/index.d.ts CHANGED
@@ -207,8 +207,10 @@ import {
207
207
  } from './services/progress-events.js';
208
208
 
209
209
  import {
210
- getMethodCard
211
- } from './services/progress-row/method-card.js';
210
+ getProgressRows,
211
+ pinProgressRow,
212
+ unpinProgressRow
213
+ } from './services/progress-row/base.js';
212
214
 
213
215
  import {
214
216
  assignModeratorToComment,
@@ -408,11 +410,9 @@ import {
408
410
  findIncompleteLesson,
409
411
  getPracticeNotes,
410
412
  getPracticeSessions,
411
- getProgressRows,
412
413
  getRecentActivity,
413
414
  getUserMonthlyStats,
414
415
  getUserWeeklyStats,
415
- pinProgressRow,
416
416
  recordUserActivity,
417
417
  recordUserPractice,
418
418
  removeUserPractice,
@@ -420,7 +420,6 @@ import {
420
420
  restoreUserActivity,
421
421
  restoreUserPractice,
422
422
  trackUserPractice,
423
- unpinProgressRow,
424
423
  updatePracticeNotes,
425
424
  updateUserPractice
426
425
  } from './services/userActivity.js';
@@ -592,7 +591,6 @@ declare module 'musora-content-services' {
592
591
  getLearningPathLessonsByIds,
593
592
  getLegacyMethods,
594
593
  getLessonContentRows,
595
- getMethodCard,
596
594
  getMonday,
597
595
  getNavigateTo,
598
596
  getNavigateToForMethod,
package/src/index.js CHANGED
@@ -211,8 +211,10 @@ import {
211
211
  } from './services/progress-events.js';
212
212
 
213
213
  import {
214
- getMethodCard
215
- } from './services/progress-row/method-card.js';
214
+ getProgressRows,
215
+ pinProgressRow,
216
+ unpinProgressRow
217
+ } from './services/progress-row/base.js';
216
218
 
217
219
  import {
218
220
  assignModeratorToComment,
@@ -412,11 +414,9 @@ import {
412
414
  findIncompleteLesson,
413
415
  getPracticeNotes,
414
416
  getPracticeSessions,
415
- getProgressRows,
416
417
  getRecentActivity,
417
418
  getUserMonthlyStats,
418
419
  getUserWeeklyStats,
419
- pinProgressRow,
420
420
  recordUserActivity,
421
421
  recordUserPractice,
422
422
  removeUserPractice,
@@ -424,7 +424,6 @@ import {
424
424
  restoreUserActivity,
425
425
  restoreUserPractice,
426
426
  trackUserPractice,
427
- unpinProgressRow,
428
427
  updatePracticeNotes,
429
428
  updateUserPractice
430
429
  } from './services/userActivity.js';
@@ -591,7 +590,6 @@ export {
591
590
  getLearningPathLessonsByIds,
592
591
  getLegacyMethods,
593
592
  getLessonContentRows,
594
- getMethodCard,
595
593
  getMonday,
596
594
  getNavigateTo,
597
595
  getNavigateToForMethod,
@@ -33,7 +33,6 @@ import {COLLECTION_TYPE} from "./sync/models/ContentProgress";
33
33
  * @param options.addProgressStatus - add progressStatus field
34
34
  * @param options.addProgressTimestamp - add progressTimestamp field
35
35
  * @param options.addResumeTimeSeconds - add resumeTimeSeconds field
36
- * @param options.addLastInteractedChild - add lastInteractedChild field. This may be different from navigateTo.id
37
36
  * @param options.collection {object|null} - define collection parameter: collection = { id: <collection_id>, type: <collection_type> } . This is needed for different collection types like learning paths.
38
37
  *
39
38
  * @returns {Promise<{ data: Object[] } | false>} - A promise that resolves to the fetched content data + added data or `false` if no data is found.
@@ -91,7 +90,6 @@ export async function addContextToContent(dataPromise, ...dataArgs) {
91
90
  progressData,
92
91
  isLikedData,
93
92
  resumeTimeData,
94
- lastInteractedChildData,
95
93
  navigateToData,
96
94
  ] = await Promise.all([ //for now assume these all return `collection = {type, id}`. it will be so when watermelon here
97
95
  addProgressPercentage || addProgressStatus || addProgressTimestamp
@@ -503,8 +503,10 @@ async function saveContentProgress(contentId, collection, progress, currentSecon
503
503
  }
504
504
  }
505
505
 
506
- // BE bubbling/trickling currently does not work, so we utilize non-tentative pushing when learning path collection
507
- await db.contentProgress.recordProgressMany(bubbledProgresses, collection, {tentative: !isLP, skipPush: true, hideFromProgressRow})
506
+ if (Object.keys(bubbledProgresses).length >= 0) {
507
+ // BE bubbling/trickling currently does not work, so we utilize non-tentative pushing when learning path collection
508
+ await db.contentProgress.recordProgressMany(bubbledProgresses, collection, {tentative: !isLP, skipPush: true, hideFromProgressRow})
509
+ }
508
510
 
509
511
  if (isLP) {
510
512
  let exportIds = bubbledProgresses
@@ -539,8 +541,8 @@ async function setStartedOrCompletedStatus(contentId, collection, isCompleted, {
539
541
  }
540
542
  // BE bubbling/trickling currently does not work, so we utilize non-tentative pushing when learning path collection
541
543
  await db.contentProgress.recordProgressMany(progresses, collection, {tentative: !isLP, skipPush: true})
542
- if (isLP) {
543
544
 
545
+ if (isLP) {
544
546
  let exportProgresses = progresses
545
547
  exportProgresses[contentId] = progress
546
548
  await duplicateLearningPathProgressToExternalContents(exportProgresses, collection, hierarchy, {skipPush: true})
@@ -0,0 +1,205 @@
1
+ /**
2
+ * @module ProgressRow
3
+ */
4
+ import { getMethodCard } from './rows/method-card.js'
5
+ import {
6
+ getPlaylistCards,
7
+ getPlaylistEngagedOnContent,
8
+ getRecentPlaylists,
9
+ processPlaylistItem,
10
+ } from './rows/playlist-card.js'
11
+ import { globalConfig } from '../config.js'
12
+ import { getContentCardMap, processContentItem } from './rows/content-card.js'
13
+ import { fetchByRailContentIds } from '../sanity.js'
14
+ import { addContextToContent } from '../contentAggregator.js'
15
+ import { fetchPlaylist } from '../content-org/playlists.js'
16
+ import { TabResponseType } from '../../contentMetaData.js'
17
+ import { PUT } from '../../infrastructure/http/HttpClient.js'
18
+
19
+
20
+ /**
21
+ * Fetches and combines recent user progress rows and playlists, excluding certain types and parents.
22
+ *
23
+ * @param {Object} [options={}] - Options for fetching progress rows.
24
+ * @param {string|null} [options.brand=null] - The brand context for progress data.
25
+ * @param {number} [options.limit=8] - Maximum number of progress rows to return.
26
+ * @returns {Promise<Object>} - A promise that resolves to an object containing progress rows formatted for UI.
27
+ *
28
+ * @example
29
+ * getProgressRows({ brand: 'drumeo', limit: 10 })
30
+ * .then(data => console.log(data))
31
+ * .catch(error => console.error(error));
32
+ */
33
+ export async function getProgressRows({ brand = 'drumeo', limit = 8 } = {}) {
34
+ const [userPinnedItem, recentPlaylists] = await Promise.all([
35
+ getUserPinnedItem(brand),
36
+ getRecentPlaylists(brand, limit)
37
+ ])
38
+ const playlistEngagedOnContent = await getPlaylistEngagedOnContent(recentPlaylists)
39
+ const [contentCardMap, playlistCards, methodCard] = await Promise.all([
40
+ getContentCardMap(brand, limit, playlistEngagedOnContent, userPinnedItem),
41
+ getPlaylistCards(recentPlaylists),
42
+ getMethodCard(brand),
43
+ ])
44
+ const pinnedCard = await popPinnedItem(userPinnedItem, contentCardMap, playlistCards, methodCard)
45
+ let allResultsLength = playlistCards.length + contentCardMap.size
46
+ if (methodCard) {
47
+ allResultsLength += 1
48
+ }
49
+ const results = sortCards(pinnedCard, contentCardMap, playlistCards, methodCard, limit)
50
+ return {
51
+ type: TabResponseType.PROGRESS_ROWS,
52
+ displayBrowseAll: allResultsLength > limit,
53
+ data: results,
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Pins a specific progress row for a user, scoped by brand.
59
+ *
60
+ * @param {string} brand - The brand context for the pin action.
61
+ * @param {number|string} id - The ID of the progress item to pin.
62
+ * @param {string} progressType - The type of progress (e.g., 'content', 'playlist').
63
+ * @returns {Promise<Object>} - A promise resolving to the response from the pin API.
64
+ *
65
+ * @example
66
+ * pinProgressRow('drumeo', 12345, 'content')
67
+ * .then(response => console.log(response))
68
+ * .catch(error => console.error(error));
69
+ */
70
+ export async function pinProgressRow(brand, id, progressType) {
71
+ const url = `/api/user-management-system/v1/progress/pin?brand=${brand}&id=${id}&progressType=${progressType}`
72
+ const response = await PUT(url, null)
73
+ if (response && !response.error) {
74
+ await updateUserPinnedProgressRow(brand, {
75
+ id,
76
+ progressType,
77
+ pinnedAt: new Date().toISOString(),
78
+ })
79
+ }
80
+ return response
81
+ }
82
+
83
+ /**
84
+ * Unpins the current pinned progress row for a user, scoped by brand.
85
+ *
86
+ * @param {string} brand - The brand context for the unpin action.
87
+ * @returns {Promise<Object>} - A promise resolving to the response from the unpin API.
88
+ *
89
+ * @example
90
+ * unpinProgressRow('drumeo', 123456)
91
+ * .then(response => console.log(response))
92
+ * .catch(error => console.error(error));
93
+ */
94
+ export async function unpinProgressRow(brand) {
95
+ const url = `/api/user-management-system/v1/progress/unpin?brand=${brand}`
96
+ const response = await PUT(url, null)
97
+ if (response && !response.error) {
98
+ await updateUserPinnedProgressRow(brand, null)
99
+ }
100
+ return response
101
+ }
102
+
103
+ async function getUserPinnedItem(brand) {
104
+ const userRaw = await globalConfig.localStorage.getItem('user')
105
+ const user = userRaw ? JSON.parse(userRaw) : {}
106
+ user.brand_pinned_progress = user.brand_pinned_progress || {}
107
+ return user.brand_pinned_progress[brand] ?? null
108
+ }
109
+
110
+ /**
111
+ * Pop the userPinnedItem from cards and return it.
112
+ * If userPinnedItem is not found, generate the pinned card from scratch.
113
+ *
114
+ **/
115
+ async function popPinnedItem(userPinnedItem, contentCardMap, playlistCards, methodCard){
116
+ if (!userPinnedItem) return null
117
+ const pinnedId = parseInt(userPinnedItem.id)
118
+ const pinnedAt = userPinnedItem.pinnedAt
119
+ const progressType = userPinnedItem.progressType ?? userPinnedItem.type
120
+
121
+ let item = null
122
+ if (progressType === 'content') {
123
+ if (contentCardMap.has(pinnedId)) {
124
+ item = contentCardMap.get(pinnedId)
125
+ contentCardMap.delete(pinnedId)
126
+ } else {
127
+ // we use fetchByRailContentIds so that we don't have the _type restriction in the query
128
+ let data = await fetchByRailContentIds([pinnedId], 'progress-tracker')
129
+ item = await processContentItem(await addContextToContent(() => data[0] ?? null, {
130
+ addNextLesson: true,
131
+ addNavigateTo: true,
132
+ addProgressStatus: true,
133
+ addProgressPercentage: true,
134
+ addProgressTimestamp: true,
135
+ }))
136
+ }
137
+ } else if (progressType === 'playlist') {
138
+ const pinnedPlaylist = playlistCards.find((p) => p.playlist.id === pinnedId)
139
+ if (pinnedPlaylist) {
140
+ item = pinnedPlaylist
141
+ } else {
142
+ const playlist = await fetchPlaylist(pinnedId)
143
+ item = await processPlaylistItem({
144
+ id: pinnedId,
145
+ playlist: playlist,
146
+ type: 'playlist',
147
+ progressTimestamp: new Date(pinnedAt).getTime(),
148
+ })
149
+ }
150
+ } else if (progressType === 'method') {
151
+ // simply get method card and return
152
+ item = methodCard
153
+ }
154
+ return item
155
+ }
156
+
157
+ /**
158
+ * Order cards by progress timestamp, move pinned card to the front,
159
+ * remove any duplicate cards showing the same content twice,
160
+ * slice the result based on the provided limit.
161
+ **/
162
+ function sortCards(pinnedCard, contentCardMap, playlistCards, methodCard, limit) {
163
+ let combined = []
164
+ if (pinnedCard) {
165
+ pinnedCard.pinned = true
166
+ combined.push(pinnedCard)
167
+ }
168
+
169
+ if (!(pinnedCard && pinnedCard.progressType === 'method')) {
170
+ combined.push(methodCard)
171
+ }
172
+
173
+ const progressList = Array.from(contentCardMap.values())
174
+ return mergeAndSortItems([...combined, ...progressList, ...playlistCards], limit)
175
+ }
176
+
177
+ function mergeAndSortItems(items, limit) {
178
+ const seen = new Set()
179
+ const deduped = []
180
+
181
+ for (const item of items) {
182
+ const key = `${item.id}-${item.progressType}`
183
+ if (!seen.has(key)) {
184
+ seen.add(key)
185
+ deduped.push(item)
186
+ }
187
+ }
188
+
189
+ return deduped
190
+ .filter((item) => typeof item.progressTimestamp === 'number' && item.progressTimestamp >= 0)
191
+ .sort((a, b) => {
192
+ if (a.pinned && !b.pinned) return -1
193
+ if (!a.pinned && b.pinned) return 1
194
+ return b.progressTimestamp - a.progressTimestamp
195
+ })
196
+ .slice(0, limit)
197
+ }
198
+
199
+ async function updateUserPinnedProgressRow(brand, pinnedData) {
200
+ const userRaw = await globalConfig.localStorage.getItem('user')
201
+ const user = userRaw ? JSON.parse(userRaw) : {}
202
+ user.brand_pinned_progress = user.brand_pinned_progress || {}
203
+ user.brand_pinned_progress[brand] = pinnedData
204
+ await globalConfig.localStorage.setItem('user', JSON.stringify(user))
205
+ }
File without changes
@@ -0,0 +1,211 @@
1
+ /**
2
+ * @module ProgressRow
3
+ */
4
+ import { getAllStartedOrCompleted, getProgressStateByIds } from '../../contentProgress.js'
5
+ import { addContextToContent } from '../../contentAggregator.js'
6
+ import { fetchByRailContentIds, fetchShows } from '../../sanity.js'
7
+ import {
8
+ collectionLessonTypes,
9
+ getFormattedType,
10
+ recentTypes,
11
+ showsLessonTypes,
12
+ songs,
13
+ } from '../../../contentTypeConfig.js'
14
+ import { getTimeRemainingUntilLocal } from '../../dateUtils.js'
15
+ import { findIncompleteLesson } from '../../userActivity.js'
16
+
17
+ /**
18
+ * Fetch any content IDs with some progress, include the userPinnedItem,
19
+ * remove any content IDs that already exist in playlistEngagedOnContent,
20
+ * and generate a map of the cards keyed by the content IDs
21
+ */
22
+ export async function getContentCardMap(brand, limit, playlistEngagedOnContent, userPinnedItem ){
23
+ let recentContentIds = await getAllStartedOrCompleted({ brand: brand, limit })
24
+ if (userPinnedItem?.progressType === 'content') {
25
+ recentContentIds.push(userPinnedItem.id)
26
+ }
27
+ if (playlistEngagedOnContent) {
28
+ for (const item of playlistEngagedOnContent) {
29
+ const parentIds = item.parent_content_data || []
30
+ recentContentIds = recentContentIds.filter(id => id !== item.id && !parentIds.includes(id))
31
+ }
32
+ }
33
+ const contents = recentContentIds.length > 0
34
+ ? await addContextToContent(
35
+ fetchByRailContentIds,
36
+ recentContentIds,
37
+ 'progress-tracker',
38
+ brand,
39
+ {
40
+ addNavigateTo: true,
41
+ addProgressStatus: true,
42
+ addProgressPercentage: true,
43
+ addProgressTimestamp: true,
44
+ }
45
+ )
46
+ : []
47
+ const contentCards = await Promise.all(generateContentPromises(contents))
48
+ return contentCards.reduce((contentMap, content) => {
49
+ contentMap.set(content.id, content)
50
+ return contentMap
51
+ }, new Map())
52
+ }
53
+
54
+ function generateContentPromises(contents) {
55
+ const promises = []
56
+ if (!contents) return promises
57
+ const existingShows = new Set()
58
+ const allRecentTypeSet = new Set(Object.values(recentTypes).flat())
59
+ contents.forEach((content) => {
60
+ const type = content.type
61
+ if (!allRecentTypeSet.has(type) && !showsLessonTypes.includes(type)) return
62
+ let childHasParent = Array.isArray(content.parent_content_data) && content.parent_content_data.length > 0
63
+ if (!childHasParent) {
64
+ promises.push(processContentItem(content))
65
+ if (showsLessonTypes.includes(type)) {
66
+ // Shows don't have a parent to link them, but need to be handled as if they're a set of children
67
+ existingShows.add(type)
68
+ }
69
+ }
70
+ })
71
+
72
+ return promises
73
+ }
74
+
75
+ export async function processContentItem(content) {
76
+ const contentType = getFormattedType(content.type, content.brand)
77
+ const isLive = content.isLive ?? false
78
+ let ctaText = getDefaultCTATextForContent(content, contentType)
79
+
80
+ content.completed_children = await getCompletedChildren(content, contentType)
81
+
82
+ if (content.type === 'guided-course') {
83
+ const nextLessonPublishedOn = content.children.find(
84
+ (child) => child.id === content.navigateTo.id
85
+ )?.published_on
86
+ let isLocked = new Date(nextLessonPublishedOn) > new Date()
87
+ if (isLocked) {
88
+ content.is_locked = true
89
+ const timeRemaining = getTimeRemainingUntilLocal(nextLessonPublishedOn, {
90
+ withTotalSeconds: true,
91
+ })
92
+ content.time_remaining_seconds = timeRemaining.totalSeconds
93
+ ctaText = 'Next lesson in ' + timeRemaining.formatted
94
+ } else if (
95
+ !content.progressStatus ||
96
+ content.progressStatus === 'not-started' ||
97
+ content.progressPercentage === 0
98
+ ) {
99
+ ctaText = 'Start Course'
100
+ }
101
+ }
102
+
103
+ if (contentType === 'show') {
104
+ const shows = await fetchShows(content.brand, content.type)
105
+ const showIds = shows.map((item) => item.id)
106
+ const progressOnItems = await getProgressStateByIds(showIds)
107
+ const completedShows = content.completed_children
108
+ const progressTimestamp = content.progressTimestamp
109
+ const wasPinned = content.pinned ?? false
110
+ if (content.progressStatus === 'completed') {
111
+ // this could be handled more gracefully if there was a parent content type for shows
112
+ // Update Dec 3rd. We updated almost everything to the DocumentaryType :D, but there's still a few
113
+ const nextByProgress = findIncompleteLesson(progressOnItems, content.id, content.type)
114
+ content = shows.find((lesson) => lesson.id === nextByProgress)
115
+ content.completed_children = completedShows
116
+ content.progressTimestamp = progressTimestamp
117
+ content.pinned = wasPinned
118
+ }
119
+ content.child_count = shows.length
120
+ content.progressPercentage = Math.round((completedShows / shows.length) * 100)
121
+ if (completedShows === shows.length) {
122
+ ctaText = 'Revisit Show'
123
+ }
124
+ }
125
+ return {
126
+ id: content.id,
127
+ progressType: 'content',
128
+ header: contentType,
129
+ pinned: content.pinned ?? false,
130
+ content: content,
131
+ body: {
132
+ progressPercent: isLive ? undefined : content.progressPercentage,
133
+ thumbnail: content.thumbnail,
134
+ title: content.title,
135
+ isLive: isLive,
136
+ badge: content.badge ?? null,
137
+ isLocked: content.is_locked ?? false,
138
+ subtitle:
139
+ collectionLessonTypes.includes(content.type) || content.lesson_count > 1
140
+ ? `${content.completed_children} of ${content.lesson_count ?? content.child_count} Lessons Complete`
141
+ : contentType === 'lesson' && isLive === false
142
+ ? `${content.progressPercentage}% Complete`
143
+ : `${content.difficulty_string} • ${content.artist_name}`,
144
+ },
145
+ cta: {
146
+ text: ctaText,
147
+ timeRemainingToUnlockSeconds: content.time_remaining_seconds ?? null,
148
+ action: {
149
+ type: content.type,
150
+ brand: content.brand,
151
+ id: content.id,
152
+ slug: content.slug,
153
+ child: content.navigateTo,
154
+ },
155
+ },
156
+ progressTimestamp: content.progressTimestamp,
157
+ }
158
+ }
159
+
160
+ function getDefaultCTATextForContent(content, contentType) {
161
+ let ctaText = 'Continue'
162
+ if (content.progressStatus === 'completed') {
163
+ if (
164
+ contentType === songs[content.brand] ||
165
+ contentType === 'play along' ||
166
+ contentType === 'jam track'
167
+ )
168
+ ctaText = 'Replay Song'
169
+ if (contentType === 'lesson') ctaText = 'Revisit Lesson'
170
+ if (contentType === 'song tutorial' || collectionLessonTypes.includes(content.type))
171
+ ctaText = 'Revisit Lessons'
172
+ if (contentType === 'pack') ctaText = 'View Lessons'
173
+ }
174
+ return ctaText
175
+ }
176
+
177
+ async function getCompletedChildren(content, contentType) {
178
+ let completedChildren = null
179
+ if (contentType === 'show') {
180
+ const shows = await addContextToContent(fetchShows, content.brand, content.type, {
181
+ addProgressStatus: true,
182
+ })
183
+ completedChildren = Object.values(shows).filter(
184
+ (show) => show.progressStatus === 'completed'
185
+ ).length
186
+ } else if (content.lesson_count > 0) {
187
+ const lessonIds = getLeafNodes(content)
188
+ const progressOnItems = await getProgressStateByIds(lessonIds)
189
+ completedChildren = Object.values(progressOnItems).filter(
190
+ (value) => value === 'completed'
191
+ ).length
192
+ }
193
+ return completedChildren
194
+ }
195
+
196
+ function getLeafNodes(content) {
197
+ const ids = []
198
+ function traverse(children) {
199
+ for (const item of children) {
200
+ if (item.children) {
201
+ traverse(item.children) // Recursively handle nested lessons
202
+ } else if (item.id) {
203
+ ids.push(item.id)
204
+ }
205
+ }
206
+ }
207
+ if (content && Array.isArray(content.children)) {
208
+ traverse(content.children)
209
+ }
210
+ return ids
211
+ }
@@ -2,11 +2,11 @@
2
2
  * @module ProgressRow
3
3
  */
4
4
 
5
- import { getActivePath, fetchLearningPathLessons } from '../content-org/learning-paths'
6
- import { getToday } from '../dateUtils.js'
7
- import { fetchMethodV2IntroVideo } from '../sanity'
8
- import { getProgressState } from '../contentProgress'
9
- import {COLLECTION_TYPE, STATE} from '../sync/models/ContentProgress'
5
+ import { getActivePath, fetchLearningPathLessons } from '../../content-org/learning-paths'
6
+ import { getToday } from '../../dateUtils.js'
7
+ import { fetchMethodV2IntroVideo } from '../../sanity'
8
+ import { getProgressState } from '../../contentProgress'
9
+ import {COLLECTION_TYPE, STATE} from '../../sync/models/ContentProgress'
10
10
 
11
11
  export async function getMethodCard(brand) {
12
12
  const introVideo = await fetchMethodV2IntroVideo(brand)
@@ -20,8 +20,7 @@ export async function getMethodCard(brand) {
20
20
  const activeLearningPath = await getActivePath(brand)
21
21
 
22
22
  if (introVideoProgressState !== STATE.COMPLETED || !activeLearningPath) {
23
- //startLearningPath('drumeo', 422533)
24
- const timestamp = Math.floor(Date.now() / 1000)
23
+ const timestamp = Math.floor(Date.now())
25
24
  const instructorText =
26
25
  introVideo.instructor?.length > 1
27
26
  ? 'Multiple Instructors'
@@ -121,8 +120,7 @@ export async function getMethodCard(brand) {
121
120
  text: ctaText,
122
121
  action: action,
123
122
  },
124
- // *1000 is to match playlists which are saved in millisecond accuracy
125
- progressTimestamp: maxProgressTimestamp * 1000,
123
+ progressTimestamp: maxProgressTimestamp,
126
124
  }
127
125
  }
128
126
  }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * @module ProgressRow
3
+ */
4
+ import { fetchUserPlaylists } from '../../content-org/playlists.js'
5
+ import { addContextToContent } from '../../contentAggregator.js'
6
+ import { fetchByRailContentIds } from '../../sanity.js'
7
+
8
+ export async function getPlaylistCards(recentPlaylists){
9
+ return await Promise.all(
10
+ recentPlaylists.map((playlist) => {
11
+ return processPlaylistItem(playlist)
12
+ })
13
+ )
14
+ }
15
+
16
+ export async function processPlaylistItem(item) {
17
+ const playlist = item.playlist
18
+
19
+ return {
20
+ id: playlist.id,
21
+ progressType: 'playlist',
22
+ header: 'playlist',
23
+ pinned: item.pinned ?? false,
24
+ playlist: playlist,
25
+ body: {
26
+ first_items_thumbnail_url: playlist.first_items_thumbnail_url,
27
+ title: playlist.name,
28
+ subtitle: `${playlist.duration_formated} • ${playlist.total_items} items • ${playlist.likes} likes • ${playlist.user.display_name}`,
29
+ total_items: playlist.total_items,
30
+ },
31
+ progressTimestamp: item.progressTimestamp,
32
+ cta: {
33
+ text: 'Continue',
34
+ action: {
35
+ brand: playlist.brand,
36
+ item_id: playlist.navigateTo.id ?? null,
37
+ content_id: playlist.navigateTo.content_id ?? null,
38
+ type: 'playlists',
39
+ // TODO deprecated, maintained to avoid breaking changes
40
+ id: playlist.id,
41
+ },
42
+ },
43
+ }
44
+ }
45
+
46
+ export async function getRecentPlaylists(brand, limit) {
47
+ const response = await fetchUserPlaylists(brand, { sort: '-last_progress', limit: limit })
48
+ const playlists = response?.data || []
49
+ const recentPlaylists = playlists.filter((p) => p.last_progress && p.last_engaged_on)
50
+ return await Promise.all(
51
+ recentPlaylists.map(async (p) => {
52
+ const utcDate = new Date(p.last_progress.replace(' ', 'T') + 'Z')
53
+ const timestamp = utcDate.getTime()
54
+ return {
55
+ type: 'playlist',
56
+ progressTimestamp: timestamp,
57
+ playlist: p,
58
+ id: p.id,
59
+ }
60
+ })
61
+ )
62
+ }
63
+
64
+ export async function getPlaylistEngagedOnContent(recentPlaylists){
65
+ const playlistEngagedOnContents = recentPlaylists.map(
66
+ (item) => item.playlist.last_engaged_on
67
+ )
68
+ return playlistEngagedOnContents.length > 0
69
+ ? await addContextToContent(fetchByRailContentIds, playlistEngagedOnContents, 'progress-tracker', {
70
+ addNavigateTo: true,
71
+ addProgressStatus: true,
72
+ addProgressPercentage: true,
73
+ addProgressTimestamp: true,
74
+ })
75
+ : []
76
+ }
@@ -5,39 +5,21 @@
5
5
  import {
6
6
  fetchUserPractices,
7
7
  fetchUserPracticeMeta,
8
- fetchUserPracticeNotes,
9
8
  fetchRecentUserActivities,
10
9
  } from './railcontent'
11
10
  import { GET, POST, PUT, DELETE } from '../infrastructure/http/HttpClient.ts'
12
11
  import { DataContext, UserActivityVersionKey } from './dataContext.js'
13
- import {
14
- fetchByRailContentId,
15
- fetchByRailContentIds,
16
- fetchMethodV2IntroVideo,
17
- fetchShows,
18
- } from './sanity'
19
- import { fetchPlaylist, fetchUserPlaylists } from './content-org/playlists'
12
+ import { fetchByRailContentIds } from './sanity'
20
13
  import {
21
14
  getMonday,
22
15
  getWeekNumber,
23
16
  isSameDate,
24
17
  isNextDay,
25
- getTimeRemainingUntilLocal,
26
- toDayjs,
27
18
  } from './dateUtils.js'
28
19
  import { globalConfig } from './config'
29
- import {
30
- collectionLessonTypes,
31
- progressTypesMapping,
32
- recentTypes,
33
- showsLessonTypes,
34
- songs,
35
- } from '../contentTypeConfig'
36
- import { getAllStartedOrCompleted, getProgressStateByIds } from './contentProgress'
37
- import { TabResponseType } from '../contentMetaData'
20
+ import { getFormattedType } from '../contentTypeConfig'
38
21
  import dayjs from 'dayjs'
39
22
  import { addContextToContent } from './contentAggregator.js'
40
- import { getMethodCard } from './progress-row/method-card.js'
41
23
  import { db, Q } from './sync'
42
24
  import {COLLECTION_TYPE} from "./sync/models/ContentProgress";
43
25
 
@@ -908,397 +890,6 @@ export async function restoreUserActivity(id) {
908
890
  return await POST(url, null)
909
891
  }
910
892
 
911
- async function extractPinnedItemsAndSortAllItems(
912
- userPinnedItem,
913
- contentsMap,
914
- eligiblePlaylistItems,
915
- methodCard,
916
- //method contents
917
- limit
918
- ) {
919
- let pinnedItem = await popPinnedItemFromContentsOrPlaylistMap(
920
- userPinnedItem,
921
- contentsMap,
922
- eligiblePlaylistItems,
923
- methodCard
924
- )
925
-
926
- let combined = []
927
-
928
- if (pinnedItem) {
929
- pinnedItem.pinned = true
930
- combined.push(pinnedItem)
931
- }
932
-
933
- if (!(pinnedItem && pinnedItem.progressType === 'method')) {
934
- combined.push(methodCard)
935
- }
936
-
937
- const progressList = Array.from(contentsMap.values())
938
- //need another for methodContents?
939
-
940
- combined = [...combined, ...progressList, ...eligiblePlaylistItems]
941
- return mergeAndSortItems(combined, limit)
942
- }
943
-
944
- function generateContentsMap(contents, playlistsContents) {
945
- const excludedTypes = new Set(['pack-bundle', 'guided-course-lesson'])
946
- const existingShows = new Set()
947
- const contentsMap = new Map()
948
- const childToParentMap = {}
949
- if (!contents) return contentsMap
950
- contents.forEach((content) => {
951
- if (Array.isArray(content.parent_content_data) && content.parent_content_data.length > 0) {
952
- childToParentMap[content.id] =
953
- content.parent_content_data[content.parent_content_data.length - 1]
954
- }
955
- })
956
-
957
- const allRecentTypeSet = new Set(Object.values(recentTypes).flat())
958
- contents.forEach((content) => {
959
- const id = content.id
960
- const type = content.type
961
- if (
962
- excludedTypes.has(type) ||
963
- (!allRecentTypeSet.has(type) && !showsLessonTypes.includes(type))
964
- )
965
- return
966
- if (!childToParentMap[id]) {
967
- // Shows don't have a parent to link them, but need to be handled as if they're a set of children
968
- if (!existingShows.has(type)) {
969
- contentsMap.set(id, content)
970
- }
971
- if (showsLessonTypes.includes(type)) {
972
- existingShows.add(type)
973
- }
974
- }
975
- })
976
-
977
- if (playlistsContents) {
978
- for (const item of playlistsContents) {
979
- const contentId = item.id
980
- contentsMap.delete(contentId)
981
- const parentIds = item.parent_content_data || []
982
- parentIds.forEach((id) => contentsMap.delete(id))
983
- }
984
- }
985
-
986
- return contentsMap
987
- }
988
-
989
- /**
990
- * Fetches and combines recent user progress rows and playlists, excluding certain types and parents.
991
- *
992
- * @param {Object} [options={}] - Options for fetching progress rows.
993
- * @param {string|null} [options.brand=null] - The brand context for progress data.
994
- * @param {number} [options.limit=8] - Maximum number of progress rows to return.
995
- * @returns {Promise<Object>} - A promise that resolves to an object containing progress rows formatted for UI.
996
- *
997
- * @example
998
- * getProgressRows({ brand: 'drumeo', limit: 10 })
999
- * .then(data => console.log(data))
1000
- * .catch(error => console.error(error));
1001
- */
1002
- export async function getProgressRows({ brand = 'drumeo', limit = 8 } = {}) {
1003
- // TODO slice progress to a reasonable number, say 100
1004
- const methodCardPromise = getMethodCard(brand)
1005
- const [recentPlaylists, nonPlaylistContentIds, userPinnedItem] = await Promise.all([
1006
- fetchUserPlaylists(brand, { sort: '-last_progress', limit: limit }),
1007
- getAllStartedOrCompleted({ brand: brand, limit }),
1008
- getUserPinnedItem(brand),
1009
- ])
1010
-
1011
- const playlists = recentPlaylists?.data || []
1012
- const eligiblePlaylistItems = await getEligiblePlaylistItems(playlists)
1013
- const playlistEngagedOnContents = eligiblePlaylistItems.map(
1014
- (item) => item.playlist.last_engaged_on
1015
- )
1016
-
1017
- // todo post v2: refactor this once we migrate playlist progress tracking to new system
1018
- if (userPinnedItem?.progressType === 'content') {
1019
- nonPlaylistContentIds.push(userPinnedItem.id)
1020
- }
1021
- //need to update addContextToContent to accept collection info
1022
- const [playlistsContents, contents] = await Promise.all([
1023
- (playlistEngagedOnContents.length > 0)
1024
- ? addContextToContent(fetchByRailContentIds, playlistEngagedOnContents, 'progress-tracker', {
1025
- addNavigateTo: true,
1026
- addProgressStatus: true,
1027
- addProgressPercentage: true,
1028
- addProgressTimestamp: true,
1029
- })
1030
- : Promise.resolve([]),
1031
- (nonPlaylistContentIds.length > 0)
1032
- ? addContextToContent(
1033
- fetchByRailContentIds,
1034
- nonPlaylistContentIds,
1035
- 'progress-tracker',
1036
- brand,
1037
- {
1038
- addNavigateTo: true,
1039
- addProgressStatus: true,
1040
- addProgressPercentage: true,
1041
- addProgressTimestamp: true,
1042
- }
1043
- )
1044
- : Promise.resolve([]),
1045
- ])
1046
-
1047
- const contentsMap = generateContentsMap(contents, playlistsContents)
1048
- const methodCard = await methodCardPromise
1049
- let combined = await extractPinnedItemsAndSortAllItems(
1050
- userPinnedItem,
1051
- contentsMap,
1052
- eligiblePlaylistItems,
1053
- methodCard,
1054
- limit
1055
- )
1056
- const results = await Promise.all(
1057
- combined.slice(0, limit).map((item) => {
1058
- switch (item.type) {
1059
- case 'playlist':
1060
- return processPlaylistItem(item)
1061
- case COLLECTION_TYPE.LEARNING_PATH:
1062
- case 'method':
1063
- return item
1064
- default:
1065
- return processContentItem(item)
1066
- }
1067
- })
1068
- )
1069
- return {
1070
- type: TabResponseType.PROGRESS_ROWS,
1071
- displayBrowseAll: combined.length > limit,
1072
- data: results,
1073
- }
1074
- }
1075
-
1076
- async function getUserPinnedItem(brand) {
1077
- const userRaw = await globalConfig.localStorage.getItem('user')
1078
- const user = userRaw ? JSON.parse(userRaw) : {}
1079
- user.brand_pinned_progress = user.brand_pinned_progress || {}
1080
- return user.brand_pinned_progress[brand] ?? null
1081
- }
1082
-
1083
- async function processContentItem(content) {
1084
- const contentType = getFormattedType(content.type, content.brand)
1085
- const isLive = content.isLive ?? false
1086
- let ctaText = getDefaultCTATextForContent(content, contentType)
1087
-
1088
- content.completed_children = await getCompletedChildren(content, contentType)
1089
-
1090
- if (content.type === 'guided-course') {
1091
- const nextLessonPublishedOn = content.children.find(
1092
- (child) => child.id === content.navigateTo.id
1093
- )?.published_on
1094
- let isLocked = new Date(nextLessonPublishedOn) > new Date()
1095
- if (isLocked) {
1096
- content.is_locked = true
1097
- const timeRemaining = getTimeRemainingUntilLocal(nextLessonPublishedOn, {
1098
- withTotalSeconds: true,
1099
- })
1100
- content.time_remaining_seconds = timeRemaining.totalSeconds
1101
- ctaText = 'Next lesson in ' + timeRemaining.formatted
1102
- } else if (
1103
- !content.progressStatus ||
1104
- content.progressStatus === 'not-started' ||
1105
- content.progressPercentage === 0
1106
- ) {
1107
- ctaText = 'Start Course'
1108
- }
1109
- }
1110
-
1111
- if (contentType === 'show') {
1112
- const shows = await fetchShows(content.brand, content.type)
1113
- const showIds = shows.map((item) => item.id)
1114
- const progressOnItems = await getProgressStateByIds(showIds)
1115
- const completedShows = content.completed_children
1116
- const progressTimestamp = content.progressTimestamp
1117
- const wasPinned = content.pinned ?? false
1118
- if (content.progressStatus === 'completed') {
1119
- // this could be handled more gracefully if there was a parent content type for shows
1120
- // Update Dec 3rd. We updated almost everything to the DocumentaryType :D, but there's still a few
1121
- const nextByProgress = findIncompleteLesson(progressOnItems, content.id, content.type)
1122
- content = shows.find((lesson) => lesson.id === nextByProgress)
1123
- content.completed_children = completedShows
1124
- content.progressTimestamp = progressTimestamp
1125
- content.progressTimestamp = progressTimestamp
1126
- content.pinned = wasPinned
1127
- }
1128
- content.child_count = shows.length
1129
- content.progressPercentage = Math.round((completedShows / shows.length) * 100)
1130
- if (completedShows === shows.length) {
1131
- ctaText = 'Revisit Show'
1132
- }
1133
- }
1134
- return {
1135
- id: content.id,
1136
- progressType: 'content',
1137
- header: contentType,
1138
- pinned: content.pinned ?? false,
1139
- content: content,
1140
- body: {
1141
- progressPercent: isLive ? undefined : content.progressPercentage,
1142
- thumbnail: content.thumbnail,
1143
- title: content.title,
1144
- isLive: isLive,
1145
- badge: content.badge ?? null,
1146
- isLocked: content.is_locked ?? false,
1147
- subtitle:
1148
- collectionLessonTypes.includes(content.type) || content.lesson_count > 1
1149
- ? `${content.completed_children} of ${content.lesson_count ?? content.child_count} Lessons Complete`
1150
- : contentType === 'lesson' && isLive === false
1151
- ? `${content.progressPercentage}% Complete`
1152
- : `${content.difficulty_string} • ${content.artist_name}`,
1153
- },
1154
- cta: {
1155
- text: ctaText,
1156
- timeRemainingToUnlockSeconds: content.time_remaining_seconds ?? null,
1157
- action: {
1158
- type: content.type,
1159
- brand: content.brand,
1160
- id: content.id,
1161
- slug: content.slug,
1162
- child: content.navigateTo,
1163
- },
1164
- },
1165
- // *1000 is to match playlists which are saved in millisecond accuracy
1166
- progressTimestamp: content.progressTimestamp * 1000,
1167
- }
1168
- }
1169
-
1170
- function getDefaultCTATextForContent(content, contentType) {
1171
- let ctaText = 'Continue'
1172
- if (content.progressStatus === 'completed') {
1173
- if (
1174
- contentType === songs[content.brand] ||
1175
- contentType === 'play along' ||
1176
- contentType === 'jam track'
1177
- )
1178
- ctaText = 'Replay Song'
1179
- if (contentType === 'lesson') ctaText = 'Revisit Lesson'
1180
- if (contentType === 'song tutorial' || collectionLessonTypes.includes(content.type))
1181
- ctaText = 'Revisit Lessons'
1182
- if (contentType === 'pack') ctaText = 'View Lessons'
1183
- }
1184
- return ctaText
1185
- }
1186
-
1187
- async function getCompletedChildren(content, contentType) {
1188
- let completedChildren = null
1189
- if (contentType === 'show') {
1190
- const shows = await addContextToContent(fetchShows, content.brand, content.type, {
1191
- addProgressStatus: true,
1192
- })
1193
- completedChildren = Object.values(shows).filter(
1194
- (show) => show.progressStatus === 'completed'
1195
- ).length
1196
- } else if (content.lesson_count > 0) {
1197
- const lessonIds = getLeafNodes(content)
1198
- const progressOnItems = await getProgressStateByIds(lessonIds)
1199
- completedChildren = Object.values(progressOnItems).filter(
1200
- (value) => value === 'completed'
1201
- ).length
1202
- }
1203
- return completedChildren
1204
- }
1205
-
1206
- async function processPlaylistItem(item) {
1207
- const playlist = item.playlist
1208
-
1209
- return {
1210
- id: playlist.id,
1211
- progressType: 'playlist',
1212
- header: 'playlist',
1213
- pinned: item.pinned ?? false,
1214
- playlist: playlist,
1215
- body: {
1216
- first_items_thumbnail_url: playlist.first_items_thumbnail_url,
1217
- title: playlist.name,
1218
- subtitle: `${playlist.duration_formated} • ${playlist.total_items} items • ${playlist.likes} likes • ${playlist.user.display_name}`,
1219
- total_items: playlist.total_items,
1220
- },
1221
- progressTimestamp: item.progressTimestamp,
1222
- cta: {
1223
- text: 'Continue',
1224
- action: {
1225
- brand: playlist.brand,
1226
- item_id: playlist.navigateTo.id ?? null,
1227
- content_id: playlist.navigateTo.content_id ?? null,
1228
- type: 'playlists',
1229
- // TODO depreciated, maintained to avoid breaking changes
1230
- id: playlist.id,
1231
- },
1232
- },
1233
- }
1234
- }
1235
-
1236
- const getFormattedType = (type, brand) => {
1237
- for (const [key, values] of Object.entries(progressTypesMapping)) {
1238
- if (values.includes(type)) {
1239
- return key === 'songs' ? songs[brand] : key
1240
- }
1241
- }
1242
-
1243
- return null
1244
- }
1245
-
1246
- function getLeafNodes(content) {
1247
- const ids = []
1248
- function traverse(children) {
1249
- for (const item of children) {
1250
- if (item.children) {
1251
- traverse(item.children) // Recursively handle nested lessons
1252
- } else if (item.id) {
1253
- ids.push(item.id)
1254
- }
1255
- }
1256
- }
1257
- if (content && Array.isArray(content.children)) {
1258
- traverse(content.children)
1259
- }
1260
- return ids
1261
- }
1262
-
1263
- async function getEligiblePlaylistItems(playlists) {
1264
- const eligible = playlists.filter((p) => p.last_progress && p.last_engaged_on)
1265
- return Promise.all(
1266
- eligible.map(async (p) => {
1267
- const utcDate = new Date(p.last_progress.replace(' ', 'T') + 'Z')
1268
- const timestamp = utcDate.getTime()
1269
- return {
1270
- type: 'playlist',
1271
- // Content timestamps are millisecond accurate so for comparison we bring this to the same resolution
1272
- progressTimestamp: timestamp / 1000,
1273
- playlist: p,
1274
- id: p.id,
1275
- }
1276
- })
1277
- )
1278
- }
1279
-
1280
- function mergeAndSortItems(items, limit) {
1281
- const seen = new Set()
1282
- const deduped = []
1283
-
1284
- for (const item of items) {
1285
- const key = `${item.id}-${item.type}`
1286
- if (!seen.has(key)) {
1287
- seen.add(key)
1288
- deduped.push(item)
1289
- }
1290
- }
1291
-
1292
- return deduped
1293
- .filter((item) => typeof item.progressTimestamp === 'number' && item.progressTimestamp >= 0)
1294
- .sort((a, b) => {
1295
- if (a.pinned && !b.pinned) return -1
1296
- if (!a.pinned && b.pinned) return 1
1297
- return b.progressTimestamp - a.progressTimestamp
1298
- })
1299
- .slice(0, limit + 5)
1300
- }
1301
-
1302
893
  export function findIncompleteLesson(progressOnItems, currentContentId, contentType) {
1303
894
  const ids = Object.keys(progressOnItems).map(Number)
1304
895
  if (contentType === 'guided-course' || contentType === COLLECTION_TYPE.LEARNING_PATH) {
@@ -1320,127 +911,6 @@ export function findIncompleteLesson(progressOnItems, currentContentId, contentT
1320
911
  return ids[0]
1321
912
  }
1322
913
 
1323
- async function popPinnedItemFromContentsOrPlaylistMap(
1324
- pinned,
1325
- contentsMap,
1326
- playlistItems,
1327
- methodCard
1328
- ) {
1329
- if (!pinned) return null
1330
- const { id, pinnedAt } = pinned
1331
- let item = null
1332
- const progressType = pinned.progressType ?? pinned.type
1333
-
1334
- if (progressType === 'content') {
1335
- const pinnedId = parseInt(id)
1336
- if (contentsMap.has(pinnedId)) {
1337
- item = contentsMap.get(pinnedId)
1338
- contentsMap.delete(pinnedId)
1339
- } else {
1340
- // we use fetchByRailContentIds so that we don't have the _type restriction in the query
1341
- let data = await fetchByRailContentIds([id], 'progress-tracker')
1342
- item = await addContextToContent(() => data[0] ?? null, {
1343
- addNextLesson: true,
1344
- addNavigateTo: true,
1345
- addProgressStatus: true,
1346
- addProgressPercentage: true,
1347
- addProgressTimestamp: true,
1348
- })
1349
- }
1350
- }
1351
- if (progressType === 'playlist') {
1352
- const pinnedPlaylist = playlistItems.find((p) => p.playlist.id === id)
1353
- if (pinnedPlaylist) {
1354
- playlistItems = playlistItems.filter((p) => p.playlist.id !== id)
1355
- item = pinnedPlaylist
1356
- } else {
1357
- const playlist = await fetchPlaylist(id)
1358
- item = {
1359
- id: id,
1360
- playlist: playlist,
1361
- type: 'playlist',
1362
- progressTimestamp: new Date(pinnedAt).getTime(),
1363
- }
1364
- }
1365
- }
1366
- if (progressType === 'method') {
1367
- // simply get method card and return
1368
- item = methodCard
1369
- //todo remove method card
1370
- }
1371
- return item
1372
- }
1373
-
1374
- function popContentAndRemoveChildrenFromContentsMap(content, contentsMap) {
1375
- if (!content.children || content.children.length === 0) {
1376
- console.warn(`content ${content.id} has no children`, content)
1377
- } else {
1378
- const children = content.children.map((child) => child.id)
1379
- if (contentsMap.has(content.id)) {
1380
- contentsMap.delete(content.id)
1381
- }
1382
- children.forEach((child) => {
1383
- if (contentsMap.has(child)) {
1384
- contentsMap.delete(child)
1385
- }
1386
- })
1387
- }
1388
- return contentsMap
1389
- }
1390
-
1391
- /**
1392
- * Pins a specific progress row for a user, scoped by brand.
1393
- *
1394
- * @param {string} brand - The brand context for the pin action.
1395
- * @param {number|string} id - The ID of the progress item to pin.
1396
- * @param {string} progressType - The type of progress (e.g., 'content', 'playlist').
1397
- * @returns {Promise<Object>} - A promise resolving to the response from the pin API.
1398
- *
1399
- * @example
1400
- * pinProgressRow('drumeo', 12345, 'content')
1401
- * .then(response => console.log(response))
1402
- * .catch(error => console.error(error));
1403
- */
1404
- export async function pinProgressRow(brand, id, progressType) {
1405
- const url = `/api/user-management-system/v1/progress/pin?brand=${brand}&id=${id}&progressType=${progressType}`
1406
- const response = await PUT(url, null)
1407
- if (response && !response.error) {
1408
- await updateUserPinnedProgressRow(brand, {
1409
- id,
1410
- progressType,
1411
- pinnedAt: new Date().toISOString(),
1412
- })
1413
- }
1414
- return response
1415
- }
1416
- /**
1417
- * Unpins the current pinned progress row for a user, scoped by brand.
1418
- *
1419
- * @param {string} brand - The brand context for the unpin action.
1420
- * @returns {Promise<Object>} - A promise resolving to the response from the unpin API.
1421
- *
1422
- * @example
1423
- * unpinProgressRow('drumeo', 123456)
1424
- * .then(response => console.log(response))
1425
- * .catch(error => console.error(error));
1426
- */
1427
- export async function unpinProgressRow(brand) {
1428
- const url = `/api/user-management-system/v1/progress/unpin?brand=${brand}`
1429
- const response = await PUT(url, null)
1430
- if (response && !response.error) {
1431
- await updateUserPinnedProgressRow(brand, null)
1432
- }
1433
- return response
1434
- }
1435
-
1436
- async function updateUserPinnedProgressRow(brand, pinnedData) {
1437
- const userRaw = await globalConfig.localStorage.getItem('user')
1438
- const user = userRaw ? JSON.parse(userRaw) : {}
1439
- user.brand_pinned_progress = user.brand_pinned_progress || {}
1440
- user.brand_pinned_progress[brand] = pinnedData
1441
- await globalConfig.localStorage.setItem('user', JSON.stringify(user))
1442
- }
1443
-
1444
914
  export async function fetchRecentActivitiesActiveTabs() {
1445
915
  const url = `/api/user-management-system/v1/activities/tabs`
1446
916
  const tabs = await GET(url)