musora-content-services 2.3.5 → 2.3.7

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 (131) hide show
  1. package/.coderabbit.yaml +22 -0
  2. package/.editorconfig +0 -0
  3. package/.github/workflows/node.js.yml +0 -0
  4. package/.prettierignore +0 -0
  5. package/.prettierrc +0 -0
  6. package/.yarnrc.yml +1 -0
  7. package/CHANGELOG.md +4 -0
  8. package/README.md +0 -0
  9. package/babel.config.cjs +0 -0
  10. package/docs/Content-Organization.html +0 -0
  11. package/docs/ContentOrganization.html +0 -0
  12. package/docs/Gamification.html +0 -0
  13. package/docs/UserManagement.html +0 -0
  14. package/docs/UserManagementSystem.html +0 -0
  15. package/docs/api_types.js.html +0 -0
  16. package/docs/config.js.html +0 -0
  17. package/docs/content-org_content-org.js.html +0 -0
  18. package/docs/content-org_playlists-types.js.html +0 -0
  19. package/docs/content-org_playlists.js.html +0 -0
  20. package/docs/content.js.html +0 -0
  21. package/docs/fonts/Montserrat/Montserrat-Bold.eot +0 -0
  22. package/docs/fonts/Montserrat/Montserrat-Bold.ttf +0 -0
  23. package/docs/fonts/Montserrat/Montserrat-Bold.woff +0 -0
  24. package/docs/fonts/Montserrat/Montserrat-Bold.woff2 +0 -0
  25. package/docs/fonts/Montserrat/Montserrat-Regular.eot +0 -0
  26. package/docs/fonts/Montserrat/Montserrat-Regular.ttf +0 -0
  27. package/docs/fonts/Montserrat/Montserrat-Regular.woff +0 -0
  28. package/docs/fonts/Montserrat/Montserrat-Regular.woff2 +0 -0
  29. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.eot +0 -0
  30. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.svg +0 -0
  31. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.ttf +0 -0
  32. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff +0 -0
  33. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff2 +0 -0
  34. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.eot +0 -0
  35. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.svg +0 -0
  36. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.ttf +0 -0
  37. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff +0 -0
  38. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff2 +0 -0
  39. package/docs/gamification_awards.js.html +0 -0
  40. package/docs/gamification_gamification.js.html +0 -0
  41. package/docs/gamification_types.js.html +0 -0
  42. package/docs/global.html +0 -0
  43. package/docs/global.html#User +0 -0
  44. package/docs/index.html +0 -0
  45. package/docs/module-Awards.html +0 -0
  46. package/docs/module-Config.html +0 -0
  47. package/docs/module-Content-Services-V2.html +0 -0
  48. package/docs/module-Content-Services.html +763 -0
  49. package/docs/module-Permissions.html +0 -0
  50. package/docs/module-Playlists.html +0 -0
  51. package/docs/module-Railcontent-Services.html +0 -0
  52. package/docs/module-Sanity-Services.html +0 -0
  53. package/docs/module-Session-Management.html +0 -0
  54. package/docs/module-Sessions.html +0 -0
  55. package/docs/module-User-Management.html +0 -0
  56. package/docs/module-User-Permissions.html +0 -0
  57. package/docs/module-UserManagement.html +0 -0
  58. package/docs/railcontent.js.html +0 -0
  59. package/docs/sanity.js.html +0 -0
  60. package/docs/scripts/collapse.js +0 -0
  61. package/docs/scripts/commonNav.js +0 -0
  62. package/docs/scripts/linenumber.js +0 -0
  63. package/docs/scripts/nav.js +0 -0
  64. package/docs/scripts/polyfill.js +0 -0
  65. package/docs/scripts/prettify/Apache-License-2.0.txt +0 -0
  66. package/docs/scripts/prettify/lang-css.js +0 -0
  67. package/docs/scripts/prettify/prettify.js +0 -0
  68. package/docs/scripts/search.js +0 -0
  69. package/docs/styles/jsdoc.css +0 -0
  70. package/docs/styles/prettify.css +0 -0
  71. package/docs/types.js.html +0 -0
  72. package/docs/user_management.js.html +0 -0
  73. package/docs/user_permissions.js.html +0 -0
  74. package/docs/user_sessions.js.html +0 -0
  75. package/docs/user_types.js.html +0 -0
  76. package/docs/user_user-management-system.js.html +0 -0
  77. package/docs/user_user-management.js.html +0 -0
  78. package/jest.config.js +0 -0
  79. package/jsdoc.json +0 -0
  80. package/package.json +1 -1
  81. package/src/contentMetaData.js +0 -0
  82. package/src/filterBuilder.js +0 -0
  83. package/src/index.d.ts +0 -0
  84. package/src/index.js +0 -0
  85. package/src/lib/httpHelper.js +0 -0
  86. package/src/lib/lastUpdated.js +0 -0
  87. package/src/services/api/types.js +0 -0
  88. package/src/services/config.js +0 -0
  89. package/src/services/content-org/content-org.js +0 -0
  90. package/src/services/content-org/playlists-types.js +0 -0
  91. package/src/services/content-org/playlists.js +0 -0
  92. package/src/services/content.js +0 -0
  93. package/src/services/contentLikes.js +0 -0
  94. package/src/services/contentProgress.js +0 -0
  95. package/src/services/dataContext.js +0 -0
  96. package/src/services/forum.js +0 -0
  97. package/src/services/gamification/awards.js +0 -0
  98. package/src/services/gamification/gamification.js +0 -0
  99. package/src/services/gamification/types.js +0 -0
  100. package/src/services/imageSRCBuilder.js +0 -0
  101. package/src/services/imageSRCVerify.js +0 -0
  102. package/src/services/railcontent.js +16 -3
  103. package/src/services/recommendations.js +0 -0
  104. package/src/services/sanity.js +1 -0
  105. package/src/services/types.js +0 -0
  106. package/src/services/user/management.js +0 -0
  107. package/src/services/user/permissions.js +0 -0
  108. package/src/services/user/sessions.js +0 -0
  109. package/src/services/user/types.js +0 -0
  110. package/src/services/user/user-management-system.js +0 -0
  111. package/src/services/userActivity.js +301 -90
  112. package/test/content.test.js +0 -0
  113. package/test/contentLikes.test.js +0 -0
  114. package/test/contentProgress.test.js +0 -0
  115. package/test/dataContext.test.js +0 -0
  116. package/test/forum.test.js +0 -0
  117. package/test/imageSRCBuilder.test.js +0 -0
  118. package/test/imageSRCVerify.test.js +0 -0
  119. package/test/initializeTests.js +0 -0
  120. package/test/lib/lastUpdated.test.js +0 -0
  121. package/test/live/contentProgressLive.test.js +0 -0
  122. package/test/live/railcontentLive.test.js +0 -0
  123. package/test/localStorageMock.js +0 -0
  124. package/test/log.js +0 -0
  125. package/test/mockData/mockData_fetchByRailContentIds_one_content.json +0 -0
  126. package/test/mockData/mockData_user_practices.json +9 -0
  127. package/test/sanityQueryService.test.js +11 -1
  128. package/test/streakMessage.test.js +263 -0
  129. package/test/user/permissions.test.js +0 -0
  130. package/test/userActivity.test.js +6 -6
  131. package/tools/generate-index.cjs +0 -0
