@vue-skuilder/db 0.1.11-9 → 0.1.12
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/dist/core/index.d.mts +7 -6
- package/dist/core/index.d.ts +7 -6
- package/dist/core/index.js +358 -87
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +358 -87
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-DqtNroSh.d.ts → dataLayerProvider-BiP3kWix.d.mts} +8 -1
- package/dist/{dataLayerProvider-BInqI_RF.d.mts → dataLayerProvider-DSdeyRT3.d.ts} +8 -1
- package/dist/impl/couch/index.d.mts +19 -7
- package/dist/impl/couch/index.d.ts +19 -7
- package/dist/impl/couch/index.js +375 -100
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +374 -99
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.mts +23 -8
- package/dist/impl/static/index.d.ts +23 -8
- package/dist/impl/static/index.js +289 -85
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +289 -85
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/{index-CUNnL38E.d.mts → index-Bmll7Xse.d.mts} +1 -1
- package/dist/{index-CLL31bEy.d.ts → index-CD8BZz2k.d.ts} +1 -1
- package/dist/index.d.mts +123 -20
- package/dist/index.d.ts +123 -20
- package/dist/index.js +1133 -343
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1137 -343
- package/dist/index.mjs.map +1 -1
- package/dist/pouch/index.d.mts +1 -0
- package/dist/pouch/index.d.ts +1 -0
- package/dist/pouch/index.js +49 -0
- package/dist/pouch/index.js.map +1 -0
- package/dist/pouch/index.mjs +16 -0
- package/dist/pouch/index.mjs.map +1 -0
- package/dist/{types-BefDGkKa.d.ts → types-CewsN87z.d.ts} +1 -1
- package/dist/{types-DC-ckZug.d.mts → types-Dbp5DaRR.d.mts} +1 -1
- package/dist/{types-legacy-Birv-Jx6.d.mts → types-legacy-6ettoclI.d.mts} +17 -2
- package/dist/{types-legacy-Birv-Jx6.d.ts → types-legacy-6ettoclI.d.ts} +17 -2
- package/dist/{userDB-DusL7OXe.d.ts → userDB-C4yyAnpp.d.mts} +89 -56
- package/dist/{userDB-C33Hzjgn.d.mts → userDB-CD6s6ZCp.d.ts} +89 -56
- package/dist/util/packer/index.d.mts +3 -3
- package/dist/util/packer/index.d.ts +3 -3
- package/package.json +3 -3
- package/src/core/interfaces/contentSource.ts +3 -2
- package/src/core/interfaces/courseDB.ts +26 -3
- package/src/core/interfaces/dataLayerProvider.ts +9 -1
- package/src/core/interfaces/userDB.ts +80 -64
- package/src/core/navigators/elo.ts +10 -7
- package/src/core/navigators/hardcodedOrder.ts +64 -0
- package/src/core/navigators/index.ts +2 -1
- package/src/core/types/contentNavigationStrategy.ts +2 -1
- package/src/core/types/types-legacy.ts +7 -2
- package/src/impl/common/BaseUserDB.ts +60 -14
- package/src/impl/couch/CouchDBSyncStrategy.ts +2 -2
- package/src/impl/couch/PouchDataLayerProvider.ts +21 -0
- package/src/impl/couch/adminDB.ts +2 -2
- package/src/impl/couch/auth.ts +13 -4
- package/src/impl/couch/classroomDB.ts +10 -12
- package/src/impl/couch/courseAPI.ts +2 -2
- package/src/impl/couch/courseDB.ts +204 -38
- package/src/impl/couch/courseLookupDB.ts +4 -3
- package/src/impl/couch/index.ts +36 -4
- package/src/impl/couch/pouchdb-setup.ts +3 -3
- package/src/impl/couch/updateQueue.ts +59 -36
- package/src/impl/static/StaticDataLayerProvider.ts +68 -17
- package/src/impl/static/courseDB.ts +64 -20
- package/src/impl/static/coursesDB.ts +10 -6
- package/src/pouch/index.ts +2 -0
- package/src/study/ItemQueue.ts +58 -0
- package/src/study/SessionController.ts +182 -111
- package/src/study/SpacedRepetition.ts +1 -1
- package/src/study/services/CardHydrationService.ts +153 -0
- package/src/study/services/EloService.ts +85 -0
- package/src/study/services/ResponseProcessor.ts +224 -0
- package/src/study/services/SrsService.ts +44 -0
- package/tsup.config.ts +1 -0
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { SrsService } from './services/SrsService';
|
|
2
|
+
import { EloService } from './services/EloService';
|
|
3
|
+
import { ResponseProcessor } from './services/ResponseProcessor';
|
|
4
|
+
import { CardHydrationService, HydratedCard } from './services/CardHydrationService';
|
|
5
|
+
import { ItemQueue } from './ItemQueue';
|
|
1
6
|
import {
|
|
2
7
|
isReview,
|
|
3
8
|
StudyContentSource,
|
|
@@ -7,7 +12,7 @@ import {
|
|
|
7
12
|
StudySessionReviewItem,
|
|
8
13
|
} from '@db/impl/couch';
|
|
9
14
|
|
|
10
|
-
import { CardRecord } from '@db/core';
|
|
15
|
+
import { CardRecord, CardHistory, CourseRegistrationDoc } from '@db/core';
|
|
11
16
|
import { Loggable } from '@db/util';
|
|
12
17
|
import { ScheduledCard } from '@db/core/types/user';
|
|
13
18
|
|
|
@@ -25,65 +30,53 @@ export interface StudySessionRecord {
|
|
|
25
30
|
records: CardRecord[];
|
|
26
31
|
}
|
|
27
32
|
|
|
28
|
-
|
|
29
|
-
private q: T[] = [];
|
|
30
|
-
private seenCardIds: string[] = [];
|
|
31
|
-
private _dequeueCount: number = 0;
|
|
32
|
-
public get dequeueCount(): number {
|
|
33
|
-
return this._dequeueCount;
|
|
34
|
-
}
|
|
33
|
+
import { DataLayerProvider } from '@db/core';
|
|
35
34
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
export type SessionAction =
|
|
36
|
+
| 'dismiss-success'
|
|
37
|
+
| 'dismiss-failed'
|
|
38
|
+
| 'marked-failed'
|
|
39
|
+
| 'dismiss-error';
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
}
|
|
41
|
+
export interface ResponseResult {
|
|
42
|
+
// Navigation
|
|
43
|
+
nextCardAction: Exclude<SessionAction, 'dismiss-error'> | 'none';
|
|
44
|
+
shouldLoadNextCard: boolean;
|
|
53
45
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return this.q.splice(0, 1)[0];
|
|
58
|
-
} else {
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
46
|
+
// UI Data (let view decide how to render)
|
|
47
|
+
isCorrect: boolean;
|
|
48
|
+
performanceScore?: number; // for shadow color calculation
|
|
62
49
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
`${typeof this.q[0]}:\n` + this.q.map((i) => `\t${i.qualifiedID}: ${i.status}`).join('\n')
|
|
66
|
-
);
|
|
67
|
-
}
|
|
50
|
+
// Cleanup
|
|
51
|
+
shouldClearFeedbackShadow: boolean;
|
|
68
52
|
}
|
|
69
53
|
|
|
70
|
-
|
|
54
|
+
interface SessionServices {
|
|
55
|
+
response: ResponseProcessor;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class SessionController<TView = unknown> extends Loggable {
|
|
71
59
|
_className = 'SessionController';
|
|
60
|
+
|
|
61
|
+
public services: SessionServices;
|
|
62
|
+
private srsService: SrsService;
|
|
63
|
+
private eloService: EloService;
|
|
64
|
+
private hydrationService: CardHydrationService<TView>;
|
|
65
|
+
|
|
72
66
|
private sources: StudyContentSource[];
|
|
67
|
+
// dataLayer and getViewComponent now injected into CardHydrationService
|
|
73
68
|
private _sessionRecord: StudySessionRecord[] = [];
|
|
74
69
|
public set sessionRecord(r: StudySessionRecord[]) {
|
|
75
70
|
this._sessionRecord = r;
|
|
76
71
|
}
|
|
77
72
|
|
|
73
|
+
// Session card stores
|
|
74
|
+
private _currentCard: HydratedCard<TView> | null = null;
|
|
75
|
+
|
|
78
76
|
private reviewQ: ItemQueue<StudySessionReviewItem> = new ItemQueue<StudySessionReviewItem>();
|
|
79
77
|
private newQ: ItemQueue<StudySessionNewItem> = new ItemQueue<StudySessionNewItem>();
|
|
80
78
|
private failedQ: ItemQueue<StudySessionFailedItem> = new ItemQueue<StudySessionFailedItem>();
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Indicates whether the session has been initialized - eg, the
|
|
84
|
-
* queues have been populated.
|
|
85
|
-
*/
|
|
86
|
-
private _isInitialized: boolean = false;
|
|
79
|
+
// END Session card stores
|
|
87
80
|
|
|
88
81
|
private startTime: Date;
|
|
89
82
|
private endTime: Date;
|
|
@@ -103,9 +96,29 @@ export class SessionController extends Loggable {
|
|
|
103
96
|
/**
|
|
104
97
|
*
|
|
105
98
|
*/
|
|
106
|
-
constructor(
|
|
99
|
+
constructor(
|
|
100
|
+
sources: StudyContentSource[],
|
|
101
|
+
time: number,
|
|
102
|
+
dataLayer: DataLayerProvider,
|
|
103
|
+
getViewComponent: (viewId: string) => TView
|
|
104
|
+
) {
|
|
107
105
|
super();
|
|
108
106
|
|
|
107
|
+
this.srsService = new SrsService(dataLayer.getUserDB());
|
|
108
|
+
this.eloService = new EloService(dataLayer, dataLayer.getUserDB());
|
|
109
|
+
|
|
110
|
+
this.hydrationService = new CardHydrationService<TView>(
|
|
111
|
+
getViewComponent,
|
|
112
|
+
(courseId: string) => dataLayer.getCourseDB(courseId),
|
|
113
|
+
() => this._selectNextItemToHydrate(),
|
|
114
|
+
(item: StudySessionItem) => this.removeItemFromQueue(item),
|
|
115
|
+
() => this.hasAvailableCards()
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
this.services = {
|
|
119
|
+
response: new ResponseProcessor(this.srsService, this.eloService),
|
|
120
|
+
};
|
|
121
|
+
|
|
109
122
|
this.sources = sources;
|
|
110
123
|
this.startTime = new Date();
|
|
111
124
|
this._secondsRemaining = time;
|
|
@@ -120,7 +133,7 @@ export class SessionController extends Loggable {
|
|
|
120
133
|
this._secondsRemaining = Math.floor((this.endTime.valueOf() - Date.now()) / 1000);
|
|
121
134
|
// this.log(this.secondsRemaining);
|
|
122
135
|
|
|
123
|
-
if (this._secondsRemaining
|
|
136
|
+
if (this._secondsRemaining <= 0) {
|
|
124
137
|
clearInterval(this._intervalHandle);
|
|
125
138
|
}
|
|
126
139
|
}
|
|
@@ -173,7 +186,7 @@ export class SessionController extends Loggable {
|
|
|
173
186
|
this.error('Error preparing study session:', e);
|
|
174
187
|
}
|
|
175
188
|
|
|
176
|
-
this.
|
|
189
|
+
await this.hydrationService.ensureHydratedCards();
|
|
177
190
|
|
|
178
191
|
this._intervalHandle = setInterval(() => {
|
|
179
192
|
this.tick();
|
|
@@ -221,11 +234,8 @@ export class SessionController extends Loggable {
|
|
|
221
234
|
}
|
|
222
235
|
|
|
223
236
|
let report = 'Review session created with:\n';
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
this.reviewQ.add(card);
|
|
227
|
-
report += `\t${card.qualifiedID}}\n`;
|
|
228
|
-
}
|
|
237
|
+
this.reviewQ.addAll(dueCards, (c) => c.cardID);
|
|
238
|
+
report += dueCards.map((card) => `Card ${card.courseID}::${card.cardID} `).join('\n');
|
|
229
239
|
this.log(report);
|
|
230
240
|
}
|
|
231
241
|
|
|
@@ -244,55 +254,41 @@ export class SessionController extends Loggable {
|
|
|
244
254
|
for (let i = 0; i < newContent.length; i++) {
|
|
245
255
|
if (newContent[i].length > 0) {
|
|
246
256
|
const item = newContent[i].splice(0, 1)[0];
|
|
247
|
-
this.log(`Adding new card: ${item.
|
|
248
|
-
this.newQ.add(item);
|
|
257
|
+
this.log(`Adding new card: ${item.courseID}::${item.cardID}`);
|
|
258
|
+
this.newQ.add(item, item.cardID);
|
|
249
259
|
n--;
|
|
250
260
|
}
|
|
251
261
|
}
|
|
252
262
|
}
|
|
253
263
|
}
|
|
254
264
|
|
|
255
|
-
private
|
|
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
|
-
|
|
265
|
+
private _selectNextItemToHydrate(): StudySessionItem | null {
|
|
276
266
|
const choice = Math.random();
|
|
277
267
|
let newBound: number = 0.1;
|
|
278
268
|
let reviewBound: number = 0.75;
|
|
279
269
|
|
|
280
270
|
if (this.reviewQ.length === 0 && this.failedQ.length === 0 && this.newQ.length === 0) {
|
|
281
271
|
// all queues empty - session is over (and course is complete?)
|
|
282
|
-
|
|
283
|
-
return this._currentCard;
|
|
272
|
+
return null;
|
|
284
273
|
}
|
|
285
274
|
|
|
286
275
|
if (this._secondsRemaining < 2 && this.failedQ.length === 0) {
|
|
287
276
|
// session is over!
|
|
288
|
-
|
|
289
|
-
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// If timer expired, only return failed cards
|
|
281
|
+
if (this._secondsRemaining <= 0) {
|
|
282
|
+
if (this.failedQ.length > 0) {
|
|
283
|
+
return this.failedQ.peek(0);
|
|
284
|
+
} else {
|
|
285
|
+
return null; // No more failed cards, session over
|
|
286
|
+
}
|
|
290
287
|
}
|
|
291
288
|
|
|
292
289
|
// supply new cards at start of session
|
|
293
290
|
if (this.newQ.dequeueCount < this.sources.length && this.newQ.length) {
|
|
294
|
-
|
|
295
|
-
return this._currentCard;
|
|
291
|
+
return this.newQ.peek(0);
|
|
296
292
|
}
|
|
297
293
|
|
|
298
294
|
const cleanupTime = this.estimateCleanupTime();
|
|
@@ -318,12 +314,6 @@ export class SessionController extends Loggable {
|
|
|
318
314
|
reviewBound = 0.1;
|
|
319
315
|
}
|
|
320
316
|
|
|
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
317
|
// exclude possibility of drawing from empty queues
|
|
328
318
|
if (this.failedQ.length === 0) {
|
|
329
319
|
reviewBound = 1;
|
|
@@ -333,26 +323,90 @@ export class SessionController extends Loggable {
|
|
|
333
323
|
}
|
|
334
324
|
|
|
335
325
|
if (choice < newBound && this.newQ.length) {
|
|
336
|
-
|
|
326
|
+
return this.newQ.peek(0);
|
|
337
327
|
} else if (choice < reviewBound && this.reviewQ.length) {
|
|
338
|
-
|
|
328
|
+
return this.reviewQ.peek(0);
|
|
339
329
|
} else if (this.failedQ.length) {
|
|
340
|
-
|
|
330
|
+
return this.failedQ.peek(0);
|
|
341
331
|
} else {
|
|
342
332
|
this.log(`No more cards available for the session!`);
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
public async nextCard(
|
|
338
|
+
action: SessionAction = 'dismiss-success'
|
|
339
|
+
): Promise<HydratedCard<TView> | null> {
|
|
340
|
+
// dismiss (or sort to failedQ) the current card
|
|
341
|
+
this.dismissCurrentCard(action);
|
|
342
|
+
|
|
343
|
+
if (this._secondsRemaining <= 0 && this.failedQ.length === 0) {
|
|
343
344
|
this._currentCard = null;
|
|
345
|
+
return null;
|
|
344
346
|
}
|
|
345
347
|
|
|
346
|
-
|
|
348
|
+
let card = this.hydrationService.dequeueHydratedCard();
|
|
349
|
+
|
|
350
|
+
// If no hydrated card but source cards available, wait for hydration
|
|
351
|
+
if (!card && this.hasAvailableCards()) {
|
|
352
|
+
card = await this.hydrationService.waitForHydratedCard();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Trigger background hydration to maintain cache (async, non-blocking)
|
|
356
|
+
await this.hydrationService.ensureHydratedCards();
|
|
357
|
+
|
|
358
|
+
if (card) {
|
|
359
|
+
this._currentCard = card;
|
|
360
|
+
} else {
|
|
361
|
+
this._currentCard = null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return card;
|
|
347
365
|
}
|
|
348
366
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
367
|
+
/**
|
|
368
|
+
* Public API for processing user responses to cards.
|
|
369
|
+
* @param cardRecord User's response record
|
|
370
|
+
* @param cardHistory Promise resolving to the card's history
|
|
371
|
+
* @param courseRegistrationDoc User's course registration document
|
|
372
|
+
* @param currentCard Current study session record
|
|
373
|
+
* @param courseId Course identifier
|
|
374
|
+
* @param cardId Card identifier
|
|
375
|
+
* @param maxAttemptsPerView Maximum attempts allowed per view
|
|
376
|
+
* @param maxSessionViews Maximum session views for this card
|
|
377
|
+
* @param sessionViews Current number of session views
|
|
378
|
+
* @returns ResponseResult with navigation and UI instructions
|
|
379
|
+
*/
|
|
380
|
+
public async submitResponse(
|
|
381
|
+
cardRecord: CardRecord,
|
|
382
|
+
cardHistory: Promise<CardHistory<CardRecord>>,
|
|
383
|
+
courseRegistrationDoc: CourseRegistrationDoc,
|
|
384
|
+
currentCard: StudySessionRecord,
|
|
385
|
+
courseId: string,
|
|
386
|
+
cardId: string,
|
|
387
|
+
maxAttemptsPerView: number,
|
|
388
|
+
maxSessionViews: number,
|
|
389
|
+
sessionViews: number
|
|
390
|
+
): Promise<ResponseResult> {
|
|
391
|
+
const studySessionItem: StudySessionItem = {
|
|
392
|
+
...currentCard.item,
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
return await this.services.response.processResponse(
|
|
396
|
+
cardRecord,
|
|
397
|
+
cardHistory,
|
|
398
|
+
studySessionItem,
|
|
399
|
+
courseRegistrationDoc,
|
|
400
|
+
currentCard,
|
|
401
|
+
courseId,
|
|
402
|
+
cardId,
|
|
403
|
+
maxAttemptsPerView,
|
|
404
|
+
maxSessionViews,
|
|
405
|
+
sessionViews
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private dismissCurrentCard(action: SessionAction = 'dismiss-success') {
|
|
356
410
|
if (this._currentCard) {
|
|
357
411
|
// this.log(`Running dismissCurrentCard on ${this._currentCard!.qualifiedID}`);
|
|
358
412
|
// if (action.includes('dismiss')) {
|
|
@@ -367,30 +421,30 @@ export class SessionController extends Loggable {
|
|
|
367
421
|
if (action === 'dismiss-success') {
|
|
368
422
|
// schedule a review - currently done in Study.vue
|
|
369
423
|
} else if (action === 'marked-failed') {
|
|
424
|
+
this.hydrationService.cacheFailedCard(this._currentCard);
|
|
425
|
+
|
|
370
426
|
let failedItem: StudySessionFailedItem;
|
|
371
427
|
|
|
372
|
-
if (isReview(this._currentCard)) {
|
|
428
|
+
if (isReview(this._currentCard.item)) {
|
|
373
429
|
failedItem = {
|
|
374
|
-
cardID: this._currentCard.cardID,
|
|
375
|
-
courseID: this._currentCard.courseID,
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
contentSourceType: this._currentCard.contentSourceType,
|
|
430
|
+
cardID: this._currentCard.item.cardID,
|
|
431
|
+
courseID: this._currentCard.item.courseID,
|
|
432
|
+
contentSourceID: this._currentCard.item.contentSourceID,
|
|
433
|
+
contentSourceType: this._currentCard.item.contentSourceType,
|
|
379
434
|
status: 'failed-review',
|
|
380
|
-
reviewID: this._currentCard.reviewID,
|
|
435
|
+
reviewID: this._currentCard.item.reviewID,
|
|
381
436
|
};
|
|
382
437
|
} else {
|
|
383
438
|
failedItem = {
|
|
384
|
-
cardID: this._currentCard.cardID,
|
|
385
|
-
courseID: this._currentCard.courseID,
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
contentSourceType: this._currentCard.contentSourceType,
|
|
439
|
+
cardID: this._currentCard.item.cardID,
|
|
440
|
+
courseID: this._currentCard.item.courseID,
|
|
441
|
+
contentSourceID: this._currentCard.item.contentSourceID,
|
|
442
|
+
contentSourceType: this._currentCard.item.contentSourceType,
|
|
389
443
|
status: 'failed-new',
|
|
390
444
|
};
|
|
391
445
|
}
|
|
392
446
|
|
|
393
|
-
this.failedQ.add(failedItem);
|
|
447
|
+
this.failedQ.add(failedItem, failedItem.cardID);
|
|
394
448
|
} else if (action === 'dismiss-error') {
|
|
395
449
|
// some error logging?
|
|
396
450
|
} else if (action === 'dismiss-failed') {
|
|
@@ -398,4 +452,21 @@ export class SessionController extends Loggable {
|
|
|
398
452
|
}
|
|
399
453
|
}
|
|
400
454
|
}
|
|
455
|
+
|
|
456
|
+
private hasAvailableCards(): boolean {
|
|
457
|
+
return this.reviewQ.length > 0 || this.newQ.length > 0 || this.failedQ.length > 0;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Helper method for CardHydrationService to remove items from appropriate queue.
|
|
462
|
+
*/
|
|
463
|
+
private removeItemFromQueue(item: StudySessionItem): void {
|
|
464
|
+
if (this.reviewQ.peek(0) === item) {
|
|
465
|
+
this.reviewQ.dequeue((queueItem) => queueItem.cardID);
|
|
466
|
+
} else if (this.newQ.peek(0) === item) {
|
|
467
|
+
this.newQ.dequeue((queueItem) => queueItem.cardID);
|
|
468
|
+
} else {
|
|
469
|
+
this.failedQ.dequeue((queueItem) => queueItem.cardID);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
401
472
|
}
|
|
@@ -39,7 +39,7 @@ function newQuestionInterval(user: DocumentUpdater, cardHistory: CardHistory<Que
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
if (currentAttempt.isCorrect) {
|
|
42
|
-
const skill = currentAttempt.performance as number;
|
|
42
|
+
const skill = Math.min(1.0, Math.max(0.0, currentAttempt.performance as number));
|
|
43
43
|
logger.debug(`Demontrated skill: \t${skill}`);
|
|
44
44
|
const interval: number = lastInterval * (0.75 + skill);
|
|
45
45
|
cardHistory.lapses = getLapses(cardHistory.records);
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import {
|
|
2
|
+
displayableDataToViewData,
|
|
3
|
+
CardData,
|
|
4
|
+
DisplayableData,
|
|
5
|
+
isCourseElo,
|
|
6
|
+
toCourseElo,
|
|
7
|
+
} from '@vue-skuilder/common';
|
|
8
|
+
import { StudySessionItem } from '@db/impl/couch';
|
|
9
|
+
import { logger } from '@db/util/logger';
|
|
10
|
+
import { ItemQueue } from '../ItemQueue';
|
|
11
|
+
import { CourseDBInterface } from '@db/core';
|
|
12
|
+
|
|
13
|
+
export interface HydratedCard<TView = unknown> {
|
|
14
|
+
item: StudySessionItem;
|
|
15
|
+
view: TView;
|
|
16
|
+
data: any[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ItemQueue now imported from separate file
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Service responsible for managing a queue of hydrated (ready-to-display) cards.
|
|
23
|
+
* Handles pre-fetching card data, caching failed cards, and maintaining optimal buffer size.
|
|
24
|
+
*/
|
|
25
|
+
export class CardHydrationService<TView = unknown> {
|
|
26
|
+
private hydratedQ: ItemQueue<HydratedCard<TView>> = new ItemQueue<HydratedCard<TView>>();
|
|
27
|
+
private failedCardCache: Map<string, HydratedCard<TView>> = new Map();
|
|
28
|
+
private hydrationInProgress: boolean = false;
|
|
29
|
+
private readonly BUFFER_SIZE = 5;
|
|
30
|
+
|
|
31
|
+
constructor(
|
|
32
|
+
private getViewComponent: (viewId: string) => TView,
|
|
33
|
+
private getCourseDB: (courseId: string) => CourseDBInterface,
|
|
34
|
+
private selectNextItemToHydrate: () => StudySessionItem | null,
|
|
35
|
+
private removeItemFromQueue: (item: StudySessionItem) => void,
|
|
36
|
+
private hasAvailableCards: () => boolean
|
|
37
|
+
) {}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get the next hydrated card from the queue.
|
|
41
|
+
* @returns Hydrated card or null if none available
|
|
42
|
+
*/
|
|
43
|
+
public dequeueHydratedCard(): HydratedCard<TView> | null {
|
|
44
|
+
return this.hydratedQ.dequeue((item) => item.item.cardID);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Check if hydration should be triggered and start background hydration if needed.
|
|
49
|
+
*/
|
|
50
|
+
public async ensureHydratedCards(): Promise<void> {
|
|
51
|
+
// Trigger background hydration to maintain cache (async, non-blocking)
|
|
52
|
+
if (this.hydratedQ.length < 3) {
|
|
53
|
+
void this.fillHydratedQueue();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Wait for a hydrated card to become available.
|
|
59
|
+
* @returns Promise that resolves to a hydrated card or null
|
|
60
|
+
*/
|
|
61
|
+
public async waitForHydratedCard(): Promise<HydratedCard<TView> | null> {
|
|
62
|
+
// If no hydrated card but source cards available, start hydration
|
|
63
|
+
if (this.hydratedQ.length === 0 && this.hasAvailableCards()) {
|
|
64
|
+
void this.fillHydratedQueue(); // Start hydration in background
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Wait for a card to become available in hydratedQ
|
|
68
|
+
while (this.hydratedQ.length === 0 && this.hasAvailableCards()) {
|
|
69
|
+
await new Promise((resolve) => setTimeout(resolve, 25)); // Short polling interval
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return this.dequeueHydratedCard();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get current hydrated queue length.
|
|
77
|
+
*/
|
|
78
|
+
public get hydratedCount(): number {
|
|
79
|
+
return this.hydratedQ.length;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Fill the hydrated queue up to BUFFER_SIZE with pre-fetched cards.
|
|
84
|
+
*/
|
|
85
|
+
private async fillHydratedQueue(): Promise<void> {
|
|
86
|
+
if (this.hydrationInProgress) {
|
|
87
|
+
return; // Prevent concurrent hydration
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
this.hydrationInProgress = true;
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
while (this.hydratedQ.length < this.BUFFER_SIZE) {
|
|
94
|
+
const nextItem = this.selectNextItemToHydrate();
|
|
95
|
+
if (!nextItem) {
|
|
96
|
+
return; // No more cards to hydrate
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
// Check cache first for failed cards
|
|
101
|
+
if (this.failedCardCache.has(nextItem.cardID)) {
|
|
102
|
+
const cachedCard = this.failedCardCache.get(nextItem.cardID)!;
|
|
103
|
+
this.hydratedQ.add(cachedCard, cachedCard.item.cardID);
|
|
104
|
+
this.failedCardCache.delete(nextItem.cardID);
|
|
105
|
+
} else {
|
|
106
|
+
// Hydrate new card using original logic pattern
|
|
107
|
+
const courseDB = this.getCourseDB(nextItem.courseID);
|
|
108
|
+
const cardData = await courseDB.getCourseDoc<CardData>(nextItem.cardID);
|
|
109
|
+
|
|
110
|
+
if (!isCourseElo(cardData.elo)) {
|
|
111
|
+
cardData.elo = toCourseElo(cardData.elo);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const view = this.getViewComponent(cardData.id_view);
|
|
115
|
+
const dataDocs = await Promise.all(
|
|
116
|
+
cardData.id_displayable_data.map((id: string) =>
|
|
117
|
+
courseDB.getCourseDoc<DisplayableData>(id, {
|
|
118
|
+
attachments: true,
|
|
119
|
+
binary: true,
|
|
120
|
+
})
|
|
121
|
+
)
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const data = dataDocs.map(displayableDataToViewData).reverse();
|
|
125
|
+
|
|
126
|
+
this.hydratedQ.add(
|
|
127
|
+
{
|
|
128
|
+
item: nextItem,
|
|
129
|
+
view,
|
|
130
|
+
data,
|
|
131
|
+
},
|
|
132
|
+
nextItem.cardID
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
} catch (e) {
|
|
136
|
+
logger.error(`Error hydrating card ${nextItem.cardID}:`, e);
|
|
137
|
+
} finally {
|
|
138
|
+
// Remove the item from the original queue, regardless of success/failure/cache
|
|
139
|
+
this.removeItemFromQueue(nextItem);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} finally {
|
|
143
|
+
this.hydrationInProgress = false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Cache a failed card for quick re-access.
|
|
149
|
+
*/
|
|
150
|
+
public cacheFailedCard(card: HydratedCard<TView>): void {
|
|
151
|
+
this.failedCardCache.set(card.item.cardID, card);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { adjustCourseScores, toCourseElo } from '@vue-skuilder/common';
|
|
2
|
+
import { DataLayerProvider, UserDBInterface, CourseRegistrationDoc } from '@db/core';
|
|
3
|
+
import { StudySessionRecord } from '../SessionController';
|
|
4
|
+
import { logger } from '@db/util/logger';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Service responsible for ELO rating calculations and updates.
|
|
8
|
+
*/
|
|
9
|
+
export class EloService {
|
|
10
|
+
private dataLayer: DataLayerProvider;
|
|
11
|
+
private user: UserDBInterface;
|
|
12
|
+
|
|
13
|
+
constructor(dataLayer: DataLayerProvider, user: UserDBInterface) {
|
|
14
|
+
this.dataLayer = dataLayer;
|
|
15
|
+
this.user = user;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Updates both user and card ELO ratings based on user performance.
|
|
20
|
+
* @param userScore Score between 0-1 representing user performance
|
|
21
|
+
* @param course_id Course identifier
|
|
22
|
+
* @param card_id Card identifier
|
|
23
|
+
* @param userCourseRegDoc User's course registration document (will be mutated)
|
|
24
|
+
* @param currentCard Current card session record
|
|
25
|
+
* @param k Optional K-factor for ELO calculation
|
|
26
|
+
*/
|
|
27
|
+
public async updateUserAndCardElo(
|
|
28
|
+
userScore: number,
|
|
29
|
+
course_id: string,
|
|
30
|
+
card_id: string,
|
|
31
|
+
userCourseRegDoc: CourseRegistrationDoc,
|
|
32
|
+
currentCard: StudySessionRecord,
|
|
33
|
+
k?: number
|
|
34
|
+
): Promise<void> {
|
|
35
|
+
if (k) {
|
|
36
|
+
logger.warn(`k value interpretation not currently implemented`);
|
|
37
|
+
}
|
|
38
|
+
const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
|
|
39
|
+
const userElo = toCourseElo(userCourseRegDoc.courses.find((c) => c.courseID === course_id)!.elo);
|
|
40
|
+
const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
|
|
41
|
+
|
|
42
|
+
if (cardElo && userElo) {
|
|
43
|
+
const eloUpdate = adjustCourseScores(userElo, cardElo, userScore);
|
|
44
|
+
userCourseRegDoc.courses.find((c) => c.courseID === course_id)!.elo = eloUpdate.userElo;
|
|
45
|
+
|
|
46
|
+
const results = await Promise.allSettled([
|
|
47
|
+
this.user.updateUserElo(course_id, eloUpdate.userElo),
|
|
48
|
+
courseDB.updateCardElo(card_id, eloUpdate.cardElo),
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
// Check the results of each operation
|
|
52
|
+
const userEloStatus = results[0].status === 'fulfilled';
|
|
53
|
+
const cardEloStatus = results[1].status === 'fulfilled';
|
|
54
|
+
|
|
55
|
+
if (userEloStatus && cardEloStatus) {
|
|
56
|
+
const user = (results[0] as PromiseFulfilledResult<any>).value;
|
|
57
|
+
const card = (results[1] as PromiseFulfilledResult<any>).value;
|
|
58
|
+
|
|
59
|
+
if (user.ok && card && card.ok) {
|
|
60
|
+
logger.info(
|
|
61
|
+
`[EloService] Updated ELOS:
|
|
62
|
+
\tUser: ${JSON.stringify(eloUpdate.userElo)})
|
|
63
|
+
\tCard: ${JSON.stringify(eloUpdate.cardElo)})
|
|
64
|
+
`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
// Log which operations succeeded and which failed
|
|
69
|
+
logger.warn(
|
|
70
|
+
`[EloService] Partial ELO update:
|
|
71
|
+
\tUser ELO update: ${userEloStatus ? 'SUCCESS' : 'FAILED'}
|
|
72
|
+
\tCard ELO update: ${cardEloStatus ? 'SUCCESS' : 'FAILED'}`
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
if (!userEloStatus && results[0].status === 'rejected') {
|
|
76
|
+
logger.error('[EloService] User ELO update error:', results[0].reason);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!cardEloStatus && results[1].status === 'rejected') {
|
|
80
|
+
logger.error('[EloService] Card ELO update error:', results[1].reason);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|