Implement calendar display with multi-day event support

- Added timezone support (Pacific/Auckland) for calendar events
- Implemented recurring event handling using recurring_ical_events library
- Created horizontal 5-day column layout for calendar display
- Fixed multi-day event rendering to show events across all active days
- Updated calendar to show next 5 days (today + 4)
- Reduced font sizes and padding for compact display
- Changed image rotation interval to 60 seconds
- Added pytz and recurring_ical_events dependencies

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 08:00:52 +13:00
parent 71a450b968
commit 189a340321
6 changed files with 215 additions and 46 deletions

86
app.py
View File

@@ -2,8 +2,11 @@ from flask import Flask, render_template, jsonify
import requests import requests
import os import os
import random import random
from datetime import datetime, timedelta from datetime import datetime, timedelta, date
from icalendar import Calendar
from config import Config from config import Config
import pytz
import recurring_ical_events
app = Flask(__name__) app = Flask(__name__)
app.config.from_object(Config) app.config.from_object(Config)
@@ -93,27 +96,76 @@ def get_weather():
@app.route('/api/calendar') @app.route('/api/calendar')
def get_calendar(): def get_calendar():
"""Fetch Google Calendar events.""" """Fetch Google Calendar events from iCal feed."""
global calendar_cache global calendar_cache
# Check cache # Check cache - use timezone-aware datetime
now = datetime.now() nz_tz = pytz.timezone('Pacific/Auckland')
now = datetime.now(nz_tz)
if (calendar_cache['data'] and calendar_cache['timestamp'] and if (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'])
# TODO: Implement Google Calendar API integration
# For now, return placeholder data
try: try:
# This is placeholder data - will be replaced with actual Google Calendar API # Fetch iCal feed
events = [ ical_url = app.config.get('GOOGLE_CALENDAR_ICAL_URL')
{ if not ical_url:
'title': 'Setup Google Calendar API', return jsonify([])
'start': (datetime.now() + timedelta(hours=2)).isoformat(),
'end': (datetime.now() + timedelta(hours=3)).isoformat(), response = requests.get(ical_url, timeout=10)
'location': '' response.raise_for_status()
}
] # Parse iCal data
cal = Calendar.from_ical(response.content)
# Use recurring_ical_events to get all events in the date range (including recurring ones)
cutoff_date = now + timedelta(days=app.config['CALENDAR_DAYS_AHEAD'])
# Get all events between now and cutoff_date
recurring_events = recurring_ical_events.of(cal).between(now, cutoff_date)
events = []
for component in recurring_events:
dtstart = component.get('dtstart')
dtend = component.get('dtend')
summary = str(component.get('summary', 'No Title'))
if dtstart and dtstart.dt:
# Handle both datetime and date objects
if isinstance(dtstart.dt, datetime):
event_start = dtstart.dt
# Make sure event_start is timezone-aware
if event_start.tzinfo is None:
event_start = nz_tz.localize(event_start)
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()))
else:
continue
# Handle end time
if dtend and dtend.dt:
if isinstance(dtend.dt, datetime):
event_end = dtend.dt
# Make sure event_end is timezone-aware
if event_end.tzinfo is None:
event_end = nz_tz.localize(event_end)
elif isinstance(dtend.dt, date):
event_end = nz_tz.localize(datetime.combine(dtend.dt, datetime.min.time()))
else:
event_end = event_start + timedelta(hours=1)
else:
event_end = event_start + timedelta(hours=1)
events.append({
'title': summary,
'start': event_start.isoformat(),
'end': event_end.isoformat(),
'location': str(component.get('location', ''))
})
# Sort events by start time
events.sort(key=lambda x: x['start'])
calendar_cache = {'data': events, 'timestamp': now} calendar_cache = {'data': events, 'timestamp': now}
return jsonify(events) return jsonify(events)
@@ -122,7 +174,7 @@ def get_calendar():
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['data']:
return jsonify(calendar_cache['data']) return jsonify(calendar_cache['data'])
return jsonify({'error': 'Unable to fetch calendar data'}), 500 return jsonify([])
@app.route('/api/background') @app.route('/api/background')
@@ -184,4 +236,4 @@ if __name__ == '__main__':
os.makedirs(app.config['CREDENTIALS_DIR'], exist_ok=True) os.makedirs(app.config['CREDENTIALS_DIR'], exist_ok=True)
# Run the app # Run the app
app.run(host='0.0.0.0', port=5000, debug=True) app.run(host='0.0.0.0', port=5002, debug=False, use_reloader=False)

View File

