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.
- package/.editorconfig +0 -0
- package/.github/workflows/node.js.yml +0 -0
- package/.prettierignore +0 -0
- package/.prettierrc +0 -0
- package/.yarnrc.yml +1 -0
- package/CHANGELOG.md +17 -0
- package/README.md +0 -0
- package/babel.config.cjs +0 -0
- package/docs/Content-Organization.html +0 -0
- package/docs/Gamification.html +0 -0
- package/docs/Playlists.html +0 -0
- package/docs/api_types.js.html +0 -0
- package/docs/config.js.html +0 -0
- package/docs/content-org_playlists-types.js.html +0 -0
- package/docs/content-org_playlists.js.html +0 -0
- package/docs/content-org_types.js.html +0 -0
- package/docs/content.js.html +0 -0
- package/docs/fonts/Montserrat/Montserrat-Bold.eot +0 -0
- package/docs/fonts/Montserrat/Montserrat-Bold.ttf +0 -0
- package/docs/fonts/Montserrat/Montserrat-Bold.woff +0 -0
- package/docs/fonts/Montserrat/Montserrat-Bold.woff2 +0 -0
- package/docs/fonts/Montserrat/Montserrat-Regular.eot +0 -0
- package/docs/fonts/Montserrat/Montserrat-Regular.ttf +0 -0
- package/docs/fonts/Montserrat/Montserrat-Regular.woff +0 -0
- package/docs/fonts/Montserrat/Montserrat-Regular.woff2 +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.eot +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.svg +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.ttf +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff2 +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.eot +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.svg +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.ttf +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff2 +0 -0
- package/docs/gamification_awards.js.html +0 -0
- package/docs/gamification_gamification.js.html +0 -0
- package/docs/gamification_types.js.html +0 -0
- package/docs/global.html +0 -0
- package/docs/index.html +0 -0
- package/docs/module-Awards.html +0 -0
- package/docs/module-Config.html +0 -0
- package/docs/module-Content-Organization-Playlists.html +0 -0
- package/docs/module-Content-Organization.html +0 -0
- package/docs/module-Content-Services-V2.html +0 -0
- package/docs/module-Content-Services.html +763 -0
- package/docs/module-Playlists.html +0 -0
- package/docs/module-Railcontent-Services.html +0 -0
- package/docs/module-Sanity-Services.html +0 -0
- package/docs/module-Session-Management.html +0 -0
- package/docs/module-User-Permissions.html +0 -0
- package/docs/module-playlists.html +0 -0
- package/docs/module-playlists_.html +0 -0
- package/docs/railcontent.js.html +0 -0
- package/docs/sanity.js.html +0 -0
- package/docs/scripts/collapse.js +0 -0
- package/docs/scripts/commonNav.js +0 -0
- package/docs/scripts/linenumber.js +0 -0
- package/docs/scripts/nav.js +0 -0
- package/docs/scripts/polyfill.js +0 -0
- package/docs/scripts/prettify/Apache-License-2.0.txt +0 -0
- package/docs/scripts/prettify/lang-css.js +0 -0
- package/docs/scripts/prettify/prettify.js +0 -0
- package/docs/scripts/search.js +0 -0
- package/docs/styles/jsdoc.css +0 -0
- package/docs/styles/prettify.css +0 -0
- package/docs/user_permissions.js.html +0 -0
- package/docs/user_sessions.js.html +0 -0
- package/docs/user_types.js.html +0 -0
- package/jest.config.js +0 -0
- package/jsdoc.json +0 -0
- package/package.json +1 -1
- package/src/contentMetaData.js +0 -0
- package/src/contentTypeConfig.js +1 -0
- package/src/filterBuilder.js +0 -0
- package/src/index.d.ts +35 -2
- package/src/index.js +35 -2
- package/src/lib/httpHelper.js +0 -0
- package/src/lib/lastUpdated.js +0 -0
- package/src/services/api/types.js +0 -0
- package/src/services/config.js +0 -0
- package/src/services/content-org/playlists-types.js +0 -0
- package/src/services/content-org/playlists.js +0 -0
- package/src/services/content.js +0 -0
- package/src/services/contentLikes.js +0 -0
- package/src/services/contentProgress.js +0 -0
- package/src/services/dataContext.js +15 -2
- package/src/services/forum.js +0 -0
- package/src/services/gamification/awards.js +0 -0
- package/src/services/gamification/gamification.js +0 -0
- package/src/services/gamification/types.js +0 -0
- package/src/services/railcontent.js +60 -0
- package/src/services/recommendations.js +0 -0
- package/src/services/sanity.js +10 -7
- package/src/services/user/management.js +0 -0
- package/src/services/user/permissions.js +0 -0
- package/src/services/user/sessions.js +0 -0
- package/src/services/user/types.js +0 -0
- package/src/services/userActivity.js +378 -23
- package/test/content.test.js +0 -0
- package/test/contentLikes.test.js +2 -0
- package/test/contentProgress.test.js +0 -0
- package/test/dataContext.test.js +0 -0
- package/test/forum.test.js +0 -0
- package/test/initializeTests.js +0 -0
- package/test/lib/lastUpdated.test.js +0 -0
- package/test/live/contentProgressLive.test.js +0 -0
- package/test/live/railcontentLive.test.js +0 -0
- package/test/localStorageMock.js +0 -0
- package/test/log.js +0 -0
- package/test/mockData/mockData_fetchByRailContentIds_one_content.json +35 -0
- package/test/sanityQueryService.test.js +0 -0
- package/test/user/permissions.test.js +0 -0
- package/test/userActivity.test.js +118 -0
- package/tools/generate-index.cjs +0 -0
|
@@ -2,31 +2,386 @@
|
|
|
2
2
|
* @module User-Activity
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {fetchHandler} from
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
package/test/content.test.js
CHANGED
|
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
|
package/test/dataContext.test.js
CHANGED
|
File without changes
|
package/test/forum.test.js
CHANGED
|
File without changes
|
package/test/initializeTests.js
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/test/localStorageMock.js
CHANGED
|
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
|
+
})
|
package/tools/generate-index.cjs
CHANGED
|
File without changes
|