diff --git a/dist/db.d.ts b/dist/db.d.ts index a9eeb2e..a935fd8 100644 --- a/dist/db.d.ts +++ b/dist/db.d.ts @@ -28,26 +28,49 @@ interface Term { createdAt: string; updatedAt: string; progress?: TermProgress; - progressHistory?: TermProgressHistory[]; topConfusionPairs?: TermConfusionPair[]; topReverseConfusionPairs?: TermConfusionPair[]; } interface TermAtp { id: number | string; - term: string; - def: string; + termSnapshot: string; + defSnapshot: string; } interface PracticeTest { id: number; studysetIds: (number | string)[]; - questionTermIds: (number | string)[]; - distractorTermIds: (number | string)[]; timestamp: string; questionsCorrect: number; questionsTotal: number; - questions: Question[]; + questions?: PracticeTestQuestion[]; +} +interface PracticeTestQuestion { + id: number; + practiceTestId: number; + termId: number | string; + termSnapshot: string; + defSnapshot: string; + type: "mcq" | "tfq" | "frq"; + position: number; + correct: boolean; + answerWith: string; + data: MCQData | TFQData | FRQData; +} +interface MCQData { + distractors: TermAtp[]; + correctChoiceIndex: number; + answeredIndex: number | null; +} +interface TFQData { + distractor?: TermAtp | null; + answeredBool: boolean; +} +interface FRQData { + answeredString: string; + userMarkedCorrect?: boolean; } interface Question { + id?: number; mcq?: MCQ; tfq?: TFQ; frq?: FRQ; @@ -87,17 +110,6 @@ interface TermProgress { termLastReviewedAt?: string; defFirstReviewedAt?: string; defLastReviewedAt?: string; - termLeitnerSystemBox?: number; - defLeitnerSystemBox?: number; -} -interface TermProgressHistory { - id: number; - timestamp: string; - termId: number | string; - termCorrectCount: number; - termIncorrectCount: number; - defCorrectCount: number; - defIncorrectCount: number; } interface TermConfusionPair { id: number; @@ -117,10 +129,10 @@ declare const db: Dexie & { studysets: EntityTable; terms: EntityTable; practiceTests: EntityTable; + practiceTestQuestions: EntityTable; termProgress: EntityTable; - termProgressHistory: EntityTable; termConfusionPairs: EntityTable; images: EntityTable; }; -export type { Studyset, Term, TermAtp, PracticeTest, Question, MCQ, TFQ, FRQ, TermProgress, TermProgressHistory, TermConfusionPair }; +export type { Studyset, Term, TermAtp, PracticeTest, PracticeTestQuestion, MCQData, TFQData, FRQData, Question, MCQ, TFQ, FRQ, TermProgress, TermConfusionPair }; export { db }; diff --git a/dist/db.js b/dist/db.js index 21e6f83..a6ec4d2 100644 --- a/dist/db.js +++ b/dist/db.js @@ -183,7 +183,7 @@ db.version(16).stores({ // Helper to convert Term to TermAtp and track IDs const toTermAtp = (t, isQuestion) => { if (!t) - return { id: 0, term: '', def: '' }; + return { id: 0, termSnapshot: '', defSnapshot: '' }; if (t.id) { if (isQuestion) questionTermIds.add(t.id); @@ -194,8 +194,8 @@ db.version(16).stores({ studysetIds.add(t.studysetId); return { id: t.id, - term: t.term || '', - def: t.def || '' + termSnapshot: t.term || t.termSnapshot || '', + defSnapshot: t.def || t.defSnapshot || '' }; }; if (q.mcq) { @@ -270,4 +270,90 @@ db.version(16).stores({ } }); }); +db.version(17).stores({ + practiceTests: "++id, timestamp, *studysetIds, questionsCorrect, questionsTotal", + practiceTestQuestions: "++id, practiceTestId, termId, [practiceTestId+position]", + termProgress: "++id, termId, termFirstReviewedAt, termLastReviewedAt, " + + "termReviewCount, defFirstReviewedAt, defLastReviewedAt, " + + "defReviewCount, termCorrectCount, termIncorrectCount, defCorrectCount, defIncorrectCount", + termProgressHistory: null +}).upgrade(async (tx) => { + await tx.table("termProgress").toCollection().modify(tp => { + delete tp.termLeitnerSystemBox; + delete tp.defLeitnerSystemBox; + }); + await tx.table("practiceTests").toCollection().each(async (pt) => { + if (pt.questions && Array.isArray(pt.questions)) { + for (let i = 0; i < pt.questions.length; i++) { + const q = pt.questions[i]; + let type = "mcq"; + let qData = {}; + let termAtp = null; + let correct = false; + let answerWith = ""; + if (q.mcq) { + type = "mcq"; + termAtp = q.mcq.term; + correct = q.mcq.correct; + answerWith = q.mcq.answerWith; + qData = { + distractors: (q.mcq.distractors || []).map((d) => ({ + id: d.id, + termSnapshot: d.termSnapshot || d.term || "", + defSnapshot: d.defSnapshot || d.def || "" + })), + correctChoiceIndex: q.mcq.correctChoiceIndex, + answeredIndex: q.mcq.answeredIndex + }; + } + else if (q.tfq) { + type = "tfq"; + termAtp = q.tfq.term; + correct = q.tfq.correct; + answerWith = q.tfq.answerWith; + qData = { + distractor: q.tfq.distractor ? { + id: q.tfq.distractor.id, + termSnapshot: q.tfq.distractor.termSnapshot || q.tfq.distractor.term || "", + defSnapshot: q.tfq.distractor.defSnapshot || q.tfq.distractor.def || "" + } : null, + answeredBool: q.tfq.answeredBool + }; + } + else if (q.frq) { + type = "frq"; + termAtp = q.frq.term; + correct = q.frq.correct; + answerWith = q.frq.answerWith; + let userMarkedCorrect = q.frq.userMarkedCorrect; + if (!correct && userMarkedCorrect) { + correct = true; + userMarkedCorrect = true; + } + qData = { + answeredString: q.frq.answeredString, + userMarkedCorrect: userMarkedCorrect + }; + } + if (termAtp) { + await tx.table("practiceTestQuestions").add({ + practiceTestId: pt.id, + termId: termAtp.id, + termSnapshot: termAtp.termSnapshot || termAtp.term || "", + defSnapshot: termAtp.defSnapshot || termAtp.def || "", + type: type, + position: i, + correct: correct, + answerWith: answerWith, + data: qData + }); + } + } + } + delete pt.questions; + delete pt.questionTermIds; + delete pt.distractorTermIds; + await tx.table("practiceTests").put(pt); + }); +}); export { db }; diff --git a/dist/index.d.ts b/dist/index.d.ts index ce43604..b3cafff 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -1,11 +1,10 @@ -import { Term, TermConfusionPair } from "./db"; +import { Term, TermConfusionPair, PracticeTestQuestion } from "./db"; type StudysetResolveProps = { terms?: boolean | TermResolveProps; practiceTests?: boolean; }; type TermResolveProps = { progress?: boolean; - progressHistory?: boolean; topConfusionPairs?: boolean; topReverseConfusionPairs?: boolean; termImageUrl?: boolean; @@ -34,7 +33,8 @@ export declare const idbApiLayer: { getTopConfusionPairs: (termId: any, resolveProps?: any) => Promise; getTopReverseConfusionPairs: (confusedTermId: any, resolveProps?: any) => Promise; recordConfusionPairs: (confusionPairs: any) => Promise; - recordPracticeTest: (practiceTest: any) => Promise; - updatePracticeTest: (id: number, practiceTest: any) => Promise; + recordPracticeTest: (practiceTest: any) => Promise; + getPracticeTestWithQuestions: (ptId: number) => Promise; + updatePracticeTestQuestion: (id: number, correct: boolean, userMarkedCorrect?: boolean) => Promise; getPracticeTestsByTermId: (termId: number | string) => Promise; }; diff --git a/dist/index.js b/dist/index.js index d4edddc..a599d40 100644 --- a/dist/index.js +++ b/dist/index.js @@ -33,6 +33,11 @@ export const idbApiLayer = { studysets[0].practiceTests = await db.practiceTests.where("studysetIds").equals(id).toArray(); /* local timestamps are ISO strings in UTC, so alphanumeric/lexical sorting is the same as chronological sorting */ studysets[0].practiceTests?.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); + await Promise.all(studysets[0].practiceTests.map(async (pt) => { + pt.questions = await db.practiceTestQuestions + .where("practiceTestId").equals(pt.id) + .sortBy("position"); + })); } return studysets[0]; }, @@ -41,7 +46,6 @@ export const idbApiLayer = { .where("[studysetId+sortOrder]") .between([studysetId, Dexie.minKey], [studysetId, Dexie.maxKey], true, true).toArray(); if (resolveProps?.progress || - resolveProps?.progressHistory || resolveProps?.topConfusionPairs || resolveProps?.topReverseConfusionPairs || resolveProps?.termImageUrl || @@ -51,9 +55,6 @@ export const idbApiLayer = { if (resolveProps?.progress) { promises.progress = db.termProgress.where("termId").equals(term.id).toArray(); } - if (resolveProps?.progressHistory) { - promises.progressHistory = db.termProgressHistory.where("termId").equals(term.id).toArray(); - } if (resolveProps?.topConfusionPairs) { promises.topConfusionPairs = this.getTopConfusionPairs(term.id); } @@ -69,7 +70,6 @@ export const idbApiLayer = { const results = await Promise.all(Object.entries(promises).map(async ([k, p]) => [k, await p])); const resolved = Object.fromEntries(results); term.progress = resolved.progress?.[0] ?? undefined; - term.progressHistory = resolved.progressHistory; term.topConfusionPairs = resolved.topConfusionPairs; term.topReverseConfusionPairs = resolved.topReverseConfusionPairs; term.termImageUrl = term.termImageKey == null ? null : resolved.termImageUrl; @@ -87,9 +87,6 @@ export const idbApiLayer = { if (resolveProps?.progress) { term.progress = (await db.termProgress.where("termId").equals(termId).toArray())?.[0]; } - if (resolveProps?.progressHistory) { - term.progressHistory = await db.termProgressHistory.where("termId").equals(termId).toArray(); - } if (resolveProps?.topConfusionPairs) { term.topConfusionPairs = await this.getTopConfusionPairs(term.id); } @@ -177,7 +174,7 @@ export const idbApiLayer = { await db.studysets.delete(id); }, updateTermProgress: async function (termProgressArray) { - for (const { termId, termReviewedAt, defReviewedAt, termLeitnerSystemBox, defLeitnerSystemBox, termCorrectIncrease, termIncorrectIncrease, defCorrectIncrease, defIncorrectIncrease } of termProgressArray) { + for (const { termId, termReviewedAt, defReviewedAt, termCorrectIncrease, termIncorrectIncrease, defCorrectIncrease, defIncorrectIncrease } of termProgressArray) { const existingProgress = await db.termProgress.where("termId").equals(termId).toArray(); if (existingProgress?.length > 0) { const termCorrectCount = (existingProgress[0].termCorrectCount) + (termCorrectIncrease ?? 0); @@ -195,16 +192,6 @@ export const idbApiLayer = { defReviewCount: defReviewedAt != null ? (existingProgress[0]?.defReviewCount ?? 0) + 1 : existingProgress[0]?.defReviewCount, - termLeitnerSystemBox: termLeitnerSystemBox ?? existingProgress[0].termLeitnerSystemBox, - defLeitnerSystemBox: defLeitnerSystemBox ?? existingProgress[0].defLeitnerSystemBox, - termCorrectCount: termCorrectCount, - termIncorrectCount: termIncorrectCount, - defCorrectCount: defCorrectCount, - defIncorrectCount: defIncorrectCount - }); - await db.termProgressHistory.add({ - termId: termId, - timestamp: (new Date()).toISOString(), termCorrectCount: termCorrectCount, termIncorrectCount: termIncorrectCount, defCorrectCount: defCorrectCount, @@ -212,7 +199,7 @@ export const idbApiLayer = { }); } else { - const newProgressId = await db.termProgress.add({ + await db.termProgress.add({ termId: termId, termFirstReviewedAt: termReviewedAt, termLastReviewedAt: termReviewedAt, @@ -222,16 +209,6 @@ export const idbApiLayer = { defLastReviewedAt: defReviewedAt, defReviewCount: defReviewedAt != null ? 1 : 0, - termLeitnerSystemBox: termLeitnerSystemBox, - defLeitnerSystemBox: defLeitnerSystemBox, - termCorrectCount: termCorrectIncrease ?? 0, - termIncorrectCount: termIncorrectIncrease ?? 0, - defCorrectCount: defCorrectIncrease ?? 0, - defIncorrectCount: defIncorrectIncrease ?? 0 - }); - await db.termProgressHistory.add({ - termId: termId, - timestamp: (new Date()).toISOString(), termCorrectCount: termCorrectIncrease ?? 0, termIncorrectCount: termIncorrectIncrease ?? 0, defCorrectCount: defCorrectIncrease ?? 0, @@ -303,62 +280,105 @@ export const idbApiLayer = { return true; }, recordPracticeTest: async function (practiceTest) { - return await db.transaction('rw', [db.practiceTests, db.termProgress, db.termProgressHistory, db.terms], async () => { + return await db.transaction('rw', [db.practiceTests, db.practiceTestQuestions, db.termProgress, db.terms], async () => { const rnISOString = (new Date()).toISOString(); const termProgressMap = new Map(); const studysetIds = new Set(); - const questionTermIds = new Set(); - const distractorTermIds = new Set(); + const involvedTermIds = new Set(); let questionsCorrect = 0; let questionsTotal = 0; + const questionsToInsert = []; if (practiceTest.questions && Array.isArray(practiceTest.questions)) { questionsTotal = practiceTest.questions.length; - for (const q of practiceTest.questions) { + for (let i = 0; i < practiceTest.questions.length; i++) { + const q = practiceTest.questions[i]; if (!q) continue; let termId = null; let answerWith = null; let correct = false; + let type = "mcq"; + let termSnapshot = ""; + let defSnapshot = ""; + let qData = {}; if (q.mcq) { + type = "mcq"; if (!q.mcq.term) throw new Error("MCQ question is missing term"); termId = q.mcq.term.id; - questionTermIds.add(termId); - (q.mcq.distractors || []).forEach((d) => { if (d.id) - distractorTermIds.add(d.id); }); + termSnapshot = q.mcq.term.termSnapshot || q.mcq.term.term || ""; + defSnapshot = q.mcq.term.defSnapshot || q.mcq.term.def || ""; answerWith = q.mcq.answerWith; correct = !!q.mcq.correct; + qData = { + distractors: (q.mcq.distractors || []).map((d) => { + if (d.id) + involvedTermIds.add(d.id); + return { + id: d.id, + termSnapshot: d.termSnapshot || d.term || "", + defSnapshot: d.defSnapshot || d.def || "" + }; + }), + correctChoiceIndex: q.mcq.correctChoiceIndex, + answeredIndex: q.mcq.answeredIndex + }; } else if (q.tfq) { + type = "tfq"; if (!q.tfq.term) throw new Error("TFQ question is missing term"); termId = q.tfq.term.id; - questionTermIds.add(termId); - if (q.tfq.distractor?.id) - distractorTermIds.add(q.tfq.distractor.id); + termSnapshot = q.tfq.term.termSnapshot || q.tfq.term.term || ""; + defSnapshot = q.tfq.term.defSnapshot || q.tfq.term.def || ""; answerWith = q.tfq.answerWith; correct = !!q.tfq.correct; + if (q.tfq.distractor?.id) + involvedTermIds.add(q.tfq.distractor.id); + qData = { + distractor: q.tfq.distractor ? { + id: q.tfq.distractor.id, + termSnapshot: q.tfq.distractor.termSnapshot || q.tfq.distractor.term || "", + defSnapshot: q.tfq.distractor.defSnapshot || q.tfq.distractor.def || "" + } : null, + answeredBool: q.tfq.answeredBool + }; } else if (q.frq) { + type = "frq"; if (!q.frq.term) throw new Error("FRQ question is missing term"); termId = q.frq.term.id; - questionTermIds.add(termId); + termSnapshot = q.frq.term.termSnapshot || q.frq.term.term || ""; + defSnapshot = q.frq.term.defSnapshot || q.frq.term.def || ""; answerWith = q.frq.answerWith; correct = !!q.frq.correct || !!q.frq.userMarkedCorrect; + qData = { + answeredString: q.frq.answeredString || "", + userMarkedCorrect: !!q.frq.userMarkedCorrect + }; } if (correct) questionsCorrect++; if (termId == null) continue; + involvedTermIds.add(termId); + questionsToInsert.push({ + termId, + termSnapshot, + defSnapshot, + type, + position: i, + correct, + answerWith, + data: qData + }); let tp = termProgressMap.get(termId); if (!tp) { tp = { termId, termReviewedAt: null, defReviewedAt: null, - termLeitnerSystemBox: null, - defLeitnerSystemBox: null, termCorrectIncrease: 0, termIncorrectIncrease: 0, defCorrectIncrease: 0, @@ -380,28 +400,21 @@ export const idbApiLayer = { if (answerWith === "DEF") { tp.defIncorrectIncrease += 1; tp.defReviewedAt = rnISOString; - tp.defLeitnerSystemBox = 1; } else { tp.termIncorrectIncrease += 1; tp.termReviewedAt = rnISOString; - tp.termLeitnerSystemBox = 1; } } } } // Fetch studysetIds for all involved terms - const combinedTermIds = new Set([...questionTermIds, ...distractorTermIds]); - const allTerms = await db.terms.bulkGet(Array.from(combinedTermIds)); + const allTerms = await db.terms.bulkGet(Array.from(involvedTermIds)); allTerms.forEach(t => { if (t?.studysetId) studysetIds.add(t.studysetId); }); for (const tp of termProgressMap.values()) { const existingProgress = await db.termProgress.where("termId").equals(tp.termId).toArray(); if (existingProgress?.length > 0) { - const termCorrectCount = (existingProgress[0].termCorrectCount) + (tp.termCorrectIncrease); - const termIncorrectCount = (existingProgress[0].termIncorrectCount) + (tp.termIncorrectIncrease); - const defCorrectCount = (existingProgress[0].defCorrectCount) + (tp.defCorrectIncrease); - const defIncorrectCount = (existingProgress[0].defIncorrectCount) + (tp.defIncorrectIncrease); await db.termProgress.update(existingProgress[0].id, { termLastReviewedAt: tp.termReviewedAt != null ? tp.termReviewedAt : existingProgress[0].termLastReviewedAt, @@ -413,20 +426,10 @@ export const idbApiLayer = { defReviewCount: tp.defReviewedAt != null ? (existingProgress[0]?.defReviewCount ?? 0) + 1 : existingProgress[0]?.defReviewCount, - termLeitnerSystemBox: tp.termLeitnerSystemBox ?? existingProgress[0].termLeitnerSystemBox, - defLeitnerSystemBox: tp.defLeitnerSystemBox ?? existingProgress[0].defLeitnerSystemBox, - termCorrectCount: termCorrectCount, - termIncorrectCount: termIncorrectCount, - defCorrectCount: defCorrectCount, - defIncorrectCount: defIncorrectCount - }); - await db.termProgressHistory.add({ - termId: tp.termId, - timestamp: rnISOString, - termCorrectCount: termCorrectCount, - termIncorrectCount: termIncorrectCount, - defCorrectCount: defCorrectCount, - defIncorrectCount: defIncorrectCount + termCorrectCount: (existingProgress[0].termCorrectCount) + (tp.termCorrectIncrease), + termIncorrectCount: (existingProgress[0].termIncorrectCount) + (tp.termIncorrectIncrease), + defCorrectCount: (existingProgress[0].defCorrectCount) + (tp.defCorrectIncrease), + defIncorrectCount: (existingProgress[0].defIncorrectCount) + (tp.defIncorrectIncrease) }); } else { @@ -438,16 +441,6 @@ export const idbApiLayer = { defFirstReviewedAt: tp.defReviewedAt, defLastReviewedAt: tp.defReviewedAt, defReviewCount: tp.defReviewedAt != null ? 1 : 0, - termLeitnerSystemBox: tp.termLeitnerSystemBox, - defLeitnerSystemBox: tp.defLeitnerSystemBox, - termCorrectCount: tp.termCorrectIncrease, - termIncorrectCount: tp.termIncorrectIncrease, - defCorrectCount: tp.defCorrectIncrease, - defIncorrectCount: tp.defIncorrectIncrease - }); - await db.termProgressHistory.add({ - termId: tp.termId, - timestamp: rnISOString, termCorrectCount: tp.termCorrectIncrease, termIncorrectCount: tp.termIncorrectIncrease, defCorrectCount: tp.defCorrectIncrease, @@ -455,78 +448,90 @@ export const idbApiLayer = { }); } } - practiceTest.questionsCorrect = questionsCorrect; - practiceTest.questionsTotal = questionsTotal; - practiceTest.studysetIds = Array.from(studysetIds); - practiceTest.questionTermIds = Array.from(questionTermIds); - practiceTest.distractorTermIds = Array.from(distractorTermIds); - if (!practiceTest.timestamp) - practiceTest.timestamp = rnISOString; - const id = await db.practiceTests.add(practiceTest); - return await db.practiceTests.get(id); + const ptRecord = { + timestamp: practiceTest.timestamp || rnISOString, + questionsCorrect, + questionsTotal, + studysetIds: Array.from(studysetIds) + }; + const ptId = await db.practiceTests.add(ptRecord); + for (const q of questionsToInsert) { + q.practiceTestId = ptId; + await db.practiceTestQuestions.add(q); + } + return await this.getPracticeTestWithQuestions(ptId); }); }, - updatePracticeTest: async function (id, practiceTest) { - return await db.transaction('rw', [db.practiceTests, db.terms], async () => { - const studysetIds = new Set(); - const questionTermIds = new Set(); - const distractorTermIds = new Set(); - let questionsCorrect = 0; - let questionsTotal = 0; - if (practiceTest.questions && Array.isArray(practiceTest.questions)) { - questionsTotal = practiceTest.questions.length; - for (const q of practiceTest.questions) { - if (!q) - continue; - let correct = false; - if (q.mcq) { - if (!q.mcq.term) - throw new Error("MCQ question is missing term"); - questionTermIds.add(q.mcq.term.id); - (q.mcq.distractors || []).forEach((d) => { if (d.id) - distractorTermIds.add(d.id); }); - correct = !!q.mcq.correct; - } - else if (q.tfq) { - if (!q.tfq.term) - throw new Error("TFQ question is missing term"); - questionTermIds.add(q.tfq.term.id); - if (q.tfq.distractor?.id) - distractorTermIds.add(q.tfq.distractor.id); - correct = !!q.tfq.correct; + getPracticeTestWithQuestions: async function (ptId) { + const pt = await db.practiceTests.get(ptId); + if (!pt) + return null; + pt.questions = await db.practiceTestQuestions + .where("practiceTestId").equals(ptId) + .sortBy("position"); + return pt; + }, + updatePracticeTestQuestion: async function (id, correct, userMarkedCorrect) { + return await db.transaction('rw', [db.practiceTests, db.practiceTestQuestions, db.termProgress], async () => { + const question = await db.practiceTestQuestions.get(id); + if (!question) + throw new Error("Question not found"); + const wasCorrect = question.correct; + const isCorrect = correct; + if (wasCorrect === isCorrect && question.type === "frq" && question.data.userMarkedCorrect === userMarkedCorrect) { + return question; + } + // Update question + const newData = { ...question.data }; + if (question.type === "frq") { + newData.userMarkedCorrect = userMarkedCorrect; + } + await db.practiceTestQuestions.update(id, { + correct: isCorrect, + data: newData + }); + // Update practice test accuracy + if (wasCorrect !== isCorrect) { + const pt = await db.practiceTests.get(question.practiceTestId); + if (pt) { + await db.practiceTests.update(pt.id, { + questionsCorrect: pt.questionsCorrect + (isCorrect ? 1 : -1) + }); + } + // Update term progress + const existingProgress = await db.termProgress.where("termId").equals(question.termId).toArray(); + if (existingProgress?.length > 0) { + const changes = {}; + if (question.answerWith === "DEF") { + changes.defCorrectCount = existingProgress[0].defCorrectCount + (isCorrect ? 1 : -1); + changes.defIncorrectCount = existingProgress[0].defIncorrectCount + (isCorrect ? -1 : 1); } - else if (q.frq) { - if (!q.frq.term) - throw new Error("FRQ question is missing term"); - questionTermIds.add(q.frq.term.id); - correct = !!q.frq.correct || !!q.frq.userMarkedCorrect; + else { + changes.termCorrectCount = existingProgress[0].termCorrectCount + (isCorrect ? 1 : -1); + changes.termIncorrectCount = existingProgress[0].termIncorrectCount + (isCorrect ? -1 : 1); } - if (correct) - questionsCorrect++; + await db.termProgress.update(existingProgress[0].id, changes); } } - const combinedTermIds = new Set([...questionTermIds, ...distractorTermIds]); - const allTerms = await db.terms.bulkGet(Array.from(combinedTermIds)); - allTerms.forEach(t => { if (t?.studysetId) - studysetIds.add(t.studysetId); }); - await db.practiceTests.update(id, { - questions: practiceTest.questions, - questionsCorrect, - questionsTotal, - studysetIds: Array.from(studysetIds), - questionTermIds: Array.from(questionTermIds), - distractorTermIds: Array.from(distractorTermIds) - }); - return await db.practiceTests.get(id); + return await db.practiceTestQuestions.get(id); }); }, getPracticeTestsByTermId: async function (termId) { - const tests = await db.practiceTests - .where("questionTermIds").equals(termId) - .or("distractorTermIds").equals(termId) - .distinct() - .toArray(); - tests.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); - return tests; + const questionIds = await db.practiceTestQuestions + .where("termId").equals(termId) + .primaryKeys(); + const questions = await db.practiceTestQuestions.bulkGet(questionIds); + const ptIds = new Set(); + questions.forEach(q => { if (q) + ptIds.add(q.practiceTestId); }); + const tests = await db.practiceTests.bulkGet(Array.from(ptIds)); + const filteredTests = tests.filter((t) => t !== undefined); + await Promise.all(filteredTests.map(async (pt) => { + pt.questions = await db.practiceTestQuestions + .where("practiceTestId").equals(pt.id) + .sortBy("position"); + })); + filteredTests.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); + return filteredTests; } }; diff --git a/src/db.ts b/src/db.ts index d62cb2c..da22b72 100644 --- a/src/db.ts +++ b/src/db.ts @@ -31,29 +31,56 @@ interface Term { createdAt: string updatedAt: string progress?: TermProgress - progressHistory?: TermProgressHistory[] topConfusionPairs?: TermConfusionPair[] topReverseConfusionPairs?: TermConfusionPair[] } interface TermAtp { id: number | string - term: string - def: string + termSnapshot: string + defSnapshot: string } interface PracticeTest { id: number studysetIds: (number | string)[] - questionTermIds: (number | string)[] - distractorTermIds: (number | string)[] timestamp: string questionsCorrect: number questionsTotal: number - questions: Question[] + questions?: PracticeTestQuestion[] +} + +interface PracticeTestQuestion { + id: number + practiceTestId: number + termId: number | string + termSnapshot: string + defSnapshot: string + type: "mcq" | "tfq" | "frq" + position: number + correct: boolean + answerWith: string + data: MCQData | TFQData | FRQData +} + +interface MCQData { + distractors: TermAtp[] + correctChoiceIndex: number + answeredIndex: number | null +} + +interface TFQData { + distractor?: TermAtp | null + answeredBool: boolean +} + +interface FRQData { + answeredString: string + userMarkedCorrect?: boolean } interface Question { + id?: number mcq?: MCQ tfq?: TFQ frq?: FRQ @@ -97,18 +124,6 @@ interface TermProgress { termLastReviewedAt?: string defFirstReviewedAt?: string defLastReviewedAt?: string - termLeitnerSystemBox?: number - defLeitnerSystemBox?: number -} - -interface TermProgressHistory { - id: number - timestamp: string - termId: number | string - termCorrectCount: number - termIncorrectCount: number - defCorrectCount: number - defIncorrectCount: number } interface TermConfusionPair { @@ -140,12 +155,12 @@ const db = new Dexie("quizfreelydata") as Dexie & { PracticeTest, "id" > - termProgress: EntityTable< - TermProgress, + practiceTestQuestions: EntityTable< + PracticeTestQuestion, "id" > - termProgressHistory: EntityTable< - TermProgressHistory, + termProgress: EntityTable< + TermProgress, "id" > termConfusionPairs: EntityTable< @@ -343,7 +358,7 @@ db.version(16).stores({ // Helper to convert Term to TermAtp and track IDs const toTermAtp = (t: any, isQuestion: boolean): TermAtp => { - if (!t) return { id: 0, term: '', def: '' }; + if (!t) return { id: 0, termSnapshot: '', defSnapshot: '' }; if (t.id) { if (isQuestion) questionTermIds.add(t.id); else distractorTermIds.add(t.id); @@ -351,8 +366,8 @@ db.version(16).stores({ if (t.studysetId) studysetIds.add(t.studysetId); return { id: t.id, - term: t.term || '', - def: t.def || '' + termSnapshot: t.term || t.termSnapshot || '', + defSnapshot: t.def || t.defSnapshot || '' }; }; @@ -429,5 +444,95 @@ db.version(16).stores({ }); }); -export type { Studyset, Term, TermAtp, PracticeTest, Question, MCQ, TFQ, FRQ, TermProgress, TermProgressHistory, TermConfusionPair } +db.version(17).stores({ + practiceTests: "++id, timestamp, *studysetIds, questionsCorrect, questionsTotal", + practiceTestQuestions: "++id, practiceTestId, termId, [practiceTestId+position]", + termProgress: "++id, termId, termFirstReviewedAt, termLastReviewedAt, " + + "termReviewCount, defFirstReviewedAt, defLastReviewedAt, " + + "defReviewCount, termCorrectCount, termIncorrectCount, defCorrectCount, defIncorrectCount", + termProgressHistory: null +}).upgrade(async tx => { + await tx.table("termProgress").toCollection().modify(tp => { + delete tp.termLeitnerSystemBox; + delete tp.defLeitnerSystemBox; + }); + + await tx.table("practiceTests").toCollection().each(async pt => { + if (pt.questions && Array.isArray(pt.questions)) { + for (let i = 0; i < pt.questions.length; i++) { + const q = pt.questions[i]; + let type: "mcq" | "tfq" | "frq" = "mcq"; + let qData: any = {}; + let termAtp: any = null; + let correct = false; + let answerWith = ""; + + if (q.mcq) { + type = "mcq"; + termAtp = q.mcq.term; + correct = q.mcq.correct; + answerWith = q.mcq.answerWith; + qData = { + distractors: (q.mcq.distractors || []).map((d: any) => ({ + id: d.id, + termSnapshot: d.termSnapshot || d.term || "", + defSnapshot: d.defSnapshot || d.def || "" + })), + correctChoiceIndex: q.mcq.correctChoiceIndex, + answeredIndex: q.mcq.answeredIndex + }; + } else if (q.tfq) { + type = "tfq"; + termAtp = q.tfq.term; + correct = q.tfq.correct; + answerWith = q.tfq.answerWith; + qData = { + distractor: q.tfq.distractor ? { + id: q.tfq.distractor.id, + termSnapshot: q.tfq.distractor.termSnapshot || q.tfq.distractor.term || "", + defSnapshot: q.tfq.distractor.defSnapshot || q.tfq.distractor.def || "" + } : null, + answeredBool: q.tfq.answeredBool + }; + } else if (q.frq) { + type = "frq"; + termAtp = q.frq.term; + correct = q.frq.correct; + answerWith = q.frq.answerWith; + + let userMarkedCorrect = q.frq.userMarkedCorrect; + if (!correct && userMarkedCorrect) { + correct = true; + userMarkedCorrect = true; + } + + qData = { + answeredString: q.frq.answeredString, + userMarkedCorrect: userMarkedCorrect + }; + } + + if (termAtp) { + await tx.table("practiceTestQuestions").add({ + practiceTestId: pt.id, + termId: termAtp.id, + termSnapshot: termAtp.termSnapshot || termAtp.term || "", + defSnapshot: termAtp.defSnapshot || termAtp.def || "", + type: type, + position: i, + correct: correct, + answerWith: answerWith, + data: qData + }); + } + } + } + delete pt.questions; + delete pt.questionTermIds; + delete pt.distractorTermIds; + await tx.table("practiceTests").put(pt); + }); +}); + +export type { Studyset, Term, TermAtp, PracticeTest, PracticeTestQuestion, MCQData, TFQData, FRQData, Question, MCQ, TFQ, FRQ, TermProgress, TermConfusionPair } export { db }; diff --git a/src/index.ts b/src/index.ts index 4a51299..df49889 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,7 @@ * https://github.com/quizfreely/idb-api-layer */ import Dexie from 'dexie'; -import { db, Term, TermConfusionPair, TermProgress, TermProgressHistory } from "./db"; +import { db, Term, TermConfusionPair, TermProgress, PracticeTestQuestion } from "./db"; import { idbLayerImg } from "./images"; function isTitleValid(newTitle: string) { @@ -28,7 +28,6 @@ type StudysetResolveProps = { } type TermResolveProps = { progress?: boolean; - progressHistory?: boolean; topConfusionPairs?: boolean; topReverseConfusionPairs?: boolean; termImageUrl?: boolean; @@ -57,6 +56,12 @@ export const idbApiLayer = { studysets[0].practiceTests?.sort( (a, b) => b.timestamp.localeCompare(a.timestamp) ); + + await Promise.all(studysets[0].practiceTests.map(async pt => { + pt.questions = await db.practiceTestQuestions + .where("practiceTestId").equals(pt.id) + .sortBy("position"); + })); } return studysets[0]; @@ -71,7 +76,6 @@ export const idbApiLayer = { true ).toArray(); if (resolveProps?.progress || - resolveProps?.progressHistory || resolveProps?.topConfusionPairs || resolveProps?.topReverseConfusionPairs || resolveProps?.termImageUrl || @@ -80,7 +84,6 @@ export const idbApiLayer = { await Promise.all(terms.map(async term => { const promises: { progress?: Promise; - progressHistory?: Promise; topConfusionPairs?: Promise; topReverseConfusionPairs?: Promise; termImageUrl?: Promise; @@ -89,9 +92,6 @@ export const idbApiLayer = { if (resolveProps?.progress) { promises.progress = db.termProgress.where("termId").equals(term.id).toArray(); } - if (resolveProps?.progressHistory) { - promises.progressHistory = db.termProgressHistory.where("termId").equals(term.id).toArray(); - } if (resolveProps?.topConfusionPairs) { promises.topConfusionPairs = this.getTopConfusionPairs(term.id); } @@ -107,14 +107,12 @@ export const idbApiLayer = { const results = await Promise.all(Object.entries(promises).map(async ([k, p]) => [k, await p])); const resolved = Object.fromEntries(results) as { progress?: TermProgress[]; - progressHistory?: TermProgressHistory[]; topConfusionPairs?: TermConfusionPair[]; topReverseConfusionPairs?: TermConfusionPair[]; termImageUrl?: string; defImageUrl?: string; }; term.progress = resolved.progress?.[0] ?? undefined; - term.progressHistory = resolved.progressHistory; term.topConfusionPairs = resolved.topConfusionPairs; term.topReverseConfusionPairs = resolved.topReverseConfusionPairs; term.termImageUrl = term.termImageKey == null ? null : resolved.termImageUrl; @@ -135,9 +133,6 @@ export const idbApiLayer = { if (resolveProps?.progress) { term.progress = (await db.termProgress.where("termId").equals(termId).toArray())?.[0]; } - if (resolveProps?.progressHistory) { - term.progressHistory = await db.termProgressHistory.where("termId").equals(termId).toArray(); - } if (resolveProps?.topConfusionPairs) { term.topConfusionPairs = await this.getTopConfusionPairs(term.id); } @@ -234,7 +229,6 @@ export const idbApiLayer = { for (const { termId, termReviewedAt, defReviewedAt, - termLeitnerSystemBox, defLeitnerSystemBox, termCorrectIncrease, termIncorrectIncrease, defCorrectIncrease, defIncorrectIncrease } of termProgressArray) { @@ -258,25 +252,14 @@ export const idbApiLayer = { defReviewCount: defReviewedAt != null ? (existingProgress[0]?.defReviewCount ?? 0) + 1 : existingProgress[0]?.defReviewCount, - termLeitnerSystemBox: termLeitnerSystemBox ?? existingProgress[0].termLeitnerSystemBox, - defLeitnerSystemBox: defLeitnerSystemBox ?? existingProgress[0].defLeitnerSystemBox, termCorrectCount: termCorrectCount, termIncorrectCount: termIncorrectCount, defCorrectCount: defCorrectCount, defIncorrectCount: defIncorrectCount } ); - - await db.termProgressHistory.add({ - termId: termId, - timestamp: (new Date()).toISOString(), - termCorrectCount: termCorrectCount, - termIncorrectCount: termIncorrectCount, - defCorrectCount: defCorrectCount, - defIncorrectCount: defIncorrectCount - }) } else { - const newProgressId = await db.termProgress.add({ + await db.termProgress.add({ termId: termId, termFirstReviewedAt: termReviewedAt, termLastReviewedAt: termReviewedAt, @@ -286,17 +269,6 @@ export const idbApiLayer = { defLastReviewedAt: defReviewedAt, defReviewCount: defReviewedAt != null ? 1 : 0, - termLeitnerSystemBox: termLeitnerSystemBox, - defLeitnerSystemBox: defLeitnerSystemBox, - termCorrectCount: termCorrectIncrease ?? 0, - termIncorrectCount: termIncorrectIncrease ?? 0, - defCorrectCount: defCorrectIncrease ?? 0, - defIncorrectCount: defIncorrectIncrease ?? 0 - }); - - await db.termProgressHistory.add({ - termId: termId, - timestamp: (new Date()).toISOString(), termCorrectCount: termCorrectIncrease ?? 0, termIncorrectCount: termIncorrectIncrease ?? 0, defCorrectCount: defCorrectIncrease ?? 0, @@ -389,47 +361,95 @@ export const idbApiLayer = { return true; }, recordPracticeTest: async function (practiceTest: any) { - return await db.transaction('rw', [db.practiceTests, db.termProgress, db.termProgressHistory, db.terms], async () => { + return await db.transaction('rw', [db.practiceTests, db.practiceTestQuestions, db.termProgress, db.terms], async () => { const rnISOString = (new Date()).toISOString(); const termProgressMap = new Map(); const studysetIds = new Set(); - const questionTermIds = new Set(); - const distractorTermIds = new Set(); + const involvedTermIds = new Set(); let questionsCorrect = 0; let questionsTotal = 0; + const questionsToInsert: any[] = []; + if (practiceTest.questions && Array.isArray(practiceTest.questions)) { questionsTotal = practiceTest.questions.length; - for (const q of practiceTest.questions) { + for (let i = 0; i < practiceTest.questions.length; i++) { + const q = practiceTest.questions[i]; if (!q) continue; + let termId: any = null; let answerWith: string | null = null; let correct = false; + let type: "mcq" | "tfq" | "frq" = "mcq"; + let termSnapshot = ""; + let defSnapshot = ""; + let qData: any = {}; if (q.mcq) { + type = "mcq"; if (!q.mcq.term) throw new Error("MCQ question is missing term"); termId = q.mcq.term.id; - questionTermIds.add(termId); - (q.mcq.distractors || []).forEach((d: any) => { if (d.id) distractorTermIds.add(d.id); }); + termSnapshot = q.mcq.term.termSnapshot || q.mcq.term.term || ""; + defSnapshot = q.mcq.term.defSnapshot || q.mcq.term.def || ""; answerWith = q.mcq.answerWith; correct = !!q.mcq.correct; + qData = { + distractors: (q.mcq.distractors || []).map((d: any) => { + if (d.id) involvedTermIds.add(d.id); + return { + id: d.id, + termSnapshot: d.termSnapshot || d.term || "", + defSnapshot: d.defSnapshot || d.def || "" + }; + }), + correctChoiceIndex: q.mcq.correctChoiceIndex, + answeredIndex: q.mcq.answeredIndex + }; } else if (q.tfq) { + type = "tfq"; if (!q.tfq.term) throw new Error("TFQ question is missing term"); termId = q.tfq.term.id; - questionTermIds.add(termId); - if (q.tfq.distractor?.id) distractorTermIds.add(q.tfq.distractor.id); + termSnapshot = q.tfq.term.termSnapshot || q.tfq.term.term || ""; + defSnapshot = q.tfq.term.defSnapshot || q.tfq.term.def || ""; answerWith = q.tfq.answerWith; correct = !!q.tfq.correct; + if (q.tfq.distractor?.id) involvedTermIds.add(q.tfq.distractor.id); + qData = { + distractor: q.tfq.distractor ? { + id: q.tfq.distractor.id, + termSnapshot: q.tfq.distractor.termSnapshot || q.tfq.distractor.term || "", + defSnapshot: q.tfq.distractor.defSnapshot || q.tfq.distractor.def || "" + } : null, + answeredBool: q.tfq.answeredBool + }; } else if (q.frq) { + type = "frq"; if (!q.frq.term) throw new Error("FRQ question is missing term"); termId = q.frq.term.id; - questionTermIds.add(termId); + termSnapshot = q.frq.term.termSnapshot || q.frq.term.term || ""; + defSnapshot = q.frq.term.defSnapshot || q.frq.term.def || ""; answerWith = q.frq.answerWith; correct = !!q.frq.correct || !!q.frq.userMarkedCorrect; + qData = { + answeredString: q.frq.answeredString || "", + userMarkedCorrect: !!q.frq.userMarkedCorrect + }; } if (correct) questionsCorrect++; if (termId == null) continue; + involvedTermIds.add(termId); + + questionsToInsert.push({ + termId, + termSnapshot, + defSnapshot, + type, + position: i, + correct, + answerWith, + data: qData + }); let tp = termProgressMap.get(termId); if (!tp) { @@ -437,8 +457,6 @@ export const idbApiLayer = { termId, termReviewedAt: null, defReviewedAt: null, - termLeitnerSystemBox: null, - defLeitnerSystemBox: null, termCorrectIncrease: 0, termIncorrectIncrease: 0, defCorrectIncrease: 0, @@ -459,29 +477,21 @@ export const idbApiLayer = { if (answerWith === "DEF") { tp.defIncorrectIncrease += 1; tp.defReviewedAt = rnISOString; - tp.defLeitnerSystemBox = 1; } else { tp.termIncorrectIncrease += 1; tp.termReviewedAt = rnISOString; - tp.termLeitnerSystemBox = 1; } } } } // Fetch studysetIds for all involved terms - const combinedTermIds = new Set([...questionTermIds, ...distractorTermIds]); - const allTerms = await db.terms.bulkGet(Array.from(combinedTermIds) as number[]); + const allTerms = await db.terms.bulkGet(Array.from(involvedTermIds) as number[]); allTerms.forEach(t => { if (t?.studysetId) studysetIds.add(t.studysetId); }); for (const tp of termProgressMap.values()) { const existingProgress = await db.termProgress.where("termId").equals(tp.termId).toArray(); if (existingProgress?.length > 0) { - const termCorrectCount = (existingProgress[0].termCorrectCount) + (tp.termCorrectIncrease); - const termIncorrectCount = (existingProgress[0].termIncorrectCount) + (tp.termIncorrectIncrease); - const defCorrectCount = (existingProgress[0].defCorrectCount) + (tp.defCorrectIncrease); - const defIncorrectCount = (existingProgress[0].defIncorrectCount) + (tp.defIncorrectIncrease); - await db.termProgress.update( existingProgress[0].id, { @@ -495,23 +505,12 @@ export const idbApiLayer = { defReviewCount: tp.defReviewedAt != null ? (existingProgress[0]?.defReviewCount ?? 0) + 1 : existingProgress[0]?.defReviewCount, - termLeitnerSystemBox: tp.termLeitnerSystemBox ?? existingProgress[0].termLeitnerSystemBox, - defLeitnerSystemBox: tp.defLeitnerSystemBox ?? existingProgress[0].defLeitnerSystemBox, - termCorrectCount: termCorrectCount, - termIncorrectCount: termIncorrectCount, - defCorrectCount: defCorrectCount, - defIncorrectCount: defIncorrectCount + termCorrectCount: (existingProgress[0].termCorrectCount) + (tp.termCorrectIncrease), + termIncorrectCount: (existingProgress[0].termIncorrectCount) + (tp.termIncorrectIncrease), + defCorrectCount: (existingProgress[0].defCorrectCount) + (tp.defCorrectIncrease), + defIncorrectCount: (existingProgress[0].defIncorrectCount) + (tp.defIncorrectIncrease) } ); - - await db.termProgressHistory.add({ - termId: tp.termId, - timestamp: rnISOString, - termCorrectCount: termCorrectCount, - termIncorrectCount: termIncorrectCount, - defCorrectCount: defCorrectCount, - defIncorrectCount: defIncorrectCount - }); } else { await db.termProgress.add({ termId: tp.termId, @@ -521,17 +520,6 @@ export const idbApiLayer = { defFirstReviewedAt: tp.defReviewedAt, defLastReviewedAt: tp.defReviewedAt, defReviewCount: tp.defReviewedAt != null ? 1 : 0, - termLeitnerSystemBox: tp.termLeitnerSystemBox, - defLeitnerSystemBox: tp.defLeitnerSystemBox, - termCorrectCount: tp.termCorrectIncrease, - termIncorrectCount: tp.termIncorrectIncrease, - defCorrectCount: tp.defCorrectIncrease, - defIncorrectCount: tp.defIncorrectIncrease - }); - - await db.termProgressHistory.add({ - termId: tp.termId, - timestamp: rnISOString, termCorrectCount: tp.termCorrectIncrease, termIncorrectCount: tp.termIncorrectIncrease, defCorrectCount: tp.defCorrectIncrease, @@ -540,71 +528,99 @@ export const idbApiLayer = { } } - practiceTest.questionsCorrect = questionsCorrect; - practiceTest.questionsTotal = questionsTotal; - practiceTest.studysetIds = Array.from(studysetIds); - practiceTest.questionTermIds = Array.from(questionTermIds); - practiceTest.distractorTermIds = Array.from(distractorTermIds); - if (!practiceTest.timestamp) practiceTest.timestamp = rnISOString; + const ptRecord = { + timestamp: practiceTest.timestamp || rnISOString, + questionsCorrect, + questionsTotal, + studysetIds: Array.from(studysetIds) + }; + + const ptId = await db.practiceTests.add(ptRecord); + + for (const q of questionsToInsert) { + q.practiceTestId = ptId; + await db.practiceTestQuestions.add(q); + } - const id = await db.practiceTests.add(practiceTest); - return await db.practiceTests.get(id); + return await this.getPracticeTestWithQuestions(ptId); }); }, - updatePracticeTest: async function (id: number, practiceTest: any) { - return await db.transaction('rw', [db.practiceTests, db.terms], async () => { - const studysetIds = new Set(); - const questionTermIds = new Set(); - const distractorTermIds = new Set(); - let questionsCorrect = 0; - let questionsTotal = 0; + getPracticeTestWithQuestions: async function(ptId: number) { + const pt = await db.practiceTests.get(ptId); + if (!pt) return null; + pt.questions = await db.practiceTestQuestions + .where("practiceTestId").equals(ptId) + .sortBy("position"); + return pt; + }, + updatePracticeTestQuestion: async function (id: number, correct: boolean, userMarkedCorrect?: boolean) { + return await db.transaction('rw', [db.practiceTests, db.practiceTestQuestions, db.termProgress], async () => { + const question = await db.practiceTestQuestions.get(id); + if (!question) throw new Error("Question not found"); - if (practiceTest.questions && Array.isArray(practiceTest.questions)) { - questionsTotal = practiceTest.questions.length; - for (const q of practiceTest.questions) { - if (!q) continue; - let correct = false; - if (q.mcq) { - if (!q.mcq.term) throw new Error("MCQ question is missing term"); - questionTermIds.add(q.mcq.term.id); - (q.mcq.distractors || []).forEach((d: any) => { if (d.id) distractorTermIds.add(d.id); }); - correct = !!q.mcq.correct; - } else if (q.tfq) { - if (!q.tfq.term) throw new Error("TFQ question is missing term"); - questionTermIds.add(q.tfq.term.id); - if (q.tfq.distractor?.id) distractorTermIds.add(q.tfq.distractor.id); - correct = !!q.tfq.correct; - } else if (q.frq) { - if (!q.frq.term) throw new Error("FRQ question is missing term"); - questionTermIds.add(q.frq.term.id); - correct = !!q.frq.correct || !!q.frq.userMarkedCorrect; + const wasCorrect = question.correct; + const isCorrect = correct; + + if (wasCorrect === isCorrect && question.type === "frq" && (question.data as any).userMarkedCorrect === userMarkedCorrect) { + return question; + } + + // Update question + const newData = { ...question.data } as any; + if (question.type === "frq") { + newData.userMarkedCorrect = userMarkedCorrect; + } + await db.practiceTestQuestions.update(id, { + correct: isCorrect, + data: newData + }); + + // Update practice test accuracy + if (wasCorrect !== isCorrect) { + const pt = await db.practiceTests.get(question.practiceTestId); + if (pt) { + await db.practiceTests.update(pt.id, { + questionsCorrect: pt.questionsCorrect + (isCorrect ? 1 : -1) + }); + } + + // Update term progress + const existingProgress = await db.termProgress.where("termId").equals(question.termId).toArray(); + if (existingProgress?.length > 0) { + const changes: any = {}; + if (question.answerWith === "DEF") { + changes.defCorrectCount = existingProgress[0].defCorrectCount + (isCorrect ? 1 : -1); + changes.defIncorrectCount = existingProgress[0].defIncorrectCount + (isCorrect ? -1 : 1); + } else { + changes.termCorrectCount = existingProgress[0].termCorrectCount + (isCorrect ? 1 : -1); + changes.termIncorrectCount = existingProgress[0].termIncorrectCount + (isCorrect ? -1 : 1); } - if (correct) questionsCorrect++; + await db.termProgress.update(existingProgress[0].id, changes); } } - const combinedTermIds = new Set([...questionTermIds, ...distractorTermIds]); - const allTerms = await db.terms.bulkGet(Array.from(combinedTermIds) as number[]); - allTerms.forEach(t => { if (t?.studysetId) studysetIds.add(t.studysetId); }); - - await db.practiceTests.update(id, { - questions: practiceTest.questions, - questionsCorrect, - questionsTotal, - studysetIds: Array.from(studysetIds), - questionTermIds: Array.from(questionTermIds), - distractorTermIds: Array.from(distractorTermIds) - }); - return await db.practiceTests.get(id); + return await db.practiceTestQuestions.get(id); }); }, getPracticeTestsByTermId: async function (termId: number | string) { - const tests = await db.practiceTests - .where("questionTermIds").equals(termId) - .or("distractorTermIds").equals(termId) - .distinct() - .toArray(); - tests.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); - return tests; + const questionIds = await db.practiceTestQuestions + .where("termId").equals(termId) + .primaryKeys(); + + const questions = await db.practiceTestQuestions.bulkGet(questionIds as number[]); + const ptIds = new Set(); + questions.forEach(q => { if (q) ptIds.add(q.practiceTestId); }); + + const tests = await db.practiceTests.bulkGet(Array.from(ptIds)); + const filteredTests = tests.filter((t): t is NonNullable => t !== undefined); + + await Promise.all(filteredTests.map(async pt => { + pt.questions = await db.practiceTestQuestions + .where("practiceTestId").equals(pt.id) + .sortBy("position"); + })); + + filteredTests.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); + return filteredTests; } } diff --git a/tests/practice_tests.spec.ts b/tests/practice_tests.spec.ts index e3cee5a..37d23ae 100644 --- a/tests/practice_tests.spec.ts +++ b/tests/practice_tests.spec.ts @@ -15,7 +15,7 @@ test('practice tests: recording and retrieval in studyset', async ({ page }) => { term: "T1", def: "D1", sortOrder: 0 } ]); const terms = await window.idbApiLayer.getTermsByStudysetId(sid); - const t1 = { id: terms[0].id, term: terms[0].term, def: terms[0].def }; + const t1 = { id: terms[0].id, termSnapshot: terms[0].term, defSnapshot: terms[0].def }; // Record two practice tests at different timestamps const time1 = "2024-01-01T10:00:00.000Z"; @@ -43,11 +43,14 @@ test('practice tests: recording and retrieval in studyset', async ({ page }) => // Should be sorted by timestamp descending expect(result.studysetWithTests?.practiceTests?.[0].timestamp).toBe("2024-01-02T10:00:00.000Z"); expect(result.studysetWithTests?.practiceTests?.[0].questionsCorrect).toBe(0); + expect(result.studysetWithTests?.practiceTests?.[0].questions).toHaveLength(1); + expect(result.studysetWithTests?.practiceTests?.[0].questions[0].termId).toBeDefined(); + expect(result.studysetWithTests?.practiceTests?.[1].timestamp).toBe("2024-01-01T10:00:00.000Z"); expect(result.studysetWithTests?.practiceTests?.[1].questionsCorrect).toBe(1); }); -test('practice tests: update and retrieval by term', async ({ page }) => { +test('practice tests: update question and retrieval by term', async ({ page }) => { const result = await page.evaluate(async () => { const sid = await window.idbApiLayer.createStudyset({ title: "S", draft: false }); await window.idbApiLayer.createTerms(sid, [ @@ -55,34 +58,29 @@ test('practice tests: update and retrieval by term', async ({ page }) => { { term: "T2", def: "D2", sortOrder: 1 } ]); const terms = await window.idbApiLayer.getTermsByStudysetId(sid); - const t1 = { id: terms[0].id, term: terms[0].term, def: terms[0].def }; - const t2 = { id: terms[1].id, term: terms[1].term, def: terms[1].def }; + const t1 = { id: terms[0].id, termSnapshot: terms[0].term, defSnapshot: terms[0].def }; + const t2 = { id: terms[1].id, termSnapshot: terms[1].term, defSnapshot: terms[1].def }; const pt = await window.idbApiLayer.recordPracticeTest({ questions: [ - { tfq: { term: t1, answerWith: "DEF", correct: true, answeredBool: true } } + { frq: { term: t1, answerWith: "DEF", correct: false, answeredString: "wrong" } } ] }); - // Update the test to include another term as a question and another as a distractor - await window.idbApiLayer.updatePracticeTest(pt.id, { - questions: [ - { mcq: { term: t1, answerWith: "DEF", correct: true, correctChoiceIndex: 0, answeredIndex: 0, distractors: [t2] } }, - { frq: { term: t2, answerWith: "TERM", correct: false, answeredString: "wrong" } } - ] - }); + const questionId = pt.questions[0].id; + // Mark FRQ as manually correct + await window.idbApiLayer.updatePracticeTestQuestion(questionId, true, true); const testsForT1 = await window.idbApiLayer.getPracticeTestsByTermId(t1.id); - const testsForT2 = await window.idbApiLayer.getPracticeTestsByTermId(t2.id); const updatedPt = await window.db.practiceTests.get(pt.id); + const updatedQuestion = await window.db.practiceTestQuestions.get(questionId); - return { testsForT1, testsForT2, updatedPt }; + return { testsForT1, updatedPt, updatedQuestion }; }); expect(result.testsForT1).toHaveLength(1); - expect(result.testsForT2).toHaveLength(1); expect(result.updatedPt.questionsCorrect).toBe(1); - expect(result.updatedPt.questionsTotal).toBe(2); - expect(result.updatedPt.questionTermIds).toContain(result.testsForT1[0].questionTermIds[0]); - expect(result.updatedPt.distractorTermIds).toContain(result.testsForT2[0].distractorTermIds[0]); + expect(result.updatedPt.questionsTotal).toBe(1); + expect(result.updatedQuestion.correct).toBe(true); + expect(result.updatedQuestion.data.userMarkedCorrect).toBe(true); }); diff --git a/tests/progress.spec.ts b/tests/progress.spec.ts index 2941a12..eddffad 100644 --- a/tests/progress.spec.ts +++ b/tests/progress.spec.ts @@ -23,38 +23,29 @@ test('updateTermProgress: initial and update', async ({ page }) => { await window.idbApiLayer.updateTermProgress([{ termId: tid, termReviewedAt: rnISOString, - termCorrectIncrease: 1, - termLeitnerSystemBox: 1 + termCorrectIncrease: 1 }]); - let termWithProgress = await window.idbApiLayer.getTermById(tid, { progress: true, progressHistory: true }); + let termWithProgress = await window.idbApiLayer.getTermById(tid, { progress: true }); const p1 = termWithProgress?.progress; - const h1 = termWithProgress?.progressHistory; // Update progress await window.idbApiLayer.updateTermProgress([{ termId: tid, defReviewedAt: rnISOString, - defCorrectIncrease: 2, - defLeitnerSystemBox: 2 + defCorrectIncrease: 2 }]); - termWithProgress = await window.idbApiLayer.getTermById(tid, { progress: true, progressHistory: true }); + termWithProgress = await window.idbApiLayer.getTermById(tid, { progress: true }); const p2 = termWithProgress?.progress; - const h2 = termWithProgress?.progressHistory; - return { p1, h1, p2, h2 }; + return { p1, p2 }; }); expect(result.p1?.termCorrectCount).toBe(1); expect(result.p1?.termReviewCount).toBe(1); - expect(result.p1?.termLeitnerSystemBox).toBe(1); - expect(result.h1).toHaveLength(1); expect(result.p2?.termCorrectCount).toBe(1); expect(result.p2?.defCorrectCount).toBe(2); expect(result.p2?.defReviewCount).toBe(1); - expect(result.p2?.defLeitnerSystemBox).toBe(2); - expect(result.p2?.termLeitnerSystemBox).toBe(1); - expect(result.h2).toHaveLength(2); });