@@ -20,48 +20,109 @@ const DATA_KEY_LAST_UPDATED_TIME = 'u'
20
20
 
21
21
  const DAYS = ['M', 'T', 'W', 'T', 'F', 'S', 'S']
22
22
 
23
+ const streakMessages = {
24
+ startStreak: "Start your streak by taking any lesson!",
25
+ restartStreak: "Restart your streak by taking any lesson!",
26
+
27
+ // Messages when last active day is today
28
+ dailyStreak: (streak) => `Nice! You have ${getIndefiniteArticle(streak)} ${streak} day streak! Way to keep it going!`,
29
+ dailyStreakShort: (streak) => `Nice! You have ${getIndefiniteArticle(streak)} ${streak} day streak!`,
30
+ weeklyStreak: (streak) => `You have ${getIndefiniteArticle(streak)} ${streak} week streak! Way to keep up the momentum!`,
31
+ greatJobWeeklyStreak: (streak) => `Great job! You have ${getIndefiniteArticle(streak)} ${streak} week streak! Way to keep it going!`,
32
+
33
+ // Messages when last active day is NOT today
34
+ dailyStreakReminder: (streak) => `You have ${getIndefiniteArticle(streak)} ${streak} day streak! Keep it going with any lesson or song!`,
35
+ weeklyStreakKeepUp: (streak) => `You have ${getIndefiniteArticle(streak)} ${streak} week streak! Keep up the momentum!`,
36
+ weeklyStreakReminder: (streak) => `You have ${getIndefiniteArticle(streak)} ${streak} week streak! Keep it going with any lesson or song!`,
37
+ };
38
+
39
+ function getIndefiniteArticle(streak) {
40
+ return streak === 8 || (streak >= 80 && streak <= 89) || (streak >= 800 && streak <= 899) ? 'an' : 'a'
41
+ }
42
+
43
+
23
44
  export let userActivityContext = new DataContext(UserActivityVersionKey, fetchUserPractices)
