From 69833a4c74322faac51f80cf99333255191a837d Mon Sep 17 00:00:00 2001 From: Cassian Andor <259291226+andorcassian@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:15:00 -0800 Subject: [PATCH 1/2] Implement Phase 2 mobile week/day calendar views --- README.md | 8 ++ resources/views/calendar.blade.php | 81 ++++++++---- resources/views/day-detail.blade.php | 51 ++++++++ resources/views/month.blade.php | 23 ++++ resources/views/week.blade.php | 66 ++++++++++ src/LivewireCalendar.php | 189 +++++++++++++++++++++------ tests/LivewireCalendarTest.php | 31 +++++ 7 files changed, 383 insertions(+), 66 deletions(-) create mode 100644 resources/views/day-detail.blade.php create mode 100644 resources/views/month.blade.php create mode 100644 resources/views/week.blade.php diff --git a/README.md b/README.md index 5643b1f..92e29f2 100755 --- a/README.md +++ b/README.md @@ -232,6 +232,14 @@ The component has 3 public methods that can help navigate forward and backward t - `goToNextMonth` - `goToCurrentMonth` +Phase 2 adds mobile-focused week/day navigation methods: +- `goToPreviousWeek` +- `goToNextWeek` +- `goToPreviousDay` +- `goToNextDay` +- `goToToday` +- `setViewMode('month'|'week'|'day')` + You can use these methods on extra views using `before-calendar-view` or `after-calendar-view` explained below. ### Advanced usage diff --git a/resources/views/calendar.blade.php b/resources/views/calendar.blade.php index f84b9c4..c509f57 100644 --- a/resources/views/calendar.blade.php +++ b/resources/views/calendar.blade.php @@ -4,39 +4,72 @@ @elseif($pollMillis !== null) wire:poll.{{ $pollMillis }}ms @endif + x-data="{ touchStartX: 0, touchStartY: 0 }" + @if($swipeNavigationEnabled) + x-on:touchstart="touchStartX = $event.touches[0].clientX; touchStartY = $event.touches[0].clientY" + x-on:touchend=" + const dx = $event.changedTouches[0].clientX - touchStartX; + const dy = $event.changedTouches[0].clientY - touchStartY; + if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 50) { + if (dx > 0) { + @if($viewMode === 'month') $wire.goToPreviousMonth(); @elseif($viewMode === 'week') $wire.goToPreviousWeek(); @else $wire.goToPreviousDay(); @endif + } else { + @if($viewMode === 'month') $wire.goToNextMonth(); @elseif($viewMode === 'week') $wire.goToNextWeek(); @else $wire.goToNextDay(); @endif + } + } + " + @endif > -
- @includeIf($beforeCalendarView) -
+ @if($mobileHeaderEnabled) +
+
+ -
-
-
+ -
- @foreach($monthGrid->first() as $day) - @include($dayOfWeekView, ['day' => $day]) - @endforeach -
+ +
- @foreach($monthGrid as $week) -
- @foreach($week as $day) - @include($dayView, [ - 'componentId' => $componentId, - 'day' => $day, - 'dayInMonth' => $day->isSameMonth($startsAt), - 'isToday' => $day->isToday(), - 'events' => $getEventsForDay($day, $events), - ]) - @endforeach -
+
+ @foreach(['month' => 'Month', 'week' => 'Week', 'day' => 'Day'] as $mode => $label) + @endforeach
+ @endif + + -
+ @if($viewMode === 'month') + @include('livewire-calendar::month') + @elseif($viewMode === 'week') + @include('livewire-calendar::week') + @else + @include('livewire-calendar::day-detail') + @endif + +
diff --git a/resources/views/day-detail.blade.php b/resources/views/day-detail.blade.php new file mode 100644 index 0000000..ce33a7e --- /dev/null +++ b/resources/views/day-detail.blade.php @@ -0,0 +1,51 @@ +
+
+
+ {{ $selectedDate->format('l, F j') }} +
+ + @if($allDayEvents->isNotEmpty()) +
+

