WIP: February 2026 event improvements and calendar refactor #1

Draft
andrew wants to merge 10 commits from feb-2026-event-improvements into master
48 changed files with 2406 additions and 926 deletions

View File

@ -20,9 +20,9 @@ class PasswordController extends Controller
'password' => ['required', Password::defaults(), 'confirmed'], 'password' => ['required', Password::defaults(), 'confirmed'],
]); ]);
$request->user()->update([ $request->user()->forceFill([
'password' => Hash::make($validated['password']), 'password' => Hash::make($validated['password']),
]); ])->save();
return back()->with('status', 'password-updated'); return back()->with('status', 'password-updated');
} }

View File

@ -35,11 +35,11 @@ class RegisteredUserController extends Controller
'password' => ['required', 'confirmed', Rules\Password::defaults()], 'password' => ['required', 'confirmed', Rules\Password::defaults()],
]); ]);
$user = User::create([ $user = new User();
'name' => $request->name, $user->name = $request->name;
'email' => $request->email, $user->email = $request->email;
'password' => Hash::make($request->password), $user->password = $request->password;
]); $user->save();
event(new Registered($user)); event(new Registered($user));

View File

@ -4,21 +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;
class CalendarController extends Controller class CalendarController extends Controller
{ {
private const VIEWS = ['day', 'week', 'month', 'four'];
/** /**
* Consolidated calendar dashboard. * Consolidated calendar dashboard.
* *
@ -32,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) public function index(
Request $request,
EventRecurrence $recurrence,
CalendarRangeResolver $rangeResolver,
CalendarViewBuilder $viewBuilder,
CalendarSettingsPersister $settingsPersister
)
{ {
/** /**
* *
@ -48,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', []));
@ -86,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
);
/** /**
* *
@ -118,78 +122,20 @@ class CalendarController extends Controller
$calendars->pluck('id'), $calendars->pluck('id'),
$span['start'], $span['start'],
$span['end'] $span['end']
)->map(function ($e) use ($calendar_map, $timeFormat, $view, $range, $tz, $weekStart, $weekEnd) { );
// event's calendar // build event payload
$cal = $calendar_map[$e->calendarid]; $events = $viewBuilder->buildEventPayloads(
$events,
// get utc dates from the database $calendar_map,
$start_utc = $e->meta->start_at ?? $timeFormat,
Carbon::createFromTimestamp($e->firstoccurence); $view,
$end_utc = $e->meta->end_at ?? $range,
($e->lastoccurence ? Carbon::createFromTimestamp($e->lastoccurence) : null); $tz,
$recurrence,
// time format handling $span,
$uiFormat = $timeFormat === '24' ? 'H:i' : 'g:ia'; $daytimeHoursForView,
);
// convert to calendar timezone
$timezone = $calendar_map[$e->calendarid]->timezone ?? config('app.timezone');
$start_local = $start_utc->copy()->timezone($timezone);
$end_local = optional($end_utc)->copy()->timezone($timezone);
// convert utc to user tz for grid placement (columns/rows must match view headers)
$start_for_grid = $start_utc->copy()->tz($tz);
$end_for_grid = optional($end_utc)->copy()->tz($tz);
// placement for time-based layouts
$placement = $this->slotPlacement(
$start_for_grid,
$end_for_grid,
$range['start']->copy()->tz($tz),
$view,
15
);
// color handling
$color = $cal['meta_color']
?? $cal['calendarcolor']
?? default_calendar_color();
$colorFg = $cal['meta_color_fg']
?? contrast_text_color($color);
logger()->info('event times', [
'id' => $e->id,
'start_at' => optional($e->meta)->start_at,
'end_at' => optional($e->meta)->end_at,
'firstoccurence' => $e->firstoccurence,
'lastoccurence' => $e->lastoccurence,
]);
// return events array
return [
// core data
'id' => $e->id,
'calendar_id' => $e->calendarid,
'calendar_slug' => $cal->slug,
'title' => $e->meta->title ?? 'No title',
'description' => $e->meta->description ?? 'No description.',
'start' => $start_utc->toIso8601String(),
'end' => optional($end_utc)->toIso8601String(),
'start_ui' => $start_local->format($uiFormat),
'end_ui' => optional($end_local)->format($uiFormat),
'timezone' => $timezone,
'visible' => $cal->visible,
'color' => $color,
'color_fg' => $colorFg,
// slot placement for time-based grid
'start_row' => $placement['start_row'],
'end_row' => $placement['end_row'],
'row_span' => $placement['row_span'],
'start_col' => $placement['start_col'],
'duration' => $placement['duration'],
];
})->keyBy('id');
/** /**
* *
@ -198,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(),
@ -209,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);
@ -223,35 +168,22 @@ class CalendarController extends Controller
$calendars->pluck('id'), $calendars->pluck('id'),
$mini_grid_start, $mini_grid_start,
$mini_grid_end $mini_grid_end
)->map(function ($e) use ($calendar_map, $tz) { );
$cal = $calendar_map[$e->calendarid];
$start_utc = $e->meta->start_at ?? Carbon::createFromTimestamp($e->firstoccurence); $mini_events = $viewBuilder->buildEventPayloads(
$end_utc = $e->meta->end_at ?? ($e->lastoccurence ? Carbon::createFromTimestamp($e->lastoccurence) : null); $mini_events,
$calendar_map,
$color = $cal->meta_color $timeFormat,
?? $cal->calendarcolor $view,
?? default_calendar_color(); ['start' => $mini_grid_start, 'end' => $mini_grid_end],
$colorFg = $cal->meta_color_fg $tz,
?? contrast_text_color($color); $recurrence,
['start' => $mini_grid_start, 'end' => $mini_grid_end],
return [ null,
'id' => $e->id, );
'calendar_id' => $e->calendarid,
'calendar_slug' => $cal->slug,
'title' => $e->meta->title ?? 'No title',
'description' => $e->meta->description ?? 'No description.',
'start' => $start_utc->toIso8601String(),
'end' => optional($end_utc)->toIso8601String(),
'timezone' => $tz,
'visible' => $cal->visible,
'color' => $color,
'color_fg' => $colorFg,
];
})->keyBy('id');
// now build the mini from mini_events (not from $events) // 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);
/** /**
* *
@ -259,27 +191,39 @@ 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 = [
'view' => $view, 'view' => $view,
'range' => $range, 'range' => $range,
'nav' => $nav, 'nav' => $nav,
'header' => $header, 'header' => $header,
'active' => [ 'week_start' => $weekStart,
'date' => $range['start']->toDateString(), 'hgroup' => $viewBuilder->viewHeaders($view, $range, $tz, $weekStart),
'year' => $range['start']->format('Y'), 'events' => $events, // keyed by occurrence
'month' => $range['start']->format("F"), 'grid' => $grid, // day objects hold only ID-sets
'day' => $range['start']->format("d"), '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, 'daytime_hours' => [
'calendars' => $calendars->mapWithKeys(function ($cal) 'enabled' => $daytimeHoursEnabled,
'start' => $daytimeHoursRange['start'],
'end' => $daytimeHoursRange['end'],
'rows' => $daytimeHoursRows,
],
'timezone' => $tz,
'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();
@ -298,24 +242,29 @@ class CalendarController extends Controller
], ],
]; ];
}), }),
'hgroup' => $this->viewHeaders($view, $range, $tz, $weekStart),
'events' => $events, // keyed, one copy each
'grid' => $grid, // day objects hold only ID-sets
'mini' => $mini, // mini calendar days with events for indicators
'mini_nav' => $mini_nav, // separate mini calendar navigation
'mini_headers' => $mini_headers,
]; ];
// time-based payload values // 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
@ -452,513 +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 wont use this
};
// rangeStart is already the "first day column" for week/four/day
$startCol = $rangeStart->copy()->startOfDay()->diffInDays($start->copy()->startOfDay()) + 1;
// clamp to view columns
$startCol = max(1, min($maxCols, $startCol));
return [
'start_row' => $startRow,
'end_row' => $endRow,
'row_span' => $rowSpan,
'duration' => $durationMinutes,
'start_col' => $startCol,
];
}
/**
*
* Assemble an array of day-objects for the requested view.
*
* Day object shape:
* [
* 'date' => '2025-07-14',
* 'label' => '14', // two-digit day number
* 'in_month' => true|false, // helpful for grey-out styling
* 'events' => [ …event payloads… ]
* ]
*
* For the "month" view the return value also contains
* 'weeks' => [ [7 day-objs], [7 day-objs], ]
*/
private function buildCalendarGrid(
string $view,
array $range,
Collection $events,
string $tz,
int $weekStart,
int $weekEnd): array
{
// use the same span the events were fetched for (month padded to full weeks, etc.)
['start' => $grid_start, 'end' => $grid_end] = $this->gridSpan($view, $range, $weekStart, $weekEnd);
// today checks
$today = Carbon::today($tz)->toDateString();
// index events by YYYY-MM-DD for quick lookup
$events_by_day = [];
foreach ($events as $ev)
{
$evTz = $ev['timezone'] ?? $tz;
$start = Carbon::parse($ev['start'])->tz($evTz);
$end = $ev['end'] ? Carbon::parse($ev['end'])->tz($evTz) : $start;
for ($d = $start->copy()->startOfDay();
$d->lte($end->copy()->endOfDay());
$d->addDay()) {
$key = $d->toDateString();
$events_by_day[$key][] = $ev['id'];
}
}
// view span bounds and build day objects
$days = [];
for ($day = $grid_start->copy(); $day->lte($grid_end); $day->addDay()) {
$iso = $day->toDateString();
$days[] = [
'date' => $iso,
'label' => $day->format('j'),
'in_month' => $day->month === $range['start']->month,
'is_today' => $day->isSameDay($today),
'events' => array_fill_keys($events_by_day[$iso] ?? [], true),
];
}
return $view === 'month'
? ['days' => $days, 'weeks' => array_chunk($days, 7)]
: ['days' => $days];
}
/**
*
* Create the specific view's date headers
*
* For day/week/four, this is an array of number dates and their day name,
* for month it's the user's preferred weekdays
*/
private function viewHeaders(string $view, array $range, string $tz, int $weekStart): array
{
$start = $range['start']->copy()->tz($tz);
$end = $range['end']->copy()->tz($tz);
$today = Carbon::today($tz)->toDateString();
// month view: weekday headers (Sunday..Saturday for now)
if ($view === 'month') {
return collect($this->weekdayHeaders($tz, $weekStart))
->map(fn ($h) => $h + ['is_today' => false])
->all();
}
// day/week/four: column headers for each day in range
$headers = [];
for ($d = $start->copy()->startOfDay(); $d->lte($end); $d->addDay()) {
$date = $d->toDateString();
$headers[] = [
'date' => $d->toDateString(), // 2026-01-31
'day' => $d->format('j'), // 31
'dow' => $d->translatedFormat('l'), // Saturday (localized)
'dow_short' => $d->translatedFormat('D'), // Sat (localized)
'month' => $d->translatedFormat('M'), // Jan (localized)
'is_today' => $date === $today, // flag for viewing real today
];
}
return $headers;
}
/**
*
* Specific headers for month views (full and mini)
*/
private function weekdayHeaders(string $tz, int $weekStart): array
{
$headers = [];
$d = Carbon::now($tz)->startOfWeek($weekStart);
for ($i = 0; $i < 7; $i++) {
$headers[] = [
// stable key (0..6 from the start day)
'key' => $i,
// Sun, Mon...
'label' => $d->translatedFormat('D'),
];
$d->addDay();
}
return $headers;
}
/**
*
* Build the mini-month grid for day buttons in the bottom left of the UI
*
* Returns ['days' => [
* [
* 'date' => '2025-06-30',
* 'label' => '30',
* 'in_month' => false,
* 'events' => [id, id ]
* ],
* ]]
*/
private function buildMiniGrid(
Carbon $monthStart,
Collection $events,
string $tz,
int $weekStart,
int $weekEnd): array
{
$monthStart = $monthStart->copy()->tz($tz);
$monthEnd = $monthStart->copy()->endOfMonth();
$gridStart = $monthStart->copy()->startOfWeek($weekStart);
$gridEnd = $monthEnd->copy()->endOfWeek($weekEnd);
if ($gridStart->diffInDays($gridEnd) + 1 < 42) {
$gridEnd->addWeek();
}
$today = Carbon::today($tz)->toDateString();
// map event ids by yyyy-mm-dd in USER tz (so indicators match what user sees)
$byDay = [];
foreach ($events as $ev) {
$s = Carbon::parse($ev['start'])->tz($tz);
$e = $ev['end'] ? Carbon::parse($ev['end'])->tz($tz) : $s;
for ($d = $s->copy()->startOfDay(); $d->lte($e->copy()->endOfDay()); $d->addDay()) {
$byDay[$d->toDateString()][] = $ev['id'];
}
}
$days = [];
for ($d = $gridStart->copy(); $d->lte($gridEnd); $d->addDay()) {
$iso = $d->toDateString();
$days[] = [
'date' => $iso,
'label' => $d->format('j'),
'in_month' => $d->between($monthStart, $monthEnd),
'is_today' => $iso === $today,
'events' => $byDay[$iso] ?? [],
];
}
return ['days' => $days];
}
/**
* Place the "now" indicator on the grid if we're in a time-based view and on the current day
*
* Returns:
* [
* 'show' => bool,
* 'row' => int, // 1..96 (15-min grid line)
* 'col_start' => int, // grid column start
* 'col_end' => int, // grid column end
* ]
*/
private function nowIndicator(string $view, array $range, string $tz, int $minutesPerSlot = 15, int $gutterCols = 1): array
{
// only meaningful for time-based views
if (!in_array($view, ['day', 'week', 'four'], true)) {
return ['show' => false, 'row' => 1, 'day_col' => 1, 'col_start' => 1, 'col_end' => 2];
}
$now = Carbon::now($tz);
$start = $range['start']->copy()->tz($tz)->startOfDay();
$end = $range['end']->copy()->tz($tz)->endOfDay();
// show only if "now" is inside the visible range
if (! $now->betweenIncluded($start, $end)) {
return ['show' => false, 'row' => 1, 'day_col' => 1, 'col_start' => 1, 'col_end' => 2];
}
// row: minutes since midnight, snapped down to slot size
$minutes = ($now->hour * 60) + $now->minute;
$snapped = intdiv($minutes, $minutesPerSlot) * $minutesPerSlot;
$row = intdiv($snapped, $minutesPerSlot) + 1; // 1-based
// column: 1..N where 1 is the first day column in the events grid
if ($view === 'day') {
$dayCol = 1;
} else {
// IMPORTANT: compare dates at midnight to avoid fractional diffs
$todayStart = $now->copy()->startOfDay();
$dayCol = $start->diffInDays($todayStart) + 1; // 1..7 or 1..4
}
$dayCol = (int) $dayCol;
return [
'show' => true,
'row' => (int) $row,
'day_col' => $dayCol,
'col_start' => $dayCol,
'col_end' => $dayCol + 1,
];
}
} }

