feat(calendar): expand recurring events in GET /calendar and /upcoming

expandRecurringEvents() iterates from the event's original start date,
generating all occurrences within the requested window using the existing
nextOccurrence() service (max 1000 iterations). The SQL query is extended
to also fetch recurring events that started before the window. Event
duration is preserved across instances. Virtual instances carry
is_recurring_instance=1 and are shown with a repeat icon in the agenda
view. /upcoming expands across a 90-day forward window.

Closes BL-01.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ulas
2026-03-31 10:17:39 +02:00
parent 82e5b2cd85
commit 6a860f2c13
4 changed files with 91 additions and 14 deletions
+2 -2
View File
@@ -8,7 +8,7 @@ Feature-Requests und geplante Erweiterungen. Einträge hier werden **nicht** imp
### BL-01 — Kalender: Wiederkehrende Events werden nicht expandiert
**Status:** Offen
**Status:** Erledigt (v0.3.0)
**Aufwand:** M (35 Tage)
Das Datenmodell speichert `recurrence_rule` (iCal RRULE) für Kalender-Events. Der `recurrence.js`-Service mit `nextOccurrence()` existiert und wird in Tasks genutzt. Im Kalender-Route (`server/routes/calendar.js`) fehlt jedoch die Expansion: Beim Abruf der Events werden Wiederholungsinstanzen nicht generiert. Wiederkehrende Termine erscheinen daher nur einmal (beim Originaldatum).
@@ -84,7 +84,7 @@ Das Budget-Formular hat eine „Wiederkehrend"-Checkbox und speichert `is_recurr
### BL-06 — Shopping: Schnell-Add Autocomplete von lokalem Verlauf
**Status:** In Arbeit (API-seitig implementiert, UI prüfen)
**Status:** Erledigt (bereits vollständig implementiert)
**Aufwand:** XS
`shopping.js` ruft `/api/v1/shopping/suggestions?q=...` auf. Prüfen ob der API-Endpunkt auf Server-Seite existiert und korrekt auf die `shopping_items`-Historie zugreift. Falls ja, Status auf „Fertig" setzen.
+1
View File
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Calendar: recurring events are now expanded in GET /api/v1/calendar — all occurrences within the requested date window are returned as virtual instances; duration is preserved; instances are marked with is_recurring_instance=1 and shown with a ↻ icon in the agenda view; /upcoming also expands recurring events within a 90-day window
- Budget: recurring entries auto-generate instances for each viewed month; instances deleted by the user are skipped permanently via `budget_recurrence_skipped` table; generated instances are marked with ↩ in the transaction list
- Budget: month-over-month comparison in summary cards — each card (Einnahmen, Ausgaben, Saldo) shows a trend line (▲/▼ + delta amount vs. previous month); previous month summary is fetched in parallel with current month
+1 -1
View File
@@ -623,7 +623,7 @@ function renderAgendaEvent(ev) {
<div class="agenda-event" data-id="${ev.id}">
<div class="agenda-event__color" style="background-color:${escHtml(ev.color)};"></div>
<div class="agenda-event__body">
<div class="agenda-event__title">${escHtml(ev.title)}${ev.recurrence_rule ? ' <i data-lucide="repeat" style="width:12px;height:12px;display:inline;vertical-align:middle;opacity:0.5" aria-hidden="true"></i>' : ''}</div>
<div class="agenda-event__title">${escHtml(ev.title)}${(ev.recurrence_rule || ev.is_recurring_instance) ? ' <i data-lucide="repeat" style="width:12px;height:12px;display:inline;vertical-align:middle;opacity:0.5" aria-hidden="true"></i>' : ''}</div>
<div class="agenda-event__meta">
<span>${timeStr}</span>
${ev.location ? `<span>📍 ${escHtml(ev.location)}</span>` : ''}
+87 -11
View File
@@ -15,8 +15,71 @@ const appleCalendar = require('../services/apple-calendar');
const { requireAdmin } = require('../auth');
const { str, color, datetime, rrule, collectErrors, MAX_TITLE, MAX_TEXT, DATE_RE, DATETIME_RE } = require('../middleware/validate');
const { nextOccurrence } = require('../services/recurrence');
const VALID_SOURCES = ['local', 'google', 'apple'];
// --------------------------------------------------------
// RRULE-Expansion: alle Vorkommen eines wiederkehrenden Events
// innerhalb [from, to] generieren (inklusive beider Grenzen).
// --------------------------------------------------------
/**
* @param {object[]} events Rohe DB-Events (können recurrence_rule haben)
* @param {string} from YYYY-MM-DD
* @param {string} to YYYY-MM-DD
* @returns {object[]} Expandiertes, sortiertes Array
*/
function expandRecurringEvents(events, from, to) {
const result = [];
for (const event of events) {
if (!event.recurrence_rule) {
result.push(event);
continue;
}
// Dauer des Events in ms (für End-Zeit-Berechnung der Instanzen)
const startMs = new Date(event.start_datetime).getTime();
const endMs = event.end_datetime ? new Date(event.end_datetime).getTime() : null;
const durationMs = endMs !== null ? endMs - startMs : null;
// Original-Zeit-Teil erhalten (z.B. 'T14:30:00' oder '' bei All-Day)
const timeSuffix = event.start_datetime.slice(10);
let currentDate = event.start_datetime.slice(0, 10); // YYYY-MM-DD
let iterations = 0;
const MAX_ITER = 1000; // Sicherheitsgrenze
while (currentDate <= to && iterations < MAX_ITER) {
iterations++;
if (currentDate >= from) {
const newStart = currentDate + timeSuffix;
let newEnd = event.end_datetime;
if (durationMs !== null) {
newEnd = new Date(new Date(newStart).getTime() + durationMs)
.toISOString()
.replace('.000Z', 'Z');
}
result.push({
...event,
start_datetime: newStart,
end_datetime: newEnd,
is_recurring_instance: currentDate !== event.start_datetime.slice(0, 10) ? 1 : 0,
});
}
const next = nextOccurrence(currentDate, event.recurrence_rule);
if (!next || next <= currentDate) break;
currentDate = next;
}
}
return result.sort((a, b) => a.start_datetime.localeCompare(b.start_datetime));
}
// --------------------------------------------------------
// GET /api/v1/calendar
// Termine in einem Datumsbereich abrufen.
@@ -46,11 +109,14 @@ router.get('/', (req, res) => {
LEFT JOIN users u_assigned ON u_assigned.id = e.assigned_to
LEFT JOIN users u_created ON u_created.id = e.created_by
WHERE (
DATE(e.start_datetime) <= ? AND
(e.end_datetime IS NULL OR DATE(e.end_datetime) >= ?)
(e.recurrence_rule IS NULL AND
DATE(e.start_datetime) <= ? AND
(e.end_datetime IS NULL OR DATE(e.end_datetime) >= ?))
OR
(e.recurrence_rule IS NOT NULL AND DATE(e.start_datetime) <= ?)
)
`;
const params = [to, from];
const params = [to, from, to];
if (req.query.assigned_to) {
sql += ' AND e.assigned_to = ?';
@@ -64,7 +130,8 @@ router.get('/', (req, res) => {
sql += ' ORDER BY e.start_datetime ASC, e.all_day DESC';
const events = db.get().prepare(sql).all(...params);
const rawEvents = db.get().prepare(sql).all(...params);
const events = expandRecurringEvents(rawEvents, from, to);
res.json({ data: events, from, to });
} catch (err) {
console.error('[calendar/GET /]', err);
@@ -80,21 +147,30 @@ router.get('/', (req, res) => {
// --------------------------------------------------------
router.get('/upcoming', (req, res) => {
try {
const limit = Math.min(parseInt(req.query.limit, 10) || 5, 20);
const now = new Date().toISOString();
const limit = Math.min(parseInt(req.query.limit, 10) || 5, 20);
const nowDate = new Date().toISOString().slice(0, 10);
// Fenster: heute bis 90 Tage voraus (für Wiederholungs-Expansion)
const future = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const events = db.get().prepare(`
const rawEvents = db.get().prepare(`
SELECT e.*,
u_assigned.display_name AS assigned_name,
u_assigned.avatar_color AS assigned_color
FROM calendar_events e
LEFT JOIN users u_assigned ON u_assigned.id = e.assigned_to
WHERE e.start_datetime >= ?
WHERE (
(e.recurrence_rule IS NULL AND DATE(e.start_datetime) BETWEEN ? AND ?)
OR
(e.recurrence_rule IS NOT NULL AND DATE(e.start_datetime) <= ?)
)
ORDER BY e.start_datetime ASC
LIMIT ?
`).all(now, limit);
`).all(nowDate, future, future);
res.json({ data: events });
const expanded = expandRecurringEvents(rawEvents, nowDate, future)
.filter((e) => e.start_datetime >= new Date().toISOString())
.slice(0, limit);
res.json({ data: expanded });
} catch (err) {
console.error('[calendar/GET /upcoming]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });