feat: filter panel + english category keys
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+164
-49
@@ -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>
|
||||
|
||||
@@ -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(`<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>`);
|
||||
});
|
||||
}
|
||||
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>`);
|
||||
});
|
||||
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 }) {
|
||||
</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(() => `
|
||||
|
||||
@@ -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
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
@@ -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;
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user