From 4fe4f6cb3863c6b8e7e911c4b04e1c69fa3665f4 Mon Sep 17 00:00:00 2001 From: Ulas Date: Tue, 31 Mar 2026 10:35:03 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20BL-07=E2=80=93BL-10=20=E2=80=94=20notes?= =?UTF-8?q?=20search,=20weather=20refresh,=20vCard=20import/export,=20PWA?= =?UTF-8?q?=20offline=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Notes: client-side full-text search bar (filters title + content) - Dashboard: weather refresh button + 30-min auto-refresh interval - Contacts: vCard 3.0 export per contact (GET /:id/vcard); vCard import via file input with client-side parser (FN, TEL, EMAIL, ADR, NOTE, CATEGORIES) - PWA: /offline.html served when network unavailable; cached in app-shell (sw v20) Co-Authored-By: Claude Sonnet 4.6 --- BACKLOG.md | 8 +++ CHANGELOG.md | 6 +++ public/offline.html | 104 ++++++++++++++++++++++++++++++++++++ public/pages/contacts.js | 63 ++++++++++++++++++++++ public/pages/dashboard.js | 51 +++++++++++++++++- public/pages/notes.js | 30 +++++++++-- public/styles/dashboard.css | 36 +++++++++++++ public/styles/notes.css | 33 ++++++++++++ public/sw.js | 15 ++++-- server/routes/contacts.js | 38 +++++++++++++ 10 files changed, 374 insertions(+), 10 deletions(-) create mode 100644 public/offline.html diff --git a/BACKLOG.md b/BACKLOG.md index 24a0587..1c66637 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -95,24 +95,32 @@ Das Budget-Formular hat eine „Wiederkehrend"-Checkbox und speichert `is_recurr ### BL-07 — Notizen: Volltextsuche / Filter +**Status:** Erledigt (v0.4.0) + Derzeit keine Suchfunktion in der Pinnwand. Die Notizen liegen im State, eine Client-seitige Filterleiste wäre ohne API-Änderung machbar. --- ### BL-08 — Dashboard: Wetter-Widget Refresh +**Status:** Erledigt (v0.4.0) + Wetter-Widget lädt beim Seitenaufruf und hat keinen manuellen Refresh-Button. Bei langem Tab-Offenbleiben können die Daten veralten. Ein 30-Minuten-Interval oder ein Refresh-Icon wäre sinnvoll (SPEC erwähnt „Refresh 30min" implizit). --- ### BL-09 — Kontakte: vCard-Import / -Export +**Status:** Erledigt (v0.4.0) + Nicht im SPEC, aber naheliegend: `.vcf`-Export eines Kontakts, Import aus vCard für Erstbefüllung. --- ### BL-10 — PWA: Offline-Fallback für kritische Seiten +**Status:** Erledigt (v0.4.0) + Der Service Worker cached aktuell den App-Shell. Bei Offline-Nutzung fehlt eine sinnvolle Fallback-Seite mit dem Hinweis auf fehlende Verbindung und einem „Wiederholen"-Button. --- diff --git a/CHANGELOG.md b/CHANGELOG.md index b8f807e..f7911ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Notes: client-side full-text search bar in toolbar — filters by title and content instantly; shows "Keine Treffer" empty state when no match +- Dashboard: weather widget refresh button (top-right corner) + automatic 30-minute refresh interval; interval is cleared when navigating away +- Contacts: vCard export button per contact (downloads .vcf file); vCard import via file input in toolbar (parses FN, TEL, EMAIL, ADR, NOTE, CATEGORIES fields) +- PWA: offline fallback page (`/offline.html`) served by service worker when network is unavailable and index.html is not cached; page includes a reload button + ## [0.3.0] - 2026-03-31 ### Added diff --git a/public/offline.html b/public/offline.html new file mode 100644 index 0000000..efb0f0c --- /dev/null +++ b/public/offline.html @@ -0,0 +1,104 @@ + + + + + + Keine Verbindung – Oikos + + + +
+ +

Keine Verbindung

+

Oikos ist gerade nicht erreichbar. Bitte prüfe deine Internetverbindung und versuche es erneut.

