diff --git a/app/Http/Controllers/CalendarController.php b/app/Http/Controllers/CalendarController.php index ceb3f34..dbdc7ad 100644 --- a/app/Http/Controllers/CalendarController.php +++ b/app/Http/Controllers/CalendarController.php @@ -17,11 +17,13 @@ use App\Services\Calendar\CreateCalendar; class CalendarController extends Controller { + private const VIEWS = ['day', 'week', 'month', 'four']; + /** * Consolidated calendar dashboard. * * Query params: - * view = month | week | 4day (default: month) + * view = month | week | four (default: month) * date = Y-m-d anchor date (default: today, in user TZ) * * The view receives a `$payload` array: @@ -32,77 +34,78 @@ class CalendarController extends Controller */ public function index(Request $request) { + /** + * + * manage parameters and core variables + */ + // set the calendar key $principal = auth()->user()->principal_uri; + // user settings + $user = $request->user(); + $tz = $user->timezone ?? config('app.timezone'); + $timeFormat = $user->getSetting('app.time_format', '12'); + + // settings + $defaultView = $user->getSetting('calendar.last_view', 'month'); + $defaultDate = $user->getSetting('calendar.last_date', Carbon::today($tz)->toDateString()); + $defaultDensity = (int) $user->getSetting('calendar.last_density', 30); + + // week start preference + $weekStartPref = $user->getSetting('calendar.week_start', 'sunday'); // 'sunday'|'monday' + $weekStartPref = in_array($weekStartPref, ['sunday', 'monday'], true) + ? $weekStartPref + : 'sunday'; + $weekStart = $weekStartPref === 'monday' ? Carbon::MONDAY : Carbon::SUNDAY; + $weekEnd = (int) (($weekStart + 6) % 7); + // get the view and time range - [$view, $range] = $this->resolveRange($request); + [$view, $range] = $this->resolveRange($request, $tz, $weekStart, $weekEnd, $defaultView, $defaultDate); + $today = Carbon::today($tz)->toDateString(); - // date range span - $span = $this->gridSpan($view, $range); + // get the display density, if present (in minutes for each step) + $stepMinutes = (int) $request->query('density', $defaultDensity); + if (! in_array($stepMinutes, [15, 30, 60], true)) { // lock it down + $stepMinutes = 30; + } + $labelEvery = match ($stepMinutes) { // how many 15-min slots per label/row + 15 => 1, + 30 => 2, + 60 => 4, + }; - // date range controls - $prev = $range['start']->copy()->subMonth()->startOfMonth()->toDateString(); - $next = $range['start']->copy()->addMonth()->startOfMonth()->toDateString(); - $today = Carbon::today()->toDateString(); + // date range span and controls + $span = $this->gridSpan($view, $range, $weekStart, $weekEnd); + $nav = $this->navDates($view, $range['start'], $tz); // get the user's visible calendars from the left bar $visible = collect($request->query('c', [])); + // keep a stable anchor date for forms that aren't the nav buttons + $anchorDate = $request->query('date', now($tz)->toDateString()); + + // persist settings + if ($request->hasAny(['view', 'date', 'density'])) { + $user->setSetting('calendar.last_view', $view); + $user->setSetting('calendar.last_date', $range['start']->toDateString()); + $user->setSetting('calendar.last_density', (string) $stepMinutes); + } + /** * * 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') + $calendars = Calendar::query() + ->dashboardForPrincipal($principal) ->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) { + $calendars = $calendars->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'); /** @@ -115,7 +118,7 @@ class CalendarController extends Controller $calendars->pluck('id'), $span['start'], $span['end'] - )->map(function ($e) use ($calendar_map) { + )->map(function ($e) use ($calendar_map, $timeFormat, $view, $range, $tz, $weekStart, $weekEnd) { // event's calendar $cal = $calendar_map[$e->calendarid]; @@ -126,11 +129,27 @@ class CalendarController extends Controller $end_utc = $e->meta->end_at ?? ($e->lastoccurence ? Carbon::createFromTimestamp($e->lastoccurence) : null); + // time format handling + $uiFormat = $timeFormat === '24' ? 'H:i' : 'g:ia'; + // 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); + // convert utc to user tz for grid placement (columns/rows must match view headers) + $start_for_grid = $start_utc->copy()->tz($tz); + $end_for_grid = optional($end_utc)->copy()->tz($tz); + + // placement for time-based layouts + $placement = $this->slotPlacement( + $start_for_grid, + $end_for_grid, + $range['start']->copy()->tz($tz), + $view, + 15 + ); + // color handling $color = $cal['meta_color'] ?? $cal['calendarcolor'] @@ -138,8 +157,17 @@ class CalendarController extends Controller $colorFg = $cal['meta_color_fg'] ?? contrast_text_color($color); + logger()->info('event times', [ + 'id' => $e->id, + 'start_at' => optional($e->meta)->start_at, + 'end_at' => optional($e->meta)->end_at, + 'firstoccurence' => $e->firstoccurence, + 'lastoccurence' => $e->lastoccurence, + ]); + // return events array return [ + // core data 'id' => $e->id, 'calendar_id' => $e->calendarid, 'calendar_slug' => $cal->slug, @@ -147,12 +175,19 @@ class CalendarController extends Controller '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'), + 'start_ui' => $start_local->format($uiFormat), + 'end_ui' => optional($end_local)->format($uiFormat), 'timezone' => $timezone, 'visible' => $cal->visible, 'color' => $color, 'color_fg' => $colorFg, + // slot placement for time-based grid + 'start_row' => $placement['start_row'], + 'end_row' => $placement['end_row'], + 'row_span' => $placement['row_span'], + 'start_col' => $placement['start_col'], + 'duration' => $placement['duration'], + ]; })->keyBy('id'); @@ -163,17 +198,22 @@ class CalendarController extends Controller // 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(); + + // anchor is a DATE string, so create it explicitly in the user tz + $mini_start = Carbon::createFromFormat('Y-m-d', $mini_anchor, $tz) + ->startOfMonth(); + $mini_nav = [ 'prev' => $mini_start->copy()->subMonth()->toDateString(), 'next' => $mini_start->copy()->addMonth()->toDateString(), - 'today' => Carbon::today()->startOfMonth()->toDateString(), + 'today' => Carbon::today($tz)->startOfMonth()->toDateString(), 'label' => $mini_start->format('F Y'), ]; + $mini_headers = $this->weekdayHeaders($tz, $weekStart); // 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); + $mini_grid_start = $mini_start->copy()->startOfWeek($weekStart); + $mini_grid_end = $mini_start->copy()->endOfMonth()->endOfWeek($weekEnd); if ($mini_grid_start->diffInDays($mini_grid_end) + 1 < 42) { $mini_grid_end->addWeek(); } @@ -183,14 +223,12 @@ class CalendarController extends Controller $calendars->pluck('id'), $mini_grid_start, $mini_grid_end - )->map(function ($e) use ($calendar_map) { + )->map(function ($e) use ($calendar_map, $tz) { $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'); - $color = $cal->meta_color ?? $cal->calendarcolor ?? default_calendar_color(); @@ -213,7 +251,7 @@ class CalendarController extends Controller })->keyBy('id'); // now build the mini from mini_events (not from $events) - $mini = $this->buildMiniGrid($mini_start, $mini_events); + $mini = $this->buildMiniGrid($mini_start, $mini_events, $tz, $weekStart, $weekEnd); /** * @@ -221,23 +259,25 @@ class CalendarController extends Controller */ // create the calendar grid of days - $grid = $this->buildCalendarGrid($view, $range, $events); + $grid = $this->buildCalendarGrid($view, $range, $events, $tz, $weekStart, $weekEnd); + + // get the title + $header = $this->headerTitle($view, $range['start'], $range['end']); // 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, + 'view' => $view, + 'range' => $range, + 'nav' => $nav, + 'header' => $header, + 'active' => [ + 'date' => $range['start']->toDateString(), + 'year' => $range['start']->format('Y'), + 'month' => $range['start']->format("F"), + 'day' => $range['start']->format("d"), ], - 'active' => [ - 'year' => $range['start']->format('Y'), - 'month' => $range['start']->format("F"), - 'day' => $range['start']->format("d"), - ], - 'calendars' => $calendars->mapWithKeys(function ($cal) + 'week_start' => $weekStart, + 'calendars' => $calendars->mapWithKeys(function ($cal) { // compute colors $color = $cal->meta_color @@ -258,10 +298,31 @@ class CalendarController extends Controller ], ]; }), - '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 + 'hgroup' => $this->viewHeaders($view, $range, $tz, $weekStart), + '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 + 'mini_headers' => $mini_headers, + ]; + + // time-based payload values + $timeBased = in_array($view, ['day', 'week', 'four'], true); + if ($timeBased) + { + // create the time gutter if we're in a time-based view + $payload['slots'] = $this->timeSlots($range['start'], $tz, $timeFormat); + $payload['time_format'] = $timeFormat; // optional, if the blade cares + + // add the now indicator + $payload['now'] = $this->nowIndicator($view, $range, $tz, 15); + } + + // send the density array always, even though it doesn't matter for month + $payload['density'] = [ + 'step' => $stepMinutes, // 15|30|60 + 'label_every' => $labelEvery, // 1|2|4 + 'anchor' => $anchorDate, ]; return view('calendar.index', $payload); @@ -375,7 +436,9 @@ class CalendarController extends Controller } /** - * delete calendar @todo + * + * Delete calendar + * @todo */ public function destroy(Calendar $calendar) { @@ -390,54 +453,91 @@ class CalendarController extends Controller */ /** - * Span actually rendered by the grid. - * Month → startOfMonth->startOfWeek .. endOfMonth->endOfWeek + * + * Prepare nav dates for the movement buttons in the calendar header + * + * @return array + * [ + * 'prev' => 2026-08-19 + * 'next' => 2026-08-21 + * 'today' => 2026-08-20 + * ] */ - private function gridSpan(string $view, array $range): array + private function navDates(string $view, Carbon $start, string $tz): array { - switch ($view) { - case 'week': - $start = $range['start']->copy(); // resolveRange already did startOfWeek - $end = $range['start']->copy()->addDays(6); - break; + // always compute in the user tz so the UX is consistent + $start = $start->copy()->tz($tz); - 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]; + return match ($view) + { + 'day' => [ + 'prev' => $start->copy()->subDay()->toDateString(), + 'next' => $start->copy()->addDay()->toDateString(), + 'today' => Carbon::today($tz)->toDateString(), + ], + 'week' => [ + 'prev' => $start->copy()->subWeek()->toDateString(), + 'next' => $start->copy()->addWeek()->toDateString(), + 'today' => Carbon::today($tz)->toDateString(), + ], + 'four' => [ + 'prev' => $start->copy()->subDays(4)->toDateString(), + 'next' => $start->copy()->addDays(4)->toDateString(), + 'today' => Carbon::today($tz)->toDateString(), + ], + default => [ // month + 'prev' => $start->copy()->subMonth()->startOfMonth()->toDateString(), + 'next' => $start->copy()->addMonth()->startOfMonth()->toDateString(), + 'today' => Carbon::today($tz)->toDateString(), + ], + }; } /** - * normalise $view and $date into a carbon range * - * @return array [$view, ['start' => Carbon, 'end' => Carbon]] + * Interpret $view and $date filters and normalize into a Carbon range + * + * @return array + * [ + * $view, + * [ + * 'start' => Carbon, + * 'end' => Carbon + * ] + * ] */ - private function resolveRange(Request $request): array - { + private function resolveRange( + Request $request, + string $tz, + int $weekStart, + int $weekEnd, + string $defaultView, + string $defaultDate + ): array { // get the view - $view = in_array($request->query('view'), ['week', '4day']) - ? $request->query('view') - : 'month'; + $requestView = $request->query('view', $defaultView); + $view = in_array($requestView, self::VIEWS, true) + ? $requestView + : 'month'; + $date = $request->query('date', $defaultDate); // anchor date in the user's timezone - $anchor = Carbon::parse($request->query('date', now()->toDateString())) - ->setTimezone(auth()->user()->timezone ?? config('app.timezone')); + $anchor = Carbon::createFromFormat('Y-m-d', $date, $tz)->startOfDay(); // set dates based on view - switch ($view) { - case 'week': - $start = $anchor->copy()->startOfWeek(); - $end = $anchor->copy()->endOfWeek(); + switch ($view) + { + case 'day': + $start = $anchor->copy()->startOfDay(); + $end = $anchor->copy()->endOfDay(); break; - case '4day': + case 'week': + $start = $anchor->copy()->startOfWeek($weekStart); + $end = $anchor->copy()->endOfWeek($weekEnd); + break; + + case 'four': // a rolling 4-day "agenda" view starting at anchor $start = $anchor->copy()->startOfDay(); $end = $anchor->copy()->addDays(3)->endOfDay(); @@ -452,6 +552,183 @@ class CalendarController extends Controller } /** + * Create the grid for each view with pre- and post-padding if needed + * + * This is different from resolveRange() as the rendered span != logical range + * + * Month: start of the month's first week to end of the last week + * Week: Sunday to Saturday + * Four: selected day to 3 days later + * Day: start of the day to the end of the day + */ + private function gridSpan(string $view, array $range, int $weekStart, int $weekEnd): array + { + switch ($view) + { + case 'day': + $start = $range['start']->copy()->startOfDay(); + $end = $range['start']->copy()->endOfDay(); + break; + + case 'week': + $start = $range['start']->copy()->startOfWeek($weekStart); + $end = $range['start']->copy()->endOfWeek($weekEnd); + break; + + case 'four': + $start = $range['start']->copy()->startOfDay(); + $end = $range['start']->copy()->addDays(3); + break; + + default: // month + $start = $range['start']->copy()->startOfMonth()->startOfWeek($weekStart); + $end = $range['end']->copy()->endOfMonth()->endOfWeek($weekEnd); + } + + return ['start' => $start, 'end' => $end]; + } + + /** + * + * Prepare the calendar title based on the view + * + * @return array + * [ + * 'strong' => 'August 20', + * 'span' => '2026', + * ] + */ + private function headerTitle(string $view, Carbon $start, Carbon $end): array + { + $sameDay = $start->isSameDay($end); + $sameMonth = $start->isSameMonth($end); + $sameYear = $start->isSameYear($end); + + // month default + $strong = $start->format('F'); + $span = $start->format('Y'); + + if ($view === 'day' || $sameDay) { + return [ + 'strong' => $start->format('F j'), + 'span' => $start->format('Y'), + ]; + } + + if (in_array($view, ['week', 'four'], true)) { + if ($sameMonth && $sameYear) { + return [ + 'strong' => $start->format('F j') . ' to ' . $end->format('j'), + 'span' => $start->format('Y'), + ]; + } + + if ($sameYear) { + return [ + 'strong' => $start->format('F') . ' to ' . $end->format('F'), + 'span' => $start->format('Y'), + ]; + } + + return [ + 'strong' => $start->format('F Y') . ' to ' . $end->format('F Y'), + 'span' => null, + ]; + } + + return ['strong' => $strong, 'span' => $span]; + } + + /** + * + * Create the time gutter for time-based views + */ + private function timeSlots(Carbon $dayStart, string $tz, string $timeFormat): array + { + $minutesPerSlot = 15; + $slotsPerDay = intdiv(24 * 60, $minutesPerSlot); // 96 + + $format = $timeFormat === '24' ? 'H:i' : 'g:i a'; + + $slots = []; + $t = $dayStart->copy()->tz($tz)->startOfDay(); + + for ($i = 0; $i < $slotsPerDay; $i++) { + $slots[] = [ + 'iso' => $t->toIso8601String(), + 'label' => $t->format($format), + 'key' => $t->format('H:i'), // stable "machine" value + 'index' => $i, // 0..95 + 'minutes' => $i * $minutesPerSlot, + 'duration' => $minutesPerSlot, // handy for styling math + ]; + + $t->addMinutes($minutesPerSlot); + } + + return $slots; + } + + /** + * + * Time-based layout slot placement + * + * Placements object: + * [ + * 'start_line' => '24', + * 'end_line' => '32', + * 'span' => '4', + * 'duration' => '60', + * ] + **/ + private function slotPlacement( + Carbon $startLocal, + ?Carbon $endLocal, + Carbon $rangeStart, + string $view, + int $minutesPerSlot = 15 + ): array + { + $start = $startLocal->copy(); + $end = ($endLocal ?? $startLocal)->copy(); + + // get the real duration in minutes + $durationMinutes = max(0, $start->diffInMinutes($end, false)); + + // duration for display purposes + $displayMinutes = $durationMinutes > 0 ? $durationMinutes : $minutesPerSlot; + + // row placement (96 rows when minutesPerSlot=15) + $startMinutesFromMidnight = ($start->hour * 60) + $start->minute; + $startRow = intdiv($startMinutesFromMidnight, $minutesPerSlot) + 1; + + $rowSpan = max(1, (int) ceil($displayMinutes / $minutesPerSlot)); + $endRow = $startRow + $rowSpan; + + // column placement + $maxCols = match ($view) { + 'day' => 1, + 'four' => 4, + 'week' => 7, + default => 1, // month won’t use this + }; + // rangeStart is already the "first day column" for week/four/day + $startCol = $rangeStart->copy()->startOfDay()->diffInDays($start->copy()->startOfDay()) + 1; + + // clamp to view columns + $startCol = max(1, min($maxCols, $startCol)); + + return [ + 'start_row' => $startRow, + 'end_row' => $endRow, + 'row_span' => $rowSpan, + 'duration' => $durationMinutes, + 'start_col' => $startCol, + ]; + } + + /** + * * Assemble an array of day-objects for the requested view. * * Day object shape: @@ -465,26 +742,28 @@ class CalendarController extends Controller * 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 + private function buildCalendarGrid( + string $view, + array $range, + Collection $events, + string $tz, + int $weekStart, + int $weekEnd): 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); + ['start' => $grid_start, 'end' => $grid_end] = $this->gridSpan($view, $range, $weekStart, $weekEnd); // today checks - $tz = auth()->user()->timezone ?? config('app.timezone', 'UTC'); - $today = \Carbon\Carbon::today($tz); + $today = Carbon::today($tz)->toDateString(); // index events by YYYY-MM-DD for quick lookup $events_by_day = []; - foreach ($events as $ev) { + 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; + $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()) { @@ -514,7 +793,67 @@ class CalendarController extends Controller } /** - * Build the mini-month grid for day buttons + * + * Create the specific view's date headers + * + * For day/week/four, this is an array of number dates and their day name, + * for month it's the user's preferred weekdays + */ + private function viewHeaders(string $view, array $range, string $tz, int $weekStart): array + { + $start = $range['start']->copy()->tz($tz); + $end = $range['end']->copy()->tz($tz); + $today = Carbon::today($tz)->toDateString(); + + // month view: weekday headers (Sunday..Saturday for now) + if ($view === 'month') { + return collect($this->weekdayHeaders($tz, $weekStart)) + ->map(fn ($h) => $h + ['is_today' => false]) + ->all(); + } + + // day/week/four: column headers for each day in range + $headers = []; + for ($d = $start->copy()->startOfDay(); $d->lte($end); $d->addDay()) { + $date = $d->toDateString(); + $headers[] = [ + 'date' => $d->toDateString(), // 2026-01-31 + 'day' => $d->format('j'), // 31 + 'dow' => $d->translatedFormat('l'), // Saturday (localized) + 'dow_short' => $d->translatedFormat('D'), // Sat (localized) + 'month' => $d->translatedFormat('M'), // Jan (localized) + 'is_today' => $date === $today, // flag for viewing real today + ]; + } + + return $headers; + } + + /** + * + * Specific headers for month views (full and mini) + */ + private function weekdayHeaders(string $tz, int $weekStart): array + { + $headers = []; + $d = Carbon::now($tz)->startOfWeek($weekStart); + + for ($i = 0; $i < 7; $i++) { + $headers[] = [ + // stable key (0..6 from the start day) + 'key' => $i, + // Sun, Mon... + 'label' => $d->translatedFormat('D'), + ]; + $d->addDay(); + } + + return $headers; + } + + /** + * + * Build the mini-month grid for day buttons in the bottom left of the UI * * Returns ['days' => [ * [ @@ -525,34 +864,36 @@ class CalendarController extends Controller * ], … * ]] */ - private function buildMiniGrid(Carbon $monthStart, Collection $events): array + private function buildMiniGrid( + Carbon $monthStart, + Collection $events, + string $tz, + int $weekStart, + int $weekEnd): array { - // get bounds - $monthEnd = $monthStart->copy()->endOfMonth(); - $gridStart = $monthStart->copy()->startOfWeek(Carbon::MONDAY); - $gridEnd = $monthEnd->copy()->endOfWeek(Carbon::SUNDAY); + $monthStart = $monthStart->copy()->tz($tz); + $monthEnd = $monthStart->copy()->endOfMonth(); + + $gridStart = $monthStart->copy()->startOfWeek($weekStart); + $gridEnd = $monthEnd->copy()->endOfWeek($weekEnd); - // 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 */ + $today = Carbon::today($tz)->toDateString(); + + // map event ids by yyyy-mm-dd in USER tz (so indicators match what user sees) $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; + $s = Carbon::parse($ev['start'])->tz($tz); + $e = $ev['end'] ? Carbon::parse($ev['end'])->tz($tz) : $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(); @@ -560,11 +901,64 @@ class CalendarController extends Controller 'date' => $iso, 'label' => $d->format('j'), 'in_month' => $d->between($monthStart, $monthEnd), + 'is_today' => $iso === $today, 'events' => $byDay[$iso] ?? [], ]; } - // will always be 42 to ensure 6 rows return ['days' => $days]; } + + /** + * Place the "now" indicator on the grid if we're in a time-based view and on the current day + * + * Returns: + * [ + * 'show' => bool, + * 'row' => int, // 1..96 (15-min grid line) + * 'col_start' => int, // grid column start + * 'col_end' => int, // grid column end + * ] + */ + private function nowIndicator(string $view, array $range, string $tz, int $minutesPerSlot = 15, int $gutterCols = 1): array + { + // only meaningful for time-based views + if (!in_array($view, ['day', 'week', 'four'], true)) { + return ['show' => false, 'row' => 1, 'day_col' => 1, 'col_start' => 1, 'col_end' => 2]; + } + + $now = Carbon::now($tz); + + $start = $range['start']->copy()->tz($tz)->startOfDay(); + $end = $range['end']->copy()->tz($tz)->endOfDay(); + + // show only if "now" is inside the visible range + if (! $now->betweenIncluded($start, $end)) { + return ['show' => false, 'row' => 1, 'day_col' => 1, 'col_start' => 1, 'col_end' => 2]; + } + + // row: minutes since midnight, snapped down to slot size + $minutes = ($now->hour * 60) + $now->minute; + $snapped = intdiv($minutes, $minutesPerSlot) * $minutesPerSlot; + $row = intdiv($snapped, $minutesPerSlot) + 1; // 1-based + + // column: 1..N where 1 is the first day column in the events grid + if ($view === 'day') { + $dayCol = 1; + } else { + // IMPORTANT: compare dates at midnight to avoid fractional diffs + $todayStart = $now->copy()->startOfDay(); + $dayCol = $start->diffInDays($todayStart) + 1; // 1..7 or 1..4 + } + + $dayCol = (int) $dayCol; + + return [ + 'show' => true, + 'row' => (int) $row, + 'day_col' => $dayCol, + 'col_start' => $dayCol, + 'col_end' => $dayCol + 1, + ]; + } } diff --git a/app/Http/Controllers/CalendarSettingsController.php b/app/Http/Controllers/CalendarSettingsController.php index 326557e..3ec2c87 100644 --- a/app/Http/Controllers/CalendarSettingsController.php +++ b/app/Http/Controllers/CalendarSettingsController.php @@ -17,7 +17,7 @@ class CalendarSettingsController extends Controller /* landing page shows the first settings pane */ public function index() { - return redirect()->route('calendar.settings.create'); + return redirect()->route('calendar.settings.display'); } /** @@ -66,12 +66,46 @@ class CalendarSettingsController extends Controller ]); } + /** + * + * Display preferences + */ + public function displayForm(Request $request) + { + $user = $request->user(); + + $data = [ + 'title' => __('calendar.settings.display.title'), + 'sub' => __('calendar.settings.display.subtitle'), + 'defaults' => [ + // store as: 'sunday' | 'monday' + 'week_start' => $user->getSetting('calendar.week_start', 'monday'), + ], + ]; + + return $this->frame('calendar.settings.display', $data); + } + + public function displayStore(Request $request): RedirectResponse + { + $data = $request->validate([ + 'week_start' => ['required', 'in:sunday,monday'], + ]); + + $user = $request->user(); + $user->setSetting('calendar.week_start', $data['week_start']); + + return Redirect::route('calendar.settings.display') + ->with('toast', [ + 'message' => __('calendar.settings.saved'), + 'type' => 'success', + ]); + } /** + * * Subscribe - **/ - - /* show “Subscribe to a calendar” form */ + */ public function subscribeForm() { return $this->frame( @@ -82,7 +116,6 @@ class CalendarSettingsController extends Controller ]); } - /* handle POST from the subscribe form */ public function subscribeStore(Request $request) { $data = $request->validate([ @@ -109,7 +142,8 @@ class CalendarSettingsController extends Controller /** - * individual calendar settings + * + * Individual calendar settings */ public function calendarForm(Request $request, string $calendarUri) { @@ -199,7 +233,8 @@ class CalendarSettingsController extends Controller /** - * content frame handler + * + * Content frame handler */ private function frame(?string $view = null, array $data = []) { diff --git a/app/Models/Calendar.php b/app/Models/Calendar.php index bdae5cf..785c4f5 100644 --- a/app/Models/Calendar.php +++ b/app/Models/Calendar.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Auth; class Calendar extends Model @@ -50,6 +51,29 @@ class Calendar extends Model } /** + * + * build all calendar data for calendar display + */ + public function scopeDashboardForPrincipal(Builder $q, string $principal): Builder + { + return $q->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('COALESCE(meta.is_remote, 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) + ->orderBy('ci.displayname'); + } + + /** + * * inbound urls * convert "/calendar/{slug}" into the correct calendar instance (uri column) * diff --git a/lang/en/calendar.php b/lang/en/calendar.php index 8f4a9c6..a9051ed 100644 --- a/lang/en/calendar.php +++ b/lang/en/calendar.php @@ -30,6 +30,10 @@ return [ 'title' => 'Create a calendar', 'subtitle' => 'Create a new local calendar.', ], + 'display' => [ + 'title' => 'Display preferences', + 'subtitle' => 'Adjust the look and feel of your calendars.' + ], 'language_region' => [ 'title' => 'Language and region', 'subtitle' => 'Choose your default language, region, and formatting preferences. These affect how dates and times are displayed in your calendars and events.', diff --git a/resources/css/etc/layout.css b/resources/css/etc/layout.css index 6006c2f..8cd949f 100644 --- a/resources/css/etc/layout.css +++ b/resources/css/etc/layout.css @@ -118,7 +118,7 @@ main { /* left column */ aside { - @apply flex flex-col col-span-1 pb-8 h-16 overflow-hidden rounded-l-lg; + @apply flex flex-col col-span-1 pb-6 2xl:pb-8 h-16 overflow-hidden rounded-l-lg; > h1 { @apply flex items-center h-16 min-h-16 px-6 2xl:px-8; @@ -185,21 +185,22 @@ main { @apply bg-white grid grid-cols-1 ml-2 rounded-md; @apply overflow-y-auto; grid-template-rows: 5rem auto; + container: content / inline-size; /* main content title and actions */ > header { @apply flex flex-row items-center justify-between w-full; - @apply bg-white sticky top-0; + @apply bg-white sticky top-0 z-10; /* app hedar; if h1 exists it means there's no aside, so force the width from that */ h1 { @apply relative flex items-center pl-6 2xl:pl-8; - width: minmax(20rem, 20dvw); + width: minmax(16rem, 20dvw); } /* actual page header */ h2 { - @apply flex flex-row gap-1 items-center justify-start relative top-2px; + @apply flex flex-row gap-2 items-center justify-start relative top-2px; animation: title-drop 350ms ease-out both; > span { @@ -309,7 +310,7 @@ main { main { &:has(aside) { - grid-template-columns: minmax(20rem, 20dvw) auto; + grid-template-columns: minmax(16rem, 20dvw) auto; grid-template-rows: 1fr; } diff --git a/resources/css/etc/theme.css b/resources/css/etc/theme.css index 8923699..8c84679 100644 --- a/resources/css/etc/theme.css +++ b/resources/css/etc/theme.css @@ -3,6 +3,7 @@ --font-serif: Chewie, ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif; --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; + --color-gray-10: #fafafa; --color-gray-50: #f6f6f6; --color-gray-100: #eeeeee; --color-gray-200: #dddddd; diff --git a/resources/css/lib/button.css b/resources/css/lib/button.css index f39d66c..615ab31 100644 --- a/resources/css/lib/button.css +++ b/resources/css/lib/button.css @@ -101,7 +101,7 @@ button, &:has(input:checked) {} - &:first-child { + &:first-of-type { @apply rounded-l-md border-l-md; &:has(input:checked), @@ -119,7 +119,7 @@ button, } } - &:last-child { + &:last-of-type { @apply border-r-md rounded-r-md; &:has(input:checked), @@ -129,18 +129,19 @@ button, } } - button:active + button { - - } - > label { > input[type="radio"] { @apply hidden absolute top-0 left-0 w-0 h-0 max-w-0 max-h-0; } } - &:has(> :last-child input:checked), - &:has(> :last-child:active) { + &:has(> label:last-of-type input:checked), + &:has(> label:last-of-type:active) { box-shadow: 1.5px 4.5px 0 -2px var(--color-primary); } + + /* small */ + &.button-group--sm { + @apply h-9 max-h-9 text-sm; + } } diff --git a/resources/css/lib/calendar.css b/resources/css/lib/calendar.css index bc92fb7..098f4ec 100644 --- a/resources/css/lib/calendar.css +++ b/resources/css/lib/calendar.css @@ -1,4 +1,7 @@ -.calendar { +/** + * month view + **/ +.calendar.month { @apply grid col-span-3 pb-6 2xl:pb-8 pt-2; grid-template-rows: 2rem 1fr; @@ -73,8 +76,245 @@ } } -/* calendar list in the left bar */ +/** + * time-based views + */ +.calendar.time { + @apply grid; + grid-template-columns: 6rem auto; + grid-template-rows: 4.5rem auto 5rem; + --row-height: 2.5rem; + --now-row: 1; + --now-col-start: 1; + --now-col-end: 2; + + /* top day bar */ + hgroup { + @apply bg-white col-span-2 border-b-2 border-primary pl-24 sticky z-10; + top: 5.5rem; + + span.name { + @apply font-semibold uppercase text-sm; + } + + a.number { + @apply flex items-center justify-center text-xl h-10 w-10 rounded-full bg-gray-100 font-semibold; + aspect-ratio: 1 / 1; + + &:hover { + @apply bg-gray-200; + } + } + + div.day-header { + @apply relative flex flex-col gap-2px justify-start items-start; + animation: header-slide 250ms ease-in; + + &:not(:last-of-type)::after { + @apply block w-px bg-gray-200 absolute -right-2 top-18; + content: ''; + height: calc(100dvh - 16rem); + } + + &.active { + a.number { + @apply bg-teal-500 text-white; + + &:hover { + @apply bg-teal-600; + } + } + } + } + } + + /* time column */ + ol.time { + @apply grid z-0 pt-4; + grid-template-rows: repeat(96, var(--row-height)); + + time { + @apply relative flex items-center justify-end items-start pr-4; + @apply text-xs text-secondary font-mono; + + &::after { + @apply block absolute h-px bg-gray-200; + width: calc(100cqw - 6rem); + content: ''; + top: 0.6rem; + left: 6rem; + } + } + } + + /* event positioning */ + ol.events { + @apply grid pt-4; + grid-template-rows: repeat(96, var(--row-height)); + --event-col: 0; + --event-row: 0; + --event-end: 4; + --event-bg: var(--color-gray-100); + --event-fg: var(--color-primary); + + li.event { + @apply flex rounded-md relative; + background-color: var(--event-bg); + color: var(--event-fg); + grid-row-start: var(--event-row); + grid-row-end: var(--event-end); + grid-column-start: var(--event-col); + grid-column-end: calc(var(--event-col) + 1); + top: 0.6rem; + transition: translate 100ms ease-in; + + > a { + @apply flex flex-col grow px-3 py-2 gap-2px; + + > span { + @apply font-semibold leading-none break-all; + } + + > time { + @apply text-sm; + } + } + + &:hover { + @apply -translate-y-2px; + } + } + } + + /* bottom controls */ + footer { + @apply bg-white flex items-end justify-end col-span-2 border-t-md border-primary z-10; + @apply sticky bottom-0 pb-8; + } + + /* now indicator */ + .now-indicator { + @apply relative pointer-events-none z-2 border-t-3 border-red-600 opacity-90 -ml-2; + grid-row: var(--now-row); + grid-column: var(--now-col-start) / var(--now-col-end); + width: calc(100% + 1rem); + top: 0.6rem; + + &::before { + @apply block w-3 h-3 rounded-full bg-red-600 -translate-y-1/2 -mt-[1.5px]; + content: ""; + } + } +} + +/* step handling */ +.calendar.time[data-density="30"] { + --row-height: 2rem; + + ol.time li:nth-child(2n) { + visibility: hidden; /* preserves space + row alignment */ + } +} +.calendar.time[data-density="60"] { + --row-height: 1.25rem; + + ol.time li:not(:nth-child(4n + 1)) { + visibility: hidden; /* preserves space + row alignment */ + } +} + +/** + * day view + */ +.calendar.day { + container: day / inline-size; + + hgroup { + @apply flex items-start justify-start; + } +} + +/** + * week view + */ +.calendar.week { + container: week / inline-size; + --days: 7; + + hgroup { + @apply grid gap-x-2; + grid-template-columns: repeat(7, 1fr); + } + + ol.events { + @apply gap-x-2; + grid-template-columns: repeat(7, 1fr); + --col: calc(100% / var(--days)); + + /* draw a 1px line at the start of each column repeat + highlight weekends */ + /* need to factor in weekends on/off */ + background-image: + linear-gradient( + to right, + var(--color-gray-10) var(--col), + transparent var(--col), + transparent calc((var(--col) * 6) + 5px), + var(--color-gray-10) calc((var(--col) * 6) + 5px) + ); + background-position: 0; + background-size: 100%; + background-repeat: no-repeat; + } + + &[data-weekstart="0"] { + ol.events { + background-image: + linear-gradient( + to right, + transparent, + transparent calc((var(--col) * 5) + 5px), + var(--color-gray-10) calc((var(--col) * 5) + 5px) + ); + } + } +} + +/** + * four-day view + */ +.calendar.four { + container: four / inline-size; + --days: 4; + + hgroup { + @apply grid gap-x-2; + grid-template-columns: repeat(var(--days), 1fr); + } + + ol.events { + @apply gap-x-2; + grid-template-columns: repeat(var(--days), 1fr); + --col: calc(100% / var(--days)); + + background-image: + repeating-linear-gradient( + to right, + transparent, + transparent var(--col), + var(--color-gray-200) var(--col), + var(--color-gray-200) calc(var(--col) + 1px) + ); + background-position: 0; + background-size: 100%; + background-repeat: no-repeat; + } +} + +/** + * calendar list in the left bar + **/ #calendar-toggles { + @apply pb-6; summary { @apply flex items-center gap-1 justify-start; @@ -93,31 +333,33 @@ } } } -} -li.calendar-toggle { - @apply relative; + li.calendar-toggle { + @apply relative; - /* hide the edit link by default */ - .edit-link { - @apply hidden absolute pl-4 right-0 top-1/2 -translate-y-1/2 underline text-sm; - background: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 33%); - } - - /* show menu on hover */ - &:hover { + /* hide the edit link by default */ .edit-link { - @apply block; + @apply hidden absolute pl-4 right-0 top-1/2 -translate-y-1/2 underline text-sm; + background: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 33%); + } + + /* show menu on hover */ + &:hover { + .edit-link { + @apply block; + } + } + + /* limit calendar titles to 1 line */ + .checkbox-label span { + @apply line-clamp-1; } } - - /* limit calendar titles to 1 line */ - .checkbox-label span { - @apply line-clamp-1; - } } -/* animations */ +/** + * animations + **/ @keyframes event-slide { from { opacity: 0; @@ -129,3 +371,14 @@ li.calendar-toggle { } } +@keyframes header-slide { + from { + opacity: 0; + transform: translateX(-0.25rem); + } + to { + opacity: 1; + transform: translateX(0); + } +} + diff --git a/resources/js/app.js b/resources/js/app.js index 4d9d64d..61953ef 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -37,6 +37,18 @@ document.addEventListener('change', event => { .forEach(el => el.classList.toggle('hidden', !show)); }); +/** + * calendar view picker + * progressive enhancement on html form with no js + */ +document.addEventListener('change', (e) => { + const form = e.target?.form; + if (!form || form.id !== 'calendar-view') return; + if (e.target.name !== 'view') return; + + form.requestSubmit(); +}); + /** * color picker component * native + hex + random palette) diff --git a/resources/svg/icons/preferences.svg b/resources/svg/icons/preferences.svg new file mode 100644 index 0000000..b953275 --- /dev/null +++ b/resources/svg/icons/preferences.svg @@ -0,0 +1 @@ + diff --git a/resources/svg/icons/solid/preferences.svg b/resources/svg/icons/solid/preferences.svg new file mode 100644 index 0000000..1900afa --- /dev/null +++ b/resources/svg/icons/solid/preferences.svg @@ -0,0 +1 @@ + diff --git a/resources/views/calendar/index.blade.php b/resources/views/calendar/index.blade.php index 5ecddec..547c7ac 100644 --- a/resources/views/calendar/index.blade.php +++ b/resources/views/calendar/index.blade.php @@ -13,11 +13,11 @@ {{ __('calendar.mine') }} + + hx-get="{{ route('calendar.settings.create') }}" + hx-target="#modal" + hx-push-url="false" + hx-swap="innerHTML" + class="button button--icon button--sm">+