musora-content-services 2.112.2 → 2.113.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ## [2.113.0](https://github.com/railroadmedia/musora-content-services/compare/v2.112.2...v2.113.0) (2026-01-08)
6
+
7
+
8
+ ### Features
9
+
10
+ * **BEH-1491:** proper card ordering (and hiding) on progress row ([#686](https://github.com/railroadmedia/musora-content-services/issues/686)) ([e519c35](https://github.com/railroadmedia/musora-content-services/commit/e519c352ac5e8d09a910b89fa03baf31490da102))
11
+
5
12
  ### [2.112.2](https://github.com/railroadmedia/musora-content-services/compare/v2.112.1...v2.112.2) (2026-01-08)
6
13
 
7
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.112.2",
3
+ "version": "2.113.0",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -467,7 +467,7 @@ export async function contentStatusReset(contentId, collection = null, {skipPush
467
467
  return resetStatus(contentId, collection, {skipPush})
468
468
  }
469
469
 
470
- async function saveContentProgress(contentId, collection, progress, currentSeconds, {skipPush = false, hideFromProgressRow = false} = {}) {
470
+ async function saveContentProgress(contentId, collection, progress, currentSeconds, {skipPush = false, fromLearningPath = false} = {}) {
471
471
  const isLP = collection?.type === COLLECTION_TYPE.LEARNING_PATH
472
472
 
473
473
  // filter out contentIds that are setting progress lower than existing
@@ -482,12 +482,14 @@ async function saveContentProgress(contentId, collection, progress, currentSecon
482
482
  collection,
483
483
  progress,
484
484
  currentSeconds,
485
- {skipPush: true, hideFromProgressRow}
485
+ {skipPush: true, fromLearningPath}
486
486
  )
487
487
  // note - previous implementation explicitly did not trickle progress to children here
488
488
  // (only to siblings/parents via le bubbles)
489
489
 
490
+ // skip bubbling if progress hasnt changed
490
491
  if (progress === currentProgress) {
492
+ if (!skipPush) db.contentProgress.requestPushUnsynced()
491
493
  return
492
494
  }
493
495
 
@@ -561,45 +563,6 @@ async function setStartedOrCompletedStatus(contentId, collection, isCompleted, {
561
563
  return response
562
564
  }
563
565
 
564
- // we cannot simply pass LP id with self collection, because we do not have a-la-carte LP's set up yet,
565
- // and we need each lesson to bubble to its parent outside of LP
566
- async function duplicateLearningPathProgressToExternalContents(ids, collection, hierarchy, {skipPush = false} = {}) {
567
- // filter out LPs. we dont want to duplicate to LP's while we dont have a-la-cart LP's set up.
568
- let filteredIds = Object.fromEntries(
569
- Object.entries(ids).filter(([id]) => {
570
- return hierarchy.parents[parseInt(id)] !== null
571
- })
572
- )
573
-
574
- const extProgresses = await getProgressDataByIds(Object.keys(filteredIds), null)
575
-
576
- // overwrite if LP progress greater, unless LP progress was reset to 0
577
- filteredIds = Object.entries(filteredIds).filter(([id, pct]) => {
578
- const extPct = extProgresses[id]?.progress
579
- return (pct !== 0)
580
- ? pct > extPct
581
- : false
582
- })
583
-
584
- // each handles its own bubbling.
585
- // skipPush on all but last to avoid multiple push requests
586
- filteredIds.forEach(([id, pct], index) => {
587
- let skip = true
588
- if (index === filteredIds.length - 1) {
589
- skip = skipPush
590
- }
591
- saveContentProgress(parseInt(id), null, pct, null, {skipPush: skip, hideFromProgressRow: true})
592
- })
593
- }
594
-
595
- async function getHierarchy(contentId, collection) {
596
- if (collection && collection.type === COLLECTION_TYPE.LEARNING_PATH) {
597
- return await fetchLearningPathHierarchy(contentId, collection)
598
- } else {
599
- return await fetchHierarchy(contentId)
600
- }
601
- }
602
-
603
566
  async function setStartedOrCompletedStatusMany(contentIds, collection, isCompleted, {skipPush = false} = {}) {
604
567
  const isLP = collection?.type === COLLECTION_TYPE.LEARNING_PATH
605
568
  const progress = isCompleted ? 100 : 0
@@ -651,6 +614,7 @@ async function resetStatus(contentId, collection = null, {skipPush = false} = {}
651
614
 
652
615
  const progress = 0
653
616
  const response = await db.contentProgress.eraseProgress(contentId, collection, {skipPush: true})
617
+
654
618
  const hierarchy = await getHierarchy(contentId, collection)
655
619
 
656
620
  let progresses = {
@@ -670,6 +634,45 @@ async function resetStatus(contentId, collection = null, {skipPush = false} = {}
670
634
  return response
671
635
  }
672
636
 
637
+ // we cannot simply pass LP id with self collection, because we do not have a-la-carte LP's set up yet,
638
+ // and we need each lesson to bubble to its parent outside of LP
639
+ async function duplicateLearningPathProgressToExternalContents(ids, collection, hierarchy, {skipPush = false} = {}) {
640
+ // filter out LPs. we dont want to duplicate to LP's while we dont have a-la-cart LP's set up.
641
+ let filteredIds = Object.fromEntries(
642
+ Object.entries(ids).filter(([id]) => {
643
+ return hierarchy.parents[parseInt(id)] !== null
644
+ })
645
+ )
646
+
647
+ const extProgresses = await getProgressDataByIds(Object.keys(filteredIds), null)
648
+
649
+ // overwrite if LP progress greater, unless LP progress was reset to 0
650
+ filteredIds = Object.entries(filteredIds).filter(([id, pct]) => {
651
+ const extPct = extProgresses[id]?.progress
652
+ return (pct !== 0)
653
+ ? pct > extPct
654
+ : false
655
+ })
656
+
657
+ // each handles its own bubbling.
658
+ // skipPush on all but last to avoid multiple push requests
659
+ filteredIds.forEach(([id, pct], index) => {
660
+ let skip = true
661
+ if (index === filteredIds.length - 1) {
662
+ skip = skipPush
663
+ }
664
+ saveContentProgress(parseInt(id), null, pct, null, {skipPush: skip, fromLearningPath: true})
665
+ })
666
+ }
667
+
668
+ async function getHierarchy(contentId, collection) {
669
+ if (collection && collection.type === COLLECTION_TYPE.LEARNING_PATH) {
670
+ return await fetchLearningPathHierarchy(contentId, collection)
671
+ } else {
672
+ return await fetchHierarchy(contentId)
673
+ }
674
+ }
675
+
673
676
  // agnostic to collection - makes returned data structure simpler,
674
677
  // as long as callers remember to pass collection where needed
675
678
  function trickleProgress(hierarchy, contentId, _collection, progress) {
@@ -107,7 +107,8 @@ export async function getMethodCard(brand) {
107
107
  )
108
108
 
109
109
  if (!maxProgressTimestamp) {
110
- maxProgressTimestamp = learningPath.active_learning_path_created_at
110
+ // active LP created_at is stored in seconds, so *1000 to match rest of cards
111
+ maxProgressTimestamp = learningPath.active_learning_path_created_at * 1000
111
112
  }
112
113
 
113
114
  return {
@@ -29,6 +29,7 @@ export default class ContentProgress extends BaseModel<{
29
29
  state: STATE
30
30
  progress_percent: number
31
31
  resume_time_seconds: number | null
32
+ last_interacted_a_la_carte: number | null
32
33
  }> {
33
34
  static table = SYNC_TABLES.CONTENT_PROGRESS
34
35
 
@@ -53,8 +54,8 @@ export default class ContentProgress extends BaseModel<{
53
54
  get resume_time_seconds() {
54
55
  return (this._getRaw('resume_time_seconds') as number) || null
55
56
  }
56
- get hide_from_progress_row() {
57
- return this._getRaw('hide_from_progress_row') as boolean
57
+ get last_interacted_a_la_carte() {
58
+ return this._getRaw('last_interacted_a_la_carte') as number
58
59
  }
59
60
 
60
61
  set content_id(value: number) {
@@ -90,8 +91,8 @@ export default class ContentProgress extends BaseModel<{
90
91
  throwIfNotNullableNumber(value)
91
92
  this._setRaw('resume_time_seconds', value !== null ? throwIfOutsideRange(value, 0, 65535) : value)
92
93
  }
93
- set hide_from_progress_row(value: boolean) {
94
- this._setRaw('hide_from_progress_row', value)
94
+ set last_interacted_a_la_carte(value: number) {
95
+ this._setRaw('last_interacted_a_la_carte', value)
95
96
  }
96
97
 
97
98
  }
@@ -62,7 +62,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
62
62
  Q.where('collection_type', COLLECTION_TYPE.SELF),
63
63
  Q.where('collection_id', COLLECTION_ID_SELF),
64
64
 
65
- Q.where('hide_from_progress_row', false),
65
+ Q.where('last_interacted_a_la_carte', Q.notEq(null)),
66
66
 
67
67
  Q.or(Q.where('state', STATE.STARTED), Q.where('state', STATE.COMPLETED)),
68
68
  Q.sortBy('updated_at', 'desc'),
@@ -135,9 +135,13 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
135
135
  }
136
136
  }
137
137
 
138
- recordProgress(contentId: number, collection: CollectionParameter | null, progressPct: number, resumeTime?: number, {skipPush = false, hideFromProgressRow = false} = {}) {
138
+ recordProgress(contentId: number, collection: CollectionParameter | null, progressPct: number, resumeTime?: number, {skipPush = false, fromLearningPath = false} = {}) {
139
139
  const id = ProgressRepository.generateId(contentId, collection)
140
140
 
141
+ if (collection?.type === COLLECTION_TYPE.LEARNING_PATH) {
142
+ fromLearningPath = true
143
+ }
144
+
141
145
  const result = this.upsertOne(id, (r) => {
142
146
  r.content_id = contentId
143
147
  r.collection_type = collection?.type ?? COLLECTION_TYPE.SELF
@@ -149,7 +153,10 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
149
153
  r.resume_time_seconds = Math.floor(resumeTime)
150
154
  }
151
155
 
152
- r.hide_from_progress_row = hideFromProgressRow
156
+ if (!fromLearningPath) {
157
+ r.last_interacted_a_la_carte = Date.now()
158
+ }
159
+
153
160
  }, { skipPush })
154
161
 
155
162
  // Emit event AFTER database write completes
@@ -180,8 +187,11 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
180
187
  recordProgressMany(
181
188
  contentProgresses: Record<string, number>, // Accept plain object
182
189
  collection: CollectionParameter | null,
183
- { tentative = true, skipPush = false, hideFromProgressRow = false }: { tentative?: boolean; skipPush?: boolean; hideFromProgressRow?: boolean } = {}
190
+ { tentative = true, skipPush = false, fromLearningPath = false }: { tentative?: boolean; skipPush?: boolean; fromLearningPath?: boolean } = {}
184
191
  ) {
192
+ if (collection?.type === COLLECTION_TYPE.LEARNING_PATH) {
193
+ fromLearningPath = true
194
+ }
185
195
 
186
196
  const data = Object.fromEntries(
187
197
  Object.entries(contentProgresses).map(([contentId, progressPct]) => [
@@ -193,7 +203,9 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
193
203
 
194
204
  r.progress_percent = progressPct
195
205
 
196
- r.hide_from_progress_row = hideFromProgressRow
206
+ if (!fromLearningPath) {
207
+ r.last_interacted_a_la_carte = Date.now()
208
+ }
197
209
  },
198
210
  ])
199
211
  )
@@ -26,7 +26,7 @@ const contentProgressTable = tableSchema({
26
26
  { name: 'state', type: 'string', isIndexed: true },
27
27
  { name: 'progress_percent', type: 'number' },
28
28
  { name: 'resume_time_seconds', type: 'number', isOptional: true },
29
- { name: 'hide_from_progress_row', type: 'boolean'},
29
+ { name: 'last_interacted_a_la_carte', type: 'number', isOptional: true },
30
30
  { name: 'created_at', type: 'number' },
31
31
  { name: 'updated_at', type: 'number', isIndexed: true }
32
32
  ]