Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions app/flashcards/flashcards-client.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,9 @@ describe("FlashcardsClient", () => {
it("keeps My Flashcards focused on saved deck management", () => {
render(<FlashcardsClient notes={notes} initialDecks={[deck()]} />);

expect(screen.getByRole("heading", { name: "My Flashcards" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Biology deck" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Review" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Edit/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Delete/ })).toBeInTheDocument();
expect(screen.getByText("1 decks")).toBeInTheDocument();
expect(screen.getByText("Biology deck")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Open actions for Biology deck/ })).toBeInTheDocument();
expect(screen.queryByRole("checkbox", { name: /Lecture One/ })).not.toBeInTheDocument();
});

Expand Down Expand Up @@ -131,7 +129,7 @@ describe("FlashcardsClient", () => {
await user.clear(screen.getByLabelText("Deck name"));
await user.type(screen.getByLabelText("Deck name"), "Edited deck");
await user.clear(screen.getByLabelText("Front"));
await user.type(screen.getByLabelText("Front"), "Edited front");
await user.type(screen.getByLabelText("Front"), "Edited front $√(x²)$");
await user.type(screen.getByLabelText("Tags"), "core");
await user.click(screen.getByRole("button", { name: /Save deck/ }));

Expand All @@ -148,7 +146,7 @@ describe("FlashcardsClient", () => {
title: "Edited deck",
cards: [
{
front: "Edited front",
front: "Edited front $\\sqrt{x^2}$",
back: "Generated back",
tags: ["core"],
},
Expand All @@ -175,7 +173,8 @@ describe("FlashcardsClient", () => {

render(<FlashcardsClient notes={notes} initialDecks={[deck()]} />);

await user.click(screen.getByRole("button", { name: /Edit/ }));
await user.click(screen.getByRole("button", { name: /Open actions for Biology deck/ }));
await user.click(screen.getByRole("button", { name: "Edit" }));
await user.clear(screen.getByLabelText("Deck name"));
await user.type(screen.getByLabelText("Deck name"), "Renamed deck");
await user.clear(screen.getByLabelText("Back"));
Expand Down Expand Up @@ -213,12 +212,13 @@ describe("FlashcardsClient", () => {

render(<FlashcardsClient notes={notes} initialDecks={[deck()]} />);

await user.click(screen.getByRole("button", { name: /Delete/ }));
await user.click(screen.getByRole("button", { name: /Open actions for Biology deck/ }));
await user.click(screen.getByRole("button", { name: "Delete" }));

await waitFor(() => {
expect(screen.queryByRole("heading", { name: "Biology deck" })).not.toBeInTheDocument();
expect(screen.queryByText("Biology deck")).not.toBeInTheDocument();
});
expect(screen.getByText("No flashcard decks")).toBeInTheDocument();
expect(screen.getByText("No saved decks")).toBeInTheDocument();
expect(fetch).toHaveBeenCalledWith("/api/flashcards?deckId=deck-1", { method: "DELETE" });
});
});
84 changes: 38 additions & 46 deletions app/flashcards/flashcards-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,14 @@ import {
Edit3,
Loader2,
MoreHorizontal,
Play,
Plus,
RotateCcw,
Save,
Trash2,
X,
} from "lucide-react";
import { toast } from "sonner";
import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { LatexMarkdown } from "@/components/math/latex-markdown";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Expand All @@ -32,6 +28,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Input, Textarea } from "@/components/ui/input";
import { normalizeTextMathToLatex } from "@/lib/math/latex";
import { cx } from "@/lib/utils";
import type { FlashcardDeck, FlashcardItem } from "@/lib/flashcards/types";

Expand Down Expand Up @@ -89,6 +86,21 @@ function cloneDeck(deck: FlashcardDeck): FlashcardDeck {
};
}

function normalizeCardMath(card: FlashcardItem): FlashcardItem {
return {
...card,
front: normalizeTextMathToLatex(card.front),
back: normalizeTextMathToLatex(card.back),
};
}

function normalizeDeckMath(deck: FlashcardDeck): FlashcardDeck {
return {
...deck,
cards: deck.cards.map(normalizeCardMath),
};
}

function withCardCount(deck: FlashcardDeck): FlashcardDeck {
return {
...deck,
Expand All @@ -112,39 +124,6 @@ function parseTags(value: string) {
.slice(0, 8);
}

function MarkdownText({
markdown,
className,
}: {
markdown: string;
className?: string;
}) {
return (
<div className={cx("min-w-0 space-y-2 text-foreground", className)}>
<ReactMarkdown
rehypePlugins={[rehypeKatex]}
remarkPlugins={[remarkGfm, remarkMath]}
components={{
p: ({ children }) => <p>{children}</p>,
ul: ({ children }) => (
<ul className="ml-5 list-disc space-y-1">{children}</ul>
),
ol: ({ children }) => (
<ol className="ml-5 list-decimal space-y-1">{children}</ol>
),
code: ({ children }) => (
<code className="rounded bg-surface-elevated px-1 py-0.5 font-mono text-[0.92em]">
{children}
</code>
),
}}
>
{markdown}
</ReactMarkdown>
</div>
);
}

function StatPill({ children }: { children: ReactNode }) {
return (
<span className="inline-flex h-8 items-center rounded-full border border-border bg-transparent px-3 text-sm font-medium text-muted-foreground">
Expand Down Expand Up @@ -252,6 +231,11 @@ function DeckEditor({
onChange={(event) =>
updateCard(index, { front: event.target.value })
}
onBlur={(event) =>
updateCard(index, {
front: normalizeTextMathToLatex(event.target.value),
})
}
/>
</label>
<label className="space-y-2 text-sm font-medium text-foreground">
Expand All @@ -263,6 +247,11 @@ function DeckEditor({
onChange={(event) =>
updateCard(index, { back: event.target.value })
}
onBlur={(event) =>
updateCard(index, {
back: normalizeTextMathToLatex(event.target.value),
})
}
/>
</label>
</div>
Expand Down Expand Up @@ -409,6 +398,7 @@ export function FlashcardsClient({
return;
}

const normalizedDeck = normalizeDeckMath(draftDeck);
setIsSaving(true);
try {
const payload = await readJsonResponse<{ deck: FlashcardDeck }>(
Expand All @@ -417,9 +407,9 @@ export function FlashcardsClient({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mode: "save",
title: draftDeck.title,
sourceNoteIds: draftDeck.sourceNoteIds,
cards: draftDeck.cards,
title: normalizedDeck.title,
sourceNoteIds: normalizedDeck.sourceNoteIds,
cards: normalizedDeck.cards,
}),
}),
);
Expand All @@ -444,17 +434,18 @@ export function FlashcardsClient({
return;
}

const normalizedDeck = normalizeDeckMath(editingDeck);
setIsSaving(true);
try {
const payload = await readJsonResponse<{ deck: FlashcardDeck }>(
await fetch("/api/flashcards", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
deckId: editingDeck.id,
title: editingDeck.title,
sourceNoteIds: editingDeck.sourceNoteIds,
cards: editingDeck.cards,
deckId: normalizedDeck.id,
title: normalizedDeck.title,
sourceNoteIds: normalizedDeck.sourceNoteIds,
cards: normalizedDeck.cards,
}),
}),
);
Expand Down Expand Up @@ -591,6 +582,7 @@ export function FlashcardsClient({

<button
type="button"
aria-label={`Open actions for ${deck.title}`}
className="shrink-0 rounded-md p-1.5 text-muted-foreground transition hover:bg-surface-elevated hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
Expand Down Expand Up @@ -903,7 +895,7 @@ export function FlashcardsClient({
<Badge variant="outline">Flip</Badge>
</div>
<div className="mx-auto flex w-full max-w-3xl flex-1 items-center justify-center py-8">
<MarkdownText
<LatexMarkdown
markdown={showBack ? activeCard.back : activeCard.front}
className="text-center text-xl font-medium leading-9"
/>
Expand Down
24 changes: 12 additions & 12 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -982,6 +982,18 @@ html.dark body {
color: var(--foreground);
}

.note-editor .note-inline-math-block math-field,
.note-renderer .note-inline-math-block math-field {
width: auto;
max-width: 100%;
min-width: 4rem;
}

.note-editor .note-inline-math-block math-field::part(container),
.note-renderer .note-inline-math-block math-field::part(container) {
padding: 0;
}

.note-renderer .katex-display {
margin: 0.25rem 0;
overflow-x: auto;
Expand Down Expand Up @@ -1019,15 +1031,3 @@ html.dark body {
user-select: none;
pointer-events: none;
}

/* ---- Inline math spans ---- */
.note-inline-math {
display: inline;
font-family: var(--font-mono), monospace;
font-size: 0.9em;
color: var(--color-accent);
background: var(--color-accent-soft);
border-radius: 3px;
padding: 0.05em 0.3em;
white-space: nowrap;
}
48 changes: 44 additions & 4 deletions app/quizzes/quizzes-client.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { QuizzesClient } from "@/app/quizzes/quizzes-client";
import type { QuizQuestion } from "@/lib/quizzes/gemini";
import type { SavedQuiz } from "@/lib/quizzes/types";
Expand Down Expand Up @@ -44,16 +44,25 @@ const savedQuiz: SavedQuiz = {
updatedAt: "2026-05-29T18:00:00.000Z",
};

function jsonResponse(payload: unknown, init?: ResponseInit) {
return {
ok: init?.status ? init.status < 400 : true,
status: init?.status ?? 200,
json: async () => payload,
} as Response;
}

describe("QuizzesClient", () => {
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it("renders library as quiz dashboard with stats and empty state", () => {
render(<QuizzesClient notes={notes} initialQuizzes={[]} initialAttempts={[]} />);

expect(screen.getByTestId("quizzes-one-page")).toHaveClass("h-full", "overflow-hidden");
expect(screen.getByRole("heading", { name: "My Quizzes" })).toBeInTheDocument();
expect(screen.getByText("0 quizzes")).toBeInTheDocument();
expect(screen.getByText("0 questions")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /create quiz/i })).toBeInTheDocument();
Expand All @@ -67,7 +76,6 @@ describe("QuizzesClient", () => {

await user.click(screen.getByRole("button", { name: /create quiz/i }));

expect(screen.getByRole("heading", { name: "Quizzes" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Create Quiz" })).toBeInTheDocument();
expect(screen.getByText("Context")).toBeInTheDocument();
expect(screen.getByText("Preview")).toBeInTheDocument();
Expand All @@ -83,7 +91,8 @@ describe("QuizzesClient", () => {
const user = userEvent.setup();
render(<QuizzesClient notes={notes} initialQuizzes={[savedQuiz]} initialAttempts={[]} />);

await user.click(screen.getByRole("button", { name: /edit/i }));
await user.click(screen.getByRole("button", { name: /Open actions for Loop quiz/ }));
await user.click(screen.getByRole("button", { name: "Edit" }));

expect(screen.getByRole("heading", { name: "Preview Quiz" })).toBeInTheDocument();
expect(screen.getByLabelText(/quiz name/i)).toHaveValue("Loop quiz");
Expand All @@ -94,4 +103,35 @@ describe("QuizzesClient", () => {
expect(screen.getAllByLabelText("Prompt")).toHaveLength(1);
expect(screen.getAllByText("Question 2").length).toBeGreaterThan(0);
});

it("normalizes edited quiz math to LaTeX before saving", async () => {
const user = userEvent.setup();
const normalizedQuiz: SavedQuiz = {
...savedQuiz,
questions: [
{
...question,
prompt: "Area $\\pi r^2$",
},
],
};
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ quiz: normalizedQuiz })));

render(<QuizzesClient notes={notes} initialQuizzes={[savedQuiz]} initialAttempts={[]} />);

await user.click(screen.getByRole("button", { name: /Open actions for Loop quiz/ }));
await user.click(screen.getByRole("button", { name: "Edit" }));
await user.clear(screen.getByLabelText("Prompt"));
await user.type(screen.getByLabelText("Prompt"), "Area $πr²$");
await user.click(screen.getByRole("button", { name: /save quiz/i }));

expect(fetch).toHaveBeenCalledTimes(1);
expect(JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body))).toMatchObject({
questions: [
{
prompt: "Area $\\pi r^2$",
},
],
});
});
});
Loading
Loading