WIP: February 2026 event improvements and calendar refactor #1

Draft
andrew wants to merge 10 commits from feb-2026-event-improvements into master
9 changed files with 164 additions and 25 deletions
Showing only changes of commit 39078680ab - Show all commits

View File

@ -52,6 +52,7 @@ class CalendarController extends Controller
$defaultView = $user->getSetting('calendar.last_view', 'month'); $defaultView = $user->getSetting('calendar.last_view', 'month');
$defaultDate = $user->getSetting('calendar.last_date', Carbon::today($tz)->toDateString()); $defaultDate = $user->getSetting('calendar.last_date', Carbon::today($tz)->toDateString());
$defaultDensity = (int) $user->getSetting('calendar.last_density', 30); $defaultDensity = (int) $user->getSetting('calendar.last_density', 30);
$defaultBusinessHours = (int) $user->getSetting('calendar.business_hours', 0);
// week start preference // week start preference
$weekStartPref = $user->getSetting('calendar.week_start', 'sunday'); // 'sunday'|'monday' $weekStartPref = $user->getSetting('calendar.week_start', 'sunday'); // 'sunday'|'monday'
@ -76,6 +77,16 @@ class CalendarController extends Controller
60 => 4, 60 => 4,
}; };
// 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)
: 96;
// date range span and controls // date range span and controls
$span = $this->gridSpan($view, $range, $weekStart, $weekEnd); $span = $this->gridSpan($view, $range, $weekStart, $weekEnd);
$nav = $this->navDates($view, $range['start'], $tz); $nav = $this->navDates($view, $range['start'], $tz);
@ -92,6 +103,9 @@ class CalendarController extends Controller
$user->setSetting('calendar.last_date', $range['start']->toDateString()); $user->setSetting('calendar.last_date', $range['start']->toDateString());
$user->setSetting('calendar.last_density', (string) $stepMinutes); $user->setSetting('calendar.last_density', (string) $stepMinutes);
} }
if ($request->has('business_hours')) {
$user->setSetting('calendar.business_hours', $businessHoursEnabled ? '1' : '0');
}
/** /**
* *
@ -121,6 +135,10 @@ class CalendarController extends Controller
$span['end'] $span['end']
); );
$businessHoursForView = ($businessHoursEnabled && in_array($view, ['day', 'week', 'four'], true))
? $businessHoursRange
: null;
$events = $this->buildEventPayloads( $events = $this->buildEventPayloads(
$events, $events,
$calendar_map, $calendar_map,
@ -130,6 +148,7 @@ class CalendarController extends Controller
$tz, $tz,
$recurrence, $recurrence,
$span, $span,
$businessHoursForView,
); );
/** /**
@ -175,6 +194,7 @@ class CalendarController extends Controller
$tz, $tz,
$recurrence, $recurrence,
['start' => $mini_grid_start, 'end' => $mini_grid_end], ['start' => $mini_grid_start, 'end' => $mini_grid_end],
null,
); );
// now build the mini from mini_events (not from $events) // now build the mini from mini_events (not from $events)
@ -231,6 +251,12 @@ class CalendarController extends Controller
'mini' => $mini, // mini calendar days with events for indicators 'mini' => $mini, // mini calendar days with events for indicators
'mini_nav' => $mini_nav, // separate mini calendar navigation 'mini_nav' => $mini_nav, // separate mini calendar navigation
'mini_headers' => $mini_headers, 'mini_headers' => $mini_headers,
'business_hours' => [
'enabled' => $businessHoursEnabled,
'start' => $businessHoursRange['start'],
'end' => $businessHoursRange['end'],
'rows' => $businessHoursRows,
],
]; ];
// time-based payload values // time-based payload values
@ -238,11 +264,23 @@ class CalendarController extends Controller
if ($timeBased) if ($timeBased)
{ {
// create the time gutter if we're in a time-based view // create the time gutter if we're in a time-based view
$payload['slots'] = $this->timeSlots($range['start'], $tz, $timeFormat); $payload['slots'] = $this->timeSlots(
$range['start'],
$tz,
$timeFormat,
$businessHoursEnabled ? $businessHoursRange : null
);
$payload['time_format'] = $timeFormat; // optional, if the blade cares $payload['time_format'] = $timeFormat; // optional, if the blade cares
// add the now indicator // add the now indicator
$payload['now'] = $this->nowIndicator($view, $range, $tz, 15); $payload['now'] = $this->nowIndicator(
$view,
$range,
$tz,
15,
1,
$businessHoursEnabled ? $businessHoursRange : null
);
} }
// send the density array always, even though it doesn't matter for month // send the density array always, even though it doesn't matter for month
@ -570,15 +608,23 @@ class CalendarController extends Controller
* *
* Create the time gutter for time-based views * Create the time gutter for time-based views
*/ */
private function timeSlots(Carbon $dayStart, string $tz, string $timeFormat): array private function timeSlots(Carbon $dayStart, string $tz, string $timeFormat, ?array $businessHours = null): array
{ {
$minutesPerSlot = 15; $minutesPerSlot = 15;
$slotsPerDay = intdiv(24 * 60, $minutesPerSlot); // 96 $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'; $format = $timeFormat === '24' ? 'H:i' : 'g:i a';
$slots = []; $slots = [];
$t = $dayStart->copy()->tz($tz)->startOfDay(); $t = $dayStart->copy()->tz($tz)->startOfDay()->addMinutes($startMinutes);
for ($i = 0; $i < $slotsPerDay; $i++) { for ($i = 0; $i < $slotsPerDay; $i++) {
$slots[] = [ $slots[] = [
@ -586,7 +632,7 @@ class CalendarController extends Controller
'label' => $t->format($format), 'label' => $t->format($format),
'key' => $t->format('H:i'), // stable "machine" value 'key' => $t->format('H:i'), // stable "machine" value
'index' => $i, // 0..95 'index' => $i, // 0..95
'minutes' => $i * $minutesPerSlot, 'minutes' => $startMinutes + ($i * $minutesPerSlot),
'duration' => $minutesPerSlot, // handy for styling math 'duration' => $minutesPerSlot, // handy for styling math
]; ];
@ -613,7 +659,8 @@ class CalendarController extends Controller
?Carbon $endLocal, ?Carbon $endLocal,
Carbon $rangeStart, Carbon $rangeStart,
string $view, string $view,
int $minutesPerSlot = 15 int $minutesPerSlot = 15,
int $gridStartMinutes = 0
): array ): array
{ {
$start = $startLocal->copy(); $start = $startLocal->copy();
@ -626,7 +673,8 @@ class CalendarController extends Controller
$displayMinutes = $durationMinutes > 0 ? $durationMinutes : $minutesPerSlot; $displayMinutes = $durationMinutes > 0 ? $durationMinutes : $minutesPerSlot;
// row placement (96 rows when minutesPerSlot=15) // row placement (96 rows when minutesPerSlot=15)
$startMinutesFromMidnight = ($start->hour * 60) + $start->minute; $startMinutesFromMidnight = (($start->hour * 60) + $start->minute) - $gridStartMinutes;
$startMinutesFromMidnight = max(0, $startMinutesFromMidnight);
$startRow = intdiv($startMinutesFromMidnight, $minutesPerSlot) + 1; $startRow = intdiv($startMinutesFromMidnight, $minutesPerSlot) + 1;
$rowSpan = max(1, (int) ceil($displayMinutes / $minutesPerSlot)); $rowSpan = max(1, (int) ceil($displayMinutes / $minutesPerSlot));
@ -665,11 +713,14 @@ class CalendarController extends Controller
array $range, array $range,
string $tz, string $tz,
EventRecurrence $recurrence, EventRecurrence $recurrence,
array $span array $span,
?array $businessHours = null
): Collection { ): Collection {
$uiFormat = $timeFormat === '24' ? 'H:i' : 'g:ia'; $uiFormat = $timeFormat === '24' ? 'H:i' : 'g:ia';
$spanStartUtc = $span['start']->copy()->utc(); $spanStartUtc = $span['start']->copy()->utc();
$spanEndUtc = $span['end']->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 ( return $events->flatMap(function ($e) use (
$calendarMap, $calendarMap,
@ -679,7 +730,10 @@ class CalendarController extends Controller
$tz, $tz,
$recurrence, $recurrence,
$spanStartUtc, $spanStartUtc,
$spanEndUtc $spanEndUtc,
$gridStartMinutes,
$gridEndMinutes,
$businessHours
) { ) {
$cal = $calendarMap[$e->calendarid]; $cal = $calendarMap[$e->calendarid];
$timezone = $cal->timezone ?? config('app.timezone'); $timezone = $cal->timezone ?? config('app.timezone');
@ -723,7 +777,10 @@ class CalendarController extends Controller
$tz, $tz,
$timezone, $timezone,
$color, $color,
$colorFg $colorFg,
$gridStartMinutes,
$gridEndMinutes,
$businessHours
) { ) {
$startUtc = $occ['start']; $startUtc = $occ['start'];
$endUtc = $occ['end']; $endUtc = $occ['end'];
@ -734,12 +791,28 @@ class CalendarController extends Controller
$startForGrid = $startUtc->copy()->tz($tz); $startForGrid = $startUtc->copy()->tz($tz);
$endForGrid = $endUtc->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( $placement = $this->slotPlacement(
$startForGrid, $startForGrid,
$endForGrid, $endForGrid,
$range['start']->copy()->tz($tz), $range['start']->copy()->tz($tz),
$view, $view,
15 15,
$gridStartMinutes
); );
$occurrenceId = $occ['recurrence_id'] $occurrenceId = $occ['recurrence_id']
@ -770,7 +843,7 @@ class CalendarController extends Controller
'start_col' => $placement['start_col'], 'start_col' => $placement['start_col'],
'duration' => $placement['duration'], 'duration' => $placement['duration'],
]; ];
}); })->filter()->values();
})->keyBy('occurrence_id'); })->keyBy('occurrence_id');
} }
@ -967,7 +1040,14 @@ class CalendarController extends Controller
* 'col_end' => int, // grid column end * 'col_end' => int, // grid column end
* ] * ]
*/ */
private function nowIndicator(string $view, array $range, string $tz, int $minutesPerSlot = 15, int $gutterCols = 1): array 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 // only meaningful for time-based views
if (!in_array($view, ['day', 'week', 'four'], true)) { if (!in_array($view, ['day', 'week', 'four'], true)) {
@ -986,8 +1066,15 @@ class CalendarController extends Controller
// row: minutes since midnight, snapped down to slot size // row: minutes since midnight, snapped down to slot size
$minutes = ($now->hour * 60) + $now->minute; $minutes = ($now->hour * 60) + $now->minute;
$snapped = intdiv($minutes, $minutesPerSlot) * $minutesPerSlot; $gridStartMinutes = $businessHours ? ((int) $businessHours['start'] * 60) : 0;
$row = intdiv($snapped, $minutesPerSlot) + 1; // 1-based $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 // column: 1..N where 1 is the first day column in the events grid
if ($view === 'day') { if ($view === 'day') {

View File

@ -131,7 +131,7 @@
/* time column */ /* time column */
ol.time { ol.time {
@apply grid z-0 pt-4; @apply grid z-0 pt-4;
grid-template-rows: repeat(96, var(--row-height)); grid-template-rows: repeat(var(--grid-rows, 96), var(--row-height));
time { time {
@apply relative flex items-center justify-end items-start pr-4; @apply relative flex items-center justify-end items-start pr-4;
@ -150,7 +150,7 @@
/* event positioning */ /* event positioning */
ol.events { ol.events {
@apply grid py-4; @apply grid py-4;
grid-template-rows: repeat(96, var(--row-height)); grid-template-rows: repeat(var(--grid-rows, 96), var(--row-height));
--event-col: 0; --event-col: 0;
--event-row: 0; --event-row: 0;
--event-end: 4; --event-end: 4;
@ -381,4 +381,3 @@
transform: translateX(0); transform: translateX(0);
} }
} }

View File

@ -74,6 +74,7 @@
:view="$view" :view="$view"
:density="$density" :density="$density"
:headers="$mini_headers" :headers="$mini_headers"
:business_hours="$business_hours"
class="aside-inset" class="aside-inset"
/> />
</x-slot> </x-slot>
@ -105,6 +106,7 @@
{{-- persist values from other forms --}} {{-- persist values from other forms --}}
<input type="hidden" name="view" value="{{ $view }}"> <input type="hidden" name="view" value="{{ $view }}">
<input type="hidden" name="density" value="{{ $density['step'] }}"> <input type="hidden" name="density" value="{{ $density['step'] }}">
<input type="hidden" name="business_hours" value="{{ (int) ($business_hours['enabled'] ?? 0) }}">
<nav class="button-group button-group--primary"> <nav class="button-group button-group--primary">
<x-button.group-button <x-button.group-button
@ -148,6 +150,7 @@
{{-- persist data from density form --}} {{-- persist data from density form --}}
<input type="hidden" name="density" value="{{ $density['step'] }}"> <input type="hidden" name="density" value="{{ $density['step'] }}">
<input type="hidden" name="business_hours" value="{{ (int) ($business_hours['enabled'] ?? 0) }}">
<x-button.group-input value="day" :active="$view === 'day'">Day</x-button.group-input> <x-button.group-input value="day" :active="$view === 'day'">Day</x-button.group-input>
<x-button.group-input value="week" :active="$view === 'week'">Week</x-button.group-input> <x-button.group-input value="week" :active="$view === 'week'">Week</x-button.group-input>
<x-button.group-input value="month" :active="$view === 'month'">Month</x-button.group-input> <x-button.group-input value="month" :active="$view === 'month'">Month</x-button.group-input>
@ -176,6 +179,7 @@
:active="$active" :active="$active"
:density="$density" :density="$density"
:weekstart="$week_start" :weekstart="$week_start"
:business_hours="$business_hours"
:now="$now" :now="$now"
/> />
@break @break
@ -190,6 +194,7 @@
:hgroup="$hgroup" :hgroup="$hgroup"
:active="$active" :active="$active"
:density="$density" :density="$density"
:business_hours="$business_hours"
:now="$now" :now="$now"
/> />
@break @break
@ -204,6 +209,7 @@
:hgroup="$hgroup" :hgroup="$hgroup"
:active="$active" :active="$active"
:density="$density" :density="$density"
:business_hours="$business_hours"
:now="$now" :now="$now"
/> />
@break @break

View File

@ -9,11 +9,12 @@
'active' => [], 'active' => [],
'density' => '30', 'density' => '30',
'now' => [], 'now' => [],
'business_hours' => [],
]) ])
<section <section
class="calendar {{ $class }}" data-density="{{ $density['step'] }}" class="calendar {{ $class }}" data-density="{{ $density['step'] }}"
style="--now-row: {{ $now['row'] }}; --now-col-start: {{ $now['col_start'] }}; --now-col-end: {{ $now['col_end'] }};" style="--now-row: {{ $now['row'] }}; --now-col-start: {{ $now['col_start'] }}; --now-col-end: {{ $now['col_end'] }}; --grid-rows: {{ $business_hours['rows'] ?? 96 }};"
> >
<hgroup> <hgroup>
@foreach ($hgroup as $h) @foreach ($hgroup as $h)
@ -39,6 +40,7 @@
@endif @endif
</ol> </ol>
<footer> <footer>
<x-calendar.time.density view="day" :density="$density" /> <x-calendar.time.business-hours view="day" :density="$density" :business_hours="$business_hours" />
<x-calendar.time.density view="day" :density="$density" :business_hours="$business_hours" />
</footer> </footer>
</section> </section>

View File

@ -9,6 +9,7 @@
'active' => [], 'active' => [],
'density' => '30', 'density' => '30',
'now' => [], 'now' => [],
'business_hours' => [],
]) ])
<section <section
@ -16,7 +17,8 @@
style= style=
"--now-row: {{ (int) $now['row'] }}; "--now-row: {{ (int) $now['row'] }};
--now-col-start: {{ (int) $now['col_start'] }}; --now-col-start: {{ (int) $now['col_start'] }};
--now-col-end: {{ (int) $now['col_end'] }};" --now-col-end: {{ (int) $now['col_end'] }};
--grid-rows: {{ $business_hours['rows'] ?? 96 }};"
> >
<hgroup> <hgroup>
@foreach ($hgroup as $h) @foreach ($hgroup as $h)
@ -42,6 +44,7 @@
@endif @endif
</ol> </ol>
<footer> <footer>
<x-calendar.time.density view="four" :density="$density" /> <x-calendar.time.business-hours view="four" :density="$density" :business_hours="$business_hours" />
<x-calendar.time.density view="four" :density="$density" :business_hours="$business_hours" />
</footer> </footer>
</section> </section>

