diff --git a/app/(app)/quizzes/quizzes-list.tsx b/app/(app)/quizzes/quizzes-list.tsx
index 5a3dc661..a35f03bc 100644
--- a/app/(app)/quizzes/quizzes-list.tsx
+++ b/app/(app)/quizzes/quizzes-list.tsx
@@ -9,19 +9,13 @@
*/
import { useEffect, useMemo, useState } from "react";
-import Link from "next/link";
-import { ClipboardCheck } from "lucide-react";
import { useSignedInPage } from "@/components/auth/use-signed-in-page";
-import { quiz, quizActionLabel, type QuizCard } from "@/lib/quiz";
+import { quiz, type QuizCard } from "@/lib/quiz";
+import { groupQuizzes } from "@/lib/quiz-groups";
+import { QuizGrid } from "@/components/quiz/quiz-grid";
+import { GroupedQuizzes } from "@/components/quiz/grouped-quizzes";
import { cn } from "@/lib/utils";
-const KIND: Record = {
- chapter: "Book chapter",
- lecture: "Lecture",
- conversation: "Conversation",
- standalone: "Quiz",
-};
-
type Filter = "all" | "todo" | "done";
export function QuizzesList() {
@@ -102,52 +96,10 @@ export function QuizzesList() {
? "No quizzes yet — check back soon."
: "Nothing here."}
+ ) : filter !== "all" ? (
+
) : (
- // minmax(0,1fr) + min-w-0: grid items otherwise refuse to shrink
- // below their content, so one long word pushes a card off-screen.
-
- {shown.map((q) => {
- const done = q.my.state === "completed";
- return (
-
-
-
- {KIND[q.target_kind]}
-
-
- {q.title}
-
- {q.target_title && (
-
- {q.target_title}
-
- )}
-
-
- {q.question_count}{" "}
- {q.question_count === 1 ? "question" : "questions"}
-
-
-
- {quizActionLabel(q.my.state)}
- {done &&
- q.my.score !== undefined &&
- ` · ${q.my.score}/${q.my.total}`}
-
-
-
-
- );
- })}
-
+
)}
);
diff --git a/components/quiz/grouped-quizzes.tsx b/components/quiz/grouped-quizzes.tsx
new file mode 100644
index 00000000..54c2ba7b
--- /dev/null
+++ b/components/quiz/grouped-quizzes.tsx
@@ -0,0 +1,74 @@
+import type { QuizSections } from "@/lib/quiz-groups";
+import { QuizGrid } from "./quiz-grid";
+
+/**
+ * The "All" tab: Books → book, Lectures & Conversations → year, then the
+ * stand-alone quizzes. groupQuizzes only returns non-empty groups, and each
+ * section here renders only when it has groups, so nothing empty shows.
+ */
+export function GroupedQuizzes({ sections }: { sections: QuizSections }) {
+ return (
+
+ {sections.books.length > 0 && (
+
+ {sections.books.map((g) => (
+
+
+
+ ))}
+
+ )}
+ {sections.lectures.length > 0 && (
+
+ {sections.lectures.map((g) => (
+
+
+
+ ))}
+
+ )}
+ {sections.other.length > 0 && (
+
+ )}
+
+ );
+}
+
+function Section({
+ title,
+ children,
+}: {
+ title: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {title}
+
+ {children}
+
+ );
+}
+
+function Group({
+ label,
+ count,
+ children,
+}: {
+ label: string;
+ count: number;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {label}
+ {count}
+
+ {children}
+
+ );
+}
diff --git a/components/quiz/quiz-grid.tsx b/components/quiz/quiz-grid.tsx
new file mode 100644
index 00000000..4b6a0aa2
--- /dev/null
+++ b/components/quiz/quiz-grid.tsx
@@ -0,0 +1,19 @@
+import type { QuizCard } from "@/lib/quiz";
+import { QuizListCard } from "./quiz-list-card";
+
+/**
+ * Quiz cards, two across from sm up. minmax(0,1fr) + min-w-0: grid items
+ * otherwise refuse to shrink below their content, so one long word pushes a
+ * card off-screen.
+ */
+export function QuizGrid({ quizzes }: { quizzes: QuizCard[] }) {
+ return (
+
+ {quizzes.map((q) => (
+
+
+
+ ))}
+
+ );
+}
diff --git a/components/quiz/quiz-list-card.tsx b/components/quiz/quiz-list-card.tsx
new file mode 100644
index 00000000..9759e84b
--- /dev/null
+++ b/components/quiz/quiz-list-card.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+/** One quiz in the /quizzes list: what it's on, its title, and your progress. */
+
+import Link from "next/link";
+import { ClipboardCheck } from "lucide-react";
+import { quizActionLabel, type QuizCard } from "@/lib/quiz";
+import { cn } from "@/lib/utils";
+
+const KIND: Record = {
+ chapter: "Book chapter",
+ lecture: "Lecture",
+ conversation: "Conversation",
+ standalone: "Quiz",
+};
+
+export function QuizListCard({ q }: { q: QuizCard }) {
+ const done = q.my.state === "completed";
+ return (
+
+ {KIND[q.target_kind]}
+
+ {q.title}
+
+ {q.target_title && (
+
+ {q.target_title}
+
+ )}
+
+
+ {q.question_count} {q.question_count === 1 ? "question" : "questions"}
+
+
+
+ {quizActionLabel(q.my.state)}
+ {done && q.my.score !== undefined && ` · ${q.my.score}/${q.my.total}`}
+
+
+
+ );
+}
diff --git a/lib/quiz-groups.ts b/lib/quiz-groups.ts
new file mode 100644
index 00000000..596bdbf5
--- /dev/null
+++ b/lib/quiz-groups.ts
@@ -0,0 +1,87 @@
+/**
+ * Groups the quiz list for the "All" tab:
+ *
+ * Books → one group per book, in the library's order
+ * Lectures & Conversations → one group per year, oldest first, undated last
+ * Other quizzes → stand-alone quizzes
+ *
+ * Only groups with quizzes in them are returned, so an empty category or
+ * sub-category never renders.
+ */
+
+import { BOOK_NAMES } from "@/lib/constants";
+import type { QuizCard } from "@/lib/quiz";
+
+export type QuizGroup = { key: string; label: string; quizzes: QuizCard[] };
+
+export type QuizSections = {
+ books: QuizGroup[];
+ lectures: QuizGroup[];
+ other: QuizCard[];
+};
+
+const BOOK_ORDER = Object.keys(BOOK_NAMES);
+
+/** "bg" from /books/bg/2 — the book a chapter quiz belongs to. */
+function bookCode(path?: string | null): string | null {
+ const m = path?.match(/^\/books\/([^/]+)/);
+ return m ? m[1] : null;
+}
+
+/**
+ * The recording's year. Saved on the quiz since target_year existed; for
+ * older quizzes, read from the slug's yymmdd prefix (720720sb-london → 1972)
+ * when it falls in Śrīla Prabhupāda's recorded years.
+ */
+function lectureYear(q: QuizCard): number | null {
+ if (q.target_year) return q.target_year;
+ const m = q.target_path?.match(/^\/transcriptions\/(\d{2})\d{4}/);
+ if (!m) return null;
+ const y = 1900 + Number(m[1]);
+ return y >= 1966 && y <= 1977 ? y : null;
+}
+
+export function groupQuizzes(cards: QuizCard[]): QuizSections {
+ const books = new Map();
+ const years = new Map();
+ const other: QuizCard[] = [];
+
+ for (const q of cards) {
+ if (q.target_kind === "chapter") {
+ const code = bookCode(q.target_path) ?? "other";
+ books.set(code, [...(books.get(code) ?? []), q]);
+ } else if (
+ q.target_kind === "lecture" ||
+ q.target_kind === "conversation"
+ ) {
+ const y = lectureYear(q);
+ years.set(y, [...(years.get(y) ?? []), q]);
+ } else {
+ other.push(q);
+ }
+ }
+
+ const rank = (code: string) => {
+ const i = BOOK_ORDER.indexOf(code);
+ return i < 0 ? BOOK_ORDER.length : i;
+ };
+
+ return {
+ books: [...books.entries()]
+ .sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
+ .map(([code, quizzes]) => ({
+ key: code,
+ label: BOOK_NAMES[code] ?? code.toUpperCase(),
+ quizzes,
+ })),
+ lectures: [...years.entries()]
+ // Oldest first; undated (null) last.
+ .sort(([a], [b]) => (a ?? 9999) - (b ?? 9999))
+ .map(([year, quizzes]) => ({
+ key: String(year ?? "undated"),
+ label: year ? String(year) : "Undated",
+ quizzes,
+ })),
+ other,
+ };
+}
diff --git a/lib/quiz.ts b/lib/quiz.ts
index 479621c4..e37d5119 100644
--- a/lib/quiz.ts
+++ b/lib/quiz.ts
@@ -66,6 +66,8 @@ export type QuizCard = {
target_kind: "chapter" | "lecture" | "conversation" | "standalone";
target_path?: string | null;
target_title: string;
+ /** Year of the lecture / conversation, when known. */
+ target_year?: number | null;
question_count: number;
my: { state: QuizState; answered: number; total: number; score?: number };
};