Grouping works

This commit is contained in:
2026-06-21 13:50:10 +03:00
parent dbe1a14fd9
commit 418c23be2e
3 changed files with 269 additions and 39 deletions
+70 -1
View File
@@ -9,6 +9,11 @@ export interface Word {
group_id: number; group_id: number;
} }
export interface WordGroup {
id: number;
name: string;
}
export interface CardSetRecord { export interface CardSetRecord {
id: number; id: number;
name: string; name: string;
@@ -25,6 +30,16 @@ export interface CardStatRecord {
last_opened: number; last_opened: number;
} }
export interface TagGroup {
tag: string;
words: Word[];
}
export interface GroupedWords {
group: WordGroup;
tags: TagGroup[];
}
/** /**
* Парсит last_opened из SQLite. * Парсит last_opened из SQLite.
* SQLite может вернуть ISO-строку "2026-05-22 10:52:06.823259+00:00", * SQLite может вернуть ISO-строку "2026-05-22 10:52:06.823259+00:00",
@@ -129,10 +144,64 @@ 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)); 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 = ?`,
[safeScore, lastOpened, statId], [safeScore, lastOpened, statId],
); );
} }
/**
* Возвращает все группы (word_group).
*/
export async function getWordGroups(): Promise<WordGroup[]> {
const rows: any[] = await execute(`
SELECT id, name FROM word_group ORDER BY id ASC
`);
return rows.map((r: any[]) => ({ id: r[0], name: r[1] })) as WordGroup[];
}
/**
* Группирует все слова по группам и тегам.
* Если у слова несколько тегов (через пробел), оно попадает в каждую теговую группу.
*/
export async function getGroupedWords(): Promise<GroupedWords[]> {
const groups = await getWordGroups();
const words = await getWordsForReview();
const result: GroupedWords[] = [];
for (const group of groups) {
const groupWords = words.filter((w) => w.group_id === group.id);
// Собираем все уникальные теги в этой группе
const tagMap = new Map<string, Word[]>();
for (const word of groupWords) {
if (!word.tags || word.tags.trim() === '') {
// Слова без тегов — в группу "без тега"
const tag = '(без тега)';
if (!tagMap.has(tag)) tagMap.set(tag, []);
tagMap.get(tag)!.push(word);
continue;
}
const tags = word.tags.split(',').map((t) => t.trim()).filter(Boolean);
for (const tag of tags) {
if (!tagMap.has(tag)) tagMap.set(tag, []);
tagMap.get(tag)!.push(word);
}
}
const tagEntries: TagGroup[] = [];
for (const [tag, tagWords] of tagMap) {
tagEntries.push({ tag, words: tagWords });
}
// Сортируем теги: сначала с наибольшим количеством слов
tagEntries.sort((a, b) => b.words.length - a.words.length);
result.push({ group, tags: tagEntries });
}
return result;
}
+87 -34
View File
@@ -1,9 +1,9 @@
import './style.css'; import './style.css';
import { initDB, closeDb, getDatabaseFile, replaceDatabaseFile } from './sqlite-manager.ts'; import { initDB, closeDb, getDatabaseFile, replaceDatabaseFile } from './sqlite-manager.ts';
import { uploadDatabase, downloadDatabase } from './sync.ts'; import { uploadDatabase, downloadDatabase } from './sync.ts';
import { getWordsForReview, getCardSets, getWordsAndStatsForSet } from './db-manager.ts'; import { getCardSets, getWordsAndStatsForSet, getGroupedWords } from './db-manager.ts';
import type { GroupedWords } from './db-manager.ts';
import type { CardSetRecord } from './db-manager.ts'; import type { CardSetRecord } from './db-manager.ts';
import type { Word } from './db-manager.ts';
import { SetOrderMode } from './algorithm.ts'; 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';
@@ -40,7 +40,6 @@ function getSyncId(): string {
const input = document.getElementById('syncId') as HTMLInputElement | null; const input = document.getElementById('syncId') as HTMLInputElement | null;
const id = input?.value.trim(); const id = input?.value.trim();
if (!id) throw new Error('Введите ID синхронизации'); if (!id) throw new Error('Введите ID синхронизации');
// Сохраняем в localStorage
localStorage.setItem(SYNC_ID_STORAGE_KEY, id); localStorage.setItem(SYNC_ID_STORAGE_KEY, id);
return id; return id;
} }
@@ -70,37 +69,98 @@ function renderWordsPage() {
<div class="card" style="flex: 1;"> <div class="card" style="flex: 1;">
<h2 class="card-title">📚 Слова</h2> <h2 class="card-title">📚 Слова</h2>
<ul id="word-list" class="word-list"></ul> <div id="word-groups"></div>
</div> </div>
<div id="status" class="status-bar">Загрузка...</div> <div id="status" class="status-bar">Загрузка...</div>
`; `;
} }
function renderWordsList(words: Word[]) { function renderWordGroups(grouped: GroupedWords[]) {
const wordListEl = document.getElementById('word-list') as HTMLUListElement | null; const containerEl = document.getElementById('word-groups');
if (!wordListEl) return; if (!containerEl) return;
wordListEl.innerHTML = ''; containerEl.innerHTML = '';
if (words.length === 0) { if (grouped.length === 0) {
wordListEl.innerHTML = '<li class="word-item word-item-empty">Нет слов для повторения 🎉</li>'; containerEl.innerHTML = '<p style="text-align:center;opacity:0.6;padding:1rem;">Нет слов 🎉</p>';
return; return;
} }
for (const word of words) { for (const entry of grouped) {
const li = document.createElement('li'); const groupEl = document.createElement('div');
li.className = 'word-item'; groupEl.className = 'word-group';
const header = document.createElement('div');
header.className = 'word-group-header';
header.innerHTML = `
<span class="word-group-name">${escapeHtml(entry.group.name)}</span>
<span class="word-group-count">${entry.tags.reduce((s, t) => s + t.words.length, 0)} слов</span>
`;
// Коллапс по клику на заголовке
let collapsed = false;
header.addEventListener('click', () => {
collapsed = !collapsed;
tagsContainer.style.display = collapsed ? 'none' : '';
header.classList.toggle('collapsed', collapsed);
});
groupEl.appendChild(header);
const tagsContainer = document.createElement('div');
tagsContainer.className = 'word-group-tags';
for (const tagEntry of entry.tags) {
const tagBlock = document.createElement('div');
tagBlock.className = 'word-tag-block';
const tagHeader = document.createElement('div');
tagHeader.className = 'word-tag-header';
tagHeader.innerHTML = `
<span class="word-tag-label">#${escapeHtml(tagEntry.tag)}</span>
<span class="word-tag-count">${tagEntry.words.length}</span>
`;
let tagCollapsed = true;
const wordsContainer = document.createElement('div');
wordsContainer.className = 'word-tag-words';
wordsContainer.style.display = 'none';
tagHeader.addEventListener('click', () => {
tagCollapsed = !tagCollapsed;
wordsContainer.style.display = tagCollapsed ? 'none' : '';
tagHeader.classList.toggle('collapsed', tagCollapsed);
});
for (const word of tagEntry.words) {
const wordEl = document.createElement('div');
wordEl.className = 'word-item';
let reading = word.key; let reading = word.key;
let translation = word.value; let translation = word.value;
if (word.more) {
try {
const parsed = JSON.parse(word.more);
reading = parsed.reading || word.key;
translation = parsed.translation || word.value;
} catch { /* ignore */ }
}
li.innerHTML = ` wordEl.innerHTML = `
<span class="word-key">${reading}</span> <span class="word-key">${escapeHtml(reading)}</span>
<span class="word-value">${escapeHtml(translation)}</span> <span class="word-value">${escapeHtml(translation)}</span>
${word.tags ? `<span class="word-tags">${escapeHtml(word.tags)}</span>` : ''}
`; `;
wordListEl.appendChild(li); wordsContainer.appendChild(wordEl);
}
tagBlock.appendChild(tagHeader);
tagBlock.appendChild(wordsContainer);
tagsContainer.appendChild(tagBlock);
}
groupEl.appendChild(tagsContainer);
containerEl.appendChild(groupEl);
} }
} }
@@ -179,7 +239,6 @@ async function startTraining(setId: number) {
const modeSelect = document.getElementById('mode-select') as HTMLSelectElement | null; const modeSelect = document.getElementById('mode-select') as HTMLSelectElement | null;
const mode = (modeSelect?.value as SetOrderMode) || SetOrderMode.Default; const mode = (modeSelect?.value as SetOrderMode) || SetOrderMode.Default;
// Получаем колоду
const sets = await getCardSets(); const sets = await getCardSets();
const set = sets.find(s => s.id === setId); const set = sets.find(s => s.id === setId);
if (!set) { if (!set) {
@@ -187,14 +246,13 @@ async function startTraining(setId: number) {
return; return;
} }
// Получаем слова и статистику
const { words, stats } = await getWordsAndStatsForSet(setId); const { words, stats } = await getWordsAndStatsForSet(setId);
if (words.length === 0) { if (words.length === 0) {
setStatus('Нет слов для тренировки', true); setStatus('Нет слов для тренировки', true);
return; return;
} }
console.log(`[Training] Запуск тренировки: ${words.length} слов, ${stats.length} записей статистики, режим: ${mode}`); console.log(`[Training] Запуск тренировки: ${words.length} слов, ${stats.length} записей, режим: ${mode}`);
const settings = { const settings = {
id: set.id, id: set.id,
@@ -230,7 +288,6 @@ async function startTraining(setId: number) {
score: s.score, score: s.score,
})); }));
// Показываем страницу тренировки
hideNav(); hideNav();
currentPage = 'training'; currentPage = 'training';
@@ -263,7 +320,7 @@ function switchPage(page: 'words' | 'sets') {
if (page === 'words') { if (page === 'words') {
renderWordsPage(); renderWordsPage();
loadWords(); loadGroupedWords();
} else { } else {
renderSetsPage(); renderSetsPage();
loadSets(); loadSets();
@@ -278,12 +335,13 @@ function showNav() {
document.getElementById('app-nav')!.style.display = 'flex'; document.getElementById('app-nav')!.style.display = 'flex';
} }
async function loadWords() { async function loadGroupedWords() {
try { try {
const words = await getWordsForReview(); const grouped = await getGroupedWords();
renderWordsList(words); renderWordGroups(grouped);
const count = words.length;
setStatus(`Слов: ${count}`); const totalWords = grouped.reduce((s, g) => s + g.tags.reduce((s2, t) => s2 + t.words.length, 0), 0);
setStatus(`Слов: ${totalWords}`);
} catch (err) { } catch (err) {
setStatus('Ошибка загрузки слов', true); setStatus('Ошибка загрузки слов', true);
console.error(err); console.error(err);
@@ -312,7 +370,7 @@ async function onDownload() {
setStatus('Скачивание...'); setStatus('Скачивание...');
await downloadDatabase(id, API_BASE_URL, replaceDatabaseFile, initDB); await downloadDatabase(id, API_BASE_URL, replaceDatabaseFile, initDB);
if (currentPage === 'words') await loadWords(); if (currentPage === 'words') await loadGroupedWords();
setStatus('База данных скачана.'); setStatus('База данных скачана.');
} catch { } catch {
// ошибка уже обработана в sync.ts // ошибка уже обработана в sync.ts
@@ -332,7 +390,7 @@ async function onUpload() {
await uploadDatabase(id, API_BASE_URL, closeDb, getDatabaseFile); await uploadDatabase(id, API_BASE_URL, closeDb, getDatabaseFile);
await initDB(); await initDB();
if (currentPage === 'words') await loadWords(); if (currentPage === 'words') await loadGroupedWords();
setStatus('База данных загружена.'); setStatus('База данных загружена.');
} catch { } catch {
// ошибка уже обработана в sync.ts // ошибка уже обработана в sync.ts
@@ -349,7 +407,6 @@ async function main() {
await initDB(); await initDB();
console.log('[App] База данных инициализирована.'); console.log('[App] База данных инициализирована.');
// Навигация
navBtns.forEach(btn => { navBtns.forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
if (currentPage !== 'training') { if (currentPage !== 'training') {
@@ -358,13 +415,9 @@ async function main() {
}); });
}); });
// Стартовая страница
switchPage('words'); switchPage('words');
// Восстанавливаем сохранённый ID синхронизации
restoreSyncId(); restoreSyncId();
// Глобальные обработчики
container.addEventListener('click', async (e) => { container.addEventListener('click', async (e) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (target.id === 'btnDownload') await onDownload(); if (target.id === 'btnDownload') await onDownload();
+108
View File
@@ -300,6 +300,114 @@ body {
opacity: 0.6; opacity: 0.6;
} }
/* ──── Группировка слов ──── */
.word-group {
margin-bottom: 1rem;
}
.word-group-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 10px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background 0.15s;
min-height: 48px;
}
.word-group-header:hover {
background: var(--accent-light);
}
.word-group-header::after {
content: '▾';
font-size: 0.8rem;
color: var(--text);
transition: transform 0.2s;
}
.word-group-header.collapsed::after {
transform: rotate(-90deg);
}
.word-group-name {
font-weight: 600;
color: var(--text-h);
font-size: 0.95rem;
}
.word-group-count {
font-size: 0.8rem;
color: var(--text);
margin-right: 0.5rem;
}
.word-group-tags {
padding-top: 0.5rem;
}
.word-tag-block {
margin-bottom: 0.5rem;
}
.word-tag-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
border-radius: 8px;
transition: background 0.15s;
}
.word-tag-header:hover {
background: var(--accent-light);
}
.word-tag-header::after {
content: '▸';
font-size: 0.75rem;
color: var(--text);
transition: transform 0.2s;
}
.word-tag-header.collapsed::after {
transform: rotate(90deg);
}
.word-tag-label {
font-size: 0.85rem;
font-weight: 500;
color: var(--accent);
}
.word-tag-count {
font-size: 0.75rem;
color: var(--text);
background: var(--accent-light);
padding: 1px 8px;
border-radius: 10px;
}
.word-tag-words {
padding: 0.25rem 0 0.25rem 0.5rem;
}
.word-tag-words .word-item {
margin-bottom: 6px;
padding: 10px 12px;
}
.word-tag-words .word-item:last-child {
margin-bottom: 0;
}
/* ──── Навигация ──── */ /* ──── Навигация ──── */
.app-nav { .app-nav {