1012 lines
33 KiB
PHP
1012 lines
33 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers;
|
||
|
||
use Carbon\Carbon;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Collection;
|
||
use Illuminate\Support\Str;
|
||
use Illuminate\Support\Facades\DB;
|
||
use App\Models\Calendar;
|
||
use App\Models\CalendarMeta;
|
||
use App\Models\CalendarInstance;
|
||
use App\Models\Event;
|
||
use App\Models\EventMeta;
|
||
use App\Models\Subscription;
|
||
use App\Services\Calendar\CreateCalendar;
|
||
use App\Services\Event\EventRecurrence;
|
||
|
||
class CalendarController extends Controller
|
||
{
|
||
private const VIEWS = ['day', 'week', 'month', 'four'];
|
||
|
||
/**
|
||
* Consolidated calendar dashboard.
|
||
*
|
||
* Query params:
|
||
* view = month | week | four (default: month)
|
||
* date = Y-m-d anchor date (default: today, in user TZ)
|
||
*
|
||
* The view receives a `$payload` array:
|
||
* ├─ view current view name
|
||
* ├─ range ['start' => Carbon, 'end' => Carbon]
|
||
* ├─ calendars keyed by calendar id (for the left-hand toggle list)
|
||
* └─ events flat list of VEVENTs in that range
|
||
*/
|
||
public function index(Request $request, EventRecurrence $recurrence)
|
||
{
|
||
/**
|
||
*
|
||
* manage parameters and core variables
|
||
*/
|
||
|
||
// set the calendar key
|
||
$principal = auth()->user()->principal_uri;
|
||
|
||
// user settings
|
||
$user = $request->user();
|
||
$tz = $user->timezone ?? config('app.timezone');
|
||
$timeFormat = $user->getSetting('app.time_format', '12');
|
||
|
||
// settings
|
||
$defaultView = $user->getSetting('calendar.last_view', 'month');
|
||
$defaultDate = $user->getSetting('calendar.last_date', Carbon::today($tz)->toDateString());
|
||
$defaultDensity = (int) $user->getSetting('calendar.last_density', 30);
|
||
|
||
// week start preference
|
||
$weekStartPref = $user->getSetting('calendar.week_start', 'sunday'); // 'sunday'|'monday'
|
||
$weekStartPref = in_array($weekStartPref, ['sunday', 'monday'], true)
|
||
? $weekStartPref
|
||
: 'sunday';
|
||
$weekStart = $weekStartPref === 'monday' ? Carbon::MONDAY : Carbon::SUNDAY;
|
||
$weekEnd = (int) (($weekStart + 6) % 7);
|
||
|
||
// get the view and time range
|
||
[$view, $range] = $this->resolveRange($request, $tz, $weekStart, $weekEnd, $defaultView, $defaultDate);
|
||
$today = Carbon::today($tz)->toDateString();
|
||
|
||
// get the display density, if present (in minutes for each step)
|
||
$stepMinutes = (int) $request->query('density', $defaultDensity);
|
||
if (! in_array($stepMinutes, [15, 30, 60], true)) { // lock it down
|
||
$stepMinutes = 30;
|
||
}
|
||
$labelEvery = match ($stepMinutes) { // how many 15-min slots per label/row
|
||
15 => 1,
|
||
30 => 2,
|
||
60 => 4,
|
||
};
|
||
|
||
// date range span and controls
|
||
$span = $this->gridSpan($view, $range, $weekStart, $weekEnd);
|
||
$nav = $this->navDates($view, $range['start'], $tz);
|
||
|
||
// get the user's visible calendars from the left bar
|
||
$visible = collect($request->query('c', []));
|
||
|
||
// keep a stable anchor date for forms that aren't the nav buttons
|
||
$anchorDate = $request->query('date', now($tz)->toDateString());
|
||
|
||
// persist settings
|
||
if ($request->hasAny(['view', 'date', 'density'])) {
|
||
$user->setSetting('calendar.last_view', $view);
|
||
$user->setSetting('calendar.last_date', $range['start']->toDateString());
|
||
$user->setSetting('calendar.last_density', (string) $stepMinutes);
|
||
}
|
||
|
||
/**
|
||
*
|
||
* calendars
|
||
*/
|
||
|
||
$calendars = Calendar::query()
|
||
->dashboardForPrincipal($principal)
|
||
->get();
|
||
|
||
$calendars = $calendars->map(function ($cal) use ($visible) {
|
||
$cal->visible = $visible->isEmpty() || $visible->contains($cal->slug);
|
||
return $cal;
|
||
});
|
||
|
||
$calendar_map = $calendars->keyBy('id');
|
||
|
||
/**
|
||
*
|
||
* get events for calendars in range
|
||
*/
|
||
|
||
// get all the events in one query
|
||
$events = Event::forCalendarsInRange(
|
||
$calendars->pluck('id'),
|
||
$span['start'],
|
||
$span['end']
|
||
);
|
||
|
||
$events = $this->buildEventPayloads(
|
||
$events,
|
||
$calendar_map,
|
||
$timeFormat,
|
||
$view,
|
||
$range,
|
||
$tz,
|
||
$recurrence,
|
||
$span,
|
||
);
|
||
|
||
/**
|
||
*
|
||
* mini calendar
|
||
*/
|
||
|
||
// create the mini calendar grid based on the mini cal controls
|
||
$mini_anchor = $request->query('mini', $range['start']->toDateString());
|
||
|
||
// anchor is a DATE string, so create it explicitly in the user tz
|
||
$mini_start = Carbon::createFromFormat('Y-m-d', $mini_anchor, $tz)
|
||
->startOfMonth();
|
||
|
||
$mini_nav = [
|
||
'prev' => $mini_start->copy()->subMonth()->toDateString(),
|
||
'next' => $mini_start->copy()->addMonth()->toDateString(),
|
||
'today' => Carbon::today($tz)->startOfMonth()->toDateString(),
|
||
'label' => $mini_start->format('F Y'),
|
||
];
|
||
$mini_headers = $this->weekdayHeaders($tz, $weekStart);
|
||
|
||
// compute the mini's 42-day span (Mon..Sun, 6 rows)
|
||
$mini_grid_start = $mini_start->copy()->startOfWeek($weekStart);
|
||
$mini_grid_end = $mini_start->copy()->endOfMonth()->endOfWeek($weekEnd);
|
||
if ($mini_grid_start->diffInDays($mini_grid_end) + 1 < 42) {
|
||
$mini_grid_end->addWeek();
|
||
}
|
||
|
||
// fetch events specifically for the mini-span
|
||
$mini_events = Event::forCalendarsInRange(
|
||
$calendars->pluck('id'),
|
||
$mini_grid_start,
|
||
$mini_grid_end
|
||
);
|
||
|
||
$mini_events = $this->buildEventPayloads(
|
||
$mini_events,
|
||
$calendar_map,
|
||
$timeFormat,
|
||
$view,
|
||
['start' => $mini_grid_start, 'end' => $mini_grid_end],
|
||
$tz,
|
||
$recurrence,
|
||
['start' => $mini_grid_start, 'end' => $mini_grid_end],
|
||
);
|
||
|
||
// now build the mini from mini_events (not from $events)
|
||
$mini = $this->buildMiniGrid($mini_start, $mini_events, $tz, $weekStart, $weekEnd);
|
||
|
||
/**
|
||
*
|
||
* main calendar grid
|
||
*/
|
||
|
||
// create the calendar grid of days
|
||
$grid = $this->buildCalendarGrid($view, $range, $events, $tz, $weekStart, $weekEnd);
|
||
|
||
// get the title
|
||
$header = $this->headerTitle($view, $range['start'], $range['end']);
|
||
|
||
// format the data for the frontend, including separate arrays for events specifically and the big grid
|
||
$payload = [
|
||
'view' => $view,
|
||
'range' => $range,
|
||
'nav' => $nav,
|
||
'header' => $header,
|
||
'active' => [
|
||
'date' => $range['start']->toDateString(),
|
||
'year' => $range['start']->format('Y'),
|
||
'month' => $range['start']->format("F"),
|
||
'day' => $range['start']->format("d"),
|
||
],
|
||
'week_start' => $weekStart,
|
||
'calendars' => $calendars->mapWithKeys(function ($cal)
|
||
{
|
||
// compute colors
|
||
$color = $cal->meta_color
|
||
?? $cal->calendarcolor
|
||
?? default_calendar_color();
|
||
$colorFg = $cal->meta_color_fg
|
||
?? contrast_text_color($color);
|
||
|
||
return [
|
||
$cal->id => [
|
||
'id' => $cal->id,
|
||
'slug' => $cal->slug,
|
||
'name' => $cal->displayname,
|
||
'color' => $color,
|
||
'color_fg' => $colorFg,
|
||
'visible' => $cal->visible,
|
||
'is_remote' => $cal->is_remote,
|
||
],
|
||
];
|
||
}),
|
||
'hgroup' => $this->viewHeaders($view, $range, $tz, $weekStart),
|
||
'events' => $events, // keyed by occurrence
|
||
'grid' => $grid, // day objects hold only ID-sets
|
||
'mini' => $mini, // mini calendar days with events for indicators
|
||
'mini_nav' => $mini_nav, // separate mini calendar navigation
|
||
'mini_headers' => $mini_headers,
|
||
];
|
||
|
||
// time-based payload values
|
||
$timeBased = in_array($view, ['day', 'week', 'four'], true);
|
||
if ($timeBased)
|
||
{
|
||
// create the time gutter if we're in a time-based view
|
||
$payload['slots'] = $this->timeSlots($range['start'], $tz, $timeFormat);
|
||
$payload['time_format'] = $timeFormat; // optional, if the blade cares
|
||
|
||
// add the now indicator
|
||
$payload['now'] = $this->nowIndicator($view, $range, $tz, 15);
|
||
}
|
||
|
||
// send the density array always, even though it doesn't matter for month
|
||
$payload['density'] = [
|
||
'step' => $stepMinutes, // 15|30|60
|
||
'label_every' => $labelEvery, // 1|2|4
|
||
'anchor' => $anchorDate,
|
||
];
|
||
|
||
return view('calendar.index', $payload);
|
||
}
|
||
|
||
/**
|
||
* create sabre calendar + meta
|
||
*/
|
||
public function store(Request $request, CreateCalendar $creator)
|
||
{
|
||
$data = $request->validate([
|
||
'name' => 'required|string|max:100',
|
||
'description' => 'nullable|string|max:255',
|
||
'timezone' => 'required|string|max:64',
|
||
'color' => 'nullable|regex:/^#[0-9A-Fa-f]{6}$/',
|
||
'redirect' => 'nullable|string', // where to go after creating
|
||
]);
|
||
|
||
$creator->create($request->user(), $data);
|
||
$redirect = $data['redirect'] ?? route('calendar.index');
|
||
|
||
return redirect($redirect)->with('toast', [
|
||
'message' => __('Calendar created!'),
|
||
'type' => 'success',
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* show calendar details
|
||
*/
|
||
public function show(Calendar $calendar)
|
||
{
|
||
$this->authorize('view', $calendar);
|
||
|
||
$calendar->load([
|
||
'meta',
|
||
'instances' => fn ($q) => $q->where('principaluri', auth()->user()->uri),
|
||
]);
|
||
|
||
/* grab the single instance for convenience in the view */
|
||
$instance = $calendar->instances->first();
|
||
$caldavUrl = $instance?->caldavUrl(); // null-safe
|
||
|
||
/* events + meta, newest first */
|
||
$events = $calendar->events()
|
||
->with('meta')
|
||
->orderByDesc('lastmodified')
|
||
->get();
|
||
|
||
return view(
|
||
'calendar.show',
|
||
compact('calendar', 'instance', 'events', 'caldavUrl')
|
||
);
|
||
}
|
||
|
||
/**
|
||
* edit calendar page
|
||
*/
|
||
public function edit(Calendar $calendar)
|
||
{
|
||
$this->authorize('update', $calendar);
|
||
|
||
$calendar->load([
|
||
'meta',
|
||
'instances' => fn ($q) =>
|
||
$q->where('principaluri', auth()->user()->uri),
|
||
]);
|
||
|
||
$instance = $calendar->instances->first(); // may be null but shouldn’t
|
||
|
||
return view('calendar.edit', compact('calendar', 'instance'));
|
||
}
|
||
|
||
/**
|
||
* update sabre + meta records
|
||
*/
|
||
public function update(Request $request, Calendar $calendar)
|
||
{
|
||
$this->authorize('update', $calendar);
|
||
|
||
$data = $request->validate([
|
||
'name' => 'required|string|max:100',
|
||
'description' => 'nullable|string|max:255',
|
||
'timezone' => 'required|string',
|
||
'color' => 'nullable|regex:/^#[0-9A-Fa-f]{6}$/',
|
||
]);
|
||
|
||
// update the instance row
|
||
$calendar->instances()
|
||
->where('principaluri', auth()->user()->uri)
|
||
->update([
|
||
'displayname' => $data['name'],
|
||
'description' => $data['description'] ?? '',
|
||
'calendarcolor' => $data['color'] ?? null,
|
||
'timezone' => $data['timezone'],
|
||
]);
|
||
|
||
// bump synctoken on master calendar row
|
||
$calendar->increment('synctoken');
|
||
|
||
// update calendar meta (our table)
|
||
$color = calendar_color($data);
|
||
$calendar->meta()->updateOrCreate([], [
|
||
'color' => $color,
|
||
'color_fg' => contrast_text_color($color),
|
||
]);
|
||
|
||
return redirect()
|
||
->route('calendar.show', $calendar)
|
||
->with('toast', __('Calendar saved successfully!'));
|
||
}
|
||
|
||
/**
|
||
*
|
||
* Delete calendar
|
||
* @todo
|
||
*/
|
||
public function destroy(Calendar $calendar)
|
||
{
|
||
$this->authorize('delete', $calendar);
|
||
$calendar->delete(); // cascades to meta via FK
|
||
return redirect()->route('calendar.index');
|
||
}
|
||
|
||
/**
|
||
*
|
||
* Private helpers
|
||
*/
|
||
|
||
/**
|
||
*
|
||
* Prepare nav dates for the movement buttons in the calendar header
|
||
*
|
||
* @return array
|
||
* [
|
||
* 'prev' => 2026-08-19
|
||
* 'next' => 2026-08-21
|
||
* 'today' => 2026-08-20
|
||
* ]
|
||
*/
|
||
private function navDates(string $view, Carbon $start, string $tz): array
|
||
{
|
||
// always compute in the user tz so the UX is consistent
|
||
$start = $start->copy()->tz($tz);
|
||
|
||
return match ($view)
|
||
{
|
||
'day' => [
|
||
'prev' => $start->copy()->subDay()->toDateString(),
|
||
'next' => $start->copy()->addDay()->toDateString(),
|
||
'today' => Carbon::today($tz)->toDateString(),
|
||
],
|
||
'week' => [
|
||
'prev' => $start->copy()->subWeek()->toDateString(),
|
||
'next' => $start->copy()->addWeek()->toDateString(),
|
||
'today' => Carbon::today($tz)->toDateString(),
|
||
],
|
||
'four' => [
|
||
'prev' => $start->copy()->subDays(4)->toDateString(),
|
||
'next' => $start->copy()->addDays(4)->toDateString(),
|
||
'today' => Carbon::today($tz)->toDateString(),
|
||
],
|
||
default => [ // month
|
||
'prev' => $start->copy()->subMonth()->startOfMonth()->toDateString(),
|
||
'next' => $start->copy()->addMonth()->startOfMonth()->toDateString(),
|
||
'today' => Carbon::today($tz)->toDateString(),
|
||
],
|
||
};
|
||
}
|
||
|
||
/**
|
||
*
|
||
* Interpret $view and $date filters and normalize into a Carbon range
|
||
*
|
||
* @return array
|
||
* [
|
||
* $view,
|
||
* [
|
||
* 'start' => Carbon,
|
||
* 'end' => Carbon
|
||
* ]
|
||
* ]
|
||
*/
|
||
private function resolveRange(
|
||
Request $request,
|
||
string $tz,
|
||
int $weekStart,
|
||
int $weekEnd,
|
||
string $defaultView,
|
||
string $defaultDate
|
||
): array {
|
||
// get the view
|
||
$requestView = $request->query('view', $defaultView);
|
||
$view = in_array($requestView, self::VIEWS, true)
|
||
? $requestView
|
||
: 'month';
|
||
$date = $request->query('date', $defaultDate);
|
||
|
||
// anchor date in the user's timezone
|
||
$anchor = Carbon::createFromFormat('Y-m-d', $date, $tz)->startOfDay();
|
||
|
||
// set dates based on view
|
||
switch ($view)
|
||
{
|
||
case 'day':
|
||
$start = $anchor->copy()->startOfDay();
|
||
$end = $anchor->copy()->endOfDay();
|
||
break;
|
||
|
||
case 'week':
|
||
$start = $anchor->copy()->startOfWeek($weekStart);
|
||
$end = $anchor->copy()->endOfWeek($weekEnd);
|
||
break;
|
||
|
||
case 'four':
|
||
// a rolling 4-day "agenda" view starting at anchor
|
||
$start = $anchor->copy()->startOfDay();
|
||
$end = $anchor->copy()->addDays(3)->endOfDay();
|
||
break;
|
||
|
||
default: // month
|
||
$start = $anchor->copy()->startOfMonth();
|
||
$end = $anchor->copy()->endOfMonth();
|
||
}
|
||
|
||
return [$view, ['start' => $start, 'end' => $end]];
|
||
}
|
||
|
||
/**
|
||
* Create the grid for each view with pre- and post-padding if needed
|
||
*
|
||
* This is different from resolveRange() as the rendered span != logical range
|
||
*
|
||
* Month: start of the month's first week to end of the last week
|
||
* Week: Sunday to Saturday
|
||
* Four: selected day to 3 days later
|
||
* Day: start of the day to the end of the day
|
||
*/
|
||
private function gridSpan(string $view, array $range, int $weekStart, int $weekEnd): array
|
||
{
|
||
switch ($view)
|
||
{
|
||
case 'day':
|
||
$start = $range['start']->copy()->startOfDay();
|
||
$end = $range['start']->copy()->endOfDay();
|
||
break;
|
||
|
||
case 'week':
|
||
$start = $range['start']->copy()->startOfWeek($weekStart);
|
||
$end = $range['start']->copy()->endOfWeek($weekEnd);
|
||
break;
|
||
|
||
case 'four':
|
||
$start = $range['start']->copy()->startOfDay();
|
||
$end = $range['start']->copy()->addDays(3);
|
||
break;
|
||
|
||
default: // month
|
||
$start = $range['start']->copy()->startOfMonth()->startOfWeek($weekStart);
|
||
$end = $range['end']->copy()->endOfMonth()->endOfWeek($weekEnd);
|
||
}
|
||
|
||
return ['start' => $start, 'end' => $end];
|
||
}
|
||
|
||
/**
|
||
*
|
||
* Prepare the calendar title based on the view
|
||
*
|
||
* @return array
|
||
* [
|
||
* 'strong' => 'August 20',
|
||
* 'span' => '2026',
|
||
* ]
|
||
*/
|
||
private function headerTitle(string $view, Carbon $start, Carbon $end): array
|
||
{
|
||
$sameDay = $start->isSameDay($end);
|
||
$sameMonth = $start->isSameMonth($end);
|
||
$sameYear = $start->isSameYear($end);
|
||
|
||
// month default
|
||
$strong = $start->format('F');
|
||
$span = $start->format('Y');
|
||
|
||
if ($view === 'day' || $sameDay) {
|
||
return [
|
||
'strong' => $start->format('F j'),
|
||
'span' => $start->format('Y'),
|
||
];
|
||
}
|
||
|
||
if (in_array($view, ['week', 'four'], true)) {
|
||
if ($sameMonth && $sameYear) {
|
||
return [
|
||
'strong' => $start->format('F j') . ' to ' . $end->format('j'),
|
||
'span' => $start->format('Y'),
|
||
];
|
||
}
|
||
|
||
if ($sameYear) {
|
||
return [
|
||
'strong' => $start->format('F') . ' to ' . $end->format('F'),
|
||
'span' => $start->format('Y'),
|
||
];
|
||
}
|
||
|
||
return [
|
||
'strong' => $start->format('F Y') . ' to ' . $end->format('F Y'),
|
||
'span' => null,
|
||
];
|
||
}
|
||
|
||
return ['strong' => $strong, 'span' => $span];
|
||
}
|
||
|
||
/**
|
||
*
|
||
* Create the time gutter for time-based views
|
||
*/
|
||
private function timeSlots(Carbon $dayStart, string $tz, string $timeFormat): array
|
||
{
|
||
$minutesPerSlot = 15;
|
||
$slotsPerDay = intdiv(24 * 60, $minutesPerSlot); // 96
|
||
|
||
$format = $timeFormat === '24' ? 'H:i' : 'g:i a';
|
||
|
||
$slots = [];
|
||
$t = $dayStart->copy()->tz($tz)->startOfDay();
|
||
|
||
for ($i = 0; $i < $slotsPerDay; $i++) {
|
||
$slots[] = [
|
||
'iso' => $t->toIso8601String(),
|
||
'label' => $t->format($format),
|
||
'key' => $t->format('H:i'), // stable "machine" value
|
||
'index' => $i, // 0..95
|
||
'minutes' => $i * $minutesPerSlot,
|
||
'duration' => $minutesPerSlot, // handy for styling math
|
||
];
|
||
|
||
$t->addMinutes($minutesPerSlot);
|
||
}
|
||
|
||
return $slots;
|
||
}
|
||
|
||
/**
|
||
*
|
||
* Time-based layout slot placement
|
||
*
|
||
* Placements object:
|
||
* [
|
||
* 'start_line' => '24',
|
||
* 'end_line' => '32',
|
||
* 'span' => '4',
|
||
* 'duration' => '60',
|
||
* ]
|
||
**/
|
||
private function slotPlacement(
|
||
Carbon $startLocal,
|
||
?Carbon $endLocal,
|
||
Carbon $rangeStart,
|
||
string $view,
|
||
int $minutesPerSlot = 15
|
||
): array
|
||
{
|
||
$start = $startLocal->copy();
|
||
$end = ($endLocal ?? $startLocal)->copy();
|
||
|
||
// get the real duration in minutes
|
||
$durationMinutes = max(0, $start->diffInMinutes($end, false));
|
||
|
||
// duration for display purposes
|
||
$displayMinutes = $durationMinutes > 0 ? $durationMinutes : $minutesPerSlot;
|
||
|
||
// row placement (96 rows when minutesPerSlot=15)
|
||
$startMinutesFromMidnight = ($start->hour * 60) + $start->minute;
|
||
$startRow = intdiv($startMinutesFromMidnight, $minutesPerSlot) + 1;
|
||
|
||
$rowSpan = max(1, (int) ceil($displayMinutes / $minutesPerSlot));
|
||
$endRow = $startRow + $rowSpan;
|
||
|
||
// column placement
|
||
$maxCols = match ($view) {
|
||
'day' => 1,
|
||
'four' => 4,
|
||
'week' => 7,
|
||
default => 1, // month won’t use this
|
||
};
|
||
// rangeStart is already the "first day column" for week/four/day
|
||
$startCol = $rangeStart->copy()->startOfDay()->diffInDays($start->copy()->startOfDay()) + 1;
|
||
|
||
// clamp to view columns
|
||
$startCol = max(1, min($maxCols, $startCol));
|
||
|
||
return [
|
||
'start_row' => $startRow,
|
||
'end_row' => $endRow,
|
||
'row_span' => $rowSpan,
|
||
'duration' => $durationMinutes,
|
||
'start_col' => $startCol,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Expand events (including recurrence) into view-ready payloads.
|
||
*/
|
||
private function buildEventPayloads(
|
||
Collection $events,
|
||
Collection $calendarMap,
|
||
string $timeFormat,
|
||
string $view,
|
||
array $range,
|
||
string $tz,
|
||
EventRecurrence $recurrence,
|
||
array $span
|
||
): Collection {
|
||
$uiFormat = $timeFormat === '24' ? 'H:i' : 'g:ia';
|
||
$spanStartUtc = $span['start']->copy()->utc();
|
||
$spanEndUtc = $span['end']->copy()->utc();
|
||
|
||
return $events->flatMap(function ($e) use (
|
||
$calendarMap,
|
||
$uiFormat,
|
||
$view,
|
||
$range,
|
||
$tz,
|
||
$recurrence,
|
||
$spanStartUtc,
|
||
$spanEndUtc
|
||
) {
|
||
$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
|
||
) {
|
||
$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);
|
||
|
||
$placement = $this->slotPlacement(
|
||
$startForGrid,
|
||
$endForGrid,
|
||
$range['start']->copy()->tz($tz),
|
||
$view,
|
||
15
|
||
);
|
||
|
||
$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'],
|
||
];
|
||
});
|
||
})->keyBy('occurrence_id');
|
||
}
|
||
|
||
/**
|
||
*
|
||
* Assemble an array of day-objects for the requested view.
|
||
*
|
||
* Day object shape:
|
||
* [
|
||
* 'date' => '2025-07-14',
|
||
* 'label' => '14', // two-digit day number
|
||
* 'in_month' => true|false, // helpful for grey-out styling
|
||
* 'events' => [ …event payloads… ]
|
||
* ]
|
||
*
|
||
* For the "month" view the return value also contains
|
||
* 'weeks' => [ [7 day-objs], [7 day-objs], … ]
|
||
*/
|
||
private function buildCalendarGrid(
|
||
string $view,
|
||
array $range,
|
||
Collection $events,
|
||
string $tz,
|
||
int $weekStart,
|
||
int $weekEnd): array
|
||
{
|
||
// use the same span the events were fetched for (month padded to full weeks, etc.)
|
||
['start' => $grid_start, 'end' => $grid_end] = $this->gridSpan($view, $range, $weekStart, $weekEnd);
|
||
|
||
// today checks
|
||
$today = Carbon::today($tz)->toDateString();
|
||
|
||
// index events by YYYY-MM-DD for quick lookup
|
||
$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'];
|
||
}
|
||
}
|
||
|
||
// view span bounds and build day objects
|
||
$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];
|
||
}
|
||
|
||
/**
|
||
*
|
||
* Create the specific view's date headers
|
||
*
|
||
* For day/week/four, this is an array of number dates and their day name,
|
||
* for month it's the user's preferred weekdays
|
||
*/
|
||
private 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();
|
||
|
||
// month view: weekday headers (Sunday..Saturday for now)
|
||
if ($view === 'month') {
|
||
return collect($this->weekdayHeaders($tz, $weekStart))
|
||
->map(fn ($h) => $h + ['is_today' => false])
|
||
->all();
|
||
}
|
||
|
||
// day/week/four: column headers for each day in range
|
||
$headers = [];
|
||
for ($d = $start->copy()->startOfDay(); $d->lte($end); $d->addDay()) {
|
||
$date = $d->toDateString();
|
||
$headers[] = [
|
||
'date' => $d->toDateString(), // 2026-01-31
|
||
'day' => $d->format('j'), // 31
|
||
'dow' => $d->translatedFormat('l'), // Saturday (localized)
|
||
'dow_short' => $d->translatedFormat('D'), // Sat (localized)
|
||
'month' => $d->translatedFormat('M'), // Jan (localized)
|
||
'is_today' => $date === $today, // flag for viewing real today
|
||
];
|
||
}
|
||
|
||
return $headers;
|
||
}
|
||
|
||
/**
|
||
*
|
||
* Specific headers for month views (full and mini)
|
||
*/
|
||
private function weekdayHeaders(string $tz, int $weekStart): array
|
||
{
|
||
$headers = [];
|
||
$d = Carbon::now($tz)->startOfWeek($weekStart);
|
||
|
||
for ($i = 0; $i < 7; $i++) {
|
||
$headers[] = [
|
||
// stable key (0..6 from the start day)
|
||
'key' => $i,
|
||
// Sun, Mon...
|
||
'label' => $d->translatedFormat('D'),
|
||
];
|
||
$d->addDay();
|
||
}
|
||
|
||
return $headers;
|
||
}
|
||
|
||
/**
|
||
*
|
||
* Build the mini-month grid for day buttons in the bottom left of the UI
|
||
*
|
||
* Returns ['days' => [
|
||
* [
|
||
* 'date' => '2025-06-30',
|
||
* 'label' => '30',
|
||
* 'in_month' => false,
|
||
* 'events' => [id, id …]
|
||
* ], …
|
||
* ]]
|
||
*/
|
||
private 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();
|
||
|
||
// map event ids by yyyy-mm-dd in USER tz (so indicators match what user sees)
|
||
$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];
|
||
}
|
||
|
||
/**
|
||
* Place the "now" indicator on the grid if we're in a time-based view and on the current day
|
||
*
|
||
* Returns:
|
||
* [
|
||
* 'show' => bool,
|
||
* 'row' => int, // 1..96 (15-min grid line)
|
||
* 'col_start' => int, // grid column start
|
||
* 'col_end' => int, // grid column end
|
||
* ]
|
||
*/
|
||
private function nowIndicator(string $view, array $range, string $tz, int $minutesPerSlot = 15, int $gutterCols = 1): array
|
||
{
|
||
// only meaningful for time-based views
|
||
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();
|
||
|
||
// show only if "now" is inside the visible range
|
||
if (! $now->betweenIncluded($start, $end)) {
|
||
return ['show' => false, 'row' => 1, 'day_col' => 1, 'col_start' => 1, 'col_end' => 2];
|
||
}
|
||
|
||
// row: minutes since midnight, snapped down to slot size
|
||
$minutes = ($now->hour * 60) + $now->minute;
|
||
$snapped = intdiv($minutes, $minutesPerSlot) * $minutesPerSlot;
|
||
$row = intdiv($snapped, $minutesPerSlot) + 1; // 1-based
|
||
|
||
// column: 1..N where 1 is the first day column in the events grid
|
||
if ($view === 'day') {
|
||
$dayCol = 1;
|
||
} else {
|
||
// IMPORTANT: compare dates at midnight to avoid fractional diffs
|
||
$todayStart = $now->copy()->startOfDay();
|
||
$dayCol = $start->diffInDays($todayStart) + 1; // 1..7 or 1..4
|
||
}
|
||
|
||
$dayCol = (int) $dayCol;
|
||
|
||
return [
|
||
'show' => true,
|
||
'row' => (int) $row,
|
||
'day_col' => $dayCol,
|
||
'col_start' => $dayCol,
|
||
'col_end' => $dayCol + 1,
|
||
];
|
||
}
|
||
}
|