musora-content-services 2.119.0 → 2.119.2

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,20 @@
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.119.2](https://github.com/railroadmedia/musora-content-services/compare/v2.119.1...v2.119.2) (2026-01-14)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * better logs in Sentry for debugging ([#712](https://github.com/railroadmedia/musora-content-services/issues/712)) ([e1019b5](https://github.com/railroadmedia/musora-content-services/commit/e1019b50b0cf606b86e71028db0a2f9ac354cfca))
11
+
12
+ ### [2.119.1](https://github.com/railroadmedia/musora-content-services/compare/v2.119.0...v2.119.1) (2026-01-14)
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+ * **T3PS-1347:** Implement WatermelonDB-based streak calculator with year boundary fix ([916a8ef](https://github.com/railroadmedia/musora-content-services/commit/916a8ef532a858f82a8133d1725ad69ea3d550e9))
18
+
5
19
  ## [2.119.0](https://github.com/railroadmedia/musora-content-services/compare/v2.118.1...v2.119.0) (2026-01-14)
6
20
 
7
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.119.0",
3
+ "version": "2.119.2",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -229,7 +229,7 @@ export const lessonTypesMapping = {
229
229
  performances: performancesLessonTypes,
230
230
  'student archives': studentArchivesLessonTypes,
231
231
  documentaries: ['documentary-lesson'],
232
- courses: ['course'],
232
+ courses: ['course', 'course-collection'],
233
233
  'guided courses': ['guided-course'],
234
234
  'course collections': ['course-collection'],
235
235
  'skill packs': ['skill-pack'],
@@ -116,7 +116,7 @@ export async function getTabResults(brand, pageName, tabName, {
116
116
  addProgressPercentage: true,
117
117
  addProgressStatus: true
118
118
  })
119
- } else if (sort === 'recommended') {
119
+ } else if (sort === 'recommended' && tabName.toLowerCase() !== Tabs.ExploreAll.name.toLowerCase()) {
120
120
  const contentTypes = lessonTypesMapping[tabName.toLowerCase()] || []
121
121
  const allRecommendations = await recommendations(brand, { contentTypes, section: tabRecSysSection })
122
122
 
@@ -162,12 +162,20 @@ export async function getTabResults(brand, pageName, tabName, {
162
162
  })
163
163
  } else {
164
164
  let temp = await fetchTabData(brand, pageName, { page, limit, sort, includedFields: mergedIncludedFields, progress: progressValue });
165
+ const [ranking, contextResults] = await Promise.all([
166
+ sort === 'recommended' ? rankItems(brand, temp.entity.map(e => e.id)) : [],
167
+ addContextToContent(() => temp.entity, {
168
+ addNextLesson: true,
169
+ addNavigateTo: true,
170
+ addProgressPercentage: true,
171
+ addProgressStatus: true
172
+ })
173
+ ]);
165
174
 
166
- results = await addContextToContent(() => temp.entity, {
167
- addNextLesson: true,
168
- addNavigateTo: true,
169
- addProgressPercentage: true,
170
- addProgressStatus: true
175
+ results = ranking.length === 0 ? contextResults : contextResults.sort((a, b) => {
176
+ const indexA = ranking.indexOf(a.id);
177
+ const indexB = ranking.indexOf(b.id);
178
+ return (indexA === -1 ? Infinity : indexA) - (indexB === -1 ? Infinity : indexB);
171
179
  })
172
180
  }
173
181
 
@@ -2,7 +2,7 @@ import watermelonLogger from '@nozbe/watermelondb/utils/common/logger'
2
2
  import { SyncError, SyncUnexpectedError } from '../errors'
3
3
 
4
4
  import * as InjectedSentry from '@sentry/browser'
5
- export type SentryBrowserOptions = NonNullable<Parameters<typeof InjectedSentry.init>[0]>;
5
+ export type SentryBrowserOptions = NonNullable<Parameters<typeof InjectedSentry.init>[0]>
6
6
 
7
7
  export type SentryLike = {
8
8
  captureException: typeof InjectedSentry.captureException
@@ -21,7 +21,7 @@ export enum SeverityLevel {
21
21
  LOG = 2,
22
22
  WARNING = 3,
23
23
  ERROR = 4,
24
- FATAL = 5
24
+ FATAL = 5,
25
25
  }
26
26
 
27
27
  export class SyncTelemetry {
@@ -41,7 +41,7 @@ export class SyncTelemetry {
41
41
  }
42
42
 
43
43
  private userId: string
44
- private Sentry: SentryLike;
44
+ private Sentry: SentryLike
45
45
  private level: SeverityLevel
46
46
  private pretty: boolean
47
47
 
@@ -50,15 +50,25 @@ export class SyncTelemetry {
50
50
  // allows us to know if Sentry shouldn't double-capture a dev-prettified console.error log
51
51
  private _ignoreConsole = false
52
52
 
53
- constructor(userId: string, { Sentry, level, pretty }: { Sentry: SentryLike, level?: keyof typeof SeverityLevel, pretty?: boolean }) {
53
+ constructor(
54
+ userId: string,
55
+ {
56
+ Sentry,
57
+ level,
58
+ pretty,
59
+ }: { Sentry: SentryLike; level?: keyof typeof SeverityLevel; pretty?: boolean }
60
+ ) {
54
61
  this.userId = userId
55
62
  this.Sentry = Sentry
56
- this.level = typeof level !== 'undefined' && level in SeverityLevel ? SeverityLevel[level] : SeverityLevel.LOG
63
+ this.level =
64
+ typeof level !== 'undefined' && level in SeverityLevel
65
+ ? SeverityLevel[level]
66
+ : SeverityLevel.LOG
57
67
  this.pretty = typeof pretty !== 'undefined' ? pretty : true
58
68
 
59
- watermelonLogger.log = (...messages: any[]) => this.log('[Watermelon]', ...messages);
60
- watermelonLogger.warn = (...messages: any[]) => this.warn('[Watermelon]', ...messages);
61
- watermelonLogger.error = (...messages: any[]) => this.error('[Watermelon]', ...messages);
69
+ watermelonLogger.log = (message: unknown) => this.log(message instanceof Error ? message : ['[Watermelon]', message].join(' '))
70
+ watermelonLogger.warn = (message: unknown) => this.warn(message instanceof Error ? message : ['[Watermelon]', message].join(' '))
71
+ watermelonLogger.error = (message: unknown) => this.error(message instanceof Error ? message : ['[Watermelon]', message].join(' '))
62
72
  }
63
73
 
64
74
  trace<T>(opts: StartSpanOptions, callback: (_span: Span) => T) {
@@ -68,8 +78,8 @@ export class SyncTelemetry {
68
78
  op: `${SYNC_TELEMETRY_TRACE_PREFIX}${opts.op}`,
69
79
  attributes: {
70
80
  ...opts.attributes,
71
- userId: this.userId
72
- }
81
+ userId: this.userId,
82
+ },
73
83
  }
74
84
  return this.Sentry.startSpan<T>(options, (span) => {
75
85
  let desc = span['_spanId'].slice(0, 4)
@@ -84,14 +94,20 @@ export class SyncTelemetry {
84
94
  }
85
95
 
86
96
  capture(err: Error, context = {}) {
87
- const wrapped = err instanceof SyncError ? err : new SyncUnexpectedError((err as Error).message, context);
97
+ const wrapped =
98
+ err instanceof SyncError ? err : new SyncUnexpectedError((err as Error).message, context)
88
99
 
89
100
  wrapped.markReported()
90
- this.Sentry.captureException(err, err instanceof SyncUnexpectedError ? {
91
- mechanism: {
92
- handled: false
93
- }
94
- } : undefined)
101
+ this.Sentry.captureException(
102
+ err,
103
+ err instanceof SyncUnexpectedError
104
+ ? {
105
+ mechanism: {
106
+ handled: false,
107
+ },
108
+ }
109
+ : undefined
110
+ )
95
111
 
96
112
  this._ignoreConsole = true
97
113
  this.error(err.message)
@@ -123,73 +139,66 @@ export class SyncTelemetry {
123
139
  return false
124
140
  }
125
141
 
126
- shouldIgnoreMessages(messages: any[]) {
127
- return messages.some(message => {
128
- return this.shouldIgnoreMessage(message)
129
- })
142
+ debug(message: unknown, extra?: any) {
143
+ this._log(SeverityLevel.DEBUG, 'info', message, extra)
130
144
  }
131
145
 
132
- debug(...messages: any[]) {
133
- this.level <= SeverityLevel.DEBUG && !this.shouldIgnoreMessages(messages) && console.debug(...this.formattedConsoleMessages(...messages));
134
- this.recordBreadcrumb('debug', ...messages)
146
+ info(message: unknown, extra?: any) {
147
+ this._log(SeverityLevel.INFO, 'info', message, extra)
135
148
  }
136
149
 
137
- info(...messages: any[]) {
138
- this.level <= SeverityLevel.INFO && !this.shouldIgnoreMessages(messages) && console.info(...this.formattedConsoleMessages(...messages));
139
- this.recordBreadcrumb('info', ...messages)
150
+ log(message: unknown, extra?: any) {
151
+ this._log(SeverityLevel.LOG, 'log', message, extra)
140
152
  }
141
153
 
142
- log(...messages: any[]) {
143
- this.level <= SeverityLevel.LOG && !this.shouldIgnoreMessages(messages) && console.log(...this.formattedConsoleMessages(...messages));
144
- this.recordBreadcrumb('log', ...messages)
154
+ warn(message: unknown, extra?: any) {
155
+ this._log(SeverityLevel.WARNING, 'warn', message, extra)
145
156
  }
146
157
 
147
- warn(...messages: any[]) {
148
- this.level <= SeverityLevel.WARNING && !this.shouldIgnoreMessages(messages) && console.warn(...this.formattedConsoleMessages(...messages));
149
- this.recordBreadcrumb('warning', ...messages)
158
+ error(message: unknown[], extra?: any) {
159
+ this._log(SeverityLevel.ERROR, 'error', message, extra)
150
160
  }
151
161
 
152
- error(...messages: any[]) {
153
- this.level <= SeverityLevel.ERROR && !this.shouldIgnoreMessages(messages) && console.error(...this.formattedConsoleMessages(...messages));
154
- this.recordBreadcrumb('error', ...messages)
162
+ fatal(message: unknown[], extra?: any) {
163
+ this._log(SeverityLevel.FATAL, 'error', message, extra)
155
164
  }
156
165
 
157
- fatal(...messages: any[]) {
158
- this.level <= SeverityLevel.FATAL && !this.shouldIgnoreMessages(messages) && console.error(...this.formattedConsoleMessages(...messages));
159
- this.recordBreadcrumb('fatal', ...messages)
160
- }
166
+ _log(level: SeverityLevel, consoleMethod: 'info' | 'log' | 'warn' | 'error', message: unknown, extra?: any) {
167
+ if (this.level > level || this.shouldIgnoreMessage(message)) return
161
168
 
162
- private recordBreadcrumb(level: InjectedSentry.Breadcrumb['level'], ...messages: any[]) {
163
- this.Sentry.addBreadcrumb({
164
- message: messages.join(', '),
165
- level,
166
- category: 'sync',
167
- })
169
+ this._ignoreConsole = true
170
+ console[consoleMethod](...this.formattedConsoleMessage(message, extra))
171
+ this._ignoreConsole = false
172
+ this.Sentry.captureMessage(message instanceof Error ? message.message : String(message), level)
168
173
  }
169
174
 
170
- private formattedConsoleMessages(...messages: any[]) {
175
+ private formattedConsoleMessage(message: unknown, extra: any) {
171
176
  if (!this.pretty) {
172
- return messages
177
+ return [message, ...(extra ? [extra] : [])]
173
178
  }
174
179
 
175
- const date = new Date();
176
- return [...this.consolePrefix(date), ...messages, ...this.consoleSuffix(date)];
180
+ const date = new Date()
181
+ return [...this.consolePrefix(date), message, ...(extra ? [extra] : []), ...this.consoleSuffix(date)]
177
182
  }
178
183
 
179
184
  private consolePrefix(date: Date) {
180
- const now = Math.round(date.getTime() / 1000).toString();
181
- return [`📡 SYNC: (%c${now.slice(0, 5)}%c${now.slice(5, 10)})`, 'color: #ccc', 'font-weight: bold;'];
185
+ const now = Math.round(date.getTime() / 1000).toString()
186
+ return [
187
+ `📡 SYNC: (%c${now.slice(0, 5)}%c${now.slice(5, 10)})`,
188
+ 'color: #ccc',
189
+ 'font-weight: bold;',
190
+ ]
182
191
  }
183
192
 
184
193
  private consoleSuffix(date: Date) {
185
- return [` [${date.toLocaleTimeString()}, ${date.getTime()}]`];
194
+ return [` [${date.toLocaleTimeString()}, ${date.getTime()}]`]
186
195
  }
187
196
 
188
197
  private shouldIgnoreMessage(message: any) {
189
198
  if (message instanceof Error) message = message.message
190
199
  if (typeof message !== 'string') return false
191
200
 
192
- return this.ignorePatterns.some(pattern => {
201
+ return this.ignorePatterns.some((pattern) => {
193
202
  if (typeof pattern === 'string') {
194
203
  return message.indexOf(pattern) !== -1
195
204
  } else if (pattern instanceof RegExp) {
@@ -0,0 +1,66 @@
1
+ import { db } from '../sync'
2
+ import { getStreaksAndMessage } from '../../services/userActivity.js'
3
+
4
+ export interface StreakData {
5
+ currentDailyStreak: number
6
+ currentWeeklyStreak: number
7
+ streakMessage: string
8
+ calculatedAt: number // timestamp
9
+ lastPracticeDate: string | null
10
+ }
11
+ export interface PracticeData {
12
+ [date: string]: Array<{
13
+ id: string | number
14
+ duration_seconds: number
15
+ }>
16
+ }
17
+ class StreakCalculator {
18
+ private cache: StreakData | null = null
19
+ async getStreakData(): Promise<StreakData> {
20
+ if (this.cache) {
21
+ return this.cache
22
+ }
23
+
24
+ return await this.recalculate()
25
+ }
26
+
27
+ async recalculate(): Promise<StreakData> {
28
+ const allPractices = await this.fetchAllPractices()
29
+
30
+ const { currentDailyStreak, currentWeeklyStreak, streakMessage } = getStreaksAndMessage(allPractices)
31
+
32
+ this.cache = {
33
+ currentDailyStreak: currentDailyStreak,
34
+ currentWeeklyStreak: currentWeeklyStreak,
35
+ streakMessage: streakMessage,
36
+ calculatedAt: Date.now(),
37
+ lastPracticeDate: this.getLastPracticeDate(allPractices)
38
+ }
39
+ return this.cache
40
+ }
41
+ invalidate(): void {
42
+ this.cache = null
43
+ }
44
+
45
+ private async fetchAllPractices(): Promise<PracticeData> {
46
+ const query = await db.practices.queryAll()
47
+
48
+ return query.data.reduce((acc, practice) => {
49
+ acc[practice.date] = acc[practice.date] || []
50
+ acc[practice.date].push({
51
+ id: practice.id,
52
+ duration_seconds: practice.duration_seconds,
53
+ })
54
+ return acc
55
+ }, {} as PracticeData)
56
+ }
57
+
58
+ private getLastPracticeDate(practices: PracticeData): string | null {
59
+ const dates = Object.keys(practices).sort()
60
+ return dates.length > 0 ? dates[dates.length - 1] : null
61
+ }
62
+ }
63
+
64
+
65
+
66
+ export const streakCalculator = new StreakCalculator()
@@ -2,26 +2,18 @@
2
2
  * @module UserActivity
3
3
  */
4
4
 
5
- import {
6
- fetchUserPractices,
7
- fetchUserPracticeMeta,
8
- fetchRecentUserActivities,
9
- } from './railcontent'
5
+ import { fetchUserPractices, fetchUserPracticeMeta, fetchRecentUserActivities } from './railcontent'
10
6
  import { GET, POST, PUT, DELETE } from '../infrastructure/http/HttpClient.ts'
11
7
  import { DataContext, UserActivityVersionKey } from './dataContext.js'
12
8
  import { fetchByRailContentIds } from './sanity'
13
- import {
14
- getMonday,
15
- getWeekNumber,
16
- isSameDate,
17
- isNextDay,
18
- } from './dateUtils.js'
9
+ import { getMonday, getWeekNumber, isSameDate, isNextDay } from './dateUtils.js'
19
10
  import { globalConfig } from './config'
20
11
  import { getFormattedType } from '../contentTypeConfig'
21
12
  import dayjs from 'dayjs'
22
13
  import { addContextToContent } from './contentAggregator.js'
23
14
  import { db, Q } from './sync'
24
- import {COLLECTION_TYPE} from "./sync/models/ContentProgress";
15
+ import { COLLECTION_TYPE } from './sync/models/ContentProgress'
16
+ import { streakCalculator } from './user/streakCalculator'
25
17
 
26
18
  const DATA_KEY_PRACTICES = 'practices'
27
19
 
@@ -78,7 +70,7 @@ async function getOwnPractices(...clauses) {
78
70
  return data
79
71
  }
80
72
 
81
- export let userActivityContext = new DataContext(UserActivityVersionKey, function() {})
73
+ export let userActivityContext = new DataContext(UserActivityVersionKey, function () {})
82
74
 
83
75
  /**
84
76
  * Retrieves user activity statistics for the current week, including daily activity and streak messages.
@@ -95,24 +87,14 @@ export async function getUserWeeklyStats() {
95
87
  const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
96
88
  const today = dayjs()
97
89
  const startOfWeek = getMonday(today, timeZone)
98
- const weekDays = Array.from({ length: 7 }, (_, i) => startOfWeek.add(i, 'day').format('YYYY-MM-DD'))
99
- // Query THIS WEEK's practices for display
100
- const weekPractices = await getOwnPractices(
101
- Q.where('date', Q.oneOf(weekDays)),
102
- Q.sortBy('date', 'desc')
90
+ const weekDays = Array.from({ length: 7 }, (_, i) =>
91
+ startOfWeek.add(i, 'day').format('YYYY-MM-DD')
103
92
  )
104
93
 
105
- // Query LAST 60 DAYS for streak calculation (balances accuracy vs performance)
106
- // This captures:
107
- // - Current active streaks up to 60 days
108
- // - Recent breaks (to show "restart" message)
109
- // - Sufficient context for accurate weekly streak calculation
110
- const sixtyDaysAgo = today.subtract(60, 'days').format('YYYY-MM-DD')
111
- const recentPractices = await getOwnPractices(
112
- Q.where('date', Q.gte(sixtyDaysAgo)),
94
+ const weekPractices = await getOwnPractices(
95
+ Q.where('date', Q.oneOf(weekDays)),
113
96
  Q.sortBy('date', 'desc')
114
97
  )
115
-
116
98
  const practiceDaysSet = new Set(Object.keys(weekPractices))
117
99
  let dailyStats = []
118
100
  for (let i = 0; i < 7; i++) {
@@ -131,8 +113,15 @@ export async function getUserWeeklyStats() {
131
113
  })
132
114
  }
133
115
 
134
- let { streakMessage } = getStreaksAndMessage(recentPractices)
135
- return { data: { dailyActiveStats: dailyStats, streakMessage, practices: weekPractices } }
116
+ const streakData = await streakCalculator.getStreakData()
117
+
118
+ return {
119
+ data: {
120
+ dailyActiveStats: dailyStats,
121
+ streakMessage: streakData.streakMessage,
122
+ practices: weekPractices,
123
+ },
124
+ }
136
125
  }
137
126
 
138
127
  /**
@@ -252,7 +241,9 @@ export async function getUserMonthlyStats(params = {}) {
252
241
  return acc
253
242
  }, {})
254
243
 
255
- const { currentDailyStreak, currentWeeklyStreak } = calculateStreaks(filteredPractices)
244
+ const streakData = await streakCalculator.getStreakData()
245
+ const currentDailyStreak = streakData.currentDailyStreak
246
+ const currentWeeklyStreak = streakData.currentWeeklyStreak
256
247
 
257
248
  return {
258
249
  data: {
@@ -285,20 +276,28 @@ export async function getUserMonthlyStats(params = {}) {
285
276
  *
286
277
  */
287
278
  export async function recordUserPractice(practiceDetails) {
288
- const day = new Date().toLocaleDateString('sv-SE'); // YYYY-MM-DD wall clock date in user's timezone
279
+ const day = new Date().toLocaleDateString('sv-SE') // YYYY-MM-DD wall clock date in user's timezone
289
280
  const durationSeconds = practiceDetails.duration_seconds
290
281
 
291
- return await db.practices.recordManualPractice(day, durationSeconds, {
282
+ const result = await db.practices.recordManualPractice(day, durationSeconds, {
292
283
  title: practiceDetails.title ?? null,
293
284
  category_id: practiceDetails.category_id ?? null,
294
285
  thumbnail_url: practiceDetails.thumbnail_url ?? null,
295
286
  instrument_id: practiceDetails.instrument_id ?? null,
296
287
  })
288
+
289
+ streakCalculator.invalidate()
290
+ return result
297
291
  }
298
292
 
299
293
  export async function trackUserPractice(contentId, incSeconds) {
300
- const day = new Date().toLocaleDateString('sv-SE'); // YYYY-MM-DD wall clock date in user's timezone
301
- return await db.practices.trackAutoPractice(contentId, day, incSeconds, { skipPush: true }); // NOTE - SKIPS PUSH
294
+ const day = new Date().toLocaleDateString('sv-SE') // YYYY-MM-DD wall clock date in user's timezone
295
+ const result = await db.practices.trackAutoPractice(contentId, day, incSeconds, {
296
+ skipPush: true,
297
+ }) // NOTE - SKIPS PUSH
298
+
299
+ streakCalculator.invalidate()
300
+ return result
302
301
  }
303
302
 
304
303
  /**
@@ -321,7 +320,9 @@ export async function trackUserPractice(contentId, incSeconds) {
321
320
  *
322
321
  */
323
322
  export async function updateUserPractice(id, practiceDetails) {
324
- return await db.practices.updateDetails(id, practiceDetails)
323
+ const result = await db.practices.updateDetails(id, practiceDetails)
324
+ streakCalculator.invalidate()
325
+ return result
325
326
  }
326
327
 
327
328
  /**
@@ -337,7 +338,9 @@ export async function updateUserPractice(id, practiceDetails) {
337
338
  * .catch(error => console.error(error));
338
339
  */
339
340
  export async function removeUserPractice(id) {
340
- return await db.practices.deleteOne(id)
341
+ const result = await db.practices.deleteOne(id)
342
+ streakCalculator.invalidate()
343
+ return result
341
344
  }
342
345
 
343
346
  /**
@@ -374,7 +377,9 @@ export async function restoreUserPractice(id) {
374
377
  */
375
378
  export async function deletePracticeSession(day) {
376
379
  const ids = await db.practices.queryAllIds(Q.where('date', day))
377
- return await db.practices.deleteSome(ids.data)
380
+ const result = await db.practices.deleteSome(ids.data)
381
+ streakCalculator.invalidate()
382
+ return result
378
383
  }
379
384
 
380
385
  /**
@@ -402,7 +407,7 @@ export async function restorePracticeSession(date) {
402
407
  (total, practice) => total + (practice.duration || 0),
403
408
  0
404
409
  )
405
-
410
+ streakCalculator.invalidate()
406
411
  return { data: formattedMeta, practiceDuration }
407
412
  }
408
413
 
@@ -434,10 +439,7 @@ export async function getPracticeSessions(params = {}) {
434
439
  let data
435
440
 
436
441
  if (userId === globalConfig.sessionConfig.userId) {
437
- const query = await db.practices.queryAll(
438
- Q.where('date', day),
439
- Q.sortBy('created_at', 'asc')
440
- )
442
+ const query = await db.practices.queryAll(Q.where('date', day), Q.sortBy('created_at', 'asc'))
441
443
  data = query.data
442
444
  } else {
443
445
  const query = await fetchUserPracticeMeta(day, userId)
@@ -530,7 +532,7 @@ export async function getRecentActivity({ page = 1, limit = 5, tabName = null }
530
532
  * .catch(error => console.error(error));
531
533
  */
532
534
  export async function createPracticeNotes(payload) {
533
- return await db.practiceDayNotes.upsertOne(payload.date, r => {
535
+ return await db.practiceDayNotes.upsertOne(payload.date, (r) => {
534
536
  r.date = payload.date
535
537
  r.notes = payload.notes
536
538
  })
@@ -550,12 +552,12 @@ export async function createPracticeNotes(payload) {
550
552
  * .catch(error => console.error(error));
551
553
  */
552
554
  export async function updatePracticeNotes(payload) {
553
- return await db.practiceDayNotes.updateOneId(payload.date, r => {
555
+ return await db.practiceDayNotes.updateOneId(payload.date, (r) => {
554
556
  r.notes = payload.notes
555
557
  })
556
558
  }
557
559
 
558
- function getStreaksAndMessage(practices) {
560
+ export function getStreaksAndMessage(practices) {
559
561
  let { currentDailyStreak, currentWeeklyStreak, streakMessage } = calculateStreaks(practices, true)
560
562
 
561
563
  return {
@@ -607,20 +609,32 @@ function calculateStreaks(practices, includeStreakMessage = false) {
607
609
  })
608
610
  currentDailyStreak = dailyStreak
609
611
 
610
- // Weekly streak calculation
611
- let weekNumbers = new Set(sortedPracticeDays.map((date) => getWeekNumber(date)))
612
+ // Weekly streak calculation - using Monday dates to handle year boundaries
613
+ let weekStartMap = new Map()
614
+ sortedPracticeDays.forEach((date) => {
615
+ const mondayDate = getMonday(date).format('YYYY-MM-DD')
616
+ if (!weekStartMap.has(mondayDate)) {
617
+ weekStartMap.set(mondayDate, getMonday(date).valueOf()) // Store as timestamp
618
+ }
619
+ })
620
+ let sortedWeekTimestamps = [...weekStartMap.values()].sort((a, b) => b - a) // Descending
612
621
  let weeklyStreak = 0
613
- let lastWeek = null
614
- ;[...weekNumbers]
615
- .sort((a, b) => b - a)
616
- .forEach((week) => {
617
- if (lastWeek === null || week === lastWeek - 1) {
622
+ let prevWeekTimestamp = null
623
+ for (const currentWeekTimestamp of sortedWeekTimestamps) {
624
+ if (prevWeekTimestamp === null) {
625
+ weeklyStreak = 1
626
+ } else {
627
+ const daysDiff = (prevWeekTimestamp - currentWeekTimestamp) / (1000 * 60 * 60 * 24)
628
+ const weeksDiff = Math.round(daysDiff / 7)
629
+
630
+ if (weeksDiff === 1) {
618
631
  weeklyStreak++
619
632
  } else {
620
- return
633
+ break // Properly break on non-consecutive week
621
634
  }
622
- lastWeek = week
623
- })
635
+ }
636
+ prevWeekTimestamp = currentWeekTimestamp
637
+ }
624
638
  currentWeeklyStreak = weeklyStreak
625
639
 
626
640
  // Calculate streak message only if includeStreakMessage is true