diff --git a/packages/common-ui/src/components/StudySession.vue b/packages/common-ui/src/components/StudySession.vue index 44b8c0bc2..33facbf5b 100644 --- a/packages/common-ui/src/components/StudySession.vue +++ b/packages/common-ui/src/components/StudySession.vue @@ -81,27 +81,17 @@ import { StudyContentSource, StudySessionItem, docIsDeleted, - CardData, CardHistory, CardRecord, - DisplayableData, isQuestionRecord, CourseRegistrationDoc, DataLayerProvider, UserDBInterface, ClassroomDBInterface, } from '@vue-skuilder/db'; -import { SessionController, StudySessionRecord } from '@vue-skuilder/db'; +import { HydratedCard, SessionController, StudySessionRecord } from '@vue-skuilder/db'; import { newInterval } from '@vue-skuilder/db'; -import { - adjustCourseScores, - CourseElo, - toCourseElo, - isCourseElo, - displayableDataToViewData, - ViewData, - Status, -} from '@vue-skuilder/common'; +import { adjustCourseScores, CourseElo, toCourseElo, ViewData, Status } from '@vue-skuilder/common'; import confetti from 'canvas-confetti'; import moment from 'moment'; @@ -175,7 +165,7 @@ export default defineComponent({ card_elo: 1000, courseNames: {} as { [courseID: string]: string }, cardCount: 1, - sessionController: null as SessionController | null, + sessionController: null as SessionController | null, sessionPrepared: false, sessionFinished: false, sessionRecord: [] as StudySessionRecord[], @@ -295,7 +285,7 @@ export default defineComponent({ } }) ) - ).filter((s) => s !== null) + ).filter((s: unknown) => s !== null) ); this.timeRemaining = this.sessionTimeLimit * 60; @@ -310,7 +300,14 @@ export default defineComponent({ // db.setChangeFcn(this.handleClassroomMessage()); }); - this.sessionController = markRaw(new SessionController(this.sessionContentSources, 60 * this.sessionTimeLimit)); + this.sessionController = markRaw( + new SessionController( + this.sessionContentSources, + 60 * this.sessionTimeLimit, + this.dataLayer, + this.getViewComponent + ) + ); this.sessionController.sessionRecord = this.sessionRecord; await this.sessionController.prepareSession(); @@ -349,7 +346,7 @@ export default defineComponent({ if (this.sessionController) { try { this.$emit('session-started'); - this.loadCard(this.sessionController.nextCard()); + this.loadCard(await this.sessionController.nextCard()); } catch (error) { console.error('[StudySession] Error loading next card:', error); this.$emit('session-error', { message: 'Failed to load study card', error }); @@ -407,7 +404,7 @@ export default defineComponent({ const item: StudySessionItem = { ...this.currentCard.item, }; - this.loadCard(this.sessionController!.nextCard('dismiss-success')); + this.loadCard(await this.sessionController!.nextCard('dismiss-success')); cardHistory.then((history: CardHistory) => { this.scheduleReview(history, item); @@ -419,7 +416,7 @@ export default defineComponent({ } }); } else { - this.loadCard(this.sessionController!.nextCard('marked-failed')); + this.loadCard(await this.sessionController!.nextCard('marked-failed')); } } else { /* !r.isCorrect */ @@ -445,16 +442,16 @@ export default defineComponent({ if (this.currentCard.records.length >= view.maxAttemptsPerView) { const sessionViews: number = this.countCardViews(this.courseID, this.cardID); if (sessionViews >= view.maxSessionViews) { - this.loadCard(this.sessionController!.nextCard('dismiss-failed')); + this.loadCard(await this.sessionController!.nextCard('dismiss-failed')); this.updateUserAndCardElo(0, this.courseID, this.cardID); } else { - this.loadCard(this.sessionController!.nextCard('marked-failed')); + this.loadCard(await this.sessionController!.nextCard('marked-failed')); } } } } } else { - this.loadCard(this.sessionController!.nextCard('dismiss-success')); + this.loadCard(await this.sessionController!.nextCard('dismiss-success')); } this.clearFeedbackShadow(); @@ -548,81 +545,56 @@ export default defineComponent({ }); }, - async loadCard(item: StudySessionItem | null) { + async loadCard(card: HydratedCard | null) { if (this.loading) { console.warn(`Attempted to load card while loading another...`); return; } - console.log(`[StudySession] loading: ${JSON.stringify(item)}`); - if (item === null) { + console.log(`[StudySession] loading: ${JSON.stringify(card)}`); + if (card === null) { this.sessionFinished = true; this.$emit('session-finished', this.sessionRecord); return; } - this.cardType = item.status; + this.cardType = card.item.status; this.loading = true; - const _courseID = item.courseID; - const _cardID = item.cardID; - - console.log(`[StudySession] Now displaying: ${_courseID}::${_cardID}`); try { - const tmpCardData = await this.dataLayer.getCourseDB(_courseID).getCourseDoc(_cardID); - - if (!isCourseElo(tmpCardData.elo)) { - tmpCardData.elo = toCourseElo(tmpCardData.elo); - } - - const tmpView: ViewComponent = this.getViewComponent(tmpCardData.id_view); - const tmpDataDocs = tmpCardData.id_displayable_data.map((id) => { - return this.dataLayer.getCourseDB(_courseID).getCourseDoc(id, { - attachments: true, - binary: true, - }); - }); - - const tmpData = []; - - for (const docPromise of tmpDataDocs) { - const doc = await docPromise; - tmpData.unshift(displayableDataToViewData(doc)); - } - this.cardCount++; - this.data = tmpData; - this.view = markRaw(tmpView); - this.cardID = _cardID; - this.courseID = _courseID; - this.card_elo = tmpCardData.elo.global.score; + this.data = card.data; + this.view = markRaw(card.view); + this.cardID = card.item.cardID; + this.courseID = card.item.courseID; + this.card_elo = card.item.elo || 1000; this.sessionRecord.push({ card: { - course_id: _courseID, - card_id: _cardID, - card_elo: tmpCardData.elo.global.score, + course_id: card.item.courseID, + card_id: card.item.cardID, + card_elo: this.card_elo, }, - item: item, + item: card.item, records: [], }); this.$emit('card-loaded', { - courseID: _courseID, - cardID: _cardID, + courseID: card.item.courseID, + cardID: card.item.cardID, cardCount: this.cardCount, }); } catch (e) { - console.warn(`[StudySession] Error loading card ${JSON.stringify(item)}:\n\t${JSON.stringify(e)}, ${e}`); + console.warn(`[StudySession] Error loading card ${JSON.stringify(card)}:\n\t${JSON.stringify(e)}, ${e}`); this.loading = false; const err = e as Error; - if (docIsDeleted(err) && isReview(item)) { - console.warn(`Card was deleted: ${_courseID}::${_cardID}`); - this.user!.removeScheduledCardReview(item.reviewID); + if (docIsDeleted(err) && isReview(card.item)) { + console.warn(`Card was deleted: ${card.item.courseID}::${card.item.cardID}`); + this.user!.removeScheduledCardReview((card.item as any).reviewID); } - this.loadCard(this.sessionController!.nextCard('dismiss-error')); + this.loadCard(await this.sessionController!.nextCard('dismiss-error')); } finally { this.loading = false; } diff --git a/packages/db/src/study/SessionController.ts b/packages/db/src/study/SessionController.ts index da2fb305c..55a69ba58 100644 --- a/packages/db/src/study/SessionController.ts +++ b/packages/db/src/study/SessionController.ts @@ -10,6 +10,7 @@ import { import { CardRecord } from '@db/core'; import { Loggable } from '@db/util'; import { ScheduledCard } from '@db/core/types/user'; +import { ViewData } from '@vue-skuilder/common'; function randomInt(min: number, max: number): number { return Math.floor(Math.random() * (max - min + 1)) + min; @@ -25,7 +26,21 @@ export interface StudySessionRecord { records: CardRecord[]; } -class ItemQueue { +export interface HydratedCard { + item: StudySessionItem; + view: TView; + data: ViewData[]; +} + +import { + CardData, + DisplayableData, + displayableDataToViewData, + isCourseElo, + toCourseElo, +} from '@vue-skuilder/common'; + +class ItemQueue { private q: T[] = []; private seenCardIds: string[] = []; private _dequeueCount: number = 0; @@ -33,16 +48,16 @@ class ItemQueue { return this._dequeueCount; } - public add(item: T) { - if (this.seenCardIds.find((d) => d === item.cardID)) { + public add(item: T, cardId: string) { + if (this.seenCardIds.find((d) => d === cardId)) { return; // do not re-add a card to the same queue } - this.seenCardIds.push(item.cardID); + this.seenCardIds.push(cardId); this.q.push(item); } - public addAll(items: T[]) { - items.forEach((i) => this.add(i)); + public addAll(items: T[], cardIdExtractor: (item: T) => string) { + items.forEach((i) => this.add(i, cardIdExtractor(i))); } public get length() { return this.q.length; @@ -63,14 +78,20 @@ class ItemQueue { public get toString(): string { return ( `${typeof this.q[0]}:\n` + - this.q.map((i) => `\t${i.courseID}+${i.cardID}: ${i.status}`).join('\n') + this.q + .map((i) => `\t${(i as any).courseID}+${(i as any).cardID}: ${(i as any).status}`) + .join('\n') ); } } -export class SessionController extends Loggable { +import { DataLayerProvider } from '@db/core'; + +export class SessionController extends Loggable { _className = 'SessionController'; private sources: StudyContentSource[]; + private dataLayer: DataLayerProvider; + private getViewComponent: (viewId: string) => TView; private _sessionRecord: StudySessionRecord[] = []; public set sessionRecord(r: StudySessionRecord[]) { this._sessionRecord = r; @@ -79,12 +100,9 @@ export class SessionController extends Loggable { private reviewQ: ItemQueue = new ItemQueue(); private newQ: ItemQueue = new ItemQueue(); private failedQ: ItemQueue = new ItemQueue(); + private hydratedQ: ItemQueue> = new ItemQueue>(); private _currentCard: StudySessionItem | null = null; - /** - * Indicates whether the session has been initialized - eg, the - * queues have been populated. - */ - private _isInitialized: boolean = false; + private hydration_in_progress: boolean = false; private startTime: Date; private endTime: Date; @@ -104,13 +122,20 @@ export class SessionController extends Loggable { /** * */ - constructor(sources: StudyContentSource[], time: number) { + constructor( + sources: StudyContentSource[], + time: number, + dataLayer: DataLayerProvider, + getViewComponent: (viewId: string) => TView + ) { super(); this.sources = sources; this.startTime = new Date(); this._secondsRemaining = time; this.endTime = new Date(this.startTime.valueOf() + 1000 * this._secondsRemaining); + this.dataLayer = dataLayer; + this.getViewComponent = getViewComponent; this.log(`Session constructed: startTime: ${this.startTime} @@ -174,7 +199,7 @@ export class SessionController extends Loggable { this.error('Error preparing study session:', e); } - this._isInitialized = true; + await this._fillHydratedQueue(); this._intervalHandle = setInterval(() => { this.tick(); @@ -222,11 +247,8 @@ export class SessionController extends Loggable { } let report = 'Review session created with:\n'; - for (let i = 0; i < dueCards.length; i++) { - const card = dueCards[i]; - this.reviewQ.add(card); - report += `\t${card.courseID}-${card.cardID}\n`; - } + this.reviewQ.addAll(dueCards, (c) => c.cardID); + report += dueCards.map((card) => `Card ${card.courseID}::${card.cardID} `).join('\n'); this.log(report); } @@ -246,55 +268,37 @@ export class SessionController extends Loggable { if (newContent[i].length > 0) { const item = newContent[i].splice(0, 1)[0]; this.log(`Adding new card: ${item.courseID}::${item.cardID}`); - this.newQ.add(item); + this.newQ.add(item, item.cardID); n--; } } } } - private nextNewCard(): StudySessionNewItem | null { - const item = this.newQ.dequeue(); - - // queue some more content if we are getting low - if (this._isInitialized && this.newQ.length < 5) { - void this.getNewCards(); - } - - return item; - } - - public nextCard( - // [ ] this is often slow. Why? + private _selectNextItemToHydrate( action: | 'dismiss-success' | 'dismiss-failed' | 'marked-failed' | 'dismiss-error' = 'dismiss-success' ): StudySessionItem | null { - // dismiss (or sort to failedQ) the current card - this.dismissCurrentCard(action); - const choice = Math.random(); let newBound: number = 0.1; let reviewBound: number = 0.75; if (this.reviewQ.length === 0 && this.failedQ.length === 0 && this.newQ.length === 0) { // all queues empty - session is over (and course is complete?) - this._currentCard = null; - return this._currentCard; + return null; } if (this._secondsRemaining < 2 && this.failedQ.length === 0) { // session is over! - this._currentCard = null; - return this._currentCard; + return null; } // supply new cards at start of session if (this.newQ.dequeueCount < this.sources.length && this.newQ.length) { - this._currentCard = this.nextNewCard(); - return this._currentCard; + return this.newQ.peek(0); } const cleanupTime = this.estimateCleanupTime(); @@ -335,17 +339,41 @@ export class SessionController extends Loggable { } if (choice < newBound && this.newQ.length) { - this._currentCard = this.nextNewCard(); + return this.newQ.peek(0); } else if (choice < reviewBound && this.reviewQ.length) { - this._currentCard = this.reviewQ.dequeue(); + return this.reviewQ.peek(0); } else if (this.failedQ.length) { - this._currentCard = this.failedQ.dequeue(); + return this.failedQ.peek(0); } else { this.log(`No more cards available for the session!`); - this._currentCard = null; + return null; + } + } + + public async nextCard( + action: + | 'dismiss-success' + | 'dismiss-failed' + | 'marked-failed' + | 'dismiss-error' = 'dismiss-success' + ): Promise | null> { + // dismiss (or sort to failedQ) the current card + this.dismissCurrentCard(action); + + let card = this.hydratedQ.dequeue(); + + // If no hydrated card but source cards available, wait for hydration + if (!card && this.hasAvailableCards()) { + void this._fillHydratedQueue(); // Start hydration in background + card = await this.nextHydratedCard(); // Wait for first available card } - return this._currentCard; + // Trigger background hydration to maintain cache (async, non-blocking) + if (this.hydratedQ.length < 3) { + void this._fillHydratedQueue(); + } + + return card; } private dismissCurrentCard( @@ -390,7 +418,7 @@ export class SessionController extends Loggable { }; } - this.failedQ.add(failedItem); + this.failedQ.add(failedItem, failedItem.cardID); } else if (action === 'dismiss-error') { // some error logging? } else if (action === 'dismiss-failed') { @@ -398,4 +426,84 @@ export class SessionController extends Loggable { } } } + + private hasAvailableCards(): boolean { + return this.reviewQ.length > 0 || this.newQ.length > 0 || this.failedQ.length > 0; + } + + private async nextHydratedCard(): Promise | null> { + // Wait for a card to become available in hydratedQ + while (this.hydratedQ.length === 0 && this.hasAvailableCards()) { + await new Promise((resolve) => setTimeout(resolve, 25)); // Short polling interval + } + return this.hydratedQ.dequeue(); + } + + private async _fillHydratedQueue() { + if (this.hydration_in_progress) { + return; // Prevent concurrent hydration + } + + const BUFFER_SIZE = 5; + this.hydration_in_progress = true; + + while (this.hydratedQ.length < BUFFER_SIZE) { + const nextItem = this._selectNextItemToHydrate(); + if (!nextItem) { + return; // No more cards to hydrate + } + + try { + const cardData = await this.dataLayer + .getCourseDB(nextItem.courseID) + .getCourseDoc(nextItem.cardID); + + if (!isCourseElo(cardData.elo)) { + cardData.elo = toCourseElo(cardData.elo); + } + + const view = this.getViewComponent(cardData.id_view); + const dataDocs = await Promise.all( + cardData.id_displayable_data.map((id: string) => + this.dataLayer.getCourseDB(nextItem.courseID).getCourseDoc(id, { + attachments: true, + binary: true, + }) + ) + ); + + const data = dataDocs.map(displayableDataToViewData).reverse(); + + this.hydratedQ.add( + { + item: nextItem, + view, + data, + }, + nextItem.cardID + ); + + // Remove the item from the original queue + if (this.reviewQ.peek(0) === nextItem) { + this.reviewQ.dequeue(); + } else if (this.newQ.peek(0) === nextItem) { + this.newQ.dequeue(); + } else { + this.failedQ.dequeue(); + } + } catch (e) { + this.error(`Error hydrating card ${nextItem.cardID}:`, e); + // Remove the failed item from the queue + if (this.reviewQ.peek(0) === nextItem) { + this.reviewQ.dequeue(); + } else if (this.newQ.peek(0) === nextItem) { + this.newQ.dequeue(); + } else { + this.failedQ.dequeue(); + } + } + } + + this.hydration_in_progress = false; + } }