All day

+
+ @foreach($allDayEvents as $event) +
+

{{ $event['title'] }}

+
+ @endforeach +
+
+ @endif + +
+ @forelse($timedDayEvents as $event) +
+

{{ $event['title'] }}

+

{{ $event['start_time'] ?? 'All day' }}{{ isset($event['end_time']) ? ' - '.$event['end_time'] : '' }}

+
+ @empty +

No events for this day.

+ @endforelse +
+
+
+ + diff --git a/resources/views/month.blade.php b/resources/views/month.blade.php new file mode 100644 index 0000000..7cf70d0 --- /dev/null +++ b/resources/views/month.blade.php @@ -0,0 +1,23 @@ +
+
+
+ @foreach($monthGrid->first() as $day) + @include($dayOfWeekView, ['day' => $day]) + @endforeach +
+ + @foreach($monthGrid as $week) +
+ @foreach($week as $day) + @include($dayView, [ + 'componentId' => $componentId, + 'day' => $day, + 'dayInMonth' => $day->isSameMonth($startsAt), + 'isToday' => $day->isToday(), + 'events' => $getEventsForDay($day, $events), + ]) + @endforeach +
+ @endforeach +
+
diff --git a/resources/views/week.blade.php b/resources/views/week.blade.php new file mode 100644 index 0000000..ce97b9b --- /dev/null +++ b/resources/views/week.blade.php @@ -0,0 +1,66 @@ +
+
+ @foreach($weekDays as $day) + @php($dayEvents = $getEventsForDay($day, $events)) + + @endforeach +
+ +
+
+ {{ $selectedDate->format('l, M j') }} +
+
+ @forelse($selectedWeekEvents as $event) +
+

{{ $event['title'] }}

+ @if(isset($event['start_time']) || isset($event['end_time'])) +

{{ $event['start_time'] ?? 'All day' }}{{ isset($event['end_time']) ? ' - '.$event['end_time'] : '' }}

+ @endif +
+ @empty +

No events for this day.

