Redesign UI as light-themed week-grid calendar matching Skylight-style device

- Replace scrolling event list + photo background with a Sun–Sat week grid
  plus a "Next Week" preview column; Prev/Next navigation and per-member filter
- Add [Name] event title prefix convention for color-coded family member cards
  (Ludwig/Dad=blue, Michelle/Mom=pink, Jason=green, Daniel=yellow)
- /api/calendar now accepts ?start=YYYY-MM-DD, returns bucketed week payload
  with member/color metadata; fetches 14 days to populate Next Week column
- Drop rotating background photo and dad joke from the display (endpoints kept)
- Update README with new UI overview, prefix convention, and API docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 20:32:15 +12:00
parent 18965045fa
commit fc6abfe745
6 changed files with 564 additions and 733 deletions

View File

@@ -1,17 +1,33 @@
# Family Calendar Display # Family Calendar Display
A smart display application for Raspberry Pi that shows time, weather, calendar events, and rotating background images. A smart display application for Raspberry Pi that shows a color-coded, week-view family calendar with live weather, styled after wall-mounted family calendar devices (e.g. Skylight).
**Project created:** February 14, 2026 **Project created:** February 14, 2026
**UI redesigned:** June 30, 2026 — switched from a scrolling event list over a photo background to a light-themed week grid with per-person event colors.
## Features ## Features
- 📅 **Week-View Calendar** - SundaySaturday grid with a "Next Week" preview column, Prev/Next navigation
- 👨‍👩‍👧‍👦 **Color-Coded Family Members** - Events tagged `[Name]` are shown in that person's color (see below)
- 🔎 **Filter by Person** - Dropdown to show only one family member's events
- 🌤️ **Weather** - Current temperature and icon in the header (OpenWeatherMap)
- 🕐 **Real-time Clock** - Current time and date display - 🕐 **Real-time Clock** - Current time and date display
- 🌤️ **Weather** - Current weather and 3-day forecast (OpenWeatherMap) - 📌 **Read-only by design** - The "+" button is a visual affordance only; this app reads a public iCal feed and cannot write events back to Google Calendar. Add/edit events directly in Google Calendar.
- 📅 **Google Calendar** - Calendar events display (supports public iCal feeds)
- 🖼️ **Rotating Backgrounds** - Beautiful images from local directory > The previous rotating-background-photo and dad-joke features were dropped in the redesign to match the cleaner device look. The `/api/background` and `/api/joke` endpoints still exist server-side if you want to bring them back.
- 🎨 **Dynamic Text Color** - Automatically adjusts text color based on background brightness
- 😄 **Dad Jokes** - Random jokes to brighten your day (optional) ## Family Member Colors
Prefix an event title in Google Calendar with `[Name]` (case-insensitive) and the app will strip the prefix and color the event card accordingly. Unprefixed events fall back to a neutral gray "Family" color.
| Name / alias | Color |
|---|---|
| `[Ludwig]` or `[Dad]` | Blue |
| `[Michelle]` or `[Mom]` | Pink |
| `[Jason]` | Green |
| `[Daniel]` | Yellow |
Example: an event titled `[Daniel] Football Practice` displays as **Football Practice** in Daniel's yellow card. Member names/aliases/colors are configured in `config.py` (`FAMILY_MEMBERS`).
## Requirements ## Requirements
@@ -98,30 +114,9 @@ If you prefer to use private calendars with authentication:
6. Save it as `credentials/google_calendar_credentials.json` 6. Save it as `credentials/google_calendar_credentials.json`
7. Share your calendar with the service account email 7. Share your calendar with the service account email
### 6. Add Background Images ### 6. Set Up Family Member Colors (optional)
Place your images in the `static/backgrounds/` directory: Prefix event titles in your calendar with `[Name]` to color-code them per person — see [Family Member Colors](#family-member-colors) above. Edit `FAMILY_MEMBERS` in `config.py` to change names, aliases, or colors.
```bash
cp /path/to/your/images/*.jpg static/backgrounds/
```
**Supported formats:** JPG, JPEG, PNG, GIF, WebP
**Recommended specifications:**
- Resolution: 1920x1080 or higher
- Aspect ratio: 16:9 (for full-screen displays)
- File size: Keep under 5MB for faster loading
**Changing Image Rotation Interval:**
Edit `static/js/app.js`:
```javascript
const INTERVALS = {
BACKGROUND: 3600000, // Milliseconds (3600000 = 60 minutes)
...
};
```
### 7. Run the Application ### 7. Run the Application
@@ -219,12 +214,11 @@ This will:
**Calendar Settings:** **Calendar Settings:**
- `GOOGLE_CALENDAR_ID` - Your calendar ID - `GOOGLE_CALENDAR_ID` - Your calendar ID
- `GOOGLE_CALENDAR_ICAL_URL` - Public iCal feed URL (easiest method) - `GOOGLE_CALENDAR_ICAL_URL` - Public iCal feed URL (easiest method)
- `CALENDAR_DAYS_AHEAD` - Number of days ahead to show events (default: 5) - `CALENDAR_DAYS_AHEAD` - Used only by the legacy events fetch; the week view always fetches two weeks at a time
**Update Intervals (in seconds):** **Update Intervals (in seconds):**
- `WEATHER_UPDATE_INTERVAL` - Weather refresh interval (default: 900) - `WEATHER_UPDATE_INTERVAL` - Weather refresh interval (default: 900)
- `CALENDAR_UPDATE_INTERVAL` - Calendar refresh interval (default: 300) - `CALENDAR_UPDATE_INTERVAL` - Calendar refresh interval (default: 300)
- `JOKE_UPDATE_INTERVAL` - Dad joke refresh interval (default: 3600)
**Other:** **Other:**
- `FLASK_SECRET_KEY` - Flask session secret key - `FLASK_SECRET_KEY` - Flask session secret key
@@ -237,20 +231,10 @@ For more precise control over update intervals, edit the `INTERVALS` object:
const INTERVALS = { const INTERVALS = {
TIME: 1000, // 1 second TIME: 1000, // 1 second
WEATHER: 900000, // 15 minutes WEATHER: 900000, // 15 minutes
CALENDAR: 300000, // 5 minutes CALENDAR: 300000 // 5 minutes
BACKGROUND: 3600000, // 60 minutes
JOKE: 3600000 // 1 hour
}; };
``` ```
**To change image rotation time:**
1. Open `static/js/app.js`
2. Find the `INTERVALS` object at the top
3. Change `BACKGROUND` to your desired value in milliseconds
- 300000 = 5 minutes
- 1800000 = 30 minutes
- 3600000 = 60 minutes (default)
## Project Structure ## Project Structure
``` ```
@@ -274,9 +258,9 @@ Calender/
- `GET /` - Main display page - `GET /` - Main display page
- `GET /api/weather` - Weather data (cached for 15 min) - `GET /api/weather` - Weather data (cached for 15 min)
- `GET /api/calendar` - Calendar events (cached for 5 min) - `GET /api/calendar?start=YYYY-MM-DD` - Sunday-starting week of events plus a "Next Week" preview, color-coded by family member (cached per-week for 5 min). `start` defaults to the current week if omitted.
- `GET /api/background` - Random background image - `GET /api/background` - Random background image (no longer used by the UI, kept for future use)
- `GET /api/joke` - Dad joke (cached for 1 hour) - `GET /api/joke` - Dad joke (no longer used by the UI, kept for future use)
## Troubleshooting ## Troubleshooting
@@ -285,18 +269,17 @@ Calender/
- Ensure you're not exceeding the free tier rate limits (60 calls/min) - Ensure you're not exceeding the free tier rate limits (60 calls/min)
### Calendar not loading ### Calendar not loading
- Verify Google Calendar API credentials are set up correctly - Verify the iCal URL in `.env` is correct and the calendar is set to Public
- Check the calendar ID is correct - Check the calendar ID is correct
- Ensure the service account has access to the calendar
### Events showing the wrong color / "Family" instead of a person
- The event title must start with `[Name]` exactly, e.g. `[Daniel] Football Practice`
- Check `FAMILY_MEMBERS` in `config.py` for the recognized aliases
### Display not starting on boot ### Display not starting on boot
- Check systemd service status: `sudo systemctl status calendar-display.service` - Check systemd service status: `sudo systemctl status calendar-display.service`
- View logs: `sudo journalctl -u calendar-display.service -f` - 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 ## Contributing
Feel free to submit issues or pull requests! Feel free to submit issues or pull requests!

124
app.py
View File

@@ -1,7 +1,8 @@
from flask import Flask, render_template, jsonify from flask import Flask, render_template, jsonify, request
import requests import requests
import os import os
import random import random
import re
from datetime import datetime, timedelta, date from datetime import datetime, timedelta, date
from icalendar import Calendar from icalendar import Calendar
from config import Config from config import Config
@@ -13,7 +14,7 @@ app.config.from_object(Config)
# Cache for API responses to avoid rate limiting # Cache for API responses to avoid rate limiting
weather_cache = {'data': None, 'timestamp': None} weather_cache = {'data': None, 'timestamp': None}
calendar_cache = {'data': None, 'timestamp': None} calendar_cache = {'data': None, 'timestamp': None, 'key': None}
joke_cache = {'data': None, 'timestamp': None} joke_cache = {'data': None, 'timestamp': None}
@@ -94,35 +95,84 @@ def get_weather():
return jsonify({'error': 'Unable to fetch weather data'}), 500 return jsonify({'error': 'Unable to fetch weather data'}), 500
MEMBER_PREFIX_RE = re.compile(r'^\s*\[([^\]]+)\]\s*(.*)')
def parse_member_and_title(summary, family_members):
"""Split a "[Alias] Title" event summary into (member_dict, clean_title)."""
match = MEMBER_PREFIX_RE.match(summary)
if not match:
return None, summary
alias = match.group(1).strip().lower()
title = match.group(2).strip() or summary
for member in family_members:
if alias in member['aliases']:
return member, title
return None, title
def week_start_for(d):
"""Return the Sunday on/before date d (Sunday-starting week)."""
return d - timedelta(days=(d.weekday() + 1) % 7)
def empty_week_payload(week_start_date, members):
days = []
for i in range(7):
d = week_start_date + timedelta(days=i)
days.append({'date': d.isoformat(), 'weekday': d.strftime('%A'), 'events': []})
next_week_start = week_start_date + timedelta(days=7)
next_week_end = next_week_start + timedelta(days=6)
return {
'week_start': week_start_date.isoformat(),
'week_end': (week_start_date + timedelta(days=6)).isoformat(),
'days': days,
'next_week': {'start': next_week_start.isoformat(), 'end': next_week_end.isoformat(), 'count': 0, 'events': []},
'members': members,
}
@app.route('/api/calendar') @app.route('/api/calendar')
def get_calendar(): def get_calendar():
"""Fetch Google Calendar events from iCal feed.""" """Fetch Google Calendar events from iCal feed for a Sunday-starting week."""
global calendar_cache global calendar_cache
# Check cache - use timezone-aware datetime
nz_tz = pytz.timezone('Pacific/Auckland') nz_tz = pytz.timezone('Pacific/Auckland')
now = datetime.now(nz_tz) now = datetime.now(nz_tz)
if (calendar_cache['data'] and calendar_cache['timestamp'] and
start_param = request.args.get('start')
if start_param:
try:
week_start_date = week_start_for(datetime.strptime(start_param, '%Y-%m-%d').date())
except ValueError:
week_start_date = week_start_for(now.date())
else:
week_start_date = week_start_for(now.date())
family_members = app.config['FAMILY_MEMBERS']
default_member = app.config['DEFAULT_MEMBER']
members_for_filter = family_members + [default_member]
cache_key = week_start_date.isoformat()
if (calendar_cache.get('key') == cache_key and calendar_cache['data'] and calendar_cache['timestamp'] and
(now - calendar_cache['timestamp']).total_seconds() < app.config['CALENDAR_UPDATE_INTERVAL']): (now - calendar_cache['timestamp']).total_seconds() < app.config['CALENDAR_UPDATE_INTERVAL']):
return jsonify(calendar_cache['data']) return jsonify(calendar_cache['data'])
try: try:
# Fetch iCal feed
ical_url = app.config.get('GOOGLE_CALENDAR_ICAL_URL') ical_url = app.config.get('GOOGLE_CALENDAR_ICAL_URL')
if not ical_url: if not ical_url:
return jsonify([]) return jsonify(empty_week_payload(week_start_date, members_for_filter))
response = requests.get(ical_url, timeout=10) response = requests.get(ical_url, timeout=10)
response.raise_for_status() response.raise_for_status()
# Parse iCal data
cal = Calendar.from_ical(response.content) cal = Calendar.from_ical(response.content)
# Use recurring_ical_events to get all events in the date range (including recurring ones) # Fetch this week + next week, so the "Next Week" preview column has data.
cutoff_date = now + timedelta(days=app.config['CALENDAR_DAYS_AHEAD']) range_start = nz_tz.localize(datetime.combine(week_start_date, datetime.min.time()))
range_end = range_start + timedelta(days=14)
# Get all events between now and cutoff_date recurring_events = recurring_ical_events.of(cal).between(range_start, range_end)
recurring_events = recurring_ical_events.of(cal).between(now, cutoff_date)
events = [] events = []
for component in recurring_events: for component in recurring_events:
@@ -131,23 +181,18 @@ def get_calendar():
summary = str(component.get('summary', 'No Title')) summary = str(component.get('summary', 'No Title'))
if dtstart and dtstart.dt: if dtstart and dtstart.dt:
# Handle both datetime and date objects
if isinstance(dtstart.dt, datetime): if isinstance(dtstart.dt, datetime):
event_start = dtstart.dt event_start = dtstart.dt
# Make sure event_start is timezone-aware
if event_start.tzinfo is None: if event_start.tzinfo is None:
event_start = nz_tz.localize(event_start) event_start = nz_tz.localize(event_start)
elif isinstance(dtstart.dt, date): elif isinstance(dtstart.dt, date):
# For date-only events, create a timezone-aware datetime
event_start = nz_tz.localize(datetime.combine(dtstart.dt, datetime.min.time())) event_start = nz_tz.localize(datetime.combine(dtstart.dt, datetime.min.time()))
else: else:
continue continue
# Handle end time
if dtend and dtend.dt: if dtend and dtend.dt:
if isinstance(dtend.dt, datetime): if isinstance(dtend.dt, datetime):
event_end = dtend.dt event_end = dtend.dt
# Make sure event_end is timezone-aware
if event_end.tzinfo is None: if event_end.tzinfo is None:
event_end = nz_tz.localize(event_end) event_end = nz_tz.localize(event_end)
elif isinstance(dtend.dt, date): elif isinstance(dtend.dt, date):
@@ -157,24 +202,53 @@ def get_calendar():
else: else:
event_end = event_start + timedelta(hours=1) event_end = event_start + timedelta(hours=1)
member, title = parse_member_and_title(summary, family_members)
member_info = member or default_member
events.append({ events.append({
'title': summary, 'title': title,
'start': event_start.isoformat(), 'start': event_start.isoformat(),
'end': event_end.isoformat(), 'end': event_end.isoformat(),
'location': str(component.get('location', '')) 'location': str(component.get('location', '')),
'member': member_info['key'],
'member_name': member_info['name'],
'color': member_info['color'],
'bg': member_info['bg'],
}) })
# Sort events by start time
events.sort(key=lambda x: x['start']) events.sort(key=lambda x: x['start'])
calendar_cache = {'data': events, 'timestamp': now} days = []
return jsonify(events) for i in range(7):
d = week_start_date + timedelta(days=i)
day_events = [e for e in events if datetime.fromisoformat(e['start']).date() == d]
days.append({'date': d.isoformat(), 'weekday': d.strftime('%A'), 'events': day_events})
next_week_start = week_start_date + timedelta(days=7)
next_week_end = next_week_start + timedelta(days=6)
next_week_events = [e for e in events if datetime.fromisoformat(e['start']).date() >= next_week_start]
result = {
'week_start': week_start_date.isoformat(),
'week_end': (week_start_date + timedelta(days=6)).isoformat(),
'days': days,
'next_week': {
'start': next_week_start.isoformat(),
'end': next_week_end.isoformat(),
'count': len(next_week_events),
'events': next_week_events[:5],
},
'members': members_for_filter,
}
calendar_cache = {'data': result, 'timestamp': now, 'key': cache_key}
return jsonify(result)
except Exception as e: except Exception as e:
app.logger.error(f"Error fetching calendar: {str(e)}") app.logger.error(f"Error fetching calendar: {str(e)}")
if calendar_cache['data']: if calendar_cache.get('data') and calendar_cache.get('key') == cache_key:
return jsonify(calendar_cache['data']) return jsonify(calendar_cache['data'])
return jsonify([]) return jsonify(empty_week_payload(week_start_date, members_for_filter))
@app.route('/api/background') @app.route('/api/background')

View File

@@ -29,3 +29,14 @@ class Config:
# Directories # Directories
BACKGROUNDS_DIR = os.path.join('static', 'backgrounds') BACKGROUNDS_DIR = os.path.join('static', 'backgrounds')
CREDENTIALS_DIR = 'credentials' CREDENTIALS_DIR = 'credentials'
# Family members for event color-coding.
# Event titles prefixed with "[Alias]" (e.g. "[Daniel] Football Practice")
# are tagged with that member's name/color and the prefix is stripped for display.
FAMILY_MEMBERS = [
{'key': 'dad', 'name': 'Ludwig', 'aliases': ['ludwig', 'dad'], 'color': '#4A90D9', 'bg': '#DCEAFB'},
{'key': 'mom', 'name': 'Michelle', 'aliases': ['michelle', 'mom'], 'color': '#D9669B', 'bg': '#FBDDEB'},
{'key': 'jason', 'name': 'Jason', 'aliases': ['jason'], 'color': '#5FA85B', 'bg': '#E1F2DD'},
{'key': 'daniel', 'name': 'Daniel', 'aliases': ['daniel'], 'color': '#D9A53B', 'bg': '#FBEFD3'},
]
DEFAULT_MEMBER = {'key': 'family', 'name': 'Family', 'color': '#8C8C8C', 'bg': '#E9E9E9'}

View File

@@ -1,441 +1,326 @@
/* Reset and base styles */
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
} }
body { html, body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: #F4F5F7;
color: #2B2B33;
overflow: hidden; overflow: hidden;
width: 100vw;
height: 100vh;
color: #ffffff;
transition: color 0.5s ease-in-out;
} }
/* Dark text for bright backgrounds */ .app {
body.light-bg {
color: #1a1a1a;
}
body.light-bg .time,
body.light-bg .date,
body.light-bg .current-temp,
body.light-bg .weather-label,
body.light-bg .weather-icon,
body.light-bg .forecast-icon,
body.light-bg section h2,
body.light-bg .day-name,
body.light-bg .day-date,
body.light-bg .event-title,
body.light-bg .event-location,
body.light-bg .joke {
text-shadow: 1px 1px 3px rgba(255, 255, 255, 0.8);
}
body.light-bg .event-time {
color: #0066cc;
}
body.light-bg .event {
border-left-color: #0066cc;
}
/* White text for dark backgrounds (default) */
body.dark-bg {
color: #ffffff;
}
body.dark-bg .event-time {
color: #4a9eff;
}
body.dark-bg .event {
border-left-color: #4a9eff;
}
/* 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.1);
z-index: 1;
}
/* Main container */
.container {
position: relative;
z-index: 2;
width: 100%;
height: 100vh; height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 1rem; padding: 18px 24px 24px;
} }
/* Header maximally transparent; text readable via text-shadow */ /* ---------- Top bar ---------- */
.header {
.topbar {
display: flex; display: flex;
justify-content: space-between;
align-items: flex-start; align-items: flex-start;
margin-bottom: 1rem; justify-content: space-between;
background: rgba(0, 0, 0, 0.03); margin-bottom: 18px;
padding: 1rem 1.5rem; flex-wrap: wrap;
border-radius: 10px; gap: 12px;
} }
.time-display { .date-time {
display: flex; display: flex;
flex-direction: column; align-items: baseline;
gap: 10px;
} }
.time { .icon-badge {
font-size: 4.5rem; font-size: 22px;
font-weight: 600; margin-right: 2px;
line-height: 1;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
} }
.date { .header-date {
font-size: 1.6rem; font-size: 28px;
font-weight: 700;
color: #1F2430;
}
.header-time {
font-size: 18px;
font-weight: 500; font-weight: 500;
margin-top: 0.3rem; color: #8A8F9C;
opacity: 0.9;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
} }
.weather-summary { .nav-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1.5rem; gap: 10px;
margin-top: 10px;
} }
.current-weather-item { .pill {
display: flex; display: inline-flex;
flex-direction: column;
align-items: center; align-items: center;
gap: 0.2rem; background: #FFFFFF;
border: 1px solid #E3E5EA;
border-radius: 18px;
padding: 6px 14px;
font-size: 13px;
font-weight: 600;
color: #4A4E5A;
} }
.weather-label { .filter-select {
font-size: 1.2rem; appearance: none;
font-weight: 600; -webkit-appearance: none;
opacity: 0.8; cursor: pointer;
text-transform: uppercase; padding-right: 28px;
letter-spacing: 0.05em; background-image: linear-gradient(45deg, transparent 50%, #8A8F9C 50%), linear-gradient(135deg, #8A8F9C 50%, transparent 50%);
background-position: calc(100% - 14px) center, calc(100% - 9px) center;
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
} }
.current-temp { .week-nav {
font-size: 3rem; display: flex;
align-items: center;
gap: 6px;
background: #FFFFFF;
border: 1px solid #E3E5EA;
border-radius: 18px;
padding: 4px 8px;
}
.nav-arrow {
border: none;
background: transparent;
font-size: 18px;
line-height: 1;
color: #4A4E5A;
cursor: pointer;
padding: 4px 8px;
border-radius: 50%;
}
.nav-arrow:hover {
background: #F0F1F4;
}
.week-range {
font-size: 13px;
font-weight: 600; font-weight: 600;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5); color: #4A4E5A;
min-width: 80px;
text-align: center;
}
.topbar-right {
display: flex;
align-items: center;
gap: 14px;
}
.weather-chip {
display: flex;
align-items: center;
gap: 6px;
background: #FFFFFF;
border: 1px solid #E3E5EA;
border-radius: 18px;
padding: 6px 14px;
} }
.weather-icon { .weather-icon {
font-size: 2.5rem; width: 22px;
height: 22px;
background-size: contain;
background-repeat: no-repeat;
} }
.header-forecast { .current-temp {
display: flex; font-size: 14px;
gap: 1rem;
}
.forecast-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
}
.forecast-icon {
font-size: 2.2rem;
}
.forecast-temps {
display: flex;
gap: 0.3rem;
font-size: 1.3rem;
}
.forecast-temps .high {
font-weight: 700; font-weight: 700;
color: #4A4E5A;
} }
.forecast-temps .low { .wifi-icon {
opacity: 0.6; font-size: 18px;
color: #8A8F9C;
} }
/* Main content */ /* ---------- Week grid ---------- */
.main-content {
.week-grid {
flex: 1; flex: 1;
display: flex;
overflow: hidden;
}
/* Sections */
section {
background: rgba(0, 0, 0, 0.03);
padding: 1rem 1.5rem;
border-radius: 10px;
overflow-y: auto;
flex: 1;
}
section h2 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.8rem;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
}
section h3 {
font-size: 1.1rem;
font-weight: 400;
margin: 1rem 0 0.5rem;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
}
/* Weather Section */
.weather-description {
font-size: 1.1rem;
margin-bottom: 0.6rem;
text-transform: capitalize;
}
.weather-details {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin: 0.8rem 0;
}
.weather-detail {
display: flex;
justify-content: space-between;
font-size: 0.95rem;
padding: 0.3rem 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: 0.5rem;
}
.forecast-day {
display: grid; display: grid;
grid-template-columns: 60px 1fr 100px; grid-template-columns: repeat(7, 1fr) 1.1fr;
align-items: center; gap: 12px;
padding: 0.6rem;
background: rgba(255, 255, 255, 0.04);
border-radius: 8px;
gap: 0.8rem;
}
.forecast-day-name {
font-size: 1rem;
font-weight: 500;
}
.forecast-description {
font-size: 0.9rem;
opacity: 0.8;
text-transform: capitalize;
}
.forecast-temp {
font-size: 1rem;
text-align: right;
}
.forecast-temp .high {
font-weight: 500;
}
.forecast-temp .low {
opacity: 0.6;
margin-left: 0.5rem;
}
/* Calendar Section */
.events-list {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.8rem;
height: 100%;
}
.day-group {
display: flex;
flex-direction: column;
min-height: 0; min-height: 0;
} }
.day-header { .day-column {
background: #FFFFFF;
border-radius: 14px;
padding: 12px 10px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; min-height: 0;
padding: 0.6rem; overflow: hidden;
background: rgba(255, 255, 255, 0.06); }
border-radius: 8px 8px 0 0;
margin-bottom: 0.5rem; .day-column.is-today {
text-align: center; box-shadow: 0 0 0 2px #4A90D9 inset;
}
.day-header {
margin-bottom: 8px;
} }
.day-name { .day-name {
font-size: 1.5rem; font-size: 11px;
font-weight: 700; font-weight: 700;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5); text-transform: uppercase;
color: #A6AAB4;
letter-spacing: 0.04em;
} }
.day-date { .day-number {
font-size: 1.2rem; font-size: 20px;
font-weight: 600; font-weight: 700;
opacity: 0.8; color: #1F2430;
margin-top: 0.2rem; }
.day-count {
font-size: 11px;
color: #A6AAB4;
margin-top: 2px;
} }
.day-events { .day-events {
flex: 1;
overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 6px;
overflow-y: auto;
flex: 1;
} }
.no-events { .event-card {
font-size: 1.2rem; border-radius: 8px;
font-weight: 500; padding: 6px 8px;
opacity: 0.5; border-left: 4px solid transparent;
font-style: italic; font-size: 11.5px;
padding: 0.5rem; line-height: 1.35;
text-align: center;
}
.event {
background: rgba(255, 255, 255, 0.04);
padding: 0.5rem 0.6rem;
border-radius: 6px;
border-left: 3px solid #4a9eff;
} }
.event-time { .event-time {
font-size: 1.1rem;
font-weight: 700; font-weight: 700;
margin-bottom: 0.2rem; color: rgba(0, 0, 0, 0.55);
color: #4a9eff; font-size: 10.5px;
} }
.event-title { .event-title {
font-size: 1.3rem;
font-weight: 600; font-weight: 600;
margin-bottom: 0.2rem; color: #2B2B33;
line-height: 1.2; overflow-wrap: anywhere;
} }
.event-location { .event-member {
font-size: 1.1rem; font-size: 10px;
font-weight: 500; color: rgba(0, 0, 0, 0.5);
opacity: 0.7; margin-top: 1px;
font-style: italic; }
.next-week-column {
background: #FFFFFF;
border-radius: 14px;
padding: 14px 12px;
display: flex;
flex-direction: column;
cursor: pointer;
}
.next-week-column:hover {
background: #F7F8FA;
}
.next-week-title {
font-size: 14px;
font-weight: 700;
color: #1F2430;
}
.next-week-range {
font-size: 11px;
color: #A6AAB4;
margin-bottom: 10px;
}
.next-week-count {
font-size: 13px;
color: #4A4E5A;
font-weight: 600;
} }
.event-placeholder { .event-placeholder {
font-size: 1rem; grid-column: 1 / -1;
opacity: 0.6;
text-align: center; text-align: center;
padding: 2rem; color: #A6AAB4;
padding-top: 60px;
font-size: 14px;
} }
/* Footer */ /* ---------- Add button ---------- */
.footer {
margin-top: 1rem; .add-fab {
background: rgba(0, 0, 0, 0.03); position: fixed;
padding: 0.8rem 1.5rem; bottom: 26px;
right: 26px;
width: 52px;
height: 52px;
border-radius: 50%;
border: none;
background: #4A90D9;
color: #FFFFFF;
font-size: 26px;
font-weight: 400;
line-height: 1;
cursor: pointer;
box-shadow: 0 6px 16px rgba(74, 144, 217, 0.4);
}
.add-fab:hover {
background: #3A7BC0;
}
.add-toast {
position: fixed;
bottom: 90px;
right: 26px;
background: #1F2430;
color: #FFFFFF;
padding: 10px 16px;
border-radius: 10px; border-radius: 10px;
font-size: 12.5px;
max-width: 240px;
opacity: 0;
transform: translateY(6px);
transition: opacity 0.2s ease, transform 0.2s ease;
pointer-events: none;
} }
.joke { .add-toast.visible {
font-size: 1.3rem; opacity: 1;
font-weight: 500; transform: translateY(0);
font-style: italic;
text-align: center;
opacity: 0.9;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
} }
/* Scrollbar styling */ /* Scrollbar styling for day columns */
::-webkit-scrollbar { .day-events::-webkit-scrollbar {
width: 8px; width: 4px;
} }
::-webkit-scrollbar-track { .day-events::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.05); background: #E3E5EA;
border-radius: 10px; border-radius: 4px;
}
::-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: 5rem;
}
.date {
font-size: 2rem;
}
.current-temp {
font-size: 4rem;
}
section h2 {
font-size: 2.5rem;
}
}
@media (max-width: 768px) {
.main-content {
grid-template-columns: 1fr;
}
.time {
font-size: 4rem;
}
.current-temp {
font-size: 3.5rem;
}
} }

