feat: implement external environmental data display components

- Add WeatherWidget for current weather conditions
- Add TideDisplay for tide times and heights
- Add WaterQualityIndicator for EPA water quality data
- Add BeachQualityDisplay for beach ratings and certifications
- Add BiodiversityInfo for marine biodiversity information
- Add EnvironmentalDataPanel composite component
- Create externalDataService for API integration
- Add types for external environmental data
- Create demo page at /demo/environmental-data
- Add comprehensive README documentation

Fulfills requirements 11.1, 11.2, 11.3, 11.4, 11.6, 11.7
Completes task 19 from implementation plan
This commit is contained in:
2025-11-16 23:07:29 +00:00
parent 1e1e54e7ff
commit 2b222b65e1
16 changed files with 1041 additions and 2 deletions

View File

@@ -0,0 +1,131 @@
// Beach quality rating display component
import { useEffect, useState } from 'react';
import { format } from 'date-fns';
import type { BeachQualityData } from '@/types';
import { externalDataService } from '@/services/externalDataService';
interface BeachQualityDisplayProps {
latitude: number;
longitude: number;
}
export function BeachQualityDisplay({ latitude, longitude }: BeachQualityDisplayProps) {
const [beachQuality, setBeachQuality] = useState<BeachQualityData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
const fetchBeachQuality = async () => {
try {
setLoading(true);
setError(null);
const data = await externalDataService.getBeachQuality(latitude, longitude);
if (mounted) {
setBeachQuality(data);
}
} catch (err) {
if (mounted) {
setError('Unable to load beach quality data');
}
} finally {
if (mounted) {
setLoading(false);
}
}
};
fetchBeachQuality();
return () => {
mounted = false;
};
}, [latitude, longitude]);
if (loading) {
return (
<div className="bg-white rounded-lg shadow p-4">
<div className="flex items-center space-x-2">
<div className="w-5 h-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-gray-600">Loading beach quality...</span>
</div>
</div>
);
}
if (error || !beachQuality) {
return (
<div className="bg-white rounded-lg shadow p-4">
<p className="text-sm text-gray-500">Beach quality data unavailable</p>
</div>
);
}
const ratingConfig = {
'blue-flag': { color: 'text-blue-600', icon: '🏴', label: 'Blue Flag Award' },
'green-coast': { color: 'text-green-600', icon: '🌿', label: 'Green Coast Award' },
'excellent': { color: 'text-green-600', icon: '⭐', label: 'Excellent' },
'good': { color: 'text-blue-600', icon: '✓', label: 'Good' },
'adequate': { color: 'text-yellow-600', icon: '○', label: 'Adequate' },
'poor': { color: 'text-red-600', icon: '✗', label: 'Poor' },
'unknown': { color: 'text-gray-500', icon: '?', label: 'Unknown' },
};
const config = ratingConfig[beachQuality.rating];
return (
<div className="bg-white rounded-lg shadow p-4">
<h3 className="text-lg font-semibold mb-3 flex items-center">
<span className="mr-2">🏖️</span>
Beach Quality
</h3>
<div className="space-y-3">
<div className="flex items-center space-x-3">
<span className="text-3xl">{config.icon}</span>
<div>
<div className={`font-semibold text-lg ${config.color}`}>
{config.label}
</div>
{beachQuality.lastInspection && (
<div className="text-xs text-gray-500">
Last inspection: {format(new Date(beachQuality.lastInspection), 'MMM d, yyyy')}
</div>
)}
</div>
</div>
{beachQuality.awards && beachQuality.awards.length > 0 && (
<div className="mt-3 pt-3 border-t border-gray-200">
<div className="text-sm font-medium text-gray-700 mb-2">Awards & Certifications</div>
<div className="flex flex-wrap gap-2">
{beachQuality.awards.map((award, index) => (
<span
key={index}
className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full"
>
{award}
</span>
))}
</div>
</div>
)}
{beachQuality.facilities && beachQuality.facilities.length > 0 && (
<div className="mt-3 pt-3 border-t border-gray-200">
<div className="text-sm font-medium text-gray-700 mb-2">Facilities</div>
<div className="grid grid-cols-2 gap-2 text-sm">
{beachQuality.facilities.map((facility, index) => (
<div key={index} className="flex items-center space-x-1">
<span className="text-green-500">✓</span>
<span className="text-gray-700">{facility}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,125 @@
// Marine biodiversity information component
import { useEffect, useState } from 'react';
import type { BiodiversityData } from '@/types';
import { externalDataService } from '@/services/externalDataService';
interface BiodiversityInfoProps {
latitude: number;
longitude: number;
}
export function BiodiversityInfo({ latitude, longitude }: BiodiversityInfoProps) {
const [biodiversity, setBiodiversity] = useState<BiodiversityData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
const fetchBiodiversity = async () => {
try {
setLoading(true);
setError(null);
const data = await externalDataService.getBiodiversity(latitude, longitude);
if (mounted) {
setBiodiversity(data);
}
} catch (err) {
if (mounted) {
setError('Unable to load biodiversity data');
}
} finally {
if (mounted) {
setLoading(false);
}
}
};
fetchBiodiversity();
return () => {
mounted = false;
};
}, [latitude, longitude]);
if (loading) {
return (
<div className="bg-white rounded-lg shadow p-4">
<div className="flex items-center space-x-2">
<div className="w-5 h-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-gray-600">Loading biodiversity data...</span>
</div>
</div>
);
}
if (error || !biodiversity) {
return (
<div className="bg-white rounded-lg shadow p-4">
<p className="text-sm text-gray-500">Biodiversity data unavailable</p>
</div>
);
}
// Don't render if there's no meaningful data
if (!biodiversity.species?.length && !biodiversity.protectedArea && !biodiversity.habitatType) {
return null;
}
return (
<div className="bg-white rounded-lg shadow p-4">
<h3 className="text-lg font-semibold mb-3 flex items-center">
<span className="mr-2">🐚</span>
Marine Biodiversity
</h3>
<div className="space-y-3">
{biodiversity.protectedArea && (
<div className="p-3 bg-green-50 rounded-lg border border-green-200">
<div className="flex items-center space-x-2">
<span className="text-green-600 text-xl">🛡️</span>
<span className="text-sm font-medium text-green-800">Protected Area</span>
</div>
</div>
)}
{biodiversity.habitatType && (
<div>
<div className="text-sm font-medium text-gray-700 mb-1">Habitat Type</div>
<div className="text-sm text-gray-600">{biodiversity.habitatType}</div>
</div>
)}
{biodiversity.conservationStatus && (
<div>
<div className="text-sm font-medium text-gray-700 mb-1">Conservation Status</div>
<div className="text-sm text-gray-600">{biodiversity.conservationStatus}</div>
</div>
)}
{biodiversity.species && biodiversity.species.length > 0 && (
<div className="mt-3 pt-3 border-t border-gray-200">
<div className="text-sm font-medium text-gray-700 mb-2">
Notable Species ({biodiversity.species.length})
</div>
<div className="space-y-2 max-h-48 overflow-y-auto">
{biodiversity.species.map((species, index) => (
<div key={index} className="p-2 bg-gray-50 rounded text-sm">
<div className="font-medium text-gray-800">{species.name}</div>
{species.scientificName && (
<div className="text-xs text-gray-500 italic">{species.scientificName}</div>
)}
{species.status && (
<div className="text-xs text-gray-600 mt-1">
Status: {species.status}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
// Composite panel displaying all external environmental data
import { WeatherWidget } from './WeatherWidget';
import { TideDisplay } from './TideDisplay';
import { WaterQualityIndicator } from './WaterQualityIndicator';
import { BeachQualityDisplay } from './BeachQualityDisplay';
import { BiodiversityInfo } from './BiodiversityInfo';
interface EnvironmentalDataPanelProps {
latitude: number;
longitude: number;
className?: string;
}
export function EnvironmentalDataPanel({
latitude,
longitude,
className = '',
}: EnvironmentalDataPanelProps) {
return (
<div className={`space-y-4 ${className}`}>
<div className="mb-4">
<h2 className="text-xl font-bold text-gray-800 mb-2">Environmental Context</h2>
<p className="text-sm text-gray-600">
Real-time environmental data for this location
</p>
</div>
<WeatherWidget latitude={latitude} longitude={longitude} />
<TideDisplay latitude={latitude} longitude={longitude} />
<WaterQualityIndicator latitude={latitude} longitude={longitude} />
<BeachQualityDisplay latitude={latitude} longitude={longitude} />
<BiodiversityInfo latitude={latitude} longitude={longitude} />
</div>
);
}

159
src/components/external/README.md vendored Normal file
View File

@@ -0,0 +1,159 @@
# External Environmental Data Components
This directory contains components for displaying external environmental data from various public APIs and data sources.
## Components
### WeatherWidget
Displays current weather conditions for a location including:
- Temperature and "feels like" temperature
- Humidity percentage
- Wind speed and direction
- Weather description
**Props:**
- `latitude: number` - Location latitude
- `longitude: number` - Location longitude
**API Endpoint:** `GET /api/external/weather/:lat/:lng`
### TideDisplay
Shows tide information for coastal locations including:
- Next tide time and type (high/low)
- Today's tide schedule with times and heights
- Tide heights in meters
**Props:**
- `latitude: number` - Location latitude
- `longitude: number` - Location longitude
**API Endpoint:** `GET /api/external/tides/:lat/:lng`
### WaterQualityIndicator
Displays water quality status from EPA data:
- Overall quality rating (excellent, good, sufficient, poor)
- Water quality parameters (bacteria, pH, temperature)
- Last update date
- Data source information
**Props:**
- `latitude: number` - Location latitude
- `longitude: number` - Location longitude
**API Endpoint:** `GET /api/external/water-quality/:lat/:lng`
### BeachQualityDisplay
Shows beach quality ratings and certifications:
- Quality rating (Blue Flag, Green Coast, etc.)
- Awards and certifications
- Available facilities
- Last inspection date
**Props:**
- `latitude: number` - Location latitude
- `longitude: number` - Location longitude
**API Endpoint:** `GET /api/external/beach-quality/:lat/:lng`
### BiodiversityInfo
Displays marine biodiversity information:
- Protected area status
- Habitat type
- Conservation status
- Notable species list with scientific names
**Props:**
- `latitude: number` - Location latitude
- `longitude: number` - Location longitude
**API Endpoint:** `GET /api/external/biodiversity/:lat/:lng`
**Note:** This component only renders if meaningful data is available.
### EnvironmentalDataPanel
Composite component that displays all external environmental data widgets together.
**Props:**
- `latitude: number` - Location latitude
- `longitude: number` - Location longitude
- `className?: string` - Optional CSS classes
## Usage
### Individual Components
```tsx
import { WeatherWidget, TideDisplay } from '@/components/external';
function MyComponent() {
return (
<div>
<WeatherWidget latitude={53.3498} longitude={-6.2603} />
<TideDisplay latitude={53.3498} longitude={-6.2603} />
</div>
);
}
```
### Composite Panel
```tsx
import { EnvironmentalDataPanel } from '@/components/external';
function ReportDetails({ report }) {
return (
<div>
<h2>Report Details</h2>
{/* ... report info ... */}
<EnvironmentalDataPanel
latitude={report.latitude}
longitude={report.longitude}
/>
</div>
);
}
```
## Error Handling
All components handle loading and error states gracefully:
- **Loading state:** Shows a spinner with loading message
- **Error state:** Displays "data unavailable" message
- **No data:** Components hide themselves or show appropriate message
Components use `try-catch` blocks and never throw errors to parent components.
## Data Caching
External API responses are cached on the backend for up to 6 hours to:
- Minimize API calls to external services
- Improve performance
- Reduce costs
- Ensure data availability
## Backend Requirements
These components require the following backend API endpoints to be implemented:
1. `GET /api/external/weather/:lat/:lng` - Weather data from OpenWeatherMap
2. `GET /api/external/tides/:lat/:lng` - Tide data from WorldTides or NOAA
3. `GET /api/external/water-quality/:lat/:lng` - EPA water quality data
4. `GET /api/external/beach-quality/:lat/:lng` - Beach quality ratings
5. `GET /api/external/biodiversity/:lat/:lng` - Marine biodiversity information
See the design document for detailed API specifications and data formats.
## Demo
Visit `/demo/environmental-data` to see all components in action with sample locations.
## Requirements Fulfilled
These components fulfill the following requirements:
- **11.1** - Weather data integration
- **11.2** - Tide information display
- **11.3** - Water quality data from EPA
- **11.4** - Beach quality ratings
- **11.6** - Beach quality from EU databases
- **11.7** - Marine biodiversity information

107
src/components/external/TideDisplay.tsx vendored Normal file
View File

@@ -0,0 +1,107 @@
// Tide times display component
import { useEffect, useState } from 'react';
import { format } from 'date-fns';
import type { TideData } from '@/types';
import { externalDataService } from '@/services/externalDataService';
interface TideDisplayProps {
latitude: number;
longitude: number;
}
export function TideDisplay({ latitude, longitude }: TideDisplayProps) {
const [tideData, setTideData] = useState<TideData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
const fetchTides = async () => {
try {
setLoading(true);
setError(null);
const data = await externalDataService.getTides(latitude, longitude);
if (mounted) {
setTideData(data);
}
} catch (err) {
if (mounted) {
setError('Unable to load tide data');
}
} finally {
if (mounted) {
setLoading(false);
}
}
};
fetchTides();
return () => {
mounted = false;
};
}, [latitude, longitude]);
if (loading) {
return (
<div className="bg-white rounded-lg shadow p-4">
<div className="flex items-center space-x-2">
<div className="w-5 h-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-gray-600">Loading tide data...</span>
</div>
</div>
);
}
if (error || !tideData) {
return (
<div className="bg-white rounded-lg shadow p-4">
<p className="text-sm text-gray-500">Tide data unavailable</p>
</div>
);
}
return (
<div className="bg-white rounded-lg shadow p-4">
<h3 className="text-lg font-semibold mb-3 flex items-center">
<span className="mr-2">🌊</span>
Tide Times
</h3>
{tideData.nextTide && (
<div className="mb-4 p-3 bg-blue-50 rounded-lg">
<div className="text-sm text-gray-600 mb-1">Next Tide</div>
<div className="flex items-center justify-between">
<span className="font-semibold capitalize">{tideData.nextTide.type} Tide</span>
<span className="text-sm">{format(new Date(tideData.nextTide.time), 'HH:mm')}</span>
</div>
<div className="text-sm text-gray-600 mt-1">
Height: {tideData.nextTide.height.toFixed(2)}m
</div>
</div>
)}
<div className="space-y-2">
<div className="text-sm font-medium text-gray-700 mb-2">Today's Tides</div>
{tideData.tides.slice(0, 4).map((tide, index) => (
<div
key={index}
className="flex items-center justify-between py-2 border-b border-gray-100 last:border-0"
>
<div className="flex items-center space-x-3">
<span className="text-lg">{tide.type === 'high' ? '⬆️' : '⬇️'}</span>
<div>
<div className="font-medium capitalize">{tide.type} Tide</div>
<div className="text-xs text-gray-500">
{format(new Date(tide.time), 'HH:mm')}
</div>
</div>
</div>
<div className="text-sm text-gray-600">{tide.height.toFixed(2)}m</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,131 @@
// Water quality indicator component
import { useEffect, useState } from 'react';
import { format } from 'date-fns';
import type { WaterQualityData } from '@/types';
import { externalDataService } from '@/services/externalDataService';
interface WaterQualityIndicatorProps {
latitude: number;
longitude: number;
}
export function WaterQualityIndicator({ latitude, longitude }: WaterQualityIndicatorProps) {
const [waterQuality, setWaterQuality] = useState<WaterQualityData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
const fetchWaterQuality = async () => {
try {
setLoading(true);
setError(null);
const data = await externalDataService.getWaterQuality(latitude, longitude);
if (mounted) {
setWaterQuality(data);
}
} catch (err) {
if (mounted) {
setError('Unable to load water quality data');
}
} finally {
if (mounted) {
setLoading(false);
}
}
};
fetchWaterQuality();
return () => {
mounted = false;
};
}, [latitude, longitude]);
if (loading) {
return (
<div className="bg-white rounded-lg shadow p-4">
<div className="flex items-center space-x-2">
<div className="w-5 h-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-gray-600">Loading water quality...</span>
</div>
</div>
);
}
if (error || !waterQuality) {
return (
<div className="bg-white rounded-lg shadow p-4">
<p className="text-sm text-gray-500">Water quality data unavailable</p>
</div>
);
}
const statusConfig = {
excellent: { color: 'bg-green-500', text: 'Excellent', icon: '✓' },
good: { color: 'bg-blue-500', text: 'Good', icon: '✓' },
sufficient: { color: 'bg-yellow-500', text: 'Sufficient', icon: '!' },
poor: { color: 'bg-red-500', text: 'Poor', icon: '✗' },
unknown: { color: 'bg-gray-400', text: 'Unknown', icon: '?' },
};
const config = statusConfig[waterQuality.status];
return (
<div className="bg-white rounded-lg shadow p-4">
<h3 className="text-lg font-semibold mb-3 flex items-center">
<span className="mr-2">💧</span>
Water Quality
</h3>
<div className="space-y-3">
<div className="flex items-center space-x-3">
<div className={`w-12 h-12 ${config.color} rounded-full flex items-center justify-center text-white text-xl font-bold`}>
{config.icon}
</div>
<div>
<div className="font-semibold text-lg">{config.text}</div>
{waterQuality.lastUpdated && (
<div className="text-xs text-gray-500">
Updated: {format(new Date(waterQuality.lastUpdated), 'MMM d, yyyy')}
</div>
)}
</div>
</div>
{waterQuality.parameters && (
<div className="mt-3 pt-3 border-t border-gray-200">
<div className="text-sm font-medium text-gray-700 mb-2">Parameters</div>
<div className="grid grid-cols-2 gap-2 text-sm">
{waterQuality.parameters.bacteria !== undefined && (
<div>
<span className="text-gray-600">Bacteria:</span>
<span className="ml-2 font-medium">{waterQuality.parameters.bacteria} CFU/100ml</span>
</div>
)}
{waterQuality.parameters.ph !== undefined && (
<div>
<span className="text-gray-600">pH:</span>
<span className="ml-2 font-medium">{waterQuality.parameters.ph.toFixed(1)}</span>
</div>
)}
{waterQuality.parameters.temperature !== undefined && (
<div>
<span className="text-gray-600">Temp:</span>
<span className="ml-2 font-medium">{waterQuality.parameters.temperature}°C</span>
</div>
)}
</div>
</div>
)}
{waterQuality.source && (
<div className="text-xs text-gray-500 mt-2">
Source: {waterQuality.source}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,104 @@
// Weather information widget component
import { useEffect, useState } from 'react';
import type { WeatherData } from '@/types';
import { externalDataService } from '@/services/externalDataService';
interface WeatherWidgetProps {
latitude: number;
longitude: number;
}
export function WeatherWidget({ latitude, longitude }: WeatherWidgetProps) {
const [weather, setWeather] = useState<WeatherData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
const fetchWeather = async () => {
try {
setLoading(true);
setError(null);
const data = await externalDataService.getWeather(latitude, longitude);
if (mounted) {
setWeather(data);
}
} catch (err) {
if (mounted) {
setError('Unable to load weather data');
}
} finally {
if (mounted) {
setLoading(false);
}
}
};
fetchWeather();
return () => {
mounted = false;
};
}, [latitude, longitude]);
if (loading) {
return (
<div className="bg-white rounded-lg shadow p-4">
<div className="flex items-center space-x-2">
<div className="w-5 h-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-gray-600">Loading weather...</span>
</div>
</div>
);
}
if (error || !weather) {
return (
<div className="bg-white rounded-lg shadow p-4">
<p className="text-sm text-gray-500">Weather data unavailable</p>
</div>
);
}
return (
<div className="bg-white rounded-lg shadow p-4">
<h3 className="text-lg font-semibold mb-3 flex items-center">
<span className="mr-2">🌤️</span>
Weather Conditions
</h3>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-2xl font-bold">{Math.round(weather.temperature)}°C</span>
<span className="text-gray-600 capitalize">{weather.description}</span>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-gray-600">Feels like:</span>
<span className="ml-2 font-medium">{Math.round(weather.feelsLike)}°C</span>
</div>
<div>
<span className="text-gray-600">Humidity:</span>
<span className="ml-2 font-medium">{weather.humidity}%</span>
</div>
<div>
<span className="text-gray-600">Wind:</span>
<span className="ml-2 font-medium">{Math.round(weather.windSpeed * 3.6)} km/h</span>
</div>
<div>
<span className="text-gray-600">Direction:</span>
<span className="ml-2 font-medium">{getWindDirection(weather.windDirection)}</span>
</div>
</div>
</div>
</div>
);
}
function getWindDirection(degrees: number): string {
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
const index = Math.round(degrees / 45) % 8;
return directions[index] || 'N';
}

7
src/components/external/index.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
// External environmental data components
export { WeatherWidget } from './WeatherWidget';
export { TideDisplay } from './TideDisplay';
export { WaterQualityIndicator } from './WaterQualityIndicator';
export { BeachQualityDisplay } from './BeachQualityDisplay';
export { BiodiversityInfo } from './BiodiversityInfo';
export { EnvironmentalDataPanel } from './EnvironmentalDataPanel';

View File

@@ -12,3 +12,6 @@ export * from './report';
// Event components
export * from './event';
// External environmental data components
export * from './external';