musora-content-services 2.111.0 → 2.111.2
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 +14 -0
- package/package.json +1 -1
- package/src/contentTypeConfig.js +10 -0
- package/src/index.d.ts +4 -6
- package/src/index.js +4 -6
- package/src/infrastructure/http/HttpClient.ts +12 -0
- package/src/services/contentAggregator.js +0 -2
- package/src/services/contentProgress.js +5 -3
- package/src/services/progress-row/base.js +205 -0
- package/src/services/progress-row/rows/.indexignore +0 -0
- package/src/services/progress-row/rows/content-card.js +211 -0
- package/src/services/progress-row/{method-card.js → rows/method-card.js} +7 -9
- package/src/services/progress-row/rows/playlist-card.js +76 -0
- package/src/services/userActivity.js +2 -532
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
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.2](https://github.com/railroadmedia/musora-content-services/compare/v2.111.1...v2.111.2) (2026-01-06)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* adds methods to set/remove http token ([0a460b6](https://github.com/railroadmedia/musora-content-services/commit/0a460b6d002fb7d85c340095d62c1e92ce253d64))
|
|
11
|
+
|
|
12
|
+
### [2.111.1](https://github.com/railroadmedia/musora-content-services/compare/v2.111.0...v2.111.1) (2026-01-06)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* 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))
|
|
18
|
+
|
|
5
19
|
## [2.111.0](https://github.com/railroadmedia/musora-content-services/compare/v2.110.3...v2.111.0) (2026-01-05)
|
|
6
20
|
|
|
7
21
|
|
package/package.json
CHANGED
package/src/contentTypeConfig.js
CHANGED
|
@@ -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
|
-
|
|
211
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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,
|
|
@@ -29,6 +29,10 @@ export class HttpClient {
|
|
|
29
29
|
this.token = token
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
public clearToken(): void {
|
|
33
|
+
this.token = null;
|
|
34
|
+
}
|
|
35
|
+
|
|
32
36
|
public async get<T>(url: string, dataVersion: string | null = null): Promise<T> {
|
|
33
37
|
return this.request<T>(url, 'GET', dataVersion)
|
|
34
38
|
}
|
|
@@ -136,3 +140,11 @@ export const POST = httpClient.post.bind(httpClient)
|
|
|
136
140
|
export const PUT = httpClient.put.bind(httpClient)
|
|
137
141
|
export const PATCH = httpClient.patch.bind(httpClient)
|
|
138
142
|
export const DELETE = httpClient.delete.bind(httpClient)
|
|
143
|
+
|
|
144
|
+
export const setHttpToken = (token: string): void => {
|
|
145
|
+
httpClient.setToken(token)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const clearHttpToken = (): void => {
|
|
149
|
+
httpClient.clearToken()
|
|
150
|
+
}
|
|
@@ -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
|
-
|
|
507
|
-
|
|
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 '
|
|
6
|
-
import { getToday } from '
|
|
7
|
-
import { fetchMethodV2IntroVideo } from '
|
|
8
|
-
import { getProgressState } from '
|
|
9
|
-
import {COLLECTION_TYPE, STATE} from '
|
|
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
|
-
|
|
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
|
-
|
|
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)
|