380 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			380 lines
		
	
	
		
			13 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;
 | 
						||
 | 
						||
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);
 | 
						||
 | 
						||
        // get the user's selected calendars
 | 
						||
        $visible = collect($request->query('c', []));
 | 
						||
 | 
						||
        // load the user's calendars
 | 
						||
        $calendars = 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'
 | 
						||
            )
 | 
						||
            ->join('calendarinstances as ci', 'ci.calendarid', '=', 'calendars.id')
 | 
						||
            ->leftJoin('calendar_meta as meta', 'meta.calendar_id', '=', 'calendars.id')
 | 
						||
            ->where('ci.principaluri', $principal)
 | 
						||
            ->orderBy('ci.displayname')
 | 
						||
            ->get()
 | 
						||
            ->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 all the events in one query
 | 
						||
        $events = Event::forCalendarsInRange(
 | 
						||
            $calendars->pluck('id'),
 | 
						||
            $range['start'],
 | 
						||
            $range['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)',
 | 
						||
                '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,
 | 
						||
            ];
 | 
						||
        })->keyBy('id');
 | 
						||
 | 
						||
        // 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,
 | 
						||
            'active' => [
 | 
						||
                'year'  => $range['start']->format('Y'),
 | 
						||
                'month' => $range['start']->format("F"),
 | 
						||
                'day' => $range['start']->format("d"),
 | 
						||
            ],
 | 
						||
            'calendars' => $calendar_map->map(function ($cal) {
 | 
						||
                return [
 | 
						||
                    'id' => $cal->id,
 | 
						||
                    'slug'  => $cal->slug,
 | 
						||
                    'name' => $cal->displayname,
 | 
						||
                    'color' => $cal->meta_color ?? $cal->calendarcolor ?? '#1a1a1a', // clean this up @todo
 | 
						||
                    'color_fg'  => $cal->meta_color_fg ?? '#ffffff', // clean this up
 | 
						||
                    'visible' => true, // default to visible; the UI can toggle this
 | 
						||
                ];
 | 
						||
            }),
 | 
						||
            'events'    => $events,   // keyed, one copy each
 | 
						||
            'grid'      => $grid,     // day objects hold only ID-sets
 | 
						||
        ];
 | 
						||
 | 
						||
        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
 | 
						||
     */
 | 
						||
 | 
						||
    /**
 | 
						||
     * 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
 | 
						||
    {
 | 
						||
        // index events by YYYY-MM-DD for quick lookup */
 | 
						||
        $events_by_day = [];
 | 
						||
        foreach ($events as $ev) {
 | 
						||
            $start = Carbon::parse($ev['start'])->tz($ev['timezone']);
 | 
						||
            $end   = $ev['end'] ? Carbon::parse($ev['end'])->tz($ev['timezone']) : $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'];
 | 
						||
            }
 | 
						||
        }
 | 
						||
 | 
						||
        // determine span of days for the selected view
 | 
						||
        switch ($view) {
 | 
						||
            case 'week':
 | 
						||
                $grid_start = $range['start']->copy();
 | 
						||
                $grid_end   = $range['start']->copy()->addDays(6);
 | 
						||
                break;
 | 
						||
 | 
						||
            case '4day':
 | 
						||
                $grid_start = $range['start']->copy();
 | 
						||
                $grid_end   = $range['start']->copy()->addDays(3);
 | 
						||
                break;
 | 
						||
 | 
						||
            default: /* month */
 | 
						||
                $grid_start = $range['start']->copy()->startOfWeek(); // Sunday start
 | 
						||
                $grid_end   = $range['end']->copy()->endOfWeek();
 | 
						||
        }
 | 
						||
 | 
						||
        // 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(Carbon::today()),
 | 
						||
                 'events'   => array_fill_keys($events_by_day[$iso] ?? [], true),
 | 
						||
             ];
 | 
						||
        }
 | 
						||
 | 
						||
        return $view === 'month'
 | 
						||
            ? ['days' => $days, 'weeks' => array_chunk($days, 7)]
 | 
						||
            : ['days' => $days];
 | 
						||
    }
 | 
						||
}
 |