diff --git a/README.md b/README.md index 79b3cba..c4c5dbc 100644 --- a/README.md +++ b/README.md @@ -6,16 +6,16 @@ Replaces the `scoutpoc` proof of concept (claude-workspace/stacks/scoutpoc). ## 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. -app/events.json ← seed only; copied to /data on first boot. +EVENTS_FEED_URL ← Portainer stack env; http://scout-calendar:8000/events.json +/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 {"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. - `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`). -- Past events are hidden from "Next up" automatically; keep or prune them in the file, your call. -- TeamSnap (team 8198615) stays the source of truth; this file is the public snapshot. - -To change the **seed** for future rebuilds too, edit `app/events.json` here in Gitea and redeploy the stack. +- Past events are hidden from "Next up" automatically; the Calendar page shows the whole program year. +- TeamSnap (team 8198615) stays the source of truth for registered families; Radicale `mike/site73` is the source for this public site. ## Layout - `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). - 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`. diff --git a/app/app.py b/app/app.py index b4fcb96..5e22762 100644 --- a/app/app.py +++ b/app/app.py @@ -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 fastapi import FastAPI, Form from fastapi.responses import HTMLResponse, RedirectResponse @@ -11,15 +11,16 @@ import admin_api # --------------------------------------------------------------------------- # Pack & Troop 73 - greenlanescouts73.org -# Calendar lives in ONE file: /data/events.json (seeded from app/events.json on -# first boot). Edit that file on the host (or ask Claude) and every page that -# shows dates updates on the next refresh. No rebuild needed. +# Calendar comes from the scout-calendar JSON feed (EVENTS_FEED_URL) - +# Radicale is the source of truth, edited in any calendar app. The last good +# 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) LEADS = DATA / "leads.jsonl" -EVENTS_LIVE = DATA / "events.json" -EVENTS_SEED = Path(__file__).parent / "events.json" +EVENTS_FEED_URL = os.environ.get("EVENTS_FEED_URL", "").strip() +EVENTS_CACHE = DATA / "events-cache.json" NTFY_URL = os.environ.get("NTFY_URL", "") # Lead pipeline lives IN THIS REPO (no dependency on the scouting73-form # 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) 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'] MF = ['January','February','March','April','May','June','July','August','September','October','November','December'] DW = ['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(): - 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: - with open(p, encoding="utf-8") as f: - return json.load(f).get("events", []) + events = _fetch_feed() + 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: + pass + return events except Exception: - continue - return [] + 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): s = datetime.date.fromisoformat(ev["date"]) diff --git a/app/events.json b/app/events.json deleted file mode 100644 index 5341619..0000000 --- a/app/events.json +++ /dev/null @@ -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" - } - ] -} diff --git a/docker-compose.yml b/docker-compose.yml index 58382e6..10daafc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,8 +6,8 @@ # - no env_file: NTFY_URL comes from the Portainer stack env # Images live OUTSIDE the repo at /srv/scout-website-assets/img (bind-mounted # read-only) so photos never bloat clones. -# The live, hand-editable calendar is /srv/scout-website/data/events.json -# (seeded from app/events.json on first boot; see README). +# The calendar comes from the scout-calendar feed (EVENTS_FEED_URL, set in +# the Portainer stack env); /data/events-cache.json is the stale fallback. name: scout-website services: @@ -25,6 +25,7 @@ services: - NTFY_TOPIC=${NTFY_TOPIC} - NTFY_TOKEN=${NTFY_TOKEN} - ADMIN_TOKEN=${ADMIN_TOKEN} + - EVENTS_FEED_URL=${EVENTS_FEED_URL} volumes: - /srv/scout-website/data:/data - /srv/scout-website/secrets/service_account.json:/app/service_account.json:ro