refactor(assignments): единый модуль assignment-utils.js (тип/«сдано»/срочность)
Логика классификации типа задания и статуса «сдано» дублировалась в трёх местах (dashboard.html, homework.html, assignmentController.js) и начала расходиться. Вынес в один UMD-модуль frontend/js/assignment-utils.js (грузится и в браузере, и в Node через require — как svg-sanitize.js): type(a), isDone(a, sub, opts), urgencyScore(a). Нюанс «сдано» для upload/file параметризован: вид ученика (acceptedOnly) — закрыто только при принятой сдаче; учитель/обзор долгов — любая сдача не на доработке. Поведение всех трёх поверхностей сохранено 1:1. - homework.html: asgnType/asgnDone/urgencyScore → тонкие делегаты в AssignmentUtils. - dashboard.html: urgencyScore делегирует; classify и upload-ветка buildAssignCard через AssignmentUtils.type/isDone (заодно корректнее: учебник-ДЗ больше не путается с upload). - assignmentController: classOutstanding/_assignTypeOf → AssignmentUtils. Verified: AU-контракт 25/25 (типы, isDone teacher vs acceptedOnly, порядок urgency); интеграция 8/8 (classOutstanding те же 14 уч./42 просрочено; homework делегирует). node --check всех файлов + инлайна обоих HTML. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ const db = require('../db/db');
|
||||
const { pushNotif } = require('../utils/notifications');
|
||||
const { stripTags } = require('../utils/sanitize');
|
||||
const { SESSION_MODES } = require('../constants');
|
||||
const AssignmentUtils = require('../../../frontend/js/assignment-utils.js'); // единый источник: тип/«сдано»
|
||||
|
||||
const VALID_ASSIGN_MODES = SESSION_MODES;
|
||||
|
||||
@@ -344,14 +345,6 @@ function myAssignments(req, res) {
|
||||
res.json(assignmentRowsForUser(req.user.id));
|
||||
}
|
||||
|
||||
/* Тип задания — те же признаки, что на /homework (asgnType). */
|
||||
function _assignTypeOf(r) {
|
||||
if (r.textbook_id) return 'textbook';
|
||||
if (r.file_id) return 'file';
|
||||
if (r.is_homework && (r.count == null || r.count <= 1) && (!r.subject_slug || r.subject_slug === 'other')) return 'upload';
|
||||
return 'test';
|
||||
}
|
||||
|
||||
/* ── GET /api/classes/:id/outstanding ── что «висит» у каждого ученика класса ──
|
||||
Учитель/админ видят по каждому ученику его НЕзакрытые задания (классовые + личные
|
||||
от этого учителя) со статусом: не начато / в процессе / на доработке / просрочено. */
|
||||
@@ -388,23 +381,16 @@ function classOutstanding(req, res) {
|
||||
);
|
||||
const pending = [];
|
||||
for (const r of rows) {
|
||||
const type = _assignTypeOf(r);
|
||||
const t = AssignmentUtils.type(r);
|
||||
const st = (t === 'upload' || t === 'file') ? subMap.get(r.id + '_' + m.id) : null;
|
||||
// Учительская семантика: любая сдача не на доработке = не долг (default opts).
|
||||
if (AssignmentUtils.isDone(r, st ? { status: st } : null)) continue;
|
||||
const overdue = r.deadline && new Date(r.deadline).getTime() < now;
|
||||
let done = false, status = overdue ? 'overdue' : 'not_started';
|
||||
if (type === 'textbook') {
|
||||
done = !!(r.textbook_all_read || r.completed_at);
|
||||
} else if (type === 'upload' || type === 'file') {
|
||||
const st = subMap.get(r.id + '_' + m.id);
|
||||
if (st && st !== 'revision') done = true; // сдал — на проверке/принято
|
||||
else if (st === 'revision') status = 'revision'; // вернули на доработку
|
||||
} else { // test
|
||||
const maxAtt = r.max_attempts || 0, used = r.attempts_used || 0;
|
||||
if (maxAtt > 0 && used >= maxAtt) done = true;
|
||||
else if (r.session_status === 'completed' && r.mode !== 'repeat') done = true;
|
||||
else if (r.session_status === 'in_progress') status = 'in_progress';
|
||||
}
|
||||
if (!done) pending.push({
|
||||
assignment_id: r.id, title: r.title, type, deadline: r.deadline,
|
||||
let status = overdue ? 'overdue' : 'not_started';
|
||||
if (st === 'revision') status = 'revision'; // вернули на доработку
|
||||
else if (t === 'test' && r.session_status === 'in_progress') status = 'in_progress';
|
||||
pending.push({
|
||||
assignment_id: r.id, title: r.title, type: t, deadline: r.deadline,
|
||||
status, is_homework: r.is_homework ? 1 : 0,
|
||||
scope: r.class_id === cidNum ? 'class' : 'direct',
|
||||
});
|
||||
|
||||
+16
-18
@@ -1889,6 +1889,7 @@
|
||||
<!-- Join modal -->
|
||||
<!-- Quick-start test modal -->
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/assignment-utils.js"></script>
|
||||
<script src="/js/sound.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script src="/js/notifications.js"></script>
|
||||
@@ -2381,15 +2382,8 @@
|
||||
body.classList.toggle('collapsed');
|
||||
}
|
||||
|
||||
/* ── Urgency sort score (lower = shown first) ── */
|
||||
function urgencyScore(a) {
|
||||
if (a.session_status === 'in_progress') return -4; // in progress <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> top
|
||||
const dlMs = a.deadline ? new Date(a.deadline) - Date.now() : Infinity;
|
||||
if (dlMs < 0) return -3; // overdue
|
||||
if (dlMs < 24 * 3600 * 1000) return -2; // urgent <24h
|
||||
if (dlMs < Infinity) return dlMs; // sorted by deadline
|
||||
return 1e12; // no deadline <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> last
|
||||
}
|
||||
/* ── Urgency sort score (lower = shown first) — общий модуль ── */
|
||||
function urgencyScore(a) { return AssignmentUtils.urgencyScore(a); }
|
||||
|
||||
/* ── Is assignment urgent for teacher (within 48h) ── */
|
||||
function isTeacherUrgent(a) {
|
||||
@@ -2457,7 +2451,7 @@
|
||||
}
|
||||
|
||||
/* ── Upload-only homework (no test, no file) ── */
|
||||
if (a.is_homework && !a.file_id && !a.session_id && a.count <= 1 && (!a.subject_slug || a.subject_slug === 'other')) {
|
||||
if (AssignmentUtils.type(a) === 'upload') {
|
||||
const over = a.deadline && new Date(a.deadline) < new Date();
|
||||
const sub = _mySubmissions.get(a.id);
|
||||
const metaParts = [classStr, dl ? `до ${dl}` : null,
|
||||
@@ -2694,18 +2688,22 @@
|
||||
reIcons(); return;
|
||||
}
|
||||
|
||||
// Classify
|
||||
// Classify (active/overdue/done) — тип и «сдано» из общего модуля AssignmentUtils.
|
||||
function classify(a) {
|
||||
const maxAtt = a.max_attempts || 0;
|
||||
const usedAtt = a.attempts_used ?? 0;
|
||||
if (a.textbook_id) {
|
||||
if (a.completed_at || a.textbook_all_read) return 'done';
|
||||
const t = AssignmentUtils.type(a);
|
||||
if (t === 'textbook') {
|
||||
if (AssignmentUtils.isDone(a)) return 'done';
|
||||
if (a.deadline && new Date(a.deadline) < now) return 'overdue';
|
||||
return 'active';
|
||||
}
|
||||
if (maxAtt > 0 && usedAtt >= maxAtt) return 'done';
|
||||
if (a.session_status === 'completed' && a.mode !== 'repeat') return 'done';
|
||||
if (!a.file_id && a.deadline && new Date(a.deadline) < now && a.session_status !== 'in_progress') return 'overdue';
|
||||
if (t === 'test') {
|
||||
if (AssignmentUtils.isDone(a)) return 'done';
|
||||
if (a.deadline && new Date(a.deadline) < now && a.session_status !== 'in_progress') return 'overdue';
|
||||
return 'active';
|
||||
}
|
||||
// upload / file: «сдано» по сабмишену здесь не считаем (как и раньше — статус
|
||||
// показывает чип сдачи в карточке); upload просрочивается по дедлайну, file — всегда активен.
|
||||
if (t === 'upload' && a.deadline && new Date(a.deadline) < now) return 'overdue';
|
||||
return 'active';
|
||||
}
|
||||
|
||||
|
||||
+6
-33
@@ -258,6 +258,7 @@
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script src="/js/notifications.js"></script>
|
||||
<script src="/js/assignment-utils.js"></script>
|
||||
<script>
|
||||
const { user, isTeacher, isAdmin } = LS.initPage();
|
||||
if (!user) throw new Error('Not logged in');
|
||||
@@ -450,39 +451,11 @@
|
||||
|
||||
/* ══ АКТУАЛЬНЫЕ ЗАДАНИЯ (что нужно сделать) ══════════════════════════ */
|
||||
|
||||
// Тип задания — по тем же признакам, что и карточки дашборда.
|
||||
function asgnType(a) {
|
||||
if (a.textbook_id) return 'textbook';
|
||||
if (a.file_id) return 'file';
|
||||
if (a.is_homework && !a.session_id && (a.count == null || a.count <= 1)
|
||||
&& (!a.subject_slug || a.subject_slug === 'other')) return 'upload';
|
||||
return 'test';
|
||||
}
|
||||
|
||||
// «Закрыто» — задание уходит из актуальных.
|
||||
function asgnDone(a) {
|
||||
const t = asgnType(a);
|
||||
if (t === 'textbook') return !!(a.textbook_all_read || a.completed_at);
|
||||
if (t === 'upload' || t === 'file') {
|
||||
const s = _subByAsgn.get(a.id);
|
||||
return !!(s && s.status === 'accepted');
|
||||
}
|
||||
// test
|
||||
const maxAtt = a.max_attempts || 0;
|
||||
const usedAtt = a.attempts_used != null ? a.attempts_used : 0;
|
||||
if (maxAtt > 0 && usedAtt >= maxAtt) return true;
|
||||
return a.session_status === 'completed' && a.mode !== 'repeat';
|
||||
}
|
||||
|
||||
// Сортировка по срочности (меньше — выше): идёт → просрочено → <24ч → по дедлайну → без срока.
|
||||
function urgencyScore(a) {
|
||||
if (a.session_status === 'in_progress') return -4;
|
||||
const dlMs = a.deadline ? new Date(a.deadline) - Date.now() : Infinity;
|
||||
if (dlMs < 0) return -3;
|
||||
if (dlMs < 24 * 3600 * 1000) return -2;
|
||||
if (dlMs < Infinity) return dlMs;
|
||||
return 1e12;
|
||||
}
|
||||
// Тип / «сдано» / срочность — из общего модуля AssignmentUtils (тот же, что у дашборда
|
||||
// и сервера). Вид ученика: upload/file закрыт ТОЛЬКО при принятой сдаче (acceptedOnly).
|
||||
function asgnType(a) { return AssignmentUtils.type(a); }
|
||||
function asgnDone(a) { return AssignmentUtils.isDone(a, _subByAsgn.get(a.id), { acceptedOnly: true }); }
|
||||
function urgencyScore(a) { return AssignmentUtils.urgencyScore(a); }
|
||||
|
||||
function deadlineChip(a) {
|
||||
if (!a.deadline) return '<span class="hw-dl-chip hw-dl-ok">Без срока</span>';
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
'use strict';
|
||||
/* assignment-utils.js — единый источник правды для классификации заданий и статуса «сдано».
|
||||
*
|
||||
* Раньше эта логика дублировалась в трёх местах (dashboard.html, homework.html,
|
||||
* assignmentController.js) и начала расходиться. Теперь — один модуль, который грузится
|
||||
* и в браузере (window.AssignmentUtils), и в Node (module.exports) — как svg-sanitize.js.
|
||||
*
|
||||
* Поля задания (как их отдаёт /assignments/my и assignmentRowsForUser):
|
||||
* textbook_id, file_id, is_homework, count, subject_slug, mode, session_status,
|
||||
* max_attempts, attempts_used, deadline, textbook_all_read, completed_at.
|
||||
*/
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
if (typeof window !== 'undefined') window.AssignmentUtils = api;
|
||||
})(this, function () {
|
||||
|
||||
/* Тип задания: textbook | file | upload | test.
|
||||
Порядок проверки: учебник → файл → загрузка работы → тест. */
|
||||
function type(a) {
|
||||
if (a.textbook_id) return 'textbook';
|
||||
if (a.file_id) return 'file';
|
||||
if (a.is_homework && (a.count == null || a.count <= 1)
|
||||
&& (!a.subject_slug || a.subject_slug === 'other')) return 'upload';
|
||||
return 'test';
|
||||
}
|
||||
|
||||
/* «Закрыто» (сдано/выполнено/прочитано) — задание уходит из активных/долгов.
|
||||
sub — последняя сдача (объект с .status) для upload/file, иначе null/undefined.
|
||||
opts.acceptedOnly=true (вид ученика на /homework): upload/file закрыт ТОЛЬКО при
|
||||
status==='accepted' (пока не приняли — у ученика «висит»).
|
||||
по умолчанию (учитель / обзор долгов): любая сдача не на доработке = закрыто
|
||||
(ученик свою часть сделал — это уже не его долг). */
|
||||
function isDone(a, sub, opts) {
|
||||
opts = opts || {};
|
||||
const t = type(a);
|
||||
if (t === 'textbook') return !!(a.textbook_all_read || a.completed_at);
|
||||
if (t === 'upload' || t === 'file') {
|
||||
const st = sub && sub.status;
|
||||
if (!st || st === 'revision') return false;
|
||||
return opts.acceptedOnly ? st === 'accepted' : true;
|
||||
}
|
||||
// test
|
||||
const maxAtt = a.max_attempts || 0;
|
||||
const used = (a.attempts_used != null) ? a.attempts_used : 0;
|
||||
if (maxAtt > 0 && used >= maxAtt) return true;
|
||||
return a.session_status === 'completed' && a.mode !== 'repeat';
|
||||
}
|
||||
|
||||
/* Срочность для сортировки (меньше — выше): идёт → просрочено → <24ч → по дедлайну → без срока. */
|
||||
function urgencyScore(a) {
|
||||
if (a.session_status === 'in_progress') return -4;
|
||||
const dlMs = a.deadline ? (new Date(a.deadline).getTime() - Date.now()) : Infinity;
|
||||
if (dlMs < 0) return -3;
|
||||
if (dlMs < 24 * 3600 * 1000) return -2;
|
||||
if (dlMs < Infinity) return dlMs;
|
||||
return 1e12;
|
||||
}
|
||||
|
||||
return { type, isDone, urgencyScore };
|
||||
});
|
||||
Reference in New Issue
Block a user