Compare commits
3 Commits
0881d04428
...
52d92462e3
| Author | SHA1 | Date | |
|---|---|---|---|
| 52d92462e3 | |||
| 39078680ab | |||
| 25632136c2 |
@ -4,22 +4,16 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use App\Models\Calendar;
|
use App\Models\Calendar;
|
||||||
use App\Models\CalendarMeta;
|
|
||||||
use App\Models\CalendarInstance;
|
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\EventMeta;
|
|
||||||
use App\Models\Subscription;
|
|
||||||
use App\Services\Calendar\CreateCalendar;
|
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;
|
use App\Services\Event\EventRecurrence;
|
||||||
|
|
||||||
class CalendarController extends Controller
|
class CalendarController extends Controller
|
||||||
{
|
{
|
||||||
private const VIEWS = ['day', 'week', 'month', 'four'];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Consolidated calendar dashboard.
|
* Consolidated calendar dashboard.
|
||||||
*
|
*
|
||||||
@ -33,7 +27,13 @@ class CalendarController extends Controller
|
|||||||
* ├─ calendars keyed by calendar id (for the left-hand toggle list)
|
* ├─ calendars keyed by calendar id (for the left-hand toggle list)
|
||||||
* └─ events flat list of VEVENTs in that range
|
* └─ 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,36 +49,36 @@ class CalendarController extends Controller
|
|||||||
$timeFormat = $user->getSetting('app.time_format', '12');
|
$timeFormat = $user->getSetting('app.time_format', '12');
|
||||||
|
|
||||||
// settings
|
// settings
|
||||||
$defaultView = $user->getSetting('calendar.last_view', 'month');
|
$defaults = $settingsPersister->defaults($user, $tz);
|
||||||
$defaultDate = $user->getSetting('calendar.last_date', Carbon::today($tz)->toDateString());
|
$weekStart = $defaults['week_start'];
|
||||||
$defaultDensity = (int) $user->getSetting('calendar.last_density', 30);
|
$weekEnd = $defaults['week_end'];
|
||||||
|
|
||||||
// week start preference
|
|
||||||
$weekStartPref = $user->getSetting('calendar.week_start', 'sunday'); // 'sunday'|'monday'
|
|
||||||
$weekStartPref = in_array($weekStartPref, ['sunday', 'monday'], true)
|
|
||||||
? $weekStartPref
|
|
||||||
: 'sunday';
|
|
||||||
$weekStart = $weekStartPref === 'monday' ? Carbon::MONDAY : Carbon::SUNDAY;
|
|
||||||
$weekEnd = (int) (($weekStart + 6) % 7);
|
|
||||||
|
|
||||||
// get the view and time range
|
// get the view and time range
|
||||||
[$view, $range] = $this->resolveRange($request, $tz, $weekStart, $weekEnd, $defaultView, $defaultDate);
|
[$view, $range] = $rangeResolver->resolveRange(
|
||||||
$today = Carbon::today($tz)->toDateString();
|
$request,
|
||||||
|
$tz,
|
||||||
|
$weekStart,
|
||||||
|
$weekEnd,
|
||||||
|
$defaults['view'],
|
||||||
|
$defaults['date']
|
||||||
|
);
|
||||||
|
|
||||||
// get the display density, if present (in minutes for each step)
|
$density = $settingsPersister->resolveDensity($request, $defaults['density']);
|
||||||
$stepMinutes = (int) $request->query('density', $defaultDensity);
|
$stepMinutes = $density['step'];
|
||||||
if (! in_array($stepMinutes, [15, 30, 60], true)) { // lock it down
|
$labelEvery = $density['label_every'];
|
||||||
$stepMinutes = 30;
|
|
||||||
}
|
$daytimeHoursEnabled = $settingsPersister->resolveDaytimeHours($request, $defaults['daytime_hours']);
|
||||||
$labelEvery = match ($stepMinutes) { // how many 15-min slots per label/row
|
$daytimeHoursRange = $settingsPersister->daytimeHoursRange();
|
||||||
15 => 1,
|
$daytimeHoursRows = $daytimeHoursEnabled
|
||||||
30 => 2,
|
? intdiv((($daytimeHoursRange['end'] - $daytimeHoursRange['start']) * 60), 15)
|
||||||
60 => 4,
|
: 96;
|
||||||
};
|
$daytimeHoursForView = ($daytimeHoursEnabled && in_array($view, ['day', 'week', 'four'], true))
|
||||||
|
? $daytimeHoursRange
|
||||||
|
: null;
|
||||||
|
|
||||||
// date range span and controls
|
// date range span and controls
|
||||||
$span = $this->gridSpan($view, $range, $weekStart, $weekEnd);
|
$span = $rangeResolver->gridSpan($view, $range, $weekStart, $weekEnd);
|
||||||
$nav = $this->navDates($view, $range['start'], $tz);
|
$nav = $rangeResolver->navDates($view, $range['start'], $tz);
|
||||||
|
|
||||||
// get the user's visible calendars from the left bar
|
// get the user's visible calendars from the left bar
|
||||||
$visible = collect($request->query('c', []));
|
$visible = collect($request->query('c', []));
|
||||||
@ -87,11 +87,14 @@ class CalendarController extends Controller
|
|||||||
$anchorDate = $request->query('date', now($tz)->toDateString());
|
$anchorDate = $request->query('date', now($tz)->toDateString());
|
||||||
|
|
||||||
// persist settings
|
// persist settings
|
||||||
if ($request->hasAny(['view', 'date', 'density'])) {
|
$settingsPersister->persist(
|
||||||
$user->setSetting('calendar.last_view', $view);
|
$user,
|
||||||
$user->setSetting('calendar.last_date', $range['start']->toDateString());
|
$request,
|
||||||
$user->setSetting('calendar.last_density', (string) $stepMinutes);
|
$view,
|
||||||
}
|
$range['start'],
|
||||||
|
$stepMinutes,
|
||||||
|
$daytimeHoursEnabled
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@ -121,7 +124,8 @@ class CalendarController extends Controller
|
|||||||
$span['end']
|
$span['end']
|
||||||
);
|
);
|
||||||
|
|
||||||
$events = $this->buildEventPayloads(
|
// build event payload
|
||||||
|
$events = $viewBuilder->buildEventPayloads(
|
||||||
$events,
|
$events,
|
||||||
$calendar_map,
|
$calendar_map,
|
||||||
$timeFormat,
|
$timeFormat,
|
||||||
@ -130,6 +134,7 @@ class CalendarController extends Controller
|
|||||||
$tz,
|
$tz,
|
||||||
$recurrence,
|
$recurrence,
|
||||||
$span,
|
$span,
|
||||||
|
$daytimeHoursForView,
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -139,10 +144,9 @@ class CalendarController extends Controller
|
|||||||
|
|
||||||
// create the mini calendar grid based on the mini cal controls
|
// create the mini calendar grid based on the mini cal controls
|
||||||
$mini_anchor = $request->query('mini', $range['start']->toDateString());
|
$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 = $mini_anchor_date->copy()->startOfMonth();
|
||||||
$mini_start = Carbon::createFromFormat('Y-m-d', $mini_anchor, $tz)
|
|
||||||
->startOfMonth();
|
|
||||||
|
|
||||||
$mini_nav = [
|
$mini_nav = [
|
||||||
'prev' => $mini_start->copy()->subMonth()->toDateString(),
|
'prev' => $mini_start->copy()->subMonth()->toDateString(),
|
||||||
@ -150,7 +154,7 @@ class CalendarController extends Controller
|
|||||||
'today' => Carbon::today($tz)->startOfMonth()->toDateString(),
|
'today' => Carbon::today($tz)->startOfMonth()->toDateString(),
|
||||||
'label' => $mini_start->format('F Y'),
|
'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)
|
// compute the mini's 42-day span (Mon..Sun, 6 rows)
|
||||||
$mini_grid_start = $mini_start->copy()->startOfWeek($weekStart);
|
$mini_grid_start = $mini_start->copy()->startOfWeek($weekStart);
|
||||||
@ -166,7 +170,7 @@ class CalendarController extends Controller
|
|||||||
$mini_grid_end
|
$mini_grid_end
|
||||||
);
|
);
|
||||||
|
|
||||||
$mini_events = $this->buildEventPayloads(
|
$mini_events = $viewBuilder->buildEventPayloads(
|
||||||
$mini_events,
|
$mini_events,
|
||||||
$calendar_map,
|
$calendar_map,
|
||||||
$timeFormat,
|
$timeFormat,
|
||||||
@ -175,10 +179,11 @@ 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)
|
||||||
$mini = $this->buildMiniGrid($mini_start, $mini_events, $tz, $weekStart, $weekEnd);
|
$mini = $viewBuilder->buildMiniGrid($mini_start, $mini_events, $tz, $weekStart, $weekEnd);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@ -186,10 +191,10 @@ class CalendarController extends Controller
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// create the calendar grid of days
|
// 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
|
// 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
|
// format the data for the frontend, including separate arrays for events specifically and the big grid
|
||||||
$payload = [
|
$payload = [
|
||||||
@ -197,16 +202,28 @@ class CalendarController extends Controller
|
|||||||
'range' => $range,
|
'range' => $range,
|
||||||
'nav' => $nav,
|
'nav' => $nav,
|
||||||
'header' => $header,
|
'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' => [
|
'active' => [
|
||||||
'date' => $range['start']->toDateString(),
|
'date' => $range['start']->toDateString(),
|
||||||
'year' => $range['start']->format('Y'),
|
'year' => $range['start']->format('Y'),
|
||||||
'month' => $range['start']->format("F"),
|
'month' => $range['start']->format("F"),
|
||||||
'day' => $range['start']->format("d"),
|
'day' => $range['start']->format("d"),
|
||||||
],
|
],
|
||||||
'week_start' => $weekStart,
|
'daytime_hours' => [
|
||||||
|
'enabled' => $daytimeHoursEnabled,
|
||||||
|
'start' => $daytimeHoursRange['start'],
|
||||||
|
'end' => $daytimeHoursRange['end'],
|
||||||
|
'rows' => $daytimeHoursRows,
|
||||||
|
],
|
||||||
|
'timezone' => $tz,
|
||||||
'calendars' => $calendars->mapWithKeys(function ($cal)
|
'calendars' => $calendars->mapWithKeys(function ($cal)
|
||||||
{
|
{
|
||||||
// compute colors
|
|
||||||
$color = $cal->meta_color
|
$color = $cal->meta_color
|
||||||
?? $cal->calendarcolor
|
?? $cal->calendarcolor
|
||||||
?? default_calendar_color();
|
?? default_calendar_color();
|
||||||
@ -225,24 +242,29 @@ 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,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// time-based payload values
|
// time-based payload values
|
||||||
$timeBased = in_array($view, ['day', 'week', 'four'], true);
|
$timeBased = in_array($view, ['day', 'week', 'four'], true);
|
||||||
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'] = $viewBuilder->timeSlots(
|
||||||
|
$range['start'],
|
||||||
|
$tz,
|
||||||
|
$timeFormat,
|
||||||
|
$daytimeHoursEnabled ? $daytimeHoursRange : 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'] = $viewBuilder->nowIndicator(
|
||||||
|
$view,
|
||||||
|
$range,
|
||||||
|
$tz,
|
||||||
|
15,
|
||||||
|
1,
|
||||||
|
$daytimeHoursEnabled ? $daytimeHoursRange : 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
|
||||||
@ -379,633 +401,4 @@ class CalendarController extends Controller
|
|||||||
* Private helpers
|
* Private helpers
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* Prepare nav dates for the movement buttons in the calendar header
|
|
||||||
*
|
|
||||||
* @return array
|
|
||||||
* [
|
|
||||||
* 'prev' => 2026-08-19
|
|
||||||
* 'next' => 2026-08-21
|
|
||||||
* 'today' => 2026-08-20
|
|
||||||
* ]
|
|
||||||
*/
|
|
||||||
private function navDates(string $view, Carbon $start, string $tz): array
|
|
||||||
{
|
|
||||||
// always compute in the user tz so the UX is consistent
|
|
||||||
$start = $start->copy()->tz($tz);
|
|
||||||
|
|
||||||
return match ($view)
|
|
||||||
{
|
|
||||||
'day' => [
|
|
||||||
'prev' => $start->copy()->subDay()->toDateString(),
|
|
||||||
'next' => $start->copy()->addDay()->toDateString(),
|
|
||||||
'today' => Carbon::today($tz)->toDateString(),
|
|
||||||
],
|
|
||||||
'week' => [
|
|
||||||
'prev' => $start->copy()->subWeek()->toDateString(),
|
|
||||||
'next' => $start->copy()->addWeek()->toDateString(),
|
|
||||||
'today' => Carbon::today($tz)->toDateString(),
|
|
||||||
],
|
|
||||||
'four' => [
|
|
||||||
'prev' => $start->copy()->subDays(4)->toDateString(),
|
|
||||||
'next' => $start->copy()->addDays(4)->toDateString(),
|
|
||||||
'today' => Carbon::today($tz)->toDateString(),
|
|
||||||
],
|
|
||||||
default => [ // month
|
|
||||||
'prev' => $start->copy()->subMonth()->startOfMonth()->toDateString(),
|
|
||||||
'next' => $start->copy()->addMonth()->startOfMonth()->toDateString(),
|
|
||||||
'today' => Carbon::today($tz)->toDateString(),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* Interpret $view and $date filters and normalize into a Carbon range
|
|
||||||
*
|
|
||||||
* @return array
|
|
||||||
* [
|
|
||||||
* $view,
|
|
||||||
* [
|
|
||||||
* 'start' => Carbon,
|
|
||||||
* 'end' => Carbon
|
|
||||||
* ]
|
|
||||||
* ]
|
|
||||||
*/
|
|
||||||
private function resolveRange(
|
|
||||||
Request $request,
|
|
||||||
string $tz,
|
|
||||||
int $weekStart,
|
|
||||||
int $weekEnd,
|
|
||||||
string $defaultView,
|
|
||||||
string $defaultDate
|
|
||||||
): array {
|
|
||||||
// get the view
|
|
||||||
$requestView = $request->query('view', $defaultView);
|
|
||||||
$view = in_array($requestView, self::VIEWS, true)
|
|
||||||
? $requestView
|
|
||||||
: 'month';
|
|
||||||
$date = $request->query('date', $defaultDate);
|
|
||||||
|
|
||||||
// anchor date in the user's timezone
|
|
||||||
$anchor = Carbon::createFromFormat('Y-m-d', $date, $tz)->startOfDay();
|
|
||||||
|
|
||||||
// set dates based on view
|
|
||||||
switch ($view)
|
|
||||||
{
|
|
||||||
case 'day':
|
|
||||||
$start = $anchor->copy()->startOfDay();
|
|
||||||
$end = $anchor->copy()->endOfDay();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'week':
|
|
||||||
$start = $anchor->copy()->startOfWeek($weekStart);
|
|
||||||
$end = $anchor->copy()->endOfWeek($weekEnd);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'four':
|
|
||||||
// a rolling 4-day "agenda" view starting at anchor
|
|
||||||
$start = $anchor->copy()->startOfDay();
|
|
||||||
$end = $anchor->copy()->addDays(3)->endOfDay();
|
|
||||||
break;
|
|
||||||
|
|
||||||
default: // month
|
|
||||||
$start = $anchor->copy()->startOfMonth();
|
|
||||||
$end = $anchor->copy()->endOfMonth();
|
|
||||||
}
|
|
||||||
|
|
||||||
return [$view, ['start' => $start, 'end' => $end]];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create the grid for each view with pre- and post-padding if needed
|
|
||||||
*
|
|
||||||
* This is different from resolveRange() as the rendered span != logical range
|
|
||||||
*
|
|
||||||
* Month: start of the month's first week to end of the last week
|
|
||||||
* Week: Sunday to Saturday
|
|
||||||
* Four: selected day to 3 days later
|
|
||||||
* Day: start of the day to the end of the day
|
|
||||||
*/
|
|
||||||
private function gridSpan(string $view, array $range, int $weekStart, int $weekEnd): array
|
|
||||||
{
|
|
||||||
switch ($view)
|
|
||||||
{
|
|
||||||
case 'day':
|
|
||||||
$start = $range['start']->copy()->startOfDay();
|
|
||||||
$end = $range['start']->copy()->endOfDay();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'week':
|
|
||||||
$start = $range['start']->copy()->startOfWeek($weekStart);
|
|
||||||
$end = $range['start']->copy()->endOfWeek($weekEnd);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'four':
|
|
||||||
$start = $range['start']->copy()->startOfDay();
|
|
||||||
$end = $range['start']->copy()->addDays(3);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default: // month
|
|
||||||
$start = $range['start']->copy()->startOfMonth()->startOfWeek($weekStart);
|
|
||||||
$end = $range['end']->copy()->endOfMonth()->endOfWeek($weekEnd);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['start' => $start, 'end' => $end];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* Prepare the calendar title based on the view
|
|
||||||
*
|
|
||||||
* @return array
|
|
||||||
* [
|
|
||||||
* 'strong' => 'August 20',
|
|
||||||
* 'span' => '2026',
|
|
||||||
* ]
|
|
||||||
*/
|
|
||||||
private function headerTitle(string $view, Carbon $start, Carbon $end): array
|
|
||||||
{
|
|
||||||
$sameDay = $start->isSameDay($end);
|
|
||||||
$sameMonth = $start->isSameMonth($end);
|
|
||||||
$sameYear = $start->isSameYear($end);
|
|
||||||
|
|
||||||
// month default
|
|
||||||
$strong = $start->format('F');
|
|
||||||
$span = $start->format('Y');
|
|
||||||
|
|
||||||
if ($view === 'day' || $sameDay) {
|
|
||||||
return [
|
|
||||||
'strong' => $start->format('F j'),
|
|
||||||
'span' => $start->format('Y'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (in_array($view, ['week', 'four'], true)) {
|
|
||||||
if ($sameMonth && $sameYear) {
|
|
||||||
return [
|
|
||||||
'strong' => $start->format('F j') . ' to ' . $end->format('j'),
|
|
||||||
'span' => $start->format('Y'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($sameYear) {
|
|
||||||
return [
|
|
||||||
'strong' => $start->format('F') . ' to ' . $end->format('F'),
|
|
||||||
'span' => $start->format('Y'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'strong' => $start->format('F Y') . ' to ' . $end->format('F Y'),
|
|
||||||
'span' => null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['strong' => $strong, 'span' => $span];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* Create the time gutter for time-based views
|
|
||||||
*/
|
|
||||||
private function timeSlots(Carbon $dayStart, string $tz, string $timeFormat): array
|
|
||||||
{
|
|
||||||
$minutesPerSlot = 15;
|
|
||||||
$slotsPerDay = intdiv(24 * 60, $minutesPerSlot); // 96
|
|
||||||
|
|
||||||
$format = $timeFormat === '24' ? 'H:i' : 'g:i a';
|
|
||||||
|
|
||||||
$slots = [];
|
|
||||||
$t = $dayStart->copy()->tz($tz)->startOfDay();
|
|
||||||
|
|
||||||
for ($i = 0; $i < $slotsPerDay; $i++) {
|
|
||||||
$slots[] = [
|
|
||||||
'iso' => $t->toIso8601String(),
|
|
||||||
'label' => $t->format($format),
|
|
||||||
'key' => $t->format('H:i'), // stable "machine" value
|
|
||||||
'index' => $i, // 0..95
|
|
||||||
'minutes' => $i * $minutesPerSlot,
|
|
||||||
'duration' => $minutesPerSlot, // handy for styling math
|
|
||||||
];
|
|
||||||
|
|
||||||
$t->addMinutes($minutesPerSlot);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $slots;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* Time-based layout slot placement
|
|
||||||
*
|
|
||||||
* Placements object:
|
|
||||||
* [
|
|
||||||
* 'start_line' => '24',
|
|
||||||
* 'end_line' => '32',
|
|
||||||
* 'span' => '4',
|
|
||||||
* 'duration' => '60',
|
|
||||||
* ]
|
|
||||||
**/
|
|
||||||
private function slotPlacement(
|
|
||||||
Carbon $startLocal,
|
|
||||||
?Carbon $endLocal,
|
|
||||||
Carbon $rangeStart,
|
|
||||||
string $view,
|
|
||||||
int $minutesPerSlot = 15
|
|
||||||
): array
|
|
||||||
{
|
|
||||||
$start = $startLocal->copy();
|
|
||||||
$end = ($endLocal ?? $startLocal)->copy();
|
|
||||||
|
|
||||||
// get the real duration in minutes
|
|
||||||
$durationMinutes = max(0, $start->diffInMinutes($end, false));
|
|
||||||
|
|
||||||
// duration for display purposes
|
|
||||||
$displayMinutes = $durationMinutes > 0 ? $durationMinutes : $minutesPerSlot;
|
|
||||||
|
|
||||||
// row placement (96 rows when minutesPerSlot=15)
|
|
||||||
$startMinutesFromMidnight = ($start->hour * 60) + $start->minute;
|
|
||||||
$startRow = intdiv($startMinutesFromMidnight, $minutesPerSlot) + 1;
|
|
||||||
|
|
||||||
$rowSpan = max(1, (int) ceil($displayMinutes / $minutesPerSlot));
|
|
||||||
$endRow = $startRow + $rowSpan;
|
|
||||||
|
|
||||||
// column placement
|
|
||||||
$maxCols = match ($view) {
|
|
||||||
'day' => 1,
|
|
||||||
'four' => 4,
|
|
||||||
'week' => 7,
|
|
||||||
default => 1, // month won’t use this
|
|
||||||
};
|
|
||||||
// rangeStart is already the "first day column" for week/four/day
|
|
||||||
$startCol = $rangeStart->copy()->startOfDay()->diffInDays($start->copy()->startOfDay()) + 1;
|
|
||||||
|
|
||||||
// clamp to view columns
|
|
||||||
$startCol = max(1, min($maxCols, $startCol));
|
|
||||||
|
|
||||||
return [
|
|
||||||
'start_row' => $startRow,
|
|
||||||
'end_row' => $endRow,
|
|
||||||
'row_span' => $rowSpan,
|
|
||||||
'duration' => $durationMinutes,
|
|
||||||
'start_col' => $startCol,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Expand events (including recurrence) into view-ready payloads.
|
|
||||||
*/
|
|
||||||
private function buildEventPayloads(
|
|
||||||
Collection $events,
|
|
||||||
Collection $calendarMap,
|
|
||||||
string $timeFormat,
|
|
||||||
string $view,
|
|
||||||
array $range,
|
|
||||||
string $tz,
|
|
||||||
EventRecurrence $recurrence,
|
|
||||||
array $span
|
|
||||||
): Collection {
|
|
||||||
$uiFormat = $timeFormat === '24' ? 'H:i' : 'g:ia';
|
|
||||||
$spanStartUtc = $span['start']->copy()->utc();
|
|
||||||
$spanEndUtc = $span['end']->copy()->utc();
|
|
||||||
|
|
||||||
return $events->flatMap(function ($e) use (
|
|
||||||
$calendarMap,
|
|
||||||
$uiFormat,
|
|
||||||
$view,
|
|
||||||
$range,
|
|
||||||
$tz,
|
|
||||||
$recurrence,
|
|
||||||
$spanStartUtc,
|
|
||||||
$spanEndUtc
|
|
||||||
) {
|
|
||||||
$cal = $calendarMap[$e->calendarid];
|
|
||||||
$timezone = $cal->timezone ?? config('app.timezone');
|
|
||||||
|
|
||||||
$color = $cal['meta_color']
|
|
||||||
?? $cal['calendarcolor']
|
|
||||||
?? default_calendar_color();
|
|
||||||
$colorFg = $cal['meta_color_fg']
|
|
||||||
?? contrast_text_color($color);
|
|
||||||
|
|
||||||
$occurrences = [];
|
|
||||||
$isRecurring = $recurrence->isRecurring($e);
|
|
||||||
|
|
||||||
if ($isRecurring) {
|
|
||||||
$occurrences = $recurrence->expand($e, $spanStartUtc, $spanEndUtc);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($occurrences) && !$isRecurring) {
|
|
||||||
$startUtc = $e->meta?->start_at
|
|
||||||
? Carbon::parse($e->meta->start_at)->utc()
|
|
||||||
: Carbon::createFromTimestamp($e->firstoccurence, 'UTC');
|
|
||||||
$endUtc = $e->meta?->end_at
|
|
||||||
? Carbon::parse($e->meta->end_at)->utc()
|
|
||||||
: ($e->lastoccurence
|
|
||||||
? Carbon::createFromTimestamp($e->lastoccurence, 'UTC')
|
|
||||||
: $startUtc->copy());
|
|
||||||
|
|
||||||
$occurrences[] = [
|
|
||||||
'start' => $startUtc,
|
|
||||||
'end' => $endUtc,
|
|
||||||
'recurrence_id' => null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return collect($occurrences)->map(function ($occ) use (
|
|
||||||
$e,
|
|
||||||
$cal,
|
|
||||||
$uiFormat,
|
|
||||||
$view,
|
|
||||||
$range,
|
|
||||||
$tz,
|
|
||||||
$timezone,
|
|
||||||
$color,
|
|
||||||
$colorFg
|
|
||||||
) {
|
|
||||||
$startUtc = $occ['start'];
|
|
||||||
$endUtc = $occ['end'];
|
|
||||||
|
|
||||||
$startLocal = $startUtc->copy()->timezone($timezone);
|
|
||||||
$endLocal = $endUtc->copy()->timezone($timezone);
|
|
||||||
|
|
||||||
$startForGrid = $startUtc->copy()->tz($tz);
|
|
||||||
$endForGrid = $endUtc->copy()->tz($tz);
|
|
||||||
|
|
||||||
$placement = $this->slotPlacement(
|
|
||||||
$startForGrid,
|
|
||||||
$endForGrid,
|
|
||||||
$range['start']->copy()->tz($tz),
|
|
||||||
$view,
|
|
||||||
15
|
|
||||||
);
|
|
||||||
|
|
||||||
$occurrenceId = $occ['recurrence_id']
|
|
||||||
? ($e->id . ':' . $occ['recurrence_id'])
|
|
||||||
: (string) $e->id;
|
|
||||||
|
|
||||||
return [
|
|
||||||
'id' => $e->id,
|
|
||||||
'occurrence_id' => $occurrenceId,
|
|
||||||
'occurrence' => $occ['recurrence_id']
|
|
||||||
? $startUtc->toIso8601String()
|
|
||||||
: null,
|
|
||||||
'calendar_id' => $e->calendarid,
|
|
||||||
'calendar_slug' => $cal->slug,
|
|
||||||
'title' => $e->meta->title ?? 'No title',
|
|
||||||
'description' => $e->meta->description ?? 'No description.',
|
|
||||||
'start' => $startUtc->toIso8601String(),
|
|
||||||
'end' => $endUtc->toIso8601String(),
|
|
||||||
'start_ui' => $startLocal->format($uiFormat),
|
|
||||||
'end_ui' => $endLocal->format($uiFormat),
|
|
||||||
'timezone' => $timezone,
|
|
||||||
'visible' => $cal->visible,
|
|
||||||
'color' => $color,
|
|
||||||
'color_fg' => $colorFg,
|
|
||||||
'start_row' => $placement['start_row'],
|
|
||||||
'end_row' => $placement['end_row'],
|
|
||||||
'row_span' => $placement['row_span'],
|
|
||||||
'start_col' => $placement['start_col'],
|
|
||||||
'duration' => $placement['duration'],
|
|
||||||
];
|
|
||||||
});
|
|
||||||
})->keyBy('occurrence_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* Assemble an array of day-objects for the requested view.
|
|
||||||
*
|
|
||||||
* Day object shape:
|
|
||||||
* [
|
|
||||||
* 'date' => '2025-07-14',
|
|
||||||
* 'label' => '14', // two-digit day number
|
|
||||||
* 'in_month' => true|false, // helpful for grey-out styling
|
|
||||||
* 'events' => [ …event payloads… ]
|
|
||||||
* ]
|
|
||||||
*
|
|
||||||
* For the "month" view the return value also contains
|
|
||||||
* 'weeks' => [ [7 day-objs], [7 day-objs], … ]
|
|
||||||
*/
|
|
||||||
private function buildCalendarGrid(
|
|
||||||
string $view,
|
|
||||||
array $range,
|
|
||||||
Collection $events,
|
|
||||||
string $tz,
|
|
||||||
int $weekStart,
|
|
||||||
int $weekEnd): array
|
|
||||||
{
|
|
||||||
// use the same span the events were fetched for (month padded to full weeks, etc.)
|
|
||||||
['start' => $grid_start, 'end' => $grid_end] = $this->gridSpan($view, $range, $weekStart, $weekEnd);
|
|
||||||
|
|
||||||
// today checks
|
|
||||||
$today = Carbon::today($tz)->toDateString();
|
|
||||||
|
|
||||||
// index events by YYYY-MM-DD for quick lookup
|
|
||||||
$events_by_day = [];
|
|
||||||
foreach ($events as $ev)
|
|
||||||
{
|
|
||||||
$evTz = $ev['timezone'] ?? $tz;
|
|
||||||
$start = Carbon::parse($ev['start'])->tz($evTz);
|
|
||||||
$end = $ev['end'] ? Carbon::parse($ev['end'])->tz($evTz) : $start;
|
|
||||||
|
|
||||||
for ($d = $start->copy()->startOfDay();
|
|
||||||
$d->lte($end->copy()->endOfDay());
|
|
||||||
$d->addDay()) {
|
|
||||||
|
|
||||||
$key = $d->toDateString();
|
|
||||||
$events_by_day[$key][] = $ev['occurrence_id'] ?? $ev['id'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// view span bounds and build day objects
|
|
||||||
$days = [];
|
|
||||||
for ($day = $grid_start->copy(); $day->lte($grid_end); $day->addDay()) {
|
|
||||||
$iso = $day->toDateString();
|
|
||||||
|
|
||||||
$days[] = [
|
|
||||||
'date' => $iso,
|
|
||||||
'label' => $day->format('j'),
|
|
||||||
'in_month' => $day->month === $range['start']->month,
|
|
||||||
'is_today' => $day->isSameDay($today),
|
|
||||||
'events' => array_fill_keys($events_by_day[$iso] ?? [], true),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $view === 'month'
|
|
||||||
? ['days' => $days, 'weeks' => array_chunk($days, 7)]
|
|
||||||
: ['days' => $days];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* Create the specific view's date headers
|
|
||||||
*
|
|
||||||
* For day/week/four, this is an array of number dates and their day name,
|
|
||||||
* for month it's the user's preferred weekdays
|
|
||||||
*/
|
|
||||||
private function viewHeaders(string $view, array $range, string $tz, int $weekStart): array
|
|
||||||
{
|
|
||||||
$start = $range['start']->copy()->tz($tz);
|
|
||||||
$end = $range['end']->copy()->tz($tz);
|
|
||||||
$today = Carbon::today($tz)->toDateString();
|
|
||||||
|
|
||||||
// month view: weekday headers (Sunday..Saturday for now)
|
|
||||||
if ($view === 'month') {
|
|
||||||
return collect($this->weekdayHeaders($tz, $weekStart))
|
|
||||||
->map(fn ($h) => $h + ['is_today' => false])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
// day/week/four: column headers for each day in range
|
|
||||||
$headers = [];
|
|
||||||
for ($d = $start->copy()->startOfDay(); $d->lte($end); $d->addDay()) {
|
|
||||||
$date = $d->toDateString();
|
|
||||||
$headers[] = [
|
|
||||||
'date' => $d->toDateString(), // 2026-01-31
|
|
||||||
'day' => $d->format('j'), // 31
|
|
||||||
'dow' => $d->translatedFormat('l'), // Saturday (localized)
|
|
||||||
'dow_short' => $d->translatedFormat('D'), // Sat (localized)
|
|
||||||
'month' => $d->translatedFormat('M'), // Jan (localized)
|
|
||||||
'is_today' => $date === $today, // flag for viewing real today
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* Specific headers for month views (full and mini)
|
|
||||||
*/
|
|
||||||
private function weekdayHeaders(string $tz, int $weekStart): array
|
|
||||||
{
|
|
||||||
$headers = [];
|
|
||||||
$d = Carbon::now($tz)->startOfWeek($weekStart);
|
|
||||||
|
|
||||||
for ($i = 0; $i < 7; $i++) {
|
|
||||||
$headers[] = [
|
|
||||||
// stable key (0..6 from the start day)
|
|
||||||
'key' => $i,
|
|
||||||
// Sun, Mon...
|
|
||||||
'label' => $d->translatedFormat('D'),
|
|
||||||
];
|
|
||||||
$d->addDay();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* Build the mini-month grid for day buttons in the bottom left of the UI
|
|
||||||
*
|
|
||||||
* Returns ['days' => [
|
|
||||||
* [
|
|
||||||
* 'date' => '2025-06-30',
|
|
||||||
* 'label' => '30',
|
|
||||||
* 'in_month' => false,
|
|
||||||
* 'events' => [id, id …]
|
|
||||||
* ], …
|
|
||||||
* ]]
|
|
||||||
*/
|
|
||||||
private function buildMiniGrid(
|
|
||||||
Carbon $monthStart,
|
|
||||||
Collection $events,
|
|
||||||
string $tz,
|
|
||||||
int $weekStart,
|
|
||||||
int $weekEnd): array
|
|
||||||
{
|
|
||||||
$monthStart = $monthStart->copy()->tz($tz);
|
|
||||||
$monthEnd = $monthStart->copy()->endOfMonth();
|
|
||||||
|
|
||||||
$gridStart = $monthStart->copy()->startOfWeek($weekStart);
|
|
||||||
$gridEnd = $monthEnd->copy()->endOfWeek($weekEnd);
|
|
||||||
|
|
||||||
if ($gridStart->diffInDays($gridEnd) + 1 < 42) {
|
|
||||||
$gridEnd->addWeek();
|
|
||||||
}
|
|
||||||
|
|
||||||
$today = Carbon::today($tz)->toDateString();
|
|
||||||
|
|
||||||
// map event ids by yyyy-mm-dd in USER tz (so indicators match what user sees)
|
|
||||||
$byDay = [];
|
|
||||||
foreach ($events as $ev) {
|
|
||||||
$s = Carbon::parse($ev['start'])->tz($tz);
|
|
||||||
$e = $ev['end'] ? Carbon::parse($ev['end'])->tz($tz) : $s;
|
|
||||||
|
|
||||||
for ($d = $s->copy()->startOfDay(); $d->lte($e->copy()->endOfDay()); $d->addDay()) {
|
|
||||||
$byDay[$d->toDateString()][] = $ev['occurrence_id'] ?? $ev['id'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$days = [];
|
|
||||||
for ($d = $gridStart->copy(); $d->lte($gridEnd); $d->addDay()) {
|
|
||||||
$iso = $d->toDateString();
|
|
||||||
$days[] = [
|
|
||||||
'date' => $iso,
|
|
||||||
'label' => $d->format('j'),
|
|
||||||
'in_month' => $d->between($monthStart, $monthEnd),
|
|
||||||
'is_today' => $iso === $today,
|
|
||||||
'events' => $byDay[$iso] ?? [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['days' => $days];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Place the "now" indicator on the grid if we're in a time-based view and on the current day
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* [
|
|
||||||
* 'show' => bool,
|
|
||||||
* 'row' => int, // 1..96 (15-min grid line)
|
|
||||||
* 'col_start' => int, // grid column start
|
|
||||||
* 'col_end' => int, // grid column end
|
|
||||||
* ]
|
|
||||||
*/
|
|
||||||
private function nowIndicator(string $view, array $range, string $tz, int $minutesPerSlot = 15, int $gutterCols = 1): array
|
|
||||||
{
|
|
||||||
// only meaningful for time-based views
|
|
||||||
if (!in_array($view, ['day', 'week', 'four'], true)) {
|
|
||||||
return ['show' => false, 'row' => 1, 'day_col' => 1, 'col_start' => 1, 'col_end' => 2];
|
|
||||||
}
|
|
||||||
|
|
||||||
$now = Carbon::now($tz);
|
|
||||||
|
|
||||||
$start = $range['start']->copy()->tz($tz)->startOfDay();
|
|
||||||
$end = $range['end']->copy()->tz($tz)->endOfDay();
|
|
||||||
|
|
||||||
// show only if "now" is inside the visible range
|
|
||||||
if (! $now->betweenIncluded($start, $end)) {
|
|
||||||
return ['show' => false, 'row' => 1, 'day_col' => 1, 'col_start' => 1, 'col_end' => 2];
|
|
||||||
}
|
|
||||||
|
|
||||||
// row: minutes since midnight, snapped down to slot size
|
|
||||||
$minutes = ($now->hour * 60) + $now->minute;
|
|
||||||
$snapped = intdiv($minutes, $minutesPerSlot) * $minutesPerSlot;
|
|
||||||
$row = intdiv($snapped, $minutesPerSlot) + 1; // 1-based
|
|
||||||
|
|
||||||
// column: 1..N where 1 is the first day column in the events grid
|
|
||||||
if ($view === 'day') {
|
|
||||||
$dayCol = 1;
|
|
||||||
} else {
|
|
||||||
// IMPORTANT: compare dates at midnight to avoid fractional diffs
|
|
||||||
$todayStart = $now->copy()->startOfDay();
|
|
||||||
$dayCol = $start->diffInDays($todayStart) + 1; // 1..7 or 1..4
|
|
||||||
}
|
|
||||||
|
|
||||||
$dayCol = (int) $dayCol;
|
|
||||||
|
|
||||||
return [
|
|
||||||
'show' => true,
|
|
||||||
'row' => (int) $row,
|
|
||||||
'day_col' => $dayCol,
|
|
||||||
'col_start' => $dayCol,
|
|
||||||
'col_end' => $dayCol + 1,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
165
app/Services/Calendar/CalendarRangeResolver.php
Normal file
165
app/Services/Calendar/CalendarRangeResolver.php
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Calendar;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class CalendarRangeResolver
|
||||||
|
{
|
||||||
|
public const VIEWS = ['day', 'week', 'month', 'four'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize view + date inputs into a resolved view and date range.
|
||||||
|
*
|
||||||
|
* @return array [$view, ['start' => 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
83
app/Services/Calendar/CalendarSettingsPersister.php
Normal file
83
app/Services/Calendar/CalendarSettingsPersister.php
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Calendar;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class CalendarSettingsPersister
|
||||||
|
{
|
||||||
|
public function defaults(User $user, string $tz): array
|
||||||
|
{
|
||||||
|
$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);
|
||||||
|
$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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
455
app/Services/Calendar/CalendarViewBuilder.php
Normal file
455
app/Services/Calendar/CalendarViewBuilder.php
Normal file
@ -0,0 +1,455 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Calendar;
|
||||||
|
|
||||||
|
use App\Services\Event\EventRecurrence;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class CalendarViewBuilder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Expand events (including recurrence) into view-ready payloads.
|
||||||
|
*/
|
||||||
|
public function buildEventPayloads(
|
||||||
|
Collection $events,
|
||||||
|
Collection $calendarMap,
|
||||||
|
string $timeFormat,
|
||||||
|
string $view,
|
||||||
|
array $range,
|
||||||
|
string $tz,
|
||||||
|
EventRecurrence $recurrence,
|
||||||
|
array $span,
|
||||||
|
?array $daytimeHours = null
|
||||||
|
): Collection {
|
||||||
|
$uiFormat = $timeFormat === '24' ? 'H:i' : 'g:ia';
|
||||||
|
$spanStartUtc = $span['start']->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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -227,7 +227,7 @@ ICS;
|
|||||||
string $rrule,
|
string $rrule,
|
||||||
string $tz
|
string $tz
|
||||||
) use ($calId, $locationIdMap, $locationSeeds, $recurrence) {
|
) use ($calId, $locationIdMap, $locationSeeds, $recurrence) {
|
||||||
$uid = Str::uuid().'@kithkin.lan';
|
$uid = Str::uuid().'@'.parse_url(config('app.url'), PHP_URL_HOST);
|
||||||
$end = $start->copy()->addHour();
|
$end = $start->copy()->addHour();
|
||||||
|
|
||||||
$startUtc = $start->copy()->utc();
|
$startUtc = $start->copy()->utc();
|
||||||
@ -289,7 +289,7 @@ ICS;
|
|||||||
$now = Carbon::now($tz)->setSeconds(0);
|
$now = Carbon::now($tz)->setSeconds(0);
|
||||||
|
|
||||||
// 3 events today
|
// 3 events today
|
||||||
$insertEvent($now->copy(), 'Playground with James', 'McCaHill Park');
|
$insertEvent($now->copy(), 'Playground with James', 'McCahill Park');
|
||||||
$insertEvent($now->copy()->addHours(2), 'Lunch with Daniel', 'Home');
|
$insertEvent($now->copy()->addHours(2), 'Lunch with Daniel', 'Home');
|
||||||
$insertEvent($now->copy()->addHours(4), 'Baseball practice', 'Meadow Park');
|
$insertEvent($now->copy()->addHours(4), 'Baseball practice', 'Meadow Park');
|
||||||
|
|
||||||
|
|||||||
@ -74,7 +74,7 @@ button,
|
|||||||
> label,
|
> label,
|
||||||
> button {
|
> button {
|
||||||
@apply relative flex items-center justify-center h-full pl-3.5 pr-3 cursor-pointer;
|
@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;
|
transition: outline 125ms ease-in-out;
|
||||||
box-shadow: var(--shadows);
|
box-shadow: var(--shadows);
|
||||||
--shadows: none;
|
--shadows: none;
|
||||||
|
|||||||
@ -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;
|
||||||
@ -149,8 +149,8 @@
|
|||||||
|
|
||||||
/* event positioning */
|
/* event positioning */
|
||||||
ol.events {
|
ol.events {
|
||||||
@apply grid pt-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;
|
||||||
@ -188,17 +188,25 @@
|
|||||||
|
|
||||||
/* bottom controls */
|
/* bottom controls */
|
||||||
footer {
|
footer {
|
||||||
@apply bg-white flex items-end justify-end col-span-2 border-t-md border-primary z-10;
|
@apply bg-white flex items-center justify-between col-span-2 border-t-md border-primary;
|
||||||
@apply sticky bottom-0 pb-8;
|
@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 */
|
/* now indicator */
|
||||||
.now-indicator {
|
.now-indicator {
|
||||||
@apply relative pointer-events-none z-2 border-t-3 border-red-600 opacity-90 -ml-2;
|
@apply relative pointer-events-none z-10 border-t-3 border-red-600 opacity-90 -ml-2;
|
||||||
grid-row: var(--now-row);
|
grid-row: var(--now-row);
|
||||||
grid-column: var(--now-col-start) / var(--now-col-end);
|
grid-column: var(--now-col-start) / var(--now-col-end);
|
||||||
width: calc(100% + 1rem);
|
width: calc(100% + 1rem);
|
||||||
top: 0.6rem;
|
top: calc(0.6rem + (var(--row-height) * var(--now-offset, 0)));
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
@apply block w-3 h-3 rounded-full bg-red-600 -translate-y-1/2 -mt-[1.5px];
|
@apply block w-3 h-3 rounded-full bg-red-600 -translate-y-1/2 -mt-[1.5px];
|
||||||
@ -381,4 +389,3 @@
|
|||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -74,6 +74,7 @@
|
|||||||
:view="$view"
|
:view="$view"
|
||||||
:density="$density"
|
:density="$density"
|
||||||
:headers="$mini_headers"
|
:headers="$mini_headers"
|
||||||
|
:daytime_hours="$daytime_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="daytime_hours" value="{{ (int) ($daytime_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,10 +150,23 @@
|
|||||||
|
|
||||||
{{-- 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'] }}">
|
||||||
<x-button.group-input value="day" :active="$view === 'day'">Day</x-button.group-input>
|
<input type="hidden" name="daytime_hours" value="{{ (int) ($daytime_hours['enabled'] ?? 0) }}">
|
||||||
<x-button.group-input value="week" :active="$view === 'week'">Week</x-button.group-input>
|
<x-button.group-input value="day" :active="$view === 'day'">
|
||||||
<x-button.group-input value="month" :active="$view === 'month'">Month</x-button.group-input>
|
<span class="lg:hidden">1</span>
|
||||||
<x-button.group-input value="four" :active="$view === 'four'">3-Up</x-button.group-input>
|
<span class="hidden lg:inline">Day</span>
|
||||||
|
</x-button.group-input>
|
||||||
|
<x-button.group-input value="week" :active="$view === 'week'">
|
||||||
|
<span class="lg:hidden">7</span>
|
||||||
|
<span class="hidden lg:inline">Week</span>
|
||||||
|
</x-button.group-input>
|
||||||
|
<x-button.group-input value="month" :active="$view === 'month'">
|
||||||
|
<span class="lg:hidden">31</span>
|
||||||
|
<span class="hidden lg:inline">Month</span>
|
||||||
|
</x-button.group-input>
|
||||||
|
<x-button.group-input value="four" :active="$view === 'four'">
|
||||||
|
<span class="lg:hidden">4</span>
|
||||||
|
<span class="hidden lg:inline">4-day</span>
|
||||||
|
</x-button.group-input>
|
||||||
<noscript><button type="submit" class="button">Apply</button></noscript>
|
<noscript><button type="submit" class="button">Apply</button></noscript>
|
||||||
</form>
|
</form>
|
||||||
<li>
|
<li>
|
||||||
@ -176,6 +191,8 @@
|
|||||||
:active="$active"
|
:active="$active"
|
||||||
:density="$density"
|
:density="$density"
|
||||||
:weekstart="$week_start"
|
:weekstart="$week_start"
|
||||||
|
:daytime_hours="$daytime_hours"
|
||||||
|
:timezone="$timezone"
|
||||||
:now="$now"
|
:now="$now"
|
||||||
/>
|
/>
|
||||||
@break
|
@break
|
||||||
@ -190,6 +207,7 @@
|
|||||||
:hgroup="$hgroup"
|
:hgroup="$hgroup"
|
||||||
:active="$active"
|
:active="$active"
|
||||||
:density="$density"
|
:density="$density"
|
||||||
|
:daytime_hours="$daytime_hours"
|
||||||
:now="$now"
|
:now="$now"
|
||||||
/>
|
/>
|
||||||
@break
|
@break
|
||||||
@ -204,6 +222,7 @@
|
|||||||
:hgroup="$hgroup"
|
:hgroup="$hgroup"
|
||||||
:active="$active"
|
:active="$active"
|
||||||
:density="$density"
|
:density="$density"
|
||||||
|
:daytime_hours="$daytime_hours"
|
||||||
:now="$now"
|
:now="$now"
|
||||||
/>
|
/>
|
||||||
@break
|
@break
|
||||||
|
|||||||
@ -9,11 +9,12 @@
|
|||||||
'active' => [],
|
'active' => [],
|
||||||
'density' => '30',
|
'density' => '30',
|
||||||
'now' => [],
|
'now' => [],
|
||||||
|
'daytime_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-offset: {{ $now['offset'] ?? 0 }}; --now-col-start: {{ $now['col_start'] }}; --now-col-end: {{ $now['col_end'] }}; --grid-rows: {{ $daytime_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.daytime-hours view="day" :density="$density" :daytime_hours="$daytime_hours" />
|
||||||
|
<x-calendar.time.density view="day" :density="$density" :daytime_hours="$daytime_hours" />
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -9,14 +9,17 @@
|
|||||||
'active' => [],
|
'active' => [],
|
||||||
'density' => '30',
|
'density' => '30',
|
||||||
'now' => [],
|
'now' => [],
|
||||||
|
'daytime_hours' => [],
|
||||||
])
|
])
|
||||||
|
|
||||||
<section
|
<section
|
||||||
class="calendar {{ $class }}" data-density="{{ $density['step'] }}"
|
class="calendar {{ $class }}" data-density="{{ $density['step'] }}"
|
||||||
style=
|
style=
|
||||||
"--now-row: {{ (int) $now['row'] }};
|
"--now-row: {{ (int) $now['row'] }};
|
||||||
|
--now-offset: {{ $now['offset'] ?? 0 }};
|
||||||
--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: {{ $daytime_hours['rows'] ?? 96 }};"
|
||||||
>
|
>
|
||||||
<hgroup>
|
<hgroup>
|
||||||
@foreach ($hgroup as $h)
|
@foreach ($hgroup as $h)
|
||||||
@ -42,6 +45,7 @@
|
|||||||
@endif
|
@endif
|
||||||
</ol>
|
</ol>
|
||||||
<footer>
|
<footer>
|
||||||
<x-calendar.time.density view="four" :density="$density" />
|
<x-calendar.time.daytime-hours view="four" :density="$density" :daytime_hours="$daytime_hours" />
|
||||||
|
<x-calendar.time.density view="four" :density="$density" :daytime_hours="$daytime_hours" />
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
'class' => '',
|
'class' => '',
|
||||||
'density' => [],
|
'density' => [],
|
||||||
'headers' => [],
|
'headers' => [],
|
||||||
|
'daytime_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="daytime_hours" value="{{ (int) ($daytime_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="daytime_hours" value="{{ (int) ($daytime_hours['enabled'] ?? 0) }}">
|
||||||
|
|
||||||
@foreach ($mini['days'] as $day)
|
@foreach ($mini['days'] as $day)
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -0,0 +1,40 @@
|
|||||||
|
@props([
|
||||||
|
'daytime_hours' => [],
|
||||||
|
'view' => 'day',
|
||||||
|
'density' => [],
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$enabled = (int) ($daytime_hours['enabled'] ?? 0) === 1;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<form id="calendar-daytime-hours"
|
||||||
|
method="get"
|
||||||
|
class="inline-flex items-center gap-2 text-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 }}">
|
||||||
|
|
||||||
|
{{-- unchecked checkboxes don't submit a value --}}
|
||||||
|
<input type="hidden" name="daytime_hours" value="0">
|
||||||
|
<x-input.checkbox-label
|
||||||
|
name="daytime_hours"
|
||||||
|
value="1"
|
||||||
|
label="{{ __('Daytime hours') }}"
|
||||||
|
:checked="$enabled"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<noscript>
|
||||||
|
<button type="submit" class="button">{{ __('Apply') }}</button>
|
||||||
|
</noscript>
|
||||||
|
</form>
|
||||||
@ -1,6 +1,7 @@
|
|||||||
@props([
|
@props([
|
||||||
'density' => [],
|
'density' => [],
|
||||||
'view' => 'day',
|
'view' => 'day',
|
||||||
|
'daytime_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="daytime_hours" value="{{ (int) ($daytime_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>
|
||||||
|
|||||||
@ -10,14 +10,18 @@
|
|||||||
'density' => '30',
|
'density' => '30',
|
||||||
'weekstart' => 0,
|
'weekstart' => 0,
|
||||||
'now' => [],
|
'now' => [],
|
||||||
|
'daytime_hours' => [],
|
||||||
|
'timezone' => 'UTC',
|
||||||
])
|
])
|
||||||
|
|
||||||
<section
|
<section
|
||||||
class="calendar {{ $class }}" data-density="{{ $density['step'] }}" data-weekstart="{{ $weekstart }}"
|
class="calendar {{ $class }}" data-density="{{ $density['step'] }}" data-weekstart="{{ $weekstart }}"
|
||||||
style=
|
style=
|
||||||
"--now-row: {{ (int) $now['row'] }};
|
"--now-row: {{ (int) $now['row'] }};
|
||||||
|
--now-offset: {{ $now['offset'] ?? 0 }};
|
||||||
--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: {{ $daytime_hours['rows'] ?? 96 }};"
|
||||||
>
|
>
|
||||||
<hgroup>
|
<hgroup>
|
||||||
@foreach ($hgroup as $h)
|
@foreach ($hgroup as $h)
|
||||||
@ -43,6 +47,12 @@
|
|||||||
@endif
|
@endif
|
||||||
</ol>
|
</ol>
|
||||||
<footer>
|
<footer>
|
||||||
<x-calendar.time.density view="week" :density="$density" />
|
<div class="left">
|
||||||
|
<a href="{{ route('account.locale') }}" class="timezone">{{ $timezone }}</a>
|
||||||
|
</div>
|
||||||
|
<div class="right">
|
||||||
|
<x-calendar.time.daytime-hours view="week" :density="$density" :daytime_hours="$daytime_hours" />
|
||||||
|
<x-calendar.time.density view="week" :density="$density" :daytime_hours="$daytime_hours" />
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
37
tests/Unit/CalendarRangeResolverTest.php
Normal file
37
tests/Unit/CalendarRangeResolverTest.php
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Services\Calendar\CalendarRangeResolver;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
test('safeDate falls back to default on invalid date', function () {
|
||||||
|
$resolver = new CalendarRangeResolver();
|
||||||
|
$tz = 'America/New_York';
|
||||||
|
|
||||||
|
$date = $resolver->safeDate('nope', $tz, '2026-02-04');
|
||||||
|
|
||||||
|
expect($date->toDateString())->toBe('2026-02-04');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveRange handles four-day view', function () {
|
||||||
|
$resolver = new CalendarRangeResolver();
|
||||||
|
$tz = 'America/New_York';
|
||||||
|
|
||||||
|
$request = Request::create('/calendar', 'GET', [
|
||||||
|
'view' => 'four',
|
||||||
|
'date' => '2026-02-04',
|
||||||
|
]);
|
||||||
|
|
||||||
|
[$view, $range] = $resolver->resolveRange(
|
||||||
|
$request,
|
||||||
|
$tz,
|
||||||
|
Carbon::SUNDAY,
|
||||||
|
Carbon::SATURDAY,
|
||||||
|
'month',
|
||||||
|
'2026-02-04'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($view)->toBe('four');
|
||||||
|
expect($range['start']->toDateString())->toBe('2026-02-04');
|
||||||
|
expect($range['end']->toDateString())->toBe('2026-02-07');
|
||||||
|
});
|
||||||
50
tests/Unit/CalendarViewBuilderTest.php
Normal file
50
tests/Unit/CalendarViewBuilderTest.php
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Services\Calendar\CalendarViewBuilder;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
test('timeSlots returns full day when no daytime hours', function () {
|
||||||
|
$builder = new CalendarViewBuilder();
|
||||||
|
$tz = 'America/New_York';
|
||||||
|
$dayStart = Carbon::create(2026, 2, 4, 0, 0, 0, $tz);
|
||||||
|
|
||||||
|
$slots = $builder->timeSlots($dayStart, $tz, '12');
|
||||||
|
|
||||||
|
expect($slots)->toHaveCount(96);
|
||||||
|
expect($slots[0]['key'])->toBe('00:00');
|
||||||
|
expect($slots[95]['key'])->toBe('23:45');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('timeSlots respects daytime hours', function () {
|
||||||
|
$builder = new CalendarViewBuilder();
|
||||||
|
$tz = 'America/New_York';
|
||||||
|
$dayStart = Carbon::create(2026, 2, 4, 0, 0, 0, $tz);
|
||||||
|
|
||||||
|
$slots = $builder->timeSlots($dayStart, $tz, '12', ['start' => 8, 'end' => 18]);
|
||||||
|
|
||||||
|
expect($slots)->toHaveCount(40);
|
||||||
|
expect($slots[0]['key'])->toBe('08:00');
|
||||||
|
expect($slots[39]['key'])->toBe('17:45');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nowIndicator uses minute-accurate offset in daytime hours', function () {
|
||||||
|
$builder = new CalendarViewBuilder();
|
||||||
|
$tz = 'America/New_York';
|
||||||
|
|
||||||
|
Carbon::setTestNow(Carbon::create(2026, 2, 4, 11, 37, 0, $tz));
|
||||||
|
|
||||||
|
$range = [
|
||||||
|
'start' => Carbon::create(2026, 2, 4, 0, 0, 0, $tz),
|
||||||
|
'end' => Carbon::create(2026, 2, 4, 23, 59, 59, $tz),
|
||||||
|
];
|
||||||
|
|
||||||
|
$now = $builder->nowIndicator('day', $range, $tz, 15, 1, ['start' => 8, 'end' => 18]);
|
||||||
|
|
||||||
|
expect($now['show'])->toBeTrue();
|
||||||
|
expect($now['row'])->toBe(15);
|
||||||
|
expect($now['offset'])->toBeGreaterThan(0.46);
|
||||||
|
expect($now['offset'])->toBeLessThan(0.47);
|
||||||
|
expect($now['col_start'])->toBe(1);
|
||||||
|
|
||||||
|
Carbon::setTestNow();
|
||||||
|
});
|
||||||
@ -1,5 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
test('that true is true', function () {
|
|
||||||
expect(true)->toBeTrue();
|
|
||||||
});
|
|
||||||
Loading…
Reference in New Issue
Block a user