@vue-skuilder/db 0.1.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/README.md +26 -0
- package/dist/core/index.d.mts +3 -0
- package/dist/core/index.d.ts +3 -0
- package/dist/core/index.js +7906 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/index.mjs +7886 -0
- package/dist/core/index.mjs.map +1 -0
- package/dist/index-QMtzQI65.d.mts +734 -0
- package/dist/index-QMtzQI65.d.ts +734 -0
- package/dist/index.d.mts +133 -0
- package/dist/index.d.ts +133 -0
- package/dist/index.js +8726 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +8699 -0
- package/dist/index.mjs.map +1 -0
- package/eslint.config.mjs +20 -0
- package/package.json +47 -0
- package/src/core/bulkImport/cardProcessor.ts +165 -0
- package/src/core/bulkImport/index.ts +2 -0
- package/src/core/bulkImport/types.ts +27 -0
- package/src/core/index.ts +9 -0
- package/src/core/interfaces/adminDB.ts +27 -0
- package/src/core/interfaces/classroomDB.ts +75 -0
- package/src/core/interfaces/contentSource.ts +64 -0
- package/src/core/interfaces/courseDB.ts +139 -0
- package/src/core/interfaces/dataLayerProvider.ts +46 -0
- package/src/core/interfaces/index.ts +7 -0
- package/src/core/interfaces/navigationStrategyManager.ts +46 -0
- package/src/core/interfaces/userDB.ts +183 -0
- package/src/core/navigators/elo.ts +76 -0
- package/src/core/navigators/index.ts +57 -0
- package/src/core/readme.md +9 -0
- package/src/core/types/contentNavigationStrategy.ts +21 -0
- package/src/core/types/db.ts +7 -0
- package/src/core/types/types-legacy.ts +155 -0
- package/src/core/types/user.ts +70 -0
- package/src/core/util/index.ts +42 -0
- package/src/factory.ts +86 -0
- package/src/impl/pouch/PouchDataLayerProvider.ts +102 -0
- package/src/impl/pouch/adminDB.ts +91 -0
- package/src/impl/pouch/auth.ts +48 -0
- package/src/impl/pouch/classroomDB.ts +306 -0
- package/src/impl/pouch/clientCache.ts +19 -0
- package/src/impl/pouch/courseAPI.ts +245 -0
- package/src/impl/pouch/courseDB.ts +772 -0
- package/src/impl/pouch/courseLookupDB.ts +135 -0
- package/src/impl/pouch/index.ts +235 -0
- package/src/impl/pouch/pouchdb-setup.ts +16 -0
- package/src/impl/pouch/types.ts +7 -0
- package/src/impl/pouch/updateQueue.ts +89 -0
- package/src/impl/pouch/user-course-relDB.ts +73 -0
- package/src/impl/pouch/userDB.ts +1097 -0
- package/src/index.ts +8 -0
- package/src/study/SessionController.ts +401 -0
- package/src/study/SpacedRepetition.ts +128 -0
- package/src/study/getCardDataShape.ts +34 -0
- package/src/study/index.ts +2 -0
- package/src/util/Loggable.ts +11 -0
- package/src/util/index.ts +1 -0
- package/src/util/logger.ts +55 -0
- package/tsconfig.json +12 -0
- package/tsup.config.ts +17 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isReview,
|
|
3
|
+
StudyContentSource,
|
|
4
|
+
StudySessionFailedItem,
|
|
5
|
+
StudySessionItem,
|
|
6
|
+
StudySessionNewItem,
|
|
7
|
+
StudySessionReviewItem,
|
|
8
|
+
} from '@/impl/pouch';
|
|
9
|
+
|
|
10
|
+
import { CardRecord } from '@/core';
|
|
11
|
+
import { Loggable } from '@/util';
|
|
12
|
+
import { ScheduledCard } from '@/core/types/user';
|
|
13
|
+
|
|
14
|
+
function randomInt(min: number, max: number): number {
|
|
15
|
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface StudySessionRecord {
|
|
19
|
+
card: {
|
|
20
|
+
course_id: string;
|
|
21
|
+
card_id: string;
|
|
22
|
+
card_elo: number;
|
|
23
|
+
};
|
|
24
|
+
item: StudySessionItem;
|
|
25
|
+
records: CardRecord[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class ItemQueue<T extends StudySessionItem> {
|
|
29
|
+
private q: T[] = [];
|
|
30
|
+
private seenCardIds: string[] = [];
|
|
31
|
+
private _dequeueCount: number = 0;
|
|
32
|
+
public get dequeueCount(): number {
|
|
33
|
+
return this._dequeueCount;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
public add(item: T) {
|
|
37
|
+
if (this.seenCardIds.find((d) => d === item.cardID)) {
|
|
38
|
+
return; // do not re-add a card to the same queue
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
this.seenCardIds.push(item.cardID);
|
|
42
|
+
this.q.push(item);
|
|
43
|
+
}
|
|
44
|
+
public addAll(items: T[]) {
|
|
45
|
+
items.forEach((i) => this.add(i));
|
|
46
|
+
}
|
|
47
|
+
public get length() {
|
|
48
|
+
return this.q.length;
|
|
49
|
+
}
|
|
50
|
+
public peek(index: number): T {
|
|
51
|
+
return this.q[index];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
public dequeue(): T | null {
|
|
55
|
+
if (this.q.length !== 0) {
|
|
56
|
+
this._dequeueCount++;
|
|
57
|
+
return this.q.splice(0, 1)[0];
|
|
58
|
+
} else {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
public get toString(): string {
|
|
64
|
+
return (
|
|
65
|
+
`${typeof this.q[0]}:\n` + this.q.map((i) => `\t${i.qualifiedID}: ${i.status}`).join('\n')
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class SessionController extends Loggable {
|
|
71
|
+
_className = 'SessionController';
|
|
72
|
+
private sources: StudyContentSource[];
|
|
73
|
+
private _sessionRecord: StudySessionRecord[] = [];
|
|
74
|
+
public set sessionRecord(r: StudySessionRecord[]) {
|
|
75
|
+
this._sessionRecord = r;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private reviewQ: ItemQueue<StudySessionReviewItem> = new ItemQueue<StudySessionReviewItem>();
|
|
79
|
+
private newQ: ItemQueue<StudySessionNewItem> = new ItemQueue<StudySessionNewItem>();
|
|
80
|
+
private failedQ: ItemQueue<StudySessionFailedItem> = new ItemQueue<StudySessionFailedItem>();
|
|
81
|
+
private _currentCard: StudySessionItem | null = null;
|
|
82
|
+
/**
|
|
83
|
+
* Indicates whether the session has been initialized - eg, the
|
|
84
|
+
* queues have been populated.
|
|
85
|
+
*/
|
|
86
|
+
private _isInitialized: boolean = false;
|
|
87
|
+
|
|
88
|
+
private startTime: Date;
|
|
89
|
+
private endTime: Date;
|
|
90
|
+
private _secondsRemaining: number;
|
|
91
|
+
public get secondsRemaining(): number {
|
|
92
|
+
return this._secondsRemaining;
|
|
93
|
+
}
|
|
94
|
+
public get report(): string {
|
|
95
|
+
return `${this.reviewQ.dequeueCount} reviews, ${this.newQ.dequeueCount} new cards`;
|
|
96
|
+
}
|
|
97
|
+
public get detailedReport(): string {
|
|
98
|
+
return this.newQ.toString + '\n' + this.reviewQ.toString + '\n' + this.failedQ.toString;
|
|
99
|
+
}
|
|
100
|
+
// @ts-expect-error NodeJS.Timeout type not available in browser context
|
|
101
|
+
private _intervalHandle: NodeJS.Timeout;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
*
|
|
105
|
+
*/
|
|
106
|
+
constructor(sources: StudyContentSource[], time: number) {
|
|
107
|
+
super();
|
|
108
|
+
|
|
109
|
+
this.sources = sources;
|
|
110
|
+
this.startTime = new Date();
|
|
111
|
+
this._secondsRemaining = time;
|
|
112
|
+
this.endTime = new Date(this.startTime.valueOf() + 1000 * this._secondsRemaining);
|
|
113
|
+
|
|
114
|
+
this.log(`Session constructed:
|
|
115
|
+
startTime: ${this.startTime}
|
|
116
|
+
endTime: ${this.endTime}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private tick() {
|
|
120
|
+
this._secondsRemaining = Math.floor((this.endTime.valueOf() - Date.now()) / 1000);
|
|
121
|
+
// this.log(this.secondsRemaining);
|
|
122
|
+
|
|
123
|
+
if (this._secondsRemaining === 0) {
|
|
124
|
+
clearInterval(this._intervalHandle);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Returns a rough, erring toward conservative, guess at
|
|
130
|
+
* the amount of time the failed cards queue will require
|
|
131
|
+
* to clean up.
|
|
132
|
+
*
|
|
133
|
+
* (seconds)
|
|
134
|
+
*/
|
|
135
|
+
private estimateCleanupTime(): number {
|
|
136
|
+
let time: number = 0;
|
|
137
|
+
for (let i = 0; i < this.failedQ.length; i++) {
|
|
138
|
+
const c = this.failedQ.peek(i);
|
|
139
|
+
// this.log(`Failed card ${c.qualifiedID} found`)
|
|
140
|
+
|
|
141
|
+
const record = this._sessionRecord.find((r) => r.item.cardID === c.cardID);
|
|
142
|
+
let cardTime = 0;
|
|
143
|
+
|
|
144
|
+
if (record) {
|
|
145
|
+
// this.log(`Card Record Found...`);
|
|
146
|
+
for (let j = 0; j < record.records.length; j++) {
|
|
147
|
+
cardTime += record.records[j].timeSpent;
|
|
148
|
+
}
|
|
149
|
+
cardTime = cardTime / record.records.length;
|
|
150
|
+
time += cardTime;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const ret: number = time / 1000;
|
|
155
|
+
this.log(`Failed card cleanup estimate: ${Math.round(ret)}`);
|
|
156
|
+
return ret;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Extremely rough, conservative, estimate of amound of time to complete
|
|
161
|
+
* all scheduled reviews
|
|
162
|
+
*/
|
|
163
|
+
private estimateReviewTime(): number {
|
|
164
|
+
const ret = 5 * this.reviewQ.length;
|
|
165
|
+
this.log(`Review card time estimate: ${ret}`);
|
|
166
|
+
return ret;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
public async prepareSession() {
|
|
170
|
+
try {
|
|
171
|
+
await Promise.all([this.getScheduledReviews(), this.getNewCards()]);
|
|
172
|
+
} catch (e) {
|
|
173
|
+
this.error('Error preparing study session:', e);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
this._isInitialized = true;
|
|
177
|
+
|
|
178
|
+
this._intervalHandle = setInterval(() => {
|
|
179
|
+
this.tick();
|
|
180
|
+
}, 1000);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
public addTime(seconds: number) {
|
|
184
|
+
this.endTime = new Date(this.endTime.valueOf() + 1000 * seconds);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
public get failedCount(): number {
|
|
188
|
+
return this.failedQ.length;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
public toString() {
|
|
192
|
+
return `Session: ${this.reviewQ.length} Reviews, ${this.newQ.length} New, ${this.failedQ.length} failed`;
|
|
193
|
+
}
|
|
194
|
+
public reportString() {
|
|
195
|
+
return `${this.reviewQ.dequeueCount} Reviews, ${this.newQ.dequeueCount} New, ${this.failedQ.dequeueCount} failed`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private async getScheduledReviews() {
|
|
199
|
+
const reviews = await Promise.all(
|
|
200
|
+
this.sources.map((c) =>
|
|
201
|
+
c.getPendingReviews().catch((error) => {
|
|
202
|
+
this.error(`Failed to get reviews for source ${c}:`, error);
|
|
203
|
+
return [];
|
|
204
|
+
})
|
|
205
|
+
)
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const dueCards: (StudySessionReviewItem & ScheduledCard)[] = [];
|
|
209
|
+
|
|
210
|
+
while (reviews.length != 0 && reviews.some((r) => r.length > 0)) {
|
|
211
|
+
// pick a random review source
|
|
212
|
+
const index = randomInt(0, reviews.length - 1);
|
|
213
|
+
const source = reviews[index];
|
|
214
|
+
|
|
215
|
+
if (source.length === 0) {
|
|
216
|
+
reviews.splice(index, 1);
|
|
217
|
+
continue;
|
|
218
|
+
} else {
|
|
219
|
+
dueCards.push(source.shift()!);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let report = 'Review session created with:\n';
|
|
224
|
+
for (let i = 0; i < dueCards.length; i++) {
|
|
225
|
+
const card = dueCards[i];
|
|
226
|
+
this.reviewQ.add(card);
|
|
227
|
+
report += `\t${card.qualifiedID}}\n`;
|
|
228
|
+
}
|
|
229
|
+
this.log(report);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private async getNewCards(n: number = 10) {
|
|
233
|
+
const perCourse = Math.ceil(n / this.sources.length);
|
|
234
|
+
const newContent = await Promise.all(this.sources.map((c) => c.getNewCards(perCourse)));
|
|
235
|
+
|
|
236
|
+
// [ ] is this a noop?
|
|
237
|
+
newContent.forEach((newContentFromSource) => {
|
|
238
|
+
newContentFromSource.filter((c) => {
|
|
239
|
+
return this._sessionRecord.find((record) => record.card.card_id === c.cardID) === undefined;
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
while (n > 0 && newContent.some((nc) => nc.length > 0)) {
|
|
244
|
+
for (let i = 0; i < newContent.length; i++) {
|
|
245
|
+
if (newContent[i].length > 0) {
|
|
246
|
+
const item = newContent[i].splice(0, 1)[0];
|
|
247
|
+
this.log(`Adding new card: ${item.qualifiedID}`);
|
|
248
|
+
this.newQ.add(item);
|
|
249
|
+
n--;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private nextNewCard(): StudySessionNewItem | null {
|
|
256
|
+
const item = this.newQ.dequeue();
|
|
257
|
+
|
|
258
|
+
// queue some more content if we are getting low
|
|
259
|
+
if (this._isInitialized && this.newQ.length < 5) {
|
|
260
|
+
void this.getNewCards();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return item;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
public nextCard(
|
|
267
|
+
action:
|
|
268
|
+
| 'dismiss-success'
|
|
269
|
+
| 'dismiss-failed'
|
|
270
|
+
| 'marked-failed'
|
|
271
|
+
| 'dismiss-error' = 'dismiss-success'
|
|
272
|
+
): StudySessionItem | null {
|
|
273
|
+
// dismiss (or sort to failedQ) the current card
|
|
274
|
+
this.dismissCurrentCard(action);
|
|
275
|
+
|
|
276
|
+
const choice = Math.random();
|
|
277
|
+
let newBound: number = 0.1;
|
|
278
|
+
let reviewBound: number = 0.75;
|
|
279
|
+
|
|
280
|
+
if (this.reviewQ.length === 0 && this.failedQ.length === 0 && this.newQ.length === 0) {
|
|
281
|
+
// all queues empty - session is over (and course is complete?)
|
|
282
|
+
this._currentCard = null;
|
|
283
|
+
return this._currentCard;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (this._secondsRemaining < 2 && this.failedQ.length === 0) {
|
|
287
|
+
// session is over!
|
|
288
|
+
this._currentCard = null;
|
|
289
|
+
return this._currentCard;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// supply new cards at start of session
|
|
293
|
+
if (this.newQ.dequeueCount < this.sources.length && this.newQ.length) {
|
|
294
|
+
this._currentCard = this.nextNewCard();
|
|
295
|
+
return this._currentCard;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const cleanupTime = this.estimateCleanupTime();
|
|
299
|
+
const reviewTime = this.estimateReviewTime();
|
|
300
|
+
const availableTime = this._secondsRemaining - (cleanupTime + reviewTime);
|
|
301
|
+
|
|
302
|
+
// if time-remaing vs (reviewQ + failureQ) looks good,
|
|
303
|
+
// lean toward newQ
|
|
304
|
+
if (availableTime > 20) {
|
|
305
|
+
newBound = 0.5;
|
|
306
|
+
reviewBound = 0.9;
|
|
307
|
+
}
|
|
308
|
+
// else if time-remaining vs failureQ looks good,
|
|
309
|
+
// lean toward reviewQ
|
|
310
|
+
else if (this._secondsRemaining - cleanupTime > 20) {
|
|
311
|
+
newBound = 0.05;
|
|
312
|
+
reviewBound = 0.9;
|
|
313
|
+
}
|
|
314
|
+
// else (time-remaining vs failureQ looks bad!)
|
|
315
|
+
// lean heavily toward failureQ
|
|
316
|
+
else {
|
|
317
|
+
newBound = 0.01;
|
|
318
|
+
reviewBound = 0.1;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// prevent (unless no other option available) re-display of
|
|
322
|
+
// most recent card
|
|
323
|
+
if (this.failedQ.length === 1 && action === 'marked-failed') {
|
|
324
|
+
reviewBound = 1;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// exclude possibility of drawing from empty queues
|
|
328
|
+
if (this.failedQ.length === 0) {
|
|
329
|
+
reviewBound = 1;
|
|
330
|
+
}
|
|
331
|
+
if (this.reviewQ.length === 0) {
|
|
332
|
+
newBound = reviewBound;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (choice < newBound && this.newQ.length) {
|
|
336
|
+
this._currentCard = this.nextNewCard();
|
|
337
|
+
} else if (choice < reviewBound && this.reviewQ.length) {
|
|
338
|
+
this._currentCard = this.reviewQ.dequeue();
|
|
339
|
+
} else if (this.failedQ.length) {
|
|
340
|
+
this._currentCard = this.failedQ.dequeue();
|
|
341
|
+
} else {
|
|
342
|
+
this.log(`No more cards available for the session!`);
|
|
343
|
+
this._currentCard = null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return this._currentCard;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private dismissCurrentCard(
|
|
350
|
+
action:
|
|
351
|
+
| 'dismiss-success'
|
|
352
|
+
| 'dismiss-failed'
|
|
353
|
+
| 'marked-failed'
|
|
354
|
+
| 'dismiss-error' = 'dismiss-success'
|
|
355
|
+
) {
|
|
356
|
+
if (this._currentCard) {
|
|
357
|
+
// this.log(`Running dismissCurrentCard on ${this._currentCard!.qualifiedID}`);
|
|
358
|
+
// if (action.includes('dismiss')) {
|
|
359
|
+
// if (this._currentCard.status === 'review' ||
|
|
360
|
+
// this._currentCard.status === 'failed-review') {
|
|
361
|
+
// removeScheduledCardReview(this.user.getUsername(),
|
|
362
|
+
// (this._currentCard as StudySessionReviewItem).reviewID);
|
|
363
|
+
// this.log(`Dismissed review card: ${this._currentCard.qualifiedID}`)
|
|
364
|
+
// }
|
|
365
|
+
// }
|
|
366
|
+
|
|
367
|
+
if (action === 'dismiss-success') {
|
|
368
|
+
// schedule a review - currently done in Study.vue
|
|
369
|
+
} else if (action === 'marked-failed') {
|
|
370
|
+
let failedItem: StudySessionFailedItem;
|
|
371
|
+
|
|
372
|
+
if (isReview(this._currentCard)) {
|
|
373
|
+
failedItem = {
|
|
374
|
+
cardID: this._currentCard.cardID,
|
|
375
|
+
courseID: this._currentCard.courseID,
|
|
376
|
+
qualifiedID: this._currentCard.qualifiedID,
|
|
377
|
+
contentSourceID: this._currentCard.contentSourceID,
|
|
378
|
+
contentSourceType: this._currentCard.contentSourceType,
|
|
379
|
+
status: 'failed-review',
|
|
380
|
+
reviewID: this._currentCard.reviewID,
|
|
381
|
+
};
|
|
382
|
+
} else {
|
|
383
|
+
failedItem = {
|
|
384
|
+
cardID: this._currentCard.cardID,
|
|
385
|
+
courseID: this._currentCard.courseID,
|
|
386
|
+
qualifiedID: this._currentCard.qualifiedID,
|
|
387
|
+
contentSourceID: this._currentCard.contentSourceID,
|
|
388
|
+
contentSourceType: this._currentCard.contentSourceType,
|
|
389
|
+
status: 'failed-new',
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
this.failedQ.add(failedItem);
|
|
394
|
+
} else if (action === 'dismiss-error') {
|
|
395
|
+
// some error logging?
|
|
396
|
+
} else if (action === 'dismiss-failed') {
|
|
397
|
+
// handled by Study.vue
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { CardHistory, CardRecord, QuestionRecord } from '@/core/types/types-legacy';
|
|
2
|
+
import { areQuestionRecords } from '@/core/util';
|
|
3
|
+
import { Update } from '@/impl/pouch/updateQueue';
|
|
4
|
+
import moment from 'moment';
|
|
5
|
+
import { logger } from '../util/logger';
|
|
6
|
+
|
|
7
|
+
type Moment = moment.Moment;
|
|
8
|
+
const duration = moment.duration;
|
|
9
|
+
|
|
10
|
+
export interface DocumentUpdater {
|
|
11
|
+
update<T extends PouchDB.Core.Document<object>>(id: string, update: Update<T>): Promise<T>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Returns the minimum number of seconds that should pass before a
|
|
16
|
+
* card is redisplayed for review / practice.
|
|
17
|
+
*
|
|
18
|
+
* @param cardHistory The user's history working with the given card
|
|
19
|
+
*/
|
|
20
|
+
export function newInterval(user: DocumentUpdater, cardHistory: CardHistory<CardRecord>): number {
|
|
21
|
+
if (areQuestionRecords(cardHistory)) {
|
|
22
|
+
return newQuestionInterval(user, cardHistory);
|
|
23
|
+
} else {
|
|
24
|
+
return 100000; // random - replace
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function newQuestionInterval(user: DocumentUpdater, cardHistory: CardHistory<QuestionRecord>) {
|
|
29
|
+
const records = cardHistory.records;
|
|
30
|
+
const currentAttempt = records[records.length - 1];
|
|
31
|
+
const lastInterval: number = lastSuccessfulInterval(records);
|
|
32
|
+
|
|
33
|
+
if (lastInterval > cardHistory.bestInterval) {
|
|
34
|
+
cardHistory.bestInterval = lastInterval;
|
|
35
|
+
// update bestInterval on cardHistory in db
|
|
36
|
+
void user.update<CardHistory<QuestionRecord>>(cardHistory._id, {
|
|
37
|
+
bestInterval: lastInterval,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (currentAttempt.isCorrect) {
|
|
42
|
+
const skill = currentAttempt.performance as number;
|
|
43
|
+
logger.debug(`Demontrated skill: \t${skill}`);
|
|
44
|
+
const interval: number = lastInterval * (0.75 + skill);
|
|
45
|
+
cardHistory.lapses = getLapses(cardHistory.records);
|
|
46
|
+
cardHistory.streak = getStreak(cardHistory.records);
|
|
47
|
+
|
|
48
|
+
if (
|
|
49
|
+
cardHistory.lapses &&
|
|
50
|
+
cardHistory.streak &&
|
|
51
|
+
cardHistory.bestInterval &&
|
|
52
|
+
(cardHistory.lapses >= 0 || cardHistory.streak >= 0)
|
|
53
|
+
) {
|
|
54
|
+
// weighted average of best-ever performance vs current performance, based
|
|
55
|
+
// on how often the card has been failed, and the current streak of success
|
|
56
|
+
const ret =
|
|
57
|
+
(cardHistory.lapses * interval + cardHistory.streak * cardHistory.bestInterval) /
|
|
58
|
+
(cardHistory.lapses + cardHistory.streak);
|
|
59
|
+
logger.debug(`Weighted average interval calculation:
|
|
60
|
+
\t(${cardHistory.lapses} * ${interval} + ${cardHistory.streak} * ${cardHistory.bestInterval}) / (${cardHistory.lapses} + ${cardHistory.streak}) = ${ret}`);
|
|
61
|
+
return ret;
|
|
62
|
+
} else {
|
|
63
|
+
return interval;
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Returns the amount of time, in seconds, of the most recent successful
|
|
72
|
+
* interval for this card. An interval is successful if the user answers
|
|
73
|
+
* a question correctly on the first attempt.
|
|
74
|
+
*
|
|
75
|
+
* @param cardHistory The record of user attempts with the question
|
|
76
|
+
*/
|
|
77
|
+
function lastSuccessfulInterval(cardHistory: QuestionRecord[]): number {
|
|
78
|
+
for (let i = cardHistory.length - 1; i >= 1; i--) {
|
|
79
|
+
if (cardHistory[i].priorAttemps === 0 && cardHistory[i].isCorrect) {
|
|
80
|
+
const lastInterval = secondsBetween(cardHistory[i - 1].timeStamp, cardHistory[i].timeStamp);
|
|
81
|
+
const ret = Math.max(lastInterval, 20 * 60 * 60);
|
|
82
|
+
logger.debug(`Last interval w/ this card was: ${lastInterval}s, returning ${ret}s`);
|
|
83
|
+
return ret;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return getInitialInterval(cardHistory); // used as a magic number here - indicates no prior intervals
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function getStreak(records: QuestionRecord[]): number {
|
|
91
|
+
let streak = 0;
|
|
92
|
+
let index = records.length - 1;
|
|
93
|
+
|
|
94
|
+
while (index >= 0 && records[index].isCorrect) {
|
|
95
|
+
index--;
|
|
96
|
+
streak++;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return streak;
|
|
100
|
+
}
|
|
101
|
+
function getLapses(records: QuestionRecord[]): number {
|
|
102
|
+
return records.filter((r) => r.isCorrect === false).length;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function getInitialInterval(cardHistory: QuestionRecord[]): number {
|
|
106
|
+
logger.warn(`history of length: ${cardHistory.length} ignored!`);
|
|
107
|
+
|
|
108
|
+
// todo make this a data-driven service, relying on:
|
|
109
|
+
// - global experience w/ the card (ie, what interval
|
|
110
|
+
// seems to be working well across the population)
|
|
111
|
+
// - the individual user (how do they respond in general
|
|
112
|
+
// when compared to the population)
|
|
113
|
+
return 60 * 60 * 24 * 3; // 3 days
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Returns the time in seconds between two Moment objects
|
|
118
|
+
* @param start The first time
|
|
119
|
+
* @param end The second time
|
|
120
|
+
*/
|
|
121
|
+
function secondsBetween(start: Moment, end: Moment): number {
|
|
122
|
+
// assertion guard against mis-typed json from database
|
|
123
|
+
start = moment(start);
|
|
124
|
+
end = moment(end);
|
|
125
|
+
const ret = duration(end.diff(start)).asSeconds();
|
|
126
|
+
// console.log(`From start: ${start} to finish: ${end} is ${ret} seconds`);
|
|
127
|
+
return ret;
|
|
128
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { allCourses } from '@vue-skuilder/courses';
|
|
2
|
+
import { log, NameSpacer, CourseConfig, DataShape } from '@vue-skuilder/common';
|
|
3
|
+
import { CardData, DisplayableData } from '@/core';
|
|
4
|
+
import { getCourseDB } from '@/impl/pouch/courseAPI';
|
|
5
|
+
|
|
6
|
+
export async function getCardDataShape(courseID: string, cardID: string) {
|
|
7
|
+
const dataShapes: DataShape[] = [];
|
|
8
|
+
allCourses.courses.forEach((course) => {
|
|
9
|
+
course.questions.forEach((question) => {
|
|
10
|
+
question.dataShapes.forEach((ds) => {
|
|
11
|
+
dataShapes.push(ds);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// log(`Datashapes: ${JSON.stringify(dataShapes)}`);
|
|
17
|
+
const db = getCourseDB(courseID);
|
|
18
|
+
const card = await db.get<CardData>(cardID);
|
|
19
|
+
const disp = await db.get<DisplayableData>(card.id_displayable_data[0]);
|
|
20
|
+
const cfg = await db.get<CourseConfig>('CourseConfig');
|
|
21
|
+
|
|
22
|
+
// log(`Config: ${JSON.stringify(cfg)}`);
|
|
23
|
+
// log(`DisplayableData: ${JSON.stringify(disp)}`);
|
|
24
|
+
const dataShape = cfg!.dataShapes.find((ds) => {
|
|
25
|
+
return ds.name === disp.id_datashape;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const ret = dataShapes.find((ds) => {
|
|
29
|
+
return ds.name === NameSpacer.getDataShapeDescriptor(dataShape!.name).dataShape;
|
|
30
|
+
})!;
|
|
31
|
+
|
|
32
|
+
log(`Returning ${JSON.stringify(ret)}`);
|
|
33
|
+
return ret;
|
|
34
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export abstract class Loggable {
|
|
2
|
+
protected abstract readonly _className: string;
|
|
3
|
+
protected log(...args: unknown[]): void {
|
|
4
|
+
// eslint-disable-next-line no-console
|
|
5
|
+
console.log(`LOG-${this._className}@${new Date()}:`, ...args);
|
|
6
|
+
}
|
|
7
|
+
protected error(...args: unknown[]): void {
|
|
8
|
+
// eslint-disable-next-line no-console
|
|
9
|
+
console.error(`ERROR-${this._className}@${new Date()}:`, ...args);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './Loggable';
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple logging utility for @vue-skuilder/db package
|
|
3
|
+
*
|
|
4
|
+
* This utility provides environment-aware logging with ESLint suppressions
|
|
5
|
+
* to resolve console statement violations while maintaining logging functionality.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const isDevelopment = typeof process !== 'undefined' && process.env.NODE_ENV === 'development';
|
|
9
|
+
const _isBrowser = typeof window !== 'undefined';
|
|
10
|
+
|
|
11
|
+
export const logger = {
|
|
12
|
+
/**
|
|
13
|
+
* Debug-level logging - only shown in development
|
|
14
|
+
*/
|
|
15
|
+
debug: (message: string, ...args: any[]): void => {
|
|
16
|
+
if (isDevelopment) {
|
|
17
|
+
// eslint-disable-next-line no-console
|
|
18
|
+
console.debug(`[DB:DEBUG] ${message}`, ...args);
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Info-level logging - general information
|
|
24
|
+
*/
|
|
25
|
+
info: (message: string, ...args: any[]): void => {
|
|
26
|
+
// eslint-disable-next-line no-console
|
|
27
|
+
console.info(`[DB:INFO] ${message}`, ...args);
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Warning-level logging - potential issues
|
|
32
|
+
*/
|
|
33
|
+
warn: (message: string, ...args: any[]): void => {
|
|
34
|
+
// eslint-disable-next-line no-console
|
|
35
|
+
console.warn(`[DB:WARN] ${message}`, ...args);
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Error-level logging - serious problems
|
|
40
|
+
*/
|
|
41
|
+
error: (message: string, ...args: any[]): void => {
|
|
42
|
+
// eslint-disable-next-line no-console
|
|
43
|
+
console.error(`[DB:ERROR] ${message}`, ...args);
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Log function for backward compatibility with existing log() usage
|
|
48
|
+
*/
|
|
49
|
+
log: (message: string, ...args: any[]): void => {
|
|
50
|
+
if (isDevelopment) {
|
|
51
|
+
// eslint-disable-next-line no-console
|
|
52
|
+
console.log(`[DB:LOG] ${message}`, ...args);
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
};
|
package/tsconfig.json
ADDED
package/tsup.config.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineConfig } from 'tsup';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
entry: [
|
|
5
|
+
'src/index.ts',
|
|
6
|
+
'src/core/index.ts',
|
|
7
|
+
'src/pouch/index.ts'
|
|
8
|
+
],
|
|
9
|
+
format: ['cjs', 'esm'],
|
|
10
|
+
dts: true,
|
|
11
|
+
splitting: false,
|
|
12
|
+
sourcemap: true,
|
|
13
|
+
clean: true,
|
|
14
|
+
outExtension: ({ format }) => ({
|
|
15
|
+
js: format === 'esm' ? '.mjs' : '.js'
|
|
16
|
+
})
|
|
17
|
+
});
|