22 Commits
Author SHA1 Message Date
luddieandClaude Sonnet 4.6 d9f3a98286 Simplify slideshow: load-on-demand, no preloading; add JS cache-busting
advanceSlide now sets src and waits for onload on the next slot directly —
no preloading, no transitionend, no setTimeout. Local files load in
milliseconds so preloading is unnecessary complexity that was causing
the slideshow to stall.

Also adds ?v=<mtime> cache-busting to the app.js script tag so Chromium
always picks up new JS after a deploy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-03 13:03:50 +12:00
luddieandClaude Sonnet 4.6 47d41b4a61 Fix slideshow stalling — replace transitionend with setTimeout for preload
transitionend is unreliable: if the Pi's display sleeps or a transition
is interrupted, the event never fires and the next slot's src is never
set. advanceSlide then waits forever on an onload that can't fire.
Replace with a 1600ms setTimeout (just past the 1.5s CSS transition)
which always fires. Also add a guard to kick-start the src if it's
blank when advanceSlide runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 13:09:00 +12:00
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
luddieandClaude Sonnet 4.6 ef4ffbbf5d Fix weather icons, forecast temps, and update docs
- Replace emoji weather icons with OWM icon images to fix rendering
  issues on Linux displays (e.g.  showing as a rectangle)
- Fix forecast daily max/min to use true high/low across all 3-hour
  slots instead of just the first entry of the day
- Update README: correct BACKGROUND interval (60 min), update kiosk
  setup for labwc/Wayland, add restart-calendar.sh instructions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 21:56:27 +13:00
luddieandClaude Sonnet 4.6 abf1036864 Fix background photo rotation interval from 1 min to 60 min
BACKGROUND interval was set to 60000ms (1 minute) instead of
3600000ms (60 minutes) as intended.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 21:39:30 +13:00
luddieandClaude Sonnet 4.5 4b33ac4bde Generalize code and improve documentation
Remove personal information and improve setup documentation:

**Code Generalization:**
- Updated .env.example with placeholder values
- Removed personal API keys and calendar IDs
- Removed all personal background images (15 images deleted)
- Added backgrounds directory README with usage instructions
- Updated .gitignore to exclude background images

**Documentation Improvements:**
- Added comprehensive Weather API setup instructions
  - How to get OpenWeatherMap API key
  - How to find location coordinates using LatLong.net
- Added detailed Google Calendar setup instructions
  - Public iCal feed method (easiest, no auth required)
  - Alternative Google Calendar API method
- Added background image specifications
  - Supported formats, recommended resolution
  - Where to add images and how to change rotation interval
- Added configuration section explaining all settings
  - Environment variables documentation
  - JavaScript intervals configuration
  - How to change image rotation time in both .env and app.js
- Added dynamic text color feature to feature list

**Setup Instructions Now Include:**
- Step-by-step Weather API key acquisition
- Google Calendar public iCal URL setup
- Background image guidelines and rotation configuration
- All configuration options clearly documented

