import json, os, shutil, datetime, urllib.request, urllib.parse from pathlib import Path from fastapi import FastAPI, Form from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles # Internal record store + admin API. The store is the source of truth for # anything a family submits; see store.py for why. import store 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. # --------------------------------------------------------------------------- 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" 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. SHEET_ID = os.environ.get("SHEET_ID", "").strip() SA_FILE = os.environ.get("SA_FILE", "/app/service_account.json") NTFY_BASE = os.environ.get("NTFY_BASE", "").rstrip("/") NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "scouting") NTFY_TOKEN = os.environ.get("NTFY_TOKEN", "").strip() SHEET_HEADERS = ["Timestamp", "Parent Name", "Email", "Phone", "Children (name + fall grade)", "Interested In", "How They Heard", "Comments"] app = FastAPI(title="Pack & Troop 73") app.mount("/static", StaticFiles(directory="/app/static"), name="static") # Creates the schema if absent and backfills the legacy leads.jsonl once. 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'] def load_events(): for p in (EVENTS_LIVE, EVENTS_SEED): try: with open(p, encoding="utf-8") as f: return json.load(f).get("events", []) except Exception: continue return [] def _dates(ev): s = datetime.date.fromisoformat(ev["date"]) e = datetime.date.fromisoformat(ev.get("end", ev["date"])) return s, e def next_up(units, n=3): today = datetime.date.today() out = [] for ev in load_events(): u = ev.get("unit", "pack") if u not in units and u != "both": continue s, e = _dates(ev) if e >= today: out.append((s, ev)) out.sort(key=lambda t: t[0]) return [ev for _, ev in out[:n]] def unit_label(u): return {"troop": "TROOP", "both": "ALL 73"}.get(u, "PACK") def ev_meta(ev, s): return " · ".join(x for x in [DW[s.weekday()], ev.get("time"), ev.get("location")] if x) def next_up_cards(events, show_unit=False): rows = [] for ev in events: s, _ = _dates(ev) chips = "" if show_unit: chips += f'{unit_label(ev.get("unit","pack"))}' if ev.get("badge"): chips += f'{ev["badge"]}' rows.append(f'''
{MB[s.month-1]}
{s.day}
{ev["title"]}{chips}
{ev_meta(ev, s)}
''') return "\n".join(rows) CSS = """ *{box-sizing:border-box}html{scroll-behavior:smooth} body{margin:0;background:#FBF8F1;color:#232838;font-family:'Public Sans',system-ui,sans-serif;line-height:1.6} a{color:#35608F}a:hover{color:#1E2F52}p{text-wrap:pretty} ::selection{background:#F0AD2D;color:#1E2F52} .wrap{max-width:1160px;margin:0 auto;padding:0 24px} .arch{font-family:'Archivo',sans-serif} header.nav{position:sticky;top:0;z-index:60;background:rgba(251,248,241,.94);backdrop-filter:blur(10px);border-bottom:1px solid #E8E1D0} .navrow{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:70px;flex-wrap:wrap} .brand{display:flex;align-items:center;gap:12px;text-decoration:none;padding:10px 0} .brand img{width:48px;height:48px;border-radius:50%;object-fit:cover;display:block;box-shadow:0 1px 4px rgba(16,25,44,.3)} .brand .b1{font-family:'Archivo';font-weight:800;font-size:15px;letter-spacing:.06em;color:#1E2F52;display:block;line-height:1.25} .brand .b2{font-size:11px;color:#6A7280;letter-spacing:.02em;display:block;line-height:1.25} nav.links{display:flex;align-items:center;gap:26px;flex-wrap:wrap} nav.links a{text-decoration:none;font-weight:600;font-size:15px;color:#333D52;padding:6px 0} nav.links a:hover{color:#1E2F52} nav.links a.on{font-weight:700;color:#1E2F52;border-bottom:2px solid #F0AD2D} nav.links a.joinbtn{display:inline-block;line-height:1.2;font-family:'Archivo';font-weight:700;font-size:15px;background:#F0AD2D;color:#1E2F52 !important;padding:11px 22px;border-radius:999px;border-bottom:none !important} nav.links a.joinbtn:hover{background:#DB9714} .hero{position:relative;display:flex;align-items:flex-end;min-height:520px} .hero .wrap{padding-top:110px;padding-bottom:58px;width:100%} .eyebrow{font-family:'Archivo';font-weight:700;font-size:13px;letter-spacing:.2em;text-transform:uppercase;margin-bottom:12px} .eyebrow.gold{color:#F7C556}.eyebrow.dark{color:#B07B12} .hero h1{margin:0 0 16px;font-family:'Archivo';font-weight:800;font-size:clamp(2.4rem,5vw,4rem);line-height:1.05;letter-spacing:-.015em;color:#fff;max-width:15ch} .hero p{margin:0;font-size:clamp(1.05rem,1.7vw,1.2rem);color:rgba(255,255,255,.94);max-width:620px} .cta{display:inline-block;font-family:'Archivo';font-weight:700;font-size:16px;background:#F0AD2D;color:#1E2F52;padding:15px 30px;border-radius:999px;text-decoration:none} .cta:hover{background:#F7C556;color:#1E2F52} .cta.ghost{background:transparent;color:#fff;border:2px solid rgba(255,255,255,.85);padding:13px 28px} .cta.ghost:hover{background:rgba(255,255,255,.14);color:#fff} h2.sec{margin:0 0 14px;font-family:'Archivo';font-weight:800;font-size:clamp(1.7rem,3vw,2.4rem);letter-spacing:-.01em;color:#1E2F52} .lead{margin:0 0 40px;color:#5A6272;font-size:1.08rem;max-width:640px} .facts{background:#fff;border-radius:16px;box-shadow:0 18px 40px rgba(16,25,44,.12);display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:1px;overflow:hidden;border:1px solid #EBE4D4;position:relative;z-index:5;margin-top:-44px} .facts>div{padding:22px 26px;background:#fff} .facts .fl{font-size:11px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:#8B93A3;margin-bottom:4px} .facts .fv{font-family:'Archivo';font-weight:700;font-size:17px;color:#1E2F52} .grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:28px} .grid3{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:18px} .grid4{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:24px} .gridcards{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px} .pcard{text-decoration:none;background:#fff;border:1px solid #EBE4D4;border-radius:18px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 6px 18px rgba(16,25,44,.06);transition:transform .15s;color:inherit} .pcard:hover{transform:translateY(-4px);color:inherit} .pcard img{width:100%;height:270px;object-fit:cover;display:block} .pcard .pad{padding:26px 28px 30px;display:flex;flex-direction:column;gap:10px} .pcard h3{margin:0;font-family:'Archivo';font-weight:800;font-size:1.45rem;color:#1E2F52} .chip{font-size:12px;font-weight:700;letter-spacing:.08em;padding:4px 12px;border-radius:999px;white-space:nowrap} .chip.gold{background:#F3EBD8;color:#7A5B00}.chip.blue{background:#E3E9F2;color:#1E2F52} .pcard p{margin:0;color:#5A6272} .plink{font-family:'Archivo';font-weight:700;color:#35608F} .evcard{display:flex;gap:16px;align-items:center;background:#fff;border:1px solid #EBE4D4;border-radius:14px;padding:16px 18px} .datechip{width:60px;min-width:60px;text-align:center;background:#1E2F52;border-radius:12px;padding:9px 4px} .dc-m{font-size:11px;font-weight:700;letter-spacing:.12em;color:#F7C556} .dc-d{font-family:'Archivo';font-weight:800;font-size:21px;color:#fff;line-height:1.15} .ev-t{font-family:'Archivo';font-weight:700;font-size:16px;color:#1E2F52;display:flex;gap:10px;align-items:center;flex-wrap:wrap} .ev-m{color:#6A7280;font-size:14px} .uchip{font-size:11px;font-weight:700;letter-spacing:.08em;padding:3px 10px;border-radius:999px;white-space:nowrap} .uchip-n{background:#EEF1F6;color:#33415E} .uchip-pack{background:#F3EBD8;color:#7A5B00}.uchip-troop{background:#F6E3DA;color:#8A3B1D}.uchip-both{background:#E3E9F2;color:#1E2F52} .badge{font-size:11px;font-weight:700;letter-spacing:.08em;background:#F0AD2D;color:#1E2F52;padding:3px 10px;border-radius:999px;white-space:nowrap} .noev{background:#fff;border:1px dashed #CBD2DC;border-radius:14px;padding:22px 24px;color:#5A6272;font-size:15px} .steps{display:flex;flex-direction:column;gap:22px} .step{display:flex;gap:18px} .step .n{font-family:'Archivo';font-weight:800;font-size:28px;color:#F0AD2D;min-width:44px} .step .t{font-family:'Archivo';font-weight:700;font-size:17px;color:#1E2F52;margin-bottom:2px} .step .d{color:#5A6272;font-size:15px} .photoband img{width:100%;height:250px;object-fit:cover;border-radius:14px;display:block} .volband{background:#1E2F52;border-radius:20px;overflow:hidden;display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr))} .volband img{width:100%;height:100%;min-height:320px;object-fit:cover;display:block} .volband .pad{padding:46px 44px;display:flex;flex-direction:column;justify-content:center} .volband h2{margin:0 0 14px;font-family:'Archivo';font-weight:800;font-size:clamp(1.7rem,3vw,2.3rem);color:#fff;letter-spacing:-.01em} .volband p{margin:0 0 12px;color:#D8DFEA;font-size:15.5px;line-height:1.7} .vchip{font-size:12.5px;font-weight:700;background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.22);color:#fff;padding:6px 14px;border-radius:999px} .band{position:relative;padding:96px 24px;text-align:center} .band h2{margin:0 0 14px;font-family:'Archivo';font-weight:800;font-size:clamp(1.9rem,3.6vw,2.7rem);color:#fff;letter-spacing:-.01em} .band p{margin:0 auto 28px;color:rgba(255,255,255,.92);font-size:1.08rem;max-width:560px} .rank{border-radius:14px;padding:20px 18px;border:1px solid #EBE4D4} .rank .rl{font-size:11px;font-weight:700;letter-spacing:.12em;margin-bottom:6px;color:#B07B12} .rank .rn{font-family:'Archivo';font-weight:800;font-size:1.15rem;color:#1E2F52;margin-bottom:4px} .rank .rd{font-size:13.5px;color:#6A7280} .rankgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px} .r1{background:linear-gradient(180deg,#fff,#FFFBF2);border-top:5px solid #F7C556} .r2{background:linear-gradient(180deg,#fff,#FEF7E8);border-top:5px solid #F0AD2D} .r3{background:linear-gradient(180deg,#fff,#FDF2DC);border-top:5px solid #E29A25} .r4{background:linear-gradient(180deg,#fff,#FBEDD2);border-top:5px solid #D4842A}.r4 .rl{color:#A86A15} .r5{background:linear-gradient(180deg,#fff,#F9E6D0);border-top:5px solid #C4552D}.r5 .rl{color:#A86A15} .r6{background:linear-gradient(180deg,#fff,#F3DECD);border-top:5px solid #8A3B1D}.r6 .rl{color:#8A3B1D} .r7{background:linear-gradient(160deg,#1E2F52,#2C4470);border:1px solid #1E2F52;border-top:5px solid #F0AD2D} .r7 .rl{color:#F7C556}.r7 .rn{color:#fff}.r7 .rd{color:#C3CBDA} .ycard{background:#fff;border:1px solid #EBE4D4;border-radius:16px;overflow:hidden} .ycard img{width:100%;height:190px;object-fit:cover;display:block} .ycard .pad{padding:20px 22px} .ycard .t{font-family:'Archivo';font-weight:800;font-size:1.1rem;color:#1E2F52;margin-bottom:4px} .ycard .d{font-size:14.5px;color:#5A6272} .infocard{background:#fff;border:1px solid #EBE4D4;border-radius:18px;overflow:hidden;display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));align-items:stretch} .infocard .pad{padding:34px 36px} .infocard img{width:100%;height:100%;min-height:300px;object-fit:cover;display:block} .rrow{display:flex;gap:12px;align-items:baseline} .rrow .k{font-family:'Archivo';font-weight:800;color:#F0AD2D;font-size:1.05rem;min-width:96px} .rrow .v{color:#333D52;font-size:15px} .calhead{background:#1E2F52;padding:72px 0 64px} .calhead h1{margin:0 0 18px;font-family:'Archivo';font-weight:800;font-size:clamp(2.4rem,5vw,4rem);line-height:1.02;letter-spacing:-.015em;color:#fff} .calhead .sub{margin:0 0 34px;color:#C3CBDA;font-size:1.08rem;max-width:640px} .rhythm{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:14px} .rhythm>div{background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.14);border-radius:14px;padding:18px 22px} .rhythm .t{font-family:'Archivo';font-weight:800;color:#F7C556;font-size:1.05rem;margin-bottom:2px} .rhythm .d{color:#D8DFEA;font-size:14px} select.unitsel{font-family:'Archivo';font-weight:700;font-size:15px;color:#1E2F52;background:#fff;border:1.5px solid #CBD2DC;border-radius:12px;padding:12px 18px;cursor:pointer;min-width:240px} .mcard{background:#fff;border:1px solid #EBE4D4;border-radius:16px;padding:26px 28px} .mhead{display:flex;align-items:baseline;justify-content:space-between;border-bottom:2px solid #F0AD2D;padding-bottom:10px;margin-bottom:16px} .mhead .mn{font-family:'Archivo';font-weight:800;font-size:1.35rem;color:#1E2F52} .mhead .my{font-size:13px;font-weight:700;color:#8B93A3} .mrow{display:flex;gap:14px;align-items:baseline} .mrow .ml{font-family:'Archivo';font-weight:800;color:#B07B12;min-width:84px;font-size:14px} .mrow .mt{font-size:15px;color:#232838} .mrow .mm{color:#5A6272} .note{background:#F3EBD8;border:1px solid #E8DCBB;border-radius:14px;padding:18px 24px;color:#6B5510;font-size:14.5px} form.lead{background:#fff;border:1px solid #EBE4D4;border-radius:18px;padding:32px 32px 34px;display:grid;gap:18px;box-shadow:0 14px 34px rgba(16,25,44,.08)} form.lead .ft{font-family:'Archivo';font-weight:800;font-size:1.3rem;color:#1E2F52} .frow{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px} label{display:block;font-weight:600;font-size:14px;margin-bottom:6px;color:#333D52} input,select,textarea{width:100%;padding:12px 13px;border:1px solid #CBD2DC;border-radius:9px;font:inherit;background:#fff} input:focus,select:focus,textarea:focus{outline:2px solid #35608F;border-color:#35608F} textarea{resize:vertical} button.cta{border:none;cursor:pointer} .req{color:#C4552D} .success{background:#E4F2E4;border:1px solid #9CC79C;color:#1E5A2E;border-radius:12px;padding:16px 20px;font-weight:600;margin-bottom:18px} .faq{background:#fff;border:1px solid #EBE4D4;border-radius:16px;padding:24px 26px} .faq .q{font-family:'Archivo';font-weight:800;font-size:1.05rem;color:#1E2F52;margin-bottom:6px} .faq .a{font-size:14.5px;color:#5A6272} footer{background:#1E2F52;color:#C3CBDA;padding:52px 0 40px;margin-top:auto} .footgrid{display:flex;justify-content:space-between;gap:36px;flex-wrap:wrap} footer .fh{font-family:'Archivo';font-weight:700;color:#F7C556;font-size:13px;letter-spacing:.14em;margin-bottom:12px} footer a{color:#D8DFEA;text-decoration:none;font-size:14px} footer a:hover{color:#fff} footer a.gold{color:#F7C556;font-weight:700} footer a.fb{display:inline-flex;align-items:center;gap:8px} """ def page(title, body, active=""): def on(k): return ' class="on"' if active == k else "" return f""" {title} {body} """ def hero(img, pos, eyebrow, h1, sub, extra="", minh=520): return f"""
{eyebrow}

{h1}

{sub}

{extra}
""" @app.get("/health") def health(): return {"status": "ok"} @app.get("/", response_class=HTMLResponse) def home(): up = next_up({"pack", "troop"}, 3) ev_html = next_up_cards(up, show_unit=True) or '
Nothing on the books right now. New dates land on the shared calendar first, so check back soon or message us.
' body = f""" {hero('autumn-hike.jpg', 'center', 'Family Scouting · Zieglerville, PA', 'Where kids find their trail.', 'A Cub Scout pack and a Scout troop for kindergarten through age 17. Camp, build, lead, and grow alongside friends, right here in the Perkiomen Valley.', '
Join us this fallSee the year
', 640)}
Meetings
Tue · Pack 6:00 · Troop 7:30
Where
St. Luke's Lutheran Church
Ages
Kindergarten to 17
Everyone
Every kid welcome · first visit free
Two programs, one community

Start in kindergarten or jump in at fifteen.

Chartered by St. Luke's Lutheran Church and part of the Cradle of Liberty Council, every kid is welcome. No experience needed at any age.

Coming up at 73

Next up at 73.

{ev_html}
This list updates itself from the shared 73 calendar.
See the full 2026-2027 calendar →
New here?

Your first visit, step by step.

01
Say hello
Send the interest form and a real leader (a local parent, not a call center) replies within a day or two.
02
Drop in on a Tuesday
Come watch a meeting, no commitment and nothing to bring. Kids usually join the fun within ten minutes.
03
Take it at your pace
Register when your family is ready. We help with uniforms, costs, and every question in between.
Start with the form →
Scouts building model rockets A Lion Cub Scout working on a craft A scout climbing at the crag
Parents and scouts exploring a trail together
Parents make it go

We run on volunteers.

Every derby track, campfire, and trail snack table is parent-powered. Nobody shows up knowing how; training is provided and the pack will happily teach you the knots.

Help comes in every size: one chocolate booth shift, planning a hike, running a derby heat, or leading a den. The more hands we have, the better the year gets for every kid.

One-shift helpersHike plannersDerby pit crewDen leaders
Raise your hand →

Your first campfire is waiting.

Tell us a little about your child and we will save you a seat at the next meeting.

Join Pack & Troop 73
""" return page("Pack & Troop 73 · Family Scouting in Zieglerville, PA", body, "home") @app.get("/cubs", response_class=HTMLResponse) def cubs(): up = next_up({"pack"}, 3) ev_html = next_up_cards(up) or '
Nothing on the books right now. New pack dates land on the shared calendar first, so check back soon or message us.
' body = f""" {hero('circle-game.jpg', 'center 30%', 'Pack 73 · Grades K-5', 'Little kids. Big adventures.', 'Cub Scouts is where it starts: derby cars, first hikes, campfire songs, and a den of friends who cheer each other on.')}
How it works

A den for every grade, a pack for every family.

Scouts meet in small grade-level dens, and the whole pack comes together for the big stuff: derbies, campouts, and award nights. Meetings are Tuesdays at 6:00 PM at St. Luke's, so siblings in different dens land on the same night.

A parent or guardian stays for every Cub Scout meeting and event; it is a do-it-together program. Lions and Tigers work hands-on with their grown-up, while older dens mostly need you in the room (and extra hands are always put to work).

A Lion Cub Scout working on a craft
The trail through Cub Scouts

Six ranks, kindergarten to crossover.

KINDERGARTEN
Lion
Big firsts with a grown-up along for every step.
1ST GRADE
Tiger
Games, field trips, and the first real badges.
2ND GRADE
Wolf
More independence, more time on the trail.
3RD GRADE
Bear
Whittling chips, science, and real outdoor skills.
4TH GRADE
Webelos
Camping like the big kids and leading the little ones.
5TH GRADE
Arrow of Light
The bridge to the troop, crossing over each spring.
A year in the life

The traditions your kid will talk about all week.

Painting pinewood derby cars
Derby season, twice
Boxcar Derby in September, the classic Pinewood Derby in February. Build it, paint it, race it.
Setting up tents at the fall campout
Family campouts
Fall and spring weekends under the stars at Green Lane Park and Camp Garrison, whole families welcome.
Scouts on a monthly pack hike
Monthly pack hikes
A different trailhead almost every month, from the Perkiomen Trail to Green Lane. Strollers welcome.
Scouts with model rockets
Movie nights & more
Rockets, Trunk or Treat, Blue & Gold banquet, Mario Party night, and a kickball party to close the year.
On the calendar

Next up for Pack 73.

{ev_html}
See the full pack calendar →
Keeping it affordable

The famous chocolate booth.

You will find us selling chocolate bars at the Green Lane Farmers Market on Saturday mornings. Fundraisers like the booth keep dues low and help every scout get to camp. Cost should never keep a kid out of Scouting; if it is a stretch, talk to us.

TuesdaysDen and pack meetings, 6:00 PM at St. Luke's
MonthlyWeekend pack hike, usually 1:00 PM
SaturdaysChocolate booth at the Green Lane Farmers Market, 7:00 AM

Come try a Tuesday.

Your first visit is free and easy: just show up and see if it clicks. Most kids know by the end of the night.

Tell us you're coming
""" return page("Cub Scouts · Pack 73 · Zieglerville, PA", body, "cubs") @app.get("/troop", response_class=HTMLResponse) def troop(): up = next_up({"troop"}, 3) ev_html = next_up_cards(up) or '
Nothing on the books right now. New troop dates land on the shared calendar first, so check back soon or message us.
' body = f""" {hero('troop-hero.jpg', 'center 35%', 'Troop 73 · Ages 11-17', 'Youth led. Outdoors first.', 'The troop is where kids run the show: planning trips, leading patrols, and learning the kind of confidence you cannot teach indoors.')}
How it works

The scouts run it. Really.

Troop 73 is organized into patrols that elect their leaders, plan the trips, cook the meals, and fix their own mistakes. Trained, background-checked adults coach from the back row.

We meet Tuesdays at 7:30 PM at St. Luke's, right after the pack, so scouting families land on one night. Every kid ages 11 to 17 is welcome, with or without a Cub Scout past. (Officially we charter as a Scouts BSA troop. Around here, it's just the troop.)

Founded in spring 2026 at St. Luke's, the troop is built by the scouts in it; the traditions are theirs to invent.

Scouts around the campfire circle at dusk
The trail through the troop

Seven ranks, first knot to Eagle.

STOP 1
Scout
The basics, the oath, and the handshake. Everyone starts here.
STOP 2
Tenderfoot
First campout skills: knots, flags, and first aid.
STOP 3
Second Class
Navigation, outdoor cooking, and safe swimming.
STOP 4
First Class
The full outdoor toolkit, ready to lead a patrol.
STOP 5
Star
Leadership roles and merit badges start stacking up.
STOP 6
Life
Leading service projects and mentoring younger scouts.
THE SUMMIT
Eagle
Scouting's highest rank, earned with a big service project of their own.
A year in the life

Weekends worth putting the phone down for.

Scouts relaxing at a campsite
Campouts in every season
Fall, winter, and spring weekends. Scouts plan the menu, pack the trailer, and run the camp.
Scouts whitewater rafting
On the water
Canoe and river trips when the weather warms, including the troop's May river run.
A scout on a climbing wall
Climb, build, navigate
Climbing, orienteering, camp gadgets, and the Klondike winter derby: skills merit badges are made of.
Scouts doing a community service project
Service & Courts of Honor
Projects that make the town better, then a Court of Honor where every rank gets its moment.
On the calendar

Next up for Troop 73.

{ev_html}
See the full troop calendar →
The rhythm

One night a week, one adventure a month.

TuesdaysTroop meeting, 7:30 PM at St. Luke's, right after the pack
Most monthsA weekend campout, trip, or service project
All yearMerit badges and rank work at each scout's own pace

Registration plus modest troop dues; fundraisers keep costs low and gear can be borrowed. Cost never keeps a kid off the trail.

Scouts in uniform at a Court of Honor

No experience needed. Curiosity helps.

Plenty of great scouts never spent a day in Cub Scouts. If your kid is 11 to 17 and likes the idea of a weekend outdoors, they belong here.

Come watch a Tuesday.

The troop takes over at 7:30 PM at St. Luke's. Drop in, meet the patrols, stay if it clicks.

Message the troop
""" return page("The Troop · Troop 73 · Zieglerville, PA", body, "troop") @app.get("/calendar", response_class=HTMLResponse) def calendar(unit: str = "all"): if unit not in ("all", "pack", "troop"): unit = "all" groups = {} for ev in load_events(): u = ev.get("unit", "pack") if unit != "all" and u != unit and u != "both": continue s, e = _dates(ev) key = s.year * 12 + s.month groups.setdefault(key, []).append((s, e, ev)) cards = [] for key in sorted(groups): rows = [] for s, e, ev in sorted(groups[key], key=lambda t: t[0]): label = f"{DWU[s.weekday()]} {s.day}" + (f"-{e.day}" if e != s else "") u = ev.get("unit", "pack") badge = f' {ev["badge"]}' if ev.get("badge") else "" meta = " · ".join(x for x in [ev.get("time"), ev.get("location")] if x) meta = f' · {meta}' if meta else "" rows.append(f'
{label}{ev["title"]} {unit_label(u)}{badge}{meta}
') y, m = divmod(key - 1, 12) cards.append(f'
{MF[m]}{y}
{"".join(rows)}
') sel = {u: " selected" if unit == u else "" for u in ("all", "pack", "troop")} body = f"""
Pack & Troop 73 program year

The 2026-2027 year, at a glance.

The highlights below are open to visiting families. Come to any of them, or just show up on a Tuesday.

Pack Tuesdays · 6:00 PM
Pack or den meetings at St. Luke's Lutheran Church (off on school holidays)
Troop Tuesdays · 7:30 PM
Troop 73 meets right after the pack, same building
About monthly · weekends
Pack hikes at rotating trailheads, usually 1:00 PM
Saturdays · 7:00 AM
Chocolate booth at the Green Lane Farmers Market
{"".join(cards)}
Dates occasionally shift. Registered families get the live TeamSnap calendar with every den and troop meeting; the newest info always wins there.
""" return page("2026-2027 Calendar · Pack & Troop 73", body, "cal") FAQ = [ ("What does it cost?", "Annual registration plus modest pack or troop dues. Fundraisers like our chocolate booth keep costs down, and quiet help is available. Cost never keeps a kid out of Pack 73; just talk to us."), ("Do parents stay at meetings?", "Yes: a parent or guardian stays at every Cub Scout meeting and event. Lions and Tigers pair up with their grown-up for the hands-on parts; for older dens, being in the room is enough. Troop scouts (11 to 17) can be dropped off."), ("Which program fits my kid?", "K through 5th grade join Pack 73's dens. Ages 11 to 17 join Troop 73. Fifth graders get the best of both: Arrow of Light now, crossover to the troop in spring."), ("Can we join mid-year?", "Any Tuesday works. Badges and adventures meet kids where they are, so a January start is just as good as a September one."), ("What about uniforms and gear?", "Start with sneakers and a water bottle. Uniform pieces come gradually, and we keep a stash of hand-me-downs. Nobody needs camping gear to start."), ("Who can join?", "Every kid. Pack 73 and Troop 73 are open to all children and teens, whatever their background or beliefs. If your kid wants to camp, build, and belong, there's a spot for them."), ] SOURCE_JS = """""" def join_body(sent=False): success = '
Thanks! Your note is on its way. A leader will be in touch soon.
' if sent else '' faq_html = "".join(f'
{q}
{a}
' for q, a in FAQ) return f"""
Join Pack & Troop 73

Come see a meeting. That's it.

Tell us a little about your child and a leader will reach out with the right den or troop, what to expect, and directions. No commitment, and your first visit is always free.

1Send the form. It goes straight to our volunteer leaders' phones.
2Hear back within a day or two from a real Pack or Troop 73 parent.
3Visit a Tuesday at St. Luke's: Pack 6:00, Troop 7:30. Sneakers recommended.
Scouts cheering during a pack game
{success}
Interest form
List all kids you're considering. Any grade K-12 welcome.
We only use this to reply about Scouting. No newsletters, no sharing.
{SOURCE_JS}
New-family FAQ

The questions every new family asks.

{faq_html}

See you Tuesday.

St. Luke's Lutheran Church, Zieglerville · Pack 6:00, Troop 7:30. Look for the kids having the most fun in the building.

""" @app.get("/join", response_class=HTMLResponse) def join_get(sent: int = 0): return page("Join us · Pack & Troop 73", join_body(bool(sent))) @app.get("/contact") def contact_redirect(): return RedirectResponse(url="/join", status_code=301) def sheet_append(rec): import gspread from google.oauth2.service_account import Credentials creds = Credentials.from_service_account_file(SA_FILE, scopes=["https://www.googleapis.com/auth/spreadsheets"]) sheet = gspread.authorize(creds).open_by_key(SHEET_ID).sheet1 vals = sheet.get_all_values() if not vals: sheet.append_row(SHEET_HEADERS) elif len(vals[0]) == 7: sheet.update_cell(1, 8, "Comments") # The submission time, not the time of the write. A replay months later must # not relabel a lead as arriving today. stamp = (rec.get("ts") or "")[:16].replace("T", " ") \ or datetime.datetime.now().strftime("%Y-%m-%d %H:%M") sheet.append_row([ stamp, rec["parent_name"], rec["email"], rec["phone"], rec["children"], rec["interested_in"], rec["source"], rec["message"], ]) def pipeline_notify(rec, sheet_error=None): """Push to the shared 'scouting' ntfy topic (JSON endpoint, UTF-8 safe).""" if not NTFY_BASE: return False lines = [ "Email: %s" % rec["email"], "Phone: %s" % (rec["phone"] or "(none given)"), "Kids: %s" % rec["children"], "Wants: %s" % rec["interested_in"], ] if rec["source"]: lines.append("Heard via: %s" % rec["source"]) if rec["message"]: lines.append("Note: %s" % rec["message"]) if sheet_error: lines.append("") lines.append("!! NOT saved to the sheet: %s" % sheet_error) lines.append("!! This push is the only copy - record it manually.") headers = {"Content-Type": "application/json"} if NTFY_TOKEN: headers["Authorization"] = "Bearer " + NTFY_TOKEN try: body = json.dumps({ "topic": NTFY_TOPIC, "title": "New 73 lead - %s" % rec["parent_name"], "message": "\n".join(lines), "tags": ["tent", "inbox_tray"], "priority": 5 if sheet_error else 4, }).encode() rq = urllib.request.Request(NTFY_BASE, data=body, headers=headers) with urllib.request.urlopen(rq, timeout=5) as resp: return resp.status < 400 except Exception: return False @app.post("/join") def join_post(parent_name: str = Form(...), email: str = Form(...), phone: str = Form(""), children: str = Form(""), interested_in: str = Form(""), source: str = Form(""), source_place: str = Form(""), source_other: str = Form(""), message: str = Form("")): heard_from = source.strip() heard_from_detail = "" if source == "Other school or daycare" and source_place.strip(): heard_from_detail = source_place.strip() source = "School/Daycare: " + heard_from_detail elif source == "Other" and source_other.strip(): heard_from_detail = source_other.strip() source = "Other: " + heard_from_detail rec = {"ts": datetime.datetime.now().isoformat(timespec="seconds"), "parent_name": parent_name.strip(), "email": email.strip(), "phone": phone.strip(), "children": children.strip() or "(not given)", "interested_in": interested_in.strip() or "(not given)", "source": source.strip() or "greenlanescouts73.org", "message": message.strip()} try: with open(LEADS, "a", encoding="utf-8") as f: f.write(json.dumps(rec) + "\n") except Exception: pass # Land it internally FIRST. Everything below this point is a mirror of a # row that already exists locally, and each mirror's outcome is recorded # against the record so a failure is replayable instead of just shouted. record_id = None try: record_id = store.insert_lead( parent_name=rec["parent_name"], email=rec["email"], phone=rec["phone"], interested_in=rec["interested_in"], children=rec["children"], heard_from=heard_from, heard_from_detail=heard_from_detail, message=rec["message"], payload=rec, submitted_at=rec["ts"]) except Exception as e: print("STORE WRITE FAILED for %s <%s>: %s" % (rec["parent_name"], rec["email"], e), flush=True) # Register every mirror as pending BEFORE attempting it. If the process dies # or a call never runs, the gap is visible and replayable instead of the row # simply having no mirror record at all. if record_id: for _t in store.TARGETS: store.set_mirror_pending(record_id, _t) sheet_error = None try: sheet_append(rec) except Exception as e: sheet_error = str(e) print("SHEET WRITE FAILED for %s <%s>: %s" % (rec["parent_name"], rec["email"], e), flush=True) if record_id: store.set_mirror(record_id, "google_sheet", sheet_error is None, sheet_error) delivered = pipeline_notify(rec, sheet_error) if record_id: store.set_mirror(record_id, "ntfy", bool(delivered), None if delivered else "ntfy push not delivered") if sheet_error and not delivered and NTFY_URL: # last-ditch push on the fallback topic so a lead is never silently lost try: b = ("LEAD (sheet+push failed): %s (%s) | Kids: %s | %s | %s" % ( rec["parent_name"], rec["email"], rec["children"], rec["interested_in"], rec["source"])).encode() rq = urllib.request.Request(NTFY_URL, data=b, headers={"Title": "greenlanescouts73.org lead", "Tags": "tent,warning"}) urllib.request.urlopen(rq, timeout=3) except Exception: pass return RedirectResponse(url="/join?sent=1", status_code=303)