diff --git a/public/pages/dashboard.js b/public/pages/dashboard.js index ece6e6e..c6c553b 100644 --- a/public/pages/dashboard.js +++ b/public/pages/dashboard.js @@ -228,6 +228,54 @@ function renderPinnedNotes(notes) { return `
${header}
${items}
`; } +// -------------------------------------------------------- +// Wetter-Widget +// -------------------------------------------------------- + +const WEATHER_ICON_BASE = 'https://openweathermap.org/img/wn/'; + +function weatherIconUrl(icon) { + return `${WEATHER_ICON_BASE}${icon}@2x.png`; +} + +function renderWeatherWidget(weather) { + if (!weather) return ''; // Kein API-Key → Widget ausblenden + + const { city, current, forecast } = weather; + + const forecastHtml = forecast.map((d) => { + const date = new Date(d.date + 'T12:00:00'); + const label = date.toLocaleDateString('de-DE', { weekday: 'short' }); + return ` +
+
${label}
+ ${d.desc} +
+ ${d.temp_max}° + ${d.temp_min}° +
+
`; + }).join(''); + + return ` +
+
+
+
${current.temp}°C
+
${current.desc}
+
${city}
+
+ Gefühlt ${current.feels_like}° · ${current.humidity}% Luftfeuchtigkeit · Wind ${current.wind_speed} km/h +
+
+ ${current.desc} +
+ ${forecast.length ? `
${forecastHtml}
` : ''} +
`; +} + // -------------------------------------------------------- // FAB Speed-Dial // -------------------------------------------------------- @@ -336,10 +384,16 @@ export async function render(container, { user }) { `; initFab(container); - // Daten laden - let data = { upcomingEvents: [], urgentTasks: [], todayMeals: [], pinnedNotes: [] }; + // Daten laden (Dashboard + Wetter parallel) + let data = { upcomingEvents: [], urgentTasks: [], todayMeals: [], pinnedNotes: [] }; + let weather = null; try { - data = await api.get('/dashboard'); + const [dashRes, weatherRes] = await Promise.all([ + api.get('/dashboard'), + api.get('/weather').catch(() => ({ data: null })), + ]); + data = dashRes; + weather = weatherRes.data ?? null; } catch (err) { console.error('[Dashboard] Ladefehler:', err.message); window.oikos?.showToast('Dashboard konnte nicht vollständig geladen werden.', 'warning'); @@ -350,6 +404,7 @@ export async function render(container, { user }) {
${renderGreeting(user)} + ${renderWeatherWidget(weather)} ${renderUrgentTasks(data.urgentTasks ?? [])} ${renderUpcomingEvents(data.upcomingEvents ?? [])} ${renderTodayMeals(data.todayMeals ?? [])} diff --git a/public/pages/tasks.js b/public/pages/tasks.js index 1d50223..5dd0359 100644 --- a/public/pages/tasks.js +++ b/public/pages/tasks.js @@ -306,11 +306,13 @@ function renderModal({ task = null, users = [] } = {}) { // -------------------------------------------------------- let state = { - tasks: [], - users: [], - filters: { status: '', priority: '', assigned_to: '' }, - groupMode: 'category', // 'category' | 'due' + tasks: [], + users: [], + filters: { status: '', priority: '', assigned_to: '' }, + groupMode: 'category', // 'category' | 'due' + viewMode: 'list', // 'list' | 'kanban' expandedTasks: new Set(), + dragTaskId: null, }; // -------------------------------------------------------- @@ -441,11 +443,159 @@ async function handleAddSubtask(parentId, container) { } } +// -------------------------------------------------------- +// Kanban-Ansicht +// -------------------------------------------------------- + +const KANBAN_COLS = [ + { status: 'open', label: 'Offen', colorVar: '--color-text-secondary' }, + { status: 'in_progress', label: 'In Bearbeitung', colorVar: '--color-warning' }, + { status: 'done', label: 'Erledigt', colorVar: '--color-success' }, +]; + +function renderKanbanCard(task) { + const due = formatDueDate(task.due_date); + return ` +
+
${task.title}
+
+ ${renderPriorityBadge(task.priority)} + ${due ? ` ${due.label}` : ''} +
+ ${task.assigned_color ? ` + ` : ''} +
`; +} + +function renderKanban(container) { + const listEl = container.querySelector('#task-list'); + if (!listEl) return; + + const grouped = {}; + for (const col of KANBAN_COLS) grouped[col.status] = []; + for (const t of state.tasks) { + if (grouped[t.status]) grouped[t.status].push(t); + else grouped['open'].push(t); + } + + listEl.innerHTML = ` +
+ ${KANBAN_COLS.map((col) => ` +
+
+ + ${col.label} + + ${grouped[col.status].length} +
+
+ ${grouped[col.status].map((t) => renderKanbanCard(t)).join('')} + +
+
+ `).join('')} +
`; + + if (window.lucide) window.lucide.createIcons(); + wireKanbanDrag(container); + updateOverdueBadge(); +} + +function wireKanbanDrag(container) { + const board = container.querySelector('.kanban-board'); + if (!board) return; + + board.addEventListener('dragstart', (e) => { + const card = e.target.closest('.kanban-card[data-task-id]'); + if (!card) return; + state.dragTaskId = card.dataset.taskId; + card.classList.add('kanban-card--dragging'); + e.dataTransfer.effectAllowed = 'move'; + }); + + board.addEventListener('dragend', (e) => { + const card = e.target.closest('.kanban-card[data-task-id]'); + if (card) card.classList.remove('kanban-card--dragging'); + board.querySelectorAll('.kanban-drop-placeholder').forEach((el) => el.hidden = true); + board.querySelectorAll('.kanban-col__body--over').forEach((el) => + el.classList.remove('kanban-col__body--over') + ); + state.dragTaskId = null; + }); + + board.addEventListener('dragover', (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + const zone = e.target.closest('[data-drop-zone]'); + if (!zone) return; + board.querySelectorAll('.kanban-col__body--over').forEach((el) => + el.classList.remove('kanban-col__body--over') + ); + zone.classList.add('kanban-col__body--over'); + }); + + board.addEventListener('dragleave', (e) => { + const zone = e.target.closest('[data-drop-zone]'); + if (zone && !zone.contains(e.relatedTarget)) { + zone.classList.remove('kanban-col__body--over'); + } + }); + + board.addEventListener('drop', async (e) => { + e.preventDefault(); + const zone = e.target.closest('[data-drop-zone]'); + if (!zone || !state.dragTaskId) return; + zone.classList.remove('kanban-col__body--over'); + + const newStatus = zone.dataset.dropZone; + const taskId = state.dragTaskId; + const task = state.tasks.find((t) => String(t.id) === String(taskId)); + if (!task || task.status === newStatus) return; + + // Optimistisches Update + task.status = newStatus; + renderKanban(container); + + try { + await api.patch(`/tasks/${taskId}/status`, { status: newStatus }); + await loadTasks(container); // sync + } catch (err) { + window.oikos.showToast(err.message, 'danger'); + await loadTasks(container); + } + }); + + // Klick auf Kanban-Card öffnet Edit-Modal + board.addEventListener('click', async (e) => { + if (e.target.closest('[draggable]')) { + const card = e.target.closest('.kanban-card[data-task-id]'); + if (!card) return; + try { + const task = await loadTaskForEdit(card.dataset.taskId); + openModal(renderModal({ task, users: state.users })); + wireModalEvents(container); + } catch (err) { + window.oikos.showToast('Aufgabe konnte nicht geladen werden.', 'danger'); + } + } + }); +} + // -------------------------------------------------------- // Partielle DOM-Updates // -------------------------------------------------------- function renderTaskList(container) { + if (state.viewMode === 'kanban') { + renderKanban(container); + return; + } const listEl = container.querySelector('#task-list'); if (!listEl) return; listEl.innerHTML = renderTaskGroups(state.tasks, state.groupMode); @@ -532,11 +682,30 @@ function wireFilterChips(container) { }); } +function wireViewToggle(container) { + const toggle = container.querySelector('#view-toggle'); + if (!toggle) return; + toggle.querySelectorAll('[data-view]').forEach((btn) => { + btn.addEventListener('click', () => { + state.viewMode = btn.dataset.view; + toggle.querySelectorAll('[data-view]').forEach((b) => + b.classList.toggle('group-toggle__btn--active', b.dataset.view === state.viewMode) + ); + // Gruppierungs-Toggle nur in Listenansicht sinnvoll + const groupToggle = container.querySelector('#group-mode-toggle'); + if (groupToggle) groupToggle.style.display = state.viewMode === 'list' ? '' : 'none'; + renderTaskList(container); + }); + }); +} + function wireGroupToggle(container) { - container.querySelectorAll('.group-toggle__btn').forEach((btn) => { - btn.addEventListener('click', async () => { + const toggle = container.querySelector('#group-mode-toggle'); + if (!toggle) return; + toggle.querySelectorAll('.group-toggle__btn').forEach((btn) => { + btn.addEventListener('click', () => { state.groupMode = btn.dataset.mode; - container.querySelectorAll('.group-toggle__btn').forEach((b) => + toggle.querySelectorAll('.group-toggle__btn').forEach((b) => b.classList.toggle('group-toggle__btn--active', b.dataset.mode === state.groupMode) ); renderTaskList(container); @@ -625,7 +794,17 @@ export async function render(container, { user }) {

Aufgaben

-
+
+ + +
+
@@ -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 };