Compare commits

..

2 Commits

36 changed files with 1514 additions and 302 deletions

View File

@ -24,6 +24,10 @@ GEOCODER_COUNTRY=USA
GEOCODER_CATEGORIES=POI,Address
ARCGIS_API_KEY=
ARCGIS_STORE_RESULTS=true # set to false to not store results
ARCGIS_DEBUG=false
ARCGIS_BASEMAP_STYLE=arcgis/community
ARCGIS_BASEMAP_ZOOM=16
GEOCODE_AFTER_MIGRATE=false
PHP_CLI_SERVER_WORKERS=4

View File

@ -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

View File

@ -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

View File

@ -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());
}

View File

@ -34,14 +34,97 @@ class GeocodeEventLocations implements ShouldQueue
public function handle(Geocoder $geocoder): void
{
// working counters
$processed = 0;
$created = 0;
$updated = 0;
$skipped = 0;
$failed = 0;
$stats = [
'locations' => [
'processed' => 0,
'updated' => 0,
'skipped' => 0,
'failed' => 0,
],
'events' => [
'processed' => 0,
'created' => 0,
'updated' => 0,
'skipped' => 0,
'failed' => 0,
],
];
$handled = 0;
Log::info('GeocodeEventLocations: start', ['limit' => $this->limit]);
// first, geocode any location rows missing coordinates
$locations = Location::query()
->whereNotNull('raw_address')
->where('raw_address', '<>', '')
->where(function ($q) {
$q->whereNull('lat')->orWhereNull('lon');
})
->orderBy('id');
$stop = false;
$locations->chunkById(200, function ($chunk) use ($geocoder, &$stats, &$handled, &$stop) {
foreach ($chunk as $loc) {
if ($stop) {
return false;
}
if ($this->limit !== null && $handled >= $this->limit) {
$stop = true;
return false;
}
$handled++;
$stats['locations']['processed']++;
try {
$norm = $geocoder->forward($loc->raw_address);
if (!$norm || !is_numeric($norm['lat'] ?? null) || !is_numeric($norm['lon'] ?? null)) {
$stats['locations']['skipped']++;
continue;
}
$changed = false;
if ($loc->lat === null && is_numeric($norm['lat'])) {
$loc->lat = $norm['lat'];
$changed = true;
}
if ($loc->lon === null && is_numeric($norm['lon'])) {
$loc->lon = $norm['lon'];
$changed = true;
}
foreach (['street', 'city', 'state', 'postal', 'country'] as $field) {
if (empty($loc->{$field}) && !empty($norm[$field])) {
$loc->{$field} = $norm[$field];
$changed = true;
}
}
if ($changed) {
$loc->save();
$stats['locations']['updated']++;
} else {
$stats['locations']['skipped']++;
}
} catch (\Throwable $e) {
$stats['locations']['failed']++;
Log::warning('GeocodeEventLocations: location failed', [
'location_id' => $loc->id,
'raw_address' => $loc->raw_address,
'error' => $e->getMessage(),
]);
}
}
}, 'id');
if ($stop) {
Log::info('GeocodeEventLocations: done', $stats);
return;
}
// events that have a non-empty location string but no linked location row yet
$todo = EventMeta::query()
->whereNull('location_id')
@ -49,46 +132,63 @@ class GeocodeEventLocations implements ShouldQueue
->where('location', '<>', '')
->orderBy('event_id'); // important for chunkById
$stop = false;
// log total to process (before limit)
$total = (clone $todo)->count();
Log::info('[geo] starting GeocodeEventLocations', ['total' => $total, 'limit' => $this->limit]);
// chunk through event_meta rows
$todo->chunkById(200, function ($chunk) use ($geocoder, &$processed, &$created, &$updated, &$skipped, &$failed, &$stop) {
$todo->chunkById(200, function ($chunk) use ($geocoder, &$stats, &$handled, &$stop) {
foreach ($chunk as $meta) {
if ($stop) {
return false; // stop further chunking
}
// respect limit if provided
if ($this->limit !== null && $processed >= $this->limit) {
if ($this->limit !== null && $handled >= $this->limit) {
$stop = true;
return false;
}
try {
// geocode the free-form location string; if it looks like a label,
// fall back to the normalized location record's raw address
// geocode the free-form location string; prefer an existing location match
$query = $meta->location;
$norm = $geocoder->forward($query);
$location = Location::where('display_name', $meta->location)
->orWhere('raw_address', $meta->location)
->first();
if (!$norm) {
$location = Location::where('display_name', $meta->location)
->orWhere('raw_address', $meta->location)
->first();
if ($location?->raw_address) {
$query = $location->raw_address;
$norm = $geocoder->forward($query);
if (!$location) {
// soft match on prefix when there is exactly one candidate
$matches = Location::where('display_name', 'like', $meta->location . '%')
->limit(2)
->get();
if ($matches->count() === 1) {
$location = $matches->first();
}
}
if ($location) {
// if we already have coords, just link and move on
if (is_numeric($location->lat) && is_numeric($location->lon)) {
$meta->location_id = $location->id;
$meta->save();
$handled++;
$stats['events']['processed']++;
$stats['events']['updated']++;
continue;
}
if ($location->raw_address) {
$query = $location->raw_address;
}
}
$norm = $geocoder->forward($query);
// skip obvious non-address labels or unresolved queries
if (!$norm || (!$norm['lat'] && !$norm['street'])) {
$skipped++;
$processed++;
$stats['events']['skipped']++;
$handled++;
$stats['events']['processed']++;
Log::info('GeocodeEventLocations: skipped', [
'event_id' => $meta->event_id,
'location' => $meta->location,
@ -133,22 +233,24 @@ class GeocodeEventLocations implements ShouldQueue
$existing->lon = $norm['lon'];
$existing->raw_address ??= $norm['raw_address'];
$existing->save();
$updated++;
$stats['events']['updated']++;
}
if ($loc->wasRecentlyCreated) {
$created++;
$stats['events']['created']++;
}
// link event_meta → locations
$meta->location_id = $loc->id;
$meta->save();
$processed++;
$handled++;
$stats['events']['processed']++;
} catch (\Throwable $e) {
$failed++;
$processed++;
$stats['events']['failed']++;
$handled++;
$stats['events']['processed']++;
Log::warning('GeocodeEventLocations: failed', [
'event_id' => $meta->event_id,
'location' => $meta->location,
@ -158,6 +260,6 @@ class GeocodeEventLocations implements ShouldQueue
}
}, 'event_id');
Log::info('GeocodeEventLocations: done', compact('processed', 'created', 'updated', 'skipped', 'failed'));
Log::info('GeocodeEventLocations: done', $stats);
}
}

View File

@ -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,
]);
}

View File

@ -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',
];

View File

@ -75,6 +75,7 @@ class Location extends Model
$changed = true;
}
if ($changed) {
$existing->save();
}

View File

@ -4,6 +4,10 @@ namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Illuminate\Database\Events\MigrationsEnded;
use App\Jobs\GeocodeEventLocations;
class AppServiceProvider extends ServiceProvider
{
@ -22,5 +26,26 @@ class AppServiceProvider extends ServiceProvider
{
// Calendar form
Blade::component('calendars._form', 'calendar-form');
if (app()->runningInConsole()) {
Event::listen(MigrationsEnded::class, function () {
if (!config('services.geocoding.after_migrate')) {
return;
}
if (!config('services.geocoding.arcgis.api_key')) {
Log::warning('Skipping geocode after migrate: missing ArcGIS API key');
return;
}
try {
GeocodeEventLocations::runNow();
} catch (\Throwable $e) {
Log::warning('Geocode after migrate failed', [
'error' => $e->getMessage(),
]);
}
});
}
}
}

View File

@ -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());

View File

@ -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,

View File

@ -39,6 +39,7 @@ return [
"provider" => env("GEOCODER", "arcgis"),
"timeout" => (int) env("GEOCODER_TIMEOUT", 20),
"user_agent" => env("GEOCODER_USER_AGENT", "Kithkin/LocalDev"),
"after_migrate" => (bool) env("GEOCODE_AFTER_MIGRATE", false),
"arcgis" => [
"api_key" => env("ARCGIS_API_KEY"),
"store" => (bool) env("ARCGIS_STORE_RESULTS", true),

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('event_meta', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@ -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',
],
],
],
];

View File

@ -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',
],
],
],
];

View File

@ -78,7 +78,7 @@ button,
> label,
> button {
@apply relative flex items-center justify-center h-full pl-3.5 pr-3 cursor-pointer;
@apply border-md border-primary border-l-0 font-medium rounded-none whitespace-nowrap;
@apply border-md border-primary border-l-0 text-base font-medium rounded-none whitespace-nowrap;
transition: outline 125ms ease-in-out;
box-shadow: var(--shadows);
--shadows: none;
@ -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;
}
}
}

View File

@ -93,21 +93,6 @@
&[data-event-visible="9"] {
.event:nth-child(n+10) { @apply hidden; }
}
&.is-expanded {
position: relative;
height: min-content;
padding-bottom: 1px;
z-index: 3;
border: 1.5px solid black;
border-radius: 0.5rem;
scale: 1.05;
width: 120%;
margin-left: -10%;
div.more-events {
@apply relative h-8;
}
}
/* events */
.event {
@ -135,6 +120,28 @@
@apply hidden;
}
}
/* expanded days with truncated events */
&.is-expanded {
position: relative;
height: min-content;
padding-bottom: 1px;
z-index: 3;
border: 1.5px solid black;
border-radius: 0.5rem;
scale: 1.05;
width: 120%;
margin-left: -10%;
margin-bottom: -100%; /* needed to break out of row in webkit */
div.more-events {
@apply relative h-8;
}
.event {
animation: none;
}
}
}
}
}
@ -151,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;
@ -170,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 {
@ -191,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;
@ -344,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 */
@ -392,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);
@ -474,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;

View File

