diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7f3d66f --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# Flask Configuration +FLASK_SECRET_KEY=your_secret_key_here + +# OpenWeatherMap API +OPENWEATHER_API_KEY=your_api_key_here + +# Weather Location (Hamilton, New Zealand) +WEATHER_LOCATION=Hamilton,NZ +WEATHER_LAT=-37.7870 +WEATHER_LON=175.2793 + +# Google Calendar +GOOGLE_CALENDAR_ID=your_calendar_id@group.calendar.google.com + +# Update Intervals (seconds) +IMAGE_ROTATION_INTERVAL=300 +WEATHER_UPDATE_INTERVAL=900 +CALENDAR_UPDATE_INTERVAL=300 +JOKE_UPDATE_INTERVAL=3600 +CALENDAR_DAYS_AHEAD=7 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac7bf28 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Environment variables +.env + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +env/ +ENV/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Credentials +credentials/*.json +!credentials/.gitkeep + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log diff --git a/README.md b/README.md index c31025d..2277788 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,224 @@ -# Calender +# Family Calendar Display -Project created: February 14, 2026 +A smart display application for Raspberry Pi that shows time, weather, calendar events, and rotating background images. + +**Project created:** February 14, 2026 + +## Features + +- 🕐 **Real-time Clock** - Current time and date display +- 🌤️ **Weather** - Current weather and 5-day forecast for Hamilton, New Zealand (OpenWeatherMap) +- 📅 **Google Calendar** - Family calendar events display +- 🖼️ **Rotating Backgrounds** - Beautiful images from local directory +- 😄 **Dad Jokes** - Random jokes to brighten your day (optional) + +## Requirements + +- Python 3.8+ +- Raspberry Pi (tested on Pi 3/4) or any Linux system +- Google Calendar API credentials +- OpenWeatherMap API key (free tier) + +## Quick Start + +### 1. Clone the Repository + +```bash +git clone ssh://git@gitea.meyfamily.co.nz:2222/luddie/Calender.git +cd Calender +``` + +### 2. Install Dependencies + +```bash +python3 -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +pip install -r requirements.txt +``` + +### 3. Configure Environment Variables + +```bash +cp .env.example .env +``` + +Edit [.env](.env) and add your API keys: + +```bash +# Get your OpenWeatherMap API key from: https://openweathermap.org/api +OPENWEATHER_API_KEY=your_api_key_here + +# Your Google Calendar ID (found in Google Calendar settings) +GOOGLE_CALENDAR_ID=your_calendar_id@group.calendar.google.com + +# Generate a secret key for Flask +FLASK_SECRET_KEY=your_random_secret_key +``` + +### 4. Set Up Google Calendar API + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Create a new project or select existing one +3. Enable the Google Calendar API +4. Create credentials (OAuth 2.0 or Service Account) +5. Download the credentials JSON file +6. Save it as [credentials/google_calendar_credentials.json](credentials/google_calendar_credentials.json) + +### 5. Add Background Images + +Place your images in the [static/backgrounds/](static/backgrounds/) directory: + +```bash +cp /path/to/your/images/*.jpg static/backgrounds/ +``` + +Supported formats: JPG, PNG, GIF, WebP + +### 6. Run the Application + +```bash +python app.py +``` + +Visit `http://localhost:5000` in your browser. + +## Raspberry Pi Deployment + +### Auto-start Flask Application + +Create a systemd service: + +```bash +sudo nano /etc/systemd/system/calendar-display.service +``` + +Add the following content: + +```ini +[Unit] +Description=Calendar Display Flask App +After=network.target + +[Service] +User=pi +WorkingDirectory=/home/pi/Calender +ExecStart=/home/pi/Calender/venv/bin/python app.py +Restart=always +Environment="PATH=/home/pi/Calender/venv/bin" + +[Install] +WantedBy=multi-user.target +``` + +Enable and start the service: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable calendar-display.service +sudo systemctl start calendar-display.service +``` + +### Configure Chromium Kiosk Mode + +Edit the autostart file: + +```bash +mkdir -p ~/.config/lxsession/LXDE-pi +nano ~/.config/lxsession/LXDE-pi/autostart +``` + +Add these lines: + +```bash +@xset s off +@xset -dpms +@xset s noblank +@chromium-browser --kiosk --noerrdialogs --disable-infobars --disable-session-crashed-bubble http://localhost:5000 +``` + +Disable screensaver and cursor (optional): + +```bash +sudo apt-get install unclutter +echo "@unclutter -idle 0" >> ~/.config/lxsession/LXDE-pi/autostart +``` + +### Reboot + +```bash +sudo reboot +``` + +The display should now start automatically on boot! + +## Configuration + +Edit [.env](.env) to customize: + +- `IMAGE_ROTATION_INTERVAL` - Seconds between background changes (default: 300) +- `WEATHER_UPDATE_INTERVAL` - Seconds between weather updates (default: 900) +- `CALENDAR_UPDATE_INTERVAL` - Seconds between calendar updates (default: 300) +- `JOKE_UPDATE_INTERVAL` - Seconds between joke updates (default: 3600) +- `CALENDAR_DAYS_AHEAD` - Number of days ahead to show events (default: 7) + +## Project Structure + +``` +Calender/ +├── app.py # Main Flask application +├── config.py # Configuration management +├── requirements.txt # Python dependencies +├── .env # Environment variables (create from .env.example) +├── .env.example # Environment template +├── static/ +│ ├── css/style.css # Styling +│ ├── js/app.js # Frontend JavaScript +│ └── backgrounds/ # Background images directory +├── templates/ +│ └── index.html # Main display template +└── credentials/ + └── google_calendar_credentials.json # Google API credentials +``` + +## API Endpoints + +- `GET /` - Main display page +- `GET /api/weather` - Weather data (cached for 15 min) +- `GET /api/calendar` - Calendar events (cached for 5 min) +- `GET /api/background` - Random background image +- `GET /api/joke` - Dad joke (cached for 1 hour) + +## Troubleshooting + +### Weather not loading +- Check your OpenWeatherMap API key is valid +- Ensure you're not exceeding the free tier rate limits (60 calls/min) + +### Calendar not loading +- Verify Google Calendar API credentials are set up correctly +- Check the calendar ID is correct +- Ensure the service account has access to the calendar + +### Display not starting on boot +- Check systemd service status: `sudo systemctl status calendar-display.service` +- View logs: `sudo journalctl -u calendar-display.service -f` + +### Background images not showing +- Ensure images are in [static/backgrounds/](static/backgrounds/) +- Check file permissions: `chmod 644 static/backgrounds/*` + +## Contributing + +Feel free to submit issues or pull requests! + +## License + +MIT License - Feel free to use and modify for personal use. + +## Credits + +Built with: +- [Flask](https://flask.palletsprojects.com/) - Web framework +- [OpenWeatherMap API](https://openweathermap.org/api) - Weather data +- [Google Calendar API](https://developers.google.com/calendar) - Calendar integration +- [icanhazdadjoke](https://icanhazdadjoke.com/) - Dad jokes API diff --git a/app.py b/app.py new file mode 100644 index 0000000..e5cc9ac --- /dev/null +++ b/app.py @@ -0,0 +1,187 @@ +from flask import Flask, render_template, jsonify +import requests +import os +import random +from datetime import datetime, timedelta +from config import Config + +app = Flask(__name__) +app.config.from_object(Config) + +# Cache for API responses to avoid rate limiting +weather_cache = {'data': None, 'timestamp': None} +calendar_cache = {'data': None, 'timestamp': None} +joke_cache = {'data': None, 'timestamp': None} + + +@app.route('/') +def index(): + """Render the main display page.""" + return render_template('index.html') + + +@app.route('/api/weather') +def get_weather(): + """Fetch weather data from OpenWeatherMap API.""" + global weather_cache + + # Check cache + now = datetime.now() + if (weather_cache['data'] and weather_cache['timestamp'] and + (now - weather_cache['timestamp']).total_seconds() < app.config['WEATHER_UPDATE_INTERVAL']): + return jsonify(weather_cache['data']) + + try: + # Fetch current weather + current_url = f"https://api.openweathermap.org/data/2.5/weather" + params = { + 'lat': app.config['WEATHER_LAT'], + 'lon': app.config['WEATHER_LON'], + 'appid': app.config['OPENWEATHER_API_KEY'], + 'units': app.config['WEATHER_UNITS'] + } + current_response = requests.get(current_url, params=params, timeout=10) + current_response.raise_for_status() + current_data = current_response.json() + + # Fetch 5-day forecast + forecast_url = f"https://api.openweathermap.org/data/2.5/forecast" + forecast_response = requests.get(forecast_url, params=params, timeout=10) + forecast_response.raise_for_status() + forecast_data = forecast_response.json() + + # Process forecast to get daily summaries + daily_forecast = [] + seen_dates = set() + + for item in forecast_data['list'][:40]: # Next 5 days (8 forecasts per day) + date = datetime.fromtimestamp(item['dt']).date() + if date not in seen_dates and len(daily_forecast) < 5: + seen_dates.add(date) + daily_forecast.append({ + 'date': date.strftime('%a'), + 'temp_max': round(item['main']['temp_max']), + 'temp_min': round(item['main']['temp_min']), + 'description': item['weather'][0]['description'], + 'icon': item['weather'][0]['icon'] + }) + + weather_data = { + 'current': { + 'temp': round(current_data['main']['temp']), + 'feels_like': round(current_data['main']['feels_like']), + 'description': current_data['weather'][0]['description'], + 'icon': current_data['weather'][0]['icon'], + 'humidity': current_data['main']['humidity'], + 'wind_speed': round(current_data['wind']['speed'] * 3.6, 1) # Convert m/s to km/h + }, + 'forecast': daily_forecast + } + + # Update cache + weather_cache = {'data': weather_data, 'timestamp': now} + + return jsonify(weather_data) + + except Exception as e: + app.logger.error(f"Error fetching weather: {str(e)}") + # Return cached data if available, otherwise return error + if weather_cache['data']: + return jsonify(weather_cache['data']) + return jsonify({'error': 'Unable to fetch weather data'}), 500 + + +@app.route('/api/calendar') +def get_calendar(): + """Fetch Google Calendar events.""" + global calendar_cache + + # Check cache + now = datetime.now() + if (calendar_cache['data'] and calendar_cache['timestamp'] and + (now - calendar_cache['timestamp']).total_seconds() < app.config['CALENDAR_UPDATE_INTERVAL']): + return jsonify(calendar_cache['data']) + + # TODO: Implement Google Calendar API integration + # For now, return placeholder data + try: + # This is placeholder data - will be replaced with actual Google Calendar API + events = [ + { + 'title': 'Setup Google Calendar API', + 'start': (datetime.now() + timedelta(hours=2)).isoformat(), + 'end': (datetime.now() + timedelta(hours=3)).isoformat(), + 'location': '' + } + ] + + calendar_cache = {'data': events, 'timestamp': now} + return jsonify(events) + + except Exception as e: + app.logger.error(f"Error fetching calendar: {str(e)}") + if calendar_cache['data']: + return jsonify(calendar_cache['data']) + return jsonify({'error': 'Unable to fetch calendar data'}), 500 + + +@app.route('/api/background') +def get_background(): + """Get a random background image.""" + try: + backgrounds_dir = app.config['BACKGROUNDS_DIR'] + + # Get list of image files + if os.path.exists(backgrounds_dir): + image_files = [f for f in os.listdir(backgrounds_dir) + if f.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp'))] + + if image_files: + random_image = random.choice(image_files) + return jsonify({'image': f'/static/backgrounds/{random_image}'}) + + # Return a default color if no images + return jsonify({'image': None, 'color': '#1a1a2e'}) + + except Exception as e: + app.logger.error(f"Error getting background: {str(e)}") + return jsonify({'image': None, 'color': '#1a1a2e'}) + + +@app.route('/api/joke') +def get_joke(): + """Fetch a dad joke.""" + global joke_cache + + # Check cache + now = datetime.now() + if (joke_cache['data'] and joke_cache['timestamp'] and + (now - joke_cache['timestamp']).total_seconds() < app.config['JOKE_UPDATE_INTERVAL']): + return jsonify(joke_cache['data']) + + try: + response = requests.get( + 'https://icanhazdadjoke.com/', + headers={'Accept': 'application/json'}, + timeout=10 + ) + response.raise_for_status() + joke_data = response.json() + + joke_cache = {'data': {'joke': joke_data['joke']}, 'timestamp': now} + return jsonify(joke_cache['data']) + + except Exception as e: + app.logger.error(f"Error fetching joke: {str(e)}") + if joke_cache['data']: + return jsonify(joke_cache['data']) + return jsonify({'joke': 'Why did the developer go broke? Because he used up all his cache!'}) + + +if __name__ == '__main__': + # Create backgrounds directory if it doesn't exist + os.makedirs(app.config['BACKGROUNDS_DIR'], exist_ok=True) + os.makedirs(app.config['CREDENTIALS_DIR'], exist_ok=True) + + # Run the app + app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/config.py b/config.py new file mode 100644 index 0000000..5accc5f --- /dev/null +++ b/config.py @@ -0,0 +1,30 @@ +import os +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +class Config: + # Flask settings + SECRET_KEY = os.getenv('FLASK_SECRET_KEY', 'dev-secret-key-change-in-production') + + # OpenWeatherMap API settings + OPENWEATHER_API_KEY = os.getenv('OPENWEATHER_API_KEY', '') + WEATHER_LOCATION = os.getenv('WEATHER_LOCATION', 'Hamilton,NZ') + WEATHER_LAT = float(os.getenv('WEATHER_LAT', '-37.7870')) + WEATHER_LON = float(os.getenv('WEATHER_LON', '175.2793')) + WEATHER_UNITS = 'metric' # Use Celsius + + # Google Calendar settings + GOOGLE_CALENDAR_ID = os.getenv('GOOGLE_CALENDAR_ID', '') + CALENDAR_DAYS_AHEAD = int(os.getenv('CALENDAR_DAYS_AHEAD', '7')) + + # Update intervals (in seconds) + IMAGE_ROTATION_INTERVAL = int(os.getenv('IMAGE_ROTATION_INTERVAL', '300')) # 5 minutes + WEATHER_UPDATE_INTERVAL = int(os.getenv('WEATHER_UPDATE_INTERVAL', '900')) # 15 minutes + CALENDAR_UPDATE_INTERVAL = int(os.getenv('CALENDAR_UPDATE_INTERVAL', '300')) # 5 minutes + JOKE_UPDATE_INTERVAL = int(os.getenv('JOKE_UPDATE_INTERVAL', '3600')) # 1 hour + + # Directories + BACKGROUNDS_DIR = os.path.join('static', 'backgrounds') + CREDENTIALS_DIR = 'credentials' diff --git a/default b/credentials/.gitkeep similarity index 100% rename from default rename to credentials/.gitkeep diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..79e7b6f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +Flask==3.0.0 +google-auth==2.25.2 +google-auth-oauthlib==1.2.0 +google-auth-httplib2==0.2.0 +google-api-python-client==2.110.0 +requests==2.31.0 +python-dotenv==1.0.0 diff --git a/static/backgrounds/IMG_3411.JPG b/static/backgrounds/IMG_3411.JPG new file mode 100644 index 0000000..38aa702 Binary files /dev/null and b/static/backgrounds/IMG_3411.JPG differ diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..2bc0a7e --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,306 @@ +/* Reset and base styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + overflow: hidden; + width: 100vw; + height: 100vh; + color: #ffffff; +} + +/* Background */ +#background { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + z-index: 0; + transition: background-image 1s ease-in-out; +} + +#background::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.4); + z-index: 1; +} + +/* Main container */ +.container { + position: relative; + z-index: 2; + width: 100%; + height: 100vh; + display: flex; + flex-direction: column; + padding: 2rem; +} + +/* Header */ +.header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 2rem; + background: rgba(0, 0, 0, 0.3); + backdrop-filter: blur(10px); + padding: 1.5rem 2rem; + border-radius: 15px; +} + +.time-display { + display: flex; + flex-direction: column; +} + +.time { + font-size: 5rem; + font-weight: 300; + line-height: 1; + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5); +} + +.date { + font-size: 1.8rem; + font-weight: 300; + margin-top: 0.5rem; + opacity: 0.9; + text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5); +} + +.weather-summary { + display: flex; + align-items: center; + gap: 1rem; +} + +.current-temp { + font-size: 4rem; + font-weight: 300; + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5); +} + +.weather-icon { + font-size: 4rem; +} + +/* Main content */ +.main-content { + flex: 1; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; + overflow: hidden; +} + +/* Sections */ +section { + background: rgba(0, 0, 0, 0.3); + backdrop-filter: blur(10px); + padding: 2rem; + border-radius: 15px; + overflow-y: auto; +} + +section h2 { + font-size: 2rem; + font-weight: 400; + margin-bottom: 1.5rem; + text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5); +} + +section h3 { + font-size: 1.5rem; + font-weight: 400; + margin: 1.5rem 0 1rem; + text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5); +} + +/* Weather Section */ +.weather-description { + font-size: 1.8rem; + margin-bottom: 1rem; + text-transform: capitalize; +} + +.weather-details { + display: flex; + flex-direction: column; + gap: 0.8rem; + margin: 1.5rem 0; +} + +.weather-detail { + display: flex; + justify-content: space-between; + font-size: 1.3rem; + padding: 0.5rem 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.weather-detail .label { + opacity: 0.8; +} + +/* Forecast */ +.forecast-days { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.forecast-day { + display: grid; + grid-template-columns: 80px 1fr 120px; + align-items: center; + padding: 1rem; + background: rgba(255, 255, 255, 0.05); + border-radius: 10px; + gap: 1rem; +} + +.forecast-day-name { + font-size: 1.3rem; + font-weight: 500; +} + +.forecast-description { + font-size: 1.1rem; + opacity: 0.8; + text-transform: capitalize; +} + +.forecast-temp { + font-size: 1.3rem; + text-align: right; +} + +.forecast-temp .high { + font-weight: 500; +} + +.forecast-temp .low { + opacity: 0.6; + margin-left: 0.5rem; +} + +/* Calendar Section */ +.events-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.event { + background: rgba(255, 255, 255, 0.05); + padding: 1.2rem; + border-radius: 10px; + border-left: 4px solid #4a9eff; +} + +.event-time { + font-size: 1.1rem; + font-weight: 500; + margin-bottom: 0.5rem; + color: #4a9eff; +} + +.event-title { + font-size: 1.4rem; + margin-bottom: 0.3rem; +} + +.event-location { + font-size: 1rem; + opacity: 0.7; + font-style: italic; +} + +.event-placeholder { + font-size: 1.2rem; + opacity: 0.6; + text-align: center; + padding: 2rem; +} + +/* Footer */ +.footer { + margin-top: 2rem; + background: rgba(0, 0, 0, 0.3); + backdrop-filter: blur(10px); + padding: 1.5rem 2rem; + border-radius: 15px; +} + +.joke { + font-size: 1.2rem; + font-style: italic; + text-align: center; + opacity: 0.9; + text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5); +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.05); + border-radius: 10px; +} + +::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 10px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.3); +} + +/* Responsive adjustments for smaller screens */ +@media (max-width: 1200px) { + .time { + font-size: 4rem; + } + + .date { + font-size: 1.5rem; + } + + .current-temp { + font-size: 3rem; + } + + section h2 { + font-size: 1.6rem; + } +} + +@media (max-width: 768px) { + .main-content { + grid-template-columns: 1fr; + } + + .time { + font-size: 3rem; + } + + .current-temp { + font-size: 2.5rem; + } +} diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..f31736e --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,189 @@ +// Update intervals (in milliseconds) +const INTERVALS = { + TIME: 1000, // 1 second + WEATHER: 900000, // 15 minutes + CALENDAR: 300000, // 5 minutes + BACKGROUND: 300000, // 5 minutes + JOKE: 3600000 // 1 hour +}; + +// Weather icon mapping (OpenWeatherMap icons to emoji or text) +const WEATHER_ICONS = { + '01d': '☀️', '01n': '🌙', + '02d': '⛅', '02n': '☁️', + '03d': '☁️', '03n': '☁️', + '04d': '☁️', '04n': '☁️', + '09d': '🌧️', '09n': '🌧️', + '10d': '🌦️', '10n': '🌧️', + '11d': '⛈️', '11n': '⛈️', + '13d': '❄️', '13n': '❄️', + '50d': '🌫️', '50n': '🌫️' +}; + +// Initialize the application +document.addEventListener('DOMContentLoaded', () => { + // Start all update functions + updateTime(); + updateWeather(); + updateCalendar(); + updateBackground(); + updateJoke(); + + // Set up intervals + setInterval(updateTime, INTERVALS.TIME); + setInterval(updateWeather, INTERVALS.WEATHER); + setInterval(updateCalendar, INTERVALS.CALENDAR); + setInterval(updateBackground, INTERVALS.BACKGROUND); + setInterval(updateJoke, INTERVALS.JOKE); +}); + +// Update time and date +function updateTime() { + const now = new Date(); + + // Format time (HH:MM) + const hours = String(now.getHours()).padStart(2, '0'); + const minutes = String(now.getMinutes()).padStart(2, '0'); + document.getElementById('time').textContent = `${hours}:${minutes}`; + + // Format date (Day, Month Date) + const options = { weekday: 'long', month: 'long', day: 'numeric' }; + const dateString = now.toLocaleDateString('en-NZ', options); + document.getElementById('date').textContent = dateString; +} + +// Fetch and update weather +async function updateWeather() { + try { + const response = await fetch('/api/weather'); + if (!response.ok) throw new Error('Weather fetch failed'); + + const data = await response.json(); + + if (data.error) { + console.error('Weather error:', data.error); + return; + } + + // Update current weather + const { current, forecast } = data; + + document.getElementById('current-temp').textContent = `${current.temp}°`; + document.getElementById('weather-icon').textContent = WEATHER_ICONS[current.icon] || '🌤️'; + document.getElementById('weather-description').textContent = current.description; + document.getElementById('feels-like').textContent = `${current.feels_like}°`; + document.getElementById('humidity').textContent = `${current.humidity}%`; + document.getElementById('wind-speed').textContent = `${current.wind_speed} km/h`; + + // Update forecast + const forecastContainer = document.getElementById('forecast-container'); + forecastContainer.innerHTML = ''; + + forecast.forEach(day => { + const dayElement = document.createElement('div'); + dayElement.className = 'forecast-day'; + dayElement.innerHTML = ` +