Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/Livewire/Hydro/FloodAlerts.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ public function boot(NWSAlertsService $nwsAlertsService): void
/**
* Two-letter US state or territory code for the map panel.
*/
public string $stateCd = 'va';
public string $stateCd = 'wa';

/**
* All active national flood alerts, sorted by severity.
Expand Down
150 changes: 148 additions & 2 deletions app/Livewire/Hydro/StreamGauge.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace App\Livewire\Hydro;

use App\Api\Queries\WaterServicesQuery;
use App\Models\UsgsStation;
use App\Services\WaterServicesService;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
Expand All @@ -22,6 +23,9 @@
* The map (wire:ignore) survives re-renders — fresh site data is pushed via
* the 'stream-gauges-updated' browser event after each load.
*
* Deep-link: ?state=va&site=01646500 pre-selects a state and opens the
* sparkline panel for the given site on arrival. Used by Dashboard bookmark links.
*
* Note: loading all active sites for a large state (e.g. TX, CA) may be slow.
* The USGS IV API returns one time series per site per parameter — a state with
* 400 active stream gauges querying two parameters returns 800 time series entries.
Expand Down Expand Up @@ -66,7 +70,7 @@ public function boot(WaterServicesService $waterServicesService): void
/**
* Two-letter US state code used to scope the site query.
*/
public string $stateCd = 'va';
public string $stateCd = 'wa';

/**
* All active stream gauge sites for the selected state, grouped by site code.
Expand Down Expand Up @@ -97,12 +101,58 @@ public function boot(WaterServicesService $waterServicesService): void
*/
public ?string $error = null;

/**
* USGS site numbers that the authenticated user has bookmarked.
* Empty for guests. Refreshed after every save/unsave action.
*
* @var list<string>
*/
public array $savedSiteCodes = [];

/**
* Flash message shown after a save or unsave action.
*/
public ?string $saveMessage = null;

/**
* Whether the last save action succeeded (controls flash message colour).
*/
public bool $saveSuccess = false;

/**
* Load sites on initial mount.
*
* If ?state= and/or ?site= are present in the URL (deep-link from Dashboard),
* pre-select the state and open the sparkline panel for the given site.
*/
public function mount(): void
{
$this->loadSites();
$state = request()->query('state');
$site = request()->query('site');

if (is_string($state) && array_key_exists($state, self::US_STATES)) {
$this->stateCd = $state;
}

// Always fit bounds on initial mount so the map zooms to the active state.
$this->loadSites(fitBounds: true);
$this->loadSavedSiteCodes();

if (is_string($site) && $site !== '') {
$this->selectSite($site);

// Fly the map to the pre-selected site and open its popup.
$siteData = collect($this->sites ?? [])->firstWhere('site_code', $site);

if ($siteData) {
$this->dispatch(
'stream-gauge-preselect',
siteCode: $site,
lat: $siteData['lat'],
lng: $siteData['lng'],
);
}
}
}