24
45
 
25
- // Get Weekly Stats
46
+ /**
47
+ * Retrieves user activity statistics for the current week, including daily activity and streak messages.
48
+ *
49
+ * @returns {Promise<Object>} - A promise that resolves to an object containing weekly user activity statistics.
50
+ *
51
+ * @example
52
+ * // Retrieve user activity statistics for the current week
53
+ * getUserWeeklyStats()
54
+ * .then(stats => console.log(stats))
55
+ * .catch(error => console.error(error));
56
+ */
26
57
  export async function getUserWeeklyStats() {
27
58
  let data = await userActivityContext.getData()
28
-
29
59
  let practices = data?.[DATA_KEY_PRACTICES] ?? {}
60
+ let sortedPracticeDays = Object.keys(practices)
61
+ .map(date => new Date(date))
62
+ .sort((a, b) => b - a);
30
63
 
31
- let today = new Date()
64
+ let today = new Date();
65
+ today.setHours(0, 0, 0, 0);
32
66
  let startOfWeek = getMonday(today) // Get last Monday
33
67
  let dailyStats = []
34
- const todayKey = today.toISOString().split('T')[0]
68
+
35
69
  for (let i = 0; i < 7; i++) {
36
70
  let day = new Date(startOfWeek)
37
71
  day.setDate(startOfWeek.getDate() + i)
38
- let dayKey = day.toISOString().split('T')[0]
39
-
40
- let dayActivity = practices[dayKey] ?? null
41
- let isActive = dayKey === todayKey
42
- let type = (dayActivity !== null ? 'tracked' : (isActive ? 'active' : 'none'))
43
- dailyStats.push({ key: i, label: DAYS[i], isActive, inStreak: dayActivity !== null, type })
72
+ let hasPractice = sortedPracticeDays.some(practiceDate => isSameDate(practiceDate, day));
73
+ let isActive = isSameDate(today, day)
74
+ let type = (hasPractice ? 'tracked' : (isActive ? 'active' : 'none'))
75
+ dailyStats.push({ key: i, label: DAYS[i], isActive, inStreak: hasPractice, type })
44
76
  }
45
77
 
46
78
  let { streakMessage } = getStreaksAndMessage(practices);
47
79
 
48
- return { data: { dailyActiveStats: dailyStats, streakMessage } }
80
+ return { data: { dailyActiveStats: dailyStats, streakMessage, practices } }
49
81
  }
50
82
 
