aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/db
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/db')
-rw-r--r--src/client/db/study-builder.test.ts169
-rw-r--r--src/client/db/study-builder.ts82
2 files changed, 251 insertions, 0 deletions
diff --git a/src/client/db/study-builder.test.ts b/src/client/db/study-builder.test.ts
new file mode 100644
index 0000000..1b5beae
--- /dev/null
+++ b/src/client/db/study-builder.test.ts
@@ -0,0 +1,169 @@
+/**
+ * @vitest-environment jsdom
+ */
+import "fake-indexeddb/auto";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { CardState, db, FieldType } from "./index";
+import {
+ localCardRepository,
+ localDeckRepository,
+ localNoteFieldTypeRepository,
+ localNoteFieldValueRepository,
+ localNoteRepository,
+ localNoteTypeRepository,
+} from "./repositories";
+import { buildStudyCards } from "./study-builder";
+
+async function clearDb() {
+ await db.decks.clear();
+ await db.cards.clear();
+ await db.reviewLogs.clear();
+ await db.noteTypes.clear();
+ await db.noteFieldTypes.clear();
+ await db.notes.clear();
+ await db.noteFieldValues.clear();
+}
+
+async function seedDeckWithDueCard() {
+ const deck = await localDeckRepository.create({
+ userId: "user-1",
+ name: "Vocab",
+ description: null,
+ defaultNoteTypeId: null,
+ });
+
+ const noteType = await localNoteTypeRepository.create({
+ userId: "user-1",
+ name: "Basic",
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ isReversible: false,
+ });
+
+ const frontField = await localNoteFieldTypeRepository.create({
+ noteTypeId: noteType.id,
+ name: "Front",
+ order: 0,
+ });
+ const backField = await localNoteFieldTypeRepository.create({
+ noteTypeId: noteType.id,
+ name: "Back",
+ order: 1,
+ });
+
+ const note = await localNoteRepository.create({
+ deckId: deck.id,
+ noteTypeId: noteType.id,
+ });
+
+ await localNoteFieldValueRepository.create({
+ noteId: note.id,
+ noteFieldTypeId: frontField.id,
+ value: "Hello",
+ });
+ await localNoteFieldValueRepository.create({
+ noteId: note.id,
+ noteFieldTypeId: backField.id,
+ value: "こんにちは",
+ });
+
+ const card = await localCardRepository.create({
+ deckId: deck.id,
+ noteId: note.id,
+ isReversed: false,
+ front: "Hello",
+ back: "こんにちは",
+ });
+ // Cards default to state=New with due=now, which counts as due.
+
+ return { deck, noteType, note, card };
+}
+
+describe("buildStudyCards", () => {
+ beforeEach(async () => {
+ await clearDb();
+ });
+
+ afterEach(async () => {
+ await clearDb();
+ });
+
+ it("assembles a StudyCard from local note + note type + field values", async () => {
+ const { deck, card } = await seedDeckWithDueCard();
+
+ const studyCards = await buildStudyCards(deck.id);
+
+ expect(studyCards).toHaveLength(1);
+ expect(studyCards[0]).toMatchObject({
+ id: card.id,
+ deckId: deck.id,
+ isReversed: false,
+ state: CardState.New,
+ noteType: {
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ },
+ fieldValuesMap: {
+ Front: "Hello",
+ Back: "こんにちは",
+ },
+ });
+ });
+
+ it("skips cards whose note has been soft-deleted", async () => {
+ const { deck, note } = await seedDeckWithDueCard();
+ await localNoteRepository.delete(note.id);
+
+ const studyCards = await buildStudyCards(deck.id);
+
+ expect(studyCards).toHaveLength(0);
+ });
+
+ it("skips cards whose note type has been soft-deleted", async () => {
+ const { deck, noteType } = await seedDeckWithDueCard();
+ await localNoteTypeRepository.delete(noteType.id);
+
+ const studyCards = await buildStudyCards(deck.id);
+
+ expect(studyCards).toHaveLength(0);
+ });
+
+ it("skips cards whose due date is past the study-day boundary", async () => {
+ const { deck, card } = await seedDeckWithDueCard();
+ // Push due date a year into the future.
+ await db.cards.update(card.id, {
+ due: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
+ });
+
+ const studyCards = await buildStudyCards(deck.id);
+
+ expect(studyCards).toHaveLength(0);
+ });
+
+ it("returns the field type assignments without losing fields", async () => {
+ const { deck, noteType } = await seedDeckWithDueCard();
+
+ // Add a third unused field type to make sure the builder handles
+ // extra fields without value (gap).
+ await localNoteFieldTypeRepository.create({
+ noteTypeId: noteType.id,
+ name: "Notes",
+ order: 2,
+ });
+
+ const studyCards = await buildStudyCards(deck.id);
+
+ expect(studyCards).toHaveLength(1);
+ const studyCard = studyCards[0];
+ if (!studyCard) throw new Error("expected one study card");
+ expect(Object.keys(studyCard.fieldValuesMap).sort()).toEqual([
+ "Back",
+ "Front",
+ ]);
+ });
+
+ it("ignores text-type field values constant when building map", async () => {
+ // Sanity check that FieldType.Text is what's set on field types.
+ expect(FieldType.Text).toBe("text");
+ });
+});
diff --git a/src/client/db/study-builder.ts b/src/client/db/study-builder.ts
new file mode 100644
index 0000000..25d1652
--- /dev/null
+++ b/src/client/db/study-builder.ts
@@ -0,0 +1,82 @@
+import type { CardStateType } from "./index";
+import {
+ localCardRepository,
+ localNoteFieldTypeRepository,
+ localNoteFieldValueRepository,
+ localNoteRepository,
+ localNoteTypeRepository,
+} from "./repositories";
+
+export interface StudyCardView {
+ id: string;
+ deckId: string;
+ noteId: string;
+ isReversed: boolean;
+ state: CardStateType;
+ noteType: {
+ frontTemplate: string;
+ backTemplate: string;
+ };
+ fieldValuesMap: Record<string, string>;
+}
+
+/**
+ * Build study card views for all due cards in a deck from IndexedDB.
+ *
+ * Cards whose note or note type has been soft-deleted are skipped, mirroring
+ * the server-side enrichment in `enrichCardsForStudy`.
+ */
+export async function buildStudyCards(
+ deckId: string,
+): Promise<StudyCardView[]> {
+ const dueCards = await localCardRepository.findDueCards(deckId);
+ if (dueCards.length === 0) {
+ return [];
+ }
+
+ const noteTypeFieldsCache = new Map<string, Map<string, string>>();
+ const result: StudyCardView[] = [];
+
+ for (const card of dueCards) {
+ const note = await localNoteRepository.findById(card.noteId);
+ if (!note || note.deletedAt !== null) continue;
+
+ const noteType = await localNoteTypeRepository.findById(note.noteTypeId);
+ if (!noteType || noteType.deletedAt !== null) continue;
+
+ let fieldTypeIdToName = noteTypeFieldsCache.get(noteType.id);
+ if (!fieldTypeIdToName) {
+ const fieldTypes = await localNoteFieldTypeRepository.findByNoteTypeId(
+ noteType.id,
+ );
+ fieldTypeIdToName = new Map(fieldTypes.map((ft) => [ft.id, ft.name]));
+ noteTypeFieldsCache.set(noteType.id, fieldTypeIdToName);
+ }
+
+ const fieldValues = await localNoteFieldValueRepository.findByNoteId(
+ note.id,
+ );
+ const fieldValuesMap: Record<string, string> = {};
+ for (const fv of fieldValues) {
+ const name = fieldTypeIdToName.get(fv.noteFieldTypeId);
+ if (name) {
+ fieldValuesMap[name] = fv.value;
+ }
+ }
+
+ result.push({
+ id: card.id,
+ deckId: card.deckId,
+ noteId: card.noteId,
+ isReversed: card.isReversed,
+ state: card.state,
+ noteType: {
+ frontTemplate: noteType.frontTemplate,
+ backTemplate: noteType.backTemplate,
+ },
+ fieldValuesMap,
+ });
+ }
+
+ return result;
+}