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
package/src/contentTypeConfig.js
CHANGED
|
@@ -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'],
|
package/src/services/content.js
CHANGED
|
@@ -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 =
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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(
|
|
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 =
|
|
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 = (
|
|
60
|
-
watermelonLogger.warn = (
|
|
61
|
-
watermelonLogger.error = (
|
|
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 =
|
|
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(
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
133
|
-
this.
|
|
134
|
-
this.recordBreadcrumb('debug', ...messages)
|
|
146
|
+
info(message: unknown, extra?: any) {
|
|
147
|
+
this._log(SeverityLevel.INFO, 'info', message, extra)
|
|
135
148
|
}
|
|
136
149
|
|
|
137
|
-
|
|
138
|
-
this.
|
|
139
|
-
this.recordBreadcrumb('info', ...messages)
|
|
150
|
+
log(message: unknown, extra?: any) {
|
|
151
|
+
this._log(SeverityLevel.LOG, 'log', message, extra)
|
|
140
152
|
}
|
|
141
153
|
|
|
142
|
-
|
|
143
|
-
this.
|
|
144
|
-
this.recordBreadcrumb('log', ...messages)
|
|
154
|
+
warn(message: unknown, extra?: any) {
|
|
155
|
+
this._log(SeverityLevel.WARNING, 'warn', message, extra)
|
|
145
156
|
}
|
|
146
157
|
|
|
147
|
-
|
|
148
|
-
this.
|
|
149
|
-
this.recordBreadcrumb('warning', ...messages)
|
|
158
|
+
error(message: unknown[], extra?: any) {
|
|
159
|
+
this._log(SeverityLevel.ERROR, 'error', message, extra)
|
|
150
160
|
}
|
|
151
161
|
|
|
152
|
-
|
|
153
|
-
this.
|
|
154
|
-
this.recordBreadcrumb('error', ...messages)
|
|
162
|
+
fatal(message: unknown[], extra?: any) {
|
|
163
|
+
this._log(SeverityLevel.FATAL, 'error', message, extra)
|
|
155
164
|
}
|
|
156
165
|
|
|
157
|
-
|
|
158
|
-
this.level
|
|
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
|
-
|
|
163
|
-
this.
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
175
|
+
private formattedConsoleMessage(message: unknown, extra: any) {
|
|
171
176
|
if (!this.pretty) {
|
|
172
|
-
return
|
|
177
|
+
return [message, ...(extra ? [extra] : [])]
|
|
173
178
|
}
|
|
174
179
|
|
|
175
|
-
const date = new Date()
|
|
176
|
-
return [...this.consolePrefix(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 [
|
|
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
|
|
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) =>
|
|
99
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
|
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')
|
|
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
|
-
|
|
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')
|
|
301
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
-
|
|
633
|
+
break // Properly break on non-consecutive week
|
|
621
634
|
}
|
|
622
|
-
|
|
623
|
-
|
|
635
|
+
}
|
|
636
|
+
prevWeekTimestamp = currentWeekTimestamp
|
|
637
|
+
}
|
|
624
638
|
currentWeeklyStreak = weeklyStreak
|
|
625
639
|
|
|
626
640
|
// Calculate streak message only if includeStreakMessage is true
|