diff --git a/app/Api/Queries/WaterServicesQuery.php b/app/Api/Queries/WaterServicesQuery.php index 3bf4eb0..a72aca9 100644 --- a/app/Api/Queries/WaterServicesQuery.php +++ b/app/Api/Queries/WaterServicesQuery.php @@ -56,6 +56,8 @@ class WaterServicesQuery private ?string $siteStatus = null; + private ?string $siteOutput = null; + private function __construct() { } @@ -191,6 +193,22 @@ public function siteType(string $siteType): self return $this; } + /** + * Control the amount of site metadata included in the response. + * + * Use 'expanded' to include `sourceInfo.siteProperty[]` — an array of key/value + * pairs containing state FIPS code, HUC, county FIPS code, and site type code. + * Defaults to 'default' (basic metadata only) when omitted. + * + * @param string $output Either 'default' or 'expanded'. + */ + public function siteOutput(string $output): self + { + $this->siteOutput = $output; + + return $this; + } + /** * Filter results to sites with the given operational status. * @@ -252,6 +270,7 @@ public function toArray(): array 'endDT' => $this->endDt?->toIso8601String(), 'siteType' => $this->siteType, 'siteStatus' => $this->siteStatus, + 'siteOutput' => $this->siteOutput, ], fn ($value) => $value !== null); } } diff --git a/app/Livewire/Pages/StationDetail.php b/app/Livewire/Pages/StationDetail.php new file mode 100644 index 0000000..425e375 --- /dev/null +++ b/app/Livewire/Pages/StationDetail.php @@ -0,0 +1,131 @@ +stationDetailService = $stationDetailService; + } + + /** + * USGS site number from the route parameter. + */ + public string $siteNo = ''; + + /** + * Station display name — used in the SEO slot. + */ + public string $stationName = 'Station Detail'; + + /** + * Station metadata for the view. + * + * @var array<string, mixed>|null + */ + public ?array $stationMeta = null; + + /** + * Chart.js-ready streamflow series for the 30-day chart. + * + * @var array{labels: list<string|null>, data: list<float|null>}|null + */ + public ?array $streamflowChart = null; + + /** + * Chart.js-ready gage height series for the 30-day chart. + * + * @var array{labels: list<string|null>, data: list<float|null>}|null + */ + public ?array $gageHeightChart = null; + + /** + * User-facing error message shown when the API call fails. + */ + public ?string $error = null; + + /** + * Load station data from the service and populate page properties. + * + * @param string $siteNo USGS site number from the route segment (e.g. '01646500'). + */ + public function mount(string $siteNo): void + { + if (! preg_match('/^\d{1,15}$/', $siteNo)) { + abort(404); + } + + $this->siteNo = $siteNo; + + try { + $result = $this->stationDetailService->loadStation($siteNo); + + $station = $result['station']; + $this->stationName = $station->name; + + $this->stationMeta = [ + 'site_no' => $station->site_no, + 'name' => $station->name, + 'state' => $station->state, + 'county' => $station->county, + 'huc' => $station->huc, + 'site_type' => $station->site_type, + 'latitude' => $station->latitude, + 'longitude' => $station->longitude, + 'elevation_ft' => $station->elevation_ft, + 'is_active' => $station->is_active, + ]; + + $this->streamflowChart = $result['streamflow']; + $this->gageHeightChart = $result['gage_height']; + } catch (Throwable $e) { + Log::error('StationDetail: failed to load site', [ + 'site_no' => $siteNo, + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + $this->error = 'Unable to load data for station ' . $siteNo . '. The site may not exist or the USGS API is temporarily unavailable.'; + } + } + + /** + * Render the station detail page. + */ + public function render(): View + { + return view('livewire.pages.station-detail'); + } +} diff --git a/app/Models/UsgsStation.php b/app/Models/UsgsStation.php index cb27cc5..95fd24f 100644 --- a/app/Models/UsgsStation.php +++ b/app/Models/UsgsStation.php @@ -4,11 +4,15 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; class UsgsStation extends Model { + /** @use HasFactory<\Database\Factories\UsgsStationFactory> */ + use HasFactory; + /** * @var list<string> */ diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index dd7add5..9e946d4 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -10,6 +10,7 @@ use App\Api\USGSWaterServices; use App\Services\EarthquakeService; use App\Services\NWSAlertsService; +use App\Services\StationDetailService; use App\Services\VolcanoService; use App\Services\WaterServicesService; use Illuminate\Support\Facades\URL; @@ -30,6 +31,7 @@ public function register(): void $this->app->singleton(VolcanoService::class); $this->app->singleton(WaterServicesService::class); $this->app->singleton(NWSAlertsService::class); + $this->app->singleton(StationDetailService::class); } /** diff --git a/app/Services/StationDetailService.php b/app/Services/StationDetailService.php new file mode 100644 index 0000000..7978e97 --- /dev/null +++ b/app/Services/StationDetailService.php @@ -0,0 +1,222 @@ +<?php + +declare(strict_types=1); + +namespace App\Services; + +use App\Api\Queries\WaterServicesQuery; +use App\Api\USGSWaterServices; +use App\Models\StationReading; +use App\Models\UsgsStation; +use Carbon\Carbon; +use RuntimeException; + +/** + * Service for loading, persisting, and returning USGS station detail data. + * + * On each call to loadStation(), the service: + * 1. Fetches 30 days of readings from the USGS IV API. + * 2. Upserts the station record in usgs_stations (creating it on first visit). + * 3. Bulk-upserts readings into station_readings — the unique constraint on + * (station_id, parameter_code, recorded_at) prevents duplicate ingestion. + * 4. Returns the station model and chart-ready series arrays for the page component. + * + * The DB accumulates readings over time as users visit the page. Eventually the DB + * will hold more than 30 days of history — querying the DB for the chart instead of + * the live API is a planned future enhancement. + * + * Note: the USGS IV API JSON format does not return site metadata (state, HUC, + * county). Those fields require a separate call to the USGS Site Service + * (/nwis/site/) and are stored as null until that integration is added. + */ +class StationDetailService +{ + /** + * @param USGSWaterServices $client Raw USGS NWIS IV API client. + */ + public function __construct(private readonly USGSWaterServices $client) + { + } + + /** + * Load 30 days of readings for the given USGS site number, persist to DB, and + * return chart-ready arrays for the station detail page. + * + * @param string $siteNo USGS site number (e.g. '01646500'). + * @return array{ + * station: UsgsStation, + * streamflow: array{labels: list<string|null>, data: list<float|null>}, + * gage_height: array{labels: list<string|null>, data: list<float|null>}, + * } + * + * @throws RuntimeException If the API returns an error or no data for the site. + */ + public function loadStation(string $siteNo): array + { + $response = $this->client->instantaneousValues( + WaterServicesQuery::make() + ->sites([$siteNo]) + ->parameterCd(['00060', '00065']) + ->period('P30D'), + ); + + if (! $response->successful()) { + throw new RuntimeException('The USGS Water Services API returned an error.'); + } + + /** @var array<int, array<string, mixed>> $timeSeries */ + $timeSeries = $response->json('value.timeSeries', []); + + if (empty($timeSeries)) { + throw new RuntimeException("No USGS data found for site {$siteNo}."); + } + + $sourceInfo = $timeSeries[0]['sourceInfo'] ?? []; + $station = $this->upsertStation($siteNo, $sourceInfo); + + $streamflow = ['labels' => [], 'data' => []]; + $gageHeight = ['labels' => [], 'data' => []]; + + foreach ($timeSeries as $ts) { + $paramCode = (string) ($ts['variable']['variableCode'][0]['value'] ?? ''); + $paramName = html_entity_decode( + (string) ($ts['variable']['variableName'] ?? ''), + ENT_QUOTES | ENT_HTML5, + ); + $unitCode = (string) ($ts['variable']['unit']['unitCode'] ?? ''); + $noDataVal = (float) ($ts['variable']['noDataValue'] ?? -999999); + + /** @var array<int, array<string, mixed>> $rawValues */ + $rawValues = $ts['values'][0]['value'] ?? []; + + [$chartLabels, $chartData, $toInsert] = $this->parseReadings( + $rawValues, + $noDataVal, + $station->id, + $paramCode, + $paramName, + $unitCode, + ); + + $this->persistReadings($toInsert); + + if ($paramCode === '00060') { + $streamflow = ['labels' => $chartLabels, 'data' => $chartData]; + } elseif ($paramCode === '00065') { + $gageHeight = ['labels' => $chartLabels, 'data' => $chartData]; + } + } + + return [ + 'station' => $station, + 'streamflow' => $streamflow, + 'gage_height' => $gageHeight, + ]; + } + + /** + * Upsert a usgs_stations record from the IV API sourceInfo block. + * + * The IV API JSON format does not include site metadata such as state or HUC, + * so those fields are stored as null. Site name and coordinates are always + * available in the sourceInfo block. + * + * @param string $siteNo USGS site number. + * @param array<string, mixed> $sourceInfo sourceInfo block from the WaterML-JSON response. + */ + private function upsertStation(string $siteNo, array $sourceInfo): UsgsStation + { + $geoLoc = $sourceInfo['geoLocation']['geogLocation'] ?? []; + + UsgsStation::updateOrCreate( + ['site_no' => $siteNo], + [ + 'name' => (string) ($sourceInfo['siteName'] ?? $siteNo), + 'state' => null, + 'county' => null, + 'huc' => null, + 'site_type' => 'ST', + 'latitude' => (float) ($geoLoc['latitude'] ?? 0), + 'longitude' => (float) ($geoLoc['longitude'] ?? 0), + 'elevation_ft' => null, + 'is_active' => true, + ], + ); + + return UsgsStation::where('site_no', $siteNo)->firstOrFail(); + } + + /** + * Parse a WaterML-JSON value array into chart-ready and DB-ready structures. + * + * @param array<int, array<string, mixed>> $rawValues Raw readings array from the API. + * @param float $noDataVal Sentinel value for missing data. + * @param int $stationId FK for station_readings. + * @param string $paramCode USGS parameter code. + * @param string $paramName Human-readable parameter name. + * @param string $unitCode Unit abbreviation. + * @return array{ + * 0: list<string|null>, + * 1: list<float|null>, + * 2: list<array<string, mixed>>, + * } [chartLabels, chartData, rowsToInsert] + */ + private function parseReadings( + array $rawValues, + float $noDataVal, + int $stationId, + string $paramCode, + string $paramName, + string $unitCode, + ): array { + $chartLabels = []; + $chartData = []; + $toInsert = []; + + foreach ($rawValues as $r) { + $raw = (float) ($r['value'] ?? $noDataVal); + $value = $raw !== $noDataVal ? $raw : null; + $dateTime = ($r['dateTime'] ?? '') ?: null; + $qualifier = (string) ($r['qualifiers'][0] ?? ''); + + $chartLabels[] = $dateTime; + $chartData[] = $value; + + if ($value !== null && $dateTime !== null) { + $toInsert[] = [ + 'station_id' => $stationId, + 'parameter_code' => $paramCode, + 'parameter_name' => $paramName, + 'value' => $value, + 'unit' => $unitCode, + 'qualifier' => $qualifier !== '' ? $qualifier : null, + 'recorded_at' => Carbon::parse($dateTime)->toDateTimeString(), + 'created_at' => now()->toDateTimeString(), + 'updated_at' => now()->toDateTimeString(), + ]; + } + } + + return [$chartLabels, $chartData, $toInsert]; + } + + /** + * Bulk-upsert readings into station_readings in 500-row chunks. + * + * The unique constraint on (station_id, parameter_code, recorded_at) prevents + * duplicate ingestion — re-visiting a station page is idempotent for already-stored + * readings and only inserts genuinely new observations. + * + * @param array<int, array<string, mixed>> $rows Rows prepared by parseReadings(). + */ + private function persistReadings(array $rows): void + { + foreach (array_chunk($rows, 500) as $chunk) { + StationReading::upsert( + $chunk, + ['station_id', 'parameter_code', 'recorded_at'], + ['value', 'unit', 'qualifier', 'updated_at'], + ); + } + } +} diff --git a/database/factories/UsgsStationFactory.php b/database/factories/UsgsStationFactory.php new file mode 100644 index 0000000..b6df410 --- /dev/null +++ b/database/factories/UsgsStationFactory.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); + +namespace Database\Factories; + +use Illuminate\Database\Eloquent\Factories\Factory; + +/** + * @extends Factory<\App\Models\UsgsStation> + */ +class UsgsStationFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array<string, mixed> + */ + public function definition(): array + { + return [ + 'site_no' => $this->faker->unique()->numerify('0########'), + 'name' => $this->faker->city() . ' River at ' . $this->faker->city(), + 'state' => strtoupper($this->faker->stateAbbr()), + 'county' => null, + 'huc' => null, + 'site_type' => 'ST', + 'latitude' => (float) $this->faker->latitude(24.0, 49.0), + 'longitude' => (float) $this->faker->longitude(-125.0, -66.0), + 'elevation_ft' => null, + 'is_active' => true, + ]; + } +} diff --git a/database/migrations/2026_04_23_140444_make_state_nullable_on_usgs_stations.php b/database/migrations/2026_04_23_140444_make_state_nullable_on_usgs_stations.php new file mode 100644 index 0000000..ac28d17 --- /dev/null +++ b/database/migrations/2026_04_23_140444_make_state_nullable_on_usgs_stations.php @@ -0,0 +1,33 @@ +<?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 { + /** + * Make usgs_stations.state nullable. + * + * State is not available from the USGS IV (instantaneous values) API — it + * requires a separate call to the USGS Site Service. Until that is wired up, + * the column is null for stations upserted via the detail page. + */ + public function up(): void + { + Schema::table('usgs_stations', function (Blueprint $table) { + $table->char('state', 2)->nullable()->change(); + }); + } + + /** + * Reverse the migration. + */ + public function down(): void + { + Schema::table('usgs_stations', function (Blueprint $table) { + $table->char('state', 2)->nullable(false)->change(); + }); + } +}; diff --git a/docs/data-model.md b/docs/data-model.md index 77fd5d3..fc58663 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -56,7 +56,7 @@ USGS monitoring locations. Shared by both water level and streamflow readings | `id` | `bigint unsigned` | Primary key | | `site_no` | `varchar(20)` | Unique. USGS site number, e.g. `09380000` (Colorado River at Lee Ferry). | | `name` | `varchar(255)` | Station name as returned by USGS | -| `state` | `char(2)` | State abbreviation, e.g. `AZ` | +| `state` | `char(2)` | Nullable. State abbreviation, e.g. `AZ`. Null for stations upserted via the detail page — requires a separate USGS Site Service call to populate. | | `county` | `varchar(255)` | Nullable | | `huc` | `varchar(16)` | Nullable. Hydrologic unit code — identifies the watershed. | | `site_type` | `varchar(10)` | USGS site type code: `ST` (stream), `LK` (lake), `GW` (groundwater), etc. | diff --git a/resources/js/app.js b/resources/js/app.js index 55a5c01..cb3d178 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -746,13 +746,19 @@ document.addEventListener('alpine:init', () => { <td style="text-align:right;font-weight:600;color:#111827;">${esc(site.site_code)}</td> </tr> </table> - <div style="margin-top:10px;"> + <div style="margin-top:10px;display:flex;flex-direction:column;gap:6px;"> <button onclick="window.dispatchEvent(new CustomEvent('stream-gauge-selected', { detail: { siteCode: '${esc(site.site_code)}' } }))" style="width:100%;padding:5px 0;background:${this.cssVar('--color-accent')};color:#fff;border:none;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;" > Load 3-day chart </button> + <a + href="/hydro/station/${esc(site.site_code)}" + style="display:block;width:100%;padding:5px 0;border:1px solid #d1d5db;border-radius:6px;font-size:12px;font-weight:600;color:#374151;text-align:center;text-decoration:none;box-sizing:border-box;" + > + View details → + </a> </div> ${site.is_provisional ? '<div style="margin-top:6px;font-size:11px;color:#9ca3af;">P — Provisional data</div>' : ''} </div> diff --git a/resources/views/livewire/pages/hydro-watch.blade.php b/resources/views/livewire/pages/hydro-watch.blade.php index 33d94f9..dbfd255 100644 --- a/resources/views/livewire/pages/hydro-watch.blade.php +++ b/resources/views/livewire/pages/hydro-watch.blade.php @@ -1,7 +1,7 @@ <x-slot:seo> <x-seo - title="HydroWatch — Flood Alerts" - description="Active NWS flood alerts across the United States. Browse current flood watches, warnings, and advisories by state with interactive maps." + title="HydroWatch — Flood Alerts & Stream Gauges" + description="Active NWS flood alerts and USGS stream gauge readings across the United States. Browse flood watches and warnings by state, and track real-time streamflow and gage height." :canonical="url('/hydro-watch')" /> </x-slot:seo> @@ -10,7 +10,7 @@ <div class="mb-6"> <h1 class="text-2xl font-semibold text-text">HydroWatch</h1> <p class="mt-1 text-sm text-muted"> - 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. </p> </div> @@ -18,4 +18,9 @@ <div class="mb-10"> @livewire('hydro.flood-alerts') </div> + + {{-- Stream gauges map + sparkline panel --}} + <div> + @livewire('hydro.stream-gauge') + </div> </div> diff --git a/resources/views/livewire/pages/station-detail.blade.php b/resources/views/livewire/pages/station-detail.blade.php new file mode 100644 index 0000000..3e6feef --- /dev/null +++ b/resources/views/livewire/pages/station-detail.blade.php @@ -0,0 +1,182 @@ +<x-slot:seo> + <x-seo + title="{{ $stationName }} — CronosPulse" + description="30-day streamflow and gage height history for USGS station {{ $siteNo }} — {{ $stationName }}." + :canonical="url('/hydro/station/' . $siteNo)" + /> +</x-slot:seo> + +<div> + + {{-- Back link --}} + <div class="mb-4"> + <a + href="{{ route('hydro-watch') }}" + class="inline-flex items-center gap-1.5 text-sm text-accent hover:underline" + > + ← Back to HydroWatch + </a> + </div> + + {{-- Error state --}} + @if ($error) + <div class="rounded-lg border border-[var(--color-danger)]/30 bg-[var(--color-danger)]/10 px-4 py-3 text-sm text-danger"> + {{ $error }} + </div> + @endif + + @if ($stationMeta !== null) + + {{-- Station header --}} + <div class="mb-6"> + <div class="flex flex-wrap items-start justify-between gap-3"> + <div> + <h1 class="text-2xl font-semibold text-text">{{ $stationMeta['name'] }}</h1> + <p class="mt-1 text-sm text-muted"> + USGS Site {{ $stationMeta['site_no'] }} + @if ($stationMeta['state']) + · {{ $stationMeta['state'] }} + @endif + </p> + </div> + <a + href="https://waterdata.usgs.gov/monitoring-location/{{ $stationMeta['site_no'] }}/" + target="_blank" + rel="noopener noreferrer" + class="shrink-0 rounded-lg border border-border bg-surface px-3 py-2 text-sm font-medium text-text transition hover:bg-surface-hover" + > + View on USGS ↗ + </a> + </div> + </div> + + {{-- Metadata grid --}} + <div class="mb-8 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5"> + + @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 + + <div class="rounded-xl border border-border bg-surface p-4"> + <p class="mb-0.5 text-xs font-semibold uppercase tracking-wider text-muted">Site type</p> + <p class="text-sm font-medium text-text"> + {{ $siteTypeLabels[$stationMeta['site_type']] ?? $stationMeta['site_type'] }} + </p> + </div> + + <div class="rounded-xl border border-border bg-surface p-4"> + <p class="mb-0.5 text-xs font-semibold uppercase tracking-wider text-muted">State</p> + <p class="text-sm font-medium text-text">{{ $stationMeta['state'] ?: '—' }}</p> + </div> + + @if ($stationMeta['huc']) + <div class="rounded-xl border border-border bg-surface p-4"> + <p class="mb-0.5 text-xs font-semibold uppercase tracking-wider text-muted">HUC</p> + <p class="font-mono text-sm font-medium text-text">{{ $stationMeta['huc'] }}</p> + </div> + @endif + + <div class="rounded-xl border border-border bg-surface p-4"> + <p class="mb-0.5 text-xs font-semibold uppercase tracking-wider text-muted">Coordinates</p> + <p class="font-mono text-sm font-medium text-text"> + {{ number_format((float) $stationMeta['latitude'], 4) }}, + {{ number_format((float) $stationMeta['longitude'], 4) }} + </p> + </div> + + @if ($stationMeta['elevation_ft'] !== null) + <div class="rounded-xl border border-border bg-surface p-4"> + <p class="mb-0.5 text-xs font-semibold uppercase tracking-wider text-muted">Elevation</p> + <p class="font-mono text-sm font-medium text-text"> + {{ number_format((float) $stationMeta['elevation_ft'], 1) }} ft + </p> + </div> + @endif + + </div> + + {{-- 30-day charts --}} + <div class="space-y-6"> + + {{-- Streamflow --}} + @if (! empty($streamflowChart['data'])) + <div class="rounded-xl border border-border bg-surface p-5"> + <p class="mb-1 text-xs font-semibold uppercase tracking-wider text-muted">30-day streamflow</p> + <p class="mb-4 text-sm text-muted">Discharge in ft³/s</p> + <div + wire:key="station-streamflow-{{ $siteNo }}" + wire:ignore + x-data="lineChart({ + labels: {{ Js::from($streamflowChart['labels']) }}, + datasets: [{ + label: 'Streamflow (ft³/s)', + data: {{ Js::from($streamflowChart['data']) }}, + borderColor: getComputedStyle(document.documentElement).getPropertyValue('--color-accent').trim(), + backgroundColor: 'transparent', + pointRadius: 0, + tension: 0.3, + borderWidth: 2, + }] + })" + > + <canvas x-ref="canvas"></canvas> + </div> + </div> + @endif + + {{-- Gage height --}} + @if (! empty($gageHeightChart['data'])) + <div class="rounded-xl border border-border bg-surface p-5"> + <p class="mb-1 text-xs font-semibold uppercase tracking-wider text-muted">30-day gage height</p> + <p class="mb-4 text-sm text-muted">Water level in feet</p> + <div + wire:key="station-gage-{{ $siteNo }}" + wire:ignore + x-data="lineChart({ + labels: {{ Js::from($gageHeightChart['labels']) }}, + datasets: [{ + label: 'Gage height (ft)', + data: {{ Js::from($gageHeightChart['data']) }}, + borderColor: getComputedStyle(document.documentElement).getPropertyValue('--color-info').trim(), + backgroundColor: 'transparent', + pointRadius: 0, + tension: 0.3, + borderWidth: 2, + }] + })" + > + <canvas x-ref="canvas"></canvas> + </div> + </div> + @endif + + @if (empty($streamflowChart['data']) && empty($gageHeightChart['data'])) + <div class="rounded-xl border border-border bg-surface p-8 text-center"> + <p class="text-sm text-muted">No readings available for the last 30 days.</p> + </div> + @endif + + </div> + + {{-- Provisional data disclaimer --}} + <p class="mt-6 text-xs text-muted"> + Data sourced from the USGS National Water Information System. Readings marked P are provisional and subject to revision. + </p> + + @endif + +</div> 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 @@ +<?php + +declare(strict_types=1); + +namespace Tests\Feature\Pages; + +use App\Livewire\Pages\StationDetail; +use App\Models\UsgsStation; +use App\Services\StationDetailService; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Livewire\Livewire; +use RuntimeException; +use Tests\TestCase; + +class StationDetailTest extends TestCase +{ + use RefreshDatabase; + + /** + * Page returns HTTP 200 for a valid numeric site number. + */ + public function test_page_renders_for_valid_site_number(): void + { + $this->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 @@ +<?php + +declare(strict_types=1); + +namespace Tests\Feature\Services; + +use App\Services\StationDetailService; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Http; +use RuntimeException; +use Tests\TestCase; + +class StationDetailServiceTest extends TestCase +{ + use RefreshDatabase; + + /** + * loadStation() upserts the station record and returns chart-ready arrays. + */ + public function test_load_station_persists_station_and_returns_chart_data(): void + { + Http::fake([ + '*' => 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<string, mixed> + */ + private function fakeApiResponse(): array + { + $sourceInfo = [ + 'siteName' => 'Potomac River near Washington DC', + 'siteCode' => [['value' => '01646500', 'agencyCode' => 'USGS']], + 'geoLocation' => [ + 'geogLocation' => ['latitude' => 38.9495, 'longitude' => -77.1228], + ], + 'siteProperty' => [ + ['name' => 'stateCd', 'value' => '51'], + ['name' => 'countyCd', 'value' => '031'], + ['name' => 'hucCd', 'value' => '02070010'], + ['name' => 'siteTypeCd','value' => 'ST'], + ], + ]; + + return [ + 'value' => [ + 'timeSeries' => [ + [ + 'sourceInfo' => $sourceInfo, + 'variable' => [ + 'variableCode' => [['value' => '00060']], + 'variableName' => 'Streamflow, ft³/s', + 'unit' => ['unitCode' => 'ft3/s'], + 'noDataValue' => -999999, + ], + 'values' => [ + ['value' => [ + ['value' => '1200', 'qualifiers' => ['P'], 'dateTime' => '2025-04-12T12:00:00.000-04:00'], + ['value' => '1234', 'qualifiers' => ['P'], 'dateTime' => '2025-04-13T12:00:00.000-04:00'], + ]], + ], + ], + [ + 'sourceInfo' => $sourceInfo, + 'variable' => [ + 'variableCode' => [['value' => '00065']], + 'variableName' => 'Gage height, ft', + 'unit' => ['unitCode' => 'ft'], + 'noDataValue' => -999999, + ], + 'values' => [ + ['value' => [ + ['value' => '4.50', 'qualifiers' => ['P'], 'dateTime' => '2025-04-12T12:00:00.000-04:00'], + ['value' => '4.56', 'qualifiers' => ['P'], 'dateTime' => '2025-04-13T12:00:00.000-04:00'], + ]], + ], + ], + ], + ], + ]; + } +}