copy()->utc(); $spanEndUtc = $span['end']->copy()->utc(); $gridStartMinutes = $daytimeHours ? ((int) $daytimeHours['start'] * 60) : 0; $gridEndMinutes = $daytimeHours ? ((int) $daytimeHours['end'] * 60) : (24 * 60); return $events->flatMap(function ($e) use ( $calendarMap, $uiFormat, $view, $range, $tz, $recurrence, $spanStartUtc, $spanEndUtc, $gridStartMinutes, $gridEndMinutes, $daytimeHours ) { $cal = $calendarMap[$e->calendarid]; $timezone = $cal->timezone ?? config('app.timezone'); $color = $cal['meta_color'] ?? $cal['calendarcolor'] ?? default_calendar_color(); $colorFg = $cal['meta_color_fg'] ?? contrast_text_color($color); $occurrences = []; $isRecurring = $recurrence->isRecurring($e); if ($isRecurring) { $occurrences = $recurrence->expand($e, $spanStartUtc, $spanEndUtc); } if (empty($occurrences) && !$isRecurring) { $startUtc = $e->meta?->start_at ? Carbon::parse($e->meta->start_at)->utc() : Carbon::createFromTimestamp($e->firstoccurence, 'UTC'); $endUtc = $e->meta?->end_at ? Carbon::parse($e->meta->end_at)->utc() : ($e->lastoccurence ? Carbon::createFromTimestamp($e->lastoccurence, 'UTC') : $startUtc->copy()); $occurrences[] = [ 'start' => $startUtc, 'end' => $endUtc, 'recurrence_id' => null, ]; } return collect($occurrences)->map(function ($occ) use ( $e, $cal, $uiFormat, $view, $range, $tz, $timezone, $color, $colorFg, $gridStartMinutes, $gridEndMinutes, $daytimeHours ) { $startUtc = $occ['start']; $endUtc = $occ['end']; $startLocal = $startUtc->copy()->timezone($timezone); $endLocal = $endUtc->copy()->timezone($timezone); $startForGrid = $startUtc->copy()->tz($tz); $endForGrid = $endUtc->copy()->tz($tz); if ($daytimeHours) { $startMinutes = ($startForGrid->hour * 60) + $startForGrid->minute; $endMinutes = ($endForGrid->hour * 60) + $endForGrid->minute; if ($endMinutes <= $gridStartMinutes || $startMinutes >= $gridEndMinutes) { return null; } $displayStartMinutes = max($startMinutes, $gridStartMinutes); $displayEndMinutes = min($endMinutes, $gridEndMinutes); $startForGrid = $startForGrid->copy()->startOfDay()->addMinutes($displayStartMinutes); $endForGrid = $endForGrid->copy()->startOfDay()->addMinutes($displayEndMinutes); } $placement = $this->slotPlacement( $startForGrid, $endForGrid, $range['start']->copy()->tz($tz), $view, 15, $gridStartMinutes, $gridEndMinutes ); $occurrenceId = $occ['recurrence_id'] ? ($e->id . ':' . $occ['recurrence_id']) : (string) $e->id; return [ 'id' => $e->id, 'occurrence_id' => $occurrenceId, 'occurrence' => $occ['recurrence_id'] ? $startUtc->toIso8601String() : null, 'calendar_id' => $e->calendarid, 'calendar_slug' => $cal->slug, 'title' => $e->meta->title ?? 'No title', 'description' => $e->meta->description ?? 'No description.', 'start' => $startUtc->toIso8601String(), 'end' => $endUtc->toIso8601String(), 'start_ui' => $startLocal->format($uiFormat), 'end_ui' => $endLocal->format($uiFormat), 'timezone' => $timezone, 'visible' => $cal->visible, 'color' => $color, 'color_fg' => $colorFg, 'start_row' => $placement['start_row'], 'end_row' => $placement['end_row'], 'row_span' => $placement['row_span'], 'start_col' => $placement['start_col'], 'duration' => $placement['duration'], ]; })->filter()->values(); })->keyBy('occurrence_id'); } /** * Assemble an array of day-objects for the requested view. */ public function buildCalendarGrid( string $view, array $range, Collection $events, string $tz, array $span ): array { ['start' => $grid_start, 'end' => $grid_end] = $span; $today = Carbon::today($tz)->toDateString(); $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['occurrence_id'] ?? $ev['id']; } } $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. */ public 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(); $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['occurrence_id'] ?? $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]; } /** * Create the time gutter for time-based views. */ public function timeSlots( Carbon $dayStart, string $tz, string $timeFormat, ?array $daytimeHours = null ): array { $minutesPerSlot = 15; $startMinutes = 0; $endMinutes = 24 * 60; if (is_array($daytimeHours)) { $startMinutes = (int) $daytimeHours['start'] * 60; $endMinutes = (int) $daytimeHours['end'] * 60; } $slotsPerDay = intdiv(max(0, $endMinutes - $startMinutes), $minutesPerSlot); $format = $timeFormat === '24' ? 'H:i' : 'g:i a'; $slots = []; $t = $dayStart->copy()->tz($tz)->startOfDay()->addMinutes($startMinutes); for ($i = 0; $i < $slotsPerDay; $i++) { $slots[] = [ 'iso' => $t->toIso8601String(), 'label' => $t->format($format), 'key' => $t->format('H:i'), 'index' => $i, 'minutes' => $startMinutes + ($i * $minutesPerSlot), 'duration' => $minutesPerSlot, ]; $t->addMinutes($minutesPerSlot); } return $slots; } /** * Create the specific view's date headers. */ public 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(); if ($view === 'month') { return collect($this->weekdayHeaders($tz, $weekStart)) ->map(fn ($h) => $h + ['is_today' => false]) ->all(); } $headers = []; for ($d = $start->copy()->startOfDay(); $d->lte($end); $d->addDay()) { $date = $d->toDateString(); $headers[] = [ 'date' => $d->toDateString(), 'day' => $d->format('j'), 'dow' => $d->translatedFormat('l'), 'dow_short' => $d->translatedFormat('D'), 'month' => $d->translatedFormat('M'), 'is_today' => $date === $today, ]; } return $headers; } /** * Specific headers for month views (full and mini). */ public function weekdayHeaders(string $tz, int $weekStart): array { $headers = []; $d = Carbon::now($tz)->startOfWeek($weekStart); for ($i = 0; $i < 7; $i++) { $headers[] = [ 'key' => $i, 'label' => $d->translatedFormat('D'), ]; $d->addDay(); } return $headers; } /** * Place the "now" indicator on the grid for time-based views. */ public function nowIndicator( string $view, array $range, string $tz, int $minutesPerSlot = 15, int $gutterCols = 1, ?array $daytimeHours = null ): array { 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(); if (!$now->betweenIncluded($start, $end)) { return ['show' => false, 'row' => 1, 'day_col' => 1, 'col_start' => 1, 'col_end' => 2]; } $minutes = ($now->hour * 60) + $now->minute; $gridStartMinutes = $daytimeHours ? ((int) $daytimeHours['start'] * 60) : 0; $gridEndMinutes = $daytimeHours ? ((int) $daytimeHours['end'] * 60) : (24 * 60); if ($daytimeHours && ($minutes < $gridStartMinutes || $minutes >= $gridEndMinutes)) { return ['show' => false, 'row' => 1, 'day_col' => 1, 'col_start' => 1, 'col_end' => 2]; } $relativeMinutes = $minutes - $gridStartMinutes; $relativeMinutes = max(0, min($relativeMinutes, $gridEndMinutes - $gridStartMinutes)); $row = intdiv($relativeMinutes, $minutesPerSlot) + 1; $offset = ($relativeMinutes % $minutesPerSlot) / $minutesPerSlot; if ($view === 'day') { $dayCol = 1; } else { $todayStart = $now->copy()->startOfDay(); $dayCol = $start->diffInDays($todayStart) + 1; } $dayCol = (int) $dayCol; return [ 'show' => true, 'row' => (int) $row, 'offset' => round($offset, 4), 'day_col' => $dayCol, 'col_start' => $dayCol, 'col_end' => $dayCol + 1, ]; } /** * Time-based layout slot placement. */ private function slotPlacement( Carbon $startLocal, ?Carbon $endLocal, Carbon $rangeStart, string $view, int $minutesPerSlot = 15, int $gridStartMinutes = 0, int $gridEndMinutes = 1440 ): array { $start = $startLocal->copy(); $end = ($endLocal ?? $startLocal)->copy(); $startMinutes = (($start->hour * 60) + $start->minute) - $gridStartMinutes; $endMinutes = (($end->hour * 60) + $end->minute) - $gridStartMinutes; $maxStart = max(0, ($gridEndMinutes - $gridStartMinutes) - $minutesPerSlot); $startMinutes = $this->snapToSlot($startMinutes, $minutesPerSlot, 0, $maxStart); $endMinutes = $this->snapToSlot( $endMinutes, $minutesPerSlot, $minutesPerSlot, max($minutesPerSlot, $gridEndMinutes - $gridStartMinutes) ); if ($endMinutes <= $startMinutes) { $endMinutes = min($startMinutes + $minutesPerSlot, $gridEndMinutes - $gridStartMinutes); } $startRow = intdiv($startMinutes, $minutesPerSlot) + 1; $rowSpan = max(1, intdiv($endMinutes - $startMinutes, $minutesPerSlot)); $endRow = $startRow + $rowSpan; $maxCols = match ($view) { 'day' => 1, 'four' => 4, 'week' => 7, default => 1, }; $startCol = $rangeStart->copy()->startOfDay()->diffInDays($start->copy()->startOfDay()) + 1; $startCol = max(1, min($maxCols, $startCol)); return [ 'start_row' => $startRow, 'end_row' => $endRow, 'row_span' => $rowSpan, 'duration' => $endMinutes - $startMinutes, 'start_col' => $startCol, ]; } private function snapToSlot(int $minutes, int $slot, int $min, int $max): int { $rounded = (int) round($minutes / $slot) * $slot; return max($min, min($rounded, $max)); } }