feat: filter panel + english category keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ulas Kalayci
2026-04-20 09:50:55 +02:00
parent b867917995
commit aae895d704
7 changed files with 291 additions and 53 deletions
+8
View File
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [0.20.22] - 2026-04-20
### Added ### Added
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "oikos", "name": "oikos",
"version": "0.20.22", "version": "0.20.23",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "oikos", "name": "oikos",
"version": "0.20.22", "version": "0.20.23",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "oikos", "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.", "description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
"main": "server/index.js", "main": "server/index.js",
"type": "module", "type": "module",
+6 -1
View File
@@ -154,7 +154,12 @@
"listView": "Listenansicht", "listView": "Listenansicht",
"kanbanView": "Kanban-Ansicht", "kanbanView": "Kanban-Ansicht",
"swipedDoneToast": "Als erledigt markiert.", "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": { "shopping": {
"title": "Einkauf", "title": "Einkauf",
+154 -39
View File
@@ -31,19 +31,19 @@ const STATUSES = () => [
]; ];
const CATEGORIES = [ const CATEGORIES = [
'Haushalt', 'Schule', 'Einkauf', 'Reparatur', 'household', 'school', 'shopping', 'repair',
'Gesundheit', 'Finanzen', 'Freizeit', 'Sonstiges', 'health', 'finance', 'leisure', 'misc',
]; ];
const CATEGORY_LABELS = () => ({ const CATEGORY_LABELS = () => ({
'Haushalt': t('tasks.categoryHousehold'), 'household': t('tasks.categoryHousehold'),
'Schule': t('tasks.categorySchool'), 'school': t('tasks.categorySchool'),
'Einkauf': t('tasks.categoryShopping'), 'shopping': t('tasks.categoryShopping'),
'Reparatur': t('tasks.categoryRepair'), 'repair': t('tasks.categoryRepair'),
'Gesundheit': t('tasks.categoryHealth'), 'health': t('tasks.categoryHealth'),
'Finanzen': t('tasks.categoryFinance'), 'finance': t('tasks.categoryFinance'),
'Freizeit': t('tasks.categoryLeisure'), 'leisure': t('tasks.categoryLeisure'),
'Sonstiges': t('tasks.categoryMisc'), 'misc': t('tasks.categoryMisc'),
}); });
const PRIORITY_LABELS = () => Object.fromEntries(PRIORITIES().map((p) => [p.value, p.label])); const PRIORITY_LABELS = () => Object.fromEntries(PRIORITIES().map((p) => [p.value, p.label]));
@@ -180,7 +180,7 @@ function renderTaskCard(task, opts = {}) {
${renderPriorityBadge(task.priority)} ${renderPriorityBadge(task.priority)}
${renderDueDate(task.due_date)} ${renderDueDate(task.due_date)}
${task.is_recurring ? `<span class="due-date" aria-label="${t('tasks.recurring')}"><i data-lucide="repeat" class="icon-sm" aria-hidden="true"></i></span>` : ''} ${task.is_recurring ? `<span class="due-date" aria-label="${t('tasks.recurring')}"><i data-lucide="repeat" class="icon-sm" aria-hidden="true"></i></span>` : ''}
${task.category !== 'Sonstiges' ? `<span class="due-date">${CATEGORY_LABELS()[task.category] ?? task.category}</span>` : ''} ${task.category !== 'misc' ? `<span class="due-date">${CATEGORY_LABELS()[task.category] ?? task.category}</span>` : ''}
</div> </div>
</div> </div>
@@ -362,6 +362,7 @@ let state = {
viewMode: 'list', // 'list' | 'kanban' (resolved at render time) viewMode: 'list', // 'list' | 'kanban' (resolved at render time)
expandedTasks: new Set(), expandedTasks: new Set(),
dragTaskId: null, dragTaskId: null,
filterPanelOpen: false,
}; };
// -------------------------------------------------------- // --------------------------------------------------------
@@ -883,49 +884,148 @@ function renderTaskList(container) {
function renderFilters(container) { function renderFilters(container) {
const bar = container.querySelector('#filter-bar'); const bar = container.querySelector('#filter-bar');
if (!bar) return; const panel = container.querySelector('#filter-panel');
if (!bar || !panel) return;
const chips = [];
const statusLabels = STATUS_LABELS(); const statusLabels = STATUS_LABELS();
const priorityLabels = PRIORITY_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) { if (state.filters.status) {
chips.push(`<span class="filter-chip filter-chip--active" data-filter="status"> const chip = document.createElement('span');
${statusLabels[state.filters.status]} chip.className = 'filter-chip filter-chip--active';
<span class="filter-chip__remove" aria-hidden="true">×</span> chip.dataset.filter = 'status';
</span>`); 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) { if (state.filters.priority) {
chips.push(`<span class="filter-chip filter-chip--active" data-filter="priority"> const chip = document.createElement('span');
${priorityLabels[state.filters.priority]} chip.className = 'filter-chip filter-chip--active';
<span class="filter-chip__remove" aria-hidden="true">×</span> chip.dataset.filter = 'priority';
</span>`); 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) { if (state.filters.assigned_to) {
const u = state.users.find((u) => u.id === Number(state.filters.assigned_to)); const u = state.users.find((u) => u.id === Number(state.filters.assigned_to));
chips.push(`<span class="filter-chip filter-chip--active" data-filter="assigned_to"> const chip = document.createElement('span');
${u?.display_name ?? 'Person'} chip.className = 'filter-chip filter-chip--active';
<span class="filter-chip__remove" aria-hidden="true">×</span> chip.dataset.filter = 'assigned_to';
</span>`); 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) const toggleBtn = document.createElement('button');
if (!state.filters.status) { toggleBtn.id = 'filter-toggle-btn';
STATUSES().forEach((s) => { toggleBtn.className = `filter-toggle-btn${state.filterPanelOpen ? ' filter-toggle-btn--open' : ''}${activeCount > 0 ? ' filter-toggle-btn--active' : ''}`;
chips.push(`<span class="filter-chip" data-filter="status" data-value="${s.value}">${s.label}</span>`); 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);
} }
if (!state.filters.priority) {
PRIORITIES().forEach((p) => { bar.appendChild(toggleBtn);
chips.push(`<span class="filter-chip" data-filter="priority" data-value="${p.value}">${p.label}</span>`); if (window.lucide) window.lucide.createIcons({ el: bar });
});
} // ---- Filter-Panel: Gruppen mit allen Optionen ----
if (!state.filters.assigned_to && state.users.length > 1) { panel.hidden = !state.filterPanelOpen;
state.users.forEach((u) => { panel.replaceChildren();
chips.push(`<span class="filter-chip" data-filter="assigned_to" data-value="${u.id}">${u.display_name}</span>`);
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 })),
}); });
} }
bar.innerHTML = chips.join(''); 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);
}
}
wireFilterChips(container); wireFilterChips(container);
} }
@@ -1135,6 +1235,20 @@ function maybeShowSwipeHint(container) {
// -------------------------------------------------------- // --------------------------------------------------------
function wireFilterChips(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) => { container.querySelectorAll('[data-filter]').forEach((chip) => {
chip.addEventListener('click', async () => { chip.addEventListener('click', async () => {
const filter = chip.dataset.filter; const filter = chip.dataset.filter;
@@ -1292,6 +1406,7 @@ export async function render(container, { user }) {
</div> </div>
<div class="tasks-filters" id="filter-bar"></div> <div class="tasks-filters" id="filter-bar"></div>
<div class="filter-panel" id="filter-panel" hidden></div>
<div id="task-list"> <div id="task-list">
${[1,2,3].map(() => ` ${[1,2,3].map(() => `
+93
View File
@@ -150,6 +150,99 @@
line-height: 1; 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 * Gruppen-Überschriften
* -------------------------------------------------------- */ * -------------------------------------------------------- */
+17
View File
@@ -388,6 +388,23 @@ const MIGRATIONS = [
CREATE INDEX IF NOT EXISTS idx_reminders_user ON reminders(created_by); 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;
`,
},
]; ];
/** /**