fix incorrect score setup
This commit is contained in:
@@ -79,7 +79,9 @@ impl CardStatistics {
|
|||||||
self.score = MAX_SCORE
|
self.score = MAX_SCORE
|
||||||
}
|
}
|
||||||
self.last_open = Utc::now();
|
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 {
|
pub fn calculated_score(&self) -> f32 {
|
||||||
let time = Utc::now() - self.last_open;
|
let time = Utc::now() - self.last_open;
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ export function calculatedScore(stat: CardStatistics): number {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const diffMs = now - stat.last_open;
|
const diffMs = now - stat.last_open;
|
||||||
const days = diffMs / (1000 * 60 * 60 * 24);
|
const days = diffMs / (1000 * 60 * 60 * 24);
|
||||||
|
console.log(`Days ${days}`)
|
||||||
const multiplier = Math.pow(FADE_PER_DAY, Math.max(0, days));
|
const multiplier = Math.pow(FADE_PER_DAY, Math.max(0, days));
|
||||||
|
console.log(`multiplier ${multiplier}`)
|
||||||
const v = stat.score * multiplier;
|
const v = stat.score * multiplier;
|
||||||
return v;
|
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 { TrainingPage } from './training.ts';
|
||||||
import type { WordItem } from './training.ts';
|
import type { WordItem } from './training.ts';
|
||||||
import { renderWordView } from './word-view.ts';
|
import { renderWordView } from './word-view.ts';
|
||||||
|
import { renderHistoryView } from './history-view.ts';
|
||||||
import type { Word } from './db-manager.ts';
|
import type { Word } from './db-manager.ts';
|
||||||
// ----- Конфигурация -----
|
// ----- Конфигурация -----
|
||||||
const API_BASE_URL = 'https://learning.micialware.ru';
|
const API_BASE_URL = 'https://learning.micialware.ru';
|
||||||
@@ -16,7 +17,7 @@ const SYNC_ID_STORAGE_KEY = 'japlearn_sync_id';
|
|||||||
// ----- Элементы UI -----
|
// ----- Элементы UI -----
|
||||||
const container = document.getElementById('page-container') as HTMLElement;
|
const container = document.getElementById('page-container') as HTMLElement;
|
||||||
const navBtns = document.querySelectorAll<HTMLButtonElement>('.nav-btn');
|
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 = {
|
const settings = {
|
||||||
id: set.id,
|
id: set.id,
|
||||||
name: set.namclinee,
|
name: set.name,
|
||||||
forward: set.forward,
|
forward: set.forward,
|
||||||
backward: set.backward,
|
backward: set.backward,
|
||||||
filter: set.filter,
|
filter: set.filter,
|
||||||
|
|||||||
+29
-9
@@ -1,6 +1,7 @@
|
|||||||
import { CardSet, calculatedScore } from './algorithm.ts';
|
import { CardSet, calculatedScore } from './algorithm.ts';
|
||||||
import type { CardSetSettings, CardStatistics, WordData, WordOpenMode } from './algorithm.ts';
|
import type { CardSetSettings, CardStatistics, WordData, WordOpenMode } from './algorithm.ts';
|
||||||
import { updateCardStat } from './db-manager.ts';
|
import { updateCardStat } from './db-manager.ts';
|
||||||
|
import { addRepetition } from './history.ts';
|
||||||
|
|
||||||
export interface WordItem {
|
export interface WordItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -118,13 +119,13 @@ export class TrainingPage {
|
|||||||
this.updateCounter();
|
this.updateCounter();
|
||||||
|
|
||||||
const stat = this.cardSet.set[this.cardSet.currentWordIndex!];
|
const stat = this.cardSet.set[this.cardSet.currentWordIndex!];
|
||||||
if (stat) {
|
// if (stat) {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
// const now = Math.floor(Date.now() / 1000);
|
||||||
stat.last_open = Date.now();
|
// stat.last_open = Date.now();
|
||||||
updateCardStat(stat.id, stat.score, now).catch((err) =>
|
// updateCardStat(stat.id, stat.score, now).catch((err) =>
|
||||||
console.error('[Training] Ошибка сохранения last_opened:', err),
|
// console.error('[Training] Ошибка сохранения last_opened:', err),
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
||||||
this.renderSide(word, this.forwardFields, this.backwardFields, false, stat);
|
this.renderSide(word, this.forwardFields, this.backwardFields, false, stat);
|
||||||
this.renderActions(false);
|
this.renderActions(false);
|
||||||
@@ -222,12 +223,15 @@ export class TrainingPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async vote(mode: WordOpenMode): Promise<void> {
|
private async vote(mode: WordOpenMode): Promise<void> {
|
||||||
this.cardSet.open(mode);
|
|
||||||
|
|
||||||
const current = this.cardSet.currentWordIndex;
|
const current = this.cardSet.currentWordIndex;
|
||||||
if (current === null) return;
|
if (current === null) return;
|
||||||
const stat = this.cardSet.set[current];
|
const stat = this.cardSet.set[current];
|
||||||
|
|
||||||
|
// Сохраняем score до ответа
|
||||||
|
const scoreBefore = stat ? Math.round(calculatedScore(stat) * 10) / 10 : 0;
|
||||||
|
|
||||||
|
this.cardSet.open(mode);
|
||||||
|
|
||||||
if (stat) {
|
if (stat) {
|
||||||
try {
|
try {
|
||||||
await updateCardStat(stat.id, stat.score, Math.floor(stat.last_open / 1000));
|
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();
|
this.renderCard();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user