calendar: consume the scout-calendar feed instead of local events.json

Events now come from EVENTS_FEED_URL (the scout-calendar container,
which serves Radicale mike/site73 as JSON). The last good copy is
cached to /data/events-cache.json and served whenever the feed is down
or empty; 'Nothing on the books' remains the cold-start floor only.
The round-trip test proved the feed reproduces all 32 rows of
app/events.json byte-identically, so nothing visible changes today.

Removes app/events.json, the first-boot seed copy, and the local load
path; README calendar section rewritten; compose gains EVENTS_FEED_URL
from the Portainer stack env.
This commit is contained in:
Claude
2026-08-29 11:33:43 -04:00
parent 7d2c241126
commit 27f1a2104b
4 changed files with 58 additions and 253 deletions
+7 -10
View File
@@ -6,16 +6,16 @@ Replaces the `scoutpoc` proof of concept (claude-workspace/stacks/scoutpoc).
## Updating the calendar (the part you'll actually do) ## Updating the calendar (the part you'll actually do)
All dates on the site (home "Next up," both program pages, the Calendar page) render from **one JSON file**, read fresh on every request: All dates on the site (home "Next up," both program pages, the Calendar page) come from the **scout-calendar feed** (claude-workspace/stacks/scout-calendar), which serves the Radicale collection `mike/site73` as JSON:
``` ```
/srv/scout-website/data/events.json ← the LIVE file. Edit this. EVENTS_FEED_URL ← Portainer stack env; http://scout-calendar:8000/events.json
app/events.json ← seed only; copied to /data on first boot. /data/events-cache.json ← last good copy, served automatically whenever the feed is down
``` ```
Edit the live file on the host (or ask Claude), save, refresh the site. **No rebuild, no redeploy.** Edit the calendar in any CalDAV client pointed at Radicale (or ask Claude), and the site follows within ~5 minutes. **No file edits, no rebuild, no redeploy.** The public ICS families can subscribe to is https://calendar.greenlanescouts73.org/calendar.ics.
Event format (one object per event): Event fields the feed emits (one object per event):
```json ```json
{"date": "2026-10-17", "end": "2026-10-18", "time": "from 4:00 PM", {"date": "2026-10-17", "end": "2026-10-18", "time": "from 4:00 PM",
@@ -26,15 +26,12 @@ Event format (one object per event):
- `date` required (YYYY-MM-DD). `end` only for multi-day events. - `date` required (YYYY-MM-DD). `end` only for multi-day events.
- `unit`: `"pack"`, `"troop"`, or `"both"` (default pack). Drives the Pack/Troop calendar filter and each page's "Next up." - `unit`: `"pack"`, `"troop"`, or `"both"` (default pack). Drives the Pack/Troop calendar filter and each page's "Next up."
- `badge`: optional shouty label (`FALL OPEN HOUSE`, `OVERNIGHT`, `THE BIG ONE`). - `badge`: optional shouty label (`FALL OPEN HOUSE`, `OVERNIGHT`, `THE BIG ONE`).
- Past events are hidden from "Next up" automatically; keep or prune them in the file, your call. - Past events are hidden from "Next up" automatically; the Calendar page shows the whole program year.
- TeamSnap (team 8198615) stays the source of truth; this file is the public snapshot. - TeamSnap (team 8198615) stays the source of truth for registered families; Radicale `mike/site73` is the source for this public site.
To change the **seed** for future rebuilds too, edit `app/events.json` here in Gitea and redeploy the stack.
## Layout ## Layout
- `app/app.py` — FastAPI app, all five pages server-rendered (Home, /cubs, /troop, /calendar?unit=, /join). `/contact` 301s to `/join`. - `app/app.py` — FastAPI app, all five pages server-rendered (Home, /cubs, /troop, /calendar?unit=, /join). `/contact` 301s to `/join`.
- `app/events.json` — calendar seed (see above).
- `docker-compose.yml` — Portainer Repository stack definition (port 8131). - `docker-compose.yml` — Portainer Repository stack definition (port 8131).
- Images: **not in the repo.** Bind-mounted read-only from `/srv/scout-website-assets/img`. Source photos live on the NAS (`Scouting-Recruitment-Images`, `scouting-comms/pack-73/assets/social`); web-sized copies in `Scouting-Recruitment-Images/_web`. - Images: **not in the repo.** Bind-mounted read-only from `/srv/scout-website-assets/img`. Source photos live on the NAS (`Scouting-Recruitment-Images`, `scouting-comms/pack-73/assets/social`); web-sized copies in `Scouting-Recruitment-Images/_web`.
+48 -15
View File
@@ -1,4 +1,4 @@
import json, os, shutil, datetime, urllib.request, urllib.parse import json, os, time, datetime, urllib.request, urllib.parse
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, Form from fastapi import FastAPI, Form
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
@@ -11,15 +11,16 @@ import admin_api
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Pack & Troop 73 - greenlanescouts73.org # Pack & Troop 73 - greenlanescouts73.org
# Calendar lives in ONE file: /data/events.json (seeded from app/events.json on # Calendar comes from the scout-calendar JSON feed (EVENTS_FEED_URL) -
# first boot). Edit that file on the host (or ask Claude) and every page that # Radicale is the source of truth, edited in any calendar app. The last good
# shows dates updates on the next refresh. No rebuild needed. # copy is cached to /data/events-cache.json and served whenever the feed is
# down, so a feed outage never blanks the site.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
DATA = Path("/data"); DATA.mkdir(parents=True, exist_ok=True) DATA = Path("/data"); DATA.mkdir(parents=True, exist_ok=True)
LEADS = DATA / "leads.jsonl" LEADS = DATA / "leads.jsonl"
EVENTS_LIVE = DATA / "events.json" EVENTS_FEED_URL = os.environ.get("EVENTS_FEED_URL", "").strip()
EVENTS_SEED = Path(__file__).parent / "events.json" EVENTS_CACHE = DATA / "events-cache.json"
NTFY_URL = os.environ.get("NTFY_URL", "") NTFY_URL = os.environ.get("NTFY_URL", "")
# Lead pipeline lives IN THIS REPO (no dependency on the scouting73-form # Lead pipeline lives IN THIS REPO (no dependency on the scouting73-form
# container): direct Google Sheet append + ntfy push, same sheet and topic. # container): direct Google Sheet append + ntfy push, same sheet and topic.
@@ -38,23 +39,55 @@ app.mount("/static", StaticFiles(directory="/app/static"), name="static")
store.init(backfill_jsonl=LEADS) store.init(backfill_jsonl=LEADS)
app.include_router(admin_api.router) app.include_router(admin_api.router)
if not EVENTS_LIVE.exists() and EVENTS_SEED.exists():
try: shutil.copyfile(EVENTS_SEED, EVENTS_LIVE)
except Exception: pass
MB = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'] MB = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC']
MF = ['January','February','March','April','May','June','July','August','September','October','November','December'] MF = ['January','February','March','April','May','June','July','August','September','October','November','December']
DW = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'] DW = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']
DWU = ['MON','TUE','WED','THU','FRI','SAT','SUN'] DWU = ['MON','TUE','WED','THU','FRI','SAT','SUN']
_FEED_TTL = 300 # seconds between feed fetches; a failure also waits this long
_feed_state = {"events": None, "fetched": 0.0}
def _fetch_feed():
rq = urllib.request.Request(EVENTS_FEED_URL, headers={"User-Agent": "scout-website"})
with urllib.request.urlopen(rq, timeout=5) as resp:
events = json.load(resp).get("events", [])
if not isinstance(events, list) or not events:
raise ValueError("feed returned no events")
return events
def load_events(): def load_events():
for p in (EVENTS_LIVE, EVENTS_SEED): """Events come from the scout-calendar feed. The last good copy is cached
to /data and served whenever the feed is unreachable or empty - the pages
never go blank because the feed had a bad moment. With no feed and no
cache yet, pages fall back to their 'Nothing on the books' empty state."""
now = time.time()
st = _feed_state
if st["events"] is not None and now - st["fetched"] < _FEED_TTL:
return st["events"]
if EVENTS_FEED_URL:
try: try:
with open(p, encoding="utf-8") as f: events = _fetch_feed()
return json.load(f).get("events", []) st.update(events=events, fetched=now)
try:
tmp = EVENTS_CACHE.with_suffix(".tmp")
tmp.write_text(json.dumps({"events": events}), encoding="utf-8")
tmp.replace(EVENTS_CACHE)
except Exception: except Exception:
continue pass
return [] return events
except Exception:
pass
if st["events"] is not None: # feed down: keep serving stale, retry after TTL
st["fetched"] = now
return st["events"]
try:
with open(EVENTS_CACHE, encoding="utf-8") as f:
events = json.load(f).get("events", [])
except Exception:
events = []
if events:
st.update(events=events, fetched=now)
return events
def _dates(ev): def _dates(ev):
s = datetime.date.fromisoformat(ev["date"]) s = datetime.date.fromisoformat(ev["date"])
-226
View File
@@ -1,226 +0,0 @@
{
"updated": "2026-08-24",
"note": "One event per object. Home shows the next 3 automatically; Calendar groups by month with Pack/Troop filters. Fields: date (YYYY-MM-DD), end (optional), time (optional), title, location (optional), unit ('pack' | 'troop' | 'both'; default pack), badge (optional short label).",
"events": [
{
"date": "2026-08-29",
"time": "1:00 PM",
"title": "Pack Hike",
"location": "Plank Road City Park",
"unit": "pack"
},
{
"date": "2026-09-01",
"time": "3:00 PM",
"title": "Canoe Day",
"unit": "troop"
},
{
"date": "2026-09-01",
"time": "6:30 PM",
"title": "Kickoff Party",
"location": "Green Lane Park, Morrow Pavilion",
"unit": "both"
},
{
"date": "2026-09-12",
"time": "10:00 AM",
"title": "Lower Frederick Community Day",
"location": "PV Middle School West",
"unit": "both"
},
{
"date": "2026-09-19",
"time": "7:00 AM",
"title": "Farmers Market chocolate booth",
"location": "Green Lane Park",
"unit": "pack"
},
{
"date": "2026-09-20",
"time": "1:00 PM",
"title": "Pack Hike",
"location": "Perkiomen Trail, Foy Park",
"unit": "pack"
},
{
"date": "2026-09-22",
"time": "6:00 PM",
"title": "Boxcar Derby",
"location": "St. Luke's",
"unit": "pack",
"badge": "FALL OPEN HOUSE"
},
{
"date": "2026-09-26",
"time": "10:30 AM",
"title": "Upper Frederick Community Day",
"location": "Municipal Building",
"unit": "both"
},
{
"date": "2026-10-02",
"time": "6:00 PM",
"title": "Parent & Scout Movie Night",
"location": "St. Luke's",
"unit": "pack"
},
{
"date": "2026-10-10",
"title": "Haunted Hayride (scouts performing)",
"location": "Green Lane Park",
"unit": "pack"
},
{
"date": "2026-10-17",
"time": "7:00 AM",
"title": "Farmers Market chocolate booth",
"location": "Green Lane Park",
"unit": "pack"
},
{
"date": "2026-10-17",
"end": "2026-10-18",
"time": "from 4:00 PM",
"title": "Fall Campout",
"location": "Green Lane Park",
"unit": "both",
"badge": "OVERNIGHT"
},
{
"date": "2026-10-27",
"time": "6:00 PM",
"title": "Trunk or Treat pack meeting",
"location": "St. Luke's",
"unit": "pack"
},
{
"date": "2026-11-07",
"time": "1:00 PM",
"title": "Pack Hike",
"location": "Schwenksville",
"unit": "pack"
},
{
"date": "2026-11-21",
"time": "3:00 PM",
"title": "Gettysburg Trip",
"unit": "troop"
},
{
"date": "2026-12-05",
"time": "1:00 PM",
"title": "Pack Hike",
"location": "Perkiomen Trail, Crusher Road",
"unit": "pack"
},
{
"date": "2026-12-11",
"time": "6:00 PM",
"title": "Parent & Scout Movie Night",
"location": "St. Luke's",
"unit": "pack"
},
{
"date": "2026-12-12",
"end": "2026-12-13",
"title": "Lock In",
"unit": "troop"
},
{
"date": "2027-01-16",
"title": "Pack Mario Party",
"location": "St. Luke's",
"unit": "pack"
},
{
"date": "2027-01-16",
"end": "2027-01-17",
"title": "Cabin Camping",
"location": "French Creek State Park",
"unit": "troop"
},
{
"date": "2027-01-23",
"time": "1:00 PM",
"title": "Pack Hike",
"location": "Red Trail, Pennsburg",
"unit": "pack"
},
{
"date": "2027-02-06",
"title": "Blue & Gold banquet",
"location": "St. Luke's",
"unit": "pack"
},
{
"date": "2027-02-20",
"title": "Pinewood Derby",
"location": "St. Luke's",
"unit": "pack",
"badge": "THE BIG ONE"
},
{
"date": "2027-02-27",
"time": "1:00 PM",
"title": "Pack Hike",
"location": "East Greenville",
"unit": "pack"
},
{
"date": "2027-03-13",
"time": "1:00 PM",
"title": "Pack Hike",
"location": "Black Rock Rd, Royersford",
"unit": "pack"
},
{
"date": "2027-03-19",
"time": "6:00 PM",
"title": "Parent & Scout Movie Night",
"location": "St. Luke's",
"unit": "pack"
},
{
"date": "2027-04-10",
"title": "Pack Spring Campout",
"location": "Camp Garrison",
"unit": "pack",
"badge": "OVERNIGHT"
},
{
"date": "2027-04-24",
"title": "Arrow of Light Crossover & Awards",
"location": "St. Luke's",
"unit": "both"
},
{
"date": "2027-05-08",
"time": "1:00 PM",
"title": "Pack Bike Ride",
"location": "Swamp Creek Park",
"unit": "pack"
},
{
"date": "2027-05-21",
"time": "6:00 PM",
"title": "Parent & Scout Movie Night",
"location": "St. Luke's",
"unit": "pack"
},
{
"date": "2027-05-25",
"time": "6:00 PM",
"title": "Kickball Party",
"location": "St. Luke's",
"unit": "pack"
},
{
"date": "2027-05-29",
"time": "1:00 PM",
"title": "Pack Hike",
"location": "Perkiomenville",
"unit": "pack"
}
]
}
+3 -2
View File
@@ -6,8 +6,8 @@
# - no env_file: NTFY_URL comes from the Portainer stack env # - no env_file: NTFY_URL comes from the Portainer stack env
# Images live OUTSIDE the repo at /srv/scout-website-assets/img (bind-mounted # Images live OUTSIDE the repo at /srv/scout-website-assets/img (bind-mounted
# read-only) so photos never bloat clones. # read-only) so photos never bloat clones.
# The live, hand-editable calendar is /srv/scout-website/data/events.json # The calendar comes from the scout-calendar feed (EVENTS_FEED_URL, set in
# (seeded from app/events.json on first boot; see README). # the Portainer stack env); /data/events-cache.json is the stale fallback.
name: scout-website name: scout-website
services: services:
@@ -25,6 +25,7 @@ services:
- NTFY_TOPIC=${NTFY_TOPIC} - NTFY_TOPIC=${NTFY_TOPIC}
- NTFY_TOKEN=${NTFY_TOKEN} - NTFY_TOKEN=${NTFY_TOKEN}
- ADMIN_TOKEN=${ADMIN_TOKEN} - ADMIN_TOKEN=${ADMIN_TOKEN}
- EVENTS_FEED_URL=${EVENTS_FEED_URL}
volumes: volumes:
- /srv/scout-website/data:/data - /srv/scout-website/data:/data
- /srv/scout-website/secrets/service_account.json:/app/service_account.json:ro - /srv/scout-website/secrets/service_account.json:/app/service_account.json:ro