View File

@ -5,6 +5,7 @@
'class' => '', 'class' => '',
'density' => [], 'density' => [],
'headers' => [], 'headers' => [],
'business_hours' => [],
]) ])
<section id="mini" class="mini mini--month {{ $class }}"> <section id="mini" class="mini mini--month {{ $class }}">
@ -22,6 +23,7 @@
{{-- preserve main calendar context for full-reload fallback --}} {{-- preserve main calendar context for full-reload fallback --}}
<input type="hidden" name="view" value="{{ $view }}"> <input type="hidden" name="view" value="{{ $view }}">
<input type="hidden" name="date" value="{{ request('date') }}"> <input type="hidden" name="date" value="{{ request('date') }}">
<input type="hidden" name="business_hours" value="{{ (int) ($business_hours['enabled'] ?? 0) }}">
{{-- nav buttons --}} {{-- nav buttons --}}
<button type="submit" name="mini" class="button--icon button--sm" value="{{ $nav['prev'] }}" aria-label="Go back 1 month"> <button type="submit" name="mini" class="button--icon button--sm" value="{{ $nav['prev'] }}" aria-label="Go back 1 month">
<x-icon-chevron-left /> <x-icon-chevron-left />
@ -51,6 +53,7 @@
{{-- stay on the same view (month/week/etc --}} {{-- stay on the same view (month/week/etc --}}
<input type="hidden" name="view" value="{{ $view }}"> <input type="hidden" name="view" value="{{ $view }}">
<input type="hidden" name="density" value="{{ $density['step'] }}"> <input type="hidden" name="density" value="{{ $density['step'] }}">
<input type="hidden" name="business_hours" value="{{ (int) ($business_hours['enabled'] ?? 0) }}">
@foreach ($mini['days'] as $day) @foreach ($mini['days'] as $day)
<button <button

View File

@ -0,0 +1,34 @@
@props([
'business_hours' => [],
'view' => 'day',
'density' => [],
])
@php
$enabled = (int) ($business_hours['enabled'] ?? 0) === 1;
@endphp
<form id="calendar-business-hours"
method="get"
class="button-group button-group--primary button-group--sm"
action="{{ route('calendar.index') }}"
hx-get="{{ route('calendar.index') }}"
hx-target="#calendar"
hx-select="#calendar"
hx-swap="outerHTML"
hx-push-url="true"
hx-trigger="change"
hx-include="#calendar-toggles">
{{-- preserve current view and anchor date --}}
<input type="hidden" name="view" value="{{ $view }}">
<input type="hidden" name="date" value="{{ $density['anchor'] ?? request('date') }}">
<input type="hidden" name="density" value="{{ $density['step'] ?? 30 }}">
<x-button.group-input value="0" name="business_hours" :active="!$enabled">All day</x-button.group-input>
<x-button.group-input value="1" name="business_hours" :active="$enabled">Business hours</x-button.group-input>
<noscript>
<button type="submit" class="button">{{ __('Apply') }}</button>
</noscript>
</form>

View File

@ -1,6 +1,7 @@
@props([ @props([
'density' => [], 'density' => [],
'view' => 'day', 'view' => 'day',
'business_hours' => [],
]) ])
<form id="calendar-density" <form id="calendar-density"
@ -18,6 +19,7 @@
{{-- preserve current view and anchor date --}} {{-- preserve current view and anchor date --}}
<input type="hidden" name="view" value="{{ $view }}"> <input type="hidden" name="view" value="{{ $view }}">
<input type="hidden" name="date" value="{{ $density['anchor'] }}"> <input type="hidden" name="date" value="{{ $density['anchor'] }}">
<input type="hidden" name="business_hours" value="{{ (int) ($business_hours['enabled'] ?? 0) }}">
<x-button.group-input value="15" name="density" :active="(int)($density['step'] ?? 30) === 15">15m</x-button.group-input> <x-button.group-input value="15" name="density" :active="(int)($density['step'] ?? 30) === 15">15m</x-button.group-input>
<x-button.group-input value="30" name="density" :active="(int)($density['step'] ?? 30) === 30">30m</x-button.group-input> <x-button.group-input value="30" name="density" :active="(int)($density['step'] ?? 30) === 30">30m</x-button.group-input>

View File

@ -10,6 +10,7 @@
'density' => '30', 'density' => '30',
'weekstart' => 0, 'weekstart' => 0,
'now' => [], 'now' => [],
'business_hours' => [],
]) ])
<section <section
@ -17,7 +18,8 @@
style= style=
"--now-row: {{ (int) $now['row'] }}; "--now-row: {{ (int) $now['row'] }};
--now-col-start: {{ (int) $now['col_start'] }}; --now-col-start: {{ (int) $now['col_start'] }};
--now-col-end: {{ (int) $now['col_end'] }};" --now-col-end: {{ (int) $now['col_end'] }};
--grid-rows: {{ $business_hours['rows'] ?? 96 }};"
> >
<hgroup> <hgroup>
@foreach ($hgroup as $h) @foreach ($hgroup as $h)
@ -43,6 +45,7 @@
@endif @endif
</ol> </ol>
<footer> <footer>
<x-calendar.time.density view="week" :density="$density" /> <x-calendar.time.business-hours view="week" :density="$density" :business_hours="$business_hours" />
<x-calendar.time.density view="week" :density="$density" :business_hours="$business_hours" />
</footer> </footer>
</section> </section>