feat(geom9 ch3 wave2 + final): §12 «Герон» + Финал Главы 3

This commit is contained in:
Maxim Dolgolyov
2026-05-29 10:13:29 +03:00
parent 8dcd54d206
commit 1b79965fce
13 changed files with 1320 additions and 10 deletions
+86
View File
@@ -0,0 +1,86 @@
'use strict';
const router = require('express').Router();
const db = require('../db/db');
const { authMiddleware } = require('../middleware/auth');
router.use(authMiddleware);
/* ── Statements (prepared once) ────────────────────────────────── */
const SQL = {
listTracks: db.prepare(`
SELECT exam_key, title, subject_slug, grade, duration_min,
tasks_per_variant, variants_count, intro_html, sort_order
FROM exam_tracks
WHERE enabled = 1
ORDER BY sort_order, exam_key
`),
getTrack: db.prepare(`
SELECT exam_key, title, subject_slug, grade, duration_min,
tasks_per_variant, variants_count, scoring_json, intro_html
FROM exam_tracks
WHERE exam_key = ? AND enabled = 1
`),
countTasks: db.prepare(`
SELECT
COUNT(*) AS total,
COALESCE(SUM(CASE WHEN task_type = 'mc' THEN 1 ELSE 0 END), 0) AS mc,
COALESCE(SUM(CASE WHEN task_type = 'open' THEN 1 ELSE 0 END), 0) AS open,
COALESCE(SUM(CASE WHEN task_type = 'long' THEN 1 ELSE 0 END), 0) AS long
FROM exam_tasks
WHERE exam_key = ?
`),
/* For each (user, task) — was the most-recent attempt correct?
A task counts as «solved» iff at least one historical attempt is_correct = 1. */
userProgress: db.prepare(`
SELECT
COUNT(DISTINCT exam_task_id) AS tasks_attempted,
COUNT(DISTINCT CASE WHEN is_correct = 1 THEN exam_task_id END) AS tasks_solved,
COUNT(*) AS total_attempts,
COALESCE(SUM(CASE WHEN is_correct = 1 THEN 1 ELSE 0 END), 0) AS correct_attempts
FROM exam_attempts a
JOIN exam_tasks t ON t.id = a.exam_task_id
WHERE a.user_id = ? AND t.exam_key = ?
`),
};
/* ── GET /api/exam-prep/tracks ──
Public list of enabled exam tracks (for a future landing page). */
router.get('/tracks', (_req, res) => {
const tracks = SQL.listTracks.all();
res.json({ tracks });
});
/* ── GET /api/exam-prep/:examKey/info ──
Track metadata + global counts + this user's aggregate progress. */
router.get('/:examKey/info', (req, res) => {
const { examKey } = req.params;
const track = SQL.getTrack.get(examKey);
if (!track) return res.status(404).json({ error: 'Unknown exam track' });
const counts = SQL.countTasks.get(examKey);
const progress = SQL.userProgress.get(req.user.id, examKey);
// Parse scoring grid (JSON in DB). Tolerant of malformed values.
let scoring = null;
if (track.scoring_json) {
try { scoring = JSON.parse(track.scoring_json); } catch { scoring = null; }
}
res.json({
track: {
exam_key: track.exam_key,
title: track.title,
subject: track.subject_slug,
grade: track.grade,
duration_min: track.duration_min,
tasks_per_variant: track.tasks_per_variant,
variants_count: track.variants_count,
intro_html: track.intro_html,
scoring,
},
counts,
progress,
});
});
module.exports = router;
+30
View File
@@ -51,6 +51,7 @@ const collectionRoutes = require('./routes/collection');
const redBookRoutes = require('./routes/red-book');
const parentRoutes = require('./routes/parent');
const exam9Routes = require('./routes/exam9');
const examPrepRoutes = require('./routes/exam-prep');
const textbookRoutes = require('./routes/textbooks');
const teacherStudentsRoutes = require('./routes/teacherStudents');
@@ -171,6 +172,7 @@ app.use('/api/red-book', requireFeature('red_book'), redBookRoutes);
app.use('/api/biochem', requireFeature('biochem'), require('./routes/biochem'));
app.use('/api/parent', parentRoutes);
app.use('/api/exam9', exam9Routes);
app.use('/api/exam-prep', examPrepRoutes);
app.use('/api/textbooks', textbookRoutes);
app.use('/api/teacher-students', teacherStudentsRoutes);
@@ -340,6 +342,34 @@ app.get('/textbooks', (_req, res) => {
res.sendFile(path.join(frontendDir, 'textbooks.html'));
});
// ── Exam preparation module: /exam-prep/:examKey/{,variants,practice,topics,mock}[/...]
// All sub-pages share the same examKey segment; sub-view is the second segment.
// The HTML file is chosen by the sub-view; examKey is parsed client-side from URL.
function sendExamPrep(res, file) {
if (!isProd) res.setHeader('Cache-Control', 'no-store');
res.sendFile(path.join(frontendDir, file));
}
const EXAM_PREP_VIEWS = {
variants: 'exam-prep-variants.html',
practice: 'exam-prep-practice.html',
topics: 'exam-prep-topics.html',
mock: 'exam-prep-mock.html',
};
// /exam-prep → redirect to default track (math9 for now)
app.get('/exam-prep', (_req, res) => res.redirect(302, '/exam-prep/math9'));
// /exam-prep/:examKey → dashboard
app.get('/exam-prep/:examKey', (_req, res) => sendExamPrep(res, 'exam-prep.html'));
// /exam-prep/:examKey/:view → corresponding sub-page (sub-view + optional trailing segments)
app.get(['/exam-prep/:examKey/:view', '/exam-prep/:examKey/:view/*'], (req, res, next) => {
const file = EXAM_PREP_VIEWS[req.params.view];
if (!file) return next();
sendExamPrep(res, file);
});
// NOTE: /exam9 remains live (served by static middleware as exam9.html) until F2
// ports the variant browser into /exam-prep/:examKey/variants. The 301 redirect
// will be added at that point.
// Serve HTML files without extension (/dashboard → dashboard.html)
// In dev: disable cache so edits are always picked up immediately
const htmlCacheOpts = isProd ? { extensions: ['html'] } : {