kithkin/app/Http/Controllers/CalendarController.php

480 lines
17 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;
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 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', []));
// 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('false 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)
->orderBy('ci.displayname')
->get();
// load the users remote/subscription calendars
$remotes = Subscription::query()
->join('calendar_meta as m', 'm.subscription_id', '=', 'calendarsubscriptions.id')
->where('principaluri', $principal)
->orderBy('displayname')
->select(
'calendarsubscriptions.id',
'calendarsubscriptions.displayname',
'calendarsubscriptions.calendarcolor',
'calendarsubscriptions.uri as slug',
DB::raw('NULL as timezone'),
'm.color as meta_color',
'm.color_fg as meta_color_fg',
DB::raw('true as is_remote')
)
->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 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,
'color' => $cal->meta_color ?? $cal->calendarcolor ?? '#1a1a1a',
'color_fg' => $cal->meta_color_fg ?? '#ffffff',
];
})->keyBy('id');
// 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'),
];
$mini = $this->buildMiniGrid($mini_start, $events);
// 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 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];
}
/**
* 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 = [];
foreach ($events as $ev) {
$s = Carbon::parse($ev['start']);
$e = $ev['end'] ? Carbon::parse($ev['end']) : $s;
for ($d = $s->copy()->startOfDay(); $d->lte($e); $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];
}
}