From aae895d704d3d2962f285e19d80bf0eae95a057e Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 20 Apr 2026 09:50:55 +0200 Subject: [PATCH] feat: filter panel + english category keys Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 8 ++ package-lock.json | 4 +- package.json | 2 +- public/locales/de.json | 7 +- public/pages/tasks.js | 213 +++++++++++++++++++++++++++++++--------- public/styles/tasks.css | 93 ++++++++++++++++++ server/db.js | 17 ++++ 7 files changed, 291 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b70b3d..b2cb80a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.20.23] - 2026-04-20 + +### Added +- Tasks: filter bar replaced by a compact toggle panel — only active filter chips are shown inline; a "Filter" button (with active-count badge) opens a grouped panel with Status, Priority, and Person sections, plus a clear-all button + +### Changed +- Tasks: category values stored in the database are now English keys (`household`, `school`, `shopping`, `repair`, `health`, `finance`, `leisure`, `misc`) instead of German strings — migration v9 converts all existing rows automatically; display labels are unchanged + ## [0.20.22] - 2026-04-20 ### Added diff --git a/package-lock.json b/package-lock.json index 156d67f..c0f2058 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oikos", - "version": "0.20.22", + "version": "0.20.23", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oikos", - "version": "0.20.22", + "version": "0.20.23", "license": "MIT", "dependencies": { "bcrypt": "^6.0.0", diff --git a/package.json b/package.json index 4e66b26..ecbd6f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.20.22", + "version": "0.20.23", "description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.", "main": "server/index.js", "type": "module", diff --git a/public/locales/de.json b/public/locales/de.json index 93b3c20..21c42c8 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -154,7 +154,12 @@ "listView": "Listenansicht", "kanbanView": "Kanban-Ansicht", "swipedDoneToast": "Als erledigt markiert.", - "swipedOpenToast": "Als offen markiert." + "swipedOpenToast": "Als offen markiert.", + "filterBtn": "Filter", + "filterGroupStatus": "Status", + "filterGroupPriority": "Priorität", + "filterGroupPerson": "Person", + "filterClearAll": "Alle Filter zurücksetzen" }, "shopping": { "title": "Einkauf", diff --git a/public/pages/tasks.js b/public/pages/tasks.js index c45cc27..3708b9b 100644 --- a/public/pages/tasks.js +++ b/public/pages/tasks.js @@ -31,19 +31,19 @@ const STATUSES = () => [ ]; const CATEGORIES = [ - 'Haushalt', 'Schule', 'Einkauf', 'Reparatur', - 'Gesundheit', 'Finanzen', 'Freizeit', 'Sonstiges', + 'household', 'school', 'shopping', 'repair', + 'health', 'finance', 'leisure', 'misc', ]; const CATEGORY_LABELS = () => ({ - 'Haushalt': t('tasks.categoryHousehold'), - 'Schule': t('tasks.categorySchool'), - 'Einkauf': t('tasks.categoryShopping'), - 'Reparatur': t('tasks.categoryRepair'), - 'Gesundheit': t('tasks.categoryHealth'), - 'Finanzen': t('tasks.categoryFinance'), - 'Freizeit': t('tasks.categoryLeisure'), - 'Sonstiges': t('tasks.categoryMisc'), + 'household': t('tasks.categoryHousehold'), + 'school': t('tasks.categorySchool'), + 'shopping': t('tasks.categoryShopping'), + 'repair': t('tasks.categoryRepair'), + 'health': t('tasks.categoryHealth'), + 'finance': t('tasks.categoryFinance'), + 'leisure': t('tasks.categoryLeisure'), + 'misc': t('tasks.categoryMisc'), }); const PRIORITY_LABELS = () => Object.fromEntries(PRIORITIES().map((p) => [p.value, p.label])); @@ -180,7 +180,7 @@ function renderTaskCard(task, opts = {}) { ${renderPriorityBadge(task.priority)} ${renderDueDate(task.due_date)} ${task.is_recurring ? `` : ''} - ${task.category !== 'Sonstiges' ? `${CATEGORY_LABELS()[task.category] ?? task.category}` : ''} + ${task.category !== 'misc' ? `${CATEGORY_LABELS()[task.category] ?? task.category}` : ''} @@ -355,13 +355,14 @@ function renderModalContent({ task = null, users = [], reminder = null } = {}) { // -------------------------------------------------------- let state = { - tasks: [], - users: [], - filters: { status: '', priority: '', assigned_to: '' }, - groupMode: 'category', // 'category' | 'due' - viewMode: 'list', // 'list' | 'kanban' (resolved at render time) - expandedTasks: new Set(), - dragTaskId: null, + tasks: [], + users: [], + filters: { status: '', priority: '', assigned_to: '' }, + groupMode: 'category', // 'category' | 'due' + viewMode: 'list', // 'list' | 'kanban' (resolved at render time) + expandedTasks: new Set(), + dragTaskId: null, + filterPanelOpen: false, }; // -------------------------------------------------------- @@ -882,50 +883,149 @@ function renderTaskList(container) { } function renderFilters(container) { - const bar = container.querySelector('#filter-bar'); - if (!bar) return; + const bar = container.querySelector('#filter-bar'); + const panel = container.querySelector('#filter-panel'); + if (!bar || !panel) return; - const chips = []; const statusLabels = STATUS_LABELS(); const priorityLabels = PRIORITY_LABELS(); + const activeCount = [state.filters.status, state.filters.priority, state.filters.assigned_to] + .filter(Boolean).length; + + // ---- Chip-Leiste: nur aktive Filter + Toggle-Button ---- + bar.replaceChildren(); + if (state.filters.status) { - chips.push(` - ${statusLabels[state.filters.status]} - - `); + const chip = document.createElement('span'); + chip.className = 'filter-chip filter-chip--active'; + chip.dataset.filter = 'status'; + chip.textContent = statusLabels[state.filters.status]; + const rm = document.createElement('span'); + rm.className = 'filter-chip__remove'; + rm.setAttribute('aria-hidden', 'true'); + rm.textContent = '×'; + chip.appendChild(rm); + bar.appendChild(chip); } if (state.filters.priority) { - chips.push(` - ${priorityLabels[state.filters.priority]} - - `); + const chip = document.createElement('span'); + chip.className = 'filter-chip filter-chip--active'; + chip.dataset.filter = 'priority'; + chip.textContent = priorityLabels[state.filters.priority]; + const rm = document.createElement('span'); + rm.className = 'filter-chip__remove'; + rm.setAttribute('aria-hidden', 'true'); + rm.textContent = '×'; + chip.appendChild(rm); + bar.appendChild(chip); } if (state.filters.assigned_to) { const u = state.users.find((u) => u.id === Number(state.filters.assigned_to)); - chips.push(` - ${u?.display_name ?? 'Person'} - - `); + const chip = document.createElement('span'); + chip.className = 'filter-chip filter-chip--active'; + chip.dataset.filter = 'assigned_to'; + chip.textContent = u?.display_name ?? t('tasks.filterGroupPerson'); + const rm = document.createElement('span'); + rm.className = 'filter-chip__remove'; + rm.setAttribute('aria-hidden', 'true'); + rm.textContent = '×'; + chip.appendChild(rm); + bar.appendChild(chip); } - // Inaktive Filter-Chips (zum Aktivieren) - if (!state.filters.status) { - STATUSES().forEach((s) => { - chips.push(`${s.label}`); - }); - } - if (!state.filters.priority) { - PRIORITIES().forEach((p) => { - chips.push(`${p.label}`); - }); - } - if (!state.filters.assigned_to && state.users.length > 1) { - state.users.forEach((u) => { - chips.push(`${u.display_name}`); - }); + const toggleBtn = document.createElement('button'); + toggleBtn.id = 'filter-toggle-btn'; + toggleBtn.className = `filter-toggle-btn${state.filterPanelOpen ? ' filter-toggle-btn--open' : ''}${activeCount > 0 ? ' filter-toggle-btn--active' : ''}`; + toggleBtn.setAttribute('aria-expanded', String(state.filterPanelOpen)); + toggleBtn.setAttribute('aria-controls', 'filter-panel'); + + const iconWrap = document.createElement('i'); + iconWrap.setAttribute('data-lucide', 'sliders-horizontal'); + iconWrap.className = 'icon-sm'; + iconWrap.setAttribute('aria-hidden', 'true'); + toggleBtn.appendChild(iconWrap); + + const label = document.createElement('span'); + label.textContent = t('tasks.filterBtn'); + toggleBtn.appendChild(label); + + if (activeCount > 0) { + const badge = document.createElement('span'); + badge.className = 'filter-toggle-btn__count'; + badge.textContent = String(activeCount); + toggleBtn.appendChild(badge); + } + + bar.appendChild(toggleBtn); + if (window.lucide) window.lucide.createIcons({ el: bar }); + + // ---- Filter-Panel: Gruppen mit allen Optionen ---- + panel.hidden = !state.filterPanelOpen; + panel.replaceChildren(); + + if (state.filterPanelOpen) { + const groups = [ + { + key: 'status', + label: t('tasks.filterGroupStatus'), + items: STATUSES().map((s) => ({ value: s.value, label: s.label })), + }, + { + key: 'priority', + label: t('tasks.filterGroupPriority'), + items: PRIORITIES().map((p) => ({ value: p.value, label: p.label })), + }, + ]; + if (state.users.length > 1) { + groups.push({ + key: 'assigned_to', + label: t('tasks.filterGroupPerson'), + items: state.users.map((u) => ({ value: String(u.id), label: u.display_name })), + }); + } + + groups.forEach((group) => { + const section = document.createElement('div'); + section.className = 'filter-panel__group'; + + const heading = document.createElement('div'); + heading.className = 'filter-panel__label'; + heading.textContent = group.label; + section.appendChild(heading); + + const row = document.createElement('div'); + row.className = 'filter-panel__chips'; + + group.items.forEach((item) => { + const isActive = state.filters[group.key] === item.value; + const chip = document.createElement('span'); + chip.className = `filter-chip${isActive ? ' filter-chip--active' : ''}`; + chip.dataset.filter = group.key; + chip.dataset.value = item.value; + chip.textContent = item.label; + if (isActive) { + const rm = document.createElement('span'); + rm.className = 'filter-chip__remove'; + rm.setAttribute('aria-hidden', 'true'); + rm.textContent = '×'; + chip.appendChild(rm); + } + row.appendChild(chip); + }); + + section.appendChild(row); + panel.appendChild(section); + }); + + if (activeCount > 0) { + const clearBtn = document.createElement('button'); + clearBtn.className = 'filter-panel__clear'; + clearBtn.id = 'filter-clear-all'; + clearBtn.textContent = t('tasks.filterClearAll'); + panel.appendChild(clearBtn); + } } - bar.innerHTML = chips.join(''); wireFilterChips(container); } @@ -1135,6 +1235,20 @@ function maybeShowSwipeHint(container) { // -------------------------------------------------------- function wireFilterChips(container) { + // Toggle-Button öffnet/schließt das Panel + container.querySelector('#filter-toggle-btn')?.addEventListener('click', () => { + state.filterPanelOpen = !state.filterPanelOpen; + renderFilters(container); + }); + + // Alle Filter zurücksetzen + container.querySelector('#filter-clear-all')?.addEventListener('click', async () => { + state.filters = { status: '', priority: '', assigned_to: '' }; + renderFilters(container); + await loadTasks(container); + }); + + // Chip-Klicks (in Bar + Panel) container.querySelectorAll('[data-filter]').forEach((chip) => { chip.addEventListener('click', async () => { const filter = chip.dataset.filter; @@ -1292,6 +1406,7 @@ export async function render(container, { user }) {
+
${[1,2,3].map(() => ` diff --git a/public/styles/tasks.css b/public/styles/tasks.css index b002023..88d01ca 100644 --- a/public/styles/tasks.css +++ b/public/styles/tasks.css @@ -150,6 +150,99 @@ line-height: 1; } +/* Filter-Toggle-Button */ +.filter-toggle-btn { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-1) var(--space-3); + border-radius: var(--radius-full); + font-size: var(--text-sm); + font-weight: var(--font-weight-medium); + white-space: nowrap; + cursor: pointer; + border: 1.5px solid var(--color-border); + background-color: var(--color-surface); + color: var(--color-text-secondary); + transition: all var(--transition-fast); + min-height: 32px; + flex-shrink: 0; +} + +.filter-toggle-btn:hover, +.filter-toggle-btn--open { + border-color: var(--color-text-secondary); + color: var(--color-text-primary); +} + +.filter-toggle-btn--active { + border-color: var(--color-accent); + color: var(--color-accent); +} + +.filter-toggle-btn__count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 var(--space-1); + border-radius: var(--radius-full); + background-color: var(--color-accent); + color: var(--color-text-on-accent); + font-size: var(--text-xs); + font-weight: var(--font-weight-semibold); + line-height: 1; +} + +/* Filter-Panel */ +.filter-panel { + background-color: var(--color-surface); + border: 1.5px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-3) var(--space-4); + margin-bottom: var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.filter-panel__group { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.filter-panel__label { + font-size: var(--text-xs); + font-weight: var(--font-weight-semibold); + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.filter-panel__chips { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.filter-panel__clear { + align-self: flex-start; + font-size: var(--text-sm); + color: var(--color-text-secondary); + text-decoration: underline; + cursor: pointer; + background: none; + border: none; + padding: 0; + margin-top: var(--space-1); +} + +.filter-panel__clear:hover { + color: var(--color-text-primary); +} + /* -------------------------------------------------------- * Gruppen-Überschriften * -------------------------------------------------------- */ diff --git a/server/db.js b/server/db.js index 9443dbc..1079106 100644 --- a/server/db.js +++ b/server/db.js @@ -388,6 +388,23 @@ const MIGRATIONS = [ CREATE INDEX IF NOT EXISTS idx_reminders_user ON reminders(created_by); `, }, + { + version: 9, + description: 'Task-Kategorien auf englische Schlüssel migrieren', + up: ` + UPDATE tasks SET category = CASE category + WHEN 'Haushalt' THEN 'household' + WHEN 'Schule' THEN 'school' + WHEN 'Einkauf' THEN 'shopping' + WHEN 'Reparatur' THEN 'repair' + WHEN 'Gesundheit' THEN 'health' + WHEN 'Finanzen' THEN 'finance' + WHEN 'Freizeit' THEN 'leisure' + WHEN 'Sonstiges' THEN 'misc' + ELSE category + END; + `, + }, ]; /**