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:
parent
ef658d1c04
commit
baeb291db5
@ -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
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -75,6 +75,7 @@ class Location extends Model
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
|
||||
if ($changed) {
|
||||
$existing->save();
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
'density' => '30',
|
||||
'now' => [],
|
||||
'daytime_hours' => [],
|
||||
'timezone' => 'UTC',
|
||||
])
|
||||
|
||||
<section
|
||||
@ -40,7 +41,12 @@
|
||||
@endif
|
||||
</ol>
|
||||
<footer>
|
||||
<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>
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
'density' => '30',
|
||||
'now' => [],
|
||||
'daytime_hours' => [],
|
||||
'timezone' => 'UTC',
|
||||
])
|
||||
|
||||
<section
|
||||
@ -62,7 +63,12 @@
|
||||
@endif
|
||||
</ol>
|
||||
<footer>
|
||||
<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>
|
||||
|
||||
@ -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'] }}"
|
||||
|
||||
@ -30,7 +30,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"
|
||||
data-calendar="{{ $event['calendar_slug'] }}"
|
||||
>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user