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); // load the user's calendars $calendars = Calendar::query() ->select( 'calendars.id', 'ci.displayname', 'ci.calendarcolor', 'meta.color as meta_color' ) ->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(); // get all the events in one query $events = Event::forCalendarsInRange( $calendars->pluck('id'), $range['start'], $range['end'] ); // create the calendar grid of days $grid = $this->buildCalendarGrid($view, $range, $events); // format the data for the frontend $payload = [ 'view' => $view, 'range' => $range, 'calendars' => $calendars->keyBy('id')->map(function ($cal) { return [ 'id' => $cal->id, 'name' => $cal->displayname, 'color' => $cal->meta_color ?? $cal->calendarcolor ?? '#999', 'on' => true, // default to visible; the UI can toggle this ]; }), 'events' => $events->map(function ($e) { // just the events map // fall back to Sabre timestamps if meta is missing $start = $e->meta->start_at ?? Carbon::createFromTimestamp($e->firstoccurence); $end = $e->meta->end_at ?? ($e->lastoccurence ? Carbon::createFromTimestamp($e->lastoccurence) : null); return [ 'id' => $e->id, 'calendar_id' => $e->calendarid, 'title' => $e->meta->title ?? '(no title)', 'start' => $start->format('c'), 'end' => optional($end)->format('c'), ]; }), 'grid' => $grid, ]; 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'] ?? null, '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'] ?? null] ); 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 */ $eventsByDay = []; foreach ($events as $ev) { $start = $ev->meta->start_at ?? Carbon::createFromTimestamp($ev->firstoccurence); $end = $ev->meta->end_at ?? ($ev->lastoccurence ? Carbon::createFromTimestamp($ev->lastoccurence) : $start); // spread multi-day events across each day they touch for ($d = $start->copy()->startOfDay(); $d->lte($end->copy()->endOfDay()); $d->addDay()) { $key = $d->toDateString(); // e.g. '2025-07-14' $eventsByDay[$key] ??= []; $eventsByDay[$key][] = [ 'id' => $ev->id, 'calendar_id' => $ev->calendarid, 'title' => $ev->meta->title ?? '(no title)', 'start' => $start->format('c'), 'end' => $end->format('c'), ]; } } // determine which individual days belong to this view */ switch ($view) { case 'week': $gridStart = $range['start']->copy(); $gridEnd = $range['start']->copy()->addDays(6); break; case '4day': $gridStart = $range['start']->copy(); $gridEnd = $range['start']->copy()->addDays(3); break; default: // month $gridStart = $range['start']->copy()->startOfWeek(); // Sunday-start; tweak if needed $gridEnd = $range['end']->copy()->endOfWeek(); } // walk the span, build the day objects */ $days = []; for ($day = $gridStart->copy(); $day->lte($gridEnd); $day->addDay()) { $iso = $day->toDateString(); $isToday = $day->isSameDay(Carbon::today()); $days[] = [ 'date' => $iso, 'label' => $day->format('j'), 'in_month' => $day->month === $range['start']->month, 'is_today' => $isToday, 'events' => $eventsByDay[$iso] ?? [], ]; } // for a month view, also group into weeks if ($view === 'month') { $weeks = array_chunk($days, 7); // 7 days per week row return ['days' => $days, 'weeks' => $weeks]; } return ['days' => $days]; } }