fix incorrect score setup
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user