diff --git a/.env.example b/.env.example index b73105f..4819adc 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/Jobs/GeocodeEventLocations.php b/app/Jobs/GeocodeEventLocations.php index e5ead19..7ea97a4 100644 --- a/app/Jobs/GeocodeEventLocations.php +++ b/app/Jobs/GeocodeEventLocations.php @@ -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); } } diff --git a/app/Models/Location.php b/app/Models/Location.php index 628e023..48dc5b9 100644 --- a/app/Models/Location.php +++ b/app/Models/Location.php @@ -75,6 +75,7 @@ class Location extends Model $changed = true; } + if ($changed) { $existing->save(); } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2bad5dc..e7aecab 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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(), + ]); + } + }); + } } } diff --git a/config/services.php b/config/services.php index f161af9..da93ed6 100644 --- a/config/services.php +++ b/config/services.php @@ -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), diff --git a/resources/css/lib/button.css b/resources/css/lib/button.css index 68c8b6c..7488ad7 100644 --- a/resources/css/lib/button.css +++ b/resources/css/lib/button.css @@ -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; diff --git a/resources/css/lib/calendar.css b/resources/css/lib/calendar.css index 6fc3a31..6f73979 100644 --- a/resources/css/lib/calendar.css +++ b/resources/css/lib/calendar.css @@ -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; + } + } } } } diff --git a/resources/js/app.js b/resources/js/app.js index 9aa727d..2c4dc36 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -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 + 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); -}); diff --git a/resources/views/calendar/index.blade.php b/resources/views/calendar/index.blade.php index c9b9fbb..e58d05b 100644 --- a/resources/views/calendar/index.blade.php +++ b/resources/views/calendar/index.blade.php @@ -95,11 +95,6 @@
@@ -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 diff --git a/resources/views/components/calendar/day/day.blade.php b/resources/views/components/calendar/day/day.blade.php index b08fcea..1305765 100644 --- a/resources/views/components/calendar/day/day.blade.php +++ b/resources/views/components/calendar/day/day.blade.php @@ -10,6 +10,7 @@ 'density' => '30', 'now' => [], 'daytime_hours' => [], + 'timezone' => 'UTC', ])