572 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			572 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
<?php
 | 
						||
 | 
						||
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;
 | 
						||
 | 
						||
class CalendarController extends Controller
 | 
						||
{
 | 
						||
    /**
 | 
						||
     * Consolidated calendar dashboard.
 | 
						||
     *
 | 
						||
     * Query params:
 | 
						||
     *  view  = month | week | 4day   (default: month)
 | 
						||
     *  date  = Y-m-d anchor date     (default: today, in user TZ)
 | 
						||
     *
 | 
						||
     * The view receives a `$payload` array:
 | 
						||
     *  ├─ view          current view name
 | 
						||
     *  ├─ range         ['start' => Carbon, 'end' => Carbon]
 | 
						||
     *  ├─ calendars     keyed by calendar id (for the left-hand toggle list)
 | 
						||
     *  └─ events        flat list of VEVENTs in that range
 | 
						||
     */
 | 
						||
    public function index(Request $request)
 | 
						||
    {
 | 
						||
        // set the calendar key
 | 
						||
        $principal = auth()->user()->principal_uri;
 | 
						||
 | 
						||
        // get the view and time range
 | 
						||
        [$view, $range] = $this->resolveRange($request);
 | 
						||
 | 
						||
        // date range span
 | 
						||
        $span = $this->gridSpan($view, $range);
 | 
						||
 | 
						||
        // date range controls
 | 
						||
        $prev  = $range['start']->copy()->subMonth()->startOfMonth()->toDateString();
 | 
						||
        $next  = $range['start']->copy()->addMonth()->startOfMonth()->toDateString();
 | 
						||
        $today = Carbon::today()->toDateString();
 | 
						||
 | 
						||
        // get the user's visible calendars from the left bar
 | 
						||
        $visible = collect($request->query('c', []));
 | 
						||
 | 
						||
        /**
 | 
						||
         *
 | 
						||
         * calendars
 | 
						||
         */
 | 
						||
 | 
						||
        // load the user's local calendars
 | 
						||
        $locals = Calendar::query()
 | 
						||
            ->select(
 | 
						||
                'calendars.id',
 | 
						||
                'ci.displayname',
 | 
						||
                'ci.calendarcolor',
 | 
						||
                'ci.uri       as slug',
 | 
						||
                'ci.timezone  as timezone',
 | 
						||
                'meta.color   as meta_color',
 | 
						||
                'meta.color_fg as meta_color_fg',
 | 
						||
                DB::raw('0 as is_remote')
 | 
						||
            )
 | 
						||
            ->join('calendarinstances as ci', 'ci.calendarid', '=', 'calendars.id')
 | 
						||
            ->leftJoin('calendar_meta as meta', 'meta.calendar_id', '=', 'calendars.id')
 | 
						||
            ->where('ci.principaluri', $principal)
 | 
						||
            ->where(function ($q) {
 | 
						||
                $q->whereNull('meta.is_remote')
 | 
						||
                  ->orWhere('meta.is_remote', false);
 | 
						||
            })
 | 
						||
            ->orderBy('ci.displayname')
 | 
						||
            ->get();
 | 
						||
 | 
						||
        // load the users remote/subscription calendars
 | 
						||
        $remotes = Calendar::query()
 | 
						||
            ->select(
 | 
						||
                'calendars.id',
 | 
						||
                'ci.displayname',
 | 
						||
                'ci.calendarcolor',
 | 
						||
                'ci.uri        as slug',
 | 
						||
                'ci.timezone   as timezone',
 | 
						||
                'meta.color    as meta_color',
 | 
						||
                'meta.color_fg as meta_color_fg',
 | 
						||
                DB::raw('1 as is_remote')
 | 
						||
            )
 | 
						||
            ->join('calendarinstances as ci', 'ci.calendarid', '=', 'calendars.id')
 | 
						||
            ->join('calendar_meta as meta', 'meta.calendar_id', '=', 'calendars.id')
 | 
						||
            ->where('ci.principaluri', $principal)
 | 
						||
            ->where('meta.is_remote', true)
 | 
						||
            ->orderBy('ci.displayname')
 | 
						||
            ->get();
 | 
						||
 | 
						||
        // merge local and remote, and add the visibility flag
 | 
						||
        $visible = collect($request->query('c', []));
 | 
						||
        $calendars = $locals->merge($remotes)->map(function ($cal) use ($visible) {
 | 
						||
            $cal->visible = $visible->isEmpty() || $visible->contains($cal->slug);
 | 
						||
            return $cal;
 | 
						||
        });
 | 
						||
 | 
						||
        // handy lookup: [id => calendar row]
 | 
						||
        $calendar_map = $calendars->keyBy('id');
 | 
						||
 | 
						||
        /**
 | 
						||
         *
 | 
						||
         * get events for calendars in range
 | 
						||
         */
 | 
						||
 | 
						||
        // get all the events in one query
 | 
						||
        $events = Event::forCalendarsInRange(
 | 
						||
            $calendars->pluck('id'),
 | 
						||
            $span['start'],
 | 
						||
            $span['end']
 | 
						||
          )->map(function ($e) use ($calendar_map) {
 | 
						||
 | 
						||
            // 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);
 | 
						||
 | 
						||
            // 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);
 | 
						||
 | 
						||
            // return events array
 | 
						||
            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(),
 | 
						||
                'start_ui'      => $start_local->format('g:ia'),
 | 
						||
                'end_ui'        => optional($end_local)->format('g:ia'),
 | 
						||
                'timezone'      => $timezone,
 | 
						||
                'visible'       => $cal->visible,
 | 
						||
                'color'         => $cal->meta_color ?? $cal->calendarcolor ?? '#1a1a1a',
 | 
						||
                'color_fg'      => $cal->meta_color_fg ?? '#ffffff',
 | 
						||
            ];
 | 
						||
        })->keyBy('id');
 | 
						||
 | 
						||
        /**
 | 
						||
         *
 | 
						||
         * mini calendar
 | 
						||
         */
 | 
						||
 | 
						||
        // create the mini calendar grid based on the mini cal controls
 | 
						||
        $mini_anchor = $request->query('mini', $range['start']->toDateString());
 | 
						||
        $mini_start  = Carbon::parse($mini_anchor)->startOfMonth();
 | 
						||
        $mini_nav = [
 | 
						||
            'prev'  => $mini_start->copy()->subMonth()->toDateString(),
 | 
						||
            'next'  => $mini_start->copy()->addMonth()->toDateString(),
 | 
						||
            'today' => Carbon::today()->startOfMonth()->toDateString(),
 | 
						||
            'label' => $mini_start->format('F Y'),
 | 
						||
        ];
 | 
						||
 | 
						||
        // compute the mini's 42-day span (Mon..Sun, 6 rows)
 | 
						||
        $mini_grid_start = $mini_start->copy()->startOfWeek(Carbon::MONDAY);
 | 
						||
        $mini_grid_end   = $mini_start->copy()->endOfMonth()->endOfWeek(Carbon::SUNDAY);
 | 
						||
        if ($mini_grid_start->diffInDays($mini_grid_end) + 1 < 42) {
 | 
						||
            $mini_grid_end->addWeek();
 | 
						||
        }
 | 
						||
 | 
						||
        // fetch events specifically for the mini-span
 | 
						||
        $mini_events = Event::forCalendarsInRange(
 | 
						||
            $calendars->pluck('id'),
 | 
						||
            $mini_grid_start,
 | 
						||
            $mini_grid_end
 | 
						||
        )->map(function ($e) use ($calendar_map) {
 | 
						||
            $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);
 | 
						||
 | 
						||
            $tz = $cal->timezone ?? config('app.timezone');
 | 
						||
 | 
						||
            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'         => $cal->meta_color ?? $cal->calendarcolor ?? '#1a1a1a',
 | 
						||
                'color_fg'      => $cal->meta_color_fg ?? '#ffffff',
 | 
						||
            ];
 | 
						||
        })->keyBy('id');
 | 
						||
 | 
						||
        // now build the mini from mini_events (not from $events)
 | 
						||
        $mini = $this->buildMiniGrid($mini_start, $mini_events);
 | 
						||
 | 
						||
        /**
 | 
						||
         *
 | 
						||
         * main calendar grid
 | 
						||
         */
 | 
						||
 | 
						||
        // create the calendar grid of days
 | 
						||
        $grid = $this->buildCalendarGrid($view, $range, $events);
 | 
						||
 | 
						||
        // format the data for the frontend, including separate arrays for events specifically and the big grid
 | 
						||
        $payload = [
 | 
						||
            'view'      => $view,
 | 
						||
            'range'     => $range,
 | 
						||
            'nav'       => [
 | 
						||
                'prev'  => $prev,
 | 
						||
                'next'  => $next,
 | 
						||
                'today' => $today,
 | 
						||
            ],
 | 
						||
            'active'    => [
 | 
						||
                'year'  => $range['start']->format('Y'),
 | 
						||
                'month' => $range['start']->format("F"),
 | 
						||
                'day'   => $range['start']->format("d"),
 | 
						||
            ],
 | 
						||
            'calendars' => $calendars->mapWithKeys(function ($cal) {
 | 
						||
                return [
 | 
						||
                    $cal->id => [
 | 
						||
                        'id'        => $cal->id,
 | 
						||
                        'slug'      => $cal->slug,
 | 
						||
                        'name'      => $cal->displayname,
 | 
						||
                        'color'     => $cal->meta_color ?? $cal->calendarcolor ?? '#1a1a1a',
 | 
						||
                        'color_fg'  => $cal->meta_color_fg ?? '#ffffff',
 | 
						||
                        'visible'   => $cal->visible,
 | 
						||
                        'is_remote' => $cal->is_remote,
 | 
						||
                    ],
 | 
						||
                ];
 | 
						||
            }),
 | 
						||
            '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
 | 
						||
        ];
 | 
						||
 | 
						||
        return view('calendar.index', $payload);
 | 
						||
    }
 | 
						||
 | 
						||
    public function create()
 | 
						||
    {
 | 
						||
        return view('calendar.create');
 | 
						||
    }
 | 
						||
 | 
						||
    /**
 | 
						||
     * create sabre calendar + meta
 | 
						||
     */
 | 
						||
    public function store(Request $request)
 | 
						||
    {
 | 
						||
        $data = $request->validate([
 | 
						||
            'name'        => 'required|string|max:100',
 | 
						||
            'description' => 'nullable|string|max:255',
 | 
						||
            'timezone'    => 'required|string',
 | 
						||
            'color'       => 'nullable|regex:/^#[0-9A-Fa-f]{6}$/',
 | 
						||
        ]);
 | 
						||
 | 
						||
        // update master calendar entry
 | 
						||
        $calId = DB::table('calendars')->insertGetId([
 | 
						||
            'synctoken'  => 1,
 | 
						||
            'components' => 'VEVENT', // or 'VEVENT,VTODO' if you add tasks
 | 
						||
        ]);
 | 
						||
 | 
						||
        // update the calendar instance row
 | 
						||
        $instance = CalendarInstance::create([
 | 
						||
            'calendarid'   => $calId,
 | 
						||
            'principaluri' => auth()->user()->principal_uri,
 | 
						||
            'uri'          => Str::uuid(),
 | 
						||
            'displayname'  => $data['name'],
 | 
						||
            'description'  => $data['description'] ?? null,
 | 
						||
            'calendarcolor'=> $data['color'] ?? null,
 | 
						||
            'timezone'     => $data['timezone'],
 | 
						||
        ]);
 | 
						||
 | 
						||
        // update calendar meta
 | 
						||
        $instance->meta()->create([
 | 
						||
            'calendar_id' => $instanceId,
 | 
						||
            'color'       => $data['color'] ?? '#1a1a1a',
 | 
						||
            'color_fg'    => contrast_text_color($data['color'] ?? '#1a1a1a'),
 | 
						||
            'created_at'  => now(),
 | 
						||
            'updated_at'  => now(),
 | 
						||
        ]);
 | 
						||
 | 
						||
        return redirect()->route('calendar.index');
 | 
						||
    }
 | 
						||
 | 
						||
    /**
 | 
						||
     * show calendar details
 | 
						||
     */
 | 
						||
    public function show(Calendar $calendar)
 | 
						||
    {
 | 
						||
        $this->authorize('view', $calendar);
 | 
						||
 | 
						||
        $calendar->load([
 | 
						||
            'meta',
 | 
						||
            'instances' => fn ($q) => $q->where('principaluri', auth()->user()->principal_uri),
 | 
						||
        ]);
 | 
						||
 | 
						||
        /* grab the single instance for convenience in the view */
 | 
						||
        $instance = $calendar->instances->first();
 | 
						||
        $caldavUrl = $instance?->caldavUrl();   // null-safe
 | 
						||
 | 
						||
        /* events + meta, newest first */
 | 
						||
        $events = $calendar->events()
 | 
						||
            ->with('meta')
 | 
						||
            ->orderByDesc('lastmodified')
 | 
						||
            ->get();
 | 
						||
 | 
						||
        return view(
 | 
						||
            'calendar.show',
 | 
						||
            compact('calendar', 'instance', 'events', 'caldavUrl')
 | 
						||
        );
 | 
						||
    }
 | 
						||
 | 
						||
    /**
 | 
						||
     * edit calendar page
 | 
						||
     */
 | 
						||
    public function edit(Calendar $calendar)
 | 
						||
    {
 | 
						||
        $this->authorize('update', $calendar);
 | 
						||
 | 
						||
        $calendar->load([
 | 
						||
        'meta',
 | 
						||
        'instances' => fn ($q) =>
 | 
						||
            $q->where('principaluri', auth()->user()->principal_uri),
 | 
						||
        ]);
 | 
						||
 | 
						||
        $instance = $calendar->instances->first();   // may be null but shouldn’t
 | 
						||
 | 
						||
        return view('calendar.edit', compact('calendar', 'instance'));
 | 
						||
    }
 | 
						||
 | 
						||
    /**
 | 
						||
     * update sabre + meta records
 | 
						||
     */
 | 
						||
    public function update(Request $request, Calendar $calendar)
 | 
						||
    {
 | 
						||
        $this->authorize('update', $calendar);
 | 
						||
 | 
						||
        $data = $request->validate([
 | 
						||
            'name'        => 'required|string|max:100',
 | 
						||
            'description' => 'nullable|string|max:255',
 | 
						||
            'timezone'    => 'required|string',
 | 
						||
            'color'       => 'nullable|regex:/^#[0-9A-Fa-f]{6}$/',
 | 
						||
        ]);
 | 
						||
 | 
						||
        // update the instance row
 | 
						||
        $calendar->instances()
 | 
						||
            ->where('principaluri', auth()->user()->principal_uri)
 | 
						||
            ->update([
 | 
						||
                'displayname' => $data['name'],
 | 
						||
                'description' => $data['description'] ?? '',
 | 
						||
                'calendarcolor' => $data['color'] ?? null,
 | 
						||
                'timezone'    => $data['timezone'],
 | 
						||
            ]);
 | 
						||
 | 
						||
        // bump synctoken on master calendar row
 | 
						||
        $calendar->increment('synctoken');
 | 
						||
 | 
						||
        // update calendar meta (our table)
 | 
						||
        $calendar->meta()->updateOrCreate([], [
 | 
						||
            'color'    => $data['color'] ?? '#1a1a1a',
 | 
						||
            'color_fg' => contrast_text_color($data['color'] ?? '#1a1a1a')
 | 
						||
        ]);
 | 
						||
 | 
						||
        return redirect()
 | 
						||
            ->route('calendar.show', $calendar)
 | 
						||
            ->with('toast', __('Calendar saved successfully!'));
 | 
						||
    }
 | 
						||
 | 
						||
    /**
 | 
						||
     * delete calendar @todo
 | 
						||
     */
 | 
						||
    public function destroy(Calendar $calendar)
 | 
						||
    {
 | 
						||
        $this->authorize('delete', $calendar);
 | 
						||
        $calendar->delete();          // cascades to meta via FK
 | 
						||
        return redirect()->route('calendar.index');
 | 
						||
    }
 | 
						||
 | 
						||
    /**
 | 
						||
     *
 | 
						||
     * Private helpers
 | 
						||
     */
 | 
						||
 | 
						||
    /**
 | 
						||
     * Span actually rendered by the grid.
 | 
						||
     * Month → startOfMonth->startOfWeek .. endOfMonth->endOfWeek
 | 
						||
     */
 | 
						||
    private function gridSpan(string $view, array $range): array
 | 
						||
    {
 | 
						||
        switch ($view) {
 | 
						||
            case 'week':
 | 
						||
                $start = $range['start']->copy();            // resolveRange already did startOfWeek
 | 
						||
                $end   = $range['start']->copy()->addDays(6);
 | 
						||
                break;
 | 
						||
 | 
						||
            case '4day':
 | 
						||
                $start = $range['start']->copy();            // resolveRange already did startOfDay
 | 
						||
                $end   = $range['start']->copy()->addDays(3);
 | 
						||
                break;
 | 
						||
 | 
						||
            default: // month
 | 
						||
                $start = $range['start']->copy()->startOfMonth()->startOfWeek();
 | 
						||
                $end   = $range['end']->copy()->endOfMonth()->endOfWeek();
 | 
						||
        }
 | 
						||
 | 
						||
        return ['start' => $start, 'end' => $end];
 | 
						||
    }
 | 
						||
 | 
						||
    /**
 | 
						||
     * normalise $view and $date into a carbon range
 | 
						||
     *
 | 
						||
     * @return array [$view, ['start' => Carbon, 'end' => Carbon]]
 | 
						||
     */
 | 
						||
    private function resolveRange(Request $request): array
 | 
						||
    {
 | 
						||
        // get the view
 | 
						||
        $view = in_array($request->query('view'), ['week', '4day'])
 | 
						||
              ? $request->query('view')
 | 
						||
              : 'month';
 | 
						||
 | 
						||
        // anchor date in the user's timezone
 | 
						||
        $anchor = Carbon::parse($request->query('date', now()->toDateString()))
 | 
						||
                        ->setTimezone(auth()->user()->timezone ?? config('app.timezone'));
 | 
						||
 | 
						||
        // set dates based on view
 | 
						||
        switch ($view) {
 | 
						||
            case 'week':
 | 
						||
                $start = $anchor->copy()->startOfWeek();
 | 
						||
                $end   = $anchor->copy()->endOfWeek();
 | 
						||
                break;
 | 
						||
 | 
						||
            case '4day':
 | 
						||
                // 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]];
 | 
						||
    }
 | 
						||
 | 
						||
    /**
 | 
						||
     * 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): 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);
 | 
						||
 | 
						||
        // today checks
 | 
						||
        $tz = auth()->user()->timezone ?? config('app.timezone', 'UTC');
 | 
						||
        $today = \Carbon\Carbon::today($tz);
 | 
						||
 | 
						||
        // 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;
 | 
						||
 | 
						||
            // spread multi-day events
 | 
						||
            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];
 | 
						||
    }
 | 
						||
 | 
						||
    /**
 | 
						||
     * Build the mini-month grid for day buttons
 | 
						||
     *
 | 
						||
     * Returns ['days' => [
 | 
						||
     *   [
 | 
						||
     *     'date'      => '2025-06-30',
 | 
						||
     *     'label'     => '30',
 | 
						||
     *     'in_month'  => false,
 | 
						||
     *     'events'    => [id, id …]
 | 
						||
     *   ], …
 | 
						||
     * ]]
 | 
						||
     */
 | 
						||
    private function buildMiniGrid(Carbon $monthStart, Collection $events): array
 | 
						||
    {
 | 
						||
        // get bounds
 | 
						||
        $monthEnd  = $monthStart->copy()->endOfMonth();
 | 
						||
        $gridStart = $monthStart->copy()->startOfWeek(Carbon::MONDAY);
 | 
						||
        $gridEnd   = $monthEnd->copy()->endOfWeek(Carbon::SUNDAY);
 | 
						||
 | 
						||
        // ensure we have 42 days (6 rows); 35 = add one extra week
 | 
						||
        if ($gridStart->diffInDays($gridEnd) + 1 < 42) {
 | 
						||
            $gridEnd->addWeek();
 | 
						||
        }
 | 
						||
 | 
						||
        /* map event-ids by yyyy-mm-dd */
 | 
						||
        $byDay = [];
 | 
						||
        $tzFallback = auth()->user()->timezone ?? config('app.timezone', 'UTC');
 | 
						||
 | 
						||
        foreach ($events as $ev) {
 | 
						||
            $evTz = $ev['timezone'] ?? $tzFallback;
 | 
						||
 | 
						||
            $s = Carbon::parse($ev['start'])->tz($evTz);
 | 
						||
            $e = $ev['end'] ? Carbon::parse($ev['end'])->tz($evTz) : $s;
 | 
						||
 | 
						||
            for ($d = $s->copy()->startOfDay(); $d->lte($e->copy()->endOfDay()); $d->addDay()) {
 | 
						||
                $byDay[$d->toDateString()][] = $ev['id'];
 | 
						||
            }
 | 
						||
        }
 | 
						||
 | 
						||
        /* Walk the 42-day span */
 | 
						||
        $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),
 | 
						||
                'events'   => $byDay[$iso] ?? [],
 | 
						||
            ];
 | 
						||
        }
 | 
						||
 | 
						||
        // will always be 42 to ensure 6 rows
 | 
						||
        return ['days' => $days];
 | 
						||
    }
 | 
						||
}
 |