kithkin/app/Services/Calendar/CalendarViewBuilder.php

461 lines
16 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'];
$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();
})->filter();
// ensure chronological ordering across calendars for all views
return $payloads
->sortBy('start')
->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));
}
}