diff --git a/dbschema.sql b/dbschema.sql index 93b0913..e06371b 100644 --- a/dbschema.sql +++ b/dbschema.sql @@ -78,3 +78,26 @@ create table states 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 +); + diff --git a/src/db-types.ts b/src/db-types.ts index cddc619..2cf7247 100644 --- a/src/db-types.ts +++ b/src/db-types.ts @@ -500,3 +500,147 @@ export interface WordState { /** Дни от epoch (целые сутки с 1970-01-01). */ 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 { + 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 { + 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[]).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 { + const created = await query( + "INSERT INTO exams (timestamp, deck_id) VALUES (?, ?) RETURNING id", + [nowSec(), deckId] as never[], + ); + const examId = Number((created[0] as Record | 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 { + const rows = await query( + "SELECT id, timestamp, deck_id FROM exams WHERE id = ? LIMIT 1", + [examId] as never[], + ); + const r = (rows as Record[])[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 { + 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[]).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 { + await command("UPDATE exam_entries SET status = ? WHERE id = ?", [ + status, + entryId, + ] as never[]); +} diff --git a/src/deck-page.ts b/src/deck-page.ts index 9cc1d08..aa90cee 100644 --- a/src/deck-page.ts +++ b/src/deck-page.ts @@ -9,6 +9,7 @@ export type DeckNav = { goDecks: () => void; goReview: (deckId: number) => void; goHistory: (deckId: number) => void; + goExams: (deckId: number) => void; }; function fmtOrDash(s: string): string { @@ -114,6 +115,9 @@ export function renderDeckPage(root: HTMLElement, deckId: number, nav: DeckNav):
+
+ +
`; const form = bodyEl.querySelector('#deck-form')!; @@ -124,9 +128,11 @@ export function renderDeckPage(root: HTMLElement, deckId: number, nav: DeckNav): const formStatus = bodyEl.querySelector('#deck-form-status')!; const reviewBtn = bodyEl.querySelector('#deck-review')!; const historyBtn = bodyEl.querySelector('#deck-history')!; + const examsBtn = bodyEl.querySelector('#deck-exams')!; reviewBtn.addEventListener('click', () => nav.goReview(d.id)); historyBtn.addEventListener('click', () => nav.goHistory(d.id)); + examsBtn.addEventListener('click', () => nav.goExams(d.id)); // Кнопка активна только при отличии от сохранённого. const snapshot = (): string => diff --git a/src/exam-page.ts b/src/exam-page.ts new file mode 100644 index 0000000..1bc4dd3 --- /dev/null +++ b/src/exam-page.ts @@ -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, '''); +} + +// Оценки экзамена: значение = 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 = ` + +
+

Экзамен…

+

+
Загрузка экзамена…
+
+
+ `; + + const backBtn = root.querySelector('#exam-back')!; + const titleEl = root.querySelector('#exam-title')!; + const progressEl = root.querySelector('#exam-progress')!; + const statusEl = root.querySelector('#exam-status')!; + const bodyEl = root.querySelector('#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(); + /** Очередь сессии: 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 `
—
`; + } + return `
` + + values.map((v) => `

${escapeHtml(v)}

`).join('') + + `
`; + } + + 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 { + 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 + ? `

Тап — показать ответ

` + : sideHtml(back ?? [], 'review-back-side'); + const gradesHtml = revealed + ? `
+ ${EXAM_GRADES.map((g) => ``).join('')} +
` + : ''; + bodyEl.innerHTML = ` + + ${gradesHtml} + `; + bodyEl.querySelector('#exam-card')!.addEventListener('click', () => { + if (!revealed) { + revealed = true; + void paint(); + } + }); + bodyEl.querySelectorAll('.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 { + if (answering) return; + const entryId = queue[pos]; + const entry = entryId != null ? entryById(entryId) : undefined; + if (!entry) return; + answering = true; + const btns = [...bodyEl.querySelectorAll('.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 `
Слово #${e.word_id} (удалено)
`; + } + return `
` + + `
${escapeHtml(w.key)}` + + (w.value ? ` — ${escapeHtml(w.value)}` : '') + + `
`; + }).join(''); + return `

${title} (${list.length})

` + + `
${rows}
`; + }; + + bodyEl.innerHTML = ` +

Итоговый результат: ${result}%

+ + ${listHtml('Правильно', answeredList)} + ${listHtml('Не получилось', unknownList)} +
+ +
+ `; + bodyEl.querySelector('#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)}`); + } + })(); +} diff --git a/src/exams-page.ts b/src/exams-page.ts new file mode 100644 index 0000000..e1f65c1 --- /dev/null +++ b/src/exams-page.ts @@ -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, '''); +} + +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 + ? 'Завершён' + : 'В процессе'; + 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 ``; +} + +export function renderExamsPage(root: HTMLElement, deckId: number, nav: ExamsNav): void { + root.innerHTML = ` + +
+

Экзамены…

+

+
Загрузка экзаменов…
+
+
+ +
+
+ `; + + const backBtn = root.querySelector('#exams-back')!; + const titleEl = root.querySelector('#exams-title')!; + const countEl = root.querySelector('#exams-count')!; + const statusEl = root.querySelector('#exams-status')!; + const listEl = root.querySelector('#exams-list')!; + const newBtn = root.querySelector('#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('.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)}`); + } + })(); +} diff --git a/src/main.ts b/src/main.ts index 9b7c6ce..466e393 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,13 +4,15 @@ import { renderDecksPage } from './decks-page.ts'; import { renderDeckPage } from './deck-page.ts'; import { renderHistoryPage } from './history-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 { initWorker } from './sqlite-api.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 = { home: 'Главная', @@ -18,6 +20,8 @@ const PAGE_TITLES: Record = { deck: 'Колода', review: 'Повторение', history: 'История', + exams: 'Экзамены', + exam: 'Экзамен', 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]; - // Колода / повторение / история — подразделы «Колод»: в меню подсвечиваем «Колоды». - 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') { renderHomePage(pageRoot, {}); } else if (page === 'decks') { renderDecksPage(pageRoot, { - goDeck: (id) => renderPage('deck', id), - goReview: (id) => renderPage('review', id), + goDeck: (deckId) => renderPage('deck', deckId), + goReview: (deckId) => renderPage('review', deckId), }); } else if (page === 'deck') { - renderDeckPage(pageRoot, deckId ?? 0, { + renderDeckPage(pageRoot, id ?? 0, { goDecks: () => renderPage('decks'), - goReview: (id) => renderPage('review', id), - goHistory: (id) => renderPage('history', id), + goReview: (deckId) => renderPage('review', deckId), + goHistory: (deckId) => renderPage('history', deckId), + goExams: (deckId) => renderPage('exams', deckId), }); } else if (page === 'review') { - renderReviewPage(pageRoot, deckId ?? 0, { goDecks: () => renderPage('decks') }); + renderReviewPage(pageRoot, id ?? 0, { goDecks: () => renderPage('decks') }); } 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 { renderSyncPage(pageRoot); } diff --git a/src/style.css b/src/style.css index ec417bf..946fec4 100644 --- a/src/style.css +++ b/src/style.css @@ -643,6 +643,18 @@ body.drawer-open { 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 */ .word-extra-list { margin: 0 0 10px; @@ -798,6 +810,82 @@ button.word-row-wrap:not(:disabled):hover { 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 кнопки оценок --- Десктоп: forward и backward на всю ширину, между ними rule::horizontal(2), размеры текста draw_key = 36, draw_value/reading/context = 24,