View File

@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\Calendar; use App\Models\Calendar;
use App\Models\Event; use App\Models\Event;
use App\Models\Location; use App\Models\Location;
use App\Services\Event\EventRecurrence;
use App\Services\Location\Geocoder; use App\Services\Location\Geocoder;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@ -12,12 +13,11 @@ use Illuminate\Http\Request;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Sabre\VObject\Reader;
class EventController extends Controller class EventController extends Controller
{ {
/** /**
* create a new event page * create a new event
*/ */
public function create(Calendar $calendar, Request $request) public function create(Calendar $calendar, Request $request)
{ {
@ -47,6 +47,7 @@ class EventController extends Controller
$start = $anchor->copy()->format('Y-m-d\TH:i'); $start = $anchor->copy()->format('Y-m-d\TH:i');
$end = $anchor->copy()->addHour()->format('Y-m-d\TH:i'); $end = $anchor->copy()->addHour()->format('Y-m-d\TH:i');
$rrule = '';
return view('event.form', compact( return view('event.form', compact(
'calendar', 'calendar',
@ -55,13 +56,14 @@ class EventController extends Controller
'start', 'start',
'end', 'end',
'tz', 'tz',
'rrule',
)); ));
} }
/** /**
* edit event page * edit event
*/ */
public function edit(Calendar $calendar, Event $event, Request $request) public function edit(Calendar $calendar, Event $event, Request $request, EventRecurrence $recurrence)
{ {
$this->authorize('update', $calendar); $this->authorize('update', $calendar);
@ -83,13 +85,17 @@ class EventController extends Controller
? Carbon::parse($event->meta->end_at)->timezone($tz)->format('Y-m-d\TH:i') ? Carbon::parse($event->meta->end_at)->timezone($tz)->format('Y-m-d\TH:i')
: null; : null;
return view('event.form', compact('calendar', 'instance', 'event', 'start', 'end', 'tz')); $rrule = $event->meta?->extra['rrule']
?? $recurrence->extractRrule($event)
?? '';
return view('event.form', compact('calendar', 'instance', 'event', 'start', 'end', 'tz', 'rrule'));
} }
/** /**
* single event view handling * single event view handling
*/ */
public function show(Request $request, Calendar $calendar, Event $event) public function show(Request $request, Calendar $calendar, Event $event, EventRecurrence $recurrence)
{ {
if ((int) $event->calendarid !== (int) $calendar->id) { if ((int) $event->calendarid !== (int) $calendar->id) {
abort(Response::HTTP_NOT_FOUND); abort(Response::HTTP_NOT_FOUND);
@ -102,16 +108,29 @@ class EventController extends Controller
$isHtmx = $request->header('HX-Request') === 'true'; $isHtmx = $request->header('HX-Request') === 'true';
$tz = $this->displayTimezone($calendar, $request); $tz = $this->displayTimezone($calendar, $request);
// prefer meta utc timestamps, fall back to sabre columns // prefer occurrence when supplied (recurring events), fall back to meta, then sabre columns
$startUtc = $event->meta?->start_at $occurrenceParam = $request->query('occurrence');
? Carbon::parse($event->meta->start_at)->utc() $occurrenceStart = null;
: Carbon::createFromTimestamp($event->firstoccurence, 'UTC'); if ($occurrenceParam) {
try {
$occurrenceStart = Carbon::parse($occurrenceParam)->utc();
} catch (\Throwable $e) {
$occurrenceStart = null;
}
}
$occurrence = $occurrenceStart
? $recurrence->resolveOccurrence($event, $occurrenceStart)
: null;
$endUtc = $event->meta?->end_at $startUtc = $occurrence['start'] ?? ($event->meta?->start_at
? Carbon::parse($event->meta->start_at)->utc()
: Carbon::createFromTimestamp($event->firstoccurence, 'UTC'));
$endUtc = $occurrence['end'] ?? ($event->meta?->end_at
? Carbon::parse($event->meta->end_at)->utc() ? Carbon::parse($event->meta->end_at)->utc()
: ($event->lastoccurence : ($event->lastoccurence
? Carbon::createFromTimestamp($event->lastoccurence, 'UTC') ? Carbon::createFromTimestamp($event->lastoccurence, 'UTC')
: $startUtc->copy()); : $startUtc->copy()));
// convert for display // convert for display
$start = $startUtc->copy()->timezone($tz); $start = $startUtc->copy()->timezone($tz);
@ -127,7 +146,7 @@ class EventController extends Controller
/** /**
* insert vevent into sabres calendarobjects + meta row * insert vevent into sabres calendarobjects + meta row
*/ */
public function store(Request $request, Calendar $calendar, Geocoder $geocoder): RedirectResponse public function store(Request $request, Calendar $calendar, Geocoder $geocoder, EventRecurrence $recurrence): RedirectResponse
{ {
$this->authorize('update', $calendar); $this->authorize('update', $calendar);
@ -139,6 +158,7 @@ class EventController extends Controller
'location' => ['nullable', 'string'], 'location' => ['nullable', 'string'],
'all_day' => ['sometimes', 'boolean'], 'all_day' => ['sometimes', 'boolean'],
'category' => ['nullable', 'string', 'max:50'], 'category' => ['nullable', 'string', 'max:50'],
'rrule' => ['nullable', 'string', 'max:255'],
// normalized location hints (optional) // normalized location hints (optional)
'loc_display_name' => ['nullable', 'string'], 'loc_display_name' => ['nullable', 'string'],
@ -160,25 +180,19 @@ class EventController extends Controller
$uid = Str::uuid() . '@' . parse_url(config('app.url'), PHP_URL_HOST); $uid = Str::uuid() . '@' . parse_url(config('app.url'), PHP_URL_HOST);
$description = $this->escapeIcsText($data['description'] ?? ''); $rrule = $this->normalizeRrule($request);
$locationStr = $this->escapeIcsText($data['location'] ?? ''); $extra = $this->mergeRecurrenceExtra([], $rrule, $tz, $request);
// write dtstart/dtend as utc with "Z" so we have one canonical representation $ical = $recurrence->buildCalendar([
$ical = <<<ICS 'uid' => $uid,
BEGIN:VCALENDAR 'start_utc' => $startUtc,
VERSION:2.0 'end_utc' => $endUtc,
PRODID:-//Kithkin//Laravel CalDAV//EN 'summary' => $data['title'],
BEGIN:VEVENT 'description' => $data['description'] ?? '',
UID:$uid 'location' => $data['location'] ?? '',
DTSTAMP:{$startUtc->format('Ymd\\THis\\Z')} 'tzid' => $rrule ? $tz : null,
DTSTART:{$startUtc->format('Ymd\\THis\\Z')} 'rrule' => $rrule,
DTEND:{$endUtc->format('Ymd\\THis\\Z')} ]);
SUMMARY:{$this->escapeIcsText($data['title'])}
DESCRIPTION:$description
LOCATION:$locationStr
END:VEVENT
END:VCALENDAR
ICS;
$event = Event::create([ $event = Event::create([
'calendarid' => $calendar->id, 'calendarid' => $calendar->id,
@ -202,6 +216,7 @@ ICS;
'category' => $data['category'] ?? null, 'category' => $data['category'] ?? null,
'start_at' => $startUtc, 'start_at' => $startUtc,
'end_at' => $endUtc, 'end_at' => $endUtc,
'extra' => $extra,
]); ]);
return redirect()->route('calendar.show', $calendar); return redirect()->route('calendar.show', $calendar);
@ -210,7 +225,7 @@ ICS;
/** /**
* update vevent + meta * update vevent + meta
*/ */
public function update(Request $request, Calendar $calendar, Event $event): RedirectResponse public function update(Request $request, Calendar $calendar, Event $event, EventRecurrence $recurrence): RedirectResponse
{ {
$this->authorize('update', $calendar); $this->authorize('update', $calendar);
@ -226,6 +241,7 @@ ICS;
'location' => ['nullable', 'string'], 'location' => ['nullable', 'string'],
'all_day' => ['sometimes', 'boolean'], 'all_day' => ['sometimes', 'boolean'],
'category' => ['nullable', 'string', 'max:50'], 'category' => ['nullable', 'string', 'max:50'],
'rrule' => ['nullable', 'string', 'max:255'],
]); ]);
$tz = $this->displayTimezone($calendar, $request); $tz = $this->displayTimezone($calendar, $request);
@ -235,25 +251,23 @@ ICS;
$uid = $event->uid; $uid = $event->uid;
$description = $this->escapeIcsText($data['description'] ?? ''); $rrule = $this->normalizeRrule($request);
$locationStr = $this->escapeIcsText($data['location'] ?? ''); $extra = $event->meta?->extra ?? [];
$summary = $this->escapeIcsText($data['title']); $extra = $this->mergeRecurrenceExtra($extra, $rrule, $tz, $request);
$rruleForIcs = $rrule ?? ($extra['rrule'] ?? $recurrence->extractRrule($event));
$ical = <<<ICS $ical = $recurrence->buildCalendar([
BEGIN:VCALENDAR 'uid' => $uid,
VERSION:2.0 'start_utc' => $startUtc,
PRODID:-//Kithkin//Laravel CalDAV//EN 'end_utc' => $endUtc,
BEGIN:VEVENT 'summary' => $data['title'],
UID:$uid 'description' => $data['description'] ?? '',
DTSTAMP:{$startUtc->format('Ymd\\THis\\Z')} 'location' => $data['location'] ?? '',
DTSTART:{$startUtc->format('Ymd\\THis\\Z')} 'tzid' => $rruleForIcs ? $tz : null,
DTEND:{$endUtc->format('Ymd\\THis\\Z')} 'rrule' => $rruleForIcs,
SUMMARY:$summary 'exdate' => $extra['exdate'] ?? [],
DESCRIPTION:$description 'rdate' => $extra['rdate'] ?? [],
LOCATION:$locationStr ]);
END:VEVENT
END:VCALENDAR
ICS;
$event->update([ $event->update([
'calendardata' => $ical, 'calendardata' => $ical,
@ -269,6 +283,7 @@ ICS;
'category' => $data['category'] ?? null, 'category' => $data['category'] ?? null,
'start_at' => $startUtc, 'start_at' => $startUtc,
'end_at' => $endUtc, 'end_at' => $endUtc,
'extra' => $extra,
]); ]);
return redirect()->route('calendar.show', $calendar); return redirect()->route('calendar.show', $calendar);
@ -305,6 +320,55 @@ ICS;
return $text; return $text;
} }
private function normalizeRrule(Request $request): ?string
{
if (! $request->has('rrule')) {
return null;
}
$rrule = trim((string) $request->input('rrule'));
return $rrule === '' ? '' : $rrule;
}
private function mergeRecurrenceExtra(array $extra, ?string $rrule, string $tz, Request $request): array
{
if ($rrule === null) {
return $extra; // no change requested
}
if ($rrule === '') {
unset($extra['rrule'], $extra['exdate'], $extra['rdate'], $extra['tzid']);
return $extra;
}
$extra['rrule'] = $rrule;
$extra['tzid'] = $tz;
$extra['exdate'] = $this->normalizeDateList($request->input('exdate', $extra['exdate'] ?? []), $tz);
$extra['rdate'] = $this->normalizeDateList($request->input('rdate', $extra['rdate'] ?? []), $tz);
return $extra;
}
private function normalizeDateList(mixed $value, string $tz): array
{
if (is_string($value)) {
$value = array_filter(array_map('trim', explode(',', $value)));
}
if (! is_array($value)) {
return [];
}
return array_values(array_filter(array_map(function ($item) use ($tz) {
if (! $item) {
return null;
}
return Carbon::parse($item, $tz)->utc()->toIso8601String();
}, $value)));
}
/** /**
* resolve location_id from hints or geocoding * resolve location_id from hints or geocoding
*/ */

View File

@ -5,6 +5,8 @@ namespace App\Http\Controllers;
use App\Models\CalendarInstance; use App\Models\CalendarInstance;
use Illuminate\Support\Facades\Response; use Illuminate\Support\Facades\Response;
use Carbon\Carbon; use Carbon\Carbon;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Reader;
class IcsController extends Controller class IcsController extends Controller
{ {
@ -25,39 +27,48 @@ class IcsController extends Controller
protected function generateICalendarFeed($events, string $tz): string protected function generateICalendarFeed($events, string $tz): string
{ {
$output = []; $vcalendar = new VCalendar();
$output[] = 'BEGIN:VCALENDAR'; $vcalendar->add('VERSION', '2.0');
$output[] = 'VERSION:2.0'; $vcalendar->add('PRODID', '-//Kithkin Calendar//EN');
$output[] = 'PRODID:-//Kithkin Calendar//EN'; $vcalendar->add('CALSCALE', 'GREGORIAN');
$output[] = 'CALSCALE:GREGORIAN'; $vcalendar->add('METHOD', 'PUBLISH');
$output[] = 'METHOD:PUBLISH';
foreach ($events as $event) { foreach ($events as $event) {
$meta = $event->meta; $ical = $event->calendardata ?? null;
if ($ical) {
try {
$parsed = Reader::read($ical);
foreach ($parsed->select('VEVENT') as $vevent) {
$vcalendar->add(clone $vevent);
}
continue;
} catch (\Throwable $e) {
// fall through to meta-based output
}
}
$meta = $event->meta;
if (!$meta || !$meta->start_at || !$meta->end_at) { if (!$meta || !$meta->start_at || !$meta->end_at) {
continue; continue;
} }
$start = Carbon::parse($meta->start_at)->timezone($tz)->format('Ymd\THis'); $start = Carbon::parse($meta->start_at)->timezone($tz);
$end = Carbon::parse($meta->end_at)->timezone($tz)->format('Ymd\THis'); $end = Carbon::parse($meta->end_at)->timezone($tz);
$output[] = 'BEGIN:VEVENT'; $vevent = $vcalendar->add('VEVENT', []);
$output[] = 'UID:' . $event->uid; $vevent->add('UID', $event->uid);
$output[] = 'SUMMARY:' . $this->escape($meta->title ?? '(Untitled)'); $vevent->add('SUMMARY', $meta->title ?? '(Untitled)');
$output[] = 'DESCRIPTION:' . $this->escape($meta->description ?? ''); $vevent->add('DESCRIPTION', $meta->description ?? '');
$output[] = 'DTSTART;TZID=' . $tz . ':' . $start; $vevent->add('DTSTART', $start, ['TZID' => $tz]);
$output[] = 'DTEND;TZID=' . $tz . ':' . $end; $vevent->add('DTEND', $end, ['TZID' => $tz]);
$output[] = 'DTSTAMP:' . Carbon::parse($event->lastmodified)->format('Ymd\THis\Z'); $vevent->add('DTSTAMP', Carbon::parse($event->lastmodified)->utc());
if ($meta->location) { if ($meta->location) {
$output[] = 'LOCATION:' . $this->escape($meta->location); $vevent->add('LOCATION', $meta->location);
} }
$output[] = 'END:VEVENT';
} }
$output[] = 'END:VCALENDAR'; return $vcalendar->serialize();
return implode("\r\n", $output);
} }
protected function escape(?string $text): string protected function escape(?string $text): string

View File

@ -53,12 +53,22 @@ class Event extends Model
**/ **/
public function scopeInRange($query, $start, $end) public function scopeInRange($query, $start, $end)
{ {
return $query->whereHas('meta', function ($q) use ($start, $end) { return $query->where(function ($q) use ($start, $end) {
$q->where('start_at', '<=', $end) $q->whereHas('meta', function ($meta) use ($start, $end) {
->where(function ($qq) use ($start) { $meta->where(function ($range) use ($start, $end) {
$qq->where('end_at', '>=', $start) $range->where('start_at', '<=', $end)
->orWhereNull('end_at'); ->where(function ($bounds) use ($start) {
}); $bounds->where('end_at', '>=', $start)
->orWhereNull('end_at');
});
})
->orWhereNotNull('extra->rrule');
})
->orWhere(function ($ical) {
$ical->where('calendardata', 'like', '%RRULE%')
->orWhere('calendardata', 'like', '%RDATE%')
->orWhere('calendardata', 'like', '%EXDATE%');
});
}); });
} }

View File

@ -30,6 +30,7 @@ class User extends Authenticatable
'firstname', 'firstname',
'lastname', 'lastname',
'displayname', 'displayname',
'name',
'email', 'email',
'timezone', 'timezone',
'phone', 'phone',
@ -59,6 +60,62 @@ class User extends Authenticatable
]; ];
} }
/**
* Expose a Breeze-compatible "name" attribute without a physical column.
* Preference: displayname (explicit override), then first + last, then email.
*/
public function getNameAttribute(): string
{
$displayname = is_string($this->displayname) ? trim($this->displayname) : '';
if ($displayname !== '') {
return $displayname;
}
$first = is_string($this->firstname) ? trim($this->firstname) : '';
$last = is_string($this->lastname) ? trim($this->lastname) : '';
$full = trim($first . ' ' . $last);
if ($full !== '') {
return $full;
}
return (string) ($this->email ?? '');
}
/**
* Map "name" writes to first/last names, keeping displayname optional.
*/
public function setNameAttribute(?string $value): void
{
$incoming = trim((string) $value);
$currentFirst = is_string($this->attributes['firstname'] ?? null)
? trim((string) $this->attributes['firstname'])
: '';
$currentLast = is_string($this->attributes['lastname'] ?? null)
? trim((string) $this->attributes['lastname'])
: '';
$currentGenerated = trim($currentFirst . ' ' . $currentLast);
if ($incoming === '') {
$this->attributes['firstname'] = null;
$this->attributes['lastname'] = null;
return;
}
$parts = preg_split('/\s+/', $incoming, 2);
$this->attributes['firstname'] = $parts[0] ?? null;
$this->attributes['lastname'] = $parts[1] ?? null;
$displayname = is_string($this->attributes['displayname'] ?? null)
? trim((string) $this->attributes['displayname'])
: '';
if ($displayname !== '' && $displayname === $currentGenerated) {
$this->attributes['displayname'] = $incoming;
}
}
/** /**
* user can own many calendars * user can own many calendars
*/ */

