HydroWatch
- Active NWS flood watches, warnings, and advisories across the United States. Select a state to view alerts on the map.
+ Active NWS flood alerts and USGS stream gauges across the United States.
@@ -18,4 +18,9 @@
+
+ {{-- Back link --}}
+
+
+ {{-- Error state --}}
+ @if ($error)
+
+ {{ $error }}
+
+ @endif
+
+ @if ($stationMeta !== null)
+
+ {{-- Station header --}}
+
+
+
+
{{ $stationMeta['name'] }}
+
+ USGS Site {{ $stationMeta['site_no'] }}
+ @if ($stationMeta['state'])
+ · {{ $stationMeta['state'] }}
+ @endif
+
+
+
+ View on USGS ↗
+
+
+
+
+ {{-- Metadata grid --}}
+
+
+ @php
+ $siteTypeLabels = [
+ 'ST' => 'Stream',
+ 'ST-CA' => 'Canal',
+ 'ST-DCH' => 'Ditch',
+ 'ST-TS' => 'Tidal stream',
+ 'LK' => 'Lake / reservoir',
+ 'WE' => 'Wetland',
+ 'ES' => 'Estuary',
+ 'GW' => 'Groundwater well',
+ 'SB' => 'Subsurface',
+ 'SP' => 'Spring',
+ 'AT' => 'Atmosphere',
+ 'OC' => 'Ocean',
+ 'OC-CO' => 'Coastal ocean',
+ ];
+ @endphp
+
+
+
Site type
+
+ {{ $siteTypeLabels[$stationMeta['site_type']] ?? $stationMeta['site_type'] }}
+
+
+
+
+
State
+
{{ $stationMeta['state'] ?: '—' }}
+
+
+ @if ($stationMeta['huc'])
+
+
HUC
+
{{ $stationMeta['huc'] }}
+
+ @endif
+
+
+
Coordinates
+
+ {{ number_format((float) $stationMeta['latitude'], 4) }},
+ {{ number_format((float) $stationMeta['longitude'], 4) }}
+
+
+
+ @if ($stationMeta['elevation_ft'] !== null)
+
+
Elevation
+
+ {{ number_format((float) $stationMeta['elevation_ft'], 1) }} ft
+
+
+ @endif
+
+
+
+ {{-- 30-day charts --}}
+
+
+ {{-- Streamflow --}}
+ @if (! empty($streamflowChart['data']))
+
+
30-day streamflow
+
Discharge in ft³/s
+
+
+
+
+ @endif
+
+ {{-- Gage height --}}
+ @if (! empty($gageHeightChart['data']))
+
+
30-day gage height
+
Water level in feet
+
+
+
+
+ @endif
+
+ @if (empty($streamflowChart['data']) && empty($gageHeightChart['data']))
+
+
No readings available for the last 30 days.
+
+ @endif
+
+
+
+ {{-- Provisional data disclaimer --}}
+
+ Data sourced from the USGS National Water Information System. Readings marked P are provisional and subject to revision.
+
+
+ @endif
+
+
diff --git a/routes/web.php b/routes/web.php
index 5a426d8..6b437d3 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -10,6 +10,7 @@
use App\Livewire\Pages\Home;
use App\Livewire\Pages\HydroWatch;
use App\Livewire\Pages\QuakeWatch;
+use App\Livewire\Pages\StationDetail;
use App\Livewire\Pages\VolcanoWatch;
use Illuminate\Support\Facades\Route;
@@ -26,6 +27,7 @@
Route::get('/quake-watch', QuakeWatch::class)->name('quake-watch');
Route::get('/volcano-watch', VolcanoWatch::class)->name('volcano-watch');
Route::get('/hydro-watch', HydroWatch::class)->name('hydro-watch');
+Route::get('/hydro/station/{siteNo}', StationDetail::class)->name('hydro.station');
Route::get('/robots.txt', RobotsController::class)->name('robots');
/*
diff --git a/tests/Feature/Hydro/FloodAlertsTest.php b/tests/Feature/Hydro/FloodAlertsTest.php
index 203aa3f..c74f4dd 100644
--- a/tests/Feature/Hydro/FloodAlertsTest.php
+++ b/tests/Feature/Hydro/FloodAlertsTest.php
@@ -8,12 +8,24 @@
use App\Livewire\Hydro\FloodAlerts;
use App\Services\NWSAlertsService;
use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
use Tests\TestCase;
class FloodAlertsTest extends TestCase
{
+ /**
+ * Flush the in-memory array cache before each test so that cache entries
+ * warmed by earlier tests (e.g. nws.flood.alerts.national) don't bleed
+ * through and cause Http::fake() calls to be silently skipped.
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+ Cache::flush();
+ }
+
/**
* Component renders with default state and no errors.
*/
diff --git a/tests/Feature/Hydro/StreamGaugeTest.php b/tests/Feature/Hydro/StreamGaugeTest.php
index 6f494da..38f3a05 100644
--- a/tests/Feature/Hydro/StreamGaugeTest.php
+++ b/tests/Feature/Hydro/StreamGaugeTest.php
@@ -7,12 +7,24 @@
use App\Livewire\Hydro\StreamGauge;
use App\Services\WaterServicesService;
use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
use Tests\TestCase;
class StreamGaugeTest extends TestCase
{
+ /**
+ * Flush the in-memory array cache before each test so that cache entries
+ * warmed by earlier tests (e.g. usgs.water.sites.va) don't bleed through
+ * and cause Http::fake() calls to be silently skipped.
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+ Cache::flush();
+ }
+
/**
* Component renders without errors when the service returns sites.
*/
diff --git a/tests/Feature/Pages/StationDetailTest.php b/tests/Feature/Pages/StationDetailTest.php
new file mode 100644
index 0000000..2f03bbe
--- /dev/null
+++ b/tests/Feature/Pages/StationDetailTest.php
@@ -0,0 +1,99 @@
+mockService();
+
+ $response = $this->get('/hydro/station/01646500');
+
+ $response->assertOk();
+ }
+
+ /**
+ * Page returns 404 for a non-numeric site number.
+ */
+ public function test_page_returns_404_for_invalid_site_number(): void
+ {
+ $response = $this->get('/hydro/station/invalid-site');
+
+ $response->assertNotFound();
+ }
+
+ /**
+ * Successful mount populates stationMeta, streamflowChart, and gageHeightChart.
+ */
+ public function test_mount_populates_station_data_on_success(): void
+ {
+ $this->mockService();
+
+ Livewire::test(StationDetail::class, ['siteNo' => '01646500'])
+ ->assertSet('stationName', 'Potomac River near Washington DC')
+ ->assertSet('error', null)
+ ->assertSet('stationMeta', fn ($meta) => $meta !== null && $meta['site_no'] === '01646500')
+ ->assertSet('streamflowChart', fn ($chart) => ! empty($chart['data']))
+ ->assertSet('gageHeightChart', fn ($chart) => ! empty($chart['data']));
+ }
+
+ /**
+ * When the service throws, the error property is set and stationMeta stays null.
+ */
+ public function test_service_failure_sets_error_message(): void
+ {
+ $mock = $this->createMock(StationDetailService::class);
+ $mock->method('loadStation')->willThrowException(new RuntimeException('API error'));
+ $this->app->instance(StationDetailService::class, $mock);
+
+ Livewire::test(StationDetail::class, ['siteNo' => '01646500'])
+ ->assertSet('stationMeta', null)
+ ->assertSet('error', fn ($e) => str_contains($e, '01646500'));
+ }
+
+ /**
+ * Bind a StationDetailService mock that returns a realistic result.
+ */
+ private function mockService(): void
+ {
+ $station = UsgsStation::factory()->create([
+ 'site_no' => '01646500',
+ 'name' => 'Potomac River near Washington DC',
+ 'state' => 'VA',
+ 'site_type' => 'ST',
+ 'latitude' => 38.9495,
+ 'longitude' => -77.1228,
+ ]);
+
+ $mock = $this->createMock(StationDetailService::class);
+ $mock->method('loadStation')->willReturn([
+ 'station' => $station,
+ 'streamflow' => [
+ 'labels' => ['2025-04-12T12:00:00.000-04:00', '2025-04-13T12:00:00.000-04:00'],
+ 'data' => [1200.0, 1234.5],
+ ],
+ 'gage_height' => [
+ 'labels' => ['2025-04-12T12:00:00.000-04:00', '2025-04-13T12:00:00.000-04:00'],
+ 'data' => [4.50, 4.56],
+ ],
+ ]);
+
+ $this->app->instance(StationDetailService::class, $mock);
+ }
+}
diff --git a/tests/Feature/Services/StationDetailServiceTest.php b/tests/Feature/Services/StationDetailServiceTest.php
new file mode 100644
index 0000000..146b75b
--- /dev/null
+++ b/tests/Feature/Services/StationDetailServiceTest.php
@@ -0,0 +1,157 @@
+ Http::response($this->fakeApiResponse(), 200),
+ ]);
+
+ /** @var StationDetailService $service */
+ $service = $this->app->make(StationDetailService::class);
+ $result = $service->loadStation('01646500');
+
+ // Station record upserted — state and HUC are null because the IV API
+ // JSON format does not return site metadata (requires Site Service call).
+ $this->assertDatabaseHas('usgs_stations', [
+ 'site_no' => '01646500',
+ 'name' => 'Potomac River near Washington DC',
+ 'state' => null,
+ 'huc' => null,
+ ]);
+
+ // Readings persisted for both parameters
+ $this->assertDatabaseHas('station_readings', ['parameter_code' => '00060']);
+ $this->assertDatabaseHas('station_readings', ['parameter_code' => '00065']);
+
+ // Chart arrays are returned and non-empty
+ $this->assertNotEmpty($result['streamflow']['data']);
+ $this->assertNotEmpty($result['gage_height']['data']);
+ $this->assertSame('01646500', $result['station']->site_no);
+ }
+
+ /**
+ * Revisiting a station upserts without creating duplicate readings.
+ */
+ public function test_revisiting_station_does_not_duplicate_readings(): void
+ {
+ Http::fake([
+ '*' => Http::response($this->fakeApiResponse(), 200),
+ ]);
+
+ /** @var StationDetailService $service */
+ $service = $this->app->make(StationDetailService::class);
+
+ $service->loadStation('01646500');
+ $service->loadStation('01646500');
+
+ $this->assertDatabaseCount('station_readings', 4); // 2 parameters × 2 readings each
+ }
+
+ /**
+ * A non-successful API response throws a RuntimeException.
+ */
+ public function test_api_error_throws_runtime_exception(): void
+ {
+ Http::fake([
+ '*' => Http::response([], 503),
+ ]);
+
+ /** @var StationDetailService $service */
+ $service = $this->app->make(StationDetailService::class);
+
+ $this->expectException(RuntimeException::class);
+ $service->loadStation('01646500');
+ }
+
+ /**
+ * An empty timeSeries response throws a RuntimeException.
+ */
+ public function test_empty_time_series_throws_runtime_exception(): void
+ {
+ Http::fake([
+ '*' => Http::response(['value' => ['timeSeries' => []]], 200),
+ ]);
+
+ /** @var StationDetailService $service */
+ $service = $this->app->make(StationDetailService::class);
+
+ $this->expectException(RuntimeException::class);
+ $service->loadStation('99999999');
+ }
+
+ /**
+ * Build a WaterML-JSON fixture with expanded site metadata and two parameters.
+ *
+ * @return array