Add nightly town sync from ArcGIS API with autocomplete
All checks were successful
linter / quality (pull_request) Successful in 1m21s
security / Dependency Audit (pull_request) Successful in 1m25s
security / Static Analysis (pull_request) Successful in 1m49s
tests / ci (8.4) (pull_request) Successful in 1m23s
tests / ci (8.5) (pull_request) Successful in 1m27s
All checks were successful
linter / quality (pull_request) Successful in 1m21s
security / Dependency Audit (pull_request) Successful in 1m25s
security / Static Analysis (pull_request) Successful in 1m49s
tests / ci (8.4) (pull_request) Successful in 1m23s
tests / ci (8.5) (pull_request) Successful in 1m27s
- Add towns table with town_pid, town_name, state, population, town_class, date_retired - Add AustralianState enum with label and abbreviation helpers - Add Town model with active() and search() scopes - Add SyncTowns job that paginates ArcGIS API and upserts all 1977 towns - Schedule SyncTowns to run nightly at 02:00 - Add /towns/search endpoint returning JSON suggestions filtered by name and state - Add Alpine-powered autocomplete on origin/destination fields in create form - Add state filter dropdown in journeys card header to narrow autocomplete results
This commit is contained in:
43
app/Enums/AustralianState.php
Normal file
43
app/Enums/AustralianState.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum AustralianState: int
|
||||||
|
{
|
||||||
|
case NSW = 1;
|
||||||
|
case VIC = 2;
|
||||||
|
case QLD = 3;
|
||||||
|
case SA = 4;
|
||||||
|
case WA = 5;
|
||||||
|
case TAS = 6;
|
||||||
|
case NT = 7;
|
||||||
|
case ACT = 8;
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::NSW => 'New South Wales',
|
||||||
|
self::VIC => 'Victoria',
|
||||||
|
self::QLD => 'Queensland',
|
||||||
|
self::SA => 'South Australia',
|
||||||
|
self::WA => 'Western Australia',
|
||||||
|
self::TAS => 'Tasmania',
|
||||||
|
self::NT => 'Northern Territory',
|
||||||
|
self::ACT => 'Australian Capital Territory',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function abbreviation(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::NSW => 'NSW',
|
||||||
|
self::VIC => 'VIC',
|
||||||
|
self::QLD => 'QLD',
|
||||||
|
self::SA => 'SA',
|
||||||
|
self::WA => 'WA',
|
||||||
|
self::TAS => 'TAS',
|
||||||
|
self::NT => 'NT',
|
||||||
|
self::ACT => 'ACT',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
76
app/Jobs/SyncTowns.php
Normal file
76
app/Jobs/SyncTowns.php
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Town;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class SyncTowns implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
private const API_URL = 'https://services-ap1.arcgis.com/ypkPEy1AmwPKGNNv/arcgis/rest/services/Town_Point/FeatureServer/0/query';
|
||||||
|
|
||||||
|
private const PAGE_SIZE = 1000;
|
||||||
|
|
||||||
|
public int $timeout = 300;
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$offset = 0;
|
||||||
|
$synced = 0;
|
||||||
|
|
||||||
|
do {
|
||||||
|
$response = Http::timeout(30)->get(self::API_URL, [
|
||||||
|
'outFields' => 'town_pid,town_name,state,population,town_class,date_retired',
|
||||||
|
'where' => '1=1',
|
||||||
|
'f' => 'json',
|
||||||
|
'resultOffset' => $offset,
|
||||||
|
'resultRecordCount' => self::PAGE_SIZE,
|
||||||
|
'orderByFields' => 'objectid ASC',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
Log::error('SyncTowns: API request failed', ['offset' => $offset, 'status' => $response->status()]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$features = $response->json('features', []);
|
||||||
|
|
||||||
|
if (empty($features)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$records = collect($features)->map(fn (array $feature) => [
|
||||||
|
'town_pid' => $feature['attributes']['town_pid'],
|
||||||
|
'town_name' => $feature['attributes']['town_name'],
|
||||||
|
'state' => $feature['attributes']['state'],
|
||||||
|
'population' => $feature['attributes']['population'],
|
||||||
|
'town_class' => $feature['attributes']['town_class'],
|
||||||
|
'date_retired' => isset($feature['attributes']['date_retired'])
|
||||||
|
? Carbon::createFromTimestampMs($feature['attributes']['date_retired'])
|
||||||
|
: null,
|
||||||
|
'updated_at' => now(),
|
||||||
|
'created_at' => now(),
|
||||||
|
])->toArray();
|
||||||
|
|
||||||
|
Town::upsert($records, uniqueBy: ['town_pid'], update: [
|
||||||
|
'town_name',
|
||||||
|
'state',
|
||||||
|
'population',
|
||||||
|
'town_class',
|
||||||
|
'date_retired',
|
||||||
|
'updated_at',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$synced += count($features);
|
||||||
|
$offset += self::PAGE_SIZE;
|
||||||
|
} while (count($features) === self::PAGE_SIZE);
|
||||||
|
|
||||||
|
Log::info("SyncTowns: synced {$synced} towns.");
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/Models/Town.php
Normal file
41
app/Models/Town.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\AustralianState;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class Town extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\TownFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'town_pid',
|
||||||
|
'town_name',
|
||||||
|
'state',
|
||||||
|
'population',
|
||||||
|
'town_class',
|
||||||
|
'date_retired',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'state' => AustralianState::class,
|
||||||
|
'date_retired' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeActive(Builder $query): void
|
||||||
|
{
|
||||||
|
$query->whereNull('date_retired');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeSearch(Builder $query, string $term): void
|
||||||
|
{
|
||||||
|
$query->where('town_name', 'like', $term.'%');
|
||||||
|
}
|
||||||
|
}
|
||||||
23
database/factories/TownFactory.php
Normal file
23
database/factories/TownFactory.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Town>
|
||||||
|
*/
|
||||||
|
class TownFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
35
database/migrations/2026_03_06_041215_create_towns_table.php
Normal file
35
database/migrations/2026_03_06_041215_create_towns_table.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('towns', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('town_pid', 15)->unique();
|
||||||
|
$table->string('town_name', 50);
|
||||||
|
$table->unsignedTinyInteger('state');
|
||||||
|
$table->unsignedInteger('population')->nullable();
|
||||||
|
$table->unsignedTinyInteger('town_class')->nullable();
|
||||||
|
$table->timestamp('date_retired')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['town_name', 'state']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('towns');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -309,10 +309,42 @@ new #[Layout('components.layouts.app')] class extends Component {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Journeys --}}
|
{{-- Journeys --}}
|
||||||
<div class="card mb-4">
|
<div class="card mb-4" x-data="{
|
||||||
|
stateFilter: '',
|
||||||
|
suggestions: {},
|
||||||
|
open: {},
|
||||||
|
async searchTowns(key, query) {
|
||||||
|
if (query.length < 2) {
|
||||||
|
this.suggestions[key] = [];
|
||||||
|
this.open[key] = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams({ query });
|
||||||
|
if (this.stateFilter) params.append('state', this.stateFilter);
|
||||||
|
const res = await fetch(`/towns/search?${params}`, {
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
this.suggestions[key] = data;
|
||||||
|
this.open[key] = data.length > 0;
|
||||||
|
},
|
||||||
|
selectTown(key, wirePath, name) {
|
||||||
|
$wire.set(wirePath, name);
|
||||||
|
this.suggestions[key] = [];
|
||||||
|
this.open[key] = false;
|
||||||
|
}
|
||||||
|
}">
|
||||||
<div class="card-header fw-semibold d-flex justify-content-between align-items-center">
|
<div class="card-header fw-semibold d-flex justify-content-between align-items-center">
|
||||||
Journeys
|
<span>Journeys</span>
|
||||||
<button type="button" wire:click="addJourney" class="btn btn-sm btn-outline-primary">+ Add Journey</button>
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<select x-model="stateFilter" class="form-select form-select-sm" style="width: auto;" title="Filter town autocomplete by state">
|
||||||
|
<option value="">All states</option>
|
||||||
|
@foreach(\App\Enums\AustralianState::cases() as $state)
|
||||||
|
<option value="{{ $state->value }}">{{ $state->abbreviation() }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
<button type="button" wire:click="addJourney" class="btn btn-sm btn-outline-primary">+ Add Journey</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@error('journeys') <div class="alert alert-danger">{{ $message }}</div> @enderror
|
@error('journeys') <div class="alert alert-danger">{{ $message }}</div> @enderror
|
||||||
@@ -328,13 +360,53 @@ new #[Layout('components.layouts.app')] class extends Component {
|
|||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label class="form-label">Origin <span class="text-danger">*</span></label>
|
<label class="form-label">Origin <span class="text-danger">*</span></label>
|
||||||
<input type="text" wire:model="journeys.{{ $i }}.origin" class="form-control @error('journeys.'.$i.'.origin') is-invalid @enderror" placeholder="e.g. Perth">
|
<div class="position-relative">
|
||||||
@error('journeys.'.$i.'.origin') <div class="invalid-feedback">{{ $message }}</div> @enderror
|
<input
|
||||||
|
type="text"
|
||||||
|
wire:model="journeys.{{ $i }}.origin"
|
||||||
|
class="form-control @error('journeys.'.$i.'.origin') is-invalid @enderror"
|
||||||
|
placeholder="e.g. Perth"
|
||||||
|
autocomplete="off"
|
||||||
|
x-on:input.debounce.300ms="searchTowns('{{ $i }}-origin', $event.target.value)"
|
||||||
|
x-on:blur="$nextTick(() => open['{{ $i }}-origin'] = false)"
|
||||||
|
>
|
||||||
|
<ul class="dropdown-menu w-100" x-show="open['{{ $i }}-origin'] ?? false" style="z-index: 1055;">
|
||||||
|
<template x-for="s in (suggestions['{{ $i }}-origin'] ?? [])">
|
||||||
|
<li>
|
||||||
|
<button type="button" class="dropdown-item small"
|
||||||
|
x-text="s.label"
|
||||||
|
x-on:mousedown.prevent="selectTown('{{ $i }}-origin', 'journeys.{{ $i }}.origin', s.name)"
|
||||||
|
></button>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@error('journeys.'.$i.'.origin') <div class="invalid-feedback d-block">{{ $message }}</div> @enderror
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label class="form-label">Destination <span class="text-danger">*</span></label>
|
<label class="form-label">Destination <span class="text-danger">*</span></label>
|
||||||
<input type="text" wire:model="journeys.{{ $i }}.destination" class="form-control @error('journeys.'.$i.'.destination') is-invalid @enderror" placeholder="e.g. Sydney">
|
<div class="position-relative">
|
||||||
@error('journeys.'.$i.'.destination') <div class="invalid-feedback">{{ $message }}</div> @enderror
|
<input
|
||||||
|
type="text"
|
||||||
|
wire:model="journeys.{{ $i }}.destination"
|
||||||
|
class="form-control @error('journeys.'.$i.'.destination') is-invalid @enderror"
|
||||||
|
placeholder="e.g. Sydney"
|
||||||
|
autocomplete="off"
|
||||||
|
x-on:input.debounce.300ms="searchTowns('{{ $i }}-destination', $event.target.value)"
|
||||||
|
x-on:blur="$nextTick(() => open['{{ $i }}-destination'] = false)"
|
||||||
|
>
|
||||||
|
<ul class="dropdown-menu w-100" x-show="open['{{ $i }}-destination'] ?? false" style="z-index: 1055;">
|
||||||
|
<template x-for="s in (suggestions['{{ $i }}-destination'] ?? [])">
|
||||||
|
<li>
|
||||||
|
<button type="button" class="dropdown-item small"
|
||||||
|
x-text="s.label"
|
||||||
|
x-on:mousedown.prevent="selectTown('{{ $i }}-destination', 'journeys.{{ $i }}.destination', s.name)"
|
||||||
|
></button>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@error('journeys.'.$i.'.destination') <div class="invalid-feedback d-block">{{ $message }}</div> @enderror
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label">Date <span class="text-danger">*</span></label>
|
<label class="form-label">Date <span class="text-danger">*</span></label>
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\SyncTowns;
|
||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Foundation\Inspiring;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\Facades\Schedule;
|
||||||
|
|
||||||
Artisan::command('inspire', function () {
|
Artisan::command('inspire', function () {
|
||||||
$this->comment(Inspiring::quote());
|
$this->comment(Inspiring::quote());
|
||||||
})->purpose('Display an inspiring quote');
|
})->purpose('Display an inspiring quote');
|
||||||
|
|
||||||
|
Schedule::job(new SyncTowns)->dailyAt('02:00');
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Town;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
@@ -11,9 +13,34 @@ Route::post('/logout', function () {
|
|||||||
Auth::logout();
|
Auth::logout();
|
||||||
session()->invalidate();
|
session()->invalidate();
|
||||||
session()->regenerateToken();
|
session()->regenerateToken();
|
||||||
|
|
||||||
return redirect()->route('login');
|
return redirect()->route('login');
|
||||||
})->name('logout')->middleware('auth');
|
})->name('logout')->middleware('auth');
|
||||||
|
|
||||||
|
// Town autocomplete search
|
||||||
|
Route::middleware(['auth'])->get('/towns/search', function (Request $request) {
|
||||||
|
$query = $request->string('query')->trim();
|
||||||
|
|
||||||
|
if ($query->length() < 2) {
|
||||||
|
return response()->json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$towns = Town::active()
|
||||||
|
->search($query)
|
||||||
|
->when($request->filled('state'), fn ($q) => $q->where('state', $request->integer('state')))
|
||||||
|
->orderBy('town_name')
|
||||||
|
->limit(10)
|
||||||
|
->get(['town_name', 'state']);
|
||||||
|
|
||||||
|
return response()->json(
|
||||||
|
$towns->map(fn ($town) => [
|
||||||
|
'name' => $town->town_name,
|
||||||
|
'state' => $town->state->abbreviation(),
|
||||||
|
'label' => $town->town_name.', '.$town->state->abbreviation(),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
})->name('towns.search');
|
||||||
|
|
||||||
// Authenticated
|
// Authenticated
|
||||||
Route::middleware(['auth'])->group(function () {
|
Route::middleware(['auth'])->group(function () {
|
||||||
Route::livewire('/dashboard', 'dashboard')->name('dashboard');
|
Route::livewire('/dashboard', 'dashboard')->name('dashboard');
|
||||||
|
|||||||
Reference in New Issue
Block a user