Training works great
This commit is contained in:
+18
-8
@@ -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();
|
const index = this.lastWeights.sample();
|
||||||
|
if (!this.history.includes(index) || maxHistory >= set.set.length) {
|
||||||
if (this.history.includes(index)) {
|
// Если история может вместить все слова — пропускаем проверку
|
||||||
return this.next(set);
|
if (this.history.length >= maxHistory) {
|
||||||
}
|
|
||||||
|
|
||||||
if (this.history.length >= this.historyLen(set.set.length)) {
|
|
||||||
this.history.shift();
|
this.history.shift();
|
||||||
}
|
}
|
||||||
this.history.push(index);
|
this.history.push(index);
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: если все индексы в истории, очищаем историю
|
||||||
|
this.history = [];
|
||||||
|
const index = this.lastWeights.sample();
|
||||||
|
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
@@ -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],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+42
-45
@@ -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];
|
||||||
|
|
||||||
|
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));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Training] Ошибка сохранения:', 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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user