Skip to content

Commit 9d5b817

Browse files
Edwin ChanEdwin Chan
authored andcommitted
Support custom problem tags
1 parent 53cef99 commit 9d5b817

7 files changed

Lines changed: 56 additions & 29 deletions

File tree

CENTRAL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ Stack: Next.js, NextAuth (Google + dev bypass), Prisma, KaTeX for math, Lucide i
3333

3434
### Recently landed
3535

36+
- **Custom problem tags** — create and edit forms accept arbitrary comma-separated set and question tags in addition to the suggested chips. Custom set tags become named, filterable categories in the problem-set catalog instead of collapsing into `Others`; question tags remain independent and become Practice topics after more than 10 published, unsolved questions use them. Tag aliases and casing still normalize through `lib/problem-tags.ts`; no schema or deploy change is required.
3637
- **Evidence-aware performance analytics** — the old points-weighted best average and duplicated leaderboard score are replaced by one derived `PerformanceProfile` in `lib/analytics.ts`. Mastery Index combines a three-set-prior proficiency score (65%), square-root visible-set breadth (20%), and a smoothed lower-quartile consistency floor (15%); best-set average, mastery rate, counts, and evidence remain visible. Dashboard, profiles, settings, student admin/detail, global analytics leaders, leaderboard, and student CSV use the same helper. `npm run simulate:performance` validates a deterministic 100-student × 100-set cohort: `0.980` ability/index rank correlation, 89 distinct values, monotonic ability deciles, and broad 80% performance outranking one perfect result. No schema/deploy change is required (sources: `lib/analytics.ts`, `scripts/simulate-performance-model.ts`, `tests/analytics.test.ts`, `docs/performance-model.md`).
3738
- **Attempt review** — `/attempts/[id]` presents a DBSOJ-inspired submission summary and dense expandable question rows with correct/incorrect/skipped state, submitted and normalized answers, accepted answers, awarded marks, topic, statement/assets, grader notes, and explanations. The route is owner-only for ordinary students and permits staff with `admin:analytics`; unauthorized attempt IDs return 404. Review links appear immediately after submit, on solved-set locks and set attempt history, on student dashboard history, and in student/per-set admin analytics. Pure summary/status helpers live in `lib/attempt-review.ts`, and `proxy.ts` now includes `/attempts/:path*`. Local Chrome QA covered student submission, expansion, light/dark rendering, history discovery, horizontal overflow, and cross-student denial. The local-only bypass controls are again surfaced when `AUTH_DEV_BYPASS=true` (sources: `app/attempts/[id]/page.tsx`, `app/problem-sets/[slug]/answer-grid.tsx`, `app/problem-sets/[slug]/page.tsx`, `app/dashboard/page.tsx`, `app/admin/students/[id]/page.tsx`, `app/admin/sets/[id]/analytics/page.tsx`, `app/page.tsx`, `proxy.ts`).
3839
- **Application-wide safety and maintainability pass** — all API JSON/multipart bodies now use bounded streamed readers and route-specific schemas; auth uses exact school-domain matching and an explicit non-production bypass opt-in; staff routing and private-profile/leaderboard authorization use the documented permissions. Submission, friendship, role mutation, problem-set asset, and FTW room transitions are race-safe; FTW room codes use cryptographic randomness. Grading preserves exact large integer/fraction/decimal identity instead of collapsing through IEEE-754. Imports stage storage before atomic metadata attachment, server and browser ZIP paths enforce actual expanded-byte limits, replacement/deletion paths compensate for failures, and file reads verify size/checksum under hard limits. Exports/restores are paginated and capped, CSV formula cells are neutralized, expensive GETs reject cross-site browser requests, and formerly unbounded assignment/practice-tag scans are computed in bounded SQL. Formerly floating `latest` dependencies are pinned, full and production dependency audits report zero vulnerabilities, and response security headers are enabled. The UI, feature set, and database schema are unchanged. Regression coverage spans 29 test files/195 tests, including policy, body, grading, concurrency, storage, and import cases.

