diff($end); $repeatType = (string) ($event['repeat_type'] ?? 'none'); $interval = max(1, (int) ($event['repeat_interval'] ?? 1)); $rangeMode = (string) ($event['repeat_range_mode'] ?? 'none'); $repeatCount = isset($event['repeat_count']) ? (int) $event['repeat_count'] : null; $repeatUntil = null; if ($rangeMode === 'until' && !empty($event['repeat_until'])) { $repeatUntil = self::parseDateTime((string) $event['repeat_until'] . 'T23:59:59', $tz); } if ($repeatType === 'none') { if (self::overlaps($start, $end, $windowStart, $windowEnd)) { return [self::occurrence($event, $start, $end)]; } return []; } $occurrences = []; $current = $start; $produced = 0; for ($i = 0; $i < self::MAX_ITERATIONS; $i++) { if ($rangeMode === 'count' && $repeatCount !== null && $produced >= $repeatCount) { break; } if ($repeatUntil && $current > $repeatUntil) { break; } $currentEnd = $current->add($duration); if (self::overlaps($current, $currentEnd, $windowStart, $windowEnd)) { $key = $current->format('c'); if (!isset($deletedMap[$key])) { $occurrences[] = self::occurrence($event, $current, $currentEnd); } } if ($current > $windowEnd->modify('+400 days')) { break; } $produced++; $current = self::nextStart($current, $repeatType, $interval, $event); if (!$current) { break; } } return $occurrences; } private static function nextStart(DateTimeImmutable $current, string $repeatType, int $interval, array $event): ?DateTimeImmutable { return match ($repeatType) { 'daily' => $current->add(new DateInterval('P' . $interval . 'D')), 'weekly', 'custom' => $current->add(new DateInterval('P' . $interval . 'W')), 'monthly' => self::nextMonthlyStart($current, $interval, $event), 'yearly' => $current->modify('+' . $interval . ' year') ?: null, default => null, }; } private static function nextMonthlyStart(DateTimeImmutable $current, int $interval, array $event): ?DateTimeImmutable { $mode = (string) ($event['repeat_nth_mode'] ?? ''); $next = $current->modify('+' . $interval . ' month'); if (!$next) { return null; } if ($mode === 'day_of_month' && isset($event['repeat_nth_day']) && $event['repeat_nth_day'] !== null) { $day = max(1, (int) $event['repeat_nth_day']); $daysInMonth = (int) $next->format('t'); return $next->setDate((int) $next->format('Y'), (int) $next->format('n'), min($day, $daysInMonth)); } if ($mode === 'weekday_of_month' && isset($event['repeat_nth_pos'], $event['repeat_nth_weekday']) && $event['repeat_nth_pos'] !== null && $event['repeat_nth_weekday'] !== null) { $year = (int) $current->format('Y'); $month = (int) $current->format('n'); for ($i = 0; $i < 120; $i++) { [$year, $month] = self::addMonths($year, $month, max(1, $interval)); $day = self::nthWeekdayOfMonth($year, $month, (int) $event['repeat_nth_weekday'], (int) $event['repeat_nth_pos']); if ($day !== null) { return $current->setDate($year, $month, $day); } } return null; } return $next; } private static function nthWeekdayOfMonth(int $year, int $month, int $weekday, int $pos): ?int { $weekday = max(0, min(6, $weekday)); $tz = new DateTimeZone('Europe/London'); if ($pos === -1) { $last = new DateTimeImmutable(sprintf('%04d-%02d-01T00:00:00', $year, $month), $tz); $last = $last->modify('last day of this month'); for ($day = (int) $last->format('j'); $day >= 1; $day--) { $dt = $last->setDate($year, $month, $day); if ((int) $dt->format('w') === $weekday) { return $day; } } return null; } $first = new DateTimeImmutable(sprintf('%04d-%02d-01T00:00:00', $year, $month), $tz); $daysInMonth = (int) $first->format('t'); $seen = 0; for ($day = 1; $day <= $daysInMonth; $day++) { $dt = $first->setDate($year, $month, $day); if ((int) $dt->format('w') !== $weekday) { continue; } $seen++; if ($seen === $pos) { return $day; } } return null; } private static function occurrence(array $event, DateTimeImmutable $start, DateTimeImmutable $end): array { return [ 'event_id' => (int) ($event['id'] ?? 0), 'uid' => (string) ($event['uid'] ?? ''), 'title' => (string) ($event['title'] ?? ''), 'description' => (string) ($event['description'] ?? ''), 'location' => (string) ($event['location'] ?? ''), 'category' => (string) ($event['category'] ?? ''), 'all_day_event' => (bool) ($event['all_day_event'] ?? false), 'occurrence_start' => $start->format('c'), 'occurrence_end' => $end->format('c'), 'repeat_type' => (string) ($event['repeat_type'] ?? 'none'), ]; } private static function overlaps(DateTimeImmutable $start, DateTimeImmutable $end, DateTimeImmutable $windowStart, DateTimeImmutable $windowEnd): bool { return $start < $windowEnd && $end > $windowStart; } private static function parseDateTime(string $value, DateTimeZone $tz): ?DateTimeImmutable { if ($value === '') { return null; } if (!str_contains($value, 'T') && preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) { return new DateTimeImmutable($value . 'T00:00:00', $tz); } try { return new DateTimeImmutable($value, $tz); } catch (\Throwable) { return null; } } private static function addMonths(int $year, int $month, int $delta): array { $index = ($year * 12) + ($month - 1) + $delta; $newYear = (int) floor($index / 12); $newMonth = ($index % 12) + 1; if ($newMonth <= 0) { $newMonth += 12; $newYear -= 1; } return [$newYear, $newMonth]; } }