musora-content-services 2.154.0 → 2.155.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.
@@ -16,14 +16,12 @@ import {
16
16
  import {TabResponseType, Tabs, capitalizeFirstLetter} from '../contentMetaData.js'
17
17
  import {recommendations, rankCategories, rankItems} from "./recommendations";
18
18
  import {addContextToContent} from "./contentAggregator.js";
19
- import {globalConfig} from "./config";
20
19
  import {getUserData} from "./user/management";
21
20
  import {
22
21
  lessonTypesMapping,
23
22
  ownedContentTypes
24
23
  } from "../contentTypeConfig";
25
- import {getPermissionsAdapter} from "./permissions/index.ts";
26
- import {MEMBERSHIP_PERMISSIONS} from "../constants/membership-permissions.ts";
24
+ import { fetchUserPermissions, getPermissionsAdapter, isUserFreeTier, doesUserHaveMembership } from './permissions/index.ts'
27
25
 
28
26
 
29
27
  export async function getLessonContentRows (brand='drumeo', pageName = 'lessons') {
@@ -93,9 +91,12 @@ export async function getTabResults(brand, pageName, tabName, {
93
91
  sort = 'recommended',
94
92
  selectedFilters = []
95
93
  } = {}) {
96
-
97
94
  if (!tabName && ['lessons', 'songs'].includes(pageName)) return { type: TabResponseType.CATALOG, data: [], meta: { filters: [], sort: {} } }
98
95
 
96
+ const userPermissions = await fetchUserPermissions()
97
+ const permissions = userPermissions.permissions || []
98
+ const isFreeTier = isUserFreeTier(userPermissions)
99
+
99
100
  // Extract and handle 'progress' filter separately
100
101
  const progressFilter = selectedFilters.find(f => f.startsWith('progress,')) || 'progress,all';
101
102
  const progressValue = progressFilter.split(',')[1].toLowerCase();
@@ -131,6 +132,10 @@ export async function getTabResults(brand, pageName, tabName, {
131
132
 
132
133
  recommendedContent = filterCoursesInCourseCollections(recommendedContent)
133
134
 
135
+ if (isFreeTier) {
136
+ recommendedContent = applyPermissionsPostSort(recommendedContent, permissions)
137
+ }
138
+
134
139
  const start = (page - 1) * limit
135
140
  const end = start + limit
136
141
  const pagesFilledByRec = Math.floor(recommendedContent.length / limit)
@@ -143,7 +148,8 @@ export async function getTabResults(brand, pageName, tabName, {
143
148
  sort: '-published_on',
144
149
  includedFields: mergedIncludedFields,
145
150
  progress: progressValue,
146
- excludeIds: recommendedContent.map(c => c.id)
151
+ excludeIds: recommendedContent.map(c => c.id),
152
+ sortPermissions: permissions,
147
153
  })
148
154
 
149
155
  // Filter out duplicates and combine
@@ -163,7 +169,8 @@ export async function getTabResults(brand, pageName, tabName, {
163
169
  limit,
164
170
  sort: '-published_on',
165
171
  includedFields: mergedIncludedFields,
166
- progress: progressValue
172
+ progress: progressValue,
173
+ sortPermissions: permissions,
167
174
  })
168
175
  contentToDisplay = temp.entity
169
176
  }
@@ -175,7 +182,17 @@ export async function getTabResults(brand, pageName, tabName, {
175
182
  addProgressStatus: true
176
183
  })
177
184
  } else {
178
- let temp = await fetchTabData(brand, pageName, { page, limit, sort, includedFields: mergedIncludedFields, progress: progressValue });
185
+ let temp = await fetchTabData(
186
+ brand,
187
+ pageName,
188
+ {
189
+ page,
190
+ limit,
191
+ sort,
192
+ includedFields: mergedIncludedFields,
193
+ progress: progressValue,
194
+ sortPermissions: permissions,
195
+ });
179
196
  const [ranking, contextResults] = await Promise.all([
180
197
  sort === 'recommended' ? rankItems(brand, temp.entity.map(e => e.id)) : [],
181
198
  addContextToContent(() => temp.entity, {
@@ -550,9 +567,7 @@ export async function getLegacyMethods(brand)
550
567
  const userPermissions = userPermissionsData.permissions
551
568
  // Users should only have access to this if they have an active membership AS WELL as the content access
552
569
  // This is hardcoded behaviour and isn't found elsewhere
553
- const hasMembership = userPermissionsData.isAdmin
554
- || userPermissions.includes(MEMBERSHIP_PERMISSIONS.base)
555
- || userPermissions.includes(MEMBERSHIP_PERMISSIONS.plus)
570
+ const hasMembership = doesUserHaveMembership(userPermissionsData)
556
571
  const hasContentPermission = userPermissions.includes(100000000 + ids[0])
557
572
  if (hasMembership && hasContentPermission) {
558
573
  return Promise.all(ids.map(id => fetchCourseCollectionData(id)))
@@ -625,3 +640,14 @@ export async function getOwnedContent(brand, {
625
640
  export function filterCoursesInCourseCollections(data) {
626
641
  return data.filter(c => !(c.type === 'course' && c.parent_id))
627
642
  }
643
+
644
+ function applyPermissionsPostSort(contentList, permissionIds) {
645
+ contentList.sort((a, b) => {
646
+ const hasAccess = (item) =>
647
+ item.permission_id?.includes(permissionIds)
648
+
649
+ return hasAccess(b) - hasAccess(a);
650
+ });
651
+
652
+ return contentList;
653
+ }
@@ -1,10 +1,17 @@
1
- import { getHierarchy } from './sanity.js'
1
+ import { getHierarchy, getHierarchies } from './sanity.js'
2
2
  import { db } from './sync'
3
3
  import { COLLECTION_ID_SELF, COLLECTION_TYPE, STATE } from './sync/models/ContentProgress'
4
- import { trackUserPractice, findIncompleteLesson } from './userActivity'
4
+ import { trackUserPractice } from './userActivity'
5
5
  import { getNextLessonLessonParentTypes } from '../contentTypeConfig.js'
6
6
  import { getDailySession, onContentCompletedLearningPathActions } from './content-org/learning-paths.ts'
7
7
 
8
+ /**
9
+ * Exported functions that are excluded from index generation.
10
+ *
11
+ * @type {string[]}
12
+ */
13
+ const excludeFromGeneratedIndex = ['_recordWatchSession', 'setStartedOrCompletedStatus', 'setStartedOrCompletedStatusMany', 'resetStatus']
14
+
8
15
  const STATE_STARTED = STATE.STARTED
9
16
  const STATE_COMPLETED = STATE.COMPLETED
10
17
  const MAX_DEPTH = 3
@@ -46,7 +53,7 @@ export async function getNavigateToForMethod(data) {
46
53
  for (const content of data) {
47
54
  if (!content) continue
48
55
 
49
- const { _, collection } = extractFromRecordId(content.record_id)
56
+ const { collection } = extractFromRecordId(content.record_id)
50
57
 
51
58
  const findFirstIncomplete = (ids, progresses) =>
52
59
  ids.find(id => progresses.get(id) !== STATE_COMPLETED) || ids[0]
@@ -181,6 +188,28 @@ export async function getNavigateTo(data) {
181
188
  return navigateToData
182
189
  }
183
190
 
191
+ function findIncompleteLesson(progressOnItems, currentContentId, contentType) {
192
+ const isMap = progressOnItems instanceof Map
193
+ const ids = isMap ? Array.from(progressOnItems.keys()) : Object.keys(progressOnItems).map(Number)
194
+ const getProgress = (id) => isMap ? progressOnItems.get(id) : progressOnItems[id]
195
+
196
+ if (contentType === 'guided-course' || contentType === COLLECTION_TYPE.LEARNING_PATH) {
197
+ return ids.find((id) => getProgress(id) !== 'completed') || ids.at(0)
198
+ }
199
+
200
+ const currentIndex = ids.indexOf(Number(currentContentId))
201
+ if (currentIndex === -1) return null
202
+
203
+ for (let i = currentIndex + 1; i < ids.length; i++) {
204
+ const id = ids[i]
205
+ if (getProgress(id) !== 'completed') {
206
+ return id
207
+ }
208
+ }
209
+
210
+ return ids[0]
211
+ }
212
+
184
213
  function buildNavigateTo(content, child = null, collection = null) {
185
214
  if (!content) {
186
215
  return null
@@ -405,10 +434,10 @@ async function _getAllStartedOrCompleted({
405
434
  * @param {int} mediaLengthSeconds - total length of video media || live event duration if livestream
406
435
  * @param {int} currentSeconds - seconds timestamp relative to beginning of video
407
436
  * @param {int} secondsPlayed - seconds played in this watch session (since last pause)
408
- * @param {any} prevSession - This function records a sessionId to pass into future updates to progress on the same video
437
+ * @param {string|null} prevSession - This function records a sessionId to pass into future updates to progress on the same video
409
438
  * @param {int|null} instrumentId - enum value of instrument id
410
439
  * @param {int|null} categoryId - enum value of category id
411
- * @param {boolean} isLivestream - determines livestream-specific progress handling
440
+ * @param {boolean|null} isLivestream - determines livestream-specific progress handling
412
441
  */
413
442
  export async function recordWatchSession(
414
443
  contentId,
@@ -419,6 +448,53 @@ export async function recordWatchSession(
419
448
  instrumentId = null,
420
449
  categoryId = null,
421
450
  isLivestream = false,
451
+ ) {
452
+ return _recordWatchSession(
453
+ contentId,
454
+ mediaLengthSeconds,
455
+ currentSeconds,
456
+ secondsPlayed,
457
+ {
458
+ collection,
459
+ prevSession,
460
+ instrumentId,
461
+ categoryId,
462
+ isLivestream,
463
+ })
464
+ }
465
+
466
+ /**
467
+ * internal function to be called by only or offline version of recordWatchSession
468
+ * @return {string} sessionId - provide in future calls to update progress
469
+ * @param {int} contentId
470
+ * @param {any} collection - progress collection context, null if a-la-carte
471
+ * @param {string} collection.type - enum value of collection type
472
+ * @param {int} collection.id - content_id of parent collection (e.g. learning path content_id)
473
+ * @param {int} mediaLengthSeconds - total length of video media || live event duration if livestream
474
+ * @param {int} currentSeconds - seconds timestamp relative to beginning of video
475
+ * @param {int} secondsPlayed - seconds played in this watch session (since last pause)
476
+ * @param {string|null} prevSession - This function records a sessionId to pass into future updates to progress on the same video
477
+ * @param {int|null} instrumentId - enum value of instrument id
478
+ * @param {int|null} categoryId - enum value of category id
479
+ * @param {boolean|null} isLivestream - determines livestream-specific progress handling
480
+ * @param {boolean} isOffline - whether this watch session is being recorded in offline mode, which affects how progress is tracked and pushed
481
+ * @param {object|null} hierarchy - response from getHierarchy, passed in to avoid redundant calls within the same session
482
+ * @private
483
+ */
484
+ export async function _recordWatchSession(
485
+ contentId,
486
+ mediaLengthSeconds,
487
+ currentSeconds,
488
+ secondsPlayed,
489
+ {
490
+ collection = null,
491
+ prevSession = null,
492
+ instrumentId = null,
493
+ categoryId = null,
494
+ isLivestream = false,
495
+ isOffline = false,
496
+ hierarchy = null,
497
+ } = {}
422
498
  ) {
423
499
  contentId = normalizeContentId(contentId)
424
500
  collection = normalizeCollection(collection)
@@ -426,7 +502,7 @@ export async function recordWatchSession(
426
502
  // Track practice and progress locally (no immediate push)
427
503
  await Promise.all([
428
504
  trackPractice(contentId, secondsPlayed, { instrumentId, categoryId }),
429
- trackProgress(contentId, collection, currentSeconds, mediaLengthSeconds, isLivestream),
505
+ trackProgress(contentId, collection, currentSeconds, mediaLengthSeconds, isLivestream, isOffline, hierarchy),
430
506
  ])
431
507
  }
432
508
 
@@ -439,7 +515,15 @@ async function trackPractice(contentId, secondsPlayed, details = {}) {
439
515
  return trackUserPractice(contentId, secondsPlayed, details)
440
516
  }
441
517
 
442
- async function trackProgress(contentId, collection, currentSeconds, mediaLengthSeconds, isLivestream = false) {
518
+ async function trackProgress(
519
+ contentId,
520
+ collection,
521
+ currentSeconds,
522
+ mediaLengthSeconds,
523
+ isLivestream = false,
524
+ isOffline = false,
525
+ hierarchy = null,
526
+ ) {
443
527
  const progress = Math.max(1, Math.min(
444
528
  99,
445
529
  Math.round(((currentSeconds ?? 0) / Math.max(1, mediaLengthSeconds)) * 100)
@@ -450,25 +534,17 @@ async function trackProgress(contentId, collection, currentSeconds, mediaLengthS
450
534
  // doesn't affect livestream resumeTime, but will send users to 0 seconds in VOD
451
535
  currentSeconds = 0
452
536
  }
453
- return saveContentProgress(contentId, collection, progress, currentSeconds, { skipPush: true })
537
+ return saveContentProgress(contentId, collection, progress, currentSeconds, { isOffline, hierarchy, skipPush: true })
454
538
  }
455
539
 
456
540
  export async function contentStatusCompleted(contentId, collection = null) {
457
541
  collection = collection ?? {id: COLLECTION_ID_SELF, type: COLLECTION_TYPE.SELF}
458
- return setStartedOrCompletedStatus(
459
- normalizeContentId(contentId),
460
- normalizeCollection(collection),
461
- true
462
- )
542
+ return setStartedOrCompletedStatus(contentId, collection, true)
463
543
  }
464
544
 
465
545
  export async function contentStatusCompletedMany(contentIds, collection = null) {
466
546
  collection = collection ?? {id: COLLECTION_ID_SELF, type: COLLECTION_TYPE.SELF}
467
- return setStartedOrCompletedStatusMany(
468
- normalizeContentIds(contentIds),
469
- normalizeCollection(collection),
470
- true
471
- )
547
+ return setStartedOrCompletedStatusMany(contentIds, collection, true)
472
548
  }
473
549
 
474
550
  // skipBubbleTrickle is only for starting enrolled GC's as a hack to get them into the progress row.
@@ -486,7 +562,18 @@ export async function contentStatusReset(contentId, collection = null, {skipPush
486
562
  return resetStatus(contentId, collection, {skipPush})
487
563
  }
488
564
 
489
- async function saveContentProgress(contentId, collection, progress, currentSeconds, {skipPush = false, accessedDirectly = true} = {}) {
565
+ async function saveContentProgress(
566
+ contentId,
567
+ collection,
568
+ progress,
569
+ currentSeconds,
570
+ {
571
+ isOffline = false,
572
+ hierarchy = null,
573
+ skipPush = false,
574
+ accessedDirectly = true,
575
+ } = {}
576
+ ) {
490
577
  collection = collection ?? {id: COLLECTION_ID_SELF, type: COLLECTION_TYPE.SELF}
491
578
  const isLP = collection?.type === COLLECTION_TYPE.LEARNING_PATH
492
579
  const isPlaylist = collection?.type === COLLECTION_TYPE.PLAYLIST
@@ -498,7 +585,9 @@ async function saveContentProgress(contentId, collection, progress, currentSecon
498
585
  progress = currentProgress
499
586
  }
500
587
 
501
- const hierarchy = await getHierarchy(contentId, collection)
588
+ if (!isOffline) {
589
+ hierarchy = await getHierarchy(contentId, collection)
590
+ }
502
591
  const metadata = hierarchy.metadata || {}
503
592
 
504
593
  if (isPlaylist) {
@@ -518,10 +607,10 @@ async function saveContentProgress(contentId, collection, progress, currentSecon
518
607
  // note - previous implementation explicitly did not trickle progress to children here
519
608
  // (only to siblings/parents via le bubbles)
520
609
 
521
- // skip bubbling if progress hasnt changed
522
- if (progress === currentProgress) {
610
+ // skip bubbling if progress hasnt changed, or if offline
611
+ if (progress === currentProgress || offline) {
523
612
  if (!skipPush) db.contentProgress.requestPushUnsynced('save-content-progress')
524
- return
613
+ return response
525
614
  }
526
615
 
527
616
  const bubbledProgresses = await bubbleProgress(hierarchy, contentId, collection)
@@ -542,6 +631,7 @@ async function saveContentProgress(contentId, collection, progress, currentSecon
542
631
  {skipPush: true, accessedDirectly})
543
632
  }
544
633
 
634
+ // there are problems if we allow downloading LPs, since we require 2 different hierarchies for this.
545
635
  if (isLP) {
546
636
  let exportIds = bubbledProgresses
547
637
  exportIds[contentId] = progress
@@ -561,10 +651,12 @@ async function saveContentProgress(contentId, collection, progress, currentSecon
561
651
  return response
562
652
  }
563
653
 
564
- async function setStartedOrCompletedStatus(contentId, collection, isCompleted, {skipPush = false, skipBubbleTrickle = false} = {}) {
654
+ async function setStartedOrCompletedStatus(contentId, collection, isCompleted, { isOffline = false, hierarchy = null, skipPush = false, skipBubbleTrickle = false } = {}) {
565
655
  const isLP = collection?.type === COLLECTION_TYPE.LEARNING_PATH
566
656
 
567
- const hierarchy = await getHierarchy(contentId, collection)
657
+ if (!isOffline) {
658
+ hierarchy = await getHierarchy(contentId, collection)
659
+ }
568
660
  const metadata = hierarchy.metadata || {}
569
661
 
570
662
  const progress = isCompleted ? 100 : 0
@@ -576,7 +668,13 @@ async function setStartedOrCompletedStatus(contentId, collection, isCompleted, {
576
668
  null,
577
669
  {skipPush: true}
578
670
  )
579
-
671
+
672
+ // skip bubbling if offline
673
+ if (isOffline) {
674
+ if (!skipPush) db.contentProgress.requestPushUnsynced('save-content-progress')
675
+ return response
676
+ }
677
+
580
678
  let allProgresses = {}
581
679
  allProgresses[contentId] = progress
582
680
 
@@ -605,12 +703,16 @@ async function setStartedOrCompletedStatus(contentId, collection, isCompleted, {
605
703
  return response
606
704
  }
607
705
 
608
- async function setStartedOrCompletedStatusMany(contentIds, collection, isCompleted, {skipPush = false} = {}) {
706
+ export async function setStartedOrCompletedStatusMany(contentIds, collection, isCompleted, { isOffline = false, hierarchy = null, skipPush = false } = {}) {
707
+ contentIds = normalizeContentIds(contentIds)
708
+ collection = normalizeCollection(collection)
709
+
609
710
  const isLP = collection?.type === COLLECTION_TYPE.LEARNING_PATH
610
711
  const progress = isCompleted ? 100 : 0
611
712
 
612
- // we assume this is used only for contents within the same hierarchy
613
- const hierarchy = await getHierarchy(collection.id, collection)
713
+ if (!isOffline) {
714
+ hierarchy = await getHierarchies(contentIds, collection)
715
+ }
614
716
  const metadata = hierarchy.metadata || {}
615
717
 
616
718
  const contents = Object.fromEntries(contentIds.map((id) => [id, progress]))
@@ -621,6 +723,12 @@ async function setStartedOrCompletedStatusMany(contentIds, collection, isComplet
621
723
  {skipPush: true}
622
724
  )
623
725
 
726
+ // skip bubbling if offline
727
+ if (isOffline) {
728
+ if (!skipPush) db.contentProgress.requestPushUnsynced('save-content-progress')
729
+ return response
730
+ }
731
+
624
732
  let allProgresses = Object.fromEntries(contentIds.map(id => [id, progress]))
625
733
 
626
734
  let progresses = {}
@@ -650,15 +758,24 @@ async function setStartedOrCompletedStatusMany(contentIds, collection, isComplet
650
758
  return response
651
759
  }
652
760
 
653
- async function resetStatus(contentId, collection = null, {skipPush = false} = {}) {
761
+ export async function resetStatus(contentId, collection = null, { isOffline = false, hierarchy = null, skipPush = false } = {}) {
762
+ contentId = normalizeContentId(contentId)
763
+ collection = normalizeCollection(collection)
764
+
654
765
  const isLP = collection?.type === COLLECTION_TYPE.LEARNING_PATH
655
766
 
656
767
  const progress = 0
657
768
  const response = await db.contentProgress.eraseProgress(normalizeContentId(contentId), normalizeCollection(collection), {skipPush: true})
658
-
659
- const hierarchy = await getHierarchy(contentId, collection)
769
+
770
+ // skip bubbling if offline
771
+ if (isOffline) {
772
+ if (!skipPush) db.contentProgress.requestPushUnsynced('save-content-progress')
773
+ return response
774
+ }
775
+
776
+ hierarchy = await getHierarchy(contentId, collection)
660
777
  const metadata = hierarchy.metadata || {}
661
-
778
+
662
779
  let allProgresses = {}
663
780
  allProgresses[contentId] = progress
664
781
 
@@ -0,0 +1,97 @@
1
+ import { db } from '../sync'
2
+ import { Q } from '@nozbe/watermelondb'
3
+ import { STATE } from '../sync/models/ContentProgress'
4
+ import { lessonRecentTypes, SONG_TYPES } from '../../contentTypeConfig.js'
5
+ import dayjs from 'dayjs'
6
+
7
+ interface Activity {
8
+ contentId: number
9
+ action: 'Start' | 'Complete'
10
+ contentType: 'lesson' | 'song'
11
+ date: string
12
+ brand: string
13
+ }
14
+
15
+ /**
16
+ * @param offlineTimestamp - Minimum `updated_at` epoch ms to include
17
+ * @param options.page - Page number (default 1)
18
+ * @param options.limit - Results per page (default 5)
19
+ * @param options.tabName - Restrict to `'lessons'`, `'songs'`, or both when `null`
20
+ * @returns `{ currentPage, totalPages, data }` where `data` is a page of `Activity` records
21
+ */
22
+ export async function getRecentActivityOffline(
23
+ offlineTimestamp: number,
24
+ {
25
+ page = 1,
26
+ limit = 5,
27
+ tabName = null
28
+ }: {
29
+ page?: number,
30
+ limit?: number,
31
+ tabName?: 'lessons'|'songs'|null
32
+ } = {}): Promise<any> {
33
+ // Note: this is kind of a hack. We're really just getting RADFOP: Recent Activities Derived From Offline Progress,
34
+ // because setting up watermelon user activities table is extremely complicated.
35
+ // Note: this implementation does not persist "activities" beyond when the corresponding record is deleted. That's ok right now.
36
+
37
+ const clauses = getClauses(offlineTimestamp, tabName)
38
+
39
+ const query = await db.contentProgress.queryAll(...clauses)
40
+ const progress = query.data
41
+
42
+ const activities = deriveActivitiesFromProgress(progress)
43
+
44
+ const totalPages = Math.ceil(activities.length / limit)
45
+ const currentPage = Math.min(page, totalPages)
46
+
47
+ const sorted = activities.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
48
+ const data = sorted.slice((currentPage - 1) * limit, currentPage * limit)
49
+
50
+ return {
51
+ currentPage,
52
+ totalPages,
53
+ data,
54
+ }
55
+ }
56
+
57
+ function getClauses(offlineTimestamp: number, tabName: string|null) {
58
+ let clauses: Q.Clause[] = [
59
+ Q.where('updated_at', Q.gte(offlineTimestamp)),
60
+ Q.sortBy('created_at', 'desc'),
61
+ ]
62
+
63
+ if (tabName === 'lessons') {
64
+ clauses.push(Q.where('content_type', Q.oneOf(lessonRecentTypes)))
65
+ } else if (tabName === 'songs') {
66
+ clauses.push(Q.where('content_type', Q.oneOf(SONG_TYPES)))
67
+ } else {
68
+ clauses.push(Q.where('content_type', Q.oneOf([...lessonRecentTypes, ...SONG_TYPES])))
69
+ }
70
+
71
+ return clauses
72
+ }
73
+
74
+ function deriveActivitiesFromProgress(progress: Record<any, any>) {
75
+ const activities: Activity[] = []
76
+ progress.forEach(p => {
77
+ const type = lessonRecentTypes.includes(p.content_type) ? 'lesson' : 'song'
78
+
79
+ activities.push({
80
+ contentId: p.content_id,
81
+ action: 'Start',
82
+ contentType: type,
83
+ date: dayjs(p.created_at).toISOString(),
84
+ brand: p.content_brand,
85
+ })
86
+ if (p.state === STATE.COMPLETED) {
87
+ activities.push({
88
+ contentId: p.content_id,
89
+ action: 'Complete',
90
+ contentType: type,
91
+ date: dayjs(p.updated_at).toISOString(),
92
+ brand: p.content_brand,
93
+ })
94
+ }
95
+ })
96
+ return activities
97
+ }
@@ -0,0 +1,47 @@
1
+ import { db } from '../sync'
2
+ import { Q } from '@nozbe/watermelondb'
3
+ import dayjs from 'dayjs'
4
+ import { globalConfig } from '../config'
5
+ import { calculateLongestStreaks } from '../userActivity.js'
6
+
7
+ /**
8
+ * @param offlineTimestamp - Minimum `updated_at` epoch ms to include
9
+ * @param options.day - Date in YYYY-MM-DD format, defaults to today
10
+ * @returns `{ data: { practices, practiceDuration } }` where `practiceDuration` is total seconds
11
+ */
12
+ export async function getPracticeSessionsOffline(
13
+ offlineTimestamp: number,
14
+ { day = dayjs().format('YYYY-MM-DD') }: { day?: string } = {}
15
+ ) {
16
+
17
+ const query = await db.practices.queryAll(
18
+ Q.where('updated_at', Q.gte(offlineTimestamp)),
19
+ Q.where('date', day),
20
+ Q.sortBy('created_at', 'asc'))
21
+ const practices = query.data
22
+
23
+ if (!practices.length) return { data: { practices: [], practiceDuration: 0 } }
24
+
25
+ const practiceDuration = Math.round(practices.reduce(
26
+ (total, practice) => total + (practice.duration_seconds || 0),
27
+ 0
28
+ ))
29
+
30
+ return { data: { practices, practiceDuration } }
31
+ }
32
+
33
+ export async function otherStatsOffline(userId = globalConfig.sessionConfig.userId) {
34
+ const longestStreaks = await calculateLongestStreaks(userId)
35
+
36
+ return {
37
+ longest_day_streak: {
38
+ type: 'day',
39
+ length: longestStreaks.longestDailyStreak,
40
+ },
41
+ longest_week_streak: {
42
+ type: 'week',
43
+ length: longestStreaks.longestWeeklyStreak,
44
+ },
45
+ total_practice_time: longestStreaks.totalPracticeSeconds,
46
+ }
47
+ }
@@ -0,0 +1,104 @@
1
+ import {
2
+ _recordWatchSession, resetStatus,
3
+ setStartedOrCompletedStatus,
4
+ setStartedOrCompletedStatusMany,
5
+ } from '../contentProgress.js'
6
+ import { COLLECTION_ID_SELF, COLLECTION_TYPE, CollectionParameter, STATE } from '../sync/models/ContentProgress'
7
+
8
+ interface HierarchyParameter {
9
+ topLevelId: number
10
+ parents: { [contentId: number]: [parentId: number] }
11
+ children: { [contentId: number]: [childId: number] }
12
+ metadata: {
13
+ brand: string
14
+ parent_id: number
15
+ type: string
16
+ }
17
+ }
18
+
19
+ /**
20
+ * @param contentId
21
+ * @param mediaLengthSeconds - Total length of the media
22
+ * @param currentSeconds - Playhead position at session end
23
+ * @param secondsPlayed - Seconds actively watched in this session
24
+ * @param hierarchy - Content hierarchy used to update parent progress offline
25
+ * @param options.collection - Collection context; defaults to self
26
+ * @param options.prevSession - Previous session identifier for continuity
27
+ * @param options.instrumentId - Instrument filter for the session
28
+ * @param options.categoryId - Category filter for the session
29
+ */
30
+ export async function recordWatchSessionOffline(
31
+ contentId: number,
32
+ mediaLengthSeconds: number,
33
+ currentSeconds: number,
34
+ secondsPlayed: number,
35
+ hierarchy: HierarchyParameter,
36
+ {
37
+ collection = null,
38
+ prevSession = null,
39
+ instrumentId = null,
40
+ categoryId = null,
41
+ }: {
42
+ collection?: CollectionParameter|null,
43
+ prevSession?: string|null,
44
+ instrumentId?: number|null,
45
+ categoryId?: number|null
46
+ } = {}
47
+ ) {
48
+ return _recordWatchSession(
49
+ contentId,
50
+ mediaLengthSeconds,
51
+ currentSeconds,
52
+ secondsPlayed,
53
+ {
54
+ collection,
55
+ prevSession,
56
+ instrumentId,
57
+ categoryId,
58
+ isOffline: true,
59
+ hierarchy,
60
+ })
61
+ }
62
+
63
+ /**
64
+ * @param contentId
65
+ * @param collection - Collection context; defaults to self
66
+ * @param hierarchy - Content hierarchy used to update parent progress offline
67
+ */
68
+ export async function contentStatusCompletedOffline(contentId: number, collection: CollectionParameter = null, hierarchy: HierarchyParameter) {
69
+ collection = collection ?? {id: COLLECTION_ID_SELF, type: COLLECTION_TYPE.SELF}
70
+ return setStartedOrCompletedStatus(contentId, collection, true, {isOffline: true, hierarchy})
71
+ }
72
+
73
+ /**
74
+ * @param contentIds
75
+ * @param collection - Collection context; defaults to self
76
+ * @param hierarchy - Content hierarchy used to update parent progress offline
77
+ */
78
+ export async function contentStatusCompletedManyOffline(contentIds: number[], collection: CollectionParameter = null, hierarchy: HierarchyParameter) {
79
+ collection = collection ?? {id: COLLECTION_ID_SELF, type: COLLECTION_TYPE.SELF}
80
+ return setStartedOrCompletedStatusMany(contentIds, collection, true, {isOffline: true, hierarchy})
81
+ }
82
+
83
+ /**
84
+ * @param contentId
85
+ * @param collection - Collection context; defaults to self
86
+ * @param hierarchy - Content hierarchy used to update parent progress offline
87
+ */
88
+ export async function contentStatusStartedOffline(contentId: number, collection: CollectionParameter = null, hierarchy: HierarchyParameter) {
89
+ collection = collection ?? {id: COLLECTION_ID_SELF, type: COLLECTION_TYPE.SELF}
90
+ return setStartedOrCompletedStatus(contentId, collection, false, {isOffline: true, hierarchy})
91
+ }
92
+
93
+ /**
94
+ * @param contentId
95
+ * @param collection - Collection context; defaults to self
96
+ * @param hierarchy - Content hierarchy used to update parent progress offline
97
+ * @param options.skipPush - Skip queuing the reset for server sync (default false)
98
+ */
99
+ export async function contentStatusResetOffline(contentId: number, collection: CollectionParameter = null, hierarchy: HierarchyParameter, {skipPush = false} = {}) {
100
+ collection = collection ?? {id: COLLECTION_ID_SELF, type: COLLECTION_TYPE.SELF}
101
+ return resetStatus(contentId, collection, {hierarchy, skipPush})
102
+ }
103
+
104
+