569 lines
19 KiB
PHP
569 lines
19 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Calendar;
|
|
|
|
use App\Services\Event\EventRecurrence;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Support\Collection;
|
|
|
|
class CalendarViewBuilder
|
|
{
|
|
/**
|
|
* Expand events (including recurrence) into view-ready payloads.
|
|
*/
|
|
public function buildEventPayloads(
|
|
Collection $events,
|
|
Collection $calendarMap,
|
|
string $timeFormat,
|
|
string $view,
|
|
array $range,
|
|
string $tz,
|
|
EventRecurrence $recurrence,
|
|
array $span,
|
|
?array $daytimeHours = null
|
|
): Collection {
|
|
$uiFormat = $timeFormat === '24' ? 'H:i' : 'g:ia';
|
|
$spanStartUtc = $span['start']->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) {
|
|
$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'];
|
|
$isAllDay = (bool) ($e->meta?->all_day ?? false);
|
|
|
|
$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;
|
|
}
|
|
}
|
|
}
|