diff --git a/app/app.py b/app/app.py index 1fbf8e7..bb6ce52 100644 --- a/app/app.py +++ b/app/app.py @@ -8,6 +8,7 @@ from fastapi.staticfiles import StaticFiles # anything a family submits; see store.py for why. import store import admin_api +import nearby import documents # --------------------------------------------------------------------------- @@ -317,6 +318,18 @@ footer a.fb{display:inline-flex;align-items:center;gap:8px} .anncitem+.anncitem{border-top:1px solid rgba(0,0,0,.09);padding-top:10px;margin-top:10px} .anncitem a{color:inherit;text-decoration:underline;font-size:13px} .ancclink{flex:none;color:inherit;text-decoration:underline;font-size:13px;white-space:nowrap} +.nu-group{margin-bottom:44px} +.nu-area{font-size:1.45rem;margin-bottom:14px} +.nu-card{padding:22px 24px;display:flex;flex-direction:column;gap:8px} +.nu-head{display:flex;flex-wrap:wrap;align-items:center;gap:10px} +.nu-name{font-family:'Archivo';font-weight:800;font-size:1.15rem;color:#1E2F52;text-decoration:none;border-bottom:2px solid #F0AD2D} +.nu-name:hover{color:#B07B12} +.nu-plain{border-bottom:none;color:#1E2F52} +.nu-chips{display:flex;flex-wrap:wrap;gap:6px} +.nu-where{color:#5A6272;font-size:14.5px;line-height:1.6} +.nu-meets{font-weight:700;color:#1E2F52;font-size:14.5px} +.nu-note{color:#6A7280;font-size:13.5px;line-height:1.6} +.nu-mail{color:#B07B12;font-weight:600;text-decoration:none} """ ICO_INFO = (' the badge a parent actually recognises. +TYPE_LABEL = { + "pack": "Cub Scouts", + "troop": "Scouts BSA", + "ship": "Sea Scouts", + "club": "Exploring", +} + +# The unit's own words for who it serves. "family" is the common case and gets +# the warmer chip; a single-gender unit is stated plainly rather than softened, +# because that is the thing a parent is scanning for. +SERVES_LABEL = { + "family": "Boys & girls", + "boys": "Boys", + "girls": "Girls", + "coed": "Co-ed", +} + +BEASCOUT = "https://beascout.scouting.org" + + +def listing(): + """Rows grouped by area, in sort_order. Empty list if the table is unusable.""" + try: + con = store.connect() + except Exception: + return [] + try: + rows = con.execute( + "SELECT * FROM nearby_units WHERE active = 1 " + "ORDER BY sort_order, unit_number" + ).fetchall() + except Exception: + return [] + finally: + con.close() + + groups, order = {}, [] + for r in rows: + area = r["area"] or "Elsewhere in the district" + if area not in groups: + groups[area] = [] + order.append(area) + groups[area].append(dict(r)) + return [(a, groups[a]) for a in order] + + +def checked_on(): + """The most recent verified_at across live rows, for the honesty line.""" + try: + con = store.connect() + except Exception: + return None + try: + row = con.execute( + "SELECT MAX(verified_at) AS v FROM nearby_units WHERE active = 1" + ).fetchone() + return row["v"] if row else None + except Exception: + return None + finally: + con.close() + + +def _esc(s): + return (str(s).replace("&", "&").replace("<", "<").replace(">", ">") + if s else "") + + +def unit_name(u): + n = _esc(u["unit_number"]) + kind = {"pack": "Pack", "troop": "Troop", "ship": "Ship", "club": "Club"}.get( + u["unit_type"], "Unit") + return f"{kind} {n}" + + +def card(u): + """One unit. + + The link sits on the unit's name, never printed raw. Most of these are + tinyurls and a wall of them reads like a spam folder. + """ + name = unit_name(u) + if u["link_url"]: + title = (f'{name}') + else: + title = f'{name}' + + chips = [f'{TYPE_LABEL.get(u["unit_type"], "Scouting")}'] + if u["serves"] and u["serves"] in SERVES_LABEL: + tone = "gold" if u["serves"] == "family" else "blue" + chips.append(f'{SERVES_LABEL[u["serves"]]}') + + lines = [] + if u["chartered_org"]: + lines.append(_esc(u["chartered_org"])) + where = ", ".join(x for x in [_esc(u["street"]), _esc(u["town"])] if x) + if where: + lines.append(where) + body = "
".join(lines) + + meets = (f'
{_esc(u["meets"])}
' + if u["meets"] else "") + notes = f'
{_esc(u["notes"])}
' if u["notes"] else "" + contact = "" + if u["contact"]: + c = _esc(u["contact"]) + contact = (f'
Contact {c}
') + + return (f'
' + f'
{title}{"".join(chips)}
' + f'
{body}
{meets}{notes}{contact}
') + + +def section(area, units): + cards = "".join(card(u) for u in units) + return (f'
' + f'

{_esc(area)}

' + f'
{cards}
') + + +def body_html(hero_html): + """The whole page body, hero included, ready to hand to page().""" + groups = listing() + if groups: + inner = "".join(section(a, u) for a, u in groups) + else: + inner = ('
This list is being rebuilt. ' + f'BeAScout.org ' + 'has every unit in the area.
') + + checked = checked_on() + stamp = (f'We checked these details on {_esc(checked)}.' if checked + else 'These details come from the district unit list.') + + return f"""{hero_html} +
+

Scouting works best close to home. If we are the wrong side of the county, or +Tuesday nights are already spoken for, one of these units may fit your family better. Every one +of them welcomes visitors, and none of them mind being asked.

+
+
{inner}
+
+Before you drive out, call ahead. {stamp} Meeting nights and locations change, +and this list is a courtesy rather than an official record. The unit's own page, linked from its +name above, is always more current than we are. +
+
+

Looking somewhere we haven't listed?

+

BeAScout is Scouting America's official lookup. Put in your zip code and it will show you +every pack, troop, ship and post near you, with current contact details for each one.

+Search BeAScout.org +
"""