history display and repetition bug fix
This commit is contained in:
1 parent
36d2c0b48f
commit
b5b4254d20
11 files changed
+480
-141
No files matched your search
+103
-1
@@ -8,6 +8,7 @@
|
||||
// - states.fsrs_memory: сырое INTEGER-значение FSRS (не дата, не трогаем).
|
||||
|
||||
import { command, query } from "./sqlite-api.ts";
|
||||
import { nowSec } from "./time.ts";
|
||||
|
||||
export interface CustomField {
|
||||
id: number;
|
||||
@@ -355,7 +356,8 @@ export async function sampleDeckWordIds(
|
||||
// Тасование Фишера–Йетса: равномерная перестановка за O(n).
|
||||
for (let i = ids.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[ids[i], ids[j]] = [ids[i]!, ids[j]!];
|
||||
// NB: в RHS индексы перевёрнуты — иначе присваивание [i],[j] = [i],[j] ничего не меняет.
|
||||
[ids[i], ids[j]] = [ids[j]!, ids[i]!];
|
||||
}
|
||||
if (limit != null && limit >= 0) return ids.slice(0, limit);
|
||||
return ids;
|
||||
@@ -371,6 +373,106 @@ export async function getCustomFields(): Promise<CustomField[]> {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Текущий score слова: свежий states по паре (deck_id, word_id).
|
||||
*
|
||||
* Берём один — самый новый (ORDER BY id DESC LIMIT 1): именно он отражает
|
||||
* последнее известное состояние до открытия карточки. Нет строки — null
|
||||
* (слово в очередь попало из выборки, а states успели удалить).
|
||||
*/
|
||||
export async function getLatestStateScore(
|
||||
deckId: number,
|
||||
wordId: number,
|
||||
): Promise<number | null> {
|
||||
const rows = await query(
|
||||
"SELECT score FROM states WHERE deck_id = ? AND word_id = ? ORDER BY id DESC LIMIT 1",
|
||||
[deckId, wordId] as never[],
|
||||
);
|
||||
const r = (rows as Record<string, unknown>[])[0];
|
||||
if (!r || r["score"] == null) return null;
|
||||
const n = Number(r["score"]);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** Оценка повторения: 0 = "не получилось", 3 = "легко". */
|
||||
export type ReviewGrade = 0 | 1 | 2 | 3;
|
||||
|
||||
/**
|
||||
* Записывает ответ в history.
|
||||
*
|
||||
* - timestamp: unix-секунды (nowSec из time.ts, тот же формат что history.timestamp);
|
||||
* - fsrs: всегда 0 (false) — FSRS-шедулер подключим позже;
|
||||
* - before/after: score слова. Логики пересчёта пока нет, поэтому оба равны
|
||||
* score на момент открытия карточки (передаётся снаружи — см. review-page).
|
||||
*
|
||||
* Ничего не возвращает: id вставленной строки не нужен для UI.
|
||||
*/
|
||||
export async function recordReviewAnswer(args: {
|
||||
deckId: number;
|
||||
wordId: number;
|
||||
answer: ReviewGrade;
|
||||
before: number;
|
||||
after: number;
|
||||
}): Promise<void> {
|
||||
await command(
|
||||
"INSERT INTO history (deck_id, word_id, timestamp, answer, before, after, fsrs) VALUES (?, ?, ?, ?, ?, ?, 0)",
|
||||
[args.deckId, args.wordId, nowSec(), args.answer, args.before, args.after] as never[],
|
||||
);
|
||||
}
|
||||
|
||||
export interface DeckHistoryFilter {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
/** Сколько всего записей history у колоды (для счётчика/пагинации). */
|
||||
export async function countDeckHistory(deckId: number): Promise<number> {
|
||||
const rows = await query("SELECT COUNT(*) as cnt FROM history WHERE deck_id = ?", [
|
||||
deckId,
|
||||
] as never[]);
|
||||
const cnt = (rows[0] as Record<string, unknown> | undefined)?.["cnt"];
|
||||
return typeof cnt === "number" ? cnt : Number(cnt ?? 0);
|
||||
}
|
||||
|
||||
/** Запись истории с подписью слова (LEFT JOIN — слово могли удалить). */
|
||||
export interface DeckHistoryItem extends HistoryEntry {
|
||||
/** key слова или '' если слово удалено. */
|
||||
wordKey: string;
|
||||
/** value слова или '' если слово удалено. */
|
||||
wordValue: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* История ответов колоды: свежие первыми, пагинация limit/offset.
|
||||
* Подтягивает key/value слова одним JOIN — без N+1.
|
||||
*/
|
||||
export async function getDeckHistory(
|
||||
deckId: number,
|
||||
filter: DeckHistoryFilter = {},
|
||||
): Promise<DeckHistoryItem[]> {
|
||||
const limit = Math.max(1, Math.min(filter.limit ?? 50, 500));
|
||||
const offset = Math.max(0, filter.offset ?? 0);
|
||||
const rows = await query(
|
||||
`SELECT h.id, h.deck_id, h.word_id, h.timestamp, h.answer, h.before, h.after, h.fsrs,
|
||||
w.key as wkey, w.value as wvalue
|
||||
FROM history h LEFT JOIN words w ON w.id = h.word_id
|
||||
WHERE h.deck_id = ? ORDER BY h.timestamp DESC, h.id DESC LIMIT ? OFFSET ?`,
|
||||
[deckId, limit, offset] as never[],
|
||||
);
|
||||
return (rows as Record<string, unknown>[]).map((r) => ({
|
||||
id: Number(r["id"]),
|
||||
deck_id: Number(r["deck_id"]),
|
||||
word_id: Number(r["word_id"]),
|
||||
timestamp: Number(r["timestamp"] ?? 0),
|
||||
answer: Number(r["answer"] ?? 0),
|
||||
before: Number(r["before"] ?? 0),
|
||||
after: Number(r["after"] ?? 0),
|
||||
fsrs: Number(r["fsrs"] ?? 0),
|
||||
wordKey: r["wkey"] == null ? "" : String(r["wkey"]),
|
||||
wordValue: r["wvalue"] == null ? "" : String(r["wvalue"]),
|
||||
}));
|
||||
}
|
||||
|
||||
export interface HistoryEntry {
|
||||
id: number;
|
||||
deck_id: number;
|
||||
|
||||
+7
-1
@@ -1,4 +1,4 @@
|
||||
// Страница одной колоды: данные строки decks + количество связанных слов.
|
||||
// Страница одной колоды: данные строки decks + количество связанных слов + ссылки.
|
||||
// forward / backward / filter — редактируемые, сохраняются в локальную БД.
|
||||
// Никакого списка states здесь нет. Навигация: «← Назад к колодам» в список.
|
||||
|
||||
@@ -8,6 +8,7 @@ import { hasDatabaseFile } from './opfs.ts';
|
||||
export type DeckNav = {
|
||||
goDecks: () => void;
|
||||
goReview: (deckId: number) => void;
|
||||
goHistory: (deckId: number) => void;
|
||||
};
|
||||
|
||||
function fmtOrDash(s: string): string {
|
||||
@@ -110,6 +111,9 @@ export function renderDeckPage(root: HTMLElement, deckId: number, nav: DeckNav):
|
||||
<div class="btn-row">
|
||||
<button id="deck-review" class="btn btn-primary" type="button"${wordCount === 0 ? ' disabled' : ''}>▶ Повторять</button>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button id="deck-history" class="btn" type="button">🕘 История ответов</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const form = bodyEl.querySelector<HTMLFormElement>('#deck-form')!;
|
||||
@@ -119,8 +123,10 @@ export function renderDeckPage(root: HTMLElement, deckId: number, nav: DeckNav):
|
||||
const saveBtn = bodyEl.querySelector<HTMLButtonElement>('#deck-save')!;
|
||||
const formStatus = bodyEl.querySelector<HTMLElement>('#deck-form-status')!;
|
||||
const reviewBtn = bodyEl.querySelector<HTMLButtonElement>('#deck-review')!;
|
||||
const historyBtn = bodyEl.querySelector<HTMLButtonElement>('#deck-history')!;
|
||||
|
||||
reviewBtn.addEventListener('click', () => nav.goReview(d.id));
|
||||
historyBtn.addEventListener('click', () => nav.goHistory(d.id));
|
||||
|
||||
// Кнопка активна только при отличии от сохранённого.
|
||||
const snapshot = (): string =>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// Страница истории ответов колоды: плоский список записей history.
|
||||
// Свежие первыми, пагинация «Показать ещё». Навигация: назад на страницу колоды.
|
||||
|
||||
import { countDeckHistory, getDeck, getDeckHistory } from './db-types.ts';
|
||||
import type { DeckHistoryItem } from './db-types.ts';
|
||||
import { hasDatabaseFile } from './opfs.ts';
|
||||
import { formatDbDateTime } from './time.ts';
|
||||
|
||||
export type HistoryNav = {
|
||||
goDeck: (deckId: number) => void;
|
||||
};
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
const HIST_PAGE = 50;
|
||||
|
||||
// answer 0..3 -> подпись + класс (те же цвета, что кнопки повторения).
|
||||
const GRADE_META: ReadonlyArray<{ label: string; cls: string }> = [
|
||||
{ label: 'Не получилось', cls: 'grade-fail' },
|
||||
{ label: 'Тяжело', cls: 'grade-hard' },
|
||||
{ label: 'Хорошо', cls: 'grade-good' },
|
||||
{ label: 'Легко', cls: 'grade-easy' },
|
||||
];
|
||||
|
||||
function histRowHtml(h: DeckHistoryItem): string {
|
||||
const g = GRADE_META[h.answer] ?? { label: `Оценка ${h.answer}`, cls: '' };
|
||||
const word = h.wordKey
|
||||
? `<span class="word-key">${escapeHtml(h.wordKey)}</span>`
|
||||
+ (h.wordValue ? `<span class="word-sep"> — </span><span class="word-value">${escapeHtml(h.wordValue)}</span>` : '')
|
||||
: `<span class="empty-val">Слово #${h.word_id} (удалено)</span>`;
|
||||
const shift = h.before === h.after ? `${h.before}` : `${h.before}→${h.after}`;
|
||||
return `<div class="hist-row-wrap">`
|
||||
+ `<div class="hist-main">${word}</div>`
|
||||
+ `<div class="hist-meta"><span class="hist-grade ${g.cls}">${escapeHtml(g.label)}</span>`
|
||||
+ `<span class="word-tag">${escapeHtml(formatDbDateTime(h.timestamp))} · score ${escapeHtml(shift)}</span></div>`
|
||||
+ `</div>`;
|
||||
}
|
||||
|
||||
export function renderHistoryPage(root: HTMLElement, deckId: number, nav: HistoryNav): void {
|
||||
root.innerHTML = `
|
||||
<button id="hist-back" class="btn btn-ghost" type="button">← Назад к колоде</button>
|
||||
<section class="card" aria-labelledby="hist-title">
|
||||
<h2 id="hist-title" class="card-title">История…</h2>
|
||||
<p id="hist-count" class="card-hint" role="status" aria-live="polite"></p>
|
||||
<div id="hist-status" class="status-bar" role="status">Загрузка истории…</div>
|
||||
<div id="hist-list" class="words-list" aria-live="polite"></div>
|
||||
<div class="btn-row"><button id="hist-more" class="btn" type="button" hidden>Показать ещё</button></div>
|
||||
</section>
|
||||
`;
|
||||
|
||||
const backBtn = root.querySelector<HTMLButtonElement>('#hist-back')!;
|
||||
const titleEl = root.querySelector<HTMLElement>('#hist-title')!;
|
||||
const countEl = root.querySelector<HTMLElement>('#hist-count')!;
|
||||
const statusEl = root.querySelector<HTMLElement>('#hist-status')!;
|
||||
const listEl = root.querySelector<HTMLElement>('#hist-list')!;
|
||||
const moreBtn = root.querySelector<HTMLButtonElement>('#hist-more')!;
|
||||
|
||||
backBtn.addEventListener('click', () => nav.goDeck(deckId));
|
||||
|
||||
function setStatus(msg: string): void {
|
||||
statusEl.textContent = msg;
|
||||
statusEl.hidden = msg === '';
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
let offset = 0;
|
||||
let loading = false;
|
||||
|
||||
function refreshMore(): void {
|
||||
const left = total - offset;
|
||||
moreBtn.hidden = left <= 0;
|
||||
if (left > 0) moreBtn.textContent = `Показать ещё (${left} осталось)`;
|
||||
}
|
||||
|
||||
async function loadPage(): Promise<void> {
|
||||
if (loading || offset >= total) return;
|
||||
loading = true;
|
||||
moreBtn.disabled = true;
|
||||
moreBtn.textContent = 'Загрузка…';
|
||||
try {
|
||||
const items = await getDeckHistory(deckId, { limit: HIST_PAGE, offset });
|
||||
offset += items.length;
|
||||
listEl.insertAdjacentHTML('beforeend', items.map(histRowHtml).join(''));
|
||||
countEl.textContent = `Показано ${offset} из ${total}`;
|
||||
} catch (err) {
|
||||
setStatus(`Не удалось загрузить историю: ${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
moreBtn.disabled = false;
|
||||
refreshMore();
|
||||
}
|
||||
}
|
||||
|
||||
moreBtn.addEventListener('click', () => void loadPage());
|
||||
|
||||
void (async () => {
|
||||
const has = await hasDatabaseFile();
|
||||
if (!has) {
|
||||
titleEl.textContent = 'История недоступна';
|
||||
setStatus('Нет локальной базы — скачай сохранение на странице «Синхронизация».');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const deck = await getDeck(deckId);
|
||||
if (!deck) {
|
||||
titleEl.textContent = 'Колода не найдена';
|
||||
setStatus(`Колоды с id=${deckId} нет в локальной базе.`);
|
||||
return;
|
||||
}
|
||||
titleEl.textContent = `🕘 ${deck.name || `Колода #${deck.id}`}`;
|
||||
total = await countDeckHistory(deck.id);
|
||||
if (total === 0) {
|
||||
countEl.textContent = 'Ответов пока нет — начни повторение.';
|
||||
setStatus('');
|
||||
return;
|
||||
}
|
||||
setStatus('');
|
||||
await loadPage();
|
||||
} catch (err) {
|
||||
titleEl.textContent = 'История недоступна';
|
||||
setStatus(`Не удалось загрузить историю: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
})();
|
||||
}
|
||||
+1
-1
@@ -10,7 +10,7 @@ export type HomeNav = {
|
||||
goSync?: () => void;
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
|
||||
+9
-4
@@ -2,19 +2,21 @@ import './style.css';
|
||||
import { renderHomePage } from './home-page.ts';
|
||||
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 { renderSyncPage } from './sync-page.ts';
|
||||
import { initWorker } from './sqlite-api.ts';
|
||||
|
||||
// Каркас приложения: топ-бар + бургер-меню + страницы (Главная / Колоды / Колода / Повторение / Синхронизация).
|
||||
// Каркас приложения: топ-бар + бургер-меню + страницы (Главная / Колоды / Колода / Повторение / История / Синхронизация).
|
||||
|
||||
type Page = 'home' | 'decks' | 'deck' | 'review' | 'sync';
|
||||
type Page = 'home' | 'decks' | 'deck' | 'review' | 'history' | 'sync';
|
||||
|
||||
const PAGE_TITLES: Record<Page, string> = {
|
||||
home: 'Главная',
|
||||
decks: 'Колоды',
|
||||
deck: 'Колода',
|
||||
review: 'Повторение',
|
||||
history: 'История',
|
||||
sync: 'Синхронизация',
|
||||
};
|
||||
|
||||
@@ -49,8 +51,8 @@ function setActiveButton(page: Page): void {
|
||||
|
||||
function renderPage(page: Page, deckId?: number): void {
|
||||
pageTitle.textContent = PAGE_TITLES[page];
|
||||
// Страницы колоды и повторения — подразделы «Колод»: в меню подсвечиваем «Колоды».
|
||||
setActiveButton(page === 'deck' || page === 'review' ? 'decks' : page);
|
||||
// Колода / повторение / история — подразделы «Колод»: в меню подсвечиваем «Колоды».
|
||||
setActiveButton(page === 'deck' || page === 'review' || page === 'history' ? 'decks' : page);
|
||||
if (page === 'home') {
|
||||
renderHomePage(pageRoot, {});
|
||||
} else if (page === 'decks') {
|
||||
@@ -62,9 +64,12 @@ function renderPage(page: Page, deckId?: number): void {
|
||||
renderDeckPage(pageRoot, deckId ?? 0, {
|
||||
goDecks: () => renderPage('decks'),
|
||||
goReview: (id) => renderPage('review', id),
|
||||
goHistory: (id) => renderPage('history', id),
|
||||
});
|
||||
} else if (page === 'review') {
|
||||
renderReviewPage(pageRoot, deckId ?? 0, { goDecks: () => renderPage('decks') });
|
||||
} else if (page === 'history') {
|
||||
renderHistoryPage(pageRoot, deckId ?? 0, { goDeck: (id) => renderPage('deck', id) });
|
||||
} else {
|
||||
renderSyncPage(pageRoot);
|
||||
}
|
||||
|
||||
+63
-20
@@ -1,17 +1,19 @@
|
||||
// Страница повторения колоды: карточка за карточкой по случайной выборке.
|
||||
// Лицевая сторона — поля из deck.forward, по тапу дорисовывается deck.backward.
|
||||
// Внизу 4 кнопки самооценки: "не получилось" / "тяжело" / "хорошо" / "легко".
|
||||
// Пока без записи ответов: только навигация по очереди (история — следующий шаг).
|
||||
// Ответ пишется в history при нажатии оценки: before/after = score до открытия.
|
||||
|
||||
import {
|
||||
getCustomFields,
|
||||
getDeck,
|
||||
getLatestStateScore,
|
||||
getWordsByIds,
|
||||
recordReviewAnswer,
|
||||
renderCardSide,
|
||||
sampleDeckWordIds,
|
||||
wordFieldValues,
|
||||
} from './db-types.ts';
|
||||
import type { CustomField, Word } from './db-types.ts';
|
||||
import type { CustomField, ReviewGrade, Word } from './db-types.ts';
|
||||
import { hasDatabaseFile } from './opfs.ts';
|
||||
|
||||
export type ReviewNav = {
|
||||
@@ -27,12 +29,13 @@ function escapeHtml(s: string): string {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
const GRADES = [
|
||||
{ label: '😞 Не получилось', cls: 'grade-fail' },
|
||||
{ label: '😟 Тяжело', cls: 'grade-hard' },
|
||||
{ label: '🙂 Хорошо', cls: 'grade-good' },
|
||||
{ label: '🤩 Легко', cls: 'grade-easy' },
|
||||
] as const;
|
||||
// Порядок = значение answer в history: 0 — "не получилось", 3 — "легко".
|
||||
const GRADES: ReadonlyArray<{ value: ReviewGrade; label: string; cls: string }> = [
|
||||
{ value: 0, label: '😞 Не получилось', cls: 'grade-fail' },
|
||||
{ value: 1, label: '😟 Тяжело', cls: 'grade-hard' },
|
||||
{ value: 2, label: '🙂 Хорошо', cls: 'grade-good' },
|
||||
{ value: 3, label: '🤩 Легко', cls: 'grade-easy' },
|
||||
];
|
||||
|
||||
export function renderReviewPage(root: HTMLElement, deckId: number, nav: ReviewNav): void {
|
||||
root.innerHTML = `
|
||||
@@ -66,6 +69,11 @@ export function renderReviewPage(root: HTMLElement, deckId: number, nav: ReviewN
|
||||
let backwardTpl = '';
|
||||
let pos = 0;
|
||||
let revealed = false;
|
||||
// score слова на момент открытия карточки (до ответа). null = ещё грузится/нет states.
|
||||
let beforeScore: number | null = null;
|
||||
// Защита от гонок: paint асинхронный (ждёт score), клики не должны мешать.
|
||||
let paintSeq = 0;
|
||||
let answering = false;
|
||||
|
||||
function sideHtml(values: string[], cls: string): string {
|
||||
if (values.length === 0) {
|
||||
@@ -77,14 +85,28 @@ export function renderReviewPage(root: HTMLElement, deckId: number, nav: ReviewN
|
||||
}
|
||||
|
||||
|
||||
function paint(): void {
|
||||
const w = words.get(queue[pos]!);
|
||||
async function paint(): Promise<void> {
|
||||
const mySeq = ++paintSeq;
|
||||
const wordId = queue[pos]!;
|
||||
const w = words.get(wordId);
|
||||
if (!w) {
|
||||
bodyEl.innerHTML = '';
|
||||
progressEl.textContent = '';
|
||||
setStatus('Слово пропало из базы — пропускаю.');
|
||||
return;
|
||||
}
|
||||
// score ДО открытия: свежий states по (deck_id, word_id). Нет строки — считаем 1
|
||||
// (дефолт схемы states.score DEFAULT 1). Ошибка чтения — тоже 1, но с пометкой.
|
||||
beforeScore = null;
|
||||
let scoreNote = '';
|
||||
try {
|
||||
beforeScore = (await getLatestStateScore(deckId, wordId)) ?? 1;
|
||||
} catch {
|
||||
beforeScore = 1;
|
||||
scoreNote = ' (score прочитать не удалось — пишу before/after=1)';
|
||||
}
|
||||
if (mySeq !== paintSeq) return; // пока ждали score, очередь уже ушла дальше
|
||||
answering = false;
|
||||
const values = wordFieldValues(w, fields);
|
||||
const front = renderCardSide(forwardTpl, values);
|
||||
const back = revealed ? renderCardSide(backwardTpl, values) : null;
|
||||
@@ -96,7 +118,7 @@ export function renderReviewPage(root: HTMLElement, deckId: number, nav: ReviewN
|
||||
// Оценки — только после открытия (вместе с backward), до этого лишь тап по карточке.
|
||||
const gradesHtml = revealed
|
||||
? `<div class="review-grades" role="group" aria-label="Оценка повторения">
|
||||
${GRADES.map((g) => `<button class="btn review-grade ${g.cls}" type="button">${g.label}</button>`).join('')}
|
||||
${GRADES.map((g) => `<button class="btn review-grade ${g.cls}" type="button" data-grade="${g.value}">${g.label}</button>`).join('')}
|
||||
</div>`
|
||||
: '';
|
||||
bodyEl.innerHTML = `
|
||||
@@ -111,25 +133,46 @@ export function renderReviewPage(root: HTMLElement, deckId: number, nav: ReviewN
|
||||
bodyEl.querySelector<HTMLButtonElement>('#review-card')!.addEventListener('click', () => {
|
||||
if (!revealed) {
|
||||
revealed = true;
|
||||
paint();
|
||||
void paint();
|
||||
}
|
||||
});
|
||||
bodyEl.querySelectorAll<HTMLButtonElement>('.review-grade').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
// Запись ответов (history/states) — следующим шагом, пока просто дальше.
|
||||
pos += 1;
|
||||
revealed = false;
|
||||
if (pos >= queue.length) paintDone();
|
||||
else paint();
|
||||
const grade = Number(btn.dataset.grade) as ReviewGrade;
|
||||
void answer(grade, scoreNote);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Нажатие оценки: пишем строку в history и идём дальше. before/after — score
|
||||
// до открытия (пересчёт по ответу добавим позже, пока оба равны beforeScore).
|
||||
async function answer(grade: ReviewGrade, scoreNote: string): Promise<void> {
|
||||
if (answering) return;
|
||||
answering = true;
|
||||
const wordId = queue[pos]!;
|
||||
const before = beforeScore ?? 1;
|
||||
const btns = [...bodyEl.querySelectorAll<HTMLButtonElement>('.review-grade')];
|
||||
for (const b of btns) b.disabled = true;
|
||||
try {
|
||||
await recordReviewAnswer({ deckId, wordId, answer: grade, before, after: before });
|
||||
} catch (err) {
|
||||
setStatus(`Ответ не записался: ${err instanceof Error ? err.message : String(err)}${scoreNote}`);
|
||||
answering = false;
|
||||
for (const b of btns) b.disabled = false;
|
||||
return;
|
||||
}
|
||||
pos += 1;
|
||||
revealed = false;
|
||||
beforeScore = null;
|
||||
if (pos >= queue.length) paintDone();
|
||||
else await paint();
|
||||
}
|
||||
|
||||
function paintDone(): void {
|
||||
progressEl.textContent = `Готово: ${queue.length} из ${queue.length}`;
|
||||
setStatus('');
|
||||
bodyEl.innerHTML = `
|
||||
<p class="card-hint">Все карточки сессии просмотрены. Запись оценок — следующим шагом.</p>
|
||||
<p class="card-hint">Все карточки сессии просмотрены, ответы записаны в history.</p>
|
||||
<div class="btn-row">
|
||||
<button id="review-again" class="btn btn-primary" type="button">🔀 Перемешать заново</button>
|
||||
</div>
|
||||
@@ -151,7 +194,7 @@ export function renderReviewPage(root: HTMLElement, deckId: number, nav: ReviewN
|
||||
return;
|
||||
}
|
||||
words = await getWordsByIds(queue);
|
||||
paint();
|
||||
void paint();
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
@@ -182,7 +225,7 @@ export function renderReviewPage(root: HTMLElement, deckId: number, nav: ReviewN
|
||||
words = await getWordsByIds(queue);
|
||||
pos = 0;
|
||||
revealed = false;
|
||||
paint();
|
||||
await paint();
|
||||
} catch (err) {
|
||||
titleEl.textContent = 'Повторение недоступно';
|
||||
setStatus(`Не удалось загрузить карточки: ${err instanceof Error ? err.message : String(err)}`);
|
||||
|
||||
@@ -565,6 +565,43 @@ button.word-row-wrap:not(:disabled):hover {
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
|
||||
/* --- История ответов колоды: плоские строки, цветы оценок --- */
|
||||
|
||||
.hist-row-wrap {
|
||||
padding: 9px 4px;
|
||||
border-bottom: 1px solid #eee9fb;
|
||||
}
|
||||
|
||||
.hist-row-wrap:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.hist-main {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.hist-meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.hist-grade {
|
||||
flex: none;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hist-grade.grade-fail,
|
||||
.hist-grade.grade-hard,
|
||||
.hist-grade.grade-good,
|
||||
.hist-grade.grade-easy {
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* --- Повторение: карточка + 4 кнопки оценок --- */
|
||||
|
||||
#review-body {
|
||||
|
||||
Reference in new issue
Block a user