musora-content-services 2.157.4 → 2.158.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.
- package/.github/workflows/docs.js.yml +1 -1
- package/CHANGELOG.md +30 -4
- package/jest.live.config.js +10 -0
- package/package.json +1 -1
- package/src/contentTypeConfig.js +1 -1
- package/src/index.d.ts +5 -0
- package/src/index.js +5 -0
- package/src/services/contentAggregator.js +2 -1
- package/src/services/endScreen/README.md +62 -0
- package/src/services/endScreen/endScreen.ts +153 -0
- package/src/services/endScreen/types.ts +63 -0
- package/src/services/forums/threads.ts +13 -2
- package/src/services/recommendations.js +3 -0
- package/src/services/sanity.js +7 -6
- package/src/services/sync/adapters/lokijs.ts +1 -0
- package/src/services/sync/resolver.ts +1 -9
- package/src/services/sync/store/index.ts +3 -13
- package/src/services/urlBuilder.ts +0 -17
- package/src/services/user/profile.js +1 -1
- package/test/unit/awards/award-callbacks.test.ts +144 -0
- package/test/unit/awards/internal/image-utils.test.ts +86 -0
- package/test/unit/endScreen.test.js +712 -0
- package/test/unit/infrastructure/DefaultHeaderProvider.test.ts +39 -0
- package/test/unit/infrastructure/FetchRequestExecutor.test.ts +88 -0
- package/test/unit/progress-row/playlist-card.test.ts +104 -0
- package/test/unit/sentry.test.ts +62 -0
- package/test/unit/sync/context.test.ts +51 -0
- package/test/unit/sync/errors/sync-errors.test.ts +106 -0
- package/test/unit/sync/errors/validators.test.ts +61 -0
- package/test/unit/sync/models/user-award-progress.test.ts +82 -0
- package/test/unit/sync/repositories/user-award-progress.static.test.ts +68 -0
- package/test/unit/sync/resolver.test.ts +6 -9
- package/test/unit/sync/run-scope.test.ts +23 -0
- package/test/unit/sync/store-configs.test.ts +37 -0
- package/test/unit/sync/telemetry/sync-telemetry.test.ts +118 -0
- package/test/unit/sync/utils/event-emitter.test.ts +64 -0
- package/test/unit/url-builder.test.ts +72 -0
- package/.claude/settings.local.json +0 -18
package/CHANGELOG.md
CHANGED
|
@@ -2,19 +2,45 @@
|
|
|
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.
|
|
5
|
+
### [2.158.1](https://github.com/railroadmedia/musora-content-services/compare/v2.158.0...v2.158.1) (2026-05-07)
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
### Bug Fixes
|
|
9
9
|
|
|
10
|
-
*
|
|
10
|
+
* increment sanity url version ([#953](https://github.com/railroadmedia/musora-content-services/issues/953)) ([61c36c9](https://github.com/railroadmedia/musora-content-services/commit/61c36c962473294e6dfe67d3d688105649e3d79f))
|
|
11
|
+
* pass collection to getProgressDataByIds ([#957](https://github.com/railroadmedia/musora-content-services/issues/957)) ([2ad1620](https://github.com/railroadmedia/musora-content-services/commit/2ad162085f45b35f617c0ba4d2cc32e7be4a3708))
|
|
12
|
+
* Revert "add resolver condition to ensure seen records always get marked sync" ([#956](https://github.com/railroadmedia/musora-content-services/issues/956)) ([56741d3](https://github.com/railroadmedia/musora-content-services/commit/56741d3f49c97e4034e7841deb4aef1f938168c5))
|
|
13
|
+
* update sanity url to v4 ([c567d22](https://github.com/railroadmedia/musora-content-services/commit/c567d227a4f3bdb9f2f1c95364d423e67b1b214f))
|
|
11
14
|
|
|
12
|
-
|
|
15
|
+
## [2.158.0](https://github.com/railroadmedia/musora-content-services/compare/v2.153.0...v2.158.0) (2026-05-05)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* **BEHLTP-23:** add multi-user account update endpoint ([#914](https://github.com/railroadmedia/musora-content-services/issues/914)) ([98f6c4b](https://github.com/railroadmedia/musora-content-services/commit/98f6c4bb830c7a8a25ba556ca07fc97023667011))
|
|
21
|
+
* **BEHSTP-167:** offline progress tracking support ([#889](https://github.com/railroadmedia/musora-content-services/issues/889)) ([0528597](https://github.com/railroadmedia/musora-content-services/commit/05285977237ccbfa577a5770a1762c31d855e59e))
|
|
22
|
+
* handle late-start active paths, and set up better resetAllLearningPaths ([#928](https://github.com/railroadmedia/musora-content-services/issues/928)) ([5d80430](https://github.com/railroadmedia/musora-content-services/commit/5d80430fbedfb21668882d402c5f6b07c82cfbf9))
|
|
23
|
+
* **MU2-1384:** enable related lessons for non-members ([#943](https://github.com/railroadmedia/musora-content-services/issues/943)) ([f091bd7](https://github.com/railroadmedia/musora-content-services/commit/f091bd7e7edfcfe8808684d97faff22f62ef6fe0))
|
|
24
|
+
* **MU2-1452:** free tier content sort to front ([#915](https://github.com/railroadmedia/musora-content-services/issues/915)) ([0655e68](https://github.com/railroadmedia/musora-content-services/commit/0655e685f32fe77728d7b8c2505283adbc1a7c97))
|
|
25
|
+
* **MU2-1463:** initialize onboarding flow ([#929](https://github.com/railroadmedia/musora-content-services/issues/929)) ([4daae5f](https://github.com/railroadmedia/musora-content-services/commit/4daae5ff28599b53fa9ee5b9bea8bdb08b706978))
|
|
26
|
+
* muppet - add invite validity/error data ([#923](https://github.com/railroadmedia/musora-content-services/issues/923)) ([3938fa5](https://github.com/railroadmedia/musora-content-services/commit/3938fa56ec9295023b6ba624c5f238d1645a911b))
|
|
13
27
|
|
|
14
28
|
|
|
15
29
|
### Bug Fixes
|
|
16
30
|
|
|
17
|
-
*
|
|
31
|
+
* add missing vimeo_live_event_id field to downloads field list ([#948](https://github.com/railroadmedia/musora-content-services/issues/948)) ([2b54208](https://github.com/railroadmedia/musora-content-services/commit/2b5420838f200ee061dc7af27029df8a3c38f87c))
|
|
32
|
+
* add typeof before LokiJSAdapter in parameter list ([6a135b5](https://github.com/railroadmedia/musora-content-services/commit/6a135b57c516c277a50d707417544ab2f9eeb194))
|
|
33
|
+
* **BEHSTP-160:** hide future content in related lessons ([#952](https://github.com/railroadmedia/musora-content-services/issues/952)) ([b8d5e06](https://github.com/railroadmedia/musora-content-services/commit/b8d5e0662fae47285978ed1c2d641cc514f25ecd))
|
|
34
|
+
* **BR-460:** report option label updates ([#940](https://github.com/railroadmedia/musora-content-services/issues/940)) ([8ad8f72](https://github.com/railroadmedia/musora-content-services/commit/8ad8f72dd9a1284b4098aa00f08962a53a170275))
|
|
35
|
+
* **BR-627:** add pagination support to fetch followed threads method ([779e308](https://github.com/railroadmedia/musora-content-services/commit/779e30881e76b17e17124852c4d2ee02d62ccf87))
|
|
36
|
+
* **BR-632:** dont trickle progress to children when enrolling in GC ([#916](https://github.com/railroadmedia/musora-content-services/issues/916)) ([67a96d6](https://github.com/railroadmedia/musora-content-services/commit/67a96d69bd8b61f12d0efd45475a7b0b08c58813))
|
|
37
|
+
* **BR-659:** fix resources for null parents ([#951](https://github.com/railroadmedia/musora-content-services/issues/951)) ([b78157b](https://github.com/railroadmedia/musora-content-services/commit/b78157b9dbb9f4d8861bca9546eab79a1c04c01e))
|
|
38
|
+
* export ([#936](https://github.com/railroadmedia/musora-content-services/issues/936)) ([16b78be](https://github.com/railroadmedia/musora-content-services/commit/16b78be3dca3cee8981f1347a37f1b1b00ffe691))
|
|
39
|
+
* remove prevSession args ([#937](https://github.com/railroadmedia/musora-content-services/issues/937)) ([2fe1dc2](https://github.com/railroadmedia/musora-content-services/commit/2fe1dc280a21e060b15567afe60a4cf959cecc90))
|
|
40
|
+
* remove timestamp filtering of practice records in offline mode ([#938](https://github.com/railroadmedia/musora-content-services/issues/938)) ([1a8ac03](https://github.com/railroadmedia/musora-content-services/commit/1a8ac03bfa1b9b3670c45049adb9b833263ef871))
|
|
41
|
+
* revert activity timestamp changes ([#942](https://github.com/railroadmedia/musora-content-services/issues/942)) ([bd9f97d](https://github.com/railroadmedia/musora-content-services/commit/bd9f97d6547409b7f2856bf716c90d6723306c36))
|
|
42
|
+
* **TMA-239:** MCS - Sanity - Add a v=2 query parameter to all sanity requests to force a cache refresh ([#933](https://github.com/railroadmedia/musora-content-services/issues/933)) ([eac5a2c](https://github.com/railroadmedia/musora-content-services/commit/eac5a2c6848aee80c462ba0ccbeba52a92652fa9))
|
|
43
|
+
* **TMA-275:** MCS - Sanity - Add a v2 url segment to to all sanity requests to force a cache refresh ([#946](https://github.com/railroadmedia/musora-content-services/issues/946)) ([b5df1dc](https://github.com/railroadmedia/musora-content-services/commit/b5df1dc943cc9d477dcac5879daaf0725fa60f76))
|
|
18
44
|
|
|
19
45
|
### [2.157.2](https://github.com/railroadmedia/musora-content-services/compare/v2.157.0...v2.157.2) (2026-04-30)
|
|
20
46
|
|
package/package.json
CHANGED
package/src/contentTypeConfig.js
CHANGED
|
@@ -116,7 +116,7 @@ export const descriptionField = 'description[0].children[0].text'
|
|
|
116
116
|
// this pulls both any defined resources for the document as well as any resources in the parent document
|
|
117
117
|
export const resourcesField = `[
|
|
118
118
|
... resource[]{resource_name, _key, "resource_url": coalesce('${CloudFrontURl}'+string::split(resource_aws.asset->fileURL, '${AWSUrl}')[1], resource_url )},
|
|
119
|
-
... coalesce(parent_content_reference[]->resource[]{resource_name, _key, "resource_url": coalesce('${CloudFrontURl}'+string::split(resource_aws.asset->fileURL, '${AWSUrl}')[1], resource_url )}, []),
|
|
119
|
+
... coalesce(parent_content_reference[count(@->resource) > 0]->resource[]{resource_name, _key, "resource_url": coalesce('${CloudFrontURl}'+string::split(resource_aws.asset->fileURL, '${AWSUrl}')[1], resource_url )}, []),
|
|
120
120
|
]`
|
|
121
121
|
|
|
122
122
|
export const contentAwardField = "*[references(^._id) && _type == 'content-award'][0]"
|
package/src/index.d.ts
CHANGED
|
@@ -153,6 +153,10 @@ import {
|
|
|
153
153
|
toLocalDay
|
|
154
154
|
} from './services/dateUtils.js';
|
|
155
155
|
|
|
156
|
+
import {
|
|
157
|
+
getEndScreen
|
|
158
|
+
} from './services/endScreen/endScreen.ts';
|
|
159
|
+
|
|
156
160
|
import {
|
|
157
161
|
createForumCategory,
|
|
158
162
|
deleteForumCategory,
|
|
@@ -667,6 +671,7 @@ declare module 'musora-content-services' {
|
|
|
667
671
|
getContentAwardsByIds,
|
|
668
672
|
getContentRows,
|
|
669
673
|
getDailySession,
|
|
674
|
+
getEndScreen,
|
|
670
675
|
getEnrichedLearningPath,
|
|
671
676
|
getEnrichedLearningPaths,
|
|
672
677
|
getHierarchies,
|
package/src/index.js
CHANGED
|
@@ -157,6 +157,10 @@ import {
|
|
|
157
157
|
toLocalDay
|
|
158
158
|
} from './services/dateUtils.js';
|
|
159
159
|
|
|
160
|
+
import {
|
|
161
|
+
getEndScreen
|
|
162
|
+
} from './services/endScreen/endScreen.ts';
|
|
163
|
+
|
|
160
164
|
import {
|
|
161
165
|
createForumCategory,
|
|
162
166
|
deleteForumCategory,
|
|
@@ -666,6 +670,7 @@ export {
|
|
|
666
670
|
getContentAwardsByIds,
|
|
667
671
|
getContentRows,
|
|
668
672
|
getDailySession,
|
|
673
|
+
getEndScreen,
|
|
669
674
|
getEnrichedLearningPath,
|
|
670
675
|
getEnrichedLearningPaths,
|
|
671
676
|
getHierarchies,
|
|
@@ -65,6 +65,7 @@ export async function addContextToContent(dataPromise, ...dataArgs) {
|
|
|
65
65
|
|
|
66
66
|
// todo: merge addProgressData with addResumeTimeSeconds to one watermelon call
|
|
67
67
|
const {
|
|
68
|
+
collection = null, // MA/FE still use this function for LP lessons, so we need this here.
|
|
68
69
|
dataField = null,
|
|
69
70
|
dataField_includeParent = false,
|
|
70
71
|
addProgressPercentage = false,
|
|
@@ -98,7 +99,7 @@ export async function addContextToContent(dataPromise, ...dataArgs) {
|
|
|
98
99
|
awards,
|
|
99
100
|
] = await Promise.all([
|
|
100
101
|
addProgressPercentage || addProgressStatus || addProgressTimestamp
|
|
101
|
-
? getProgressDataByIds(ids) : Promise.resolve(null),
|
|
102
|
+
? getProgressDataByIds(ids, collection) : Promise.resolve(null),
|
|
102
103
|
addIsLiked ? isContentLikedByIds(ids) : Promise.resolve(null),
|
|
103
104
|
addResumeTimeSeconds ? getResumeTimeSecondsByIds(ids) : Promise.resolve(null),
|
|
104
105
|
addNavigateTo ? getNavigateTo(items) : Promise.resolve(null),
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# End Screen Service
|
|
2
|
+
|
|
3
|
+
Determines what content to show after a user finishes a lesson.
|
|
4
|
+
|
|
5
|
+
## Functions
|
|
6
|
+
|
|
7
|
+
| Function | Type | Use case |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| `getEndScreen(params)` | async | All content types — lessons, courses, playlists |
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Decision Logic
|
|
14
|
+
|
|
15
|
+
### `getEndScreen`
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
single-song / play-along / jam → countdown-up-next + RecSys recommendation
|
|
19
|
+
playlist (not last item) → countdown-up-next + next item
|
|
20
|
+
playlist (last item) → null
|
|
21
|
+
no course → countdown-up-next + RecSys recommendation
|
|
22
|
+
course (not last lesson) → countdown-up-next + next lesson
|
|
23
|
+
course (last, has next course) → course-complete + first lesson of next course
|
|
24
|
+
course (last, no next course) → course-complete + RecSys recommendation
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
RecSys fallback: if the recommender returns nothing → related lesson (standalone) or related course (course-complete)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
## API Reference
|
|
31
|
+
|
|
32
|
+
### `getEndScreen(params): Promise<EndScreenResult>`
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
// Parameters
|
|
36
|
+
{
|
|
37
|
+
lesson: { id: number, type?: string } // required
|
|
38
|
+
brand: string // required
|
|
39
|
+
course?: { id: number, children?: ContentItem[] }
|
|
40
|
+
collection?: { id: number, type: string, children?: { id: number, children?: ContentItem[] }[] }
|
|
41
|
+
playlist?: { id: number, items?: ContentItem[] }
|
|
42
|
+
user_playlist_item_index?: number // current item's index in playlist; skips findIndex lookup when provided
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Return value
|
|
46
|
+
{
|
|
47
|
+
variant: 'countdown-up-next' | 'course-complete'
|
|
48
|
+
upNext: object | null // null if RecSys and related content both return nothing
|
|
49
|
+
countdownAutoplay: boolean
|
|
50
|
+
ctaLabels: { primary: string, secondary: string }
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Implementation
|
|
57
|
+
|
|
58
|
+
### When to call
|
|
59
|
+
|
|
60
|
+
Call `getEndScreen` **at page load**, not when the video ends. RecSys calls happen in the background while the user watches — by the time the video ends, the result is ready.
|
|
61
|
+
|
|
62
|
+
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import {fetchSimilarItems} from '../recommendations.js'
|
|
2
|
+
import {fetchByRailContentIds, fetchCourseCollectionData, fetchRelatedLessons} from '../sanity.js'
|
|
3
|
+
import { addContextToContent } from '../contentAggregator.js'
|
|
4
|
+
import { playAlongLessonTypes, jamTrackLessonTypes, lessonTypesMapping } from '../../contentTypeConfig.js'
|
|
5
|
+
import {getUserData} from "../user/management.js";
|
|
6
|
+
import type {
|
|
7
|
+
ContentItem,
|
|
8
|
+
Collection,
|
|
9
|
+
Course,
|
|
10
|
+
Playlist,
|
|
11
|
+
CtaLabels,
|
|
12
|
+
EndScreenResult,
|
|
13
|
+
GetEndScreenParams,
|
|
14
|
+
} from './types.ts'
|
|
15
|
+
|
|
16
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
const SINGLE_SONG_LESSON_TYPES: string[] = [
|
|
19
|
+
...playAlongLessonTypes,
|
|
20
|
+
...jamTrackLessonTypes,
|
|
21
|
+
...lessonTypesMapping['single lessons'],
|
|
22
|
+
]
|
|
23
|
+
const COURSE_COMPLETE_CTA: CtaLabels = { primary: 'Play Now', secondary: 'Back to Home'}
|
|
24
|
+
const COUNTDOWN_CTA: CtaLabels = { primary: 'Play Now' }
|
|
25
|
+
const COUNTDOWN_CTA_REPLAY: CtaLabels = { primary: 'Play Now', secondary: 'Replay' }
|
|
26
|
+
|
|
27
|
+
export async function getEndScreen({
|
|
28
|
+
lesson,
|
|
29
|
+
course = null,
|
|
30
|
+
collection = null,
|
|
31
|
+
playlist = null,
|
|
32
|
+
user_playlist_item_index = null,
|
|
33
|
+
next_item = null,
|
|
34
|
+
brand
|
|
35
|
+
}: GetEndScreenParams): Promise<EndScreenResult> {
|
|
36
|
+
const userData = await getUserData()
|
|
37
|
+
const isAdmin = userData?.is_admin ?? false
|
|
38
|
+
|
|
39
|
+
if (playlist) {
|
|
40
|
+
const nextItem = next_item ??
|
|
41
|
+
getNextItemInPlaylistOrNull(lesson.id, playlist, user_playlist_item_index, isAdmin)
|
|
42
|
+
|
|
43
|
+
return nextItem ? buildCountdown(nextItem, false) : null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (SINGLE_SONG_LESSON_TYPES.includes(lesson.type ?? '')) {
|
|
47
|
+
return buildCountdown(await fetchEndScreenRecommendation(brand, lesson.id, null, isAdmin), true)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!course) {
|
|
51
|
+
return buildCountdown(await fetchEndScreenRecommendation(brand, lesson.id, null, isAdmin), false)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const nextLesson = getNextLessonOrNull(lesson.id, course, isAdmin)
|
|
55
|
+
if (nextLesson) {
|
|
56
|
+
return buildCountdown(nextLesson, false)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// TODO: remove internal fetch if FE provides collection.children directly
|
|
60
|
+
// collection.vue fetches lessonsInCourse (course children) but not the parent collection's courses
|
|
61
|
+
const resolvedCollection: Collection | null =
|
|
62
|
+
collection?.type === 'course' && collection?.parent_id
|
|
63
|
+
? await fetchCourseCollectionData(collection.parent_id)
|
|
64
|
+
: collection
|
|
65
|
+
|
|
66
|
+
const nextCourseFirstLesson = getFirstLessonOfNextCourseOrNull(course.id, resolvedCollection, isAdmin)
|
|
67
|
+
if (nextCourseFirstLesson) {
|
|
68
|
+
return buildCourseComplete(nextCourseFirstLesson)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return buildCourseComplete(await fetchEndScreenRecommendation(brand, lesson.id, course.id, isAdmin))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─── Builders ─────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
function buildCountdown(upNext: any, withReply: boolean): EndScreenResult {
|
|
77
|
+
return { variant: 'countdown-up-next', upNext, countdownAutoplay: true, ctaLabels: withReply ? COUNTDOWN_CTA_REPLAY : COUNTDOWN_CTA }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildCourseComplete(upNext: any): EndScreenResult {
|
|
81
|
+
return { variant: 'course-complete', upNext, countdownAutoplay: false, ctaLabels: COURSE_COMPLETE_CTA }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
function getNextItemInPlaylistOrNull(contentId: number, playlist: Playlist, user_playlist_item_index: number|null, isAdmin: boolean): ContentItem | null {
|
|
87
|
+
const items = playlist.items ?? []
|
|
88
|
+
const index = user_playlist_item_index !== null ? user_playlist_item_index : items.findIndex((item) => Number(item.id) === Number(contentId))
|
|
89
|
+
if (index < 0 || index === items.length - 1) return null
|
|
90
|
+
return items.slice(index + 1).find(item => isReleasedContent(item, isAdmin)) ?? null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function getNextLessonOrNull(lessonId: number, course: Course, isAdmin: boolean): ContentItem | null {
|
|
94
|
+
const children = course.children ?? []
|
|
95
|
+
const index = children.findIndex((child) => Number(child.id) === Number(lessonId))
|
|
96
|
+
if (index < 0 || index === children.length - 1) return null
|
|
97
|
+
return children.slice(index + 1).find(child => isReleasedContent(child, isAdmin)) ?? null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isReleasedContent(content: ContentItem, isAdmin: boolean): boolean {
|
|
101
|
+
if (isAdmin) return true
|
|
102
|
+
if (!content?.status) return false
|
|
103
|
+
return (content.status === 'published') && !content.need_access
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getFirstLessonOfNextCourseOrNull(
|
|
107
|
+
courseId: number,
|
|
108
|
+
collection: Collection | null,
|
|
109
|
+
isAdmin: boolean
|
|
110
|
+
): ContentItem | null {
|
|
111
|
+
if (!collection) return null
|
|
112
|
+
const courses = collection.children ?? []
|
|
113
|
+
const index = courses.findIndex((course) => Number(course.id) === Number(courseId))
|
|
114
|
+
if (index < 0 || index === courses.length - 1) return null
|
|
115
|
+
const nextCourse = courses.slice(index + 1).find(course => isReleasedContent(course, isAdmin))
|
|
116
|
+
if (!nextCourse?.children) return null
|
|
117
|
+
return nextCourse.children.find(lesson => isReleasedContent(lesson, isAdmin)) ?? null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function fetchEndScreenRecommendation(
|
|
121
|
+
brand: string,
|
|
122
|
+
contentId: number,
|
|
123
|
+
parentId: number | null = null,
|
|
124
|
+
isAdmin: boolean
|
|
125
|
+
): Promise<any | null> {
|
|
126
|
+
try {
|
|
127
|
+
let recData: number[] = await fetchSimilarItems(contentId, brand, 20)
|
|
128
|
+
const contents: ContentItem[] = recData.length > 0 ? await fetchByRailContentIds(recData) : []
|
|
129
|
+
let recommended =
|
|
130
|
+
contents?.find((c) => isReleasedContent(c, isAdmin) && c.id !== parentId && (!c.parent_id || c.parent_id !== parentId)) ??
|
|
131
|
+
contents?.find((c) => isReleasedContent(c, isAdmin) && c.id !== parentId) ??
|
|
132
|
+
null
|
|
133
|
+
|
|
134
|
+
if (!recommended) {
|
|
135
|
+
const relatedLesson = await fetchRelatedLessons(parentId ?? contentId).then((r) =>
|
|
136
|
+
r.related_lessons?.[0] ?? null
|
|
137
|
+
)
|
|
138
|
+
recommended = isReleasedContent(relatedLesson, isAdmin) ? relatedLesson : null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!recommended) {
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return await addContextToContent(() => recommended, {
|
|
146
|
+
addProgressPercentage: true,
|
|
147
|
+
addProgressStatus: true,
|
|
148
|
+
addNavigateTo: true,
|
|
149
|
+
})
|
|
150
|
+
} catch {
|
|
151
|
+
return null
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module EndScreen Types
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type EndScreenVariant =
|
|
6
|
+
| 'countdown-up-next'
|
|
7
|
+
| 'course-complete'
|
|
8
|
+
|
|
9
|
+
export interface CtaLabels {
|
|
10
|
+
primary: string
|
|
11
|
+
secondary?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface EndScreenResult {
|
|
15
|
+
variant: EndScreenVariant
|
|
16
|
+
upNext: object | null
|
|
17
|
+
countdownAutoplay: boolean
|
|
18
|
+
ctaLabels: CtaLabels
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ContentItem {
|
|
22
|
+
id: number
|
|
23
|
+
type?: string
|
|
24
|
+
status?: string
|
|
25
|
+
parent_id?: number
|
|
26
|
+
need_access?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface Course {
|
|
30
|
+
id: number
|
|
31
|
+
type?: string
|
|
32
|
+
status?: string
|
|
33
|
+
children?: ContentItem[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CourseInCollection {
|
|
37
|
+
id: number
|
|
38
|
+
status?: string
|
|
39
|
+
children?: ContentItem[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface Collection {
|
|
43
|
+
id: number
|
|
44
|
+
type: string
|
|
45
|
+
status?: string
|
|
46
|
+
parent_id?: number
|
|
47
|
+
children?: CourseInCollection[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface Playlist {
|
|
51
|
+
id: number
|
|
52
|
+
items?: ContentItem[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface GetEndScreenParams {
|
|
56
|
+
lesson: ContentItem
|
|
57
|
+
course?: Course | null
|
|
58
|
+
collection?: Collection | null
|
|
59
|
+
playlist?: Playlist | null
|
|
60
|
+
user_playlist_item_index?: number | null
|
|
61
|
+
next_item?: any | null
|
|
62
|
+
brand: string
|
|
63
|
+
}
|
|
@@ -184,18 +184,29 @@ export async function unlockThread(threadId: number, brand: string): Promise<voi
|
|
|
184
184
|
return httpClient.delete<void>(`${baseUrl}/v1/threads/${threadId}/lock?brand=${brand}`)
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
export interface FetchFollowedThreadsParams {
|
|
188
|
+
page?: number,
|
|
189
|
+
limit?: number,
|
|
190
|
+
}
|
|
191
|
+
|
|
187
192
|
/**
|
|
188
193
|
* Fetches followed forum Threads for the given brand and current user.
|
|
189
194
|
*
|
|
190
195
|
* @param {string} brand - The brand context (e.g., "drumeo", "singeo").
|
|
196
|
+
* @param {FetchFollowedThreadsParams} params - Optional pagination parameters.
|
|
191
197
|
* @returns {Promise<PaginatedResponse<ForumThread>>} - A promise that resolves to the list of forum threads.
|
|
192
198
|
* @throws {HttpError} - If the request fails.
|
|
193
199
|
*/
|
|
194
200
|
export async function fetchFollowedThreads(
|
|
195
|
-
brand: string
|
|
201
|
+
brand: string,
|
|
202
|
+
params: FetchFollowedThreadsParams = {}
|
|
196
203
|
): Promise<PaginatedResponse<ForumThread>> {
|
|
197
204
|
const httpClient = new HttpClient(globalConfig.baseUrl)
|
|
198
|
-
|
|
205
|
+
const queryObj: Record<string, string> = { brand, ...Object.fromEntries(
|
|
206
|
+
Object.entries(params).filter(([_, v]) => v !== undefined && v !== null).map(([k, v]) => [k, String(v)])
|
|
207
|
+
)}
|
|
208
|
+
const query = new URLSearchParams(queryObj).toString()
|
|
209
|
+
return httpClient.get<PaginatedResponse<ForumThread>>(`${baseUrl}/v1/threads?${query}`)
|
|
199
210
|
}
|
|
200
211
|
|
|
201
212
|
export interface FetchLatestThreadParams {
|
package/src/services/sanity.js
CHANGED
|
@@ -1146,20 +1146,22 @@ export async function fetchSiblingContent(railContentId, brand = null) {
|
|
|
1146
1146
|
*/
|
|
1147
1147
|
export async function fetchRelatedLessons(railContentId) {
|
|
1148
1148
|
const defaultFilterFields = `_type==^._type && brand == ^.brand && railcontent_id != ${railContentId}`
|
|
1149
|
-
|
|
1149
|
+
const params = {
|
|
1150
|
+
showMembershipRestrictedContent: true,
|
|
1151
|
+
availableContentStatuses: ['published'],
|
|
1152
|
+
}
|
|
1150
1153
|
const filterSameArtist = await new FilterBuilder(
|
|
1151
1154
|
`${defaultFilterFields} && references(^.artist->_id)`,
|
|
1152
|
-
|
|
1155
|
+
params
|
|
1153
1156
|
).buildFilter()
|
|
1154
1157
|
const filterSameGenre = await new FilterBuilder(
|
|
1155
1158
|
`${defaultFilterFields} && references(^.genre[]->_id)`,
|
|
1156
|
-
|
|
1159
|
+
params
|
|
1157
1160
|
).buildFilter()
|
|
1158
1161
|
const filterSameDifficulty = await new FilterBuilder(
|
|
1159
1162
|
`${defaultFilterFields} && difficulty == ^.difficulty`,
|
|
1160
|
-
|
|
1163
|
+
params
|
|
1161
1164
|
).buildFilter()
|
|
1162
|
-
|
|
1163
1165
|
const queryFields = getFieldsForContentType()
|
|
1164
1166
|
|
|
1165
1167
|
const query = `*[railcontent_id == ${railContentId}]{
|
|
@@ -1169,7 +1171,6 @@ export async function fetchRelatedLessons(railContentId) {
|
|
|
1169
1171
|
...(*[${filterSameGenre}]{${queryFields}}|order(published_on desc, title asc)[0...10]),
|
|
1170
1172
|
...(*[${filterSameDifficulty}]{${queryFields}}|order(published_on desc, title asc)[0...10]),
|
|
1171
1173
|
])[0...10]}`
|
|
1172
|
-
|
|
1173
1174
|
return await fetchSanity(query, false, { processNeedAccess: true })
|
|
1174
1175
|
}
|
|
1175
1176
|
|
|
@@ -25,6 +25,7 @@ export default class LokiPersistenceErrorAwareAdapter extends (LokiJSAdapter as
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
_overrideSaveDatabase(onPersistenceError?: (err: Error) => void) {
|
|
28
|
+
if (!this._driver) return
|
|
28
29
|
const driver = this._driver
|
|
29
30
|
const persistenceAdapter = driver.loki.persistenceAdapter
|
|
30
31
|
const oldSaveDatabase = persistenceAdapter.saveDatabase;
|
|
@@ -7,7 +7,6 @@ export type SyncResolution = {
|
|
|
7
7
|
tuplesForUpdate: [BaseModel, SyncEntry][]
|
|
8
8
|
tuplesForRestore: [BaseModel, SyncEntry][]
|
|
9
9
|
idsForDestroy: RecordId[]
|
|
10
|
-
recordsForSynced: BaseModel[]
|
|
11
10
|
}
|
|
12
11
|
|
|
13
12
|
export type SyncResolverComparator<T extends BaseModel = BaseModel> = (serverEntry: SyncEntryNonDeleted<T>, localModel: T) => 'SERVER' | 'LOCAL'
|
|
@@ -25,8 +24,7 @@ export default class SyncResolver {
|
|
|
25
24
|
entriesForCreate: [],
|
|
26
25
|
tuplesForUpdate: [],
|
|
27
26
|
tuplesForRestore: [],
|
|
28
|
-
idsForDestroy: []
|
|
29
|
-
recordsForSynced: []
|
|
27
|
+
idsForDestroy: []
|
|
30
28
|
}
|
|
31
29
|
}
|
|
32
30
|
|
|
@@ -60,9 +58,6 @@ export default class SyncResolver {
|
|
|
60
58
|
} else if (this.comparator(server as SyncEntryNonDeleted<BaseModel>, local) !== 'LOCAL') {
|
|
61
59
|
// local is older, so update it with server's
|
|
62
60
|
this.resolution.tuplesForUpdate.push([local, server])
|
|
63
|
-
} else {
|
|
64
|
-
// server is older - can happen with clock skew - just mark as synced
|
|
65
|
-
this.resolution.recordsForSynced.push(local)
|
|
66
61
|
}
|
|
67
62
|
}
|
|
68
63
|
|
|
@@ -74,9 +69,6 @@ export default class SyncResolver {
|
|
|
74
69
|
} else if (this.comparator(server as SyncEntryNonDeleted<BaseModel>, local) !== 'LOCAL') {
|
|
75
70
|
// local is older, so update it with server's
|
|
76
71
|
this.resolution.tuplesForUpdate.push([local, server])
|
|
77
|
-
} else {
|
|
78
|
-
// server is older - can happen with clock skew - just mark as synced
|
|
79
|
-
this.resolution.recordsForSynced.push(local)
|
|
80
72
|
}
|
|
81
73
|
}
|
|
82
74
|
|
|
@@ -184,12 +184,8 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
184
184
|
async pushRecordIdsImpatiently(ids: RecordId[], span?: Span) {
|
|
185
185
|
const records = await this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(ids)))
|
|
186
186
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
queueThrottle({ state: this.pushThrottleState }, () => {
|
|
190
|
-
return this.executePush(records, span)
|
|
191
|
-
})
|
|
192
|
-
)
|
|
187
|
+
// don't use coalescer or throttle - otherwise it could pick up on currently in-flight retrying (i.e., not impatient) requests
|
|
188
|
+
return this.executePush(records, span)
|
|
193
189
|
}
|
|
194
190
|
|
|
195
191
|
async readAll() {
|
|
@@ -962,15 +958,9 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
962
958
|
r._raw._changed = ''
|
|
963
959
|
})
|
|
964
960
|
})
|
|
965
|
-
const syncedBuilds = result.recordsForSynced.map((record) => {
|
|
966
|
-
return record.prepareUpdate((r) => {
|
|
967
|
-
r._raw._status = 'synced'
|
|
968
|
-
r._raw._changed = ''
|
|
969
|
-
})
|
|
970
|
-
})
|
|
971
961
|
|
|
972
962
|
return [
|
|
973
|
-
[...destroyedBuilds, ...createdBuilds, ...updatedBuilds, ...restoreDestroyBuilds
|
|
963
|
+
[...destroyedBuilds, ...createdBuilds, ...updatedBuilds, ...restoreDestroyBuilds],
|
|
974
964
|
[...restoreCreateBuilds],
|
|
975
965
|
]
|
|
976
966
|
}
|
|
@@ -97,10 +97,6 @@ const CONTENT_TYPES_WITHOUT_OVERVIEW = ['course', 'guided-course']
|
|
|
97
97
|
* @example
|
|
98
98
|
* generateContentUrl({ id: 456, type: 'course-part', parentId: 789, brand: 'pianote' })
|
|
99
99
|
* // Returns: "/pianote/lessons/course/789/456"
|
|
100
|
-
*
|
|
101
|
-
* @example
|
|
102
|
-
* generateContentUrl({ id: 123, type: 'pack-bundle', navigateTo: { id: 456 }, brand: 'guitareo' })
|
|
103
|
-
* // Returns: "/guitareo/lessons/pack/123/456"
|
|
104
100
|
*/
|
|
105
101
|
export async function generateContentUrl({
|
|
106
102
|
id,
|
|
@@ -128,18 +124,6 @@ export async function generateContentUrl({
|
|
|
128
124
|
return `/${brand}/lessons/course-collection/overview/${id}`
|
|
129
125
|
}
|
|
130
126
|
|
|
131
|
-
if (type === 'pack') {
|
|
132
|
-
return `/${brand}/lessons/pack/overview/${id}`
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
if (type === 'pack-bundle') {
|
|
136
|
-
if (navigateTo?.id) {
|
|
137
|
-
return `/${brand}/lessons/pack/${id}/${navigateTo.id}`
|
|
138
|
-
}
|
|
139
|
-
// Fallback to overview if navigateTo is missing
|
|
140
|
-
return `/${brand}/lessons/pack/overview/${id}`
|
|
141
|
-
}
|
|
142
|
-
|
|
143
127
|
// Recursive helper to fetch navigateTo with optional deep fetching
|
|
144
128
|
const fetchNavigateToRecursive = async (contentId: number | string, shouldGoDeeper: boolean): Promise<any> => {
|
|
145
129
|
const content = await fetchByRailContentIds([contentId])
|
|
@@ -209,7 +193,6 @@ export async function generateContentUrl({
|
|
|
209
193
|
'course-lesson': 'course',
|
|
210
194
|
'guided-course-lesson': 'course',
|
|
211
195
|
'guided-course': 'course',
|
|
212
|
-
'pack-bundle-lesson': 'pack',
|
|
213
196
|
'documentary-lesson': 'documentary',
|
|
214
197
|
'skill-pack-lesson': 'skill-pack',
|
|
215
198
|
|
|
@@ -28,7 +28,7 @@ export async function otherStats(userId = globalConfig.sessionConfig.userId) {
|
|
|
28
28
|
type: 'week',
|
|
29
29
|
length: longestStreaks.longestWeeklyStreak,
|
|
30
30
|
},
|
|
31
|
-
total_practice_time: longestStreaks.totalPracticeSeconds,
|
|
31
|
+
total_practice_time: longestStreaks.totalPracticeSeconds + (stats.v1_practice_time ?? 0),
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|