@vue-skuilder/db 0.1.11 → 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 (53) 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 +146 -37
  4. package/dist/core/index.js.map +1 -1
  5. package/dist/core/index.mjs +146 -37
  6. package/dist/core/index.mjs.map +1 -1
  7. package/dist/{dataLayerProvider-VieuAAkV.d.mts → dataLayerProvider-BiP3kWix.d.mts} +1 -1
  8. package/dist/{dataLayerProvider-juuqUHOP.d.ts → dataLayerProvider-DSdeyRT3.d.ts} +1 -1
  9. package/dist/impl/couch/index.d.mts +3 -3
  10. package/dist/impl/couch/index.d.ts +3 -3
  11. package/dist/impl/couch/index.js +146 -37
  12. package/dist/impl/couch/index.js.map +1 -1
  13. package/dist/impl/couch/index.mjs +146 -37
  14. package/dist/impl/couch/index.mjs.map +1 -1
  15. package/dist/impl/static/index.d.mts +14 -6
  16. package/dist/impl/static/index.d.ts +14 -6
  17. package/dist/impl/static/index.js +147 -39
  18. package/dist/impl/static/index.js.map +1 -1
  19. package/dist/impl/static/index.mjs +147 -39
  20. package/dist/impl/static/index.mjs.map +1 -1
  21. package/dist/{index-DZyxHCcf.d.mts → index-Bmll7Xse.d.mts} +1 -1
  22. package/dist/{index-CWY6yhkV.d.ts → index-CD8BZz2k.d.ts} +1 -1
  23. package/dist/index.d.mts +119 -24
  24. package/dist/index.d.ts +119 -24
  25. package/dist/index.js +785 -261
  26. package/dist/index.js.map +1 -1
  27. package/dist/index.mjs +789 -265
  28. package/dist/index.mjs.map +1 -1
  29. package/dist/{types-DtoI27Xh.d.ts → types-CewsN87z.d.ts} +1 -1
  30. package/dist/{types-Che4wTwA.d.mts → types-Dbp5DaRR.d.mts} +1 -1
  31. package/dist/{types-legacy-B8ahaCbj.d.mts → types-legacy-6ettoclI.d.mts} +13 -2
  32. package/dist/{types-legacy-B8ahaCbj.d.ts → types-legacy-6ettoclI.d.ts} +13 -2
  33. package/dist/{userDB-DJ8HMw83.d.mts → userDB-C4yyAnpp.d.mts} +3 -3
  34. package/dist/{userDB-B7zTQ123.d.ts → userDB-CD6s6ZCp.d.ts} +3 -3
  35. package/dist/util/packer/index.d.mts +3 -3
  36. package/dist/util/packer/index.d.ts +3 -3
  37. package/package.json +3 -3
  38. package/src/core/navigators/hardcodedOrder.ts +64 -0
  39. package/src/core/navigators/index.ts +1 -0
  40. package/src/core/types/contentNavigationStrategy.ts +2 -1
  41. package/src/core/types/types-legacy.ts +2 -2
  42. package/src/impl/common/BaseUserDB.ts +15 -11
  43. package/src/impl/couch/courseDB.ts +74 -27
  44. package/src/impl/couch/updateQueue.ts +8 -3
  45. package/src/impl/static/StaticDataLayerProvider.ts +57 -17
  46. package/src/impl/static/courseDB.ts +17 -12
  47. package/src/impl/static/coursesDB.ts +10 -6
  48. package/src/study/ItemQueue.ts +58 -0
  49. package/src/study/SessionController.ts +132 -178
  50. package/src/study/services/CardHydrationService.ts +153 -0
  51. package/src/study/services/EloService.ts +85 -0
  52. package/src/study/services/ResponseProcessor.ts +224 -0
  53. package/src/study/services/SrsService.ts +44 -0
