From 8f00cbfac33cf04c849220d24798ad5ce580ff57 Mon Sep 17 00:00:00 2001 From: zhngharry Date: Wed, 15 Jul 2026 17:36:27 +0200 Subject: [PATCH 01/10] refactor(examlense): dedupe results dashboard & exams-list status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the pages/ reuse roadmap (results + exams list): - Reuse scoreRollup() in ByQuestionType, FiguresComparison, and AllTasks instead of re-implementing the earned/max reduce. - Extract (5 call sites) and , plus formatScoreSummary() for the shared "{count} tasks · {earned}/{max} ({pct}%)" label. - Delete dead OverallScoreCard (no importers). - TaskBreakdownTable: compute the intentional binary perfect/zeroed coloring once instead of inlining the conditions in both row and cell. - Consolidate exam-status label/style/sort-rank into one EXAM_STATUS_META table (lib/exam/exam-status), consumed by ExamStatusBadge and the ExamsTable Status sort; removes the duplicated ready→Draft collapse rule. Behavior-preserving; typecheck, vitest (18), and build all pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../frontend/src/lib/exam/exam-status.ts | 80 +++++++++++++++++++ .../frontend/src/lib/grading/grading.ts | 13 +++ .../src/pages/exam-results/ExamResults.tsx | 8 +- .../exam-results/components/AllTasksList.tsx | 20 +---- .../components/ByQuestionTypeCard.tsx | 55 +++++-------- .../components/FiguresComparisonCard.tsx | 40 +++------- .../components/LearningGoalsCard.tsx | 14 ++-- .../components/OverallScoreCard.tsx | 41 ---------- .../exam-results/components/RollupRow.tsx | 26 ++++++ .../exam-results/components/ScoreBar.tsx | 36 +++++++++ .../components/TaskBreakdownTable.tsx | 15 ++-- .../exams/components/ExamStatusBadge.tsx | 58 ++------------ .../src/pages/exams/components/ExamsTable.tsx | 14 +--- 13 files changed, 216 insertions(+), 204 deletions(-) create mode 100644 apps/examlense/frontend/src/lib/exam/exam-status.ts delete mode 100644 apps/examlense/frontend/src/pages/exam-results/components/OverallScoreCard.tsx create mode 100644 apps/examlense/frontend/src/pages/exam-results/components/RollupRow.tsx create mode 100644 apps/examlense/frontend/src/pages/exam-results/components/ScoreBar.tsx diff --git a/apps/examlense/frontend/src/lib/exam/exam-status.ts b/apps/examlense/frontend/src/lib/exam/exam-status.ts new file mode 100644 index 0000000..a8c75fe --- /dev/null +++ b/apps/examlense/frontend/src/lib/exam/exam-status.ts @@ -0,0 +1,80 @@ +import { + CheckCircle2, + FileText, + Gavel, + Loader2, + Sparkles, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import type { Exam } from "./exam-helpers"; + +type ExamStatus = Exam["status"]; + +export interface ExamStatusMeta { + /** User-facing badge label. */ + label: string; + /** Badge chip classes (background + text). */ + className: string; + Icon: LucideIcon; + /** Whether the icon spins (in-progress states). */ + spin: boolean; + /** Lifecycle order for the Status-column sort. */ + rank: number; +} + +const DRAFT_META: ExamStatusMeta = { + label: "Draft", + className: "border border-hestia-border text-hestia-text-muted", + Icon: FileText, + spin: false, + rank: 2, +}; + +/** + * Single source of truth for how each exam status renders (badge) and sorts + * (Status column). Both the dashboard badge and the table sort read from here + * so the mapping — including the `ready`→Draft collapse — can't drift. + * + * `failed` is surfaced separately (a warning affordance on the row, its own + * card variant), so in the badge it deliberately keeps the neutral "Draft" + * appearance; only its `rank` distinguishes it, sorting failed exams last. + */ +export const EXAM_STATUS_META: Record = { + parsing: { + label: "Parsing", + className: "bg-hestia-primary/10 text-hestia-primary", + Icon: Loader2, + spin: true, + rank: 0, + }, + evaluating: { + label: "Evaluating", + className: "bg-hestia-success/10 text-hestia-success", + Icon: Sparkles, + spin: false, + rank: 1, + }, + draft: DRAFT_META, + // `ready` (editable draft ready to send) collapses into Draft for display. + ready: DRAFT_META, + grading: { + label: "Grading", + className: "bg-hestia-accent/10 text-hestia-accent", + Icon: Gavel, + spin: false, + rank: 3, + }, + finished: { + label: "Finished", + className: "bg-hestia-success/10 text-hestia-success", + Icon: CheckCircle2, + spin: false, + rank: 4, + }, + // Neutral badge appearance (see note above); sorts last. + failed: { ...DRAFT_META, rank: 5 }, +}; + +/** Resolve status metadata, collapsing any unknown state to Draft. */ +export const examStatusMeta = (status: ExamStatus): ExamStatusMeta => + EXAM_STATUS_META[status] ?? DRAFT_META; diff --git a/apps/examlense/frontend/src/lib/grading/grading.ts b/apps/examlense/frontend/src/lib/grading/grading.ts index ab181cc..1308e6b 100644 --- a/apps/examlense/frontend/src/lib/grading/grading.ts +++ b/apps/examlense/frontend/src/lib/grading/grading.ts @@ -102,6 +102,19 @@ export const scoreRollup = ( return { count: tasks.length, earned, max, pct: max > 0 ? Math.round((earned / max) * 100) : 0 }; }; +/** Compact "{count} tasks · {earned}/{max} ({pct}%)" label for a score rollup. */ +export const formatScoreSummary = ({ + count, + earned, + max, + pct, +}: { + count: number; + earned: number; + max: number; + pct: number; +}): string => `${count} tasks · ${earned}/${max} (${pct}%)`; + export interface GoalRollup { goalId: number; count: number; diff --git a/apps/examlense/frontend/src/pages/exam-results/ExamResults.tsx b/apps/examlense/frontend/src/pages/exam-results/ExamResults.tsx index 37146c6..bdd8c27 100644 --- a/apps/examlense/frontend/src/pages/exam-results/ExamResults.tsx +++ b/apps/examlense/frontend/src/pages/exam-results/ExamResults.tsx @@ -15,6 +15,7 @@ import { TaskBreakdownTable } from "@/pages/exam-results/components/TaskBreakdow import { TaskScoreBarChart } from "@/pages/exam-results/components/TaskScoreBarChart"; import { LearningGoalsCard } from "@/pages/exam-results/components/LearningGoalsCard"; import { AllTasksList } from "@/pages/exam-results/components/AllTasksList"; +import { ScoreBar } from "@/pages/exam-results/components/ScoreBar"; import { ResultsSidebar, type ResultsViewItem, @@ -219,12 +220,7 @@ const ExamResults = () => { {totals.earned} / {totals.max} -
-
-
+
} right={} diff --git a/apps/examlense/frontend/src/pages/exam-results/components/AllTasksList.tsx b/apps/examlense/frontend/src/pages/exam-results/components/AllTasksList.tsx index ed81dd2..21260ec 100644 --- a/apps/examlense/frontend/src/pages/exam-results/components/AllTasksList.tsx +++ b/apps/examlense/frontend/src/pages/exam-results/components/AllTasksList.tsx @@ -22,11 +22,11 @@ import { } from "@/lib/exam/exam-helpers"; import { effectiveScore, + scoreRollup, type TaskAnswer, type TaskGrade, } from "@/lib/grading/grading"; -import { SCORE_FILL_CLASS, scoreTier } from "@/lib/grading/score-color"; -import { cn } from "@/lib/utils/utils"; +import { ScoreBar } from "./ScoreBar"; interface Props { tasks: Task[]; @@ -63,11 +63,7 @@ export const AllTasksList = ({ const title = sec?.name?.trim() || (sec ? "Untitled section" : "Unassigned tasks"); - const earned = secTasks.reduce((sum, tk) => { - const eff = effectiveScore(tk, gradesById.get(tk.id), answersById.get(tk.id)); - return sum + (eff.score ?? 0); - }, 0); - const max = secTasks.reduce((sum, tk) => sum + (tk.points ?? 0), 0); + const { earned, max } = scoreRollup(secTasks, gradesById, answersById); return { slug, title, @@ -217,15 +213,7 @@ export const AllTasksList = ({ {maxPoints} -
-
-
+
); diff --git a/apps/examlense/frontend/src/pages/exam-results/components/ByQuestionTypeCard.tsx b/apps/examlense/frontend/src/pages/exam-results/components/ByQuestionTypeCard.tsx index 1165c60..1c182a5 100644 --- a/apps/examlense/frontend/src/pages/exam-results/components/ByQuestionTypeCard.tsx +++ b/apps/examlense/frontend/src/pages/exam-results/components/ByQuestionTypeCard.tsx @@ -1,7 +1,8 @@ import type { Task } from "@/lib/exam/exam-helpers"; import type { TaskGrade, TaskAnswer } from "@/lib/grading/grading"; -import { effectiveScore } from "@/lib/grading/grading"; +import { formatScoreSummary, scoreRollup } from "@/lib/grading/grading"; import { TASK_TYPE_LABELS } from "@/lib/exam/labels"; +import { RollupRow } from "./RollupRow"; interface Props { tasks: Task[]; @@ -11,15 +12,16 @@ interface Props { export const ByQuestionTypeCard = ({ tasks, grades, answers }: Props) => { const types = ["single_choice", "multiple_choice", "text"] as const; - const rows = types.map((type) => { - const filtered = tasks.filter((tk) => tk.type === type); - const max = filtered.reduce((s, tk) => s + (tk.points ?? 0), 0); - const earned = filtered.reduce((s, tk) => { - const eff = effectiveScore(tk, grades.get(tk.id), answers.get(tk.id)); - return s + (eff.score ?? 0); - }, 0); - return { type, count: filtered.length, earned, max }; - }).filter((r) => r.count > 0); + const rows = types + .map((type) => ({ + type, + ...scoreRollup( + tasks.filter((tk) => tk.type === type), + grades, + answers, + ), + })) + .filter((r) => r.count > 0); return (
@@ -27,30 +29,15 @@ export const ByQuestionTypeCard = ({ tasks, grades, answers }: Props) => { By Question Type
- {rows.map((r) => { - const pct = r.max > 0 ? Math.round((r.earned / r.max) * 100) : 0; - return ( -
-
-
- - {TASK_TYPE_LABELS[r.type]} - - - {r.count} tasks · {r.earned}/{r.max} ({pct}%) - -
-
-
-
-
-
- ); - })} + {rows.map((r) => ( + + ))}
); -}; \ No newline at end of file +}; diff --git a/apps/examlense/frontend/src/pages/exam-results/components/FiguresComparisonCard.tsx b/apps/examlense/frontend/src/pages/exam-results/components/FiguresComparisonCard.tsx index ce8563f..4235445 100644 --- a/apps/examlense/frontend/src/pages/exam-results/components/FiguresComparisonCard.tsx +++ b/apps/examlense/frontend/src/pages/exam-results/components/FiguresComparisonCard.tsx @@ -1,6 +1,7 @@ import type { Task, SectionBlock } from "@/lib/exam/exam-helpers"; import type { TaskGrade, TaskAnswer } from "@/lib/grading/grading"; -import { effectiveScore } from "@/lib/grading/grading"; +import { formatScoreSummary, scoreRollup } from "@/lib/grading/grading"; +import { RollupRow } from "./RollupRow"; interface Props { tasks: Task[]; @@ -18,17 +19,8 @@ export const FiguresComparisonCard = ({ tasks, blocks, grades, answers }: Props) const withFigures = tasks.filter((tk) => tk.section_id && sectionsWithFigures.has(tk.section_id)); const withoutFigures = tasks.filter((tk) => !tk.section_id || !sectionsWithFigures.has(tk.section_id)); - const calc = (list: Task[]) => { - const max = list.reduce((s, tk) => s + (tk.points ?? 0), 0); - const earned = list.reduce((s, tk) => { - const eff = effectiveScore(tk, grades.get(tk.id), answers.get(tk.id)); - return s + (eff.score ?? 0); - }, 0); - return { count: list.length, earned, max, pct: max > 0 ? Math.round((earned / max) * 100) : 0 }; - }; - - const fig = calc(withFigures); - const noFig = calc(withoutFigures); + const fig = scoreRollup(withFigures, grades, answers); + const noFig = scoreRollup(withoutFigures, grades, answers); if (fig.count === 0 || noFig.count === 0) return null; @@ -44,24 +36,14 @@ export const FiguresComparisonCard = ({ tasks, blocks, grades, answers }: Props)
{items.map((item) => ( -
-
-
- {item.label} - - {item.count} tasks · {item.earned}/{item.max} ({item.pct}%) - -
-
-
-
-
-
+ ))}
); -}; \ No newline at end of file +}; diff --git a/apps/examlense/frontend/src/pages/exam-results/components/LearningGoalsCard.tsx b/apps/examlense/frontend/src/pages/exam-results/components/LearningGoalsCard.tsx index 6ec47f5..9474a1d 100644 --- a/apps/examlense/frontend/src/pages/exam-results/components/LearningGoalsCard.tsx +++ b/apps/examlense/frontend/src/pages/exam-results/components/LearningGoalsCard.tsx @@ -2,11 +2,12 @@ import { useMemo } from "react"; import { Target } from "lucide-react"; import type { Task } from "@/lib/exam/exam-helpers"; import type { TaskGrade, TaskAnswer } from "@/lib/grading/grading"; -import { goalRollup, scoreRollup } from "@/lib/grading/grading"; +import { formatScoreSummary, goalRollup, scoreRollup } from "@/lib/grading/grading"; import { Badge } from "@/components/ui/badge"; import { useExamLearningGoals } from "@/hooks/data/use-learning-goals"; import type { LearningGoalResponse } from "@/lib/learning-goals/learning-goals"; import { BLOOM_LABELS, SOLO_LABELS } from "@/lib/exam/labels"; +import { ScoreBar } from "./ScoreBar"; interface Props { tasks: Task[]; @@ -80,7 +81,7 @@ export const LearningGoalsCard = ({ tasks, grades, answers, examId }: Props) => {goal.text} - {count} tasks · {earned}/{max} ({pct}%) + {formatScoreSummary({ count, earned, max, pct })}
@@ -95,12 +96,7 @@ export const LearningGoalsCard = ({ tasks, grades, answers, examId }: Props) => )}
-
-
-
+
))} @@ -111,7 +107,7 @@ export const LearningGoalsCard = ({ tasks, grades, answers, examId }: Props) => Unassigned tasks - {unassigned.count} tasks · {unassigned.earned}/{unassigned.max} ({unassigned.pct}%) + {formatScoreSummary(unassigned)} diff --git a/apps/examlense/frontend/src/pages/exam-results/components/OverallScoreCard.tsx b/apps/examlense/frontend/src/pages/exam-results/components/OverallScoreCard.tsx deleted file mode 100644 index 9b866a7..0000000 --- a/apps/examlense/frontend/src/pages/exam-results/components/OverallScoreCard.tsx +++ /dev/null @@ -1,41 +0,0 @@ -interface Props { - earned: number; - max: number; -} - -export const OverallScoreCard = ({ earned, max }: Props) => { - const pct = max > 0 ? Math.round((earned / max) * 100) : 0; - - return ( -
-

- Overall Score -

-
- - - - - - {pct}% - -
-

- {earned} / {max} -

-
- ); -}; \ No newline at end of file diff --git a/apps/examlense/frontend/src/pages/exam-results/components/RollupRow.tsx b/apps/examlense/frontend/src/pages/exam-results/components/RollupRow.tsx new file mode 100644 index 0000000..c170a22 --- /dev/null +++ b/apps/examlense/frontend/src/pages/exam-results/components/RollupRow.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from "react"; +import { ScoreBar } from "./ScoreBar"; + +interface Props { + label: ReactNode; + /** Right-aligned summary text — see `formatScoreSummary`. */ + meta: string; + /** Percentage 0–100 for the bar. */ + pct: number; +} + +/** + * One "label · summary + progress bar" row, shared by the aggregate results + * cards (by question type, figures vs. no figures) so their layout can't drift. + */ +export const RollupRow = ({ label, meta, pct }: Props) => ( +
+
+
+ {label} + {meta} +
+ +
+
+); diff --git a/apps/examlense/frontend/src/pages/exam-results/components/ScoreBar.tsx b/apps/examlense/frontend/src/pages/exam-results/components/ScoreBar.tsx new file mode 100644 index 0000000..b3ca60e --- /dev/null +++ b/apps/examlense/frontend/src/pages/exam-results/components/ScoreBar.tsx @@ -0,0 +1,36 @@ +import { cn } from "@/lib/utils/utils"; +import { SCORE_FILL_CLASS, scoreTier } from "@/lib/grading/score-color"; + +interface Props { + /** Fill percentage, 0–100. */ + pct: number; + /** + * `primary` — solid brand fill (neutral aggregate bars). + * `tier` — performance color from the shared `scoreTier` thresholds. + */ + tone?: "primary" | "tier"; + /** Extra classes on the track (e.g. a width or visibility override). */ + className?: string; +} + +/** + * The thin track + fill progress bar used across the results dashboard. Single + * source so every score bar stays visually identical; `tone="tier"` routes the + * fill color through `lib/grading/score-color` instead of the solid brand color. + */ +export const ScoreBar = ({ pct, tone = "primary", className }: Props) => ( +
+
+
+); diff --git a/apps/examlense/frontend/src/pages/exam-results/components/TaskBreakdownTable.tsx b/apps/examlense/frontend/src/pages/exam-results/components/TaskBreakdownTable.tsx index c323c75..04d0299 100644 --- a/apps/examlense/frontend/src/pages/exam-results/components/TaskBreakdownTable.tsx +++ b/apps/examlense/frontend/src/pages/exam-results/components/TaskBreakdownTable.tsx @@ -23,13 +23,18 @@ export const TaskBreakdownTable = ({ tasks, grades, answers, labelById }: Props) return tasks.map((tk) => { const eff = effectiveScore(tk, grades.get(tk.id), answers.get(tk.id)); const pts = tk.points ?? 0; + const pct = pts > 0 ? Math.round(((eff.score ?? 0) / pts) * 100) : 0; return { id: tk.id, label: labelById.get(tk.id) ?? "", type: tk.type, points: pts, score: eff.score ?? 0, - pct: pts > 0 ? Math.round(((eff.score ?? 0) / pts) * 100) : 0, + pct, + // Deliberately binary (perfect / zeroed), not the 80/50 performance + // tiers — a per-task table flags only aced and failed tasks. + isPerfect: pct === 100, + isZeroed: pct === 0 && pts > 0, }; }); }, [tasks, grades, answers, labelById]); @@ -81,8 +86,8 @@ export const TaskBreakdownTable = ({ tasks, grades, answers, labelById }: Props) key={r.id} className={cn( "border-b border-hestia-border/50", - r.pct === 100 && "bg-hestia-success/5", - r.pct === 0 && r.points > 0 && "bg-hestia-danger/5", + r.isPerfect && "bg-hestia-success/5", + r.isZeroed && "bg-hestia-danger/5", )} > {r.label} @@ -91,8 +96,8 @@ export const TaskBreakdownTable = ({ tasks, grades, answers, labelById }: Props) {r.score} 0 && "text-hestia-danger", + r.isPerfect && "text-hestia-success", + r.isZeroed && "text-hestia-danger", r.pct > 0 && r.pct < 100 && "text-hestia-text", )}> {r.pct}% diff --git a/apps/examlense/frontend/src/pages/exams/components/ExamStatusBadge.tsx b/apps/examlense/frontend/src/pages/exams/components/ExamStatusBadge.tsx index 5cc3e4b..e035c7a 100644 --- a/apps/examlense/frontend/src/pages/exams/components/ExamStatusBadge.tsx +++ b/apps/examlense/frontend/src/pages/exams/components/ExamStatusBadge.tsx @@ -1,59 +1,13 @@ -import { CheckCircle2, FileText, Gavel, Loader2, Sparkles } from "lucide-react"; import type { Exam } from "@/lib/exam/exam-helpers"; +import { examStatusMeta } from "@/lib/exam/exam-status"; /** - * Unified status badge shown on every exam card in the dashboard. Covers the - * five user-facing statuses: Parsing, Draft, Evaluating, Grading, Finished. - * The DB still also has `ready` (= editable draft that's ready to send) and - * `failed` (handled separately on its own card variant); `ready` collapses - * into "Draft" for display. + * Unified status badge shown on every exam row in the dashboard. Appearance and + * labels come from the shared `EXAM_STATUS_META` table (`lib/exam/exam-status`), + * where `ready` and `failed` collapse into the neutral "Draft" chip. */ export const ExamStatusBadge = ({ status }: { status: Exam["status"] }) => { - // Map DB status -> visible badge. `ready` and any unknown future state - // collapse to "Draft" so the dashboard never shows a blank chip. - const variant = (() => { - switch (status) { - case "parsing": - return { - label: "Parsing", - className: "bg-hestia-primary/10 text-hestia-primary", - Icon: Loader2, - spin: true, - }; - case "evaluating": - return { - label: "Evaluating", - className: "bg-hestia-success/10 text-hestia-success", - Icon: Sparkles, - spin: false, - }; - case "grading": - return { - label: "Grading", - className: "bg-hestia-accent/10 text-hestia-accent", - Icon: Gavel, - spin: false, - }; - case "finished": - return { - label: "Finished", - className: "bg-hestia-success/10 text-hestia-success", - Icon: CheckCircle2, - spin: false, - }; - case "draft": - case "ready": - default: - return { - label: "Draft", - className: "border border-hestia-border text-hestia-text-muted", - Icon: FileText, - spin: false, - }; - } - })(); - - const { Icon, label, className, spin } = variant; + const { Icon, label, className, spin } = examStatusMeta(status); const isEvaluating = status === "evaluating"; return ( { {label} ); -}; \ No newline at end of file +}; diff --git a/apps/examlense/frontend/src/pages/exams/components/ExamsTable.tsx b/apps/examlense/frontend/src/pages/exams/components/ExamsTable.tsx index 09f6126..f4cd1ab 100644 --- a/apps/examlense/frontend/src/pages/exams/components/ExamsTable.tsx +++ b/apps/examlense/frontend/src/pages/exams/components/ExamsTable.tsx @@ -3,6 +3,7 @@ import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react"; import type { ExamListItem } from "@/lib/api/api-client"; import { fuzzyMatch } from "@/lib/utils/fuzzy"; import { progressSortValue } from "@/lib/exam/exam-progress"; +import { EXAM_STATUS_META } from "@/lib/exam/exam-status"; import { Table, TableBody, @@ -27,17 +28,6 @@ const PAGE_SIZE = 10; type SortKey = "title" | "status" | "progress" | "created"; type SortDir = "asc" | "desc"; -/** Lifecycle order used when sorting by Status. */ -const STATUS_RANK: Record = { - parsing: 0, - evaluating: 1, - draft: 2, - ready: 2, - grading: 3, - finished: 4, - failed: 5, -}; - /** Default direction when a column is first selected. */ const DEFAULT_DIR: Record = { title: "asc", @@ -51,7 +41,7 @@ const compare = (a: ExamListItem, b: ExamListItem, key: SortKey): number => { case "title": return (a.title || "Untitled exam").localeCompare(b.title || "Untitled exam"); case "status": - return STATUS_RANK[a.status] - STATUS_RANK[b.status]; + return EXAM_STATUS_META[a.status].rank - EXAM_STATUS_META[b.status].rank; case "progress": return progressSortValue(a) - progressSortValue(b); case "created": From 01597ed50cc27e2b7b3fbd3cc5e02b060cdee1d5 Mon Sep 17 00:00:00 2001 From: zhngharry Date: Wed, 15 Jul 2026 17:45:27 +0200 Subject: [PATCH 02/10] refactor(examlense): extract useExamMutations from ExamEdit Move the 13 CRUD mutators plus sectionIdForTask/sectionIdForBlock/ unconfirmIfNeeded out of ExamEdit into a page-local useExamMutations hook, with shared withSaveStatus + optimisticListUpdate helpers for the plain- envelope paths. Add-paths, duplicateTask, addSection, deleteSection and persistReorder keep their bespoke bodies (behavior-preserving). Drops ExamEdit from 1442 to 1127 lines. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../frontend/src/pages/exam-edit/ExamEdit.tsx | 365 ++--------------- .../src/pages/exam-edit/use-exam-mutations.ts | 377 ++++++++++++++++++ 2 files changed, 402 insertions(+), 340 deletions(-) create mode 100644 apps/examlense/frontend/src/pages/exam-edit/use-exam-mutations.ts diff --git a/apps/examlense/frontend/src/pages/exam-edit/ExamEdit.tsx b/apps/examlense/frontend/src/pages/exam-edit/ExamEdit.tsx index ea53b19..29b2bee 100644 --- a/apps/examlense/frontend/src/pages/exam-edit/ExamEdit.tsx +++ b/apps/examlense/frontend/src/pages/exam-edit/ExamEdit.tsx @@ -27,23 +27,13 @@ import { } from "@dnd-kit/sortable"; import { patchExam as apiPatchExam, - patchTask as apiPatchTask, - createTask as apiCreateTask, - deleteTask as apiDeleteTask, - patchSection as apiPatchSection, - createSection as apiCreateSection, - deleteSection as apiDeleteSection, - patchBlock as apiPatchBlock, - createBlock as apiCreateBlock, - deleteBlock as apiDeleteBlock, - deleteTasksBySection, - deleteBlocksBySection, listAnswers, cancelExam, } from "@/lib/api/api-client"; import { subscribeExam } from "@/lib/api/sse"; import { useExam, useTasks, examKey, tasksKey } from "@/hooks/data/use-exam"; import { useSections, useSectionBlocks, sectionsKey, blocksKey } from "@/hooks/data/use-sections"; +import { useExamMutations } from "@/pages/exam-edit/use-exam-mutations"; import { SaveStatusProvider, useSaveStatus, SaveIndicator } from "@/pages/exam-edit/components/SaveStatus"; import { useToast } from "@/hooks/ui/use-toast"; import { examLearningGoalsKey } from "@/hooks/data/use-learning-goals"; @@ -193,46 +183,6 @@ const ExamEditInner = () => { // indicator to place itself relative to the visible area. const scrollRef = useRef(null); - const sectionIdForTask = useCallback( - (taskId: string): string | null => { - const t = (tasks ?? []).find((tk) => tk.id === taskId); - return t?.section_id ?? null; - }, - [tasks], - ); - - const sectionIdForBlock = useCallback( - (blockId: string): string | null => { - const b = (blocks ?? []).find((bk) => bk.id === blockId); - return b?.section_id ?? null; - }, - [blocks], - ); - - // Unconfirm the given section (if confirmed) before performing an edit. - // Centralised so every mutation path keeps the confirmation/answer state - // honest: confirmation is a commit point, any edit reopens it. - const unconfirmIfNeeded = useCallback( - async (sectionId: string | null | undefined) => { - if (sectionId && confirmApiRef.current.isConfirmed(sectionId)) { - await confirmApiRef.current.unconfirm(sectionId); - } - }, - [], - ); - - const patchExam = async (patch: Partial) => { - if (!id || !exam) return; - setSaving(); - qc.setQueryData(examKey(id), { ...exam, ...patch }); - try { - await apiPatchExam(id, patch as Record); - setSaved(); - } catch { - setError(); - } - }; - const sendToEvaluation = async () => { if (!id || !exam) return; evaluationStartedRef.current = true; @@ -302,295 +252,6 @@ const ExamEditInner = () => { }); }; - const patchTask = async (taskId: string, patch: Partial) => { - if (!id) return; - await unconfirmIfNeeded(sectionIdForTask(taskId)); - setSaving(); - qc.setQueryData(tasksKey(id), (prev) => - (prev ?? []).map((t) => (t.id === taskId ? { ...t, ...patch } : t)), - ); - try { - await apiPatchTask(taskId, patch as Record); - setSaved(); - } catch { - setError(); - } - }; - - const addTask = async ( - type: TaskType, - afterPosition: number, - sectionId: string | null, - ) => { - if (!id) return; - await unconfirmIfNeeded(sectionId); - setSaving(); - markPendingAdd(); - try { - await apiCreateTask({ - exam_id: id, - position: afterPosition + 1, - type, - section_id: sectionId, - options: - type === "text" - ? null - : [ - { id: crypto.randomUUID(), text: "", is_correct: false }, - { id: crypto.randomUUID(), text: "", is_correct: false }, - ], - }); - setSaved(); - qc.invalidateQueries({ queryKey: tasksKey(id) }); - } catch { - setError(); - } - }; - - const deleteTask = async (taskId: string) => { - if (!id) return; - await unconfirmIfNeeded(sectionIdForTask(taskId)); - setSaving(); - qc.setQueryData(tasksKey(id), (prev) => - (prev ?? []).filter((t) => t.id !== taskId), - ); - try { - await apiDeleteTask(taskId); - setSaved(); - } catch { - setError(); - } - }; - - const duplicateTask = async (task: Task) => { - if (!id) return; - await unconfirmIfNeeded(task.section_id); - setSaving(); - try { - await apiCreateTask({ - exam_id: id, - position: task.position + 1, - type: task.type, - prompt: task.prompt, - options: task.options ?? null, - reference_answer: task.reference_answer, - section: task.section, - points: task.points, - section_id: task.section_id, - }); - setSaved(); - qc.invalidateQueries({ queryKey: tasksKey(id) }); - } catch { - setError(); - } - }; - - const patchSection = async (sectionId: string, patch: Partial
) => { - if (!id) return; - // Confirmation toggles flow through useSectionConfirmations and bypass - // this helper, so any patchSection call here is a user-initiated edit - // and should reopen the section. - if (!("confirmed_at" in patch)) { - await unconfirmIfNeeded(sectionId); - } - setSaving(); - qc.setQueryData(sectionsKey(id), (prev) => - (prev ?? []).map((s) => (s.id === sectionId ? { ...s, ...patch } : s)), - ); - try { - await apiPatchSection(sectionId, patch as Record); - setSaved(); - } catch { - setError(); - } - }; - - const patchBlock = async ( - blockId: string, - patch: Partial, - ) => { - if (!id) return; - await unconfirmIfNeeded(sectionIdForBlock(blockId)); - setSaving(); - qc.setQueryData(blocksKey(id), (prev) => - (prev ?? []).map((b) => (b.id === blockId ? { ...b, ...patch } : b)), - ); - try { - await apiPatchBlock(blockId, patch as Record); - setSaved(); - } catch { - setError(); - } - }; - - const addContextBlock = async ( - afterPosition: number, - sectionId: string, - ) => { - if (!id) return; - await unconfirmIfNeeded(sectionId); - setSaving(); - markPendingAdd(); - try { - await apiCreateBlock({ - exam_id: id, - section_id: sectionId, - position: afterPosition + 1, - content: "", - }); - setSaved(); - qc.invalidateQueries({ queryKey: blocksKey(id) }); - qc.invalidateQueries({ queryKey: tasksKey(id) }); - } catch (error) { - console.error("addContextBlock", error); - setError(); - } - }; - - const addFigureBlock = async ( - afterPosition: number, - sectionId: string, - ) => { - if (!id) return; - await unconfirmIfNeeded(sectionId); - setSaving(); - markPendingAdd(); - try { - await apiCreateBlock({ - exam_id: id, - section_id: sectionId, - position: afterPosition + 1, - content: "", - kind: "figure", - }); - setSaved(); - qc.invalidateQueries({ queryKey: blocksKey(id) }); - qc.invalidateQueries({ queryKey: tasksKey(id) }); - } catch { - setError(); - } - }; - - const deleteBlock = async (blockId: string) => { - if (!id) return; - await unconfirmIfNeeded(sectionIdForBlock(blockId)); - setSaving(); - qc.setQueryData(blocksKey(id), (prev) => - (prev ?? []).filter((b) => b.id !== blockId), - ); - try { - await apiDeleteBlock(blockId); - setSaved(); - } catch { - setError(); - } - }; - - const addSection = async (afterPosition?: number) => { - if (!id) return; - setSaving(); - // The backend's create-section is transactional and shifts later sections - // down itself, so we just hand it the target position (append falls past - // the end, where nothing needs shifting). - const position = - afterPosition != null ? afterPosition + 1 : (sections?.length ?? 0) + 1; - try { - await apiCreateSection({ exam_id: id, name: "", position }); - setSaved(); - qc.invalidateQueries({ queryKey: sectionsKey(id) }); - } catch { - setError(); - } - }; - - const deleteSection = async (sectionId: string) => { - if (!id) return; - setSaving(); - // Delete tasks, blocks, then the section itself - try { - await deleteTasksBySection(id, sectionId); - await deleteBlocksBySection(id, sectionId); - await apiDeleteSection(sectionId); - setSaved(); - qc.invalidateQueries({ queryKey: sectionsKey(id) }); - qc.invalidateQueries({ queryKey: tasksKey(id) }); - qc.invalidateQueries({ queryKey: blocksKey(id) }); - } catch { - setError(); - } - }; - - /** - * Persist a new ordering of items within a section. Assigns sequential - * positions (0, 1, 2, ...) and writes only the rows whose position changed. - * Cache is updated optimistically so the UI does not flicker. - */ - const persistReorder = async (newOrder: BlockItem[]) => { - if (!id) return; - // Items in a single reorder all belong to the same section (the call - // originates from one section's DndContext). Take the section id from - // the first item that exposes one and reopen its confirmation. - const firstWithSection = newOrder.find( - (it) => - (it.kind === "task" && it.task.section_id) || - it.kind === "context" || - it.kind === "figure", - ); - const reorderSectionId = - firstWithSection?.kind === "task" - ? firstWithSection.task.section_id - : firstWithSection?.kind === "context" || firstWithSection?.kind === "figure" - ? firstWithSection.block.section_id - : null; - await unconfirmIfNeeded(reorderSectionId); - setSaving(); - - const taskUpdates: Array<{ id: string; position: number }> = []; - const blockUpdates: Array<{ id: string; position: number }> = []; - - newOrder.forEach((item, idx) => { - if (item.kind === "task" && item.task.position !== idx) { - taskUpdates.push({ id: item.task.id, position: idx }); - } - if ( - (item.kind === "context" || item.kind === "figure") && - item.block.position !== idx - ) { - blockUpdates.push({ id: item.block.id, position: idx }); - } - }); - - // Optimistic cache update - if (taskUpdates.length > 0) { - const map = new Map(taskUpdates.map((u) => [u.id, u.position])); - qc.setQueryData(tasksKey(id), (prev) => - (prev ?? []).map((t) => - map.has(t.id) ? { ...t, position: map.get(t.id)! } : t, - ), - ); - } - if (blockUpdates.length > 0) { - const map = new Map(blockUpdates.map((u) => [u.id, u.position])); - qc.setQueryData(blocksKey(id), (prev) => - (prev ?? []).map((b) => - map.has(b.id) ? { ...b, position: map.get(b.id)! } : b, - ), - ); - } - - try { - await Promise.all([ - ...taskUpdates.map((u) => apiPatchTask(u.id, { position: u.position })), - ...blockUpdates.map((u) => apiPatchBlock(u.id, { position: u.position })), - ]); - setSaved(); - } catch { - setError(); - qc.invalidateQueries({ queryKey: tasksKey(id) }); - qc.invalidateQueries({ queryKey: blocksKey(id) }); - } - }; - // Group tasks + context blocks by section_id (preserving section order; unassigned tasks go last) const grouped = useMemo(() => { const sortedSections = (sections ?? []) @@ -765,6 +426,30 @@ const ExamEditInner = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [id, allBlockCollapseIds.join("|")]); + // CRUD/mutation layer (optimistic writes + save-status + unconfirm-on-edit). + const { + patchExam, + patchTask, + addTask, + deleteTask, + duplicateTask, + patchSection, + patchBlock, + addContextBlock, + addFigureBlock, + deleteBlock, + addSection, + deleteSection, + persistReorder, + } = useExamMutations(id, { + exam, + tasks, + blocks, + sections, + confirmApiRef, + markPendingAdd, + }); + const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), diff --git a/apps/examlense/frontend/src/pages/exam-edit/use-exam-mutations.ts b/apps/examlense/frontend/src/pages/exam-edit/use-exam-mutations.ts new file mode 100644 index 0000000..3b8f12d --- /dev/null +++ b/apps/examlense/frontend/src/pages/exam-edit/use-exam-mutations.ts @@ -0,0 +1,377 @@ +import { type MutableRefObject } from "react"; +import { useQueryClient, type QueryKey } from "@tanstack/react-query"; +import { + patchExam as apiPatchExam, + patchTask as apiPatchTask, + createTask as apiCreateTask, + deleteTask as apiDeleteTask, + patchSection as apiPatchSection, + createSection as apiCreateSection, + deleteSection as apiDeleteSection, + patchBlock as apiPatchBlock, + createBlock as apiCreateBlock, + deleteBlock as apiDeleteBlock, + deleteTasksBySection, + deleteBlocksBySection, +} from "@/lib/api/api-client"; +import { examKey, tasksKey } from "@/hooks/data/use-exam"; +import { sectionsKey, blocksKey } from "@/hooks/data/use-sections"; +import { useSaveStatus } from "@/pages/exam-edit/components/SaveStatus"; +import { useSectionConfirmations } from "@/hooks/data/use-section-confirmations"; +import type { + BlockItem, + Exam, + Section, + SectionBlock, + Task, + TaskType, +} from "@/lib/exam/exam-helpers"; + +interface ExamMutationDeps { + exam: Exam | undefined; + tasks: Task[] | undefined; + blocks: SectionBlock[] | undefined; + sections: Section[] | undefined; + /** Latest section-confirmation API (read via a ref so edit closures stay stable). */ + confirmApiRef: MutableRefObject>; + /** Flag an Add action so the newly created block auto-expands (see ExamEdit). */ + markPendingAdd: () => void; +} + +/** + * The editor's CRUD/mutation layer, extracted from ExamEdit. Every mutator + * shares the same envelope — optionally `unconfirmIfNeeded` → `setSaving()` → + * optimistic cache write → `try { await api; setSaved() } catch { setError() }`. + * A handful of paths deliberately deviate (add-paths invalidate instead of + * optimistically writing; `patchExam` skips unconfirm; `patchSection` skips it + * when toggling `confirmed_at`); those keep their bespoke bodies. + */ +export function useExamMutations( + id: string | undefined, + { exam, tasks, blocks, sections, confirmApiRef, markPendingAdd }: ExamMutationDeps, +) { + const qc = useQueryClient(); + const { setSaving, setSaved, setError } = useSaveStatus(); + + const sectionIdForTask = (taskId: string): string | null => { + const t = (tasks ?? []).find((tk) => tk.id === taskId); + return t?.section_id ?? null; + }; + + const sectionIdForBlock = (blockId: string): string | null => { + const b = (blocks ?? []).find((bk) => bk.id === blockId); + return b?.section_id ?? null; + }; + + // Unconfirm the given section (if confirmed) before performing an edit. + // Centralised so every mutation path keeps the confirmation/answer state + // honest: confirmation is a commit point, any edit reopens it. + const unconfirmIfNeeded = async (sectionId: string | null | undefined) => { + if (sectionId && confirmApiRef.current.isConfirmed(sectionId)) { + await confirmApiRef.current.unconfirm(sectionId); + } + }; + + // The plain envelope: flip the save indicator, run the work, settle status. + const withSaveStatus = async (fn: () => Promise) => { + setSaving(); + try { + await fn(); + setSaved(); + } catch { + setError(); + } + }; + + // Optimistically map over a cached list query. + const optimisticListUpdate = ( + key: QueryKey, + updater: (prev: T[]) => T[], + ) => { + qc.setQueryData(key, (prev) => updater(prev ?? [])); + }; + + const patchExam = async (patch: Partial) => { + if (!id || !exam) return; + await withSaveStatus(async () => { + qc.setQueryData(examKey(id), { ...exam, ...patch }); + await apiPatchExam(id, patch as Record); + }); + }; + + const patchTask = async (taskId: string, patch: Partial) => { + if (!id) return; + await unconfirmIfNeeded(sectionIdForTask(taskId)); + await withSaveStatus(async () => { + optimisticListUpdate(tasksKey(id), (prev) => + prev.map((t) => (t.id === taskId ? { ...t, ...patch } : t)), + ); + await apiPatchTask(taskId, patch as Record); + }); + }; + + const addTask = async ( + type: TaskType, + afterPosition: number, + sectionId: string | null, + ) => { + if (!id) return; + await unconfirmIfNeeded(sectionId); + setSaving(); + markPendingAdd(); + try { + await apiCreateTask({ + exam_id: id, + position: afterPosition + 1, + type, + section_id: sectionId, + options: + type === "text" + ? null + : [ + { id: crypto.randomUUID(), text: "", is_correct: false }, + { id: crypto.randomUUID(), text: "", is_correct: false }, + ], + }); + setSaved(); + qc.invalidateQueries({ queryKey: tasksKey(id) }); + } catch { + setError(); + } + }; + + const deleteTask = async (taskId: string) => { + if (!id) return; + await unconfirmIfNeeded(sectionIdForTask(taskId)); + await withSaveStatus(async () => { + optimisticListUpdate(tasksKey(id), (prev) => + prev.filter((t) => t.id !== taskId), + ); + await apiDeleteTask(taskId); + }); + }; + + const duplicateTask = async (task: Task) => { + if (!id) return; + await unconfirmIfNeeded(task.section_id); + setSaving(); + try { + await apiCreateTask({ + exam_id: id, + position: task.position + 1, + type: task.type, + prompt: task.prompt, + options: task.options ?? null, + reference_answer: task.reference_answer, + section: task.section, + points: task.points, + section_id: task.section_id, + }); + setSaved(); + qc.invalidateQueries({ queryKey: tasksKey(id) }); + } catch { + setError(); + } + }; + + const patchSection = async (sectionId: string, patch: Partial
) => { + if (!id) return; + // Confirmation toggles flow through useSectionConfirmations and bypass + // this helper, so any patchSection call here is a user-initiated edit + // and should reopen the section. + if (!("confirmed_at" in patch)) { + await unconfirmIfNeeded(sectionId); + } + await withSaveStatus(async () => { + optimisticListUpdate
(sectionsKey(id), (prev) => + prev.map((s) => (s.id === sectionId ? { ...s, ...patch } : s)), + ); + await apiPatchSection(sectionId, patch as Record); + }); + }; + + const patchBlock = async (blockId: string, patch: Partial) => { + if (!id) return; + await unconfirmIfNeeded(sectionIdForBlock(blockId)); + await withSaveStatus(async () => { + optimisticListUpdate(blocksKey(id), (prev) => + prev.map((b) => (b.id === blockId ? { ...b, ...patch } : b)), + ); + await apiPatchBlock(blockId, patch as Record); + }); + }; + + const addContextBlock = async (afterPosition: number, sectionId: string) => { + if (!id) return; + await unconfirmIfNeeded(sectionId); + setSaving(); + markPendingAdd(); + try { + await apiCreateBlock({ + exam_id: id, + section_id: sectionId, + position: afterPosition + 1, + content: "", + }); + setSaved(); + qc.invalidateQueries({ queryKey: blocksKey(id) }); + qc.invalidateQueries({ queryKey: tasksKey(id) }); + } catch (error) { + console.error("addContextBlock", error); + setError(); + } + }; + + const addFigureBlock = async (afterPosition: number, sectionId: string) => { + if (!id) return; + await unconfirmIfNeeded(sectionId); + setSaving(); + markPendingAdd(); + try { + await apiCreateBlock({ + exam_id: id, + section_id: sectionId, + position: afterPosition + 1, + content: "", + kind: "figure", + }); + setSaved(); + qc.invalidateQueries({ queryKey: blocksKey(id) }); + qc.invalidateQueries({ queryKey: tasksKey(id) }); + } catch { + setError(); + } + }; + + const deleteBlock = async (blockId: string) => { + if (!id) return; + await unconfirmIfNeeded(sectionIdForBlock(blockId)); + await withSaveStatus(async () => { + optimisticListUpdate(blocksKey(id), (prev) => + prev.filter((b) => b.id !== blockId), + ); + await apiDeleteBlock(blockId); + }); + }; + + const addSection = async (afterPosition?: number) => { + if (!id) return; + setSaving(); + // The backend's create-section is transactional and shifts later sections + // down itself, so we just hand it the target position (append falls past + // the end, where nothing needs shifting). + const position = + afterPosition != null ? afterPosition + 1 : (sections?.length ?? 0) + 1; + try { + await apiCreateSection({ exam_id: id, name: "", position }); + setSaved(); + qc.invalidateQueries({ queryKey: sectionsKey(id) }); + } catch { + setError(); + } + }; + + const deleteSection = async (sectionId: string) => { + if (!id) return; + setSaving(); + // Delete tasks, blocks, then the section itself + try { + await deleteTasksBySection(id, sectionId); + await deleteBlocksBySection(id, sectionId); + await apiDeleteSection(sectionId); + setSaved(); + qc.invalidateQueries({ queryKey: sectionsKey(id) }); + qc.invalidateQueries({ queryKey: tasksKey(id) }); + qc.invalidateQueries({ queryKey: blocksKey(id) }); + } catch { + setError(); + } + }; + + /** + * Persist a new ordering of items within a section. Assigns sequential + * positions (0, 1, 2, ...) and writes only the rows whose position changed. + * Cache is updated optimistically so the UI does not flicker. + */ + const persistReorder = async (newOrder: BlockItem[]) => { + if (!id) return; + // Items in a single reorder all belong to the same section (the call + // originates from one section's DndContext). Take the section id from + // the first item that exposes one and reopen its confirmation. + const firstWithSection = newOrder.find( + (it) => + (it.kind === "task" && it.task.section_id) || + it.kind === "context" || + it.kind === "figure", + ); + const reorderSectionId = + firstWithSection?.kind === "task" + ? firstWithSection.task.section_id + : firstWithSection?.kind === "context" || firstWithSection?.kind === "figure" + ? firstWithSection.block.section_id + : null; + await unconfirmIfNeeded(reorderSectionId); + setSaving(); + + const taskUpdates: Array<{ id: string; position: number }> = []; + const blockUpdates: Array<{ id: string; position: number }> = []; + + newOrder.forEach((item, idx) => { + if (item.kind === "task" && item.task.position !== idx) { + taskUpdates.push({ id: item.task.id, position: idx }); + } + if ( + (item.kind === "context" || item.kind === "figure") && + item.block.position !== idx + ) { + blockUpdates.push({ id: item.block.id, position: idx }); + } + }); + + // Optimistic cache update + if (taskUpdates.length > 0) { + const map = new Map(taskUpdates.map((u) => [u.id, u.position])); + qc.setQueryData(tasksKey(id), (prev) => + (prev ?? []).map((t) => + map.has(t.id) ? { ...t, position: map.get(t.id)! } : t, + ), + ); + } + if (blockUpdates.length > 0) { + const map = new Map(blockUpdates.map((u) => [u.id, u.position])); + qc.setQueryData(blocksKey(id), (prev) => + (prev ?? []).map((b) => + map.has(b.id) ? { ...b, position: map.get(b.id)! } : b, + ), + ); + } + + try { + await Promise.all([ + ...taskUpdates.map((u) => apiPatchTask(u.id, { position: u.position })), + ...blockUpdates.map((u) => apiPatchBlock(u.id, { position: u.position })), + ]); + setSaved(); + } catch { + setError(); + qc.invalidateQueries({ queryKey: tasksKey(id) }); + qc.invalidateQueries({ queryKey: blocksKey(id) }); + } + }; + + return { + patchExam, + patchTask, + addTask, + deleteTask, + duplicateTask, + patchSection, + patchBlock, + addContextBlock, + addFigureBlock, + deleteBlock, + addSection, + deleteSection, + persistReorder, + }; +} From 15e33966d89ce3034e12ab32580e8c73f1979b20 Mon Sep 17 00:00:00 2001 From: zhngharry Date: Wed, 15 Jul 2026 21:22:19 +0200 Subject: [PATCH 03/10] refactor(examlense): share useSectionGroups + useCurrentSectionId Extract the duplicated section-grouping / taskLetter / figureLabel memos and the hash-based current-section bootstrap from ExamEdit and GradingView into a shared useSectionGroups hook (parameterized by includeEmpty) plus useCurrentSectionId (editor passes introGate). Add index-based slug helpers (sectionIndexSlug, UNASSIGNED_SLUG) to exam-helpers and a unit test for the pure grouping/letter logic. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/hooks/ui/use-section-groups.test.ts | 83 +++++++++ .../src/hooks/ui/use-section-groups.ts | 162 ++++++++++++++++++ .../frontend/src/lib/exam/exam-helpers.ts | 10 ++ .../frontend/src/pages/exam-edit/ExamEdit.tsx | 106 ++---------- .../src/pages/exam-grading/GradingView.tsx | 70 ++------ 5 files changed, 281 insertions(+), 150 deletions(-) create mode 100644 apps/examlense/frontend/src/hooks/ui/use-section-groups.test.ts create mode 100644 apps/examlense/frontend/src/hooks/ui/use-section-groups.ts diff --git a/apps/examlense/frontend/src/hooks/ui/use-section-groups.test.ts b/apps/examlense/frontend/src/hooks/ui/use-section-groups.test.ts new file mode 100644 index 0000000..3e37c0e --- /dev/null +++ b/apps/examlense/frontend/src/hooks/ui/use-section-groups.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import { computeSectionGroups, computeTaskLetters } from "./use-section-groups"; +import { figureLabelsForBlocks, type Section, type SectionBlock, type Task } from "@/lib/exam/exam-helpers"; + +const section = (id: string, position: number, name = ""): Section => + ({ id, position, name }) as Section; + +const task = (id: string, position: number, sectionId: string | null): Task => + ({ id, position, section_id: sectionId, created_at: `2024-01-0${position + 1}` }) as Task; + +const figure = (id: string, position: number, sectionId: string): SectionBlock => + ({ id, position, section_id: sectionId, kind: "figure", created_at: "2024-01-01" }) as SectionBlock; + +describe("computeSectionGroups", () => { + const sections = [section("s2", 2), section("s1", 1), section("empty", 3)]; + const tasks = [ + task("t2", 1, "s1"), + task("t1", 0, "s1"), + task("t3", 0, "s2"), + task("orphan", 0, null), + ]; + const blocks = [figure("f1", 2, "s1")]; + + it("orders sections by position and assigns index-based slugs", () => { + const grouped = computeSectionGroups(sections, tasks, blocks, true); + // s1 (pos 1) → section-1, s2 (pos 2) → section-2, empty (pos 3) → section-3, orphan last. + expect(grouped.map((g) => g.slug)).toEqual([ + "section-1", + "section-2", + "section-3", + "section-unassigned", + ]); + expect(grouped[0].section?.id).toBe("s1"); + expect(grouped[3].section).toBeNull(); + }); + + it("includeEmpty:false drops sections with no items and keeps the orphan bucket", () => { + const grouped = computeSectionGroups(sections, tasks, blocks, false); + // "empty" section has no items → dropped; orphan has a task → kept. + expect(grouped.map((g) => g.slug)).toEqual([ + "section-1", + "section-2", + "section-unassigned", + ]); + }); + + it("interleaves tasks and blocks by position within a section", () => { + const grouped = computeSectionGroups(sections, tasks, blocks, true); + const s1 = grouped[0]; + // s1 items by position: t1 (0), t2 (1), f1 (2). + expect(s1.items.map((it) => it.position)).toEqual([0, 1, 2]); + expect(s1.items[2].kind).toBe("figure"); + }); +}); + +describe("computeTaskLetters", () => { + const sections = [section("s1", 1), section("s2", 2)]; + const tasks = [ + task("t2", 1, "s1"), + task("t1", 0, "s1"), + task("t3", 0, "s2"), + task("orphan", 0, null), + ]; + + it("assigns per-section letter labels ordered by task position", () => { + const grouped = computeSectionGroups(sections, tasks, [], true); + const letters = computeTaskLetters(grouped); + // s1 tasks by position: t1 (0) → a, t2 (1) → b. + expect(letters.get("t1")).toBe("a"); + expect(letters.get("t2")).toBe("b"); + // First (only) task in s2 and in the orphan bucket → a. + expect(letters.get("t3")).toBe("a"); + expect(letters.get("orphan")).toBe("a"); + }); +}); + +describe("figureLabelsForBlocks (shared by useSectionGroups)", () => { + it("labels figures as Figure {sectionNumber}.{index}", () => { + const sections = [section("s1", 1)]; + const blocks = [figure("f1", 2, "s1")]; + expect(figureLabelsForBlocks(sections, blocks).get("f1")).toBe("Figure 1.1"); + }); +}); diff --git a/apps/examlense/frontend/src/hooks/ui/use-section-groups.ts b/apps/examlense/frontend/src/hooks/ui/use-section-groups.ts new file mode 100644 index 0000000..c645595 --- /dev/null +++ b/apps/examlense/frontend/src/hooks/ui/use-section-groups.ts @@ -0,0 +1,162 @@ +import { useEffect, useMemo, useState } from "react"; +import { + figureLabelsForBlocks, + letterLabel, + mergeSectionItems, + sectionIndexSlug, + UNASSIGNED_SLUG, + type BlockItem, + type Section, + type SectionBlock, + type Task, +} from "@/lib/exam/exam-helpers"; + +export interface SectionGroup { + section: Section | null; + tasks: Task[]; + items: BlockItem[]; + slug: string; +} + +interface SectionGroupsOptions { + /** + * When true (editor), every section is kept even if empty and the orphan + * bucket is appended only when it holds tasks. When false (grading), groups + * with no items are dropped entirely. + */ + includeEmpty: boolean; +} + +/** + * Group tasks + blocks by section. Pure (no hooks) so it can be unit-tested; + * `useSectionGroups` memoizes it. Sections keep their sorted order and get an + * index-based slug; unassigned tasks fall into a trailing "orphan" group. When + * `includeEmpty` is false, groups with no items are dropped. + */ +export function computeSectionGroups( + sections: Section[] | undefined, + tasks: Task[] | undefined, + blocks: SectionBlock[] | undefined, + includeEmpty: boolean, +): SectionGroup[] { + const sortedSections = (sections ?? []) + .slice() + .sort((a, b) => a.position - b.position); + const tasksBySection = new Map(); + for (const task of tasks ?? []) { + const key = task.section_id ?? null; + const arr = tasksBySection.get(key) ?? []; + arr.push(task); + tasksBySection.set(key, arr); + } + const blocksBySection = new Map(); + for (const block of blocks ?? []) { + const arr = blocksBySection.get(block.section_id) ?? []; + arr.push(block); + blocksBySection.set(block.section_id, arr); + } + + const groups: SectionGroup[] = []; + sortedSections.forEach((s, index) => { + const sectionTasks = tasksBySection.get(s.id) ?? []; + const sectionBlocks = blocksBySection.get(s.id) ?? []; + groups.push({ + section: s, + tasks: sectionTasks, + items: mergeSectionItems(sectionTasks, sectionBlocks), + slug: sectionIndexSlug(index), + }); + }); + + const orphan = tasksBySection.get(null) ?? []; + if (orphan.length > 0) { + groups.push({ + section: null, + tasks: orphan, + items: mergeSectionItems(orphan, []), + slug: UNASSIGNED_SLUG, + }); + } + + return includeEmpty ? groups : groups.filter((g) => g.items.length > 0); +} + +/** Per-task letter labels (a, b, c…) within each section, ordered by position. */ +export function computeTaskLetters(grouped: SectionGroup[]): Map { + const m = new Map(); + grouped.forEach((g) => { + g.tasks + .slice() + .sort((a, b) => a.position - b.position) + .forEach((task, i) => m.set(task.id, letterLabel(i))); + }); + return m; +} + +/** + * Group an exam's tasks + blocks by section for the editor and grading views. + * Returns the grouping plus the per-task letter labels and the figure display + * labels that both views derive from it. + */ +export function useSectionGroups( + sections: Section[] | undefined, + tasks: Task[] | undefined, + blocks: SectionBlock[] | undefined, + { includeEmpty }: SectionGroupsOptions, +): { + grouped: SectionGroup[]; + taskLetterById: Map; + figureLabels: Map; +} { + const grouped = useMemo( + () => computeSectionGroups(sections, tasks, blocks, includeEmpty), + [tasks, sections, blocks, includeEmpty], + ); + + const taskLetterById = useMemo(() => computeTaskLetters(grouped), [grouped]); + + const figureLabels = useMemo( + () => figureLabelsForBlocks(sections, blocks), + [sections, blocks], + ); + + return { grouped, taskLetterById, figureLabels }; +} + +interface CurrentSectionIdOptions { + /** + * When true, an out-of-range current id resets to "" (empty) instead of the + * first group — the editor uses this to keep the intro slide visible until + * the user picks a section. + */ + introGate?: boolean; +} + +/** + * Track the carousel's current section slug, bootstrapped from the URL hash + * and re-validated whenever the grouping changes (a deleted section falls back + * to the first group, or to "" when the editor's intro is still pending). + */ +export function useCurrentSectionId( + grouped: SectionGroup[], + { introGate = false }: CurrentSectionIdOptions = {}, +): [string, (id: string) => void] { + const [currentId, setCurrentId] = useState(() => { + if (typeof window === "undefined") return ""; + return window.location.hash.replace(/^#/, ""); + }); + + useEffect(() => { + // Keep the current id (e.g. a hash deep-link) untouched while the grouping + // is still empty during initial load. + if (grouped.length === 0) return; + const validIds = new Set(grouped.map((g) => g.slug)); + setCurrentId((prev) => { + if (validIds.has(prev)) return prev; + if (introGate) return ""; + return grouped[0]?.slug ?? ""; + }); + }, [grouped, introGate]); + + return [currentId, setCurrentId]; +} diff --git a/apps/examlense/frontend/src/lib/exam/exam-helpers.ts b/apps/examlense/frontend/src/lib/exam/exam-helpers.ts index 6880db6..02b39c0 100644 --- a/apps/examlense/frontend/src/lib/exam/exam-helpers.ts +++ b/apps/examlense/frontend/src/lib/exam/exam-helpers.ts @@ -266,6 +266,16 @@ export const figureLabelsForBlocks = ( return out; }; +/** Carousel/hash slug for the orphan ("Unassigned tasks") bucket. */ +export const UNASSIGNED_SLUG = "section-unassigned"; + +/** + * Index-based carousel/hash slug for a real section: 1-based position in the + * sorted section list. Shared by the editor and grading views so their + * deep-link hashes stay in lockstep. + */ +export const sectionIndexSlug = (index: number) => `section-${index + 1}`; + /** * Build a URL-hash-safe slug for a section. Used by the editor + grading * + results sidebars to deep-link to a specific section. diff --git a/apps/examlense/frontend/src/pages/exam-edit/ExamEdit.tsx b/apps/examlense/frontend/src/pages/exam-edit/ExamEdit.tsx index 29b2bee..907930b 100644 --- a/apps/examlense/frontend/src/pages/exam-edit/ExamEdit.tsx +++ b/apps/examlense/frontend/src/pages/exam-edit/ExamEdit.tsx @@ -61,19 +61,19 @@ import { convertTaskType, examModePath, examModeSlug, - figureLabelsForBlocks, isSectionReady, itemId, - letterLabel, - mergeSectionItems, totalPoints, type BlockItem, type Exam, type Section, - type SectionBlock, type Task, type TaskType, } from "@/lib/exam/exam-helpers"; +import { + useSectionGroups, + useCurrentSectionId, +} from "@/hooks/ui/use-section-groups"; import { TASK_TYPE_LABELS } from "@/lib/exam/labels"; import { Badge } from "@/components/ui/badge"; import { @@ -104,8 +104,6 @@ const taskTypeIcon = (type: TaskType) => { } }; -const sectionIndexSlug = (index: number) => `section-${index + 1}`; - const ExamEditInner = () => { const { id } = useParams<{ id: string }>(); const qc = useQueryClient(); @@ -252,58 +250,13 @@ const ExamEditInner = () => { }); }; - // Group tasks + context blocks by section_id (preserving section order; unassigned tasks go last) - const grouped = useMemo(() => { - const sortedSections = (sections ?? []) - .slice() - .sort((a, b) => a.position - b.position); - const tasksBySection = new Map(); - for (const task of tasks ?? []) { - const key = task.section_id ?? null; - const arr = tasksBySection.get(key) ?? []; - arr.push(task); - tasksBySection.set(key, arr); - } - const blocksBySection = new Map(); - for (const block of blocks ?? []) { - const arr = blocksBySection.get(block.section_id) ?? []; - arr.push(block); - blocksBySection.set(block.section_id, arr); - } - - const groups: Array<{ - section: Section | null; - tasks: Task[]; - items: BlockItem[]; - slug: string; - }> = []; - for (const [index, s] of sortedSections.entries()) { - const sectionTasks = tasksBySection.get(s.id) ?? []; - const sectionBlocks = blocksBySection.get(s.id) ?? []; - groups.push({ - section: s, - tasks: sectionTasks, - items: mergeSectionItems(sectionTasks, sectionBlocks), - slug: sectionIndexSlug(index), - }); - } - const orphan = tasksBySection.get(null) ?? []; - if (orphan.length > 0) { - orphan.sort((a, b) => a.position - b.position); - groups.push({ - section: null, - tasks: orphan, - items: mergeSectionItems(orphan, []), - slug: "section-unassigned", - }); - } - return groups; - }, [tasks, sections, blocks]); - - // Auto-generated labels for figure blocks: "Figure {section}.{index}". - const figureLabels = useMemo( - () => figureLabelsForBlocks(sections, blocks), - [sections, blocks], + // Group tasks + context blocks by section (editor keeps empty sections). + // taskLetterById → a/b/c labels; figureLabels → "Figure {section}.{index}". + const { grouped, taskLetterById, figureLabels } = useSectionGroups( + sections, + tasks, + blocks, + { includeEmpty: true }, ); // Last position used inside a given section (across tasks + blocks). @@ -358,24 +311,10 @@ const ExamEditInner = () => { setIntroComplete(localStorage.getItem(introKey) === "1"); }, [introKey, showInlineIntro]); - const [currentSectionId, setCurrentSectionId] = useState(() => { - if (typeof window !== "undefined") { - const hashed = window.location.hash.replace(/^#/, ""); - if (hashed) return hashed; - } - return ""; + const [currentSectionId, setCurrentSectionId] = useCurrentSectionId(grouped, { + introGate: showInlineIntro && !introComplete, }); - useEffect(() => { - if (grouped.length === 0) return; - const validIds = new Set(grouped.map((g) => g.slug)); - setCurrentSectionId((prev) => { - if (validIds.has(prev)) return prev; - if (showInlineIntro && !introComplete) return ""; - return grouped[0]?.slug ?? ""; - }); - }, [grouped, introComplete, showInlineIntro]); - const markIntroComplete = useCallback(() => { if (introKey) localStorage.setItem(introKey, "1"); setIntroComplete(true); @@ -386,7 +325,7 @@ const ExamEditInner = () => { if (showInlineIntro && !introComplete) markIntroComplete(); setCurrentSectionId(slug); }, - [introComplete, markIntroComplete, showInlineIntro], + [introComplete, markIntroComplete, showInlineIntro, setCurrentSectionId], ); // Auto-expand newly created blocks. We only treat ids as "new" right @@ -467,21 +406,6 @@ const ExamEditInner = () => { void persistReorder(newOrder); }; - // Pure UI labels: a, b, c... per task within its section. Computed in - // render — no memoization, no callbacks, no cache churn. - const taskLetterById = (() => { - const counts = new Map(); - const out = new Map(); - const sorted = (tasks ?? []).slice().sort((a, b) => a.position - b.position); - for (const task of sorted) { - const key = task.section_id ?? null; - const i = counts.get(key) ?? 0; - out.set(task.id, letterLabel(i)); - counts.set(key, i + 1); - } - return out; - })(); - /** * Render a single block. When collapsed → lightweight TOC row. When * expanded → full editor card. Both paths share the same SortableItem so @@ -762,7 +686,7 @@ const ExamEditInner = () => { findNextUnconfirmed(idx + 1, grouped.length) ?? findNextUnconfirmed(0, idx); if (nextSlug) setCurrentSectionId(nextSlug); - }, [confirmApi, currentGroup, currentSectionRealId, grouped]); + }, [confirmApi, currentGroup, currentSectionRealId, grouped, setCurrentSectionId]); if (isLoading) { return ; diff --git a/apps/examlense/frontend/src/pages/exam-grading/GradingView.tsx b/apps/examlense/frontend/src/pages/exam-grading/GradingView.tsx index edd6a57..07c998a 100644 --- a/apps/examlense/frontend/src/pages/exam-grading/GradingView.tsx +++ b/apps/examlense/frontend/src/pages/exam-grading/GradingView.tsx @@ -28,14 +28,11 @@ import { SectionCarousel, type CarouselSlide, } from "@/components/shared/exam-content/SectionCarousel"; +import { type Task } from "@/lib/exam/exam-helpers"; import { - figureLabelsForBlocks, - letterLabel, - mergeSectionItems, - type Section, - type SectionBlock, - type Task, -} from "@/lib/exam/exam-helpers"; + useSectionGroups, + useCurrentSectionId, +} from "@/hooks/ui/use-section-groups"; import { effectiveScore, examTotals, @@ -156,60 +153,15 @@ export const GradingView = ({ examId }: Props) => { } }; - const grouped = useMemo(() => { - const sortedSections = (sections ?? []).slice().sort((a, b) => a.position - b.position); - const allSections: (Section | null)[] = [...sortedSections, null]; - const taskList = tasks ?? []; - const blockList = blocks ?? []; - const sectionIndexById = new Map(); - sortedSections.forEach((s, i) => sectionIndexById.set(s.id, i)); - return allSections - .map((sec) => { - const sId = sec?.id ?? null; - const sectionTasks = taskList.filter((tk) => (tk.section_id ?? null) === sId); - const sectionBlocks: SectionBlock[] = sec - ? blockList.filter((b) => b.section_id === sec.id) - : []; - const slug = sec - ? `section-${(sectionIndexById.get(sec.id) ?? 0) + 1}` - : "section-unassigned"; - return { - section: sec, - tasks: sectionTasks, - items: mergeSectionItems(sectionTasks, sectionBlocks), - slug, - }; - }) - .filter((g) => g.items.length > 0); - }, [tasks, sections, blocks]); - - const taskLetterById = useMemo(() => { - const m = new Map(); - grouped.forEach((g) => { - g.tasks - .slice() - .sort((a, b) => a.position - b.position) - .forEach((task, i) => m.set(task.id, letterLabel(i))); - }); - return m; - }, [grouped]); - - const figureLabels = useMemo( - () => figureLabelsForBlocks(sections, blocks), - [sections, blocks], + // Group tasks + blocks by section (grading drops sections with no items). + const { grouped, taskLetterById, figureLabels } = useSectionGroups( + sections, + tasks, + blocks, + { includeEmpty: false }, ); - const [currentId, setCurrentId] = useState(() => { - if (typeof window === "undefined") return ""; - return window.location.hash.replace(/^#/, ""); - }); - - useEffect(() => { - const validIds = new Set(grouped.map((g) => g.slug)); - if (!currentId || !validIds.has(currentId)) { - setCurrentId(grouped[0]?.slug ?? ""); - } - }, [grouped, currentId]); + const [currentId, setCurrentId] = useCurrentSectionId(grouped); const sectionEntries = useGradingSectionEntries( sections ?? [], From c24b8a54346d0e802310078ec9ccb71c70e3b66b Mon Sep 17 00:00:00 2001 From: zhngharry Date: Wed, 15 Jul 2026 21:27:51 +0200 Subject: [PATCH 04/10] refactor(examlense): drop needless useEffects in results/exams-list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both effects computed state that can be derived during render (see React's "You Might Not Need an Effect"): - AllTasksList: resolve the active section slug during render (fall back to the first section when the stored slug is empty/stale) instead of syncing it in an effect — also removes an extra render pass. - ExamsTable: derive `safePage` by clamping to totalPages during render (drops the clamp effect); reset to page 1 on sort inside `onSort`, and on the Title search prop change via the render-time prev-value pattern (drops the reset effect). Pagination controls now read `safePage`. No behavior change. Typecheck (my files) and vitest pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../exam-results/components/AllTasksList.tsx | 18 +++++----- .../src/pages/exams/components/ExamsTable.tsx | 34 +++++++++++-------- 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/apps/examlense/frontend/src/pages/exam-results/components/AllTasksList.tsx b/apps/examlense/frontend/src/pages/exam-results/components/AllTasksList.tsx index 21260ec..0ccf5d6 100644 --- a/apps/examlense/frontend/src/pages/exam-results/components/AllTasksList.tsx +++ b/apps/examlense/frontend/src/pages/exam-results/components/AllTasksList.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { ReadOnlyContextBlock } from "@/components/shared/exam-content/read-only/ReadOnlyContextBlock"; import { ReadOnlyFigureBlock } from "@/components/shared/exam-content/read-only/ReadOnlyFigureBlock"; import { ReadOnlyTaskCard } from "@/components/shared/exam-content/read-only/ReadOnlyTaskCard"; @@ -108,12 +108,12 @@ export const AllTasksList = ({ if (typeof window === "undefined") return ""; return window.location.hash.replace(/^#/, ""); }); - useEffect(() => { - const validIds = new Set(grouped.map((g) => g.slug)); - if (!currentSlug || !validIds.has(currentSlug)) { - setCurrentSlug(grouped[0]?.slug ?? ""); - } - }, [grouped, currentSlug]); + // Resolve the effective section during render rather than syncing state via an + // effect: fall back to the first section whenever the stored slug is empty or + // points at a section that no longer exists. + const activeSlug = grouped.some((g) => g.slug === currentSlug) + ? currentSlug + : grouped[0]?.slug ?? ""; const slides: CarouselSlide[] = grouped.map((g) => { const letterById = new Map(); @@ -246,7 +246,7 @@ export const AllTasksList = ({
@@ -254,7 +254,7 @@ export const AllTasksList = ({
diff --git a/apps/examlense/frontend/src/pages/exams/components/ExamsTable.tsx b/apps/examlense/frontend/src/pages/exams/components/ExamsTable.tsx index f4cd1ab..f4c1a23 100644 --- a/apps/examlense/frontend/src/pages/exams/components/ExamsTable.tsx +++ b/apps/examlense/frontend/src/pages/exams/components/ExamsTable.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react"; import type { ExamListItem } from "@/lib/api/api-client"; import { fuzzyMatch } from "@/lib/utils/fuzzy"; @@ -118,6 +118,7 @@ export const ExamsTable = ({ setSortKey(key); setSortDir(DEFAULT_DIR[key]); } + setPage(1); }; const filtered = useMemo(() => { @@ -131,16 +132,19 @@ export const ExamsTable = ({ const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); - // Reset to the first page whenever the result set is re-shaped, and keep the - // current page in range if the filtered set shrinks. - useEffect(() => { + // Reset to the first page when the Title search (a prop) changes — adjusted + // during render rather than in an effect. Sort changes reset the page in + // `onSort`. + const [prevQuery, setPrevQuery] = useState(query); + if (query !== prevQuery) { + setPrevQuery(query); setPage(1); - }, [query, sortKey, sortDir]); - useEffect(() => { - setPage((p) => Math.min(p, totalPages)); - }, [totalPages]); + } - const pageStart = (page - 1) * PAGE_SIZE; + // Clamp during render so a shrinking result set can't strand us past the last + // page; the stored `page` is corrected lazily rather than via an effect. + const safePage = Math.min(page, totalPages); + const pageStart = (safePage - 1) * PAGE_SIZE; const pageRows = filtered.slice(pageStart, pageStart + PAGE_SIZE); return ( @@ -181,19 +185,19 @@ export const ExamsTable = ({ href="#" onClick={(e) => { e.preventDefault(); - setPage((p) => Math.max(1, p - 1)); + setPage(Math.max(1, safePage - 1)); }} - className={cn(page === 1 && "pointer-events-none opacity-50")} + className={cn(safePage === 1 && "pointer-events-none opacity-50")} /> - {pageItems(page, totalPages).map((item, i) => ( + {pageItems(safePage, totalPages).map((item, i) => ( {item === "…" ? ( ) : ( { e.preventDefault(); setPage(item); @@ -209,9 +213,9 @@ export const ExamsTable = ({ href="#" onClick={(e) => { e.preventDefault(); - setPage((p) => Math.min(totalPages, p + 1)); + setPage(Math.min(totalPages, safePage + 1)); }} - className={cn(page === totalPages && "pointer-events-none opacity-50")} + className={cn(safePage === totalPages && "pointer-events-none opacity-50")} /> From 2c986362dd56f4b354f04e857309fa67c7d80d78 Mon Sep 17 00:00:00 2001 From: zhngharry Date: Wed, 15 Jul 2026 21:28:05 +0200 Subject: [PATCH 05/10] refactor(examlense): extract useInlineTextEdit + MarkdownEditField Collapse the byte-identical inline-edit state machine and textarea/MarkdownView toggle duplicated in TaskCard (prompt) and ContextBlockCard (content) into a shared useInlineTextEdit hook and MarkdownEditField component. Preserves each card's distinct read-view surface (TaskCard's bordered surface vs the context block's card-less hover tint). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../shared/exam-content/MarkdownEditField.tsx | 74 ++++++++++++++ .../src/hooks/ui/use-inline-text-edit.ts | 92 +++++++++++++++++ .../exam-edit/components/ContextBlockCard.tsx | 97 ++++-------------- .../pages/exam-edit/components/TaskCard.tsx | 99 ++++--------------- 4 files changed, 201 insertions(+), 161 deletions(-) create mode 100644 apps/examlense/frontend/src/components/shared/exam-content/MarkdownEditField.tsx create mode 100644 apps/examlense/frontend/src/hooks/ui/use-inline-text-edit.ts diff --git a/apps/examlense/frontend/src/components/shared/exam-content/MarkdownEditField.tsx b/apps/examlense/frontend/src/components/shared/exam-content/MarkdownEditField.tsx new file mode 100644 index 0000000..25871d6 --- /dev/null +++ b/apps/examlense/frontend/src/components/shared/exam-content/MarkdownEditField.tsx @@ -0,0 +1,74 @@ +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils/utils"; +import { + MarkdownView, + markdownSurfaceClassName, + markdownTextareaClassName, +} from "@/components/shared/exam-content/MarkdownView"; +import type { InlineTextEdit } from "@/hooks/ui/use-inline-text-edit"; + +interface Props { + field: InlineTextEdit; + placeholder: string; + /** aria-label for the read-view click target. */ + ariaLabel: string; + rows?: number; + /** Extra classes appended to the textarea's markdown class. */ + textareaClassName?: string; + /** Class for the read-view click target (defaults to the bordered surface). */ + readViewClassName?: string; + /** Class forwarded to MarkdownView in the read view. */ + markdownClassName?: string; + hint?: string; +} + +/** + * The textarea ↔ MarkdownView toggle shared by the editable task and context + * cards. Editing (or a blank value) shows the textarea plus a "Markdown + * supported" hint; otherwise a click-to-edit rendered-markdown surface. + */ +export const MarkdownEditField = ({ + field, + placeholder, + ariaLabel, + rows = 2, + textareaClassName, + readViewClassName = markdownSurfaceClassName, + markdownClassName, + hint = "Code blocks and snippets (Markdown) supported", +}: Props) => { + const { editing, isEmpty, enterEdit, textareaRef, textareaProps, value } = field; + + if (editing || isEmpty) { + return ( + <> +