feat: textbooks Phase 4 — A1+A2+A3+B4+C7 + назначение ученику

A1 — карточка ДЗ-чтения у ученика на /dashboard:
  - Новая ветка в buildAssignCard для assignments с textbook_id
  - Прогресс-бар «X из Y §», цвет берётся из textbook.color
  - Кнопка «Открыть / Продолжить» с deep-link на первый требуемый параграф
  - В classify(): textbook_all_read → done, deadline → overdue

A2 — авто-проверка выполнения:
  - При POST /:slug/progress с mark_read: проверяются активные textbook-assignments
  - Если все требуемые § прочитаны → INSERT в assignment_completion
  - SSE-уведомление учителю «Ученик завершил чтение: <title>»
  - myAssignments возвращает completed_at и textbook_all_read

A3 — учительский UI прогресса класса:
  - Новая страница /textbook-progress (учитель/админ)
  - Селекторы «учебник × класс» → таблица учеников с прогрессом
  - Сортировка по количеству прочитанного, дата last_at
  - Кнопка «Прогресс класса» добавлена в /textbooks (видна учителям)

B4 — admin-UI управления учебниками:
  - /admin-textbooks (только admin) — таблица всех учебников
  - Inline-редактирование title/author, тоггл is_active
  - Колонка «Читателей» (count из textbook_progress)
  - Endpoints: GET /api/textbooks/admin/all, PATCH /admin/:id

C7 — закладки/заметки внутри учебника:
  - Таблица textbook_bookmarks (user, textbook, para, text, note, color)
  - API: GET/POST/PATCH/DELETE для CRUD закладок
  - В tracker: при выделении текста (8-400 симв) появляется плавающая «+ Закладка»
  - Кнопка-иконка в overlay top-left открывает панель «Мои закладки»
  - Хранится paragraph-якорь, цвет, заметка, кнопка удалить

Назначение ученику (в дополнение к классу):
  - В модалке /textbooks — переключатель «Классу / Ученику»
  - Поиск ученика по имени/email через /api/classes/students
  - Submit использует POST /api/assignments (createDirectAssignment)
  - createDirectAssignment расширен textbook_slug + textbook_paragraphs
  - Учитель может назначать только ученикам своих классов

myAssignments расширен: возвращает textbook fields + post-process
  считает textbook_required_count, textbook_read_count, textbook_all_read.

Deep-link поддержка: /textbook/<slug>#pN в tracker.js — на load и hashchange
вызывает setParaTab(pN) (нативная функция учебника).

