first exam iteration

This commit is contained in:
micialware committed 2026-10-05 22:25:59 +03:00
1 parent 17731364bd
commit a81579d4fc
7 files changed
+715 -12

No files matched your search

+23
View File
@@ -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
View File
@@ -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[]);
}
+6
View File
@@ -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 =>
+275
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Оценки экзамена: значение = 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)}`);
}
})();
}
+147
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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
View File
@@ -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);
} }
+88
View File
@@ -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,