361 lines
12 KiB
PHP
361 lines
12 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;
|
||
|
||
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);
|
||
|
||
// load the user's calendars
|
||
$calendars = Calendar::query()
|
||
->select(
|
||
'calendars.id',
|
||
'ci.displayname',
|
||
'ci.calendarcolor',
|
||
'meta.color as meta_color'
|
||
)
|
||
->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();
|
||
|
||
// get all the events in one query
|
||
$events = Event::forCalendarsInRange(
|
||
$calendars->pluck('id'),
|
||
$range['start'],
|
||
$range['end']
|
||
);
|
||
|
||
// create the calendar grid of days
|
||
$grid = $this->buildCalendarGrid($view, $range, $events);
|
||
|
||
// format the data for the frontend
|
||
$payload = [
|
||
'view' => $view,
|
||
'range' => $range,
|
||
'calendars' => $calendars->keyBy('id')->map(function ($cal) {
|
||
return [
|
||
'id' => $cal->id,
|
||
'name' => $cal->displayname,
|
||
'color' => $cal->meta_color ?? $cal->calendarcolor ?? '#999',
|
||
'on' => true, // default to visible; the UI can toggle this
|
||
];
|
||
}),
|
||
'events' => $events->map(function ($e) { // just the events map
|
||
// fall back to Sabre timestamps if meta is missing
|
||
$start = $e->meta->start_at
|
||
?? Carbon::createFromTimestamp($e->firstoccurence);
|
||
$end = $e->meta->end_at
|
||
?? ($e->lastoccurence ? Carbon::createFromTimestamp($e->lastoccurence) : null);
|
||
|
||
return [
|
||
'id' => $e->id,
|
||
'calendar_id' => $e->calendarid,
|
||
'title' => $e->meta->title ?? '(no title)',
|
||
'start' => $start->format('c'),
|
||
'end' => optional($end)->format('c'),
|
||
];
|
||
}),
|
||
'grid' => $grid,
|
||
];
|
||
|
||
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'] ?? null,
|
||
'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'] ?? null]
|
||
);
|
||
|
||
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 */
|
||
$eventsByDay = [];
|
||
foreach ($events as $ev) {
|
||
$start = $ev->meta->start_at
|
||
?? Carbon::createFromTimestamp($ev->firstoccurence);
|
||
$end = $ev->meta->end_at
|
||
?? ($ev->lastoccurence
|
||
? Carbon::createFromTimestamp($ev->lastoccurence)
|
||
: $start);
|
||
|
||
// spread multi-day events across each day they touch
|
||
for ($d = $start->copy()->startOfDay();
|
||
$d->lte($end->copy()->endOfDay());
|
||
$d->addDay()) {
|
||
|
||
$key = $d->toDateString(); // e.g. '2025-07-14'
|
||
$eventsByDay[$key] ??= [];
|
||
$eventsByDay[$key][] = [
|
||
'id' => $ev->id,
|
||
'calendar_id' => $ev->calendarid,
|
||
'title' => $ev->meta->title ?? '(no title)',
|
||
'start' => $start->format('c'),
|
||
'end' => $end->format('c'),
|
||
];
|
||
}
|
||
}
|
||
|
||
// determine which individual days belong to this view */
|
||
switch ($view) {
|
||
case 'week':
|
||
$gridStart = $range['start']->copy();
|
||
$gridEnd = $range['start']->copy()->addDays(6);
|
||
break;
|
||
|
||
case '4day':
|
||
$gridStart = $range['start']->copy();
|
||
$gridEnd = $range['start']->copy()->addDays(3);
|
||
break;
|
||
|
||
default: // month
|
||
$gridStart = $range['start']->copy()->startOfWeek(); // Sunday-start; tweak if needed
|
||
$gridEnd = $range['end']->copy()->endOfWeek();
|
||
}
|
||
|
||
// walk the span, build the day objects */
|
||
$days = [];
|
||
for ($day = $gridStart->copy(); $day->lte($gridEnd); $day->addDay()) {
|
||
$iso = $day->toDateString();
|
||
$isToday = $day->isSameDay(Carbon::today());
|
||
$days[] = [
|
||
'date' => $iso,
|
||
'label' => $day->format('j'),
|
||
'in_month' => $day->month === $range['start']->month,
|
||
'is_today' => $isToday,
|
||
'events' => $eventsByDay[$iso] ?? [],
|
||
];
|
||
}
|
||
|
||
// for a month view, also group into weeks
|
||
if ($view === 'month') {
|
||
$weeks = array_chunk($days, 7); // 7 days per week row
|
||
return ['days' => $days, 'weeks' => $weeks];
|
||
}
|
||
|
||
return ['days' => $days];
|
||
}
|
||
}
|