From ce9011bf351d9666bb2e81c92ae06a0eb1716d12 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Wed, 31 Dec 2025 13:25:42 +0900 Subject: feat(study): render note-based cards using template system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update StudyPage to support both legacy cards (direct front/back) and note-based cards (template rendering with field values). Add new CardForStudy type that includes note type templates and field values as a name-value map for efficient client-side rendering. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/server/repositories/card.test.ts | 50 ++++++++++++++++ src/server/repositories/card.ts | 107 ++++++++++++++++++++++++++++++++++- src/server/repositories/types.ts | 20 +++++++ 3 files changed, 175 insertions(+), 2 deletions(-) (limited to 'src/server/repositories') diff --git a/src/server/repositories/card.test.ts b/src/server/repositories/card.test.ts index 64c071e..9d7ffa6 100644 --- a/src/server/repositories/card.test.ts +++ b/src/server/repositories/card.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { Card, + CardForStudy, CardRepository, CardWithNoteData, Note, @@ -85,6 +86,21 @@ function createMockCardWithNoteData( }; } +function createMockCardForStudy( + overrides: Partial = {}, +): CardForStudy { + const card = createMockCard({ + noteId: overrides.noteType ? "note-uuid-123" : null, + isReversed: overrides.noteType ? false : null, + ...overrides, + }); + return { + ...card, + noteType: overrides.noteType ?? null, + fieldValuesMap: overrides.fieldValuesMap ?? {}, + }; +} + function createMockCardRepo(): CardRepository { return { findByDeckId: vi.fn(), @@ -97,6 +113,7 @@ function createMockCardRepo(): CardRepository { softDeleteByNoteId: vi.fn(), findDueCards: vi.fn(), findDueCardsWithNoteData: vi.fn(), + findDueCardsForStudy: vi.fn(), updateFSRSFields: vi.fn(), }; } @@ -354,6 +371,39 @@ describe("Card interface contracts", () => { expect(cardWithNote).toHaveProperty("fieldValues"); expect(Array.isArray(cardWithNote.fieldValues)).toBe(true); }); + + it("CardForStudy extends Card with noteType and fieldValuesMap", () => { + const cardForStudy = createMockCardForStudy({ + noteType: { + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + }, + fieldValuesMap: { + Front: "Question", + Back: "Answer", + }, + }); + + expect(cardForStudy).toHaveProperty("id"); + expect(cardForStudy).toHaveProperty("deckId"); + expect(cardForStudy).toHaveProperty("noteType"); + expect(cardForStudy).toHaveProperty("fieldValuesMap"); + expect(cardForStudy.noteType?.frontTemplate).toBe("{{Front}}"); + expect(cardForStudy.fieldValuesMap.Front).toBe("Question"); + }); + + it("CardForStudy can represent legacy card with null noteType", () => { + const legacyCard = createMockCardForStudy({ + front: "Legacy Question", + back: "Legacy Answer", + }); + + expect(legacyCard.noteId).toBeNull(); + expect(legacyCard.noteType).toBeNull(); + expect(legacyCard.fieldValuesMap).toEqual({}); + expect(legacyCard.front).toBe("Legacy Question"); + expect(legacyCard.back).toBe("Legacy Answer"); + }); }); describe("Card and Note relationship", () => { diff --git a/src/server/repositories/card.ts b/src/server/repositories/card.ts index 92811d4..7116642 100644 --- a/src/server/repositories/card.ts +++ b/src/server/repositories/card.ts @@ -1,7 +1,19 @@ import { and, eq, isNull, lte, sql } from "drizzle-orm"; import { db } from "../db/index.js"; -import { CardState, cards, noteFieldValues, notes } from "../db/schema.js"; -import type { Card, CardRepository, CardWithNoteData } from "./types.js"; +import { + CardState, + cards, + noteFieldTypes, + noteFieldValues, + notes, + noteTypes, +} from "../db/schema.js"; +import type { + Card, + CardForStudy, + CardRepository, + CardWithNoteData, +} from "./types.js"; export const cardRepository: CardRepository = { async findByDeckId(deckId: string): Promise { @@ -219,6 +231,97 @@ export const cardRepository: CardRepository = { return cardsWithNoteData; }, + async findDueCardsForStudy( + deckId: string, + now: Date, + limit: number, + ): Promise { + const dueCards = await this.findDueCards(deckId, now, limit); + + const cardsForStudy: CardForStudy[] = []; + + for (const card of dueCards) { + // Legacy card (no note association) + if (!card.noteId) { + cardsForStudy.push({ + ...card, + noteType: null, + fieldValuesMap: {}, + }); + continue; + } + + // Fetch note to get noteTypeId + const noteResult = await db + .select() + .from(notes) + .where(and(eq(notes.id, card.noteId), isNull(notes.deletedAt))); + + const note = noteResult[0]; + if (!note) { + // Note was deleted, treat as legacy card + cardsForStudy.push({ + ...card, + noteType: null, + fieldValuesMap: {}, + }); + continue; + } + + // Fetch note type for templates + const noteTypeResult = await db + .select({ + frontTemplate: noteTypes.frontTemplate, + backTemplate: noteTypes.backTemplate, + }) + .from(noteTypes) + .where( + and(eq(noteTypes.id, note.noteTypeId), isNull(noteTypes.deletedAt)), + ); + + const noteType = noteTypeResult[0]; + if (!noteType) { + // Note type was deleted, treat as legacy card + cardsForStudy.push({ + ...card, + noteType: null, + fieldValuesMap: {}, + }); + continue; + } + + // Fetch field values with their field names + const fieldValuesWithNames = await db + .select({ + fieldName: noteFieldTypes.name, + value: noteFieldValues.value, + }) + .from(noteFieldValues) + .innerJoin( + noteFieldTypes, + eq(noteFieldValues.noteFieldTypeId, noteFieldTypes.id), + ) + .where(eq(noteFieldValues.noteId, card.noteId)); + + // Convert to name-value map + const fieldValuesMap: Record = {}; + for (const fv of fieldValuesWithNames) { + fieldValuesMap[fv.fieldName] = fv.value; + } + + cardsForStudy.push({ + ...card, + noteType: { + frontTemplate: noteType.frontTemplate, + backTemplate: noteType.backTemplate, + }, + fieldValuesMap, + }); + } + + return cardsForStudy; + }, + async updateFSRSFields( id: string, deckId: string, diff --git a/src/server/repositories/types.ts b/src/server/repositories/types.ts index 8b86061..c864be0 100644 --- a/src/server/repositories/types.ts +++ b/src/server/repositories/types.ts @@ -108,6 +108,21 @@ export interface CardWithNoteData extends Card { fieldValues: NoteFieldValue[]; } +/** + * Card data prepared for study, including all necessary template rendering info. + * For note-based cards, includes templates and field values as a name-value map. + * For legacy cards, note and templates are null. + */ +export interface CardForStudy extends Card { + /** Note type templates for rendering (null for legacy cards) */ + noteType: { + frontTemplate: string; + backTemplate: string; + } | null; + /** Field values as a name-value map for template rendering (empty for legacy cards) */ + fieldValuesMap: Record; +} + export interface CardRepository { findByDeckId(deckId: string): Promise; findById(id: string, deckId: string): Promise; @@ -139,6 +154,11 @@ export interface CardRepository { now: Date, limit: number, ): Promise; + findDueCardsForStudy( + deckId: string, + now: Date, + limit: number, + ): Promise; updateFSRSFields( id: string, deckId: string, -- cgit v1.2.3-70-g09d2