first exam iteration
This commit is contained in:
1 parent
17731364bd
commit
a81579d4fc
7 files changed
+715
-12
No files matched your search
@@ -78,3 +78,26 @@ create table states
|
|||||||
fsrs_open_day integer not null
|
fsrs_open_day integer not null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
create table exams
|
||||||
|
(
|
||||||
|
id INTEGER
|
||||||
|
primary key autoincrement,
|
||||||
|
timestamp INTEGER,
|
||||||
|
deck_id INTEGER not null
|
||||||
|
references decks
|
||||||
|
on delete cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
create table exam_entries
|
||||||
|
(
|
||||||
|
id INTEGER
|
||||||
|
primary key autoincrement,
|
||||||
|
exam_id INTEGER not null
|
||||||
|
references exams
|
||||||
|
on delete cascade,
|
||||||
|
word_id INTEGER not null
|
||||||
|
references words
|
||||||
|
on delete cascade,
|
||||||
|
status INTEGER default null
|
||||||
|
);
|
||||||
|
|
||||||
+144
@@ -500,3 +500,147 @@ export interface WordState {
|
|||||||
/** Дни от epoch (целые сутки с 1970-01-01). */
|
/** Дни от epoch (целые сутки с 1970-01-01). */
|
||||||
fsrs_open_day: number;
|
fsrs_open_day: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Экзамены (exams / exam_entries) --- */
|
||||||
|
|
||||||
|
export interface Exam {
|
||||||
|
id: number;
|
||||||
|
/** UNIX timestamp в секундах (колонка nullable — 0 если NULL). */
|
||||||
|
timestamp: number;
|
||||||
|
deck_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamEntry {
|
||||||
|
id: number;
|
||||||
|
exam_id: number;
|
||||||
|
word_id: number;
|
||||||
|
/** NULL — не отвечено, 1 — «Знаю», 0 — «Не знаю» (integer-булево десктопа). */
|
||||||
|
status: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Экзамен колоды со счётчиками: агрегат exams + exam_entries одним запросом. */
|
||||||
|
export interface DeckExamStats extends Exam {
|
||||||
|
/** Всего записей exam_entries. */
|
||||||
|
total: number;
|
||||||
|
/** status = 1 («Знаю»). */
|
||||||
|
answered: number;
|
||||||
|
/** status = 0 («Не знаю»). */
|
||||||
|
unknown: number;
|
||||||
|
/** status IS NULL. */
|
||||||
|
remaining: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создаёт таблицы экзаменов, если их нет (старые БД могли скачаны до
|
||||||
|
* появления exams/exam_entries). Идемпотентно, безопасно вызывать на каждом
|
||||||
|
* заходе на страницы экзаменов.
|
||||||
|
*/
|
||||||
|
export async function ensureExamTables(): Promise<void> {
|
||||||
|
await command(
|
||||||
|
`CREATE TABLE IF NOT EXISTS exams (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp INTEGER,
|
||||||
|
deck_id INTEGER NOT NULL REFERENCES decks ON DELETE CASCADE
|
||||||
|
)`,
|
||||||
|
);
|
||||||
|
await command(
|
||||||
|
`CREATE TABLE IF NOT EXISTS exam_entries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
exam_id INTEGER NOT NULL REFERENCES exams ON DELETE CASCADE,
|
||||||
|
word_id INTEGER NOT NULL REFERENCES words ON DELETE CASCADE,
|
||||||
|
status INTEGER DEFAULT NULL
|
||||||
|
)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Все экзамены колоды со счётчиками, свежие первыми.
|
||||||
|
* LEFT JOIN + GROUP BY: ноль записей у экзамена даёт total=0 (не теряем строку).
|
||||||
|
*/
|
||||||
|
export async function getDeckExams(deckId: number): Promise<DeckExamStats[]> {
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT e.id, e.timestamp, e.deck_id,
|
||||||
|
COUNT(en.id) as total,
|
||||||
|
COALESCE(SUM(CASE WHEN en.status = 1 THEN 1 ELSE 0 END), 0) as answered,
|
||||||
|
COALESCE(SUM(CASE WHEN en.status = 0 THEN 1 ELSE 0 END), 0) as unknown
|
||||||
|
FROM exams e
|
||||||
|
LEFT JOIN exam_entries en ON en.exam_id = e.id
|
||||||
|
WHERE e.deck_id = ?
|
||||||
|
GROUP BY e.id
|
||||||
|
ORDER BY e.timestamp DESC, e.id DESC`,
|
||||||
|
[deckId] as never[],
|
||||||
|
);
|
||||||
|
return (rows as Record<string, unknown>[]).map((r) => {
|
||||||
|
const total = Number(r["total"] ?? 0);
|
||||||
|
const answered = Number(r["answered"] ?? 0);
|
||||||
|
const unknown = Number(r["unknown"] ?? 0);
|
||||||
|
return {
|
||||||
|
id: Number(r["id"]),
|
||||||
|
timestamp: Number(r["timestamp"] ?? 0),
|
||||||
|
deck_id: Number(r["deck_id"]),
|
||||||
|
total,
|
||||||
|
answered,
|
||||||
|
unknown,
|
||||||
|
remaining: Math.max(0, total - answered - unknown),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Новый экзамен по колоде: строка в exams (timestamp = nowSec) + записи
|
||||||
|
* exam_entries (status NULL) на каждое уникальное слово из states колоды —
|
||||||
|
* как append_exam_entries на десктопе, одним INSERT … SELECT вместо батча.
|
||||||
|
* Возвращает id созданного экзамена (INSERT … RETURNING, атомарно).
|
||||||
|
*/
|
||||||
|
export async function createExam(deckId: number): Promise<number> {
|
||||||
|
const created = await query(
|
||||||
|
"INSERT INTO exams (timestamp, deck_id) VALUES (?, ?) RETURNING id",
|
||||||
|
[nowSec(), deckId] as never[],
|
||||||
|
);
|
||||||
|
const examId = Number((created[0] as Record<string, unknown> | undefined)?.["id"] ?? 0);
|
||||||
|
if (!examId) throw new Error("Не удалось создать экзамен: нет id новой строки");
|
||||||
|
await command(
|
||||||
|
`INSERT INTO exam_entries (exam_id, word_id, status)
|
||||||
|
SELECT ?, word_id, NULL
|
||||||
|
FROM (SELECT DISTINCT word_id FROM states WHERE deck_id = ?)`,
|
||||||
|
[examId, deckId] as never[],
|
||||||
|
);
|
||||||
|
return examId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Один экзамен по id или null, если строки нет. */
|
||||||
|
export async function getExam(examId: number): Promise<Exam | null> {
|
||||||
|
const rows = await query(
|
||||||
|
"SELECT id, timestamp, deck_id FROM exams WHERE id = ? LIMIT 1",
|
||||||
|
[examId] as never[],
|
||||||
|
);
|
||||||
|
const r = (rows as Record<string, unknown>[])[0];
|
||||||
|
if (!r) return null;
|
||||||
|
return {
|
||||||
|
id: Number(r["id"]),
|
||||||
|
timestamp: Number(r["timestamp"] ?? 0),
|
||||||
|
deck_id: Number(r["deck_id"]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Записи экзамена в порядке id (слова подтягиваются отдельно getWordsByIds). */
|
||||||
|
export async function getExamEntries(examId: number): Promise<ExamEntry[]> {
|
||||||
|
const rows = await query(
|
||||||
|
"SELECT id, exam_id, word_id, status FROM exam_entries WHERE exam_id = ? ORDER BY id",
|
||||||
|
[examId] as never[],
|
||||||
|
);
|
||||||
|
return (rows as Record<string, unknown>[]).map((r) => ({
|
||||||
|
id: Number(r["id"]),
|
||||||
|
exam_id: Number(r["exam_id"]),
|
||||||
|
word_id: Number(r["word_id"]),
|
||||||
|
status: r["status"] == null ? null : Number(r["status"]),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ответ на запись экзамена: 1 — «Знаю», 0 — «Не знаю». */
|
||||||
|
export async function setExamEntryStatus(entryId: number, status: 0 | 1): Promise<void> {
|
||||||
|
await command("UPDATE exam_entries SET status = ? WHERE id = ?", [
|
||||||
|
status,
|
||||||
|
entryId,
|
||||||
|
] as never[]);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ export type DeckNav = {
|
|||||||
goDecks: () => void;
|
goDecks: () => void;
|
||||||
goReview: (deckId: number) => void;
|
goReview: (deckId: number) => void;
|
||||||
goHistory: (deckId: number) => void;
|
goHistory: (deckId: number) => void;
|
||||||
|
goExams: (deckId: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function fmtOrDash(s: string): string {
|
function fmtOrDash(s: string): string {
|
||||||
@@ -114,6 +115,9 @@ export function renderDeckPage(root: HTMLElement, deckId: number, nav: DeckNav):
|
|||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button id="deck-history" class="btn" type="button">История ответов</button>
|
<button id="deck-history" class="btn" type="button">История ответов</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button id="deck-exams" class="btn" type="button">Экзамены</button>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const form = bodyEl.querySelector<HTMLFormElement>('#deck-form')!;
|
const form = bodyEl.querySelector<HTMLFormElement>('#deck-form')!;
|
||||||
@@ -124,9 +128,11 @@ export function renderDeckPage(root: HTMLElement, deckId: number, nav: DeckNav):
|
|||||||
const formStatus = bodyEl.querySelector<HTMLElement>('#deck-form-status')!;
|
const formStatus = bodyEl.querySelector<HTMLElement>('#deck-form-status')!;
|
||||||
const reviewBtn = bodyEl.querySelector<HTMLButtonElement>('#deck-review')!;
|
const reviewBtn = bodyEl.querySelector<HTMLButtonElement>('#deck-review')!;
|
||||||
const historyBtn = bodyEl.querySelector<HTMLButtonElement>('#deck-history')!;
|
const historyBtn = bodyEl.querySelector<HTMLButtonElement>('#deck-history')!;
|
||||||
|
const examsBtn = bodyEl.querySelector<HTMLButtonElement>('#deck-exams')!;
|
||||||
|
|
||||||
reviewBtn.addEventListener('click', () => nav.goReview(d.id));
|
reviewBtn.addEventListener('click', () => nav.goReview(d.id));
|
||||||
historyBtn.addEventListener('click', () => nav.goHistory(d.id));
|
historyBtn.addEventListener('click', () => nav.goHistory(d.id));
|
||||||
|
examsBtn.addEventListener('click', () => nav.goExams(d.id));
|
||||||
|
|
||||||
// Кнопка активна только при отличии от сохранённого.
|
// Кнопка активна только при отличии от сохранённого.
|
||||||
const snapshot = (): string =>
|
const snapshot = (): string =>
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
// Страница одного экзамена: незаответованные записи (status IS NULL) вперемешку,
|
||||||
|
// карточка как в повторении (forward → tap → backward), оценки «Не знаю» (0)
|
||||||
|
// и «Знаю» (1). Ответ пишется в exam_entries.status; после последней карточки —
|
||||||
|
// экран результата: % правильных + списки «Правильно» / «Не получилось».
|
||||||
|
|
||||||
|
import {
|
||||||
|
ensureExamTables,
|
||||||
|
getCustomFields,
|
||||||
|
getDeck,
|
||||||
|
getExam,
|
||||||
|
getExamEntries,
|
||||||
|
getWordsByIds,
|
||||||
|
renderCardSide,
|
||||||
|
setExamEntryStatus,
|
||||||
|
wordFieldValues,
|
||||||
|
} from './db-types.ts';
|
||||||
|
import type { CustomField, Exam, ExamEntry, Word } from './db-types.ts';
|
||||||
|
import { hasDatabaseFile } from './opfs.ts';
|
||||||
|
import { formatDbDateTime } from './time.ts';
|
||||||
|
|
||||||
|
export type ExamNav = {
|
||||||
|
goExams: (deckId: number) => void;
|
||||||
|
goDecks: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Оценки экзамена: значение = status в exam_entries (integer-булево десктопа).
|
||||||
|
const EXAM_GRADES: ReadonlyArray<{ value: 0 | 1; label: string; cls: string }> = [
|
||||||
|
{ value: 0, label: 'Не знаю', cls: 'grade-fail' },
|
||||||
|
{ value: 1, label: 'Знаю', cls: 'grade-good' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function renderExamPage(root: HTMLElement, examId: number, nav: ExamNav): void {
|
||||||
|
root.innerHTML = `
|
||||||
|
<button id="exam-back" class="btn btn-ghost" type="button">← Назад к экзаменам</button>
|
||||||
|
<section class="card" aria-labelledby="exam-title">
|
||||||
|
<h2 id="exam-title" class="card-title">Экзамен…</h2>
|
||||||
|
<p id="exam-progress" class="card-hint" role="status" aria-live="polite"></p>
|
||||||
|
<div id="exam-status" class="status-bar" role="status">Загрузка экзамена…</div>
|
||||||
|
<div id="exam-body"></div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const backBtn = root.querySelector<HTMLButtonElement>('#exam-back')!;
|
||||||
|
const titleEl = root.querySelector<HTMLElement>('#exam-title')!;
|
||||||
|
const progressEl = root.querySelector<HTMLElement>('#exam-progress')!;
|
||||||
|
const statusEl = root.querySelector<HTMLElement>('#exam-status')!;
|
||||||
|
const bodyEl = root.querySelector<HTMLElement>('#exam-body')!;
|
||||||
|
|
||||||
|
// Назад — на список экзаменов колоды; если deck_id неизвестен, то в «Колоды».
|
||||||
|
let backDeckId: number | null = null;
|
||||||
|
function goBack(): void {
|
||||||
|
if (backDeckId != null) nav.goExams(backDeckId);
|
||||||
|
else nav.goDecks();
|
||||||
|
}
|
||||||
|
backBtn.addEventListener('click', goBack);
|
||||||
|
|
||||||
|
function setStatus(msg: string): void {
|
||||||
|
statusEl.textContent = msg;
|
||||||
|
statusEl.hidden = msg === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let exam: Exam | null = null;
|
||||||
|
let fields: CustomField[] = [];
|
||||||
|
let forwardTpl = '';
|
||||||
|
let backwardTpl = '';
|
||||||
|
/** Все записи экзамена (порядок id) + слова по id. */
|
||||||
|
let entries: ExamEntry[] = [];
|
||||||
|
let words = new Map<number, Word>();
|
||||||
|
/** Очередь сессии: id незаответованных записей вперемешку. */
|
||||||
|
let queue: number[] = [];
|
||||||
|
let pos = 0;
|
||||||
|
let revealed = false;
|
||||||
|
// Защита от гонок: параллельные клики не должны писать в БД одновременно.
|
||||||
|
let answering = false;
|
||||||
|
|
||||||
|
const entryById = (id: number): ExamEntry | undefined => entries.find((e) => e.id === id);
|
||||||
|
|
||||||
|
function sideHtml(values: string[], cls: string): string {
|
||||||
|
if (values.length === 0) {
|
||||||
|
return `<div class="review-side ${cls}"><span class="empty-val">—</span></div>`;
|
||||||
|
}
|
||||||
|
return `<div class="review-side ${cls}">`
|
||||||
|
+ values.map((v) => `<p class="review-line">${escapeHtml(v)}</p>`).join('')
|
||||||
|
+ `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function progressText(): string {
|
||||||
|
const solved = entries.filter((e) => e.status !== null).length;
|
||||||
|
return `Карточка ${Math.min(pos + 1, queue.length)} из ${queue.length}`
|
||||||
|
+ ` · решено ${solved} из ${entries.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function paint(): Promise<void> {
|
||||||
|
const entryId = queue[pos];
|
||||||
|
const entry = entryId != null ? entryById(entryId) : undefined;
|
||||||
|
const w = entry ? words.get(entry.word_id) : undefined;
|
||||||
|
if (!entry || !w) {
|
||||||
|
// Запись пропала (слово удалили) — пропускаем карточку.
|
||||||
|
pos += 1;
|
||||||
|
revealed = false;
|
||||||
|
if (pos >= queue.length) paintDone();
|
||||||
|
else await paint();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const values = wordFieldValues(w, fields);
|
||||||
|
const front = renderCardSide(forwardTpl, values);
|
||||||
|
const back = revealed ? renderCardSide(backwardTpl, values) : null;
|
||||||
|
progressEl.textContent = progressText();
|
||||||
|
setStatus('');
|
||||||
|
const backHtml = !revealed
|
||||||
|
? `<p class="review-tap">Тап — показать ответ</p>`
|
||||||
|
: sideHtml(back ?? [], 'review-back-side');
|
||||||
|
const gradesHtml = revealed
|
||||||
|
? `<div class="review-grades" role="group" aria-label="Оценка экзамена">
|
||||||
|
${EXAM_GRADES.map((g) => `<button class="btn review-grade ${g.cls}" type="button" data-status="${g.value}">${g.label}</button>`).join('')}
|
||||||
|
</div>`
|
||||||
|
: '';
|
||||||
|
bodyEl.innerHTML = `
|
||||||
|
<button id="exam-card" class="review-card" type="button"
|
||||||
|
aria-expanded="${revealed}" aria-label="${revealed ? 'Карточка открыта' : 'Показать ответ'}">
|
||||||
|
${sideHtml(front, 'review-front')}
|
||||||
|
${revealed ? `<hr class="review-hr" />` : ''}
|
||||||
|
${backHtml}
|
||||||
|
</button>
|
||||||
|
${gradesHtml}
|
||||||
|
`;
|
||||||
|
bodyEl.querySelector<HTMLButtonElement>('#exam-card')!.addEventListener('click', () => {
|
||||||
|
if (!revealed) {
|
||||||
|
revealed = true;
|
||||||
|
void paint();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
bodyEl.querySelectorAll<HTMLButtonElement>('.review-grade').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const status = Number(btn.dataset.status) as 0 | 1;
|
||||||
|
void answer(status);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нажатие оценки: пишем status в exam_entries и идём дальше; на последней — итог.
|
||||||
|
async function answer(status: 0 | 1): Promise<void> {
|
||||||
|
if (answering) return;
|
||||||
|
const entryId = queue[pos];
|
||||||
|
const entry = entryId != null ? entryById(entryId) : undefined;
|
||||||
|
if (!entry) return;
|
||||||
|
answering = true;
|
||||||
|
const btns = [...bodyEl.querySelectorAll<HTMLButtonElement>('.review-grade')];
|
||||||
|
for (const b of btns) b.disabled = true;
|
||||||
|
try {
|
||||||
|
await setExamEntryStatus(entry.id, status);
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`Ответ не записался: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
answering = false;
|
||||||
|
for (const b of btns) b.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entry.status = status;
|
||||||
|
pos += 1;
|
||||||
|
revealed = false;
|
||||||
|
answering = false;
|
||||||
|
if (pos >= queue.length) paintDone();
|
||||||
|
else await paint();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Экран результата: % + списки правильных и неправильных (как exams.rs десктопа). */
|
||||||
|
function paintDone(): void {
|
||||||
|
const total = entries.length;
|
||||||
|
const answeredList = entries.filter((e) => e.status === 1);
|
||||||
|
const unknownList = entries.filter((e) => e.status === 0);
|
||||||
|
const result = total > 0 ? Math.round((answeredList.length / total) * 1000) / 10 : 0;
|
||||||
|
progressEl.textContent = `Решено: ${answeredList.length + unknownList.length} из ${total}`;
|
||||||
|
setStatus('');
|
||||||
|
|
||||||
|
const listHtml = (title: string, list: ExamEntry[]): string => {
|
||||||
|
const rows = list.map((e) => {
|
||||||
|
const w = words.get(e.word_id);
|
||||||
|
if (!w) {
|
||||||
|
return `<div class="hist-row-wrap"><span class="empty-val">Слово #${e.word_id} (удалено)</span></div>`;
|
||||||
|
}
|
||||||
|
return `<div class="hist-row-wrap">`
|
||||||
|
+ `<div class="hist-main"><span class="word-key">${escapeHtml(w.key)}</span>`
|
||||||
|
+ (w.value ? `<span class="word-sep"> — </span><span class="word-value">${escapeHtml(w.value)}</span>` : '')
|
||||||
|
+ `</div></div>`;
|
||||||
|
}).join('');
|
||||||
|
return `<h3 class="deck-sub">${title} (${list.length})</h3>`
|
||||||
|
+ `<div class="words-list">${rows}</div>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
bodyEl.innerHTML = `
|
||||||
|
<p class="exam-result">Итоговый результат: ${result}%</p>
|
||||||
|
<div class="exam-bar" role="img" aria-label="Итог экзамена: ${answeredList.length} верно, ${unknownList.length} нет">
|
||||||
|
<span class="exam-bar-ok" style="width:${total > 0 ? (answeredList.length / total) * 100 : 0}%"></span>
|
||||||
|
<span class="exam-bar-no" style="width:${total > 0 ? (unknownList.length / total) * 100 : 0}%"></span>
|
||||||
|
</div>
|
||||||
|
${listHtml('Правильно', answeredList)}
|
||||||
|
${listHtml('Не получилось', unknownList)}
|
||||||
|
<div class="btn-row">
|
||||||
|
<button id="exam-finish" class="btn btn-primary" type="button">К экзаменам</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
bodyEl.querySelector<HTMLButtonElement>('#exam-finish')!.addEventListener('click', goBack);
|
||||||
|
}
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const has = await hasDatabaseFile();
|
||||||
|
if (!has) {
|
||||||
|
titleEl.textContent = 'Экзамен недоступен';
|
||||||
|
setStatus('Нет локальной базы — скачай сохранение на странице «Синхронизация».');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await ensureExamTables();
|
||||||
|
exam = await getExam(examId);
|
||||||
|
if (!exam) {
|
||||||
|
titleEl.textContent = 'Экзамен не найден';
|
||||||
|
setStatus(`Экзамена с id=${examId} нет в локальной базе.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
backDeckId = exam.deck_id;
|
||||||
|
const [deck, customFields, examEntries] = await Promise.all([
|
||||||
|
getDeck(exam.deck_id),
|
||||||
|
getCustomFields(),
|
||||||
|
getExamEntries(exam.id),
|
||||||
|
]);
|
||||||
|
if (!deck) {
|
||||||
|
titleEl.textContent = 'Колода не найдена';
|
||||||
|
setStatus(`Колоды с id=${exam.deck_id} нет в локальной базе.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
forwardTpl = deck.forward;
|
||||||
|
backwardTpl = deck.backward;
|
||||||
|
fields = customFields;
|
||||||
|
entries = examEntries;
|
||||||
|
titleEl.textContent = `Экзамен от ${exam.timestamp > 0 ? formatDbDateTime(exam.timestamp) : `#${exam.id}`}`;
|
||||||
|
|
||||||
|
words = await getWordsByIds(entries.map((e) => e.word_id));
|
||||||
|
|
||||||
|
// Очередь: только неотвеченные; тасование Фишера–Йетса (как sampleDeckWordIds).
|
||||||
|
queue = entries.filter((e) => e.status === null).map((e) => e.id);
|
||||||
|
for (let i = queue.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[queue[i], queue[j]] = [queue[j]!, queue[i]!];
|
||||||
|
}
|
||||||
|
pos = 0;
|
||||||
|
revealed = false;
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
progressEl.textContent = '';
|
||||||
|
setStatus('В экзамене нет карточек.');
|
||||||
|
bodyEl.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (queue.length === 0) {
|
||||||
|
// Уже полностью пройден — сразу показываем результат.
|
||||||
|
paintDone();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await paint();
|
||||||
|
} catch (err) {
|
||||||
|
titleEl.textContent = 'Экзамен недоступен';
|
||||||
|
setStatus(`Не удалось загрузить экзамен: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// Страница экзаменов колоды: список exams со счётчиками + создание нового.
|
||||||
|
// Строка = дата + статус (решено/итог); тап по строке — страница экзамена.
|
||||||
|
// Навигация: назад на страницу колоды, вперёд — на конкретный экзамен.
|
||||||
|
|
||||||
|
import { createExam, ensureExamTables, getDeck, getDeckExams, getDeckWordCount } from './db-types.ts';
|
||||||
|
import type { DeckExamStats } from './db-types.ts';
|
||||||
|
import { hasDatabaseFile } from './opfs.ts';
|
||||||
|
import { formatDbDateTime } from './time.ts';
|
||||||
|
|
||||||
|
export type ExamsNav = {
|
||||||
|
goDeck: (deckId: number) => void;
|
||||||
|
goExam: (examId: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluralExams(n: number): string {
|
||||||
|
const m10 = n % 10;
|
||||||
|
const m100 = n % 100;
|
||||||
|
if (m10 === 1 && m100 !== 11) return 'экзамен';
|
||||||
|
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return 'экзамена';
|
||||||
|
return 'экзаменов';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluralCards(n: number): string {
|
||||||
|
const m10 = n % 10;
|
||||||
|
const m100 = n % 100;
|
||||||
|
if (m10 === 1 && m100 !== 11) return 'карточка';
|
||||||
|
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return 'карточки';
|
||||||
|
return 'карточек';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Строка списка: дата + бейдж статуса + пояснение (как hist-строки истории). */
|
||||||
|
function examRowHtml(e: DeckExamStats): string {
|
||||||
|
const solved = e.answered + e.unknown;
|
||||||
|
const finished = e.total > 0 && solved >= e.total;
|
||||||
|
const badge = finished
|
||||||
|
? '<span class="hist-grade grade-good">Завершён</span>'
|
||||||
|
: '<span class="hist-grade grade-easy">В процессе</span>';
|
||||||
|
const note = finished
|
||||||
|
? `результат ${e.total > 0 ? Math.round((e.answered / e.total) * 1000) / 10 : 0}%`
|
||||||
|
: `${solved} из ${e.total} ${pluralCards(e.total)}`;
|
||||||
|
const date = e.timestamp > 0 ? formatDbDateTime(e.timestamp) : 'Без даты';
|
||||||
|
return `<button class="exam-row" type="button" data-exam-id="${e.id}">`
|
||||||
|
+ `<span class="exam-row-date">${escapeHtml(date)}</span>`
|
||||||
|
+ `<span class="exam-row-meta">${badge}`
|
||||||
|
+ `<span class="exam-row-note">${escapeHtml(note)}</span></span>`
|
||||||
|
+ `</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderExamsPage(root: HTMLElement, deckId: number, nav: ExamsNav): void {
|
||||||
|
root.innerHTML = `
|
||||||
|
<button id="exams-back" class="btn btn-ghost" type="button">← Назад к колоде</button>
|
||||||
|
<section class="card" aria-labelledby="exams-title">
|
||||||
|
<h2 id="exams-title" class="card-title">Экзамены…</h2>
|
||||||
|
<p id="exams-count" class="card-hint" role="status" aria-live="polite"></p>
|
||||||
|
<div id="exams-status" class="status-bar" role="status">Загрузка экзаменов…</div>
|
||||||
|
<div id="exams-list" class="words-list" aria-live="polite"></div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button id="exams-new" class="btn btn-primary" type="button">Создать экзамен</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const backBtn = root.querySelector<HTMLButtonElement>('#exams-back')!;
|
||||||
|
const titleEl = root.querySelector<HTMLElement>('#exams-title')!;
|
||||||
|
const countEl = root.querySelector<HTMLElement>('#exams-count')!;
|
||||||
|
const statusEl = root.querySelector<HTMLElement>('#exams-status')!;
|
||||||
|
const listEl = root.querySelector<HTMLElement>('#exams-list')!;
|
||||||
|
const newBtn = root.querySelector<HTMLButtonElement>('#exams-new')!;
|
||||||
|
|
||||||
|
backBtn.addEventListener('click', () => nav.goDeck(deckId));
|
||||||
|
|
||||||
|
function setStatus(msg: string): void {
|
||||||
|
statusEl.textContent = msg;
|
||||||
|
statusEl.hidden = msg === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.addEventListener('click', (e) => {
|
||||||
|
const row = (e.target as HTMLElement).closest<HTMLButtonElement>('.exam-row');
|
||||||
|
if (!row) return;
|
||||||
|
const id = Number(row.dataset.examId);
|
||||||
|
if (Number.isFinite(id) && id > 0) nav.goExam(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
newBtn.addEventListener('click', () => {
|
||||||
|
if (newBtn.disabled) return;
|
||||||
|
newBtn.disabled = true;
|
||||||
|
newBtn.textContent = 'Создание…';
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const examId = await createExam(deckId);
|
||||||
|
nav.goExam(examId);
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`Не удалось создать экзамен: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
newBtn.disabled = false;
|
||||||
|
newBtn.textContent = 'Создать экзамен';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const has = await hasDatabaseFile();
|
||||||
|
if (!has) {
|
||||||
|
titleEl.textContent = 'Экзамены недоступны';
|
||||||
|
newBtn.disabled = true;
|
||||||
|
setStatus('Нет локальной базы — скачай сохранение на странице «Синхронизация».');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await ensureExamTables();
|
||||||
|
const deck = await getDeck(deckId);
|
||||||
|
if (!deck) {
|
||||||
|
titleEl.textContent = 'Колода не найдена';
|
||||||
|
newBtn.disabled = true;
|
||||||
|
setStatus(`Колоды с id=${deckId} нет в локальной базе.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
titleEl.textContent = deck.name || `Колода #${deck.id}`;
|
||||||
|
|
||||||
|
const wordCount = await getDeckWordCount(deck.id);
|
||||||
|
if (wordCount === 0) {
|
||||||
|
newBtn.disabled = true;
|
||||||
|
setStatus('В колоде нет слов — экзамен создать не из чего.');
|
||||||
|
} else {
|
||||||
|
setStatus('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const exams = await getDeckExams(deck.id);
|
||||||
|
countEl.textContent = exams.length === 0
|
||||||
|
? 'Экзаменов пока нет.'
|
||||||
|
: `Всего: ${exams.length} ${pluralExams(exams.length)}`;
|
||||||
|
listEl.innerHTML = exams.map(examRowHtml).join('');
|
||||||
|
} catch (err) {
|
||||||
|
titleEl.textContent = 'Экзамены недоступны';
|
||||||
|
newBtn.disabled = true;
|
||||||
|
setStatus(`Не удалось загрузить экзамены: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
+32
-12
@@ -4,13 +4,15 @@ import { renderDecksPage } from './decks-page.ts';
|
|||||||
import { renderDeckPage } from './deck-page.ts';
|
import { renderDeckPage } from './deck-page.ts';
|
||||||
import { renderHistoryPage } from './history-page.ts';
|
import { renderHistoryPage } from './history-page.ts';
|
||||||
import { renderReviewPage } from './review-page.ts';
|
import { renderReviewPage } from './review-page.ts';
|
||||||
|
import { renderExamsPage } from './exams-page.ts';
|
||||||
|
import { renderExamPage } from './exam-page.ts';
|
||||||
import { renderSyncPage } from './sync-page.ts';
|
import { renderSyncPage } from './sync-page.ts';
|
||||||
import { initWorker } from './sqlite-api.ts';
|
import { initWorker } from './sqlite-api.ts';
|
||||||
import { setupPwa } from './pwa.ts';
|
import { setupPwa } from './pwa.ts';
|
||||||
|
|
||||||
// Каркас приложения: топ-бар + бургер-меню + страницы (Главная / Колоды / Колода / Повторение / История / Синхронизация).
|
// Каркас приложения: топ-бар + бургер-меню + страницы (Главная / Колоды / Колода / Повторение / История / Экзамены / Синхронизация).
|
||||||
|
|
||||||
type Page = 'home' | 'decks' | 'deck' | 'review' | 'history' | 'sync';
|
type Page = 'home' | 'decks' | 'deck' | 'review' | 'history' | 'exams' | 'exam' | 'sync';
|
||||||
|
|
||||||
const PAGE_TITLES: Record<Page, string> = {
|
const PAGE_TITLES: Record<Page, string> = {
|
||||||
home: 'Главная',
|
home: 'Главная',
|
||||||
@@ -18,6 +20,8 @@ const PAGE_TITLES: Record<Page, string> = {
|
|||||||
deck: 'Колода',
|
deck: 'Колода',
|
||||||
review: 'Повторение',
|
review: 'Повторение',
|
||||||
history: 'История',
|
history: 'История',
|
||||||
|
exams: 'Экзамены',
|
||||||
|
exam: 'Экзамен',
|
||||||
sync: 'Синхронизация',
|
sync: 'Синхронизация',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,27 +54,43 @@ function setActiveButton(page: Page): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPage(page: Page, deckId?: number): void {
|
// renderPage(page, id): для deck/review/history/exams id = deckId, для exam — examId.
|
||||||
|
function renderPage(page: Page, id?: number): void {
|
||||||
pageTitle.textContent = PAGE_TITLES[page];
|
pageTitle.textContent = PAGE_TITLES[page];
|
||||||
// Колода / повторение / история — подразделы «Колод»: в меню подсвечиваем «Колоды».
|
// Колода / повторение / история / экзамены — подразделы «Колод»: в меню подсвечиваем «Колоды».
|
||||||
setActiveButton(page === 'deck' || page === 'review' || page === 'history' ? 'decks' : page);
|
setActiveButton(
|
||||||
|
page === 'deck' || page === 'review' || page === 'history' || page === 'exams' || page === 'exam'
|
||||||
|
? 'decks'
|
||||||
|
: page,
|
||||||
|
);
|
||||||
if (page === 'home') {
|
if (page === 'home') {
|
||||||
renderHomePage(pageRoot, {});
|
renderHomePage(pageRoot, {});
|
||||||
} else if (page === 'decks') {
|
} else if (page === 'decks') {
|
||||||
renderDecksPage(pageRoot, {
|
renderDecksPage(pageRoot, {
|
||||||
goDeck: (id) => renderPage('deck', id),
|
goDeck: (deckId) => renderPage('deck', deckId),
|
||||||
goReview: (id) => renderPage('review', id),
|
goReview: (deckId) => renderPage('review', deckId),
|
||||||
});
|
});
|
||||||
} else if (page === 'deck') {
|
} else if (page === 'deck') {
|
||||||
renderDeckPage(pageRoot, deckId ?? 0, {
|
renderDeckPage(pageRoot, id ?? 0, {
|
||||||
goDecks: () => renderPage('decks'),
|
goDecks: () => renderPage('decks'),
|
||||||
goReview: (id) => renderPage('review', id),
|
goReview: (deckId) => renderPage('review', deckId),
|
||||||
goHistory: (id) => renderPage('history', id),
|
goHistory: (deckId) => renderPage('history', deckId),
|
||||||
|
goExams: (deckId) => renderPage('exams', deckId),
|
||||||
});
|
});
|
||||||
} else if (page === 'review') {
|
} else if (page === 'review') {
|
||||||
renderReviewPage(pageRoot, deckId ?? 0, { goDecks: () => renderPage('decks') });
|
renderReviewPage(pageRoot, id ?? 0, { goDecks: () => renderPage('decks') });
|
||||||
} else if (page === 'history') {
|
} else if (page === 'history') {
|
||||||
renderHistoryPage(pageRoot, deckId ?? 0, { goDeck: (id) => renderPage('deck', id) });
|
renderHistoryPage(pageRoot, id ?? 0, { goDeck: (deckId) => renderPage('deck', deckId) });
|
||||||
|
} else if (page === 'exams') {
|
||||||
|
renderExamsPage(pageRoot, id ?? 0, {
|
||||||
|
goDeck: (deckId) => renderPage('deck', deckId),
|
||||||
|
goExam: (examId) => renderPage('exam', examId),
|
||||||
|
});
|
||||||
|
} else if (page === 'exam') {
|
||||||
|
renderExamPage(pageRoot, id ?? 0, {
|
||||||
|
goExams: (deckId) => renderPage('exams', deckId),
|
||||||
|
goDecks: () => renderPage('decks'),
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
renderSyncPage(pageRoot);
|
renderSyncPage(pageRoot);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -643,6 +643,18 @@ body.drawer-open {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Раскрытое слово — единый скруглённый блок: подсветка строки открывает только
|
||||||
|
верхние углы, плашка доп. полей — только нижние (общий радиус --radius).
|
||||||
|
Раньше у строки был общий радиус, а у плашки — своя (radius-xs), и блок
|
||||||
|
выглядел разъехавшимся. */
|
||||||
|
.word-row.open {
|
||||||
|
border-radius: var(--radius) var(--radius) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-row-wrap .word-extra-list {
|
||||||
|
border-radius: 0 0 var(--radius) var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
/* container::rounded_box: фон background.weak, радиус 2 */
|
/* container::rounded_box: фон background.weak, радиус 2 */
|
||||||
.word-extra-list {
|
.word-extra-list {
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
@@ -798,6 +810,82 @@ button.word-row-wrap:not(:disabled):hover {
|
|||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Экзамены: список + итоговый экран --- */
|
||||||
|
|
||||||
|
/* Строка списка экзаменов: кнопка во всю ширину с разделителем (как word-row). */
|
||||||
|
.exam-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-quarter);
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 6px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--bg-strong);
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-row:not(:disabled):active {
|
||||||
|
background: var(--surface-press-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-row-date {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-row-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--space-half);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Пояснение рядом с бейджем статуса — приглушённой подписью (как word-tag). */
|
||||||
|
.exam-row-note {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Тело страницы экзамена: карточка + оценки / экран результата. */
|
||||||
|
#exam-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Итоговый процент — крупно по центру (text size 28 на десктопе). */
|
||||||
|
.exam-result {
|
||||||
|
margin: var(--space) 0 0;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Итоговый бар: success = верно, danger = нет, остаток — фон bg-weak. */
|
||||||
|
.exam-bar {
|
||||||
|
display: flex;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-weak);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-bar-ok {
|
||||||
|
background: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-bar-no {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Повторение: карточка + 4 кнопки оценок ---
|
/* --- Повторение: карточка + 4 кнопки оценок ---
|
||||||
Десктоп: forward и backward на всю ширину, между ними rule::horizontal(2),
|
Десктоп: forward и backward на всю ширину, между ними rule::horizontal(2),
|
||||||
размеры текста draw_key = 36, draw_value/reading/context = 24,
|
размеры текста draw_key = 36, draw_value/reading/context = 24,
|
||||||
|
|||||||
Reference in new issue
Block a user