View File

@@ -2,315 +2,195 @@
const INTERVALS = { const INTERVALS = {
TIME: 1000, // 1 second TIME: 1000, // 1 second
WEATHER: 900000, // 15 minutes WEATHER: 900000, // 15 minutes
CALENDAR: 300000, // 5 minutes CALENDAR: 300000 // 5 minutes
BACKGROUND: 3600000, // 60 minutes
JOKE: 3600000 // 1 hour
}; };
const DAY_LABELS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const MONTH_LABELS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
let currentWeekStart = startOfWeek(new Date());
let activeFilter = 'all';
let filterOptionsBuilt = false;
let lastWeekData = null;
// Return an OWM icon image tag for a given icon code // Return an OWM icon image tag for a given icon code
function weatherIcon(code) { function weatherIcon(code) {
const src = `https://openweathermap.org/img/wn/${code}@2x.png`; const src = `https://openweathermap.org/img/wn/${code}@2x.png`;
return `<img src="${src}" alt="${code}" class="owm-icon">`; return `<img src="${src}" alt="${code}" class="owm-icon">`;
} }
// Initialize the application // Sunday-starting week for a given date
document.addEventListener('DOMContentLoaded', () => { function startOfWeek(date) {
// Start all update functions const d = new Date(date);
updateTime(); d.setHours(0, 0, 0, 0);
updateWeather(); d.setDate(d.getDate() - d.getDay());
updateCalendar(); return d;
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 function toISODate(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function formatRangeLabel(startISO, endISO) {
const start = new Date(startISO + 'T00:00:00');
const end = new Date(endISO + 'T00:00:00');
const startLabel = `${MONTH_LABELS[start.getMonth()]} ${start.getDate()}`;
const endLabel = end.getMonth() === start.getMonth() ? `${end.getDate()}` : `${MONTH_LABELS[end.getMonth()]} ${end.getDate()}`;
return `${startLabel}-${endLabel}`;
}
// Initialize the application
document.addEventListener('DOMContentLoaded', () => {
updateTime();
updateWeather();
fetchCalendar();
document.getElementById('prev-week').addEventListener('click', () => shiftWeek(-7));
document.getElementById('next-week').addEventListener('click', () => shiftWeek(7));
document.getElementById('member-filter').addEventListener('change', (e) => {
activeFilter = e.target.value;
if (lastWeekData) renderWeek(lastWeekData);
});
document.getElementById('add-btn').addEventListener('click', showAddToast);
setInterval(updateTime, INTERVALS.TIME);
setInterval(updateWeather, INTERVALS.WEATHER);
setInterval(fetchCalendar, INTERVALS.CALENDAR);
});
function shiftWeek(days) {
currentWeekStart = new Date(currentWeekStart);
currentWeekStart.setDate(currentWeekStart.getDate() + days);
fetchCalendar();
}
// Update time and date in the header
function updateTime() {
const now = new Date();
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
document.getElementById('header-time').textContent = `${hours}:${minutes}`;
document.getElementById('header-date').textContent = `${MONTH_LABELS[now.getMonth()]} ${String(now.getDate()).padStart(2, '0')}`;
}
// Fetch and update weather chip
async function updateWeather() { async function updateWeather() {
try { try {
const response = await fetch('/api/weather'); const response = await fetch('/api/weather');
if (!response.ok) throw new Error('Weather fetch failed'); if (!response.ok) throw new Error('Weather fetch failed');
const data = await response.json(); const data = await response.json();
if (data.error || !data.current) return;
if (data.error) { document.getElementById('current-temp').textContent = `${data.current.temp}°`;
console.error('Weather error:', data.error); document.getElementById('weather-icon').innerHTML = weatherIcon(data.current.icon);
return;
}
// Update current weather in header
const { current, forecast } = data;
document.getElementById('current-temp').textContent = `${current.temp}°`;
document.getElementById('weather-icon').innerHTML = weatherIcon(current.icon);
// Update 3-day forecast in header
const headerForecast = document.getElementById('header-forecast');
headerForecast.innerHTML = '';
forecast.forEach(day => {
const dayElement = document.createElement('div');
dayElement.className = 'forecast-item';
dayElement.innerHTML = `
<div class="weather-label">${day.date}</div>
<div class="forecast-icon">${weatherIcon(day.icon)}</div>
<div class="forecast-temps">
<span class="high">${day.temp_max}°</span>
<span class="low">${day.temp_min}°</span>
</div>
`;
headerForecast.appendChild(dayElement);
});
} catch (error) { } catch (error) {
console.error('Error updating weather:', error); console.error('Error updating weather:', error);
} }
} }
// Fetch and update calendar events // Fetch the current week's calendar data and render it
async function updateCalendar() { async function fetchCalendar() {
try { try {
const response = await fetch('/api/calendar'); const response = await fetch(`/api/calendar?start=${toISODate(currentWeekStart)}`);
if (!response.ok) throw new Error('Calendar fetch failed'); if (!response.ok) throw new Error('Calendar fetch failed');
const events = await response.json(); const data = await response.json();
lastWeekData = data;
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;
}
// Group events by day
const eventsByDay = {};
const today = new Date();
today.setHours(0, 0, 0, 0);
// Create 5 days (today + 4 more)
for (let i = 0; i < 5; i++) {
const date = new Date(today);
date.setDate(today.getDate() + i);
const dateKey = date.toISOString().split('T')[0];
eventsByDay[dateKey] = {
date: date,
events: []
};
}
// Group events by their date (including multi-day events)
events.forEach(event => {
const eventStart = new Date(event.start);
const eventEnd = new Date(event.end);
eventStart.setHours(0, 0, 0, 0);
eventEnd.setHours(0, 0, 0, 0);
// Check each day in our 5-day view
Object.keys(eventsByDay).forEach(dateKey => {
const dayDate = new Date(eventsByDay[dateKey].date);
dayDate.setHours(0, 0, 0, 0);
// Include event if this day falls within the event's duration
if (dayDate >= eventStart && dayDate <= eventEnd) {
eventsByDay[dateKey].events.push(event);
}
});
});
// Render events grouped by day
eventsContainer.innerHTML = '';
Object.keys(eventsByDay).sort().forEach(dateKey => {
const dayData = eventsByDay[dateKey];
const dayElement = document.createElement('div');
dayElement.className = 'day-group';
// Format day header
const dayDate = dayData.date;
const isToday = dayDate.toDateString() === new Date().toDateString();
const dayName = isToday ? 'Today' : dayDate.toLocaleDateString('en-NZ', { weekday: 'long' });
const dateStr = dayDate.toLocaleDateString('en-NZ', { day: 'numeric', month: 'short' });
dayElement.innerHTML = `
<div class="day-header">
<span class="day-name">${dayName}</span>
<span class="day-date">${dateStr}</span>
</div>
<div class="day-events"></div>
`;
const dayEventsContainer = dayElement.querySelector('.day-events');
if (dayData.events.length === 0) {
dayEventsContainer.innerHTML = '<div class="no-events">No events</div>';
} else {
dayData.events.forEach(event => {
const eventElement = document.createElement('div');
eventElement.className = 'event';
const startTime = new Date(event.start);
const endTime = new Date(event.end);
// Check if it's an all-day event (time is 00:00)
const isAllDay = startTime.getHours() === 0 && startTime.getMinutes() === 0
&& endTime.getHours() === 0 && endTime.getMinutes() === 0;
let timeString;
if (isAllDay) {
timeString = 'All day';
} else {
timeString = startTime.toLocaleTimeString('en-NZ', { hour: '2-digit', minute: '2-digit' });
}
eventElement.innerHTML = `
<div class="event-time">${timeString}</div>
<div class="event-title">${event.title}</div>
${event.location ? `<div class="event-location">📍 ${event.location}</div>` : ''}
`;
dayEventsContainer.appendChild(eventElement);
});
}
eventsContainer.appendChild(dayElement);
});
document.getElementById('week-range').textContent = formatRangeLabel(data.week_start, data.week_end);
buildFilterOptions(data.members);
renderWeek(data);
} catch (error) { } catch (error) {
console.error('Error updating calendar:', error); console.error('Error updating calendar:', error);
document.getElementById('week-grid').innerHTML = '<div class="event-placeholder">Unable to load calendar</div>';
} }
} }
// Calculate brightness of an image function buildFilterOptions(members) {
function calculateImageBrightness(imageSrc, callback) { if (filterOptionsBuilt || !members) return;
const img = new Image(); const select = document.getElementById('member-filter');
img.crossOrigin = 'Anonymous'; members.forEach(member => {
const option = document.createElement('option');
img.onload = () => { option.value = member.key;
// Create a canvas to analyze the image option.textContent = member.name;
const canvas = document.createElement('canvas'); select.appendChild(option);
const ctx = canvas.getContext('2d'); });
filterOptionsBuilt = true;
// Use a smaller size for faster processing
canvas.width = 100;
canvas.height = 100;
// Draw the image scaled down
ctx.drawImage(img, 0, 0, 100, 100);
// Get image data
const imageData = ctx.getImageData(0, 0, 100, 100);
const data = imageData.data;
// Calculate average brightness
let totalBrightness = 0;
const pixelCount = data.length / 4;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
// Calculate perceived brightness (using luminance formula)
const brightness = (0.299 * r + 0.587 * g + 0.114 * b);
totalBrightness += brightness;
}
const avgBrightness = totalBrightness / pixelCount;
// Return brightness value (0-255)
callback(avgBrightness);
};
img.onerror = () => {
console.error('Error loading image for brightness analysis');
// Default to dark background if error
callback(100);
};
img.src = imageSrc;
} }
// Update background image function filterEvents(events) {
async function updateBackground() { if (activeFilter === 'all') return events;
try { return events.filter(e => e.member === activeFilter);
const response = await fetch('/api/background'); }
if (!response.ok) throw new Error('Background fetch failed');
const data = await response.json(); function formatEventTime(event) {
const backgroundElement = document.getElementById('background'); const start = new Date(event.start);
const end = new Date(event.end);
const isAllDay = start.getHours() === 0 && start.getMinutes() === 0 && end.getHours() === 0 && end.getMinutes() === 0;
if (isAllDay) return 'All day';
return start.toLocaleTimeString('en-NZ', { hour: '2-digit', minute: '2-digit' });
}
if (data.image) { function eventCardHTML(event) {
// Preload image to avoid flicker return `
const img = new Image(); <div class="event-card" style="background:${event.bg}; border-left-color:${event.color};">
img.onload = () => { <div class="event-time">${formatEventTime(event)}</div>
backgroundElement.style.backgroundImage = `url('${data.image}')`; <div class="event-title">${event.title}</div>
<div class="event-member">${event.member_name}</div>
</div>
`;
}
// Analyze brightness and adjust text color function renderWeek(data) {
calculateImageBrightness(data.image, (brightness) => { const grid = document.getElementById('week-grid');
// Threshold: 128 is middle brightness grid.innerHTML = '';
// If brightness > 140, use dark text (light background)
// If brightness <= 140, use white text (dark background)
if (brightness > 140) {
document.body.classList.remove('dark-bg');
document.body.classList.add('light-bg');
} else {
document.body.classList.remove('light-bg');
document.body.classList.add('dark-bg');
}
});
};
img.src = data.image;
} else if (data.color) {
backgroundElement.style.backgroundColor = data.color;
backgroundElement.style.backgroundImage = 'none';
// Assume solid colors are dark
document.body.classList.remove('light-bg');
document.body.classList.add('dark-bg');
}
} catch (error) { const todayISO = toISODate(new Date());
console.error('Error updating background:', error);
} data.days.forEach(day => {
} const dayDate = new Date(day.date + 'T00:00:00');
const events = filterEvents(day.events);
// Fetch and update dad joke
async function updateJoke() { const column = document.createElement('div');
try { column.className = 'day-column' + (day.date === todayISO ? ' is-today' : '');
const response = await fetch('/api/joke'); column.innerHTML = `
if (!response.ok) throw new Error('Joke fetch failed'); <div class="day-header">
<div class="day-name">${DAY_LABELS[dayDate.getDay()]}</div>
const data = await response.json(); <div class="day-number">${dayDate.getDate()}</div>
<div class="day-count">${events.length} event${events.length === 1 ? '' : 's'}</div>
if (data.joke) { </div>
document.getElementById('joke').textContent = data.joke; <div class="day-events">
} ${events.map(eventCardHTML).join('') || ''}
</div>
} catch (error) { `;
console.error('Error updating joke:', error); grid.appendChild(column);
});
const nextWeekEvents = filterEvents(data.next_week.events);
const nextColumn = document.createElement('div');
nextColumn.className = 'next-week-column';
nextColumn.innerHTML = `
<div class="next-week-title">Next Week</div>
<div class="next-week-range">${formatRangeLabel(data.next_week.start, data.next_week.end)}</div>
<div class="next-week-count">${data.next_week.count} event${data.next_week.count === 1 ? '' : 's'}</div>
`;
nextColumn.addEventListener('click', () => shiftWeek(7));
grid.appendChild(nextColumn);
}
function showAddToast() {
let toast = document.querySelector('.add-toast');
if (!toast) {
toast = document.createElement('div');
toast.className = 'add-toast';
toast.textContent = 'This display is read-only — add events directly in Google Calendar.';
document.body.appendChild(toast);
} }
toast.classList.add('visible');
clearTimeout(showAddToast._timer);
showAddToast._timer = setTimeout(() => toast.classList.remove('visible'), 3000);
} }

View File

@@ -3,48 +3,46 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Family Calendar Display</title> <title>Family Calendar</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head> </head>
<body> <body>
<!-- Background container --> <div class="app">
<div id="background"></div> <!-- Top bar: date/time, week navigation, filter, weather -->
<header class="topbar">
<!-- Main content overlay --> <div class="topbar-left">
<div class="container"> <div class="date-time">
<!-- Header with Time and Date --> <span class="icon-badge">&#128197;</span>
<header class="header"> <span id="header-date" class="header-date">Loading...</span>
<div class="time-display"> <span id="header-time" class="header-time">--:--</span>
<div id="time" class="time">--:--</div> </div>
<div id="date" class="date">Loading...</div> <div class="nav-row">
<span class="pill view-pill">Week</span>
<div class="week-nav">
<button id="prev-week" class="nav-arrow" aria-label="Previous week">&#8249;</button>
<span id="week-range" class="week-range">--</span>
<button id="next-week" class="nav-arrow" aria-label="Next week">&#8250;</button>
</div>
<select id="member-filter" class="pill filter-select">
<option value="all">Filter: All</option>
</select>
</div>
</div> </div>
<div class="weather-summary"> <div class="topbar-right">
<div class="current-weather-item"> <div class="weather-chip">
<div class="weather-label">Today</div> <span id="weather-icon" class="weather-icon"></span>
<div id="current-temp" class="current-temp">--°</div> <span id="current-temp" class="current-temp">--&deg;</span>
<div id="weather-icon" class="weather-icon"></div>
</div>
<div id="header-forecast" class="header-forecast">
<!-- 3-day forecast will be inserted here -->
</div> </div>
<span class="wifi-icon" id="connection-status" title="Connected">&#128225;</span>
</div> </div>
</header> </header>
<!-- Main content area --> <!-- Week grid: Sunday - Saturday + Next Week preview -->
<div class="main-content"> <main id="week-grid" class="week-grid">
<!-- Calendar Section --> <div class="event-placeholder">Loading calendar...</div>
<section class="calendar-section"> </main>
<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 --> <button id="add-btn" class="add-fab" title="Add events from Google Calendar" aria-label="Add event">+</button>
<footer class="footer">
<div id="joke" class="joke">Loading a joke to brighten your day...</div>
</footer>
</div> </div>
<script src="{{ url_for('static', filename='js/app.js') }}"></script> <script src="{{ url_for('static', filename='js/app.js') }}"></script>