fix incorrect score setup

This commit is contained in:
2026-07-03 13:28:26 +03:00
parent 45ff877017
commit 15c3ec2018
6 changed files with 352 additions and 12 deletions
+3 -1
View File
@@ -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;
+2
View File
@@ -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;
}
+118
View File
@@ -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<void> {
container.innerHTML = `
<div class="history-header">
<button id="btnHistoryBack" class="btn" style="flex:none; padding:0.4rem 0.8rem;">← Назад</button>
<span style="font-size:0.85rem;color:var(--text);">Загрузка...</span>
</div>
<div class="history-card">
<div id="history-content" style="text-align:center;padding:2rem;opacity:0.6;">Загрузка истории...</div>
</div>
`;
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 = '<div style="text-align:center;padding:2rem;opacity:0.6;">История пуста</div>';
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<string, string> = {
easy: '🟢 Легко',
ok: '🟡 Нормально',
hard: '🟠 Тяжело',
none: '🔴 Не знаю',
};
const row = document.createElement('div');
row.className = 'history-row';
row.innerHTML = `
<div class="history-row-word">${escapeHtml(wordText)}</div>
<div class="history-row-mode">${modeLabels[record.mode] || record.mode}</div>
<div class="history-row-scores">
<span class="history-score-before">${record.score_before.toFixed(1)}</span>
<span class="history-score-arrow">→</span>
<span class="history-score-after">${record.score_after.toFixed(1)}</span>
</div>
<div class="history-row-date">${dateStr}</div>
`;
contentEl.appendChild(row);
}
} catch (err) {
console.error('[History] Ошибка загрузки:', err);
const contentEl = document.getElementById('history-content');
if (contentEl) {
contentEl.innerHTML = '<div style="text-align:center;padding:2rem;color:var(--error);">Ошибка загрузки истории</div>';
}
}
}
async function getWordMap(wordIds: number[]): Promise<Map<number, string>> {
const map = new Map<number, string>();
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;
}
+197
View File
@@ -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<IDBDatabase> {
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<IDBDatabase> {
if (db) return db;
db = await openDB();
return db;
}
/**
* Добавляет запись о повторении в историю.
* После добавления удаляет самые старые записи, если превышен лимит (2000 на колоду).
*/
export async function addRepetition(record: Omit<RepetitionRecord, 'id'>): Promise<void> {
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<void> {
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<RepetitionRecord[]> {
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<number[]> {
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<number>();
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 колод'));
};
});
}
+3 -2
View File
@@ -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<HTMLButtonElement>('.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,
+29 -9
View File
@@ -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<void> {
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();
}