feat(年度趋势): 历年概况那张表只有一堆数、看不出趋势;改成按届比,这届和往届在同一批题上逐题比一次就对
- 新接口 /problem-analytics/grades:每道题在各届(班号前两位)的人数和一次就对人数 - 删掉 /problem-analytics/yearly 和只给它用的学年时间函数 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0980ea858a
commit
318ffac90f
4 files changed
+45
-158
No files matched your search
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* 学情分析里的「卡点分析」和「历年概况」。
|
* 学情分析里的「卡点分析」和「年度趋势」(这届和往届比)。
|
||||||
*
|
*
|
||||||
* 两边共用一个口径:**人 × 题**。原来的卡点数的是「错过一次的人」,全部时间、不分班 ——
|
* 两边共用一个口径:**人 × 题**。原来的卡点数的是「错过一次的人」,全部时间、不分班 ——
|
||||||
* 阿福讲白搭显示 325 人卡住,交过的 782 人里其实 753 人后来做对了,排在前面的只是做的人多。
|
* 阿福讲白搭显示 325 人卡住,交过的 782 人里其实 753 人后来做对了,排在前面的只是做的人多。
|
||||||
@@ -20,8 +20,7 @@ import {
|
|||||||
STUDENT_ROLES,
|
STUDENT_ROLES,
|
||||||
type StuckProblem,
|
type StuckProblem,
|
||||||
type StuckProblemDetail,
|
type StuckProblemDetail,
|
||||||
type YearlyOverview,
|
type GradeTrendProblem,
|
||||||
type YearlySummary,
|
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import { and, eq, inArray, sql, type SQL } from "drizzle-orm"
|
import { and, eq, inArray, sql, type SQL } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
@@ -30,7 +29,7 @@ import { requireTeacher, type AppEnv } from "../../auth/middleware"
|
|||||||
import { db, schema } from "../../db"
|
import { db, schema } from "../../db"
|
||||||
import { failure, success } from "../../http"
|
import { failure, success } from "../../http"
|
||||||
import { JudgeStatus } from "../../judge/status"
|
import { JudgeStatus } from "../../judge/status"
|
||||||
import { schoolYearOf, schoolYearStart, schoolYearStartOf, shiftMonthsByCalendar } from "../../time"
|
import { schoolYearOf } from "../../time"
|
||||||
import { queryInteger } from "../helpers"
|
import { queryInteger } from "../helpers"
|
||||||
|
|
||||||
export const adminAnalyticsRoutes = new Hono<AppEnv>()
|
export const adminAnalyticsRoutes = new Hono<AppEnv>()
|
||||||
@@ -242,126 +241,47 @@ adminAnalyticsRoutes.get("/problem-analytics/stuck/:id", requireTeacher, async (
|
|||||||
} satisfies StuckProblemDetail)
|
} satisfies StuckProblemDetail)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 历年概况
|
// ---------------------------------------------------------------- 年度趋势:这届和往届比
|
||||||
|
|
||||||
/** 「在用的年级」:这段时间里至少这么多人交过题的年级才列 */
|
/** 一个格子(题 × 届)至少这么多人做过才给,再少的前端也不会拿来比 */
|
||||||
const GRADE_MIN_USERS = 20
|
const GRADE_CELL_MIN_USERS = 5
|
||||||
|
|
||||||
adminAnalyticsRoutes.get("/problem-analytics/yearly", requireTeacher, async (c) => {
|
/**
|
||||||
const now = new Date()
|
* 每道题每一届的「一次就对」。届 = 班号前两位(253 → 25 级),没有班级或班号不是数字的不算。
|
||||||
const current = schoolYearStart(now)
|
* 「第一次交」按这个人在这道题上的全部历史算,和卡点同一个口径。比哪届、门槛多少都在前端,
|
||||||
const [first] = await db.execute<{ year: number | null }>(sql`
|
* 换选项不用再取一遍。
|
||||||
select ${schoolYearOf(sql`min(create_time)`)} as year
|
*/
|
||||||
from ${schema.submission} where contest_id is null
|
adminAnalyticsRoutes.get("/problem-analytics/grades", requireTeacher, async (c) => {
|
||||||
`)
|
|
||||||
const firstYear = Math.min(first?.year ?? current.year, current.year)
|
|
||||||
|
|
||||||
// 每个完整学年、进行中的学年(到现在),外加去年同期(上一学年开学到去年的今天)
|
|
||||||
const ranges: { key: number; year: number; from: string; to: string }[] = []
|
|
||||||
for (let year = firstYear; year <= current.year; year += 1) {
|
|
||||||
const to = year === current.year ? now.toISOString() : schoolYearStartOf(year + 1)
|
|
||||||
ranges.push({ key: year, year, from: schoolYearStartOf(year), to })
|
|
||||||
}
|
|
||||||
const hasLastYear = current.year - 1 >= firstYear
|
|
||||||
if (hasLastYear)
|
|
||||||
ranges.push({
|
|
||||||
key: -1,
|
|
||||||
year: current.year - 1,
|
|
||||||
from: schoolYearStartOf(current.year - 1),
|
|
||||||
to: shiftMonthsByCalendar(now, -12).toISOString(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const values = sql.join(
|
|
||||||
ranges.map(
|
|
||||||
(range) => sql`(${range.key}::int, ${range.from}::timestamptz, ${range.to}::timestamptz)`,
|
|
||||||
),
|
|
||||||
sql`, `,
|
|
||||||
)
|
|
||||||
const rows = await db.execute<{
|
const rows = await db.execute<{
|
||||||
key: number
|
id: number
|
||||||
|
display_id: string
|
||||||
|
title: string
|
||||||
|
grade: string
|
||||||
users: number
|
users: number
|
||||||
submissions: number
|
first_try: number
|
||||||
problems: number
|
|
||||||
c_submissions: number
|
|
||||||
solved_pairs: number
|
|
||||||
started_pairs: number
|
|
||||||
first_try_pairs: number
|
|
||||||
solved_started: number
|
|
||||||
solved_tries: number
|
|
||||||
grades: string[] | null
|
|
||||||
}>(sql`
|
}>(sql`
|
||||||
with ranges(key, f, t) as (values ${values}),
|
with s as (${studentSubmissions([sql`u.class_name ~ '^[0-9]{3,}$'`])}),
|
||||||
s as (${studentSubmissions([])}),
|
|
||||||
pairs as (
|
pairs as (
|
||||||
select min(create_time) as first_at, min(ac_at) as ac_at,
|
select problem_id, left(min(class_name), 2) as grade,
|
||||||
bool_or(rn = 1 and result in (${ACCEPTED})) as first_try,
|
bool_or(rn = 1 and result in (${ACCEPTED})) as first_try
|
||||||
count(*) filter (where ac_at is null or create_time <= ac_at) as tries
|
|
||||||
from s group by user_id, problem_id
|
from s group by user_id, problem_id
|
||||||
),
|
|
||||||
sub_stats as (
|
|
||||||
select r.key, count(distinct s.user_id)::int as users, count(*)::int as submissions,
|
|
||||||
count(distinct s.problem_id)::int as problems,
|
|
||||||
count(*) filter (where s.language in ('C', 'C++'))::int as c_submissions
|
|
||||||
from s join ranges r on s.create_time >= r.f and s.create_time < r.t
|
|
||||||
group by r.key
|
|
||||||
),
|
|
||||||
pair_stats as (
|
|
||||||
select r.key,
|
|
||||||
count(*) filter (where p.ac_at >= r.f and p.ac_at < r.t)::int as solved_pairs,
|
|
||||||
count(*) filter (where p.first_at >= r.f and p.first_at < r.t)::int as started_pairs,
|
|
||||||
count(*) filter (where p.first_at >= r.f and p.first_at < r.t and p.first_try)::int
|
|
||||||
as first_try_pairs,
|
|
||||||
count(*) filter (where p.first_at >= r.f and p.first_at < r.t and p.ac_at is not null)::int
|
|
||||||
as solved_started,
|
|
||||||
coalesce(sum(p.tries) filter (
|
|
||||||
where p.first_at >= r.f and p.first_at < r.t and p.ac_at is not null
|
|
||||||
), 0)::int as solved_tries
|
|
||||||
from pairs p join ranges r
|
|
||||||
on (p.first_at >= r.f and p.first_at < r.t) or (p.ac_at >= r.f and p.ac_at < r.t)
|
|
||||||
group by r.key
|
|
||||||
),
|
|
||||||
grade_stats as (
|
|
||||||
select key, json_agg(grade order by grade) as grades from (
|
|
||||||
select r.key, left(s.class_name, 2) as grade
|
|
||||||
from s join ranges r on s.create_time >= r.f and s.create_time < r.t
|
|
||||||
where s.class_name ~ '^[0-9]{3,}$'
|
|
||||||
group by r.key, left(s.class_name, 2)
|
|
||||||
having count(distinct s.user_id) >= ${GRADE_MIN_USERS}
|
|
||||||
) g group by key
|
|
||||||
)
|
)
|
||||||
select r.key, coalesce(sub.users, 0) as users, coalesce(sub.submissions, 0) as submissions,
|
select p.id, p._id as display_id, p.title, pairs.grade,
|
||||||
coalesce(sub.problems, 0) as problems, coalesce(sub.c_submissions, 0) as c_submissions,
|
count(*)::int as users, count(*) filter (where first_try)::int as first_try
|
||||||
coalesce(ps.solved_pairs, 0) as solved_pairs, coalesce(ps.started_pairs, 0) as started_pairs,
|
from pairs join ${schema.problem} p on p.id = pairs.problem_id
|
||||||
coalesce(ps.first_try_pairs, 0) as first_try_pairs,
|
group by p.id, pairs.grade
|
||||||
coalesce(ps.solved_started, 0) as solved_started,
|
having count(*) >= ${GRADE_CELL_MIN_USERS}
|
||||||
coalesce(ps.solved_tries, 0) as solved_tries, gs.grades
|
|
||||||
from ranges r
|
|
||||||
left join sub_stats sub on sub.key = r.key
|
|
||||||
left join pair_stats ps on ps.key = r.key
|
|
||||||
left join grade_stats gs on gs.key = r.key
|
|
||||||
`)
|
`)
|
||||||
|
const problems = new Map<number, GradeTrendProblem>()
|
||||||
const byKey = new Map(rows.map((row) => [row.key, row]))
|
for (const row of rows) {
|
||||||
const summary = (range: (typeof ranges)[number]): YearlySummary => {
|
const problem = problems.get(row.id) ?? {
|
||||||
const row = byKey.get(range.key)
|
id: row.id,
|
||||||
return {
|
displayId: row.display_id,
|
||||||
year: range.year,
|
title: row.title,
|
||||||
until: range.to,
|
grades: [],
|
||||||
grades: row?.grades ?? [],
|
|
||||||
users: row?.users ?? 0,
|
|
||||||
submissions: row?.submissions ?? 0,
|
|
||||||
problems: row?.problems ?? 0,
|
|
||||||
solvedPairs: row?.solved_pairs ?? 0,
|
|
||||||
startedPairs: row?.started_pairs ?? 0,
|
|
||||||
firstTryPairs: row?.first_try_pairs ?? 0,
|
|
||||||
solvedStarted: row?.solved_started ?? 0,
|
|
||||||
solvedTries: row?.solved_tries ?? 0,
|
|
||||||
cSubmissions: row?.c_submissions ?? 0,
|
|
||||||
}
|
}
|
||||||
|
problem.grades.push({ grade: row.grade, users: row.users, firstTry: row.first_try })
|
||||||
|
problems.set(row.id, problem)
|
||||||
}
|
}
|
||||||
const lastYear = ranges.find((range) => range.key === -1)
|
return success(c, [...problems.values()] satisfies GradeTrendProblem[])
|
||||||
return success(c, {
|
|
||||||
years: ranges.filter((range) => range.key !== -1).map(summary),
|
|
||||||
lastYearToDate: lastYear ? summary(lastYear) : null,
|
|
||||||
} satisfies YearlyOverview)
|
|
||||||
})
|
})
|
||||||
+1
-16
@@ -95,21 +95,6 @@ export function termStart(now: Date | number | string = new Date()): string {
|
|||||||
return fromWallClock(new Date(Date.UTC(termYear, termMonth - 1, 1))).toISOString()
|
return fromWallClock(new Date(Date.UTC(termYear, termMonth - 1, 1))).toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 学年从哪天零点(东八区)算起:9 月 1 日。返回 `{ year, start }`,`year` 是起始年
|
|
||||||
* (2025 = 25–26 学年),`start` 是 ISO 字符串。SQL 里按学年分组用 `schoolYearOf()`,两边同一个口径。
|
|
||||||
*/
|
|
||||||
export function schoolYearStart(now: Date | number | string = new Date()) {
|
|
||||||
const wall = toWallClock(now)
|
|
||||||
const year = wall.getUTCMonth() + 1 >= 9 ? wall.getUTCFullYear() : wall.getUTCFullYear() - 1
|
|
||||||
return { year, start: schoolYearStartOf(year) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 某个学年起始年的 9 月 1 日零点(东八区),ISO 字符串 */
|
|
||||||
export function schoolYearStartOf(year: number): string {
|
|
||||||
return fromWallClock(new Date(Date.UTC(year, 8, 1))).toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 按北京时间的日历做月份平移,日号超出目标月长度时截到月末,时分秒毫秒原样保留 */
|
/** 按北京时间的日历做月份平移,日号超出目标月长度时截到月末,时分秒毫秒原样保留 */
|
||||||
export function shiftMonthsByCalendar(instant: Date, months: number): Date {
|
export function shiftMonthsByCalendar(instant: Date, months: number): Date {
|
||||||
const wall = toWallClock(instant)
|
const wall = toWallClock(instant)
|
||||||
@@ -137,7 +122,7 @@ export function localTime(column: SQLWrapper) {
|
|||||||
return sql`(${column} at time zone ${TIME_ZONE_SQL})`
|
return sql`(${column} at time zone ${TIME_ZONE_SQL})`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 时刻所在学年的起始年(9 月起算),和 `schoolYearStart()` 同一个口径 */
|
/** 时刻所在学年的起始年:9 月 1 日起算,2025 = 25–26 学年 */
|
||||||
export function schoolYearOf(column: SQLWrapper) {
|
export function schoolYearOf(column: SQLWrapper) {
|
||||||
return sql<number>`extract(year from ${localTime(column)} - interval '8 months')::int`
|
return sql<number>`extract(year from ${localTime(column)} - interval '8 months')::int`
|
||||||
}
|
}
|
||||||
File renamed without changes.
@@ -584,31 +584,14 @@ export const stuckProblemDetailSchema = z.object({
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const yearlySummarySchema = z.object({
|
/** 年度趋势:一道题在各届(班号前两位)的「一次就对」,只给至少 5 人做过的格子 */
|
||||||
/** 学年起始年,2025 = 25–26 学年 */
|
export const gradeTrendProblemSchema = z.object({
|
||||||
year: z.number().int(),
|
id: z.number().int(),
|
||||||
/** 统计到哪天为止(不含),进行中的学年和去年同期才有意义 */
|
displayId: z.string(),
|
||||||
until: z.string(),
|
title: z.string(),
|
||||||
grades: z.array(z.string()),
|
grades: z.array(
|
||||||
users: z.number().int(),
|
z.object({ grade: z.string(), users: z.number().int(), firstTry: z.number().int() }),
|
||||||
submissions: z.number().int(),
|
),
|
||||||
problems: z.number().int(),
|
|
||||||
/** 这段时间里第一次做对的人题对 */
|
|
||||||
solvedPairs: z.number().int(),
|
|
||||||
/** 第一次交在这段时间里的人题对,和其中第一次就对的 */
|
|
||||||
startedPairs: z.number().int(),
|
|
||||||
firstTryPairs: z.number().int(),
|
|
||||||
/** 上面那批里做对了的,以及它们做对那次及之前一共交了几次 */
|
|
||||||
solvedStarted: z.number().int(),
|
|
||||||
solvedTries: z.number().int(),
|
|
||||||
cSubmissions: z.number().int(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const yearlyOverviewSchema = z.object({
|
|
||||||
/** 从最早的学年到现在这个学年(最后一个是进行中的,只算到今天) */
|
|
||||||
years: z.array(yearlySummarySchema),
|
|
||||||
/** 去年同期:上一个学年开学到「去年的今天」 */
|
|
||||||
lastYearToDate: yearlySummarySchema.nullable(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 自学情况
|
// ---------------------------------------------------------------- 自学情况
|
||||||
@@ -1000,8 +983,7 @@ export type AdminAiReportList = z.infer<typeof adminAiReportListSchema>
|
|||||||
export type StuckProblem = z.infer<typeof stuckProblemSchema>
|
export type StuckProblem = z.infer<typeof stuckProblemSchema>
|
||||||
export type StuckStudent = z.infer<typeof stuckStudentSchema>
|
export type StuckStudent = z.infer<typeof stuckStudentSchema>
|
||||||
export type StuckProblemDetail = z.infer<typeof stuckProblemDetailSchema>
|
export type StuckProblemDetail = z.infer<typeof stuckProblemDetailSchema>
|
||||||
export type YearlySummary = z.infer<typeof yearlySummarySchema>
|
export type GradeTrendProblem = z.infer<typeof gradeTrendProblemSchema>
|
||||||
export type YearlyOverview = z.infer<typeof yearlyOverviewSchema>
|
|
||||||
export type LearnBoard = z.infer<typeof learnBoardSchema>
|
export type LearnBoard = z.infer<typeof learnBoardSchema>
|
||||||
export type LearnBoardStudent = z.infer<typeof learnBoardStudentSchema>
|
export type LearnBoardStudent = z.infer<typeof learnBoardStudentSchema>
|
||||||
export type LearnExerciseProgress = z.infer<typeof learnExerciseProgressSchema>
|
export type LearnExerciseProgress = z.infer<typeof learnExerciseProgressSchema>
|
||||||
|
|||||||
Reference in new issue
Block a user