Skip to content
254 changes: 91 additions & 163 deletions packages/common-ui/src/components/StudySession.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
<div v-if="sessionFinished" class="text-h4">
<p>Study session finished! Great job!</p>
<p v-if="sessionController">{{ sessionController.report }}</p>
<p>
<!-- <p>
Start <a @click="$emit('session-finished')">another study session</a>, or try
<router-link :to="`/edit/${courseID}`">adding some new content</router-link> to challenge yourself and others!
</p>
</p> -->
<heat-map :activity-records-getter="() => user.getActivityRecords()" />
</div>

Expand Down Expand Up @@ -65,35 +65,35 @@
</template>

<script lang="ts">
import { defineComponent, PropType, markRaw } from 'vue';
import { isQuestionView } from '../composables/CompositionViewable';
import { alertUser } from './SnackbarService';
import { defineComponent, markRaw, PropType } from 'vue';
import { ViewComponent } from '../composables';
import { isQuestionView } from '../composables/CompositionViewable';
import HeatMap from './HeatMap.vue';
import SkMouseTrap from './SkMouseTrap.vue';
import { alertUser } from './SnackbarService';
import StudySessionTimer from './StudySessionTimer.vue';
import HeatMap from './HeatMap.vue';
import CardViewer from './cardRendering/CardViewer.vue';

import { CourseElo, Status, toCourseElo, ViewData } from '@vue-skuilder/common';
import {
ContentSourceID,
getStudySource,
isReview,
StudyContentSource,
StudySessionItem,
docIsDeleted,
CardHistory,
CardRecord,
isQuestionRecord,
ClassroomDBInterface,
ContentSourceID,
CourseRegistrationDoc,
DataLayerProvider,
docIsDeleted,
getStudySource,
HydratedCard,
isQuestionRecord,
isReview,
ResponseResult,
SessionController,
StudyContentSource,
StudySessionRecord,
UserDBInterface,
ClassroomDBInterface,
} from '@vue-skuilder/db';
import { HydratedCard, SessionController, StudySessionRecord } from '@vue-skuilder/db';
import { newInterval } from '@vue-skuilder/db';
import { adjustCourseScores, CourseElo, toCourseElo, ViewData, Status } from '@vue-skuilder/common';
import confetti from 'canvas-confetti';
import moment from 'moment';

import { StudySessionConfig } from './StudySession.types';

