musora-content-services 2.2.0 → 2.3.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 (115) hide show
  1. package/.editorconfig +0 -0
  2. package/.github/workflows/node.js.yml +0 -0
  3. package/.prettierignore +0 -0
  4. package/.prettierrc +0 -0
  5. package/.yarnrc.yml +1 -0
  6. package/CHANGELOG.md +17 -0
  7. package/README.md +0 -0
  8. package/babel.config.cjs +0 -0
  9. package/docs/Content-Organization.html +0 -0
  10. package/docs/Gamification.html +0 -0
  11. package/docs/Playlists.html +0 -0
  12. package/docs/api_types.js.html +0 -0
  13. package/docs/config.js.html +0 -0
  14. package/docs/content-org_playlists-types.js.html +0 -0
  15. package/docs/content-org_playlists.js.html +0 -0
  16. package/docs/content-org_types.js.html +0 -0
  17. package/docs/content.js.html +0 -0
  18. package/docs/fonts/Montserrat/Montserrat-Bold.eot +0 -0
  19. package/docs/fonts/Montserrat/Montserrat-Bold.ttf +0 -0
  20. package/docs/fonts/Montserrat/Montserrat-Bold.woff +0 -0
  21. package/docs/fonts/Montserrat/Montserrat-Bold.woff2 +0 -0
  22. package/docs/fonts/Montserrat/Montserrat-Regular.eot +0 -0
  23. package/docs/fonts/Montserrat/Montserrat-Regular.ttf +0 -0
  24. package/docs/fonts/Montserrat/Montserrat-Regular.woff +0 -0
  25. package/docs/fonts/Montserrat/Montserrat-Regular.woff2 +0 -0
  26. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.eot +0 -0
  27. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.svg +0 -0
  28. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.ttf +0 -0
  29. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff +0 -0
  30. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff2 +0 -0
  31. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.eot +0 -0
  32. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.svg +0 -0
  33. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.ttf +0 -0
  34. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff +0 -0
  35. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff2 +0 -0
  36. package/docs/gamification_awards.js.html +0 -0
  37. package/docs/gamification_gamification.js.html +0 -0
  38. package/docs/gamification_types.js.html +0 -0
  39. package/docs/global.html +0 -0
  40. package/docs/index.html +0 -0
  41. package/docs/module-Awards.html +0 -0
  42. package/docs/module-Config.html +0 -0
  43. package/docs/module-Content-Organization-Playlists.html +0 -0
  44. package/docs/module-Content-Organization.html +0 -0
  45. package/docs/module-Content-Services-V2.html +0 -0
  46. package/docs/module-Content-Services.html +763 -0
  47. package/docs/module-Playlists.html +0 -0
  48. package/docs/module-Railcontent-Services.html +0 -0
  49. package/docs/module-Sanity-Services.html +0 -0
  50. package/docs/module-Session-Management.html +0 -0
  51. package/docs/module-User-Permissions.html +0 -0
  52. package/docs/module-playlists.html +0 -0
  53. package/docs/module-playlists_.html +0 -0
  54. package/docs/railcontent.js.html +0 -0
  55. package/docs/sanity.js.html +0 -0
  56. package/docs/scripts/collapse.js +0 -0
  57. package/docs/scripts/commonNav.js +0 -0
  58. package/docs/scripts/linenumber.js +0 -0
  59. package/docs/scripts/nav.js +0 -0
  60. package/docs/scripts/polyfill.js +0 -0
  61. package/docs/scripts/prettify/Apache-License-2.0.txt +0 -0
  62. package/docs/scripts/prettify/lang-css.js +0 -0
  63. package/docs/scripts/prettify/prettify.js +0 -0
  64. package/docs/scripts/search.js +0 -0
  65. package/docs/styles/jsdoc.css +0 -0
  66. package/docs/styles/prettify.css +0 -0
  67. package/docs/user_permissions.js.html +0 -0
  68. package/docs/user_sessions.js.html +0 -0
  69. package/docs/user_types.js.html +0 -0
  70. package/jest.config.js +0 -0
  71. package/jsdoc.json +0 -0
  72. package/package.json +1 -1
  73. package/src/contentMetaData.js +0 -0
  74. package/src/contentTypeConfig.js +1 -0
  75. package/src/filterBuilder.js +0 -0
  76. package/src/index.d.ts +35 -2
  77. package/src/index.js +35 -2
  78. package/src/lib/httpHelper.js +0 -0
  79. package/src/lib/lastUpdated.js +0 -0
  80. package/src/services/api/types.js +0 -0
  81. package/src/services/config.js +0 -0
  82. package/src/services/content-org/playlists-types.js +0 -0
  83. package/src/services/content-org/playlists.js +0 -0
  84. package/src/services/content.js +0 -0
  85. package/src/services/contentLikes.js +0 -0
  86. package/src/services/contentProgress.js +0 -0
  87. package/src/services/dataContext.js +15 -2
  88. package/src/services/forum.js +0 -0
  89. package/src/services/gamification/awards.js +0 -0
  90. package/src/services/gamification/gamification.js +0 -0
  91. package/src/services/gamification/types.js +0 -0
  92. package/src/services/railcontent.js +60 -0
  93. package/src/services/recommendations.js +0 -0
  94. package/src/services/sanity.js +10 -7
  95. package/src/services/user/management.js +0 -0
  96. package/src/services/user/permissions.js +0 -0
  97. package/src/services/user/sessions.js +0 -0
  98. package/src/services/user/types.js +0 -0
  99. package/src/services/userActivity.js +378 -23
  100. package/test/content.test.js +0 -0
  101. package/test/contentLikes.test.js +2 -0
  102. package/test/contentProgress.test.js +0 -0
  103. package/test/dataContext.test.js +0 -0
  104. package/test/forum.test.js +0 -0
  105. package/test/initializeTests.js +0 -0
  106. package/test/lib/lastUpdated.test.js +0 -0
  107. package/test/live/contentProgressLive.test.js +0 -0
  108. package/test/live/railcontentLive.test.js +0 -0
  109. package/test/localStorageMock.js +0 -0
  110. package/test/log.js +0 -0
  111. package/test/mockData/mockData_fetchByRailContentIds_one_content.json +35 -0
  112. package/test/sanityQueryService.test.js +0 -0
  113. package/test/user/permissions.test.js +0 -0
  114. package/test/userActivity.test.js +118 -0
  115. package/tools/generate-index.cjs +0 -0