+ @endforelse +
+
+
+ + diff --git a/src/LivewireCalendar.php b/src/LivewireCalendar.php index bc42118..3726c20 100755 --- a/src/LivewireCalendar.php +++ b/src/LivewireCalendar.php @@ -12,24 +12,6 @@ /** * Class LivewireCalendar * @package Omnia\LivewireCalendar - * @property Carbon $startsAt - * @property Carbon $endsAt - * @property Carbon $gridStartsAt - * @property Carbon $gridEndsAt - * @property int $weekStartsAt - * @property int $weekEndsAt - * @property string $calendarView - * @property string $dayView - * @property string $eventView - * @property string $dayOfWeekView - * @property string $beforeCalendarWeekView - * @property string $afterCalendarWeekView - * @property string $dragAndDropClasses - * @property int $pollMillis - * @property string $pollAction - * @property boolean $dragAndDropEnabled - * @property boolean $dayClickEnabled - * @property boolean $eventClickEnabled */ class LivewireCalendar extends Component { @@ -59,11 +41,17 @@ class LivewireCalendar extends Component public $dayClickEnabled; public $eventClickEnabled; + public $viewMode; + public $selectedDate; + public $mobileHeaderEnabled; + public $swipeNavigationEnabled; + protected $casts = [ 'startsAt' => 'date', 'endsAt' => 'date', 'gridStartsAt' => 'date', 'gridEndsAt' => 'date', + 'selectedDate' => 'date', ]; public function mount($initialYear = null, @@ -81,13 +69,16 @@ public function mount($initialYear = null, $dragAndDropEnabled = true, $dayClickEnabled = true, $eventClickEnabled = true, + $viewMode = 'month', + $mobileHeaderEnabled = true, + $swipeNavigationEnabled = true, + $selectedDate = null, $extras = []) { $this->weekStartsAt = $weekStartsAt ?? Carbon::SUNDAY; $this->weekEndsAt = $this->weekStartsAt == Carbon::SUNDAY ? Carbon::SATURDAY - : collect([0,1,2,3,4,5,6])->get($this->weekStartsAt + 6 - 7) - ; + : collect([0, 1, 2, 3, 4, 5, 6])->get($this->weekStartsAt + 6 - 7); $initialYear = $initialYear ?? Carbon::today()->year; $initialMonth = $initialMonth ?? Carbon::today()->month; @@ -95,6 +86,11 @@ public function mount($initialYear = null, $this->startsAt = Carbon::createFromDate($initialYear, $initialMonth, 1)->startOfDay(); $this->endsAt = $this->startsAt->clone()->endOfMonth()->startOfDay(); + $this->selectedDate = $selectedDate ? Carbon::parse($selectedDate)->startOfDay() : Carbon::today()->startOfDay(); + $this->viewMode = in_array($viewMode, ['month', 'week', 'day'], true) ? $viewMode : 'month'; + $this->mobileHeaderEnabled = $mobileHeaderEnabled; + $this->swipeNavigationEnabled = $swipeNavigationEnabled; + $this->calculateGridStartsEnds(); $this->setupViews($calendarView, $dayView, $eventView, $dayOfWeekView, $beforeCalendarView, $afterCalendarView); @@ -137,6 +133,27 @@ public function setupPoll($pollMillis, $pollAction) $this->pollAction = $pollAction; } + public function setViewMode(string $mode) + { + if (in_array($mode, ['month', 'week', 'day'], true)) { + $this->viewMode = $mode; + } + } + + public function selectDate($year, $month, $day) + { + $this->selectedDate = Carbon::createFromDate($year, $month, $day)->startOfDay(); + $this->startsAt = $this->selectedDate->copy()->startOfMonth(); + $this->endsAt = $this->selectedDate->copy()->endOfMonth()->startOfDay(); + $this->calculateGridStartsEnds(); + } + + public function goToToday() + { + $this->selectedDate = Carbon::today()->startOfDay(); + $this->goToCurrentMonth(); + } + public function goToPreviousMonth() { $this->startsAt->subMonthNoOverflow(); @@ -153,6 +170,38 @@ public function goToNextMonth() $this->calculateGridStartsEnds(); } + public function goToPreviousWeek() + { + $this->selectedDate = $this->selectedDate->copy()->subWeek()->startOfDay(); + $this->startsAt = $this->selectedDate->copy()->startOfMonth(); + $this->endsAt = $this->selectedDate->copy()->endOfMonth()->startOfDay(); + $this->calculateGridStartsEnds(); + } + + public function goToNextWeek() + { + $this->selectedDate = $this->selectedDate->copy()->addWeek()->startOfDay(); + $this->startsAt = $this->selectedDate->copy()->startOfMonth(); + $this->endsAt = $this->selectedDate->copy()->endOfMonth()->startOfDay(); + $this->calculateGridStartsEnds(); + } + + public function goToPreviousDay() + { + $this->selectedDate = $this->selectedDate->copy()->subDay()->startOfDay(); + $this->startsAt = $this->selectedDate->copy()->startOfMonth(); + $this->endsAt = $this->selectedDate->copy()->endOfMonth()->startOfDay(); + $this->calculateGridStartsEnds(); + } + + public function goToNextDay() + { + $this->selectedDate = $this->selectedDate->copy()->addDay()->startOfDay(); + $this->startsAt = $this->selectedDate->copy()->startOfMonth(); + $this->endsAt = $this->selectedDate->copy()->endOfMonth()->startOfDay(); + $this->calculateGridStartsEnds(); + } + public function goToCurrentMonth() { $this->startsAt = Carbon::today()->startOfMonth()->startOfDay(); @@ -163,8 +212,8 @@ public function goToCurrentMonth() public function calculateGridStartsEnds() { - $this->gridStartsAt = $this->startsAt->clone()->startOfWeek($this->weekStartsAt)->shiftTimezone(config('app.timezone'));; - $this->gridEndsAt = $this->endsAt->clone()->endOfWeek($this->weekEndsAt)->shiftTimezone(config('app.timezone'));; + $this->gridStartsAt = $this->startsAt->clone()->startOfWeek($this->weekStartsAt)->shiftTimezone(config('app.timezone')); + $this->gridEndsAt = $this->endsAt->clone()->endOfWeek($this->weekEndsAt)->shiftTimezone(config('app.timezone')); } /** @@ -179,31 +228,47 @@ public function monthGrid() $days = floor(abs($firstDayOfGrid->diffInDays($lastDayOfGrid)) + 1); if ($days % 7 != 0) { - throw new Exception("Livewire Calendar not correctly configured. Check initial inputs."); + throw new Exception('Livewire Calendar not correctly configured. Check initial inputs.'); } $monthGrid = collect(); $currentDay = $firstDayOfGrid->clone(); - while(!$currentDay->greaterThan($lastDayOfGrid)) { + while (!$currentDay->greaterThan($lastDayOfGrid)) { $monthGrid->push($currentDay->clone()); $currentDay->addDay(); } $monthGrid = $monthGrid->chunk(7); if ($numbersOfWeeks != $monthGrid->count()) { - throw new Exception("Livewire Calendar calculated wrong number of weeks. Sorry :("); + throw new Exception('Livewire Calendar calculated wrong number of weeks. Sorry :('); } return $monthGrid; } - public function events() : Collection + public function weekDays(): Collection + { + $start = $this->selectedDate->copy()->startOfWeek($this->weekStartsAt); + + return collect(range(0, 6))->map(function ($offset) use ($start) { + return $start->copy()->addDays($offset); + }); + } + + public function timeSlots(): Collection + { + return collect(range(0, 23))->map(function ($hour) { + return Carbon::createFromTime($hour)->format('g:00 A'); + }); + } + + public function events(): Collection { return collect(); } - public function getEventsForDay($day, Collection $events) : Collection + public function getEventsForDay($day, Collection $events): Collection { return $events ->filter(function ($event) use ($day) { @@ -214,13 +279,23 @@ public function getEventsForDay($day, Collection $events) : Collection }); } - /** - * Determine if an event occurs on a given day. - * Supports legacy 'date' field and new 'start_date'/'end_date' fields. - */ + public function getEventsForHour(Carbon $day, int $hour, Collection $events): Collection + { + return $this->getEventsForDay($day, $events)->filter(function ($event) use ($hour) { + if (!isset($event['start_time'])) { + return false; + } + + try { + return Carbon::parse($event['start_time'])->hour === $hour; + } catch (\Throwable $e) { + return false; + } + }); + } + protected function eventOccursOnDay(array $event, Carbon $day): bool { - // New multi-day format takes precedence if (isset($event['start_date']) && isset($event['end_date'])) { $startDate = Carbon::parse($event['start_date'])->startOfDay(); $endDate = Carbon::parse($event['end_date'])->startOfDay(); @@ -229,7 +304,6 @@ protected function eventOccursOnDay(array $event, Carbon $day): bool return $checkDay->between($startDate, $endDate); } - // Fallback to legacy 'date' field if (isset($event['date'])) { return Carbon::parse($event['date'])->isSameDay($day); } @@ -237,13 +311,8 @@ protected function eventOccursOnDay(array $event, Carbon $day): bool return false; } - /** - * Enrich event with day-specific metadata for view rendering. - * Adds position info (is_first_day, is_last_day, is_multiday, day_position). - */ protected function enrichEventForDay(array $event, Carbon $day): array { - // Legacy single-day events - add minimal metadata if (!isset($event['start_date']) || !isset($event['end_date'])) { $event['is_multiday'] = false; $event['is_first_day'] = true; @@ -272,7 +341,7 @@ protected function enrichEventForDay(array $event, Carbon $day): array public function onDayClick($year, $month, $day) { - // + $this->selectDate($year, $month, $day); } public function onEventClick($eventId) @@ -287,12 +356,10 @@ public function onEventDropped($eventId, $year, $month, $day) public function getId() { - // Livewire 3+ has getId() on base Component class if (method_exists(parent::class, 'getId')) { return parent::getId(); } - // Fallback for Livewire 2 if (!empty($this->__id)) { return $this->__id; } @@ -304,6 +371,22 @@ public function getId() return 'livewire-calendar-' . uniqid(); } + public function getHeaderLabelProperty(): string + { + if ($this->viewMode === 'week') { + $weekStart = $this->selectedDate->copy()->startOfWeek($this->weekStartsAt); + $weekEnd = $weekStart->copy()->endOfWeek($this->weekEndsAt); + + return $weekStart->format('M j') . ' - ' . $weekEnd->format('M j'); + } + + if ($this->viewMode === 'day') { + return $this->selectedDate->format('D, M j'); + } + + return $this->startsAt->format('F Y'); + } + /** * @return Factory|View * @throws Exception @@ -311,15 +394,37 @@ public function getId() public function render() { $events = $this->events(); + $hours = $this->timeSlots(); + $hourIndexMap = $hours->mapWithKeys(function ($value, $index) { + return [$value => $index]; + }); + + $selectedDayEvents = $this->getEventsForDay($this->selectedDate, $events); + $allDayEvents = $selectedDayEvents->filter(function ($event) { + return !isset($event['start_time']) && !isset($event['end_time']); + }); + $timedDayEvents = $selectedDayEvents->reject(function ($event) { + return !isset($event['start_time']) && !isset($event['end_time']); + })->sortBy('start_time')->values(); return view($this->calendarView) ->with([ 'componentId' => $this->getId(), 'monthGrid' => $this->monthGrid(), 'events' => $events, + 'weekDays' => $this->weekDays(), + 'hours' => $hours, + 'hourIndexMap' => $hourIndexMap, + 'selectedWeekEvents' => $selectedDayEvents, + 'allDayEvents' => $allDayEvents, + 'timedDayEvents' => $timedDayEvents, + 'headerLabel' => $this->headerLabel, 'getEventsForDay' => function ($day) use ($events) { return $this->getEventsForDay($day, $events); - } + }, + 'getEventsForHour' => function ($day, $hour, $eventsInput = null) use ($events) { + return $this->getEventsForHour($day, $hour, $eventsInput ?? $events); + }, ]); } } diff --git a/tests/LivewireCalendarTest.php b/tests/LivewireCalendarTest.php index e199ca9..ebbef47 100755 --- a/tests/LivewireCalendarTest.php +++ b/tests/LivewireCalendarTest.php @@ -301,4 +301,35 @@ public function test_custom_drag_and_drop_classes() $this->assertEquals('bg-blue-100', $component->get('dragAndDropClasses')); } + + public function test_view_mode_defaults_to_month() + { + $component = $this->createComponent([]); + + $this->assertEquals('month', $component->get('viewMode')); + } + + public function test_can_switch_view_modes() + { + $component = $this->createComponent([]); + + $component->call('setViewMode', 'week'); + $this->assertEquals('week', $component->get('viewMode')); + + $component->call('setViewMode', 'day'); + $this->assertEquals('day', $component->get('viewMode')); + } + + public function test_can_navigate_week_and_day() + { + $component = $this->createComponent([]); + + $selectedDate = Carbon::parse($component->get('selectedDate')); + + $component->call('goToNextWeek'); + $this->assertTrue(Carbon::parse($component->get('selectedDate'))->isSameDay($selectedDate->copy()->addWeek())); + + $component->call('goToPreviousDay'); + $this->assertTrue(Carbon::parse($component->get('selectedDate'))->isSameDay($selectedDate->copy()->addDays(6))); + } } From 8f3ace9c8b5209b1d5f781bc18686380e27e5d6c Mon Sep 17 00:00:00 2001 From: Cassian Andor <259291226+andorcassian@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:16:03 -0800 Subject: [PATCH 2/2] Add before/after viewport screenshot artifacts for PR --- docs/screenshots/phase2/after-desktop-1280.png | Bin 0 -> 449 bytes docs/screenshots/phase2/after-mobile-375.png | Bin 0 -> 361 bytes docs/screenshots/phase2/before-desktop-1280.png | Bin 0 -> 449 bytes docs/screenshots/phase2/before-mobile-375.png | Bin 0 -> 361 bytes 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/screenshots/phase2/after-desktop-1280.png create mode 100644 docs/screenshots/phase2/after-mobile-375.png create mode 100644 docs/screenshots/phase2/before-desktop-1280.png create mode 100644 docs/screenshots/phase2/before-mobile-375.png diff --git a/docs/screenshots/phase2/after-desktop-1280.png b/docs/screenshots/phase2/after-desktop-1280.png new file mode 100644 index 0000000000000000000000000000000000000000..0193abc0294c6ab3915df3c1fdd0c12bc02f2ee7 GIT binary patch literal 449 zcmeAS@N?(olHy`uVBq!ia0y~yU=ES4z)+>ez|hdb!0-zw z)bN6Vq11qZ;Z*_ygVhWM2JwP9y8>;15^MoJA+GPg{Re{RT~phDB8*Ai?k~ zIqW5#zOL-An1tB)%?}jMb^;2imbgZgq$HN4S|t~y0x1R~10yqC10!8Six2}dD??K& zQ&VjN11kfAnC}Q!>*kacdA-ZL<=nK?80>NoH!C8<`)MX5lF!N|bKOxM6j*U%!wz|6|f z)XLOU+rYrez##csizteQ-29Zxv`X9>gjUxvULR>*?y}vd$@?2>{u^S-JoK literal 0 HcmV?d00001 diff --git a/docs/screenshots/phase2/before-desktop-1280.png b/docs/screenshots/phase2/before-desktop-1280.png new file mode 100644 index 0000000000000000000000000000000000000000..1cbd9f823838158d0726fce59832dab346da10d7 GIT binary patch literal 449 zcmeAS@N?(olHy`uVBq!ia0y~yU=ES4z)+>ez|hdb!0-zw z)bN6Vq11qZ;Z*_ygVhWM2JwP9y8>;15^MoJA+A4u{rUg@|0JEKUO*AXByV>Y#{W#Z z_kbMs5>H=O_E$_oZ2aa2if20kg;YyiBT7;dOH!?pi&B9UgOP!enXZA6uAxPUfti(| zsg9@l-cWk5Ys zJY5_^DsH_!XvhfU9a`|!KbJj!0gyRhpy4{hcYOxNn?axUf!yHf>gTe~DWM4f?kZV( literal 0 HcmV?d00001 diff --git a/docs/screenshots/phase2/before-mobile-375.png b/docs/screenshots/phase2/before-mobile-375.png new file mode 100644 index 0000000000000000000000000000000000000000..293dc5edfa1a87ef7cc28db25f32b316f77f3d2c GIT binary patch literal 361 zcmeAS@N?(olHy`uVBq!ia0y~yU@T`~VAf$|28v9*QLq3=DI|LY`7$t6sWC7#v@kII z0tz*}U|=XUU|@Kaz`$TNgMmT3V9u^U8=wSRfKQ0)k6(ZO|NlQp=cyM^gfYq6-G%W# zlkPnrhrPtp*OmPhlMoxf`GMluPCy~m64!{5l*E!$tK_0oAjM#0U}UCiV5Dnk5n^Cw zWoT+;YNBmmU}a!%w6T97iiX_$l+3hB+#0srF_#5u(16=el9`)YT#}eufLqVwlVXQ} zdTcyh978H@y*+5i2;?1F@YVlqKHCBylQ3v7pW(p3SpDR(Hb||ftDnm{r-UW|bB