WIP: February 2026 event improvements and calendar refactor #1

Draft
andrew wants to merge 10 commits from feb-2026-event-improvements into master
9 changed files with 598 additions and 182 deletions
Showing only changes of commit 0881d04428 - Show all commits

View File

@ -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'];
}
}

View File

@ -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 sabres 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 = <<<ICS
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Kithkin//Laravel CalDAV//EN
BEGIN:VEVENT
UID:$uid
DTSTAMP:{$startUtc->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 = <<<ICS
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Kithkin//Laravel CalDAV//EN
BEGIN:VEVENT
UID:$uid
DTSTAMP:{$startUtc->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
*/

View File

@ -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

View File

@ -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%');
});
});
}

View File

@ -0,0 +1,185 @@
<?php
namespace App\Services\Event;
use App\Models\Event;
use Carbon\Carbon;
use DateTimeZone;
use Illuminate\Support\Str;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Reader;
use Sabre\VObject\Recur\EventIterator;
class EventRecurrence
{
/**
* Build a VCALENDAR string from core fields and optional recurrence.
*/
public function buildCalendar(array $data): string
{
$vcalendar = new VCalendar();
$vcalendar->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;
}
}
}

View File

@ -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

View File

@ -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
<a class="event{{ $event['visible'] ? '' : ' hidden' }}"
href="{{ route('calendar.event.show', [$event['calendar_slug'], $event['id']]) }}"
hx-get="{{ route('calendar.event.show', [$event['calendar_slug'], $event['id']]) }}"
href="{{ route('calendar.event.show', $showParams) }}"
hx-get="{{ route('calendar.event.show', $showParams) }}"
hx-target="#modal"
hx-push-url="false"
hx-swap="innerHTML"

View File

@ -3,7 +3,7 @@
])
<li class="event"
data-event-id="{{ $event['id'] }}"
data-event-id="{{ $event['occurrence_id'] ?? $event['id'] }}"
data-calendar-id="{{ $event['calendar_slug'] }}"
data-start="{{ $event['start_ui'] }}"
data-duration="{{ $event['duration'] }}"
@ -14,9 +14,15 @@
--event-bg: {{ $event['color'] }};
--event-fg: {{ $event['color_fg'] }};
">
@php
$showParams = [$event['calendar_slug'], $event['id']];
if (!empty($event['occurrence'])) {
$showParams['occurrence'] = $event['occurrence'];
}
@endphp
<a class="event{{ $event['visible'] ? '' : ' hidden' }}"
href="{{ route('calendar.event.show', [$event['calendar_slug'], $event['id']]) }}"
hx-get="{{ route('calendar.event.show', [$event['calendar_slug'], $event['id']]) }}"
href="{{ route('calendar.event.show', $showParams) }}"
hx-get="{{ route('calendar.event.show', $showParams) }}"
hx-target="#modal"
hx-push-url="false"
hx-swap="innerHTML"

View File

@ -105,6 +105,22 @@
</label>
</div>
{{-- Recurrence (advanced) --}}
<details class="mb-6">
<summary class="cursor-pointer text-sm text-gray-600">
{{ __('Repeat (advanced)') }}
</summary>
<div class="mt-3">
<x-input-label for="rrule" :value="__('RRULE')" />
<x-text-input id="rrule" name="rrule" type="text" class="mt-1 block w-full"
:value="old('rrule', $rrule ?? '')" />
<p class="mt-1 text-xs text-gray-500">
Example: <code>FREQ=WEEKLY;BYDAY=MO,WE</code>
</p>
<x-input.error class="mt-2" :messages="$errors->get('rrule')" />
</div>
</details>
{{-- Submit --}}
<div class="flex justify-end space-x-2">
<a href="{{ route('calendar.show', $calendar) }}"