@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.
Files changed (76) hide show
  1. package/dist/core/index.d.mts +7 -6
  2. package/dist/core/index.d.ts +7 -6
  3. package/dist/core/index.js +358 -87
  4. package/dist/core/index.js.map +1 -1
  5. package/dist/core/index.mjs +358 -87
  6. package/dist/core/index.mjs.map +1 -1
  7. package/dist/{dataLayerProvider-DqtNroSh.d.ts → dataLayerProvider-BiP3kWix.d.mts} +8 -1
  8. package/dist/{dataLayerProvider-BInqI_RF.d.mts → dataLayerProvider-DSdeyRT3.d.ts} +8 -1
  9. package/dist/impl/couch/index.d.mts +19 -7
  10. package/dist/impl/couch/index.d.ts +19 -7
  11. package/dist/impl/couch/index.js +375 -100
  12. package/dist/impl/couch/index.js.map +1 -1
  13. package/dist/impl/couch/index.mjs +374 -99
  14. package/dist/impl/couch/index.mjs.map +1 -1
  15. package/dist/impl/static/index.d.mts +23 -8
  16. package/dist/impl/static/index.d.ts +23 -8
  17. package/dist/impl/static/index.js +289 -85
  18. package/dist/impl/static/index.js.map +1 -1
  19. package/dist/impl/static/index.mjs +289 -85
  20. package/dist/impl/static/index.mjs.map +1 -1
  21. package/dist/{index-CUNnL38E.d.mts → index-Bmll7Xse.d.mts} +1 -1
  22. package/dist/{index-CLL31bEy.d.ts → index-CD8BZz2k.d.ts} +1 -1
  23. package/dist/index.d.mts +123 -20
  24. package/dist/index.d.ts +123 -20
  25. package/dist/index.js +1133 -343
  26. package/dist/index.js.map +1 -1
  27. package/dist/index.mjs +1137 -343
  28. package/dist/index.mjs.map +1 -1
  29. package/dist/pouch/index.d.mts +1 -0
  30. package/dist/pouch/index.d.ts +1 -0
  31. package/dist/pouch/index.js +49 -0
  32. package/dist/pouch/index.js.map +1 -0
  33. package/dist/pouch/index.mjs +16 -0
  34. package/dist/pouch/index.mjs.map +1 -0
  35. package/dist/{types-BefDGkKa.d.ts → types-CewsN87z.d.ts} +1 -1
  36. package/dist/{types-DC-ckZug.d.mts → types-Dbp5DaRR.d.mts} +1 -1
  37. package/dist/{types-legacy-Birv-Jx6.d.mts → types-legacy-6ettoclI.d.mts} +17 -2
  38. package/dist/{types-legacy-Birv-Jx6.d.ts → types-legacy-6ettoclI.d.ts} +17 -2
  39. package/dist/{userDB-DusL7OXe.d.ts → userDB-C4yyAnpp.d.mts} +89 -56
  40. package/dist/{userDB-C33Hzjgn.d.mts → userDB-CD6s6ZCp.d.ts} +89 -56
  41. package/dist/util/packer/index.d.mts +3 -3
  42. package/dist/util/packer/index.d.ts +3 -3
  43. package/package.json +3 -3
  44. package/src/core/interfaces/contentSource.ts +3 -2
  45. package/src/core/interfaces/courseDB.ts +26 -3
  46. package/src/core/interfaces/dataLayerProvider.ts +9 -1
  47. package/src/core/interfaces/userDB.ts +80 -64
  48. package/src/core/navigators/elo.ts +10 -7
  49. package/src/core/navigators/hardcodedOrder.ts +64 -0
  50. package/src/core/navigators/index.ts +2 -1
  51. package/src/core/types/contentNavigationStrategy.ts +2 -1
  52. package/src/core/types/types-legacy.ts +7 -2
  53. package/src/impl/common/BaseUserDB.ts +60 -14
  54. package/src/impl/couch/CouchDBSyncStrategy.ts +2 -2
  55. package/src/impl/couch/PouchDataLayerProvider.ts +21 -0
  56. package/src/impl/couch/adminDB.ts +2 -2
  57. package/src/impl/couch/auth.ts +13 -4
  58. package/src/impl/couch/classroomDB.ts +10 -12
  59. package/src/impl/couch/courseAPI.ts +2 -2
  60. package/src/impl/couch/courseDB.ts +204 -38
  61. package/src/impl/couch/courseLookupDB.ts +4 -3
  62. package/src/impl/couch/index.ts +36 -4
  63. package/src/impl/couch/pouchdb-setup.ts +3 -3
  64. package/src/impl/couch/updateQueue.ts +59 -36
  65. package/src/impl/static/StaticDataLayerProvider.ts +68 -17
  66. package/src/impl/static/courseDB.ts +64 -20
  67. package/src/impl/static/coursesDB.ts +10 -6
  68. package/src/pouch/index.ts +2 -0
  69. package/src/study/ItemQueue.ts +58 -0
  70. package/src/study/SessionController.ts +182 -111
  71. package/src/study/SpacedRepetition.ts +1 -1
  72. package/src/study/services/CardHydrationService.ts +153 -0
  73. package/src/study/services/EloService.ts +85 -0
  74. package/src/study/services/ResponseProcessor.ts +224 -0
  75. package/src/study/services/SrsService.ts +44 -0
  76. 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
- 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
- }
33
+ import { DataLayerProvider } from '@db/core';
35
34
 
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
- }
35
+ export type SessionAction =
36
+ | 'dismiss-success'
37
+ | 'dismiss-failed'
38
+ | 'marked-failed'
39
+ | 'dismiss-error';
40
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
- }
41
+ export interface ResponseResult {
42
+ // Navigation
43
+ nextCardAction: Exclude<SessionAction, 'dismiss-error'> | 'none';
44
+ shouldLoadNextCard: boolean;
53
45
 
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
- }
46
+ // UI Data (let view decide how to render)
47
+ isCorrect: boolean;
48
+ performanceScore?: number; // for shadow color calculation
62
49
 
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
- }
50
+ // Cleanup
51
+ shouldClearFeedbackShadow: boolean;
68
52
  }
69
53
 
70
- export class SessionController extends Loggable {
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
- 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;
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(sources: StudyContentSource[], time: number) {
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 === 0) {
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._isInitialized = true;
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
- 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
- }
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.qualifiedID}`);
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 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
-
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
- this._currentCard = null;
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
- this._currentCard = null;
289
- return this._currentCard;
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
- this._currentCard = this.nextNewCard();
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
- this._currentCard = this.nextNewCard();
326
+ return this.newQ.peek(0);
337
327
  } else if (choice < reviewBound && this.reviewQ.length) {
338
- this._currentCard = this.reviewQ.dequeue();
328
+ return this.reviewQ.peek(0);
339
329
  } else if (this.failedQ.length) {
340
- this._currentCard = this.failedQ.dequeue();
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
- return this._currentCard;
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
- private dismissCurrentCard(
350
- action:
351
- | 'dismiss-success'
352
- | 'dismiss-failed'
353
- | 'marked-failed'
354
- | 'dismiss-error' = 'dismiss-success'
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
- qualifiedID: this._currentCard.qualifiedID,
377
- contentSourceID: this._currentCard.contentSourceID,
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
- qualifiedID: this._currentCard.qualifiedID,
387
- contentSourceID: this._currentCard.contentSourceID,
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
+ }