View 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();
}
}
}

View 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');
}
}
}

View File

@ -0,0 +1,460 @@
<?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);
$payloads = $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();
})->filter();
// ensure chronological ordering across calendars for all views
return $payloads
->sortBy('start')
->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));
}
}

View File

@ -0,0 +1,185 @@
<?php
namespace App\Services\Event;
use App\Models\Event;
use Carbon\Carbon;
use DateTimeZone;
use Illuminate\Support\Str;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Reader;
use Sabre\VObject\Recur\EventIterator;
class EventRecurrence
{
/**
* Build a VCALENDAR string from core fields and optional recurrence.
*/
public function buildCalendar(array $data): string
{
$vcalendar = new VCalendar();
$vcalendar->add('PRODID', '-//Kithkin//Laravel CalDAV//EN');
$vcalendar->add('VERSION', '2.0');
$vcalendar->add('CALSCALE', 'GREGORIAN');
$vevent = $vcalendar->add('VEVENT', []);
$uid = $data['uid'];
$startUtc = $data['start_utc'];
$endUtc = $data['end_utc'];
$tzid = $data['tzid'] ?? null;
$vevent->add('UID', $uid);
$vevent->add('DTSTAMP', $startUtc->copy()->utc());
if ($tzid) {
$startLocal = $startUtc->copy()->tz($tzid);
$endLocal = $endUtc->copy()->tz($tzid);
$vevent->add('DTSTART', $startLocal, ['TZID' => $tzid]);
$vevent->add('DTEND', $endLocal, ['TZID' => $tzid]);
} else {
$vevent->add('DTSTART', $startUtc->copy()->utc());
$vevent->add('DTEND', $endUtc->copy()->utc());
}
if (!empty($data['summary'])) {
$vevent->add('SUMMARY', $data['summary']);
}
if (!empty($data['description'])) {
$vevent->add('DESCRIPTION', $data['description']);
}
if (!empty($data['location'])) {
$vevent->add('LOCATION', $data['location']);
}
$rrule = $data['rrule'] ?? null;
if ($rrule) {
$vevent->add('RRULE', $rrule);
}
$exdates = $data['exdate'] ?? [];
if (!empty($exdates)) {
foreach ($exdates as $ex) {
$dt = Carbon::parse($ex, $tzid ?: 'UTC');
if ($tzid) {
$vevent->add('EXDATE', $dt, ['TZID' => $tzid]);
} else {
$vevent->add('EXDATE', $dt->utc());
}
}
}
$rdates = $data['rdate'] ?? [];
if (!empty($rdates)) {
foreach ($rdates as $r) {
$dt = Carbon::parse($r, $tzid ?: 'UTC');
if ($tzid) {
$vevent->add('RDATE', $dt, ['TZID' => $tzid]);
} else {
$vevent->add('RDATE', $dt->utc());
}
}
}
return $vcalendar->serialize();
}
/**
* Check if a stored event contains recurrence data.
*/
public function isRecurring(Event $event): bool
{
$extra = $event->meta?->extra ?? [];
if (!empty($extra['rrule'])) {
return true;
}
return Str::contains($event->calendardata ?? '', ['RRULE', 'RDATE', 'EXDATE']);
}
/**
* Expand recurring instances within the requested range.
*
* Returns an array of ['start' => Carbon, 'end' => Carbon, 'recurrence_id' => string|null]
*/
public function expand(Event $event, Carbon $rangeStart, Carbon $rangeEnd): array
{
$vcalendar = $this->readCalendar($event->calendardata);
if (!$vcalendar || empty($vcalendar->VEVENT)) {
return [];
}
$vevent = $vcalendar->VEVENT;
$uid = (string) $vevent->UID;
$startTz = $vevent->DTSTART?->getDateTime()?->getTimezone()
?? new DateTimeZone('UTC');
$iter = new EventIterator($vcalendar, $uid);
$iter->fastForward($rangeStart->copy()->setTimezone($startTz)->toDateTime());
$items = [];
while ($iter->valid()) {
$start = Carbon::instance($iter->getDTStart());
$end = Carbon::instance($iter->getDTEnd());
if ($start->gt($rangeEnd)) {
break;
}
$startUtc = $start->copy()->utc();
$endUtc = $end->copy()->utc();
$items[] = [
'start' => $startUtc,
'end' => $endUtc,
'recurrence_id' => $startUtc->format('Ymd\\THis\\Z'),
];
$iter->next();
}
return $items;
}
/**
* Resolve a single occurrence by its DTSTART.
*/
public function resolveOccurrence(Event $event, Carbon $occurrenceStart): ?array
{
$rangeStart = $occurrenceStart->copy()->subDay();
$rangeEnd = $occurrenceStart->copy()->addDay();
foreach ($this->expand($event, $rangeStart, $rangeEnd) as $occ) {
if ($occ['start']->equalTo($occurrenceStart)) {
return $occ;
}
}
return null;
}
public function extractRrule(Event $event): ?string
{
$vcalendar = $this->readCalendar($event->calendardata);
if (!$vcalendar || empty($vcalendar->VEVENT)) {
return null;
}
$vevent = $vcalendar->VEVENT;
return isset($vevent->RRULE) ? (string) $vevent->RRULE : null;
}
private function readCalendar(?string $ical): ?VCalendar
{
if (!$ical) {
return null;
}
try {
return Reader::read($ical);
} catch (\Throwable $e) {
return null;
}
}
}

