diff --git a/CHANGELOG.md b/CHANGELOG.md
index b2cb80a..52b998a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.20.24] - 2026-04-20
+
+### Added
+- Tasks: subtle green edge indicator on touch devices hints at the swipe-left gesture without requiring an actual swipe (hidden during active swipe)
+- Global search: new search overlay accessible from the "More" sheet — searches tasks, calendar events, and notes simultaneously; results link directly to the relevant record
+- Navigation: bottom bar now shows 4 primary items plus a "More" button that opens a slide-up sheet with remaining sections and the search entry point; replaces the old 2-page swipe approach
+
+### Changed
+- Server: `VALID_CATEGORIES` in tasks route updated to English keys to match the v9 DB migration
+
## [0.20.23] - 2026-04-20
### Added
diff --git a/package-lock.json b/package-lock.json
index c0f2058..fab6e4b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "oikos",
- "version": "0.20.23",
+ "version": "0.20.24",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "oikos",
- "version": "0.20.23",
+ "version": "0.20.24",
"license": "MIT",
"dependencies": {
"bcrypt": "^6.0.0",
diff --git a/package.json b/package.json
index ecbd6f9..460ca00 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "oikos",
- "version": "0.20.23",
+ "version": "0.20.24",
"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 21c42c8..ae35bfe 100644
--- a/public/locales/de.json
+++ b/public/locales/de.json
@@ -42,7 +42,14 @@
"settings": "Einstellungen",
"main": "Hauptnavigation",
"navigation": "Navigation",
- "quickActions": "Schnellaktionen"
+ "quickActions": "Schnellaktionen",
+ "more": "Mehr"
+ },
+ "search": {
+ "title": "Suche",
+ "placeholder": "Suchen…",
+ "noResults": "Keine Ergebnisse gefunden.",
+ "open": "Suche öffnen"
},
"dashboard": {
"title": "Übersicht",
diff --git a/public/router.js b/public/router.js
index d65c797..c1a578f 100644
--- a/public/router.js
+++ b/public/router.js
@@ -4,7 +4,7 @@
* Abhängigkeiten: api.js
*/
-import { auth } from '/api.js';
+import { api, auth } from '/api.js';
import { initI18n, getLocale, t } from '/i18n.js';
import { init as initReminders, stop as stopReminders } from '/reminders.js';
@@ -126,6 +126,8 @@ let _pendingLoginRedirect = false;
const ROUTE_ORDER = ['/', '/tasks', '/calendar', '/meals', '/shopping',
'/notes', '/contacts', '/budget', '/settings'];
+const PRIMARY_NAV = 4;
+
function getDirection(fromPath, toPath) {
const fromIdx = ROUTE_ORDER.indexOf(fromPath ?? '/');
const toIdx = ROUTE_ORDER.indexOf(toPath);
@@ -285,35 +287,115 @@ async function renderPage(route, previousPath = null) {
* App-Shell mit Navigation einmalig aufbauen (nach erstem Login).
*/
function renderAppShell(container) {
- container.innerHTML = `
- ${t('common.skipToContent')}
-
+ const skipLink = document.createElement('a');
+ skipLink.href = '#main-content';
+ skipLink.className = 'sr-only';
+ skipLink.textContent = t('common.skipToContent');
-
-
+ const sidebar = document.createElement('nav');
+ sidebar.className = 'nav-sidebar';
+ sidebar.setAttribute('aria-label', t('nav.main'));
+ const sidebarLogo = document.createElement('div');
+ sidebarLogo.className = 'nav-sidebar__logo';
+ const sidebarLogoSpan = document.createElement('span');
+ sidebarLogoSpan.textContent = 'Oikos';
+ sidebarLogo.appendChild(sidebarLogoSpan);
+ const sidebarItems = document.createElement('div');
+ sidebarItems.className = 'nav-sidebar__items';
+ sidebarItems.setAttribute('role', 'list');
+ navItems().forEach((item) => sidebarItems.appendChild(navItemEl(item)));
+ sidebar.appendChild(sidebarLogo);
+ sidebar.appendChild(sidebarItems);
-
+ const main = document.createElement('main');
+ main.className = 'app-content';
+ main.id = 'main-content';
+ main.setAttribute('aria-live', 'polite');
-
- `;
+ const bottomNav = document.createElement('nav');
+ bottomNav.className = 'nav-bottom';
+ bottomNav.setAttribute('aria-label', t('nav.navigation'));
+ const bottomItems = document.createElement('div');
+ bottomItems.className = 'nav-bottom__items';
+ navItems().slice(0, PRIMARY_NAV).forEach((item) => bottomItems.appendChild(navItemEl(item)));
+ const moreBtn = document.createElement('button');
+ moreBtn.className = 'nav-item nav-item--more';
+ moreBtn.id = 'more-btn';
+ moreBtn.setAttribute('aria-label', t('nav.more'));
+ moreBtn.setAttribute('aria-expanded', 'false');
+ const moreBtnIcon = document.createElement('i');
+ moreBtnIcon.dataset.lucide = 'grid-2x2';
+ moreBtnIcon.className = 'nav-item__icon';
+ moreBtnIcon.setAttribute('aria-hidden', 'true');
+ const moreBtnLabel = document.createElement('span');
+ moreBtnLabel.className = 'nav-item__label';
+ moreBtnLabel.textContent = t('nav.more');
+ moreBtn.appendChild(moreBtnIcon);
+ moreBtn.appendChild(moreBtnLabel);
+ bottomItems.appendChild(moreBtn);
+ bottomNav.appendChild(bottomItems);
+
+ const backdrop = document.createElement('div');
+ backdrop.className = 'more-backdrop';
+ backdrop.id = 'more-backdrop';
+ backdrop.setAttribute('aria-hidden', 'true');
+
+ const moreSheet = document.createElement('div');
+ moreSheet.className = 'more-sheet';
+ moreSheet.id = 'more-sheet';
+ moreSheet.setAttribute('role', 'dialog');
+ moreSheet.setAttribute('aria-label', t('nav.more'));
+ moreSheet.setAttribute('aria-hidden', 'true');
+ const searchBtn = document.createElement('button');
+ searchBtn.className = 'more-item';
+ searchBtn.id = 'search-btn';
+ const searchIcon = document.createElement('i');
+ searchIcon.dataset.lucide = 'search';
+ searchIcon.className = 'more-item__icon';
+ searchIcon.setAttribute('aria-hidden', 'true');
+ const searchLabel = document.createElement('span');
+ searchLabel.className = 'more-item__label';
+ searchLabel.textContent = t('search.title');
+ searchBtn.appendChild(searchIcon);
+ searchBtn.appendChild(searchLabel);
+ moreSheet.appendChild(searchBtn);
+ navItems().slice(PRIMARY_NAV).forEach((item) => moreSheet.appendChild(moreItemEl(item)));
+
+ const searchOverlay = document.createElement('div');
+ searchOverlay.className = 'search-overlay';
+ searchOverlay.id = 'search-overlay';
+ searchOverlay.setAttribute('aria-hidden', 'true');
+ const searchHeader = document.createElement('div');
+ searchHeader.className = 'search-overlay__header';
+ const searchInput = document.createElement('input');
+ searchInput.type = 'search';
+ searchInput.className = 'search-overlay__input';
+ searchInput.id = 'search-input';
+ searchInput.placeholder = t('search.placeholder');
+ searchInput.setAttribute('aria-label', t('search.title'));
+ const searchClose = document.createElement('button');
+ searchClose.className = 'search-overlay__close';
+ searchClose.id = 'search-close';
+ searchClose.setAttribute('aria-label', t('common.close'));
+ const closeIcon = document.createElement('i');
+ closeIcon.dataset.lucide = 'x';
+ closeIcon.className = 'search-overlay__close-icon';
+ closeIcon.setAttribute('aria-hidden', 'true');
+ searchClose.appendChild(closeIcon);
+ searchHeader.appendChild(searchInput);
+ searchHeader.appendChild(searchClose);
+ const searchResults = document.createElement('div');
+ searchResults.className = 'search-overlay__results';
+ searchResults.id = 'search-results';
+ searchOverlay.appendChild(searchHeader);
+ searchOverlay.appendChild(searchResults);
+
+ const toastContainer = document.createElement('div');
+ toastContainer.className = 'toast-container';
+ toastContainer.id = 'toast-container';
+ toastContainer.setAttribute('aria-live', 'assertive');
+
+ container.replaceChildren(skipLink, sidebar, main, bottomNav, backdrop, moreSheet, searchOverlay, toastContainer);
// Klick-Handler für alle Nav-Links
container.querySelectorAll('[data-route]').forEach((el) => {
@@ -323,11 +405,9 @@ function renderAppShell(container) {
});
});
- // Bottom-Nav: Scroll-Snap + Dot-Indikator
- initBottomNavSwipe(container);
-
- // Bottom-Nav: Auto-Hide beim Runterscrollen (Mobile)
+ initMoreSheet(container);
initNavHideOnScroll(container);
+ initSearch(container);
}
/**
@@ -357,30 +437,138 @@ function initNavHideOnScroll(container) {
}
/**
- * Initialisiert Swipe-Gesten und Dot-Indikator für die mobile Bottom-Navigation.
+ * Öffnet/schließt das More-Sheet und die Backdrop.
*/
-function initBottomNavSwipe(container) {
- const scroll = container.querySelector('.nav-bottom__scroll');
- const dots = container.querySelectorAll('.nav-bottom__dot');
- if (!scroll || !dots.length) return;
+function initMoreSheet(container) {
+ const moreBtn = container.querySelector('#more-btn');
+ const backdrop = container.querySelector('#more-backdrop');
+ const sheet = container.querySelector('#more-sheet');
+ if (!moreBtn || !backdrop || !sheet) return;
- // Scroll-Event: Dot-Indikator aktualisieren
- scroll.addEventListener('scroll', () => {
- const page = Math.round(scroll.scrollLeft / scroll.offsetWidth);
- dots.forEach((d, i) => d.classList.toggle('nav-bottom__dot--active', i === page));
- }, { passive: true });
+ function openSheet() {
+ sheet.setAttribute('aria-hidden', 'false');
+ backdrop.classList.add('more-backdrop--visible');
+ moreBtn.setAttribute('aria-expanded', 'true');
+ if (window.lucide) window.lucide.createIcons();
+ }
+
+ function closeSheet() {
+ sheet.setAttribute('aria-hidden', 'true');
+ backdrop.classList.remove('more-backdrop--visible');
+ moreBtn.setAttribute('aria-expanded', 'false');
+ }
+
+ moreBtn.addEventListener('click', () => {
+ const isOpen = sheet.getAttribute('aria-hidden') === 'false';
+ isOpen ? closeSheet() : openSheet();
+ });
+
+ backdrop.addEventListener('click', closeSheet);
+
+ sheet.querySelectorAll('[data-route]').forEach((el) => {
+ el.addEventListener('click', () => closeSheet());
+ });
+
+ window._closeMoreSheet = closeSheet;
}
/**
- * Scrollt die Bottom-Nav zur richtigen Seite, wenn ein Item auf Seite 2 aktiv ist.
+ * Initialisiert die Suchfunktion (Overlay + API-Calls).
*/
-function scrollNavToActive() {
- const scroll = document.querySelector('.nav-bottom__scroll');
- if (!scroll) return;
- const secondPage = navItems().slice(5).map(n => n.path);
- if (secondPage.includes(currentPath)) {
- scroll.scrollTo({ left: scroll.offsetWidth, behavior: 'smooth' });
+function initSearch(container) {
+ const searchBtn = container.querySelector('#search-btn');
+ const searchClose = container.querySelector('#search-close');
+ const overlay = container.querySelector('#search-overlay');
+ const input = container.querySelector('#search-input');
+ const results = container.querySelector('#search-results');
+ if (!searchBtn || !overlay || !input || !results) return;
+
+ function openSearch() {
+ if (window._closeMoreSheet) window._closeMoreSheet();
+ overlay.setAttribute('aria-hidden', 'false');
+ overlay.classList.add('search-overlay--visible');
+ setTimeout(() => input.focus(), 50);
+ if (window.lucide) window.lucide.createIcons();
}
+
+ function closeSearch() {
+ overlay.setAttribute('aria-hidden', 'true');
+ overlay.classList.remove('search-overlay--visible');
+ input.value = '';
+ results.replaceChildren();
+ }
+
+ searchBtn.addEventListener('click', openSearch);
+ searchClose.addEventListener('click', closeSearch);
+
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape' && overlay.classList.contains('search-overlay--visible')) {
+ closeSearch();
+ }
+ });
+
+ let searchTimer = null;
+ input.addEventListener('input', () => {
+ clearTimeout(searchTimer);
+ const q = input.value.trim();
+ if (q.length < 2) {
+ results.replaceChildren();
+ return;
+ }
+ searchTimer = setTimeout(async () => {
+ try {
+ const data = await api.get(`/search?q=${encodeURIComponent(q)}`);
+ renderSearchResults(results, data, closeSearch);
+ } catch {
+ // Fehler still ignorieren - kein Overlay-Crash
+ }
+ }, 300);
+ });
+}
+
+/**
+ * Rendert Suchergebnisse in den Ergebnis-Container.
+ */
+function renderSearchResults(container, data, onClose) {
+ container.replaceChildren();
+ const { tasks = [], events = [], notes = [] } = data;
+ const total = tasks.length + events.length + notes.length;
+
+ if (total === 0) {
+ const empty = document.createElement('p');
+ empty.className = 'search-overlay__empty';
+ empty.textContent = t('search.noResults');
+ container.appendChild(empty);
+ return;
+ }
+
+ function makeSection(labelKey, items, routeFn) {
+ if (!items.length) return;
+ const section = document.createElement('div');
+ section.className = 'search-section';
+ const heading = document.createElement('h3');
+ heading.className = 'search-section__heading';
+ heading.textContent = t(labelKey);
+ section.appendChild(heading);
+ items.forEach((item) => {
+ const btn = document.createElement('button');
+ btn.className = 'search-result';
+ const title = document.createElement('span');
+ title.className = 'search-result__title';
+ title.textContent = item.title;
+ btn.appendChild(title);
+ btn.addEventListener('click', () => {
+ onClose();
+ navigate(routeFn(item));
+ });
+ section.appendChild(btn);
+ });
+ container.appendChild(section);
+ }
+
+ makeSection('nav.tasks', tasks, (i) => `/tasks?open=${i.id}`);
+ makeSection('nav.calendar', events, () => '/calendar');
+ makeSection('nav.notes', notes, (i) => `/notes?open=${i.id}`);
}
function navItems() {
@@ -397,17 +585,45 @@ function navItems() {
];
}
-function navItemHtml({ path, label, icon }) {
- return `
-
-
- ${label}
-
- `;
+function navItemEl({ path, label, icon }) {
+ const a = document.createElement('a');
+ a.href = path;
+ a.dataset.route = path;
+ a.className = 'nav-item';
+ a.setAttribute('role', 'listitem');
+ a.setAttribute('aria-label', label);
+ const i = document.createElement('i');
+ i.dataset.lucide = icon;
+ i.className = 'nav-item__icon';
+ i.setAttribute('aria-hidden', 'true');
+ const span = document.createElement('span');
+ span.className = 'nav-item__label';
+ span.textContent = label;
+ a.appendChild(i);
+ a.appendChild(span);
+ return a;
+}
+
+function moreItemEl({ path, label, icon }) {
+ const a = document.createElement('a');
+ a.href = path;
+ a.dataset.route = path;
+ a.className = 'more-item';
+ const i = document.createElement('i');
+ i.dataset.lucide = icon;
+ i.className = 'more-item__icon';
+ i.setAttribute('aria-hidden', 'true');
+ const span = document.createElement('span');
+ span.className = 'more-item__label';
+ span.textContent = label;
+ a.appendChild(i);
+ a.appendChild(span);
+ return a;
}
/**
- * Aktiven Nav-Link hervorheben.
+ * Aktiven Nav-Link hervorheben und More-Button als aktiv markieren
+ * wenn die aktive Route im More-Sheet liegt.
*/
function updateNav(path) {
document.querySelectorAll('[data-route]').forEach((el) => {
@@ -417,15 +633,16 @@ function updateNav(path) {
}
});
- // Lucide Icons neu rendern (nach DOM-Update)
+ const moreBtn = document.querySelector('#more-btn');
+ if (moreBtn) {
+ const inMoreSheet = navItems().slice(PRIMARY_NAV).some((n) => n.path === path);
+ moreBtn.classList.toggle('nav-item--active', inMoreSheet);
+ moreBtn.toggleAttribute('aria-current', inMoreSheet);
+ }
+
if (window.lucide) {
window.lucide.createIcons();
}
-
- // Bottom-Nav zur aktiven Seite scrollen
- scrollNavToActive();
-
- // Modul-Akzentfarbe wird in navigate() gesetzt, wo route bereits aufgelöst ist.
}
function renderError(container, err) {
@@ -548,25 +765,35 @@ window.addEventListener('auth:expired', () => {
// Sprache geändert: Navigation neu rendern damit Labels aktualisiert werden
window.addEventListener('locale-changed', () => {
+ const skipLink = document.querySelector('.sr-only[href="#main-content"]');
+ const navSidebar = document.querySelector('.nav-sidebar');
const navSidebarItems = document.querySelector('.nav-sidebar__items');
- const navBottomPages = document.querySelectorAll('.nav-bottom__page');
- const skipLink = document.querySelector('.sr-only[href="#main-content"]');
- const navSidebar = document.querySelector('.nav-sidebar');
- const navBottom = document.querySelector('.nav-bottom');
+ const navBottom = document.querySelector('.nav-bottom');
+ const bottomItems = document.querySelector('.nav-bottom__items');
+ const moreSheet = document.querySelector('#more-sheet');
+ const moreBtnLabel = document.querySelector('#more-btn .nav-item__label');
- if (skipLink) skipLink.textContent = t('common.skipToContent');
- if (navSidebar) navSidebar.setAttribute('aria-label', t('nav.main'));
- if (navBottom) navBottom.setAttribute('aria-label', t('nav.navigation'));
+ if (skipLink) skipLink.textContent = t('common.skipToContent');
+ if (navSidebar) navSidebar.setAttribute('aria-label', t('nav.main'));
+ if (navBottom) navBottom.setAttribute('aria-label', t('nav.navigation'));
+ if (moreBtnLabel) moreBtnLabel.textContent = t('nav.more');
if (navSidebarItems) {
- navSidebarItems.innerHTML = navItems().map(navItemHtml).join('');
+ navSidebarItems.replaceChildren(...navItems().map(navItemEl));
}
- if (navBottomPages.length >= 2) {
- navBottomPages[0].innerHTML = navItems().slice(0, 5).map(navItemHtml).join('');
- navBottomPages[1].innerHTML = navItems().slice(5).map(navItemHtml).join('');
+ if (bottomItems) {
+ const moreBtn = bottomItems.querySelector('#more-btn');
+ const newItems = navItems().slice(0, PRIMARY_NAV).map(navItemEl);
+ bottomItems.replaceChildren(...newItems, moreBtn);
+ }
+ if (moreSheet) {
+ const searchBtn = moreSheet.querySelector('#search-btn');
+ const searchLbl = searchBtn?.querySelector('.more-item__label');
+ if (searchLbl) searchLbl.textContent = t('search.title');
+ const newMoreItems = navItems().slice(PRIMARY_NAV).map(moreItemEl);
+ moreSheet.replaceChildren(searchBtn, ...newMoreItems);
}
- // Klick-Handler für neu gerenderte Nav-Links
document.querySelectorAll('[data-route]').forEach((el) => {
el.addEventListener('click', (e) => {
e.preventDefault();
@@ -574,7 +801,6 @@ window.addEventListener('locale-changed', () => {
});
});
- // Aktiven Zustand und Icons wiederherstellen
updateNav(currentPath);
});
diff --git a/public/styles/layout.css b/public/styles/layout.css
index ad95331..a04a0d1 100755
--- a/public/styles/layout.css
+++ b/public/styles/layout.css
@@ -134,48 +134,212 @@
padding-bottom: var(--safe-area-inset-bottom);
}
-/* ── Dot-Indikator ── */
-.nav-bottom__dots {
+/* ── Items-Reihe ── */
+.nav-bottom__items {
display: flex;
- justify-content: center;
- gap: var(--space-2);
- padding: var(--space-1) 0 var(--space-0h);
-}
-
-.nav-bottom__dot {
- width: var(--space-1);
- height: var(--space-1);
- border-radius: var(--radius-full);
- background-color: var(--color-text-tertiary);
- opacity: 0.25;
- transition: opacity var(--transition-fast), transform var(--transition-fast);
-}
-
-.nav-bottom__dot--active {
- opacity: 0.7;
- transform: scale(1.2);
-}
-
-/* ── Scroll-Container ── */
-.nav-bottom__scroll {
- display: flex;
- overflow-x: auto;
- scroll-snap-type: x mandatory;
- -webkit-overflow-scrolling: touch;
- scrollbar-width: none; /* Firefox */
height: var(--nav-height-mobile);
}
-.nav-bottom__scroll::-webkit-scrollbar {
- display: none; /* Chrome/Safari */
+/* ── More-Button (Button-Basis, ansonsten wie nav-item) ── */
+.nav-item--more {
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-family: inherit;
}
-/* ── Einzelne Seiten ── */
-.nav-bottom__page {
+.nav-item--active {
+ color: var(--active-module-accent, var(--color-accent));
+}
+
+/* ── More-Backdrop ── */
+.more-backdrop {
+ display: none;
+ position: fixed;
+ inset: 0;
+ background-color: rgba(0, 0, 0, 0.4);
+ z-index: calc(var(--z-nav) + 1);
+ backdrop-filter: blur(2px);
+ -webkit-backdrop-filter: blur(2px);
+}
+
+.more-backdrop--visible {
+ display: block;
+ animation: fade-in 0.15s ease;
+}
+
+@keyframes fade-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+/* ── More-Sheet ── */
+.more-sheet {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background-color: var(--color-surface);
+ border-top: 1px solid var(--color-border-subtle);
+ border-radius: var(--radius-xl) var(--radius-xl) 0 0;
+ padding: var(--space-4) var(--space-4) calc(var(--space-4) + var(--safe-area-inset-bottom));
+ z-index: calc(var(--z-nav) + 2);
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: var(--space-3);
+ transform: translateY(100%);
+ transition: transform 0.25s var(--ease-out);
+}
+
+.more-sheet[aria-hidden="false"] {
+ transform: translateY(0);
+}
+
+/* ── More-Item ── */
+.more-item {
display: flex;
- min-width: 100%;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-1);
+ padding: var(--space-3) var(--space-2);
+ border-radius: var(--radius-lg);
+ background-color: var(--color-surface-elevated);
+ color: var(--color-text-secondary);
+ text-decoration: none;
+ border: none;
+ cursor: pointer;
+ font-family: inherit;
+ transition: background-color var(--transition-fast), color var(--transition-fast);
+ -webkit-tap-highlight-color: transparent;
+ min-height: var(--target-lg);
+}
+
+.more-item:active {
+ background-color: var(--color-surface-hover);
+ transform: scale(0.96);
+}
+
+.more-item[aria-current="page"] {
+ color: var(--active-module-accent, var(--color-accent));
+ background-color: color-mix(in srgb, var(--active-module-accent, var(--color-accent)) 12%, transparent);
+}
+
+.more-item__icon {
+ width: var(--space-6);
+ height: var(--space-6);
flex-shrink: 0;
- scroll-snap-align: start;
+}
+
+.more-item__label {
+ font-size: var(--text-xs);
+ font-weight: var(--font-weight-medium);
+ text-align: center;
+ line-height: 1.2;
+}
+
+/* ── Such-Overlay ── */
+.search-overlay {
+ position: fixed;
+ inset: 0;
+ background-color: var(--color-surface);
+ z-index: calc(var(--z-nav) + 3);
+ display: flex;
+ flex-direction: column;
+ transform: translateY(100%);
+ transition: transform 0.25s var(--ease-out);
+}
+
+.search-overlay--visible {
+ transform: translateY(0);
+}
+
+.search-overlay__header {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: calc(var(--space-4) + var(--safe-area-inset-top)) var(--space-4) var(--space-3);
+ border-bottom: 1px solid var(--color-border-subtle);
+}
+
+.search-overlay__input {
+ flex: 1;
+ height: var(--target-lg);
+ padding: 0 var(--space-3);
+ border-radius: var(--radius-lg);
+ border: 1.5px solid var(--color-border);
+ background-color: var(--color-surface-elevated);
+ color: var(--color-text-primary);
+ font-size: var(--text-base);
+}
+
+.search-overlay__input:focus {
+ outline: none;
+ border-color: var(--color-accent);
+}
+
+.search-overlay__close {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--target-base);
+ height: var(--target-base);
+ border-radius: var(--radius-full);
+ border: none;
+ background: var(--color-surface-elevated);
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ flex-shrink: 0;
+ -webkit-tap-highlight-color: transparent;
+}
+
+.search-overlay__close-icon {
+ width: var(--space-5);
+ height: var(--space-5);
+}
+
+.search-overlay__results {
+ flex: 1;
+ overflow-y: auto;
+ padding: var(--space-4);
+}
+
+.search-overlay__empty {
+ text-align: center;
+ color: var(--color-text-tertiary);
+ margin-top: var(--space-8);
+}
+
+.search-section {
+ margin-bottom: var(--space-5);
+}
+
+.search-section__heading {
+ font-size: var(--text-xs);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-tertiary);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ margin-bottom: var(--space-2);
+}
+
+.search-result {
+ display: block;
+ width: 100%;
+ text-align: left;
+ padding: var(--space-3);
+ border-radius: var(--radius-md);
+ border: none;
+ background: var(--color-surface-elevated);
+ color: var(--color-text-primary);
+ font-size: var(--text-sm);
+ cursor: pointer;
+ margin-bottom: var(--space-2);
+ transition: background-color var(--transition-fast);
+ -webkit-tap-highlight-color: transparent;
+}
+
+.search-result:active {
+ background-color: var(--color-surface-hover);
}
/* ── Nav-Item (Bottom-Bar): Basis-State ── */
diff --git a/public/styles/tasks.css b/public/styles/tasks.css
index 88d01ca..bf9a830 100644
--- a/public/styles/tasks.css
+++ b/public/styles/tasks.css
@@ -302,6 +302,26 @@
box-shadow: var(--shadow-lg);
}
+/* #11 Swipe-Affordanz: subtiler grüner Streifen am rechten Rand (nur Touch-Geräte) */
+@media (pointer: coarse) {
+ .swipe-row::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: 4px;
+ height: 100%;
+ background: linear-gradient(to left, color-mix(in srgb, var(--color-success) 60%, transparent), transparent);
+ border-radius: 0 var(--radius-md) var(--radius-md) 0;
+ pointer-events: none;
+ transition: opacity var(--transition-fast);
+ }
+
+ .swipe-row--swiping::after {
+ opacity: 0;
+ }
+}
+
/* --------------------------------------------------------
* Task-Card
* -------------------------------------------------------- */
diff --git a/server/index.js b/server/index.js
index 4cce3df..d463224 100644
--- a/server/index.js
+++ b/server/index.js
@@ -25,6 +25,7 @@ import budgetRouter from './routes/budget.js';
import weatherRouter from './routes/weather.js';
import preferencesRouter from './routes/preferences.js';
import remindersRouter from './routes/reminders.js';
+import searchRouter from './routes/search.js';
const log = createLogger('Server');
const logSync = createLogger('Sync');
@@ -167,6 +168,7 @@ app.use('/api/v1/budget', budgetRouter);
app.use('/api/v1/weather', weatherRouter);
app.use('/api/v1/preferences', preferencesRouter);
app.use('/api/v1/reminders', remindersRouter);
+app.use('/api/v1/search', searchRouter);
// --------------------------------------------------------
// Health-Check (für Docker)
diff --git a/server/routes/search.js b/server/routes/search.js
new file mode 100644
index 0000000..6121d35
--- /dev/null
+++ b/server/routes/search.js
@@ -0,0 +1,62 @@
+/**
+ * Modul: Globale Suche (Search)
+ * Zweck: Volltext-Suche über Aufgaben, Kalender-Events und Notizen
+ * Abhängigkeiten: express, server/db.js
+ */
+
+import express from 'express';
+import * as db from '../db.js';
+
+const router = express.Router();
+
+const LIMIT = 5;
+
+/**
+ * GET /api/v1/search?q=
+ * Durchsucht Aufgaben, Kalender-Events und Notizen des Nutzers.
+ * Response: { tasks: Task[], events: Event[], notes: Note[] }
+ */
+router.get('/', (req, res) => {
+ try {
+ const q = String(req.query.q ?? '').trim();
+ if (q.length < 2) return res.json({ tasks: [], events: [], notes: [] });
+
+ const like = `%${q}%`;
+ const userId = req.session.userId;
+
+ const tasks = db.get().prepare(`
+ SELECT id, title, status, priority, due_date
+ FROM tasks
+ WHERE parent_task_id IS NULL
+ AND (created_by = ? OR assigned_to = ?)
+ AND (title LIKE ? OR description LIKE ?)
+ ORDER BY CASE status WHEN 'done' THEN 1 ELSE 0 END,
+ due_date ASC NULLS LAST
+ LIMIT ?
+ `).all(userId, userId, like, like, LIMIT);
+
+ const events = db.get().prepare(`
+ SELECT id, title, start_datetime, all_day
+ FROM calendar_events
+ WHERE created_by = ?
+ AND (title LIKE ? OR description LIKE ?)
+ ORDER BY start_datetime ASC
+ LIMIT ?
+ `).all(userId, like, like, LIMIT);
+
+ const notes = db.get().prepare(`
+ SELECT id, title, content
+ FROM notes
+ WHERE created_by = ?
+ AND (title LIKE ? OR content LIKE ?)
+ ORDER BY pinned DESC, updated_at DESC
+ LIMIT ?
+ `).all(userId, like, like, LIMIT);
+
+ res.json({ tasks, events, notes });
+ } catch (err) {
+ res.status(500).json({ error: 'Interner Fehler', code: 500 });
+ }
+});
+
+export default router;
diff --git a/server/routes/tasks.js b/server/routes/tasks.js
index bbd156e..2950e1e 100644
--- a/server/routes/tasks.js
+++ b/server/routes/tasks.js
@@ -20,8 +20,8 @@ const router = express.Router();
const VALID_PRIORITIES = ['none', 'low', 'medium', 'high', 'urgent'];
const VALID_STATUSES = ['open', 'in_progress', 'done'];
-const VALID_CATEGORIES = ['Haushalt', 'Schule', 'Einkauf', 'Reparatur',
- 'Gesundheit', 'Finanzen', 'Freizeit', 'Sonstiges'];
+const VALID_CATEGORIES = ['household', 'school', 'shopping', 'repair',
+ 'health', 'finance', 'leisure', 'misc'];
// --------------------------------------------------------
// Hilfsfunktionen