From 52d92462e3acc766a23949cbc69d3eb161b45342 Mon Sep 17 00:00:00 2001 From: Andrew Gioia Date: Wed, 4 Feb 2026 12:19:03 -0500 Subject: [PATCH] Refactors CalendarController into specific services; fixes some previous bugs with CalendarController and possible 500s; adds first unit tests --- app/Http/Controllers/CalendarController.php | 854 ++---------------- .../Calendar/CalendarRangeResolver.php | 165 ++++ .../Calendar/CalendarSettingsPersister.php | 83 ++ app/Services/Calendar/CalendarViewBuilder.php | 455 ++++++++++ resources/css/lib/button.css | 2 +- resources/css/lib/calendar.css | 14 +- resources/views/calendar/index.blade.php | 33 +- .../components/calendar/day/day.blade.php | 8 +- .../components/calendar/four/four.blade.php | 9 +- .../components/calendar/mini/mini.blade.php | 6 +- ...ours.blade.php => daytime-hours.blade.php} | 18 +- .../calendar/time/density.blade.php | 4 +- .../components/calendar/week/week.blade.php | 15 +- tests/Unit/CalendarRangeResolverTest.php | 37 + tests/Unit/CalendarViewBuilderTest.php | 50 + tests/Unit/ExampleTest.php | 5 - 16 files changed, 942 insertions(+), 816 deletions(-) create mode 100644 app/Services/Calendar/CalendarRangeResolver.php create mode 100644 app/Services/Calendar/CalendarSettingsPersister.php create mode 100644 app/Services/Calendar/CalendarViewBuilder.php rename resources/views/components/calendar/time/{business-hours.blade.php => daytime-hours.blade.php} (62%) create mode 100644 tests/Unit/CalendarRangeResolverTest.php create mode 100644 tests/Unit/CalendarViewBuilderTest.php delete mode 100644 tests/Unit/ExampleTest.php diff --git a/app/Http/Controllers/CalendarController.php b/app/Http/Controllers/CalendarController.php index dc99977..aa93c50 100644 --- a/app/Http/Controllers/CalendarController.php +++ b/app/Http/Controllers/CalendarController.php @@ -4,22 +4,16 @@ 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\Calendar\CalendarRangeResolver; +use App\Services\Calendar\CalendarViewBuilder; +use App\Services\Calendar\CalendarSettingsPersister; use App\Services\Event\EventRecurrence; class CalendarController extends Controller { - private const VIEWS = ['day', 'week', 'month', 'four']; - /** * Consolidated calendar dashboard. * @@ -33,7 +27,13 @@ class CalendarController extends Controller * ├─ 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) + public function index( + Request $request, + EventRecurrence $recurrence, + CalendarRangeResolver $rangeResolver, + CalendarViewBuilder $viewBuilder, + CalendarSettingsPersister $settingsPersister + ) { /** * @@ -49,47 +49,36 @@ class CalendarController extends Controller $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); - $defaultBusinessHours = (int) $user->getSetting('calendar.business_hours', 0); - - // 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); + $defaults = $settingsPersister->defaults($user, $tz); + $weekStart = $defaults['week_start']; + $weekEnd = $defaults['week_end']; // get the view and time range - [$view, $range] = $this->resolveRange($request, $tz, $weekStart, $weekEnd, $defaultView, $defaultDate); - $today = Carbon::today($tz)->toDateString(); + [$view, $range] = $rangeResolver->resolveRange( + $request, + $tz, + $weekStart, + $weekEnd, + $defaults['view'], + $defaults['date'] + ); - // 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, - }; + $density = $settingsPersister->resolveDensity($request, $defaults['density']); + $stepMinutes = $density['step']; + $labelEvery = $density['label_every']; - // business hours toggle - $businessHoursEnabled = (int) $request->query('business_hours', $defaultBusinessHours) === 1; - $businessHoursRange = [ - 'start' => 8, - 'end' => 18, - ]; - $businessHoursRows = $businessHoursEnabled - ? intdiv((($businessHoursRange['end'] - $businessHoursRange['start']) * 60), 15) + $daytimeHoursEnabled = $settingsPersister->resolveDaytimeHours($request, $defaults['daytime_hours']); + $daytimeHoursRange = $settingsPersister->daytimeHoursRange(); + $daytimeHoursRows = $daytimeHoursEnabled + ? intdiv((($daytimeHoursRange['end'] - $daytimeHoursRange['start']) * 60), 15) : 96; + $daytimeHoursForView = ($daytimeHoursEnabled && in_array($view, ['day', 'week', 'four'], true)) + ? $daytimeHoursRange + : null; // date range span and controls - $span = $this->gridSpan($view, $range, $weekStart, $weekEnd); - $nav = $this->navDates($view, $range['start'], $tz); + $span = $rangeResolver->gridSpan($view, $range, $weekStart, $weekEnd); + $nav = $rangeResolver->navDates($view, $range['start'], $tz); // get the user's visible calendars from the left bar $visible = collect($request->query('c', [])); @@ -98,14 +87,14 @@ class CalendarController extends Controller $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); - } - if ($request->has('business_hours')) { - $user->setSetting('calendar.business_hours', $businessHoursEnabled ? '1' : '0'); - } + $settingsPersister->persist( + $user, + $request, + $view, + $range['start'], + $stepMinutes, + $daytimeHoursEnabled + ); /** * @@ -135,11 +124,8 @@ class CalendarController extends Controller $span['end'] ); - $businessHoursForView = ($businessHoursEnabled && in_array($view, ['day', 'week', 'four'], true)) - ? $businessHoursRange - : null; - - $events = $this->buildEventPayloads( + // build event payload + $events = $viewBuilder->buildEventPayloads( $events, $calendar_map, $timeFormat, @@ -148,7 +134,7 @@ class CalendarController extends Controller $tz, $recurrence, $span, - $businessHoursForView, + $daytimeHoursForView, ); /** @@ -158,10 +144,9 @@ class CalendarController extends Controller // create the mini calendar grid based on the mini cal controls $mini_anchor = $request->query('mini', $range['start']->toDateString()); + $mini_anchor_date = $rangeResolver->safeDate($mini_anchor, $tz, $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_start = $mini_anchor_date->copy()->startOfMonth(); $mini_nav = [ 'prev' => $mini_start->copy()->subMonth()->toDateString(), @@ -169,7 +154,7 @@ class CalendarController extends Controller 'today' => Carbon::today($tz)->startOfMonth()->toDateString(), 'label' => $mini_start->format('F Y'), ]; - $mini_headers = $this->weekdayHeaders($tz, $weekStart); + $mini_headers = $viewBuilder->weekdayHeaders($tz, $weekStart); // compute the mini's 42-day span (Mon..Sun, 6 rows) $mini_grid_start = $mini_start->copy()->startOfWeek($weekStart); @@ -185,7 +170,7 @@ class CalendarController extends Controller $mini_grid_end ); - $mini_events = $this->buildEventPayloads( + $mini_events = $viewBuilder->buildEventPayloads( $mini_events, $calendar_map, $timeFormat, @@ -198,7 +183,7 @@ class CalendarController extends Controller ); // now build the mini from mini_events (not from $events) - $mini = $this->buildMiniGrid($mini_start, $mini_events, $tz, $weekStart, $weekEnd); + $mini = $viewBuilder->buildMiniGrid($mini_start, $mini_events, $tz, $weekStart, $weekEnd); /** * @@ -206,27 +191,39 @@ class CalendarController extends Controller */ // create the calendar grid of days - $grid = $this->buildCalendarGrid($view, $range, $events, $tz, $weekStart, $weekEnd); + $grid = $viewBuilder->buildCalendarGrid($view, $range, $events, $tz, $span); // get the title - $header = $this->headerTitle($view, $range['start'], $range['end']); + $header = $rangeResolver->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"), + 'view' => $view, + 'range' => $range, + 'nav' => $nav, + 'header' => $header, + 'week_start' => $weekStart, + 'hgroup' => $viewBuilder->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, + '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) + 'daytime_hours' => [ + 'enabled' => $daytimeHoursEnabled, + 'start' => $daytimeHoursRange['start'], + 'end' => $daytimeHoursRange['end'], + 'rows' => $daytimeHoursRows, + ], + 'timezone' => $tz, + 'calendars' => $calendars->mapWithKeys(function ($cal) { - // compute colors $color = $cal->meta_color ?? $cal->calendarcolor ?? default_calendar_color(); @@ -245,41 +242,28 @@ class CalendarController extends Controller ], ]; }), - '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, - 'business_hours' => [ - 'enabled' => $businessHoursEnabled, - 'start' => $businessHoursRange['start'], - 'end' => $businessHoursRange['end'], - 'rows' => $businessHoursRows, - ], ]; // time-based payload values $timeBased = in_array($view, ['day', 'week', 'four'], true); - if ($timeBased) - { + if ($timeBased) { // create the time gutter if we're in a time-based view - $payload['slots'] = $this->timeSlots( + $payload['slots'] = $viewBuilder->timeSlots( $range['start'], $tz, $timeFormat, - $businessHoursEnabled ? $businessHoursRange : null + $daytimeHoursEnabled ? $daytimeHoursRange : null ); $payload['time_format'] = $timeFormat; // optional, if the blade cares // add the now indicator - $payload['now'] = $this->nowIndicator( + $payload['now'] = $viewBuilder->nowIndicator( $view, $range, $tz, 15, 1, - $businessHoursEnabled ? $businessHoursRange : null + $daytimeHoursEnabled ? $daytimeHoursRange : null ); } @@ -417,682 +401,4 @@ class CalendarController extends Controller * 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 $businessHours = null): array - { - $minutesPerSlot = 15; - $startMinutes = 0; - $endMinutes = 24 * 60; - - if (is_array($businessHours)) { - $startMinutes = (int) $businessHours['start'] * 60; - $endMinutes = (int) $businessHours['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'), // stable "machine" value - 'index' => $i, // 0..95 - 'minutes' => $startMinutes + ($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, - int $gridStartMinutes = 0 - ): 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) - $gridStartMinutes; - $startMinutesFromMidnight = max(0, $startMinutesFromMidnight); - $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, - ?array $businessHours = null - ): Collection { - $uiFormat = $timeFormat === '24' ? 'H:i' : 'g:ia'; - $spanStartUtc = $span['start']->copy()->utc(); - $spanEndUtc = $span['end']->copy()->utc(); - $gridStartMinutes = $businessHours ? ((int) $businessHours['start'] * 60) : 0; - $gridEndMinutes = $businessHours ? ((int) $businessHours['end'] * 60) : (24 * 60); - - return $events->flatMap(function ($e) use ( - $calendarMap, - $uiFormat, - $view, - $range, - $tz, - $recurrence, - $spanStartUtc, - $spanEndUtc, - $gridStartMinutes, - $gridEndMinutes, - $businessHours - ) { - $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, - $businessHours - ) { - $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 ($businessHours) { - $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 - ); - - $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. - * - * 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 $businessHours = null - ): 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; - $gridStartMinutes = $businessHours ? ((int) $businessHours['start'] * 60) : 0; - $gridEndMinutes = $businessHours ? ((int) $businessHours['end'] * 60) : (24 * 60); - - if ($businessHours && ($minutes < $gridStartMinutes || $minutes > $gridEndMinutes)) { - return ['show' => false, 'row' => 1, 'day_col' => 1, 'col_start' => 1, 'col_end' => 2]; - } - - $snapped = intdiv($minutes - $gridStartMinutes, $minutesPerSlot) * $minutesPerSlot; - $row = intdiv(max(0, $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, - ]; - } } diff --git a/app/Services/Calendar/CalendarRangeResolver.php b/app/Services/Calendar/CalendarRangeResolver.php new file mode 100644 index 0000000..072ad69 --- /dev/null +++ b/app/Services/Calendar/CalendarRangeResolver.php @@ -0,0 +1,165 @@ + Carbon, 'end' => Carbon]] + */ + public function resolveRange( + Request $request, + string $tz, + int $weekStart, + int $weekEnd, + string $defaultView, + string $defaultDate + ): array { + $requestView = $request->query('view', $defaultView); + $view = in_array($requestView, self::VIEWS, true) + ? $requestView + : 'month'; + + $date = $request->query('date', $defaultDate); + $anchor = $this->safeDate($date, $tz, $defaultDate); + + 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': + $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]]; + } + + /** + * Calendar grid span differs from logical range (e.g. month padding). + */ + public 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]; + } + + /** + * Navigation dates for header controls. + */ + public function navDates(string $view, Carbon $start, string $tz): array + { + $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 => [ + 'prev' => $start->copy()->subMonth()->startOfMonth()->toDateString(), + 'next' => $start->copy()->addMonth()->startOfMonth()->toDateString(), + 'today' => Carbon::today($tz)->toDateString(), + ], + }; + } + + /** + * Title text for the calendar header. + */ + public function headerTitle(string $view, Carbon $start, Carbon $end): array + { + $sameDay = $start->isSameDay($end); + $sameMonth = $start->isSameMonth($end); + $sameYear = $start->isSameYear($end); + + $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]; + } + + /** + * Safe date parsing with fallback to a default date string. + */ + public function safeDate(string $date, string $tz, string $fallbackDate): Carbon + { + try { + return Carbon::createFromFormat('Y-m-d', $date, $tz)->startOfDay(); + } catch (\Throwable $e) { + return Carbon::createFromFormat('Y-m-d', $fallbackDate, $tz)->startOfDay(); + } + } +} diff --git a/app/Services/Calendar/CalendarSettingsPersister.php b/app/Services/Calendar/CalendarSettingsPersister.php new file mode 100644 index 0000000..76f7134 --- /dev/null +++ b/app/Services/Calendar/CalendarSettingsPersister.php @@ -0,0 +1,83 @@ +getSetting('calendar.last_view', 'month'); + $defaultDate = $user->getSetting('calendar.last_date', Carbon::today($tz)->toDateString()); + $defaultDensity = (int) $user->getSetting('calendar.last_density', 30); + $defaultDaytimeHours = (int) $user->getSetting('calendar.daytime_hours', 0); + + $weekStartPref = $user->getSetting('calendar.week_start', 'sunday'); + $weekStartPref = in_array($weekStartPref, ['sunday', 'monday'], true) + ? $weekStartPref + : 'sunday'; + $weekStart = $weekStartPref === 'monday' ? Carbon::MONDAY : Carbon::SUNDAY; + $weekEnd = (int) (($weekStart + 6) % 7); + + return [ + 'view' => $defaultView, + 'date' => $defaultDate, + 'density' => $defaultDensity, + 'daytime_hours' => $defaultDaytimeHours, + 'week_start_pref' => $weekStartPref, + 'week_start' => $weekStart, + 'week_end' => $weekEnd, + ]; + } + + public function resolveDensity(Request $request, int $defaultDensity): array + { + $stepMinutes = (int) $request->query('density', $defaultDensity); + if (!in_array($stepMinutes, [15, 30, 60], true)) { + $stepMinutes = 30; + } + + $labelEvery = match ($stepMinutes) { + 15 => 1, + 30 => 2, + 60 => 4, + }; + + return [ + 'step' => $stepMinutes, + 'label_every' => $labelEvery, + ]; + } + + public function resolveDaytimeHours(Request $request, int $defaultDaytimeHours): bool + { + return (int) $request->query('daytime_hours', $defaultDaytimeHours) === 1; + } + + public function daytimeHoursRange(): array + { + return ['start' => 8, 'end' => 18]; + } + + public function persist( + User $user, + Request $request, + string $view, + Carbon $rangeStart, + int $stepMinutes, + bool $daytimeHoursEnabled + ): void { + if ($request->hasAny(['view', 'date', 'density'])) { + $user->setSetting('calendar.last_view', $view); + $user->setSetting('calendar.last_date', $rangeStart->toDateString()); + $user->setSetting('calendar.last_density', (string) $stepMinutes); + } + + if ($request->has('daytime_hours')) { + $user->setSetting('calendar.daytime_hours', $daytimeHoursEnabled ? '1' : '0'); + } + } +} diff --git a/app/Services/Calendar/CalendarViewBuilder.php b/app/Services/Calendar/CalendarViewBuilder.php new file mode 100644 index 0000000..8078e77 --- /dev/null +++ b/app/Services/Calendar/CalendarViewBuilder.php @@ -0,0 +1,455 @@ +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)); + } +} diff --git a/resources/css/lib/button.css b/resources/css/lib/button.css index 615ab31..6d43ea4 100644 --- a/resources/css/lib/button.css +++ b/resources/css/lib/button.css @@ -74,7 +74,7 @@ button, > label, > button { @apply relative flex items-center justify-center h-full pl-3.5 pr-3 cursor-pointer; - @apply border-md border-primary border-l-0 font-medium rounded-none; + @apply border-md border-primary border-l-0 font-medium rounded-none whitespace-nowrap; transition: outline 125ms ease-in-out; box-shadow: var(--shadows); --shadows: none; diff --git a/resources/css/lib/calendar.css b/resources/css/lib/calendar.css index b69878f..146479a 100644 --- a/resources/css/lib/calendar.css +++ b/resources/css/lib/calendar.css @@ -188,8 +188,16 @@ /* bottom controls */ footer { - @apply bg-white flex items-end justify-end col-span-2 border-t-md border-primary z-10; - @apply sticky bottom-0 pb-8; + @apply bg-white flex items-center justify-between col-span-2 border-t-md border-primary; + @apply sticky bottom-0 pt-2 pb-8 z-10; + + a.timezone { + @apply text-xs bg-gray-100 rounded px-2 py-1; + } + + div.right { + @apply flex items-center gap-4 justify-end; + } } /* now indicator */ @@ -198,7 +206,7 @@ grid-row: var(--now-row); grid-column: var(--now-col-start) / var(--now-col-end); width: calc(100% + 1rem); - top: 0.6rem; + top: calc(0.6rem + (var(--row-height) * var(--now-offset, 0))); &::before { @apply block w-3 h-3 rounded-full bg-red-600 -translate-y-1/2 -mt-[1.5px]; diff --git a/resources/views/calendar/index.blade.php b/resources/views/calendar/index.blade.php index fde170c..50f49d4 100644 --- a/resources/views/calendar/index.blade.php +++ b/resources/views/calendar/index.blade.php @@ -74,7 +74,7 @@ :view="$view" :density="$density" :headers="$mini_headers" - :business_hours="$business_hours" + :daytime_hours="$daytime_hours" class="aside-inset" /> @@ -106,7 +106,7 @@ {{-- persist values from other forms --}} - +