Expand Down Expand Up @@ -371,140 +371,83 @@ export default defineComponent({
this.currentCard.records.push(r);

console.log(`[StudySession] StudySession.processResponse is running...`);
// DEBUG: Added logging to track hanging issue - can be removed if issue resolved
// console.log(`[StudySession] About to call logCardRecord...`);
const cardHistory = this.logCardRecord(r);
// console.log(`[StudySession] logCardRecord called, cardHistory promise created...`);

// Get view constraints for response processing
let maxAttemptsPerView = 1;
let maxSessionViews = 1;
if (isQuestionView(this.$refs.cardViewer?.$refs.activeView)) {
const view = this.$refs.cardViewer.$refs.activeView;
maxAttemptsPerView = view.maxAttemptsPerView;
maxSessionViews = view.maxSessionViews;
}
const sessionViews = this.countCardViews(this.courseID, this.cardID);

// Process response through SessionController
// DEBUG: Added logging to track hanging issue - can be removed if issue resolved
// console.log(`[StudySession] About to call submitResponse...`);
const result: ResponseResult = await this.sessionController!.submitResponse(
r,
cardHistory,
this.userCourseRegDoc!,
this.currentCard,
this.courseID,
this.cardID,
maxAttemptsPerView,
maxSessionViews,
sessionViews
);
// DEBUG: Added logging to track hanging issue - can be removed if issue resolved
// console.log(`[StudySession] submitResponse completed, result:`, result);

// Handle UI feedback based on result
this.handleUIFeedback(result);

// Handle navigation based on result
if (result.shouldLoadNextCard) {
this.loadCard(await this.sessionController!.nextCard(result.nextCardAction));
}

if (isQuestionRecord(r)) {
console.log(`[StudySession] Question is ${r.isCorrect ? '' : 'in'}correct`);
if (r.isCorrect) {
try {
if (this.$refs.shadowWrapper) {
this.$refs.shadowWrapper.setAttribute(
'style',
`--r: ${255 * (1 - (r.performance as number))}; --g:${255}`
);
this.$refs.shadowWrapper.classList.add('correct');
}
} catch (e) {
// swallow error
console.warn(`[StudySession] Error setting shadowWrapper style: ${e}`);
}

if (this.sessionConfig.likesConfetti) {
confetti({
origin: {
y: 1,
x: 0.25 + 0.5 * Math.random(),
},
disableForReducedMotion: true,
angle: 60 + 60 * Math.random(),
});
}
// Clear feedback shadow if requested
if (result.shouldClearFeedbackShadow) {
this.clearFeedbackShadow();
}
},

if (r.priorAttemps === 0) {
const item: StudySessionItem = {
...this.currentCard.item,
};
this.loadCard(await this.sessionController!.nextCard('dismiss-success'));

cardHistory.then((history: CardHistory<CardRecord>) => {
this.scheduleReview(history, item);
if (history.records.length === 1) {
this.updateUserAndCardElo(0.5 + (r.performance as number) / 2, this.courseID, this.cardID);
} else {
const k = Math.ceil(32 / history.records.length);
this.updateUserAndCardElo(0.5 + (r.performance as number) / 2, this.courseID, this.cardID, k);
}
});
} else {
this.loadCard(await this.sessionController!.nextCard('marked-failed'));
}
} else {
/* !r.isCorrect */
try {
if (this.$refs.shadowWrapper) {
this.$refs.shadowWrapper.classList.add('incorrect');
}
} catch (e) {
// swallow error
console.warn(`[StudySession] Error setting shadowWrapper style: ${e}`);
handleUIFeedback(result: ResponseResult) {
if (result.isCorrect) {
// Handle correct response UI
try {
if (this.$refs.shadowWrapper && result.performanceScore !== undefined) {
this.$refs.shadowWrapper.setAttribute('style', `--r: ${255 * (1 - result.performanceScore)}; --g:${255}`);
this.$refs.shadowWrapper.classList.add('correct');
}
} catch (e) {
console.warn(`[StudySession] Error setting shadowWrapper style: ${e}`);
}

cardHistory.then((history: CardHistory<CardRecord>) => {
if (history.records.length !== 1 && r.priorAttemps === 0) {
this.updateUserAndCardElo(0, this.courseID, this.cardID);
}
// Show confetti for correct responses
if (this.sessionConfig.likesConfetti) {
confetti({
origin: {
y: 1,
x: 0.25 + 0.5 * Math.random(),
},
disableForReducedMotion: true,
angle: 60 + 60 * Math.random(),
});

// [ ] v3 version. Keep an eye on this -
if (isQuestionView(this.$refs.cardViewer?.$refs.activeView)) {
const view = this.$refs.cardViewer.$refs.activeView;

if (this.currentCard.records.length >= view.maxAttemptsPerView) {
const sessionViews: number = this.countCardViews(this.courseID, this.cardID);
if (sessionViews >= view.maxSessionViews) {
this.loadCard(await this.sessionController!.nextCard('dismiss-failed'));
this.updateUserAndCardElo(0, this.courseID, this.cardID);
} else {
this.loadCard(await this.sessionController!.nextCard('marked-failed'));
}
}
}
}
} else {
this.loadCard(await this.sessionController!.nextCard('dismiss-success'));
}

this.clearFeedbackShadow();
},

async updateUserAndCardElo(userScore: number, course_id: string, card_id: string, k?: number) {
if (k) {
console.warn(`k value interpretation not currently implemented`);
}
const courseDB = this.dataLayer.getCourseDB(this.currentCard.card.course_id);
const userElo = toCourseElo(this.userCourseRegDoc!.courses.find((c) => c.courseID === course_id)!.elo);
const cardElo = (await courseDB.getCardEloData([this.currentCard.card.card_id]))[0];

if (cardElo && userElo) {
const eloUpdate = adjustCourseScores(userElo, cardElo, userScore);
this.userCourseRegDoc!.courses.find((c) => c.courseID === course_id)!.elo = eloUpdate.userElo;

const results = await Promise.allSettled([
this.user!.updateUserElo(course_id, eloUpdate.userElo),
courseDB.updateCardElo(card_id, eloUpdate.cardElo),
]);

// Check the results of each operation
const userEloStatus = results[0].status === 'fulfilled';
const cardEloStatus = results[1].status === 'fulfilled';

if (userEloStatus && cardEloStatus) {
const user = results[0].value;
const card = results[1].value;

if (user.ok && card && card.ok) {
console.log(
`[StudySession] Updated ELOS:
\tUser: ${JSON.stringify(eloUpdate.userElo)})
\tCard: ${JSON.stringify(eloUpdate.cardElo)})
`
);
}
} else {
// Log which operations succeeded and which failed
console.log(
`[StudySession] Partial ELO update:
\tUser ELO update: ${userEloStatus ? 'SUCCESS' : 'FAILED'}
\tCard ELO update: ${cardEloStatus ? 'SUCCESS' : 'FAILED'}`
);

if (!userEloStatus && results[0].status === 'rejected') {
console.error('[StudySession] User ELO update error:', results[0].reason);
}

if (!cardEloStatus && results[1].status === 'rejected') {
console.error('[StudySession] Card ELO update error:', results[1].reason);
// Handle incorrect response UI
try {
if (this.$refs.shadowWrapper) {
this.$refs.shadowWrapper.classList.add('incorrect');
}
} catch (e) {
console.warn(`[StudySession] Error setting shadowWrapper style: ${e}`);
}
}
},
Expand All @@ -523,26 +466,11 @@ export default defineComponent({
},

async logCardRecord(r: CardRecord): Promise<CardHistory<CardRecord>> {
return await this.user!.putCardRecord(r);
},

async scheduleReview(history: CardHistory<CardRecord>, item: StudySessionItem) {
const nextInterval = newInterval(this.$props.user, history);
const nextReviewTime = moment.utc().add(nextInterval, 'seconds');

if (isReview(item)) {
console.log(`[StudySession] Removing previously scheduled review for: ${item.cardID}`);
this.user!.removeScheduledCardReview(item.reviewID);
}

this.user!.scheduleCardReview({
user: this.user!.getUsername(),
course_id: history.courseID,
card_id: history.cardID,
time: nextReviewTime,
scheduledFor: item.contentSourceType,
schedulingAgentId: item.contentSourceID,
});
// DEBUG: Added logging to track hanging issue - can be removed if issue resolved
// console.log(`[StudySession] About to call user.putCardRecord...`);
const result = await this.user!.putCardRecord(r);
// console.log(`[StudySession] user.putCardRecord completed`);
return result;
},

async loadCard(card: HydratedCard | null) {
Expand Down
7 changes: 4 additions & 3 deletions packages/db/src/impl/couch/updateQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,10 @@ export default class UpdateQueue extends Loggable {
): Promise<T & PouchDB.Core.GetMeta & PouchDB.Core.RevisionIdMeta> {
logger.debug(`Applying updates on doc: ${id}`);
if (this.inprogressUpdates[id]) {
// console.log(`Updates in progress...`);
await this.readDB.info(); // stall for a round trip
// console.log(`Retrying...`);
// Poll instead of recursing to avoid infinite recursion
while (this.inprogressUpdates[id]) {
await new Promise(resolve => setTimeout(resolve, Math.random() * 50));
}
return this.applyUpdates<T>(id);
} else {
if (this.pendingUpdates[id] && this.pendingUpdates[id].length > 0) {
Expand Down
58 changes: 58 additions & 0 deletions packages/db/src/study/ItemQueue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
export class ItemQueue<T> {
private q: T[] = [];
private seenCardIds: string[] = [];
private _dequeueCount: number = 0;
public get dequeueCount(): number {
return this._dequeueCount;
}

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(cardId);
this.q.push(item);
}

public addAll(items: T[], cardIdExtractor: (item: T) => string) {
items.forEach((i) => this.add(i, cardIdExtractor(i)));
}

public get length() {
return this.q.length;
}

public peek(index: number): T {
return this.q[index];
}

public dequeue(cardIdExtractor?: (item: T) => string): T | null {
if (this.q.length !== 0) {
this._dequeueCount++;
const item = this.q.splice(0, 1)[0];

// Remove cardId from seenCardIds when dequeuing to allow re-queueing
if (cardIdExtractor) {
const cardId = cardIdExtractor(item);
const index = this.seenCardIds.indexOf(cardId);
if (index > -1) {
this.seenCardIds.splice(index, 1);
}
}

return item;
} else {
return null;
}
}

public get toString(): string {
return (
`${typeof this.q[0]}:\n` +
this.q
.map((i) => `\t${(i as any).courseID}+${(i as any).cardID}: ${(i as any).status}`)
.join('\n')
);
}
}
Loading