kithkin/app/Http/Controllers/CalendarController.php

572 lines
20 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 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 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
*/
/**
* 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];
}
}