Users can now easily set up their own calendar display without any personal information in the codebase.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-16 22:36:17 +13:00
luddieandClaude Sonnet 4.5 ced41148ef Add dynamic text color adjustment based on background brightness
Automatically adjusts text color for optimal readability:
- Bright backgrounds: Text switches to dark color (#1a1a1a)
- Dark backgrounds: Text remains white (#ffffff)
- Uses luminance formula (0.299×R + 0.587×G + 0.114×B) for accurate brightness detection
- Analyzes images using canvas to sample 100x100 pixels
- Smooth color transitions (0.5s) between background changes
- Adjusts text shadows for better contrast in each mode
- Event time color adapts (#0066cc for light, #4a9eff for dark)

Technical implementation:
- Added calculateImageBrightness() function in app.js
- Modified updateBackground() to analyze and apply appropriate CSS class
- Added .light-bg and .dark-bg CSS classes for text color themes
- Brightness threshold set at 140 (out of 255)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-16 22:25:51 +13:00
luddieandClaude Sonnet 4.5 11adc10c34 Increase font sizes and make text bolder for better readability
- Increased time display from 3rem to 4.5rem with font-weight 600
- Increased date from 1.1rem to 1.6rem with font-weight 500
- Increased current temperature from 2rem to 3rem with font-weight 600
- Increased weather labels from 0.85rem to 1.2rem with font-weight 600
- Increased weather icons from 1.8rem to 2.5rem
- Increased forecast icons from 1.5rem to 2.2rem
- Increased forecast temps from 0.9rem to 1.3rem with high temps at font-weight 700
- Increased section headers from 1.3rem to 2rem with font-weight 700
- Increased day names from 1rem to 1.5rem with font-weight 700
- Increased day dates from 0.85rem to 1.2rem with font-weight 600
- Increased event times from 0.75rem to 1.1rem with font-weight 700
- Increased event titles from 0.9rem to 1.3rem with font-weight 600
- Increased event locations from 0.85rem to 1.1rem with font-weight 500
- Increased joke text from 0.9rem to 1.3rem with font-weight 500
- Added new background images
- Removed old background images

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-16 22:22:42 +13:00
luddie c5ae7cdd96 Merge pull request 'stuff' (#2) from two into first
Reviewed-on: #2
2026-02-16 18:28:35 +13:00
13 changed files with 940 additions and 678 deletions
+13 -8
View File
@@ -1,20 +1,25 @@
# Flask Configuration # Flask Configuration
FLASK_SECRET_KEY=your_secret_key_here FLASK_SECRET_KEY=calendar-display-secret-key-change-this
# OpenWeatherMap API # OpenWeatherMap API
OPENWEATHER_API_KEY=your_api_key_here # Get your free API key from: https://openweathermap.org/api
OPENWEATHER_API_KEY=your_openweather_api_key_here
# Weather Location (Hamilton, New Zealand) # Weather Location
WEATHER_LOCATION=Hamilton,NZ # Find coordinates at: https://www.latlong.net/
WEATHER_LAT=-37.7870 WEATHER_LOCATION=YourCity,CountryCode
WEATHER_LON=175.2793 WEATHER_LAT=0.0000
WEATHER_LON=0.0000
# Google Calendar # Google Calendar
# Option 1: Use public iCal URL (easiest - no authentication needed)
# Get from: Google Calendar Settings > Calendar > Integrate Calendar > Public URL to this calendar (iCal format)
GOOGLE_CALENDAR_ID=your_calendar_id@group.calendar.google.com GOOGLE_CALENDAR_ID=your_calendar_id@group.calendar.google.com
GOOGLE_CALENDAR_ICAL_URL=https://calendar.google.com/calendar/ical/your_calendar_id%40group.calendar.google.com/public/basic.ics
# Update Intervals (seconds) # Update Intervals (seconds)
IMAGE_ROTATION_INTERVAL=300 IMAGE_ROTATION_INTERVAL=60
WEATHER_UPDATE_INTERVAL=900 WEATHER_UPDATE_INTERVAL=900
CALENDAR_UPDATE_INTERVAL=300 CALENDAR_UPDATE_INTERVAL=300
JOKE_UPDATE_INTERVAL=3600 JOKE_UPDATE_INTERVAL=3600
CALENDAR_DAYS_AHEAD=7 CALENDAR_DAYS_AHEAD=5
+5
View File
@@ -43,3 +43,8 @@ Thumbs.db
# Logs # Logs
*.log *.log
# Background Images (users should add their own)
static/backgrounds/*
!static/backgrounds/README.md
!static/backgrounds/.gitkeep
+121 -48
View File
@@ -1,16 +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 for Hamilton, New Zealand (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** - Family calendar events display
- 🖼️ **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.
- 😄 **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
@@ -55,26 +72,53 @@ GOOGLE_CALENDAR_ID=your_calendar_id@group.calendar.google.com
FLASK_SECRET_KEY=your_random_secret_key FLASK_SECRET_KEY=your_random_secret_key
``` ```
### 4. Set Up Google Calendar API ### 4. Get OpenWeatherMap API Key
1. Go to [OpenWeatherMap](https://openweathermap.org/api)
2. Sign up for a free account
3. Navigate to "API keys" section
4. Copy your API key
5. Add it to your `.env` file as `OPENWEATHER_API_KEY`
**Find Your Location Coordinates:**
1. Go to [LatLong.net](https://www.latlong.net/)
2. Search for your city
3. Copy the latitude and longitude values
4. Add them to your `.env` file as `WEATHER_LAT` and `WEATHER_LON`
### 5. Set Up Google Calendar
**Option 1: Public iCal Feed (Easiest - No Authentication)**
1. Open [Google Calendar](https://calendar.google.com/)
2. Go to Settings (⚙️ gear icon)
3. Select the calendar you want to display
4. Scroll down to "Integrate calendar"
5. Copy the **"Public URL to this calendar"** in iCal format
- The URL looks like: `https://calendar.google.com/calendar/ical/...@group.calendar.google.com/public/basic.ics`
6. Add this URL to your `.env` file as `GOOGLE_CALENDAR_ICAL_URL`
7. Also copy the Calendar ID (looks like `abc123...@group.calendar.google.com`)
8. Add it to your `.env` file as `GOOGLE_CALENDAR_ID`
**Note:** Your calendar must be set to "Public" for this method to work.
**Option 2: Google Calendar API with Credentials (Advanced)**
If you prefer to use private calendars with authentication:
1. Go to [Google Cloud Console](https://console.cloud.google.com/) 1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new project or select existing one 2. Create a new project or select existing one
3. Enable the Google Calendar API 3. Enable the Google Calendar API
4. Create credentials (OAuth 2.0 or Service Account) 4. Create credentials (Service Account)
5. Download the credentials JSON file 5. Download the credentials JSON file
6. Save it as [credentials/google_calendar_credentials.json](credentials/google_calendar_credentials.json) 6. Save it as `credentials/google_calendar_credentials.json`
7. Share your calendar with the service account email
### 5. Add Background Images ### 6. Set Up Family Member Colors (optional)
Place your images in the [static/backgrounds/](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 ### 7. Run the Application
cp /path/to/your/images/*.jpg static/backgrounds/
```
Supported formats: JPG, PNG, GIF, WebP
### 6. Run the Application
```bash ```bash
python app.py python app.py
@@ -120,27 +164,21 @@ sudo systemctl start calendar-display.service
### Configure Chromium Kiosk Mode ### Configure Chromium Kiosk Mode
Edit the autostart file: The Pi uses **labwc** (Wayland). Create the autostart entry:
```bash ```bash
mkdir -p ~/.config/lxsession/LXDE-pi mkdir -p ~/.config/autostart
nano ~/.config/lxsession/LXDE-pi/autostart nano ~/.config/autostart/chromium-kiosk.desktop
``` ```
Add these lines: Add the following:
```bash ```ini
@xset s off [Desktop Entry]
@xset -dpms Type=Application
@xset s noblank Name=Chromium Kiosk
@chromium-browser --kiosk --noerrdialogs --disable-infobars --disable-session-crashed-bubble http://localhost:5000 Exec=chromium-browser --ozone-platform=wayland --kiosk --noerrdialogs --incognito --disable-infobars --simulate-touch-screen --force-show-cursor http://localhost:5002
``` X-GNOME-Autostart-enabled=true
Disable screensaver and cursor (optional):
```bash
sudo apt-get install unclutter
echo "@unclutter -idle 0" >> ~/.config/lxsession/LXDE-pi/autostart
``` ```
### Reboot ### Reboot
@@ -151,15 +189,51 @@ sudo reboot
The display should now start automatically on boot! The display should now start automatically on boot!
### Restarting Without Rebooting
A helper script is included to restart both the Flask service and the Chromium kiosk without rebooting:
```bash
bash ~/restart-calendar.sh
```
This will:
1. Restart the `calendar-display` systemd service
2. Kill and relaunch Chromium in the correct Wayland session
## Configuration ## Configuration
Edit [.env](.env) to customize: ### Environment Variables (`.env` file)
- `IMAGE_ROTATION_INTERVAL` - Seconds between background changes (default: 300) **Weather Settings:**
- `WEATHER_UPDATE_INTERVAL` - Seconds between weather updates (default: 900) - `OPENWEATHER_API_KEY` - Your OpenWeatherMap API key
- `CALENDAR_UPDATE_INTERVAL` - Seconds between calendar updates (default: 300) - `WEATHER_LOCATION` - City name and country code (e.g., "London,UK")
- `JOKE_UPDATE_INTERVAL` - Seconds between joke updates (default: 3600) - `WEATHER_LAT` - Latitude of your location
- `CALENDAR_DAYS_AHEAD` - Number of days ahead to show events (default: 7) - `WEATHER_LON` - Longitude of your location
**Calendar Settings:**
- `GOOGLE_CALENDAR_ID` - Your calendar ID
- `GOOGLE_CALENDAR_ICAL_URL` - Public iCal feed URL (easiest method)
- `CALENDAR_DAYS_AHEAD` - Used only by the legacy events fetch; the week view always fetches two weeks at a time
**Update Intervals (in seconds):**
- `WEATHER_UPDATE_INTERVAL` - Weather refresh interval (default: 900)
- `CALENDAR_UPDATE_INTERVAL` - Calendar refresh interval (default: 300)
**Other:**
- `FLASK_SECRET_KEY` - Flask session secret key
### JavaScript Configuration (`static/js/app.js`)
For more precise control over update intervals, edit the `INTERVALS` object:
```javascript
const INTERVALS = {
TIME: 1000, // 1 second
WEATHER: 900000, // 15 minutes
CALENDAR: 300000 // 5 minutes
};
```
## Project Structure ## Project Structure
@@ -184,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
@@ -195,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!
+148 -41
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,14 +14,15 @@ 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}
@app.route('/') @app.route('/')
def index(): def index():
"""Render the main display page.""" js_path = os.path.join(app.static_folder, 'js', 'app.js')
return render_template('index.html') cache_bust = int(os.path.getmtime(js_path))
return render_template('index.html', cache_bust=cache_bust)
@app.route('/api/weather') @app.route('/api/weather')
@@ -53,21 +55,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 +106,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 +199,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 +220,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 +338,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'}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

+48
View File
@@ -0,0 +1,48 @@
# Background Images
This directory is where you place your background images for the calendar display.
## Adding Images
1. Copy your image files to this directory:
```bash
cp /path/to/your/images/* static/backgrounds/
```
2. Supported formats:
- JPEG/JPG
- PNG
- GIF
- WebP
3. Recommended image specifications:
- Resolution: 1920x1080 or higher
- Aspect ratio: 16:9 (for full-screen displays)
- File size: Keep under 5MB for faster loading
## Image Rotation
Images automatically rotate at the interval specified in your `.env` file.
To change the rotation interval, edit the `.env` file:
```bash
# Time in seconds (default: 60 seconds = 1 minute)
IMAGE_ROTATION_INTERVAL=60
```
Or directly in `static/js/app.js`:
```javascript
const INTERVALS = {
BACKGROUND: 60000, // Milliseconds (60000 = 1 minute)
...
};
```
## Tips
- Use high-quality images for best display
- The app will randomly select images from this directory
- Delete or move images you no longer want displayed
- Images are cached by the browser, so you may need to hard-refresh (Ctrl+F5) to see new images immediately
Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

+284 -326
View File
@@ -1,393 +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; 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;
} }
/* Background */ .app {
#background { height: 100vh;
position: fixed; display: flex;
top: 0; flex-direction: column;
left: 0; padding: 14px 20px 20px;
width: 100%; }
height: 100%;
background-size: cover; /* ---------- Top bar ---------- */
background-position: center;
.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; font-weight: 600;
top: 0; color: #4A4E5A;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.1);
z-index: 1;
} }
/* Main container */ /* ---------- Topbar right-side items removed — now live in info panel ---------- */
.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 { /* ---------- Week grid ---------- */
display: flex;
flex-direction: column;
}
.time { .week-grid {
font-size: 3rem;
font-weight: 300;
line-height: 1;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
}
.date {
font-size: 1.1rem;
font-weight: 300;
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 {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
}
.weather-label {
font-size: 0.85rem;
opacity: 0.8;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.current-temp {
font-size: 2rem;
font-weight: 300;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
}
.weather-icon {
font-size: 1.8rem;
}
.header-forecast {
display: flex;
gap: 1rem;
}
.forecast-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
}
.forecast-icon {
font-size: 1.5rem;
}
.forecast-temps {
display: flex;
gap: 0.3rem;
font-size: 0.9rem;
}
.forecast-temps .high {
font-weight: 500;
}
.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: 1.3rem;
font-weight: 400;
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: 1rem; font-size: 13px;
font-weight: 500; 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: 0.85rem; font-size: 26px;
opacity: 0.8; font-weight: 700;
margin-top: 0.2rem; color: #1F2430;
}
.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: 0.85rem; border-radius: 8px;
opacity: 0.5; padding: 8px 10px;
font-style: italic; border-left: 5px solid transparent;
padding: 0.5rem; font-size: 16px;
text-align: center; line-height: 1.35;
}
.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: 0.75rem; font-weight: 700;
font-weight: 500; color: rgba(0, 0, 0, 0.5);
margin-bottom: 0.2rem; font-size: 15px;
color: #4a9eff;
} }
.event-title { .event-title {
font-size: 0.9rem; font-weight: 700;
margin-bottom: 0.2rem; color: #1A1A2A;
line-height: 1.2; overflow-wrap: anywhere;
} }
.event-location { .event-member {
font-size: 0.85rem; font-size: 14px;
opacity: 0.7; font-weight: 600;
font-style: italic; color: rgba(0, 0, 0, 0.45);
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: 0.9rem; opacity: 1;
font-style: italic; transform: translateY(0);
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: 4rem; left: 50%;
} transform: translateX(-50%);
color: rgba(255, 255, 255, 0.35);
.date { font-size: 13px;
font-size: 1.5rem; letter-spacing: 0.04em;
} pointer-events: none;
white-space: nowrap;
.current-temp {
font-size: 3rem;
}
section h2 {
font-size: 1.6rem;
}
} }
@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: 3rem; background: #E3E5EA;
} border-radius: 4px;
.current-temp {
font-size: 2.5rem;
}
} }
+286 -218
View File
@@ -1,256 +1,324 @@
// 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: 60000, // 1 minute
JOKE: 3600000 // 1 hour
}; };
// Weather icon mapping (OpenWeatherMap icons to emoji or text) const INACTIVITY_TIMEOUT = 20 * 60 * 1000; // 20 minutes
const WEATHER_ICONS = { const SLIDE_DURATION = 15 * 1000; // 15 seconds per photo
'01d': '☀️', '01n': '🌙',
'02d': '⛅', '02n': '☁️',
'03d': '☁️', '03n': '☁️',
'04d': '☁️', '04n': '☁️',
'09d': '🌧️', '09n': '🌧️',
'10d': '🌦️', '10n': '🌧️',
'11d': '⛈️', '11n': '⛈️',
'13d': '❄️', '13n': '❄️',
'50d': '🌫️', '50n': '🌫️'
};
// Initialize the application const DAY_LABELS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
document.addEventListener('DOMContentLoaded', () => { const MONTH_LABELS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
// Start all update functions
updateTime();
updateWeather();
updateCalendar();
updateBackground();
updateJoke();
// Set up intervals let activeFilter = 'all';
setInterval(updateTime, INTERVALS.TIME); let filterOptionsBuilt = false;
setInterval(updateWeather, INTERVALS.WEATHER); let lastData = null;
setInterval(updateCalendar, INTERVALS.CALENDAR); let lastWeatherData = null;
setInterval(updateBackground, INTERVALS.BACKGROUND);
setInterval(updateJoke, INTERVALS.JOKE);
});
// Update time and date // Slideshow state
function updateTime() { let photos = [];
const now = new Date(); let slideIndex = 0;
let activeSlot = 'a';
let slideTimer = null;
let inactivityTimer = null;
let slideshowActive = false;
// Format time (HH:MM) function weatherIcon(code) {
return `<img src="https://openweathermap.org/img/wn/${code}@2x.png" 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}`;
}
document.addEventListener('DOMContentLoaded', () => {
updateTime();
updateWeather();
fetchCalendar();
initSlideshow();
document.getElementById('member-filter').addEventListener('change', (e) => {
activeFilter = e.target.value;
if (lastData) renderDays(lastData);
});
document.getElementById('add-btn').addEventListener('click', showAddToast);
setInterval(updateTime, INTERVALS.TIME);
setInterval(updateWeather, INTERVALS.WEATHER);
setInterval(fetchCalendar, INTERVALS.CALENDAR);
});
function updateTime() {
const now = new Date();
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').textContent = WEATHER_ICONS[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">${WEATHER_ICONS[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>';
} }
} }
// Update background image function buildFilterOptions(members) {
async function updateBackground() { if (filterOptionsBuilt || !members) return;
const select = document.getElementById('member-filter');
members.forEach(member => {
const option = document.createElement('option');
option.value = member.key;
option.textContent = member.name;
select.appendChild(option);
});
filterOptionsBuilt = true;
}
function filterEvents(events) {
if (activeFilter === 'all') return events;
return events.filter(e => e.member === activeFilter);
}
function formatEventTime(event) {
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' });
}
function eventCardHTML(event) {
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>
`;
}
// Grid positions for 6 days arranged 3-per-row, info panel in col 4 spanning both rows
const DAY_GRID_POSITIONS = [
{col: 1, row: 1}, {col: 2, row: 1}, {col: 3, row: 1},
{col: 1, row: 2}, {col: 2, row: 2}, {col: 3, row: 2},
];
function renderDays(data) {
const grid = document.getElementById('week-grid');
grid.innerHTML = '';
const todayISO = toISODate(new Date());
const now = new Date();
data.days.forEach((day, i) => {
const dayDate = new Date(day.date + 'T00:00:00');
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 { try {
const response = await fetch('/api/background'); const res = await fetch('/api/photos');
if (!response.ok) throw new Error('Background fetch failed'); photos = await res.json();
} catch (e) {
const data = await response.json(); console.error('Could not load photos:', e);
const backgroundElement = document.getElementById('background');
if (data.image) {
// Preload image to avoid flicker
const img = new Image();
img.onload = () => {
backgroundElement.style.backgroundImage = `url('${data.image}')`;
};
img.src = data.image;
} else if (data.color) {
backgroundElement.style.backgroundColor = data.color;
backgroundElement.style.backgroundImage = 'none';
}
} catch (error) {
console.error('Error updating background:', error);
} }
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]);
slideTimer = setInterval(advanceSlide, SLIDE_DURATION);
} }
// Fetch and update dad joke function hideSlideshow() {
async function updateJoke() { slideshowActive = false;
try { clearInterval(slideTimer);
const response = await fetch('/api/joke'); slideTimer = null;
if (!response.ok) throw new Error('Joke fetch failed');
const data = await response.json(); const overlay = document.getElementById('slideshow-overlay');
overlay.classList.add('hidden');
if (data.joke) { const a = document.getElementById('slide-a');
document.getElementById('joke').textContent = data.joke; const b = document.getElementById('slide-b');
} a.onload = null; b.onload = null;
a.classList.remove('active'); b.classList.remove('active');
a.removeAttribute('src'); b.removeAttribute('src');
} catch (error) { resetInactivityTimer();
console.error('Error updating joke:', error); }
}
function loadSlide(slot, url) {
const img = document.getElementById(`slide-${slot}`);
const show = () => { img.onload = null; img.classList.add('active'); };
img.onload = show;
img.src = url;
if (img.complete && img.naturalWidth > 0) show();
}
function advanceSlide() {
const nextSlot = activeSlot === 'a' ? 'b' : 'a';
const current = document.getElementById(`slide-${activeSlot}`);
const next = document.getElementById(`slide-${nextSlot}`);
slideIndex = (slideIndex + 1) % photos.length;
const swap = () => {
next.onload = null;
next.classList.add('active');
current.classList.remove('active');
activeSlot = nextSlot;
};
next.onload = swap;
next.src = photos[slideIndex];
if (next.complete && next.naturalWidth > 0) swap();
} }
+22 -37
View File
@@ -3,50 +3,35 @@
<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 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> <!-- 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>
<script src="{{ url_for('static', filename='js/app.js') }}?v={{ cache_bust }}"></script>
</body> </body>
</html> </html>