Event modals now change the url for proper back handling; improvements to geocoding existing locations with no lat and lon; normalizes time based view footer

This commit is contained in:
Andrew Gioia 2026-02-11 09:18:27 -05:00
parent ef658d1c04
commit baeb291db5
Signed by: andrew
GPG Key ID: FC09694A000800C8
15 changed files with 267 additions and 87 deletions

View File

@ -24,6 +24,10 @@ GEOCODER_COUNTRY=USA
GEOCODER_CATEGORIES=POI,Address GEOCODER_CATEGORIES=POI,Address
ARCGIS_API_KEY= ARCGIS_API_KEY=
ARCGIS_STORE_RESULTS=true # set to false to not store results 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 PHP_CLI_SERVER_WORKERS=4

View File

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

View File

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

View File

@ -4,6 +4,10 @@ namespace App\Providers;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Blade; 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 class AppServiceProvider extends ServiceProvider
{ {
@ -22,5 +26,26 @@ class AppServiceProvider extends ServiceProvider
{ {
// Calendar form // Calendar form
Blade::component('calendars._form', '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

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

View File

@ -78,7 +78,7 @@ button,
> label, > label,
> button { > button {
@apply relative flex items-center justify-center h-full pl-3.5 pr-3 cursor-pointer; @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; transition: outline 125ms ease-in-out;
box-shadow: var(--shadows); box-shadow: var(--shadows);
--shadows: none; --shadows: none;

View File

@ -93,21 +93,6 @@
&[data-event-visible="9"] { &[data-event-visible="9"] {
.event:nth-child(n+10) { @apply hidden; } .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 */ /* events */
.event { .event {
@ -135,6 +120,28 @@
@apply hidden; @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;
}
}
} }
} }
} }

View File

@ -16,6 +16,7 @@ const SELECTORS = {
}; };
/** /**
*
* htmx/global * htmx/global
*/ */
@ -33,8 +34,10 @@ document.addEventListener('htmx:configRequest', (evt) => {
}) })
/** /**
*
* global auth expiry redirect (fetch/axios) * global auth expiry redirect (fetch/axios)
*/ */
const AUTH_REDIRECT_STATUSES = new Set([401, 419]); const AUTH_REDIRECT_STATUSES = new Set([401, 419]);
const redirectToLogin = () => { const redirectToLogin = () => {
if (window.location.pathname !== '/login') { if (window.location.pathname !== '/login') {
@ -67,9 +70,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) => { document.addEventListener('change', (event) => {
const target = event.target; const target = event.target;
@ -90,9 +95,24 @@ document.addEventListener('change', (event) => {
form.requestSubmit(); 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();
});
/** /**
*
* calendar sidebar expand toggle * calendar sidebar expand toggle
*/ */
document.addEventListener('click', (event) => { document.addEventListener('click', (event) => {
const toggle = event.target.closest(SELECTORS.calendarExpandToggle); const toggle = event.target.closest(SELECTORS.calendarExpandToggle);
if (!toggle) return; if (!toggle) return;
@ -107,9 +127,11 @@ document.addEventListener('click', (event) => {
}); });
/** /**
*
* color picker component * color picker component
* native <input type="color"> + hex + random palette) * native <input type="color"> + hex + random palette)
*/ */
function initColorPickers(root = document) { function initColorPickers(root = document) {
const isHex = (v) => /^#?[0-9a-fA-F]{6}$/.test((v || '').trim()); const isHex = (v) => /^#?[0-9a-fA-F]{6}$/.test((v || '').trim());
@ -198,8 +220,10 @@ function initColorPickers(root = document) {
} }
/** /**
*
* month view overflow handling (progressive enhancement) * month view overflow handling (progressive enhancement)
*/ */
function initMonthOverflow(root = document) { function initMonthOverflow(root = document) {
const days = root.querySelectorAll(SELECTORS.monthDay); const days = root.querySelectorAll(SELECTORS.monthDay);
days.forEach((day) => updateMonthOverflow(day)); days.forEach((day) => updateMonthOverflow(day));
@ -311,7 +335,28 @@ function updateMonthOverflow(dayEl) {
} }
} }
// 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;
const dayEl = button.closest(SELECTORS.monthDay);
if (!dayEl) return;
dayEl.classList.toggle('is-expanded');
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 * initialization
*/ */
@ -328,21 +373,3 @@ document.addEventListener('htmx:afterSwap', (e) => {
initColorPickers(e.target); initColorPickers(e.target);
initMonthOverflow(e.target); initMonthOverflow(e.target);
}); });
document.addEventListener('click', (event) => {
const button = event.target.closest(SELECTORS.monthDayMore);
if (!button) return;
const dayEl = button.closest(SELECTORS.monthDay);
if (!dayEl) return;
dayEl.classList.toggle('is-expanded');
updateMonthOverflow(dayEl);
});
let monthResizeTimer;
window.addEventListener('resize', () => {
if (!document.querySelector(SELECTORS.monthDay)) return;
window.clearTimeout(monthResizeTimer);
monthResizeTimer = window.setTimeout(() => initMonthOverflow(), 100);
});

View File

@ -95,11 +95,6 @@
</button> </button>
</h2> </h2>
<menu> <menu>
<li>
<a class="button button--icon" href="{{ route('calendar.settings') }}">
<x-icon-settings />
</a>
</li>
<li> <li>
<form id="calendar-nav" <form id="calendar-nav"
action="{{ route('calendar.index') }}" action="{{ route('calendar.index') }}"
@ -177,9 +172,13 @@
</x-button.group-input> </x-button.group-input>
<noscript><button type="submit" class="button">Apply</button></noscript> <noscript><button type="submit" class="button">Apply</button></noscript>
</form> </form>
<li> </li>
<a class="button button--primary" href="{{ route('calendar.create') }}"> <li class="gap-0 flex flex-row">
<x-icon-plus-circle /> Create <a class="button button--icon" href="{{ route('calendar.settings') }}">
<x-icon-settings />
</a>
<a class="button button--icon" href="{{ route('calendar.create') }}">
<x-icon-plus-circle />
</a> </a>
</li> </li>
</menu> </menu>
@ -216,6 +215,7 @@
:active="$active" :active="$active"
:density="$density" :density="$density"
:daytime_hours="$daytime_hours" :daytime_hours="$daytime_hours"
:timezone="$timezone"
:now="$now" :now="$now"
/> />
@break @break
@ -231,6 +231,7 @@
:active="$active" :active="$active"
:density="$density" :density="$density"
:daytime_hours="$daytime_hours" :daytime_hours="$daytime_hours"
:timezone="$timezone"
:now="$now" :now="$now"
/> />
@break @break

View File

@ -10,6 +10,7 @@
'density' => '30', 'density' => '30',
'now' => [], 'now' => [],
'daytime_hours' => [], 'daytime_hours' => [],
'timezone' => 'UTC',
]) ])
<section <section
@ -40,7 +41,12 @@
@endif @endif
</ol> </ol>
<footer> <footer>
<x-calendar.time.daytime-hours view="day" :density="$density" :daytime_hours="$daytime_hours" /> <div class="left">
<x-calendar.time.density view="day" :density="$density" :daytime_hours="$daytime_hours" /> <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> </footer>
</section> </section>

View File

@ -10,6 +10,7 @@
'density' => '30', 'density' => '30',
'now' => [], 'now' => [],
'daytime_hours' => [], 'daytime_hours' => [],
'timezone' => 'UTC',
]) ])
<section <section
@ -62,7 +63,12 @@
@endif @endif
</ol> </ol>
<footer> <footer>
<x-calendar.time.daytime-hours view="four" :density="$density" :daytime_hours="$daytime_hours" /> <div class="left">
<x-calendar.time.density view="four" :density="$density" :daytime_hours="$daytime_hours" /> <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> </footer>
</section> </section>

View File

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

View File

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

View File

@ -1,11 +1,11 @@
<dialog <dialog
hx-on:click="if(event.target === this) this.close()" 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" <div id="modal"
hx-target="this" hx-target="this"
hx-on::before-swap="this.dataset.prevUrl = window.location.href"
hx-on::after-swap="this.closest('dialog')?.showModal()" hx-on::after-swap="this.closest('dialog')?.showModal()"
hx-swap="innerHTML"> hx-swap="innerHTML">
</div> </div>
</dialog> </dialog>

View File

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