83
+ /**
84
+ * Retrieves user activity statistics for a specified month, including daily and weekly activity data.
85
+ *
86
+ * @param {number} [year=new Date().getFullYear()] - The year for which to retrieve the statistics.
87
+ * @param {number} [month=new Date().getMonth()] - The month (0-indexed) for which to retrieve the statistics.
88
+ * @returns {Promise<Object>} - A promise that resolves to an object containing user activity statistics.
89
+ *
90
+ * @example
91
+ * // Retrieve user activity statistics for the current month
92
+ * getUserMonthlyStats()
93
+ * .then(stats => console.log(stats))
94
+ * .catch(error => console.error(error));
95
+ *
96
+ * @example
97
+ * // Retrieve user activity statistics for March 2024
98
+ * getUserMonthlyStats(2024, 2)
99
+ * .then(stats => console.log(stats))
100
+ * .catch(error => console.error(error));
101
+ */
51
102
  export async function getUserMonthlyStats(year = new Date().getFullYear(), month = new Date().getMonth(), day = 1) {
52
103
  let data = await userActivityContext.getData()
53
104
  let practices = data?.[DATA_KEY_PRACTICES] ?? {}
105
+ let sortedPracticeDays = Object.keys(practices)
106
+ .map(dateStr => {
107
+ const [y, m, d] = dateStr.split('-').map(Number);
108
+ const newDate = new Date();
109
+ newDate.setFullYear(y, m - 1, d);
110
+ return newDate;
111
+ })
112
+ .sort((a, b) => a - b);
113
+
54
114
  // Get the first day of the specified month and the number of days in that month
55
115
  let firstDayOfMonth = new Date(year, month, 1)
56
116
  let today = new Date()
117
+ today.setHours(0, 0, 0, 0);
57
118
 
58
- let startOfMonth = getMonday(firstDayOfMonth)
119
+ let startOfGrid = getMonday(firstDayOfMonth)
59
120
  let endOfMonth = new Date(year, month + 1, 0)
60
121
  while (endOfMonth.getDay() !== 0) {
61
122
  endOfMonth.setDate(endOfMonth.getDate() + 1)
62
123
  }
63
124
 
64
- let daysInMonth = Math.ceil((endOfMonth - startOfMonth) / (1000 * 60 * 60 * 24)) + 1;
125
+ let daysInMonth = Math.ceil((endOfMonth - startOfGrid) / (1000 * 60 * 60 * 24)) + 1;
65
126
 
66
127
  let dailyStats = []
67
128
  let practiceDuration = 0
@@ -69,11 +130,11 @@ export async function getUserMonthlyStats(year = new Date().getFullYear(), month
69
130
  let weeklyStats = {}
70
131
 
71
132
  for (let i = 0; i < daysInMonth; i++) {
72
- let day = new Date(startOfMonth)
73
- day.setDate(startOfMonth.getDate() + i)
74
- let dayKey = day.toISOString().split('T')[0]
133
+ let day = new Date(startOfGrid)
134
+ day.setDate(startOfGrid.getDate() + i)
135
+ let dayKey = `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`;
75
136
 
76
- // Check if the user has activity for the day, default to 0 if undefined
137
+ // Check if the user has activity for the day
77
138
  let dayActivity = practices[dayKey] ?? null
78
139
  let weekKey = getWeekNumber(day)
79
140
 
@@ -81,13 +142,13 @@ export async function getUserMonthlyStats(year = new Date().getFullYear(), month
81
142
  weeklyStats[weekKey] = { key: weekKey, inStreak: false };
82
143
  }
83
144
 
84
- if (dayActivity) {
145
+ if (dayActivity !== null) {
85
146
  practiceDuration += dayActivity.reduce((sum, entry) => sum + entry.duration_seconds, 0)
86
147
  daysPracticed++;
87
148
  }
88
- let isActive = dayKey === today.toISOString().split('T')[0]
89
- let type = (dayActivity !== null ? 'tracked' : (isActive ? 'active' : 'none'))
90
149
 
150
+ let isActive = isSameDate(today, day)
151
+ let type = ((dayActivity !== null) ? 'tracked' : (isActive ? 'active' : 'none'))
91
152
  let isInStreak = dayActivity !== null;
92
153
  if (isInStreak) {
93
154
  weeklyStats[weekKey].inStreak = true;
@@ -126,7 +187,36 @@ export async function getUserPractices() {
126
187
  let data = await userActivityContext.getData()
127
188
  return data?.[DATA_KEY_PRACTICES] ?? []
128
189
  }
129
-
190
+ /**
191
+ * Records user practice data and updates both the remote and local activity context.
192
+ *
193
+ * @param {Object} practiceDetails - The details of the practice session.
194
+ * @param {number} practiceDetails.duration_seconds - The duration of the practice session in seconds.
195
+ * @param {boolean} [practiceDetails.auto=true] - Whether the session was automatically logged.
196
+ * @param {number} [practiceDetails.content_id] - The ID of the practiced content (if available).
197
+ * @param {number} [practiceDetails.category_id] - The ID of the associated category (if available).
198
+ * @param {string} [practiceDetails.title] - The title of the practice session (max 64 characters).
199
+ * @param {string} [practiceDetails.thumbnail_url] - The URL of the session's thumbnail (max 255 characters).
200
+ * @returns {Promise<Object>} - A promise that resolves to the response from logging the user practice.
201
+ *
202
+ * @example
203
+ * // Record an auto practice session with content ID
204
+ * recordUserPractice({ content_id: 123, duration_seconds: 300 })
205
+ * .then(response => console.log(response))
206
+ * .catch(error => console.error(error));
207
+ *
208
+ * @example
209
+ * // Record a custom practice session with additional details
210
+ * recordUserPractice({
211
+ * duration_seconds: 600,
212
+ * auto: false,
213
+ * category_id: 5,
214
+ * title: "Guitar Warm-up",
215
+ * thumbnail_url: "https://example.com/thumbnail.jpg"
216
+ * })
217
+ * .then(response => console.log(response))
218
+ * .catch(error => console.error(error));
219
+ */
130
220
  export async function recordUserPractice(practiceDetails) {
131
221
  practiceDetails.auto = 0;
132
222
  if (practiceDetails.content_id) {
@@ -160,12 +250,46 @@ export async function recordUserPractice(practiceDetails) {
160
250
  }
161
251
  );
162
252
  }
163
-
253
+ /**
254
+ * Updates a user's practice session with new details and syncs the changes remotely.
255
+ *
256
+ * @param {number} id - The unique identifier of the practice session to update.
257
+ * @param {Object} practiceDetails - The updated details of the practice session.
258
+ * @param {number} [practiceDetails.duration_seconds] - The duration of the practice session in seconds.
259
+ * @param {number} [practiceDetails.category_id] - The ID of the associated category (if available).
260
+ * @param {string} [practiceDetails.title] - The title of the practice session (max 64 characters).
261
+ * @param {string} [practiceDetails.thumbnail_url] - The URL of the session's thumbnail (max 255 characters).
262
+ * @returns {Promise<Object>} - A promise that resolves to the response from updating the user practice.
263
+ *
264
+ * @example
265
+ * // Update a practice session's duration
266
+ * updateUserPractice(123, { duration_seconds: 600 })
267
+ * .then(response => console.log(response))
268
+ * .catch(error => console.error(error));
269
+ *
270
+ * @example
271
+ * // Change a practice session to manual and update its category
272
+ * updateUserPractice(456, { auto: false, category_id: 8 })
273
+ * .then(response => console.log(response))
274
+ * .catch(error => console.error(error));
275
+ */
164
276
  export async function updateUserPractice(id, practiceDetails) {
165
277
  const url = `/api/user/practices/v1/practices/${id}`
166
278
  return await fetchHandler(url, 'PUT', null, practiceDetails)
167
279
  }
168
280
 
281
+ /**
282
+ * Removes a user's practice session by ID, updating both the local and remote activity context.
283
+ *
284
+ * @param {number} id - The unique identifier of the practice session to be removed.
285
+ * @returns {Promise<void>} - A promise that resolves once the practice session is removed.
286
+ *
287
+ * @example
288
+ * // Remove a practice session with ID 123
289
+ * removeUserPractice(123)
290
+ * .then(() => console.log("Practice session removed successfully"))
291
+ * .catch(error => console.error(error));
292
+ */
169
293
  export async function removeUserPractice(id) {
170
294
  let url = `/api/user/practices/v1/practices${buildQueryString([id])}`;
171
295
  await userActivityContext.update(
@@ -184,6 +308,18 @@ export async function removeUserPractice(id) {
184
308
  );
185
309
  }
186
310
 
311
+ /**
312
+ * Restores a previously deleted user's practice session by ID, updating both the local and remote activity context.
313
+ *
314
+ * @param {number} id - The unique identifier of the practice session to be restored.
315
+ * @returns {Promise<Object>} - A promise that resolves to the response containing the restored practice session data.
316
+ *
317
+ * @example
318
+ * // Restore a deleted practice session with ID 123
319
+ * restoreUserPractice(123)
320
+ * .then(response => console.log("Practice session restored:", response))
321
+ * .catch(error => console.error(error));
322
+ */
187
323
  export async function restoreUserPractice(id) {
188
324
  let url = `/api/user/practices/v1/practices/restore${buildQueryString([id])}`;
189
325
  const response = await fetchHandler(url, 'put');
@@ -208,14 +344,51 @@ export async function deletePracticeSession(day) {
208
344
  if (!userPracticesIds.length) return [];
209
345
 
210
346
  const url = `/api/user/practices/v1/practices${buildQueryString(userPracticesIds)}`;
211
- return await fetchHandler(url, 'delete', null);
347
+ await userActivityContext.update(async function (localContext) {
348
+ if (localContext.data?.[DATA_KEY_PRACTICES]?.[day]) {
349
+ localContext.data[DATA_KEY_PRACTICES][day] = localContext.data[DATA_KEY_PRACTICES][day].filter(
350
+ practice => !userPracticesIds.includes(practice.id)
351
+ );
352
+ }
353
+ });
354
+
355
+ return await fetchHandler(url, 'DELETE', null);
212
356
  }
213
357
 
214
358
  export async function restorePracticeSession(date) {
215
359
  const url = `/api/user/practices/v1/practices/restore?date=${date}`;
216
- return await fetchHandler(url, 'put', null);
360
+ const response = await fetchHandler(url, 'PUT', null);
361
+
362
+ if (response?.data) {
363
+ await userActivityContext.updateLocal(async function (localContext) {
364
+ if (!localContext.data[DATA_KEY_PRACTICES][date]) {
365
+ localContext.data[DATA_KEY_PRACTICES][date] = [];
366
+ }
367
+
368
+ response.data.forEach(restoredPractice => {
369
+ localContext.data[DATA_KEY_PRACTICES][date].push({
370
+ id: restoredPractice.id,
371
+ duration_seconds: restoredPractice.duration_seconds,
372
+ });
373
+ });
374
+ });
375
+ }
376
+
377
+ return response;
217
378
  }
218
379
 
380
+ /**
381
+ * Retrieves and formats a user's practice sessions for a specific day.
382
+ *
383
+ * @param {string} day - The date for which practice sessions should be retrieved (format: YYYY-MM-DD).
384
+ * @returns {Promise<Object>} - A promise that resolves to an object containing the practice sessions and total practice duration.
385
+ *
386
+ * @example
387
+ * // Get practice sessions for a specific day
388
+ * getPracticeSessions("2025-03-31")
389
+ * .then(response => console.log(response))
390
+ * .catch(error => console.error(error));
391
+ */
219
392
  export async function getPracticeSessions(day) {
220
393
  const userPracticesIds = await getUserPracticeIds(day);
221
394
  if (!userPracticesIds.length) return { data: { practices: [], practiceDuration: 0 } };
@@ -259,20 +432,17 @@ export async function getRecentActivity() {
259
432
  return { data: recentActivity };
260
433
  }
261
434
 
262
- function getStreaksAndMessage(practices)
263
- {
264
- let { currentDailyStreak, currentWeeklyStreak } = calculateStreaks(practices)
435
+ function getStreaksAndMessage(practices) {
436
+ let { currentDailyStreak, currentWeeklyStreak, streakMessage } = calculateStreaks(practices, true);
265
437
 
266
- let streakMessage = currentWeeklyStreak > 1
267
- ? `That's ${currentWeeklyStreak} weeks in a row! Keep going!`
268
- : `Nice! You have a ${currentDailyStreak} day streak! Way to keep it going!`
269
438
  return {
270
439
  currentDailyStreak,
271
440
  currentWeeklyStreak,
272
441
  streakMessage,
273
- }
442
+ };
274
443
  }
275
444
 
445
+
276
446
  async function getUserPracticeIds(day = new Date().toISOString().split('T')[0]) {
277
447
  let data = await userActivityContext.getData();
278
448
  let practices = data?.[DATA_KEY_PRACTICES] ?? {};
@@ -301,85 +471,126 @@ function getMonday(d) {
301
471
  }
302
472
 
303
473
  // Helper: Get the week number
304
- function getWeekNumber(date) {
305
- let startOfYear = new Date(date.getFullYear(), 0, 1)
306
- let diff = date - startOfYear
307
- let oneWeekMs = 7 * 24 * 60 * 60 * 1000
308
- return Math.ceil((diff / oneWeekMs) + startOfYear.getDay() / 7)
474
+ function getWeekNumber(d) {
475
+ let newDate = new Date(d.getTime());
476
+ newDate.setUTCDate(newDate.getUTCDate() + 4 - (newDate.getUTCDay()||7));
477
+ var yearStart = new Date(Date.UTC(newDate.getUTCFullYear(),0,1));
478
+ var weekNo = Math.ceil(( ( (newDate - yearStart) / 86400000) + 1)/7);
479
+ return weekNo;
309
480
  }
310
481
 
311
482
  // Helper: function to check if two dates are consecutive days
312
- function isNextDay(prevDateStr, currentDateStr) {
313
- let prevDate = new Date(prevDateStr)
314
- let currentDate = new Date(currentDateStr)
315
- let diff = (currentDate - prevDate) / (1000 * 60 * 60 * 24)
316
- return diff === 1
483
+ function isNextDay(prev, current) {
484
+ const prevDate = new Date(prev.getFullYear(), prev.getMonth(), prev.getDate());
485
+ const nextDate = new Date(prevDate);
486
+ nextDate.setDate(prevDate.getDate() + 1); // Add 1 day
487
+
488
+ return (
489
+ nextDate.getFullYear() === current.getFullYear() &&
490
+ nextDate.getMonth() === current.getMonth() &&
491
+ nextDate.getDate() === current.getDate()
492
+ );
317
493
  }
318
494
 
319
495
  // Helper: Calculate streaks
320
- function calculateStreaks(practices) {
321
- let currentDailyStreak = 0
322
- let currentWeeklyStreak = 0
323
-
324
- let sortedPracticeDays = Object.keys(practices).sort() // Ensure dates are sorted in order
325
-
496
+ function calculateStreaks(practices, includeStreakMessage = false) {
497
+ let currentDailyStreak = 0;
498
+ let currentWeeklyStreak = 0;
499
+ let lastActiveDay = null;
500
+ let streakMessage = '';
501
+
502
+ let sortedPracticeDays = Object.keys(practices)
503
+ .map(dateStr => {
504
+ const [year, month, day] = dateStr.split('-').map(Number);
505
+ const newDate = new Date();
506
+ newDate.setFullYear(year, month - 1, day);
507
+ return newDate;
508
+ })
509
+ .sort((a, b) => a - b);
326
510
  if (sortedPracticeDays.length === 0) {
327
- return { currentDailyStreak: 1, currentWeeklyStreak: 1 }
511
+ return { currentDailyStreak: 0, currentWeeklyStreak: 0, streakMessage: streakMessages.startStreak };
328
512
  }
513
+ lastActiveDay = sortedPracticeDays[sortedPracticeDays.length - 1];
329
514
 
330
- let dailyStreak = 0
331
- let longestDailyStreak = 0
332
- let prevDay = null
333
-
334
- sortedPracticeDays.forEach((dayKey) => {
335
- if (prevDay === null || isNextDay(prevDay, dayKey)) {
336
- dailyStreak++
337
- longestDailyStreak = Math.max(longestDailyStreak, dailyStreak)
515
+ let dailyStreak = 0;
516
+ let prevDay = null;
517
+ sortedPracticeDays.forEach((currentDay) => {
518
+ if (prevDay === null || isNextDay(prevDay, currentDay)) {
519
+ dailyStreak++;
338
520
  } else {
339
- dailyStreak = 1 // Reset streak if there's a gap
521
+ dailyStreak = 1;
340
522
  }
341
- prevDay = dayKey
342
- })
343
-
344
- currentDailyStreak = dailyStreak
345
-
346
- // Calculate weekly streaks
347
- let weeklyStreak = 0
348
- let prevWeek = null
349
- let currentWeekActivity = false
350
-
351
- sortedPracticeDays.forEach((dayKey) => {
352
- let date = new Date(dayKey)
353
- let weekNumber = getWeekNumber(date)
354
-
355
- if (prevWeek === null) {
356
- prevWeek = weekNumber
357
- currentWeekActivity = true
358
- } else if (weekNumber !== prevWeek) {
359
- // A new week has started
360
- if (currentWeekActivity) {
361
- weeklyStreak++
362
- currentWeekActivity = false
363
- }
364
- prevWeek = weekNumber
523
+ prevDay = currentDay;
524
+ });
525
+ currentDailyStreak = dailyStreak;
526
+
527
+ // Weekly streak calculation
528
+ let weekNumbers = new Set(sortedPracticeDays.map(date => getWeekNumber(date)));
529
+ let weeklyStreak = 0;
530
+ let lastWeek = null;
531
+ [...weekNumbers].sort((a, b) => b - a).forEach(week => {
532
+ if (lastWeek === null || week === lastWeek - 1) {
533
+ weeklyStreak++;
534
+ } else {
535
+ return;
365
536
  }
366
-
367
- if (practices[dayKey]) {
368
- currentWeekActivity = true
537
+ lastWeek = week;
538
+ });
539
+ currentWeeklyStreak = weeklyStreak;
540
+
541
+ // Calculate streak message only if includeStreakMessage is true
542
+ if (includeStreakMessage) {
543
+ let today = new Date();
544
+ let yesterday = new Date(today);
545
+ yesterday.setDate(today.getDate() - 1);
546
+
547
+ let currentWeekStart = getMonday(today);
548
+ let lastWeekStart = new Date(currentWeekStart);
549
+ lastWeekStart.setDate(currentWeekStart.getDate() - 7);
550
+
551
+ let hasYesterdayPractice = sortedPracticeDays.some(date =>
552
+ isSameDate(date, yesterday)
553
+ );
554
+ let hasCurrentWeekPractice = sortedPracticeDays.some(date => date >= currentWeekStart);
555
+ let hasCurrentWeekPreviousPractice = sortedPracticeDays.some(date => date >= currentWeekStart && date < today);
556
+ let hasLastWeekPractice = sortedPracticeDays.some(date => date >= lastWeekStart && date < currentWeekStart);
557
+ let hasOlderPractice = sortedPracticeDays.some(date => date < lastWeekStart );
558
+
559
+ if (isSameDate(lastActiveDay, today)) {
560
+ if (hasYesterdayPractice) {
561
+ streakMessage = streakMessages.dailyStreak(currentDailyStreak);
562
+ } else if (hasCurrentWeekPreviousPractice) {
563
+ streakMessage = streakMessages.weeklyStreak(currentWeeklyStreak);
564
+ } else if (hasLastWeekPractice) {
565
+ streakMessage = streakMessages.greatJobWeeklyStreak(currentWeeklyStreak);
566
+ } else {
567
+ streakMessage = streakMessages.dailyStreakShort(currentDailyStreak);
568
+ }
569
+ } else {
570
+ if ((hasYesterdayPractice && currentDailyStreak >= 2) || (hasYesterdayPractice && sortedPracticeDays.length == 1)
571
+ || (hasYesterdayPractice && !hasLastWeekPractice && hasOlderPractice)){
572
+ streakMessage = streakMessages.dailyStreakReminder(currentDailyStreak);
573
+ } else if (hasCurrentWeekPractice) {
574
+ streakMessage = streakMessages.weeklyStreakKeepUp(currentWeeklyStreak);
575
+ } else if (hasLastWeekPractice) {
576
+ streakMessage = streakMessages.weeklyStreakReminder(currentWeeklyStreak);
577
+ } else {
578
+ streakMessage = streakMessages.restartStreak;
579
+ }
369
580
  }
370
- })
371
581
 
372
- // If the user has activity in the current week, count it
373
- if (currentWeekActivity) {
374
- weeklyStreak++
375
582
  }
376
583
 
377
- currentWeeklyStreak = weeklyStreak
584
+ return { currentDailyStreak, currentWeeklyStreak, streakMessage };
585
+ }
378
586
 
379
- return { currentDailyStreak, currentWeeklyStreak }
587
+ function isSameDate(date1, date2, method = '') {
588
+ return date1.toISOString().split('T')[0] === date2.toISOString().split('T')[0];
380
589
  }
381
590
 
382
591
 
383
592
 
384
593
 
385
594
 
595
+
596
+
File without changes
File without changes
File without changes
File without changes
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,9 @@
1
+ {
2
+ "version": 1,
3
+ "config": { "key": 1, "enabled": 1, "checkInterval": 1, "refreshInterval": 2 },
4
+ "data": {
5
+ "practices": {
6
+ "DATE_PLACEHOLDER": [{ "duration_seconds": "DURATION_PLACEHOLDER" }]
7
+ }
8
+ }
9
+ }
@@ -148,9 +148,19 @@ describe('Sanity Queries', function() {
148
148
  })
149
149
 
150
150
  test('fetchLessonContent', async () => {
151
- const id = 380094
151
+ const id = 392820
152
+ const response = await fetchLessonContent(id)
153
+ expect(response.id).toBe(id)
154
+ expect(response.video.type).toBeDefined()
155
+ })
156
+
157
+ test('fetchLessonContent-PlayAlong-containts-array-of-videos', async () => {
158
+ const id = 9184
152
159
  const response = await fetchLessonContent(id)
153
160
  expect(response.id).toBe(id)
161
+ expect(response.video.length).toBeGreaterThanOrEqual(1)
162
+ const firstElement = response.video.find(() => true)
163
+ expect(firstElement.version_name).toBeDefined()
154
164
  })
155
165
 
156
166
  test('fetchAllSongsInProgress', async () => {