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 --}}
-
+