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 @@
+ {{ $data['sub'] }} +
+