@@ -9,14 +9,18 @@ export class StaticCoursesDB implements CoursesDBInterface {
9
9
  constructor(private manifests: Record<string, StaticCourseManifest>) {}
10
10
 
11
11
  async getCourseConfig(courseId: string): Promise<CourseConfig> {
12
- if (!this.manifests[courseId]) {
13
- // throw new Error(`Course ${courseId} not found`);
14
- logger.warn(`Course ${courseId} not found`);
15
- return {} as CourseConfig; // Return empty config if course not found
12
+ const manifest = this.manifests[courseId];
13
+ if (!manifest) {
14
+ logger.warn(`Course manifest for ${courseId} not found`);
15
+ throw new Error(`Course ${courseId} not found`);
16
16
  }
17
17
 
18
- // Would need to fetch the course config from static files
19
- return {} as CourseConfig;
18
+ if (manifest.courseConfig) {
19
+ return manifest.courseConfig;
20
+ } else {
21
+ logger.warn(`Course config not found in manifest for course ${courseId}`);
22
+ throw new Error(`Course config not found for course ${courseId}`);
23
+ }
20
24
  }
21
25
 
22
26
  async getCourseList(): Promise<CourseConfig[]> {
@@ -0,0 +1,58 @@
1
+ export class ItemQueue<T> {
2
+ private q: T[] = [];
3
+ private seenCardIds: string[] = [];
4
+ private _dequeueCount: number = 0;
5
+ public get dequeueCount(): number {
6
+ return this._dequeueCount;
7
+ }
8
+
9
+ public add(item: T, cardId: string) {
10
+ if (this.seenCardIds.find((d) => d === cardId)) {
11
+ return; // do not re-add a card to the same queue
12
+ }
13
+
14
+ this.seenCardIds.push(cardId);
15
+ this.q.push(item);
16
+ }
17
+
18
+ public addAll(items: T[], cardIdExtractor: (item: T) => string) {
19
+ items.forEach((i) => this.add(i, cardIdExtractor(i)));
20
+ }
21
+
22
+ public get length() {
23
+ return this.q.length;
24
+ }
25
+
26
+ public peek(index: number): T {
27
+ return this.q[index];
28
+ }
29
+
30
+ public dequeue(cardIdExtractor?: (item: T) => string): T | null {
31
+ if (this.q.length !== 0) {
32
+ this._dequeueCount++;
33
+ const item = this.q.splice(0, 1)[0];
34
+
35
+ // Remove cardId from seenCardIds when dequeuing to allow re-queueing
36
+ if (cardIdExtractor) {
37
+ const cardId = cardIdExtractor(item);
38
+ const index = this.seenCardIds.indexOf(cardId);
39
+ if (index > -1) {
40
+ this.seenCardIds.splice(index, 1);
41
+ }
42
+ }
43
+
44
+ return item;
45
+ } else {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ public get toString(): string {
51
+ return (
52
+ `${typeof this.q[0]}:\n` +
53
+ this.q
54
+ .map((i) => `\t${(i as any).courseID}+${(i as any).cardID}: ${(i as any).status}`)
55
+ .join('\n')
56
+ );
57
+ }
58
+ }
@@ -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,10 +12,9 @@ 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
- import { ViewData } from '@vue-skuilder/common';
14
18
 
15
19
  function randomInt(min: number, max: number): number {
16
20
  return Math.floor(Math.random() * (max - min + 1)) + min;
@@ -26,83 +30,53 @@ export interface StudySessionRecord {
26
30
  records: CardRecord[];
27
31
  }
28
32
 
29
- export interface HydratedCard<TView = unknown> {
30
- item: StudySessionItem;
31
- view: TView;
32
- data: ViewData[];
33
- }
34
-
35
- import {
36
- CardData,
37
- DisplayableData,
38
- displayableDataToViewData,
39
- isCourseElo,
40
- toCourseElo,
41
- } from '@vue-skuilder/common';
42
-
43
- class ItemQueue<T> {
44
- private q: T[] = [];
45
- private seenCardIds: string[] = [];
46
- private _dequeueCount: number = 0;
47
- public get dequeueCount(): number {
48
- return this._dequeueCount;
49
- }
33
+ import { DataLayerProvider } from '@db/core';
50
34
 
51
- public add(item: T, cardId: string) {
52
- if (this.seenCardIds.find((d) => d === cardId)) {
53
- return; // do not re-add a card to the same queue
54
- }
35
+ export type SessionAction =
36
+ | 'dismiss-success'
37
+ | 'dismiss-failed'
38
+ | 'marked-failed'
39
+ | 'dismiss-error';
55
40
 
56
- this.seenCardIds.push(cardId);
57
- this.q.push(item);
58
- }
59
- public addAll(items: T[], cardIdExtractor: (item: T) => string) {
60
- items.forEach((i) => this.add(i, cardIdExtractor(i)));
61
- }
62
- public get length() {
63
- return this.q.length;
64
- }
65
- public peek(index: number): T {
66
- return this.q[index];
67
- }
41
+ export interface ResponseResult {
42
+ // Navigation
43
+ nextCardAction: Exclude<SessionAction, 'dismiss-error'> | 'none';
44
+ shouldLoadNextCard: boolean;
68
45
 
69
- public dequeue(): T | null {
70
- if (this.q.length !== 0) {
71
- this._dequeueCount++;
72
- return this.q.splice(0, 1)[0];
73
- } else {
74
- return null;
75
- }
76
- }
46
+ // UI Data (let view decide how to render)
47
+ isCorrect: boolean;
48
+ performanceScore?: number; // for shadow color calculation
77
49
 
78
- public get toString(): string {
79
- return (
80
- `${typeof this.q[0]}:\n` +
81
- this.q
82
- .map((i) => `\t${(i as any).courseID}+${(i as any).cardID}: ${(i as any).status}`)
83
- .join('\n')
84
- );
85
- }
50
+ // Cleanup
51
+ shouldClearFeedbackShadow: boolean;
86
52
  }
87
53
 
88
- import { DataLayerProvider } from '@db/core';
54
+ interface SessionServices {
55
+ response: ResponseProcessor;
56
+ }
89
57
 
90
58
  export class SessionController<TView = unknown> extends Loggable {
91
59
  _className = 'SessionController';
60
+
61
+ public services: SessionServices;
62
+ private srsService: SrsService;
63
+ private eloService: EloService;
64
+ private hydrationService: CardHydrationService<TView>;
65
+
92
66
  private sources: StudyContentSource[];
93
- private dataLayer: DataLayerProvider;
94
- private getViewComponent: (viewId: string) => TView;
67
+ // dataLayer and getViewComponent now injected into CardHydrationService
95
68
  private _sessionRecord: StudySessionRecord[] = [];
96
69
  public set sessionRecord(r: StudySessionRecord[]) {
97
70
  this._sessionRecord = r;
98
71
  }
99
72
 
73
+ // Session card stores
74
+ private _currentCard: HydratedCard<TView> | null = null;
75
+
100
76
  private reviewQ: ItemQueue<StudySessionReviewItem> = new ItemQueue<StudySessionReviewItem>();
101
77
  private newQ: ItemQueue<StudySessionNewItem> = new ItemQueue<StudySessionNewItem>();
102
78
  private failedQ: ItemQueue<StudySessionFailedItem> = new ItemQueue<StudySessionFailedItem>();
103
- private hydratedQ: ItemQueue<HydratedCard<TView>> = new ItemQueue<HydratedCard<TView>>();
104
- private _currentCard: StudySessionItem | null = null;
105
- private hydration_in_progress: boolean = false;
79
+ // END Session card stores
106
80
 
107
81
  private startTime: Date;
108
82
  private endTime: Date;
@@ -130,12 +104,25 @@ export class SessionController<TView = unknown> extends Loggable {
130
104
  ) {
131
105
  super();
132
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
+
133
122
  this.sources = sources;
134
123
  this.startTime = new Date();
135
124
  this._secondsRemaining = time;
136
125
  this.endTime = new Date(this.startTime.valueOf() + 1000 * this._secondsRemaining);
137
- this.dataLayer = dataLayer;
138
- this.getViewComponent = getViewComponent;
139
126
 
140
127
  this.log(`Session constructed:
141
128
  startTime: ${this.startTime}
@@ -146,7 +133,7 @@ export class SessionController<TView = unknown> extends Loggable {
146
133
  this._secondsRemaining = Math.floor((this.endTime.valueOf() - Date.now()) / 1000);
147
134
  // this.log(this.secondsRemaining);
148
135
 
149
- if (this._secondsRemaining === 0) {
136
+ if (this._secondsRemaining <= 0) {
150
137
  clearInterval(this._intervalHandle);
151
138
  }
152
139
  }
@@ -199,7 +186,7 @@ export class SessionController<TView = unknown> extends Loggable {
199
186
  this.error('Error preparing study session:', e);
200
187
  }
201
188
 
202
- await this._fillHydratedQueue();
189
+ await this.hydrationService.ensureHydratedCards();
203
190
 
204
191
  this._intervalHandle = setInterval(() => {
205
192
  this.tick();
@@ -275,13 +262,7 @@ export class SessionController<TView = unknown> extends Loggable {
275
262
  }
276
263
  }
277
264
 
278
- private _selectNextItemToHydrate(
279
- action:
280
- | 'dismiss-success'
281
- | 'dismiss-failed'
282
- | 'marked-failed'
283
- | 'dismiss-error' = 'dismiss-success'
284
- ): StudySessionItem | null {
265
+ private _selectNextItemToHydrate(): StudySessionItem | null {
285
266
  const choice = Math.random();
286
267
  let newBound: number = 0.1;
287
268
  let reviewBound: number = 0.75;
@@ -333,12 +314,6 @@ export class SessionController<TView = unknown> extends Loggable {
333
314
  reviewBound = 0.1;
334
315
  }
335
316
 
336
- // prevent (unless no other option available) re-display of
337
- // most recent card
338
- if (this.failedQ.length === 1 && action === 'marked-failed') {
339
- reviewBound = 1;
340
- }
341
-
342
317
  // exclude possibility of drawing from empty queues
343
318
  if (this.failedQ.length === 0) {
344
319
  reviewBound = 1;
@@ -360,38 +335,78 @@ export class SessionController<TView = unknown> extends Loggable {
360
335
  }
361
336
 
362
337
  public async nextCard(
363
- action:
364
- | 'dismiss-success'
365
- | 'dismiss-failed'
366
- | 'marked-failed'
367
- | 'dismiss-error' = 'dismiss-success'
338
+ action: SessionAction = 'dismiss-success'
368
339
  ): Promise<HydratedCard<TView> | null> {
369
340
  // dismiss (or sort to failedQ) the current card
370
341
  this.dismissCurrentCard(action);
371
342
 
372
- let card = this.hydratedQ.dequeue();
343
+ if (this._secondsRemaining <= 0 && this.failedQ.length === 0) {
344
+ this._currentCard = null;
345
+ return null;
346
+ }
347
+
348
+ let card = this.hydrationService.dequeueHydratedCard();
373
349
 
374
350
  // If no hydrated card but source cards available, wait for hydration
375
351
  if (!card && this.hasAvailableCards()) {
376
- void this._fillHydratedQueue(); // Start hydration in background
377
- card = await this.nextHydratedCard(); // Wait for first available card
352
+ card = await this.hydrationService.waitForHydratedCard();
378
353
  }
379
354
 
380
355
  // Trigger background hydration to maintain cache (async, non-blocking)
381
- if (this.hydratedQ.length < 3) {
382
- void this._fillHydratedQueue();
356
+ await this.hydrationService.ensureHydratedCards();
357
+
358
+ if (card) {
359
+ this._currentCard = card;
360
+ } else {
361
+ this._currentCard = null;
383
362
  }
384
363
 
385
364
  return card;
386
365
  }
387
366
 
388
- private dismissCurrentCard(
389
- action:
390
- | 'dismiss-success'
391
- | 'dismiss-failed'
392
- | 'marked-failed'
393
- | 'dismiss-error' = 'dismiss-success'
394
- ) {
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') {
395
410
  if (this._currentCard) {
396
411
  // this.log(`Running dismissCurrentCard on ${this._currentCard!.qualifiedID}`);
397
412
  // if (action.includes('dismiss')) {
@@ -406,23 +421,25 @@ export class SessionController<TView = unknown> extends Loggable {
406
421
  if (action === 'dismiss-success') {
407
422
  // schedule a review - currently done in Study.vue
408
423
  } else if (action === 'marked-failed') {
424
+ this.hydrationService.cacheFailedCard(this._currentCard);
425
+
409
426
  let failedItem: StudySessionFailedItem;
410
427
 
411
- if (isReview(this._currentCard)) {
428
+ if (isReview(this._currentCard.item)) {
412
429
  failedItem = {
413
- cardID: this._currentCard.cardID,
414
- courseID: this._currentCard.courseID,
415
- contentSourceID: this._currentCard.contentSourceID,
416
- 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,
417
434
  status: 'failed-review',
418
- reviewID: this._currentCard.reviewID,
435
+ reviewID: this._currentCard.item.reviewID,
419
436
  };
420
437
  } else {
421
438
  failedItem = {
422
- cardID: this._currentCard.cardID,
423
- courseID: this._currentCard.courseID,
424
- contentSourceID: this._currentCard.contentSourceID,
425
- 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,
426
443
  status: 'failed-new',
427
444
  };
428
445
  }
@@ -440,79 +457,16 @@ export class SessionController<TView = unknown> extends Loggable {
440
457
  return this.reviewQ.length > 0 || this.newQ.length > 0 || this.failedQ.length > 0;
441
458
  }
442
459
 
443
- private async nextHydratedCard(): Promise<HydratedCard<TView> | null> {
444
- // Wait for a card to become available in hydratedQ
445
- while (this.hydratedQ.length === 0 && this.hasAvailableCards()) {
446
- await new Promise((resolve) => setTimeout(resolve, 25)); // Short polling interval
447
- }
448
- return this.hydratedQ.dequeue();
449
- }
450
-
451
- private async _fillHydratedQueue() {
452
- if (this.hydration_in_progress) {
453
- return; // Prevent concurrent hydration
454
- }
455
-
456
- const BUFFER_SIZE = 5;
457
- this.hydration_in_progress = true;
458
-
459
- while (this.hydratedQ.length < BUFFER_SIZE) {
460
- const nextItem = this._selectNextItemToHydrate();
461
- if (!nextItem) {
462
- return; // No more cards to hydrate
463
- }
464
-
465
- try {
466
- const cardData = await this.dataLayer
467
- .getCourseDB(nextItem.courseID)
468
- .getCourseDoc<CardData>(nextItem.cardID);
469
-
470
- if (!isCourseElo(cardData.elo)) {
471
- cardData.elo = toCourseElo(cardData.elo);
472
- }
473
-
474
- const view = this.getViewComponent(cardData.id_view);
475
- const dataDocs = await Promise.all(
476
- cardData.id_displayable_data.map((id: string) =>
477
- this.dataLayer.getCourseDB(nextItem.courseID).getCourseDoc<DisplayableData>(id, {
478
- attachments: true,
479
- binary: true,
480
- })
481
- )
482
- );
483
-
484
- const data = dataDocs.map(displayableDataToViewData).reverse();
485
-
486
- this.hydratedQ.add(
487
- {
488
- item: nextItem,
489
- view,
490
- data,
491
- },
492
- nextItem.cardID
493
- );
494
-
495
- // Remove the item from the original queue
496
- if (this.reviewQ.peek(0) === nextItem) {
497
- this.reviewQ.dequeue();
498
- } else if (this.newQ.peek(0) === nextItem) {
499
- this.newQ.dequeue();
500
- } else {
501
- this.failedQ.dequeue();
502
- }
503
- } catch (e) {
504
- this.error(`Error hydrating card ${nextItem.cardID}:`, e);
505
- // Remove the failed item from the queue
506
- if (this.reviewQ.peek(0) === nextItem) {
507
- this.reviewQ.dequeue();
508
- } else if (this.newQ.peek(0) === nextItem) {
509
- this.newQ.dequeue();
510
- } else {
511
- this.failedQ.dequeue();
512
- }
513
- }
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);
514
470
  }
515
-
516
- this.hydration_in_progress = false;
517
471
  }
518
472
  }
@@ -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
+ }