aboutsummaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-31 13:25:42 +0900
committernsfisis <nsfisis@gmail.com>2025-12-31 13:25:42 +0900
commitce9011bf351d9666bb2e81c92ae06a0eb1716d12 (patch)
treebeea42ad8c4b755d1c36705c6c052d5b0588eaac /src
parent5e42032146de07d4ab53598e9311efd145f9dfc3 (diff)
downloadkioku-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.tsx47
-rw-r--r--src/server/repositories/card.test.ts50
-rw-r--r--src/server/repositories/card.ts107
-rw-r--r--src/server/repositories/types.ts20
-rw-r--r--src/server/routes/cards.test.ts1
-rw-r--r--src/server/routes/study.test.ts100
-rw-r--r--src/server/routes/study.ts6
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);
})