authorize('update', $calendar); $instance = $calendar->instanceForUser($request->user()); $tz = $this->displayTimezone($calendar, $request); // build a fresh event "shell" with meta defaults $event = new Event; $event->meta = (object) [ 'title' => '', 'description' => '', 'location' => '', 'start_at' => null, 'end_at' => null, 'all_day' => false, 'category' => '', ]; // if ?date=YYYY-MM-DD is present, start that day at 9am; otherwise "now" $anchor = $request->query('date') ? Carbon::parse($request->query('date'), $tz)->startOfDay()->addHours(9) : Carbon::now($tz); $anchor->second(0); $start = $anchor->copy()->format('Y-m-d\TH:i'); $end = $anchor->copy()->addHour()->format('Y-m-d\TH:i'); return view('event.form', compact( 'calendar', 'instance', 'event', 'start', 'end', 'tz', )); } /** * edit event page */ public function edit(Calendar $calendar, Event $event, Request $request) { $this->authorize('update', $calendar); // ensure the event belongs to the parent calendar if ((int) $event->calendarid !== (int) $calendar->id) { abort(Response::HTTP_NOT_FOUND); } $instance = $calendar->instanceForUser($request->user()); $tz = $this->displayTimezone($calendar, $request); $event->load('meta'); $start = $event->meta?->start_at ? Carbon::parse($event->meta->start_at)->timezone($tz)->format('Y-m-d\TH:i') : null; $end = $event->meta?->end_at ? 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')); } /** * single event view handling */ public function show(Request $request, Calendar $calendar, Event $event) { if ((int) $event->calendarid !== (int) $calendar->id) { abort(Response::HTTP_NOT_FOUND); } $this->authorize('view', $event); $event->load(['meta', 'meta.venue']); $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'); $endUtc = $event->meta?->end_at ? Carbon::parse($event->meta->end_at)->utc() : ($event->lastoccurence ? Carbon::createFromTimestamp($event->lastoccurence, 'UTC') : $startUtc->copy()); // convert for display $start = $startUtc->copy()->timezone($tz); $end = $endUtc->copy()->timezone($tz); $data = compact('calendar', 'event', 'start', 'end', 'tz'); return $isHtmx ? view('event.partials.details', $data) : view('event.show', $data); } /** * insert vevent into sabre’s calendarobjects + meta row */ public function store(Request $request, Calendar $calendar, Geocoder $geocoder): RedirectResponse { $this->authorize('update', $calendar); $data = $request->validate([ 'title' => ['required', 'string', 'max:200'], 'start_at' => ['required', 'date_format:Y-m-d\TH:i'], 'end_at' => ['required', 'date_format:Y-m-d\TH:i', 'after:start_at'], 'description' => ['nullable', 'string'], 'location' => ['nullable', 'string'], 'all_day' => ['sometimes', 'boolean'], 'category' => ['nullable', 'string', 'max:50'], // normalized location hints (optional) 'loc_display_name' => ['nullable', 'string'], 'loc_place_name' => ['nullable', 'string'], 'loc_street' => ['nullable', 'string'], 'loc_city' => ['nullable', 'string'], 'loc_state' => ['nullable', 'string'], 'loc_postal' => ['nullable', 'string'], 'loc_country' => ['nullable', 'string'], 'loc_lat' => ['nullable'], 'loc_lon' => ['nullable'], ]); $tz = $this->displayTimezone($calendar, $request); // parse input in display tz, store in utc $startUtc = $this->parseLocalDatetimeToUtc($data['start_at'], $tz); $endUtc = $this->parseLocalDatetimeToUtc($data['end_at'], $tz); $uid = Str::uuid() . '@' . parse_url(config('app.url'), PHP_URL_HOST); $description = $this->escapeIcsText($data['description'] ?? ''); $locationStr = $this->escapeIcsText($data['location'] ?? ''); // 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; $event = Event::create([ 'calendarid' => $calendar->id, 'uri' => Str::uuid() . '.ics', 'lastmodified' => time(), 'etag' => md5($ical), 'size' => strlen($ical), 'componenttype' => 'VEVENT', 'uid' => $uid, 'calendardata' => $ical, ]); $locationId = $this->resolveLocationId($request, $geocoder, $data); $event->meta()->create([ 'title' => $data['title'], 'description' => $data['description'] ?? null, 'location' => $data['location'] ?? null, 'location_id' => $locationId, 'all_day' => (bool) ($data['all_day'] ?? false), 'category' => $data['category'] ?? null, 'start_at' => $startUtc, 'end_at' => $endUtc, ]); return redirect()->route('calendar.show', $calendar); } /** * update vevent + meta */ public function update(Request $request, Calendar $calendar, Event $event): RedirectResponse { $this->authorize('update', $calendar); if ((int) $event->calendarid !== (int) $calendar->id) { abort(Response::HTTP_NOT_FOUND); } $data = $request->validate([ 'title' => ['required', 'string', 'max:200'], 'start_at' => ['required', 'date_format:Y-m-d\TH:i'], 'end_at' => ['required', 'date_format:Y-m-d\TH:i', 'after:start_at'], 'description' => ['nullable', 'string'], 'location' => ['nullable', 'string'], 'all_day' => ['sometimes', 'boolean'], 'category' => ['nullable', 'string', 'max:50'], ]); $tz = $this->displayTimezone($calendar, $request); $startUtc = $this->parseLocalDatetimeToUtc($data['start_at'], $tz); $endUtc = $this->parseLocalDatetimeToUtc($data['end_at'], $tz); $uid = $event->uid; $description = $this->escapeIcsText($data['description'] ?? ''); $locationStr = $this->escapeIcsText($data['location'] ?? ''); $summary = $this->escapeIcsText($data['title']); $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; $event->update([ 'calendardata' => $ical, 'etag' => md5($ical), 'lastmodified' => time(), ]); $event->meta()->updateOrCreate([], [ 'title' => $data['title'], 'description' => $data['description'] ?? null, 'location' => $data['location'] ?? null, 'all_day' => (bool) ($data['all_day'] ?? false), 'category' => $data['category'] ?? null, 'start_at' => $startUtc, 'end_at' => $endUtc, ]); return redirect()->route('calendar.show', $calendar); } /** * pick display timezone: calendar instance -> user -> utc */ private function displayTimezone(Calendar $calendar, Request $request): string { $instanceTz = $calendar->instanceForUser($request->user())?->timezone; $userTz = $request->user()?->timezone; return $instanceTz ?: ($userTz ?: 'UTC'); } /** * parse datetime-local in tz and return utc carbon */ private function parseLocalDatetimeToUtc(string $value, string $tz): Carbon { // datetime-local: 2026-01-21T09:00 $local = Carbon::createFromFormat('Y-m-d\TH:i', $value, $tz)->seconds(0); return $local->utc(); } /** * minimal ics escaping for text properties */ private function escapeIcsText(string $text): string { $text = str_replace(["\r\n", "\r", "\n"], "\\n", $text); $text = str_replace(["\\", ";", ","], ["\\\\", "\;", "\,"], $text); return $text; } /** * resolve location_id from hints or geocoding */ private function resolveLocationId(Request $request, Geocoder $geocoder, array $data): ?int { $raw = $data['location'] ?? null; if (!$raw) return null; $hasNormHints = $request->filled('loc_display_name') || $request->filled('loc_place_name') || $request->filled('loc_street') || $request->filled('loc_city') || $request->filled('loc_state') || $request->filled('loc_postal') || $request->filled('loc_country') || $request->filled('loc_lat') || $request->filled('loc_lon'); if ($hasNormHints) { $norm = [ 'display_name' => $request->input('loc_display_name') ?: $raw, 'place_name' => $request->input('loc_place_name'), 'raw_address' => $raw, 'street' => $request->input('loc_street'), 'city' => $request->input('loc_city'), 'state' => $request->input('loc_state'), 'postal' => $request->input('loc_postal'), 'country' => $request->input('loc_country'), 'lat' => $request->filled('loc_lat') ? (float) $request->input('loc_lat') : null, 'lon' => $request->filled('loc_lon') ? (float) $request->input('loc_lon') : null, ]; return Location::findOrCreateNormalized($norm, $raw)->id; } $norm = $geocoder->forward($raw); if ($norm) { return Location::findOrCreateNormalized($norm, $raw)->id; } return Location::labelOnly($raw)->id; } }