@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.
- package/dist/core/index.d.mts +7 -6
- package/dist/core/index.d.ts +7 -6
- package/dist/core/index.js +146 -37
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +146 -37
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-VieuAAkV.d.mts → dataLayerProvider-BiP3kWix.d.mts} +1 -1
- package/dist/{dataLayerProvider-juuqUHOP.d.ts → dataLayerProvider-DSdeyRT3.d.ts} +1 -1
- package/dist/impl/couch/index.d.mts +3 -3
- package/dist/impl/couch/index.d.ts +3 -3
- package/dist/impl/couch/index.js +146 -37
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +146 -37
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.mts +14 -6
- package/dist/impl/static/index.d.ts +14 -6
- package/dist/impl/static/index.js +147 -39
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +147 -39
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/{index-DZyxHCcf.d.mts → index-Bmll7Xse.d.mts} +1 -1
- package/dist/{index-CWY6yhkV.d.ts → index-CD8BZz2k.d.ts} +1 -1
- package/dist/index.d.mts +119 -24
- package/dist/index.d.ts +119 -24
- package/dist/index.js +785 -261
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +789 -265
- package/dist/index.mjs.map +1 -1
- package/dist/{types-DtoI27Xh.d.ts → types-CewsN87z.d.ts} +1 -1
- package/dist/{types-Che4wTwA.d.mts → types-Dbp5DaRR.d.mts} +1 -1
- package/dist/{types-legacy-B8ahaCbj.d.mts → types-legacy-6ettoclI.d.mts} +13 -2
- package/dist/{types-legacy-B8ahaCbj.d.ts → types-legacy-6ettoclI.d.ts} +13 -2
- package/dist/{userDB-DJ8HMw83.d.mts → userDB-C4yyAnpp.d.mts} +3 -3
- package/dist/{userDB-B7zTQ123.d.ts → userDB-CD6s6ZCp.d.ts} +3 -3
- 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/navigators/hardcodedOrder.ts +64 -0
- package/src/core/navigators/index.ts +1 -0
- package/src/core/types/contentNavigationStrategy.ts +2 -1
- package/src/core/types/types-legacy.ts +2 -2
- package/src/impl/common/BaseUserDB.ts +15 -11
- package/src/impl/couch/courseDB.ts +74 -27
- package/src/impl/couch/updateQueue.ts +8 -3
- package/src/impl/static/StaticDataLayerProvider.ts +57 -17
- package/src/impl/static/courseDB.ts +17 -12
- package/src/impl/static/coursesDB.ts +10 -6
- package/src/study/ItemQueue.ts +58 -0
- package/src/study/SessionController.ts +132 -178
- 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
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
logger.warn(`Course ${courseId} not found`);
|
|
15
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
35
|
+
export type SessionAction =
|
|
36
|
+
| 'dismiss-success'
|
|
37
|
+
| 'dismiss-failed'
|
|
38
|
+
| 'marked-failed'
|
|
39
|
+
| 'dismiss-error';
|
|
55
40
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
382
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
+
}
|