/**
Expand All @@ -114,6 +164,7 @@ public function updatedStateCd(): void
{
$this->selectedSiteCode = null;
$this->sparklineData = null;
$this->saveMessage = null;
$this->loadSites(fitBounds: true);
}

Expand Down Expand Up @@ -141,6 +192,7 @@ public function selectSite(string $siteCode): void
$this->selectedSiteCode = $siteCode;
$this->sparklineData = null;
$this->error = null;
$this->saveMessage = null;

try {
// Cache sparkline data per site at 15 minutes — USGS readings arrive
Expand Down Expand Up @@ -172,6 +224,76 @@ function () use ($siteCode): array {
}
}

/**
* Save the currently-selected stream gauge to the user's Dashboard.
*
* Creates a UsgsStation record if one does not yet exist for this site code
* (using the name and coordinates already held in $sites). Capped at 30
* bookmarks per user — mirrors the 20-search cap on QuakeWatch.
*/
public function saveStation(): void
{
$user = auth()->user();

if (! $user || ! $this->selectedSiteCode) {
return;
}

if ($user->savedStations()->count() >= 30) {
$this->saveMessage = 'You have reached the 30 saved station limit. Delete one to add more.';
$this->saveSuccess = false;

return;
}

$siteData = collect($this->sites ?? [])->firstWhere('site_code', $this->selectedSiteCode);
$station = UsgsStation::firstOrCreate(
['site_no' => $this->selectedSiteCode],
[
'name' => $this->sparklineData['site_name'] ?? $this->selectedSiteCode,
'state' => null,
'site_type' => 'ST',
'latitude' => (float) ($siteData['lat'] ?? 0),
'longitude' => (float) ($siteData['lng'] ?? 0),
],
);

$user->savedStations()->firstOrCreate(
['station_id' => $station->id],
['state_cd' => $this->stateCd],
);

$this->loadSavedSiteCodes();
$this->saveMessage = 'Station saved to your dashboard.';
$this->saveSuccess = true;
}

/**
* Remove the currently-selected stream gauge from the user's saved stations.
*/
public function unsaveStation(): void
{
$user = auth()->user();

if (! $user || ! $this->selectedSiteCode) {
return;
}

$station = UsgsStation::where('site_no', $this->selectedSiteCode)->first();

if (! $station) {
return;
}

$user->savedStations()
->where('station_id', $station->id)
->delete();

$this->loadSavedSiteCodes();
$this->saveMessage = null;
$this->saveSuccess = false;
}

/**
* Render the StreamGauge component.
*/
Expand Down Expand Up @@ -245,6 +367,30 @@ function (): array {
$this->dispatch('stream-gauges-updated', sites: $this->sites ?? [], fitBounds: $fitBounds, stateCd: $this->stateCd);
}

/**
* Populate $savedSiteCodes for the authenticated user.
*
* Stores the set of USGS site numbers the user has bookmarked so the view
* can toggle between "Save" and "Saved ✓" without an extra query per render.
*/
private function loadSavedSiteCodes(): void
{
if (! auth()->check()) {
$this->savedSiteCodes = [];

return;
}

$this->savedSiteCodes = auth()->user()
->savedStations()
->with('station')
->get()
->pluck('station.site_no')
->filter()
->values()
->toArray();
}

/**
* Convert a WaterServicesData allValues array into Chart.js-ready labels + data arrays.
*
Expand Down
32 changes: 28 additions & 4 deletions app/Livewire/Pages/Dashboard.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace App\Livewire\Pages;

use App\Models\SavedEarthquakeSearch;
use App\Models\SavedStation;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\View\View;
use Livewire\Attributes\Layout;
Expand All @@ -30,11 +31,24 @@ public function deleteSearch(int $id): void
}

/**
* Render the Dashboard page.
* Remove a saved stream gauge station by saved_stations ID.
*
* Passes saved searches ordered newest-first.
* Verifies ownership before deleting so IDs cannot be spoofed.
*/
public function deleteStation(int $id): void
{
$record = SavedStation::find($id);

if ($record && $record->user_id === auth()->id()) {
$record->delete();
}
}