@@ -17,7 +17,8 @@ class Config:
# Google Calendar settings # Google Calendar settings
GOOGLE_CALENDAR_ID = os.getenv('GOOGLE_CALENDAR_ID', '') GOOGLE_CALENDAR_ID = os.getenv('GOOGLE_CALENDAR_ID', '')
CALENDAR_DAYS_AHEAD = int(os.getenv('CALENDAR_DAYS_AHEAD', '7')) GOOGLE_CALENDAR_ICAL_URL = os.getenv('GOOGLE_CALENDAR_ICAL_URL', '')
CALENDAR_DAYS_AHEAD = int(os.getenv('CALENDAR_DAYS_AHEAD', '5'))
# Update intervals (in seconds) # Update intervals (in seconds)
IMAGE_ROTATION_INTERVAL = int(os.getenv('IMAGE_ROTATION_INTERVAL', '300')) # 5 minutes IMAGE_ROTATION_INTERVAL = int(os.getenv('IMAGE_ROTATION_INTERVAL', '300')) # 5 minutes

View File

@@ -1,7 +1,5 @@
Flask==3.0.0 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 requests==2.31.0
python-dotenv==1.0.0 python-dotenv==1.0.0
pytz==2024.1
recurring_ical_events==3.8.0

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

View File

@@ -34,7 +34,7 @@ body {
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
background: rgba(0, 0, 0, 0.4); background: rgba(0, 0, 0, 0.1);
z-index: 1; z-index: 1;
} }
@@ -55,7 +55,7 @@ body {
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
margin-bottom: 1rem; margin-bottom: 1rem;
background: rgba(0, 0, 0, 0.3); background: rgba(0, 0, 0, 0.05);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
border-radius: 10px; border-radius: 10px;
@@ -150,7 +150,7 @@ body {
/* Sections */ /* Sections */
section { section {
background: rgba(0, 0, 0, 0.3); background: rgba(0, 0, 0, 0.05);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
border-radius: 10px; border-radius: 10px;
@@ -242,28 +242,75 @@ section h3 {
/* Calendar Section */ /* Calendar Section */
.events-list { .events-list {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.8rem;
height: 100%;
}
.day-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.6rem; min-height: 0;
}
.day-header {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.6rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px 8px 0 0;
margin-bottom: 0.5rem;
text-align: center;
}
.day-name {
font-size: 1rem;
font-weight: 500;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
}
.day-date {
font-size: 0.85rem;
opacity: 0.8;
margin-top: 0.2rem;
}
.day-events {
display: flex;
flex-direction: column;
gap: 0.5rem;
overflow-y: auto;
flex: 1;
}
.no-events {
font-size: 0.85rem;
opacity: 0.5;
font-style: italic;
padding: 0.5rem;
text-align: center;
} }
.event { .event {
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
padding: 0.8rem; padding: 0.5rem 0.6rem;
border-radius: 8px; border-radius: 6px;
border-left: 3px solid #4a9eff; border-left: 3px solid #4a9eff;
} }
.event-time { .event-time {
font-size: 0.9rem; font-size: 0.75rem;
font-weight: 500; font-weight: 500;
margin-bottom: 0.3rem; margin-bottom: 0.2rem;
color: #4a9eff; color: #4a9eff;
} }
.event-title { .event-title {
font-size: 1.1rem; font-size: 0.9rem;
margin-bottom: 0.2rem; margin-bottom: 0.2rem;
line-height: 1.2;
} }
.event-location { .event-location {
@@ -282,7 +329,7 @@ section h3 {
/* Footer */ /* Footer */
.footer { .footer {
margin-top: 1rem; margin-top: 1rem;
background: rgba(0, 0, 0, 0.3); background: rgba(0, 0, 0, 0.05);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
padding: 0.8rem 1.5rem; padding: 0.8rem 1.5rem;
border-radius: 10px; border-radius: 10px;

View File

@@ -114,26 +114,97 @@ async function updateCalendar() {
return; 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 = ''; eventsContainer.innerHTML = '';
events.forEach(event => { Object.keys(eventsByDay).sort().forEach(dateKey => {
const eventElement = document.createElement('div'); const dayData = eventsByDay[dateKey];
eventElement.className = 'event'; const dayElement = document.createElement('div');
dayElement.className = 'day-group';
const startTime = new Date(event.start); // Format day header
const endTime = new Date(event.end); 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' });
// Format time dayElement.innerHTML = `
const timeOptions = { hour: '2-digit', minute: '2-digit', weekday: 'short', day: 'numeric', month: 'short' }; <div class="day-header">
const timeString = startTime.toLocaleDateString('en-NZ', timeOptions); <span class="day-name">${dayName}</span>
<span class="day-date">${dateStr}</span>
eventElement.innerHTML = ` </div>
<div class="event-time">${timeString}</div> <div class="day-events"></div>
<div class="event-title">${event.title}</div>
${event.location ? `<div class="event-location">📍 ${event.location}</div>` : ''}
`; `;
eventsContainer.appendChild(eventElement); 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);
}); });
} catch (error) { } catch (error) {