copy()->utc(); $spanEndUtc = $span['end']->copy()->utc(); $gridStartMinutes = $daytimeHours ? ((int) $daytimeHours['start'] * 60) : 0; $gridEndMinutes = $daytimeHours ? ((int) $daytimeHours['end'] * 60) : (24 * 60); $payloads = $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) { $meta = $e->meta; $isAllDay = (bool) ($meta?->all_day ?? false); $startUtc = null; $endUtc = null; if ($isAllDay && $meta?->start_on && $meta?->end_on) { $allDayTz = $meta?->tzid ?? ($meta?->extra['tzid'] ?? null) ?? $timezone; $startUtc = Carbon::parse($meta->start_on->toDateString(), $allDayTz) ->startOfDay() ->utc(); $endUtc = Carbon::parse($meta->end_on->toDateString(), $allDayTz) ->startOfDay() ->utc(); } else { if ($meta?->start_at instanceof Carbon) { $startUtc = $meta->start_at->copy()->utc(); } elseif ($meta?->start_at) { $startUtc = Carbon::parse($meta->start_at, 'UTC'); } else { $startUtc = Carbon::createFromTimestamp($e->firstoccurence, 'UTC'); } if ($meta?->end_at instanceof Carbon) { $endUtc = $meta->end_at->copy()->utc(); } elseif ($meta?->end_at) { $endUtc = Carbon::parse($meta->end_at, 'UTC'); } else { $endUtc = $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, $spanStartUtc, $spanEndUtc ) { $startUtc = $occ['start']; $endUtc = $occ['end']; $isAllDay = (bool) ($e->meta?->all_day ?? false); if ($endUtc->lte($spanStartUtc) || $startUtc->gte($spanEndUtc)) { return null; } $startLocal = $startUtc->copy()->timezone($timezone); $endLocal = $endUtc->copy()->timezone($timezone); $startForGrid = $startUtc->copy()->tz($tz); $endForGrid = $endUtc->copy()->tz($tz); if ($daytimeHours && !$isAllDay) { $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.', 'all_day' => $isAllDay, '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(); })->filter(); // ensure chronological ordering across calendars for all views $payloads = $payloads ->sortBy('start') ->keyBy('occurrence_id'); return $this->applyOverlapLayout($payloads, $view); } /** * 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; if (!empty($ev['all_day']) && $end->gt($start)) { $end = $end->copy()->subSecond(); } 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)); } /** * Apply overlap metadata for time-based views (day/four/week). */ private function applyOverlapLayout(Collection $events, string $view): Collection { if (!in_array($view, ['day', 'four', 'week'], true) || $events->isEmpty()) { return $events; } $items = $events->all(); // keyed by occurrence_id $eventsByCol = []; foreach ($items as $id => $event) { $col = (int) ($event['start_col'] ?? 1); $eventsByCol[$col][$id] = $event; } foreach ($eventsByCol as $group) { uasort($group, function (array $a, array $b) { $cmp = ($a['start_row'] ?? 0) <=> ($b['start_row'] ?? 0); if ($cmp !== 0) { return $cmp; } return ($a['end_row'] ?? 0) <=> ($b['end_row'] ?? 0); }); $cluster = []; $clusterEnd = null; foreach ($group as $id => $event) { $startRow = (int) ($event['start_row'] ?? 0); $endRow = (int) ($event['end_row'] ?? 0); if ($clusterEnd !== null && $startRow >= $clusterEnd) { $this->assignOverlapCluster($cluster, $items); $cluster = []; $clusterEnd = null; } $cluster[$id] = $event; $clusterEnd = $clusterEnd === null ? $endRow : max($clusterEnd, $endRow); } if ($cluster) { $this->assignOverlapCluster($cluster, $items); } } return collect($items); } private function assignOverlapCluster(array $cluster, array &$items): void { if (empty($cluster)) { return; } $active = []; $availableCols = []; $assigned = []; $maxCol = 0; foreach ($cluster as $id => $event) { $startRow = (int) ($event['start_row'] ?? 0); $endRow = (int) ($event['end_row'] ?? 0); foreach ($active as $idx => $info) { if ($info['end'] <= $startRow) { $availableCols[] = $info['col']; unset($active[$idx]); } } sort($availableCols); if (!empty($availableCols)) { $col = array_shift($availableCols); } else { $col = $maxCol; $maxCol++; } $assigned[$id] = $col; $active[] = ['end' => $endRow, 'col' => $col]; } $totalCols = max(1, $maxCol); $width = 100 / $totalCols; foreach ($cluster as $id => $event) { $index = $assigned[$id] ?? 0; $items[$id]['overlap_count'] = $totalCols; $items[$id]['overlap_index'] = $index; $items[$id]['overlap_width'] = round($width, 4); $items[$id]['overlap_offset'] = round($width * $index, 4); $items[$id]['overlap_z'] = $index + 1; } } }