kithkin/app/Http/Controllers/CalendarController.php

965 lines
33 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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;
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)
{
/**
*
* 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']
)->map(function ($e) use ($calendar_map, $timeFormat, $view, $range, $tz, $weekStart, $weekEnd) {
// event's calendar
$cal = $calendar_map[$e->calendarid];
// get utc dates from the database
$start_utc = $e->meta->start_at ??
Carbon::createFromTimestamp($e->firstoccurence);
$end_utc = $e->meta->end_at ??
($e->lastoccurence ? Carbon::createFromTimestamp($e->lastoccurence) : null);
// time format handling
$uiFormat = $timeFormat === '24' ? 'H:i' : 'g:ia';
// convert to calendar timezone
$timezone = $calendar_map[$e->calendarid]->timezone ?? config('app.timezone');
$start_local = $start_utc->copy()->timezone($timezone);
$end_local = optional($end_utc)->copy()->timezone($timezone);
// convert utc to user tz for grid placement (columns/rows must match view headers)
$start_for_grid = $start_utc->copy()->tz($tz);
$end_for_grid = optional($end_utc)->copy()->tz($tz);
// placement for time-based layouts
$placement = $this->slotPlacement(
$start_for_grid,
$end_for_grid,
$range['start']->copy()->tz($tz),
$view,
15
);
// color handling
$color = $cal['meta_color']
?? $cal['calendarcolor']
?? default_calendar_color();
$colorFg = $cal['meta_color_fg']
?? contrast_text_color($color);
logger()->info('event times', [
'id' => $e->id,
'start_at' => optional($e->meta)->start_at,
'end_at' => optional($e->meta)->end_at,
'firstoccurence' => $e->firstoccurence,
'lastoccurence' => $e->lastoccurence,
]);
// return events array
return [
// core data
'id' => $e->id,
'calendar_id' => $e->calendarid,
'calendar_slug' => $cal->slug,
'title' => $e->meta->title ?? 'No title',
'description' => $e->meta->description ?? 'No description.',
'start' => $start_utc->toIso8601String(),
'end' => optional($end_utc)->toIso8601String(),
'start_ui' => $start_local->format($uiFormat),
'end_ui' => optional($end_local)->format($uiFormat),
'timezone' => $timezone,
'visible' => $cal->visible,
'color' => $color,
'color_fg' => $colorFg,
// slot placement for time-based grid
'start_row' => $placement['start_row'],
'end_row' => $placement['end_row'],
'row_span' => $placement['row_span'],
'start_col' => $placement['start_col'],
'duration' => $placement['duration'],
];
})->keyBy('id');
/**
*
* 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
)->map(function ($e) use ($calendar_map, $tz) {
$cal = $calendar_map[$e->calendarid];
$start_utc = $e->meta->start_at ?? Carbon::createFromTimestamp($e->firstoccurence);
$end_utc = $e->meta->end_at ?? ($e->lastoccurence ? Carbon::createFromTimestamp($e->lastoccurence) : null);
$color = $cal->meta_color
?? $cal->calendarcolor
?? default_calendar_color();
$colorFg = $cal->meta_color_fg
?? contrast_text_color($color);
return [
'id' => $e->id,
'calendar_id' => $e->calendarid,
'calendar_slug' => $cal->slug,
'title' => $e->meta->title ?? 'No title',
'description' => $e->meta->description ?? 'No description.',
'start' => $start_utc->toIso8601String(),
'end' => optional($end_utc)->toIso8601String(),
'timezone' => $tz,
'visible' => $cal->visible,
'color' => $color,
'color_fg' => $colorFg,
];
})->keyBy('id');
// 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, one copy each
'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 shouldnt
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 wont 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,
];
}
/**
*
* 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['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['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,
];
}
}