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.
Files changed (38) hide show
  1. package/.github/workflows/docs.js.yml +1 -1
  2. package/CHANGELOG.md +30 -4
  3. package/jest.live.config.js +10 -0
  4. package/package.json +1 -1
  5. package/src/contentTypeConfig.js +1 -1
  6. package/src/index.d.ts +5 -0
  7. package/src/index.js +5 -0
  8. package/src/services/contentAggregator.js +2 -1
  9. package/src/services/endScreen/README.md +62 -0
  10. package/src/services/endScreen/endScreen.ts +153 -0
  11. package/src/services/endScreen/types.ts +63 -0
  12. package/src/services/forums/threads.ts +13 -2
  13. package/src/services/recommendations.js +3 -0
  14. package/src/services/sanity.js +7 -6
  15. package/src/services/sync/adapters/lokijs.ts +1 -0
  16. package/src/services/sync/resolver.ts +1 -9
  17. package/src/services/sync/store/index.ts +3 -13
  18. package/src/services/urlBuilder.ts +0 -17
  19. package/src/services/user/profile.js +1 -1
  20. package/test/unit/awards/award-callbacks.test.ts +144 -0
  21. package/test/unit/awards/internal/image-utils.test.ts +86 -0
  22. package/test/unit/endScreen.test.js +712 -0
  23. package/test/unit/infrastructure/DefaultHeaderProvider.test.ts +39 -0
  24. package/test/unit/infrastructure/FetchRequestExecutor.test.ts +88 -0
  25. package/test/unit/progress-row/playlist-card.test.ts +104 -0
  26. package/test/unit/sentry.test.ts +62 -0
  27. package/test/unit/sync/context.test.ts +51 -0
  28. package/test/unit/sync/errors/sync-errors.test.ts +106 -0
  29. package/test/unit/sync/errors/validators.test.ts +61 -0
  30. package/test/unit/sync/models/user-award-progress.test.ts +82 -0
  31. package/test/unit/sync/repositories/user-award-progress.static.test.ts +68 -0
  32. package/test/unit/sync/resolver.test.ts +6 -9
  33. package/test/unit/sync/run-scope.test.ts +23 -0
  34. package/test/unit/sync/store-configs.test.ts +37 -0
  35. package/test/unit/sync/telemetry/sync-telemetry.test.ts +118 -0
  36. package/test/unit/sync/utils/event-emitter.test.ts +64 -0
  37. package/test/unit/url-builder.test.ts +72 -0
  38. package/.claude/settings.local.json +0 -18
@@ -31,7 +31,7 @@ jobs:
31
31
  - name: Setup Node.js
32
32
  uses: actions/setup-node@v4
33
33
  with:
34
- node-version: '20'
34
+ node-version: '24'
35
35
 
36
36
  - name: Install dependencies for v2
37
37
  working-directory: main-content
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.157.4](https://github.com/railroadmedia/musora-content-services/compare/v2.157.3...v2.157.4) (2026-05-05)
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
- * update sanity url to v4 ([91daeb7](https://github.com/railroadmedia/musora-content-services/commit/91daeb7e50801832a4d824f6f20695d29879fd36))
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
- ### [2.157.3](https://github.com/railroadmedia/musora-content-services/compare/v2.157.2...v2.157.3) (2026-05-05)
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
- * increment sanity url version ([de73b89](https://github.com/railroadmedia/musora-content-services/commit/de73b89bbc53175ba5901c2588fdb2f47ff35069))
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
 
@@ -0,0 +1,10 @@
1
+ /** @type {import('jest').Config} */
2
+ import baseConfig from './jest.config.js'
3
+
4
+ export default {
5
+ ...baseConfig,
6
+ modulePathIgnorePatterns: [],
7
+ setupFilesAfterEnv: ['dotenv/config'],
8
+ testTimeout: 1000000,
9
+ collectCoverage: false,
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.157.4",
3
+ "version": "2.158.1",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -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
- return httpClient.get<PaginatedResponse<ForumThread>>(`${baseUrl}/v1/threads?brand=${brand}`)
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 {
@@ -37,6 +37,9 @@ export async function fetchSimilarItems(content_id, brand, count = 10) {
37
37
  brand: brand,
38
38
  content_ids: content_id,
39
39
  num_similar: count + 1,
40
+ page_size: count + 1,
41
+ page: 1,
42
+ exclude_interacted: true
40
43
  }
41
44
  const url = `/similar_items/`
42
45
  try {
@@ -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
- { showMembershipRestrictedContent: true }
1155
+ params
1153
1156
  ).buildFilter()
1154
1157
  const filterSameGenre = await new FilterBuilder(
1155
1158
  `${defaultFilterFields} && references(^.genre[]->_id)`,
1156
- { showMembershipRestrictedContent: true }
1159
+ params
1157
1160
  ).buildFilter()
1158
1161
  const filterSameDifficulty = await new FilterBuilder(
1159
1162
  `${defaultFilterFields} && difficulty == ^.difficulty`,
1160
- { showMembershipRestrictedContent: true }
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
- return await this.pushCoalescer.push(
188
- records,
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, ...syncedBuilds],
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