Announcements: a site-wide notice with a start and an end

The one string on this site where a deploy is the wrong latency is
"tonight's meeting is cancelled, the lot is flooded" at 4pm on a
Tuesday. That is a record with a lifecycle, not site copy, so it gets
a table and a write path rather than a commit.

store.py
  announcements table, created by the existing IF NOT EXISTS path so
  there is no migration. ends_at is REQUIRED: an announcement that
  never expires is site copy, and site copy belongs in the repo where
  it has a diff. Nothing is hard-deleted; taking one down early sets
  revoked_at, so what the site said and when survives.

  Ranking is urgent first, then most recent. Recency alone would let a
  routine Wednesday notice bury a Tuesday cancellation still live.

  Two caps, enforced here rather than in the route so the future panel
  inherits them: 200 characters, and 3 live at once. Both reject rather
  than truncate. Clipping a cancellation mid-sentence is worse than
  making someone shorten it, and a 4th live notice is a signal nobody
  is expiring things rather than something to render.

app.py
  announcement_bar() above the sticky nav. Native <details>, no
  JavaScript, which matters on a read-only rootfs with no build step.
  Collapsed clamps to one line with a count; expanded lists all of
  them and caps at 40vh. One notice renders with no chevron and no
  count: the common case must not look like a widget.

  FAILS OPEN. The admin API fails closed because it serves family
  phone numbers. This is the opposite case, and a broken announcement
  must never take down the public homepage.

  Colours are the existing note and rust token pairs from the brand
  standard. No new colour enters the palette.

admin_api.py
  GET/POST/DELETE on /api/admin/announcements. Leads stay read-only;
  announcements are the deliberate exception, because being mutable
  and expiring is the entire feature rather than a guess at a process.
  list returns a computed state per row (live, scheduled, expired,
  revoked, over_cap) so "why is my notice not showing" is answerable
  from the API and not from the homepage.
