Implement calendar display application
Built a full-featured smart display app with Flask backend and responsive frontend. Features: - Real-time clock display - Weather integration (OpenWeatherMap API) for Hamilton, NZ - Google Calendar integration (placeholder, needs credentials) - Rotating background images from local directory - Dad jokes display (icanhazdadjoke API) Technical stack: - Backend: Python Flask with API endpoints - Frontend: HTML/CSS/JavaScript with auto-updating data - Caching system to avoid API rate limits - Responsive design for various screen sizes Deployment ready for Raspberry Pi with systemd service and Chromium kiosk mode setup instructions. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
20
.env.example
Normal file
20
.env.example
Normal file
@@ -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
|
||||
45
.gitignore
vendored
Normal file
45
.gitignore
vendored
Normal file
@@ -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
|
||||
225
README.md
225
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
|
||||
|
||||
187
app.py
Normal file
187
app.py
Normal file
@@ -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)
|
||||
30
config.py
Normal file
30
config.py
Normal file
@@ -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'
|
||||
7
requirements.txt
Normal file
7
requirements.txt
Normal file
@@ -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
|
||||
BIN
static/backgrounds/IMG_3411.JPG
Normal file
BIN
static/backgrounds/IMG_3411.JPG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 MiB |
306
static/css/style.css
Normal file
306
static/css/style.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
189
static/js/app.js
Normal file
189
static/js/app.js
Normal file
@@ -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 = `
|
||||
<div class="forecast-day-name">${day.date}</div>
|
||||
<div class="forecast-description">${day.description}</div>
|
||||
<div class="forecast-temp">
|
||||
<span class="high">${day.temp_max}°</span>
|
||||
<span class="low">${day.temp_min}°</span>
|
||||
</div>
|
||||
`;
|
||||
forecastContainer.appendChild(dayElement);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating weather:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch and update calendar events
|
||||
async function updateCalendar() {
|
||||
try {
|
||||
const response = await fetch('/api/calendar');
|
||||
if (!response.ok) throw new Error('Calendar fetch failed');
|
||||
|
||||
const events = await response.json();
|
||||
|
||||
if (events.error) {
|
||||
console.error('Calendar error:', events.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const eventsContainer = document.getElementById('calendar-events');
|
||||
|
||||
if (!events || events.length === 0) {
|
||||
eventsContainer.innerHTML = '<div class="event-placeholder">No upcoming events</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
eventsContainer.innerHTML = '';
|
||||
|
||||
events.forEach(event => {
|
||||
const eventElement = document.createElement('div');
|
||||
eventElement.className = 'event';
|
||||
|
||||
const startTime = new Date(event.start);
|
||||
const endTime = new Date(event.end);
|
||||
|
||||
// Format time
|
||||
const timeOptions = { hour: '2-digit', minute: '2-digit', weekday: 'short', day: 'numeric', month: 'short' };
|
||||
const timeString = startTime.toLocaleDateString('en-NZ', timeOptions);
|
||||
|
||||
eventElement.innerHTML = `
|
||||
<div class="event-time">${timeString}</div>
|
||||
<div class="event-title">${event.title}</div>
|
||||
${event.location ? `<div class="event-location">📍 ${event.location}</div>` : ''}
|
||||
`;
|
||||
|
||||
eventsContainer.appendChild(eventElement);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating calendar:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update background image
|
||||
async function updateBackground() {
|
||||
try {
|
||||
const response = await fetch('/api/background');
|
||||
if (!response.ok) throw new Error('Background fetch failed');
|
||||
|
||||
const data = await response.json();
|
||||
const backgroundElement = document.getElementById('background');
|
||||
|
||||
if (data.image) {
|
||||
// Preload image to avoid flicker
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
backgroundElement.style.backgroundImage = `url('${data.image}')`;
|
||||
};
|
||||
img.src = data.image;
|
||||
} else if (data.color) {
|
||||
backgroundElement.style.backgroundColor = data.color;
|
||||
backgroundElement.style.backgroundImage = 'none';
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating background:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch and update dad joke
|
||||
async function updateJoke() {
|
||||
try {
|
||||
const response = await fetch('/api/joke');
|
||||
if (!response.ok) throw new Error('Joke fetch failed');
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.joke) {
|
||||
document.getElementById('joke').textContent = data.joke;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating joke:', error);
|
||||
}
|
||||
}
|
||||
76
templates/index.html
Normal file
76
templates/index.html
Normal file
@@ -0,0 +1,76 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Family Calendar Display</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Background container -->
|
||||
<div id="background"></div>
|
||||
|
||||
<!-- Main content overlay -->
|
||||
<div class="container">
|
||||
<!-- Header with Time and Date -->
|
||||
<header class="header">
|
||||
<div class="time-display">
|
||||
<div id="time" class="time">--:--</div>
|
||||
<div id="date" class="date">Loading...</div>
|
||||
</div>
|
||||
<div class="weather-summary">
|
||||
<div id="current-temp" class="current-temp">--°</div>
|
||||
<div id="weather-icon" class="weather-icon"></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main content area -->
|
||||
<div class="main-content">
|
||||
<!-- Weather Section -->
|
||||
<section class="weather-section">
|
||||
<h2>Weather</h2>
|
||||
<div class="current-weather">
|
||||
<div id="weather-description" class="weather-description">Loading...</div>
|
||||
<div class="weather-details">
|
||||
<div class="weather-detail">
|
||||
<span class="label">Feels like:</span>
|
||||
<span id="feels-like">--°</span>
|
||||
</div>
|
||||
<div class="weather-detail">
|
||||
<span class="label">Humidity:</span>
|
||||
<span id="humidity">--%</span>
|
||||
</div>
|
||||
<div class="weather-detail">
|
||||
<span class="label">Wind:</span>
|
||||
<span id="wind-speed">-- km/h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 5-Day Forecast -->
|
||||
<div class="forecast">
|
||||
<h3>Forecast</h3>
|
||||
<div id="forecast-container" class="forecast-days">
|
||||
<!-- Forecast items will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Calendar Section -->
|
||||
<section class="calendar-section">
|
||||
<h2>Upcoming Events</h2>
|
||||
<div id="calendar-events" class="events-list">
|
||||
<div class="event-placeholder">Loading events...</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Footer with Dad Joke -->
|
||||
<footer class="footer">
|
||||
<div id="joke" class="joke">Loading a joke to brighten your day...</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user