diff --git a/app/Http/Controllers/CalendarController.php b/app/Http/Controllers/CalendarController.php
index aa93c50..bc173bf 100644
--- a/app/Http/Controllers/CalendarController.php
+++ b/app/Http/Controllers/CalendarController.php
@@ -11,6 +11,7 @@ use App\Services\Calendar\CalendarRangeResolver;
use App\Services\Calendar\CalendarViewBuilder;
use App\Services\Calendar\CalendarSettingsPersister;
use App\Services\Event\EventRecurrence;
+use Illuminate\Support\Facades\Log;
class CalendarController extends Controller
{
@@ -137,6 +138,96 @@ class CalendarController extends Controller
$daytimeHoursForView,
);
+ $allDayEvents = $events->filter(fn ($event) => !empty($event['all_day']));
+ $timeEvents = $events->filter(fn ($event) => empty($event['all_day']));
+ $hasAllDayEvents = $allDayEvents->isNotEmpty();
+
+ if ($request->boolean('debug_all_day')) {
+ $calendarIds = $calendars->pluck('id')->values();
+ $event13CalendarId = Event::where('id', 13)->value('calendarid');
+ $rangeStart = $span['start']->toDateTimeString();
+ $rangeEnd = $span['end']->toDateTimeString();
+
+ $metaRangeCount = Event::whereIn('calendarid', $calendarIds)
+ ->whereHas('meta', function ($meta) use ($rangeStart, $rangeEnd) {
+ $meta->where(function ($range) use ($rangeStart, $rangeEnd) {
+ $range->where('start_at', '<=', $rangeEnd)
+ ->where(function ($bounds) use ($rangeStart) {
+ $bounds->where('end_at', '>=', $rangeStart)
+ ->orWhereNull('end_at');
+ });
+ });
+ })
+ ->count();
+
+ $metaRruleCount = Event::whereIn('calendarid', $calendarIds)
+ ->whereHas('meta', function ($meta) {
+ $meta->whereNotNull('extra->rrule');
+ })
+ ->count();
+
+ $icalRruleCount = Event::whereIn('calendarid', $calendarIds)
+ ->where(function ($ical) {
+ $ical->where('calendardata', 'like', '%RRULE%')
+ ->orWhere('calendardata', 'like', '%RDATE%')
+ ->orWhere('calendardata', 'like', '%EXDATE%');
+ })
+ ->count();
+
+ $event13Range = Event::where('id', 13)
+ ->whereHas('meta', function ($meta) use ($rangeStart, $rangeEnd) {
+ $meta->where(function ($range) use ($rangeStart, $rangeEnd) {
+ $range->where('start_at', '<=', $rangeEnd)
+ ->where(function ($bounds) use ($rangeStart) {
+ $bounds->where('end_at', '>=', $rangeStart)
+ ->orWhereNull('end_at');
+ });
+ });
+ })
+ ->exists();
+
+ $event13Rrule = Event::where('id', 13)
+ ->whereHas('meta', function ($meta) {
+ $meta->whereNotNull('extra->rrule');
+ })
+ ->exists();
+
+ $event13Ical = Event::where('id', 13)
+ ->where(function ($ical) {
+ $ical->where('calendardata', 'like', '%RRULE%')
+ ->orWhere('calendardata', 'like', '%RDATE%')
+ ->orWhere('calendardata', 'like', '%EXDATE%');
+ })
+ ->exists();
+ Log::info('calendar all-day debug', [
+ 'view' => $view,
+ 'range_start' => $range['start']->toDateTimeString(),
+ 'range_end' => $range['end']->toDateTimeString(),
+ 'span_start' => $span['start']->toDateTimeString(),
+ 'span_end' => $span['end']->toDateTimeString(),
+ 'meta_range_count' => $metaRangeCount,
+ 'meta_rrule_count' => $metaRruleCount,
+ 'ical_rrule_count' => $icalRruleCount,
+ 'event_13_range' => $event13Range,
+ 'event_13_meta_rrule' => $event13Rrule,
+ 'event_13_ical_rrule' => $event13Ical,
+ 'calendar_ids' => $calendarIds->all(),
+ 'events_for_calendars' => Event::whereIn('calendarid', $calendarIds)->count(),
+ 'event_13_calendar_id' => $event13CalendarId,
+ 'event_13_in_scope' => $event13CalendarId ? $calendarIds->contains($event13CalendarId) : false,
+ 'events_total' => $events->count(),
+ 'events_all_day' => $allDayEvents->count(),
+ 'events_time' => $timeEvents->count(),
+ 'all_day_sample' => $allDayEvents->take(5)->values()->map(fn ($e) => [
+ 'id' => $e['id'] ?? null,
+ 'occurrence' => $e['occurrence'] ?? null,
+ 'start' => $e['start'] ?? null,
+ 'end' => $e['end'] ?? null,
+ 'timezone' => $e['timezone'] ?? null,
+ ])->all(),
+ ]);
+ }
+
/**
*
* mini calendar
@@ -205,6 +296,9 @@ class CalendarController extends Controller
'week_start' => $weekStart,
'hgroup' => $viewBuilder->viewHeaders($view, $range, $tz, $weekStart),
'events' => $events, // keyed by occurrence
+ 'events_time' => $timeEvents,
+ 'events_all_day'=> $allDayEvents,
+ 'has_all_day' => $hasAllDayEvents,
'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
diff --git a/app/Http/Controllers/EventController.php b/app/Http/Controllers/EventController.php
index 1f40982..2ae7efd 100644
--- a/app/Http/Controllers/EventController.php
+++ b/app/Http/Controllers/EventController.php
@@ -49,7 +49,7 @@ class EventController extends Controller
$end = $anchor->copy()->addHour()->format('Y-m-d\TH:i');
$rrule = '';
- return view('event.form', compact(
+ $data = compact(
'calendar',
'instance',
'event',
@@ -57,7 +57,13 @@ class EventController extends Controller
'end',
'tz',
'rrule',
- ));
+ );
+
+ if ($request->header('HX-Request') === 'true') {
+ return view('event.partials.form-modal', $data);
+ }
+
+ return view('event.form', $data);
}
/**
@@ -89,7 +95,13 @@ class EventController extends Controller
?? $recurrence->extractRrule($event)
?? '';
- return view('event.form', compact('calendar', 'instance', 'event', 'start', 'end', 'tz', 'rrule'));
+ $data = compact('calendar', 'instance', 'event', 'start', 'end', 'tz', 'rrule');
+
+ if ($request->header('HX-Request') === 'true') {
+ return view('event.partials.form-modal', $data);
+ }
+
+ return view('event.form', $data);
}
/**
@@ -149,6 +161,10 @@ class EventController extends Controller
$start = $startUtc->copy()->timezone($tz);
$end = $endUtc->copy()->timezone($tz);
+ if ($event->meta?->all_day && $end->gt($start)) {
+ $end = $end->copy()->subDay();
+ }
+
$map = $this->buildBasemapTiles($event->meta?->venue);
$event->setAttribute('color', $calendarColor);
@@ -168,15 +184,32 @@ class EventController extends Controller
{
$this->authorize('update', $calendar);
- $data = $request->validate([
+ $this->normalizeRecurrenceInputs($request);
+
+ $rules = [
'title' => ['required', 'string', 'max:200'],
- 'start_at' => ['required', 'date_format:Y-m-d\TH:i'],
- 'end_at' => ['required', 'date_format:Y-m-d\TH:i', 'after:start_at'],
'description' => ['nullable', 'string'],
'location' => ['nullable', 'string'],
'all_day' => ['sometimes', 'boolean'],
'category' => ['nullable', 'string', 'max:50'],
- 'rrule' => ['nullable', 'string', 'max:255'],
+ 'repeat_frequency' => [
+ function (string $attribute, mixed $value, $fail) {
+ if ($value === null || $value === '') {
+ return;
+ }
+ if (!in_array($value, ['daily', 'weekly', 'monthly', 'yearly'], true)) {
+ $fail(__('calendar.event.recurrence.invalid_frequency'));
+ }
+ }
+ ],
+ 'repeat_interval' => ['nullable', 'integer', 'min:1', 'max:365'],
+ 'repeat_weekdays' => ['nullable', 'array'],
+ 'repeat_weekdays.*' => ['in:SU,MO,TU,WE,TH,FR,SA'],
+ 'repeat_monthly_mode' => ['nullable', 'in:days,weekday'],
+ 'repeat_month_days' => ['nullable', 'array'],
+ 'repeat_month_days.*' => ['integer', 'min:1', 'max:31'],
+ 'repeat_month_week' => ['nullable', 'in:first,second,third,fourth,last'],
+ 'repeat_month_weekday' => ['nullable', 'in:SU,MO,TU,WE,TH,FR,SA'],
// normalized location hints (optional)
'loc_display_name' => ['nullable', 'string'],
@@ -188,23 +221,47 @@ class EventController extends Controller
'loc_country' => ['nullable', 'string'],
'loc_lat' => ['nullable'],
'loc_lon' => ['nullable'],
- ]);
+ ];
+
+ if ($request->boolean('all_day')) {
+ $rules['start_at'] = ['required', 'date'];
+ $rules['end_at'] = ['required', 'date', 'after_or_equal:start_at'];
+ } else {
+ $rules['start_at'] = ['required', 'date_format:Y-m-d\\TH:i'];
+ $rules['end_at'] = ['required', 'date_format:Y-m-d\\TH:i', 'after:start_at'];
+ }
+
+ $data = $request->validate($rules);
$tz = $this->displayTimezone($calendar, $request);
+ $isAllDay = (bool) ($data['all_day'] ?? false);
// parse input in display tz, store in utc
- $startUtc = $this->parseLocalDatetimeToUtc($data['start_at'], $tz);
- $endUtc = $this->parseLocalDatetimeToUtc($data['end_at'], $tz);
+ if ($isAllDay) {
+ [$startOn, $endOn, $startUtc, $endUtc] = $this->deriveAllDayRange(
+ $data['start_at'],
+ $data['end_at'],
+ $tz
+ );
+ } else {
+ $startOn = null;
+ $endOn = null;
+ $startUtc = $this->parseLocalDatetimeToUtc($data['start_at'], $tz);
+ $endUtc = $this->parseLocalDatetimeToUtc($data['end_at'], $tz);
+ }
$uid = Str::uuid() . '@' . parse_url(config('app.url'), PHP_URL_HOST);
- $rrule = $this->normalizeRrule($request);
+ $rrule = $this->buildRruleFromRequest($request, $this->parseLocalInputToTz($data['start_at'], $tz, $isAllDay));
$extra = $this->mergeRecurrenceExtra([], $rrule, $tz, $request);
$ical = $recurrence->buildCalendar([
'uid' => $uid,
'start_utc' => $startUtc,
'end_utc' => $endUtc,
+ 'all_day' => $isAllDay,
+ 'start_date' => $startOn,
+ 'end_date' => $endOn,
'summary' => $data['title'],
'description' => $data['description'] ?? '',
'location' => $data['location'] ?? '',
@@ -230,10 +287,13 @@ class EventController extends Controller
'description' => $data['description'] ?? null,
'location' => $data['location'] ?? null,
'location_id' => $locationId,
- 'all_day' => (bool) ($data['all_day'] ?? false),
+ 'all_day' => $isAllDay,
'category' => $data['category'] ?? null,
'start_at' => $startUtc,
'end_at' => $endUtc,
+ 'start_on' => $startOn,
+ 'end_on' => $endOn,
+ 'tzid' => $isAllDay ? $tz : null,
'extra' => $extra,
]);
@@ -251,25 +311,63 @@ class EventController extends Controller
abort(Response::HTTP_NOT_FOUND);
}
- $data = $request->validate([
+ $this->normalizeRecurrenceInputs($request);
+
+ $rules = [
'title' => ['required', 'string', 'max:200'],
- 'start_at' => ['required', 'date_format:Y-m-d\TH:i'],
- 'end_at' => ['required', 'date_format:Y-m-d\TH:i', 'after:start_at'],
'description' => ['nullable', 'string'],
'location' => ['nullable', 'string'],
'all_day' => ['sometimes', 'boolean'],
'category' => ['nullable', 'string', 'max:50'],
- 'rrule' => ['nullable', 'string', 'max:255'],
- ]);
+ 'repeat_frequency' => [
+ function (string $attribute, mixed $value, $fail) {
+ if ($value === null || $value === '') {
+ return;
+ }
+ if (!in_array($value, ['daily', 'weekly', 'monthly', 'yearly'], true)) {
+ $fail(__('calendar.event.recurrence.invalid_frequency'));
+ }
+ }
+ ],
+ 'repeat_interval' => ['nullable', 'integer', 'min:1', 'max:365'],
+ 'repeat_weekdays' => ['nullable', 'array'],
+ 'repeat_weekdays.*' => ['in:SU,MO,TU,WE,TH,FR,SA'],
+ 'repeat_monthly_mode' => ['nullable', 'in:days,weekday'],
+ 'repeat_month_days' => ['nullable', 'array'],
+ 'repeat_month_days.*' => ['integer', 'min:1', 'max:31'],
+ 'repeat_month_week' => ['nullable', 'in:first,second,third,fourth,last'],
+ 'repeat_month_weekday' => ['nullable', 'in:SU,MO,TU,WE,TH,FR,SA'],
+ ];
+
+ if ($request->boolean('all_day')) {
+ $rules['start_at'] = ['required', 'date'];
+ $rules['end_at'] = ['required', 'date', 'after_or_equal:start_at'];
+ } else {
+ $rules['start_at'] = ['required', 'date_format:Y-m-d\\TH:i'];
+ $rules['end_at'] = ['required', 'date_format:Y-m-d\\TH:i', 'after:start_at'];
+ }
+
+ $data = $request->validate($rules);
$tz = $this->displayTimezone($calendar, $request);
+ $isAllDay = (bool) ($data['all_day'] ?? false);
- $startUtc = $this->parseLocalDatetimeToUtc($data['start_at'], $tz);
- $endUtc = $this->parseLocalDatetimeToUtc($data['end_at'], $tz);
+ if ($isAllDay) {
+ [$startOn, $endOn, $startUtc, $endUtc] = $this->deriveAllDayRange(
+ $data['start_at'],
+ $data['end_at'],
+ $tz
+ );
+ } else {
+ $startOn = null;
+ $endOn = null;
+ $startUtc = $this->parseLocalDatetimeToUtc($data['start_at'], $tz);
+ $endUtc = $this->parseLocalDatetimeToUtc($data['end_at'], $tz);
+ }
$uid = $event->uid;
- $rrule = $this->normalizeRrule($request);
+ $rrule = $this->buildRruleFromRequest($request, $this->parseLocalInputToTz($data['start_at'], $tz, $isAllDay));
$extra = $event->meta?->extra ?? [];
$extra = $this->mergeRecurrenceExtra($extra, $rrule, $tz, $request);
$rruleForIcs = $rrule ?? ($extra['rrule'] ?? $recurrence->extractRrule($event));
@@ -278,6 +376,9 @@ class EventController extends Controller
'uid' => $uid,
'start_utc' => $startUtc,
'end_utc' => $endUtc,
+ 'all_day' => $isAllDay,
+ 'start_date' => $startOn,
+ 'end_date' => $endOn,
'summary' => $data['title'],
'description' => $data['description'] ?? '',
'location' => $data['location'] ?? '',
@@ -297,10 +398,13 @@ class EventController extends Controller
'title' => $data['title'],
'description' => $data['description'] ?? null,
'location' => $data['location'] ?? null,
- 'all_day' => (bool) ($data['all_day'] ?? false),
+ 'all_day' => $isAllDay,
'category' => $data['category'] ?? null,
'start_at' => $startUtc,
'end_at' => $endUtc,
+ 'start_on' => $startOn,
+ 'end_on' => $endOn,
+ 'tzid' => $isAllDay ? $tz : null,
'extra' => $extra,
]);
@@ -328,6 +432,40 @@ class EventController extends Controller
return $local->utc();
}
+ /**
+ * derive all-day date range (exclusive end) and utc bounds
+ */
+ private function deriveAllDayRange(string $start, string $end, string $tz): array
+ {
+ $startLocal = $this->parseAllDayInput($start, $tz);
+ $endLocal = $this->parseAllDayInput($end, $tz);
+
+ $startOn = $startLocal->toDateString();
+ $endOn = $endLocal->toDateString();
+
+ if (Carbon::parse($endOn, $tz)->lte(Carbon::parse($startOn, $tz))) {
+ $endOn = Carbon::parse($startOn, $tz)->addDay()->toDateString();
+ }
+
+ $startUtc = Carbon::parse($startOn, $tz)->startOfDay()->utc();
+ $endUtc = Carbon::parse($endOn, $tz)->startOfDay()->utc();
+
+ return [$startOn, $endOn, $startUtc, $endUtc];
+ }
+
+ private function parseAllDayInput(string $value, string $tz): Carbon
+ {
+ if (preg_match('/^\\d{4}-\\d{2}-\\d{2}$/', $value) === 1) {
+ return Carbon::createFromFormat('Y-m-d', $value, $tz)->startOfDay();
+ }
+
+ try {
+ return Carbon::createFromFormat('Y-m-d\\TH:i', $value, $tz)->seconds(0);
+ } catch (\Throwable $e) {
+ return Carbon::parse($value, $tz)->startOfDay();
+ }
+ }
+
/**
* minimal ics escaping for text properties
*/
@@ -338,14 +476,89 @@ class EventController extends Controller
return $text;
}
- private function normalizeRrule(Request $request): ?string
+ private function normalizeRecurrenceInputs(Request $request): void
{
- if (! $request->has('rrule')) {
+ if ($request->has('repeat_interval') && $request->input('repeat_interval') === '') {
+ $request->merge(['repeat_interval' => null]);
+ }
+ }
+
+ private function buildRruleFromRequest(Request $request, Carbon $startLocal): ?string
+ {
+ if (! $request->has('repeat_frequency')) {
return null;
}
- $rrule = trim((string) $request->input('rrule'));
- return $rrule === '' ? '' : $rrule;
+ $freq = strtolower((string) $request->input('repeat_frequency', ''));
+ if ($freq === '') {
+ return '';
+ }
+
+ $interval = max(1, (int) $request->input('repeat_interval', 1));
+ $parts = ['FREQ=' . strtoupper($freq)];
+ if ($interval > 1) {
+ $parts[] = 'INTERVAL=' . $interval;
+ }
+
+ $weekdayMap = [
+ 'sun' => 'SU',
+ 'mon' => 'MO',
+ 'tue' => 'TU',
+ 'wed' => 'WE',
+ 'thu' => 'TH',
+ 'fri' => 'FR',
+ 'sat' => 'SA',
+ ];
+ $defaultWeekday = $weekdayMap[strtolower($startLocal->format('D'))] ?? 'MO';
+
+ if ($freq === 'weekly') {
+ $days = (array) $request->input('repeat_weekdays', []);
+ $days = array_values(array_filter($days, fn ($d) => preg_match('/^(SU|MO|TU|WE|TH|FR|SA)$/', $d)));
+ if (empty($days)) {
+ $days = [$defaultWeekday];
+ }
+ $parts[] = 'BYDAY=' . implode(',', $days);
+ }
+
+ if ($freq === 'monthly') {
+ $mode = $request->input('repeat_monthly_mode', 'days');
+ if ($mode === 'weekday') {
+ $week = $request->input('repeat_month_week', 'first');
+ $weekday = $request->input('repeat_month_weekday', $defaultWeekday);
+
+ $weekMap = [
+ 'first' => 1,
+ 'second' => 2,
+ 'third' => 3,
+ 'fourth' => 4,
+ 'last' => -1,
+ ];
+ $bysetpos = $weekMap[$week] ?? 1;
+ $parts[] = 'BYDAY=' . $weekday;
+ $parts[] = 'BYSETPOS=' . $bysetpos;
+ } else {
+ $days = (array) $request->input('repeat_month_days', []);
+ $days = array_values(array_filter($days, fn ($d) => is_numeric($d) && (int) $d >= 1 && (int) $d <= 31));
+ if (empty($days)) {
+ $days = [(int) $startLocal->format('j')];
+ }
+ $parts[] = 'BYMONTHDAY=' . implode(',', $days);
+ }
+ }
+
+ if ($freq === 'yearly') {
+ $parts[] = 'BYMONTH=' . $startLocal->format('n');
+ $parts[] = 'BYMONTHDAY=' . $startLocal->format('j');
+ }
+
+ return implode(';', $parts);
+ }
+
+ private function parseLocalInputToTz(string $value, string $tz, bool $isAllDay): Carbon
+ {
+ return $isAllDay
+ ? $this->parseAllDayInput($value, $tz)
+ : Carbon::createFromFormat('Y-m-d\\TH:i', $value, $tz)->seconds(0);
}
private function mergeRecurrenceExtra(array $extra, ?string $rrule, string $tz, Request $request): array
diff --git a/app/Http/Controllers/IcsController.php b/app/Http/Controllers/IcsController.php
index 12613a5..1a852a3 100644
--- a/app/Http/Controllers/IcsController.php
+++ b/app/Http/Controllers/IcsController.php
@@ -60,19 +60,26 @@ class IcsController extends Controller
}
$meta = $event->meta;
- if (!$meta || !$meta->start_at || !$meta->end_at) {
+ if (!$meta) {
continue;
}
- $start = Carbon::parse($meta->start_at)->timezone($tz);
- $end = Carbon::parse($meta->end_at)->timezone($tz);
-
$vevent = $vcalendar->add('VEVENT', []);
$vevent->add('UID', $event->uid);
$vevent->add('SUMMARY', $meta->title ?? '(Untitled)');
$vevent->add('DESCRIPTION', $meta->description ?? '');
- $vevent->add('DTSTART', $start, ['TZID' => $tz]);
- $vevent->add('DTEND', $end, ['TZID' => $tz]);
+
+ if ($meta->all_day && $meta->start_on && $meta->end_on) {
+ $vevent->add('DTSTART', Carbon::parse($meta->start_on), ['VALUE' => 'DATE']);
+ $vevent->add('DTEND', Carbon::parse($meta->end_on), ['VALUE' => 'DATE']);
+ } elseif ($meta->start_at && $meta->end_at) {
+ $start = Carbon::parse($meta->start_at)->timezone($tz);
+ $end = Carbon::parse($meta->end_at)->timezone($tz);
+ $vevent->add('DTSTART', $start, ['TZID' => $tz]);
+ $vevent->add('DTEND', $end, ['TZID' => $tz]);
+ } else {
+ continue;
+ }
if ($event->lastmodified) {
$vevent->add('DTSTAMP', Carbon::createFromTimestamp($event->lastmodified)->utc());
}
diff --git a/app/Jobs/SyncSubscription.php b/app/Jobs/SyncSubscription.php
index 1d48615..647f7b6 100644
--- a/app/Jobs/SyncSubscription.php
+++ b/app/Jobs/SyncSubscription.php
@@ -147,14 +147,29 @@ class SyncSubscription implements ShouldQueue
$end = isset($vevent->DTEND)
? Carbon::instance($vevent->DTEND->getDateTime())->utc()
: $start;
+ $isAllDay = $dtStart->isFloating();
+ $startOn = null;
+ $endOn = null;
+ $tzid = null;
+
+ if ($isAllDay) {
+ $startOn = $start->toDateString();
+ $endOn = $end->toDateString();
+ if (Carbon::parse($endOn)->lte(Carbon::parse($startOn))) {
+ $endOn = Carbon::parse($startOn)->addDay()->toDateString();
+ }
+ }
EventMeta::upsertForEvent($object->id, [
'title' => (string) ($vevent->SUMMARY ?? 'Untitled'),
'description' => (string) ($vevent->DESCRIPTION ?? ''),
'location' => (string) ($vevent->LOCATION ?? ''),
- 'all_day' => $dtStart->isFloating(),
+ 'all_day' => $isAllDay,
'start_at' => $start,
'end_at' => $end,
+ 'start_on' => $startOn,
+ 'end_on' => $endOn,
+ 'tzid' => $tzid,
]);
}
diff --git a/app/Models/EventMeta.php b/app/Models/EventMeta.php
index 714669e..d24b67a 100644
--- a/app/Models/EventMeta.php
+++ b/app/Models/EventMeta.php
@@ -22,6 +22,9 @@ class EventMeta extends Model
'is_private',
'start_at',
'end_at',
+ 'start_on',
+ 'end_on',
+ 'tzid',
'extra',
];
@@ -30,6 +33,8 @@ class EventMeta extends Model
'is_private' => 'boolean',
'start_at' => 'datetime',
'end_at' => 'datetime',
+ 'start_on' => 'date',
+ 'end_on' => 'date',
'extra' => 'array',
];
diff --git a/app/Services/Calendar/CalendarViewBuilder.php b/app/Services/Calendar/CalendarViewBuilder.php
index 1bac2c5..d2bd987 100644
--- a/app/Services/Calendar/CalendarViewBuilder.php
+++ b/app/Services/Calendar/CalendarViewBuilder.php
@@ -90,6 +90,7 @@ class CalendarViewBuilder
) {
$startUtc = $occ['start'];
$endUtc = $occ['end'];
+ $isAllDay = (bool) ($e->meta?->all_day ?? false);
$startLocal = $startUtc->copy()->timezone($timezone);
$endLocal = $endUtc->copy()->timezone($timezone);
@@ -97,7 +98,7 @@ class CalendarViewBuilder
$startForGrid = $startUtc->copy()->tz($tz);
$endForGrid = $endUtc->copy()->tz($tz);
- if ($daytimeHours) {
+ if ($daytimeHours && !$isAllDay) {
$startMinutes = ($startForGrid->hour * 60) + $startForGrid->minute;
$endMinutes = ($endForGrid->hour * 60) + $endForGrid->minute;
@@ -136,6 +137,7 @@ class CalendarViewBuilder
'calendar_slug' => $cal->slug,
'title' => $e->meta->title ?? 'No title',
'description' => $e->meta->description ?? 'No description.',
+ 'all_day' => $isAllDay,
'start' => $startUtc->toIso8601String(),
'end' => $endUtc->toIso8601String(),
'start_ui' => $startLocal->format($uiFormat),
@@ -179,6 +181,9 @@ class CalendarViewBuilder
$evTz = $ev['timezone'] ?? $tz;
$start = Carbon::parse($ev['start'])->tz($evTz);
$end = $ev['end'] ? Carbon::parse($ev['end'])->tz($evTz) : $start;
+ if (!empty($ev['all_day']) && $end->gt($start)) {
+ $end = $end->copy()->subSecond();
+ }
for ($d = $start->copy()->startOfDay();
$d->lte($end->copy()->endOfDay());
diff --git a/app/Services/Event/EventRecurrence.php b/app/Services/Event/EventRecurrence.php
index 285f1ab..296d3a5 100644
--- a/app/Services/Event/EventRecurrence.php
+++ b/app/Services/Event/EventRecurrence.php
@@ -27,11 +27,17 @@ class EventRecurrence
$startUtc = $data['start_utc'];
$endUtc = $data['end_utc'];
$tzid = $data['tzid'] ?? null;
+ $allDay = (bool) ($data['all_day'] ?? false);
+ $startDate = $data['start_date'] ?? null;
+ $endDate = $data['end_date'] ?? null;
$vevent->add('UID', $uid);
$vevent->add('DTSTAMP', $startUtc->copy()->utc());
- if ($tzid) {
+ if ($allDay && $startDate && $endDate) {
+ $vevent->add('DTSTART', Carbon::parse($startDate), ['VALUE' => 'DATE']);
+ $vevent->add('DTEND', Carbon::parse($endDate), ['VALUE' => 'DATE']);
+ } elseif ($tzid) {
$startLocal = $startUtc->copy()->tz($tzid);
$endLocal = $endUtc->copy()->tz($tzid);
$vevent->add('DTSTART', $startLocal, ['TZID' => $tzid]);
@@ -62,7 +68,9 @@ class EventRecurrence
if (!empty($exdates)) {
foreach ($exdates as $ex) {
$dt = Carbon::parse($ex, $tzid ?: 'UTC');
- if ($tzid) {
+ if ($allDay) {
+ $vevent->add('EXDATE', Carbon::parse($dt->toDateString()), ['VALUE' => 'DATE']);
+ } elseif ($tzid) {
$vevent->add('EXDATE', $dt, ['TZID' => $tzid]);
} else {
$vevent->add('EXDATE', $dt->utc());
@@ -74,7 +82,9 @@ class EventRecurrence
if (!empty($rdates)) {
foreach ($rdates as $r) {
$dt = Carbon::parse($r, $tzid ?: 'UTC');
- if ($tzid) {
+ if ($allDay) {
+ $vevent->add('RDATE', Carbon::parse($dt->toDateString()), ['VALUE' => 'DATE']);
+ } elseif ($tzid) {
$vevent->add('RDATE', $dt, ['TZID' => $tzid]);
} else {
$vevent->add('RDATE', $dt->utc());
@@ -117,19 +127,43 @@ class EventRecurrence
?? new DateTimeZone('UTC');
$iter = new EventIterator($vcalendar, $uid);
- $iter->fastForward($rangeStart->copy()->setTimezone($startTz)->toDateTime());
+ $baseStart = $vevent->DTSTART?->getDateTime();
+ $baseEnd = $vevent->DTEND?->getDateTime();
+ $durationSeconds = 0;
+ if ($baseStart && $baseEnd) {
+ $durationSeconds = max(0, $baseEnd->getTimestamp() - $baseStart->getTimestamp());
+ }
+
+ $searchStart = $rangeStart->copy()->subSeconds($durationSeconds)->setTimezone($startTz);
+ $iter->fastForward($searchStart->toDateTime());
$items = [];
while ($iter->valid()) {
$start = Carbon::instance($iter->getDTStart());
$end = Carbon::instance($iter->getDTEnd());
+ $allDayTz = null;
+ if ($event->meta?->all_day) {
+ $allDayTz = $event->meta?->tzid
+ ?? ($event->meta?->extra['tzid'] ?? null);
+ }
- if ($start->gt($rangeEnd)) {
- break;
+ if ($allDayTz) {
+ $start = Carbon::parse($start->toDateString(), $allDayTz);
+ $end = Carbon::parse($end->toDateString(), $allDayTz);
}
$startUtc = $start->copy()->utc();
$endUtc = $end->copy()->utc();
+
+ if ($startUtc->gt($rangeEnd)) {
+ break;
+ }
+
+ if ($endUtc->lte($rangeStart)) {
+ $iter->next();
+ continue;
+ }
+
$items[] = [
'start' => $startUtc,
'end' => $endUtc,
diff --git a/database/migrations/2026_02_11_000000_add_all_day_dates_to_event_meta.php b/database/migrations/2026_02_11_000000_add_all_day_dates_to_event_meta.php
new file mode 100644
index 0000000..4cfb0f9
--- /dev/null
+++ b/database/migrations/2026_02_11_000000_add_all_day_dates_to_event_meta.php
@@ -0,0 +1,30 @@
+date('start_on')->nullable()->after('end_at');
+ $table->date('end_on')->nullable()->after('start_on');
+ $table->string('tzid')->nullable()->after('end_on');
+
+ $table->index(['all_day', 'start_on']);
+ $table->index(['all_day', 'end_on']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('event_meta', function (Blueprint $table) {
+ $table->dropIndex(['all_day', 'start_on']);
+ $table->dropIndex(['all_day', 'end_on']);
+
+ $table->dropColumn(['start_on', 'end_on', 'tzid']);
+ });
+ }
+};
diff --git a/lang/en/calendar.php b/lang/en/calendar.php
index 56b4ba6..6371bae 100644
--- a/lang/en/calendar.php
+++ b/lang/en/calendar.php
@@ -81,6 +81,44 @@ return [
'attachments_coming' => 'Attachment support coming soon.',
'notes' => 'Notes',
'no_description' => 'No description yet.',
+ 'all_day_events' => 'All-day events',
+ 'recurrence' => [
+ 'label' => 'Repeat',
+ 'frequency' => 'Frequency',
+ 'none' => 'Does not repeat',
+ 'every' => 'Every',
+ 'daily' => 'Daily',
+ 'weekly' => 'Weekly',
+ 'monthly' => 'Monthly',
+ 'yearly' => 'Yearly',
+ 'on_days' => 'On days',
+ 'on_the' => 'On the',
+ 'yearly_hint' => 'Repeats on the same date each year.',
+ 'invalid_frequency' => 'Please choose a valid repeat frequency.',
+ 'weekdays' => [
+ 'sun' => 'Sunday',
+ 'mon' => 'Monday',
+ 'tue' => 'Tuesday',
+ 'wed' => 'Wednesday',
+ 'thu' => 'Thursday',
+ 'fri' => 'Friday',
+ 'sat' => 'Saturday',
+ 'sun_short' => 'S',
+ 'mon_short' => 'M',
+ 'tue_short' => 'T',
+ 'wed_short' => 'W',
+ 'thu_short' => 'T',
+ 'fri_short' => 'F',
+ 'sat_short' => 'S',
+ ],
+ 'week_order' => [
+ 'first' => 'First',
+ 'second' => 'Second',
+ 'third' => 'Third',
+ 'fourth' => 'Fourth',
+ 'last' => 'Last',
+ ],
+ ],
],
];
diff --git a/lang/it/calendar.php b/lang/it/calendar.php
index 7fbc4dc..d15bf96 100644
--- a/lang/it/calendar.php
+++ b/lang/it/calendar.php
@@ -81,6 +81,44 @@ return [
'attachments_coming' => 'Supporto allegati in arrivo.',
'notes' => 'Note',
'no_description' => 'Nessuna descrizione.',
+ 'all_day_events' => 'Eventi di tutto il giorno',
+ 'recurrence' => [
+ 'label' => 'Ripeti',
+ 'frequency' => 'Frequenza',
+ 'none' => 'Non si ripete',
+ 'every' => 'Ogni',
+ 'daily' => 'Giornaliero',
+ 'weekly' => 'Settimanale',
+ 'monthly' => 'Mensile',
+ 'yearly' => 'Annuale',
+ 'on_days' => 'Nei giorni',
+ 'on_the' => 'Nel',
+ 'yearly_hint' => 'Si ripete nella stessa data ogni anno.',
+ 'invalid_frequency' => 'Seleziona una frequenza valida.',
+ 'weekdays' => [
+ 'sun' => 'Domenica',
+ 'mon' => 'Lunedi',
+ 'tue' => 'Martedi',
+ 'wed' => 'Mercoledi',
+ 'thu' => 'Giovedi',
+ 'fri' => 'Venerdi',
+ 'sat' => 'Sabato',
+ 'sun_short' => 'D',
+ 'mon_short' => 'L',
+ 'tue_short' => 'M',
+ 'wed_short' => 'M',
+ 'thu_short' => 'G',
+ 'fri_short' => 'V',
+ 'sat_short' => 'S',
+ ],
+ 'week_order' => [
+ 'first' => 'Primo',
+ 'second' => 'Secondo',
+ 'third' => 'Terzo',
+ 'fourth' => 'Quarto',
+ 'last' => 'Ultimo',
+ ],
+ ],
],
];
diff --git a/resources/css/lib/button.css b/resources/css/lib/button.css
index 7488ad7..3c65dad 100644
--- a/resources/css/lib/button.css
+++ b/resources/css/lib/button.css
@@ -146,6 +146,10 @@ button,
/* small */
&.button-group--sm {
- @apply h-9 max-h-9 text-sm;
+ @apply h-9 max-h-9;
+
+ > label {
+ @apply text-sm;
+ }
}
}
diff --git a/resources/css/lib/calendar.css b/resources/css/lib/calendar.css
index 6f73979..a9a6e3d 100644
--- a/resources/css/lib/calendar.css
+++ b/resources/css/lib/calendar.css
@@ -158,6 +158,12 @@
--now-col-start: 1;
--now-col-end: 2;
+ /* if we have an all day event, change the grid */
+ &.allday {
+ grid-template-columns: 6rem auto;
+ grid-template-rows: 5rem min-content auto 5rem;
+ }
+
/* top day bar */
hgroup {
@apply bg-white col-span-2 border-b-2 border-primary pl-24 sticky z-10;
@@ -177,7 +183,7 @@
}
div.day-header {
- @apply relative flex flex-col gap-1 justify-end items-start pb-2;
+ @apply relative flex flex-col gap-1 justify-end items-start pb-2 h-full;
animation: header-slide 250ms ease-in;
&:not(:last-of-type)::after {
@@ -198,6 +204,40 @@
}
}
+ /* all day bar */
+ ol.day {
+ @apply sticky top-42 grid col-span-2 bg-white py-2 border-b border-primary col-span-2 pl-24 z-2 overflow-x-hidden;
+ box-shadow: 0 0.25rem 0.5rem -0.25rem var(--color-gray-200);
+
+ &::before {
+ @apply absolute left-0 top-1/2 -translate-y-1/2 w-24 pr-4 text-right;
+ @apply uppercase text-xs font-mono text-secondary font-medium;
+ content: 'All day';
+ }
+
+ li.events {
+ @apply flex flex-col gap-1 relative overflow-x-hidden;
+ 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);
+
+ a.event {
+ @apply flex items-center text-xs gap-1 px-1 py-px font-medium rounded-sm w-full;
+ background-color: var(--event-bg);
+ color: var(--event-fg);
+
+ > span {
+ @apply truncate;
+ }
+
+ &:hover {
+ background-color: color-mix(in srgb, var(--event-bg) 100%, #000 10%);
+ }
+ }
+ }
+ }
+
/* time column */
ol.time {
@apply grid z-0 pt-4;
@@ -351,12 +391,17 @@
hgroup {
@apply grid gap-x-2;
- grid-template-columns: repeat(7, 1fr);
+ grid-template-columns: repeat(var(--days), 1fr);
+ }
+
+ ol.day {
+ @apply gap-x-2;
+ grid-template-columns: repeat(var(--days), 1fr);
}
ol.events {
@apply gap-x-2;
- grid-template-columns: repeat(7, 1fr);
+ grid-template-columns: repeat(var(--days), 1fr);
--col: calc(100% / var(--days));
/* draw a 1px line at the start of each column repeat + highlight weekends */
@@ -399,6 +444,11 @@
grid-template-columns: repeat(var(--days), 1fr);
}
+ ol.day {
+ @apply gap-x-2;
+ grid-template-columns: repeat(var(--days), 1fr);
+ }
+
ol.events {
@apply gap-x-2;
grid-template-columns: repeat(var(--days), 1fr);
@@ -481,6 +531,14 @@
.calendar.time {
grid-template-rows: 4rem auto 5rem;
+ &.allday {
+ grid-template-rows: 4rem min-content auto 5rem;
+ }
+
+ ol.day {
+ @apply top-38;
+ }
+
hgroup {
div.day-header {
@apply flex-row items-center justify-start gap-2;
diff --git a/resources/js/app.js b/resources/js/app.js
index 2c4dc36..f5a48b9 100644
--- a/resources/js/app.js
+++ b/resources/js/app.js
@@ -9,6 +9,16 @@ const SELECTORS = {
colorPickerColor: '[data-colorpicker-color]',
colorPickerHex: '[data-colorpicker-hex]',
colorPickerRandom: '[data-colorpicker-random]',
+ eventAllDayToggle: '[data-all-day-toggle]',
+ eventStartInput: '[data-event-start]',
+ eventEndInput: '[data-event-end]',
+ recurrenceFrequency: '[data-recurrence-frequency]',
+ recurrenceInterval: '[data-recurrence-interval]',
+ recurrenceUnit: '[data-recurrence-unit]',
+ recurrenceSection: '[data-recurrence-section]',
+ monthlyMode: '[data-monthly-mode]',
+ monthlyDays: '[data-monthly-days]',
+ monthlyWeekday: '[data-monthly-weekday]',
monthDay: '.calendar.month .day',
monthDayEvent: 'a.event',
monthDayMore: '[data-day-more]',
@@ -108,6 +118,172 @@ window.addEventListener('popstate', () => {
dialog.close();
});
+/**
+ * event form all-day toggle
+ */
+function initEventAllDayToggles(root = document) {
+ const toDate = (value) => {
+ if (!value) return '';
+ return value.split('T')[0];
+ };
+
+ const withTime = (date, time) => {
+ if (!date) return '';
+ return `${date}T${time}`;
+ };
+
+ root.querySelectorAll(SELECTORS.eventAllDayToggle).forEach((toggle) => {
+ if (toggle.__allDayWired) return;
+ toggle.__allDayWired = true;
+
+ const form = toggle.closest('form');
+ if (!form) return;
+
+ const start = form.querySelector(SELECTORS.eventStartInput);
+ const end = form.querySelector(SELECTORS.eventEndInput);
+ if (!start || !end) return;
+
+ const apply = () => {
+ if (toggle.checked) {
+ if (start.type === 'datetime-local') {
+ start.dataset.datetimeValue = start.value;
+ }
+ if (end.type === 'datetime-local') {
+ end.dataset.datetimeValue = end.value;
+ }
+
+ const startDate = toDate(start.value);
+ const endDate = toDate(end.value);
+
+ start.type = 'date';
+ end.type = 'date';
+
+ if (startDate) start.value = startDate;
+ if (endDate) end.value = endDate;
+
+ if (start.value && end.value && end.value < start.value) {
+ end.value = start.value;
+ }
+ } else {
+ const startDate = toDate(start.value);
+ const endDate = toDate(end.value);
+ const startTime = (start.dataset.datetimeValue || '').split('T')[1] || '09:00';
+ const endTime = (end.dataset.datetimeValue || '').split('T')[1] || '10:00';
+
+ start.type = 'datetime-local';
+ end.type = 'datetime-local';
+
+ start.value = start.dataset.datetimeValue || withTime(startDate, startTime);
+ end.value = end.dataset.datetimeValue || withTime(endDate || startDate, endTime);
+ }
+ };
+
+ toggle.addEventListener('change', apply);
+ apply();
+ });
+}
+
+/**
+ * recurrence preset selector
+ */
+function initRecurrenceControls(root = document) {
+ const sections = root.querySelectorAll(SELECTORS.recurrenceSection);
+ if (!sections.length) return;
+
+ const select = root.querySelector(SELECTORS.recurrenceFrequency);
+ const intervalRow = root.querySelector(SELECTORS.recurrenceInterval);
+ const intervalUnit = root.querySelector(SELECTORS.recurrenceUnit);
+ const monthModes = root.querySelectorAll(SELECTORS.monthlyMode);
+ const monthDays = root.querySelector(SELECTORS.monthlyDays);
+ const monthWeekday = root.querySelector(SELECTORS.monthlyWeekday);
+
+ if (!select) return;
+
+ const unitMap = {
+ daily: 'day',
+ weekly: 'week',
+ monthly: 'month',
+ yearly: 'year',
+ };
+
+ const applyMonthlyMode = () => {
+ if (!monthDays || !monthWeekday) return;
+ const modeInput = Array.from(monthModes).find((input) => input.checked);
+ const mode = modeInput?.value || 'days';
+
+ if (mode === 'weekday') {
+ monthDays.classList.add('hidden');
+ monthWeekday.classList.remove('hidden');
+ } else {
+ monthDays.classList.remove('hidden');
+ monthWeekday.classList.add('hidden');
+ }
+ };
+
+ const apply = () => {
+ const value = select.value;
+ const show = value !== '';
+
+ sections.forEach((section) => {
+ const type = section.getAttribute('data-recurrence-section');
+ if (!show) {
+ section.classList.add('hidden');
+ return;
+ }
+ section.classList.toggle('hidden', type !== value);
+ });
+
+ if (intervalRow) {
+ intervalRow.classList.toggle('hidden', !show);
+ }
+
+ if (intervalUnit) {
+ const unit = unitMap[value] || 'day';
+ intervalUnit.textContent = unit ? `${unit}(s)` : '';
+ }
+
+ if (value === 'monthly') {
+ applyMonthlyMode();
+ }
+ };
+
+ select.addEventListener('change', apply);
+ monthModes.forEach((input) => input.addEventListener('change', applyMonthlyMode));
+
+ apply();
+}
+
+/**
+ * auto-scroll time views to 8am on load (when daytime hours are disabled)
+ */
+function initTimeViewAutoScroll(root = document)
+{
+ // make sure we're on a time calendar
+ const calendar = root.querySelector('.calendar.time');
+ if (!calendar) return;
+
+ // get out if we're autoscrolled or daytime hours is set
+ if (calendar.dataset.autoscrolled === '1') return;
+ if (calendar.dataset.daytimeHoursEnabled === '1') return;
+
+ // find the target minute (7:45am)
+ const target = calendar.querySelector('[data-slot-minutes="465"]');
+ if (!target) return;
+
+ // get the scroll container and offset
+ const container = calendar.closest('article') || document.querySelector('article#calendar');
+ if (!container) return;
+ const header = container.querySelector('header');
+ const headerOffset = header ? header.offsetHeight : 0;
+ const containerRect = container.getBoundingClientRect();
+ const targetRect = target.getBoundingClientRect();
+ const top = targetRect.top - containerRect.top + container.scrollTop - headerOffset - 12;
+
+ // scroll
+ container.scrollTo({ top: Math.max(top, 0), behavior: 'auto' });
+ calendar.dataset.autoscrolled = '1';
+}
+
/**
*
* calendar sidebar expand toggle
@@ -362,6 +538,9 @@ window.addEventListener('resize', () => {
function initUI() {
initColorPickers();
+ initEventAllDayToggles();
+ initRecurrenceControls();
+ initTimeViewAutoScroll();
initMonthOverflow();
}
@@ -371,5 +550,8 @@ document.addEventListener('DOMContentLoaded', initUI);
// rebind in htmx for swapped content
document.addEventListener('htmx:afterSwap', (e) => {
initColorPickers(e.target);
+ initEventAllDayToggles(e.target);
+ initRecurrenceControls(e.target);
+ initTimeViewAutoScroll(e.target);
initMonthOverflow(e.target);
});
diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php
index ad4c5f9..44ab805 100644
--- a/resources/views/auth/login.blade.php
+++ b/resources/views/auth/login.blade.php
@@ -2,34 +2,47 @@