@ -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]',
@ -16,6 +26,7 @@ const SELECTORS = {
};
/**
*
* htmx/global
*/
@ -33,8 +44,10 @@ document.addEventListener('htmx:configRequest', (evt) => {
})
/**
*
* global auth expiry redirect (fetch/axios)
*/
const AUTH_REDIRECT_STATUSES = new Set([401, 419]);
const redirectToLogin = () => {
if (window.location.pathname !== '/login') {
@ -67,9 +80,11 @@ if (window.axios) {
}
/**
* calendar ui
* progressive enhancement on html form with no js
*
* calendar ui improvements
*/
// progressive enhancement on html form with no JS
document.addEventListener('change', (event) => {
const target = event.target;
@ -90,9 +105,190 @@ document.addEventListener('change', (event) => {
form.requestSubmit();
});
// close event modal on back/forward navigation
window.addEventListener('popstate', () => {
if (!document.querySelector('article#calendar')) return;
const dialog = document.querySelector('dialog');
if (!dialog?.open) return;
const modal = dialog.querySelector('#modal');
if (!modal?.querySelector('[data-modal-kind="event"]')) return;
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
*/
document.addEventListener('click', (event) => {
const toggle = event.target.closest(SELECTORS.calendarExpandToggle);
if (!toggle) return;
@ -107,9 +303,11 @@ document.addEventListener('click', (event) => {
});
/**
*
* color picker component
* native <input type="color"> + hex + random palette)
*/
function initColorPickers(root = document) {
const isHex = (v) => /^#?[0-9a-fA-F]{6}$/.test((v || '').trim());
@ -198,8 +396,10 @@ function initColorPickers(root = document) {
}
/**
*
* month view overflow handling (progressive enhancement)
*/
function initMonthOverflow(root = document) {
const days = root.querySelectorAll(SELECTORS.monthDay);
days.forEach((day) => updateMonthOverflow(day));
@ -311,24 +511,7 @@ function updateMonthOverflow(dayEl) {
}
}
/**
* initialization
*/
function initUI() {
initColorPickers();
initMonthOverflow();
}
// initial bind
document.addEventListener('DOMContentLoaded', initUI);
// rebind in htmx for swapped content
document.addEventListener('htmx:afterSwap', (e) => {
initColorPickers(e.target);
initMonthOverflow(e.target);
});
// show more events in a month calendar day when some are hidden
document.addEventListener('click', (event) => {
const button = event.target.closest(SELECTORS.monthDayMore);
if (!button) return;
@ -340,9 +523,35 @@ document.addEventListener('click', (event) => {
updateMonthOverflow(dayEl);
});
// month day resizer
let monthResizeTimer;
window.addEventListener('resize', () => {
if (!document.querySelector(SELECTORS.monthDay)) return;
window.clearTimeout(monthResizeTimer);
monthResizeTimer = window.setTimeout(() => initMonthOverflow(), 100);
});
/**
*
* initialization
*/
function initUI() {
initColorPickers();
initEventAllDayToggles();
initRecurrenceControls();
initTimeViewAutoScroll();
initMonthOverflow();
}
// initial bind
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);
});

View File

@ -2,34 +2,47 @@
<!-- Session Status -->
<x-auth-session-status class="mb-4" :status="session('status')" />
<form method="POST" action="{{ route('login') }}">
<form method="POST" action="{{ route('login') }}" class="auth">
@csrf
<!-- Email Address -->
<div>
<x-input.label for="email" :value="__('Email')" />
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" />
<x-input.error :messages="$errors->get('email')" class="mt-2" />
<div class="input-row input-row--1">
<div class="input-cell">
<x-input.label for="email" :value="__('Email')" />
<x-input.text
id="email"
type="email"
name="email"
:value="old('email')"
required
autofocus
autocomplete="username" />
<x-input.error :messages="$errors->get('email')" class="mt-2" />
</div>
</div>
<!-- Password -->
<div class="mt-4">
<x-input.label for="password" :value="__('Password')" />
<x-text-input id="password" class="block mt-1 w-full"
type="password"
name="password"
required autocomplete="current-password" />
<x-input.error :messages="$errors->get('password')" class="mt-2" />
<div class="input-row input-row--1">
<div class="input-cell">
<x-input.label for="password" :value="__('Password')" />
<x-input.text
id="password"
type="password"
name="password"
required
autocomplete="current-password" />
<x-input.error :messages="$errors->get('password')" class="mt-2" />
</div>
</div>
<!-- Remember Me -->
<div class="block mt-4">
<label for="remember_me" class="inline-flex items-center gap-2">
<input id="remember_me" type="checkbox" name="remember">
<span>{{ __('Remember me') }}</span>
</label>
<div class="input-row input-row--1">
<div class="input-cell">
<label for="remember_me" class="inline-flex items-center gap-2">
<input id="remember_me" type="checkbox" name="remember">
<span>{{ __('Remember me') }}</span>
</label>
</div>
</div>
<div class="flex items-center justify-between mt-4 gap-4">

View File

@ -1,3 +1,8 @@
@php
$eventCalendar = $calendars->firstWhere('is_remote', false) ?? $calendars->first();
$eventCalendarSlug = $eventCalendar['slug'] ?? null;
@endphp
<x-app-layout id="calendar">
<x-slot name="aside">
@ -95,11 +100,6 @@
</button>
</h2>
<menu>
<li>
<a class="button button--icon" href="{{ route('calendar.settings') }}">
<x-icon-settings />
</a>
</li>
<li>
<form id="calendar-nav"
action="{{ route('calendar.index') }}"
@ -177,10 +177,22 @@
</x-button.group-input>
<noscript><button type="submit" class="button">Apply</button></noscript>
</form>
<li>
<a class="button button--primary" href="{{ route('calendar.create') }}">
<x-icon-plus-circle /> Create
</li>
<li class="gap-0 flex flex-row">
<a class="button button--icon" href="{{ route('calendar.settings') }}">
<x-icon-settings />
</a>
@if($eventCalendarSlug)
<a class="button button--icon"
href="{{ route('calendar.event.create', $eventCalendarSlug) }}"
hx-get="{{ route('calendar.event.create', $eventCalendarSlug) }}"
hx-target="#modal"
hx-push-url="false"
hx-swap="innerHTML"
aria-label="{{ __('Create Event') }}">
<x-icon-plus-circle />
</a>
@endif
</li>
</menu>
</x-slot>
@ -192,7 +204,9 @@
class="week time"
:grid="$grid"
:calendars="$calendars"
:events="$events"
:events="$events_time"
:all_day_events="$events_all_day"
:has_all_day="$has_all_day"
:slots="$slots"
:timeformat="$time_format"
:hgroup="$hgroup"
@ -209,13 +223,16 @@
class="day time"
:grid="$grid"
:calendars="$calendars"
:events="$events"
:events="$events_time"
:all_day_events="$events_all_day"
:has_all_day="$has_all_day"
:slots="$slots"
:timeformat="$time_format"
:hgroup="$hgroup"
:active="$active"
:density="$density"
:daytime_hours="$daytime_hours"
:timezone="$timezone"
:now="$now"
/>
@break
@ -224,13 +241,16 @@
class="four time"
:grid="$grid"
:calendars="$calendars"
:events="$events"
:events="$events_time"
:all_day_events="$events_all_day"
:has_all_day="$has_all_day"
:slots="$slots"
:timeformat="$time_format"
:hgroup="$hgroup"
:active="$active"
:density="$density"
:daytime_hours="$daytime_hours"
:timezone="$timezone"
:now="$now"
/>
@break

View File

@ -2,6 +2,8 @@
'grid' => [],
'calendars' => [],
'events' => [],
'all_day_events' => [],
'has_all_day' => false,
'class' => '',
'slots' => [],
'timeformat' => '',
@ -10,11 +12,20 @@
'density' => '30',
'now' => [],
'daytime_hours' => [],
'timezone' => 'UTC',
])
<section
class="calendar {{ $class }}" data-density="{{ $density['step'] }}"
style="--now-row: {{ $now['row'] }}; --now-offset: {{ $now['offset'] ?? 0 }}; --now-col-start: {{ $now['col_start'] }}; --now-col-end: {{ $now['col_end'] }}; --grid-rows: {{ $daytime_hours['rows'] ?? 96 }};"
@class(['calendar', $class, 'allday' => $has_all_day ?? false])
data-density="{{ $density['step'] }}"
data-daytime-hours-enabled="{{ (int) ($daytime_hours['enabled'] ?? 0) }}"
style="
--now-row: {{ $now['row'] }};
--now-offset: {{ $now['offset'] ?? 0 }};
--now-col-start: {{ $now['col_start'] }};
--now-col-end: {{ $now['col_end'] }};
--grid-rows: {{ $daytime_hours['rows'] ?? 96 }};
"
>
<hgroup>
@foreach ($hgroup as $h)
@ -24,10 +35,17 @@
</div>
@endforeach
</hgroup>
@if($has_all_day)
<ol class="day" aria-label="{{ __('calendar.event.all_day_events') }}">
@foreach ($all_day_events as $event)
<x-calendar.time.day-event :event="$event" />
@endforeach
</ol>
@endif
<ol class="time" aria-label="{{ __('Times') }}">
@foreach ($slots as $slot)
<li>
<time datetime="{{ $slot['iso'] }}">{{ $slot['label'] }}</time>
<time datetime="{{ $slot['iso'] }}" data-slot-minutes="{{ $slot['minutes'] }}">{{ $slot['label'] }}</time>
</li>
@endforeach
</ol>
@ -40,7 +58,12 @@
@endif
</ol>
<footer>
<x-calendar.time.daytime-hours view="day" :density="$density" :daytime_hours="$daytime_hours" />
<x-calendar.time.density view="day" :density="$density" :daytime_hours="$daytime_hours" />
<div class="left">
<a href="{{ route('account.locale') }}" class="timezone">{{ $timezone }}</a>
</div>
<div class="right">
<x-calendar.time.daytime-hours view="day" :density="$density" :daytime_hours="$daytime_hours" />
<x-calendar.time.density view="day" :density="$density" :daytime_hours="$daytime_hours" />
</div>
</footer>
</section>

View File

@ -2,6 +2,8 @@
'grid' => [],
'calendars' => [],
'events' => [],
'all_day_events' => [],
'has_all_day' => false,
'class' => '',
'slots' => [],
'timeformat' => '',
@ -10,16 +12,20 @@
'density' => '30',
'now' => [],
'daytime_hours' => [],
'timezone' => 'UTC',
])
<section
class="calendar {{ $class }}" data-density="{{ $density['step'] }}"
style=
"--now-row: {{ (int) $now['row'] }};
@class(['calendar', $class, 'allday' => $has_all_day ?? false])
data-density="{{ $density['step'] }}"
data-daytime-hours-enabled="{{ (int) ($daytime_hours['enabled'] ?? 0) }}"
style="
--now-row: {{ (int) $now['row'] }};
--now-offset: {{ $now['offset'] ?? 0 }};
--now-col-start: {{ (int) $now['col_start'] }};
--now-col-end: {{ (int) $now['col_end'] }};
--grid-rows: {{ $daytime_hours['rows'] ?? 96 }};"
--grid-rows: {{ $daytime_hours['rows'] ?? 96 }};
"
>
<hgroup>
@foreach ($hgroup as $h)
@ -44,12 +50,19 @@
{{ $h['day'] }}
</a>
</div>
@endforeach
@endforeach
</hgroup>
@if($has_all_day)
<ol class="day" aria-label="{{ __('calendar.event.all_day_events') }}">
@foreach ($all_day_events as $event)
<x-calendar.time.day-event :event="$event" />
@endforeach
</ol>
@endif
<ol class="time" aria-label="{{ __('Times') }}">
@foreach ($slots as $slot)
<li>
<time datetime="{{ $slot['iso'] }}">{{ $slot['label'] }}</time>
<time datetime="{{ $slot['iso'] }}" data-slot-minutes="{{ $slot['minutes'] }}">{{ $slot['label'] }}</time>
</li>
@endforeach
</ol>
@ -62,7 +75,12 @@
@endif
</ol>
<footer>
<x-calendar.time.daytime-hours view="four" :density="$density" :daytime_hours="$daytime_hours" />
<x-calendar.time.density view="four" :density="$density" :daytime_hours="$daytime_hours" />
<div class="left">
<a href="{{ route('account.locale') }}" class="timezone">{{ $timezone }}</a>
</div>
<div class="right">
<x-calendar.time.daytime-hours view="four" :density="$density" :daytime_hours="$daytime_hours" />
<x-calendar.time.density view="four" :density="$density" :daytime_hours="$daytime_hours" />
</div>
</footer>
</section>

View File

@ -28,7 +28,7 @@
href="{{ route('calendar.event.show', $showParams) }}"
hx-get="{{ route('calendar.event.show', $showParams) }}"
hx-target="#modal"
hx-push-url="false"
hx-push-url="true"
hx-swap="innerHTML"
style="--event-color: {{ $color }}"
data-calendar="{{ $event['calendar_slug'] }}"

View File

@ -0,0 +1,27 @@
@props([
'event' => [],
])
<li class="events"
data-event-id="{{ $event['occurrence_id'] ?? $event['id'] }}"
data-calendar-id="{{ $event['calendar_slug'] }}"
style="
--event-col: {{ $event['start_col'] ?? 1 }};
--event-bg: {{ $event['color'] }};
--event-fg: {{ $event['color_fg'] }};">
@php
$showParams = [$event['calendar_slug'], $event['id']];
if (!empty($event['occurrence'])) {
$showParams['occurrence'] = $event['occurrence'];
}
@endphp
<a class="event{{ $event['visible'] ? '' : ' hidden' }}"
href="{{ route('calendar.event.show', $showParams) }}"
hx-get="{{ route('calendar.event.show', $showParams) }}"
hx-target="#modal"
hx-push-url="true"
hx-swap="innerHTML"
data-calendar="{{ $event['calendar_slug'] }}">
<span>{{ $event['title'] }}</span>
</a>
</li>

View File

@ -26,13 +26,13 @@
$showParams['occurrence'] = $event['occurrence'];
}
@endphp
<a class="event{{ $event['visible'] ? '' : ' hidden' }}"
href="{{ route('calendar.event.show', $showParams) }}"
hx-get="{{ route('calendar.event.show', $showParams) }}"
hx-target="#modal"
hx-push-url="false"
hx-swap="innerHTML"
data-calendar="{{ $event['calendar_slug'] }}"
<a class="event{{ $event['visible'] ? '' : ' hidden' }}"
href="{{ route('calendar.event.show', $showParams) }}"
hx-get="{{ route('calendar.event.show', $showParams) }}"
hx-target="#modal"
hx-push-url="true"
hx-swap="innerHTML"
data-calendar="{{ $event['calendar_slug'] }}"
>
<span>{{ $event['title'] }}</span>
<time datetime="{{ $event['start'] }}">{{ $event['start_ui'] }} - {{ $event['end_ui'] }}</time>

View File

@ -2,6 +2,8 @@
'grid' => [],
'calendars' => [],
'events' => [],
'all_day_events' => [],
'has_all_day' => false,
'class' => '',
'slots' => [],
'timeformat' => '',
@ -15,13 +17,17 @@
])
<section
class="calendar {{ $class }}" data-density="{{ $density['step'] }}" data-weekstart="{{ $weekstart }}"
style=
"--now-row: {{ (int) $now['row'] }};
@class(['calendar', $class, 'allday' => $has_all_day ?? false])
data-density="{{ $density['step'] }}"
data-weekstart="{{ $weekstart }}"
data-daytime-hours-enabled="{{ (int) ($daytime_hours['enabled'] ?? 0) }}"
style="
--now-row: {{ (int) $now['row'] }};
--now-offset: {{ $now['offset'] ?? 0 }};
--now-col-start: {{ (int) $now['col_start'] }};
--now-col-end: {{ (int) $now['col_end'] }};
--grid-rows: {{ $daytime_hours['rows'] ?? 96 }};"
--grid-rows: {{ $daytime_hours['rows'] ?? 96 }};
"
>
<hgroup>
@foreach ($hgroup as $h)
@ -46,12 +52,19 @@
{{ $h['day'] }}
</a>
</div>
@endforeach
@endforeach
</hgroup>
@if($has_all_day)
<ol class="day" aria-label="{{ __('calendar.event.all_day_events') }}">
@foreach ($all_day_events as $event)
<x-calendar.time.day-event :event="$event" />
@endforeach
</ol>
@endif
<ol class="time" aria-label="{{ __('Times') }}">
@foreach ($slots as $slot)
<li>
<time datetime="{{ $slot['iso'] }}">{{ $slot['label'] }}</time>
<time datetime="{{ $slot['iso'] }}" data-slot-minutes="{{ $slot['minutes'] }}">{{ $slot['label'] }}</time>
</li>
@endforeach
</ol>

View File

@ -1,3 +0,0 @@
<button {{ $attributes->merge(['type' => 'submit', 'class' => 'inline-flex items-center px-4 py-2 bg-red-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-red-500 active:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition ease-in-out duration-150']) }}>
{{ $slot }}
</button>

View File

@ -1,5 +0,0 @@
@props(['value'])
<label {{ $attributes->merge(['class' => 'block font-medium text-sm text-gray-700']) }}>
{{ $value ?? $slot }}
</label>

View File

@ -1,11 +1,11 @@
<dialog
hx-on:click="if(event.target === this) this.close()"
hx-on:close="document.getElementById('modal').innerHTML=''"
hx-on:close="const modal = document.getElementById('modal'); const isEvent = modal?.querySelector('[data-modal-kind=\'event\']'); const prevUrl = modal?.dataset?.prevUrl; modal.innerHTML=''; if (isEvent && prevUrl) history.replaceState({}, '', prevUrl);"
>
<div id="modal"
hx-target="this"
hx-on::before-swap="this.dataset.prevUrl = window.location.href"
hx-on::after-swap="this.closest('dialog')?.showModal()"
hx-swap="innerHTML">
</div>
</dialog>

View File

@ -1,3 +0,0 @@
<button {{ $attributes->merge(['type' => 'submit', 'class' => 'inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 focus:bg-gray-700 active:bg-gray-900 focus:outline-hidden focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition ease-in-out duration-150']) }}>
{{ $slot }}
</button>

View File

@ -1,3 +0,0 @@
<button {{ $attributes->merge(['type' => 'button', 'class' => 'inline-flex items-center px-4 py-2 bg-white border border-gray-300 rounded-md font-semibold text-xs text-gray-700 uppercase tracking-widest shadow-xs hover:bg-gray-50 focus:outline-hidden focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-25 transition ease-in-out duration-150']) }}>
{{ $slot }}
</button>

View File

@ -1,3 +0,0 @@
@props(['disabled' => false])
<input @disabled($disabled) {{ $attributes->merge(['class' => '']) }}>

View File

@ -16,124 +16,7 @@
<div class="py-6">
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white shadow-sm sm:rounded-lg p-6">
<form method="POST"
action="{{ $event->exists
? route('calendar.event.update', [$calendar, $event])
: route('calendar.event.store', $calendar) }}">
@csrf
@if($event->exists)
@method('PUT')
@endif
{{-- Title --}}
<div class="mb-6">
<x-input-label for="title" :value="__('Title')" />
<x-text-input id="title" name="title" type="text" class="mt-1 block w-full"
:value="old('title', $event->meta?->title ?? '')" required autofocus />
<x-input.error class="mt-2" :messages="$errors->get('title')" />
</div>
{{-- Description --}}
<div class="mb-6">
<x-input-label for="description" :value="__('Description')" />
<textarea id="description" name="description" rows="3"
class="mt-1 block w-full rounded-md shadow-xs border-gray-300 focus:border-indigo-300 focus:ring-3">{{ old('description', $event->meta?->description ?? '') }}</textarea>
<x-input.error class="mt-2" :messages="$errors->get('description')" />
</div>
{{-- Location --}}
<div class="mb-6">
<x-input-label for="location" :value="__('Location')" />
<x-text-input id="location"
name="location"
type="text"
class="mt-1 block w-full"
:value="old('location', $event->meta?->location ?? '')"
{{-- live suggestions via htmx --}}
hx-get="{{ route('location.suggest') }}"
hx-trigger="keyup changed delay:300ms"
hx-target="#location-suggestions"
hx-swap="innerHTML" />
{{-- suggestion dropdown target --}}
<div id="location-suggestions" class="relative z-20"></div>
{{-- hidden fields (filled when user clicks a suggestion; handy for step #2) --}}
<input type="hidden" id="loc_display_name" name="loc_display_name" />
<input type="hidden" id="loc_place_name" name="loc_place_name" />
<input type="hidden" id="loc_street" name="loc_street" />
<input type="hidden" id="loc_city" name="loc_city" />
<input type="hidden" id="loc_state" name="loc_state" />
<input type="hidden" id="loc_postal" name="loc_postal" />
<input type="hidden" id="loc_country" name="loc_country" />
<input type="hidden" id="loc_lat" name="loc_lat" />
<input type="hidden" id="loc_lon" name="loc_lon" />
<x-input.error class="mt-2" :messages="$errors->get('location')" />
</div>
{{-- Start / End --}}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
<div>
<x-input-label for="start_at" :value="__('Starts')" />
<x-text-input id="start_at" name="start_at" type="datetime-local"
class="mt-1 block w-full"
:value="old('start_at', $start)"
required />
<x-input.error class="mt-2" :messages="$errors->get('start_at')" />
</div>
<div>
<x-input-label for="end_at" :value="__('Ends')" />
<x-text-input id="end_at" name="end_at" type="datetime-local"
class="mt-1 block w-full"
:value="old('end_at', $end)"
required />
<x-input.error class="mt-2" :messages="$errors->get('end_at')" />
</div>
</div>
{{-- All-day --}}
<div class="flex items-center mb-6">
<input id="all_day" name="all_day" type="checkbox" value="1"
@checked(old('all_day', $event->meta?->all_day)) />
<label for="all_day" class="ms-2 text-sm text-gray-700">
{{ __('All day event') }}
</label>
</div>
{{-- Recurrence (advanced) --}}
<details class="mb-6">
<summary class="cursor-pointer text-sm text-gray-600">
{{ __('Repeat (advanced)') }}
</summary>
<div class="mt-3">
<x-input-label for="rrule" :value="__('RRULE')" />
<x-text-input id="rrule" name="rrule" type="text" class="mt-1 block w-full"
:value="old('rrule', $rrule ?? '')" />
<p class="mt-1 text-xs text-gray-500">
Example: <code>FREQ=WEEKLY;BYDAY=MO,WE</code>
</p>
<x-input.error class="mt-2" :messages="$errors->get('rrule')" />
</div>
</details>
{{-- Submit --}}
<div class="flex justify-end space-x-2">
<a href="{{ route('calendar.show', $calendar) }}"
class="inline-flex items-center px-4 py-2 bg-gray-200 rounded-md">
{{ __('Cancel') }}
</a>
<x-primary-button>
{{ $event->exists ? __('Save') : __('Create') }}
</x-primary-button>
</div>
</form>
@include('event.partials.form', compact('calendar', 'event', 'start', 'end', 'rrule'))
</div>
</div>
</div>

View File

@ -18,7 +18,7 @@
$map = $map ?? ['enabled' => false, 'needs_key' => false, 'url' => null];
@endphp
<x-modal.content :class="$map['enabled'] ? 'with-map' : null">
<x-modal.content data-modal-kind="event" :class="$map['enabled'] ? 'with-map' : null">
<x-modal.title class="gap-4">
<span class="inline-block h-4 w-4 rounded-full" style="background: {{ $event->color }};"></span>
<h2>{{ $title }}</h2>

View File

@ -0,0 +1,23 @@
<x-modal.content>
<x-modal.title>
<h2>{{ $event->exists ? __('Edit Event') : __('Create Event') }}</h2>
</x-modal.title>
<x-modal.body>
@include('event.partials.form', [
'calendar' => $calendar,
'event' => $event,
'start' => $start,
'end' => $end,
'rrule' => $rrule,
'isModal' => true,
])
</x-modal.body>
<x-modal.footer>
<x-button variant="secondary" onclick="this.closest('dialog')?.close()">
{{ __('common.cancel') }}
</x-button>
<x-button variant="primary" type="submit" form="event-form">
{{ $event->exists ? __('Save') : __('Create') }}
</x-button>
</x-modal.footer>
</x-modal.content>

View File

@ -0,0 +1,319 @@
@php
$isModal = $isModal ?? false;
$formAction = $event->exists
? route('calendar.event.update', [$calendar, $event])
: route('calendar.event.store', $calendar);
$rruleValue = trim((string) ($rrule ?? ''));
$rruleParts = [];
foreach (array_filter(explode(';', $rruleValue)) as $chunk) {
if (!str_contains($chunk, '=')) continue;
[$key, $value] = explode('=', $chunk, 2);
$rruleParts[strtoupper($key)] = $value;
}
$freq = strtolower($rruleParts['FREQ'] ?? '');
$interval = (int) ($rruleParts['INTERVAL'] ?? 1);
if ($interval < 1) $interval = 1;
$byday = array_filter(explode(',', $rruleParts['BYDAY'] ?? ''));
$bymonthday = array_filter(explode(',', $rruleParts['BYMONTHDAY'] ?? ''));
$bysetpos = $rruleParts['BYSETPOS'] ?? null;
$startDefault = old('start_at', $start ?? null);
$startDate = $startDefault ? \Carbon\Carbon::parse($startDefault) : \Carbon\Carbon::now();
$weekdayMap = [
'Sun' => 'SU',
'Mon' => 'MO',
'Tue' => 'TU',
'Wed' => 'WE',
'Thu' => 'TH',
'Fri' => 'FR',
'Sat' => 'SA',
];
$defaultWeekday = $weekdayMap[$startDate->format('D')] ?? 'MO';
$defaultMonthDay = (int) $startDate->format('j');
$weekMap = [1 => 'first', 2 => 'second', 3 => 'third', 4 => 'fourth'];
$startWeek = $startDate->copy();
$isLastWeek = $startWeek->copy()->addWeek()->month !== $startWeek->month;
$defaultMonthWeek = $isLastWeek ? 'last' : ($weekMap[$startDate->weekOfMonth] ?? 'first');
$monthMode = 'days';
if (!empty($bymonthday)) {
$monthMode = 'days';
} elseif (!empty($byday) && $bysetpos) {
$monthMode = 'weekday';
}
$repeatFrequency = old('repeat_frequency', $freq ?: '');
$repeatInterval = old('repeat_interval', $interval);
$repeatWeekdays = old('repeat_weekdays', $byday ?: [$defaultWeekday]);
$repeatMonthDays = old('repeat_month_days', $bymonthday ?: [$defaultMonthDay]);
$repeatMonthMode = old('repeat_monthly_mode', $monthMode);
$setposMap = ['1' => 'first', '2' => 'second', '3' => 'third', '4' => 'fourth', '-1' => 'last'];
$repeatMonthWeek = old('repeat_month_week', $setposMap[(string) $bysetpos] ?? $defaultMonthWeek);
$repeatMonthWeekday = old('repeat_month_weekday', $byday[0] ?? $defaultWeekday);
$rruleOptions = [
'daily' => __('calendar.event.recurrence.daily'),
'weekly' => __('calendar.event.recurrence.weekly'),
'monthly' => __('calendar.event.recurrence.monthly'),
'yearly' => __('calendar.event.recurrence.yearly'),
];
$weekdayOptions = [
'SU' => __('calendar.event.recurrence.weekdays.sun_short'),
'MO' => __('calendar.event.recurrence.weekdays.mon_short'),
'TU' => __('calendar.event.recurrence.weekdays.tue_short'),
'WE' => __('calendar.event.recurrence.weekdays.wed_short'),
'TH' => __('calendar.event.recurrence.weekdays.thu_short'),
'FR' => __('calendar.event.recurrence.weekdays.fri_short'),
'SA' => __('calendar.event.recurrence.weekdays.sat_short'),
];
$weekdayLong = [
'SU' => __('calendar.event.recurrence.weekdays.sun'),
'MO' => __('calendar.event.recurrence.weekdays.mon'),
'TU' => __('calendar.event.recurrence.weekdays.tue'),
'WE' => __('calendar.event.recurrence.weekdays.wed'),
'TH' => __('calendar.event.recurrence.weekdays.thu'),
'FR' => __('calendar.event.recurrence.weekdays.fri'),
'SA' => __('calendar.event.recurrence.weekdays.sat'),
];
@endphp
<form method="POST" id="event-form" action="{{ $formAction }}" class="settings modal">
@csrf
@if($event->exists)
@method('PUT')
@endif
{{-- Title --}}
<div class="input-row input-row--1">
<div class="input-cell">
<x-input.label for="title" :value="__('Title')" />
<x-input.text
id="title"
name="title"
type="text"
:value="old('title', $event->meta?->title ?? '')"
required
autofocus
/>
<x-input.error class="mt-2" :messages="$errors->get('title')" />
</div>
</div>
{{-- Description --}}
<div class="input-row input-row--1">
<div class="input-cell">
<x-input.label for="description" :value="__('Description')" />
<x-input.textarea
id="description"
name="description"
rows="3">{{ old('description', $event->meta?->description ?? '') }}</x-input.textarea>
<x-input.error :messages="$errors->get('description')" />
</div>
</div>
{{-- Location --}}
<div class="input-row input-row--1">
<div class="input-cell">
<x-input.label for="location" :value="__('Location')" />
<x-input.text
id="location"
name="location"
:value="old('location', $event->meta?->location ?? '')"
{{-- live suggestions via htmx --}}
hx-get="{{ route('location.suggest') }}"
hx-trigger="keyup changed delay:300ms"
hx-target="#location-suggestions"
hx-swap="innerHTML"
/>
<x-input.error :messages="$errors->get('location')" />
{{-- suggestion dropdown target --}}
<div id="location-suggestions" class="relative z-20"></div>
{{-- hidden fields (filled when user clicks a suggestion; handy for step #2) --}}
<input type="hidden" id="loc_display_name" name="loc_display_name" />
<input type="hidden" id="loc_place_name" name="loc_place_name" />
<input type="hidden" id="loc_street" name="loc_street" />
<input type="hidden" id="loc_city" name="loc_city" />
<input type="hidden" id="loc_state" name="loc_state" />
<input type="hidden" id="loc_postal" name="loc_postal" />
<input type="hidden" id="loc_country" name="loc_country" />
<input type="hidden" id="loc_lat" name="loc_lat" />
<input type="hidden" id="loc_lon" name="loc_lon" />
</div>
</div>
{{-- Start / End --}}
<div class="input-row input-row--1-1">
<div class="input-cell">
<x-input.label for="start_at" :value="__('Starts')" />
<x-input.text
id="start_at"
name="start_at"
type="datetime-local"
:value="old('start_at', $start)"
data-event-start
required
/>
<x-input.error :messages="$errors->get('start_at')" />
</div>
<div class="input-cell">
<x-input.label for="end_at" :value="__('Ends')" />
<x-input.text
id="end_at"
name="end_at"
type="datetime-local"
:value="old('end_at', $end)"
data-event-end
required
/>
<x-input.error :messages="$errors->get('end_at')" />
</div>
</div>
{{-- All-day --}}
<div class="input-row input-row--1">
<div class="input-cell">
<x-input.checkbox-label
label="{{ __('All day event') }}"
id="all_day"
name="all_day"
value="1"
data-all-day-toggle
:checked="(bool) old('all_day', $event->meta?->all_day)"
/>
</div>
</div>
{{-- Recurrence (advanced) --}}
<div class="input-row input-row--1">
<div class="input-cell">
<details>
<summary class="cursor-pointer text-sm text-gray-600">
{{ __('calendar.event.recurrence.label') }}
</summary>
<div class="mt-3">
<x-input.label for="repeat_frequency" :value="__('calendar.event.recurrence.frequency')" />
<x-input.select
id="repeat_frequency"
name="repeat_frequency"
:options="$rruleOptions"
:selected="$repeatFrequency"
:placeholder="__('calendar.event.recurrence.none')"
data-recurrence-frequency
/>
<x-input.error :messages="$errors->get('repeat_frequency')" />
<div class="mt-3 {{ $repeatFrequency === '' ? 'hidden' : '' }}" data-recurrence-interval>
<x-input.label for="repeat_interval" :value="__('calendar.event.recurrence.every')" />
<div class="flex items-center gap-2">
<x-input.text
id="repeat_interval"
name="repeat_interval"
type="number"
min="1"
max="365"
:value="$repeatInterval"
/>
<span class="text-sm text-gray-600" data-recurrence-unit></span>
</div>
</div>
<div class="mt-4 {{ $repeatFrequency !== 'weekly' ? 'hidden' : '' }}" data-recurrence-section="weekly">
<p class="text-sm text-gray-600">{{ __('calendar.event.recurrence.on_days') }}</p>
<div class="flex flex-wrap gap-2 mt-2">
@foreach ($weekdayOptions as $code => $label)
<label class="inline-flex items-center gap-2 text-sm">
<x-input.checkbox
name="repeat_weekdays[]"
value="{{ $code }}"
:checked="in_array($code, (array) $repeatWeekdays, true)"
/>
<span title="{{ $weekdayLong[$code] ?? $code }}">{{ $label }}</span>
</label>
@endforeach
</div>
</div>
<div class="mt-4 {{ $repeatFrequency !== 'monthly' ? 'hidden' : '' }}" data-recurrence-section="monthly">
<div class="flex flex-col gap-3">
<div class="flex items-center gap-4">
<x-input.radio-label
id="repeat_monthly_mode_days"
name="repeat_monthly_mode"
value="days"
label="{{ __('calendar.event.recurrence.on_days') }}"
:checked="$repeatMonthMode === 'days'"
data-monthly-mode
/>
<x-input.radio-label
id="repeat_monthly_mode_weekday"
name="repeat_monthly_mode"
value="weekday"
label="{{ __('calendar.event.recurrence.on_the') }}"
:checked="$repeatMonthMode === 'weekday'"
data-monthly-mode
/>
</div>
<div class="grid grid-cols-7 gap-2 {{ $repeatMonthMode !== 'days' ? 'hidden' : '' }}" data-monthly-days>
@for ($day = 1; $day <= 31; $day++)
<label class="inline-flex items-center gap-2 text-xs">
<x-input.checkbox
name="repeat_month_days[]"
value="{{ $day }}"
:checked="in_array($day, (array) $repeatMonthDays)"
/>
<span>{{ $day }}</span>
</label>
@endfor
</div>
<div class="flex items-center gap-3 {{ $repeatMonthMode !== 'weekday' ? 'hidden' : '' }}" data-monthly-weekday>
<x-input.select
id="repeat_month_week"
name="repeat_month_week"
:options="[
'first' => __('calendar.event.recurrence.week_order.first'),
'second' => __('calendar.event.recurrence.week_order.second'),
'third' => __('calendar.event.recurrence.week_order.third'),
'fourth' => __('calendar.event.recurrence.week_order.fourth'),
'last' => __('calendar.event.recurrence.week_order.last'),
]"
:selected="$repeatMonthWeek"
/>
<x-input.select
id="repeat_month_weekday"
name="repeat_month_weekday"
:options="$weekdayLong"
:selected="$repeatMonthWeekday"
/>
</div>
</div>
</div>
<div class="mt-4 text-sm text-gray-600 {{ $repeatFrequency !== 'yearly' ? 'hidden' : '' }}" data-recurrence-section="yearly">
{{ __('calendar.event.recurrence.yearly_hint') }}
</div>
</div>
</details>
</div>
</div>
{{-- Submit --}}
@if(!$isModal)
<div class="input-row input-row--actions input-row--start sticky-bottom">
<x-button type="anchor" variant="tertiary" href="{{ route('calendar.show', $calendar) }}">
{{ __('common.cancel') }}
</x-button>
<x-button variant="primary" type="submit">
{{ $event->exists ? __('Save') : __('Create') }}
</x-button>
</div>
@endif
</form>