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 人后来做对了,排在前面的只是做的人多。
|
||||
@@ -20,8 +20,7 @@ import {
|
||||
STUDENT_ROLES,
|
||||
type StuckProblem,
|
||||
type StuckProblemDetail,
|
||||
type YearlyOverview,
|
||||
type YearlySummary,
|
||||
type GradeTrendProblem,
|
||||
} from "@oj2/contract"
|
||||
import { and, eq, inArray, sql, type SQL } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
@@ -30,7 +29,7 @@ import { requireTeacher, type AppEnv } from "../../auth/middleware"
|
||||
import { db, schema } from "../../db"
|
||||
import { failure, success } from "../../http"
|
||||
import { JudgeStatus } from "../../judge/status"
|
||||
import { schoolYearOf, schoolYearStart, schoolYearStartOf, shiftMonthsByCalendar } from "../../time"
|
||||
import { schoolYearOf } from "../../time"
|
||||
import { queryInteger } from "../helpers"
|
||||
|
||||
export const adminAnalyticsRoutes = new Hono<AppEnv>()
|
||||
@@ -242,126 +241,47 @@ adminAnalyticsRoutes.get("/problem-analytics/stuck/:id", requireTeacher, async (
|
||||
} 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()
|
||||
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
|
||||
`)
|
||||
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`, `,
|
||||
)
|
||||
/**
|
||||
* 每道题每一届的「一次就对」。届 = 班号前两位(253 → 25 级),没有班级或班号不是数字的不算。
|
||||
* 「第一次交」按这个人在这道题上的全部历史算,和卡点同一个口径。比哪届、门槛多少都在前端,
|
||||
* 换选项不用再取一遍。
|
||||
*/
|
||||
adminAnalyticsRoutes.get("/problem-analytics/grades", requireTeacher, async (c) => {
|
||||
const rows = await db.execute<{
|
||||
key: number
|
||||
id: number
|
||||
display_id: string
|
||||
title: string
|
||||
grade: string
|
||||
users: number
|
||||
submissions: 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
|
||||
first_try: number
|
||||
}>(sql`
|
||||
with ranges(key, f, t) as (values ${values}),
|
||||
s as (${studentSubmissions([])}),
|
||||
with s as (${studentSubmissions([sql`u.class_name ~ '^[0-9]{3,}$'`])}),
|
||||
pairs as (
|
||||
select min(create_time) as first_at, min(ac_at) as ac_at,
|
||||
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
|
||||
select problem_id, left(min(class_name), 2) as grade,
|
||||
bool_or(rn = 1 and result in (${ACCEPTED})) as first_try
|
||||
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,
|
||||
coalesce(sub.problems, 0) as problems, coalesce(sub.c_submissions, 0) as c_submissions,
|
||||
coalesce(ps.solved_pairs, 0) as solved_pairs, coalesce(ps.started_pairs, 0) as started_pairs,
|
||||
coalesce(ps.first_try_pairs, 0) as first_try_pairs,
|
||||
coalesce(ps.solved_started, 0) as solved_started,
|
||||
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
|
||||
select p.id, p._id as display_id, p.title, pairs.grade,
|
||||
count(*)::int as users, count(*) filter (where first_try)::int as first_try
|
||||
from pairs join ${schema.problem} p on p.id = pairs.problem_id
|
||||
group by p.id, pairs.grade
|
||||
having count(*) >= ${GRADE_CELL_MIN_USERS}
|
||||
`)
|
||||
|
||||
const byKey = new Map(rows.map((row) => [row.key, row]))
|
||||
const summary = (range: (typeof ranges)[number]): YearlySummary => {
|
||||
const row = byKey.get(range.key)
|
||||
return {
|
||||
year: range.year,
|
||||
until: range.to,
|
||||
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,
|
||||
const problems = new Map<number, GradeTrendProblem>()
|
||||
for (const row of rows) {
|
||||
const problem = problems.get(row.id) ?? {
|
||||
id: row.id,
|
||||
displayId: row.display_id,
|
||||
title: row.title,
|
||||
grades: [],
|
||||
}
|
||||
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, {
|
||||
years: ranges.filter((range) => range.key !== -1).map(summary),
|
||||
lastYearToDate: lastYear ? summary(lastYear) : null,
|
||||
} satisfies YearlyOverview)
|
||||
return success(c, [...problems.values()] satisfies GradeTrendProblem[])
|
||||
})
|
||||
+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()
|
||||
}
|
||||
|
||||
/**
|
||||
* 学年从哪天零点(东八区)算起: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 {
|
||||
const wall = toWallClock(instant)
|
||||
@@ -137,7 +122,7 @@ export function localTime(column: SQLWrapper) {
|
||||
return sql`(${column} at time zone ${TIME_ZONE_SQL})`
|
||||
}
|
||||
|
||||
/** 时刻所在学年的起始年(9 月起算),和 `schoolYearStart()` 同一个口径 */
|
||||
/** 时刻所在学年的起始年:9 月 1 日起算,2025 = 25–26 学年 */
|
||||
export function schoolYearOf(column: SQLWrapper) {
|
||||
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({
|
||||
/** 学年起始年,2025 = 25–26 学年 */
|
||||
year: z.number().int(),
|
||||
/** 统计到哪天为止(不含),进行中的学年和去年同期才有意义 */
|
||||
until: z.string(),
|
||||
grades: z.array(z.string()),
|
||||
users: 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(),
|
||||
/** 年度趋势:一道题在各届(班号前两位)的「一次就对」,只给至少 5 人做过的格子 */
|
||||
export const gradeTrendProblemSchema = z.object({
|
||||
id: z.number().int(),
|
||||
displayId: z.string(),
|
||||
title: z.string(),
|
||||
grades: z.array(
|
||||
z.object({ grade: z.string(), users: z.number().int(), firstTry: z.number().int() }),
|
||||
),
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 自学情况
|
||||
@@ -1000,8 +983,7 @@ export type AdminAiReportList = z.infer<typeof adminAiReportListSchema>
|
||||
export type StuckProblem = z.infer<typeof stuckProblemSchema>
|
||||
export type StuckStudent = z.infer<typeof stuckStudentSchema>
|
||||
export type StuckProblemDetail = z.infer<typeof stuckProblemDetailSchema>
|
||||
export type YearlySummary = z.infer<typeof yearlySummarySchema>
|
||||
export type YearlyOverview = z.infer<typeof yearlyOverviewSchema>
|
||||
export type GradeTrendProblem = z.infer<typeof gradeTrendProblemSchema>
|
||||
export type LearnBoard = z.infer<typeof learnBoardSchema>
|
||||
export type LearnBoardStudent = z.infer<typeof learnBoardStudentSchema>
|
||||
export type LearnExerciseProgress = z.infer<typeof learnExerciseProgressSchema>
|
||||
|
||||
Reference in new issue
Block a user