View File

@ -3,6 +3,7 @@
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
return new class extends Migration return new class extends Migration
{ {
@ -12,26 +13,38 @@ return new class extends Migration
return base_path("vendor/sabre/dav/examples/sql/{$file}"); return base_path("vendor/sabre/dav/examples/sql/{$file}");
} }
private function prefix(): string
{
$driver = DB::connection()->getDriverName();
return match ($driver) {
'sqlite' => 'sqlite',
'pgsql' => 'pgsql',
default => 'mysql',
};
}
public function up(): void public function up(): void
{ {
// Disable FK checks for smooth batch execution $prefix = $this->prefix();
DB::statement('SET FOREIGN_KEY_CHECKS = 0'); Schema::disableForeignKeyConstraints();
// Principals (users & groups) // Principals (users & groups)
DB::unprepared(File::get($this->sql('mysql.principals.sql'))); DB::unprepared(File::get($this->sql("{$prefix}.principals.sql")));
// CalDAV calendars + objects // CalDAV calendars + objects
DB::unprepared(File::get($this->sql('mysql.calendars.sql'))); DB::unprepared(File::get($this->sql("{$prefix}.calendars.sql")));
// CardDAV address books + cards // CardDAV address books + cards
DB::unprepared(File::get($this->sql('mysql.addressbooks.sql'))); DB::unprepared(File::get($this->sql("{$prefix}.addressbooks.sql")));
DB::statement('SET FOREIGN_KEY_CHECKS = 1'); Schema::enableForeignKeyConstraints();
} }
public function down(): void public function down(): void
{ {
DB::statement('SET FOREIGN_KEY_CHECKS = 0'); $this->prefix();
Schema::disableForeignKeyConstraints();
// Drop in reverse dependency order // Drop in reverse dependency order
DB::statement('DROP TABLE IF EXISTS DB::statement('DROP TABLE IF EXISTS
@ -47,6 +60,6 @@ return new class extends Migration
groupmembers groupmembers
'); ');
DB::statement('SET FOREIGN_KEY_CHECKS = 1'); Schema::enableForeignKeyConstraints();
} }
}; };

View File

@ -9,7 +9,9 @@ return new class extends Migration
// add composite + geo + optional fulltext indexes to locations // add composite + geo + optional fulltext indexes to locations
public function up(): void public function up(): void
{ {
Schema::table('locations', function (Blueprint $table) { $driver = Schema::getConnection()->getDriverName();
Schema::table('locations', function (Blueprint $table) use ($driver) {
// composite btree index for common lookups // composite btree index for common lookups
$table->index( $table->index(
['display_name', 'city', 'state', 'postal', 'country'], ['display_name', 'city', 'state', 'postal', 'country'],
@ -21,17 +23,23 @@ return new class extends Migration
// optional: fulltext index for free-form text searching // optional: fulltext index for free-form text searching
// note: requires mysql/mariadb version with innodb fulltext support // note: requires mysql/mariadb version with innodb fulltext support
$table->fullText('raw_address', 'locations_raw_address_fulltext'); if (in_array($driver, ['mysql', 'pgsql'], true)) {
$table->fullText('raw_address', 'locations_raw_address_fulltext');
}
}); });
} }
// drop the indexes added in up() // drop the indexes added in up()
public function down(): void public function down(): void
{ {
Schema::table('locations', function (Blueprint $table) { $driver = Schema::getConnection()->getDriverName();
Schema::table('locations', function (Blueprint $table) use ($driver) {
$table->dropIndex('locations_name_city_idx'); $table->dropIndex('locations_name_city_idx');
$table->dropIndex('locations_lat_lon_idx'); $table->dropIndex('locations_lat_lon_idx');
$table->dropFullText('locations_raw_address_fulltext'); if (in_array($driver, ['mysql', 'pgsql'], true)) {
$table->dropFullText('locations_raw_address_fulltext');
}
}); });
} }
}; };

View File

@ -8,6 +8,7 @@ use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Carbon\Carbon; use Carbon\Carbon;
use App\Models\User; use App\Models\User;
use App\Services\Event\EventRecurrence;
class DatabaseSeeder extends Seeder class DatabaseSeeder extends Seeder
{ {
@ -217,6 +218,68 @@ ICS;
); );
}; };
$recurrence = new EventRecurrence();
$insertRecurringEvent = function (
Carbon $start,
string $summary,
string $locationKey,
string $rrule,
string $tz
) use ($calId, $locationIdMap, $locationSeeds, $recurrence) {
$uid = Str::uuid().'@'.parse_url(config('app.url'), PHP_URL_HOST);
$end = $start->copy()->addHour();
$startUtc = $start->copy()->utc();
$endUtc = $end->copy()->utc();
$locationDisplay = $locationKey;
$locationRaw = $locationSeeds[$locationKey]['raw'] ?? null;
$icalLocation = $locationRaw ?? $locationDisplay;
$ical = $recurrence->buildCalendar([
'uid' => $uid,
'start_utc' => $startUtc,
'end_utc' => $endUtc,
'summary' => $summary,
'description' => 'Automatically seeded recurring event',
'location' => $icalLocation,
'tzid' => $tz,
'rrule' => $rrule,
]);
$eventId = DB::table('calendarobjects')->insertGetId([
'calendarid' => $calId,
'uri' => Str::uuid().'.ics',
'lastmodified' => time(),
'etag' => md5($ical),
'size' => strlen($ical),
'componenttype' => 'VEVENT',
'uid' => $uid,
'calendardata' => $ical,
]);
DB::table('event_meta')->updateOrInsert(
['event_id' => $eventId],
[
'title' => $summary,
'description' => 'Automatically seeded recurring event',
'location' => $locationRaw ? null : $locationDisplay,
'location_id' => $locationIdMap[$locationKey] ?? null,
'all_day' => false,
'category' => 'Demo',
'start_at' => $startUtc,
'end_at' => $endUtc,
'extra' => json_encode([
'rrule' => $rrule,
'tzid' => $tz,
]),
'created_at' => now(),
'updated_at' => now(),
]
);
};
/** /**
* *
* create events * create events
@ -226,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');
@ -244,6 +307,16 @@ ICS;
$insertEvent($future5a, 'Teacher conference (3rd grade)', 'Fairview Elementary'); $insertEvent($future5a, 'Teacher conference (3rd grade)', 'Fairview Elementary');
$insertEvent($future5b, 'Family game night', 'Living Room'); $insertEvent($future5b, 'Family game night', 'Living Room');
// recurring: weekly on Mon/Wed for 8 weeks at 6:30pm
$recurringStart = $now->copy()->next(Carbon::MONDAY)->setTime(18, 30);
$insertRecurringEvent(
$recurringStart,
'Evening run',
'McCahill Park',
'FREQ=WEEKLY;BYDAY=MO,WE;COUNT=16',
$tz
);
/** /**
* *
* address books * address books

View File

@ -46,6 +46,35 @@ return [
'saved' => 'Your calendar settings have been saved!', 'saved' => 'Your calendar settings have been saved!',
'title' => 'Calendar settings', 'title' => 'Calendar settings',
], ],
'timezone_help' => 'You can override your default time zone here.' 'timezone_help' => 'You can override your default time zone here.',
'toggle_sidebar' => 'Toggle calendar sidebar',
'event' => [
'when' => 'When',
'all_day' => 'All day',
'location' => 'Location',
'map_coming' => 'Map preview coming soon.',
'no_location' => 'No location set.',
'details' => 'Details',
'repeats' => 'Repeats',
'does_not_repeat' => 'Does not repeat',
'category' => 'Category',
'none' => 'None',
'visibility' => 'Visibility',
'private' => 'Private',
'default' => 'Default',
'all_day_handling' => 'All-day handling',
'timed' => 'Timed',
'all_day_coming' => 'Multi-day all-day UI coming soon',
'alerts' => 'Alerts',
'reminder' => 'Reminder',
'minutes_before' => 'minutes before',
'alerts_coming' => 'No alerts set. (Coming soon)',
'invitees' => 'Invitees',
'invitees_coming' => 'Invitees and RSVP tracking coming soon.',
'attachments' => 'Attachments',
'attachments_coming' => 'Attachment support coming soon.',
'notes' => 'Notes',
'no_description' => 'No description yet.',
],
]; ];

68
lang/it/account.php Normal file
View File

@ -0,0 +1,68 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Account Language Lines
|--------------------------------------------------------------------------
|
| Account, profile, and user settings language lines.
|
*/
// addresses
'address' => [
'city' => 'Citta',
'country' => 'Paese',
'home' => 'Indirizzo di casa',
'label' => 'Etichetta indirizzo',
'line1' => 'Indirizzo riga 1',
'line2' => 'Indirizzo riga 2',
'state' => 'Provincia',
'work' => 'Indirizzo di lavoro',
'zip' => 'CAP',
],
'billing' => [
'home' => 'Usa il tuo indirizzo di casa per la fatturazione',
'work' => 'Usa il tuo indirizzo di lavoro per la fatturazione',
],
'delete' => 'Elimina account',
'delete-your' => 'Elimina il tuo account',
'delete-confirm' => 'Elimina davvero il mio account!',
'email' => 'Email',
'email_address' => 'Indirizzo email',
'first_name' => 'Nome',
'last_name' => 'Cognome',
'phone' => 'Numero di telefono',
'settings' => [
'addresses' => [
'title' => 'Indirizzi',
'subtitle' => 'Gestisci i tuoi indirizzi di casa e lavoro e scegli quale usare per la fatturazione.',
],
'delete' => [
'title' => 'Qui ci sono draghi',
'subtitle' => 'Elimina il tuo account e rimuovi tutte le informazioni dal nostro database. Non puo essere annullato, quindi consigliamo di esportare i tuoi dati prima e migrare a un nuovo provider.',
'explanation' => 'Nota: non e come altre app che "eliminano" i dati&mdash;non stiamo impostando <code>is_deleted = 1</code>, li stiamo rimuovendo dal nostro database.',
],
'delete-confirm' => [
'title' => 'Conferma eliminazione account',
'subtitle' => 'Inserisci la tua password e conferma che vuoi eliminare definitivamente il tuo account.',
],
'information' => [
'title' => 'Informazioni personali',
'subtitle' => 'Il tuo nome, email e altri dettagli principali del account.',
],
'locale' => [
'title' => 'Preferenze locali',
'subtitle' => 'Posizione, fuso orario e altre preferenze regionali per calendari ed eventi.'
],
'password' => [
'title' => 'Password',
'subtitle' => 'Assicurati che il tuo account usi una password lunga e casuale per restare sicuro. Consigliamo anche un password manager!',
],
'title' => 'Impostazioni account',
],
'title' => 'Account',
];

