diff --git a/app/Http/Controllers/Auth/PasswordController.php b/app/Http/Controllers/Auth/PasswordController.php index 6916409..6712835 100644 --- a/app/Http/Controllers/Auth/PasswordController.php +++ b/app/Http/Controllers/Auth/PasswordController.php @@ -20,9 +20,9 @@ class PasswordController extends Controller 'password' => ['required', Password::defaults(), 'confirmed'], ]); - $request->user()->update([ + $request->user()->forceFill([ 'password' => Hash::make($validated['password']), - ]); + ])->save(); return back()->with('status', 'password-updated'); } diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php index 0739e2e..90fc357 100644 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -35,11 +35,11 @@ class RegisteredUserController extends Controller 'password' => ['required', 'confirmed', Rules\Password::defaults()], ]); - $user = User::create([ - 'name' => $request->name, - 'email' => $request->email, - 'password' => Hash::make($request->password), - ]); + $user = new User(); + $user->name = $request->name; + $user->email = $request->email; + $user->password = $request->password; + $user->save(); event(new Registered($user)); diff --git a/app/Http/Controllers/CalendarController.php b/app/Http/Controllers/CalendarController.php index dbdc7ad..aa93c50 100644 --- a/app/Http/Controllers/CalendarController.php +++ b/app/Http/Controllers/CalendarController.php @@ -4,21 +4,16 @@ namespace App\Http\Controllers; use Carbon\Carbon; use Illuminate\Http\Request; -use Illuminate\Support\Collection; -use Illuminate\Support\Str; -use Illuminate\Support\Facades\DB; use App\Models\Calendar; -use App\Models\CalendarMeta; -use App\Models\CalendarInstance; use App\Models\Event; -use App\Models\EventMeta; -use App\Models\Subscription; use App\Services\Calendar\CreateCalendar; +use App\Services\Calendar\CalendarRangeResolver; +use App\Services\Calendar\CalendarViewBuilder; +use App\Services\Calendar\CalendarSettingsPersister; +use App\Services\Event\EventRecurrence; class CalendarController extends Controller { - private const VIEWS = ['day', 'week', 'month', 'four']; - /** * Consolidated calendar dashboard. * @@ -32,7 +27,13 @@ class CalendarController extends Controller * ├─ calendars keyed by calendar id (for the left-hand toggle list) * └─ events flat list of VEVENTs in that range */ - public function index(Request $request) + 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'); // settings - $defaultView = $user->getSetting('calendar.last_view', 'month'); - $defaultDate = $user->getSetting('calendar.last_date', Carbon::today($tz)->toDateString()); - $defaultDensity = (int) $user->getSetting('calendar.last_density', 30); - - // week start preference - $weekStartPref = $user->getSetting('calendar.week_start', 'sunday'); // 'sunday'|'monday' - $weekStartPref = in_array($weekStartPref, ['sunday', 'monday'], true) - ? $weekStartPref - : 'sunday'; - $weekStart = $weekStartPref === 'monday' ? Carbon::MONDAY : Carbon::SUNDAY; - $weekEnd = (int) (($weekStart + 6) % 7); + $defaults = $settingsPersister->defaults($user, $tz); + $weekStart = $defaults['week_start']; + $weekEnd = $defaults['week_end']; // get the view and time range - [$view, $range] = $this->resolveRange($request, $tz, $weekStart, $weekEnd, $defaultView, $defaultDate); - $today = Carbon::today($tz)->toDateString(); + [$view, $range] = $rangeResolver->resolveRange( + $request, + $tz, + $weekStart, + $weekEnd, + $defaults['view'], + $defaults['date'] + ); - // get the display density, if present (in minutes for each step) - $stepMinutes = (int) $request->query('density', $defaultDensity); - if (! in_array($stepMinutes, [15, 30, 60], true)) { // lock it down - $stepMinutes = 30; - } - $labelEvery = match ($stepMinutes) { // how many 15-min slots per label/row - 15 => 1, - 30 => 2, - 60 => 4, - }; + $density = $settingsPersister->resolveDensity($request, $defaults['density']); + $stepMinutes = $density['step']; + $labelEvery = $density['label_every']; + + $daytimeHoursEnabled = $settingsPersister->resolveDaytimeHours($request, $defaults['daytime_hours']); + $daytimeHoursRange = $settingsPersister->daytimeHoursRange(); + $daytimeHoursRows = $daytimeHoursEnabled + ? intdiv((($daytimeHoursRange['end'] - $daytimeHoursRange['start']) * 60), 15) + : 96; + $daytimeHoursForView = ($daytimeHoursEnabled && in_array($view, ['day', 'week', 'four'], true)) + ? $daytimeHoursRange + : null; // date range span and controls - $span = $this->gridSpan($view, $range, $weekStart, $weekEnd); - $nav = $this->navDates($view, $range['start'], $tz); + $span = $rangeResolver->gridSpan($view, $range, $weekStart, $weekEnd); + $nav = $rangeResolver->navDates($view, $range['start'], $tz); // get the user's visible calendars from the left bar $visible = collect($request->query('c', [])); @@ -86,11 +87,14 @@ class CalendarController extends Controller $anchorDate = $request->query('date', now($tz)->toDateString()); // persist settings - if ($request->hasAny(['view', 'date', 'density'])) { - $user->setSetting('calendar.last_view', $view); - $user->setSetting('calendar.last_date', $range['start']->toDateString()); - $user->setSetting('calendar.last_density', (string) $stepMinutes); - } + $settingsPersister->persist( + $user, + $request, + $view, + $range['start'], + $stepMinutes, + $daytimeHoursEnabled + ); /** * @@ -118,78 +122,20 @@ class CalendarController extends Controller $calendars->pluck('id'), $span['start'], $span['end'] - )->map(function ($e) use ($calendar_map, $timeFormat, $view, $range, $tz, $weekStart, $weekEnd) { + ); - // event's calendar - $cal = $calendar_map[$e->calendarid]; - - // get utc dates from the database - $start_utc = $e->meta->start_at ?? - Carbon::createFromTimestamp($e->firstoccurence); - $end_utc = $e->meta->end_at ?? - ($e->lastoccurence ? Carbon::createFromTimestamp($e->lastoccurence) : null); - - // time format handling - $uiFormat = $timeFormat === '24' ? 'H:i' : 'g:ia'; - - // 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'); + // build event payload + $events = $viewBuilder->buildEventPayloads( + $events, + $calendar_map, + $timeFormat, + $view, + $range, + $tz, + $recurrence, + $span, + $daytimeHoursForView, + ); /** * @@ -198,10 +144,9 @@ class CalendarController extends Controller // create the mini calendar grid based on the mini cal controls $mini_anchor = $request->query('mini', $range['start']->toDateString()); + $mini_anchor_date = $rangeResolver->safeDate($mini_anchor, $tz, $range['start']->toDateString()); - // anchor is a DATE string, so create it explicitly in the user tz - $mini_start = Carbon::createFromFormat('Y-m-d', $mini_anchor, $tz) - ->startOfMonth(); + $mini_start = $mini_anchor_date->copy()->startOfMonth(); $mini_nav = [ 'prev' => $mini_start->copy()->subMonth()->toDateString(), @@ -209,7 +154,7 @@ class CalendarController extends Controller 'today' => Carbon::today($tz)->startOfMonth()->toDateString(), 'label' => $mini_start->format('F Y'), ]; - $mini_headers = $this->weekdayHeaders($tz, $weekStart); + $mini_headers = $viewBuilder->weekdayHeaders($tz, $weekStart); // compute the mini's 42-day span (Mon..Sun, 6 rows) $mini_grid_start = $mini_start->copy()->startOfWeek($weekStart); @@ -223,35 +168,22 @@ class CalendarController extends Controller $calendars->pluck('id'), $mini_grid_start, $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); - $end_utc = $e->meta->end_at ?? ($e->lastoccurence ? Carbon::createFromTimestamp($e->lastoccurence) : null); - - $color = $cal->meta_color - ?? $cal->calendarcolor - ?? default_calendar_color(); - $colorFg = $cal->meta_color_fg - ?? contrast_text_color($color); - - return [ - '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'); + $mini_events = $viewBuilder->buildEventPayloads( + $mini_events, + $calendar_map, + $timeFormat, + $view, + ['start' => $mini_grid_start, 'end' => $mini_grid_end], + $tz, + $recurrence, + ['start' => $mini_grid_start, 'end' => $mini_grid_end], + null, + ); // 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 - $grid = $this->buildCalendarGrid($view, $range, $events, $tz, $weekStart, $weekEnd); + $grid = $viewBuilder->buildCalendarGrid($view, $range, $events, $tz, $span); // get the title - $header = $this->headerTitle($view, $range['start'], $range['end']); + $header = $rangeResolver->headerTitle($view, $range['start'], $range['end']); // format the data for the frontend, including separate arrays for events specifically and the big grid $payload = [ - 'view' => $view, - 'range' => $range, - 'nav' => $nav, - 'header' => $header, - 'active' => [ - 'date' => $range['start']->toDateString(), - 'year' => $range['start']->format('Y'), - 'month' => $range['start']->format("F"), - 'day' => $range['start']->format("d"), + 'view' => $view, + 'range' => $range, + 'nav' => $nav, + 'header' => $header, + 'week_start' => $weekStart, + 'hgroup' => $viewBuilder->viewHeaders($view, $range, $tz, $weekStart), + 'events' => $events, // keyed by occurrence + 'grid' => $grid, // day objects hold only ID-sets + 'mini' => $mini, // mini calendar days with events for indicators + 'mini_nav' => $mini_nav, // separate mini calendar navigation + 'mini_headers' => $mini_headers, + 'active' => [ + 'date' => $range['start']->toDateString(), + 'year' => $range['start']->format('Y'), + 'month' => $range['start']->format("F"), + 'day' => $range['start']->format("d"), ], - 'week_start' => $weekStart, - 'calendars' => $calendars->mapWithKeys(function ($cal) + 'daytime_hours' => [ + 'enabled' => $daytimeHoursEnabled, + 'start' => $daytimeHoursRange['start'], + 'end' => $daytimeHoursRange['end'], + 'rows' => $daytimeHoursRows, + ], + 'timezone' => $tz, + 'calendars' => $calendars->mapWithKeys(function ($cal) { - // compute colors $color = $cal->meta_color ?? $cal->calendarcolor ?? default_calendar_color(); @@ -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 $timeBased = in_array($view, ['day', 'week', 'four'], true); - if ($timeBased) - { + if ($timeBased) { // create the time gutter if we're in a time-based view - $payload['slots'] = $this->timeSlots($range['start'], $tz, $timeFormat); + $payload['slots'] = $viewBuilder->timeSlots( + $range['start'], + $tz, + $timeFormat, + $daytimeHoursEnabled ? $daytimeHoursRange : null + ); $payload['time_format'] = $timeFormat; // optional, if the blade cares // 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 @@ -452,513 +401,4 @@ class CalendarController extends Controller * Private helpers */ - /** - * - * Prepare nav dates for the movement buttons in the calendar header - * - * @return array - * [ - * 'prev' => 2026-08-19 - * 'next' => 2026-08-21 - * 'today' => 2026-08-20 - * ] - */ - private function navDates(string $view, Carbon $start, string $tz): array - { - // always compute in the user tz so the UX is consistent - $start = $start->copy()->tz($tz); - - return match ($view) - { - 'day' => [ - 'prev' => $start->copy()->subDay()->toDateString(), - 'next' => $start->copy()->addDay()->toDateString(), - 'today' => Carbon::today($tz)->toDateString(), - ], - 'week' => [ - 'prev' => $start->copy()->subWeek()->toDateString(), - 'next' => $start->copy()->addWeek()->toDateString(), - 'today' => Carbon::today($tz)->toDateString(), - ], - 'four' => [ - 'prev' => $start->copy()->subDays(4)->toDateString(), - 'next' => $start->copy()->addDays(4)->toDateString(), - 'today' => Carbon::today($tz)->toDateString(), - ], - default => [ // month - 'prev' => $start->copy()->subMonth()->startOfMonth()->toDateString(), - 'next' => $start->copy()->addMonth()->startOfMonth()->toDateString(), - 'today' => Carbon::today($tz)->toDateString(), - ], - }; - } - - /** - * - * Interpret $view and $date filters and normalize into a Carbon range - * - * @return array - * [ - * $view, - * [ - * 'start' => Carbon, - * 'end' => Carbon - * ] - * ] - */ - private function resolveRange( - Request $request, - string $tz, - int $weekStart, - int $weekEnd, - string $defaultView, - string $defaultDate - ): array { - // get the view - $requestView = $request->query('view', $defaultView); - $view = in_array($requestView, self::VIEWS, true) - ? $requestView - : 'month'; - $date = $request->query('date', $defaultDate); - - // anchor date in the user's timezone - $anchor = Carbon::createFromFormat('Y-m-d', $date, $tz)->startOfDay(); - - // set dates based on view - switch ($view) - { - case 'day': - $start = $anchor->copy()->startOfDay(); - $end = $anchor->copy()->endOfDay(); - break; - - case 'week': - $start = $anchor->copy()->startOfWeek($weekStart); - $end = $anchor->copy()->endOfWeek($weekEnd); - break; - - case 'four': - // a rolling 4-day "agenda" view starting at anchor - $start = $anchor->copy()->startOfDay(); - $end = $anchor->copy()->addDays(3)->endOfDay(); - break; - - default: // month - $start = $anchor->copy()->startOfMonth(); - $end = $anchor->copy()->endOfMonth(); - } - - return [$view, ['start' => $start, 'end' => $end]]; - } - - /** - * Create the grid for each view with pre- and post-padding if needed - * - * This is different from resolveRange() as the rendered span != logical range - * - * Month: start of the month's first week to end of the last week - * Week: Sunday to Saturday - * Four: selected day to 3 days later - * Day: start of the day to the end of the day - */ - private function gridSpan(string $view, array $range, int $weekStart, int $weekEnd): array - { - switch ($view) - { - case 'day': - $start = $range['start']->copy()->startOfDay(); - $end = $range['start']->copy()->endOfDay(); - break; - - case 'week': - $start = $range['start']->copy()->startOfWeek($weekStart); - $end = $range['start']->copy()->endOfWeek($weekEnd); - break; - - case 'four': - $start = $range['start']->copy()->startOfDay(); - $end = $range['start']->copy()->addDays(3); - break; - - default: // month - $start = $range['start']->copy()->startOfMonth()->startOfWeek($weekStart); - $end = $range['end']->copy()->endOfMonth()->endOfWeek($weekEnd); - } - - return ['start' => $start, 'end' => $end]; - } - - /** - * - * Prepare the calendar title based on the view - * - * @return array - * [ - * 'strong' => 'August 20', - * 'span' => '2026', - * ] - */ - private function headerTitle(string $view, Carbon $start, Carbon $end): array - { - $sameDay = $start->isSameDay($end); - $sameMonth = $start->isSameMonth($end); - $sameYear = $start->isSameYear($end); - - // month default - $strong = $start->format('F'); - $span = $start->format('Y'); - - if ($view === 'day' || $sameDay) { - return [ - 'strong' => $start->format('F j'), - 'span' => $start->format('Y'), - ]; - } - - if (in_array($view, ['week', 'four'], true)) { - if ($sameMonth && $sameYear) { - return [ - 'strong' => $start->format('F j') . ' to ' . $end->format('j'), - 'span' => $start->format('Y'), - ]; - } - - if ($sameYear) { - return [ - 'strong' => $start->format('F') . ' to ' . $end->format('F'), - 'span' => $start->format('Y'), - ]; - } - - return [ - 'strong' => $start->format('F Y') . ' to ' . $end->format('F Y'), - 'span' => null, - ]; - } - - return ['strong' => $strong, 'span' => $span]; - } - - /** - * - * Create the time gutter for time-based views - */ - private function timeSlots(Carbon $dayStart, string $tz, string $timeFormat): array - { - $minutesPerSlot = 15; - $slotsPerDay = intdiv(24 * 60, $minutesPerSlot); // 96 - - $format = $timeFormat === '24' ? 'H:i' : 'g:i a'; - - $slots = []; - $t = $dayStart->copy()->tz($tz)->startOfDay(); - - for ($i = 0; $i < $slotsPerDay; $i++) { - $slots[] = [ - 'iso' => $t->toIso8601String(), - 'label' => $t->format($format), - 'key' => $t->format('H:i'), // stable "machine" value - 'index' => $i, // 0..95 - 'minutes' => $i * $minutesPerSlot, - 'duration' => $minutesPerSlot, // handy for styling math - ]; - - $t->addMinutes($minutesPerSlot); - } - - return $slots; - } - - /** - * - * Time-based layout slot placement - * - * Placements object: - * [ - * 'start_line' => '24', - * 'end_line' => '32', - * 'span' => '4', - * 'duration' => '60', - * ] - **/ - private function slotPlacement( - Carbon $startLocal, - ?Carbon $endLocal, - Carbon $rangeStart, - string $view, - int $minutesPerSlot = 15 - ): array - { - $start = $startLocal->copy(); - $end = ($endLocal ?? $startLocal)->copy(); - - // get the real duration in minutes - $durationMinutes = max(0, $start->diffInMinutes($end, false)); - - // duration for display purposes - $displayMinutes = $durationMinutes > 0 ? $durationMinutes : $minutesPerSlot; - - // row placement (96 rows when minutesPerSlot=15) - $startMinutesFromMidnight = ($start->hour * 60) + $start->minute; - $startRow = intdiv($startMinutesFromMidnight, $minutesPerSlot) + 1; - - $rowSpan = max(1, (int) ceil($displayMinutes / $minutesPerSlot)); - $endRow = $startRow + $rowSpan; - - // column placement - $maxCols = match ($view) { - 'day' => 1, - 'four' => 4, - 'week' => 7, - default => 1, // month won’t use this - }; - // rangeStart is already the "first day column" for week/four/day - $startCol = $rangeStart->copy()->startOfDay()->diffInDays($start->copy()->startOfDay()) + 1; - - // clamp to view columns - $startCol = max(1, min($maxCols, $startCol)); - - return [ - 'start_row' => $startRow, - 'end_row' => $endRow, - 'row_span' => $rowSpan, - 'duration' => $durationMinutes, - 'start_col' => $startCol, - ]; - } - - /** - * - * 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, - ]; - } } diff --git a/app/Http/Controllers/EventController.php b/app/Http/Controllers/EventController.php index 8cf24df..22ce043 100644 --- a/app/Http/Controllers/EventController.php +++ b/app/Http/Controllers/EventController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use App\Models\Calendar; use App\Models\Event; use App\Models\Location; +use App\Services\Event\EventRecurrence; use App\Services\Location\Geocoder; use Carbon\Carbon; use Illuminate\Http\RedirectResponse; @@ -12,12 +13,11 @@ use Illuminate\Http\Request; use Illuminate\Support\Str; use Illuminate\Support\Facades\Log; use Symfony\Component\HttpFoundation\Response; -use Sabre\VObject\Reader; class EventController extends Controller { /** - * create a new event page + * create a new event */ public function create(Calendar $calendar, Request $request) { @@ -47,6 +47,7 @@ class EventController extends Controller $start = $anchor->copy()->format('Y-m-d\TH:i'); $end = $anchor->copy()->addHour()->format('Y-m-d\TH:i'); + $rrule = ''; return view('event.form', compact( 'calendar', @@ -55,13 +56,14 @@ class EventController extends Controller 'start', 'end', '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); @@ -83,13 +85,17 @@ class EventController extends Controller ? Carbon::parse($event->meta->end_at)->timezone($tz)->format('Y-m-d\TH:i') : 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 */ - 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) { abort(Response::HTTP_NOT_FOUND); @@ -102,16 +108,29 @@ class EventController extends Controller $isHtmx = $request->header('HX-Request') === 'true'; $tz = $this->displayTimezone($calendar, $request); - // prefer meta utc timestamps, fall back to sabre columns - $startUtc = $event->meta?->start_at - ? Carbon::parse($event->meta->start_at)->utc() - : Carbon::createFromTimestamp($event->firstoccurence, 'UTC'); + // prefer occurrence when supplied (recurring events), fall back to meta, then sabre columns + $occurrenceParam = $request->query('occurrence'); + $occurrenceStart = null; + 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() : ($event->lastoccurence ? Carbon::createFromTimestamp($event->lastoccurence, 'UTC') - : $startUtc->copy()); + : $startUtc->copy())); // convert for display $start = $startUtc->copy()->timezone($tz); @@ -127,7 +146,7 @@ class EventController extends Controller /** * insert vevent into sabre’s 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); @@ -139,6 +158,7 @@ class EventController extends Controller 'location' => ['nullable', 'string'], 'all_day' => ['sometimes', 'boolean'], 'category' => ['nullable', 'string', 'max:50'], + 'rrule' => ['nullable', 'string', 'max:255'], // normalized location hints (optional) 'loc_display_name' => ['nullable', 'string'], @@ -160,25 +180,19 @@ class EventController extends Controller $uid = Str::uuid() . '@' . parse_url(config('app.url'), PHP_URL_HOST); - $description = $this->escapeIcsText($data['description'] ?? ''); - $locationStr = $this->escapeIcsText($data['location'] ?? ''); + $rrule = $this->normalizeRrule($request); + $extra = $this->mergeRecurrenceExtra([], $rrule, $tz, $request); - // write dtstart/dtend as utc with "Z" so we have one canonical representation - $ical = <<format('Ymd\\THis\\Z')} -DTSTART:{$startUtc->format('Ymd\\THis\\Z')} -DTEND:{$endUtc->format('Ymd\\THis\\Z')} -SUMMARY:{$this->escapeIcsText($data['title'])} -DESCRIPTION:$description -LOCATION:$locationStr -END:VEVENT -END:VCALENDAR -ICS; + $ical = $recurrence->buildCalendar([ + 'uid' => $uid, + 'start_utc' => $startUtc, + 'end_utc' => $endUtc, + 'summary' => $data['title'], + 'description' => $data['description'] ?? '', + 'location' => $data['location'] ?? '', + 'tzid' => $rrule ? $tz : null, + 'rrule' => $rrule, + ]); $event = Event::create([ 'calendarid' => $calendar->id, @@ -202,6 +216,7 @@ ICS; 'category' => $data['category'] ?? null, 'start_at' => $startUtc, 'end_at' => $endUtc, + 'extra' => $extra, ]); return redirect()->route('calendar.show', $calendar); @@ -210,7 +225,7 @@ ICS; /** * 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); @@ -226,6 +241,7 @@ ICS; 'location' => ['nullable', 'string'], 'all_day' => ['sometimes', 'boolean'], 'category' => ['nullable', 'string', 'max:50'], + 'rrule' => ['nullable', 'string', 'max:255'], ]); $tz = $this->displayTimezone($calendar, $request); @@ -235,25 +251,23 @@ ICS; $uid = $event->uid; - $description = $this->escapeIcsText($data['description'] ?? ''); - $locationStr = $this->escapeIcsText($data['location'] ?? ''); - $summary = $this->escapeIcsText($data['title']); + $rrule = $this->normalizeRrule($request); + $extra = $event->meta?->extra ?? []; + $extra = $this->mergeRecurrenceExtra($extra, $rrule, $tz, $request); + $rruleForIcs = $rrule ?? ($extra['rrule'] ?? $recurrence->extractRrule($event)); - $ical = <<format('Ymd\\THis\\Z')} -DTSTART:{$startUtc->format('Ymd\\THis\\Z')} -DTEND:{$endUtc->format('Ymd\\THis\\Z')} -SUMMARY:$summary -DESCRIPTION:$description -LOCATION:$locationStr -END:VEVENT -END:VCALENDAR -ICS; + $ical = $recurrence->buildCalendar([ + 'uid' => $uid, + 'start_utc' => $startUtc, + 'end_utc' => $endUtc, + 'summary' => $data['title'], + 'description' => $data['description'] ?? '', + 'location' => $data['location'] ?? '', + 'tzid' => $rruleForIcs ? $tz : null, + 'rrule' => $rruleForIcs, + 'exdate' => $extra['exdate'] ?? [], + 'rdate' => $extra['rdate'] ?? [], + ]); $event->update([ 'calendardata' => $ical, @@ -269,6 +283,7 @@ ICS; 'category' => $data['category'] ?? null, 'start_at' => $startUtc, 'end_at' => $endUtc, + 'extra' => $extra, ]); return redirect()->route('calendar.show', $calendar); @@ -305,6 +320,55 @@ ICS; 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 */ diff --git a/app/Http/Controllers/IcsController.php b/app/Http/Controllers/IcsController.php index 670dd2a..da3b636 100644 --- a/app/Http/Controllers/IcsController.php +++ b/app/Http/Controllers/IcsController.php @@ -5,6 +5,8 @@ namespace App\Http\Controllers; use App\Models\CalendarInstance; use Illuminate\Support\Facades\Response; use Carbon\Carbon; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Reader; class IcsController extends Controller { @@ -25,39 +27,48 @@ class IcsController extends Controller protected function generateICalendarFeed($events, string $tz): string { - $output = []; - $output[] = 'BEGIN:VCALENDAR'; - $output[] = 'VERSION:2.0'; - $output[] = 'PRODID:-//Kithkin Calendar//EN'; - $output[] = 'CALSCALE:GREGORIAN'; - $output[] = 'METHOD:PUBLISH'; + $vcalendar = new VCalendar(); + $vcalendar->add('VERSION', '2.0'); + $vcalendar->add('PRODID', '-//Kithkin Calendar//EN'); + $vcalendar->add('CALSCALE', 'GREGORIAN'); + $vcalendar->add('METHOD', 'PUBLISH'); 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) { continue; } - $start = Carbon::parse($meta->start_at)->timezone($tz)->format('Ymd\THis'); - $end = Carbon::parse($meta->end_at)->timezone($tz)->format('Ymd\THis'); + $start = Carbon::parse($meta->start_at)->timezone($tz); + $end = Carbon::parse($meta->end_at)->timezone($tz); - $output[] = 'BEGIN:VEVENT'; - $output[] = 'UID:' . $event->uid; - $output[] = 'SUMMARY:' . $this->escape($meta->title ?? '(Untitled)'); - $output[] = 'DESCRIPTION:' . $this->escape($meta->description ?? ''); - $output[] = 'DTSTART;TZID=' . $tz . ':' . $start; - $output[] = 'DTEND;TZID=' . $tz . ':' . $end; - $output[] = 'DTSTAMP:' . Carbon::parse($event->lastmodified)->format('Ymd\THis\Z'); + $vevent = $vcalendar->add('VEVENT', []); + $vevent->add('UID', $event->uid); + $vevent->add('SUMMARY', $meta->title ?? '(Untitled)'); + $vevent->add('DESCRIPTION', $meta->description ?? ''); + $vevent->add('DTSTART', $start, ['TZID' => $tz]); + $vevent->add('DTEND', $end, ['TZID' => $tz]); + $vevent->add('DTSTAMP', Carbon::parse($event->lastmodified)->utc()); if ($meta->location) { - $output[] = 'LOCATION:' . $this->escape($meta->location); + $vevent->add('LOCATION', $meta->location); } - $output[] = 'END:VEVENT'; } - $output[] = 'END:VCALENDAR'; - - return implode("\r\n", $output); + return $vcalendar->serialize(); } protected function escape(?string $text): string diff --git a/app/Models/Event.php b/app/Models/Event.php index 549cb1e..53f0680 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -53,12 +53,22 @@ class Event extends Model **/ public function scopeInRange($query, $start, $end) { - return $query->whereHas('meta', function ($q) use ($start, $end) { - $q->where('start_at', '<=', $end) - ->where(function ($qq) use ($start) { - $qq->where('end_at', '>=', $start) - ->orWhereNull('end_at'); - }); + return $query->where(function ($q) use ($start, $end) { + $q->whereHas('meta', function ($meta) use ($start, $end) { + $meta->where(function ($range) use ($start, $end) { + $range->where('start_at', '<=', $end) + ->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%'); + }); }); } diff --git a/app/Models/User.php b/app/Models/User.php index 5f66828..72e7101 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -30,6 +30,7 @@ class User extends Authenticatable 'firstname', 'lastname', 'displayname', + 'name', 'email', 'timezone', '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 */ diff --git a/app/Services/Calendar/CalendarRangeResolver.php b/app/Services/Calendar/CalendarRangeResolver.php new file mode 100644 index 0000000..072ad69 --- /dev/null +++ b/app/Services/Calendar/CalendarRangeResolver.php @@ -0,0 +1,165 @@ + Carbon, 'end' => Carbon]] + */ + public function resolveRange( + Request $request, + string $tz, + int $weekStart, + int $weekEnd, + string $defaultView, + string $defaultDate + ): array { + $requestView = $request->query('view', $defaultView); + $view = in_array($requestView, self::VIEWS, true) + ? $requestView + : 'month'; + + $date = $request->query('date', $defaultDate); + $anchor = $this->safeDate($date, $tz, $defaultDate); + + switch ($view) { + case 'day': + $start = $anchor->copy()->startOfDay(); + $end = $anchor->copy()->endOfDay(); + break; + case 'week': + $start = $anchor->copy()->startOfWeek($weekStart); + $end = $anchor->copy()->endOfWeek($weekEnd); + break; + case 'four': + $start = $anchor->copy()->startOfDay(); + $end = $anchor->copy()->addDays(3)->endOfDay(); + break; + default: // month + $start = $anchor->copy()->startOfMonth(); + $end = $anchor->copy()->endOfMonth(); + } + + return [$view, ['start' => $start, 'end' => $end]]; + } + + /** + * Calendar grid span differs from logical range (e.g. month padding). + */ + public function gridSpan(string $view, array $range, int $weekStart, int $weekEnd): array + { + switch ($view) { + case 'day': + $start = $range['start']->copy()->startOfDay(); + $end = $range['start']->copy()->endOfDay(); + break; + case 'week': + $start = $range['start']->copy()->startOfWeek($weekStart); + $end = $range['start']->copy()->endOfWeek($weekEnd); + break; + case 'four': + $start = $range['start']->copy()->startOfDay(); + $end = $range['start']->copy()->addDays(3); + break; + default: // month + $start = $range['start']->copy()->startOfMonth()->startOfWeek($weekStart); + $end = $range['end']->copy()->endOfMonth()->endOfWeek($weekEnd); + } + + return ['start' => $start, 'end' => $end]; + } + + /** + * Navigation dates for header controls. + */ + public function navDates(string $view, Carbon $start, string $tz): array + { + $start = $start->copy()->tz($tz); + + return match ($view) { + 'day' => [ + 'prev' => $start->copy()->subDay()->toDateString(), + 'next' => $start->copy()->addDay()->toDateString(), + 'today' => Carbon::today($tz)->toDateString(), + ], + 'week' => [ + 'prev' => $start->copy()->subWeek()->toDateString(), + 'next' => $start->copy()->addWeek()->toDateString(), + 'today' => Carbon::today($tz)->toDateString(), + ], + 'four' => [ + 'prev' => $start->copy()->subDays(4)->toDateString(), + 'next' => $start->copy()->addDays(4)->toDateString(), + 'today' => Carbon::today($tz)->toDateString(), + ], + default => [ + 'prev' => $start->copy()->subMonth()->startOfMonth()->toDateString(), + 'next' => $start->copy()->addMonth()->startOfMonth()->toDateString(), + 'today' => Carbon::today($tz)->toDateString(), + ], + }; + } + + /** + * Title text for the calendar header. + */ + public function headerTitle(string $view, Carbon $start, Carbon $end): array + { + $sameDay = $start->isSameDay($end); + $sameMonth = $start->isSameMonth($end); + $sameYear = $start->isSameYear($end); + + $strong = $start->format('F'); + $span = $start->format('Y'); + + if ($view === 'day' || $sameDay) { + return [ + 'strong' => $start->format('F j'), + 'span' => $start->format('Y'), + ]; + } + + if (in_array($view, ['week', 'four'], true)) { + if ($sameMonth && $sameYear) { + return [ + 'strong' => $start->format('F j') . ' to ' . $end->format('j'), + 'span' => $start->format('Y'), + ]; + } + + if ($sameYear) { + return [ + 'strong' => $start->format('F') . ' to ' . $end->format('F'), + 'span' => $start->format('Y'), + ]; + } + + return [ + 'strong' => $start->format('F Y') . ' to ' . $end->format('F Y'), + 'span' => null, + ]; + } + + return ['strong' => $strong, 'span' => $span]; + } + + /** + * Safe date parsing with fallback to a default date string. + */ + public function safeDate(string $date, string $tz, string $fallbackDate): Carbon + { + try { + return Carbon::createFromFormat('Y-m-d', $date, $tz)->startOfDay(); + } catch (\Throwable $e) { + return Carbon::createFromFormat('Y-m-d', $fallbackDate, $tz)->startOfDay(); + } + } +} diff --git a/app/Services/Calendar/CalendarSettingsPersister.php b/app/Services/Calendar/CalendarSettingsPersister.php new file mode 100644 index 0000000..76f7134 --- /dev/null +++ b/app/Services/Calendar/CalendarSettingsPersister.php @@ -0,0 +1,83 @@ +getSetting('calendar.last_view', 'month'); + $defaultDate = $user->getSetting('calendar.last_date', Carbon::today($tz)->toDateString()); + $defaultDensity = (int) $user->getSetting('calendar.last_density', 30); + $defaultDaytimeHours = (int) $user->getSetting('calendar.daytime_hours', 0); + + $weekStartPref = $user->getSetting('calendar.week_start', 'sunday'); + $weekStartPref = in_array($weekStartPref, ['sunday', 'monday'], true) + ? $weekStartPref + : 'sunday'; + $weekStart = $weekStartPref === 'monday' ? Carbon::MONDAY : Carbon::SUNDAY; + $weekEnd = (int) (($weekStart + 6) % 7); + + return [ + 'view' => $defaultView, + 'date' => $defaultDate, + 'density' => $defaultDensity, + 'daytime_hours' => $defaultDaytimeHours, + 'week_start_pref' => $weekStartPref, + 'week_start' => $weekStart, + 'week_end' => $weekEnd, + ]; + } + + public function resolveDensity(Request $request, int $defaultDensity): array + { + $stepMinutes = (int) $request->query('density', $defaultDensity); + if (!in_array($stepMinutes, [15, 30, 60], true)) { + $stepMinutes = 30; + } + + $labelEvery = match ($stepMinutes) { + 15 => 1, + 30 => 2, + 60 => 4, + }; + + return [ + 'step' => $stepMinutes, + 'label_every' => $labelEvery, + ]; + } + + public function resolveDaytimeHours(Request $request, int $defaultDaytimeHours): bool + { + return (int) $request->query('daytime_hours', $defaultDaytimeHours) === 1; + } + + public function daytimeHoursRange(): array + { + return ['start' => 8, 'end' => 18]; + } + + public function persist( + User $user, + Request $request, + string $view, + Carbon $rangeStart, + int $stepMinutes, + bool $daytimeHoursEnabled + ): void { + if ($request->hasAny(['view', 'date', 'density'])) { + $user->setSetting('calendar.last_view', $view); + $user->setSetting('calendar.last_date', $rangeStart->toDateString()); + $user->setSetting('calendar.last_density', (string) $stepMinutes); + } + + if ($request->has('daytime_hours')) { + $user->setSetting('calendar.daytime_hours', $daytimeHoursEnabled ? '1' : '0'); + } + } +} diff --git a/app/Services/Calendar/CalendarViewBuilder.php b/app/Services/Calendar/CalendarViewBuilder.php new file mode 100644 index 0000000..9b4423d --- /dev/null +++ b/app/Services/Calendar/CalendarViewBuilder.php @@ -0,0 +1,460 @@ +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)); + } +} diff --git a/app/Services/Event/EventRecurrence.php b/app/Services/Event/EventRecurrence.php new file mode 100644 index 0000000..285f1ab --- /dev/null +++ b/app/Services/Event/EventRecurrence.php @@ -0,0 +1,185 @@ +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; + } + } +} diff --git a/database/migrations/2025_07_15_000000_create_sabre_schema.php b/database/migrations/2025_07_15_000000_create_sabre_schema.php index 3901e95..4043bfa 100644 --- a/database/migrations/2025_07_15_000000_create_sabre_schema.php +++ b/database/migrations/2025_07_15_000000_create_sabre_schema.php @@ -3,6 +3,7 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Schema; return new class extends Migration { @@ -12,26 +13,38 @@ return new class extends Migration 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 { - // Disable FK checks for smooth batch execution - DB::statement('SET FOREIGN_KEY_CHECKS = 0'); + $prefix = $this->prefix(); + Schema::disableForeignKeyConstraints(); // Principals (users & groups) - DB::unprepared(File::get($this->sql('mysql.principals.sql'))); + DB::unprepared(File::get($this->sql("{$prefix}.principals.sql"))); // 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 - 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 { - DB::statement('SET FOREIGN_KEY_CHECKS = 0'); + $this->prefix(); + Schema::disableForeignKeyConstraints(); // Drop in reverse dependency order DB::statement('DROP TABLE IF EXISTS @@ -47,6 +60,6 @@ return new class extends Migration groupmembers '); - DB::statement('SET FOREIGN_KEY_CHECKS = 1'); + Schema::enableForeignKeyConstraints(); } }; diff --git a/database/migrations/2025_08_20_000000_add_indexes_to_locations_table.php b/database/migrations/2025_08_20_000000_add_indexes_to_locations_table.php index 466f90d..ab0a667 100644 --- a/database/migrations/2025_08_20_000000_add_indexes_to_locations_table.php +++ b/database/migrations/2025_08_20_000000_add_indexes_to_locations_table.php @@ -9,7 +9,9 @@ return new class extends Migration // add composite + geo + optional fulltext indexes to locations 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 $table->index( ['display_name', 'city', 'state', 'postal', 'country'], @@ -21,17 +23,23 @@ return new class extends Migration // optional: fulltext index for free-form text searching // 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() 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_lat_lon_idx'); - $table->dropFullText('locations_raw_address_fulltext'); + if (in_array($driver, ['mysql', 'pgsql'], true)) { + $table->dropFullText('locations_raw_address_fulltext'); + } }); } -}; \ No newline at end of file +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 1681548..1c3c07f 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -8,6 +8,7 @@ use Illuminate\Support\Str; use Illuminate\Support\Facades\DB; use Carbon\Carbon; use App\Models\User; +use App\Services\Event\EventRecurrence; 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 @@ -226,7 +289,7 @@ ICS; $now = Carbon::now($tz)->setSeconds(0); // 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(4), 'Baseball practice', 'Meadow Park'); @@ -244,6 +307,16 @@ ICS; $insertEvent($future5a, 'Teacher conference (3rd grade)', 'Fairview Elementary'); $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 diff --git a/lang/en/calendar.php b/lang/en/calendar.php index a9051ed..19e32af 100644 --- a/lang/en/calendar.php +++ b/lang/en/calendar.php @@ -46,6 +46,35 @@ return [ 'saved' => 'Your calendar settings have been saved!', '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.', + ], ]; diff --git a/lang/it/account.php b/lang/it/account.php new file mode 100644 index 0000000..d19682b --- /dev/null +++ b/lang/it/account.php @@ -0,0 +1,68 @@ + [ + '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—non stiamo impostando is_deleted = 1, 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', + +]; diff --git a/lang/it/auth.php b/lang/it/auth.php new file mode 100644 index 0000000..12a48b9 --- /dev/null +++ b/lang/it/auth.php @@ -0,0 +1,20 @@ + 'Queste credenziali non corrispondono ai nostri record.', + 'password' => 'La password fornita non e corretta.', + 'throttle' => 'Troppi tentativi di accesso. Riprova tra :seconds secondi.', + +]; diff --git a/lang/it/calendar.php b/lang/it/calendar.php new file mode 100644 index 0000000..2e22574 --- /dev/null +++ b/lang/it/calendar.php @@ -0,0 +1,80 @@ + '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 :calendar.' + ], + '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.', + ], + +]; diff --git a/lang/it/common.php b/lang/it/common.php index b84840e..659add8 100644 --- a/lang/it/common.php +++ b/lang/it/common.php @@ -2,10 +2,41 @@ return [ + /* + |-------------------------------------------------------------------------- + | Common words and phrases + |-------------------------------------------------------------------------- + | + | Generic words used throughout the app in more than one location. + | + */ + + 'address' => 'Indirizzo', + 'addresses' => 'Indirizzi', 'calendar' => 'Calendario', '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', 'events' => 'Eventi', + 'language' => 'Lingua', + 'language_select' => 'Seleziona una lingua', + 'password' => 'Password', + 'region' => 'Regione', + 'region_select' => 'Seleziona una regione', + 'save_changes' => 'Salva modifiche', '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', ]; diff --git a/lang/it/pagination.php b/lang/it/pagination.php new file mode 100644 index 0000000..9d6a2e2 --- /dev/null +++ b/lang/it/pagination.php @@ -0,0 +1,19 @@ + '« Precedente', + 'next' => 'Successivo »', + +]; diff --git a/lang/it/passwords.php b/lang/it/passwords.php new file mode 100644 index 0000000..f337db6 --- /dev/null +++ b/lang/it/passwords.php @@ -0,0 +1,22 @@ + '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.', + +]; diff --git a/lang/it/validation.php b/lang/it/validation.php new file mode 100644 index 0000000..dafb622 --- /dev/null +++ b/lang/it/validation.php @@ -0,0 +1,198 @@ + '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' => [], + +]; diff --git a/resources/css/etc/layout.css b/resources/css/etc/layout.css index 8cd949f..19409c2 100644 --- a/resources/css/etc/layout.css +++ b/resources/css/etc/layout.css @@ -98,6 +98,7 @@ body { main { @apply overflow-hidden rounded-lg; max-height: calc(100dvh - 1rem); + container: main / inline-size; /* app */ body#app & { @@ -186,11 +187,12 @@ main { @apply overflow-y-auto; grid-template-rows: 5rem auto; container: content / inline-size; + transition: margin 200ms ease-in-out, width 200ms ease-in-out; /* main content title and actions */ - > header { + header { @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 */ h1 { @@ -208,9 +210,19 @@ main { } } + /* expand button */ + button.calendar-expand-toggle { + svg { + transition: transform 150ms ease-in-out; + } + } + /* header 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 */ &#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 { /* */ } } + + /* 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 */ @@ -276,8 +322,8 @@ main { */ /* show app nav on the left at md */ -@media (width >= theme(--breakpoint-md)) { - +@media (width >= theme(--breakpoint-md)) +{ body#app { grid-template-columns: 5rem auto; grid-template-rows: 1fr 0; @@ -309,13 +355,15 @@ main { } main { + &:has(aside) { - grid-template-columns: minmax(16rem, 20dvw) auto; + grid-template-columns: max(16rem, 20dvw) auto; grid-template-rows: 1fr; } 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 { @apply backdrop-blur-xs sticky top-0 z-1 shrink-0 h-20 min-h-20; @@ -326,6 +374,20 @@ main { article { @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; + } + } } } } diff --git a/resources/css/lib/button.css b/resources/css/lib/button.css index 615ab31..6d43ea4 100644 --- a/resources/css/lib/button.css +++ b/resources/css/lib/button.css @@ -74,7 +74,7 @@ button, > label, > button { @apply relative flex items-center justify-center h-full pl-3.5 pr-3 cursor-pointer; - @apply border-md border-primary border-l-0 font-medium rounded-none; + @apply border-md border-primary border-l-0 font-medium rounded-none whitespace-nowrap; transition: outline 125ms ease-in-out; box-shadow: var(--shadows); --shadows: none; diff --git a/resources/css/lib/calendar.css b/resources/css/lib/calendar.css index 098f4ec..b7afa09 100644 --- a/resources/css/lib/calendar.css +++ b/resources/css/lib/calendar.css @@ -131,7 +131,7 @@ /* time column */ ol.time { @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 { @apply relative flex items-center justify-end items-start pr-4; @@ -149,8 +149,8 @@ /* event positioning */ ol.events { - @apply grid pt-4; - grid-template-rows: repeat(96, var(--row-height)); + @apply grid py-4; + grid-template-rows: repeat(var(--grid-rows, 96), var(--row-height)); --event-col: 0; --event-row: 0; --event-end: 4; @@ -158,7 +158,7 @@ --event-fg: var(--color-primary); li.event { - @apply flex rounded-md relative; + @apply flex rounded-md relative border border-white; background-color: var(--event-bg); color: var(--event-fg); grid-row-start: var(--event-row); @@ -166,39 +166,46 @@ grid-column-start: var(--event-col); grid-column-end: calc(var(--event-col) + 1); top: 0.6rem; - transition: translate 100ms ease-in; - > a { - @apply flex flex-col grow px-3 py-2 gap-2px; + a.event { + @apply flex flex-col grow px-3 py-2 gap-2px text-sm; > span { @apply font-semibold leading-none break-all; } > time { - @apply text-sm; + @apply text-xs; } } &:hover { - @apply -translate-y-2px; + animation: event-hover 125ms ease forwards; } } } /* bottom controls */ footer { - @apply bg-white flex items-end justify-end col-span-2 border-t-md border-primary z-10; - @apply sticky bottom-0 pb-8; + @apply bg-white flex items-center justify-between col-span-2 border-t-md border-primary; + @apply sticky bottom-0 pt-2 pb-8 z-10; + + a.timezone { + @apply text-xs bg-gray-100 rounded px-2 py-1; + } + + div.right { + @apply flex items-center gap-4 justify-end; + } } /* now indicator */ .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-column: var(--now-col-start) / var(--now-col-end); width: calc(100% + 1rem); - top: 0.6rem; + top: calc(0.6rem + (var(--row-height) * var(--now-offset, 0))); &::before { @apply block w-3 h-3 rounded-full bg-red-600 -translate-y-1/2 -mt-[1.5px]; @@ -208,19 +215,36 @@ } /* step handling */ -.calendar.time[data-density="30"] { +.calendar.time[data-density="30"] { /* half-hourly */ --row-height: 2rem; ol.time li:nth-child(2n) { visibility: hidden; /* preserves space + row alignment */ } } -.calendar.time[data-density="60"] { +.calendar.time[data-density="60"] { /* hourly */ --row-height: 1.25rem; ol.time li:not(:nth-child(4n + 1)) { 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); } } - +@keyframes event-hover { + from { + transform: translateY(0); + z-index: 1; + } + to { + transform: translateY(-2px); + z-index: 2; + } +} @keyframes header-slide { from { opacity: 0; @@ -381,4 +414,3 @@ transform: translateX(0); } } - diff --git a/resources/css/lib/modal.css b/resources/css/lib/modal.css index 9c79f62..446f357 100644 --- a/resources/css/lib/modal.css +++ b/resources/css/lib/modal.css @@ -3,26 +3,29 @@ } dialog { - @apply grid fixed top-0 right-0 bottom-0 left-0 m-0 p-0 pointer-events-none; - @apply justify-items-center items-start bg-transparent opacity-0 invisible; - @apply w-full h-full max-w-full max-h-full overflow-y-hidden; + @apply grid fixed inset-0 m-0 p-0 pointer-events-none; + @apply place-items-center bg-transparent opacity-0 invisible; + @apply w-full h-full max-w-none max-h-none overflow-clip; background-color: rgba(26, 26, 26, 0.75); 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: + background-color 150ms cubic-bezier(0,0,.2,1), opacity 150ms cubic-bezier(0,0,.2,1), visibility 150ms cubic-bezier(0,0,.2,1); z-index: 100; #modal { - @apply relative rounded-lg bg-white border-gray-200 p-0; - @apply flex flex-col items-start col-start-1 row-start-2 translate-y-4; + @apply relative rounded-xl bg-white border-gray-200 p-0; + @apply flex flex-col items-start col-start-1 translate-y-4; @apply overscroll-contain overflow-y-auto; max-height: calc(100vh - 5em); width: 91.666667%; max-width: 36rem; 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 { @apply block absolute top-4 right-4; diff --git a/resources/js/app.js b/resources/js/app.js index 61953ef..9d862f9 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,6 +1,16 @@ import './bootstrap'; 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 */ @@ -19,34 +29,43 @@ document.addEventListener('htmx:configRequest', (evt) => { }) /** - * calendar toggle + * calendar ui * progressive enhancement on html form with no js */ -document.addEventListener('change', event => { - const checkbox = event.target; +document.addEventListener('change', (event) => { + const target = event.target; - // ignore anything that isn’t one of our checkboxes - if (!checkbox.matches('.calendar-toggle')) return; + if (target?.matches(SELECTORS.calendarToggle)) { + const slug = target.value; + const show = target.checked; - const slug = checkbox.value; - const show = checkbox.checked; + document + .querySelectorAll(`[data-calendar="${slug}"]`) + .forEach(el => el.classList.toggle('hidden', !show)); + return; + } - // toggle .hidden on every matching event element - document - .querySelectorAll(`[data-calendar="${slug}"]`) - .forEach(el => el.classList.toggle('hidden', !show)); + const form = target?.form; + if (!form || form.id !== 'calendar-view') return; + if (target.name !== 'view') return; + + form.requestSubmit(); }); /** - * calendar view picker - * progressive enhancement on html form with no js + * calendar sidebar expand toggle */ -document.addEventListener('change', (e) => { - const form = e.target?.form; - if (!form || form.id !== 'calendar-view') return; - if (e.target.name !== 'view') return; +document.addEventListener('click', (event) => { + const toggle = event.target.closest(SELECTORS.calendarExpandToggle); + if (!toggle) 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; el.__colorpickerWired = true; - const color = el.querySelector('[data-colorpicker-color]'); - const hex = el.querySelector('[data-colorpicker-hex]'); - const btn = el.querySelector('[data-colorpicker-random]'); + const color = el.querySelector(SELECTORS.colorPickerColor); + const hex = el.querySelector(SELECTORS.colorPickerHex); + const btn = el.querySelector(SELECTORS.colorPickerRandom); 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 -document.addEventListener('DOMContentLoaded', () => initColorPickers()); +document.addEventListener('DOMContentLoaded', initUI); // rebind in htmx for swapped content document.addEventListener('htmx:afterSwap', (e) => { diff --git a/resources/svg/icons/collapse.svg b/resources/svg/icons/collapse.svg new file mode 100644 index 0000000..22d5115 --- /dev/null +++ b/resources/svg/icons/collapse.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/svg/icons/expand.svg b/resources/svg/icons/expand.svg new file mode 100644 index 0000000..c9db0b7 --- /dev/null +++ b/resources/svg/icons/expand.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/views/account/edit.blade.php b/resources/views/account/edit.blade.php deleted file mode 100644 index 1a1cc24..0000000 --- a/resources/views/account/edit.blade.php +++ /dev/null @@ -1,38 +0,0 @@ - - -

- {{ __('Profile') }} -

-
- -
-
-
-
- @include('account.partials.update-profile-information-form') -
-
- -
-
- @include('account.partials.addresses-form', [ - 'home' => $home ?? null, - 'billing' => $billing ?? null, - ]) -
-
- -
-
- @include('account.partials.update-password-form') -
-
- -
-
- @include('account.partials.delete-user-form') -
-
-
-
-
diff --git a/resources/views/calendar/index.blade.php b/resources/views/calendar/index.blade.php index 547c7ac..c9b9fbb 100644 --- a/resources/views/calendar/index.blade.php +++ b/resources/views/calendar/index.blade.php @@ -74,6 +74,7 @@ :view="$view" :density="$density" :headers="$mini_headers" + :daytime_hours="$daytime_hours" class="aside-inset" /> @@ -84,6 +85,14 @@ @if(!empty($header['span'])) {{ $header['span'] }} @endif +
  • @@ -105,6 +114,7 @@ {{-- persist values from other forms --}} +