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 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', [])); // 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('false 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) ->orderBy('ci.displayname') ->get(); // load the users remote/subscription calendars $remotes = Subscription::query() ->join('calendar_meta as m', 'm.subscription_id', '=', 'calendarsubscriptions.id') ->where('principaluri', $principal) ->orderBy('displayname') ->select( 'calendarsubscriptions.id', 'calendarsubscriptions.displayname', 'calendarsubscriptions.calendarcolor', 'calendarsubscriptions.uri as slug', DB::raw('NULL as timezone'), 'm.color as meta_color', 'm.color_fg as meta_color_fg', DB::raw('true as is_remote') ) ->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 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, 'color' => $cal->meta_color ?? $cal->calendarcolor ?? '#1a1a1a', 'color_fg' => $cal->meta_color_fg ?? '#ffffff', ]; })->keyBy('id'); // 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'), ]; $mini = $this->buildMiniGrid($mini_start, $events); // 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 */ /** * 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]; } /** * 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 = []; foreach ($events as $ev) { $s = Carbon::parse($ev['start']); $e = $ev['end'] ? Carbon::parse($ev['end']) : $s; for ($d = $s->copy()->startOfDay(); $d->lte($e); $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]; } }