@@ -2,31 +2,386 @@
2
2
  * @module User-Activity
3
3
  */
4
4
 
5
- import {fetchHandler} from "./railcontent";
6
- const userActivityStats = {
7
- user: {
8
- id: 1,
9
- fullName: 'John Doe',
10
- profilePictureUrl: 'https://i.pravatar.cc/300',
5
+ import {fetchUserPractices, logUserPractice, fetchUserPracticeMeta, fetchHandler} from './railcontent'
6
+ import { DataContext, UserActivityVersionKey } from './dataContext.js'
7
+ import {fetchByRailContentIds} from "./sanity";
8
+ import {lessonTypesMapping} from "../contentTypeConfig";
9
+
10
+ const recentActivity = {
11
+ data: [
12
+ { id: 5,title: '3 Easy Classical Songs For Beginners', action: 'Comment', thumbnail: 'https://s3-alpha-sig.figma.com/img/22c3/1eb9/d819a2c6727b78deb2fcf051349a0667?Expires=1743984000&Key-Pair-Id=APKAQ4GOSFWCW27IBOMQ&Signature=RGusttOtnWP80iL68~l4XqFrQNe-kOTnUSLtMXQwGJNLfWNze6~fMZ15LsH4IYpz85mJKgILgCegi1sEPF6loBJpKYF9AH5HC2Zz1fenM1T3V387dgb4FifujKtR-DJuBpknPNFvZ9wC9ebCfoXhc1HLe7BJUDSr8gJssiqsimQPU-9TanAOJAFTaxOfvQ0-WEW1VIdCWLX0OOjn1qs~jZTeOGcxy3b~OD1CxiUmwp5tA3lBgqya18Mf8kmcfHjByNmYysd2FwV5tS19dCnmzbE9hwvLwMOnQ38SYOKhxCLsteDRBIxLNjTGJFDUm4wF~089Kkd1zA8pn8-kVfYtwg__', summary: 'Just completed the advanced groove lesson! I’m finally feeling more confident with my fills. Thanks for the clear explanations and practice tips! ', date: '2025-03-25 10:09:48' },
13
+ { id:4, title: 'Piano Man by Billy Joel', action: 'Play', thumbnail:'https://s3-alpha-sig.figma.com/img/fab0/fffe/3cb08e0256a02a18ac37b2db1c2b4a1f?Expires=1743984000&Key-Pair-Id=APKAQ4GOSFWCW27IBOMQ&Signature=smjF95zM9b469lFJ8F6mmjpRs4jPlDcBnflDXc9G~Go1ab87fnWpmg-megUoLmSkqu-Rf3s8P5buzqNP-YnqQl413g3grqNURTIwHRaI2HplN1OXL~OBLU9jHjgQxZmMI6VfSLs301W~cU9NHmMLYRr38r9mVQM6ippSMawj7MFSywiPhvHSvAIXt65o6HlNszhq1n5eZmxVdiL7tjifSja~fGVtHDsX0wuD3L-KAN5TIqywAgRzzFFMHw3yYxiOHajbRSi0s0LJNIHRF4iBJFFZWVXY5vdNX5YKmAmblnbfYIK3GrwJiaVEv6rGzOo~nN4Zh-FKJWvjyPd2oBmfbg__', date: '2025-03-25 10:04:48' },
14
+ { id:3, title: 'General Piano Discussion', action: 'Post', thumbnail: 'https://s3-alpha-sig.figma.com/img/22c3/1eb9/d819a2c6727b78deb2fcf051349a0667?Expires=1743984000&Key-Pair-Id=APKAQ4GOSFWCW27IBOMQ&Signature=RGusttOtnWP80iL68~l4XqFrQNe-kOTnUSLtMXQwGJNLfWNze6~fMZ15LsH4IYpz85mJKgILgCegi1sEPF6loBJpKYF9AH5HC2Zz1fenM1T3V387dgb4FifujKtR-DJuBpknPNFvZ9wC9ebCfoXhc1HLe7BJUDSr8gJssiqsimQPU-9TanAOJAFTaxOfvQ0-WEW1VIdCWLX0OOjn1qs~jZTeOGcxy3b~OD1CxiUmwp5tA3lBgqya18Mf8kmcfHjByNmYysd2FwV5tS19dCnmzbE9hwvLwMOnQ38SYOKhxCLsteDRBIxLNjTGJFDUm4wF~089Kkd1zA8pn8-kVfYtwg__', summary: 'Just completed the advanced groove lesson! I’m finally feeling more confident with my fills. Thanks for the clear explanations and practice tips! ', date: '2025-03-25 09:49:48' },
15
+ { id:2, title: 'Welcome To Guitareo', action: 'Complete', thumbnail: 'https://s3-alpha-sig.figma.com/img/22c3/1eb9/d819a2c6727b78deb2fcf051349a0667?Expires=1743984000&Key-Pair-Id=APKAQ4GOSFWCW27IBOMQ&Signature=RGusttOtnWP80iL68~l4XqFrQNe-kOTnUSLtMXQwGJNLfWNze6~fMZ15LsH4IYpz85mJKgILgCegi1sEPF6loBJpKYF9AH5HC2Zz1fenM1T3V387dgb4FifujKtR-DJuBpknPNFvZ9wC9ebCfoXhc1HLe7BJUDSr8gJssiqsimQPU-9TanAOJAFTaxOfvQ0-WEW1VIdCWLX0OOjn1qs~jZTeOGcxy3b~OD1CxiUmwp5tA3lBgqya18Mf8kmcfHjByNmYysd2FwV5tS19dCnmzbE9hwvLwMOnQ38SYOKhxCLsteDRBIxLNjTGJFDUm4wF~089Kkd1zA8pn8-kVfYtwg__',date: '2025-03-25 09:34:48' },
16
+ { id:1, title: 'Welcome To Guitareo', action: 'Start', thumbnail: 'https://s3-alpha-sig.figma.com/img/22c3/1eb9/d819a2c6727b78deb2fcf051349a0667?Expires=1743984000&Key-Pair-Id=APKAQ4GOSFWCW27IBOMQ&Signature=RGusttOtnWP80iL68~l4XqFrQNe-kOTnUSLtMXQwGJNLfWNze6~fMZ15LsH4IYpz85mJKgILgCegi1sEPF6loBJpKYF9AH5HC2Zz1fenM1T3V387dgb4FifujKtR-DJuBpknPNFvZ9wC9ebCfoXhc1HLe7BJUDSr8gJssiqsimQPU-9TanAOJAFTaxOfvQ0-WEW1VIdCWLX0OOjn1qs~jZTeOGcxy3b~OD1CxiUmwp5tA3lBgqya18Mf8kmcfHjByNmYysd2FwV5tS19dCnmzbE9hwvLwMOnQ38SYOKhxCLsteDRBIxLNjTGJFDUm4wF~089Kkd1zA8pn8-kVfYtwg__',date: '2025-03-25 09:04:48' },
17
+ ],
18
+ }
19
+
20
+ const DATA_KEY_PRACTICES = 'practices'
21
+ const DATA_KEY_LAST_UPDATED_TIME = 'u'
22
+
23
+ const DAYS = ['M', 'T', 'W', 'T', 'F', 'S', 'S']
24
+
25
+ export let userActivityContext = new DataContext(UserActivityVersionKey, fetchUserPractices)
26
+
27
+ // Get Weekly Stats
28
+ export async function getUserWeeklyStats() {
29
+ let data = await userActivityContext.getData()
30
+
31
+ let practices = data?.[DATA_KEY_PRACTICES] ?? {}
32
+
33
+ let today = new Date()
34
+ let startOfWeek = getMonday(today) // Get last Monday
35
+ let dailyStats = []
36
+ const todayKey = today.toISOString().split('T')[0]
37
+ for (let i = 0; i < 7; i++) {
38
+ let day = new Date(startOfWeek)
39
+ day.setDate(startOfWeek.getDate() + i)
40
+ let dayKey = day.toISOString().split('T')[0]
41
+
42
+ let dayActivity = practices[dayKey] ?? null
43
+ let isActive = dayKey === todayKey
44
+ let type = (dayActivity !== null ? 'tracked' : (isActive ? 'active' : 'none'))
45
+ dailyStats.push({ key: i, label: DAYS[i], isActive, inStreak: dayActivity !== null, type })
46
+ }
47
+
48
+ let { streakMessage } = getStreaksAndMessage(practices);
49
+
50
+ return { data: { dailyActiveStats: dailyStats, streakMessage } }
51
+ }
52
+
53
+ export async function getUserMonthlyStats(year = new Date().getFullYear(), month = new Date().getMonth(), day = 1) {
54
+ let data = await userActivityContext.getData()
55
+ let practices = data?.[DATA_KEY_PRACTICES] ?? {}
56
+ // Get the first day of the specified month and the number of days in that month
57
+ let firstDayOfMonth = new Date(year, month, 1)
58
+ let today = new Date()
59
+
60
+ let startOfMonth = getMonday(firstDayOfMonth)
61
+ let endOfMonth = new Date(year, month + 1, 0)
62
+ while (endOfMonth.getDay() !== 0) {
63
+ endOfMonth.setDate(endOfMonth.getDate() + 1)
64
+ }
65
+
66
+ let daysInMonth = Math.ceil((endOfMonth - startOfMonth) / (1000 * 60 * 60 * 24)) + 1;
67
+
68
+ let dailyStats = []
69
+ let practiceDuration = 0
70
+ let daysPracticed = 0
71
+ let weeklyStats = {}
72
+
73
+ for (let i = 0; i < daysInMonth; i++) {
74
+ let day = new Date(startOfMonth)
75
+ day.setDate(startOfMonth.getDate() + i)
76
+ let dayKey = day.toISOString().split('T')[0]
77
+
78
+ // Check if the user has activity for the day, default to 0 if undefined
79
+ let dayActivity = practices[dayKey] ?? null
80
+ let weekKey = getWeekNumber(day)
81
+
82
+ if (!weeklyStats[weekKey]) {
83
+ weeklyStats[weekKey] = { key: weekKey, inStreak: false };
84
+ }
85
+
86
+ if (dayActivity) {
87
+ practiceDuration += dayActivity.reduce((sum, entry) => sum + entry.duration_seconds, 0)
88
+ daysPracticed++;
89
+ }
90
+ let isActive = dayKey === today.toISOString().split('T')[0]
91
+ let type = (dayActivity !== null ? 'tracked' : (isActive ? 'active' : 'none'))
92
+
93
+ let isInStreak = dayActivity !== null;
94
+ if (isInStreak) {
95
+ weeklyStats[weekKey].inStreak = true;
96
+ }
97
+
98
+ dailyStats.push({
99
+ key: i,
100
+ label: dayKey,
101
+ isActive,
102
+ inStreak: dayActivity !== null,
103
+ type,
104
+ })
105
+ }
106
+
107
+ let filteredPractices = Object.keys(practices)
108
+ .filter((date) => new Date(date) <= endOfMonth)
109
+ .reduce((obj, key) => {
110
+ obj[key] = practices[key]
111
+ return obj
112
+ }, {})
113
+
114
+ let { currentDailyStreak, currentWeeklyStreak } = calculateStreaks(filteredPractices);
115
+
116
+ return { data: {
117
+ dailyActiveStats: dailyStats,
118
+ weeklyActiveStats: Object.values(weeklyStats),
119
+ practiceDuration,
120
+ currentDailyStreak,
121
+ currentWeeklyStreak,
122
+ daysPracticed,
123
+ }
124
+ }
125
+ }
126
+
127
+ export async function getUserPractices() {
128
+ let data = await userActivityContext.getData()
129
+ return data?.[DATA_KEY_PRACTICES] ?? []
130
+ }
131
+
132
+ export async function recordUserPractice(practiceDetails) {
133
+ practiceDetails.auto = 0;
134
+ if (practiceDetails.content_id) {
135
+ practiceDetails.auto = 1;
136
+ }
137
+
138
+ await userActivityContext.update(
139
+ async function (localContext) {
140
+ let userData = localContext.data ?? { [DATA_KEY_PRACTICES]: {} };
141
+ localContext.data = userData;
142
+ },
143
+ async function () {
144
+ const response = await logUserPractice(practiceDetails);
145
+ if (response) {
146
+ await userActivityContext.updateLocal(async function (localContext) {
147
+ const newPractices = response.data ?? []
148
+ newPractices.forEach(newPractice => {
149
+ const { date } = newPractice;
150
+ if (!localContext.data[DATA_KEY_PRACTICES][date]) {
151
+ localContext.data[DATA_KEY_PRACTICES][date] = [];
152
+ }
153
+ localContext.data[DATA_KEY_PRACTICES][date][DATA_KEY_LAST_UPDATED_TIME] = Math.round(new Date().getTime() / 1000)
154
+ localContext.data[DATA_KEY_PRACTICES][date].push({
155
+ id: newPractice.id,
156
+ duration_seconds: newPractice.duration_seconds // Add the new practice for this date
157
+ });
158
+ });
159
+ });
160
+ }
161
+ return response;
162
+ }
163
+ );
164
+ }
165
+
166
+ export async function updateUserPractice(id, practiceDetails) {
167
+ const url = `/api/user/practices/v1/practices/${id}`
168
+ return await fetchHandler(url, 'PUT', null, practiceDetails)
169
+ }
170
+
171
+ export async function removeUserPractice(id) {
172
+ let url = `/api/user/practices/v1/practices${buildQueryString([id])}`;
173
+ await userActivityContext.update(
174
+ async function (localContext) {
175
+ if (localContext.data?.[DATA_KEY_PRACTICES]) {
176
+ Object.keys(localContext.data[DATA_KEY_PRACTICES]).forEach(date => {
177
+ localContext.data[DATA_KEY_PRACTICES][date] = localContext.data[DATA_KEY_PRACTICES][date].filter(
178
+ practice => practice.id !== id
179
+ );
180
+ });
181
+ }
11
182
  },
12
- dailyActiveStats: [
13
- { label: 'M', isActive: false, inStreak: false, type: 'none' },
14
- { label: 'T', isActive: false, inStreak: false, type: 'none' },
15
- { label: 'W', isActive: true, inStreak: true, type: 'tracked' },
16
- { label: 'T', isActive: true, inStreak: true, type: 'tracked' },
17
- { label: 'F', isActive: false, inStreak: false, type: 'none' },
18
- { label: 'S', isActive: true, inStreak: false, type: 'active' },
19
- { label: 'S', isActive: false, inStreak: false, type: 'none' }
20
- ],
21
- currentDailyStreak: 3,
22
- currentWeeklyStreak: 2,
23
- streakMessage: "That's 8 weeks in a row! Way to keep your streak going.",
24
- };
25
-
26
- export async function getUserActivityStats(brand) {
27
- return userActivityStats;
28
- //return await fetchHandler(`/api/user-activity/v1/stats`);
183
+ async function () {
184
+ return await fetchHandler(url, 'delete');
185
+ }
186
+ );
187
+ }
188
+
189
+ export async function restoreUserPractice(id) {
190
+ let url = `/api/user/practices/v1/practices/restore${buildQueryString([id])}`;
191
+ const response = await fetchHandler(url, 'put');
192
+ if (response?.data) {
193
+ await userActivityContext.updateLocal(async function (localContext) {
194
+ const restoredPractice = response.data;
195
+ const { date } = restoredPractice;
196
+ if (!localContext.data[DATA_KEY_PRACTICES][date]) {
197
+ localContext.data[DATA_KEY_PRACTICES][date] = [];
198
+ }
199
+ localContext.data[DATA_KEY_PRACTICES][date].push({
200
+ id: restoredPractice.id,
201
+ duration_seconds: restoredPractice.duration_seconds,
202
+ });
203
+ });
204
+ }
205
+ return response;
29
206
  }
30
207
 
208
+ export async function deletePracticeSession(day) {
209
+ const userPracticesIds = await getUserPracticeIds(day);
210
+ if (!userPracticesIds.length) return [];
211
+
212
+ const url = `/api/user/practices/v1/practices${buildQueryString(userPracticesIds)}`;
213
+ return await fetchHandler(url, 'delete', null);
214
+ }
215
+
216
+ export async function restorePracticeSession(date) {
217
+ const url = `/api/user/practices/v1/practices/restore?date=${date}`;
218
+ return await fetchHandler(url, 'put', null);
219
+ }
220
+
221
+ export async function getPracticeSessions(day) {
222
+ const userPracticesIds = await getUserPracticeIds(day);
223
+ if (!userPracticesIds.length) return { data: { practices: [], practiceDuration: 0 } };
224
+
225
+ const meta = await fetchUserPracticeMeta(userPracticesIds);
226
+ if (!meta.data.length) return { data: { practices: [], practiceDuration: 0 } };
227
+ const practiceDuration = meta.data.reduce((total, practice) => total + (practice.duration_seconds || 0), 0);
228
+ const contentIds = meta.data.map(practice => practice.content_id).filter(id => id !== null);
229
+
230
+ const contents = await fetchByRailContentIds(contentIds);
231
+ const getFormattedType = (type) => {
232
+ for (const [key, values] of Object.entries(lessonTypesMapping)) {
233
+ if (values.includes(type)) {
234
+ return key.replace(/\b\w/g, char => char.toUpperCase());
235
+ }
236
+ }
237
+ return null;
238
+ };
239
+
240
+ const formattedMeta = meta.data.map(practice => {
241
+ const content = contents.find(c => c.id === practice.content_id) || {};
242
+ return {
243
+ id: practice.id,
244
+ auto: practice.auto,
245
+ thumbnail: (practice.content_id)? content.thumbnail : '',
246
+ duration: practice.duration_seconds || 0,
247
+ content_url: content.url || null,
248
+ title: (practice.content_id)? content.title : practice.title,
249
+ category_id: practice.category_id,
250
+ instrument_id: practice.instrument_id ,
251
+ content_type: getFormattedType(content.type || ''),
252
+ content_id: practice.content_id || null,
253
+ content_brand: content.brand || null,
254
+ };
255
+ });
256
+ return { data: { practices: formattedMeta, practiceDuration } };
257
+ }
258
+
259
+
260
+ export async function getRecentActivity() {
261
+ return { data: recentActivity };
262
+ }
263
+
264
+ function getStreaksAndMessage(practices)
265
+ {
266
+ let { currentDailyStreak, currentWeeklyStreak } = calculateStreaks(practices)
267
+
268
+ let streakMessage = currentWeeklyStreak > 1
269
+ ? `That's ${currentWeeklyStreak} weeks in a row! Keep going!`
270
+ : `Nice! You have a ${currentDailyStreak} day streak! Way to keep it going!`
271
+ return {
272
+ currentDailyStreak,
273
+ currentWeeklyStreak,
274
+ streakMessage,
275
+ }
276
+ }
277
+
278
+ async function getUserPracticeIds(day = new Date().toISOString().split('T')[0]) {
279
+ let data = await userActivityContext.getData();
280
+ let practices = data?.[DATA_KEY_PRACTICES] ?? {};
281
+ let userPracticesIds = [];
282
+ Object.keys(practices).forEach(date => {
283
+ if (date === day) {
284
+ practices[date].forEach(practice => userPracticesIds.push(practice.id));
285
+ }
286
+ });
287
+
288
+ return userPracticesIds;
289
+ }
290
+
291
+ function buildQueryString(ids, paramName = 'practice_ids') {
292
+ if (!ids.length) return '';
293
+ return '?' + ids.map(id => `${paramName}[]=${id}`).join('&');
294
+ }
295
+
296
+
297
+ // Helper: Get start of the week (Monday)
298
+ function getMonday(d) {
299
+ d = new Date(d)
300
+ var day = d.getDay(),
301
+ diff = d.getDate() - day + (day == 0 ? -6 : 1) // adjust when day is sunday
302
+ return new Date(d.setDate(diff))
303
+ }
304
+
305
+ // Helper: Get the week number
306
+ function getWeekNumber(date) {
307
+ let startOfYear = new Date(date.getFullYear(), 0, 1)
308
+ let diff = date - startOfYear
309
+ let oneWeekMs = 7 * 24 * 60 * 60 * 1000
310
+ return Math.ceil((diff / oneWeekMs) + startOfYear.getDay() / 7)
311
+ }
312
+
313
+ // Helper: function to check if two dates are consecutive days
314
+ function isNextDay(prevDateStr, currentDateStr) {
315
+ let prevDate = new Date(prevDateStr)
316
+ let currentDate = new Date(currentDateStr)
317
+ let diff = (currentDate - prevDate) / (1000 * 60 * 60 * 24)
318
+ return diff === 1
319
+ }
320
+
321
+ // Helper: Calculate streaks
322
+ function calculateStreaks(practices) {
323
+ let currentDailyStreak = 0
324
+ let currentWeeklyStreak = 0
325
+
326
+ let sortedPracticeDays = Object.keys(practices).sort() // Ensure dates are sorted in order
327
+
328
+ if (sortedPracticeDays.length === 0) {
329
+ return { currentDailyStreak: 1, currentWeeklyStreak: 1 }
330
+ }
331
+
332
+ let dailyStreak = 0
333
+ let longestDailyStreak = 0
334
+ let prevDay = null
335
+
336
+ sortedPracticeDays.forEach((dayKey) => {
337
+ if (prevDay === null || isNextDay(prevDay, dayKey)) {
338
+ dailyStreak++
339
+ longestDailyStreak = Math.max(longestDailyStreak, dailyStreak)
340
+ } else {
341
+ dailyStreak = 1 // Reset streak if there's a gap
342
+ }
343
+ prevDay = dayKey
344
+ })
345
+
346
+ currentDailyStreak = dailyStreak
347
+
348
+ // Calculate weekly streaks
349
+ let weeklyStreak = 0
350
+ let prevWeek = null
351
+ let currentWeekActivity = false
352
+
353
+ sortedPracticeDays.forEach((dayKey) => {
354
+ let date = new Date(dayKey)
355
+ let weekNumber = getWeekNumber(date)
356
+
357
+ if (prevWeek === null) {
358
+ prevWeek = weekNumber
359
+ currentWeekActivity = true
360
+ } else if (weekNumber !== prevWeek) {
361
+ // A new week has started
362
+ if (currentWeekActivity) {
363
+ weeklyStreak++
364
+ currentWeekActivity = false
365
+ }
366
+ prevWeek = weekNumber
367
+ }
368
+
369
+ if (practices[dayKey]) {
370
+ currentWeekActivity = true
371
+ }
372
+ })
373
+
374
+ // If the user has activity in the current week, count it
375
+ if (currentWeekActivity) {
376
+ weeklyStreak++
377
+ }
378
+
379
+ currentWeeklyStreak = weeklyStreak
380
+
381
+ return { currentDailyStreak, currentWeeklyStreak }
382
+ }
383
+
384
+
385
+
31
386
 
32
387
 
File without changes
@@ -5,6 +5,7 @@ import {
5
5
  unlikeContent,
6
6
  } from '../src/services/contentLikes'
7
7
  import { initializeTestService } from './initializeTests'
8
+ import { userActivityContext } from '../src/services/userActivity.js'
8
9
 
9
10
  const railContentModule = require('../src/services/railcontent.js')
10
11
 
@@ -17,6 +18,7 @@ describe('contentLikesDataContext', function () {
17
18
  mock = jest.spyOn(dataContext, 'fetchData')
18
19
  var json = JSON.parse(`{"version":${testVersion},"data":[308516,308515,308514,308518]}`)
19
20
  mock.mockImplementation(() => json)
21
+ dataContext.ensureLocalContextLoaded()
20
22
  })
21
23
 
22
24
  test('contentLiked', async () => {
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
package/test/log.js CHANGED
File without changes
@@ -0,0 +1,35 @@
1
+ [
2
+ {
3
+ "permission_id": [
4
+ 78,
5
+ 89,
6
+ 91,
7
+ 92,
8
+ 88,
9
+ 90
10
+ ],
11
+ "thumbnail": "https://cdn.sanity.io/images/4032r8py/staging/5f15b20b428c06263fd39599fc310ab00eb05fee-1920x1080.jpg",
12
+ "difficulty_string": "Beginner",
13
+ "url": "/drumeo/quick-tips/how-to-play-drums/415183",
14
+ "lesson_count": null,
15
+ "id": 415183,
16
+ "image": "https://cdn.sanity.io/images/4032r8py/staging/5f15b20b428c06263fd39599fc310ab00eb05fee-1920x1080.jpg",
17
+ "web_url_path": "/drumeo/quick-tips/how-to-play-drums/415183",
18
+ "type": "quick-tips",
19
+ "brand": "drumeo",
20
+ "genre": null,
21
+ "status": "published",
22
+ "xp": 100,
23
+ "railcontent_id": 415183,
24
+ "artist": null,
25
+ "progress_percent": null,
26
+ "child_count": null,
27
+ "sanity_id": "quick-tips_415183",
28
+ "artist_name": "Brandon Toews",
29
+ "title": "How To Play Drums",
30
+ "difficulty": 3,
31
+ "published_on": "2024-12-13T12:00:00.000000Z",
32
+ "length_in_seconds": 576,
33
+ "slug": "how-to-play-drums"
34
+ }
35
+ ]
File without changes
File without changes
@@ -0,0 +1,118 @@
1
+ import { initializeTestService } from './initializeTests.js'
2
+ import {getUserMonthlyStats, getUserWeeklyStats, userActivityContext, recordUserPractice, getUserPractices} from '../src/services/userActivity.js'
3
+ import { logUserPractice } from '../src/services/railcontent.js'
4
+ import {fetchByRailContentIds} from "../src";
5
+ import mockData_fetchByRailContentIds_one_content from './mockData/mockData_fetchByRailContentIds_one_content.json';
6
+
7
+ global.fetch = jest.fn()
8
+ let mock = null
9
+ const testVersion = 1
10
+ const DEBUG = true
11
+
12
+ jest.mock('../src/services/railcontent', () => ({
13
+ ...jest.requireActual('../src/services/railcontent'),
14
+ logUserPractice: jest.fn(() => Promise.resolve()),
15
+ fetchUserPermissionsData: jest.fn(() => ({ permissions: [78, 91, 92], isAdmin: false }))
16
+ }))
17
+
18
+ jest.mock('../src/services/sanity', () => ({
19
+ ...jest.requireActual('../src/services/sanity'),
20
+ fetchByRailContentIds: jest.fn(() => Promise.resolve(mockData_fetchByRailContentIds_one_content)),
21
+ }))
22
+ describe('User Activity API Tests', function () {
23
+ beforeEach(() => {
24
+ initializeTestService()
25
+ mock = jest.spyOn(userActivityContext, 'fetchData')
26
+ var json = JSON.parse(
27
+ `{
28
+ "version": ${testVersion},
29
+ "config": { "key": 1, "enabled": 1, "checkInterval": 1, "refreshInterval": 2 },
30
+ "data": {
31
+ "practices": {
32
+ "2025-02-10": [{ "duration_seconds": 190 }],
33
+ "2025-02-11": [{ "duration_seconds": 340 }],
34
+ "2025-02-19": [{ "duration_seconds": 340 }],
35
+ "2025-03-01": [{ "duration_seconds": 360 }],
36
+ "2025-03-03": [{ "duration_seconds": 360 }],
37
+ "2025-03-05": [{ "duration_seconds": 100 }],
38
+ "2025-03-11": [{ "duration_seconds": 190 }],
39
+ "2025-03-14": [{ "duration_seconds": 456 }],
40
+ "2025-03-15": [{ "duration_seconds": 124 }],
41
+ "2025-03-16": [{ "duration_seconds": 452 }, { "duration_seconds": 456 }],
42
+ "2025-03-17": [{ "duration_seconds": 122 }]
43
+ }
44
+ }
45
+ }`
46
+ )
47
+ mock.mockImplementation(() => json)
48
+ userActivityContext.ensureLocalContextLoaded()
49
+ })
50
+
51
+ test('fetches user practices successfully', async () => {
52
+ userActivityContext.clearCache()
53
+ const practices = await getUserMonthlyStats()
54
+ consoleLog(practices)
55
+ // Assert that dailyActiveStats contains correct data
56
+ const dailyStats = practices.dailyActiveStats
57
+ const currentDate = new Date()
58
+ const currentDateString = currentDate.toISOString().split('T')[0]
59
+ expect(dailyStats).toHaveLength(42)
60
+
61
+ // Verify current day's stats (e.g., March 17, 2025)
62
+ const current = dailyStats.find(stat => stat.label === currentDateString)
63
+ expect(current).toBeTruthy()
64
+ expect(current.isActive).toBe(true)
65
+ expect(current.type).toBe('active')
66
+ expect(current.inStreak).toBe(false)
67
+
68
+ // Ensure that mock was called as expected
69
+ expect(mock).toHaveBeenCalledTimes(1)
70
+ })
71
+
72
+ test('fetches user practices from past', async () => {
73
+ userActivityContext.clearCache()
74
+ const practices = await getUserMonthlyStats( 2025, 1)
75
+ consoleLog(practices)
76
+
77
+ // Assert that dailyActiveStats contains correct data
78
+ const dailyStats = practices.dailyActiveStats
79
+ const feb10 = dailyStats.find(stat => stat.label === '2025-02-10')
80
+ expect(feb10.inStreak).toBe(true)
81
+ expect(feb10.type).toBe('tracked')
82
+ expect(feb10.isActive).toBe(false)
83
+ })
84
+
85
+ test('fetches user practices for current week', async () => {
86
+ userActivityContext.clearCache()
87
+ const practices = await getUserWeeklyStats( )
88
+ consoleLog(practices)
89
+
90
+ const dailyStats = practices.dailyActiveStats
91
+ const monday = dailyStats.find(stat => stat.label === 'M')
92
+ expect(monday).toBeDefined
93
+ const tuesday = dailyStats.find(stat => stat.label === 'T')
94
+ expect(tuesday).toBeDefined
95
+ })
96
+
97
+ test('should add a new practice entry and call logUserPractice', async () => {
98
+ userActivityContext.clearCache()
99
+ const mockPractice = {
100
+ duration_seconds: 300,
101
+ content_id: 415183
102
+ }
103
+
104
+ jest.spyOn(userActivityContext, 'update').mockImplementation(async (callback) => {
105
+ await callback(userActivityContext)
106
+ })
107
+
108
+ await recordUserPractice(mockPractice)
109
+
110
+ expect(userActivityContext.update).toHaveBeenCalledTimes(1)
111
+ })
112
+
113
+ function consoleLog(message, object=null, debug=false) {
114
+ if (debug || DEBUG) {
115
+ console.log(message, object);
116
+ }
117
+ }
118
+ })
File without changes