From 236da90af22e68fe60da34e47398db4a01671fa3 Mon Sep 17 00:00:00 2001 From: Andrew Gioia Date: Thu, 12 Feb 2026 13:17:02 -0500 Subject: [PATCH] Removes old stock input components; auto scrolls time-based calendars to a better starting time; fixes recurring event logic and stubs out new repeat form for event edit --- app/Http/Controllers/CalendarController.php | 94 ++++++ app/Http/Controllers/EventController.php | 263 +++++++++++++-- app/Http/Controllers/IcsController.php | 19 +- app/Jobs/SyncSubscription.php | 17 +- app/Models/EventMeta.php | 5 + app/Services/Calendar/CalendarViewBuilder.php | 7 +- app/Services/Event/EventRecurrence.php | 46 ++- ...000000_add_all_day_dates_to_event_meta.php | 30 ++ lang/en/calendar.php | 38 +++ lang/it/calendar.php | 38 +++ resources/css/lib/button.css | 6 +- resources/css/lib/calendar.css | 64 +++- resources/js/app.js | 182 ++++++++++ resources/views/auth/login.blade.php | 51 +-- resources/views/calendar/index.blade.php | 31 +- .../components/calendar/day/day.blade.php | 23 +- .../components/calendar/four/four.blade.php | 24 +- .../calendar/time/day-event.blade.php | 27 ++ .../components/calendar/week/week.blade.php | 25 +- .../views/components/danger-button.blade.php | 3 - .../views/components/input-label.blade.php | 5 - .../views/components/primary-button.blade.php | 3 - .../components/secondary-button.blade.php | 3 - .../views/components/text-input.blade.php | 3 - resources/views/event/form.blade.php | 119 +------ .../views/event/partials/form-modal.blade.php | 23 ++ resources/views/event/partials/form.blade.php | 319 ++++++++++++++++++ 27 files changed, 1250 insertions(+), 218 deletions(-) create mode 100644 database/migrations/2026_02_11_000000_add_all_day_dates_to_event_meta.php create mode 100644 resources/views/components/calendar/time/day-event.blade.php delete mode 100644 resources/views/components/danger-button.blade.php delete mode 100644 resources/views/components/input-label.blade.php delete mode 100644 resources/views/components/primary-button.blade.php delete mode 100644 resources/views/components/secondary-button.blade.php delete mode 100644 resources/views/components/text-input.blade.php create mode 100644 resources/views/event/partials/form-modal.blade.php create mode 100644 resources/views/event/partials/form.blade.php diff --git a/app/Http/Controllers/CalendarController.php b/app/Http/Controllers/CalendarController.php index aa93c50..bc173bf 100644 --- a/app/Http/Controllers/CalendarController.php +++ b/app/Http/Controllers/CalendarController.php @@ -11,6 +11,7 @@ use App\Services\Calendar\CalendarRangeResolver; use App\Services\Calendar\CalendarViewBuilder; use App\Services\Calendar\CalendarSettingsPersister; use App\Services\Event\EventRecurrence; +use Illuminate\Support\Facades\Log; class CalendarController extends Controller { @@ -137,6 +138,96 @@ class CalendarController extends Controller $daytimeHoursForView, ); + $allDayEvents = $events->filter(fn ($event) => !empty($event['all_day'])); + $timeEvents = $events->filter(fn ($event) => empty($event['all_day'])); + $hasAllDayEvents = $allDayEvents->isNotEmpty(); + + if ($request->boolean('debug_all_day')) { + $calendarIds = $calendars->pluck('id')->values(); + $event13CalendarId = Event::where('id', 13)->value('calendarid'); + $rangeStart = $span['start']->toDateTimeString(); + $rangeEnd = $span['end']->toDateTimeString(); + + $metaRangeCount = Event::whereIn('calendarid', $calendarIds) + ->whereHas('meta', function ($meta) use ($rangeStart, $rangeEnd) { + $meta->where(function ($range) use ($rangeStart, $rangeEnd) { + $range->where('start_at', '<=', $rangeEnd) + ->where(function ($bounds) use ($rangeStart) { + $bounds->where('end_at', '>=', $rangeStart) + ->orWhereNull('end_at'); + }); + }); + }) + ->count(); + + $metaRruleCount = Event::whereIn('calendarid', $calendarIds) + ->whereHas('meta', function ($meta) { + $meta->whereNotNull('extra->rrule'); + }) + ->count(); + + $icalRruleCount = Event::whereIn('calendarid', $calendarIds) + ->where(function ($ical) { + $ical->where('calendardata', 'like', '%RRULE%') + ->orWhere('calendardata', 'like', '%RDATE%') + ->orWhere('calendardata', 'like', '%EXDATE%'); + }) + ->count(); + + $event13Range = Event::where('id', 13) + ->whereHas('meta', function ($meta) use ($rangeStart, $rangeEnd) { + $meta->where(function ($range) use ($rangeStart, $rangeEnd) { + $range->where('start_at', '<=', $rangeEnd) + ->where(function ($bounds) use ($rangeStart) { + $bounds->where('end_at', '>=', $rangeStart) + ->orWhereNull('end_at'); + }); + }); + }) + ->exists(); + + $event13Rrule = Event::where('id', 13) + ->whereHas('meta', function ($meta) { + $meta->whereNotNull('extra->rrule'); + }) + ->exists(); + + $event13Ical = Event::where('id', 13) + ->where(function ($ical) { + $ical->where('calendardata', 'like', '%RRULE%') + ->orWhere('calendardata', 'like', '%RDATE%') + ->orWhere('calendardata', 'like', '%EXDATE%'); + }) + ->exists(); + Log::info('calendar all-day debug', [ + 'view' => $view, + 'range_start' => $range['start']->toDateTimeString(), + 'range_end' => $range['end']->toDateTimeString(), + 'span_start' => $span['start']->toDateTimeString(), + 'span_end' => $span['end']->toDateTimeString(), + 'meta_range_count' => $metaRangeCount, + 'meta_rrule_count' => $metaRruleCount, + 'ical_rrule_count' => $icalRruleCount, + 'event_13_range' => $event13Range, + 'event_13_meta_rrule' => $event13Rrule, + 'event_13_ical_rrule' => $event13Ical, + 'calendar_ids' => $calendarIds->all(), + 'events_for_calendars' => Event::whereIn('calendarid', $calendarIds)->count(), + 'event_13_calendar_id' => $event13CalendarId, + 'event_13_in_scope' => $event13CalendarId ? $calendarIds->contains($event13CalendarId) : false, + 'events_total' => $events->count(), + 'events_all_day' => $allDayEvents->count(), + 'events_time' => $timeEvents->count(), + 'all_day_sample' => $allDayEvents->take(5)->values()->map(fn ($e) => [ + 'id' => $e['id'] ?? null, + 'occurrence' => $e['occurrence'] ?? null, + 'start' => $e['start'] ?? null, + 'end' => $e['end'] ?? null, + 'timezone' => $e['timezone'] ?? null, + ])->all(), + ]); + } + /** * * mini calendar @@ -205,6 +296,9 @@ class CalendarController extends Controller 'week_start' => $weekStart, 'hgroup' => $viewBuilder->viewHeaders($view, $range, $tz, $weekStart), 'events' => $events, // keyed by occurrence + 'events_time' => $timeEvents, + 'events_all_day'=> $allDayEvents, + 'has_all_day' => $hasAllDayEvents, '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 diff --git a/app/Http/Controllers/EventController.php b/app/Http/Controllers/EventController.php index 1f40982..2ae7efd 100644 --- a/app/Http/Controllers/EventController.php +++ b/app/Http/Controllers/EventController.php @@ -49,7 +49,7 @@ class EventController extends Controller $end = $anchor->copy()->addHour()->format('Y-m-d\TH:i'); $rrule = ''; - return view('event.form', compact( + $data = compact( 'calendar', 'instance', 'event', @@ -57,7 +57,13 @@ class EventController extends Controller 'end', 'tz', 'rrule', - )); + ); + + if ($request->header('HX-Request') === 'true') { + return view('event.partials.form-modal', $data); + } + + return view('event.form', $data); } /** @@ -89,7 +95,13 @@ class EventController extends Controller ?? $recurrence->extractRrule($event) ?? ''; - return view('event.form', compact('calendar', 'instance', 'event', 'start', 'end', 'tz', 'rrule')); + $data = compact('calendar', 'instance', 'event', 'start', 'end', 'tz', 'rrule'); + + if ($request->header('HX-Request') === 'true') { + return view('event.partials.form-modal', $data); + } + + return view('event.form', $data); } /** @@ -149,6 +161,10 @@ class EventController extends Controller $start = $startUtc->copy()->timezone($tz); $end = $endUtc->copy()->timezone($tz); + if ($event->meta?->all_day && $end->gt($start)) { + $end = $end->copy()->subDay(); + } + $map = $this->buildBasemapTiles($event->meta?->venue); $event->setAttribute('color', $calendarColor); @@ -168,15 +184,32 @@ class EventController extends Controller { $this->authorize('update', $calendar); - $data = $request->validate([ + $this->normalizeRecurrenceInputs($request); + + $rules = [ 'title' => ['required', 'string', 'max:200'], - 'start_at' => ['required', 'date_format:Y-m-d\TH:i'], - 'end_at' => ['required', 'date_format:Y-m-d\TH:i', 'after:start_at'], 'description' => ['nullable', 'string'], 'location' => ['nullable', 'string'], 'all_day' => ['sometimes', 'boolean'], 'category' => ['nullable', 'string', 'max:50'], - 'rrule' => ['nullable', 'string', 'max:255'], + 'repeat_frequency' => [ + function (string $attribute, mixed $value, $fail) { + if ($value === null || $value === '') { + return; + } + if (!in_array($value, ['daily', 'weekly', 'monthly', 'yearly'], true)) { + $fail(__('calendar.event.recurrence.invalid_frequency')); + } + } + ], + 'repeat_interval' => ['nullable', 'integer', 'min:1', 'max:365'], + 'repeat_weekdays' => ['nullable', 'array'], + 'repeat_weekdays.*' => ['in:SU,MO,TU,WE,TH,FR,SA'], + 'repeat_monthly_mode' => ['nullable', 'in:days,weekday'], + 'repeat_month_days' => ['nullable', 'array'], + 'repeat_month_days.*' => ['integer', 'min:1', 'max:31'], + 'repeat_month_week' => ['nullable', 'in:first,second,third,fourth,last'], + 'repeat_month_weekday' => ['nullable', 'in:SU,MO,TU,WE,TH,FR,SA'], // normalized location hints (optional) 'loc_display_name' => ['nullable', 'string'], @@ -188,23 +221,47 @@ class EventController extends Controller 'loc_country' => ['nullable', 'string'], 'loc_lat' => ['nullable'], 'loc_lon' => ['nullable'], - ]); + ]; + + if ($request->boolean('all_day')) { + $rules['start_at'] = ['required', 'date']; + $rules['end_at'] = ['required', 'date', 'after_or_equal:start_at']; + } else { + $rules['start_at'] = ['required', 'date_format:Y-m-d\\TH:i']; + $rules['end_at'] = ['required', 'date_format:Y-m-d\\TH:i', 'after:start_at']; + } + + $data = $request->validate($rules); $tz = $this->displayTimezone($calendar, $request); + $isAllDay = (bool) ($data['all_day'] ?? false); // parse input in display tz, store in utc - $startUtc = $this->parseLocalDatetimeToUtc($data['start_at'], $tz); - $endUtc = $this->parseLocalDatetimeToUtc($data['end_at'], $tz); + if ($isAllDay) { + [$startOn, $endOn, $startUtc, $endUtc] = $this->deriveAllDayRange( + $data['start_at'], + $data['end_at'], + $tz + ); + } else { + $startOn = null; + $endOn = null; + $startUtc = $this->parseLocalDatetimeToUtc($data['start_at'], $tz); + $endUtc = $this->parseLocalDatetimeToUtc($data['end_at'], $tz); + } $uid = Str::uuid() . '@' . parse_url(config('app.url'), PHP_URL_HOST); - $rrule = $this->normalizeRrule($request); + $rrule = $this->buildRruleFromRequest($request, $this->parseLocalInputToTz($data['start_at'], $tz, $isAllDay)); $extra = $this->mergeRecurrenceExtra([], $rrule, $tz, $request); $ical = $recurrence->buildCalendar([ 'uid' => $uid, 'start_utc' => $startUtc, 'end_utc' => $endUtc, + 'all_day' => $isAllDay, + 'start_date' => $startOn, + 'end_date' => $endOn, 'summary' => $data['title'], 'description' => $data['description'] ?? '', 'location' => $data['location'] ?? '', @@ -230,10 +287,13 @@ class EventController extends Controller 'description' => $data['description'] ?? null, 'location' => $data['location'] ?? null, 'location_id' => $locationId, - 'all_day' => (bool) ($data['all_day'] ?? false), + 'all_day' => $isAllDay, 'category' => $data['category'] ?? null, 'start_at' => $startUtc, 'end_at' => $endUtc, + 'start_on' => $startOn, + 'end_on' => $endOn, + 'tzid' => $isAllDay ? $tz : null, 'extra' => $extra, ]); @@ -251,25 +311,63 @@ class EventController extends Controller abort(Response::HTTP_NOT_FOUND); } - $data = $request->validate([ + $this->normalizeRecurrenceInputs($request); + + $rules = [ 'title' => ['required', 'string', 'max:200'], - 'start_at' => ['required', 'date_format:Y-m-d\TH:i'], - 'end_at' => ['required', 'date_format:Y-m-d\TH:i', 'after:start_at'], 'description' => ['nullable', 'string'], 'location' => ['nullable', 'string'], 'all_day' => ['sometimes', 'boolean'], 'category' => ['nullable', 'string', 'max:50'], - 'rrule' => ['nullable', 'string', 'max:255'], - ]); + 'repeat_frequency' => [ + function (string $attribute, mixed $value, $fail) { + if ($value === null || $value === '') { + return; + } + if (!in_array($value, ['daily', 'weekly', 'monthly', 'yearly'], true)) { + $fail(__('calendar.event.recurrence.invalid_frequency')); + } + } + ], + 'repeat_interval' => ['nullable', 'integer', 'min:1', 'max:365'], + 'repeat_weekdays' => ['nullable', 'array'], + 'repeat_weekdays.*' => ['in:SU,MO,TU,WE,TH,FR,SA'], + 'repeat_monthly_mode' => ['nullable', 'in:days,weekday'], + 'repeat_month_days' => ['nullable', 'array'], + 'repeat_month_days.*' => ['integer', 'min:1', 'max:31'], + 'repeat_month_week' => ['nullable', 'in:first,second,third,fourth,last'], + 'repeat_month_weekday' => ['nullable', 'in:SU,MO,TU,WE,TH,FR,SA'], + ]; + + if ($request->boolean('all_day')) { + $rules['start_at'] = ['required', 'date']; + $rules['end_at'] = ['required', 'date', 'after_or_equal:start_at']; + } else { + $rules['start_at'] = ['required', 'date_format:Y-m-d\\TH:i']; + $rules['end_at'] = ['required', 'date_format:Y-m-d\\TH:i', 'after:start_at']; + } + + $data = $request->validate($rules); $tz = $this->displayTimezone($calendar, $request); + $isAllDay = (bool) ($data['all_day'] ?? false); - $startUtc = $this->parseLocalDatetimeToUtc($data['start_at'], $tz); - $endUtc = $this->parseLocalDatetimeToUtc($data['end_at'], $tz); + if ($isAllDay) { + [$startOn, $endOn, $startUtc, $endUtc] = $this->deriveAllDayRange( + $data['start_at'], + $data['end_at'], + $tz + ); + } else { + $startOn = null; + $endOn = null; + $startUtc = $this->parseLocalDatetimeToUtc($data['start_at'], $tz); + $endUtc = $this->parseLocalDatetimeToUtc($data['end_at'], $tz); + } $uid = $event->uid; - $rrule = $this->normalizeRrule($request); + $rrule = $this->buildRruleFromRequest($request, $this->parseLocalInputToTz($data['start_at'], $tz, $isAllDay)); $extra = $event->meta?->extra ?? []; $extra = $this->mergeRecurrenceExtra($extra, $rrule, $tz, $request); $rruleForIcs = $rrule ?? ($extra['rrule'] ?? $recurrence->extractRrule($event)); @@ -278,6 +376,9 @@ class EventController extends Controller 'uid' => $uid, 'start_utc' => $startUtc, 'end_utc' => $endUtc, + 'all_day' => $isAllDay, + 'start_date' => $startOn, + 'end_date' => $endOn, 'summary' => $data['title'], 'description' => $data['description'] ?? '', 'location' => $data['location'] ?? '', @@ -297,10 +398,13 @@ class EventController extends Controller 'title' => $data['title'], 'description' => $data['description'] ?? null, 'location' => $data['location'] ?? null, - 'all_day' => (bool) ($data['all_day'] ?? false), + 'all_day' => $isAllDay, 'category' => $data['category'] ?? null, 'start_at' => $startUtc, 'end_at' => $endUtc, + 'start_on' => $startOn, + 'end_on' => $endOn, + 'tzid' => $isAllDay ? $tz : null, 'extra' => $extra, ]); @@ -328,6 +432,40 @@ class EventController extends Controller return $local->utc(); } + /** + * derive all-day date range (exclusive end) and utc bounds + */ + private function deriveAllDayRange(string $start, string $end, string $tz): array + { + $startLocal = $this->parseAllDayInput($start, $tz); + $endLocal = $this->parseAllDayInput($end, $tz); + + $startOn = $startLocal->toDateString(); + $endOn = $endLocal->toDateString(); + + if (Carbon::parse($endOn, $tz)->lte(Carbon::parse($startOn, $tz))) { + $endOn = Carbon::parse($startOn, $tz)->addDay()->toDateString(); + } + + $startUtc = Carbon::parse($startOn, $tz)->startOfDay()->utc(); + $endUtc = Carbon::parse($endOn, $tz)->startOfDay()->utc(); + + return [$startOn, $endOn, $startUtc, $endUtc]; + } + + private function parseAllDayInput(string $value, string $tz): Carbon + { + if (preg_match('/^\\d{4}-\\d{2}-\\d{2}$/', $value) === 1) { + return Carbon::createFromFormat('Y-m-d', $value, $tz)->startOfDay(); + } + + try { + return Carbon::createFromFormat('Y-m-d\\TH:i', $value, $tz)->seconds(0); + } catch (\Throwable $e) { + return Carbon::parse($value, $tz)->startOfDay(); + } + } + /** * minimal ics escaping for text properties */ @@ -338,14 +476,89 @@ class EventController extends Controller return $text; } - private function normalizeRrule(Request $request): ?string + private function normalizeRecurrenceInputs(Request $request): void { - if (! $request->has('rrule')) { + if ($request->has('repeat_interval') && $request->input('repeat_interval') === '') { + $request->merge(['repeat_interval' => null]); + } + } + + private function buildRruleFromRequest(Request $request, Carbon $startLocal): ?string + { + if (! $request->has('repeat_frequency')) { return null; } - $rrule = trim((string) $request->input('rrule')); - return $rrule === '' ? '' : $rrule; + $freq = strtolower((string) $request->input('repeat_frequency', '')); + if ($freq === '') { + return ''; + } + + $interval = max(1, (int) $request->input('repeat_interval', 1)); + $parts = ['FREQ=' . strtoupper($freq)]; + if ($interval > 1) { + $parts[] = 'INTERVAL=' . $interval; + } + + $weekdayMap = [ + 'sun' => 'SU', + 'mon' => 'MO', + 'tue' => 'TU', + 'wed' => 'WE', + 'thu' => 'TH', + 'fri' => 'FR', + 'sat' => 'SA', + ]; + $defaultWeekday = $weekdayMap[strtolower($startLocal->format('D'))] ?? 'MO'; + + if ($freq === 'weekly') { + $days = (array) $request->input('repeat_weekdays', []); + $days = array_values(array_filter($days, fn ($d) => preg_match('/^(SU|MO|TU|WE|TH|FR|SA)$/', $d))); + if (empty($days)) { + $days = [$defaultWeekday]; + } + $parts[] = 'BYDAY=' . implode(',', $days); + } + + if ($freq === 'monthly') { + $mode = $request->input('repeat_monthly_mode', 'days'); + if ($mode === 'weekday') { + $week = $request->input('repeat_month_week', 'first'); + $weekday = $request->input('repeat_month_weekday', $defaultWeekday); + + $weekMap = [ + 'first' => 1, + 'second' => 2, + 'third' => 3, + 'fourth' => 4, + 'last' => -1, + ]; + $bysetpos = $weekMap[$week] ?? 1; + $parts[] = 'BYDAY=' . $weekday; + $parts[] = 'BYSETPOS=' . $bysetpos; + } else { + $days = (array) $request->input('repeat_month_days', []); + $days = array_values(array_filter($days, fn ($d) => is_numeric($d) && (int) $d >= 1 && (int) $d <= 31)); + if (empty($days)) { + $days = [(int) $startLocal->format('j')]; + } + $parts[] = 'BYMONTHDAY=' . implode(',', $days); + } + } + + if ($freq === 'yearly') { + $parts[] = 'BYMONTH=' . $startLocal->format('n'); + $parts[] = 'BYMONTHDAY=' . $startLocal->format('j'); + } + + return implode(';', $parts); + } + + private function parseLocalInputToTz(string $value, string $tz, bool $isAllDay): Carbon + { + return $isAllDay + ? $this->parseAllDayInput($value, $tz) + : Carbon::createFromFormat('Y-m-d\\TH:i', $value, $tz)->seconds(0); } private function mergeRecurrenceExtra(array $extra, ?string $rrule, string $tz, Request $request): array diff --git a/app/Http/Controllers/IcsController.php b/app/Http/Controllers/IcsController.php index 12613a5..1a852a3 100644 --- a/app/Http/Controllers/IcsController.php +++ b/app/Http/Controllers/IcsController.php @@ -60,19 +60,26 @@ class IcsController extends Controller } $meta = $event->meta; - if (!$meta || !$meta->start_at || !$meta->end_at) { + if (!$meta) { continue; } - $start = Carbon::parse($meta->start_at)->timezone($tz); - $end = Carbon::parse($meta->end_at)->timezone($tz); - $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]); + + if ($meta->all_day && $meta->start_on && $meta->end_on) { + $vevent->add('DTSTART', Carbon::parse($meta->start_on), ['VALUE' => 'DATE']); + $vevent->add('DTEND', Carbon::parse($meta->end_on), ['VALUE' => 'DATE']); + } elseif ($meta->start_at && $meta->end_at) { + $start = Carbon::parse($meta->start_at)->timezone($tz); + $end = Carbon::parse($meta->end_at)->timezone($tz); + $vevent->add('DTSTART', $start, ['TZID' => $tz]); + $vevent->add('DTEND', $end, ['TZID' => $tz]); + } else { + continue; + } if ($event->lastmodified) { $vevent->add('DTSTAMP', Carbon::createFromTimestamp($event->lastmodified)->utc()); } diff --git a/app/Jobs/SyncSubscription.php b/app/Jobs/SyncSubscription.php index 1d48615..647f7b6 100644 --- a/app/Jobs/SyncSubscription.php +++ b/app/Jobs/SyncSubscription.php @@ -147,14 +147,29 @@ class SyncSubscription implements ShouldQueue $end = isset($vevent->DTEND) ? Carbon::instance($vevent->DTEND->getDateTime())->utc() : $start; + $isAllDay = $dtStart->isFloating(); + $startOn = null; + $endOn = null; + $tzid = null; + + if ($isAllDay) { + $startOn = $start->toDateString(); + $endOn = $end->toDateString(); + if (Carbon::parse($endOn)->lte(Carbon::parse($startOn))) { + $endOn = Carbon::parse($startOn)->addDay()->toDateString(); + } + } EventMeta::upsertForEvent($object->id, [ 'title' => (string) ($vevent->SUMMARY ?? 'Untitled'), 'description' => (string) ($vevent->DESCRIPTION ?? ''), 'location' => (string) ($vevent->LOCATION ?? ''), - 'all_day' => $dtStart->isFloating(), + 'all_day' => $isAllDay, 'start_at' => $start, 'end_at' => $end, + 'start_on' => $startOn, + 'end_on' => $endOn, + 'tzid' => $tzid, ]); } diff --git a/app/Models/EventMeta.php b/app/Models/EventMeta.php index 714669e..d24b67a 100644 --- a/app/Models/EventMeta.php +++ b/app/Models/EventMeta.php @@ -22,6 +22,9 @@ class EventMeta extends Model 'is_private', 'start_at', 'end_at', + 'start_on', + 'end_on', + 'tzid', 'extra', ]; @@ -30,6 +33,8 @@ class EventMeta extends Model 'is_private' => 'boolean', 'start_at' => 'datetime', 'end_at' => 'datetime', + 'start_on' => 'date', + 'end_on' => 'date', 'extra' => 'array', ]; diff --git a/app/Services/Calendar/CalendarViewBuilder.php b/app/Services/Calendar/CalendarViewBuilder.php index 1bac2c5..d2bd987 100644 --- a/app/Services/Calendar/CalendarViewBuilder.php +++ b/app/Services/Calendar/CalendarViewBuilder.php @@ -90,6 +90,7 @@ class CalendarViewBuilder ) { $startUtc = $occ['start']; $endUtc = $occ['end']; + $isAllDay = (bool) ($e->meta?->all_day ?? false); $startLocal = $startUtc->copy()->timezone($timezone); $endLocal = $endUtc->copy()->timezone($timezone); @@ -97,7 +98,7 @@ class CalendarViewBuilder $startForGrid = $startUtc->copy()->tz($tz); $endForGrid = $endUtc->copy()->tz($tz); - if ($daytimeHours) { + if ($daytimeHours && !$isAllDay) { $startMinutes = ($startForGrid->hour * 60) + $startForGrid->minute; $endMinutes = ($endForGrid->hour * 60) + $endForGrid->minute; @@ -136,6 +137,7 @@ class CalendarViewBuilder 'calendar_slug' => $cal->slug, 'title' => $e->meta->title ?? 'No title', 'description' => $e->meta->description ?? 'No description.', + 'all_day' => $isAllDay, 'start' => $startUtc->toIso8601String(), 'end' => $endUtc->toIso8601String(), 'start_ui' => $startLocal->format($uiFormat), @@ -179,6 +181,9 @@ class CalendarViewBuilder $evTz = $ev['timezone'] ?? $tz; $start = Carbon::parse($ev['start'])->tz($evTz); $end = $ev['end'] ? Carbon::parse($ev['end'])->tz($evTz) : $start; + if (!empty($ev['all_day']) && $end->gt($start)) { + $end = $end->copy()->subSecond(); + } for ($d = $start->copy()->startOfDay(); $d->lte($end->copy()->endOfDay()); diff --git a/app/Services/Event/EventRecurrence.php b/app/Services/Event/EventRecurrence.php index 285f1ab..296d3a5 100644 --- a/app/Services/Event/EventRecurrence.php +++ b/app/Services/Event/EventRecurrence.php @@ -27,11 +27,17 @@ class EventRecurrence $startUtc = $data['start_utc']; $endUtc = $data['end_utc']; $tzid = $data['tzid'] ?? null; + $allDay = (bool) ($data['all_day'] ?? false); + $startDate = $data['start_date'] ?? null; + $endDate = $data['end_date'] ?? null; $vevent->add('UID', $uid); $vevent->add('DTSTAMP', $startUtc->copy()->utc()); - if ($tzid) { + if ($allDay && $startDate && $endDate) { + $vevent->add('DTSTART', Carbon::parse($startDate), ['VALUE' => 'DATE']); + $vevent->add('DTEND', Carbon::parse($endDate), ['VALUE' => 'DATE']); + } elseif ($tzid) { $startLocal = $startUtc->copy()->tz($tzid); $endLocal = $endUtc->copy()->tz($tzid); $vevent->add('DTSTART', $startLocal, ['TZID' => $tzid]); @@ -62,7 +68,9 @@ class EventRecurrence if (!empty($exdates)) { foreach ($exdates as $ex) { $dt = Carbon::parse($ex, $tzid ?: 'UTC'); - if ($tzid) { + if ($allDay) { + $vevent->add('EXDATE', Carbon::parse($dt->toDateString()), ['VALUE' => 'DATE']); + } elseif ($tzid) { $vevent->add('EXDATE', $dt, ['TZID' => $tzid]); } else { $vevent->add('EXDATE', $dt->utc()); @@ -74,7 +82,9 @@ class EventRecurrence if (!empty($rdates)) { foreach ($rdates as $r) { $dt = Carbon::parse($r, $tzid ?: 'UTC'); - if ($tzid) { + if ($allDay) { + $vevent->add('RDATE', Carbon::parse($dt->toDateString()), ['VALUE' => 'DATE']); + } elseif ($tzid) { $vevent->add('RDATE', $dt, ['TZID' => $tzid]); } else { $vevent->add('RDATE', $dt->utc()); @@ -117,19 +127,43 @@ class EventRecurrence ?? new DateTimeZone('UTC'); $iter = new EventIterator($vcalendar, $uid); - $iter->fastForward($rangeStart->copy()->setTimezone($startTz)->toDateTime()); + $baseStart = $vevent->DTSTART?->getDateTime(); + $baseEnd = $vevent->DTEND?->getDateTime(); + $durationSeconds = 0; + if ($baseStart && $baseEnd) { + $durationSeconds = max(0, $baseEnd->getTimestamp() - $baseStart->getTimestamp()); + } + + $searchStart = $rangeStart->copy()->subSeconds($durationSeconds)->setTimezone($startTz); + $iter->fastForward($searchStart->toDateTime()); $items = []; while ($iter->valid()) { $start = Carbon::instance($iter->getDTStart()); $end = Carbon::instance($iter->getDTEnd()); + $allDayTz = null; + if ($event->meta?->all_day) { + $allDayTz = $event->meta?->tzid + ?? ($event->meta?->extra['tzid'] ?? null); + } - if ($start->gt($rangeEnd)) { - break; + if ($allDayTz) { + $start = Carbon::parse($start->toDateString(), $allDayTz); + $end = Carbon::parse($end->toDateString(), $allDayTz); } $startUtc = $start->copy()->utc(); $endUtc = $end->copy()->utc(); + + if ($startUtc->gt($rangeEnd)) { + break; + } + + if ($endUtc->lte($rangeStart)) { + $iter->next(); + continue; + } + $items[] = [ 'start' => $startUtc, 'end' => $endUtc, diff --git a/database/migrations/2026_02_11_000000_add_all_day_dates_to_event_meta.php b/database/migrations/2026_02_11_000000_add_all_day_dates_to_event_meta.php new file mode 100644 index 0000000..4cfb0f9 --- /dev/null +++ b/database/migrations/2026_02_11_000000_add_all_day_dates_to_event_meta.php @@ -0,0 +1,30 @@ +date('start_on')->nullable()->after('end_at'); + $table->date('end_on')->nullable()->after('start_on'); + $table->string('tzid')->nullable()->after('end_on'); + + $table->index(['all_day', 'start_on']); + $table->index(['all_day', 'end_on']); + }); + } + + public function down(): void + { + Schema::table('event_meta', function (Blueprint $table) { + $table->dropIndex(['all_day', 'start_on']); + $table->dropIndex(['all_day', 'end_on']); + + $table->dropColumn(['start_on', 'end_on', 'tzid']); + }); + } +}; diff --git a/lang/en/calendar.php b/lang/en/calendar.php index 56b4ba6..6371bae 100644 --- a/lang/en/calendar.php +++ b/lang/en/calendar.php @@ -81,6 +81,44 @@ return [ 'attachments_coming' => 'Attachment support coming soon.', 'notes' => 'Notes', 'no_description' => 'No description yet.', + 'all_day_events' => 'All-day events', + 'recurrence' => [ + 'label' => 'Repeat', + 'frequency' => 'Frequency', + 'none' => 'Does not repeat', + 'every' => 'Every', + 'daily' => 'Daily', + 'weekly' => 'Weekly', + 'monthly' => 'Monthly', + 'yearly' => 'Yearly', + 'on_days' => 'On days', + 'on_the' => 'On the', + 'yearly_hint' => 'Repeats on the same date each year.', + 'invalid_frequency' => 'Please choose a valid repeat frequency.', + 'weekdays' => [ + 'sun' => 'Sunday', + 'mon' => 'Monday', + 'tue' => 'Tuesday', + 'wed' => 'Wednesday', + 'thu' => 'Thursday', + 'fri' => 'Friday', + 'sat' => 'Saturday', + 'sun_short' => 'S', + 'mon_short' => 'M', + 'tue_short' => 'T', + 'wed_short' => 'W', + 'thu_short' => 'T', + 'fri_short' => 'F', + 'sat_short' => 'S', + ], + 'week_order' => [ + 'first' => 'First', + 'second' => 'Second', + 'third' => 'Third', + 'fourth' => 'Fourth', + 'last' => 'Last', + ], + ], ], ]; diff --git a/lang/it/calendar.php b/lang/it/calendar.php index 7fbc4dc..d15bf96 100644 --- a/lang/it/calendar.php +++ b/lang/it/calendar.php @@ -81,6 +81,44 @@ return [ 'attachments_coming' => 'Supporto allegati in arrivo.', 'notes' => 'Note', 'no_description' => 'Nessuna descrizione.', + 'all_day_events' => 'Eventi di tutto il giorno', + 'recurrence' => [ + 'label' => 'Ripeti', + 'frequency' => 'Frequenza', + 'none' => 'Non si ripete', + 'every' => 'Ogni', + 'daily' => 'Giornaliero', + 'weekly' => 'Settimanale', + 'monthly' => 'Mensile', + 'yearly' => 'Annuale', + 'on_days' => 'Nei giorni', + 'on_the' => 'Nel', + 'yearly_hint' => 'Si ripete nella stessa data ogni anno.', + 'invalid_frequency' => 'Seleziona una frequenza valida.', + 'weekdays' => [ + 'sun' => 'Domenica', + 'mon' => 'Lunedi', + 'tue' => 'Martedi', + 'wed' => 'Mercoledi', + 'thu' => 'Giovedi', + 'fri' => 'Venerdi', + 'sat' => 'Sabato', + 'sun_short' => 'D', + 'mon_short' => 'L', + 'tue_short' => 'M', + 'wed_short' => 'M', + 'thu_short' => 'G', + 'fri_short' => 'V', + 'sat_short' => 'S', + ], + 'week_order' => [ + 'first' => 'Primo', + 'second' => 'Secondo', + 'third' => 'Terzo', + 'fourth' => 'Quarto', + 'last' => 'Ultimo', + ], + ], ], ]; diff --git a/resources/css/lib/button.css b/resources/css/lib/button.css index 7488ad7..3c65dad 100644 --- a/resources/css/lib/button.css +++ b/resources/css/lib/button.css @@ -146,6 +146,10 @@ button, /* small */ &.button-group--sm { - @apply h-9 max-h-9 text-sm; + @apply h-9 max-h-9; + + > label { + @apply text-sm; + } } } diff --git a/resources/css/lib/calendar.css b/resources/css/lib/calendar.css index 6f73979..a9a6e3d 100644 --- a/resources/css/lib/calendar.css +++ b/resources/css/lib/calendar.css @@ -158,6 +158,12 @@ --now-col-start: 1; --now-col-end: 2; + /* if we have an all day event, change the grid */ + &.allday { + grid-template-columns: 6rem auto; + grid-template-rows: 5rem min-content auto 5rem; + } + /* top day bar */ hgroup { @apply bg-white col-span-2 border-b-2 border-primary pl-24 sticky z-10; @@ -177,7 +183,7 @@ } div.day-header { - @apply relative flex flex-col gap-1 justify-end items-start pb-2; + @apply relative flex flex-col gap-1 justify-end items-start pb-2 h-full; animation: header-slide 250ms ease-in; &:not(:last-of-type)::after { @@ -198,6 +204,40 @@ } } + /* all day bar */ + ol.day { + @apply sticky top-42 grid col-span-2 bg-white py-2 border-b border-primary col-span-2 pl-24 z-2 overflow-x-hidden; + box-shadow: 0 0.25rem 0.5rem -0.25rem var(--color-gray-200); + + &::before { + @apply absolute left-0 top-1/2 -translate-y-1/2 w-24 pr-4 text-right; + @apply uppercase text-xs font-mono text-secondary font-medium; + content: 'All day'; + } + + li.events { + @apply flex flex-col gap-1 relative overflow-x-hidden; + grid-row-start: var(--event-row); + grid-row-end: var(--event-end); + grid-column-start: var(--event-col); + grid-column-end: calc(var(--event-col) + 1); + + a.event { + @apply flex items-center text-xs gap-1 px-1 py-px font-medium rounded-sm w-full; + background-color: var(--event-bg); + color: var(--event-fg); + + > span { + @apply truncate; + } + + &:hover { + background-color: color-mix(in srgb, var(--event-bg) 100%, #000 10%); + } + } + } + } + /* time column */ ol.time { @apply grid z-0 pt-4; @@ -351,12 +391,17 @@ hgroup { @apply grid gap-x-2; - grid-template-columns: repeat(7, 1fr); + grid-template-columns: repeat(var(--days), 1fr); + } + + ol.day { + @apply gap-x-2; + grid-template-columns: repeat(var(--days), 1fr); } ol.events { @apply gap-x-2; - grid-template-columns: repeat(7, 1fr); + grid-template-columns: repeat(var(--days), 1fr); --col: calc(100% / var(--days)); /* draw a 1px line at the start of each column repeat + highlight weekends */ @@ -399,6 +444,11 @@ grid-template-columns: repeat(var(--days), 1fr); } + ol.day { + @apply gap-x-2; + grid-template-columns: repeat(var(--days), 1fr); + } + ol.events { @apply gap-x-2; grid-template-columns: repeat(var(--days), 1fr); @@ -481,6 +531,14 @@ .calendar.time { grid-template-rows: 4rem auto 5rem; + &.allday { + grid-template-rows: 4rem min-content auto 5rem; + } + + ol.day { + @apply top-38; + } + hgroup { div.day-header { @apply flex-row items-center justify-start gap-2; diff --git a/resources/js/app.js b/resources/js/app.js index 2c4dc36..f5a48b9 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -9,6 +9,16 @@ const SELECTORS = { colorPickerColor: '[data-colorpicker-color]', colorPickerHex: '[data-colorpicker-hex]', colorPickerRandom: '[data-colorpicker-random]', + eventAllDayToggle: '[data-all-day-toggle]', + eventStartInput: '[data-event-start]', + eventEndInput: '[data-event-end]', + recurrenceFrequency: '[data-recurrence-frequency]', + recurrenceInterval: '[data-recurrence-interval]', + recurrenceUnit: '[data-recurrence-unit]', + recurrenceSection: '[data-recurrence-section]', + monthlyMode: '[data-monthly-mode]', + monthlyDays: '[data-monthly-days]', + monthlyWeekday: '[data-monthly-weekday]', monthDay: '.calendar.month .day', monthDayEvent: 'a.event', monthDayMore: '[data-day-more]', @@ -108,6 +118,172 @@ window.addEventListener('popstate', () => { dialog.close(); }); +/** + * event form all-day toggle + */ +function initEventAllDayToggles(root = document) { + const toDate = (value) => { + if (!value) return ''; + return value.split('T')[0]; + }; + + const withTime = (date, time) => { + if (!date) return ''; + return `${date}T${time}`; + }; + + root.querySelectorAll(SELECTORS.eventAllDayToggle).forEach((toggle) => { + if (toggle.__allDayWired) return; + toggle.__allDayWired = true; + + const form = toggle.closest('form'); + if (!form) return; + + const start = form.querySelector(SELECTORS.eventStartInput); + const end = form.querySelector(SELECTORS.eventEndInput); + if (!start || !end) return; + + const apply = () => { + if (toggle.checked) { + if (start.type === 'datetime-local') { + start.dataset.datetimeValue = start.value; + } + if (end.type === 'datetime-local') { + end.dataset.datetimeValue = end.value; + } + + const startDate = toDate(start.value); + const endDate = toDate(end.value); + + start.type = 'date'; + end.type = 'date'; + + if (startDate) start.value = startDate; + if (endDate) end.value = endDate; + + if (start.value && end.value && end.value < start.value) { + end.value = start.value; + } + } else { + const startDate = toDate(start.value); + const endDate = toDate(end.value); + const startTime = (start.dataset.datetimeValue || '').split('T')[1] || '09:00'; + const endTime = (end.dataset.datetimeValue || '').split('T')[1] || '10:00'; + + start.type = 'datetime-local'; + end.type = 'datetime-local'; + + start.value = start.dataset.datetimeValue || withTime(startDate, startTime); + end.value = end.dataset.datetimeValue || withTime(endDate || startDate, endTime); + } + }; + + toggle.addEventListener('change', apply); + apply(); + }); +} + +/** + * recurrence preset selector + */ +function initRecurrenceControls(root = document) { + const sections = root.querySelectorAll(SELECTORS.recurrenceSection); + if (!sections.length) return; + + const select = root.querySelector(SELECTORS.recurrenceFrequency); + const intervalRow = root.querySelector(SELECTORS.recurrenceInterval); + const intervalUnit = root.querySelector(SELECTORS.recurrenceUnit); + const monthModes = root.querySelectorAll(SELECTORS.monthlyMode); + const monthDays = root.querySelector(SELECTORS.monthlyDays); + const monthWeekday = root.querySelector(SELECTORS.monthlyWeekday); + + if (!select) return; + + const unitMap = { + daily: 'day', + weekly: 'week', + monthly: 'month', + yearly: 'year', + }; + + const applyMonthlyMode = () => { + if (!monthDays || !monthWeekday) return; + const modeInput = Array.from(monthModes).find((input) => input.checked); + const mode = modeInput?.value || 'days'; + + if (mode === 'weekday') { + monthDays.classList.add('hidden'); + monthWeekday.classList.remove('hidden'); + } else { + monthDays.classList.remove('hidden'); + monthWeekday.classList.add('hidden'); + } + }; + + const apply = () => { + const value = select.value; + const show = value !== ''; + + sections.forEach((section) => { + const type = section.getAttribute('data-recurrence-section'); + if (!show) { + section.classList.add('hidden'); + return; + } + section.classList.toggle('hidden', type !== value); + }); + + if (intervalRow) { + intervalRow.classList.toggle('hidden', !show); + } + + if (intervalUnit) { + const unit = unitMap[value] || 'day'; + intervalUnit.textContent = unit ? `${unit}(s)` : ''; + } + + if (value === 'monthly') { + applyMonthlyMode(); + } + }; + + select.addEventListener('change', apply); + monthModes.forEach((input) => input.addEventListener('change', applyMonthlyMode)); + + apply(); +} + +/** + * auto-scroll time views to 8am on load (when daytime hours are disabled) + */ +function initTimeViewAutoScroll(root = document) +{ + // make sure we're on a time calendar + const calendar = root.querySelector('.calendar.time'); + if (!calendar) return; + + // get out if we're autoscrolled or daytime hours is set + if (calendar.dataset.autoscrolled === '1') return; + if (calendar.dataset.daytimeHoursEnabled === '1') return; + + // find the target minute (7:45am) + const target = calendar.querySelector('[data-slot-minutes="465"]'); + if (!target) return; + + // get the scroll container and offset + const container = calendar.closest('article') || document.querySelector('article#calendar'); + if (!container) return; + const header = container.querySelector('header'); + const headerOffset = header ? header.offsetHeight : 0; + const containerRect = container.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + const top = targetRect.top - containerRect.top + container.scrollTop - headerOffset - 12; + + // scroll + container.scrollTo({ top: Math.max(top, 0), behavior: 'auto' }); + calendar.dataset.autoscrolled = '1'; +} + /** * * calendar sidebar expand toggle @@ -362,6 +538,9 @@ window.addEventListener('resize', () => { function initUI() { initColorPickers(); + initEventAllDayToggles(); + initRecurrenceControls(); + initTimeViewAutoScroll(); initMonthOverflow(); } @@ -371,5 +550,8 @@ document.addEventListener('DOMContentLoaded', initUI); // rebind in htmx for swapped content document.addEventListener('htmx:afterSwap', (e) => { initColorPickers(e.target); + initEventAllDayToggles(e.target); + initRecurrenceControls(e.target); + initTimeViewAutoScroll(e.target); initMonthOverflow(e.target); }); diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index ad4c5f9..44ab805 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -2,34 +2,47 @@ -
+ @csrf -
- - - +
+
+ + + +
-
- - - - - +
+
+ + + +
-
- +
+
+ +
diff --git a/resources/views/calendar/index.blade.php b/resources/views/calendar/index.blade.php index e58d05b..f0df88d 100644 --- a/resources/views/calendar/index.blade.php +++ b/resources/views/calendar/index.blade.php @@ -1,3 +1,8 @@ +@php + $eventCalendar = $calendars->firstWhere('is_remote', false) ?? $calendars->first(); + $eventCalendarSlug = $eventCalendar['slug'] ?? null; +@endphp + @@ -177,9 +182,17 @@ - - - + @if($eventCalendarSlug) + + + + @endif @@ -191,7 +204,9 @@ class="week time" :grid="$grid" :calendars="$calendars" - :events="$events" + :events="$events_time" + :all_day_events="$events_all_day" + :has_all_day="$has_all_day" :slots="$slots" :timeformat="$time_format" :hgroup="$hgroup" @@ -208,7 +223,9 @@ class="day time" :grid="$grid" :calendars="$calendars" - :events="$events" + :events="$events_time" + :all_day_events="$events_all_day" + :has_all_day="$has_all_day" :slots="$slots" :timeformat="$time_format" :hgroup="$hgroup" @@ -224,7 +241,9 @@ class="four time" :grid="$grid" :calendars="$calendars" - :events="$events" + :events="$events_time" + :all_day_events="$events_all_day" + :has_all_day="$has_all_day" :slots="$slots" :timeformat="$time_format" :hgroup="$hgroup" diff --git a/resources/views/components/calendar/day/day.blade.php b/resources/views/components/calendar/day/day.blade.php index 1305765..e79ff26 100644 --- a/resources/views/components/calendar/day/day.blade.php +++ b/resources/views/components/calendar/day/day.blade.php @@ -2,6 +2,8 @@ 'grid' => [], 'calendars' => [], 'events' => [], + 'all_day_events' => [], + 'has_all_day' => false, 'class' => '', 'slots' => [], 'timeformat' => '', @@ -14,8 +16,16 @@ ])
$has_all_day ?? false]) + data-density="{{ $density['step'] }}" + data-daytime-hours-enabled="{{ (int) ($daytime_hours['enabled'] ?? 0) }}" + style=" + --now-row: {{ $now['row'] }}; + --now-offset: {{ $now['offset'] ?? 0 }}; + --now-col-start: {{ $now['col_start'] }}; + --now-col-end: {{ $now['col_end'] }}; + --grid-rows: {{ $daytime_hours['rows'] ?? 96 }}; + " >
@foreach ($hgroup as $h) @@ -25,10 +35,17 @@
@endforeach + @if($has_all_day) +
    + @foreach ($all_day_events as $event) + + @endforeach +
+ @endif
    @foreach ($slots as $slot)
  1. - +
  2. @endforeach
diff --git a/resources/views/components/calendar/four/four.blade.php b/resources/views/components/calendar/four/four.blade.php index 07ed304..933d042 100644 --- a/resources/views/components/calendar/four/four.blade.php +++ b/resources/views/components/calendar/four/four.blade.php @@ -2,6 +2,8 @@ 'grid' => [], 'calendars' => [], 'events' => [], + 'all_day_events' => [], + 'has_all_day' => false, 'class' => '', 'slots' => [], 'timeformat' => '', @@ -14,13 +16,16 @@ ])
$has_all_day ?? false]) + data-density="{{ $density['step'] }}" + data-daytime-hours-enabled="{{ (int) ($daytime_hours['enabled'] ?? 0) }}" + style=" + --now-row: {{ (int) $now['row'] }}; --now-offset: {{ $now['offset'] ?? 0 }}; --now-col-start: {{ (int) $now['col_start'] }}; --now-col-end: {{ (int) $now['col_end'] }}; - --grid-rows: {{ $daytime_hours['rows'] ?? 96 }};" + --grid-rows: {{ $daytime_hours['rows'] ?? 96 }}; + " >
@foreach ($hgroup as $h) @@ -45,12 +50,19 @@ {{ $h['day'] }}
- @endforeach + @endforeach + @if($has_all_day) +
    + @foreach ($all_day_events as $event) + + @endforeach +
+ @endif
    @foreach ($slots as $slot)
  1. - +
  2. @endforeach
diff --git a/resources/views/components/calendar/time/day-event.blade.php b/resources/views/components/calendar/time/day-event.blade.php new file mode 100644 index 0000000..59ccd39 --- /dev/null +++ b/resources/views/components/calendar/time/day-event.blade.php @@ -0,0 +1,27 @@ +@props([ + 'event' => [], +]) + +
  • + @php + $showParams = [$event['calendar_slug'], $event['id']]; + if (!empty($event['occurrence'])) { + $showParams['occurrence'] = $event['occurrence']; + } + @endphp + + {{ $event['title'] }} + +
  • diff --git a/resources/views/components/calendar/week/week.blade.php b/resources/views/components/calendar/week/week.blade.php index 7170c52..491a691 100644 --- a/resources/views/components/calendar/week/week.blade.php +++ b/resources/views/components/calendar/week/week.blade.php @@ -2,6 +2,8 @@ 'grid' => [], 'calendars' => [], 'events' => [], + 'all_day_events' => [], + 'has_all_day' => false, 'class' => '', 'slots' => [], 'timeformat' => '', @@ -15,13 +17,17 @@ ])
    $has_all_day ?? false]) + data-density="{{ $density['step'] }}" + data-weekstart="{{ $weekstart }}" + data-daytime-hours-enabled="{{ (int) ($daytime_hours['enabled'] ?? 0) }}" + style=" + --now-row: {{ (int) $now['row'] }}; --now-offset: {{ $now['offset'] ?? 0 }}; --now-col-start: {{ (int) $now['col_start'] }}; --now-col-end: {{ (int) $now['col_end'] }}; - --grid-rows: {{ $daytime_hours['rows'] ?? 96 }};" + --grid-rows: {{ $daytime_hours['rows'] ?? 96 }}; + " >
    @foreach ($hgroup as $h) @@ -46,12 +52,19 @@ {{ $h['day'] }}
    - @endforeach + @endforeach + @if($has_all_day) +
      + @foreach ($all_day_events as $event) + + @endforeach +
    + @endif
      @foreach ($slots as $slot)
    1. - +
    2. @endforeach
    diff --git a/resources/views/components/danger-button.blade.php b/resources/views/components/danger-button.blade.php deleted file mode 100644 index 0ce850b..0000000 --- a/resources/views/components/danger-button.blade.php +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/resources/views/components/input-label.blade.php b/resources/views/components/input-label.blade.php deleted file mode 100644 index 1cc65e2..0000000 --- a/resources/views/components/input-label.blade.php +++ /dev/null @@ -1,5 +0,0 @@ -@props(['value']) - - diff --git a/resources/views/components/primary-button.blade.php b/resources/views/components/primary-button.blade.php deleted file mode 100644 index 0cc5558..0000000 --- a/resources/views/components/primary-button.blade.php +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/resources/views/components/secondary-button.blade.php b/resources/views/components/secondary-button.blade.php deleted file mode 100644 index bf7b1d5..0000000 --- a/resources/views/components/secondary-button.blade.php +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/resources/views/components/text-input.blade.php b/resources/views/components/text-input.blade.php deleted file mode 100644 index 3302335..0000000 --- a/resources/views/components/text-input.blade.php +++ /dev/null @@ -1,3 +0,0 @@ -@props(['disabled' => false]) - -merge(['class' => '']) }}> diff --git a/resources/views/event/form.blade.php b/resources/views/event/form.blade.php index 25aef4a..bee8032 100644 --- a/resources/views/event/form.blade.php +++ b/resources/views/event/form.blade.php @@ -16,124 +16,7 @@
    - - - @csrf - @if($event->exists) - @method('PUT') - @endif - - {{-- Title --}} -
    - - - -
    - - {{-- Description --}} -
    - - - -
    - - {{-- Location --}} -
    - - - - {{-- suggestion dropdown target --}} -
    - - {{-- hidden fields (filled when user clicks a suggestion; handy for step #2) --}} - - - - - - - - - - - -
    - - {{-- Start / End --}} -
    - -
    - - - -
    - -
    - - - -
    - -
    - - {{-- All-day --}} -
    - meta?->all_day)) /> - -
    - - {{-- Recurrence (advanced) --}} -
    - - {{ __('Repeat (advanced)') }} - -
    - - -

    - Example: FREQ=WEEKLY;BYDAY=MO,WE -

    - -
    -
    - - {{-- Submit --}} -
    - - {{ __('Cancel') }} - - - - {{ $event->exists ? __('Save') : __('Create') }} - -
    - - + @include('event.partials.form', compact('calendar', 'event', 'start', 'end', 'rrule'))
    diff --git a/resources/views/event/partials/form-modal.blade.php b/resources/views/event/partials/form-modal.blade.php new file mode 100644 index 0000000..f3ce176 --- /dev/null +++ b/resources/views/event/partials/form-modal.blade.php @@ -0,0 +1,23 @@ + + +

    {{ $event->exists ? __('Edit Event') : __('Create Event') }}

    +
    + + @include('event.partials.form', [ + 'calendar' => $calendar, + 'event' => $event, + 'start' => $start, + 'end' => $end, + 'rrule' => $rrule, + 'isModal' => true, + ]) + + + + {{ __('common.cancel') }} + + + {{ $event->exists ? __('Save') : __('Create') }} + + +
    diff --git a/resources/views/event/partials/form.blade.php b/resources/views/event/partials/form.blade.php new file mode 100644 index 0000000..c9629bb --- /dev/null +++ b/resources/views/event/partials/form.blade.php @@ -0,0 +1,319 @@ +@php + $isModal = $isModal ?? false; + $formAction = $event->exists + ? route('calendar.event.update', [$calendar, $event]) + : route('calendar.event.store', $calendar); + $rruleValue = trim((string) ($rrule ?? '')); + $rruleParts = []; + foreach (array_filter(explode(';', $rruleValue)) as $chunk) { + if (!str_contains($chunk, '=')) continue; + [$key, $value] = explode('=', $chunk, 2); + $rruleParts[strtoupper($key)] = $value; + } + + $freq = strtolower($rruleParts['FREQ'] ?? ''); + $interval = (int) ($rruleParts['INTERVAL'] ?? 1); + if ($interval < 1) $interval = 1; + + $byday = array_filter(explode(',', $rruleParts['BYDAY'] ?? '')); + $bymonthday = array_filter(explode(',', $rruleParts['BYMONTHDAY'] ?? '')); + $bysetpos = $rruleParts['BYSETPOS'] ?? null; + + $startDefault = old('start_at', $start ?? null); + $startDate = $startDefault ? \Carbon\Carbon::parse($startDefault) : \Carbon\Carbon::now(); + $weekdayMap = [ + 'Sun' => 'SU', + 'Mon' => 'MO', + 'Tue' => 'TU', + 'Wed' => 'WE', + 'Thu' => 'TH', + 'Fri' => 'FR', + 'Sat' => 'SA', + ]; + $defaultWeekday = $weekdayMap[$startDate->format('D')] ?? 'MO'; + $defaultMonthDay = (int) $startDate->format('j'); + + $weekMap = [1 => 'first', 2 => 'second', 3 => 'third', 4 => 'fourth']; + $startWeek = $startDate->copy(); + $isLastWeek = $startWeek->copy()->addWeek()->month !== $startWeek->month; + $defaultMonthWeek = $isLastWeek ? 'last' : ($weekMap[$startDate->weekOfMonth] ?? 'first'); + + $monthMode = 'days'; + if (!empty($bymonthday)) { + $monthMode = 'days'; + } elseif (!empty($byday) && $bysetpos) { + $monthMode = 'weekday'; + } + + $repeatFrequency = old('repeat_frequency', $freq ?: ''); + $repeatInterval = old('repeat_interval', $interval); + $repeatWeekdays = old('repeat_weekdays', $byday ?: [$defaultWeekday]); + $repeatMonthDays = old('repeat_month_days', $bymonthday ?: [$defaultMonthDay]); + $repeatMonthMode = old('repeat_monthly_mode', $monthMode); + + $setposMap = ['1' => 'first', '2' => 'second', '3' => 'third', '4' => 'fourth', '-1' => 'last']; + $repeatMonthWeek = old('repeat_month_week', $setposMap[(string) $bysetpos] ?? $defaultMonthWeek); + $repeatMonthWeekday = old('repeat_month_weekday', $byday[0] ?? $defaultWeekday); + + $rruleOptions = [ + 'daily' => __('calendar.event.recurrence.daily'), + 'weekly' => __('calendar.event.recurrence.weekly'), + 'monthly' => __('calendar.event.recurrence.monthly'), + 'yearly' => __('calendar.event.recurrence.yearly'), + ]; + + $weekdayOptions = [ + 'SU' => __('calendar.event.recurrence.weekdays.sun_short'), + 'MO' => __('calendar.event.recurrence.weekdays.mon_short'), + 'TU' => __('calendar.event.recurrence.weekdays.tue_short'), + 'WE' => __('calendar.event.recurrence.weekdays.wed_short'), + 'TH' => __('calendar.event.recurrence.weekdays.thu_short'), + 'FR' => __('calendar.event.recurrence.weekdays.fri_short'), + 'SA' => __('calendar.event.recurrence.weekdays.sat_short'), + ]; + $weekdayLong = [ + 'SU' => __('calendar.event.recurrence.weekdays.sun'), + 'MO' => __('calendar.event.recurrence.weekdays.mon'), + 'TU' => __('calendar.event.recurrence.weekdays.tue'), + 'WE' => __('calendar.event.recurrence.weekdays.wed'), + 'TH' => __('calendar.event.recurrence.weekdays.thu'), + 'FR' => __('calendar.event.recurrence.weekdays.fri'), + 'SA' => __('calendar.event.recurrence.weekdays.sat'), + ]; +@endphp + +