fix(自学): 少于三分钟的不算已读

原来只要打开过(viewCount > 0)就记成已读,于是「点开看一眼就退」和「认真读完」
在老师那张表上长得一模一样,「已读 17/17」并不说明他学了。加一道 3 分钟的门槛 ——
按最短的一课定的下限,读不完还翻不动的课文,扫一眼是到不了这个数的。

阈值 TUTORIAL_READ_SECONDS 放在 contract 里,前后端共用一个数:学生端目录的 ✓、
后台「按学生」的已读课数、「按课程」的读过人数,三处口径必须一致,各写各的迟早分叉。

**累计时长故意不卡**。时长记的是真实停留,不满三分钟的秒数照样算进去,于是
「已读 0 课、累计 25 分钟」成为一种能看见的状态 —— 他翻了很多课,每课都没读满。
这正是这道门槛想让老师看见的东西,把时长一起滤掉反而把信息删了。

「按课程」的人均时长跟着改了分子:分母是读满三分钟的人,分子就得是同一批人的时长,
否则拿全部时长去除达标人数,人均会被翻一眼就走的人凭空抬高。

顺带修掉「✓ 已读 · -」:心跳 15 秒一跳,点开就走会落在 0 秒上,而 readableDuration(0)
返回的是 "-"。新口径下打勾要求 ≥180 秒,这个组合不可能再出现;打开过但没读满的
显示灰色的「读了 N 分钟」,记是记下了,只是还没到「已读」。

后台筛选栏上方写了一行口径说明,免得老师对着「已读 0 课 / 累计 25 分钟」猜是不是坏了。

本机实跑:student 读 A 课 500 秒、B 课 60 秒 → 已读 1/2、累计 560 秒;
A 课「读过 2 人、累计 700、人均 350」,B 课「读过 0 人、累计 60、人均 0」。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rpSKCNpVcMhTFhw21YiL7
This commit is contained in:
2026-09-02 20:37:30 -06:00
parent 9675dfbd42
commit 286487acf3
4 changed files with 46 additions and 6 deletions

View File

@@ -1,4 +1,5 @@
import {
TUTORIAL_READ_SECONDS,
learnExerciseAttemptSchema,
learnExerciseProgressListSchema,
learnExerciseProgressSchema,
@@ -93,7 +94,9 @@ adminLearnRoutes.get("/learn-analytics/students", requireTeacher, async (c) => {
username: schema.user.username,
realName: schema.userProfile.realName,
className: schema.user.className,
readCount: count(schema.tutorialProgress.tutorialId),
// 「已读」按 TUTORIAL_READ_SECONDS 卡,不是「有这条记录」:点开一眼就退的不算。
// 累计时长不卡,那些秒数照样算 —— 「已读 0 课、累计 25 分钟」是要看见的一种情况
readCount: sql<number>`count(${schema.tutorialProgress.tutorialId}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS})`.mapWith(Number),
totalSeconds: sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}), 0)`.mapWith(Number),
lastViewedAt: sql<string | null>`max(${schema.tutorialProgress.lastViewedAt})`,
}).from(schema.user)
@@ -148,8 +151,11 @@ adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) =>
// 数的是 user.id 而不是 progress.user_idjoin 不上的(老师自己试读的、
// 已禁用的、不在所选班级的)在这一列是 NULLcount(distinct) 正好不算它,
// 而 progress.user_id 那边永远非空,会把过滤当没发生
readers: sql<number>`count(distinct ${schema.user.id})`.mapWith(Number),
readers: sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS})`.mapWith(Number),
totalSeconds: sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.user.id} is not null), 0)`.mapWith(Number),
// 人均时长的分母是 readers读满 3 分钟的人),分子就得是同一批人的时长,
// 否则拿全部时长去除达标人数,人均会被翻了一眼就走的人凭空抬高
readSeconds: sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS}), 0)`.mapWith(Number),
}).from(schema.tutorial)
.leftJoin(schema.tutorialProgress, eq(schema.tutorialProgress.tutorialId, schema.tutorial.id))
.leftJoin(schema.user, and(
@@ -164,9 +170,9 @@ adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) =>
return success(c, learnTutorialProgressListSchema.parse({
studentCount,
results: rows.map((row) => learnTutorialProgressSchema.parse({
results: rows.map(({ readSeconds, ...row }) => learnTutorialProgressSchema.parse({
...row,
avgSeconds: row.readers ? Math.round(row.totalSeconds / row.readers) : 0,
avgSeconds: row.readers ? Math.round(readSeconds / row.readers) : 0,
})),
}))
})