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. - + )} ); 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 }; };