+
+
+
+
+
@@ -666,6 +845,7 @@ export async function render(container, { user }) {
}
// UI verdrahten
+ wireViewToggle(container);
wireGroupToggle(container);
wireNewTaskBtn(container);
wireTaskList(container);
diff --git a/public/styles/dashboard.css b/public/styles/dashboard.css
index ec339a0..d091479 100644
--- a/public/styles/dashboard.css
+++ b/public/styles/dashboard.css
@@ -329,6 +329,95 @@
overflow: hidden;
}
+/* --------------------------------------------------------
+ * Wetter-Widget
+ * -------------------------------------------------------- */
+.weather-widget {
+ background: linear-gradient(135deg, #1a73e8 0%, #0f4fa8 100%);
+ color: #ffffff;
+}
+
+.weather-widget__main {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--space-4) var(--space-5);
+}
+
+.weather-widget__temp {
+ font-size: var(--text-4xl, 2.25rem);
+ font-weight: var(--font-weight-bold);
+ line-height: 1;
+ margin-bottom: var(--space-1);
+}
+
+.weather-widget__desc {
+ font-size: var(--text-base);
+ font-weight: var(--font-weight-medium);
+ text-transform: capitalize;
+ margin-bottom: 2px;
+}
+
+.weather-widget__city {
+ font-size: var(--text-sm);
+ opacity: 0.85;
+ margin-bottom: var(--space-2);
+}
+
+.weather-widget__meta {
+ font-size: var(--text-xs);
+ opacity: 0.75;
+ line-height: 1.4;
+}
+
+.weather-widget__icon {
+ width: 80px;
+ height: 80px;
+ flex-shrink: 0;
+ filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));
+}
+
+.weather-forecast {
+ display: flex;
+ border-top: 1px solid rgba(255,255,255,0.2);
+ padding: var(--space-3) var(--space-5);
+ gap: var(--space-4);
+}
+
+.weather-forecast__day {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+ flex: 1;
+}
+
+.weather-forecast__label {
+ font-size: var(--text-xs);
+ font-weight: var(--font-weight-semibold);
+ opacity: 0.85;
+ text-transform: capitalize;
+}
+
+.weather-forecast__icon {
+ width: 32px;
+ height: 32px;
+}
+
+.weather-forecast__temps {
+ display: flex;
+ gap: var(--space-2);
+ font-size: var(--text-xs);
+}
+
+.weather-forecast__high {
+ font-weight: var(--font-weight-semibold);
+}
+
+.weather-forecast__low {
+ opacity: 0.65;
+}
+
/* --------------------------------------------------------
* Skeleton-Zustände (pro Widget)
* -------------------------------------------------------- */
diff --git a/public/styles/tasks.css b/public/styles/tasks.css
index 687cb75..b0af895 100644
--- a/public/styles/tasks.css
+++ b/public/styles/tasks.css
@@ -495,6 +495,128 @@
margin-left: auto;
}
+/* --------------------------------------------------------
+ * Kanban-Board
+ * -------------------------------------------------------- */
+.kanban-board {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: var(--space-3);
+ align-items: start;
+ min-height: 60vh;
+}
+
+@media (max-width: 640px) {
+ .kanban-board {
+ grid-template-columns: 1fr;
+ }
+}
+
+.kanban-col {
+ background-color: var(--color-surface-2);
+ border-radius: var(--radius-md);
+ display: flex;
+ flex-direction: column;
+ min-height: 200px;
+}
+
+.kanban-col__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--space-3) var(--space-4);
+ border-bottom: 1.5px solid var(--color-border);
+}
+
+.kanban-col__title {
+ font-size: var(--text-sm);
+ font-weight: var(--font-weight-semibold);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.kanban-col__count {
+ font-size: var(--text-xs);
+ color: var(--color-text-disabled);
+ background-color: var(--color-surface);
+ padding: 2px var(--space-2);
+ border-radius: var(--radius-full);
+}
+
+.kanban-col__body {
+ flex: 1;
+ padding: var(--space-3);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ min-height: 80px;
+ transition: background-color var(--transition-fast);
+}
+
+.kanban-col__body--over {
+ background-color: var(--color-accent-light);
+ border-radius: 0 0 var(--radius-md) var(--radius-md);
+}
+
+.kanban-card {
+ background-color: var(--color-surface);
+ border-radius: var(--radius-sm);
+ box-shadow: var(--shadow-sm);
+ padding: var(--space-3);
+ cursor: grab;
+ user-select: none;
+ transition: box-shadow var(--transition-fast), opacity var(--transition-fast),
+ transform var(--transition-fast);
+}
+
+.kanban-card:hover {
+ box-shadow: var(--shadow-md);
+}
+
+.kanban-card--dragging {
+ opacity: 0.4;
+ cursor: grabbing;
+ transform: rotate(1.5deg);
+}
+
+.kanban-card--done {
+ opacity: 0.6;
+}
+
+.kanban-card__title {
+ font-size: var(--text-sm);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-primary);
+ margin-bottom: var(--space-2);
+ line-height: 1.4;
+}
+
+.kanban-card--done .kanban-card__title {
+ text-decoration: line-through;
+ color: var(--color-text-secondary);
+}
+
+.kanban-card__meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+ margin-bottom: var(--space-2);
+}
+
+.kanban-card__footer {
+ display: flex;
+ justify-content: flex-end;
+ margin-top: var(--space-2);
+}
+
+.kanban-drop-placeholder {
+ height: 60px;
+ border: 2px dashed var(--color-accent);
+ border-radius: var(--radius-sm);
+ background-color: var(--color-accent-light);
+ opacity: 0.5;
+}
+
/* --------------------------------------------------------
* Leer-Zustand
* -------------------------------------------------------- */
diff --git a/public/sw.js b/public/sw.js
index a6da92c..91f5083 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -4,7 +4,7 @@
* Abhängigkeiten: keine
*/
-const CACHE_NAME = 'oikos-v1';
+const CACHE_NAME = 'oikos-v2';
// App-Shell-Ressourcen, die offline verfügbar sein sollen
const APP_SHELL = [
@@ -16,6 +16,14 @@ const APP_SHELL = [
'/styles/reset.css',
'/styles/layout.css',
'/styles/login.css',
+ '/styles/dashboard.css',
+ '/styles/tasks.css',
+ '/styles/shopping.css',
+ '/styles/meals.css',
+ '/styles/calendar.css',
+ '/styles/notes.css',
+ '/styles/contacts.css',
+ '/styles/budget.css',
'/manifest.json',
];
diff --git a/server/routes/tasks.js b/server/routes/tasks.js
index 143dc03..8504f51 100644
--- a/server/routes/tasks.js
+++ b/server/routes/tasks.js
@@ -6,9 +6,10 @@
'use strict';
-const express = require('express');
-const router = express.Router();
-const db = require('../db');
+const express = require('express');
+const router = express.Router();
+const db = require('../db');
+const { nextOccurrence } = require('../services/recurrence');
// --------------------------------------------------------
// Konstanten
@@ -253,6 +254,25 @@ router.patch('/:id/status', (req, res) => {
if (result.changes === 0)
return res.status(404).json({ error: 'Aufgabe nicht gefunden.', code: 404 });
+ // Wiederkehrende Aufgabe: nächste Instanz erstellen wenn erledigt
+ if (status === 'done') {
+ const task = db.get().prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id);
+ if (task?.is_recurring && task.recurrence_rule && !task.parent_task_id) {
+ const nextDate = nextOccurrence(task.due_date, task.recurrence_rule);
+ if (nextDate) {
+ db.get().prepare(`
+ INSERT INTO tasks (title, description, category, priority, status,
+ due_date, due_time, assigned_to, created_by, is_recurring, recurrence_rule)
+ VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, 1, ?)
+ `).run(
+ task.title, task.description, task.category, task.priority,
+ nextDate, task.due_time, task.assigned_to, task.created_by,
+ task.recurrence_rule
+ );
+ }
+ }
+ }
+
res.json({ data: { id: Number(req.params.id), status } });
} catch (err) {
console.error('[Tasks] PATCH /:id/status Fehler:', err);
diff --git a/server/routes/weather.js b/server/routes/weather.js
index b1eaf65..dd26807 100644
--- a/server/routes/weather.js
+++ b/server/routes/weather.js
@@ -4,10 +4,92 @@
* Abhängigkeiten: express, node-fetch, dotenv
*/
-const express = require('express');
-const router = express.Router();
+'use strict';
-// Platzhalter — wird in Phase 4 implementiert
-router.get('/', (req, res) => res.json({ data: null }));
+const express = require('express');
+const router = express.Router();
+
+// Cache: Daten für 30 Minuten halten
+let cache = { data: null, ts: 0 };
+const CACHE_TTL_MS = 30 * 60 * 1000;
+
+// --------------------------------------------------------
+// GET /api/v1/weather
+// Gibt aktuelles Wetter + 3-Tage-Vorschau zurück.
+// Erfordert OPENWEATHER_API_KEY + OPENWEATHER_CITY in .env
+// Response: { data: { current, forecast } } | { data: null }
+// --------------------------------------------------------
+router.get('/', async (req, res) => {
+ try {
+ const apiKey = process.env.OPENWEATHER_API_KEY;
+ const city = process.env.OPENWEATHER_CITY || 'Berlin';
+ const units = process.env.OPENWEATHER_UNITS || 'metric';
+ const lang = process.env.OPENWEATHER_LANG || 'de';
+
+ // Kein API-Key → leere Antwort (Widget wird ausgeblendet)
+ if (!apiKey) {
+ return res.json({ data: null });
+ }
+
+ // Cache prüfen
+ if (cache.data && Date.now() - cache.ts < CACHE_TTL_MS) {
+ return res.json({ data: cache.data });
+ }
+
+ // Dynamischer Import für node-fetch (ESM)
+ const { default: fetch } = await import('node-fetch');
+
+ // Aktuelles Wetter
+ const currentUrl = `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(city)}&appid=${apiKey}&units=${units}&lang=${lang}`;
+ const currentRes = await fetch(currentUrl, { signal: AbortSignal.timeout(8000) });
+ if (!currentRes.ok) {
+ console.warn(`[Weather] API Fehler: ${currentRes.status}`);
+ return res.json({ data: null });
+ }
+ const currentJson = await currentRes.json();
+
+ // 5-Tage-Forecast (3h-Intervalle → wir nehmen Mittags-Werte für Tagesvorschau)
+ const forecastUrl = `https://api.openweathermap.org/data/2.5/forecast?q=${encodeURIComponent(city)}&appid=${apiKey}&units=${units}&lang=${lang}&cnt=24`;
+ const forecastRes = await fetch(forecastUrl, { signal: AbortSignal.timeout(8000) });
+ let forecastDays = [];
+ if (forecastRes.ok) {
+ const forecastJson = await forecastRes.json();
+ // Ein Eintrag pro Tag: nächstgelegener Mittags-Wert (12:00 Uhr)
+ const seen = new Set();
+ for (const item of forecastJson.list ?? []) {
+ const dateStr = item.dt_txt.slice(0, 10); // YYYY-MM-DD
+ if (seen.has(dateStr)) continue;
+ seen.add(dateStr);
+ forecastDays.push({
+ date: dateStr,
+ temp_min: Math.round(item.main.temp_min),
+ temp_max: Math.round(item.main.temp_max),
+ icon: item.weather[0]?.icon,
+ desc: item.weather[0]?.description,
+ });
+ if (forecastDays.length >= 3) break;
+ }
+ }
+
+ const data = {
+ city: currentJson.name,
+ current: {
+ temp: Math.round(currentJson.main.temp),
+ feels_like: Math.round(currentJson.main.feels_like),
+ humidity: currentJson.main.humidity,
+ icon: currentJson.weather[0]?.icon,
+ desc: currentJson.weather[0]?.description,
+ wind_speed: Math.round((currentJson.wind?.speed ?? 0) * 3.6), // m/s → km/h
+ },
+ forecast: forecastDays,
+ };
+
+ cache = { data, ts: Date.now() };
+ res.json({ data });
+ } catch (err) {
+ console.warn('[Weather] Fehler:', err.message);
+ res.json({ data: null }); // Fallback: Widget ausblenden, kein Error-Screen
+ }
+});
module.exports = router;
diff --git a/server/services/recurrence.js b/server/services/recurrence.js
index e061d7a..038a914 100644
--- a/server/services/recurrence.js
+++ b/server/services/recurrence.js
@@ -1,12 +1,99 @@
/**
* Modul: Wiederholungsregeln (Recurrence)
- * Zweck: RRULE-Parser und -Generator für wiederkehrende Aufgaben und Termine
- * Abhängigkeiten: keine externen (eigene Implementierung für FREQ=DAILY/WEEKLY/MONTHLY)
+ * Zweck: RRULE-Subset-Parser (FREQ=DAILY/WEEKLY/MONTHLY, BYDAY, INTERVAL, UNTIL)
+ * + Berechnung des nächsten Fälligkeitsdatums für wiederkehrende Aufgaben
+ * Abhängigkeiten: keine
*/
-// Platzhalter — wird in Phase 4 implementiert
+'use strict';
-module.exports = {
- nextOccurrence: () => null,
- expandRule: () => [],
-};
+const DAY_MAP = { MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6, SU: 0 };
+
+/**
+ * Parsed einen RRULE-String in ein Objekt.
+ * Beispiel: "FREQ=WEEKLY;BYDAY=MO,TH;INTERVAL=1"
+ * @param {string} rule
+ * @returns {{ freq, interval, byday, until }|null}
+ */
+function parseRRule(rule) {
+ if (!rule) return null;
+ const parts = {};
+ for (const segment of rule.split(';')) {
+ const eq = segment.indexOf('=');
+ if (eq === -1) continue;
+ parts[segment.slice(0, eq).toUpperCase()] = segment.slice(eq + 1);
+ }
+
+ const freq = parts.FREQ ?? null;
+ const interval = parseInt(parts.INTERVAL ?? '1', 10) || 1;
+ const byday = (parts.BYDAY ?? '').split(',')
+ .map((d) => DAY_MAP[d.trim().toUpperCase()])
+ .filter((d) => d !== undefined);
+ const until = parts.UNTIL ? parseUntilDate(parts.UNTIL) : null;
+
+ if (!['DAILY', 'WEEKLY', 'MONTHLY'].includes(freq)) return null;
+
+ return { freq, interval, byday, until };
+}
+
+function parseUntilDate(str) {
+ // Akzeptiert YYYYMMDD oder YYYYMMDDTHHmmssZ
+ const clean = str.replace(/[TZ]/g, '');
+ const y = parseInt(clean.slice(0, 4), 10);
+ const m = parseInt(clean.slice(4, 6), 10) - 1;
+ const d = parseInt(clean.slice(6, 8), 10);
+ return new Date(Date.UTC(y, m, d));
+}
+
+/**
+ * Berechnet das nächste Fälligkeitsdatum nach dem gegebenen Basisdatum.
+ * @param {string} baseDateStr - ISO-Datums-String (YYYY-MM-DD)
+ * @param {string} rrule - RRULE-String
+ * @returns {string|null} - Nächstes Datum als YYYY-MM-DD oder null (Ende der Serie)
+ */
+function nextOccurrence(baseDateStr, rrule) {
+ const parsed = parseRRule(rrule);
+ if (!parsed || !baseDateStr) return null;
+
+ const base = new Date(baseDateStr + 'T00:00:00Z');
+ if (isNaN(base.getTime())) return null;
+
+ const { freq, interval, byday, until } = parsed;
+ const next = new Date(base);
+
+ if (freq === 'DAILY') {
+ next.setUTCDate(next.getUTCDate() + interval);
+
+ } else if (freq === 'WEEKLY') {
+ if (byday.length === 0) {
+ // Kein BYDAY → selber Wochentag, nächste Woche
+ next.setUTCDate(next.getUTCDate() + 7 * interval);
+ } else {
+ // Finde den nächsten passenden Wochentag (nach heute)
+ const currentDay = base.getUTCDay();
+ const sorted = [...byday].sort((a, b) => {
+ const da = (a - currentDay + 7) % 7 || 7;
+ const db = (b - currentDay + 7) % 7 || 7;
+ return da - db;
+ });
+ // Tage bis zum nächsten Vorkommen (mind. 1, damit nicht derselbe Tag)
+ let daysUntil = (sorted[0] - currentDay + 7) % 7;
+ if (daysUntil === 0) daysUntil = 7 * interval;
+ next.setUTCDate(next.getUTCDate() + daysUntil);
+ }
+
+ } else if (freq === 'MONTHLY') {
+ const targetDay = base.getUTCDate();
+ next.setUTCMonth(next.getUTCMonth() + interval);
+ // Monatsüberlauf korrigieren (z.B. 31. März + 1 Monat → 30. April)
+ const lastDay = new Date(Date.UTC(next.getUTCFullYear(), next.getUTCMonth() + 1, 0)).getUTCDate();
+ next.setUTCDate(Math.min(targetDay, lastDay));
+ }
+
+ // UNTIL-Grenze prüfen
+ if (until && next > until) return null;
+
+ return next.toISOString().slice(0, 10); // YYYY-MM-DD
+}
+
+module.exports = { parseRRule, nextOccurrence };