From f09da88ff7935ddcaba9b0de05a77c7c7f60be3d Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 29 Sep 2026 18:28:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=BB=9F=E8=AE=A1):=20=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E8=A1=A5=E4=B8=8A=E6=AF=8F=E4=BA=BA=E6=AF=8F?= =?UTF-8?q?=E9=81=93=E9=A2=98=E7=9A=84=E6=AF=8F=E4=B8=80=E6=AC=A1=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E3=80=81=E6=B5=81=E7=A8=8B=E5=9B=BE=E6=AF=8F=E4=BA=BA?= =?UTF-8?q?=E6=9C=80=E5=A5=BD=E7=9A=84=E4=B8=80=E6=AC=A1=EF=BC=8C=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=E5=9B=BE=E4=B9=9F=E8=83=BD=E4=B8=80=E6=AC=A1=E6=9F=A5?= =?UTF-8?q?=E5=87=A0=E9=81=93=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 GET /submissions/statistics/grid:范围内每个学生的每次提交(只含普通学生,最多 5000 条), 没选班级时附带各班花名册人数,统计页按「人 × 题」画方块串、按班级汇总 - /flowcharts/statistics 题号可以一次填几道,多返回 people(每人分数最高的那张) Co-Authored-By: Claude Opus 5.5 --- apps/api/src/routes/flowchart.ts | 180 ++-- apps/api/src/routes/submission-statistics.ts | 132 +++ .../oj/problem/components/StatisticsModal.vue | 31 - .../components/FlowchartStatisticsPanel.vue | 513 ----------- .../src/shared/components/StatisticsPanel.vue | 813 ------------------ .../src/shared/composables/statisticsQuery.ts | 65 -- packages/contract/src/flowchart.ts | 13 + packages/contract/src/submission.ts | 33 + 8 files changed, 288 insertions(+), 1492 deletions(-) delete mode 100644 apps/web/src/oj/problem/components/StatisticsModal.vue delete mode 100644 apps/web/src/shared/components/FlowchartStatisticsPanel.vue delete mode 100644 apps/web/src/shared/components/StatisticsPanel.vue delete mode 100644 apps/web/src/shared/composables/statisticsQuery.ts diff --git a/apps/api/src/routes/flowchart.ts b/apps/api/src/routes/flowchart.ts index 38322de..6d2a18f 100644 --- a/apps/api/src/routes/flowchart.ts +++ b/apps/api/src/routes/flowchart.ts @@ -358,21 +358,30 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => { ] if (start) filters.push(sql`${schema.flowchartSubmission.createTime} >= ${start}`) - const displayId = c.req.query("problemDisplayId")?.trim() - if (displayId) { - const [problem] = await db - .select({ id: schema.problem.id }) + // 可以一次查几道(统计页的题号框和代码统计同一个),有一道找不到就整体报错, + // 不退化成「全部题目」—— 否则打错一个字看到的是全站数据 + const displayIds = parseDisplayIds(c.req.query("problemDisplayId") ?? "") + if (displayIds.length) { + const lowered = displayIds.map((id) => id.toLowerCase()) + const problems = await db + .select({ id: schema.problem.id, displayId: schema.problem.displayId }) .from(schema.problem) .where( and( - sql`lower(${schema.problem.displayId}) = lower(${displayId})`, + inArray(sql`lower(${schema.problem.displayId})`, lowered), isNull(schema.problem.contestId), eq(schema.problem.visible, true), ), ) - .limit(1) - if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist") - filters.push(eq(schema.flowchartSubmission.problemId, problem.id)) + const found = new Set(problems.map((row) => row.displayId.toLowerCase())) + const missing = displayIds.find((id) => !found.has(id.toLowerCase())) + if (missing) return failure(c, 404, "problem-not-found", `Problem ${missing} does not exist`) + filters.push( + inArray( + schema.flowchartSubmission.problemId, + problems.map((row) => row.id), + ), + ) } const username = c.req.query("username")?.trim() @@ -400,49 +409,52 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => { * (criteria 255 + suggestions 64 + feedback 47),现在 2134 条无感,5 万条就是一次 * 点击 18MB,而老师是开着面板反复切时段、切班的。 */ - const [[totals], gradeRows, criteriaRows, textRows, submittedRows] = await Promise.all([ - db - .select({ - total: count(), - /** - * 均分拆成 sum / count 两项,不直接用 `avg()`:分母是**有分数的条数**而不是 - * 总条数(对齐 Django 的 Avg(),它跳过 NULL),拆开之后这个口径在代码里是 - * 写明的,也省掉 avg() 在空集上回 NULL 还要兜底。 - */ - scoreSum: sql`coalesce(sum(${schema.flowchartSubmission.aiScore}), 0)`.mapWith( - Number, - ), - scoreCount: sql`count(${schema.flowchartSubmission.aiScore})::int`.mapWith(Number), - // 完成人数。user_id 和 username 一一对应,按哪个 distinct 都一样, - // 按 user_id 就不必 join user - completedCount: - sql`count(distinct ${schema.flowchartSubmission.userId})::int`.mapWith(Number), - }) - .from(schema.flowchartSubmission) - .where(where), - db - .select({ grade: schema.flowchartSubmission.aiGrade, n: count() }) - .from(schema.flowchartSubmission) - .where(where) - .groupBy(schema.flowchartSubmission.aiGrade), - /** - * 各项**平均分**。`ai_criteria_details` 是 `{ 项名: { score, max, comment } }`, - * 用 jsonb_each 展开之后按项名分组。分数不是数字的项整项跳过,和原来 JS 那句 - * `typeof detail.score !== "number"` 的 continue 一致。 - * - * **那道 `jsonb_typeof(...) = 'object'` 的闸不能省,而且要写在 jsonb_each 的参数里。** - * 不能省:撞上标量(历史脏数据)jsonb_each 直接抛错,整个面板 500 —— - * 拿 `'5'::jsonb` 和 `'[1,2]'::jsonb` 各插一行验过。 - * - * 写在哪儿则纯是规划器的脸色:挪进 where 当基表过滤条件时,53350 行的探针上 - * 实测 180ms → 360ms,因为计划从「并行 Partial HashAggregate」换成了「串行 - * GroupAggregate + 21 万行外部归并排序、落盘 26MB」。两种写法都正确,选快的那个。 - * - * 每项的**满分**不在这里取,见下面 criteriaMax 的注释:在这条 SQL 里按 - * create_time 取「最新那条」要给 21 万行(4 项 × 5 万条)排序,同一个探针上 - * 实测 254ms → 842ms,而满分本来就是几个常数。 - */ - db.execute<{ key: string; avg: number }>(sql` + const [[totals], gradeRows, criteriaRows, textRows, submittedRows, peopleRows] = + await Promise.all([ + db + .select({ + total: count(), + /** + * 均分拆成 sum / count 两项,不直接用 `avg()`:分母是**有分数的条数**而不是 + * 总条数(对齐 Django 的 Avg(),它跳过 NULL),拆开之后这个口径在代码里是 + * 写明的,也省掉 avg() 在空集上回 NULL 还要兜底。 + */ + scoreSum: sql`coalesce(sum(${schema.flowchartSubmission.aiScore}), 0)`.mapWith( + Number, + ), + scoreCount: sql`count(${schema.flowchartSubmission.aiScore})::int`.mapWith( + Number, + ), + // 完成人数。user_id 和 username 一一对应,按哪个 distinct 都一样, + // 按 user_id 就不必 join user + completedCount: + sql`count(distinct ${schema.flowchartSubmission.userId})::int`.mapWith(Number), + }) + .from(schema.flowchartSubmission) + .where(where), + db + .select({ grade: schema.flowchartSubmission.aiGrade, n: count() }) + .from(schema.flowchartSubmission) + .where(where) + .groupBy(schema.flowchartSubmission.aiGrade), + /** + * 各项**平均分**。`ai_criteria_details` 是 `{ 项名: { score, max, comment } }`, + * 用 jsonb_each 展开之后按项名分组。分数不是数字的项整项跳过,和原来 JS 那句 + * `typeof detail.score !== "number"` 的 continue 一致。 + * + * **那道 `jsonb_typeof(...) = 'object'` 的闸不能省,而且要写在 jsonb_each 的参数里。** + * 不能省:撞上标量(历史脏数据)jsonb_each 直接抛错,整个面板 500 —— + * 拿 `'5'::jsonb` 和 `'[1,2]'::jsonb` 各插一行验过。 + * + * 写在哪儿则纯是规划器的脸色:挪进 where 当基表过滤条件时,53350 行的探针上 + * 实测 180ms → 360ms,因为计划从「并行 Partial HashAggregate」换成了「串行 + * GroupAggregate + 21 万行外部归并排序、落盘 26MB」。两种写法都正确,选快的那个。 + * + * 每项的**满分**不在这里取,见下面 criteriaMax 的注释:在这条 SQL 里按 + * create_time 取「最新那条」要给 21 万行(4 项 × 5 万条)排序,同一个探针上 + * 实测 254ms → 842ms,而满分本来就是几个常数。 + */ + db.execute<{ key: string; avg: number }>(sql` select e.key as key, avg((e.value->>'score')::double precision) as avg from ${schema.flowchartSubmission} cross join lateral jsonb_each( @@ -453,25 +465,51 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => { where ${where} and jsonb_typeof(e.value->'score') = 'number' group by e.key `), - // 词云的原料。只有这条要读大列,所以只有它按时间倒序取最近的 N 条 - db - .select({ - criteria: schema.flowchartSubmission.aiCriteriaDetails, - feedback: schema.flowchartSubmission.aiFeedback, - suggestions: schema.flowchartSubmission.aiSuggestions, - }) - .from(schema.flowchartSubmission) - .where(where) - .orderBy(desc(schema.flowchartSubmission.createTime)) - .limit(WORDCLOUD_TEXT_LIMIT), - // 「谁没做」只在有花名册时算得出来,行数也就一个班 - roster.length - ? db - .selectDistinct({ userId: schema.flowchartSubmission.userId }) - .from(schema.flowchartSubmission) - .where(where) - : [], - ]) + // 词云的原料。只有这条要读大列,所以只有它按时间倒序取最近的 N 条 + db + .select({ + criteria: schema.flowchartSubmission.aiCriteriaDetails, + feedback: schema.flowchartSubmission.aiFeedback, + suggestions: schema.flowchartSubmission.aiSuggestions, + }) + .from(schema.flowchartSubmission) + .where(where) + .orderBy(desc(schema.flowchartSubmission.createTime)) + .limit(WORDCLOUD_TEXT_LIMIT), + // 「谁没做」只在有花名册时算得出来,行数也就一个班 + roster.length + ? db + .selectDistinct({ userId: schema.flowchartSubmission.userId }) + .from(schema.flowchartSubmission) + .where(where) + : [], + // 每人最好的一次。等级取分数最高那张的等级(同分取最近的),只算普通学生 + db + .select({ + username: schema.user.username, + className: schema.user.className, + bestScore: sql`max(${schema.flowchartSubmission.aiScore})`, + bestGrade: sql`(array_agg(${schema.flowchartSubmission.aiGrade} + order by ${schema.flowchartSubmission.aiScore} desc nulls last, ${schema.flowchartSubmission.createTime} desc))[1]`, + count: count(), + }) + .from(schema.flowchartSubmission) + .innerJoin(schema.user, eq(schema.user.id, schema.flowchartSubmission.userId)) + .where( + and(where, eq(schema.user.adminType, "Regular User"), eq(schema.user.isDisabled, false)), + ) + .groupBy(schema.user.id, schema.user.username, schema.user.className) + .orderBy(sql`max(${schema.flowchartSubmission.aiScore}) asc nulls first`) + .limit(500), + ]) + + const people = peopleRows.map((row) => ({ + username: row.username, + realName: stripClassPrefix(row.username, row.className), + bestScore: row.bestScore === null ? null : Number(row.bestScore), + bestGrade: row.bestGrade, + count: row.count, + })) if (!totals || totals.total === 0) { return success(c, { @@ -488,6 +526,7 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => { username: row.username, realName: stripClassPrefix(row.username, row.className), })), + people: [], } satisfies FlowchartStatistics) } @@ -551,6 +590,7 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => { username: row.username, realName: stripClassPrefix(row.username, row.className), })), + people, } satisfies FlowchartStatistics) }) diff --git a/apps/api/src/routes/submission-statistics.ts b/apps/api/src/routes/submission-statistics.ts index 31965f9..5aebe7e 100644 --- a/apps/api/src/routes/submission-statistics.ts +++ b/apps/api/src/routes/submission-statistics.ts @@ -8,6 +8,7 @@ import { type SubmissionStatistics, + type SubmissionStatisticsGrid, type SubmissionStatisticsItems, type TodaySubmissionStatistics, } from "@oj2/contract" @@ -652,3 +653,134 @@ submissionStatisticsRoutes.get("/submissions/statistics/items", requireTeacher, truncated, } satisfies SubmissionStatisticsItems) }) + +/** 方块串一次最多给多少条。一个班一个月七八百条,5000 是全年级查一周的量 */ +const GRID_LIMIT = 5000 + +/** + * 统计页的方块串:范围内每个学生的每一次提交(口径见契约 submissionStatisticsGridSchema)。 + * 用户名的匹配和统计接口同一套(ilike,填 `ks253` 圈一个班)。 + */ +submissionStatisticsRoutes.get("/submissions/statistics/grid", requireTeacher, async (c) => { + const scope = await statisticsScope(c) + if (!scope.ok) return failure(c, scope.status, scope.code, scope.message) + const filters = [...scope.filters] + + const username = c.req.query("username")?.trim() + if (username) { + const ids = (await matchedUsers(username)).map((row) => row.id) + filters.push(ids.length ? inArray(schema.submission.userId, ids) : sql`false`) + } + + const rows = await db + .select({ + id: schema.submission.id, + userId: schema.submission.userId, + problemId: schema.submission.problemId, + result: schema.submission.result, + createTime: schema.submission.createTime, + username: schema.user.username, + className: schema.user.className, + }) + .from(schema.submission) + // 只要普通学生:老师试题的提交不该出现在「谁做了几次」里 + .innerJoin(schema.user, eq(schema.user.id, schema.submission.userId)) + .where( + and(...filters, eq(schema.user.adminType, "Regular User"), eq(schema.user.isDisabled, false)), + ) + .orderBy(desc(schema.submission.createTime)) + .limit(GRID_LIMIT + 1) + const truncated = rows.length > GRID_LIMIT + const kept = rows.slice(0, GRID_LIMIT).reverse() + + // 题目:传了就按传的顺序,没传就按范围里第一次有人交的时间 + const requested = parseDisplayIds(c.req.query("problemDisplayId") ?? "") + const problemIds = [...new Set(kept.map((row) => row.problemId))] + const problems = problemIds.length + ? await db + .select({ + id: schema.problem.id, + displayId: schema.problem.displayId, + title: schema.problem.title, + }) + .from(schema.problem) + .where(inArray(schema.problem.id, problemIds)) + : [] + const problemById = new Map(problems.map((row) => [row.id, row])) + const ordered = requested.length + ? requested.flatMap((id) => { + const hit = problems.find((row) => row.displayId.toLowerCase() === id.toLowerCase()) + return hit ? [hit] : [] + }) + : problemIds.map((id) => problemById.get(id)!).filter(Boolean) + // 传了题号但这些题在范围里没人交:表头仍然要有它们 + if (requested.length && ordered.length < requested.length) { + const missing = requested.filter( + (id) => !ordered.some((row) => row.displayId.toLowerCase() === id.toLowerCase()), + ) + const { ids } = await findPublicProblemsByDisplayIds(missing) + if (ids.length) { + const extra = await db + .select({ + id: schema.problem.id, + displayId: schema.problem.displayId, + title: schema.problem.title, + }) + .from(schema.problem) + .where(inArray(schema.problem.id, ids)) + ordered.push(...extra) + const rank = new Map(requested.map((id, i) => [id.toLowerCase(), i])) + ordered.sort( + (a, b) => + (rank.get(a.displayId.toLowerCase()) ?? 0) - (rank.get(b.displayId.toLowerCase()) ?? 0), + ) + } + } + + const byUser = new Map() + for (const row of kept) { + let entry = byUser.get(row.userId) + if (!entry) { + entry = { + username: row.username, + realName: stripClassPrefix(row.username, row.className), + className: row.className, + submissions: [], + } + byUser.set(row.userId, entry) + } + entry.submissions.push({ + id: row.id, + problemDisplayId: problemById.get(row.problemId)?.displayId ?? "", + result: row.result, + createTime: row.createTime, + }) + } + + // 按班级汇总时要知道每个班多少人,「一道没交」才算得出来 + const classSizes: Record = {} + if (!username) { + const names = [...new Set(kept.map((row) => row.className).filter((name) => name !== null))] + if (names.length) { + const sizes = await db + .select({ className: schema.user.className, value: count() }) + .from(schema.user) + .where( + and( + inArray(schema.user.className, names as string[]), + eq(schema.user.adminType, "Regular User"), + eq(schema.user.isDisabled, false), + ), + ) + .groupBy(schema.user.className) + for (const row of sizes) if (row.className) classSizes[row.className] = row.value + } + } + + return success(c, { + problems: ordered.map((row) => ({ problemDisplayId: row.displayId, title: row.title })), + rows: [...byUser.values()], + classSizes, + truncated, + } satisfies SubmissionStatisticsGrid) +}) diff --git a/apps/web/src/oj/problem/components/StatisticsModal.vue b/apps/web/src/oj/problem/components/StatisticsModal.vue deleted file mode 100644 index aae331a..0000000 --- a/apps/web/src/oj/problem/components/StatisticsModal.vue +++ /dev/null @@ -1,31 +0,0 @@ - - - diff --git a/apps/web/src/shared/components/FlowchartStatisticsPanel.vue b/apps/web/src/shared/components/FlowchartStatisticsPanel.vue deleted file mode 100644 index 1619d2d..0000000 --- a/apps/web/src/shared/components/FlowchartStatisticsPanel.vue +++ /dev/null @@ -1,513 +0,0 @@ - - - - - diff --git a/apps/web/src/shared/components/StatisticsPanel.vue b/apps/web/src/shared/components/StatisticsPanel.vue deleted file mode 100644 index 4ee2f0a..0000000 --- a/apps/web/src/shared/components/StatisticsPanel.vue +++ /dev/null @@ -1,813 +0,0 @@ - - - diff --git a/apps/web/src/shared/composables/statisticsQuery.ts b/apps/web/src/shared/composables/statisticsQuery.ts deleted file mode 100644 index 436c264..0000000 --- a/apps/web/src/shared/composables/statisticsQuery.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { reactive } from "vue" -import { PANEL_DURATION_OPTIONS } from "utils/constants" -import storage from "utils/storage" - -/** - * 统计面板的查询条件(班级/用户、题号、时间段),**记一小时**:一小时内再打开, - * 框里是上次查的那一组。 - * - * 老师一节课里会反复开关这个弹窗(看一眼、关掉、走下去辅导、回来再开),每次都从空的 - * 选班级、敲题号很烦。但也不能一直记着 —— 曾经把上次的班级永久写进 localStorage, - * 下课换了班、或者换个人坐这台机器,上一个人查的班悄悄留在框里,老师没注意就按了 - * 「统计」,看到的整个是别人的班。一小时是折中:一节课够用,隔一节课就自己清掉。 - * 注意**连堂**时下一节仍在一小时内,框里会是上一个班,这一点是接受了的。 - * - * 一小时从「最后一次查询」算起(调用方在每次查完后调 `save`,自动刷新也算), - * 开着面板盯一整节课不会中途过期。 - * - * 调用方传进来的初值优先:题目页固定是这道题、提交列表带着自己的筛选条件, - * 那是比缓存更明确的上下文;为空的那几项才用缓存补上。 - * 登录框那份 LOGIN_CLASS 是另一回事:那记的是这台机器的身份,不是一次临时查询。 - * - * @param storageKey localStorage 的键。**两个面板各用各的** —— 流程图题和代码题的 - * 题号不是一批,互相带过去只会查出一片空。 - */ -export function useStatisticsQuery( - storageKey: string, - initial: { username: string; problem: string }, -) { - const TTL = 60 * 60 * 1000 - - type Cached = { username: string; problem: string; duration: string; savedAt: number } - - function load(): Partial { - try { - const cached: Cached | null = storage.get(storageKey) - if (!cached || Date.now() - cached.savedAt > TTL) return {} - return cached - } catch { - return {} - } - } - - const cached = load() - - const query = reactive({ - username: initial.username || cached.username || "", - problem: initial.problem || cached.problem || "", - // 选项表改过之后旧缓存里的值可能已经不在里面了,认不出来就回到第一档 - duration: PANEL_DURATION_OPTIONS.some((option) => option.value === cached.duration) - ? cached.duration! - : PANEL_DURATION_OPTIONS[0].value, - }) - - function save() { - storage.set(storageKey, { - // 选择框点了清除是 null,不是空串 - username: query.username ?? "", - problem: query.problem ?? "", - duration: query.duration, - savedAt: Date.now(), - } satisfies Cached) - } - - return { query, save } -} diff --git a/packages/contract/src/flowchart.ts b/packages/contract/src/flowchart.ts index 8ab0163..5b2dd46 100644 --- a/packages/contract/src/flowchart.ts +++ b/packages/contract/src/flowchart.ts @@ -106,6 +106,19 @@ export const flowchartStatisticsSchema = z.object({ wordFrequencies: z.array(z.object({ word: z.string(), count: z.number().int() })), // 与提交统计共用「未完成学生」的形状,见 submission.ts 的 unacceptedStudentSchema dataUnaccepted: z.array(z.object({ username: z.string(), realName: z.string() })), + /** + * 每人**最好的一次**(分数最高的那张),统计页的「每人最好的一次」表用。 + * 只含普通学生;按最好成绩从低到高排,没拿到 A 的排前面 —— 老师要先看的是他们 + */ + people: z.array( + z.object({ + username: z.string(), + realName: z.string(), + bestScore: z.number().nullable(), + bestGrade: z.string().nullable(), + count: z.number().int(), + }), + ), }) export const flowchartUpdateSchema = z.object({ diff --git a/packages/contract/src/submission.ts b/packages/contract/src/submission.ts index c7e1805..ef5bb30 100644 --- a/packages/contract/src/submission.ts +++ b/packages/contract/src/submission.ts @@ -386,6 +386,38 @@ export const submissionStatisticsItemsSchema = z.object({ truncated: z.boolean(), }) +/** + * 统计页的方块串(GET /submissions/statistics/grid):范围内每个学生的每一次提交, + * 前端按「人 × 题」排成方块串,并据此算每道题做完几人。计数口径(正确率、做完、没交) + * 仍以 submissionStatisticsSchema 为准,这里只给「每一次交了什么结果」。 + * + * 只含普通学生(老师试题留下的提交不混进来),最多 5000 条最近的,超了 `truncated`。 + * `classSizes` 只在没传用户名(从题目页进来、按班级汇总)时给:各班花名册人数。 + */ +export const submissionStatisticsGridSchema = z.object({ + /** 传了题号就按传的顺序;没传就是范围内出现过的题,按第一次有人交的时间排 */ + problems: z.array(z.object({ problemDisplayId: z.string(), title: z.string() })), + rows: z.array( + z.object({ + username: z.string(), + /** 剥掉 `ks<班级号>` 前缀后的那段,同 unacceptedStudentSchema.realName */ + realName: z.string(), + className: z.string().nullable(), + /** 从早到晚 */ + submissions: z.array( + z.object({ + id: z.string(), + problemDisplayId: z.string(), + result: judgeStatusSchema, + createTime: z.string(), + }), + ), + }), + ), + classSizes: z.record(z.string(), z.number().int()), + truncated: z.boolean(), +}) + export const submissionStatisticsSchema = z.object({ submissionCount: z.number().int(), acceptedCount: z.number().int(), @@ -528,6 +560,7 @@ export type SubmissionStatistics = z.infer export type TodaySubmissionStatistics = z.infer export type SubmissionStatisticsUser = z.infer export type SubmissionStatisticsItems = z.infer +export type SubmissionStatisticsGrid = z.infer export type UnacceptedStudent = z.infer export type AttemptedStudent = z.infer