history display and repetition bug fix

This commit is contained in:
micialware committed 2026-09-15 16:34:13 +03:00
1 parent 36d2c0b48f
commit b5b4254d20
11 files changed
+480 -141

No files matched your search

+103 -1
View File
@@ -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
View File
@@ -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 =>
+131
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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
View File
@@ -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
View File
@@ -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
View File
@@ -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, '&#39;');
}
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)}`);
+37
View File
@@ -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 {