480 lines
17 KiB
PHP
480 lines
17 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 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 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];
|
||
}
|
||
|
||
/**
|
||
* 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];
|
||
}
|
||
}
|