From 0881d044285e0ee317a7433f835e37598adcbbcb Mon Sep 17 00:00:00 2001 From: Andrew Gioia Date: Tue, 3 Feb 2026 11:43:08 -0500 Subject: [PATCH 01/10] Adds initial support for event recurrence; updates database seed file for recurring event example --- app/Http/Controllers/CalendarController.php | 247 +++++++++++------- app/Http/Controllers/EventController.php | 164 ++++++++---- app/Http/Controllers/IcsController.php | 53 ++-- app/Models/Event.php | 22 +- app/Services/Event/EventRecurrence.php | 185 +++++++++++++ database/seeders/DatabaseSeeder.php | 73 ++++++ .../components/calendar/month/day.blade.php | 8 +- .../components/calendar/time/event.blade.php | 12 +- resources/views/event/form.blade.php | 16 ++ 9 files changed, 598 insertions(+), 182 deletions(-) create mode 100644 app/Services/Event/EventRecurrence.php diff --git a/app/Http/Controllers/CalendarController.php b/app/Http/Controllers/CalendarController.php index dbdc7ad..c176692 100644 --- a/app/Http/Controllers/CalendarController.php +++ b/app/Http/Controllers/CalendarController.php @@ -14,6 +14,7 @@ use App\Models\Event; use App\Models\EventMeta; use App\Models\Subscription; use App\Services\Calendar\CreateCalendar; +use App\Services\Event\EventRecurrence; class CalendarController extends Controller { @@ -32,7 +33,7 @@ class CalendarController extends Controller * ├─ calendars keyed by calendar id (for the left-hand toggle list) * └─ events flat list of VEVENTs in that range */ - public function index(Request $request) + public function index(Request $request, EventRecurrence $recurrence) { /** * @@ -118,78 +119,18 @@ class CalendarController extends Controller $calendars->pluck('id'), $span['start'], $span['end'] - )->map(function ($e) use ($calendar_map, $timeFormat, $view, $range, $tz, $weekStart, $weekEnd) { + ); - // event's calendar - $cal = $calendar_map[$e->calendarid]; - - // get utc dates from the database - $start_utc = $e->meta->start_at ?? - Carbon::createFromTimestamp($e->firstoccurence); - $end_utc = $e->meta->end_at ?? - ($e->lastoccurence ? Carbon::createFromTimestamp($e->lastoccurence) : null); - - // time format handling - $uiFormat = $timeFormat === '24' ? 'H:i' : 'g:ia'; - - // convert to calendar timezone - $timezone = $calendar_map[$e->calendarid]->timezone ?? config('app.timezone'); - $start_local = $start_utc->copy()->timezone($timezone); - $end_local = optional($end_utc)->copy()->timezone($timezone); - - // convert utc to user tz for grid placement (columns/rows must match view headers) - $start_for_grid = $start_utc->copy()->tz($tz); - $end_for_grid = optional($end_utc)->copy()->tz($tz); - - // placement for time-based layouts - $placement = $this->slotPlacement( - $start_for_grid, - $end_for_grid, - $range['start']->copy()->tz($tz), - $view, - 15 - ); - - // color handling - $color = $cal['meta_color'] - ?? $cal['calendarcolor'] - ?? default_calendar_color(); - $colorFg = $cal['meta_color_fg'] - ?? contrast_text_color($color); - - logger()->info('event times', [ - 'id' => $e->id, - 'start_at' => optional($e->meta)->start_at, - 'end_at' => optional($e->meta)->end_at, - 'firstoccurence' => $e->firstoccurence, - 'lastoccurence' => $e->lastoccurence, - ]); - - // return events array - return [ - // core data - 'id' => $e->id, - 'calendar_id' => $e->calendarid, - 'calendar_slug' => $cal->slug, - 'title' => $e->meta->title ?? 'No title', - 'description' => $e->meta->description ?? 'No description.', - 'start' => $start_utc->toIso8601String(), - 'end' => optional($end_utc)->toIso8601String(), - 'start_ui' => $start_local->format($uiFormat), - 'end_ui' => optional($end_local)->format($uiFormat), - 'timezone' => $timezone, - 'visible' => $cal->visible, - 'color' => $color, - 'color_fg' => $colorFg, - // slot placement for time-based grid - 'start_row' => $placement['start_row'], - 'end_row' => $placement['end_row'], - 'row_span' => $placement['row_span'], - 'start_col' => $placement['start_col'], - 'duration' => $placement['duration'], - - ]; - })->keyBy('id'); + $events = $this->buildEventPayloads( + $events, + $calendar_map, + $timeFormat, + $view, + $range, + $tz, + $recurrence, + $span, + ); /** * @@ -223,32 +164,18 @@ class CalendarController extends Controller $calendars->pluck('id'), $mini_grid_start, $mini_grid_end - )->map(function ($e) use ($calendar_map, $tz) { - $cal = $calendar_map[$e->calendarid]; + ); - $start_utc = $e->meta->start_at ?? Carbon::createFromTimestamp($e->firstoccurence); - $end_utc = $e->meta->end_at ?? ($e->lastoccurence ? Carbon::createFromTimestamp($e->lastoccurence) : null); - - $color = $cal->meta_color - ?? $cal->calendarcolor - ?? default_calendar_color(); - $colorFg = $cal->meta_color_fg - ?? contrast_text_color($color); - - return [ - 'id' => $e->id, - 'calendar_id' => $e->calendarid, - 'calendar_slug' => $cal->slug, - 'title' => $e->meta->title ?? 'No title', - 'description' => $e->meta->description ?? 'No description.', - 'start' => $start_utc->toIso8601String(), - 'end' => optional($end_utc)->toIso8601String(), - 'timezone' => $tz, - 'visible' => $cal->visible, - 'color' => $color, - 'color_fg' => $colorFg, - ]; - })->keyBy('id'); + $mini_events = $this->buildEventPayloads( + $mini_events, + $calendar_map, + $timeFormat, + $view, + ['start' => $mini_grid_start, 'end' => $mini_grid_end], + $tz, + $recurrence, + ['start' => $mini_grid_start, 'end' => $mini_grid_end], + ); // now build the mini from mini_events (not from $events) $mini = $this->buildMiniGrid($mini_start, $mini_events, $tz, $weekStart, $weekEnd); @@ -299,7 +226,7 @@ class CalendarController extends Controller ]; }), 'hgroup' => $this->viewHeaders($view, $range, $tz, $weekStart), - 'events' => $events, // keyed, one copy each + 'events' => $events, // keyed by occurrence 'grid' => $grid, // day objects hold only ID-sets 'mini' => $mini, // mini calendar days with events for indicators 'mini_nav' => $mini_nav, // separate mini calendar navigation @@ -727,6 +654,126 @@ class CalendarController extends Controller ]; } + /** + * Expand events (including recurrence) into view-ready payloads. + */ + private function buildEventPayloads( + Collection $events, + Collection $calendarMap, + string $timeFormat, + string $view, + array $range, + string $tz, + EventRecurrence $recurrence, + array $span + ): Collection { + $uiFormat = $timeFormat === '24' ? 'H:i' : 'g:ia'; + $spanStartUtc = $span['start']->copy()->utc(); + $spanEndUtc = $span['end']->copy()->utc(); + + return $events->flatMap(function ($e) use ( + $calendarMap, + $uiFormat, + $view, + $range, + $tz, + $recurrence, + $spanStartUtc, + $spanEndUtc + ) { + $cal = $calendarMap[$e->calendarid]; + $timezone = $cal->timezone ?? config('app.timezone'); + + $color = $cal['meta_color'] + ?? $cal['calendarcolor'] + ?? default_calendar_color(); + $colorFg = $cal['meta_color_fg'] + ?? contrast_text_color($color); + + $occurrences = []; + $isRecurring = $recurrence->isRecurring($e); + + if ($isRecurring) { + $occurrences = $recurrence->expand($e, $spanStartUtc, $spanEndUtc); + } + + if (empty($occurrences) && !$isRecurring) { + $startUtc = $e->meta?->start_at + ? Carbon::parse($e->meta->start_at)->utc() + : Carbon::createFromTimestamp($e->firstoccurence, 'UTC'); + $endUtc = $e->meta?->end_at + ? Carbon::parse($e->meta->end_at)->utc() + : ($e->lastoccurence + ? Carbon::createFromTimestamp($e->lastoccurence, 'UTC') + : $startUtc->copy()); + + $occurrences[] = [ + 'start' => $startUtc, + 'end' => $endUtc, + 'recurrence_id' => null, + ]; + } + + return collect($occurrences)->map(function ($occ) use ( + $e, + $cal, + $uiFormat, + $view, + $range, + $tz, + $timezone, + $color, + $colorFg + ) { + $startUtc = $occ['start']; + $endUtc = $occ['end']; + + $startLocal = $startUtc->copy()->timezone($timezone); + $endLocal = $endUtc->copy()->timezone($timezone); + + $startForGrid = $startUtc->copy()->tz($tz); + $endForGrid = $endUtc->copy()->tz($tz); + + $placement = $this->slotPlacement( + $startForGrid, + $endForGrid, + $range['start']->copy()->tz($tz), + $view, + 15 + ); + + $occurrenceId = $occ['recurrence_id'] + ? ($e->id . ':' . $occ['recurrence_id']) + : (string) $e->id; + + return [ + 'id' => $e->id, + 'occurrence_id' => $occurrenceId, + 'occurrence' => $occ['recurrence_id'] + ? $startUtc->toIso8601String() + : null, + 'calendar_id' => $e->calendarid, + 'calendar_slug' => $cal->slug, + 'title' => $e->meta->title ?? 'No title', + 'description' => $e->meta->description ?? 'No description.', + 'start' => $startUtc->toIso8601String(), + 'end' => $endUtc->toIso8601String(), + 'start_ui' => $startLocal->format($uiFormat), + 'end_ui' => $endLocal->format($uiFormat), + 'timezone' => $timezone, + 'visible' => $cal->visible, + 'color' => $color, + 'color_fg' => $colorFg, + 'start_row' => $placement['start_row'], + 'end_row' => $placement['end_row'], + 'row_span' => $placement['row_span'], + 'start_col' => $placement['start_col'], + 'duration' => $placement['duration'], + ]; + }); + })->keyBy('occurrence_id'); + } + /** * * Assemble an array of day-objects for the requested view. @@ -769,7 +816,7 @@ class CalendarController extends Controller $d->addDay()) { $key = $d->toDateString(); - $events_by_day[$key][] = $ev['id']; + $events_by_day[$key][] = $ev['occurrence_id'] ?? $ev['id']; } } @@ -890,7 +937,7 @@ class CalendarController extends Controller $e = $ev['end'] ? Carbon::parse($ev['end'])->tz($tz) : $s; for ($d = $s->copy()->startOfDay(); $d->lte($e->copy()->endOfDay()); $d->addDay()) { - $byDay[$d->toDateString()][] = $ev['id']; + $byDay[$d->toDateString()][] = $ev['occurrence_id'] ?? $ev['id']; } } diff --git a/app/Http/Controllers/EventController.php b/app/Http/Controllers/EventController.php index 8cf24df..22ce043 100644 --- a/app/Http/Controllers/EventController.php +++ b/app/Http/Controllers/EventController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use App\Models\Calendar; use App\Models\Event; use App\Models\Location; +use App\Services\Event\EventRecurrence; use App\Services\Location\Geocoder; use Carbon\Carbon; use Illuminate\Http\RedirectResponse; @@ -12,12 +13,11 @@ use Illuminate\Http\Request; use Illuminate\Support\Str; use Illuminate\Support\Facades\Log; use Symfony\Component\HttpFoundation\Response; -use Sabre\VObject\Reader; class EventController extends Controller { /** - * create a new event page + * create a new event */ public function create(Calendar $calendar, Request $request) { @@ -47,6 +47,7 @@ class EventController extends Controller $start = $anchor->copy()->format('Y-m-d\TH:i'); $end = $anchor->copy()->addHour()->format('Y-m-d\TH:i'); + $rrule = ''; return view('event.form', compact( 'calendar', @@ -55,13 +56,14 @@ class EventController extends Controller 'start', 'end', 'tz', + 'rrule', )); } /** - * edit event page + * edit event */ - public function edit(Calendar $calendar, Event $event, Request $request) + public function edit(Calendar $calendar, Event $event, Request $request, EventRecurrence $recurrence) { $this->authorize('update', $calendar); @@ -83,13 +85,17 @@ class EventController extends Controller ? Carbon::parse($event->meta->end_at)->timezone($tz)->format('Y-m-d\TH:i') : null; - return view('event.form', compact('calendar', 'instance', 'event', 'start', 'end', 'tz')); + $rrule = $event->meta?->extra['rrule'] + ?? $recurrence->extractRrule($event) + ?? ''; + + return view('event.form', compact('calendar', 'instance', 'event', 'start', 'end', 'tz', 'rrule')); } /** * single event view handling */ - public function show(Request $request, Calendar $calendar, Event $event) + public function show(Request $request, Calendar $calendar, Event $event, EventRecurrence $recurrence) { if ((int) $event->calendarid !== (int) $calendar->id) { abort(Response::HTTP_NOT_FOUND); @@ -102,16 +108,29 @@ class EventController extends Controller $isHtmx = $request->header('HX-Request') === 'true'; $tz = $this->displayTimezone($calendar, $request); - // prefer meta utc timestamps, fall back to sabre columns - $startUtc = $event->meta?->start_at - ? Carbon::parse($event->meta->start_at)->utc() - : Carbon::createFromTimestamp($event->firstoccurence, 'UTC'); + // prefer occurrence when supplied (recurring events), fall back to meta, then sabre columns + $occurrenceParam = $request->query('occurrence'); + $occurrenceStart = null; + if ($occurrenceParam) { + try { + $occurrenceStart = Carbon::parse($occurrenceParam)->utc(); + } catch (\Throwable $e) { + $occurrenceStart = null; + } + } + $occurrence = $occurrenceStart + ? $recurrence->resolveOccurrence($event, $occurrenceStart) + : null; - $endUtc = $event->meta?->end_at + $startUtc = $occurrence['start'] ?? ($event->meta?->start_at + ? Carbon::parse($event->meta->start_at)->utc() + : Carbon::createFromTimestamp($event->firstoccurence, 'UTC')); + + $endUtc = $occurrence['end'] ?? ($event->meta?->end_at ? Carbon::parse($event->meta->end_at)->utc() : ($event->lastoccurence ? Carbon::createFromTimestamp($event->lastoccurence, 'UTC') - : $startUtc->copy()); + : $startUtc->copy())); // convert for display $start = $startUtc->copy()->timezone($tz); @@ -127,7 +146,7 @@ class EventController extends Controller /** * insert vevent into sabre’s calendarobjects + meta row */ - public function store(Request $request, Calendar $calendar, Geocoder $geocoder): RedirectResponse + public function store(Request $request, Calendar $calendar, Geocoder $geocoder, EventRecurrence $recurrence): RedirectResponse { $this->authorize('update', $calendar); @@ -139,6 +158,7 @@ class EventController extends Controller 'location' => ['nullable', 'string'], 'all_day' => ['sometimes', 'boolean'], 'category' => ['nullable', 'string', 'max:50'], + 'rrule' => ['nullable', 'string', 'max:255'], // normalized location hints (optional) 'loc_display_name' => ['nullable', 'string'], @@ -160,25 +180,19 @@ class EventController extends Controller $uid = Str::uuid() . '@' . parse_url(config('app.url'), PHP_URL_HOST); - $description = $this->escapeIcsText($data['description'] ?? ''); - $locationStr = $this->escapeIcsText($data['location'] ?? ''); + $rrule = $this->normalizeRrule($request); + $extra = $this->mergeRecurrenceExtra([], $rrule, $tz, $request); - // write dtstart/dtend as utc with "Z" so we have one canonical representation - $ical = <<format('Ymd\\THis\\Z')} -DTSTART:{$startUtc->format('Ymd\\THis\\Z')} -DTEND:{$endUtc->format('Ymd\\THis\\Z')} -SUMMARY:{$this->escapeIcsText($data['title'])} -DESCRIPTION:$description -LOCATION:$locationStr -END:VEVENT -END:VCALENDAR -ICS; + $ical = $recurrence->buildCalendar([ + 'uid' => $uid, + 'start_utc' => $startUtc, + 'end_utc' => $endUtc, + 'summary' => $data['title'], + 'description' => $data['description'] ?? '', + 'location' => $data['location'] ?? '', + 'tzid' => $rrule ? $tz : null, + 'rrule' => $rrule, + ]); $event = Event::create([ 'calendarid' => $calendar->id, @@ -202,6 +216,7 @@ ICS; 'category' => $data['category'] ?? null, 'start_at' => $startUtc, 'end_at' => $endUtc, + 'extra' => $extra, ]); return redirect()->route('calendar.show', $calendar); @@ -210,7 +225,7 @@ ICS; /** * update vevent + meta */ - public function update(Request $request, Calendar $calendar, Event $event): RedirectResponse + public function update(Request $request, Calendar $calendar, Event $event, EventRecurrence $recurrence): RedirectResponse { $this->authorize('update', $calendar); @@ -226,6 +241,7 @@ ICS; 'location' => ['nullable', 'string'], 'all_day' => ['sometimes', 'boolean'], 'category' => ['nullable', 'string', 'max:50'], + 'rrule' => ['nullable', 'string', 'max:255'], ]); $tz = $this->displayTimezone($calendar, $request); @@ -235,25 +251,23 @@ ICS; $uid = $event->uid; - $description = $this->escapeIcsText($data['description'] ?? ''); - $locationStr = $this->escapeIcsText($data['location'] ?? ''); - $summary = $this->escapeIcsText($data['title']); + $rrule = $this->normalizeRrule($request); + $extra = $event->meta?->extra ?? []; + $extra = $this->mergeRecurrenceExtra($extra, $rrule, $tz, $request); + $rruleForIcs = $rrule ?? ($extra['rrule'] ?? $recurrence->extractRrule($event)); - $ical = <<format('Ymd\\THis\\Z')} -DTSTART:{$startUtc->format('Ymd\\THis\\Z')} -DTEND:{$endUtc->format('Ymd\\THis\\Z')} -SUMMARY:$summary -DESCRIPTION:$description -LOCATION:$locationStr -END:VEVENT -END:VCALENDAR -ICS; + $ical = $recurrence->buildCalendar([ + 'uid' => $uid, + 'start_utc' => $startUtc, + 'end_utc' => $endUtc, + 'summary' => $data['title'], + 'description' => $data['description'] ?? '', + 'location' => $data['location'] ?? '', + 'tzid' => $rruleForIcs ? $tz : null, + 'rrule' => $rruleForIcs, + 'exdate' => $extra['exdate'] ?? [], + 'rdate' => $extra['rdate'] ?? [], + ]); $event->update([ 'calendardata' => $ical, @@ -269,6 +283,7 @@ ICS; 'category' => $data['category'] ?? null, 'start_at' => $startUtc, 'end_at' => $endUtc, + 'extra' => $extra, ]); return redirect()->route('calendar.show', $calendar); @@ -305,6 +320,55 @@ ICS; return $text; } + private function normalizeRrule(Request $request): ?string + { + if (! $request->has('rrule')) { + return null; + } + + $rrule = trim((string) $request->input('rrule')); + return $rrule === '' ? '' : $rrule; + } + + private function mergeRecurrenceExtra(array $extra, ?string $rrule, string $tz, Request $request): array + { + if ($rrule === null) { + return $extra; // no change requested + } + + if ($rrule === '') { + unset($extra['rrule'], $extra['exdate'], $extra['rdate'], $extra['tzid']); + return $extra; + } + + $extra['rrule'] = $rrule; + $extra['tzid'] = $tz; + + $extra['exdate'] = $this->normalizeDateList($request->input('exdate', $extra['exdate'] ?? []), $tz); + $extra['rdate'] = $this->normalizeDateList($request->input('rdate', $extra['rdate'] ?? []), $tz); + + return $extra; + } + + private function normalizeDateList(mixed $value, string $tz): array + { + if (is_string($value)) { + $value = array_filter(array_map('trim', explode(',', $value))); + } + + if (! is_array($value)) { + return []; + } + + return array_values(array_filter(array_map(function ($item) use ($tz) { + if (! $item) { + return null; + } + + return Carbon::parse($item, $tz)->utc()->toIso8601String(); + }, $value))); + } + /** * resolve location_id from hints or geocoding */ diff --git a/app/Http/Controllers/IcsController.php b/app/Http/Controllers/IcsController.php index 670dd2a..da3b636 100644 --- a/app/Http/Controllers/IcsController.php +++ b/app/Http/Controllers/IcsController.php @@ -5,6 +5,8 @@ namespace App\Http\Controllers; use App\Models\CalendarInstance; use Illuminate\Support\Facades\Response; use Carbon\Carbon; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Reader; class IcsController extends Controller { @@ -25,39 +27,48 @@ class IcsController extends Controller protected function generateICalendarFeed($events, string $tz): string { - $output = []; - $output[] = 'BEGIN:VCALENDAR'; - $output[] = 'VERSION:2.0'; - $output[] = 'PRODID:-//Kithkin Calendar//EN'; - $output[] = 'CALSCALE:GREGORIAN'; - $output[] = 'METHOD:PUBLISH'; + $vcalendar = new VCalendar(); + $vcalendar->add('VERSION', '2.0'); + $vcalendar->add('PRODID', '-//Kithkin Calendar//EN'); + $vcalendar->add('CALSCALE', 'GREGORIAN'); + $vcalendar->add('METHOD', 'PUBLISH'); foreach ($events as $event) { - $meta = $event->meta; + $ical = $event->calendardata ?? null; + if ($ical) { + try { + $parsed = Reader::read($ical); + foreach ($parsed->select('VEVENT') as $vevent) { + $vcalendar->add(clone $vevent); + } + continue; + } catch (\Throwable $e) { + // fall through to meta-based output + } + } + + $meta = $event->meta; if (!$meta || !$meta->start_at || !$meta->end_at) { continue; } - $start = Carbon::parse($meta->start_at)->timezone($tz)->format('Ymd\THis'); - $end = Carbon::parse($meta->end_at)->timezone($tz)->format('Ymd\THis'); + $start = Carbon::parse($meta->start_at)->timezone($tz); + $end = Carbon::parse($meta->end_at)->timezone($tz); - $output[] = 'BEGIN:VEVENT'; - $output[] = 'UID:' . $event->uid; - $output[] = 'SUMMARY:' . $this->escape($meta->title ?? '(Untitled)'); - $output[] = 'DESCRIPTION:' . $this->escape($meta->description ?? ''); - $output[] = 'DTSTART;TZID=' . $tz . ':' . $start; - $output[] = 'DTEND;TZID=' . $tz . ':' . $end; - $output[] = 'DTSTAMP:' . Carbon::parse($event->lastmodified)->format('Ymd\THis\Z'); + $vevent = $vcalendar->add('VEVENT', []); + $vevent->add('UID', $event->uid); + $vevent->add('SUMMARY', $meta->title ?? '(Untitled)'); + $vevent->add('DESCRIPTION', $meta->description ?? ''); + $vevent->add('DTSTART', $start, ['TZID' => $tz]); + $vevent->add('DTEND', $end, ['TZID' => $tz]); + $vevent->add('DTSTAMP', Carbon::parse($event->lastmodified)->utc()); if ($meta->location) { - $output[] = 'LOCATION:' . $this->escape($meta->location); + $vevent->add('LOCATION', $meta->location); } - $output[] = 'END:VEVENT'; } - $output[] = 'END:VCALENDAR'; - - return implode("\r\n", $output); + return $vcalendar->serialize(); } protected function escape(?string $text): string diff --git a/app/Models/Event.php b/app/Models/Event.php index 549cb1e..53f0680 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -53,12 +53,22 @@ class Event extends Model **/ public function scopeInRange($query, $start, $end) { - return $query->whereHas('meta', function ($q) use ($start, $end) { - $q->where('start_at', '<=', $end) - ->where(function ($qq) use ($start) { - $qq->where('end_at', '>=', $start) - ->orWhereNull('end_at'); - }); + return $query->where(function ($q) use ($start, $end) { + $q->whereHas('meta', function ($meta) use ($start, $end) { + $meta->where(function ($range) use ($start, $end) { + $range->where('start_at', '<=', $end) + ->where(function ($bounds) use ($start) { + $bounds->where('end_at', '>=', $start) + ->orWhereNull('end_at'); + }); + }) + ->orWhereNotNull('extra->rrule'); + }) + ->orWhere(function ($ical) { + $ical->where('calendardata', 'like', '%RRULE%') + ->orWhere('calendardata', 'like', '%RDATE%') + ->orWhere('calendardata', 'like', '%EXDATE%'); + }); }); } diff --git a/app/Services/Event/EventRecurrence.php b/app/Services/Event/EventRecurrence.php new file mode 100644 index 0000000..285f1ab --- /dev/null +++ b/app/Services/Event/EventRecurrence.php @@ -0,0 +1,185 @@ +add('PRODID', '-//Kithkin//Laravel CalDAV//EN'); + $vcalendar->add('VERSION', '2.0'); + $vcalendar->add('CALSCALE', 'GREGORIAN'); + $vevent = $vcalendar->add('VEVENT', []); + + $uid = $data['uid']; + $startUtc = $data['start_utc']; + $endUtc = $data['end_utc']; + $tzid = $data['tzid'] ?? null; + + $vevent->add('UID', $uid); + $vevent->add('DTSTAMP', $startUtc->copy()->utc()); + + if ($tzid) { + $startLocal = $startUtc->copy()->tz($tzid); + $endLocal = $endUtc->copy()->tz($tzid); + $vevent->add('DTSTART', $startLocal, ['TZID' => $tzid]); + $vevent->add('DTEND', $endLocal, ['TZID' => $tzid]); + } else { + $vevent->add('DTSTART', $startUtc->copy()->utc()); + $vevent->add('DTEND', $endUtc->copy()->utc()); + } + + if (!empty($data['summary'])) { + $vevent->add('SUMMARY', $data['summary']); + } + + if (!empty($data['description'])) { + $vevent->add('DESCRIPTION', $data['description']); + } + + if (!empty($data['location'])) { + $vevent->add('LOCATION', $data['location']); + } + + $rrule = $data['rrule'] ?? null; + if ($rrule) { + $vevent->add('RRULE', $rrule); + } + + $exdates = $data['exdate'] ?? []; + if (!empty($exdates)) { + foreach ($exdates as $ex) { + $dt = Carbon::parse($ex, $tzid ?: 'UTC'); + if ($tzid) { + $vevent->add('EXDATE', $dt, ['TZID' => $tzid]); + } else { + $vevent->add('EXDATE', $dt->utc()); + } + } + } + + $rdates = $data['rdate'] ?? []; + if (!empty($rdates)) { + foreach ($rdates as $r) { + $dt = Carbon::parse($r, $tzid ?: 'UTC'); + if ($tzid) { + $vevent->add('RDATE', $dt, ['TZID' => $tzid]); + } else { + $vevent->add('RDATE', $dt->utc()); + } + } + } + + return $vcalendar->serialize(); + } + + /** + * Check if a stored event contains recurrence data. + */ + public function isRecurring(Event $event): bool + { + $extra = $event->meta?->extra ?? []; + if (!empty($extra['rrule'])) { + return true; + } + + return Str::contains($event->calendardata ?? '', ['RRULE', 'RDATE', 'EXDATE']); + } + + /** + * Expand recurring instances within the requested range. + * + * Returns an array of ['start' => Carbon, 'end' => Carbon, 'recurrence_id' => string|null] + */ + public function expand(Event $event, Carbon $rangeStart, Carbon $rangeEnd): array + { + $vcalendar = $this->readCalendar($event->calendardata); + if (!$vcalendar || empty($vcalendar->VEVENT)) { + return []; + } + + $vevent = $vcalendar->VEVENT; + $uid = (string) $vevent->UID; + + $startTz = $vevent->DTSTART?->getDateTime()?->getTimezone() + ?? new DateTimeZone('UTC'); + + $iter = new EventIterator($vcalendar, $uid); + $iter->fastForward($rangeStart->copy()->setTimezone($startTz)->toDateTime()); + + $items = []; + while ($iter->valid()) { + $start = Carbon::instance($iter->getDTStart()); + $end = Carbon::instance($iter->getDTEnd()); + + if ($start->gt($rangeEnd)) { + break; + } + + $startUtc = $start->copy()->utc(); + $endUtc = $end->copy()->utc(); + $items[] = [ + 'start' => $startUtc, + 'end' => $endUtc, + 'recurrence_id' => $startUtc->format('Ymd\\THis\\Z'), + ]; + + $iter->next(); + } + + return $items; + } + + /** + * Resolve a single occurrence by its DTSTART. + */ + public function resolveOccurrence(Event $event, Carbon $occurrenceStart): ?array + { + $rangeStart = $occurrenceStart->copy()->subDay(); + $rangeEnd = $occurrenceStart->copy()->addDay(); + + foreach ($this->expand($event, $rangeStart, $rangeEnd) as $occ) { + if ($occ['start']->equalTo($occurrenceStart)) { + return $occ; + } + } + + return null; + } + + public function extractRrule(Event $event): ?string + { + $vcalendar = $this->readCalendar($event->calendardata); + if (!$vcalendar || empty($vcalendar->VEVENT)) { + return null; + } + + $vevent = $vcalendar->VEVENT; + return isset($vevent->RRULE) ? (string) $vevent->RRULE : null; + } + + private function readCalendar(?string $ical): ?VCalendar + { + if (!$ical) { + return null; + } + + try { + return Reader::read($ical); + } catch (\Throwable $e) { + return null; + } + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 1681548..630dcd0 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -8,6 +8,7 @@ use Illuminate\Support\Str; use Illuminate\Support\Facades\DB; use Carbon\Carbon; use App\Models\User; +use App\Services\Event\EventRecurrence; class DatabaseSeeder extends Seeder { @@ -217,6 +218,68 @@ ICS; ); }; + $recurrence = new EventRecurrence(); + + $insertRecurringEvent = function ( + Carbon $start, + string $summary, + string $locationKey, + string $rrule, + string $tz + ) use ($calId, $locationIdMap, $locationSeeds, $recurrence) { + $uid = Str::uuid().'@kithkin.lan'; + $end = $start->copy()->addHour(); + + $startUtc = $start->copy()->utc(); + $endUtc = $end->copy()->utc(); + + $locationDisplay = $locationKey; + $locationRaw = $locationSeeds[$locationKey]['raw'] ?? null; + $icalLocation = $locationRaw ?? $locationDisplay; + + $ical = $recurrence->buildCalendar([ + 'uid' => $uid, + 'start_utc' => $startUtc, + 'end_utc' => $endUtc, + 'summary' => $summary, + 'description' => 'Automatically seeded recurring event', + 'location' => $icalLocation, + 'tzid' => $tz, + 'rrule' => $rrule, + ]); + + $eventId = DB::table('calendarobjects')->insertGetId([ + 'calendarid' => $calId, + 'uri' => Str::uuid().'.ics', + 'lastmodified' => time(), + 'etag' => md5($ical), + 'size' => strlen($ical), + 'componenttype' => 'VEVENT', + 'uid' => $uid, + 'calendardata' => $ical, + ]); + + DB::table('event_meta')->updateOrInsert( + ['event_id' => $eventId], + [ + 'title' => $summary, + 'description' => 'Automatically seeded recurring event', + 'location' => $locationRaw ? null : $locationDisplay, + 'location_id' => $locationIdMap[$locationKey] ?? null, + 'all_day' => false, + 'category' => 'Demo', + 'start_at' => $startUtc, + 'end_at' => $endUtc, + 'extra' => json_encode([ + 'rrule' => $rrule, + 'tzid' => $tz, + ]), + 'created_at' => now(), + 'updated_at' => now(), + ] + ); + }; + /** * * create events @@ -244,6 +307,16 @@ ICS; $insertEvent($future5a, 'Teacher conference (3rd grade)', 'Fairview Elementary'); $insertEvent($future5b, 'Family game night', 'Living Room'); + // recurring: weekly on Mon/Wed for 8 weeks at 6:30pm + $recurringStart = $now->copy()->next(Carbon::MONDAY)->setTime(18, 30); + $insertRecurringEvent( + $recurringStart, + 'Evening run', + 'McCahill Park', + 'FREQ=WEEKLY;BYDAY=MO,WE;COUNT=16', + $tz + ); + /** * * address books diff --git a/resources/views/components/calendar/month/day.blade.php b/resources/views/components/calendar/month/day.blade.php index 31a03db..32679d3 100644 --- a/resources/views/components/calendar/month/day.blade.php +++ b/resources/views/components/calendar/month/day.blade.php @@ -19,10 +19,14 @@ @php $event = $events[$eventId]; $color = $event['color'] ?? '#999'; + $showParams = [$event['calendar_slug'], $event['id']]; + if (!empty($event['occurrence'])) { + $showParams['occurrence'] = $event['occurrence']; + } @endphp + @php + $showParams = [$event['calendar_slug'], $event['id']]; + if (!empty($event['occurrence'])) { + $showParams['occurrence'] = $event['occurrence']; + } + @endphp + {{-- Recurrence (advanced) --}} +
+ + {{ __('Repeat (advanced)') }} + +
+ + +

+ Example: FREQ=WEEKLY;BYDAY=MO,WE +

+ +
+
+ {{-- Submit --}}
Date: Tue, 3 Feb 2026 15:08:16 -0500 Subject: [PATCH 02/10] Updates database seeder with recurring event; fixes .now-indicator position --- database/seeders/DatabaseSeeder.php | 4 ++-- resources/css/lib/calendar.css | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 630dcd0..1c3c07f 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -227,7 +227,7 @@ ICS; string $rrule, string $tz ) use ($calId, $locationIdMap, $locationSeeds, $recurrence) { - $uid = Str::uuid().'@kithkin.lan'; + $uid = Str::uuid().'@'.parse_url(config('app.url'), PHP_URL_HOST); $end = $start->copy()->addHour(); $startUtc = $start->copy()->utc(); @@ -289,7 +289,7 @@ ICS; $now = Carbon::now($tz)->setSeconds(0); // 3 events today - $insertEvent($now->copy(), 'Playground with James', 'McCaHill Park'); + $insertEvent($now->copy(), 'Playground with James', 'McCahill Park'); $insertEvent($now->copy()->addHours(2), 'Lunch with Daniel', 'Home'); $insertEvent($now->copy()->addHours(4), 'Baseball practice', 'Meadow Park'); diff --git a/resources/css/lib/calendar.css b/resources/css/lib/calendar.css index 098f4ec..952d63b 100644 --- a/resources/css/lib/calendar.css +++ b/resources/css/lib/calendar.css @@ -149,7 +149,7 @@ /* event positioning */ ol.events { - @apply grid pt-4; + @apply grid py-4; grid-template-rows: repeat(96, var(--row-height)); --event-col: 0; --event-row: 0; @@ -194,7 +194,7 @@ /* now indicator */ .now-indicator { - @apply relative pointer-events-none z-2 border-t-3 border-red-600 opacity-90 -ml-2; + @apply relative pointer-events-none z-10 border-t-3 border-red-600 opacity-90 -ml-2; grid-row: var(--now-row); grid-column: var(--now-col-start) / var(--now-col-end); width: calc(100% + 1rem); -- 2.34.1 From 39078680ab80cec3b4de338362ca8193de05a373 Mon Sep 17 00:00:00 2001 From: Andrew Gioia Date: Tue, 3 Feb 2026 15:54:08 -0500 Subject: [PATCH 03/10] Adds business hours toggle for time-based views --- app/Http/Controllers/CalendarController.php | 119 +++++++++++++++--- resources/css/lib/calendar.css | 5 +- resources/views/calendar/index.blade.php | 6 + .../components/calendar/day/day.blade.php | 6 +- .../components/calendar/four/four.blade.php | 7 +- .../components/calendar/mini/mini.blade.php | 3 + .../calendar/time/business-hours.blade.php | 34 +++++ .../calendar/time/density.blade.php | 2 + .../components/calendar/week/week.blade.php | 7 +- 9 files changed, 164 insertions(+), 25 deletions(-) create mode 100644 resources/views/components/calendar/time/business-hours.blade.php diff --git a/app/Http/Controllers/CalendarController.php b/app/Http/Controllers/CalendarController.php index c176692..dc99977 100644 --- a/app/Http/Controllers/CalendarController.php +++ b/app/Http/Controllers/CalendarController.php @@ -52,6 +52,7 @@ class CalendarController extends Controller $defaultView = $user->getSetting('calendar.last_view', 'month'); $defaultDate = $user->getSetting('calendar.last_date', Carbon::today($tz)->toDateString()); $defaultDensity = (int) $user->getSetting('calendar.last_density', 30); + $defaultBusinessHours = (int) $user->getSetting('calendar.business_hours', 0); // week start preference $weekStartPref = $user->getSetting('calendar.week_start', 'sunday'); // 'sunday'|'monday' @@ -76,6 +77,16 @@ class CalendarController extends Controller 60 => 4, }; + // business hours toggle + $businessHoursEnabled = (int) $request->query('business_hours', $defaultBusinessHours) === 1; + $businessHoursRange = [ + 'start' => 8, + 'end' => 18, + ]; + $businessHoursRows = $businessHoursEnabled + ? intdiv((($businessHoursRange['end'] - $businessHoursRange['start']) * 60), 15) + : 96; + // date range span and controls $span = $this->gridSpan($view, $range, $weekStart, $weekEnd); $nav = $this->navDates($view, $range['start'], $tz); @@ -92,6 +103,9 @@ class CalendarController extends Controller $user->setSetting('calendar.last_date', $range['start']->toDateString()); $user->setSetting('calendar.last_density', (string) $stepMinutes); } + if ($request->has('business_hours')) { + $user->setSetting('calendar.business_hours', $businessHoursEnabled ? '1' : '0'); + } /** * @@ -121,6 +135,10 @@ class CalendarController extends Controller $span['end'] ); + $businessHoursForView = ($businessHoursEnabled && in_array($view, ['day', 'week', 'four'], true)) + ? $businessHoursRange + : null; + $events = $this->buildEventPayloads( $events, $calendar_map, @@ -130,6 +148,7 @@ class CalendarController extends Controller $tz, $recurrence, $span, + $businessHoursForView, ); /** @@ -175,6 +194,7 @@ class CalendarController extends Controller $tz, $recurrence, ['start' => $mini_grid_start, 'end' => $mini_grid_end], + null, ); // now build the mini from mini_events (not from $events) @@ -231,6 +251,12 @@ class CalendarController extends Controller 'mini' => $mini, // mini calendar days with events for indicators 'mini_nav' => $mini_nav, // separate mini calendar navigation 'mini_headers' => $mini_headers, + 'business_hours' => [ + 'enabled' => $businessHoursEnabled, + 'start' => $businessHoursRange['start'], + 'end' => $businessHoursRange['end'], + 'rows' => $businessHoursRows, + ], ]; // time-based payload values @@ -238,11 +264,23 @@ class CalendarController extends Controller if ($timeBased) { // create the time gutter if we're in a time-based view - $payload['slots'] = $this->timeSlots($range['start'], $tz, $timeFormat); + $payload['slots'] = $this->timeSlots( + $range['start'], + $tz, + $timeFormat, + $businessHoursEnabled ? $businessHoursRange : null + ); $payload['time_format'] = $timeFormat; // optional, if the blade cares // add the now indicator - $payload['now'] = $this->nowIndicator($view, $range, $tz, 15); + $payload['now'] = $this->nowIndicator( + $view, + $range, + $tz, + 15, + 1, + $businessHoursEnabled ? $businessHoursRange : null + ); } // send the density array always, even though it doesn't matter for month @@ -570,15 +608,23 @@ class CalendarController extends Controller * * Create the time gutter for time-based views */ - private function timeSlots(Carbon $dayStart, string $tz, string $timeFormat): array + private function timeSlots(Carbon $dayStart, string $tz, string $timeFormat, ?array $businessHours = null): array { $minutesPerSlot = 15; - $slotsPerDay = intdiv(24 * 60, $minutesPerSlot); // 96 + $startMinutes = 0; + $endMinutes = 24 * 60; + + if (is_array($businessHours)) { + $startMinutes = (int) $businessHours['start'] * 60; + $endMinutes = (int) $businessHours['end'] * 60; + } + + $slotsPerDay = intdiv(max(0, $endMinutes - $startMinutes), $minutesPerSlot); $format = $timeFormat === '24' ? 'H:i' : 'g:i a'; $slots = []; - $t = $dayStart->copy()->tz($tz)->startOfDay(); + $t = $dayStart->copy()->tz($tz)->startOfDay()->addMinutes($startMinutes); for ($i = 0; $i < $slotsPerDay; $i++) { $slots[] = [ @@ -586,7 +632,7 @@ class CalendarController extends Controller 'label' => $t->format($format), 'key' => $t->format('H:i'), // stable "machine" value 'index' => $i, // 0..95 - 'minutes' => $i * $minutesPerSlot, + 'minutes' => $startMinutes + ($i * $minutesPerSlot), 'duration' => $minutesPerSlot, // handy for styling math ]; @@ -613,7 +659,8 @@ class CalendarController extends Controller ?Carbon $endLocal, Carbon $rangeStart, string $view, - int $minutesPerSlot = 15 + int $minutesPerSlot = 15, + int $gridStartMinutes = 0 ): array { $start = $startLocal->copy(); @@ -626,7 +673,8 @@ class CalendarController extends Controller $displayMinutes = $durationMinutes > 0 ? $durationMinutes : $minutesPerSlot; // row placement (96 rows when minutesPerSlot=15) - $startMinutesFromMidnight = ($start->hour * 60) + $start->minute; + $startMinutesFromMidnight = (($start->hour * 60) + $start->minute) - $gridStartMinutes; + $startMinutesFromMidnight = max(0, $startMinutesFromMidnight); $startRow = intdiv($startMinutesFromMidnight, $minutesPerSlot) + 1; $rowSpan = max(1, (int) ceil($displayMinutes / $minutesPerSlot)); @@ -665,11 +713,14 @@ class CalendarController extends Controller array $range, string $tz, EventRecurrence $recurrence, - array $span + array $span, + ?array $businessHours = null ): Collection { $uiFormat = $timeFormat === '24' ? 'H:i' : 'g:ia'; $spanStartUtc = $span['start']->copy()->utc(); $spanEndUtc = $span['end']->copy()->utc(); + $gridStartMinutes = $businessHours ? ((int) $businessHours['start'] * 60) : 0; + $gridEndMinutes = $businessHours ? ((int) $businessHours['end'] * 60) : (24 * 60); return $events->flatMap(function ($e) use ( $calendarMap, @@ -679,7 +730,10 @@ class CalendarController extends Controller $tz, $recurrence, $spanStartUtc, - $spanEndUtc + $spanEndUtc, + $gridStartMinutes, + $gridEndMinutes, + $businessHours ) { $cal = $calendarMap[$e->calendarid]; $timezone = $cal->timezone ?? config('app.timezone'); @@ -723,7 +777,10 @@ class CalendarController extends Controller $tz, $timezone, $color, - $colorFg + $colorFg, + $gridStartMinutes, + $gridEndMinutes, + $businessHours ) { $startUtc = $occ['start']; $endUtc = $occ['end']; @@ -734,12 +791,28 @@ class CalendarController extends Controller $startForGrid = $startUtc->copy()->tz($tz); $endForGrid = $endUtc->copy()->tz($tz); + if ($businessHours) { + $startMinutes = ($startForGrid->hour * 60) + $startForGrid->minute; + $endMinutes = ($endForGrid->hour * 60) + $endForGrid->minute; + + if ($endMinutes <= $gridStartMinutes || $startMinutes >= $gridEndMinutes) { + return null; + } + + $displayStartMinutes = max($startMinutes, $gridStartMinutes); + $displayEndMinutes = min($endMinutes, $gridEndMinutes); + + $startForGrid = $startForGrid->copy()->startOfDay()->addMinutes($displayStartMinutes); + $endForGrid = $endForGrid->copy()->startOfDay()->addMinutes($displayEndMinutes); + } + $placement = $this->slotPlacement( $startForGrid, $endForGrid, $range['start']->copy()->tz($tz), $view, - 15 + 15, + $gridStartMinutes ); $occurrenceId = $occ['recurrence_id'] @@ -770,7 +843,7 @@ class CalendarController extends Controller 'start_col' => $placement['start_col'], 'duration' => $placement['duration'], ]; - }); + })->filter()->values(); })->keyBy('occurrence_id'); } @@ -967,7 +1040,14 @@ class CalendarController extends Controller * 'col_end' => int, // grid column end * ] */ - private function nowIndicator(string $view, array $range, string $tz, int $minutesPerSlot = 15, int $gutterCols = 1): array + private function nowIndicator( + string $view, + array $range, + string $tz, + int $minutesPerSlot = 15, + int $gutterCols = 1, + ?array $businessHours = null + ): array { // only meaningful for time-based views if (!in_array($view, ['day', 'week', 'four'], true)) { @@ -986,8 +1066,15 @@ class CalendarController extends Controller // row: minutes since midnight, snapped down to slot size $minutes = ($now->hour * 60) + $now->minute; - $snapped = intdiv($minutes, $minutesPerSlot) * $minutesPerSlot; - $row = intdiv($snapped, $minutesPerSlot) + 1; // 1-based + $gridStartMinutes = $businessHours ? ((int) $businessHours['start'] * 60) : 0; + $gridEndMinutes = $businessHours ? ((int) $businessHours['end'] * 60) : (24 * 60); + + if ($businessHours && ($minutes < $gridStartMinutes || $minutes > $gridEndMinutes)) { + return ['show' => false, 'row' => 1, 'day_col' => 1, 'col_start' => 1, 'col_end' => 2]; + } + + $snapped = intdiv($minutes - $gridStartMinutes, $minutesPerSlot) * $minutesPerSlot; + $row = intdiv(max(0, $snapped), $minutesPerSlot) + 1; // 1-based // column: 1..N where 1 is the first day column in the events grid if ($view === 'day') { diff --git a/resources/css/lib/calendar.css b/resources/css/lib/calendar.css index 952d63b..b69878f 100644 --- a/resources/css/lib/calendar.css +++ b/resources/css/lib/calendar.css @@ -131,7 +131,7 @@ /* time column */ ol.time { @apply grid z-0 pt-4; - grid-template-rows: repeat(96, var(--row-height)); + grid-template-rows: repeat(var(--grid-rows, 96), var(--row-height)); time { @apply relative flex items-center justify-end items-start pr-4; @@ -150,7 +150,7 @@ /* event positioning */ ol.events { @apply grid py-4; - grid-template-rows: repeat(96, var(--row-height)); + grid-template-rows: repeat(var(--grid-rows, 96), var(--row-height)); --event-col: 0; --event-row: 0; --event-end: 4; @@ -381,4 +381,3 @@ transform: translateX(0); } } - diff --git a/resources/views/calendar/index.blade.php b/resources/views/calendar/index.blade.php index 547c7ac..fde170c 100644 --- a/resources/views/calendar/index.blade.php +++ b/resources/views/calendar/index.blade.php @@ -74,6 +74,7 @@ :view="$view" :density="$density" :headers="$mini_headers" + :business_hours="$business_hours" class="aside-inset" /> @@ -105,6 +106,7 @@ {{-- persist values from other forms --}} +