20
lang/it/auth.php Normal file
View File

@ -0,0 +1,20 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used during authentication for various
| messages that we need to display to the user. You are free to modify
| these language lines according to your application's requirements.
|
*/
'failed' => 'Queste credenziali non corrispondono ai nostri record.',
'password' => 'La password fornita non e corretta.',
'throttle' => 'Troppi tentativi di accesso. Riprova tra :seconds secondi.',
];

80
lang/it/calendar.php Normal file
View File

@ -0,0 +1,80 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Calendar Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used throughout the calendar app,
| including calendar settings and events.
|
*/
'color' => 'Colore',
'create' => 'Crea calendario',
'description' => 'Descrizione',
'ics' => [
'url' => 'URL ICS',
'url_help' => 'Non puoi modificare un URL di calendario pubblico. Se devi fare una modifica, annulla l iscrizione e aggiungilo di nuovo.',
],
'mine' => 'I miei calendari',
'name' => 'Nome calendario',
'settings' => [
'calendar' => [
'title' => 'Impostazioni calendario',
'subtitle' => 'Dettagli e impostazioni per <strong>:calendar</strong>.'
],
'create' => [
'title' => 'Crea un calendario',
'subtitle' => 'Crea un nuovo calendario locale.',
],
'display' => [
'title' => 'Preferenze di visualizzazione',
'subtitle' => 'Regola aspetto e comportamento dei tuoi calendari.'
],
'language_region' => [
'title' => 'Lingua e regione',
'subtitle' => 'Scegli la lingua predefinita, la regione e le preferenze di formattazione. Queste influenzano come date e orari sono mostrati nei calendari e negli eventi.',
],
'my_calendars' => 'Impostazioni per i miei calendari',
'subscribe' => [
'title' => 'Iscriviti a un calendario',
'subtitle' => 'Aggiungi un calendario `.ics` da un altro servizio',
],
'saved' => 'Le impostazioni del calendario sono state salvate!',
'title' => 'Impostazioni calendario',
],
'timezone_help' => 'Puoi sovrascrivere il tuo fuso orario predefinito qui.',
'toggle_sidebar' => 'Mostra o nascondi la barra laterale del calendario',
'event' => [
'when' => 'Quando',
'all_day' => 'Tutto il giorno',
'location' => 'Luogo',
'map_coming' => 'Anteprima mappa in arrivo.',
'no_location' => 'Nessun luogo impostato.',
'details' => 'Dettagli',
'repeats' => 'Ripete',
'does_not_repeat' => 'Non si ripete',
'category' => 'Categoria',
'none' => 'Nessuno',
'visibility' => 'Visibilita',
'private' => 'Privato',
'default' => 'Predefinito',
'all_day_handling' => 'Gestione giornata intera',
'timed' => 'Con orario',
'all_day_coming' => 'UI giornate intere multi-giorno in arrivo',
'alerts' => 'Avvisi',
'reminder' => 'Promemoria',
'minutes_before' => 'minuti prima',
'alerts_coming' => 'Nessun avviso impostato. (In arrivo)',
'invitees' => 'Invitati',
'invitees_coming' => 'Invitati e RSVP in arrivo.',
'attachments' => 'Allegati',
'attachments_coming' => 'Supporto allegati in arrivo.',
'notes' => 'Note',
'no_description' => 'Nessuna descrizione.',
],
];

View File

@ -2,10 +2,41 @@
return [ return [
/*
|--------------------------------------------------------------------------
| Common words and phrases
|--------------------------------------------------------------------------
|
| Generic words used throughout the app in more than one location.
|
*/
'address' => 'Indirizzo',
'addresses' => 'Indirizzi',
'calendar' => 'Calendario', 'calendar' => 'Calendario',
'calendars' => 'Calendari', 'calendars' => 'Calendari',
'cancel' => 'Annulla',
'cancel_back' => 'Annulla e torna indietro',
'cancel_funny' => 'Portami via',
'date' => 'Data',
'date_select' => 'Seleziona una data',
'date_format' => 'Formato data',
'date_format_select' => 'Seleziona un formato data',
'event' => 'Evento', 'event' => 'Evento',
'events' => 'Eventi', 'events' => 'Eventi',
'language' => 'Lingua',
'language_select' => 'Seleziona una lingua',
'password' => 'Password',
'region' => 'Regione',
'region_select' => 'Seleziona una regione',
'save_changes' => 'Salva modifiche',
'settings' => 'Impostazioni', 'settings' => 'Impostazioni',
'time' => 'Ora',
'time_select' => 'Seleziona un orario',
'time_format' => 'Formato ora',
'time_format_select' => 'Seleziona un formato ora',
'timezone' => 'Fuso orario',
'timezone_default' => 'Fuso orario predefinito',
'timezone_select' => 'Seleziona un fuso orario',
]; ];

19
lang/it/pagination.php Normal file
View File

@ -0,0 +1,19 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Pagination Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used by the paginator library to build
| the simple pagination links. You are free to change them to anything
| you want to customize your views to better match your application.
|
*/
'previous' => '&laquo; Precedente',
'next' => 'Successivo &raquo;',
];

22
lang/it/passwords.php Normal file
View File

@ -0,0 +1,22 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Password Reset Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are the default lines which match reasons
| that are given by the password broker for a password update attempt
| outcome such as failure due to an invalid password / reset token.
|
*/
'reset' => 'La tua password e stata reimpostata.',
'sent' => 'Ti abbiamo inviato via email il link per reimpostare la password.',
'throttled' => 'Attendi prima di riprovare.',
'token' => 'Questo token di reimpostazione password non e valido.',
'user' => 'Non troviamo un utente con questo indirizzo email.',
];

198
lang/it/validation.php Normal file
View File

@ -0,0 +1,198 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Validation Language Lines
|--------------------------------------------------------------------------
|
| The following language lines contain the default error messages used by
| the validator class. Some of these rules have multiple versions such
| as the size rules. Feel free to tweak each of these messages here.
|
*/
'accepted' => 'Il campo :attribute deve essere accettato.',
'accepted_if' => 'Il campo :attribute deve essere accettato quando :other e :value.',
'active_url' => 'Il campo :attribute deve essere un URL valido.',
'after' => 'Il campo :attribute deve essere una data successiva a :date.',
'after_or_equal' => 'Il campo :attribute deve essere una data successiva o uguale a :date.',
'alpha' => 'Il campo :attribute deve contenere solo lettere.',
'alpha_dash' => 'Il campo :attribute deve contenere solo lettere, numeri, trattini e underscore.',
'alpha_num' => 'Il campo :attribute deve contenere solo lettere e numeri.',
'any_of' => 'Il campo :attribute non e valido.',
'array' => 'Il campo :attribute deve essere un array.',
'ascii' => 'Il campo :attribute deve contenere solo caratteri alfanumerici a singolo byte e simboli.',
'before' => 'Il campo :attribute deve essere una data precedente a :date.',
'before_or_equal' => 'Il campo :attribute deve essere una data precedente o uguale a :date.',
'between' => [
'array' => 'Il campo :attribute deve avere tra :min e :max elementi.',
'file' => 'Il campo :attribute deve essere tra :min e :max kilobyte.',
'numeric' => 'Il campo :attribute deve essere tra :min e :max.',
'string' => 'Il campo :attribute deve essere tra :min e :max caratteri.',
],
'boolean' => 'Il campo :attribute deve essere vero o falso.',
'can' => 'Il campo :attribute contiene un valore non autorizzato.',
'confirmed' => 'La conferma del campo :attribute non corrisponde.',
'contains' => 'Il campo :attribute non contiene un valore richiesto.',
'current_password' => 'La password inserita non e corretta.',
'date' => 'Il campo :attribute deve essere una data valida.',
'date_equals' => 'Il campo :attribute deve essere una data uguale a :date.',
'date_format' => 'Il campo :attribute deve corrispondere al formato :format.',
'decimal' => 'Il campo :attribute deve avere :decimal decimali.',
'declined' => 'Il campo :attribute deve essere rifiutato.',
'declined_if' => 'Il campo :attribute deve essere rifiutato quando :other e :value.',
'different' => 'Il campo :attribute e :other devono essere diversi.',
'digits' => 'Il campo :attribute deve essere di :digits cifre.',
'digits_between' => 'Il campo :attribute deve essere tra :min e :max cifre.',
'dimensions' => 'Il campo :attribute ha dimensioni immagine non valide.',
'distinct' => 'Il campo :attribute ha un valore duplicato.',
'doesnt_end_with' => 'Il campo :attribute non deve terminare con uno dei seguenti: :values.',
'doesnt_start_with' => 'Il campo :attribute non deve iniziare con uno dei seguenti: :values.',
'email' => 'Il campo :attribute deve essere un indirizzo email valido.',
'ends_with' => 'Il campo :attribute deve terminare con uno dei seguenti: :values.',
'enum' => 'Il valore selezionato per :attribute non e valido.',
'exists' => 'Il valore selezionato per :attribute non e valido.',
'extensions' => 'Il campo :attribute deve avere una delle seguenti estensioni: :values.',
'file' => 'Il campo :attribute deve essere un file.',
'filled' => 'Il campo :attribute deve avere un valore.',
'gt' => [
'array' => 'Il campo :attribute deve avere piu di :value elementi.',
'file' => 'Il campo :attribute deve essere maggiore di :value kilobyte.',
'numeric' => 'Il campo :attribute deve essere maggiore di :value.',
'string' => 'Il campo :attribute deve essere maggiore di :value caratteri.',
],
'gte' => [
'array' => 'Il campo :attribute deve avere :value elementi o piu.',
'file' => 'Il campo :attribute deve essere maggiore o uguale a :value kilobyte.',
'numeric' => 'Il campo :attribute deve essere maggiore o uguale a :value.',
'string' => 'Il campo :attribute deve essere maggiore o uguale a :value caratteri.',
],
'hex_color' => 'Il campo :attribute deve essere un colore esadecimale valido.',
'image' => 'Il campo :attribute deve essere una immagine.',
'in' => 'Il valore selezionato per :attribute non e valido.',
'in_array' => 'Il campo :attribute deve esistere in :other.',
'in_array_keys' => 'Il campo :attribute deve contenere almeno una delle seguenti chiavi: :values.',
'integer' => 'Il campo :attribute deve essere un numero intero.',
'ip' => 'Il campo :attribute deve essere un indirizzo IP valido.',
'ipv4' => 'Il campo :attribute deve essere un indirizzo IPv4 valido.',
'ipv6' => 'Il campo :attribute deve essere un indirizzo IPv6 valido.',
'json' => 'Il campo :attribute deve essere una stringa JSON valida.',
'list' => 'Il campo :attribute deve essere una lista.',
'lowercase' => 'Il campo :attribute deve essere in minuscolo.',
'lt' => [
'array' => 'Il campo :attribute deve avere meno di :value elementi.',
'file' => 'Il campo :attribute deve essere minore di :value kilobyte.',
'numeric' => 'Il campo :attribute deve essere minore di :value.',
'string' => 'Il campo :attribute deve essere minore di :value caratteri.',
],
'lte' => [
'array' => 'Il campo :attribute non deve avere piu di :value elementi.',
'file' => 'Il campo :attribute deve essere minore o uguale a :value kilobyte.',
'numeric' => 'Il campo :attribute deve essere minore o uguale a :value.',
'string' => 'Il campo :attribute deve essere minore o uguale a :value caratteri.',
],
'mac_address' => 'Il campo :attribute deve essere un indirizzo MAC valido.',
'max' => [
'array' => 'Il campo :attribute non deve avere piu di :max elementi.',
'file' => 'Il campo :attribute non deve essere maggiore di :max kilobyte.',
'numeric' => 'Il campo :attribute non deve essere maggiore di :max.',
'string' => 'Il campo :attribute non deve essere maggiore di :max caratteri.',
],
'max_digits' => 'Il campo :attribute non deve avere piu di :max cifre.',
'mimes' => 'Il campo :attribute deve essere un file di tipo: :values.',
'mimetypes' => 'Il campo :attribute deve essere un file di tipo: :values.',
'min' => [
'array' => 'Il campo :attribute deve avere almeno :min elementi.',
'file' => 'Il campo :attribute deve essere almeno :min kilobyte.',
'numeric' => 'Il campo :attribute deve essere almeno :min.',
'string' => 'Il campo :attribute deve essere almeno :min caratteri.',
],
'min_digits' => 'Il campo :attribute deve avere almeno :min cifre.',
'missing' => 'Il campo :attribute deve essere assente.',
'missing_if' => 'Il campo :attribute deve essere assente quando :other e :value.',
'missing_unless' => 'Il campo :attribute deve essere assente a meno che :other sia :value.',
'missing_with' => 'Il campo :attribute deve essere assente quando :values e presente.',
'missing_with_all' => 'Il campo :attribute deve essere assente quando :values sono presenti.',
'multiple_of' => 'Il campo :attribute deve essere un multiplo di :value.',
'not_in' => 'Il valore selezionato per :attribute non e valido.',
'not_regex' => 'Il formato del campo :attribute non e valido.',
'numeric' => 'Il campo :attribute deve essere un numero.',
'password' => [
'letters' => 'Il campo :attribute deve contenere almeno una lettera.',
'mixed' => 'Il campo :attribute deve contenere almeno una lettera maiuscola e una minuscola.',
'numbers' => 'Il campo :attribute deve contenere almeno un numero.',
'symbols' => 'Il campo :attribute deve contenere almeno un simbolo.',
'uncompromised' => 'Il valore :attribute e apparso in una violazione di dati. Scegli un altro :attribute.',
],
'present' => 'Il campo :attribute deve essere presente.',
'present_if' => 'Il campo :attribute deve essere presente quando :other e :value.',
'present_unless' => 'Il campo :attribute deve essere presente a meno che :other sia :value.',
'present_with' => 'Il campo :attribute deve essere presente quando :values e presente.',
'present_with_all' => 'Il campo :attribute deve essere presente quando :values sono presenti.',
'prohibited' => 'Il campo :attribute e proibito.',
'prohibited_if' => 'Il campo :attribute e proibito quando :other e :value.',
'prohibited_if_accepted' => 'Il campo :attribute e proibito quando :other e accettato.',
'prohibited_if_declined' => 'Il campo :attribute e proibito quando :other e rifiutato.',
'prohibited_unless' => 'Il campo :attribute e proibito a meno che :other sia in :values.',
'prohibits' => 'Il campo :attribute impedisce la presenza di :other.',
'regex' => 'Il formato del campo :attribute non e valido.',
'required' => 'Il campo :attribute e obbligatorio.',
'required_array_keys' => 'Il campo :attribute deve contenere voci per: :values.',
'required_if' => 'Il campo :attribute e obbligatorio quando :other e :value.',
'required_if_accepted' => 'Il campo :attribute e obbligatorio quando :other e accettato.',
'required_if_declined' => 'Il campo :attribute e obbligatorio quando :other e rifiutato.',
'required_unless' => 'Il campo :attribute e obbligatorio a meno che :other sia in :values.',
'required_with' => 'Il campo :attribute e obbligatorio quando :values e presente.',
'required_with_all' => 'Il campo :attribute e obbligatorio quando :values sono presenti.',
'required_without' => 'Il campo :attribute e obbligatorio quando :values non e presente.',
'required_without_all' => 'Il campo :attribute e obbligatorio quando nessuno di :values e presente.',
'same' => 'Il campo :attribute deve corrispondere a :other.',
'size' => [
'array' => 'Il campo :attribute deve contenere :size elementi.',
'file' => 'Il campo :attribute deve essere di :size kilobyte.',
'numeric' => 'Il campo :attribute deve essere :size.',
'string' => 'Il campo :attribute deve essere di :size caratteri.',
],
'starts_with' => 'Il campo :attribute deve iniziare con uno dei seguenti: :values.',
'string' => 'Il campo :attribute deve essere una stringa.',
'timezone' => 'Il campo :attribute deve essere un fuso orario valido.',
'unique' => 'Il valore :attribute e gia stato preso.',
'uploaded' => 'Il campo :attribute non e riuscito a caricare.',
'uppercase' => 'Il campo :attribute deve essere in maiuscolo.',
'url' => 'Il campo :attribute deve essere un URL valido.',
'ulid' => 'Il campo :attribute deve essere un ULID valido.',
'uuid' => 'Il campo :attribute deve essere un UUID valido.',
/*
|--------------------------------------------------------------------------
| Custom Validation Language Lines
|--------------------------------------------------------------------------
|
| Here you may specify custom validation messages for attributes using the
| convention "attribute.rule" to name the lines. This makes it quick to
| specify a specific custom language line for a given attribute rule.
|
*/
'custom' => [
'attribute-name' => [
'rule-name' => 'custom-message',
],
],
/*
|--------------------------------------------------------------------------
| Custom Validation Attributes
|--------------------------------------------------------------------------
|
| The following language lines are used to swap our attribute placeholder
| with something more reader friendly such as "E-Mail Address" instead
| of "email". This simply helps us make our message more expressive.
|
*/
'attributes' => [],
];

