diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-31 13:25:42 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-31 13:25:42 +0900 |
| commit | ce9011bf351d9666bb2e81c92ae06a0eb1716d12 (patch) | |
| tree | beea42ad8c4b755d1c36705c6c052d5b0588eaac /src | |
| parent | 5e42032146de07d4ab53598e9311efd145f9dfc3 (diff) | |
| download | kioku-ce9011bf351d9666bb2e81c92ae06a0eb1716d12.tar.gz kioku-ce9011bf351d9666bb2e81c92ae06a0eb1716d12.tar.zst kioku-ce9011bf351d9666bb2e81c92ae06a0eb1716d12.zip | |
feat(study): render note-based cards using template system
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 <noreply@anthropic.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/client/pages/StudyPage.tsx | 47 | ||||
| -rw-r--r-- | src/server/repositories/card.test.ts | 50 | ||||
| -rw-r--r-- | src/server/repositories/card.ts | 107 | ||||
| -rw-r--r-- | src/server/repositories/types.ts | 20 | ||||
| -rw-r--r-- | src/server/routes/cards.test.ts | 1 | ||||
| -rw-r--r-- | src/server/routes/study.test.ts | 100 | ||||
| -rw-r--r-- | src/server/routes/study.ts | 6 |
7 files changed, 248 insertions, 83 deletions
diff --git a/src/client/pages/StudyPage.tsx b/src/client/pages/StudyPage.tsx index 5bd31c0..bdaf7e3 100644 --- a/src/client/pages/StudyPage.tsx +++ b/src/client/pages/StudyPage.tsx @@ -5,13 +5,16 @@ import { faSpinner, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useParams } from "wouter"; import { ApiClientError, apiClient } from "../api"; +import { renderCard } from "../utils/templateRenderer"; interface Card { id: string; deckId: string; + noteId: string | null; + isReversed: boolean | null; front: string; back: string; state: number; @@ -20,6 +23,13 @@ interface Card { difficulty: number; reps: number; lapses: number; + /** 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 */ + fieldValuesMap: Record<string, string>; } interface Deck { @@ -222,6 +232,30 @@ export function StudyPage() { return () => window.removeEventListener("keydown", handleKeyDown); }, [handleKeyDown]); + const currentCard = cards[currentIndex]; + const isSessionComplete = currentIndex >= cards.length && cards.length > 0; + const hasNoCards = !isLoading && cards.length === 0; + const remainingCards = cards.length - currentIndex; + + // Compute rendered card content for both legacy and note-based cards + const cardContent = useMemo(() => { + if (!currentCard) return null; + + // Note-based card: use template rendering + if (currentCard.noteType && currentCard.fieldValuesMap) { + const rendered = renderCard({ + frontTemplate: currentCard.noteType.frontTemplate, + backTemplate: currentCard.noteType.backTemplate, + fieldValues: currentCard.fieldValuesMap, + isReversed: currentCard.isReversed ?? false, + }); + return { front: rendered.front, back: rendered.back }; + } + + // Legacy card: use front/back directly + return { front: currentCard.front, back: currentCard.back }; + }, [currentCard]); + if (!deckId) { return ( <div className="min-h-screen bg-cream flex items-center justify-center"> @@ -238,11 +272,6 @@ export function StudyPage() { ); } - const currentCard = cards[currentIndex]; - const isSessionComplete = currentIndex >= cards.length && cards.length > 0; - const hasNoCards = !isLoading && cards.length === 0; - const remainingCards = cards.length - currentIndex; - return ( <div className="min-h-screen bg-cream flex flex-col"> {/* Header */} @@ -383,7 +412,7 @@ export function StudyPage() { )} {/* Active Study Card */} - {currentCard && !isSessionComplete && ( + {currentCard && cardContent && !isSessionComplete && ( <div data-testid="study-card" className="flex-1 flex flex-col"> {/* Card */} <button @@ -406,7 +435,7 @@ export function StudyPage() { data-testid="card-front" className="text-xl md:text-2xl text-ink font-medium whitespace-pre-wrap break-words leading-relaxed" > - {currentCard.front} + {cardContent.front} </p> <p className="mt-8 text-muted text-sm flex items-center gap-2"> <kbd className="px-2 py-0.5 bg-ivory rounded text-xs font-mono"> @@ -420,7 +449,7 @@ export function StudyPage() { data-testid="card-back" className="text-xl md:text-2xl text-ink font-medium whitespace-pre-wrap break-words leading-relaxed animate-fade-in" > - {currentCard.back} + {cardContent.back} </p> )} </button> 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> = {}, +): 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<Card[]> { @@ -219,6 +231,97 @@ export const cardRepository: CardRepository = { return cardsWithNoteData; }, + async findDueCardsForStudy( + deckId: string, + now: Date, + limit: number, + ): Promise<CardForStudy[]> { + 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<string, string> = {}; + 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<string, string>; +} + export interface CardRepository { findByDeckId(deckId: string): Promise<Card[]>; findById(id: string, deckId: string): Promise<Card | undefined>; @@ -139,6 +154,11 @@ export interface CardRepository { now: Date, limit: number, ): Promise<CardWithNoteData[]>; + findDueCardsForStudy( + deckId: string, + now: Date, + limit: number, + ): Promise<CardForStudy[]>; updateFSRSFields( id: string, deckId: string, diff --git a/src/server/routes/cards.test.ts b/src/server/routes/cards.test.ts index 53991f3..780ea44 100644 --- a/src/server/routes/cards.test.ts +++ b/src/server/routes/cards.test.ts @@ -26,6 +26,7 @@ function createMockCardRepo(): CardRepository { softDeleteByNoteId: vi.fn(), findDueCards: vi.fn(), findDueCardsWithNoteData: vi.fn(), + findDueCardsForStudy: vi.fn(), updateFSRSFields: vi.fn(), }; } diff --git a/src/server/routes/study.test.ts b/src/server/routes/study.test.ts index 77cb15c..41abecd 100644 --- a/src/server/routes/study.test.ts +++ b/src/server/routes/study.test.ts @@ -5,12 +5,10 @@ import { CardState, Rating } from "../db/schema.js"; import { errorHandler } from "../middleware/index.js"; import type { Card, + CardForStudy, CardRepository, - CardWithNoteData, Deck, DeckRepository, - Note, - NoteFieldValue, ReviewLog, ReviewLogRepository, } from "../repositories/index.js"; @@ -28,6 +26,7 @@ function createMockCardRepo(): CardRepository { softDeleteByNoteId: vi.fn(), findDueCards: vi.fn(), findDueCardsWithNoteData: vi.fn(), + findDueCardsForStudy: vi.fn(), updateFSRSFields: vi.fn(), }; } @@ -118,47 +117,19 @@ function createMockReviewLog(overrides: Partial<ReviewLog> = {}): ReviewLog { }; } -function createMockCardWithNoteData( - overrides: Partial<CardWithNoteData> = {}, -): CardWithNoteData { +function createMockCardForStudy( + overrides: Partial<CardForStudy> = {}, +): CardForStudy { return { ...createMockCard(overrides), - note: overrides.note ?? null, - fieldValues: overrides.fieldValues ?? [], - }; -} - -function createMockNote(overrides: Partial<Note> = {}): Note { - return { - id: "note-uuid-123", - deckId: "deck-uuid-123", - noteTypeId: "note-type-uuid-123", - createdAt: new Date("2024-01-01"), - updatedAt: new Date("2024-01-01"), - deletedAt: null, - syncVersion: 0, - ...overrides, - }; -} - -function createMockNoteFieldValue( - overrides: Partial<NoteFieldValue> = {}, -): NoteFieldValue { - return { - id: "field-value-uuid-123", - noteId: "note-uuid-123", - noteFieldTypeId: "field-type-uuid-123", - value: "Test value", - createdAt: new Date("2024-01-01"), - updatedAt: new Date("2024-01-01"), - syncVersion: 0, - ...overrides, + noteType: overrides.noteType ?? null, + fieldValuesMap: overrides.fieldValuesMap ?? {}, }; } interface StudyResponse { card?: Card; - cards?: CardWithNoteData[]; + cards?: CardForStudy[]; error?: { code: string; message: string; @@ -195,7 +166,7 @@ describe("GET /api/decks/:deckId/study", () => { vi.mocked(mockDeckRepo.findById).mockResolvedValue( createMockDeck({ id: DECK_ID }), ); - vi.mocked(mockCardRepo.findDueCardsWithNoteData).mockResolvedValue([]); + vi.mocked(mockCardRepo.findDueCardsForStudy).mockResolvedValue([]); const res = await app.request(`/api/decks/${DECK_ID}/study`, { method: "GET", @@ -209,36 +180,34 @@ describe("GET /api/decks/:deckId/study", () => { DECK_ID, "user-uuid-123", ); - expect(mockCardRepo.findDueCardsWithNoteData).toHaveBeenCalledWith( + expect(mockCardRepo.findDueCardsForStudy).toHaveBeenCalledWith( DECK_ID, expect.any(Date), 100, ); }); - it("returns due cards with note data", async () => { + it("returns due cards (legacy cards without note)", async () => { const mockCards = [ - createMockCardWithNoteData({ + createMockCardForStudy({ id: "card-1", front: "Q1", back: "A1", - note: null, - fieldValues: [], + noteType: null, + fieldValuesMap: {}, }), - createMockCardWithNoteData({ + createMockCardForStudy({ id: "card-2", front: "Q2", back: "A2", - note: null, - fieldValues: [], + noteType: null, + fieldValuesMap: {}, }), ]; vi.mocked(mockDeckRepo.findById).mockResolvedValue( createMockDeck({ id: DECK_ID }), ); - vi.mocked(mockCardRepo.findDueCardsWithNoteData).mockResolvedValue( - mockCards, - ); + vi.mocked(mockCardRepo.findDueCardsForStudy).mockResolvedValue(mockCards); const res = await app.request(`/api/decks/${DECK_ID}/study`, { method: "GET", @@ -248,32 +217,29 @@ describe("GET /api/decks/:deckId/study", () => { expect(res.status).toBe(200); const body = (await res.json()) as StudyResponse; expect(body.cards).toHaveLength(2); + expect(body.cards?.[0]?.noteType).toBeNull(); }); - it("returns due cards with note and field values when available", async () => { - const mockNote = createMockNote({ id: "note-1" }); - const mockFieldValues = [ - createMockNoteFieldValue({ noteId: "note-1", value: "Front" }), - createMockNoteFieldValue({ - id: "fv-2", - noteId: "note-1", - value: "Back", - }), - ]; + it("returns due cards with note type and field values when available", async () => { const mockCards = [ - createMockCardWithNoteData({ + createMockCardForStudy({ id: "card-1", noteId: "note-1", - note: mockNote, - fieldValues: mockFieldValues, + isReversed: false, + noteType: { + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + }, + fieldValuesMap: { + Front: "Question", + Back: "Answer", + }, }), ]; vi.mocked(mockDeckRepo.findById).mockResolvedValue( createMockDeck({ id: DECK_ID }), ); - vi.mocked(mockCardRepo.findDueCardsWithNoteData).mockResolvedValue( - mockCards, - ); + vi.mocked(mockCardRepo.findDueCardsForStudy).mockResolvedValue(mockCards); const res = await app.request(`/api/decks/${DECK_ID}/study`, { method: "GET", @@ -283,8 +249,8 @@ describe("GET /api/decks/:deckId/study", () => { expect(res.status).toBe(200); const body = (await res.json()) as StudyResponse; expect(body.cards).toHaveLength(1); - expect(body.cards?.[0]?.note?.id).toBe("note-1"); - expect(body.cards?.[0]?.fieldValues).toHaveLength(2); + expect(body.cards?.[0]?.noteType?.frontTemplate).toBe("{{Front}}"); + expect(body.cards?.[0]?.fieldValuesMap?.Front).toBe("Question"); }); it("returns 404 for non-existent deck", async () => { diff --git a/src/server/routes/study.ts b/src/server/routes/study.ts index ccb0692..9c16699 100644 --- a/src/server/routes/study.ts +++ b/src/server/routes/study.ts @@ -51,11 +51,7 @@ export function createStudyRouter(deps: StudyDependencies) { } const now = new Date(); - const dueCards = await cardRepo.findDueCardsWithNoteData( - deckId, - now, - 100, - ); + const dueCards = await cardRepo.findDueCardsForStudy(deckId, now, 100); return c.json({ cards: dueCards }, 200); }) |
