diff --git a/lang.rs b/lang.rs index 898df96..e3eb572 100644 --- a/lang.rs +++ b/lang.rs @@ -79,7 +79,9 @@ impl CardStatistics { self.score = MAX_SCORE } self.last_open = Utc::now(); - } + updateCardStat(self.id, self.score, last_open).catch((err) => + console.error('[Training] Ошибка сохранения last_opened:', err), + ); } pub fn calculated_score(&self) -> f32 { let time = Utc::now() - self.last_open; diff --git a/src/algorithm.ts b/src/algorithm.ts index 95b2055..7f45235 100644 --- a/src/algorithm.ts +++ b/src/algorithm.ts @@ -55,7 +55,9 @@ export function calculatedScore(stat: CardStatistics): number { const now = Date.now(); const diffMs = now - stat.last_open; const days = diffMs / (1000 * 60 * 60 * 24); + console.log(`Days ${days}`) const multiplier = Math.pow(FADE_PER_DAY, Math.max(0, days)); + console.log(`multiplier ${multiplier}`) const v = stat.score * multiplier; return v; } diff --git a/src/history-view.ts b/src/history-view.ts new file mode 100644 index 0000000..4212cb6 --- /dev/null +++ b/src/history-view.ts @@ -0,0 +1,118 @@ +import { getHistory } from './history.ts'; +import type { RepetitionRecord } from './history.ts'; +import { execute } from './sqlite-manager.ts'; + +export interface HistoryViewCallbacks { + onBack: () => void; +} + +/** + * Показывает страницу истории повторений для указанной колоды. + */ +export async function renderHistoryView( + container: HTMLElement, + setId: number, + callbacks: HistoryViewCallbacks, +): Promise { + container.innerHTML = ` +
+ + Загрузка... +
+
+
Загрузка истории...
+
+ `; + + document.getElementById('btnHistoryBack')?.addEventListener('click', () => { + callbacks.onBack(); + }); + + try { + const records = await getHistory(setId, 2000); + + // Получаем слова для отображения + const wordIds = [...new Set(records.map((r) => r.word_id))]; + const wordMap = await getWordMap(wordIds); + + const headerSpan = container.querySelector('.history-header span'); + if (headerSpan) { + headerSpan.textContent = `записей: ${records.length}`; + } + + const contentEl = document.getElementById('history-content'); + if (!contentEl) return; + + if (records.length === 0) { + contentEl.innerHTML = '
История пуста
'; + return; + } + + contentEl.innerHTML = ''; + + for (const record of records) { + const word = wordMap.get(record.word_id); + const wordText = word ? word : `#${record.word_id}`; + const date = new Date(record.timestamp); + const dateStr = date.toLocaleString('ru-RU', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + + const modeLabels: Record = { + easy: '🟢 Легко', + ok: '🟡 Нормально', + hard: '🟠 Тяжело', + none: '🔴 Не знаю', + }; + + const row = document.createElement('div'); + row.className = 'history-row'; + row.innerHTML = ` +
${escapeHtml(wordText)}
+
${modeLabels[record.mode] || record.mode}
+
+ ${record.score_before.toFixed(1)} + + ${record.score_after.toFixed(1)} +
+
${dateStr}
+ `; + contentEl.appendChild(row); + } + } catch (err) { + console.error('[History] Ошибка загрузки:', err); + const contentEl = document.getElementById('history-content'); + if (contentEl) { + contentEl.innerHTML = '
Ошибка загрузки истории
'; + } + } +} + +async function getWordMap(wordIds: number[]): Promise> { + const map = new Map(); + if (wordIds.length === 0) return map; + + try { + const placeholders = wordIds.map(() => '?').join(','); + const rows: any[] = await execute( + `SELECT id, key FROM words WHERE id IN (${placeholders})`, + wordIds, + ); + for (const row of rows) { + // rows — массив массивов [id, key] + map.set(row[0], row[1]); + } + } catch { + // ignore + } + return map; +} + +function escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} \ No newline at end of file diff --git a/src/history.ts b/src/history.ts new file mode 100644 index 0000000..91e0c2d --- /dev/null +++ b/src/history.ts @@ -0,0 +1,197 @@ +// ──── История повторений (IndexedDB) ──── +// Каждая колода хранит до 2000 последних записей. + +const DB_NAME = 'JapLearnHistory'; +const DB_VERSION = 1; + +export interface RepetitionRecord { + id?: number; // автоинкремент + set_id: number; + word_id: number; + timestamp: number; // unix ms + mode: string; // easy | ok | hard | none + score_before: number; + score_after: number; +} + +let db: IDBDatabase | null = null; + +function openDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = () => { + const database = request.result; + + // Создаём store для каждой колоды динамически при первой записи, + // но для простоты сделаем один store с индексом по set_id + if (!database.objectStoreNames.contains('repetitions')) { + const store = database.createObjectStore('repetitions', { + keyPath: 'id', + autoIncrement: true, + }); + store.createIndex('set_id', 'set_id', { unique: false }); + store.createIndex('timestamp', 'timestamp', { unique: false }); + } + }; + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + reject(new Error('Не удалось открыть IndexedDB')); + }; + }); +} + +async function getDB(): Promise { + if (db) return db; + db = await openDB(); + return db; +} + +/** + * Добавляет запись о повторении в историю. + * После добавления удаляет самые старые записи, если превышен лимит (2000 на колоду). + */ +export async function addRepetition(record: Omit): Promise { + const database = await getDB(); + + return new Promise((resolve, reject) => { + const tx = database.transaction('repetitions', 'readwrite'); + const store = tx.objectStore('repetitions'); + + // Добавляем запись + const addRequest = store.add(record); + + addRequest.onsuccess = () => { + // После добавления проверяем лимит + trimHistory(record.set_id, 2000); + resolve(); + }; + + addRequest.onerror = () => { + reject(new Error('Ошибка записи в историю')); + }; + }); +} + +/** + * Удаляет самые старые записи для указанной колоды, оставляя не более limit. + */ +async function trimHistory(setId: number, limit: number): Promise { + const database = await getDB(); + + return new Promise((resolve, reject) => { + const tx = database.transaction('repetitions', 'readwrite'); + const store = tx.objectStore('repetitions'); + const index = store.index('set_id'); + const range = IDBKeyRange.only(setId); + + const countRequest = index.count(range); + + countRequest.onsuccess = () => { + const count = countRequest.result; + + if (count <= limit) { + resolve(); + return; + } + + // Получаем все ID для этой колоды, сортируем по timestamp + const allRequest = index.getAllKeys(range); + + allRequest.onsuccess = () => { + // Получаем записи с их id и timestamp для сортировки + const cursorRequest = index.openCursor(range); + const entries: { id: number; timestamp: number }[] = []; + + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + if (cursor) { + entries.push({ id: cursor.value.id, timestamp: cursor.value.timestamp }); + cursor.continue(); + } else { + // Сортируем по timestamp (старые первые) + entries.sort((a, b) => a.timestamp - b.timestamp); + + // Сколько нужно удалить + const toDelete = entries.length - limit; + + for (let i = 0; i < toDelete; i++) { + store.delete(entries[i].id); + } + + resolve(); + } + }; + }; + }; + + countRequest.onerror = () => reject(new Error('Ошибка подсчёта записей')); + }); +} + +/** + * Получает историю повторений для указанной колоды, отсортированную по времени (новые первые). + */ +export async function getHistory(setId: number, limit = 2000): Promise { + const database = await getDB(); + + return new Promise((resolve, reject) => { + const tx = database.transaction('repetitions', 'readonly'); + const store = tx.objectStore('repetitions'); + const index = store.index('set_id'); + const range = IDBKeyRange.only(setId); + + const records: RepetitionRecord[] = []; + + const cursorRequest = index.openCursor(range, 'prev'); // новые первые + + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + if (cursor && records.length < limit) { + records.push(cursor.value); + cursor.continue(); + } else { + resolve(records); + } + }; + + cursorRequest.onerror = () => { + reject(new Error('Ошибка чтения истории')); + }; + }); +} + +/** + * Получает список ID колод, для которых есть история. + */ +export async function getHistorySetIds(): Promise { + const database = await getDB(); + + return new Promise((resolve, reject) => { + const tx = database.transaction('repetitions', 'readonly'); + const store = tx.objectStore('repetitions'); + const index = store.index('set_id'); + + const setIdSet = new Set(); + + const cursorRequest = index.openCursor(); + + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + if (cursor) { + setIdSet.add(cursor.value.set_id); + cursor.continue(); + } else { + resolve(Array.from(setIdSet)); + } + }; + + cursorRequest.onerror = () => { + reject(new Error('Ошибка чтения ID колод')); + }; + }); +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index ebcc19d..d8201d9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,6 +8,7 @@ import { SetOrderMode } from './algorithm.ts'; import { TrainingPage } from './training.ts'; import type { WordItem } from './training.ts'; import { renderWordView } from './word-view.ts'; +import { renderHistoryView } from './history-view.ts'; import type { Word } from './db-manager.ts'; // ----- Конфигурация ----- const API_BASE_URL = 'https://learning.micialware.ru'; @@ -16,7 +17,7 @@ const SYNC_ID_STORAGE_KEY = 'japlearn_sync_id'; // ----- Элементы UI ----- const container = document.getElementById('page-container') as HTMLElement; const navBtns = document.querySelectorAll('.nav-btn'); -let currentPage: 'words' | 'sets' | 'training' | 'wordview' = 'words'; +let currentPage: 'words' | 'sets' | 'training' | 'wordview' | 'history' = 'words'; // ----- Вспомогательные функции ----- @@ -280,7 +281,7 @@ async function startTraining(setId: number) { const settings = { id: set.id, - name: set.namclinee, + name: set.name, forward: set.forward, backward: set.backward, filter: set.filter, diff --git a/src/training.ts b/src/training.ts index 57f2418..d7386ec 100644 --- a/src/training.ts +++ b/src/training.ts @@ -1,6 +1,7 @@ import { CardSet, calculatedScore } from './algorithm.ts'; import type { CardSetSettings, CardStatistics, WordData, WordOpenMode } from './algorithm.ts'; import { updateCardStat } from './db-manager.ts'; +import { addRepetition } from './history.ts'; export interface WordItem { id: number; @@ -118,13 +119,13 @@ export class TrainingPage { this.updateCounter(); const stat = this.cardSet.set[this.cardSet.currentWordIndex!]; - if (stat) { - const now = Math.floor(Date.now() / 1000); - stat.last_open = Date.now(); - updateCardStat(stat.id, stat.score, now).catch((err) => - console.error('[Training] Ошибка сохранения last_opened:', err), - ); - } + // if (stat) { + // const now = Math.floor(Date.now() / 1000); + // stat.last_open = Date.now(); + // updateCardStat(stat.id, stat.score, now).catch((err) => + // console.error('[Training] Ошибка сохранения last_opened:', err), + // ); + // } this.renderSide(word, this.forwardFields, this.backwardFields, false, stat); this.renderActions(false); @@ -222,12 +223,15 @@ export class TrainingPage { } private async vote(mode: WordOpenMode): Promise { - this.cardSet.open(mode); - const current = this.cardSet.currentWordIndex; if (current === null) return; const stat = this.cardSet.set[current]; + // Сохраняем score до ответа + const scoreBefore = stat ? Math.round(calculatedScore(stat) * 10) / 10 : 0; + + this.cardSet.open(mode); + if (stat) { try { await updateCardStat(stat.id, stat.score, Math.floor(stat.last_open / 1000)); @@ -236,6 +240,22 @@ export class TrainingPage { } } + // Сохраняем в историю IndexedDB + if (stat) { + const scoreAfter = Math.round(calculatedScore(stat) * 10) / 10; + const word = this.words[current]; + if (word) { + addRepetition({ + set_id: stat.set_id, + word_id: word.id, + timestamp: Date.now(), + mode, + score_before: scoreBefore, + score_after: scoreAfter, + }).catch((err) => console.error('[Training] Ошибка сохранения истории:', err)); + } + } + // Бесконечная тренировка — всегда переходим к следующей карте this.renderCard(); }