feat: Phase 4 — Wetter-Widget, Wiederkehrende Aufgaben, Kanban-Ansicht, PWA

- server/routes/weather.js: OpenWeatherMap-Proxy (aktuelles Wetter + 3-Tage-Forecast,
  30-min-Cache, graceful fallback wenn kein API-Key gesetzt)
- public/pages/dashboard.js: Weather-Widget parallel mit Dashboard-Daten laden
- public/styles/dashboard.css: Weather-Widget-Styles (Gradient, Forecast-Strip)
- server/services/recurrence.js: RRULE-Parser (FREQ=DAILY/WEEKLY/MONTHLY, BYDAY,
  INTERVAL, UNTIL) + nextOccurrence()-Funktion
- server/routes/tasks.js: Bei PATCH /:id/status = done → nächste Instanz
  wiederkehrender Aufgaben automatisch anlegen
- public/pages/tasks.js: Kanban-Ansicht (3 Spalten: Offen/In Bearbeitung/Erledigt)
  mit HTML5 Drag & Drop, View-Toggle (Liste/Kanban)
- public/styles/tasks.css: Kanban-Board-Styles (Spalten, Cards, Drag-over-Highlight)
- public/sw.js: Cache-Version auf v2, alle Modul-CSS-Dateien im APP_SHELL-Cache

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ulsklyc
2026-03-24 21:32:22 +01:00
parent 74b6e5f078
commit 450ae37f42
8 changed files with 669 additions and 26 deletions
+23 -3
View File
@@ -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);
+86 -4
View File
@@ -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;
+94 -7
View File
@@ -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 };