+ +
+ + diff --git a/public/pages/contacts.js b/public/pages/contacts.js index d196d14..a19b225 100644 --- a/public/pages/contacts.js +++ b/public/pages/contacts.js @@ -52,6 +52,11 @@ export async function render(container, { user }) { id="contacts-search" placeholder="Name, Telefon oder E-Mail suchen…" autocomplete="off"> + @@ -327,3 +354,39 @@ function escHtml(str) { .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } + +/** + * Minimaler vCard 3.0/4.0 Parser. + * Gibt { name, phone, email, address, notes, category } zurück. + */ +function parseVCard(text) { + const unescapeVCard = (s) => String(s || '') + .replace(/\\n/g, '\n').replace(/\\,/g, ',').replace(/\\;/g, ';').replace(/\\\\/g, '\\'); + + // Zeilenfortsetzungen entfalten (RFC 6350 §3.2) + const unfolded = text.replace(/\r?\n[ \t]/g, ''); + + const get = (prop) => { + const re = new RegExp(`^${prop}(?:;[^:]*)?:(.*)$`, 'im'); + const m = re.exec(unfolded); + return m ? unescapeVCard(m[1].trim()) : null; + }; + + const name = get('FN') || get('N')?.split(';')[0] || null; + const phone = get('TEL') || null; + const email = get('EMAIL') || null; + + // ADR: ;;street;city;region;postal;country + const adrRaw = get('ADR'); + let address = null; + if (adrRaw) { + const parts = adrRaw.split(';').map((p) => p.trim()).filter(Boolean); + address = parts.join(', ') || null; + } + + const notes = get('NOTE') || null; + const catRaw = get('CATEGORIES') || null; + const category = CATEGORIES.find((c) => catRaw?.toLowerCase().includes(c.toLowerCase())) || 'Sonstiges'; + + return { name, phone, email, address, notes, category }; +} diff --git a/public/pages/dashboard.js b/public/pages/dashboard.js index b16e0c3..ba0aaae 100644 --- a/public/pages/dashboard.js +++ b/public/pages/dashboard.js @@ -289,7 +289,10 @@ function renderWeatherWidget(weather) { }).join(''); return ` -
+
+
@@ -462,4 +465,50 @@ export async function render(container, { user }) { wireLinks(container); initFab(container, _fabController.signal); if (window.lucide) window.lucide.createIcons(); + + // Wetter-Refresh: Button + 30-Minuten-Interval + const refreshBtn = container.querySelector('#weather-refresh-btn'); + if (refreshBtn) { + const doWeatherRefresh = async () => { + refreshBtn.disabled = true; + refreshBtn.classList.add('weather-widget__refresh--spinning'); + try { + const res = await api.get('/weather').catch(() => ({ data: null })); + const wWidget = container.querySelector('#weather-widget'); + if (wWidget) { + const fresh = renderWeatherWidget(res.data ?? null); + wWidget.outerHTML = fresh; + const newWidget = container.querySelector('#weather-widget'); + if (newWidget && window.lucide) window.lucide.createIcons({ el: newWidget }); + wireWeatherRefresh(container); + } + } catch { /* silently ignore */ } + }; + + refreshBtn.addEventListener('click', doWeatherRefresh, { signal: _fabController.signal }); + + // 30-Minuten Auto-Refresh — abortiert wenn Seite verlassen wird + const timerId = setInterval(doWeatherRefresh, 30 * 60 * 1000); + _fabController.signal.addEventListener('abort', () => clearInterval(timerId)); + } +} + +function wireWeatherRefresh(container) { + const refreshBtn = container.querySelector('#weather-refresh-btn'); + if (!refreshBtn) return; + const doWeatherRefresh = async () => { + refreshBtn.disabled = true; + refreshBtn.classList.add('weather-widget__refresh--spinning'); + try { + const res = await api.get('/weather').catch(() => ({ data: null })); + const wWidget = container.querySelector('#weather-widget'); + if (wWidget) { + wWidget.outerHTML = renderWeatherWidget(res.data ?? null); + const newWidget = container.querySelector('#weather-widget'); + if (newWidget && window.lucide) window.lucide.createIcons({ el: newWidget }); + wireWeatherRefresh(container); + } + } catch { /* silently ignore */ } + }; + refreshBtn.addEventListener('click', doWeatherRefresh, { signal: _fabController.signal }); } diff --git a/public/pages/notes.js b/public/pages/notes.js index 6614abe..009c52b 100644 --- a/public/pages/notes.js +++ b/public/pages/notes.js @@ -21,7 +21,7 @@ const NOTE_COLORS = [ // State // -------------------------------------------------------- -let state = { notes: [], user: null }; +let state = { notes: [], user: null, filterQuery: '' }; let _container = null; // -------------------------------------------------------- @@ -49,6 +49,12 @@ export async function render(container, { user }) {

Pinnwand

+