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:
@@ -158,7 +158,7 @@
|
||||
|
||||
## External Data Integration
|
||||
|
||||
- [ ] 19. Create external data display components
|
||||
- [-] 19. Create external data display components
|
||||
- Build weather information widget
|
||||
- Implement tide times display
|
||||
- Add water quality indicator
|
||||
|
||||
11
src/App.tsx
11
src/App.tsx
@@ -23,7 +23,8 @@ import {
|
||||
LocationSelectorDemo,
|
||||
ReportDetailDemo,
|
||||
WhyCleanPage,
|
||||
UserProfilePage
|
||||
UserProfilePage,
|
||||
EnvironmentalDataDemo
|
||||
} from './pages';
|
||||
|
||||
// Services
|
||||
@@ -166,6 +167,14 @@ function App() {
|
||||
</Layout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/demo/environmental-data"
|
||||
element={
|
||||
<Layout>
|
||||
<EnvironmentalDataDemo />
|
||||
</Layout>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ApiInitializer>
|
||||
|
||||
131
src/components/external/BeachQualityDisplay.tsx
vendored
Normal file
131
src/components/external/BeachQualityDisplay.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
125
src/components/external/BiodiversityInfo.tsx
vendored
Normal file
125
src/components/external/BiodiversityInfo.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/components/external/EnvironmentalDataPanel.tsx
vendored
Normal file
35
src/components/external/EnvironmentalDataPanel.tsx
vendored
Normal 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
159
src/components/external/README.md
vendored
Normal 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
107
src/components/external/TideDisplay.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
131
src/components/external/WaterQualityIndicator.tsx
vendored
Normal file
131
src/components/external/WaterQualityIndicator.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
104
src/components/external/WeatherWidget.tsx
vendored
Normal file
104
src/components/external/WeatherWidget.tsx
vendored
Normal 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
7
src/components/external/index.ts
vendored
Normal 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';
|
||||
@@ -12,3 +12,6 @@ export * from './report';
|
||||
|
||||
// Event components
|
||||
export * from './event';
|
||||
|
||||
// External environmental data components
|
||||
export * from './external';
|
||||
|
||||
53
src/pages/EnvironmentalDataDemo.tsx
Normal file
53
src/pages/EnvironmentalDataDemo.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
// Demo page for external environmental data components
|
||||
import { useState } from 'react';
|
||||
import { EnvironmentalDataPanel } from '@/components/external';
|
||||
|
||||
export function EnvironmentalDataDemo() {
|
||||
// Default to a location in Ireland (Dublin Bay)
|
||||
const [latitude, setLatitude] = useState(53.3498);
|
||||
const [longitude, setLongitude] = useState(-6.2603);
|
||||
|
||||
const handleLocationChange = () => {
|
||||
// Example: Switch to another Irish coastal location (Galway Bay)
|
||||
if (latitude === 53.3498) {
|
||||
setLatitude(53.2707);
|
||||
setLongitude(-9.0568);
|
||||
} else {
|
||||
setLatitude(53.3498);
|
||||
setLongitude(-6.2603);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
Environmental Data Display
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
View real-time environmental context for coastal locations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Current Location</h2>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Latitude: {latitude.toFixed(4)}</p>
|
||||
<p className="text-sm text-gray-600">Longitude: {longitude.toFixed(4)}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLocationChange}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Switch Location
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EnvironmentalDataPanel latitude={latitude} longitude={longitude} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,3 +8,4 @@ export { default as LocationSelectorDemo } from './LocationSelectorDemo';
|
||||
export { ReportDetailDemo } from './ReportDetailDemo';
|
||||
export { WhyCleanPage } from './WhyCleanPage';
|
||||
export { UserProfilePage } from './UserProfilePage';
|
||||
export { EnvironmentalDataDemo } from './EnvironmentalDataDemo';
|
||||
|
||||
111
src/services/externalDataService.ts
Normal file
111
src/services/externalDataService.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
// External environmental data service
|
||||
import { apiClient } from './api';
|
||||
import type {
|
||||
WeatherData,
|
||||
TideData,
|
||||
WaterQualityData,
|
||||
BeachQualityData,
|
||||
BiodiversityData,
|
||||
ExternalEnvironmentalData,
|
||||
} from '@/types';
|
||||
|
||||
export const externalDataService = {
|
||||
/**
|
||||
* Fetch weather data for a location
|
||||
*/
|
||||
async getWeather(latitude: number, longitude: number): Promise<WeatherData | null> {
|
||||
try {
|
||||
const data = await apiClient.get<WeatherData>(
|
||||
`/external/weather/${latitude}/${longitude}`
|
||||
);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch weather data:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch tide information for a location
|
||||
*/
|
||||
async getTides(latitude: number, longitude: number): Promise<TideData | null> {
|
||||
try {
|
||||
const data = await apiClient.get<TideData>(
|
||||
`/external/tides/${latitude}/${longitude}`
|
||||
);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tide data:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch water quality data for a location
|
||||
*/
|
||||
async getWaterQuality(latitude: number, longitude: number): Promise<WaterQualityData | null> {
|
||||
try {
|
||||
const data = await apiClient.get<WaterQualityData>(
|
||||
`/external/water-quality/${latitude}/${longitude}`
|
||||
);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch water quality data:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch beach quality rating for a location
|
||||
*/
|
||||
async getBeachQuality(latitude: number, longitude: number): Promise<BeachQualityData | null> {
|
||||
try {
|
||||
const data = await apiClient.get<BeachQualityData>(
|
||||
`/external/beach-quality/${latitude}/${longitude}`
|
||||
);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch beach quality data:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch marine biodiversity information for a location
|
||||
*/
|
||||
async getBiodiversity(latitude: number, longitude: number): Promise<BiodiversityData | null> {
|
||||
try {
|
||||
const data = await apiClient.get<BiodiversityData>(
|
||||
`/external/biodiversity/${latitude}/${longitude}`
|
||||
);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch biodiversity data:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all available external environmental data for a location
|
||||
*/
|
||||
async getAllEnvironmentalData(
|
||||
latitude: number,
|
||||
longitude: number
|
||||
): Promise<ExternalEnvironmentalData> {
|
||||
const [weather, tides, waterQuality, beachQuality, biodiversity] = await Promise.all([
|
||||
this.getWeather(latitude, longitude),
|
||||
this.getTides(latitude, longitude),
|
||||
this.getWaterQuality(latitude, longitude),
|
||||
this.getBeachQuality(latitude, longitude),
|
||||
this.getBiodiversity(latitude, longitude),
|
||||
]);
|
||||
|
||||
return {
|
||||
weather: weather || undefined,
|
||||
tides: tides || undefined,
|
||||
waterQuality: waterQuality || undefined,
|
||||
beachQuality: beachQuality || undefined,
|
||||
biodiversity: biodiversity || undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -4,6 +4,7 @@ export { reportService } from './reportService';
|
||||
export { analyticsService } from './analyticsService';
|
||||
export { eventService } from './eventService';
|
||||
export { userService } from './userService';
|
||||
export { externalDataService } from './externalDataService';
|
||||
export type { ApiError } from './api';
|
||||
export type { CreateReportPayload, UploadPhotoResponse } from './reportService';
|
||||
export type { CreateEventDTO, UpdateEventDTO, EventFilters, EventRegistration } from './eventService';
|
||||
|
||||
@@ -179,3 +179,65 @@ export interface QuantityAggregation {
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
// External environmental data types
|
||||
export interface WeatherData {
|
||||
temperature: number; // Celsius
|
||||
feelsLike: number;
|
||||
humidity: number; // percentage
|
||||
windSpeed: number; // m/s
|
||||
windDirection: number; // degrees
|
||||
description: string;
|
||||
icon: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface TideData {
|
||||
tides: Array<{
|
||||
time: Date;
|
||||
height: number; // meters
|
||||
type: 'high' | 'low';
|
||||
}>;
|
||||
nextTide?: {
|
||||
time: Date;
|
||||
height: number;
|
||||
type: 'high' | 'low';
|
||||
};
|
||||
}
|
||||
|
||||
export interface WaterQualityData {
|
||||
status: 'excellent' | 'good' | 'sufficient' | 'poor' | 'unknown';
|
||||
lastUpdated?: Date;
|
||||
parameters?: {
|
||||
bacteria?: number;
|
||||
ph?: number;
|
||||
temperature?: number;
|
||||
};
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface BeachQualityData {
|
||||
rating: 'blue-flag' | 'green-coast' | 'excellent' | 'good' | 'adequate' | 'poor' | 'unknown';
|
||||
awards?: string[];
|
||||
facilities?: string[];
|
||||
lastInspection?: Date;
|
||||
}
|
||||
|
||||
export interface BiodiversityData {
|
||||
species?: Array<{
|
||||
name: string;
|
||||
scientificName?: string;
|
||||
status?: string;
|
||||
}>;
|
||||
protectedArea?: boolean;
|
||||
habitatType?: string;
|
||||
conservationStatus?: string;
|
||||
}
|
||||
|
||||
export interface ExternalEnvironmentalData {
|
||||
weather?: WeatherData;
|
||||
tides?: TideData;
|
||||
waterQuality?: WaterQualityData;
|
||||
beachQuality?: BeachQualityData;
|
||||
biodiversity?: BiodiversityData;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user