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) { /** * * manage parameters and core variables */ // set the calendar key $principal = auth()->user()->principal_uri; // user settings $user = $request->user(); $tz = $user->timezone ?? config('app.timezone'); $timeFormat = $user->getSetting('app.time_format', '12'); // settings $defaultView = $user->getSetting('calendar.last_view', 'month'); $defaultDate = $user->getSetting('calendar.last_date', Carbon::today($tz)->toDateString()); $defaultDensity = (int) $user->getSetting('calendar.last_density', 30); // week start preference $weekStartPref = $user->getSetting('calendar.week_start', 'sunday'); // 'sunday'|'monday' $weekStartPref = in_array($weekStartPref, ['sunday', 'monday'], true) ? $weekStartPref : 'sunday'; $weekStart = $weekStartPref === 'monday' ? Carbon::MONDAY : Carbon::SUNDAY; $weekEnd = (int) (($weekStart + 6) % 7); // get the view and time range [$view, $range] = $this->resolveRange($request, $tz, $weekStart, $weekEnd, $defaultView, $defaultDate); $today = Carbon::today($tz)->toDateString(); // get the display density, if present (in minutes for each step) $stepMinutes = (int) $request->query('density', $defaultDensity); if (! in_array($stepMinutes, [15, 30, 60], true)) { // lock it down $stepMinutes = 30; } $labelEvery = match ($stepMinutes) { // how many 15-min slots per label/row 15 => 1, 30 => 2, 60 => 4, }; // date range span and controls $span = $this->gridSpan($view, $range, $weekStart, $weekEnd); $nav = $this->navDates($view, $range['start'], $tz); // get the user's visible calendars from the left bar $visible = collect($request->query('c', [])); // keep a stable anchor date for forms that aren't the nav buttons $anchorDate = $request->query('date', now($tz)->toDateString()); // persist settings if ($request->hasAny(['view', 'date', 'density'])) { $user->setSetting('calendar.last_view', $view); $user->setSetting('calendar.last_date', $range['start']->toDateString()); $user->setSetting('calendar.last_density', (string) $stepMinutes); } /** * * calendars */ $calendars = Calendar::query() ->dashboardForPrincipal($principal) ->get(); $calendars = $calendars->map(function ($cal) use ($visible) { $cal->visible = $visible->isEmpty() || $visible->contains($cal->slug); return $cal; }); $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, $timeFormat, $view, $range, $tz, $weekStart, $weekEnd) { // 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); // time format handling $uiFormat = $timeFormat === '24' ? 'H:i' : 'g:ia'; // 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); // convert utc to user tz for grid placement (columns/rows must match view headers) $start_for_grid = $start_utc->copy()->tz($tz); $end_for_grid = optional($end_utc)->copy()->tz($tz); // placement for time-based layouts $placement = $this->slotPlacement( $start_for_grid, $end_for_grid, $range['start']->copy()->tz($tz), $view, 15 ); // color handling $color = $cal['meta_color'] ?? $cal['calendarcolor'] ?? default_calendar_color(); $colorFg = $cal['meta_color_fg'] ?? contrast_text_color($color); logger()->info('event times', [ 'id' => $e->id, 'start_at' => optional($e->meta)->start_at, 'end_at' => optional($e->meta)->end_at, 'firstoccurence' => $e->firstoccurence, 'lastoccurence' => $e->lastoccurence, ]); // return events array return [ // core data '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($uiFormat), 'end_ui' => optional($end_local)->format($uiFormat), 'timezone' => $timezone, 'visible' => $cal->visible, 'color' => $color, 'color_fg' => $colorFg, // slot placement for time-based grid 'start_row' => $placement['start_row'], 'end_row' => $placement['end_row'], 'row_span' => $placement['row_span'], 'start_col' => $placement['start_col'], 'duration' => $placement['duration'], ]; })->keyBy('id'); /** * * mini calendar */ // create the mini calendar grid based on the mini cal controls $mini_anchor = $request->query('mini', $range['start']->toDateString()); // anchor is a DATE string, so create it explicitly in the user tz $mini_start = Carbon::createFromFormat('Y-m-d', $mini_anchor, $tz) ->startOfMonth(); $mini_nav = [ 'prev' => $mini_start->copy()->subMonth()->toDateString(), 'next' => $mini_start->copy()->addMonth()->toDateString(), 'today' => Carbon::today($tz)->startOfMonth()->toDateString(), 'label' => $mini_start->format('F Y'), ]; $mini_headers = $this->weekdayHeaders($tz, $weekStart); // compute the mini's 42-day span (Mon..Sun, 6 rows) $mini_grid_start = $mini_start->copy()->startOfWeek($weekStart); $mini_grid_end = $mini_start->copy()->endOfMonth()->endOfWeek($weekEnd); 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, $tz) { $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); $color = $cal->meta_color ?? $cal->calendarcolor ?? default_calendar_color(); $colorFg = $cal->meta_color_fg ?? contrast_text_color($color); 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' => $color, 'color_fg' => $colorFg, ]; })->keyBy('id'); // now build the mini from mini_events (not from $events) $mini = $this->buildMiniGrid($mini_start, $mini_events, $tz, $weekStart, $weekEnd); /** * * main calendar grid */ // create the calendar grid of days $grid = $this->buildCalendarGrid($view, $range, $events, $tz, $weekStart, $weekEnd); // get the title $header = $this->headerTitle($view, $range['start'], $range['end']); // format the data for the frontend, including separate arrays for events specifically and the big grid $payload = [ 'view' => $view, 'range' => $range, 'nav' => $nav, 'header' => $header, 'active' => [ 'date' => $range['start']->toDateString(), 'year' => $range['start']->format('Y'), 'month' => $range['start']->format("F"), 'day' => $range['start']->format("d"), ], 'week_start' => $weekStart, 'calendars' => $calendars->mapWithKeys(function ($cal) { // compute colors $color = $cal->meta_color ?? $cal->calendarcolor ?? default_calendar_color(); $colorFg = $cal->meta_color_fg ?? contrast_text_color($color); return [ $cal->id => [ 'id' => $cal->id, 'slug' => $cal->slug, 'name' => $cal->displayname, 'color' => $color, 'color_fg' => $colorFg, 'visible' => $cal->visible, 'is_remote' => $cal->is_remote, ], ]; }), 'hgroup' => $this->viewHeaders($view, $range, $tz, $weekStart), '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 'mini_headers' => $mini_headers, ]; // time-based payload values $timeBased = in_array($view, ['day', 'week', 'four'], true); if ($timeBased) { // create the time gutter if we're in a time-based view $payload['slots'] = $this->timeSlots($range['start'], $tz, $timeFormat); $payload['time_format'] = $timeFormat; // optional, if the blade cares // add the now indicator $payload['now'] = $this->nowIndicator($view, $range, $tz, 15); } // send the density array always, even though it doesn't matter for month $payload['density'] = [ 'step' => $stepMinutes, // 15|30|60 'label_every' => $labelEvery, // 1|2|4 'anchor' => $anchorDate, ]; return view('calendar.index', $payload); } /** * create sabre calendar + meta */ public function store(Request $request, CreateCalendar $creator) { $data = $request->validate([ 'name' => 'required|string|max:100', 'description' => 'nullable|string|max:255', 'timezone' => 'required|string|max:64', 'color' => 'nullable|regex:/^#[0-9A-Fa-f]{6}$/', 'redirect' => 'nullable|string', // where to go after creating ]); $creator->create($request->user(), $data); $redirect = $data['redirect'] ?? route('calendar.index'); return redirect($redirect)->with('toast', [ 'message' => __('Calendar created!'), 'type' => 'success', ]); } /** * show calendar details */ public function show(Calendar $calendar) { $this->authorize('view', $calendar); $calendar->load([ 'meta', 'instances' => fn ($q) => $q->where('principaluri', auth()->user()->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()->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()->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) $color = calendar_color($data); $calendar->meta()->updateOrCreate([], [ 'color' => $color, 'color_fg' => contrast_text_color($color), ]); 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 */ /** * * Prepare nav dates for the movement buttons in the calendar header * * @return array * [ * 'prev' => 2026-08-19 * 'next' => 2026-08-21 * 'today' => 2026-08-20 * ] */ private function navDates(string $view, Carbon $start, string $tz): array { // always compute in the user tz so the UX is consistent $start = $start->copy()->tz($tz); return match ($view) { 'day' => [ 'prev' => $start->copy()->subDay()->toDateString(), 'next' => $start->copy()->addDay()->toDateString(), 'today' => Carbon::today($tz)->toDateString(), ], 'week' => [ 'prev' => $start->copy()->subWeek()->toDateString(), 'next' => $start->copy()->addWeek()->toDateString(), 'today' => Carbon::today($tz)->toDateString(), ], 'four' => [ 'prev' => $start->copy()->subDays(4)->toDateString(), 'next' => $start->copy()->addDays(4)->toDateString(), 'today' => Carbon::today($tz)->toDateString(), ], default => [ // month 'prev' => $start->copy()->subMonth()->startOfMonth()->toDateString(), 'next' => $start->copy()->addMonth()->startOfMonth()->toDateString(), 'today' => Carbon::today($tz)->toDateString(), ], }; } /** * * Interpret $view and $date filters and normalize into a Carbon range * * @return array * [ * $view, * [ * 'start' => Carbon, * 'end' => Carbon * ] * ] */ private function resolveRange( Request $request, string $tz, int $weekStart, int $weekEnd, string $defaultView, string $defaultDate ): array { // get the view $requestView = $request->query('view', $defaultView); $view = in_array($requestView, self::VIEWS, true) ? $requestView : 'month'; $date = $request->query('date', $defaultDate); // anchor date in the user's timezone $anchor = Carbon::createFromFormat('Y-m-d', $date, $tz)->startOfDay(); // set dates based on view switch ($view) { case 'day': $start = $anchor->copy()->startOfDay(); $end = $anchor->copy()->endOfDay(); break; case 'week': $start = $anchor->copy()->startOfWeek($weekStart); $end = $anchor->copy()->endOfWeek($weekEnd); break; case 'four': // 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]]; } /** * Create the grid for each view with pre- and post-padding if needed * * This is different from resolveRange() as the rendered span != logical range * * Month: start of the month's first week to end of the last week * Week: Sunday to Saturday * Four: selected day to 3 days later * Day: start of the day to the end of the day */ private function gridSpan(string $view, array $range, int $weekStart, int $weekEnd): array { switch ($view) { case 'day': $start = $range['start']->copy()->startOfDay(); $end = $range['start']->copy()->endOfDay(); break; case 'week': $start = $range['start']->copy()->startOfWeek($weekStart); $end = $range['start']->copy()->endOfWeek($weekEnd); break; case 'four': $start = $range['start']->copy()->startOfDay(); $end = $range['start']->copy()->addDays(3); break; default: // month $start = $range['start']->copy()->startOfMonth()->startOfWeek($weekStart); $end = $range['end']->copy()->endOfMonth()->endOfWeek($weekEnd); } return ['start' => $start, 'end' => $end]; } /** * * Prepare the calendar title based on the view * * @return array * [ * 'strong' => 'August 20', * 'span' => '2026', * ] */ private function headerTitle(string $view, Carbon $start, Carbon $end): array { $sameDay = $start->isSameDay($end); $sameMonth = $start->isSameMonth($end); $sameYear = $start->isSameYear($end); // month default $strong = $start->format('F'); $span = $start->format('Y'); if ($view === 'day' || $sameDay) { return [ 'strong' => $start->format('F j'), 'span' => $start->format('Y'), ]; } if (in_array($view, ['week', 'four'], true)) { if ($sameMonth && $sameYear) { return [ 'strong' => $start->format('F j') . ' to ' . $end->format('j'), 'span' => $start->format('Y'), ]; } if ($sameYear) { return [ 'strong' => $start->format('F') . ' to ' . $end->format('F'), 'span' => $start->format('Y'), ]; } return [ 'strong' => $start->format('F Y') . ' to ' . $end->format('F Y'), 'span' => null, ]; } return ['strong' => $strong, 'span' => $span]; } /** * * Create the time gutter for time-based views */ private function timeSlots(Carbon $dayStart, string $tz, string $timeFormat): array { $minutesPerSlot = 15; $slotsPerDay = intdiv(24 * 60, $minutesPerSlot); // 96 $format = $timeFormat === '24' ? 'H:i' : 'g:i a'; $slots = []; $t = $dayStart->copy()->tz($tz)->startOfDay(); for ($i = 0; $i < $slotsPerDay; $i++) { $slots[] = [ 'iso' => $t->toIso8601String(), 'label' => $t->format($format), 'key' => $t->format('H:i'), // stable "machine" value 'index' => $i, // 0..95 'minutes' => $i * $minutesPerSlot, 'duration' => $minutesPerSlot, // handy for styling math ]; $t->addMinutes($minutesPerSlot); } return $slots; } /** * * Time-based layout slot placement * * Placements object: * [ * 'start_line' => '24', * 'end_line' => '32', * 'span' => '4', * 'duration' => '60', * ] **/ private function slotPlacement( Carbon $startLocal, ?Carbon $endLocal, Carbon $rangeStart, string $view, int $minutesPerSlot = 15 ): array { $start = $startLocal->copy(); $end = ($endLocal ?? $startLocal)->copy(); // get the real duration in minutes $durationMinutes = max(0, $start->diffInMinutes($end, false)); // duration for display purposes $displayMinutes = $durationMinutes > 0 ? $durationMinutes : $minutesPerSlot; // row placement (96 rows when minutesPerSlot=15) $startMinutesFromMidnight = ($start->hour * 60) + $start->minute; $startRow = intdiv($startMinutesFromMidnight, $minutesPerSlot) + 1; $rowSpan = max(1, (int) ceil($displayMinutes / $minutesPerSlot)); $endRow = $startRow + $rowSpan; // column placement $maxCols = match ($view) { 'day' => 1, 'four' => 4, 'week' => 7, default => 1, // month won’t use this }; // rangeStart is already the "first day column" for week/four/day $startCol = $rangeStart->copy()->startOfDay()->diffInDays($start->copy()->startOfDay()) + 1; // clamp to view columns $startCol = max(1, min($maxCols, $startCol)); return [ 'start_row' => $startRow, 'end_row' => $endRow, 'row_span' => $rowSpan, 'duration' => $durationMinutes, 'start_col' => $startCol, ]; } /** * * 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, string $tz, int $weekStart, int $weekEnd): 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, $weekStart, $weekEnd); // today checks $today = Carbon::today($tz)->toDateString(); // 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; 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]; } /** * * Create the specific view's date headers * * For day/week/four, this is an array of number dates and their day name, * for month it's the user's preferred weekdays */ private function viewHeaders(string $view, array $range, string $tz, int $weekStart): array { $start = $range['start']->copy()->tz($tz); $end = $range['end']->copy()->tz($tz); $today = Carbon::today($tz)->toDateString(); // month view: weekday headers (Sunday..Saturday for now) if ($view === 'month') { return collect($this->weekdayHeaders($tz, $weekStart)) ->map(fn ($h) => $h + ['is_today' => false]) ->all(); } // day/week/four: column headers for each day in range $headers = []; for ($d = $start->copy()->startOfDay(); $d->lte($end); $d->addDay()) { $date = $d->toDateString(); $headers[] = [ 'date' => $d->toDateString(), // 2026-01-31 'day' => $d->format('j'), // 31 'dow' => $d->translatedFormat('l'), // Saturday (localized) 'dow_short' => $d->translatedFormat('D'), // Sat (localized) 'month' => $d->translatedFormat('M'), // Jan (localized) 'is_today' => $date === $today, // flag for viewing real today ]; } return $headers; } /** * * Specific headers for month views (full and mini) */ private function weekdayHeaders(string $tz, int $weekStart): array { $headers = []; $d = Carbon::now($tz)->startOfWeek($weekStart); for ($i = 0; $i < 7; $i++) { $headers[] = [ // stable key (0..6 from the start day) 'key' => $i, // Sun, Mon... 'label' => $d->translatedFormat('D'), ]; $d->addDay(); } return $headers; } /** * * Build the mini-month grid for day buttons in the bottom left of the UI * * Returns ['days' => [ * [ * 'date' => '2025-06-30', * 'label' => '30', * 'in_month' => false, * 'events' => [id, id …] * ], … * ]] */ private function buildMiniGrid( Carbon $monthStart, Collection $events, string $tz, int $weekStart, int $weekEnd): array { $monthStart = $monthStart->copy()->tz($tz); $monthEnd = $monthStart->copy()->endOfMonth(); $gridStart = $monthStart->copy()->startOfWeek($weekStart); $gridEnd = $monthEnd->copy()->endOfWeek($weekEnd); if ($gridStart->diffInDays($gridEnd) + 1 < 42) { $gridEnd->addWeek(); } $today = Carbon::today($tz)->toDateString(); // map event ids by yyyy-mm-dd in USER tz (so indicators match what user sees) $byDay = []; foreach ($events as $ev) { $s = Carbon::parse($ev['start'])->tz($tz); $e = $ev['end'] ? Carbon::parse($ev['end'])->tz($tz) : $s; for ($d = $s->copy()->startOfDay(); $d->lte($e->copy()->endOfDay()); $d->addDay()) { $byDay[$d->toDateString()][] = $ev['id']; } } $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), 'is_today' => $iso === $today, 'events' => $byDay[$iso] ?? [], ]; } return ['days' => $days]; } /** * Place the "now" indicator on the grid if we're in a time-based view and on the current day * * Returns: * [ * 'show' => bool, * 'row' => int, // 1..96 (15-min grid line) * 'col_start' => int, // grid column start * 'col_end' => int, // grid column end * ] */ private function nowIndicator(string $view, array $range, string $tz, int $minutesPerSlot = 15, int $gutterCols = 1): array { // only meaningful for time-based views if (!in_array($view, ['day', 'week', 'four'], true)) { return ['show' => false, 'row' => 1, 'day_col' => 1, 'col_start' => 1, 'col_end' => 2]; } $now = Carbon::now($tz); $start = $range['start']->copy()->tz($tz)->startOfDay(); $end = $range['end']->copy()->tz($tz)->endOfDay(); // show only if "now" is inside the visible range if (! $now->betweenIncluded($start, $end)) { return ['show' => false, 'row' => 1, 'day_col' => 1, 'col_start' => 1, 'col_end' => 2]; } // row: minutes since midnight, snapped down to slot size $minutes = ($now->hour * 60) + $now->minute; $snapped = intdiv($minutes, $minutesPerSlot) * $minutesPerSlot; $row = intdiv($snapped, $minutesPerSlot) + 1; // 1-based // column: 1..N where 1 is the first day column in the events grid if ($view === 'day') { $dayCol = 1; } else { // IMPORTANT: compare dates at midnight to avoid fractional diffs $todayStart = $now->copy()->startOfDay(); $dayCol = $start->diffInDays($todayStart) + 1; // 1..7 or 1..4 } $dayCol = (int) $dayCol; return [ 'show' => true, 'row' => (int) $row, 'day_col' => $dayCol, 'col_start' => $dayCol, 'col_end' => $dayCol + 1, ]; } }