This commit is contained in:
claude
2026-09-01 19:22:04 -04:00
parent 985253422d
commit e02125392a
3 changed files with 344 additions and 6 deletions
+98 -1
View File
@@ -1,4 +1,4 @@
import json, os, re, time, datetime, urllib.request, urllib.parse
import html, json, os, re, time, datetime, urllib.request, urllib.parse
from pathlib import Path
from fastapi import FastAPI, Form, Request
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
@@ -268,8 +268,104 @@ footer a.fb{display:inline-flex;align-items:center;gap:8px}
.docd{display:block;margin-top:3px;color:#6A7280;font-size:14px}
.docmeta{font-size:12.5px;font-weight:600;color:#8B93A3;white-space:nowrap}
@media(max-width:560px){.docrow{flex-wrap:wrap}.docmeta{width:100%;padding-left:74px}}
.annc{border-bottom:1px solid;font-family:'Public Sans',system-ui,sans-serif}
.annc-info{background:#F3EBD8;border-color:#E8DCBB;color:#6B5510}
.annc-urgent{background:#F6E3DA;border-color:#DFB9A4;color:#8A3B1D}
.anncrow{display:flex;align-items:center;gap:9px;padding:11px 0}
.annc summary{cursor:pointer;list-style:none;-webkit-tap-highlight-color:transparent}
.annc summary::-webkit-details-marker{display:none}
.annc summary:focus-visible{outline:2px solid currentColor;outline-offset:-3px}
.anncico,.anncchev{flex:none;display:flex;align-items:center}
.anncchev{transition:transform .15s ease}
.annc[open] .anncchev{transform:rotate(180deg)}
.anncline{flex:1;min-width:0;font-size:14px;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.anncopen{display:none}
.annc[open] .ancctop{display:none}
.annc[open] .anncopen{display:block;font-weight:600}
.annccount{flex:none;font-size:11px;font-weight:700;padding:1px 7px;border-radius:999px;border:1px solid currentColor}
.annc[open] .annccount{display:none}
.anncbody{max-height:40vh;overflow-y:auto;padding-bottom:12px}
.anncitem{padding:10px 0 0;font-size:14px;line-height:1.5;overflow-wrap:anywhere}
.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}
"""
ICO_INFO = ('<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor"'
' stroke-width="2" stroke-linecap="round" aria-hidden="true">'
'<circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 7.6v.01"/></svg>')
ICO_URGENT = ('<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor"'
' stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
'<path d="M10.3 4.3 2.6 17.6a2 2 0 0 0 1.7 3h15.4a2 2 0 0 0 1.7-3L13.7 4.3a2 2 0 0 0-3.4 0Z"/>'
'<path d="M12 9.5v4M12 17.2v.01"/></svg>')
ICO_CHEV = ('<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor"'
' stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
'<path d="m6 9 6 6 6-6"/></svg>')
def _annc_item(a):
"""One announcement in the expanded list. Text is escaped; the link is
rendered only from a url store.create_announcement already validated."""
out = '<div class="anncitem">%s' % html.escape(a["message"])
if a.get("link_url"):
out += '<br><a href="%s">%s</a>' % (
html.escape(a["link_url"], quote=True),
html.escape(a.get("link_text") or "More details"))
return out + "</div>"
def announcement_bar():
"""Render the live announcements, or nothing at all.
FAILS OPEN, deliberately. The admin API fails closed because it serves
family phone numbers; this is the opposite case. A broken announcement
must never take down the public homepage, so any error here renders an
empty string and logs.
Collapsed shows the top-ranked notice on one clamped line plus a count.
Expanded lists all of them. Native <details>, so there is no JavaScript
and nothing to load - which matters on a read-only rootfs with no build
step. The single-notice case, which is nearly always the case, renders
with no chevron and no count: it must not look like a widget.
"""
try:
items = store.active_announcements()
except Exception as e:
print("announcement_bar: %s" % e, flush=True)
return ""
if not items:
return ""
top = items[0]
level = "urgent" if any(i["level"] == "urgent" for i in items) else "info"
ico = ICO_URGENT if level == "urgent" else ICO_INFO
label = "Urgent notice" if level == "urgent" else "Notice"
if len(items) == 1:
link = ""
if top.get("link_url"):
link = ('<a class="ancclink" href="%s">%s</a>' % (
html.escape(top["link_url"], quote=True),
html.escape(top.get("link_text") or "More details")))
return ('<div class="annc annc-%s" role="region" aria-label="%s"><div class="wrap anncrow">'
'<span class="anncico">%s</span>'
'<span class="anncline">%s</span>%s'
'</div></div>' % (level, label, ico, html.escape(top["message"]), link))
body = "".join(_annc_item(i) for i in items)
return ('<details class="annc annc-%s"><summary><div class="wrap anncrow">'
'<span class="anncico">%s</span>'
'<span class="anncline ancctop">%s</span>'
'<span class="anncline anncopen">%d notices</span>'
'<span class="annccount">+%d</span>'
'<span class="anncchev">%s</span>'
'</div></summary><div class="wrap anncbody">%s</div></details>'
% (level, ico, html.escape(top["message"]), len(items),
len(items) - 1, ICO_CHEV, body))
def page(title, body, active=""):
def on(k): return ' class="on"' if active == k else ""
return f"""<!doctype html>
@@ -290,6 +386,7 @@ def page(title, body, active=""):
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;600;700;800;900&family=Public+Sans:wght@400;600;700&display=swap" rel="stylesheet">
<style>{CSS}</style>
</head><body style="display:flex;flex-direction:column;min-height:100vh">
{announcement_bar()}
<header class="nav"><div class="wrap navrow">
<a class="brand" href="/"><img src="/static/img/patch-73.png" alt="Pack and Troop 73 patch"><span><span class="b1">PACK &amp; TROOP 73</span><span class="b2">Zieglerville, PA · Cradle of Liberty Council</span></span></a>
<nav class="links">