Carbon, 'end' => Carbon] * ├─ calendars keyed by calendar id (for the left-hand toggle list) * └─ events flat list of VEVENTs in that range */ public function index(Request $request) { // set the calendar key $principal = auth()->user()->principal_uri; // get the view and time range [$view, $range] = $this->resolveRange($request); // get the user's selected calendars $visible = collect($request->query('c', [])); // load the user's calendars $calendars = Calendar::query() ->select( 'calendars.id', 'ci.displayname', 'ci.calendarcolor', 'ci.uri as slug', 'ci.timezone as timezone', 'meta.color as meta_color', 'meta.color_fg as meta_color_fg' ) ->join('calendarinstances as ci', 'ci.calendarid', '=', 'calendars.id') ->leftJoin('calendar_meta as meta', 'meta.calendar_id', '=', 'calendars.id') ->where('ci.principaluri', $principal) ->orderBy('ci.displayname') ->get() ->map(function ($cal) use ($visible) { $cal->visible = $visible->isEmpty() || $visible->contains($cal->slug); return $cal; }); // handy lookup: [id => calendar row] $calendar_map = $calendars->keyBy('id'); // get all the events in one query $events = Event::forCalendarsInRange( $calendars->pluck('id'), $range['start'], $range['end'] )->map(function ($e) use ($calendar_map) { // 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); // 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); // return events array return [ 'id' => $e->id, 'calendar_id' => $e->calendarid, 'calendar_slug' => $cal->slug, 'title' => $e->meta->title ?? '(no title)', 'start' => $start_utc->toIso8601String(), 'end' => optional($end_utc)->toIso8601String(), 'start_ui' => $start_local->format('g:ia'), 'end_ui' => optional($end_local)->format('g:ia'), 'timezone' => $timezone, 'visible' => $cal->visible, ]; })->keyBy('id'); // create the calendar grid of days $grid = $this->buildCalendarGrid($view, $range, $events); // format the data for the frontend, including separate arrays for events specifically and the big grid $payload = [ 'view' => $view, 'range' => $range, 'active' => [ 'year' => $range['start']->format('Y'), 'month' => $range['start']->format("F"), 'day' => $range['start']->format("d"), ], 'calendars' => $calendar_map->map(function ($cal) { return [ 'id' => $cal->id, 'slug' => $cal->slug, 'name' => $cal->displayname, 'color' => $cal->meta_color ?? $cal->calendarcolor ?? '#1a1a1a', // clean this up @todo 'color_fg' => $cal->meta_color_fg ?? '#ffffff', // clean this up 'visible' => true, // default to visible; the UI can toggle this ]; }), 'events' => $events, // keyed, one copy each 'grid' => $grid, // day objects hold only ID-sets ]; return view('calendar.index', $payload); } public function create() { return view('calendar.create'); } /** * create sabre calendar + meta */ public function store(Request $request) { $data = $request->validate([ 'name' => 'required|string|max:100', 'description' => 'nullable|string|max:255', 'timezone' => 'required|string', 'color' => 'nullable|regex:/^#[0-9A-Fa-f]{6}$/', ]); // update master calendar entry $calId = DB::table('calendars')->insertGetId([ 'synctoken' => 1, 'components' => 'VEVENT', // or 'VEVENT,VTODO' if you add tasks ]); // update the calendar instance row $instance = CalendarInstance::create([ 'calendarid' => $calId, 'principaluri' => auth()->user()->principal_uri, 'uri' => Str::uuid(), 'displayname' => $data['name'], 'description' => $data['description'] ?? null, 'calendarcolor'=> $data['color'] ?? null, 'timezone' => $data['timezone'], ]); // update calendar meta $instance->meta()->create([ 'calendar_id' => $instanceId, 'color' => $data['color'] ?? '#1a1a1a', 'color_fg' => contrast_text_color($data['color'] ?? '#1a1a1a'), 'created_at' => now(), 'updated_at' => now(), ]); return redirect()->route('calendar.index'); } /** * show calendar details */ public function show(Calendar $calendar) { $this->authorize('view', $calendar); $calendar->load([ 'meta', 'instances' => fn ($q) => $q->where('principaluri', auth()->user()->principal_uri), ]); /* grab the single instance for convenience in the view */ $instance = $calendar->instances->first(); $caldavUrl = $instance?->caldavUrl(); // null-safe /* events + meta, newest first */ $events = $calendar->events() ->with('meta') ->orderByDesc('lastmodified') ->get(); return view( 'calendar.show', compact('calendar', 'instance', 'events', 'caldavUrl') ); } /** * edit calendar page */ public function edit(Calendar $calendar) { $this->authorize('update', $calendar); $calendar->load([ 'meta', 'instances' => fn ($q) => $q->where('principaluri', auth()->user()->principal_uri), ]); $instance = $calendar->instances->first(); // may be null but shouldn’t return view('calendar.edit', compact('calendar', 'instance')); } /** * update sabre + meta records */ public function update(Request $request, Calendar $calendar) { $this->authorize('update', $calendar); $data = $request->validate([ 'name' => 'required|string|max:100', 'description' => 'nullable|string|max:255', 'timezone' => 'required|string', 'color' => 'nullable|regex:/^#[0-9A-Fa-f]{6}$/', ]); // update the instance row $calendar->instances() ->where('principaluri', auth()->user()->principal_uri) ->update([ 'displayname' => $data['name'], 'description' => $data['description'] ?? '', 'calendarcolor' => $data['color'] ?? null, 'timezone' => $data['timezone'], ]); // bump synctoken on master calendar row $calendar->increment('synctoken'); // update calendar meta (our table) $calendar->meta()->updateOrCreate([], [ 'color' => $data['color'] ?? '#1a1a1a', 'color_fg' => contrast_text_color($data['color'] ?? '#1a1a1a') ]); return redirect() ->route('calendar.show', $calendar) ->with('toast', __('Calendar saved successfully!')); } /** * delete calendar @todo */ public function destroy(Calendar $calendar) { $this->authorize('delete', $calendar); $calendar->delete(); // cascades to meta via FK return redirect()->route('calendar.index'); } /** * * Private helpers */ /** * normalise $view and $date into a carbon range * * @return array [$view, ['start' => Carbon, 'end' => Carbon]] */ private function resolveRange(Request $request): array { // get the view $view = in_array($request->query('view'), ['week', '4day']) ? $request->query('view') : 'month'; // anchor date in the user's timezone $anchor = Carbon::parse($request->query('date', now()->toDateString())) ->setTimezone(auth()->user()->timezone ?? config('app.timezone')); // set dates based on view switch ($view) { case 'week': $start = $anchor->copy()->startOfWeek(); $end = $anchor->copy()->endOfWeek(); break; case '4day': // a rolling 4-day "agenda" view starting at anchor $start = $anchor->copy()->startOfDay(); $end = $anchor->copy()->addDays(3)->endOfDay(); break; default: // month $start = $anchor->copy()->startOfMonth(); $end = $anchor->copy()->endOfMonth(); } return [$view, ['start' => $start, 'end' => $end]]; } /** * Assemble an array of day-objects for the requested view. * * Day object shape: * [ * 'date' => '2025-07-14', * 'label' => '14', // two-digit day number * 'in_month' => true|false, // helpful for grey-out styling * 'events' => [ …event payloads… ] * ] * * For the "month" view the return value also contains * 'weeks' => [ [7 day-objs], [7 day-objs], … ] */ private function buildCalendarGrid(string $view, array $range, Collection $events): array { // index events by YYYY-MM-DD for quick lookup */ $events_by_day = []; foreach ($events as $ev) { $start = Carbon::parse($ev['start'])->tz($ev['timezone']); $end = $ev['end'] ? Carbon::parse($ev['end'])->tz($ev['timezone']) : $start; // spread multi-day events for ($d = $start->copy()->startOfDay(); $d->lte($end->copy()->endOfDay()); $d->addDay()) { $key = $d->toDateString(); $events_by_day[$key][] = $ev['id']; } } // determine span of days for the selected view switch ($view) { case 'week': $grid_start = $range['start']->copy(); $grid_end = $range['start']->copy()->addDays(6); break; case '4day': $grid_start = $range['start']->copy(); $grid_end = $range['start']->copy()->addDays(3); break; default: /* month */ $grid_start = $range['start']->copy()->startOfWeek(); // Sunday start $grid_end = $range['end']->copy()->endOfWeek(); } // view span bounds and build day objects $days = []; for ($day = $grid_start->copy(); $day->lte($grid_end); $day->addDay()) { $iso = $day->toDateString(); $days[] = [ 'date' => $iso, 'label' => $day->format('j'), 'in_month' => $day->month === $range['start']->month, 'is_today' => $day->isSameDay(Carbon::today()), 'events' => array_fill_keys($events_by_day[$iso] ?? [], true), ]; } return $view === 'month' ? ['days' => $days, 'weeks' => array_chunk($days, 7)] : ['days' => $days]; } }