Author SHA1 Message Date
luddieandClaude Sonnet 4.6 2e606f061a Set slideshow inactivity timeout to 20 minutes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 21:55:40 +12:00
luddieandClaude Sonnet 4.6 05d6fd93b5 Fix slideshow mid-transition flash — delay src change until fade-out completes
Changing current.src immediately triggered the new image to render while
the element was still mid-opacity-transition (partially visible), causing
the next queued photo to briefly flash through the crossfade. Now we wait
for the transitionend event before setting the new preload src.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 21:49:54 +12:00
luddieandClaude Sonnet 4.6 1e97e7c805 Fix slideshow crossfade flash — wait for image load before fading in
Previously the active class was applied via requestAnimationFrame
before the image had decoded, causing a brief blank-then-flash.
Now loadSlide waits for onload before transitioning, and advanceSlide
checks complete/naturalWidth (or waits for onload) on the preloaded
slot before doing the swap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 21:41:25 +12:00
luddieandClaude Sonnet 4.6 afddfd5bb1 Fetch photo list fresh on each slideshow start
Previously fetched once at page load — if photos were added after the
service started they wouldn't appear until a restart. Now fetches on
every slideshow activation so new photos show up immediately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 21:27:40 +12:00
luddieandClaude Sonnet 4.6 21073f7469 Increase event card font sizes by 2px
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:16:10 +12:00
luddieandClaude Sonnet 4.6 ed3f803464 Redesign layout: 3-per-row 2-row grid, 6 days, bigger fonts
- Show 6 days in a 3x2 grid (3 columns, 2 rows) with info panel
  spanning the full right column
- Day columns explicitly grid-placed to prevent auto-flow into panel column
- Larger fonts throughout: event cards 14px, day number 26px, clock 52px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 20:13:50 +12:00
luddieandClaude Sonnet 4.6 575eb6c525 Fix multi-day events showing on all days they span
- Fetch from 7 days back so events that started before today (e.g.
  weekly chore rotation starting last Sunday) are included