/**
* Render the Dashboard page.
*
* @param Collection<int, SavedEarthquakeSearch> $searches
* Passes saved earthquake searches and saved stream gauge stations,
* both ordered newest-first.
*/
public function render(): View
{
Expand All @@ -44,6 +58,16 @@ public function render(): View
->latest()
->get();

return view('livewire.pages.dashboard', ['searches' => $searches]);
/** @var Collection<int, SavedStation> $stations */
$stations = auth()->user()
->savedStations()
->with('station')
->latest()
->get();

return view('livewire.pages.dashboard', [
'searches' => $searches,
'stations' => $stations,
]);
}
}
1 change: 1 addition & 0 deletions app/Models/SavedStation.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class SavedStation extends Model
protected $fillable = [
'user_id',
'station_id',
'state_cd',
];

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class () extends Migration {
/**
* Add state_cd to saved_stations.
*
* Stores the two-letter lowercase state code selected in the StreamGauge
* component at the time the station was saved. Used to build the HydroWatch
* deep-link (?state=va&site=01646500) from the Dashboard without needing a
* separate lookup — usgs_stations.state is nullable for IV-API-sourced records.
*/
public function up(): void
{
Schema::table('saved_stations', function (Blueprint $table) {
$table->char('state_cd', 2)->nullable()->after('station_id');
});
}

/**
* Reverse the migration.
*/
public function down(): void
{
Schema::table('saved_stations', function (Blueprint $table) {
$table->dropColumn('state_cd');
});
}
};
30 changes: 29 additions & 1 deletion resources/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -613,8 +613,11 @@ document.addEventListener('alpine:init', () => {
window.Alpine.data('streamGaugeMap', ({ elementId, centerLat = 39.5, centerLng = -98.35, zoom = 4, initialSites = [] }) => ({
map: null,
markerLayer: null,
/** @type {Record<string, L.Marker>} Markers indexed by site_code for direct lookup. */
markers: {},
_sitesListener: null,
_resetListener: null,
_preselectListener: null,

init() {
this.map = L.map(elementId).setView([centerLat, centerLng], zoom);
Expand All @@ -624,7 +627,10 @@ document.addEventListener('alpine:init', () => {
maxZoom: 18,
}).addTo(this.map);

this.markerLayer = L.markerClusterGroup({ chunkedLoading: true });
this.markerLayer = L.markerClusterGroup({
chunkedLoading: true,
disableClusteringAtZoom: 10,
});
this.map.addLayer(this.markerLayer);

this.renderMarkers(initialSites);
Expand All @@ -637,8 +643,24 @@ document.addEventListener('alpine:init', () => {
this.map.flyTo([centerLat, centerLng], zoom, { duration: 0.8 });
};

// Deep-link: fly to a pre-selected site and open its popup.
// Fired by StreamGauge::mount() when ?site= is present in the URL.
// stream-gauges-updated fires first (renderMarkers runs), so markers
// are already stored in this.markers by the time this fires.
this._preselectListener = (e) => {
const { siteCode, lat, lng } = e.detail;
if (lat && lng) {
this.map.flyTo([lat, lng], 12, { duration: 0.8 });
}
this.map.once('moveend', () => {
const marker = this.markers[siteCode];
if (marker) marker.openPopup();
});
};

window.addEventListener('stream-gauges-updated', this._sitesListener);
window.addEventListener('stream-gauge-map-reset', this._resetListener);
window.addEventListener('stream-gauge-preselect', this._preselectListener);
},

/**
Expand All @@ -652,6 +674,7 @@ document.addEventListener('alpine:init', () => {
*/
renderMarkers(sites, fitBounds = false, stateCd = null) {
this.markerLayer.clearLayers();
this.markers = {};

sites.forEach((site) => {
if (! site.lat || ! site.lng) return;
Expand All @@ -678,6 +701,10 @@ document.addEventListener('alpine:init', () => {
const marker = L.marker([site.lat, site.lng], { icon });
marker.bindPopup(this.popupContent(site), { minWidth: 230 });
this.markerLayer.addLayer(marker);

if (site.site_code) {
this.markers[site.site_code] = marker;
}
});

if (fitBounds) {
Expand Down Expand Up @@ -775,6 +802,7 @@ document.addEventListener('alpine:init', () => {
destroy() {
window.removeEventListener('stream-gauges-updated', this._sitesListener);
window.removeEventListener('stream-gauge-map-reset', this._resetListener);
window.removeEventListener('stream-gauge-preselect', this._preselectListener);
if (this.map) {
this.map.remove();
this.map = null;
Expand Down
Loading
Loading