feat: implement external API integration with static tide tables (task 32)

- Add in-memory caching for external API responses (6-hour expiration)
- Implement OpenWeatherMap integration for weather data
- Create static tide tables for 22 Irish coastal stations
- Add tide calculation based on semi-diurnal patterns and lunar cycles
- Implement water quality, beach quality, and biodiversity placeholder endpoints
- Add comprehensive API documentation (EXTERNAL_API_SETUP.md)
- Fix frontend WaterQualityIndicator crash
- Remove Redis dependency (using in-memory cache instead)
- All external data endpoints functional and tested
This commit is contained in:
2025-11-16 23:24:42 +00:00
parent 92426c5cc1
commit 3d487a468e
15 changed files with 2260 additions and 4 deletions

View File

@@ -0,0 +1,403 @@
import axios from 'axios';
import { findNearestStation, calculateTideTimes } from '../data/tideTables';
// Cache expiration: 6 hours (in milliseconds)
const CACHE_EXPIRATION = 6 * 60 * 60 * 1000;
// In-memory cache
interface CacheEntry<T> {
data: T;
expiresAt: number;
}
const memoryCache = new Map<string, CacheEntry<any>>();
// Simple in-memory cache utilities
const cache = {
get<T>(key: string): T | null {
const entry = memoryCache.get(key);
if (!entry) return null;
// Check if expired
if (Date.now() > entry.expiresAt) {
memoryCache.delete(key);
return null;
}
return entry.data as T;
},
set(key: string, value: any, expirationMs: number): void {
memoryCache.set(key, {
data: value,
expiresAt: Date.now() + expirationMs,
});
},
};
// API Keys from environment
const OPENWEATHER_API_KEY = process.env.OPENWEATHER_API_KEY;
const STORMGLASS_API_KEY = process.env.STORMGLASS_API_KEY;
/**
* Weather data interface
*/
export interface WeatherData {
temperature: number;
feelsLike: number;
humidity: number;
windSpeed: number;
windDirection: number;
description: string;
icon: string;
timestamp: string;
}
/**
* Tide data interface
*/
export interface TideData {
extremes: Array<{
type: 'High' | 'Low';
time: string;
height: number;
}>;
currentHeight?: number;
nextTide?: {
type: 'High' | 'Low';
time: string;
height: number;
};
timestamp: string;
}
/**
* Water quality data interface
*/
export interface WaterQualityData {
status: 'excellent' | 'good' | 'sufficient' | 'poor' | 'unknown';
lastSampled?: string;
parameters?: {
ecoli?: number;
enterococci?: number;
};
source: string;
timestamp: string;
}
/**
* Beach quality data interface
*/
export interface BeachQualityData {
rating: 'blue-flag' | 'green-coast' | 'clean-beach' | 'none';
awards: string[];
facilities: string[];
accessibility: string;
lastUpdated?: string;
timestamp: string;
}
/**
* Get weather data from OpenWeatherMap API
*/
export const getWeatherData = async (
latitude: number,
longitude: number
): Promise<WeatherData | null> => {
const cacheKey = `weather:${latitude.toFixed(4)}:${longitude.toFixed(4)}`;
try {
// Check cache first
const cached = cache.get<WeatherData>(cacheKey);
if (cached) {
console.log('Weather data retrieved from cache');
return cached;
}
// Check if API key is configured
if (!OPENWEATHER_API_KEY) {
console.warn('OpenWeatherMap API key not configured');
return null;
}
// Fetch from API
const response = await axios.get(
'https://api.openweathermap.org/data/2.5/weather',
{
params: {
lat: latitude,
lon: longitude,
appid: OPENWEATHER_API_KEY,
units: 'metric',
},
timeout: 5000,
}
);
const data = response.data;
const weatherData: WeatherData = {
temperature: data.main.temp,
feelsLike: data.main.feels_like,
humidity: data.main.humidity,
windSpeed: data.wind.speed,
windDirection: data.wind.deg,
description: data.weather[0].description,
icon: data.weather[0].icon,
timestamp: new Date().toISOString(),
};
// Cache the result
cache.set(cacheKey, weatherData, CACHE_EXPIRATION);
return weatherData;
} catch (error) {
console.error('Error fetching weather data:', error);
return null;
}
};
/**
* Get tide data using static tide tables for Irish coastal locations
* Based on semi-diurnal tide patterns and nearest tide station
*/
export const getTideData = async (
latitude: number,
longitude: number
): Promise<TideData | null> => {
const cacheKey = `tide:${latitude.toFixed(4)}:${longitude.toFixed(4)}`;
try {
// Check cache first
const cached = cache.get<TideData>(cacheKey);
if (cached) {
console.log('Tide data retrieved from cache');
return cached;
}
// Find nearest tide station
const station = findNearestStation(latitude, longitude);
console.log(`Using tide station: ${station.name} (${station.region})`);
// Calculate tide times for today and tomorrow
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const todayTides = calculateTideTimes(station, today);
const tomorrowTides = calculateTideTimes(station, tomorrow);
// Combine and filter to next 24 hours
const allTides = [...todayTides, ...tomorrowTides];
const now = Date.now();
const next24Hours = now + 24 * 60 * 60 * 1000;
const extremes = allTides
.filter((tide) => {
const tideTime = tide.time.getTime();
return tideTime >= now && tideTime <= next24Hours;
})
.map((tide) => ({
type: tide.type,
time: tide.time.toISOString(),
height: tide.height,
}));
// Find next tide
const nextTide = extremes.length > 0 ? extremes[0] : undefined;
const tideData: TideData = {
extremes,
nextTide,
timestamp: new Date().toISOString(),
};
// Cache the result
cache.set(cacheKey, tideData, CACHE_EXPIRATION);
return tideData;
} catch (error) {
console.error('Error calculating tide data:', error);
return null;
}
};
/**
* Get water quality data from Irish EPA
* Note: This is a simplified implementation. The actual EPA API may require
* different endpoints or authentication.
*/
export const getWaterQualityData = async (
latitude: number,
longitude: number
): Promise<WaterQualityData | null> => {
const cacheKey = `water-quality:${latitude.toFixed(4)}:${longitude.toFixed(4)}`;
try {
// Check cache first
const cached = cache.get<WaterQualityData>(cacheKey);
if (cached) {
console.log('Water quality data retrieved from cache');
return cached;
}
// Note: EPA API integration would go here
// For now, return mock data structure
// In production, you would integrate with:
// https://gis.epa.ie/EPAMaps/
// or EPA's open data portal
console.warn('EPA water quality API not fully implemented - returning placeholder');
const waterQualityData: WaterQualityData = {
status: 'unknown',
source: 'EPA Ireland',
timestamp: new Date().toISOString(),
};
// Cache the result
cache.set(cacheKey, waterQualityData, CACHE_EXPIRATION);
return waterQualityData;
} catch (error) {
console.error('Error fetching water quality data:', error);
return null;
}
};
/**
* Get beach quality rating data
* This would integrate with Blue Flag, Green Coast Award databases
*/
export const getBeachQualityData = async (
latitude: number,
longitude: number
): Promise<BeachQualityData | null> => {
const cacheKey = `beach-quality:${latitude.toFixed(4)}:${longitude.toFixed(4)}`;
try {
// Check cache first
const cached = cache.get<BeachQualityData>(cacheKey);
if (cached) {
console.log('Beach quality data retrieved from cache');
return cached;
}
// Note: Blue Flag / Green Coast API integration would go here
// For now, return mock data structure
// In production, you would integrate with:
// - Blue Flag Ireland database
// - Green Coast Award database
// - An Taisce beach data
console.warn('Beach quality API not fully implemented - returning placeholder');
const beachQualityData: BeachQualityData = {
rating: 'none',
awards: [],
facilities: [],
accessibility: 'unknown',
timestamp: new Date().toISOString(),
};
// Cache the result
await cache.set(cacheKey, beachQualityData, CACHE_EXPIRATION);
return beachQualityData;
} catch (error) {
console.error('Error fetching beach quality data:', error);
return null;
}
};
/**
* Biodiversity data interface
*/
export interface BiodiversityData {
species: Array<{
name: string;
scientificName: string;
category: string;
}>;
marineProtectedArea: boolean;
habitatTypes: string[];
conservationStatus?: string;
source: string;
timestamp: string;
}
/**
* Get marine biodiversity data for a location
* This would integrate with GBIF, Marine Institute Ireland, or similar databases
*/
export const getBiodiversityData = async (
latitude: number,
longitude: number
): Promise<BiodiversityData | null> => {
const cacheKey = `biodiversity:${latitude.toFixed(4)}:${longitude.toFixed(4)}`;
try {
// Check cache first
const cached = await cache.get<BiodiversityData>(cacheKey);
if (cached) {
console.log('Biodiversity data retrieved from cache');
return cached;
}
// Note: GBIF or Marine Institute API integration would go here
// For now, return mock data structure
// In production, you would integrate with:
// - GBIF (Global Biodiversity Information Facility)
// - Marine Institute Ireland
// - National Biodiversity Data Centre
console.warn('Biodiversity API not fully implemented - returning placeholder');
const biodiversityData: BiodiversityData = {
species: [],
marineProtectedArea: false,
habitatTypes: [],
source: 'Placeholder',
timestamp: new Date().toISOString(),
};
// Cache the result
await cache.set(cacheKey, biodiversityData, CACHE_EXPIRATION);
return biodiversityData;
} catch (error) {
console.error('Error fetching biodiversity data:', error);
return null;
}
};
/**
* Get all environmental data for a location
*/
export const getAllEnvironmentalData = async (
latitude: number,
longitude: number
) => {
try {
// Fetch all data in parallel
const [weather, tides, waterQuality, beachQuality, biodiversity] = await Promise.all([
getWeatherData(latitude, longitude),
getTideData(latitude, longitude),
getWaterQualityData(latitude, longitude),
getBeachQualityData(latitude, longitude),
getBiodiversityData(latitude, longitude),
]);
return {
weather,
tides,
waterQuality,
beachQuality,
biodiversity,
location: { latitude, longitude },
timestamp: new Date().toISOString(),
};
} catch (error) {
console.error('Error fetching environmental data:', error);
throw error;
}
};