View File

@ -98,6 +98,7 @@ body {
main { main {
@apply overflow-hidden rounded-lg; @apply overflow-hidden rounded-lg;
max-height: calc(100dvh - 1rem); max-height: calc(100dvh - 1rem);
container: main / inline-size;
/* app */ /* app */
body#app & { body#app & {
@ -186,11 +187,12 @@ main {
@apply overflow-y-auto; @apply overflow-y-auto;
grid-template-rows: 5rem auto; grid-template-rows: 5rem auto;
container: content / inline-size; container: content / inline-size;
transition: margin 200ms ease-in-out, width 200ms ease-in-out;
/* main content title and actions */ /* main content title and actions */
> header { header {
@apply flex flex-row items-center justify-between w-full; @apply flex flex-row items-center justify-between w-full;
@apply bg-white sticky top-0 z-10; @apply bg-white sticky top-0 z-20;
/* app hedar; if h1 exists it means there's no aside, so force the width from that */ /* app hedar; if h1 exists it means there's no aside, so force the width from that */
h1 { h1 {
@ -208,9 +210,19 @@ main {
} }
} }
/* expand button */
button.calendar-expand-toggle {
svg {
transition: transform 150ms ease-in-out;
}
}
/* header menu */ /* header menu */
menu { menu {
@apply flex flex-row items-center justify-end gap-4; @apply fixed right-0 top-2 flex flex-col bg-gray-100 gap-6 p-6 rounded-l-xl;
height: calc(100dvh - 0.5rem);
width: 33dvw;
display: none;
} }
} }
@ -242,12 +254,46 @@ main {
/* section specific */ /* section specific */
&#calendar { &#calendar {
/* */ header {
.calendar-expand-toggle {
@apply ml-1 opacity-0 pointer-events-none transition-opacity duration-150;
}
&:hover .calendar-expand-toggle,
&:focus-within .calendar-expand-toggle {
@apply opacity-100 pointer-events-auto;
}
}
} }
&#settings { &#settings {
/* */ /* */
} }
} }
/* expanded */
&.expanded {
button.calendar-expand-toggle {
svg {
transform: rotate(180deg);
}
}
}
}
/* container sizing */
@container content (width >= 64rem)
{
main {
article {
header {
menu {
@apply relative top-auto right-auto h-auto w-auto rounded-none bg-transparent;
@apply flex flex-row items-center justify-end gap-4 p-0;
}
}
}
}
} }
/* app logo */ /* app logo */
@ -276,8 +322,8 @@ main {
*/ */
/* show app nav on the left at md */ /* show app nav on the left at md */
@media (width >= theme(--breakpoint-md)) { @media (width >= theme(--breakpoint-md))
{
body#app { body#app {
grid-template-columns: 5rem auto; grid-template-columns: 5rem auto;
grid-template-rows: 1fr 0; grid-template-rows: 1fr 0;
@ -309,13 +355,15 @@ main {
} }
main { main {
&:has(aside) { &:has(aside) {
grid-template-columns: minmax(16rem, 20dvw) auto; grid-template-columns: max(16rem, 20dvw) auto;
grid-template-rows: 1fr; grid-template-rows: 1fr;
} }
aside { aside {
@apply bg-white overflow-y-auto h-full; @apply bg-white overflow-y-auto h-full min-w-48;
transition: translate 200ms ease-in-out, visibility 200ms ease-in-out, opacity 200ms ease-in-out;
> h1 { > h1 {
@apply backdrop-blur-xs sticky top-0 z-1 shrink-0 h-20 min-h-20; @apply backdrop-blur-xs sticky top-0 z-1 shrink-0 h-20 min-h-20;
@ -326,6 +374,20 @@ main {
article { article {
@apply w-full ml-0 pl-3 2xl:pl-4 pr-6 2xl:pr-8 rounded-l-none rounded-r-lg; @apply w-full ml-0 pl-3 2xl:pl-4 pr-6 2xl:pr-8 rounded-l-none rounded-r-lg;
} }
/* when the calendar is expanded and aside is gone */
&.expanded {
aside {
@apply -translate-x-6 invisible opacity-0;
}
article {
@apply pl-6;
margin-left: min(-16rem, -20dvw) !important;
width: 100cqw !important;
}
}
} }
} }
} }

View File

@ -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;

View File

@ -131,7 +131,7 @@
/* time column */ /* time column */
ol.time { ol.time {
@apply grid z-0 pt-4; @apply grid z-0 pt-4;
grid-template-rows: repeat(96, var(--row-height)); grid-template-rows: repeat(var(--grid-rows, 96), var(--row-height));
time { time {
@apply relative flex items-center justify-end items-start pr-4; @apply relative flex items-center justify-end items-start pr-4;
@ -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;
@ -158,7 +158,7 @@
--event-fg: var(--color-primary); --event-fg: var(--color-primary);
li.event { li.event {
@apply flex rounded-md relative; @apply flex rounded-md relative border border-white;
background-color: var(--event-bg); background-color: var(--event-bg);
color: var(--event-fg); color: var(--event-fg);
grid-row-start: var(--event-row); grid-row-start: var(--event-row);
@ -166,39 +166,46 @@
grid-column-start: var(--event-col); grid-column-start: var(--event-col);
grid-column-end: calc(var(--event-col) + 1); grid-column-end: calc(var(--event-col) + 1);
top: 0.6rem; top: 0.6rem;
transition: translate 100ms ease-in;
> a { a.event {
@apply flex flex-col grow px-3 py-2 gap-2px; @apply flex flex-col grow px-3 py-2 gap-2px text-sm;
> span { > span {
@apply font-semibold leading-none break-all; @apply font-semibold leading-none break-all;
} }
> time { > time {
@apply text-sm; @apply text-xs;
} }
} }
&:hover { &:hover {
@apply -translate-y-2px; animation: event-hover 125ms ease forwards;
} }
} }
} }
/* 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];
@ -208,19 +215,36 @@
} }
/* step handling */ /* step handling */
.calendar.time[data-density="30"] { .calendar.time[data-density="30"] { /* half-hourly */
--row-height: 2rem; --row-height: 2rem;
ol.time li:nth-child(2n) { ol.time li:nth-child(2n) {
visibility: hidden; /* preserves space + row alignment */ visibility: hidden; /* preserves space + row alignment */
} }
} }
.calendar.time[data-density="60"] { .calendar.time[data-density="60"] { /* hourly */
--row-height: 1.25rem; --row-height: 1.25rem;
ol.time li:not(:nth-child(4n + 1)) { ol.time li:not(:nth-child(4n + 1)) {
visibility: hidden; /* preserves space + row alignment */ visibility: hidden; /* preserves space + row alignment */
} }
&.week {
ol.events {
li.event[data-span="1"] {
a.event > span,
a.event > time {
@apply text-xs;
}
}
li.event[data-span="1"],
li.event[data-span="2"] {
> a.event {
@apply flex-row items-center gap-3;
}
}
}
}
} }
/** /**
@ -370,7 +394,16 @@
transform: translateX(0); transform: translateX(0);
} }
} }
@keyframes event-hover {
from {
transform: translateY(0);
z-index: 1;
}
to {
transform: translateY(-2px);
z-index: 2;
}
}
@keyframes header-slide { @keyframes header-slide {
from { from {
opacity: 0; opacity: 0;
@ -381,4 +414,3 @@
transform: translateX(0); transform: translateX(0);
} }
} }

View File

@ -3,26 +3,29 @@
} }
dialog { dialog {
@apply grid fixed top-0 right-0 bottom-0 left-0 m-0 p-0 pointer-events-none; @apply grid fixed inset-0 m-0 p-0 pointer-events-none;
@apply justify-items-center items-start bg-transparent opacity-0 invisible; @apply place-items-center bg-transparent opacity-0 invisible;
@apply w-full h-full max-w-full max-h-full overflow-y-hidden; @apply w-full h-full max-w-none max-h-none overflow-clip;
background-color: rgba(26, 26, 26, 0.75); background-color: rgba(26, 26, 26, 0.75);
backdrop-filter: blur(0.25rem); backdrop-filter: blur(0.25rem);
grid-template-rows: minmax(20dvh, 2rem) 1fr; /*(grid-template-rows: minmax(20dvh, 2rem) 1fr; */
overscroll-behavior: contain;
scrollbar-gutter: auto;
transition: transition:
background-color 150ms cubic-bezier(0,0,.2,1),
opacity 150ms cubic-bezier(0,0,.2,1), opacity 150ms cubic-bezier(0,0,.2,1),
visibility 150ms cubic-bezier(0,0,.2,1); visibility 150ms cubic-bezier(0,0,.2,1);
z-index: 100; z-index: 100;
#modal { #modal {
@apply relative rounded-lg bg-white border-gray-200 p-0; @apply relative rounded-xl bg-white border-gray-200 p-0;
@apply flex flex-col items-start col-start-1 row-start-2 translate-y-4; @apply flex flex-col items-start col-start-1 translate-y-4;
@apply overscroll-contain overflow-y-auto; @apply overscroll-contain overflow-y-auto;
max-height: calc(100vh - 5em); max-height: calc(100vh - 5em);
width: 91.666667%; width: 91.666667%;
max-width: 36rem; max-width: 36rem;
transition: all 150ms cubic-bezier(0,0,.2,1); transition: all 150ms cubic-bezier(0,0,.2,1);
box-shadow: #00000040 0 1.5rem 4rem -0.5rem; box-shadow: 0 1.5rem 4rem -0.5rem rgba(0, 0, 0, 0.4);
> .close-modal { > .close-modal {
@apply block absolute top-4 right-4; @apply block absolute top-4 right-4;

View File

@ -1,6 +1,16 @@
import './bootstrap'; import './bootstrap';
import htmx from 'htmx.org'; import htmx from 'htmx.org';
const SELECTORS = {
calendarToggle: '.calendar-toggle',
calendarViewForm: '#calendar-view',
calendarExpandToggle: '[data-calendar-expand]',
colorPicker: '[data-colorpicker]',
colorPickerColor: '[data-colorpicker-color]',
colorPickerHex: '[data-colorpicker-hex]',
colorPickerRandom: '[data-colorpicker-random]',
};
/** /**
* htmx/global * htmx/global
*/ */
@ -19,34 +29,43 @@ document.addEventListener('htmx:configRequest', (evt) => {
}) })
/** /**
* calendar toggle * calendar ui
* progressive enhancement on html form with no js * progressive enhancement on html form with no js
*/ */
document.addEventListener('change', event => { document.addEventListener('change', (event) => {
const checkbox = event.target; const target = event.target;
// ignore anything that isnt one of our checkboxes if (target?.matches(SELECTORS.calendarToggle)) {
if (!checkbox.matches('.calendar-toggle')) return; const slug = target.value;
const show = target.checked;
const slug = checkbox.value; document
const show = checkbox.checked; .querySelectorAll(`[data-calendar="${slug}"]`)
.forEach(el => el.classList.toggle('hidden', !show));
return;
}
// toggle .hidden on every matching event element const form = target?.form;
document if (!form || form.id !== 'calendar-view') return;
.querySelectorAll(`[data-calendar="${slug}"]`) if (target.name !== 'view') return;
.forEach(el => el.classList.toggle('hidden', !show));
form.requestSubmit();
}); });
/** /**
* calendar view picker * calendar sidebar expand toggle
* progressive enhancement on html form with no js
*/ */
document.addEventListener('change', (e) => { document.addEventListener('click', (event) => {
const form = e.target?.form; const toggle = event.target.closest(SELECTORS.calendarExpandToggle);
if (!form || form.id !== 'calendar-view') return; if (!toggle) return;
if (e.target.name !== 'view') return;
form.requestSubmit(); event.preventDefault();
const main = toggle.closest('main');
if (!main) return;
const isExpanded = main.classList.toggle('expanded');
toggle.setAttribute('aria-pressed', isExpanded ? 'true' : 'false');
}); });
/** /**
@ -71,9 +90,9 @@ function initColorPickers(root = document) {
if (el.__colorpickerWired) return; if (el.__colorpickerWired) return;
el.__colorpickerWired = true; el.__colorpickerWired = true;
const color = el.querySelector('[data-colorpicker-color]'); const color = el.querySelector(SELECTORS.colorPickerColor);
const hex = el.querySelector('[data-colorpicker-hex]'); const hex = el.querySelector(SELECTORS.colorPickerHex);
const btn = el.querySelector('[data-colorpicker-random]'); const btn = el.querySelector(SELECTORS.colorPickerRandom);
if (!color || !hex) return; if (!color || !hex) return;
@ -137,11 +156,15 @@ function initColorPickers(root = document) {
} }
}; };
root.querySelectorAll('[data-colorpicker]').forEach(wire); root.querySelectorAll(SELECTORS.colorPicker).forEach(wire);
}
function initUI() {
initColorPickers();
} }
// initial bind // initial bind
document.addEventListener('DOMContentLoaded', () => initColorPickers()); document.addEventListener('DOMContentLoaded', initUI);
// rebind in htmx for swapped content // rebind in htmx for swapped content
document.addEventListener('htmx:afterSwap', (e) => { document.addEventListener('htmx:afterSwap', (e) => {

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left-to-line-icon lucide-arrow-left-to-line"><path d="M3 19V5"/><path d="m13 6-6 6 6 6"/><path d="M7 12h14"/></svg>

After

Width:  |  Height:  |  Size: 323 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-right-to-line-icon lucide-arrow-right-to-line"><path d="M17 12H3"/><path d="m11 18 6-6-6-6"/><path d="M21 5v14"/></svg>

After

Width:  |  Height:  |  Size: 327 B

View File

@ -1,38 +0,0 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Profile') }}
</h2>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
<div class="max-w-xl">
@include('account.partials.update-profile-information-form')
</div>
</div>
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
<div class="max-w-xl">
@include('account.partials.addresses-form', [
'home' => $home ?? null,
'billing' => $billing ?? null,
])
</div>
</div>
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
<div class="max-w-xl">
@include('account.partials.update-password-form')
</div>
</div>
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
<div class="max-w-xl">
@include('account.partials.delete-user-form')
</div>
</div>
</div>
</div>
</x-app-layout>

View File

@ -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>
@ -84,6 +85,14 @@
@if(!empty($header['span'])) @if(!empty($header['span']))
<span>{{ $header['span'] }}</span> <span>{{ $header['span'] }}</span>
@endif @endif
<button
type="button"
class="button button--icon button--sm calendar-expand-toggle"
data-calendar-expand
aria-label="{{ __('calendar.toggle_sidebar') }}"
>
<x-icon-collapse />
</button>
</h2> </h2>
<menu> <menu>
<li> <li>
@ -105,6 +114,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 +158,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 +199,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 +215,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 +230,7 @@
:hgroup="$hgroup" :hgroup="$hgroup"
:active="$active" :active="$active"
:density="$density" :density="$density"
:daytime_hours="$daytime_hours"
:now="$now" :now="$now"
/> />
@break @break

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -19,10 +19,14 @@
@php @php
$event = $events[$eventId]; $event = $events[$eventId];
$color = $event['color'] ?? '#999'; $color = $event['color'] ?? '#999';
$showParams = [$event['calendar_slug'], $event['id']];
if (!empty($event['occurrence'])) {
$showParams['occurrence'] = $event['occurrence'];
}
@endphp @endphp
<a class="event{{ $event['visible'] ? '' : ' hidden' }}" <a class="event{{ $event['visible'] ? '' : ' hidden' }}"
href="{{ route('calendar.event.show', [$event['calendar_slug'], $event['id']]) }}" href="{{ route('calendar.event.show', $showParams) }}"
hx-get="{{ route('calendar.event.show', [$event['calendar_slug'], $event['id']]) }}" hx-get="{{ route('calendar.event.show', $showParams) }}"
hx-target="#modal" hx-target="#modal"
hx-push-url="false" hx-push-url="false"
hx-swap="innerHTML" hx-swap="innerHTML"

View File

@ -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>

View File

@ -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>

View File

@ -3,10 +3,11 @@
]) ])
<li class="event" <li class="event"
data-event-id="{{ $event['id'] }}" data-event-id="{{ $event['occurrence_id'] ?? $event['id'] }}"
data-calendar-id="{{ $event['calendar_slug'] }}" data-calendar-id="{{ $event['calendar_slug'] }}"
data-start="{{ $event['start_ui'] }}" data-start="{{ $event['start_ui'] }}"
data-duration="{{ $event['duration'] }}" data-duration="{{ $event['duration'] }}"
data-span="{{ $event['row_span'] }}"
style=" style="
--event-row: {{ $event['start_row'] }}; --event-row: {{ $event['start_row'] }};
--event-end: {{ $event['end_row'] }}; --event-end: {{ $event['end_row'] }};
@ -14,9 +15,15 @@
--event-bg: {{ $event['color'] }}; --event-bg: {{ $event['color'] }};
--event-fg: {{ $event['color_fg'] }}; --event-fg: {{ $event['color_fg'] }};
"> ">
@php
$showParams = [$event['calendar_slug'], $event['id']];
if (!empty($event['occurrence'])) {
$showParams['occurrence'] = $event['occurrence'];
}
@endphp
<a class="event{{ $event['visible'] ? '' : ' hidden' }}" <a class="event{{ $event['visible'] ? '' : ' hidden' }}"
href="{{ route('calendar.event.show', [$event['calendar_slug'], $event['id']]) }}" href="{{ route('calendar.event.show', $showParams) }}"
hx-get="{{ route('calendar.event.show', [$event['calendar_slug'], $event['id']]) }}" hx-get="{{ route('calendar.event.show', $showParams) }}"
hx-target="#modal" hx-target="#modal"
hx-push-url="false" hx-push-url="false"
hx-swap="innerHTML" hx-swap="innerHTML"

View File

@ -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>

View File

@ -105,6 +105,22 @@
</label> </label>
</div> </div>
{{-- Recurrence (advanced) --}}
<details class="mb-6">
<summary class="cursor-pointer text-sm text-gray-600">
{{ __('Repeat (advanced)') }}
</summary>
<div class="mt-3">
<x-input-label for="rrule" :value="__('RRULE')" />
<x-text-input id="rrule" name="rrule" type="text" class="mt-1 block w-full"
:value="old('rrule', $rrule ?? '')" />
<p class="mt-1 text-xs text-gray-500">
Example: <code>FREQ=WEEKLY;BYDAY=MO,WE</code>
</p>
<x-input.error class="mt-2" :messages="$errors->get('rrule')" />
</div>
</details>
{{-- Submit --}} {{-- Submit --}}
<div class="flex justify-end space-x-2"> <div class="flex justify-end space-x-2">
<a href="{{ route('calendar.show', $calendar) }}" <a href="{{ route('calendar.show', $calendar) }}"

View File

@ -1,26 +1,147 @@
@php
$meta = $event->meta;
$title = $meta->title ?? '(no title)';
$allDay = (bool) ($meta->all_day ?? false);
$calendarName = $calendar->displayname ?? __('common.calendar');
$calendarColor = $calendar->meta_color ?? $calendar->calendarcolor ?? default_calendar_color();
$rrule = $meta?->extra['rrule'] ?? null;
$tzid = $meta?->extra['tzid'] ?? $tz;
$locationLabel = $meta?->location_label ?? '';
$hasLocation = trim((string) $locationLabel) !== '';
$venue = $meta?->venue;
$addressLine1 = $venue?->street;
$addressLine2 = trim(implode(', ', array_filter([
$venue?->city,
$venue?->state,
$venue?->postal,
])));
$addressLine3 = $venue?->country;
@endphp
<x-modal.content> <x-modal.content>
<x-modal.title> <x-modal.title>
{{ $event->meta->title ?? '(no title)' }} <div class="flex items-center gap-3">
<span class="inline-block h-3 w-3 rounded-full" style="background: {{ $calendarColor }};"></span>
<span>{{ $title }}</span>
</div>
</x-modal.title> </x-modal.title>
<x-modal.body> <x-modal.body>
<p class="text-gray-700"> <div class="flex flex-col gap-6">
{{ $start->format('l, F j, Y · g:i A') }} <section class="space-y-1">
@unless ($start->equalTo($end)) <p class="text-xs uppercase tracking-wide text-gray-400">{{ __('calendar.event.when') }}</p>
&nbsp;&nbsp; @if ($allDay)
{{ $end->isSameDay($start) <p class="text-lg text-gray-900">
? $end->format('g:i A') {{ $start->format('l, F j, Y') }}
: $end->format('l, F j, Y · g:i A') }} @unless ($start->isSameDay($end))
@endunless &nbsp;&nbsp;
</p> {{ $end->format('l, F j, Y') }}
@endunless
<span class="text-sm text-gray-500">({{ __('calendar.event.all_day') }})</span>
</p>
@else
<p class="text-lg text-gray-900">
{{ $start->format('l, F j, Y · g:i A') }}
@unless ($start->equalTo($end))
&nbsp;&nbsp;
{{ $end->isSameDay($start)
? $end->format('g:i A')
: $end->format('l, F j, Y · g:i A') }}
@endunless
</p>
@endif
<p class="text-sm text-gray-500">{{ __('common.timezone') }}: {{ $tzid }}</p>
</section>
@if ($event->meta->location) <section class="space-y-1">
<p><strong>Where:</strong> {{ $event->meta->location_label }}</p> <p class="text-xs uppercase tracking-wide text-gray-400">{{ __('common.calendar') }}</p>
@endif <p class="text-gray-900">{{ $calendarName }}</p>
</section>
@if ($event->meta->description) <section class="space-y-2">
<p> <p class="text-xs uppercase tracking-wide text-gray-400">{{ __('calendar.event.location') }}</p>
{!! Str::markdown(nl2br(e($event->meta->description))) !!} @if ($hasLocation)
</p> <p class="text-gray-900">{{ $locationLabel }}</p>
@endif @if ($addressLine1 || $addressLine2 || $addressLine3)
<div class="text-sm text-gray-600">
@if ($addressLine1)
<div>{{ $addressLine1 }}</div>
@endif
@if ($addressLine2)
<div>{{ $addressLine2 }}</div>
@endif
@if ($addressLine3)
<div>{{ $addressLine3 }}</div>
@endif
</div>
@endif
<div class="mt-2 rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4 text-sm text-gray-500">
{{ __('calendar.event.map_coming') }}
</div>
@else
<p class="text-sm text-gray-500">{{ __('calendar.event.no_location') }}</p>
@endif
</section>
<section class="space-y-2">
<p class="text-xs uppercase tracking-wide text-gray-400">{{ __('calendar.event.details') }}</p>
<div class="grid grid-cols-1 gap-3 text-sm text-gray-700">
<div>
<span class="text-gray-500">{{ __('calendar.event.repeats') }}:</span>
@if ($rrule)
<span class="ml-1 font-mono text-gray-800">{{ $rrule }}</span>
@else
<span class="ml-1 text-gray-500">{{ __('calendar.event.does_not_repeat') }}</span>
@endif
</div>
<div>
<span class="text-gray-500">{{ __('calendar.event.category') }}:</span>
<span class="ml-1">{{ $meta->category ?? __('calendar.event.none') }}</span>
</div>
<div>
<span class="text-gray-500">{{ __('calendar.event.visibility') }}:</span>
<span class="ml-1">{{ ($meta->is_private ?? false) ? __('calendar.event.private') : __('calendar.event.default') }}</span>
</div>
<div>
<span class="text-gray-500">{{ __('calendar.event.all_day_handling') }}:</span>
<span class="ml-1">
{{ $allDay ? __('calendar.event.all_day') : __('calendar.event.timed') }}
<span class="text-gray-400">· {{ __('calendar.event.all_day_coming') }}</span>
</span>
</div>
</div>
</section>
<section class="space-y-2">
<p class="text-xs uppercase tracking-wide text-gray-400">{{ __('calendar.event.alerts') }}</p>
@if (!is_null($meta->reminder_minutes))
<p class="text-sm text-gray-700">
{{ __('calendar.event.reminder') }}: {{ $meta->reminder_minutes }} {{ __('calendar.event.minutes_before') }}
</p>
@else
<p class="text-sm text-gray-500">{{ __('calendar.event.alerts_coming') }}</p>
@endif
</section>
<section class="space-y-2">
<p class="text-xs uppercase tracking-wide text-gray-400">{{ __('calendar.event.invitees') }}</p>
<p class="text-sm text-gray-500">{{ __('calendar.event.invitees_coming') }}</p>
</section>
<section class="space-y-2">
<p class="text-xs uppercase tracking-wide text-gray-400">{{ __('calendar.event.attachments') }}</p>
<p class="text-sm text-gray-500">{{ __('calendar.event.attachments_coming') }}</p>
</section>
<section class="space-y-2">
<p class="text-xs uppercase tracking-wide text-gray-400">{{ __('calendar.event.notes') }}</p>
@if ($meta->description)
<div class="prose prose-sm max-w-none text-gray-800">
{!! Str::markdown(nl2br(e($meta->description))) !!}
</div>
@else
<p class="text-sm text-gray-500">{{ __('calendar.event.no_description') }}</p>
@endif
</section>
</div>
</x-modal.body> </x-modal.body>
</x-modal.content> </x-modal.content>

View File

@ -28,9 +28,6 @@
<!-- bottom --> <!-- bottom -->
<section class="bottom"> <section class="bottom">
<x-button.icon type="anchor" :href="route('settings')">
<x-icon-settings class="w-7 h-7" />
</x-button.icon>
<x-button.icon type="anchor" :href="route('account.index')"> <x-button.icon type="anchor" :href="route('account.index')">
<x-icon-user-circle class="w-7 h-7" /> <x-icon-user-circle class="w-7 h-7" />
</x-button.icon> </x-button.icon>

View File

@ -2,32 +2,35 @@
use App\Models\User; use App\Models\User;
test('profile page is displayed', function () { test('account info page is displayed', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$response = $this $response = $this
->actingAs($user) ->actingAs($user)
->get('/profile'); ->get('/account/info');
$response->assertOk(); $response->assertOk();
}); });
test('profile information can be updated', function () { test('account information can be updated', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$response = $this $response = $this
->actingAs($user) ->actingAs($user)
->patch('/profile', [ ->patch('/account/info', [
'name' => 'Test User', 'firstname' => 'Test',
'lastname' => 'User',
'email' => 'test@example.com', 'email' => 'test@example.com',
]); ]);
$response $response
->assertSessionHasNoErrors() ->assertSessionHasNoErrors()
->assertRedirect('/profile'); ->assertRedirect('/account/info');
$user->refresh(); $user->refresh();
$this->assertSame('Test', $user->firstname);
$this->assertSame('User', $user->lastname);
$this->assertSame('Test User', $user->name); $this->assertSame('Test User', $user->name);
$this->assertSame('test@example.com', $user->email); $this->assertSame('test@example.com', $user->email);
$this->assertNull($user->email_verified_at); $this->assertNull($user->email_verified_at);
@ -38,14 +41,15 @@ test('email verification status is unchanged when the email address is unchanged
$response = $this $response = $this
->actingAs($user) ->actingAs($user)
->patch('/profile', [ ->patch('/account/info', [
'name' => 'Test User', 'firstname' => 'Test',
'lastname' => 'User',
'email' => $user->email, 'email' => $user->email,
]); ]);
$response $response
->assertSessionHasNoErrors() ->assertSessionHasNoErrors()
->assertRedirect('/profile'); ->assertRedirect('/account/info');
$this->assertNotNull($user->refresh()->email_verified_at); $this->assertNotNull($user->refresh()->email_verified_at);
}); });
@ -55,13 +59,13 @@ test('user can delete their account', function () {
$response = $this $response = $this
->actingAs($user) ->actingAs($user)
->delete('/profile', [ ->delete('/account', [
'password' => 'password', 'password' => 'password',
]); ]);
$response $response
->assertSessionHasNoErrors() ->assertSessionHasNoErrors()
->assertRedirect('/'); ->assertRedirect('/dashboard');
$this->assertGuest(); $this->assertGuest();
$this->assertNull($user->fresh()); $this->assertNull($user->fresh());
@ -72,14 +76,14 @@ test('correct password must be provided to delete account', function () {
$response = $this $response = $this
->actingAs($user) ->actingAs($user)
->from('/profile') ->from('/account/delete/confirm')
->delete('/profile', [ ->delete('/account', [
'password' => 'wrong-password', 'password' => 'wrong-password',
]); ]);
$response $response
->assertSessionHasErrorsIn('userDeletion', 'password') ->assertSessionHasErrorsIn('userDeletion', 'password')
->assertRedirect('/profile'); ->assertRedirect('/account/delete/confirm');
$this->assertNotNull($user->fresh()); $this->assertNotNull($user->fresh());
}); });

View File

@ -8,7 +8,7 @@ test('password can be updated', function () {
$response = $this $response = $this
->actingAs($user) ->actingAs($user)
->from('/profile') ->from('/account/password')
->put('/password', [ ->put('/password', [
'current_password' => 'password', 'current_password' => 'password',
'password' => 'new-password', 'password' => 'new-password',
@ -17,7 +17,7 @@ test('password can be updated', function () {
$response $response
->assertSessionHasNoErrors() ->assertSessionHasNoErrors()
->assertRedirect('/profile'); ->assertRedirect('/account/password');
$this->assertTrue(Hash::check('new-password', $user->refresh()->password)); $this->assertTrue(Hash::check('new-password', $user->refresh()->password));
}); });
@ -27,7 +27,7 @@ test('correct password must be provided to update password', function () {
$response = $this $response = $this
->actingAs($user) ->actingAs($user)
->from('/profile') ->from('/account/password')
->put('/password', [ ->put('/password', [
'current_password' => 'wrong-password', 'current_password' => 'wrong-password',
'password' => 'new-password', 'password' => 'new-password',
@ -36,5 +36,5 @@ test('correct password must be provided to update password', function () {
$response $response
->assertSessionHasErrorsIn('updatePassword', 'current_password') ->assertSessionHasErrorsIn('updatePassword', 'current_password')
->assertRedirect('/profile'); ->assertRedirect('/account/password');
}); });

View File

@ -0,0 +1,41 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
function createTestUser(array $overrides = []): User
{
return User::forceCreate(array_merge([
'firstname' => 'Test',
'lastname' => 'User',
'displayname' => 'Test User',
'email' => 'test+'.Str::uuid().'@example.com',
'timezone' => 'America/New_York',
'password' => Hash::make('password'),
], $overrides));
}
test('calendar index handles invalid date without error', function () {
$user = createTestUser();
$this->actingAs($user)
->get(route('calendar.index', ['date' => 'not-a-date']))
->assertOk();
});
test('daytime_hours persists to user settings', function () {
$user = createTestUser();
$this->actingAs($user)
->get(route('calendar.index', ['daytime_hours' => 1]))
->assertOk();
$value = DB::table('user_settings')
->where('user_id', $user->id)
->where('key', 'calendar.daytime_hours')
->value('value');
expect($value)->toBe('1');
});

View 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');
});

View 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();
});

View File

@ -1,5 +0,0 @@
<?php
test('that true is true', function () {
expect(true)->toBeTrue();
});