app/admin/create/page-client.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -615,7 +615,7 @@ export function CreateSetPageClient({ importDraftKey }: CreateSetPageClientProps
615615
</div>
616616

617617
<div className="form-field">
618-
<label htmlFor="set-topics">Set tags (comma-separated)</label>
618+
<label htmlFor="set-topics">Set tags (comma-separated; new tags allowed)</label>
619619
<input
620620
id="set-topics"
621621
type="text"
@@ -624,8 +624,8 @@ export function CreateSetPageClient({ importDraftKey }: CreateSetPageClientProps
624624
onChange={(e) => setTopicTags(e.target.value)}
625625
/>
626626
<small className="form-hint">
627-
These describe the whole set. Practice pools are built from the optional question tags
628-
on each problem below.
627+
Type any new tag name; it is created when you save. These describe the whole set.
628+
Practice pools are built from the optional question tags on each problem below.
629629
</small>
630630
<div className="tag-chip-group">
631631
{TAG_OPTIONS.map((option) => (
@@ -898,8 +898,8 @@ export function CreateSetPageClient({ importDraftKey }: CreateSetPageClientProps
898898
onChange={(e) => updateProblem(p.id, "topicTags", e.target.value)}
899899
/>
900900
<small className="form-hint">
901-
Add tags if this question should feed Practice mode. A tag appears in Practice
902-
after more than 10 published questions use it.
901+
Type any new tag name to create it. A tag appears in Practice after more than 10
902+
published questions use it.
903903
</small>
904904
<div className="tag-chip-group">
905905
{TAG_OPTIONS.map((option) => (

app/admin/sets/[id]/set-edit-form.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -593,16 +593,16 @@ export function SetEditForm({ set }: { set: SetData }) {
593593
</div>
594594

595595
<label className="form-field">
596-
<span className="form-label">Set tags</span>
596+
<span className="form-label">Set tags (new tags allowed)</span>
597597
<input
598598
className="form-input"
599599
value={topicTags}
600600
placeholder="algebra, equations"
601601
onChange={(e) => setTopicTags(e.target.value)}
602602
/>
603603
<small className="form-hint">
604-
These describe the whole set. Practice pools are built from the optional question
605-
tags on each problem.
604+
Type any new tag name; it is created when you save. These describe the whole set.
605+
Practice pools are built from the optional question tags on each problem.
606606
</small>
607607
<div className="tag-chip-group">
608608
{TAG_OPTIONS.map((option) => (
@@ -868,8 +868,8 @@ export function SetEditForm({ set }: { set: SetData }) {
868868
placeholder="Algebra, Linear Equations"
869869
/>
870870
<small className="form-hint">
871-
Optional. These tags place the question into Practice pools once more than
872-
10 published questions share the same tag.
871+
Type any new tag name to create it. These tags place the question into
872+
Practice pools once more than 10 published questions share the same tag.
873873
</small>
874874
<div className="tag-chip-group">
875875
{TAG_OPTIONS.map((option) => (

app/problem-sets/page.tsx

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { prisma } from "@/lib/db";
77
import {
88
OTHER_PROBLEM_SET_TAG,
99
STANDARD_PROBLEM_SET_TAGS,
10+
canonicalizeProblemTag,
1011
categorizeProblemSetTags,
1112
normalizeTagList,
1213
normalizeProblemTag,
@@ -37,8 +38,6 @@ type SetRow = {
3738
recommendationScore: number;
3839
};
3940

40-
const CATEGORY_ORDER = [...STANDARD_PROBLEM_SET_TAGS, OTHER_PROBLEM_SET_TAG];
41-
4241
type ProblemSetsSearchParams = Promise<{
4342
category?: string;
4443
hideSolved?: string;
@@ -97,10 +96,8 @@ export default async function ProblemSetsPage({
9796
const hideSolved = params.hideSolved === "1";
9897
const query = params.q?.trim() ?? "";
9998
const normalizedQuery = query.toLowerCase();
100-
const activeCategory =
101-
CATEGORY_ORDER.find(
102-
(category) => normalizeProblemTag(category) === normalizeProblemTag(params.category ?? ""),
103-
) ?? null;
99+
const requestedCategory = params.category?.trim().slice(0, 64) ?? "";
100+
const activeCategory = requestedCategory ? canonicalizeProblemTag(requestedCategory) : null;
104101

105102
function problemSetsHref(next: {
106103
category?: string | null;
@@ -247,8 +244,9 @@ export default async function ProblemSetsPage({
247244

248245
const setRows: SetRow[] = visibleSets.map((set) => {
249246
const progress = attemptMap.get(set.id) ?? { bestScore: 0, attempts: 0 };
247+
const setTags = normalizeTagList(set.topicTags);
250248
const allTags = normalizeTagList([
251-
...set.topicTags,
249+
...setTags,
252250
...set.problems.flatMap((problem) => problem.topicTags),
253251
]);
254252
const solvedUsers = new Set(
@@ -262,7 +260,7 @@ export default async function ProblemSetsPage({
262260
title: set.title,
263261
order: set.order,
264262
createdAt: set.createdAt,
265-
categories: categorizeProblemSetTags(allTags),
263+
categories: categorizeProblemSetTags(setTags),
266264
tags: allTags,
267265
problemCount: set._count.problems,
268266
bestScore: progress.bestScore,
@@ -336,8 +334,25 @@ export default async function ProblemSetsPage({
336334
});
337335
const filteredRows = hideSolved ? mediaRows.filter((set) => set.bestScore < 100) : mediaRows;
338336

337+
const availableCategories = new Set(filteredRows.flatMap((set) => set.categories));
338+
const categoryOrder = [
339+
...STANDARD_PROBLEM_SET_TAGS.filter((category) => availableCategories.has(category)),
340+
...Array.from(availableCategories)
341+
.filter(
342+
(category) =>
343+
category !== OTHER_PROBLEM_SET_TAG &&
344+
!STANDARD_PROBLEM_SET_TAGS.some(
345+
(standard) => normalizeProblemTag(standard) === normalizeProblemTag(category),
346+
),
347+
)
348+
.sort((left, right) => left.localeCompare(right)),
349+
...(availableCategories.has(OTHER_PROBLEM_SET_TAG) ? [OTHER_PROBLEM_SET_TAG] : []),
350+
];
351+
if (activeCategory && !categoryOrder.includes(activeCategory)) {
352+
categoryOrder.push(activeCategory);
353+
}
339354
const groupedRows = new Map<string, SetRow[]>(
340-
CATEGORY_ORDER.map((category) => [
355+
categoryOrder.map((category) => [
341356
category,
342357
filteredRows.filter((set) => {
343358
const matchesSearch =
@@ -611,7 +626,7 @@ export default async function ProblemSetsPage({
611626
All
612627
<span className="category-pill-count">{filteredRows.length}</span>
613628
</Link>
614-
{CATEGORY_ORDER.map((category) => {
629+
{categoryOrder.map((category) => {
615630
const rows = groupedRows.get(category) ?? [];
616631
if (rows.length === 0 && activeCategory !== category) return null;
617632
return (

docs/admin-guide.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@
1111
1. Click **Create Set** in the sidebar.
1212
2. Fill out the set title, slug, topic tags, and settings.
1313
- **Order ID:** The identifier shown in the set grid (e.g. `1`, `2`, `20212`, `A1`). Accepts any text and uses natural sorting, so `2` appears before `10`. If left blank, the system assigns the next available number.
14+
- **Set tags:** Enter comma-separated names or use the suggested chips. New names are created when the set is saved and become filterable categories in the problem-set catalog.
1415
- **Tests tag:** Use the set tag `Tests` for school test papers that have 20 problems with levels `(1)`, `(2)`, and `(3)`. Store these as 60 answerable questions so the student page renders a grouped 20×3 answer sheet.
1516
3. Add problems one-by-one, including statement, answer type, and answer key.
1617
- **Problem Number:** A positive integer (e.g. 1, 2, 3). If you leave it as-is, new problems default to sequential numbers.
18+
- **Question tags:** Enter any comma-separated names to create or reuse Practice topics. A topic appears in Practice after more than 10 published, unsolved questions use that tag.
1719
4. For each problem, use the **LaTeX / HTML** toggle next to the statement field.
1820
- Use **LaTeX** for `$...$`/`$$...$$` style input.
1921
- Table environments (`tabular`, `tabular*`, `tabularx`, and `longtable`) and chemistry with `\ce{...}` are supported through the compatibility renderer; see [LaTeX rendering support](./latex-support.md).

lib/problem-tags.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -123,20 +123,16 @@ export function normalizeTagList(tags: string[]): string[] {
123123
export function categorizeProblemSetTags(tags: string[]): string[] {
124124
const normalizedTags = normalizeTagList(tags);
125125
const categories: string[] = [];
126-
let hasCustom = false;
127126

128127
for (const tag of normalizedTags) {
129128
const standardTag = standardTagLookup.get(normalizeProblemTag(tag));
130-
if (standardTag) {
131-
if (!categories.includes(standardTag)) {
132-
categories.push(standardTag);
133-
}
134-
} else {
135-
hasCustom = true;
129+
const category = standardTag ?? tag;
130+
if (!categories.includes(category)) {
131+
categories.push(category);
136132
}
137133
}
138134

139-
if (categories.length === 0 || hasCustom) {
135+
if (categories.length === 0) {
140136
categories.push(OTHER_PROBLEM_SET_TAG);
141137
}
142138

tests/problem-tags.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { normalizeTagList } from "../lib/problem-tags";
2+
import { categorizeProblemSetTags, normalizeTagList } from "../lib/problem-tags";
33

44
describe("normalizeTagList", () => {
55
it("canonicalizes aliases and duplicate casing", () => {
@@ -23,3 +23,16 @@ describe("normalizeTagList", () => {
2323
expect(normalizeTagList(["hle", "Hle", "HLE", "Humanity's Last Exam"])).toEqual(["HLE"]);
2424
});
2525
});
26+
27+
describe("categorizeProblemSetTags", () => {
28+
it("promotes custom set tags to filterable categories", () => {
29+
expect(categorizeProblemSetTags(["algebra", "olympiad shortlist"])).toEqual([
30+
"Algebra",
31+
"Olympiad Shortlist",
32+
]);
33+
});
34+
35+
it("uses Others only when a set has no tags", () => {
36+
expect(categorizeProblemSetTags([])).toEqual(["Others"]);
37+
});
38+
});

0 commit comments

Comments
 (0)