feat(统计): 统计能按「一节课」列了——哪天哪个班几点到几点、来了几人、错得最多的题,选什么时间段都有

原来这些只有「今日统计」里有、而且只看今天;老师那一版并进统计之后,今日统计接口只留学生版。
课按「隔 30 分钟切开、一段里有一道题 5 人以上做过」切,26计算机0班 9 月 29 日是 14:04–15:38,
不再被课后两次零星提交拖成 14:04–18:42。统计接口多给一份结果分布,数字行画结果条用。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-30 09:23:53 +08:00
1 parent e98ee610a1
commit 921990285c
2 files changed
+386 -155

No files matched your search

+309 -102
View File
@@ -8,6 +8,7 @@
import {
type ProblemLanguage,
type SubmissionLessons,
type SubmissionStatistics,
type SubmissionStatisticsGrid,
type TodaySubmissionStatistics,
@@ -21,15 +22,8 @@ import { failure, success } from "../http"
import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/status"
import { type ContestEnv } from "../services/contest"
import { getBooleanOption } from "../services/options"
import { dayStart } from "../time"
import {
isAdminRole,
isTeacherOrAbove,
parseDisplayIds,
rounded,
scopedUsers,
stripClassPrefix,
} from "./helpers"
import { dayStart, localTime } from "../time"
import { isAdminRole, parseDisplayIds, rounded, scopedUsers, stripClassPrefix } from "./helpers"
export const submissionStatisticsRoutes = new Hono<ContestEnv>()
@@ -40,27 +34,19 @@ function judgedRate(accepted: number, judged: number) {
return judged > 0 ? rounded((accepted / judged) * 100) : 0
}
/** 同班这么多人做了同一道题,才算这个班今天上了课(和首页「班里在做」同一个门槛) */
const TODAY_LESSON_MIN_USERS = 5
/** 最后一条提交在这么多分钟内,算「正在上课」 */
const TODAY_LIVE_MINUTES = 15
type TodayRow = {
class_name: string | null
problem_id: number
display_id: string
title: string
visible: boolean
problemset_id: number | null
user_id: number
result: JudgeStatusValue
language: ProblemLanguage
create_time: string
}
/**
* 「今日统计」(提交列表顶上那颗标签点开的)。一般是老师看:今天哪几个班上了课、几点到几点、
* 错得最多的是哪几道;学生版从简。口径和那颗标签一致:东八区今天 + 非比赛提交。
* 「今日统计」(提交列表顶上那颗标签点开的),只剩学生版 —— 老师那一版并进了统计的
* 「一行一节课」(下面的 /submissions/statistics/lessons)。口径和那颗标签一致:东八区今天 + 非比赛提交。
*
* 今天的提交一天也就几百到一千来条,整批拉回来在进程里分组,比写五六条各自 group by 的
* SQL 好读,也只扫一遍 `create_time` 索引。
@@ -74,15 +60,13 @@ submissionStatisticsRoutes.get("/submissions/today-statistics", optionalAuth, as
*/
const showProblems =
(await getBooleanOption("submission_list_show_all", true)) || isAdminRole(user)
const teacher = isTeacherOrAbove(user)
const since = dayStart()
const [rows, [flow]] = await Promise.all([
db.execute<TodayRow>(sql`
select u.class_name, s.problem_id, p._id as display_id, p.title, p.visible,
s.problemset_id, s.user_id, s.result, s.language, s.create_time
select s.problem_id, p._id as display_id, p.title, p.visible,
s.user_id, s.result, s.language
from ${schema.submission} s
join ${schema.problem} p on p.id = s.problem_id
left join ${schema.user} u on u.id = s.user_id
where s.contest_id is null and s.create_time >= ${since}
`),
db
@@ -114,7 +98,7 @@ submissionStatisticsRoutes.get("/submissions/today-statistics", optionalAuth, as
const byProblem = groupBy(rows, (row) => row.problem_id)
const mineRows = user ? rows.filter((row) => row.user_id === user.id) : []
// ---------- 学生:大家都在做 ----------
// 大家都在做
const problems = showProblems
? [...byProblem.values()]
.filter((list) => list[0]!.visible)
@@ -138,80 +122,6 @@ submissionStatisticsRoutes.get("/submissions/today-statistics", optionalAuth, as
.slice(0, 8)
: []
// ---------- 老师:哪几个班上了课、错得最多的题 ----------
let classes: TodaySubmissionStatistics["classes"] = null
let scattered: TodaySubmissionStatistics["scattered"] = null
let hardProblems: TodaySubmissionStatistics["hardProblems"] = null
if (teacher) {
const byClass = groupBy(rows, (row) => row.class_name)
const lessonClasses: { className: string; list: TodayRow[]; problemCount: number }[] = []
scattered = []
for (const [className, list] of byClass) {
const problemCount = className
? [...groupBy(list, (row) => row.problem_id).values()].filter(
(group) => distinctUsers(group) >= TODAY_LESSON_MIN_USERS,
).length
: 0
if (className && problemCount) lessonClasses.push({ className, list, problemCount })
else scattered.push({ className, userCount: distinctUsers(list), total: list.length })
}
scattered.sort((a, b) =>
a.className === null ? 1 : b.className === null ? -1 : b.total - a.total,
)
const sizes = lessonClasses.length
? await db
.select({ className: schema.user.className, value: count() })
.from(schema.user)
.where(
and(
inArray(
schema.user.className,
lessonClasses.map((item) => item.className),
),
eq(schema.user.adminType, "Regular User"),
eq(schema.user.isDisabled, false),
),
)
.groupBy(schema.user.className)
: []
const liveSince = new Date(Date.now() - TODAY_LIVE_MINUTES * 60_000).toISOString()
classes = lessonClasses
.map(({ className, list, problemCount }) => {
const times = list.map((row) => row.create_time).sort()
return {
className,
start: times[0]!,
end: times.at(-1)!,
userCount: distinctUsers(list),
classSize: sizes.find((row) => row.className === className)?.value ?? 0,
problemCount,
fromProblemSet: list.filter((row) => row.problemset_id !== null).length * 2 > list.length,
total: list.length,
correctRate: rate(list),
live: times.at(-1)! >= liveSince,
}
})
.sort((a, b) => a.start.localeCompare(b.start))
const failedOf = (list: TodayRow[]) => list.filter((row) => !isAccepted(row) && !isJudging(row))
hardProblems = [...byProblem.values()]
.filter((list) => failedOf(list).length > 0)
.sort((a, b) => failedOf(b).length - failedOf(a).length)
.slice(0, 6)
.map((list) => ({
problemDisplayId: list[0]!.display_id,
problemTitle: list[0]!.title,
className: countBy(list, (row) => row.class_name)[0]?.value ?? null,
total: list.length,
accepted: list.filter(isAccepted).length,
failures: countBy(failedOf(list), (row) => row.result).map(({ value, count }) => ({
result: value,
count,
})),
userCount: distinctUsers(list),
acceptedUsers: acceptedUsers(list),
}))
}
return success(c, {
asOf: new Date().toISOString(),
total: rows.length,
@@ -235,9 +145,6 @@ submissionStatisticsRoutes.get("/submissions/today-statistics", optionalAuth, as
}
: null,
problems,
classes,
scattered,
hardProblems,
} satisfies TodaySubmissionStatistics)
})
@@ -486,7 +393,7 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async
*/
const solvedFilter = sql`count(distinct ${schema.submission.problemId}) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
const [[totals], perUser] = await Promise.all([
const [[totals], perUser, results] = await Promise.all([
db
.select({
total: count(),
@@ -529,6 +436,13 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async
schema.user.adminType,
)
.orderBy(desc(count())),
// 数字行里那根结果条(原来今日统计底部那根,并进来之后哪个时间段都有)
db
.select({ result: schema.submission.result, count: count() })
.from(schema.submission)
.where(where)
.groupBy(schema.submission.result)
.orderBy(desc(count())),
])
const submissionCount = totals?.total ?? 0
@@ -624,6 +538,7 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async
data,
dataUnaccepted,
dataAttempted,
results,
} satisfies SubmissionStatistics)
})
@@ -758,3 +673,295 @@ submissionStatisticsRoutes.get("/submissions/statistics/grid", requireTeacher, a
truncated,
} satisfies SubmissionStatisticsGrid)
})
/** 相邻两条提交隔这么久,就当下课了、后面的是另一段 */
const LESSON_GAP_MINUTES = 30
/** 一段里有一道题这么多人做过,才算一节课(和原来今日统计、首页「班里在做」同一个门槛) */
const LESSON_MIN_USERS = 5
/** 最后一条在这么多分钟内,算「还在上课」 */
const LESSON_LIVE_MINUTES = 15
/** 一次列多少节课。「一节课」「今天」一般一两行,「这学期」「全部」有几百节,先给最近的这些 */
const LESSONS_DEFAULT = 50
const LESSONS_MAX = 500
type LessonAggRow = {
level: number
class_name: string | null
day: string | null
sid: number | null
problem_id: number | null
result: JudgeStatusValue | null
lesson: boolean | null
users: number
total: number
accepted: number
judging: number
from_ps: number
first: string
last: string
accepted_users: number
}
/**
* 统计「一行一节课」的总览(口径见契约 submissionLessonsSchema)。
*
* 切课、零散提交、错得最多的题全在一条 SQL 里:先按「班 + 东八区哪一天」隔 30 分钟切段,
* 再标出哪几段算课,最后用 grouping sets 一次聚合出下面五种粒度 —— 不走方块串那个明细
* 接口在前端切,是因为那边最多给 5000 条,「这学期」就不止这个数,截断之后课会少算。
*/
submissionStatisticsRoutes.get("/submissions/statistics/lessons", requireTeacher, async (c) => {
const range = statisticsRange(c)
if (!range) return failure(c, 400, "invalid-request", "end is required")
const limit = Math.min(
LESSONS_MAX,
Math.max(1, Number.parseInt(c.req.query("limit") ?? "", 10) || LESSONS_DEFAULT),
)
const accepted = sql.join(
ACCEPTED_RESULTS.map((value) => sql`${value}`),
sql`, `,
)
const judging = sql.join(
UNJUDGED_RESULTS.map((value) => sql`${value}`),
sql`, `,
)
// grouping() 的位:class_name 32、day 16、sid 8、problem_id 4、result 2、lesson 1。
// 没参与分组的那几列记 1,所以每种粒度对应一个固定的 level
const rows = await db.execute<LessonAggRow>(sql`
with s as (
select u.class_name, s.user_id, s.problem_id, s.result, s.problemset_id, s.create_time,
to_char(${localTime(sql`s.create_time`)}, 'YYYY-MM-DD') as day
from ${schema.submission} s
join ${schema.user} u on u.id = s.user_id
where s.contest_id is null
and s.create_time <= ${range.end}
${range.start ? sql`and s.create_time >= ${range.start}` : sql``}
-- 只算普通学生:老师试题的提交不该把一段拼成「一节课」
and u.admin_type = 'Regular User' and u.is_disabled = false
),
g as (
select *,
case when create_time - lag(create_time) over w > ${`${LESSON_GAP_MINUTES} minutes`}::interval
then 1 else 0 end as brk
from s
window w as (partition by class_name, day order by create_time)
),
seg as (
select *,
sum(brk) over (partition by class_name, day order by create_time rows unbounded preceding) as sid
from g
),
lesson_seg as (
select class_name, day, sid
from (
select class_name, day, sid, problem_id, count(distinct user_id) as n
from seg where class_name is not null
group by class_name, day, sid, problem_id
) p
group by class_name, day, sid
having max(n) >= ${LESSON_MIN_USERS}
),
marked as (
select seg.*, (l.sid is not null) as lesson
from seg
left join lesson_seg l
on l.class_name = seg.class_name and l.day = seg.day and l.sid = seg.sid
)
select grouping(class_name, day, sid, problem_id, result, lesson) as level,
class_name, day, sid::int as sid, problem_id, result, lesson,
count(distinct user_id)::int as users,
count(*)::int as total,
(count(*) filter (where result in (${accepted})))::int as accepted,
(count(*) filter (where result in (${judging})))::int as judging,
(count(*) filter (where problemset_id is not null))::int as from_ps,
min(create_time) as first,
max(create_time) as last,
(count(distinct user_id) filter (where result in (${accepted})))::int as accepted_users
from marked
group by grouping sets (
(class_name, day, sid, problem_id),
(class_name, day, sid, lesson),
(problem_id, class_name),
(problem_id, result),
(lesson, class_name, day)
)
`)
const LEVEL = { segProblem: 3, segment: 6, problemClass: 27, problemResult: 57, dayClass: 14 }
const segKey = (row: LessonAggRow) => `${row.class_name}|${row.day}|${row.sid}`
const failedOf = (row: { total: number; accepted: number; judging: number }) =>
row.total - row.accepted - row.judging
const problemsOfSeg = new Map<string, LessonAggRow[]>()
for (const row of rows) {
if (row.level !== LEVEL.segProblem) continue
const key = segKey(row)
problemsOfSeg.set(key, [...(problemsOfSeg.get(key) ?? []), row])
}
const allLessons = rows
.filter((row) => row.level === LEVEL.segment && row.lesson)
.sort((a, b) => b.first.localeCompare(a.first))
const picked = allLessons.slice(0, limit)
const hasMore = allLessons.length > limit
// 题号、标题一次查齐:课的题、每节课错得最多的那道、整段时间错得最多的几道
const hardCandidates = new Map<number, { rows: LessonAggRow[]; failures: LessonAggRow[] }>()
for (const row of rows) {
if (row.problem_id === null) continue
if (row.level === LEVEL.problemClass || row.level === LEVEL.problemResult) {
const entry = hardCandidates.get(row.problem_id) ?? { rows: [], failures: [] }
if (row.level === LEVEL.problemClass) entry.rows.push(row)
else entry.failures.push(row)
hardCandidates.set(row.problem_id, entry)
}
}
const hardRanked = [...hardCandidates.entries()]
.map(([problemId, entry]) => {
const sum = (key: "total" | "accepted" | "judging" | "users" | "accepted_users") =>
entry.rows.reduce((acc, row) => acc + row[key], 0)
return {
problemId,
className: [...entry.rows].sort((a, b) => b.total - a.total)[0]?.class_name ?? null,
total: sum("total"),
accepted: sum("accepted"),
judging: sum("judging"),
userCount: sum("users"),
acceptedUsers: sum("accepted_users"),
failures: entry.failures
.filter(
(row) =>
row.result !== null &&
!ACCEPTED_RESULTS.includes(row.result) &&
!UNJUDGED_RESULTS.includes(row.result),
)
.sort((a, b) => b.total - a.total)
.map((row) => ({ result: row.result!, count: row.total })),
}
})
.filter((row) => failedOf(row) > 0)
.sort((a, b) => failedOf(b) - failedOf(a))
.slice(0, 6)
const lessonProblemRows = picked.map((lesson) =>
(problemsOfSeg.get(segKey(lesson)) ?? [])
.filter((row) => row.users >= LESSON_MIN_USERS)
.sort((a, b) => b.users - a.users),
)
const problemIds = [
...new Set([
...lessonProblemRows.flat().map((row) => row.problem_id!),
...hardRanked.map((row) => row.problemId),
]),
]
const problems = problemIds.length
? await db
.select({
id: schema.problem.id,
displayId: schema.problem.displayId,
title: schema.problem.title,
visible: schema.problem.visible,
})
.from(schema.problem)
.where(inArray(schema.problem.id, problemIds))
: []
const problemById = new Map(problems.map((row) => [row.id, row]))
const lessonClasses = [...new Set(picked.map((row) => row.class_name!))]
const sizes = lessonClasses.length
? await db
.select({ className: schema.user.className, value: count() })
.from(schema.user)
.where(
and(
inArray(schema.user.className, lessonClasses),
eq(schema.user.adminType, "Regular User"),
eq(schema.user.isDisabled, false),
),
)
.groupBy(schema.user.className)
: []
const sum = (list: LessonAggRow[], key: "total" | "accepted" | "judging") =>
list.reduce((acc, row) => acc + row[key], 0)
const liveSince = new Date(Date.now() - LESSON_LIVE_MINUTES * 60_000).toISOString()
const lessons = picked.map((lesson, index) => {
const own = lessonProblemRows[index]!
const hardest = [...own].sort((a, b) => failedOf(b) - failedOf(a))[0]
const hardestProblem = hardest ? problemById.get(hardest.problem_id!) : undefined
return {
className: lesson.class_name!,
day: lesson.day!,
start: lesson.first,
end: lesson.last,
userCount: lesson.users,
classSize: sizes.find((row) => row.className === lesson.class_name)?.value ?? 0,
// 点进去就拿这几道去筛:统计接口只认公开题,一次最多 20 道
problems: own
.map((row) => problemById.get(row.problem_id!))
.filter((row) => row?.visible)
.map((row) => row!.displayId)
.slice(0, STATISTICS_MAX_PROBLEMS),
fromProblemSet: lesson.from_ps * 2 > lesson.total,
// 次数和正确率只算这节课的题:点进去筛的就是这几道,两边的数才对得上
// (一段里零星一两个人顺手交的别的题不算)
total: sum(own, "total"),
correctRate: judgedRate(sum(own, "accepted"), sum(own, "total") - sum(own, "judging")),
live: lesson.last >= liveSince,
hardest:
hardest && hardestProblem && failedOf(hardest) > 0
? {
problemDisplayId: hardestProblem.displayId,
problemTitle: hardestProblem.title,
failed: failedOf(hardest),
}
: null,
}
})
// 零散提交只给列出来的这些课覆盖到的日子(「这学期」只列了最近 50 节时,更早的零散不给)
const earliestDay = hasMore ? (picked.at(-1)?.day ?? null) : null
const scattered = rows
.filter(
(row) =>
row.level === LEVEL.dayClass &&
row.lesson === false &&
(earliestDay === null || row.day! >= earliestDay),
)
.sort((a, b) =>
a.day !== b.day
? b.day!.localeCompare(a.day!)
: a.class_name === null
? 1
: b.class_name === null
? -1
: b.total - a.total,
)
.map((row) => ({
day: row.day!,
className: row.class_name,
userCount: row.users,
total: row.total,
}))
return success(c, {
lessons,
scattered,
hardProblems: hardRanked.flatMap((row) => {
const problem = problemById.get(row.problemId)
if (!problem) return []
return [
{
problemDisplayId: problem.displayId,
problemTitle: problem.title,
className: row.className,
total: row.total,
accepted: row.accepted,
failures: row.failures,
userCount: row.userCount,
acceptedUsers: row.acceptedUsers,
},
]
}),
hasMore,
} satisfies SubmissionLessons)
})
+77 -53
View File
@@ -401,6 +401,79 @@ export const submissionStatisticsSchema = z.object({
* 就像统计只认成功的提交。dataUnaccepted 没有花名册就真的算不出来,仍然为空。
*/
dataAttempted: z.array(attemptedStudentSchema),
/** 按判题结果,条数倒序 —— 数字行里那根结果条(含还在判的) */
results: z.array(z.object({ result: judgeStatusSchema, count: z.number().int() })),
})
/**
* 统计「一行一节课」的总览(GET /submissions/statistics/lessons,设计稿「统计合二为一」B)。
* 没选班、没填学生、没填题号时用它;原来「今日统计」老师那一版并进来了,换成任意时间段。
*
* **一节课怎么切**:只算普通学生(和方块串同一个口径),按「班 + 东八区哪一天」分组,
* 相邻两条提交隔 30 分钟以上就切开;切出来的一段里有一道题 5 人以上做过,才算一节课。
* 其余的是零散提交(补做、课后自己练)。这样 26计算机0班 9 月 29 日是 14:04–15:38,
* 不会被 16:24、18:42 那两条课后提交拖成「14:04–18:42」。
*/
const lessonFailureSchema = z.object({ result: judgeStatusSchema, count: z.number().int() })
export const submissionLessonsSchema = z.object({
/** 最近的 N 节课,按开始时间倒序(界面上按天分组,这节课在最上面) */
lessons: z.array(
z.object({
className: z.string(),
/** 东八区的日子,`2026-09-29` */
day: z.string(),
/** 这一段第一条、最后一条提交 */
start: z.string(),
end: z.string(),
userCount: z.number().int(),
/** 班里的学生数(未禁用的普通用户) */
classSize: z.number().int(),
/** 这节课做的题(这一段里 5 人以上做过的),做的人多的在前,最多 20 道 —— 点进去就筛这几道 */
problems: z.array(z.string()),
/** 多半是从题单里交的 —— 「题单 7 道」 */
fromProblemSet: z.boolean(),
/** 这节课的题(上面 problems)交了几次 —— 和点进去看到的对得上 */
total: z.number().int(),
/** 同上,判完的里面对了多少,百分数 */
correctRate: z.number(),
/** 最后一条在 15 分钟内:还在上课,界面上引去课堂看板 */
live: z.boolean(),
/** 这节课的题里没过次数最多的那道;一次都没错过为 null */
hardest: z
.object({
problemDisplayId: z.string(),
problemTitle: z.string(),
failed: z.number().int(),
})
.nullable(),
}),
),
/** 零散提交,按天、按班(null = 没填班级的号)。只给列出来的这些课覆盖到的日子 */
scattered: z.array(
z.object({
day: z.string(),
className: z.string().nullable(),
userCount: z.number().int(),
total: z.number().int(),
}),
),
/** 整个时间段里错得最多的题,按没过的次数倒序,最多 6 道 */
hardProblems: z.array(
z.object({
problemDisplayId: z.string(),
problemTitle: z.string(),
/** 这道题交得最多的那个班 */
className: z.string().nullable(),
total: z.number().int(),
accepted: z.number().int(),
failures: z.array(lessonFailureSchema),
userCount: z.number().int(),
acceptedUsers: z.number().int(),
}),
),
/** 更早还有课没列出来(「这学期」「全部」那种),界面上给「再往前看」 */
hasMore: z.boolean(),
})
/**
@@ -416,9 +489,9 @@ export const submissionStatisticsSchema = z.object({
const todayFailureSchema = z.object({ result: judgeStatusSchema, count: z.number().int() })
/**
* 「今日统计」(提交列表顶上那颗标签点开的)。一般是老师在看(设计稿「今日统计重设计」老师 B):
* 今天哪几个班上了课、各在几点、错得最多的是哪几道。学生版从简:自己今天怎样、大家在做哪几道。
* 口径:东八区今天 + 非比赛提交。
* 「今日统计」(提交列表顶上那颗标签点开的),**只剩学生版**:自己今天怎样、大家在做哪几道。
* 老师那一版(今天哪几个班上了课、错得最多的题)2026-09 并进了统计(submissionLessonsSchema),
* 老师的提交列表上不再有这颗标签。口径:东八区今天 + 非比赛提交。
*/
export const todaySubmissionStatisticsSchema = z.object({
/** 这份数据是几点算的 —— 标题上的「截至 19:40」 */
@@ -456,56 +529,6 @@ export const todaySubmissionStatisticsSchema = z.object({
mine: z.enum(["accepted", "tried", "none"]).nullable(),
}),
),
/**
* 老师:今天上了课的班(同班 5 人以上做了同一道题),按开始时间排。学生为 null。
* 时段是这个班今天第一条到最后一条提交,`live` = 最后一条在 15 分钟内(正在上课,
* 界面上引去课堂看板 —— 盯人是看板的活)
*/
classes: z
.array(
z.object({
className: z.string(),
start: z.string(),
end: z.string(),
userCount: z.number().int(),
/** 班里的学生数 */
classSize: z.number().int(),
/** 这节课做的题数(班里 5 人以上做过的) */
problemCount: z.number().int(),
/** 多半是从题单里交的 —— 「题单 9 道」 */
fromProblemSet: z.boolean(),
total: z.number().int(),
correctRate: z.number(),
live: z.boolean(),
}),
)
.nullable(),
/** 老师:没上课的零星提交,按班(null = 没填班级的号) */
scattered: z
.array(
z.object({
className: z.string().nullable(),
userCount: z.number().int(),
total: z.number().int(),
}),
)
.nullable(),
/** 老师:错得最多的题,按没过的次数倒序,最多 6 道 */
hardProblems: z
.array(
z.object({
problemDisplayId: z.string(),
problemTitle: z.string(),
/** 这道题今天交得最多的那个班 */
className: z.string().nullable(),
total: z.number().int(),
accepted: z.number().int(),
failures: z.array(todayFailureSchema),
userCount: z.number().int(),
acceptedUsers: z.number().int(),
}),
)
.nullable(),
})
export const formatCodeRequestSchema = z.object({
@@ -579,6 +602,7 @@ export type SubmissionDetail = z.infer<typeof submissionDetailSchema>
export type SubmissionUpdate = z.infer<typeof submissionUpdateSchema>
export type SubmissionStatistics = z.infer<typeof submissionStatisticsSchema>
export type TodaySubmissionStatistics = z.infer<typeof todaySubmissionStatisticsSchema>
export type SubmissionLessons = z.infer<typeof submissionLessonsSchema>
export type SubmissionStatisticsUser = z.infer<typeof submissionStatisticsUserSchema>
export type SubmissionStatisticsGrid = z.infer<typeof submissionStatisticsGridSchema>
export type UnacceptedStudent = z.infer<typeof unacceptedStudentSchema>