converted weather to openMeteo
This commit is contained in:
parent
d3eeaa7f40
commit
659a7951ca
6 changed files with 593 additions and 83 deletions
|
|
@ -8,6 +8,9 @@ REACT_APP_DATA_SOURCES=https://www.reddit.com/r/technology.json,https://hacker-n
|
|||
# Location for weather (supports "city, state, country" or zip code)
|
||||
REACT_APP_LOCATION=harrisburg, pa, usa
|
||||
|
||||
# Optional: Temperature unit for weather ('F' for Fahrenheit, 'C' for Celsius, default: F)
|
||||
REACT_APP_TEMPERATURE_UNIT=F
|
||||
|
||||
# Optional: Custom CORS proxy (default: allorigins.win)
|
||||
REACT_APP_CORS_PROXY=https://api.allorigins.win/raw?url=
|
||||
|
||||
|
|
@ -26,3 +29,6 @@ REACT_APP_CLAUDE_INSTRUCTIONS=
|
|||
|
||||
# Optional: Debug mode (shows raw data, enables dummy data)
|
||||
REACT_APP_DEBUG_MODE=false
|
||||
|
||||
# Optional: Date format for charts (MM/DD/YYYY, DD/MM/YYYY, MM/DD, DD/MM)
|
||||
REACT_APP_CHART_DATE_FORMAT=MM/DD/YYYY
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@ function Dashboard({ themeToggle, isDark }) {
|
|||
|
||||
<Box className="widget-grid">
|
||||
<Box className="widget-row">
|
||||
<BriefingWidget briefing={briefing} />
|
||||
<BriefingWidget briefing={briefing} styles={{maxWidth:'50%'}}/>
|
||||
<WeatherWidget
|
||||
data={sourceData.weather}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,80 +1,309 @@
|
|||
import { Box, Chip, Stack } from '@mui/material';
|
||||
import { Cloud, WbSunny, Thunderstorm, AcUnit } from '@mui/icons-material';
|
||||
import { Box, Chip, Stack, Paper, Typography, Divider, useTheme } from '@mui/material';
|
||||
import {
|
||||
Cloud,
|
||||
WbSunny,
|
||||
Thunderstorm,
|
||||
AcUnit,
|
||||
Opacity,
|
||||
Air,
|
||||
WbCloudy,
|
||||
Grain,
|
||||
Foggy
|
||||
} from '@mui/icons-material';
|
||||
import { LineChart } from '@mui/x-charts/LineChart';
|
||||
import GenericWidget from './GenericWidget';
|
||||
|
||||
function WeatherWidget({ data, sx }) {
|
||||
const getWeatherIcon = (description) => {
|
||||
const desc = description?.toLowerCase() || '';
|
||||
if (desc.includes('sun') || desc.includes('clear')) return <WbSunny />;
|
||||
if (desc.includes('cloud') || desc.includes('overcast')) return <Cloud />;
|
||||
if (desc.includes('rain') || desc.includes('storm')) return <Thunderstorm />;
|
||||
if (desc.includes('snow') || desc.includes('ice')) return <AcUnit />;
|
||||
return <Cloud />;
|
||||
const theme = useTheme();
|
||||
|
||||
// Weather icon mapping based on Open-Meteo icon types
|
||||
const getWeatherIcon = (iconType) => {
|
||||
const iconMap = {
|
||||
'sunny': <WbSunny sx={{ fontSize: 40, color: theme.palette.warning.main }} />,
|
||||
'partly_cloudy': <WbCloudy sx={{ fontSize: 40, color: theme.palette.grey[600] }} />,
|
||||
'cloudy': <Cloud sx={{ fontSize: 40, color: theme.palette.grey[500] }} />,
|
||||
'foggy': <Foggy sx={{ fontSize: 40, color: theme.palette.grey[400] }} />,
|
||||
'rainy': <Grain sx={{ fontSize: 40, color: theme.palette.info.main }} />,
|
||||
'snowy': <AcUnit sx={{ fontSize: 40, color: theme.palette.info.light }} />,
|
||||
'stormy': <Thunderstorm sx={{ fontSize: 40, color: theme.palette.error.main }} />
|
||||
};
|
||||
return iconMap[iconType] || <Cloud sx={{ fontSize: 40 }} />;
|
||||
};
|
||||
|
||||
// Small icon for forecast
|
||||
const getForecastIcon = (iconType) => {
|
||||
const iconMap = {
|
||||
'sunny': <WbSunny sx={{ fontSize: 24, color: theme.palette.warning.main }} />,
|
||||
'partly_cloudy': <WbCloudy sx={{ fontSize: 24, color: theme.palette.grey[600] }} />,
|
||||
'cloudy': <Cloud sx={{ fontSize: 24, color: theme.palette.grey[500] }} />,
|
||||
'foggy': <Foggy sx={{ fontSize: 24, color: theme.palette.grey[400] }} />,
|
||||
'rainy': <Grain sx={{ fontSize: 24, color: theme.palette.info.main }} />,
|
||||
'snowy': <AcUnit sx={{ fontSize: 24, color: theme.palette.info.light }} />,
|
||||
'stormy': <Thunderstorm sx={{ fontSize: 24, color: theme.palette.error.main }} />
|
||||
};
|
||||
return iconMap[iconType] || <Cloud sx={{ fontSize: 24 }} />;
|
||||
};
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateStr) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
// Format short date for charts based on user preference
|
||||
const formatChartDate = (dateStr) => {
|
||||
const date = new Date(dateStr);
|
||||
const format = import.meta.env.REACT_APP_CHART_DATE_FORMAT || 'MM/DD/YYYY';
|
||||
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
|
||||
switch(format) {
|
||||
case 'MM/DD/YYYY':
|
||||
return `${month}/${day}/${year}`;
|
||||
case 'DD/MM/YYYY':
|
||||
return `${day}/${month}/${year}`;
|
||||
case 'MM/DD':
|
||||
return `${month}/${day}`;
|
||||
case 'DD/MM':
|
||||
return `${day}/${month}`;
|
||||
default:
|
||||
return `${month}/${day}/${year}`; // Default to MM/DD/YYYY
|
||||
}
|
||||
};
|
||||
|
||||
if (!data || data.error) {
|
||||
return (
|
||||
<GenericWidget title="Weather" sx={{ opacity: 0.5, ...sx }}>
|
||||
<p className="widget-error">
|
||||
{data?.message || 'Weather data unavailable'}
|
||||
</p>
|
||||
<GenericWidget title="Weather" sx={{ minWidth: '50%', opacity: 0.7, ...sx }}>
|
||||
<Box sx={{ textAlign: 'center', py: 3 }}>
|
||||
<Cloud sx={{ fontSize: 48, color: theme.palette.grey[400], mb: 2 }} />
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{data?.message || 'Weather data unavailable'}
|
||||
</Typography>
|
||||
{data?.location && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Location: {data.location}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</GenericWidget>
|
||||
);
|
||||
}
|
||||
|
||||
const current = data.current || {};
|
||||
const location = data.nearestArea?.areaName?.[0]?.value || data.location || 'Unknown';
|
||||
const temp = current.temp_F || current.temp_C || '?';
|
||||
const tempUnit = current.temp_F ? '°F' : '°C';
|
||||
const description = current.weatherDesc?.[0]?.value || 'No description';
|
||||
const humidity = current.humidity || '?';
|
||||
const windSpeed = current.windspeedMiles || current.windspeedKmph || '?';
|
||||
const windUnit = current.windspeedMiles ? 'mph' : 'km/h';
|
||||
const { current, forecast, historical, units, location } = data;
|
||||
|
||||
// Prepare chart data
|
||||
const tempChartData = historical?.dates ? historical.dates.map((date, idx) => ({
|
||||
date: formatChartDate(date),
|
||||
high: historical.maxTemps[idx],
|
||||
low: historical.minTemps[idx]
|
||||
})) : [];
|
||||
|
||||
const windChartData = historical?.dates ? historical.dates.map((date, idx) => ({
|
||||
date: formatChartDate(date),
|
||||
max: historical.maxWindSpeeds[idx],
|
||||
avg: historical.avgWindSpeeds[idx]
|
||||
})) : [];
|
||||
|
||||
return (
|
||||
<GenericWidget title={`Weather - ${location}`} sx={{ ...sx }}>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<GenericWidget title={`Weather - ${location}`} sx={{ minWidth: '50%', ...sx }}>
|
||||
{/* Current Weather Section */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Box sx={{ mr: 2 }}>
|
||||
{getWeatherIcon(description)}
|
||||
<Box sx={{ mr: 3 }}>
|
||||
{getWeatherIcon(current?.icon)}
|
||||
</Box>
|
||||
<Box>
|
||||
<span className="weather-temp">{temp}</span>
|
||||
<span className="weather-unit">{tempUnit}</span>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h3" component="span" sx={{ fontWeight: 300 }}>
|
||||
{Math.round(current?.temp || 0)}
|
||||
</Typography>
|
||||
<Typography variant="h5" component="span" sx={{ ml: 0.5 }}>
|
||||
{units?.temperature}
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mt: 1 }}>
|
||||
{current?.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<p className="weather-description">{description}</p>
|
||||
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1 }}>
|
||||
<Chip
|
||||
label={`Humidity: ${humidity}%`}
|
||||
icon={<Opacity />}
|
||||
label={`${current?.humidity}%`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ borderColor: theme.palette.info.main }}
|
||||
/>
|
||||
<Chip
|
||||
label={`Wind: ${windSpeed} ${windUnit}`}
|
||||
icon={<Air />}
|
||||
label={`${Math.round(current?.windSpeed || 0)} ${units?.windSpeed}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ borderColor: theme.palette.success.main }}
|
||||
/>
|
||||
<Chip
|
||||
label={`Feels like ${Math.round(current?.apparentTemp || 0)}${units?.temperature}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{data.forecast && data.forecast.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<div className="forecast-label">Forecast</div>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{data.forecast.slice(0, 3).map((day, index) => (
|
||||
<Box key={index} sx={{ textAlign: 'center' }}>
|
||||
<div className="forecast-day">Day {index + 1}</div>
|
||||
<div className="forecast-temp">
|
||||
{day.maxtempF || day.maxtempC}° / {day.mintempF || day.mintempC}°
|
||||
</div>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* 5-Day Forecast */}
|
||||
{forecast && forecast.length > 0 && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
5-Day Forecast
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, overflowX: 'auto' }}>
|
||||
{forecast.map((day, index) => (
|
||||
<Paper
|
||||
key={index}
|
||||
elevation={1}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
minWidth: 90,
|
||||
textAlign: 'center',
|
||||
bgcolor: theme.palette.mode === 'dark'
|
||||
? 'rgba(255,255,255,0.05)'
|
||||
: 'rgba(0,0,0,0.02)',
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" display="block" sx={{ mb: 1 }}>
|
||||
{index === 0 ? 'Today' : formatDate(day.date)}
|
||||
</Typography>
|
||||
{getForecastIcon(day.icon)}
|
||||
<Typography variant="body2" sx={{ mt: 1, fontWeight: 500 }}>
|
||||
{Math.round(day.maxTemp)}°
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{Math.round(day.minTemp)}°
|
||||
</Typography>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* Historical Temperature Chart */}
|
||||
{tempChartData.length > 0 && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
7-Day Temperature History
|
||||
</Typography>
|
||||
<LineChart
|
||||
height={200}
|
||||
series={[
|
||||
{
|
||||
data: tempChartData.map(d => d.high),
|
||||
label: 'High',
|
||||
color: theme.palette.error.main,
|
||||
showMark: false
|
||||
},
|
||||
{
|
||||
data: tempChartData.map(d => d.low),
|
||||
label: 'Low',
|
||||
color: theme.palette.info.main,
|
||||
showMark: false
|
||||
}
|
||||
]}
|
||||
xAxis={[{
|
||||
scaleType: 'point',
|
||||
data: tempChartData.map(d => d.date),
|
||||
tickLabelStyle: {
|
||||
angle: 45,
|
||||
textAnchor: 'start',
|
||||
fontSize: 10
|
||||
},
|
||||
tickNumber: tempChartData.length
|
||||
}]}
|
||||
yAxis={[{
|
||||
label: `Temperature (${units?.temperature})`,
|
||||
labelStyle: { fontSize: 12 }
|
||||
}]}
|
||||
margin={{ top: 20, bottom: 80, left: 60, right: 30 }}
|
||||
sx={{
|
||||
'.MuiLineElement-root': {
|
||||
strokeWidth: 2
|
||||
},
|
||||
'.MuiMarkElement-root': {
|
||||
scale: '0.6'
|
||||
}
|
||||
}}
|
||||
slotProps={{
|
||||
legend: {
|
||||
direction: 'row',
|
||||
position: { vertical: 'top', horizontal: 'middle' },
|
||||
padding: 0
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* Historical Wind Speed Chart */}
|
||||
{windChartData.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
7-Day Wind Speed History
|
||||
</Typography>
|
||||
<LineChart
|
||||
height={200}
|
||||
series={[
|
||||
{
|
||||
data: windChartData.map(d => d.max),
|
||||
label: 'Max Wind',
|
||||
color: theme.palette.warning.main,
|
||||
showMark: false
|
||||
},
|
||||
{
|
||||
data: windChartData.map(d => d.avg),
|
||||
label: 'Avg Wind',
|
||||
color: theme.palette.success.main,
|
||||
showMark: false
|
||||
}
|
||||
]}
|
||||
xAxis={[{
|
||||
scaleType: 'point',
|
||||
data: windChartData.map(d => d.date),
|
||||
tickLabelStyle: {
|
||||
angle: 45,
|
||||
textAnchor: 'start',
|
||||
fontSize: 10
|
||||
},
|
||||
tickNumber: windChartData.length
|
||||
}]}
|
||||
yAxis={[{
|
||||
label: `Wind Speed (${units?.windSpeed})`,
|
||||
labelStyle: { fontSize: 12 }
|
||||
}]}
|
||||
margin={{ top: 20, bottom: 80, left: 60, right: 30 }}
|
||||
sx={{
|
||||
'.MuiLineElement-root': {
|
||||
strokeWidth: 2
|
||||
},
|
||||
'.MuiMarkElement-root': {
|
||||
scale: '0.6'
|
||||
}
|
||||
}}
|
||||
slotProps={{
|
||||
legend: {
|
||||
direction: 'row',
|
||||
position: { vertical: 'top', horizontal: 'middle' },
|
||||
padding: 0
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</GenericWidget>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { parseSource, standardizeSourceData } from './sourceParser';
|
||||
import { getCachedData, cacheSourceData } from './storageService';
|
||||
import { fetchWithCors } from '../utils/corsProxy';
|
||||
import { fetchOpenMeteoWeather } from './openMeteoService';
|
||||
import {
|
||||
DEFAULT_DATA_SOURCES,
|
||||
WEATHER_API_BASE,
|
||||
DEFAULT_REFRESH_INTERVAL
|
||||
DEFAULT_REFRESH_INTERVAL,
|
||||
DEFAULT_TEMPERATURE_UNIT
|
||||
} from '../utils/constants';
|
||||
|
||||
/**
|
||||
|
|
@ -64,8 +66,8 @@ export async function fetchSingleSource(url) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Fetch weather data from wttr.in
|
||||
* @param {string} location - Location string (city, zip, etc.)
|
||||
* Fetch weather data using Open-Meteo API
|
||||
* @param {string|number|Object} location - Location as string, zip code, or {latitude, longitude}
|
||||
* @returns {Promise<Object>} Weather data
|
||||
*/
|
||||
export async function fetchWeather(location) {
|
||||
|
|
@ -74,41 +76,27 @@ export async function fetchWeather(location) {
|
|||
return null;
|
||||
}
|
||||
|
||||
const cacheKey = `weather_${location.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
||||
|
||||
// Check cache first
|
||||
const cached = getCachedData(cacheKey);
|
||||
if (cached) {
|
||||
console.log(`Using cached weather data for ${location}`);
|
||||
return cached;
|
||||
// Parse location - check if it's a numeric string (zip code)
|
||||
let parsedLocation = location;
|
||||
if (typeof location === 'string' && /^\d+$/.test(location)) {
|
||||
parsedLocation = parseInt(location, 10);
|
||||
}
|
||||
|
||||
// Get temperature unit from environment or use default
|
||||
const tempUnit = import.meta.env.REACT_APP_TEMPERATURE_UNIT || DEFAULT_TEMPERATURE_UNIT;
|
||||
|
||||
try {
|
||||
// Format location for wttr.in
|
||||
const formattedLocation = encodeURIComponent(location);
|
||||
const weatherUrl = `${WEATHER_API_BASE}/${formattedLocation}?format=j1`;
|
||||
|
||||
console.log(`Fetching weather for ${location}`);
|
||||
const data = await fetchWithCors(weatherUrl);
|
||||
|
||||
// Standardize weather data
|
||||
const weatherData = {
|
||||
type: 'weather',
|
||||
location: location,
|
||||
current: data.current_condition?.[0] || {},
|
||||
forecast: data.weather || [],
|
||||
nearestArea: data.nearest_area?.[0] || {},
|
||||
fetchedAt: Date.now()
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
const ttl = parseInt(import.meta.env.REACT_APP_REFRESH_INTERVAL) || DEFAULT_REFRESH_INTERVAL;
|
||||
cacheSourceData(cacheKey, weatherData, ttl);
|
||||
|
||||
// Use the new Open-Meteo service (now supports numbers and objects)
|
||||
const weatherData = await fetchOpenMeteoWeather(parsedLocation, tempUnit);
|
||||
return weatherData;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching weather for ${location}:`, error);
|
||||
return null;
|
||||
console.error(`Error fetching weather for ${parsedLocation}:`, error);
|
||||
return {
|
||||
type: 'weather',
|
||||
error: true,
|
||||
message: error.message || 'Failed to fetch weather data',
|
||||
location: location
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
286
claudash/src/services/openMeteoService.js
Normal file
286
claudash/src/services/openMeteoService.js
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
import { getCachedData, cacheSourceData } from './storageService';
|
||||
import { fetchWithCors } from '../utils/corsProxy';
|
||||
|
||||
// Weather code descriptions mapping (WMO Weather interpretation codes)
|
||||
const WEATHER_CODE_MAP = {
|
||||
0: { description: 'Clear sky', icon: 'sunny' },
|
||||
1: { description: 'Mainly clear', icon: 'partly_cloudy' },
|
||||
2: { description: 'Partly cloudy', icon: 'partly_cloudy' },
|
||||
3: { description: 'Overcast', icon: 'cloudy' },
|
||||
45: { description: 'Foggy', icon: 'foggy' },
|
||||
48: { description: 'Depositing rime fog', icon: 'foggy' },
|
||||
51: { description: 'Light drizzle', icon: 'rainy' },
|
||||
53: { description: 'Moderate drizzle', icon: 'rainy' },
|
||||
55: { description: 'Dense drizzle', icon: 'rainy' },
|
||||
56: { description: 'Light freezing drizzle', icon: 'rainy' },
|
||||
57: { description: 'Dense freezing drizzle', icon: 'rainy' },
|
||||
61: { description: 'Slight rain', icon: 'rainy' },
|
||||
63: { description: 'Moderate rain', icon: 'rainy' },
|
||||
65: { description: 'Heavy rain', icon: 'rainy' },
|
||||
66: { description: 'Light freezing rain', icon: 'rainy' },
|
||||
67: { description: 'Heavy freezing rain', icon: 'rainy' },
|
||||
71: { description: 'Slight snow fall', icon: 'snowy' },
|
||||
73: { description: 'Moderate snow fall', icon: 'snowy' },
|
||||
75: { description: 'Heavy snow fall', icon: 'snowy' },
|
||||
77: { description: 'Snow grains', icon: 'snowy' },
|
||||
80: { description: 'Slight rain showers', icon: 'rainy' },
|
||||
81: { description: 'Moderate rain showers', icon: 'rainy' },
|
||||
82: { description: 'Violent rain showers', icon: 'stormy' },
|
||||
85: { description: 'Slight snow showers', icon: 'snowy' },
|
||||
86: { description: 'Heavy snow showers', icon: 'snowy' },
|
||||
95: { description: 'Thunderstorm', icon: 'stormy' },
|
||||
96: { description: 'Thunderstorm with slight hail', icon: 'stormy' },
|
||||
99: { description: 'Thunderstorm with heavy hail', icon: 'stormy' }
|
||||
};
|
||||
|
||||
/**
|
||||
* Get weather description and icon from weather code
|
||||
* @param {number} code - WMO weather interpretation code
|
||||
* @returns {Object} Description and icon type
|
||||
*/
|
||||
function getWeatherInfo(code) {
|
||||
return WEATHER_CODE_MAP[code] || { description: 'Unknown', icon: 'cloudy' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for API calls
|
||||
* @param {Date} date - Date object
|
||||
* @returns {string} Formatted date string YYYY-MM-DD
|
||||
*/
|
||||
function formatDate(date) {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Geocode location to coordinates using Open-Meteo
|
||||
* @param {string|number|Object} location - Location as string, zip code (number), or {latitude, longitude} object
|
||||
* @returns {Promise<Object>} Geocoded coordinates
|
||||
*/
|
||||
async function geocodeLocation(location) {
|
||||
// If location is already a coordinates object, return it
|
||||
if (typeof location === 'object' && location.latitude && location.longitude) {
|
||||
return {
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
displayName: location.displayName || `${location.latitude}, ${location.longitude}`,
|
||||
name: location.name || 'Custom Location',
|
||||
country: location.country || '',
|
||||
admin1: location.admin1 || ''
|
||||
};
|
||||
}
|
||||
|
||||
// Convert zip code (number) to string for geocoding
|
||||
const locationStr = typeof location === 'number' ? location.toString() : location;
|
||||
const cacheKey = `geocode_${locationStr.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
||||
|
||||
// Check cache first (geocoding results are stable)
|
||||
const cached = getCachedData(cacheKey);
|
||||
if (cached) {
|
||||
console.log(`Using cached geocoding for ${locationStr}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(locationStr)}&count=1&language=en&format=json`;
|
||||
console.log(`Geocoding location: ${locationStr}`);
|
||||
|
||||
const data = await fetchWithCors(url);
|
||||
|
||||
if (data.results && data.results.length > 0) {
|
||||
const result = data.results[0];
|
||||
const geocodeData = {
|
||||
latitude: result.latitude,
|
||||
longitude: result.longitude,
|
||||
name: result.name,
|
||||
country: result.country,
|
||||
admin1: result.admin1, // State/Province
|
||||
displayName: `${result.name}${result.admin1 ? ', ' + result.admin1 : ''}${result.country ? ', ' + result.country : ''}`
|
||||
};
|
||||
|
||||
// Cache for 7 days (geocoding doesn't change often)
|
||||
cacheSourceData(cacheKey, geocodeData, 60 * 24 * 7);
|
||||
return geocodeData;
|
||||
} else {
|
||||
throw new Error(`Location not found: ${location}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Geocoding error for ${location}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch current weather and forecast from Open-Meteo
|
||||
* @param {number} latitude - Latitude
|
||||
* @param {number} longitude - Longitude
|
||||
* @param {string} tempUnit - Temperature unit ('F' or 'C')
|
||||
* @returns {Promise<Object>} Current weather and forecast data
|
||||
*/
|
||||
async function fetchCurrentAndForecast(latitude, longitude, tempUnit = 'F') {
|
||||
const tempUnitParam = tempUnit === 'F' ? 'fahrenheit' : 'celsius';
|
||||
const windUnitParam = tempUnit === 'F' ? 'mph' : 'kmh';
|
||||
|
||||
const url = `https://api.open-meteo.com/v1/forecast?` +
|
||||
`latitude=${latitude}&longitude=${longitude}` +
|
||||
`¤t=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m,wind_direction_10m` +
|
||||
`&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max` +
|
||||
`&temperature_unit=${tempUnitParam}` +
|
||||
`&wind_speed_unit=${windUnitParam}` +
|
||||
`&timezone=auto` +
|
||||
`&forecast_days=5`;
|
||||
|
||||
console.log(`Fetching current weather and forecast for ${latitude}, ${longitude}`);
|
||||
|
||||
const data = await fetchWithCors(url);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch historical weather data from Open-Meteo Archive API
|
||||
* @param {number} latitude - Latitude
|
||||
* @param {number} longitude - Longitude
|
||||
* @param {string} tempUnit - Temperature unit ('F' or 'C')
|
||||
* @returns {Promise<Object>} Historical weather data
|
||||
*/
|
||||
async function fetchHistorical(latitude, longitude, tempUnit = 'F') {
|
||||
const tempUnitParam = tempUnit === 'F' ? 'fahrenheit' : 'celsius';
|
||||
const windUnitParam = tempUnit === 'F' ? 'mph' : 'kmh';
|
||||
|
||||
// Calculate date range (past 7 days)
|
||||
const endDate = new Date();
|
||||
endDate.setDate(endDate.getDate() - 1); // Yesterday
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - 7); // 7 days ago
|
||||
|
||||
const url = `https://archive-api.open-meteo.com/v1/archive?` +
|
||||
`latitude=${latitude}&longitude=${longitude}` +
|
||||
`&start_date=${formatDate(startDate)}` +
|
||||
`&end_date=${formatDate(endDate)}` +
|
||||
`&daily=temperature_2m_max,temperature_2m_min,wind_speed_10m_max,wind_speed_10m_mean,weather_code` +
|
||||
`&temperature_unit=${tempUnitParam}` +
|
||||
`&wind_speed_unit=${windUnitParam}` +
|
||||
`&timezone=auto`;
|
||||
|
||||
console.log(`Fetching historical weather for ${latitude}, ${longitude}`);
|
||||
|
||||
const data = await fetchWithCors(url);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch complete weather data from Open-Meteo
|
||||
* @param {string|number|Object} location - Location as string, zip code, or {latitude, longitude}
|
||||
* @param {string} tempUnit - Temperature unit ('F' or 'C')
|
||||
* @returns {Promise<Object>} Complete weather data
|
||||
*/
|
||||
export async function fetchOpenMeteoWeather(location, tempUnit = 'F') {
|
||||
if (!location) {
|
||||
throw new Error('No location provided');
|
||||
}
|
||||
|
||||
// Generate cache key based on location type
|
||||
let cacheKeyLocation;
|
||||
if (typeof location === 'object' && location.latitude && location.longitude) {
|
||||
cacheKeyLocation = `${location.latitude}_${location.longitude}`;
|
||||
} else {
|
||||
cacheKeyLocation = String(location).replace(/[^a-zA-Z0-9]/g, '_');
|
||||
}
|
||||
const cacheKey = `openmeteo_${cacheKeyLocation}_${tempUnit}`;
|
||||
|
||||
// Check cache first
|
||||
const cached = getCachedData(cacheKey);
|
||||
if (cached) {
|
||||
console.log(`Using cached Open-Meteo data for ${location}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Geocode the location
|
||||
const geocoded = await geocodeLocation(location);
|
||||
|
||||
// Step 2: Fetch current weather and forecast
|
||||
const currentForecast = await fetchCurrentAndForecast(
|
||||
geocoded.latitude,
|
||||
geocoded.longitude,
|
||||
tempUnit
|
||||
);
|
||||
|
||||
// Step 3: Fetch historical data
|
||||
const historical = await fetchHistorical(
|
||||
geocoded.latitude,
|
||||
geocoded.longitude,
|
||||
tempUnit
|
||||
);
|
||||
|
||||
// Parse current weather
|
||||
const currentWeatherInfo = getWeatherInfo(currentForecast.current.weather_code);
|
||||
|
||||
// Parse forecast data
|
||||
const forecast = currentForecast.daily.time.map((date, index) => {
|
||||
const weatherInfo = getWeatherInfo(currentForecast.daily.weather_code[index]);
|
||||
return {
|
||||
date: date,
|
||||
maxTemp: currentForecast.daily.temperature_2m_max[index],
|
||||
minTemp: currentForecast.daily.temperature_2m_min[index],
|
||||
precipitation: currentForecast.daily.precipitation_sum[index],
|
||||
windSpeed: currentForecast.daily.wind_speed_10m_max[index],
|
||||
weatherCode: currentForecast.daily.weather_code[index],
|
||||
description: weatherInfo.description,
|
||||
icon: weatherInfo.icon
|
||||
};
|
||||
});
|
||||
|
||||
// Parse historical data
|
||||
const historicalData = {
|
||||
dates: historical.daily.time,
|
||||
maxTemps: historical.daily.temperature_2m_max,
|
||||
minTemps: historical.daily.temperature_2m_min,
|
||||
maxWindSpeeds: historical.daily.wind_speed_10m_max,
|
||||
avgWindSpeeds: historical.daily.wind_speed_10m_mean,
|
||||
weatherCodes: historical.daily.weather_code
|
||||
};
|
||||
|
||||
// Build complete weather data structure
|
||||
const weatherData = {
|
||||
type: 'weather',
|
||||
location: geocoded.displayName,
|
||||
coordinates: {
|
||||
latitude: geocoded.latitude,
|
||||
longitude: geocoded.longitude
|
||||
},
|
||||
current: {
|
||||
temp: currentForecast.current.temperature_2m,
|
||||
apparentTemp: currentForecast.current.apparent_temperature,
|
||||
humidity: currentForecast.current.relative_humidity_2m,
|
||||
windSpeed: currentForecast.current.wind_speed_10m,
|
||||
windDirection: currentForecast.current.wind_direction_10m,
|
||||
weatherCode: currentForecast.current.weather_code,
|
||||
description: currentWeatherInfo.description,
|
||||
icon: currentWeatherInfo.icon,
|
||||
time: currentForecast.current.time
|
||||
},
|
||||
forecast: forecast,
|
||||
historical: historicalData,
|
||||
units: {
|
||||
temperature: tempUnit === 'F' ? '°F' : '°C',
|
||||
windSpeed: tempUnit === 'F' ? 'mph' : 'km/h',
|
||||
precipitation: 'in'
|
||||
},
|
||||
fetchedAt: Date.now()
|
||||
};
|
||||
|
||||
// Cache for 15 minutes
|
||||
cacheSourceData(cacheKey, weatherData, 15);
|
||||
|
||||
return weatherData;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching Open-Meteo weather for ${location}:`, error);
|
||||
// Return error object that WeatherWidget can handle
|
||||
return {
|
||||
type: 'weather',
|
||||
error: true,
|
||||
message: error.message || 'Failed to fetch weather data',
|
||||
location: location
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ export const DEFAULT_REFRESH_INTERVAL = 15; // minutes
|
|||
export const DEFAULT_HISTORY_DAYS = 7;
|
||||
export const DEFAULT_CLAUDE_MODEL = 'claude-sonnet-4-5-20250929';
|
||||
export const DEFAULT_CORS_PROXY = 'https://api.allorigins.win/raw?url=';
|
||||
export const DEFAULT_TEMPERATURE_UNIT = 'F'; // 'F' for Fahrenheit, 'C' for Celsius
|
||||
|
||||
// Cache TTL in minutes
|
||||
export const CACHE_TTL = 15;
|
||||
|
|
|
|||
Loading…
Reference in a new issue