kithkin/app/Http/Controllers/CalendarController.php

380 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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 shouldnt
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];
}
}