Training works great

This commit is contained in:
2026-06-13 14:48:02 +03:00
parent 388ed84fff
commit 99a00e0743
4 changed files with 88 additions and 63 deletions
+21 -11
View File
@@ -56,7 +56,8 @@ export function calculatedScore(stat: CardStatistics): number {
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);
const multiplier = Math.pow(FADE_PER_DAY, Math.max(0, days)); const multiplier = Math.pow(FADE_PER_DAY, Math.max(0, days));
return stat.score * multiplier; const v = stat.score * multiplier;
return v;
} }
export function updateStatScore(stat: CardStatistics, status: WordOpenMode): void { export function updateStatScore(stat: CardStatistics, status: WordOpenMode): void {
@@ -154,17 +155,25 @@ class SemiRandomSRSModule implements SRSModule {
private initialized = false; private initialized = false;
next(set: { words: WordData[]; set: CardStatistics[] }): number { next(set: { words: WordData[]; set: CardStatistics[] }): number {
const maxHistory = this.historyLen(set.set.length);
// Пробуем выбрать индекс не из истории (не более 100 попыток)
for (let attempt = 0; attempt < 100; attempt++) {
const index = this.lastWeights.sample();
if (!this.history.includes(index) || maxHistory >= set.set.length) {
// Если история может вместить все слова — пропускаем проверку
if (this.history.length >= maxHistory) {
this.history.shift();
}
this.history.push(index);
return index;
}
}
// Fallback: если все индексы в истории, очищаем историю
this.history = [];
const index = this.lastWeights.sample(); const index = this.lastWeights.sample();
if (this.history.includes(index)) {
return this.next(set);
}
if (this.history.length >= this.historyLen(set.set.length)) {
this.history.shift();
}
this.history.push(index); this.history.push(index);
return index; return index;
} }
@@ -178,11 +187,12 @@ class SemiRandomSRSModule implements SRSModule {
const weights = set.set.map( const weights = set.set.map(
(s) => Math.pow(100.0 / Math.max(0.1, calculatedScore(s)), 2.0) * 2.0, (s) => Math.pow(100.0 / Math.max(0.1, calculatedScore(s)), 2.0) * 2.0,
); );
this.lastWeights = new WeightedIndex(weights); this.lastWeights = new WeightedIndex(weights);
} }
private historyLen(setLen: number): number { private historyLen(setLen: number): number {
return Math.min(MAX_HISTORY_LEN, Math.floor(setLen * MAX_HISTORY_LEN_PART)); return Math.min(MAX_HISTORY_LEN, Math.max(0, Math.floor(setLen * MAX_HISTORY_LEN_PART)));
} }
} }
+18 -2
View File
@@ -25,6 +25,20 @@ export interface CardStatRecord {
last_opened: number; last_opened: number;
} }
/**
* Парсит last_opened из SQLite.
* SQLite может вернуть ISO-строку "2026-05-22 10:52:06.823259+00:00",
* хотя в схеме поле INTEGER. Приводим к unix timestamp (секунды).
*/
function parseLastOpened(value: any): number {
if (typeof value === 'number') return value;
if (typeof value === 'string') {
const ts = Date.parse(value);
if (!isNaN(ts)) return Math.floor(ts / 1000);
}
return Math.floor(Date.now() / 1000);
}
/** /**
* Получает все колоды (card_set). * Получает все колоды (card_set).
*/ */
@@ -101,7 +115,7 @@ export async function getWordsAndStatsForSet(
word_id: r[1], word_id: r[1],
set_id: r[2], set_id: r[2],
score: r[3], score: r[3],
last_opened: r[4], last_opened: parseLastOpened(r[4]),
})); }));
return { words, stats }; return { words, stats };
@@ -115,8 +129,10 @@ export async function updateCardStat(
score: number, score: number,
lastOpened: number, lastOpened: number,
): Promise<void> { ): Promise<void> {
// Убеждаемся, что score — число >= 1
const safeScore = Math.max(1, Math.round(score || 1));
await executeVoid( await executeVoid(
`UPDATE card_stats SET score = ?, last_opened = ? WHERE id = ?`, `UPDATE card_stats SET score = ?, last_opened = ? WHERE id = ?`,
[score, lastOpened, statId], [safeScore, lastOpened, statId],
); );
} }
+2
View File
@@ -193,6 +193,8 @@ async function startTraining(setId: number) {
return; return;
} }
console.log(`[Training] Запуск тренировки: ${words.length} слов, ${stats.length} записей статистики, режим: ${mode}`);
const settings = { const settings = {
id: set.id, id: set.id,
name: set.name, name: set.name,
+47 -50
View File
@@ -11,11 +11,28 @@ export interface WordItem {
group_id: number; group_id: number;
} }
function getFieldValues(more: Record<string, string>, fields: string): string[] { /**
* Извлекает значения полей из слова.
* Сначала ищет в основных полях (key, value, tags),
* потом в more (JSON-словарь).
* Если поле не найдено — пропускается (возвращается пустая строка, фильтруется).
*/
function getWordFieldValues(word: WordItem, fields: string): string[] {
const directFields: Record<string, string> = {
key: word.key,
value: word.value,
tags: word.tags,
};
return fields return fields
.split(/\s+/) .split(/\s+/)
.filter(Boolean) .filter(Boolean)
.map((f) => more[f] || f); .map((f) => {
if (f in directFields && directFields[f]) return directFields[f];
if (f in word.more && word.more[f]) return word.more[f];
return '';
})
.filter(Boolean);
} }
export interface TrainingCallbacks { export interface TrainingCallbacks {
@@ -28,6 +45,8 @@ export class TrainingPage {
private words: WordItem[]; private words: WordItem[];
private callbacks: TrainingCallbacks; private callbacks: TrainingCallbacks;
private totalCards: number; private totalCards: number;
private forwardFields: string;
private backwardFields: string;
constructor( constructor(
container: HTMLElement, container: HTMLElement,
@@ -39,6 +58,8 @@ export class TrainingPage {
this.container = container; this.container = container;
this.callbacks = callbacks; this.callbacks = callbacks;
this.totalCards = stats.length; this.totalCards = stats.length;
this.forwardFields = settings.forward || 'key';
this.backwardFields = settings.backward || 'value';
const wordData: WordData[] = words.map((w) => ({ const wordData: WordData[] = words.map((w) => ({
id: w.id, id: w.id,
@@ -55,13 +76,12 @@ export class TrainingPage {
} }
private render(): void { private render(): void {
const current = this.cardSet.currentWordIndex;
const total = this.totalCards; const total = this.totalCards;
this.container.innerHTML = ` this.container.innerHTML = `
<div class="training-header"> <div class="training-header">
<button id="btnTrainingBack" class="btn" style="flex:none; padding:0.4rem 0.8rem;">← Назад</button> <button id="btnTrainingBack" class="btn" style="flex:none; padding:0.4rem 0.8rem;">← Назад</button>
<span style="font-size:0.85rem;color:var(--text);">${current !== null ? current + 1 : 0} / ${total}</span> <span style="font-size:0.85rem;color:var(--text);">слов: ${total}</span>
</div> </div>
<div id="training-card" class="training-card"> <div id="training-card" class="training-card">
<div class="training-content" id="training-content"> <div class="training-content" id="training-content">
@@ -83,11 +103,7 @@ export class TrainingPage {
const word = this.words.find((w) => w.id === result.word.id); const word = this.words.find((w) => w.id === result.word.id);
if (!word) return; if (!word) return;
const settings = (this.cardSet as any).settings as CardSetSettings | undefined; this.renderSide(word, this.forwardFields, this.backwardFields, false);
const forwardFields = settings?.forward || 'key';
const backwardFields = settings?.backward || 'value';
this.renderSide(word, forwardFields, backwardFields, false);
this.renderActions(false); this.renderActions(false);
} }
@@ -97,27 +113,33 @@ export class TrainingPage {
backwardFields: string, backwardFields: string,
showBack: boolean, showBack: boolean,
): void { ): void {
const more = word.more;
const contentEl = document.getElementById('training-content'); const contentEl = document.getElementById('training-content');
if (!contentEl) return; if (!contentEl) return;
if (!showBack) { if (!showBack) {
const values = getFieldValues(more, forwardFields); const values = getWordFieldValues(word, forwardFields);
if (values.length === 0) {
contentEl.innerHTML = '<div class="training-field" style="opacity:0.4;">Нет данных для отображения</div>';
return;
}
contentEl.innerHTML = values contentEl.innerHTML = values
.map((v) => `<div class="training-field">${this.escapeHtml(v)}</div>`) .map((v) => `<div class="training-field">${this.escapeHtml(v)}</div>`)
.join(''); .join('');
} else { } else {
const fwdVals = getFieldValues(more, forwardFields); const fwdVals = getWordFieldValues(word, forwardFields);
const bwdVals = getFieldValues(more, backwardFields); const bwdVals = getWordFieldValues(word, backwardFields);
contentEl.innerHTML = ` contentEl.innerHTML = `
<div class="training-side"> <div class="training-side">
${fwdVals.map((v) => `<div class="training-field">${this.escapeHtml(v)}</div>`).join('')} ${fwdVals.length > 0
? fwdVals.map((v) => `<div class="training-field">${this.escapeHtml(v)}</div>`).join('')
: '<div class="training-field" style="opacity:0.4;">—</div>'}
</div> </div>
<div class="training-divider"></div> <div class="training-divider"></div>
<div class="training-side training-side-back"> <div class="training-side training-side-back">
${bwdVals.map((v) => `<div class="training-field">${this.escapeHtml(v)}</div>`).join('')} ${bwdVals.length > 0
? bwdVals.map((v) => `<div class="training-field">${this.escapeHtml(v)}</div>`).join('')
: '<div class="training-field" style="opacity:0.4;">—</div>'}
</div> </div>
`; `;
} }
@@ -157,11 +179,7 @@ export class TrainingPage {
const word = this.words[current]; const word = this.words[current];
if (!word) return; if (!word) return;
const settings = (this.cardSet as any).settings as CardSetSettings | undefined; this.renderSide(word, this.forwardFields, this.backwardFields, true);
const forwardFields = settings?.forward || 'key';
const backwardFields = settings?.backward || 'value';
this.renderSide(word, forwardFields, backwardFields, true);
this.renderActions(true); this.renderActions(true);
} }
@@ -172,37 +190,16 @@ export class TrainingPage {
if (current === null) return; if (current === null) return;
const stat = this.cardSet.set[current]; const stat = this.cardSet.set[current];
try { if (stat) {
await updateCardStat(stat.id, stat.score, Math.floor(stat.last_open / 1000)); try {
} catch (err) { await updateCardStat(stat.id, stat.score, Math.floor(stat.last_open / 1000));
console.error('[Training] Ошибка сохранения:', err); } catch (err) {
console.error('[Training] Ошибка сохранения:', err);
}
} }
if (current < this.totalCards - 1) { // Бесконечная тренировка — всегда переходим к следующей карте
this.renderCard(); this.renderCard();
} else {
this.finish();
}
}
private finish(): void {
this.container.innerHTML = `
<div class="training-header">
<button id="btnTrainingFinish" class="btn" style="flex:none; padding:0.4rem 0.8rem;">← Назад</button>
</div>
<div class="training-card" style="display:flex;align-items:center;justify-content:center;">
<div style="text-align:center;">
<div style="font-size:3rem;margin-bottom:1rem;">🎉</div>
<h2 style="margin:0;color:var(--text-h);">Тренировка завершена!</h2>
<p style="color:var(--text);">Все ${this.totalCards} карточек пройдены.</p>
</div>
</div>
<div class="training-actions"></div>
`;
document.getElementById('btnTrainingFinish')?.addEventListener('click', () => {
this.callbacks.onFinish();
});
} }
private escapeHtml(text: string): string { private escapeHtml(text: string): string {