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

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

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

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

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

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;
}
}
}
}
}

View File

@ -16,6 +16,7 @@ const SELECTORS = {
};
/**
*
* htmx/global
*/
@ -33,8 +34,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 +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) => {
const target = event.target;
@ -90,9 +95,24 @@ 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();
});
/**
*
* calendar sidebar expand toggle
*/
document.addEventListener('click', (event) => {
const toggle = event.target.closest(SELECTORS.calendarExpandToggle);
if (!toggle) return;
@ -107,9 +127,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 +220,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,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
*/
@ -328,21 +373,3 @@ document.addEventListener('htmx:afterSwap', (e) => {
initColorPickers(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>
</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,9 +172,13 @@
</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>
<a class="button button--icon" href="{{ route('calendar.create') }}">
<x-icon-plus-circle />
</a>
</li>
</menu>
@ -216,6 +215,7 @@
:active="$active"
:density="$density"
:daytime_hours="$daytime_hours"
:timezone="$timezone"
:now="$now"
/>
@break
@ -231,6 +231,7 @@
:active="$active"
:density="$density"
:daytime_hours="$daytime_hours"
:timezone="$timezone"
:now="$now"
/>
@break

View File

@ -10,6 +10,7 @@
'density' => '30',
'now' => [],
'daytime_hours' => [],
'timezone' => 'UTC',
])
<section
@ -40,7 +41,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

@ -10,6 +10,7 @@
'density' => '30',
'now' => [],
'daytime_hours' => [],
'timezone' => 'UTC',
])
<section
@ -62,7 +63,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

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

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

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