WIP: February 2026 event improvements and calendar refactor #1
@ -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'];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 = <<<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
|
||||
*/
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
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%');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
185
app/Services/Event/EventRecurrence.php
Normal file
185
app/Services/Event/EventRecurrence.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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) }}"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user