diff --git a/claudash/.env.example b/claudash/.env.example index 3826623..b2d84ea 100644 --- a/claudash/.env.example +++ b/claudash/.env.example @@ -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 diff --git a/claudash/src/components/Dashboard.jsx b/claudash/src/components/Dashboard.jsx index aa00cf4..9e8da41 100644 --- a/claudash/src/components/Dashboard.jsx +++ b/claudash/src/components/Dashboard.jsx @@ -213,7 +213,7 @@ function Dashboard({ themeToggle, isDark }) { - + diff --git a/claudash/src/components/widgets/WeatherWidget.jsx b/claudash/src/components/widgets/WeatherWidget.jsx index 31e4c6d..fde8e46 100644 --- a/claudash/src/components/widgets/WeatherWidget.jsx +++ b/claudash/src/components/widgets/WeatherWidget.jsx @@ -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 ; - if (desc.includes('cloud') || desc.includes('overcast')) return ; - if (desc.includes('rain') || desc.includes('storm')) return ; - if (desc.includes('snow') || desc.includes('ice')) return ; - return ; + const theme = useTheme(); + + // Weather icon mapping based on Open-Meteo icon types + const getWeatherIcon = (iconType) => { + const iconMap = { + 'sunny': , + 'partly_cloudy': , + 'cloudy': , + 'foggy': , + 'rainy': , + 'snowy': , + 'stormy': + }; + return iconMap[iconType] || ; + }; + + // Small icon for forecast + const getForecastIcon = (iconType) => { + const iconMap = { + 'sunny': , + 'partly_cloudy': , + 'cloudy': , + 'foggy': , + 'rainy': , + 'snowy': , + 'stormy': + }; + return iconMap[iconType] || ; + }; + + // 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 ( - -

- {data?.message || 'Weather data unavailable'} -

+ + + + + {data?.message || 'Weather data unavailable'} + + {data?.location && ( + + Location: {data.location} + + )} + ); } - 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 ( - - + + {/* Current Weather Section */} + - - {getWeatherIcon(description)} + + {getWeatherIcon(current?.icon)} - - {temp} - {tempUnit} + + + {Math.round(current?.temp || 0)} + + + {units?.temperature} + + + {current?.description} + -

{description}

- - + } + label={`${current?.humidity}%`} size="small" variant="outlined" + sx={{ borderColor: theme.palette.info.main }} /> } + label={`${Math.round(current?.windSpeed || 0)} ${units?.windSpeed}`} + size="small" + variant="outlined" + sx={{ borderColor: theme.palette.success.main }} + /> + - - {data.forecast && data.forecast.length > 0 && ( - -
Forecast
- - {data.forecast.slice(0, 3).map((day, index) => ( - -
Day {index + 1}
-
- {day.maxtempF || day.maxtempC}° / {day.mintempF || day.mintempC}° -
-
- ))} -
-
- )}
+ + + + {/* 5-Day Forecast */} + {forecast && forecast.length > 0 && ( + + + 5-Day Forecast + + + {forecast.map((day, index) => ( + + + {index === 0 ? 'Today' : formatDate(day.date)} + + {getForecastIcon(day.icon)} + + {Math.round(day.maxTemp)}° + + + {Math.round(day.minTemp)}° + + + ))} + + + )} + + + + {/* Historical Temperature Chart */} + {tempChartData.length > 0 && ( + + + 7-Day Temperature History + + 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 + } + }} + /> + + )} + + + + {/* Historical Wind Speed Chart */} + {windChartData.length > 0 && ( + + + 7-Day Wind Speed History + + 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 + } + }} + /> + + )}
); } diff --git a/claudash/src/services/dataFetcher.js b/claudash/src/services/dataFetcher.js index af35cdd..21e7f65 100644 --- a/claudash/src/services/dataFetcher.js +++ b/claudash/src/services/dataFetcher.js @@ -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} 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 + }; } } diff --git a/claudash/src/services/openMeteoService.js b/claudash/src/services/openMeteoService.js new file mode 100644 index 0000000..cc82e12 --- /dev/null +++ b/claudash/src/services/openMeteoService.js @@ -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} 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} 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} 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} 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 + }; + } +} \ No newline at end of file diff --git a/claudash/src/utils/constants.js b/claudash/src/utils/constants.js index 276c1d1..3ccef60 100644 --- a/claudash/src/utils/constants.js +++ b/claudash/src/utils/constants.js @@ -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;