Миграция 005: assignment_completion + textbook_bookmarks + индексы.
This commit is contained in:
Maxim Dolgolyov
2026-05-16 16:37:11 +03:00
parent e8018d85c1
commit 3ff2f01178
8 changed files with 1118 additions and 65 deletions
@@ -195,11 +195,15 @@ function myAssignments(req, res) {
SELECT * FROM (
SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at,
a.file_id, f.title AS file_title,
a.textbook_id, a.textbook_paragraphs,
tb.slug AS textbook_slug, tb.title AS textbook_title, tb.color AS textbook_color, tb.para_count AS textbook_para_count,
tp.paragraphs_read AS textbook_read,
c.name AS class_name, c.id AS class_id, u.name AS teacher_name,
latest.session_id,
ts.score, ts.total, ts.status AS session_status,
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
CASE WHEN latest.session_id IS NULL THEN 0 ELSE 1 END AS done,
ac.completed_at AS completed_at,
a.is_homework, a.max_attempts,
(SELECT COUNT(*) FROM assignment_sessions ax
JOIN test_sessions tx ON tx.id = ax.session_id AND tx.status = 'completed'
@@ -209,6 +213,9 @@ function myAssignments(req, res) {
JOIN users u ON u.id = c.teacher_id
JOIN assignments a ON a.class_id = c.id AND a.user_id IS NULL
LEFT JOIN files f ON f.id = a.file_id
LEFT JOIN textbooks tb ON tb.id = a.textbook_id
LEFT JOIN textbook_progress tp ON tp.user_id = cm.user_id AND tp.textbook_id = a.textbook_id
LEFT JOIN assignment_completion ac ON ac.assignment_id = a.id AND ac.user_id = cm.user_id
LEFT JOIN assignment_sessions latest ON latest.assignment_id = a.id AND latest.user_id = cm.user_id
AND latest.id = (SELECT MAX(id) FROM assignment_sessions WHERE assignment_id = a.id AND user_id = cm.user_id)
LEFT JOIN test_sessions ts ON ts.id = latest.session_id
@@ -216,11 +223,15 @@ function myAssignments(req, res) {
UNION ALL
SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at,
a.file_id, f.title AS file_title,
a.textbook_id, a.textbook_paragraphs,
tb.slug AS textbook_slug, tb.title AS textbook_title, tb.color AS textbook_color, tb.para_count AS textbook_para_count,
tp.paragraphs_read AS textbook_read,
'Личное задание' AS class_name, 0 AS class_id, u.name AS teacher_name,
latest.session_id,
ts.score, ts.total, ts.status AS session_status,
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
CASE WHEN latest.session_id IS NULL THEN 0 ELSE 1 END AS done,
ac.completed_at AS completed_at,
a.is_homework, a.max_attempts,
(SELECT COUNT(*) FROM assignment_sessions ax
JOIN test_sessions tx ON tx.id = ax.session_id AND tx.status = 'completed'
@@ -228,15 +239,58 @@ function myAssignments(req, res) {
FROM assignments a
JOIN users u ON u.id = a.created_by
LEFT JOIN files f ON f.id = a.file_id
LEFT JOIN textbooks tb ON tb.id = a.textbook_id
LEFT JOIN textbook_progress tp ON tp.user_id = ? AND tp.textbook_id = a.textbook_id
LEFT JOIN assignment_completion ac ON ac.assignment_id = a.id AND ac.user_id = ?
LEFT JOIN assignment_sessions latest ON latest.assignment_id = a.id AND latest.user_id = ?
AND latest.id = (SELECT MAX(id) FROM assignment_sessions WHERE assignment_id = a.id AND user_id = ?)
LEFT JOIN test_sessions ts ON ts.id = latest.session_id
WHERE a.user_id = ?
) ORDER BY done ASC, deadline ASC, created_at DESC
`).all(uid, uid, uid, uid, uid);
`).all(uid, uid, uid, uid, uid, uid, uid);
// Post-process: compute textbook reading completion from required vs read paragraphs
for (const r of rows) {
if (r.textbook_id) {
const required = parseTextbookParas(r.textbook_paragraphs, r.textbook_para_count);
let read = [];
try { read = JSON.parse(r.textbook_read || '[]'); } catch {}
const readKeys = new Set(read);
const requiredKeys = required.map(n => 'p' + n);
const readCount = requiredKeys.filter(k => readKeys.has(k)).length;
r.textbook_required_count = requiredKeys.length;
r.textbook_read_count = readCount;
r.textbook_all_read = requiredKeys.length > 0 && readCount === requiredKeys.length;
if (r.textbook_all_read || r.completed_at) r.done = 1;
}
// Strip raw paragraphs_read JSON from response (not needed by client)
delete r.textbook_read;
}
res.json(rows);
}
/* Parse "1-5", "1,3,7", "1-3,5,7-9" → [1,2,3,4,5] or [1,3,7] etc.
If empty/null, returns [1..fallback] (the whole book). */
function parseTextbookParas(spec, fallback) {
if (!spec || !spec.trim()) {
return Array.from({ length: fallback || 0 }, (_, i) => i + 1);
}
const out = new Set();
for (const chunk of spec.split(',')) {
const part = chunk.trim();
if (!part) continue;
const dash = part.match(/^(\d+)\s*[-]\s*(\d+)$/);
if (dash) {
const a = Number(dash[1]), b = Number(dash[2]);
for (let i = Math.min(a, b); i <= Math.max(a, b); i++) out.add(i);
} else if (/^\d+$/.test(part)) {
out.add(Number(part));
}
}
return [...out].sort((a, b) => a - b);
}
/* ── POST /api/assignments/:id/start ── student starts session ─────────── */
function startAssignment(req, res) {
const uid = req.user.id;
@@ -452,7 +506,8 @@ function assignmentQuestionStats(req, res) {
/* ── POST /api/assignments ── direct assignment to a single student ──────── */
function createDirectAssignment(req, res) {
const { deadline, student_email, student_id, file_id, is_homework = 1 } = req.body;
const { deadline, student_email, student_id, file_id, is_homework = 1,
textbook_slug, textbook_paragraphs } = req.body;
const mode = req.body.mode || 'exam';
const count = Number(req.body.count) || 25;
let { title, subject_slug, test_id } = req.body;
@@ -465,11 +520,11 @@ function createDirectAssignment(req, res) {
let student;
if (student_id) {
student = db.prepare("SELECT id, name FROM users WHERE id = ? AND role = 'student'").get(Number(student_id));
student = db.prepare("SELECT id, name FROM users WHERE id = ? AND role IN ('student','free_student')").get(Number(student_id));
if (!student) return res.status(404).json({ error: 'Ученик не найден' });
} else {
if (!student_email?.trim()) return res.status(400).json({ error: 'student_email required' });
student = db.prepare("SELECT id, name FROM users WHERE email = ? AND role = 'student'")
student = db.prepare("SELECT id, name FROM users WHERE email = ? AND role IN ('student','free_student')")
.get(student_email.trim().toLowerCase());
if (!student) return res.status(404).json({ error: 'Ученик с таким email не найден' });
}
@@ -490,6 +545,15 @@ function createDirectAssignment(req, res) {
if (!t) return res.status(400).json({ error: 'Test not found' });
subject_slug = t.subject_slug;
}
// Textbook: resolve slug → id, derive subject
let textbook_id = null;
if (textbook_slug) {
const tb = db.prepare('SELECT id, subject FROM textbooks WHERE slug=? AND is_active=1').get(textbook_slug);
if (!tb) return res.status(400).json({ error: 'Учебник не найден' });
textbook_id = tb.id;
if (!subject_slug) subject_slug = tb.subject;
}
if (file_id && !subject_slug) {
const f = db.prepare('SELECT subject_slug FROM files WHERE id = ?').get(file_id);
if (f?.subject_slug) subject_slug = f.subject_slug;
@@ -498,9 +562,9 @@ function createDirectAssignment(req, res) {
if (!subject_slug) subject_slug = 'other';
const r = db.prepare(`
INSERT INTO assignments (user_id, title, subject_slug, mode, count, deadline, created_by, test_id, file_id, is_homework)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(student.id, stripTags(title.trim()), subject_slug, mode, Number(count), deadline || null, req.user.id, test_id, file_id || null, is_homework ? 1 : 0);
INSERT INTO assignments (user_id, title, subject_slug, mode, count, deadline, created_by, test_id, file_id, is_homework, textbook_id, textbook_paragraphs)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(student.id, stripTags(title.trim()), subject_slug, mode, Number(count), deadline || null, req.user.id, test_id, file_id || null, is_homework ? 1 : 0, textbook_id, textbook_paragraphs || null);
// Уведомление ученику
pushNotif(student.id, 'assignment', `Для вас задание: «${title.trim()}»`, '/dashboard');