- All-day multi-day events now appear on every day they cover, not just
  their start date (using iCal's exclusive-end convention correctly)
- Timed events continue to show only on their start date

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 17:08:33 +12:00
luddieandClaude Sonnet 4.6 41e9c3bf71 Fix forecast min/max to use true daily range across all 3-hour slots
Previously took min/max from only the first 3-hour entry of each day,
giving a near-flat range. Now aggregates all slots for each date and
uses the midday slot for icon/description.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 21:32:05 +12:00
luddieandClaude Sonnet 4.6 db1d422b21 Add Boys/beetroot color, bump event font size by 1px
- New 'Boys' member with beetroot color (#6B1040 / #D4A8C0) — any event
  containing the word "Boys" gets this color
- Mom/Dad were already matched via fuzzy alias detection (no change needed)
- Event card font sizes increased by 1px (12.5px body, 11.5px time, 11px member)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 21:25:11 +12:00
luddieandClaude Sonnet 4.6 98b4de70e7 Color-code events by family member name anywhere in title
- Fuzzy name matching: if a member's name appears anywhere in the event
  title (word boundary, case-insensitive), it gets their color — no
  [prefix] required. e.g. "Daniel's Week" → green, "Jason Karate" → blue
- More vibrant card color palette (deeper pastels, stronger border accent)
- Bolder event title text and slightly thicker left border for clarity

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 21:17:29 +12:00
luddieandClaude Sonnet 4.6 93b2dad8fd Add photo slideshow screensaver with inactivity timer
- After 1 hour of inactivity the screen switches to a fullscreen
  crossfade slideshow of photos from static/photos/
- Touch anywhere on screen to dismiss and return to the calendar
- /api/photos endpoint serves a shuffled list of images from static/photos/
- Two-slot A/B image swap for smooth 1.5s crossfades; next image preloads
  in the background while current is displayed (15s per photo)
- Slideshow only activates if static/photos/ contains at least one image

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 21:00:22 +12:00
luddieandClaude Sonnet 4.6 20c9bb55b1 Switch to 5-day rolling view with clock/weather info panel
- Show today + next 4 days instead of a fixed Sun-Sat week grid
- Replace Next Week column with a right-side info panel (live clock,
  date, current weather + 3-day forecast)
- Simplify topbar to just the view pill, date range, and member filter
- Adjust grid to repeat(5, 1fr) 190px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 20:53:36 +12:00
luddieandClaude Sonnet 4.6 fc6abfe745 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>
2026-07-27 20:32:15 +12:00
luddie 18965045fa Merge pull request 'fix/background-rotation-interval' (#3) from fix/background-rotation-interval into first
Reviewed-on: #3
2026-02-27 21:58:07 +13:00
6 changed files with 785 additions and 762 deletions
+34 -51
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!
+145 -39
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}
@@ -53,21 +54,31 @@ def get_weather():
forecast_response.raise_for_status() forecast_response.raise_for_status()
forecast_data = forecast_response.json() forecast_data = forecast_response.json()
# Process forecast to get daily summaries # Group all 3-hour slots by date so we can find the true daily min/max
daily_forecast = [] from collections import defaultdict
seen_dates = set() slots_by_date = defaultdict(list)
for item in forecast_data['list']:
d = datetime.fromtimestamp(item['dt']).date()
slots_by_date[d].append(item)
for item in forecast_data['list'][:40]: # Next 3 days (8 forecasts per day) today_date = datetime.now().date()
date = datetime.fromtimestamp(item['dt']).date() future_dates = sorted(d for d in slots_by_date if d > today_date)[:3]
if date not in seen_dates and len(daily_forecast) < 3:
seen_dates.add(date) daily_forecast = []
daily_forecast.append({ for d in future_dates:
'date': date.strftime('%a'), slots = slots_by_date[d]
'temp_max': round(item['main']['temp_max']), # True daily min/max across all 3-hour periods
'temp_min': round(item['main']['temp_min']), temp_max = round(max(s['main']['temp_max'] for s in slots))
'description': item['weather'][0]['description'], temp_min = round(min(s['main']['temp_min'] for s in slots))
'icon': item['weather'][0]['icon'] # Use the midday slot for icon/description
}) midday = min(slots, key=lambda s: abs(datetime.fromtimestamp(s['dt']).hour - 12))
daily_forecast.append({
'date': d.strftime('%a'),
'temp_max': temp_max,
'temp_min': temp_min,
'description': midday['weather'][0]['description'],
'icon': midday['weather'][0]['icon']
})
weather_data = { weather_data = {
'current': { 'current': {
@@ -94,35 +105,91 @@ 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):
"""Identify a family member from an event title.
Checks in order:
1. [Alias] prefix (strips the prefix from the displayed title)
2. Alias word appearing anywhere in the title (title kept as-is)
"""
match = MEMBER_PREFIX_RE.match(summary)
if match:
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
summary_lower = summary.lower()
for member in family_members:
for alias in member['aliases']:
if re.search(r'\b' + re.escape(alias) + r'\b', summary_lower):
return member, summary
return None, summary
NUM_DAYS = 6
def empty_days_payload(start_date, members):
days = []
for i in range(NUM_DAYS):
d = start_date + timedelta(days=i)
days.append({'date': d.isoformat(), 'weekday': d.strftime('%A'), 'events': []})
return {
'start': start_date.isoformat(),
'end': (start_date + timedelta(days=NUM_DAYS - 1)).isoformat(),
'days': days,
'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 — today plus the next 4 days."""
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:
start_date = datetime.strptime(start_param, '%Y-%m-%d').date()
except ValueError:
start_date = now.date()
else:
start_date = now.date()
family_members = app.config['FAMILY_MEMBERS']
default_member = app.config['DEFAULT_MEMBER']
members_for_filter = family_members + [default_member]
cache_key = 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_days_payload(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 from 7 days back so multi-day events that started before
cutoff_date = now + timedelta(days=app.config['CALENDAR_DAYS_AHEAD']) # today (e.g. "Bins - Daniel's Week" starting last Sunday) are included.
fetch_start = nz_tz.localize(datetime.combine(start_date - timedelta(days=7), datetime.min.time()))
# Get all events between now and cutoff_date fetch_end = nz_tz.localize(datetime.combine(start_date + timedelta(days=NUM_DAYS), datetime.min.time()))
recurring_events = recurring_ical_events.of(cal).between(now, cutoff_date) recurring_events = recurring_ical_events.of(cal).between(fetch_start, fetch_end)
events = [] events = []
for component in recurring_events: for component in recurring_events:
@@ -131,23 +198,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 +219,68 @@ 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} def event_covers_day(e, d):
return jsonify(events) e_start = datetime.fromisoformat(e['start'])
e_end = datetime.fromisoformat(e['end'])
is_all_day = (e_start.hour == 0 and e_start.minute == 0 and
e_end.hour == 0 and e_end.minute == 0)
if is_all_day:
# iCal all-day end is exclusive (end = day after last day)
return e_start.date() <= d < e_end.date()
else:
return e_start.date() == d
days = []
for i in range(NUM_DAYS):
d = start_date + timedelta(days=i)
day_events = [e for e in events if event_covers_day(e, d)]
days.append({'date': d.isoformat(), 'weekday': d.strftime('%A'), 'events': day_events})
result = {
'start': start_date.isoformat(),
'end': (start_date + timedelta(days=NUM_DAYS - 1)).isoformat(),
'days': days,
'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(empty_week_payload(week_start_date, members_for_filter))
@app.route('/api/photos')
def get_photos():
"""Return a shuffled list of all image URLs from the photos directory."""
photos_dir = app.config['PHOTOS_DIR']
if not os.path.exists(photos_dir):
return jsonify([]) return jsonify([])
images = [
f'/static/photos/{f}' for f in os.listdir(photos_dir)
if f.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp'))
]
random.shuffle(images)
return jsonify(images)
@app.route('/api/background') @app.route('/api/background')
@@ -231,8 +337,8 @@ def get_joke():
if __name__ == '__main__': if __name__ == '__main__':
# Create backgrounds directory if it doesn't exist
os.makedirs(app.config['BACKGROUNDS_DIR'], exist_ok=True) os.makedirs(app.config['BACKGROUNDS_DIR'], exist_ok=True)
os.makedirs(app.config['PHOTOS_DIR'], exist_ok=True)
os.makedirs(app.config['CREDENTIALS_DIR'], exist_ok=True) os.makedirs(app.config['CREDENTIALS_DIR'], exist_ok=True)
# Run the app # Run the app
+13
View File
@@ -28,4 +28,17 @@ class Config:
# Directories # Directories
BACKGROUNDS_DIR = os.path.join('static', 'backgrounds') BACKGROUNDS_DIR = os.path.join('static', 'backgrounds')
PHOTOS_DIR = os.path.join('static', 'photos')
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': '#1A6BBF', 'bg': '#C2DEFA'},
{'key': 'mom', 'name': 'Michelle', 'aliases': ['michelle', 'mom'], 'color': '#BF1A6B', 'bg': '#FAC2DC'},
{'key': 'jason', 'name': 'Jason', 'aliases': ['jason'], 'color': '#1A8C1E', 'bg': '#BFEDCA'},
{'key': 'daniel','name': 'Daniel', 'aliases': ['daniel'], 'color': '#BF7A00', 'bg': '#FAE4A0'},
{'key': 'boys', 'name': 'Boys', 'aliases': ['boys'], 'color': '#6B1040', 'bg': '#D4A8C0'},
]
DEFAULT_MEMBER = {'key': 'family', 'name': 'Family', 'color': '#666666', 'bg': '#E0E0E0'}
+283 -373
View File
@@ -1,441 +1,351 @@
/* 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;
overflow: hidden;
width: 100vw;
height: 100vh;
color: #ffffff;
transition: color 0.5s ease-in-out;
}
/* Dark text for bright backgrounds */
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%; height: 100%;
background-size: cover; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background-position: center; background: #F4F5F7;
color: #2B2B33;
overflow: hidden;
}
.app {
height: 100vh;
display: flex;
flex-direction: column;
padding: 14px 20px 20px;
}
/* ---------- Top bar ---------- */
.topbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
}
.pill {
display: inline-flex;
align-items: center;
background: #FFFFFF;
border: 1px solid #E3E5EA;
border-radius: 18px;
padding: 6px 14px;
font-size: 13px;
font-weight: 600;
color: #4A4E5A;
}
.filter-select {
appearance: none;
-webkit-appearance: none;
cursor: pointer;
padding-right: 28px;
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; background-repeat: no-repeat;
z-index: 0;
transition: background-image 1s ease-in-out;
} }
#background::before { .date-range-label {
content: ''; font-size: 13px;
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;
display: flex;
flex-direction: column;
padding: 1rem;
}
/* Header maximally transparent; text readable via text-shadow */
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
background: rgba(0, 0, 0, 0.03);
padding: 1rem 1.5rem;
border-radius: 10px;
}
.time-display {
display: flex;
flex-direction: column;
}
.time {
font-size: 4.5rem;
font-weight: 600; font-weight: 600;
line-height: 1; color: #4A4E5A;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
} }
.date { /* ---------- Topbar right-side items removed — now live in info panel ---------- */
font-size: 1.6rem;
font-weight: 500;
margin-top: 0.3rem;
opacity: 0.9;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
}
.weather-summary {
display: flex;
align-items: center;
gap: 1.5rem;
}
.current-weather-item { /* ---------- Week grid ---------- */
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
}
.weather-label { .week-grid {
font-size: 1.2rem;
font-weight: 600;
opacity: 0.8;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.current-temp {
font-size: 3rem;
font-weight: 600;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
}
.weather-icon {
font-size: 2.5rem;
}
.header-forecast {
display: flex;
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;
}
.forecast-temps .low {
opacity: 0.6;
}
/* Main content */
.main-content {
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(3, 1fr) 210px;
align-items: center; grid-template-rows: 1fr 1fr;
padding: 0.6rem; gap: 12px;
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: 13px;
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: 26px;
font-weight: 600; font-weight: 700;
opacity: 0.8; color: #1F2430;
margin-top: 0.2rem; }
.day-count {
font-size: 12px;
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: 8px 10px;
opacity: 0.5; border-left: 5px solid transparent;
font-style: italic; font-size: 16px;
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.5);
color: #4a9eff; font-size: 15px;
} }
.event-title { .event-title {
font-size: 1.3rem; font-weight: 700;
font-weight: 600; color: #1A1A2A;
margin-bottom: 0.2rem; overflow-wrap: anywhere;
line-height: 1.2;
} }
.event-location { .event-member {
font-size: 1.1rem; font-size: 14px;
font-weight: 500; font-weight: 600;
opacity: 0.7; color: rgba(0, 0, 0, 0.45);
font-style: italic; margin-top: 2px;
} }
.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 */ /* ---------- Info panel (clock + weather) ---------- */
.footer {
margin-top: 1rem; .info-panel {
background: rgba(0, 0, 0, 0.03); background: #FFFFFF;
padding: 0.8rem 1.5rem; border-radius: 14px;
padding: 18px 14px;
display: flex;
flex-direction: column;
gap: 0;
overflow: hidden;
}
.info-clock {
font-size: 52px;
font-weight: 700;
color: #1F2430;
letter-spacing: -1px;
line-height: 1;
}
.info-weekday {
font-size: 13px;
font-weight: 600;
color: #A6AAB4;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 4px;
}
.info-date {
font-size: 15px;
font-weight: 700;
color: #4A4E5A;
margin-top: 2px;
}
.info-divider {
height: 1px;
background: #F0F1F4;
margin: 14px 0;
}
.info-weather-current {
display: flex;
align-items: center;
gap: 8px;
}
.info-weather-current .owm-icon {
width: 40px;
height: 40px;
}
.info-current-temp {
font-size: 28px;
font-weight: 700;
color: #1F2430;
}
.info-current-desc {
font-size: 11px;
color: #A6AAB4;
text-transform: capitalize;
margin-top: 2px;
}
.info-forecast {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
.forecast-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
}
.forecast-row .f-day {
font-weight: 600;
color: #4A4E5A;
width: 30px;
}
.forecast-row .f-icon .owm-icon {
width: 22px;
height: 22px;
}
.forecast-row .f-temps {
text-align: right;
color: #4A4E5A;
}
.forecast-row .f-temps .f-max {
font-weight: 700;
}
.forecast-row .f-temps .f-min {
color: #A6AAB4;
}
/* ---------- Add button ---------- */
.add-fab {
position: fixed;
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 */ /* ---------- Photo slideshow screensaver ---------- */
::-webkit-scrollbar {
width: 8px; .slideshow-overlay {
position: fixed;
inset: 0;
background: #000;
z-index: 100;
} }
::-webkit-scrollbar-track { .slideshow-overlay.hidden {
background: rgba(255, 255, 255, 0.05); display: none;
border-radius: 10px;
} }
::-webkit-scrollbar-thumb { .slide-img {
background: rgba(255, 255, 255, 0.2); position: absolute;
border-radius: 10px; inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 1.5s ease-in-out;
} }
::-webkit-scrollbar-thumb:hover { .slide-img.active {
background: rgba(255, 255, 255, 0.3); opacity: 1;
} }
/* Responsive adjustments for smaller screens */ .slideshow-hint {
@media (max-width: 1200px) { position: absolute;
.time { bottom: 32px;
font-size: 5rem; left: 50%;
} transform: translateX(-50%);
color: rgba(255, 255, 255, 0.35);
.date { font-size: 13px;
font-size: 2rem; letter-spacing: 0.04em;
} pointer-events: none;
white-space: nowrap;
.current-temp {
font-size: 4rem;
}
section h2 {
font-size: 2.5rem;
}
} }
@media (max-width: 768px) { /* Scrollbar styling for day columns */
.main-content { .day-events::-webkit-scrollbar {
grid-template-columns: 1fr; width: 4px;
} }
.time { .day-events::-webkit-scrollbar-thumb {
font-size: 4rem; background: #E3E5EA;
} border-radius: 4px;
.current-temp {
font-size: 3.5rem;
}
} }
+289 -263
View File
@@ -1,316 +1,342 @@
// Update intervals (in milliseconds)
const INTERVALS = { const INTERVALS = {
TIME: 1000, // 1 second TIME: 1000,
WEATHER: 900000, // 15 minutes WEATHER: 900000,
CALENDAR: 300000, // 5 minutes CALENDAR: 300000
BACKGROUND: 3600000, // 60 minutes
JOKE: 3600000 // 1 hour
}; };
// Return an OWM icon image tag for a given icon code const INACTIVITY_TIMEOUT = 20 * 60 * 1000; // 20 minutes
const SLIDE_DURATION = 15 * 1000; // 15 seconds per photo
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 activeFilter = 'all';
let filterOptionsBuilt = false;
let lastData = null;
let lastWeatherData = null;
// Slideshow state
let photos = [];
let slideIndex = 0;
let activeSlot = 'a';
let slideTimer = null;
let inactivityTimer = null;
let slideshowActive = false;
function weatherIcon(code) { function weatherIcon(code) {
const src = `https://openweathermap.org/img/wn/${code}@2x.png`; return `<img src="https://openweathermap.org/img/wn/${code}@2x.png" alt="${code}" class="owm-icon">`;
return `<img src="${src}" alt="${code}" class="owm-icon">`; }
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', () => { document.addEventListener('DOMContentLoaded', () => {
// Start all update functions
updateTime(); updateTime();
updateWeather(); updateWeather();
updateCalendar(); fetchCalendar();
updateBackground(); initSlideshow();
updateJoke();
document.getElementById('member-filter').addEventListener('change', (e) => {
activeFilter = e.target.value;
if (lastData) renderDays(lastData);
});
document.getElementById('add-btn').addEventListener('click', showAddToast);
// Set up intervals
setInterval(updateTime, INTERVALS.TIME); setInterval(updateTime, INTERVALS.TIME);
setInterval(updateWeather, INTERVALS.WEATHER); setInterval(updateWeather, INTERVALS.WEATHER);
setInterval(updateCalendar, INTERVALS.CALENDAR); setInterval(fetchCalendar, INTERVALS.CALENDAR);
setInterval(updateBackground, INTERVALS.BACKGROUND);
setInterval(updateJoke, INTERVALS.JOKE);
}); });
// Update time and date
function updateTime() { function updateTime() {
const now = new Date(); const now = new Date();
// Format time (HH:MM)
const hours = String(now.getHours()).padStart(2, '0'); const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0'); const minutes = String(now.getMinutes()).padStart(2, '0');
document.getElementById('time').textContent = `${hours}:${minutes}`; const el = document.getElementById('info-clock');
if (el) el.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() { 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) { lastWeatherData = data;
console.error('Weather error:', data.error); renderInfoWeather(data);
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 function renderInfoWeather(data) {
async function updateCalendar() { const tempEl = document.getElementById('info-current-temp');
const iconEl = document.getElementById('info-weather-icon');
const descEl = document.getElementById('info-current-desc');
const forecastEl = document.getElementById('info-forecast');
if (!tempEl) return;
tempEl.textContent = `${data.current.temp}°`;
iconEl.innerHTML = weatherIcon(data.current.icon);
descEl.textContent = data.current.description;
forecastEl.innerHTML = (data.forecast || []).map(day => `
<div class="forecast-row">
<span class="f-day">${day.date}</span>
<span class="f-icon">${weatherIcon(day.icon)}</span>
<span class="f-temps">
<span class="f-max">${day.temp_max}°</span>
<span class="f-min"> / ${day.temp_min}°</span>
</span>
</div>
`).join('');
}
async function fetchCalendar() {
try { try {
const response = await fetch('/api/calendar'); const response = await fetch(`/api/calendar?start=${toISODate(new Date())}`);
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();
lastData = 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('date-range').textContent = formatRangeLabel(data.start, data.end);
buildFilterOptions(data.members);
renderDays(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');
option.value = member.key;
option.textContent = member.name;
select.appendChild(option);
});
filterOptionsBuilt = true;
}
img.onload = () => { function filterEvents(events) {
// Create a canvas to analyze the image if (activeFilter === 'all') return events;
const canvas = document.createElement('canvas'); return events.filter(e => e.member === activeFilter);
const ctx = canvas.getContext('2d'); }
// Use a smaller size for faster processing function formatEventTime(event) {
canvas.width = 100; const start = new Date(event.start);
canvas.height = 100; 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' });
}
// Draw the image scaled down function eventCardHTML(event) {
ctx.drawImage(img, 0, 0, 100, 100); return `
<div class="event-card" style="background:${event.bg}; border-left-color:${event.color};">
<div class="event-time">${formatEventTime(event)}</div>
<div class="event-title">${event.title}</div>
<div class="event-member">${event.member_name}</div>
</div>
`;
}
// Get image data // Grid positions for 6 days arranged 3-per-row, info panel in col 4 spanning both rows
const imageData = ctx.getImageData(0, 0, 100, 100); const DAY_GRID_POSITIONS = [
const data = imageData.data; {col: 1, row: 1}, {col: 2, row: 1}, {col: 3, row: 1},
{col: 1, row: 2}, {col: 2, row: 2}, {col: 3, row: 2},
];
// Calculate average brightness function renderDays(data) {
let totalBrightness = 0; const grid = document.getElementById('week-grid');
const pixelCount = data.length / 4; grid.innerHTML = '';
for (let i = 0; i < data.length; i += 4) { const todayISO = toISODate(new Date());
const r = data[i]; const now = new Date();
const g = data[i + 1];
const b = data[i + 2];
// Calculate perceived brightness (using luminance formula) data.days.forEach((day, i) => {
const brightness = (0.299 * r + 0.587 * g + 0.114 * b); const dayDate = new Date(day.date + 'T00:00:00');
totalBrightness += brightness; const events = filterEvents(day.events);
const pos = DAY_GRID_POSITIONS[i];
const column = document.createElement('div');
column.className = 'day-column' + (day.date === todayISO ? ' is-today' : '');
column.style.gridColumn = pos.col;
column.style.gridRow = pos.row;
column.innerHTML = `
<div class="day-header">
<div class="day-name">${DAY_LABELS[dayDate.getDay()]}</div>
<div class="day-number">${dayDate.getDate()}</div>
<div class="day-count">${events.length} event${events.length === 1 ? '' : 's'}</div>
</div>
<div class="day-events">
${events.map(eventCardHTML).join('')}
</div>
`;
grid.appendChild(column);
});
// Info panel — clock + date + weather
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const panel = document.createElement('div');
panel.className = 'info-panel';
panel.style.gridColumn = '4';
panel.style.gridRow = '1 / 3';
panel.innerHTML = `
<div id="info-clock" class="info-clock">${hours}:${minutes}</div>
<div class="info-weekday">${DAY_LABELS[now.getDay()]}</div>
<div class="info-date">${now.getDate()} ${MONTH_LABELS[now.getMonth()]} ${now.getFullYear()}</div>
<div class="info-divider"></div>
<div class="info-weather-current">
<span id="info-weather-icon">--</span>
<div>
<div id="info-current-temp" class="info-current-temp">--°</div>
<div id="info-current-desc" class="info-current-desc">--</div>
</div>
</div>
<div id="info-forecast" class="info-forecast"></div>
`;
grid.appendChild(panel);
// Populate weather immediately if we already have it
if (lastWeatherData) renderInfoWeather(lastWeatherData);
}
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);
}
// ---------- Slideshow screensaver ----------
function initSlideshow() {
['touchstart', 'click', 'mousemove', 'keydown'].forEach(evt => {
document.addEventListener(evt, handleUserActivity, { passive: true });
});
resetInactivityTimer();
}
function handleUserActivity() {
if (slideshowActive) {
hideSlideshow();
} else {
resetInactivityTimer();
}
}
function resetInactivityTimer() {
clearTimeout(inactivityTimer);
inactivityTimer = setTimeout(showSlideshow, INACTIVITY_TIMEOUT);
}
async function showSlideshow() {
// Fetch fresh each time so newly added photos appear without a restart
try {
const res = await fetch('/api/photos');
photos = await res.json();
} catch (e) {
console.error('Could not load photos:', e);
}
if (!photos.length) {
resetInactivityTimer();
return;
}
slideshowActive = true;
const overlay = document.getElementById('slideshow-overlay');
overlay.classList.remove('hidden');
slideIndex = Math.floor(Math.random() * photos.length);
activeSlot = 'a';
loadSlide('a', photos[slideIndex]);
if (photos.length > 1) {
loadSlide('b', photos[(slideIndex + 1) % photos.length]);
}
slideTimer = setInterval(advanceSlide, SLIDE_DURATION);
}
function hideSlideshow() {
slideshowActive = false;
clearInterval(slideTimer);
slideTimer = null;
const overlay = document.getElementById('slideshow-overlay');
overlay.classList.add('hidden');
// Clear images to free memory
document.getElementById('slide-a').classList.remove('active');
document.getElementById('slide-b').classList.remove('active');
document.getElementById('slide-a').src = '';
document.getElementById('slide-b').src = '';
resetInactivityTimer();
}
function loadSlide(slot, url) {
const img = document.getElementById(`slide-${slot}`);
if (slot === activeSlot) {
// Only fade in once the image is fully decoded — no blank-then-flash
img.onload = () => { img.onload = null; img.classList.add('active'); };
img.src = url;
// Handle cached images that won't fire onload
if (img.complete && img.naturalWidth > 0) {
img.onload = null;
img.classList.add('active');
} }
} else {
img.src = url; // preload only
}
}
const avgBrightness = totalBrightness / pixelCount; function advanceSlide() {
const nextSlot = activeSlot === 'a' ? 'b' : 'a';
const current = document.getElementById(`slide-${activeSlot}`);
const next = document.getElementById(`slide-${nextSlot}`);
// Return brightness value (0-255) const doSwap = () => {
callback(avgBrightness); next.onload = null;
next.classList.add('active');
current.classList.remove('active');
activeSlot = nextSlot;
slideIndex = (slideIndex + 1) % photos.length;
const preloadSrc = photos[(slideIndex + 1) % photos.length];
// Only change src after the fade-out completes — changing it mid-transition
// causes the new image to appear at partial opacity (the flash the user sees)
current.addEventListener('transitionend', () => { current.src = preloadSrc; }, { once: true });
}; };
img.onerror = () => { // If the preloaded image is ready, swap immediately; otherwise wait for it
console.error('Error loading image for brightness analysis'); if (next.complete && next.naturalWidth > 0) {
// Default to dark background if error doSwap();
callback(100); } else {
}; next.onload = doSwap;
img.src = imageSrc;
}
// 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}')`;
// Analyze brightness and adjust text color
calculateImageBrightness(data.image, (brightness) => {
// Threshold: 128 is middle brightness
// 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) {
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);
} }
} }
+21 -36
View File
@@ -3,48 +3,33 @@
<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 --> <span class="pill view-pill">5 Days</span>
<div class="container"> <span id="date-range" class="pill date-range-label">--</span>
<!-- Header with Time and Date --> <select id="member-filter" class="pill filter-select">
<header class="header"> <option value="all">Filter: All</option>
<div class="time-display"> </select>
<div id="time" class="time">--:--</div>
<div id="date" class="date">Loading...</div>
</div>
<div class="weather-summary">
<div class="current-weather-item">
<div class="weather-label">Today</div>
<div id="current-temp" class="current-temp">--°</div>
<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>
</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>
<div id="joke" class="joke">Loading a joke to brighten your day...</div>
</footer> <!-- Fullscreen photo slideshow screensaver -->
<div id="slideshow-overlay" class="slideshow-overlay hidden">
<img id="slide-a" class="slide-img" src="" alt="">
<img id="slide-b" class="slide-img" src="" alt="">
<div class="slideshow-hint">Touch to return to calendar</div>
</div> </div>
<script src="{{ url_for('static', filename='js/app.js') }}"></script> <script src="{{ url_for('static', filename='js/app.js') }}"></script>