456 lines
15 KiB
PHP
456 lines
15 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);
|
|
|
|
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));
|
|
}
|
|
}
|