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); // date range span $span = $this->gridSpan($view, $range); // date range controls $prev = $range['start']->copy()->subMonth()->startOfMonth()->toDateString(); $next = $range['start']->copy()->addMonth()->startOfMonth()->toDateString(); $today = Carbon::today()->toDateString(); // get the user's visible calendars from the left bar $visible = collect($request->query('c', [])); /** * * calendars */ // load the user's local calendars $locals = 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', DB::raw('0 as is_remote') ) ->join('calendarinstances as ci', 'ci.calendarid', '=', 'calendars.id') ->leftJoin('calendar_meta as meta', 'meta.calendar_id', '=', 'calendars.id') ->where('ci.principaluri', $principal) ->where(function ($q) { $q->whereNull('meta.is_remote') ->orWhere('meta.is_remote', false); }) ->orderBy('ci.displayname') ->get(); // load the users remote/subscription calendars $remotes = 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', DB::raw('1 as is_remote') ) ->join('calendarinstances as ci', 'ci.calendarid', '=', 'calendars.id') ->join('calendar_meta as meta', 'meta.calendar_id', '=', 'calendars.id') ->where('ci.principaluri', $principal) ->where('meta.is_remote', true) ->orderBy('ci.displayname') ->get(); // merge local and remote, and add the visibility flag $visible = collect($request->query('c', [])); $calendars = $locals->merge($remotes)->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 events for calendars in range */ // get all the events in one query $events = Event::forCalendarsInRange( $calendars->pluck('id'), $span['start'], $span['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', 'description' => $e->meta->description ?? 'No description.', '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, 'color' => $cal->meta_color ?? $cal->calendarcolor ?? '#1a1a1a', 'color_fg' => $cal->meta_color_fg ?? '#ffffff', ]; })->keyBy('id'); /** * * mini calendar */ // create the mini calendar grid based on the mini cal controls $mini_anchor = $request->query('mini', $range['start']->toDateString()); $mini_start = Carbon::parse($mini_anchor)->startOfMonth(); $mini_nav = [ 'prev' => $mini_start->copy()->subMonth()->toDateString(), 'next' => $mini_start->copy()->addMonth()->toDateString(), 'today' => Carbon::today()->startOfMonth()->toDateString(), 'label' => $mini_start->format('F Y'), ]; // compute the mini's 42-day span (Mon..Sun, 6 rows) $mini_grid_start = $mini_start->copy()->startOfWeek(Carbon::MONDAY); $mini_grid_end = $mini_start->copy()->endOfMonth()->endOfWeek(Carbon::SUNDAY); if ($mini_grid_start->diffInDays($mini_grid_end) + 1 < 42) { $mini_grid_end->addWeek(); } // fetch events specifically for the mini-span $mini_events = Event::forCalendarsInRange( $calendars->pluck('id'), $mini_grid_start, $mini_grid_end )->map(function ($e) use ($calendar_map) { $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); $tz = $cal->timezone ?? config('app.timezone'); 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' => $cal->meta_color ?? $cal->calendarcolor ?? '#1a1a1a', 'color_fg' => $cal->meta_color_fg ?? '#ffffff', ]; })->keyBy('id'); // now build the mini from mini_events (not from $events) $mini = $this->buildMiniGrid($mini_start, $mini_events); /** * * main calendar grid */ // 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, 'nav' => [ 'prev' => $prev, 'next' => $next, 'today' => $today, ], 'active' => [ 'year' => $range['start']->format('Y'), 'month' => $range['start']->format("F"), 'day' => $range['start']->format("d"), ], 'calendars' => $calendars->mapWithKeys(function ($cal) { return [ $cal->id => [ 'id' => $cal->id, 'slug' => $cal->slug, 'name' => $cal->displayname, 'color' => $cal->meta_color ?? $cal->calendarcolor ?? '#1a1a1a', 'color_fg' => $cal->meta_color_fg ?? '#ffffff', 'visible' => $cal->visible, 'is_remote' => $cal->is_remote, ], ]; }), 'events' => $events, // keyed, one copy each '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 ]; 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 */ /** * Span actually rendered by the grid. * Month → startOfMonth->startOfWeek .. endOfMonth->endOfWeek */ private function gridSpan(string $view, array $range): array { switch ($view) { case 'week': $start = $range['start']->copy(); // resolveRange already did startOfWeek $end = $range['start']->copy()->addDays(6); break; case '4day': $start = $range['start']->copy(); // resolveRange already did startOfDay $end = $range['start']->copy()->addDays(3); break; default: // month $start = $range['start']->copy()->startOfMonth()->startOfWeek(); $end = $range['end']->copy()->endOfMonth()->endOfWeek(); } return ['start' => $start, 'end' => $end]; } /** * 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 { // use the same span the events were fetched for (month padded to full weeks, etc.) ['start' => $grid_start, 'end' => $grid_end] = $this->gridSpan($view, $range); // today checks $tz = auth()->user()->timezone ?? config('app.timezone', 'UTC'); $today = \Carbon\Carbon::today($tz); // index events by YYYY-MM-DD for quick lookup $events_by_day = []; foreach ($events as $ev) { $evTz = $ev['timezone'] ?? $tz; $start = Carbon::parse($ev['start'])->tz($evTz); $end = $ev['end'] ? Carbon::parse($ev['end'])->tz($evTz) : $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']; } } // 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($today), 'events' => array_fill_keys($events_by_day[$iso] ?? [], true), ]; } return $view === 'month' ? ['days' => $days, 'weeks' => array_chunk($days, 7)] : ['days' => $days]; } /** * Build the mini-month grid for day buttons * * Returns ['days' => [ * [ * 'date' => '2025-06-30', * 'label' => '30', * 'in_month' => false, * 'events' => [id, id …] * ], … * ]] */ private function buildMiniGrid(Carbon $monthStart, Collection $events): array { // get bounds $monthEnd = $monthStart->copy()->endOfMonth(); $gridStart = $monthStart->copy()->startOfWeek(Carbon::MONDAY); $gridEnd = $monthEnd->copy()->endOfWeek(Carbon::SUNDAY); // ensure we have 42 days (6 rows); 35 = add one extra week if ($gridStart->diffInDays($gridEnd) + 1 < 42) { $gridEnd->addWeek(); } /* map event-ids by yyyy-mm-dd */ $byDay = []; $tzFallback = auth()->user()->timezone ?? config('app.timezone', 'UTC'); foreach ($events as $ev) { $evTz = $ev['timezone'] ?? $tzFallback; $s = Carbon::parse($ev['start'])->tz($evTz); $e = $ev['end'] ? Carbon::parse($ev['end'])->tz($evTz) : $s; for ($d = $s->copy()->startOfDay(); $d->lte($e->copy()->endOfDay()); $d->addDay()) { $byDay[$d->toDateString()][] = $ev['id']; } } /* Walk the 42-day span */ $days = []; for ($d = $gridStart->copy(); $d->lte($gridEnd); $d->addDay()) { $iso = $d->toDateString(); $days[] = [ 'date' => $iso, 'label' => $d->format('j'), 'in_month' => $d->between($monthStart, $monthEnd), 'events' => $byDay[$iso] ?? [], ]; } // will always be 42 to ensure 6 rows return ['days' => $days]; } }