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]
## [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
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+154 -39
View File
@@ -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 ? `<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>
@@ -362,6 +362,7 @@ let state = {
viewMode: 'list', // 'list' | 'kanban' (resolved at render time)
expandedTasks: new Set(),
dragTaskId: null,
filterPanelOpen: false,
};
// --------------------------------------------------------
@@ -883,49 +884,148 @@ function renderTaskList(container) {
function renderFilters(container) {
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 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(`<span class="filter-chip filter-chip--active" data-filter="status">
${statusLabels[state.filters.status]}
<span class="filter-chip__remove" aria-hidden="true">×</span>
</span>`);
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(`<span class="filter-chip filter-chip--active" data-filter="priority">
${priorityLabels[state.filters.priority]}
<span class="filter-chip__remove" aria-hidden="true">×</span>
</span>`);
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(`<span class="filter-chip filter-chip--active" data-filter="assigned_to">
${u?.display_name ?? 'Person'}
<span class="filter-chip__remove" aria-hidden="true">×</span>
</span>`);
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(`<span class="filter-chip" data-filter="status" data-value="${s.value}">${s.label}</span>`);
});
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);
}
if (!state.filters.priority) {
PRIORITIES().forEach((p) => {
chips.push(`<span class="filter-chip" data-filter="priority" data-value="${p.value}">${p.label}</span>`);
});
}
if (!state.filters.assigned_to && state.users.length > 1) {
state.users.forEach((u) => {
chips.push(`<span class="filter-chip" data-filter="assigned_to" data-value="${u.id}">${u.display_name}</span>`);
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 })),
});
}
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);
}
@@ -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 }) {
</div>
<div class="tasks-filters" id="filter-bar"></div>
<div class="filter-panel" id="filter-panel" hidden></div>
<div id="task-list">
${[1,2,3].map(() => `
+93
View File
@@ -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
* -------------------------------------------------------- */
+17
View File
@@ -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;
`,
},
];
/**